// lecons.mjs -- les leçons (étape 1g) dans un vrai navigateur sans écran // (Playwright), sur le site servi par l'image de production : la carte // « prochaine leçon » de l'accueil, la liste des leçons, un écran // d'explication, un exercice refusé puis réussi, la progression gardée après // un rechargement, la fin d'une leçon et une adresse de leçon inconnue. // // Copyright (C) 2026 les auteurs d'Ustagammon. // Licence : GPLv3 ou ultérieure (voir LICENSE). // // Usage : node lecons.mjs . Lancé par tests/browser.sh. Captures de la recette (Chromium) // dans /lecons/, en 412×915 et 915×412. import { mkdirSync } from "node:fs"; import { chromium, firefox } from "playwright"; import { hiddenUnderActionBar, tallButtons } from "./recette.mjs"; const [base, browserName, shots] = process.argv.slice(2); const browserType = { chromium, firefox }[browserName]; if (!base || !browserType || !shots) { console.error("usage : node lecons.mjs "); process.exit(2); } const full = browserName === "chromium"; const dir = `${shots}/lecons`; 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); } } /** 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}-lecons-echec.png` }).catch(() => {}); throw new Error(`${what} : attente dépassée (${JSON.stringify(last)})`); } /** Range une capture de l'écran courant dans chaque taille (Chromium), avec * la recette : rien sous une barre d'action, aucun bouton étiré. */ async function capture(page, name, settle = async () => {}) { if (!full) { return; } for (const [width, height] of SIZES) { await page.setViewportSize({ width, height }); await sleep(300); await settle(); const hidden = await hiddenUnderActionBar(page); check(hidden.length === 0, `${name} ${width}x${height} : caché sous la barre d'action : ${hidden.join(", ")}`); const tall = await tallButtons(page); check(tall.length === 0, `${name} ${width}x${height} : boutons étirés : ${tall.join(", ")}`); await page.screenshot({ path: `${dir}/${name}-${width}x${height}.png`, fullPage: true }); captures++; } await page.setViewportSize({ width: 412, height: 915 }); await sleep(150); } /** L'écran d'une leçon prêt : plateau dessiné pour cette adresse, coup * illustré joué (« Revoir » utilisable) s'il y en a un. */ async function lessonReady(page, hash) { await until(page, `leçon ${hash}`, (hash) => { const section = document.getElementById("lesson"); const replay = document.getElementById("lesson-replay"); return location.hash === hash && section.dataset.ready !== undefined && !section.hidden && document.querySelectorAll("#lesson-board polygon[data-point]").length === 24 && (replay.hidden || !replay.disabled); }, hash); } /** Touche des cases du plateau de la leçon, groupe par groupe (un groupe : * un demi-coup). */ async function playTouches(page, touches) { for (const group of touches) { for (const place of group) { await page.click(`#lesson-board [data-place="${place}"]`); await sleep(200); } await until(page, "demi-coup joué", () => !document.querySelector("#lesson[data-busy]")); await sleep(150); } } async function verdict(page) { await until(page, "verdict", () => { const v = document.getElementById("lesson-feedback").dataset.verdict; return v === "right" || v === "wrong"; }); return page.evaluate(() => document.getElementById("lesson-feedback").dataset.verdict); } /** L'état affiché de chaque carte de la liste. */ async function listStates(page) { return page.evaluate(() => [...document.querySelectorAll("#lesson-list .lesson-card")].map((card) => ({ id: card.id, current: card.classList.contains("current"), state: card.querySelector(".lesson-card-state")?.textContent ?? "", done: card.querySelectorAll(".lesson-progress .segment.done").length, }))); } const context = await browser.newContext({ viewport: { width: 412, height: 915 }, deviceScaleFactor: 1, locale: "fr-FR", }); await context.addInitScript(() => { window.__csp = []; document.addEventListener("securitypolicyviolation", (e) => window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`), ); // Le premier lancement est passé : l'accueil complet. if (localStorage.getItem("ustagammon.settings") === null) { localStorage.setItem("ustagammon.settings", JSON.stringify({ welcomed: true, tutorialSeen: 99 })); } }); 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(); }); try { // ------------------------------------------- La carte de l'accueil await page.goto(`${base}#/accueil`); await until(page, "carte de la leçon", () => { const card = document.getElementById("home-lesson"); return location.hash === "#/accueil" && !card.hidden && !card.closest("[hidden]") && card.textContent.includes("Leçon 1") && !document.getElementById("home-new").disabled; }); const home = await page.evaluate(() => ({ go: document.getElementById("home-lesson-go").getAttribute("href"), goText: document.getElementById("home-lesson-go").textContent, goClass: document.getElementById("home-lesson-go").className, all: document.getElementById("home-lessons").getAttribute("href"), title: document.querySelector("#home-lesson .lesson-card-title").textContent, state: document.querySelector("#home-lesson .lesson-card-state").textContent, summary: document.getElementById("home-progress").textContent, primaries: [...document.querySelectorAll("#home a.button:not(.secondary), #home button:not(.secondary):not(.icon-button)")] .filter((el) => !el.closest("[hidden]")).length, })); check(home.go === "#/lecons/1/1" && home.goText === "Commencer" && home.goClass.includes("secondary") && home.all === "#/lecons" && home.title === "Les ouvertures" && home.state === "À commencer · 4 exercices" && home.summary.startsWith("0 leçon terminée sur 5") && home.primaries === 1, `carte de l'accueil : ${JSON.stringify(home)}`); await capture(page, "01-accueil-carte", async () => { await page.locator("#home-lesson").scrollIntoViewIfNeeded(); }); log("accueil : carte « prochaine leçon » (leçon 1, à commencer), « Commencer » en secondaire"); // --------------------------------------------------------- La liste await page.click("#home-lessons"); await until(page, "liste des leçons", () => location.hash === "#/lecons" && document.querySelectorAll("#lesson-list .lesson-card").length === 5); const list = await listStates(page); check(list[0].current && list.filter((c) => c.current).length === 1 && list.every((c) => c.state.startsWith("À commencer")), `liste : ${JSON.stringify(list)}`); const primaries = await page.evaluate(() => [...document.querySelectorAll("#lessons a.button:not(.secondary)")].filter((el) => !el.closest("[hidden]")).length); check(primaries === 1, `liste : ${primaries} actions principales`); check(await page.getAttribute("#lessons-rules", "href") === "#/tutoriel/1", "liste : rappel des règles"); await capture(page, "02-liste"); log("liste : cinq leçons, la première proposée, une seule action principale, rappel des règles"); // ------------------------------------------- Un écran d'explication await page.click("#lesson-card-1 .lesson-card-action"); await lessonReady(page, "#/lecons/1/1"); const explain = await page.evaluate(() => ({ overline: document.getElementById("lesson-overline").textContent, title: document.getElementById("lesson-title").textContent, segments: document.querySelectorAll("#lesson-progress .segment").length, skip: document.getElementById("lesson-skip").getAttribute("href"), back: document.getElementById("lesson-back").getAttribute("href"), next: document.getElementById("lesson-next").getAttribute("href"), topbar: getComputedStyle(document.querySelector(".topbar")).display, glossary: [...document.querySelectorAll("#lesson-text a")].map((a) => a.getAttribute("href")), })); check(explain.overline === "Leçon 1 · 1 sur 4" && explain.title === "Un point dès le premier coup" && explain.segments === 4 && explain.skip === "#/lecons" && explain.back === "#/lecons" && explain.next === "#/lecons/1/1/a-vous" && explain.topbar === "none" && explain.glossary.includes("#/glossaire/jan-interieur"), `explication : ${JSON.stringify(explain)}`); check(await page.locator("#lesson-board .trace-arrow").count() > 0, "le coup illustré n'a pas laissé de traces"); await capture(page, "03-explication"); log("explication : coup illustré 8/5 6/5 joué tout seul, barre de quatre étapes, liens du glossaire"); // ------------------------------------------- Un exercice « à vous » await page.click("#lesson-next"); await lessonReady(page, "#/lecons/1/1/a-vous"); // 13/11 : ne mène pas à 8/4 6/4, refusé. await playTouches(page, [["p13", "p11"]]); check(await verdict(page) === "wrong", "exercice : 13/11 accepté"); await until(page, "pion revenu", () => document.querySelectorAll("#lesson-board .hit.can-move").length > 0); const refused = await page.textContent("#lesson-feedback"); check(refused.startsWith("Ce coup est permis, mais") && !/erreur/i.test(refused), `refus : ${refused}`); await capture(page, "04-exercice-refuse"); await playTouches(page, [["p8", "p4"], ["p6"]]); check(await verdict(page) === "right", "exercice : 8/4 6/4 refusé"); check(await page.locator("#lesson-board .hit.can-move").count() === 0, "plateau encore jouable après le bon coup"); const right = await page.textContent("#lesson-feedback"); check(right.includes("8/4 6/4"), `réussite : ${right}`); check(!(await page.evaluate(() => document.getElementById("lesson-next").classList.contains("secondary"))), "« Continuer » pas principal après le bon coup"); await capture(page, "05-exercice-reussi"); log("exercice : 13/11 refusé avec bienveillance, 8/4 6/4 salué"); // ------------------------------------ La progression, gardée await page.goto(`${base}#/lecons`); await page.reload(); await until(page, "liste après rechargement", () => location.hash === "#/lecons" && document.querySelectorAll("#lesson-list .lesson-card").length === 5 && document.querySelector("#lesson-card-1 .lesson-card-state")?.textContent === "1 exercice sur 4"); const after = await listStates(page); check(after[0].current && after[0].done === 1, `liste après l'exercice : ${JSON.stringify(after)}`); await page.goto(`${base}#/accueil`); await until(page, "accueil après l'exercice", () => document.querySelector("#home-lesson .lesson-card-state")?.textContent === "1 exercice sur 4" && document.getElementById("home-lesson-go").textContent === "Continuer" && document.getElementById("home-lesson-go").getAttribute("href") === "#/lecons/1/1"); log("progression : « 1 exercice sur 4 » après rechargement, « Continuer » sur l'accueil"); // --------------------------------------- La fin d'une leçon await page.goto(`${base}#/lecons/1/fin`); await lessonReady(page, "#/lecons/1/fin"); const end = await page.evaluate(() => ({ overline: document.getElementById("lesson-overline").textContent, next: document.getElementById("lesson-next").getAttribute("href"), text: document.getElementById("lesson-next").textContent, })); check(end.overline === "Leçon 1 · fin" && end.next === "#/lecons/2/1" && end.text === "Leçon suivante", `fin : ${JSON.stringify(end)}`); await page.click("#lesson-next"); await lessonReady(page, "#/lecons/2/1"); log("fin : « Leçon suivante » mène à la leçon 2"); // Une leçon inconnue : la liste ; une étape inconnue : la première. await page.goto(`${base}#/lecons/9/1`); await until(page, "leçon inconnue", () => location.hash === "#/lecons" && document.querySelectorAll("#lesson-list .lesson-card").length === 5); await page.goto(`${base}#/lecons/3/9`); await lessonReady(page, "#/lecons/3/1"); log("adresses : leçon inconnue vers la liste, étape inconnue vers la première"); const csp = await page.evaluate(() => window.__csp); check(csp.length === 0, `violations de la CSP : ${csp.join(", ")}`); check(problems.length === 0, problems.join("\n")); log(`leçons : OK${full ? `, ${captures} captures dans ${dir}` : ""}`); } catch (error) { console.error(`[${browserName}] ÉCHEC : ${error.message}`); if (problems.length > 0) { console.error(problems.join("\n")); } await browser.close(); process.exit(1); } await browser.close();