mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-24 12:49:03 -05:00
A damaged member of a data tree's archive (zlib.error, EOFError) ended the run on a traceback, and a failed write left its .part. --offline refusing a data tree, or the download after a --from copy failed, lost its kind, so the advice named the network instead of --offline.
5652 lines
254 KiB
Python
5652 lines
254 KiB
Python
"""Tests for install.py platform detection and config parsing."""
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import functools
|
|
import hashlib
|
|
import http.server
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
import os
|
|
import shlex
|
|
import re
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import unittest
|
|
from types import SimpleNamespace
|
|
import unittest.mock
|
|
import urllib.error
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
_spec = importlib.util.spec_from_file_location("install", REPO_ROOT / "install.py")
|
|
install = importlib.util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(install)
|
|
|
|
|
|
def _tempdir(test: unittest.TestCase) -> str:
|
|
"""A scratch directory under the repository's tmp/, removed after the test.
|
|
|
|
The system temp directory is a RAM disk here, and directories left by
|
|
every run piled up on it.
|
|
"""
|
|
scratch = REPO_ROOT / "tmp"
|
|
scratch.mkdir(exist_ok=True)
|
|
path = tempfile.mkdtemp(dir=str(scratch))
|
|
test.addCleanup(shutil.rmtree, path, True)
|
|
return path
|
|
|
|
|
|
class TestShellUnquote(unittest.TestCase):
|
|
def test_plain_value(self):
|
|
self.assertEqual(install._shell_unquote("/userdata/bios"), "/userdata/bios")
|
|
|
|
def test_double_quoted(self):
|
|
self.assertEqual(install._shell_unquote('"/home/deck/Emulation"'), "/home/deck/Emulation")
|
|
|
|
def test_single_quoted(self):
|
|
self.assertEqual(install._shell_unquote("'/home/deck/Emulation'"), "/home/deck/Emulation")
|
|
|
|
def test_concatenated_quoted_unquoted(self):
|
|
# EmuDeck SD card layout (issue: quote left in the middle of the path)
|
|
self.assertEqual(
|
|
install._shell_unquote('"/run/media/deck/EmuSD"/Emulation'),
|
|
"/run/media/deck/EmuSD/Emulation",
|
|
)
|
|
|
|
def test_concatenated_preserves_spaces_in_quotes(self):
|
|
self.assertEqual(
|
|
install._shell_unquote('"/run/media/deck/My Card"/Emulation'),
|
|
"/run/media/deck/My Card/Emulation",
|
|
)
|
|
|
|
def test_home_variable_expansion(self):
|
|
old = os.environ.get("HOME")
|
|
os.environ["HOME"] = "/home/deck"
|
|
try:
|
|
self.assertEqual(
|
|
install._shell_unquote('"$HOME"/Emulation'),
|
|
"/home/deck/Emulation",
|
|
)
|
|
self.assertEqual(
|
|
install._shell_unquote('"${HOME}/Emulation"'),
|
|
"/home/deck/Emulation",
|
|
)
|
|
finally:
|
|
if old is not None:
|
|
os.environ["HOME"] = old
|
|
|
|
def test_single_quotes_do_not_expand(self):
|
|
self.assertEqual(install._shell_unquote("'$HOME/x'"), "$HOME/x")
|
|
|
|
def test_trailing_comment(self):
|
|
self.assertEqual(install._shell_unquote("/data/bios # main dir"), "/data/bios")
|
|
|
|
def test_unquoted_stops_at_whitespace(self):
|
|
self.assertEqual(install._shell_unquote("/data/bios extra"), "/data/bios")
|
|
|
|
def test_tilde_expansion(self):
|
|
old = os.environ.get("HOME")
|
|
os.environ["HOME"] = "/home/deck"
|
|
try:
|
|
self.assertEqual(install._shell_unquote("~/Emulation"), "/home/deck/Emulation")
|
|
finally:
|
|
if old is not None:
|
|
os.environ["HOME"] = old
|
|
|
|
|
|
class TestParseBashVar(unittest.TestCase):
|
|
def _write(self, content: str) -> Path:
|
|
fd, path = tempfile.mkstemp(suffix=".sh")
|
|
os.close(fd)
|
|
Path(path).write_text(content, encoding="utf-8")
|
|
self.addCleanup(os.unlink, path)
|
|
return Path(path)
|
|
|
|
def test_emudeck_sd_card_concatenation(self):
|
|
path = self._write(
|
|
'emulationPath="/run/media/deck/EmuSD"/Emulation\n'
|
|
'biosPath="/run/media/deck/EmuSD"/Emulation/bios\n'
|
|
)
|
|
self.assertEqual(
|
|
install._parse_bash_var(path, "emulationPath"),
|
|
"/run/media/deck/EmuSD/Emulation",
|
|
)
|
|
self.assertEqual(
|
|
install._parse_bash_var(path, "biosPath"),
|
|
"/run/media/deck/EmuSD/Emulation/bios",
|
|
)
|
|
|
|
def test_emudeck_home_variable(self):
|
|
old = os.environ.get("HOME")
|
|
os.environ["HOME"] = "/home/deck"
|
|
try:
|
|
path = self._write('emulationPath="$HOME"/Emulation\n')
|
|
self.assertEqual(
|
|
install._parse_bash_var(path, "emulationPath"),
|
|
"/home/deck/Emulation",
|
|
)
|
|
finally:
|
|
if old is not None:
|
|
os.environ["HOME"] = old
|
|
|
|
def test_missing_key(self):
|
|
path = self._write("other=1\n")
|
|
self.assertIsNone(install._parse_bash_var(path, "emulationPath"))
|
|
|
|
def test_missing_file(self):
|
|
self.assertIsNone(
|
|
install._parse_bash_var(Path("/nonexistent/settings.sh"), "emulationPath")
|
|
)
|
|
|
|
|
|
class TestParseJsonPath(unittest.TestCase):
|
|
def _write(self, data) -> Path:
|
|
fd, path = tempfile.mkstemp(suffix=".json")
|
|
os.close(fd)
|
|
Path(path).write_text(
|
|
data if isinstance(data, str) else json.dumps(data), encoding="utf-8"
|
|
)
|
|
self.addCleanup(os.unlink, path)
|
|
return Path(path)
|
|
|
|
def test_retrodeck_rd_home_path(self):
|
|
path = self._write(
|
|
{"version": "1.0", "paths": {"rd_home_path": "/home/deck/retrodeck"}}
|
|
)
|
|
self.assertEqual(
|
|
install._parse_json_path(path, "paths", "rd_home_path"),
|
|
"/home/deck/retrodeck",
|
|
)
|
|
|
|
def test_missing_key_returns_none(self):
|
|
path = self._write({"paths": {}})
|
|
self.assertIsNone(install._parse_json_path(path, "paths", "rd_home_path"))
|
|
|
|
def test_invalid_json_returns_none(self):
|
|
path = self._write("{not json")
|
|
self.assertIsNone(install._parse_json_path(path, "paths", "rd_home_path"))
|
|
|
|
def test_empty_value_returns_none(self):
|
|
path = self._write({"paths": {"rd_home_path": ""}})
|
|
self.assertIsNone(install._parse_json_path(path, "paths", "rd_home_path"))
|
|
|
|
def test_missing_file(self):
|
|
self.assertIsNone(
|
|
install._parse_json_path(Path("/nonexistent/rd.json"), "paths", "rd_home_path")
|
|
)
|
|
|
|
|
|
class TestDefaultDests(unittest.TestCase):
|
|
def test_retrodeck_defaults_to_rdhome(self):
|
|
self.assertEqual(
|
|
install.DEFAULT_DESTS["retrodeck"], Path.home() / "retrodeck"
|
|
)
|
|
|
|
def test_known_fixed_paths(self):
|
|
self.assertEqual(install.DEFAULT_DESTS["batocera"], Path("/userdata/bios"))
|
|
self.assertEqual(
|
|
install.DEFAULT_DESTS["recalbox"], Path("/recalbox/share/bios")
|
|
)
|
|
|
|
def test_mister_defaults_to_games_on_the_sd_card(self):
|
|
self.assertEqual(
|
|
install.DEFAULT_DESTS["misterfpga"], Path("/media/fat/games")
|
|
)
|
|
|
|
|
|
class TestDefaultsFollowTheRegistry(unittest.TestCase):
|
|
"""A platform named but not detected fell back to ~/bios on every OS for
|
|
RetroArch, RetroBat, BizHawk and RomM, a folder none of them reads."""
|
|
|
|
ENV = {"USERPROFILE": "/u", "APPDATA": "/u/AppData/Roaming"}
|
|
|
|
def _expand(self, raw: str) -> Path:
|
|
text = raw.replace("$HOME", str(Path.home()))
|
|
for name, value in self.ENV.items():
|
|
text = text.replace(f"%{name}%", value)
|
|
return Path(text.replace("\\", "/"))
|
|
|
|
def test_the_default_is_a_folder_detection_looks_for(self):
|
|
import yaml
|
|
|
|
registry = yaml.safe_load(
|
|
(Path(__file__).resolve().parent.parent / "platforms" / "_registry.yml")
|
|
.read_text(encoding="utf-8")
|
|
)["platforms"]
|
|
checked = 0
|
|
with unittest.mock.patch.dict(os.environ, self.ENV):
|
|
for name, entry in registry.items():
|
|
by_os: dict[str, set[Path]] = {}
|
|
for rule in (entry.get("install") or {}).get("detect", []):
|
|
if rule.get("bios_path"):
|
|
by_os.setdefault(rule["os"], set()).add(self._expand(rule["bios_path"]))
|
|
elif rule.get("method") == "path_exists":
|
|
by_os.setdefault(rule["os"], set()).add(self._expand(rule["path"]))
|
|
elif rule.get("parse_key") == "system_directory":
|
|
config = self._expand(rule["config"])
|
|
by_os.setdefault(rule["os"], set()).add(config.parent / "system")
|
|
if name in install.PORTABLE_ONLY:
|
|
# No folder of its own to fall back on: the run stops
|
|
# and asks for --dest (test_a_portable_platform_is_not_guessed).
|
|
continue
|
|
for os_type, candidates in by_os.items():
|
|
with self.subTest(platform=name, os=os_type):
|
|
self.assertIn(install._default_dest(os_type, name), candidates)
|
|
checked += 1
|
|
# The installer's own default comes first in the registry and
|
|
# is the one the pack guide and the installer name.
|
|
first = next(
|
|
(self._expand(rule["path"]) for rule in (entry.get("install") or {}).get("detect", [])
|
|
if rule.get("os") == "windows" and rule.get("method") == "path_exists"),
|
|
None,
|
|
)
|
|
if name == "retrobat":
|
|
self.assertEqual(install._default_dest("windows", name), first)
|
|
self.assertGreater(checked, 10)
|
|
|
|
|
|
class TestEmbeddedDetection(unittest.TestCase):
|
|
"""MiSTer is identified by the main binary at the SD card root."""
|
|
|
|
def _detect_with(self, existing: set[str], os_id: str = ""):
|
|
real_exists = install.Path.exists
|
|
real_parse = install._parse_os_release
|
|
|
|
def fake_exists(self):
|
|
return str(self) in existing
|
|
|
|
install.Path.exists = fake_exists
|
|
install._parse_os_release = lambda: {"ID": os_id} if os_id else {}
|
|
try:
|
|
return install._detect_embedded()
|
|
finally:
|
|
install.Path.exists = real_exists
|
|
install._parse_os_release = real_parse
|
|
|
|
def test_mister_detected_from_main_binary(self):
|
|
# A path rule of the registry, read by _detect_by_path.
|
|
real_exists = install.Path.exists
|
|
install.Path.exists = lambda self: str(self) == "/media/fat/MiSTer"
|
|
try:
|
|
found: list = []
|
|
install._detect_by_path("linux", found)
|
|
finally:
|
|
install.Path.exists = real_exists
|
|
self.assertEqual(found, [("misterfpga", Path("/media/fat/games"))])
|
|
|
|
def test_rocknix_wins_over_path_probes(self):
|
|
self.assertEqual(
|
|
self._detect_with({"/media/fat/MiSTer"}, os_id="rocknix"),
|
|
[("rocknix", Path("/storage/roms/bios"))],
|
|
)
|
|
|
|
def test_no_embedded_os_detected(self):
|
|
self.assertEqual(self._detect_with(set()), [])
|
|
|
|
|
|
class TestRunOutcome(unittest.TestCase):
|
|
"""A run says what happened and leaves with a status that matches."""
|
|
|
|
def _report(self, **kw):
|
|
defaults = dict(
|
|
paths=[Path("/userdata/bios")],
|
|
downloaded=0,
|
|
up_to_date=0,
|
|
errors=0,
|
|
omitted=0,
|
|
interrupted=False,
|
|
)
|
|
defaults.update(kw)
|
|
buf = io.StringIO()
|
|
code = 0
|
|
with contextlib.redirect_stdout(buf):
|
|
try:
|
|
install._report_outcome(**defaults)
|
|
except SystemExit as exc:
|
|
code = exc.code
|
|
return buf.getvalue(), code
|
|
|
|
def test_success_names_the_location_and_the_way_to_check(self):
|
|
out, code = self._report(downloaded=12, up_to_date=3)
|
|
self.assertEqual(code, 0)
|
|
self.assertIn("12 files installed", out)
|
|
self.assertIn("/userdata/bios", out)
|
|
self.assertIn("--check", out)
|
|
|
|
def test_total_failure_does_not_say_done_and_exits_two(self):
|
|
out, code = self._report(errors=7)
|
|
self.assertEqual(code, 2)
|
|
self.assertNotIn("Done.", out)
|
|
self.assertIn("Nothing was installed", out)
|
|
|
|
def test_partial_failure_exits_one(self):
|
|
out, code = self._report(downloaded=5, errors=2)
|
|
self.assertEqual(code, 1)
|
|
self.assertIn("5 files installed", out)
|
|
self.assertIn("2 files failed", out)
|
|
|
|
def test_interruption_is_resumable_and_exits_130(self):
|
|
out, code = self._report(downloaded=431, interrupted=True)
|
|
self.assertEqual(code, 130)
|
|
self.assertIn("carry on", out)
|
|
self.assertIn("Nothing is lost", out)
|
|
|
|
def test_one_file_reads_as_one_file(self):
|
|
out, _ = self._report(downloaded=1)
|
|
self.assertIn("1 file installed", out)
|
|
self.assertNotIn("1 files", out)
|
|
|
|
def test_every_destination_is_named(self):
|
|
out, _ = self._report(
|
|
paths=[Path("/a/bios"), Path("/b/bios")], downloaded=2
|
|
)
|
|
self.assertIn("/a/bios", out)
|
|
self.assertIn("/b/bios", out)
|
|
|
|
|
|
class TestFreeSpaceGuard(unittest.TestCase):
|
|
"""The run refuses before writing when the volume cannot hold it."""
|
|
|
|
def setUp(self):
|
|
self.root = Path(_tempdir(self))
|
|
self.real = install.shutil.disk_usage
|
|
|
|
def tearDown(self):
|
|
install.shutil.disk_usage = self.real
|
|
shutil.rmtree(self.root, ignore_errors=True)
|
|
|
|
def _run(self, free: int, needed: int, path: Path | None = None):
|
|
install.shutil.disk_usage = lambda _p: SimpleNamespace(free=free)
|
|
buf = io.StringIO()
|
|
code = 0
|
|
with contextlib.redirect_stderr(buf):
|
|
try:
|
|
install._check_free_space(path or self.root, needed)
|
|
except SystemExit as exc:
|
|
code = exc.code
|
|
return buf.getvalue(), code
|
|
|
|
def test_enough_space_says_nothing(self):
|
|
out, code = self._run(free=10_000, needed=1_000)
|
|
self.assertEqual((out, code), ("", 0))
|
|
|
|
def test_short_of_space_names_both_numbers_and_stops(self):
|
|
"""Nothing was installed: code 2, as the exit table says, not the 1
|
|
of a partial install."""
|
|
out, code = self._run(free=1_000, needed=5_000)
|
|
self.assertEqual(code, 2)
|
|
self.assertIn("Needed", out)
|
|
self.assertIn("Free", out)
|
|
self.assertIn("--dest", out)
|
|
|
|
def test_probe_climbs_to_an_existing_ancestor(self):
|
|
missing = self.root / "not" / "created" / "yet"
|
|
out, code = self._run(free=1, needed=2, path=missing)
|
|
self.assertEqual(code, 2)
|
|
|
|
def test_the_space_check_comes_before_the_folder(self):
|
|
source = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
|
|
check = source.index("_check_free_space(bios_path, dl_size")
|
|
self.assertLess(check, source.index("bios_path.mkdir(parents=True, exist_ok=True)"))
|
|
|
|
|
|
class AMissingDestIsSaid(unittest.TestCase):
|
|
"""--dest ~/.config/retroarh/system (a typo) was created with every
|
|
missing parent and reported done, and --check on it said only
|
|
"0/7 present"."""
|
|
|
|
def _call(self, dest: Path, ask: bool, answer: str = "") -> tuple[str, object]:
|
|
out = io.StringIO()
|
|
code = None
|
|
with contextlib.redirect_stdout(out), \
|
|
unittest.mock.patch("builtins.input", return_value=answer):
|
|
try:
|
|
install._missing_dest(dest, ask)
|
|
except SystemExit as exc:
|
|
code = exc.code
|
|
return out.getvalue(), code
|
|
|
|
def test_an_existing_folder_says_nothing(self):
|
|
root = Path(_tempdir(self))
|
|
self.assertEqual(self._call(root, True), ("", None))
|
|
|
|
def test_a_deep_missing_path_is_asked_and_no_is_the_default(self):
|
|
root = Path(_tempdir(self))
|
|
dest = root / "retroarh" / "system"
|
|
out, code = self._call(dest, True, "")
|
|
self.assertEqual(code, 2)
|
|
self.assertIn(f"The nearest folder that does: {root}", out)
|
|
self.assertFalse((root / "retroarh").exists())
|
|
self.assertEqual(self._call(dest, True, "y")[1], None)
|
|
|
|
def test_one_missing_level_or_no_terminal_is_said_not_asked(self):
|
|
root = Path(_tempdir(self))
|
|
# A "no" that was asked would stop the run with 2.
|
|
out, code = self._call(root / "bios", True, "n")
|
|
self.assertIsNone(code)
|
|
self.assertIn("does not exist", out)
|
|
out, code = self._call(root / "a" / "b", False, "n")
|
|
self.assertIsNone(code)
|
|
self.assertIn("does not exist", out)
|
|
|
|
|
|
class AFolderThatRefusesWritesIsNamed(unittest.TestCase):
|
|
"""A read-only BIOS folder ended the run on a traceback (a subfolder to
|
|
create), or on three retries per file and "once that is resolved", and a
|
|
platform named but not detected was told to use sudo, which on another
|
|
machine creates /userdata/bios on the wrong disk."""
|
|
|
|
ENTRY = {"dest": "mame2015/neogeo.zip", "repo_path": "bios/x", "size": 4}
|
|
|
|
def test_a_subfolder_that_cannot_be_made_is_a_failure_not_a_crash(self):
|
|
with unittest.mock.patch.object(Path, "mkdir", side_effect=PermissionError(13, "denied")):
|
|
result = install._download_one(self.ENTRY, Path("/userdata/bios"))
|
|
self.assertEqual(result, ("mame2015/neogeo.zip", False, install.NOT_WRITABLE))
|
|
|
|
def test_a_refused_write_is_not_retried(self):
|
|
class _Body(io.BytesIO):
|
|
status, headers = 200, {"Content-Length": "4"}
|
|
|
|
with tempfile.TemporaryDirectory() as tmp, \
|
|
unittest.mock.patch.object(install.urllib.request, "urlopen",
|
|
return_value=_Body(b"data")) as opened, \
|
|
unittest.mock.patch.object(install.tempfile, "NamedTemporaryFile",
|
|
side_effect=PermissionError(13, "denied")):
|
|
result = install._download_one(self.ENTRY, Path(tmp))
|
|
self.assertEqual(result[1:], (False, install.NOT_WRITABLE))
|
|
self.assertEqual(opened.call_count, 1)
|
|
|
|
def _summary_for(self, assumed):
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf), self.assertRaises(SystemExit):
|
|
install._report_outcome(
|
|
paths=[Path("/userdata/bios")], downloaded=0, up_to_date=0, errors=1,
|
|
omitted=0, interrupted=False,
|
|
failures=[("neogeo.zip", install.NOT_WRITABLE)], assumed=assumed,
|
|
)
|
|
return buf.getvalue()
|
|
|
|
def test_the_summary_says_sudo_on_the_console_and_dest_elsewhere(self):
|
|
here = self._summary_for(set())
|
|
self.assertIn("sudo", here)
|
|
guessed = self._summary_for({Path("/userdata/bios")})
|
|
self.assertIn("--dest", guessed)
|
|
self.assertNotIn("sudo", guessed)
|
|
|
|
|
|
class TestStopFlag(unittest.TestCase):
|
|
"""Ctrl+C ends the run instead of letting the queue drain."""
|
|
|
|
def tearDown(self):
|
|
install._stop.clear()
|
|
|
|
def test_worker_returns_immediately_once_stopped(self):
|
|
install._stop.set()
|
|
dest, ok, reason = install._download_one({"dest": "x.bin"}, Path("/nowhere"))
|
|
self.assertEqual((dest, ok, reason), ("x.bin", False, "stopped"))
|
|
|
|
def test_download_files_reports_no_interruption_normally(self):
|
|
failed, interrupted = install.download_files([], Path("/nowhere"))
|
|
self.assertEqual((failed, interrupted), ([], False))
|
|
|
|
|
|
class TestAndroidDetection(unittest.TestCase):
|
|
"""Android reports itself as Linux, and RetroArch is the frontend there."""
|
|
|
|
def setUp(self):
|
|
self.saved = {
|
|
key: os.environ.get(key)
|
|
for key in ("ANDROID_ROOT", "ANDROID_DATA", "EXTERNAL_STORAGE", "RETROBIOS_OS")
|
|
}
|
|
for key in self.saved:
|
|
os.environ.pop(key, None)
|
|
|
|
def tearDown(self):
|
|
for key, value in self.saved.items():
|
|
if value is None:
|
|
os.environ.pop(key, None)
|
|
else:
|
|
os.environ[key] = value
|
|
|
|
def test_runtime_variables_name_android(self):
|
|
os.environ["ANDROID_ROOT"] = "/system"
|
|
os.environ["ANDROID_DATA"] = "/data"
|
|
self.assertTrue(install._is_android())
|
|
|
|
def test_plain_linux_is_not_android(self):
|
|
self.assertFalse(
|
|
install._is_android() and not Path("/system/build.prop").exists()
|
|
)
|
|
|
|
def test_forced_os_accepts_android(self):
|
|
os.environ["RETROBIOS_OS"] = "android"
|
|
self.assertEqual(install.detect_os(), "android")
|
|
|
|
def test_default_directory_found_without_a_config(self):
|
|
storage = Path(_tempdir(self))
|
|
(storage / "RetroArch" / "system").mkdir(parents=True)
|
|
os.environ["EXTERNAL_STORAGE"] = str(storage)
|
|
self.assertEqual(
|
|
install._detect_android(),
|
|
[("retroarch", storage / "RetroArch" / "system")],
|
|
)
|
|
shutil.rmtree(storage)
|
|
|
|
def test_config_default_resolves_against_the_retroarch_root(self):
|
|
storage = Path(_tempdir(self))
|
|
files = storage / "Android" / "data" / "com.retroarch.aarch64" / "files"
|
|
files.mkdir(parents=True)
|
|
(files / "retroarch.cfg").write_text(
|
|
'system_directory = "default"\n'
|
|
'bundle_assets_extract_version_current = "1745000000"\n')
|
|
os.environ["EXTERNAL_STORAGE"] = str(storage)
|
|
self.assertEqual(
|
|
install._detect_android(),
|
|
[("retroarch", storage / "RetroArch" / "system")],
|
|
)
|
|
shutil.rmtree(storage)
|
|
|
|
def test_custom_system_directory_wins(self):
|
|
storage = Path(_tempdir(self))
|
|
files = storage / "Android" / "data" / "com.retroarch" / "files"
|
|
files.mkdir(parents=True)
|
|
custom = storage / "Emulation" / "bios"
|
|
(files / "retroarch.cfg").write_text(f'system_directory = "{custom}"\n')
|
|
os.environ["EXTERNAL_STORAGE"] = str(storage)
|
|
self.assertEqual(install._detect_android(), [("retroarch", custom)])
|
|
shutil.rmtree(storage)
|
|
|
|
def test_nothing_found_on_a_bare_device(self):
|
|
storage = Path(_tempdir(self))
|
|
os.environ["EXTERNAL_STORAGE"] = str(storage)
|
|
self.assertEqual(install._detect_android(), [])
|
|
shutil.rmtree(storage)
|
|
|
|
def test_detect_platforms_routes_android(self):
|
|
storage = Path(_tempdir(self))
|
|
(storage / "RetroArch" / "system").mkdir(parents=True)
|
|
os.environ["EXTERNAL_STORAGE"] = str(storage)
|
|
self.assertEqual(
|
|
install.detect_platforms("android"),
|
|
[("retroarch", storage / "RetroArch" / "system")],
|
|
)
|
|
shutil.rmtree(storage)
|
|
|
|
def test_manual_hint_names_the_android_path(self):
|
|
hint = "\n".join(install._manual_usage_hint("android"))
|
|
self.assertIn("/storage/emulated/0/RetroArch/system", hint)
|
|
|
|
def test_named_platform_falls_back_to_shared_storage(self):
|
|
storage = Path(_tempdir(self))
|
|
os.environ["EXTERNAL_STORAGE"] = str(storage)
|
|
self.assertEqual(
|
|
install._default_dest("android", "retroarch"),
|
|
storage / "RetroArch" / "system",
|
|
)
|
|
self.assertEqual(
|
|
install._default_dest("linux", "batocera"), Path("/userdata/bios")
|
|
)
|
|
shutil.rmtree(storage)
|
|
|
|
def test_packages_match_the_published_flavors(self):
|
|
self.assertEqual(
|
|
install.ANDROID_RETROARCH_PACKAGES,
|
|
("com.retroarch.aarch64", "com.retroarch", "com.retroarch.ra32"),
|
|
)
|
|
|
|
|
|
class TestLaunchboxDetection(unittest.TestCase):
|
|
"""LaunchBox references its emulators in Data/Emulators.xml."""
|
|
|
|
def _make_launchbox(self, app_path: str, title: str = "Retroarch"):
|
|
root = Path(_tempdir(self))
|
|
data = root / "LaunchBox" / "Data"
|
|
data.mkdir(parents=True)
|
|
xml = data / "Emulators.xml"
|
|
xml.write_text(
|
|
"<?xml version=\"1.0\" standalone=\"yes\"?>\n"
|
|
"<LaunchBox>\n"
|
|
" <Emulator>\n"
|
|
" <ID>x</ID>\n"
|
|
f" <Title>{title}</Title>\n"
|
|
f" <ApplicationPath>{app_path}</ApplicationPath>\n"
|
|
" </Emulator>\n"
|
|
"</LaunchBox>\n"
|
|
)
|
|
return root, xml
|
|
|
|
def test_relative_application_path(self):
|
|
root, xml = self._make_launchbox("Emulators\\RetroArch\\retroarch.exe")
|
|
ra_dir = root / "LaunchBox" / "Emulators" / "RetroArch"
|
|
ra_dir.mkdir(parents=True)
|
|
self.assertEqual(
|
|
install._launchbox_retroarch_system_dir(xml), ra_dir / "system"
|
|
)
|
|
|
|
def test_absolute_application_path(self):
|
|
emu_dir = Path(_tempdir(self)) / "RetroArch"
|
|
emu_dir.mkdir(parents=True)
|
|
root, xml = self._make_launchbox(str(emu_dir / "retroarch.exe"))
|
|
self.assertEqual(
|
|
install._launchbox_retroarch_system_dir(xml), emu_dir / "system"
|
|
)
|
|
|
|
def test_no_retroarch_entry(self):
|
|
root, xml = self._make_launchbox("Emulators\\PCSX2\\pcsx2.exe", "PCSX2")
|
|
self.assertIsNone(install._launchbox_retroarch_system_dir(xml))
|
|
|
|
def test_missing_emulator_directory(self):
|
|
root, xml = self._make_launchbox("Emulators\\RetroArch\\retroarch.exe")
|
|
self.assertIsNone(install._launchbox_retroarch_system_dir(xml))
|
|
|
|
def test_stale_entry_before_installed_one(self):
|
|
root = Path(_tempdir(self))
|
|
data = root / "LaunchBox" / "Data"
|
|
data.mkdir(parents=True)
|
|
ra_dir = root / "LaunchBox" / "Emulators" / "RetroArch"
|
|
ra_dir.mkdir(parents=True)
|
|
xml = data / "Emulators.xml"
|
|
xml.write_text(
|
|
"<?xml version=\"1.0\" standalone=\"yes\"?>\n"
|
|
"<LaunchBox>\n"
|
|
" <Emulator>\n"
|
|
" <Title>RetroArch (old)</Title>\n"
|
|
" <ApplicationPath>Emulators\\RetroArch-old\\retroarch.exe</ApplicationPath>\n"
|
|
" </Emulator>\n"
|
|
" <Emulator>\n"
|
|
" <Title>RetroArch</Title>\n"
|
|
" <ApplicationPath>Emulators\\RetroArch\\retroarch.exe</ApplicationPath>\n"
|
|
" </Emulator>\n"
|
|
"</LaunchBox>\n"
|
|
)
|
|
self.assertEqual(
|
|
install._launchbox_retroarch_system_dir(xml), ra_dir / "system"
|
|
)
|
|
|
|
def test_missing_or_malformed_xml(self):
|
|
self.assertIsNone(
|
|
install._launchbox_retroarch_system_dir(Path("/nonexistent/Emulators.xml"))
|
|
)
|
|
root = Path(_tempdir(self))
|
|
bad = root / "Emulators.xml"
|
|
bad.write_text("not xml at all <<<")
|
|
self.assertIsNone(install._launchbox_retroarch_system_dir(bad))
|
|
|
|
|
|
class TestRetroarchPathExpansion(unittest.TestCase):
|
|
"""RetroArch abbreviates paths with '~' and ':' in its config."""
|
|
|
|
def _cfg(self, value: str) -> Path:
|
|
app_dir = Path(_tempdir(self))
|
|
(app_dir / "retroarch.cfg").write_text(f'system_directory = "{value}"\n')
|
|
return app_dir
|
|
|
|
def test_application_dir_prefix(self):
|
|
app_dir = self._cfg(":\\system")
|
|
self.assertEqual(
|
|
install._parse_retroarch_system_dir(app_dir / "retroarch.cfg"),
|
|
app_dir / "system",
|
|
)
|
|
|
|
def test_application_dir_prefix_forward_slash(self):
|
|
app_dir = self._cfg(":/system")
|
|
self.assertEqual(
|
|
install._parse_retroarch_system_dir(app_dir / "retroarch.cfg"),
|
|
app_dir / "system",
|
|
)
|
|
|
|
def test_home_prefix(self):
|
|
app_dir = self._cfg("~/bios")
|
|
self.assertEqual(
|
|
install._parse_retroarch_system_dir(app_dir / "retroarch.cfg"),
|
|
Path.home() / "bios",
|
|
)
|
|
|
|
def test_application_dir_overrides_config_dir(self):
|
|
app_dir = self._cfg(":\\system")
|
|
elsewhere = Path(_tempdir(self))
|
|
self.assertEqual(
|
|
install._parse_retroarch_system_dir(
|
|
app_dir / "retroarch.cfg", elsewhere
|
|
),
|
|
elsewhere / "system",
|
|
)
|
|
|
|
def test_absolute_path_untouched(self):
|
|
target = Path(_tempdir(self)) / "bios"
|
|
app_dir = self._cfg(str(target))
|
|
self.assertEqual(
|
|
install._parse_retroarch_system_dir(app_dir / "retroarch.cfg"), target
|
|
)
|
|
|
|
|
|
class TestLaunchboxRoot(unittest.TestCase):
|
|
"""LaunchBox installs where the user chooses and keeps no registry key."""
|
|
|
|
def _make_root(self, root: Path) -> Path:
|
|
(root / "Data").mkdir(parents=True)
|
|
(root / "Data" / "Emulators.xml").write_text(
|
|
"<?xml version=\"1.0\" standalone=\"yes\"?>\n<LaunchBox></LaunchBox>\n"
|
|
)
|
|
return root
|
|
|
|
def test_shortcut_target_is_read(self):
|
|
# Byte layout of the shortcut a real install writes
|
|
lnk = Path(_tempdir(self)) / "LaunchBox.lnk"
|
|
lnk.write_bytes(
|
|
b"L\x00\x00\x00\x01\x14\x02\x00\x00\x00\x00\x00\xc0\x00\x00\x00"
|
|
b"\x00\x00\x00\x46" + b"\x00" * 8
|
|
+ b"C:\\Games\\LaunchBox\\Core\\LaunchBox.exe\x00"
|
|
b"\x1c\x00\x00\x00LaunchBox\x00"
|
|
)
|
|
self.assertEqual(
|
|
install._lnk_target(lnk, "LaunchBox.exe"),
|
|
Path("C:/Games/LaunchBox/Core/LaunchBox.exe"),
|
|
)
|
|
|
|
def test_shortcut_without_target_is_ignored(self):
|
|
lnk = Path(_tempdir(self)) / "LaunchBox.lnk"
|
|
lnk.write_bytes(b"L\x00\x00\x00 no target here ")
|
|
self.assertIsNone(install._lnk_target(lnk, "LaunchBox.exe"))
|
|
|
|
def test_shortcut_pointing_elsewhere_falls_back(self):
|
|
# A stale shortcut must not win over a real installation
|
|
userprofile = Path(_tempdir(self))
|
|
root = self._make_root(userprofile / "LaunchBox")
|
|
appdata = Path(_tempdir(self))
|
|
lnk_dir = (
|
|
appdata / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "LaunchBox"
|
|
)
|
|
lnk_dir.mkdir(parents=True)
|
|
(lnk_dir / "LaunchBox.lnk").write_bytes(
|
|
b"L\x00\x00\x00D:\\Gone\\LaunchBox\\Core\\LaunchBox.exe\x00"
|
|
)
|
|
env = {"APPDATA": str(appdata), "USERPROFILE": str(userprofile)}
|
|
with unittest.mock.patch.dict(os.environ, env):
|
|
self.assertEqual(install.launchbox_root("windows"), root)
|
|
|
|
def test_falls_back_to_userprofile(self):
|
|
userprofile = Path(_tempdir(self))
|
|
root = self._make_root(userprofile / "LaunchBox")
|
|
env = {"APPDATA": str(Path(_tempdir(self))), "USERPROFILE": str(userprofile)}
|
|
with unittest.mock.patch.dict(os.environ, env):
|
|
self.assertEqual(install.launchbox_root("windows"), root)
|
|
|
|
def test_absent_on_linux(self):
|
|
self.assertIsNone(install.launchbox_root("linux"))
|
|
|
|
def test_nothing_installed(self):
|
|
env = {
|
|
"APPDATA": str(Path(_tempdir(self))),
|
|
"USERPROFILE": str(Path(_tempdir(self))),
|
|
}
|
|
with unittest.mock.patch.dict(os.environ, env):
|
|
self.assertIsNone(install.launchbox_root("windows"))
|
|
|
|
|
|
class TestLaunchboxBiosDirs(unittest.TestCase):
|
|
"""LaunchBox computes where each emulator it manages keeps its BIOS."""
|
|
|
|
def _make(self, entries: list[tuple[str, str]]) -> Path:
|
|
root = Path(_tempdir(self)) / "LaunchBox"
|
|
(root / "Data").mkdir(parents=True)
|
|
body = "".join(
|
|
" <Emulator>\n"
|
|
f" <Title>{title}</Title>\n"
|
|
f" <ApplicationPath>{app}</ApplicationPath>\n"
|
|
" </Emulator>\n"
|
|
for title, app in entries
|
|
)
|
|
(root / "Data" / "Emulators.xml").write_text(
|
|
"<?xml version=\"1.0\" standalone=\"yes\"?>\n"
|
|
f"<LaunchBox>\n{body}</LaunchBox>\n"
|
|
)
|
|
for _, app in entries:
|
|
(root / Path(app.replace("\\", "/")).parent).mkdir(parents=True, exist_ok=True)
|
|
return root
|
|
|
|
def test_pcsx2_portable_default_bios(self):
|
|
root = self._make([("PCSX2", "Emulators\\PCSX2\\pcsx2.exe")])
|
|
emu = root / "Emulators" / "PCSX2"
|
|
(emu / "portable.ini").write_text("")
|
|
(emu / "inis").mkdir()
|
|
(emu / "inis" / "PCSX2.ini").write_text("[Folders]\nBios = bios\n")
|
|
self.assertEqual(install.launchbox_bios_dirs(root)["pcsx2"], emu / "bios")
|
|
|
|
def test_pcsx2_portable_absolute_bios(self):
|
|
root = self._make([("PCSX2", "Emulators\\PCSX2\\pcsx2.exe")])
|
|
emu = root / "Emulators" / "PCSX2"
|
|
elsewhere = Path(_tempdir(self)) / "ps2bios"
|
|
(emu / "portable.ini").write_text("")
|
|
(emu / "inis").mkdir()
|
|
(emu / "inis" / "PCSX2.ini").write_text(f"Bios = {elsewhere}\n")
|
|
self.assertEqual(install.launchbox_bios_dirs(root)["pcsx2"], elsewhere)
|
|
|
|
def test_pcsx2_portable_txt_also_triggers_portable_mode(self):
|
|
root = self._make([("PCSX2", "Emulators\\PCSX2\\pcsx2.exe")])
|
|
emu = root / "Emulators" / "PCSX2"
|
|
(emu / "portable.txt").write_text("")
|
|
self.assertEqual(install.launchbox_bios_dirs(root)["pcsx2"], emu / "bios")
|
|
|
|
def test_pcsx2_portable_txt_names_a_data_root(self):
|
|
root = self._make([("PCSX2", "Emulators\\PCSX2\\pcsx2.exe")])
|
|
emu = root / "Emulators" / "PCSX2"
|
|
(emu / "portable.txt").write_text("mydata\n")
|
|
self.assertEqual(
|
|
install.launchbox_bios_dirs(root)["pcsx2"], emu / "mydata" / "bios"
|
|
)
|
|
|
|
def test_pcsx2_without_config_uses_documents(self):
|
|
root = self._make([("PCSX2", "Emulators\\PCSX2\\pcsx2.exe")])
|
|
userprofile = Path(_tempdir(self))
|
|
with unittest.mock.patch.dict(os.environ, {"USERPROFILE": str(userprofile)}):
|
|
self.assertEqual(
|
|
install.launchbox_bios_dirs(root)["pcsx2"],
|
|
userprofile / "Documents" / "PCSX2" / "bios",
|
|
)
|
|
|
|
def test_xemu_defaults_to_bios_folder(self):
|
|
root = self._make([("Xemu", "Emulators\\Xemu\\xemu.exe")])
|
|
emu = root / "Emulators" / "Xemu"
|
|
with unittest.mock.patch.dict(os.environ, {"APPDATA": str(Path(_tempdir(self)))}):
|
|
self.assertEqual(install.launchbox_bios_dirs(root)["xemu"], emu / "bios")
|
|
|
|
def test_xemu_honours_configured_bootrom(self):
|
|
root = self._make([("Xemu", "Emulators\\Xemu\\xemu.exe")])
|
|
emu = root / "Emulators" / "Xemu"
|
|
configured = Path(_tempdir(self)) / "xbox"
|
|
configured.mkdir()
|
|
(configured / "mcpx_1.0.bin").write_text("x")
|
|
(emu / "xemu.toml").write_text(
|
|
"[sys.files]\n"
|
|
f"bootrom_path = '{configured / 'mcpx_1.0.bin'}'\n"
|
|
)
|
|
self.assertEqual(install.launchbox_bios_dirs(root)["xemu"], configured)
|
|
|
|
def test_dolphin_portable_user_folder(self):
|
|
root = self._make([("Dolphin", "Emulators\\Dolphin\\Dolphin.exe")])
|
|
emu = root / "Emulators" / "Dolphin"
|
|
(emu / "portable.txt").write_text("")
|
|
self.assertEqual(install.launchbox_bios_dirs(root)["dolphin"], emu / "User")
|
|
|
|
def test_dolphin_without_portable_marker_is_absent(self):
|
|
root = self._make([("Dolphin", "Emulators\\Dolphin\\Dolphin.exe")])
|
|
(root / "Emulators" / "Dolphin" / "User").mkdir()
|
|
self.assertNotIn("dolphin", install.launchbox_bios_dirs(root))
|
|
|
|
def test_unknown_emulators_are_ignored(self):
|
|
root = self._make([("Cemu", "Emulators\\Cemu\\Cemu.exe")])
|
|
self.assertEqual(install.launchbox_bios_dirs(root), {})
|
|
|
|
def test_doctype_is_rejected(self):
|
|
root = Path(_tempdir(self)) / "LaunchBox"
|
|
(root / "Data").mkdir(parents=True)
|
|
(root / "Data" / "Emulators.xml").write_text(
|
|
"<!DOCTYPE LaunchBox [<!ENTITY a 'x'>]>\n<LaunchBox></LaunchBox>\n"
|
|
)
|
|
self.assertEqual(install.launchbox_emulators(root / "Data" / "Emulators.xml"), {})
|
|
|
|
|
|
class TestStandaloneCopiesExtraDirs(unittest.TestCase):
|
|
"""Setups that keep emulators outside the default locations."""
|
|
|
|
def _manifest(self) -> dict:
|
|
return {
|
|
"standalone_copies": [
|
|
{
|
|
"pattern": "ps2-*.bin",
|
|
"emulator": "pcsx2",
|
|
"targets": {"windows": []},
|
|
},
|
|
{
|
|
"file": "GC/USA/IPL.bin",
|
|
"emulator": "dolphin",
|
|
"targets": {"windows": []},
|
|
},
|
|
]
|
|
}
|
|
|
|
def test_pattern_entry_copies_into_extra_dir(self):
|
|
bios = Path(_tempdir(self))
|
|
(bios / "ps2-0230a.bin").write_text("rom")
|
|
extra = Path(_tempdir(self)) / "bios"
|
|
extra.mkdir()
|
|
copied, _, _ = install.do_standalone_copies(
|
|
self._manifest(), bios, "windows", {"pcsx2": extra}
|
|
)
|
|
self.assertEqual(copied, 1)
|
|
self.assertTrue((extra / "ps2-0230a.bin").exists())
|
|
|
|
def test_file_entry_keeps_its_subdirectory(self):
|
|
bios = Path(_tempdir(self))
|
|
(bios / "GC" / "USA").mkdir(parents=True)
|
|
(bios / "GC" / "USA" / "IPL.bin").write_text("ipl")
|
|
user = Path(_tempdir(self)) / "User"
|
|
(user / "GC" / "USA").mkdir(parents=True)
|
|
copied, _, _ = install.do_standalone_copies(
|
|
self._manifest(), bios, "windows", {"dolphin": user}
|
|
)
|
|
self.assertEqual(copied, 1)
|
|
self.assertTrue((user / "GC" / "USA" / "IPL.bin").exists())
|
|
|
|
def test_no_extra_dirs_is_unchanged(self):
|
|
bios = Path(_tempdir(self))
|
|
(bios / "ps2-0230a.bin").write_text("rom")
|
|
self.assertEqual(
|
|
install.do_standalone_copies(self._manifest(), bios, "windows"), (0, 0, 0)
|
|
)
|
|
|
|
|
|
class StandaloneCopiesReadWhereThePackPutTheFile(unittest.TestCase):
|
|
"""The registry names Citra/sysdata/aes_keys.txt for every platform,
|
|
but EmuDeck's manifest installs it at the root. The installer looked under
|
|
the registry's path, found nothing and copied nothing to Azahar."""
|
|
|
|
def _copy(self, entry: dict) -> tuple[Path, tuple[int, int, int]]:
|
|
bios = Path(_tempdir(self))
|
|
(bios / "aes_keys.txt").write_text("keys")
|
|
sysdata = Path(_tempdir(self)) / "sysdata"
|
|
sysdata.mkdir()
|
|
manifest = {"standalone_copies": [
|
|
{**entry, "emulator": "azahar", "targets": {"linux": [str(sysdata)]}},
|
|
]}
|
|
return sysdata, install.do_standalone_copies(manifest, bios, "linux")
|
|
|
|
def test_the_source_the_manifest_names_is_copied(self):
|
|
sysdata, (copied, _, _) = self._copy(
|
|
{"file": "Citra/sysdata/aes_keys.txt", "source": "aes_keys.txt"}
|
|
)
|
|
self.assertEqual(copied, 1)
|
|
self.assertEqual((sysdata / "aes_keys.txt").read_text(), "keys")
|
|
|
|
def test_without_a_source_the_file_path_is_read(self):
|
|
_, (copied, _, _) = self._copy({"file": "Citra/sysdata/aes_keys.txt"})
|
|
self.assertEqual(copied, 0)
|
|
|
|
def test_an_unsafe_source_is_refused_at_load(self):
|
|
manifest = json.loads(
|
|
(Path(__file__).resolve().parents[1] / "install" / "emudeck.json").read_text()
|
|
)
|
|
manifest["standalone_copies"] = [
|
|
{"file": "aes_keys.txt", "source": "../../etc/passwd",
|
|
"targets": {"linux": ["/tmp"]}},
|
|
]
|
|
with self.assertRaisesRegex(ValueError, r"standalone_copies\[0\]\.source"):
|
|
install._validate_manifest(manifest, "emudeck")
|
|
|
|
|
|
class DetectionFollowsTheRegistry(unittest.TestCase):
|
|
"""The registry declared RetroPie at ~/RetroPie/BIOS and BizHawk at
|
|
~/.config/BizHawk/Firmware, every manifest published it, and the
|
|
installer never read it: such a home detected nothing."""
|
|
|
|
def test_the_table_is_the_registry(self):
|
|
import yaml # noqa: PLC0415
|
|
|
|
registry = yaml.safe_load(
|
|
(Path(__file__).resolve().parents[1] / "platforms" / "_registry.yml").read_text()
|
|
)
|
|
rules = {
|
|
(rule["os"], name, rule["path"], rule.get("bios_path"))
|
|
for name, entry in registry["platforms"].items()
|
|
for rule in (entry.get("install") or {}).get("detect", [])
|
|
if rule["method"] == "path_exists"
|
|
}
|
|
self.assertEqual(set(install._PATH_RULES), rules)
|
|
|
|
def test_the_embedded_table_is_the_registry(self):
|
|
"""Only the path rules were compared: Knulli, muOS, ArkOS and the
|
|
Recalbox version file were detected by the installer and absent
|
|
from every manifest's `detect`, and ROCKNIX was declared another
|
|
way. A changed bios_path in the registry reached no installer."""
|
|
import yaml # noqa: PLC0415
|
|
|
|
registry = yaml.safe_load(
|
|
(Path(__file__).resolve().parents[1] / "platforms" / "_registry.yml").read_text()
|
|
)
|
|
rules = []
|
|
for name, entry in registry["platforms"].items():
|
|
for rule in (entry.get("install") or {}).get("detect", []):
|
|
if rule["method"] == "os_release":
|
|
values = tuple(rule["match"])
|
|
elif rule["method"] == "file_exists":
|
|
files = rule["file"]
|
|
values = tuple(files if isinstance(files, list) else [files])
|
|
elif rule["method"] == "file_matches":
|
|
values = (rule["file"], rule["pattern"])
|
|
else:
|
|
continue
|
|
rules.append(install._OsRule(
|
|
"" if rule.get("installs") is False else name, rule["name"],
|
|
rule["method"], values, rule.get("bios_path"),
|
|
tuple(rule.get("config", ())), rule.get("home", ""),
|
|
rule.get("derives_from", ""),
|
|
))
|
|
self.assertEqual(sorted(install._EMBEDDED_RULES, key=repr), sorted(rules, key=repr))
|
|
|
|
def test_every_config_file_read_is_published(self):
|
|
"""The registry published five RetroArch config files and the fixed
|
|
RetroDECK folder, but the installer also read Snap, the older macOS file,
|
|
C:\\RetroArch, Program Files, ES-DE, Steam and RetroDECK's own
|
|
config. A manifest reader saw a subset of what the installer does."""
|
|
import yaml # noqa: PLC0415
|
|
|
|
registry = yaml.safe_load(
|
|
(Path(__file__).resolve().parents[1] / "platforms" / "_registry.yml").read_text()
|
|
)
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
env = {
|
|
"HOME": str(root / "home"), "APPDATA": str(root / "appdata"),
|
|
"ProgramFiles": str(root / "pf"), "ProgramFiles(x86)": str(root / "pf86"),
|
|
}
|
|
|
|
def expand(text: str) -> str:
|
|
text = text.replace("$HOME", env["HOME"])
|
|
for var in ("ProgramFiles(x86)", "ProgramFiles", "APPDATA"):
|
|
text = text.replace(f"%{var}%", env[var])
|
|
return str(Path(text.replace("\\", "/")))
|
|
|
|
published = {
|
|
(rule["os"], expand(rule["config"]))
|
|
for entry in registry["platforms"].values()
|
|
for rule in (entry.get("install") or {}).get("detect", [])
|
|
if rule["method"] == "config_file"
|
|
}
|
|
read: set[tuple[str, str]] = set()
|
|
|
|
def record(os_type: str):
|
|
def probe(path, *_args, **_kwargs):
|
|
read.add((os_type, str(Path(str(path).replace("\\", "/")))))
|
|
return None
|
|
return probe
|
|
|
|
with unittest.mock.patch.dict(os.environ, env), \
|
|
unittest.mock.patch.object(install, "_detect_embedded", lambda: []), \
|
|
unittest.mock.patch.object(install, "_detect_android", lambda: []):
|
|
for os_type in ("linux", "darwin", "windows"):
|
|
with unittest.mock.patch.object(
|
|
install, "_parse_retroarch_system_dir", record(os_type)
|
|
):
|
|
install.detect_platforms(os_type)
|
|
with unittest.mock.patch.object(Path, "is_file", lambda _self: True):
|
|
for folder in install._windows_retroarch_dirs():
|
|
read.add(("windows", str(folder / "retroarch.cfg")))
|
|
retrodeck = root / "home/.var/app/net.retrodeck.retrodeck/config/retrodeck"
|
|
read |= {("linux", str(retrodeck / "retrodeck.json")),
|
|
("linux", str(retrodeck / "retrodeck.cfg"))}
|
|
# The default Steam library is the Program Files one, already read.
|
|
self.assertEqual(sorted(read - published), [])
|
|
|
|
def test_the_first_embedded_match_wins(self):
|
|
with unittest.mock.patch.object(install, "_parse_os_release", return_value={}), \
|
|
unittest.mock.patch.object(
|
|
Path, "exists",
|
|
lambda p: str(p) in ("/usr/share/knulli/knulli.version",
|
|
"/usr/share/batocera/batocera.version"),
|
|
):
|
|
self.assertEqual(install._detect_embedded(), [("knulli", Path("/userdata/bios"))])
|
|
# What ROCKNIX writes since 2025: OS_NAME and HW_DEVICE, no ID.
|
|
with unittest.mock.patch.object(install, "_parse_os_release",
|
|
return_value={"OS_NAME": "ROCKNIX", "HW_DEVICE": "RK3566"}):
|
|
self.assertEqual(
|
|
install._detect_embedded(), [("rocknix", Path("/storage/roms/bios"))]
|
|
)
|
|
|
|
def _home(self) -> Path:
|
|
home = Path(_tempdir(self))
|
|
patcher = unittest.mock.patch.dict(os.environ, {"HOME": str(home), "USERPROFILE": str(home)})
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
return home
|
|
|
|
def test_a_home_layout_is_detected(self):
|
|
home = self._home()
|
|
(home / "RetroPie" / "BIOS").mkdir(parents=True)
|
|
found: list = []
|
|
install._detect_by_path("linux", found)
|
|
self.assertIn(("retropie", home / "RetroPie" / "BIOS"), found)
|
|
|
|
def test_bizhawk_is_found_by_its_executable(self):
|
|
"""~/.config/BizHawk/Firmware is a folder no build reads: the
|
|
archive extracted to ~/Games/BizHawk-2.11.1 reads the Firmware folder
|
|
beside EmuHawkMono.sh."""
|
|
home = self._home()
|
|
extracted = home / "Games" / "BizHawk-2.11.1"
|
|
extracted.mkdir(parents=True)
|
|
(extracted / "EmuHawkMono.sh").write_text("#!/bin/sh\n")
|
|
found: list = []
|
|
install._detect_by_executable("linux", found)
|
|
self.assertEqual(found, [("bizhawk", extracted / "Firmware")])
|
|
self.assertNotIn("bizhawk", install._os_default_dests("linux"))
|
|
self.assertNotIn("bizhawk", install._os_default_dests("windows"))
|
|
|
|
def test_a_portable_platform_is_not_guessed(self):
|
|
"""--platform bizhawk with no install found wrote 924 files into
|
|
~/.config/BizHawk/Firmware, then detected that folder next time."""
|
|
home = self._home()
|
|
with unittest.mock.patch.object(sys, "argv", ["install.py", "--platform", "bizhawk"]), \
|
|
unittest.mock.patch.object(install, "detect_os", return_value="linux"), \
|
|
unittest.mock.patch.object(install, "detect_platforms", return_value=[]), \
|
|
contextlib.redirect_stdout(io.StringIO()), \
|
|
contextlib.redirect_stderr(io.StringIO()) as err, \
|
|
self.assertRaises(SystemExit) as stop:
|
|
install.main()
|
|
self.assertEqual(stop.exception.code, 1)
|
|
self.assertIn("--dest", err.getvalue())
|
|
self.assertFalse((home / ".config" / "BizHawk").exists())
|
|
|
|
def test_the_executable_table_is_the_registry(self):
|
|
import yaml # noqa: PLC0415
|
|
|
|
registry = yaml.safe_load(
|
|
(Path(__file__).resolve().parents[1] / "platforms" / "_registry.yml").read_text()
|
|
)
|
|
rules = {
|
|
(rule["os"], name, tuple(rule["file"]), rule["bios_subdir"])
|
|
for name, entry in registry["platforms"].items()
|
|
for rule in (entry.get("install") or {}).get("detect", [])
|
|
if rule["method"] == "executable"
|
|
}
|
|
self.assertEqual(set(install._EXE_RULES), rules)
|
|
|
|
def test_a_windows_profile_path_is_detected(self):
|
|
home = self._home()
|
|
(home / "BizHawk" / "Firmware").mkdir(parents=True)
|
|
found: list = []
|
|
install._detect_by_path("windows", found)
|
|
self.assertEqual(found, [("bizhawk", home / "BizHawk" / "Firmware")])
|
|
|
|
def test_a_platform_already_found_is_not_added_again(self):
|
|
home = self._home()
|
|
(home / "Emulation" / "bios").mkdir(parents=True)
|
|
found = [("emudeck", home / "elsewhere" / "bios")]
|
|
install._detect_by_path("linux", found)
|
|
self.assertEqual(found, [("emudeck", home / "elsewhere" / "bios")])
|
|
|
|
def test_emudeck_on_macos_is_found_by_its_settings(self):
|
|
"""EmuDeck's macOS build reads ~/.config/EmuDeck/settings.sh as the
|
|
Linux one does (functions/all.sh:3-4,24, vars.sh:2): a Mac with
|
|
EmuDeck detected nothing and was offered RetroArch's folder."""
|
|
home = self._home()
|
|
settings = home / ".config" / "EmuDeck" / "settings.sh"
|
|
settings.parent.mkdir(parents=True)
|
|
settings.write_text(f"emulationPath={home / 'Games' / 'Emulation'}\n")
|
|
self.assertIn(("emudeck", home / "Games" / "Emulation" / "bios"),
|
|
install.detect_platforms("darwin"))
|
|
settings.unlink()
|
|
(home / "Emulation" / "bios").mkdir(parents=True)
|
|
self.assertIn(("emudeck", home / "Emulation" / "bios"),
|
|
install.detect_platforms("darwin"))
|
|
|
|
|
|
class RetroArchIsFoundWhereWindowsInstallsPutIt(unittest.TestCase):
|
|
"""Program Files\\RetroArch-Win64, a Steam library on another drive and
|
|
an installed ES-DE's Emulators folder all held a RetroArch the one-liner
|
|
reported as "no supported platform detected"."""
|
|
|
|
def _env(self, program_files: Path) -> None:
|
|
patcher = unittest.mock.patch.dict(
|
|
os.environ, {"ProgramFiles": str(program_files), "ProgramFiles(x86)": ""}
|
|
)
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
|
|
@staticmethod
|
|
def _exe(folder: Path) -> Path:
|
|
folder.mkdir(parents=True)
|
|
(folder / "retroarch.exe").write_bytes(b"MZ")
|
|
return folder
|
|
|
|
def test_program_files_win64(self):
|
|
program_files = Path(_tempdir(self))
|
|
self._env(program_files)
|
|
found = self._exe(program_files / "RetroArch-Win64")
|
|
self.assertIn(found, install._windows_retroarch_dirs())
|
|
|
|
def test_a_scoop_install(self):
|
|
# `scoop install retroarch` puts it under ~/scoop/apps/retroarch/current,
|
|
# which none of the searched folders covered.
|
|
program_files = Path(_tempdir(self))
|
|
self._env(program_files)
|
|
home = Path(_tempdir(self))
|
|
found = self._exe(home / "scoop" / "apps" / "retroarch" / "current")
|
|
with unittest.mock.patch.dict(os.environ, {"USERPROFILE": str(home)}):
|
|
os.environ.pop("SCOOP", None)
|
|
self.assertIn(found, install._windows_retroarch_dirs())
|
|
|
|
def test_a_steam_library_on_another_drive(self):
|
|
program_files = Path(_tempdir(self))
|
|
library = Path(_tempdir(self))
|
|
steamapps = program_files / "Steam" / "steamapps"
|
|
steamapps.mkdir(parents=True)
|
|
(steamapps / "libraryfolders.vdf").write_text(
|
|
'"libraryfolders"\n{\n\t"1"\n\t{\n\t\t"path"\t\t"' + str(library) + '"\n\t}\n}\n'
|
|
)
|
|
self._env(program_files)
|
|
found = self._exe(library / "steamapps" / "common" / "RetroArch")
|
|
self.assertIn(found, install._windows_retroarch_dirs())
|
|
|
|
def test_an_installed_es_de(self):
|
|
program_files = Path(_tempdir(self))
|
|
self._env(program_files)
|
|
found = self._exe(program_files / "ES-DE" / "Emulators" / "RetroArch-Win64")
|
|
self.assertIn(found, install._windows_retroarch_dirs())
|
|
|
|
|
|
class InstallerStaysOnPython38AndTheStdlib(unittest.TestCase):
|
|
"""install.py runs where the bootstraps let it run: Python 3.8, stdlib only.
|
|
|
|
The suite runs on 3.12 with pyyaml installed, so a removeprefix() or an
|
|
import of yaml kept every test green and died at the user's first run,
|
|
after the bootstrap had told them 3.8 was enough.
|
|
"""
|
|
|
|
SOURCE = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
|
|
|
|
def test_the_grammar_is_python_38(self):
|
|
import ast # noqa: PLC0415
|
|
|
|
ast.parse(self.SOURCE, filename="install.py", feature_version=(3, 8))
|
|
|
|
def test_every_import_is_standard_library(self):
|
|
import ast # noqa: PLC0415
|
|
import sys # noqa: PLC0415
|
|
|
|
tree = ast.parse(self.SOURCE)
|
|
imported = set()
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Import):
|
|
imported.update(alias.name.split(".")[0] for alias in node.names)
|
|
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
|
|
imported.add(node.module.split(".")[0])
|
|
self.assertEqual(sorted(imported - set(sys.stdlib_module_names)), [])
|
|
|
|
def test_no_api_newer_than_38(self):
|
|
newer = (".removeprefix(", ".removesuffix(", "zoneinfo", "graphlib",
|
|
"functools.cache(", ".bit_count(", "math.lcm(", "math.nextafter(",
|
|
"tomllib", ".is_relative_to(", ".with_stem(", ".hardlink_to(",
|
|
"itertools.pairwise(", "str.removeprefix", "types.NoneType")
|
|
used = [api for api in newer if api in self.SOURCE]
|
|
self.assertEqual(used, [])
|
|
|
|
def test_a_python_38_interpreter_compiles_and_imports_it(self):
|
|
"""ast.parse(feature_version=(3, 8)) on 3.12 accepts parenthesized
|
|
context managers and PEP 701 f-strings: only a 3.8 interpreter says."""
|
|
import shutil # noqa: PLC0415
|
|
import subprocess # noqa: PLC0415
|
|
|
|
python38 = shutil.which("python3.8")
|
|
if python38 is None:
|
|
self.skipTest("no python3.8 on PATH, the static checks below still run")
|
|
code = (
|
|
"import importlib.util; "
|
|
"spec = importlib.util.spec_from_file_location('install', 'install.py'); "
|
|
"spec.loader.exec_module(importlib.util.module_from_spec(spec))"
|
|
)
|
|
proc = subprocess.run(
|
|
[python38, "-I", "-c", code], cwd=REPO_ROOT,
|
|
capture_output=True, text=True, timeout=60, check=False,
|
|
)
|
|
self.assertEqual(proc.returncode, 0, proc.stderr)
|
|
|
|
def test_a_python_38_interpreter_runs_it_end_to_end(self):
|
|
"""An import proves the grammar, not the calls a function makes.
|
|
|
|
dict | dict, zip(strict=True) or functools.cache import cleanly on
|
|
3.8 and raise the first time their path runs: listing, narrowing by
|
|
system, region and target, installing and checking all run here, on
|
|
the repository's own manifests served over loopback. CI installs the
|
|
interpreter, so there it may not skip.
|
|
"""
|
|
import shutil # noqa: PLC0415
|
|
import subprocess # noqa: PLC0415
|
|
|
|
python38 = shutil.which("python3.8")
|
|
if python38 is None:
|
|
if os.environ.get("CI"):
|
|
self.fail("CI must provide python3.8 for the installer's floor")
|
|
self.skipTest("no python3.8 on PATH, the static checks below still run")
|
|
handler = functools.partial(
|
|
http.server.SimpleHTTPRequestHandler, directory=str(REPO_ROOT)
|
|
)
|
|
handler.log_message = lambda *args: None
|
|
httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
|
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
|
self.addCleanup(httpd.server_close)
|
|
self.addCleanup(httpd.shutdown)
|
|
(REPO_ROOT / "tmp").mkdir(exist_ok=True)
|
|
home = Path(tempfile.mkdtemp(dir=str(REPO_ROOT / "tmp")))
|
|
self.addCleanup(shutil.rmtree, home, True)
|
|
env = {
|
|
**os.environ,
|
|
"HOME": str(home),
|
|
"RETROBIOS_OS": "linux",
|
|
"RETROBIOS_BASE_URL": f"http://127.0.0.1:{httpd.server_address[1]}",
|
|
}
|
|
dest = home / "system"
|
|
base = [python38, "-I", "install.py", "--platform", "retroarch", "--no-input"]
|
|
for extra in (
|
|
["--list-systems"],
|
|
["--list-cores"],
|
|
["--list-targets"],
|
|
["--dest", str(dest), "--system", "atari-lynx", "--region", "us",
|
|
"--target", "switch", "--yes"],
|
|
["--dest", str(dest), "--system", "atari-lynx", "--check"],
|
|
):
|
|
with self.subTest(args=extra):
|
|
proc = subprocess.run(
|
|
base + extra, cwd=REPO_ROOT, env=env,
|
|
capture_output=True, text=True, timeout=300, check=False,
|
|
)
|
|
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
|
self.assertNotIn("Traceback", proc.stderr)
|
|
self.assertTrue((dest / "lynxboot.img").is_file())
|
|
# A check is scripted as `--check && launch`: missing files fail it.
|
|
proc = subprocess.run(
|
|
base + ["--dest", str(home / "empty"), "--system", "atari-lynx", "--check"],
|
|
cwd=REPO_ROOT, env=env, capture_output=True, text=True, timeout=300,
|
|
check=False,
|
|
)
|
|
self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr)
|
|
self.assertIn("to download:", proc.stdout)
|
|
|
|
def test_no_syntax_a_312_parser_lets_through(self):
|
|
"""Parenthesized context managers (3.9) and f-strings reusing their
|
|
own quote or a backslash inside a replacement field (3.12)."""
|
|
import io # noqa: PLC0415
|
|
import tokenize # noqa: PLC0415
|
|
|
|
found = []
|
|
tokens = list(tokenize.generate_tokens(io.StringIO(self.SOURCE).readline))
|
|
for i, tok in enumerate(tokens):
|
|
if tok.type == tokenize.NAME and tok.string == "with" and i + 1 < len(tokens):
|
|
if tokens[i + 1].string != "(":
|
|
continue
|
|
depth = 0
|
|
for inner in tokens[i + 1:]:
|
|
if inner.string in "([{" and inner.type == tokenize.OP:
|
|
depth += 1
|
|
elif inner.string in ")]}" and inner.type == tokenize.OP:
|
|
depth -= 1
|
|
if depth == 0:
|
|
break
|
|
elif depth == 1 and inner.type == tokenize.NAME and inner.string == "as":
|
|
found.append(f"line {tok.start[0]}: parenthesized with")
|
|
break
|
|
fstring_start = getattr(tokenize, "FSTRING_START", None)
|
|
if fstring_start is not None:
|
|
stack = []
|
|
for tok in tokens:
|
|
if tok.type == fstring_start:
|
|
if stack:
|
|
found.append(f"line {tok.start[0]}: f-string inside an f-string")
|
|
stack.append(tok.string[-1])
|
|
elif tok.type == tokenize.FSTRING_END:
|
|
stack.pop()
|
|
elif stack and tok.type == tokenize.STRING and stack[-1] in tok.string:
|
|
found.append(f"line {tok.start[0]}: f-string reuses its quote")
|
|
elif stack and tok.type == tokenize.FSTRING_MIDDLE:
|
|
continue
|
|
elif stack and tok.type == tokenize.ERRORTOKEN and tok.string == "\\":
|
|
found.append(f"line {tok.start[0]}: backslash in an f-string field")
|
|
self.assertEqual(found, [])
|
|
|
|
def test_no_runtime_generic_builtin(self):
|
|
"""dict[str, Path] outside an annotation runs on 3.9 and later only."""
|
|
import ast # noqa: PLC0415
|
|
|
|
tree = ast.parse(self.SOURCE)
|
|
annotations = set()
|
|
for node in ast.walk(tree):
|
|
for field in ("annotation", "returns"):
|
|
sub = getattr(node, field, None)
|
|
if sub is not None:
|
|
annotations.update(id(n) for n in ast.walk(sub))
|
|
generic = {"dict", "list", "tuple", "set", "frozenset", "type"}
|
|
used = sorted(
|
|
f"line {node.lineno}: {node.value.id}[...]"
|
|
for node in ast.walk(tree)
|
|
if isinstance(node, ast.Subscript)
|
|
and isinstance(node.value, ast.Name)
|
|
and node.value.id in generic
|
|
and id(node) not in annotations
|
|
)
|
|
self.assertEqual(used, [])
|
|
|
|
|
|
def test_no_local_is_assigned_and_never_read(self):
|
|
"""ruff --select F in py38 mode is the installer's lint gate, and an
|
|
unread `appdata` in _os_default_dests made it exit 1. The suite has
|
|
no ruff, so the rule is read from the syntax tree."""
|
|
import ast # noqa: PLC0415
|
|
|
|
tree = ast.parse((Path(__file__).resolve().parents[1] / "install.py").read_text())
|
|
unread = []
|
|
for func in ast.walk(tree):
|
|
if not isinstance(func, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
continue
|
|
stored, loaded = {}, set()
|
|
for node in ast.walk(func):
|
|
if isinstance(node, ast.Name):
|
|
if isinstance(node.ctx, ast.Store):
|
|
stored.setdefault(node.id, node.lineno)
|
|
else:
|
|
loaded.add(node.id)
|
|
nested = {
|
|
n.id for inner in ast.walk(func) if inner is not func
|
|
and isinstance(inner, (ast.FunctionDef, ast.Lambda))
|
|
for n in ast.walk(inner) if isinstance(n, ast.Name)
|
|
}
|
|
unread += [
|
|
f"{func.name}:{line} {name}" for name, line in stored.items()
|
|
if name not in loaded | nested and not name.startswith("_")
|
|
and not any(
|
|
isinstance(g, (ast.Global, ast.Nonlocal)) and name in g.names
|
|
for g in ast.walk(func)
|
|
)
|
|
]
|
|
self.assertEqual(unread, [])
|
|
# Nor a module rebound: a loop variable named `platform` shadowed the
|
|
# platform module in _detect_by_path (F402).
|
|
modules = {
|
|
alias.asname or alias.name.split(".")[0]
|
|
for node in tree.body if isinstance(node, ast.Import)
|
|
for alias in node.names
|
|
}
|
|
rebound = sorted(
|
|
f"{node.id}:{node.lineno}" for node in ast.walk(tree)
|
|
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store)
|
|
and node.id in modules
|
|
)
|
|
self.assertEqual(rebound, [])
|
|
|
|
class ARefusedCopyIsAnError(unittest.TestCase):
|
|
"""A PermissionError on a standalone copy was counted as "dir not found"
|
|
and the run ended on Done with exit code 0."""
|
|
|
|
def test_a_refused_copy_is_counted_failed_not_skipped(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
bios = Path(tmp, "bios")
|
|
bios.mkdir()
|
|
(bios / "prod.keys").write_bytes(b"k")
|
|
target = Path(tmp, "keys")
|
|
target.mkdir()
|
|
manifest = {"standalone_copies": [
|
|
{"file": "prod.keys", "targets": {"linux": [str(target)]}}
|
|
]}
|
|
with unittest.mock.patch("shutil.copy2", side_effect=PermissionError(13, "denied")):
|
|
result = install.do_standalone_copies(manifest, bios, "linux")
|
|
self.assertEqual(result, (0, 0, 1))
|
|
|
|
|
|
class TargetFlagIsAppliedOrRefused(unittest.TestCase):
|
|
"""A filter the installer cannot apply must stop it, not be ignored.
|
|
|
|
An unknown target used to print a warning and then install every file:
|
|
4 GB where the user asked for a subset. And the aliases the pack builder
|
|
accepts -- switch, ps2, psp -- were absent from the published target
|
|
manifests, so the documented word was the one that failed.
|
|
"""
|
|
|
|
def test_the_published_manifests_carry_the_builder_aliases(self):
|
|
path = REPO_ROOT / "install" / "targets" / "retroarch.json"
|
|
if not path.is_file():
|
|
self.skipTest("no target manifest generated yet")
|
|
targets = json.loads(path.read_text())
|
|
self.assertIn("nintendo-switch", targets)
|
|
for alias in ("switch", "nx"):
|
|
with self.subTest(alias=alias):
|
|
self.assertIn(alias, targets, f"builder accepts --target {alias}")
|
|
self.assertEqual(targets[alias], targets["nintendo-switch"])
|
|
|
|
def test_every_alias_resolves_to_its_canonical_core_list(self):
|
|
import sys as _sys
|
|
|
|
_sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
|
from common import load_target_config
|
|
|
|
for name in ("install/targets/retroarch.json",):
|
|
path = REPO_ROOT / name
|
|
if not path.is_file():
|
|
self.skipTest(f"{name} not generated yet")
|
|
targets = json.loads(path.read_text())
|
|
for alias in ("switch", "ps2", "psp"):
|
|
# An alias the builder accepts and the manifest lacks is a
|
|
# documented word that fails on the installer's side.
|
|
self.assertIn(alias, targets, f"builder accepts --target {alias}")
|
|
with self.subTest(alias=alias):
|
|
resolved = load_target_config(
|
|
"retroarch", alias, str(REPO_ROOT / "platforms")
|
|
)
|
|
self.assertEqual(
|
|
sorted(targets[alias] or []),
|
|
sorted(resolved),
|
|
f"{alias} disagrees with what the builder resolves",
|
|
)
|
|
|
|
def test_every_published_target_is_the_list_the_builder_resolves(self):
|
|
"""add_cores and remove_cores reach the installer, not only the builder.
|
|
|
|
The published lists were the scraped ones, untouched by the
|
|
overrides: Batocera x86_64 lacked citron and xenia, which the pack
|
|
for that target ships, and carried the Amiga machine variants and
|
|
the launchers the builder drops.
|
|
|
|
Both ways: a target the builder knows and the installer lacks answers
|
|
"unknown target" on one side only, and a published null left behind
|
|
once the builder filters a target makes the installer serve every core.
|
|
"""
|
|
import sys as _sys
|
|
|
|
_sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
|
from common import list_available_targets, load_target_config
|
|
|
|
platforms = str(REPO_ROOT / "platforms")
|
|
sources = sorted(
|
|
path.stem for path in (REPO_ROOT / "platforms" / "targets").glob("*.yml")
|
|
if not path.name.startswith("_")
|
|
)
|
|
manifests = {
|
|
path.stem: path for path in (REPO_ROOT / "install" / "targets").glob("*.json")
|
|
}
|
|
if not manifests:
|
|
self.skipTest("no target manifest generated yet")
|
|
self.assertEqual(sorted(manifests), sources)
|
|
for platform in sources:
|
|
targets = json.loads(manifests[platform].read_text())
|
|
known = {
|
|
name
|
|
for target in list_available_targets(platform, platforms)
|
|
for name in (target["name"], *target["aliases"])
|
|
}
|
|
with self.subTest(platform=platform):
|
|
self.assertEqual(sorted(targets), sorted(known))
|
|
for target, cores in targets.items():
|
|
with self.subTest(platform=platform, target=target):
|
|
resolved = load_target_config(platform, target, platforms)
|
|
self.assertEqual(cores, None if resolved is None else sorted(resolved))
|
|
|
|
|
|
class TestBaseUrlScheme(unittest.TestCase):
|
|
"""The bootstraps insist on HTTPS, so the installer must not be laxer."""
|
|
|
|
def test_https_is_accepted(self):
|
|
self.assertEqual(
|
|
install._checked_base_url("https://example.test/repo"),
|
|
"https://example.test/repo",
|
|
)
|
|
|
|
def test_plain_http_is_refused(self):
|
|
with self.assertRaises(SystemExit):
|
|
install._checked_base_url("http://example.test/repo")
|
|
|
|
def test_local_file_scheme_is_refused(self):
|
|
with self.assertRaises(SystemExit):
|
|
install._checked_base_url("file:///etc")
|
|
|
|
def test_loopback_http_is_allowed_for_end_to_end_runs(self):
|
|
for url in (
|
|
"http://127.0.0.1:8080/repo",
|
|
"http://localhost:8080/repo",
|
|
"http://[::1]:8080/repo",
|
|
):
|
|
self.assertEqual(install._checked_base_url(url), url)
|
|
|
|
|
|
class TestLocalCheckHashing(unittest.TestCase):
|
|
"""A file is read once, not once per declared digest."""
|
|
|
|
def test_both_digests_come_from_one_read(self):
|
|
tmp = Path(_tempdir(self))
|
|
payload = b"BIOS PAYLOAD"
|
|
(tmp / "boot.bin").write_bytes(payload)
|
|
entry = {
|
|
"dest": "boot.bin",
|
|
"size": len(payload),
|
|
"sha1": hashlib.sha1(payload).hexdigest(),
|
|
"sha256": hashlib.sha256(payload).hexdigest(),
|
|
}
|
|
calls = []
|
|
real_digest = install._digest_file
|
|
|
|
def recording(path, algorithms):
|
|
calls.append(tuple(sorted(algorithms)))
|
|
return real_digest(path, algorithms)
|
|
|
|
install._digest_file = recording
|
|
try:
|
|
to_download, up_to_date, mismatched = install.check_local([entry], tmp)
|
|
finally:
|
|
install._digest_file = real_digest
|
|
self.assertEqual((len(to_download), len(up_to_date), len(mismatched)), (0, 1, 0))
|
|
self.assertEqual(calls, [("sha1", "sha256")], "file must be read once")
|
|
|
|
def test_a_wrong_digest_still_lands_in_mismatched(self):
|
|
tmp = Path(_tempdir(self))
|
|
(tmp / "boot.bin").write_bytes(b"BIOS PAYLOAD")
|
|
entry = {
|
|
"dest": "boot.bin",
|
|
"size": 12,
|
|
"sha1": "0" * 40,
|
|
"sha256": hashlib.sha256(b"BIOS PAYLOAD").hexdigest(),
|
|
}
|
|
_, up_to_date, mismatched = install.check_local([entry], tmp)
|
|
self.assertEqual((len(up_to_date), len(mismatched)), (0, 1))
|
|
|
|
|
|
class TestStandaloneCopyTargetsAreUntrusted(unittest.TestCase):
|
|
"""The manifest names these directories, so it does not get to escape them."""
|
|
|
|
def _manifest(self, target: str) -> dict:
|
|
return {
|
|
"standalone_copies": [
|
|
{"file": "boot.bin", "targets": {"linux": [target]}}
|
|
]
|
|
}
|
|
|
|
def test_traversal_in_a_target_is_refused_by_validation(self):
|
|
manifest = {
|
|
"manifest_version": 2,
|
|
"platform": "retroarch",
|
|
"files": [],
|
|
"standalone_copies": [
|
|
{"file": "boot.bin", "targets": {"linux": ["~/emu/../../../etc"]}}
|
|
],
|
|
}
|
|
with self.assertRaises(ValueError):
|
|
install._validate_manifest(manifest, "retroarch")
|
|
|
|
def test_nul_in_a_target_is_refused_by_validation(self):
|
|
manifest = {
|
|
"manifest_version": 2,
|
|
"platform": "retroarch",
|
|
"files": [],
|
|
"standalone_copies": [
|
|
{"file": "boot.bin", "targets": {"linux": ["/tmp/a\x00b"]}}
|
|
],
|
|
}
|
|
with self.assertRaises(ValueError):
|
|
install._validate_manifest(manifest, "retroarch")
|
|
|
|
def test_entries_the_copy_step_cannot_read_are_refused(self):
|
|
"""Each passed validation and crashed do_standalone_copies after the
|
|
download: a list for detect, no file nor pattern, a list for emulator."""
|
|
for entry in (
|
|
{"note": "hi", "detect": ["/x"]},
|
|
{"emulator": "pcsx2", "targets": {"linux": ["/tmp"]}},
|
|
{"file": "bios/x.bin", "emulator": ["a"], "targets": {"linux": ["/tmp"]}},
|
|
):
|
|
manifest = {"manifest_version": 2, "platform": "retroarch", "files": [],
|
|
"standalone_copies": [entry]}
|
|
with self.subTest(entry=entry), self.assertRaises(ValueError):
|
|
install._validate_manifest(manifest, "retroarch")
|
|
|
|
def test_plain_absolute_target_still_works(self):
|
|
manifest = {
|
|
"manifest_version": 2,
|
|
"platform": "retroarch",
|
|
"files": [],
|
|
"standalone_copies": [
|
|
{"file": "boot.bin", "targets": {"linux": ["~/.config/emu/bios"]}}
|
|
],
|
|
}
|
|
self.assertIsInstance(install._validate_manifest(manifest, "retroarch"), dict)
|
|
|
|
def test_symlinked_destination_is_not_written_through(self):
|
|
"""A link at the name was skipped in silence and counted as "dir
|
|
not found" while the folder existed: the emulator kept whatever the
|
|
link pointed at. The link now gives way to the file."""
|
|
bios = Path(_tempdir(self))
|
|
(bios / "boot.bin").write_text("rom")
|
|
target = Path(_tempdir(self))
|
|
outside = Path(_tempdir(self)) / "secret"
|
|
outside.write_text("untouched")
|
|
(target / "boot.bin").symlink_to(outside)
|
|
|
|
out = io.StringIO()
|
|
with contextlib.redirect_stdout(out):
|
|
copied, skipped, failed = install.do_standalone_copies(
|
|
self._manifest(str(target)), bios, "linux"
|
|
)
|
|
self.assertEqual((copied, skipped, failed), (1, 0, 0))
|
|
self.assertEqual(outside.read_text(), "untouched")
|
|
self.assertFalse((target / "boot.bin").is_symlink())
|
|
self.assertEqual((target / "boot.bin").read_text(), "rom")
|
|
self.assertIn("link replaced", out.getvalue())
|
|
self.assertEqual(sorted(p.name for p in target.iterdir()), ["boot.bin"])
|
|
|
|
|
|
@unittest.skipUnless(shutil.which("pwsh"), "pwsh not available")
|
|
class TestDestinationFollowsUserLinks(unittest.TestCase):
|
|
"""EmuDeck links bios/shadps4/sys_modules into shadPS4's own directory.
|
|
|
|
The manifest still cannot climb out of the BIOS root, but a link the
|
|
user's frontend placed under it is where the emulator reads the file.
|
|
"""
|
|
|
|
def test_symlinked_directory_under_root_is_followed(self):
|
|
bios = Path(_tempdir(self))
|
|
elsewhere = Path(_tempdir(self)) / "sys_modules"
|
|
elsewhere.mkdir()
|
|
(bios / "shadps4").mkdir()
|
|
(bios / "shadps4" / "sys_modules").symlink_to(elsewhere)
|
|
|
|
dest = install._destination_path(bios, "shadps4/sys_modules/libSceLibcInternal.sprx")
|
|
|
|
self.assertEqual(dest, bios.resolve() / "shadps4" / "sys_modules" / "libSceLibcInternal.sprx")
|
|
self.assertEqual(dest.resolve().parent, elsewhere.resolve())
|
|
|
|
def test_check_local_survives_a_linked_directory(self):
|
|
bios = Path(_tempdir(self))
|
|
elsewhere = Path(_tempdir(self)) / "sys_modules"
|
|
elsewhere.mkdir()
|
|
(bios / "shadps4").mkdir()
|
|
(bios / "shadps4" / "sys_modules").symlink_to(elsewhere)
|
|
entry = {"dest": "shadps4/sys_modules/x.sprx", "size": 1, "sha1": "0" * 40}
|
|
|
|
to_download, up_to_date, mismatched = install.check_local([entry], bios)
|
|
|
|
self.assertEqual([e["dest"] for e in to_download], ["shadps4/sys_modules/x.sprx"])
|
|
self.assertEqual((up_to_date, mismatched), ([], []))
|
|
|
|
def test_climbing_is_still_refused(self):
|
|
bios = Path(_tempdir(self))
|
|
with self.assertRaises(ValueError):
|
|
install._destination_path(bios, "../outside.bin")
|
|
with self.assertRaises(ValueError):
|
|
install._destination_path(bios, "/etc/passwd")
|
|
|
|
|
|
class TestDetectedPlatformPrompt(unittest.TestCase):
|
|
"""With two platforms detected the menu had no quit entry, and q or any
|
|
wrong answer brought back a bare prompt."""
|
|
|
|
PLATFORMS = [("emudeck", Path("/e")), ("retroarch", Path("/r"))]
|
|
|
|
def _answer(self, *answers: str):
|
|
replies = iter(answers)
|
|
out = io.StringIO()
|
|
with unittest.mock.patch("builtins.input", lambda _prompt="": next(replies)), \
|
|
contextlib.redirect_stdout(out):
|
|
try:
|
|
result = install._prompt_platform_choice(self.PLATFORMS)
|
|
except SystemExit as exc:
|
|
result = exc
|
|
return result, out.getvalue()
|
|
|
|
def test_q_quits_and_the_menu_says_so(self):
|
|
result, out = self._answer("q")
|
|
self.assertIsInstance(result, SystemExit)
|
|
self.assertEqual(result.code, 130)
|
|
self.assertIn("q) quit", out)
|
|
|
|
def test_a_wrong_answer_names_the_choices(self):
|
|
result, out = self._answer("x", "2")
|
|
self.assertEqual(result, [self.PLATFORMS[1]])
|
|
self.assertIn("Type a number from 1 to 3, or q to quit.", out)
|
|
|
|
def test_all_installs_every_platform(self):
|
|
result, _ = self._answer("3")
|
|
self.assertEqual(result, self.PLATFORMS)
|
|
|
|
|
|
class TestManualPlatformPrompt(unittest.TestCase):
|
|
"""With nothing detected, an interactive run asks instead of exiting."""
|
|
|
|
def test_number_picks_the_platform_and_default_directory(self):
|
|
answers = iter(["1", ""])
|
|
with unittest.mock.patch("builtins.input", lambda _prompt="": next(answers)):
|
|
chosen = install._prompt_manual_platform("linux")
|
|
self.assertEqual(
|
|
chosen, [("retroarch", Path.home() / ".config" / "retroarch" / "system")]
|
|
)
|
|
|
|
def test_enter_takes_retroarch_as_the_dest_menu_does(self):
|
|
"""The --dest menu offered `[Enter for RetroArch]` and this one, the
|
|
same list, had no default."""
|
|
answers = iter(["", ""])
|
|
out = io.StringIO()
|
|
with unittest.mock.patch("builtins.input", lambda _prompt="": next(answers)), \
|
|
contextlib.redirect_stdout(out):
|
|
chosen = install._prompt_manual_platform("linux")
|
|
self.assertEqual(chosen[0][0], "retroarch")
|
|
|
|
def test_name_and_explicit_directory(self):
|
|
answers = iter(["batocera", "~/my bios"])
|
|
with unittest.mock.patch("builtins.input", lambda _prompt="": next(answers)):
|
|
chosen = install._prompt_manual_platform("linux")
|
|
self.assertEqual(chosen, [("batocera", Path("~/my bios").expanduser())])
|
|
|
|
def test_default_directory_comes_from_the_table(self):
|
|
answers = iter(["batocera", ""])
|
|
with unittest.mock.patch("builtins.input", lambda _prompt="": next(answers)):
|
|
chosen = install._prompt_manual_platform("linux")
|
|
self.assertEqual(chosen, [("batocera", install.DEFAULT_DESTS["batocera"])])
|
|
|
|
def test_quit_stops_like_ctrl_c(self):
|
|
"""`install.sh && retroarch` went on to start RetroArch with no BIOS
|
|
after a q, which left with 0 where Ctrl+C leaves with 130."""
|
|
with unittest.mock.patch("builtins.input", lambda _prompt="": "q"):
|
|
with self.assertRaises(SystemExit) as raised:
|
|
install._prompt_manual_platform("linux")
|
|
self.assertEqual(raised.exception.code, 130)
|
|
|
|
def test_quotes_around_a_typed_folder_are_dropped(self):
|
|
"""Explorer's "Copy as path" quotes the folder: the files went into a
|
|
folder named `"` under the current directory, and the run said Done."""
|
|
root = Path(_tempdir(self))
|
|
for typed in (f'"{root}/My Card/bios"', f"'{root}/My Card/bios'",
|
|
f' "{root}/My Card/bios" '):
|
|
answers = iter(["1", typed])
|
|
with self.subTest(typed=typed), \
|
|
unittest.mock.patch("builtins.input", lambda _prompt="": next(answers)), \
|
|
contextlib.redirect_stdout(io.StringIO()):
|
|
chosen = install._prompt_manual_platform("linux")
|
|
self.assertEqual(chosen, [("retroarch", root / "My Card" / "bios")])
|
|
|
|
def test_home_and_variables_are_expanded_on_every_system(self):
|
|
for os_type in ("linux", "windows", "darwin"):
|
|
answers = iter(["1", '"~/$RB_TEST_DIR/bios"'])
|
|
with self.subTest(os_type=os_type), \
|
|
unittest.mock.patch.dict(os.environ, {"RB_TEST_DIR": "cards"}), \
|
|
unittest.mock.patch("builtins.input", lambda _prompt="": next(answers)), \
|
|
contextlib.redirect_stdout(io.StringIO()):
|
|
chosen = install._prompt_manual_platform(os_type)
|
|
self.assertEqual(chosen[0][1], Path.home() / "cards" / "bios")
|
|
|
|
def test_a_default_folder_not_there_is_not_found(self):
|
|
"""With nothing installed, Enter printed "Found RetroArch at" a
|
|
folder that did not exist."""
|
|
root = Path(_tempdir(self))
|
|
answers = iter(["1", ""])
|
|
out = io.StringIO()
|
|
with unittest.mock.patch.object(install, "_default_dest",
|
|
return_value=root / "retroarch" / "system"), \
|
|
unittest.mock.patch("builtins.input", lambda _prompt="": next(answers)), \
|
|
contextlib.redirect_stdout(out):
|
|
install._prompt_manual_platform("linux")
|
|
self.assertIn("default BIOS folder", out.getvalue())
|
|
self.assertIn("will be created", out.getvalue())
|
|
self.assertNotIn("Found", out.getvalue())
|
|
|
|
def test_a_wrong_answer_says_what_to_type(self):
|
|
answers = iter(["99", "1", ""])
|
|
out = io.StringIO()
|
|
with unittest.mock.patch("builtins.input", lambda _prompt="": next(answers)), \
|
|
contextlib.redirect_stdout(out):
|
|
install._prompt_manual_platform("linux")
|
|
self.assertIn("or q to quit", out.getvalue())
|
|
|
|
def test_windows_hint_shows_the_one_liner_with_arguments(self):
|
|
with unittest.mock.patch.dict(os.environ, {"RETROBIOS_BOOTSTRAP": "ps1"}):
|
|
hint = "\n".join(install._manual_usage_hint("windows"))
|
|
self.assertIn("--platform retroarch --dest", hint)
|
|
self.assertIn("install.ps1", hint)
|
|
|
|
|
|
class TestSteamRetroarchDetection(unittest.TestCase):
|
|
"""The Steam build is a portable install under the default library."""
|
|
|
|
def test_steam_library_is_found_on_windows(self):
|
|
program_files = Path(_tempdir(self))
|
|
ra_dir = program_files / "Steam" / "steamapps" / "common" / "RetroArch"
|
|
ra_dir.mkdir(parents=True)
|
|
(ra_dir / "retroarch.cfg").write_text('system_directory = ":\\system"\n')
|
|
env = {"ProgramFiles(x86)": str(program_files)}
|
|
with unittest.mock.patch.dict(os.environ, env, clear=True):
|
|
found = install.detect_platforms("windows")
|
|
self.assertIn(("retroarch", ra_dir / "system"), found)
|
|
|
|
|
|
class TestLaunchboxDetectionPowershell(unittest.TestCase):
|
|
"""install.ps1 must resolve LaunchBox's portable RetroArch on its own."""
|
|
|
|
def _run(self, entries: str, seed=None, manifest: dict | None = None):
|
|
userprofile = Path(_tempdir(self))
|
|
ra_dir = userprofile / "LaunchBox" / "Emulators" / "RetroArch"
|
|
ra_dir.mkdir(parents=True)
|
|
data = userprofile / "LaunchBox" / "Data"
|
|
data.mkdir(parents=True)
|
|
(data / "Emulators.xml").write_text(
|
|
"<?xml version=\"1.0\" standalone=\"yes\"?>\n"
|
|
"<LaunchBox>\n"
|
|
f"{entries}"
|
|
"</LaunchBox>\n"
|
|
)
|
|
if seed is not None:
|
|
seed(userprofile)
|
|
serve_root = Path(_tempdir(self))
|
|
(serve_root / "install").mkdir()
|
|
payload = dict(manifest if manifest is not None else {"files": []})
|
|
payload.setdefault("manifest_version", 2)
|
|
payload.setdefault("platform", "retroarch")
|
|
payload.setdefault("files", [])
|
|
(serve_root / "install" / "retroarch.json").write_text(json.dumps(payload))
|
|
handler = functools.partial(
|
|
http.server.SimpleHTTPRequestHandler, directory=str(serve_root)
|
|
)
|
|
httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
|
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
|
try:
|
|
env = dict(os.environ)
|
|
env.update(
|
|
USERPROFILE=str(userprofile),
|
|
APPDATA=str(_tempdir(self)),
|
|
RETROBIOS_BASE_URL=f"http://127.0.0.1:{httpd.server_address[1]}",
|
|
# A LaunchBox layout is a Windows layout: the runner is Linux.
|
|
RETROBIOS_OS="wsl",
|
|
)
|
|
proc = subprocess.run(
|
|
[
|
|
"pwsh", "-NoProfile", "-File",
|
|
str(REPO_ROOT / "install.ps1"), "--standalone-copies",
|
|
],
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=60,
|
|
)
|
|
finally:
|
|
httpd.shutdown()
|
|
httpd.server_close()
|
|
return proc, ra_dir
|
|
|
|
def _assert_resolved(self, proc, ra_dir):
|
|
self.assertIn("Found LaunchBox", proc.stdout)
|
|
self.assertIn("Found RetroArch at", proc.stdout)
|
|
self.assertIn(str(ra_dir), proc.stdout)
|
|
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
|
self.assertIn("Done.", proc.stdout)
|
|
|
|
def test_detects_launchbox_retroarch(self):
|
|
proc, ra_dir = self._run(
|
|
" <Emulator>\n"
|
|
" <Title>Retroarch</Title>\n"
|
|
" <ApplicationPath>Emulators\\RetroArch\\retroarch.exe</ApplicationPath>\n"
|
|
" </Emulator>\n"
|
|
)
|
|
self._assert_resolved(proc, ra_dir)
|
|
|
|
def test_stale_entry_before_installed_one(self):
|
|
proc, ra_dir = self._run(
|
|
" <Emulator>\n"
|
|
" <Title>RetroArch (old)</Title>\n"
|
|
" <ApplicationPath>Emulators\\RetroArch-old\\retroarch.exe</ApplicationPath>\n"
|
|
" </Emulator>\n"
|
|
" <Emulator>\n"
|
|
" <Title>RetroArch</Title>\n"
|
|
" <ApplicationPath>Emulators\\RetroArch\\retroarch.exe</ApplicationPath>\n"
|
|
" </Emulator>\n"
|
|
)
|
|
self._assert_resolved(proc, ra_dir)
|
|
|
|
def test_system_directory_from_config(self):
|
|
def seed(root: Path) -> None:
|
|
ra = root / "LaunchBox" / "Emulators" / "RetroArch"
|
|
(ra / "bios").mkdir(parents=True)
|
|
(ra / "retroarch.cfg").write_text('system_directory = ":\\bios"\n')
|
|
|
|
proc, ra_dir = self._run(
|
|
" <Emulator>\n"
|
|
" <Title>RetroArch</Title>\n"
|
|
" <ApplicationPath>Emulators\\RetroArch\\retroarch.exe</ApplicationPath>\n"
|
|
" </Emulator>\n",
|
|
seed=seed,
|
|
)
|
|
self.assertIn("Found LaunchBox", proc.stdout)
|
|
self.assertIn("Found RetroArch at", proc.stdout)
|
|
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
|
self.assertIn(str(ra_dir / "bios"), proc.stdout)
|
|
|
|
def test_standalone_copy_reaches_launchbox_emulator(self):
|
|
def seed(root: Path) -> None:
|
|
system = root / "LaunchBox" / "Emulators" / "RetroArch" / "system"
|
|
system.mkdir(parents=True)
|
|
(system / "ps2-0230a.bin").write_text("rom")
|
|
pcsx2 = root / "LaunchBox" / "Emulators" / "PCSX2"
|
|
(pcsx2 / "bios").mkdir(parents=True)
|
|
(pcsx2 / "portable.ini").write_text("")
|
|
(pcsx2 / "inis").mkdir()
|
|
(pcsx2 / "inis" / "PCSX2.ini").write_text("[Folders]\nBios = bios\n")
|
|
|
|
manifest = {
|
|
"files": [],
|
|
"standalone_copies": [
|
|
{
|
|
"pattern": "ps2-*.bin",
|
|
"emulator": "pcsx2",
|
|
"targets": {"windows": []},
|
|
}
|
|
],
|
|
}
|
|
proc, ra_dir = self._run(
|
|
" <Emulator>\n"
|
|
" <Title>RetroArch</Title>\n"
|
|
" <ApplicationPath>Emulators\\RetroArch\\retroarch.exe</ApplicationPath>\n"
|
|
" </Emulator>\n"
|
|
" <Emulator>\n"
|
|
" <Title>PCSX2</Title>\n"
|
|
" <ApplicationPath>Emulators\\PCSX2\\pcsx2.exe</ApplicationPath>\n"
|
|
" </Emulator>\n",
|
|
seed=seed,
|
|
manifest=manifest,
|
|
)
|
|
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
|
landed = ra_dir.parent / "PCSX2" / "bios" / "ps2-0230a.bin"
|
|
self.assertTrue(landed.exists(), proc.stdout)
|
|
|
|
|
|
class TestForcedOs(unittest.TestCase):
|
|
"""RETROBIOS_OS overrides detection with a known name only."""
|
|
|
|
def test_known_name_wins(self):
|
|
with unittest.mock.patch.dict(os.environ, {"RETROBIOS_OS": "Windows"}):
|
|
self.assertEqual(install.detect_os(), "windows")
|
|
|
|
def test_unknown_name_is_ignored(self):
|
|
with unittest.mock.patch.dict(os.environ, {"RETROBIOS_OS": "amiga"}):
|
|
self.assertIn(install.detect_os(), ("linux", "wsl", "windows", "darwin"))
|
|
|
|
|
|
class TestDetectFrontends(unittest.TestCase):
|
|
"""Frontends have no BIOS directory of their own but hint at the setup."""
|
|
|
|
def test_esde_from_home_directory(self):
|
|
home = Path(_tempdir(self))
|
|
(home / "ES-DE").mkdir()
|
|
old = os.environ.pop("ESDE_APPDATA_DIR", None)
|
|
try:
|
|
self.assertIn("esde", install.detect_frontends("linux", home=home))
|
|
finally:
|
|
if old is not None:
|
|
os.environ["ESDE_APPDATA_DIR"] = old
|
|
|
|
def test_esde_from_env_dir(self):
|
|
home = Path(_tempdir(self))
|
|
appdata = Path(_tempdir(self))
|
|
os.environ["ESDE_APPDATA_DIR"] = str(appdata)
|
|
try:
|
|
self.assertIn("esde", install.detect_frontends("linux", home=home))
|
|
finally:
|
|
del os.environ["ESDE_APPDATA_DIR"]
|
|
|
|
def test_nothing_detected(self):
|
|
home = Path(_tempdir(self))
|
|
old = os.environ.pop("ESDE_APPDATA_DIR", None)
|
|
try:
|
|
self.assertEqual(install.detect_frontends("linux", home=home), [])
|
|
finally:
|
|
if old is not None:
|
|
os.environ["ESDE_APPDATA_DIR"] = old
|
|
|
|
|
|
class TestNormalizePlatform(unittest.TestCase):
|
|
"""Manifest URLs are case sensitive: user input must be normalized."""
|
|
|
|
def test_lowercases_input(self):
|
|
self.assertEqual(install.normalize_platform("Batocera"), "batocera")
|
|
|
|
def test_strips_whitespace(self):
|
|
self.assertEqual(install.normalize_platform(" retroarch "), "retroarch")
|
|
|
|
def test_unknown_platform_exits(self):
|
|
with self.assertRaises(SystemExit):
|
|
install.normalize_platform("launchbox")
|
|
|
|
def test_all_available_platforms_accepted(self):
|
|
for plat in install.AVAILABLE_PLATFORMS:
|
|
self.assertEqual(install.normalize_platform(plat), plat)
|
|
|
|
|
|
class TestAvailablePlatforms(unittest.TestCase):
|
|
"""The platform list must match the generated manifests."""
|
|
|
|
def test_matches_install_manifests(self):
|
|
manifests = {p.stem for p in (REPO_ROOT / "install").glob("*.json")}
|
|
self.assertEqual(set(install.AVAILABLE_PLATFORMS), manifests)
|
|
|
|
def test_every_registered_platform_is_installable(self):
|
|
"""Archived platforms still ship packs, so they still need a manifest."""
|
|
import sys as _sys
|
|
|
|
_sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
|
from common import list_registered_platforms
|
|
|
|
registered = set(
|
|
list_registered_platforms(
|
|
str(REPO_ROOT / "platforms"), include_archived=True
|
|
)
|
|
)
|
|
missing = registered - set(install.AVAILABLE_PLATFORMS)
|
|
self.assertEqual(missing, set(), f"no install manifest for {sorted(missing)}")
|
|
|
|
def test_powershell_wrapper_pins_installer_hash(self):
|
|
content = (REPO_ROOT / "install.ps1").read_text()
|
|
match = re.search(r'\$defaultInstallSha256 = "([0-9a-f]{64})"', content)
|
|
self.assertIsNotNone(match, "install.ps1 must embed the installer SHA256")
|
|
expected = hashlib.sha256((REPO_ROOT / "install.py").read_bytes()).hexdigest()
|
|
self.assertEqual(match.group(1), expected)
|
|
|
|
def test_powershell_wrapper_rejects_non_https_bootstrap(self):
|
|
content = (REPO_ROOT / "install.ps1").read_text()
|
|
self.assertIn('$uri.Scheme -ne "https"', content)
|
|
|
|
def test_shell_wrapper_pins_installer_hash(self):
|
|
content = (REPO_ROOT / "install.sh").read_text()
|
|
match = re.search(r'DEFAULT_INSTALL_SHA256="([0-9a-f]{64})"', content)
|
|
self.assertIsNotNone(match, "install.sh must embed the installer SHA256")
|
|
expected = hashlib.sha256((REPO_ROOT / "install.py").read_bytes()).hexdigest()
|
|
self.assertEqual(match.group(1), expected)
|
|
|
|
def test_shell_one_liner_downloads_verifies_and_forwards_arguments(self):
|
|
scratch = REPO_ROOT / "tmp" / "tests"
|
|
scratch.mkdir(parents=True, exist_ok=True)
|
|
with tempfile.TemporaryDirectory(dir=scratch) as directory:
|
|
root = Path(directory)
|
|
# A piped script must not trust a same-named file in the caller's
|
|
# working directory. Only a real local install.sh may use its peer.
|
|
(root / "install.py").write_text(
|
|
"raise SystemExit('untrusted cwd installer ran')\n",
|
|
encoding="utf-8",
|
|
)
|
|
fake_bin = root / "bin"
|
|
fake_bin.mkdir()
|
|
marker = root / "curl-called"
|
|
fake_curl = fake_bin / "curl"
|
|
fake_curl.write_text(
|
|
"#!/bin/sh\n"
|
|
"output=\n"
|
|
"while [ \"$#\" -gt 0 ]; do\n"
|
|
" if [ \"$1\" = --output ]; then output=$2; shift 2; else shift; fi\n"
|
|
"done\n"
|
|
"cp -- \"$FAKE_INSTALLER_SOURCE\" \"$output\"\n"
|
|
": > \"$FAKE_CURL_MARKER\"\n",
|
|
encoding="utf-8",
|
|
)
|
|
fake_curl.chmod(0o755)
|
|
|
|
env = os.environ.copy()
|
|
env.update(
|
|
PATH=f"{fake_bin}{os.pathsep}{env['PATH']}",
|
|
TMPDIR=str(root),
|
|
RETROBIOS_INSTALL_URL="https://example.invalid/install.py",
|
|
RETROBIOS_INSTALL_SHA256=hashlib.sha256(
|
|
(REPO_ROOT / "install.py").read_bytes()
|
|
).hexdigest(),
|
|
FAKE_INSTALLER_SOURCE=str(REPO_ROOT / "install.py"),
|
|
FAKE_CURL_MARKER=str(marker),
|
|
)
|
|
proc = subprocess.run(
|
|
["sh", "-s", "--", "--list-platforms"],
|
|
cwd=root,
|
|
env=env,
|
|
input=(REPO_ROOT / "install.sh").read_text(encoding="utf-8"),
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
|
self.assertTrue(marker.is_file(), "piped bootstrap did not download install.py")
|
|
self.assertIn("retroarch", proc.stdout)
|
|
|
|
def test_busybox_wget_downloads_the_installer(self):
|
|
"""BusyBox wget, the only one in the RomM image, refuses
|
|
--https-only: with no curl the one-liner always failed and blamed
|
|
the network."""
|
|
scratch = REPO_ROOT / "tmp" / "tests"
|
|
scratch.mkdir(parents=True, exist_ok=True)
|
|
with tempfile.TemporaryDirectory(dir=scratch) as directory:
|
|
root = Path(directory)
|
|
fake_bin = root / "bin"
|
|
fake_bin.mkdir()
|
|
for tool in ("mktemp", "wc", "tr", "sha256sum", "awk", "rm", "rmdir",
|
|
"grep", "tail", "cp", "dirname", "cat", "python3"):
|
|
found = shutil.which(tool)
|
|
if found:
|
|
(fake_bin / tool).symlink_to(found)
|
|
(fake_bin / "wget").write_text(
|
|
"#!/bin/sh\n"
|
|
"output=\n"
|
|
"for arg in \"$@\"; do\n"
|
|
" case \"$arg\" in\n"
|
|
" --help) echo 'Usage: wget [-cqS] [-O FILE] [-T SEC] URL' >&2; exit 1 ;;\n"
|
|
" --https-only) echo \"wget: unrecognized option '$arg'\" >&2; exit 1 ;;\n"
|
|
" --output-document=*) output=${arg#--output-document=} ;;\n"
|
|
" esac\n"
|
|
"done\n"
|
|
"cp -- \"$FAKE_INSTALLER_SOURCE\" \"$output\"\n",
|
|
encoding="utf-8",
|
|
)
|
|
(fake_bin / "wget").chmod(0o755)
|
|
env = {
|
|
"PATH": str(fake_bin), "HOME": str(root), "TMPDIR": str(root),
|
|
"RETROBIOS_INSTALL_URL": "https://example.invalid/install.py",
|
|
"FAKE_INSTALLER_SOURCE": str(REPO_ROOT / "install.py"),
|
|
}
|
|
proc = subprocess.run(
|
|
["/bin/sh", "-s", "--", "--list-platforms"], cwd=root, env=env,
|
|
input=(REPO_ROOT / "install.sh").read_text(encoding="utf-8"),
|
|
capture_output=True, text=True, timeout=60, check=False,
|
|
)
|
|
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
|
self.assertIn("retroarch", proc.stdout)
|
|
|
|
def test_a_failed_download_says_so_in_one_message(self):
|
|
"""Offline, the one-liner printed curl's progress table and
|
|
"curl: (6) ...", then exited 6, a code the table does not list."""
|
|
scratch = REPO_ROOT / "tmp" / "tests"
|
|
scratch.mkdir(parents=True, exist_ok=True)
|
|
with tempfile.TemporaryDirectory(dir=scratch) as directory:
|
|
root = Path(directory)
|
|
fake_bin = root / "bin"
|
|
fake_bin.mkdir()
|
|
(fake_bin / "curl").write_text(
|
|
"#!/bin/sh\necho 'curl: (6) Could not resolve host: x' >&2\nexit 6\n",
|
|
encoding="utf-8",
|
|
)
|
|
(fake_bin / "curl").chmod(0o755)
|
|
env = os.environ.copy()
|
|
env.update(
|
|
PATH=f"{fake_bin}{os.pathsep}{env['PATH']}",
|
|
TMPDIR=str(root),
|
|
RETROBIOS_INSTALL_URL="https://example.invalid/install.py",
|
|
)
|
|
proc = subprocess.run(
|
|
["sh", "-s"], cwd=root, env=env,
|
|
input=(REPO_ROOT / "install.sh").read_text(encoding="utf-8"),
|
|
capture_output=True, text=True, timeout=30, check=False,
|
|
)
|
|
self.assertEqual(proc.returncode, 1, proc.stderr)
|
|
self.assertIn("could not be downloaded", proc.stderr)
|
|
self.assertIn("Nothing was run and nothing was written", proc.stderr)
|
|
self.assertEqual(proc.stderr.count("curl: (6)"), 1)
|
|
|
|
def test_powershell_turns_a_failure_into_one_line(self):
|
|
source = (REPO_ROOT / "install.ps1").read_text(encoding="utf-8")
|
|
self.assertIn('[Console]::Error.WriteLine("Error: "', source)
|
|
self.assertIn("winget install Python", source)
|
|
|
|
@unittest.skipUnless(shutil.which("script"), "script(1) provides the terminal")
|
|
def test_piped_one_liner_hands_the_terminal_to_the_installer(self):
|
|
"""curl | sh gives the installer the script as stdin, so isatty()
|
|
was false and every question it asks was skipped on Linux and Mac."""
|
|
scratch = REPO_ROOT / "tmp" / "tests"
|
|
scratch.mkdir(parents=True, exist_ok=True)
|
|
with tempfile.TemporaryDirectory(dir=scratch) as directory:
|
|
root = Path(directory)
|
|
probe = root / "probe.py"
|
|
probe.write_text(
|
|
"import sys\nprint('installer stdin is a terminal:', sys.stdin.isatty())\n",
|
|
encoding="utf-8",
|
|
)
|
|
fake_bin = root / "bin"
|
|
fake_bin.mkdir()
|
|
(fake_bin / "curl").write_text(
|
|
"#!/bin/sh\n"
|
|
"output=\n"
|
|
"while [ \"$#\" -gt 0 ]; do\n"
|
|
" if [ \"$1\" = --output ]; then output=$2; shift 2; else shift; fi\n"
|
|
"done\n"
|
|
f"cp -- {shlex.quote(str(probe))} \"$output\"\n",
|
|
encoding="utf-8",
|
|
)
|
|
(fake_bin / "curl").chmod(0o755)
|
|
env = os.environ.copy()
|
|
env.update(
|
|
PATH=f"{fake_bin}{os.pathsep}{env['PATH']}",
|
|
TMPDIR=str(root),
|
|
RETROBIOS_INSTALL_URL="https://example.invalid/install.py",
|
|
RETROBIOS_INSTALL_SHA256=hashlib.sha256(probe.read_bytes()).hexdigest(),
|
|
)
|
|
piped = f"sh -s < {shlex.quote(str(REPO_ROOT / 'install.sh'))}"
|
|
proc = subprocess.run(
|
|
["script", "-qec", piped, "/dev/null"],
|
|
cwd=root, env=env, capture_output=True, text=True, timeout=30,
|
|
check=False,
|
|
)
|
|
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
|
self.assertIn("installer stdin is a terminal: True", proc.stdout)
|
|
|
|
|
|
class FileListsAreRetried(unittest.TestCase):
|
|
"""The file list was fetched once with a 30 s timeout: one timeout on
|
|
flaky Wi-Fi ended the run, while each file got three attempts."""
|
|
|
|
def _fetch(self, outcomes):
|
|
calls = []
|
|
|
|
def urlopen(url, timeout=None):
|
|
calls.append(url)
|
|
outcome = outcomes.pop(0)
|
|
if isinstance(outcome, Exception):
|
|
raise outcome
|
|
return contextlib.nullcontext(io.BytesIO(outcome))
|
|
|
|
with unittest.mock.patch.object(install.urllib.request, "urlopen", urlopen), \
|
|
unittest.mock.patch.object(install.time, "sleep"), \
|
|
unittest.mock.patch.object(install, "_read_limited_json",
|
|
lambda resp, limit, label: json.loads(resp.read())):
|
|
try:
|
|
return install._fetch_json("https://x/list.json", 1000, "list"), calls
|
|
except OSError as exc:
|
|
return exc, calls
|
|
|
|
def test_a_timeout_then_an_answer_succeeds(self):
|
|
import urllib.error
|
|
|
|
result, calls = self._fetch([urllib.error.URLError("timed out"), b'{"a": 1}'])
|
|
self.assertEqual(result, {"a": 1})
|
|
self.assertEqual(len(calls), 2)
|
|
|
|
def test_a_404_is_not_retried(self):
|
|
import urllib.error
|
|
|
|
missing = urllib.error.HTTPError("https://x", 404, "Not Found", None, None)
|
|
result, calls = self._fetch([missing])
|
|
self.assertIs(result, missing)
|
|
self.assertEqual(len(calls), 1)
|
|
|
|
def test_three_failures_end_with_advice(self):
|
|
import urllib.error
|
|
|
|
failures = [urllib.error.URLError("timed out")] * install.MAX_RETRIES
|
|
err = io.StringIO()
|
|
with unittest.mock.patch.object(install.urllib.request, "urlopen",
|
|
side_effect=failures), \
|
|
unittest.mock.patch.object(install.time, "sleep"), \
|
|
contextlib.redirect_stderr(err):
|
|
with self.assertRaises(SystemExit) as caught:
|
|
install.fetch_manifest("retroarch")
|
|
self.assertEqual(caught.exception.code, 1)
|
|
self.assertIn("HTTPS_PROXY", err.getvalue())
|
|
self.assertIn(f"after {install.MAX_RETRIES} attempts", err.getvalue())
|
|
|
|
|
|
class TargetListFailureIsNotAnAnswer(unittest.TestCase):
|
|
"""A timeout is not 'no targets': it sent users to the full pack."""
|
|
|
|
def test_unreachable_list_is_none(self):
|
|
import urllib.error
|
|
|
|
def boom(*args, **kwargs):
|
|
raise urllib.error.URLError("timed out")
|
|
|
|
with unittest.mock.patch.object(install.urllib.request, "urlopen", boom), \
|
|
contextlib.redirect_stderr(io.StringIO()):
|
|
self.assertIsNone(install.fetch_targets("retroarch"))
|
|
|
|
def test_absent_list_is_empty(self):
|
|
import urllib.error
|
|
|
|
def missing(url, *args, **kwargs):
|
|
raise urllib.error.HTTPError(url, 404, "Not Found", {}, None)
|
|
|
|
with unittest.mock.patch.object(install.urllib.request, "urlopen", missing):
|
|
self.assertEqual(install.fetch_targets("retroarch"), {})
|
|
|
|
|
|
class RetroArchWhereItReallyLives(unittest.TestCase):
|
|
"""Windows: retroarch.cfg sits beside retroarch.exe and system defaults
|
|
to <exe folder>\\system. Only %APPDATA% was read, and the fallback was a
|
|
folder RetroArch never reads. macOS: the config is under
|
|
Application Support/RetroArch/config, the system folder in Documents."""
|
|
|
|
def _exe_dir(self) -> tuple[Path, dict]:
|
|
program_files = Path(_tempdir(self))
|
|
exe_dir = program_files / "RetroArch"
|
|
exe_dir.mkdir()
|
|
(exe_dir / "retroarch.exe").write_bytes(b"MZ")
|
|
return exe_dir, {"ProgramFiles": str(program_files)}
|
|
|
|
def test_windows_install_folder_without_a_config(self):
|
|
exe_dir, env = self._exe_dir()
|
|
with unittest.mock.patch.dict(os.environ, env, clear=True):
|
|
found = install.detect_platforms("windows")
|
|
self.assertIn(("retroarch", exe_dir / "system"), found)
|
|
|
|
def test_windows_appdata_config_at_default_names_the_exe_folder(self):
|
|
exe_dir, env = self._exe_dir()
|
|
appdata = Path(_tempdir(self))
|
|
(appdata / "RetroArch").mkdir()
|
|
(appdata / "RetroArch" / "retroarch.cfg").write_text('system_directory = "default"\n')
|
|
env["APPDATA"] = str(appdata)
|
|
with unittest.mock.patch.dict(os.environ, env, clear=True):
|
|
found = install.detect_platforms("windows")
|
|
self.assertIn(("retroarch", exe_dir / "system"), found)
|
|
|
|
def test_windows_config_beside_the_exe_wins(self):
|
|
exe_dir, env = self._exe_dir()
|
|
(exe_dir / "retroarch.cfg").write_text('system_directory = "D:\\bios"\n')
|
|
with unittest.mock.patch.dict(os.environ, env, clear=True):
|
|
found = install.detect_platforms("windows")
|
|
self.assertIn(("retroarch", Path("D:\\bios")), found)
|
|
|
|
def test_macos_config_folder_and_documents_default(self):
|
|
home = Path(_tempdir(self))
|
|
support = home / "Library" / "Application Support" / "RetroArch"
|
|
(support / "config").mkdir(parents=True)
|
|
(support / "config" / "retroarch.cfg").write_text('system_directory = "default"\n')
|
|
with unittest.mock.patch.object(install.Path, "home", return_value=home):
|
|
found = install.detect_platforms("darwin")
|
|
self.assertIn(("retroarch", home / "Documents" / "RetroArch" / "system"), found)
|
|
(support / "portable.txt").write_text("")
|
|
found = install.detect_platforms("darwin")
|
|
self.assertIn(("retroarch", support / "system"), found)
|
|
|
|
|
|
class NoPythonNamesThisSystemsCommand(unittest.TestCase):
|
|
"""Without Python the bootstrap said `sudo apt install python3` on every
|
|
system: Termux has no sudo and installs with `pkg install python`, and
|
|
macOS has no apt."""
|
|
|
|
def _run(self, extra_env: dict, uname: str = "Linux") -> subprocess.CompletedProcess:
|
|
root = Path(_tempdir(self))
|
|
fake_bin = root / "bin"
|
|
fake_bin.mkdir()
|
|
for tool in ("sed", "tr", "dirname", "cat"):
|
|
found = shutil.which(tool)
|
|
if found:
|
|
(fake_bin / tool).symlink_to(found)
|
|
(fake_bin / "uname").write_text(f"#!/bin/sh\necho {uname}\n", encoding="utf-8")
|
|
(fake_bin / "uname").chmod(0o755)
|
|
# No terminal: the Termux offer is a question asked on /dev/tty.
|
|
return subprocess.run(
|
|
["/bin/sh", str(REPO_ROOT / "install.sh")], cwd=root,
|
|
env={"PATH": str(fake_bin), "HOME": str(root), **extra_env},
|
|
capture_output=True, text=True, timeout=30, check=False,
|
|
start_new_session=True,
|
|
)
|
|
|
|
def test_termux_is_told_pkg(self):
|
|
proc = self._run({"TERMUX_VERSION": "0.118",
|
|
"PREFIX": "/data/data/com.termux/files/usr"})
|
|
self.assertEqual(proc.returncode, 1)
|
|
self.assertIn("Install it with: pkg install python", proc.stderr)
|
|
self.assertNotIn("sudo", proc.stderr)
|
|
|
|
def test_macos_is_told_xcode_select(self):
|
|
proc = self._run({}, uname="Darwin")
|
|
self.assertEqual(proc.returncode, 1)
|
|
self.assertIn("Install it with: xcode-select --install", proc.stderr)
|
|
self.assertNotIn("apt", proc.stderr)
|
|
|
|
|
|
@unittest.skipUnless(shutil.which("pwsh") and shutil.which("script"), "needs PowerShell and a terminal")
|
|
class WindowsWithoutPythonIsOfferedWinget(unittest.TestCase):
|
|
"""A Windows machine without Python was told to install it, open a new
|
|
window and run the command again, while winget, present on Windows 10
|
|
and 11, could do it from the bootstrap."""
|
|
|
|
def _folder(self) -> tuple[Path, Path]:
|
|
folder = Path(_tempdir(self))
|
|
shutil.copy(REPO_ROOT / "install.ps1", folder / "install.ps1")
|
|
(folder / "install.py").write_text("print('installer ran')\n")
|
|
fake_bin = folder / "bin"
|
|
fake_bin.mkdir()
|
|
python = shutil.which("python3") or sys.executable
|
|
(fake_bin / "winget").write_text(
|
|
f"#!/bin/sh\necho \"winget $*\"\n{shutil.which('ln')} -s {shlex.quote(python)} "
|
|
f"{shlex.quote(str(fake_bin / 'python3'))}\n"
|
|
)
|
|
(fake_bin / "winget").chmod(0o755)
|
|
return folder, fake_bin
|
|
|
|
def _run(self, folder: Path, fake_bin: Path, answer: str) -> subprocess.CompletedProcess:
|
|
pwsh = shutil.which("pwsh")
|
|
command = (f"{shlex.quote(pwsh)} -NoProfile -File "
|
|
f"{shlex.quote(str(folder / 'install.ps1'))}")
|
|
return subprocess.run(
|
|
[shutil.which("script"), "-qec", command, "/dev/null"], input=answer,
|
|
env={**os.environ, "PATH": f"{fake_bin}{os.pathsep}/usr/bin/nonexistent"},
|
|
capture_output=True, text=True, timeout=120, check=False,
|
|
)
|
|
|
|
def test_yes_installs_and_carries_on(self):
|
|
folder, fake_bin = self._folder()
|
|
proc = self._run(folder, fake_bin, "y\n")
|
|
self.assertIn("Install Python 3.12 now with winget?", proc.stdout)
|
|
self.assertIn("winget install --id Python.Python.3.12", proc.stdout)
|
|
self.assertIn("installer ran", proc.stdout)
|
|
self.assertEqual(proc.returncode, 0, proc.stdout)
|
|
|
|
def test_no_names_the_command(self):
|
|
folder, fake_bin = self._folder()
|
|
proc = self._run(folder, fake_bin, "n\n")
|
|
self.assertNotIn("installer ran", proc.stdout)
|
|
self.assertIn("winget install Python.Python.3.12", proc.stdout)
|
|
self.assertFalse((fake_bin / "python3").exists())
|
|
|
|
|
|
class ThePowerShellWrapperPassesTheStatusOn(unittest.TestCase):
|
|
"""Every non-zero exit became a red "installer failed" exception and
|
|
code 1: a --check that found work read as a crash, 2 and 130 were lost."""
|
|
|
|
@unittest.skipUnless(shutil.which("pwsh"), "needs PowerShell")
|
|
def test_the_installer_code_comes_back_unchanged(self):
|
|
folder = Path(_tempdir(self))
|
|
shutil.copy(REPO_ROOT / "install.ps1", folder / "install.ps1")
|
|
for code in (0, 1, 2, 130):
|
|
with self.subTest(code=code):
|
|
(folder / "install.py").write_text(f"import sys\nsys.exit({code})\n")
|
|
proc = subprocess.run(
|
|
["pwsh", "-NoProfile", "-File", str(folder / "install.ps1")],
|
|
capture_output=True, text=True, timeout=120,
|
|
)
|
|
self.assertEqual(proc.returncode, code, proc.stderr)
|
|
self.assertNotIn("installer failed", proc.stdout + proc.stderr)
|
|
|
|
|
|
class ThePowerShellWrapperPassesEveryArgument(unittest.TestCase):
|
|
"""A [Parameter()] attribute made install.ps1 an advanced script, whose
|
|
common parameters took `-v` (read as `-Verbose`), `-vb`, `-d` and `-ea`:
|
|
the documented `-v` never reached install.py."""
|
|
|
|
@unittest.skipUnless(shutil.which("pwsh"), "needs PowerShell")
|
|
def test_short_flags_reach_the_installer(self):
|
|
folder = Path(_tempdir(self))
|
|
shutil.copy(REPO_ROOT / "install.ps1", folder / "install.ps1")
|
|
(folder / "install.py").write_text(
|
|
"import json, sys\nprint(json.dumps(sys.argv[1:]))\n"
|
|
)
|
|
for args in (["-v", "--check"], ["-vb"], ["-Verbose"], ["-d", "-ea", "x"],
|
|
["--platform", "a b"], []):
|
|
with self.subTest(args=args):
|
|
proc = subprocess.run(
|
|
["pwsh", "-NoProfile", "-File", str(folder / "install.ps1"), *args],
|
|
capture_output=True, text=True, timeout=120,
|
|
)
|
|
self.assertEqual(proc.returncode, 0, proc.stderr)
|
|
self.assertEqual(json.loads(proc.stdout), args)
|
|
|
|
|
|
@unittest.skipIf(os.name == "nt" or os.geteuid() == 0, "needs a folder this user cannot write")
|
|
class AnUnwritableFolderIsCodeTwo(unittest.TestCase):
|
|
"""A BIOS folder that could not be created ended with 1, while the same
|
|
folder existing read-only ended with the documented 2."""
|
|
|
|
def test_both_shapes_exit_two(self):
|
|
handler = functools.partial(
|
|
http.server.SimpleHTTPRequestHandler, directory=str(REPO_ROOT)
|
|
)
|
|
handler.log_message = lambda *args: None
|
|
httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
|
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
|
self.addCleanup(httpd.server_close)
|
|
self.addCleanup(httpd.shutdown)
|
|
(REPO_ROOT / "tmp").mkdir(exist_ok=True)
|
|
home = Path(tempfile.mkdtemp(dir=str(REPO_ROOT / "tmp")))
|
|
locked = home / "locked"
|
|
locked.mkdir()
|
|
locked.chmod(0o555)
|
|
self.addCleanup(shutil.rmtree, home, True)
|
|
self.addCleanup(locked.chmod, 0o755)
|
|
env = {
|
|
**os.environ,
|
|
"HOME": str(home),
|
|
"RETROBIOS_OS": "linux",
|
|
"RETROBIOS_BASE_URL": f"http://127.0.0.1:{httpd.server_address[1]}",
|
|
}
|
|
for dest in (locked / "sub", locked):
|
|
with self.subTest(dest=dest.name):
|
|
proc = subprocess.run(
|
|
[sys.executable, "install.py", "--platform", "retroarch",
|
|
"--dest", str(dest), "--system", "atari-lynx", "--no-input"],
|
|
cwd=str(REPO_ROOT), env=env, capture_output=True, text=True,
|
|
timeout=300, check=False,
|
|
)
|
|
self.assertEqual(proc.returncode, 2, proc.stdout + proc.stderr)
|
|
|
|
|
|
|
|
class SudoAdviceNamesTheFolder(unittest.TestCase):
|
|
""""Run the same command with sudo" re-ran detection under root's HOME,
|
|
found nothing, installed under /root and reported success."""
|
|
|
|
def test_the_advice_carries_platform_and_folder(self):
|
|
advice = install._write_advice(Path("/media/card/Emulation/bios"), False, "emudeck")
|
|
self.assertIn("--platform emudeck", advice)
|
|
self.assertIn("--dest /media/card/Emulation/bios", advice)
|
|
|
|
def test_the_closing_report_passes_the_platform(self):
|
|
source = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
|
|
self.assertIn("platform_of={path: plat for plat, path in platforms}", source)
|
|
|
|
@unittest.skipUnless(hasattr(os, "statvfs"), "statvfs reads mount flags")
|
|
def test_a_read_only_mount_is_not_sent_to_sudo(self):
|
|
"""A card Linux mounted read-only after a dirty unplug was answered
|
|
with 'run it again with sudo', which fails the same way."""
|
|
real = os.statvfs(".")
|
|
flags = SimpleNamespace(f_flag=real.f_flag | os.ST_RDONLY)
|
|
for plat in ("retroarch", ""):
|
|
with self.subTest(platform=plat), \
|
|
unittest.mock.patch.object(install.os, "statvfs", return_value=flags):
|
|
advice = install._write_advice(Path("/media/card/bios"), False, plat)
|
|
self.assertIn("mounted read-only", advice)
|
|
self.assertNotIn("Run it again with sudo", advice)
|
|
self.assertNotIn("sudo python3", advice)
|
|
writable = SimpleNamespace(f_flag=real.f_flag & ~os.ST_RDONLY)
|
|
with unittest.mock.patch.object(install.os, "statvfs", return_value=writable):
|
|
self.assertIn("sudo", install._write_advice(Path("/media/card/bios"), False, "retroarch"))
|
|
|
|
|
|
|
|
class CaseVariants(unittest.TestCase):
|
|
"""The RetroArch list is folded for Windows and macOS: a Linux user got
|
|
BIOS.col and np2kai/2608_BD.WAV only, while Mesen-CE opens bios.col and
|
|
np2kai np2kai/2608_bd.wav. The manifest carries those as case_variants,
|
|
installed where the directory keeps case."""
|
|
|
|
def _manifest(self, variants):
|
|
entry = {
|
|
"dest": "BIOS.col", "sha1": "a" * 40, "sha256": "b" * 64,
|
|
"size": 8192, "repo_path": "bios/Coleco/BIOS.col", "cores": None,
|
|
}
|
|
return {
|
|
"manifest_version": 2, "platform": "retroarch", "files": [entry],
|
|
"total_files": 1, "total_size": 8192, "case_variants": variants,
|
|
}
|
|
|
|
def test_a_kept_case_directory_is_detected(self):
|
|
directory = Path(_tempdir(self)) / "not" / "yet"
|
|
self.assertTrue(install.keeps_case(directory))
|
|
self.assertEqual(list(Path(directory.parents[1]).iterdir()), [])
|
|
|
|
def test_variants_cross_the_trust_boundary(self):
|
|
variant = {
|
|
"dest": "bios.col", "sha1": "a" * 40, "sha256": "b" * 64,
|
|
"size": 8192, "repo_path": "bios/Coleco/BIOS.col", "cores": ["mesence"],
|
|
}
|
|
install._validate_manifest(self._manifest([variant]), "retroarch")
|
|
for bad in ({**variant, "dest": "../x"}, {**variant, "dest": "BIOS.col"}):
|
|
with self.subTest(dest=bad["dest"]), self.assertRaises(ValueError):
|
|
install._validate_manifest(self._manifest([bad]), "retroarch")
|
|
|
|
def test_main_adds_them_only_where_case_is_kept(self):
|
|
source = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
|
|
compact = " ".join(source.split())
|
|
self.assertIn(
|
|
'variants = manifest.get("case_variants", []) '
|
|
"case_kept = keeps_case(bios_path) if variants and case_kept:", compact,
|
|
)
|
|
|
|
def test_the_published_retroarch_list_carries_them(self):
|
|
manifest = json.loads((REPO_ROOT / "install" / "retroarch.json").read_text())
|
|
variants = {e["dest"] for e in manifest.get("case_variants", [])}
|
|
self.assertIn("bios.col", variants)
|
|
lakka = json.loads((REPO_ROOT / "install" / "lakka.json").read_text())
|
|
folded = {e["dest"] for e in manifest["files"]}
|
|
# A case-keeping RetroArch install is the Lakka list, less what
|
|
# Lakka's own core packages install (installed_on: lakka).
|
|
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
|
from common import load_emulator_profiles
|
|
|
|
installed = {
|
|
entry.get("path") or entry.get("name")
|
|
for profile in load_emulator_profiles(str(REPO_ROOT / "emulators")).values()
|
|
for entry in profile.get("files") or []
|
|
if isinstance(entry, dict) and "lakka" in (entry.get("installed_on") or [])
|
|
}
|
|
lakka_dests = {e["dest"] for e in lakka["files"]}
|
|
self.assertEqual(lakka_dests - (folded | variants), set())
|
|
self.assertEqual((folded | variants) - lakka_dests - installed, set(),
|
|
"a case-keeping RetroArch install equals the Lakka list")
|
|
|
|
|
|
|
|
class AnUnreadableRetroDeckConfigIsSaid(unittest.TestCase):
|
|
"""retrodeck.json present but unreadable: the installer wrote to
|
|
~/retrodeck without a word, where a home moved to an SD card is not."""
|
|
|
|
def test_the_fallback_is_announced(self):
|
|
import contextlib
|
|
|
|
home = Path(_tempdir(self))
|
|
conf = home / ".var" / "app" / "net.retrodeck.retrodeck" / "config" / "retrodeck"
|
|
conf.mkdir(parents=True)
|
|
(conf / "retrodeck.json").write_text("{truncated")
|
|
err = io.StringIO()
|
|
with unittest.mock.patch.object(install.Path, "home", return_value=home), \
|
|
unittest.mock.patch.object(install, "_detect_embedded", return_value=[]), \
|
|
contextlib.redirect_stderr(err):
|
|
found = install.detect_platforms("linux")
|
|
self.assertIn(("retrodeck", home / "retrodeck"), found)
|
|
self.assertIn("could not read paths.rd_home_path", err.getvalue())
|
|
|
|
|
|
|
|
class RetroDeckFoldersMovedOneByOne(unittest.TestCase):
|
|
"""RetroDECK moves its bios folder on its own and records the new place
|
|
in retrodeck.json paths.bios_path, but only rd_home_path was read: the
|
|
files went to <home>/bios, a folder RetroDECK no longer reads."""
|
|
|
|
def _detect(self, paths: dict) -> tuple[Path, list]:
|
|
home = Path(_tempdir(self))
|
|
conf = home / ".var/app/net.retrodeck.retrodeck/config/retrodeck"
|
|
conf.mkdir(parents=True)
|
|
(conf / "retrodeck.json").write_text(json.dumps({"paths": paths}))
|
|
with unittest.mock.patch.object(install.Path, "home", return_value=home), \
|
|
unittest.mock.patch.object(install, "_detect_embedded", return_value=[]), \
|
|
unittest.mock.patch.dict(install._MOVED_FOLDERS, clear=True), \
|
|
contextlib.redirect_stderr(io.StringIO()):
|
|
found = install.detect_platforms("linux")
|
|
dests = {name: install._destination_path(home / "retrodeck", f"{name}/a.bin")
|
|
for name in ("bios", "roms", "saves")}
|
|
label = install.found_label("retrodeck", home / "retrodeck")
|
|
return home, [found, dests, label]
|
|
|
|
def test_a_moved_bios_folder_is_followed(self):
|
|
home = Path(_tempdir(self))
|
|
sd = home / "sd"
|
|
root, (found, dests, label) = self._detect({
|
|
"rd_home_path": "", "bios_path": str(sd / "bios"), "roms_path": str(sd / "roms"),
|
|
})
|
|
rd_home = root / "retrodeck"
|
|
self.assertIn(("retrodeck", rd_home), found)
|
|
self.assertEqual(dests["bios"], (sd / "bios").resolve() / "a.bin")
|
|
self.assertEqual(dests["roms"], (sd / "roms").resolve() / "a.bin")
|
|
self.assertEqual(dests["saves"], rd_home.resolve() / "saves" / "a.bin")
|
|
self.assertIn(f"bios/ at {sd / 'bios'}", label)
|
|
|
|
def test_folders_in_place_change_nothing(self):
|
|
home = Path(_tempdir(self))
|
|
rd_home = home / "rd"
|
|
_root, (found, dests, label) = self._detect({
|
|
"rd_home_path": str(rd_home), "bios_path": str(rd_home / "bios"),
|
|
})
|
|
self.assertIn(("retrodeck", rd_home), found)
|
|
self.assertNotIn("bios/ at", label)
|
|
|
|
|
|
class AndroidCitationsNameTheRelease(unittest.TestCase):
|
|
"""The Android detection cited platform_unix.c lines of a RetroArch
|
|
revision nobody named, which no longer held the code they described."""
|
|
|
|
def test_the_lines_are_those_of_v1_22_2(self):
|
|
page = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8")
|
|
for text in (install._detect_android.__doc__, page):
|
|
self.assertIn("v1.22.2", text)
|
|
self.assertIn("platform_unix.c:1670-1697", text.replace("\n ", ""))
|
|
self.assertNotIn("1327-1395", text)
|
|
self.assertNotIn("2836-2851", text)
|
|
|
|
|
|
class AndroidRetroArchFolderFollowsItsBuild(unittest.TestCase):
|
|
"""The Google Play build of RetroArch builds its defaults under
|
|
Android/data/<package>/files/RetroArch, the retroarch.com build under
|
|
the shared storage root (RetroArch v1.22.2, MainMenuActivity.java:
|
|
182-183), and both publish com.retroarch and com.retroarch.aarch64.
|
|
Detection sent every build to <shared storage>/RetroArch/system."""
|
|
|
|
PLAY_CODE = 1597175870
|
|
SITE_CODE = 1745000000
|
|
|
|
def _run(self, make):
|
|
root = Path(_tempdir(self))
|
|
app_data, storage = root / "data", root / "storage"
|
|
app_data.mkdir()
|
|
storage.mkdir()
|
|
make(app_data, storage)
|
|
try:
|
|
with unittest.mock.patch.object(install, "ANDROID_APP_DATA", app_data), \
|
|
unittest.mock.patch.dict(os.environ, {"EXTERNAL_STORAGE": str(storage)}):
|
|
return storage, install._detect_android(), install.android_unreachable_folders()
|
|
finally:
|
|
for folder in root.rglob("*"):
|
|
if folder.is_dir():
|
|
folder.chmod(0o755)
|
|
|
|
@staticmethod
|
|
def _install(app_data, storage, package, cfg=None, visible=True):
|
|
(app_data / package).mkdir()
|
|
if visible:
|
|
files = storage / "Android" / "data" / package / "files"
|
|
files.mkdir(parents=True)
|
|
if cfg is not None:
|
|
(files / "retroarch.cfg").write_text(cfg)
|
|
|
|
@staticmethod
|
|
def _cfg(code, system="default"):
|
|
return (f'system_directory = "{system}"\n'
|
|
f'bundle_assets_extract_version_current = "{code}"\n')
|
|
|
|
def test_google_play_build_reads_its_own_files(self):
|
|
storage, found, lines = self._run(lambda data, storage: self._install(
|
|
data, storage, "com.retroarch", self._cfg(self.PLAY_CODE)))
|
|
folder = storage / "Android/data/com.retroarch/files/RetroArch/system"
|
|
self.assertEqual(found, [("retroarch", folder)])
|
|
self.assertEqual(lines, [])
|
|
|
|
def test_retroarch_com_build_reads_shared_storage(self):
|
|
storage, found, lines = self._run(lambda data, storage: self._install(
|
|
data, storage, "com.retroarch.aarch64", self._cfg(self.SITE_CODE)))
|
|
self.assertEqual(found, [("retroarch", storage / "RetroArch/system")])
|
|
self.assertEqual(lines, [])
|
|
|
|
def test_ra32_has_no_google_play_build(self):
|
|
storage, found, _lines = self._run(lambda data, storage: self._install(
|
|
data, storage, "com.retroarch.ra32", 'system_directory = "default"\n'))
|
|
self.assertEqual(found, [("retroarch", storage / "RetroArch/system")])
|
|
|
|
def test_play_plus_folder_seen_without_a_config(self):
|
|
def make(data, storage):
|
|
self._install(data, storage, "com.retroarch.aarch64")
|
|
(storage / "Android/data/com.retroarch.aarch64/files/RetroArch").mkdir()
|
|
storage, found, _lines = self._run(make)
|
|
self.assertEqual(found, [(
|
|
"retroarch",
|
|
storage / "Android/data/com.retroarch.aarch64/files/RetroArch/system")])
|
|
|
|
def test_play_folder_out_of_reach_is_named(self):
|
|
if os.geteuid() == 0:
|
|
self.skipTest("root writes any folder")
|
|
|
|
def make(data, storage):
|
|
self._install(data, storage, "com.retroarch", self._cfg(self.PLAY_CODE))
|
|
(storage / "Android/data/com.retroarch/files").chmod(0o555)
|
|
storage, found, lines = self._run(make)
|
|
self.assertEqual(found, [])
|
|
self.assertEqual(len(lines), 1)
|
|
self.assertIn("RetroArch (Google Play build, com.retroarch) is installed", lines[0])
|
|
self.assertIn(str(storage / "Android/data/com.retroarch/files/RetroArch/system"),
|
|
lines[0])
|
|
|
|
def test_hidden_private_data_and_shared_folder_mean_retroarch_com(self):
|
|
def make(data, storage):
|
|
self._install(data, storage, "com.retroarch", visible=False)
|
|
(storage / "RetroArch").mkdir()
|
|
storage, found, lines = self._run(make)
|
|
self.assertEqual(found, [("retroarch", storage / "RetroArch/system")])
|
|
self.assertEqual(lines, [])
|
|
|
|
def test_undecided_build_names_both_folders(self):
|
|
storage, found, lines = self._run(lambda data, storage: self._install(
|
|
data, storage, "com.retroarch.aarch64", visible=False))
|
|
self.assertEqual(found, [])
|
|
self.assertEqual(len(lines), 1)
|
|
self.assertIn(
|
|
str(storage / "Android/data/com.retroarch.aarch64/files/RetroArch/system"),
|
|
lines[0])
|
|
self.assertIn(str(storage / "RetroArch/system"), lines[0])
|
|
|
|
def test_named_folder_needs_no_build(self):
|
|
storage, found, _lines = self._run(lambda data, storage: self._install(
|
|
data, storage, "com.retroarch", f'system_directory = "{storage}/bios"\n'))
|
|
self.assertEqual(found, [("retroarch", storage / "bios")])
|
|
|
|
def test_each_installed_build_is_found(self):
|
|
def make(data, storage):
|
|
self._install(data, storage, "com.retroarch", self._cfg(self.PLAY_CODE))
|
|
self._install(data, storage, "com.retroarch.ra32", self._cfg(self.SITE_CODE))
|
|
storage, found, _lines = self._run(make)
|
|
self.assertEqual(found, [
|
|
("retroarch", storage / "Android/data/com.retroarch/files/RetroArch/system"),
|
|
("retroarch", storage / "RetroArch/system"),
|
|
])
|
|
|
|
def test_detection_prints_the_retroarch_line(self):
|
|
err = io.StringIO()
|
|
root = Path(_tempdir(self))
|
|
(root / "data" / "com.retroarch").mkdir(parents=True)
|
|
with unittest.mock.patch.object(install, "ANDROID_APP_DATA", root / "data"), \
|
|
unittest.mock.patch.dict(os.environ, {"EXTERNAL_STORAGE": str(root / "s")}), \
|
|
contextlib.redirect_stderr(err):
|
|
install.detect_platforms("android")
|
|
self.assertIn("RetroArch (com.retroarch) is installed", err.getvalue())
|
|
|
|
|
|
class AndroidEmulatorsOutOfReachAreSaid(unittest.TestCase):
|
|
"""DraStic keeps its BIOS in its private data and Flycast in
|
|
Android/data, which Termux cannot write on Android 11 and later: both
|
|
were skipped without a word."""
|
|
|
|
def _lines(self, make) -> list[str]:
|
|
root = Path(_tempdir(self))
|
|
app_data, storage = root / "data", root / "storage"
|
|
app_data.mkdir()
|
|
make(app_data, storage)
|
|
with unittest.mock.patch.object(install, "ANDROID_APP_DATA", app_data), \
|
|
unittest.mock.patch.dict(os.environ, {"EXTERNAL_STORAGE": str(storage)}):
|
|
return install.android_unreachable_folders()
|
|
|
|
def test_nothing_installed_says_nothing(self):
|
|
self.assertEqual(self._lines(lambda data, storage: None), [])
|
|
|
|
def test_an_installed_drastic_is_named(self):
|
|
lines = self._lines(lambda data, storage: (data / "com.dsemu.drastic").mkdir())
|
|
self.assertEqual(len(lines), 1)
|
|
self.assertIn("DraStic is installed", lines[0])
|
|
self.assertIn("nds_bios_arm7.bin", lines[0])
|
|
|
|
def test_flycast_out_of_reach_is_named(self):
|
|
lines = self._lines(lambda data, storage: (data / "com.flycast.emulator").mkdir())
|
|
self.assertEqual(len(lines), 1)
|
|
self.assertIn("Flycast is installed", lines[0])
|
|
self.assertIn("dc_boot.bin", lines[0])
|
|
|
|
def test_a_writable_flycast_folder_is_a_copy_target(self):
|
|
def make(data, storage):
|
|
(data / "com.flycast.emulator").mkdir()
|
|
(storage / "Android/data/com.flycast.emulator/files/data").mkdir(parents=True)
|
|
self.assertEqual(self._lines(make), [])
|
|
import yaml # noqa: PLC0415
|
|
|
|
registry = yaml.safe_load((REPO_ROOT / "platforms" / "_registry.yml").read_text())
|
|
copies = {entry.get("file"): entry for entry in registry["_standalone_copies"]}
|
|
self.assertEqual(copies["dc/dc_boot.bin"]["targets"]["android"],
|
|
["$EXTERNAL_STORAGE/Android/data/com.flycast.emulator/files/data"])
|
|
|
|
def test_detection_prints_them(self):
|
|
err = io.StringIO()
|
|
with unittest.mock.patch.object(install, "_detect_android", return_value=[]), \
|
|
unittest.mock.patch.object(install, "android_unreachable_folders",
|
|
return_value=["DraStic is installed, but"]), \
|
|
contextlib.redirect_stderr(err):
|
|
install.detect_platforms("android")
|
|
self.assertIn("DraStic is installed", err.getvalue())
|
|
|
|
|
|
class TheBoardTheSystemNamesIsTheTarget(unittest.TestCase):
|
|
"""Batocera, Recalbox and ROCKNIX each write the board they were built
|
|
for, but the installer read none of them, so a Batocera RPi4 installed
|
|
every board's files (8.6 GiB where bcm2711 needs 5.5)."""
|
|
|
|
def test_each_source_is_read(self):
|
|
root = Path(_tempdir(self))
|
|
arch = root / "batocera.arch"
|
|
arch.write_text("bcm2711")
|
|
with unittest.mock.patch.dict(install._BOARD_SOURCES, {"batocera": ("file", str(arch))}):
|
|
self.assertEqual(install.detect_board("batocera"), "bcm2711")
|
|
with unittest.mock.patch.object(install, "_parse_os_release",
|
|
return_value={"HW_DEVICE": "RK3326"}):
|
|
self.assertEqual(install.detect_board("rocknix"), "rk3326")
|
|
self.assertIsNone(install.detect_board("retroarch"))
|
|
|
|
def test_lakka_names_its_device_or_its_project(self):
|
|
# An RPi4 Lakka installed 5.7 GB of every board's files: its board
|
|
# was in os-release and never read.
|
|
for release, board in (
|
|
({"LIBREELEC_PROJECT": "RPi", "LIBREELEC_DEVICE": "RPi4"}, "rpi4"),
|
|
({"LIBREELEC_PROJECT": "Generic"}, "generic"),
|
|
):
|
|
with self.subTest(board=board), unittest.mock.patch.object(
|
|
install, "_parse_os_release", return_value=release
|
|
):
|
|
self.assertEqual(install.detect_board("lakka"), board)
|
|
|
|
def test_every_board_and_target_are_refused_together(self):
|
|
result = subprocess.run(
|
|
[sys.executable, str(REPO_ROOT / "install.py"), "--every-board",
|
|
"--target", "bcm2711", "--platform", "batocera", "--dry-run"],
|
|
capture_output=True, text=True, timeout=120,
|
|
)
|
|
self.assertEqual(result.returncode, 2)
|
|
self.assertIn("--every-board", result.stderr)
|
|
|
|
def test_the_sources_name_our_target_lists(self):
|
|
import yaml # noqa: PLC0415
|
|
|
|
for platform in install._BOARD_SOURCES:
|
|
with self.subTest(platform=platform):
|
|
targets = yaml.safe_load(
|
|
(REPO_ROOT / "platforms" / "targets" / f"{platform}.yml").read_text()
|
|
)["targets"]
|
|
self.assertTrue(targets)
|
|
|
|
|
|
|
|
class CaseTwinsOnAFoldingVolume(unittest.TestCase):
|
|
"""Batocera and Recalbox list np2kai/bios.rom and np2kai/BIOS.ROM, other
|
|
bytes each. Installed to a share opened from Windows or an exFAT card,
|
|
the second overwrote the first and --check never converged."""
|
|
|
|
def test_one_file_per_name_is_kept(self):
|
|
files = [{"dest": "np2kai/bios.rom"}, {"dest": "np2kai/BIOS.ROM"}, {"dest": "x.bin"}]
|
|
kept, folded = install.fold_case_twins(files)
|
|
self.assertEqual([f["dest"] for f in kept], ["np2kai/bios.rom", "x.bin"])
|
|
self.assertEqual(folded, ["np2kai/BIOS.ROM"])
|
|
|
|
def test_main_folds_only_where_case_is_not_kept(self):
|
|
source = " ".join((REPO_ROOT / "install.py").read_text(encoding="utf-8").split())
|
|
self.assertIn("if not case_kept: files, folded = fold_case_twins(files)", source)
|
|
|
|
|
|
|
|
class StandaloneCopiesReachFlatpakInstalls(unittest.TestCase):
|
|
"""Copies went only to native and AppImage directories: Dolphin, PPSSPP
|
|
and Azahar from Flathub received nothing. Azahar's native Linux
|
|
directory was wrong too: XDG_DATA_HOME/azahar-emu (common_paths.h
|
|
EMU_DATA_DIR), not azahar."""
|
|
|
|
def test_flatpak_and_native_directories(self):
|
|
import yaml # noqa: PLC0415
|
|
|
|
registry = yaml.safe_load((REPO_ROOT / "platforms" / "_registry.yml").read_text())
|
|
linux = {
|
|
target
|
|
for entry in registry["_standalone_copies"]
|
|
for target in (entry.get("targets") or {}).get("linux", [])
|
|
}
|
|
for expected in (
|
|
"$HOME/.var/app/org.DolphinEmu.dolphin-emu/data/dolphin-emu/GC/USA",
|
|
"$HOME/.var/app/org.ppsspp.PPSSPP/config/ppsspp/PSP/SYSTEM",
|
|
"$HOME/.var/app/org.azahar_emu.Azahar/data/azahar-emu/sysdata",
|
|
"$HOME/.local/share/azahar-emu/sysdata",
|
|
):
|
|
with self.subTest(target=expected):
|
|
self.assertIn(expected, linux)
|
|
self.assertNotIn("$HOME/.local/share/azahar/sysdata", linux)
|
|
|
|
|
|
|
|
class TheInstallerPageNamesEveryEvidence(unittest.TestCase):
|
|
"""The page said BizHawk, RetroBat, RomM and RetroPie had no detection
|
|
and fell back to ~/bios, while install.py found each by its directory."""
|
|
|
|
def test_every_rule_is_on_the_page(self):
|
|
page = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8")
|
|
page = page.replace("\\\\", "\\")
|
|
expected = [path for rule in install._EMBEDDED_RULES
|
|
if rule.method == "file_exists" for path in rule.values]
|
|
expected += [rule.name for rule in install._EMBEDDED_RULES]
|
|
expected += [path.replace("$HOME", "~").rstrip("/")
|
|
for _os, _p, path, _b in install._PATH_RULES]
|
|
missing = [path for path in expected if path not in page]
|
|
self.assertEqual(missing, [])
|
|
self.assertNotIn("have no detection", page)
|
|
|
|
|
|
|
|
class BizHawkIsFoundWhereItReads(unittest.TestCase):
|
|
"""BizHawk reads <data dir>/Firmware: $BIZHAWK_DATA_HOME on Unix (the
|
|
Nix package sets $XDG_DATA_HOME/emuhawk-monort-<version>), else the
|
|
EmuHawk folder. Only two conventional folders were looked at, and a
|
|
forced --platform bizhawk wrote where EmuHawk never reads."""
|
|
|
|
def test_the_data_home_variable(self):
|
|
home = Path(_tempdir(self))
|
|
with unittest.mock.patch.dict(os.environ, {"BIZHAWK_DATA_HOME": str(home)}):
|
|
self.assertEqual(install._bizhawk_unix_firmware(), home / "Firmware")
|
|
|
|
def test_the_nix_data_home(self):
|
|
xdg = Path(_tempdir(self))
|
|
(xdg / "emuhawk-monort-2.11.1").mkdir()
|
|
env = {"XDG_DATA_HOME": str(xdg), "BIZHAWK_DATA_HOME": ""}
|
|
with unittest.mock.patch.dict(os.environ, env):
|
|
self.assertEqual(
|
|
install._bizhawk_unix_firmware(), xdg / "emuhawk-monort-2.11.1" / "Firmware"
|
|
)
|
|
|
|
def test_launchbox_names_emuhawk(self):
|
|
source = " ".join((REPO_ROOT / "install.py").read_text(encoding="utf-8").split())
|
|
self.assertIn('.get("emuhawk.exe")', source)
|
|
|
|
|
|
|
|
class RetroArchUwpIsFound(unittest.TestCase):
|
|
"""The UWP/MSIX RetroArch keeps retroarch.cfg and system/ in its
|
|
package's LocalState, but no rule looked under %LOCALAPPDATA%\\Packages."""
|
|
|
|
def _state(self) -> Path:
|
|
local = Path(_tempdir(self))
|
|
state = local / "Packages" / f"{install.RETROARCH_UWP_ID}_abc123" / "LocalState"
|
|
state.mkdir(parents=True)
|
|
patcher = unittest.mock.patch.dict(os.environ, {"LOCALAPPDATA": str(local)})
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
return state
|
|
|
|
def test_the_default_is_local_state_system(self):
|
|
state = self._state()
|
|
self.assertEqual(install._retroarch_uwp_system_dir(), state / "system")
|
|
|
|
def test_a_tilde_is_local_state(self):
|
|
state = self._state()
|
|
(state / "retroarch.cfg").write_text('system_directory = "~\\bios"\n')
|
|
self.assertEqual(install._retroarch_uwp_system_dir(), state / "bios")
|
|
|
|
def test_nothing_installed(self):
|
|
with unittest.mock.patch.dict(os.environ, {"LOCALAPPDATA": _tempdir(self)}):
|
|
self.assertIsNone(install._retroarch_uwp_system_dir())
|
|
|
|
|
|
|
|
class QuitMeansNoAtTheLastQuestion(unittest.TestCase):
|
|
"""Every menu taught q as quit, yet at "Download 3.2 GB ...? [Y/n]" a q
|
|
started the download."""
|
|
|
|
def test_quit_words_refuse(self):
|
|
for answer, expected in (("q", False), ("quit", False), ("exit", False),
|
|
("n", False), ("No", False), ("", True), ("y", True)):
|
|
with self.subTest(answer=answer), \
|
|
unittest.mock.patch.object(install, "_ask", return_value=answer):
|
|
self.assertIs(install._confirm("? "), expected)
|
|
|
|
|
|
|
|
class ACutFileListIsRetried(unittest.TestCase):
|
|
"""Wi-Fi dropped mid install/batocera.json: the short body parsed as an
|
|
invalid file list, no retry, no network advice."""
|
|
|
|
class _Response(io.BytesIO):
|
|
def __init__(self, body: bytes, length: int):
|
|
super().__init__(body)
|
|
self.headers = {"Content-Length": str(length)}
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
return False
|
|
|
|
def test_the_second_attempt_succeeds(self):
|
|
full = json.dumps({"a": 1}).encode()
|
|
answers = iter([self._Response(full[:4], len(full)), self._Response(full, len(full))])
|
|
with unittest.mock.patch.object(install.urllib.request, "urlopen",
|
|
lambda *a, **k: next(answers)), \
|
|
unittest.mock.patch.object(install.time, "sleep"):
|
|
self.assertEqual(install._fetch_json("https://x/y.json", 1000, "manifest"), {"a": 1})
|
|
|
|
def test_a_retry_is_announced(self):
|
|
import contextlib
|
|
|
|
full = json.dumps({"a": 1}).encode()
|
|
answers = iter([self._Response(full[:4], len(full)), self._Response(full, len(full))])
|
|
err = io.StringIO()
|
|
with unittest.mock.patch.object(install.urllib.request, "urlopen",
|
|
lambda *a, **k: next(answers)), \
|
|
unittest.mock.patch.object(install.time, "sleep"), \
|
|
contextlib.redirect_stderr(err):
|
|
install._fetch_json("https://x/y.json", 1000, "file list")
|
|
self.assertIn("trying again (2/3)", err.getvalue())
|
|
|
|
def test_a_short_body_is_a_connection_error(self):
|
|
with self.assertRaises(ConnectionError):
|
|
install._read_limited_json(self._Response(b'{"a"', 10), 1000, "manifest")
|
|
|
|
|
|
|
|
class DataDirectoriesAreInstalled(unittest.TestCase):
|
|
"""The README said the installer does what the pack does, but for RetroArch
|
|
it left out 2,640 data files (Dolphin's Sys, PPSSPP's assets) and
|
|
--check still said "All files up to date"."""
|
|
|
|
def _zip(self, root: Path) -> Path:
|
|
import zipfile
|
|
|
|
archive = root / "Dolphin.zip"
|
|
with zipfile.ZipFile(archive, "w") as zf:
|
|
zf.writestr("Dolphin/Sys/GC/font.bin", b"font")
|
|
zf.writestr("Dolphin/Sys/GC/dsp.bin", b"new bytes upstream")
|
|
zf.writestr("Dolphin/Themes/x.png", b"theme")
|
|
return archive
|
|
|
|
def _entry(self) -> dict:
|
|
return {
|
|
"ref": "dolphin-sys", "dest": "dolphin-emu", "url": "https://example.invalid/D.zip",
|
|
"type": "zip", "strip_components": 1, "exclude": ["Themes"],
|
|
"systems": ["nintendo-gamecube"],
|
|
"files": {
|
|
"Sys/GC/font.bin": {"sha1": hashlib.sha1(b"font").hexdigest(), "size": 4},
|
|
"Sys/GC/dsp.bin": {"sha1": hashlib.sha1(b"old").hexdigest(), "size": 3},
|
|
"Sys/GC/gone.bin": {"sha1": "0" * 40, "size": 1},
|
|
},
|
|
}
|
|
|
|
def test_verified_members_land_the_rest_are_said(self):
|
|
work = Path(_tempdir(self))
|
|
archive = self._zip(work)
|
|
bios = work / "system"
|
|
bios.mkdir()
|
|
|
|
def fetch(url, directory):
|
|
copy = Path(directory) / "a.zip"
|
|
shutil.copy(archive, copy)
|
|
return copy
|
|
|
|
with unittest.mock.patch.object(install, "_fetch_archive", fetch):
|
|
counts = install.install_data_directory(self._entry(), bios)
|
|
again = install.install_data_directory(self._entry(), bios)
|
|
self.assertEqual((counts["installed"], counts["changed"], counts["absent"]), (1, 1, 1))
|
|
self.assertEqual((bios / "dolphin-emu" / "Sys" / "GC" / "font.bin").read_bytes(), b"font")
|
|
self.assertFalse((bios / "dolphin-emu" / "Sys" / "GC" / "dsp.bin").exists())
|
|
self.assertFalse((bios / "dolphin-emu" / "Themes").exists())
|
|
self.assertEqual(again["present"], 1)
|
|
self.assertEqual(sorted(p.name for p in (bios / "dolphin-emu").iterdir()), ["Sys"])
|
|
|
|
def test_a_member_that_inflates_is_cut_at_its_size(self):
|
|
# A member whose header claims the recorded size and whose stream
|
|
# never ends: reading stops one byte past it.
|
|
class Endless(io.RawIOBase):
|
|
served = 0
|
|
|
|
def readable(self):
|
|
return True
|
|
|
|
def readinto(self, buffer):
|
|
Endless.served += len(buffer)
|
|
buffer[:] = b"x" * len(buffer)
|
|
return len(buffer)
|
|
|
|
work = Path(_tempdir(self))
|
|
entry = self._entry()
|
|
entry["files"] = {"Sys/GC/font.bin": {"sha1": "1" * 40, "size": 4}}
|
|
|
|
def members(_archive, _entry):
|
|
yield "Sys/GC/font.bin", 4, lambda: io.BufferedReader(Endless())
|
|
|
|
with unittest.mock.patch.object(install, "_fetch_archive",
|
|
lambda url, d: Path(d) / "a"), \
|
|
unittest.mock.patch.object(install, "_archive_members", members):
|
|
counts = install.install_data_directory(entry, work)
|
|
self.assertEqual(counts["changed"], 1)
|
|
self.assertLess(Endless.served, 64 * 1024)
|
|
|
|
def test_a_damaged_member_is_a_reason_and_leaves_no_part(self):
|
|
"""zlib.error and EOFError from a damaged member ended the run on a
|
|
traceback, and a failed write left its .part beside the tree."""
|
|
import zlib
|
|
|
|
work = Path(_tempdir(self))
|
|
entry = self._entry()
|
|
entry["files"] = {"Sys/GC/font.bin": {"sha1": "1" * 40, "size": 4}}
|
|
for error in (zlib.error("invalid stored block lengths"), EOFError(),
|
|
OSError(28, "No space left on device")):
|
|
with self.subTest(error=type(error).__name__):
|
|
class Broken(io.RawIOBase):
|
|
def readable(self):
|
|
return True
|
|
|
|
def readinto(self, buffer, error=error):
|
|
raise error
|
|
|
|
def members(_archive, _entry):
|
|
yield "Sys/GC/font.bin", 4, lambda: io.BufferedReader(Broken())
|
|
|
|
with unittest.mock.patch.object(install, "_fetch_archive",
|
|
lambda url, d: Path(d) / "a"), \
|
|
unittest.mock.patch.object(install, "_archive_members", members):
|
|
counts = install.install_data_directory(entry, work)
|
|
self.assertTrue(counts["error"])
|
|
self.assertEqual(counts["installed"], 0)
|
|
self.assertEqual([p.name for p in work.rglob("*.part")], [])
|
|
|
|
def test_offline_keeps_its_reason(self):
|
|
work = Path(_tempdir(self))
|
|
with unittest.mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}):
|
|
counts = install.install_data_directory(self._entry(), work)
|
|
self.assertIsInstance(counts["error"], install.OfflineReason)
|
|
|
|
def test_a_tarball_maps_through_source_path(self):
|
|
import io as _io
|
|
import tarfile
|
|
|
|
work = Path(_tempdir(self))
|
|
archive = work / "src.tar.gz"
|
|
with tarfile.open(archive, "w:gz") as tf:
|
|
data = b"index"
|
|
info = tarfile.TarInfo("pcsx2-v1/bin/resources/GameIndex.yaml")
|
|
info.size = len(data)
|
|
tf.addfile(info, _io.BytesIO(data))
|
|
entry = {"type": "tarball", "source_path": "pcsx2-v1/bin/resources"}
|
|
names = [rel for rel, _size, _open in install._archive_members(archive, entry)]
|
|
self.assertEqual(names, ["GameIndex.yaml"])
|
|
|
|
def test_the_trust_boundary(self):
|
|
entry = self._entry()
|
|
install._check_data_directory(entry, "d")
|
|
for bad in ({"url": "http://example.com/x.zip"}, {"dest": "../up"},
|
|
{"files": {"../x": {"sha1": "0" * 40, "size": 1}}},
|
|
{"type": "rar"}):
|
|
with self.subTest(bad=bad), self.assertRaises(ValueError):
|
|
install._check_data_directory({**entry, **bad}, "d")
|
|
|
|
def test_the_published_retroarch_list_carries_them(self):
|
|
manifest = json.loads((REPO_ROOT / "install" / "retroarch.json").read_text())
|
|
dirs = manifest.get("data_directories") or []
|
|
files = sum(len(d["files"]) for d in dirs)
|
|
self.assertEqual(
|
|
manifest["pack_files"] - manifest["total_files"] - files,
|
|
2, "the pack's two documents are the only files the installer lacks",
|
|
)
|
|
|
|
|
|
|
|
class AChunkedCutIsADownloadFailure(unittest.TestCase):
|
|
"""Behind a proxy that re-encodes as chunked, a cut raised
|
|
http.client.IncompleteRead, which no handler caught: the run ended on a
|
|
traceback with no summary."""
|
|
|
|
def test_the_file_fails_and_the_run_goes_on(self):
|
|
import http.client
|
|
|
|
class Cut:
|
|
headers = {}
|
|
status = 200
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
return False
|
|
|
|
def read(self, *_args):
|
|
raise http.client.IncompleteRead(b"abc", 10)
|
|
|
|
entry = {"dest": "a.bin", "repo_path": "bios/a.bin", "size": 10,
|
|
"sha1": "0" * 40, "sha256": "0" * 64}
|
|
with unittest.mock.patch.object(install.urllib.request, "urlopen",
|
|
lambda *a, **k: Cut()), \
|
|
unittest.mock.patch.object(install, "_retry_delay", return_value=0):
|
|
dest, ok, reason = install._download_one(entry, Path(_tempdir(self)))
|
|
self.assertFalse(ok)
|
|
self.assertEqual(reason, "the connection was cut")
|
|
self.assertIsInstance(reason, install.NetworkReason)
|
|
|
|
|
|
|
|
class TheProgressLineFitsTheTerminal(unittest.TestCase):
|
|
"""At 80 columns the 94-100 column line wrapped, and every redraw left
|
|
a ghost line behind."""
|
|
|
|
def test_each_width(self):
|
|
import os as _os
|
|
|
|
for columns in (40, 60, 80, 120):
|
|
with self.subTest(columns=columns):
|
|
out = io.StringIO()
|
|
term = install.Terminal(out)
|
|
term.live = True
|
|
progress = install.Progress(5865, 2_300_000_000, term)
|
|
progress.bytes = 1_000_000_000
|
|
progress.files = 5000
|
|
with unittest.mock.patch.object(
|
|
install.shutil, "get_terminal_size",
|
|
return_value=_os.terminal_size((columns, 24)),
|
|
):
|
|
progress._draw()
|
|
line = out.getvalue().split("\033[K")[-1]
|
|
self.assertLess(len(line), columns)
|
|
self.assertIn("%", line)
|
|
|
|
|
|
|
|
class AResumedCopyIsNotSpeed(unittest.TestCase):
|
|
"""The live line divided every byte by the run's time, the 3 GB a
|
|
resumed partial copy already held included: 2.5 GB/s and 0:00 left on a
|
|
1 MB/s link."""
|
|
|
|
def test_speed_counts_only_what_this_run_fetched(self):
|
|
import os as _os
|
|
|
|
out = io.StringIO()
|
|
term = install.Terminal(out)
|
|
term.live = True
|
|
progress = install.Progress(1, 4_000_000_000, term)
|
|
# Ten seconds exactly: read from the clock, a slow runner added a few
|
|
# milliseconds and moved the last digit of the speed.
|
|
progress.elapsed = lambda: 10.0
|
|
progress.bytes = 3_000_000_000 + 10_000_000
|
|
progress.resumed = 3_000_000_000
|
|
with unittest.mock.patch.object(
|
|
install.shutil, "get_terminal_size",
|
|
return_value=_os.terminal_size((120, 24)),
|
|
):
|
|
progress._draw()
|
|
line = out.getvalue()
|
|
self.assertIn(f"{install.format_size(1_000_000)}/s", line)
|
|
self.assertNotIn("0:00 left", line)
|
|
|
|
|
|
|
|
class ACheckNamesWhatTheCollectionLacks(unittest.TestCase):
|
|
"""--check counted the required files the manifest leaves out and named
|
|
none, then said all files were up to date: on Recalbox, c3.555 stayed
|
|
unnamed for a user asking why Caesar III would not start."""
|
|
|
|
def test_recalbox_caesar3(self):
|
|
import shutil # noqa: PLC0415
|
|
import subprocess # noqa: PLC0415
|
|
|
|
manifest = json.loads((REPO_ROOT / "install" / "recalbox.json").read_text())
|
|
lacking = sorted(
|
|
e["dest"] for e in manifest.get("omitted_files", [])
|
|
if e.get("required") and e.get("system") == "caesar3"
|
|
)
|
|
if not lacking:
|
|
self.skipTest("the Recalbox manifest omits no required caesar3 file")
|
|
handler = functools.partial(
|
|
http.server.SimpleHTTPRequestHandler, directory=str(REPO_ROOT)
|
|
)
|
|
handler.log_message = lambda *args: None
|
|
httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
|
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
|
self.addCleanup(httpd.server_close)
|
|
self.addCleanup(httpd.shutdown)
|
|
(REPO_ROOT / "tmp").mkdir(exist_ok=True)
|
|
home = Path(tempfile.mkdtemp(dir=str(REPO_ROOT / "tmp")))
|
|
self.addCleanup(shutil.rmtree, home, True)
|
|
env = {
|
|
**os.environ, "HOME": str(home), "RETROBIOS_OS": "linux",
|
|
"RETROBIOS_BASE_URL": f"http://127.0.0.1:{httpd.server_address[1]}",
|
|
}
|
|
proc = subprocess.run(
|
|
[sys.executable, "-I", "install.py", "--platform", "recalbox",
|
|
"--no-input", "--dest", str(home / "bios"), "--check"],
|
|
cwd=REPO_ROOT, env=env, capture_output=True, text=True, timeout=300,
|
|
check=False,
|
|
)
|
|
self.assertNotIn("Traceback", proc.stderr)
|
|
for dest in lacking:
|
|
self.assertIn(dest, proc.stdout)
|
|
self.assertNotIn("unsafe", proc.stdout)
|
|
|
|
|
|
|
|
class ARefusedStandaloneCopyIsNamed(unittest.TestCase):
|
|
"""A standalone copy refused by a read-only emulator folder counted as
|
|
an error with no failures entry: the summary named no file, pointed at
|
|
the BIOS folder and gave no advice."""
|
|
|
|
def test_the_file_and_its_folder_reach_the_summary(self):
|
|
import contextlib # noqa: PLC0415
|
|
|
|
if os.name == "nt" or os.geteuid() == 0:
|
|
self.skipTest("a read-only folder needs POSIX modes and a non-root user")
|
|
bios = Path(_tempdir(self))
|
|
(bios / "scph5501.bin").write_bytes(b"x")
|
|
target = Path(_tempdir(self)) / "pcsx2-bios"
|
|
target.mkdir()
|
|
target.chmod(0o500)
|
|
self.addCleanup(target.chmod, 0o700)
|
|
manifest = {"standalone_copies": [
|
|
{"file": "scph5501.bin", "emulator": "pcsx2",
|
|
"targets": {"linux": [str(target)]}},
|
|
]}
|
|
failures: list = []
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
_copied, _skipped, failed = install.do_standalone_copies(
|
|
manifest, bios, "linux", None, failures)
|
|
self.assertEqual(failed, 1)
|
|
self.assertEqual(failures, [(str(target / "scph5501.bin"), install.NOT_WRITABLE)])
|
|
|
|
out = io.StringIO()
|
|
with contextlib.redirect_stdout(out), self.assertRaises(SystemExit):
|
|
install._report_outcome([bios], 0, 1, failed, 0, False, failures=failures)
|
|
text = out.getvalue()
|
|
self.assertIn(str(target / "scph5501.bin"), text)
|
|
self.assertIn(str(target), text.split("Location")[1])
|
|
self.assertIn("cannot write there", text)
|
|
|
|
|
|
|
|
class OneEmulatorsFilesInOneCommand(unittest.TestCase):
|
|
"""An emulator page offers one command for that emulator's files.
|
|
--core kept the platform's whole list, and narrowing to cores kept the
|
|
platform files of every system no core is mapped to."""
|
|
|
|
MANIFEST = {
|
|
"system_cores": {"ibm-pc": ["pcem"], "nintendo-nes": ["fceumm"]},
|
|
"core_names": {"pcem": ["pcem"], "fceumm": ["fceumm"]},
|
|
"files": [],
|
|
}
|
|
|
|
def test_only_its_files_and_its_systems(self):
|
|
import contextlib # noqa: PLC0415
|
|
|
|
files = [
|
|
{"dest": "pcem/ibmpc.bin", "cores": ["pcem"], "systems": ["ibm-pc"]},
|
|
{"dest": "disksys.rom", "cores": None, "systems": ["nintendo-nes"]},
|
|
{"dest": "ep128emu/exos.rom", "cores": None, "systems": ["enterprise"]},
|
|
{"dest": "ibm.rom", "cores": None, "systems": ["ibm-pc"]},
|
|
]
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
kept, _omitted = install._narrow_to_emulators(
|
|
["PCem"], self.MANIFEST, "retroarch", files, [])
|
|
self.assertEqual(sorted(e["dest"] for e in kept), ["ibm.rom", "pcem/ibmpc.bin"])
|
|
|
|
def test_an_unknown_name_is_refused(self):
|
|
import contextlib # noqa: PLC0415
|
|
|
|
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
|
|
install._narrow_to_emulators(["nope"], self.MANIFEST, "retroarch", [], [])
|
|
|
|
|
|
|
|
class ThePagesQuoteTheBootstrapsOwnWords(unittest.TestCase):
|
|
"""The installer page and the FAQ quoted "install.py SHA-256 mismatch",
|
|
a message the bootstraps no longer print."""
|
|
|
|
def test_the_quoted_message_is_printed(self):
|
|
quoted = "does not match its expected"
|
|
for wrapper in ("install.sh", "install.ps1"):
|
|
self.assertIn(quoted, (REPO_ROOT / wrapper).read_text(encoding="utf-8"))
|
|
for page in ("wiki/installer.md", "wiki/faq.md"):
|
|
text = (REPO_ROOT / page).read_text(encoding="utf-8")
|
|
self.assertNotIn("SHA-256 mismatch`", text)
|
|
self.assertIn(quoted, " ".join(text.split()))
|
|
|
|
|
|
|
|
class APartialIsClosedBeforeItIsDeleted(unittest.TestCase):
|
|
"""Windows refuses to delete an open file: a resumed partial copy whose
|
|
hash no longer matched was unlinked with its handle still open, and the
|
|
PermissionError ended the run."""
|
|
|
|
def test_the_source_closes_first(self):
|
|
source = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
|
|
branch = source[source.index(" except _Corrupt:"):]
|
|
branch = branch[:branch.index("delay = 0.0")]
|
|
self.assertLess(branch.index("partial.close()"), branch.index("tmp_path.unlink()"))
|
|
|
|
|
|
class WindowsSeenFromWsl(unittest.TestCase):
|
|
"""Under WSL, C:\\RetroBat\\bios was a relative path, %APPDATA% and
|
|
%USERPROFILE% were unset, and --platform retrobat wrote 7 753 files into
|
|
~/bios inside the distribution, where RetroBat reads nothing."""
|
|
|
|
def setUp(self):
|
|
self.root = Path(_tempdir(self))
|
|
patcher = unittest.mock.patch.dict(install._WSL, {"active": True, "root": str(self.root)})
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
|
|
def test_a_drive_path_is_its_mount(self):
|
|
self.assertEqual(install._host_path("C:\\RetroBat\\bios"), self.root / "c" / "RetroBat" / "bios")
|
|
self.assertEqual(install._rule_path("C:\\RetroBat\\bios"), self.root / "c" / "RetroBat" / "bios")
|
|
self.assertEqual(install._default_dest("wsl", "retrobat"), self.root / "c" / "RetroBat" / "bios")
|
|
self.assertEqual(install._host_path("relative/path"), Path("relative/path"))
|
|
|
|
def test_retrobat_on_the_windows_side_is_detected(self):
|
|
(self.root / "c" / "RetroBat" / "bios").mkdir(parents=True)
|
|
found: list = []
|
|
install._detect_by_path("wsl", found)
|
|
self.assertIn(("retrobat", self.root / "c" / "RetroBat" / "bios"), found)
|
|
|
|
def test_the_windows_variables_are_read_through_cmd(self):
|
|
answers = {"USERPROFILE": "C:\\Users\\me", "APPDATA": "C:\\Users\\me\\AppData\\Roaming"}
|
|
|
|
def cmd(argv, **kwargs):
|
|
# cmd.exe must not read the terminal: it swallowed the answer
|
|
# typed at the first prompt.
|
|
self.assertIs(kwargs.get("stdin"), subprocess.DEVNULL)
|
|
name = argv[-1].split("%")[1]
|
|
return subprocess.CompletedProcess(argv, 0, answers.get(name, f"%{name}%") + "\r\n", "")
|
|
|
|
cleared = {name: "" for name in install._WINDOWS_VARIABLES}
|
|
with unittest.mock.patch.dict(os.environ, cleared), \
|
|
unittest.mock.patch.object(install.subprocess, "run", cmd):
|
|
install._enter_wsl()
|
|
self.assertEqual(os.environ["USERPROFILE"], str(self.root / "c" / "Users" / "me"))
|
|
self.assertEqual(os.environ["APPDATA"],
|
|
str(self.root / "c" / "Users" / "me" / "AppData" / "Roaming"))
|
|
self.assertEqual(os.environ["ProgramFiles"], "")
|
|
|
|
|
|
class SteamRetroArchOnMacIsPortable(unittest.TestCase):
|
|
"""platform_darwin.m makes the Steam build portable (HAVE_STEAM) with no
|
|
portable.txt: a default system_directory is Application Support's, and
|
|
the installer took ~/Documents/RetroArch/system."""
|
|
|
|
def test_the_steam_bundle_moves_the_base(self):
|
|
home = Path(_tempdir(self))
|
|
support = home / "Library" / "Application Support"
|
|
with unittest.mock.patch.dict(os.environ, {"HOME": str(home)}), \
|
|
unittest.mock.patch.object(Path, "home", return_value=home):
|
|
self.assertEqual(install._macos_retroarch_base(), home / "Documents" / "RetroArch")
|
|
(support / "Steam/steamapps/common/RetroArch/RetroArch.app").mkdir(parents=True)
|
|
self.assertEqual(install._macos_retroarch_base(), support / "RetroArch")
|
|
|
|
|
|
class AMissingFileListNamesTheRef(unittest.TestCase):
|
|
"""A 404 on the manifest said three attempts were made and to check the
|
|
connection and proxy, but it was asked once, and the cause is the ref."""
|
|
|
|
def _run(self, ref: str) -> str:
|
|
error = install.urllib.error.HTTPError("u", 404, "Not Found", None, None)
|
|
with unittest.mock.patch.object(install, "RELEASE_REF", ref), \
|
|
unittest.mock.patch.object(install, "_fetch_json", side_effect=error), \
|
|
contextlib.redirect_stderr(io.StringIO()) as err, \
|
|
self.assertRaises(SystemExit):
|
|
install.fetch_manifest("retroarch")
|
|
return err.getvalue()
|
|
|
|
def test_a_pinned_tag_is_named(self):
|
|
text = self._run("v2020.01.01")
|
|
self.assertIn("RETROBIOS_REF=v2020.01.01", text)
|
|
self.assertNotIn("attempts", text)
|
|
self.assertNotIn("proxy", text)
|
|
|
|
def test_the_default_ref_is_not_blamed_on_the_network(self):
|
|
text = self._run(install.DEFAULT_RELEASE_REF)
|
|
self.assertIn("No file list for retroarch", text)
|
|
self.assertNotIn("proxy", text)
|
|
|
|
|
|
class TheInstallerSaysWhatItFound(unittest.TestCase):
|
|
"""--check gave a count and no name, a read-only folder printed a line
|
|
per file, a piped log put errors before their step, and Ctrl+C while
|
|
the file list was fetched ended on a socket traceback."""
|
|
|
|
def setUp(self):
|
|
import functools # noqa: PLC0415
|
|
import http.server # noqa: PLC0415
|
|
import shutil # noqa: PLC0415
|
|
|
|
handler = functools.partial(
|
|
http.server.SimpleHTTPRequestHandler, directory=str(REPO_ROOT)
|
|
)
|
|
handler.log_message = lambda *args: None
|
|
httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
|
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
|
self.addCleanup(httpd.server_close)
|
|
self.addCleanup(httpd.shutdown)
|
|
(REPO_ROOT / "tmp").mkdir(exist_ok=True)
|
|
self.home = Path(tempfile.mkdtemp(dir=str(REPO_ROOT / "tmp")))
|
|
self.addCleanup(shutil.rmtree, self.home, True)
|
|
self.env = {
|
|
**os.environ, "HOME": str(self.home), "RETROBIOS_OS": "linux",
|
|
"RETROBIOS_BASE_URL": f"http://127.0.0.1:{httpd.server_address[1]}",
|
|
}
|
|
|
|
def _run(self, *extra: str) -> subprocess.CompletedProcess:
|
|
return subprocess.run(
|
|
[sys.executable, "install.py", "--platform", "retroarch", "--no-input", *extra],
|
|
cwd=REPO_ROOT, env=self.env, capture_output=True, text=True, timeout=300,
|
|
check=False,
|
|
)
|
|
|
|
def test_check_names_the_files(self):
|
|
dest = self.home / "system"
|
|
self.assertEqual(self._run("--dest", str(dest), "--system", "atari-lynx", "--yes").returncode, 0)
|
|
(dest / "lynxboot.img").write_bytes(b"corrupt")
|
|
proc = self._run("--dest", str(dest), "--system", "atari-lynx", "--check")
|
|
self.assertEqual(proc.returncode, 1)
|
|
self.assertIn("wrong hash: lynxboot.img", proc.stdout)
|
|
self.assertIn("without --check", proc.stdout)
|
|
|
|
def test_a_read_only_folder_is_said_once(self):
|
|
if os.geteuid() == 0:
|
|
self.skipTest("root writes anywhere")
|
|
dest = self.home / "system"
|
|
dest.mkdir()
|
|
dest.chmod(0o500)
|
|
self.addCleanup(dest.chmod, 0o700)
|
|
proc = self._run("--dest", str(dest), "--system", "atari-lynx", "--yes")
|
|
output = proc.stdout + proc.stderr
|
|
self.assertNotEqual(proc.returncode, 0)
|
|
self.assertLessEqual(output.count("FAILED"), 1, output)
|
|
self.assertIn("failed: 2 files (folder not writable)", output)
|
|
|
|
def test_a_piped_log_keeps_its_order(self):
|
|
proc = subprocess.run(
|
|
[sys.executable, "install.py", "--platform", "retroarch", "--no-input",
|
|
"--target", "no-such-target", "--dest", str(self.home / "s")],
|
|
cwd=REPO_ROOT, env=self.env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
text=True, timeout=300, check=False,
|
|
)
|
|
banner = proc.stdout.find("RetroBIOS")
|
|
error = proc.stdout.lower().find("unknown target")
|
|
self.assertGreaterEqual(banner, 0, proc.stdout)
|
|
self.assertGreater(error, banner, proc.stdout)
|
|
|
|
def test_ctrl_c_before_the_downloads_is_a_stop(self):
|
|
import signal # noqa: PLC0415
|
|
import socket # noqa: PLC0415
|
|
|
|
silent = socket.socket()
|
|
silent.bind(("127.0.0.1", 0))
|
|
silent.listen(1)
|
|
self.addCleanup(silent.close)
|
|
env = {**self.env, "RETROBIOS_BASE_URL": f"http://127.0.0.1:{silent.getsockname()[1]}"}
|
|
proc = subprocess.Popen(
|
|
[sys.executable, "install.py", "--platform", "retroarch", "--no-input",
|
|
"--dest", str(self.home / "s")],
|
|
cwd=REPO_ROOT, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
|
)
|
|
import time # noqa: PLC0415
|
|
|
|
time.sleep(3)
|
|
proc.send_signal(signal.SIGINT)
|
|
out, err = proc.communicate(timeout=60)
|
|
self.assertEqual(proc.returncode, 130, out + err)
|
|
self.assertIn("Stopped", out)
|
|
self.assertNotIn("Traceback", err)
|
|
|
|
|
|
class NoFallbackOutsideWhatAPlatformReads(unittest.TestCase):
|
|
"""A forced platform not found here fell back to ~/bios for RetroBat on
|
|
Linux and for BizHawk from the menu, and on Windows to /userdata/bios,
|
|
which resolves to the root of the current drive. Each run reported done."""
|
|
|
|
def test_the_fallback_is_none_where_nothing_is_read(self):
|
|
self.assertIsNone(install._default_dest("linux", "retrobat"))
|
|
self.assertIsNone(install._default_dest("linux", "bizhawk"))
|
|
self.assertIsNone(install._default_dest("windows", "batocera"))
|
|
self.assertIsNone(install._default_dest("windows", "retropie"))
|
|
self.assertEqual(install._default_dest("linux", "batocera"), Path("/userdata/bios"))
|
|
# emudeck-we functions/all.ps1:12-14, not the profile folder.
|
|
self.assertEqual(
|
|
install._default_dest("windows", "emudeck"), install._host_path("C:/Emulation/bios")
|
|
)
|
|
|
|
def test_a_forced_platform_without_a_folder_is_refused(self):
|
|
with unittest.mock.patch.object(install, "detect_platforms", return_value=[]), \
|
|
unittest.mock.patch.object(install, "detect_os", return_value="linux"), \
|
|
unittest.mock.patch("sys.argv", ["install.py", "--platform", "retrobat",
|
|
"--no-input"]), \
|
|
contextlib.redirect_stdout(io.StringIO()), \
|
|
contextlib.redirect_stderr(io.StringIO()) as err, \
|
|
self.assertRaises(SystemExit) as caught:
|
|
install.main()
|
|
self.assertEqual(caught.exception.code, 1)
|
|
self.assertIn("--dest", err.getvalue())
|
|
|
|
|
|
class EveryStandaloneCopyNamesItsMacFolder(unittest.TestCase):
|
|
"""--standalone-copies copied Azahar's keys and Flycast's boot ROM on
|
|
Linux and Windows and silently skipped them on macOS, where both
|
|
emulators read a fixed Application Support folder."""
|
|
|
|
def test_each_copy_has_a_darwin_target(self):
|
|
import yaml
|
|
|
|
registry = yaml.safe_load(
|
|
(Path(__file__).resolve().parents[1] / "platforms" / "_registry.yml").read_text()
|
|
)
|
|
missing = [
|
|
entry.get("file") or entry.get("pattern")
|
|
for entry in registry["_standalone_copies"]
|
|
if entry.get("targets") and "darwin" not in entry["targets"]
|
|
]
|
|
self.assertEqual(missing, [])
|
|
|
|
|
|
|
|
class _LoopbackRun:
|
|
"""install.py against the repository's own manifests over loopback."""
|
|
|
|
def serve(self, test: unittest.TestCase) -> dict:
|
|
handler = functools.partial(
|
|
http.server.SimpleHTTPRequestHandler, directory=str(REPO_ROOT)
|
|
)
|
|
handler.log_message = lambda *args: None
|
|
httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
|
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
|
test.addCleanup(httpd.server_close)
|
|
test.addCleanup(httpd.shutdown)
|
|
self.home = Path(_tempdir(test))
|
|
return {
|
|
**os.environ, "HOME": str(self.home), "RETROBIOS_OS": "linux",
|
|
"RETROBIOS_BASE_URL": f"http://127.0.0.1:{httpd.server_address[1]}",
|
|
}
|
|
|
|
|
|
def _install_run(env: dict, *argv: str, **kwargs) -> subprocess.CompletedProcess:
|
|
return subprocess.run(
|
|
[sys.executable, "install.py", *argv], cwd=REPO_ROOT, env=env,
|
|
capture_output=True, text=True, timeout=300, check=False, **kwargs,
|
|
)
|
|
|
|
|
|
class ATypoGetsTheClosestName(unittest.TestCase):
|
|
"""--platform batocra, --system playstation or --core swanstaton were
|
|
answered with every name the platform has, up to 400 on one line, and
|
|
MiSTer FPGA as the site writes it was refused."""
|
|
|
|
def test_the_names_the_site_writes_are_platforms(self):
|
|
self.assertEqual(install.normalize_platform("MiSTer FPGA"), "misterfpga")
|
|
self.assertEqual(install.normalize_platform("RetroArch "), "retroarch")
|
|
self.assertEqual(install.normalize_platform("RetroDECK"), "retrodeck")
|
|
|
|
def test_a_system_name_stands_for_the_list_it_gets(self):
|
|
"""--list-platforms printed `muOS: retroarch`, and --platform muos
|
|
answered "Unknown platform"."""
|
|
for typed, plat in (("muos", "retroarch"), ("muOS", "retroarch"),
|
|
("ArkOS", "retroarch"), ("PAN4ELEC", "amberelec"),
|
|
("JELOS", "retroarch")):
|
|
out = io.StringIO()
|
|
with self.subTest(typed=typed), contextlib.redirect_stdout(out):
|
|
self.assertEqual(install.normalize_platform(typed), plat)
|
|
self.assertIn(f"--platform {plat}", out.getvalue())
|
|
|
|
def test_a_system_that_gets_no_list_is_still_refused(self):
|
|
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
|
|
install.normalize_platform("TrimUI")
|
|
|
|
def test_a_platform_typo_is_answered_with_one_name(self):
|
|
err = io.StringIO()
|
|
with contextlib.redirect_stderr(err), self.assertRaises(SystemExit) as raised:
|
|
install.normalize_platform("batocra")
|
|
self.assertEqual(raised.exception.code, 1)
|
|
self.assertIn("Did you mean: batocera?", err.getvalue())
|
|
self.assertNotIn("recalbox", err.getvalue())
|
|
|
|
def test_a_system_is_found_inside_its_full_id(self):
|
|
known = ["3do", "nintendo-gba", "sega-saturn", "sony-playstation", "sony-playstation-2"]
|
|
with self.assertRaises(ValueError) as raised:
|
|
install.resolve_choices(["playstation"], known, "system")
|
|
text = str(raised.exception)
|
|
self.assertIn("unknown system: playstation", text)
|
|
self.assertIn("sony-playstation", text)
|
|
self.assertIn("--list-systems", text)
|
|
self.assertNotIn("3do", text)
|
|
|
|
def test_a_core_typo_points_at_the_listing(self):
|
|
known = [f"core{i}" for i in range(200)] + ["swanstation"]
|
|
with self.assertRaises(ValueError) as raised:
|
|
install.resolve_choices(["swanstaton"], known, "core")
|
|
text = str(raised.exception)
|
|
self.assertIn("Did you mean: swanstation?", text)
|
|
self.assertIn("--list-cores lists all 201", text)
|
|
self.assertNotIn("core17", text)
|
|
|
|
def test_a_name_spelled_as_the_site_writes_it(self):
|
|
known = ["sony-playstation", "sony-playstation-2"]
|
|
self.assertEqual(
|
|
install.resolve_choices(["Sony PlayStation"], known, "system"),
|
|
["sony-playstation"],
|
|
)
|
|
|
|
def test_a_target_typo(self):
|
|
err = io.StringIO()
|
|
with contextlib.redirect_stderr(err):
|
|
install._unknown_target("swich", "retroarch", {"switch": {}, "desktop": {}})
|
|
self.assertIn("Did you mean: switch?", err.getvalue())
|
|
self.assertIn("--list-targets", err.getvalue())
|
|
|
|
|
|
class PlatformsAreNamedAsTheyNameThemselves(unittest.TestCase):
|
|
"""str.capitalize() printed Retroarch, Retrodeck and Misterfpga on the
|
|
first screen a user sees."""
|
|
|
|
def test_the_table_is_the_manifests(self):
|
|
for plat in install.AVAILABLE_PLATFORMS:
|
|
manifest = json.loads((REPO_ROOT / "install" / f"{plat}.json").read_text())
|
|
with self.subTest(plat=plat):
|
|
self.assertEqual(install.PLATFORM_NAMES[plat], manifest["display_name"])
|
|
|
|
def test_the_menus_use_them(self):
|
|
out = io.StringIO()
|
|
with unittest.mock.patch("builtins.input", lambda _prompt="": "1"), \
|
|
contextlib.redirect_stdout(out):
|
|
install._prompt_platform_choice(
|
|
[("misterfpga", Path("/m")), ("retrodeck", Path("/r"))])
|
|
self.assertIn("MiSTer FPGA (/m)", out.getvalue())
|
|
self.assertIn("RetroDECK (/r)", out.getvalue())
|
|
self.assertEqual(install.display_name("romm", {"display_name": "RomM"}), "RomM")
|
|
self.assertEqual(install.display_name("romm", {"display_name": "\x1b[2J"}), "RomM")
|
|
|
|
|
|
class HintsGiveTheUsersOwnCommandBack(unittest.TestCase):
|
|
"""A one-liner user was told to run python3 install.py, a file they do
|
|
not have, and the write advice dropped --system, turning a retry of 2
|
|
files into 8069, with sudo on Windows."""
|
|
|
|
def setUp(self):
|
|
patcher = unittest.mock.patch.object(install, "_INVOCATION", install.Invocation())
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
|
|
def _record(self, *argv: str) -> None:
|
|
args = SimpleNamespace(
|
|
system=None, core=None, emulator=None, region=None, target=None,
|
|
cores_dir=None, every_board=False, installed_cores=False,
|
|
standalone_copies=False, check=False, dry_run=False, no_game_data=False,
|
|
declared_only=False,
|
|
)
|
|
flags = list(argv)
|
|
while flags:
|
|
flag = flags.pop(0)
|
|
attr = flag[2:].replace("-", "_")
|
|
if attr in ("system", "core", "emulator", "region"):
|
|
setattr(args, attr, [*(getattr(args, attr) or []), flags.pop(0)])
|
|
else:
|
|
setattr(args, attr, True)
|
|
install._INVOCATION.record(args)
|
|
|
|
def test_the_shell_one_liner_is_rebuilt(self):
|
|
with unittest.mock.patch.dict(os.environ, {"RETROBIOS_BOOTSTRAP": "sh"}):
|
|
hint = "\n".join(install._manual_usage_hint("linux"))
|
|
self.assertIn("install.sh | sh -s -- --platform retroarch", hint)
|
|
self.assertNotIn("python3 install.py", hint)
|
|
self.assertIn(install.INSTALLER_PAGE, hint)
|
|
|
|
def test_a_clone_keeps_python(self):
|
|
with unittest.mock.patch.dict(os.environ, {"RETROBIOS_BOOTSTRAP": ""}):
|
|
hint = "\n".join(install._manual_usage_hint("linux"))
|
|
self.assertIn("python3 install.py --platform retroarch", hint)
|
|
|
|
def test_a_listing_hint_keeps_the_listing(self):
|
|
"""--list-systems with nothing detected was answered with a full
|
|
install command: the listing flag gone, a --dest added."""
|
|
home = Path(_tempdir(self))
|
|
env = {**os.environ, "HOME": str(home), "RETROBIOS_OS": "linux",
|
|
"RETROBIOS_BOOTSTRAP": ""}
|
|
for flag in ("--list-systems", "--list-cores", "--list-emulators", "--list-targets"):
|
|
with self.subTest(flag=flag):
|
|
proc = _install_run(env, flag, "--no-input")
|
|
self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr)
|
|
self.assertIn(f"python3 install.py --platform retroarch {flag}", proc.stdout)
|
|
self.assertNotIn("--dest", proc.stdout)
|
|
|
|
def test_the_write_advice_keeps_the_narrowing(self):
|
|
self._record("--system", "atari-lynx", "--region", "us")
|
|
install._INVOCATION.os_type = "linux"
|
|
with unittest.mock.patch.dict(os.environ, {"RETROBIOS_BOOTSTRAP": "sh"}):
|
|
advice = install._write_advice(Path("/media/ro"), False, "retroarch")
|
|
self.assertIn("| sudo sh -s -- --platform retroarch --dest /media/ro "
|
|
"--system atari-lynx --region us", advice)
|
|
|
|
def test_the_platforms_own_list_is_kept_in_the_hint(self):
|
|
self._record("--declared-only", "--system", "atari-lynx")
|
|
install._INVOCATION.os_type = "linux"
|
|
with unittest.mock.patch.dict(os.environ, {"RETROBIOS_BOOTSTRAP": ""}):
|
|
command = install._INVOCATION.command("retroarch", None)
|
|
self.assertIn("--system atari-lynx --declared-only", command)
|
|
|
|
def test_windows_has_no_sudo(self):
|
|
self._record("--system", "atari-lynx")
|
|
install._INVOCATION.os_type = "windows"
|
|
with unittest.mock.patch.dict(os.environ, {"RETROBIOS_BOOTSTRAP": "ps1"}):
|
|
advice = install._write_advice(Path("C:/Program Files/RetroArch/system"),
|
|
False, "retroarch")
|
|
self.assertNotIn("sudo", advice)
|
|
self.assertIn("administrator", advice)
|
|
self.assertIn("install.ps1", advice)
|
|
self.assertIn("--system atari-lynx", advice)
|
|
|
|
def test_a_full_drive_keeps_the_platform(self):
|
|
install._INVOCATION.os_type = "linux"
|
|
err = io.StringIO()
|
|
with unittest.mock.patch.object(install.shutil, "disk_usage",
|
|
return_value=SimpleNamespace(free=1)), \
|
|
contextlib.redirect_stderr(err), self.assertRaises(SystemExit):
|
|
install._check_free_space(Path(_tempdir(self)), 10, "batocera")
|
|
self.assertIn("--platform batocera --dest <folder on another drive>", err.getvalue())
|
|
|
|
def test_the_one_liner_tells_the_installer_how_it_was_started(self):
|
|
scratch = REPO_ROOT / "tmp" / "tests"
|
|
scratch.mkdir(parents=True, exist_ok=True)
|
|
with tempfile.TemporaryDirectory(dir=scratch) as directory:
|
|
root = Path(directory)
|
|
probe = root / "probe.py"
|
|
probe.write_text("import os\nprint('bootstrap:', os.environ.get('RETROBIOS_BOOTSTRAP'))\n")
|
|
fake_bin = root / "bin"
|
|
fake_bin.mkdir()
|
|
(fake_bin / "curl").write_text(
|
|
"#!/bin/sh\noutput=\n"
|
|
"while [ \"$#\" -gt 0 ]; do\n"
|
|
" if [ \"$1\" = --output ]; then output=$2; shift 2; else shift; fi\n"
|
|
"done\n"
|
|
f"cp -- {shlex.quote(str(probe))} \"$output\"\n")
|
|
(fake_bin / "curl").chmod(0o755)
|
|
env = {**os.environ, "PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}",
|
|
"TMPDIR": str(root),
|
|
"RETROBIOS_INSTALL_URL": "https://example.invalid/install.py",
|
|
"RETROBIOS_INSTALL_SHA256": hashlib.sha256(probe.read_bytes()).hexdigest()}
|
|
proc = subprocess.run(["sh", "-s"], cwd=root, env=env, capture_output=True,
|
|
input=(REPO_ROOT / "install.sh").read_text(),
|
|
text=True, timeout=30, check=False)
|
|
self.assertIn("bootstrap: sh", proc.stdout, proc.stderr)
|
|
|
|
def test_the_help_has_examples_and_the_page(self):
|
|
env = {**os.environ, "RETROBIOS_BOOTSTRAP": "sh"}
|
|
proc = _install_run(env, "--help")
|
|
self.assertEqual(proc.returncode, 0)
|
|
self.assertIn("examples:", proc.stdout)
|
|
self.assertIn("install.sh | sh -s -- --platform batocera --dest", proc.stdout)
|
|
self.assertIn(install.INSTALLER_PAGE, proc.stdout)
|
|
|
|
|
|
class AnOverrideIsNamedWhenItIsTheCause(unittest.TestCase):
|
|
"""A RETROBIOS_INSTALL_SHA256 of the user's own read as a cut download,
|
|
and a dead RETROBIOS_BASE_URL as a network fault naming no host."""
|
|
|
|
def test_an_unreachable_base_url_is_named(self):
|
|
error = install.urllib.error.URLError("timed out")
|
|
with unittest.mock.patch.dict(os.environ, {"RETROBIOS_BASE_URL": "https://mirror.invalid"}), \
|
|
unittest.mock.patch.object(install, "_fetch_json", side_effect=error), \
|
|
contextlib.redirect_stderr(io.StringIO()) as err, \
|
|
self.assertRaises(SystemExit):
|
|
install.fetch_manifest("retroarch")
|
|
text = err.getvalue()
|
|
self.assertIn("Address: " + install.MANIFEST_URL.format(platform="retroarch"), text)
|
|
self.assertIn("RETROBIOS_BASE_URL is set", text)
|
|
|
|
def test_a_pinned_fingerprint_is_named(self):
|
|
scratch = REPO_ROOT / "tmp" / "tests"
|
|
scratch.mkdir(parents=True, exist_ok=True)
|
|
with tempfile.TemporaryDirectory(dir=scratch) as directory:
|
|
root = Path(directory)
|
|
fake_bin = root / "bin"
|
|
fake_bin.mkdir()
|
|
(fake_bin / "curl").write_text(
|
|
"#!/bin/sh\noutput=\n"
|
|
"while [ \"$#\" -gt 0 ]; do\n"
|
|
" if [ \"$1\" = --output ]; then output=$2; shift 2; else shift; fi\n"
|
|
"done\n"
|
|
"echo 'print(1)' > \"$output\"\n")
|
|
(fake_bin / "curl").chmod(0o755)
|
|
env = {**os.environ, "PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}",
|
|
"TMPDIR": str(root), "RETROBIOS_INSTALL_SHA256": "0" * 64}
|
|
proc = subprocess.run(["sh", "-s"], cwd=root, env=env, capture_output=True,
|
|
input=(REPO_ROOT / "install.sh").read_text(),
|
|
text=True, timeout=30, check=False)
|
|
self.assertEqual(proc.returncode, 1)
|
|
self.assertIn("RETROBIOS_INSTALL_SHA256 is set", proc.stderr)
|
|
self.assertNotIn("cut short", proc.stderr)
|
|
ps1 = (REPO_ROOT / "install.ps1").read_text()
|
|
self.assertIn('"RETROBIOS_INSTALL_URL", "RETROBIOS_INSTALL_SHA256"', ps1)
|
|
|
|
|
|
@unittest.skipUnless(shutil.which("pwsh"), "needs PowerShell")
|
|
class ThePowerShellOneLinerLeavesTheSessionAlone(unittest.TestCase):
|
|
"""irm | iex left $ErrorActionPreference = Stop and the script's
|
|
variables in the user's session: their next error stopped everything."""
|
|
|
|
def test_nothing_is_left_behind(self):
|
|
script = (
|
|
"Get-Content install.ps1 -Raw | Invoke-Expression; "
|
|
"\"EAP=$ErrorActionPreference temporary=[$temporary] installer=[$installer] "
|
|
"bootstrap=[$env:RETROBIOS_BOOTSTRAP]\""
|
|
)
|
|
# Refused before any request, so the run needs no network.
|
|
env = {**os.environ, "RETROBIOS_INSTALL_URL": "http://127.0.0.1/install.py"}
|
|
env.pop("RETROBIOS_BOOTSTRAP", None)
|
|
proc = subprocess.run(["pwsh", "-NoProfile", "-Command", script], cwd=REPO_ROOT,
|
|
env=env, capture_output=True, text=True, timeout=120)
|
|
self.assertIn("must use HTTPS", proc.stderr)
|
|
self.assertIn("EAP=Continue temporary=[] installer=[] bootstrap=[]", proc.stdout)
|
|
|
|
|
|
class AQuestionLeftWithCtrlDOrCtrlCIsAStop(unittest.TestCase):
|
|
"""Ctrl+C or Ctrl+D at a question left with 0, so `install.sh && retroarch`
|
|
carried on with no BIOS, while the page documents 130."""
|
|
|
|
def test_eof_raises_the_stop(self):
|
|
def closed(_prompt=""):
|
|
raise EOFError
|
|
|
|
with unittest.mock.patch("builtins.input", closed), \
|
|
contextlib.redirect_stdout(io.StringIO()), \
|
|
self.assertRaises(KeyboardInterrupt):
|
|
install._ask("> ")
|
|
|
|
@unittest.skipIf(os.name == "nt", "needs a pseudo-terminal")
|
|
def test_ctrl_d_at_the_platform_question_exits_130(self):
|
|
import pty # noqa: PLC0415
|
|
import time # noqa: PLC0415
|
|
|
|
home = Path(_tempdir(self))
|
|
master, slave = pty.openpty()
|
|
proc = subprocess.Popen(
|
|
[sys.executable, "install.py"], cwd=REPO_ROOT, stdin=slave,
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
|
env={**os.environ, "HOME": str(home), "RETROBIOS_OS": "linux"},
|
|
)
|
|
os.close(slave)
|
|
self.addCleanup(os.close, master)
|
|
time.sleep(3)
|
|
os.write(master, b"\x04")
|
|
out, _ = proc.communicate(timeout=60)
|
|
if "Choose the platform" not in out:
|
|
self.skipTest("a platform was detected on this machine")
|
|
self.assertEqual(proc.returncode, 130, out)
|
|
self.assertIn("Stopped", out)
|
|
|
|
|
|
class WhatTheCollectionLacksSaysForWhom(unittest.TestCase):
|
|
"""Required files the installer cannot supply were listed by bare name:
|
|
DARK.GOB, Data.rsdk, ... and 69 more, with no system and no emulator."""
|
|
|
|
def test_grouped_by_system_with_its_emulators(self):
|
|
entries = [
|
|
{"dest": "DARK.GOB", "system": "dark-forces", "cores": ["theforceengine"]},
|
|
{"dest": "SOUNDS.GOB", "system": "dark-forces", "cores": ["theforceengine"]},
|
|
{"dest": "Data.rsdk", "system": "sonic-mania", "cores": ["sonicmania"]},
|
|
]
|
|
lines = install._needed_lines(entries, "Needed to start an emulator")
|
|
self.assertIn(" dark-forces (theforceengine): DARK.GOB, SOUNDS.GOB", lines)
|
|
self.assertIn(" sonic-mania (sonicmania): Data.rsdk", lines)
|
|
self.assertIn("(3 files)", lines[0])
|
|
self.assertIn(f"{install.SITE_URL}emulators/", lines[-1])
|
|
|
|
|
|
class RunsAgainstTheRealLists(unittest.TestCase, _LoopbackRun):
|
|
"""Behaviour read end to end over loopback."""
|
|
|
|
def setUp(self):
|
|
self.env = self.serve(self)
|
|
|
|
def test_a_wrong_hash_is_listed_first(self):
|
|
dest = self.home / "system"
|
|
dest.mkdir()
|
|
(dest / "scph5501.bin").write_bytes(b"junk")
|
|
proc = _install_run(self.env, "--platform", "retroarch", "--dest", str(dest),
|
|
"--check", "--no-input")
|
|
self.assertEqual(proc.returncode, 1, proc.stderr)
|
|
listing = proc.stdout.split("to download:\n", 1)[1].splitlines()
|
|
self.assertEqual(listing[0].strip(), "wrong hash: scph5501.bin")
|
|
|
|
def test_a_dry_run_counts_the_data_trees_and_ends_on_its_last_line(self):
|
|
proc = _install_run(self.env, "--platform", "retroarch", "--dest",
|
|
str(self.home / "s"), "--dry-run", "--no-input")
|
|
self.assertEqual(proc.returncode, 0, proc.stderr)
|
|
lines = [line for line in proc.stdout.splitlines() if line.strip()]
|
|
self.assertIn("Nothing was written", lines[-1])
|
|
header = next(line for line in lines if line.startswith("Would download"))
|
|
self.assertIn("data files", header)
|
|
|
|
def test_dest_alone_says_whose_list_it_is(self):
|
|
proc = _install_run(self.env, "--dest", str(self.home / "card"), "--dry-run",
|
|
"--no-input", "--system", "atari-lynx")
|
|
self.assertEqual(proc.returncode, 0, proc.stderr)
|
|
self.assertIn("File list: RetroArch", proc.stdout)
|
|
self.assertIn("--platform", proc.stdout)
|
|
|
|
def test_a_cores_folder_can_be_named(self):
|
|
cores = self.home / "cores"
|
|
cores.mkdir()
|
|
(cores / "handy_libretro.so").write_bytes(b"")
|
|
proc = _install_run(self.env, "--platform", "retroarch", "--dest",
|
|
str(self.home / "s"), "--cores-dir", str(cores),
|
|
"--dry-run", "--no-input")
|
|
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
|
self.assertIn("to the installed cores", proc.stdout)
|
|
|
|
def test_no_core_found_says_how_to_go_on(self):
|
|
proc = _install_run(self.env, "--platform", "retroarch", "--dest",
|
|
str(self.home / "s"), "--installed-cores", "--dry-run",
|
|
"--no-input")
|
|
self.assertEqual(proc.returncode, 1)
|
|
self.assertIn("--cores-dir <cores folder>", proc.stderr)
|
|
self.assertIn("without --installed-cores", proc.stderr)
|
|
|
|
|
|
class DestAloneFollowsWhatIsFoundHere(unittest.TestCase):
|
|
"""--dest without --platform always meant RetroArch's list, even on a
|
|
machine where Batocera was found, and nothing said so."""
|
|
|
|
def _pick(self, found, interactive=False, answer=""):
|
|
out = io.StringIO()
|
|
with unittest.mock.patch.object(install, "detect_platforms", return_value=found), \
|
|
unittest.mock.patch("builtins.input", lambda _prompt="": answer), \
|
|
contextlib.redirect_stdout(out), contextlib.redirect_stderr(out):
|
|
try:
|
|
return install._platform_for_dest("linux", Path("/media/card"), interactive), out.getvalue()
|
|
except SystemExit as exc:
|
|
return exc, out.getvalue()
|
|
|
|
def test_nothing_found_is_retroarch_said_aloud(self):
|
|
plat, out = self._pick([])
|
|
self.assertEqual(plat, "retroarch")
|
|
self.assertIn("File list: RetroArch (nothing was detected here)", out)
|
|
|
|
def test_the_one_platform_found_is_used(self):
|
|
plat, out = self._pick([("batocera", Path("/userdata/bios"))])
|
|
self.assertEqual(plat, "batocera")
|
|
self.assertIn("File list: Batocera", out)
|
|
|
|
def test_several_found_without_a_terminal_is_refused(self):
|
|
result, out = self._pick([("emudeck", Path("/e")), ("retroarch", Path("/r"))])
|
|
self.assertIsInstance(result, SystemExit)
|
|
self.assertEqual(result.code, 1)
|
|
self.assertIn("--platform", out)
|
|
|
|
def test_a_terminal_asks_and_enter_keeps_the_one_found(self):
|
|
plat, out = self._pick([("batocera", Path("/userdata/bios"))], interactive=True)
|
|
self.assertEqual(plat, "batocera")
|
|
self.assertIn("Which platform's files go to /media/card?", out)
|
|
|
|
|
|
class _FakeResponse:
|
|
"""What urlopen returns, serving one payload from its start."""
|
|
|
|
def __init__(self, payload: bytes) -> None:
|
|
self.status = 200
|
|
self.headers = {"Content-Length": str(len(payload))}
|
|
self._body = io.BytesIO(payload)
|
|
|
|
def __enter__(self) -> _FakeResponse:
|
|
return self
|
|
|
|
def __exit__(self, *_exc: object) -> None:
|
|
return None
|
|
|
|
def read(self, size: int = -1) -> bytes:
|
|
return self._body.read(size)
|
|
|
|
|
|
def _sharing_violation() -> PermissionError:
|
|
exc = PermissionError(13, "The process cannot access the file")
|
|
exc.winerror = 32
|
|
return exc
|
|
|
|
|
|
class ACompletePartialIsHandedOffOnWindows(unittest.TestCase):
|
|
"""Windows renames no open file, so the run that verified a large copy
|
|
released its lock before the rename. A second run claimed the copy in
|
|
that moment, found it complete and truncated it, and the first run's
|
|
rename failed with a sharing violation reported as "folder not
|
|
writable"."""
|
|
|
|
PAYLOAD = bytes(range(256)) * 200
|
|
ENTRY = {
|
|
"dest": "big.bin", "repo_path": "bios/big.bin", "size": len(PAYLOAD),
|
|
"sha256": hashlib.sha256(PAYLOAD).hexdigest(),
|
|
}
|
|
|
|
def _run(self, tmp: Path, seen: list[int]):
|
|
part = tmp / ".big.bin.part"
|
|
|
|
def urlopen(request, timeout=None):
|
|
seen.append(part.stat().st_size if part.exists() else -1)
|
|
return _FakeResponse(self.PAYLOAD)
|
|
|
|
with unittest.mock.patch.object(install, "RESUME_FROM_SIZE", 1024), \
|
|
unittest.mock.patch.object(install, "_NO_RENAME_WHILE_OPEN", True), \
|
|
unittest.mock.patch.object(install.urllib.request, "urlopen", urlopen):
|
|
return install._download_one(dict(self.ENTRY), tmp)
|
|
|
|
def test_the_other_runs_copy_is_left_whole(self):
|
|
tmp = Path(_tempdir(self))
|
|
(tmp / ".big.bin.part").write_bytes(self.PAYLOAD)
|
|
seen: list[int] = []
|
|
self.assertEqual(self._run(tmp, seen), ("big.bin", True, ""))
|
|
# The download ran while the complete copy was still whole.
|
|
self.assertEqual(seen, [len(self.PAYLOAD)])
|
|
self.assertEqual((tmp / "big.bin").read_bytes(), self.PAYLOAD)
|
|
# Nothing held it afterwards: it was a leftover, now removed.
|
|
self.assertFalse((tmp / ".big.bin.part").exists())
|
|
|
|
def test_the_rename_waits_for_the_other_run_to_let_go(self):
|
|
tmp = Path(_tempdir(self))
|
|
source = tmp / "copy"
|
|
source.write_bytes(b"x")
|
|
real_replace = os.replace
|
|
calls = []
|
|
|
|
def replace(src, dst):
|
|
calls.append(src)
|
|
if len(calls) < 3:
|
|
raise _sharing_violation()
|
|
return real_replace(src, dst)
|
|
|
|
with unittest.mock.patch.object(install.os, "replace", replace), \
|
|
unittest.mock.patch.object(install.time, "sleep"):
|
|
install._replace_released(source, tmp / "dest", {})
|
|
self.assertEqual(len(calls), 3)
|
|
self.assertEqual((tmp / "dest").read_bytes(), b"x")
|
|
|
|
def test_a_copy_the_other_run_placed_counts_once_verified(self):
|
|
tmp = Path(_tempdir(self))
|
|
dest = tmp / "dest"
|
|
dest.write_bytes(self.PAYLOAD)
|
|
expected = {"sha256": self.ENTRY["sha256"]}
|
|
install._replace_released(tmp / "gone", dest, expected)
|
|
dest.write_bytes(b"other bytes")
|
|
with self.assertRaises(FileNotFoundError):
|
|
install._replace_released(tmp / "gone", dest, expected)
|
|
|
|
def test_a_sharing_violation_is_no_refused_folder(self):
|
|
self.assertFalse(install._write_refused(_sharing_violation()))
|
|
self.assertTrue(install._write_refused(PermissionError(13, "denied")))
|
|
|
|
|
|
class StandaloneFoldersAreNamedWhenThereAreAny(unittest.TestCase, _LoopbackRun):
|
|
"""'Standalone copies skipped' closed every install, on a machine with
|
|
no standalone emulator as on one whose DuckStation lacked its BIOS."""
|
|
|
|
def setUp(self):
|
|
self.env = self.serve(self)
|
|
|
|
def _run(self) -> str:
|
|
proc = _install_run(self.env, "--platform", "retroarch", "--dest",
|
|
str(self.home / "system"), "--system", "atari-lynx",
|
|
"--no-input")
|
|
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
|
return proc.stdout
|
|
|
|
def test_nothing_is_said_without_an_emulator_folder(self):
|
|
out = self._run()
|
|
self.assertNotIn("skipped", out)
|
|
self.assertNotIn("--standalone-copies", out)
|
|
|
|
def test_a_found_folder_is_named(self):
|
|
duckstation = self.home / ".local" / "share" / "duckstation" / "bios"
|
|
duckstation.mkdir(parents=True)
|
|
out = self._run()
|
|
self.assertIn("Standalone emulator folders found here:", out)
|
|
self.assertIn(str(duckstation), out)
|
|
self.assertIn("--standalone-copies", out)
|
|
|
|
|
|
class StandaloneEmulatorsWithoutAPlatformAreServed(unittest.TestCase, _LoopbackRun):
|
|
"""ES-DE with DuckStation and PCSX2 and no RetroArch: the run said 'No
|
|
supported platform detected' and offered RetroArch's folder, which no
|
|
emulator on the machine reads."""
|
|
|
|
def setUp(self):
|
|
self.env = self.serve(self)
|
|
(self.home / "ES-DE").mkdir()
|
|
self.duckstation = self.home / ".local" / "share" / "duckstation" / "bios"
|
|
self.duckstation.mkdir(parents=True)
|
|
|
|
def test_the_emulator_folder_is_filled(self):
|
|
proc = _install_run(self.env, "--no-input", "--dry-run")
|
|
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
|
self.assertIn("Found standalone emulator folders:", proc.stdout)
|
|
self.assertIn(f"to {self.duckstation}:", proc.stdout)
|
|
self.assertNotIn("--platform retroarch --dest", proc.stdout)
|
|
proc = _install_run(self.env, "--no-input")
|
|
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
|
names = sorted(path.name for path in self.duckstation.iterdir())
|
|
self.assertIn("scph5501.bin", names)
|
|
self.assertTrue(all(name.startswith("scph") for name in names), names)
|
|
proc = _install_run(self.env, "--no-input", "--check")
|
|
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
|
self.assertIn("All files up to date.", proc.stdout)
|
|
|
|
def test_no_emulator_folder_keeps_the_hint(self):
|
|
self.duckstation.rmdir()
|
|
proc = _install_run(self.env, "--no-input")
|
|
self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr)
|
|
self.assertIn("--platform retroarch --dest", proc.stdout)
|
|
|
|
|
|
class TheEmulatorNamesHaveTheirOwnListing(unittest.TestCase, _LoopbackRun):
|
|
"""--emulator nope said "--list-cores lists all 441" and --list-cores
|
|
printed 189: the buildbot names --emulator accepts were listed nowhere."""
|
|
|
|
def setUp(self):
|
|
self.env = self.serve(self)
|
|
|
|
def test_the_hint_counts_what_the_listing_prints(self):
|
|
proc = _install_run(self.env, "--platform", "retroarch", "--no-input",
|
|
"--emulator", "nope")
|
|
self.assertEqual(proc.returncode, 1, proc.stdout)
|
|
found = re.search(r"--list-emulators lists all (\d+)\.", proc.stderr)
|
|
self.assertIsNotNone(found, proc.stderr)
|
|
listing = _install_run(self.env, "--platform", "retroarch", "--no-input",
|
|
"--list-emulators")
|
|
self.assertEqual(listing.returncode, 0, listing.stderr)
|
|
names = [re.split(r"\s{2,}", line.strip())[0]
|
|
for line in listing.stdout.splitlines() if line.startswith(" ")]
|
|
manifest = json.loads((REPO_ROOT / "install" / "retroarch.json").read_text())
|
|
accepted = install.emulator_names(
|
|
manifest, manifest["files"], manifest.get("omitted_files", []))
|
|
self.assertEqual(int(found.group(1)), len(accepted))
|
|
self.assertTrue(set(accepted) <= set(names), set(accepted) - set(names))
|
|
self.assertIn("mednafen_psx", accepted)
|
|
self.assertRegex(listing.stdout, r"mednafen_psx\s+same as beetle_psx")
|
|
|
|
def test_every_listed_name_is_accepted(self):
|
|
manifest = {"core_names": {"beetle_psx": ["mednafen_psx"]}}
|
|
files = [{"dest": "a", "cores": ["pcsx_rearmed"]}]
|
|
names = install.emulator_names(manifest, files, [])
|
|
self.assertEqual(names, {"beetle_psx": "beetle_psx", "mednafen_psx": "beetle_psx",
|
|
"pcsx_rearmed": "pcsx_rearmed"})
|
|
self.assertEqual(install.resolve_choices(["MEDNAFEN_PSX"], sorted(names), "emulator"),
|
|
["mednafen_psx"])
|
|
|
|
|
|
class ANetworkFailureNamesTheNetwork(unittest.TestCase):
|
|
"""Every transfer cut by the connection ended on "Location that was being
|
|
written to" and "once that is resolved", naming the folder and nothing
|
|
about the connection or the proxy."""
|
|
|
|
def _fail(self, error: BaseException) -> str:
|
|
def urlopen(*_args, **_kwargs):
|
|
raise error
|
|
|
|
entry = {"dest": "a.bin", "repo_path": "bios/a.bin", "size": 10,
|
|
"sha256": "0" * 64}
|
|
with unittest.mock.patch.object(install.urllib.request, "urlopen", urlopen), \
|
|
unittest.mock.patch.object(install, "_retry_delay", return_value=0):
|
|
_dest, ok, reason = install._download_one(entry, Path(_tempdir(self)))
|
|
self.assertFalse(ok)
|
|
return reason
|
|
|
|
def test_cuts_and_refusals_are_network_failures(self):
|
|
import http.client
|
|
import socket
|
|
|
|
for error in (
|
|
urllib.error.URLError(socket.gaierror(-2, "Name or service not known")),
|
|
http.client.IncompleteRead(b"abc", 10),
|
|
ConnectionResetError(104, "Connection reset by peer"),
|
|
socket.timeout("timed out"),
|
|
urllib.error.HTTPError("u", 503, "Service Unavailable", None, None),
|
|
):
|
|
with self.subTest(error=error):
|
|
self.assertIsInstance(self._fail(error), install.NetworkReason)
|
|
|
|
def test_a_full_disk_and_a_missing_file_are_not(self):
|
|
import errno
|
|
|
|
for error in (OSError(errno.ENOSPC, "No space left on device"),
|
|
urllib.error.HTTPError("u", 404, "Not Found", None, None)):
|
|
with self.subTest(error=error):
|
|
self.assertNotIsInstance(self._fail(error), install.NetworkReason)
|
|
|
|
def test_the_summary_points_at_the_connection(self):
|
|
failures = [("a.bin", install.NetworkReason("timed out")),
|
|
("b.bin", install.NetworkReason("Connection reset by peer"))]
|
|
out = io.StringIO()
|
|
with contextlib.redirect_stdout(out), self.assertRaises(SystemExit) as raised:
|
|
install._report_outcome([Path("/bios")], 0, 0, 2, 0, False, failures=failures)
|
|
self.assertEqual(raised.exception.code, 2)
|
|
text = out.getvalue()
|
|
self.assertIn("HTTPS_PROXY", text)
|
|
self.assertIn("Destination: /bios", text)
|
|
self.assertNotIn("being written to", text)
|
|
|
|
|
|
class RetroBatIsFoundWhereItSaysItIs(unittest.TestCase):
|
|
"""RetroBat on D:\\RetroBat was not detected, and --platform retrobat
|
|
wrote 7.6 GB into C:\\RetroBat\\bios, a folder it does not read.
|
|
RetroBat.exe writes its folder to the registry on every start."""
|
|
|
|
def test_the_table_is_the_registry(self):
|
|
import yaml # noqa: PLC0415
|
|
|
|
registry = yaml.safe_load((REPO_ROOT / "platforms" / "_registry.yml").read_text())
|
|
rules = {
|
|
(rule["os"], name, rule["key"], rule["value"], rule["bios_subdir"])
|
|
for name, entry in registry["platforms"].items()
|
|
for rule in (entry.get("install") or {}).get("detect", [])
|
|
if rule["method"] == "registry"
|
|
}
|
|
self.assertEqual(set(install._REGISTRY_RULES), rules)
|
|
|
|
def test_the_registry_folder_wins_over_the_default(self):
|
|
folder = Path(_tempdir(self))
|
|
with unittest.mock.patch.object(install, "_read_registry",
|
|
return_value=str(folder) + "\\"):
|
|
found: list = []
|
|
install._detect_by_registry("windows", found)
|
|
self.assertEqual(found, [("retrobat", folder / "bios")])
|
|
|
|
def test_a_folder_since_removed_is_passed_over(self):
|
|
gone = Path(_tempdir(self)) / "moved"
|
|
with unittest.mock.patch.object(install, "_read_registry", return_value=str(gone)):
|
|
found: list = []
|
|
install._detect_by_registry("windows", found)
|
|
self.assertEqual(found, [])
|
|
|
|
def test_wsl_reads_it_through_reg_exe(self):
|
|
output = ("\nHKEY_CURRENT_USER\\SOFTWARE\\RetroBat\n"
|
|
" LatestKnownInstallPath REG_SZ D:\\Games\\RetroBat\\\n\n")
|
|
with unittest.mock.patch.dict(install._WSL, {"active": True, "root": "/mnt"}), \
|
|
unittest.mock.patch.object(install.os, "name", "posix"), \
|
|
unittest.mock.patch.object(
|
|
install.subprocess, "run",
|
|
return_value=SimpleNamespace(stdout=output, returncode=0)) as run:
|
|
value = install._read_registry("HKCU\\SOFTWARE\\RetroBat", "LatestKnownInstallPath")
|
|
self.assertEqual(value, "D:\\Games\\RetroBat\\")
|
|
self.assertEqual(install._host_path(value.rstrip("\\")),
|
|
Path("/mnt/d/Games/RetroBat"))
|
|
self.assertEqual(run.call_args[0][0][:2], ["reg.exe", "query"])
|
|
|
|
|
|
class EmuDeckWindowsIsFoundAsItsScriptsFindIt(unittest.TestCase):
|
|
"""EmuDeck's Windows scripts read %APPDATA%\\EmuDeck\\settings.ps1, else
|
|
%USERPROFILE%\\EmuDeck\\settings.ps1, and take C:\\Emulation when neither
|
|
names a folder (functions/all.ps1:4-14). Only the first was read, and a
|
|
forced --platform emudeck wrote into %USERPROFILE%\\Emulation\\bios."""
|
|
|
|
def _env(self) -> tuple[Path, Path]:
|
|
root = Path(_tempdir(self))
|
|
appdata, profile = root / "appdata", root / "profile"
|
|
patcher = unittest.mock.patch.dict(
|
|
os.environ, {"APPDATA": str(appdata), "USERPROFILE": str(profile)})
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
return appdata, profile
|
|
|
|
@staticmethod
|
|
def _settings(folder: Path, text: str, encoding: str = "utf-8-sig") -> None:
|
|
(folder / "EmuDeck").mkdir(parents=True, exist_ok=True)
|
|
(folder / "EmuDeck" / "settings.ps1").write_bytes(text.encode(encoding))
|
|
|
|
def test_the_profile_settings_are_read_when_appdata_has_none(self):
|
|
_appdata, profile = self._env()
|
|
self._settings(profile, '\n$emulationPath="/games/Emulation"\n')
|
|
self.assertEqual(install._emudeck_windows_bios(), Path("/games/Emulation/bios"))
|
|
|
|
def test_appdata_comes_first(self):
|
|
appdata, profile = self._env()
|
|
self._settings(profile, '$emulationPath="/b/Emulation"\n')
|
|
self._settings(appdata, '$emulationPath="/a/Emulation"\n', "utf-16")
|
|
self.assertEqual(install._emudeck_windows_bios(), Path("/a/Emulation/bios"))
|
|
|
|
def test_settings_naming_no_folder_mean_c_emulation(self):
|
|
appdata, _profile = self._env()
|
|
self._settings(appdata, '$device="Windows"\n')
|
|
self.assertEqual(install._emudeck_windows_bios(),
|
|
install._host_path("C:\\Emulation") / "bios")
|
|
|
|
def test_a_network_install_is_its_share(self):
|
|
appdata, _profile = self._env()
|
|
self._settings(appdata, '$networkInstallation="true"\n'
|
|
'$VolName="/nas/games"\n'
|
|
'$emulationPath=$testdrive.ToString() + ":\\Emulation"\n')
|
|
self.assertEqual(install._emudeck_windows_bios(),
|
|
Path("/nas/games\\Emulation") / "bios")
|
|
|
|
def test_no_settings_detects_nothing(self):
|
|
self._env()
|
|
self.assertIsNone(install._emudeck_windows_bios())
|
|
|
|
def test_a_forced_platform_falls_back_to_c_emulation(self):
|
|
self.assertEqual(install._default_dest("windows", "emudeck"),
|
|
install._host_path("C:/Emulation/bios"))
|
|
|
|
|
|
def _device(test: unittest.TestCase, files: dict[str, str],
|
|
release: str = "", home: str = "/root") -> Path:
|
|
"""A fake root holding these files, read by the embedded rules."""
|
|
root = Path(_tempdir(test))
|
|
if release:
|
|
files = {**files, "/etc/os-release": release}
|
|
for name, text in files.items():
|
|
path = root / name.lstrip("/")
|
|
if name.endswith("/"):
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
continue
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(text)
|
|
for patcher in (
|
|
unittest.mock.patch.object(install, "_SYSTEM_ROOT", root),
|
|
unittest.mock.patch.dict(os.environ, {"HOME": home}),
|
|
unittest.mock.patch.dict(install._RETROARCH_CONFIGS, clear=True),
|
|
unittest.mock.patch.dict(install._DETECTED_OS, clear=True),
|
|
):
|
|
patcher.start()
|
|
test.addCleanup(patcher.stop)
|
|
return root
|
|
|
|
|
|
class MuOsIsReadFromItsRetroArchConfig(unittest.TestCase):
|
|
"""muOS wrote to /mnt/mmc/MUOS/bios while RetroArch reads
|
|
/run/muos/storage/bios, bound to the second card when it holds MUOS/bios,
|
|
and --installed-cores found no core in /opt/muos/share/core."""
|
|
|
|
USER = "/opt/muos/share/info/config/retroarch.cfg"
|
|
DEFAULT = "/opt/muos/share/emulator/retroarch/retroarch.default.cfg"
|
|
|
|
def _muos(self, files: dict[str, str]) -> Path:
|
|
root = _device(self, {"/opt/muos/": "", **files})
|
|
found = install._detect_embedded()
|
|
self.assertEqual(len(found), 1)
|
|
self.assertEqual(install.detected_os(*found[0]), "muOS")
|
|
return root
|
|
|
|
def test_the_users_config_decides(self):
|
|
root = self._muos({
|
|
self.USER: 'libretro_directory = "/opt/muos/share/core"\n'
|
|
'system_directory = "/mnt/sdcard/MUOS/bios"\n',
|
|
self.DEFAULT: 'system_directory = "/run/muos/storage/bios"\n',
|
|
})
|
|
self.assertEqual(install._detect_embedded(),
|
|
[("retroarch", root / "mnt/sdcard/MUOS/bios")])
|
|
self.assertIn(Path("/opt/muos/share/core"),
|
|
install.core_dirs("retroarch", root / "mnt/sdcard/MUOS/bios"))
|
|
|
|
def test_before_the_first_launch_the_default_does(self):
|
|
root = self._muos({self.DEFAULT: 'system_directory = "/run/muos/storage/bios"\n'})
|
|
self.assertEqual(install._detect_embedded(),
|
|
[("retroarch", root / "run/muos/storage/bios")])
|
|
|
|
def test_without_a_config_the_bound_folder_is_used(self):
|
|
root = self._muos({})
|
|
self.assertEqual(install._detect_embedded(),
|
|
[("retroarch", root / "run/muos/storage/bios")])
|
|
|
|
|
|
class HandheldSystemsByTheirOwnFiles(unittest.TestCase):
|
|
"""AmberELEC, EmuELEC, JELOS, ArkOS on a second card and a dozen other
|
|
handheld systems were not detected, or got /roms/bios whatever their
|
|
RetroArch read: each is found by what its own build writes, and its
|
|
folder is the system_directory of its RetroArch config."""
|
|
|
|
ELEC_CFG = "/storage/.config/retroarch/retroarch.cfg"
|
|
ARK_CFG = "/home/ark/.config/retroarch/retroarch.cfg"
|
|
TITLE = "/usr/share/plymouth/themes/text.plymouth"
|
|
|
|
def _found(self, files: dict, release: str = "", home: str = "/root"):
|
|
root = _device(self, files, release, home)
|
|
with contextlib.redirect_stderr(io.StringIO()) as err:
|
|
found = install._detect_embedded()
|
|
names = [install.detected_os(plat, path) for plat, path in found]
|
|
return root, found, names, err.getvalue()
|
|
|
|
def _one(self, files: dict, release: str = "", home: str = "/root"):
|
|
root, found, names, _err = self._found(files, release, home)
|
|
self.assertEqual(len(found), 1, found)
|
|
return root, found[0], names[0]
|
|
|
|
def _elec(self, release: str, name: str, extra: dict | None = None,
|
|
plat: str = "retroarch", folder: str = "storage/roms/bios"):
|
|
cfg = {self.ELEC_CFG: 'system_directory = "~/roms/bios"\n'}
|
|
root, (found_plat, path), found_name = self._one({**cfg, **(extra or {})}, release)
|
|
self.assertEqual((found_plat, path, found_name),
|
|
(plat, root / folder, name))
|
|
|
|
# AmberELEC and EmuELEC publish their own lists, written against
|
|
# /storage/roms (bios/, ports/ and neogeo/ side by side).
|
|
def test_amberelec(self):
|
|
self._elec('NAME="AmberELEC"\nID="coreelec"\n', "AmberELEC",
|
|
plat="amberelec", folder="storage/roms")
|
|
|
|
def test_chaos_names_itself_amberelec(self):
|
|
# tech4bot/AmberELEC writes the same os-release as AmberELEC.
|
|
self._elec('NAME="AmberELEC"\nID="coreelec"\nCOREELEC_DEVICE="RG552"\n', "AmberELEC",
|
|
plat="amberelec", folder="storage/roms")
|
|
|
|
def test_pan4elec_over_amberelec(self):
|
|
self._elec('NAME="AmberELEC"\n', "PAN4ELEC",
|
|
{"/usr/bin/batocera-info": 'echo "OPERATING SYSTEM: PAN4ELEC"\n'},
|
|
plat="amberelec", folder="storage/roms")
|
|
|
|
def test_emuelec(self):
|
|
self._elec('NAME="EmuELEC"\nID="emuelec"\n', "EmuELEC",
|
|
plat="emuelec", folder="storage/roms")
|
|
|
|
def test_jelos(self):
|
|
self._elec('OS_NAME="JELOS"\nHW_DEVICE="RK3566"\n', "JELOS")
|
|
|
|
def test_kolos_over_jelos(self):
|
|
self._elec('OS_NAME="JELOS"\n', "KOLOS",
|
|
{"/usr/lib/systemd/system/jelos-ready.service":
|
|
"Description=KOLOS system ready script\n"})
|
|
|
|
def test_unofficialos(self):
|
|
self._elec('OS_NAME="UnofficialOS"\n', "UnofficialOS")
|
|
|
|
def test_archr(self):
|
|
self._elec('NAME="ArchR"\nID="archr"\nOS_NAME="ArchR"\n', "ArchR")
|
|
|
|
def test_an_elec_config_moved_elsewhere_is_followed(self):
|
|
root, (_plat, path), _name = self._one(
|
|
{self.ELEC_CFG: 'system_directory = "/storage/roms2/bios"\n'}, 'OS_NAME="JELOS"\n')
|
|
self.assertEqual(path, root / "storage/roms2/bios")
|
|
|
|
def test_arkos_first_and_second_card(self):
|
|
base = {"/home/ark/": "", "/opt/system/": "",
|
|
self.TITLE: "[ubuntu-text]\ntitle=ArkOS 2.0 (02012025)-3\n"}
|
|
for folder in ("/roms/bios", "/roms2/bios"):
|
|
with self.subTest(folder=folder):
|
|
root, found, name = self._one(
|
|
{**base, self.ARK_CFG: f'system_directory = "{folder}"\n'})
|
|
self.assertEqual((found, name),
|
|
(("retroarch", root / folder.lstrip("/")), "ArkOS"))
|
|
|
|
def test_arkos_32_bit_config_when_the_64_bit_one_is_missing(self):
|
|
root, found, _name = self._one({
|
|
"/home/ark/": "", "/opt/system/": "",
|
|
"/home/ark/.config/retroarch32/retroarch.cfg": 'system_directory = "/roms2/bios"\n',
|
|
})
|
|
self.assertEqual(found, ("retroarch", root / "roms2/bios"))
|
|
|
|
def test_darkos_first_and_second_card(self):
|
|
base = {"/home/ark/": "", "/opt/system/": "",
|
|
self.TITLE: "title=dArkOS (05102026)\n"}
|
|
for folder in ("/roms/bios", "/roms2/bios"):
|
|
with self.subTest(folder=folder):
|
|
root, found, name = self._one(
|
|
{**base, self.ARK_CFG: f'system_directory = "{folder}"\n'})
|
|
self.assertEqual((found, name),
|
|
(("retroarch", root / folder.lstrip("/")), "dArkOS"))
|
|
|
|
def test_darkos_without_a_config(self):
|
|
root, found, name = self._one({"/home/ark/": "", "/opt/system/": "",
|
|
self.TITLE: "title=dArkOS (05102026)\n"})
|
|
self.assertEqual((found, name), (("retroarch", root / "roms/bios"), "dArkOS"))
|
|
|
|
def test_retrooz(self):
|
|
cfg = "/home/odroid/.config/retroarch32/retroarch.cfg"
|
|
root, found, name = self._one({
|
|
"/opt/.retrooz/version": "0.60\n", self.TITLE: "[ubuntu-text]\ntitle=RetroOZ\n",
|
|
cfg: 'system_directory = "/roms/bios"\n',
|
|
})
|
|
self.assertEqual((found, name), (("retroarch", root / "roms/bios"), "RetroOZ"))
|
|
root, found, name = self._one({"/etc/retrooz_version": "1\n"})
|
|
self.assertEqual((found, name), (("retroarch", root / "roms/bios"), "RetroOZ"))
|
|
|
|
def test_thera_is_not_detected(self):
|
|
_root, found, _names, _err = self._found({self.TITLE: "title= *TheRA RC 1*\n"})
|
|
self.assertEqual(found, [])
|
|
|
|
def test_spruceos_on_a_trimui(self):
|
|
root, found, name = self._one({
|
|
"/usr/trimui/": "", "/mnt/SDCARD/spruce/": "",
|
|
"/mnt/SDCARD/Saves/ra-configs/retroarch-Brick.cfg":
|
|
'system_directory = "/mnt/SDCARD/BIOS"\n',
|
|
})
|
|
self.assertEqual((found, name), (("retroarch", root / "mnt/SDCARD/BIOS"), "spruceOS"))
|
|
|
|
def test_crossmix_os(self):
|
|
root, found, name = self._one({
|
|
"/usr/trimui/": "", "/mnt/SDCARD/System/usr/trimui/crossmix-version.txt": "1.3\n",
|
|
"/mnt/SDCARD/RetroArch/retroarch.cfg": 'system_directory = "/mnt/SDCARD/BIOS"\n',
|
|
})
|
|
self.assertEqual((found, name), (("retroarch", root / "mnt/SDCARD/BIOS"), "CrossMix-OS"))
|
|
|
|
def _miyoo_mini(self, marker: str, name: str):
|
|
root, found, found_name = self._one({
|
|
marker: "x\n",
|
|
"/mnt/SDCARD/RetroArch/.retroarch/retroarch.cfg":
|
|
'system_directory = "/mnt/SDCARD/BIOS"\n',
|
|
})
|
|
self.assertEqual((found, found_name), (("retroarch", root / "mnt/SDCARD/BIOS"), name))
|
|
|
|
def test_onion(self):
|
|
self._miyoo_mini("/mnt/SDCARD/.tmp_update/onionVersion/version.txt", "Onion")
|
|
|
|
def test_allium(self):
|
|
self._miyoo_mini("/mnt/SDCARD/.allium/version.txt", "Allium")
|
|
|
|
def test_koriki(self):
|
|
self._miyoo_mini("/mnt/SDCARD/Koriki/version.txt", "Koriki")
|
|
|
|
def test_miyoocfw_default_and_config(self):
|
|
base = {"/etc/main": "export HOME=/mnt\n", "/usr/bin/miyooctl": ""}
|
|
root, found, name = self._one(base)
|
|
self.assertEqual((found, name),
|
|
(("retroarch", root / "mnt/.retroarch/system"), "MiyooCFW"))
|
|
root, found, _name = self._one(
|
|
{**base, "/mnt/.retroarch/retroarch.cfg": 'system_directory = "~/bios"\n'})
|
|
self.assertEqual(found, ("retroarch", root / "mnt/bios"))
|
|
|
|
def test_plumos(self):
|
|
root, found, name = self._one({"/mnt/plumos-user/": ""}, 'NAME="plumOS"\nID=plumos\n')
|
|
self.assertEqual((found, name), (("retroarch", root / "mnt/plumos-user/bios"), "plumOS"))
|
|
_root, found, _names, err = self._found({}, 'NAME="plumOS"\nID=plumos\n')
|
|
self.assertEqual(found, [])
|
|
self.assertIn("plumOS found here", err)
|
|
|
|
def test_trimui_stock_and_minui_are_named_not_installed_to(self):
|
|
_root, found, _names, err = self._found({"/usr/trimui/": ""})
|
|
self.assertEqual(found, [])
|
|
self.assertIn("TrimUI found here", err)
|
|
_root, found, _names, err = self._found(
|
|
{"/usr/trimui/": "", "/mnt/SDCARD/.system/res/": ""})
|
|
self.assertEqual(found, [])
|
|
self.assertIn("MinUI found here", err)
|
|
self.assertIn("--platform minui", err)
|
|
self.assertIn("--dest", err)
|
|
self.assertNotIn("TrimUI", err)
|
|
|
|
def test_a_minui_card_on_its_device_gets_its_file_list(self):
|
|
"""--list-platforms said the MinUI layout was not written yet, while
|
|
--from-pack and --dest wrote it: on the device the card root is the
|
|
folder, and the card's version.txt names which of the three it is."""
|
|
for release, plat, shown in (("MinUI-20251127-0", "minui", "MinUI"),
|
|
("NextUI-20250601-1", "nextui", "NextUI"),
|
|
("MyMinUI-20260809b-0", "myminui", "MyMinUI")):
|
|
with self.subTest(release=release):
|
|
root, found, name = self._one({
|
|
"/usr/trimui/": "", "/mnt/SDCARD/.system/res/": "",
|
|
"/mnt/SDCARD/.system/version.txt": release + "\n"})
|
|
self.assertEqual((found, name), ((plat, root / "mnt/SDCARD"), shown))
|
|
|
|
def test_the_listing_says_what_the_minui_family_gets(self):
|
|
line = next(line for line in install._system_lines() if line.startswith("MinUI:"))
|
|
self.assertNotIn("does not write", line)
|
|
self.assertIn("minui, nextui or myminui", line)
|
|
|
|
def test_two_unrelated_systems_are_refused(self):
|
|
_root, found, names, _err = self._found(
|
|
{"/mnt/SDCARD/Koriki/version.txt": "1\n"}, 'OS_NAME="JELOS"\n')
|
|
self.assertEqual(names, ["JELOS", "Koriki"])
|
|
self.assertEqual(install.os_conflict(found), ["JELOS", "Koriki"])
|
|
with unittest.mock.patch.object(sys, "argv", ["install.py", "--no-input"]), \
|
|
unittest.mock.patch.object(install, "detect_os", return_value="linux"), \
|
|
unittest.mock.patch.object(install, "detect_platforms", return_value=found), \
|
|
unittest.mock.patch.object(install, "detect_frontends", return_value=[]), \
|
|
contextlib.redirect_stdout(io.StringIO()), \
|
|
contextlib.redirect_stderr(io.StringIO()) as err, \
|
|
self.assertRaises(SystemExit) as stop:
|
|
install.main()
|
|
self.assertEqual(stop.exception.code, 1)
|
|
self.assertIn("JELOS and Koriki are both detected here", err.getvalue())
|
|
with unittest.mock.patch.object(sys, "argv", ["install.py", "--no-input", "--events"]), \
|
|
unittest.mock.patch.object(install, "detect_os", return_value="linux"), \
|
|
unittest.mock.patch.object(install, "detect_platforms", return_value=found), \
|
|
unittest.mock.patch.object(install, "detect_frontends", return_value=[]), \
|
|
contextlib.redirect_stdout(io.StringIO()) as out, \
|
|
contextlib.redirect_stderr(io.StringIO()), \
|
|
self.assertRaises(SystemExit):
|
|
install.main()
|
|
errors = [event for event in map(json.loads, out.getvalue().splitlines())
|
|
if event["event"] == "error"]
|
|
self.assertEqual([(e["stage"], e["reason"]) for e in errors], [("detect", "conflict")])
|
|
|
|
def test_the_system_is_announced_by_its_own_name(self):
|
|
root, found, _name = self._one({self.ELEC_CFG: ""}, 'OS_NAME="JELOS"\n')
|
|
self.assertEqual(install.found_label(*found),
|
|
f"JELOS at {root / 'storage/roms/bios'} (RetroArch files)")
|
|
|
|
def test_list_platforms_names_every_system(self):
|
|
with unittest.mock.patch.object(sys, "argv", ["install.py", "--list-platforms"]), \
|
|
unittest.mock.patch.object(install, "detect_os", return_value="linux"), \
|
|
unittest.mock.patch.object(install, "detect_platforms", return_value=[]), \
|
|
contextlib.redirect_stdout(io.StringIO()) as out:
|
|
install.main()
|
|
for rule in install._EMBEDDED_RULES:
|
|
self.assertIn(f" {rule.name}: ", out.getvalue())
|
|
|
|
|
|
class BatoceraAndKnulliByWhatTheirBuildsWrite(unittest.TestCase):
|
|
"""No Batocera build writes /etc/batocera-version (batocera-system.mk
|
|
writes /usr/share/batocera/batocera.version), and Knulli writes no
|
|
/etc/knulli-release and removes /usr/share/batocera: neither was
|
|
detected, and a Knulli board got every board's files without a word."""
|
|
|
|
def _detect(self, release: dict, files: tuple[str, ...]):
|
|
with unittest.mock.patch.object(install, "_parse_os_release", return_value=release), \
|
|
unittest.mock.patch.object(Path, "exists", lambda p: str(p) in files):
|
|
return install._detect_embedded()
|
|
|
|
def test_batocera_by_its_version_file(self):
|
|
self.assertEqual(self._detect({}, ("/usr/share/batocera/batocera.version",)),
|
|
[("batocera", Path("/userdata/bios"))])
|
|
self.assertEqual(self._detect({}, ("/etc/batocera-version",)), [])
|
|
|
|
def test_knulli_by_its_os_release_or_version_file(self):
|
|
self.assertEqual(self._detect({"OS_NAME": "knulli"}, ()),
|
|
[("knulli", Path("/userdata/bios"))])
|
|
self.assertEqual(self._detect({}, ("/usr/share/knulli/knulli.version",)),
|
|
[("knulli", Path("/userdata/bios"))])
|
|
|
|
def test_amberelec_and_emuelec_by_their_os_release(self):
|
|
"""Both write os-release from DISTRONAME in scripts/image. AmberELEC
|
|
keeps CoreELEC's ID, so its NAME tells it apart."""
|
|
self.assertEqual(self._detect({"NAME": "AmberELEC", "ID": "coreelec"}, ()),
|
|
[("amberelec", Path("/storage/roms"))])
|
|
self.assertEqual(self._detect({"NAME": "EmuELEC", "ID": "emuelec"}, ()),
|
|
[("emuelec", Path("/storage/roms"))])
|
|
self.assertEqual(self._detect({"NAME": "CoreELEC", "ID": "coreelec"}, ()), [])
|
|
|
|
def test_a_knulli_board_is_named_and_not_narrowed(self):
|
|
board = Path(_tempdir(self)) / "knulli.arch"
|
|
board.write_text("a133")
|
|
with unittest.mock.patch.dict(install._DERIVATIVE_BOARDS,
|
|
{"knulli": ("Knulli", str(board))}):
|
|
self.assertEqual(install.derivative_board("knulli"), ("Knulli", "a133"))
|
|
self.assertIsNone(install.derivative_board("recalbox"))
|
|
|
|
def test_the_run_says_why_every_board_is_installed(self):
|
|
source = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
|
|
block = source[source.index("board = detect_board(plat_name)"):]
|
|
block = block[:block.index("if target:")]
|
|
self.assertIn("has no", block)
|
|
self.assertIn("derivative_board(plat_name)", block)
|
|
|
|
|
|
class RomMFirmwareFollowsItsTemplate(unittest.TestCase):
|
|
"""RomM 5.4 reads firmware where filesystem.structure.firmware says, and
|
|
a {platform}/roms library is told to declare "{platform}/bios". The
|
|
installer only wrote bios/<platform>/, where such a library lists none."""
|
|
|
|
CONFIG = (
|
|
"# RomM\n"
|
|
"exclude:\n"
|
|
" platforms: []\n"
|
|
"filesystem:\n"
|
|
" structure:\n"
|
|
" default: \"{platform}/roms/{game}\"\n"
|
|
" firmware: \"{platform}/bios\" # as RomM suggests\n"
|
|
"system:\n"
|
|
" firmware: elsewhere\n"
|
|
)
|
|
|
|
def _romm(self, config: str | None) -> Path:
|
|
base = Path(_tempdir(self))
|
|
library = base / "library"
|
|
(library / "psx" / "roms").mkdir(parents=True)
|
|
if config is not None:
|
|
(base / "config").mkdir()
|
|
(base / "config" / "config.yml").write_text(config)
|
|
return library / "bios"
|
|
|
|
def test_templates_parse_as_romm_parses_them(self):
|
|
self.assertEqual(install.parse_romm_firmware("bios/{platform}"), (("bios",), ()))
|
|
self.assertEqual(install.parse_romm_firmware("{platform}/bios"), ((), ("bios",)))
|
|
self.assertEqual(install.parse_romm_firmware("/a/{platform}/b/c/"),
|
|
(("a",), ("b", "c")))
|
|
for bad in ("", "bios", "{platform}/{game}", "{region}/{platform}",
|
|
"../{platform}", "{platform}/{platform}"):
|
|
with self.subTest(template=bad), self.assertRaises(ValueError):
|
|
install.parse_romm_firmware(bad)
|
|
|
|
def test_the_template_is_read_from_config_yml(self):
|
|
bios = self._romm(self.CONFIG)
|
|
layout = install.romm_firmware_layout(bios, None)
|
|
self.assertEqual(layout, ((), ("bios",)))
|
|
files = [{"dest": "psx/scph5501.bin"}, {"dest": "3do/panafz1.bin"}]
|
|
moved, root = install.romm_relayout(files, bios, *layout)
|
|
self.assertEqual(root, bios.parent)
|
|
self.assertEqual([f["dest"] for f in moved],
|
|
["psx/bios/scph5501.bin", "3do/bios/panafz1.bin"])
|
|
|
|
def test_the_default_keeps_the_pack_layout(self):
|
|
bios = self._romm("filesystem:\n structure:\n firmware: bios/{platform}\n")
|
|
files = [{"dest": "psx/scph5501.bin"}]
|
|
self.assertEqual(install.romm_relayout(files, bios, *install.romm_firmware_layout(
|
|
bios, None)), (files, bios))
|
|
|
|
def test_a_platform_first_library_without_a_template_is_refused(self):
|
|
bios = self._romm(None)
|
|
with self.assertRaises(ValueError) as raised:
|
|
install.romm_firmware_layout(bios, None)
|
|
self.assertIn('--romm-firmware "{platform}/bios"', str(raised.exception))
|
|
self.assertEqual(install.romm_firmware_layout(bios, "{platform}/bios"),
|
|
((), ("bios",)))
|
|
|
|
def test_the_option_is_refused_where_romm_is_not_installed(self):
|
|
err = io.StringIO()
|
|
with unittest.mock.patch.object(install, "detect_os", return_value="linux"), \
|
|
unittest.mock.patch("sys.argv", [
|
|
"install.py", "--platform", "batocera", "--dest", _tempdir(self),
|
|
"--romm-firmware", "{platform}/bios", "--no-input"]), \
|
|
contextlib.redirect_stdout(io.StringIO()), \
|
|
contextlib.redirect_stderr(err), self.assertRaises(SystemExit) as caught:
|
|
install.main()
|
|
self.assertEqual(caught.exception.code, 1)
|
|
self.assertIn("--romm-firmware applies to RomM", err.getvalue())
|
|
|
|
|
|
class RomMFirmwareInstallsUnderItsTemplate(unittest.TestCase, _LoopbackRun):
|
|
"""The whole run, over loopback: config.yml beside the library decides."""
|
|
|
|
def setUp(self):
|
|
self.env = self.serve(self)
|
|
|
|
def test_lynx_lands_in_its_platform_folder(self):
|
|
base = self.home / "romm"
|
|
(base / "library" / "lynx" / "roms").mkdir(parents=True)
|
|
(base / "config").mkdir()
|
|
(base / "config" / "config.yml").write_text(
|
|
"filesystem:\n structure:\n default: \"{platform}/roms/{game}\"\n"
|
|
" firmware: \"{platform}/bios\"\n")
|
|
proc = _install_run(self.env, "--platform", "romm", "--dest",
|
|
str(base / "library" / "bios"), "--system", "atari-lynx",
|
|
"--no-input")
|
|
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
|
self.assertTrue((base / "library" / "lynx" / "bios" / "lynxboot.img").is_file())
|
|
self.assertFalse((base / "library" / "bios").exists())
|
|
self.assertIn("{platform}/bios", proc.stdout)
|
|
|
|
|
|
class _SyntheticTree:
|
|
"""A served tree of four BIOS files over loopback, one with no system,
|
|
and a BIOS folder holding one of them, another with other bytes, and
|
|
neither of the last two."""
|
|
|
|
FILES = {
|
|
"bios/A/a.bin": b"alpha" * 20,
|
|
"bios/A/b.bin": b"bravo" * 30,
|
|
"bios/B/c.bin": b"charlie" * 10,
|
|
"bios/X/d.bin": b"delta" * 5,
|
|
}
|
|
|
|
def serve_tree(self, test: unittest.TestCase) -> dict:
|
|
root = Path(_tempdir(test))
|
|
files = []
|
|
for path, data in self.FILES.items():
|
|
(root / path).parent.mkdir(parents=True, exist_ok=True)
|
|
(root / path).write_bytes(data)
|
|
name = Path(path).name
|
|
systems = {"a.bin": ["sys-a"], "b.bin": ["sys-a"], "c.bin": ["sys-b"]}.get(name)
|
|
entry = {"dest": name, "repo_path": path, "size": len(data),
|
|
"sha1": hashlib.sha1(data).hexdigest(), "cores": None}
|
|
if systems:
|
|
entry["systems"] = systems
|
|
files.append(entry)
|
|
manifest = {
|
|
"manifest_version": 2, "platform": "retroarch", "display_name": "RetroArch",
|
|
"files": files, "system_names": {"sys-a": ["alpha"]},
|
|
"omitted_files": [{"dest": "e.bin", "name": "e.bin", "system": "sys-b",
|
|
"required": True, "reason": "not_found",
|
|
"systems": ["sys-b"]}],
|
|
}
|
|
(root / "install").mkdir()
|
|
(root / "install" / "retroarch.json").write_text(json.dumps(manifest))
|
|
|
|
class Quiet(http.server.SimpleHTTPRequestHandler):
|
|
def log_message(self, *args) -> None:
|
|
return
|
|
|
|
handler = functools.partial(Quiet, directory=str(root))
|
|
httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
|
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
|
test.addCleanup(httpd.server_close)
|
|
test.addCleanup(httpd.shutdown)
|
|
self.home = Path(_tempdir(test))
|
|
self.bios = self.home / "bios"
|
|
self.bios.mkdir()
|
|
(self.bios / "a.bin").write_bytes(self.FILES["bios/A/a.bin"])
|
|
(self.bios / "b.bin").write_bytes(b"other bytes")
|
|
return {
|
|
**os.environ, "HOME": str(self.home), "RETROBIOS_OS": "linux",
|
|
"RETROBIOS_BASE_URL": f"http://127.0.0.1:{httpd.server_address[1]}",
|
|
}
|
|
|
|
|
|
def _events(proc: subprocess.CompletedProcess) -> list[dict]:
|
|
"""The --events stream, every stdout line a JSON object."""
|
|
return [json.loads(line) for line in proc.stdout.splitlines()]
|
|
|
|
|
|
class SystemEventsFollowTheChoice(unittest.TestCase):
|
|
"""--system nintendo-gba wrote `system` events for nintendo-ds, gb and
|
|
gbc: SkyEmu's files carry four systems, and each was described by the
|
|
few of its files the choice kept."""
|
|
|
|
def test_only_the_chosen_systems_are_described(self):
|
|
files = [{"dest": "gba_bios.bin", "size": 1, "systems": ["nintendo-gba"]},
|
|
{"dest": "SkyEmu/dmg.bin", "size": 1,
|
|
"systems": ["nintendo-gb", "nintendo-gba"]}]
|
|
stream = io.StringIO()
|
|
install.EVENTS.open(stream)
|
|
self.addCleanup(setattr, install.EVENTS, "stream", None)
|
|
install._emit_check("retroarch", {}, Path("/b"), files, [], [], 0, 0,
|
|
chosen=["nintendo-gba"])
|
|
systems = [json.loads(line)["system"] for line in stream.getvalue().splitlines()
|
|
if json.loads(line)["event"] == "system"]
|
|
self.assertEqual(systems, ["nintendo-gba"])
|
|
stream.seek(0)
|
|
stream.truncate()
|
|
install._emit_check("retroarch", {}, Path("/b"), files, [], [], 0, 0)
|
|
systems = [json.loads(line)["system"] for line in stream.getvalue().splitlines()
|
|
if json.loads(line)["event"] == "system"]
|
|
self.assertEqual(systems, ["nintendo-gb", "nintendo-gba"])
|
|
|
|
|
|
class EventsDescribeTheRun(unittest.TestCase, _SyntheticTree):
|
|
"""--events: the handheld port reads the run as JSON lines on stdout and
|
|
never parses the text, which goes to stderr."""
|
|
|
|
def setUp(self):
|
|
self.env = self.serve_tree(self)
|
|
|
|
def _run(self, *argv: str) -> tuple[subprocess.CompletedProcess, list[dict]]:
|
|
proc = _install_run(self.env, "--platform", "retroarch", "--dest", str(self.bios),
|
|
"--events", "--jobs", "1", *argv)
|
|
self.assertNotIn("Traceback", proc.stderr)
|
|
return proc, _events(proc)
|
|
|
|
def _head(self, mode: str, installer: str) -> list[dict]:
|
|
return [
|
|
{"event": "start", "schema": install.EVENT_SCHEMA, "installer": installer,
|
|
"mode": mode},
|
|
{"event": "detect", "os": "linux", "os_name": install._os_name(),
|
|
"platforms": [{"platform": "retroarch", "name": "RetroArch",
|
|
"bios_dir": str(self.bios)}]},
|
|
# a.bin and b.bin are in place: the first read and the last.
|
|
{"event": "checking", "files_done": 1, "files_total": 2},
|
|
{"event": "checking", "files_done": 2, "files_total": 2},
|
|
{"event": "file", "platform": "retroarch", "dest": "a.bin",
|
|
"systems": ["sys-a"], "size": 100, "state": "present"},
|
|
{"event": "file", "platform": "retroarch", "dest": "b.bin",
|
|
"systems": ["sys-a"], "size": 150, "state": "wrong_hash"},
|
|
{"event": "file", "platform": "retroarch", "dest": "c.bin",
|
|
"systems": ["sys-b"], "size": 70, "state": "missing"},
|
|
{"event": "file", "platform": "retroarch", "dest": "d.bin",
|
|
"systems": [], "size": 25, "state": "missing"},
|
|
{"event": "system", "platform": "retroarch", "system": "other", "name": "other",
|
|
"files": 1, "present": 0, "missing": 1, "wrong_hash": 0, "bytes_needed": 25},
|
|
{"event": "system", "platform": "retroarch", "system": "sys-a", "name": "alpha",
|
|
"files": 2, "present": 1, "missing": 0, "wrong_hash": 1, "bytes_needed": 150},
|
|
{"event": "system", "platform": "retroarch", "system": "sys-b", "name": "sys-b",
|
|
"files": 1, "present": 0, "missing": 1, "wrong_hash": 0, "bytes_needed": 70},
|
|
{"event": "check", "platform": "retroarch", "name": "RetroArch",
|
|
"bios_dir": str(self.bios), "files": 4, "present": 1, "missing": 2,
|
|
"wrong_hash": 1, "bytes_needed": 245, "data_missing": 0,
|
|
"not_installable": 1},
|
|
# Every file of this list is the platform's own (cores: null).
|
|
{"event": "declared", "platform": "retroarch", "files": 4, "present": 1,
|
|
"missing": 2, "wrong_hash": 1, "bytes_needed": 245,
|
|
"dests": ["a.bin", "b.bin", "c.bin", "d.bin"]},
|
|
]
|
|
|
|
SKIPPED = [{"reason": "not_found", "label": "not found", "files": 1, "required": 1}]
|
|
|
|
def test_a_check_run(self):
|
|
proc, events = self._run("--check")
|
|
self.assertEqual(proc.returncode, 1)
|
|
installer = events[0]["installer"]
|
|
self.assertEqual(events, self._head("check", installer) + [
|
|
{"event": "summary", "mode": "check", "installed": 0, "up_to_date": 1,
|
|
"pending": 3, "failed": [], "skipped": self.SKIPPED, "left_out": [], "kept": [],
|
|
"interrupted": False},
|
|
{"event": "end", "code": 1},
|
|
])
|
|
self.assertIn("2/4 present (1 verified, 1 wrong hash)", proc.stderr)
|
|
self.assertFalse((self.bios / "c.bin").exists())
|
|
|
|
def test_an_install_run(self):
|
|
proc, events = self._run()
|
|
self.assertEqual(proc.returncode, 0, proc.stderr)
|
|
installer = events[0]["installer"]
|
|
kept = events[-2]["kept"]
|
|
self.assertEqual(len(kept), 1, kept)
|
|
self.assertEqual(Path(kept[0]["folder"]).parent, self.home / install.BACKUP_DIR)
|
|
progress = [event for event in events if event["event"] == "progress"]
|
|
for event in progress:
|
|
self.assertEqual(list(event), ["event", *install.EVENT_KEYS["progress"]])
|
|
done = [{"event": "downloaded", "dest": dest, "ok": True, "reason": "",
|
|
"size": size, "files_done": n, "files_total": 3, "bytes_done": total,
|
|
"bytes_total": 245}
|
|
for n, (dest, size, total) in enumerate(
|
|
(("c.bin", 70, 70), ("d.bin", 25, 95), ("b.bin", 150, 245)), 1)]
|
|
# The last transfer: every byte came over the network.
|
|
self.assertEqual([e for e in events if e["event"] == "transfer"][-1],
|
|
{"event": "transfer", "bytes_local": 0, "bytes_network": 245})
|
|
self.assertEqual([e for e in events if e["event"] not in ("progress", "transfer")],
|
|
self._head("install", installer) + [
|
|
{"event": "download", "platform": "retroarch", "files": 3, "bytes": 245},
|
|
*done,
|
|
{"event": "summary", "mode": "install", "installed": 3, "up_to_date": 1,
|
|
"pending": 0, "failed": [], "skipped": self.SKIPPED, "left_out": [],
|
|
"kept": [{"folder": kept[0]["folder"], "files": 1, "bytes": 11}],
|
|
"interrupted": False},
|
|
{"event": "end", "code": 0},
|
|
])
|
|
self.assertEqual((self.bios / "b.bin").read_bytes(), self.FILES["bios/A/b.bin"])
|
|
self.assertIn("Done. 3 files installed", proc.stderr)
|
|
|
|
def test_every_event_has_its_schema_keys(self):
|
|
_proc, events = self._run()
|
|
for event in events:
|
|
self.assertEqual(list(event), ["event", *install.EVENT_KEYS[event["event"]]])
|
|
|
|
def test_a_file_list_out_of_reach_is_a_network_error(self):
|
|
# A port nothing listens on: the connection is refused at once.
|
|
with socket.socket() as probe:
|
|
probe.bind(("127.0.0.1", 0))
|
|
closed = probe.getsockname()[1]
|
|
env = {**self.env, "RETROBIOS_BASE_URL": f"http://127.0.0.1:{closed}"}
|
|
proc = _install_run(env, "--platform", "retroarch", "--dest", str(self.bios),
|
|
"--events", "--check")
|
|
events = _events(proc)
|
|
self.assertEqual([e["event"] for e in events], ["start", "detect", "error", "end"])
|
|
self.assertEqual(events[2]["reason"], "network")
|
|
self.assertEqual(events[2]["stage"], "manifest")
|
|
self.assertEqual(events[3], {"event": "end", "code": 1})
|
|
|
|
def test_nothing_detected_is_an_empty_platform_list(self):
|
|
proc = _install_run(self.env, "--events", "--check")
|
|
events = _events(proc)
|
|
self.assertEqual(proc.returncode, 1)
|
|
self.assertEqual(events[1]["platforms"], [])
|
|
self.assertEqual(events[-1], {"event": "end", "code": 1})
|
|
|
|
def test_the_schema_is_documented(self):
|
|
page = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8")
|
|
for kind, keys in install.EVENT_KEYS.items():
|
|
row = next((line for line in page.splitlines()
|
|
if line.startswith(f"| `{kind}` |")), None)
|
|
self.assertIsNotNone(row, kind)
|
|
for key in keys:
|
|
self.assertIn(f"`{key}`", row, f"{kind}.{key}")
|
|
|
|
def test_a_run_is_valid_against_the_published_schema(self):
|
|
try:
|
|
from jsonschema import Draft202012Validator # noqa: PLC0415
|
|
except ImportError:
|
|
self.skipTest("jsonschema is not installed")
|
|
schema = json.loads(EVENT_SCHEMA_PATH.read_text(encoding="utf-8"))
|
|
validator = Draft202012Validator(schema)
|
|
for argv in (("--check",), ()):
|
|
_proc, events = self._run(*argv)
|
|
for event in events:
|
|
errors = [error.message for error in validator.iter_errors(event)]
|
|
self.assertEqual(errors, [], event)
|
|
self.assertTrue(list(validator.iter_errors({"event": "file", "platform": "x"})))
|
|
self.assertTrue(list(validator.iter_errors(
|
|
{"event": "end", "code": 0, "extra": 1})))
|
|
|
|
|
|
EVENT_SCHEMA_PATH = REPO_ROOT / "schemas" / "install-events.schema.json"
|
|
|
|
|
|
class TheEventSchemaFileIsEventKeys(unittest.TestCase):
|
|
"""The table in the wiki was the only description of --events a reader
|
|
outside Python could check a stream against."""
|
|
|
|
def _generator(self):
|
|
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
|
import generate_event_schema # noqa: PLC0415
|
|
|
|
return generate_event_schema
|
|
|
|
def test_the_committed_file_is_the_generated_one(self):
|
|
generator = self._generator()
|
|
self.assertEqual(EVENT_SCHEMA_PATH.read_text(encoding="utf-8"),
|
|
generator.render(generator.build_schema()),
|
|
"run python scripts/generate_event_schema.py")
|
|
|
|
def test_each_kind_has_exactly_its_keys(self):
|
|
schema = json.loads(EVENT_SCHEMA_PATH.read_text(encoding="utf-8"))
|
|
self.assertEqual(schema["properties"]["event"]["enum"], list(install.EVENT_KEYS))
|
|
for kind, keys in install.EVENT_KEYS.items():
|
|
shape = schema["$defs"][kind]
|
|
self.assertEqual(shape["required"], ["event", *keys], kind)
|
|
self.assertEqual(list(shape["properties"]), ["event", *keys], kind)
|
|
self.assertIs(shape["additionalProperties"], False, kind)
|
|
start = schema["$defs"]["start"]["properties"]["schema"]
|
|
self.assertEqual(start, {"const": install.EVENT_SCHEMA})
|
|
|
|
def test_a_key_without_a_type_stops_the_generator(self):
|
|
generator = self._generator()
|
|
with unittest.mock.patch.dict(install.EVENT_KEYS, {"new": ("untyped_key",)}), \
|
|
unittest.mock.patch.object(generator, "EVENT_KEYS", install.EVENT_KEYS), \
|
|
self.assertRaises(KeyError):
|
|
generator.build_schema()
|
|
|
|
def test_the_check_command_fails_on_a_stale_file(self):
|
|
generator = self._generator()
|
|
scratch = Path(_tempdir(self)) / "events.schema.json"
|
|
scratch.write_text("{}\n", encoding="utf-8")
|
|
with contextlib.redirect_stdout(io.StringIO()), \
|
|
contextlib.redirect_stderr(io.StringIO()):
|
|
self.assertEqual(generator.main(["--check", "--output", str(scratch)]), 1)
|
|
self.assertEqual(generator.main(["--output", str(scratch)]), 0)
|
|
self.assertEqual(generator.main(["--check", "--output", str(scratch)]), 0)
|
|
|
|
|
|
class NetworkFailuresInPlainWords(unittest.TestCase):
|
|
"""Offline, the run printed "[Errno -2] Name or service not known". A
|
|
beginner needs "no internet connection", and the raw text only under
|
|
--verbose."""
|
|
|
|
def _words(self, reason: BaseException, **kwargs) -> tuple[str, str]:
|
|
return install.network_words(urllib.error.URLError(reason), **kwargs)
|
|
|
|
def test_the_common_failures(self):
|
|
import ssl # noqa: PLC0415
|
|
|
|
cases = (
|
|
(socket.gaierror(-2, "Name or service not known"), "no internet connection"),
|
|
(socket.timeout("timed out"), "timed out"),
|
|
(ConnectionRefusedError(111, "Connection refused"), "refused the connection"),
|
|
(OSError(101, "Network is unreachable"), "no internet connection"),
|
|
(ssl.SSLCertVerificationError(1, "certificate verify failed"),
|
|
"certificate was rejected"),
|
|
)
|
|
with unittest.mock.patch.object(install, "_ca_bundle_found", return_value=True):
|
|
for reason, words in cases:
|
|
with self.subTest(reason=reason):
|
|
code, text = self._words(reason)
|
|
self.assertEqual(code, "network")
|
|
self.assertIn(words, text)
|
|
self.assertNotIn("Errno", text)
|
|
|
|
def test_verbose_keeps_the_raw_text(self):
|
|
_code, text = self._words(socket.gaierror(-2, "Name or service not known"),
|
|
verbose=True)
|
|
self.assertIn("Name or service not known", text)
|
|
|
|
def test_a_clock_in_the_past_is_named(self):
|
|
"""A handheld with no clock battery boots in 1980, and every
|
|
certificate then reads as not yet valid."""
|
|
import datetime # noqa: PLC0415
|
|
import ssl # noqa: PLC0415
|
|
|
|
error = ssl.SSLCertVerificationError(1, "certificate is not yet valid")
|
|
with unittest.mock.patch.object(install, "_ca_bundle_found", return_value=True):
|
|
code, text = self._words(error, today=datetime.date(1980, 1, 1))
|
|
self.assertEqual(code, "clock")
|
|
self.assertIn("The clock reads 1980-01-01", text)
|
|
self.assertIn("set the date", text)
|
|
code, _text = self._words(error, today=install.CLOCK_FLOOR)
|
|
self.assertEqual(code, "network")
|
|
|
|
def test_no_certificate_list_names_ssl_cert_file(self):
|
|
import ssl # noqa: PLC0415
|
|
|
|
error = ssl.SSLCertVerificationError(1, "unable to get local issuer certificate")
|
|
with unittest.mock.patch.object(install, "_ca_bundle_found", return_value=False):
|
|
code, text = self._words(error)
|
|
self.assertEqual(code, "no_ca")
|
|
self.assertIn("SSL_CERT_FILE", text)
|
|
|
|
def test_a_certificate_list_is_looked_for_where_openssl_and_systems_keep_it(self):
|
|
root = Path(_tempdir(self))
|
|
bundle = root / "cert.pem"
|
|
paths = SimpleNamespace(cafile=str(root / "none.pem"), capath=str(root / "none"),
|
|
openssl_cafile=str(root / "no.pem"),
|
|
openssl_capath=str(root / "no"))
|
|
with unittest.mock.patch("ssl.get_default_verify_paths", return_value=paths), \
|
|
unittest.mock.patch.object(install, "CA_BUNDLES", ()), \
|
|
unittest.mock.patch.dict(os.environ, {"SSL_CERT_FILE": "", "SSL_CERT_DIR": ""}):
|
|
self.assertFalse(install._ca_bundle_found())
|
|
with unittest.mock.patch.object(install, "CA_BUNDLES", (str(bundle),)):
|
|
self.assertFalse(install._ca_bundle_found())
|
|
bundle.write_text("x")
|
|
self.assertTrue(install._ca_bundle_found())
|
|
|
|
def test_a_file_list_out_of_reach_by_name_says_so(self):
|
|
env = {**os.environ, "RETROBIOS_BASE_URL": "https://retrobios-nohost.invalid",
|
|
"HOME": _tempdir(self)}
|
|
proc = subprocess.run(
|
|
[sys.executable, str(REPO_ROOT / "install.py"), "--platform", "retroarch",
|
|
"--dest", _tempdir(self), "--check"],
|
|
env=env, capture_output=True, text=True, timeout=120, check=False,
|
|
stdin=subprocess.DEVNULL,
|
|
)
|
|
self.assertEqual(proc.returncode, 1)
|
|
self.assertIn("no internet connection", proc.stderr)
|
|
self.assertNotIn("Errno", proc.stderr)
|
|
|
|
|
|
class AFileYouHadIsKept(unittest.TestCase):
|
|
"""A BIOS file whose contents differ was replaced and lost: a personal
|
|
dump or another revision could not be brought back by deleting what the
|
|
installer wrote. It is moved beside the BIOS folder first, never inside,
|
|
where a core that finds its BIOS by content could take it."""
|
|
|
|
NEW = b"the collection's copy"
|
|
OLD = b"a dump of my own"
|
|
|
|
def setUp(self):
|
|
install.KEPT.reset()
|
|
self.addCleanup(install.KEPT.reset)
|
|
self.home = Path(_tempdir(self))
|
|
self.bios = self.home / "system"
|
|
self.bios.mkdir()
|
|
|
|
def _entry(self, dest: str) -> dict:
|
|
return {"dest": dest, "repo_path": f"bios/{dest}", "size": len(self.NEW),
|
|
"sha1": hashlib.sha1(self.NEW).hexdigest(),
|
|
"sha256": hashlib.sha256(self.NEW).hexdigest()}
|
|
|
|
def _download(self, dest: str):
|
|
with unittest.mock.patch.object(install.urllib.request, "urlopen",
|
|
lambda *a, **k: _FakeResponse(self.NEW)):
|
|
return install._download_one(self._entry(dest), self.bios)
|
|
|
|
def _run_folder(self) -> Path:
|
|
runs = list((self.home / install.BACKUP_DIR).iterdir())
|
|
self.assertEqual(len(runs), 1, runs)
|
|
return runs[0]
|
|
|
|
def test_a_differing_file_is_kept_with_its_relative_path(self):
|
|
(self.bios / "sub").mkdir()
|
|
(self.bios / "sub" / "x.bin").write_bytes(self.OLD)
|
|
self.assertEqual(self._download("sub/x.bin"), ("sub/x.bin", True, ""))
|
|
self.assertEqual((self.bios / "sub" / "x.bin").read_bytes(), self.NEW)
|
|
run = self._run_folder()
|
|
self.assertEqual((run / "system" / "sub" / "x.bin").read_bytes(), self.OLD)
|
|
self.assertEqual(install.KEPT.summary(),
|
|
[{"folder": str(run), "files": 1, "bytes": len(self.OLD)}])
|
|
# Nothing new sits inside the BIOS folder, where a scan would read it.
|
|
self.assertEqual(sorted(p.name for p in self.bios.rglob("*")), ["sub", "x.bin"])
|
|
|
|
def test_an_identical_file_is_not_copied(self):
|
|
(self.bios / "x.bin").write_bytes(self.NEW)
|
|
self.assertEqual(self._download("x.bin"), ("x.bin", True, ""))
|
|
self.assertFalse((self.home / install.BACKUP_DIR).exists())
|
|
self.assertEqual(install.KEPT.summary(), [])
|
|
|
|
def test_a_missing_file_keeps_nothing(self):
|
|
self.assertEqual(self._download("x.bin"), ("x.bin", True, ""))
|
|
self.assertFalse((self.home / install.BACKUP_DIR).exists())
|
|
|
|
def test_a_second_replacement_keeps_the_first_file(self):
|
|
# Two lists in one run writing one path: what is kept is the user's.
|
|
(self.bios / "x.bin").write_bytes(self.OLD)
|
|
self._download("x.bin")
|
|
(self.bios / "x.bin").write_bytes(b"what this run wrote first")
|
|
self._download("x.bin")
|
|
self.assertEqual((self._run_folder() / "system" / "x.bin").read_bytes(), self.OLD)
|
|
|
|
def test_a_file_that_cannot_be_kept_stays(self):
|
|
(self.bios / "x.bin").write_bytes(self.OLD)
|
|
|
|
def refuse(*_args):
|
|
raise PermissionError(13, "Permission denied")
|
|
|
|
with unittest.mock.patch.object(install, "_move_aside", refuse):
|
|
_dest, ok, reason = self._download("x.bin")
|
|
self.assertFalse(ok)
|
|
self.assertIn(install.NOT_KEPT, reason)
|
|
self.assertEqual((self.bios / "x.bin").read_bytes(), self.OLD)
|
|
self.assertEqual(sorted(p.name for p in self.bios.iterdir()), ["x.bin"])
|
|
|
|
def test_a_symlinked_subfolder_keeps_working(self):
|
|
elsewhere = self.home / "shadPS4" / "sys_modules"
|
|
elsewhere.mkdir(parents=True)
|
|
(elsewhere / "lib.sprx").write_bytes(self.OLD)
|
|
(self.bios / "shadps4").mkdir()
|
|
(self.bios / "shadps4" / "sys_modules").symlink_to(elsewhere)
|
|
self.assertTrue(self._download("shadps4/sys_modules/lib.sprx")[1])
|
|
self.assertEqual((elsewhere / "lib.sprx").read_bytes(), self.NEW)
|
|
self.assertTrue((self.bios / "shadps4" / "sys_modules").is_symlink())
|
|
run = self._run_folder()
|
|
kept = run / "system" / "shadps4" / "sys_modules" / "lib.sprx"
|
|
self.assertEqual(kept.read_bytes(), self.OLD)
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
self.assertEqual(install.restore_kept(run), 0)
|
|
self.assertEqual((elsewhere / "lib.sprx").read_bytes(), self.OLD)
|
|
self.assertTrue((self.bios / "shadps4" / "sys_modules").is_symlink())
|
|
|
|
def test_a_link_at_the_name_is_not_kept(self):
|
|
# The replace takes the link's place: the file it points to is untouched.
|
|
target = self.home / "mine.bin"
|
|
target.write_bytes(self.OLD)
|
|
(self.bios / "x.bin").symlink_to(target)
|
|
self.assertTrue(self._download("x.bin")[1])
|
|
self.assertEqual(target.read_bytes(), self.OLD)
|
|
self.assertFalse((self.home / install.BACKUP_DIR).exists())
|
|
|
|
@unittest.skipIf(hasattr(os, "geteuid") and os.geteuid() == 0, "root writes anywhere")
|
|
def test_without_room_beside_the_folder_it_goes_inside(self):
|
|
(self.bios / "x.bin").write_bytes(self.OLD)
|
|
self.home.chmod(0o555)
|
|
self.addCleanup(self.home.chmod, 0o755)
|
|
self.assertTrue(self._download("x.bin")[1])
|
|
runs = list((self.bios / install.BACKUP_DIR).iterdir())
|
|
self.assertEqual(len(runs), 1)
|
|
self.assertEqual((runs[0] / "x.bin").read_bytes(), self.OLD)
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
self.assertEqual(install.restore_kept(runs[0]), 0)
|
|
self.assertEqual((self.bios / "x.bin").read_bytes(), self.OLD)
|
|
|
|
def test_a_data_tree_file_is_kept(self):
|
|
import zipfile
|
|
|
|
tree = self.bios / "dolphin-emu" / "Sys"
|
|
tree.mkdir(parents=True)
|
|
(tree / "a.ini").write_bytes(self.OLD)
|
|
archive = self.home / "src.zip"
|
|
with zipfile.ZipFile(archive, "w") as zf:
|
|
zf.writestr("Sys/a.ini", self.NEW)
|
|
entry = {"ref": "d", "dest": "dolphin-emu", "url": "https://example.invalid/d.zip",
|
|
"type": "zip", "files": {"Sys/a.ini": {
|
|
"sha1": hashlib.sha1(self.NEW).hexdigest(), "size": len(self.NEW)}}}
|
|
|
|
def fetch(url, directory):
|
|
copy = Path(directory) / "a.zip"
|
|
shutil.copy(archive, copy)
|
|
return copy
|
|
|
|
with unittest.mock.patch.object(install, "_fetch_archive", fetch):
|
|
counts = install.install_data_directory(entry, self.bios)
|
|
self.assertEqual(counts["installed"], 1)
|
|
self.assertEqual((tree / "a.ini").read_bytes(), self.NEW)
|
|
kept = self._run_folder() / "system" / "dolphin-emu" / "Sys" / "a.ini"
|
|
self.assertEqual(kept.read_bytes(), self.OLD)
|
|
|
|
def test_a_standalone_copy_keeps_the_emulators_file(self):
|
|
(self.bios / "ps2-0230a.bin").write_bytes(self.NEW)
|
|
pcsx2 = self.home / "PCSX2" / "bios"
|
|
pcsx2.mkdir(parents=True)
|
|
(pcsx2 / "ps2-0230a.bin").write_bytes(self.OLD)
|
|
manifest = {"standalone_copies": [
|
|
{"pattern": "ps2-*.bin", "emulator": "pcsx2", "targets": {"linux": [str(pcsx2)]}}]}
|
|
self.assertEqual(install.do_standalone_copies(manifest, self.bios, "linux"), (1, 0, 0))
|
|
self.assertEqual((pcsx2 / "ps2-0230a.bin").read_bytes(), self.NEW)
|
|
runs = list((self.home / "PCSX2" / install.BACKUP_DIR).iterdir())
|
|
self.assertEqual((runs[0] / "bios" / "ps2-0230a.bin").read_bytes(), self.OLD)
|
|
# Copied again, the file is the same: nothing more is kept.
|
|
self.assertEqual(install.do_standalone_copies(manifest, self.bios, "linux"), (1, 0, 0))
|
|
self.assertEqual(install.KEPT.summary()[0]["files"], 1)
|
|
|
|
def test_a_kept_file_inside_the_tree_is_no_copy_source(self):
|
|
inside = self.bios / install.BACKUP_DIR / "20261022-153000"
|
|
inside.mkdir(parents=True)
|
|
(inside / "ps2-old.bin").write_bytes(self.OLD)
|
|
pcsx2 = self.home / "PCSX2" / "bios"
|
|
pcsx2.mkdir(parents=True)
|
|
manifest = {"standalone_copies": [
|
|
{"pattern": "ps2-*.bin", "emulator": "pcsx2", "targets": {"linux": [str(pcsx2)]}}]}
|
|
self.assertEqual(install.do_standalone_copies(manifest, self.bios, "linux"), (0, 0, 0))
|
|
|
|
def test_restore_refuses_a_folder_that_is_no_backup(self):
|
|
with contextlib.redirect_stderr(io.StringIO()) as err:
|
|
self.assertEqual(install.restore_kept(self.bios), 2)
|
|
self.assertIn(install.BACKUP_DIR, err.getvalue())
|
|
|
|
def test_a_linked_backup_holder_is_not_written_through(self):
|
|
# Someone with write access to a shared parent folder placed a link
|
|
# where the holder goes: the user's file must not land at its target.
|
|
elsewhere = self.home / "elsewhere"
|
|
elsewhere.mkdir()
|
|
(self.home / install.BACKUP_DIR).symlink_to(elsewhere)
|
|
(self.bios / "x.bin").write_bytes(self.OLD)
|
|
self.assertTrue(self._download("x.bin")[1])
|
|
self.assertEqual(list(elsewhere.iterdir()), [])
|
|
inside = list((self.bios / install.BACKUP_DIR).iterdir())
|
|
self.assertEqual(len(inside), 1)
|
|
self.assertEqual((inside[0] / "x.bin").read_bytes(), self.OLD)
|
|
|
|
def test_a_holder_with_every_name_taken_is_not_reused(self):
|
|
# Folders planted under every dated name of this run: none of them
|
|
# may receive the user's file.
|
|
holder = self.home / install.BACKUP_DIR
|
|
stamp = "20260101-000000"
|
|
for n in range(1, 1000):
|
|
(holder / (stamp if n == 1 else f"{stamp}-{n}")).mkdir(parents=True)
|
|
(self.bios / "x.bin").write_bytes(self.OLD)
|
|
with unittest.mock.patch.object(install.time, "strftime", return_value=stamp):
|
|
self.assertTrue(self._download("x.bin")[1])
|
|
self.assertEqual(sum(1 for p in holder.rglob("x.bin")), 0)
|
|
inside = list((self.bios / install.BACKUP_DIR).iterdir())
|
|
self.assertEqual(len(inside), 1)
|
|
self.assertEqual((inside[0] / "x.bin").read_bytes(), self.OLD)
|
|
|
|
def test_a_holder_swapped_after_the_check_is_noticed(self):
|
|
holder = self.home / install.BACKUP_DIR
|
|
holder.mkdir()
|
|
checked = install._check_holder(holder)
|
|
self.assertTrue(install._same_folder(holder, checked))
|
|
holder.rename(self.home / "moved")
|
|
(self.home / "elsewhere").mkdir()
|
|
holder.symlink_to(self.home / "elsewhere")
|
|
self.assertFalse(install._same_folder(holder, checked))
|
|
with self.assertRaises(OSError):
|
|
install._check_holder(holder)
|
|
|
|
@unittest.skipUnless(hasattr(os, "getuid"), "user ids are POSIX")
|
|
def test_a_holder_another_user_owns_is_refused(self):
|
|
(self.home / install.BACKUP_DIR).mkdir()
|
|
with self.assertRaises(OSError):
|
|
with unittest.mock.patch.object(install.os, "getuid", return_value=os.getuid() + 1):
|
|
install._check_holder(self.home / install.BACKUP_DIR)
|
|
install._check_holder(self.home / install.BACKUP_DIR)
|
|
|
|
def test_restore_refuses_a_folder_the_installer_did_not_write(self):
|
|
planted = self.home / install.BACKUP_DIR / "20260101-000000"
|
|
(planted / "system").mkdir(parents=True)
|
|
(planted / "system" / "x.bin").write_bytes(b"not the user's")
|
|
(self.bios / "x.bin").write_bytes(self.OLD)
|
|
with contextlib.redirect_stderr(io.StringIO()):
|
|
self.assertEqual(install.restore_kept(planted), 2)
|
|
self.assertEqual((self.bios / "x.bin").read_bytes(), self.OLD)
|
|
|
|
def test_restore_does_not_follow_links_inside_the_backup(self):
|
|
(self.bios / "x.bin").write_bytes(self.OLD)
|
|
self.assertTrue(self._download("x.bin")[1])
|
|
run = self._run_folder()
|
|
outside = self.home / "outside"
|
|
(outside / "deep").mkdir(parents=True)
|
|
(outside / "deep" / "y.bin").write_bytes(b"foreign")
|
|
(run / "system" / "linked").symlink_to(outside)
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
self.assertEqual(install.restore_kept(run), 0)
|
|
self.assertEqual((self.bios / "x.bin").read_bytes(), self.OLD)
|
|
self.assertFalse((self.bios / "linked").exists())
|
|
|
|
|
|
class KeptFilesEndToEnd(unittest.TestCase, _SyntheticTree):
|
|
"""The run says what it kept and where, --dry-run what it would keep
|
|
and writes nothing, and --restore puts the original bytes back."""
|
|
|
|
def setUp(self):
|
|
self.env = self.serve_tree(self)
|
|
|
|
def test_dry_run_says_what_would_be_kept_and_writes_nothing(self):
|
|
proc = _install_run(self.env, "--platform", "retroarch", "--dest", str(self.bios),
|
|
"--dry-run", "--no-input")
|
|
self.assertEqual(proc.returncode, 0, proc.stderr)
|
|
self.assertIn("Would keep 1 file you have (11 B)", proc.stdout)
|
|
self.assertIn(str(self.home / install.BACKUP_DIR), proc.stdout)
|
|
self.assertFalse((self.home / install.BACKUP_DIR).exists())
|
|
self.assertEqual((self.bios / "b.bin").read_bytes(), b"other bytes")
|
|
events = _events(_install_run(self.env, "--platform", "retroarch", "--dest",
|
|
str(self.bios), "--dry-run", "--events"))
|
|
kept = events[-2]["kept"]
|
|
self.assertEqual([(item["files"], item["bytes"]) for item in kept], [(1, 11)])
|
|
self.assertFalse((self.home / install.BACKUP_DIR).exists())
|
|
|
|
def test_install_then_restore(self):
|
|
proc = _install_run(self.env, "--platform", "retroarch", "--dest", str(self.bios),
|
|
"--no-input", "--jobs", "1")
|
|
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
|
self.assertEqual((self.bios / "b.bin").read_bytes(), self.FILES["bios/A/b.bin"])
|
|
runs = list((self.home / install.BACKUP_DIR).iterdir())
|
|
self.assertEqual(len(runs), 1)
|
|
self.assertIn("Kept 1 file you had (11 B), which the collection's copies "
|
|
f"replaced, in {runs[0]}", proc.stdout)
|
|
self.assertIn(f"--restore {runs[0]}", proc.stdout)
|
|
# a.bin held the expected bytes: it is not among the kept files.
|
|
self.assertEqual([p.name for p in (runs[0] / "bios").iterdir()], ["b.bin"])
|
|
|
|
refused = _install_run(self.env, "--restore", str(runs[0]), "--platform", "retroarch")
|
|
self.assertEqual(refused.returncode, 2)
|
|
self.assertIn("--restore", refused.stderr)
|
|
|
|
looked = _install_run(self.env, "--restore", str(runs[0]), "--dry-run")
|
|
self.assertEqual(looked.returncode, 0, looked.stderr)
|
|
self.assertIn(f"would put back {self.bios / 'b.bin'}", looked.stdout)
|
|
self.assertEqual((self.bios / "b.bin").read_bytes(), self.FILES["bios/A/b.bin"])
|
|
|
|
back = _install_run(self.env, "--restore", str(runs[0]))
|
|
self.assertEqual(back.returncode, 0, back.stdout + back.stderr)
|
|
self.assertEqual((self.bios / "b.bin").read_bytes(), b"other bytes")
|
|
self.assertEqual((self.bios / "a.bin").read_bytes(), self.FILES["bios/A/a.bin"])
|
|
self.assertIn("Put back 1 file", back.stdout)
|
|
# The backup stays until the user deletes it.
|
|
self.assertTrue((runs[0] / "bios" / "b.bin").is_file())
|
|
|
|
# A second install keeps the restored file in a new folder.
|
|
again = _install_run(self.env, "--platform", "retroarch", "--dest", str(self.bios),
|
|
"--no-input", "--jobs", "1")
|
|
self.assertEqual(again.returncode, 0, again.stderr)
|
|
self.assertEqual(len(list((self.home / install.BACKUP_DIR).iterdir())), 2)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|