Files
libretro/tests/test_handheld_launchers.py
T
Abdessamad Derraz 801a0150e6 feat: run the handheld launchers in the tui
The PortMaster port now starts install.py --tui on the console (or in
foot or xterm under a compositor), with gptokeyb in kill mode on the
installer's path, retrobios.gptk, TERM=linux and a Terminus font, and
puts the console, font and mapper back on exit. pugwash screens stay as
the fallback where no terminal can show, and start in no-check mode so
PortMaster's A/B fix applies. Without a network, every launcher installs
from a pack found on the card or names the pack and the folder.

Adds launchers for Batocera and Knulli (Ports, evmapy .keys), muOS
(.muxapp in muxterm with gptokeyb2) and the Tools menu of ArkOS,
dArkOS, ROCKNIX, JELOS and AmberELEC, built by build_port.py with the
pinned installer and tested on a simulated device.
2026-10-23 16:26:47 +02:00

733 lines
31 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 unittest
import zipfile
from pathlib import Path
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)
@unittest.skipUnless(shutil.which("shellcheck"), "shellcheck is not installed")
def test_shellcheck(self):
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_muxterm_mapping_adds_only_what_muxterm_leaves(self):
"""muxterm writes the D-pad, Start, B and Y to the program itself
(MustardOS/terminal input.c, osk.c): mapping them again would press
each twice."""
text = (PORTS / "muos" / "muxterm.gptk").read_text()
self.assertIn("[controls]\noverlay = clear\n", text)
mapping = read_gptk(PORTS / "muos" / "muxterm.gptk")
mapping.pop("overlay")
self.assertEqual(mapping, {"a": ["enter"], "x": ["space"],
"l1": ["pageup"], "r1": ["pagedown"]})
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/muxterm.gptk", names)
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_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, sys
calls = os.environ["FAKE_CALLS"]
def tty(fd):
try:
return os.ttyname(fd)
except OSError:
return ""
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", "")}) + "\n")
if "--check" in sys.argv:
print(json.dumps({"event": "detect", "os": "linux", "os_name": "Fake OS",
"platforms": [{"platform": "retroarch", "name": "RetroArch",
"bios_dir": "/roms/bios"}]}))
print(json.dumps({"event": "error", "stage": "manifest", "reason": "network",
"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_ERROR"):
print("Error: " + os.environ["FAKE_ERROR"], file=sys.stderr)
sys.exit(int(os.environ.get("FAKE_CODE", "0")))
'''
# Records its arguments and stays up until it is told to stop, as gptokeyb
# does, and records that it was.
FAKE_MAPPER = '''#!/bin/sh
printf '%s\\n' "$@" > "$FAKE_STATE/mapper.args"
echo $$ > "$FAKE_STATE/mapper.pid"
trap 'echo stopped > "$FAKE_STATE/mapper.stopped"; kill $child 2>/dev/null; exit 0' TERM
sleep 30 &
child=$!
wait $child
'''
FAKE_RECORDER = '''#!/bin/sh
printf '%s\\n' "$(basename "$0") $*" >> "$FAKE_STATE/tools.log"
'''
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, then runs its command.
FAKE_TERMAPP = '''#!/bin/sh
printf '%s\\n' "$(basename "$0") $*" >> "$FAKE_STATE/tools.log"
while [ $# -gt 0 ]; do
case "$1" in
--|-e) shift; break ;;
*) shift ;;
esac
done
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_RECORDER)
# 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")}
self.env = {
**environ,
"PATH": f"{self.bin}{os.pathsep}{os.environ['PATH']}",
"HOME": str(self.root),
"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,
}
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, **env: str):
"""Run a launcher; its console output is read from the pty."""
stdin = self.slave if stdio_tty else subprocess.DEVNULL
stdout = self.slave if stdio_tty else subprocess.PIPE
proc = subprocess.run(argv, env={**self.env, **env}, stdin=stdin, stdout=stdout,
stderr=subprocess.PIPE, timeout=120, check=False)
self.screen = self._drain()
return proc
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 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 tools(self) -> list[str]:
path = self.state / "tools.log"
return path.read_text().splitlines() if path.exists() else []
def mapper_args(self) -> list[str]:
path = self.state / "mapper.args"
return path.read_text().splitlines() if path.exists() else []
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_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; }
'''
class ThePortMasterPort(unittest.TestCase):
"""RetroBIOS.sh from the PortMaster archive, on a fake PortMaster."""
def device(self, **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)
(control / "control.txt").write_text(
CONTROL_TXT.format(roms=str(roms).lstrip("/"), console=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")
self.script = str(roms / "ports" / "RetroBIOS.sh")
return device
def dialog(self, device: Device) -> list[str]:
path = device.state / "dialog.log"
return path.read_text().splitlines() if path.exists() else []
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")
# Kill mode on the installer's own path, which the interface's command
# line carries: never python3.
installer = str(self.gamedir / "install.py")
self.assertEqual(device.mapper_args(),
["-1", installer, "-c", str(self.gamedir / "retrobios.gptk")])
self.assertEqual(call["argv0"], installer)
device.assert_mapper_stopped()
device.assert_console_restored()
self.assertIn("\x1bc", device.screen)
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}",
])
self.assertEqual(self.dialog(device), [])
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_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_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_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_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.assertEqual(device.mapper_args()[1], str(self.gamedir / "install.py"))
device.assert_mapper_stopped()
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 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(), [])
def test_console_switch_when_no_terminal_app(self):
device, ports = self.device(FAKE_PGREP="0")
(device.root / "active").write_text("tty2\n")
number = re.fullmatch(r"/dev/pts/(\d+)", device.console)
self.assertIsNotNone(number)
proc = device.run(["/bin/bash", str(ports / "RetroBIOS.sh")],
RB_VT_ACTIVE=str(device.root / "active"),
RB_VT_PREFIX="/dev/pts/", RB_VT_SWITCH=number.group(1))
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 {number.group(1)}", "chvt 2"])
device.assert_console_restored()
class TheMuosApp(unittest.TestCase):
def device(self, muxterm: bool) -> 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'
f'GET_VAR() {{ echo "{device.root / "mmc"}"; }}\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)
shutil.copy(device.bin / "gptokeyb", pm / "gptokeyb2")
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"])
self.assertEqual(device.mapper_args(),
[str(app / "install.py"), "-c", str(app / "muxterm.gptk")])
device.assert_mapper_stopped()
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()[2], 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)])
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 test_arkos_console_one(self):
device, tools = self.device()
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")
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"))
installer = str(tools / "retrobios" / "install.py")
self.assertEqual(device.mapper_args(),
["-1", installer, "-c", str(tools / "retrobios" / "retrobios.gptk")])
device.assert_mapper_stopped()
device.assert_console_restored()
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()[:2], ["-1", str(tools / "retrobios" / "install.py")])
def test_rocknix_runs_in_the_terminal_tools_opens(self):
device, tools = self.device()
etc = device.root / "fs" / "etc"
etc.mkdir(parents=True)
(etc / "os-release").write_text('NAME="ROCKNIX"\n')
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")
# 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")], [])
if __name__ == "__main__":
unittest.main()