invisible_playwright/scripts/ci_font_gate.py
feder-cr a0a0cc2369 scripts: ci_font_gate.py - assert the Windows font persona on every OS
Companion to ci_drive_gate.py, run by release.yml's gate job on each leg's
native runner. Launches the built binary headless, enumerates the visible font
families with the width probe web detectors use, and asserts: the detected set
equals the canonical Windows family set (identical on every OS), no known host
family (macOS Helvetica Neue/Menlo, Linux DejaVu/Ubuntu, ...) is visible, and
the CSS generics resolve to Windows fonts (serif=Times New Roman, mono=Consolas,
system-ui=Segoe UI). This is how the macOS build gets font-validated - there is
no local Mac. No proxy, no secrets.
2026-07-06 03:50:56 +02:00

170 lines
7.4 KiB
Python

#!/usr/bin/env python3
"""CI font gate - assert the patched binary exposes exactly the Windows font
persona on EVERY host OS (Windows / Linux / macOS), with zero host-font leak.
The patched binary is bundle-only: at font-list construction it drops every host
system font and exposes only the bundled Windows-11 family set (the exposed set
IS the bundle). This gate launches the binary on its NATIVE runner - so
macOS/CoreText, Linux/fontconfig and Windows/DWrite are each tested for real -
enumerates the visible families with the same width-probe web detectors use, and
asserts three things:
1. the detected family set == the canonical Windows set (EXPECTED): the SAME
set on all three platforms. A leaked host font or a missing Windows one
fails here. This is the "identical on every OS" contract.
2. no known host family is visible (macOS: Helvetica Neue / Geneva / Menlo ...;
Linux: DejaVu / Ubuntu ...) - a POSITIVE proof that block-at-birth ran for
this platform's backend, not just "no obvious tell".
3. the CSS generics resolve to Windows fonts (serif=Times New Roman,
sans-serif=Arial, monospace=Consolas) and system-ui=Segoe UI.
This is the macOS validator the local Win/Linux gate cannot be - there is no
local Mac, so CoreText is only ever exercised here. Headless, no proxy, no
secrets, loopback-free (about:blank + arrow-function evaluate, which is not
eval and carries no CSP problem) -> safe in public CI.
Usage: python ci_font_gate.py <firefox-binary>
Exit 0 + "FONT GATE OK ..." on success; non-zero + the diff on failure.
"""
from __future__ import annotations
import sys
# The canonical Windows-11 family set the bundle exposes. Verified byte-for-byte
# identical on Windows/DWrite and Linux/fontconfig; macOS/CoreText must match it
# too. Keep this sorted and in sync with the bundle (browser/fonts/moz.build).
EXPECTED = [
"Arial", "Arial Black", "Bahnschrift", "Calibri", "Cambria", "Cambria Math",
"Candara", "Comic Sans MS", "Consolas", "Constantia", "Corbel",
"Courier New", "Ebrima", "Franklin Gothic", "Franklin Gothic Medium",
"Gabriola", "Gadugi", "Georgia", "Impact", "Ink Free", "Javanese Text",
"Leelawadee", "Leelawadee UI", "Lucida Console", "Lucida Sans Unicode",
"MS Gothic", "MS PGothic", "MS UI Gothic", "MV Boli", "Malgun Gothic",
"Marlett", "Microsoft Himalaya", "Microsoft JhengHei",
"Microsoft JhengHei UI", "Microsoft New Tai Lue", "Microsoft PhagsPa",
"Microsoft Sans Serif", "Microsoft Tai Le", "Microsoft Uighur",
"Microsoft YaHei", "Microsoft YaHei UI", "Microsoft Yi Baiti",
"MingLiU-ExtB", "Mongolian Baiti", "Myanmar Text", "NSimSun", "Nirmala UI",
"PMingLiU-ExtB", "Palatino Linotype", "Segoe Fluent Icons",
"Segoe MDL2 Assets", "Segoe Print", "Segoe Script", "Segoe UI",
"Segoe UI Emoji", "Segoe UI Historic", "Segoe UI Symbol", "SimSun",
"SimSun-ExtB", "Sitka Small", "Sylfaen", "Symbol", "Tahoma",
"Times New Roman", "Trebuchet MS", "Verdana", "Webdings", "Wingdings",
"Wingdings 2", "Wingdings 3", "Yu Gothic", "Yu Gothic UI",
]
# Host families that must NEVER be visible - one per backend. Their presence is a
# hard fail (block-at-birth did not run for this OS). These are decoys added to
# the probe list; they must all come back absent.
HOST_MUST_BE_ABSENT = [
# macOS / CoreText
"Helvetica Neue", "Geneva", "Menlo", "Monaco", "Avenir", "Lucida Grande",
"Apple SD Gothic Neo", "PingFang SC",
# Linux / fontconfig
"DejaVu Sans", "Liberation Sans", "Ubuntu", "Nimbus Sans", "Noto Sans",
# Office / non-standard families intentionally dropped from the bundle
"Century Gothic", "Agency FB", "Monotype Corsiva", "Pristina",
]
# CSS generic -> the Windows family it must resolve to under bundle-only.
GENERICS = {
"serif": "Times New Roman",
"sans-serif": "Arial",
"monospace": "Consolas",
"system-ui": "Segoe UI",
}
# 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
# of its target Windows family so the caller can assert they coincide.
DETECT_JS = r"""(arg) => {
const bases = ['monospace', 'sans-serif', 'serif'];
const sample = 'mmmmmmmmmmlli WwQ 0123456789 gjpqy';
const sp = document.createElement('span');
sp.style.cssText =
'position:absolute;left:-9999px;font-size:72px;white-space:nowrap;';
sp.textContent = sample;
document.body.appendChild(sp);
const size = (ff) => { sp.style.fontFamily = ff; return sp.offsetWidth + 'x' + sp.offsetHeight; };
const bw = {};
for (const b of bases) bw[b] = size(b);
const present = {};
for (const f of arg.cands) {
present[f] = bases.some((b) => size("'" + f + "'," + b) !== bw[b]);
}
const gen = {};
for (const g of arg.generics) gen[g] = size(g);
const genref = {};
for (const w of arg.targets) genref[w] = size("'" + w + "'");
document.body.removeChild(sp);
return { present, gen, genref };
}"""
# Suppress the new-tab machinery so the launch is quiet (mirrors ci_drive_gate).
_PREFS = {
"browser.startup.page": 0,
"browser.newtabpage.enabled": False,
"browser.newtab.preload": False,
"browser.newtabpage.activity-stream.enabled": False,
}
def main(exe: str) -> int:
from playwright.sync_api import sync_playwright
cands = EXPECTED + HOST_MUST_BE_ABSENT
arg = {
"cands": cands,
"generics": list(GENERICS.keys()),
"targets": list(GENERICS.values()),
}
with sync_playwright() as p:
browser = p.firefox.launch(executable_path=exe, headless=True,
firefox_user_prefs=_PREFS)
try:
page = browser.new_page()
page.goto("about:blank")
r = page.evaluate(DETECT_JS, arg)
finally:
browser.close()
detected = {f for f, v in r["present"].items() if v}
expected = set(EXPECTED)
missing = sorted(expected - detected)
# Anything detected that isn't in EXPECTED (host leaks land here too).
extra = sorted(detected - expected)
leaked_host = [h for h in HOST_MUST_BE_ABSENT if r["present"].get(h)]
gen_bad = []
for g, want in GENERICS.items():
got, ref = r["gen"].get(g), r["genref"].get(want)
if got != ref:
gen_bad.append(f"{g} -> {got} (expected {want} = {ref})")
n = len(detected)
print(f"[font-gate] {exe}")
print(f"[font-gate] detected {n} families (expected {len(EXPECTED)})")
if missing:
print(f"[font-gate] MISSING (in bundle, not exposed): {missing}")
if extra:
print(f"[font-gate] UNEXPECTED (exposed, not in canonical set): {extra}")
if leaked_host:
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}")
ok = not missing and not extra and not leaked_host and not gen_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).")
return 0
print("FONT GATE FAILED - the exposed set does not match the Windows "
"persona on this OS (see the diff above).")
return 1
if __name__ == "__main__":
if len(sys.argv) < 2:
print("usage: python ci_font_gate.py <firefox-binary>", file=sys.stderr)
raise SystemExit(2)
raise SystemExit(main(sys.argv[1]))