mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-25 05:09:03 -05:00
feat: run the search gate before each deploy
This commit is contained in:
1 parent
e1d35c3cb4
commit
a93e9ee406
3 files changed
+123
-15
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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__":
|
||||||
|
|||||||
Reference in new issue
Block a user