chore: merge the app on the card

This commit is contained in:
Abdessamad Derraz committed 2026-10-26 21:14:51 +01:00
commit f4bc8878d9
13 files changed
+1658 -18

No files matched your search

+470
View File
@@ -0,0 +1,470 @@
"""The RetroBIOS app put on a handheld's card from a computer (--add-app).
Cards are folders holding the files their systems write, the repository a
loopback copy of the launcher list and the files it names. What a console
then does with the files (the Archive Manager, the Ports list, the Tools
menu) is not exercised.
"""
from __future__ import annotations
import hashlib
import io
import json
import os
import shutil
import stat
import subprocess
import sys
import tempfile
import unittest
import zipfile
from pathlib import Path
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT))
sys.path.insert(0, str(REPO_ROOT / "tests"))
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import install # noqa: E402
from test_install_selection import FakeRepository # noqa: E402
INDEX = REPO_ROOT / "install" / "launchers" / "index.json"
def make_card(root: Path, system: str) -> Path:
"""A card as each system leaves it, and the BIOS folder on it."""
bios = _card_files(root, system)
bios.mkdir(parents=True, exist_ok=True)
return bios
def _card_files(root: Path, system: str) -> Path:
root.mkdir(parents=True, exist_ok=True)
if system == "muOS":
(root / "MUOS" / "bios").mkdir(parents=True)
return root / "MUOS" / "bios"
if system == "KNULLI":
(root / "system").mkdir()
(root / "system" / "batocera.conf").write_text("system.hostname=KNULLI\n")
return root / "bios"
if system == "ROCKNIX":
(root / ".config" / "system" / "configs").mkdir(parents=True)
(root / ".config" / "system" / "configs" / "system.cfg").write_text(
"root.password=rocknix\n")
return root / "roms" / "bios"
if system == "Onion":
(root / ".tmp_update" / "onionVersion").mkdir(parents=True)
(root / ".tmp_update" / "onionVersion" / "version.txt").write_text("4.3.1\n")
return root / "BIOS"
if system == "spruceOS":
(root / "spruce").mkdir()
return root / "BIOS"
if system == "MinUI":
(root / ".system").mkdir()
(root / ".system" / "version.txt").write_text("MinUI-20250101\n")
return root
raise ValueError(system)
# The file list each card's system gets.
CARD_PLATFORMS = {"muOS": "retroarch", "KNULLI": "knulli", "ROCKNIX": "rocknix",
"Onion": "retroarch", "spruceOS": "retroarch", "MinUI": "minui"}
class AppRepository(FakeRepository):
"""The fake repository with the launcher list and every file it names,
and the BIOS lists of the systems whose cards are tried."""
def __enter__(self) -> AppRepository:
super().__enter__()
index = json.loads(INDEX.read_text(encoding="utf-8"))
(self.root / "install" / "launchers").mkdir()
(self.root / "install" / "launchers" / "index.json").write_text(json.dumps(index))
for launcher in index["launchers"].values():
for entry in launcher.get("files", launcher.get("members", [])):
if "repo_path" in entry:
target = self.root / entry["repo_path"]
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(REPO_ROOT / entry["repo_path"], target)
base = json.loads((self.root / "install" / "retroarch.json").read_text())
for plat in ("knulli", "rocknix", "minui"):
(self.root / "install" / f"{plat}.json").write_text(
json.dumps({**base, "platform": plat}))
return self
def card(self, system: str) -> tuple[Path, Path]:
root = self.root / "cards" / system
return root, make_card(root, system)
def tamper(self, repo_path: str) -> None:
"""One byte changed, the size kept: only the hash tells."""
path = self.root / repo_path
data = bytearray(path.read_bytes())
data[-1] ^= 0x20
path.write_bytes(bytes(data))
def cli(self, *options: str, env: dict | None = None) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, str(REPO_ROOT / "install.py"), *options],
env={**self.env(), **(env or {})}, capture_output=True, text=True,
timeout=120, check=False, stdin=subprocess.DEVNULL)
def _tempdir(test: unittest.TestCase) -> Path:
(REPO_ROOT / "tmp").mkdir(exist_ok=True)
path = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
test.addCleanup(shutil.rmtree, path, True)
return path
class Placement(unittest.TestCase):
def test_each_card_and_where_its_app_goes(self):
root = _tempdir(self)
expected = {"muOS": "ARCHIVE", "KNULLI": "roms/ports", "ROCKNIX": ".config/modules"}
for system, folder in expected.items():
with self.subTest(system=system):
card = install.card_app(make_card(root / system, system))
self.assertIsNotNone(card)
self.assertEqual(card.system, system)
self.assertEqual(card.root, root / system)
self.assertEqual(card.place.folder, folder)
self.assertEqual(card.why, "")
def test_a_bios_folder_inside_the_card_finds_its_root(self):
root = _tempdir(self)
bios = make_card(root / "sd1", "muOS")
bios.mkdir(parents=True, exist_ok=True)
self.assertEqual(install.card_app(bios).root, root / "sd1")
# The card's root itself is not its BIOS folder.
self.assertIsNone(install.card_app(root / "sd1"))
def test_cards_without_a_launcher_say_why(self):
root = _tempdir(self)
for system, words in (("Onion", "has no Python"), ("MinUI", "has no Python"),
("spruceOS", "no RetroBIOS app for spruceOS")):
with self.subTest(system=system):
card = install.card_app(make_card(root / system, system))
self.assertIsNone(card.place)
self.assertIn(words, card.why)
def test_a_folder_on_no_card(self):
self.assertIsNone(install.card_app(_tempdir(self) / "bios"))
def test_the_table_names_systems_detection_names(self):
names = {rule.name for rule in install._card_rules()}
names |= {install.display_name(name) for name, _f, _p in install._CARD_RULES}
self.assertLessEqual(set(install.APP_PLACES), names)
self.assertLessEqual(install.NO_PYTHON_CARDS, names)
self.assertFalse(set(install.APP_PLACES) & install.NO_PYTHON_CARDS)
def test_the_list_follows_the_table(self):
index = json.loads(INDEX.read_text(encoding="utf-8"))
for place in install.APP_PLACES.values():
with self.subTest(key=place.key):
targets = install.app_targets(index, place)
for target in targets:
self.assertTrue(target["dest"].startswith(place.folder + "/"))
self.assertEqual(target.get("executable", False) and not place.executable,
False, target["dest"])
class TheList(unittest.TestCase):
def setUp(self):
self.index = json.loads(INDEX.read_text(encoding="utf-8"))
self.place = install.APP_PLACES["KNULLI"]
def refused(self, change) -> str:
data = json.loads(json.dumps(self.index))
change(data["launchers"]["knulli"]["files"][0], data)
with self.assertRaises(ValueError) as caught:
install.app_targets(data, self.place)
return str(caught.exception)
def test_entries_are_held_to_their_folder(self):
self.assertIn("outside", self.refused(
lambda entry, _d: entry.update(dest="roms/RetroBIOS.sh")))
self.assertIn("unsafe", self.refused(
lambda entry, _d: entry.update(dest="roms/ports/../../bios/x")))
self.assertIn("unsafe", self.refused(
lambda entry, _d: entry.update(repo_path="/etc/passwd")))
def test_hash_size_and_source_are_checked(self):
self.assertIn("sha256", self.refused(lambda entry, _d: entry.update(sha256="ab")))
self.assertIn("size", self.refused(lambda entry, _d: entry.update(size=0)))
self.assertIn("repo_path or content", self.refused(
lambda entry, _d: entry.update(content="x")))
self.assertIn("schema", self.refused(lambda _e, data: data.update(schema=2)))
self.assertIn("no launcher", self.refused(
lambda _e, data: data["launchers"].pop("knulli")))
def test_the_archive_rebuilds_to_its_recorded_hash(self):
launcher = self.index["launchers"]["muos"]
members = [(entry, (REPO_ROOT / entry["repo_path"]).read_bytes()
if "repo_path" in entry else entry["content"].encode())
for entry in launcher["members"]]
data = install.app_archive(list(reversed(members)))
# Under every Python the installer runs on: CI runs this under 3.8 too.
self.assertEqual(hashlib.sha256(data).hexdigest(), launcher["archive"]["sha256"])
self.assertEqual(len(data), launcher["archive"]["size"])
@unittest.skipIf(sys.version_info < (3, 9), "the build scripts need Python 3.9")
def test_the_archive_holds_what_the_release_archive_holds(self):
launcher = self.index["launchers"]["muos"]
members = [(entry, (REPO_ROOT / entry["repo_path"]).read_bytes()
if "repo_path" in entry else entry["content"].encode())
for entry in launcher["members"]]
data = install.app_archive(members)
import build_port # noqa: PLC0415
built = _tempdir(self) / build_port.MUOS_APP
build_port.build_archive(build_port.MUOS_APP, built)
with zipfile.ZipFile(built) as release, zipfile.ZipFile(io.BytesIO(data)) as ours:
self.assertEqual(release.namelist(), ours.namelist())
for name in release.namelist():
self.assertEqual(release.read(name), ours.read(name), name)
self.assertEqual(release.getinfo(name).external_attr,
ours.getinfo(name).external_attr, name)
class PutTheApp(unittest.TestCase):
def setUp(self):
self.repo = AppRepository()
self.repo.__enter__()
self.addCleanup(self.repo.__exit__, None, None, None)
install.KEPT.reset()
patcher = mock.patch.multiple(
install, LAUNCHERS_URL=f"{self.repo.env()['RETROBIOS_BASE_URL']}"
"/install/launchers/index.json",
RAW_FILE_URL=f"{self.repo.env()['RETROBIOS_BASE_URL']}/{{path}}")
patcher.start()
self.addCleanup(patcher.stop)
self.index = json.loads(INDEX.read_text(encoding="utf-8"))
self.bios: dict[str, Path] = {}
def put(self, system: str) -> tuple[install.CardApp, dict]:
if system not in self.bios:
self.bios[system] = self.repo.card(system)[1]
card = install.card_app(self.bios[system])
return card, install.put_app(card)
def assertMatchesTheList(self, card: install.CardApp) -> None:
for target in install.app_targets(self.index, card.place):
path = card.root / target["dest"]
self.assertEqual(hashlib.sha256(path.read_bytes()).hexdigest(), target["sha256"])
def test_muos_gets_its_archive(self):
card, fields = self.put("muOS")
self.assertTrue(fields["ok"], fields)
self.assertEqual(fields["written"], ["ARCHIVE/RetroBIOS.muxapp"])
self.assertMatchesTheList(card)
with zipfile.ZipFile(card.root / "ARCHIVE" / "RetroBIOS.muxapp") as archive:
self.assertEqual(archive.read("RetroBIOS/install.py"),
(REPO_ROOT / "install.py").read_bytes())
mode = archive.getinfo("RetroBIOS/mux_launch.sh").external_attr >> 16
self.assertEqual(mode & 0o777, 0o755)
def test_knulli_and_rocknix_get_the_files(self):
for system, script, executable in (("KNULLI", "roms/ports/RetroBIOS.sh", False),
("ROCKNIX", ".config/modules/RetroBIOS.sh", True)):
with self.subTest(system=system):
card, fields = self.put(system)
self.assertTrue(fields["ok"], fields)
self.assertEqual(sorted(fields["written"]), sorted(fields["dests"]))
self.assertMatchesTheList(card)
mode = (card.root / script).stat().st_mode
self.assertEqual(bool(mode & stat.S_IXUSR), executable)
def test_a_second_run_writes_nothing(self):
card, first = self.put("KNULLI")
stamps = {path: path.stat().st_mtime_ns for path in card.root.rglob("*")}
_card, second = self.put("KNULLI")
self.assertTrue(second["ok"])
self.assertEqual(second["written"], [])
self.assertEqual(second["dests"], first["dests"])
self.assertEqual({path: path.stat().st_mtime_ns for path in card.root.rglob("*")},
stamps)
self.assertIn("already on the KNULLI card", install.app_lines(card, second)[0])
def test_a_different_file_in_place_is_kept_aside(self):
root, bios = self.repo.card("KNULLI")
self.bios["KNULLI"] = bios
mine = root / "roms" / "ports" / "RetroBIOS.sh"
mine.parent.mkdir(parents=True)
mine.write_text("my own script\n")
card, fields = self.put("KNULLI")
self.assertTrue(fields["ok"], fields)
self.assertEqual(len(fields["kept"]), 1)
self.assertEqual(fields["kept"][0]["files"], 1)
kept = list(Path(fields["kept"][0]["folder"]).rglob("RetroBIOS.sh"))
self.assertEqual([path.read_text() for path in kept], ["my own script\n"])
self.assertMatchesTheList(card)
self.assertTrue(any("kept in" in line for line in install.app_lines(card, fields)))
def test_a_tampered_file_is_refused_and_nothing_written(self):
self.repo.tamper("ports/shared/tui.txt")
root, bios = self.repo.card("ROCKNIX")
self.bios["ROCKNIX"] = bios
before = sorted(root.rglob("*"))
card, fields = self.put("ROCKNIX")
self.assertFalse(fields["ok"])
self.assertIn("SHA-256 mismatch", fields["reason"])
self.assertEqual(fields["written"], [])
self.assertEqual(sorted(root.rglob("*")), before)
self.assertIn("could not be put", install.app_lines(card, fields)[0])
def test_a_tampered_member_never_reaches_the_archive(self):
self.repo.tamper("ports/muos/mux_launch.sh")
root, bios = self.repo.card("muOS")
self.bios["muOS"] = bios
_card, fields = self.put("muOS")
self.assertFalse(fields["ok"])
self.assertFalse((root / "ARCHIVE").exists())
def test_a_card_without_a_launcher_is_not_written(self):
root, bios = self.repo.card("Onion")
self.bios["Onion"] = bios
_card, fields = self.put("Onion")
self.assertFalse(fields["ok"])
self.assertIn("has no Python", fields["reason"])
self.assertEqual(sorted(p.name for p in root.iterdir()), [".tmp_update", "BIOS"])
class TheQuestion(unittest.TestCase):
"""Line mode at a terminal asks once, and only yes puts the app."""
def run_finish(self, answer: str, system: str = "muOS", **given) -> list:
root = _tempdir(self)
bios = make_card(root / "card", system)
args = mock.Mock(add_app=False, yes=False)
for key, value in given.items():
setattr(args, key, value)
put = mock.Mock(return_value={"system": system, "folder": str(root / "card"),
"ok": True, "reason": "", "dests": ["x"],
"written": ["x"], "kept": []})
out = io.StringIO()
with mock.patch.object(install, "_ask", return_value=answer) as ask, \
mock.patch.object(install, "put_app", put), \
mock.patch("sys.stdout", out):
install.finish_app(args, [("retroarch", bios)], [], interactive=True)
self.output = out.getvalue()
return [ask.call_args_list, put.call_args_list]
def test_yes_puts_it(self):
asked, put = self.run_finish("y")
self.assertEqual(len(asked), 1)
self.assertIn("[y/N]", asked[0][0][0])
self.assertEqual(len(put), 1)
self.assertIn("Archive Manager", self.output)
def test_enter_or_no_leaves_the_card_alone(self):
for answer in ("", "n", "no", "q"):
with self.subTest(answer=answer):
_asked, put = self.run_finish(answer)
self.assertEqual(put, [])
def test_yes_flag_asks_nothing(self):
asked, put = self.run_finish("y", yes=True)
self.assertEqual((asked, put), ([], []))
def test_a_console_without_python_says_so_once(self):
asked, put = self.run_finish("y", system="Onion")
self.assertEqual((asked, put), ([], []))
self.assertIn(install.NO_PYTHON_WORDS, self.output)
class TheOption(unittest.TestCase):
"""--add-app is applied or refused, never ignored."""
@classmethod
def setUpClass(cls):
cls.repo = AppRepository()
cls.repo.__enter__()
@classmethod
def tearDownClass(cls):
cls.repo.__exit__(None, None, None)
def test_refused_where_it_cannot_apply(self):
root = _tempdir(self)
pack = root / "RetroArch_BIOS_Pack.zip"
zipfile.ZipFile(pack, "w").close()
for options, words in (
(["--check"], "--check and --dry-run do not"),
(["--dry-run"], "--check and --dry-run do not"),
(["--list-systems"], "a listing does not"),
(["--from-pack", str(pack)], "--from-pack makes no network request"),
(["--offline"], "which --offline does not"),
(["--restore", str(root)], "takes no --add-app"),
):
with self.subTest(options=options):
where = [] if "--restore" in options else ["--platform", "retroarch",
"--dest", str(root)]
proc = self.repo.cli(*where, "--add-app", *options)
self.assertEqual(proc.returncode, 2, proc.stderr)
self.assertIn("error:", proc.stderr)
self.assertIn(words, proc.stderr)
def test_refused_for_a_folder_on_no_card_or_no_launcher(self):
root = _tempdir(self)
for system, words in ((None, "is not on a card this installer recognises"),
("Onion", "Onion has no Python"),
("spruceOS", "no RetroBIOS app for spruceOS yet")):
with self.subTest(system=system):
bios = make_card(root / str(system), system) if system else root / "bios"
proc = self.repo.cli("--platform", "retroarch", "--dest", str(bios),
"--add-app", "--events")
self.assertEqual(proc.returncode, 2, proc.stderr)
self.assertIn(words, proc.stderr)
events = [json.loads(line) for line in proc.stdout.splitlines()]
app = [event for event in events if event["event"] == "app"]
self.assertEqual(len(app), 1)
self.assertFalse(app[0]["ok"])
self.assertIn(words, app[0]["reason"])
self.assertEqual(events[-1], {"event": "end", "code": 2})
self.assertFalse([e for e in events if e["event"] == "download"])
def test_applied_after_the_install_and_reported(self):
root = _tempdir(self)
for system in ("muOS", "KNULLI"):
with self.subTest(system=system):
bios = make_card(root / system, system)
plat = CARD_PLATFORMS[system]
command = ("--platform", plat, "--dest", str(root / system), "--add-app",
"--events")
proc = self.repo.cli(*command)
self.assertEqual(proc.returncode, 0, proc.stderr)
events = [json.loads(line) for line in proc.stdout.splitlines()]
kinds = [event["event"] for event in events]
self.assertEqual(kinds[-3:], ["summary", "app", "end"])
app = events[-2]
self.assertTrue(app["ok"], app)
self.assertEqual(app["system"], system)
self.assertEqual(app["folder"], str(root / system))
self.assertEqual(sorted(app["written"]), sorted(app["dests"]))
self.assertEqual(list(app), ["event", *install.EVENT_KEYS["app"]])
self.assertEqual(len(list(bios.iterdir())), 5)
self.assertIn("RetroBIOS is on the", proc.stderr)
again = self.repo.cli(*command)
app = [json.loads(line) for line in again.stdout.splitlines()][-2]
self.assertEqual((again.returncode, app["ok"], app["written"]), (0, True, []))
def test_not_added_when_the_install_does_not_finish(self):
root = _tempdir(self)
make_card(root / "card", "muOS")
# A BIOS folder that is a file: every download fails.
(root / "card" / "MUOS" / "bios").rmdir()
(root / "card" / "MUOS" / "bios").write_text("in the way")
proc = self.repo.cli("--platform", "retroarch", "--dest", str(root / "card"),
"--add-app", "--events")
self.assertNotEqual(proc.returncode, 0)
events = [json.loads(line) for line in proc.stdout.splitlines()]
app = [event for event in events if event["event"] == "app"]
self.assertEqual(len(app), 1, proc.stderr)
self.assertEqual((app[0]["ok"], app[0]["reason"]),
(False, "the install did not finish"))
self.assertFalse((root / "card" / "ARCHIVE").exists())
if __name__ == "__main__":
unittest.main()
+194 -1
View File
@@ -79,7 +79,7 @@ def _args(**given) -> argparse.Namespace:
cores_dir=None, system=None, core=None, region=None, no_game_data=False,
list_systems=False, list_cores=False, romm_firmware=None, list_emulators=False,
no_input=False, dry_run=False, yes=False, quiet=False, events=False,
restore=None, from_pack=None, manifest=None, tui=False, plain=False,
restore=None, from_pack=None, manifest=None, tui=False, plain=False, add_app=False,
)
values["from"] = None
values.update(given)
@@ -3380,6 +3380,144 @@ class SaveDirectoryRepository(FakeRepository):
return self
def _app_card(test: unittest.TestCase, system: str) -> tuple[Path, Path]:
"""A card of system in the repository's tmp/, its root and BIOS folder."""
from test_install_app import make_card # noqa: PLC0415
(REPO_ROOT / "tmp").mkdir(exist_ok=True)
root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
test.addCleanup(shutil.rmtree, root, True)
return root / "card", make_card(root / "card", system)
def _installed_state(dest: Path, code: int = 0, failed: list | None = None) -> install.TuiState:
"""The result screen of an install into dest."""
state = fresh_state()
state.platform, state.dest, state.manifest = "retroarch", dest, MANIFEST
state.stack, state.run, state.code = ["result"], "install", code
state.summary = {"event": "summary", "mode": "install", "installed": 5, "up_to_date": 0,
"pending": 0, "failed": failed or [], "skipped": [], "left_out": [],
"kept": [], "interrupted": False}
return state
class AppOnTheCard(unittest.TestCase):
"""The RetroBIOS app offered on the result screen and in the options."""
def setUp(self):
patcher = mock.patch.dict(os.environ)
patcher.start()
self.addCleanup(patcher.stop)
os.environ.pop(install.OFFLINE_VARIABLE, None)
def fields(self, card_root: Path, ok: bool = True) -> dict:
return {"event": "app", "system": "muOS", "folder": str(card_root), "ok": ok,
"reason": "" if ok else "it could not be downloaded: no connection",
"dests": ["ARCHIVE/RetroBIOS.muxapp"],
"written": ["ARCHIVE/RetroBIOS.muxapp"] if ok else [], "kept": []}
def test_the_result_offers_it_and_exit_stays_the_default(self):
root, bios = _app_card(self, "muOS")
state = _installed_state(bios)
rows = install.tui_rows(state)
self.assertEqual([row.action for row in rows], ["exit", "addapp"])
self.assertEqual(rows[1].label, install.APP_OFFER)
screen = "\n".join(install.tui_view(state, 80, 24))
self.assertIn(install.APP_OFFER, screen)
self.assertIn("A/Enter Exit", screen)
self.assertEqual(press(state, "enter"), [])
self.assertEqual(state.exit_code, 0)
def test_choosing_it_puts_it_then_says_what_it_did(self):
root, bios = _app_card(self, "muOS")
state = _installed_state(bios)
effects = press(state, "down", "enter")
self.assertEqual(effects, [("app", install.card_app(bios))])
self.assertTrue(state.app_running)
screen = "\n".join(install.tui_view(state, 80, 24))
self.assertIn("Putting RetroBIOS on the card...", screen)
# Nothing ends the screen while the card is written.
self.assertEqual(press(state, "enter", "esc"), [])
self.assertIsNone(state.exit_code)
install.tui_receive(state, ("app", self.fields(root)))
self.assertEqual(install.tui_rows(state), [])
screen = "\n".join(install.tui_view(state, 80, 24))
self.assertIn("RetroBIOS is on the muOS card: 1 file written", screen)
self.assertIn("Archive Manager", screen)
self.assertIn("--add-app", install.tui_command(state))
press(state, "enter")
self.assertEqual(state.exit_code, 0)
def test_a_failure_is_said_and_ends_on_1(self):
root, bios = _app_card(self, "KNULLI")
state = _installed_state(bios)
press(state, "down", "enter")
install.tui_receive(state, ("app", {**self.fields(root, ok=False), "system": "KNULLI"}))
_title, lines = install.tui_outcome(state)
self.assertTrue(any("could not be put on the KNULLI card" in line for line in lines))
press(state, "enter")
self.assertEqual(state.exit_code, 1)
def test_space_and_b_never_put_it(self):
_root, bios = _app_card(self, "muOS")
state = _installed_state(bios)
self.assertEqual(press(state, "down", " "), [])
self.assertFalse(state.app_running)
press(state, "esc")
self.assertEqual(state.exit_code, 0)
def test_no_offer_where_it_cannot_go(self):
_root, muos = _app_card(self, "muOS")
_root, onion = _app_card(self, "Onion")
cases = {
"failed install": _installed_state(muos, 1, [{"dest": "a", "reason": "x"}]),
"plain folder": _installed_state(SYSTEM_DIR),
"no launcher": _installed_state(onion),
}
offline = _installed_state(muos)
for name, state in cases.items():
with self.subTest(case=name):
self.assertEqual(install.tui_rows(state), [])
os.environ[install.OFFLINE_VARIABLE] = "1"
self.assertEqual(install.tui_rows(offline), [])
os.environ.pop(install.OFFLINE_VARIABLE)
_title, lines = install.tui_outcome(cases["no launcher"])
self.assertIn(install.NO_PYTHON_WORDS, lines)
def test_put_by_the_install_itself_no_second_offer(self):
root, bios = _app_card(self, "muOS")
state = _installed_state(bios)
install._take_event(state, self.fields(root))
self.assertEqual(install.tui_rows(state), [])
self.assertIn("RetroBIOS is on the muOS card", "\n".join(install.tui_outcome(state)[1]))
def test_the_options_row_sets_the_flag_of_the_install_only(self):
_root, bios = _app_card(self, "muOS")
state = _installed_state(bios)
state.stack = ["plan", "options"]
rows = install.tui_rows(state)
row = next(row for row in rows if row.action == "addapp")
self.assertEqual((row.label, row.right), ("Put RetroBIOS on this card: no", "--add-app"))
state.focus["options"] = rows.index(row)
press(state, "enter")
self.assertTrue(state.add_app)
self.assertIn("--add-app", install.tui_argv(state, "install", "py", "install.py"))
for run in ("check", "confirm", "preview"):
self.assertNotIn("--add-app", install.tui_argv(state, run, "py", "install.py"))
self.assertIn("--add-app", install.tui_command(state))
press(state, "left")
self.assertFalse(state.add_app)
plain = _installed_state(SYSTEM_DIR)
plain.stack = ["plan", "options"]
self.assertFalse(any(row.action == "addapp" for row in install.tui_rows(plain)))
def test_the_command_line_flag_ticks_the_row(self):
_root, bios = _app_card(self, "ROCKNIX")
state, _effects = install.tui_start(_args(add_app=True, platform="rocknix",
dest=bios), "linux", detect=False)
self.assertTrue(state.add_app)
@unittest.skipUnless(pyte and pty and hasattr(pty, "fork"),
"screen tests need pyte (pip install pyte==0.8.2) and a pty")
class EndToEnd(unittest.TestCase):
@@ -3410,6 +3548,61 @@ class EndToEnd(unittest.TestCase):
self.addCleanup(lambda: term.status is None and os.kill(term.pid, 9))
return term
def card_repo(self, system: str) -> tuple[Path, Path]:
from test_install_app import AppRepository, make_card # noqa: PLC0415
repo = self.repo(AppRepository)
root = repo.root / "card"
return root, make_card(root, system)
def assertAppOnCard(self, root: Path, system: str) -> None:
index = json.loads((REPO_ROOT / "install" / "launchers" / "index.json").read_text())
targets = install.app_targets(index, install.APP_PLACES[system])
for target in targets:
data = (root / target["dest"]).read_bytes()
self.assertEqual(hashlib.sha256(data).hexdigest(), target["sha256"],
target["dest"])
def test_put_the_app_on_a_muos_card_from_the_result(self):
root, bios = self.card_repo("muOS")
term = self.start("--platform", "retroarch", "--dest", str(root))
term.wait("files to download")
term.send("\r")
screen = term.wait(install.APP_OFFER)
self.assertIn("> Exit", screen)
term.send(DOWN, "\r")
screen = term.wait("RetroBIOS is on the muOS card")
self.assertIn("Archive Manager", screen)
term.send("\r")
self.assertEqual(term.close(), 0)
self.assertAppOnCard(root, "muOS")
self.assertEqual(len(list(bios.iterdir())), 5)
self.assertIn("RetroBIOS is on the muOS card", term.after())
def test_put_the_app_on_a_knulli_card_from_the_options(self):
root, _bios = self.card_repo("KNULLI")
term = self.start("--platform", "knulli", "--dest", str(root))
term.wait("files to download")
term.send("o")
term.wait("Each row is an option")
for _step in range(20):
if "> Put RetroBIOS on this card: no" in term.settle():
break
term.send(DOWN)
term.send("\r")
term.wait("> Put RetroBIOS on this card: yes")
term.send("\x1b")
term.wait("files to download")
term.send("\r")
screen = term.wait("RetroBIOS is on the KNULLI card")
self.assertIn("start RetroBIOS from Ports", screen)
self.assertNotIn(install.APP_OFFER, screen)
term.send("\r")
self.assertEqual(term.close(), 0)
self.assertAppOnCard(root, "KNULLI")
self.assertIn("--add-app", term.after())
def test_three_presses_install_everything(self):
repo = self.repo()
term = self.start()
+41
View File
@@ -111,6 +111,47 @@ class ThePortArchive(unittest.TestCase):
self.assertIn(f'DEFAULT_INSTALL_SHA256="{bundled}"', install_sh)
class LauncherIndex(unittest.TestCase):
"""install/launchers/index.json, which --add-app reads, lists the members of
the archives built from the same tree, each with its size and hash."""
def test_the_committed_list_is_current(self):
import generate_launcher_index # noqa: PLC0415
text = generate_launcher_index.render(generate_launcher_index.build_index())
self.assertEqual(
generate_launcher_index.OUTPUT.read_text(encoding="utf-8"), text,
"install/launchers/index.json is stale: run "
"python scripts/generate_launcher_index.py")
def test_every_member_of_the_archives_is_listed(self):
import generate_launcher_index # noqa: PLC0415
index = json.loads(generate_launcher_index.OUTPUT.read_text(encoding="utf-8"))
places = {place.key: place for place in install.APP_PLACES.values()}
for key, archive in generate_launcher_index.LAUNCHER_ARCHIVES.items():
with self.subTest(key=key):
launcher = index["launchers"][key]
listed = launcher.get("members") or launcher["files"]
names = [entry.get("name") or entry["dest"] for entry in listed]
prefix = "" if "members" in launcher else places[key].folder + "/"
self.assertEqual(sorted(names), sorted(
prefix + name for name, _path in build_port.archive_members(archive)))
for entry in listed:
if "repo_path" in entry:
data = (REPO_ROOT / entry["repo_path"]).read_bytes()
self.assertEqual(hashlib.sha256(data).hexdigest(), entry["sha256"])
def test_the_list_refuses_an_installer_the_bootstraps_do_not_pin(self):
import generate_launcher_index # noqa: PLC0415
root = _tempdir(self)
shutil.copyfile(REPO_ROOT / "install.py", root / "install.py")
(root / "install.sh").write_text('DEFAULT_INSTALL_SHA256="' + "0" * 64 + '"\n')
with self.assertRaises(ValueError):
generate_launcher_index.build_index(root)
class EventRecords(unittest.TestCase):
"""rbevents turns --events into fields the FIFO carries intact."""