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,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())
|
||||
Reference in New Issue
Block a user