// 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 // 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 // /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 "); 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/` : ""}`);