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