mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-29 15:17:20 -05:00
chore: merge the installer audit fixes
This commit is contained in:
commit
2f653c2228
11 files changed
+997
-95
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])]
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user