mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-01 07:33:45 -06:00
chore: merge the app on the card
This commit is contained in:
commit
f4bc8878d9
13 files changed
+1658
-18
No files matched your search
@@ -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
@@ -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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in new issue
Block a user