MIL-013: Debian container and Windows Sandbox checks #107
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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].
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# 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 | [f8ccfca] |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
[f8ccfca]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/f8ccfca02ef3a518eae472e3acdcd2aef03b5950
|
||||
@@ -0,0 +1,61 @@
|
||||
# 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 | [f8ccfca] |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
[f8ccfca]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/f8ccfca02ef3a518eae472e3acdcd2aef03b5950
|
||||
@@ -0,0 +1,54 @@
|
||||
# RC-079: Review of the container checks
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-079 |
|
||||
| CrossReference | [MIL-013] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-15 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [2f192b6] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [MIL-013] (the milestone's Go / No-Go criteria, checked against the container and sandbox checks)
|
||||
- Checklist used: none exists for this result; the ten criteria of MIL-013 are the checklist
|
||||
- Scope: full review
|
||||
- Language and domain: n/a (record of results)
|
||||
- Language reviewer: none
|
||||
- Mode: autonomous, not independent (reviewer is `AI as stand-in for S01`)
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | `docker build` of the Debian image succeeds | Pass | Image `flash-card-debian` built from `docker/debian.Dockerfile` (base `python:3.13-slim-bookworm`, Python 3.13.16); size 1697 MB; `binutils` was missing at first and was added after the first smoke build failed |
|
||||
| 2 | Linters and the whole test suite run on the virtual desktop and end with exit code 0, e2e tests not skipped | Pass | `run-checks.sh` twice: ruff, ruff format, mypy (49 files), lint-imports (1 kept) and 272 tests passed in 3.5 s; 33 s per run |
|
||||
| 3 | The full GNOME session is tried and the result recorded; fallback to mutter stated | Pass | `gnome-shell --x11` starts and then fails with `Gio.IOErrorEnum: Could not connect` in `LoginManagerSystemd` (no login manager in a container); the checks ran on GNOME's window manager mutter 43.8, which the script prints (`desktop: mutter on :99`) |
|
||||
| 4 | Nothing opens or moves on the host desktop during the runs | Pass | All runs of this record were `docker run` commands with no display; note: earlier unit runs of the test suite on the host by a helper agent opened Tk windows briefly before the Product Owner's instruction; from then on the suite ran only in the container |
|
||||
| 5 | The screenshot script writes the four PNG files from the Debian image | Pass | `screenshot-level.png`, `-front.png`, `-back.png`, `-menu.png` (20 to 23 KB each) written to the mounted folder and read by eye: the level screen, the card with the menu bar (File, About) are right |
|
||||
| 6 | The Linux binary is built and passes the smoke test 5 consecutive runs, and the source program too | Pass | `smoke-binary.sh 5` twice: the PyInstaller binary 5 of 5 (0.64 s per run) and `python -m flash_card` 5 of 5 (0.38 s); each run: window found, click at the known button (650, 659), progress file has header plus 2 cards |
|
||||
| 7 | The Windows executable passes the smoke test inside Windows Sandbox, 5 consecutive runs | Fail | Not run. `tools/sandbox/` (`flash-card.wsb`, `smoke.ps1`, `run.ps1`) is written, the PowerShell parses without errors and the configuration is well-formed XML, but starting Windows Sandbox opens a window on the host, and the Product Owner asked that no app runs on the development machine while working. The run waits for the Product Owner's go |
|
||||
| 8 | The lost-click symptom of RC-072 is investigated and the finding recorded | Pass | 10 of 10 presses of the known button registered in the container (binary and source). The symptom was seen only with the simulated Windows mouse (about 3 of 8 reruns) and is most likely a timing problem of that click automation (cursor move, focus, button down and up) and not a program defect; this is a finding for Linux only and it is not proven for Windows until criterion 7 is run |
|
||||
| 9 | README and the wiki Install page in three languages describe the commands and the limits; `check-readme.sh` exit 0 | Pass | Sections added; `check-readme.sh` exit 0; macOS stated as not covered, the Debian check stated as not a Windows check, the sandbox requirements stated |
|
||||
| 10 | BC-001, PP-001 and ADR-0005 are reviewed Go; code review has no failed Mandatory criterion | Pass | RC-073 to RC-076; RC-077 (Python) and RC-078 (shell) are Go |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go-with-conditions — everything that can run without the host desktop runs and passes: the Debian checks (272 tests, linters), the screenshots, and the smoke test of the Linux binary and the source program. Criterion 7 (the Windows executable in Windows Sandbox) is not run and keeps the issue "Run and record the checks" open. Reviewed by AI as stand-in for S01; not independent of the author.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| Run `powershell -File tools\sandbox\run.ps1 -Runs 5` after `python tools/build_executable.py` at a time that suits the Product Owner (Windows Sandbox shows its own window), and record the result in a new review record | S01 | 2026-10-21 |
|
||||
| Decide whether the GNOME Shell start in the container is worth a login-manager stub (it is not needed for the checks) | S01 | 2026-10-21 |
|
||||
|
||||
---
|
||||
|
||||
[MIL-013]: ../milestones/mil-013-container-checks.md
|
||||
[2f192b6]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/2f192b6936a92f85c6c200de3cac3d6cf3572903
|
||||
@@ -58,7 +58,7 @@ updated whenever an artifact instance is created or reviewed.
|
||||
| MIL-010 | Milestone / Gateway | en | it | BC-001 | - | [RC-054], [RC-055] |
|
||||
| MIL-011 | Milestone / Gateway | en | it | BC-001 | - | [RC-065] |
|
||||
| MIL-012 | Milestone / Gateway | en | it | BC-001 | - | [RC-070], [RC-072] |
|
||||
| MIL-013 | Milestone / Gateway | en | it | BC-001 | - | [RC-076] |
|
||||
| MIL-013 | Milestone / Gateway | en | it | BC-001 | - | [RC-076], [RC-079] |
|
||||
| ADR-0005 | Architecture Decision Record | - | - | BC-001 | - | [RC-075] |
|
||||
| PY-MIL-001 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-023] |
|
||||
| PY-MIL-002 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-024] |
|
||||
@@ -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,8 @@ 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
|
||||
[RC-079]: ./reviews/rc-079-container-checks.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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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}"
|
||||
@@ -0,0 +1,36 @@
|
||||
<!--
|
||||
Windows Sandbox configuration of the flash card smoke test (ADR-0005).
|
||||
Do not open this file directly: tools/sandbox/run.ps1 replaces the three
|
||||
@...@ placeholders with absolute host paths and starts the sandbox.
|
||||
|
||||
dist read-only the built flash-card.exe
|
||||
scripts read-only smoke.ps1, which runs inside the sandbox
|
||||
out read-write result.json and screenshots written by smoke.ps1
|
||||
-->
|
||||
<Configuration>
|
||||
<VGpu>Disable</VGpu>
|
||||
<Networking>Disable</Networking>
|
||||
<AudioInput>Disable</AudioInput>
|
||||
<VideoInput>Disable</VideoInput>
|
||||
<ClipboardRedirection>Disable</ClipboardRedirection>
|
||||
<MappedFolders>
|
||||
<MappedFolder>
|
||||
<HostFolder>@DIST@</HostFolder>
|
||||
<SandboxFolder>C:\fc\dist</SandboxFolder>
|
||||
<ReadOnly>true</ReadOnly>
|
||||
</MappedFolder>
|
||||
<MappedFolder>
|
||||
<HostFolder>@SCRIPTS@</HostFolder>
|
||||
<SandboxFolder>C:\fc\scripts</SandboxFolder>
|
||||
<ReadOnly>true</ReadOnly>
|
||||
</MappedFolder>
|
||||
<MappedFolder>
|
||||
<HostFolder>@OUT@</HostFolder>
|
||||
<SandboxFolder>C:\fc\out</SandboxFolder>
|
||||
<ReadOnly>false</ReadOnly>
|
||||
</MappedFolder>
|
||||
</MappedFolders>
|
||||
<LogonCommand>
|
||||
<Command>powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\fc\scripts\smoke.ps1 -Runs @RUNS@</Command>
|
||||
</LogonCommand>
|
||||
</Configuration>
|
||||
@@ -0,0 +1,56 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Start the Windows Sandbox smoke test of dist\flash-card.exe from the host.
|
||||
|
||||
.DESCRIPTION
|
||||
Writes a configuration from flash-card.wsb with absolute paths, starts
|
||||
Windows Sandbox, waits for out\result.json and prints the summary.
|
||||
The tests themselves (window, click, progress file) run inside the sandbox
|
||||
with the sandbox's own mouse; the host's mouse and focus are not used.
|
||||
Windows Sandbox shows its own window while it runs. Needs Windows 11 Pro
|
||||
with the feature "Windows Sandbox" enabled.
|
||||
|
||||
.EXAMPLE
|
||||
powershell -File tools\sandbox\run.ps1 -Runs 5
|
||||
#>
|
||||
param(
|
||||
[int]$Runs = 5,
|
||||
[int]$TimeoutMinutes = 15
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
|
||||
$dist = Join-Path $root 'dist'
|
||||
$scripts = $PSScriptRoot
|
||||
$out = Join-Path $root 'build\sandbox-out'
|
||||
|
||||
if (-not (Test-Path (Join-Path $dist 'flash-card.exe'))) {
|
||||
throw "dist\flash-card.exe not found: build it first with python tools\build_executable.py"
|
||||
}
|
||||
if (-not (Get-Command WindowsSandbox.exe -ErrorAction SilentlyContinue)) {
|
||||
throw "WindowsSandbox.exe not found: enable the Windows feature 'Windows Sandbox' (Windows 11 Pro)"
|
||||
}
|
||||
if (Get-Process -Name 'WindowsSandbox*' -ErrorAction SilentlyContinue) {
|
||||
throw "A Windows Sandbox is already running: close it first (only one can run at a time)"
|
||||
}
|
||||
|
||||
Remove-Item $out -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Path $out -Force | Out-Null
|
||||
|
||||
$config = (Get-Content (Join-Path $scripts 'flash-card.wsb') -Raw) `
|
||||
-replace '@DIST@', $dist -replace '@SCRIPTS@', $scripts -replace '@OUT@', $out -replace '@RUNS@', $Runs
|
||||
$wsb = Join-Path $out 'run.wsb'
|
||||
Set-Content -Path $wsb -Value $config -Encoding UTF8
|
||||
|
||||
Write-Host "Starting Windows Sandbox ($Runs runs); results in $out"
|
||||
Start-Process -FilePath WindowsSandbox.exe -ArgumentList "`"$wsb`""
|
||||
|
||||
$result = Join-Path $out 'result.json'
|
||||
$end = (Get-Date).AddMinutes($TimeoutMinutes)
|
||||
while ((Get-Date) -lt $end -and -not (Test-Path $result)) { Start-Sleep -Seconds 5 }
|
||||
if (-not (Test-Path $result)) { throw "No result.json after $TimeoutMinutes minutes" }
|
||||
Start-Sleep -Seconds 3
|
||||
$summary = Get-Content $result -Raw | ConvertFrom-Json
|
||||
Write-Host ("passed {0} of {1} runs in {2}" -f $summary.passed, $summary.runs, $summary.os)
|
||||
$summary.results | Format-Table run, window, clicked, progress_rows, ok, seconds
|
||||
if ($summary.passed -ne $summary.runs) { exit 1 }
|
||||
@@ -0,0 +1,132 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Smoke test of flash-card.exe, run INSIDE Windows Sandbox (ADR-0005).
|
||||
|
||||
.DESCRIPTION
|
||||
Starts the executable on the sandbox's own desktop, presses the known button
|
||||
with the sandbox's own mouse, reads the progress file, takes screenshots and
|
||||
writes result.json to C:\fc\out. Then it shuts the sandbox down.
|
||||
It never touches the host: the host sees only the mapped folders.
|
||||
#>
|
||||
param(
|
||||
[int]$Runs = 5,
|
||||
[string]$Exe = 'C:\fc\dist\flash-card.exe',
|
||||
[string]$Out = 'C:\fc\out'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
Add-Type @'
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Runtime.InteropServices;
|
||||
public static class Win {
|
||||
public delegate bool EnumProc(IntPtr h, IntPtr l);
|
||||
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc p, IntPtr l);
|
||||
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr h, StringBuilder s, int n);
|
||||
[DllImport("user32.dll")] public static extern bool GetClientRect(IntPtr h, out RECT r);
|
||||
[DllImport("user32.dll")] public static extern bool ClientToScreen(IntPtr h, ref POINT p);
|
||||
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
||||
[DllImport("user32.dll")] public static extern void mouse_event(uint f, uint x, uint y, uint d, UIntPtr e);
|
||||
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
||||
[DllImport("shcore.dll")] public static extern int SetProcessDpiAwareness(int v);
|
||||
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
|
||||
[StructLayout(LayoutKind.Sequential)] public struct POINT { public int X, Y; }
|
||||
public static IntPtr Find(string title) {
|
||||
IntPtr found = IntPtr.Zero;
|
||||
EnumWindows(delegate (IntPtr h, IntPtr l) {
|
||||
if (IsWindowVisible(h)) {
|
||||
var sb = new StringBuilder(256);
|
||||
GetWindowText(h, sb, 256);
|
||||
if (sb.ToString() == title) { found = h; return false; }
|
||||
}
|
||||
return true;
|
||||
}, IntPtr.Zero);
|
||||
return found;
|
||||
}
|
||||
}
|
||||
'@
|
||||
[void][Win]::SetProcessDpiAwareness(2)
|
||||
|
||||
function Wait-Window([int]$Seconds) {
|
||||
$end = (Get-Date).AddSeconds($Seconds)
|
||||
while ((Get-Date) -lt $end) {
|
||||
$h = [Win]::Find('Flashy')
|
||||
if ($h -ne [IntPtr]::Zero) { return $h }
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
return [IntPtr]::Zero
|
||||
}
|
||||
|
||||
function Save-Screen([string]$Path) {
|
||||
$b = [System.Windows.Forms.SystemInformation]::VirtualScreen
|
||||
$bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height
|
||||
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
||||
$g.CopyFromScreen($b.Left, $b.Top, 0, 0, $bmp.Size)
|
||||
$bmp.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
$g.Dispose(); $bmp.Dispose()
|
||||
}
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
|
||||
$work = Join-Path $env:TEMP 'fc-smoke'
|
||||
$results = @()
|
||||
for ($n = 1; $n -le $Runs; $n++) {
|
||||
$run = [ordered]@{ run = $n; window = $false; clicked = $false; progress_rows = $null; ok = $false; seconds = 0 }
|
||||
$t0 = Get-Date
|
||||
$dir = Join-Path $work "run$n"
|
||||
Remove-Item $dir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Path (Join-Path $dir 'data') -Force | Out-Null
|
||||
Copy-Item $Exe (Join-Path $dir 'flash-card.exe')
|
||||
# three words: after one known word the progress file has a header and two cards
|
||||
[IO.File]::WriteAllText((Join-Path $dir 'data\ms-da.csv'), "ms;da`naaa;bbb`nccc;ddd`neee;fff`n", (New-Object Text.UTF8Encoding $false))
|
||||
$p = Start-Process -FilePath (Join-Path $dir 'flash-card.exe') -ArgumentList '--level', 'all', '--no-speech' -WorkingDirectory $dir -PassThru
|
||||
try {
|
||||
$h = Wait-Window 60
|
||||
if ($h -ne [IntPtr]::Zero) {
|
||||
$run.window = $true
|
||||
Start-Sleep -Milliseconds 1500
|
||||
if ($n -eq 1) { Save-Screen (Join-Path $Out 'sandbox-card.png') }
|
||||
$r = New-Object Win+RECT
|
||||
[void][Win]::GetClientRect($h, [ref]$r)
|
||||
$pt = New-Object Win+POINT
|
||||
[void][Win]::ClientToScreen($h, [ref]$pt)
|
||||
$x = $pt.X + 650
|
||||
$y = $pt.Y + $r.Bottom - 101
|
||||
[void][Win]::SetForegroundWindow($h)
|
||||
Start-Sleep -Milliseconds 400
|
||||
[void][Win]::SetCursorPos($x - 12, $y - 12)
|
||||
Start-Sleep -Milliseconds 300
|
||||
[void][Win]::SetCursorPos($x, $y)
|
||||
Start-Sleep -Milliseconds 400
|
||||
[Win]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero)
|
||||
Start-Sleep -Milliseconds 120
|
||||
[Win]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero)
|
||||
$run.clicked = $true
|
||||
$prog = Join-Path $dir 'data\words_to_learn.csv'
|
||||
$end = (Get-Date).AddSeconds(6)
|
||||
while ((Get-Date) -lt $end -and -not (Test-Path $prog)) { Start-Sleep -Milliseconds 250 }
|
||||
if (Test-Path $prog) {
|
||||
$rows = ([IO.File]::ReadAllLines($prog) | Where-Object { $_.Trim() -ne '' }).Count
|
||||
$run.progress_rows = $rows
|
||||
$run.ok = ($rows -eq 3)
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if (-not $p.HasExited) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue }
|
||||
Get-Process -Name 'flash-card' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
$run.seconds = [math]::Round(((Get-Date) - $t0).TotalSeconds, 1)
|
||||
$results += [pscustomobject]$run
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
|
||||
$summary = [ordered]@{
|
||||
runs = $Runs
|
||||
passed = ($results | Where-Object { $_.ok }).Count
|
||||
os = (Get-CimInstance Win32_OperatingSystem).Caption
|
||||
results = $results
|
||||
}
|
||||
$summary | ConvertTo-Json -Depth 5 | Set-Content -Path (Join-Path $Out 'result.json') -Encoding UTF8
|
||||
shutdown.exe /s /t 5
|
||||
Reference in New Issue
Block a user