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:
@@ -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