// 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 : chargement (plateau dessiné avant le moteur), dés, coups au // toucher (joués, annulés), coups de l'ordinateur, videau (avec sa // confirmation), aucun coup possible, menu, nouvelle partie reprise, // vibrations, plein écran (repli par rotation), paysage, reprise après // rechargement, aide, thème sombre, puis hors ligne (service worker). // // Il vérifie en chemin les critères automatisables de la recette design // (docs/identite/charte/recette-ux.md) et, dans Chromium, range les // captures de la revue dans /revision-ux/ : 412×915, 915×412 et // 844×320, thèmes clair et sombre, à six moments de la partie. // // 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 { mkdirSync } from "node:fs"; 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); } // La recette complète (six vues à chaque moment, captures de la revue) dans // Chromium ; Firefox joue la même partie et vérifie la vue courante. const fullReview = browserName === "chromium"; const reviewDir = `${shots}/revision-ux`; if (fullReview) { mkdirSync(reviewDir, { recursive: true }); } const PORTRAIT = { width: 412, height: 915 }; const VIEWS = [ [412, 915], [915, 412], [844, 320], ]; const browser = await browserType.launch(); const context = await browser.newContext({ viewport: PORTRAIT, deviceScaleFactor: 1, locale: "fr-FR", }); // Pour le diagnostic d'un blocage : la dernière commande envoyée au moteur // (Web Worker). Pour la recette : chaque violation de la politique de // sécurité du contenu, chaque boîte native (confirm, alert, prompt) et // chaque vibration demandée. 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}`), ); window.__dialogs = []; for (const name of ["confirm", "alert", "prompt"]) { window[name] = (...args) => { window.__dialogs.push(`${name}(${args.join(", ")})`); return null; }; } window.__vibrations = []; Object.defineProperty(Navigator.prototype, "vibrate", { configurable: true, value: (pattern) => { window.__vibrations.push(Array.isArray(pattern) ? pattern.join(",") : String(pattern)); return true; }, }); }); const page = await context.newPage(); const problems = []; page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`)); page.on("dialog", (dialog) => { problems.push(`boîte native ouverte : ${dialog.type()} « ${dialog.message()} »`); void dialog.dismiss(); }); 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` }); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); /** Relève les violations de la CSP signalées par la page (avant chaque * rechargement, qui les efface), et les boîtes natives. */ const violations = []; const dialogs = []; async function collectViolations() { const seen = await page.evaluate(() => ({ csp: window.__csp, dialogs: window.__dialogs })); violations.push(...seen.csp); dialogs.push(...seen.dialogs); } 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; } async function vibrations() { return page.evaluate(() => [...window.__vibrations]); } /** * 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 panel = document.getElementById("panel-veil"); if (!panel.hidden) { return panel.dataset.kind; } const usable = (id) => { const b = document.getElementById(id); return !b.hidden && !b.disabled; }; for (const id of ["again", "take", "skip", "confirm", "roll"]) { if (usable(id)) { return id; } } return document.querySelector(".hit.can-move") ? "move" : null; }); if (todo !== null) { return todo; } await sleep(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}`); } // ------------------------------------------------------- Recette design /** Les boîtes des zones de l'écran de jeu en portrait (y et hauteur). */ async function zoneBoxes() { return page.evaluate(() => { const box = (selector) => { const r = document.querySelector(selector).getBoundingClientRect(); return [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)]; }; return { topbar: box(".topbar"), computer: box("#strip-computer"), board: box("#board"), player: box("#strip-player"), info: box(".infoline"), prompt: box("#prompt"), actions: box("#actions"), }; }); } let stableBoxes = null; let stableChecks = 0; /** Portrait : rien ne bouge sous le doigt, d'une étape à l'autre de la * partie (et dès le chargement). */ async function checkStable(when) { const boxes = await zoneBoxes(); if (stableBoxes === null) { stableBoxes = boxes; const order = ["topbar", "computer", "board", "player", "info", "prompt", "actions"]; for (let i = 1; i < order.length; i++) { const above = boxes[order[i - 1]]; check(above[1] + above[3] <= boxes[order[i]][1] + 0.5, `portrait : ${order[i - 1]} n'est pas au-dessus de ${order[i]}`); } const actions = boxes.actions; check(Math.abs(actions[1] + actions[3] - PORTRAIT.height) <= 1, `portrait : barre d'actions pas collée en bas (${JSON.stringify(actions)})`); } for (const [zone, box] of Object.entries(boxes)) { check(JSON.stringify(box) === JSON.stringify(stableBoxes[zone]), `portrait : ${zone} a bougé (${when}) : ${JSON.stringify(stableBoxes[zone])} -> ` + JSON.stringify(box)); } stableChecks++; } /** Les critères de la recette vérifiables sur la vue courante. Renvoie des * mesures pour le compte rendu. */ async function checkView(width, height, when) { const where = `${width}x${height} (${when})`; const seen = await page.evaluate(() => { const visible = (el) => { if (el.closest("[hidden]")) { return false; } const r = el.getBoundingClientRect(); if (r.width === 0 || r.height === 0) { return false; } const style = getComputedStyle(el); return style.visibility !== "hidden" && style.display !== "none"; }; const describe = (el) => el.id ? `#${el.id}` : el.getAttribute("data-action") ?? el.getAttribute("aria-label") ?? el.className?.baseVal ?? el.className; // Cibles tactiles : boutons et zones d'action du plateau. const small = []; for (const el of document.querySelectorAll("button, [role=button], [role=switch], [data-action]")) { if (!visible(el)) { continue; } const r = el.getBoundingClientRect(); if (r.width < 43.5 || r.height < 43.5) { small.push(`${describe(el)} ${Math.round(r.width)}x${Math.round(r.height)}`); } } // Textes du plateau : taille à l'écran. const scaleOf = (el) => { const m = el.getScreenCTM(); return Math.hypot(m.a, m.b); }; let minNumber = Infinity; const smallText = []; for (const text of document.querySelectorAll("#board text")) { const px = parseFloat(getComputedStyle(text).fontSize) * scaleOf(text); if (text.classList.contains("point-number")) { minNumber = Math.min(minNumber, px); } else if (px < 12.9) { smallText.push(`${text.getAttribute("class")} ${px.toFixed(1)} px`); } } // Textes de la page à lire : 13 px au moins. for (const el of document.querySelectorAll("body *:not(svg *)")) { const own = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim()); if (own && visible(el) && parseFloat(getComputedStyle(el).fontSize) < 13) { smallText.push(`${describe(el)} ${getComputedStyle(el).fontSize}`); } } // Couleurs des flèches : impaires d'une couleur, paires d'une autre. const fills = {}; for (const p of document.querySelectorAll("#board polygon[data-point]")) { fills[p.getAttribute("data-point")] = getComputedStyle(p).fill; } // Boutons principaux (brique) visibles. const primaries = [...document.querySelectorAll( "button:not(.secondary):not(.icon-button):not(.item):not(.count):not(.link-button)", )].filter(visible).map(describe); const board = document.getElementById("board").getBoundingClientRect(); const frame = document.querySelector("#board .board-frame").getBoundingClientRect(); const scroller = document.scrollingElement; return { small, minNumber, smallText, fills, primaries, landscape: document.documentElement.classList.contains("landscape"), board: [board.x, board.y, board.width, board.height], frame: [frame.x, frame.y, frame.width, frame.height], scroll: [scroller.scrollWidth, scroller.scrollHeight], topbar: visible(document.querySelector(".topbar")), overscroll: getComputedStyle(document.documentElement).overscrollBehaviorY, promptBackground: getComputedStyle(document.getElementById("prompt")).backgroundColor, pointHits: [...document.querySelectorAll('#board [data-place^="p"]')].map( (el) => el.getBoundingClientRect().width, ), }; }); check(seen.small.length === 0, `${where} : cibles sous 44 px : ${seen.small.join(", ")}`); check(seen.minNumber >= 12, `${where} : numéros des flèches à ${seen.minNumber.toFixed(1)} px`); check(seen.smallText.length === 0, `${where} : textes sous 13 px : ${seen.smallText.join(", ")}`); const odd = new Set(); const even = new Set(); for (const [n, fill] of Object.entries(seen.fills)) { (Number(n) % 2 ? odd : even).add(fill); } check(Object.keys(seen.fills).length === 24 && odd.size === 1 && even.size === 1 && [...odd][0] !== [...even][0] && seen.fills[12] !== seen.fills[13], `${where} : couleurs des flèches ${JSON.stringify(seen.fills)}`); check(seen.primaries.length <= 1, `${where} : plusieurs actions principales : ${seen.primaries}`); check(seen.overscroll === "none", `${where} : overscroll-behavior ${seen.overscroll}`); check(/^(transparent|rgba\(0, 0, 0, 0\))$/.test(seen.promptBackground), `${where} : la consigne a un fond (${seen.promptBackground})`); const landscape = width > height && height <= 640; check(seen.landscape === landscape, `${where} : mise en page ${seen.landscape ? "paysage" : "portrait"}`); if (landscape) { const edge = (box) => box.every((v, i) => Math.abs(v - [0, 0, width, height][i]) <= 1); check(edge(seen.board) && edge(seen.frame), `${where} : cadre pas bord à bord : ${seen.board} / ${seen.frame}`); check(seen.scroll[0] <= width && seen.scroll[1] <= height, `${where} : la page défile`); check(!seen.topbar, `${where} : barre haute visible en paysage`); check(Math.min(...seen.pointHits) >= 44, `${where} : flèches de ${Math.min(...seen.pointHits).toFixed(1)} px`); } return seen; } /** Ce qui doit survivre à une rotation : partie, sélection, dés. */ async function screenState() { return page.evaluate(() => ({ head: JSON.parse(localStorage.getItem("ustagammon.current-game"))?.head ?? null, selected: document.querySelector("#board .checker.selected") !== null, landing: [...document.querySelectorAll("#board .hit.can-land")] .map((e) => e.getAttribute("data-place")) .join(" "), dice: [...document.querySelectorAll("#board g.die[data-value]")] .map((d) => `${d.getAttribute("data-value")}${d.classList.contains("die-used") ? "u" : ""}`) .join(" "), traces: document.querySelectorAll(".trace-arrow").length, menu: !document.getElementById("menu-veil").hidden, panel: !document.getElementById("panel-veil").hidden, })); } const recette = { views: 0, captures: 0, minNumber: Infinity, rotations: 0 }; /** * Un moment de la partie : dans chaque vue (trois tailles, deux thèmes), * vérifie la recette et l'état conservé par la rotation, et range la * capture. Revient ensuite en portrait clair. */ async function review(moment) { const before = await screenState(); const views = fullReview ? VIEWS : [[PORTRAIT.width, PORTRAIT.height], [844, 320]]; const themes = fullReview ? ["light", "dark"] : ["light"]; for (const [width, height] of views) { await page.setViewportSize({ width, height }); for (const theme of themes) { await page.emulateMedia({ colorScheme: theme }); await sleep(120); const seen = await checkView(width, height, moment); recette.views++; recette.minNumber = Math.min(recette.minNumber, seen.minNumber); const now = await screenState(); check(JSON.stringify(now) === JSON.stringify(before), `${moment}, ${width}x${height} : état perdu à la rotation : ` + `${JSON.stringify(before)} -> ${JSON.stringify(now)}`); recette.rotations++; if (fullReview) { await page.screenshot({ path: `${reviewDir}/${moment}-${width}x${height}-${theme}.png` }); recette.captures++; } } } await page.setViewportSize(PORTRAIT); await page.emulateMedia({ colorScheme: "light" }); await sleep(120); check(JSON.stringify(await screenState()) === JSON.stringify(before), `${moment} : état perdu au retour en portrait`); } // --------------------------------------------------- Coup de l'ordinateur /** 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 && blocked === (await shownDice()).dice, `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)}`); // Flèches de 2,5 px, sous les pions. const arrows = await page.evaluate(() => { const firstChecker = document.querySelector("#board .checker"); return [...document.querySelectorAll("#board .trace-arrow")].map((line) => { const m = line.getScreenCTM(); return { px: parseFloat(getComputedStyle(line).strokeWidth) * Math.hypot(m.a, m.b), under: Boolean(line.compareDocumentPosition(firstChecker) & Node.DOCUMENT_POSITION_FOLLOWING), }; }); }); check(arrows.every((a) => Math.abs(a.px - 2.5) < 0.2 && a.under), `flèches du coup de l'ordinateur : ${JSON.stringify(arrows)}`); arrowsChecked++; return traces; } /** Une zone à toucher d'une action du plateau (dés ou videau) : count zones * (les dés en ont une de chaque côté du plateau), chacune un bouton * accessible d'au moins 44 px de côté. Renvoie le locator de la zone index. */ async function touchTarget(action, label, count = 1, index = 0) { const zones = page.locator(`[data-action="${action}"]`); check((await zones.count()) === count, `zone ${action} : ${await zones.count()} sur le plateau, ${count} attendues`); for (const zone of await zones.all()) { 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 zones.nth(index); } /** Le nombre de dés montrés, et de dés estompés (déjà joués). */ async function shownDice() { return { dice: await page.locator("#board g.die[data-value]").count(), used: await page.locator("#board g.die-used").count(), }; } /** Le dernier jet du joueur, d'après le journal. */ function lastPlayerRoll(journal) { const rolled = journal.events.filter((e) => e.type === "DiceRolled").at(-1); return rolled?.roll ?? []; } /** 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().filter((a) => a.effect?.target?.closest?.("#board")) .length, arrows: document.querySelectorAll(".trace-arrow").length, })); animations = Math.max(animations, seen.animations); arrows = Math.min(arrows, seen.arrows); await sleep(30); } 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(250); 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" }); } // ------------------------------------------------------------- Menu async function openMenu() { await page.click("#menu-button"); check(await page.isVisible("#menu"), "le menu ne s'ouvre pas"); } /** Nouvelle partie depuis le menu : effet immédiat, bandeau 10 s. Renvoie * le journal de la partie quittée. */ async function newGameFromMenu() { const before = await savedJournal(); await openMenu(); const start = Date.now(); await page.click("#menu-new-game"); check(await page.isVisible("#banner"), "pas de bandeau « Reprendre l'ancienne »"); check((await page.textContent("#banner")).includes("Reprendre l'ancienne"), `bandeau : « ${await page.textContent("#banner")} »`); check(!(await page.isVisible("#menu")), "le menu reste ouvert après « Nouvelle partie »"); await waitForPlayer(); const after = await savedJournal(); check(after.head !== before.head && !after.events.some((e) => e.type === "MovePlayed" && e.side === "player"), "« Nouvelle partie » n'a pas commencé de partie"); log(`nouvelle partie commencée en ${Date.now() - start} ms, sans confirmation`); return before; } // 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 > 180_000) { clearInterval(watchdog); console.error(`[${browserName}] bloqué depuis 3 minutes (étape : ${stepName})`); await shot("bloque").catch(() => {}); process.exit(1); } }, 5_000); // ------------------------------------------------------------- Chargement // Le moteur est retenu le temps de regarder l'écran de chargement : le // plateau doit être là sans lui (réponse immédiate). let releaseEngine; const engineHeld = new Promise((resolve) => { releaseEngine = resolve; }); await page.route("**/wasm/engine.data", async (route) => { await engineHeld; await route.continue(); }); const start = Date.now(); await page.goto(base); await page.waitForSelector("#board polygon[data-point]"); { const loading = await page.evaluate(() => ({ busy: document.body.classList.contains("busy"), points: document.querySelectorAll("#board polygon[data-point]").length, checkers: document.querySelectorAll("#board .checker").length, info: document.getElementById("info").textContent, moves: document.querySelectorAll(".hit.can-move, [data-action=roll]").length, prompt: document.getElementById("prompt").textContent, })); check(loading.prompt === "", `chargement : consigne affichée « ${loading.prompt} »`); check(loading.busy && loading.points === 24 && loading.checkers === 30, `chargement : plateau incomplet ${JSON.stringify(loading)}`); check(loading.info === "Chargement du moteur…", `chargement : « ${loading.info} »`); check(loading.moves === 0, "chargement : le plateau est jouable avant le moteur"); log(`plateau dessiné en ${Date.now() - start} ms, avant le moteur (${loading.checkers} pions)`); } await checkStable("chargement"); await review("1-chargement"); releaseEngine(); await page.unroute("**/wasm/engine.data"); let todo = await waitForPlayer(); log(`moteur chargé, partie prête en ${Date.now() - start} ms (${todo})`); await checkStable("moteur chargé"); await shot("1-debut"); // Nouvelle partie dès le début : le bandeau disparaît après 10 s, et // l'ancienne partie n'est alors plus proposée. step("bandeau de 10 s"); await newGameFromMenu(); await sleep(10_300); check(!(await page.isVisible("#banner")), "le bandeau « Reprendre l'ancienne » reste après 10 s"); log("bandeau « Reprendre l'ancienne » retiré après 10 s"); let doubled = false; let selectionShot = false; let reloaded = false; let resumed = false; let landscapeTurn = false; let rotatedChecked = false; let vibrationsChecked = false; let tracesSeen = 0; let replays = 0; let skips = 0; let skipsByDice = 0; let rolls = 0; let keyboardRolls = 0; let halfMoves = 0; let singleTouch = 0; let twoTouches = 0; let confirms = 0; let undone = 0; let doubles = 0; let responseMs = 0; let confirmButtonSeen = 0; let landingsSeen = 0; let arrowsChecked = 0; const moments = new Set(); // Vrai dès que le joueur a touché un pion ou « Annuler » pendant son coup : // le coup de l'ordinateur n'est plus montré. let touched = false; // Vrai juste après le rechargement : la partie reprise ne revibre pas. let justReloaded = false; /** Un tour complet joué en paysage (844 × 320), par les zones du plateau : * dés, pions, dés pour jouer le coup. */ async function landscapeTurnPlay() { await page.setViewportSize({ width: 844, height: 320 }); await sleep(150); await checkView(844, 320, "tour en paysage"); await (await touchTarget("roll", "Lancer les dés", 2, 1)).click(); for (let n = 0; n < 6; n++) { const now = await waitForPlayer(); if (now === "confirm") { await (await touchTarget("confirm", "Jouer ce coup", 2, 0)).click(); break; } if (now === "skip") { await (await touchTarget("skip", "Passer votre tour", 2, 0)).click(); break; } check(now === "move", `paysage : ${now} pendant le coup`); await page.click(".hit.can-move"); if ((await page.locator(".hit.can-land").count()) > 0) { await page.click(".hit.can-land"); } } await waitForPlayer(); await page.setViewportSize(PORTRAIT); await sleep(150); log("un tour joué en paysage (844 x 320) par les zones du plateau"); } for (let actions = 0; ; actions++) { check(actions < 1500, "la partie ne finit pas"); todo = await waitForPlayer(); step(`partie, action ${actions}`); const afterReload = justReloaded; justReloaded = false; await checkStable(`action ${actions} (${todo})`); // 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 = touched ? null : await checkTraces(); if (traces !== null) { tracesSeen++; if (!moments.has("coup-ordinateur")) { moments.add("coup-ordinateur"); await review("4-coup-ordinateur"); } 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`); } } const info = (await page.textContent("#info")) ?? ""; check(!/\(\d+,\d s\)/.test(info), `durée de calcul affichée : « ${info} »`); if (process.env.TRACE) { log(`${todo} : ${info} / ${await page.textContent("#prompt")}`); } if (todo === "over") { break; } if (todo === "skip") { // Aucun coup possible : le jet reste affiché, barré, avec un // message ; le tour ne passe qu'en touchant les dés (ou Continuer). const blocked = await page.locator(".die-blocked").count(); check(/^Aucun coup possible avec \d-\d\.$/.test(info) && blocked >= 2 && blocked === (await shownDice()).dice, `aucun coup possible mal montré : « ${info} », ${blocked} dés barrés`); await sleep(400); check(await page.isVisible("#skip"), "aucun coup possible : le tour est passé tout seul"); skips++; if (skips % 2 === 1) { skipsByDice++; await (await touchTarget("skip", "Passer votre tour", 2, skips % 2)).click(); } else { await page.click("#skip"); } log(`aucun coup possible : « ${info} », vous passez votre tour`); } else if (todo === "take") { log("l'ordinateur double : vous prenez"); await shot("videau-propose"); check(afterReload || (await vibrations()).includes("40,60,40"), "l'ordinateur double sans vibration"); await page.click("#take"); } else if (todo === "roll") { if (!moments.has("a-vous")) { moments.add("a-vous"); await review("2-a-vous-de-lancer"); } if (!doubled && (await page.isVisible("#double")) && (await page.isEnabled("#double"))) { doubled = true; // Toucher le videau demande confirmation ; « Annuler » ne // propose rien, puis « Doubler » (le bouton) et confirmer. const question = "Doubler à 2 ? L'ordinateur pourra prendre ou passer."; await (await touchTarget("double", "Doubler")).click(); check(await page.isVisible("#double-dialog"), "videau touché : pas de confirmation"); check((await page.textContent("#double-question")) === question, `confirmation du videau : « ${await page.textContent("#double-question")} »`); await shot("videau-confirmation"); await page.click("#double-cancel"); check(!(await page.isVisible("#double-dialog")), "Annuler ne ferme pas la confirmation"); const offered = (await savedJournal()).events.some((e) => e.type === "CubeOffered"); check(!offered && (await page.isVisible("#roll")), "double proposé malgré Annuler"); await page.click("#double"); check(await page.isVisible("#double-dialog"), "« Doubler » : pas de confirmation"); await page.click("#double-confirm"); await waitForPlayer(); const answer = (await page.textContent("#info")) || (await page.textContent("#prompt")); log(`videau touché : confirmation demandée, annulée, puis double confirmé ; réponse : ${answer}`); continue; } rolls++; if (rolls === 3 && !landscapeTurn) { landscapeTurn = true; await landscapeTurnPlay(); continue; } if (rolls === 4 && !rotatedChecked) { // Plein écran : Chromium sans écran refuse de verrouiller // l'orientation, d'où le repli par rotation CSS de tout l'écran. rotatedChecked = true; await page.click("#fullscreen-button"); await sleep(300); const view = await page.evaluate(() => { const r = document.getElementById("board").getBoundingClientRect(); return { rotated: document.documentElement.classList.contains("rotated"), landscape: document.documentElement.classList.contains("landscape"), board: [r.x, r.y, r.width, r.height].map(Math.round), }; }); check(view.landscape, "plein écran : pas de vue paysage"); if (view.rotated) { check(JSON.stringify(view.board) === JSON.stringify([0, 0, 412, 915]), `repli tourné : plateau ${view.board}`); } await shot("plein-ecran"); if (fullReview) { await page.screenshot({ path: `${reviewDir}/plein-ecran-repli-412x915-light.png` }); } // Les touchers suivent la rotation : lancer les dés par le plateau. await (await touchTarget("roll", "Lancer les dés", 2, 1)).click(); await waitForPlayer(); check((await savedJournal()).events.at(-1).type === "DiceRolled", "repli tourné : toucher les dés ne les lance pas"); // Sortie par le menu (colonne de commandes), puis « Quitter ». await (await touchTarget("menu", "Menu")).click(); check((await page.textContent("#menu-fullscreen")).includes("Quitter le plein écran"), "menu : pas de « Quitter le plein écran »"); await page.click("#menu-fullscreen"); await sleep(300); check(!(await page.evaluate(() => document.documentElement.classList.contains("landscape"))), "plein écran quitté : la vue paysage reste"); log(`plein écran : ${view.rotated ? "repli par rotation CSS" : "paysage verrouillé"}, ` + "dés lancés en touchant le plateau tourné, sortie par le menu"); continue; } // Dés : au clavier (Espace), en les touchant (d'un côté puis de // l'autre du plateau) ou par le bouton. const before = (await vibrations()).length; if (rolls % 5 === 0) { keyboardRolls++; await page.evaluate(() => document.activeElement?.blur()); await page.keyboard.press("Space"); } else if (rolls % 2 === 0) { await (await touchTarget("roll", "Lancer les dés", 2, (rolls / 2) % 2)).click(); } else { await page.click("#roll"); } await waitForPlayer(); check((await vibrations()).length === before, "le lancer des dés fait vibrer"); } else if (todo === "confirm") { // Coup complet : rien n'est joué tant qu'il n'est pas validé. const journal = await savedJournal(); const last = journal.events.at(-1); check(last?.type === "DiceRolled" && (await page.isEnabled("#cancel")), `coup complet déjà joué ou non annulable (dernier événement ${last?.type})`); check(/jouer ce coup/i.test((await page.textContent("#prompt")) ?? ""), `consigne de validation absente : « ${await page.textContent("#prompt")} »`); const dice = await shownDice(); check(dice.used === halfMoves && dice.used > 0, `dés estompés : ${dice.used} pour ${halfMoves} demi-coups`); // « Jouer ce coup » : l'action principale de la barre, à droite. const bar = await page.evaluate(() => { const confirm = document.getElementById("confirm"); const cancel = document.getElementById("cancel"); return { text: confirm.textContent, primary: !confirm.classList.contains("secondary"), right: confirm.getBoundingClientRect().x > cancel.getBoundingClientRect().x, inBar: confirm.closest("#actions .slot.primary") !== null, }; }); check(await page.isVisible("#confirm") && bar.text === "Jouer ce coup" && bar.primary && bar.right && bar.inBar, `coup complet : « Jouer ce coup » mal placé ${JSON.stringify(bar)}`); confirmButtonSeen++; if (undone === 0 && halfMoves >= 2) { // Une fois : « Annuler » défait les demi-coups un par un. if (confirms === 0) { await shot("2b-coup-a-valider"); } for (let left = halfMoves - 1; left >= 0; left--) { await page.click("#cancel"); await waitForPlayer(); const after = await shownDice(); check(after.used === left && (await page.isHidden("#confirm")), `Annuler : ${after.used} dés estompés, ${left} attendus`); undone++; } check(await page.isDisabled("#cancel"), "Annuler reste actif sans demi-coup à défaire"); check((await savedJournal()).head === journal.head, "Annuler a touché au journal"); log(`Annuler : ${undone} demi-coups défaits un par un, journal intact`); halfMoves = 0; continue; } confirms++; // Une fois sur trois avec le bouton, sinon en touchant les dés // (d'un côté ou de l'autre). if (confirms % 3 === 0) { await page.click("#confirm"); } else { await (await touchTarget("confirm", "Jouer ce coup", 2, confirms % 2)).click(); } await waitForPlayer(); const played = await savedJournal(); check(countMoves(played, "player") === countMoves(journal, "player") + 1, "le coup joué n'est pas dans le journal"); halfMoves = 0; touched = false; } else { check((await page.locator("[data-action=roll], [data-action=confirm], [data-action=double]") .count()) === 0, "dés ou videau touchables pendant que vous jouez votre coup"); if (halfMoves === 0) { // Quatre dés pour un double, deux sinon. const [a, b] = lastPlayerRoll(await savedJournal()); const dice = await shownDice(); check(dice.dice === (a === b ? 4 : 2) && dice.used === 0, `jet ${a}-${b} : ${dice.dice} dés montrés, ${dice.used} estompés`); if (a === b) { doubles++; } } touched = true; // Vibrations coupées dans le menu : le pion se pose sans vibrer. const silent = reloaded && !vibrationsChecked; if (silent) { await openMenu(); await page.click("#menu-vibrations"); check((await page.getAttribute("#menu-vibrations", "aria-checked")) === "false", "interrupteur des vibrations"); await page.keyboard.press("Escape"); check(!(await page.isVisible("#menu")), "Échap ne ferme pas le menu"); } const vibrationsBefore = (await vibrations()).length; // Un pion à destination unique avance dès qu'on le touche ; sinon, // ses cases d'arrivée s'allument. Le premier toucher est mesuré : // l'écran répond dans le gestionnaire du toucher. const ms = await page.evaluate(() => { const hit = document.querySelector(".hit.can-move"); const t0 = performance.now(); hit.dispatchEvent(new MouseEvent("click", { bubbles: true })); const answered = document.querySelector("#board .checker.selected") !== null || document.body.classList.contains("busy"); return answered ? performance.now() - t0 : Infinity; }); responseMs = Math.max(responseMs, ms); check(ms < 100, `toucher d'un pion : pas d'effet visible en moins de 100 ms (${ms})`); if ((await page.locator(".hit.can-land").count()) > 0) { // Cases d'arrivée : le triangle de la flèche voilé, et un anneau // pointillé là où le pion se posera. const marks = await page.evaluate(() => ({ lands: document.querySelectorAll(".hit.can-land").length, rings: document.querySelectorAll("#board .landing").length, zones: [...document.querySelectorAll("#board .target-zone")].map((z) => z.tagName), })); check(marks.rings === marks.lands && marks.zones.filter((t) => t === "polygon").length >= marks.lands - 1, `cases d'arrivée mal marquées : ${JSON.stringify(marks)}`); landingsSeen++; twoTouches++; if (!selectionShot) { selectionShot = true; await shot("2-pion-touche"); await review("3-coup-en-cours"); } await page.click(".hit.can-land"); } else { singleTouch++; } halfMoves++; await waitForPlayer(); const dice = await shownDice(); check(dice.used === halfMoves, `${dice.used} dés estompés après ${halfMoves} demi-coups`); const vibrationsAfter = (await vibrations()).length; if (silent) { vibrationsChecked = true; check(vibrationsAfter === vibrationsBefore, "vibrations coupées : le pion vibre encore"); await openMenu(); await page.click("#menu-vibrations"); await page.click("#menu-close"); log("vibrations coupées dans le menu : le pion se pose sans vibrer"); } else { const added = (await vibrations()).slice(vibrationsBefore); check(added.length === 1 && ["12", "25,40,25"].includes(added[0]), `pion posé : vibrations ${JSON.stringify(added)}`); } continue; } // Une fois, au milieu de la partie, entre deux coups : fermer et rouvrir // la page ; puis une nouvelle partie, et la reprise de l'ancienne. 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("#strip-player .count"); await collectViolations(); await page.reload(); // Avant le moteur, le plateau de la partie enregistrée est déjà là. await page.waitForSelector("#board polygon[data-point]"); check((await page.textContent("#strip-player .count")) === pips, "chargement : le plateau dessiné n'est pas celui de la partie enregistrée"); 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("#strip-player .count")) === pips, "plateau différent après rechargement"); justReloaded = true; log(`partie reprise après rechargement (événement ${after.head})`); } else if (reloaded && !resumed) { resumed = true; step("menu"); await openMenu(); await review("5-menu-ouvert"); await page.keyboard.press("Escape"); // Le clavier ouvre aussi le menu (M) et Échap le ferme. await page.evaluate(() => document.activeElement?.blur()); await page.keyboard.press("m"); check(await page.isVisible("#menu"), "M n'ouvre pas le menu"); await page.keyboard.press("Escape"); const pips = await page.textContent("#strip-player .count"); const old = await newGameFromMenu(); await page.click("#resume-old"); await waitForPlayer(); const back = await savedJournal(); check(JSON.stringify(back) === JSON.stringify(old), "« Reprendre l'ancienne » ne restaure pas la partie exacte"); check((await page.textContent("#strip-player .count")) === pips, "« Reprendre l'ancienne » : plateau différent"); check(!(await page.isVisible("#banner")), "le bandeau reste après la reprise"); log(`nouvelle partie puis « Reprendre l'ancienne » : journal identique (${old.events.length} événements)`); } } // --------------------------------------------------------- Fin de partie const journal = await savedJournal(); const result = `${await page.textContent("#panel-text")}, ${await page.textContent("#panel-detail")}`; check((await page.textContent("#panel-overline")) === "Fin de partie" && /^(Vous gagnez|L'ordinateur gagne|Vous passez|L'ordinateur passe)/.test( await page.textContent("#panel-text")) && /^\d+ points? pour (vous|lui)(, videau à \d+)?\.$/.test(await page.textContent("#panel-detail")), `panneau de fin de partie : ${result}`); check((await page.textContent("#prompt")) === "", "fin de partie : la consigne répète le panneau"); log( `partie finie : ${result} (${countMoves(journal, "player")} coups du joueur, ` + `${countMoves(journal, "computer")} de l'ordinateur, ${journal.events.length} événements)`, ); const won = journal.events.at(-1).winner === "player"; check((await vibrations()).at(-1) === (won ? "30,50,30,50,60" : "60"), `fin de partie : vibration ${(await vibrations()).at(-1)}`); check((await page.textContent("#panel-secondary")) === "Revoir la partie" && (await page.textContent("#panel-primary")) === "Nouvelle partie", "fin de partie : panneau sans « Revoir la partie » et « Nouvelle partie »"); await review("6-fin-de-partie"); await page.setViewportSize({ width: 844, height: 320 }); await sleep(150); check((await page.locator("#band-text").count()) === 0, "fin de partie : consigne sous le panneau en paysage"); await page.setViewportSize(PORTRAIT); await sleep(150); check(countMoves(journal, "player") >= 3 && countMoves(journal, "computer") >= 3, "trop peu de coups"); check(reloaded && resumed, "rechargement ou reprise de l'ancienne partie non essayés"); 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"); check(landscapeTurn && rotatedChecked, "paysage ou plein écran non essayés"); check(vibrationsChecked, "interrupteur des vibrations non essayé"); check(singleTouch > 0 && twoTouches > 0, `saisie : ${singleTouch} pions avancés d'un toucher, ${twoTouches} en deux touchers`); check(undone >= 2, "Annuler n'a pas été essayé sur un coup complet"); check(confirms >= 3, "trop peu de coups joués pour essayer dés et bouton"); check(moments.size === 2 && selectionShot, `moments de la recette manquants : ${[...moments]}`); check(confirmButtonSeen >= 3 && landingsSeen > 0 && arrowsChecked > 0, "« Jouer ce coup », cases d'arrivée ou flèches non vérifiés"); log(`« Jouer ce coup » vu ${confirmButtonSeen} fois à droite de la barre d'actions ; ` + `cases d'arrivée marquées ${landingsSeen} fois ; flèches vérifiées sur ${arrowsChecked} coups`); log(`dés lancés ${rolls} fois (${keyboardRolls} au clavier), dont la moitié en les touchant`); log(`demi-coups : ${singleTouch} d'un seul toucher, ${twoTouches} en deux touchers ; ` + `${confirms} coups joués ; ${doubles} doubles montrés en quatre dés`); log(`coup de l'ordinateur montré ${tracesSeen} fois, et effacé à chaque action suivante`); log(skips > 0 ? `aucun coup possible ${skips} fois (${skipsByDice} en touchant les dés)` : "aucun jet sans coup possible dans cette partie"); log(`vibrations demandées : ${(await vibrations()).length}`); await shot("4-fin-de-partie"); // « Revoir la partie » ferme le panneau ; « Nouvelle partie » reste dans la // barre d'actions. await page.click("#panel-secondary"); check(!(await page.isVisible("#panel")) && (await page.isVisible("#again")), "« Revoir la partie » : panneau toujours là, ou pas de « Nouvelle partie »"); step("aide"); // Aide : scénarios 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 >= 9, `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("compte de points"); // Toucher le compte de points : la bulle donne la fiche du glossaire. await page.click("#strip-player .count"); await page.waitForSelector("#pip-bubble:not([hidden]) .bubble-text"); const bubble = await page.textContent("#pip-bubble .bubble-text"); check(/^nombre total de cases que vos pions doivent encore parcourir/.test(bubble), `bulle du compte de points : « ${bubble} »`); await shot("compte-de-points"); await page.keyboard.press("Escape"); check(!(await page.isVisible("#pip-bubble")), "Échap ne ferme pas la bulle"); log("compte de points : bulle avec la fiche du glossaire"); step("thème sombre"); await page.emulateMedia({ colorScheme: "dark" }); await page.click("#again"); await waitForPlayer(); await shot("6-sombre"); await page.emulateMedia({ colorScheme: "light" }); 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(", ")}`); check(dialogs.length === 0, `boîtes natives appelées : ${dialogs.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é ; aucune boîte native"); log(`recette : ${recette.views} vues vérifiées (${stableChecks} contrôles de stabilité du ` + `portrait, ${recette.rotations} rotations sans perte d'état), numéros des flèches à ` + `${recette.minNumber.toFixed(1)} px au moins, réponse au toucher en ${responseMs.toFixed(1)} ms ` + `au plus${fullReview ? `, ${recette.captures} captures dans revision-ux/` : ""}`); clearInterval(watchdog); await browser.close(); if (problems.length > 0) { console.error(problems.join("\n")); process.exit(1); } log("aucune erreur dans la page");