// prompts.mjs -- « Demander à une IA » (étape 1f) par l'interface, dans un // vrai navigateur sans écran (Playwright), sur le site servi par l'image de // production (charte, ecrans-etape-1.md, « 1f. Demander à une IA »). // // Copyright (C) 2026 les auteurs d'Ustagammon. // Licence : GPLv3 ou ultérieure (voir LICENSE). // // Usage : node prompts.mjs // Lancé par tests/browser.sh. La fin de course courte de bilan.mjs : le // joueur a 6-1, deux pions sur 6 et 1, et joue 6/5/off (grosse erreur) ; // l'ordinateur double, le joueur passe. Vérifie le bouton « Demander à une // IA » dans la feuille du maître, sur le bilan, dans l'analyse et sur une // fiche du glossaire ; la feuille du prompt à son adresse (…/ia), lisible // avant la copie, rechargeable ; une seule feuille à la fois (depuis la // feuille du maître ou le bilan, la page du prompt prend leur place, à la // même hauteur, avec « ‹ Explication » ou « ‹ Bilan ») ; la copie (dans // Chromium, le presse-papiers est relu et comparé au texte de la feuille, et // aux chiffres de la feuille du maître), confirmée sur le bouton (« Copié », // 2 s) ; le retour (« ‹ … », Échap, retour du téléphone) qui ramène à // l'écran d'avant, la croix qui ferme ; aucune requête vers un autre site ; // l'absence d'erreur et de violation de la politique de sécurité. Captures (Chromium) // dans /prompts/, 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 prompts.mjs "); process.exit(2); } const full = browserName === "chromium"; const dir = `${shots}/prompts`; mkdirSync(dir, { recursive: true }); const PORTRAIT = { width: 412, height: 915 }; const LANDSCAPE = { width: 915, height: 412 }; const SIZES = [PORTRAIT, LANDSCAPE]; 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", }); if (full) { // Chromium : le test relit le presse-papiers. await context.grantPermissions(["clipboard-read", "clipboard-write"], { origin: new URL(base).origin, }); } await context.addInitScript(() => { window.__dice = []; let state = 5; 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()}`); } }); // Aucun appel réseau hors du site : l'application n'appelle aucune IA. const origin = new URL(base).origin; page.on("request", (request) => { const url = request.url(); if (!url.startsWith(origin) && !url.startsWith("data:") && !url.startsWith("blob:")) { problems.push(`requête hors du site : ${url}`); } }); /** 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}`); } const idle = () => until("écran libre", () => !document.body.classList.contains("busy")); /** Une capture de la recette dans les deux tailles (Chromium seulement), * après la règle commune : rien d'interactif sous une barre d'action, pas * de bouton étiré. before() est rejoué avant chaque taille (« Copié » ne * dure que 2 s sur le bouton). */ async function capture(name, before = null) { if (!full) { return; } // « Copié » d'une copie précédente est reparti (sauf pour sa propre // capture, où before le fait réapparaître). await until("bouton revenu", notCopied, null, 10_000); for (const size of SIZES) { await page.setViewportSize(size); await sleep(300); await before?.(); const hidden = await hiddenUnderActionBar(page); check( hidden.length === 0, `${name} ${size.width}x${size.height} : caché sous la barre d'action : ${hidden.join(", ")}`, ); const tall = await tallButtons(page); check(tall.length === 0, `${name} ${size.width}x${size.height} : boutons étirés : ${tall.join(", ")}`); await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` }); captures++; } await page.setViewportSize(PORTRAIT); await sleep(300); } /** Le bouton de copie n'est pas (ou plus) en « Copié ». */ const notCopied = () => { const copy = document.getElementById("prompt-copy"); return copy === null || !copy.classList.contains("copied"); }; /** La page du prompt, prête et visible : son texte. */ async function openPrompt(button, hashEnd) { await page.locator(button).scrollIntoViewIfNeeded(); await page.click(button); await until( "feuille du prompt prête", (end) => { const prompt = document.getElementById("prompt-page"); return ( location.hash.endsWith(end) && prompt?.dataset.ready === "true" && prompt.getClientRects().length > 0 ); }, hashEnd, ); return page.textContent("#prompt-text"); } /** Une seule feuille visible : la page du prompt, dans la feuille hostId * (feuille du maître, bilan) ou dans la sienne (null) ; une poignée, une * croix et un titre visibles au plus ; « ‹ back ». */ async function checkOneSheet(hostId, back, what) { const seen = await page.evaluate((host) => { const visible = (el) => el.getClientRects().length > 0 && getComputedStyle(el).visibility === "visible"; const count = (selector) => [...document.querySelectorAll(selector)].filter(visible).length; const prompt = document.getElementById("prompt-page"); return { parent: prompt.parentElement.id, ownSheet: !document.getElementById("prompt-veil").hidden, grips: count(".grip"), closes: count('[role="dialog"] .icon-button'), titles: count('[role="dialog"] h2'), back: document.getElementById("prompt-back").textContent, hostVisible: host === null || !document.getElementById(host).parentElement.hidden, }; }, hostId); check(seen.parent === (hostId ?? "prompt-sheet"), `${what} : page du prompt dans ${seen.parent}`); check(seen.ownSheet === (hostId === null), `${what} : feuille propre ${seen.ownSheet}`); check(seen.hostVisible, `${what} : la feuille d'origine n'est pas affichée`); check(seen.grips <= 1, `${what} : ${seen.grips} poignées visibles`); check(seen.closes === 1, `${what} : ${seen.closes} croix visibles`); check(seen.titles === 1, `${what} : ${seen.titles} titres visibles`); check(seen.back === `‹ ${back}`, `${what} : retour « ${seen.back} »`); } /** La hauteur et le haut d'un élément. */ const boxOf = (selector) => page.evaluate((s) => { const r = document.querySelector(s).getBoundingClientRect(); return { top: Math.round(r.top), height: Math.round(r.height) }; }, selector); /** Ce que tout prompt doit contenir (cadrage, « IA générative en V1 »). */ function checkPrompt(text, what) { for (const part of [ "Tu es un coach de backgammon", "Je débute au backgammon.", "Position ID : ", "Match ID : ", "https://ustagammon.sans.pub/#/position/", "X : mes pions", "GNU Backgammon (analyse à 2 plis)", "ne les contredis pas", "Réponds en français", ]) { check(text.includes(part), `${what} : « ${part} » absent`); } check(!/\{[a-z_]+\}/.test(text) && !text.includes("undefined"), `${what} : repère vide`); } /** La copie, confirmée sur le bouton : « Copié », coche, verdict-good. */ const copiedOnButton = () => { const copy = document.getElementById("prompt-copy"); return copy.classList.contains("copied") && copy.textContent === "Copié"; }; /** Copie faite (« Copié ») ou repli (le message sous le texte). */ const copyDone = () => { const copy = document.getElementById("prompt-copy"); return ( copy.classList.contains("copied") || document.getElementById("prompt-status").textContent !== "" ); }; /** Copie le prompt, attend « Copié » sur le bouton puis son retour à * « Copier le prompt » après 2 s, et (Chromium) relit le presse-papiers. */ async function copyPrompt(what) { await page.click("#prompt-copy"); await until("copie faite", copyDone, null, 10_000); const shown = await page.evaluate(() => { const copy = document.getElementById("prompt-copy"); return { copied: copy.classList.contains("copied"), label: copy.textContent, check: copy.querySelector("svg.icon") !== null, color: getComputedStyle(copy).color, good: getComputedStyle(document.documentElement) .getPropertyValue("--verdict-good") .trim(), status: document.getElementById("prompt-status").textContent, text: document.getElementById("prompt-text").textContent, banners: document.querySelectorAll("#prompt-banner").length, }; }); check(shown.banners === 0, `${what} : un bandeau de copie`); if (shown.copied) { check(shown.label === "Copié" && shown.check, `${what} : bouton « ${shown.label} »`); // La couleur du bouton est verdict-good (#rrggbb dans app.css). const rgb = shown.good .replace("#", "") .match(/../g) .map((h) => Number.parseInt(h, 16)) .join(", "); check(shown.color === `rgb(${rgb})`, `${what} : couleur ${shown.color} pour ${shown.good}`); // Deux secondes après, le bouton revient. await until("« Copier le prompt » revenu", notCopied, null, 5_000); const label = await page.textContent("#prompt-copy"); check(label === "Copier le prompt", `${what} : le bouton reste « ${label} »`); } if (full) { check(shown.copied, `${what} : pas de « Copié » sur le bouton`); const clipboard = await page.evaluate(() => navigator.clipboard.readText()); check(clipboard === shown.text, `${what} : le presse-papiers diffère du texte affiché`); return clipboard; } // Firefox : la copie, ou son repli (texte sélectionné, et un message). check(shown.copied || shown.status.includes("sélectionné"), `${what} : ni copie ni repli`); log(`${what} : ${shown.copied ? "copié" : "repli par sélection"}`); return shown.text; } /** « ‹ … » : la page du prompt s'en va, l'écran d'avant revient. */ async function backFromPrompt(hashBelow) { await page.click("#prompt-back"); await promptGone(hashBelow); } /** La page du prompt n'est plus affichée, à l'adresse hashBelow. */ async function promptGone(hashBelow) { await until( "feuille du prompt fermée", (below) => document.getElementById("prompt-veil").hidden && document.querySelector(".prompting") === null && location.hash === below, hashBelow, ); } // --------------------------------------- Une erreur, et son explication 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 [[6, 5], [5, 0]]) { await page.click(`#board .hit[data-place="p${from}"]`); await idle(); const target = to === 0 ? "off-player" : `p${to}`; const land = page.locator(`#board .hit.can-land[data-place="${target}"]`); 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 until("ligne du maître", () => !document.getElementById("advice-line").hidden); await page.click("#advice-line"); await until("feuille du maître", () => document.getElementById("explain-ask-ai") !== null); const explainHash = await page.evaluate(() => location.hash); // Les écarts de « Voir les chiffres du moteur », pour les comparer au prompt. await page.click("#explain-numbers summary"); const sheetNumbers = await page.evaluate(() => [...document.querySelectorAll("#explain-numbers .explain-table span")].map((s) => s.textContent), ); await capture("1-bouton-feuille-du-maitre", () => page.locator("#explain-ask-ai").scrollIntoViewIfNeeded(), ); // La feuille du maître, avant : sa hauteur, que la page du prompt garde. const explainBox = await boxOf("#explain-sheet"); const moveText = await openPrompt("#explain-ask-ai", "/ia"); await checkOneSheet("explain-sheet", "Explication", "feuille du maître"); const promptBox = await boxOf("#prompt-page"); check( Math.abs(promptBox.top - explainBox.top) <= 1 && Math.abs(promptBox.height - explainBox.height) <= 1, `la feuille change de hauteur : ${JSON.stringify(explainBox)} puis ${JSON.stringify(promptBox)}`, ); check((await page.evaluate(() => location.hash)) === `${explainHash}/ia`, "adresse de la feuille"); checkPrompt(moveText, "prompt du coup"); check(moveText.includes("J'ai joué 6/off : "), "prompt du coup : coup joué"); check(moveText.includes("1. 6/off 1/off : équité"), "prompt du coup : meilleur coup"); check(moveText.includes("« Grosse erreur »"), "prompt du coup : appréciation"); // Chaque ligne du classement de la feuille (rang, coup, écart) est dans le // prompt, avec le même écart. for (let i = 0; i + 2 < sheetNumbers.length; i += 3) { const [rank, move, loss] = sheetNumbers.slice(i, i + 3); const notation = move.replace(" (vous)", ""); if (Number(rank) <= 3 || move.includes("(vous)")) { check( moveText.includes(`${rank}. ${notation}`) && moveText.includes(`écart ${loss}`), `prompt du coup : ligne ${rank} ${notation} ${loss} absente`, ); } } await capture("2-feuille-du-prompt"); const copied = await copyPrompt("prompt du coup"); await capture("3-prompt-copie", async () => { await page.click("#prompt-copy"); await until("« Copié »", copiedOnButton); }); log(`prompt du coup copié (${copied.length} caractères)`); // Échap ramène à l'explication, qui n'est pas fermée. await page.keyboard.press("Escape"); await promptGone(explainHash); check( await page.evaluate( () => !document.getElementById("explain-veil").hidden && getComputedStyle(document.getElementById("explain-close")).visibility === "visible", ), "l'explication n'est pas revenue", ); // Rechargée, l'adresse de la feuille la rouvre. await page.goto(`${base}${explainHash}/ia`); await page.reload(); await until( "feuille rouverte au rechargement", () => document.getElementById("prompt-page")?.dataset.ready === "true", ); check((await page.textContent("#prompt-text")) === moveText, "le prompt rechargé diffère"); await checkOneSheet("explain-sheet", "Explication", "feuille du maître rechargée"); // « ‹ Explication » y ramène. await backFromPrompt(explainHash); // La croix ferme la feuille : l'explication avec. await openPrompt("#explain-ask-ai", "/ia"); await page.click("#prompt-close"); await until( "feuille du maître fermée", () => document.getElementById("explain-veil").hidden && location.hash === "#/", ); await promptGone("#/"); // ------------------------------------------------------------- Le bilan await page.click("#drop"); await until( "bilan prêt", () => /^#\/parties\/[a-z0-9-]+\/bilan$/.test(location.hash) && document.getElementById("summary")?.dataset.ready === "true" && document.getElementById("summary-ask-ai") !== null, ); await until("mini-plateau du coup à revoir", () => document.querySelector("#summary-board .board-frame") !== null, ); const summaryHash = await page.evaluate(() => location.hash); const id = summaryHash.split("/")[2]; await capture("4-bouton-bilan", () => page.locator("#summary-ask-ai").scrollIntoViewIfNeeded()); const summaryText = await openPrompt("#summary-ask-ai", "/bilan/ia"); await checkOneSheet("summary", "Bilan", "bilan"); checkPrompt(summaryText, "prompt du bilan"); check(summaryText.includes("Résultat : l'ordinateur a gagné"), "prompt du bilan : résultat"); check( summaryText.includes("j'ai joué 6/off, GNU Backgammon préférait 6/off 1/off"), "prompt du bilan : erreur", ); check(summaryText.includes("Le coup à revoir, ma plus grosse erreur (coup 1)"), "coup à revoir"); await capture("5-feuille-du-prompt-bilan"); await copyPrompt("prompt du bilan"); // Le retour du téléphone ramène au bilan. await page.goBack(); await promptGone(summaryHash); check( await page.evaluate(() => document.getElementById("summary")?.dataset.ready === "true"), "le bilan n'est plus là après la feuille du prompt", ); // ----------------------------------------------------------- L'analyse await page.click("#summary-revisit"); await until( "analyse sur le coup à revoir", (game) => location.hash === `#/parties/${game}/analyse/1` && document.querySelector("#analysis-board .board-frame") !== null && !document.getElementById("analysis-ask-ai").hidden, id, ); await capture("6-bouton-analyse", () => page.locator("#analysis-ask-ai").scrollIntoViewIfNeeded(), ); const analysisText = await openPrompt("#analysis-ask-ai", `/parties/${id}/analyse/1/ia`); checkPrompt(analysisText, "prompt de l'analyse"); await checkOneSheet(null, "Analyse", "analyse"); // Le même coup, les mêmes chiffres que dans la feuille du maître (le niveau // du joueur, lui, compte maintenant toutes ses décisions de la partie). const numbersOf = (text) => text.slice(text.indexOf("J'ai joué"), text.indexOf("Ces évaluations")); check( numbersOf(analysisText) === numbersOf(moveText) && numbersOf(moveText).length > 50, "les chiffres du coup diffèrent entre la feuille du maître et l'analyse", ); await backFromPrompt(`#/parties/${id}/analyse/1`); // Coup 2 : le joueur a passé le double, une bonne décision ; son prompt de // videau. await page.goto(`${base}#/parties/${id}/analyse/2`); await until("coup 2", () => document.getElementById("analysis-turn")?.textContent.startsWith("Coup 2 sur 2") && !document.getElementById("analysis-ask-ai").hidden, ); const cubeText = await openPrompt("#analysis-ask-ai", "/analyse/2/ia"); checkPrompt(cubeText, "prompt du videau"); check(cubeText.includes("prendre ou passer le double de l'ordinateur"), "prompt du videau"); check(cubeText.includes("J'ai choisi de passer"), "prompt du videau : décision"); // « Fermer » : sans feuille dessous, retour à l'analyse. await page.click("#prompt-done"); await promptGone(`#/parties/${id}/analyse/2`); // --------------------------------------------------------- Le glossaire await page.goto(`${base}#/glossaire/videau`); await until( "fiche du videau", () => { const link = document.getElementById("glossary-ask-ai-videau"); return link !== null && !link.hidden; }, ); check( (await page.locator(".term-card .ask-ai:not([hidden])").count()) === 1, "un seul bouton « Demander à une IA » dans le glossaire", ); await capture("7-bouton-glossaire", () => page.locator("#glossary-ask-ai-videau").scrollIntoViewIfNeeded(), ); const conceptText = await openPrompt("#glossary-ask-ai-videau", "#/glossaire/videau/ia"); checkPrompt(conceptText, "prompt du concept"); await checkOneSheet(null, "Glossaire", "glossaire"); check(conceptText.includes("le concept « Videau » (en anglais : Cube)"), "prompt du concept"); check(/chances de gagner \d+ %/.test(conceptText), "prompt du concept : évaluation"); await capture("8-feuille-du-prompt-glossaire"); await copyPrompt("prompt du concept"); if (full) { // Le repli : navigator.clipboard refuse (comme hors contexte sûr) ; le // texte est sélectionné dans la feuille et copié par le navigateur. await until("bouton revenu", notCopied, null, 5_000); await page.evaluate(async () => { await navigator.clipboard.writeText(""); navigator.clipboard.writeText = () => Promise.reject(new Error("refusé")); }); await page.click("#prompt-copy"); await until("copie de repli", copyDone, null, 10_000); const fallback = await page.evaluate(async () => ({ selected: window.getSelection().toString(), text: document.getElementById("prompt-text").textContent, copied: document.getElementById("prompt-copy").classList.contains("copied"), clipboard: await navigator.clipboard.readText(), })); // Copié depuis une sélection, le texte perd ses espaces insécables // (« 41 % ») : ce sont des espaces ordinaires dans le presse-papiers. const plain = (text) => text.replace(/ /g, " ").trim(); check(plain(fallback.selected) === plain(fallback.text), "repli : texte non sélectionné"); check( fallback.copied && plain(fallback.clipboard) === plain(fallback.text), `repli : le navigateur n'a pas copié le texte sélectionné (« Copié » ${fallback.copied}, presse-papiers ${fallback.clipboard.length} car. pour ${fallback.text.length})`, ); log("repli par sélection du texte : copié"); } await backFromPrompt("#/glossaire/videau"); 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(`demander à une IA : tout est conforme (${captures} captures)`);