@nodefony/devkit 10.0.0-beta.2 → 10.0.0-beta.3

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 (36) hide show
  1. package/README.md +5 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateParam.js +1 -1
  5. package/dist/index.js +2 -2
  6. package/dist/nodefony/controllers/DevkitController.js +2 -2
  7. package/dist/nodefony/controllers/McpController.js +71 -6
  8. package/dist/nodefony/service/DevkitService.js +2 -2
  9. package/dist/nodefony/src/card.js +1 -2
  10. package/dist/types/nodefony/controllers/McpController.d.ts +12 -1
  11. package/docs/index.md +62 -14
  12. package/package.json +9 -9
  13. package/skills/nodefony-add-realtime-channel/SKILL.md +66 -9
  14. package/skills/nodefony-add-service/SKILL.md +11 -0
  15. package/skills/nodefony-authentication/SKILL.md +234 -0
  16. package/skills/nodefony-browser/SKILL.md +45 -21
  17. package/skills/nodefony-browser/references/sondes.md +13 -0
  18. package/skills/nodefony-browser/scripts/audit.mjs +59 -10
  19. package/skills/nodefony-browser/scripts/inspect.mjs +122 -4
  20. package/skills/nodefony-browser/scripts/lib/browser.mjs +112 -6
  21. package/skills/nodefony-browser/scripts/lib/probes.mjs +166 -7
  22. package/skills/nodefony-dev/SKILL.md +47 -81
  23. package/skills/nodefony-dev/scripts/docs.mjs +21 -5
  24. package/skills/nodefony-devops/SKILL.md +16 -13
  25. package/skills/nodefony-devops/references/compose.md +8 -0
  26. package/skills/nodefony-devops/references/frontal.md +5 -1
  27. package/skills/nodefony-devops/references/secrets.md +18 -4
  28. package/skills/nodefony-devops/references/variables.md +52 -19
  29. package/skills/nodefony-migrate-schema/SKILL.md +5 -2
  30. package/skills/nodefony-migrate-schema/references/verdicts.md +20 -19
  31. package/skills/nodefony-protect-route/SKILL.md +118 -17
  32. package/skills/nodefony-security-review/SKILL.md +127 -0
  33. package/skills/nodefony-security-review/scripts/review-diff.mjs +1090 -0
  34. package/skills/nodefony-troubleshoot/SKILL.md +151 -0
  35. package/skills/nodefony-troubleshoot/scripts/log-errors.mjs +465 -0
  36. package/skills/nodefony-upgrade/SKILL.md +130 -0
@@ -24,11 +24,13 @@
24
24
  * `@env` NF_BROWSER_PASSWORD mot de passe associé
25
25
  * `@env` NF_BROWSER_ACTIONS séquence d'interactions AVANT mesure, séparées par `|` —
26
26
  * `verbe:cible[=valeur]` : clic (défaut), double, droit, survol,
27
- * saisir, touche, voir, defiler, attendre. Un écran qui se déplie
27
+ * saisir, choisir, touche, voir, defiler, attendre. Un écran qui se déplie
28
28
  * au clic n'existe pas tant qu'on ne l'a pas ouvert.
29
29
  * `@env` NF_BROWSER_FULLPAGE 1 = capture la page ENTIÈRE (défaut : la fenêtre)
30
30
  * `@env` NF_BROWSER_PROBES sélecteurs CSS à sonder, séparés par des virgules (`libellé=sélecteur`)
31
31
  * `@env` NF_BROWSER_WIDTHS largeurs de la famille responsive (défaut 360,768,1280)
32
+ * `@env` NF_BROWSER_VIEWPORT écran mesuré : mobile, mobile-court, tablette, bureau (défaut), large, tres-large, ou LARGEURxHAUTEUR — sous 600 px, émulé en téléphone (tactile, densité 2)
33
+ * `@env` NF_BROWSER_PRIMARY sélecteur de l'action principale pour la famille pli (défaut : le plus grand bouton de validation visible)
32
34
  * `@env` NF_BROWSER_SEUIL_LOURD bytes au-delà desquels une ressource est « lourde » (défaut 512000)
33
35
  * `@env` NF_BROWSER_SEUIL_LENT millisecondes au-delà desquelles une réponse est « lente » (défaut 1000)
34
36
  * `@requires` conteneur du profil `browser` démarré · serveur joignable depuis le conteneur
@@ -42,6 +44,7 @@ import { open, goTo, LOGIN, OUTPUT } from "./lib/browser.mjs";
42
44
  import { sourceWcag } from "./lib/wcag.mjs";
