Tests navigateur : accueil, tutoriel, aide, installation, mise à jour ; plan : résultat

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-09 15:35:00 +01:00
co-authored by Claude Opus 5.5
parent 441bca1bd7
commit 5fdca812d9
7 changed files with 644 additions and 30 deletions
+430
View File
@@ -0,0 +1,430 @@
// accueil.mjs -- l'accueil, le tutoriel des règles, l'aide, l'installation
// et la mise à jour de l'application, dans un vrai navigateur sans écran
// (Playwright), sur le site servi par l'image de production
// (docs/plans/2026-11-03-accueil-tutoriel-aide.md, point 7).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Usage : node accueil.mjs <adresse du site> <chromium|firefox> <dossier des
// captures> <adresse du site modifiable> <dossier de ce site>
// Lancé par tests/browser.sh. Le second site sert la même chose depuis un
// dossier que ce script modifie : il y publie une deuxième version pour
// essayer la mise à jour. Captures de la recette design (Chromium) dans
// <dossier des captures>/accueil-tutoriel/, en 412×915 et 915×412.
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { chromium, firefox } from "playwright";
const [base, browserName, shots, updateBase, updateDir] = process.argv.slice(2);
const browserType = { chromium, firefox }[browserName];
if (!base || !browserType || !shots || !updateBase || !updateDir) {
console.error("usage : node accueil.mjs <adresse> <chromium|firefox> <captures> <adresse 2> <dossier 2>");
process.exit(2);
}
const full = browserName === "chromium";
const dir = `${shots}/accueil-tutoriel`;
mkdirSync(dir, { recursive: true });
const SIZES = [
[412, 915],
[915, 412],
];
const browser = await browserType.launch();
const problems = [];
const log = (text) => console.log(`[${browserName}] ${text}`);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let captures = 0;
function check(condition, message) {
if (!condition) {
throw new Error(message);
}
}
/** Un contexte neuf (stockage vide), avec des réglages déjà enregistrés si
* settings est donné. Erreurs de la page et violations de la CSP relevées. */
async function newPage(settings = null, url = base) {
const context = await browser.newContext({
viewport: { width: 412, height: 915 },
deviceScaleFactor: 1,
locale: "fr-FR",
});
await context.addInitScript((saved) => {
window.__csp = [];
document.addEventListener("securitypolicyviolation", (e) =>
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
);
// Un élément est-il à l'écran ? (Utilisé par les attentes.)
window.__shown = (id) => {
const el = document.getElementById(id);
return el !== null && !el.closest("[hidden]") && el.getClientRects().length > 0;
};
window.__vibrations = [];
Object.defineProperty(Navigator.prototype, "vibrate", {
configurable: true,
value: (pattern) => {
window.__vibrations.push(String(pattern));
return true;
},
});
if (saved !== null && localStorage.getItem("ustagammon.settings") === null) {
localStorage.setItem("ustagammon.settings", saved);
}
}, settings === null ? null : JSON.stringify(settings));
const page = await context.newPage();
page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`));
page.on("console", (message) => {
if (message.type() === "error") {
problems.push(`console : ${message.text()}`);
}
});
page.on("dialog", (dialog) => {
problems.push(`boîte native : ${dialog.type()}`);
void dialog.dismiss();
});
await page.goto(url);
return { context, page };
}
/** Attend que test (évalué dans la page) soit vrai, au plus ms. (Pas de
* waitForFunction : Playwright y passe par eval, que la CSP interdit.) */
async function until(page, what, test, arg, ms = 30_000) {
let last = null;
for (const end = Date.now() + ms; Date.now() < end; ) {
last = await page.evaluate(test, arg).catch((error) => `page illisible : ${error.message}`);
if (last === true) {
return;
}
await sleep(100);
}
await page.screenshot({ path: `${shots}/${browserName}-accueil-echec.png` }).catch(() => {});
throw new Error(`${what} : attente dépassée (${JSON.stringify(last)})`);
}
/** Range une capture de l'écran courant dans les deux tailles (Chromium). */
async function capture(page, name, settle = async () => {}) {
if (!full) {
return;
}
for (const [width, height] of SIZES) {
await page.setViewportSize({ width, height });
await sleep(250);
await settle();
await page.screenshot({ path: `${dir}/${name}-${width}x${height}.png`, fullPage: true });
captures++;
}
await page.setViewportSize({ width: 412, height: 915 });
await sleep(150);
}
/** Vérifie la CSP et ferme le contexte. */
async function close(context, page) {
const csp = await page.evaluate(() => window.__csp).catch(() => []);
check(csp.length === 0, `violations de la CSP : ${csp.join(", ")}`);
await context.close();
}
// ------------------------------------------------- Premier lancement
/** L'écran du tutoriel prêt : plateau dessiné pour cette adresse, coup
* illustré joué (« Revoir » utilisable) s'il y en a un. */
async function tutorialReady(page, hash) {
await until(page, `tutoriel ${hash}`, (hash) => {
const section = document.getElementById("tutorial");
const replay = document.getElementById("tutorial-replay");
return location.hash === hash && section.dataset.ready !== undefined &&
document.querySelectorAll("#tutorial-board polygon[data-point]").length === 24 &&
(replay.hidden || !replay.disabled);
}, hash);
}
/** Touche une case du plateau du tutoriel. */
async function touch(page, place) {
await page.click(`#tutorial-board [data-place="${place}"]`);
await sleep(200);
}
async function verdict(page) {
await until(page, "verdict", () => {
const v = document.getElementById("tutorial-feedback").dataset.verdict;
return v === "right" || v === "wrong";
});
return page.evaluate(() => document.getElementById("tutorial-feedback").dataset.verdict);
}
/** Les gestes des écrans « à vous » : un coup refusé, puis le bon. */
const PRACTICE = {
// 24/21 : ne peut plus mener à 8/5 6/5, refusé tout de suite.
3: { wrong: [["p24", "p21"]], right: [["p8", "p5"], ["p6"]] },
// 24/23 puis 24/18 : un coup complet qui ne frappe pas.
4: { wrong: [["p24", "p23"], ["p24"]], right: [["p13"], ["p8"]] },
// 6/3 : ne sort pas deux pions.
6: { wrong: [["p6", "p3"]], right: [["p6", "off-player"], ["p3"]] },
};
async function playTouches(page, touches) {
for (const group of touches) {
for (const place of group) {
await touch(page, place);
}
await until(page, "demi-coup joué", () => !document.querySelector("#tutorial[data-busy]"));
await sleep(150);
}
}
{
const { context, page } = await newPage();
await until(page, "premier lancement", () =>
location.hash === "#/accueil" && !document.getElementById("home-welcome").hidden);
const first = await page.evaluate(() => ({
topbar: getComputedStyle(document.querySelector(".topbar")).display,
links: [...document.querySelectorAll("#home-welcome a.choice")].map((a) => a.getAttribute("href")),
main: document.getElementById("home-main").hidden,
}));
check(first.topbar === "none" && first.main, `premier lancement : ${JSON.stringify(first)}`);
check(JSON.stringify(first.links) === '["#/tutoriel/1","#/"]', `choix : ${first.links}`);
await capture(page, "01-premier-lancement");
log("premier lancement : « Je découvre » et « Je connais les règles », de vrais liens");
// Le tutoriel complet, par ses liens « Continuer ».
await page.click("#welcome-discover");
let screens = 0;
for (let step = 1; step <= 8; step++) {
const hash = `#/tutoriel/${step}`;
await tutorialReady(page, hash);
// La barre ne mène qu'aux étapes déjà vues.
const links = await page.evaluate(() =>
[...document.querySelectorAll("#tutorial-progress a")].map((a) => a.getAttribute("href")));
const expected = Array.from({ length: step - 1 }, (_, i) => `#/tutoriel/${i + 1}`);
check(JSON.stringify(links) === JSON.stringify(expected),
`étape ${step} : barre de progression ${JSON.stringify(links)}`);
const illustrated = await page.evaluate(() => !document.getElementById("tutorial-replay").hidden);
if (illustrated) {
check(await page.locator("#tutorial-board .trace-arrow").count() > 0,
`étape ${step} : le coup illustré n'a pas laissé de traces`);
// « Revoir » rejoue le coup.
await page.click("#tutorial-replay");
await tutorialReady(page, hash);
}
if (step === 2) {
check(await page.locator("#tutorial-board .tutorial-path").count() === 1,
"étape 2 : pas de chemin en pointillés");
}
await capture(page, `tutoriel-${step}`);
screens++;
await page.click("#tutorial-next");
const practice = await page.evaluate(() => location.hash.endsWith("/a-vous"));
if (!practice) {
continue;
}
const avous = `${hash}/a-vous`;
await tutorialReady(page, avous);
check(await page.evaluate(() => document.getElementById("tutorial-next").classList.contains("secondary")),
`étape ${step}, à vous : « Continuer » principal avant le geste`);
await capture(page, `tutoriel-${step}-a-vous`);
if (step === 8) {
await page.click("#tutorial-drop");
check(await verdict(page) === "wrong", "étape 8 : « Je passe » accepté");
await capture(page, "tutoriel-8-a-vous-refuse");
await page.click("#tutorial-take");
await until(page, "étape 8 juste", () =>
document.getElementById("tutorial-feedback").dataset.verdict === "right");
} else {
const moves = PRACTICE[step];
await playTouches(page, moves.wrong);
check(await verdict(page) === "wrong", `étape ${step} : coup faux accepté`);
// Le pion revient : de nouveau des pions jouables.
await until(page, "pion revenu", () => document.querySelectorAll("#tutorial-board .hit.can-move").length > 0);
const words = await page.textContent("#tutorial-feedback");
check(!/erreur/i.test(words), `étape ${step} : « erreur » dans le refus`);
await capture(page, `tutoriel-${step}-a-vous-refuse`);
await playTouches(page, moves.right);
check(await verdict(page) === "right", `étape ${step} : bon coup refusé`);
check(await page.locator("#tutorial-board .hit.can-move").count() === 0,
`étape ${step} : plateau encore jouable après le bon coup`);
}
check(!(await page.evaluate(() => document.getElementById("tutorial-next").classList.contains("secondary"))),
`étape ${step} : « Continuer » pas principal après le bon geste`);
const vibrated = await page.evaluate(() => window.__vibrations.at(-1));
check(vibrated === "12", `étape ${step} : vibration ${vibrated}`);
await capture(page, `tutoriel-${step}-a-vous-reussi`);
screens++;
await page.click("#tutorial-next");
}
await until(page, "fin du tutoriel", () => location.hash === "#/tutoriel/fin" &&
document.getElementById("tutorial").dataset.ready !== undefined);
await capture(page, "tutoriel-fin");
// Le retour du téléphone : l'écran d'avant.
await page.goBack();
await tutorialReady(page, "#/tutoriel/8/a-vous");
await page.goForward();
await until(page, "fin", () => location.hash === "#/tutoriel/fin");
await page.click("#tutorial-next");
await until(page, "première partie", () => location.hash === "#/" && window.__shown("game"));
log(`tutoriel : ${screens} écrans, gestes refusés puis justes sur les 4 écrans « à vous », fin`);
// Le choix ne revient plus : l'accueil, ensuite.
await page.goto(base);
await until(page, "accueil", () => location.hash === "#/accueil" &&
document.getElementById("home-welcome").hidden && !document.getElementById("home-main").hidden);
await close(context, page);
}
// --------------------------------------------------- Accueil, installation
{
// Une partie terminée : l'invitation à installer apparaît.
const settings = { welcomed: true, gamesStarted: 2, gamesFinished: 1, tutorialSeen: 99 };
const { context, page } = await newPage(settings);
await until(page, "accueil prêt", () => location.hash === "#/accueil" &&
!document.getElementById("home-new").disabled && !document.getElementById("install-card").hidden);
const home = await page.evaluate(() => ({
resume: document.getElementById("home-resume").getAttribute("href"),
primaries: [...document.querySelectorAll("#home a.button:not(.secondary), #home button:not(.secondary):not(.icon-button)")]
.filter((el) => !el.closest("[hidden]")).length,
rules: document.getElementById("home-rules").getAttribute("href"),
}));
check(home.resume === "#/" && home.primaries === 1 && home.rules === "#/tutoriel/1",
`accueil : ${JSON.stringify(home)}`);
await capture(page, "02-accueil");
await capture(page, "03-carte-installation", async () => {
await page.locator("#install-card").scrollIntoViewIfNeeded();
});
// Sans invite du navigateur : la marche à suivre, une feuille avec son
// adresse, que le retour referme.
await page.click("#install-card-button");
await until(page, "feuille d'installation", () => location.hash === "#/installer" && window.__shown("install-sheet"));
check((await page.locator("#install-steps li").count()) === 3, "marche à suivre sans trois étapes");
await capture(page, "04-feuille-installation");
await page.goBack();
await until(page, "feuille refermée", () => location.hash === "#/accueil" && !window.__shown("install-sheet"));
if (browserName === "chromium") {
// L'invite de Chrome, simulée : « Installer » l'ouvre.
await page.evaluate(() => {
window.__prompted = 0;
const event = new Event("beforeinstallprompt", { cancelable: true });
event.prompt = () => {
window.__prompted++;
return Promise.resolve();
};
event.userChoice = Promise.resolve({ outcome: "dismissed" });
window.dispatchEvent(event);
});
await page.click("#install-card-button");
await until(page, "invite du navigateur", () => window.__prompted === 1);
check(await page.evaluate(() => location.hash) === "#/accueil", "l'invite a ouvert la feuille");
// Installée : plus aucune proposition.
await page.evaluate(() => window.dispatchEvent(new Event("appinstalled")));
await until(page, "installée", () => document.getElementById("install-card").hidden &&
document.getElementById("menu-install").hidden);
log("installation : invite de Chrome simulée, puis plus rien une fois installée");
} else {
await page.click("#install-later");
await until(page, "plus tard", () => document.getElementById("install-card").hidden);
log("installation : marche à suivre, « Plus tard »");
}
await close(context, page);
}
// ------------------------------------------------------------------- Aide
{
const { context, page } = await newPage({ welcomed: true, gamesStarted: 2 }, `${base}#/aide`);
await until(page, "aide", () => window.__shown("help-play") && document.getElementById("help-content").dataset.loaded === "true");
const help = await page.evaluate(() => ({
steps: document.querySelectorAll("#help-play .steps, #help-play li").length,
questions: [...document.querySelectorAll("#help-play .question h3")].map((h) => h.textContent),
tutorial: document.getElementById("help-tutorial")?.getAttribute("href"),
}));
check(help.steps === 0, "aide : les étapes Gherkin sont affichées");
check(help.questions.length >= 25 && help.tutorial === "#/tutoriel/1", `aide : ${JSON.stringify(help)}`);
check(help.questions.filter((q) => q.endsWith("?")).length >= 20, "aide : trop peu de questions");
await capture(page, "05-aide-jouer");
const question = await page.evaluate(() =>
[...document.querySelectorAll("#help-play .question")].find((q) => q.querySelector(".see-board"))?.id);
await page.click(`#${question} h3 a`);
await until(page, "question ouverte", (id) => document.getElementById(id).classList.contains("open"), question);
await capture(page, "06-aide-question");
// Le retour du téléphone referme la question.
await page.goBack();
await until(page, "question refermée", () => location.hash === "#/aide" && !document.querySelector(".question.open"));
await page.click('#help-tabs a[data-screen="glossary"]');
await until(page, "glossaire", () => window.__shown("help-glossary"));
const before = await page.locator("#help-glossary .term-card:not([hidden])").count();
await page.fill("#glossary-search", "sortir");
await sleep(200);
const after = await page.locator("#help-glossary .term-card:not([hidden])").count();
check(after > 0 && after < before, `recherche : ${after} fiches sur ${before}`);
await capture(page, "07-aide-glossaire-recherche");
await page.click('#help-tabs a[data-screen="about"]');
await until(page, "à propos", () => window.__shown("help-about"));
const version = await page.textContent("#about-version");
check(/^Version publiée : [0-9a-f]{12}\.$/.test(version), `version : « ${version} »`);
await capture(page, "08-aide-a-propos");
log(`aide : ${help.questions.length} questions, recherche dans le glossaire (${after}/${before}), ${version}`);
await close(context, page);
}
// ----------------------------------------------------------- Mise à jour
if (browserName === "chromium") {
const { context, page } = await newPage({ welcomed: true, gamesStarted: 2 }, `${updateBase}#/`);
await until(page, "partie prête", () => !document.body.classList.contains("busy"), undefined, 60_000);
// Le service worker installé et aux commandes (première installation :
// sans attendre l'accord du joueur).
await until(page, "service worker", async () => {
const reg = await navigator.serviceWorker.getRegistration();
return reg?.active?.state === "activated" && navigator.serviceWorker.controller !== null;
}, undefined, 120_000);
const journal = () => page.evaluate(() => localStorage.length >= 0 &&
new Promise((resolve) => {
const open = indexedDB.open("ustagammon");
open.onsuccess = () => {
const db = open.result;
const tx = db.transaction(["settings"]);
const current = tx.objectStore("settings").get("current");
current.onsuccess = () => {
db.close();
resolve(current.result?.value ?? null);
};
};
open.onerror = () => resolve(null);
}));
const gameBefore = await journal();
check(await page.isHidden("#update-banner"), "bandeau de mise à jour sans nouvelle version");
// Une deuxième version publiée : autre version.json, autre sw.js.
writeFileSync(`${updateDir}/version.json`, '{"commit": "0123456789abcdef0123", "dirty": false}\n');
const sw = readFileSync(`${updateDir}/sw.js`, "utf8");
writeFileSync(`${updateDir}/sw.js`, sw.replace(/^const VERSION = "[0-9a-f]+";$/m, 'const VERSION = "0000000000000002";'));
await page.click("#menu-button");
await page.waitForSelector("#menu", { state: "visible" });
await page.click("#menu-update");
await until(page, "bandeau de mise à jour", () => window.__shown("update-banner") && window.__shown("update-apply"), undefined, 120_000);
check((await page.textContent("#update-text")) === "Nouvelle version disponible.",
`bandeau : « ${await page.textContent("#update-text")} »`);
await capture(page, "09-mise-a-jour");
await Promise.all([page.waitForEvent("load", { timeout: 60_000 }), page.click("#update-apply")]);
await until(page, "partie reprise", () => location.hash === "#/" && !document.body.classList.contains("busy"), undefined, 60_000);
check(await journal() === gameBefore, "après la mise à jour, ce n'est plus la même partie");
await page.goto(`${updateBase}#/a-propos`);
await until(page, "nouvelle version", () =>
document.getElementById("about-version")?.textContent === "Version publiée : 0123456789ab.");
log("mise à jour : deuxième version publiée, bandeau, « Mettre à jour », partie retrouvée, nouvelle version");
// Rien de neuf : « Vous avez la dernière version ».
await page.goto(`${updateBase}#/`);
await until(page, "partie", () => !document.body.classList.contains("busy"), undefined, 60_000);
await page.click("#menu-button");
await page.waitForSelector("#menu", { state: "visible" });
await page.click("#menu-update");
await until(page, "dernière version", () =>
document.getElementById("update-text").textContent === "Vous avez la dernière version.", undefined, 60_000);
await close(context, page);
}
await browser.close();
if (problems.length > 0) {
console.error(problems.join("\n"));
process.exit(1);
}
log(`aucune erreur dans la page${full ? ` ; ${captures} captures dans accueil-tutoriel/` : ""}`);