mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-13 13:33:52 -06:00
chore: merge installer fixes, fourth pass
This commit is contained in:
commit
a29b836730
27 files changed
+888
-158
No files matched your search
+277
-6
@@ -451,6 +451,64 @@ class AMissingDestIsSaid(unittest.TestCase):
|
||||
self.assertIn("does not exist", out)
|
||||
|
||||
|
||||
class ADestThatIsAFileIsNamed(unittest.TestCase):
|
||||
"""--dest naming a file, or a path under one, failed at mkdir with
|
||||
EEXIST or ENOTDIR, and the run said this user cannot write there and
|
||||
offered a sudo command that fails the same way."""
|
||||
|
||||
def _run(self, dest: Path) -> subprocess.CompletedProcess:
|
||||
env = dict(os.environ, RETROBIOS_OS="linux")
|
||||
return subprocess.run(
|
||||
[sys.executable, str(REPO_ROOT / "install.py"), "--platform", "retroarch",
|
||||
"--dest", str(dest), "--manifest", str(REPO_ROOT / "install" / "retroarch.json"),
|
||||
"--system", "nintendo-gba", "--yes", "--events", "--offline"],
|
||||
env=env, capture_output=True, text=True, timeout=120, check=False,
|
||||
stdin=subprocess.DEVNULL)
|
||||
|
||||
def test_the_file_is_named_and_sudo_is_not_offered(self):
|
||||
root = Path(_tempdir(self))
|
||||
(root / "scph5501.bin").write_bytes(b"x")
|
||||
for dest, words in (
|
||||
(root / "scph5501.bin", f"{root / 'scph5501.bin'} is a file, not a folder"),
|
||||
(root / "scph5501.bin" / "sub",
|
||||
f"cannot be a folder: {root / 'scph5501.bin'} is a file"),
|
||||
):
|
||||
with self.subTest(dest=dest):
|
||||
proc = self._run(dest)
|
||||
self.assertEqual(proc.returncode, 1, proc.stderr)
|
||||
self.assertIn(words, proc.stderr)
|
||||
self.assertNotIn("sudo", proc.stderr)
|
||||
errors = [json.loads(line) for line in proc.stdout.splitlines()
|
||||
if line.startswith("{") and '"error"' in line]
|
||||
self.assertEqual([(e["stage"], e["reason"]) for e in errors],
|
||||
[("dest", "not_a_folder")])
|
||||
self.assertEqual((root / "scph5501.bin").read_bytes(), b"x")
|
||||
|
||||
|
||||
class SaveFilesAListHoldsNoneOfAreNotPromised(unittest.TestCase):
|
||||
"""RetroPie, ROCKNIX, AmberELEC, EmuELEC and RomM keep their save
|
||||
directory in no fixed place: their lists leave those files out with no
|
||||
hash or address. The run said "Name it with --save-dir", and said it
|
||||
again with --save-dir given, which installs none of them."""
|
||||
|
||||
def test_the_reason_is_said_and_no_option_is_offered(self):
|
||||
manifest = json.loads((REPO_ROOT / "install" / "retropie.json").read_text())
|
||||
self.assertIsNone(manifest.get("save_directory"))
|
||||
root = Path(_tempdir(self))
|
||||
env = dict(os.environ, RETROBIOS_OS="linux")
|
||||
for extra in ([], ["--save-dir", str(root / "saves")]):
|
||||
with self.subTest(extra=extra):
|
||||
proc = _install_run(
|
||||
env, "--platform", "retropie", "--dest", str(root / "bios"),
|
||||
"--manifest", "install/retropie.json", "--offline", "--no-input",
|
||||
"--yes", "--emulator", "azahar", *extra)
|
||||
out = proc.stdout + proc.stderr
|
||||
self.assertIn("read from RetroPie's save directory left out: RetroPie "
|
||||
"keeps it in no fixed place, and its file list holds none "
|
||||
"of them.", out)
|
||||
self.assertNotIn("Name it with --save-dir", out)
|
||||
|
||||
|
||||
class AFolderThatRefusesWritesIsNamed(unittest.TestCase):
|
||||
"""A read-only BIOS folder ended the run on a traceback (a subfolder to
|
||||
create), or on three retries per file and "once that is resolved", and a
|
||||
@@ -2673,6 +2731,52 @@ class RetroArchWhereItReallyLives(unittest.TestCase):
|
||||
self.assertIn(("retroarch", support / "system"), found)
|
||||
|
||||
|
||||
class LinuxRetroArchFollowsXdgConfigHome(unittest.TestCase):
|
||||
"""RetroArch on Linux keeps its config under $XDG_CONFIG_HOME/retroarch
|
||||
when the variable is set, then falls back to ~/.retroarch.cfg
|
||||
(file_path_special.c fill_pathname_application_data, configuration.c
|
||||
open_default_config_file). Only ~/.config/retroarch was read: a home
|
||||
with its own XDG layout detected nothing."""
|
||||
|
||||
def _env(self) -> tuple[Path, Path]:
|
||||
home = Path(_tempdir(self))
|
||||
xdg = Path(_tempdir(self))
|
||||
patcher = unittest.mock.patch.dict(os.environ, {"HOME": str(home)}, clear=True)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
return home, xdg
|
||||
|
||||
def test_the_config_under_xdg_config_home_is_read(self):
|
||||
home, xdg = self._env()
|
||||
(xdg / "retroarch").mkdir()
|
||||
(xdg / "retroarch" / "retroarch.cfg").write_text('system_directory = "/srv/bios"\n')
|
||||
# A config under ~/.config is not the one this RetroArch opens.
|
||||
(home / ".config" / "retroarch").mkdir(parents=True)
|
||||
(home / ".config" / "retroarch" / "retroarch.cfg").write_text(
|
||||
'system_directory = "/elsewhere"\n')
|
||||
os.environ["XDG_CONFIG_HOME"] = str(xdg)
|
||||
found = install.detect_platforms("linux")
|
||||
self.assertIn(("retroarch", Path("/srv/bios")), found)
|
||||
self.assertNotIn(("retroarch", Path("/elsewhere")), found)
|
||||
self.assertEqual(install._os_default_dests("linux")["retroarch"],
|
||||
xdg / "retroarch" / "system")
|
||||
|
||||
def test_home_retroarch_cfg_is_the_fallback(self):
|
||||
home, xdg = self._env()
|
||||
os.environ["XDG_CONFIG_HOME"] = str(xdg)
|
||||
(home / ".retroarch.cfg").write_text('system_directory = "default"\n')
|
||||
found = install.detect_platforms("linux")
|
||||
self.assertIn(("retroarch", xdg / "retroarch" / "system"), found)
|
||||
|
||||
def test_without_the_variable_the_folder_is_under_dot_config(self):
|
||||
home, _ = self._env()
|
||||
(home / ".config" / "retroarch").mkdir(parents=True)
|
||||
(home / ".config" / "retroarch" / "retroarch.cfg").write_text(
|
||||
'system_directory = "default"\n')
|
||||
found = install.detect_platforms("linux")
|
||||
self.assertIn(("retroarch", home / ".config" / "retroarch" / "system"), found)
|
||||
|
||||
|
||||
class NoPythonNamesThisSystemsCommand(unittest.TestCase):
|
||||
"""Without Python the bootstrap said `sudo apt install python3` on every
|
||||
system: Termux has no sudo and installs with `pkg install python`, and
|
||||
@@ -3790,6 +3894,96 @@ class DataDirectoriesAreInstalled(unittest.TestCase):
|
||||
(len(body), len(body)))
|
||||
emit.assert_not_called()
|
||||
|
||||
def test_a_cut_archive_resumes_where_it_stopped(self):
|
||||
"""A data archive cut at 90% was deleted and fetched again from
|
||||
zero, on the retry and on the next run, while the run said that
|
||||
nothing was lost."""
|
||||
work = Path(_tempdir(self))
|
||||
body = self._zip(work).read_bytes()
|
||||
keep = work / ".dolphin-sys.archive.part"
|
||||
requests: list[dict] = []
|
||||
|
||||
class Response(io.BytesIO):
|
||||
def __init__(self, data, status, headers):
|
||||
super().__init__(data)
|
||||
self.status, self.headers = status, headers
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def urlopen(request, timeout=None):
|
||||
sent = dict(request.header_items())
|
||||
requests.append(sent)
|
||||
if "Range" in sent:
|
||||
start = int(sent["Range"][len("bytes="):-1])
|
||||
if sent.get("If-range") != '"v1"':
|
||||
return Response(body, 200, {"ETag": '"v2"'})
|
||||
return Response(body[start:], 206, {
|
||||
"Content-Range": f"bytes {start}-{len(body) - 1}/{len(body)}",
|
||||
"Content-Length": str(len(body) - start), "ETag": '"v1"'})
|
||||
cut = len(body) * 9 // 10
|
||||
|
||||
class Cut(Response):
|
||||
def read(self, size=-1):
|
||||
if self.tell() >= cut:
|
||||
raise ConnectionResetError("cut")
|
||||
return super().read(min(size, cut - self.tell()))
|
||||
return Cut(body, 200, {"Content-Length": str(len(body)), "ETag": '"v1"'})
|
||||
|
||||
with unittest.mock.patch.object(install.urllib.request, "urlopen", urlopen), \
|
||||
unittest.mock.patch.object(install, "_network_allowed", lambda url: None), \
|
||||
unittest.mock.patch.object(install, "_retry_delay", lambda *a: 0):
|
||||
# The next run, after a stop: what the cut attempt wrote is kept.
|
||||
with unittest.mock.patch.object(install, "MAX_RETRIES", 1), \
|
||||
self.assertRaises(ConnectionResetError):
|
||||
install._fetch_archive("https://example.invalid/D.zip", work, None, keep)
|
||||
self.assertEqual(keep.stat().st_size, len(body) * 9 // 10)
|
||||
path = install._fetch_archive("https://example.invalid/D.zip", work, None, keep)
|
||||
self.assertEqual(path.read_bytes(), body)
|
||||
self.assertEqual(requests[-1]["Range"], f"bytes={len(body) * 9 // 10}-")
|
||||
self.assertEqual(requests[-1]["If-range"], '"v1"')
|
||||
|
||||
def test_a_changed_archive_is_fetched_whole(self):
|
||||
work = Path(_tempdir(self))
|
||||
body = self._zip(work).read_bytes()
|
||||
keep = work / ".dolphin-sys.archive.part"
|
||||
keep.write_bytes(b"older revision")
|
||||
keep.with_name(keep.name + ".source").write_text(
|
||||
'https://example.invalid/D.zip\n"v0"\n')
|
||||
|
||||
class Whole(io.BytesIO):
|
||||
status, headers = 200, {"ETag": '"v1"'}
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
with unittest.mock.patch.object(install.urllib.request, "urlopen",
|
||||
lambda request, timeout=None: Whole(body)), \
|
||||
unittest.mock.patch.object(install, "_network_allowed", lambda url: None):
|
||||
path = install._fetch_archive("https://example.invalid/D.zip", work, None, keep)
|
||||
self.assertEqual(path.read_bytes(), body)
|
||||
|
||||
def test_a_tree_read_whole_leaves_no_archive(self):
|
||||
work = Path(_tempdir(self))
|
||||
archive = self._zip(work)
|
||||
bios = work / "system"
|
||||
bios.mkdir()
|
||||
|
||||
def fetch(url, directory, progress=None, keep=None):
|
||||
shutil.copy(archive, keep)
|
||||
keep.with_name(keep.name + ".source").write_text(f"{url}\n\"v1\"\n")
|
||||
return keep
|
||||
|
||||
with unittest.mock.patch.object(install, "_fetch_archive", fetch):
|
||||
install.install_data_directory(self._entry(), bios)
|
||||
self.assertEqual([p.name for p in (bios / "dolphin-emu").iterdir()], ["Sys"])
|
||||
|
||||
def test_the_run_ends_on_the_archive_bytes_too(self):
|
||||
from test_install_selection import FakeRepository # noqa: PLC0415
|
||||
|
||||
@@ -3811,13 +4005,44 @@ class DataDirectoriesAreInstalled(unittest.TestCase):
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertIn(f"{install.format_size(bios + archive)} downloaded in", proc.stdout)
|
||||
|
||||
def test_the_plan_announces_the_archive_not_the_tree(self):
|
||||
"""yaps2's tarball is 21.2 MB for a 6.5 MB tree, Cemu's 21.4 MB for
|
||||
9.9 KB: the plan announced the tree, and the run downloaded far more."""
|
||||
from test_install_selection import FakeRepository # noqa: PLC0415
|
||||
|
||||
member = b"profile"
|
||||
archive_size = 21_360_579
|
||||
with FakeRepository() as repo:
|
||||
listed = json.loads((repo.root / "install" / "retroarch.json").read_text())
|
||||
listed["data_directories"] = [{
|
||||
"ref": "cemu-gameprofiles", "dest": "cemu", "type": "tarball",
|
||||
"url": "https://example.invalid/Cemu.tar.gz", "source_path": "Cemu-1/bin",
|
||||
"systems": ["nintendo-gba"], "archive_size": archive_size,
|
||||
"files": {"gameProfiles/a.ini": {"sha1": hashlib.sha1(member).hexdigest(),
|
||||
"size": len(member)}}}]
|
||||
(repo.root / "install" / "retroarch.json").write_text(json.dumps(listed))
|
||||
proc = repo.run("--no-input", "--dry-run")
|
||||
bios = sum(len(payload) for payload, *_ in FakeRepository.PAYLOADS.values())
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertIn(f"({install.format_size(bios + archive_size)})", proc.stdout)
|
||||
self.assertIn(f"(a {install.format_size(archive_size)} archive)", proc.stdout)
|
||||
|
||||
def test_a_pack_or_an_old_list_counts_the_files_themselves(self):
|
||||
needed = {"a": {"size": 7}}
|
||||
entry = {"archive_size": 21_360_579}
|
||||
self.assertEqual(install.data_fetch_bytes(entry, needed), 21_360_579)
|
||||
self.assertEqual(install.data_fetch_bytes({}, needed), 7)
|
||||
self.assertEqual(install.data_fetch_bytes(entry, {}), 0)
|
||||
with unittest.mock.patch.object(install.LOCAL, "pack", object()):
|
||||
self.assertEqual(install.data_fetch_bytes(entry, needed), 7)
|
||||
|
||||
def test_verified_members_land_the_rest_are_said(self):
|
||||
work = Path(_tempdir(self))
|
||||
archive = self._zip(work)
|
||||
bios = work / "system"
|
||||
bios.mkdir()
|
||||
|
||||
def fetch(url, directory, progress=None):
|
||||
def fetch(url, directory, progress=None, keep=None):
|
||||
copy = Path(directory) / "a.zip"
|
||||
shutil.copy(archive, copy)
|
||||
return copy
|
||||
@@ -3853,8 +4078,9 @@ class DataDirectoriesAreInstalled(unittest.TestCase):
|
||||
def members(_archive, _entry):
|
||||
yield "Sys/GC/font.bin", 4, lambda: io.BufferedReader(Endless())
|
||||
|
||||
with unittest.mock.patch.object(install, "_fetch_archive",
|
||||
lambda url, d, progress=None: Path(d) / "a"), \
|
||||
with unittest.mock.patch.object(
|
||||
install, "_fetch_archive",
|
||||
lambda url, d, progress=None, keep=None: Path(d) / "a"), \
|
||||
unittest.mock.patch.object(install, "_archive_members", members):
|
||||
counts = install.install_data_directory(entry, work)
|
||||
self.assertEqual(counts["changed"], 1)
|
||||
@@ -3881,8 +4107,9 @@ class DataDirectoriesAreInstalled(unittest.TestCase):
|
||||
def members(_archive, _entry):
|
||||
yield "Sys/GC/font.bin", 4, lambda: io.BufferedReader(Broken())
|
||||
|
||||
with unittest.mock.patch.object(install, "_fetch_archive",
|
||||
lambda url, d, progress=None: Path(d) / "a"), \
|
||||
with unittest.mock.patch.object(
|
||||
install, "_fetch_archive",
|
||||
lambda url, d, progress=None, keep=None: Path(d) / "a"), \
|
||||
unittest.mock.patch.object(install, "_archive_members", members):
|
||||
counts = install.install_data_directory(entry, work)
|
||||
self.assertTrue(counts["error"])
|
||||
@@ -4422,6 +4649,50 @@ def _install_run(env: dict, *argv: str, **kwargs) -> subprocess.CompletedProcess
|
||||
)
|
||||
|
||||
|
||||
class AListingAppliesItsOptionsOrRefusesThem(unittest.TestCase):
|
||||
"""--list-systems --list-cores printed the systems alone, and
|
||||
--list-platforms, --list-targets and --list-emulators printed the same
|
||||
list with --system, --region or --core as without, code 0."""
|
||||
|
||||
def test_two_listings_or_an_option_a_listing_ignores_are_refused(self):
|
||||
env = dict(os.environ, RETROBIOS_OS="linux")
|
||||
given = ["--platform", "retroarch", "--offline", "--no-input",
|
||||
"--manifest", "install/retroarch.json"]
|
||||
for argv, words in (
|
||||
([*given, "--list-systems", "--list-cores"],
|
||||
"--list-systems and --list-cores each print a list"),
|
||||
([*given, "--list-cores", "--list-emulators"],
|
||||
"--list-cores and --list-emulators each print a list"),
|
||||
(["--list-platforms", "--list-systems", "--platform", "retroarch"],
|
||||
"--list-platforms and --list-systems each print a list"),
|
||||
(["--list-platforms", "--region", "us"],
|
||||
"--list-platforms prints the whole list, which --region does not narrow"),
|
||||
(["--list-platforms", "--platform", "retroarch"],
|
||||
"--list-platforms prints the whole list, which --platform does not narrow"),
|
||||
([*given, "--list-targets", "--system", "sony-playstation"],
|
||||
"--list-targets prints the whole list, which --system does not narrow"),
|
||||
([*given, "--list-targets", "--target", "switch"],
|
||||
"--list-targets prints the whole list, which --target does not narrow"),
|
||||
([*given, "--list-emulators", "--system", "sony-playstation"],
|
||||
"--list-emulators prints the whole list, which --system does not narrow"),
|
||||
([*given, "--list-emulators", "--core", "beetle_psx"],
|
||||
"--list-emulators prints the whole list, which --core does not narrow"),
|
||||
):
|
||||
with self.subTest(argv=argv):
|
||||
proc = _install_run(env, *argv)
|
||||
self.assertEqual(proc.returncode, 2, proc.stdout + proc.stderr)
|
||||
self.assertIn(words, proc.stderr)
|
||||
|
||||
def test_the_options_a_listing_reads_still_apply(self):
|
||||
env = dict(os.environ, RETROBIOS_OS="linux")
|
||||
given = ["--platform", "retroarch", "--offline", "--no-input",
|
||||
"--manifest", "install/retroarch.json"]
|
||||
whole = _install_run(env, *given, "--list-cores")
|
||||
narrowed = _install_run(env, *given, "--list-cores", "--system", "nintendo-gba")
|
||||
self.assertEqual((whole.returncode, narrowed.returncode), (0, 0), narrowed.stderr)
|
||||
self.assertLess(len(narrowed.stdout), len(whole.stdout))
|
||||
|
||||
|
||||
class AMissingPackNamesTheOptionAndThePacksBeside(unittest.TestCase):
|
||||
"""A typo in --from-pack said "Error: X does not exist": not the option,
|
||||
not the pack sitting next to the name typed."""
|
||||
@@ -6562,7 +6833,7 @@ class AFileYouHadIsKept(unittest.TestCase):
|
||||
"type": "zip", "files": {"Sys/a.ini": {
|
||||
"sha1": hashlib.sha1(self.NEW).hexdigest(), "size": len(self.NEW)}}}
|
||||
|
||||
def fetch(url, directory, progress=None):
|
||||
def fetch(url, directory, progress=None, keep=None):
|
||||
copy = Path(directory) / "a.zip"
|
||||
shutil.copy(archive, copy)
|
||||
return copy
|
||||
|
||||
@@ -778,7 +778,12 @@ class OfflineMakesNoRequest(Scratch):
|
||||
self.assertIn("--offline", proc.stderr)
|
||||
|
||||
def test_a_listing_and_a_pack_need_no_network_anyway(self):
|
||||
proc = self.run_installer("--offline", "--list-platforms")
|
||||
# --platform alone with it is refused: the listing names every one.
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(REPO_ROOT / "install.py"), "--offline", "--list-platforms"],
|
||||
env=_offline_env(), capture_output=True, text=True, timeout=120,
|
||||
check=False, stdin=subprocess.DEVNULL,
|
||||
)
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
|
||||
|
||||
|
||||
@@ -2967,12 +2967,44 @@ class SaveDirectoryLeftOut(unittest.TestCase):
|
||||
UNPLACED = {"reason": "no_save_directory", "label": "no save directory", "files": 2,
|
||||
"required": 0}
|
||||
|
||||
def _ended(self, manifest: dict) -> tuple:
|
||||
state = fresh_state()
|
||||
state.stack = ["setup"]
|
||||
with mock.patch.object(install.Path, "exists", return_value=True):
|
||||
install.tui_update(state, Key("enter"))
|
||||
install.tui_receive(state, ("manifest", "retroarch", manifest))
|
||||
summary = {"event": "summary", "mode": "check", "installed": 0, "up_to_date": 0,
|
||||
"pending": 5, "failed": [], "skipped": [self.UNPLACED], "left_out": [],
|
||||
"kept": [], "interrupted": False}
|
||||
for event in [*FILES, *SYSTEMS, CHECK, summary]:
|
||||
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, []))
|
||||
return state, summary
|
||||
|
||||
def test_a_list_that_holds_none_of_them_offers_no_folder(self):
|
||||
"""RetroPie's list leaves its save files out with no hash or address:
|
||||
--save-dir installs none of them, and was advised again each run."""
|
||||
state, summary = self._ended(MANIFEST)
|
||||
line = ("2 files read from RetroArch's save directory left out: RetroArch keeps "
|
||||
"it in no fixed place, and its file list holds none of them.")
|
||||
plan = " ".join(" ".join(install.tui_view(state, 120, 40)).split())
|
||||
self.assertIn(line, plan)
|
||||
self.assertNotIn("--save-dir", plan)
|
||||
state.stack.append("result")
|
||||
state.code = 0
|
||||
state.summary = {**summary, "mode": "install", "installed": 5}
|
||||
self.assertEqual([text for text in install.tui_outcome(state)[1] if "save" in text],
|
||||
[line])
|
||||
|
||||
def test_files_left_out_of_the_save_directory_are_said(self):
|
||||
state = fresh_state()
|
||||
state.stack = ["setup"]
|
||||
with mock.patch.object(install.Path, "exists", return_value=True):
|
||||
install.tui_update(state, Key("enter"))
|
||||
install.tui_receive(state, ("manifest", "retroarch", MANIFEST))
|
||||
install.tui_receive(state, ("manifest", "retroarch", {
|
||||
**MANIFEST, "save_directory": {"folder": "saves", "by_core": True}}))
|
||||
summary = {"event": "summary", "mode": "check", "installed": 0, "up_to_date": 0,
|
||||
"pending": 5, "failed": [], "skipped": [self.UNPLACED], "left_out": [],
|
||||
"kept": [], "interrupted": False}
|
||||
@@ -3834,6 +3866,8 @@ class BackTicksAndSaveFolder(unittest.TestCase):
|
||||
def test_the_save_folder_remedy_is_the_same_on_both_screens(self):
|
||||
summary = {"skipped": [{"reason": "no_save_directory", "files": 1}]}
|
||||
state = checked_state()
|
||||
state.manifest = {**state.manifest, "save_directory": {"folder": "saves",
|
||||
"by_core": True}}
|
||||
lines = install._unplaced_lines(summary, state)
|
||||
self.assertIn("Options, Save folder (--save-dir)", lines[0])
|
||||
|
||||
@@ -5898,7 +5932,10 @@ def _card_from_rules(root: Path, setup: install.CardSetup) -> Path:
|
||||
(root / "bios").mkdir(exist_ok=True)
|
||||
for value in rule.values:
|
||||
(root / value).parent.mkdir(parents=True, exist_ok=True)
|
||||
if rule.method == "games_part" and "." not in PurePosixPath(value).name:
|
||||
# A marker the BIOS folder sits under is a folder on the card
|
||||
# (muOS's MUOS/bios): written as a file, no folder fits below it.
|
||||
if (rule.method == "games_part" and "." not in PurePosixPath(value).name
|
||||
or str(setup.below).startswith(f"{value}/")):
|
||||
(root / value).mkdir(exist_ok=True)
|
||||
else:
|
||||
(root / value).write_text("", encoding="utf-8")
|
||||
@@ -9763,14 +9800,21 @@ class ScreenRowsAndFilters(unittest.TestCase):
|
||||
|
||||
from test_install_offline import PAYLOAD, _offline_env, _pack
|
||||
|
||||
blocker = self.root / "file"
|
||||
blocker.write_text("not a folder")
|
||||
if os.name == "nt" or os.geteuid() == 0:
|
||||
self.skipTest("a folder that refuses writes is POSIX, and root writes anyway")
|
||||
# A file in the way is refused before the plan, as not_a_folder.
|
||||
blocker = self.root / "closed"
|
||||
blocker.mkdir()
|
||||
pack = _pack(self.root, "RetroArch_v1_BIOS_Pack.zip", PAYLOAD)
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(REPO_ROOT / "install.py"), "--platform", "retroarch",
|
||||
"--dest", str(blocker / "system"), "--from-pack", str(pack), "--events"],
|
||||
env=_offline_env(), capture_output=True, text=True, timeout=120, check=False,
|
||||
stdin=subprocess.DEVNULL)
|
||||
os.chmod(blocker, 0o500)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(REPO_ROOT / "install.py"), "--platform", "retroarch",
|
||||
"--dest", str(blocker / "system"), "--from-pack", str(pack), "--events"],
|
||||
env=_offline_env(), capture_output=True, text=True, timeout=120,
|
||||
check=False, stdin=subprocess.DEVNULL)
|
||||
finally:
|
||||
os.chmod(blocker, 0o700)
|
||||
events = [json.loads(line) for line in proc.stdout.splitlines()]
|
||||
kinds = [event["event"] for event in events]
|
||||
self.assertEqual(kinds[-3:], ["error", "summary", "end"], proc.stderr)
|
||||
@@ -11836,6 +11880,8 @@ class OneChoicePerRun(unittest.TestCase):
|
||||
|
||||
def test_the_farewell_names_the_option_not_the_screen(self):
|
||||
state = _installed_state(SYSTEM_DIR)
|
||||
state.manifest = {**state.manifest, "save_directory": {"folder": "saves",
|
||||
"by_core": True}}
|
||||
state.summary = {**state.summary, "skipped": [
|
||||
{"reason": "no_save_directory", "label": "", "files": 1, "required": False}]}
|
||||
farewell = " ".join(install.tui_farewell(state))
|
||||
|
||||
@@ -93,6 +93,26 @@ class RefreshConcurrency(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(seen, [True])
|
||||
|
||||
def test_the_archive_size_is_recorded_without_replacing_the_tree(self):
|
||||
"""The installer announces a tree's download as the archive's size:
|
||||
a tarball extracted to 10 KB is a download of 21 MB. A tree current
|
||||
before the size was kept is measured, its files left as they are."""
|
||||
payload = _zip_bytes({"a.bin": b"x" * 100})
|
||||
entry = {"source_type": "zip", "source_url": "https://x/pak.zip",
|
||||
"local_cache": str(self.cache)}
|
||||
with mock.patch.object(rdd.urllib.request, "urlopen",
|
||||
lambda *a, **k: _Response(payload)), \
|
||||
mock.patch.object(rdd, "_get_remote_etag", return_value="e1"):
|
||||
self.assertTrue(rdd.refresh_entry("pak", entry, versions_path=self.versions))
|
||||
self.assertEqual(rdd._load_versions(self.versions)["pak"]["archive_size"],
|
||||
len(payload))
|
||||
rdd._record_version("pak", {"sha": "e1"}, self.versions)
|
||||
(self.cache / "a.bin").write_bytes(b"kept")
|
||||
self.assertFalse(rdd.refresh_entry("pak", entry, versions_path=self.versions))
|
||||
self.assertEqual(rdd._load_versions(self.versions)["pak"],
|
||||
{"sha": "e1", "archive_size": len(payload)})
|
||||
self.assertEqual((self.cache / "a.bin").read_bytes(), b"kept")
|
||||
|
||||
def test_a_current_tree_is_not_waited_for(self):
|
||||
"""A pipeline reads every tree for its whole run. A build beside it
|
||||
waited on the exclusive lock before asking the remote, which would
|
||||
@@ -100,7 +120,7 @@ class RefreshConcurrency(unittest.TestCase):
|
||||
import fcntl # noqa: PLC0415
|
||||
|
||||
self.cache.mkdir(parents=True)
|
||||
rdd._record_version("pak", {"sha": "e1"}, self.versions)
|
||||
rdd._record_version("pak", {"sha": "e1", "archive_size": 1}, self.versions)
|
||||
entry = {"source_type": "zip", "source_url": "https://x/pak.zip",
|
||||
"local_cache": str(self.cache)}
|
||||
result: list = []
|
||||
@@ -258,7 +278,7 @@ class DryRunReport(unittest.TestCase):
|
||||
def test_the_remote_tag_is_shown_beside_the_cached_one(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
versions = Path(tmp) / "versions.json"
|
||||
versions.write_text(json.dumps({"k": {"etag": '"2725165595"'}}))
|
||||
versions.write_text(json.dumps({"k": {"etag": '"2725165595"', "archive_size": 1}}))
|
||||
entry = {"source_type": "zip", "source_url": "https://x.invalid/a.zip",
|
||||
"local_cache": tmp}
|
||||
with mock.patch.object(rdd, "_get_remote_etag", return_value='"2685251384"'), \
|
||||
@@ -278,5 +298,30 @@ class DryRunReport(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TheManifestCarriesTheArchiveSize(unittest.TestCase):
|
||||
"""The size refresh_data_dirs recorded reaches the install list, which
|
||||
the installer announces as the tree's download."""
|
||||
|
||||
def test_recorded_size_is_written_and_an_unrecorded_one_left_out(self):
|
||||
from generate_pack import _manifest_data_directories # noqa: PLC0415
|
||||
|
||||
(REPO_ROOT / "tmp").mkdir(exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp:
|
||||
data = Path(tmp) / "data"
|
||||
for ref in ("pak", "old"):
|
||||
(data / ref).mkdir(parents=True)
|
||||
(data / ref / "a.bin").write_bytes(b"abc")
|
||||
(data / ".versions.json").write_text(json.dumps(
|
||||
{"pak": {"sha": "e1", "archive_size": 21_360_579}, "old": {"sha": "e2"}}))
|
||||
registry = {ref: {"source_type": "zip", "source_url": f"https://x/{ref}.zip",
|
||||
"local_cache": str(data / ref)} for ref in ("pak", "old")}
|
||||
members = [(str(data / ref / "a.bin"), f"{ref}/a.bin") for ref in ("pak", "old")]
|
||||
refs = {f"{ref}/a.bin": (ref, ("s",), ref) for ref in ("pak", "old")}
|
||||
listed = {entry["ref"]: entry for entry in
|
||||
_manifest_data_directories(members, refs, registry, "")}
|
||||
self.assertEqual(listed["pak"]["archive_size"], 21_360_579)
|
||||
self.assertNotIn("archive_size", listed["old"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user