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/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"}]) 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/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__": diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index dc58eabb..f32a6190 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_r3_reference`, `test_site_r3_systems`, `test_site_r3_wiki`, `test_site_r4_components`, `test_site_r4_css`, `test_site_r4_p0`, `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_r3_reference`, `test_site_r3_systems`, `test_site_r3_wiki`, `test_site_r4_components`, `test_site_r4_css`, `test_site_r4_p0`, `test_site_r4_search`, `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