Files
libretro/tests/test_handheld_launchers.py
T
Abdessamad Derraz f51bc069d5 fix: refuse a detected folder under a closed one
A system_directory read from retroarch.cfg under a folder this user cannot open ended the run, and the interface before its first screen, on a traceback. It now gets the refusal --dest gets. On a handheld, an idle close on an error screen shows that error for a few seconds instead of waiting for a button that cannot come.
2026-10-24 00:31:40 +02:00

1969 lines
92 KiB
Python

"""The handheld launchers: their scripts, controller mappings and archives,
and what each one does on a simulated device.
A device is simulated with a pseudo-terminal standing for its console, a
fake install.py that records how it was started, and fakes for what the
firmware provides (gptokeyb, setfont, chvt, curl, foot, xterm, muxterm,
PortMaster's control.txt and muOS's retro.sh), all on PATH or in a folder
the launcher reads. What only a device shows (the text reaching the screen,
the pad reaching the console, a compositor covering it) is not exercised.
"""
from __future__ import annotations
import hashlib
import importlib.util
import json
import os
import pty
import re
import shutil
import signal
import struct
import subprocess
import sys
import tempfile
import termios
import time
import unittest
import zipfile
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parent.parent
PORTS = REPO_ROOT / "ports"
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import build_port # noqa: E402
_spec = importlib.util.spec_from_file_location("install", REPO_ROOT / "install.py")
install = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(install)
SCRIPTS = [
PORTS / "shared" / "tui.txt",
PORTS / "RetroBIOS" / "RetroBIOS.sh",
PORTS / "RetroBIOS" / "retrobios" / "screens.txt",
PORTS / "batocera" / "RetroBIOS.sh",
PORTS / "muos" / "mux_launch.sh",
PORTS / "tools" / "RetroBIOS.sh",
]
# What each button does in the interface (wiki/installer.md, the full-screen
# interface): the convergent menu mapping of handheld_runtime.md.
ROLES = {
"a": "enter", "start": "enter", "b": "esc", "back": "esc", "x": "space",
"y": "tab", "l1": "pageup", "r1": "pagedown",
"up": "up", "down": "down", "left": "left", "right": "right",
}
# What a Linux console or a terminal sends for each key gptokeyb and evmapy
# emit, and the key the interface reads from it.
SEQUENCES = {
"enter": "\r", "esc": "\x1b", "space": " ", "tab": "\t",
"pageup": "\x1b[5~", "pagedown": "\x1b[6~",
"up": "\x1b[A", "down": "\x1b[B", "left": "\x1b[D", "right": "\x1b[C",
}
INTERFACE_KEYS = {
"enter": "enter", "esc": "esc", "space": "char", "tab": "tab",
"pageup": "pgup", "pagedown": "pgdn",
"up": "up", "down": "down", "left": "left", "right": "right",
}
# gptokeyb's char_to_keycode (PortsMaster/gptokeyb src/util.cpp) names.
GPTOKEYB_NAMES = set(SEQUENCES) | {"backspace", '\\"'}
def _tempdir(test: unittest.TestCase) -> Path:
scratch = REPO_ROOT / "tmp"
scratch.mkdir(exist_ok=True)
path = Path(tempfile.mkdtemp(dir=str(scratch)))
test.addCleanup(shutil.rmtree, path, True)
return path
def read_gptk(path: Path) -> dict[str, list[str]]:
"""key -> values, as gptokeyb's parseConfigFile reads `key = value`."""
mapping: dict[str, list[str]] = {}
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or line.startswith("["):
continue
key, sep, value = line.partition(" = ")
assert sep, line
mapping.setdefault(key, []).append(value.split("#")[0].strip())
return mapping
class EveryScriptParses(unittest.TestCase):
def test_bash_n(self):
for script in SCRIPTS:
with self.subTest(script=str(script.relative_to(REPO_ROOT))):
proc = subprocess.run(["bash", "-n", str(script)], capture_output=True,
text=True, check=False)
self.assertEqual(proc.returncode, 0, proc.stderr)
def test_shellcheck(self):
"""CI provides shellcheck (validate.yml checks it), so there it may
not skip."""
if shutil.which("shellcheck") is None:
if os.environ.get("CI"):
self.fail("CI must provide shellcheck for the handheld launchers")
self.skipTest("shellcheck is not installed")
proc = subprocess.run(
["shellcheck", "--shell=bash", "--severity=style", "--external-sources",
"--source-path=SCRIPTDIR", *map(str, SCRIPTS)],
capture_output=True, text=True, check=False)
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
def test_launchers_are_executable(self):
for script in (PORTS / "RetroBIOS" / "RetroBIOS.sh", PORTS / "batocera" / "RetroBIOS.sh",
PORTS / "muos" / "mux_launch.sh", PORTS / "tools" / "RetroBIOS.sh"):
with self.subTest(script=str(script.relative_to(REPO_ROOT))):
self.assertTrue(script.stat().st_mode & 0o111)
self.assertTrue(script.read_text().startswith("#!/bin/bash\n"))
def test_muos_headers(self):
"""muOS's frontend reads the HELP, ICON and GRID lines of an app
(MustardOS internal share/application/*/mux_launch.sh)."""
head = (PORTS / "muos" / "mux_launch.sh").read_text().splitlines()[1:4]
self.assertEqual([line.split(":")[0] for line in head], ["# HELP", "# ICON", "# GRID"])
self.assertTrue(all(line.split(":", 1)[1].strip() for line in head))
class ControllerMappings(unittest.TestCase):
def _decodes(self, key: str) -> str:
decoder = install.KeyDecoder()
keys = decoder.feed(SEQUENCES[key]) + decoder.flush()
self.assertEqual(len(keys), 1, key)
return keys[0].name
def test_gptk_maps_every_button_the_interface_reads(self):
mapping = read_gptk(PORTS / "shared" / "retrobios.gptk")
for button, key in ROLES.items():
with self.subTest(button=button):
self.assertEqual(mapping[button][0], key)
self.assertEqual(self._decodes(key), INTERFACE_KEYS[key])
for values in mapping.values():
self.assertIn(values[0], GPTOKEYB_NAMES | {"repeat"})
self.assertTrue(set(values[1:]) <= {"repeat"})
self.assertEqual(mapping["up"], ["up", "repeat"])
self.assertEqual(mapping["down"], ["down", "repeat"])
self.assertEqual(mapping["guide"], ['\\"'])
def test_evmapy_keys_map_the_same_buttons(self):
"""Batocera's evmapy names L1 and R1 pageup and pagedown, and Select
select (batocera-pygame evmapy.keys)."""
data = json.loads((PORTS / "batocera" / "RetroBIOS.sh.keys").read_text())
actions = {a["trigger"]: a for a in data["actions_player1"]}
triggers = {"a": "a", "start": "start", "b": "b", "back": "select", "x": "x",
"y": "y", "l1": "pageup", "r1": "pagedown", "up": "up",
"down": "down", "left": "left", "right": "right"}
for button, trigger in triggers.items():
with self.subTest(button=button):
action = actions[trigger]
self.assertEqual(action["type"], "key")
self.assertEqual(action["target"], [f"KEY_{ROLES[button].upper()}"])
for stick in ("up", "down", "left", "right"):
self.assertEqual(actions[f"joystick1{stick}"]["target"], [f"KEY_{stick.upper()}"])
self.assertEqual(len(actions), len(data["actions_player1"]))
def test_x_ticks_and_y_opens_the_options_on_every_route(self):
"""The interface reads Space as Tick and Tab as Options: a mapping
that sends Y as Space activates the focused row instead."""
self.assertEqual(self._decodes("space"), "char")
self.assertEqual(self._decodes("tab"), "tab")
gptk = read_gptk(PORTS / "shared" / "retrobios.gptk")
self.assertEqual((gptk["x"], gptk["y"]), (["space"], ["tab"]))
keys = json.loads((PORTS / "batocera" / "RetroBIOS.sh.keys").read_text())
targets = {a["trigger"]: a["target"] for a in keys["actions_player1"]}
self.assertEqual((targets["x"], targets["y"]), (["KEY_SPACE"], ["KEY_TAB"]))
# muxterm sends Y as Space on its own (MustardOS/terminal input.c):
# where gptokeyb runs beside it, muxterm is told to leave the pad
# (assert_muxterm_hides_every_pad).
for launcher in (PORTS / "muos" / "mux_launch.sh", PORTS / "RetroBIOS" / "RetroBIOS.sh"):
self.assertIn("rb_muxterm ", launcher.read_text())
self.assertFalse((PORTS / "muos" / "muxterm.gptk").exists())
def sdl_vidpid(bus: int, vendor: int, product: int, version: int, name: str) -> tuple[int, int]:
"""The vendor and product SDL 2 reads back from the GUID it builds for
a device: SDL_CreateJoystickGUID, then SDL_GetJoystickGUIDInfo
(libsdl-org/SDL release-2.30.x src/joystick/SDL_joystick.c). A vendor of
0 makes the GUID carry the name instead of the ids."""
data = bytearray(16)
struct.pack_into("<HH", data, 0, bus, 0x5a5a)
if vendor:
struct.pack_into("<HHHHH", data, 4, vendor, 0, product, 0, version)
else:
raw = name.encode()[:11]
data[4:4 + len(raw)] = raw
words = struct.unpack("<8H", bytes(data))
if (words[0] < 0x20 or words[0] == 0xFF) and words[3] == 0 and words[5] == 0:
return words[2], words[4]
return 0, 0
def sdl_vidpid_list(hint: str) -> list[int]:
"""SDL_LoadVIDPIDListFromHint: every pair of 0x numbers."""
entries = []
for vendor, product in re.findall(r"0x([0-9a-fA-F]+)[^x]*?0x([0-9a-fA-F]+)", hint):
entries.append((int(vendor, 16) & 0xFFFF) << 16 | (int(product, 16) & 0xFFFF))
return entries
# Input devices a muOS device lists in /proc/bus/input/devices: the H700
# pad before 2026-08-28 (gpio-keys, 0001:0001, MustardOS internal
# f6a14e3121 device/rg35xx-plus/control/gamecontrollerdb/modern.txt), the
# muinput pad after it (0x756d), a USB and a Bluetooth pad, gptokeyb2's
# uinput keyboard, and pads whose vendor is 0.
PADS = [
(0x19, 0x0001, 0x0001, 0x0100, "Deeplay-keys"),
(0x19, 0x0001, 0x0001, 0x0100, "muOS-Keys"),
(0x06, 0x756d, 0x6b6d, 0x0001, "muOS-Keys"),
(0x03, 0x045e, 0x028e, 0x0114, "Microsoft X-Box 360 pad"),
(0x05, 0x057e, 0x2009, 0x8111, "Pro Controller"),
(0x06, 0x0000, 0x0000, 0x0000, "Fake Keyboard"),
]
PADS += [(bus, 0, product, 1, "pad"[:length] + "x" * max(0, length - 3))
for bus in (0x03, 0x05, 0x06, 0x19, 0xFF) for product in (0, 1, 0x2009)
for length in range(13)]
PADS += [(bus, vendor, product, 1, "pad")
for bus in (0x03, 0x05, 0x19) for vendor in (1, 0x0079, 0xFFFF)
for product in (0, 1, 0xFFFF)]
def assert_muxterm_hides_every_pad(test: unittest.TestCase, hint: str) -> None:
"""muxterm reads the pad itself (T/input.c:303-317) while gptokeyb2
sends its keys, so every button would act twice. The launchers set
SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT, which hides from SDL every
device it does not name (SDL_gamecontroller.c:2145-2150, applied to the
joystick layer by SDL_ShouldIgnoreJoystick, SDL_joystick.c:3006)."""
allowed = sdl_vidpid_list(hint)
# An empty list allows every device.
test.assertTrue(allowed, hint)
for pad in PADS:
vendor, product = sdl_vidpid(*pad)
with test.subTest(pad=pad):
test.assertNotIn(vendor << 16 | product, allowed)
def assert_muxterm_mapping(test: unittest.TestCase, args: list[str], shared: Path) -> None:
"""muxterm keeps PgUp and PgDn for its scrollback and never writes them
to the terminal (T/input.c:352-360): in muxterm, L1 and R1 send nothing,
so they cannot scroll the interface away. Every other button is
retrobios.gptk's."""
test.assertEqual(args[-2], "-c")
mapping = read_gptk(Path(args[-1]))
expected = read_gptk(shared)
expected["l1"] = expected["r1"] = ['\\"']
test.assertEqual(mapping, expected)
class TheSdlModel(unittest.TestCase):
def test_it_reads_the_ids_as_sdl_does(self):
self.assertEqual(sdl_vidpid(0x19, 1, 1, 0x100, "Deeplay-keys"), (1, 1))
self.assertEqual(sdl_vidpid(0x05, 0, 0x2009, 1, "pad"), (0, 0))
self.assertEqual(sdl_vidpid(0x05, 0, 0x2009, 1, "ab"), (0x6261, 0))
self.assertEqual(sdl_vidpid_list("0x0001/0x0001, 0x045e/0x028e"),
[0x00010001, 0x045e028e])
def test_the_first_hint_allowed_the_h700_pad(self):
with self.assertRaises(AssertionError):
assert_muxterm_hides_every_pad(unittest.TestCase(), "0x0001/0x0001")
class TheArchives(unittest.TestCase):
LAYOUT = {
build_port.PORT_ZIP: ("RetroBIOS.sh", "retrobios"),
build_port.BATOCERA_ZIP: ("RetroBIOS.sh", "retrobios"),
build_port.MUOS_APP: (None, "RetroBIOS"),
build_port.TOOLS_ZIP: ("RetroBIOS.sh", "retrobios"),
}
def setUp(self):
self.out = _tempdir(self)
def test_every_release_archive_is_built(self):
self.assertEqual(set(build_port.ARCHIVES), set(self.LAYOUT))
def test_layout(self):
for name, (script, folder) in self.LAYOUT.items():
with self.subTest(archive=name):
with zipfile.ZipFile(build_port.build_archive(name, self.out / name)) as zf:
names = zf.namelist()
modes = {n: zf.getinfo(n).external_attr >> 16 for n in names}
self.assertEqual(names, sorted(names))
top = [n for n in names if "/" not in n]
self.assertEqual({n.split("/")[0] for n in names if "/" in n}, {folder})
for member in ("install.py", "tui.txt", "rbevents.py",
"licenses/LICENSE-retrobios.txt"):
self.assertIn(f"{folder}/{member}", names)
launcher = script or f"{folder}/mux_launch.sh"
self.assertTrue(modes[launcher] & 0o111, launcher)
if name == build_port.BATOCERA_ZIP:
self.assertEqual(top, ["RetroBIOS.sh", "RetroBIOS.sh.keys"])
self.assertNotIn("retrobios/retrobios.gptk", names)
elif script:
self.assertEqual(top, [script])
self.assertIn(f"{folder}/retrobios.gptk", names)
else:
self.assertEqual(top, [])
self.assertIn("RetroBIOS/retrobios.gptk", names)
def test_each_carries_the_pinned_installer(self):
pinned = build_port.pinned_installer_sha256()
for name, (_script, folder) in self.LAYOUT.items():
with self.subTest(archive=name):
with zipfile.ZipFile(build_port.build_archive(name, self.out / name)) as zf:
bundled = hashlib.sha256(zf.read(f"{folder}/install.py")).hexdigest()
self.assertEqual(bundled, pinned)
def test_reproducible(self):
for name in build_port.ARCHIVES:
with self.subTest(archive=name):
first = build_port.build_archive(name, self.out / f"a-{name}").read_bytes()
second = build_port.build_archive(name, self.out / f"b-{name}").read_bytes()
self.assertEqual(first, second)
def test_each_names_the_release_packs(self):
"""packs.txt names the platforms a release publishes a pack for, as
the registry marks them, and the pack's name for each."""
registry = yaml.safe_load((REPO_ROOT / "platforms" / "_registry.yml").read_text())
released = {name for name, entry in registry["platforms"].items()
if entry.get("release_pack") is True}
labels = build_port.release_pack_labels()
self.assertEqual(set(labels), released)
self.assertEqual(labels["retroarch"], "RetroArch")
expected = "".join(f"{p}\t{label}\n" for p, label in sorted(labels.items()))
for name, (_script, folder) in self.LAYOUT.items():
with self.subTest(archive=name):
with zipfile.ZipFile(build_port.build_archive(name, self.out / name)) as zf:
self.assertEqual(zf.read(f"{folder}/packs.txt").decode(), expected)
def test_an_unpinned_installer_is_refused(self):
root = _tempdir(self)
for name in ("install.py", "install.sh", "LICENSE", "NOTICE"):
shutil.copy(REPO_ROOT / name, root / name)
shutil.copytree(PORTS, root / "ports")
with (root / "install.py").open("a") as fh:
fh.write("\n")
for name in build_port.ARCHIVES:
with self.subTest(archive=name), self.assertRaises(ValueError):
build_port.build_archive(name, self.out / name, root)
def test_the_release_process_attaches_each(self):
text = (REPO_ROOT / "wiki" / "release-process.md").read_text(encoding="utf-8")
for name in build_port.ARCHIVES:
self.assertIn(name, text)
FAKE_INSTALL = r'''
import json, os, signal, subprocess, sys, time
calls = os.environ["FAKE_CALLS"]
state = os.environ["FAKE_STATE"]
def tty(fd):
try:
return os.ttyname(fd)
except OSError:
return ""
def note(name, text=""):
with open(os.path.join(state, name), "a") as fh:
fh.write(text + "\n")
if "--engine" in sys.argv:
# The engine the interface starts in its own session: it writes its
# summary on SIGINT, as install.py does, and records any other signal.
def summary(signum, _frame):
note("engine.log", "summary after SIGINT")
sys.exit(130)
def other(signum, _frame):
note("engine.log", f"signal {signum}")
sys.exit(128 + signum)
signal.signal(signal.SIGINT, summary)
signal.signal(signal.SIGTERM, other)
with open(os.path.join(state, "engine.pid"), "w") as fh:
fh.write(str(os.getpid()))
note("engine.ready")
time.sleep(60)
sys.exit(0)
with open(calls, "a") as fh:
fh.write(json.dumps({"argv0": sys.argv[0], "args": sys.argv[1:], "stdin": tty(0),
"stdout": tty(1), "term": os.environ.get("TERM", ""),
"pythonhome": os.environ.get("PYTHONHOME", ""),
"base_url": os.environ.get("RETROBIOS_BASE_URL", ""),
"offline": os.environ.get("RETROBIOS_INSTALL_OFFLINE", ""),
"idle_exit": os.environ.get("RETROBIOS_IDLE_EXIT"),
"pad": os.environ.get("RETROBIOS_PAD")}) + "\n")
if "--check" in sys.argv:
# install.py --check --events: detection is local, then the file list.
# With RETROBIOS_INSTALL_OFFLINE the file list stops the run at once
# with reason offline. Otherwise it is downloaded: a closed loopback
# port refuses at once, any other address is three tries of 30 s on a
# device without a network (FAKE_CURL, the probe that found it so).
offline = os.environ.get("RETROBIOS_INSTALL_OFFLINE", "") not in ("", "0")
base_url = os.environ.get("RETROBIOS_BASE_URL", "")
no_network = os.environ.get("FAKE_CURL", "0") in ("5", "6", "7", "28")
if os.environ.get("FAKE_DETECT_HANG"):
time.sleep(60)
print(json.dumps({"event": "start", "mode": "check"}), flush=True)
print(json.dumps({"event": "detect", "os": "linux", "os_name": "Fake OS",
"platforms": [{"platform": os.environ.get("FAKE_PLATFORM", "retroarch"),
"name": os.environ.get("FAKE_PLATFORM_NAME", "RetroArch"),
"bios_dir": "/roms/bios"}]}), flush=True)
if no_network and not offline and not base_url.startswith("http://127.0.0.1"):
time.sleep(90)
print(json.dumps({"event": "error", "stage": "manifest",
"reason": "offline" if offline else "network",
"platform": os.environ.get("FAKE_PLATFORM", "retroarch"),
"message": "this run makes no network request (--offline)" if offline
else "unreachable"}))
print(json.dumps({"event": "end", "code": 1}))
sys.exit(1)
if os.environ.get("FAKE_REFUSE"):
print("install.py: error: --tui cannot open the full-screen interface here: "
"the terminal is 30x10, smaller than 40x12", file=sys.stderr)
sys.exit(2)
if os.environ.get("FAKE_RAW") and os.isatty(0):
import tty as _tty
_tty.setraw(0)
if os.environ.get("FAKE_DRAIN") and os.isatty(0):
# What the console still holds when the interface starts: bytes a
# question left behind are read as the interface's first keys.
# TCSANOW, as install.py sets its mode: TCSAFLUSH would discard them.
import select as _select
import termios as _termios
import tty as _tty
_tty.setraw(0, _termios.TCSANOW)
left = b""
while _select.select([0], [], [], 0.3)[0]:
chunk = os.read(0, 64)
if not chunk:
break
left += chunk
note("ui.input", left.hex())
if os.environ.get("FAKE_HOLD"):
# The interface up and waiting for a key until a signal ends it. With
# FAKE_ENGINE an install runs, and SIGTERM gives it 2 s to say what it
# wrote, as install.py's run_tui does. FAKE_NO_HANDLER is a signal that
# comes before run_tui installs its handlers.
engine = None
if os.environ.get("FAKE_ENGINE"):
engine = subprocess.Popen([sys.executable, sys.argv[0], "--events", "--engine"],
start_new_session=True)
while not os.path.exists(os.path.join(state, "engine.ready")):
time.sleep(0.05)
def stop(signum, _frame):
note("ui.log", f"signal {signum}")
if engine is not None:
engine.send_signal(signal.SIGINT)
try:
engine.wait(2)
except subprocess.TimeoutExpired:
engine.kill()
print("RetroBIOS: the install was stopped, 2 files written.")
if os.environ.get("FAKE_TERM_ERROR"):
print("Error: " + os.environ["FAKE_TERM_ERROR"], file=sys.stderr)
sys.exit(128 + signum)
if os.environ.get("FAKE_IGNORE_TERM"):
# An interface stuck where its signal handler never runs.
signal.signal(signal.SIGTERM, signal.SIG_IGN)
elif not os.environ.get("FAKE_NO_HANDLER"):
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGHUP, stop)
with open(os.path.join(state, "ui.pid"), "w") as fh:
fh.write(str(os.getpid()))
note("ui.ready")
time.sleep(60)
sys.exit(0)
if os.environ.get("FAKE_IDLE_ERROR"):
# RETROBIOS_IDLE_EXIT on an error screen: the idle line, then the error.
print("No button pressed, closing.", file=sys.stderr)
print("Error: " + os.environ["FAKE_IDLE_ERROR"], file=sys.stderr)
if os.environ.get("FAKE_ERROR"):
print("Error: " + os.environ["FAKE_ERROR"], file=sys.stderr)
sys.exit(int(os.environ.get("FAKE_CODE", "0")))
'''
# The real pgrep and pkill: Device puts a pgrep on PATH that answers for the
# compositor check.
PGREP = shutil.which("pgrep", path="/usr/bin:/bin") or "pgrep"
PKILL = shutil.which("pkill", path="/usr/bin:/bin") or "pkill"
# gptokeyb as PortsMaster/gptokeyb runs it: kill mode takes the argument
# after the flag (`-1`, `-k` or `-sudokill`) as its target. Start + Select
# (FAKE_PRESS, once FAKE_PRESS_AT exists, by default once the interface is
# up) is doKillMode in src/util.cpp: pkill -f on the target, which also
# matches gptokeyb's own command line (SDL turns that SIGTERM into an event
# it does not read there), 3 s, then pkill -9 -f and exit. `-sudokill` runs
# both through sudo. Stopped otherwise by a SIGTERM, which it records.
# FAKE_MAPPER_DIES is a mapper that cannot start (no /dev/uinput).
FAKE_MAPPER = f'''#!/bin/bash
printf '%s\\n' "$@" > "$FAKE_STATE/mapper.args"
echo $$ > "$FAKE_STATE/mapper.pid"
printf '%s\\n' "${{SDL_GAMECONTROLLERCONFIG_FILE:-}}" > "$FAKE_STATE/mapper.db"
if [ -n "${{FAKE_MAPPER_DIES:-}}" ]; then
echo "gptokeyb: cannot open /dev/uinput" >&2
exit 1
fi
name="" sudo=""
while [ $# -gt 0 ]; do
case "$1" in
-1|-k) name="$2"; shift 2 ;;
-sudokill) name="$2" sudo="sudo"; shift 2 ;;
-c) shift 2 ;;
*) shift ;;
esac
done
killing=""
trap '[ -n "$killing" ] || {{ echo stopped > "$FAKE_STATE/mapper.stopped"; exit 0; }}' TERM
until [ -n "${{FAKE_PRESS:-}}" ] && [ -e "$FAKE_STATE/${{FAKE_PRESS_AT:-ui.ready}}" ]; do sleep 0.1; done
killing=1
echo "kill $name" >> "$FAKE_STATE/mapper.log"
$sudo {PKILL} -f "$name"
sleep 3
if {PGREP} -f "$name" > /dev/null; then $sudo {PKILL} -9 -f "$name"; fi
exit 0
'''
# gptokeyb2 as PortsMaster/gptokeyb2 src/util.c kills: SIGKILL only, by
# process name (pkill -9 NAME) unless -X, then pgrep -f and kill -9 on every
# match but its own ancestry and children. It quits after a kill, and keeps
# running when it has no process name. FAKE_MAPPER_STAYS is gptokeyb2
# before 2026-10-05, which keeps running after a -X kill (an inner
# `int status` shadows the result, 23581b62ec src/util.c:486-519).
FAKE_GPTOKEYB2 = f'''#!/bin/bash
printf '%s\\n' "$@" > "$FAKE_STATE/mapper.args"
echo $$ > "$FAKE_STATE/mapper.pid"
if [ -n "${{FAKE_MAPPER_DIES:-}}" ]; then
echo "gptokeyb2: cannot open /dev/uinput" >&2
exit 1
fi
want_kill="" name=""
while [ $# -gt 0 ]; do
case "$1" in
-X) want_kill=1; shift ;;
-c) shift 2 ;;
-*) shift ;;
*) [ -z "$name" ] && name="$1"; shift ;;
esac
done
trap 'echo stopped > "$FAKE_STATE/mapper.stopped"; exit 0' TERM
until [ -n "${{FAKE_PRESS:-}}" ] && [ -e "$FAKE_STATE/${{FAKE_PRESS_AT:-ui.ready}}" ]; do sleep 0.1; done
echo "kill $name" >> "$FAKE_STATE/mapper.log"
if [ -z "$name" ]; then
while :; do sleep 0.1; done
fi
if [ -n "$want_kill" ]; then
ours=" $$ "
p=$$
while [ "$p" -gt 1 ]; do
p=$(awk '{{print $4}}' "/proc/$p/stat" 2>/dev/null)
[ -n "$p" ] || break
ours="$ours$p "
done
for pid in $({PGREP} -f "$name"); do
case "$ours" in *" $pid "*) continue ;; esac
[ "$(awk '{{print $4}}' "/proc/$pid/stat" 2>/dev/null)" = "$$" ] && continue
kill -9 "$pid" 2>/dev/null
done
else
{PKILL} -9 "$name"
fi
if [ -n "${{FAKE_MAPPER_STAYS:-}}" ]; then
while :; do sleep 0.1; done
fi
exit 0
'''
# ROCKNIX's /usr/bin/sudo: a stub that runs nothing (ROCKNIX distribution
# packages/sysutils/busybox/scripts/sudo).
ROCKNIX_SUDO = """#!/bin/sh
printf '%s\\n' "sudo $*" >> "$FAKE_STATE/tools.log"
echo " You are 'root' so 'sudo' is not required or supported"
exit 1
"""
FAKE_RECORDER = """#!/bin/sh
printf '%s\\n' "$(basename "$0") $*" >> "$FAKE_STATE/tools.log"
"""
# chvt brings the console to the front, so RB_VT_ACTIVE then names it. A
# compositor that keeps the display (FAKE_CHVT_REFUSED) leaves chvt waiting
# in VT_WAITACTIVE and the console in front unchanged.
FAKE_CHVT = """#!/bin/sh
printf '%s\\n' "chvt $*" >> "$FAKE_STATE/tools.log"
[ -n "$FAKE_CHVT_REFUSED" ] && exec sleep 30
[ -n "$RB_VT_ACTIVE" ] && printf 'tty%s\\n' "$1" > "$RB_VT_ACTIVE"
exit 0
"""
FAKE_SETFONT = """#!/bin/sh
printf '%s\\n' "setfont $*" >> "$FAKE_STATE/tools.log"
while [ $# -gt 0 ]; do
[ "$1" = "-O" ] && { [ -n "$FAKE_NO_SAVE" ] && exit 1; printf 'font' > "$2"; }
shift
done
"""
# A terminal app: records how it was started and the SDL hint it was given,
# then runs its command. FAKE_TERM_FAILS is one that cannot open its window.
FAKE_TERMAPP = """#!/bin/sh
printf '%s\\n' "$(basename "$0") $*" >> "$FAKE_STATE/tools.log"
printf '%s\\n' "${SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT:-}" > "$FAKE_STATE/termapp.sdl"
printf '%s\\n' "${SDL_GAMECONTROLLER_IGNORE_DEVICES:-}" > "$FAKE_STATE/termapp.sdl_ignore"
echo $$ > "$FAKE_STATE/termapp.pid"
if [ -n "$FAKE_TERM_FAILS" ]; then
echo "$(basename "$0"): Can't open display" >&2
exit 1
fi
while [ $# -gt 0 ]; do
case "$1" in
--|-e) shift; break ;;
*) shift ;;
esac
done
exec "$@"
"""
# sudo with env_reset: the environment is dropped but for the variables
# --preserve-env names.
FAKE_SUDO = """#!/bin/bash
printf '%s\\n' "sudo $*" >> "$FAKE_STATE/tools.log"
keep=""
while [ $# -gt 0 ]; do
case "$1" in
--preserve-env=*) keep="${1#--preserve-env=}"; shift ;;
-*) shift ;;
*) break ;;
esac
done
case ",$keep," in
*,SDL_GAMECONTROLLERCONFIG_FILE,*) ;;
*) unset SDL_GAMECONTROLLERCONFIG_FILE ;;
esac
exec "$@"
"""
class Device:
"""A simulated handheld: fake tools on PATH and a pty as its console."""
def __init__(self, test: unittest.TestCase, **env: str):
self.test = test
self.root = _tempdir(test)
self.state = self.root / "state"
self.state.mkdir()
self.bin = self.root / "bin"
self.bin.mkdir()
master, slave = pty.openpty()
test.addCleanup(os.close, master)
test.addCleanup(os.close, slave)
termios.tcsetattr(slave, termios.TCSANOW, termios.tcgetattr(slave))
import fcntl # noqa: PLC0415
# Wide enough that no path in a message is folded.
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 50, 250, 0, 0))
self.master, self.slave = master, slave
self.console = os.ttyname(slave)
self.before = termios.tcgetattr(slave)
(self.bin / "python3").symlink_to(shutil.which("python3.8") or sys.executable)
self.tool("gptokeyb", FAKE_MAPPER)
self.tool("setfont", FAKE_SETFONT)
self.tool("chvt", FAKE_CHVT)
# A probe that cannot connect is offline (curl exit 7).
self.tool("curl", '#!/bin/sh\nexit "${FAKE_CURL:-0}"\n')
self.tool("pgrep", '#!/bin/sh\nexit "${FAKE_PGREP:-1}"\n')
# A runner's real sudo needs no password: the Tools entry then ran
# the fake mapper as root, with a reset environment, and recorded
# nothing. A device has no sudo unless a test gives it one.
self.tool("sudo", "#!/bin/sh\nexit 1\n")
fonts = self.root / "fonts"
fonts.mkdir()
for name in ("Lat7-TerminusBold32x16", "Lat7-TerminusBold22x11",
"Lat7-TerminusBold20x10", "Lat7-Terminus16"):
(fonts / f"{name}.psf.gz").write_bytes(b"")
(self.root / "fb").write_text("640,480\n")
environ = {k: v for k, v in os.environ.items()
if k not in ("DISPLAY", "WAYLAND_DISPLAY", "RETROBIOS_BASE_URL",
"RETROBIOS_INSTALL_OFFLINE", "RETROBIOS_IDLE_EXIT",
"RETROBIOS_PAD", "XAUTHORITY", "XDG_RUNTIME_DIR")}
self.env = {
**environ,
"PATH": f"{self.bin}{os.pathsep}{os.environ['PATH']}",
"HOME": str(self.root),
"XDG_RUNTIME_DIR": str(self.root / "runtime"),
"FAKE_STATE": str(self.state),
"FAKE_CALLS": str(self.state / "calls.jsonl"),
"RB_FONTS": str(fonts),
"RB_FB": str(self.root / "fb"),
"RB_MESSAGE_WAIT": "1",
"TERM": "dumb",
**env,
}
test.addCleanup(self._reap)
def tool(self, name: str, text: str) -> Path:
path = self.bin / name
# python3 starts as a link to the real interpreter: never write through it.
if path.is_symlink() or path.exists():
path.unlink()
path.write_text(text)
path.chmod(0o755)
return path
def unpack(self, archive: str, into: Path) -> Path:
"""The launcher archive extracted where the firmware keeps it, with
the fake installer in place of install.py."""
into.mkdir(parents=True, exist_ok=True)
built = build_port.build_archive(archive, self.root / "dist" / archive)
with zipfile.ZipFile(built) as zf:
zf.extractall(into)
for info in zf.infolist():
mode = info.external_attr >> 16
if mode:
(into / info.filename).chmod(mode & 0o777)
for found in into.rglob("install.py"):
found.write_text(FAKE_INSTALL)
return into
def run(self, argv: list[str], stdio_tty: bool = False, keys: str = "", **env: str):
"""Run a launcher with *keys* waiting on the console as buttons
pressed. Its console output is read from the pty."""
if keys:
os.write(self.master, keys.encode())
stdin = self.slave if stdio_tty else subprocess.DEVNULL
stdout = self.slave if stdio_tty else subprocess.PIPE
start = time.monotonic()
proc = subprocess.run(argv, env={**self.env, **env}, stdin=stdin, stdout=stdout,
stderr=subprocess.PIPE, timeout=120, check=False)
self.elapsed = time.monotonic() - start
self.screen = self._drain()
return proc
def spawn(self, argv: list[str], **env: str) -> subprocess.Popen:
proc = subprocess.Popen(argv, env={**self.env, **env}, stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
self.test.addCleanup(lambda: proc.poll() is None and proc.kill())
return proc
def wait_for(self, name: str, timeout: float = 20) -> None:
end = time.monotonic() + timeout
while not (self.state / name).exists():
self.test.assertLess(time.monotonic(), end, f"{name} never appeared")
time.sleep(0.05)
def _drain(self) -> str:
chunks = []
os.set_blocking(self.master, False)
while True:
try:
chunk = os.read(self.master, 65536)
except (BlockingIOError, OSError):
break
if not chunk:
break
chunks.append(chunk)
return b"".join(chunks).decode("utf-8", "replace")
def _reap(self) -> None:
"""A fake the launcher failed to stop must not outlive the test."""
for name in ("mapper.pid", "ui.pid", "engine.pid"):
path = self.state / name
if path.exists():
try:
os.kill(int(path.read_text()), signal.SIGKILL)
except (ProcessLookupError, ValueError):
pass
def calls(self) -> list[dict]:
path = self.state / "calls.jsonl"
if not path.exists():
return []
return [json.loads(line) for line in path.read_text().splitlines()]
def tui_calls(self) -> list[dict]:
return [c for c in self.calls() if "--tui" in c["args"]]
def lines(self, name: str) -> list[str]:
path = self.state / name
return path.read_text().splitlines() if path.exists() else []
def tools(self) -> list[str]:
return self.lines("tools.log")
def mapper_args(self) -> list[str]:
return self.lines("mapper.args")
def assert_mapper_stopped(self) -> None:
"""The launcher stopped gptokeyb itself, before it exited."""
pid = int((self.state / "mapper.pid").read_text())
stopped = (self.state / "mapper.stopped").exists()
if not stopped:
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
self.test.assertTrue(stopped, "gptokeyb was left running")
def assert_mapper_gone(self) -> None:
"""gptokeyb ended, by its own kill mode or by the launcher."""
pid = int((self.state / "mapper.pid").read_text())
end = time.monotonic() + 5
while Path(f"/proc/{pid}").exists() and time.monotonic() < end:
time.sleep(0.05)
self.test.assertFalse(Path(f"/proc/{pid}").exists(), "gptokeyb was left running")
def assert_console_restored(self) -> None:
self.test.assertEqual(termios.tcgetattr(self.slave), self.before)
def sentinels(token: str) -> list[int]:
"""The Start + Select sentinels of a run: `bash -c SCRIPT TOKEN`, where
the mapper carries the token after its own path."""
found = []
for entry in Path("/proc").iterdir():
if not entry.name.isdigit():
continue
try:
argv = (entry / "cmdline").read_bytes().split(b"\0")
except OSError:
continue
if len(argv) > 3 and argv[1] == b"-c" and argv[3] == token.encode():
found.append(int(entry.name))
return found
def wait_until(test: unittest.TestCase, check, timeout: float, what: str) -> None:
end = time.monotonic() + timeout
while not check():
test.assertLess(time.monotonic(), end, what)
time.sleep(0.1)
CONTROL_TXT = '''
directory="{roms}"
ESUDO=""
ESUDOKILL="-1"
CUR_TTY="{console}"
GPTOKEYB="$controlfolder/gptokeyb $ESUDOKILL"
get_controls() {{ sdl_controllerconfig="fake"; }}
'''
DIALOG_TXT = '''
PM_PID=""
PM_DONE="$FAKE_STATE/pm_done"
trace() { printf '%s\\n' "$*" >> "$FAKE_STATE/dialog.log"; }
PortMasterIPCheck() { echo 192.168.0.1; }
PortMasterDialogInit() { trace "INIT $*"; }
PortMasterDialogExit() { trace EXIT; }
PortMasterDialog() { trace "CMD $*"; }
PortMasterDialogResult() { trace "ASK $*"; echo quit; }
pm_finish() { trace FINISH; }
'''
class ThePortMasterPort(unittest.TestCase):
"""RetroBIOS.sh from the PortMaster archive, on a fake PortMaster."""
def device(self, console: str | None = None, **env: str) -> Device:
device = Device(self, **env)
roms = device.root / "roms"
self.gamedir = device.unpack(build_port.PORT_ZIP, roms / "ports") / "retrobios"
control = device.root / "xdg" / "PortMaster"
control.mkdir(parents=True)
self.control = control
(control / "control.txt").write_text(
CONTROL_TXT.format(roms=str(roms).lstrip("/"), console=console or device.console))
(control / "PortMasterDialog.txt").write_text(DIALOG_TXT)
shutil.copy(device.bin / "gptokeyb", control / "gptokeyb")
device.env["XDG_DATA_HOME"] = str(device.root / "xdg")
# No muxterm on this device unless a test puts one there.
device.env["RB_MUXTERM"] = str(device.root / "no-muxterm")
self.script = str(roms / "ports" / "RetroBIOS.sh")
return device
def dialog(self, device: Device) -> list[str]:
return device.lines("dialog.log")
def test_online_on_the_console(self):
device = self.device()
proc = device.run(["bash", self.script], FAKE_RAW="1", FAKE_CODE="0")
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
[call] = device.tui_calls()
self.assertEqual(call["args"], ["--tui"])
self.assertEqual((call["stdin"], call["stdout"]), (device.console, device.console))
self.assertEqual(call["term"], "linux")
installer = str(self.gamedir / "install.py")
self.assertEqual(call["argv0"], installer)
self.assertEqual(call["pad"], "gptokeyb")
self.assertEqual(call["idle_exit"], "300")
# Kill mode on the Start + Select sentinel, never on the installer's
# path, which the engine's command line carries too.
args = device.mapper_args()
self.assertEqual(args[0], "-1")
self.assertRegex(args[1], r"^retrobios-stop-\d+x$")
self.assertEqual(args[2:], ["-c", str(self.gamedir / "retrobios.gptk")])
device.assert_mapper_stopped()
self.assertEqual(sentinels(args[1]), [])
device.assert_console_restored()
# Reset when the console is taken and again when it is handed back.
self.assertEqual(device.screen.count("\x1bc"), 2)
self.assertLess(device.screen.index("Checking the connection"),
device.screen.rindex("\x1bc"))
saved = str(self.gamedir / "run" / "font.psf")
fonts = device.env["RB_FONTS"]
self.assertEqual(device.tools(), [
f"setfont -C {device.console} -O {saved} {fonts}/Lat7-TerminusBold22x11.psf.gz",
f"setfont -C {device.console} {saved}",
])
# PortMaster's own end of a port, after the interface, no screens.
self.assertEqual(self.dialog(device), ["FINISH"])
def test_the_interface_status_is_the_port_status(self):
device = self.device()
proc = device.run(["bash", self.script], FAKE_CODE="3", FAKE_ERROR="the folder is read-only")
self.assertEqual(proc.returncode, 3)
self.assertIn("Error: the folder is read-only", device.screen)
self.assertIn("log.txt", device.screen)
def test_offline_installs_from_a_pack_on_the_card(self):
device = self.device(FAKE_CURL="7")
roms = device.root / "roms"
(roms / "Batocera_43.1_BIOS_Pack.zip").write_bytes(b"")
pack = self.gamedir / "RetroArch_Lakka_v1.22.2_BIOS_Pack.zip"
pack.write_bytes(b"")
proc = device.run(["bash", self.script])
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
[call] = device.tui_calls()
self.assertEqual(call["args"], ["--tui", "--from-pack", str(pack),
"--platform", "retroarch"])
def test_offline_prefers_the_pack_of_this_system(self):
device = self.device(FAKE_CURL="6")
(self.gamedir / "Batocera_43.1_BIOS_Pack.zip").write_bytes(b"")
pack = device.root / "roms" / "RetroArch_Lakka_v1.22.2_BIOS_Pack.part1of2.zip"
pack.write_bytes(b"")
device.run(["bash", self.script])
[call] = device.tui_calls()
self.assertEqual(call["args"][1:3], ["--from-pack", str(pack)])
def test_offline_without_a_pack_says_where_to_put_one(self):
device = self.device(FAKE_CURL="7")
proc = device.run(["bash", self.script])
self.assertEqual(proc.returncode, 1)
self.assertEqual(device.tui_calls(), [])
screen = " ".join(device.screen.split())
self.assertIn("No network connection", screen)
self.assertIn("RetroArch", screen)
self.assertIn(str(self.gamedir), screen)
self.assertIn("github.com/Abdess/retrobios/releases/latest", screen)
device.assert_mapper_stopped()
device.assert_console_restored()
def test_offline_says_so_at_once_and_bounds_the_detection(self):
"""A detection run that hangs is stopped after RB_DETECT_LIMIT, with
a line on the console from the start."""
device = self.device(FAKE_CURL="28")
pack = self.gamedir / "RetroArch_Lakka_v1.22.2_BIOS_Pack.zip"
pack.write_bytes(b"")
proc = device.run(["bash", self.script], FAKE_DETECT_HANG="1", RB_DETECT_LIMIT="2")
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
self.assertLess(device.elapsed, 15)
screen = device.screen
self.assertIn("Checking the connection", screen)
self.assertIn("No connection. Looking for the BIOS folder", screen)
[call] = device.tui_calls()
# The detection was stopped: no platform, the first pack.
self.assertEqual(call["args"], ["--tui", "--from-pack", str(pack)])
log = (self.gamedir / "log.txt").read_text()
self.assertIn("the detection run took over 2 s and was stopped", log)
def test_the_detection_is_told_to_make_no_request(self):
"""RETROBIOS_INSTALL_OFFLINE: install.py detects, then stops where
the file list would download, at once and with reason offline
(install.py _network_allowed). The interface run does not inherit
it, and no address is borrowed for the detection."""
device = self.device(FAKE_CURL="7")
pack = self.gamedir / "RetroArch_Lakka_v1.22.2_BIOS_Pack.zip"
pack.write_bytes(b"")
proc = device.run(["bash", self.script])
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
self.assertLess(device.elapsed, 15)
[check] = [c for c in device.calls() if "--check" in c["args"]]
self.assertEqual(check["args"], ["--check", "--events"])
self.assertEqual((check["offline"], check["base_url"]), ("1", ""))
detected = (self.gamedir / "run" / "detect.jsonl").read_text().splitlines()
self.assertEqual([json.loads(line)["event"] for line in detected],
["start", "detect", "error", "end"])
self.assertEqual(json.loads(detected[2])["reason"], "offline")
[call] = device.tui_calls()
self.assertEqual(call["args"], ["--tui", "--from-pack", str(pack),
"--platform", "retroarch"])
self.assertEqual((call["offline"], call["base_url"]), ("", ""))
self.assertNotIn("took over", (self.gamedir / "log.txt").read_text())
def test_a_certificate_failure_is_not_offline(self):
# curl 60: the network answered, install.py reports the certificate.
device = self.device(FAKE_CURL="60")
device.run(["bash", self.script])
[call] = device.tui_calls()
self.assertEqual(call["args"], ["--tui"])
def test_an_old_python_is_told_on_the_console(self):
device = self.device()
device.tool("python3", '#!/bin/sh\n[ "$1" = "--version" ] && echo "Python 3.7.3"\nexit 1\n')
device.tool("uname", "#!/bin/sh\necho armv7l\n")
proc = device.run(["bash", self.script])
self.assertEqual(proc.returncode, 1)
screen = " ".join(device.screen.split())
self.assertIn("Python 3.8 or newer is needed", screen)
self.assertIn("3.7.3", screen)
self.assertIn("--dest", screen)
self.assertEqual(device.calls(), [])
def test_the_runtime_is_mounted_then_unmounted(self):
"""ArkOS's Python 3.7: PortMaster's python_3.11 runtime, mounted for
the run and unmounted after it."""
device = self.device()
real = shutil.which("python3.8") or sys.executable
device.tool("python3", '#!/bin/sh\n[ "$1" = "--version" ] && echo "Python 3.7.3"\nexit 1\n')
device.tool("uname", "#!/bin/sh\necho aarch64\n")
# The runtime's python, a stand-in that ignores the PYTHONHOME the
# launcher sets for the real one.
device.tool("mount", '#!/bin/sh\nprintf "mount %s\\n" "$*" >> "$FAKE_STATE/tools.log"\n'
'mkdir -p "$2/bin"\n'
f'printf \'#!/bin/sh\\nunset PYTHONHOME\\nexec {real} "$@"\\n\' > "$2/bin/python3"\n'
'chmod +x "$2/bin/python3"\n')
device.tool("umount", FAKE_RECORDER)
(self.control / "libs").mkdir()
squashfs = self.control / "libs" / "python_3.11.squashfs"
squashfs.write_bytes(b"")
proc = device.run(["bash", self.script])
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
self.assertEqual(len(device.tui_calls()), 1)
runtime = str(device.root / "python_3.11")
mounts = [t for t in device.tools() if t.startswith(("mount", "umount"))]
self.assertEqual(mounts, [f"umount {runtime}", f"mount {squashfs} {runtime}",
f"umount {runtime}"])
def test_a_refused_console_falls_back_to_portmaster_screens(self):
device = self.device()
device.run(["bash", self.script], FAKE_REFUSE="1")
self.assertEqual(len(device.tui_calls()), 1)
dialog = self.dialog(device)
self.assertEqual(dialog[0], "INIT no-check")
self.assertIn("ASK selection_list --register=notice --want-description --want-cancel",
dialog)
device.assert_mapper_stopped()
device.assert_console_restored()
def test_a_console_this_user_cannot_open_is_opened_up(self):
"""PortMaster's control.txt chmods the consoles only under sudo: the
launcher does it for the one it draws on."""
device = self.device()
os.chmod(device.console, 0o000)
self.addCleanup(os.chmod, device.console, 0o620)
proc = device.run(["bash", self.script])
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
[call] = device.tui_calls()
self.assertEqual(call["stdin"], device.console)
self.assertEqual(os.stat(device.console).st_mode & 0o777, 0o666)
def test_under_a_compositor_without_a_terminal_app_the_screens_open(self):
device = self.device(FAKE_PGREP="0")
device.run(["bash", self.script])
self.assertEqual(device.tui_calls(), [])
self.assertEqual(self.dialog(device)[0], "INIT no-check")
self.assertEqual(device.mapper_args(), [])
def test_arkos_offers_the_card_portmaster_does_not_run_from(self):
device = self.device(FAKE_CURL="7")
marker = device.root / "text.plymouth"
marker.write_text("")
roms = device.root / "roms" / "bios"
other = device.root / "roms2" / "bios"
roms.mkdir(parents=True)
other.mkdir(parents=True)
(roms / "a.bin").write_bytes(b"x")
(other / "b.bin").write_bytes(b"y")
pack = self.gamedir / "RetroArch_Lakka_v1.22.2_BIOS_Pack.zip"
pack.write_bytes(b"")
device.run(["bash", self.script], keys="\r", RB_ARK_MARKER=str(marker),
RB_OTHER_CARDS=f"{roms}:{other}",
RB_RETROARCH_CFG=str(device.root / "none.cfg"))
[call] = device.tui_calls()
# The port's games card is roms here: its bios is the destination.
self.assertEqual(call["args"][:3], ["--tui", "--from", str(other)])
def test_arkos_destination_is_the_folder_retroarch_reads(self):
"""control.txt names roms2 as the port's card whenever a second card
is mounted: RetroArch's system_directory decides."""
device = self.device(FAKE_CURL="7")
marker = device.root / "text.plymouth"
marker.write_text("")
roms = device.root / "roms" / "bios"
other = device.root / "roms2" / "bios"
roms.mkdir(parents=True)
other.mkdir(parents=True)
(roms / "a.bin").write_bytes(b"x")
(other / "b.bin").write_bytes(b"y")
cfg = device.root / "retroarch.cfg"
cfg.write_text(f'system_directory = "{other}"\n')
(self.gamedir / "RetroArch_Lakka_v1.22.2_BIOS_Pack.zip").write_bytes(b"")
device.run(["bash", self.script], keys="\r", RB_ARK_MARKER=str(marker),
RB_OTHER_CARDS=f"{roms}:{other}", RB_RETROARCH_CFG=str(cfg))
[call] = device.tui_calls()
self.assertEqual(call["args"][:3], ["--tui", "--from", str(roms)])
def test_under_a_compositor_foot_shows_the_interface(self):
device = self.device(WAYLAND_DISPLAY="wayland-1")
device.tool("foot", FAKE_TERMAPP)
proc = device.run(["bash", self.script], FAKE_CODE="4")
self.assertEqual(proc.returncode, 4)
[call] = device.tui_calls()
self.assertEqual(call["args"], ["--tui"])
self.assertEqual(device.tools(), [f"foot -F -- bash {self.gamedir}/tui.txt here"])
self.assertRegex(device.mapper_args()[1], r"^retrobios-stop-\d+x$")
device.assert_mapper_stopped()
def test_a_terminal_app_that_cannot_open_hands_over(self):
"""foot refused the display: the interface never ran, and the port
opens PortMaster's screens instead of ending on a status of 1."""
device = self.device(WAYLAND_DISPLAY="wayland-1")
device.tool("foot", FAKE_TERMAPP)
proc = device.run(["bash", self.script], FAKE_TERM_FAILS="1")
self.assertEqual(device.tui_calls(), [])
self.assertEqual(self.dialog(device)[0], "INIT no-check", proc.stderr.decode())
log = (self.gamedir / "log.txt").read_text()
self.assertIn("foot ended with status 1 before the interface ran", log)
device.assert_mapper_stopped()
def test_on_muos_the_interface_opens_in_muxterm(self):
"""mod_muOS.txt sets CUR_TTY to /dev/null: muxterm shows the
interface, told to leave the pad to gptokeyb."""
device = self.device(console="/dev/null")
muxterm = device.tool("muxterm", FAKE_TERMAPP)
proc = device.run(["bash", self.script], RB_MUXTERM=str(muxterm))
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
[call] = device.tui_calls()
self.assertEqual(call["args"], ["--tui"])
self.assertEqual(device.tools(), [f"muxterm --gl -- bash {self.gamedir}/tui.txt here"])
[hint] = device.lines("termapp.sdl")
assert_muxterm_hides_every_pad(self, hint)
assert_muxterm_mapping(self, device.mapper_args(), self.gamedir / "retrobios.gptk")
self.assertEqual(call["pad"], "gptokeyb_muxterm")
self.assertEqual(call["idle_exit"], "300")
self.assertEqual(self.dialog(device), ["FINISH"])
def test_on_muos_without_muxterm_the_log_says_why_the_screens_open(self):
device = self.device(console="/dev/null")
device.run(["bash", self.script])
self.assertEqual(device.tui_calls(), [])
self.assertEqual(self.dialog(device)[0], "INIT no-check")
log = (self.gamedir / "log.txt").read_text()
self.assertIn("/dev/null is not a console", log)
def test_start_select_lets_the_install_say_what_it_wrote(self):
"""gptokeyb's kill reaches the sentinel only: the interface is asked
to stop, gives the running install its SIGINT and 2 s, and writes
its result. The engine never sees gptokeyb's SIGTERM or SIGKILL."""
device = self.device()
proc = device.run(["bash", self.script], FAKE_HOLD="1", FAKE_ENGINE="1",
FAKE_PRESS="1", RB_MESSAGE_WAIT="300")
self.assertEqual(proc.returncode, 143, proc.stderr.decode())
self.assertLess(device.elapsed, 15)
self.assertEqual(device.lines("ui.log"), ["signal 15"])
self.assertEqual(device.lines("engine.log"), ["summary after SIGINT"])
self.assertIn("RetroBIOS: the install was stopped, 2 files written.", device.screen)
self.assertNotIn("Press A", device.screen)
device.assert_mapper_gone()
device.assert_console_restored()
self.assertEqual(self.dialog(device), ["FINISH"])
def test_start_select_on_an_error_screen_returns_at_once(self):
"""The interface leaves an error screen with its farewell on stderr,
and gptokeyb is gone: no message may wait for a button."""
device = self.device()
proc = device.run(["bash", self.script], FAKE_HOLD="1", FAKE_PRESS="1",
FAKE_TERM_ERROR="could not reach the file list",
RB_MESSAGE_WAIT="300")
self.assertEqual(proc.returncode, 143)
self.assertLess(device.elapsed, 10)
self.assertNotIn("Press A", device.screen)
device.assert_mapper_gone()
device.assert_console_restored()
def test_an_idle_close_on_an_error_names_the_error_at_once(self):
"""The interface closed for want of a button: the mapper never
opened the pad, so no A can close a message. The error it closed is
named, for RB_DEAD_WAIT seconds, not the idle line for 300."""
device = self.device()
proc = device.run(["bash", self.script], FAKE_IDLE_ERROR="could not reach the file list",
FAKE_CODE="1", RB_MESSAGE_WAIT="300", RB_DEAD_WAIT="1")
self.assertEqual(proc.returncode, 1)
self.assertLess(device.elapsed, 10)
self.assertIn("could not reach the file list", device.screen)
self.assertNotIn("No button pressed", device.screen)
device.assert_console_restored()
def test_start_select_before_the_interface_handles_signals(self):
"""A SIGTERM before run_tui installs its handlers ends Python on the
default action, status 143."""
device = self.device()
proc = device.run(["bash", self.script], FAKE_HOLD="1", FAKE_PRESS="1",
FAKE_NO_HANDLER="1", RB_MESSAGE_WAIT="300")
self.assertEqual(proc.returncode, 143)
self.assertLess(device.elapsed, 10)
self.assertNotIn("Press A", device.screen)
device.assert_console_restored()
def test_a_mapper_that_dies_at_once_starts_no_interface(self):
device = self.device()
proc = device.run(["bash", self.script], FAKE_MAPPER_DIES="1", RB_MESSAGE_WAIT="300",
RB_DEAD_WAIT="1")
self.assertEqual(proc.returncode, 1)
self.assertLess(device.elapsed, 10)
self.assertEqual(device.tui_calls(), [])
screen = " ".join(device.screen.split())
self.assertIn("The controller cannot be read", screen)
self.assertIn("log.txt", screen)
log = (self.gamedir / "log.txt").read_text()
self.assertIn("cannot open /dev/uinput", log)
device.assert_console_restored()
def test_a_mapper_that_dies_later_ends_the_interface(self):
"""Nothing moves the interface once gptokeyb is gone: it is asked to
stop instead of waiting for a key forever."""
device = self.device()
proc = device.spawn(["bash", self.script], FAKE_HOLD="1", RB_DEAD_WAIT="1",
RB_MESSAGE_WAIT="300")
device.wait_for("ui.ready")
os.kill(int((device.state / "mapper.pid").read_text()), signal.SIGKILL)
self.assertEqual(proc.wait(timeout=20), 143)
self.assertEqual(device.lines("ui.log"), ["signal 15"])
# The interface disappears: the console says why, for RB_DEAD_WAIT.
screen = " ".join(device._drain().split())
self.assertIn("gptokeyb, which turns the buttons into keys, stopped during the run",
screen)
device.assert_console_restored()
def test_start_select_with_an_interface_that_ignores_sigterm(self):
"""An interface that does not stop within RB_STOP_GRACE is killed,
and the engine it started in its own session with it."""
device = self.device()
proc = device.run(["bash", self.script], FAKE_HOLD="1", FAKE_ENGINE="1",
FAKE_IGNORE_TERM="1", FAKE_PRESS="1", RB_STOP_GRACE="1",
RB_MESSAGE_WAIT="300")
self.assertEqual(proc.returncode, 137, proc.stderr.decode())
self.assertLess(device.elapsed, 15)
engine = int((device.state / "engine.pid").read_text())
wait_until(self, lambda: not Path(f"/proc/{engine}").exists(), 5,
"the engine outlived the interface")
log = (self.gamedir / "log.txt").read_text()
self.assertIn("the interface did not stop within 1 s", log)
device.assert_console_restored()
def test_an_interface_that_ignores_sigterm_without_pkill(self):
"""With no working pkill, the engines cannot be found: the interface
itself is still killed after RB_STOP_GRACE."""
device = self.device()
device.tool("pkill", "#!/bin/sh\necho 'pkill: applet not found' >&2\nexit 127\n")
proc = device.run(["bash", self.script], FAKE_HOLD="1", FAKE_IGNORE_TERM="1",
FAKE_PRESS="1", RB_STOP_GRACE="1", RB_MESSAGE_WAIT="300")
self.assertEqual(proc.returncode, 137, proc.stderr.decode())
self.assertLess(device.elapsed, 15)
ui = int((device.state / "ui.pid").read_text())
self.assertFalse(Path(f"/proc/{ui}").exists())
def test_the_sentinel_ends_when_the_launcher_is_killed(self):
"""SIGKILL leaves the launcher no trap: the sentinel ends by itself
once the launcher is gone."""
device = self.device()
proc = device.spawn(["bash", self.script], FAKE_HOLD="1")
device.wait_for("ui.ready")
token = device.mapper_args()[1]
self.assertEqual(len(sentinels(token)), 1)
proc.kill()
proc.wait(timeout=5)
wait_until(self, lambda: not sentinels(token), 10, "the sentinel outlived the launcher")
def test_a_closed_terminal_ends_the_run(self):
"""The terminal app closed (muxterm's Menu, foot's window): the run
inside it stops the interface and ends the run, and no other route
or screen opens after it."""
device = self.device(WAYLAND_DISPLAY="wayland-1")
device.tool("foot", FAKE_TERMAPP)
proc = device.spawn(["bash", self.script], FAKE_HOLD="1")
device.wait_for("ui.ready")
# FAKE_TERMAPP execs its command: its pid is the run inside it.
os.kill(int((device.state / "termapp.pid").read_text()), signal.SIGHUP)
self.assertEqual(proc.wait(timeout=20), 143)
self.assertEqual(device.lines("ui.log"), ["signal 15"])
self.assertEqual(self.dialog(device), ["FINISH"])
device.assert_mapper_stopped()
def test_sigterm_to_the_launcher_stops_the_interface_and_restores(self):
"""A firmware that signals the launcher alone: the trap stops the
interface, the mapper and puts the console back."""
device = self.device()
proc = device.spawn(["bash", self.script], FAKE_HOLD="1", FAKE_RAW="1")
device.wait_for("ui.ready")
proc.send_signal(signal.SIGTERM)
self.assertEqual(proc.wait(timeout=20), 143)
self.assertEqual(device.lines("ui.log"), ["signal 15"])
device.assert_mapper_stopped()
device.assert_console_restored()
def test_the_font_is_put_back_without_setfont_o(self):
"""busybox setfont has no -O: the font is changed only when the
Lat7-Terminus20x10 ArkOS tools restore exists, and that one is put
back."""
device = self.device(FAKE_NO_SAVE="1")
fonts = device.env["RB_FONTS"]
(Path(fonts) / "Lat7-Terminus20x10.psf.gz").write_bytes(b"")
device.run(["bash", self.script])
chosen = f"{fonts}/Lat7-TerminusBold22x11.psf.gz"
saved = str(self.gamedir / "run" / "font.psf")
self.assertEqual(device.tools(), [
f"setfont -C {device.console} -O {saved} {chosen}",
f"setfont -C {device.console} {chosen}",
f"setfont -C {device.console} {fonts}/Lat7-Terminus20x10.psf.gz",
])
def test_without_the_fallback_font_the_font_is_left(self):
device = self.device(FAKE_NO_SAVE="1")
device.run(["bash", self.script])
self.assertEqual(len([t for t in device.tools() if t.startswith("setfont")]), 1)
def test_a_read_only_card_runs_from_the_runtime_folder(self):
device = self.device()
self.gamedir.chmod(0o555)
self.addCleanup(self.gamedir.chmod, 0o755)
proc = device.run(["bash", self.script])
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
self.assertEqual(len(device.tui_calls()), 1)
spare = device.root / "runtime" / "retrobios"
log = (spare / "log.txt").read_text()
self.assertIn(f"{self.gamedir} cannot be written", log)
self.assertTrue((spare / "run" / "status").exists())
def test_nothing_writable_is_said_on_the_console(self):
device = self.device()
self.gamedir.chmod(0o555)
self.addCleanup(self.gamedir.chmod, 0o755)
runtime = device.root / "runtime"
runtime.mkdir(mode=0o555)
self.addCleanup(runtime.chmod, 0o755)
proc = device.run(["bash", self.script], RB_DEAD_WAIT="2")
self.assertEqual(proc.returncode, 1)
self.assertEqual(device.calls(), [])
self.assertIn("the SD card may be read-only or full", " ".join(device.screen.split()))
# Left on the console long enough to read before the menu redraws.
self.assertGreaterEqual(device.elapsed, 2)
def test_the_previous_log_is_kept(self):
device = self.device()
(self.gamedir / "log.txt").write_text("the failure\n")
device.run(["bash", self.script])
self.assertEqual((self.gamedir / "log.prev.txt").read_text(), "the failure\n")
self.assertNotIn("the failure", (self.gamedir / "log.txt").read_text())
class TheBatoceraPort(unittest.TestCase):
def device(self, **env: str) -> tuple[Device, Path]:
device = Device(self, **env)
ports = device.unpack(build_port.BATOCERA_ZIP, device.root / "userdata" / "roms" / "ports")
return device, ports
def console_switch(self, device: Device) -> dict[str, str]:
(device.root / "active").write_text("tty2\n")
number = re.fullmatch(r"/dev/pts/(\d+)", device.console)
self.assertIsNotNone(number)
self.number = number.group(1)
return {"RB_VT_ACTIVE": str(device.root / "active"), "RB_VT_PREFIX": "/dev/pts/",
"RB_VT_SWITCH": self.number}
def test_xterm_in_the_x11_session(self):
device, ports = self.device(DISPLAY=":0")
device.tool("xterm", FAKE_TERMAPP)
proc = device.run(["/bin/bash", str(ports / "RetroBIOS.sh")])
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
[call] = device.tui_calls()
self.assertEqual(call["args"], ["--tui"])
self.assertEqual(device.tools(),
[f"xterm -fullscreen -e bash {ports}/retrobios/tui.txt here"])
# evmapy reads RetroBIOS.sh.keys: no gptokeyb is started.
self.assertEqual(device.mapper_args(), [])
self.assertEqual(call["pad"], "evmapy")
self.assertEqual(call["idle_exit"], "300")
def test_the_x_cookie_goes_with_the_display(self):
"""S31emulationstation starts X with XAUTHORITY=/var/lib/.Xauthority:
without it xterm is refused the display."""
device, ports = self.device()
device.tool("xterm", FAKE_TERMAPP + "")
socket_dir = device.root / "x11"
socket_dir.mkdir()
import socket as _socket # noqa: PLC0415
sock = _socket.socket(_socket.AF_UNIX)
sock.bind(str(socket_dir / "X0"))
self.addCleanup(sock.close)
cookie = device.root / "Xauthority"
cookie.write_text("")
device.tool("xterm", '#!/bin/sh\nprintf "%s %s\\n" "$DISPLAY" "$XAUTHORITY" '
'> "$FAKE_STATE/x.env"\n' + FAKE_TERMAPP.split("\n", 1)[1])
proc = device.run(["/bin/bash", str(ports / "RetroBIOS.sh")],
RB_X_SOCKET=str(socket_dir / "X0"), RB_XAUTH=str(cookie))
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
self.assertEqual(device.lines("x.env"), [f":0 {cookie}"])
def test_console_switch_when_no_terminal_app(self):
device, ports = self.device(FAKE_PGREP="0")
proc = device.run(["/bin/bash", str(ports / "RetroBIOS.sh")], **self.console_switch(device))
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
[call] = device.tui_calls()
self.assertEqual(call["stdin"], device.console)
chvt = [line for line in device.tools() if line.startswith("chvt")]
self.assertEqual(chvt, [f"chvt {self.number}", "chvt 2"])
device.assert_console_restored()
def test_a_refused_console_switch_shows_nothing_on_an_unseen_console(self):
"""The compositor keeps the display: chvt waits and the console in
front stays the same. The launcher switches back and stops, instead
of an interface no key reaches."""
device, ports = self.device(FAKE_PGREP="0")
proc = device.run(["/bin/bash", str(ports / "RetroBIOS.sh")], FAKE_CHVT_REFUSED="1",
**self.console_switch(device))
self.assertEqual(proc.returncode, 125)
self.assertLess(device.elapsed, 20)
self.assertEqual(device.tui_calls(), [])
chvt = [line for line in device.tools() if line.startswith("chvt")]
self.assertEqual(chvt, [f"chvt {self.number}", "chvt 2"])
self.assertEqual((device.root / "active").read_text(), "tty2\n")
log = (ports / "retrobios" / "log.txt").read_text()
self.assertIn(f"console {self.number} did not come to the front", log)
self.assertNotIn("\x1bc", device.screen)
def test_xterm_that_cannot_open_hands_over_to_the_console(self):
device, ports = self.device(DISPLAY=":0", FAKE_PGREP="0")
device.tool("xterm", FAKE_TERMAPP)
proc = device.run(["/bin/bash", str(ports / "RetroBIOS.sh")], FAKE_TERM_FAILS="1",
**self.console_switch(device))
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
[call] = device.tui_calls()
self.assertEqual(call["stdin"], device.console)
self.assertTrue(device.tools()[0].startswith("xterm -fullscreen -e bash"))
class TheMuosApp(unittest.TestCase):
def device(self, muxterm: bool, gptokeyb2: bool = True) -> tuple[Device, Path]:
device = Device(self)
apps = device.unpack(build_port.MUOS_APP, device.root / "mmc" / "MUOS" / "application")
muos = device.root / "opt-muos"
(muos / "script" / "var").mkdir(parents=True)
(muos / "script" / "var" / "retro.sh").write_text(
'SETUP_APP() { printf "SETUP_APP %s\\n" "$1" >> "$FAKE_STATE/tools.log"; }\n'
'GET_VAR() { case "$2" in\n'
f' storage/rom/mount) echo "{device.root / "mmc"}" ;;\n'
f' storage/sdcard/mount) echo "{device.root / "sdcard"}" ;;\n'
' *) echo "" ;;\nesac; }\n')
if muxterm:
(muos / "frontend").mkdir()
shutil.copy(device.tool("muxterm", FAKE_TERMAPP), muos / "frontend" / "muxterm")
pm = device.root / "mmc" / "MUOS" / "PortMaster"
pm.mkdir(parents=True)
if gptokeyb2:
(pm / "gptokeyb2").write_text(FAKE_GPTOKEYB2)
(pm / "gptokeyb2").chmod(0o755)
device.env["RB_MUOS"] = str(muos)
return device, apps / "RetroBIOS"
def test_in_muxterm(self):
device, app = self.device(muxterm=True)
# muOS starts an app as <app>/mux_launch.sh <app>.
proc = device.run([str(app / "mux_launch.sh"), str(app)])
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
[call] = device.tui_calls()
self.assertEqual(call["args"], ["--tui"])
self.assertEqual(device.tools(), ["SETUP_APP retrobios",
f"muxterm --gl -- bash {app}/tui.txt here"])
# muxterm leaves the pad to gptokeyb2, which sends each button once
# with the interface's mapping: Y is Tab (Options), not muxterm's Space.
[hint] = device.lines("termapp.sdl")
assert_muxterm_hides_every_pad(self, hint)
args = device.mapper_args()
self.assertEqual(args[0], "-X")
self.assertRegex(args[1], r"^retrobios-stop-\d+x$")
assert_muxterm_mapping(self, args, app / "retrobios.gptk")
self.assertEqual(read_gptk(Path(args[-1]))["y"], ["tab"])
self.assertEqual(call["pad"], "gptokeyb_muxterm")
self.assertEqual(call["idle_exit"], "300")
device.assert_mapper_stopped()
def test_muxterm_alone_without_gptokeyb2(self):
device, app = self.device(muxterm=True, gptokeyb2=False)
proc = device.run([str(app / "mux_launch.sh"), str(app)])
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
[call] = device.tui_calls()
self.assertEqual(call["pad"], "muxterm")
self.assertEqual(call["idle_exit"], "300")
# muxterm then reads the pad itself.
self.assertEqual(device.lines("termapp.sdl"), [""])
self.assertEqual(device.mapper_args(), [])
def test_start_select_ends_the_run_in_muxterm(self):
"""gptokeyb2 kills with SIGKILL: -X reaches the sentinel, and the
interface is asked to stop as on the other routes."""
device, app = self.device(muxterm=True)
proc = device.run([str(app / "mux_launch.sh"), str(app)], FAKE_HOLD="1",
FAKE_PRESS="1", RB_MESSAGE_WAIT="300")
self.assertEqual(proc.returncode, 143, proc.stderr.decode())
self.assertLess(device.elapsed, 10)
self.assertEqual(device.lines("ui.log"), ["signal 15"])
device.assert_mapper_gone()
def test_start_select_ends_the_run_on_the_console(self):
device, app = self.device(muxterm=False)
proc = device.run([str(app / "mux_launch.sh"), str(app)], FAKE_HOLD="1",
FAKE_PRESS="1", RB_CONSOLE=device.console, RB_MESSAGE_WAIT="300")
self.assertEqual(proc.returncode, 143, proc.stderr.decode())
self.assertLess(device.elapsed, 10)
self.assertEqual(device.lines("ui.log"), ["signal 15"])
device.assert_console_restored()
def test_start_select_with_a_gptokeyb2_that_keeps_running(self):
"""gptokeyb2 before 2026-10-05 keeps running after its -X kill: only
the sentinel's end says Start + Select was pressed."""
device, app = self.device(muxterm=True)
proc = device.run([str(app / "mux_launch.sh"), str(app)], FAKE_HOLD="1",
FAKE_PRESS="1", FAKE_MAPPER_STAYS="1", RB_MESSAGE_WAIT="300")
self.assertEqual(proc.returncode, 143, proc.stderr.decode())
self.assertLess(device.elapsed, 10)
self.assertEqual(device.lines("ui.log"), ["signal 15"])
self.assertIn("Start + Select", (app / "log.txt").read_text())
device.assert_mapper_stopped()
def test_a_gptokeyb2_that_cannot_start_is_named(self):
device, app = self.device(muxterm=False)
proc = device.run([str(app / "mux_launch.sh"), str(app)], FAKE_MAPPER_DIES="1",
RB_CONSOLE=device.console, RB_DEAD_WAIT="1")
self.assertEqual(proc.returncode, 1)
self.assertEqual(device.tui_calls(), [])
screen = " ".join(device.screen.split())
self.assertIn("gptokeyb2, which turns the buttons into keys, stopped as soon as it "
"started", screen)
def test_an_arrow_at_the_question_leaves_nothing_behind(self):
"""An arrow is three bytes: the first answers the question, the rest
must not reach the interface as two more keys."""
device, app = self.device(muxterm=False)
_sd1, sd2, _pack = self.two_cards(device)
proc = device.run([str(app / "mux_launch.sh"), str(app)], keys="\x1b[B",
FAKE_CURL="7", FAKE_DRAIN="1", RB_CONSOLE=device.console,
RB_BIOS_DEST=str(sd2))
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
[call] = device.tui_calls()
self.assertNotIn("--from", call["args"])
self.assertEqual(device.lines("ui.input"), [""])
def test_start_select_at_the_question_starts_no_interface(self):
device, app = self.device(muxterm=False)
_sd1, sd2, _pack = self.two_cards(device)
proc = device.run([str(app / "mux_launch.sh"), str(app)], FAKE_CURL="7",
FAKE_PRESS="1", FAKE_PRESS_AT="calls.jsonl", RB_MESSAGE_WAIT="300",
RB_CONSOLE=device.console, RB_BIOS_DEST=str(sd2))
self.assertEqual(proc.returncode, 143, proc.stderr.decode())
self.assertLess(device.elapsed, 10)
self.assertEqual(device.tui_calls(), [])
# Never started: an interface started then stopped at once would
# end the same way, before Python records anything.
self.assertFalse((app / "run" / "tui.err").exists())
def test_on_the_console_without_muxterm(self):
device, app = self.device(muxterm=False)
proc = device.run([str(app / "mux_launch.sh"), str(app)], RB_CONSOLE=device.console)
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
[call] = device.tui_calls()
self.assertEqual(call["stdin"], device.console)
self.assertEqual(device.mapper_args()[3], str(app / "retrobios.gptk"))
device.assert_console_restored()
def test_offline_reads_a_pack_at_the_card_root(self):
device, app = self.device(muxterm=True)
pack = device.root / "mmc" / "RetroArch_Lakka_v1.22.2_BIOS_Pack.zip"
pack.write_bytes(b"")
device.run([str(app / "mux_launch.sh"), str(app)], FAKE_CURL="7")
[call] = device.tui_calls()
self.assertEqual(call["args"][:3], ["--tui", "--from-pack", str(pack)])
def test_offline_names_the_pack_that_serves_muos(self):
"""muOS runs RetroArch, and install.py reads it as retroarch: the
RetroArch pack serves it."""
device, app = self.device(muxterm=True)
proc = device.run([str(app / "mux_launch.sh"), str(app)], FAKE_CURL="7")
self.assertEqual(proc.returncode, 1)
# muxterm draws what its child writes: here the launcher's stdout.
screen = " ".join(proc.stdout.decode().split())
self.assertIn("download the RetroArch BIOS pack (a file whose name starts with "
"RetroArch_ and ends in _BIOS_Pack.zip)", screen)
def test_offline_takes_no_pack_named_for_another_system(self):
device, app = self.device(muxterm=True)
(device.root / "mmc" / "Batocera_43.1_BIOS_Pack.zip").write_bytes(b"")
proc = device.run([str(app / "mux_launch.sh"), str(app)], FAKE_CURL="7")
self.assertEqual(proc.returncode, 1)
self.assertEqual(device.tui_calls(), [])
def test_offline_reads_a_pack_on_the_second_card(self):
device, app = self.device(muxterm=True)
(device.root / "sdcard").mkdir()
pack = device.root / "sdcard" / "RetroArch_Lakka_v1.22.2_BIOS_Pack.zip"
pack.write_bytes(b"")
device.run([str(app / "mux_launch.sh"), str(app)], FAKE_CURL="7")
[call] = device.tui_calls()
self.assertEqual(call["args"][:3], ["--tui", "--from-pack", str(pack)])
def two_cards(self, device: Device) -> tuple[Path, Path, Path]:
"""SD1 and SD2 both hold MUOS/bios, SD2's is the one muOS binds."""
sd1 = device.root / "mmc" / "MUOS" / "bios"
sd2 = device.root / "sdcard" / "MUOS" / "bios"
sd1.mkdir(parents=True)
sd2.mkdir(parents=True)
(sd1 / "scph5501.bin").write_bytes(b"x")
(sd2 / "gba_bios.bin").write_bytes(b"y")
pack = device.root / "mmc" / "RetroArch_Lakka_v1.22.2_BIOS_Pack.zip"
pack.write_bytes(b"")
return sd1, sd2, pack
def test_offline_offers_the_other_card(self):
device, app = self.device(muxterm=False)
sd1, sd2, pack = self.two_cards(device)
proc = device.run([str(app / "mux_launch.sh"), str(app)], keys="\r", FAKE_CURL="7",
RB_CONSOLE=device.console, RB_BIOS_DEST=str(sd2))
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
self.assertIn("Copy the BIOS files from your other card?", device.screen)
self.assertIn(str(sd1), device.screen)
[call] = device.tui_calls()
self.assertEqual(call["args"], ["--tui", "--from", str(sd1), "--from-pack", str(pack),
"--platform", "retroarch"])
def test_b_declines_the_other_card(self):
device, app = self.device(muxterm=False)
_sd1, sd2, pack = self.two_cards(device)
device.run([str(app / "mux_launch.sh"), str(app)], keys="\x1b", FAKE_CURL="7",
RB_CONSOLE=device.console, RB_BIOS_DEST=str(sd2))
[call] = device.tui_calls()
self.assertEqual(call["args"], ["--tui", "--from-pack", str(pack),
"--platform", "retroarch"])
def test_the_card_the_system_reads_is_never_offered(self):
device, app = self.device(muxterm=False)
sd1, _sd2, _pack = self.two_cards(device)
shutil.rmtree(device.root / "sdcard")
device.run([str(app / "mux_launch.sh"), str(app)], FAKE_CURL="7",
RB_CONSOLE=device.console, RB_BIOS_DEST=str(sd1))
self.assertNotIn("other card", device.screen)
[call] = device.tui_calls()
self.assertNotIn("--from", call["args"])
def test_online_asks_nothing(self):
device, app = self.device(muxterm=False)
_sd1, sd2, _pack = self.two_cards(device)
device.run([str(app / "mux_launch.sh"), str(app)],
RB_CONSOLE=device.console, RB_BIOS_DEST=str(sd2))
self.assertNotIn("other card", device.screen)
[call] = device.tui_calls()
self.assertEqual(call["args"], ["--tui"])
class TheToolsEntry(unittest.TestCase):
def device(self, **env: str) -> tuple[Device, Path]:
device = Device(self, **env)
tools = device.unpack(build_port.TOOLS_ZIP, device.root / "tools")
device.env["RB_ROOT"] = str(device.root / "fs")
return device, tools
def arkos(self, device: Device) -> Path:
fs = device.root / "fs"
(fs / "opt" / "system").mkdir(parents=True)
(fs / "opt" / "inttools").mkdir(parents=True)
shutil.copy(device.bin / "gptokeyb", fs / "opt" / "inttools" / "gptokeyb")
return fs
def test_arkos_console_one(self):
device, tools = self.device()
self.arkos(device)
proc = device.run(["bash", str(tools / "RetroBIOS.sh")], RB_CONSOLE=device.console)
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
[call] = device.tui_calls()
self.assertEqual((call["stdin"], call["term"]), (device.console, "linux"))
self.assertEqual(call["pad"], "gptokeyb")
self.assertEqual(call["idle_exit"], "300")
args = device.mapper_args()
self.assertEqual(args[0], "-1")
self.assertRegex(args[1], r"^retrobios-stop-\d+x$")
self.assertEqual(args[2:], ["-c", str(tools / "retrobios" / "retrobios.gptk")])
device.assert_mapper_stopped()
device.assert_console_restored()
def test_the_previous_log_is_kept(self):
device, tools = self.device()
self.arkos(device)
log = tools / "retrobios" / "log.txt"
log.write_text("the failure\n")
device.run(["bash", str(tools / "RetroBIOS.sh")], RB_CONSOLE=device.console)
self.assertEqual((tools / "retrobios" / "log.prev.txt").read_text(), "the failure\n")
self.assertNotIn("the failure", log.read_text())
def test_arkos_sudo_keeps_the_controller_list(self):
"""sudo's env_reset would drop SDL_GAMECONTROLLERCONFIG_FILE, and a
pad SDL does not know sends nothing."""
device, tools = self.device()
fs = self.arkos(device)
db = fs / "opt" / "inttools" / "gamecontrollerdb.txt"
db.write_text("")
device.tool("sudo", FAKE_SUDO)
proc = device.run(["bash", str(tools / "RetroBIOS.sh")], RB_CONSOLE=device.console)
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
self.assertEqual(device.lines("mapper.db"), [str(db)])
started = [t for t in device.tools() if "gptokeyb" in t]
self.assertTrue(started[0].startswith(
"sudo --preserve-env=SDL_GAMECONTROLLERCONFIG_FILE "), started)
def test_start_select_on_the_console(self):
device, tools = self.device()
self.arkos(device)
proc = device.run(["bash", str(tools / "RetroBIOS.sh")], RB_CONSOLE=device.console,
FAKE_HOLD="1", FAKE_PRESS="1", RB_MESSAGE_WAIT="300")
self.assertEqual(proc.returncode, 143, proc.stderr.decode())
self.assertLess(device.elapsed, 10)
self.assertEqual(device.lines("ui.log"), ["signal 15"])
self.assertNotIn("Press A", device.screen)
device.assert_mapper_gone()
device.assert_console_restored()
def test_arkos_other_card_without_a_pack_needs_one(self):
"""--from needs the list of files, which an offline run takes from a
pack: the message says so."""
device, tools = self.device(FAKE_CURL="7")
fs = self.arkos(device)
roms, roms2 = device.root / "roms" / "bios", device.root / "roms2" / "bios"
roms.mkdir(parents=True)
roms2.mkdir(parents=True)
(roms / "bios.bin").write_bytes(b"x")
cfg = fs / "home" / "ark" / ".config" / "retroarch"
cfg.mkdir(parents=True)
(cfg / "retroarch.cfg").write_text(f'system_directory = "{roms2}"\n')
proc = device.run(["bash", str(tools / "RetroBIOS.sh")], keys="\r",
RB_CONSOLE=device.console, RB_OTHER_CARDS=f"{roms}:{roms2}")
self.assertEqual(proc.returncode, 1)
screen = " ".join(device.screen.split())
self.assertIn(f"Copy the BIOS files from your other card? {roms} holds files", screen)
self.assertIn("with it, the files on your other card are copied too", screen)
self.assertEqual(device.tui_calls(), [])
def test_amberelec_console(self):
device, tools = self.device()
etc = device.root / "fs" / "etc"
etc.mkdir(parents=True)
(etc / "os-release").write_text('NAME="AmberELEC"\n')
proc = device.run(["bash", str(tools / "RetroBIOS.sh")], RB_CONSOLE=device.console)
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
[call] = device.tui_calls()
self.assertEqual(call["stdin"], device.console)
self.assertEqual(device.mapper_args()[0], "-1")
def rocknix(self, device: Device) -> None:
etc = device.root / "fs" / "etc"
etc.mkdir(parents=True)
(etc / "os-release").write_text('NAME="ROCKNIX"\n')
device.tool("sudo", ROCKNIX_SUDO)
def control_ini(self, device: Device) -> None:
"""control-gen_init.sh writes control.ini as control-gen.cpp:31
does: `ESUDOKILL="-sudokill"`, and GPTOKEYB with it."""
folder = device.root / "fs" / "storage" / ".config" / "gptokeyb"
shutil.copy(device.bin / "gptokeyb", device.root / "gptokeyb-rocknix")
device.tool("control-gen_init.sh", f'''#!/bin/sh
mkdir -p "{folder}"
cat > "{folder}/control.ini" <<'INI'
controlfolder="{device.root}"
ESUDO="sudo"
ESUDOKILL="-sudokill"
export SDL_GAMECONTROLLERCONFIG_FILE="$controlfolder/gamecontrollerdb.txt"
get_controls() {{
DEVICE="03000000"
param_device="fake pad"
}}
GPTOKEYB="$controlfolder/gptokeyb-rocknix $ESUDOKILL"
INI
''')
def test_rocknix_runs_in_the_terminal_tools_opens(self):
device, tools = self.device()
self.rocknix(device)
proc = device.run(["bash", str(tools / "RetroBIOS.sh")], stdio_tty=True, TERM="xterm")
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
[call] = device.tui_calls()
self.assertEqual((call["stdin"], call["stdout"]), (device.console, device.console))
self.assertEqual(call["term"], "xterm")
self.assertEqual(call["pad"], "gptokeyb")
self.assertEqual(call["idle_exit"], "300")
# The terminal app owns its window: no console reset, no font.
self.assertNotIn("\x1bc", device.screen)
self.assertEqual([t for t in device.tools() if t.startswith("setfont")], [])
def test_rocknix_loads_the_controller_list_its_own_tools_load(self):
"""control-gen_init.sh writes control.ini, which names the pad's
controller list and the GPTOKEYB to start (ROCKNIX fex-emu's Start
FEXConfig.sh). Its ESUDO is not this launcher's."""
device, tools = self.device()
self.rocknix(device)
self.control_ini(device)
proc = device.run(["bash", str(tools / "RetroBIOS.sh")], stdio_tty=True, TERM="xterm")
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
self.assertEqual(device.lines("mapper.args")[0], "-1")
self.assertRegex(device.mapper_args()[1], r"^retrobios-stop-\d+x$")
self.assertEqual(device.lines("mapper.db"), [f"{device.root}/gamecontrollerdb.txt"])
started = [t for t in device.tools() if t.startswith("sudo")]
self.assertEqual(started, [])
# Stopped without the sudo control.ini names.
device.assert_mapper_stopped()
def test_rocknix_start_select_with_its_controller_setup(self):
"""control.ini's `-sudokill` runs the kill through sudo, which on
ROCKNIX runs nothing: the mapper is started with -1, and Start +
Select ends the run at once instead of 3 s later through the
liveness watch."""
device, tools = self.device()
self.rocknix(device)
self.control_ini(device)
proc = device.run(["bash", str(tools / "RetroBIOS.sh")], stdio_tty=True, TERM="xterm",
FAKE_HOLD="1", FAKE_PRESS="1", RB_MESSAGE_WAIT="300")
self.assertEqual(proc.returncode, 143, proc.stderr.decode())
self.assertLess(device.elapsed, 10)
self.assertEqual(device.lines("ui.log"), ["signal 15"])
log = (tools / "retrobios" / "log.txt").read_text()
self.assertIn("Start + Select: the interface is asked to stop", log)
self.assertNotIn("the controller mapper stopped", log)
self.assertNotIn("You are 'root'", log)
device.assert_mapper_gone()
def test_rocknix_offline_names_no_pack_the_releases_lack(self):
"""No release pack spells ROCKNIX, and install.py refuses one named
for another system: the RetroArch pack on the card is not offered,
and the message gives the computer route."""
device, tools = self.device(FAKE_CURL="7")
self.rocknix(device)
(tools / "retrobios" / "RetroArch_v1.22.2_BIOS_Pack.zip").write_bytes(b"")
proc = device.run(["bash", str(tools / "RetroBIOS.sh")], stdio_tty=True, TERM="xterm",
FAKE_PLATFORM="rocknix", FAKE_PLATFORM_NAME="ROCKNIX")
self.assertEqual(proc.returncode, 1)
self.assertEqual(device.tui_calls(), [])
screen = " ".join(device.screen.split())
self.assertIn("the releases carry no BIOS pack for ROCKNIX", screen)
self.assertIn("--dest", screen)
self.assertNotIn("download", screen)
def test_rocknix_start_select_in_foot(self):
device, tools = self.device()
self.rocknix(device)
proc = device.run(["bash", str(tools / "RetroBIOS.sh")], stdio_tty=True, TERM="xterm",
FAKE_HOLD="1", FAKE_PRESS="1", FAKE_TERM_ERROR="unreachable",
RB_MESSAGE_WAIT="300")
self.assertEqual(proc.returncode, 143, proc.stderr.decode())
self.assertLess(device.elapsed, 10)
self.assertNotIn("Press A", device.screen)
device.assert_mapper_gone()
class TheLiveness(unittest.TestCase):
def test_a_zombie_is_not_running(self):
"""A process that exited and that its parent has not reaped still
has /proc/PID: rb_alive reads its state."""
pid = os.fork()
if pid == 0:
os._exit(0)
self.addCleanup(os.waitpid, pid, 0)
wait_until(self, lambda: Path(f"/proc/{pid}/stat").read_text().split(") ")[1][0] == "Z",
5, "no zombie")
proc = subprocess.run(
["bash", "-c", 'source "$1"; rb_alive "$2" && echo alive || echo gone', "bash",
str(PORTS / "shared" / "tui.txt"), str(pid)],
capture_output=True, text=True, check=False)
self.assertEqual(proc.stdout.strip(), "gone", proc.stderr)
proc = subprocess.run(
["bash", "-c", 'source "$1"; rb_alive "$$" && echo alive || echo gone', "bash",
str(PORTS / "shared" / "tui.txt")],
capture_output=True, text=True, check=False)
self.assertEqual(proc.stdout.strip(), "alive", proc.stderr)
class TheDocs(unittest.TestCase):
def test_the_offline_packs_named_are_the_ones_that_serve(self):
"""The wiki names the pack each firmware takes offline: the release
pack of the platform install.py detects it as, or none."""
labels = build_port.release_pack_labels()
served = {}
for rule in install._EMBEDDED_RULES:
served.setdefault(rule.name, labels.get(rule.manifest))
for name in ("muOS", "ArkOS", "dArkOS", "JELOS"):
self.assertEqual(served[name], "RetroArch", name)
self.assertEqual(served["Batocera"], "Batocera")
for name in ("ROCKNIX", "KNULLI", "AmberELEC"):
self.assertIsNone(served[name], name)
text = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8")
self.assertIn("The releases carry no pack for ROCKNIX,\nKnulli or AmberELEC", text)
def test_every_pad_value_is_documented(self):
"""RETROBIOS_PAD is a contract the interface reads: each value tui.txt
names has its row in the handheld section."""
text = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8")
tui = (PORTS / "shared" / "tui.txt").read_text()
for value in ("gptokeyb", "muxterm", "evmapy", "miyoo"):
self.assertIn(f"| `{value}` |", text)
self.assertIn(value, tui)
def handheld(self) -> str:
text = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8")
start = text.index("## On a handheld")
end = text.find("\n## ", start + 1)
return text[start:end if end != -1 else len(text)]
def test_the_idle_limit_is_documented(self):
"""Every launcher sets RETROBIOS_IDLE_EXIT for the interface: a
mapper that runs but never opens the pad leaves no button to quit."""
self.assertIn("`RETROBIOS_IDLE_EXIT`", self.handheld())
self.assertIn("RETROBIOS_IDLE_EXIT", (PORTS / "shared" / "tui.txt").read_text())
def test_no_page_buttons_are_promised_in_muxterm(self):
"""muxterm keeps PgUp and PgDn for its scrollback (T/input.c:352-360)."""
readme = " ".join((PORTS / "muos" / "README.md").read_text().split())
self.assertNotIn("L1 and R1 to page", readme)
self.assertIn("L1 and R1 do nothing in muxterm", readme)
rows = [line for line in self.handheld().splitlines() if line.startswith("| L1, R1 |")]
self.assertEqual(len(rows), 1)
self.assertIn("not in muxterm", rows[0])
def test_muos_paths_are_the_card_muos_mounts(self):
"""muOS mounts the first card at /mnt/mmc (MustardOS internal
mux_launch.sh, GET_VAR storage/rom/mount)."""
text = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8")
start = text.index("## On a handheld")
end = text.find("\n## ", start + 1)
section = text[start:end if end != -1 else len(text)]
self.assertNotIn("/mmc/MUOS", section.replace("/mnt/mmc", ""))
self.assertIn("`MUOS/PortMaster/autoinstall/`", section)
if __name__ == "__main__":
unittest.main()