MIL-013: Debian container and Windows Sandbox checks

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

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