Plateau jouable en PWA : interface, Web Worker, stockage, service worker

L'application est à la racine du site : plateau en SVG, saisie au toucher
(pion puis case d'arrivée, proposées à partir du moteur), boutons du jet,
du videau et de nouvelle partie, coup de l'ordinateur affiché avec son
temps, fin de partie, reprise depuis localStorage, aide lue dans les
copies de docs/. Le moteur tourne dans le Web Worker existant ; main.ts
est le seul assembleur (nouvelles règles dependency-cruiser). Le service
worker met tout le site en cache, avec une empreinte du contenu comme
version. Les pages de test du moteur passent sous /moteur/. Politique de
sécurité du contenu dans le Caddyfile ; make test-browser joue une partie
dans Chromium et Firefox sans écran (Playwright, hors de make test).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-02 21:47:12 +01:00
co-authored by Claude Opus 5.5
parent 613ff38254
commit a8636b3f83
37 changed files with 2548 additions and 106 deletions
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Joue une partie complète par l'interface dans Chromium et Firefox sans
# écran (tests/browser/play.mjs), sur le site servi par l'image de production
# construite en local, comme tests/test_site.sh. Les captures d'écran vont
# dans build/browser-shots/.
#
# Outils : l'image de Playwright (navigateurs) et le paquet playwright,
# installé une fois par npm dans build/browser-tools/ (réseau nécessaire la
# première fois). Lent (plusieurs minutes) : lancé par make test-browser,
# pas par make test.
set -euo pipefail
cd "$(dirname "$0")/.."
image="${PLAYWRIGHT_IMAGE:?lancer via make test-browser}"
browsers="${BROWSERS:-chromium firefox}"
unset MAKEFLAGS MFLAGS MAKELEVEL
make -s package >/dev/null
site_image="ustagammon/site-test:local"
docker build -q -t "$site_image" build/deploy >/dev/null
container="$(docker run -d --rm -p 127.0.0.1::80 "$site_image")"
trap 'docker stop "$container" >/dev/null' EXIT
port="$(docker port "$container" 80/tcp | head -n1 | sed 's/.*://')"
# Le paquet playwright, à la version des navigateurs de l'image.
tools=build/browser-tools
version="${image##*:v}"
version="${version%%-*}"
if [ ! -f "$tools/node_modules/playwright/package.json" ]; then
mkdir -p "$tools"
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD/$tools:/tools" -w /tools \
"$image" npm install --no-audit --no-fund --save-exact "playwright@$version" >/dev/null
fi
rm -rf build/browser-shots
mkdir -p build/browser-shots
cp tests/browser/play.mjs "$tools/"
status=0
for browser in $browsers; do
# Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker
# demande un contexte sûr, ce qu'est localhost.
docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
node play.mjs "http://localhost:$port/" "$browser" /shots || status=1
done
exit "$status"
+215
View File
@@ -0,0 +1,215 @@
// play.mjs -- joue une partie complète par l'interface, dans un vrai
// navigateur sans écran (Playwright), sur le site servi par l'image de
// production : dés, coups au toucher, coups de l'ordinateur, videau, reprise
// après rechargement, aide, puis hors ligne (service worker).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Usage : node play.mjs <adresse du site> <chromium|firefox> <dossier des captures>
// Lancé par tests/browser.sh, dans l'image de Playwright. Sort en erreur au
// premier problème, ou si la page signale une erreur (dont une violation de
// la politique de sécurité du contenu).
import { chromium, firefox } from "playwright";
const [base, browserName, shots] = process.argv.slice(2);
const browserType = { chromium, firefox }[browserName];
if (!base || !browserType || !shots) {
console.error("usage : node play.mjs <adresse> <chromium|firefox> <dossier des captures>");
process.exit(2);
}
const browser = await browserType.launch();
const context = await browser.newContext({
viewport: { width: 360, height: 740 },
deviceScaleFactor: 2,
locale: "fr-FR",
});
const page = await context.newPage();
const problems = [];
page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`));
page.on("console", (message) => {
if (message.type() === "error") {
problems.push(`console : ${message.text()}`);
}
});
const log = (text) => console.log(`[${browserName}] ${text}`);
const shot = (name) => page.screenshot({ path: `${shots}/${browserName}-${name}.png` });
function check(condition, message) {
if (!condition) {
throw new Error(message);
}
}
/** Les coups joués de chaque camp, d'après le journal enregistré. */
async function savedJournal() {
return page.evaluate(() => JSON.parse(localStorage.getItem("ustagammon.current-game")));
}
function countMoves(journal, side) {
return journal.events.filter((e) => e.type === "MovePlayed" && e.side === side).length;
}
/**
* Attend que le joueur ait quelque chose à faire, et dit quoi. (Pas de
* page.waitForFunction : Playwright y passe par eval, que la politique de
* sécurité du site interdit, à raison.)
*/
async function waitForPlayer() {
for (let i = 0; i < 600; i++) {
const todo = await page.evaluate(() => {
if (document.body.classList.contains("busy")) {
return null;
}
const usable = (id) => {
const b = document.getElementById(id);
return !b.hidden && !b.disabled;
};
for (const id of ["again", "take", "roll"]) {
if (usable(id)) {
return id;
}
}
return document.querySelector(".hit.can-move") ? "move" : null;
});
if (todo !== null) {
return todo;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error("rien à faire pour le joueur depuis 60 s");
}
const start = Date.now();
await page.goto(base);
const first = await waitForPlayer();
log(`moteur chargé, partie prête en ${Date.now() - start} ms (${first})`);
await shot("1-debut");
let doubled = false;
let selectionShot = false;
let reloaded = false;
let computerMs = [];
for (let actions = 0; ; actions++) {
check(actions < 800, "la partie ne finit pas");
const todo = await waitForPlayer();
const info = await page.textContent("#info");
const timing = /\((\d+),(\d) s\)/.exec(info ?? "");
if (timing) {
computerMs.push(Number(timing[1]) * 1000 + Number(timing[2]) * 100);
}
if (todo === "again") {
break;
}
if (todo === "take") {
log("l'ordinateur double : vous prenez");
await shot("videau-propose");
await page.click("#take");
} else if (todo === "roll") {
if (!doubled && (await page.isEnabled("#double"))) {
doubled = true;
log("vous doublez");
await page.click("#double");
await waitForPlayer();
const answer = (await page.textContent("#info")) || (await page.textContent("#prompt"));
log(`réponse : ${answer}`);
continue;
}
await page.click("#roll");
} else {
await page.click(".hit.can-move");
await page.waitForSelector(".hit.can-land");
if (!selectionShot) {
selectionShot = true;
await shot("2-pion-touche");
}
await page.click(".hit.can-land");
}
// Une fois, au milieu de la partie : fermer et rouvrir la page.
const journal = await savedJournal();
if (!reloaded && countMoves(journal, "computer") >= 3 && countMoves(journal, "player") >= 3) {
reloaded = true;
await waitForPlayer();
await shot("3-milieu-de-partie");
const before = await savedJournal();
const pips = await page.textContent("#pips");
await page.reload();
await waitForPlayer();
const after = await savedJournal();
check(after.head === before.head, "la partie n'a pas été reprise après rechargement");
check((await page.textContent("#pips")) === pips, "plateau différent après rechargement");
log(`partie reprise après rechargement (événement ${after.head})`);
}
}
const journal = await savedJournal();
const result = await page.textContent("#prompt");
log(
`partie finie : ${result} (${countMoves(journal, "player")} coups du joueur, ` +
`${countMoves(journal, "computer")} de l'ordinateur, ${journal.events.length} événements)`,
);
check(countMoves(journal, "player") >= 3 && countMoves(journal, "computer") >= 3, "trop peu de coups");
check(reloaded, "la reprise après rechargement n'a pas été essayée");
if (computerMs.length > 0) {
log(`temps affichés des tours de l'ordinateur : max ${Math.max(...computerMs)} ms, ` +
`${computerMs.length} tours`);
}
await shot("4-fin-de-partie");
// Aide : scénario et glossaire lus dans les copies de docs/.
await page.click("#help-button");
await page.waitForSelector(".glossary dt");
const terms = await page.locator(".glossary dt").count();
const scenarios = await page.locator("#help-content h3").count();
check(terms >= 20 && scenarios >= 5, `aide incomplète : ${terms} termes, ${scenarios} scénarios`);
log(`aide : ${scenarios} scénarios, ${terms} termes du glossaire`);
await shot("5-aide");
await page.click("#help-back");
// Thème sombre.
await page.emulateMedia({ colorScheme: "dark" });
await page.click("#again");
await waitForPlayer();
await shot("6-sombre");
await page.emulateMedia({ colorScheme: "light" });
// Hors ligne : une fois le cache du service worker rempli, la page se
// recharge sans réseau, et le moteur répond.
const cached = await page.evaluate(async () => {
await navigator.serviceWorker.ready;
for (let i = 0; i < 600; i++) {
const names = await caches.keys();
if (names.length === 1) {
const cache = await caches.open(names[0]);
const keys = await cache.keys();
if (keys.some((r) => r.url.endsWith("/wasm/engine.data"))) {
return keys.length;
}
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
return 0;
});
check(cached > 0, "le service worker n'a pas mis le site en cache");
await context.setOffline(true);
await page.reload();
const offline = await waitForPlayer();
if (offline === "roll") {
await page.click("#roll");
await waitForPlayer();
}
await page.click("#help-button");
await page.waitForSelector(".glossary dt");
await page.click("#help-back");
log(`hors ligne : page rechargée depuis le cache (${cached} adresses), moteur et aide disponibles`);
await shot("7-hors-ligne");
await browser.close();
if (problems.length > 0) {
console.error(problems.join("\n"));
process.exit(1);
}
log("aucune erreur dans la page");
+51 -2
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env bash
# Vérifie l'image de production (deploy/) avec le site assemblé (make package) :
# construit l'image en local, la lance, et contrôle ce que reçoit un
# navigateur (pages, types MIME du moteur, compression, version).
# navigateur (application, pages de test du moteur, aide, manifeste, service
# worker, types MIME, compression, politique de sécurité, version).
set -euo pipefail
cd "$(dirname "$0")/.."
@@ -37,12 +38,60 @@ check() {
}
check "" "text/html"
check "bench.html" "text/html"
check "app.css" "text/css"
check "app/main.js" "text/javascript"
check "app/ui/app.js" "text/javascript"
check "app/domain/game/game.js" "text/javascript"
check "manifest.webmanifest" "application/manifest\+json"
check "icons/icon-192.png" "image/png"
check "icons/icon-512.png" "image/png"
check "sw.js" "text/javascript"
check "engine-worker.mjs" "text/javascript"
check "wasm/engine.mjs" "text/javascript"
check "wasm/engine.wasm" "application/wasm"
check "wasm/engine.data" "application/octet-stream"
check "version.json" "application/json"
# Pages de test du moteur (étape 0b).
check "moteur/" "text/html"
check "moteur/bench.html" "text/html"
check "moteur/engine-client.mjs" "text/javascript"
# Aide : copies du glossaire et des scénarios (ADR 0008).
check "docs/scenarios/jouer-une-partie.feature" "text/plain"
check "docs/glossaire/README.md" "text/markdown"
check "docs/glossaire/videau.md" "text/markdown"
cmp -s <(curl -s "$base/docs/glossaire/videau.md") docs/glossaire/videau.md \
|| report "/docs/glossaire/videau.md diffère de la source"
# Le manifeste désigne des icônes servies, et l'application à la racine.
manifest="$(curl -s "$base/manifest.webmanifest")"
grep -q '"start_url": "./"' <<<"$manifest" || report "manifeste : start_url absent"
while read -r icon; do
curl -sf -o /dev/null "$base/$icon" || report "manifeste : icône $icon introuvable"
done < <(grep -o '"src": "[^"]*"' <<<"$manifest" | sed 's/"src": "\(.*\)"/\1/')
# Le service worker met en cache chaque fichier du site, avec une version.
sw="$(curl -s "$base/sw.js")"
grep -qE '^const VERSION = "[0-9a-f]{16}";' <<<"$sw" || report "sw.js : version absente"
for file in ./ index.html app/main.js wasm/engine.data wasm/engine.wasm moteur/ \
docs/glossaire/videau.md version.json; do
grep -qF " \"$file\"," <<<"$sw" || report "sw.js : $file absent de la liste à mettre en cache"
done
listed="$(grep -c '^ "' <<<"$sw")"
present="$(find build/site -type f ! -name sw.js | wc -l)"
# Chaque fichier, plus « ./ » et « moteur/ » pour les deux index.html.
[ "$listed" -eq "$((present + 2))" ] \
|| report "sw.js liste $listed adresses pour $present fichiers (plus 2 dossiers)"
grep -o '^ "[^"]*"' <<<"$sw" | tr -d ' "' | while read -r file; do
curl -sf -o /dev/null "$base/$file" || echo "sw.js : $file introuvable sur le site"
done | grep . && report "sw.js liste des fichiers introuvables"
# Politique de sécurité du contenu : WebAssembly permis, rien en ligne.
csp="$(curl -s -D - -o /dev/null "$base/" | grep -i '^Content-Security-Policy:')"
grep -q "script-src 'self' 'wasm-unsafe-eval';" <<<"$csp" \
|| report "CSP absente ou sans 'wasm-unsafe-eval'"
grep -q "unsafe-inline" <<<"$csp" && report "CSP : unsafe-inline ne doit pas être permis"
curl -s -D - -o /dev/null "$base/engine-worker.mjs" | grep -qi '^Content-Security-Policy:' \
|| report "CSP absente du Web Worker du moteur"
curl -s -H "Accept-Encoding: gzip" -D - -o /dev/null "$base/wasm/engine.data" \
| grep -qi "^Content-Encoding: gzip" || report "/wasm/engine.data : pas de compression gzip"
+11 -2
View File
@@ -46,8 +46,13 @@ web app depcruise --config .dependency-cruiser.cjs src tests \
# doit être signalée par dependency-cruiser.
fixture=build/web-violations
rm -rf "$fixture"
mkdir -p "$fixture/src/domain" "$fixture/src/engine" "$fixture/src/ui"
mkdir -p "$fixture/src/domain" "$fixture/src/engine" "$fixture/src/ui" "$fixture/src/storage"
cp app/tsconfig.json "$fixture/"
echo 'export const main = 1;' >"$fixture/src/main.ts"
echo 'export const store = 1;' >"$fixture/src/storage/store.ts"
echo 'import { store } from "../storage/store.js"; export const f = store;' >"$fixture/src/ui/uses-storage.ts"
echo 'import { e } from "../ui/loop-b.js"; export const g = e;' >"$fixture/src/storage/uses-ui.ts"
echo 'import { main } from "../main.js"; export const h = main;' >"$fixture/src/ui/uses-main.ts"
echo 'import { engine } from "../engine/adapter.js"; export const a = engine;' >"$fixture/src/domain/uses-engine.ts"
echo 'import { readFileSync } from "node:fs"; export const b = readFileSync;' >"$fixture/src/domain/uses-node.ts"
echo 'export const engine = 1;' >"$fixture/src/engine/adapter.ts"
@@ -56,10 +61,14 @@ echo 'import { e } from "./loop-b.js"; export const d = e;' >"$fixture/src/ui/lo
echo 'import { d } from "./loop-a.js"; export const e = d;' >"$fixture/src/ui/loop-b.ts"
output="$(web "$fixture" depcruise --config /src/app/.dependency-cruiser.cjs src 2>&1)" \
&& report "dependency-cruiser accepte des violations volontaires"
for rule in domain-is-pure ui-does-not-call-engine no-circular; do
for rule in domain-is-pure ui-does-not-call-engine only-main-assembles nothing-imports-main no-circular; do
grep -q "$rule" <<<"$output" || report "violation volontaire de $rule non détectée"
done
grep -q 'uses-node' <<<"$output" || report "import de Node dans le domaine non détecté"
for file in uses-storage uses-ui; do
grep -q "only-main-assembles: src/[a-z]*/$file" <<<"$output" \
|| report "assemblage hors de main.ts non détecté ($file)"
done
rm -rf "$fixture"
[ "$errors" -eq 0 ]