From a93e9ee406e38d4253dc7ce1d052d4c848d0d123 Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Fri, 23 Oct 2026 18:58:54 +0200
Subject: [PATCH 1/3] feat: run the search gate before each deploy
---
.github/workflows/deploy-site.yml | 10 +++
website/tools/measure.mjs | 11 ++-
website/tools/search_gate.py | 117 ++++++++++++++++++++++++++----
3 files changed, 123 insertions(+), 15 deletions(-)
diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml
index 5d4b370d..de650c89 100644
--- a/.github/workflows/deploy-site.yml
+++ b/.github/workflows/deploy-site.yml
@@ -134,6 +134,16 @@ jobs:
npm run build --prefix website
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,
# so a run that only moves the clock leaves the file untouched and this
# stays a real staleness check rather than a guaranteed failure.
diff --git a/website/tools/measure.mjs b/website/tools/measure.mjs
index 2a854573..26f65b54 100644
--- a/website/tools/measure.mjs
+++ b/website/tools/measure.mjs
@@ -2,10 +2,17 @@
// node measure.mjs weight ...
// node measure.mjs search starlight|mkdocs ...
// 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 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 rows = [];
diff --git a/website/tools/search_gate.py b/website/tools/search_gate.py
index 4d63160c..522d8296 100644
--- a/website/tools/search_gate.py
+++ b/website/tools/search_gate.py
@@ -3,19 +3,36 @@
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
-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/
+
+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
+import argparse
import json
+import socket
import subprocess
import sys
+import time
+import urllib.error
+import urllib.request
+from collections.abc import Iterator
+from contextlib import contextmanager
from pathlib import Path
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
# 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"
# holds the manufacturer page and the console's own (systems/sony-playstation/).
"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),
"dolphin": (("emulators/dolphin/",), 1),
"duckstation": (("emulators/duckstation/",), 1),
- # A console and "bios": the console's page, before any setup's list for it.
- "playstation bios": (("systems/sony",), 3),
+ # A console and "bios": the console's own page first, before any setup's
+ # list for it or a part of its manufacturer's page.
+ "playstation bios": (("systems/sony-playstation/",), 1),
"dreamcast bios": (("systems/sega",), 3),
"neo geo bios": (("systems/snk",), 3),
"wonderswan color": (("systems/bandai",), 3),
# 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),
- "dc bios missing": (("systems/sega",), 3),
+ "dc bios missing": (("systems/sega", "emulators/flycast/"), 3),
"gba bios": (("systems/nintendo",), 3),
+ # The words an emulator prints, and the page that answers them.
"bios not found": (("wiki/troubleshooting/",), 3),
+ "please insert playstation cd-rom": (("wiki/troubleshooting/",), 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.
"r36s": (("platforms/", "which-pack/"), 3),
"steam deck": (("platforms/emudeck/", "platforms/retrodeck/", "which-pack/"), 1),
@@ -53,13 +85,44 @@ QUERIES: dict[str, tuple[tuple[str, ...], int]] = {
}
-def main() -> int:
- base = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:4321/retrobios/"
+def _free_port() -> int:
+ 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]
run = subprocess.run(
["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
for line in run.stdout.splitlines():
result = json.loads(line)
@@ -70,10 +133,38 @@ def main() -> int:
top = [entry.split(" | ") for entry in result["top"][:3]]
hit = any(href[len(root):].startswith(pages) for _title, href in top[:rank])
answered += hit
- titles = "; ".join(title for title, _href in top)
- print(f"{'ok ' if hit else 'MISS'} {result['query']!r} (top {rank}): {titles}")
+ 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}): {shown}")
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__":
From fcb4aa27da6b82312299bba8b83fbddfa2903484 Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Fri, 23 Oct 2026 18:58:55 +0200
Subject: [PATCH 2/3] feat: search fields and descriptions from pages
---
scripts/generate_site.py | 176 +++++++++++++++++++++++++------
scripts/sitestarlight.py | 43 +++++++-
scripts/validate_site.py | 40 +++++++
tests/test_site_emulator_page.py | 4 +-
tests/test_site_exports.py | 20 ++--
tests/test_site_r2_nav.py | 4 +-
6 files changed, 238 insertions(+), 49 deletions(-)
diff --git a/scripts/generate_site.py b/scripts/generate_site.py
index 446e91a3..e9c10f66 100644
--- a/scripts/generate_site.py
+++ b/scripts/generate_site.py
@@ -2109,12 +2109,23 @@ def _browser_title(relative: Path, title: str) -> str:
}
if key in index_titles:
return index_titles[key]
- if key in PART_PAGES:
+ # A console page is filed with the parts of its manufacturer's page, and
+ # titled "Sega: Dreamcast". A part names its range of files.
+ part = key in PART_PAGES and (
+ not key.startswith("systems/") or ": " not in title
+ or re.search(r", part \d+ of \d+$", title))
+ if part:
return title
+ # The words a web search for a console or an emulator types: "dreamcast
+ # bios", "duckstation bios". The h1 and the sidebar keep the name.
if key.startswith("emulators/"):
- return f"{title} emulator firmware"
+ has_files = PAGE_DATA.get(key, {}).get("hasPart")
+ # Lexaloffle, QEMU and ScummVM name a system page too.
+ own = " emulator" if f"systems/{relative.name}" in PAGE_DATA else ""
+ return f"{title}{own} BIOS files" if has_files else f"{title} emulator"
if key.startswith("systems/"):
- return f"{title} systems"
+ maker, _, console = title.partition(": ")
+ return f"{console} BIOS files ({maker})" if console else f"{title} BIOS files"
return title
@@ -2157,45 +2168,129 @@ def _page_description(markdown: str, relative: Path, title: str) -> str:
}
if key in specific:
return specific[key]
- if key in PART_PAGES:
- return (f"{title}: every file with its hashes, size, source references "
- "and who reads it.")
+ # What a search engine shows is the page's own first sentence: 707 pages
+ # shared five templates of level 3 words ("source-pinned", "provenance").
+ browser = _browser_title(relative, title)
+ held = re.search(r"\bThis part holds (.+?)\.(?: |$)", markdown, re.M)
+ if key in PART_PAGES and held:
+ return _shorten(f"{browser}. This part holds {_plain_markdown(held[1])}.", 155)
+ lead = _lead(markdown)
+ if key in PART_PAGES and re.search(r"\bpart \d+ of \d+", title):
+ # The parts of one list open on the same sentence.
+ first = _first_paragraph(lead) or _first_paragraph(markdown)
+ return _shorten(f"{title}. {first}", 155)
if key.startswith(f"{release_record.GET_DIR}/"):
- return (f"{title}: the BIOS files of this setup, as the ZIP of the latest "
- "release or one command, at an address that stays the same.")
- if key.startswith("platforms/") and relative.stem != "index":
- return (f"{title}: where the BIOS files go, the command that installs them, the pack, "
- "and every file by system with its hashes.")
- if key.startswith("emulators/") and relative.stem != "index":
- # Pinned only when the profile names the revision it was read at:
- # 48 pages without one called themselves source-pinned.
- about = PAGE_DATA.get(key, {}).get("about", {})
- pinned = any(a.get("version") for a in (about if isinstance(about, list) else [about]))
- kind = "source-pinned emulator firmware profile" if pinned else "emulator firmware profile"
- if not PAGE_DATA.get(key, {}).get("hasPart"):
- # 152 cores that load no file announced paths and hashes.
- kind = kind.replace(" firmware", "")
- trees = (" The data directories it reads are listed."
- if "\n## Data directories\n" in markdown else "")
- return f"{title}: {kind}. No BIOS or firmware file is required.{trees}"
- return f"{title}: {kind} with paths, hashes, requirements and validation behavior."
+ size = re.search(r"^- (.+ to download\b.*?)\.?$", markdown, re.M)
+ if size:
+ return _shorten(f"{title}: the BIOS pack of this setup, {_plain_markdown(size[1])}. "
+ "The address stays the same and always gives the latest ZIP.", 155)
+ return _shorten(_first_paragraph(lead) or title, 155)
if key.startswith("systems/") and relative.stem != "index":
- return f"{title}: indexed firmware files, hashes, variants, provenance and platform or emulator usage."
+ return _system_description(markdown, browser)
+ if key.startswith("emulators/") and relative.stem != "index":
+ return _emulator_description(markdown, key, title, _first_paragraph(lead))
+ first = _first_paragraph(lead, minimum=35)
+ questions = [_plain_markdown(re.sub(r"\s*\{[^}]*\}\s*$", "", heading))
+ for heading in re.findall(r"^## (.+\?)(?:[ \t]*\{[^}]*\})?[ \t]*$", markdown, re.M)]
+ if not first and len(questions) >= 3:
+ # A page of questions with no intro: its first paragraph is the
+ # answer to one of them ("Run the command with --dry-run").
+ return _shorten(f"{title}: {' '.join(questions)}", 155)
+ first = first or _first_paragraph(markdown, minimum=35)
+ if first:
+ return _shorten(first, 155)
+ return f"{title}: RetroBIOS source-traced retrogaming firmware reference."
- paragraphs = re.split(r"\n\s*\n", markdown)
- for paragraph in paragraphs:
+
+def _lead(markdown: str) -> str:
+ """The text a page opens with, before its first section, details
+ block or ignored section (the install box, the profile table)."""
+ return re.split(r"^(?:## | str:
+ """An emulator page by its intro sentence ("DuckStation emulates Sony
+ PlayStation. It picks the BIOS for your game's region: ..."), or by
+ its profile table when the profile names no system."""
+ notes = re.search(r"Technical notes
\s*\n\n(.+?)(?:\n\n|$)", markdown, re.S)
+ if not intro and notes:
+ sentence = re.split(r"(?<=\.)\s", _plain_markdown(notes[1]), maxsplit=1)[0]
+ intro = f"{title}: {sentence[:1].lower()}{sentence[1:]}"
+ if not intro:
+ row = {name: _plain_markdown(value) for name, value in re.findall(
+ r"^\| (Type|Classification|Version) \| (.+?) \|$", markdown, re.M)}
+ kind = row.get("Classification") or row.get("Type") or "emulator"
+ version = row.get("Version", "")
+ known = f", version {version}" if version and version[0].isdigit() else ""
+ intro = f"{title}: {kind.lower()}{known}."
+ elif title not in intro:
+ # "It reads 1 file": the profile names no system to open on.
+ intro = f"{title}: {intro[:1].lower()}{intro[1:]}"
+ # An alias page sends the reader to the core whose files it shares.
+ if PAGE_DATA.get(key, {}).get("hasPart") or "uses the same firmware as" in intro:
+ return _shorten(intro, 155)
+ needs = "It needs no BIOS or firmware file."
+ if "\n## Data directories" in markdown:
+ needs = "It needs no BIOS or firmware file, only the data folders listed."
+ return _shorten(f"{intro} {needs}", 155)
+
+
+# Lines a page opens with that say where it sits, not what it holds.
+_PLACE_LINES = ("Part of ", "This part holds ", "On a card, ")
+
+
+def _first_paragraph(markdown: str, minimum: int = 1) -> str:
+ """The first paragraph of prose of a page body, as plain text: no
+ heading, table, list, fence, admonition or HTML block, and none that
+ only says where the page sits."""
+ for paragraph in re.split(r"\n\s*\n", markdown):
stripped = paragraph.strip()
if (
not stripped
- or stripped.startswith(("#", "|", "```", "???", ":::", "<", "- ", "* "))
+ or stripped.startswith(("#", "|", "```", "???", ":::", "<", "- ", "* ", "import "))
):
continue
plain = _plain_markdown(stripped)
+ if plain.startswith(_PLACE_LINES):
+ continue
# A paragraph that ends on a colon introduces what follows: alone,
# it describes nothing ("One file needs no clone and no local setup:").
- if len(plain) >= 35 and not plain.endswith(":"):
- return _shorten(plain)
- return f"{title}: RetroBIOS source-traced retrogaming firmware reference."
+ if len(plain) >= minimum and not plain.endswith(":"):
+ return plain
+ return ""
+
+
+# Section headings of a system page that are not a console.
+_NOT_A_CONSOLE = {"Next steps", "Files", "Other files"}
+_CARD_TITLE = re.compile(r'class="rb-file-title"[^>]*>([^<]+)')
+
+
+def _system_description(markdown: str, browser: str) -> str:
+ """A system page by what it holds: a manufacturer page by its consoles,
+ a console page by its "Start with" line, else by its first files."""
+ consoles = [
+ _plain_markdown(re.sub(r"\s*\{[^}]*\}\s*$", "", heading))
+ for heading in re.findall(r"^## (.+?)[ \t]*$", markdown, re.M)
+ ]
+ consoles = [name for name in consoles if name and name not in _NOT_A_CONSOLE]
+ if len(consoles) > 1:
+ shown = consoles if len(consoles) <= 4 else consoles[:3] + [f"{len(consoles) - 3} more"]
+ return _shorten(f"{browser} for {_said_as_list(shown)}, one card per file with its "
+ "hashes and the emulators that read it.", 155)
+ start = re.search(r"^\*\*Start with\*\*(?P.*)$(?P(?:\n+- .*)*)", markdown, re.M)
+ if start:
+ # "Start with the files of your machine:" introduces one line per machine.
+ machines = [_plain_markdown(item) for item in re.findall(r"^- (.+)$", start["list"], re.M)]
+ text = _plain_markdown(start["line"]).lstrip(": ")
+ said = "; ".join(machines) if machines and text.endswith(":") else text
+ return _shorten(f"{browser}. Start with {said}", 155)
+ names = list(dict.fromkeys(html.unescape(name) for name in _CARD_TITLE.findall(markdown)))
+ if names:
+ shown = names if len(names) <= 3 else names[:3] + [f"{len(names) - 3} more"]
+ return _shorten(f"{browser}: {_said_as_list(shown)}, with their hashes and the "
+ "emulators that read them.", 155)
+ return _shorten(f"{browser}, one card per file with its hashes and the emulators "
+ "that read it.", 155)
# Structured data a page generator knows beyond its text, by page path
@@ -2555,15 +2650,25 @@ LEVELS = {
# - A guide behind the first two levels (reference, options, contributing)
# names everything a reader types, and ranks after the page that answers.
# - A launcher, an alias or a frozen copy answers a name after the emulator.
-SEARCH_WEIGHT = {"start": 10, "setup": 1.5, "reference": 0.5, "contribute": 0.5}
+# - A part of a split page (a console's files, 5 of 9) holds cards, each found
+# by its own heading, and ranks after the page that answers the question.
+SEARCH_WEIGHT = {"start": 10, "setup": 1.5, "reference": 0.5, "contribute": 0.5, "part": 0.5}
SYSTEM_PAGE_WEIGHT = 5
DEEP_GUIDE_WEIGHT = 0.5
SEARCH_DEMOTED: set[str] = set()
+# A tool page a reader reaches by a task, not its name: "check my dump",
+# "md5 check" found emulator pages and never the page that checks a file.
+SEARCH_WEIGHT_OF_PAGE = {"files/index.md": 2}
DEMOTED_WEIGHT = 0.5
def search_weight(relative: Path, level: str) -> float | None:
- """The search weight of a page, None for the default."""
+ """The search weight of a page, None for the default. A part page
+ states its own in its front matter: search_weight(path, "part")."""
+ if level == "part":
+ return SEARCH_WEIGHT["part"]
+ if relative.as_posix() in SEARCH_WEIGHT_OF_PAGE:
+ return SEARCH_WEIGHT_OF_PAGE[relative.as_posix()]
if relative.as_posix() in SEARCH_DEMOTED:
return DEMOTED_WEIGHT
if relative.parts[0] == "systems":
@@ -2643,7 +2748,7 @@ def decorate_markdown_pages(docs: Path) -> dict[str, str]:
title = _page_title(markdown, relative)
browser_title = _browser_title(relative, title)
- description = _page_description(markdown, relative, title)
+ description = str(authored.get("description") or _page_description(markdown, relative, title))
if relative.name == "index.md":
page_path = "" if len(relative.parts) == 1 else "/".join(relative.parts[:-1]) + "/"
else:
@@ -2675,6 +2780,7 @@ def decorate_markdown_pages(docs: Path) -> dict[str, str]:
search=authored.get("search") != "exclude",
logo=str(authored.get("logo") or ""),
parent=authored.get("parent"),
+ authored=authored,
)
write_decorated(docs / target, text)
# One file per page: a page that gained or lost its components
@@ -8855,7 +8961,7 @@ def generate_nav(
],
"reference": [
# Find a file first: the one tool a reader comes to with a file in hand.
- (5, {"Find a file": "files/index.md"}, "Checking files"),
+ (5, {"Find or check a file": "files/index.md"}, "Checking files"),
(20, {"What the packs do not hold": "gaps.md"}, "Checking files"),
(30, {"Cores by platform": "cross-reference.md"}, "Checking files"),
(40, {"Dump provenance": "provenance.md"}, "Checking files"),
diff --git a/scripts/sitestarlight.py b/scripts/sitestarlight.py
index c6c3b6f3..5763f19b 100644
--- a/scripts/sitestarlight.py
+++ b/scripts/sitestarlight.py
@@ -417,6 +417,32 @@ def page_aliases(rel: str) -> list[str]:
return list(_page_aliases.get(rel, []))
+# Search fields a page's own front matter fills, never typed here.
+SEARCH_FIELDS = ("consoles", "terms")
+
+
+def field_words(value: object) -> list[str]:
+ """The words of a front matter search field: a list, or one string
+ with its words or phrases split at commas."""
+ if isinstance(value, str):
+ return [part.strip() for part in value.split(",") if part.strip()]
+ if isinstance(value, (list, tuple)):
+ return [str(part).strip() for part in value if str(part).strip()]
+ return []
+
+
+def _neighbour(rel: str, value: object) -> object:
+ """A `prev:` or `next:` the page states, its link resolved like any
+ other link of the page."""
+ if isinstance(value, dict) and value.get("link"):
+ # Starlight writes the link as given: a site path gets the base.
+ link = resolve(rel, str(value["link"]))
+ if link.startswith("/") and not link.startswith(BASE):
+ link = BASE + link.lstrip("/")
+ return {**value, "link": link}
+ return value
+
+
_NEXT_STEPS = re.compile(r"^## Next steps\b[^\n]*\n+(?P(?:[ \t]*[-*] .*\n?)+)", re.M)
@@ -445,11 +471,20 @@ def finalize_page(
search: bool = True,
logo: str = "",
parent: dict | None = None,
+ authored: dict | None = None,
) -> tuple[str, str]:
"""The Starlight file for a page body: its path under the content
collection and its text. *parent* ({label, link}, the link written
relative to the page) is the page a part page belongs to: the route
- middleware makes it the last crumb and marks it in the sidebar."""
+ middleware makes it the last crumb and marks it in the sidebar.
+
+ *authored* is the front matter the page was written with. Its
+ `searchWeight`, `prev` and `next` win over the computed ones (a part
+ page states its neighbours and its weight), and its `consoles` and
+ `terms` become search fields (astro.config.mjs, metaWeights): the
+ consoles a manufacturer page holds sections for, and the words a page
+ answers that its text does not say, as an error message words them."""
+ authored = authored or {}
title, title_id, body = split_title(markdown)
body = body.lstrip("\n")
body = rewrite_links(body, rel)
@@ -468,6 +503,8 @@ def finalize_page(
r' None:
super().close()
self.page.title = "".join(self._title_parts).strip()
+ self.page.h1 = re.sub(r"\s+", " ", "".join(self._h1_parts)).strip()
def _parse_page(path: Path) -> Page:
@@ -322,6 +336,31 @@ def _check_pages(
return issues
+def description_template(page: Page, site_name: str = "RetroBIOS") -> str:
+ """A page's description with its own names taken out: its h1 and its
+ window title. What is left is the text it shares with its template."""
+ text = page.descriptions[0] if page.descriptions else ""
+ names = {page.h1, page.title.removesuffix(f" - {site_name}").strip()}
+ for name in sorted(filter(None, names), key=len, reverse=True):
+ text = text.replace(name, "")
+ return re.sub(r"\s+", " ", text).strip()
+
+
+def _check_description_templates(pages: dict, site: Path) -> list[str]:
+ """No more than MAX_SHARED_DESCRIPTION pages share one description
+ once their names are taken out."""
+ shared: dict[str, list[Path]] = defaultdict(list)
+ for path, page in pages.items():
+ if path.relative_to(site) != Path("404.html") and page.descriptions:
+ shared[description_template(page)].append(path)
+ return [
+ f"{len(paths)} pages share the description {text!r} after their name: "
+ + ", ".join(str(path.relative_to(site)) for path in paths[:3])
+ for text, paths in shared.items()
+ if len(paths) > MAX_SHARED_DESCRIPTION
+ ]
+
+
def _check_links(pages: dict, site: Path, base_path: str) -> list[str]:
"""Every local link and fragment resolves to something on disk."""
issues: list[str] = []
@@ -422,6 +461,7 @@ def validate_site(
rendered = ", ".join(str(path.relative_to(site)) for path in paths[:5])
issues.append(f"duplicate {label} {value!r}: {rendered}")
+ issues.extend(_check_description_templates(content, site))
issues.extend(_check_links(pages, site, base_path))
issues.extend(_check_pagefind(site))
if platforms_dir is not None:
diff --git a/tests/test_site_emulator_page.py b/tests/test_site_emulator_page.py
index 0342897b..ba592d3e 100644
--- a/tests/test_site_emulator_page.py
+++ b/tests/test_site_emulator_page.py
@@ -103,9 +103,9 @@ class WhatASearchSnippetSays(unittest.TestCase):
gs.PAGE_DATA[rel.as_posix()] = {}
text = gs._page_description("# Lutro\n", rel, "Lutro")
self.assertNotIn("hashes", text)
- self.assertIn("No BIOS or firmware file is required.", text)
+ self.assertIn("It needs no BIOS or firmware file.", text)
gs.PAGE_DATA[rel.as_posix()] = {"hasPart": [{"name": "a.bin"}]}
- self.assertIn("paths, hashes", gs._page_description("# Lutro\n", rel, "Lutro"))
+ self.assertNotIn("needs no BIOS", gs._page_description("# Lutro\n", rel, "Lutro"))
def test_a_long_paragraph_is_cut_at_a_word(self):
plain = ("files/?sha1= is the address that does not move: it opens the card "
diff --git a/tests/test_site_exports.py b/tests/test_site_exports.py
index a4a873c1..cedc664b 100644
--- a/tests/test_site_exports.py
+++ b/tests/test_site_exports.py
@@ -179,14 +179,15 @@ class SiteReferenceContracts(unittest.TestCase):
self.assertNotIn("\n #if FEATURE", rendered)
def test_browser_titles_distinguish_emulators_from_system_pages(self):
- self.assertEqual(
- _browser_title(Path("emulators/qemu.md"), "QEMU"),
- "QEMU emulator firmware",
- )
- self.assertEqual(
- _browser_title(Path("systems/qemu.md"), "QEMU"),
- "QEMU systems",
- )
+ from unittest import mock # noqa: PLC0415
+
+ import generate_site # noqa: PLC0415
+
+ pages = {"systems/qemu.md": {}, "emulators/qemu.md": {"hasPart": [{"name": "a"}]}}
+ with mock.patch.dict(generate_site.PAGE_DATA, pages, clear=True):
+ self.assertEqual(_browser_title(Path("emulators/qemu.md"), "QEMU"),
+ "QEMU emulator BIOS files")
+ self.assertEqual(_browser_title(Path("systems/qemu.md"), "QEMU"), "QEMU BIOS files")
ACCEPTED = (
("md5", "c87a52c277f7952b41c639fc7bf0a43b"),
@@ -581,7 +582,8 @@ class OnlyAPinnedProfileClaimsAPin(unittest.TestCase):
with mock.patch.dict(generate_site.PAGE_DATA, pages, clear=True):
pinned = generate_site._page_description("", Path("emulators/pinned.md"), "P")
loose = generate_site._page_description("", Path("emulators/redream.md"), "R")
- self.assertIn("source-pinned", pinned)
+ # A search snippet is level 0: the pin is on the page, not in it.
+ self.assertNotIn("source-pinned", pinned)
self.assertNotIn("source-pinned", loose)
diff --git a/tests/test_site_r2_nav.py b/tests/test_site_r2_nav.py
index 0510ced7..c3f549ac 100644
--- a/tests/test_site_r2_nav.py
+++ b/tests/test_site_r2_nav.py
@@ -233,7 +233,7 @@ class ReferenceGrouped(unittest.TestCase):
reference = self._reference()
groups = {next(iter(item)): next(iter(item.values())) for item in reference
if isinstance(item, dict) and isinstance(next(iter(item.values())), list)}
- self.assertEqual(groups["Checking files"][0], {"Find a file": "files/index.md"})
+ self.assertEqual(groups["Checking files"][0], {"Find or check a file": "files/index.md"})
self.assertIn({"What the packs do not hold": "gaps.md"}, groups["Checking files"])
self.assertEqual(groups["Data"][:3], [{"Data and API": "data.md"},
{"Data model": "wiki/data-model.md"},
@@ -250,7 +250,7 @@ class ReferenceGrouped(unittest.TestCase):
reference = self._reference(wiki)
group = next(item["Checking files"] for item in reference
if isinstance(item, dict) and "Checking files" in item)
- self.assertEqual(group[:2], [{"Find a file": "files/index.md"},
+ self.assertEqual(group[:2], [{"Find or check a file": "files/index.md"},
{"How each platform checks files": "wiki/modes.md"}])
From 59ef4fbccfd0fcbdfb482ac0e2c188d8dc0e77f5 Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Fri, 23 Oct 2026 18:58:55 +0200
Subject: [PATCH 3/3] chore: test the round 4 search package
---
tests/test_site_r4_search.py | 259 +++++++++++++++++++++++++++++++++++
wiki/testing-guide.md | 2 +-
2 files changed, 260 insertions(+), 1 deletion(-)
create mode 100644 tests/test_site_r4_search.py
diff --git a/tests/test_site_r4_search.py b/tests/test_site_r4_search.py
new file mode 100644
index 00000000..188b2aa4
--- /dev/null
+++ b/tests/test_site_r4_search.py
@@ -0,0 +1,259 @@
+"""Search (round 4, package 3).
+
+The search gate listed the queries that missed and no workflow ran it: its
+browser runner could not even start from the repository root. Console and
+error words had no field to reach their page ("wonderswan color", "bios not
+found"), parts of a split page carried the console page's weight and came
+first for "which mame version", and 707 pages gave search engines one of
+five template sentences of level 3 words as their description. These tests
+read the generator, the gate, the workflow and, where it is built, the site.
+"""
+
+from __future__ import annotations
+
+import ast
+import re
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+import yaml
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(REPO_ROOT / "scripts"))
+
+import generate_site as gs # noqa: E402
+import sitestarlight # noqa: E402
+import validate_site # noqa: E402
+
+TOOLS = REPO_ROOT / "website" / "tools"
+WORKFLOW = REPO_ROOT / ".github" / "workflows" / "deploy-site.yml"
+DOCS = REPO_ROOT / "website" / "src" / "content" / "docs"
+DIST = REPO_ROOT / "website" / "dist"
+
+
+def _front(text: str) -> dict:
+ return yaml.safe_load(text.split("---\n")[1])
+
+
+def _gate_queries() -> dict:
+ tree = ast.parse((TOOLS / "search_gate.py").read_text(encoding="utf-8"))
+ for node in tree.body:
+ if isinstance(node, ast.AnnAssign) and getattr(node.target, "id", "") == "QUERIES":
+ return ast.literal_eval(node.value)
+ raise AssertionError("no QUERIES in search_gate.py")
+
+
+def _finalize(rel: str, body: str, authored: dict) -> dict:
+ _target, text = sitestarlight.finalize_page(
+ rel, body, level="", weight=None, browser_title="T", description="d",
+ structured={}, terms={}, authored=authored)
+ return {"front": _front(text), "text": text}
+
+
+class TheGateRunsAndBlocks(unittest.TestCase):
+ def test_the_deploy_runs_the_gate_before_the_upload(self) -> None:
+ steps = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))["jobs"]["build"]["steps"]
+ runs = [str(step.get("run", "")) for step in steps]
+ uses = [str(step.get("uses", "")) for step in steps]
+ gate = next(i for i, run in enumerate(runs)
+ if "website/tools/search_gate.py --site-dir website/dist" in run)
+ build = next(i for i, run in enumerate(runs) if "npm run build --prefix website" in run)
+ upload = next(i for i, use in enumerate(uses) if "upload-pages-artifact" in use)
+ self.assertLess(build, gate)
+ self.assertLess(gate, upload)
+ # The driver is pinned to a version and kept out of the lockfile.
+ self.assertRegex(runs[gate], r"npm install --no-save --prefix website playwright-core@\d+\.\d+\.\d+")
+
+ def test_no_third_party_action_and_every_action_pinned(self) -> None:
+ text = WORKFLOW.read_text(encoding="utf-8")
+ for repo, ref in re.findall(r"uses:\s*([\w.-]+/[\w.-]+)@(\S+)", text):
+ with self.subTest(action=repo):
+ self.assertTrue(repo.startswith("actions/"))
+ self.assertRegex(ref, r"^[0-9a-f]{40}$")
+
+ def test_the_runner_resolves_from_website_and_says_what_is_missing(self) -> None:
+ source = (TOOLS / "measure.mjs").read_text(encoding="utf-8")
+ self.assertIn("await import('playwright-core')", source)
+ self.assertIn("npm install --no-save --prefix website playwright-core", source)
+ self.assertNotRegex(source, r"^import \{ chromium \}", "a static import fails before any message")
+
+ def test_the_gate_serves_a_build_dir_and_fails_on_a_miss(self) -> None:
+ source = (TOOLS / "search_gate.py").read_text(encoding="utf-8")
+ self.assertIn("--site-dir", source)
+ self.assertIn("serve.mjs", source)
+ self.assertIn("return 0 if answered == len(QUERIES)", source)
+
+ def test_the_plans_queries_are_gated(self) -> None:
+ queries = _gate_queries()
+ expected = {
+ "playstation bios": ("systems/sony-playstation/", 1),
+ "please insert playstation cd-rom": ("wiki/troubleshooting/", 3),
+ "bios not found": ("wiki/troubleshooting/", 3),
+ "check my dump": ("files/", 1),
+ "md5 check": ("files/", 1),
+ "manifest": ("data/", 3),
+ "events": ("wiki/installer/", 3),
+ "exit code": ("wiki/installer/", 3),
+ "rate limit": ("data/", 3),
+ "ios": ("wiki/faq/", 3),
+ "dc bios missing": ("emulators/flycast/", 3),
+ "neogeo.zip": ("systems/snk/", 1),
+ "scph5501": ("systems/sony-playstation/", 3),
+ }
+ for query, (page, rank) in expected.items():
+ with self.subTest(query=query):
+ pages, gated = queries[query]
+ self.assertTrue(any(page.startswith(prefix) for prefix in pages))
+ self.assertLessEqual(gated, rank)
+
+ def test_the_home_has_a_weight_budget(self) -> None:
+ source = (TOOLS / "search_gate.py").read_text(encoding="utf-8")
+ self.assertIn("HOME_BUDGET_KB = 80", source)
+
+
+class FieldsFromFrontMatter(unittest.TestCase):
+ def test_consoles_and_terms_become_search_fields(self) -> None:
+ text = _finalize("systems/snk.md", "# SNK\n\nBody.\n", {
+ "consoles": ["Neo Geo", "Neo Geo Pocket Color"],
+ "terms": "bios not found, bios missing",
+ })["text"]
+ self.assertIn('data-pagefind-meta="consoles:Neo Geo Neo Geo Pocket Color"', text)
+ self.assertIn('data-pagefind-meta="terms:bios not found bios missing"', text)
+ # A field value is an attribute: none of it reaches an excerpt.
+ self.assertNotRegex(text, r">[^<]*bios missing[^<]*<")
+
+ def test_no_search_word_is_typed_in_the_module(self) -> None:
+ source = (REPO_ROOT / "scripts" / "sitestarlight.py").read_text(encoding="utf-8")
+ for words in ("bios not found", "mame version", "WonderSwan"):
+ self.assertNotIn(words, source)
+
+ def test_a_page_without_fields_gets_none(self) -> None:
+ text = _finalize("wiki/legal.md", "# Legal\n\nBody.\n", {})["text"]
+ self.assertNotIn("consoles:", text)
+ self.assertNotIn("terms:", text)
+
+
+class FindOrCheckAFile(unittest.TestCase):
+ """"check my dump" and "md5 check" found emulator pages first."""
+
+ def test_the_page_weighs_two(self) -> None:
+ rel = Path("files/index.md")
+ self.assertEqual(gs.search_weight(rel, gs.page_level(rel)), 2)
+
+ def test_its_terms_become_a_field(self) -> None:
+ text = _finalize("files/index.md", "# Find or check a file\n\nBody.\n",
+ {"terms": "check dump, md5 check"})["text"]
+ self.assertIn('data-pagefind-meta="terms:check dump md5 check"', text)
+
+ def test_the_sidebar_names_it_as_its_title(self) -> None:
+ source = (REPO_ROOT / "scripts" / "generate_site.py").read_text(encoding="utf-8")
+ self.assertIn('{"Find or check a file": "files/index.md"}', source)
+ self.assertNotIn('{"Find a file": "files/index.md"}', source)
+
+
+class PartPages(unittest.TestCase):
+ def test_part_weight(self) -> None:
+ self.assertEqual(gs.SEARCH_WEIGHT["part"], 0.5)
+ self.assertEqual(gs.search_weight(Path("systems/arcade-mame-5.md"), "part"), 0.5)
+
+ def test_a_part_keeps_its_own_weight_and_neighbours(self) -> None:
+ """The MAME card parts write prev, next and searchWeight: the build
+ kept only parent and wrote prev: false, next: false."""
+ page = (
+ "---\n"
+ "parent:\n label: MAME\n link: mame.md\n"
+ "prev:\n label: MAME files, part 2 of 8\n link: mame-files-2.md\n"
+ "next:\n label: MAME files, part 4 of 8\n link: mame-files-4.md\n"
+ f"searchWeight: {gs.search_weight(Path('emulators/mame-files-3.md'), 'part')}\n"
+ "---\n\n# MAME files, part 3 of 8\n\nPart of [MAME](mame.md). "
+ "This part holds 196 entries, `a.zip` to `b.zip`.\n"
+ )
+ with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp:
+ path = Path(tmp) / "emulators" / "mame-files-3.md"
+ path.parent.mkdir()
+ path.write_text(page, encoding="utf-8")
+ gs.PART_PAGES.add("emulators/mame-files-3.md")
+ self.addCleanup(gs.PART_PAGES.discard, "emulators/mame-files-3.md")
+ gs.decorate_markdown_pages(Path(tmp))
+ front = _front(path.read_text(encoding="utf-8"))
+ self.assertEqual(front["searchWeight"], 0.5)
+ self.assertEqual(front["prev"], {"label": "MAME files, part 2 of 8",
+ "link": "/retrobios/emulators/mame-files-2/"})
+ self.assertEqual(front["next"]["link"], "/retrobios/emulators/mame-files-4/")
+ self.assertEqual(front["parent"]["link"], "/retrobios/emulators/mame/")
+ self.assertIn("This part holds 196 entries, a.zip to b.zip", front["description"])
+
+ def test_built_mame_parts_state_their_neighbours(self) -> None:
+ part = DOCS / "emulators" / "mame-files-3.md"
+ if not part.is_file():
+ self.skipTest("site pages not generated")
+ front = _front(part.read_text(encoding="utf-8"))
+ if "searchWeight" not in front and front.get("prev") is False:
+ self.skipTest("the emulator pages package is not merged")
+ self.assertEqual(front["searchWeight"], 0.5)
+ self.assertTrue(str(front["prev"]["link"]).startswith("/retrobios/emulators/"))
+
+
+class Descriptions(unittest.TestCase):
+ def test_titles_say_bios_files(self) -> None:
+ saved = dict(gs.PAGE_DATA)
+ self.addCleanup(lambda: (gs.PAGE_DATA.clear(), gs.PAGE_DATA.update(saved)))
+ gs.PAGE_DATA["emulators/duckstation.md"] = {"hasPart": [{"name": "scph5501.bin"}]}
+ self.assertEqual(gs._browser_title(Path("emulators/duckstation.md"), "DuckStation"),
+ "DuckStation BIOS files")
+ self.assertEqual(gs._browser_title(Path("systems/sega.md"), "Sega"), "Sega BIOS files")
+ self.assertEqual(gs._browser_title(Path("systems/sega-dreamcast.md"), "Sega: Dreamcast"),
+ "Dreamcast BIOS files (Sega)")
+
+ def test_an_emulator_page_is_described_by_its_intro(self) -> None:
+ saved = dict(gs.PAGE_DATA)
+ self.addCleanup(lambda: (gs.PAGE_DATA.clear(), gs.PAGE_DATA.update(saved)))
+ gs.PAGE_DATA["emulators/duckstation.md"] = {"hasPart": [{"name": "scph5501.bin"}]}
+ body = ("# DuckStation\n\nDuckStation emulates Sony PlayStation. It picks the BIOS for "
+ "your game's region.\n\n\n\nThe installer finds your platform.\n")
+ text = gs._page_description(body, Path("emulators/duckstation.md"), "DuckStation")
+ self.assertTrue(text.startswith("DuckStation emulates Sony PlayStation."))
+ self.assertNotIn("source-pinned", text)
+
+ def test_a_console_page_is_described_by_its_start_line(self) -> None:
+ body = "# Sega: Dreamcast\n\nPart of Sega.\n\n**Start with**: `dc_boot.bin`, `dc_flash.bin`.\n"
+ text = gs._page_description(body, Path("systems/sega-dreamcast.md"), "Sega: Dreamcast")
+ self.assertEqual(text, "Dreamcast BIOS files (Sega). Start with dc_boot.bin, dc_flash.bin.")
+
+ def test_the_faq_is_described_by_its_questions(self) -> None:
+ body = ("# Common questions\n\n## Do I need BIOS files?\n\nYes.\n\n## Which one?\n\n"
+ "Run the command with --dry-run.\n\n## Zip or not?\n\nZip.\n")
+ text = gs._page_description(body, Path("wiki/faq.md"), "Common questions")
+ self.assertTrue(text.startswith("Common questions: Do I need BIOS files?"))
+
+ def test_a_written_description_wins(self) -> None:
+ source = (REPO_ROOT / "scripts" / "generate_site.py").read_text(encoding="utf-8")
+ self.assertIn('authored.get("description") or _page_description(', source)
+
+ def test_the_check_counts_templates(self) -> None:
+ pages = {}
+ for n in range(12):
+ page = validate_site.Page(Path(f"/s/p{n}/index.html"))
+ page.h1 = f"Core {n}"
+ page.title = f"Core {n} BIOS files - RetroBIOS"
+ page.descriptions = [f"Core {n}: emulator firmware profile with paths."]
+ pages[page.path] = page
+ issues = validate_site._check_description_templates(pages, Path("/s"))
+ self.assertEqual(len(issues), 1)
+ self.assertIn("12 pages share", issues[0])
+ for page in list(pages.values())[:2]:
+ page.descriptions = [f"{page.h1} emulates a console of its own {page.h1[-1]}x."]
+ self.assertEqual(validate_site._check_description_templates(pages, Path("/s")), [])
+
+ def test_built_descriptions_are_not_templates(self) -> None:
+ if not (DIST / "index.html").is_file():
+ self.skipTest("site not built")
+ pages = {path.resolve(): validate_site._parse_page(path) for path in DIST.rglob("*.html")}
+ content = {path: page for path, page in pages.items() if not page.redirect}
+ self.assertEqual(validate_site._check_description_templates(content, DIST.resolve()), [])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md
index 6e833970..5fd67254 100644
--- a/wiki/testing-guide.md
+++ b/wiki/testing-guide.md
@@ -57,7 +57,7 @@ Every module in `tests/`, by the area it guards.
| Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_from`, `test_install_offline`, `test_install_selection`, `test_install_tui` |
| Handheld launchers | a pty as the console, fake installer and firmware tools, stubbed PortMaster dialogs | `test_handheld_launchers`, `test_port` |
| Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` |
-| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_collection`, `test_site_r3_components`, `test_site_r3_css`, `test_site_r3_emulators`, `test_site_r3_entry`, `test_site_r3_nav`, `test_site_r3_p0`, `test_site_r4_p0`, `test_site_r3_reference`, `test_site_r3_systems`, `test_site_r3_wiki`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
+| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_collection`, `test_site_r3_components`, `test_site_r3_css`, `test_site_r3_emulators`, `test_site_r3_entry`, `test_site_r3_nav`, `test_site_r3_p0`, `test_site_r4_p0`, `test_site_r4_search`, `test_site_r3_reference`, `test_site_r3_systems`, `test_site_r3_wiki`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
## Test architecture