Files
libretro/tests/test_handheld_launchers.py
T

1568 lines
71 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.
for launcher in (PORTS / "muos" / "mux_launch.sh", PORTS / "RetroBIOS" / "RetroBIOS.sh"):
text = launcher.read_text()
self.assertIn("SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT=0x0001/0x0001", text)
self.assertFalse((PORTS / "muos" / "muxterm.gptk").exists())
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)
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", ""),
"pad": os.environ.get("RETROBIOS_PAD")}) + "\n")
if "--check" in sys.argv:
if os.environ.get("FAKE_DETECT_HANG"):
time.sleep(60)
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"}]}))
print(json.dumps({"event": "error", "stage": "manifest", "reason": "network",
"platform": os.environ.get("FAKE_PLATFORM", "retroarch"),
"message": "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_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 not os.environ.get("FAKE_NO_HANDLER"):
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGHUP, stop)
note("ui.ready")
time.sleep(60)
sys.exit(0)
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 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.
# 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=""
while [ $# -gt 0 ]; do
case "$1" in
-1|-k|-sudokill) name="$2"; 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/ui.ready" ]; do sleep 0.1; done
killing=1
echo "kill $name" >> "$FAKE_STATE/mapper.log"
{PKILL} -f "$name"
sleep 3
if {PGREP} -f "$name" > /dev/null; then {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_GPTOKEYB2 = f'''#!/bin/bash
printf '%s\\n' "$@" > "$FAKE_STATE/mapper.args"
echo $$ > "$FAKE_STATE/mapper.pid"
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/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
exit 0
'''
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"
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')
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",
"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",):
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)
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")
# 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()
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):
"""The file-list download is three tries of 30 s: the detection run
is pointed at a closed loopback port and 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)
[check] = [c for c in device.calls() if "--check" in c["args"]]
self.assertEqual(check["base_url"], "http://127.0.0.1:9")
[call] = device.tui_calls()
# The detection was stopped: no platform, the first pack.
self.assertEqual(call["args"], ["--tui", "--from-pack", str(pack)])
self.assertEqual(call["base_url"], "")
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"])
self.assertEqual(device.lines("termapp.sdl"), ["0x0001/0x0001"])
self.assertEqual(device.mapper_args()[2:], ["-c", str(self.gamedir / "retrobios.gptk")])
self.assertEqual(call["pad"], "gptokeyb")
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_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")
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"])
device.assert_console_restored()
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])
self.assertEqual(proc.returncode, 1)
self.assertEqual(device.calls(), [])
self.assertIn("the SD card may be read-only or full", " ".join(device.screen.split()))
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")
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.
self.assertEqual(device.lines("termapp.sdl"), ["0x0001/0x0001"])
args = device.mapper_args()
self.assertEqual(args[0], "-X")
self.assertRegex(args[1], r"^retrobios-stop-\d+x$")
self.assertEqual(args[2:], ["-c", str(app / "retrobios.gptk")])
self.assertEqual(read_gptk(app / "retrobios.gptk")["y"], ["tab"])
self.assertEqual(call["pad"], "gptokeyb")
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")
# 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_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")
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')
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")
# 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)
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
''')
proc = device.run(["bash", str(tools / "RetroBIOS.sh")], stdio_tty=True, TERM="xterm")
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
args = device.mapper_args()
self.assertEqual(args[0], "-sudokill")
self.assertRegex(args[1], r"^retrobios-stop-\d+x$")
self.assertEqual(device.lines("mapper.db"), [f"{device.root}/gamecontrollerdb.txt"])
# Stopped without the sudo control.ini names.
device.assert_mapper_stopped()
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 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 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()