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:
@@ -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}" "$@"
|
||||
@@ -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}"
|
||||
@@ -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}"
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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}"
|
||||
Reference in New Issue
Block a user