Files
psxrecomp/tools/tests/test_bundled_release_layout.py
T

425 lines
21 KiB
Python

#!/usr/bin/env python3
"""Releases ship the compiled game built from COMMITTED generated/ C.
WHY THIS EXISTS
===============
Until 2026-09-30 the release flow was "setup-host": CI wiped generated/,
shipped emitters + sources + a wizard, and every player regenerated and
rebuilt the title on their own machine. That shipped uncompiled work. The
pivot to bundled releases touches four places that can drift back
independently -- the CI template, the scaffold's .gitignore, the scaffold's
packager wrapper, and Project Studio's audit/apply -- so this test pins each
one to the bundled contract rather than trusting the docs.
Every check here is against the ARTIFACT (the template text, the wrapper it
emits, the script's exit status on a fixture tree), never the intent.
Run: python3 tools/tests/test_bundled_release_layout.py
"""
import os
import re
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
FW = Path(__file__).resolve().parents[2]
TOOLKIT = FW / "tools" / "new_project_layout"
sys.path.insert(0, str(TOOLKIT))
TEMPLATE = FW / "docs" / "ci" / "templates" / "game-release.yml"
CHECK_GENERATED = FW / "tools" / "ci" / "check_generated.sh"
BOOT = "SLUS_012.34"
GIT = shutil.which("git")
BASH = shutil.which("bash")
if os.name == "nt":
# PATH can resolve bash to the WSL launcher and git to an MSYS shim.
# Neither reliably accepts the native Windows fixture/script paths.
git_root = Path(os.environ.get("ProgramFiles", "C:/Program Files")) / "Git"
if (git_root / "cmd/git.exe").is_file():
GIT = str(git_root / "cmd/git.exe")
if (git_root / "bin/bash.exe").is_file():
BASH = str(git_root / "bin/bash.exe")
def _git(root, *args):
return subprocess.run([GIT, "-C", str(root), *args], capture_output=True,
text=True, check=False)
def _fixture_title(root: Path, *, ignore_generated=False, track=True,
retail=False):
"""A minimal game repo with committed generated C."""
root.mkdir(parents=True, exist_ok=True)
_git(root, "init", "-q")
_git(root, "config", "user.email", "t@example.invalid")
_git(root, "config", "user.name", "t")
(root / "game.toml").write_text(
f'[game]\nname = "Fixture"\nexe = "disc/{BOOT}"\n'
'[runtime]\noverlay_cache = true\n', encoding="utf-8")
(root / "CMakeLists.txt").write_text(
'psxrecomp_add_game_runtime(psx-runtime\n'
f' GEN_MARKER "generated/{BOOT}_dispatch.c"\n'
f' GEN_FULL_GLOB "generated/{BOOT}_full_*.c"\n)\n', encoding="utf-8")
gen = root / "generated"
gen.mkdir(exist_ok=True)
(gen / f"{BOOT}_dispatch.c").write_text("/* dispatch */\n", encoding="utf-8")
(gen / f"{BOOT}_full_00.c").write_text("/* full */\n", encoding="utf-8")
if retail:
# A stray SCPH stem in the TITLE's generated/ is harmless to the gate
# now (BIOS C is the framework's business, see test_fails_on_retail_bios_c).
(gen / "SCPH1001_dispatch.c").write_text("/* retail */\n", encoding="utf-8")
(gen / "SCPH1001_full.c").write_text("/* retail */\n", encoding="utf-8")
(root / ".gitignore").write_text(
("/generated/\n" if ignore_generated else "") + "/disc/\n/bios/\n/dist/\n/analysis/\n",
encoding="utf-8")
_git(root, "add", "game.toml", "CMakeLists.txt", ".gitignore")
if track and not ignore_generated:
_git(root, "add", "generated")
_git(root, "commit", "-q", "-m", "fixture")
return root
class TemplateContract(unittest.TestCase):
def setUp(self):
self.text = TEMPLATE.read_text(encoding="utf-8")
def test_template_exists_under_its_new_name(self):
self.assertTrue(TEMPLATE.is_file(), TEMPLATE)
self.assertFalse((TEMPLATE.parent / "setup-release.yml").exists(),
"the setup-host template must not linger beside the bundled one")
def test_never_clears_generated(self):
self.assertNotIn("clear_generated", self.text)
self.assertFalse((FW / "tools" / "ci" / "clear_generated.sh").exists(),
"clear_generated.sh wipes the release's inputs; it must be gone")
def test_gates_on_committed_game_c_before_building(self):
self.assertIn("tools/ci/check_generated.sh", self.text)
self.assertLess(self.text.index("check_generated.sh"),
self.text.index("Configure & build the game"))
def test_links_committed_bios_backends(self):
self.assertNotIn("generate_openbios", self.text, "BIOS C is committed, not emitted in CI")
self.assertIn("-DPSXRECOMP_BIOS_STALE_FATAL=ON", self.text)
self.assertIn("BIOS backends linked: .*SCPH1001", self.text)
def test_configures_a_full_build(self):
self.assertIn("-DPSXRECOMP_REQUIRE_GAME_C=ON", self.text)
self.assertNotIn("-DPSXRECOMP_FORCE_SETUP_HOST=ON", self.text)
self.assertNotIn("-DPSXRECOMP_ALLOW_NO_BIOS=ON", self.text)
self.assertIn("linking generated game C (full runtime)", self.text)
self.assertIn("BIOS backends linked: .*OpenBIOS", self.text)
def test_packages_through_the_bundled_wrapper(self):
self.assertIn("scripts/package_release.sh build-ci", self.text)
self.assertNotIn("package_setup_release.sh", self.text)
self.assertNotIn("package_setup_host.sh", self.text)
def test_zip_verification_rejects_kit_content(self):
for pat in ("'^psxrecomp/'", "'^psxrecomp_cli.py'", "'^generated/'", "'^psxrecomp-game'"):
self.assertIn(pat, self.text, f"zip gate must reject {pat}")
self.assertIn("bios/openbios.bin", self.text)
def test_parses_as_yaml_when_pyyaml_is_available(self):
try:
import yaml # noqa: F401
except ImportError:
self.skipTest("pyyaml not installed")
import yaml
doc = yaml.safe_load(self.text)
self.assertEqual(set(doc["jobs"]), {"prepare", "build", "release"})
names = [s.get("name", "") for s in doc["jobs"]["build"]["steps"]]
self.assertIn("Verify committed game C", names)
self.assertIn("Package game zip", names)
class ScaffoldContract(unittest.TestCase):
def test_gitignore_templates_track_generated(self):
for p in (TOOLKIT / "templates" / "gitignore.in",
FW / "docs" / "ci" / "templates" / "game.gitignore"):
lines = p.read_text(encoding="utf-8").splitlines()
ignored = [ln for ln in lines if re.match(r"^\s*/?generated/?\s*$", ln)]
self.assertEqual(ignored, [], f"{p} ignores generated/")
def test_packager_wrapper_template_is_bundled(self):
tpl = TOOLKIT / "templates" / "package_release.sh.in"
self.assertTrue(tpl.is_file(), tpl)
self.assertFalse((TOOLKIT / "templates" / "package_setup_release.sh.in").exists())
text = tpl.read_text(encoding="utf-8")
self.assertIn("package_game_release.sh", text)
self.assertNotIn("package_setup_host.sh", text)
self.assertNotIn("--project-dir", text, "a bundled release ships no sources")
def test_scaffold_scripts_use_the_new_template_and_wrapper(self):
for script in (TOOLKIT / "setup_project.sh", TOOLKIT / "setup_project.ps1"):
text = script.read_text(encoding="utf-8")
self.assertIn("game-release.yml", text, script)
self.assertIn("package_release.sh.in", text, script)
self.assertNotIn("setup-release.yml", text, script)
self.assertNotIn("package_setup_release", text, script)
self.assertIn("git add generated", text, script)
def test_fill_tokens_fills_the_template(self):
with tempfile.TemporaryDirectory() as td:
out = Path(td) / "release.yml"
r = subprocess.run([sys.executable, str(TOOLKIT / "fill_tokens.py"),
str(TEMPLATE), str(out), "--ci-placeholders",
"--set", "ZIP_PREFIX=fx", "--set", "GAME_TITLE=Fixture: Recompiled"],
capture_output=True, text=True, check=False)
self.assertEqual(r.returncode, 0, r.stderr)
text = out.read_text(encoding="utf-8")
self.assertNotIn("YOUR_ZIP_PREFIX", text)
self.assertNotIn("YOUR_GAME_TITLE", text)
self.assertIn("dist/fx-*-${{ matrix.artifact }}.zip", text)
self.assertIn('name: "Fixture: Recompiled ${{ needs.prepare.outputs.tag }}"', text)
class CheckGeneratedScript(unittest.TestCase):
def run_check(self, root):
return subprocess.run([BASH, CHECK_GENERATED.as_posix(), "--root", root.as_posix()],
capture_output=True, text=True, check=False)
def test_passes_on_committed_game_c(self):
with tempfile.TemporaryDirectory() as td:
root = _fixture_title(Path(td) / "t")
r = self.run_check(root)
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
self.assertIn("generated C ok", r.stdout)
def test_fails_when_marker_is_missing(self):
with tempfile.TemporaryDirectory() as td:
root = _fixture_title(Path(td) / "t")
(root / "generated" / f"{BOOT}_dispatch.c").unlink()
r = self.run_check(root)
self.assertNotEqual(r.returncode, 0)
self.assertIn("is missing", r.stderr)
def test_fails_when_generated_is_ignored(self):
with tempfile.TemporaryDirectory() as td:
root = _fixture_title(Path(td) / "t", ignore_generated=True)
r = self.run_check(root)
self.assertNotEqual(r.returncode, 0)
self.assertIn("NOT tracked", r.stderr)
def test_fails_when_generated_is_untracked(self):
with tempfile.TemporaryDirectory() as td:
root = _fixture_title(Path(td) / "t", track=False)
r = self.run_check(root)
self.assertNotEqual(r.returncode, 0)
self.assertIn("NOT tracked", r.stderr)
def test_fails_on_retail_bios_c(self):
# Retail BIOS C is committed by the FRAMEWORK and linked (decision
# 2026-09-30); a copy in the title's generated/ is not a failure.
with tempfile.TemporaryDirectory() as td:
root = _fixture_title(Path(td) / "t", retail=True)
r = self.run_check(root)
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
def test_fails_when_framework_bios_backend_is_missing(self):
with tempfile.TemporaryDirectory() as td:
root = _fixture_title(Path(td) / "t")
(root / "psxrecomp" / "generated").mkdir(parents=True)
(root / "psxrecomp" / "generated" / "OpenBIOS_full.c").write_text("x", encoding="utf-8")
r = self.run_check(root)
self.assertNotEqual(r.returncode, 0)
self.assertRegex(r.stderr, r"psxrecomp/generated/OpenBIOS_(dispatch\.c is missing|full\.c exists but is not tracked)")
class ProjectStudioContract(unittest.TestCase):
def test_audit_flags_ignored_generated_and_missing_c(self):
from project_studio.detect import audit_project
with tempfile.TemporaryDirectory() as td:
root = _fixture_title(Path(td) / "t", ignore_generated=True)
report = audit_project(root)
by_id = {c.id: c for c in report.checks}
self.assertEqual(by_id["gitignore"].status.value, "fail")
self.assertEqual(by_id["generated_committed"].status.value, "fail")
def test_audit_passes_committed_generated(self):
from project_studio.detect import audit_project
with tempfile.TemporaryDirectory() as td:
root = _fixture_title(Path(td) / "t")
report = audit_project(root)
by_id = {c.id: c for c in report.checks}
self.assertEqual(by_id["generated_committed"].status.value, "pass")
self.assertNotIn("retail_bios_c", by_id)
def test_merge_gitignore_unignores_generated(self):
from project_studio.models import MigrateOptions
from project_studio.ops import op_merge_gitignore
with tempfile.TemporaryDirectory() as td:
root = _fixture_title(Path(td) / "t", ignore_generated=True)
res = op_merge_gitignore(root, MigrateOptions())
self.assertTrue(res.ok, res.message)
lines = (root / ".gitignore").read_text(encoding="utf-8").splitlines()
self.assertFalse(any(re.match(r"^\s*/?generated/?\s*$", ln) for ln in lines))
self.assertIn("/disc/", lines)
def test_emit_packager_and_ci_workflow(self):
from project_studio.models import MigrateOptions
from project_studio.ops import op_emit_ci_workflow, op_emit_packager
with tempfile.TemporaryDirectory() as td:
root = _fixture_title(Path(td) / "t")
opts = MigrateOptions(zip_prefix="fx", window_title="Fixture Recompiled",
project_name="FixtureRecomp")
pk = op_emit_packager(root, opts)
self.assertTrue(pk.ok, pk.message)
wrapper = root / "scripts" / "package_release.sh"
self.assertTrue(wrapper.is_file())
self.assertIn("package_game_release.sh", wrapper.read_text(encoding="utf-8"))
ci = op_emit_ci_workflow(root, opts)
self.assertTrue(ci.ok, ci.message)
wf = (root / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
self.assertIn("check_generated.sh", wf)
self.assertIn("dist/fx-*", wf)
# generate_ci --check agrees the installed workflow is current.
r = subprocess.run([sys.executable, str(FW / "tools" / "generate_ci.py"),
str(root), "--check"], capture_output=True, text=True,
check=False)
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
# ...and calls it stale once it carries a retired step, even though
# it lacks nothing the template has.
wf_path = root / ".github" / "workflows" / "release.yml"
wf_path.write_text(wf.replace(" - name: Verify committed game C\n",
" - name: Generate OpenBIOS backend C\n"
" run: true\n"
" - name: Verify committed game C\n"),
encoding="utf-8")
r = subprocess.run([sys.executable, str(FW / "tools" / "generate_ci.py"),
str(root), "--check"], capture_output=True, text=True,
check=False)
self.assertNotEqual(r.returncode, 0, "a retired step must read as stale")
self.assertIn("retired: Generate OpenBIOS backend C", r.stdout + r.stderr)
class PackagerScriptContract(unittest.TestCase):
def test_bundled_packager_routes_through_release_stage(self):
text = (FW / "tools" / "package_game_release.sh").read_text(encoding="utf-8")
self.assertIn("release_overlay_stage.sh", text)
self.assertIn("psx_add_mod_catalog", text)
self.assertIn("psx_add_overlay_toolchain", text)
self.assertIn("PSXRECOMP_FORCE_SETUP_HOST:BOOL=ON", text,
"must refuse to package a setup host as a game")
# No source allowlist of any kind: nothing project-owned is shipped.
self.assertNotIn("--project-file", text)
self.assertNotIn("--project-dir", text)
def test_bundled_packager_ships_recomp_ui_notices(self):
# The executable links recomp-ui and assets/ carries its fonts and
# images; their license and notices must ship with them.
text = (FW / "tools" / "package_game_release.sh").read_text(encoding="utf-8")
self.assertIn('"${STAGE}/licenses/recomp-ui-LICENSE"', text)
self.assertIn('assets/common/${sub}/NOTICE.md', text)
self.assertIn('"${STAGE}/assets/${sub}/NOTICE.md"', text)
self.assertIn("RECOMP_UI_ROOT", text, "must follow the tree the build linked")
self.assertIn('[[ -f "${UI_ROOT}/recomp_ui.cmake" ]] ||', text,
"missing recomp-ui tree must fail packaging")
self.assertIn('[[ -f "${notice}" ]] ||', text,
"missing asset notices must fail packaging")
template = TEMPLATE.read_text(encoding="utf-8")
for path in ("licenses/recomp-ui-LICENSE", "assets/fonts/NOTICE.md",
"assets/img/NOTICE.md"):
self.assertIn(f"'{path}'", template,
f"the CI zip verifier must require {path}")
def test_scripts_are_executable_and_parse(self):
for rel in ("tools/package_game_release.sh", "tools/ci/check_generated.sh"):
p = FW / rel
self.assertTrue(os.access(p, os.X_OK), f"{rel} not executable")
r = subprocess.run([BASH, "-n", p.as_posix()], capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stderr)
class MigrateBundledHelpers(unittest.TestCase):
"""tools/migrate_bundled_release.py decides from the title's files; pin each decision."""
def test_disc_resolution_order(self):
from project_studio.migrate_bundled import find_disc
with tempfile.TemporaryDirectory() as td:
root = Path(td)
(root / "disc").mkdir()
self.assertIsNone(find_disc(root, None), "no disc -> None, never a guess")
a = root / "disc" / "A.cue"
a.write_text("", encoding="utf-8")
self.assertEqual(find_disc(root, None), a)
(root / "disc" / "B.cue").write_text("", encoding="utf-8")
self.assertIsNone(find_disc(root, None), "two cues is ambiguous")
(root / "disc.cfg").write_text("disc/B.cue\n", encoding="utf-8")
self.assertEqual(find_disc(root, None), root / "disc" / "B.cue")
self.assertEqual(find_disc(root, "disc/A.cue"), a, "--disc wins")
self.assertIsNone(find_disc(root, "disc/missing.cue"))
def test_overlay_cache_key_is_added_once_and_never_flips_false(self):
from project_studio.migrate_bundled import ensure_overlay_cache_key
with tempfile.TemporaryDirectory() as td:
root = Path(td)
gt = root / "game.toml"
gt.write_text("[game]\nexe = \"X\"\n\n[runtime]\nbios_hle = true\n", encoding="utf-8")
changed, _ = ensure_overlay_cache_key(root, dry_run=True)
self.assertTrue(changed)
self.assertNotIn("overlay_cache", gt.read_text(encoding="utf-8"), "dry-run writes nothing")
changed, _ = ensure_overlay_cache_key(root, dry_run=False)
self.assertTrue(changed)
text = gt.read_text(encoding="utf-8")
self.assertRegex(text, r"\[runtime\]\n(#[^\n]*\n)*overlay_cache = true\n")
self.assertIn("bios_hle = true", text)
changed, _ = ensure_overlay_cache_key(root, dry_run=False)
self.assertFalse(changed, "idempotent")
gt.write_text("[runtime]\noverlay_cache = false\n", encoding="utf-8")
changed, note = ensure_overlay_cache_key(root, dry_run=False)
self.assertFalse(changed, "an explicit false is the title's decision")
self.assertIn("false", note)
gt.write_text("[game]\nexe = \"X\"\n", encoding="utf-8")
ensure_overlay_cache_key(root, dry_run=False)
self.assertIn("[runtime]\n", gt.read_text(encoding="utf-8"), "section created when absent")
def test_zip_prefix_is_kept_from_either_wrapper(self):
from project_studio.migrate_bundled import zip_prefix_from_wrapper
with tempfile.TemporaryDirectory() as td:
root = Path(td)
(root / "scripts").mkdir()
self.assertEqual(zip_prefix_from_wrapper(root), "")
(root / "scripts" / "package_setup_release.sh").write_text(
"exec bash x.sh \\\n --zip-prefix bpe \\\n \"$@\"\n", encoding="utf-8")
self.assertEqual(zip_prefix_from_wrapper(root), "bpe")
(root / "scripts" / "package_release.sh").write_text(
"--zip-prefix newer\n", encoding="utf-8")
self.assertEqual(zip_prefix_from_wrapper(root), "newer", "the live wrapper wins")
def test_refuses_without_preconditions(self):
from project_studio.migrate_bundled import BundledMigrateOptions, migrate_to_bundled_release
with tempfile.TemporaryDirectory() as td:
root = Path(td)
r = migrate_to_bundled_release(root, BundledMigrateOptions(dry_run=True))
self.assertFalse(r.ok)
self.assertIn("not a git repository", r.message)
subprocess.run([GIT, "init", "-q", str(root)], check=True)
(root / "CMakeLists.txt").write_text("project(x)\n", encoding="utf-8")
r = migrate_to_bundled_release(root, BundledMigrateOptions(dry_run=True))
self.assertFalse(r.ok)
self.assertIn("psxrecomp_add_game_runtime", r.message)
def test_wrapper_parses_and_documents_itself(self):
p = FW / "tools" / "migrate_bundled_release.py"
self.assertTrue(os.access(p, os.X_OK))
r = subprocess.run([sys.executable, str(p), "--help"], capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stderr)
for flag in ("--psxrecomp-ref", "--skip-generate", "--push", "--dry-run", "--disc"):
self.assertIn(flag, r.stdout)
if __name__ == "__main__":
if GIT is None or BASH is None:
print("git and bash are required", file=sys.stderr)
sys.exit(2)
unittest.main(verbosity=1)