mirror of
https://github.com/feder-cr/invisible_playwright.git
synced 2026-06-13 08:55:12 +02:00
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:
parent
e524695088
commit
c2103ed0db
6 changed files with 271 additions and 147 deletions
20
.github/workflows/release.yml
vendored
20
.github/workflows/release.yml
vendored
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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})"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
106
tests/test_cloak.py
Normal file
106
tests/test_cloak.py
Normal file
|
|
@ -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."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue