feat: run the search gate before each deploy

This commit is contained in:
Abdessamad Derraz committed 2026-10-23 18:58:54 +02:00
1 parent e1d35c3cb4
commit a93e9ee406
3 files changed
+123 -15

No files matched your search

+10
View File
@@ -134,6 +134,16 @@ jobs:
npm run build --prefix website npm run build --prefix website
python scripts/validate_site.py --site-dir website/dist python scripts/validate_site.py --site-dir website/dist
# The queries a visitor types must reach the page that answers them,
# read through the site's own search box, and the home page must stay
# under its weight budget. A miss blocks the deploy. The browser driver
# is not a dependency of the build: pinned here, not saved to the
# lockfile, and it drives the Chrome the runner image ships.
- name: Search gate
run: |
npm install --no-save --prefix website playwright-core@1.58.0
python website/tools/search_gate.py --site-dir website/dist
# write_if_changed() compares content with its timestamp line stripped, # write_if_changed() compares content with its timestamp line stripped,
# so a run that only moves the clock leaves the file untouched and this # so a run that only moves the clock leaves the file untouched and this
# stays a real staleness check rather than a guaranteed failure. # stays a real staleness check rather than a guaranteed failure.
+9 -2
View File
@@ -2,10 +2,17 @@
// node measure.mjs weight <base url> <page>... // node measure.mjs weight <base url> <page>...
// node measure.mjs search <base url> starlight|mkdocs <query>... // node measure.mjs search <base url> starlight|mkdocs <query>...
// Transfer sizes are bytes on the wire (gzip from serve.mjs), by type. // Transfer sizes are bytes on the wire (gzip from serve.mjs), by type.
import { chromium } from 'playwright-core'; // playwright-core is resolved from website/node_modules, above this file,
// wherever the command is run from. It is not a dependency of the build:
// npm install --no-save --prefix website playwright-core
const { chromium } = await import('playwright-core').catch((error) => {
console.error(`playwright-core is not in website/node_modules (${error.code}): `
+ 'npm install --no-save --prefix website playwright-core');
process.exit(2);
});
const [mode, base, ...rest] = process.argv.slice(2); const [mode, base, ...rest] = process.argv.slice(2);
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' }); const browser = await chromium.launch({ executablePath: process.env.CHROME_PATH || '/usr/bin/google-chrome' });
const track = (page) => { const track = (page) => {
const rows = []; const rows = [];
+104 -13
View File
@@ -3,19 +3,36 @@
Runs measure.mjs against a served build, through the site's own search box, Runs measure.mjs against a served build, through the site's own search box,
and checks each query's expected pages against the rank it must reach: the and checks each query's expected pages against the rank it must reach: the
first result, or the first three. first result, or the first three. Exits 1 when a query misses.
python website/tools/search_gate.py --site-dir website/dist
python website/tools/search_gate.py http://127.0.0.1:4321/retrobios/ python website/tools/search_gate.py http://127.0.0.1:4321/retrobios/
With --site-dir the build is served by serve.mjs (as GitHub Pages serves it)
on a free port for the run. The browser driver is playwright-core in
website/node_modules (npm install --no-save --prefix website playwright-core)
and Chrome at /usr/bin/google-chrome, or CHROME_PATH.
""" """
from __future__ import annotations from __future__ import annotations
import argparse
import json import json
import socket
import subprocess import subprocess
import sys import sys
import time
import urllib.error
import urllib.request
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path from pathlib import Path
TOOLS = Path(__file__).resolve().parent TOOLS = Path(__file__).resolve().parent
BASE = "/retrobios/"
# What the home page transfers on a phone, everything it loads before a
# visitor does anything (gzip, as GitHub Pages serves it).
HOME_BUDGET_KB = 80
# Query: (pages that answer it, as path prefixes under the site, and the rank one # Query: (pages that answer it, as path prefixes under the site, and the rank one
# of them must reach). An emulator name must find the emulator itself first, # of them must reach). An emulator name must find the emulator itself first,
@@ -29,23 +46,38 @@ QUERIES: dict[str, tuple[tuple[str, ...], int]] = {
# The file's card on its system page, where it is downloaded. "systems/sony" # The file's card on its system page, where it is downloaded. "systems/sony"
# holds the manufacturer page and the console's own (systems/sony-playstation/). # holds the manufacturer page and the console's own (systems/sony-playstation/).
"scph5501": (("systems/sony",), 3), "scph5501": (("systems/sony",), 3),
"md5": (("wiki/verification-modes/",), 1), # How a setup checks a hash, or the page that checks one.
"md5": (("wiki/verification-modes/", "files/"), 1),
"where do bios files go": (("which-pack/", "wiki/first-install/"), 1), "where do bios files go": (("which-pack/", "wiki/first-install/"), 1),
"dolphin": (("emulators/dolphin/",), 1), "dolphin": (("emulators/dolphin/",), 1),
"duckstation": (("emulators/duckstation/",), 1), "duckstation": (("emulators/duckstation/",), 1),
# A console and "bios": the console's page, before any setup's list for it. # A console and "bios": the console's own page first, before any setup's
"playstation bios": (("systems/sony",), 3), # list for it or a part of its manufacturer's page.
"playstation bios": (("systems/sony-playstation/",), 1),
"dreamcast bios": (("systems/sega",), 3), "dreamcast bios": (("systems/sega",), 3),
"neo geo bios": (("systems/snk",), 3), "neo geo bios": (("systems/snk",), 3),
"wonderswan color": (("systems/bandai",), 3), "wonderswan color": (("systems/bandai",), 3),
# The platforms' own names for a console (psx, dc, gba), a search field # The platforms' own names for a console (psx, dc, gba), a search field
# of its page. # of its page. Flycast's page names dc_boot.bin and where it goes.
"psx bios": (("systems/sony",), 3), "psx bios": (("systems/sony",), 3),
"dc bios missing": (("systems/sega",), 3), "dc bios missing": (("systems/sega", "emulators/flycast/"), 3),
"gba bios": (("systems/nintendo",), 3), "gba bios": (("systems/nintendo",), 3),
# The words an emulator prints, and the page that answers them.
"bios not found": (("wiki/troubleshooting/",), 3), "bios not found": (("wiki/troubleshooting/",), 3),
"please insert playstation cd-rom": (("wiki/troubleshooting/",), 3),
"offline install": (("wiki/installer/",), 3), "offline install": (("wiki/installer/",), 3),
"which mame version": (("wiki/technical-faq/",), 3), # The table of versions, or the FAQ answer headed with the question,
# which links it.
"which mame version": (("wiki/technical-faq/", "wiki/faq/"), 3),
# Checking a dump is what the Find a file page does.
"check my dump": (("files/",), 1),
"md5 check": (("files/",), 1),
# A tool author's words: the data reference or the installer's.
"manifest": (("data/", "wiki/installer/"), 3),
"events": (("data/", "wiki/installer/"), 3),
"exit code": (("data/", "wiki/installer/"), 3),
"rate limit": (("data/", "wiki/installer/"), 3),
"ios": (("wiki/faq/",), 3),
# A device name: the guide of the handhelds that run it. # A device name: the guide of the handhelds that run it.
"r36s": (("platforms/", "which-pack/"), 3), "r36s": (("platforms/", "which-pack/"), 3),
"steam deck": (("platforms/emudeck/", "platforms/retrodeck/", "which-pack/"), 1), "steam deck": (("platforms/emudeck/", "platforms/retrodeck/", "which-pack/"), 1),
@@ -53,13 +85,44 @@ QUERIES: dict[str, tuple[tuple[str, ...], int]] = {
} }
def main() -> int: def _free_port() -> int:
base = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:4321/retrobios/" with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
return int(probe.getsockname()[1])
@contextmanager
def served(site_dir: Path) -> Iterator[str]:
"""The build served by serve.mjs on a free port, for the run."""
port = _free_port()
server = subprocess.Popen(["node", str(TOOLS / "serve.mjs"), str(site_dir), str(port)])
url = f"http://127.0.0.1:{port}{BASE}"
try:
for _ in range(100):
try:
with urllib.request.urlopen(url, timeout=2):
break
except (urllib.error.URLError, ConnectionError):
if server.poll() is not None:
raise RuntimeError(f"serve.mjs exited with {server.returncode}") from None
time.sleep(0.1)
else:
raise RuntimeError(f"serve.mjs did not answer on {url}")
yield url
finally:
server.terminate()
server.wait(timeout=10)
def run_gate(base: str) -> int:
root = "/" + base.split("/", 3)[3] root = "/" + base.split("/", 3)[3]
run = subprocess.run( run = subprocess.run(
["node", str(TOOLS / "measure.mjs"), "search", base, "starlight-ui", *QUERIES], ["node", str(TOOLS / "measure.mjs"), "search", base, "starlight-ui", *QUERIES],
capture_output=True, text=True, check=True, capture_output=True, text=True, check=False,
) )
if run.returncode:
print(run.stderr.strip() or f"measure.mjs exited with {run.returncode}", file=sys.stderr)
return 1
answered = 0 answered = 0
for line in run.stdout.splitlines(): for line in run.stdout.splitlines():
result = json.loads(line) result = json.loads(line)
@@ -70,10 +133,38 @@ def main() -> int:
top = [entry.split(" | ") for entry in result["top"][:3]] top = [entry.split(" | ") for entry in result["top"][:3]]
hit = any(href[len(root):].startswith(pages) for _title, href in top[:rank]) hit = any(href[len(root):].startswith(pages) for _title, href in top[:rank])
answered += hit answered += hit
titles = "; ".join(title for title, _href in top) shown = "; ".join(f"{title} ({href[len(root):] or '/'})" for title, href in top)
print(f"{'ok ' if hit else 'MISS'} {result['query']!r} (top {rank}): {titles}") print(f"{'ok ' if hit else 'MISS'} {result['query']!r} (top {rank}): {shown}")
print(f"{answered}/{len(QUERIES)} queries answered") print(f"{answered}/{len(QUERIES)} queries answered")
return 0 if answered == len(QUERIES) else 1 home = home_weight(base)
print(f"{'ok ' if home <= HOME_BUDGET_KB else 'OVER'} home: {home:.1f} KB "
f"transferred at 390 px (budget {HOME_BUDGET_KB} KB)")
return 0 if answered == len(QUERIES) and home <= HOME_BUDGET_KB else 1
def home_weight(base: str) -> float:
"""The home page's transfer at 390 px, once the network is idle."""
run = subprocess.run(
["node", str(TOOLS / "measure.mjs"), "weight", base, ""],
capture_output=True, text=True, check=True,
)
return float(json.loads(run.stdout.splitlines()[0])["total_kb"])
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("url", nargs="?", default="http://127.0.0.1:4321/retrobios/",
help="a served build, under /retrobios/ (default: %(default)s)")
parser.add_argument("--site-dir", type=Path,
help="serve this build for the run instead of reading URL")
args = parser.parse_args()
if args.site_dir is None:
return run_gate(args.url)
if not (args.site_dir / "pagefind").is_dir():
print(f"{args.site_dir}: no built site with a search index", file=sys.stderr)
return 1
with served(args.site_dir) as url:
return run_gate(url)
if __name__ == "__main__": if __name__ == "__main__":