Files
Ustagammon/tests/browser/prompts.mjs
Pierre MARTINandClaude Opus 5.5 d5f08fe393 Demander à une IA : une seule feuille à la fois, la copie confirmée sur le bouton
Recette de 1f (F1, F2). Depuis la feuille du maître ou le bilan, la page
du prompt prend la place de leur contenu, à la même hauteur, avec
« ‹ Explication » ou « ‹ Bilan » pour revenir ; depuis l'analyse et le
glossaire, elle monte dans sa propre feuille. La croix et « Fermer »
ferment la feuille. Plus de bandeau « Prompt copié » : le bouton passe à
« ✓ Copié » (verdict-good) 2 s. Test navigateur : une poignée, une croix,
un titre, même hauteur, retour du téléphone, couleur du bouton. Charte et
amendement de l'ADR 0013 mis à jour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:54:32 +01:00

544 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 <adresse du site> <chromium|firefox> <dossier des captures>
// 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 <dossier des captures>/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 <adresse> <chromium|firefox> <dossier des captures>");
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)`);