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"] == ""