43
45
  import {
44
46
  FAMILIES,
47
+ captureSlug,
45
48
  parseActions,
46
49
  parseFamilies,
47
50
  parseProbes,
@@ -72,6 +75,7 @@ const EXPECT = process.argv[3] ?? process.env.NF_BROWSER_EXPECT ?? "";
72
75
  * | `droit` | clic droit — ouvre un menu contextuel applicatif |
73
76
  * | `survol` | survol : révèle une infobulle, un menu déroulant |
74
77
  * | `saisir` | remplit un champ (`saisir:Nom=mon-app`) |
78
+ * | `choisir` | choisit une option d'une liste (`choisir:Habillage=dots`) |
75
79
  * | `touche` | frappe une touche (`touche:Enter`, `touche:Escape`) |
76
80
  * | `voir` | amène dans la vue SANS cliquer |
77
81
  * | `defiler` | fait défiler de N pixels (`defiler:600`), page ou conteneur |
@@ -285,7 +289,28 @@ for (const { verb, target, value } of ACTIONS) {
285
289
  break;
286
290
  }
287
291
  }
288
- locator ??= page.locator(target).first();
292
+ // Aucun candidat visible À CET INSTANT ne dit pas que la cible est un
293
+ // sélecteur : juste après un clic qui navigue, la page suivante n'est pas
294
+ // encore rendue. On attend donc le texte OU le sélecteur — et le texte seul
295
+ // quand la cible n'est pas du CSS valide. Trancher sur l'instant faisait
296
+ // chercher une balise `<FreeOTP>` une fois sur trois.
297
+ if (locator === null) {
298
+ // Une navigation en vol détruirait le contexte de l'évaluation ci-dessous.
299
+ await page.waitForLoadState("domcontentloaded").catch(() => {});
300
+ const isCss = await page
301
+ .evaluate((s) => {
302
+ try {
303
+ document.querySelector(s);
304
+ return true;
305
+ } catch {
306
+ return false;
307
+ }
308
+ }, target)
309
+ .catch(() => false);
310
+ locator = isCss
311
+ ? candidates.or(page.locator(target)).first()
312
+ : candidates.first();
313
+ }
289
314
  try {
290
315
  await locator.waitFor({ timeout: 15000 });
291
316
  if (verb === "clic") await locator.click();
@@ -293,7 +318,13 @@ for (const { verb, target, value } of ACTIONS) {
293
318
  else if (verb === "droit") await locator.click({ button: "right" });
294
319
  else if (verb === "survol") await locator.hover();
295
320
  else if (verb === "saisir") await locator.fill(value);
296
- else if (verb === "touche") await locator.press(value || "Enter");
321
+ // `choisir` : la cible peut être le `<label>` qui ENVELOPPE la liste —
322
+ // Playwright reporte alors le choix sur son contrôle. La valeur est celle
323
+ // de l'option (`value`), sinon son libellé.
324
+ else if (verb === "choisir") {
325
+ const chosen = await locator.selectOption(value);
326
+ if (chosen.length === 0) throw new Error(`option « ${value} » absente`);
327
+ } else if (verb === "touche") await locator.press(value || "Enter");
297
328
  else if (verb === "voir") await locator.scrollIntoViewIfNeeded();
298
329
  // `attendre` : le `waitFor` ci-dessus EST l'action.
299
330
  } catch {
@@ -835,8 +866,93 @@ if (active.has("stockage")) {
835
866
  delete measured.stockageWeb;
836
867
  }
837
868
 
869
+ // ── Famille pli — les proportions, sur l'écran DEMANDÉ ──────────────────────
870
+ // Deux questions qu'aucune autre famille ne pose : faut-il défiler pour AGIR
871
+ // (l'action principale est-elle au-dessus du bas de l'écran ?), et quelle place
872
+ // le bloc qui la porte prend-il dans l'écran ? Le défilement seul ne tranche
873
+ // pas : un pied de page sous le pli est normal, un bouton sous le pli ne l'est
874
+ // pas. Mesurée AVANT la famille responsive, qui redimensionne la fenêtre.
875
+ if (active.has("pli")) {
876
+ const primarySelector = process.env.NF_BROWSER_PRIMARY ?? "";
877
+ measured.pli = await page.evaluate((selector) => {
878
+ const visible = (el) => {
879
+ const r = el.getBoundingClientRect();
880
+ const st = getComputedStyle(el);
881
+ return r.width > 0 && r.height > 0 && st.visibility !== "hidden";
882
+ };
883
+ let primary = null;
884
+ if (selector) {
885
+ primary = [...document.querySelectorAll(selector)].find(visible) ?? null;
886
+ } else {
887
+ // Le plus GRAND bouton de validation visible : un bouton « retour » est
888
+ // aussi de type submit, mais jamais le plus large.
889
+ let best = 0;
890
+ for (const el of document.querySelectorAll(
891
+ 'button[type="submit"], input[type="submit"], button:not([type])',
892
+ )) {
893
+ if (!visible(el)) continue;
894
+ const r = el.getBoundingClientRect();
895
+ if (r.width * r.height > best) {
896
+ best = r.width * r.height;
897
+ primary = el;
898
+ }
899
+ }
900
+ }
901
+ const doc = document.scrollingElement ?? document.documentElement;
902
+ const view = { width: innerWidth, height: innerHeight };
903
+ const out = {
904
+ view: `${view.width}×${view.height}`,
905
+ pageHeight: doc.scrollHeight,
906
+ scrollPx: Math.max(0, doc.scrollHeight - view.height),
907
+ overflowXPx: Math.max(0, doc.scrollWidth - view.width),
908
+ };
909
+ if (primary === null) {
910
+ return {
911
+ ...out,
912
+ verdict: "ALERTE",
913
+ reason: "aucune action principale trouvée (NF_BROWSER_PRIMARY)",
914
+ };
915
+ }
916
+ const r = primary.getBoundingClientRect();
917
+ const bottom = Math.round(r.bottom + scrollY);
918
+ // Le BLOC principal : le plus grand ancêtre de l'action qui reste plus
919
+ // étroit que l'écran — une carte, une colonne de formulaire.
920
+ let frame = null;
921
+ for (
922
+ let el = primary.parentElement;
923
+ el && el !== document.body;
924
+ el = el.parentElement
925
+ ) {
926
+ const fr = el.getBoundingClientRect();
927
+ if (fr.width < view.width * 0.96) frame = el;
928
+ }
929
+ const fr = frame?.getBoundingClientRect();
930
+ const label = (primary.innerText || primary.value || "")
931
+ .trim()
932
+ .slice(0, 40);
933
+ const marginPx = view.height - bottom;
934
+ return {
935
+ ...out,
936
+ primary: { label, bottom, marginPx },
937
+ frame: fr
938
+ ? {
939
+ size: `${Math.round(fr.width)}×${Math.round(fr.height)}`,
940
+ widthShare: Math.round((fr.width / view.width) * 100),
941
+ heightShare: Math.round((fr.height / view.height) * 100),
942
+ }
943
+ : null,
944
+ verdict: marginPx < 0 || out.overflowXPx > 0 ? "ALERTE" : "OK",
945
+ ...(marginPx < 0
946
+ ? { reason: `défiler ${-marginPx} px pour atteindre « ${label} »` }
947
+ : out.overflowXPx > 0
948
+ ? { reason: `la page déborde de ${out.overflowXPx} px en largeur` }
949
+ : {}),
950
+ };
951
+ }, primarySelector);
952
+ }
953
+
838
954
  // ── Capture — AVANT la famille responsive, qui déforme le viewport ──────────
839
- const slug = PAGE.replace(/\//g, "-").replace(/^-/, "") || "racine";
955
+ const slug = captureSlug(PAGE);
840
956
  const shot = path.join(OUTPUT, `${slug}-${stamp}.png`);
841
957
  // `NF_BROWSER_FULLPAGE=1` : la page ENTIÈRE, pas la fenêtre. Un formulaire long
842
958
  // (ou une page qu'on vient de déplier) a l'essentiel SOUS la ligne de flottaison
@@ -905,6 +1021,8 @@ console.log(
905
1021
  // Le navigateur qui a produit ces chiffres — un Chrome de système et le
906
1022
  // Chromium du pilote n'ont pas la même version.
907
1023
  browserName: decor.browserUsed,
1024
+ // L'écran mesuré : une proportion sans son écran ne conclut rien.
1025
+ viewport: `${decor.VIEWPORT.name}${decor.VIEWPORT.mobile ? " (téléphone)" : ""}`,
908
1026
  ...measured,
909
1027
  consoleErrors,
910
1028
  uncaughtErrors,
@@ -12,6 +12,7 @@
12
12
  * `@env` NF_BROWSER_LOGIN chemin du formulaire de connexion — REQUIS dès qu'un identifiant est donné, aucun défaut n'est deviné
13
13
  * `@env` NF_BROWSER_USER identifiant ; sans lui, aucune authentification n'est tentée
14
14
  * `@env` NF_BROWSER_PASSWORD mot de passe associé
15
+ * `@env` NF_BROWSER_WHOAMI chemin qui répond 2xx à une session authentifiée, et 401/403 ou un 2xx portant `{ user: null }` sinon — prouve une session reprise (défaut `/nodefony/security/api/auth/me` ; vide = aucune reprise)
15
16
  * `@env` NF_BROWSER_ENGINE navigateur imposé (chromium, chrome, msedge) ; sans lui, le premier qui répond
16
17
  * `@env` NF_BROWSER_COLOR_SCHEME schéma de couleurs émulé (light, dark, no-preference) ; sans lui, celui du navigateur
17
18
  * `@env` NF_BROWSER_STORAGE entrées de stockage local posées AVANT chargement (`clé=valeur`, séparées par des virgules)
@@ -22,8 +23,10 @@ import {
22
23
  environmentDefaults,
23
24
  authStateName,
24
25
  browserOrder,
26
+ sessionVerdict,
25
27
  parseColorScheme,
26
28
  parseStorage,
29
+ parseViewport,
27
30
  } from "./probes.mjs";
28
31
 
29
32
  /**
@@ -61,12 +64,38 @@ try {
61
64
  * en croyant que l'application est en panne.
62
65
  */
63
66
  const IN_CONTAINER = existsSync("/.dockerenv");
67
+ /**
68
+ * La racine de l'application, pour ancrer `tmp/browser/` — par le sous-chemin
69
+ * léger du cœur (`nodefony/project-root`), et SEULEMENT hors conteneur :
70
+ * là-bas les sondes sont copiées seules, sans `nodefony`, et la sortie est le
71
+ * volume `/output`. Paquet introuvable → `null`, sortie relative au dossier
72
+ * courant.
73
+ */
74
+ const APP_ROOT = IN_CONTAINER
75
+ ? null
76
+ : await import("nodefony/project-root").then(
77
+ (m) => m.findProjectRoot(process.cwd()),
78
+ () => null,
79
+ );
64
80
  const { base: baseUrl, out: OUT } = environmentDefaults({
65
81
  inContainer: IN_CONTAINER,
82
+ root: APP_ROOT,
66
83
  base: process.env.NF_BROWSER_BASE,
67
84
  out: process.env.NF_BROWSER_OUT,
68
85
  });
69
86
 
87
+ /**
88
+ * L'écran mesuré (`NF_BROWSER_VIEWPORT`) — `bureau` (1 440 × 900) par défaut.
89
+ * Un nom inconnu est REFUSÉ (code 64) : mesurer sur un autre écran que celui
90
+ * demandé ferait conclure sur une page qu'on n'a pas regardée.
91
+ */
92
+ const viewport = parseViewport(process.env.NF_BROWSER_VIEWPORT);
93
+ if ("error" in viewport) {
94
+ console.error(`NF_BROWSER_VIEWPORT : ${viewport.error}`);
95
+ process.exit(64); // EX_USAGE
96
+ }
97
+ export const VIEWPORT = viewport;
98
+
70
99
  /** Où atterrissent captures et état d'authentification. */
71
100
  export const OUTPUT = OUT;
72
101
  export const BASE = baseUrl;
@@ -103,6 +132,15 @@ if (USER && !LOGIN) {
103
132
  process.exit(64); // EX_USAGE
104
133
  }
105
134
 
135
+ /**
136
+ * Le point qui PROUVE qu'une session reprise est vivante : 2xx avec un `user`,
137
+ * `{ user: null }` (ou 401/403) sinon. Défaut : celui de `@nodefony/security`, présent dans toute
138
+ * application qui l'installe. Chaîne VIDE = rien pour prouver, donc aucune
139
+ * session n'est reprise — la sonde se connecte à chaque lancement.
140
+ */
141
+ export const WHOAMI =
142
+ process.env.NF_BROWSER_WHOAMI ?? "/nodefony/security/api/auth/me";
143
+
106
144
  /**
107
145
  * L'état d'authentification, SAUVEGARDÉ puis réutilisé d'une sonde à l'autre.
108
146
  *
@@ -111,12 +149,15 @@ if (USER && !LOGIN) {
111
149
  * Sans lui, chaque inspection rejoue le parcours de connexion — quelques
112
150
  * secondes perdues et une occasion d'échec de plus à chaque exécution.
113
151
  *
114
- * Son nom porte l'IDENTIFIANT (cf {@link authStateName}) : un état est la session
152
+ * Son nom porte l'IDENTIFIANT et l'ORIGINE (cf {@link authStateName}) : un état est la session
115
153
  * de quelqu'un, et le réutiliser pour un autre compte fait mesurer une identité
116
154
  * qu'on n'a pas demandée. Effet de bord bienvenu — deux comptes gardent chacun
117
155
  * leur session, donc aucun des deux ne se reconnecte à cause de l'autre.
118
156
  */
119
- const STATE = path.join(OUT, authStateName(process.env.NF_BROWSER_USER));
157
+ const STATE = path.join(
158
+ OUT,
159
+ authStateName(process.env.NF_BROWSER_USER, baseUrl),
160
+ );
120
161
  // Créé AVANT la première écriture : en local, le dossier n'existe pas encore,
121
162
  // et l'échec ne surviendrait qu'à la sauvegarde — après la connexion, donc
122
163
  // après avoir fait croire que tout allait bien.
@@ -141,6 +182,8 @@ mkdirSync(OUT, { recursive: true });
141
182
  const { schema: COLOR_SCHEME, invalid: invalidScheme } = parseColorScheme(
142
183
  process.env.NF_BROWSER_COLOR_SCHEME,
143
184
  );
185
+ /** Schéma de couleurs demandé, `null` sans demande — l'audit le pose aussi au moteur. */
186
+ export { COLOR_SCHEME };
144
187
  if (invalidScheme) {
145
188
  console.error(
146
189
  `NF_BROWSER_COLOR_SCHEME inconnu : « ${invalidScheme} ».\n` +
@@ -201,7 +244,13 @@ export async function open() {
201
244
  }
202
245
  const options = {
203
246
  ignoreHTTPSErrors: true, // certificat de développement auto-signé
204
- viewport: { width: 1440, height: 900 },
247
+ viewport: { width: VIEWPORT.width, height: VIEWPORT.height },
248
+ // Un écran de téléphone se dit TACTILE et dense : des pages en dépendent
249
+ // (`pointer: coarse`, `hover: none`), et une fenêtre étroite seule ne
250
+ // l'annoncerait pas.
251
+ ...(VIEWPORT.mobile
252
+ ? { isMobile: true, hasTouch: true, deviceScaleFactor: 2 }
253
+ : {}),
205
254
  ...(COLOR_SCHEME ? { colorScheme: COLOR_SCHEME } : {}),
206
255
  };
207
256
  let reuse = Boolean(USER) && existsSync(STATE);
@@ -220,6 +269,22 @@ export async function open() {
220
269
  reuse = false;
221
270
  }
222
271
  }
272
+ if (reuse && ctx) {
273
+ const why = await staleSessionReason(ctx);
274
+ if (why !== null) {
275
+ // Un contexte NEUF plutôt que des cookies effacés : le stockage local
276
+ // peut avoir mémorisé un identifiant, et le jeton anti-CSRF est lié à la
277
+ // session morte. La reprise se DIT — rejouée en silence, elle cacherait
278
+ // qu'il y avait quelque chose à rejouer.
279
+ console.error(
280
+ `Session reprise ${why} — l'état est jeté et le parcours de connexion est rejoué.`,
281
+ );
282
+ await ctx.close();
283
+ rmSync(STATE, { force: true });
284
+ ctx = null;
285
+ reuse = false;
286
+ }
287
+ }
223
288
  if (!ctx) ctx = await browser.newContext(options);
224
289
  if (STORAGE.length > 0) {
225
290
  // AVANT tout script de la page, et à CHAQUE navigation : une application
@@ -231,8 +296,7 @@ export async function open() {
231
296
  // prime sur ce qu'une session précédente avait laissé.
232
297
  await ctx.addInitScript((entries) => {
233
298
  try {
234
- for (const { cle: key, valeur: value } of entries)
235
- localStorage.setItem(key, value);
299
+ for (const { key, value } of entries) localStorage.setItem(key, value);
236
300
  } catch {
237
301
  // Stockage refusé (mode privé, origine opaque) : la sonde continue —
238
302
  // le thème sera celui par défaut, et la mesure le DIRA (champ `theme`).
@@ -242,6 +306,39 @@ export async function open() {
242
306
  return { browser, ctx, page: await ctx.newPage(), reuse };
243
307
  }
244
308
 
309
+ /**
310
+ * Prouve qu'une session reprise est vivante, AVANT toute navigation.
311
+ *
312
+ * Sans cette preuve, la sonde ne constatait une session morte que si la page
313
+ * renvoyait vers le formulaire de connexion — ce qu'une page PUBLIQUE ne fait
314
+ * jamais : elle mesurait alors en anonyme, sans le dire.
315
+ *
316
+ * @param ctx - le contexte ouvert sur l'état repris (ses cookies servent la requête).
317
+ * @returns `null` si la session est prouvée vivante, sinon la raison de la jeter.
318
+ */
319
+ async function staleSessionReason(ctx) {
320
+ if (WHOAMI === "") return "non prouvable (NF_BROWSER_WHOAMI vide)";
321
+ let status;
322
+ let body;
323
+ try {
324
+ const answer = await ctx.request.get(`${BASE}${WHOAMI}`, {
325
+ failOnStatusCode: false,
326
+ maxRedirects: 0,
327
+ timeout: 10000,
328
+ });
329
+ status = answer.status();
330
+ // Le corps dit l'anonymat quand le statut ne le dit pas (`{ user: null }`).
331
+ body = await answer.json().catch(() => undefined);
332
+ } catch (e) {
333
+ return `non prouvable (${WHOAMI} injoignable : ${String(e).split("\n")[0].slice(0, 120)})`;
334
+ }
335
+ const verdict = sessionVerdict(status, body);
336
+ if (verdict === "alive") return null;
337
+ return verdict === "dead"
338
+ ? `PÉRIMÉE (${WHOAMI} → ${status}${status < 300 ? ", user: null" : ""})`
339
+ : `non prouvable (${WHOAMI} → ${status}, attendu 2xx, ou 401/403 — NF_BROWSER_WHOAMI)`;
340
+ }
341
+
245
342
  /**
246
343
  * Connexion par le formulaire, en deux temps (identifiant, puis mot de passe).
247
344
  *
@@ -301,7 +398,16 @@ export async function signIn(page, ctx) {
301
398
  }
302
399
  await pw.fill(PASSWORD, { timeout: 15000 });
303
400
  await pw.press("Enter");
304
- await page.waitForURL((u) => !u.pathname.endsWith(LOGIN), { timeout: 20000 });
401
+ // Connexion aboutie = on a quitté l'écran de connexion, OU le formulaire a
402
+ // disparu. Le second cas est celui d'un fournisseur d'identité (OpenID
403
+ // Connect) : la page demandée renvoie chez lui, et il RAMÈNE sur cette même
404
+ // page — l'URL finit donc comme elle a commencé, et attendre de la quitter
405
+ // expirait sur une connexion réussie. Un mot de passe refusé laisse le champ
406
+ // en place : les deux attentes expirent, comme avant.
407
+ await Promise.any([
408
+ page.waitForURL((u) => !u.pathname.endsWith(LOGIN), { timeout: 20000 }),
409
+ pw.waitFor({ state: "detached", timeout: 20000 }),
410
+ ]);
305
411
  await ctx.storageState({ path: STATE });
306
412
  }
307
413
 
@@ -8,6 +8,7 @@
8
8
  * FAUSSE en silence — exactement la classe de bug qu'une sonde ne peut pas se
9
9
  * permettre.
10
10
  */
11
+ import path from "node:path";
11
12
  import { createHash } from "node:crypto";
12
13
 
13
14
  /**
@@ -25,8 +26,67 @@ export const FAMILIES = Object.freeze({
25
26
  perf: "temps de rendu — TTFB, FCP, LCP, CLS, tâches longues",
26
27
  stockage: "cookies (attributs, jamais les valeurs) et Web Storage",
27
28
  responsive: "débordement horizontal à plusieurs largeurs d'écran",
29
+ pli: "proportions — l'action principale visible sans défiler, la place du bloc principal dans l'écran",
28
30
  });
29
31
 
32
+ /**
33
+ * Écrans nommés pour `NF_BROWSER_VIEWPORT` — les tailles qu'on vérifie le plus
34
+ * souvent, pour ne pas les réécrire (et les mal recopier) à chaque mesure.
35
+ * `mobile-court` est l'écran qui révèle un formulaire trop haut : un téléphone
36
+ * de 667 px de haut, encore courant.
37
+ */
38
+ export const VIEWPORT_PRESETS = Object.freeze({
39
+ mobile: { width: 390, height: 844 },
40
+ "mobile-court": { width: 375, height: 667 },
41
+ tablette: { width: 820, height: 1180 },
42
+ bureau: { width: 1440, height: 900 },
43
+ large: { width: 1920, height: 1080 },
44
+ "tres-large": { width: 2560, height: 1440 },
45
+ });
46
+
47
+ /** En deçà de cette largeur, l'écran est émulé comme un TÉLÉPHONE (tactile, densité 2). */
48
+ export const MOBILE_MAX_WIDTH = 600;
49
+
50
+ /**
51
+ * Analyse l'écran demandé (`NF_BROWSER_VIEWPORT`) : un nom de
52
+ * {@link VIEWPORT_PRESETS} ou `LARGEURxHAUTEUR`. Absent = `bureau`, l'écran
53
+ * historique de la sonde.
54
+ *
55
+ * Un écran étroit n'est pas qu'une fenêtre réduite : un téléphone annonce un
56
+ * écran tactile et une densité de pixels, et certaines pages en dépendent
57
+ * (`@media (pointer: coarse)`, `hover`). Sous {@link MOBILE_MAX_WIDTH}, la sonde
58
+ * l'émule donc comme un téléphone.
59
+ *
60
+ * @param {string|undefined} raw - `mobile`, `390x844`…
61
+ * @returns {{ name: string, width: number, height: number, mobile: boolean } | { error: string }}
62
+ */
63
+ export function parseViewport(raw) {
64
+ const value = (raw ?? "").trim().toLowerCase();
65
+ const named = Object.hasOwn(VIEWPORT_PRESETS, value || "bureau")
66
+ ? VIEWPORT_PRESETS[value || "bureau"]
67
+ : null;
68
+ let size = named;
69
+ if (size === null) {
70
+ const m = /^(\d{3,4})x(\d{3,4})$/.exec(value);
71
+ const width = m ? Number(m[1]) : NaN;
72
+ const height = m ? Number(m[2]) : NaN;
73
+ if (!(width >= 240 && width <= 4000 && height >= 240 && height <= 4000)) {
74
+ return {
75
+ error:
76
+ `écran « ${raw} » inconnu : ${Object.keys(VIEWPORT_PRESETS).join(", ")}, ` +
77
+ "ou LARGEURxHAUTEUR (240 à 4000 px)",
78
+ };
79
+ }
80
+ size = { width, height };
81
+ }
82
+ return {
83
+ name: named ? value || "bureau" : `${size.width}x${size.height}`,
84
+ width: size.width,
85
+ height: size.height,
86
+ mobile: size.width < MOBILE_MAX_WIDTH,
87
+ };
88
+ }
89
+
30
90
  /**
31
91
  * Analyse la liste de familles demandée (`NF_BROWSER_FAMILIES`).
32
92
  *
@@ -85,7 +145,7 @@ export function parseProbes(raw) {
85
145
  }
86
146
 
87
147
  /** Verbes qui prennent une VALEUR après la cible. Les autres n'en ont aucune. */
88
- const VERBS_WITH_VALUE = new Set(["saisir"]);
148
+ const VERBS_WITH_VALUE = new Set(["saisir", "choisir"]);
89
149
 
90
150
  /** Verbes reconnus d'une action, le premier étant celui par défaut. */
91
151
  export const ACTION_VERBS = [
@@ -94,6 +154,7 @@ export const ACTION_VERBS = [
94
154
  "droit",
95
155
  "survol",
96
156
  "saisir",
157
+ "choisir",
97
158
  "touche",
98
159
  "voir",
99
160
  "defiler",
@@ -113,7 +174,8 @@ export const ACTION_VERBS = [
113
174
  * s'arrêtait en annonçant un élément introuvable, ce qui envoyait chercher un
114
175
  * défaut dans la page. D'où deux règles :
115
176
  *
116
- * 1. seuls les verbes qui PRENNENT une valeur (`saisir`) découpent sur `=` ;
177
+ * 1. seuls les verbes qui PRENNENT une valeur (`saisir`, `choisir`) découpent
178
+ * sur `=` ;
117
179
  * pour tous les autres, la cible est le reste entier ;
118
180
  * 2. même pour ceux-là, la coupure ignore les `=` situés à l'intérieur de
119
181
  * crochets — `saisir:input[name=q]=bonjour` saisit bien « bonjour » dans
@@ -178,6 +240,24 @@ export function parseColorScheme(raw) {
178
240
  return { schema: null, invalid: v };
179
241
  }
180
242
 
243
+ /**
244
+ * Arguments de lancement qui imposent le schéma de couleurs AU MOTEUR.
245
+ *
246
+ * L'émulation du pilote (`colorScheme` d'un contexte) ne vaut que pour les
247
+ * pages qu'il ouvre lui-même. Lighthouse ouvre son onglet par le protocole de
248
+ * débogage : sans ce réglage, l'audit mesure le thème du SYSTÈME, sans le dire.
249
+ * Blink connaît deux valeurs (`0` sombre, `1` clair) — `no-preference` n'en a
250
+ * pas, et ne pose donc rien.
251
+ *
252
+ * @param {string|null} schema - schéma rendu par `parseColorScheme`.
253
+ * @returns {string[]} arguments à ajouter au lancement du navigateur.
254
+ */
255
+ export function colorSchemeLaunchArgs(schema) {
256
+ if (schema === "dark") return ["--blink-settings=preferredColorScheme=0"];
257
+ if (schema === "light") return ["--blink-settings=preferredColorScheme=1"];
258
+ return [];
259
+ }
260
+
181
261
  /**
182
262
  * Analyse les entrées de stockage à poser avant chargement (`NF_BROWSER_STORAGE`).
183
263
  *
@@ -224,21 +304,64 @@ export function parseStorage(raw) {
224
304
  * évite de faire dire à `process.platform` une chose qu'il ne sait pas. Une
225
305
  * capacité se constate ; c'est l'appelant qui constate, cette fonction décide.
226
306
  *
227
- * @param {{inContainer: boolean, base?: string, out?: string}} stage -
307
+ * Le dossier de sortie local s'ancre à la RACINE de l'application (`root`,
308
+ * constatée par l'appelant) : relatif au dossier courant, il semait un
309
+ * `tmp/browser/` dans chaque sous-dossier d'où l'on lançait la sonde.
310
+ *
311
+ * @param {{inContainer: boolean, base?: string, out?: string, root?: string | null}} stage -
228
312
  * le constat, et les valeurs explicites qui l'emportent toujours.
229
313
  * @returns {{ base: string, out: string }} origine à joindre, dossier de sortie.
230
314
  */
231
- export function environmentDefaults({ inContainer, base, out } = {}) {
315
+ export function environmentDefaults({ inContainer, base, out, root } = {}) {
232
316
  return {
233
317
  base:
234
318
  base ||
235
319
  (inContainer
236
320
  ? "https://host.docker.internal:5152"
237
321
  : "https://127.0.0.1:5152"),
238
- out: out || (inContainer ? "/output" : "tmp/browser"),
322
+ out:
323
+ out ||
324
+ (inContainer
325
+ ? "/output"
326
+ : root
327
+ ? path.join(root, "tmp", "browser")
328
+ : "tmp/browser"),
239
329
  };
240
330
  }
241
331
 
332
+ /**
333
+ * Verdict sur une session REPRISE, d'après le statut du point « qui suis-je ».
334
+ *
335
+ * Une session se PROUVE, elle ne se déduit pas : l'absence de renvoi vers le
336
+ * formulaire de connexion ne dit rien sur une page publique, qui se sert aussi
337
+ * bien à un anonyme. Vécu — après un redémarrage du serveur, la sonde mesurait
338
+ * une page en anonyme, sans un mot, avec un état de session mort.
339
+ *
340
+ * Un point « qui suis-je » peut répondre `200` à un anonyme en le DISANT dans
341
+ * son corps (`{ user: null }`) — c'est le contrat des bibliothèques qui livrent
342
+ * aussi le client de connexion. Juger au seul statut ferait alors reprendre une
343
+ * session morte, et mesurer en anonyme sans le dire.
344
+ *
345
+ * @param {number} status - statut HTTP rendu par `NF_BROWSER_WHOAMI`.
346
+ * @param {unknown} [body] - corps JSON de la réponse, s'il a pu être lu.
347
+ * @returns {"alive"|"dead"|"unproven"} `alive` (2xx sans `user: null`), `dead`
348
+ * (401/403, ou 2xx dont le corps porte `user: null`), sinon `unproven` — le
349
+ * point ne répond pas comme attendu, et une session qu'on ne sait pas
350
+ * prouver ne se réutilise pas.
351
+ */
352
+ export function sessionVerdict(status, body) {
353
+ if (status >= 200 && status < 300) {
354
+ const anonymous =
355
+ body !== null &&
356
+ typeof body === "object" &&
357
+ Object.hasOwn(body, "user") &&
358
+ body.user === null;
359
+ return anonymous ? "dead" : "alive";
360
+ }
361
+ if (status === 401 || status === 403) return "dead";
362
+ return "unproven";
363
+ }
364
+
242
365
  /**
243
366
  * Nom du fichier d'état d'authentification, DÉRIVÉ de l'identifiant.
244
367
  *
@@ -255,20 +378,56 @@ export function environmentDefaults({ inContainer, base, out } = {}) {
255
378
  * fragment (`a@b` et `a-b`) — et une collision de nom rouvrirait exactement le
256
379
  * trou qu'on ferme.
257
380
  *
381
+ * L'ORIGINE entre dans l'empreinte : le même identifiant sur deux
382
+ * applications n'est pas la même session. Vécu — `admin` de la console
383
+ * d'administration (port 5152) et `admin` de Keycloak (port 8444) partageaient
384
+ * un fichier : la sonde reprenait l'un sur l'autre, retombait sur l'écran de
385
+ * connexion et le mesurait à la place de la page demandée.
386
+ *
258
387
  * @param {string|undefined} login - l'identifiant de connexion demandé.
388
+ * @param {string} [origin] - l'origine visée (`NF_BROWSER_BASE`).
259
389
  * @returns {string} le nom de fichier, sans dossier.
260
390
  */
261
- export function authStateName(login) {
391
+ export function authStateName(login, origin = "") {
262
392
  const raw = login ?? "";
263
393
  const readable =
264
394
  raw.replace(/[^A-Za-z0-9._-]/gu, "_").slice(0, 40) || "anonyme";
265
395
  const fingerprint = createHash("sha256")
266
- .update(raw)
396
+ .update(`${origin}\n${raw}`)
267
397
  .digest("hex")
268
398
  .slice(0, 8);
269
399
  return `.auth-state-${readable}-${fingerprint}.json`;
270
400
  }
271
401
 
402
+ /**
403
+ * Nom de fichier d'une capture ou d'un rapport, DÉRIVÉ de la page visée.
404
+ *
405
+ * Le chemin donne un fragment LISIBLE ; la requête et le fragment d'URL n'y
406
+ * entrent jamais tels quels : une page d'autorisation OAuth en porte plusieurs
407
+ * centaines de caractères (`ENAMETOOLONG`), et `?`, `&`, `%` ou `:` sont
408
+ * refusés par certains systèmes de fichiers (Windows). Une requête présente
409
+ * laisse une EMPREINTE, pour que deux variantes d'une même page (`?ui_locales=fr`
410
+ * et `?ui_locales=en`) ne s'écrasent pas.
411
+ *
412
+ * @param {string} page - chemin demandé, requête comprise.
413
+ * @returns {string} le fragment de nom, sans dossier ni extension (≤ 80 caractères).
414
+ */
415
+ export function captureSlug(page) {
416
+ const cut = page.search(/[?#]/u);
417
+ const pathname = cut === -1 ? page : page.slice(0, cut);
418
+ const readable =
419
+ pathname
420
+ .replace(/[^A-Za-z0-9._-]+/gu, "-")
421
+ .replace(/^-+|-+$/gu, "")
422
+ .slice(0, 70) || "racine";
423
+ if (cut === -1) return readable;
424
+ const fingerprint = createHash("sha256")
425
+ .update(page.slice(cut))
426
+ .digest("hex")
427
+ .slice(0, 8);
428
+ return `${readable}-${fingerprint}`;
429
+ }
430
+
272
431
  /**
273
432
  * Analyse les largeurs d'écran de la famille `responsive` (`NF_BROWSER_WIDTHS`).
274
433
  *