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.
This commit is contained in:
@@ -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"
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
+29
-56
@@ -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
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user