- Sens du jeu (étape 2) : le tracé passe sous les pions et sous les numéros des flèches ; liseré ivoire de 8 px, points de 4 px à l'écran. - Tutoriel en paysage : plateau à gauche sur toute la hauteur (géométrie du jeu en paysage, colonne gauche du cadre), colonne à droite avec la barre de progression, le titre, le texte, la consigne et les boutons ; seul le texte peut défiler. Tient en 915x412 et 844x320. - Recette automatique étendue au tutoriel : numéros d'au moins 12 px, page sans défilement et boutons en vue en paysage, captures en 844x320. - Boutons du videau « Prendre » et « Passer », lien « Quitter ». - Bandeau « Nouvelle version prête. » en bas, au-dessus de la barre d'actions, seulement entre deux tours (betweenTurns, testé). - Test des adresses : le lien Aide est lu au moment du toucher, la carte du terme ouvert est mesurée sous la barre de l'aide. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
549 lines
28 KiB
JavaScript
549 lines
28 KiB
JavaScript
// accueil.mjs -- l'accueil, le tutoriel des règles, l'aide, l'installation
|
||
// et la mise à jour de l'application, dans un vrai navigateur sans écran
|
||
// (Playwright), sur le site servi par l'image de production
|
||
// (docs/plans/2026-11-03-accueil-tutoriel-aide.md, point 7).
|
||
//
|
||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||
//
|
||
// Usage : node accueil.mjs <adresse du site> <chromium|firefox> <dossier des
|
||
// captures> <adresse du site modifiable> <dossier de ce site>
|
||
// Lancé par tests/browser.sh. Le second site sert la même chose depuis un
|
||
// dossier que ce script modifie : il y publie une deuxième version pour
|
||
// essayer la mise à jour. Captures de la recette design (Chromium) dans
|
||
// <dossier des captures>/accueil-tutoriel/, en 412×915 et 915×412, et aussi
|
||
// en 844×320 pour le tutoriel.
|
||
|
||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||
import { chromium, firefox } from "playwright";
|
||
|
||
const [base, browserName, shots, updateBase, updateDir] = process.argv.slice(2);
|
||
const browserType = { chromium, firefox }[browserName];
|
||
if (!base || !browserType || !shots || !updateBase || !updateDir) {
|
||
console.error("usage : node accueil.mjs <adresse> <chromium|firefox> <captures> <adresse 2> <dossier 2>");
|
||
process.exit(2);
|
||
}
|
||
const full = browserName === "chromium";
|
||
const dir = `${shots}/accueil-tutoriel`;
|
||
mkdirSync(dir, { recursive: true });
|
||
const SIZES = [
|
||
[412, 915],
|
||
[915, 412],
|
||
];
|
||
// Le tutoriel est aussi vérifié dans le plus petit paysage de la recette.
|
||
const TUTORIAL_SIZES = [...SIZES, [844, 320]];
|
||
|
||
const browser = await browserType.launch();
|
||
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);
|
||
}
|
||
}
|
||
|
||
/** Un contexte neuf (stockage vide), avec des réglages déjà enregistrés si
|
||
* settings est donné. Erreurs de la page et violations de la CSP relevées. */
|
||
async function newPage(settings = null, url = base) {
|
||
const context = await browser.newContext({
|
||
viewport: { width: 412, height: 915 },
|
||
deviceScaleFactor: 1,
|
||
locale: "fr-FR",
|
||
});
|
||
await context.addInitScript((saved) => {
|
||
window.__csp = [];
|
||
document.addEventListener("securitypolicyviolation", (e) =>
|
||
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
|
||
);
|
||
// Un élément est-il à l'écran ? (Utilisé par les attentes.)
|
||
window.__shown = (id) => {
|
||
const el = document.getElementById(id);
|
||
return el !== null && !el.closest("[hidden]") && el.getClientRects().length > 0;
|
||
};
|
||
window.__vibrations = [];
|
||
Object.defineProperty(Navigator.prototype, "vibrate", {
|
||
configurable: true,
|
||
value: (pattern) => {
|
||
window.__vibrations.push(String(pattern));
|
||
return true;
|
||
},
|
||
});
|
||
if (saved !== null && localStorage.getItem("ustagammon.settings") === null) {
|
||
localStorage.setItem("ustagammon.settings", saved);
|
||
}
|
||
}, settings === null ? null : JSON.stringify(settings));
|
||
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()}`);
|
||
}
|
||
});
|
||
page.on("dialog", (dialog) => {
|
||
problems.push(`boîte native : ${dialog.type()}`);
|
||
void dialog.dismiss();
|
||
});
|
||
await page.goto(url);
|
||
return { context, page };
|
||
}
|
||
|
||
/** Attend que test (évalué dans la page) soit vrai, au plus ms. (Pas de
|
||
* waitForFunction : Playwright y passe par eval, que la CSP interdit.) */
|
||
async function until(page, what, test, arg, ms = 30_000) {
|
||
let last = null;
|
||
for (const end = Date.now() + ms; Date.now() < end; ) {
|
||
last = await page.evaluate(test, arg).catch((error) => `page illisible : ${error.message}`);
|
||
if (last === true) {
|
||
return;
|
||
}
|
||
await sleep(100);
|
||
}
|
||
await page.screenshot({ path: `${shots}/${browserName}-accueil-echec.png` }).catch(() => {});
|
||
throw new Error(`${what} : attente dépassée (${JSON.stringify(last)})`);
|
||
}
|
||
|
||
/** Range une capture de l'écran courant dans chaque taille (Chromium). */
|
||
async function capture(page, name, settle = async () => {}, sizes = SIZES) {
|
||
if (!full) {
|
||
return;
|
||
}
|
||
for (const [width, height] of sizes) {
|
||
await page.setViewportSize({ width, height });
|
||
await sleep(250);
|
||
await settle();
|
||
await page.screenshot({ path: `${dir}/${name}-${width}x${height}.png`, fullPage: true });
|
||
captures++;
|
||
}
|
||
await page.setViewportSize({ width: 412, height: 915 });
|
||
await sleep(150);
|
||
}
|
||
|
||
/** Vérifie la CSP et ferme le contexte. */
|
||
async function close(context, page) {
|
||
const csp = await page.evaluate(() => window.__csp).catch(() => []);
|
||
check(csp.length === 0, `violations de la CSP : ${csp.join(", ")}`);
|
||
await context.close();
|
||
}
|
||
|
||
// ------------------------------------------------- Premier lancement
|
||
|
||
/** L'écran du tutoriel prêt : plateau dessiné pour cette adresse, coup
|
||
* illustré joué (« Revoir » utilisable) s'il y en a un. */
|
||
async function tutorialReady(page, hash) {
|
||
await until(page, `tutoriel ${hash}`, (hash) => {
|
||
const section = document.getElementById("tutorial");
|
||
const replay = document.getElementById("tutorial-replay");
|
||
return location.hash === hash && section.dataset.ready !== undefined &&
|
||
document.querySelectorAll("#tutorial-board polygon[data-point]").length === 24 &&
|
||
(replay.hidden || !replay.disabled);
|
||
}, hash);
|
||
}
|
||
|
||
/** Recette du tutoriel dans la taille courante : numéros des flèches de
|
||
* 12 px au moins ; en paysage, rien ne défile et les boutons sont en vue. */
|
||
async function checkTutorialView(page, name) {
|
||
const view = await page.evaluate(() => {
|
||
const svg = document.getElementById("tutorial-board");
|
||
const box = svg.getBoundingClientRect();
|
||
const vb = svg.viewBox.baseVal;
|
||
const scale = Math.min(box.width / vb.width, box.height / vb.height);
|
||
const numbers = [...svg.querySelectorAll(".point-number")]
|
||
.map((t) => Number(t.getAttribute("font-size")) * scale);
|
||
const buttons = ["tutorial-back", "tutorial-next", "tutorial-drop", "tutorial-take"]
|
||
.map((id) => document.getElementById(id))
|
||
.filter((el) => el !== null && el.closest("[hidden]") === null)
|
||
.map((el) => {
|
||
const r = el.getBoundingClientRect();
|
||
return { id: el.id, top: r.top, bottom: r.bottom };
|
||
});
|
||
return {
|
||
landscape: document.documentElement.classList.contains("landscape"),
|
||
minNumber: numbers.length === 0 ? 0 : Math.min(...numbers),
|
||
scroll: document.scrollingElement.scrollHeight - innerHeight,
|
||
body: (() => {
|
||
const b = document.querySelector("#tutorial .tutorial-body");
|
||
return b.scrollHeight - b.clientHeight;
|
||
})(),
|
||
height: innerHeight,
|
||
buttons,
|
||
};
|
||
});
|
||
const size = `${name} en ${await page.evaluate(() => `${innerWidth}x${innerHeight}`)}`;
|
||
check(view.minNumber >= 12, `${size} : numéros des flèches de ${view.minNumber.toFixed(1)} px`);
|
||
if (view.landscape) {
|
||
check(view.scroll <= 1, `${size} : la page défile de ${view.scroll} px`);
|
||
check(view.body <= 1, `${size} : le texte défile de ${view.body} px`);
|
||
if (view.body > tutorialBodyScroll.px) {
|
||
tutorialBodyScroll = { px: view.body, where: size };
|
||
}
|
||
for (const b of view.buttons) {
|
||
check(b.top >= 0 && b.bottom <= view.height + 0.5,
|
||
`${size} : « ${b.id} » hors de l'écran (${b.top}-${b.bottom} pour ${view.height})`);
|
||
}
|
||
}
|
||
tutorialViews++;
|
||
tutorialMinNumber = Math.min(tutorialMinNumber, view.minNumber);
|
||
}
|
||
let tutorialViews = 0;
|
||
let tutorialBodyScroll = { px: 0, where: "" };
|
||
let tutorialMinNumber = Infinity;
|
||
|
||
/** Capture d'un écran du tutoriel, recette comprise, dans les trois tailles. */
|
||
async function tutorialCapture(page, name) {
|
||
await capture(page, name, () => checkTutorialView(page, name), TUTORIAL_SIZES);
|
||
}
|
||
|
||
/** Touche une case du plateau du tutoriel. */
|
||
async function touch(page, place) {
|
||
await page.click(`#tutorial-board [data-place="${place}"]`);
|
||
await sleep(200);
|
||
}
|
||
|
||
async function verdict(page) {
|
||
await until(page, "verdict", () => {
|
||
const v = document.getElementById("tutorial-feedback").dataset.verdict;
|
||
return v === "right" || v === "wrong";
|
||
});
|
||
return page.evaluate(() => document.getElementById("tutorial-feedback").dataset.verdict);
|
||
}
|
||
|
||
/** Les gestes des écrans « à vous » : un coup refusé, puis le bon. */
|
||
const PRACTICE = {
|
||
// 24/21 : ne peut plus mener à 8/5 6/5, refusé tout de suite.
|
||
3: { wrong: [["p24", "p21"]], right: [["p8", "p5"], ["p6"]] },
|
||
// 24/23 puis 24/18 : un coup complet qui ne frappe pas.
|
||
4: { wrong: [["p24", "p23"], ["p24"]], right: [["p13"], ["p8"]] },
|
||
// 6/3 : ne sort pas deux pions.
|
||
6: { wrong: [["p6", "p3"]], right: [["p6", "off-player"], ["p3"]] },
|
||
};
|
||
|
||
async function playTouches(page, touches) {
|
||
for (const group of touches) {
|
||
for (const place of group) {
|
||
await touch(page, place);
|
||
}
|
||
await until(page, "demi-coup joué", () => !document.querySelector("#tutorial[data-busy]"));
|
||
await sleep(150);
|
||
}
|
||
}
|
||
|
||
{
|
||
const { context, page } = await newPage();
|
||
await until(page, "premier lancement", () =>
|
||
location.hash === "#/accueil" && !document.getElementById("home-welcome").hidden);
|
||
const first = await page.evaluate(() => ({
|
||
topbar: getComputedStyle(document.querySelector(".topbar")).display,
|
||
links: [...document.querySelectorAll("#home-welcome a.choice")].map((a) => a.getAttribute("href")),
|
||
main: document.getElementById("home-main").hidden,
|
||
}));
|
||
check(first.topbar === "none" && first.main, `premier lancement : ${JSON.stringify(first)}`);
|
||
check(JSON.stringify(first.links) === '["#/tutoriel/1","#/"]', `choix : ${first.links}`);
|
||
await capture(page, "01-premier-lancement");
|
||
log("premier lancement : « Je découvre » et « Je connais les règles », de vrais liens");
|
||
|
||
// Le tutoriel complet, par ses liens « Continuer ».
|
||
await page.click("#welcome-discover");
|
||
let screens = 0;
|
||
for (let step = 1; step <= 8; step++) {
|
||
const hash = `#/tutoriel/${step}`;
|
||
await tutorialReady(page, hash);
|
||
// La barre ne mène qu'aux étapes déjà vues.
|
||
const links = await page.evaluate(() =>
|
||
[...document.querySelectorAll("#tutorial-progress a")].map((a) => a.getAttribute("href")));
|
||
const expected = Array.from({ length: step - 1 }, (_, i) => `#/tutoriel/${i + 1}`);
|
||
check(JSON.stringify(links) === JSON.stringify(expected),
|
||
`étape ${step} : barre de progression ${JSON.stringify(links)}`);
|
||
const illustrated = await page.evaluate(() => !document.getElementById("tutorial-replay").hidden);
|
||
if (illustrated) {
|
||
check(await page.locator("#tutorial-board .trace-arrow").count() > 0,
|
||
`étape ${step} : le coup illustré n'a pas laissé de traces`);
|
||
// « Revoir » rejoue le coup.
|
||
await page.click("#tutorial-replay");
|
||
await tutorialReady(page, hash);
|
||
}
|
||
if (step === 2) {
|
||
check(await page.locator("#tutorial-board .tutorial-path").count() === 1,
|
||
"étape 2 : pas de chemin en pointillés");
|
||
// Sous les pions et sous les numéros des flèches.
|
||
const order = await page.evaluate(() => {
|
||
const children = [...document.getElementById("tutorial-board").children];
|
||
const at = (selector) => children.findIndex((c) => c.matches(selector));
|
||
const numbers = children.filter((c) => c.matches(".point-number")).map((c) => children.indexOf(c));
|
||
return { path: at(".tutorial-path"), pieces: at("g.pieces"), firstNumber: Math.min(...numbers) };
|
||
});
|
||
check(order.path >= 0 && order.path < order.pieces && order.path < order.firstNumber,
|
||
`étape 2 : le chemin passe au-dessus des pions ou des numéros ${JSON.stringify(order)}`);
|
||
}
|
||
await tutorialCapture(page, `tutoriel-${step}`);
|
||
screens++;
|
||
await page.click("#tutorial-next");
|
||
const practice = await page.evaluate(() => location.hash.endsWith("/a-vous"));
|
||
if (!practice) {
|
||
continue;
|
||
}
|
||
const avous = `${hash}/a-vous`;
|
||
await tutorialReady(page, avous);
|
||
check(await page.evaluate(() => document.getElementById("tutorial-next").classList.contains("secondary")),
|
||
`étape ${step}, à vous : « Continuer » principal avant le geste`);
|
||
await tutorialCapture(page, `tutoriel-${step}-a-vous`);
|
||
if (step === 8) {
|
||
await page.click("#tutorial-drop");
|
||
check(await verdict(page) === "wrong", "étape 8 : « Passer » accepté");
|
||
await tutorialCapture(page, "tutoriel-8-a-vous-refuse");
|
||
await page.click("#tutorial-take");
|
||
await until(page, "étape 8 juste", () =>
|
||
document.getElementById("tutorial-feedback").dataset.verdict === "right");
|
||
} else {
|
||
const moves = PRACTICE[step];
|
||
await playTouches(page, moves.wrong);
|
||
check(await verdict(page) === "wrong", `étape ${step} : coup faux accepté`);
|
||
// Le pion revient : de nouveau des pions jouables.
|
||
await until(page, "pion revenu", () => document.querySelectorAll("#tutorial-board .hit.can-move").length > 0);
|
||
const words = await page.textContent("#tutorial-feedback");
|
||
check(!/erreur/i.test(words), `étape ${step} : « erreur » dans le refus`);
|
||
await tutorialCapture(page, `tutoriel-${step}-a-vous-refuse`);
|
||
await playTouches(page, moves.right);
|
||
check(await verdict(page) === "right", `étape ${step} : bon coup refusé`);
|
||
check(await page.locator("#tutorial-board .hit.can-move").count() === 0,
|
||
`étape ${step} : plateau encore jouable après le bon coup`);
|
||
}
|
||
check(!(await page.evaluate(() => document.getElementById("tutorial-next").classList.contains("secondary"))),
|
||
`étape ${step} : « Continuer » pas principal après le bon geste`);
|
||
const vibrated = await page.evaluate(() => window.__vibrations.at(-1));
|
||
check(vibrated === "12", `étape ${step} : vibration ${vibrated}`);
|
||
await tutorialCapture(page, `tutoriel-${step}-a-vous-reussi`);
|
||
screens++;
|
||
await page.click("#tutorial-next");
|
||
}
|
||
await until(page, "fin du tutoriel", () => location.hash === "#/tutoriel/fin" &&
|
||
document.getElementById("tutorial").dataset.ready !== undefined);
|
||
await tutorialCapture(page, "tutoriel-fin");
|
||
// Le retour du téléphone : l'écran d'avant.
|
||
await page.goBack();
|
||
await tutorialReady(page, "#/tutoriel/8/a-vous");
|
||
await page.goForward();
|
||
await until(page, "fin", () => location.hash === "#/tutoriel/fin");
|
||
await page.click("#tutorial-next");
|
||
await until(page, "première partie", () => location.hash === "#/" && window.__shown("game"));
|
||
log(`tutoriel : ${screens} écrans, gestes refusés puis justes sur les 4 écrans « à vous », fin` +
|
||
(full ? ` ; recette sur ${tutorialViews} vues, numéros de ${tutorialMinNumber.toFixed(1)} px au moins, ` +
|
||
`texte défilant au plus de ${tutorialBodyScroll.px} px${tutorialBodyScroll.px > 0 ? ` (${tutorialBodyScroll.where})` : ""}` : ""));
|
||
|
||
// Le choix ne revient plus : l'accueil, ensuite.
|
||
await page.goto(base);
|
||
await until(page, "accueil", () => location.hash === "#/accueil" &&
|
||
document.getElementById("home-welcome").hidden && !document.getElementById("home-main").hidden);
|
||
await close(context, page);
|
||
}
|
||
|
||
// --------------------------------------------------- Accueil, installation
|
||
|
||
{
|
||
// Une partie terminée : l'invitation à installer apparaît.
|
||
const settings = { welcomed: true, gamesStarted: 2, gamesFinished: 1, tutorialSeen: 99 };
|
||
const { context, page } = await newPage(settings);
|
||
await until(page, "accueil prêt", () => location.hash === "#/accueil" &&
|
||
!document.getElementById("home-new").disabled && !document.getElementById("install-card").hidden);
|
||
const home = await page.evaluate(() => ({
|
||
resume: document.getElementById("home-resume").getAttribute("href"),
|
||
primaries: [...document.querySelectorAll("#home a.button:not(.secondary), #home button:not(.secondary):not(.icon-button)")]
|
||
.filter((el) => !el.closest("[hidden]")).length,
|
||
rules: document.getElementById("home-rules").getAttribute("href"),
|
||
}));
|
||
check(home.resume === "#/" && home.primaries === 1 && home.rules === "#/tutoriel/1",
|
||
`accueil : ${JSON.stringify(home)}`);
|
||
await capture(page, "02-accueil");
|
||
await capture(page, "03-carte-installation", async () => {
|
||
await page.locator("#install-card").scrollIntoViewIfNeeded();
|
||
});
|
||
// Sans invite du navigateur : la marche à suivre, une feuille avec son
|
||
// adresse, que le retour referme.
|
||
await page.click("#install-card-button");
|
||
await until(page, "feuille d'installation", () => location.hash === "#/installer" && window.__shown("install-sheet"));
|
||
check((await page.locator("#install-steps li").count()) === 3, "marche à suivre sans trois étapes");
|
||
await capture(page, "04-feuille-installation");
|
||
await page.goBack();
|
||
await until(page, "feuille refermée", () => location.hash === "#/accueil" && !window.__shown("install-sheet"));
|
||
if (browserName === "chromium") {
|
||
// L'invite de Chrome, simulée : « Installer » l'ouvre.
|
||
await page.evaluate(() => {
|
||
window.__prompted = 0;
|
||
const event = new Event("beforeinstallprompt", { cancelable: true });
|
||
event.prompt = () => {
|
||
window.__prompted++;
|
||
return Promise.resolve();
|
||
};
|
||
event.userChoice = Promise.resolve({ outcome: "dismissed" });
|
||
window.dispatchEvent(event);
|
||
});
|
||
await page.click("#install-card-button");
|
||
await until(page, "invite du navigateur", () => window.__prompted === 1);
|
||
check(await page.evaluate(() => location.hash) === "#/accueil", "l'invite a ouvert la feuille");
|
||
// Installée : plus aucune proposition.
|
||
await page.evaluate(() => window.dispatchEvent(new Event("appinstalled")));
|
||
await until(page, "installée", () => document.getElementById("install-card").hidden &&
|
||
document.getElementById("menu-install").hidden);
|
||
log("installation : invite de Chrome simulée, puis plus rien une fois installée");
|
||
} else {
|
||
await page.click("#install-later");
|
||
await until(page, "plus tard", () => document.getElementById("install-card").hidden);
|
||
log("installation : marche à suivre, « Plus tard »");
|
||
}
|
||
await close(context, page);
|
||
}
|
||
|
||
// ------------------------------------------------------------------- Aide
|
||
|
||
{
|
||
const { context, page } = await newPage({ welcomed: true, gamesStarted: 2 }, `${base}#/aide`);
|
||
await until(page, "aide", () => window.__shown("help-play") && document.getElementById("help-content").dataset.loaded === "true");
|
||
const help = await page.evaluate(() => ({
|
||
steps: document.querySelectorAll("#help-play .steps, #help-play li").length,
|
||
questions: [...document.querySelectorAll("#help-play .question h3")].map((h) => h.textContent),
|
||
tutorial: document.getElementById("help-tutorial")?.getAttribute("href"),
|
||
}));
|
||
check(help.steps === 0, "aide : les étapes Gherkin sont affichées");
|
||
check(help.questions.length >= 25 && help.tutorial === "#/tutoriel/1", `aide : ${JSON.stringify(help)}`);
|
||
check(help.questions.filter((q) => q.endsWith("?")).length >= 20, "aide : trop peu de questions");
|
||
await capture(page, "05-aide-jouer");
|
||
const question = await page.evaluate(() =>
|
||
[...document.querySelectorAll("#help-play .question")].find((q) => q.querySelector(".see-board"))?.id);
|
||
await page.click(`#${question} h3 a`);
|
||
await until(page, "question ouverte", (id) => document.getElementById(id).classList.contains("open"), question);
|
||
await capture(page, "06-aide-question");
|
||
// Le retour du téléphone referme la question.
|
||
await page.goBack();
|
||
await until(page, "question refermée", () => location.hash === "#/aide" && !document.querySelector(".question.open"));
|
||
await page.click('#help-tabs a[data-screen="glossary"]');
|
||
await until(page, "glossaire", () => window.__shown("help-glossary"));
|
||
const before = await page.locator("#help-glossary .term-card:not([hidden])").count();
|
||
await page.fill("#glossary-search", "sortir");
|
||
await sleep(200);
|
||
const after = await page.locator("#help-glossary .term-card:not([hidden])").count();
|
||
check(after > 0 && after < before, `recherche : ${after} fiches sur ${before}`);
|
||
await capture(page, "07-aide-glossaire-recherche");
|
||
await page.click('#help-tabs a[data-screen="about"]');
|
||
await until(page, "à propos", () => window.__shown("help-about"));
|
||
const version = await page.textContent("#about-version");
|
||
check(/^Version publiée : [0-9a-f]{12}\.$/.test(version), `version : « ${version} »`);
|
||
await capture(page, "08-aide-a-propos");
|
||
log(`aide : ${help.questions.length} questions, recherche dans le glossaire (${after}/${before}), ${version}`);
|
||
await close(context, page);
|
||
}
|
||
|
||
// ----------------------------------------------------------- Mise à jour
|
||
|
||
if (browserName === "chromium") {
|
||
const { context, page } = await newPage({ welcomed: true, gamesStarted: 2 }, `${updateBase}#/`);
|
||
await until(page, "partie prête", () => !document.body.classList.contains("busy"), undefined, 60_000);
|
||
// Le service worker installé et aux commandes (première installation :
|
||
// sans attendre l'accord du joueur).
|
||
await until(page, "service worker", async () => {
|
||
const reg = await navigator.serviceWorker.getRegistration();
|
||
return reg?.active?.state === "activated" && navigator.serviceWorker.controller !== null;
|
||
}, undefined, 120_000);
|
||
const journal = () => page.evaluate(() => localStorage.length >= 0 &&
|
||
new Promise((resolve) => {
|
||
const open = indexedDB.open("ustagammon");
|
||
open.onsuccess = () => {
|
||
const db = open.result;
|
||
const tx = db.transaction(["settings"]);
|
||
const current = tx.objectStore("settings").get("current");
|
||
current.onsuccess = () => {
|
||
db.close();
|
||
resolve(current.result?.value ?? null);
|
||
};
|
||
};
|
||
open.onerror = () => resolve(null);
|
||
}));
|
||
check(await page.isHidden("#update-banner"), "bandeau de mise à jour sans nouvelle version");
|
||
// Si vous avez la main (jet d'ouverture), le bandeau attend la fin du
|
||
// tour : on publie la nouvelle version d'abord, puis on joue le coup.
|
||
const toPlay = () => page.evaluate(() => {
|
||
if (document.body.classList.contains("busy")) {
|
||
return "busy";
|
||
}
|
||
const usable = (id) => {
|
||
const b = document.getElementById(id);
|
||
return b !== null && !b.hidden && !b.disabled;
|
||
};
|
||
for (const id of ["roll", "again", "confirm", "skip", "take"]) {
|
||
if (usable(id)) {
|
||
return id;
|
||
}
|
||
}
|
||
if (document.querySelector("#board .hit.can-land")) {
|
||
return "land";
|
||
}
|
||
return document.querySelector("#board .hit.can-move") ? "move" : "busy";
|
||
});
|
||
// Une deuxième version publiée : autre version.json, autre sw.js.
|
||
writeFileSync(`${updateDir}/version.json`, '{"commit": "0123456789abcdef0123", "dirty": false}\n');
|
||
const sw = readFileSync(`${updateDir}/sw.js`, "utf8");
|
||
writeFileSync(`${updateDir}/sw.js`, sw.replace(/^const VERSION = "[0-9a-f]+";$/m, 'const VERSION = "0000000000000002";'));
|
||
await page.click("#menu-button");
|
||
await page.waitForSelector("#menu", { state: "visible" });
|
||
await page.click("#menu-update");
|
||
await until(page, "nouvelle version trouvée", () =>
|
||
document.getElementById("update-text")?.textContent !== "Recherche d'une mise à jour…", undefined, 120_000);
|
||
// Pendant votre coup, jamais de bandeau : seulement entre deux tours.
|
||
let waited = false;
|
||
for (let i = 0; i < 40; i++) {
|
||
const todo = await toPlay();
|
||
if (todo === "roll" || todo === "again") {
|
||
break;
|
||
}
|
||
if (todo === "busy") {
|
||
await sleep(100);
|
||
continue;
|
||
}
|
||
if (todo === "move" || todo === "land" || todo === "confirm") {
|
||
await sleep(400);
|
||
check(await page.isHidden("#update-banner"), `bandeau de mise à jour pendant votre coup (${todo})`);
|
||
waited = true;
|
||
}
|
||
await page.click(todo === "move" ? "#board .hit.can-move" : todo === "land" ? "#board .hit.can-land" : `#${todo}`);
|
||
await sleep(200);
|
||
}
|
||
await until(page, "bandeau de mise à jour", () => window.__shown("update-banner") && window.__shown("update-apply"), undefined, 120_000);
|
||
check((await page.textContent("#update-text")) === "Nouvelle version prête.",
|
||
`bandeau : « ${await page.textContent("#update-text")} »`);
|
||
// En bas, au-dessus de la barre d'actions, comme « Reprendre l'ancienne ».
|
||
const place = await page.evaluate(() => {
|
||
const banner = document.getElementById("update-banner").getBoundingClientRect();
|
||
const actions = document.getElementById("actions").getBoundingClientRect();
|
||
const top = document.querySelector(".topbar").getBoundingClientRect();
|
||
return { bottom: banner.bottom, top: banner.top, actions: actions.top, bar: top.bottom };
|
||
});
|
||
check(place.bottom <= place.actions && place.top > place.bar,
|
||
`bandeau de mise à jour mal placé : ${JSON.stringify(place)}`);
|
||
const gameBefore = await journal();
|
||
await capture(page, "09-mise-a-jour");
|
||
await Promise.all([page.waitForEvent("load", { timeout: 60_000 }), page.click("#update-apply")]);
|
||
await until(page, "partie reprise", () => location.hash === "#/" && !document.body.classList.contains("busy"), undefined, 60_000);
|
||
check(await journal() === gameBefore, "après la mise à jour, ce n'est plus la même partie");
|
||
await page.goto(`${updateBase}#/a-propos`);
|
||
await until(page, "nouvelle version", () =>
|
||
document.getElementById("about-version")?.textContent === "Version publiée : 0123456789ab.");
|
||
log(`mise à jour : deuxième version publiée, bandeau ${waited ? "retenu pendant votre coup, puis " : ""}entre deux tours, « Mettre à jour », partie retrouvée, nouvelle version`);
|
||
// Rien de neuf : « Vous avez la dernière version ».
|
||
await page.goto(`${updateBase}#/`);
|
||
await until(page, "partie", () => !document.body.classList.contains("busy"), undefined, 60_000);
|
||
await page.click("#menu-button");
|
||
await page.waitForSelector("#menu", { state: "visible" });
|
||
await page.click("#menu-update");
|
||
await until(page, "dernière version", () =>
|
||
document.getElementById("update-text").textContent === "Vous avez la dernière version.", undefined, 60_000);
|
||
await close(context, page);
|
||
}
|
||
|
||
await browser.close();
|
||
if (problems.length > 0) {
|
||
console.error(problems.join("\n"));
|
||
process.exit(1);
|
||
}
|
||
log(`aucune erreur dans la page${full ? ` ; ${captures} captures dans accueil-tutoriel/` : ""}`);
|