@nodefony/devkit 10.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +318 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateParam.js +8 -0
  6. package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorate.js +9 -0
  7. package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorateMetadata.js +6 -0
  8. package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorateParam.js +8 -0
  9. package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorate.js +9 -0
  10. package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorateMetadata.js +6 -0
  11. package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorateParam.js +8 -0
  12. package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorate.js +9 -0
  13. package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorateMetadata.js +6 -0
  14. package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorateParam.js +8 -0
  15. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  16. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  17. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateParam.js +8 -0
  18. package/dist/index.js +47 -0
  19. package/dist/nodefony/command/CardCommand.js +70 -0
  20. package/dist/nodefony/config/config.js +200 -0
  21. package/dist/nodefony/config/defineModuleConfig.js +36 -0
  22. package/dist/nodefony/controllers/DevkitController.js +60 -0
  23. package/dist/nodefony/controllers/McpController.js +223 -0
  24. package/dist/nodefony/controllers/OAuthMetadataController.js +89 -0
  25. package/dist/nodefony/interfaces/IDevkitService.js +1 -0
  26. package/dist/nodefony/interfaces/index.js +1 -0
  27. package/dist/nodefony/service/DevkitService.js +198 -0
  28. package/dist/nodefony/src/card.js +2 -0
  29. package/dist/nodefony/src/errors/DevkitError.js +21 -0
  30. package/dist/nodefony/src/mcp/guard.js +51 -0
  31. package/dist/nodefony/src/mcp/protocol.js +127 -0
  32. package/dist/nodefony/src/mcp/server.js +133 -0
  33. package/dist/nodefony/src/mcp/tools.js +163 -0
  34. package/dist/types/index.d.ts +52 -0
  35. package/dist/types/nodefony/command/CardCommand.d.ts +33 -0
  36. package/dist/types/nodefony/config/config.d.ts +25 -0
  37. package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
  38. package/dist/types/nodefony/controllers/DevkitController.d.ts +37 -0
  39. package/dist/types/nodefony/controllers/McpController.d.ts +70 -0
  40. package/dist/types/nodefony/controllers/OAuthMetadataController.d.ts +39 -0
  41. package/dist/types/nodefony/interfaces/IDevkitService.d.ts +69 -0
  42. package/dist/types/nodefony/interfaces/index.d.ts +1 -0
  43. package/dist/types/nodefony/service/DevkitService.d.ts +143 -0
  44. package/dist/types/nodefony/src/card.d.ts +18 -0
  45. package/dist/types/nodefony/src/errors/DevkitError.d.ts +14 -0
  46. package/dist/types/nodefony/src/mcp/guard.d.ts +66 -0
  47. package/dist/types/nodefony/src/mcp/protocol.d.ts +139 -0
  48. package/dist/types/nodefony/src/mcp/server.d.ts +48 -0
  49. package/dist/types/nodefony/src/mcp/tools.d.ts +81 -0
  50. package/docs/index.md +358 -0
  51. package/package.json +77 -0
  52. package/skills/nodefony-add-crud/SKILL.md +199 -0
  53. package/skills/nodefony-add-realtime-channel/SKILL.md +95 -0
  54. package/skills/nodefony-add-service/SKILL.md +90 -0
  55. package/skills/nodefony-browser/SKILL.md +416 -0
  56. package/skills/nodefony-browser/references/socket.md +115 -0
  57. package/skills/nodefony-browser/references/sondes.md +175 -0
  58. package/skills/nodefony-browser/scripts/audit.mjs +169 -0
  59. package/skills/nodefony-browser/scripts/inspect.mjs +903 -0
  60. package/skills/nodefony-browser/scripts/lib/browser.mjs +357 -0
  61. package/skills/nodefony-browser/scripts/lib/probes.mjs +501 -0
  62. package/skills/nodefony-browser/scripts/lib/wcag.mjs +153 -0
  63. package/skills/nodefony-browser/scripts/socket.mjs +354 -0
  64. package/skills/nodefony-browser/scripts/watch.mjs +125 -0
  65. package/skills/nodefony-migrate-schema/SKILL.md +359 -0
  66. package/skills/nodefony-migrate-schema/references/verdicts.md +139 -0
  67. package/skills/nodefony-protect-route/SKILL.md +195 -0
