diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index ad11cd62..2787400f 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -17,6 +17,8 @@ on: - "install.py" - "install.sh" - "install.ps1" + # The handheld port bundles install.py and its tests read both. + - "ports/**" # Every input of the site build, so a page that would fail the deploy # fails here first (tests/test_workflow_paths.py). - "wiki/**" @@ -42,6 +44,7 @@ on: - "install.py" - "install.sh" - "install.ps1" + - "ports/**" - "wiki/**" - "docs_assets/**" - "provenance/**" diff --git a/ports/RetroBIOS/README.md b/ports/RetroBIOS/README.md new file mode 100644 index 00000000..eb023ef9 --- /dev/null +++ b/ports/RetroBIOS/README.md @@ -0,0 +1,18 @@ +## Notes + +RetroBIOS runs the RetroBIOS installer (`install.py`, from +[github.com/Abdess/retrobios](https://github.com/Abdess/retrobios)) on the +device. The installer finds the BIOS folder of the system, checks every file +its emulators read against its hash, and downloads the missing ones. The +screens are PortMaster's own. + +Needs a network connection. Python 3.8 or newer: the system's, or +PortMaster's `python_3.11` runtime on aarch64, downloaded on first use. + +## Controls + +| Button | Action | +|--|--| +| D-Pad | Move in a list | +| A | Choose | +| B | Back, on PortMaster releases that read it (every list also has a Back or Quit entry) | diff --git a/ports/RetroBIOS/RetroBIOS.sh b/ports/RetroBIOS/RetroBIOS.sh new file mode 100755 index 00000000..59928003 --- /dev/null +++ b/ports/RetroBIOS/RetroBIOS.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# RetroBIOS: checks the BIOS folder of this system and downloads the files +# it lacks, with PortMaster's own screens. + +XDG_DATA_HOME=${XDG_DATA_HOME:-$HOME/.local/share} + +if [ -d "/opt/system/Tools/PortMaster/" ]; then + controlfolder="/opt/system/Tools/PortMaster" +elif [ -d "/opt/tools/PortMaster/" ]; then + controlfolder="/opt/tools/PortMaster" +elif [ -d "$XDG_DATA_HOME/PortMaster/" ]; then + controlfolder="$XDG_DATA_HOME/PortMaster" +else + controlfolder="/roms/ports/PortMaster" +fi + +source "$controlfolder/control.txt" +[ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt" +get_controls + +GAMEDIR="/$directory/ports/retrobios" +cd "$GAMEDIR" || exit 1 + +> "$GAMEDIR/log.txt" && exec > >(tee "$GAMEDIR/log.txt") 2>&1 + +# PortMasterDialog.txt has shipped with PortMaster since before 2024.02.26, +# and the 8.5.23 copy JELOS and KOLOS preinstall has no funcs.txt to source +# it: it is sourced here. +if [ ! -f "$controlfolder/PortMasterDialog.txt" ]; then + echo "RetroBIOS needs PortMaster 2024.02.26 or newer: update PortMaster, then start RetroBIOS again." + exit 1 +fi +source "$controlfolder/PortMasterDialog.txt" +source "$GAMEDIR/screens.txt" + +export SDL_GAMECONTROLLERCONFIG="$sdl_controllerconfig" + +rb_main +status=$? + +if declare -F pm_finish > /dev/null; then + pm_finish +fi +exit $status diff --git a/ports/RetroBIOS/gameinfo.xml b/ports/RetroBIOS/gameinfo.xml new file mode 100644 index 00000000..4eb01b2f --- /dev/null +++ b/ports/RetroBIOS/gameinfo.xml @@ -0,0 +1,11 @@ + + + + ./RetroBIOS.sh + RetroBIOS + Checks the BIOS folder of this system against the files its emulators read, and downloads the missing ones from the RetroBIOS collection, each verified by its hash. + Abdess + Open Source + Utility + + diff --git a/ports/RetroBIOS/port.json b/ports/RetroBIOS/port.json new file mode 100644 index 00000000..083ef871 --- /dev/null +++ b/ports/RetroBIOS/port.json @@ -0,0 +1,31 @@ +{ + "version": 4, + "name": "retrobios.zip", + "items": [ + "RetroBIOS.sh", + "retrobios" + ], + "items_opt": [], + "attr": { + "title": "RetroBIOS", + "porter": [ + "Abdess" + ], + "desc": "Checks the BIOS folder of this system against the files its emulators read, and downloads the missing ones from the RetroBIOS collection, each verified by its hash.", + "desc_md": null, + "inst": "Needs a network connection: the list of files and the files come from the internet. Uses the system's Python 3.8 or newer, or PortMaster's python_3.11 runtime on aarch64.", + "inst_md": null, + "genres": [ + "other" + ], + "image": null, + "rtr": true, + "exp": false, + "runtime": [], + "store": [], + "availability": "full", + "reqs": [], + "arch": [], + "min_glibc": "" + } +} diff --git a/ports/RetroBIOS/retrobios/rbevents.py b/ports/RetroBIOS/retrobios/rbevents.py new file mode 100644 index 00000000..8c71c3a0 --- /dev/null +++ b/ports/RetroBIOS/retrobios/rbevents.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Turn install.py --events into tab-separated records for the launcher. + +The launcher is bash and draws through PortMaster's dialog FIFO, so it reads +one record per line with `IFS=$'\\t' read`. Text fields are cleaned for that +FIFO: PortMasterDialog sends its arguments through an unquoted +`echo $(printf ...)`, where a tab or newline splits a field and `*`, `?` or +`[` expand as a file pattern. + + rbevents.py status EVENTS records of a --check run + rbevents.py follow SUMMARY an install run on stdin: progress + records on stdout, the outcome in SUMMARY + +Python 3.8 and the standard library, like install.py. +""" +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path +from typing import Iterable, Iterator, TextIO + +_UNSAFE = re.compile(r"[\x00-\x1f\x7f*?\[\]\\]+") +_SPACES = re.compile(r" {2,}") + + +def clean(value: object) -> str: + """One FIFO-safe field: no control character, pattern character or run of spaces.""" + text = _UNSAFE.sub(" ", str(value)) + return _SPACES.sub(" ", text).strip() + + +def size_text(count: int) -> str: + """12 KB, 3.4 MB, 1.2 GB.""" + value = float(count) + for unit in ("B", "KB", "MB", "GB"): + if value < 1024 or unit == "GB": + return f"{value:.0f} {unit}" if unit in ("B", "KB") else f"{value:.1f} {unit}" + value /= 1024 + return f"{value:.1f} GB" + + +def events(lines: Iterable[str]) -> Iterator[dict]: + """The JSON objects of an event stream. A line that is not one is skipped.""" + for line in lines: + line = line.strip() + if not line.startswith("{"): + continue + try: + event = json.loads(line) + except ValueError: + continue + if isinstance(event, dict) and isinstance(event.get("event"), str): + yield event + + +def record(out: TextIO, *fields: object) -> None: + # A tab is whitespace to `read`, so two in a row are one separator: an + # empty field would shift the ones after it. "-" stands for it. + out.write("\t".join(clean(field) or "-" for field in fields) + "\n") + out.flush() + + +def status(stream: Iterable[str], out: TextIO) -> None: + """The records the start screen and the menus read. + + Only the first detected platform is described: a handheld runs one. + """ + platform = None + ended = False + for event in events(stream): + kind = event["event"] + if kind == "start": + record(out, "start", event.get("installer", "")) + elif kind == "detect": + found = event.get("platforms") or [] + record(out, "detect", event.get("os_name", ""), len(found)) + if found: + first = found[0] + platform = first.get("platform") + record(out, "platform", platform, first.get("name", ""), + first.get("bios_dir", "")) + elif kind == "error": + record(out, "error", event.get("reason", ""), event.get("message", "")) + elif kind == "system" and event.get("platform") == platform: + needed = event.get("missing", 0) + event.get("wrong_hash", 0) + system = event.get("system", "") + # install.py files what belongs to no single system under "other". + name = "Files of no single system" if system == "other" else event.get("name", "") + record(out, "system", system, name, + event.get("files", 0), event.get("present", 0), event.get("missing", 0), + event.get("wrong_hash", 0), size_text(event.get("bytes_needed", 0)), + "1" if needed else "0") + elif kind == "check" and event.get("platform") == platform: + record(out, "check", event.get("files", 0), event.get("present", 0), + event.get("missing", 0), event.get("wrong_hash", 0), + size_text(event.get("bytes_needed", 0)), event.get("data_missing", 0), + event.get("not_installable", 0)) + elif kind == "end": + ended = True + record(out, "end", event.get("code", 1)) + if not ended: + # Refused before the run started, or stopped without a word. + record(out, "end", "none") + + +def follow(stream: Iterable[str], out: TextIO, summary: TextIO) -> None: + """Progress records as an install runs, and its outcome in *summary*.""" + ended = False + for event in events(stream): + kind = event["event"] + if kind == "download": + record(out, "download", event.get("files", 0), size_text(event.get("bytes", 0))) + elif kind == "downloaded": + record(out, "file", event.get("files_done", 0), event.get("files_total", 0), + "ok" if event.get("ok") else "failed", event.get("dest", ""), + size_text(event.get("bytes_done", 0)), + size_text(event.get("bytes_total", 0))) + elif kind == "progress": + record(out, "progress", event.get("files_done", 0), event.get("files_total", 0), + size_text(event.get("bytes_done", 0)), + size_text(event.get("bytes_total", 0))) + elif kind == "error": + record(out, "error", event.get("reason", ""), event.get("message", "")) + record(summary, "error", event.get("reason", ""), event.get("message", "")) + elif kind == "summary": + record(summary, "summary", event.get("installed", 0), event.get("up_to_date", 0), + event.get("pending", 0), "1" if event.get("interrupted") else "0") + for failure in event.get("failed") or []: + record(summary, "failed", failure.get("dest", ""), failure.get("reason", "")) + for group in event.get("skipped") or []: + record(summary, "skipped", group.get("label", ""), group.get("files", 0), + group.get("required", 0)) + elif kind == "end": + ended = True + record(summary, "end", event.get("code", 1)) + if not ended: + record(summary, "end", "none") + + +def main(argv: list[str]) -> int: + if len(argv) == 2 and argv[0] == "status": + with open(argv[1], encoding="utf-8", errors="replace") as stream: + status(stream, sys.stdout) + return 0 + if len(argv) == 2 and argv[0] == "follow": + with Path(argv[1]).open("w", encoding="utf-8") as summary: + follow(sys.stdin, sys.stdout, summary) + return 0 + sys.stderr.write("usage: rbevents.py status EVENTS | follow SUMMARY\n") + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/ports/RetroBIOS/retrobios/screens.txt b/ports/RetroBIOS/retrobios/screens.txt new file mode 100644 index 00000000..91d45650 --- /dev/null +++ b/ports/RetroBIOS/retrobios/screens.txt @@ -0,0 +1,462 @@ +#!/bin/bash +# RetroBIOS screens, drawn by PortMaster's own GUI (pugwash fifo_control) +# through the functions of PortMasterDialog.txt. Sourced by RetroBIOS.sh +# after PortMasterDialog.txt. +# +# Only what PortMaster 2024.02.26 and the 8.5.23 copy JELOS and KOLOS ship +# is used: PortMasterDialogInit, PortMasterDialog, PortMasterDialogResult, +# PortMasterDialogExit, PortMasterIPCheck, and the commands +# register_set_info, register_clear, selection_list, messages_begin, +# message, messages_end, progress, progress_clear and check_runtime. +# Those releases ignore --want-cancel, so every list ends on its own Back +# or Quit entry. They also read "\n" literally, so no text has a newline, +# and their wait for an answer gives up after 30 seconds: rb_answer keeps +# waiting while pugwash is alive. +# +# Inputs: GAMEDIR, controlfolder, ESUDO, and RB_INSTALL (install.py). + +RB_STATE="${RB_STATE:-$GAMEDIR/run}" +RB_LOG="${RB_LOG:-$GAMEDIR/log.txt}" +RB_INSTALL="${RB_INSTALL:-$GAMEDIR/install.py}" +RB_EVENTS="${RB_EVENTS:-$GAMEDIR/rbevents.py}" +RB_RUNTIME="python_3.11" +RB_PC_ROUTE="On a computer, with this SD card mounted, the same installer fills the card: install.py --platform NAME --dest BIOS-FOLDER-ON-THE-CARD. Instructions: abdess.github.io/retrobios/wiki/installer" +RB_PYTHON="" +RB_PYTHONHOME="" +RB_MOUNTED="" +RB_PYTHON_PROBLEM="" + +rb_arch() { + uname -m +} + +rb_py() { + if [ -n "$RB_PYTHONHOME" ]; then + PYTHONHOME="$RB_PYTHONHOME" "$RB_PYTHON" "$@" + else + "$RB_PYTHON" "$@" + fi +} + +# --- Python --------------------------------------------------------------- + +rb_python_ok() { + "$@" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 8) else 1)' >/dev/null 2>&1 +} + +# The OS python3 when it is 3.8 or newer, else PortMaster's python_3.11 +# runtime on aarch64, mounted as PortMaster's own Python ports do. Sets +# RB_PYTHON_PROBLEM when neither can run install.py. +rb_select_python() { + if command -v python3 >/dev/null 2>&1 && rb_python_ok python3; then + RB_PYTHON="python3" + return 0 + fi + if [ "$(rb_arch)" != "aarch64" ]; then + RB_PYTHON_PROBLEM="old" + return 1 + fi + rb_runtime +} + +rb_runtime() { + local python_dir="$HOME/$RB_RUNTIME" + local squashfs="$controlfolder/libs/$RB_RUNTIME.squashfs" + if [ ! -f "$squashfs" ]; then + if [ -z "$(PortMasterIPCheck)" ]; then + RB_PYTHON_PROBLEM="runtime_offline" + return 1 + fi + PortMasterDialogInit "no-check" + PortMasterDialog "messages_begin" + PortMasterDialog "message" "This system's Python is older than 3.8. Downloading PortMaster's $RB_RUNTIME runtime." + local answer + answer=$(rb_answer "check_runtime" "$RB_RUNTIME.squashfs") + PortMasterDialog "messages_end" + PortMasterDialogExit + if [ "$answer" != "OKAY" ] || [ ! -f "$squashfs" ]; then + RB_PYTHON_PROBLEM="runtime_failed" + return 1 + fi + fi + mkdir -p "$python_dir" + if [ "${PM_CAN_MOUNT:-Y}" != "N" ]; then + $ESUDO umount "$python_dir" >/dev/null 2>&1 + fi + if ! $ESUDO mount "$squashfs" "$python_dir"; then + RB_PYTHON_PROBLEM="runtime_failed" + return 1 + fi + RB_MOUNTED="$python_dir" + RB_PYTHON="$python_dir/bin/python3" + RB_PYTHONHOME="$python_dir" + if ! PYTHONHOME="$python_dir" rb_python_ok "$RB_PYTHON"; then + RB_PYTHON_PROBLEM="runtime_failed" + return 1 + fi + return 0 +} + +rb_release_python() { + if [ -n "$RB_MOUNTED" ] && [ "${PM_CAN_MOUNT:-Y}" != "N" ]; then + $ESUDO umount "$RB_MOUNTED" >/dev/null 2>&1 + fi + RB_MOUNTED="" +} + +# --- Dialog primitives ---------------------------------------------------- + +# A command that waits for the user, and its answer. PortMasterDialogResult +# stops waiting after 30 seconds on the older releases and then returns +# WAIT: the answer is read again until pugwash writes one or exits. +rb_answer() { + local answer tries=0 + answer=$(PortMasterDialogResult "$@") + while [ "$answer" = "WAIT" ] || [ -z "$answer" ]; do + if [ -n "$PM_PID" ] && [ ! -d "/proc/$PM_PID" ]; then + break + fi + if [ "$tries" -ge 6000 ]; then + break + fi + tries=$((tries + 1)) + sleep 0.3 + answer=$(cat "$PM_DONE" 2>/dev/null) + done + printf '%s\n' "$answer" +} + +rb_plural() { + if [ "$1" = "1" ]; then + printf '%s %s\n' "$1" "$2" + else + printf '%s %ss\n' "$1" "$2" + fi +} + +rb_entry() { + PortMasterDialog "register_set_info" "$1" "$2" "title:$3" "description:$4" +} + +# Show a list and print the chosen key. B, where the release reads it, +# answers like the Back or Quit entry ($2). +rb_choose() { + local answer + answer=$(rb_answer "selection_list" "--register=$1" "--want-description" "--want-cancel") + case "$answer" in + CANCEL|FAIL|WAIT|"") printf '%s\n' "$2" ;; + *) printf '%s\n' "$answer" ;; + esac +} + +# A screen of text with one or two ways out: rb_notice TITLE TEXT KEY LABEL [KEY LABEL]. +# B answers like the last one, which leaves. +rb_notice() { + local title="$1" text="$2" + shift 2 + local last="$1" answer + PortMasterDialog "register_clear" "notice" + # The list has no heading of its own: the first entry is the heading. + rb_entry "notice" "info" "$title" "$text" + rb_entry "notice" "$1" "$2" "$text" + if [ $# -ge 4 ]; then + rb_entry "notice" "$3" "$4" "$text" + last="$3" + fi + while true; do + answer=$(rb_choose "notice" "$last") + if [ "$answer" != "info" ]; then + printf '%s\n' "$answer" + return + fi + done +} + +# --- Reading install.py --------------------------------------------------- + +# Run a --check and read its records into RB_* variables. +rb_check() { + mkdir -p "$RB_STATE" + PortMasterDialog "messages_begin" + PortMasterDialog "message" "Reading the BIOS folder and the file list." + rb_py "$RB_INSTALL" --check --events "${RB_PLATFORM_ARGS[@]}" > "$RB_STATE/check.jsonl" 2>> "$RB_LOG" + PortMasterDialog "messages_end" + rb_py "$RB_EVENTS" status "$RB_STATE/check.jsonl" > "$RB_STATE/status.tsv" 2>> "$RB_LOG" + rb_read_status "$RB_STATE/status.tsv" +} + +rb_read_status() { + RB_OS_NAME="this system" + RB_DETECTED="" + RB_PLATFORM="" + RB_PLATFORM_NAME="" + RB_BIOS_DIR="" + RB_ERROR="" + RB_ERROR_TEXT="" + RB_CHECKED="" + RB_END="none" + RB_SYSTEMS=() + RB_COMPLETE=0 + local kind a b c d e f g h + while IFS=$'\t' read -r kind a b c d e f g h; do + case "$kind" in + detect) RB_OS_NAME="$a"; RB_DETECTED="$b" ;; + platform) RB_PLATFORM="$a"; RB_PLATFORM_NAME="$b"; RB_BIOS_DIR="$c" ;; + error) RB_ERROR="$a"; RB_ERROR_TEXT="$b" ;; + check) + RB_CHECKED=1 + RB_FILES="$a"; RB_PRESENT="$b"; RB_MISSING="$c"; RB_WRONG="$d" + RB_NEEDED_SIZE="$e"; RB_DATA_MISSING="$f"; RB_NOT_INSTALLABLE="$g" + ;; + system) + if [ "$h" = "1" ]; then + RB_SYSTEMS+=("$a"$'\t'"$b"$'\t'"$c"$'\t'"$d"$'\t'"$e"$'\t'"$f"$'\t'"$g") + else + RB_COMPLETE=$((RB_COMPLETE + 1)) + fi + ;; + end) RB_END="$a" ;; + esac + done < "$1" +} + +rb_status_text() { + local text="$RB_OS_NAME, $RB_PLATFORM_NAME file list: $RB_PRESENT/$RB_FILES BIOS files, $RB_MISSING missing, $RB_WRONG wrong hash." + if [ "${RB_NOT_INSTALLABLE:-0}" != "0" ]; then + text="$text $RB_NOT_INSTALLABLE not in the collection." + fi + printf '%s\n' "$text" +} + +rb_pending() { + printf '%s\n' $((RB_MISSING + RB_WRONG + RB_DATA_MISSING)) +} + +# --- Screens -------------------------------------------------------------- + +rb_screen_no_python() { + local found + found=$(python3 --version 2>&1 | awk '{print $2}') + local text + case "$RB_PYTHON_PROBLEM" in + old) text="Python 3.8 or newer is needed. This system has ${found:-none}, and PortMaster's Python runtime exists for aarch64 only, and this device is $(rb_arch)." ;; + runtime_offline) text="Python 3.8 or newer is needed. This system has ${found:-none}, and PortMaster's $RB_RUNTIME runtime needs a connection to download." ;; + *) text="Python 3.8 or newer is needed. This system has ${found:-none}, and PortMaster's $RB_RUNTIME runtime could not be installed or run." ;; + esac + rb_notice "Python 3.8 or newer is needed" "$text $RB_PC_ROUTE" "quit" "Quit" > /dev/null +} + +# What the check found when it found nothing to show: no platform, no +# connection, an unreadable list. Prints check or quit. +rb_screen_problem() { + if [ "$RB_END" = "none" ]; then + rb_notice "The installer stopped before checking" "install.py ended without a result. Its messages are in $RB_LOG." "check" "Check again" "quit" "Quit" + elif [ "$RB_DETECTED" = "0" ]; then + rb_notice "No BIOS folder found" "install.py knows no BIOS folder on $RB_OS_NAME. $RB_PC_ROUTE" "check" "Check again" "quit" "Quit" + elif [ "$RB_ERROR" = "network" ]; then + rb_notice "No connection: checking only" "$RB_OS_NAME, BIOS folder $RB_BIOS_DIR. The list of files to check comes from the internet, and so do the files. Connect to Wi-Fi, then check again." "check" "Check again" "quit" "Quit" + elif [ "$RB_ERROR" = "invalid" ]; then + rb_notice "The file list is newer than this port" "$RB_ERROR_TEXT. Update RetroBIOS from PortMaster, or use the installer on a computer. $RB_PC_ROUTE" "check" "Check again" "quit" "Quit" + else + rb_notice "The file list could not be read" "${RB_ERROR_TEXT:-install.py ended with code $RB_END}. Its messages are in $RB_LOG." "check" "Check again" "quit" "Quit" + fi +} + +# The menu after a check. Prints update, choose, check or quit. +rb_screen_menu() { + local status pending + status=$(rb_status_text) + pending=$(rb_pending) + PortMasterDialog "register_clear" "menu" + # The list has no heading of its own: the first entry is the status. + if [ "$pending" -gt 0 ]; then + local data="" + if [ "${RB_DATA_MISSING:-0}" != "0" ]; then + data=" and $RB_DATA_MISSING data files" + fi + rb_entry "menu" "status" "$RB_OS_NAME: $RB_PRESENT/$RB_FILES BIOS files, $RB_MISSING missing, $RB_WRONG wrong hash" "$status BIOS folder: $RB_BIOS_DIR." + rb_entry "menu" "update" "Update everything missing" "$status Downloads $(rb_plural $((RB_MISSING + RB_WRONG)) file)$data, $RB_NEEDED_SIZE, to $RB_BIOS_DIR." + rb_entry "menu" "choose" "Choose systems" "$status Pick the systems to update." + else + rb_entry "menu" "status" "$RB_OS_NAME: all $RB_FILES BIOS files present" "$status Nothing to download. BIOS folder: $RB_BIOS_DIR." + fi + rb_entry "menu" "check" "Check again" "$status Reads the BIOS folder and the file list again." + rb_entry "menu" "quit" "Quit" "$status" + local answer + while true; do + answer=$(rb_choose "menu" "quit") + if [ "$answer" != "status" ]; then + printf '%s\n' "$answer" + return + fi + done +} + +# Systems with files to fetch, toggled with A. Prints the chosen system +# ids, one per line, or nothing for Back. +rb_screen_systems() { + local -A chosen=() + local line id name files present missing wrong size answer count + while true; do + count=0 + for id in "${!chosen[@]}"; do + count=$((count + 1)) + done + PortMasterDialog "register_clear" "systems" + if [ "$count" -gt 0 ]; then + rb_entry "systems" "update" "Update the $count selected" "Downloads what the selected systems lack to $RB_BIOS_DIR." + else + rb_entry "systems" "update" "Update the selected" "Nothing selected yet: pick systems below with A." + fi + rb_entry "systems" "back" "Back" "Back to the menu. $(rb_plural "$RB_COMPLETE" system) complete, not listed." + for line in "${RB_SYSTEMS[@]}"; do + IFS=$'\t' read -r id name files present missing wrong size <<< "$line" + local mark="" + if [ -n "${chosen[$id]}" ]; then + mark=" (selected)" + fi + rb_entry "systems" "sys:$id" "$name$mark" "$present/$files present, $missing missing, $wrong wrong hash, $size to download. A selects or clears it." + done + answer=$(rb_choose "systems" "back") + case "$answer" in + back) return ;; + update) + if [ "$count" -gt 0 ]; then + printf '%s\n' "${!chosen[@]}" | sort + return + fi + ;; + sys:*) + id="${answer#sys:}" + if [ -n "${chosen[$id]}" ]; then + unset "chosen[$id]" + else + chosen[$id]=1 + fi + ;; + esac + done +} + +# Download with a progress bar fed by the event stream: rb_update [SYSTEM...] +rb_update() { + local args=() system + for system in "$@"; do + args+=(--system "$system") + done + mkdir -p "$RB_STATE" + PortMasterDialog "messages_begin" + PortMasterDialog "message" "Downloading to $RB_BIOS_DIR." + PortMasterDialog "progress" "Starting" 0 1 + local kind a b c d e f + rb_py "$RB_INSTALL" --events "${RB_PLATFORM_ARGS[@]}" "${args[@]}" 2>> "$RB_LOG" \ + | rb_py "$RB_EVENTS" follow "$RB_STATE/summary.tsv" 2>> "$RB_LOG" \ + | while IFS=$'\t' read -r kind a b c d e f; do + case "$kind" in + download) PortMasterDialog "message" "$(rb_plural "$a" file), $b." ;; + file) PortMasterDialog "progress" "$d, $e of $f" "$a" "$b" ;; + progress) PortMasterDialog "progress" "$c of $d" "$a" "$b" ;; + error) PortMasterDialog "message" "$b" ;; + esac + done + PortMasterDialog "progress_clear" + PortMasterDialog "messages_end" +} + +# What the update did. Prints back or quit. +rb_screen_summary() { + local kind a b c d shown=0 failed=0 + local end="none" installed=0 up_to_date=0 pending=0 interrupted=0 error="" + while IFS=$'\t' read -r kind a b c d; do + case "$kind" in + summary) installed="$a"; up_to_date="$b"; pending="$c"; interrupted="$d" ;; + failed) failed=$((failed + 1)) ;; + error) error="$b" ;; + end) end="$a" ;; + esac + done < "$RB_STATE/summary.tsv" + PortMasterDialog "register_clear" "summary" + # The first entry is the outcome, the heading the list does not have. + if [ "$end" = "none" ]; then + rb_entry "summary" "info:result" "The installer stopped" "install.py ended without a result. Its messages are in $RB_LOG." + elif [ -n "$error" ]; then + rb_entry "summary" "info:result" "Nothing downloaded" "$error." + else + rb_entry "summary" "info:result" "Installed: $(rb_plural "$installed" file)" "$up_to_date already up to date, $failed failed, $pending still to fetch." + fi + rb_entry "summary" "back" "Back to the menu" "Checks the BIOS folder again." + rb_entry "summary" "quit" "Quit" "Leaves RetroBIOS." + if [ "$interrupted" = "1" ]; then + rb_entry "summary" "info:stopped" "Stopped before the end" "Update again to carry on. Files already installed are kept." + fi + while IFS=$'\t' read -r kind a b c d; do + case "$kind" in + failed) + shown=$((shown + 1)) + if [ "$shown" -le 50 ]; then + rb_entry "summary" "info:failed:$shown" "Failed: $a" "$b" + fi + ;; + skipped) + rb_entry "summary" "info:skipped:$a" "Not in the collection ($a): $(rb_plural "$b" file)" "Required: $c. The RetroBIOS collection does not hold these files, so they cannot be downloaded." + ;; + esac + done < "$RB_STATE/summary.tsv" + if [ "$shown" -gt 50 ]; then + rb_entry "summary" "info:more" "And $((shown - 50)) more failed" "The full list is in $RB_LOG." + fi + local answer + while true; do + answer=$(rb_choose "summary" "back") + case "$answer" in + back|quit) printf '%s\n' "$answer"; return ;; + esac + done +} + +# --- Flow ----------------------------------------------------------------- + +rb_main() { + if ! rb_select_python; then + PortMasterDialogInit "no-harbour" + rb_screen_no_python + PortMasterDialogExit + rb_release_python + return 1 + fi + PortMasterDialogInit "no-harbour" + RB_PLATFORM_ARGS=() + local action systems chosen + rb_check + while true; do + if [ -z "$RB_CHECKED" ]; then + action=$(rb_screen_problem) + else + # Later runs name the platform the first one found, so a second + # detected platform never changes what the screens describe. + RB_PLATFORM_ARGS=(--platform "$RB_PLATFORM") + action=$(rb_screen_menu) + fi + case "$action" in + update) + rb_update + [ "$(rb_screen_summary)" = "quit" ] && break + rb_check + ;; + choose) + systems=$(rb_screen_systems) + if [ -n "$systems" ]; then + mapfile -t chosen <<< "$systems" + rb_update "${chosen[@]}" + [ "$(rb_screen_summary)" = "quit" ] && break + rb_check + fi + ;; + check) rb_check ;; + *) break ;; + esac + done + PortMasterDialogExit + rb_release_python + return 0 +} diff --git a/scripts/build_port.py b/scripts/build_port.py new file mode 100644 index 00000000..33082be6 --- /dev/null +++ b/scripts/build_port.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Build the PortMaster port in ports/RetroBIOS into dist/RetroBIOS.zip. + +The archive has the layout PortMaster's own release build gives a port +(PortMaster-New tools/build_release.py): the launch script at the top, and +one folder holding port.json, gameinfo.xml, the README as .md, the +licenses and the port's files. install.py is copied from the repository +root, and only when it is the installer the bootstraps pin, so the port +runs the same bytes as the one-line command of the same commit. Members are +written as the packs write theirs: fixed timestamps, sorted names, the +executable bit read from the source, so the same tree gives the same zip. + +Usage: + python scripts/build_port.py + python scripts/build_port.py --output-dir tmp/port +""" + +from __future__ import annotations + +import argparse +import hashlib +import re +import sys +import zipfile +from pathlib import Path + +from artifacts import ArtifactLockBusy, artifact_lock + +REPO_ROOT = Path(__file__).resolve().parent.parent +PORT_SOURCE = REPO_ROOT / "ports" / "RetroBIOS" +PORT_DIR = "retrobios" +PORT_SCRIPT = "RetroBIOS.sh" +PORT_ZIP = "RetroBIOS.zip" +_PIN = re.compile(r'^DEFAULT_INSTALL_SHA256="([0-9a-f]{64})"$', re.MULTILINE) + + +def pinned_installer_sha256(root: Path = REPO_ROOT) -> str: + """The SHA-256 install.sh accepts for install.py.""" + found = _PIN.search((root / "install.sh").read_text(encoding="utf-8")) + if found is None: + raise ValueError("install.sh pins no installer SHA-256") + return found.group(1) + + +def port_members(root: Path = REPO_ROOT) -> list[tuple[str, Path]]: + """(name in the archive, source file) for every member, sorted by name.""" + source = root / "ports" / "RetroBIOS" + members = [ + (PORT_SCRIPT, source / PORT_SCRIPT), + (f"{PORT_DIR}/port.json", source / "port.json"), + (f"{PORT_DIR}/gameinfo.xml", source / "gameinfo.xml"), + (f"{PORT_DIR}/{PORT_DIR}.md", source / "README.md"), + (f"{PORT_DIR}/install.py", root / "install.py"), + (f"{PORT_DIR}/licenses/LICENSE-retrobios.txt", root / "LICENSE"), + (f"{PORT_DIR}/licenses/NOTICE-retrobios.txt", root / "NOTICE"), + ] + members += [ + (f"{PORT_DIR}/{path.relative_to(source / PORT_DIR).as_posix()}", path) + for path in sorted((source / PORT_DIR).rglob("*")) + if path.is_file() and "__pycache__" not in path.parts + ] + names = [name for name, _path in members] + if len(set(names)) != len(names): + raise ValueError("two port members share a name") + return sorted(members) + + +def build_port(output: Path, root: Path = REPO_ROOT) -> Path: + """Write the port archive to *output* and return it.""" + pinned = pinned_installer_sha256(root) + actual = hashlib.sha256((root / "install.py").read_bytes()).hexdigest() + if actual != pinned: + raise ValueError( + f"install.py is {actual[:12]}, install.sh pins {pinned[:12]}: " + "re-pin the bootstraps before building the port" + ) + # The packs' member writer: imported here, as it loads the whole + # pack builder, which a caller reading PORT_ZIP does not need. + from generate_pack import _add_pack_member # noqa: PLC0415 + + output.parent.mkdir(parents=True, exist_ok=True) + scratch = output.with_name(f".{output.name}.tmp") + with zipfile.ZipFile(scratch, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zf: + for name, path in port_members(root): + _add_pack_member(zf, str(path), name) + scratch.replace(output) + return output + + +def main() -> int: + parser = argparse.ArgumentParser( + allow_abbrev=False, description="Build the RetroBIOS PortMaster port" + ) + parser.add_argument("--output-dir", type=Path, default=REPO_ROOT / "dist", + help="where RetroBIOS.zip is written (default: dist/)") + args = parser.parse_args() + try: + with artifact_lock(str(args.output_dir)): + path = build_port(args.output_dir / PORT_ZIP) + except (ArtifactLockBusy, OSError, ValueError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + digest = hashlib.sha256(path.read_bytes()).hexdigest() + print(f"{path} ({path.stat().st_size} bytes, sha256 {digest})") + return 0 + + +if __name__ == "__main__": + from common import run_cli + + run_cli(main) diff --git a/tests/test_port.py b/tests/test_port.py new file mode 100644 index 00000000..076a46be --- /dev/null +++ b/tests/test_port.py @@ -0,0 +1,436 @@ +"""The PortMaster port: its archive, its event reader and its screens. + +The screens are driven here with PortMasterDialog's functions replaced by +stubs that record each command and answer from a script, and with a fake +install.py that writes canned --events streams. What a device adds (pugwash +drawing, the pad, mount, the network) is not exercised. +""" +from __future__ import annotations + +import hashlib +import importlib.util +import io +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +import zipfile +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +PORT = REPO_ROOT / "ports" / "RetroBIOS" +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) +_spec = importlib.util.spec_from_file_location( + "rbevents", PORT / "retrobios" / "rbevents.py") +rbevents = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(rbevents) + +SCRIPTS = [PORT / "RetroBIOS.sh", PORT / "retrobios" / "screens.txt"] + + +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 + + +class EveryScriptParses(unittest.TestCase): + def test_bash_n(self): + for script in SCRIPTS: + with self.subTest(script=script.name): + proc = subprocess.run(["bash", "-n", str(script)], capture_output=True, + text=True, check=False) + self.assertEqual(proc.returncode, 0, proc.stderr) + + def test_only_dialog_functions_the_oldest_releases_have(self): + """PortMaster 2024.02.26 and 8.5.23 define these in PortMasterDialog.txt, + and have no funcs.txt: pm_message and the rest would be undefined.""" + known = {"PortMasterDialogInit", "PortMasterDialog", "PortMasterDialogResult", + "PortMasterDialogExit", "PortMasterIPCheck"} + commands = {"register_set_info", "register_clear", "selection_list", + "messages_begin", "message", "messages_end", "progress", + "progress_clear", "check_runtime"} + text = "\n".join(path.read_text(encoding="utf-8") for path in SCRIPTS) + import re # noqa: PLC0415 + used = set(re.findall(r"\b(PortMasterDialog\w*|PortMasterIPCheck)\b", text)) + self.assertLessEqual(used - {"PortMasterDialog.txt"}, known) + self.assertNotRegex(text, r"\bpm_message\b|\bpm_platform_helper\b") + sent = set(re.findall(r'(?:PortMasterDialog|rb_answer) "(\w+)"', text)) + self.assertLessEqual(sent, commands) + self.assertNotIn("register_clear_info", text) + + +class ThePortArchive(unittest.TestCase): + def setUp(self): + self.out = _tempdir(self) + + def _build(self, name: str = "RetroBIOS.zip") -> Path: + return build_port.build_port(self.out / name) + + def test_layout_portmaster_installs(self): + """One launch script at the top, one folder holding port.json: what + harbourmaster's captain.check_port accepts without a warning.""" + with zipfile.ZipFile(self._build()) as zf: + names = zf.namelist() + port = json.loads(zf.read("retrobios/port.json")) + mode = zf.getinfo("RetroBIOS.sh").external_attr >> 16 + self.assertEqual(names, sorted(names)) + self.assertEqual([n for n in names if "/" not in n], ["RetroBIOS.sh"]) + self.assertEqual({n.split("/")[0] for n in names if "/" in n}, {"retrobios"}) + self.assertEqual([n for n in names if n.endswith(".sh")], ["RetroBIOS.sh"]) + for member in ("retrobios/install.py", "retrobios/rbevents.py", + "retrobios/screens.txt", "retrobios/gameinfo.xml", + "retrobios/retrobios.md", "retrobios/licenses/LICENSE-retrobios.txt"): + self.assertIn(member, names) + self.assertEqual(port["version"], 4) + self.assertEqual(port["name"], "retrobios.zip") + self.assertEqual(port["items"], ["RetroBIOS.sh", "retrobios"]) + self.assertEqual(port["attr"]["genres"], ["other"]) + self.assertTrue(mode & 0o111, "the launch script is executable") + + def test_bundled_installer_is_the_pinned_one(self): + with zipfile.ZipFile(self._build()) as zf: + bundled = hashlib.sha256(zf.read("retrobios/install.py")).hexdigest() + self.assertEqual(bundled, build_port.pinned_installer_sha256()) + install_sh = (REPO_ROOT / "install.sh").read_text(encoding="utf-8") + self.assertIn(f'DEFAULT_INSTALL_SHA256="{bundled}"', install_sh) + + 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(PORT, root / "ports" / "RetroBIOS") + with (root / "install.py").open("a") as fh: + fh.write("\n") + with self.assertRaises(ValueError): + build_port.build_port(self.out / "x.zip", root) + + def test_reproducible(self): + first = self._build("a.zip").read_bytes() + second = self._build("b.zip").read_bytes() + self.assertEqual(first, second) + + +class EventRecords(unittest.TestCase): + """rbevents turns --events into fields the FIFO carries intact.""" + + def test_fields_are_fifo_safe(self): + self.assertEqual(rbevents.clean("a\tb\n[x]*? c"), "a b x c") + out = io.StringIO() + rbevents.record(out, "file", "", "a") + self.assertEqual(out.getvalue(), "file\t-\ta\n") + + def test_follow_writes_the_outcome(self): + stream = [ + json.dumps({"event": "download", "platform": "p", "files": 1, "bytes": 2048}), + "not json", + json.dumps({"event": "downloaded", "dest": "x.bin", "ok": False, + "reason": "HTTP 404", "size": 2048, "files_done": 1, + "files_total": 1, "bytes_done": 0, "bytes_total": 2048}), + json.dumps({"event": "summary", "mode": "install", "installed": 0, + "up_to_date": 4, "pending": 1, + "failed": [{"dest": "x.bin", "reason": "HTTP 404"}], + "skipped": [], "interrupted": False}), + json.dumps({"event": "end", "code": 2}), + ] + out, summary = io.StringIO(), io.StringIO() + rbevents.follow(stream, out, summary) + self.assertEqual(out.getvalue().splitlines(), [ + "download\t1\t2 KB", "file\t1\t1\tfailed\tx.bin\t0 B\t2 KB"]) + self.assertEqual(summary.getvalue().splitlines(), [ + "summary\t0\t4\t1\t0", "failed\tx.bin\tHTTP 404", "end\t2"]) + + def test_a_run_without_end_says_so(self): + out = io.StringIO() + rbevents.status([], out) + self.assertEqual(out.getvalue(), "end\tnone\n") + + +FAKE_INSTALL = r''' +import json, os, sys +from pathlib import Path + +state = Path(os.environ["FAKE_STATE"]) +scenario = os.environ["FAKE_SCENARIO"] +keys = json.loads(os.environ["FAKE_KEYS"]) +with (state / "calls.txt").open("a") as fh: + fh.write(" ".join(sys.argv[1:]) + "\n") +check = "--check" in sys.argv + + +def emit(kind, **fields): + assert sorted(fields) == sorted(keys[kind]), kind + print(json.dumps({"event": kind, **fields}), flush=True) + + +def end(code): + emit("end", code=code) + sys.exit(code) + + +emit("start", schema=1, installer="fake", mode="check" if check else "install") +if scenario == "nodetect": + emit("detect", os="linux", os_name="Fake OS 1.0", platforms=[]) + end(1) +emit("detect", os="linux", os_name="Fake OS 1.0", platforms=[ + {"platform": "retroarch", "name": "RetroArch", "bios_dir": "/roms/bios"}]) +if scenario == "offline": + emit("error", stage="manifest", reason="network", platform="retroarch", + message="the file list could not be fetched: [Errno 101] Network is unreachable") + end(1) +done = scenario == "complete" or (state / "installed").exists() +if check: + for system, name, files, present, missing, wrong, needed in ( + ("sys-a", "Alpha*", 2, 2 if done else 0, 0 if done else 1, 0 if done else 1, + 0 if done else 300), + ("sys-b", "Bravo", 1, 1, 0, 0, 0), + ): + emit("system", platform="retroarch", system=system, name=name, files=files, + present=present, missing=missing, wrong_hash=wrong, bytes_needed=needed) + emit("check", platform="retroarch", name="RetroArch", bios_dir="/roms/bios", files=3, + present=3 if done else 1, missing=0 if done else 1, wrong_hash=0 if done else 1, + bytes_needed=0 if done else 300, data_missing=0, not_installable=1) + emit("summary", mode="check", installed=0, up_to_date=3 if done else 1, + pending=0 if done else 2, failed=[], skipped=[], interrupted=False) + end(0 if done else 1) +emit("download", platform="retroarch", files=2, bytes=300) +for n, (dest, size) in enumerate((("a.bin", 100), ("b.bin", 200)), 1): + emit("downloaded", dest=dest, ok=True, reason="", size=size, files_done=n, + files_total=2, bytes_done=100 if n == 1 else 300, bytes_total=300) +(state / "installed").write_text("") +emit("summary", mode="install", installed=2, up_to_date=1, pending=0, failed=[], + skipped=[{"reason": "not_found", "label": "not found", "files": 1, "required": 1}], + interrupted=False) +end(0) +''' + +# PortMasterDialog.txt replaced: every call is a trace line, fields split by +# \x1f, and each selection list is answered from the answers file. +DRIVER = r''' +trace() { + printf '%s' "$1" + shift + local field + for field in "$@"; do printf '\x1f%s' "$field"; done + printf '\n' +} >> "$RB_TRACE" +PM_PID="" +PM_DONE="$FAKE_STATE/pm_done" +PortMasterDialogInit() { trace INIT "$@"; } +PortMasterDialogExit() { trace EXIT; } +PortMasterIPCheck() { [ -n "$FAKE_IP" ] && echo 192.168.0.1; } +PortMasterDialog() { trace CMD "$@"; } +PortMasterDialogResult() { + trace ASK "$@" + if [ "$1" = "check_runtime" ]; then + echo "${FAKE_RUNTIME:-FAIL}" + return + fi + local answer + answer=$(head -n 1 "$RB_ANSWERS") + sed -i 1d "$RB_ANSWERS" + case "$answer" in + "") echo CANCEL ;; + WAIT:*) printf '%s' "${answer#WAIT:}" > "$PM_DONE"; echo WAIT ;; + *) echo "$answer" ;; + esac +} +source "$GAMEDIR/screens.txt" +if [ -n "$FAKE_ARCH" ]; then + rb_arch() { echo "$FAKE_ARCH"; } +fi +rb_main +trace STATUS "$?" +''' + + +class TheScreens(unittest.TestCase): + """Which screens the port shows, scenario by scenario.""" + + def run_port(self, scenario: str, answers: list[str], **env: str) -> list[tuple]: + work = _tempdir(self) + (work / "install.py").write_text(FAKE_INSTALL) + (work / "driver.sh").write_text(DRIVER) + (work / "answers").write_text("".join(f"{a}\n" for a in answers)) + bin_dir = work / "bin" + bin_dir.mkdir() + # The oldest Python install.py supports, where it is installed: the + # event reader runs on whatever the device has. + (bin_dir / "python3").symlink_to(shutil.which("python3.8") or sys.executable) + self.work = work + full_env = { + **os.environ, + "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", + "HOME": str(work), + "GAMEDIR": str(PORT / "retrobios"), + "controlfolder": str(work / "PortMaster"), + "ESUDO": "", + "RB_STATE": str(work / "run"), + "RB_LOG": str(work / "log.txt"), + "RB_INSTALL": str(work / "install.py"), + "RB_TRACE": str(work / "trace"), + "RB_ANSWERS": str(work / "answers"), + "FAKE_STATE": str(work), + "FAKE_SCENARIO": scenario, + "FAKE_KEYS": json.dumps(install.EVENT_KEYS), + **env, + } + proc = subprocess.run(["bash", str(work / "driver.sh")], env=full_env, + capture_output=True, text=True, timeout=120, check=False) + self.assertEqual(proc.returncode, 0, proc.stderr) + log = work / "log.txt" + self.assertNotIn("Traceback", log.read_text() if log.exists() else "") + return self._screens((work / "trace").read_text(encoding="utf-8")) + + def _screens(self, trace: str) -> list[tuple]: + """INIT, EXIT, STATUS, each list shown with its entries, and the + progress and message commands, in order.""" + registers: dict[str, dict[str, tuple[str, str]]] = {} + shown: list[tuple] = [] + for line in trace.splitlines(): + kind, *fields = line.split("\x1f") + for field in fields: + self.assertNotRegex(field, r"[*?\[\]\t]", line) + if kind == "CMD" and fields[0] == "register_clear": + registers[fields[1]] = {} + elif kind == "CMD" and fields[0] == "register_set_info": + info = dict(item.split(":", 1) for item in fields[3:]) + registers.setdefault(fields[1], {})[fields[2]] = ( + info["title"], info["description"]) + elif kind == "ASK" and fields[0] == "selection_list": + self.assertIn("--want-cancel", fields) + name = fields[1].split("=", 1)[1] + shown.append(("list", name, dict(registers[name]))) + elif kind == "ASK": + shown.append(("ask", *fields)) + elif kind == "CMD" and fields[0] in ("progress", "message"): + shown.append(("cmd", *fields)) + elif kind in ("INIT", "EXIT", "STATUS"): + shown.append((kind, *fields)) + for screen in shown: + if screen[0] == "list": + titles = [title for title, _desc in screen[2].values()] + self.assertTrue({"Back", "Quit", "Back to the menu"} & set(titles), + f"{screen[1]} has no Back or Quit entry") + return shown + + def lists(self, shown: list[tuple]) -> list[tuple[str, list[str]]]: + return [(s[1], list(s[2])) for s in shown if s[0] == "list"] + + def calls(self) -> list[str]: + return (self.work / "calls.txt").read_text().splitlines() + + def test_all_present(self): + shown = self.run_port("complete", ["quit"]) + self.assertEqual(self.lists(shown), [("menu", ["status", "check", "quit"])]) + menu = shown[[s[0] for s in shown].index("list")][2] + self.assertEqual(menu["status"][0], "Fake OS 1.0: all 3 BIOS files present") + self.assertEqual(shown[0], ("INIT", "no-harbour")) + self.assertEqual(shown[-2:], [("EXIT",), ("STATUS", "0")]) + self.assertEqual(self.calls(), ["--check --events"]) + + def test_some_missing_update_everything(self): + shown = self.run_port("missing", ["update", "back", "quit"]) + self.assertEqual(self.lists(shown), [ + ("menu", ["status", "update", "choose", "check", "quit"]), + ("summary", ["info:result", "back", "quit", "info:skipped:not found"]), + ("menu", ["status", "check", "quit"]), + ]) + menu = shown[[s[0] for s in shown].index("list")][2] + self.assertEqual(menu["status"][0], "Fake OS 1.0: 1/3 BIOS files, 1 missing, 1 wrong hash") + self.assertIn("Downloads 2 files, 300 B, to /roms/bios", menu["update"][1]) + progress = [s for s in shown if s[:2] == ("cmd", "progress")] + self.assertEqual(progress, [ + ("cmd", "progress", "Starting", "0", "1"), + ("cmd", "progress", "a.bin, 100 B of 300 B", "1", "2"), + ("cmd", "progress", "b.bin, 300 B of 300 B", "2", "2"), + ]) + summary = [s for s in shown if s[:2] == ("list", "summary")][0][2] + self.assertEqual(summary["info:result"][0], "Installed: 2 files") + self.assertEqual(self.calls(), [ + "--check --events", + "--events --platform retroarch", + "--check --events --platform retroarch", + ]) + + def test_some_missing_choose_systems(self): + shown = self.run_port("missing", [ + "choose", "update", "sys:sys-a", "sys:sys-a", "sys:sys-a", "update", "quit"]) + systems = [s[2] for s in shown if s[:2] == ("list", "systems")] + self.assertEqual(list(systems[0]), ["update", "back", "sys:sys-a"]) + self.assertEqual(systems[0]["sys:sys-a"][0], "Alpha") + self.assertEqual(systems[1]["sys:sys-a"][0], "Alpha") + self.assertEqual(systems[2]["sys:sys-a"][0], "Alpha (selected)") + self.assertEqual(systems[2]["update"][0], "Update the 1 selected") + self.assertIn("1 system complete, not listed", systems[0]["back"][1]) + self.assertEqual(self.calls()[1], "--events --platform retroarch --system sys-a") + self.assertEqual(self.lists(shown)[-1][0], "summary") + self.assertEqual(shown[-1], ("STATUS", "0")) + + def test_no_network(self): + shown = self.run_port("offline", ["quit"]) + self.assertEqual(self.lists(shown), [("notice", ["info", "check", "quit"])]) + notice = shown[[s[0] for s in shown].index("list")][2] + self.assertEqual(notice["info"][0], "No connection: checking only") + self.assertIn("Connect to Wi-Fi", notice["info"][1]) + self.assertIn("/roms/bios", notice["info"][1]) + + def test_detection_failure(self): + shown = self.run_port("nodetect", ["check", "quit"]) + self.assertEqual([name for name, _keys in self.lists(shown)], ["notice", "notice"]) + notice = shown[[s[0] for s in shown].index("list")][2] + self.assertEqual(notice["info"][0], "No BIOS folder found") + self.assertIn("Fake OS 1.0", notice["info"][1]) + self.assertIn("--dest", notice["info"][1]) + self.assertEqual(len(self.calls()), 2) + + def test_python_too_old(self): + for arch, env, words in ( + ("armv7l", {}, ["3.7.3", "aarch64 only", "armv7l", "--dest"]), + ("aarch64", {}, ["3.7.3", "needs a connection", "--dest"]), + ("aarch64", {"FAKE_IP": "1", "FAKE_RUNTIME": "FAIL"}, + ["3.7.3", "could not be installed", "--dest"]), + ): + with self.subTest(arch=arch, env=env): + shown = self.run_old(arch, **env) + lists = self.lists(shown) + self.assertEqual(lists, [("notice", ["info", "quit"])]) + notice = [s for s in shown if s[0] == "list"][0][2] + self.assertEqual(notice["info"][0], "Python 3.8 or newer is needed") + for word in words: + self.assertIn(word, notice["info"][1]) + self.assertEqual(shown[-1], ("STATUS", "1")) + self.assertFalse((self.work / "calls.txt").exists()) + asked_runtime = ("ask", "check_runtime", "python_3.11.squashfs") in shown + self.assertEqual(asked_runtime, bool(env.get("FAKE_RUNTIME"))) + + def run_old(self, arch: str, **env: str) -> list[tuple]: + """A python3 on PATH that is 3.7.3, ahead of every other.""" + holder = _tempdir(self) + fake = holder / "python3" + fake.write_text('#!/bin/sh\n[ "$1" = "--version" ] && echo "Python 3.7.3"\nexit 1\n') + fake.chmod(0o755) + return self.run_port("complete", [], FAKE_ARCH=arch, + PATH=f"{holder}{os.pathsep}{os.environ['PATH']}", **env) + + def test_an_answer_after_the_old_30_second_wait(self): + """2024.02.26 and 8.5.23 stop waiting after 30 s and answer WAIT: + the port reads the answer pugwash writes later.""" + shown = self.run_port("complete", ["WAIT:quit"]) + self.assertEqual(shown[-1], ("STATUS", "0")) + self.assertEqual(len(self.lists(shown)), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index b3a50f9c..3feaf147 100644 --- a/wiki/testing-guide.md +++ b/wiki/testing-guide.md @@ -44,6 +44,7 @@ Every module in `tests/`, by the area it guards. | Slots and regions | synthetic + profiles | `test_region`, `test_region_agnostic_scan`, `test_region_groups`, `test_region_held`, `test_region_platform_index`, `test_region_required_only`, `test_slot`, `test_slots`, `test_slots_shipped_file` | | Hardware targets and systems | synthetic + target files | `test_extract_targets`, `test_lakka_targets`, `test_libretro_slugs`, `test_system_ids`, `test_system_selection`, `test_target_names`, `test_target_scrapers`, `test_target_systems`, `test_target_verify` | | Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_selection` | +| Handheld port | stubbed PortMaster dialogs, fake installer | `test_port` | | Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` | | Scrapers, upstream and provenance | inline sources, synthetic | `test_check_freshness`, `test_dedup`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_profile_sync`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_scraper_contract`, `test_upstream`, `test_upstream_index` | | Site, docs and repository contracts | rendered pages, sources | `test_contributing_text`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_doc_pages`, `test_doc_surfaces`, `test_optional_dependencies`, `test_prose_style`, `test_site_availability`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_platform_hashes`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_write`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` | @@ -251,6 +252,23 @@ python scripts/generate_pack.py --all --verify-packs --output-dir dist/ Integrated as pipeline step 6/8 (runs after consistency check, before README generation). Requires packs in `dist/`, skip with `--skip-packs`. +### test_port.py + +The PortMaster port in `ports/RetroBIOS/`. `bash -n` on its scripts, the +archive `scripts/build_port.py` writes (layout, reproducibility, the bundled +`install.py` equal to the one `install.sh` pins), and the event reader. The +screens run in bash with PortMasterDialog's functions replaced by stubs that +record each command and answer from a script, against a fake `install.py` +whose event streams follow `EVENT_KEYS`: all files present, some missing +(update everything, choose systems), no network, no BIOS folder detected, +Python too old, and an answer that arrives after the 30-second wait of the +older PortMaster releases. Drawing, the pad, `mount` and the network belong +to a device and are not exercised. + +```bash +python -m unittest tests.test_port -v +``` + ## Verification discipline The test suite is one layer of verification. The full quality gate is: