Étape 0d.2 : plateau jouable en PWA hors ligne, aide intégrée #13
+8
-3
@@ -71,7 +71,11 @@ make deploy-prod`.
|
||||
en-têtes dont la politique de sécurité, et commit de `version.json`.
|
||||
- `make test-browser` (hors de `make test`, plusieurs minutes) joue une
|
||||
partie complète par l'interface dans Chromium et Firefox sans écran, sur
|
||||
la même image, puis vérifie la reprise, l'aide et le hors ligne.
|
||||
la même image, puis vérifie la reprise, l'aide, le hors ligne (Chromium
|
||||
seulement : Firefox sous Playwright refuse toute navigation « hors
|
||||
ligne », on y vérifie que le cache est complet et que la page rechargée
|
||||
est servie par le service worker) et la politique de sécurité. Captures
|
||||
d'écran dans `build/browser-shots/`.
|
||||
|
||||
**Hors ligne et mises à jour.** Le service worker met tous les fichiers du
|
||||
site en cache à la première visite (environ 9 Mo), puis sert tout depuis ce
|
||||
@@ -93,8 +97,9 @@ politique de sécurité du contenu (`Content-Security-Policy`) : tout vient du
|
||||
site lui-même, aucun script ni style en ligne, aucun `eval`, seule la
|
||||
compilation du WebAssembly est permise (`'wasm-unsafe-eval'`). Elle est
|
||||
vérifiée par `tests/test_site.sh` (en-tête présent) et par
|
||||
`make test-browser` (aucune violation signalée par Chromium ni Firefox
|
||||
pendant une partie).
|
||||
`make test-browser` : aucune violation signalée par Chromium ni Firefox
|
||||
pendant toute une partie (moteur, Web Worker, service worker, aide), et un
|
||||
script en ligne ajouté exprès à la page est bien bloqué.
|
||||
|
||||
## Vérifier
|
||||
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ for browser in $browsers; do
|
||||
# Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker
|
||||
# demande un contexte sûr, ce qu'est localhost.
|
||||
docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
|
||||
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
|
||||
-e TRACE -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
|
||||
node play.mjs "http://localhost:$port/" "$browser" /shots || status=1
|
||||
done
|
||||
exit "$status"
|
||||
|
||||
+119
-6
@@ -26,17 +26,51 @@ const context = await browser.newContext({
|
||||
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") {
|
||||
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);
|
||||
@@ -79,9 +113,40 @@ async function waitForPlayer() {
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error("rien à faire pour le joueur depuis 60 s");
|
||||
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();
|
||||
@@ -95,6 +160,10 @@ 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) {
|
||||
@@ -136,6 +205,7 @@ for (let actions = 0; ; actions++) {
|
||||
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();
|
||||
@@ -159,6 +229,7 @@ if (computerMs.length > 0) {
|
||||
}
|
||||
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");
|
||||
@@ -169,6 +240,7 @@ 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");
|
||||
@@ -176,16 +248,19 @@ 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 () => {
|
||||
await navigator.serviceWorker.ready;
|
||||
// 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();
|
||||
if (keys.some((r) => r.url.endsWith("/wasm/engine.data"))) {
|
||||
const active = (await navigator.serviceWorker.getRegistration())?.active;
|
||||
if (active && keys.some((r) => r.url.endsWith("/wasm/engine.data"))) {
|
||||
return keys.length;
|
||||
}
|
||||
}
|
||||
@@ -194,8 +269,19 @@ const cached = await page.evaluate(async () => {
|
||||
return 0;
|
||||
});
|
||||
check(cached > 0, "le service worker n'a pas mis le site en cache");
|
||||
await context.setOffline(true);
|
||||
// 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");
|
||||
@@ -204,9 +290,36 @@ if (offline === "roll") {
|
||||
await page.click("#help-button");
|
||||
await page.waitForSelector(".glossary dt");
|
||||
await page.click("#help-back");
|
||||
log(`hors ligne : page rechargée depuis le cache (${cached} adresses), moteur et aide disponibles`);
|
||||
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"));
|
||||
|
||||
Reference in New Issue
Block a user