343 lines
13 KiB
JavaScript
343 lines
13 KiB
JavaScript
// play.mjs -- joue une partie complète par l'interface, dans un vrai
|
|
// navigateur sans écran (Playwright), sur le site servi par l'image de
|
|
// production : dés, coups au toucher, coups de l'ordinateur, videau, reprise
|
|
// après rechargement, aide, puis hors ligne (service worker).
|
|
//
|
|
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
|
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
|
//
|
|
// Usage : node play.mjs <adresse du site> <chromium|firefox> <dossier des captures>
|
|
// Lancé par tests/browser.sh, dans l'image de Playwright. Sort en erreur au
|
|
// premier problème, ou si la page signale une erreur (dont une violation de
|
|
// la politique de sécurité du contenu).
|
|
|
|
import { chromium, firefox } from "playwright";
|
|
|
|
const [base, browserName, shots] = process.argv.slice(2);
|
|
const browserType = { chromium, firefox }[browserName];
|
|
if (!base || !browserType || !shots) {
|
|
console.error("usage : node play.mjs <adresse> <chromium|firefox> <dossier des captures>");
|
|
process.exit(2);
|
|
}
|
|
|
|
const browser = await browserType.launch();
|
|
const context = await browser.newContext({
|
|
viewport: { width: 360, height: 740 },
|
|
deviceScaleFactor: 2,
|
|
locale: "fr-FR",
|
|
});
|
|
// Pour le diagnostic d'un blocage : la dernière commande envoyée au moteur
|
|
// (Web Worker), et si elle a reçu sa réponse ; et, pour vérifier la
|
|
// politique de sécurité du contenu, chaque violation signalée par la page.
|
|
await context.addInitScript(() => {
|
|
window.__engine = { sent: 0, received: 0, last: null };
|
|
const post = Worker.prototype.postMessage;
|
|
Worker.prototype.postMessage = function (message, ...rest) {
|
|
window.__engine.sent++;
|
|
window.__engine.last = message?.args ?? null;
|
|
if (!this.__counted) {
|
|
this.__counted = true;
|
|
this.addEventListener("message", () => window.__engine.received++);
|
|
}
|
|
return post.call(this, message, ...rest);
|
|
};
|
|
window.__csp = [];
|
|
document.addEventListener("securitypolicyviolation", (e) =>
|
|
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
|
|
);
|
|
});
|
|
const page = await context.newPage();
|
|
const problems = [];
|
|
page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`));
|
|
page.on("crash", () => {
|
|
console.error(`[${browserName}] la page a planté`);
|
|
process.exit(1);
|
|
});
|
|
// Vrai pendant le contrôle final de la politique de sécurité, qui provoque
|
|
// exprès une violation.
|
|
let cspProbe = false;
|
|
page.on("console", (message) => {
|
|
if (message.type() === "error" && !(cspProbe && /Content.Security.Policy/i.test(message.text()))) {
|
|
problems.push(`console : ${message.text()}`);
|
|
}
|
|
});
|
|
const log = (text) => console.log(`[${browserName}] ${text}`);
|
|
const shot = (name) => page.screenshot({ path: `${shots}/${browserName}-${name}.png` });
|
|
|
|
/** Relève les violations de la CSP signalées par la page (avant chaque
|
|
* rechargement, qui les efface). */
|
|
const violations = [];
|
|
async function collectViolations() {
|
|
violations.push(...(await page.evaluate(() => window.__csp)));
|
|
}
|
|
|
|
function check(condition, message) {
|
|
if (!condition) {
|
|
throw new Error(message);
|
|
}
|
|
}
|
|
|
|
/** Les coups joués de chaque camp, d'après le journal enregistré. */
|
|
async function savedJournal() {
|
|
return page.evaluate(() => JSON.parse(localStorage.getItem("ustagammon.current-game")));
|
|
}
|
|
|
|
function countMoves(journal, side) {
|
|
return journal.events.filter((e) => e.type === "MovePlayed" && e.side === side).length;
|
|
}
|
|
|
|
/**
|
|
* Attend que le joueur ait quelque chose à faire, et dit quoi. (Pas de
|
|
* page.waitForFunction : Playwright y passe par eval, que la politique de
|
|
* sécurité du site interdit, à raison.)
|
|
*/
|
|
async function waitForPlayer() {
|
|
for (let i = 0; i < 600; i++) {
|
|
const todo = await page.evaluate(() => {
|
|
if (document.body.classList.contains("busy")) {
|
|
return null;
|
|
}
|
|
const usable = (id) => {
|
|
const b = document.getElementById(id);
|
|
return !b.hidden && !b.disabled;
|
|
};
|
|
for (const id of ["again", "take", "roll"]) {
|
|
if (usable(id)) {
|
|
return id;
|
|
}
|
|
}
|
|
return document.querySelector(".hit.can-move") ? "move" : null;
|
|
});
|
|
if (todo !== null) {
|
|
return todo;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
}
|
|
await shot("bloque").catch(() => {});
|
|
const screen = await page
|
|
.evaluate(() => {
|
|
const text = (id) => document.getElementById(id)?.textContent;
|
|
const busy = document.body.classList.contains("busy") ? "occupé" : "libre";
|
|
const engine = window.__engine;
|
|
return (
|
|
`${busy} ; « ${text("info")} » ; « ${text("prompt")} » ; moteur : ` +
|
|
`${engine.sent} commandes, ${engine.received} réponses, dernière ` +
|
|
`${JSON.stringify(engine.last)} ; journal ` +
|
|
localStorage.getItem("ustagammon.current-game")
|
|
);
|
|
})
|
|
.catch((error) => `page illisible (${error.message})`);
|
|
throw new Error(`rien à faire pour le joueur depuis 60 s : ${screen}`);
|
|
}
|
|
|
|
// Garde-fou : une étape bloquée plus de deux minutes arrête tout, avec une
|
|
// capture et l'état de l'écran.
|
|
let lastStep = Date.now();
|
|
let stepName = "chargement";
|
|
function step(name) {
|
|
lastStep = Date.now();
|
|
stepName = name;
|
|
}
|
|
const watchdog = setInterval(async () => {
|
|
if (Date.now() - lastStep > 120_000) {
|
|
clearInterval(watchdog);
|
|
console.error(`[${browserName}] bloqué depuis 2 minutes (étape : ${stepName})`);
|
|
await shot("bloque").catch(() => {});
|
|
process.exit(1);
|
|
}
|
|
}, 5_000);
|
|
|
|
const start = Date.now();
|
|
await page.goto(base);
|
|
const first = await waitForPlayer();
|
|
log(`moteur chargé, partie prête en ${Date.now() - start} ms (${first})`);
|
|
await shot("1-debut");
|
|
|
|
let doubled = false;
|
|
let selectionShot = false;
|
|
let reloaded = false;
|
|
let computerMs = [];
|
|
for (let actions = 0; ; actions++) {
|
|
check(actions < 800, "la partie ne finit pas");
|
|
const todo = await waitForPlayer();
|
|
step(`partie, action ${actions}`);
|
|
if (process.env.TRACE) {
|
|
log(`${todo} : ${await page.textContent("#info")} / ${await page.textContent("#prompt")}`);
|
|
}
|
|
const info = await page.textContent("#info");
|
|
const timing = /\((\d+),(\d) s\)/.exec(info ?? "");
|
|
if (timing) {
|
|
computerMs.push(Number(timing[1]) * 1000 + Number(timing[2]) * 100);
|
|
}
|
|
if (todo === "again") {
|
|
break;
|
|
}
|
|
if (todo === "take") {
|
|
log("l'ordinateur double : vous prenez");
|
|
await shot("videau-propose");
|
|
await page.click("#take");
|
|
} else if (todo === "roll") {
|
|
if (!doubled && (await page.isEnabled("#double"))) {
|
|
doubled = true;
|
|
log("vous doublez");
|
|
await page.click("#double");
|
|
await waitForPlayer();
|
|
const answer = (await page.textContent("#info")) || (await page.textContent("#prompt"));
|
|
log(`réponse : ${answer}`);
|
|
continue;
|
|
}
|
|
await page.click("#roll");
|
|
} else {
|
|
await page.click(".hit.can-move");
|
|
await page.waitForSelector(".hit.can-land");
|
|
if (!selectionShot) {
|
|
selectionShot = true;
|
|
await shot("2-pion-touche");
|
|
}
|
|
await page.click(".hit.can-land");
|
|
}
|
|
|
|
// Une fois, au milieu de la partie : fermer et rouvrir la page.
|
|
const journal = await savedJournal();
|
|
if (!reloaded && countMoves(journal, "computer") >= 3 && countMoves(journal, "player") >= 3) {
|
|
reloaded = true;
|
|
await waitForPlayer();
|
|
await shot("3-milieu-de-partie");
|
|
const before = await savedJournal();
|
|
const pips = await page.textContent("#pips");
|
|
await collectViolations();
|
|
await page.reload();
|
|
await waitForPlayer();
|
|
const after = await savedJournal();
|
|
check(after.head === before.head, "la partie n'a pas été reprise après rechargement");
|
|
check((await page.textContent("#pips")) === pips, "plateau différent après rechargement");
|
|
log(`partie reprise après rechargement (événement ${after.head})`);
|
|
}
|
|
}
|
|
|
|
const journal = await savedJournal();
|
|
const result = await page.textContent("#prompt");
|
|
log(
|
|
`partie finie : ${result} (${countMoves(journal, "player")} coups du joueur, ` +
|
|
`${countMoves(journal, "computer")} de l'ordinateur, ${journal.events.length} événements)`,
|
|
);
|
|
check(countMoves(journal, "player") >= 3 && countMoves(journal, "computer") >= 3, "trop peu de coups");
|
|
check(reloaded, "la reprise après rechargement n'a pas été essayée");
|
|
if (computerMs.length > 0) {
|
|
log(`temps affichés des tours de l'ordinateur : max ${Math.max(...computerMs)} ms, ` +
|
|
`${computerMs.length} tours`);
|
|
}
|
|
await shot("4-fin-de-partie");
|
|
|
|
step("aide");
|
|
// Aide : scénario et glossaire lus dans les copies de docs/.
|
|
await page.click("#help-button");
|
|
await page.waitForSelector(".glossary dt");
|
|
const terms = await page.locator(".glossary dt").count();
|
|
const scenarios = await page.locator("#help-content h3").count();
|
|
check(terms >= 20 && scenarios >= 5, `aide incomplète : ${terms} termes, ${scenarios} scénarios`);
|
|
log(`aide : ${scenarios} scénarios, ${terms} termes du glossaire`);
|
|
await shot("5-aide");
|
|
await page.click("#help-back");
|
|
|
|
step("thème sombre");
|
|
// Thème sombre.
|
|
await page.emulateMedia({ colorScheme: "dark" });
|
|
await page.click("#again");
|
|
await waitForPlayer();
|
|
await shot("6-sombre");
|
|
await page.emulateMedia({ colorScheme: "light" });
|
|
|
|
step("paysage");
|
|
// Téléphone tourné : le plateau et les boutons tiennent dans l'écran.
|
|
for (const [width, height] of [[915, 412], [740, 360]]) {
|
|
await page.setViewportSize({ width, height });
|
|
const board = await page.locator("#board").boundingBox();
|
|
const actions = await page.locator(".actions").boundingBox();
|
|
check(board.y + board.height <= height && board.x + board.width <= width,
|
|
`paysage ${width}x${height} : plateau hors de l'écran`);
|
|
check(actions.y + actions.height <= height, `paysage ${width}x${height} : boutons hors de l'écran`);
|
|
}
|
|
log("paysage : plateau et boutons visibles en entier");
|
|
await shot("6-paysage");
|
|
await page.setViewportSize({ width: 360, height: 740 });
|
|
|
|
step("mise en cache");
|
|
// Hors ligne : une fois le cache du service worker rempli, la page se
|
|
// recharge sans réseau, et le moteur répond.
|
|
const cached = await page.evaluate(async () => {
|
|
// Le service worker est enregistré une fois le moteur chargé ; s'il ne
|
|
// s'installe pas, l'attente s'arrête au bout d'une minute.
|
|
for (let i = 0; i < 600; i++) {
|
|
const names = await caches.keys();
|
|
if (names.length === 1) {
|
|
const cache = await caches.open(names[0]);
|
|
const keys = await cache.keys();
|
|
const active = (await navigator.serviceWorker.getRegistration())?.active;
|
|
if (active && keys.some((r) => r.url.endsWith("/wasm/engine.data"))) {
|
|
return keys.length;
|
|
}
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
}
|
|
return 0;
|
|
});
|
|
check(cached > 0, "le service worker n'a pas mis le site en cache");
|
|
// Firefox sous Playwright refuse toute navigation une fois le contexte
|
|
// « hors ligne », même servie par le service worker (NS_ERROR_OFFLINE) : on
|
|
// y vérifie seulement que la page rechargée est contrôlée par le service
|
|
// worker, dont le cache est complet.
|
|
const offlineMode = browserName === "chromium";
|
|
if (offlineMode) {
|
|
await context.setOffline(true);
|
|
}
|
|
step("rechargement hors ligne");
|
|
await collectViolations();
|
|
await page.reload();
|
|
const controlled = await page.evaluate(() => navigator.serviceWorker.controller !== null);
|
|
check(controlled, "la page rechargée n'est pas contrôlée par le service worker");
|
|
const offline = await waitForPlayer();
|
|
if (offline === "roll") {
|
|
await page.click("#roll");
|
|
await waitForPlayer();
|
|
}
|
|
await page.click("#help-button");
|
|
await page.waitForSelector(".glossary dt");
|
|
await page.click("#help-back");
|
|
log(
|
|
offlineMode
|
|
? `hors ligne : page rechargée sans réseau depuis le cache (${cached} adresses), moteur et aide disponibles`
|
|
: `cache du service worker rempli (${cached} adresses), page rechargée sous son contrôle ` +
|
|
"(le hors ligne lui-même n'est pas simulable dans Firefox sous Playwright)",
|
|
);
|
|
await shot("7-hors-ligne");
|
|
|
|
step("CSP");
|
|
// Politique de sécurité du contenu : aucune violation pendant toute la
|
|
// partie (moteur WebAssembly, Web Worker, service worker, aide compris) ; et
|
|
// elle est bien appliquée : un script en ligne ajouté à la page est bloqué.
|
|
await collectViolations();
|
|
check(violations.length === 0, `violations de la CSP : ${violations.join(", ")}`);
|
|
cspProbe = true;
|
|
const probe = await page.evaluate(async () => {
|
|
const script = document.createElement("script");
|
|
script.textContent = "window.__inline = true;";
|
|
document.head.append(script);
|
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
return { ran: window.__inline === true, violations: window.__csp };
|
|
});
|
|
check(!probe.ran, "CSP non appliquée : un script en ligne a été exécuté");
|
|
check(
|
|
probe.violations.some((v) => v.startsWith("script-src")),
|
|
"CSP : le script en ligne bloqué n'a pas été signalé",
|
|
);
|
|
log("CSP : aucune violation pendant la partie, script en ligne bloqué");
|
|
|
|
clearInterval(watchdog);
|
|
await browser.close();
|
|
if (problems.length > 0) {
|
|
console.error(problems.join("\n"));
|
|
process.exit(1);
|
|
}
|
|
log("aucune erreur dans la page");
|