chore: merge the round 4 installer fixes

This commit is contained in:
Abdessamad Derraz committed 2026-10-23 22:48:24 +02:00
commit 14e3923533
12 files changed
+541 -73

No files matched your search

+10
View File
@@ -682,6 +682,16 @@ class FirstInstallWalkthrough(unittest.TestCase):
self.text = _page("first-install.md")
self.run = _page("installer.md")
def test_the_stop_keys_are_the_ones_the_screen_names(self):
"""The page said "B or Ctrl+C stops it" for a computer, whose screen
says Esc or Ctrl-C, and where the b key does nothing."""
import install
section = _section(self.text, "4. Wait")
computer = install._STOP_KEYS["keys"][0].replace("Ctrl-C", "Ctrl+C")
self.assertIn(f"{computer} stops", section)
self.assertIn(f"{install._STOP_KEYS['gptokeyb'][0]} on a handheld", section)
def test_no_hand_kept_size_before_the_run(self):
import install
+34
View File
@@ -488,5 +488,39 @@ class TheOneLinerReadsInstalledCores(unittest.TestCase):
self.assertEqual(repo.installed(), ["gba_bios.bin", "mgba/extra.bin"])
class TheInterfaceFindsTheCoresLineModeFinds(unittest.TestCase):
"""The interface names the BIOS folder with --dest, so its runs never
read retroarch.cfg: "only installed cores" found none where line mode,
detecting the same config, found them (macOS, Flatpak, any custom
system_directory)."""
def test_a_config_away_from_the_bios_folder(self):
with _CoreRepository() as repo:
(repo.root / "retroarch.cfg").unlink()
home = repo.root / "home"
config = home / ".config" / "retroarch"
config.mkdir(parents=True)
cores = repo.root / "elsewhere" / "my cores"
cores.mkdir(parents=True)
(cores / "mgba_libretro.so").write_bytes(b"")
(config / "retroarch.cfg").write_text(
f'system_directory = "{repo.dest}"\n'
f'libretro_directory = "{cores}"\n')
env = {**repo.env(), "HOME": str(home), "RETROBIOS_OS": "linux",
"XDG_CONFIG_HOME": str(home / ".config")}
repo.dest.mkdir()
found = []
for given in ((), ("--dest", str(repo.dest))):
command = [sys.executable, str(REPO_ROOT / "install.py"), "--platform",
"retroarch", *given, "--installed-cores", "--check"]
proc = subprocess.run(command, env=env, capture_output=True, text=True,
timeout=60, check=False, stdin=subprocess.DEVNULL)
self.assertNotIn("no installed core found", proc.stderr)
found.append([line for line in proc.stdout.splitlines()
if "core installed" in line or "Narrowed" in line])
self.assertEqual(found[0], found[1])
self.assertIn(" 1 core installed", found[1])
if __name__ == "__main__":
unittest.main()
+83
View File
@@ -507,6 +507,89 @@ class ARefusedFolderReportsEachFile(unittest.TestCase):
list(range(1, len(downloaded) + 1)))
class AFolderThisUserCannotEnter(unittest.TestCase):
"""A subfolder of the BIOS folder without search permission (made 0700
by an emulator that ran as root, or denied by a Windows ACL) raised
PermissionError from Path.exists() before any file was handled, in
--check and in an install alike."""
def _locked(self, repo: FakeRepository) -> Path:
(repo.dest / "pcsx").mkdir(parents=True)
(repo.dest / "pcsx" / "extra.bin").write_bytes(b"old")
os.chmod(repo.dest / "pcsx", 0o000)
return repo.dest / "pcsx"
@unittest.skipIf(os.name == "nt" or os.geteuid() == 0,
"a folder without search permission is POSIX")
def test_check_names_the_file_and_reads_the_others(self):
with FakeRepository() as repo:
locked = self._locked(repo)
try:
proc = repo.run("--check")
finally:
os.chmod(locked, 0o755)
self.assertNotIn("Traceback", proc.stderr)
self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr)
self.assertIn("wrong hash: pcsx/extra.bin", proc.stdout)
self.assertIn("could not be read (Permission denied)", proc.stdout)
self.assertIn("missing: scph5501.bin", proc.stdout)
@unittest.skipIf(os.name == "nt" or os.geteuid() == 0,
"a folder without search permission is POSIX")
def test_install_fails_that_file_alone(self):
with FakeRepository() as repo:
locked = self._locked(repo)
try:
proc = repo.run("--events")
finally:
os.chmod(locked, 0o755)
installed = repo.installed()
self.assertNotIn("Traceback", proc.stderr)
events = [json.loads(line) for line in proc.stdout.splitlines()]
failed = {e["dest"]: e["reason"] for e in events
if e["event"] == "downloaded" and not e["ok"]}
self.assertEqual(list(failed), ["pcsx/extra.bin"], proc.stderr)
self.assertIn("this user may not have access", failed["pcsx/extra.bin"])
self.assertIn("scph5501.bin", installed)
self.assertIn("mgba/extra.bin", installed)
@unittest.skipIf(os.name == "nt" or os.geteuid() == 0,
"a folder without search permission is POSIX")
def test_declared_state_reads_it_as_wrong(self):
with FakeRepository() as repo:
locked = self._locked(repo)
entry = {"dest": "pcsx/extra.bin", "sha1": "0" * 40, "size": 3}
try:
state = install._declared_state(entry, entry, repo.dest)
finally:
os.chmod(locked, 0o755)
self.assertEqual(state, "wrong_hash")
class OutputToAFileAsksNothing(unittest.TestCase):
"""`install.py > log.txt` at a terminal: the menu went into the file
and the run waited on an answer to a prompt nobody could see."""
@unittest.skipIf(os.name == "nt", "a pseudo-terminal for stdin")
def test_the_run_goes_on_as_with_no_input(self):
import pty
master, slave = pty.openpty()
try:
with FakeRepository() as repo:
proc = subprocess.run(
repo.command(), env=repo.env(), stdin=slave, capture_output=True,
text=True, timeout=60, check=False)
installed = repo.installed()
finally:
os.close(master)
os.close(slave)
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertIn("nothing is asked", proc.stderr)
self.assertNotIn("Enter) install everything", proc.stdout)
self.assertEqual(len(installed), len(FakeRepository.PAYLOADS))
class _Tty(io.StringIO):
def isatty(self) -> bool:
return True
+61
View File
@@ -854,6 +854,67 @@ class AFolderAroundTheBiosFolder(FromFolder):
["old card/copy.zip, inside as inner.bin"])
@unittest.skipUnless(Path("/proc/self/fd").is_dir(), "reads the open files from /proc")
def test_an_archive_the_list_replaces_is_not_held_open(self):
"""Windows refuses to rename, replace or delete a file a process
holds open. The --from index kept the list's own wrong archive open
once its members had been listed, and the run failed to move it
aside, every run."""
good = io.BytesIO()
with zipfile.ZipFile(good, "w") as archive:
archive.writestr("a.bin", b"A" * 300)
good = good.getvalue()
card = self.root / "card"
bios = card / "bios"
_zip(bios / "neogeo.zip", {"x.bin": b"X" * 77})
(card / "backup").mkdir()
(card / "backup" / "neogeo.zip").write_bytes(good)
_zip(card / "backup" / "pack.zip", {"renamed.bin": GBA})
self.list.write_text(json.dumps({
"manifest_version": 2, "platform": "retroarch",
"files": [_entry("neogeo.zip", good, ["snk-neogeo"]),
_entry("gba_bios.bin", GBA, ["nintendo-gba"])]}))
def held() -> set[str]:
out = set()
for fd in os.listdir("/proc/self/fd"):
with contextlib.suppress(OSError):
out.add(os.path.realpath(os.readlink(f"/proc/self/fd/{fd}")))
return out
real_replace, real_rename, real_unlink = os.replace, os.rename, os.unlink
def refusing(real):
def call(*paths, **kwargs):
files = paths[:2] if real is not real_unlink else paths[:1]
if any(os.path.realpath(path) in held() for path in files):
raise PermissionError(13, "The process cannot access the file",
str(files[0]))
return real(*paths, **kwargs)
return call
argv = ["install.py", "--platform", "retroarch", "--dest", str(bios),
"--manifest", str(self.list), "--from", str(card), "--no-input", "--yes"]
out = io.StringIO()
with mock.patch.object(sys, "argv", argv), \
mock.patch.dict(os.environ, {"RETROBIOS_INSTALL_OFFLINE": "1"}), \
mock.patch.object(os, "replace", refusing(real_replace)), \
mock.patch.object(os, "rename", refusing(real_rename)), \
mock.patch.object(os, "unlink", refusing(real_unlink)), \
mock.patch.object(os, "remove", refusing(real_unlink)), \
contextlib.redirect_stdout(out), contextlib.redirect_stderr(out):
try:
install.main()
code = 0
except SystemExit as exc:
code = exc.code
finally:
install.LOCAL.reset()
self.assertEqual(code, 0, out.getvalue())
self.assertEqual((bios / "neogeo.zip").read_bytes(), good)
self.assertEqual((bios / "gba_bios.bin").read_bytes(), GBA)
class ManySameSizedFiles(FromFolder):
"""A card of 16 KB ROMs and a list of 16 KB entries: each entry sorted
and walked every file of its size."""
+15 -2
View File
@@ -374,6 +374,7 @@ class DecliningTheSizePointsAtTheChoice(unittest.TestCase):
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.object(install, "_is_terminal", return_value=True), \
mock.patch("builtins.input", lambda _prompt="": next(replies)), \
contextlib.redirect_stdout(out):
try:
@@ -744,14 +745,26 @@ class TheOneLinerNarrows(unittest.TestCase):
env = dict(repo.env(), HOME=str(home), USERPROFILE=str(home),
XDG_CONFIG_HOME=str(home), RETROBIOS_OS="linux")
leader, follower = pty.openpty()
# The question needs the output on the terminal too: sent to a
# pipe, the run asks nothing.
proc = subprocess.Popen(
[sys.executable, str(REPO_ROOT / "install.py")], stdin=follower,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, text=True,
stdout=follower, stderr=subprocess.PIPE, env=env, text=True,
)
os.close(follower)
os.write(leader, b"\x04")
out, err = proc.communicate(timeout=60)
err = proc.communicate(timeout=60)[1]
chunks = []
while True:
try:
chunk = os.read(leader, 65536)
except OSError:
break
if not chunk:
break
chunks.append(chunk)
os.close(leader)
out = b"".join(chunks).decode("utf-8", "replace")
self.assertEqual(proc.returncode, 130, out + err)
self.assertIn("Stopped. Nothing was installed.", out)
self.assertNotIn("carry on", out)
+87 -1
View File
@@ -2205,6 +2205,33 @@ class AuditRoundThree(unittest.TestCase):
# P2-1: a card too small for the download.
def test_a_stop_asked_for_says_only_the_outcome(self):
"""Start+Select (SIGTERM) or SIGHUP stops the child before it writes
a summary: the farewell then listed the log's last lines."""
state = checked_state()
state.download = {"files": 50}
state.finished = [("scph5501.bin", True, "")]
state.tail = [" [2/50] scph5501.bin ok",
" Stopping. Files already installed are kept."]
for code, shown in ((130, False), (1, True)):
with self.subTest(code=code):
state.code = code
_title, lines = install.tui_outcome(state)
self.assertEqual(any("[2/50]" in line for line in lines), shown, lines)
def test_a_pack_installs_from_the_pack_not_the_network(self):
"""With --from-pack and no Wi-Fi, the plan said "to download", which
on a handheld with no network read as a failure on its way."""
state = checked_state()
with mock.patch.object(install.LOCAL, "pack", object()):
headline = str(install.tui_view(state, 80, 24)[0])
plan = "\n".join(str(line) for line in install.tui_view(state, 80, 24))
state.summary = {"installed": 3, "failed": [], "up_to_date": 0}
title, lines = install.tui_outcome(state)
self.assertIn("to install from the pack", plan, headline)
self.assertNotIn("to download", plan)
self.assertEqual(lines[0], "3 files installed from the pack.")
def test_p2_1_another_folder_comes_first_when_space_is_short(self):
state = checked_state({"free": 1024, "writable": True, "cores": []})
self.assertEqual([row.label for row in install.tui_rows(state)[:2]],
@@ -2212,6 +2239,17 @@ class AuditRoundThree(unittest.TestCase):
self.assertEqual(press(state, "enter"), [])
self.assertEqual(state.screen, "folder")
def test_offline_with_nothing_to_copy_offers_no_install_anyway(self):
"""Offline with nothing here to copy, Install is removed: a folder
short of space then put "Install anyway" back, a run that could
only fail every file."""
state = checked_state({"free": 1024, "writable": False, "cores": []})
with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}):
labels = [row.label for row in install.tui_rows(state)]
self.assertEqual(labels[0], "Choose another folder")
self.assertNotIn("Install anyway", labels)
self.assertFalse(any(label.startswith("Install") for label in labels), labels)
FULL_TAIL = [
" Checking existing files...",
" 0/50 present (0 verified, 0 wrong hash)",
@@ -2557,6 +2595,34 @@ class IdleExit(unittest.TestCase):
["RetroBIOS: No button pressed, closing."])
class AReadErrorIsNotAClosedWindow(unittest.TestCase):
"""Any EIO read as the window closing: a failing SD card answers EIO
too, and that run ended 129, no error.log, no launcher message."""
def test_eio_counts_only_once_the_terminal_hung_up(self):
import pty
master, slave = pty.openpty()
screen = open(os.dup(slave), "w", encoding="utf-8") # noqa: SIM115
terminal = mock.Mock(fd=slave, stdout=screen)
try:
card = OSError(errno.EIO, "Input/output error", "/media/card/bios/x.bin")
self.assertFalse(install._closed_terminal(card, terminal))
self.assertFalse(install._closed_terminal(
OSError(errno.ENOENT, "No such file"), terminal))
os.close(master)
master = -1
self.assertTrue(install._closed_terminal(card, terminal))
self.assertTrue(install._closed_terminal(termios.error(errno.EIO, "gone"),
terminal))
finally:
with contextlib.suppress(OSError):
screen.close()
os.close(slave)
if master >= 0:
os.close(master)
class AuditRoundFour(unittest.TestCase):
"""tui_audit_3.md, each finding held where it was found."""
@@ -2935,8 +3001,13 @@ class EndToEnd(unittest.TestCase):
term.send("\r")
screen = term.wait("files to download")
self.assertIn("6 files to download", screen)
# The second folder the run writes to, named before
# anything is written, and again on the result.
self.assertIn("1 file read from RetroArch's save directory goes to "
"~/MySaves", screen)
term.send("\r")
term.wait("6 files installed.")
screen = term.wait("6 files installed.")
self.assertIn("save directory goes to ~/MySaves", screen)
term.send("\r")
self.assertEqual(term.close(), 0)
results.append(written(repo, saves))
@@ -3035,6 +3106,9 @@ class EndToEnd(unittest.TestCase):
after = term.after()
self.assertIn(f"Stopped: {on_disk} of 6 installed, run again to finish.", after)
self.assertNotIn("Nothing was installed", after)
# The plain outcome only: the log's last lines are for a run that died.
self.assertIsNone(re.search(r"\[\d+/6\]", after), after)
self.assertNotIn("Stopping.", after)
time.sleep(1)
self.assertEqual(len(repo.installed()), on_disk)
@@ -3176,6 +3250,18 @@ class EndToEnd(unittest.TestCase):
term.send("q")
self.assertEqual(term.close(), 130)
def test_no_key_on_a_screen_reached_by_itself_closes_it(self):
"""Given --platform, as the offline launcher route is, the check
runs at once and the plan follows without a key: the close counted
only on the first screen, and never came."""
repo = self.repo()
self.env[install.TUI_IDLE_ENV] = "1"
term = self.start("--platform", "retroarch", "--dest", str(repo.dest))
term.wait("> Install")
self.assertEqual(term.close(), 0)
self.assertIn("No button pressed, closing.", term.after())
self.assertEqual(repo.installed(), [])
def test_a_desktop_names_its_keys_and_a_pad_its_buttons(self):
self.repo()
for env, bar in (({"DISPLAY": ":0"}, "Enter Continue Esc Quit ? Help"),