Files
Ustagammon/tests/browser/play.mjs
T

1118 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 <dossier>/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 <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 { 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 <adresse> <chromium|firefox> <dossier des captures>");
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,
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}`);
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)}`);
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,
}));
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;
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`);
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) {
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");
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");
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]}`);
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");