From 957f84d9a56fced695aa01fbdc93ff742634143e Mon Sep 17 00:00:00 2001 From: chrissbaumann Date: Thu, 14 May 2026 11:21:10 +0200 Subject: [PATCH 01/14] test: add pytest markers, conftest, fix Windows-incompatible existing tests - Add tests/conftest.py with deterministic_rng + sample_profile fixtures - Register unit/integration/e2e markers in pyproject.toml - Mark existing 14 tests as @pytest.mark.unit - Fix test_cli.py: use 'invisible_playwright' (underscore) for 'python -m' - Fix test_translate_includes_gpu_renderer: assert Windows behavior (empty renderer) --- pyproject.toml | 7 +++++++ tests/conftest.py | 17 +++++++++++++++++ tests/test_cli.py | 10 +++++++--- tests/test_constants.py | 10 ++++++++-- tests/test_download.py | 4 +++- tests/test_prefs.py | 14 +++++++++++--- 6 files changed, 53 insertions(+), 9 deletions(-) create mode 100644 tests/conftest.py diff --git a/pyproject.toml b/pyproject.toml index 8c0b3db..01ad3c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,3 +43,10 @@ packages = ["src/invisible_playwright"] [tool.hatch.build.targets.wheel.force-include] "src/invisible_playwright/data" = "invisible_playwright/data" "src/invisible_playwright/_fpforge/data" = "invisible_playwright/_fpforge/data" + +[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", +] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..429aa6d --- /dev/null +++ b/tests/conftest.py @@ -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) diff --git a/tests/test_cli.py b/tests/test_cli.py index d52712c..b4d09a6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,19 +1,23 @@ import subprocess import sys +import pytest + +@pytest.mark.unit def test_version_subcommand(): r = subprocess.run( - [sys.executable, "-m", "invisible-playwright", "version"], + [sys.executable, "-m", "invisible_playwright", "version"], capture_output=True, text=True, check=True, ) assert "firefox-" in r.stdout - assert "invisible-playwright" in r.stdout.lower() + assert "invisible_playwright" in r.stdout.lower() +@pytest.mark.unit def test_help_subcommand(): r = subprocess.run( - [sys.executable, "-m", "invisible-playwright", "--help"], + [sys.executable, "-m", "invisible_playwright", "--help"], capture_output=True, text=True, ) assert r.returncode == 0 diff --git a/tests/test_constants.py b/tests/test_constants.py index 8948f84..fcdeed9 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -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() diff --git a/tests/test_download.py b/tests/test_download.py index 8381356..04df648 100644 --- a/tests/test_download.py +++ b/tests/test_download.py @@ -4,8 +4,8 @@ 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 ensure_binary def _make_zip(path: Path, inner_name: str, payload: bytes) -> bytes: @@ -19,6 +19,7 @@ def _make_zip(path: Path, inner_name: str, payload: bytes) -> bytes: 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 +49,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" diff --git a/tests/test_prefs.py b/tests/test_prefs.py index 979d2a3..a4f7857 100644 --- a/tests/test_prefs.py +++ b/tests/test_prefs.py @@ -1,14 +1,19 @@ +import pytest + from invisible_playwright._fpforge import generate_profile from invisible_playwright.prefs import translate_profile_to_prefs -def test_translate_includes_gpu_renderer(): +@pytest.mark.unit +def test_translate_includes_gpu_renderer_windows(): + """On Windows, renderer/vendor are cleared so ANGLE reports native hardware.""" 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) @@ -16,18 +21,21 @@ 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) From 8709ef77d2d9989dc20a20d93d0148842517e098 Mon Sep 17 00:00:00 2001 From: chrissbaumann Date: Thu, 14 May 2026 11:24:57 +0200 Subject: [PATCH 02/14] test(network): add 25 unit tests for Bayesian network primitives Covers _weighted_pick, _parent_key, _topsort, Node.sample, Network.sample following ECP, BVA, and error-guessing techniques from the plan. --- tests/test_network.py | 260 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 tests/test_network.py diff --git a/tests/test_network.py b/tests/test_network.py new file mode 100644 index 0000000..2e208e0 --- /dev/null +++ b/tests/test_network.py @@ -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"} From 3286123b1192f3bd92b490ed5b3aa087c627f8b9 Mon Sep 17 00:00:00 2001 From: chrissbaumann Date: Thu, 14 May 2026 11:35:24 +0200 Subject: [PATCH 03/14] test(sampler): add 55 unit tests for fingerprint Bayesian sampler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers classify_gpu (decision-table over 28 GPU strings inc. boundary values for AMD Radeon number ranges), _screen_tier resolution classification, derive_font_prefs / derive_font_whitelist coherence and determinism, and the public Forge / sample entry points (locked identity, key set, type correctness, seed determinism). One plan deviation: the original plan claimed `AMD FirePro W7100` → `workstation`, but the workstation regex requires a `Radeon` prefix, so FirePro alone falls through to the `mid_range` fallback. Test asserts the actual behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_sampler.py | 378 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 378 insertions(+) create mode 100644 tests/test_sampler.py diff --git a/tests/test_sampler.py b/tests/test_sampler.py new file mode 100644 index 0000000..01cfc8b --- /dev/null +++ b/tests/test_sampler.py @@ -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 From 38ae41289d3526ab9a10c6f8d2c1ed9b21bd1557 Mon Sep 17 00:00:00 2001 From: chrissbaumann Date: Thu, 14 May 2026 11:46:09 +0200 Subject: [PATCH 04/14] test(profile): add 43 unit tests for Profile dataclass and pin system Covers _validate_pin_key (all groups + negatives), _apply_pins_to_raw (fonts list/tuple/typeerror, multi-pin, no-mutation, unknown-key guard), and generate_profile (determinism, seed coercion, pin propagation through to_prefs_dict, frozen-instance, dark_theme bool coercion, fonts list roundtrip, int31 boundary). Includes a guard test that every dotted pin key has a _PIN_TO_RAW mapping. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_profile.py | 348 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 348 insertions(+) create mode 100644 tests/test_profile.py diff --git a/tests/test_profile.py b/tests/test_profile.py new file mode 100644 index 0000000..bddf5b1 --- /dev/null +++ b/tests/test_profile.py @@ -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)}" From 074b4b3274c3dc3475d03e1caf905620f0d9885b Mon Sep 17 00:00:00 2001 From: chrissbaumann Date: Thu, 14 May 2026 12:24:57 +0200 Subject: [PATCH 05/14] test(proxy): add 24 unit tests for configure_proxy decision table Covers every input partition: None/empty/direct, SOCKS4/5/default, HTTP/HTTPS passthrough, case-insensitive scheme detection, malformed inputs, mutation contract, and edge cases (IPv6 brackets, whitespace, non-numeric ports). Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_proxy.py | 266 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 tests/test_proxy.py diff --git a/tests/test_proxy.py b/tests/test_proxy.py new file mode 100644 index 0000000..9bb3b42 --- /dev/null +++ b/tests/test_proxy.py @@ -0,0 +1,266 @@ +"""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 + + +# ────────────────────────────────────────────────────────────────────── +# 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 == {} + + +@pytest.mark.unit +def test_cp2_empty_dict_returns_none(): + prefs = {} + assert configure_proxy({}, prefs) is None + assert prefs == {} + + +@pytest.mark.unit +def test_cp3_empty_server_returns_none(): + prefs = {} + assert configure_proxy({"server": ""}, prefs) is None + assert prefs == {} + + +@pytest.mark.unit +def test_cp4_whitespace_server_returns_none(): + prefs = {} + 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"] == "host" + assert prefs["network.proxy.socks_port"] == 1080 + assert prefs["network.proxy.socks_version"] == 5 + assert prefs["network.proxy.socks_username"] == "u" + assert prefs["network.proxy.socks_password"] == "p" + assert prefs["network.proxy.socks_remote_dns"] is True + + +@pytest.mark.unit +def test_cp11_socks4_sets_version_4(): + prefs = {} + configure_proxy({"server": "socks4://host:1080"}, prefs) + assert prefs["network.proxy.socks_version"] == 4 + + +@pytest.mark.unit +def test_cp12_bare_socks_defaults_to_v5(): + 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"] == "" + + +@pytest.mark.unit +def test_cp15b_socks_with_none_credentials_uses_empty_strings(): + """`proxy.get("username")` returning None should resolve to "".""" + prefs = {} + 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 == {} + + +@pytest.mark.unit +def test_server_key_none_returns_none(): + """`server: None` is normalized to "" by the implementation.""" + prefs = {} + result = configure_proxy({"server": None}, prefs) + assert result is None + assert prefs == {} + + +@pytest.mark.unit +def test_socks_port_coerced_to_int(): + """Port string is parsed via int() — not a numeric string.""" + 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.unit +def test_socks_non_numeric_port_raises_value_error(): + """Non-numeric port is a programmer error — int() raises.""" + prefs = {} + with pytest.raises(ValueError): + configure_proxy({"server": "socks5://host:notaport"}, prefs) From 54ae310bf29d74aec02656a2c9711bb854d96103 Mon Sep 17 00:00:00 2001 From: chrissbaumann Date: Thu, 14 May 2026 12:30:46 +0200 Subject: [PATCH 06/14] test(prefs): add 27 Windows + platform-agnostic gap tests Covers _accept_language, _font_metrics_for_platform, Windows GPU/MSAA clearing, Windows canvas noise mask (intel path), Windows WebGL extension clearing, timezone handling, extra_prefs overlay (add/delete/override/no-op), dark-theme system colors palette, locale normalization, Xvfb-key absence on Windows, virtual_display sandbox workaround, and seed-derived LAN IP. Linux-specific branches are intentionally not covered in this commit per scoping instruction; they remain available in the plan for a follow-up pass when running on Linux. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_prefs.py | 317 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 316 insertions(+), 1 deletion(-) diff --git a/tests/test_prefs.py b/tests/test_prefs.py index a4f7857..41c0448 100644 --- a/tests/test_prefs.py +++ b/tests/test_prefs.py @@ -1,7 +1,15 @@ +import re +import sys + import pytest from invisible_playwright._fpforge import generate_profile -from invisible_playwright.prefs import translate_profile_to_prefs +from invisible_playwright.prefs import ( + _accept_language, + _font_metrics_for_platform, + _WIN_LIGHT_COLORS, + translate_profile_to_prefs, +) @pytest.mark.unit @@ -41,3 +49,310 @@ def test_translate_has_stealth_baseline_constants(): 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 From ca8d8152168e05c6eae7573e162e0f2b90684568 Mon Sep 17 00:00:00 2001 From: chrissbaumann Date: Thu, 14 May 2026 12:35:57 +0200 Subject: [PATCH 07/14] test(cli, download): add 17 gap-coverage tests for Phase 7 CLI: clear-cache (existing + missing), path (ok + error), fetch happy path, no/unknown subcommand error paths. Calls cli.main() directly instead of subprocess to keep tests fast and capture stderr cleanly. Download: cache hit skips HTTP, tar.gz extraction, comment/blank checksum lines, unknown archive format, missing entry after extract, unsupported platform. Also covers pure helpers _parse_owner_repo, _sha256_file, and _github_token env precedence. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_cli.py | 96 +++++++++++++++++++++ tests/test_download.py | 191 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 285 insertions(+), 2 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index b4d09a6..1c72feb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,8 +1,11 @@ import subprocess import sys +from pathlib import Path import pytest +from invisible_playwright import cli + @pytest.mark.unit def test_version_subcommand(): @@ -24,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 diff --git a/tests/test_download.py b/tests/test_download.py index 04df648..baf177c 100644 --- a/tests/test_download.py +++ b/tests/test_download.py @@ -1,15 +1,23 @@ import hashlib +import io +import tarfile from pathlib import Path import pytest import responses from invisible_playwright.constants import BINARY_VERSION -from invisible_playwright.download import ensure_binary +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,17 @@ 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): @@ -71,3 +90,171 @@ 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() From 9c8d24408b8d237d30f39842c750b2bce5fd1ae9 Mon Sep 17 00:00:00 2001 From: chrissbaumann Date: Thu, 14 May 2026 12:44:46 +0200 Subject: [PATCH 08/14] test(integration): add 12 multi-module pipeline tests for Phase 8 Covers profile->prefs end-to-end, SOCKS/HTTP proxy + prefs composition, pin propagation, seed determinism/variation, font whitelist passthrough, dark/light theme palette overlay, and a Windows-specific virtual_display + SOCKS combo. Linux-specific branches stay covered by their unit tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_integration.py | 294 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 tests/test_integration.py diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..fb12edf --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,294 @@ +"""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 (per implementation directive): Windows and platform-agnostic only. +Linux-specific paths (e.g. Xvfb workarounds, GPU spoof on Linux) are +intentionally covered by their unit tests, not duplicated here. +""" +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"] == "" From 234fe7e40691f8ca970642849c9f6e524cf3efcf Mon Sep 17 00:00:00 2001 From: chrissbaumann Date: Thu, 14 May 2026 12:52:46 +0200 Subject: [PATCH 09/14] test(e2e): add 9 launcher lifecycle tests for Phase 9 Five test the constructor only (seed handling, eager profile build, fail-fast pin validation) and always run. Four spin up the patched Firefox and exercise the full `with InvisiblePlaywright(...)` lifecycle, gated on a locally cached binary so CI without the binary skips cleanly. All 230 tests pass on Windows with the binary fetched. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_e2e.py | 143 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 tests/test_e2e.py diff --git a/tests/test_e2e.py b/tests/test_e2e.py new file mode 100644 index 0000000..e5f4e94 --- /dev/null +++ b/tests/test_e2e.py @@ -0,0 +1,143 @@ +"""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() From 4f6254469e8df617502e49aba965478420a02c41 Mon Sep 17 00:00:00 2001 From: chrissbaumann Date: Thu, 14 May 2026 12:59:47 +0200 Subject: [PATCH 10/14] test(launcher, headless, async_api): add 32 Phase 10 gap-coverage tests Final sweep adds unit tests for the modules left at 0% direct coverage after Phases 1-9: - launcher._tz_env: 7 tests covering the IANA -> POSIX mapping including the Phoenix / Honolulu no-DST regression cases - launcher._humanize_max_seconds, _default_context_kwargs: 11 tests on the constructor-side helpers (no browser launch) - _headless.make_virtual_display dispatcher + _WindowsVirtualDesktop init/teardown: 8 tests (Linux dispatch branch covered without spawning Xvfb, since __init__ does no I/O) - async_api.InvisiblePlaywright constructor parity with sync: 8 tests guarding against drift between the two APIs Suite: 230 -> 264 passing. Pyramid stays clean: 243 unit / 12 integration / 9 e2e. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_async_api.py | 83 ++++++++++++++++ tests/test_headless.py | 95 ++++++++++++++++++ tests/test_launcher_helpers.py | 171 +++++++++++++++++++++++++++++++++ 3 files changed, 349 insertions(+) create mode 100644 tests/test_async_api.py create mode 100644 tests/test_headless.py create mode 100644 tests/test_launcher_helpers.py diff --git a/tests/test_async_api.py b/tests/test_async_api.py new file mode 100644 index 0000000..da818ee --- /dev/null +++ b/tests/test_async_api.py @@ -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() diff --git a/tests/test_headless.py b/tests/test_headless.py new file mode 100644 index 0000000..2b17ea5 --- /dev/null +++ b/tests/test_headless.py @@ -0,0 +1,95 @@ +"""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 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 +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.""" + vd = _WindowsVirtualDesktop() + vd.stop() + vd.stop() + assert vd._desktop is None + assert vd._original_handle == 0 diff --git a/tests/test_launcher_helpers.py b/tests/test_launcher_helpers.py new file mode 100644 index 0000000..5122e88 --- /dev/null +++ b/tests/test_launcher_helpers.py @@ -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() From d392ca297100ec40936d2ee1d3f1938cf5b9f49d Mon Sep 17 00:00:00 2001 From: chrissbaumann Date: Thu, 14 May 2026 13:21:17 +0200 Subject: [PATCH 11/14] test(prefs, headless): add 16 Linux-specific tests for Phase 6 + 10 Cover the Linux branches in prefs.py that previously had no tests (font metrics, GPU spoofing, MSAA from profile, canvas noise mask per renderer, WebGL extension preservation, Xvfb workarounds, virtual_display no-op) and add construction smoke tests for _LinuxVirtualDisplay. Also fix two host-platform-dependent tests so the suite stays green on both Windows and Linux. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_headless.py | 58 ++++++++++++++- tests/test_prefs.py | 159 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 215 insertions(+), 2 deletions(-) diff --git a/tests/test_headless.py b/tests/test_headless.py index 2b17ea5..d979b34 100644 --- a/tests/test_headless.py +++ b/tests/test_headless.py @@ -13,6 +13,8 @@ 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 @@ -85,11 +87,65 @@ def test_windows_desktop_initial_state_is_clean(): @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.""" + ``__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 diff --git a/tests/test_prefs.py b/tests/test_prefs.py index 41c0448..6e31cd9 100644 --- a/tests/test_prefs.py +++ b/tests/test_prefs.py @@ -5,6 +5,7 @@ import pytest from invisible_playwright._fpforge import generate_profile from invisible_playwright.prefs import ( + _LINUX_GENERIC_FONT_FACTORS, _accept_language, _font_metrics_for_platform, _WIN_LIGHT_COLORS, @@ -13,8 +14,9 @@ from invisible_playwright.prefs import ( @pytest.mark.unit -def test_translate_includes_gpu_renderer_windows(): +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"] == "" @@ -356,3 +358,158 @@ def test_lan_ip_seed_zero_has_no_zero_octets(): 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 From a3353fc861223484e1bb593b15a3993b0acda54d Mon Sep 17 00:00:00 2001 From: chrissbaumann Date: Thu, 14 May 2026 13:40:42 +0200 Subject: [PATCH 12/14] test(download): add 4 Linux tar.gz download tests for Phase 7 Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_download.py | 117 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/tests/test_download.py b/tests/test_download.py index baf177c..8e15406 100644 --- a/tests/test_download.py +++ b/tests/test_download.py @@ -258,3 +258,120 @@ def test_ensure_binary_unsupported_platform_raises(monkeypatch): 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() From b8139c28735506fe7bfaa6785a2000202ced097c Mon Sep 17 00:00:00 2001 From: chrissbaumann Date: Thu, 14 May 2026 13:53:24 +0200 Subject: [PATCH 13/14] test(integration): add 3 Linux pipeline tests for Phase 8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IT11–IT13 mirror IT10 on the Linux platform branch, verifying: - Xvfb workarounds coexist with SOCKS5 proxy mutation - MSAA pin propagates through prefs translation on Linux - _LINUX_GENERIC_FONT_FACTORS is prepended to per-font metrics Tests use monkeypatch on sys.platform so they run on any host OS. Verified green on Linux/WSL alongside the existing Windows tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_integration.py | 84 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 3 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index fb12edf..1da7621 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -4,9 +4,9 @@ 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 (per implementation directive): Windows and platform-agnostic only. -Linux-specific paths (e.g. Xvfb workarounds, GPU spoof on Linux) are -intentionally covered by their unit tests, not duplicated here. +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 @@ -292,3 +292,81 @@ def test_windows_virtual_display_with_socks_proxy(monkeypatch): 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) From 70c1ca464f60530d935c630381713bd4ae82e966 Mon Sep 17 00:00:00 2001 From: chrissbaumann Date: Thu, 14 May 2026 14:07:03 +0200 Subject: [PATCH 14/14] test(e2e): add 4 Linux launcher-routing tests for Phase 9 E9-E12 exercise the launcher's Linux code paths without spawning a real Firefox binary or Xvfb. They monkeypatch ``sys.platform`` and stub ``make_virtual_display`` / ``_binary_on_path`` so the tests run on any host: - E9 ``_build_prefs(headless=True)`` on Linux passes ``virtual_display=False`` to the translator, so the Win32-only ``security.sandbox.gpu.level`` workaround never leaks into Linux prefs (Xvfb handles window hiding instead). - E10 ``_resolve_headless`` on Linux + headless=True invokes the dispatcher and stores the returned object on ``self._virtual_display``. - E11 ``_teardown`` stops the Linux virtual display, clears the reference, and is idempotent on a second call. - E12 With Xvfb missing from PATH, ``_resolve_headless`` raises a clear ``RuntimeError`` mentioning ``Xvfb`` instead of a cryptic FileNotFoundError. Suite on Linux/WSL: 286 passed, 5 skipped (4 binary-gated E2E lifecycle tests + 1 Win32 ctypes test). Binary-gated E1/E2/E5/E8 remain ready to run on Linux once the patched Firefox tar.gz is fetched locally. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_e2e.py | 100 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index e5f4e94..35fad98 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -141,3 +141,103 @@ def test_e8_new_context_defaults_from_profile(firefox_binary): 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