chore: merge main into the launcher fixes

This commit is contained in:
Abdessamad Derraz committed 2026-10-23 18:53:10 +02:00
commit 41efb3e99f
35 files changed
+1251 -188

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
emulator: "00_example" emulator: "00_example"
type: libretro type: test
core_classification: pure_libretro core_classification: pure_libretro
source: "https://github.com/libretro/libretro-core-info" source: "https://github.com/libretro/libretro-core-info"
profiled_date: "2026-03-22" profiled_date: "2026-03-22"
+1 -1
View File
@@ -11,7 +11,7 @@
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
$defaultInstallSha256 = "c90676dcc433061bd97aca94930cd7acd74295624840cb5c85a22897f9c1dd12" $defaultInstallSha256 = "1a26e1066a699413ab3d19f50ee552185ee2e9cbf74ee44548d3c9ea66f1b512"
$maximumInstallerBytes = 2MB $maximumInstallerBytes = 2MB
$installerExit = 0 $installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+535 -80
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -18,7 +18,7 @@ esac
TEMP_INSTALLER="" TEMP_INSTALLER=""
TEMP_DIRECTORY="" TEMP_DIRECTORY=""
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
DEFAULT_INSTALL_SHA256="c90676dcc433061bd97aca94930cd7acd74295624840cb5c85a22897f9c1dd12" DEFAULT_INSTALL_SHA256="1a26e1066a699413ab3d19f50ee552185ee2e9cbf74ee44548d3c9ea66f1b512"
MAX_INSTALLER_BYTES=2097152 MAX_INSTALLER_BYTES=2097152
cleanup() { cleanup() {
+16
View File
@@ -912,6 +912,22 @@
"type": "string", "type": "string",
"description": "Why source_commit is held on purpose rather than followed. The profile is then judged against its own revision, like one pinned to a superseded tag: comparing it to HEAD says nothing and recaling would repoint its refs at code the build it documents never had." "description": "Why source_commit is held on purpose rather than followed. The profile is then judged against its own revision, like one pinned to a superseded tag: comparing it to HEAD says nothing and recaling would repoint its refs at code the build it documents never had."
}, },
"data_root": {
"type": "object",
"minProperties": 1,
"propertyNames": {"enum": ["android", "ios", "windows", "macos", "linux"]},
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"required": ["path", "source_ref"],
"properties": {
"path": {"type": "string", "minLength": 1, "description": "The folder as the user finds it on that OS, with the environment variable or home prefix the code expands (%APPDATA%, ~, Android/data/<package>)."},
"source_ref": {"type": "string", "minLength": 1},
"note": {"type": "string"}
}
},
"description": "Per operating system, the folder the app reads its user data from, under which standalone_path destinations sit. Only the systems the app runs on are keyed."
},
"upstream_frozen": { "upstream_frozen": {
"type": "string", "type": "string",
"description": "Why upstream_commit is held on purpose rather than followed: the port vendors that upstream tree, synced to that release, or merged that commit. The upstream view is judged against its own revision and a bump leaves it, the way pin_frozen holds source_commit." "description": "Why upstream_commit is held on purpose rather than followed: the port vendors that upstream tree, synced to that release, or merged that commit. The upstream view is judged against its own revision and a bump leaves it, the way pin_frozen holds source_commit."
+1 -1
View File
@@ -1,8 +1,8 @@
RetroBIOS: Confirm RetroBIOS: Confirm
1 file to download, 16.0 KB 1 file to download, 16.0 KB
0 in place, 1 missing, 0 wrong copies
BIOS folder: ~/.config/retroarch/system BIOS folder: ~/.config/retroarch/system
0 in place, 1 missing, 0 wrong copies
Free space: 50.0 GB Free space: 50.0 GB
> Install > Install
+2 -2
View File
@@ -1,12 +1,12 @@
RetroBIOS: Confirm RetroBIOS: Confirm
1 file to download, 16.0 KB 1 file to download, 16.0 KB
BIOS folder: ~/.co.../retroarch/system
0 in place, 1 missing, 0 wrong copies 0 in place, 1 missing, 0 wrong copies
BIOS folder:
~/.config/retroarch/system
Free space: 50.0 GB Free space: 50.0 GB
> Install > Install
Back Back
A/Enter Install B/Esc Back ? Help A/Enter Install B/Esc Back ? Help
+2 -2
View File
@@ -1,9 +1,8 @@
RetroBIOS: Confirm RetroBIOS: Confirm
1 file to download, 16.0 KB 1 file to download, 16.0 KB
BIOS folder: ~/.co.../retroarch/system
0 in place, 1 missing, 0 wrong copies 0 in place, 1 missing, 0 wrong copies
BIOS folder:
~/.config/retroarch/system
Free space: 50.0 GB Free space: 50.0 GB
> Install > Install
@@ -12,4 +11,5 @@
A/Enter Install B/Esc Back ? Help A/Enter Install B/Esc Back ? Help
+1 -1
View File
@@ -1,8 +1,8 @@
RetroBIOS: Confirm RetroBIOS: Confirm
1 file to download, 16.0 KB 1 file to download, 16.0 KB
0 in place, 1 missing, 0 wrong copies
BIOS folder: ~/.config/retroarch/system BIOS folder: ~/.config/retroarch/system
0 in place, 1 missing, 0 wrong copies
Free space: 50.0 GB Free space: 50.0 GB
> Install > Install
+1 -1
View File
@@ -3,7 +3,7 @@
Each row is an option of the command line, named on the right. Each row is an option of the command line, named on the right.
> Preferred regions: North America, Japan --region > Preferred regions: North America, Japan --region
Leave out game data: no --no-game-data Game data: kept --no-game-data
Installed cores only: no (1 found) --installed-cores Installed cores only: no (1 found) --installed-cores
Cores: all --core Cores: all --core
Parallel downloads: 8 --jobs Parallel downloads: 8 --jobs
+1 -1
View File
@@ -1,7 +1,7 @@
RetroBIOS: Options RetroBIOS: Options
> Preferred regions: North America, Ja > Preferred regions: North America, Ja
Leave out game data: no Game data: kept
Installed cores only: no (1 found) Installed cores only: no (1 found)
Cores: all Cores: all
Parallel downloads: 8 Parallel downloads: 8
+1 -1
View File
@@ -1,7 +1,7 @@
RetroBIOS: Options RetroBIOS: Options
> Preferred regions: North America, Ja > Preferred regions: North America, Ja
Leave out game data: no Game data: kept
Installed cores only: no (1 found) Installed cores only: no (1 found)
Cores: all Cores: all
Parallel downloads: 8 Parallel downloads: 8
+1 -1
View File
@@ -3,7 +3,7 @@
Each row is an option of the command line, named on the right. Each row is an option of the command line, named on the right.
> Preferred regions: North America, Japan --region > Preferred regions: North America, Japan --region
Leave out game data: no --no-game-data Game data: kept --no-game-data
Installed cores only: no (1 found) --installed-cores Installed cores only: no (1 found) --installed-cores
Cores: all --core Cores: all --core
Parallel downloads: 8 --jobs Parallel downloads: 8 --jobs
+2 -2
View File
@@ -1,11 +1,10 @@
RetroBIOS: RetroArch RetroBIOS: RetroArch
4 files to download, 1.0 MB 4 files to download, 1.0 MB
1 in place, 3 missing, 1 wrong copy
BIOS folder: ~/.config/retroarch/system BIOS folder: ~/.config/retroarch/system
1 in place, 3 missing, 1 wrong copy
Free space: 50.0 GB Free space: 50.0 GB
They are replaced, and the old copies are kept in a backup folder. They are replaced, and the old copies are kept in a backup folder.
2 files of the list are not in the collection.
> Install > Install
Choose systems Choose systems
@@ -35,6 +34,7 @@
A/Enter Install B/Esc Back Y/o Options ? Help A/Enter Install B/Esc Back Y/o Options ? Help
+2 -2
View File
@@ -1,9 +1,9 @@
RetroBIOS: RetroArch RetroBIOS: RetroArch
4 files to download, 1.0 MB 4 files to download, 1.0 MB
BIOS folder: ~/.co.../retroarch/system
1 in place, 3 missing, 1 wrong copy 1 in place, 3 missing, 1 wrong copy
BIOS folder: Free space: 50.0 GB
~/.config/retroarch/system
> Install > Install
Choose systems Choose systems
Copy from your old card or a folder Copy from your old card or a folder
+2 -2
View File
@@ -1,12 +1,12 @@
RetroBIOS: RetroArch RetroBIOS: RetroArch
4 files to download, 1.0 MB 4 files to download, 1.0 MB
BIOS folder: ~/.co.../retroarch/system
1 in place, 3 missing, 1 wrong copy 1 in place, 3 missing, 1 wrong copy
BIOS folder:
~/.config/retroarch/system
Free space: 50.0 GB Free space: 50.0 GB
They are replaced, and the old copies They are replaced, and the old copies
are kept in a backup folder. are kept in a backup folder.
> Install > Install
Choose systems Choose systems
Copy from your old card or a folder Copy from your old card or a folder
+2 -2
View File
@@ -1,11 +1,10 @@
RetroBIOS: RetroArch RetroBIOS: RetroArch
4 files to download, 1.0 MB 4 files to download, 1.0 MB
1 in place, 3 missing, 1 wrong copy
BIOS folder: ~/.config/retroarch/system BIOS folder: ~/.config/retroarch/system
1 in place, 3 missing, 1 wrong copy
Free space: 50.0 GB Free space: 50.0 GB
They are replaced, and the old copies are kept in a backup folder. They are replaced, and the old copies are kept in a backup folder.
2 files of the list are not in the collection.
> Install > Install
Choose systems Choose systems
@@ -21,4 +20,5 @@
A/Enter Install B/Esc Back Y/o Options ? Help A/Enter Install B/Esc Back Y/o Options ? Help
+1 -1
View File
@@ -1,8 +1,8 @@
RetroBIOS: RetroArch RetroBIOS: RetroArch
Everything is in place Everything is in place
5 in place, 0 missing, 0 wrong copies
BIOS folder: ~/.config/retroarch/system BIOS folder: ~/.config/retroarch/system
5 in place, 0 missing, 0 wrong copies
Free space: 50.0 GB Free space: 50.0 GB
> Close > Close
+2 -2
View File
@@ -1,12 +1,12 @@
RetroBIOS: RetroArch RetroBIOS: RetroArch
Everything is in place Everything is in place
BIOS folder: ~/.co.../retroarch/system
5 in place, 0 missing, 0 wrong copies 5 in place, 0 missing, 0 wrong copies
BIOS folder:
~/.config/retroarch/system
Free space: 50.0 GB Free space: 50.0 GB
> Close > Close
Options Options
A Close B Back Y Options ? Help A Close B Back Y Options ? Help
+2 -2
View File
@@ -1,9 +1,8 @@
RetroBIOS: RetroArch RetroBIOS: RetroArch
Everything is in place Everything is in place
BIOS folder: ~/.co.../retroarch/system
5 in place, 0 missing, 0 wrong copies 5 in place, 0 missing, 0 wrong copies
BIOS folder:
~/.config/retroarch/system
Free space: 50.0 GB Free space: 50.0 GB
> Close > Close
@@ -12,4 +11,5 @@
A Close B Back Y Options ? Help A Close B Back Y Options ? Help
+1 -1
View File
@@ -1,8 +1,8 @@
RetroBIOS: RetroArch RetroBIOS: RetroArch
Everything is in place Everything is in place
5 in place, 0 missing, 0 wrong copies
BIOS folder: ~/.config/retroarch/system BIOS folder: ~/.config/retroarch/system
5 in place, 0 missing, 0 wrong copies
Free space: 50.0 GB Free space: 50.0 GB
> Close > Close
+1 -1
View File
@@ -6,7 +6,6 @@
The files it replaced are kept in /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 The files it replaced are kept in /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
To put them back: python3 install.py --restore To put them back: python3 install.py --restore
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
2 files of the list are not in the collection, 1 of them needed to start an emulator.
Same choice without questions: Same choice without questions:
python3 install.py --platform retroarch --dest /home/user/.config/retroarch/system python3 install.py --platform retroarch --dest /home/user/.config/retroarch/system
Details: /tmp/retrobios-x/install.log Details: /tmp/retrobios-x/install.log
@@ -35,6 +34,7 @@
A/Enter Exit Up/Down Scroll A/Enter Exit Up/Down Scroll
+1 -1
View File
@@ -8,5 +8,5 @@
/home/user/.config/retroarch/ /home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200 retrobios-backup/2026-10-23_1200
To put them back: python3 install.py To put them back: python3 install.py
1-4 of 9 1-4 of 8
A/Enter Exit Up/Down Scroll A/Enter Exit Up/Down Scroll
+1 -1
View File
@@ -11,5 +11,5 @@
--restore --restore
/home/user/.config/retroarch/ /home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200 retrobios-backup/2026-10-23_1200
1-5 of 9 1-5 of 8
A/Enter Exit Up/Down Scroll A/Enter Exit Up/Down Scroll
+2 -2
View File
@@ -7,8 +7,6 @@
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
To put them back: python3 install.py --restore To put them back: python3 install.py --restore
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
2 files of the list are not in the collection, 1 of them needed to start an
emulator.
Same choice without questions: Same choice without questions:
python3 install.py --platform retroarch --dest python3 install.py --platform retroarch --dest
/home/user/.config/retroarch/system /home/user/.config/retroarch/system
@@ -21,4 +19,6 @@
A/Enter Exit Up/Down Scroll A/Enter Exit Up/Down Scroll
+16 -13
View File
@@ -699,13 +699,14 @@ class FirstInstallWalkthrough(unittest.TestCase):
step3 = _section(self.run, "The file index and the download") step3 = _section(self.run, "The file index and the download")
step4 = _section(self.run, "The end of the run") step4 = _section(self.run, "The end of the run")
step5 = _section(self.run, "A later check") step5 = _section(self.run, "A later check")
# First install names the three endings a run prints, and no count: # First install names the three endings the full screen shows as
# a figure from one setup read as the size of every install. # its title, and no count: a figure from one setup read as the size
# of every install.
source = (ROOT / "install.py").read_text(encoding="utf-8") source = (ROOT / "install.py").read_text(encoding="utf-8")
for ending in ("Done.", "Finished with errors.", "Nothing was installed."): for ending in ("Done", "Finished with errors", "Nothing installed"):
with self.subTest(ending=ending): with self.subTest(ending=ending):
self.assertIn(f"`{ending}`", _section(self.text, "5. Read the last line")) self.assertIn(f"**{ending}**", _section(self.text, "5. Read the result"))
self.assertIn(f'"\\n{ending} ', source) self.assertIn(f'title = "{ending}"', source)
self.assertNotRegex(self.text, r"Done\. \d+ files") self.assertNotRegex(self.text, r"Done\. \d+ files")
count, size = re.search(r"\n (\d+) files \(([\d.]+ [KMG]B)\)", step3).groups() count, size = re.search(r"\n (\d+) files \(([\d.]+ [KMG]B)\)", step3).groups()
self.assertIn(f"0/{count} present", step3) self.assertIn(f"0/{count} present", step3)
@@ -726,15 +727,17 @@ class FirstInstallWalkthrough(unittest.TestCase):
self.assertIn(f"all {required} belong", step4) self.assertIn(f"all {required} belong", step4)
self.assertIn(f"other {total - required} are optional", step4) self.assertIn(f"other {total - required} are optional", step4)
def test_no_platform_detected_reads_like_the_installer(self): def test_nothing_found_reads_like_the_installer(self):
source = (ROOT / "install.py").read_text(encoding="utf-8") # The one-liner hands the terminal to the installer, which opens the
branch = source.split('print(" No supported platform detected.")', 1)[1][:1600] # full screen: its first screen says the heading and offers the row.
self.assertIn("if not interactive or listing:", branch) import install
self.assertIn("_prompt_manual_platform(os_type)", branch)
self.assertIn('"$INSTALLER" "$@" </dev/tty', (ROOT / "install.sh").read_text(encoding="utf-8")) self.assertIn('"$INSTALLER" "$@" </dev/tty', (ROOT / "install.sh").read_text(encoding="utf-8"))
section = _section(self.text, "No supported platform detected.") state = install.TuiState()
self.assertIn("a menu follows", section) screen = "\n".join(install.tui_view(state, 80, 24))
self.assertIn("--no-input", section) self.assertIn("Nothing was found here.", screen)
section = _section(self.text, "Nothing was found here.")
self.assertIn(f"**{install.tui_rows(state)[-1].label}**", section)
self.assertNotIn("cannot stop and ask", section) self.assertNotIn("cannot stop and ask", section)
+2 -1
View File
@@ -353,7 +353,8 @@ class TheInterfaceReadsAFoundEvent(unittest.TestCase):
state, _before = self.receive("check") state, _before = self.receive("check")
self.assertEqual(state.found, [self.FOUND]) self.assertEqual(state.found, [self.FOUND])
state, _before = self.receive("install") state, _before = self.receive("install")
self.assertEqual(state.local_dests, {"scph5501.bin"}) self.assertEqual(list(state.local_dests), ["scph5501.bin"])
self.assertEqual(state.local_dests["scph5501.bin"][0], self.FOUND["folder"])
def test_a_dry_run_ignores_it(self): def test_a_dry_run_ignores_it(self):
for run in ("confirm", "preview"): for run in ("confirm", "preview"):
+535 -17
View File
@@ -28,7 +28,7 @@ import threading
import time import time
import unittest import unittest
import warnings import warnings
from pathlib import Path from pathlib import Path, PureWindowsPath
from unittest import mock from unittest import mock
REPO_ROOT = Path(__file__).resolve().parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent
@@ -656,9 +656,10 @@ class Flow(unittest.TestCase):
mock.patch.object(install, "detect_frontends", return_value=[]): mock.patch.object(install, "detect_frontends", return_value=[]):
state, effects = install.tui_start(_args(), "linux") state, effects = install.tui_start(_args(), "linux")
self.assertEqual((state.screen, effects), ("setup", [])) self.assertEqual((state.screen, effects), ("setup", []))
self.assertEqual(state.setups, [("minui", card, "MinUI SD card")]) label = f"MinUI SD card ({card.name})"
self.assertEqual(state.setups, [("minui", card, label)])
self.assertEqual([row.label for row in install.tui_rows(state)], self.assertEqual([row.label for row in install.tui_rows(state)],
["MinUI SD card", "Another setup or folder"]) [label, "Another setup or folder"])
def test_an_error_offers_another_try(self): def test_an_error_offers_another_try(self):
state = fresh_state() state = fresh_state()
@@ -717,9 +718,11 @@ class PlanWarnings(unittest.TestCase):
def test_a_folder_that_cannot_take_the_files_says_so_first(self): def test_a_folder_that_cannot_take_the_files_says_so_first(self):
state = checked_state({"free": 1024, "writable": False, "cores": []}) state = checked_state({"free": 1024, "writable": False, "cores": []})
text = " ".join(install.tui_view(state, 120, 40)) text = " ".join(install.tui_view(state, 120, 40))
self.assertIn("Not enough free space for this download", text) self.assertIn("Not enough free space: 1.0 MB needed.", text)
self.assertIn("You cannot write to this folder: choose another one, or run the " self.assertIn("You cannot write to this folder: choose another one.", text)
"installer with sudo.", text) # sudo changes the home folder: the advice names this one.
self.assertIn("Or run it as root, this folder named: sudo python3 install.py "
f"--platform retroarch --dest {SYSTEM_DIR}", " ".join(text.split()))
rows = install.tui_rows(state) rows = install.tui_rows(state)
self.assertEqual([row.action for row in rows[:2]], ["folder", "install"]) self.assertEqual([row.action for row in rows[:2]], ["folder", "install"])
self.assertEqual(state.focus.get("plan", 0), 0) self.assertEqual(state.focus.get("plan", 0), 0)
@@ -880,6 +883,7 @@ def golden_states() -> dict:
installing.run, installing.running = "install", True installing.run, installing.running = "install", True
installing.download = {"event": "download", "files": 4, "bytes": 1064964} installing.download = {"event": "download", "files": 4, "bytes": 1064964}
installing.samples, installing.now = [(100.0, 12672), (104.0, 540672)], 104.0 installing.samples, installing.now = [(100.0, 12672), (104.0, 540672)], 104.0
installing.network = 540672
installing.progress = {"files_done": 2, "files_total": 4, "bytes_done": 540672, installing.progress = {"files_done": 2, "files_total": 4, "bytes_done": 540672,
"bytes_total": 1064964} "bytes_total": 1064964}
installing.finished = [("gba_bios.bin", True, ""), ("scph5500.bin", False, "HTTP 404")] installing.finished = [("gba_bios.bin", True, ""), ("scph5500.bin", False, "HTTP 404")]
@@ -1100,6 +1104,10 @@ class SmallFixes(unittest.TestCase):
install.tui_receive(state, ("event", state.run_id, { install.tui_receive(state, ("event", state.run_id, {
"event": "progress", "files_done": 0, "files_total": 2, "event": "progress", "files_done": 0, "files_total": 2,
"bytes_done": done, "bytes_total": 1000 * mega})) "bytes_done": done, "bytes_total": 1000 * mega}))
# The partial copy resumed is counted local by the engine.
install.tui_receive(state, ("event", state.run_id, {
"event": "transfer", "bytes_local": 500 * mega,
"bytes_network": done - 500 * mega}))
text = "\n".join(install.tui_view(state, 80, 24)) text = "\n".join(install.tui_view(state, 80, 24))
self.assertIn("1.0 MB/s, 8:18 left", text) self.assertIn("1.0 MB/s, 8:18 left", text)
@@ -1248,8 +1256,9 @@ class CleanupSurvivesABrokenTerminal(unittest.TestCase):
code = install.run_tui(_args()) code = install.run_tui(_args())
self.assertIs(sys.stdout, tty) self.assertIs(sys.stdout, tty)
self.assertEqual(code, 1) self.assertEqual(code, 1)
# The crash keeps its log, and nothing else of the run. # The crash keeps its log, marked kept, and nothing else of the run.
self.assertEqual(sorted(path.name for path in work.iterdir()), ["error.log"]) self.assertEqual(sorted(path.name for path in work.iterdir()),
["error.log", "kept", "owner"])
self.assertEqual(termios.tcgetattr(slave), before) self.assertEqual(termios.tcgetattr(slave), before)
for number, handler in handlers.items(): for number, handler in handlers.items():
self.assertEqual(signal.getsignal(number), handler) self.assertEqual(signal.getsignal(number), handler)
@@ -1384,7 +1393,7 @@ class AuditRoundTwo(unittest.TestCase):
_title, lines = install.tui_outcome(state) _title, lines = install.tui_outcome(state)
self.assertEqual([line for line in lines if "failed" in line], self.assertEqual([line for line in lines if "failed" in line],
["Nothing was installed: 50 files failed.", ["Nothing was installed: 50 files failed.",
" 50 files failed (folder not writable), the first f0.bin"]) " 50 files failed, all for one reason: folder not writable."])
def test_p2_6_a_folder_inside_a_card_offers_its_root(self): def test_p2_6_a_folder_inside_a_card_offers_its_root(self):
card = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) card = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
@@ -1444,7 +1453,12 @@ class AuditRoundTwo(unittest.TestCase):
("retroarch", SYSTEM_DIR, "RetroArch"), ("retroarch", SYSTEM_DIR, "RetroArch"),
("emudeck", HOME / "Emulation/bios", "EmuDeck")]) ("emudeck", HOME / "Emulation/bios", "EmuDeck")])
self.assertEqual([label for _p, _f, label in setups], self.assertEqual([label for _p, _f, label in setups],
["RetroArch (Flatpak)", "RetroArch (retroarch/system)", "EmuDeck"]) ["RetroArch (Flatpak)", "RetroArch (installed)", "EmuDeck"])
# Two that no package names keep the end of their folder.
twice = install.tell_apart([("retroarch", SYSTEM_DIR, "RetroArch"),
("retroarch", HOME / "ra2/system", "RetroArch")])
self.assertEqual([label for _p, _f, label in twice],
["RetroArch (retroarch/system)", "RetroArch (ra2/system)"])
state = fresh_state() state = fresh_state()
state.setups = setups state.setups = setups
self.assertIn("RetroArch (Flatpak)", "\n".join(install.tui_view(state, 40, 15))) self.assertIn("RetroArch (Flatpak)", "\n".join(install.tui_view(state, 40, 15)))
@@ -1634,7 +1648,8 @@ class CopyFromAnOldCard(unittest.TestCase):
self.recheck(state, [found("scph5500.bin", str(old)), found("gba_bios.bin", str(old))]) self.recheck(state, [found("scph5500.bin", str(old)), found("gba_bios.bin", str(old))])
self.assertEqual(state.screen, "plan") self.assertEqual(state.screen, "plan")
text = "".join("".join(install.tui_view(state, 120, 40)).split()) text = "".join("".join(install.tui_view(state, 120, 40)).split())
self.assertIn("".join(f"2 found in {old}, 2 to download (512.0 KB)".split()), text) # The folder by its name: the whole path pushed the BIOS folder off.
self.assertIn("".join(f"2 found in {old.name}, 2 to download (512.0 KB)".split()), text)
# A second folder from the options: --from twice. # A second folder from the options: --from twice.
press(state, "o") press(state, "o")
state.focus["options"] = next(i for i, r in enumerate(install.tui_rows(state)) state.focus["options"] = next(i for i, r in enumerate(install.tui_rows(state))
@@ -1655,11 +1670,15 @@ class CopyFromAnOldCard(unittest.TestCase):
def test_left_takes_the_last_folder_back(self): def test_left_takes_the_last_folder_back(self):
state = checked_state() state = checked_state()
state.from_folders = [Path("/media/old")] state.from_folders = [Path("/media/old")]
state.checked_flags = install.tui_flags(state)
state.stack.append("options") state.stack.append("options")
state.focus["options"] = next(i for i, r in enumerate(install.tui_rows(state)) state.focus["options"] = next(i for i, r in enumerate(install.tui_rows(state))
if r.action == "from") if r.action == "from")
self.assertEqual(press(state, "left"), [("run", "check")]) # Said, and the screen stays: Back to the plan counts again.
self.assertEqual(state.from_folders, []) self.assertEqual(press(state, "left"), [])
self.assertEqual((state.screen, state.from_folders), ("options", []))
self.assertIn("No longer copying from old.", "\n".join(install.tui_view(state, 80, 24)))
self.assertEqual(press(state, "esc"), [("run", "check")])
def test_the_command_line_folders_are_kept(self): def test_the_command_line_folders_are_kept(self):
with mock.patch.object(install, "detect_platforms", return_value=[]), \ with mock.patch.object(install, "detect_platforms", return_value=[]), \
@@ -1699,10 +1718,17 @@ class CopyFromAnOldCard(unittest.TestCase):
(10.0, {"event": "downloaded", "dest": "big.bin", "ok": True, "reason": "", (10.0, {"event": "downloaded", "dest": "big.bin", "ok": True, "reason": "",
"size": 200 * mega, "files_done": 1, "files_total": 2, "size": 200 * mega, "files_done": 1, "files_total": 2,
"bytes_done": 200 * mega, "bytes_total": 300 * mega}), "bytes_done": 200 * mega, "bytes_total": 300 * mega}),
# The engine says which bytes came from this machine.
(10.0, {"event": "transfer", "bytes_local": 200 * mega,
"bytes_network": 0}),
(11.0, {"event": "progress", "files_done": 1, "files_total": 2, (11.0, {"event": "progress", "files_done": 1, "files_total": 2,
"bytes_done": 201 * mega, "bytes_total": 300 * mega}), "bytes_done": 201 * mega, "bytes_total": 300 * mega}),
(11.0, {"event": "transfer", "bytes_local": 200 * mega,
"bytes_network": mega}),
(12.0, {"event": "progress", "files_done": 1, "files_total": 2, (12.0, {"event": "progress", "files_done": 1, "files_total": 2,
"bytes_done": 202 * mega, "bytes_total": 300 * mega})] "bytes_done": 202 * mega, "bytes_total": 300 * mega}),
(12.0, {"event": "transfer", "bytes_local": 200 * mega,
"bytes_network": 2 * mega})]
for when, event in events: for when, event in events:
state.now = when state.now = when
install.tui_receive(state, ("event", state.run_id, event)) install.tui_receive(state, ("event", state.run_id, event))
@@ -1710,6 +1736,495 @@ class CopyFromAnOldCard(unittest.TestCase):
self.assertIn("1.0 MB/s, 1:38 left", text) self.assertIn("1.0 MB/s, 1:38 left", text)
class AuditRoundThree(unittest.TestCase):
"""tui_audit_2.md: its P1 and P2 findings, each held by a test."""
CARDS = {
"muOS": ({"MUOS/": ""}, "retroarch", "MUOS/bios"),
"Onion": ({".tmp_update/onionVersion/version.txt": "4.3.1\n"}, "retroarch", "BIOS"),
"spruceOS": ({"spruce/": ""}, "retroarch", "BIOS"),
"KNULLI": ({"system/batocera.conf": "a=1\nsystem.hostname=KNULLI\n"}, "knulli",
"bios"),
"ROCKNIX": ({".config/system/configs/system.cfg": "root.password=rocknix\n"},
"rocknix", "roms/bios"),
}
def card(self, files: dict) -> Path:
root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
self.addCleanup(shutil.rmtree, root, True)
for name, text in files.items():
path = root / name
if name.endswith("/"):
path.mkdir(parents=True, exist_ok=True)
else:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return root
# P1-1: a handheld's card mounted on a computer.
def test_p1_1_each_card_by_its_own_files(self):
for name, (files, plat, folder) in self.CARDS.items():
with self.subTest(card=name):
root = self.card(files)
self.assertEqual(install.card_system(root), (plat, root / folder, name))
def test_p1_1_a_card_nothing_names_is_left_alone(self):
self.assertIsNone(install.card_system(self.card({"roms/": "", "BIOS/": ""})))
# Batocera's share and a KNULLI board seeded with its name read alike.
self.assertIsNone(install.card_system(self.card({
"system/batocera.conf": "system.hostname=BATOCERA\n"})))
def test_p1_1_the_card_rules_are_the_device_rules(self):
# The folder and the marker come from the rule the device reads, so
# they are never written twice.
rules = {rule.name: rule for rule in install._card_rules()}
self.assertLessEqual({"muOS", "Onion", "spruceOS", "CrossMix-OS", "Allium", "Koriki",
"KNULLI", "ROCKNIX"}, set(rules))
device = {rule.name: rule for rule in install._EMBEDDED_RULES
if rule.values and rule.values[0].startswith(install._CARD_MOUNTS)}
for name, rule in device.items():
if rule.bios and rule.manifest:
with self.subTest(system=name):
self.assertTrue(rule.bios.endswith("/" + rules[name].bios))
self.assertEqual(install.DEFAULT_DESTS["knulli"], Path("/userdata") / "bios")
self.assertEqual(install.DEFAULT_DESTS["rocknix"], Path("/storage") / "roms/bios")
def test_p1_1_the_first_screen_offers_the_card_folder(self):
root = self.card(self.CARDS["muOS"][0])
with mock.patch.object(install, "_mounted_cards", return_value=[root]), \
mock.patch.object(install, "detect_platforms", return_value=[]), \
mock.patch.object(install, "detect_frontends", return_value=[]):
state, effects = install.tui_start(_args(), "linux")
label = f"muOS SD card ({root.name})"
self.assertEqual(state.setups, [("retroarch", root / "MUOS" / "bios", label)])
text = " ".join(" ".join(install.tui_view(state, 120, 40)).split())
self.assertIn(f"> {label}", text)
self.assertIn(f"BIOS folder: {root / 'MUOS' / 'bios'}", text)
(root / "MUOS" / "bios").mkdir()
self.assertEqual(press(state, "enter"), [("fetch", "retroarch")])
self.assertEqual(state.dest, root / "MUOS" / "bios")
def test_p1_1_a_drive_letter_names_the_card(self):
self.assertEqual(install._mount_name(PureWindowsPath("E:\\")), "E:\\")
self.assertEqual(install._mount_name(Path("/media/me/MUOS")), "MUOS")
def test_p1_1_on_the_device_its_card_is_not_offered_twice(self):
root = self.card(self.CARDS["muOS"][0])
bound = Path("/run/muos/storage/bios")
with mock.patch.object(install, "_mounted_cards", return_value=[root]), \
mock.patch.object(install, "detect_platforms", return_value=[("retroarch",
bound)]), \
mock.patch.object(install, "detect_frontends", return_value=[]), \
mock.patch.dict(install._DETECTED_OS, {("retroarch", bound): "muOS"}):
state, _effects = install.tui_start(_args(), "linux")
self.assertEqual([setup[1] for setup in state.setups], [bound])
def test_p1_1_browsing_to_the_card_root_uses_its_folder(self):
root = self.card(self.CARDS["KNULLI"][0])
with mock.patch.object(install, "_mounted_cards", return_value=[root]):
rows, _error = install._browse_rows(REPO_ROOT / "tests")
self.assertEqual(rows[2].label, f"{root} (KNULLI card)")
state = fresh_state()
install._choose(state, "knulli", root)
self.assertEqual(state.dest, root / "bios")
# Another platform's list keeps the folder it was given.
install._choose(state, "batocera", root)
self.assertEqual(state.dest, root)
def test_p1_1_the_command_line_writes_where_muos_reads(self):
root = self.card(self.CARDS["muOS"][0])
with FakeRepository() as repo:
proc = install_run("--platform", "muos", "--dest", str(root), "--yes",
env=repo.env())
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertIn(f"muOS card: RetroArch files, in {root / 'MUOS' / 'bios'}", proc.stdout)
self.assertEqual(sorted(path.name for path in root.iterdir()), ["MUOS"])
self.assertTrue((root / "MUOS" / "bios" / "scph5501.bin").is_file())
def test_p1_1_a_folder_that_is_no_muos_card_is_refused(self):
root = self.card({"roms/": ""})
proc = install_run("--platform", "muos", "--dest", str(root), "--yes")
self.assertEqual(proc.returncode, 1)
self.assertIn(f"{root} is not a muOS card: it holds no MUOS.", proc.stderr)
self.assertIn("--dest <card>/MUOS/bios", proc.stderr)
self.assertEqual(list(root.iterdir()), [root / "roms"])
# Its BIOS folder named outright is taken as given.
self.assertEqual(install.card_dest("muos", "retroarch", root / "MUOS" / "bios"),
("retroarch", root / "MUOS" / "bios"))
# On the device, the folder muOS binds it to.
bound = Path("/run/muos/storage/bios")
self.assertEqual(install.card_dest("muos", "retroarch", bound), ("retroarch", bound))
# A list named by its own name says which list, not which card.
self.assertEqual(install.card_dest("retroarch", "retroarch", root),
("retroarch", root))
# P1-2: Space is X on PortMaster and Y on muxterm.
def test_p1_2_space_on_install_opens_the_confirm_screen(self):
state = checked_state()
self.assertEqual(install.tui_rows(state)[0].action, "install")
self.assertEqual(press(state, " "), [("run", "confirm")])
self.assertEqual(state.screen, "confirm")
# The same plan comes back: the screen still waits for A.
install.tui_receive(state, ("event", state.run_id, dict(CHECK)))
self.assertEqual(install.tui_receive(state, ("ended", state.run_id, 0, [])), [])
self.assertEqual(state.screen, "confirm")
self.assertEqual(install.tui_rows(state)[0].action, "install")
self.assertEqual(press(state, " "), [])
self.assertEqual(state.screen, "confirm")
self.assertEqual(press(state, "enter"), [("run", "install")])
def test_p1_2_space_never_closes_or_quits(self):
for action in ("quit", "close"):
with self.subTest(row=action):
state = checked_state()
if action == "close":
state.check = {**CHECK, "missing": 0, "wrong_hash": 0, "bytes_needed": 0}
rows = install.tui_rows(state)
state.focus["plan"] = next(i for i, r in enumerate(rows) if r.action == action)
self.assertEqual(press(state, " "), [])
self.assertIsNone(state.exit_code)
state = checked_state()
state.focus["plan"] = next(i for i, r in enumerate(install.tui_rows(state))
if r.action == "options")
press(state, " ")
self.assertEqual(state.screen, "options")
def test_p1_2_enter_still_installs_in_three_presses(self):
state = checked_state()
self.assertEqual(press(state, "enter"), [("run", "install")])
self.assertEqual(state.screen, "installing")
# P2-1: a card too small for the download.
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]],
["Choose another folder", "Install anyway"])
self.assertEqual(press(state, "enter"), [])
self.assertEqual(state.screen, "folder")
FULL_TAIL = [
" Checking existing files...",
" 0/50 present (0 verified, 0 wrong hash)",
" Downloading 50 files (845.7 KB)...",
"Not enough space on the drive holding /media/me/MUOS/MUOS/bios.",
" Needed: 1.2 GB. Free: 300.0 MB.",
" Free up 920.0 MB, or install to another drive:",
" python3 install.py --platform retroarch --dest '<folder on another drive>'",
]
def test_p2_1_a_full_card_is_said_in_words(self):
root = self.card(self.CARDS["muOS"][0])
state = checked_state()
state.dest, state.log = root / "MUOS" / "bios", "/tmp/retrobios-tui-x/install.log"
state.stack.append("result")
state.code, state.tail = 2, list(self.FULL_TAIL)
with mock.patch.object(install, "_mounted_cards", return_value=[root]):
title, lines = install.tui_outcome(state)
self.assertEqual(title, "Not enough space")
self.assertEqual(lines[0], "The card is full: 1.2 GB needed, 300.0 MB free.")
self.assertIn("Free some space, or run again and choose another folder.", lines)
text = "\n".join(lines)
for raw in ("Checking existing files", "Downloading", "stopped with code", "Details:"):
self.assertNotIn(raw, text)
self.assertFalse(install.tui_keeps_log(state))
def test_p2_1_advice_never_names_the_interface_list(self):
if hasattr(os, "geteuid") and os.geteuid() == 0:
self.skipTest("root writes to a read-only folder")
folder = self.card({})
folder.chmod(0o555)
self.addCleanup(folder.chmod, 0o755)
with FakeRepository() as repo:
listed = repo.root / "install" / "retroarch.json"
proc = install_run("--platform", "retroarch", "--dest", str(folder),
f"--manifest={listed}", "--yes",
env={**repo.env(), install.TUI_LIST_ENV: "1"})
self.assertIn("sudo", proc.stdout + proc.stderr)
self.assertNotIn("--manifest", proc.stdout + proc.stderr)
# P2-2: the BIOS folder line on a handheld screen.
def test_p2_2_the_plan_keeps_the_folder_at_handheld_sizes(self):
zipped = "/home/me/a/long/path/to/the/Downloads/PSX BIOS.zip"
for cols, rows in ((40, 12), (40, 15)):
with self.subTest(size=f"{cols}x{rows}"):
state = checked_state()
state.dest = Path("/home/someone/with/a/long/path/.config/retroarch/system")
state.found = [found("scph5500.bin", zipped)]
state.cols, state.rows = cols, rows
lines = [line.strip() for line in install.tui_view(state, cols, rows)]
folder = next(line for line in lines if line.startswith("BIOS folder: "))
self.assertTrue(folder.endswith("retroarch/system"), folder)
self.assertIn("...", folder)
self.assertIn("1 found in PSX BIOS.zip,", " ".join(lines))
self.assertNotIn("/home/me/a/long", " ".join(lines))
# P2-3: where the files came from.
def test_p2_3_the_result_says_what_came_from_the_card(self):
state = checked_state()
state.stack.append("result")
state.code, state.download = 0, {"files": 5, "bytes": 10}
state.local_dests = {"scph5500.bin": ("/media/me/OLD", ""),
"scph5501.bin": ("/media/me/OLD", ""),
"gba_bios.bin": (str(SYSTEM_DIR), "one folder too deep")}
state.finished = [("scph5500.bin", True, ""), ("scph5501.bin", True, ""),
("gba_bios.bin", True, ""), ("pcsx/extra.bin", True, ""),
("fc/disksys.rom", False, "HTTP 404")]
state.summary = {"installed": 4, "up_to_date": 0, "failed": [
{"dest": "fc/disksys.rom", "reason": "HTTP 404"}]}
_title, lines = install.tui_outcome(state)
self.assertEqual(lines[1], "2 files copied from OLD, 1 fixed from copies "
"already in the BIOS folder, 1 downloaded, 1 still missing.")
# The line mode's own sentence, from the same counts.
self.assertEqual(lines[1], install.sources_line([("OLD", 2)], 1, 1, 1))
def test_p2_3_a_run_that_copied_nothing_adds_no_line(self):
state = install.TuiState()
state.summary, state.finished = {"installed": 2, "failed": []}, [("a", True, "")]
self.assertEqual(install._tui_sources(state, 2, 0), "")
# P2-4 and P2-5: the systems list.
def systems_state(self) -> install.TuiState:
state = checked_state()
state.systems = [*state.systems,
{"system": "other", "name": "other", "files": 2, "present": 0,
"missing": 2, "wrong_hash": 0, "bytes_needed": 300},
{"system": "3do", "name": "3do", "files": 3, "present": 0,
"missing": 3, "wrong_hash": 0, "bytes_needed": 600}]
state.files = [*state.files,
{"event": "file", "dest": "panafz1.bin", "systems": ["3do"],
"size": 100, "state": "missing"},
{"event": "file", "dest": "mame/hash/a.xml", "systems": ["3do", "mac"],
"size": 200, "state": "missing"},
{"event": "file", "dest": "mame/hash/b.xml", "systems": ["3do", "mac"],
"size": 300, "state": "missing"}]
state.ticked |= {"other", "3do"}
state.stack.append("systems")
return state
def test_p2_4_the_other_row_is_named_and_explained(self):
state = self.systems_state()
rows = install.tui_rows(state)
other = next(i for i, row in enumerate(rows) if row.value == "other")
self.assertEqual(rows[other].label, "[x] Other systems and game engines")
state.focus["systems"] = other
text = " ".join(" ".join(install.tui_view(state, 80, 24)).split())
self.assertIn("Other systems and game engines: what cores load for systems this "
"setup does not list", text)
def test_p2_5_shared_files_are_counted_apart(self):
state = self.systems_state()
rows = install.tui_rows(state)
three = next(i for i, row in enumerate(rows) if row.value == "3do")
self.assertEqual(rows[three].right, "1 file, 100 B + 2 shared")
state.focus["systems"] = three
text = " ".join(" ".join(install.tui_view(state, 80, 24)).split())
self.assertIn("2 of these files are shared with other systems, counted once", text)
# The line mode counts the same files apart.
self.assertIn("and 2 files, 500 B shared with other systems", install._system_sized([
{"dest": "panafz1.bin", "size": 100, "systems": ["3do"]},
{"dest": "a.xml", "size": 200, "systems": ["3do", "mac"]},
{"dest": "b.xml", "size": 300, "systems": ["3do", "mac"]}]))
narrow = install.tui_view(state, 40, 15)
self.assertTrue(any("100 B + 2 shared" in line for line in narrow), narrow)
# P2-6: a file that cannot be supplied, by name, where it matters.
def unavailable_state(self) -> install.TuiState:
state = checked_state()
state.manifest = {**MANIFEST, "system_titles": {"nintendo-gba": "Game Boy Advance"},
"omitted_files": [
{"dest": "gba_extra.bin", "system": "nintendo-gba",
"required": True, "reason": "not_found"},
{"dest": "optional.bin", "system": "nintendo-gba",
"required": False, "reason": "not_found"}]}
state.stack.append("result")
state.code, state.download = 0, {"files": 1, "bytes": 1}
state.summary = {"installed": 1, "up_to_date": 0, "failed": []}
return state
def test_p2_6_the_missing_file_is_named_with_its_system(self):
state = self.unavailable_state()
_title, lines = install.tui_outcome(state)
self.assertIn("Not available here, and needed to start its emulator: gba_extra.bin "
"(Game Boy Advance). Your own copy goes in the BIOS folder.", lines)
self.assertNotIn("collection", " ".join(lines))
def test_p2_6_it_is_left_out_when_it_does_not_concern_the_run(self):
state = self.unavailable_state()
state.ticked = {"sony-playstation"}
self.assertEqual(install._unavailable_lines(state), [])
state = self.unavailable_state()
state.summary = {"installed": 1, "failed": [], "interrupted": True}
self.assertFalse(any("Not available" in line for line in install.tui_outcome(state)[1]))
# P3: small fixes the audit listed.
def fetch_failure(self, error: BaseException, env: dict | None = None) -> tuple:
inbox = __import__("queue").Queue()
with mock.patch.object(install, "_fetch_json", side_effect=error), \
mock.patch.dict(os.environ, env or {}):
for name in ("HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"):
if name not in (env or {}):
os.environ.pop(name, None)
install._tui_fetch("retroarch", inbox, io.StringIO())
_kind, _plat, title, lines, _code = inbox.get_nowait()
return title, lines
def test_p3_the_clock_names_the_row_to_choose(self):
import ssl
error = urllib_error(ssl.SSLCertVerificationError("certificate is not yet valid"))
words = install.network_words(error, today=install.datetime.date(1980, 1, 1))
with mock.patch.object(install, "network_words", return_value=words):
_title, lines = self.fetch_failure(error)
self.assertTrue(lines[0].endswith("then choose Try again."), lines[0])
def test_p3_a_proxy_in_the_way_is_named_not_shown(self):
error = urllib_error(ConnectionRefusedError(111, "Connection refused"))
_title, lines = self.fetch_failure(
error, {"HTTPS_PROXY": "http://me:secret@10.0.0.1:3128"})
self.assertIn("HTTPS_PROXY is set, so the connection goes through a proxy: check "
"that it answers.", lines)
self.assertNotIn("secret", " ".join(lines))
_title, lines = self.fetch_failure(error)
self.assertFalse(any("PROXY" in line for line in lines))
def test_p3_a_list_that_is_no_json_is_said_in_words(self):
try:
install.json.loads("<html>")
except install.json.JSONDecodeError as cause:
error = ValueError(f"invalid file list: {cause}")
error.__cause__ = cause
title, lines = self.fetch_failure(error)
self.assertEqual(title, "The file list is not valid")
self.assertNotIn("Expecting value", " ".join(lines))
self.assertIn("a Wi-Fi sign-in page or a cut download looks like this.", lines[0])
def test_p3_the_preview_counts_its_files_only(self):
state = checked_state()
state.stack += ["options", "preview"]
state.dry = dict(CHECK)
state.dry_files = [{**FILES[0], "dest": f"f{n}.bin"} for n in range(40)]
state.rows = 24
lines = [line.strip() for line in install.tui_view(state, 80, 24)]
self.assertIn("1-18 of 40", lines)
def test_p3_game_data_reads_without_a_double_negative(self):
state = checked_state()
label = next(row.label for row in install._option_rows(state)
if row.action == "game_data")
self.assertEqual(label, "Game data: kept")
state.no_game_data = True
label = next(row.label for row in install._option_rows(state)
if row.action == "game_data")
self.assertEqual(label, "Game data: left out, firmware only")
def test_p3_the_plan_says_which_config_named_the_folder(self):
config = HOME / ".config/retroarch/retroarch.cfg"
with mock.patch.dict(install._RETROARCH_CONFIGS, {SYSTEM_DIR: (config, config.parent)}):
text = "\n".join(install.tui_view(checked_state(), 80, 24))
self.assertIn("Named by ~/.config/retroarch/retroarch.cfg", text)
# The engine's own events (main, after the second audit).
def test_the_speed_is_the_network_bytes(self):
state = checked_state()
state.stack.append("installing")
state.run, state.running = "install", True
install.tui_receive(state, ("event", state.run_id,
{"event": "download", "files": 4, "bytes": 4 << 20}))
for now, done, network in ((100.0, 1 << 20, 0), (102.0, 3 << 20, 1 << 20)):
state.now = now
install.tui_receive(state, ("event", state.run_id, {
"event": "progress", "files_done": 1, "files_total": 4, "bytes_done": done,
"bytes_total": 4 << 20}))
install.tui_receive(state, ("event", state.run_id, {
"event": "transfer", "bytes_local": done - network, "bytes_network": network}))
text = "\n".join(install.tui_view(state, 80, 24))
# 1 MB from the network in 2 s, whatever came from the old card.
self.assertIn("512.0 KB/s, 0:02 left", text)
def test_the_plan_says_what_the_scan_left_out(self):
state = checked_state()
state.from_folders = [Path("E:\\")]
install._start_check(state)
events = [{"event": "scanned", "folder": "E:\\", "files": 200000, "truncated": True,
"other_drives": ["E:\\D"]},
{"event": "unreadable", "platform": "retroarch", "folder": "E:\\",
"path": "E:\\bios\\scph5501.bin", "member": "", "reason": "Permission denied"},
{"event": "unreadable", "platform": "retroarch", "folder": "E:\\",
"path": "E:\\backup.zip", "member": "gba_bios.bin",
"reason": "it is encrypted, a password is needed"}]
for event in [*FILES, *SYSTEMS, *events, CHECK]:
install.tui_receive(state, ("event", state.run_id, event))
with mock.patch.object(install, "_tui_folder_facts", return_value={
"free": 50 * 1024 ** 3, "writable": True, "cores": []}):
install.tui_receive(state, ("ended", state.run_id, 1, []))
text = " ".join(" ".join(install.tui_view(state, 120, 40)).split())
self.assertIn("Read the first 200,000 files of E:\\.", text)
self.assertIn("E:\\D is another drive: add it to copy from it too.", text)
self.assertIn("2 files on your old card could not be read.", text)
rows = install.tui_rows(state)
state.focus["plan"] = next(i for i, row in enumerate(rows) if row.action == "unreadable")
press(state, "enter")
self.assertEqual(state.screen, "unreadable")
self.assertEqual(install._pager_items(state), [
"E:\\bios\\scph5501.bin: Permission denied",
"E:\\backup.zip, gba_bios.bin: it is encrypted, a password is needed"])
def test_the_interface_does_not_index_the_folders_itself(self):
missing = REPO_ROOT / "tmp" / "no-such-old-card"
err = io.StringIO()
with mock.patch.object(install, "FolderIndex", side_effect=AssertionError("indexed")), \
mock.patch.object(install, "detect_os", return_value="linux"), \
mock.patch.object(install, "tui_start", side_effect=AssertionError("opened")), \
mock.patch.object(sys, "stderr", err):
self.assertEqual(install.run_tui(_args(**{"from": [missing]})), 1)
self.assertIn(f"Error: {missing} does not exist: --from names the folder", err.getvalue())
# Work folders: only a kill no handler sees leaves one, and the next
# run takes it away.
def test_work_folders_left_behind_are_pruned(self):
import subprocess
root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
self.addCleanup(shutil.rmtree, root, True)
gone = subprocess.Popen([sys.executable, "-c", "pass"])
gone.wait()
now = time.time()
week = install.TUI_KEEP_SECONDS
def folder(name: str, pid: int | None, kept: bool, age: float) -> Path:
path = root / f"{install.TUI_WORK_PREFIX}{name}"
path.mkdir()
if pid is not None:
(path / install.TUI_OWNER).write_text(str(pid))
if kept:
(path / install.TUI_KEPT).write_text("")
os.utime(path, (now - age, now - age))
return path
killed = folder("killed", gone.pid, False, 60)
running = folder("running", os.getpid(), False, 2 * week)
failed = folder("failed", gone.pid, True, 60)
failed_old = folder("failed-old", gone.pid, True, 2 * week)
unknown = folder("unknown", None, False, 60)
unknown_old = folder("unknown-old", None, False, 2 * week)
other = root / "something-else"
other.mkdir()
removed = install._prune_work_folders(root, now)
self.assertEqual(sorted(removed), sorted([killed, failed_old, unknown_old]))
self.assertEqual(sorted(path.name for path in root.iterdir()),
sorted(path.name for path in (running, failed, unknown, other)))
class Crash(unittest.TestCase): class Crash(unittest.TestCase):
"""An unexpected error ends on one sentence and a log, never a traceback.""" """An unexpected error ends on one sentence and a log, never a traceback."""
@@ -2030,11 +2545,14 @@ class EndToEnd(unittest.TestCase):
term.send("\r") term.send("\r")
screen = term.wait("found in") screen = term.wait("found in")
# The folder wraps at a slash: compared without the spaces. # The folder wraps at a slash: compared without the spaces.
self.assertIn("".join(f"3 found in {old}, 2 to download (8 B)".split()), self.assertIn("3 found in oldcard, 2 to download (8 B)", screen)
"".join(screen.split()))
# Checked again, the plan opens on Install. # Checked again, the plan opens on Install.
term.send("\r") term.send("\r")
term.wait("files installed.") screen = term.wait("files installed.")
# Where each came from, as the line mode says it.
self.assertIn("5 files installed.", screen)
self.assertIn("3 files copied from", screen)
self.assertIn("2 downloaded.", screen)
term.send("\r") term.send("\r")
self.assertEqual(term.close(), 0) self.assertEqual(term.close(), 0)
self.assertEqual(len(repo.installed()), 5) self.assertEqual(len(repo.installed()), 5)
+26
View File
@@ -96,3 +96,29 @@ class SemanticManifestValidation(unittest.TestCase):
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
try:
import jsonschema
except ImportError:
jsonschema = None
@unittest.skipIf(jsonschema is None, "jsonschema unavailable")
class DataRootIsKeyedBySystem(unittest.TestCase):
"""AetherSX2 gave Android/data/xyz.aethersx2.android only in its notes:
the site could not print the folder, nor which OS tabs apply."""
def _errors(self, value):
schema = json.loads((REPO_ROOT / "schemas/emulator.schema.json").read_text(encoding="utf-8"))
validator = jsonschema.Draft202012Validator(schema["properties"]["data_root"])
return list(validator.iter_errors(value))
def test_a_system_with_its_path_and_reference(self):
self.assertEqual(self._errors({"android": {
"path": "Android/data/xyz.aethersx2.android/files", "source_ref": "a.kt:1"}}), [])
def test_an_unknown_system_or_a_path_without_reference(self):
self.assertTrue(self._errors({"amiga": {"path": "x", "source_ref": "y"}}))
self.assertTrue(self._errors({"windows": {"path": "%APPDATA%/X"}}))
self.assertTrue(self._errors({}))
+10 -4
View File
@@ -125,15 +125,21 @@ class FirstInstallEnds(unittest.TestCase):
"""C7, B15: the page said `Done.` may list failures, and sent a wrong """C7, B15: the page said `Done.` may list failures, and sent a wrong
folder to a section with no answer for it.""" folder to a section with no answer for it."""
def test_the_wrong_folder_answer_is_dest(self): def test_the_wrong_folder_answer_is_on_the_screen(self):
section = _section(_wiki("first-install.md"), "2. Check the folder it found") # The one-liner opens the full screen: its first screen has the
self.assertIn("--dest", section) # answer, a row the page names as the interface spells it.
import install
section = _section(_wiki("first-install.md"), "2. Check the setup it found")
state = install.TuiState()
row = install.tui_rows(state)[-1].label
self.assertIn(f"**{row}**", section)
self.assertIn("<your setup>", section) self.assertIn("<your setup>", section)
self.assertNotIn("Batocera", section) self.assertNotIn("Batocera", section)
def test_the_diagram_follows_the_steps(self): def test_the_diagram_follows_the_steps(self):
text = _wiki("first-install.md") text = _wiki("first-install.md")
self.assertLess(text.index("## 5. Read the last line"), text.index("<!-- image: install-flow -->")) self.assertLess(text.index("## 5. Read the result"), text.index("<!-- image: install-flow -->"))
class OneNamePerPage(unittest.TestCase): class OneNamePerPage(unittest.TestCase):
+15 -5
View File
@@ -251,11 +251,21 @@ class SmallCopyFixes(unittest.TestCase):
self.assertIn("`retroarch/cores/system/` on the SD card", guides) self.assertIn("`retroarch/cores/system/` on the SD card", guides)
self.assertIn("--dest E:\\retroarch\\cores\\system", section) self.assertIn("--dest E:\\retroarch\\cores\\system", section)
def test_the_end_line_is_the_printed_one(self): def test_the_end_is_the_shown_one(self):
section = _section(_wiki("first-install.md"), "5. Read the last line") # The one-liner opens the full screen: the page names the titles
self.assertIn("`N files installed, M already up to date.`", section) # its result screen shows, as tui_outcome writes them.
source = (REPO_ROOT / "install.py").read_text(encoding="utf-8") import install
self.assertIn("installed, \"\n f\"{_plural(up_to_date, 'file')} already up to date.", source)
section = _section(_wiki("first-install.md"), "5. Read the result")
state = install.TuiState()
state.stack, state.code = ["result"], 0
for summary in ({"installed": 3, "up_to_date": 0, "failed": []},
{"installed": 2, "failed": [{"dest": "a.bin", "reason": "HTTP 404"}]},
{"installed": 0, "failed": [{"dest": "a.bin", "reason": "HTTP 404"}]}):
state.summary = summary
title, _lines = install.tui_outcome(state)
with self.subTest(title=title):
self.assertIn(f"**{title}**", section)
class CommunityAnswers(unittest.TestCase): class CommunityAnswers(unittest.TestCase):
+30 -34
View File
@@ -67,44 +67,44 @@ curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh |
On Batocera, Recalbox and similar setups, see On Batocera, Recalbox and similar setups, see
[Get your files](../which-pack.md). [Get your files](../which-pack.md).
## 2. Check the folder it found ## 2. Check the setup it found
It shows your setup and its BIOS folder: It opens a menu:
``` ```
Found <your setup> at <its folder> RetroBIOS: Where do you play?
Found on this machine:
> <your setup>
Another setup or folder
BIOS folder: <its folder>
``` ```
- **Right folder?** Go on. - **Right folder?** Press Enter.
- **Wrong folder?** Press Ctrl+C. Run it again with your folder: - **Wrong folder?** Choose **Another setup or folder**, then browse to yours.
- **A handheld's SD card in a reader?** It is listed, as **muOS SD card**.
<!-- os-tabs windows: --dest D:\path\to\bios --> Arrows move, Enter chooses, Esc goes back. A controller's A and B do the same.
```sh
curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh -s -- --dest ~/path/to/bios
```
## 3. Choose what to install ## 3. Install
``` The next screen counts the missing files and the free space.
Enter) install everything Press Enter on **Install**. **Choose systems** keeps the consoles you
i) only what the installed cores read play.
c) choose systems, cores and regions
q) quit
```
**Enter** installs everything. **c** lets you pick consoles. **Old card?** In a card reader, choose **Copy the BIOS files from your old
card or a folder**, then the card. It stays unchanged.
## 4. Wait ## 4. Wait
Ctrl+C stops it. Run the command again to carry on. B or Ctrl+C stops it. Run it again to carry on.
## 5. Read the last line ## 5. Read the result
- **`Done.`** `N files installed, M already up to date.` All files are in place. - **Done**: all files are in place.
- **`Finished with errors.`** Some files failed. Run the same command again. - **Finished with errors**: run the command again. It retries only those.
It retries only those. - **Nothing installed**: the screen says why. See [If it stops](#if-it-stops).
- **`Nothing was installed.`** The lines above it say why. See
[If it stops](#if-it-stops). For questions line by line (screen readers), add `--plain`.
## What the command does ## What the command does
@@ -127,18 +127,14 @@ All good:
## Undo ## Undo
Every file it replaces goes to a `retrobios-backup` folder next to your BIOS Files it replaces go to a `retrobios-backup` folder beside your BIOS folder.
folder. The last lines say how to put them back. The result says how to restore them.
## If it stops ## If it stops
### No supported platform detected. ### Nothing was found here.
At a terminal, a menu follows. Pick your setup by its number, then type its Choose **Another setup or folder**, then your setup and its folder.
BIOS folder, or press Enter for the usual one.
With `--no-input`, or without a terminal, it stops and prints the command to
use.
### Error: Python 3.8 or newer is required. ### Error: Python 3.8 or newer is required.
@@ -157,9 +153,9 @@ The download was cut short. Nothing was changed. Run the command again.
You cannot write there. Do what the message says. You cannot write there. Do what the message says.
### Not enough space on the drive holding ... ### The card is full
Nothing was written. Free some space, or pick another drive with `--dest`. Nothing was written. Free some space, or choose another folder.
### The game still does not start ### The game still does not start
+31 -1
View File
@@ -67,7 +67,10 @@ curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh \
``` ```
`--dest` is the BIOS folder itself: on a card, `retroarch/cores/system` for `--dest` is the BIOS folder itself: on a card, `retroarch/cores/system` for
RetroArch on Switch, `RetroArch/system` on Android. RetroArch on Switch, `RetroArch/system` on Android. The root of a handheld's
card works too when the installer knows that card (muOS, Onion, spruceOS,
KNULLI, ROCKNIX and the MinUI family, see
[Platform detection](#platform-detection)): the files go where its system reads.
PowerShell needs another form. `iex` takes the script text as its own argument, PowerShell needs another form. `iex` takes the script text as its own argument,
so anything appended to `irm ... | iex` is read as an argument to `iex` and so anything appended to `irm ... | iex` is read as an argument to `iex` and
@@ -454,6 +457,9 @@ python install.py --from ~/Downloads --from /media/me/backup
python install.py --check --from /media/me/STOCK python install.py --check --from /media/me/STOCK
``` ```
The first line names the root of the muOS card, the one holding `MUOS`: the
files go to `MUOS/bios` on it, where muOS reads them, and the run says so.
Every file is recognised by its contents, never by its name: Every file is recognised by its contents, never by its name:
`PSX BIOS (USA).bin` in a subfolder lands as `scph5501.bin` when it holds those `PSX BIOS (USA).bin` in a subfolder lands as `scph5501.bin` when it holds those
bytes, and a file of the right name with other bytes (an empty file left by a bytes, and a file of the right name with other bytes (an empty file left by a
@@ -749,6 +755,26 @@ family writes the release name as the first line of `.system/version.txt` at
the card root (`MinUI-`, `NextUI-`, `MyMinUI-`), and that line names the file the card root (`MinUI-`, `NextUI-`, `MyMinUI-`), and that line names the file
list. list.
A card that holds a system's own files is read the same way, without
`--platform` or with the one that names its list, by the rules the table above
gives for the device, below the card's root instead of its mount point:
| On the card | System | Files go to |
|-------------|--------|-------------|
| `MUOS/` | muOS | `MUOS/bios`, RetroArch's list |
| `.tmp_update/onionVersion/version.txt` | Onion | `BIOS`, RetroArch's list |
| `spruce/` | spruceOS | `BIOS`, RetroArch's list |
| `System/usr/trimui/crossmix-version.txt` | CrossMix-OS | `BIOS`, RetroArch's list |
| `.allium/` | Allium | `BIOS`, RetroArch's list |
| `Koriki/version.txt` | Koriki | `BIOS`, RetroArch's list |
| `system/batocera.conf` with `system.hostname=KNULLI`, as KNULLI's first boot writes its share | KNULLI | `bios`, KNULLI's list |
| `.config/system/configs/system.cfg` with `root.password=rocknix`, as ROCKNIX's first boot writes its storage | ROCKNIX | `roms/bios`, ROCKNIX's list |
The full screen lists such a card on its first screen ("muOS SD card (E:\)").
Named by its system (`--platform muos`, `--platform onion`), a `--dest` that
is neither such a card nor its BIOS folder is refused, with the folder that
system reads.
Desktop installs are read from their own configuration, and several can be Desktop installs are read from their own configuration, and several can be
found on one machine. found on one machine.
@@ -1173,6 +1199,10 @@ fields, the installer refuses to narrow rather than install everything.
| `130` | stopped with Ctrl+C, or with `q`, Ctrl+C or Ctrl+D at a question. Run the same command again to carry on | | `130` | stopped with Ctrl+C, or with `q`, Ctrl+C or Ctrl+D at a question. Run the same command again to carry on |
| `141` | the output was piped to a reader that closed early (`--list-platforms \| head`) | | `141` | the output was piped to a reader that closed early (`--list-platforms \| head`) |
The full screen ended by SIGTERM (Start+Select on a handheld) before any
install exits `143`. During an install it stops the install first, and exits
`130`.
`install.ps1` passes the installer's code on unchanged: run as a file it exits `install.ps1` passes the installer's code on unchanged: run as a file it exits
with it, run through `irm \| iex` it sets `$LASTEXITCODE` (an `exit` there with it, run through `irm \| iex` it sets `$LASTEXITCODE` (an `exit` there
would close the PowerShell window). It declares no parameter of its own, so would close the PowerShell window). It declares no parameter of its own, so
+1
View File
@@ -591,6 +591,7 @@ does not look.
| `known_hash_adler32` | expected Adler-32 hash (Dolphin's DSP ROMs). Pair with `adler32_byteswap` when the code hashes 16-bit byte-swapped data | | `known_hash_adler32` | expected Adler-32 hash (Dolphin's DSP ROMs). Pair with `adler32_byteswap` when the code hashes 16-bit byte-swapped data |
| `adler32_pairs_with`, `adler32_pairs` | the file hashed together with this one and the combinations the code accepts, this file's value first, in table order: Dolphin warns on a DSP ROM pair outside its `known_roms` even when each file passes alone | | `adler32_pairs_with`, `adler32_pairs` | the file hashed together with this one and the combinations the code accepts, this file's value first, in table order: Dolphin warns on a DSP ROM pair outside its `known_roms` even when each file passes alone |
| `pin_frozen` | why `source_commit` is held on purpose rather than followed. The profile is then judged against its own revision, like one pinned to a superseded tag | | `pin_frozen` | why `source_commit` is held on purpose rather than followed. The profile is then judged against its own revision, like one pinned to a superseded tag |
| `data_root` | per operating system (`android`, `ios`, `windows`, `macos`, `linux`), the folder the app reads its user data from, under which `standalone_path` destinations sit: `path` as the user finds it (`Android/data/<package>/files`, `%APPDATA%/...`), with the `source_ref` that builds it. Only the systems the app runs on are keyed |
| `upstream_frozen` | why `upstream_commit` is held: the port vendors that upstream tree, synced to that release, or merged that commit. The upstream view is read at its own pin and a bump leaves it. Without it, a port that is a GitHub fork of its upstream follows the merge base of the two | | `upstream_frozen` | why `upstream_commit` is held: the port vendors that upstream tree, synced to that release, or merged that commit. The upstream view is read at its own pin and a bump leaves it. Without it, a port that is a GitHub fork of its upstream follows the merge base of the two |
| `prose_written_at` | the pin a reader confirmed the prose citations against. When the profile's history shows a text living under several pins, profile_sync takes this one as the revision the text describes, so a bump is no longer refused for a citation already reread. It never overrides a history with a single candidate | | `prose_written_at` | the pin a reader confirmed the prose citations against. When the profile's history shows a text living under several pins, profile_sync takes this one as the revision the text describes, so a bump is no longer refused for a citation already reread. It never overrides a history with a single candidate |
| `version_source` | for a project published outside any forge, `{url, pattern}`: the page that announces releases and a regular expression whose first group is the latest version. `profile_sync --check-version` compares it with `core_version`, and `check_freshness --profiles` calls the profile stale when they differ. `no_genuine_copy` names an announced version found only rebuilt or re-signed: the profile stays on the newest unmodified build and is not stale until the page announces a later one | | `version_source` | for a project published outside any forge, `{url, pattern}`: the page that announces releases and a regular expression whose first group is the latest version. `profile_sync --check-version` compares it with `core_version`, and `check_freshness --profiles` calls the profile stale when they differ. `no_genuine_copy` names an announced version found only rebuilt or re-signed: the profile stays on the newest unmodified build and is not stale until the page announces a later one |
+2 -1
View File
@@ -38,7 +38,8 @@ Run it without `--check`. It adds missing files and replaces wrong ones.
- **Right folder?** [Get your files](../which-pack.md) names it. - **Right folder?** [Get your files](../which-pack.md) names it.
- **Exact name?** `scph5501.bin` and `SCPH-5501.BIN` differ. On Linux, - **Exact name?** `scph5501.bin` and `SCPH-5501.BIN` differ. On Linux,
capitals count too. capitals count too.
- **A `bios` folder inside your BIOS folder?** Move its files up one level. - **A `bios` folder inside your BIOS folder?** Run the installer: it copies
those files up one level.
- **Unzipped by hand on Linux?** The command adds names that differ by - **Unzipped by hand on Linux?** The command adds names that differ by
capitals. capitals.
- **A ZIP in parts, like `Pack.part1of2.zip`?** Unzip every part into one - **A ZIP in parts, like `Pack.part1of2.zip`?** Unzip every part into one