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:
feder-cr 2026-05-16 17:14:08 -07:00
commit 07701a901d
15 changed files with 3191 additions and 94 deletions

View file

@ -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
View 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
View 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()

View file

@ -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

View file

@ -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()

View file

@ -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
View 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
View 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
View 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)

View 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
View 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"}

View file

@ -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
View 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)}"

View file

@ -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
View 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