@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.
- package/LICENSE +544 -0
- package/README.md +318 -0
- package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateParam.js +8 -0
- package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorateParam.js +8 -0
- package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorateParam.js +8 -0
- package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorateParam.js +8 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateParam.js +8 -0
- package/dist/index.js +47 -0
- package/dist/nodefony/command/CardCommand.js +70 -0
- package/dist/nodefony/config/config.js +200 -0
- package/dist/nodefony/config/defineModuleConfig.js +36 -0
- package/dist/nodefony/controllers/DevkitController.js +60 -0
- package/dist/nodefony/controllers/McpController.js +223 -0
- package/dist/nodefony/controllers/OAuthMetadataController.js +89 -0
- package/dist/nodefony/interfaces/IDevkitService.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/service/DevkitService.js +198 -0
- package/dist/nodefony/src/card.js +2 -0
- package/dist/nodefony/src/errors/DevkitError.js +21 -0
- package/dist/nodefony/src/mcp/guard.js +51 -0
- package/dist/nodefony/src/mcp/protocol.js +127 -0
- package/dist/nodefony/src/mcp/server.js +133 -0
- package/dist/nodefony/src/mcp/tools.js +163 -0
- package/dist/types/index.d.ts +52 -0
- package/dist/types/nodefony/command/CardCommand.d.ts +33 -0
- package/dist/types/nodefony/config/config.d.ts +25 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
- package/dist/types/nodefony/controllers/DevkitController.d.ts +37 -0
- package/dist/types/nodefony/controllers/McpController.d.ts +70 -0
- package/dist/types/nodefony/controllers/OAuthMetadataController.d.ts +39 -0
- package/dist/types/nodefony/interfaces/IDevkitService.d.ts +69 -0
- package/dist/types/nodefony/interfaces/index.d.ts +1 -0
- package/dist/types/nodefony/service/DevkitService.d.ts +143 -0
- package/dist/types/nodefony/src/card.d.ts +18 -0
- package/dist/types/nodefony/src/errors/DevkitError.d.ts +14 -0
- package/dist/types/nodefony/src/mcp/guard.d.ts +66 -0
- package/dist/types/nodefony/src/mcp/protocol.d.ts +139 -0
- package/dist/types/nodefony/src/mcp/server.d.ts +48 -0
- package/dist/types/nodefony/src/mcp/tools.d.ts +81 -0
- package/docs/index.md +358 -0
- package/package.json +77 -0
- package/skills/nodefony-add-crud/SKILL.md +199 -0
- package/skills/nodefony-add-realtime-channel/SKILL.md +95 -0
- package/skills/nodefony-add-service/SKILL.md +90 -0
- package/skills/nodefony-browser/SKILL.md +416 -0
- package/skills/nodefony-browser/references/socket.md +115 -0
- package/skills/nodefony-browser/references/sondes.md +175 -0
- package/skills/nodefony-browser/scripts/audit.mjs +169 -0
- package/skills/nodefony-browser/scripts/inspect.mjs +903 -0
- package/skills/nodefony-browser/scripts/lib/browser.mjs +357 -0
- package/skills/nodefony-browser/scripts/lib/probes.mjs +501 -0
- package/skills/nodefony-browser/scripts/lib/wcag.mjs +153 -0
- package/skills/nodefony-browser/scripts/socket.mjs +354 -0
- package/skills/nodefony-browser/scripts/watch.mjs +125 -0
- package/skills/nodefony-migrate-schema/SKILL.md +359 -0
- package/skills/nodefony-migrate-schema/references/verdicts.md +139 -0
- 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();
|