@@ -0,0 +1,301 @@
// lecons.mjs -- les leçons (étape 1g) dans un vrai navigateur sans écran
// (Playwright), sur le site servi par l'image de production : la carte
// « prochaine leçon » de l'accueil, la liste des leçons, un écran
// d'explication, un exercice refusé puis réussi, la progression gardée après
// un rechargement, la fin d'une leçon et une adresse de leçon inconnue.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Usage : node lecons.mjs <adresse du site> <chromium|firefox> <dossier des
// captures>. Lancé par tests/browser.sh. Captures de la recette (Chromium)
// dans <dossier des captures>/lecons/, en 412× 915 et 915× 412.
import { mkdirSync } from "node:fs" ;
import { chromium , firefox } from "playwright" ;
import { hiddenUnderActionBar , missingTopbar , 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 lecons.mjs <adresse> <chromium|firefox> <captures>" ) ;
process . exit ( 2 ) ;
}
const full = browserName === "chromium" ;
const dir = ` ${ shots } /lecons ` ;
mkdirSync ( dir , { recursive : true } ) ;
const SIZES = [
[ 412 , 915 ] ,
[ 915 , 412 ] ,
] ;
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 ) ;
}
}
/** 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 } -lecons-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), avec
* la recette : rien sous une barre d'action, aucun bouton étiré. */
async function capture ( page , name , settle = async ( ) => { } ) {
if ( ! full ) {
return ;
}
for ( const [ width , height ] of SIZES ) {
await page . setViewportSize ( { width , height } ) ;
await sleep ( 300 ) ;
await settle ( ) ;
const hidden = await hiddenUnderActionBar ( page ) ;
check ( hidden . length === 0 , ` ${ name } ${ width } x ${ height } : caché sous la barre d'action : ${ hidden . join ( ", " ) } ` ) ;
const topbar = await missingTopbar ( page ) ;
check ( topbar === null , ` ${ name } : ${ topbar } ` ) ;
const tall = await tallButtons ( page ) ;
check ( tall . length === 0 , ` ${ name } ${ width } x ${ height } : boutons étirés : ${ tall . join ( ", " ) } ` ) ;
await page . screenshot ( { path : ` ${ dir } / ${ name } - ${ width } x ${ height } .png ` , fullPage : true } ) ;
captures ++ ;
}
await page . setViewportSize ( { width : 412 , height : 915 } ) ;
await sleep ( 150 ) ;
}
/** L'écran d'une leçon prêt : plateau dessiné pour cette adresse, coup
* illustré joué (« Revoir » utilisable) s'il y en a un. */
async function lessonReady ( page , hash ) {
await until ( page , ` leçon ${ hash } ` , ( hash ) => {
const section = document . getElementById ( "lesson" ) ;
const replay = document . getElementById ( "lesson-replay" ) ;
return location . hash === hash && section . dataset . ready !== undefined &&
! section . hidden &&
document . querySelectorAll ( "#lesson-board polygon[data-point]" ) . length === 24 &&
( replay . hidden || ! replay . disabled ) ;
} , hash ) ;
}
/** Touche des cases du plateau de la leçon, groupe par groupe (un groupe :
* un demi-coup). */
async function playTouches ( page , touches ) {
for ( const group of touches ) {
for ( const place of group ) {
await page . click ( ` #lesson-board [data-place=" ${ place } "] ` ) ;
await sleep ( 200 ) ;
}
await until ( page , "demi-coup joué" , ( ) => ! document . querySelector ( "#lesson[data-busy]" ) ) ;
await sleep ( 150 ) ;
}
}
async function verdict ( page ) {
await until ( page , "verdict" , ( ) => {
const v = document . getElementById ( "lesson-feedback" ) . dataset . verdict ;
return v === "right" || v === "wrong" ;
} ) ;
return page . evaluate ( ( ) => document . getElementById ( "lesson-feedback" ) . dataset . verdict ) ;
}
/** L'état affiché de chaque carte de la liste. */
async function listStates ( page ) {
return page . evaluate ( ( ) =>
[ ... document . querySelectorAll ( "#lesson-list .lesson-card" ) ] . map ( ( card ) => ( {
id : card . id ,
current : card . classList . contains ( "current" ) ,
state : card . querySelector ( ".lesson-card-state" ) ? . textContent ? ? "" ,
done : card . querySelectorAll ( ".lesson-progress .segment.done" ) . length ,
} ) ) ) ;
}
const context = await browser . newContext ( {
viewport : { width : 412 , height : 915 } ,
deviceScaleFactor : 1 ,
locale : "fr-FR" ,
} ) ;
await context . addInitScript ( ( ) => {
window . _ _csp = [ ] ;
document . addEventListener ( "securitypolicyviolation" , ( e ) =>
window . _ _csp . push ( ` ${ e . effectiveDirective } ${ e . blockedURI } ` ) ,
) ;
// Le premier lancement est passé : l'accueil complet.
if ( localStorage . getItem ( "ustagammon.settings" ) === null ) {
localStorage . setItem ( "ustagammon.settings" , JSON . stringify ( { welcomed : true , tutorialSeen : 99 } ) ) ;
}
} ) ;
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 ( ) ;
} ) ;
try {
// ------------------------------------------- La carte de l'accueil
await page . goto ( ` ${ base } #/accueil ` ) ;
await until ( page , "carte de la leçon" , ( ) => {
const card = document . getElementById ( "home-lesson" ) ;
return location . hash === "#/accueil" && ! card . hidden && ! card . closest ( "[hidden]" ) &&
card . textContent . includes ( "Leçon 1" ) && ! document . getElementById ( "home-new" ) . disabled &&
document . getElementById ( "home-progress" ) . textContent . includes ( " · " ) ;
} ) ;
const home = await page . evaluate ( ( ) => ( {
go : document . getElementById ( "home-lesson-go" ) . getAttribute ( "href" ) ,
goText : document . getElementById ( "home-lesson-go" ) . textContent ,
goClass : document . getElementById ( "home-lesson-go" ) . className ,
all : document . getElementById ( "home-lessons" ) . getAttribute ( "href" ) ,
title : document . querySelector ( "#home-lesson .lesson-card-title" ) . textContent ,
state : document . querySelector ( "#home-lesson .lesson-card-state" ) . textContent ,
summary : document . getElementById ( "home-progress" ) . textContent ,
primaries : [ ... document . querySelectorAll ( "#home a.button:not(.secondary), #home button:not(.secondary):not(.icon-button)" ) ]
. filter ( ( el ) => ! el . closest ( "[hidden]" ) ) . length ,
} ) ) ;
check ( home . go === "#/lecons/1/1" && home . goText === "Commencer" && home . goClass . includes ( "secondary" ) &&
home . all === "#/lecons" && home . title === "Les ouvertures" && home . state === "À commencer · 4 exercices" &&
home . summary . startsWith ( "Aucune leçon terminée sur 5 · " ) && home . primaries === 1 ,
` carte de l'accueil : ${ JSON . stringify ( home ) } ` ) ;
await capture ( page , "01-accueil-carte" , async ( ) => {
await page . locator ( "#home-lesson" ) . scrollIntoViewIfNeeded ( ) ;
} ) ;
log ( "accueil : carte « prochaine leçon » (leçon 1, à commencer), « Commencer » en secondaire" ) ;
// --------------------------------------------------------- La liste
await page . click ( "#home-lessons" ) ;
await until ( page , "liste des leçons" , ( ) => location . hash === "#/lecons" &&
document . querySelectorAll ( "#lesson-list .lesson-card" ) . length === 5 ) ;
const list = await listStates ( page ) ;
check ( list [ 0 ] . current && list . filter ( ( c ) => c . current ) . length === 1 &&
list . every ( ( c ) => c . state . startsWith ( "À commencer" ) ) , ` liste : ${ JSON . stringify ( list ) } ` ) ;
const primaries = await page . evaluate ( ( ) =>
[ ... document . querySelectorAll ( "#lessons a.button:not(.secondary)" ) ] . filter ( ( el ) => ! el . closest ( "[hidden]" ) ) . length ) ;
check ( primaries === 1 , ` liste : ${ primaries } actions principales ` ) ;
check ( await page . getAttribute ( "#lessons-rules" , "href" ) === "#/tutoriel/1" , "liste : rappel des règles" ) ;
const rulesHeight = await page . evaluate ( ( ) => document . getElementById ( "lessons-rules" ) . getBoundingClientRect ( ) . height ) ;
check ( rulesHeight >= 44 , ` liste : rappel des règles haut de ${ rulesHeight } px ` ) ;
await capture ( page , "02-liste" ) ;
log ( "liste : cinq leçons, la première proposée, une seule action principale, rappel des règles" ) ;
// ------------------------------------------- Un écran d'explication
await page . click ( "#lesson-card-1 .lesson-card-action" ) ;
await lessonReady ( page , "#/lecons/1/1" ) ;
const explain = await page . evaluate ( ( ) => ( {
overline : document . getElementById ( "lesson-overline" ) . textContent ,
title : document . getElementById ( "lesson-title" ) . textContent ,
segments : document . querySelectorAll ( "#lesson-progress .segment" ) . length ,
skip : document . getElementById ( "lesson-skip" ) . getAttribute ( "href" ) ,
back : document . getElementById ( "lesson-back" ) . getAttribute ( "href" ) ,
next : document . getElementById ( "lesson-next" ) . getAttribute ( "href" ) ,
topbar : getComputedStyle ( document . querySelector ( ".topbar" ) ) . display ,
glossary : [ ... document . querySelectorAll ( "#lesson-text a" ) ] . map ( ( a ) => a . getAttribute ( "href" ) ) ,
} ) ) ;
check ( explain . overline === "Leçon 1 · 1 sur 4" && explain . title === "Un point dès le premier coup" &&
explain . segments === 4 && explain . skip === "#/lecons" && explain . back === "#/lecons" &&
explain . next === "#/lecons/1/1/a-vous" && explain . topbar === "none" &&
explain . glossary . includes ( "#/glossaire/jan-interieur" ) , ` explication : ${ JSON . stringify ( explain ) } ` ) ;
// « Revoir » sur la ligne du titre ; « Retour » et « Continuer » en bas
// de l'écran, sous le pouce (recette 1g).
const layout = await page . evaluate ( ( ) => {
const title = document . getElementById ( "lesson-title" ) . getBoundingClientRect ( ) ;
const replay = document . getElementById ( "lesson-replay" ) . getBoundingClientRect ( ) ;
const next = document . getElementById ( "lesson-next" ) . getBoundingClientRect ( ) ;
return { titleTop : title . top , titleBottom : title . bottom , replayTop : replay . top ,
replayBottom : replay . bottom , nextBottom : next . bottom , height : innerHeight } ;
} ) ;
check ( layout . replayTop < layout . titleBottom && layout . replayBottom > layout . titleTop ,
` « Revoir » hors de la ligne du titre : ${ JSON . stringify ( layout ) } ` ) ;
check ( layout . nextBottom > layout . height - 64 , ` « Continuer » loin du bas : ${ JSON . stringify ( layout ) } ` ) ;
check ( await page . locator ( "#lesson-board .trace-arrow" ) . count ( ) > 0 , "le coup illustré n'a pas laissé de traces" ) ;
await capture ( page , "03-explication" ) ;
log ( "explication : coup illustré 8/5 6/5 joué tout seul, barre de quatre étapes, liens du glossaire" ) ;
// ------------------------------------------- Un exercice « à vous »
await page . click ( "#lesson-next" ) ;
await lessonReady ( page , "#/lecons/1/1/a-vous" ) ;
// 13/11 : ne mène pas à 8/4 6/4, refusé.
await playTouches ( page , [ [ "p13" , "p11" ] ] ) ;
check ( await verdict ( page ) === "wrong" , "exercice : 13/11 accepté" ) ;
await until ( page , "pion revenu" , ( ) => document . querySelectorAll ( "#lesson-board .hit.can-move" ) . length > 0 ) ;
const refused = await page . textContent ( "#lesson-feedback" ) ;
check ( refused . startsWith ( "Ce coup est permis, mais" ) && ! /erreur/i . test ( refused ) , ` refus : ${ refused } ` ) ;
await capture ( page , "04-exercice-refuse" ) ;
await playTouches ( page , [ [ "p8" , "p4" ] , [ "p6" ] ] ) ;
check ( await verdict ( page ) === "right" , "exercice : 8/4 6/4 refusé" ) ;
check ( await page . locator ( "#lesson-board .hit.can-move" ) . count ( ) === 0 , "plateau encore jouable après le bon coup" ) ;
const right = await page . textContent ( "#lesson-feedback" ) ;
check ( right . includes ( "8/4 6/4" ) , ` réussite : ${ right } ` ) ;
check ( ! ( await page . evaluate ( ( ) => document . getElementById ( "lesson-next" ) . classList . contains ( "secondary" ) ) ) ,
"« Continuer » pas principal après le bon coup" ) ;
await capture ( page , "05-exercice-reussi" ) ;
log ( "exercice : 13/11 refusé avec bienveillance, 8/4 6/4 salué" ) ;
// ------------------------------------ La progression, gardée
await page . goto ( ` ${ base } #/lecons ` ) ;
await page . reload ( ) ;
await until ( page , "liste après rechargement" , ( ) => location . hash === "#/lecons" &&
document . querySelectorAll ( "#lesson-list .lesson-card" ) . length === 5 &&
document . querySelector ( "#lesson-card-1 .lesson-card-state" ) ? . textContent === "1 exercice sur 4" ) ;
const after = await listStates ( page ) ;
check ( after [ 0 ] . current && after [ 0 ] . done === 1 , ` liste après l'exercice : ${ JSON . stringify ( after ) } ` ) ;
await page . goto ( ` ${ base } #/accueil ` ) ;
await until ( page , "accueil après l'exercice" , ( ) =>
document . querySelector ( "#home-lesson .lesson-card-state" ) ? . textContent === "1 exercice sur 4" &&
document . getElementById ( "home-lesson-go" ) . textContent === "Continuer" &&
document . getElementById ( "home-lesson-go" ) . getAttribute ( "href" ) === "#/lecons/1/1" ) ;
log ( "progression : « 1 exercice sur 4 » après rechargement, « Continuer » sur l'accueil" ) ;
// --------------------------------------- La fin d'une leçon
await page . goto ( ` ${ base } #/lecons/1/fin ` ) ;
await lessonReady ( page , "#/lecons/1/fin" ) ;
const end = await page . evaluate ( ( ) => ( {
overline : document . getElementById ( "lesson-overline" ) . textContent ,
next : document . getElementById ( "lesson-next" ) . getAttribute ( "href" ) ,
text : document . getElementById ( "lesson-next" ) . textContent ,
} ) ) ;
check ( end . overline === "Leçon 1 · fin" && end . next === "#/lecons/2/1" && end . text === "Leçon suivante" ,
` fin : ${ JSON . stringify ( end ) } ` ) ;
await page . click ( "#lesson-next" ) ;
await lessonReady ( page , "#/lecons/2/1" ) ;
log ( "fin : « Leçon suivante » mène à la leçon 2" ) ;
// Une leçon inconnue : la liste ; une étape inconnue : la première.
await page . goto ( ` ${ base } #/lecons/9/1 ` ) ;
await until ( page , "leçon inconnue" , ( ) => location . hash === "#/lecons" &&
document . querySelectorAll ( "#lesson-list .lesson-card" ) . length === 5 ) ;
await page . goto ( ` ${ base } #/lecons/3/9 ` ) ;
await lessonReady ( page , "#/lecons/3/1" ) ;
log ( "adresses : leçon inconnue vers la liste, étape inconnue vers la première" ) ;
const csp = await page . evaluate ( ( ) => window . _ _csp ) ;
check ( csp . length === 0 , ` violations de la CSP : ${ csp . join ( ", " ) } ` ) ;
check ( problems . length === 0 , problems . join ( "\n" ) ) ;
log ( ` leçons : OK ${ full ? ` , ${ captures } captures dans ${ dir } ` : "" } ` ) ;
} catch ( error ) {
console . error ( ` [ ${ browserName } ] ÉCHEC : ${ error . message } ` ) ;
if ( problems . length > 0 ) {
console . error ( problems . join ( "\n" ) ) ;
}
await browser . close ( ) ;
process . exit ( 1 ) ;
}
await browser . close ( ) ;