// 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 // 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 "); process.exit(2); } const browser = await browserType.launch(); const context = await browser.newContext({ viewport: { width: 360, height: 740 }, deviceScaleFactor: 2, locale: "fr-FR", }); // Pour le diagnostic d'un blocage : la dernière commande envoyée au moteur // (Web Worker), et si elle a reçu sa réponse ; et, pour vérifier la // politique de sécurité du contenu, chaque violation signalée par la page. await context.addInitScript(() => { window.__engine = { sent: 0, received: 0, last: null }; const post = Worker.prototype.postMessage; Worker.prototype.postMessage = function (message, ...rest) { window.__engine.sent++; window.__engine.last = message?.args ?? null; if (!this.__counted) { this.__counted = true; this.addEventListener("message", () => window.__engine.received++); } return post.call(this, message, ...rest); }; window.__csp = []; document.addEventListener("securitypolicyviolation", (e) => window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`), ); }); const page = await context.newPage(); const problems = []; page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`)); page.on("crash", () => { console.error(`[${browserName}] la page a planté`); process.exit(1); }); // Vrai pendant le contrôle final de la politique de sécurité, qui provoque // exprès une violation. let cspProbe = false; page.on("console", (message) => { if (message.type() === "error" && !(cspProbe && /Content.Security.Policy/i.test(message.text()))) { problems.push(`console : ${message.text()}`); } }); const log = (text) => console.log(`[${browserName}] ${text}`); const shot = (name) => page.screenshot({ path: `${shots}/${browserName}-${name}.png` }); /** Relève les violations de la CSP signalées par la page (avant chaque * rechargement, qui les efface). */ const violations = []; async function collectViolations() { violations.push(...(await page.evaluate(() => window.__csp))); } 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", "skip", "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)); } await shot("bloque").catch(() => {}); const screen = await page .evaluate(() => { const text = (id) => document.getElementById(id)?.textContent; const busy = document.body.classList.contains("busy") ? "occupé" : "libre"; const engine = window.__engine; return ( `${busy} ; « ${text("info")} » ; « ${text("prompt")} » ; moteur : ` + `${engine.sent} commandes, ${engine.received} réponses, dernière ` + `${JSON.stringify(engine.last)} ; journal ` + localStorage.getItem("ustagammon.current-game") ); }) .catch((error) => `page illisible (${error.message})`); throw new Error(`rien à faire pour le joueur depuis 60 s : ${screen}`); } /** Le dernier coup de l'ordinateur, s'il est la dernière chose jouée (fin de * partie mise à part) et qu'il a déplacé des pions ; sinon null. */ function computerMoveToShow(journal) { const last = journal.events.filter((e) => e.type !== "GameEnded").at(-1); return last?.type === "MovePlayed" && last.side === "computer" && last.halfMoves.length > 0 ? last : null; } /** Ce que montre le plateau du coup de l'ordinateur. */ async function shownTraces() { return page.evaluate(() => ({ arrows: document.querySelectorAll(".trace-arrow").length, landed: document.querySelectorAll(".trace-landed").length, ghosts: document.querySelectorAll(".trace-ghost").length, hits: document.querySelectorAll(".trace-hit").length, replay: !document.getElementById("replay").hidden, })); } /** Après un tour de l'ordinateur : ses traces sont là si son coup est la * dernière chose jouée, et rien sinon. */ async function checkTraces() { const journal = await savedJournal(); const move = computerMoveToShow(journal); const traces = await shownTraces(); const last = journal.events.filter((e) => e.type !== "GameEnded").at(-1); if (last?.type === "MovePlayed" && last.side === "computer" && last.halfMoves.length === 0) { // L'ordinateur n'a pas pu jouer : son jet reste barré, avec un message. const info = await page.textContent("#info"); const blocked = await page.locator(".die-blocked").count(); check(/^L'ordinateur ne peut pas jouer \d-\d\.$/.test(info ?? "") && blocked === 2, `jet de l'ordinateur sans coup mal montré : « ${info} », ${blocked} dés barrés`); log(`l'ordinateur ne peut pas jouer : « ${info} »`); } if (move === null) { check(traces.arrows + traces.landed + traces.ghosts === 0 && !traces.replay, `traces sans coup de l'ordinateur à montrer : ${JSON.stringify(traces)}`); return null; } check(traces.arrows === move.halfMoves.length && traces.landed > 0 && traces.ghosts > 0 && traces.replay, `coup de l'ordinateur mal montré : ${JSON.stringify(traces)}`); return traces; } /** La zone à toucher d'une action du plateau (dés ou videau) : un bouton * accessible, d'au moins 44 px de côté. Renvoie son sélecteur. */ async function touchTarget(action, label) { const selector = `[data-action="${action}"]`; const zone = page.locator(selector); check((await zone.count()) === 1, `zone ${action} absente du plateau`); check((await zone.getAttribute("role")) === "button" && (await zone.getAttribute("aria-label")) === label, `zone ${action} sans rôle accessible`); const box = await zone.boundingBox(); check(box.width >= 44 && box.height >= 44, `zone ${action} trop petite : ${Math.round(box.width)} x ${Math.round(box.height)} px`); return selector; } /** Observe la page pendant ms millisecondes : le plus grand nombre * d'animations en cours à la fois, et le plus petit nombre de flèches. */ async function watchReplay(ms) { let animations = 0; let arrows = Infinity; for (const end = Date.now() + ms; Date.now() < end; ) { const seen = await page.evaluate(() => ({ animations: document.getAnimations().length, arrows: document.querySelectorAll(".trace-arrow").length, })); animations = Math.max(animations, seen.animations); arrows = Math.min(arrows, seen.arrows); await new Promise((resolve) => setTimeout(resolve, 40)); } return { animations, arrows }; } /** « Revoir » : rejoue le coup (sans animation en mouvement réduit), puis * remet les mêmes traces. */ async function checkReplay(traces, reducedMotion) { await page.emulateMedia({ reducedMotion: reducedMotion ? "reduce" : "no-preference" }); await page.click("#replay"); const seen = await watchReplay(300); if (reducedMotion) { check(seen.animations === 0, "Revoir en mouvement réduit : des pions glissent quand même"); } else { check(seen.animations > 0 && seen.arrows === 0, `Revoir : pas d'animation sans traces (${JSON.stringify(seen)})`); } await waitForPlayer(); const after = await shownTraces(); check(JSON.stringify(after) === JSON.stringify(traces), `traces différentes après Revoir : ${JSON.stringify(after)}`); await page.emulateMedia({ reducedMotion: "no-preference" }); } // Garde-fou : une étape bloquée plus de deux minutes arrête tout, avec une // capture et l'état de l'écran. let lastStep = Date.now(); let stepName = "chargement"; function step(name) { lastStep = Date.now(); stepName = name; } const watchdog = setInterval(async () => { if (Date.now() - lastStep > 120_000) { clearInterval(watchdog); console.error(`[${browserName}] bloqué depuis 2 minutes (étape : ${stepName})`); await shot("bloque").catch(() => {}); process.exit(1); } }, 5_000); 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 = []; let tracesSeen = 0; let replays = 0; let skips = 0; let rolls = 0; for (let actions = 0; ; actions++) { check(actions < 800, "la partie ne finit pas"); const todo = await waitForPlayer(); step(`partie, action ${actions}`); // Le coup de l'ordinateur est montré jusqu'à l'action suivante du // joueur ; deux fois, on le revoit (la seconde en mouvement réduit). const traces = await checkTraces(); if (traces !== null) { tracesSeen++; if (replays < 2) { replays++; await checkReplay(traces, replays === 2); if (replays === 1) { await shot("coup-adverse"); } log(`coup de l'ordinateur revu${replays === 2 ? " en mouvement réduit" : ""} : ` + `${traces.arrows} flèches, ${traces.landed} pions entourés, ` + `${traces.ghosts} fantômes, ${traces.hits} pions frappés signalés`); } } if (process.env.TRACE) { log(`${todo} : ${await page.textContent("#info")} / ${await page.textContent("#prompt")}`); } 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 === "skip") { // Aucun coup possible : le jet reste affiché, barré, avec un // message, et le tour ne passe qu'en touchant « Continuer ». const info = await page.textContent("#info"); const blocked = await page.locator(".die-blocked").count(); check(/^Aucun coup possible avec \d-\d\.$/.test(info ?? "") && blocked === 2, `aucun coup possible mal montré : « ${info} », ${blocked} dés barrés`); skips++; log(`aucun coup possible : « ${info} », vous continuez`); await page.click("#skip"); } else 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 en touchant le videau"); await page.click(await touchTarget("double", "Doubler")); await waitForPlayer(); const answer = (await page.textContent("#info")) || (await page.textContent("#prompt")); log(`réponse : ${answer}`); continue; } // Une fois sur deux, en touchant les dés en attente plutôt que le // bouton. rolls++; await page.click(rolls % 2 === 0 ? await touchTarget("roll", "Lancer les dés") : "#roll"); } else { check((await page.locator("[data-action]").count()) === 0, "dés ou videau touchables pendant que vous jouez votre coup"); 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 collectViolations(); 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"); check(replays === 2, "le coup de l'ordinateur n'a pas été revu"); check(doubled && rolls >= 2, "dés ou videau jamais touchés sur le plateau"); log(`dés lancés ${Math.floor(rolls / 2)} fois en les touchant, videau touché pour doubler`); log(`coup de l'ordinateur montré ${tracesSeen} fois, et effacé à chaque action suivante`); log(skips > 0 ? `« Continuer » demandé ${skips} fois` : "aucun jet sans coup possible dans cette partie"); 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"); step("aide"); // 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"); step("thème sombre"); // Thème sombre. await page.emulateMedia({ colorScheme: "dark" }); await page.click("#again"); await waitForPlayer(); await shot("6-sombre"); await page.emulateMedia({ colorScheme: "light" }); step("paysage"); // Téléphone tourné : le plateau et les boutons tiennent dans l'écran. for (const [width, height] of [[915, 412], [740, 360]]) { await page.setViewportSize({ width, height }); const board = await page.locator("#board").boundingBox(); const actions = await page.locator(".actions").boundingBox(); check(board.y + board.height <= height && board.x + board.width <= width, `paysage ${width}x${height} : plateau hors de l'écran`); check(actions.y + actions.height <= height, `paysage ${width}x${height} : boutons hors de l'écran`); } log("paysage : plateau et boutons visibles en entier"); await shot("6-paysage"); await page.setViewportSize({ width: 360, height: 740 }); step("mise en cache"); // 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 () => { // Le service worker est enregistré une fois le moteur chargé ; s'il ne // s'installe pas, l'attente s'arrête au bout d'une minute. 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(); const active = (await navigator.serviceWorker.getRegistration())?.active; if (active && 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"); // Firefox sous Playwright refuse toute navigation une fois le contexte // « hors ligne », même servie par le service worker (NS_ERROR_OFFLINE) : on // y vérifie seulement que la page rechargée est contrôlée par le service // worker, dont le cache est complet. const offlineMode = browserName === "chromium"; if (offlineMode) { await context.setOffline(true); } step("rechargement hors ligne"); await collectViolations(); await page.reload(); const controlled = await page.evaluate(() => navigator.serviceWorker.controller !== null); check(controlled, "la page rechargée n'est pas contrôlée par le service worker"); 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( offlineMode ? `hors ligne : page rechargée sans réseau depuis le cache (${cached} adresses), moteur et aide disponibles` : `cache du service worker rempli (${cached} adresses), page rechargée sous son contrôle ` + "(le hors ligne lui-même n'est pas simulable dans Firefox sous Playwright)", ); await shot("7-hors-ligne"); step("CSP"); // Politique de sécurité du contenu : aucune violation pendant toute la // partie (moteur WebAssembly, Web Worker, service worker, aide compris) ; et // elle est bien appliquée : un script en ligne ajouté à la page est bloqué. await collectViolations(); check(violations.length === 0, `violations de la CSP : ${violations.join(", ")}`); cspProbe = true; const probe = await page.evaluate(async () => { const script = document.createElement("script"); script.textContent = "window.__inline = true;"; document.head.append(script); await new Promise((resolve) => setTimeout(resolve, 300)); return { ran: window.__inline === true, violations: window.__csp }; }); check(!probe.ran, "CSP non appliquée : un script en ligne a été exécuté"); check( probe.violations.some((v) => v.startsWith("script-src")), "CSP : le script en ligne bloqué n'a pas été signalé", ); log("CSP : aucune violation pendant la partie, script en ligne bloqué"); clearInterval(watchdog); await browser.close(); if (problems.length > 0) { console.error(problems.join("\n")); process.exit(1); } log("aucune erreur dans la page");