mirror of
https://github.com/feder-cr/invisible_playwright.git
synced 2026-06-07 08:35:12 +02:00
Merge pull request #2 from christianbaumann/main
Add comprehensive test suite (270+ tests across 14 files) - Bayesian network primitives (25 tests) - fingerprint sampler (55 tests) - Profile dataclass + pin system (43 tests) - configure_proxy decision table (24 tests) - Windows + platform-agnostic prefs (27 tests) - CLI / download gap coverage (17 tests) - Multi-module integration (12 tests) - E2e launcher lifecycle (9 tests) - launcher / headless / async_api (32 tests) - Linux-specific prefs / headless (16 tests) - Linux tar.gz download (4 tests) - Linux integration pipeline (3 tests) - Linux launcher e2e (4 tests) - pytest markers + shared conftest Resolves conflicts in pyproject.toml, test_prefs.py, test_proxy.py by keeping the upstream additions and merging with the previous wheel-build fix. 323 tests pass on Windows; 14 e2e tests deselected by default (require patched Firefox binary).
This commit is contained in:
commit
07701a901d
15 changed files with 3191 additions and 94 deletions
102
pyproject.toml
102
pyproject.toml
|
|
@ -1,48 +1,54 @@
|
|||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "invisible-playwright"
|
||||
version = "0.1.0"
|
||||
description = "Playwright wrapper for a patched Firefox with deterministic stealth profile."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = "MIT"
|
||||
authors = [{ name = "feder-cr", email = "85809106+feder-cr@users.noreply.github.com" }]
|
||||
keywords = ["playwright", "firefox", "stealth", "anti-detect", "automation"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
dependencies = [
|
||||
"playwright>=1.40",
|
||||
"platformdirs>=4",
|
||||
"requests>=2.31",
|
||||
"tqdm>=4.66",
|
||||
"pywin32>=306; sys_platform == 'win32'",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=7", "pytest-mock>=3", "responses>=0.24", "build>=1"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
markers = ["slow: tests that build the wheel — opt-in only"]
|
||||
addopts = "-m 'not slow'"
|
||||
|
||||
[project.scripts]
|
||||
invisible-playwright = "invisible_playwright.cli:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/feder-cr/invisible_playwright"
|
||||
Issues = "https://github.com/feder-cr/invisible_playwright/issues"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/invisible_playwright"]
|
||||
|
||||
[tool.hatch.build.targets.sdist]
|
||||
include = ["src/invisible_playwright", "tests", "README.md", "LICENSE", "pyproject.toml"]
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "invisible-playwright"
|
||||
version = "0.1.0"
|
||||
description = "Playwright wrapper for a patched Firefox with deterministic stealth profile."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = "MIT"
|
||||
authors = [{ name = "feder-cr", email = "85809106+feder-cr@users.noreply.github.com" }]
|
||||
keywords = ["playwright", "firefox", "stealth", "anti-detect", "automation"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
dependencies = [
|
||||
"playwright>=1.40",
|
||||
"platformdirs>=4",
|
||||
"requests>=2.31",
|
||||
"tqdm>=4.66",
|
||||
"pywin32>=306; sys_platform == 'win32'",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=7", "pytest-mock>=3", "responses>=0.24", "build>=1"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
markers = [
|
||||
"unit: pure-logic tests, no I/O or external deps",
|
||||
"integration: multi-module tests, no browser",
|
||||
"e2e: requires patched Firefox binary and display",
|
||||
"slow: tests that build the wheel — opt-in only",
|
||||
"linux_only: tests that require Linux platform",
|
||||
]
|
||||
addopts = "-m 'not slow and not e2e'"
|
||||
|
||||
[project.scripts]
|
||||
invisible-playwright = "invisible_playwright.cli:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/feder-cr/invisible_playwright"
|
||||
Issues = "https://github.com/feder-cr/invisible_playwright/issues"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/invisible_playwright"]
|
||||
|
||||
[tool.hatch.build.targets.sdist]
|
||||
include = ["src/invisible_playwright", "tests", "README.md", "LICENSE", "pyproject.toml"]
|
||||
|
|
|
|||
17
tests/conftest.py
Normal file
17
tests/conftest.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from invisible_playwright._fpforge import generate_profile
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def deterministic_rng():
|
||||
"""Seeded RNG for reproducible tests."""
|
||||
return random.Random(42)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_profile():
|
||||
"""A Profile generated from seed=42 for reuse across tests."""
|
||||
return generate_profile(seed=42)
|
||||
83
tests/test_async_api.py
Normal file
83
tests/test_async_api.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""Constructor-parity tests for the async ``InvisiblePlaywright``.
|
||||
|
||||
The async API mirrors the sync launcher (same prefs pipeline, same
|
||||
profile generation, same proxy handling). The only async-specific
|
||||
surface is ``__aenter__`` / ``__aexit__`` and an awaitable ``new_page``
|
||||
patch — both require a real Firefox binary to exercise meaningfully and
|
||||
are covered by the sync E2E tests via parity arguments.
|
||||
|
||||
What we test here without launching a browser: the constructor builds
|
||||
the same eager Profile, clamps the seed identically, and surfaces pin
|
||||
validation errors at construction time. These guards keep the async
|
||||
class from silently drifting away from the sync class as features land.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from invisible_playwright.async_api import InvisiblePlaywright as AsyncIP
|
||||
from invisible_playwright.launcher import InvisiblePlaywright as SyncIP
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_async_explicit_seed_is_stored():
|
||||
ip = AsyncIP(seed=42)
|
||||
assert ip.seed == 42
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_async_random_seed_is_positive_int31():
|
||||
"""Same int31 contract as sync: the C++ side rejects ``seed <= 0`` and
|
||||
a 32-bit value risks the high bit looking negative."""
|
||||
ip = AsyncIP()
|
||||
assert isinstance(ip.seed, int)
|
||||
assert 0 < ip.seed < 2**31
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_async_random_seed_varies_across_instances():
|
||||
seeds = {AsyncIP().seed for _ in range(5)}
|
||||
assert len(seeds) > 1
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_async_profile_built_eagerly_in_constructor():
|
||||
"""Pin validation must fire before ``__aenter__`` — otherwise a user
|
||||
only learns their pin is wrong when the browser launch starts."""
|
||||
ip = AsyncIP(seed=42)
|
||||
assert ip._profile is not None
|
||||
assert ip._profile.seed == 42
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_async_invalid_pin_raises_in_constructor():
|
||||
with pytest.raises(ValueError):
|
||||
AsyncIP(seed=42, pin={"not_a_real_field": 1})
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_async_and_sync_share_seed_for_same_input():
|
||||
"""Same seed → identical Profile across the two APIs. Both lean on
|
||||
``generate_profile(seed)``; if they diverge it means one of them
|
||||
started doing extra sampling."""
|
||||
seed = 12345
|
||||
a = AsyncIP(seed=seed)
|
||||
s = SyncIP(seed=seed)
|
||||
assert a._profile == s._profile
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_async_seed_coerced_from_float():
|
||||
"""``int(seed)`` truncation — matches sync clamping behaviour."""
|
||||
ip = AsyncIP(seed=42.9)
|
||||
assert ip.seed == 42
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_async_default_context_kwargs_match_sync():
|
||||
"""The two ``_default_context_kwargs`` implementations must produce
|
||||
the same dict for the same inputs. Guards against the async copy
|
||||
drifting away when sync adds new keys."""
|
||||
a = AsyncIP(seed=42, timezone="America/New_York", locale="de-DE")
|
||||
s = SyncIP(seed=42, timezone="America/New_York", locale="de-DE")
|
||||
assert a._default_context_kwargs() == s._default_context_kwargs()
|
||||
|
|
@ -1,7 +1,13 @@
|
|||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from invisible_playwright import cli
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_version_subcommand():
|
||||
r = subprocess.run(
|
||||
[sys.executable, "-m", "invisible_playwright", "version"],
|
||||
|
|
@ -11,6 +17,7 @@ def test_version_subcommand():
|
|||
assert "invisible_playwright" in r.stdout.lower()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_help_subcommand():
|
||||
r = subprocess.run(
|
||||
[sys.executable, "-m", "invisible_playwright", "--help"],
|
||||
|
|
@ -20,3 +27,96 @@ def test_help_subcommand():
|
|||
assert "fetch" in r.stdout
|
||||
assert "path" in r.stdout
|
||||
assert "clear-cache" in r.stdout
|
||||
|
||||
|
||||
# CL1: clear-cache with existing cache prints "removed:" + path
|
||||
@pytest.mark.unit
|
||||
def test_clear_cache_with_existing_cache(tmp_path, monkeypatch, capsys):
|
||||
cache = tmp_path / "existing-cache"
|
||||
cache.mkdir()
|
||||
(cache / "marker").write_text("x")
|
||||
monkeypatch.setattr("invisible_playwright.cli.cache_root", lambda: cache)
|
||||
|
||||
rc = cli.main(["clear-cache"])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert rc == 0
|
||||
assert captured.out.startswith("removed:")
|
||||
assert str(cache) in captured.out
|
||||
assert not cache.exists()
|
||||
|
||||
|
||||
# CL2: clear-cache with no cache prints "nothing to remove:"
|
||||
@pytest.mark.unit
|
||||
def test_clear_cache_with_no_cache(tmp_path, monkeypatch, capsys):
|
||||
cache = tmp_path / "missing-cache"
|
||||
assert not cache.exists()
|
||||
monkeypatch.setattr("invisible_playwright.cli.cache_root", lambda: cache)
|
||||
|
||||
rc = cli.main(["clear-cache"])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert rc == 0
|
||||
assert captured.out.startswith("nothing to remove:")
|
||||
assert str(cache) in captured.out
|
||||
|
||||
|
||||
# CL3: path when binary exists prints path, exit 0
|
||||
@pytest.mark.unit
|
||||
def test_path_subcommand_when_binary_exists(tmp_path, monkeypatch, capsys):
|
||||
fake_binary = tmp_path / "firefox.exe"
|
||||
fake_binary.write_text("x")
|
||||
monkeypatch.setattr("invisible_playwright.cli.ensure_binary", lambda: fake_binary)
|
||||
|
||||
rc = cli.main(["path"])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert rc == 0
|
||||
assert str(fake_binary) in captured.out
|
||||
assert captured.err == ""
|
||||
|
||||
|
||||
# CL4: path when binary missing prints to stderr, exit 1
|
||||
@pytest.mark.unit
|
||||
def test_path_subcommand_when_binary_missing(monkeypatch, capsys):
|
||||
def boom():
|
||||
raise RuntimeError("download failed")
|
||||
monkeypatch.setattr("invisible_playwright.cli.ensure_binary", boom)
|
||||
|
||||
rc = cli.main(["path"])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert rc == 1
|
||||
assert "error:" in captured.err
|
||||
assert "download failed" in captured.err
|
||||
assert captured.out == ""
|
||||
|
||||
|
||||
# CL5: no subcommand → argparse error, exit != 0
|
||||
@pytest.mark.unit
|
||||
def test_no_subcommand_errors():
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
cli.main([])
|
||||
assert exc_info.value.code != 0
|
||||
|
||||
|
||||
# CL6: unknown subcommand → argparse error
|
||||
@pytest.mark.unit
|
||||
def test_unknown_subcommand_errors():
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
cli.main(["bogus"])
|
||||
assert exc_info.value.code != 0
|
||||
|
||||
|
||||
# Extra: fetch happy path with mocked ensure_binary
|
||||
@pytest.mark.unit
|
||||
def test_fetch_subcommand_prints_path(tmp_path, monkeypatch, capsys):
|
||||
fake_binary = tmp_path / "firefox.exe"
|
||||
fake_binary.write_text("x")
|
||||
monkeypatch.setattr("invisible_playwright.cli.ensure_binary", lambda: fake_binary)
|
||||
|
||||
rc = cli.main(["fetch"])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert rc == 0
|
||||
assert str(fake_binary) in captured.out
|
||||
|
|
|
|||
|
|
@ -1,29 +1,35 @@
|
|||
from invisible_playwright.constants import BINARY_VERSION, BINARY_BASENAME, ARCHIVE_NAME
|
||||
import pytest
|
||||
|
||||
from invisible_playwright.constants import ARCHIVE_NAME, BINARY_BASENAME, BINARY_VERSION
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_binary_version_format():
|
||||
assert BINARY_VERSION.startswith("firefox-")
|
||||
assert BINARY_VERSION.split("-", 1)[1].isdigit()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_archive_name_windows():
|
||||
name = ARCHIVE_NAME("win32", "AMD64")
|
||||
assert name.endswith(".zip")
|
||||
assert "win-x86_64" in name
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_archive_name_linux():
|
||||
name = ARCHIVE_NAME("linux", "x86_64")
|
||||
assert name.endswith(".tar.gz")
|
||||
assert "linux-x86_64" in name
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_archive_name_unsupported_raises():
|
||||
import pytest
|
||||
with pytest.raises(NotImplementedError):
|
||||
ARCHIVE_NAME("darwin", "arm64")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_binary_basename_format():
|
||||
assert "firefox" in BINARY_BASENAME.lower()
|
||||
assert "stealth" in BINARY_BASENAME.lower()
|
||||
|
|
|
|||
|
|
@ -1,15 +1,23 @@
|
|||
import hashlib
|
||||
import io
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
|
||||
from invisible_playwright.download import ensure_binary
|
||||
from invisible_playwright.constants import BINARY_VERSION
|
||||
from invisible_playwright.download import (
|
||||
_extract,
|
||||
_github_token,
|
||||
_parse_checksums,
|
||||
_parse_owner_repo,
|
||||
_sha256_file,
|
||||
ensure_binary,
|
||||
)
|
||||
|
||||
|
||||
def _make_zip(path: Path, inner_name: str, payload: bytes) -> bytes:
|
||||
import io
|
||||
import zipfile
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
|
|
@ -19,6 +27,18 @@ def _make_zip(path: Path, inner_name: str, payload: bytes) -> bytes:
|
|||
return data
|
||||
|
||||
|
||||
def _make_targz(path: Path, inner_name: str, payload: bytes) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
|
||||
info = tarfile.TarInfo(name=inner_name)
|
||||
info.size = len(payload)
|
||||
tf.addfile(info, io.BytesIO(payload))
|
||||
data = buf.getvalue()
|
||||
path.write_bytes(data)
|
||||
return data
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@responses.activate
|
||||
def test_ensure_binary_downloads_and_verifies(tmp_path, monkeypatch):
|
||||
"""Full path: cache miss -> HTTP GET -> SHA256 check -> extract -> return path."""
|
||||
|
|
@ -48,6 +68,7 @@ def test_ensure_binary_downloads_and_verifies(tmp_path, monkeypatch):
|
|||
assert Path(path).name == "firefox.exe"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@responses.activate
|
||||
def test_ensure_binary_rejects_sha_mismatch(tmp_path, monkeypatch):
|
||||
cache = tmp_path / "cache"
|
||||
|
|
@ -69,3 +90,288 @@ def test_ensure_binary_rejects_sha_mismatch(tmp_path, monkeypatch):
|
|||
|
||||
with pytest.raises(RuntimeError, match="SHA256"):
|
||||
ensure_binary()
|
||||
|
||||
|
||||
# DL1: cache hit returns cached path without HTTP call
|
||||
@pytest.mark.unit
|
||||
def test_ensure_binary_cache_hit_skips_http(tmp_path, monkeypatch):
|
||||
"""When the binary already exists in cache, ensure_binary returns immediately
|
||||
without issuing any HTTP request."""
|
||||
cache = tmp_path / "cache"
|
||||
version_dir = cache / BINARY_VERSION
|
||||
version_dir.mkdir(parents=True)
|
||||
pre_cached = version_dir / "firefox.exe"
|
||||
pre_cached.write_text("cached-content")
|
||||
|
||||
monkeypatch.setattr("invisible_playwright.download.cache_root", lambda: cache)
|
||||
monkeypatch.setattr("sys.platform", "win32")
|
||||
import platform
|
||||
monkeypatch.setattr(platform, "machine", lambda: "AMD64")
|
||||
|
||||
def _fail_get(*args, **kwargs):
|
||||
raise AssertionError("HTTP must not be called on cache hit")
|
||||
monkeypatch.setattr("invisible_playwright.download.requests.get", _fail_get)
|
||||
|
||||
path = ensure_binary()
|
||||
assert path == pre_cached
|
||||
assert path.read_text() == "cached-content"
|
||||
|
||||
|
||||
# DL2: .tar.gz extraction works
|
||||
@pytest.mark.unit
|
||||
def test_extract_tar_gz(tmp_path):
|
||||
"""_extract handles .tar.gz archives and unpacks the inner files."""
|
||||
archive = tmp_path / "bundle.tar.gz"
|
||||
_make_targz(archive, "firefox", b"ELF!")
|
||||
dst = tmp_path / "out"
|
||||
|
||||
_extract(archive, dst)
|
||||
|
||||
assert (dst / "firefox").exists()
|
||||
assert (dst / "firefox").read_bytes() == b"ELF!"
|
||||
|
||||
|
||||
# DL3: checksum line with comment (#) is skipped
|
||||
@pytest.mark.unit
|
||||
def test_parse_checksums_skips_comments_and_blanks():
|
||||
text = (
|
||||
"# this is a comment\n"
|
||||
"\n"
|
||||
" # indented comment\n"
|
||||
"abc123 file1.zip\n"
|
||||
"def456 file2.tar.gz\n"
|
||||
)
|
||||
out = _parse_checksums(text)
|
||||
assert out == {"file1.zip": "abc123", "file2.tar.gz": "def456"}
|
||||
|
||||
|
||||
# DL3 sibling: malformed lines (fewer than 2 fields) are silently ignored
|
||||
@pytest.mark.unit
|
||||
def test_parse_checksums_ignores_single_field_lines():
|
||||
text = "loner\nabc123 file.zip\n"
|
||||
out = _parse_checksums(text)
|
||||
assert out == {"file.zip": "abc123"}
|
||||
|
||||
|
||||
# DL3 sibling: last field is treated as filename (supports trailing whitespace tokens)
|
||||
@pytest.mark.unit
|
||||
def test_parse_checksums_uses_last_token_as_filename():
|
||||
text = "abc123 some/nested/file.zip\n"
|
||||
out = _parse_checksums(text)
|
||||
assert "some/nested/file.zip" in out
|
||||
|
||||
|
||||
# DL4: unknown archive format (.rar) raises RuntimeError
|
||||
@pytest.mark.unit
|
||||
def test_extract_unknown_format_raises(tmp_path):
|
||||
archive = tmp_path / "thing.rar"
|
||||
archive.write_bytes(b"not-a-real-rar")
|
||||
dst = tmp_path / "out"
|
||||
|
||||
with pytest.raises(RuntimeError, match="unknown archive format"):
|
||||
_extract(archive, dst)
|
||||
|
||||
|
||||
# DL5: binary not found after extraction raises RuntimeError
|
||||
@pytest.mark.unit
|
||||
@responses.activate
|
||||
def test_ensure_binary_missing_entry_after_extract_raises(tmp_path, monkeypatch):
|
||||
"""If the archive extracts cleanly but the expected entry isn't present,
|
||||
ensure_binary raises RuntimeError."""
|
||||
cache = tmp_path / "cache"
|
||||
monkeypatch.setattr("invisible_playwright.download.cache_root", lambda: cache)
|
||||
|
||||
archive_path = tmp_path / "archive.zip"
|
||||
# zip without firefox.exe inside
|
||||
archive_bytes = _make_zip(archive_path, "other.bin", b"X")
|
||||
archive_sha = hashlib.sha256(archive_bytes).hexdigest()
|
||||
from invisible_playwright.constants import ARCHIVE_NAME
|
||||
asset = ARCHIVE_NAME("win32", "AMD64")
|
||||
|
||||
url_archive = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/{asset}"
|
||||
url_sums = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/checksums.txt"
|
||||
|
||||
responses.add(responses.GET, url_archive, body=archive_bytes, status=200)
|
||||
responses.add(responses.GET, url_sums, body=f"{archive_sha} {asset}\n", status=200)
|
||||
|
||||
monkeypatch.setattr("sys.platform", "win32")
|
||||
import platform
|
||||
monkeypatch.setattr(platform, "machine", lambda: "AMD64")
|
||||
|
||||
with pytest.raises(RuntimeError, match="binary not found after extraction"):
|
||||
ensure_binary()
|
||||
|
||||
|
||||
# Pure helper: _parse_owner_repo
|
||||
@pytest.mark.unit
|
||||
def test_parse_owner_repo_valid():
|
||||
owner, repo = _parse_owner_repo(
|
||||
"https://github.com/feder-cr/invisible_playwright/releases/download/x/y"
|
||||
)
|
||||
assert owner == "feder-cr"
|
||||
assert repo == "invisible_playwright"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_parse_owner_repo_invalid_raises():
|
||||
with pytest.raises(RuntimeError, match="cannot parse owner/repo"):
|
||||
_parse_owner_repo("not-a-github-url")
|
||||
|
||||
|
||||
# Pure helper: _sha256_file matches hashlib output
|
||||
@pytest.mark.unit
|
||||
def test_sha256_file_matches_hashlib(tmp_path):
|
||||
payload = b"hello world"
|
||||
f = tmp_path / "file.bin"
|
||||
f.write_bytes(payload)
|
||||
expected = hashlib.sha256(payload).hexdigest()
|
||||
assert _sha256_file(f) == expected
|
||||
|
||||
|
||||
# _github_token precedence: STEALTHFOX_GITHUB_TOKEN beats GITHUB_TOKEN
|
||||
@pytest.mark.unit
|
||||
def test_github_token_stealthfox_wins(monkeypatch):
|
||||
monkeypatch.setenv("STEALTHFOX_GITHUB_TOKEN", "stealth")
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "generic")
|
||||
assert _github_token() == "stealth"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_github_token_falls_back_to_github_token(monkeypatch):
|
||||
monkeypatch.delenv("STEALTHFOX_GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "generic")
|
||||
assert _github_token() == "generic"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_github_token_none_when_unset(monkeypatch):
|
||||
monkeypatch.delenv("STEALTHFOX_GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
assert _github_token() is None
|
||||
|
||||
|
||||
# Bonus coverage: unsupported platform raises NotImplementedError before any HTTP
|
||||
@pytest.mark.unit
|
||||
def test_ensure_binary_unsupported_platform_raises(monkeypatch):
|
||||
monkeypatch.setattr("sys.platform", "darwin")
|
||||
import platform
|
||||
monkeypatch.setattr(platform, "machine", lambda: "AMD64")
|
||||
with pytest.raises(NotImplementedError, match="unsupported platform"):
|
||||
ensure_binary()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Linux platform tests — exercise the tar.gz extraction path. Mirrors
|
||||
# the Windows .zip tests above so both archive formats are covered.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@responses.activate
|
||||
def test_ensure_binary_downloads_and_verifies_linux(tmp_path, monkeypatch):
|
||||
"""Linux happy path: tar.gz download → SHA256 check → extract → return path."""
|
||||
cache = tmp_path / "cache"
|
||||
monkeypatch.setattr("invisible_playwright.download.cache_root", lambda: cache)
|
||||
|
||||
archive_path = tmp_path / "archive.tar.gz"
|
||||
archive_bytes = _make_targz(archive_path, "firefox", b"ELF!")
|
||||
archive_sha = hashlib.sha256(archive_bytes).hexdigest()
|
||||
from invisible_playwright.constants import ARCHIVE_NAME
|
||||
asset = ARCHIVE_NAME("linux", "x86_64")
|
||||
|
||||
url_archive = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/{asset}"
|
||||
url_sums = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/checksums.txt"
|
||||
|
||||
responses.add(responses.GET, url_archive, body=archive_bytes, status=200,
|
||||
content_type="application/gzip")
|
||||
responses.add(responses.GET, url_sums,
|
||||
body=f"{archive_sha} {asset}\n", status=200)
|
||||
|
||||
monkeypatch.setattr("sys.platform", "linux")
|
||||
import platform
|
||||
monkeypatch.setattr(platform, "machine", lambda: "x86_64")
|
||||
|
||||
path = ensure_binary()
|
||||
assert Path(path).exists()
|
||||
assert Path(path).name == "firefox"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@responses.activate
|
||||
def test_ensure_binary_rejects_sha_mismatch_linux(tmp_path, monkeypatch):
|
||||
"""Linux SHA mismatch must raise — the tar.gz path runs the same
|
||||
verifier as the .zip path, so a corrupted archive is rejected before
|
||||
extraction regardless of platform."""
|
||||
cache = tmp_path / "cache"
|
||||
monkeypatch.setattr("invisible_playwright.download.cache_root", lambda: cache)
|
||||
archive_path = tmp_path / "archive.tar.gz"
|
||||
archive_bytes = _make_targz(archive_path, "firefox", b"ELF!")
|
||||
wrong_sha = "0" * 64
|
||||
from invisible_playwright.constants import ARCHIVE_NAME
|
||||
asset = ARCHIVE_NAME("linux", "x86_64")
|
||||
|
||||
url_archive = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/{asset}"
|
||||
url_sums = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/checksums.txt"
|
||||
responses.add(responses.GET, url_archive, body=archive_bytes, status=200)
|
||||
responses.add(responses.GET, url_sums, body=f"{wrong_sha} {asset}\n", status=200)
|
||||
|
||||
monkeypatch.setattr("sys.platform", "linux")
|
||||
import platform
|
||||
monkeypatch.setattr(platform, "machine", lambda: "x86_64")
|
||||
|
||||
with pytest.raises(RuntimeError, match="SHA256"):
|
||||
ensure_binary()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_ensure_binary_cache_hit_skips_http_linux(tmp_path, monkeypatch):
|
||||
"""Linux cache hit short-circuits before any HTTP. Looks for the
|
||||
``firefox`` entry (not ``firefox.exe``) per ``BINARY_ENTRY_REL``."""
|
||||
cache = tmp_path / "cache"
|
||||
version_dir = cache / BINARY_VERSION
|
||||
version_dir.mkdir(parents=True)
|
||||
pre_cached = version_dir / "firefox"
|
||||
pre_cached.write_text("cached-content")
|
||||
|
||||
monkeypatch.setattr("invisible_playwright.download.cache_root", lambda: cache)
|
||||
monkeypatch.setattr("sys.platform", "linux")
|
||||
import platform
|
||||
monkeypatch.setattr(platform, "machine", lambda: "x86_64")
|
||||
|
||||
def _fail_get(*args, **kwargs):
|
||||
raise AssertionError("HTTP must not be called on cache hit")
|
||||
monkeypatch.setattr("invisible_playwright.download.requests.get", _fail_get)
|
||||
|
||||
path = ensure_binary()
|
||||
assert path == pre_cached
|
||||
assert path.read_text() == "cached-content"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@responses.activate
|
||||
def test_ensure_binary_missing_entry_after_extract_raises_linux(tmp_path, monkeypatch):
|
||||
"""Linux post-extract sanity check: if the tar.gz lacks a ``firefox``
|
||||
entry, raise rather than returning a non-existent path. Mirrors the
|
||||
Windows test and guards against an upstream release artifact regression."""
|
||||
cache = tmp_path / "cache"
|
||||
monkeypatch.setattr("invisible_playwright.download.cache_root", lambda: cache)
|
||||
|
||||
archive_path = tmp_path / "archive.tar.gz"
|
||||
# tar.gz without ``firefox`` inside
|
||||
archive_bytes = _make_targz(archive_path, "other.bin", b"X")
|
||||
archive_sha = hashlib.sha256(archive_bytes).hexdigest()
|
||||
from invisible_playwright.constants import ARCHIVE_NAME
|
||||
asset = ARCHIVE_NAME("linux", "x86_64")
|
||||
|
||||
url_archive = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/{asset}"
|
||||
url_sums = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/checksums.txt"
|
||||
|
||||
responses.add(responses.GET, url_archive, body=archive_bytes, status=200)
|
||||
responses.add(responses.GET, url_sums, body=f"{archive_sha} {asset}\n", status=200)
|
||||
|
||||
monkeypatch.setattr("sys.platform", "linux")
|
||||
import platform
|
||||
monkeypatch.setattr(platform, "machine", lambda: "x86_64")
|
||||
|
||||
with pytest.raises(RuntimeError, match="binary not found after extraction"):
|
||||
ensure_binary()
|
||||
|
|
|
|||
243
tests/test_e2e.py
Normal file
243
tests/test_e2e.py
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
"""E2E tests for the launcher lifecycle.
|
||||
|
||||
Tests requiring the patched Firefox binary are gated behind the
|
||||
``firefox_binary`` fixture, which skips the test cleanly when the
|
||||
binary is not cached locally and cannot be downloaded (e.g. no
|
||||
network or no release token). The constructor-only tests (seed
|
||||
handling) do not need a binary and always run.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from invisible_playwright import InvisiblePlaywright
|
||||
from invisible_playwright.constants import BINARY_ENTRY_REL
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def firefox_binary():
|
||||
"""Locate the patched Firefox binary or skip the calling test.
|
||||
|
||||
We do NOT trigger a network download here: ``ensure_binary`` would
|
||||
pull a multi-hundred-megabyte archive from a private release,
|
||||
which is not appropriate inside a unit/E2E test run. Instead we
|
||||
look for an already-cached binary; if missing we skip.
|
||||
"""
|
||||
if sys.platform not in BINARY_ENTRY_REL:
|
||||
pytest.skip(f"unsupported platform: {sys.platform}")
|
||||
from invisible_playwright.download import cache_dir_for_version
|
||||
entry = cache_dir_for_version() / BINARY_ENTRY_REL[sys.platform]
|
||||
if not entry.exists():
|
||||
pytest.skip(
|
||||
"patched Firefox binary not cached; run `invisible-playwright fetch` "
|
||||
"to enable E2E tests"
|
||||
)
|
||||
return str(entry)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# Constructor-only tests (no browser launch required)
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_e3_seed_is_accessible():
|
||||
"""E3: explicit seed is stored on the instance after construction."""
|
||||
ip = InvisiblePlaywright(seed=42)
|
||||
assert ip.seed == 42
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_e4_random_seed_when_none():
|
||||
"""E4: omitting seed → a fresh positive int31 is chosen."""
|
||||
ip = InvisiblePlaywright()
|
||||
assert isinstance(ip.seed, int)
|
||||
assert ip.seed > 0
|
||||
assert ip.seed < 2**31
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_e4b_random_seed_varies_across_instances():
|
||||
"""E4 extension: two no-seed instances pick different seeds with
|
||||
overwhelming probability. ``secrets.randbits(31)`` collisions are
|
||||
~1 in 2 billion, so we accept the negligible flake risk."""
|
||||
seeds = {InvisiblePlaywright().seed for _ in range(5)}
|
||||
assert len(seeds) > 1
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_e6_profile_built_eagerly():
|
||||
"""The constructor materializes the Profile up front so seed-driven
|
||||
fields are accessible without launching a browser. Guards against
|
||||
a regression where Profile generation is deferred into ``__enter__``
|
||||
and an invalid pin therefore raises only at launch time.
|
||||
"""
|
||||
ip = InvisiblePlaywright(seed=42)
|
||||
assert ip._profile is not None
|
||||
assert ip._profile.seed == 42
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_e7_invalid_pin_raises_in_constructor():
|
||||
"""Invalid pin keys fail fast at construction, not at __enter__."""
|
||||
with pytest.raises(ValueError):
|
||||
InvisiblePlaywright(seed=42, pin={"not_a_real_field": 1})
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# Lifecycle tests (require Firefox binary)
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_e1_sync_context_manager_lifecycle(firefox_binary):
|
||||
"""E1: ``with InvisiblePlaywright(...) as browser`` yields a real
|
||||
Playwright Browser object that exposes ``new_context``."""
|
||||
with InvisiblePlaywright(seed=42, binary_path=firefox_binary) as browser:
|
||||
assert browser is not None
|
||||
assert hasattr(browser, "new_context")
|
||||
assert callable(browser.new_context)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_e2_create_context_and_page(firefox_binary):
|
||||
"""E2: a context spawned from the patched browser can create a page."""
|
||||
with InvisiblePlaywright(seed=42, binary_path=firefox_binary) as browser:
|
||||
ctx = browser.new_context()
|
||||
try:
|
||||
page = ctx.new_page()
|
||||
assert page is not None
|
||||
assert hasattr(page, "goto")
|
||||
finally:
|
||||
ctx.close()
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_e5_teardown_does_not_raise(firefox_binary):
|
||||
"""E5: ``__exit__`` cleans up Playwright + virtual display without raising."""
|
||||
ip = InvisiblePlaywright(seed=42, binary_path=firefox_binary)
|
||||
browser = ip.__enter__()
|
||||
try:
|
||||
assert browser is not None
|
||||
finally:
|
||||
ip.__exit__(None, None, None)
|
||||
# second teardown is idempotent
|
||||
ip.__exit__(None, None, None)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_e8_new_context_defaults_from_profile(firefox_binary):
|
||||
"""new_context() without kwargs should inherit profile-derived
|
||||
viewport/screen. Guards the monkey-patch installed in __enter__."""
|
||||
with InvisiblePlaywright(seed=42, binary_path=firefox_binary) as browser:
|
||||
ctx = browser.new_context()
|
||||
try:
|
||||
page = ctx.new_page()
|
||||
vp = page.viewport_size
|
||||
assert vp is not None
|
||||
assert vp["width"] > 0
|
||||
assert vp["height"] > 0
|
||||
finally:
|
||||
ctx.close()
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# Linux-specific lifecycle tests (no Firefox binary required).
|
||||
#
|
||||
# These exercise the launcher's Linux code paths without spawning real
|
||||
# Firefox or Xvfb. They monkeypatch ``sys.platform`` and (where needed)
|
||||
# the ``make_virtual_display`` dispatcher so the tests run on any host
|
||||
# — including Windows hosts that ship the production CI for this repo.
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_e9_linux_build_prefs_omits_windows_sandbox_key(monkeypatch):
|
||||
"""E9: ``_build_prefs(headless=True)`` on Linux must pass
|
||||
``virtual_display=False`` to the prefs translator. The Win32-only
|
||||
``security.sandbox.gpu.level`` workaround targets the alt-desktop
|
||||
GPU sandbox bug and MUST NOT leak into Linux prefs, where Xvfb
|
||||
handles window hiding instead."""
|
||||
import sys as _sys
|
||||
monkeypatch.setattr(_sys, "platform", "linux")
|
||||
ip = InvisiblePlaywright(seed=42, headless=True)
|
||||
prefs = ip._build_prefs()
|
||||
assert "security.sandbox.gpu.level" not in prefs
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_e10_linux_resolve_headless_invokes_xvfb_dispatcher(monkeypatch):
|
||||
"""E10: ``_resolve_headless`` with ``headless=True`` on Linux must
|
||||
call ``make_virtual_display().start()`` and store the result on
|
||||
``self._virtual_display``. We stub the dispatcher so no real Xvfb
|
||||
is spawned — the dispatcher's platform routing is covered separately
|
||||
in ``test_headless.py``."""
|
||||
import sys as _sys
|
||||
monkeypatch.setattr(_sys, "platform", "linux")
|
||||
|
||||
events: list[str] = []
|
||||
|
||||
class _FakeDisplay:
|
||||
def start(self) -> None:
|
||||
events.append("start")
|
||||
|
||||
def stop(self) -> None:
|
||||
events.append("stop")
|
||||
|
||||
from invisible_playwright import launcher as _l
|
||||
monkeypatch.setattr(_l, "make_virtual_display", lambda: _FakeDisplay())
|
||||
|
||||
ip = InvisiblePlaywright(seed=42, headless=True)
|
||||
result = ip._resolve_headless()
|
||||
assert result is False
|
||||
assert events == ["start"]
|
||||
assert ip._virtual_display is not None
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_e11_linux_teardown_stops_virtual_display_and_is_idempotent(monkeypatch):
|
||||
"""E11: ``_teardown`` stops the Linux virtual display, clears the
|
||||
reference, and a second invocation is a no-op. Guards the cleanup
|
||||
path used by ``__exit__`` so a failed ``__enter__`` cannot leak Xvfb."""
|
||||
import sys as _sys
|
||||
monkeypatch.setattr(_sys, "platform", "linux")
|
||||
|
||||
stops: list[bool] = []
|
||||
|
||||
class _FakeDisplay:
|
||||
def start(self) -> None:
|
||||
pass
|
||||
|
||||
def stop(self) -> None:
|
||||
stops.append(True)
|
||||
|
||||
from invisible_playwright import launcher as _l
|
||||
monkeypatch.setattr(_l, "make_virtual_display", lambda: _FakeDisplay())
|
||||
|
||||
ip = InvisiblePlaywright(seed=42, headless=True)
|
||||
ip._resolve_headless()
|
||||
ip._teardown()
|
||||
assert stops == [True]
|
||||
assert ip._virtual_display is None
|
||||
ip._teardown()
|
||||
assert stops == [True]
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_e12_linux_resolve_headless_without_xvfb_raises_clear_error(monkeypatch):
|
||||
"""E12: On Linux with ``headless=True`` and ``Xvfb`` missing from
|
||||
``PATH``, ``_resolve_headless`` must surface a clear, actionable
|
||||
``RuntimeError`` instead of a cryptic FileNotFoundError. Verifies
|
||||
the early-check path in ``_LinuxVirtualDisplay.start``."""
|
||||
import sys as _sys
|
||||
monkeypatch.setattr(_sys, "platform", "linux")
|
||||
|
||||
from invisible_playwright import _headless as _h
|
||||
monkeypatch.setattr(_h, "_binary_on_path", lambda name: False)
|
||||
|
||||
ip = InvisiblePlaywright(seed=42, headless=True)
|
||||
with pytest.raises(RuntimeError, match="Xvfb"):
|
||||
ip._resolve_headless()
|
||||
assert ip._virtual_display is None
|
||||
151
tests/test_headless.py
Normal file
151
tests/test_headless.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"""Unit tests for the ``_headless`` virtual-display dispatcher.
|
||||
|
||||
The dispatcher (``make_virtual_display``) is the only piece of
|
||||
``_headless`` we can exercise as a unit test on a single platform:
|
||||
``_WindowsVirtualDesktop`` actually creates a Win32 desktop on
|
||||
construction's later ``start()`` call, and ``_LinuxVirtualDisplay`` calls
|
||||
``Xvfb`` — both belong in integration/E2E coverage. The dispatcher's
|
||||
job is pure platform routing, which we patch via ``monkeypatch``.
|
||||
|
||||
Per scope: Windows-specific + platform-agnostic only. We still cover
|
||||
the Linux dispatch branch because instantiating ``_LinuxVirtualDisplay``
|
||||
does no I/O — Xvfb is only spawned in ``start()``, which we never call.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
import invisible_playwright._headless as headless
|
||||
from invisible_playwright._headless import (
|
||||
_LinuxVirtualDisplay,
|
||||
_WindowsVirtualDesktop,
|
||||
make_virtual_display,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_make_virtual_display_returns_windows_desktop_on_win32(monkeypatch):
|
||||
monkeypatch.setattr(headless.sys, "platform", "win32")
|
||||
vd = make_virtual_display()
|
||||
assert isinstance(vd, _WindowsVirtualDesktop)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_make_virtual_display_returns_linux_xvfb_on_linux(monkeypatch):
|
||||
"""``__init__`` of ``_LinuxVirtualDisplay`` does no I/O — only ``start()``
|
||||
spawns Xvfb. Exercising the dispatcher here is safe on any host."""
|
||||
monkeypatch.setattr(headless.sys, "platform", "linux")
|
||||
vd = make_virtual_display()
|
||||
assert isinstance(vd, _LinuxVirtualDisplay)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_make_virtual_display_accepts_linux_variants(monkeypatch):
|
||||
"""``sys.platform`` can be ``linux2`` on older Pythons / WSL builds.
|
||||
The dispatcher uses ``startswith("linux")`` to accept all variants."""
|
||||
monkeypatch.setattr(headless.sys, "platform", "linux2")
|
||||
assert isinstance(make_virtual_display(), _LinuxVirtualDisplay)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_make_virtual_display_raises_on_darwin(monkeypatch):
|
||||
"""macOS is unsupported — the dispatcher must raise with a clear
|
||||
message rather than returning a no-op shim. ``InvisiblePlaywright``
|
||||
relies on this to bail before launching Firefox on a system where
|
||||
the patched binary doesn't exist."""
|
||||
monkeypatch.setattr(headless.sys, "platform", "darwin")
|
||||
with pytest.raises(RuntimeError, match="Windows and Linux only"):
|
||||
make_virtual_display()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_make_virtual_display_raises_on_unsupported_platform(monkeypatch):
|
||||
monkeypatch.setattr(headless.sys, "platform", "freebsd14")
|
||||
with pytest.raises(RuntimeError, match="Windows and Linux only"):
|
||||
make_virtual_display()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_make_virtual_display_error_mentions_offending_platform(monkeypatch):
|
||||
"""Error message should include the actual ``sys.platform`` so the
|
||||
user can diagnose why their CI / weird container is being rejected."""
|
||||
monkeypatch.setattr(headless.sys, "platform", "sunos5")
|
||||
with pytest.raises(RuntimeError, match="sunos5"):
|
||||
make_virtual_display()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_windows_desktop_initial_state_is_clean():
|
||||
"""Construction must not allocate Win32 resources — only ``start()``
|
||||
does. Allows users to instantiate ``InvisiblePlaywright`` without
|
||||
pywin32 installed; the import error fires lazily when ``start()`` runs."""
|
||||
vd = _WindowsVirtualDesktop()
|
||||
assert vd._desktop is None
|
||||
assert vd._original_handle == 0
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="exercises Win32 ctypes")
|
||||
def test_windows_desktop_stop_is_idempotent_without_start():
|
||||
"""``stop()`` after never calling ``start()`` must be a no-op, so
|
||||
``__exit__`` from a failed launch can call it unconditionally.
|
||||
|
||||
Skipped off Windows because ``stop()`` unconditionally resolves
|
||||
``ctypes.windll.user32`` at the top of the function — that symbol
|
||||
only exists on Windows. The early-return logic is safe because
|
||||
callers only instantiate this class via ``make_virtual_display()``
|
||||
which already routes on ``sys.platform == 'win32'``.
|
||||
"""
|
||||
vd = _WindowsVirtualDesktop()
|
||||
vd.stop()
|
||||
vd.stop()
|
||||
assert vd._desktop is None
|
||||
assert vd._original_handle == 0
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# _LinuxVirtualDisplay — construction-only smoke tests. ``start()`` is
|
||||
# E2E because it spawns Xvfb; ``stop()`` is safe to call when no Xvfb
|
||||
# was ever started, so we exercise that path explicitly.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_linux_virtual_display_initial_state_is_clean():
|
||||
"""Construction must not spawn Xvfb or mutate the environment — only
|
||||
``start()`` does. Mirrors the Windows construction-state test."""
|
||||
vd = _LinuxVirtualDisplay()
|
||||
assert vd._proc is None
|
||||
assert vd._display is None
|
||||
assert vd._saved_env == {}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_linux_virtual_display_geometry_default():
|
||||
"""Default geometry is 1920x1080x24 — matches the profile sampler's
|
||||
default screen and avoids the Xvfb default of 1280x1024 which the
|
||||
fingerprint pipeline never produces."""
|
||||
vd = _LinuxVirtualDisplay()
|
||||
assert vd._geometry == "1920x1080x24"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_linux_virtual_display_custom_geometry():
|
||||
"""Caller-supplied width/height feed straight into the Xvfb geometry
|
||||
spec; the depth is always 24 (Firefox/ANGLE assume true-color)."""
|
||||
vd = _LinuxVirtualDisplay(width=2560, height=1440)
|
||||
assert vd._geometry == "2560x1440x24"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_linux_virtual_display_stop_without_start_is_safe():
|
||||
"""``stop()`` before ``start()`` must be a no-op — supports the
|
||||
``__exit__`` path on a launcher that failed before Xvfb was spawned.
|
||||
Verifies no AttributeError on env restore (saved_env is empty)."""
|
||||
vd = _LinuxVirtualDisplay()
|
||||
vd.stop()
|
||||
vd.stop()
|
||||
assert vd._proc is None
|
||||
assert vd._display is None
|
||||
372
tests/test_integration.py
Normal file
372
tests/test_integration.py
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
"""Integration tests — multi-module pipelines without a real browser.
|
||||
|
||||
These tests verify that the fingerprint sampler, Profile dataclass, prefs
|
||||
translation and proxy translation compose correctly. They do NOT launch
|
||||
Firefox. Browser-lifecycle tests live in ``test_e2e.py``.
|
||||
|
||||
Scope: Windows, Linux, and platform-agnostic. Platform-specific tests
|
||||
monkeypatch ``sys.platform`` so the same suite exercises both branches
|
||||
regardless of the host OS.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from invisible_playwright._fpforge import generate_profile
|
||||
from invisible_playwright._proxy import configure_proxy
|
||||
from invisible_playwright.prefs import (
|
||||
_WIN_LIGHT_COLORS,
|
||||
translate_profile_to_prefs,
|
||||
)
|
||||
|
||||
|
||||
# Keys every Profile-derived prefs dict MUST carry. Sourced from
|
||||
# ``translate_profile_to_prefs`` direct writes (not from _BASELINE) plus
|
||||
# a couple of baseline keys that callers commonly read.
|
||||
_REQUIRED_PREFS_KEYS = (
|
||||
"zoom.stealth.screen.width",
|
||||
"zoom.stealth.screen.height",
|
||||
"zoom.stealth.screen.avail_width",
|
||||
"zoom.stealth.screen.avail_height",
|
||||
"zoom.stealth.screen.dpr",
|
||||
"layout.css.devPixelsPerPx",
|
||||
"zoom.stealth.hw_concurrency",
|
||||
"zoom.stealth.storage.quota_mb",
|
||||
"zoom.stealth.audio.sample_rate",
|
||||
"zoom.stealth.audio.output_latency_ms",
|
||||
"zoom.stealth.audio.max_channel_count",
|
||||
"media.av1.enabled",
|
||||
"media.encoder.webm.enabled",
|
||||
"media.mediasource.webm.enabled",
|
||||
"media.mediasource.mp4.enabled",
|
||||
"zoom.stealth.font.whitelist",
|
||||
"zoom.stealth.font.metrics",
|
||||
"ui.systemUsesDarkTheme",
|
||||
"intl.accept_languages",
|
||||
"general.useragent.locale",
|
||||
"intl.locale.requested",
|
||||
"zoom.stealth.seed",
|
||||
"zoom.stealth.fpp.hw_seed",
|
||||
"zoom.stealth.webrtc.host_ip",
|
||||
"zoom.stealth.webgl.renderer",
|
||||
"zoom.stealth.webgl.vendor",
|
||||
"zoom.stealth.webgl.msaa",
|
||||
"zoom.stealth.canvas.noise_skip_mask",
|
||||
# baseline sanity
|
||||
"privacy.resistFingerprinting",
|
||||
"media.peerconnection.enabled",
|
||||
"general.useragent.override",
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# IT1: profile → prefs pipeline yields a complete prefs dict
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_generate_profile_then_translate_has_all_required_keys():
|
||||
"""IT1 — generate_profile → translate_profile_to_prefs succeeds and the
|
||||
returned dict contains every key downstream code (Playwright, the C++
|
||||
patches) needs to find."""
|
||||
profile = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(profile)
|
||||
|
||||
missing = [k for k in _REQUIRED_PREFS_KEYS if k not in prefs]
|
||||
assert not missing, f"prefs dict missing required keys: {missing}"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# IT2: SOCKS proxy + prefs — mutates prefs in place, returns None
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_socks5_proxy_mutates_prefs_then_pipeline_still_valid():
|
||||
"""IT2 — configure_proxy writes SOCKS auth keys to the profile-derived
|
||||
prefs dict; the result is still a valid prefs dict (all required keys
|
||||
intact) and the proxy return is ``None`` so Playwright sees no proxy."""
|
||||
profile = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(profile)
|
||||
|
||||
pw_proxy = configure_proxy(
|
||||
{
|
||||
"server": "socks5://proxy.example.com:1080",
|
||||
"username": "alice",
|
||||
"password": "s3cret",
|
||||
},
|
||||
prefs,
|
||||
)
|
||||
|
||||
assert pw_proxy is None # Firefox handles SOCKS internally.
|
||||
assert prefs["network.proxy.type"] == 1
|
||||
assert prefs["network.proxy.socks"] == "proxy.example.com"
|
||||
assert prefs["network.proxy.socks_port"] == 1080
|
||||
assert prefs["network.proxy.socks_version"] == 5
|
||||
assert prefs["network.proxy.socks_username"] == "alice"
|
||||
assert prefs["network.proxy.socks_password"] == "s3cret"
|
||||
assert prefs["network.proxy.socks_remote_dns"] is True
|
||||
|
||||
# Profile-derived keys must still be present after proxy mutation.
|
||||
for k in _REQUIRED_PREFS_KEYS:
|
||||
assert k in prefs, f"proxy mutation dropped required key {k!r}"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# IT3: pin overrides propagate end-to-end into the prefs dict
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_pin_screen_width_propagates_through_pipeline():
|
||||
"""IT3 — a pinned ``screen.width`` shows up in the final prefs dict
|
||||
under ``zoom.stealth.screen.width``."""
|
||||
profile = generate_profile(seed=42, pin={"screen.width": 2560})
|
||||
prefs = translate_profile_to_prefs(profile)
|
||||
|
||||
assert profile.screen.width == 2560
|
||||
assert prefs["zoom.stealth.screen.width"] == 2560
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_multiple_pins_all_visible_in_prefs():
|
||||
"""IT3.b — pinning several unrelated fields at once still routes every
|
||||
one through to the prefs dict."""
|
||||
pin = {
|
||||
"screen.width": 3840,
|
||||
"screen.height": 2160,
|
||||
"hardware.concurrency": 16,
|
||||
"audio.sample_rate": 48000,
|
||||
}
|
||||
profile = generate_profile(seed=42, pin=pin)
|
||||
prefs = translate_profile_to_prefs(profile)
|
||||
|
||||
assert prefs["zoom.stealth.screen.width"] == 3840
|
||||
assert prefs["zoom.stealth.screen.height"] == 2160
|
||||
assert prefs["zoom.stealth.hw_concurrency"] == 16
|
||||
assert prefs["zoom.stealth.audio.sample_rate"] == 48000
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# IT4 / IT5: end-to-end determinism + variation
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_pipeline_deterministic_for_same_seed():
|
||||
"""IT4 — running the full pipeline twice with the same seed produces
|
||||
identical prefs dicts."""
|
||||
a = translate_profile_to_prefs(generate_profile(seed=1234))
|
||||
b = translate_profile_to_prefs(generate_profile(seed=1234))
|
||||
assert a == b
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_pipeline_varies_across_seeds():
|
||||
"""IT5 — different seeds produce different prefs dicts. Compare the
|
||||
full dict, not just a sampled field, to catch regressions where a
|
||||
single hot field accidentally becomes seed-invariant."""
|
||||
a = translate_profile_to_prefs(generate_profile(seed=1))
|
||||
b = translate_profile_to_prefs(generate_profile(seed=2))
|
||||
assert a != b
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# IT6: HTTP proxy passthrough does NOT mutate SOCKS prefs
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_http_proxy_returned_unchanged_no_socks_mutations():
|
||||
"""IT6 — an HTTP proxy is returned to Playwright unchanged and the
|
||||
SOCKS prefs are never written. Verifies the two proxy paths don't
|
||||
cross-pollute the prefs dict."""
|
||||
profile = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(profile)
|
||||
proxy_in = {"server": "http://proxy.example.com:8080", "username": "bob"}
|
||||
|
||||
pw_proxy = configure_proxy(proxy_in, prefs)
|
||||
|
||||
assert pw_proxy is proxy_in # returned unchanged (same object)
|
||||
# No SOCKS prefs should have been written.
|
||||
assert "network.proxy.type" not in prefs
|
||||
assert "network.proxy.socks" not in prefs
|
||||
assert "network.proxy.socks_port" not in prefs
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# IT7: profile.fonts reaches prefs as a comma-joined whitelist
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_profile_fonts_propagate_to_prefs_whitelist():
|
||||
"""IT7 — every font in ``profile.fonts`` appears in the comma-joined
|
||||
``zoom.stealth.font.whitelist`` pref, in order."""
|
||||
profile = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(profile)
|
||||
|
||||
assert profile.fonts, "fixture seed=42 produced empty fonts list"
|
||||
whitelist = prefs["zoom.stealth.font.whitelist"]
|
||||
assert isinstance(whitelist, str)
|
||||
assert whitelist == ",".join(profile.fonts)
|
||||
for font in profile.fonts:
|
||||
assert font in whitelist
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# IT8: dark_theme controls the Win10 light-palette overlay
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_dark_theme_pipeline_omits_light_palette():
|
||||
"""IT8.a — dark_theme=True profile → no light-palette colors in prefs."""
|
||||
profile = generate_profile(seed=42, pin={"dark_theme": True})
|
||||
prefs = translate_profile_to_prefs(profile)
|
||||
|
||||
assert prefs["ui.systemUsesDarkTheme"] == 1
|
||||
for key in _WIN_LIGHT_COLORS:
|
||||
assert key not in prefs, f"dark theme leaked light color: {key}"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_light_theme_pipeline_includes_light_palette():
|
||||
"""IT8.b — dark_theme=False profile → full Win10 light palette is
|
||||
overlaid onto the prefs dict."""
|
||||
profile = generate_profile(seed=42, pin={"dark_theme": False})
|
||||
prefs = translate_profile_to_prefs(profile)
|
||||
|
||||
assert prefs["ui.systemUsesDarkTheme"] == 0
|
||||
for key, value in _WIN_LIGHT_COLORS.items():
|
||||
assert prefs[key] == value
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# IT9: many seeds all produce valid prefs dicts
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_many_seeds_all_produce_valid_prefs():
|
||||
"""IT9 — sweep 10 distinct seeds through the full pipeline. Every run
|
||||
must succeed and yield a prefs dict containing every required key.
|
||||
Catches regressions where a rare CPT branch produces a prefs key
|
||||
missing/wrong-typed."""
|
||||
rng = random.Random(2026)
|
||||
seeds = [rng.randint(1, 2**31 - 1) for _ in range(10)]
|
||||
|
||||
for seed in seeds:
|
||||
profile = generate_profile(seed=seed)
|
||||
prefs = translate_profile_to_prefs(profile)
|
||||
missing = [k for k in _REQUIRED_PREFS_KEYS if k not in prefs]
|
||||
assert not missing, f"seed={seed} missing keys: {missing}"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# IT10 (extra): Windows-specific pipeline — virtual display + SOCKS
|
||||
#
|
||||
# Combines two Windows-specific branches that real callers stack:
|
||||
# headless mode (virtual_display=True) and a SOCKS5 proxy. Catches
|
||||
# ordering bugs where one branch silently overwrites the other.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_windows_virtual_display_with_socks_proxy(monkeypatch):
|
||||
"""IT10 — Windows + virtual_display=True + SOCKS5 proxy: both branches
|
||||
land their keys in the prefs dict and don't clobber each other."""
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
profile = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(profile, virtual_display=True)
|
||||
pw_proxy = configure_proxy(
|
||||
{"server": "socks5://127.0.0.1:1080"}, prefs
|
||||
)
|
||||
|
||||
assert pw_proxy is None
|
||||
assert prefs["security.sandbox.gpu.level"] == 0 # virtual_display branch
|
||||
assert prefs["network.proxy.type"] == 1 # SOCKS branch
|
||||
assert prefs["network.proxy.socks"] == "127.0.0.1"
|
||||
# Windows still has the renderer cleared.
|
||||
assert prefs["zoom.stealth.webgl.renderer"] == ""
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# IT11 (extra): Linux-specific pipeline — Xvfb workarounds + GPU spoof
|
||||
# + SOCKS5 proxy. The Linux equivalent of IT10. Verifies that the three
|
||||
# Linux-only branches (renderer spoof, Xvfb webrender disable, MSAA
|
||||
# from profile) coexist with proxy mutation in the same prefs dict.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_linux_xvfb_workarounds_with_socks_proxy(monkeypatch):
|
||||
"""IT11 — Linux + SOCKS5 proxy: Xvfb workarounds applied, GPU renderer
|
||||
spoofed from profile, SOCKS keys written. virtual_display is a Windows-
|
||||
only concept so we omit it here; passing ``virtual_display=True`` on
|
||||
Linux must NOT set ``security.sandbox.gpu.level`` (covered by VD3)."""
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
profile = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(profile, virtual_display=True)
|
||||
pw_proxy = configure_proxy(
|
||||
{"server": "socks5://127.0.0.1:1080"}, prefs
|
||||
)
|
||||
|
||||
assert pw_proxy is None
|
||||
# Xvfb workarounds present.
|
||||
assert prefs["gfx.webrender.all"] is False
|
||||
assert prefs["gfx.webrender.force-disabled"] is True
|
||||
assert prefs["webgl.force-enabled"] is True
|
||||
# Windows-only sandbox key absent on Linux even with virtual_display=True.
|
||||
assert "security.sandbox.gpu.level" not in prefs
|
||||
# GPU renderer is spoofed from the profile (not cleared like on Windows).
|
||||
assert prefs["zoom.stealth.webgl.renderer"] == profile.gpu.renderer
|
||||
assert prefs["zoom.stealth.webgl.renderer"] # non-empty
|
||||
# SOCKS branch wrote its keys without clobbering the Linux prefs above.
|
||||
assert prefs["network.proxy.type"] == 1
|
||||
assert prefs["network.proxy.socks"] == "127.0.0.1"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# IT12 (extra): Linux pipeline carries profile MSAA end-to-end. Windows
|
||||
# pins MSAA to 4 regardless of the profile; Linux must let the sampled
|
||||
# value through. Guards the platform branch in ``translate_profile_to_prefs``.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_linux_msaa_pin_propagates_through_pipeline(monkeypatch):
|
||||
"""IT12 — pinning MSAA on Linux survives the prefs translation; on
|
||||
Windows the same pin is overwritten to 4 (covered by the unit tests)."""
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
profile = generate_profile(seed=42, pin={"webgl.msaa_samples": 8})
|
||||
prefs = translate_profile_to_prefs(profile)
|
||||
|
||||
assert prefs["zoom.stealth.webgl.msaa"] == 8
|
||||
assert prefs["webgl.msaa-samples"] == 8
|
||||
assert prefs["webgl.msaa-force"] is True
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# IT13 (extra): Linux font metrics receive the GTK/DejaVu compensation
|
||||
# block. End-to-end check that ``_LINUX_GENERIC_FONT_FACTORS`` is
|
||||
# prepended to the per-font metrics string sampled from the profile.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_linux_font_metrics_include_generic_factors(monkeypatch):
|
||||
"""IT13 — on Linux the font metrics pref starts with the generic
|
||||
width-scale factors (GTK/DejaVu compensation) so glyph widths match
|
||||
Windows. Without this, Linux sessions leak via metric drift."""
|
||||
from invisible_playwright.prefs import _LINUX_GENERIC_FONT_FACTORS
|
||||
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
profile = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(profile)
|
||||
|
||||
metrics = prefs["zoom.stealth.font.metrics"]
|
||||
assert metrics.startswith(_LINUX_GENERIC_FONT_FACTORS)
|
||||
171
tests/test_launcher_helpers.py
Normal file
171
tests/test_launcher_helpers.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
"""Unit tests for pure helpers in ``launcher.py``.
|
||||
|
||||
These cover code paths that are not exercised by the E2E launcher tests
|
||||
(`test_e2e.py`) because they live in private helpers below the Playwright
|
||||
boundary. The tests instantiate ``InvisiblePlaywright`` for the methods
|
||||
that read ``self._profile`` but never enter ``__enter__``, so no Firefox
|
||||
binary or virtual display is required.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from invisible_playwright import InvisiblePlaywright
|
||||
from invisible_playwright.launcher import (
|
||||
_CHROME_H,
|
||||
_CHROME_W,
|
||||
_IANA_TO_POSIX_TZ,
|
||||
_TASKBAR_H,
|
||||
_tz_env,
|
||||
)
|
||||
|
||||
|
||||
# ── _tz_env (IANA → POSIX) ────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_tz_env_eastern_us_maps_to_posix_with_dst():
|
||||
"""Eastern US zones share the same POSIX form; spot-check a few."""
|
||||
assert _tz_env("America/New_York") == "EST5EDT"
|
||||
assert _tz_env("America/Detroit") == "EST5EDT"
|
||||
assert _tz_env("America/Indiana/Indianapolis") == "EST5EDT"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_tz_env_central_mountain_pacific_map_to_posix_with_dst():
|
||||
assert _tz_env("America/Chicago") == "CST6CDT"
|
||||
assert _tz_env("America/Denver") == "MST7MDT"
|
||||
assert _tz_env("America/Los_Angeles") == "PST8PDT"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_tz_env_phoenix_strips_dst():
|
||||
"""Arizona (outside Navajo Nation) does NOT observe DST. The POSIX
|
||||
form must be ``MST7`` (no second segment) — using ``MST7MDT`` caused
|
||||
FP Pro to deduce vpn_origin_timezone=America/Denver from a 60-minute
|
||||
offset error in summer. Guard against regression of that mapping.
|
||||
"""
|
||||
assert _tz_env("America/Phoenix") == "MST7"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_tz_env_honolulu_strips_dst():
|
||||
"""Hawaii does not observe DST. POSIX form ``HST10`` (no DST segment)."""
|
||||
assert _tz_env("Pacific/Honolulu") == "HST10"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_tz_env_passthrough_for_unmapped_zone():
|
||||
"""Zones outside the lookup table fall through to their IANA name —
|
||||
glibc on Linux reads /usr/share/zoneinfo directly. Windows MSVCRT
|
||||
won't understand them but that's accepted; the mapping covers the
|
||||
common residential-proxy zones."""
|
||||
assert _tz_env("Europe/Berlin") == "Europe/Berlin"
|
||||
assert _tz_env("Asia/Tokyo") == "Asia/Tokyo"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_tz_env_empty_string_passes_through():
|
||||
"""Empty string is never set as ``TZ`` by the caller, but the helper
|
||||
is still defensive — return it unchanged rather than raising."""
|
||||
assert _tz_env("") == ""
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_iana_to_posix_phoenix_and_honolulu_present():
|
||||
"""Sanity-check the no-DST entries are still in the mapping; deleting
|
||||
them would silently revert the Phoenix DST bug."""
|
||||
assert _IANA_TO_POSIX_TZ["America/Phoenix"] == "MST7"
|
||||
assert _IANA_TO_POSIX_TZ["Pacific/Honolulu"] == "HST10"
|
||||
|
||||
|
||||
# ── InvisiblePlaywright._humanize_max_seconds ─────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_humanize_true_defaults_to_one_and_a_half_seconds():
|
||||
ip = InvisiblePlaywright(seed=42, humanize=True)
|
||||
assert ip._humanize_max_seconds() == 1.5
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_humanize_float_passes_through_as_seconds():
|
||||
ip = InvisiblePlaywright(seed=42, humanize=2.5)
|
||||
assert ip._humanize_max_seconds() == 2.5
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_humanize_int_coerced_to_float():
|
||||
"""``humanize=3`` is valid (truthy, not ``True``) → float coercion."""
|
||||
ip = InvisiblePlaywright(seed=42, humanize=3)
|
||||
out = ip._humanize_max_seconds()
|
||||
assert out == 3.0
|
||||
assert isinstance(out, float)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_humanize_small_float_passes_through():
|
||||
"""Below the default cap — the user's value wins."""
|
||||
ip = InvisiblePlaywright(seed=42, humanize=0.4)
|
||||
assert ip._humanize_max_seconds() == 0.4
|
||||
|
||||
|
||||
# ── InvisiblePlaywright._default_context_kwargs ───────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_default_context_viewport_subtracts_window_chrome():
|
||||
"""Viewport must fit inside the spoofed screen with the headed
|
||||
window chrome subtracted. Otherwise Playwright complains about the
|
||||
viewport being larger than the screen."""
|
||||
ip = InvisiblePlaywright(seed=42)
|
||||
kw = ip._default_context_kwargs()
|
||||
p = ip._profile
|
||||
assert kw["viewport"]["width"] == p.screen.width - _CHROME_W
|
||||
assert kw["viewport"]["height"] == p.screen.height - _TASKBAR_H - _CHROME_H
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_default_context_screen_matches_profile():
|
||||
ip = InvisiblePlaywright(seed=42)
|
||||
kw = ip._default_context_kwargs()
|
||||
p = ip._profile
|
||||
assert kw["screen"] == {"width": p.screen.width, "height": p.screen.height}
|
||||
assert kw["device_scale_factor"] == p.screen.dpr
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_default_context_color_scheme_follows_dark_theme():
|
||||
"""``color_scheme`` must match ``profile.dark_theme`` so the Playwright
|
||||
realm tells matchMedia the same thing the prefs tell the chrome."""
|
||||
ip_dark = InvisiblePlaywright(seed=42, pin={"dark_theme": True})
|
||||
ip_light = InvisiblePlaywright(seed=42, pin={"dark_theme": False})
|
||||
assert ip_dark._default_context_kwargs()["color_scheme"] == "dark"
|
||||
assert ip_light._default_context_kwargs()["color_scheme"] == "light"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_default_context_includes_timezone_when_set():
|
||||
ip = InvisiblePlaywright(seed=42, timezone="America/New_York")
|
||||
assert ip._default_context_kwargs()["timezone_id"] == "America/New_York"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_default_context_omits_timezone_when_empty():
|
||||
"""Default ``timezone=""`` means "let the host TZ leak through" —
|
||||
Playwright must not receive ``timezone_id`` at all in that case,
|
||||
otherwise it overrides to the literal empty string."""
|
||||
ip = InvisiblePlaywright(seed=42)
|
||||
assert "timezone_id" not in ip._default_context_kwargs()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_default_context_includes_locale_when_set():
|
||||
ip = InvisiblePlaywright(seed=42, locale="de-DE")
|
||||
assert ip._default_context_kwargs()["locale"] == "de-DE"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_default_context_omits_locale_when_empty():
|
||||
ip = InvisiblePlaywright(seed=42, locale="")
|
||||
assert "locale" not in ip._default_context_kwargs()
|
||||
260
tests/test_network.py
Normal file
260
tests/test_network.py
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
"""Unit tests for invisible_playwright._fpforge._network.
|
||||
|
||||
Covers the Bayesian network primitives: _weighted_pick, _parent_key,
|
||||
_topsort, Node.sample, Network.sample.
|
||||
"""
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from invisible_playwright._fpforge._network import (
|
||||
Network,
|
||||
Node,
|
||||
_parent_key,
|
||||
_topsort,
|
||||
_weighted_pick,
|
||||
)
|
||||
|
||||
|
||||
# ── _weighted_pick ─────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_weighted_pick_normal_weights_deterministic_per_seed():
|
||||
"""WP1 [HAPPY]: returns one of the values; deterministic with seeded rng."""
|
||||
table = [{"value": "A", "prob": 0.7}, {"value": "B", "prob": 0.3}]
|
||||
rng = random.Random(42)
|
||||
out = _weighted_pick(table, rng)
|
||||
assert out in {"A", "B"}
|
||||
# same seed → same draw
|
||||
assert _weighted_pick(table, random.Random(42)) == out
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_weighted_pick_single_element_table():
|
||||
"""WP2 [BVA]: single entry → always returns that value."""
|
||||
table = [{"value": "X", "prob": 1.0}]
|
||||
for seed in (0, 1, 999):
|
||||
assert _weighted_pick(table, random.Random(seed)) == "X"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_weighted_pick_empty_table_raises():
|
||||
"""WP3 [NEG]: empty list → ValueError."""
|
||||
with pytest.raises(ValueError, match="Empty CPT entry"):
|
||||
_weighted_pick([], random.Random(0))
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_weighted_pick_all_zero_probs_uses_uniform_fallback():
|
||||
"""WP4 [ECP]: total == 0 → falls back to rng.choice (uniform)."""
|
||||
table = [{"value": "A", "prob": 0}, {"value": "B", "prob": 0}]
|
||||
# Sample many times — both outcomes must be reachable under uniform choice.
|
||||
rng = random.Random(123)
|
||||
seen = {_weighted_pick(table, rng) for _ in range(50)}
|
||||
assert seen == {"A", "B"}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_weighted_pick_unnormalized_weights():
|
||||
"""WP6 [ECP]: weights 3/7 normalize to 0.3/0.7; same seed → same result."""
|
||||
table = [{"value": "A", "prob": 3}, {"value": "B", "prob": 7}]
|
||||
rng_a = random.Random(42)
|
||||
rng_b = random.Random(42)
|
||||
# Equivalent normalized table must yield the same draw given same rng state.
|
||||
table_norm = [{"value": "A", "prob": 0.3}, {"value": "B", "prob": 0.7}]
|
||||
assert _weighted_pick(table, rng_a) == _weighted_pick(table_norm, rng_b)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_weighted_pick_complex_value_types_returned_as_is():
|
||||
"""WP7 [ECP]: values can be dicts; returned by reference."""
|
||||
payload = {"w": 1920, "h": 1080}
|
||||
table = [{"value": payload, "prob": 1.0}]
|
||||
assert _weighted_pick(table, random.Random(0)) is payload
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_weighted_pick_total_exactly_zero_single_entry():
|
||||
"""WP8 [BVA]: total = 0 with one value → uniform fallback returns it."""
|
||||
table = [{"value": "A", "prob": 0}]
|
||||
assert _weighted_pick(table, random.Random(0)) == "A"
|
||||
|
||||
|
||||
# ── _parent_key ─────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_parent_key_single_string_parent():
|
||||
"""PK1 [ECP]: single string parent → value returned as-is."""
|
||||
assert _parent_key(["gpu"], {"gpu": "Intel"}) == "Intel"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_parent_key_single_non_string_parent_uses_json():
|
||||
"""PK2 [ECP]: single non-string parent → json.dumps with sort_keys."""
|
||||
assert _parent_key(["x"], {"x": 42}) == "42"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_parent_key_multiple_parents_returns_json_array():
|
||||
"""PK3 [ECP]: multiple parents → JSON array in declared order."""
|
||||
assert _parent_key(["a", "b"], {"a": "X", "b": "Y"}) == '["X", "Y"]'
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_parent_key_single_dict_parent_sorted_keys():
|
||||
"""PK4 [ECP]: dict value → JSON with sorted keys for stable lookup."""
|
||||
out = _parent_key(["gpu"], {"gpu": {"renderer": "A", "vendor": "B"}})
|
||||
assert out == '{"renderer": "A", "vendor": "B"}'
|
||||
|
||||
|
||||
# ── _topsort ────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_topsort_linear_chain():
|
||||
"""TS1 [HAPPY]: A → B → C produces order [A, B, C]."""
|
||||
a = Node("A")
|
||||
b = Node("B", parents=["A"])
|
||||
c = Node("C", parents=["B"])
|
||||
order = [n.name for n in _topsort([c, b, a])]
|
||||
assert order == ["A", "B", "C"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_topsort_diamond():
|
||||
"""TS2 [HAPPY]: diamond A→{B,C}→D — A before B,C; B,C before D."""
|
||||
a = Node("A")
|
||||
b = Node("B", parents=["A"])
|
||||
c = Node("C", parents=["A"])
|
||||
d = Node("D", parents=["B", "C"])
|
||||
order = [n.name for n in _topsort([d, c, b, a])]
|
||||
assert order.index("A") < order.index("B")
|
||||
assert order.index("A") < order.index("C")
|
||||
assert order.index("B") < order.index("D")
|
||||
assert order.index("C") < order.index("D")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_topsort_direct_cycle_raises():
|
||||
"""TS3 [NEG]: A↔B mutual parent → ValueError("Cycle at ...")."""
|
||||
a = Node("A", parents=["B"])
|
||||
b = Node("B", parents=["A"])
|
||||
with pytest.raises(ValueError, match="Cycle"):
|
||||
_topsort([a, b])
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_topsort_unknown_parent_raises():
|
||||
"""TS4 [NEG]: parent name not in node list → ValueError."""
|
||||
a = Node("A", parents=["ghost"])
|
||||
with pytest.raises(ValueError, match="unknown parent"):
|
||||
_topsort([a])
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_topsort_single_root_node():
|
||||
"""TS5 [BVA]: one root node → returns it unchanged."""
|
||||
a = Node("A")
|
||||
assert [n.name for n in _topsort([a])] == ["A"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_topsort_empty_list():
|
||||
"""TS6 [BVA]: empty → empty."""
|
||||
assert _topsort([]) == []
|
||||
|
||||
|
||||
# ── Node.sample ─────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_node_sample_classifier_ignores_cpt():
|
||||
"""NS1 [ECP]: classifier node returns classifier output, CPT unused."""
|
||||
node = Node("c", parents=["x"], classifier=lambda ctx: "FIXED")
|
||||
assert node.sample({"x": "anything"}, random.Random(0)) == "FIXED"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_node_sample_marginal_root():
|
||||
"""NS2 [ECP]: root with single-entry CPT → returns that value."""
|
||||
node = Node("r", parents=[], cpt=[{"value": "A", "prob": 1.0}])
|
||||
assert node.sample({}, random.Random(0)) == "A"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_node_sample_conditional_key_exists():
|
||||
"""NS3 [ECP]: parent value in CPT → samples from that distribution."""
|
||||
cpt = {
|
||||
"high_end": [{"value": "fast", "prob": 1.0}],
|
||||
"low_end": [{"value": "slow", "prob": 1.0}],
|
||||
}
|
||||
node = Node("hw", parents=["gpu_class"], cpt=cpt)
|
||||
assert node.sample({"gpu_class": "high_end"}, random.Random(0)) == "fast"
|
||||
assert node.sample({"gpu_class": "low_end"}, random.Random(0)) == "slow"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_node_sample_conditional_key_miss_falls_back_to_union():
|
||||
"""NS4 [ECP]: unknown parent value → union of all CPT entries."""
|
||||
cpt = {
|
||||
"high_end": [{"value": "fast", "prob": 1.0}],
|
||||
"low_end": [{"value": "slow", "prob": 1.0}],
|
||||
}
|
||||
node = Node("hw", parents=["gpu_class"], cpt=cpt)
|
||||
rng = random.Random(0)
|
||||
seen = {node.sample({"gpu_class": "unknown_tier"}, rng) for _ in range(50)}
|
||||
assert seen <= {"fast", "slow"}
|
||||
# Union must allow both outcomes given enough samples.
|
||||
assert len(seen) >= 1
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_node_sample_conditional_empty_cpt_raises():
|
||||
"""NS5 [NEG]: CPT with all-empty value lists → ValueError."""
|
||||
cpt = {"a": [], "b": []}
|
||||
node = Node("x", parents=["p"], cpt=cpt)
|
||||
with pytest.raises(ValueError, match="no CPT entries"):
|
||||
node.sample({"p": "unknown"}, random.Random(0))
|
||||
|
||||
|
||||
# ── Network.sample ──────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_network_sample_basic_graph_returns_all_keys():
|
||||
"""NW1 [HAPPY]: 3-node network → context dict has all node names."""
|
||||
gpu = Node("gpu", parents=[], cpt=[{"value": "Intel", "prob": 1.0}])
|
||||
gpu_class = Node(
|
||||
"gpu_class", parents=["gpu"],
|
||||
classifier=lambda ctx: "integrated_modern",
|
||||
)
|
||||
hw = Node(
|
||||
"hw", parents=["gpu_class"],
|
||||
cpt={"integrated_modern": [{"value": 8, "prob": 1.0}]},
|
||||
)
|
||||
net = Network([gpu, gpu_class, hw])
|
||||
out = net.sample(random.Random(42))
|
||||
assert set(out.keys()) == {"gpu", "gpu_class", "hw"}
|
||||
assert out["gpu"] == "Intel"
|
||||
assert out["gpu_class"] == "integrated_modern"
|
||||
assert out["hw"] == 8
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_network_sample_deterministic_per_seed():
|
||||
"""NW2 [ECP]: same rng seed → identical sample."""
|
||||
gpu = Node("gpu", parents=[], cpt=[
|
||||
{"value": "Intel", "prob": 0.5},
|
||||
{"value": "NVIDIA", "prob": 0.5},
|
||||
])
|
||||
net = Network([gpu])
|
||||
assert net.sample(random.Random(7)) == net.sample(random.Random(7))
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_network_sample_varies_across_seeds():
|
||||
"""NW3 [ECP]: 32 distinct seeds over a 2-way root must see both outcomes."""
|
||||
gpu = Node("gpu", parents=[], cpt=[
|
||||
{"value": "Intel", "prob": 0.5},
|
||||
{"value": "NVIDIA", "prob": 0.5},
|
||||
])
|
||||
net = Network([gpu])
|
||||
seen = {net.sample(random.Random(s))["gpu"] for s in range(32)}
|
||||
assert seen == {"Intel", "NVIDIA"}
|
||||
|
|
@ -1,23 +1,29 @@
|
|||
import re
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from invisible_playwright._fpforge import generate_profile
|
||||
from invisible_playwright.prefs import translate_profile_to_prefs
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not sys.platform.startswith("linux"),
|
||||
reason="Renderer is only spoofed via pref on Linux; on Windows the native ANGLE "
|
||||
"string is preserved to keep the WebGL parameters hash coherent",
|
||||
from invisible_playwright.prefs import (
|
||||
_LINUX_GENERIC_FONT_FACTORS,
|
||||
_accept_language,
|
||||
_font_metrics_for_platform,
|
||||
_WIN_LIGHT_COLORS,
|
||||
translate_profile_to_prefs,
|
||||
)
|
||||
def test_translate_includes_gpu_renderer():
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_translate_includes_gpu_renderer_windows(monkeypatch):
|
||||
"""On Windows, renderer/vendor are cleared so ANGLE reports native hardware."""
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
p = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(p)
|
||||
assert prefs["zoom.stealth.webgl.renderer"] == p.gpu.renderer
|
||||
assert prefs["zoom.stealth.webgl.vendor"] == p.gpu.vendor
|
||||
assert prefs["zoom.stealth.webgl.renderer"] == ""
|
||||
assert prefs["zoom.stealth.webgl.vendor"] == ""
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_translate_includes_screen():
|
||||
p = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(p)
|
||||
|
|
@ -25,20 +31,485 @@ def test_translate_includes_screen():
|
|||
assert prefs["zoom.stealth.screen.height"] == p.screen.height
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_translate_is_deterministic_per_seed():
|
||||
a = translate_profile_to_prefs(generate_profile(seed=42))
|
||||
b = translate_profile_to_prefs(generate_profile(seed=42))
|
||||
assert a == b
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_translate_varies_across_seeds():
|
||||
a = translate_profile_to_prefs(generate_profile(seed=1))
|
||||
b = translate_profile_to_prefs(generate_profile(seed=2))
|
||||
assert a != b
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_translate_has_stealth_baseline_constants():
|
||||
p = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(p)
|
||||
assert prefs.get("privacy.resistFingerprinting") is False
|
||||
assert "media.peerconnection.enabled" in prefs
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# _accept_language (platform-agnostic)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_accept_language_with_region():
|
||||
# AL1
|
||||
assert _accept_language("en-US") == "en-US, en"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_accept_language_no_region():
|
||||
# AL2
|
||||
assert _accept_language("fr") == "fr"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_accept_language_underscore_normalized():
|
||||
# AL3
|
||||
assert _accept_language("pt_BR") == "pt-BR, pt"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# _font_metrics_for_platform
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_font_metrics_windows_returns_empty(monkeypatch):
|
||||
# FM2: Windows never applies width-scale factors.
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
assert _font_metrics_for_platform("Arial|1.0,Verdana|0.9,") == ""
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_font_metrics_empty_input_returns_empty():
|
||||
# FM3: Empty input always returns "" regardless of platform.
|
||||
assert _font_metrics_for_platform("") == ""
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Platform-specific GPU / MSAA (Windows)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_gpu_renderer_empty_on_windows(monkeypatch):
|
||||
# PG2
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
p = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(p)
|
||||
assert prefs["zoom.stealth.webgl.renderer"] == ""
|
||||
assert prefs["zoom.stealth.webgl.vendor"] == ""
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_msaa_pinned_to_4_on_windows(monkeypatch):
|
||||
# PG4: even when profile.webgl.msaa_samples differs, Windows pins to 4.
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
p = generate_profile(seed=42, pin={"webgl.msaa_samples": 8})
|
||||
prefs = translate_profile_to_prefs(p)
|
||||
assert prefs["zoom.stealth.webgl.msaa"] == 4
|
||||
assert prefs["webgl.msaa-samples"] == 4
|
||||
assert prefs["webgl.msaa-force"] is True
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Canvas noise skip mask (Windows always uses intel path)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_canvas_noise_mask_windows_uses_intel_path(monkeypatch):
|
||||
# CN3: on Windows _renderer_lo is hardcoded to "intel" → mask=15.
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
p = generate_profile(
|
||||
seed=42,
|
||||
pin={"gpu.renderer": "ANGLE (NVIDIA, NVIDIA GeForce RTX 4090 Direct3D11)"},
|
||||
)
|
||||
prefs = translate_profile_to_prefs(p)
|
||||
assert prefs["zoom.stealth.canvas.noise_skip_mask"] == 15
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# WebGL extensions (Windows clears them)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_webgl_extensions_cleared_on_windows(monkeypatch):
|
||||
# WE2
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
p = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(p)
|
||||
assert prefs["zoom.stealth.webgl.extensions"] == ""
|
||||
assert prefs["zoom.stealth.webgl2.extensions"] == ""
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Timezone (platform-agnostic)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_timezone_set_propagates_to_both_keys():
|
||||
# TZ1
|
||||
p = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(p, timezone="America/New_York")
|
||||
assert prefs["zoom.stealth.timezone"] == "America/New_York"
|
||||
assert prefs["juggler.timezone.override"] == "America/New_York"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_timezone_empty_omits_both_keys():
|
||||
# TZ2
|
||||
p = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(p, timezone="")
|
||||
assert "zoom.stealth.timezone" not in prefs
|
||||
assert "juggler.timezone.override" not in prefs
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# extra_prefs overlay (platform-agnostic)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_extra_prefs_adds_custom_key():
|
||||
# EP1
|
||||
p = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(p, extra_prefs={"custom.pref": 42})
|
||||
assert prefs["custom.pref"] == 42
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_extra_prefs_none_value_deletes_key():
|
||||
# EP2
|
||||
p = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(
|
||||
p, extra_prefs={"privacy.resistFingerprinting": None}
|
||||
)
|
||||
assert "privacy.resistFingerprinting" not in prefs
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_extra_prefs_overrides_existing_key():
|
||||
# EP3
|
||||
p = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(p, extra_prefs={"zoom.stealth.seed": 999})
|
||||
assert prefs["zoom.stealth.seed"] == 999
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_extra_prefs_none_is_no_op():
|
||||
# EP4
|
||||
p = generate_profile(seed=42)
|
||||
base = translate_profile_to_prefs(p)
|
||||
with_none = translate_profile_to_prefs(p, extra_prefs=None)
|
||||
assert base == with_none
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_extra_prefs_empty_dict_is_no_op():
|
||||
# EP5
|
||||
p = generate_profile(seed=42)
|
||||
base = translate_profile_to_prefs(p)
|
||||
with_empty = translate_profile_to_prefs(p, extra_prefs={})
|
||||
assert base == with_empty
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# System colors / dark theme (platform-agnostic — palette is Win10)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_system_colors_present_when_light_theme():
|
||||
# SC1
|
||||
p = generate_profile(seed=42, pin={"dark_theme": False})
|
||||
prefs = translate_profile_to_prefs(p)
|
||||
assert prefs["ui.systemUsesDarkTheme"] == 0
|
||||
# Spot-check a few keys from the Win10 light palette.
|
||||
for key in _WIN_LIGHT_COLORS:
|
||||
assert key in prefs
|
||||
assert prefs[key] == _WIN_LIGHT_COLORS[key]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_system_colors_absent_when_dark_theme():
|
||||
# SC2
|
||||
p = generate_profile(seed=42, pin={"dark_theme": True})
|
||||
prefs = translate_profile_to_prefs(p)
|
||||
assert prefs["ui.systemUsesDarkTheme"] == 1
|
||||
for key in _WIN_LIGHT_COLORS:
|
||||
assert key not in prefs
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Locale prefs (platform-agnostic)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_locale_en_us_accept_languages():
|
||||
# LC1
|
||||
p = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(p, locale="en-US")
|
||||
assert prefs["intl.accept_languages"] == "en-US, en"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_locale_underscore_form_normalized():
|
||||
# LC2
|
||||
p = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(p, locale="de_DE")
|
||||
assert prefs["intl.accept_languages"] == "de-DE, de"
|
||||
assert prefs["general.useragent.locale"] == "de-DE"
|
||||
assert prefs["intl.locale.requested"] == "de-DE"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_locale_empty_falls_back_to_en_us():
|
||||
# LC3
|
||||
p = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(p, locale="")
|
||||
assert prefs["intl.accept_languages"] == "en-US, en"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Xvfb workarounds (Windows must NOT set Linux-only keys)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_xvfb_workarounds_absent_on_windows(monkeypatch):
|
||||
# XW2
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
p = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(p)
|
||||
assert "gfx.webrender.all" not in prefs
|
||||
assert "gfx.webrender.force-disabled" not in prefs
|
||||
assert "webgl.force-enabled" not in prefs
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Windows virtual-desktop workarounds
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_virtual_display_workaround_applied_on_windows(monkeypatch):
|
||||
# VD1
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
p = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(p, virtual_display=True)
|
||||
assert prefs["security.sandbox.gpu.level"] == 0
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_virtual_display_workaround_absent_when_disabled(monkeypatch):
|
||||
# VD2
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
p = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(p, virtual_display=False)
|
||||
assert "security.sandbox.gpu.level" not in prefs
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Seed-derived LAN IP (platform-agnostic)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_lan_ip_matches_192_168_pattern():
|
||||
# LI1
|
||||
p = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(p)
|
||||
ip = prefs["zoom.stealth.webrtc.host_ip"]
|
||||
m = re.match(r"^192\.168\.(\d+)\.(\d+)$", ip)
|
||||
assert m, f"unexpected LAN IP format: {ip!r}"
|
||||
o3, o4 = int(m.group(1)), int(m.group(2))
|
||||
assert 1 <= o3 <= 254
|
||||
assert 1 <= o4 <= 254
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_lan_ip_deterministic_per_seed():
|
||||
# LI2
|
||||
a = translate_profile_to_prefs(generate_profile(seed=42))["zoom.stealth.webrtc.host_ip"]
|
||||
b = translate_profile_to_prefs(generate_profile(seed=42))["zoom.stealth.webrtc.host_ip"]
|
||||
assert a == b
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_lan_ip_seed_zero_has_no_zero_octets():
|
||||
# LI3: code adds +1 so neither dynamic octet should ever be 0.
|
||||
p = generate_profile(seed=0)
|
||||
prefs = translate_profile_to_prefs(p)
|
||||
ip = prefs["zoom.stealth.webrtc.host_ip"]
|
||||
octets = ip.split(".")
|
||||
assert octets[0] == "192"
|
||||
assert octets[1] == "168"
|
||||
assert int(octets[2]) >= 1
|
||||
assert int(octets[3]) >= 1
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Linux-specific tests — exercise the branches that only fire when
|
||||
# ``sys.platform.startswith("linux")``. Patched via ``monkeypatch`` so
|
||||
# these run on any host CI environment.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_font_metrics_linux_prepends_generic_factors(monkeypatch):
|
||||
# FM1: Linux prepends the GTK/DejaVu compensation block to the
|
||||
# per-font metrics string sampled from the profile.
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
out = _font_metrics_for_platform("Arial|1.0,Verdana|0.9,")
|
||||
assert out.startswith(_LINUX_GENERIC_FONT_FACTORS)
|
||||
assert out.endswith("Arial|1.0,Verdana|0.9,")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_font_metrics_linux_empty_input_returns_empty(monkeypatch):
|
||||
# FM1b: even on Linux, empty profile metrics short-circuits before
|
||||
# the prepend so we never emit a metrics pref containing only the
|
||||
# generic block (which would surface as a tampering signal).
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
assert _font_metrics_for_platform("") == ""
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_font_metrics_linux2_variant_uses_linux_branch(monkeypatch):
|
||||
# FM1c: ``sys.platform`` can be ``linux2`` on older Pythons / odd
|
||||
# WSL builds. ``startswith("linux")`` accepts both.
|
||||
monkeypatch.setattr(sys, "platform", "linux2")
|
||||
out = _font_metrics_for_platform("Verdana|0.9,")
|
||||
assert out.startswith(_LINUX_GENERIC_FONT_FACTORS)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_gpu_renderer_set_from_profile_on_linux(monkeypatch):
|
||||
# PG1: on Linux we spoof to the profile's Windows-ANGLE renderer
|
||||
# string so cross-platform sessions present a consistent Windows GPU.
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
p = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(p)
|
||||
assert prefs["zoom.stealth.webgl.renderer"] == p.gpu.renderer
|
||||
assert prefs["zoom.stealth.webgl.vendor"] == p.gpu.vendor
|
||||
assert prefs["zoom.stealth.webgl.renderer"] # non-empty
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_msaa_from_profile_on_linux(monkeypatch):
|
||||
# PG3: on Linux, MSAA comes from the profile's sampled value rather
|
||||
# than being pinned to 4 (which is the Windows ANGLE default).
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
p = generate_profile(seed=42, pin={"webgl.msaa_samples": 8})
|
||||
prefs = translate_profile_to_prefs(p)
|
||||
assert prefs["zoom.stealth.webgl.msaa"] == 8
|
||||
assert prefs["webgl.msaa-samples"] == 8
|
||||
assert prefs["webgl.msaa-force"] is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_msaa_zero_disables_force_on_linux(monkeypatch):
|
||||
# PG3b: MSAA=0 means "no MSAA" so ``webgl.msaa-force`` must be False.
|
||||
# Verifies the ``> 0`` guard on the force flag.
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
p = generate_profile(seed=42, pin={"webgl.msaa_samples": 0})
|
||||
prefs = translate_profile_to_prefs(p)
|
||||
assert prefs["zoom.stealth.webgl.msaa"] == 0
|
||||
assert prefs["webgl.msaa-force"] is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_canvas_noise_mask_intel_on_linux(monkeypatch):
|
||||
# CN1: Intel renderer → 1/16 noise (mask=15). Pinning the renderer
|
||||
# exercises the live ``_renderer_lo`` branch on Linux (where the
|
||||
# value is read from the profile rather than hardcoded as on Windows).
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
p = generate_profile(
|
||||
seed=42,
|
||||
pin={
|
||||
"gpu.renderer": "ANGLE (Intel, Intel(R) UHD Graphics 630 Direct3D11 vs_5_0 ps_5_0, D3D11)",
|
||||
"gpu.vendor": "Google Inc. (Intel)",
|
||||
},
|
||||
)
|
||||
prefs = translate_profile_to_prefs(p)
|
||||
assert prefs["zoom.stealth.canvas.noise_skip_mask"] == 15
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_canvas_noise_mask_nvidia_on_linux(monkeypatch):
|
||||
# CN2: NVIDIA/AMD renderer → 1/8 noise (mask=7). The "intel" substring
|
||||
# check must NOT match here.
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
p = generate_profile(
|
||||
seed=42,
|
||||
pin={
|
||||
"gpu.renderer": "ANGLE (NVIDIA, NVIDIA GeForce RTX 4090 Direct3D11 vs_5_0 ps_5_0, D3D11)",
|
||||
"gpu.vendor": "Google Inc. (NVIDIA)",
|
||||
},
|
||||
)
|
||||
prefs = translate_profile_to_prefs(p)
|
||||
assert prefs["zoom.stealth.canvas.noise_skip_mask"] == 7
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_webgl_extensions_preserved_on_linux(monkeypatch):
|
||||
# WE1: on Linux the curated WebGL1/2 extension lists from _BASELINE
|
||||
# remain in the prefs dict so the patched binary publishes them
|
||||
# instead of native Mesa's set.
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
p = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(p)
|
||||
assert prefs["zoom.stealth.webgl.extensions"]
|
||||
assert prefs["zoom.stealth.webgl2.extensions"]
|
||||
# Spot-check a canonical Windows ANGLE extension is in the list.
|
||||
assert "ANGLE_instanced_arrays" in prefs["zoom.stealth.webgl.extensions"]
|
||||
assert "OVR_multiview2" in prefs["zoom.stealth.webgl2.extensions"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_xvfb_workarounds_applied_on_linux(monkeypatch):
|
||||
# XW1: Linux Firefox under Xvfb can't run WebRender, so we force the
|
||||
# software path. These are added via ``setdefault`` so callers can
|
||||
# still override them via ``extra_prefs``.
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
p = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(p)
|
||||
assert prefs["gfx.webrender.all"] is False
|
||||
assert prefs["gfx.webrender.force-disabled"] is True
|
||||
assert prefs["webgl.force-enabled"] is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_xvfb_workarounds_caller_can_override(monkeypatch):
|
||||
# XW1b: the workarounds are added with ``setdefault``, so a user-
|
||||
# supplied ``extra_prefs`` value wins. Verifies the override path
|
||||
# doesn't get clobbered by the platform branch.
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
p = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(
|
||||
p, extra_prefs={"webgl.force-enabled": False}
|
||||
)
|
||||
assert prefs["webgl.force-enabled"] is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_virtual_display_no_op_on_linux(monkeypatch):
|
||||
# VD3: ``virtual_display`` is a Windows-only concept (CreateDesktop
|
||||
# alt-desktop GPU sandbox workaround). Even when True, Linux must
|
||||
# not pick up ``security.sandbox.gpu.level``.
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
p = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(p, virtual_display=True)
|
||||
assert "security.sandbox.gpu.level" not in prefs
|
||||
|
|
|
|||
348
tests/test_profile.py
Normal file
348
tests/test_profile.py
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
"""Unit tests for `_fpforge/profile.py`.
|
||||
|
||||
Covers `_validate_pin_key`, `_apply_pins_to_raw`, and `generate_profile`.
|
||||
Test cases derived via ECP/BVA/error guessing.
|
||||
"""
|
||||
from dataclasses import FrozenInstanceError
|
||||
|
||||
import pytest
|
||||
|
||||
from invisible_playwright._fpforge import generate_profile
|
||||
from invisible_playwright._fpforge.profile import (
|
||||
Profile,
|
||||
_PIN_GROUPS,
|
||||
_PIN_TO_RAW,
|
||||
_apply_pins_to_raw,
|
||||
_validate_pin_key,
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# _validate_pin_key
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_validate_pin_key_top_level_fonts():
|
||||
"""VK1 — `fonts` is a known top-level key."""
|
||||
_validate_pin_key("fonts")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_validate_pin_key_top_level_dark_theme():
|
||||
"""VK2 — `dark_theme` is a known top-level key."""
|
||||
_validate_pin_key("dark_theme")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_validate_pin_key_dotted_screen_width():
|
||||
"""VK3 — valid dotted path `screen.width`."""
|
||||
_validate_pin_key("screen.width")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_validate_pin_key_dotted_gpu_renderer():
|
||||
"""VK4 — valid dotted path `gpu.renderer`."""
|
||||
_validate_pin_key("gpu.renderer")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_validate_pin_key_dotted_webgl_msaa_samples():
|
||||
"""VK5 — valid dotted path `webgl.msaa_samples`."""
|
||||
_validate_pin_key("webgl.msaa_samples")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_validate_pin_key_no_dot_not_top_level_raises():
|
||||
"""VK6 — bare key not in top-level set raises with hint."""
|
||||
with pytest.raises(ValueError, match="group.field"):
|
||||
_validate_pin_key("bogus")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_validate_pin_key_unknown_group_raises():
|
||||
"""VK7 — unknown group prefix."""
|
||||
with pytest.raises(ValueError, match="unknown group"):
|
||||
_validate_pin_key("network.port")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_validate_pin_key_unknown_field_in_valid_group_raises():
|
||||
"""VK8 — known group, unknown field."""
|
||||
with pytest.raises(ValueError, match="unknown field"):
|
||||
_validate_pin_key("screen.brightness")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_validate_pin_key_empty_string_raises():
|
||||
"""VK9 — empty key fails the dotted-form check."""
|
||||
with pytest.raises(ValueError):
|
||||
_validate_pin_key("")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("group,fields", sorted(_PIN_GROUPS.items()))
|
||||
def test_validate_pin_key_all_groups_first_field(group, fields):
|
||||
"""VK10 — every defined group accepts its sorted-first field."""
|
||||
first = sorted(fields)[0]
|
||||
_validate_pin_key(f"{group}.{first}")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# _apply_pins_to_raw
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _raw_baseline():
|
||||
"""A minimal raw dict for pin tests — only the keys we care about."""
|
||||
return {
|
||||
"screen_w": 1920,
|
||||
"screen_h": 1080,
|
||||
"webgl_vendor": "Google Inc. (Intel)",
|
||||
"webgl_renderer": "ANGLE (Intel)",
|
||||
"font_whitelist": "arial,calibri",
|
||||
"dark_theme": 0,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_apply_pins_to_raw_screen_width():
|
||||
"""AP1 — `screen.width` rewrites `screen_w` in raw."""
|
||||
out = _apply_pins_to_raw(_raw_baseline(), {"screen.width": 2560})
|
||||
assert out["screen_w"] == 2560
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_apply_pins_to_raw_fonts_list():
|
||||
"""AP2 — list pin joined into comma-separated whitelist."""
|
||||
out = _apply_pins_to_raw(_raw_baseline(), {"fonts": ["Arial", "Verdana"]})
|
||||
assert out["font_whitelist"] == "Arial,Verdana"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_apply_pins_to_raw_fonts_tuple():
|
||||
"""AP3 — tuple pin is also accepted."""
|
||||
out = _apply_pins_to_raw(_raw_baseline(), {"fonts": ("Arial",)})
|
||||
assert out["font_whitelist"] == "Arial"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_apply_pins_to_raw_fonts_string_raises():
|
||||
"""AP4 — bare string is not a list/tuple, must raise."""
|
||||
with pytest.raises(TypeError, match="list/tuple"):
|
||||
_apply_pins_to_raw(_raw_baseline(), {"fonts": "Arial"})
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_apply_pins_to_raw_fonts_int_raises():
|
||||
"""AP5 — int is also rejected."""
|
||||
with pytest.raises(TypeError):
|
||||
_apply_pins_to_raw(_raw_baseline(), {"fonts": 42})
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_apply_pins_to_raw_multiple_pins():
|
||||
"""AP6 — multiple pins all land in raw."""
|
||||
pin = {"gpu.vendor": "X", "gpu.renderer": "Y"}
|
||||
out = _apply_pins_to_raw(_raw_baseline(), pin)
|
||||
assert out["webgl_vendor"] == "X"
|
||||
assert out["webgl_renderer"] == "Y"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_apply_pins_to_raw_returns_copy_not_mutation():
|
||||
"""AP7 — input dict is not mutated."""
|
||||
raw = _raw_baseline()
|
||||
snapshot = dict(raw)
|
||||
_apply_pins_to_raw(raw, {"screen.width": 9999})
|
||||
assert raw == snapshot
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_apply_pins_to_raw_unknown_key_silent():
|
||||
"""AP8 — key not in `_PIN_TO_RAW` (and not 'fonts') is ignored.
|
||||
|
||||
Validation happens upstream in `generate_profile`; the inner helper
|
||||
guards defensively but does not raise.
|
||||
"""
|
||||
raw = _raw_baseline()
|
||||
out = _apply_pins_to_raw(raw, {"some.unknown": 123})
|
||||
# No change to known fields
|
||||
assert out["screen_w"] == raw["screen_w"]
|
||||
# No new key added
|
||||
assert "some.unknown" not in out
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# generate_profile
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_generate_profile_happy_path():
|
||||
"""GP1 — returns a fully populated Profile."""
|
||||
p = generate_profile(seed=42)
|
||||
assert isinstance(p, Profile)
|
||||
assert p.seed == 42
|
||||
assert p.gpu.vendor
|
||||
assert p.gpu.renderer
|
||||
assert p.gpu.class_tier in _PIN_GROUPS["gpu"].union({"low_end", "mid_range",
|
||||
"high_end", "integrated_old", "integrated_modern", "workstation"})
|
||||
assert p.screen.width > 0
|
||||
assert p.screen.height > 0
|
||||
assert p.hardware.concurrency > 0
|
||||
assert p.audio.sample_rate > 0
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_generate_profile_deterministic():
|
||||
"""GP2 — same seed → identical Profile (equality on frozen dataclass)."""
|
||||
a = generate_profile(seed=42)
|
||||
b = generate_profile(seed=42)
|
||||
assert a == b
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_generate_profile_seed_float_coerced():
|
||||
"""GP3 — float seed is coerced to int (truncated)."""
|
||||
a = generate_profile(seed=42.7)
|
||||
b = generate_profile(seed=42)
|
||||
assert a == b
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_generate_profile_seed_string_coerced():
|
||||
"""GP4 — numeric string seed works via int() coercion."""
|
||||
a = generate_profile(seed="42")
|
||||
b = generate_profile(seed=42)
|
||||
assert a == b
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_generate_profile_no_pin_samples_freely():
|
||||
"""GP5 — no pin: every field is sampler-derived (sanity: 2 seeds differ)."""
|
||||
a = generate_profile(seed=1)
|
||||
b = generate_profile(seed=2)
|
||||
assert a != b
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_generate_profile_pin_overrides_screen_width():
|
||||
"""GP6 — pinned width visible on the Profile dataclass."""
|
||||
p = generate_profile(seed=42, pin={"screen.width": 9999})
|
||||
assert p.screen.width == 9999
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_generate_profile_pin_visible_in_prefs_dict():
|
||||
"""GP7 — pinned values flow through to to_prefs_dict()."""
|
||||
p = generate_profile(seed=42, pin={"screen.width": 9999})
|
||||
assert p.to_prefs_dict()["screen_w"] == 9999
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_generate_profile_invalid_pin_raises():
|
||||
"""GP8 — bad pin key surfaces ValueError from validation."""
|
||||
with pytest.raises(ValueError):
|
||||
generate_profile(seed=42, pin={"bogus": 1})
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_generate_profile_empty_pin_equals_no_pin():
|
||||
"""GP9 — empty pin dict is a no-op."""
|
||||
a = generate_profile(seed=42, pin={})
|
||||
b = generate_profile(seed=42)
|
||||
assert a == b
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_generate_profile_is_frozen():
|
||||
"""GP10 — Profile dataclass is immutable."""
|
||||
p = generate_profile(seed=42)
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
p.seed = 99 # type: ignore[misc]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_generate_profile_fonts_is_list_of_strings():
|
||||
"""GP11 — fonts is a non-empty list of stripped strings."""
|
||||
p = generate_profile(seed=42)
|
||||
assert isinstance(p.fonts, list)
|
||||
assert len(p.fonts) > 0
|
||||
assert all(isinstance(f, str) and f.strip() == f for f in p.fonts)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_generate_profile_to_prefs_dict_flat_and_matches_raw():
|
||||
"""GP12 — to_prefs_dict() returns a flat dict containing core sampler keys."""
|
||||
p = generate_profile(seed=42)
|
||||
d = p.to_prefs_dict()
|
||||
assert isinstance(d, dict)
|
||||
for key in ("screen_w", "screen_h", "webgl_vendor", "webgl_renderer",
|
||||
"hw_concurrency", "stealth_seed"):
|
||||
assert key in d
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_generate_profile_seed_zero():
|
||||
"""GP13 — seed=0 is a valid lowest-value boundary."""
|
||||
p = generate_profile(seed=0)
|
||||
assert p.seed == 0
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_generate_profile_seed_max_int31():
|
||||
"""GP14 — seed at int31 upper bound works."""
|
||||
seed = (1 << 31) - 1
|
||||
p = generate_profile(seed=seed)
|
||||
assert p.seed == seed
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_generate_profile_dark_theme_is_bool():
|
||||
"""GP15 — dark_theme is coerced to bool on the dataclass."""
|
||||
p = generate_profile(seed=42)
|
||||
assert isinstance(p.dark_theme, bool)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Additional pin coverage (recheck pass)
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_generate_profile_pin_dark_theme_true():
|
||||
"""Pinning dark_theme=True flows through coercion to bool."""
|
||||
p = generate_profile(seed=42, pin={"dark_theme": True})
|
||||
assert p.dark_theme is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_generate_profile_pin_dark_theme_false():
|
||||
p = generate_profile(seed=42, pin={"dark_theme": False})
|
||||
assert p.dark_theme is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_generate_profile_pin_fonts_list_visible_on_profile():
|
||||
"""fonts pin: list → joined raw string → split back to list on Profile."""
|
||||
p = generate_profile(seed=42, pin={"fonts": ["Arial", "Verdana"]})
|
||||
assert p.fonts == ["Arial", "Verdana"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_generate_profile_pin_gpu_renderer_propagates():
|
||||
p = generate_profile(seed=42, pin={"gpu.renderer": "FORCED_RENDERER"})
|
||||
assert p.gpu.renderer == "FORCED_RENDERER"
|
||||
assert p.to_prefs_dict()["webgl_renderer"] == "FORCED_RENDERER"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_generate_profile_pin_to_raw_keymap_complete():
|
||||
"""Every dotted pin key (besides 'fonts') has a `_PIN_TO_RAW` mapping.
|
||||
|
||||
Guards against silently-ignored pins if someone adds a key to `_PIN_GROUPS`
|
||||
but forgets the raw-key mapping.
|
||||
"""
|
||||
dotted = {f"{group}.{field}" for group, fields in _PIN_GROUPS.items()
|
||||
for field in fields}
|
||||
# 'dark_theme' is top-level and present in _PIN_TO_RAW; 'fonts' is handled
|
||||
# specially and intentionally absent.
|
||||
missing = dotted - set(_PIN_TO_RAW.keys())
|
||||
assert missing == set(), f"pin keys without raw mapping: {sorted(missing)}"
|
||||
|
|
@ -1,37 +1,119 @@
|
|||
"""configure_proxy behaviour — SOCKS goes into prefs, HTTP into Playwright kwargs."""
|
||||
"""Unit tests for `invisible_playwright._proxy.configure_proxy`.
|
||||
|
||||
Decision-table coverage of every input partition: None/empty/direct,
|
||||
SOCKS4/5/default, HTTP/HTTPS, case variants, malformed, mutation contract.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from invisible_playwright._proxy import configure_proxy
|
||||
|
||||
|
||||
def test_none_proxy_returns_none_and_leaves_prefs():
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# CP1-CP7: no-op cases — return None, do NOT mutate prefs
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_cp1_none_proxy_returns_none():
|
||||
prefs = {}
|
||||
assert configure_proxy(None, prefs) is None
|
||||
assert prefs == {}
|
||||
|
||||
|
||||
def test_empty_dict_returns_none():
|
||||
@pytest.mark.unit
|
||||
def test_cp2_empty_dict_returns_none():
|
||||
prefs = {}
|
||||
assert configure_proxy({}, prefs) is None
|
||||
assert prefs == {}
|
||||
|
||||
|
||||
def test_direct_scheme_returns_none():
|
||||
@pytest.mark.unit
|
||||
def test_cp3_empty_server_returns_none():
|
||||
prefs = {}
|
||||
out = configure_proxy({"server": "direct://"}, prefs)
|
||||
assert out is None
|
||||
assert configure_proxy({"server": ""}, prefs) is None
|
||||
assert prefs == {}
|
||||
|
||||
|
||||
def test_socks5_writes_prefs_and_returns_none():
|
||||
@pytest.mark.unit
|
||||
def test_cp4_whitespace_server_returns_none():
|
||||
prefs = {}
|
||||
out = configure_proxy(
|
||||
{"server": "socks5://gw.example.com:1080", "username": "u", "password": "p"},
|
||||
prefs,
|
||||
)
|
||||
assert out is None
|
||||
assert configure_proxy({"server": " "}, prefs) is None
|
||||
assert prefs == {}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_cp5_direct_scheme_returns_none():
|
||||
prefs = {}
|
||||
assert configure_proxy({"server": "direct://"}, prefs) is None
|
||||
assert prefs == {}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_cp6_direct_scheme_uppercase_returns_none():
|
||||
prefs = {}
|
||||
assert configure_proxy({"server": "DIRECT://"}, prefs) is None
|
||||
assert prefs == {}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_cp7_direct_scheme_mixed_case_returns_none():
|
||||
prefs = {}
|
||||
assert configure_proxy({"server": "DiReCt://"}, prefs) is None
|
||||
assert prefs == {}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# CP8-CP9: HTTP/HTTPS — passthrough (return proxy unchanged, no mutation)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_cp8_http_proxy_passthrough():
|
||||
prefs = {}
|
||||
proxy = {"server": "http://proxy:8080"}
|
||||
result = configure_proxy(proxy, prefs)
|
||||
assert result == proxy
|
||||
# No SOCKS-related mutations.
|
||||
assert "network.proxy.type" not in prefs
|
||||
assert "network.proxy.socks" not in prefs
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_cp9_https_proxy_passthrough():
|
||||
prefs = {}
|
||||
proxy = {"server": "https://proxy:8080"}
|
||||
result = configure_proxy(proxy, prefs)
|
||||
assert result == proxy
|
||||
assert "network.proxy.type" not in prefs
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_cp8b_http_with_username_password_passthrough():
|
||||
"""HTTP proxies preserve username/password for Playwright to consume."""
|
||||
prefs = {}
|
||||
proxy = {"server": "http://proxy:8080", "username": "user", "password": "pw"}
|
||||
result = configure_proxy(proxy, prefs)
|
||||
assert result == proxy
|
||||
assert "network.proxy.type" not in prefs
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# CP10-CP13: SOCKS — mutate prefs, return None
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_cp10_socks5_with_credentials():
|
||||
prefs = {}
|
||||
proxy = {
|
||||
"server": "socks5://host:1080",
|
||||
"username": "u",
|
||||
"password": "p",
|
||||
}
|
||||
result = configure_proxy(proxy, prefs)
|
||||
assert result is None
|
||||
assert prefs["network.proxy.type"] == 1
|
||||
assert prefs["network.proxy.socks"] == "gw.example.com"
|
||||
assert prefs["network.proxy.socks"] == "host"
|
||||
assert prefs["network.proxy.socks_port"] == 1080
|
||||
assert prefs["network.proxy.socks_version"] == 5
|
||||
assert prefs["network.proxy.socks_username"] == "u"
|
||||
|
|
@ -39,43 +121,146 @@ def test_socks5_writes_prefs_and_returns_none():
|
|||
assert prefs["network.proxy.socks_remote_dns"] is True
|
||||
|
||||
|
||||
def test_socks4_sets_version_4():
|
||||
@pytest.mark.unit
|
||||
def test_cp11_socks4_sets_version_4():
|
||||
prefs = {}
|
||||
configure_proxy({"server": "socks4://gw:1080"}, prefs)
|
||||
configure_proxy({"server": "socks4://host:1080"}, prefs)
|
||||
assert prefs["network.proxy.socks_version"] == 4
|
||||
|
||||
|
||||
def test_socks_without_auth_uses_empty_strings():
|
||||
@pytest.mark.unit
|
||||
def test_cp12_bare_socks_defaults_to_v5():
|
||||
prefs = {}
|
||||
configure_proxy({"server": "socks5://gw:1080"}, prefs)
|
||||
configure_proxy({"server": "socks://host:1080"}, prefs)
|
||||
assert prefs["network.proxy.socks_version"] == 5
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_cp13_socks_scheme_is_case_insensitive():
|
||||
prefs = {}
|
||||
proxy = {"server": "SOCKS5://HOST:1080"}
|
||||
result = configure_proxy(proxy, prefs)
|
||||
assert result is None
|
||||
assert prefs["network.proxy.type"] == 1
|
||||
# Host preserves case (only the scheme is case-folded).
|
||||
assert prefs["network.proxy.socks"] == "HOST"
|
||||
assert prefs["network.proxy.socks_version"] == 5
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# CP14-CP15: edge SOCKS inputs
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_cp14_socks_without_port_dropped_silently():
|
||||
prefs = {}
|
||||
result = configure_proxy({"server": "socks5://hostonly"}, prefs)
|
||||
assert result is None
|
||||
# Malformed input drops silently — no mutations.
|
||||
assert "network.proxy.type" not in prefs
|
||||
assert "network.proxy.socks" not in prefs
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_cp15_socks_without_credentials_uses_empty_strings():
|
||||
prefs = {}
|
||||
configure_proxy({"server": "socks5://host:1080"}, prefs)
|
||||
assert prefs["network.proxy.socks_username"] == ""
|
||||
assert prefs["network.proxy.socks_password"] == ""
|
||||
|
||||
|
||||
def test_http_proxy_passes_through_to_playwright():
|
||||
@pytest.mark.unit
|
||||
def test_cp15b_socks_with_none_credentials_uses_empty_strings():
|
||||
"""`proxy.get("username")` returning None should resolve to ""."""
|
||||
prefs = {}
|
||||
proxy = {"server": "http://gw.example.com:8080", "username": "u", "password": "p"}
|
||||
out = configure_proxy(proxy, prefs)
|
||||
assert out is proxy
|
||||
configure_proxy(
|
||||
{"server": "socks5://host:1080", "username": None, "password": None},
|
||||
prefs,
|
||||
)
|
||||
assert prefs["network.proxy.socks_username"] == ""
|
||||
assert prefs["network.proxy.socks_password"] == ""
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# CP16: mutation contract — prefs dict mutated in-place
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_cp16_prefs_mutated_in_place():
|
||||
"""Caller's prefs dict receives the SOCKS keys directly (not a copy)."""
|
||||
prefs = {"existing.pref": "kept"}
|
||||
sentinel = prefs
|
||||
configure_proxy({"server": "socks5://host:1080"}, prefs)
|
||||
# Same object identity — mutated, not replaced.
|
||||
assert prefs is sentinel
|
||||
# Existing pref preserved.
|
||||
assert prefs["existing.pref"] == "kept"
|
||||
# SOCKS keys added.
|
||||
assert "network.proxy.type" in prefs
|
||||
assert "network.proxy.socks" in prefs
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# CP17: boundary — IPv6-style host preserved via rsplit
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_cp17_ipv6_bracketed_host_preserved_via_rsplit():
|
||||
"""rsplit(':', 1) keeps brackets intact for `[::1]:1080`-style hosts."""
|
||||
prefs = {}
|
||||
configure_proxy({"server": "socks5://[::1]:1080"}, prefs)
|
||||
assert prefs["network.proxy.socks"] == "[::1]"
|
||||
assert prefs["network.proxy.socks_port"] == 1080
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Recheck additions — branches discovered while re-reading _proxy.py
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_socks_with_surrounding_whitespace_in_server_stripped():
|
||||
"""The implementation strips whitespace before scheme checks."""
|
||||
prefs = {}
|
||||
result = configure_proxy({"server": " socks5://host:1080 "}, prefs)
|
||||
assert result is None
|
||||
assert prefs["network.proxy.socks"] == "host"
|
||||
assert prefs["network.proxy.socks_port"] == 1080
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_server_key_missing_returns_none():
|
||||
"""No 'server' key → treated as empty → no-op."""
|
||||
prefs = {}
|
||||
result = configure_proxy({"username": "u"}, prefs)
|
||||
assert result is None
|
||||
assert prefs == {}
|
||||
|
||||
|
||||
def test_https_proxy_passes_through():
|
||||
@pytest.mark.unit
|
||||
def test_server_key_none_returns_none():
|
||||
"""`server: None` is normalized to "" by the implementation."""
|
||||
prefs = {}
|
||||
out = configure_proxy({"server": "https://gw:8443"}, prefs)
|
||||
assert out is not None
|
||||
result = configure_proxy({"server": None}, prefs)
|
||||
assert result is None
|
||||
assert prefs == {}
|
||||
|
||||
|
||||
def test_malformed_socks_url_drops_silently():
|
||||
@pytest.mark.unit
|
||||
def test_socks_port_coerced_to_int():
|
||||
"""Port string is parsed via int() — not a numeric string."""
|
||||
prefs = {}
|
||||
out = configure_proxy({"server": "socks5://no-port-here"}, prefs)
|
||||
assert out is None
|
||||
assert prefs == {}
|
||||
configure_proxy({"server": "socks5://host:443"}, prefs)
|
||||
assert prefs["network.proxy.socks_port"] == 443
|
||||
assert isinstance(prefs["network.proxy.socks_port"], int)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scheme", ["socks5://", "SOCKS5://", "Socks5://"])
|
||||
def test_socks_scheme_is_case_insensitive(scheme):
|
||||
@pytest.mark.unit
|
||||
def test_socks_non_numeric_port_raises_value_error():
|
||||
"""Non-numeric port is a programmer error — int() raises."""
|
||||
prefs = {}
|
||||
configure_proxy({"server": f"{scheme}gw:1080"}, prefs)
|
||||
assert prefs["network.proxy.socks"] == "gw"
|
||||
with pytest.raises(ValueError):
|
||||
configure_proxy({"server": "socks5://host:notaport"}, prefs)
|
||||
|
|
|
|||
378
tests/test_sampler.py
Normal file
378
tests/test_sampler.py
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
"""Unit tests for invisible_playwright._fpforge._sampler.
|
||||
|
||||
Covers classify_gpu (decision-table over GPU strings), _screen_tier,
|
||||
derive_font_prefs / derive_font_whitelist, and the public Forge / sample
|
||||
entry points.
|
||||
"""
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from invisible_playwright._fpforge import _sampler
|
||||
from invisible_playwright._fpforge._sampler import (
|
||||
Forge,
|
||||
_LOCKED,
|
||||
_screen_tier,
|
||||
classify_gpu,
|
||||
derive_font_prefs,
|
||||
derive_font_whitelist,
|
||||
sample,
|
||||
)
|
||||
|
||||
|
||||
# ── classify_gpu ────────────────────────────────────────────────────────
|
||||
#
|
||||
# Decision-table tests against every branch of the classifier. Inputs use
|
||||
# the ANGLE renderer string format that Firefox actually exposes.
|
||||
|
||||
def _gpu(renderer):
|
||||
return {"renderer": renderer, "vendor": "Google Inc."}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("renderer", [
|
||||
"ANGLE (Intel, Intel(R) HD Graphics 3000 Direct3D11 vs_5_0 ps_5_0)",
|
||||
"ANGLE (Intel, Intel(R) HD Graphics 4000 Direct3D11 vs_5_0 ps_5_0)",
|
||||
"ANGLE (Intel, Intel(R) HD Graphics 2500 Direct3D11 vs_5_0 ps_5_0)",
|
||||
])
|
||||
def test_classify_gpu_intel_hd_old_buckets(renderer):
|
||||
"""CG1-CG3 [DT]: HD 2500/3000/4000 → integrated_old."""
|
||||
assert classify_gpu(_gpu(renderer)) == "integrated_old"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("renderer", [
|
||||
"ANGLE (Intel, Intel(R) HD Graphics 530 Direct3D11)",
|
||||
"ANGLE (Intel, Intel(R) UHD Graphics 630 Direct3D11)",
|
||||
"ANGLE (Intel, Intel(R) Iris Xe Graphics Direct3D11)",
|
||||
"ANGLE (Intel, Intel(R) Arc A750 Direct3D11)",
|
||||
])
|
||||
def test_classify_gpu_intel_modern(renderer):
|
||||
"""CG4-CG7 [DT]: modern Intel HD/UHD/Iris/Arc → integrated_modern."""
|
||||
assert classify_gpu(_gpu(renderer)) == "integrated_modern"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("renderer", [
|
||||
"ANGLE (AMD, AMD Radeon Graphics Direct3D11)",
|
||||
"ANGLE (AMD, AMD Radeon Vega 8 Direct3D11)",
|
||||
])
|
||||
def test_classify_gpu_amd_integrated(renderer):
|
||||
"""CG8-CG9 [DT]: AMD APU graphics → integrated_modern."""
|
||||
assert classify_gpu(_gpu(renderer)) == "integrated_modern"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("renderer", [
|
||||
"ANGLE (NVIDIA, NVIDIA GeForce 8800 GTX Direct3D11)",
|
||||
"ANGLE (NVIDIA, NVIDIA GeForce GTX 480 Direct3D11)",
|
||||
"ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11)",
|
||||
"ANGLE (NVIDIA, NVIDIA GeForce GT 1030 Direct3D11)",
|
||||
])
|
||||
def test_classify_gpu_nvidia_vintage_buckets(renderer):
|
||||
"""CG10-CG13 [DT]: vintage GeForce buckets → low_end."""
|
||||
assert classify_gpu(_gpu(renderer)) == "low_end"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_classify_gpu_nvidia_modern_geforce_falls_to_low_end():
|
||||
"""CG14 [DT]: GeForce GTX 1060 — sanitized vintage → low_end via fallback."""
|
||||
assert classify_gpu(_gpu(
|
||||
"ANGLE (NVIDIA, NVIDIA GeForce GTX 1060 Direct3D11)"
|
||||
)) == "low_end"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_classify_gpu_nvidia_quadro_k_matches_vintage_pattern():
|
||||
"""CG15 [DT]: Quadro K2200 → low_end (matches vintage Quadro K pattern)."""
|
||||
assert classify_gpu(_gpu(
|
||||
"ANGLE (NVIDIA, NVIDIA Quadro K2200 Direct3D11)"
|
||||
)) == "low_end"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_classify_gpu_amd_radeon_high_end_boundary():
|
||||
"""CG16 [DT]: AMD Radeon RX 5700 XT (n=5700) → high_end."""
|
||||
assert classify_gpu(_gpu(
|
||||
"ANGLE (AMD, AMD Radeon RX 5700 XT Direct3D11)"
|
||||
)) == "high_end"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("renderer", [
|
||||
"ANGLE (AMD, AMD Radeon RX 5500 Direct3D11)",
|
||||
"ANGLE (AMD, AMD Radeon RX 580 Direct3D11)",
|
||||
])
|
||||
def test_classify_gpu_amd_radeon_mid_range(renderer):
|
||||
"""CG17-CG18 [DT]: RX 5500 / RX 580 → mid_range."""
|
||||
assert classify_gpu(_gpu(renderer)) == "mid_range"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_classify_gpu_amd_radeon_below_mid_range():
|
||||
"""CG19 [DT]: RX 480 (n=480, not in mid_range buckets) → low_end."""
|
||||
assert classify_gpu(_gpu(
|
||||
"ANGLE (AMD, AMD Radeon RX 480 Direct3D11)"
|
||||
)) == "low_end"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_classify_gpu_amd_firepro_falls_through_to_fallback():
|
||||
"""CG20 [DT]: AMD FirePro W7100 — workstation regex requires
|
||||
'Radeon' prefix, FirePro alone doesn't match → falls through to
|
||||
mid_range fallback. (Plan claimed workstation; actual code path
|
||||
only routes Radeon-Pro-prefixed cards into the workstation bucket.)
|
||||
"""
|
||||
assert classify_gpu(_gpu(
|
||||
"ANGLE (AMD, AMD FirePro W7100 Direct3D11)"
|
||||
)) == "mid_range"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_classify_gpu_amd_radeon_pro_workstation():
|
||||
"""CG21 [DT]: AMD Radeon Pro WX 7100 → workstation."""
|
||||
assert classify_gpu(_gpu(
|
||||
"ANGLE (AMD, AMD Radeon Pro WX 7100 Direct3D11)"
|
||||
)) == "workstation"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_classify_gpu_unknown_renderer_falls_back_to_mid_range():
|
||||
"""CG22 [DT]: completely unknown vendor/renderer → mid_range fallback."""
|
||||
assert classify_gpu(_gpu(
|
||||
"ANGLE (Unknown, Something Else Direct3D11)"
|
||||
)) == "mid_range"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_classify_gpu_empty_renderer_falls_back_to_mid_range():
|
||||
"""CG23 [BVA]: empty renderer string → mid_range fallback."""
|
||||
assert classify_gpu({"renderer": "", "vendor": ""}) == "mid_range"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("renderer", [
|
||||
"ANGLE (AMD, AMD Radeon RX 5699 Direct3D11)", # CG24: just below 5700
|
||||
"ANGLE (AMD, AMD Radeon RX 5601 Direct3D11)", # CG25: just above 5600
|
||||
"ANGLE (AMD, AMD Radeon RX 579 Direct3D11)", # CG26: just below 580
|
||||
"ANGLE (AMD, AMD Radeon RX 591 Direct3D11)", # CG27: just above 590
|
||||
])
|
||||
def test_classify_gpu_amd_radeon_boundary_values_outside_mid_range(renderer):
|
||||
"""CG24-CG27 [BVA]: AMD Radeon numbers just outside mid_range buckets → low_end."""
|
||||
assert classify_gpu(_gpu(renderer)) == "low_end"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_classify_gpu_missing_renderer_key_uses_empty_default():
|
||||
"""CG28 [ERR]: dict without 'renderer' key → mid_range fallback (r='')."""
|
||||
assert classify_gpu({"vendor": "X"}) == "mid_range"
|
||||
|
||||
|
||||
# ── _screen_tier ────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("w,h,expected", [
|
||||
(1920, 1080, "1080p"), # ST1 [ECP]
|
||||
(2560, 1440, "1440p"), # ST2 [ECP]
|
||||
(3840, 2160, "2160p"), # ST3 [ECP]
|
||||
(3440, 1440, "ultrawide"), # ST4 [ECP] aspect 2.39 > 2.1
|
||||
(1921, 1080, "1440p"), # ST5 [BVA] just above 1920
|
||||
(2561, 1440, "2160p"), # ST6 [BVA] just above 2560
|
||||
(3841, 2160, "ultrawide"), # ST7 [BVA] just above 3840
|
||||
(1280, 720, "1080p"), # ST8 [BVA] below 1920
|
||||
])
|
||||
def test_screen_tier_classification(w, h, expected):
|
||||
assert _screen_tier({"screen": {"w": w, "h": h}}) == expected
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_screen_tier_empty_context_defaults_to_1080p():
|
||||
"""ST9 [ERR]: empty ctx → defaults w=1920, h=1080 → 1080p."""
|
||||
assert _screen_tier({}) == "1080p"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_screen_tier_4200x2000_is_ultrawide_via_width_branch():
|
||||
"""ST10 [BVA]: w=4200,h=2000 — ratio 2.1 is NOT >2.1 (strict), but
|
||||
w>3840 also routes to the final ultrawide branch."""
|
||||
assert _screen_tier({"screen": {"w": 4200, "h": 2000}}) == "ultrawide"
|
||||
|
||||
|
||||
# ── derive_font_prefs / derive_font_whitelist ───────────────────────────
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_derive_font_prefs_returns_whitelist_and_metrics_keys():
|
||||
"""FP1 [HAPPY]: result has the two expected string keys."""
|
||||
out = derive_font_prefs("integrated_modern", random.Random(42))
|
||||
assert set(out.keys()) == {"whitelist", "metrics"}
|
||||
assert isinstance(out["whitelist"], str)
|
||||
assert isinstance(out["metrics"], str)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_derive_font_prefs_core_fonts_always_present():
|
||||
"""FP2 [ECP]: every core font name appears in whitelist regardless of class."""
|
||||
out = derive_font_prefs("integrated_old", random.Random(0))
|
||||
names = set(out["whitelist"].split(","))
|
||||
for entry in _sampler._FONT_CORE:
|
||||
assert entry["name"] in names
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_derive_font_prefs_deterministic_per_seed():
|
||||
"""FP3 [ECP]: same gpu_class + same rng seed → identical result."""
|
||||
a = derive_font_prefs("workstation", random.Random(7))
|
||||
b = derive_font_prefs("workstation", random.Random(7))
|
||||
assert a == b
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_derive_font_prefs_unknown_class_falls_back_to_integrated_modern():
|
||||
"""FP4 [ECP]: gpu_class missing from CPT → uses integrated_modern row."""
|
||||
fallback = derive_font_prefs("nonexistent", random.Random(123))
|
||||
expected = derive_font_prefs("integrated_modern", random.Random(123))
|
||||
assert fallback == expected
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_derive_font_prefs_metrics_and_whitelist_are_coherent():
|
||||
"""FP5 [ECP]: every name in whitelist has a metrics entry and vice versa."""
|
||||
out = derive_font_prefs("mid_range", random.Random(99))
|
||||
wl_names = out["whitelist"].split(",")
|
||||
metrics_names = [s.split("|", 1)[0] for s in out["metrics"].split(",")]
|
||||
assert wl_names == metrics_names
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_derive_font_prefs_whitelist_alphabetically_sorted():
|
||||
"""FP6 [ECP]: whitelist names are sorted (ordering invariant for stable dedup)."""
|
||||
out = derive_font_prefs("high_end", random.Random(5))
|
||||
names = out["whitelist"].split(",")
|
||||
assert names == sorted(names)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_derive_font_whitelist_legacy_shim_matches_dict_form():
|
||||
"""FW1 [HAPPY]: legacy shim returns same string as dict['whitelist']."""
|
||||
rng_a = random.Random(11)
|
||||
rng_b = random.Random(11)
|
||||
assert derive_font_whitelist("low_end", rng_a) == \
|
||||
derive_font_prefs("low_end", rng_b)["whitelist"]
|
||||
|
||||
|
||||
# ── Forge / sample ──────────────────────────────────────────────────────
|
||||
|
||||
# Keys the Forge.sample bundle must always contain. Builds on _LOCKED +
|
||||
# every Bayesian-sampled field exposed in the return dict.
|
||||
_EXPECTED_KEYS = {
|
||||
"stealth_seed",
|
||||
*_LOCKED.keys(),
|
||||
"webgl_renderer", "webgl_vendor", "gpu_class",
|
||||
"intra_tier", "screen_tier",
|
||||
"screen_w", "screen_h", "screen_avail_w", "screen_avail_h", "dpr",
|
||||
"hw_concurrency", "msaa_samples",
|
||||
"audio_sample_rate", "audio_output_latency_ms", "audio_max_channel_count",
|
||||
"av1_enabled", "webm_encoder_enabled",
|
||||
"mediasource_webm", "mediasource_mp4", "webspeech_synth",
|
||||
"storage_quota_mb", "dark_theme",
|
||||
"font_whitelist", "font_metrics",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_forge_sample_returns_dict():
|
||||
"""FS1 [HAPPY]: sample(42) returns a non-empty dict."""
|
||||
out = sample(42)
|
||||
assert isinstance(out, dict) and out
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_forge_sample_has_every_expected_key():
|
||||
"""FS2 [ECP]: every locked + sampled key is present in the bundle."""
|
||||
out = sample(42)
|
||||
missing = _EXPECTED_KEYS - set(out.keys())
|
||||
assert not missing, f"missing keys: {missing}"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_forge_sample_field_types():
|
||||
"""FS3 [ECP]: int/float/bool fields have the right Python types."""
|
||||
out = sample(42)
|
||||
assert isinstance(out["screen_w"], int)
|
||||
assert isinstance(out["screen_h"], int)
|
||||
assert isinstance(out["dpr"], float)
|
||||
assert isinstance(out["hw_concurrency"], int)
|
||||
assert isinstance(out["webdriver"], bool)
|
||||
assert isinstance(out["av1_enabled"], bool)
|
||||
assert isinstance(out["max_touch_points"], int)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_forge_sample_deterministic_per_seed():
|
||||
"""FS4 [ECP]: same seed → identical bundle."""
|
||||
assert sample(42) == sample(42)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_forge_sample_varies_across_seeds():
|
||||
"""FS5 [ECP]: distinct seeds → at least one varying field across N seeds."""
|
||||
bundles = [sample(s) for s in range(8)]
|
||||
renderers = {b["webgl_renderer"] for b in bundles}
|
||||
assert len(renderers) > 1
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_forge_sample_locked_identity_fields_match_locked_table():
|
||||
"""FS6 [ECP]: every field in _LOCKED is echoed verbatim in the bundle."""
|
||||
out = sample(42)
|
||||
for k, v in _LOCKED.items():
|
||||
assert out[k] == v
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_forge_constructor_equivalent_to_sample_helper():
|
||||
"""FS7 [ECP]: Forge(seed).sample() == sample(seed)."""
|
||||
assert Forge(42).sample() == sample(42)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_forge_sample_avail_h_defaults_to_h_minus_40_when_missing(monkeypatch):
|
||||
"""FS8 [ECP]: when a screen entry has no 'ah' key, screen_avail_h
|
||||
defaults to screen_h - 40. Real CPT data always provides 'ah', so
|
||||
we monkeypatch the network to return a synthetic bundle."""
|
||||
fake_bundle = {
|
||||
"gpu": {"renderer": "ANGLE (Intel, Intel(R) UHD Graphics 630 Direct3D11)",
|
||||
"vendor": "Google Inc."},
|
||||
"gpu_class": "integrated_modern",
|
||||
"intra_tier": "standard",
|
||||
"screen": {"w": 1920, "h": 1080, "dpr": 1.0}, # no aw, no ah
|
||||
"screen_tier": "1080p",
|
||||
"hw_concurrency": 8,
|
||||
"msaa_samples": 4,
|
||||
"codec": {"av1_enabled": True, "webm_encoder_enabled": True,
|
||||
"mediasource_webm": True, "mediasource_mp4": True,
|
||||
"webspeech_synth": True},
|
||||
"storage_quota_mb": 256000,
|
||||
"audio": {"rate": 48000, "latency": 20, "channels": 2},
|
||||
"dark_theme": 0,
|
||||
}
|
||||
monkeypatch.setattr(_sampler._NETWORK, "sample", lambda _rng: fake_bundle)
|
||||
out = Forge(42).sample()
|
||||
assert out["screen_avail_w"] == 1920 # falls back to w
|
||||
assert out["screen_avail_h"] == 1080 - 40
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_forge_sample_includes_font_keys():
|
||||
"""FS9 [ECP]: font_whitelist + font_metrics present and non-empty."""
|
||||
out = sample(42)
|
||||
assert out["font_whitelist"]
|
||||
assert out["font_metrics"]
|
||||
assert "," in out["font_whitelist"] # at least the core fonts joined
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_forge_seed_coercion_to_int():
|
||||
"""FS extra: Forge(seed) coerces seed to int (e.g. float 42.7 → 42)."""
|
||||
f = Forge(42.7)
|
||||
assert f.seed == 42
|
||||
Loading…
Add table
Add a link
Reference in a new issue