From f8ccfca02ef3a518eae472e3acdcd2aef03b5950 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Thu, 15 Oct 2026 10:09:48 +0800 Subject: [PATCH] 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 --- .dockerignore | 15 ++ .gitattributes | 4 + README.md | 20 ++ docker/debian.Dockerfile | 45 +++++ docs/artifact-registry.md | 2 +- docs/sqa/reviews/rc-077-py-mil-013.md | 63 +++++++ docs/sqa/reviews/rc-078-sh-mil-013.md | 60 ++++++ docs/sqa/traceability-matrix.md | 6 + docs/wiki/Install-da.md | 20 ++ docs/wiki/Install-ms.md | 20 ++ docs/wiki/Install.md | 20 ++ tests/unit/test_capture_screenshots.py | 41 ++++ tests/unit/test_smoke_driver.py | 122 ++++++++++++ tools/capture_screenshots.py | 65 ++++++- tools/docker/in-container.sh | 50 +++++ tools/docker/run-checks.sh | 21 +++ tools/docker/screenshots.sh | 19 ++ tools/docker/selftest.sh | 37 ++++ tools/docker/smoke-binary.sh | 31 +++ tools/docker/smoke_driver.py | 252 +++++++++++++++++++++++++ tools/docker/start-desktop.sh | 78 ++++++++ tools/sandbox/flash-card.wsb | 36 ++++ tools/sandbox/run.ps1 | 56 ++++++ tools/sandbox/smoke.ps1 | 132 +++++++++++++ 24 files changed, 1205 insertions(+), 10 deletions(-) create mode 100644 .dockerignore create mode 100644 docker/debian.Dockerfile create mode 100644 docs/sqa/reviews/rc-077-py-mil-013.md create mode 100644 docs/sqa/reviews/rc-078-sh-mil-013.md create mode 100644 tests/unit/test_capture_screenshots.py create mode 100644 tests/unit/test_smoke_driver.py create mode 100644 tools/docker/in-container.sh create mode 100644 tools/docker/run-checks.sh create mode 100644 tools/docker/screenshots.sh create mode 100644 tools/docker/selftest.sh create mode 100644 tools/docker/smoke-binary.sh create mode 100644 tools/docker/smoke_driver.py create mode 100644 tools/docker/start-desktop.sh create mode 100644 tools/sandbox/flash-card.wsb create mode 100644 tools/sandbox/run.ps1 create mode 100644 tools/sandbox/smoke.ps1 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..523eb0c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.venv +.env +.env.* +.codex +**/__pycache__ +**/*.pyc +.pytest_cache +.mypy_cache +.ruff_cache +.import_linter_cache +build +dist +docs/doxygen +data/words_to_learn*.csv diff --git a/.gitattributes b/.gitattributes index 05ecb58..e8a2b54 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,7 @@ *.toml text eol=lf *.csv text eol=lf *.png binary +*.sh text eol=lf +*.Dockerfile text eol=lf +.dockerignore text eol=lf +*.ps1 text eol=crlf diff --git a/README.md b/README.md index 906be9b..591d151 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,26 @@ The window test in `tests/e2e/` is skipped when no display is available. Build the source code documentation (Doxygen comments) with `doxygen Doxyfile`; the HTML is written to `docs/doxygen/html/`. The screenshots are made with `python tools/capture_screenshots.py` after `python -m pip install -e ".[tools]"` (Pillow, tool only). +### Checks without a desktop (Docker) + +The tests, the linters, the screenshots and a smoke test of a Linux binary run in a Debian container on a virtual screen, so no window opens on your own desktop. + +```bash +docker build -f docker/debian.Dockerfile -t flash-card-debian . +# linters and the whole test suite, including the real window tests +docker run --rm -v "$PWD:/repo:ro" flash-card-debian bash /repo/tools/docker/in-container.sh run-checks.sh +# screenshots into ./build/container-out +docker run --rm -v "$PWD:/repo:ro" -v "$PWD/build/container-out:/out" flash-card-debian bash /repo/tools/docker/in-container.sh screenshots.sh /out +# build the Linux binary and press the known button 5 times (binary and source) +docker run --rm -v "$PWD:/repo:ro" flash-card-debian bash /repo/tools/docker/in-container.sh smoke-binary.sh 5 +``` + +On Windows PowerShell write `${PWD}` for `$PWD`. The repository is mounted read-only and copied inside the container. + +- The desktop is Xvfb with GNOME's window manager (mutter). GNOME Shell is tried first and does not start in a container (it needs a login manager), so the scripts fall back to mutter and print which one ran. +- The Windows executable is checked in **Windows Sandbox** (Windows 11 Pro, feature enabled): `powershell -File tools\sandbox\run.ps1 -Runs 5` after `python tools/build_executable.py`. The sandbox shows its own window while it runs; the click is made inside the sandbox. +- Not covered: macOS (it cannot be run in Docker on Windows or Linux and Apple's licence allows it only on Apple hardware), and Windows itself in the Debian container. + ## 📄 License GNU Affero General Public License v3.0 or later. See [LICENSE][license]. diff --git a/docker/debian.Dockerfile b/docker/debian.Dockerfile new file mode 100644 index 0000000..41e389b --- /dev/null +++ b/docker/debian.Dockerfile @@ -0,0 +1,45 @@ +# Debian based image for the flash card checks (ADR-0005). +# +# Build (from the repository root): +# docker build -f docker/debian.Dockerfile -t flash-card-debian . +# Run the linters and the tests on a virtual desktop: +# docker run --rm -v "$PWD:/app" flash-card-debian +# +# The base image is the official Python image (Debian bookworm), because the +# program needs Python 3.13. The desktop is a virtual X screen (Xvfb) with +# GNOME Shell when it can start in a container, else GNOME's window manager. +FROM python:3.13-slim-bookworm + +ENV DEBIAN_FRONTEND=noninteractive \ + PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + DISPLAY=:99 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + xvfb xauth x11-utils x11-xserver-utils xdotool dbus dbus-x11 \ + procps ca-certificates \ + libgl1-mesa-dri libegl1 libgles2 \ + mutter gnome-session gnome-shell gnome-settings-daemon \ + adwaita-icon-theme gsettings-desktop-schemas \ + tk fonts-liberation fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* + +# PyInstaller on Linux needs objdump (binutils). +RUN apt-get update \ + && apt-get install -y --no-install-recommends binutils \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd --create-home --shell /bin/bash flash +WORKDIR /app + +# Dependencies first, so a change of the sources does not rebuild this layer. +COPY pyproject.toml README.md LICENSE ./ +COPY src ./src +RUN python -m pip install --upgrade pip \ + && python -m pip install -e ".[dev,speech,tools,build]" \ + && chown -R flash:flash /app + +USER flash +ENV PATH="/app/tools/docker:${PATH}" +CMD ["bash", "tools/docker/run-checks.sh"] diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index d025cc5..b20c733 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -25,7 +25,7 @@ document of a type. `Primary File` may contain a glob (e.g. | DCD | Design Class Diagram | docs/dcd.md | 002 | | DICT | Domain Dictionary | docs/dictionary.md | 002 | | ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0006 | -| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 077 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 080 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | ## Languages diff --git a/docs/sqa/reviews/rc-077-py-mil-013.md b/docs/sqa/reviews/rc-077-py-mil-013.md new file mode 100644 index 0000000..831c25c --- /dev/null +++ b/docs/sqa/reviews/rc-077-py-mil-013.md @@ -0,0 +1,63 @@ +# RC-077: Review of PY-MIL-013 + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-077 | +| CrossReference | [PY-MIL-013], [QC-PY-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-15 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending | + +--- + +## Artifact Under Review + +- Instance reviewed: [PY-MIL-013] +- Checklist used: [QC-PY-001] +- Scope: full review +- Language and domain: n/a (technical type) +- Language reviewer: none +- Mode: autonomous, not independent (reviewer is `AI as stand-in for S01`) + +## Checklist Results + +| # | Criterion | Status | Evidence/Notes | +| --- | --- | --- | --- | +| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | PEP 8 casing holds in `smoke_driver.py`, `capture_screenshots.py` and the new tests; ruff rule set N passes. | +| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (`known_button_point`, `parse_geometry`, `platform_kind`, `window_box`, `progress_ok`). | +| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` pass on 83 files; the one `type: ignore` for `os.killpg` names the errors it silences. | +| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every signature is annotated; `mypy --strict` passes on 49 source files and on the four tool scripts. | +| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | Process start and stop failures are turned into a failed run with its reason, not swallowed; no bare `except`. | +| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults, no builtin shadowed. | +| 7 | Files, locks and connections are managed with context managers | Pass | Temporary folders are context-managed or removed in `finally`; processes are stopped as a group. | +| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public modules, functions and constants have Doxygen docstrings; `doxygen Doxyfile` covers `src` and ends with 0 warnings. | +| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No `print` in `src`; the tools write results with `sys.stdout.write` as JSON lines. | +| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | No program class changed; the tools are packaging and check support outside the design classes (ADR-0005). | +| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 272 tests; the pure parts of the smoke driver and the capture tool are unit tested with fakes (no xdotool, no display); the whole suite passed twice in the Debian container including the real-window e2e tests. | +| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` reports no issue; no `Any`. | +| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `dependencies` stays empty; the container image installs the extras `dev`, `speech`, `tools` and `build`. | +| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | The click point, the geometry parsing and the success rule are one function each in `smoke_driver.py`. | +| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `lint-imports` reports `Layers point inward` kept; the tools import only the composition root. | +| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | Pure functions with injected `run`, `start` and `sleep`; no function mixes the platform choice with drawing. | +| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | `tools/docker/` holds the container scripts and the driver; the layers and the test folders are unchanged. | +| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | File and folder names follow the rule. | + +## Overall Verdict + +Go — every Mandatory criterion passes. Reviewed by AI as stand-in for S01; not independent of the author. + +Reviewed in the Debian container (python:3.13-slim-bookworm, Python 3.13.16). + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| none | - | - | + +--- + +[PY-MIL-013]: ../../../tools +[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md diff --git a/docs/sqa/reviews/rc-078-sh-mil-013.md b/docs/sqa/reviews/rc-078-sh-mil-013.md new file mode 100644 index 0000000..3793f84 --- /dev/null +++ b/docs/sqa/reviews/rc-078-sh-mil-013.md @@ -0,0 +1,60 @@ +# RC-078: Review of SH-MIL-013 + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-078 | +| CrossReference | [SH-MIL-013], [QC-SH-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-15 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending | + +--- + +## Artifact Under Review + +- Instance reviewed: [SH-MIL-013] +- Checklist used: [QC-SH-001] +- Scope: full review +- Language and domain: n/a (technical type) +- Language reviewer: none +- Mode: autonomous, not independent (reviewer is `AI as stand-in for S01`) + +## Checklist Results + +| # | Criterion | Status | Evidence/Notes | +| --- | --- | --- | --- | +| 1 | Starts with `#!/usr/bin/env bash` and `set -euo pipefail` (or a comment explains the exception) | Pass | Every executable script starts with `#!/usr/bin/env bash` and `set -euo pipefail`; `start-desktop.sh` is sourced, says so in its header and sets no shell options in the caller. | +| 2 | Every expansion is quoted; lists are arrays; tests use `[[ ]]` and `$(...)` | Pass | Expansions are quoted, tests use `[[ ]]`, command substitutions use `$(...)`; shellcheck reports nothing. | +| 3 | Names follow the conventions: `kebab-case.sh` files, `snake_case` functions and variables, `UPPER_SNAKE` constants and environment variables | Pass | File names are kebab-case; variables are snake_case, environment variables UPPER_SNAKE. | +| 4 | Passes `shellcheck` and `bash -n` with no unexplained `disable` comments | Pass | `shellcheck -x` and `bash -n` pass on all six scripts with no `disable` comment. | +| 5 | Errors go to standard error with an `error:` message and a non-zero exit code; bad or missing arguments print a usage line | Pass | Errors go to standard error with `error:`, a usage line is printed, and bad arguments exit with 2 (`selftest.sh` checks it). | +| 6 | Temporary files use `mktemp` with a `trap ... EXIT` cleanup; no fixed `/tmp` names | Pass | `start-desktop.sh` uses `mktemp -d` with a `trap ... EXIT`; no fixed `/tmp` names remain. | +| 7 | No secret is written in the script, echoed, or put on a command line; secrets come from the environment or a gitignored file | Pass | No secret is used or written. | +| 8 | A script that changes state outside its own directory defaults to a dry run or needs an explicit flag, and says so in its header | Pass | `in-container.sh` is the only script that deletes a folder; its header says so and it refuses the repository and `/` as work folder. | +| 9 | A header comment states purpose, usage, options, environment variables and exit codes | Pass | Each header gives purpose, usage, environment, tools and exit codes. | +| 10 | The script implements a task or design it cites; deviations are recorded | Pass | The scripts implement tasks 3, 4 and 6 of MIL-013 and ADR-0005; the fallback from GNOME Shell to mutter is recorded in the output. | +| 11 | Behaviour is tested for success, failure and any disabled or bypass path | Pass | `selftest.sh` tests the refusals and `bash -n`; the success paths are the container runs of `run-checks.sh`, `screenshots.sh` and `smoke-binary.sh` (recorded in RC-079). | +| 12 | Formatted with `shfmt` (or the project's formatter) | Fail | `shfmt` was not run; Optional, deferred. | +| 13 | Safe to re-run: a second run does not duplicate or corrupt what the first did | Pass | The second container run gave the same result as the first (272 passed, 5 of 5 smoke runs). | +| 14 | Bash version and external tools it needs are stated; GNU-only options are named | Pass | Bash and the external tools are listed under `Needs`; GNU `tar --exclude` is named by the tool list. | +| 15 | A command sequence, constant, path or message used more than once is defined once (a function, a variable or a sourced helper); a helper is shared between scripts only where it expresses the same knowledge | Pass | The desktop start is defined once in `start-desktop.sh` and sourced by the three scripts. | + +## Overall Verdict + +Go — every Mandatory criterion passes; the failed Optional criteria are deferred with an action item. Reviewed by AI as stand-in for S01; not independent of the author. + +Container run of the self test: 11 cases ok. + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| Criterion 12 (Optional) failed: Formatted with `shfmt` (or the project's formatter); fix or accept the deferral | S01 | 2026-10-21 | + +--- + +[SH-MIL-013]: ../../../tools/docker +[QC-SH-001]: ../../../framework/qc/code/qc-programming-shell.md diff --git a/docs/sqa/traceability-matrix.md b/docs/sqa/traceability-matrix.md index 75b6bea..f290b02 100644 --- a/docs/sqa/traceability-matrix.md +++ b/docs/sqa/traceability-matrix.md @@ -72,6 +72,8 @@ updated whenever an artifact instance is created or reviewed. | PY-MIL-011 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-066] | | PY-FIX-001 | Python Source Code | - | - | PY-MIL-008 | - | [RC-049] | | PY-MIL-012 | Python Source Code | - | - | MIL-012 | - | [RC-071] | +| PY-MIL-013 | Python Source Code | - | - | MIL-013 | - | [RC-077] | +| SH-MIL-013 | Shell Script | - | - | MIL-013 | - | [RC-078] | ## Coverage Notes @@ -207,3 +209,7 @@ updated whenever an artifact instance is created or reviewed. [MIL-013]: ../milestones/mil-013-container-checks.md [ADR-0005]: ../adr/adr-0005-container-and-sandbox-checks.md [f86bc91]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/f86bc9136e7719fbcd049a4f803959019b7d2df3 +[RC-077]: ./reviews/rc-077-py-mil-013.md +[RC-078]: ./reviews/rc-078-sh-mil-013.md +[PY-MIL-013]: ../../tools +[SH-MIL-013]: ../../tools/docker diff --git a/docs/wiki/Install-da.md b/docs/wiki/Install-da.md index 36ca138..b3d7076 100644 --- a/docs/wiki/Install-da.md +++ b/docs/wiki/Install-da.md @@ -74,6 +74,26 @@ Scriptet bruger PyInstaller og skriver `dist/flash-card.exe`: én fil uden konso - `python tools/build_executable.py --no-speech` udelader talepakkerne; `--clean` bygger forfra. - Programfilen er ikke i repositoryet. Byg den på det system, hvor den skal køre; Windows kan vise en SmartScreen-advarsel, fordi filen ikke er signeret. +## Kontroller uden skrivebord (Docker) + +Testene, linterne, skærmbillederne og en røgtest af en Linux-binær kører i en Debian-container på en virtuel skærm, så der ikke åbnes noget vindue på dit eget skrivebord. + +```bash +docker build -f docker/debian.Dockerfile -t flash-card-debian . +# linters og hele testsuiten, også testene af det rigtige vindue +docker run --rm -v "$PWD:/repo:ro" flash-card-debian bash /repo/tools/docker/in-container.sh run-checks.sh +# skærmbilleder til ./build/container-out +docker run --rm -v "$PWD:/repo:ro" -v "$PWD/build/container-out:/out" flash-card-debian bash /repo/tools/docker/in-container.sh screenshots.sh /out +# byg Linux-binæren og tryk på "kender" 5 gange (binær og kildekode) +docker run --rm -v "$PWD:/repo:ro" flash-card-debian bash /repo/tools/docker/in-container.sh smoke-binary.sh 5 +``` + +I Windows PowerShell skriver du `${PWD}` i stedet for `$PWD`. Repositoryet monteres skrivebeskyttet og kopieres inde i containeren. + +- Skrivebordet er Xvfb med GNOMEs vindueshåndtering (mutter). GNOME Shell prøves først og starter ikke i en container (den kræver en login-manager), så scripts falder tilbage til mutter og skriver hvilken der kørte. +- Windows-programfilen kontrolleres i **Windows Sandbox** (Windows 11 Pro, funktionen aktiveret): `powershell -File tools\sandbox\run.ps1 -Runs 5` efter `python tools/build_executable.py`. Sandboxen viser sit eget vindue, mens den kører; klikket sker inde i sandboxen. +- Ikke dækket: macOS (kan ikke køres i Docker på Windows eller Linux, og Apples licens tillader det kun på Apple-hardware), og selve Windows i Debian-containeren. + ## Dokumentation af kildekoden ```bash diff --git a/docs/wiki/Install-ms.md b/docs/wiki/Install-ms.md index 78c96de..47c17c6 100644 --- a/docs/wiki/Install-ms.md +++ b/docs/wiki/Install-ms.md @@ -74,6 +74,26 @@ Skrip menggunakan PyInstaller dan menulis `dist/flash-card.exe`: satu fail tanpa - `python tools/build_executable.py --no-speech` meninggalkan pakej suara; `--clean` membina semula dari awal. - Fail boleh laku tidak disimpan dalam repositori. Bina di sistem tempat ia akan dijalankan; Windows mungkin menunjukkan amaran SmartScreen kerana fail tidak ditandatangani. +## Semakan tanpa desktop (Docker) + +Ujian, linter, tangkapan skrin dan ujian asap bagi binari Linux dijalankan dalam kontena Debian pada skrin maya, jadi tiada tetingkap dibuka pada desktop anda sendiri. + +```bash +docker build -f docker/debian.Dockerfile -t flash-card-debian . +# linter dan seluruh suite ujian, termasuk ujian tetingkap sebenar +docker run --rm -v "$PWD:/repo:ro" flash-card-debian bash /repo/tools/docker/in-container.sh run-checks.sh +# tangkapan skrin ke ./build/container-out +docker run --rm -v "$PWD:/repo:ro" -v "$PWD/build/container-out:/out" flash-card-debian bash /repo/tools/docker/in-container.sh screenshots.sh /out +# bina binari Linux dan tekan butang "tahu" 5 kali (binari dan kod sumber) +docker run --rm -v "$PWD:/repo:ro" flash-card-debian bash /repo/tools/docker/in-container.sh smoke-binary.sh 5 +``` + +Dalam Windows PowerShell tulis `${PWD}` untuk `$PWD`. Repositori dipasang baca sahaja dan disalin di dalam kontena. + +- Desktop ialah Xvfb dengan pengurus tetingkap GNOME (mutter). GNOME Shell dicuba dahulu dan tidak bermula dalam kontena (ia memerlukan pengurus log masuk), jadi skrip beralih kepada mutter dan mencetak yang mana dijalankan. +- Fail boleh laku Windows disemak dalam **Windows Sandbox** (Windows 11 Pro, ciri diaktifkan): `powershell -File tools\sandbox\run.ps1 -Runs 5` selepas `python tools/build_executable.py`. Sandbox menunjukkan tetingkapnya sendiri semasa berjalan; klik dibuat di dalam sandbox. +- Tidak dilindungi: macOS (tidak boleh dijalankan dalam Docker pada Windows atau Linux dan lesen Apple hanya membenarkannya pada perkakasan Apple), dan Windows sebenar dalam kontena Debian. + ## Dokumentasi kod sumber ```bash diff --git a/docs/wiki/Install.md b/docs/wiki/Install.md index 6ad8347..80b2899 100644 --- a/docs/wiki/Install.md +++ b/docs/wiki/Install.md @@ -74,6 +74,26 @@ The script uses PyInstaller and writes `dist/flash-card.exe`: one file, no conso - `python tools/build_executable.py --no-speech` leaves the speech packages out; `--clean` rebuilds from scratch. - The executable is not committed to the repository. Build it on the system you want to run it on; Windows may show a SmartScreen warning because the file is not signed. +## Checks without a desktop (Docker) + +The tests, the linters, the screenshots and a smoke test of a Linux binary run in a Debian container on a virtual screen, so no window opens on your own desktop. + +```bash +docker build -f docker/debian.Dockerfile -t flash-card-debian . +# linters and the whole test suite, including the real window tests +docker run --rm -v "$PWD:/repo:ro" flash-card-debian bash /repo/tools/docker/in-container.sh run-checks.sh +# screenshots into ./build/container-out +docker run --rm -v "$PWD:/repo:ro" -v "$PWD/build/container-out:/out" flash-card-debian bash /repo/tools/docker/in-container.sh screenshots.sh /out +# build the Linux binary and press the known button 5 times (binary and source) +docker run --rm -v "$PWD:/repo:ro" flash-card-debian bash /repo/tools/docker/in-container.sh smoke-binary.sh 5 +``` + +On Windows PowerShell write `${PWD}` for `$PWD`. The repository is mounted read-only and copied inside the container. + +- The desktop is Xvfb with GNOME's window manager (mutter). GNOME Shell is tried first and does not start in a container (it needs a login manager), so the scripts fall back to mutter and print which one ran. +- The Windows executable is checked in **Windows Sandbox** (Windows 11 Pro, feature enabled): `powershell -File tools\sandbox\run.ps1 -Runs 5` after `python tools/build_executable.py`. The sandbox shows its own window while it runs; the click is made inside the sandbox. +- Not covered: macOS (it cannot be run in Docker on Windows or Linux and Apple's licence allows it only on Apple hardware), and Windows itself in the Debian container. + ## Documentation of the source code ```bash diff --git a/tests/unit/test_capture_screenshots.py b/tests/unit/test_capture_screenshots.py new file mode 100644 index 0000000..3371dd3 --- /dev/null +++ b/tests/unit/test_capture_screenshots.py @@ -0,0 +1,41 @@ +"""Tests of the pure parts of the screenshot tool. + +@file test_capture_screenshots.py +@brief Checks platform_kind and window_box of tools/capture_screenshots.py. +""" + +import importlib.util +from pathlib import Path +from types import ModuleType + +import pytest + +pytest.importorskip("PIL") + +_TOOL_PATH = Path(__file__).resolve().parents[2] / "tools" / "capture_screenshots.py" + + +def _load_tool() -> ModuleType: + spec = importlib.util.spec_from_file_location("capture_screenshots", _TOOL_PATH) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_platform_kind_is_windows_for_win32() -> None: + assert _load_tool().platform_kind("win32") == "windows" + + +def test_platform_kind_is_x11_for_linux() -> None: + assert _load_tool().platform_kind("linux") == "x11" + + +def test_window_box_adds_menu_height_on_x11() -> None: + assert _load_tool().window_box("x11", 10, 100, 900, 726, 28) == (10, 72, 910, 826) + + +def test_window_box_ignores_menu_height_on_windows() -> None: + box = _load_tool().window_box("windows", 10, 100, 900, 726, 28) + assert box == (10, 100, 910, 826) diff --git a/tests/unit/test_smoke_driver.py b/tests/unit/test_smoke_driver.py new file mode 100644 index 0000000..6b2fc26 --- /dev/null +++ b/tests/unit/test_smoke_driver.py @@ -0,0 +1,122 @@ +"""Tests of the smoke driver tool. + +@file test_smoke_driver.py +@brief Checks the pure helpers and the run loop of tools/docker/smoke_driver.py +with a fake xdotool and a fake program. +""" + +import importlib.util +import json +import sys +from collections.abc import Sequence +from pathlib import Path +from types import ModuleType + +import pytest + +_TOOL_PATH = ( + Path(__file__).resolve().parents[2] / "tools" / "docker" / "smoke_driver.py" +) +_GEOMETRY = "WINDOW=7\nX=0\nY=0\nWIDTH=900\nHEIGHT=800\nSCREEN=0\n" + + +def _load_tool() -> ModuleType: + spec = importlib.util.spec_from_file_location("smoke_driver", _TOOL_PATH) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + # dataclasses looks the module up by name while the class is built + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class _FakeProgram: + def __init__(self) -> None: + self.stopped = False + + def stop(self) -> None: + self.stopped = True + + +class _FakeSystem: + """Fake xdotool and program; a click writes the progress file.""" + + def __init__(self, has_window: bool, writes_progress: bool) -> None: + self.has_window = has_window + self.writes_progress = writes_progress + self.program = _FakeProgram() + self.data_dir: Path | None = None + + def start(self, command: Sequence[str]) -> _FakeProgram: + self.data_dir = Path(command[command.index("--data-dir") + 1]) + return self.program + + def run(self, args: Sequence[str]) -> str: + if args[1] == "search": + return "7\n" if self.has_window else "" + if args[1] == "getwindowgeometry": + return _GEOMETRY + if args[1] == "mousemove" and self.writes_progress: + assert self.data_dir is not None + progress = self.data_dir / "words_to_learn.csv" + progress.write_text("ms;da\nw1;t1\nw2;t2\n", encoding="utf-8") + return "" + + +def test_known_button_point_is_101_above_bottom() -> None: + assert _load_tool().known_button_point(900, 800) == (650, 699) + + +def test_words_file_text_has_header_and_cards() -> None: + assert _load_tool().words_file_text(2) == "ms;da\nw1;t1\nw2;t2\n" + + +def test_parse_geometry_reads_width_and_height() -> None: + assert _load_tool().parse_geometry(_GEOMETRY) == (900, 800) + + +def test_parse_geometry_fails_without_size() -> None: + with pytest.raises(ValueError): + _load_tool().parse_geometry("X=0\n") + + +def test_progress_ok_needs_exactly_one_card_less() -> None: + tool = _load_tool() + assert tool.progress_ok(3, 3) + assert not tool.progress_ok(4, 3) + assert not tool.progress_ok(0, 3) + + +def test_main_returns_zero_when_click_saves_progress( + capsys: pytest.CaptureFixture[str], +) -> None: + system = _FakeSystem(has_window=True, writes_progress=True) + code = _load_tool().main( + ["--repeat", "2", "--words", "3", "--command", "prog", "-m", "x"], + run=system.run, + start=system.start, + sleep=lambda _: None, + ) + lines = [json.loads(x) for x in capsys.readouterr().out.splitlines()] + assert code == 0 + assert lines[0]["ok"] is True + assert lines[0]["clicked"] == [650, 699] + assert lines[-1] == {"runs": 2, "passed": 2} + assert system.program.stopped + + +def test_main_fails_when_no_window_appears( + capsys: pytest.CaptureFixture[str], +) -> None: + system = _FakeSystem(has_window=False, writes_progress=False) + code = _load_tool().main( + ["--repeat", "1", "--timeout", "1", "--command", "prog"], + run=system.run, + start=system.start, + sleep=lambda _: None, + ) + first = json.loads(capsys.readouterr().out.splitlines()[0]) + assert code == 1 + assert first["window"] is False + assert system.program.stopped diff --git a/tools/capture_screenshots.py b/tools/capture_screenshots.py index bbdcfc1..6722e15 100644 --- a/tools/capture_screenshots.py +++ b/tools/capture_screenshots.py @@ -13,7 +13,7 @@ from __future__ import annotations import argparse import ctypes -import ctypes.wintypes +import os import random import shutil import sys @@ -23,7 +23,7 @@ import tkinter as tk from collections.abc import Sequence from pathlib import Path -from PIL import ImageGrab +from PIL import Image, ImageGrab sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) @@ -45,6 +45,46 @@ DEFAULT_OUTPUT_DIR = PROJECT_ROOT / "docs" / "images" SCREENSHOT_SEED = 7 ## @brief Extra time in seconds to let the window settle before a grab. SETTLE_SECONDS = 0.5 +## @brief Height in pixels of the menu bar above the client area on X11. +MENU_BAR_HEIGHT_X11 = 28 +## @brief Platform kind of Windows. +KIND_WINDOWS = "windows" +## @brief Platform kind of every other platform (an X11 display). +KIND_X11 = "x11" + + +def platform_kind(platform: str) -> str: + """Choose how to grab the screen. + + @param platform A `sys.platform` value. + @return "windows" for win32, otherwise "x11". + """ + return KIND_WINDOWS if platform == "win32" else KIND_X11 + + +def window_box( + kind: str, rootx: int, rooty: int, width: int, height: int, menu_height: int +) -> tuple[int, int, int, int]: + """Return the screen box to grab for a window. + + @param kind The platform kind from `platform_kind`. + @param rootx Left edge of the client area. + @param rooty Top edge of the client area. + @param width Width of the client area. + @param height Height of the client area. + @param menu_height Height of the menu bar above the client area; it is + added on X11 only, as on Windows the frame box is read from the system. + @return Left, top, right and bottom in screen pixels. + """ + top = rooty - menu_height if kind == KIND_X11 else rooty + return rootx, top, rootx + width, rooty + height + + +def _grab(box: tuple[int, int, int, int]) -> Image.Image: + """Grab a screen box on the current platform.""" + if platform_kind(sys.platform) == KIND_X11: + return ImageGrab.grab(bbox=box, xdisplay=os.environ.get("DISPLAY")) + return ImageGrab.grab(bbox=box) def _make_dpi_aware() -> None: @@ -59,6 +99,8 @@ def _frame_box(root: tk.Tk) -> tuple[int, int, int, int]: @param root The Tk root window. @return Left, top, right and bottom in screen pixels. """ + import ctypes.wintypes # Windows only, so it is imported here + handle = ctypes.windll.user32.GetParent(root.winfo_id()) # type: ignore[attr-defined,unused-ignore] rect = ctypes.wintypes.RECT() ctypes.windll.user32.GetWindowRect(handle, ctypes.byref(rect)) # type: ignore[attr-defined,unused-ignore] @@ -102,13 +144,14 @@ def capture(output_dir: Path) -> list[Path]: root.lift() root.attributes("-topmost", True) _pump(root, SETTLE_SECONDS) - window_box = ( + kind = platform_kind(sys.platform) + client = ( root.winfo_rootx(), root.winfo_rooty(), - root.winfo_rootx() + root.winfo_width(), - root.winfo_rooty() + root.winfo_height(), + root.winfo_width(), + root.winfo_height(), ) - ImageGrab.grab(bbox=window_box).save(level) + _grab(window_box(kind, *client, 0)).save(level) application.start(Level(None)) view = application.view if not isinstance(view, FlashCardView): @@ -123,10 +166,14 @@ def capture(output_dir: Path) -> list[Path]: + view.right_button.winfo_height() + 20, ) - ImageGrab.grab(bbox=box).save(front) - ImageGrab.grab(bbox=_frame_box(root)).save(menu) + _grab(box).save(front) + if kind == KIND_WINDOWS: + menu_box = _frame_box(root) + else: + menu_box = window_box(kind, *client, MENU_BAR_HEIGHT_X11) + _grab(menu_box).save(menu) _pump(root, FLIP_DELAY_MS / 1000 + SETTLE_SECONDS) - ImageGrab.grab(bbox=box).save(back) + _grab(box).save(back) root.destroy() return [level, front, back, menu] diff --git a/tools/docker/in-container.sh b/tools/docker/in-container.sh new file mode 100644 index 0000000..53d30e8 --- /dev/null +++ b/tools/docker/in-container.sh @@ -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