@@ -0,0 +1,903 @@
1
+ /**
2
+ * Ouvre une page dans le navigateur en conteneur, la MESURE et la capture.
3
+ *
4
+ * Pilote Playwright DIRECTEMENT (aucun protocole intermédiaire) : une commande,
5
+ * un JSON en sortie, un code de retour. Rend les couleurs et tailles RÉELLEMENT
6
+ * calculées par le moteur de rendu — ce qu'une capture d'écran ne dit pas.
7
+ *
8
+ * Le socle (thème, langue, titre, scripts servis, sondes de style, console,
9
+ * erreurs non capturées, violations CSP, capture) sort toujours ; le reste est
10
+ * découpé en FAMILLES activables — un mur de JSON que personne ne lit ne sert
11
+ * à rien. La doc de chaque famille vit dans `references/sondes.md` du skill.
12
+ *
13
+ * `@usage` node skills/nodefony-browser/scripts/inspect.mjs /tableau-de-bord "Chiffre d'affaires"
14
+ * `@env` NF_BROWSER_BASE origine à joindre (défaut CONSTATÉ : 127.0.0.1 en local, host.docker.internal en conteneur)
15
+ * `@env` NF_BROWSER_OUT dossier des captures et de l'état d'authentification (défaut constaté de la même façon)
16
+ * `@env` NF_BROWSER_PAGE chemin de la page à ouvrir (défaut /)
17
+ * `@env` NF_BROWSER_EXPECT texte DISCRIMINANT attendu avant de mesurer (défaut : aucun, on mesure après domcontentloaded)
18
+ * `@env` NF_BROWSER_FAMILIES familles de sondes à activer, séparées par des virgules (a11y, axe, rendu, reseau, perf, stockage, responsive — ou « toutes ») ; défaut : aucune, le socle seul
19
+ * `@env` NF_BROWSER_ENGINE navigateur imposé (chromium, chrome, msedge) ; sans lui, le premier qui répond
20
+ * `@env` NF_BROWSER_COLOR_SCHEME schéma de couleurs émulé (light, dark, no-preference) — un défaut peut n'exister que dans UN thème
21
+ * `@env` NF_BROWSER_STORAGE entrées de stockage local posées AVANT chargement (`clé=valeur`) — pour une application qui MÉMORISE son thème
22
+ * `@env` NF_BROWSER_LOGIN chemin du formulaire de connexion de TON application — requis dès qu'un identifiant est donné, aucun défaut n'est deviné
23
+ * `@env` NF_BROWSER_USER identifiant de connexion ; si absent, aucune authentification n'est tentée
24
+ * `@env` NF_BROWSER_PASSWORD mot de passe associé
25
+ * `@env` NF_BROWSER_ACTIONS séquence d'interactions AVANT mesure, séparées par `|` —
26
+ * `verbe:cible[=valeur]` : clic (défaut), double, droit, survol,
27
+ * saisir, touche, voir, defiler, attendre. Un écran qui se déplie
28
+ * au clic n'existe pas tant qu'on ne l'a pas ouvert.
29
+ * `@env` NF_BROWSER_FULLPAGE 1 = capture la page ENTIÈRE (défaut : la fenêtre)
30
+ * `@env` NF_BROWSER_PROBES sélecteurs CSS à sonder, séparés par des virgules (`libellé=sélecteur`)
31
+ * `@env` NF_BROWSER_WIDTHS largeurs de la famille responsive (défaut 360,768,1280)
32
+ * `@env` NF_BROWSER_SEUIL_LOURD bytes au-delà desquels une ressource est « lourde » (défaut 512000)
33
+ * `@env` NF_BROWSER_SEUIL_LENT millisecondes au-delà desquelles une réponse est « lente » (défaut 1000)
34
+ * `@requires` conteneur du profil `browser` démarré · serveur joignable depuis le conteneur
35
+ * `@output` un objet JSON sur stdout + une capture PNG horodatée dans /output
36
+ * `@exit` 0 mesure rendue (le verdict est une DONNÉE, pas un code de retour) · 64 usage (famille inconnue, identifiant sans chemin de connexion) · 65 texte attendu jamais apparu
37
+ */
38
+ import { existsSync, readFileSync } from "node:fs";
39
+ import path from "node:path";
40
+ import * as decor from "./lib/browser.mjs";
41
+ import { open, goTo, LOGIN, OUTPUT } from "./lib/browser.mjs";
42
+ import { sourceWcag } from "./lib/wcag.mjs";
43
+ import {
44
+ FAMILIES,
45
+ parseActions,
46
+ parseFamilies,
47
+ parseProbes,
48
+ parseWidths,
49
+ summarizeAxe,
50
+ verdictGlobal,
51
+ } from "./lib/probes.mjs";
52
+
53
+ const PAGE = process.argv[2] ?? process.env.NF_BROWSER_PAGE ?? "/";
54
+ const EXPECT = process.argv[3] ?? process.env.NF_BROWSER_EXPECT ?? "";
55
+ /**
56
+ * Ce qu'il faut FAIRE avant de mesurer — une séquence, pas un geste.
57
+ *
58
+ * Certaines pages ne sont pas un état mais un PARCOURS : le formulaire de
59
+ * création d'application n'affiche ses questions qu'après le choix d'un type,
60
+ * un menu ne s'ouvre qu'au survol, un panneau qu'au second clic. Sans ce
61
+ * levier, on photographie l'écran d'accueil et l'on conclut « le champ n'y est
62
+ * pas » — alors qu'on ne l'a jamais ouvert.
63
+ *
64
+ * Grammaire : `verbe:cible[=valeur]`, séparés par `|`. Le verbe est facultatif
65
+ * (`clic` par défaut), et la cible est cherchée d'abord comme TEXTE visible —
66
+ * ce que voit l'utilisateur — puis comme sélecteur CSS.
67
+ *
68
+ * | verbe | ce qu'il fait |
69
+ * | ---------- | --------------------------------------------------------- |
70
+ * | `clic` | clic gauche (le défaut) |
71
+ * | `double` | double-clic |
72
+ * | `droit` | clic droit — ouvre un menu contextuel applicatif |
73
+ * | `survol` | survol : révèle une infobulle, un menu déroulant |
74
+ * | `saisir` | remplit un champ (`saisir:Nom=mon-app`) |
75
+ * | `touche` | frappe une touche (`touche:Enter`, `touche:Escape`) |
76
+ * | `voir` | amène dans la vue SANS cliquer |
77
+ * | `defiler` | fait défiler de N pixels (`defiler:600`), page ou conteneur |
78
+ * | `attendre` | attend qu'un texte APPARAISSE (après une action lente) |
79
+ *
80
+ * 🔴 `voir` existe parce qu'un clic n'est pas neutre : sur un formulaire, le
81
+ * texte d'une question est un `label` — cliquer dessus COCHE la case qu'il
82
+ * décrit, et l'on observerait un écran que l'observation a modifié.
83
+ *
84
+ * ⚠️ Et `defiler` n'est pas `NF_BROWSER_FULLPAGE` : une application dont le
85
+ * contenu défile dans un conteneur interne (toute console à barre latérale
86
+ * fixe) ne GRANDIT pas — la capture « page entière » y rend exactement la
87
+ * fenêtre, et l'on conclut que ce qui est plus bas n'existe pas. Vécu.
88
+ */
89
+ const ACTIONS = parseActions(process.env.NF_BROWSER_ACTIONS);
90
+
91
+ const { kept, unknown } = parseFamilies(process.env.NF_BROWSER_FAMILIES);
92
+ if (unknown.length > 0) {
93
+ // Refuser, jamais ignorer : une famille fautée en silence ferait croire
94
+ // qu'on a mesuré ce qu'on n'a pas mesuré.
95
+ console.error(
96
+ `Famille(s) de sondes inconnue(s) : ${unknown.join(", ")}\n` +
97
+ `Familles disponibles (ou « toutes ») :\n` +
98
+ Object.entries(FAMILIES)
99
+ .map(([name, description]) => ` ${name} — ${description}`)
100
+ .join("\n"),
101
+ );
102
+ process.exit(64); // EX_USAGE
103
+ }
104
+ const active = new Set(kept);
105
+
106
+ /**
107
+ * Sondes de style par défaut — surchargées par NF_BROWSER_PROBES.
108
+ *
109
+ * Ici, et SEULEMENT ici, les sélecteurs CSS sont le bon outil, alors que
110
+ * Playwright les déconseille : la recommandation vise les tests, où l'on veut
111
+ * atteindre ce que l'UTILISATEUR perçoit (rôle, libellé). Une sonde de style
112
+ * fait l'inverse — elle mesure une IMPLÉMENTATION et doit viser la classe.
113
+ * Le défaut vise des éléments que TOUTE page possède.
114
+ */
115
+ const { probes: PROBES, rejected } = parseProbes(
116
+ process.env.NF_BROWSER_PROBES ?? "titre principal=h1,corps de page=body",
117
+ );
118
+ if (rejected.length > 0) {
119
+ console.error(
120
+ `Sonde(s) ignorée(s), forme attendue « libellé=sélecteur » : ${rejected.join(" · ")}`,
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Le code d'`axe-core`, quel que soit l'endroit d'où il est joignable.
126
+ *
127
+ * Trois voies, de la plus explicite à la plus commode — parce que le script
128
+ * s'exécute DANS un conteneur où l'arborescence de l'application n'est pas
129
+ * forcément montée, et qu'un « module introuvable » y est illisible.
130
+ *
131
+ * @returns {Promise<string>} le source complet, prêt à être évalué dans la page.
132
+ * @throws Si aucune des trois voies n'aboutit — dire l'indisponibilité vaut
133
+ * toujours mieux que rendre un verdict sans avoir mesuré.
134
+ */
135
+ async function axeSource() {
136
+ const explicite = process.env.NF_BROWSER_AXE;
137
+ if (explicite) return readFileSync(explicite, "utf8");
138
+ const sibling = new URL("./axe.min.js", import.meta.url);
139
+ if (existsSync(sibling)) return readFileSync(sibling, "utf8");
140
+ const { default: axe } = await import("axe-core");
141
+ return axe.source;
142
+ }
143
+
144
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
145
+
146
+ const { browser, ctx, page, reuse } = await open();
147
+
148
+ // ── Collecteurs — posés AVANT toute navigation ──────────────────────────────
149
+ const consoleErrors = [];
150
+ const uncaughtErrors = [];
151
+ page.on("console", (m) => {
152
+ if (m.type() === "error" && consoleErrors.length < 30)
153
+ consoleErrors.push(m.text().slice(0, 300));
154
+ });
155
+ // `pageerror` et non seulement `console` : une exception non capturée qui tue
156
+ // l'application ne passe pas toujours par console.error.
157
+ page.on("pageerror", (e) => {
158
+ if (uncaughtErrors.length < 20) uncaughtErrors.push(String(e).slice(0, 300));
159
+ });
160
+
161
+ // Les violations CSP ne sont visibles QUE depuis la page : le réseau montre la
162
+ // requête absente, jamais la raison. L'init script est réinjecté à chaque
163
+ // navigation — c'est l'état de la DERNIÈRE page qu'on lit, celle qu'on mesure.
164
+ await page.addInitScript(() => {
165
+ window.__nfCsp = [];
166
+ document.addEventListener("securitypolicyviolation", (e) => {
167
+ if (window.__nfCsp.length < 20)
168
+ window.__nfCsp.push({
169
+ directive: e.violatedDirective,
170
+ blocked: String(e.blockedURI ?? "").slice(0, 140),
171
+ source: String(e.sourceFile ?? "").slice(0, 140),
172
+ line: e.lineNumber,
173
+ });
174
+ });
175
+ });
176
+
177
+ if (active.has("perf")) {
178
+ // LCP et CLS n'existent qu'en OBSERVANT pendant le chargement : les lire
179
+ // après coup rend null. `buffered: true` rattrape ce qui s'est produit entre
180
+ // l'injection et l'observation ; le try par type, car un navigateur qui
181
+ // ignore un type d'entrée lève — et tuerait les deux autres mesures.
182
+ await page.addInitScript(() => {
183
+ window.__nfPerf = { lcpMs: null, cls: 0, longTasks: 0 };
184
+ try {
185
+ new PerformanceObserver((l) => {
186
+ const e = l.getEntries().pop();
187
+ if (e) window.__nfPerf.lcpMs = e.startTime;
188
+ }).observe({ type: "largest-contentful-paint", buffered: true });
189
+ } catch {}
190
+ try {
191
+ new PerformanceObserver((l) => {
192
+ for (const e of l.getEntries())
193
+ if (!e.hadRecentInput) window.__nfPerf.cls += e.value;
194
+ }).observe({ type: "layout-shift", buffered: true });
195
+ } catch {}
196
+ try {
197
+ new PerformanceObserver((l) => {
198
+ window.__nfPerf.longTasks += l.getEntries().length;
199
+ }).observe({ type: "longtask", buffered: true });
200
+ } catch {}
201
+ });
202
+ }
203
+
204
+ const finishedRequests = [];
205
+ const networkFailures = [];
206
+ if (active.has("reseau")) {
207
+ page.on("requestfinished", (rq) => {
208
+ if (finishedRequests.length < 300) finishedRequests.push(rq);
209
+ });
210
+ page.on("requestfailed", (rq) => {
211
+ if (networkFailures.length < 40)
212
+ networkFailures.push({
213
+ url: rq.url().slice(0, 140),
214
+ type: rq.resourceType(),
215
+ error: rq.failure()?.errorText ?? "?",
216
+ });
217
+ });
218
+ page.on("response", (r) => {
219
+ if (r.status() >= 400 && networkFailures.length < 40)
220
+ networkFailures.push({
221
+ url: r.url().slice(0, 140),
222
+ type: r.request().resourceType(),
223
+ status: r.status(),
224
+ });
225
+ });
226
+ }
227
+
228
+ await goTo(page, ctx, PAGE, reuse);
229
+
230
+ // Attendre un texte DISCRIMINANT, jamais `networkidle` : une application qui se
231
+ // monte puis demande ses données passe par un état « réseau calme » où l'écran
232
+ // est encore vide. Mesurer là rend des sondes absentes et des 401 encore en vol
233
+ // — on décrit alors un écran qui n'existe déjà plus. Vécu.
234
+ if (EXPECT) {
235
+ try {
236
+ await page.getByText(EXPECT).first().waitFor({ timeout: 20000 });
237
+ } catch {
238
+ // La cause la plus fréquente n'est pas « le texte n'existe pas » mais
239
+ // « on n'est pas sur la page qu'on croit » — identifiants refusés, session
240
+ // expirée, route protégée. On le CONSTATE avant de rendre la main.
241
+ console.error(
242
+ `Texte attendu jamais apparu : « ${EXPECT} »\n` +
243
+ `Page réellement ouverte : ${page.url()}\n` +
244
+ (LOGIN && new URL(page.url()).pathname.endsWith(LOGIN)
245
+ ? "→ on est resté sur le formulaire de connexion : identifiants refusés, ou la page demandée est protégée et NF_BROWSER_USER n'a pas été fourni."
246
+ : "→ la page est bien ouverte : le texte attendu est absent, ou il n'est pas encore rendu."),
247
+ );
248
+ await browser.close();
249
+ process.exit(65); // EX_DATAERR
250
+ }
251
+ }
252
+
253
+ // Jouer la séquence — après le texte discriminant (la page est montée), avant
254
+ // toute mesure. Une action qui ne trouve pas sa cible ARRÊTE la sonde : la
255
+ // mesure qui suivrait porterait sur un écran qu'on n'a pas ouvert, et rien ne
256
+ // le dirait.
257
+ for (const { verb, target, value } of ACTIONS) {
258
+ if (verb === "defiler") {
259
+ const pixels = Number.parseInt(target, 10);
260
+ if (!Number.isFinite(pixels)) {
261
+ console.error(`defiler attend un nombre de pixels, reçu « ${target} »`);
262
+ await browser.close();
263
+ process.exit(64); // EX_USAGE
264
+ }
265
+ // Le conteneur qui défile RÉELLEMENT, pas la fenêtre : sur une console à
266
+ // barre latérale fixe, `window.scrollBy` ne bouge rien du tout.
267
+ await page.evaluate((dy) => {
268
+ const scrollable = [...document.querySelectorAll("*")].find(
269
+ (el) => el.scrollHeight > el.clientHeight + 40 && el.clientHeight > 200,
270
+ );
271
+ (scrollable ?? window).scrollBy(0, dy);
272
+ }, pixels);
273
+ continue;
274
+ }
275
+ // Le premier candidat VISIBLE, pas le premier du DOM : un libellé apparaît
276
+ // souvent d'abord dans un menu replié ou un gabarit caché, et agir là ne fait
277
+ // rien tout en passant pour un succès.
278
+ const candidates = page.getByText(target, { exact: false });
279
+ let locator = null;
280
+ const total = await candidates.count();
281
+ for (let i = 0; i < total; i += 1) {
282
+ const c = candidates.nth(i);
283
+ if (await c.isVisible().catch(() => false)) {
284
+ locator = c;
285
+ break;
286
+ }
287
+ }
288
+ locator ??= page.locator(target).first();
289
+ try {
290
+ await locator.waitFor({ timeout: 15000 });
291
+ if (verb === "clic") await locator.click();
292
+ else if (verb === "double") await locator.dblclick();
293
+ else if (verb === "droit") await locator.click({ button: "right" });
294
+ else if (verb === "survol") await locator.hover();
295
+ else if (verb === "saisir") await locator.fill(value);
296
+ else if (verb === "touche") await locator.press(value || "Enter");
297
+ else if (verb === "voir") await locator.scrollIntoViewIfNeeded();
298
+ // `attendre` : le `waitFor` ci-dessus EST l'action.
299
+ } catch {
300
+ console.error(
301
+ `Action « ${verb}:${target} » impossible\n` +
302
+ `Page réellement ouverte : ${page.url()}\n` +
303
+ "→ le libellé a changé, l'élément n'est pas encore rendu, ou il faut " +
304
+ "agir sur autre chose avant lui (NF_BROWSER_ACTIONS accepte une séquence, séparée par « | »).",
305
+ );
306
+ await browser.close();
307
+ process.exit(65); // EX_DATAERR
308
+ }
309
+ }
310
+
311
+ // ── Les fonctions qui voyagent vers la page ─────────────────────────────────
312
+ // Elles sont AUTOSUFFISANTES (aucune fermeture sur ce module) : la sonde les
313
+ // injecte par leur code source dans une expression unique. C'est ce qui permet
314
+ // aux calculs WCAG d'avoir UNE seule implémentation, importée par les tests et
315
+ // exécutée par le navigateur.
316
+
317
+ /**
318
+ * Décrit un élément en une ligne courte — pour des exemples lisibles, jamais
319
+ * un dump de DOM.
320
+ */
321
+ function describeElement(el) {
322
+ const t = el.tagName.toLowerCase();
323
+ const id = el.id ? `#${el.id}` : "";
324
+ const cls = !id && el.classList.length ? `.${el.classList[0]}` : "";
325
+ const text = (el.textContent ?? "").trim().slice(0, 30);
326
+ return `${t}${id}${cls}${text ? ` « ${text} »` : ""}`.slice(0, 90);
327
+ }
328
+
329
+ /** Un élément participe-t-il au rendu — filtre commun des sondes. */
330
+ function isVisible(el) {
331
+ const r = el.getBoundingClientRect();
332
+ if (r.width === 0 && r.height === 0) return false;
333
+ const cs = getComputedStyle(el);
334
+ return cs.display !== "none" && cs.visibility !== "hidden";
335
+ }
336
+
337
+ /**
338
+ * Le fond RÉELLEMENT perçu derrière un élément — toutes couches EMPILÉES.
339
+ *
340
+ * Deux erreurs classiques, qui font toutes deux conclure faux :
341
+ *
342
+ * • lire `backgroundColor` sur l'élément seul rend `rgba(0, 0, 0, 0)` presque
343
+ * toujours, et le contraste calculé contre du transparent n'a aucun sens ;
344
+ * • s'arrêter à la première couche NON transparente traite un voile à 13 %
345
+ * comme un aplat plein — c'est-à-dire comme une couleur que personne ne voit.
346
+ *
347
+ * On empile donc les couches translucides jusqu'à la première opaque, puis on
348
+ * les compose de bas en haut, exactement comme le fait le moteur de rendu.
349
+ *
350
+ * @param {Element} el - l'élément dont on cherche le fond perçu.
351
+ * @returns {string} une couleur `rgb()` opaque, telle qu'elle est PERÇUE.
352
+ */
353
+ function effectiveBackground(el) {
354
+ const layers = [];
355
+ let opaqueLayer = null;
356
+ for (let n = el; n; n = n.parentElement) {
357
+ const bg = getComputedStyle(n).backgroundColor;
358
+ if (!bg || /transparent/.test(bg)) continue;
359
+ const { a } = parseColor(bg);
360
+ if (a === 0) continue;
361
+ if (a >= 1) {
362
+ opaqueLayer = bg;
363
+ break;
364
+ }
365
+ layers.push(bg);
366
+ }
367
+ // Faute de couche opaque rencontrée, le fond de la page fait socle — et à
368
+ // défaut le blanc, qui est ce qu'un navigateur peint sous un document nu.
369
+ if (opaqueLayer === null) {
370
+ const rootBackground = getComputedStyle(
371
+ document.documentElement,
372
+ ).backgroundColor;
373
+ opaqueLayer =
374
+ rootBackground && parseColor(rootBackground).a >= 1
375
+ ? rootBackground
376
+ : "rgb(255, 255, 255)";
377
+ }
378
+ // De la plus basse à la plus haute : chacune se compose sur le résultat
379
+ // précédent, jamais sur le socle seul.
380
+ let perceived = opaqueLayer;
381
+ for (let i = layers.length - 1; i >= 0; i--)
382
+ perceived = compose(layers[i], perceived);
383
+ return perceived;
384
+ }
385
+
386
+ /**
387
+ * Famille a11y — ce qu'un lecteur d'écran ou un clavier rencontrent VRAIMENT.
388
+ * Chaque règle rend un compte et 3 exemples, jamais la liste entière.
389
+ */
390
+ function probeA11y() {
391
+ const block = (list) => ({
392
+ total: list.length,
393
+ examples: list.slice(0, 3).map(describeElement),
394
+ });
395
+ // Nom accessible SIMPLIFIÉ (aria-label → aria-labelledby → texte → title →
396
+ // alt d'une image fille). L'algorithme complet de la norme fait plus ; le
397
+ // simplifié suffit à attraper un bouton-icône muet — le cas réel.
398
+ const accessibleName = (el) => {
399
+ const aria = el.getAttribute("aria-label");
400
+ if (aria && aria.trim()) return aria.trim();
401
+ const refs = el.getAttribute("aria-labelledby");
402
+ if (refs) {
403
+ const t = refs
404
+ .split(/\s+/)
405
+ .map((i) => document.getElementById(i)?.textContent ?? "")
406
+ .join(" ")
407
+ .trim();
408
+ if (t) return t;
409
+ }
410
+ const text = (el.textContent ?? "").trim();
411
+ if (text) return text;
412
+ if (el.getAttribute("title")) return el.getAttribute("title");
413
+ const img = el.querySelector("img[alt]");
414
+ if (img && img.getAttribute("alt")?.trim())
415
+ return img.getAttribute("alt").trim();
416
+ return "";
417
+ };
418
+ const withoutAlt = [...document.querySelectorAll("img")]
419
+ .filter(isVisible)
420
+ .filter((i) => !i.hasAttribute("alt"));
421
+ const withoutLabel = [
422
+ ...document.querySelectorAll("input:not([type=hidden]), select, textarea"),
423
+ ]
424
+ .filter(isVisible)
425
+ .filter((c) => {
426
+ if (
427
+ c.getAttribute("aria-label") ||
428
+ c.getAttribute("aria-labelledby") ||
429
+ c.getAttribute("title")
430
+ )
431
+ return false;
432
+ if (c.id && document.querySelector(`label[for="${CSS.escape(c.id)}"]`))
433
+ return false;
434
+ return !c.closest("label");
435
+ });
436
+ const withoutName = [
437
+ ...document.querySelectorAll("button, a[href], [role=button]"),
438
+ ]
439
+ .filter(isVisible)
440
+ .filter((c) => !accessibleName(c));
441
+ // Hiérarchie des titres : les niveaux dans l'ordre du document, et les sauts
442
+ // (h2→h4) qui cassent la table des matières d'un lecteur d'écran.
443
+ const headings = [...document.querySelectorAll("h1,h2,h3,h4,h5,h6")]
444
+ .filter(isVisible)
445
+ .map((h) => Number(h.tagName[1]));
446
+ const skips = [];
447
+ for (let i = 1; i < headings.length; i++)
448
+ if (headings[i] > headings[i - 1] + 1)
449
+ skips.push(`h${headings[i - 1]}→h${headings[i]}`);
450
+ const h1 = headings.filter((n) => n === 1).length;
451
+ // Cibles < 24×24 (WCAG 2.5.8). Les liens DANS le texte (display inline) sont
452
+ // exemptés par le critère lui-même — les compter noierait le signal.
453
+ const interactive = [
454
+ ...document.querySelectorAll(
455
+ "button, a[href], input:not([type=hidden]), select, textarea, [role=button]",
456
+ ),
457
+ ].filter(isVisible);
458
+ const small = [];
459
+ for (const el of interactive) {
460
+ if (el.tagName === "A" && getComputedStyle(el).display === "inline")
461
+ continue;
462
+ const r = el.getBoundingClientRect();
463
+ if (r.width < 24 || r.height < 24)
464
+ small.push({
465
+ element: describeElement(el),
466
+ size: `${Math.round(r.width)}×${Math.round(r.height)}`,
467
+ });
468
+ if (small.length >= 40) break;
469
+ }
470
+ // Un tabindex POSITIF impose un ordre de focus manuel qui diverge du DOM —
471
+ // l'anti-pattern classique d'un parcours clavier incompréhensible.
472
+ const positiveTabIndexes = [
473
+ ...document.querySelectorAll("[tabindex]"),
474
+ ].filter((el) => Number(el.getAttribute("tabindex")) > 0);
475
+ const lang = document.documentElement.lang || null;
476
+ const alerts =
477
+ withoutAlt.length +
478
+ withoutLabel.length +
479
+ withoutName.length +
480
+ small.length +
481
+ positiveTabIndexes.length +
482
+ skips.length +
483
+ (h1 === 1 ? 0 : 1) +
484
+ (lang ? 0 : 1);
485
+ return {
486
+ verdict: alerts === 0 ? "OK" : "ALERTE",
487
+ lang,
488
+ headings: { h1, order: headings.join(","), skips },
489
+ imagesWithoutAlt: block(withoutAlt),
490
+ fieldsWithoutLabel: block(withoutLabel),
491
+ controlsWithoutName: block(withoutName),
492
+ targetsTooSmall: {
493
+ total: small.length,
494
+ threshold: "24×24",
495
+ // Regroupées par FAMILLE, pas listées une à une. Trente-six cibles trop
496
+ // petites, c'est presque toujours un composant réutilisé trente-six fois :
497
+ // trois exemples bruts font croire à trente-six corrections, quand il n'y
498
+ // en a qu'une. Le compte par famille dit ce qu'il faut corriger, et
499
+ // combien d'écrans en profiteront.
500
+ families: Object.entries(
501
+ small.reduce((acc, p) => {
502
+ // Le texte distingue deux boutons du même composant : on l'enlève.
503
+ const key = `${p.element.replace(/ «[\s\S]*$/, "")} ${p.size}`;
504
+ acc[key] = (acc[key] ?? 0) + 1;
505
+ return acc;
506
+ }, Object.create(null)),
507
+ )
508
+ .sort((a, b) => b[1] - a[1])
509
+ .slice(0, 5)
510
+ .map(([what, n]) => ({ what, occurrences: n })),
511
+ examples: small.slice(0, 3),
512
+ },
513
+ positiveTabIndexValues: block(positiveTabIndexes),
514
+ visibleFocusables: interactive.length,
515
+ };
516
+ }
517
+
518
+ /**
519
+ * Famille rendu — la page tient-elle dans son viewport, ses polices sont-elles
520
+ * VRAIMENT arrivées. Les éléments hors viewport sont une INFO, pas le verdict :
521
+ * carrousels et textes pour lecteurs d'écran en produisent légitimement.
522
+ */
523
+ function probeRendering() {
524
+ const doc = document.scrollingElement ?? document.documentElement;
525
+ const overflowPx = Math.max(0, doc.scrollWidth - window.innerWidth);
526
+ const outsideViewport = [];
527
+ let totalOutside = 0;
528
+ for (const el of document.querySelectorAll("body *")) {
529
+ const r = el.getBoundingClientRect();
530
+ if (r.width > 0 && r.right > window.innerWidth + 1) {
531
+ totalOutside += 1;
532
+ if (outsideViewport.length < 3) outsideViewport.push(describeElement(el));
533
+ if (totalOutside >= 200) break;
534
+ }
535
+ }
536
+ const families = {};
537
+ let failedFonts = 0;
538
+ for (const f of document.fonts) {
539
+ families[`${f.family} ${f.weight}`] = f.status;
540
+ if (f.status === "error") failedFonts += 1;
541
+ }
542
+ return {
543
+ verdict: overflowPx > 0 || failedFonts > 0 ? "ALERTE" : "OK",
544
+ horizontalOverflow: { present: overflowPx > 0, overflowPx },
545
+ elementsOutsideViewport: { total: totalOutside, examples: outsideViewport },
546
+ fonts: {
547
+ status: document.fonts.status,
548
+ failed: failedFonts,
549
+ families,
550
+ },
551
+ };
552
+ }
553
+
554
+ /** Famille stockage, volet page — tailles et clés, JAMAIS les valeurs. */
555
+ function probeWebStorage() {
556
+ // Les valeurs ne sortent pas : un jeton de session imprimé dans un JSON de
557
+ // sonde finit dans un terminal, un log de CI, un rapport — il a fuité.
558
+ const inventory = (store) => {
559
+ const keys = [];
560
+ let bytes = 0;
561
+ for (let i = 0; i < store.length; i++) {
562
+ const key = store.key(i);
563
+ // ×2 : les chaînes JavaScript comptent en unités UTF-16.
564
+ const size = (store.getItem(key) ?? "").length * 2;
565
+ bytes += size;
566
+ keys.push({ key, bytes: size });
567
+ }
568
+ keys.sort((a, b) => b.bytes - a.bytes);
569
+ return { keys: keys.length, bytes, largest: keys.slice(0, 5) };
570
+ };
571
+ return {
572
+ localStorage: inventory(window.localStorage),
573
+ sessionStorage: inventory(window.sessionStorage),
574
+ };
575
+ }
576
+
577
+ /** Famille perf — lit ce que les observateurs injectés AVANT navigation ont vu. */
578
+ function readPerf() {
579
+ const round = (v) => (v == null || Number.isNaN(v) ? null : Math.round(v));
580
+ const nav = performance.getEntriesByType("navigation")[0];
581
+ const fcp = performance.getEntriesByName("first-contentful-paint")[0];
582
+ const p = window.__nfPerf ?? {};
583
+ const lcpMs = round(p.lcpMs);
584
+ const cls =
585
+ typeof p.cls === "number" ? Math.round(p.cls * 1000) / 1000 : null;
586
+ return {
587
+ // Seuils « bons » de l'initiative Web Vitals — au-delà, l'utilisateur
588
+ // attend ou voit la page bouger sous son doigt.
589
+ verdict:
590
+ (lcpMs != null && lcpMs > 2500) || (cls != null && cls > 0.1)
591
+ ? "ALERTE"
592
+ : "OK",
593
+ ttfbMs: nav ? round(nav.responseStart) : null,
594
+ domContentLoadedMs: nav ? round(nav.domContentLoadedEventEnd) : null,
595
+ loadCompleteMs: nav ? round(nav.loadEventEnd) : null,
596
+ fcpMs: fcp ? round(fcp.startTime) : null,
597
+ lcpMs,
598
+ cls,
599
+ longTasks: typeof p.longTasks === "number" ? p.longTasks : null,
600
+ thresholds: { lcpGoodMs: 2500, clsGood: 0.1 },
601
+ };
602
+ }
603
+
604
+ /**
605
+ * La mesure principale, composée puis évaluée en UNE expression dans la page.
606
+ * Async : les polices se constatent après `document.fonts.ready` (borné — une
607
+ * police qui ne finit jamais ne doit pas suspendre la sonde).
608
+ */
609
+ async function measurePage(args) {
610
+ const root = document.documentElement;
611
+ if (args.families.includes("rendu")) {
612
+ await Promise.race([
613
+ document.fonts.ready,
614
+ new Promise((r) => setTimeout(r, 2000)),
615
+ ]);
616
+ }
617
+ const base = {
618
+ // Le thème clair/sombre se lit sur ce qui est STANDARD, jamais sur
619
+ // l'attribut d'une bibliothèque en particulier : `color-scheme` est la
620
+ // valeur que le moteur de rendu APPLIQUE, et `data-theme` la convention
621
+ // du CSS nu. Autre marquage → le sonder soi-même (NF_BROWSER_PROBES).
622
+ theme: getComputedStyle(root).colorScheme || (root.dataset.theme ?? "?"),
623
+ lang: root.lang,
624
+ title: document.title,
625
+ // Les scripts RÉELLEMENT servis — c'est ce qui permet de vérifier que le
626
+ // bundle observé est bien celui qu'on vient de bâtir.
627
+ scripts: [...document.querySelectorAll("script[src]")].map((s) =>
628
+ s.getAttribute("src"),
629
+ ),
630
+ probes: args.probes.map(({ label, sel }) => {
631
+ const el = document.querySelector(sel);
632
+ if (!el) return { label, absent: true, selector: sel };
633
+ const cs = getComputedStyle(el);
634
+ const background = effectiveBackground(el);
635
+ const r = el.getBoundingClientRect();
636
+ const contrast = contrastRatio(compose(cs.color, background), background);
637
+ const px = parseFloat(cs.fontSize);
638
+ const bold = Number(cs.fontWeight) >= 700;
639
+ return {
640
+ label,
641
+ text: (el.textContent ?? "").trim().slice(0, 40),
642
+ color: cs.color,
643
+ background,
644
+ contrast,
645
+ font: `${cs.fontSize}${bold ? " gras" : ""}`,
646
+ wcag: verdictWcag(contrast, px, bold),
647
+ size: `${Math.round(r.width)}×${Math.round(r.height)}`,
648
+ };
649
+ }),
650
+ violationsCSP: window.__nfCsp ?? [],
651
+ };
652
+ if (args.families.includes("a11y")) base.a11y = probeA11y();
653
+ if (args.families.includes("rendu")) base.rendu = probeRendering();
654
+ if (args.families.includes("stockage")) base.stockageWeb = probeWebStorage();
655
+ if (args.families.includes("perf")) base.perf = readPerf();
656
+ return base;
657
+ }
658
+
659
+ // Composition : les sources des fonctions pures + les sondes + l'appel, en une
660
+ // expression unique. Passer par une chaîne évite `eval` DANS la page (que la
661
+ // CSP de l'application refuserait à bon droit) : c'est le pilote qui évalue.
662
+ const expression = `((args) => {
663
+ ${sourceWcag()}
664
+ ${describeElement}
665
+ ${isVisible}
666
+ ${effectiveBackground}
667
+ ${probeA11y}
668
+ ${probeRendering}
669
+ ${probeWebStorage}
670
+ ${readPerf}
671
+ return (${measurePage})(args);
672
+ })(${JSON.stringify({ probes: PROBES, families: [...active] })})`;
673
+
674
+ const measured = await page.evaluate(expression);
675
+
676
+ // ── Famille axe — l'audit d'accessibilité par un moteur dont c'est le métier ─
677
+ //
678
+ // Pourquoi une dépendance plutôt qu'un calcul maison : les règles WCAG sont
679
+ // pleines de cas particuliers qu'on ne devine pas — canaux en 0–1 des couleurs
680
+ // modernes, fonds semi-transparents à composer, texte peint par une police en
681
+ // couleurs, éléments masqués aux seules techniques d'assistance. Une sonde
682
+ // écrite à la main les rate, rend des échecs inventés qui NOIENT les vrais, et
683
+ // donne le pire des résultats : un rapport qu'on cesse de lire. `axe-core` est
684
+ // le moteur qu'embarque Lighthouse pour ce volet ; on l'appelle directement.
685
+ //
686
+ // Il est ÉVALUÉ par le pilote, jamais ajouté en `<script>` : la politique de
687
+ // sécurité de contenu d'une application sérieuse refuserait l'injection — et
688
+ // elle aurait raison.
689
+ if (active.has("axe")) {
690
+ try {
691
+ const axeCode = await axeSource();
692
+ const report = await page.evaluate(async (source) => {
693
+ // eslint-disable-next-line no-new-func -- évalué par le pilote, hors CSP
694
+ new Function(source)();
695
+ return await window.axe.run(document, {
696
+ resultTypes: ["violations", "incomplete"],
697
+ // La capture d'un nœud fautif suffit à le corriger ; l'inventaire
698
+ // complet gonfle la sortie sans rien apprendre.
699
+ elementRef: false,
700
+ });
701
+ }, axeCode);
702
+ measured.axe = summarizeAxe(report);
703
+ } catch (e) {
704
+ // Dire l'indisponibilité, ne JAMAIS rendre un verdict OK sans avoir mesuré.
705
+ measured.axe = {
706
+ verdict: "INDISPONIBLE",
707
+ reason: String(e).slice(0, 200),
708
+ remedy:
709
+ "Copier axe.min.js à côté des sondes (docker cp node_modules/axe-core/axe.min.js <conteneur>:/app/see-screen/axe.min.js), ou donner son chemin dans NF_BROWSER_AXE.",
710
+ };
711
+ }
712
+ }
713
+
714
+ // ── Arbre d'accessibilité — la voie Playwright, hors page ───────────────────
715
+ if (active.has("a11y") && measured.a11y) {
716
+ try {
717
+ // L'arbre ARIA tel que Playwright le calcule : ce que perçoit une
718
+ // technologie d'assistance, rôles et noms compris. Tronqué : il dit la
719
+ // STRUCTURE, pas l'inventaire.
720
+ const yaml = await page.locator("body").ariaSnapshot();
721
+ const lines = yaml.split("\n");
722
+ measured.a11y.tree = {
723
+ lines: lines.slice(0, 80),
724
+ totalLines: lines.length,
725
+ truncated: lines.length > 80,
726
+ };
727
+ } catch (e) {
728
+ measured.a11y.tree = { unavailable: String(e).slice(0, 140) };
729
+ }
730
+ }
731
+
732
+ // ── Famille réseau — bilan des collecteurs ──────────────────────────────────
733
+ if (active.has("reseau")) {
734
+ const heavyThreshold = Number(process.env.NF_BROWSER_SEUIL_LOURD ?? 512000);
735
+ const slowThreshold = Number(process.env.NF_BROWSER_SEUIL_LENT ?? 1000);
736
+ const items = await Promise.all(
737
+ finishedRequests.map(async (rq) => {
738
+ let bytes = null;
739
+ try {
740
+ // `sizes()` rend la taille RÉELLEMENT transférée ; l'en-tête
741
+ // content-length manque sur les réponses en flux.
742
+ bytes = (await rq.sizes()).responseBodySize;
743
+ } catch {
744
+ const r = await rq.response().catch(() => null);
745
+ const raw = r ? Number(r.headers()["content-length"]) : NaN;
746
+ bytes = Number.isFinite(raw) ? raw : null;
747
+ }
748
+ const t = rq.timing();
749
+ const ms =
750
+ t && typeof t.responseEnd === "number" && t.responseEnd >= 0
751
+ ? Math.round(t.responseEnd)
752
+ : null;
753
+ return { url: rq.url(), type: rq.resourceType(), bytes, ms };
754
+ }),
755
+ );
756
+ const byType = {};
757
+ let totalBytes = 0;
758
+ let unknownBytes = 0;
759
+ for (const i of items) {
760
+ byType[i.type] = (byType[i.type] ?? 0) + 1;
761
+ if (i.bytes != null && i.bytes >= 0) totalBytes += i.bytes;
762
+ else unknownBytes += 1;
763
+ }
764
+ const compact = (i) => ({
765
+ url: i.url.slice(0, 140),
766
+ type: i.type,
767
+ bytes: i.bytes,
768
+ ms: i.ms,
769
+ });
770
+ const heavy = items
771
+ .filter((i) => (i.bytes ?? 0) > heavyThreshold)
772
+ .sort((a, b) => (b.bytes ?? 0) - (a.bytes ?? 0))
773
+ .slice(0, 10)
774
+ .map(compact);
775
+ const slow = items
776
+ .filter((i) => (i.ms ?? 0) > slowThreshold)
777
+ .sort((a, b) => (b.ms ?? 0) - (a.ms ?? 0))
778
+ .slice(0, 10)
779
+ .map(compact);
780
+ measured.reseau = {
781
+ verdict: networkFailures.length > 0 || heavy.length > 0 ? "ALERTE" : "OK",
782
+ total: items.length,
783
+ byType,
784
+ totalBytes,
785
+ unknownBytes,
786
+ failures: networkFailures,
787
+ heavy: { bytesThreshold: heavyThreshold, resources: heavy },
788
+ slow: { msThreshold: slowThreshold, resources: slow },
789
+ };
790
+ }
791
+
792
+ // ── Famille stockage — volet cookies, lu hors page ──────────────────────────
793
+ if (active.has("stockage")) {
794
+ const cookies = await ctx.cookies();
795
+ const overHttps = page.url().startsWith("https");
796
+ const insecure = cookies.filter((c) => !c.secure).length;
797
+ measured.stockage = {
798
+ // Un cookie sans Secure sur une origine https voyagera aussi en clair.
799
+ verdict: overHttps && insecure > 0 ? "ALERTE" : "OK",
800
+ cookies: cookies.map((c) => ({
801
+ name: c.name,
802
+ domain: c.domain,
803
+ path: c.path,
804
+ secure: c.secure,
805
+ httpOnly: c.httpOnly,
806
+ sameSite: c.sameSite,
807
+ expired:
808
+ c.expires === -1 ? "session" : new Date(c.expires * 1000).toISOString(),
809
+ })),
810
+ ...measured.stockageWeb,
811
+ };
812
+ delete measured.stockageWeb;
813
+ }
814
+
815
+ // ── Capture — AVANT la famille responsive, qui déforme le viewport ──────────
816
+ const slug = PAGE.replace(/\//g, "-").replace(/^-/, "") || "racine";
817
+ const shot = path.join(OUTPUT, `${slug}-${stamp}.png`);
818
+ // `NF_BROWSER_FULLPAGE=1` : la page ENTIÈRE, pas la fenêtre. Un formulaire long
819
+ // (ou une page qu'on vient de déplier) a l'essentiel SOUS la ligne de flottaison
820
+ // — la capture par défaut laisse alors conclure « ce n'est pas là ».
821
+ await page.screenshot({
822
+ path: shot,
823
+ fullPage: process.env.NF_BROWSER_FULLPAGE === "1",
824
+ });
825
+
826
+ if (active.has("responsive")) {
827
+ const { widths, invalidWidths: invalid } = parseWidths(
828
+ process.env.NF_BROWSER_WIDTHS ?? "360,768,1280",
829
+ );
830
+ if (invalid.length > 0)
831
+ console.error(
832
+ `Largeur(s) ignorée(s) (entier entre 240 et 4000) : ${invalid.join(", ")}`,
833
+ );
834
+ const byWidth = [];
835
+ for (const width of widths) {
836
+ await page.setViewportSize({ width, height: 900 });
837
+ // Laisser les media queries et le reflow se produire — mesurer dans la
838
+ // même frame que le resize rend l'ANCIENNE géométrie.
839
+ await page.waitForTimeout(300);
840
+ const r = await page.evaluate(() => {
841
+ const doc = document.scrollingElement ?? document.documentElement;
842
+ const overflowPx = Math.max(0, doc.scrollWidth - window.innerWidth);
843
+ let overflowing = 0;
844
+ const examples = [];
845
+ if (overflowPx > 0) {
846
+ for (const el of document.querySelectorAll("body *")) {
847
+ const rect = el.getBoundingClientRect();
848
+ if (rect.width > 0 && rect.right > window.innerWidth + 1) {
849
+ overflowing += 1;
850
+ if (examples.length < 3) {
851
+ const id = el.id ? `#${el.id}` : "";
852
+ const cls =
853
+ !id && el.classList.length ? `.${el.classList[0]}` : "";
854
+ examples.push(`${el.tagName.toLowerCase()}${id}${cls}`);
855
+ }
856
+ if (overflowing >= 200) break;
857
+ }
858
+ }
859
+ }
860
+ return { overflowPx, overflowingElements: overflowing, examples };
861
+ });
862
+ byWidth.push({
863
+ width,
864
+ ...r,
865
+ verdict: r.overflowPx > 0 ? "ALERTE" : "OK",
866
+ });
867
+ }
868
+ measured.responsive = {
869
+ verdict: verdictGlobal(byWidth.map((l) => l.verdict)),
870
+ byWidth,
871
+ };
872
+ }
873
+
874
+ // ── Sortie ──────────────────────────────────────────────────────────────────
875
+ const verdicts = [...active]
876
+ .map((f) => measured[f]?.verdict)
877
+ .filter((v) => typeof v === "string");
878
+ console.log(
879
+ JSON.stringify(
880
+ {
881
+ url: page.url(),
882
+ // Le navigateur qui a produit ces chiffres — un Chrome de système et le
883
+ // Chromium du pilote n'ont pas la même version.
884
+ browserName: decor.browserUsed,
885
+ ...measured,
886
+ consoleErrors,
887
+ uncaughtErrors,
888
+ // Le chemin RENDU est celui où l'appelant trouvera l'image. Dans un
889
+ // conteneur, `/output` est un volume monté et ne veut rien dire au
890
+ // dehors : on le retraduit en son point de montage habituel. En local,
891
+ // le chemin est déjà le bon.
892
+ capture:
893
+ OUTPUT === "/output" ? shot.replace("/output", "tmp/browser") : shot,
894
+ // Le verdict agrège les FAMILLES active — les erreurs de console et les
895
+ // violations CSP restent des données : un parcours de connexion produit
896
+ // des 401 légitimes, et trancher ici les ferait passer pour des pannes.
897
+ ...(verdicts.length > 0 ? { verdict: verdictGlobal(verdicts) } : {}),
898
+ },
899
+ null,
900
+ 2,
901
+ ),
902
+ );
903
+ await browser.close();