mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-13 05:23:51 -06:00
chore: merge scraper and tool fixes
This commit is contained in:
commit
8e3ba2cfd3
28 files changed
+744
-215
No files matched your search
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
|
||||
@@ -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, [])
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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```",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user