MIL-013: Debian container and Windows Sandbox checks

A Debian image (python:3.13-slim-bookworm with Xvfb and GNOME's window
manager) runs the linters, the whole test suite, the screenshots and the
smoke test of a PyInstaller Linux binary and of the source program, so no
window opens on the development machine. The screenshot tool works on
Linux, a smoke driver presses the known button with the virtual mouse, and
Windows Sandbox scripts are written (not run, they open a window on the
host). README and the wiki Install pages describe it. Review records
RC-077 (Python) and RC-078 (shell).

Task: MIL-013#2
Task: MIL-013#3
Task: MIL-013#4
Task: MIL-013#5
Task: MIL-013#6
Task: MIL-013#7
Task: MIL-013#9
Task: MIL-013#10
This commit is contained in:
2026-10-15 10:09:48 +08:00
parent a72ddb9175
commit f8ccfca02e
24 changed files with 1205 additions and 10 deletions
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Run one of the check scripts on a COPY of the repository, so that the
# mounted repository stays untouched (no build/, dist/ or caches in it).
#
# Purpose: isolate the checks from the host (ADR-0005).
# Usage: in-container.sh <script> [args...]
# in-container.sh run-checks.sh
# in-container.sh screenshots.sh /out
# in-container.sh smoke-binary.sh 5
# Env: REPO_DIR (default /repo, mounted read-only), WORK_DIR (default
# $HOME/work; it is deleted and recreated, so it must be a folder of
# the container, never the mounted repository)
# Needs: bash 5, tar
# Exit: 2 on bad arguments or a missing script; else the exit code of the
# script that was run
set -euo pipefail
repo="${REPO_DIR:-/repo}"
work="${WORK_DIR:-${HOME}/work}"
usage() {
echo "usage: in-container.sh <script> [args...]" >&2
echo "scripts: run-checks.sh screenshots.sh smoke-binary.sh" >&2
}
if [[ $# -lt 1 ]]; then
usage
exit 2
fi
script="$1"
shift
if [[ ! -f "${repo}/tools/docker/${script}" ]]; then
echo "error: no such script: tools/docker/${script}" >&2
usage
exit 2
fi
if [[ "${work}" == "${repo}" || "${work}" == "/" ]]; then
echo "error: WORK_DIR must not be the repository or /" >&2
exit 2
fi
rm -rf "${work}"
mkdir -p "${work}"
tar -C "${repo}" \
--exclude=.git --exclude=.venv --exclude=build --exclude=dist \
--exclude=docs/doxygen --exclude=.codex --exclude='__pycache__' \
-cf - . | tar -C "${work}" -xf -
cd "${work}"
export PYTHONPATH="${work}/src"
exec bash "tools/docker/${script}" "$@"
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Run the linters and the whole test suite (with the e2e tests of the real Tk
# window) on the virtual desktop.
#
# Purpose: the quality gate without a desktop of the host (ADR-0005).
# Usage: run-checks.sh (from the repository root, via in-container.sh)
# Env: see start-desktop.sh
# Needs: ruff, mypy, import-linter, pytest (the dev extra), the desktop
# Exit: 0 only when every check passes
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/../.."
# shellcheck source=tools/docker/start-desktop.sh
. tools/docker/start-desktop.sh
python -m ruff check src tests tools
python -m ruff format --check src tests tools
python -m mypy
lint-imports
python -m pytest -ra -q
echo "checks passed on desktop: ${DESKTOP_KIND}"
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Take the screenshots (level screen, card front, card back, window with the
# menu bar) on the virtual desktop.
#
# Purpose: screenshots without a desktop of the host (ADR-0005).
# Usage: screenshots.sh [output-folder] (default /out)
# Env: see start-desktop.sh
# Needs: Pillow (the tools extra), the desktop
# Exit: 0 when the four files were written; else the tool's exit code
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/../.."
# shellcheck source=tools/docker/start-desktop.sh
. tools/docker/start-desktop.sh
out="${1:-/out}"
mkdir -p "${out}"
python tools/capture_screenshots.py --output-dir "${out}"
ls -l "${out}"
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Tests of the check scripts themselves: argument errors and refusals. The
# success paths are the runs of run-checks.sh, screenshots.sh and
# smoke-binary.sh.
#
# Purpose: QC-SH-001 criterion 11 (success, failure and bypass paths).
# Usage: selftest.sh (from the repository root)
# Needs: bash 5
# Exit: 0 when every case behaves as stated; 1 when one differs
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/../.."
fail=0
expect_exit() {
local expected="$1" label="$2"
shift 2
local code=0
"$@" >/dev/null 2>&1 || code=$?
if [[ "${code}" -ne "${expected}" ]]; then
echo "FAIL: ${label}: exit ${code}, expected ${expected}" >&2
fail=1
else
echo "ok: ${label}"
fi
}
expect_exit 2 "in-container without arguments" bash tools/docker/in-container.sh
expect_exit 2 "in-container with an unknown script" bash tools/docker/in-container.sh nope.sh
expect_exit 2 "in-container refuses the repository as work folder" \
env REPO_DIR="$(pwd)" WORK_DIR="$(pwd)" bash tools/docker/in-container.sh run-checks.sh
expect_exit 2 "smoke-binary with a text count" bash tools/docker/smoke-binary.sh many
expect_exit 2 "smoke-binary with zero" bash tools/docker/smoke-binary.sh 0
for script in start-desktop run-checks screenshots smoke-binary in-container selftest; do
expect_exit 0 "bash -n ${script}.sh" bash -n "tools/docker/${script}.sh"
done
exit "${fail}"
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Build the Linux binary with PyInstaller and smoke-test it, and the source
# program, on the virtual desktop. The smoke test starts the program, finds the
# window, presses the known button with the virtual mouse of the desktop and
# checks the progress file.
#
# Purpose: the "does the packaged program work" check without a desktop of the
# host (ADR-0005, RC-072).
# Usage: smoke-binary.sh [repeat-count] (default 5)
# Env: see start-desktop.sh
# Needs: pyinstaller (the build extra), binutils, xdotool, the desktop
# Exit: 0 only when every run of both programs passes; 2 on a bad count
set -euo pipefail
repeat="${1:-5}"
if ! [[ "${repeat}" =~ ^[1-9][0-9]*$ ]]; then
echo "error: repeat-count must be a positive integer, got '${repeat}'" >&2
echo "usage: smoke-binary.sh [repeat-count]" >&2
exit 2
fi
cd "$(dirname "${BASH_SOURCE[0]}")/../.."
# shellcheck source=tools/docker/start-desktop.sh
. tools/docker/start-desktop.sh
python tools/build_executable.py --clean
test -x dist/flash-card
echo "== binary"
python tools/docker/smoke_driver.py --repeat "${repeat}" --command dist/flash-card
echo "== source"
python tools/docker/smoke_driver.py --repeat "${repeat}" --command python -m flash_card
+252
View File
@@ -0,0 +1,252 @@
"""Smoke driver that clicks the known button of the running program.
@file smoke_driver.py
@brief Starts the program on the current X display, finds its window with
xdotool, clicks the tick button and checks that one card was saved as known.
Uses the standard library only, so it runs in a bare container. It needs
`xdotool` and a DISPLAY. Exit code 0 only when every run is ok.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import signal
import subprocess
import sys
import tempfile
import time
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol
## @brief Title of the program window.
WINDOW_NAME = "Flashy"
## @brief X of the tick button, in window pixels.
KNOWN_BUTTON_X = 650
## @brief Distance of the tick button centre above the bottom window edge.
KNOWN_BUTTON_BOTTOM_OFFSET = 101
## @brief Seconds between two polls.
POLL_SECONDS = 0.25
## @brief Seconds to wait for the progress file after the click.
PROGRESS_TIMEOUT_SECONDS = 5.0
## @brief Name of the vocabulary file written for a run.
VOCABULARY_NAME = "ms-da.csv"
## @brief Name of the progress file the program writes.
PROGRESS_NAME = "words_to_learn.csv"
class Running(Protocol):
"""A started program that can be stopped."""
def stop(self) -> None:
"""Stop the program and its children."""
## @brief Runs a command and returns its stdout ("" when it fails).
RunCommand = Callable[[Sequence[str]], str]
## @brief Starts the program and returns a handle to stop it.
StartProgram = Callable[[Sequence[str]], Running]
## @brief Waits for a number of seconds.
Sleep = Callable[[float], None]
def known_button_point(width: int, height: int) -> tuple[int, int]:
"""Return the click point of the known button inside the window.
@param width Window width in pixels (the button x is fixed).
@param height Window height in pixels.
@return X and Y relative to the window.
"""
return KNOWN_BUTTON_X, height - KNOWN_BUTTON_BOTTOM_OFFSET
def words_file_text(count: int) -> str:
"""Return the text of a vocabulary file with count cards.
@param count Number of cards.
@return A `ms;da` header and the lines `w1;t1`, `w2;t2`, ...
"""
lines = ["ms;da", *(f"w{i};t{i}" for i in range(1, count + 1))]
return "\n".join(lines) + "\n"
def parse_geometry(shell_output: str) -> tuple[int, int]:
"""Read the size from `xdotool getwindowgeometry --shell`.
@param shell_output The KEY=VALUE lines.
@return Width and height.
@throws ValueError When WIDTH or HEIGHT is missing.
"""
values: dict[str, int] = {}
for line in shell_output.splitlines():
key, _, value = line.partition("=")
if key in ("WIDTH", "HEIGHT") and value.strip().isdigit():
values[key] = int(value)
if "WIDTH" not in values or "HEIGHT" not in values:
raise ValueError("no WIDTH and HEIGHT in the geometry output")
return values["WIDTH"], values["HEIGHT"]
def progress_ok(rows: int, words: int) -> bool:
"""Tell whether the progress file shows exactly one card known.
@param rows Lines in the progress file, the header included.
@param words Cards at the start.
@return True when the file has the header and words - 1 cards.
"""
return rows == words
class _Process:
"""A real subprocess, stopped as a whole process group."""
def __init__(self, process: subprocess.Popen[bytes]) -> None:
self._process = process
def stop(self) -> None:
"""Terminate the process group, then kill it if it stays."""
try:
os.killpg(self._process.pid, signal.SIGTERM) # type: ignore[attr-defined,unused-ignore]
self._process.wait(timeout=3)
except (ProcessLookupError, subprocess.TimeoutExpired):
self._process.kill()
def real_run(args: Sequence[str]) -> str:
"""Run a command and return stdout, or "" when it fails."""
try:
done = subprocess.run(args, capture_output=True, text=True, check=False)
except OSError:
return ""
return done.stdout if done.returncode == 0 else ""
def real_start(command: Sequence[str]) -> Running:
"""Start the program in its own process group."""
return _Process(subprocess.Popen(command, start_new_session=True))
@dataclass(frozen=True)
class RunResult:
"""The outcome of one run."""
window: bool
clicked: tuple[int, int] | None
progress_rows: int
ok: bool
def _count_rows(path: Path) -> int:
if not path.exists():
return 0
return len(path.read_text(encoding="utf-8").splitlines())
def _wait_for_window(run: RunCommand, sleep: Sleep, timeout: float) -> str | None:
waited = 0.0
while True:
found = run(["xdotool", "search", "--name", WINDOW_NAME]).split()
if found:
return found[0]
if waited >= timeout:
return None
sleep(POLL_SECONDS)
waited += POLL_SECONDS
def _wait_for_progress(path: Path, sleep: Sleep) -> int:
waited = 0.0
rows = _count_rows(path)
while rows == 0 and waited < PROGRESS_TIMEOUT_SECONDS:
sleep(POLL_SECONDS)
waited += POLL_SECONDS
rows = _count_rows(path)
return rows
def run_once(
command: Sequence[str],
words: int,
timeout: float,
run: RunCommand,
start: StartProgram,
sleep: Sleep = time.sleep,
) -> RunResult:
"""Start the program, click the known button and check the progress file.
@param command The program command.
@param words Number of cards in the vocabulary.
@param timeout Seconds to wait for the window.
@param run Runs xdotool.
@param start Starts the program.
@param sleep Waits between polls.
@return The result of the run.
"""
folder = Path(tempfile.mkdtemp(prefix="flash-card-smoke-"))
data = folder / "data"
data.mkdir()
(data / VOCABULARY_NAME).write_text(words_file_text(words), encoding="utf-8")
argv = [*command, "--data-dir", str(data), "--level", "all", "--no-speech"]
program = start(argv)
try:
window = _wait_for_window(run, sleep, timeout)
if window is None:
return RunResult(False, None, 0, False)
width, height = parse_geometry(
run(["xdotool", "getwindowgeometry", "--shell", window])
)
x, y = known_button_point(width, height)
run(["xdotool", "mousemove", "--window", window, str(x), str(y), "click", "1"])
rows = _wait_for_progress(data / PROGRESS_NAME, sleep)
return RunResult(True, (x, y), rows, progress_ok(rows, words))
finally:
program.stop()
shutil.rmtree(folder, ignore_errors=True)
def main(
argv: Sequence[str] | None = None,
run: RunCommand = real_run,
start: StartProgram = real_start,
sleep: Sleep = time.sleep,
) -> int:
"""Run the driver.
@param argv Command line arguments, or None for `sys.argv`.
@param run Runs xdotool.
@param start Starts the program.
@param sleep Waits between polls.
@return 0 when every run is ok, otherwise 1.
"""
parser = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0])
parser.add_argument("--repeat", type=int, default=5)
parser.add_argument("--timeout", type=float, default=30.0)
parser.add_argument("--words", type=int, default=3)
parser.add_argument("--command", nargs=argparse.REMAINDER, required=True)
args = parser.parse_args(argv)
passed = 0
for index in range(1, args.repeat + 1):
began = time.monotonic()
result = run_once(args.command, args.words, args.timeout, run, start, sleep)
passed += result.ok
line = {
"run": index,
"window": result.window,
"clicked": list(result.clicked) if result.clicked else None,
"progress_rows": result.progress_rows,
"ok": result.ok,
"seconds": round(time.monotonic() - began, 2),
}
sys.stdout.write(json.dumps(line) + "\n")
summary = {"runs": args.repeat, "passed": passed}
sys.stdout.write(json.dumps(summary) + "\n")
return 0 if passed == args.repeat else 1
if __name__ == "__main__":
raise SystemExit(main())
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# Start a virtual desktop for the checks. Source this file; it must not be run.
#
# Purpose: give the checks a desktop of their own (a virtual X screen) so that
# no window opens on the host (ADR-0005).
# Usage: . tools/docker/start-desktop.sh
# Options: none
# Env in: DISPLAY (default :99), SCREEN_SIZE (default 1280x1024x24),
# SHELL_WAIT_SECONDS (seconds to wait for GNOME Shell, default 25)
# Env out: DISPLAY, DBUS_SESSION_BUS_ADDRESS, DESKTOP_KIND (gnome-shell or
# mutter), DESKTOP_LOG_DIR (logs and the file desktop-kind)
# Needs: bash 5, Xvfb, xdpyinfo, xprop, dbus-launch, gnome-shell, mutter
# Exit: returns 0 when a desktop runs; 1 when no window manager came up
#
# Steps: Xvfb; a private D-Bus session; GNOME Shell first, and GNOME's window
# manager (mutter) when the shell does not come up in a container (it needs a
# login manager that a container does not have). The kind that runs is printed.
export DISPLAY="${DISPLAY:-:99}"
screen_size="${SCREEN_SIZE:-1280x1024x24}"
shell_wait_seconds="${SHELL_WAIT_SECONDS:-25}"
DESKTOP_LOG_DIR="$(mktemp -d)"
export DESKTOP_LOG_DIR
trap 'rm -rf "${DESKTOP_LOG_DIR}"' EXIT
if ! xdpyinfo -display "${DISPLAY}" >/dev/null 2>&1; then
Xvfb "${DISPLAY}" -screen 0 "${screen_size}" -nolisten tcp >"${DESKTOP_LOG_DIR}/xvfb.log" 2>&1 &
for _ in $(seq 1 40); do
xdpyinfo -display "${DISPLAY}" >/dev/null 2>&1 && break
sleep 0.25
done
fi
if [[ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ]]; then
eval "$(dbus-launch --sh-syntax)"
export DBUS_SESSION_BUS_ADDRESS
fi
# Name of the window manager that owns the screen, or nothing.
wm_name() {
local check
check="$(xprop -root _NET_SUPPORTING_WM_CHECK 2>/dev/null | awk '{print $NF}')"
[[ -n "${check}" ]] || return 0
xprop -id "${check}" _NET_WM_NAME 2>/dev/null | sed -n 's/.*= "\(.*\)"/\1/p'
}
DESKTOP_KIND=""
if command -v gnome-shell >/dev/null 2>&1; then
gnome-shell --x11 --sm-disable >"${DESKTOP_LOG_DIR}/gnome-shell.log" 2>&1 &
gnome_pid=$!
for _ in $(seq 1 $((shell_wait_seconds * 2))); do
if kill -0 "${gnome_pid}" 2>/dev/null && wm_name | grep -qi "gnome"; then
DESKTOP_KIND="gnome-shell"
break
fi
sleep 0.5
done
if [[ -z "${DESKTOP_KIND}" ]]; then
kill "${gnome_pid}" 2>/dev/null || true
fi
fi
if [[ -z "${DESKTOP_KIND}" ]]; then
mutter --x11 --sm-disable >"${DESKTOP_LOG_DIR}/mutter.log" 2>&1 &
for _ in $(seq 1 40); do
if wm_name | grep -qi "mutter"; then
DESKTOP_KIND="mutter"
break
fi
sleep 0.25
done
fi
if [[ -z "${DESKTOP_KIND}" ]]; then
echo "error: no window manager came up on ${DISPLAY}" >&2
return 1
fi
export DESKTOP_KIND
echo "${DESKTOP_KIND}" >"${DESKTOP_LOG_DIR}/desktop-kind"
echo "desktop: ${DESKTOP_KIND} on ${DISPLAY}"