Navigation : un retour en cours retient l'adresse suivante ; test des adresses

history.go est asynchrone : une adresse demandée pendant un retour attend
son arrivée. Le test dans le navigateur ouvre chaque adresse du tableau de
l'ADR 0013 dans un nouvel onglet, vérifie le retour, le rôle link des liens
et l'aller-retour d'un lien de position, et range ses captures dans
browser-shots/adresses/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-09 13:45:17 +01:00
co-authored by Claude Opus 5.5
parent f4914b30ab
commit 97a098e98f
3 changed files with 318 additions and 12 deletions
+284 -4
View File
@@ -76,6 +76,15 @@ await context.addInitScript(() => {
return null;
};
}
// Les liens copiés (« Copier le lien de la position ») : le
// presse-papiers du navigateur sans écran n'est pas lisible partout.
window.__copied = [];
if (window.Clipboard) {
Clipboard.prototype.writeText = function (text) {
window.__copied.push(String(text));
return Promise.resolve();
};
}
window.__vibrations = [];
Object.defineProperty(Navigator.prototype, "vibrate", {
configurable: true,
@@ -312,7 +321,7 @@ async function checkView(width, height, when) {
el.className?.baseVal ?? el.className;
// Cibles tactiles : boutons et zones d'action du plateau.
const small = [];
for (const el of document.querySelectorAll("button, [role=button], [role=switch], [data-action]")) {
for (const el of document.querySelectorAll("button, [role=button], [role=switch], [data-action], a.icon-button, a.item, a.button")) {
if (!visible(el)) {
continue;
}
@@ -528,7 +537,7 @@ async function touchTarget(action, label, count = 1, index = 0) {
check((await zones.count()) === count,
`zone ${action} : ${await zones.count()} sur le plateau, ${count} attendues`);
for (const zone of await zones.all()) {
check((await zone.getAttribute("role")) === "button" &&
check((await zone.getAttribute("role")) === (action === "menu" ? "link" : "button") &&
(await zone.getAttribute("aria-label")) === label, `zone ${action} sans rôle accessible`);
const box = await zone.boundingBox();
check(box.width >= 44 && box.height >= 44,
@@ -588,10 +597,270 @@ async function checkReplay(traces, reducedMotion) {
await page.emulateMedia({ reducedMotion: "no-preference" });
}
// ----------------------------------------------------------- Adresses
/** Un nouvel onglet ouvert directement sur une adresse (fragment ou URL
* complète), ses erreurs comptées comme celles de la page principale. */
async function openTab(address, viewport = PORTRAIT) {
const tab = await context.newPage();
await tab.setViewportSize(viewport);
tab.on("pageerror", (error) => problems.push(`erreur de la page (${address}) : ${error.message}`));
tab.on("console", (message) => {
if (message.type() === "error") {
problems.push(`console (${address}) : ${message.text()}`);
}
});
tab.on("dialog", (dialog) => {
problems.push(`boîte native ouverte (${address}) : ${dialog.type()}`);
void dialog.dismiss();
});
await tab.goto(new URL(address, base).href);
return tab;
}
/** Ce que montre un onglet : écran, question, terme, feuille, message. */
async function screenOf(tab) {
return tab.evaluate(() => {
const shown = (id) => {
const el = document.getElementById(id);
return el !== null && !el.hasAttribute("hidden") && el.getClientRects().length > 0;
};
const text = (id) => document.getElementById(id)?.textContent ?? "";
return {
hash: location.hash,
busy: document.body.classList.contains("busy"),
game: shown("game"),
help: shown("help"),
play: shown("help-play"),
glossary: shown("help-glossary"),
about: shown("help-about"),
question: document.querySelector("#help-play .question.open")?.id ?? null,
term: document.querySelector("#help-glossary dt.current")?.id ?? null,
menu: shown("menu"),
games: shown("games"),
listed: document.querySelectorAll("#games .games-item").length,
expanded:
document
.querySelector('#games [data-action="branches"][aria-expanded="true"]')
?.closest(".games-item")?.dataset.id ?? null,
position: shown("position-view"),
positionBoard: shown("position-board"),
positionTitle: text("position-title"),
positionMessage: text("position-message"),
positionPlay: shown("position-play"),
info: text("info"),
};
});
}
/** Attend que l'onglet montre l'écran attendu (expected : test sur
* screenOf), au plus 30 s ; sinon, échoue avec ce qu'il montre. */
async function expectScreen(tab, what, expected) {
let state = null;
for (let i = 0; i < 300; i++) {
state = await screenOf(tab);
if (expected(state)) {
return state;
}
await sleep(100);
}
await tab.screenshot({ path: `${shots}/${browserName}-adresse-echec.png` }).catch(() => {});
throw new Error(`${what} : écran inattendu ${JSON.stringify(state)}`);
}
/** Le dernier lien copié par l'application dans cet onglet. */
async function lastCopied(tab) {
for (let i = 0; i < 50; i++) {
const copied = await tab.evaluate(() => window.__copied.at(-1) ?? null);
if (copied !== null) {
return copied;
}
await sleep(100);
}
throw new Error("aucun lien copié");
}
/** L'élément est-il exposé comme un lien (rôle link) ? */
async function isLink(tab, selector) {
const all = tab.locator(selector);
const links = all.and(tab.getByRole("link"));
return (await all.count()) > 0 && (await links.count()) === (await all.count());
}
async function checkAddresses() {
const dir = `${shots}/adresses`;
mkdirSync(dir, { recursive: true });
const playing = (s) => s.hash === "#/" && s.game && !s.help && !s.position && !s.menu && !s.games;
// Le lien de la position en cours, copié depuis le menu de la partie.
await openMenu();
await page.evaluate(() => {
window.__copied = [];
});
await page.click("#menu-copy-link");
const positionUrl = await lastCopied(page);
const positionHash = new URL(positionUrl).hash;
check(/^#\/position\/[^/]{14,}\/[^/]+$/.test(positionHash), `lien de position copié : ${positionUrl}`);
check(!(await page.isVisible("#menu")), "le menu reste ouvert après la copie du lien");
// Chaque adresse du tableau de l'ADR, dans un nouvel onglet. Les
// identifiants d'une question et d'une partie sont lus en chemin.
let question = null;
let gameId = null;
let gamesBefore = 0;
const notYet = (s) => s.hash === "#/" && s.game && s.info.includes("n'existe pas encore");
const addresses = [
["#/", playing],
["#/accueil", notYet],
["#/aide", (s) => s.help && s.play && !s.glossary && !s.about && s.question === null],
[() => `#/aide/${question}`, (s) => s.help && s.play && s.question === `aide-${question}`],
["#/glossaire", (s) => s.help && s.glossary && !s.play && s.term === null],
["#/glossaire/lien-de-position", (s) => s.help && s.glossary && s.term === "glossaire-lien-de-position"],
["#/a-propos", (s) => s.help && s.about && !s.play && !s.glossary],
["#/tutoriel", notYet],
["#/tutoriel/2", notYet],
["#/parties", (s) => s.games && s.listed >= 2 && !s.menu],
[() => `#/parties/${gameId}`, (s) => s.games && s.expanded === gameId],
["#/menu", (s) => s.menu && !s.games],
[positionHash, (s) => s.position && s.positionBoard && !s.game && s.positionPlay],
[`#/position/${positionHash.split("/")[2]}`, (s) => s.position && s.positionBoard],
["#/position/pas-une-position", (s) =>
s.position && !s.positionBoard && s.positionMessage.includes("ne décrit pas une position")],
["#/nulle-part", (s) => s.hash === "#/" && s.game && s.info.includes("ne mène à aucun écran")],
];
for (const [address, expected] of addresses) {
const hash = typeof address === "function" ? address() : address;
const tab = await openTab(hash);
await expectScreen(tab, `adresse ${hash}`, expected);
if (hash === "#/aide") {
question = await tab.evaluate(() =>
document.querySelector("#help-play .question")?.id.replace(/^aide-/, ""));
check(typeof question === "string" && question !== "", "aide : aucune question");
}
if (hash === "#/parties") {
await tab.waitForSelector("#games .games-item");
gameId = await tab.evaluate(() => document.querySelector("#games .games-item")?.dataset.id);
gamesBefore = (await screenOf(tab)).listed;
}
await tab.close();
}
log(`adresses : ${addresses.length} adresses ouvertes directement, chacune sur son écran`);
// Captures : l'aide ouverte par son adresse, une question, un terme, la
// vue d'une position, un lien illisible ; en portrait et en paysage.
const captures = [
["aide", "#/aide", (s) => s.help && s.play],
["question", `#/aide/${question}`, (s) => s.question === `aide-${question}`],
["terme", "#/glossaire/lien-de-position", (s) => s.term === "glossaire-lien-de-position"],
["position", positionHash, (s) => s.position && s.positionBoard],
["lien-invalide", "#/position/pas-une-position", (s) => s.position &&
s.positionMessage.includes("ne décrit pas une position")],
];
for (const [width, height] of [[412, 915], [915, 412]]) {
for (const [name, hash, expected] of captures) {
const tab = await openTab(hash, { width, height });
await expectScreen(tab, `capture ${name}`, expected);
await sleep(300);
await tab.screenshot({ path: `${dir}/${browserName}-${name}-${width}x${height}.png` });
await tab.close();
}
}
// Retour du navigateur et rôle des liens, dans un même onglet.
{
const tab = await openTab("#/");
await expectScreen(tab, "partie", playing);
for (const selector of ["#help-button", "#menu-button"]) {
check(await isLink(tab, selector), `${selector} n'est pas un lien`);
}
for (const selector of ["#roll, #confirm, #again, #double", "#fullscreen-button"]) {
check((await tab.locator(selector).and(tab.getByRole("link")).count()) === 0,
`${selector} : une action de jeu exposée comme un lien`);
}
await tab.click("#help-button");
await expectScreen(tab, "aide par son lien", (s) => s.hash === "#/aide" && s.help && s.play);
for (const selector of ["#help-back", "#help-tabs a", "#help-play .question h3 a"]) {
check(await isLink(tab, selector), `${selector} : pas des liens`);
}
await tab.click(`#aide-${question} h3 a`);
await expectScreen(tab, "question par son lien", (s) => s.hash === `#/aide/${question}` &&
s.question === `aide-${question}`);
await tab.goBack();
await expectScreen(tab, "retour : question refermée", (s) => s.hash === "#/aide" && s.help &&
s.question === null);
await tab.click('#help-tabs a[data-screen="glossary"]');
await expectScreen(tab, "onglet glossaire", (s) => s.hash === "#/glossaire" && s.glossary);
check(await isLink(tab, "#help-glossary dt a"), "les termes du glossaire ne sont pas des liens");
await tab.goBack();
await expectScreen(tab, "retour : onglet Jouer", (s) => s.hash === "#/aide" && s.play);
await tab.goBack();
await expectScreen(tab, "retour : la partie", playing);
// Le menu, puis « Mes parties » depuis le menu : le retour ferme la
// feuille et ramène à la partie, sans rouvrir le menu.
await tab.click("#menu-button");
await expectScreen(tab, "menu par son lien", (s) => s.hash === "#/menu" && s.menu);
for (const selector of ["#menu-games", "#menu-help"]) {
check(await isLink(tab, selector), `${selector} n'est pas un lien`);
}
check((await tab.locator("#menu-new-game").and(tab.getByRole("button")).count()) === 1,
"« Nouvelle partie » n'est plus un bouton");
await tab.goBack();
await expectScreen(tab, "retour : menu fermé", playing);
await tab.click("#menu-button");
await expectScreen(tab, "menu", (s) => s.menu);
await tab.click("#menu-games");
await expectScreen(tab, "mes parties depuis le menu", (s) => s.hash === "#/parties" && s.games &&
!s.menu);
await tab.goBack();
await expectScreen(tab, "retour : mes parties fermé", playing);
// Échap quitte l'aide comme « Retour ».
await tab.click("#help-button");
await expectScreen(tab, "aide", (s) => s.help);
await tab.keyboard.press("Escape");
await expectScreen(tab, "Échap : la partie", playing);
await tab.close();
}
log("adresses : retour du navigateur (question, onglet, menu, mes parties), Échap, rôle link des liens");
// Aller-retour d'un lien de position : le lien copié depuis la vue
// ouverte par ce lien est le même ; « Retour à ma partie » ; « Jouer à
// partir d'ici » commence une partie de plus, l'ancienne restant
// reprenable.
{
const tab = await openTab(positionUrl);
await expectScreen(tab, "lien de position", (s) => s.position && s.positionBoard && s.positionPlay);
await tab.click("#position-copy");
const again = await lastCopied(tab);
check(again === positionUrl, `aller-retour du lien : ${again} au lieu de ${positionUrl}`);
check(await isLink(tab, "#position-back"), "« Retour à ma partie » n'est pas un lien");
await tab.click("#position-back");
await expectScreen(tab, "retour à ma partie (nouvel onglet)", playing);
// Depuis la partie : le lien suivi, puis « Retour à ma partie ».
await tab.evaluate((hash) => {
location.hash = hash;
}, positionHash);
await expectScreen(tab, "lien suivi depuis la partie", (s) => s.position && s.positionBoard);
await tab.click("#position-back");
await expectScreen(tab, "retour à ma partie", playing);
await tab.goForward();
await expectScreen(tab, "avance : la position", (s) => s.position && s.positionPlay);
await tab.click("#position-play");
await expectScreen(tab, "jouer à partir d'ici", (s) => playing(s) && !s.busy);
await tab.goto(new URL("#/parties", base).href);
const after = await expectScreen(tab, "mes parties", (s) => s.games && s.listed > 0);
check(after.listed === gamesBefore + 1,
`jouer à partir d'ici : ${after.listed} parties, ${gamesBefore + 1} attendues`);
await tab.close();
}
log("adresses : lien de position copié, rouvert à l'identique, retour à la partie, partie jouée depuis la position");
}
// ------------------------------------------------------------- Menu
async function openMenu() {
await page.click("#menu-button");
// Le menu a son adresse (#/menu) : il s'ouvre au changement d'adresse.
await page.waitForSelector("#menu", { state: "visible", timeout: 5000 }).catch(() => {});
check(await page.isVisible("#menu"), "le menu ne s'ouvre pas");
}
@@ -1074,6 +1343,7 @@ for (let actions = 0; ; actions++) {
// Le clavier ouvre aussi le menu (M) et Échap le ferme.
await page.evaluate(() => document.activeElement?.blur());
await page.keyboard.press("m");
await page.waitForSelector("#menu", { state: "visible", timeout: 5000 }).catch(() => {});
check(await page.isVisible("#menu"), "M n'ouvre pas le menu");
await page.keyboard.press("Escape");
const pips = await page.textContent("#strip-player .count");
@@ -1214,7 +1484,7 @@ step("mes parties");
step("aide");
// Aide : scénarios et glossaire lus dans les copies de docs/.
await page.click("#help-button");
await page.waitForSelector(".glossary dt");
await page.waitForSelector(".glossary dt", { state: "attached" });
const terms = await page.locator(".glossary dt").count();
const scenarios = await page.locator("#help-content h3").count();
check(terms >= 20 && scenarios >= 9, `aide incomplète : ${terms} termes, ${scenarios} scénarios`);
@@ -1281,7 +1551,7 @@ if (offline === "roll") {
await waitForPlayer();
}
await page.click("#help-button");
await page.waitForSelector(".glossary dt");
await page.waitForSelector(".glossary dt", { state: "attached" });
await page.click("#help-back");
log(
offlineMode
@@ -1313,6 +1583,16 @@ check(
);
log("CSP : aucune violation pendant la partie, script en ligne bloqué ; aucune boîte native");
step("adresses");
// Chaque écran a son adresse (ADR 0013, amendement du 2026-11-03) : ouverte
// directement dans un nouvel onglet, elle montre le bon écran ; le retour du
// navigateur referme d'abord la question ou la feuille ouverte ; les liens
// sont des liens (rôle link) ; un lien de position fait l'aller-retour.
if (offlineMode) {
await context.setOffline(false);
}
await checkAddresses();
log(`recette : ${recette.views} vues vérifiées (${stableChecks} contrôles de stabilité du ` +
`portrait, ${recette.rotations} rotations sans perte d'état), numéros des flèches à ` +
`${recette.minNumber.toFixed(1)} px au moins, réponse au toucher en ${responseMs.toFixed(1)} ms ` +