From c2103ed0dbafe148c9508f83a57b37b78f58cab1 Mon Sep 17 00:00:00 2001 From: feder-cr <85809106+feder-cr@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:58:14 +0200 Subject: [PATCH] headless: cloak on Windows/macOS, Xvfb on Linux; CI cloak + webgl-masking guards headless=True now hides the window via the binary's own cloak pref (zoom.stealth.cloak_windows) on Windows and macOS instead of the broken thread-level SetThreadDesktop; macOS is now supported. Linux keeps Xvfb. Adds e2e guards that also run per-platform in the release drive-gate: - test_cloak: the window is hidden (Windows DWMWA_CLOAKED / macOS CGWindowAlpha) yet still renders + drives; the macOS leg is where the cocoa cloak patch runs. - a WebGL readPixels masking guard: the gamma noise must stay a smooth gamma remap, not the pixelscan-maskable +-1 spikes. --- .github/workflows/release.yml | 20 +++++ src/invisible_playwright/_headless.py | 120 ++++++++------------------ src/invisible_playwright/launcher.py | 21 +++-- tests/test_cloak.py | 106 +++++++++++++++++++++++ tests/test_fingerprint_surface.py | 66 ++++++++++++++ tests/test_headless.py | 85 +++++++----------- 6 files changed, 271 insertions(+), 147 deletions(-) create mode 100644 tests/test_cloak.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2e79737..968e9bb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -341,6 +341,26 @@ jobs: shell: bash run: python scripts/ci_drive_gate.py "$FF_EXE" ${{ matrix.extra }} + # CLOAK + WEBGL-MASKING GUARDS — run the wrapper's e2e cloak/gamma checks + # against THIS leg's freshly-built artifact, on its native runner. The + # wrapper's headless=True is headed+hidden (cloak on Win/macOS, its own + # Xvfb on Linux), so software-GL rendering works on the GPU-less hosts. + # test_cloak asserts the window is hidden (Windows DWMWA_CLOAKED / macOS + # CGWindowAlpha) AND still renders — the macOS leg is the only place the + # cocoa cloak patch gets RUN. The webgl guard catches a regression of the + # gamma readPixels noise back to the pixelscan-maskable ±1 spike form. + - name: Install pyobjc Quartz (macOS — to read the cloak window alpha) + if: matrix.kind == 'mac' + run: python -m pip install --quiet pyobjc-framework-Quartz + - name: Cloak + WebGL-masking guards (headed) + shell: bash + run: | + python -m pip install --quiet -e . + INVPW_BINARY_PATH="$FF_EXE" python -m pytest \ + tests/test_cloak.py \ + "tests/test_fingerprint_surface.py::test_webgl_readpixels_no_masking_signature" \ + -m e2e -o addopts='' -q + publish: name: publish-draft-release needs: [build, gate] diff --git a/src/invisible_playwright/_headless.py b/src/invisible_playwright/_headless.py index 2e4b249..3cfee09 100644 --- a/src/invisible_playwright/_headless.py +++ b/src/invisible_playwright/_headless.py @@ -2,18 +2,23 @@ Playwright's ``headless=True`` flips Firefox onto a different code path — no widget tree, software-only rendering, distinct timing — and anti-bot -systems can spot the divergence. Running the browser *headed* on a -virtual display gives us the real rendering pipeline while keeping the -windows off the user's screen. +systems can spot the divergence. Running the browser *headed* but hidden +gives us the real rendering pipeline while keeping the windows off screen. -Linux: spawns its own ``Xvfb`` instance, points ``DISPLAY`` at it. -Windows: creates a hidden desktop via ``CreateDesktop`` and binds the -calling thread to it, so Playwright's child processes inherit it. +Two mechanisms, by platform: + +- **Windows & macOS**: the patched binary cloaks its OWN chrome windows + when ``zoom.stealth.cloak_windows`` is set — ``DWMWA_CLOAK`` (Windows) + / ``NSWindow`` alpha-0 + pinned occlusion-ignore (macOS). The window + renders on the real GPU but never appears on screen, in the taskbar or + the Dock. The launcher injects the pref; nothing host-side is spawned. + +- **Linux**: spawns its own ``Xvfb`` instance and points ``DISPLAY`` at + it (X11/Wayland have no per-window cloak that keeps the GPU rendering). """ from __future__ import annotations import os -import secrets import subprocess import sys import time @@ -131,95 +136,40 @@ class _LinuxVirtualDisplay: self._display = None -class _WindowsVirtualDesktop: - """A hidden Windows desktop the calling thread is bound to. +# Windows & macOS: the patched Firefox cloaks its own chrome windows when this +# pref is set (DWMWA_CLOAK / NSWindow alpha-0 + pinned occlusion-ignore), so the +# window renders on the real GPU but never shows on screen / in the taskbar or +# Dock. window_occlusion_tracking is disabled so a hidden window keeps painting. +CLOAK_PREFS = { + "zoom.stealth.cloak_windows": True, + "widget.windows.window_occlusion_tracking.enabled": False, +} - Playwright's child processes (node driver → firefox.exe) inherit the - desktop because their ``STARTUPINFO.lpDesktop`` is NULL — Windows uses - the calling thread's desktop in that case. - pywin32 ships ``CreateDesktop`` in ``win32service`` but doesn't expose - ``SetThreadDesktop`` / ``GetThreadDesktop`` as module functions. We - call them directly via ctypes against ``user32.dll``. +def cloak_prefs() -> dict: + """Prefs that make the patched binary self-cloak its chrome windows. + + Used on Windows & macOS, where hiding is done inside the binary rather than + with a host-side virtual display. """ - - def __init__(self) -> None: - self._desktop = None # PyHDESK from win32service.CreateDesktop - self._original_handle = 0 # raw HDESK int of the previous desktop - - def start(self) -> None: - try: - import win32con # type: ignore - import win32service # type: ignore - except ImportError as e: - raise RuntimeError( - "invisible_playwright headless=True on Windows requires pywin32. " - "Install it: pip install pywin32" - ) from e - - import ctypes - from ctypes import wintypes - user32 = ctypes.windll.user32 - kernel32 = ctypes.windll.kernel32 - - # Save the current desktop handle so we can restore it on stop(). - get_thread_desktop = user32.GetThreadDesktop - get_thread_desktop.argtypes = [wintypes.DWORD] - get_thread_desktop.restype = wintypes.HANDLE - self._original_handle = get_thread_desktop(kernel32.GetCurrentThreadId()) - - name = f"sf_{secrets.token_hex(4)}" - self._desktop = win32service.CreateDesktop( - name, 0, win32con.GENERIC_ALL, None - ) - - # Bind the calling thread to the new desktop. Children spawned - # afterwards (Playwright driver → firefox.exe) inherit it because - # their STARTUPINFO.lpDesktop is NULL. - set_thread_desktop = user32.SetThreadDesktop - set_thread_desktop.argtypes = [wintypes.HANDLE] - set_thread_desktop.restype = wintypes.BOOL - if not set_thread_desktop(int(self._desktop)): - err = ctypes.get_last_error() - raise RuntimeError( - f"SetThreadDesktop failed (GetLastError={err}). " - "The thread cannot have any windows or hooks; close them first." - ) - - def stop(self) -> None: - import ctypes - from ctypes import wintypes - user32 = ctypes.windll.user32 - - if self._original_handle: - try: - set_thread_desktop = user32.SetThreadDesktop - set_thread_desktop.argtypes = [wintypes.HANDLE] - set_thread_desktop.restype = wintypes.BOOL - set_thread_desktop(self._original_handle) - except Exception: - pass - self._original_handle = 0 - - if self._desktop is not None: - try: - self._desktop.CloseDesktop() - except Exception: - pass - self._desktop = None + return dict(CLOAK_PREFS) def make_virtual_display(): - """Return a started/stoppable virtual-display object for this platform. + """Return a start()/stop()-able virtual display, or ``None`` when the + platform hides windows via the in-binary cloak pref instead. - InvisiblePlaywright supports Windows x86_64 and Linux x86_64 only. + - Linux: a fresh ``Xvfb`` (the launcher start()s/stop()s it). + - Windows / macOS: ``None`` — the binary self-cloaks via ``cloak_prefs()``, + injected by the launcher; nothing host-side needs spawning. """ - if sys.platform == "win32": - return _WindowsVirtualDesktop() if sys.platform.startswith("linux"): return _LinuxVirtualDisplay() + if sys.platform in ("win32", "darwin"): + return None raise RuntimeError( - f"invisible_playwright supports Windows and Linux only (got {sys.platform!r})" + f"invisible_playwright supports Windows, macOS and Linux " + f"(got {sys.platform!r})" ) diff --git a/src/invisible_playwright/launcher.py b/src/invisible_playwright/launcher.py index 226debf..c07238b 100644 --- a/src/invisible_playwright/launcher.py +++ b/src/invisible_playwright/launcher.py @@ -9,7 +9,7 @@ from playwright.sync_api import Browser, BrowserContext, Playwright, sync_playwr from ._fpforge import Profile, generate_profile from ._geo import prepare_session_geo -from ._headless import make_virtual_display +from ._headless import cloak_prefs, make_virtual_display from ._proxy import configure_proxy as _configure_proxy_shared from .download import ensure_binary from .prefs import translate_profile_to_prefs @@ -340,6 +340,12 @@ class InvisiblePlaywright: extra_prefs=self._extra_prefs, virtual_display=bool(self._headless and _sys.platform == "win32"), ) + # Windows & macOS hide the headless window via the binary's own cloak + # (DWMWA_CLOAK / NSWindow alpha) — inject the pref so the patched build + # cloaks its chrome windows. setdefault: an explicit user override wins. + if self._headless and _sys.platform in ("win32", "darwin"): + for _k, _v in cloak_prefs().items(): + prefs.setdefault(_k, _v) prefs["invisible_playwright.humanize"] = bool(self._humanize) if self._humanize: prefs["invisible_playwright.humanize.maxTime"] = str(self._humanize_max_seconds()) @@ -379,15 +385,18 @@ class InvisiblePlaywright: def _resolve_headless(self) -> bool: """Translate the user's ``headless`` flag. - When ``True``, we keep Firefox in headed mode (real rendering - pipeline → coherent fingerprint) and hide the windows on a fresh - Xvfb (Linux) or hidden Windows desktop. + When ``True``, Firefox stays in headed mode (real rendering pipeline → + coherent fingerprint) and the window is hidden: on Linux via a fresh + Xvfb spawned here; on Windows/macOS via the binary's own window cloak + (the ``zoom.stealth.cloak_windows`` pref added in ``_build_prefs``), so + ``make_virtual_display()`` returns ``None`` and nothing is spawned. """ if not self._headless: return False vd = make_virtual_display() - vd.start() - self._virtual_display = vd + if vd is not None: + vd.start() + self._virtual_display = vd return False def _humanize_max_seconds(self) -> float: diff --git a/tests/test_cloak.py b/tests/test_cloak.py new file mode 100644 index 0000000..bb8b5e4 --- /dev/null +++ b/tests/test_cloak.py @@ -0,0 +1,106 @@ +"""Cloak guard (e2e) — verifies the source-level "invisible headless" cloak: +the chrome window is hidden from the screen YET keeps rendering on the real GPU +(not Playwright's native headless, which has no WebGL). Runs per-platform in CI: +- Windows: the DWMWA_CLOAK attribute (queried via DWMWA_CLOAKED). +- macOS: the NSWindow alpha (queried via Quartz CGWindowListCopyWindowInfo). +- Linux: skipped — there the wrapper hides via Xvfb, not a source-level cloak. + +This is the CI validation for the macOS cocoa cloak patch, which can't be built +or run on the Windows/Linux dev boxes. +""" +from __future__ import annotations + +import sys +import time + +import pytest + +from invisible_playwright import InvisiblePlaywright + +CLOAK_PREFS = { + "zoom.stealth.cloak_windows": True, + "widget.windows.window_occlusion_tracking.enabled": False, +} + +_WEBGL_RENDERER = """() => { + const g = document.createElement('canvas').getContext('webgl'); + if (!g) return 'NO-WEBGL'; + const d = g.getExtension('WEBGL_debug_renderer_info'); + return d ? g.getParameter(d.UNMASKED_RENDERER_WEBGL) : (g.getParameter(g.RENDERER) || ''); +}""" + + +def _windows_moz_window_cloaked() -> bool: + """True if at least one MozillaWindowClass top-level window is DWMWA_CLOAKED.""" + import ctypes + from ctypes import wintypes + + user32 = ctypes.windll.user32 + dwm = ctypes.windll.dwmapi + DWMWA_CLOAKED = 14 + ENUM = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) + found = [] + + def cb(hwnd, _): + c = ctypes.create_unicode_buffer(256) + user32.GetClassNameW(hwnd, c, 256) + if c.value == "MozillaWindowClass": + v = wintypes.DWORD(0) + dwm.DwmGetWindowAttribute(wintypes.HWND(hwnd), DWMWA_CLOAKED, + ctypes.byref(v), 4) + found.append(v.value) + return True + + user32.EnumWindows(ENUM(cb), 0) + return any(state != 0 for state in found) + + +def _macos_firefox_window_alpha_zero() -> bool: + """True if a Firefox on-screen window reports ~0 alpha (cloaked).""" + from Quartz import ( # type: ignore + CGWindowListCopyWindowInfo, + kCGWindowListOptionOnScreenOnly, + kCGNullWindowID, + ) + + infos = CGWindowListCopyWindowInfo(kCGWindowListOptionOnScreenOnly, kCGNullWindowID) + alphas = [] + for w in infos or []: + owner = (w.get("kCGWindowOwnerName") or "") + if "firefox" in owner.lower() or "nightly" in owner.lower(): + alphas.append(float(w.get("kCGWindowAlpha", 1.0))) + # cloaked windows are alpha 0; if Firefox has any window it must be ~0. + return bool(alphas) and all(a < 0.05 for a in alphas) + + +@pytest.mark.e2e +@pytest.mark.skipif( + sys.platform.startswith("linux"), + reason="source-level cloak is Windows/macOS only; Linux hides via Xvfb", +) +def test_cloak_hides_window_but_keeps_rendering(firefox_binary): + with InvisiblePlaywright( + seed=42, binary_path=firefox_binary, headless=False, extra_prefs=CLOAK_PREFS + ) as browser: + page = browser.new_context().new_page() + page.goto("https://example.com", timeout=30_000) + time.sleep(2) + + # 1) still renders on the real GPU pipeline (a non-blank screenshot proves + # the compositor is alive despite the window being hidden). + shot = page.screenshot() + assert len(shot) > 3000, "cloaked window produced a blank screenshot (rendering paused)" + + # 2) real WebGL present (native headless has none) -> headed pipeline intact. + renderer = page.evaluate(_WEBGL_RENDERER) + assert renderer and renderer != "NO-WEBGL", f"no real WebGL under cloak: {renderer!r}" + + # 3) the window is actually hidden (per-platform). + if sys.platform == "win32": + assert _windows_moz_window_cloaked(), "Firefox window is not DWMWA_CLOAKED" + elif sys.platform == "darwin": + try: + hidden = _macos_firefox_window_alpha_zero() + except ImportError: + pytest.skip("pyobjc Quartz not available to verify macOS cloak alpha") + assert hidden, "Firefox macOS window is not alpha-cloaked" diff --git a/tests/test_fingerprint_surface.py b/tests/test_fingerprint_surface.py index 1de5d39..56789f9 100644 --- a/tests/test_fingerprint_surface.py +++ b/tests/test_fingerprint_surface.py @@ -236,3 +236,69 @@ def test_fpscanner_no_userAgentData_on_firefox(page): """navigator.userAgentData is Chromium-only. Presence on Firefox UA = bot.""" if "Firefox" in _ev(page, "navigator.userAgent"): assert not _ev(page, "'userAgentData' in navigator") + + +# =========================================================================== +# WebGL masking-detector guard (pixelscan getFixedRedBox / webglHash) +# +# pixelscan flags "fingerprint masking" on the WebGL readPixels output. We +# reproduce ITS probe locally (the fingerprintjs gradient triangle) and check +# the structural signature it keys on: our stealth readPixels noise MUST be a +# coherent, monotonic gamma remap (smooth, ~0 spikes), NOT isolated +-1 flips +# (which read as unnatural high-frequency noise and were flagged as masking). +# This is the CI-safe local stand-in for pixelscan's server-side check; it +# guards the gamma fix from ever silently regressing to the +-1 algorithm. +# =========================================================================== + +_WEBGL_MASKING_PROBE = """() => { + const c = document.createElement('canvas'); + const gl = c.getContext('webgl') || c.getContext('experimental-webgl'); + if (!gl) return { error: 'no-webgl' }; + const vs = 'attribute vec2 a;uniform vec2 o;varying vec2 v;' + + 'void main(){v=a+o;gl_Position=vec4(a,0,1);}'; + const fs = 'precision mediump float;varying vec2 v;' + + 'void main(){gl_FragColor=vec4(v,0,1);}'; + const buf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData(gl.ARRAY_BUFFER, + new Float32Array([-0.2,-0.9,0, 0.4,-0.26,0, 0,0.732134444,0]), gl.STATIC_DRAW); + const p = gl.createProgram(); + const s1 = gl.createShader(gl.VERTEX_SHADER); gl.shaderSource(s1, vs); gl.compileShader(s1); + const s2 = gl.createShader(gl.FRAGMENT_SHADER); gl.shaderSource(s2, fs); gl.compileShader(s2); + gl.attachShader(p, s1); gl.attachShader(p, s2); gl.linkProgram(p); gl.useProgram(p); + const loc = gl.getAttribLocation(p, 'a'); gl.enableVertexAttribArray(loc); + gl.vertexAttribPointer(loc, 3, gl.FLOAT, false, 0, 0); + const off = gl.getUniformLocation(p, 'o'); gl.uniform2f(off, 1, 1); + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 3); + const w = gl.drawingBufferWidth, h = gl.drawingBufferHeight; + const px = new Uint8Array(w * h * 4); + gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px); + // count small local extrema (|delta|<=3 to both horizontal neighbours, same + // sign) — the +-1-noise signature; a smooth/monotonic render has ~none. + let spikes = 0; + for (let y = 0; y < h; y++) { + for (let x = 1; x < w - 1; x++) { + for (let ch = 0; ch < 3; ch++) { + const i = (y * w + x) * 4 + ch; const val = px[i]; + if (val === 0) continue; + const dl = val - px[i - 4], dr = val - px[i + 4]; + if (dl * dr > 0 && Math.abs(dl) <= 3 && Math.abs(dr) <= 3) spikes++; + } + } + } + return { spikes: spikes, dims: w + 'x' + h }; +}""" + + +@pytest.mark.e2e +def test_webgl_readpixels_no_masking_signature(page): + """Stealth WebGL readPixels noise must be a coherent gamma remap (smooth), + not isolated +-1 flips. +-1 noise on the smooth gradient triangle produced + ~300+ 'spikes' and pixelscan flagged it as masking; the gamma remap leaves + the gradient smooth (~0 spikes). Regression guard for the gamma fix.""" + res = _ev(page, _WEBGL_MASKING_PROBE) + assert "error" not in res, f"WebGL probe failed: {res}" + # genuine / gamma -> ~0; the rejected +-1 algorithm produced ~320. + assert res["spikes"] < 30, ( + f"WebGL readPixels shows {res['spikes']} high-frequency noise spikes " + f"(pixelscan-maskable); the stealth noise must be a smooth gamma remap." + ) diff --git a/tests/test_headless.py b/tests/test_headless.py index d979b34..d79b921 100644 --- a/tests/test_headless.py +++ b/tests/test_headless.py @@ -1,35 +1,39 @@ -"""Unit tests for the ``_headless`` virtual-display dispatcher. +"""Unit tests for the ``_headless`` window-hider 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``. +``make_virtual_display`` is pure platform routing: +- Linux: a ``_LinuxVirtualDisplay`` (Xvfb) object the launcher start()s/stop()s. +- Windows / macOS: ``None`` — the patched binary self-cloaks its chrome windows + via ``cloak_prefs()`` (injected by the launcher), so nothing host-side spawns. +- Anything else: a clear ``RuntimeError`` naming the platform. -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. +``_LinuxVirtualDisplay`` construction does no I/O (Xvfb is only spawned in +``start()``), so it's safe to exercise on any host. """ from __future__ import annotations -import sys - import pytest import invisible_playwright._headless as headless from invisible_playwright._headless import ( + CLOAK_PREFS, _LinuxVirtualDisplay, - _WindowsVirtualDesktop, + cloak_prefs, make_virtual_display, ) @pytest.mark.unit -def test_make_virtual_display_returns_windows_desktop_on_win32(monkeypatch): +def test_make_virtual_display_returns_none_on_win32(monkeypatch): + """Windows hides via the in-binary cloak pref, not a host-side display.""" monkeypatch.setattr(headless.sys, "platform", "win32") - vd = make_virtual_display() - assert isinstance(vd, _WindowsVirtualDesktop) + assert make_virtual_display() is None + + +@pytest.mark.unit +def test_make_virtual_display_returns_none_on_darwin(monkeypatch): + """macOS is now supported — it hides via the same in-binary cloak pref.""" + monkeypatch.setattr(headless.sys, "platform", "darwin") + assert make_virtual_display() is None @pytest.mark.unit @@ -37,8 +41,7 @@ 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) + assert isinstance(make_virtual_display(), _LinuxVirtualDisplay) @pytest.mark.unit @@ -49,21 +52,10 @@ def test_make_virtual_display_accepts_linux_variants(monkeypatch): 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"): + with pytest.raises(RuntimeError, match="Windows, macOS and Linux"): make_virtual_display() @@ -77,32 +69,13 @@ def test_make_virtual_display_error_mentions_offending_platform(monkeypatch): @pytest.mark.unit -def test_windows_desktop_initial_state_is_clean(): - """Construction must not allocate Win32 resources — only ``start()`` - does. Allows users to instantiate ``InvisiblePlaywright`` without - pywin32 installed; the import error fires lazily when ``start()`` runs.""" - vd = _WindowsVirtualDesktop() - assert vd._desktop is None - assert vd._original_handle == 0 - - -@pytest.mark.unit -@pytest.mark.skipif(sys.platform != "win32", reason="exercises Win32 ctypes") -def test_windows_desktop_stop_is_idempotent_without_start(): - """``stop()`` after never calling ``start()`` must be a no-op, so - ``__exit__`` from a failed launch can call it unconditionally. - - Skipped off Windows because ``stop()`` unconditionally resolves - ``ctypes.windll.user32`` at the top of the function — that symbol - only exists on Windows. The early-return logic is safe because - callers only instantiate this class via ``make_virtual_display()`` - which already routes on ``sys.platform == 'win32'``. - """ - vd = _WindowsVirtualDesktop() - vd.stop() - vd.stop() - assert vd._desktop is None - assert vd._original_handle == 0 +def test_cloak_prefs_enables_cloak_and_disables_occlusion(): + """The cloak prefs must turn on the in-binary cloak and turn OFF Windows + occlusion tracking (so a hidden window keeps painting). Returns a copy.""" + p = cloak_prefs() + assert p["zoom.stealth.cloak_windows"] is True + assert p["widget.windows.window_occlusion_tracking.enabled"] is False + assert p == CLOAK_PREFS and p is not CLOAK_PREFS # ──────────────────────────────────────────────────────────────────────