Files
Ustagammon/tests/browser/prompts.mjs
T

443 lines
18 KiB
JavaScript
Raw 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 ; 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) ; le bandeau « Prompt copié » ; la fermeture qui ramène à
// l'écran d'en dessous ; 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 (le bandeau
* « Prompt copié » ne dure que 2 s). */
async function capture(name, before = null) {
if (!full) {
return;
}
// Le bandeau « Prompt copié » d'une copie précédente est parti (sauf
// pour sa propre capture, où before le fait réapparaître).
await until(
"bandeau parti",
() => document.getElementById("prompt-banner").hidden,
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);
}
/** La feuille du prompt, prête : son texte. */
async function openPrompt(button, hashEnd) {
await page.locator(button).scrollIntoViewIfNeeded();
await page.click(button);
await until(
"feuille du prompt prête",
(end) =>
location.hash.endsWith(end) &&
!document.getElementById("prompt-veil").hidden &&
document.getElementById("prompt-sheet")?.dataset.ready === "true",
hashEnd,
);
return page.textContent("#prompt-text");
}
/** 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 suis un joueur de niveau ",
"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`);
}
/** Copie le prompt, attend le bandeau, et (Chromium) relit le
* presse-papiers. */
async function copyPrompt(what) {
await page.click("#prompt-copy");
await until(
"copie faite",
() =>
!document.getElementById("prompt-banner").hidden ||
document.getElementById("prompt-status").textContent !== "",
null,
10_000,
);
const shown = await page.evaluate(() => ({
banner: !document.getElementById("prompt-banner").hidden,
bannerText: document.getElementById("prompt-banner").textContent,
status: document.getElementById("prompt-status").textContent,
text: document.getElementById("prompt-text").textContent,
}));
if (full) {
check(shown.banner && shown.bannerText.includes("Prompt copié"), `${what} : pas de bandeau`);
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.banner || shown.status.includes("sélectionné"), `${what} : ni copie ni repli`);
log(`${what} : ${shown.banner ? "copié" : "repli par sélection"}`);
return shown.text;
}
/** Ferme la feuille du prompt : l'écran d'en dessous revient. */
async function closePrompt(hashBelow) {
await page.click("#prompt-done");
await until(
"feuille du prompt fermée",
(below) => document.getElementById("prompt-veil").hidden && 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(),
);
const moveText = await openPrompt("#explain-ask-ai", "/ia");
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("bandeau", () => !document.getElementById("prompt-banner").hidden);
});
log(`prompt du coup copié (${copied.length} caractères)`);
// Échap ferme la feuille du prompt, pas l'explication dessous.
await page.keyboard.press("Escape");
await until(
"retour à l'explication",
(below) =>
document.getElementById("prompt-veil").hidden &&
!document.getElementById("explain-veil").hidden &&
location.hash === below,
explainHash,
);
// 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-sheet")?.dataset.ready === "true",
);
check((await page.textContent("#prompt-text")) === moveText, "le prompt rechargé diffère");
await closePrompt(explainHash);
await page.click("#explain-done");
await until("feuille du maître fermée", () => document.getElementById("explain-veil").hidden);
// ------------------------------------------------------------- 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");
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");
await closePrompt(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");
// 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 closePrompt(`#/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");
await closePrompt(`#/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");
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("bandeau parti", () => document.getElementById("prompt-banner").hidden);
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",
() =>
!document.getElementById("prompt-banner").hidden ||
document.getElementById("prompt-status").textContent !== "",
null,
10_000,
);
const fallback = await page.evaluate(async () => ({
selected: window.getSelection().toString(),
text: document.getElementById("prompt-text").textContent,
banner: !document.getElementById("prompt-banner").hidden,
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.banner && plain(fallback.clipboard) === plain(fallback.text),
`repli : le navigateur n'a pas copié le texte sélectionné (bandeau ${fallback.banner}, presse-papiers ${fallback.clipboard.length} car. pour ${fallback.text.length})`,
);
log("repli par sélection du texte : copié");
}
await closePrompt("#/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)`);