chore: merge the installer audit fixes

This commit is contained in:
Abdessamad Derraz committed 2026-10-23 14:36:05 +02:00
commit 2f653c2228
11 files changed
+997 -95

No files matched your search

+2 -1
View File
@@ -392,7 +392,8 @@ class ExitStatusTable(unittest.TestCase):
section = _section(_page("installer.md"), "Exit status")
documented = {int(row[0].strip("`")) for row in _table_rows(section)}
raised = {int(code) for code in re.findall(r"sys\.exit\((\d+)\)", source)}
self.assertEqual(documented, raised | {2})
# 0 is a run that returns, 2 the code argparse leaves with.
self.assertEqual(documented, raised | {0, 2})
self.assertIn("install.py: error:", section)
for option in set(re.findall(r"(--[a-z][a-z-]+)", section)):
self.assertIn(f'"{option}"', source, option)
+200 -4
View File
@@ -1784,7 +1784,7 @@ class TestDetectedPlatformPrompt(unittest.TestCase):
def test_q_quits_and_the_menu_says_so(self):
result, out = self._answer("q")
self.assertIsInstance(result, SystemExit)
self.assertEqual(result.code, 0)
self.assertEqual(result.code, 130)
self.assertIn("q) quit", out)
def test_a_wrong_answer_names_the_choices(self):
@@ -1808,6 +1808,16 @@ class TestManualPlatformPrompt(unittest.TestCase):
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)):
@@ -1820,11 +1830,51 @@ class TestManualPlatformPrompt(unittest.TestCase):
chosen = install._prompt_manual_platform("linux")
self.assertEqual(chosen, [("batocera", install.DEFAULT_DESTS["batocera"])])
def test_quit_exits_cleanly(self):
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, 0)
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", ""])
@@ -3261,7 +3311,8 @@ class AChunkedCutIsADownloadFailure(unittest.TestCase):
unittest.mock.patch.object(install, "_retry_delay", return_value=0):
dest, ok, reason = install._download_one(entry, Path(_tempdir(self)))
self.assertFalse(ok)
self.assertIn("IncompleteRead", reason)
self.assertEqual(reason, "the connection was cut")
self.assertIsInstance(reason, install.NetworkReason)
@@ -3719,6 +3770,21 @@ class ATypoGetsTheClosestName(unittest.TestCase):
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:
@@ -4672,8 +4738,28 @@ class HandheldSystemsByTheirOwnFiles(unittest.TestCase):
{"/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')
@@ -4918,6 +5004,31 @@ def _events(proc: subprocess.CompletedProcess) -> list[dict]:
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."""
@@ -5037,6 +5148,91 @@ class EventsDescribeTheRun(unittest.TestCase, _SyntheticTree):
self.assertIn(f"`{key}`", row, f"{kind}.{key}")
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
+155
View File
@@ -404,6 +404,161 @@ class ReviewCases(unittest.TestCase):
progress.add(40)
self.assertEqual(progress.transferred(), 40)
class _HeldHandler(http.server.BaseHTTPRequestHandler):
"""Serves the manifest and first.bin at once, every other file once released."""
files: dict[str, bytes] = {}
manifest = b""
release = threading.Event()
def log_message(self, *args) -> None: # noqa: D102
pass
def do_GET(self) -> None: # noqa: N802, D102
if self.path.endswith(".json"):
body = self.manifest
else:
name = self.path.rsplit("/", 1)[-1]
if name != "first.bin":
self.release.wait(30)
body = self.files[name]
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
class AStoppedRunCountsWhatLanded(unittest.TestCase):
"""Stopped at 8 of 118 files, the summary said "118 files installed":
every queued file was counted, written or not, and a user who read it
did not run the command again."""
@unittest.skipIf(os.name == "nt", "SIGINT is sent the POSIX way")
def test_only_the_written_file_is_counted(self):
import signal # noqa: PLC0415
import time # noqa: PLC0415
files = {name: name.encode() * 64 for name in ("first.bin", "second.bin", "third.bin")}
manifest = {"manifest_version": 2, "platform": "retroarch", "files": [
{"dest": name, "size": len(data), "sha1": hashlib.sha1(data).hexdigest(),
"repo_path": f"bios/{name}", "cores": None}
for name, data in files.items()
]}
_HeldHandler.files, _HeldHandler.manifest = files, json.dumps(manifest).encode()
_HeldHandler.release = threading.Event()
httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _HeldHandler)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
self.addCleanup(httpd.server_close)
self.addCleanup(httpd.shutdown)
self.addCleanup(_HeldHandler.release.set)
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp:
dest = Path(tmp) / "bios"
env = dict(os.environ,
RETROBIOS_BASE_URL=f"http://127.0.0.1:{httpd.server_address[1]}")
proc = subprocess.Popen(
[sys.executable, str(REPO_ROOT / "install.py"), "--platform", "retroarch",
"--dest", str(dest), "--no-input"],
env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True,
)
deadline = time.monotonic() + 30
while not (dest / "first.bin").exists() and time.monotonic() < deadline:
time.sleep(0.05)
time.sleep(0.2)
proc.send_signal(signal.SIGINT)
time.sleep(0.3)
_HeldHandler.release.set()
out, err = proc.communicate(timeout=60)
written = sorted(p.name for p in dest.iterdir() if not p.name.startswith("."))
self.assertEqual(proc.returncode, 130, out + err)
self.assertEqual(written, ["first.bin"])
self.assertIn("Stopped. 1 file installed.", out)
class ARefusedFolderReportsEachFile(unittest.TestCase):
"""A read-only folder ended the run on its first refusal, and --events
wrote one `downloaded` line for seven failed files, while the events
table promises one per finished file."""
@unittest.skipIf(os.name == "nt" or os.geteuid() == 0,
"a read-only folder is refused by POSIX permissions")
def test_one_event_per_file_with_the_reason(self):
with FakeRepository() as repo:
repo.dest.mkdir()
os.chmod(repo.dest, 0o555)
try:
proc = repo.run("--events")
finally:
os.chmod(repo.dest, 0o755)
events = [json.loads(line) for line in proc.stdout.splitlines()]
downloaded = [e for e in events if e["event"] == "downloaded"]
self.assertEqual(proc.returncode, 2, proc.stderr)
self.assertEqual(len(downloaded), len(FakeRepository.PAYLOADS))
self.assertEqual({e["reason"] for e in downloaded}, {install.NOT_WRITABLE})
self.assertEqual(sorted(e["files_done"] for e in downloaded),
list(range(1, len(downloaded) + 1)))
class _Tty(io.StringIO):
def isatty(self) -> bool:
return True
class ColourFollowsNoColorOrg(unittest.TestCase):
"""no-color.org: only a NO_COLOR that is set and not empty turns colour
off. An empty one, as `NO_COLOR= cmd` leaves, turned it off too."""
def _colour(self, **env: str) -> bool:
clean = {k: v for k, v in os.environ.items()
if k not in ("NO_COLOR", "FORCE_COLOR", "CLICOLOR_FORCE", "TERM")}
with mock.patch.dict(os.environ, {**clean, **env}, clear=True), \
mock.patch.object(install, "_enable_ansi", return_value=True):
return install.Terminal(_Tty()).colour
def test_an_empty_no_color_leaves_colour_on(self):
self.assertTrue(self._colour())
self.assertTrue(self._colour(NO_COLOR=""))
self.assertFalse(self._colour(NO_COLOR="1"))
def test_force_color_paints_a_pipe(self):
with mock.patch.object(install, "_enable_ansi", return_value=True):
for env, expected in (({}, False), ({"FORCE_COLOR": "1"}, True),
({"CLICOLOR_FORCE": "1"}, True),
({"FORCE_COLOR": "0"}, False),
({"FORCE_COLOR": "1", "NO_COLOR": "1"}, False)):
clean = {k: v for k, v in os.environ.items()
if k not in ("NO_COLOR", "FORCE_COLOR", "CLICOLOR_FORCE")}
with self.subTest(env=env), \
mock.patch.dict(os.environ, {**clean, **env}, clear=True):
term = install.Terminal(io.StringIO())
self.assertEqual(term.colour, expected)
self.assertFalse(term.live)
class SmallWordings(unittest.TestCase):
def test_check_asks_which_to_check(self):
"""--check with several platforms asked "Install for:", and
installs nothing."""
out = io.StringIO()
with mock.patch("builtins.input", lambda _prompt="": "1"), \
contextlib.redirect_stdout(out):
install._prompt_platform_choice(
[("emudeck", Path("/e")), ("retroarch", Path("/r"))], "Check:")
self.assertIn("Check:", out.getvalue())
self.assertNotIn("Install for:", out.getvalue())
def test_nothing_detected_is_said(self):
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as home:
env = dict(os.environ, HOME=home, USERPROFILE=home, XDG_CONFIG_HOME=home,
RETROBIOS_OS="linux")
proc = subprocess.run(
[sys.executable, str(REPO_ROOT / "install.py"), "--list-platforms"],
env=env, capture_output=True, text=True, timeout=60, check=False,
)
self.assertEqual(proc.returncode, 0, proc.stderr)
self.assertIn("Detected on this system: none", proc.stdout)
class WindowsMandatoryLock(unittest.TestCase):
"""Issue 82: on Windows the partial copy's lock is mandatory, so hashing
it by reopening its path failed with a sharing violation, and every file
+22 -1
View File
@@ -477,12 +477,33 @@ class TheSummaryNamesTheBulk(unittest.TestCase):
_sized("mame/3do_m2.zip", 37 * MB, ["3do"], ["mame"]),
]
lines = install.bulk_hint(files, {"system_cores": {"3do": ["mame", "opera"]}},
systems_chosen=True, cores_chosen=False)
systems_chosen=True, cores_chosen=False,
accepted={"mame", "opera"})
self.assertEqual(lines, [
" mame pulls 337.0 MB of this 349.0 MB download for 3do. --core opera "
"keeps 12.0 MB of it and saves 337.0 MB (96%)."
])
def test_only_a_core_the_option_accepts_is_advised(self):
"""`--core ares keeps 11.0 MB` was printed, and `--core ares` then
answered "unknown core": ares emulates the PlayStation and loads no
file of its own, and --core takes only the cores that do."""
files = [
_sized("scph5501.bin", 1 * MB, ["sony-playstation"]),
_sized("ps2_bios.bin", 300 * MB, ["sony-playstation"], ["duckstation"]),
_sized("pcsx/extra.bin", 2 * MB, ["sony-playstation"], ["pcsx_rearmed"]),
]
manifest = {"system_cores": {"sony-playstation": ["ares", "duckstation",
"pcsx_rearmed"]}}
lines = install.bulk_hint(files, manifest, True, False,
accepted={"duckstation", "pcsx_rearmed"})
self.assertEqual(len(lines), 1)
self.assertIn("--core pcsx_rearmed keeps 3.0 MB", lines[0])
self.assertNotIn("ares", lines[0])
self.assertEqual(install.bulk_hint(files, manifest, True, False,
accepted={"duckstation"}), [])
self.assertEqual(install.bulk_hint(files, manifest, True, False), [])
def test_a_system_pulling_most_of_the_download_is_named(self):
files = [_sized("a.zip", 400 * MB, ["arcade"]), _sized("b.bin", 50 * MB, ["nes"]),
_sized("c.bin", 10 * MB, ["gba"])]
+196
View File
@@ -9,10 +9,12 @@ to one answer.
from __future__ import annotations
import contextlib
import functools
import hashlib
import http.server
import importlib.util
import io
import json
import os
import random
@@ -311,6 +313,24 @@ class CommandLineChoices(unittest.TestCase):
self.assertIn("sega-saturn", str(ctx.exception))
self.assertIn("nintendo-gba", str(ctx.exception))
def test_a_short_name_is_suggested_never_taken(self):
"""`--system gba`, `psx` and `ps1` were refused on RetroArch with no
suggestion, though nintendo-gba holds the very letters."""
known = [f"system-{i}" for i in range(40)] + [
"nintendo-gb", "nintendo-gba", "nintendo-gbc", "sony-playstation",
"sony-playstation-2", "sega-mega-drive"]
for typed, meant in (("gba", "nintendo-gba"), ("psx", "sony-playstation"),
("ps1", "sony-playstation"), ("PS2", "sony-playstation-2"),
("genesis", "sega-mega-drive"), ("gb", "nintendo-gb")):
with self.subTest(typed=typed), self.assertRaises(ValueError) as ctx:
install.resolve_choices([typed], known, "system")
self.assertIn(f"Did you mean: {meant}", str(ctx.exception))
def test_a_short_name_for_a_system_the_list_lacks_is_not_suggested(self):
with self.assertRaises(ValueError) as ctx:
install.resolve_choices(["psx"], [f"system-{i}" for i in range(40)], "system")
self.assertNotIn("sony-playstation", str(ctx.exception))
def test_an_empty_list_is_refused(self):
with self.assertRaises(ValueError):
install.resolve_regions([" , "])
@@ -334,6 +354,114 @@ class CommandLineChoices(unittest.TestCase):
install.parse_selection(bad, 5)
class DecliningTheSizePointsAtTheChoice(unittest.TestCase):
"""Saying no to `Download 8.4 GB?` printed "--system, --core and
--region install less (see --help)": a 91-line help for someone who had
just passed the c entry that does it."""
def _run(self, answers: list[str], *options: str) -> str:
replies = iter(answers)
out = io.StringIO()
with FakeRepository() as repo, \
mock.patch.object(sys, "argv", ["install.py", "--platform", "retroarch",
"--dest", str(repo.dest), *options]), \
mock.patch.object(install, "MANIFEST_URL",
repo.env()["RETROBIOS_BASE_URL"] + "/install/{platform}.json"), \
mock.patch.object(install, "RAW_FILE_URL",
repo.env()["RETROBIOS_BASE_URL"] + "/{path}"), \
mock.patch.object(install, "CONFIRM_FROM_BYTES", 1), \
mock.patch("sys.stdin", mock.Mock(isatty=lambda: True)), \
mock.patch("builtins.input", lambda _prompt="": next(replies)), \
contextlib.redirect_stdout(out):
try:
install.main()
except SystemExit:
pass
self.assertEqual(repo.installed(), [])
return out.getvalue()
def test_install_everything_then_no_names_the_c_entry(self):
out = self._run(["", "n"])
self.assertIn("type c at the first question", out)
self.assertNotIn("see --help", out)
def test_options_given_keep_the_option_advice(self):
out = self._run(["n"], "--system", "sony-playstation")
self.assertIn("--system, --core and --region install less", out)
class ACoreWithNoFileOfItsOwn(unittest.TestCase):
"""pcsx_rearmed, swanstation and pcsx1 showed `0 files, 0 B` in the
cores step and in --list-cores, which reads as "this core gets nothing":
their files are the platform's own, which are always kept."""
FILES = [
{"dest": "scph5501.bin", "size": 5, "cores": None, "systems": ["sony-playstation"],
"declared_choices": [], "slot_choices": [{"cores": ["pcsx_rearmed"]}]},
{"dest": "ps2.bin", "size": 9, "cores": ["duckstation"],
"systems": ["sony-playstation"]},
]
def _line(self, out: str, core: str) -> str:
return next(line for line in out.splitlines() if line.strip().startswith(core)
or f") {core}" in line)
def test_the_listing_says_its_files_are_the_platforms(self):
out = io.StringIO()
with contextlib.redirect_stdout(out):
install._print_choices("retroarch", self.FILES, [], systems=False)
line = self._line(out.getvalue(), "pcsx_rearmed")
self.assertNotIn("0 files", line)
self.assertIn("always kept", line)
self.assertIn("1 file, 9 B", self._line(out.getvalue(), "duckstation"))
def test_the_cores_step_says_so_too(self):
answers = iter(["c", "", ""])
out = io.StringIO()
with mock.patch("builtins.input", lambda _prompt="": next(answers)), \
contextlib.redirect_stdout(out):
install._prompt_custom_selection("retroarch", self.FILES, [])
line = self._line(out.getvalue(), "pcsx_rearmed")
self.assertNotIn("0 files", line)
self.assertIn("always kept", line)
class FilesSharedBySystemsAreCountedApart(unittest.TestCase):
"""`3do 1704 files, 349.0 MB`: 1689 of them are MAME's hash and plugin
files, which six systems carry. Picking 3DO, one expects its BIOS."""
FILES = [
{"dest": "panafz10.bin", "size": 4, "cores": None, "systems": ["3do"]},
{"dest": "mame/hash/a.xml", "size": 100, "cores": None,
"systems": ["3do", "philips-cdi"]},
{"dest": "mame/hash/b.xml", "size": 50, "cores": None,
"systems": ["3do", "philips-cdi"]},
{"dest": "gba_bios.bin", "size": 8, "cores": None, "systems": ["nintendo-gba"]},
]
def _listing(self) -> str:
out = io.StringIO()
with contextlib.redirect_stdout(out):
install._print_choices("retroarch", self.FILES, [], systems=True)
return out.getvalue()
def test_the_listing(self):
lines = {line.split()[0]: line for line in self._listing().splitlines()}
self.assertIn("1 file, 4 B, and 2 files, 150 B shared with other systems",
lines["3do"])
self.assertIn("2 files, 150 B, all shared with other systems", lines["philips-cdi"])
self.assertTrue(lines["nintendo-gba"].endswith("1 file, 8 B"))
def test_the_systems_step(self):
answers = iter(["c", ""])
out = io.StringIO()
with mock.patch("builtins.input", lambda _prompt="": next(answers)), \
contextlib.redirect_stdout(out):
install._prompt_custom_selection("retroarch", self.FILES, [])
self.assertIn("1 file, 4 B, and 2 files, 150 B shared with other systems",
out.getvalue())
class TheBoundaryChecksTheNewFields(unittest.TestCase):
def _manifest(self, **extra):
entry = {"dest": "a.bin", "sha1": "a" * 40, "size": 1,
@@ -544,6 +672,74 @@ class TheOneLinerNarrows(unittest.TestCase):
self.assertIn("--system sony-playstation --region north-america", out)
self.assertEqual(repo.installed(), ["pcsx/extra.bin", "scph5501.bin"])
@unittest.skipUnless(shutil.which("script"), "script(1) provides the terminal")
def test_a_quoted_folder_typed_at_the_prompt(self):
"""The quotes "Copy as path" adds were kept: the files went into a
folder named `"` under the current directory, created without the
question --dest asks, and the run said Done."""
with FakeRepository() as repo:
home = repo.root / "home"
home.mkdir()
# Read for the standalone emulator folders, of which there are none.
(repo.root / "install" / "emudeck.json").write_text(json.dumps(
{"manifest_version": 2, "platform": "emudeck", "files": []}))
card = repo.root / "My Card" / "bios"
env = dict(repo.env(), HOME=str(home), USERPROFILE=str(home),
XDG_CONFIG_HOME=str(home), RETROBIOS_OS="linux")
command = f"{shlex.quote(sys.executable)} {shlex.quote(str(REPO_ROOT / 'install.py'))}"
for answer, code in (("n", 2), ("y", 0)):
with self.subTest(answer=answer):
proc = subprocess.run(
["script", "-qec", command, "/dev/null"],
input=f'1\n"{card}"\n{answer}\n\n', env=env, cwd=repo.root,
capture_output=True, text=True, timeout=60, check=False,
)
self.assertEqual(proc.returncode, code, proc.stdout + proc.stderr)
self.assertIn(f"Create 2 folders under {repo.root}?", proc.stdout)
self.assertFalse((repo.root / '"').exists())
self.assertEqual(len([p for p in card.rglob("*") if p.is_file()]), 5)
def test_q_at_every_menu_stops_on_130(self):
"""`install.sh && retroarch` started RetroArch after a q, which left
with 0 where Ctrl+C and Ctrl+D leave with 130."""
files = [{"dest": "a.bin", "size": 1, "systems": ["nintendo-gba"]}]
menus = (
lambda: install._choose_platform("Platform:"),
lambda: install._pick("Systems:", [("nintendo-gba", files)]),
lambda: install._prompt_custom_selection("retroarch", files, []),
)
for menu in menus:
with self.subTest(menu=menu), \
mock.patch("builtins.input", lambda _prompt="": "q"), \
mock.patch("sys.stdout", new_callable=lambda: open(os.devnull, "w")), \
self.assertRaises(SystemExit) as stop:
menu()
self.assertEqual(stop.exception.code, 130)
@unittest.skipIf(os.name == "nt", "a pseudo-terminal is a POSIX one")
def test_ctrl_d_at_the_first_question_has_nothing_to_carry_on(self):
import pty # noqa: PLC0415
with FakeRepository() as repo:
home = repo.root / "home"
home.mkdir()
(repo.root / "install" / "emudeck.json").write_text(json.dumps(
{"manifest_version": 2, "platform": "emudeck", "files": []}))
env = dict(repo.env(), HOME=str(home), USERPROFILE=str(home),
XDG_CONFIG_HOME=str(home), RETROBIOS_OS="linux")
leader, follower = pty.openpty()
proc = subprocess.Popen(
[sys.executable, str(REPO_ROOT / "install.py")], stdin=follower,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, text=True,
)
os.close(follower)
os.write(leader, b"\x04")
out, err = proc.communicate(timeout=60)
os.close(leader)
self.assertEqual(proc.returncode, 130, out + err)
self.assertIn("Stopped. Nothing was installed.", out)
self.assertNotIn("carry on", out)
@unittest.skipUnless(shutil.which("script"), "script(1) provides the terminal")
def test_enter_installs_everything_at_a_terminal(self):
with FakeRepository() as repo: