chore: merge scraper and tool fixes

This commit is contained in:
Abdessamad Derraz committed 2026-11-06 16:36:32 +01:00
commit 8e3ba2cfd3
28 files changed
+744 -215

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
"""A disposable copy of the checkout, for tests that run a writer and expect
it to refuse.
Run in the checkout, a regression made the very write the test guards
against: generate_site rewrote website/src/data/published-parts.json and the
FAQ page whatever --docs-dir said, and a pipeline whose lock probe failed
would regenerate database.json and install/. A file another session had
already changed listed the same name in `git diff --name-only` before and
after, so its edits were lost without a signal.
The copy holds every file git tracks or would track, as the working tree has
it. bios/ and data/ (17 GB and 600 MB of inputs no such test writes) are
links to the checkout's.
"""
from __future__ import annotations
import atexit
import hashlib
import os
import shutil
import subprocess
import tempfile
from functools import cache
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
LINKED = ("bios", "data")
@cache
def scratch_checkout() -> Path:
"""The copy, built once per test process and removed at its exit."""
root = Path(tempfile.mkdtemp(prefix="checkout-", dir=REPO_ROOT / "tmp" / "tests"))
# A detached worktree with nothing checked out: the scripts that read
# HEAD (the site's bootstrap pin) find this commit.
subprocess.run(
["git", "worktree", "add", "--quiet", "--detach", "--no-checkout", str(root), "HEAD"],
cwd=REPO_ROOT, capture_output=True, check=True,
)
atexit.register(_remove, root)
listed = subprocess.run(
["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
cwd=REPO_ROOT, capture_output=True, check=True,
).stdout.decode("utf-8")
for name in filter(None, listed.split("\0")):
source = REPO_ROOT / name
if name.split("/", 1)[0] in LINKED or not source.is_file() or source.is_symlink():
continue
target = root / name
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
for name in LINKED:
if (REPO_ROOT / name).exists():
(root / name).symlink_to(REPO_ROOT / name, target_is_directory=True)
return root
def _remove(root: Path) -> None:
subprocess.run(["git", "worktree", "remove", "--force", str(root)],
cwd=REPO_ROOT, capture_output=True, check=False)
shutil.rmtree(root, ignore_errors=True)
def snapshot(root: Path) -> dict[str, str]:
"""The SHA-1 of every file under root, links not followed. A file
written with the bytes it held is no change: what is caught is a file
whose content a run replaced. Scratch space, caches, bytecode and lock
files are no output."""
seen = {}
for directory, dirs, files in os.walk(root):
dirs[:] = [d for d in dirs if d != "__pycache__"
and Path(directory, d) not in (root / "tmp", root / ".cache")]
for name in files:
path = Path(directory) / name
if name != ".lock" and not path.is_symlink():
seen[str(path.relative_to(root))] = hashlib.sha1(path.read_bytes()).hexdigest()
return seen
+11 -6
View File
@@ -27,6 +27,7 @@ from common import ( # noqa: E402
hold_repository_outputs,
publish_hold,
)
from tests.scratchtree import scratch_checkout # noqa: E402
def _tempdir(test: unittest.TestCase) -> str:
@@ -117,11 +118,13 @@ class PackLockCliTest(unittest.TestCase):
self.dir = _tempdir(self)
def _run(self, argv: list[str]) -> subprocess.CompletedProcess:
# In a copy: a pipeline that missed the lock regenerated
# database.json and install/ wherever it ran.
return subprocess.run(
[sys.executable, *argv],
capture_output=True,
text=True,
cwd=str(REPO_ROOT),
cwd=str(scratch_checkout()),
timeout=300,
)
@@ -236,11 +239,12 @@ class PipelineHoldsItsFixedOutputs(unittest.TestCase):
def test_a_second_pipeline_is_refused_whatever_its_output_dir(self):
scratch = _tempdir(self)
with hold_repository_outputs(str(REPO_ROOT)):
checkout = scratch_checkout()
with hold_repository_outputs(str(checkout)):
proc = subprocess.run(
[sys.executable, "scripts/pipeline.py", "--offline", "--skip-docs",
"--output-dir", scratch],
capture_output=True, text=True, cwd=str(REPO_ROOT), timeout=120,
capture_output=True, text=True, cwd=str(checkout), timeout=120,
)
self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr)
self.assertIn("another run is writing database.json", proc.stderr)
@@ -262,11 +266,12 @@ class StandaloneWritersHonourTheRepositoryHold(unittest.TestCase):
env = {k: v for k, v in os.environ.items() if k != "RETROBIOS_HELD_ARTIFACT_DIR"}
return subprocess.run(
[sys.executable, *argv], capture_output=True, text=True,
cwd=str(REPO_ROOT), timeout=300, env=env, check=False,
cwd=str(scratch_checkout()), timeout=300, env=env, check=False,
)
def test_a_writer_of_a_fixed_output_is_refused(self):
with hold_repository_outputs(str(REPO_ROOT)):
# The writers run in a copy, where a missed lock writes nothing of ours.
with hold_repository_outputs(str(scratch_checkout())):
for argv in (
["scripts/generate_db.py", "--bios-dir", "bios", "--output", "database.json"],
["scripts/generate_pack.py", "--all", "--manifest", "--offline",
@@ -371,7 +376,7 @@ class SiteGenerationHoldsDocs(unittest.TestCase):
with artifact_lock(docs):
result = subprocess.run(
[sys.executable, "scripts/generate_site.py", "--docs-dir", docs],
cwd=REPO_ROOT, capture_output=True, text=True, timeout=600,
cwd=scratch_checkout(), capture_output=True, text=True, timeout=600,
)
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertIn("in use by another run", result.stderr)
+37 -4
View File
@@ -318,8 +318,23 @@ class CoreResolutionTests(unittest.TestCase):
record.write_text(cf.json.dumps({"blobs": {"a": "1", "b": "1"}}))
cf.record_core_info({"a": "2", "b": "2"}, ["a"], record)
self.assertEqual(cf.load_core_info_record(record), {"a": "2", "b": "1"})
with self.assertRaises(ValueError):
cf.record_core_info({"a": "2"}, ["ghost"], record)
with self.assertRaises(cf.InputMissing) as raised:
cf.record_core_info({"a": "2", "ghosts": "1"}, ["ghost"], record)
self.assertIn("Did you mean: ghosts?", str(raised.exception))
def test_record_errors_are_one_line(self):
"""A mistyped core and an empty offline cache ended on a traceback."""
import subprocess # noqa: PLC0415
with tempfile.TemporaryDirectory() as tmp:
proc = subprocess.run(
[sys.executable, str(REPO_ROOT / "scripts" / "check_freshness.py"), "--offline",
"--cache-dir", tmp, "--record-coreinfo"],
capture_output=True, text=True, check=False, timeout=120,
)
self.assertEqual(proc.returncode, 1)
self.assertNotIn("Traceback", proc.stderr)
self.assertIn("run without --offline", proc.stderr)
def test_offline_without_a_cached_listing_is_skipped(self):
"""Offline, the row read UNKNOWN and failed the run as an upstream
@@ -462,10 +477,28 @@ class CatalogParsingTests(unittest.TestCase):
row = cf._check_redump_games(False)
self.assertEqual((row.status, row.items), (cf.STALE, ["sony-playstation"]))
self.assertEqual(cf._check_redump_games(True).status, cf.SKIPPED)
with mock.patch.object(cf, "_game_catalogs", return_value=[{"fetched": "2026-11-03"}]):
for rebuilt, status in (("2026-07-08", cf.OK), ("2026-11-04", cf.STALE)):
with mock.patch.object(cf, "_game_catalogs", return_value=[{"fetched": "2026-11-03"}]), \
mock.patch.object(cf, "_today", return_value="2026-11-06"):
for rebuilt, status in (("2026-11-02", cf.OK), ("2026-11-04", cf.STALE)):
with mock.patch.object(cf, "_no_intro_asset_date", return_value=rebuilt):
self.assertEqual(cf._check_nointro_games("", False).status, status)
def test_a_mirror_that_stopped_rebuilding_is_stale(self):
"""The mirror failed every daily rebuild from 2026-07-08, and both
No-Intro rows read OK: an import newer than a dead pack is no
sign of a fresh catalog."""
with mock.patch.object(cf, "_today", return_value="2026-11-06"), \
mock.patch.object(cf, "_no_intro_asset_date", return_value="2026-07-08"), \
mock.patch.object(cf, "_game_catalogs", return_value=[{"fetched": "2026-11-03"}]), \
mock.patch.object(cf, "_snapshot", return_value={"imported_at": "2026-08-08"}):
for row in (cf._check_nointro_games("", False), cf._check_no_intro("", False)):
self.assertEqual(row.status, cf.STALE)
self.assertIn("2026-07-08, 121 days ago", row.detail)
def test_the_freshness_check_reads_the_importer_mirror(self):
self.assertEqual(cf.NO_INTRO_MIRROR, cf.game_dat_importer.NOINTRO_MIRROR)
self.assertIn(cf.NO_INTRO_MIRROR.removeprefix("https://github.com/"),
cf.game_dat_importer.NOINTRO_RELEASE_API)
self.assertIn("no-intro games",
[f.subject for f in cf.check_catalogs("", True)])
+72 -2
View File
@@ -803,14 +803,20 @@ class SearchOrder(unittest.TestCase):
longest = ('rp_module_id="xm7"\nrp_module_help="Copy bios files '
+ ", ".join(f"n{i}.rom" for i in range(12)) + ' and n12.rom to $biosdir"\n')
exporter = discover_exporters()["retropie"]()
produced = exporter.render(
written = exporter.render(
systems, None,
{"scriptmodules/libretrocores/lr-flycast.sh": script,
"scriptmodules/emulators/xm7.sh": longest},
)["scriptmodules/libretrocores/lr-flycast.sh"]
)
produced = written["scriptmodules/libretrocores/lr-flycast.sh"]
self.assertIn("Dreamcast BIOS file dc_boot.bin to", produced)
self.assertIn("BIOS files naomi.zip, awbios.zip and naomi2.zip to", produced)
self.assertNotIn("other.zip", produced)
# Rewritten for naomi2.zip, other.zip left out: the summary said
# "Left as is" of a package in the diff.
outcome = exporter.outcome(systems, written)
self.assertIn("Updated in part: lr-flycast,", outcome)
self.assertNotIn("Left as is: lr-flycast", outcome)
def test_cores_that_disagree_keep_the_best_rank(self):
"""Nothing is dropped on the rank here, so the best one wins.
@@ -829,6 +835,70 @@ class SearchOrder(unittest.TestCase):
self.assertEqual(entry.priority, 2)
class TwoAdditionsAtOnePath(unittest.TestCase):
"""Two cores declaring two dumps of one file the platform lacks: both
were counted added while the render wrote one. Recalbox's md5 list takes
both, a System.dat name and a RetroDECK path hold one."""
A, B = "615a" + "0" * 28, "ef49" + "0" * 28
def _addition(self, md5: str, core: str, system: str = "dos") -> NativeFile:
return NativeFile(
name="MT32_CONTROL.ROM", destination="mt32/MT32_CONTROL.ROM", native_system=system,
truth={"name": "MT32_CONTROL.ROM", "path": "mt32/MT32_CONTROL.ROM", "md5": md5,
"crc32": md5[:8], "required": True, "_cores": [core]},
runners=[(False, core)],
)
def _added(self, exporter, systems) -> list[str]:
return [c["name"] for c in landed_changes(exporter, systems).records
if c["kind"] == "added"]
def test_recalbox_writes_both_md5_in_one_element(self):
systems = {"dos": NativeSystem("dos", files=[
self._addition(self.A, "dosbox_pure"), self._addition(self.B, "dosbox_core")])}
exporter = discover_exporters()["recalbox"]()
produced = exporter.render(systems, Report(), {})["es_bios.xml"]
elements = [e for e in ET.fromstring(produced).iter("bios")]
self.assertEqual(len(elements), 1)
self.assertEqual(elements[0].get("md5").split(","), [self.A, self.B])
self.assertEqual(elements[0].get("core"), "libretro/dosbox_pure,libretro/dosbox_core")
self.assertEqual(exporter.validate(systems, {"es_bios.xml": produced}), [])
self.assertEqual(len(self._added(exporter, systems)), 2)
def test_system_dat_counts_the_line_it_wrote(self):
systems = {"DOS": NativeSystem("DOS", files=[
self._addition(self.A, "dosbox_pure", "DOS"),
self._addition(self.B, "dosbox_core", "DOS")])}
exporter = discover_exporters()["retroarch"]()
original = 'clrmamepro (\n\tname "System"\n)\n\ngame (\n\tname "System"\n' \
'\tcomment "System"\n\n\tcomment "DOS"\n\trom ( name a.bin size 1 crc 00000000 )\n)\n'
produced = exporter.render(systems, Report(), {"System.dat": original})["System.dat"]
self.assertEqual(produced.count("MT32_CONTROL.ROM"), 1)
self.assertEqual(len(self._added(exporter, systems)), 1)
def test_retrodeck_counts_the_entry_it_appended(self):
theirs = NativeFile(
name="scph1001.bin", destination="scph1001.bin", native_system="psx",
platform={"name": "scph1001.bin", "md5": "1" * 32, "component": "duckstation"})
def addition(md5: str) -> NativeFile:
return NativeFile(
name="scph5501.bin", destination="scph5501.bin", native_system="psx",
truth={"name": "scph5501.bin", "md5": md5, "required": True,
"_cores": ["duckstation"]},
runners=[(True, "duckstation")])
systems = {"psx": NativeSystem("psx", files=[theirs, addition(self.A), addition(self.B)])}
manifest = json.dumps({"duckstation": {"name": "DuckStation", "bios": [
{"filename": "scph1001.bin", "md5": "1" * 32, "system": "psx"}]}})
exporter = discover_exporters()["retrodeck"]()
produced = exporter.render(systems, Report(), {"duckstation/component_manifest.json": manifest})
bios = json.loads(produced["duckstation/component_manifest.json"])["duckstation"]["bios"]
self.assertEqual([e["filename"] for e in bios], ["scph1001.bin", "scph5501.bin"])
self.assertEqual(self._added(exporter, systems), ["scph5501.bin"])
class AdditionOrder(unittest.TestCase):
"""What we add is listed the way the code looks for it."""
+12 -5
View File
@@ -299,12 +299,19 @@ class RecordsNameNoLocalPath(unittest.TestCase):
self.assertEqual(summary, "Copy it from your own copy of the game into <dest>/jazz2/Source/. "
"Saves go to <save-dir>.")
def test_no_committed_record_holds_an_absolute_path(self) -> None:
path = REPO_ROOT / "measurements" / "pack-probes.jsonl"
def test_no_committed_record_holds_a_local_path(self) -> None:
"""Ten records cut at 400 characters inside the scratch path kept
its tail, which no absolute-path pattern sees."""
absolute = re.compile(r"(?<![\w.<>-])/(?:home|root|tmp|mnt|Users|srv)/")
records = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
found = [r["id"] for r in records
if absolute.search((r.get("source") or {}).get("installer_summary", ""))]
# The tail of a scratch folder: a worktree, or the probes' own tmp/
# subfolders.
scratch = re.compile(r"worktrees/|\btmp/(?:pack_probe|tests|campaign)\b")
found = [
f"{path.name}:{number}"
for path in sorted((REPO_ROOT / "measurements").glob("*.jsonl"))
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1)
if absolute.search(line) or scratch.search(line)
]
self.assertEqual(found, [])
+49
View File
@@ -5983,6 +5983,55 @@ class ARoleIsReadPerPart(unittest.TestCase):
self.assertEqual(list(_prose_runs("read at boot (:507), devilutionx.mpq")), [])
class AnOnlinePassSaysWhereItIs(unittest.TestCase):
"""`--all --triage` online printed nothing for 15 minutes, and Ctrl-C
left no ranking at all, while --offline answered in three."""
def _main(self, *argv: str, stop_after: int | None = None):
calls = []
def build(_args, name, _profile, _path, _failed):
if stop_after is not None and len(calls) == stop_after:
raise KeyboardInterrupt
calls.append(name)
return ProfileReport(name=name, entries=[], counts={})
out, err = io.StringIO(), io.StringIO()
saved = sys.argv
try:
sys.argv = ["profile_sync.py", "--all", "--triage", *argv]
with mock.patch.object(profile_sync, "_build_full_report", side_effect=build), \
mock.patch.object(profile_sync, "_check_quota"), \
contextlib.redirect_stdout(out), contextlib.redirect_stderr(err), \
self.assertRaises(SystemExit) as caught:
profile_sync.main()
raise SystemExit(0)
finally:
sys.argv = saved
return caught.exception.code, out.getvalue(), err.getvalue()
def test_ctrl_c_prints_the_ranking_of_what_was_read(self):
code, out, err = self._main("--offline", stop_after=3)
self.assertEqual(code, 130)
self.assertEqual(out.splitlines()[-1], "3 profiles, 0 with something to review")
self.assertIn("interrupted after 3 of", err)
def test_an_online_pass_names_its_size_and_the_offline_mode(self):
_code, _out, err = self._main("--limit", "2")
self.assertIn("reading 2 profiles against their forges", err)
self.assertIn("--offline answers from the cache alone", err)
def test_a_progress_line_at_each_interval(self):
clock = iter([0.0, 10.0, profile_sync.PROGRESS_SECONDS + 1, 40.0])
err = io.StringIO()
with mock.patch.object(profile_sync.time, "monotonic", lambda: next(clock)), \
contextlib.redirect_stderr(err):
progress = profile_sync._Progress(9, "read", quiet=False)
for done, name in enumerate(("a", "b", "c")):
progress.step(done, name)
self.assertEqual(err.getvalue(), "read: 1 of 9, at b\n")
class TheTriageCountsWhatBlocksThePin(unittest.TestCase):
"""Prose written under an older pin refuses the recale and the bump, yet
the triage, --changed-only and --strict read "0 to review" on blastem,
+28
View File
@@ -10,10 +10,12 @@ from __future__ import annotations
import tests # noqa: F401 offline, scratch under tmp/, the suite's own cache
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
@@ -281,5 +283,31 @@ class SavedFindings(unittest.TestCase):
rs.load_findings(path, ("data",))
class AFailedCheckIsNamed(unittest.TestCase):
"""A crash of check_freshness exits 1 like a STALE row: its traceback,
on stderr, was dropped for "unreadable JSON", and a timeout ended on
a traceback of refresh_stale itself."""
def _run(self, raises: Exception | None = None, **result):
proc = subprocess.CompletedProcess([], **result) if result else None
with mock.patch.object(rs.subprocess, "run", side_effect=raises, return_value=proc):
return rs._run_check_freshness(("coreinfo",), []) # noqa: SLF001
def test_a_crash_shows_its_stderr(self):
with self.assertRaises(RuntimeError) as raised:
self._run(returncode=1, stdout="", stderr="Traceback\nRuntimeError: no listing\n")
self.assertIn("RuntimeError: no listing", str(raised.exception))
self.assertNotIn("JSON", str(raised.exception))
def test_stale_rows_are_read(self):
self.assertEqual(self._run(returncode=1, stdout='[{"area": "coreinfo"}]', stderr=""),
[{"area": "coreinfo"}])
def test_a_timeout_says_what_to_run(self):
with self.assertRaises(RuntimeError) as raised:
self._run(raises=subprocess.TimeoutExpired(["x"], 1800))
self.assertIn("--findings", str(raised.exception))
if __name__ == "__main__":
unittest.main()
+27 -22
View File
@@ -644,21 +644,26 @@ class UnreadableReleaseStopsTheScrape(unittest.TestCase):
def _refuse(*_args, **_kwargs):
raise urllib.error.HTTPError("https://api.github.com/x", 403, "rate limited", {}, None)
# Read at a release tag: the pin is the tag, not a branch commit.
TAG_PINNED = {"amberelec", "batocera", "bizhawk", "emuelec", "knulli", "recalbox",
"rocknix", "romm"}
@staticmethod
def _registry_scrapers() -> list[str]:
"""Every platform scraper the registry names: a hand-written list
left rocknix, retrodeck, minui, nextui and myminui unchecked."""
import yaml # noqa: PLC0415
registry = yaml.safe_load((REPO_ROOT / "platforms" / "_registry.yml").read_text())
return sorted({entry["scraper"] for entry in registry["platforms"].values()
if entry.get("scraper")})
def test_the_tag_pinned_scrapers_are_registered(self):
self.assertLessEqual(self.TAG_PINNED, set(self._registry_scrapers()))
def test_every_pinned_scraper_raises(self):
for name in (
"scraper.bizhawk_scraper",
"scraper.romm_scraper",
"scraper.batocera_scraper",
"scraper.recalbox_scraper",
"scraper.libretro_scraper",
"scraper.retrobat_scraper",
"scraper.emudeck_scraper",
"scraper.misterfpga_scraper",
"scraper.retropie_scraper",
"scraper.amberelec_scraper",
"scraper.emuelec_scraper",
"scraper.knulli_scraper",
):
for scraper in self._registry_scrapers():
name = f"scraper.{scraper}_scraper"
module = importlib.import_module(name)
with self.subTest(scraper=name), mock.patch(
"urllib.request.urlopen", self._refuse
@@ -681,16 +686,16 @@ class UnreadableReleaseStopsTheScrape(unittest.TestCase):
def answer(request, timeout=None):
return Answer(json.dumps({"sha": sha}).encode())
for name in (
"scraper.libretro_scraper",
"scraper.retrobat_scraper",
"scraper.emudeck_scraper",
"scraper.misterfpga_scraper",
"scraper.retropie_scraper",
):
branch_read = [s for s in self._registry_scrapers() if s not in self.TAG_PINNED]
self.assertGreater(len(branch_read), 8)
for scraper in branch_read:
name = f"scraper.{scraper}_scraper"
module = importlib.import_module(name)
with self.subTest(scraper=name), mock.patch("urllib.request.urlopen", answer):
self.assertRegex(module.Scraper().url, f"/{sha}(/|$)")
instance = module.Scraper()
# RetroDECK reads a tree of manifests and keeps the commit apart.
recorded = getattr(instance, "revision", "") or instance.url
self.assertRegex(recorded, f"(^|/){sha}(/|$)")
def test_the_export_reads_the_recorded_tarball(self):
from export_native import _tarball_at
+25
View File
@@ -1466,6 +1466,31 @@ class TheFolderCheckExitCode(unittest.TestCase):
"nosuch", Path(folder), {"/nosuch.json": None, "/catalog.json": catalog})
self.assertEqual(code, "unknown platform: nosuch. Known: batocera")
def test_a_folder_that_is_not_there_is_named(self):
"""A mistyped folder read as every file missing, exit 1."""
with tempfile.TemporaryDirectory() as folder:
code, _err = self.run_script("p", Path(folder) / "bois", {"/p.json": self.CONTRACT})
self.assertIsInstance(code, str)
self.assertIn("bois", code)
self.assertIn("No such file or directory", code)
def test_a_missing_argument_prints_the_usage(self):
err = io.StringIO()
with mock.patch.object(sys, "argv", ["check", "batocera"]), \
mock.patch("sys.stderr", err), self.assertRaises(SystemExit) as raised:
runpy.run_path(self.SCRIPT, run_name="__main__")
self.assertTrue(str(raised.exception.code).startswith("Usage: "))
def test_no_network_is_a_message(self):
def offline(url: str, *args, **kwargs):
raise urllib.error.URLError("Name or service not known")
with tempfile.TemporaryDirectory() as folder, \
mock.patch("urllib.request.urlopen", offline), \
mock.patch.object(sys, "argv", ["check", "p", folder]), \
self.assertRaises(SystemExit) as raised:
runpy.run_path(self.SCRIPT, run_name="__main__")
self.assertEqual(raised.exception.code, "error: <urlopen error Name or service not known>")
def test_the_page_shows_the_script(self):
script = Path(self.SCRIPT).read_text(encoding="utf-8")
self.assertIn("```python\n" + script.rstrip("\n") + "\n```",
+35 -9
View File
@@ -25,6 +25,7 @@ sys.path.insert(0, str(REPO_ROOT / "scripts"))
import common # noqa: E402
import generate_pack as builder # noqa: E402
from tests.scratchtree import scratch_checkout, snapshot # noqa: E402
class SpellingsAgree(unittest.TestCase):
@@ -221,7 +222,7 @@ class AMissingDatabaseIsNamed(unittest.TestCase):
with self.subTest(script=script):
proc = subprocess.run(
[sys.executable, f"scripts/{script}", "--db", "nosuch.json", *extra],
capture_output=True, text=True, cwd=str(REPO_ROOT), timeout=600,
capture_output=True, text=True, cwd=str(scratch_checkout()), timeout=600,
check=False,
)
self.assertNotEqual(proc.returncode, 0, proc.stdout[-500:])
@@ -256,7 +257,7 @@ class AMissingBiosDirIsRefused(unittest.TestCase):
with self.subTest(script=script):
proc = subprocess.run(
[sys.executable, f"scripts/{script}", "--bios-dir", "nosuch", *extra],
capture_output=True, text=True, cwd=str(REPO_ROOT), timeout=600,
capture_output=True, text=True, cwd=str(scratch_checkout()), timeout=600,
check=False,
)
self.assertNotEqual(proc.returncode, 0)
@@ -309,14 +310,15 @@ class AMissingProfileDirIsRefused(unittest.TestCase):
"dat": str(root / "empty.dat")}
taking = _scripts_taking("--emulators-dir")
self.assertGreater(len(taking), 15)
tracked = ["git", "diff", "--name-only"]
before = subprocess.run(tracked, cwd=REPO_ROOT, capture_output=True, text=True).stdout
# In a copy: run in the checkout, a regression wrote there first.
checkout = scratch_checkout()
before = snapshot(checkout)
for path in taking:
extra = [arg.format(**values) for arg in self.EXTRA.get(path.stem, [])]
with self.subTest(script=path.stem):
proc = subprocess.run(
[*_command(path), "--emulators-dir", "nosuch", *extra],
capture_output=True, text=True, cwd=str(REPO_ROOT), timeout=600,
capture_output=True, text=True, cwd=str(checkout), timeout=600,
check=False, env={**os.environ, "RETROBIOS_OFFLINE": "1"},
)
self.assertNotEqual(proc.returncode, 0, proc.stdout[-500:])
@@ -328,9 +330,11 @@ class AMissingProfileDirIsRefused(unittest.TestCase):
]
self.assertEqual(written, [])
# Read as empty, generate_site rewrote published-parts.json and the
# FAQ, and sitesources source-paths.json.
after = subprocess.run(tracked, cwd=REPO_ROOT, capture_output=True, text=True).stdout
self.assertEqual(after, before)
# FAQ, and sitesources source-paths.json. The copy compares the
# files themselves: a name `git diff` already listed hid a rewrite.
after = snapshot(checkout)
self.assertEqual(sorted(k for k in before.keys() | after.keys()
if before.get(k) != after.get(k)), [])
class AMissingPlatformsDirIsRefused(unittest.TestCase):
@@ -369,7 +373,7 @@ class AMissingPlatformsDirIsRefused(unittest.TestCase):
with self.subTest(script=path.stem):
proc = subprocess.run(
[*_command(path), "--platforms-dir", "nosuch", *extra],
capture_output=True, text=True, cwd=str(REPO_ROOT), timeout=600,
capture_output=True, text=True, cwd=str(scratch_checkout()), timeout=600,
check=False, env={**os.environ, "RETROBIOS_OFFLINE": "1"},
)
self.assertNotEqual(proc.returncode, 0, proc.stdout[-500:])
@@ -377,6 +381,28 @@ class AMissingPlatformsDirIsRefused(unittest.TestCase):
self.assertRegex(proc.stderr, r"ERROR: .*nosuch")
class TheCopyKeepsTheCheckoutOutOfReach(unittest.TestCase):
"""The refusal tests above run their writers in a copy."""
def test_a_script_in_the_copy_writes_to_the_copy(self):
checkout = scratch_checkout()
proc = subprocess.run(
[sys.executable, "-c", "import sys; sys.path.insert(0, 'scripts'); "
"import generate_site; print(generate_site._REPO_ROOT)"],
capture_output=True, text=True, cwd=str(checkout), timeout=300, check=True,
)
self.assertEqual(Path(proc.stdout.strip()), checkout.resolve())
def test_a_rewrite_of_a_file_is_seen(self):
checkout = scratch_checkout()
page = checkout / "wiki" / "index.md"
before = snapshot(checkout)
original = page.read_bytes()
self.addCleanup(page.write_bytes, original)
page.write_bytes(original + b"\n")
self.assertNotEqual(snapshot(checkout), before)
class AMissingInputFileIsNamed(unittest.TestCase):
"""The documented `--from-md5-file missing.txt` ended on a
FileNotFoundError at the bottom of a stack trace."""
+32 -1
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import tests # noqa: F401 offline, scratch under tmp/, the suite's own cache
import json
import os
import sys
import unittest
import urllib.error
@@ -591,12 +592,42 @@ class EmuDeckScriptNamesHaveOneRule(unittest.TestCase):
with mock.patch.object(
emudeck_targets_scraper, "_list_emuscripts", return_value=listing
), mock.patch.object(scraper, "_load_retroarch_cores", return_value=["mgba"]):
cores = scraper._fetch_cores_for_target("unused", "Windows")
cores = scraper._fetch_cores_for_target("unused", "Windows", "windows-x86_64")
standalone = {emulator_of_script(name) for name in listing} - {None, "retroarch"}
self.assertEqual(standalone, {"mupen64plus_next", "pcsx2", "xemu"})
self.assertEqual(cores, sorted(standalone | {"mgba"}))
class EmuDeckWindowsReadsTheWindowsBuild(unittest.TestCase):
"""The RetroArch list was the first target of the architecture:
EmuDeck's Windows target carried fsuae, omicron and video_processor,
which the buildbot publishes for Linux only."""
def test_each_target_gets_its_own_retroarch_list(self):
seen: list[str] = []
def load(target: str) -> list[str]:
seen.append(target)
return [target]
scraper = emudeck_targets_scraper.Scraper(revision="0" * 40)
with mock.patch.object(
emudeck_targets_scraper, "_list_emuscripts", return_value=["emuDeckRetroArch.sh"]
), mock.patch.object(scraper, "_load_retroarch_cores", side_effect=load):
data = scraper.fetch_targets()
self.assertEqual(seen, ["linux-x86_64", "windows-x86_64"])
self.assertEqual(data["targets"]["windows"]["cores"], ["windows-x86_64"])
def test_the_list_is_read_by_target_name_from_any_directory(self):
cwd = os.getcwd()
self.addCleanup(os.chdir, cwd)
os.chdir(Path(__file__).parent)
load = emudeck_targets_scraper.Scraper._load_retroarch_cores
windows, linux = load("windows-x86_64"), load("linux-x86_64")
self.assertNotIn("video_processor", windows)
self.assertIn("video_processor", linux)
class HashScrapersRefuseAnUnknownVersion(unittest.TestCase):
"""mame_hash_scraper wrote core_version "unknown" after a failed API call
and cached it for a day, while the pipeline step still read OK."""
+19
View File
@@ -603,6 +603,25 @@ class OneSpellingPerOption(unittest.TestCase):
missing.append(f"{path.relative_to(REPO_ROOT)}:{node.lineno} {long}")
self.assertEqual(missing, [])
def test_one_long_name_per_option(self):
"""arcade_parent_importer took --database where every other script
takes --db, and refused `--db database.json`."""
import ast # noqa: PLC0415
renamed = {"--database": "--db"}
found = []
for path in sorted((REPO_ROOT / "scripts").rglob("*.py")):
tree = ast.parse(path.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if (isinstance(node, ast.Call)
and getattr(node.func, "attr", None) == "add_argument"):
found.extend(
f"{path.relative_to(REPO_ROOT)}:{node.lineno} {a.value}"
for a in node.args
if isinstance(a, ast.Constant) and a.value in renamed
)
self.assertEqual(found, [])
class ThePipelineRefreshesTheCommittedExport(unittest.TestCase):
"""Step 2e wrote to <output-dir>/upstream while exports/ is the copy the