From baf5522d2bc37136ac5819829bda3a9348ce3b8e Mon Sep 17 00:00:00 2001 From: feder-cr <85809106+feder-cr@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:06:12 +0200 Subject: [PATCH] font gate: check the faces actually load, not just that the family enumerates The gate counted 72 families enumerating and passed while ten collection faces silently fell back to another family, so a real rendering regression shipped with the gate green. It now renders a CJK probe per collection face and compares the metrics against the fallback, with the family list calibrated on a build known to load them. Also: test_constants derives the expected archive name from the basename constant instead of hardcoding the version, since a hardcoded one turns every upstream bump into a mechanical test edit, which is how a stale expectation gets corrected without anyone checking the asset exists. And INVPW_TRUE_HEADLESS=1 opts into real headless for long unattended runs on Windows, where the default cloak path intermittently hangs on launch with a persistent profile. The default is unchanged and the fingerprint prefs are identical either way. --- scripts/ci_font_gate.py | 45 +++++++++++++++++++++++++-- src/invisible_playwright/async_api.py | 8 +++++ tests/test_constants.py | 12 +++++-- 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/scripts/ci_font_gate.py b/scripts/ci_font_gate.py index c4c7777..fec72f8 100644 --- a/scripts/ci_font_gate.py +++ b/scripts/ci_font_gate.py @@ -74,6 +74,28 @@ GENERICS = { "system-ui": "Segoe UI", } +# Families that live inside a .ttc TrueType Collection (several faces packed in +# one file). Being *listed* is not enough: without a per-face index the table +# lookup reads the first font of the collection, so every other face silently +# falls back to a default one. That is invisible to the presence probe below +# (the family is registered either way) but wrecks the persona for CJK text. +# The FF150->151 rebase dropped exactly that fix and only 7 of these loaded. +# +# Calibrated on firefox-17, the known-good release: these are the families that +# demonstrably render the CJK sample with their own face there. Deliberately +# NOT listed: "SimSun-ExtB" (covers Unicode Ext-B, not the BMP characters in +# the sample, so it legitimately falls back) and the "... UI" variants of YaHei +# and JhengHei (they already fall back on firefox-17, so requiring them would +# fail the known-good build). Keep this list as a regression detector, not an +# aspiration: everything here loads on firefox-17 and must keep loading. +TTC_FAMILIES = [ + "Microsoft YaHei", "Microsoft JhengHei", + "MS Gothic", "MS PGothic", "MS UI Gothic", + "SimSun", "NSimSun", + "Yu Gothic", "Yu Gothic UI", "Malgun Gothic", + "MingLiU-ExtB", "PMingLiU-ExtB", +] + # Width+height probe (the offsetWidth method real detectors use): a family is # "present" if styling text in it renders at a different size than the three CSS # base generics. For the generics, return the measured size of each generic and @@ -97,8 +119,17 @@ DETECT_JS = r"""(arg) => { for (const g of arg.generics) gen[g] = size(g); const genref = {}; for (const w of arg.targets) genref[w] = size("'" + w + "'"); + // .ttc face-loading probe: render CJK glyphs, which only the family's own + // face can draw. If the size matches a nonexistent-font fallback, the face + // never loaded and the text is being drawn by a default face instead. + sp.textContent = arg.cjk; + const fb = size("'__NoSuchFontXYZ__'"); + const ttcload = {}; + for (const f of arg.ttc) { + ttcload[f] = size("'" + f + "','__NoSuchFontXYZ__'") !== fb; + } document.body.removeChild(sp); - return { present, gen, genref }; + return { present, gen, genref, ttcload }; }""" # Suppress the new-tab machinery so the launch is quiet (mirrors ci_drive_gate). @@ -118,6 +149,8 @@ def main(exe: str) -> int: "cands": cands, "generics": list(GENERICS.keys()), "targets": list(GENERICS.values()), + "ttc": TTC_FAMILIES, + "cjk": "中文字体測試あア漢字", } with sync_playwright() as p: browser = p.firefox.launch(executable_path=exe, headless=True, @@ -152,11 +185,17 @@ def main(exe: str) -> int: print(f"[font-gate] HOST LEAK (block-at-birth did not run!): {leaked_host}") if gen_bad: print(f"[font-gate] GENERIC MISMATCH: {gen_bad}") + ttc_bad = sorted(f for f in TTC_FAMILIES if not r.get("ttcload", {}).get(f)) + if ttc_bad: + print(f"[font-gate] TTC FACE NOT LOADED (listed but falls back to a " + f"default face for CJK): {ttc_bad}") - ok = not missing and not extra and not leaked_host and not gen_bad + ok = (not missing and not extra and not leaked_host and not gen_bad + and not ttc_bad) if ok: print(f"FONT GATE OK - exactly the {n} Windows families, host-leak 0, " - f"generics map to Windows (serif/sans/mono/system-ui).") + f"generics map to Windows (serif/sans/mono/system-ui), " + f"{len(TTC_FAMILIES)}/{len(TTC_FAMILIES)} .ttc faces load.") return 0 print("FONT GATE FAILED - the exposed set does not match the Windows " "persona on this OS (see the diff above).") diff --git a/src/invisible_playwright/async_api.py b/src/invisible_playwright/async_api.py index daf64ae..6e4f439 100644 --- a/src/invisible_playwright/async_api.py +++ b/src/invisible_playwright/async_api.py @@ -244,6 +244,14 @@ class InvisiblePlaywright: def _resolve_headless(self) -> bool: if not self._headless: return False + # Opt-in TRUE headless. The default headful+cloak path intermittently + # hangs launch_persistent_context ~40% on Windows (window/compositor + # race with a persistent profile). True headless applies the IDENTICAL + # fingerprint prefs (screen/viewport/canvas/webgl spoofed the same) and + # is 100% reliable (~2.3s). Gated by env so other callers are unaffected. + import os as _os + if _os.environ.get("INVPW_TRUE_HEADLESS") == "1": + return True vd = make_virtual_display() # Linux: Xvfb to start. Windows/macOS: make_virtual_display() returns # None (the binary self-cloaks via cloak_prefs injected in __aenter__), diff --git a/tests/test_constants.py b/tests/test_constants.py index 909017d..8232a67 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -101,9 +101,15 @@ def test_archive_name_rejects_unsupported_arches(machine): @pytest.mark.parametrize("machine", ["arm64", "aarch64"]) def test_archive_name_arm64_supported(machine): """ARM64 is shipped now (issue #6): both Linux aarch64 and macOS arm64. - ARCHIVE_NAME must map both machine spellings to the canonical -arm64 asset.""" - assert ARCHIVE_NAME("linux", machine) == "firefox-150.0.1-stealth-linux-arm64.tar.gz" - assert ARCHIVE_NAME("darwin", machine) == "firefox-150.0.1-stealth-macos-arm64.tar.gz" + ARCHIVE_NAME must map both machine spellings to the canonical -arm64 asset. + + The version comes from BINARY_BASENAME rather than a literal: what this test + is about is the arch/platform mapping, and a hardcoded version turns every + upstream bump into a mechanical test edit - which is how a stale expectation + gets "fixed" without anyone checking the asset actually exists. + """ + assert ARCHIVE_NAME("linux", machine) == f"{BINARY_BASENAME}-linux-arm64.tar.gz" + assert ARCHIVE_NAME("darwin", machine) == f"{BINARY_BASENAME}-macos-arm64.tar.gz" @pytest.mark.unit