Files
Ustagammon/tests/browser/play.mjs
T
Pierre MARTINandClaude Opus 5.5 2c43a0a69f Test dans les navigateurs : CSP vérifiée, diagnostic des blocages
make test-browser vérifie maintenant la politique de sécurité dans
Chromium et Firefox : aucune violation pendant toute la partie, et un
script en ligne ajouté exprès est bloqué. Un blocage est diagnostiqué
(étape, capture, dernière commande du moteur, journal de la partie) au
lieu d'attendre sans fin. Firefox sous Playwright refuse toute navigation
hors ligne : on y vérifie le cache et le contrôle par le service worker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:46:48 +01:00

329 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("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");