diff --git a/tests/browser.sh b/tests/browser.sh index 9867947..d87df9f 100755 --- a/tests/browser.sh +++ b/tests/browser.sh @@ -11,9 +11,12 @@ # Le conseil du maître (tests/browser/maitre.mjs), captures dans # build/browser-shots/maitre/. Le bilan de fin de partie et l'écran # d'analyse (tests/browser/bilan.mjs), captures dans -# build/browser-shots/bilan/. +# build/browser-shots/bilan/. « Nouvelle partie » après une partie finie et +# son bilan, par chaque entrée (tests/browser/nouvelle-partie.mjs), captures +# dans build/browser-shots/nouvelle-partie/. # -# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre, bilan) +# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre, bilan, +# nouvelle-partie) # restreignent le test. # # Outils : l'image de Playwright (navigateurs) et le paquet playwright, @@ -27,7 +30,7 @@ cd "$(dirname "$0")/.." image="${PLAYWRIGHT_IMAGE:?lancer via make test-browser}" browsers="${BROWSERS:-chromium firefox}" # Les scripts à lancer (tests/browser/.mjs), tous par défaut. -scripts="${SCRIPTS:-play accueil match maitre bilan}" +scripts="${SCRIPTS:-play accueil match maitre bilan nouvelle-partie}" unset MAKEFLAGS MFLAGS MAKELEVEL make -s package >/dev/null @@ -65,7 +68,7 @@ update_port="$(docker port "$update_container" 80/tcp | head -n1 | sed 's/.*://' rm -rf build/browser-shots mkdir -p build/browser-shots cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/recette.mjs \ - tests/browser/maitre.mjs tests/browser/bilan.mjs "$tools/" + tests/browser/maitre.mjs tests/browser/bilan.mjs tests/browser/nouvelle-partie.mjs "$tools/" status=0 for browser in $browsers; do # Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker @@ -105,5 +108,13 @@ for browser in $browsers; do -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \ node bilan.mjs "http://localhost:$port/" "$browser" /shots || status=1 fi + if [[ " $scripts " == *" nouvelle-partie "* ]]; then + # « Nouvelle partie » après une partie finie et son bilan : la + # nouvelle partie tout de suite ; captures dans + # build/browser-shots/nouvelle-partie/. + 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" \ + node nouvelle-partie.mjs "http://localhost:$port/" "$browser" /shots || status=1 + fi done exit "$status" diff --git a/tests/browser/nouvelle-partie.mjs b/tests/browser/nouvelle-partie.mjs new file mode 100644 index 0000000..03e8535 --- /dev/null +++ b/tests/browser/nouvelle-partie.mjs @@ -0,0 +1,321 @@ +// nouvelle-partie.mjs -- après une partie finie et son bilan, « Nouvelle +// partie » mène tout droit à la nouvelle partie, sur le plateau, avec les +// réglages choisis, par chaque entrée vers #/nouvelle-partie : l'accueil +// (l'application rouverte), le menu, « Changer les réglages » du bilan. +// Dans un vrai navigateur sans écran (Playwright), sur le site servi par +// l'image de production. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Usage : node nouvelle-partie.mjs +// Lancé par tests/browser.sh. Défaut signalé par un joueur : « tu fais +// nouvelle partie […] tu te retrouves sur le résultat du match précédent et +// tu dois cliquer à nouveau sur nouvelle partie ». Chaque partie finie est +// une fin de course courte, partie d'un lien de position avec des dés +// imposés, comme dans bilan.mjs. Vérifie aussi le libellé du bouton +// (« Commencer la partie », « Commencer le match ») et que le coup de +// l'ordinateur, sur la ligne d'information, mène à la fiche « Notation des +// coups ». Captures (Chromium) dans /nouvelle-partie/, +// en 412×915 et 915×412. + +import { mkdirSync } from "node:fs"; +import { chromium, firefox } from "playwright"; +import { hiddenUnderActionBar } from "./recette.mjs"; + +const [base, browserName, shots] = process.argv.slice(2); +const browserType = { chromium, firefox }[browserName]; +if (!base || !browserType || !shots) { + console.error("usage : node nouvelle-partie.mjs "); + process.exit(2); +} +const full = browserName === "chromium"; +const dir = `${shots}/nouvelle-partie`; +mkdirSync(dir, { recursive: true }); +const PORTRAIT = { width: 412, height: 915 }; +const LANDSCAPE = { width: 915, height: 412 }; +// Le joueur au trait : un pion sur 6, un sur 1 ; l'ordinateur : un sur 1, +// un sur 2. Le joueur joue 6/5/off, l'ordinateur double, le joueur passe. +const BEAROFF = "BQAACAIAAAAAAA"; + +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); + } +} + +const browser = await browserType.launch(); +const context = await browser.newContext({ + viewport: PORTRAIT, + deviceScaleFactor: 1, + locale: "fr-FR", + reducedMotion: "reduce", +}); +await context.addInitScript(() => { + // Les dés imposés d'abord (un dé par valeur), puis une graine fixe. + window.__dice = []; + let state = 7; + Math.random = () => { + const die = window.__dice.shift(); + if (die !== undefined) { + return (die - 0.5) / 6; + } + state = (state * 1103515245 + 12345) % 2147483648; + return state / 2147483648; + }; + window.__csp = []; + document.addEventListener("securitypolicyviolation", (e) => + window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`), + ); +}); +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()}`); + } +}); + +/** Attend une condition dans la page (sans page.waitForFunction, qui passe + * par eval, interdit par la politique de sécurité du site). */ +async function until(what, fn, arg, ms = 120_000) { + for (let waited = 0; waited < ms; waited += 100) { + if (await page.evaluate(fn, arg)) { + return; + } + await sleep(100); + } + await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {}); + throw new Error(`attente trop longue : ${what} (adresse ${await page.evaluate(() => location.hash)})`); +} + +const idle = () => until("écran libre", () => !document.body.classList.contains("busy")); + +/** Une capture en portrait et en paysage (Chromium seulement), après la + * règle de la recette : rien d'interactif sous une barre d'action. */ +async function capture(name) { + if (!full) { + return; + } + for (const size of [PORTRAIT, LANDSCAPE]) { + await page.setViewportSize(size); + await sleep(300); + const hidden = await hiddenUnderActionBar(page); + check( + hidden.length === 0, + `${name} ${size.width}x${size.height} : caché sous la barre d'action : ${hidden.join(", ")}`, + ); + await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` }); + captures++; + } + await page.setViewportSize(PORTRAIT); + await sleep(300); +} + +/** Une partie finie et son bilan à l'écran : une fin de course depuis un + * lien de position, 6/5/off, puis le double de l'ordinateur passé. Renvoie + * l'identifiant de la partie. */ +async function finishedGameWithSummary() { + await page.goto(`${base}#/position/${encodeURIComponent(BEAROFF)}`); + await until("position lue", () => { + const play = document.getElementById("position-play"); + return !play.hidden && !play.disabled; + }); + await page.click("#position-play"); + await until( + "partie commencée", + () => + location.hash === "#/" && + !document.body.classList.contains("busy") && + !document.getElementById("roll").hidden, + ); + await page.evaluate(() => window.__dice.push(6, 1)); + await page.click("#roll"); + await idle(); + for (const [from, to] of [ + ["p6", "p5"], + ["p5", "off-player"], + ]) { + await page.click(`#board .hit[data-place="${from}"]`); + await idle(); + const land = page.locator(`#board .hit.can-land[data-place="${to}"]`); + if ((await land.count()) > 0) { + await land.click(); + await idle(); + } + } + await page.click("#confirm"); + await until("double de l'ordinateur", () => !document.getElementById("drop").hidden); + await page.click("#drop"); + await until( + "bilan de la partie finie", + () => + /^#\/parties\/[a-z0-9-]+\/bilan$/.test(location.hash) && + document.getElementById("summary")?.dataset.ready === "true", + ); + return page.evaluate(() => location.hash.split("/")[2]); +} + +/** Choisit les réglages sur l'écran « Nouvelle partie », vérifie le libellé + * du bouton, puis commence. */ +async function chooseAndStart(choice, shot) { + await until( + "écran « Nouvelle partie »", + () => + location.hash === "#/nouvelle-partie" && + !document.getElementById("new-game").hidden && + !document.getElementById("new-game-start").disabled, + ); + await page.click(choice.match ? "#new-game-match" : "#new-game-free"); + if (choice.match) { + await page.click(`#new-game-length-${choice.length}`); + } + await page.click(`#new-game-level-${choice.level}`); + const label = await page.textContent("#new-game-start"); + const expected = choice.match ? "Commencer le match" : "Commencer la partie"; + check(label === expected, `bouton : « ${label} », attendu « ${expected} »`); + if (shot) { + await capture("1-nouvelle-partie"); + } + await page.click("#new-game-start"); +} + +/** La nouvelle partie est à l'écran, sur le plateau, avec les réglages + * choisis ; ni le bilan ni le panneau de fin de l'ancienne partie, même un + * peu plus tard. */ +async function checkNewGame(entry, oldId, choice) { + await until( + `${entry} : partie commencée`, + () => location.hash === "#/" && !document.body.classList.contains("busy"), + ); + // Le défaut apparaissait juste après : l'adresse passait au bilan de + // l'ancienne partie. On regarde pendant deux secondes. + for (let waited = 0; waited < 2000; waited += 100) { + const s = await page.evaluate(() => ({ + hash: location.hash, + summary: !document.getElementById("summary-veil").hidden, + panel: !document.getElementById("panel-veil").hidden, + board: !document.getElementById("game").hidden, + newGame: !document.getElementById("new-game").hidden, + })); + check( + s.hash === "#/" && !s.summary && !s.panel && s.board && !s.newGame, + `${entry} : la nouvelle partie n'est pas à l'écran (ancienne partie ${oldId}) : ${JSON.stringify(s)}`, + ); + await sleep(100); + } + await idle(); + const shown = await page.evaluate(() => ({ + line: document.getElementById("match-line").textContent, + lineShown: !document.getElementById("match-line").hidden, + level: JSON.parse(localStorage.getItem("ustagammon.settings") ?? "{}").level, + over: document.getElementById("again").hidden === false, + })); + check(!shown.over, `${entry} : la partie à l'écran est finie`); + check(shown.level === choice.level, `${entry} : niveau ${shown.level}`); + if (choice.match) { + check( + shown.lineShown && shown.line === `Match en ${choice.length} points`, + `${entry} : ligne du match « ${shown.line} »`, + ); + } else { + check(!shown.lineShown, `${entry} : un match au lieu d'une partie libre`); + } + log(`${entry} : nouvelle partie directe (${choice.match ? `match en ${choice.length}` : "partie libre"}, ${choice.level})`); +} + +// --------------------------- 1. L'application rouverte, puis l'accueil + +let oldId = await finishedGameWithSummary(); +// L'application rouverte : sans adresse, elle ouvre l'accueil. +await page.goto(base); +await until( + "accueil", + () => + location.hash === "#/accueil" && + !document.getElementById("home-new").hidden && + !document.getElementById("home-new").disabled, +); +await page.click("#home-new"); +let choice = { match: true, length: 3, level: "beginner" }; +await chooseAndStart(choice, true); +await checkNewGame("accueil", oldId, choice); +await capture("2-partie-demarree"); + +// Le coup de l'ordinateur, sur la ligne d'information, mène à la fiche +// « Notation des coups ». On joue jusqu'à ce qu'il ait joué un coup. +for (let actions = 0; ; actions++) { + check(actions < 200, "l'ordinateur ne joue pas de coup"); + const link = await page.evaluate(() => document.querySelector("#info a.notation")?.href ?? null); + if (link !== null) { + check(link.endsWith("#/glossaire/notation-des-coups"), `lien de la notation : ${link}`); + break; + } + const what = await page.evaluate(() => { + const usable = (id) => { + const b = document.getElementById(id); + return !b.hidden && !b.disabled; + }; + for (const id of ["take", "skip", "confirm", "roll"]) { + if (usable(id)) { + return `#${id}`; + } + } + if (document.querySelector("#board .hit.can-land")) { + return "#board .hit.can-land"; + } + return document.querySelector("#board .hit.can-move") ? "#board .hit.can-move" : null; + }); + if (what === null) { + await sleep(100); + continue; + } + await page.locator(what).first().click(); + await idle(); +} +await page.click("#info a.notation"); +await until( + "fiche « Notation des coups »", + () => + location.hash === "#/glossaire/notation-des-coups" && + document.getElementById("glossaire-notation-des-coups") !== null, +); +await page.goBack(); +await until("retour à la partie", () => location.hash === "#/"); +log("ligne de l'ordinateur : la notation mène à sa fiche"); + +// --------------------------------- 2. L'application rouverte sur le menu + +oldId = await finishedGameWithSummary(); +await page.goto(`${base}#/menu`); +await until("menu", () => !document.getElementById("menu-veil").hidden); +await page.click("#menu-new-game"); +choice = { match: false, level: "amateur" }; +await chooseAndStart(choice, false); +await checkNewGame("menu", oldId, choice); + +// ------------------------------- 3. « Changer les réglages » du bilan + +oldId = await finishedGameWithSummary(); +await page.click("#summary-settings"); +choice = { match: true, length: 5, level: "intermediate" }; +await chooseAndStart(choice, false); +await checkNewGame("bilan, « Changer les réglages »", oldId, choice); + +const csp = await page.evaluate(() => window.__csp); +for (const violation of csp) { + problems.push(`politique de sécurité : ${violation}`); +} +await browser.close(); +if (problems.length > 0) { + for (const problem of problems) { + console.error(`[${browserName}] ${problem}`); + } + process.exit(1); +} +log(`nouvelle partie : tout est conforme (${captures} captures)`);