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

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 (65) hide show
  1. package/README.md +151 -5
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  3. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorateParam.js +1 -2
  4. package/dist/index.js +24 -11
  5. package/dist/nodefony/config/config.js +16 -137
  6. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  7. package/dist/nodefony/controllers/DevkitController.js +7 -14
  8. package/dist/nodefony/controllers/McpController.js +27 -163
  9. package/dist/nodefony/controllers/ProfileSessionController.js +47 -0
  10. package/dist/nodefony/mcp/observe.js +235 -0
  11. package/dist/nodefony/mcp/profileReport.js +242 -0
  12. package/dist/nodefony/mcp/profileRoute.js +521 -0
  13. package/dist/nodefony/mcp/shotClient.js +84 -0
  14. package/dist/nodefony/mcp/shutdown.js +44 -0
  15. package/dist/nodefony/mcp/tools.js +696 -0
  16. package/dist/nodefony/service/DevkitService.js +93 -29
  17. package/dist/nodefony/src/card.js +1 -2
  18. package/dist/nodefony/src/errors/DevkitError.js +1 -2
  19. package/dist/nodefony/src/profileSession.js +14 -0
  20. package/dist/types/index.d.ts +15 -1
  21. package/dist/types/nodefony/config/config.d.ts +1 -1
  22. package/dist/types/nodefony/controllers/McpController.d.ts +13 -17
  23. package/dist/types/nodefony/controllers/ProfileSessionController.d.ts +31 -0
  24. package/dist/types/nodefony/interfaces/IDevkitService.d.ts +22 -1
  25. package/dist/types/nodefony/mcp/observe.d.ts +83 -0
  26. package/dist/types/nodefony/mcp/profileReport.d.ts +138 -0
  27. package/dist/types/nodefony/mcp/profileRoute.d.ts +87 -0
  28. package/dist/types/nodefony/mcp/shotClient.d.ts +53 -0
  29. package/dist/types/nodefony/mcp/shutdown.d.ts +25 -0
  30. package/dist/types/nodefony/mcp/tools.d.ts +109 -0
  31. package/dist/types/nodefony/service/DevkitService.d.ts +41 -4
  32. package/dist/types/nodefony/src/profileSession.d.ts +12 -0
  33. package/docs/index.md +217 -20
  34. package/package.json +17 -12
  35. package/skills/nodefony-add-crud/SKILL.md +5 -0
  36. package/skills/nodefony-add-realtime-channel/SKILL.md +66 -9
  37. package/skills/nodefony-add-service/SKILL.md +14 -3
  38. package/skills/nodefony-authentication/SKILL.md +234 -0
  39. package/skills/nodefony-browser/SKILL.md +65 -21
  40. package/skills/nodefony-browser/references/socket.md +13 -4
  41. package/skills/nodefony-browser/references/sondes.md +13 -0
  42. package/skills/nodefony-browser/scripts/audit.mjs +59 -10
  43. package/skills/nodefony-browser/scripts/inspect.mjs +123 -5
  44. package/skills/nodefony-browser/scripts/lib/browser.mjs +112 -6
  45. package/skills/nodefony-browser/scripts/lib/probes.mjs +219 -7
  46. package/skills/nodefony-browser/scripts/socket.mjs +70 -4
  47. package/skills/nodefony-browser/scripts/watch.mjs +101 -0
  48. package/skills/nodefony-dev/SKILL.md +78 -82
  49. package/skills/nodefony-dev/scripts/docs.mjs +24 -8
  50. package/skills/nodefony-devops/SKILL.md +98 -74
  51. package/skills/nodefony-devops/references/compose.md +8 -0
  52. package/skills/nodefony-devops/references/dimensionnement.md +122 -0
  53. package/skills/nodefony-devops/references/frontal.md +282 -48
  54. package/skills/nodefony-devops/references/kubernetes.md +41 -14
  55. package/skills/nodefony-devops/references/secrets.md +30 -5
  56. package/skills/nodefony-devops/references/variables.md +66 -31
  57. package/skills/nodefony-migrate-schema/SKILL.md +5 -2
  58. package/skills/nodefony-migrate-schema/references/verdicts.md +20 -19
  59. package/skills/nodefony-protect-route/SKILL.md +124 -21
  60. package/skills/nodefony-security-review/SKILL.md +127 -0
  61. package/skills/nodefony-security-review/scripts/review-diff.mjs +1090 -0
  62. package/skills/nodefony-troubleshoot/SKILL.md +225 -0
  63. package/skills/nodefony-troubleshoot/scripts/log-errors.mjs +465 -0
  64. package/skills/nodefony-upgrade/SKILL.md +130 -0
  65. package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
@@ -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,62 @@ export function parseColorScheme(raw) {
178
240
  return { schema: null, invalid: v };
179
241
  }
180
242
 
243
+ /**
244
+ * Analyse une coupure réseau demandée (`NF_BROWSER_CUT`), au format
245
+ * `<début>:<durée>` en millisecondes — `4000:3000` coupe le réseau 4 s après
246
+ * l'ouverture de la page et le rétablit 3 s plus tard.
247
+ *
248
+ * La coupure existe pour éprouver une REPRISE : un flux temps réel qui revient
249
+ * après une perte de réseau ne se constate sur aucune capture. Une valeur
250
+ * malformée est REFUSÉE : ignorée, elle ferait observer une page jamais coupée
251
+ * en croyant avoir éprouvé sa reprise.
252
+ *
253
+ * @param {string|undefined} raw - valeur brute (`"4000:3000"`, vide).
254
+ * @returns {{ cut: { at: number, duration: number }|null, invalid: string|null }}
255
+ * `cut` à null quand rien n'est demandé.
256
+ */
257
+ export function parseCut(raw) {
258
+ const v = (raw ?? "").trim();
259
+ if (!v) return { cut: null, invalid: null };
260
+ const m = /^(\d+):(\d+)$/.exec(v);
261
+ if (!m || Number(m[2]) === 0) return { cut: null, invalid: v };
262
+ return { cut: { at: Number(m[1]), duration: Number(m[2]) }, invalid: null };
263
+ }
264
+
265
+ /**
266
+ * Ramène un code de fermeture WebSocket à un code qu'un NAVIGATEUR peut
267
+ * émettre : 1000, ou 3000 à 4999 — tout autre lève `InvalidAccessError`.
268
+ *
269
+ * 🔴 Le côté serveur d'une route Playwright est un vrai WebSocket de page.
270
+ * Fermé avec 1001 ou 1006, il refuse, Playwright avale l'erreur et rend « ok » :
271
+ * la connexion réelle reste ouverte et le serveur continue de pousser vers un
272
+ * socket que la sonde dit fermé. Mesuré : 47 s sans fermeture côté serveur.
273
+ *
274
+ * @param {number} code - le code voulu.
275
+ * @returns {number} ce code s'il est émissible, 1000 sinon.
276
+ */
277
+ export function sendableCloseCode(code) {
278
+ return code === 1000 || (code >= 3000 && code <= 4999) ? code : 1000;
279
+ }
280
+
281
+ /**
282
+ * Arguments de lancement qui imposent le schéma de couleurs AU MOTEUR.
283
+ *
284
+ * L'émulation du pilote (`colorScheme` d'un contexte) ne vaut que pour les
285
+ * pages qu'il ouvre lui-même. Lighthouse ouvre son onglet par le protocole de
286
+ * débogage : sans ce réglage, l'audit mesure le thème du SYSTÈME, sans le dire.
287
+ * Blink connaît deux valeurs (`0` sombre, `1` clair) — `no-preference` n'en a
288
+ * pas, et ne pose donc rien.
289
+ *
290
+ * @param {string|null} schema - schéma rendu par `parseColorScheme`.
291
+ * @returns {string[]} arguments à ajouter au lancement du navigateur.
292
+ */
293
+ export function colorSchemeLaunchArgs(schema) {
294
+ if (schema === "dark") return ["--blink-settings=preferredColorScheme=0"];
295
+ if (schema === "light") return ["--blink-settings=preferredColorScheme=1"];
296
+ return [];
297
+ }
298
+
181
299
  /**
182
300
  * Analyse les entrées de stockage à poser avant chargement (`NF_BROWSER_STORAGE`).
183
301
  *
@@ -224,21 +342,64 @@ export function parseStorage(raw) {
224
342
  * évite de faire dire à `process.platform` une chose qu'il ne sait pas. Une
225
343
  * capacité se constate ; c'est l'appelant qui constate, cette fonction décide.
226
344
  *
227
- * @param {{inContainer: boolean, base?: string, out?: string}} stage -
345
+ * Le dossier de sortie local s'ancre à la RACINE de l'application (`root`,
346
+ * constatée par l'appelant) : relatif au dossier courant, il semait un
347
+ * `tmp/browser/` dans chaque sous-dossier d'où l'on lançait la sonde.
348
+ *
349
+ * @param {{inContainer: boolean, base?: string, out?: string, root?: string | null}} stage -
228
350
  * le constat, et les valeurs explicites qui l'emportent toujours.
229
351
  * @returns {{ base: string, out: string }} origine à joindre, dossier de sortie.
230
352
  */
231
- export function environmentDefaults({ inContainer, base, out } = {}) {
353
+ export function environmentDefaults({ inContainer, base, out, root } = {}) {
232
354
  return {
233
355
  base:
234
356
  base ||
235
357
  (inContainer
236
358
  ? "https://host.docker.internal:5152"
237
359
  : "https://127.0.0.1:5152"),
238
- out: out || (inContainer ? "/output" : "tmp/browser"),
360
+ out:
361
+ out ||
362
+ (inContainer
363
+ ? "/output"
364
+ : root
365
+ ? path.join(root, "tmp", "browser")
366
+ : "tmp/browser"),
239
367
  };
240
368
  }
241
369
 
370
+ /**
371
+ * Verdict sur une session REPRISE, d'après le statut du point « qui suis-je ».
372
+ *
373
+ * Une session se PROUVE, elle ne se déduit pas : l'absence de renvoi vers le
374
+ * formulaire de connexion ne dit rien sur une page publique, qui se sert aussi
375
+ * bien à un anonyme. Vécu — après un redémarrage du serveur, la sonde mesurait
376
+ * une page en anonyme, sans un mot, avec un état de session mort.
377
+ *
378
+ * Un point « qui suis-je » peut répondre `200` à un anonyme en le DISANT dans
379
+ * son corps (`{ user: null }`) — c'est le contrat des bibliothèques qui livrent
380
+ * aussi le client de connexion. Juger au seul statut ferait alors reprendre une
381
+ * session morte, et mesurer en anonyme sans le dire.
382
+ *
383
+ * @param {number} status - statut HTTP rendu par `NF_BROWSER_WHOAMI`.
384
+ * @param {unknown} [body] - corps JSON de la réponse, s'il a pu être lu.
385
+ * @returns {"alive"|"dead"|"unproven"} `alive` (2xx sans `user: null`), `dead`
386
+ * (401/403, ou 2xx dont le corps porte `user: null`), sinon `unproven` — le
387
+ * point ne répond pas comme attendu, et une session qu'on ne sait pas
388
+ * prouver ne se réutilise pas.
389
+ */
390
+ export function sessionVerdict(status, body) {
391
+ if (status >= 200 && status < 300) {
392
+ const anonymous =
393
+ body !== null &&
394
+ typeof body === "object" &&
395
+ Object.hasOwn(body, "user") &&
396
+ body.user === null;
397
+ return anonymous ? "dead" : "alive";
398
+ }
399
+ if (status === 401 || status === 403) return "dead";
400
+ return "unproven";
401
+ }
402
+
242
403
  /**
243
404
  * Nom du fichier d'état d'authentification, DÉRIVÉ de l'identifiant.
244
405
  *
@@ -255,20 +416,56 @@ export function environmentDefaults({ inContainer, base, out } = {}) {
255
416
  * fragment (`a@b` et `a-b`) — et une collision de nom rouvrirait exactement le
256
417
  * trou qu'on ferme.
257
418
  *
419
+ * L'ORIGINE entre dans l'empreinte : le même identifiant sur deux
420
+ * applications n'est pas la même session. Vécu — `admin` de la console
421
+ * d'administration (port 5152) et `admin` de Keycloak (port 8444) partageaient
422
+ * un fichier : la sonde reprenait l'un sur l'autre, retombait sur l'écran de
423
+ * connexion et le mesurait à la place de la page demandée.
424
+ *
258
425
  * @param {string|undefined} login - l'identifiant de connexion demandé.
426
+ * @param {string} [origin] - l'origine visée (`NF_BROWSER_BASE`).
259
427
  * @returns {string} le nom de fichier, sans dossier.
260
428
  */
261
- export function authStateName(login) {
429
+ export function authStateName(login, origin = "") {
262
430
  const raw = login ?? "";
263
431
  const readable =
264
432
  raw.replace(/[^A-Za-z0-9._-]/gu, "_").slice(0, 40) || "anonyme";
265
433
  const fingerprint = createHash("sha256")
266
- .update(raw)
434
+ .update(`${origin}\n${raw}`)
267
435
  .digest("hex")
268
436
  .slice(0, 8);
269
437
  return `.auth-state-${readable}-${fingerprint}.json`;
270
438
  }
271
439
 
440
+ /**
441
+ * Nom de fichier d'une capture ou d'un rapport, DÉRIVÉ de la page visée.
442
+ *
443
+ * Le chemin donne un fragment LISIBLE ; la requête et le fragment d'URL n'y
444
+ * entrent jamais tels quels : une page d'autorisation OAuth en porte plusieurs
445
+ * centaines de caractères (`ENAMETOOLONG`), et `?`, `&`, `%` ou `:` sont
446
+ * refusés par certains systèmes de fichiers (Windows). Une requête présente
447
+ * laisse une EMPREINTE, pour que deux variantes d'une même page (`?ui_locales=fr`
448
+ * et `?ui_locales=en`) ne s'écrasent pas.
449
+ *
450
+ * @param {string} page - chemin demandé, requête comprise.
451
+ * @returns {string} le fragment de nom, sans dossier ni extension (≤ 80 caractères).
452
+ */
453
+ export function captureSlug(page) {
454
+ const cut = page.search(/[?#]/u);
455
+ const pathname = cut === -1 ? page : page.slice(0, cut);
456
+ const readable =
457
+ pathname
458
+ .replace(/[^A-Za-z0-9._-]+/gu, "-")
459
+ .replace(/^-+|-+$/gu, "")
460
+ .slice(0, 70) || "racine";
461
+ if (cut === -1) return readable;
462
+ const fingerprint = createHash("sha256")
463
+ .update(page.slice(cut))
464
+ .digest("hex")
465
+ .slice(0, 8);
466
+ return `${readable}-${fingerprint}`;
467
+ }
468
+
272
469
  /**
273
470
  * Analyse les largeurs d'écran de la famille `responsive` (`NF_BROWSER_WIDTHS`).
274
471
  *
@@ -324,6 +521,21 @@ export function median(values) {
324
521
  const m = Math.floor(sorted.length / 2);
325
522
  return sorted.length % 2 === 1 ? sorted[m] : (sorted[m - 1] + sorted[m]) / 2;
326
523
  }
524
+
525
+ /**
526
+ * Arrondit une durée au centième de milliseconde — la précision d'un RTT local.
527
+ *
528
+ * Un arrondi à la milliseconde entière rend 0 pour tout aller-retour sous
529
+ * 0,5 ms : sur une même machine, la sonde annonçait une latence nulle. Le
530
+ * centième reste sous la résolution de `performance.now()` côté navigateur
531
+ * (~0,1 ms hors isolation), donc ne fabrique aucune précision qui n'existe pas.
532
+ *
533
+ * @param {number} ms - durée brute en millisecondes.
534
+ * @returns {number} la durée arrondie à 0,01 ms.
535
+ */
536
+ export function roundMs(ms) {
537
+ return Math.round(ms * 100) / 100;
538
+ }
327
539
  /**
328
540
  * Résume un rapport axe-core en un bloc lisible — le tri éditorial, pas la mesure.
329
541
  *
@@ -24,10 +24,10 @@
24
24
  * `@env` NF_BROWSER_PINGS nombre de mesures de latence (défaut 5)
25
25
  * `@requires` conteneur du profil `browser` démarré · serveur joignable · un endpoint temps réel exposé
26
26
  * `@output` un objet JSON sur stdout : accueil, abonnement, latence, api, reconnexion — un verdict par étape
27
- * `@exit` 0 scénario joué (les verdicts sont des DONNÉES) · 64 usage (endpoint manquant, params illisibles) · 65 accueil jamais reçu
27
+ * `@exit` 0 scénario joué (les verdicts sont des DONNÉES) · 64 usage (endpoint manquant, params illisibles) · 65 accueil jamais reçu (refus au handshake : JSON sur stdout, statut nommé)
28
28
  */
29
29
  import { open, goTo } from "./lib/browser.mjs";
30
- import { median } from "./lib/probes.mjs";
30
+ import { median, roundMs } from "./lib/probes.mjs";
31
31
 
32
32
  const ENDPOINT = process.argv[2] ?? process.env.NF_BROWSER_SOCKET ?? "";
33
33
  if (!ENDPOINT.startsWith("/")) {
@@ -95,6 +95,13 @@ async function scenarioSocket(conf) {
95
95
  const openSocket = () =>
96
96
  new Promise((resolve, reject) => {
97
97
  const ws = new WebSocket(url);
98
+ // Une fermeture AVANT l'ouverture est un refus du HANDSHAKE (réponse
99
+ // non-101 : 401, 403, 404…) — rien à attendre, et surtout pas les huit
100
+ // secondes du garde, qui feraient lire un refus net comme un silence.
101
+ let opened = false;
102
+ ws.addEventListener("open", () => {
103
+ opened = true;
104
+ });
98
105
  const guard = setTimeout(() => {
99
106
  try {
100
107
  ws.close();
@@ -105,6 +112,14 @@ async function scenarioSocket(conf) {
105
112
  }, conf.timeoutMs);
106
113
  ws.addEventListener("close", (ev) => {
107
114
  closes.push({ a: a(), code: ev.code, reason: ev.reason });
115
+ if (!opened) {
116
+ clearTimeout(guard);
117
+ const refusal = new Error(
118
+ `accueil jamais reçu : connexion refusée au handshake sur ${url}`,
119
+ );
120
+ refusal.handshake = { afterMs: a(), closeCode: ev.code };
121
+ reject(refusal);
122
+ }
108
123
  });
109
124
  ws.addEventListener("message", (ev) => {
110
125
  let frame;
@@ -166,7 +181,8 @@ async function scenarioSocket(conf) {
166
181
  const settle = (value) => {
167
182
  if (settled) return;
168
183
  settled = true;
169
- resolve({ ...value, ms: Math.round(performance.now() - start) });
184
+ // Durée BRUTE : l'arrondi se fait côté Node (`roundMs`), où il s'éprouve.
185
+ resolve({ ...value, ms: performance.now() - start });
170
186
  };
171
187
  const guard = setTimeout(() => {
172
188
  pending.delete(id);
@@ -181,8 +197,21 @@ async function scenarioSocket(conf) {
181
197
  ws.send(JSON.stringify(frame));
182
198
  });
183
199
 
184
- const { ws, welcome } = await openSocket();
200
+ let first;
201
+ try {
202
+ first = await openSocket();
203
+ } catch (e) {
204
+ // Le refus au handshake est une RÉPONSE du serveur, pas une panne de la
205
+ // sonde : il se rend en données (le pilote y joint le statut HTTP que seul
206
+ // le navigateur connaît — la page ne voit qu'un code de fermeture 1006).
207
+ if (!e?.handshake) throw e;
208
+ result.welcome = { verdict: "REFUSÉ AU HANDSHAKE", ...e.handshake };
209
+ result.closes = closes;
210
+ return result;
211
+ }
212
+ const { ws, welcome } = first;
185
213
  result.welcome = {
214
+ verdict: "OK",
186
215
  receivedAfterMs: a(),
187
216
  protocol: welcome.protocol ?? null,
188
217
  channels: welcome.channels ?? [],
@@ -330,6 +359,19 @@ async function scenarioSocket(conf) {
330
359
  const { browser, ctx, page, reuse } = await open();
331
360
  await goTo(page, ctx, process.env.NF_BROWSER_PAGE ?? "/", reuse);
332
361
 
362
+ // Le statut HTTP d'un handshake refusé n'existe que pour le NAVIGATEUR : la
363
+ // page n'en reçoit qu'un code de fermeture 1006. Le pilote l'écoute là où le
364
+ // navigateur le dit — l'erreur du socket et la console.
365
+ const handshakeErrors = [];
366
+ page.on("websocket", (socket) => {
367
+ socket.on("socketerror", (error) => handshakeErrors.push(String(error)));
368
+ });
369
+ page.on("console", (message) => {
370
+ if (message.type() === "error" && message.text().includes("WebSocket")) {
371
+ handshakeErrors.push(message.text());
372
+ }
373
+ });
374
+
333
375
  let result;
334
376
  try {
335
377
  result = await page.evaluate(scenarioSocket, cfg);
@@ -344,11 +386,35 @@ try {
344
386
  process.exit(65); // EX_DATAERR
345
387
  }
346
388
 
389
+ if (result.welcome?.verdict === "REFUSÉ AU HANDSHAKE") {
390
+ // Les messages du navigateur arrivent par le protocole de débogage, parfois
391
+ // juste APRÈS la fermeture vue par la page : une courte attente bornée.
392
+ if (handshakeErrors.length === 0) await page.waitForTimeout(500);
393
+ const reason = handshakeErrors[0]?.slice(0, 300) ?? null;
394
+ // Chromium nomme le code (« Unexpected response code: 404 ») SAUF pour un
395
+ // 401 porteur de `WWW-Authenticate` : il le traite en défi d'authentification
396
+ // qu'il ne sait pas satisfaire, et ne dit que cela.
397
+ const status = /HTTP Authentication failed/i.test(reason ?? "")
398
+ ? "401"
399
+ : /\b([1-5]\d\d)\b/.exec(reason ?? "")?.[1];
400
+ result.welcome.status = status ? Number(status) : null;
401
+ result.welcome.browserReason = reason;
402
+ console.log(JSON.stringify(result, null, 2));
403
+ console.error(
404
+ `Accueil jamais reçu : le serveur a REFUSÉ le handshake (statut ${result.welcome.status ?? "non communiqué par le navigateur"}).\n` +
405
+ `→ 401/403 : la page ${process.env.NF_BROWSER_PAGE ?? "/"} n'est pas authentifiée, ou le compte n'a pas le droit (NF_BROWSER_USER + NF_BROWSER_LOGIN) ; 404 : chemin du endpoint faux ; 403 sur une page authentifiée : Origin refusé.`,
406
+ );
407
+ await browser.close();
408
+ process.exit(65); // EX_DATAERR — l'accueil n'est pas venu, la cause est nommée
409
+ }
410
+
347
411
  // La médiane se calcule ici, avec la fonction que les tests éprouvent — la
348
412
  // moyenne serait déplacée par un seul aller-retour aberrant.
349
413
  if (Array.isArray(result.latency?.measuresMs)) {
414
+ result.latency.measuresMs = result.latency.measuresMs.map(roundMs);
350
415
  result.latency.medianMs = median(result.latency.measuresMs);
351
416
  }
417
+ if (typeof result.api?.ms === "number") result.api.ms = roundMs(result.api.ms);
352
418
 
353
419
  console.log(JSON.stringify(result, null, 2));
354
420
  await browser.close();
@@ -13,16 +13,27 @@
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
15
  * `@env` NF_BROWSER_UNTIL expression JS évaluée DANS la page ; l'observation s'arrête dès qu'elle est vraie (point d'arrêt applicatif)
16
+ * `@env` NF_BROWSER_CUT coupure réseau `<début>:<durée>` en ms (`4000:3000`), comptée depuis l'ouverture de la page — éprouve la REPRISE d'un flux ; chaque socket dit alors quand il s'est fermé et rouvert
16
17
  * `@env` NF_BROWSER_MAXFRAMES nombre de frames WebSocket conservées par sens (défaut 12)
17
18
  * `@requires` conteneur du profil `browser` démarré · serveur Nodefony joignable
18
19
  * `@output` JSON : sockets et leurs frames (horodatées), requêtes non-2xx, erreurs console, verdict de la condition
19
20
  */
20
21
  import { open, goTo } from "./lib/browser.mjs";
22
+ import { parseCut, sendableCloseCode } from "./lib/probes.mjs";
21
23
 
22
24
  const PAGE = process.argv[2] ?? process.env.NF_BROWSER_PAGE ?? "/";
23
25
  const DURATION = Number(process.argv[3] ?? 8000);
24
26
  const UNTIL = process.env.NF_BROWSER_UNTIL ?? "";
25
27
  const MAX = Number(process.env.NF_BROWSER_MAXFRAMES ?? 12);
28
+ const { cut: CUT, invalid: invalidCut } = parseCut(process.env.NF_BROWSER_CUT);
29
+ if (invalidCut) {
30
+ console.error(
31
+ `NF_BROWSER_CUT attend « <début>:<durée> » en ms (ex. 4000:3000), reçu « ${invalidCut} »`,
32
+ );
33
+ process.exit(64);
34
+ }
35
+ // Sockets que la coupure FERME (cf « La coupure réseau » plus bas).
36
+ const isRouted = (url) => !new URL(url).pathname.startsWith("/_vite/");
26
37
 
27
38
  const { browser, ctx, page, reuse } = await open();
28
39
 
@@ -39,6 +50,9 @@ const uncaughtErrors = [];
39
50
  // qu'on ne lit, et une sortie de plusieurs mégaoctets serait illisible — donc
40
51
  // inexploitable pour décider. On garde les premières de chaque sens.
41
52
  page.on("websocket", (ws) => {
53
+ // Un socket routé pour la coupure est enregistré par la route : l'écouter
54
+ // ici aussi le compterait deux fois.
55
+ if (CUT && isRouted(ws.url())) return;
42
56
  const rec = { url: ws.url(), openedAt: at(), sent: [], received: [] };
43
57
  sockets.push(rec);
44
58
  ws.on("framesent", (f) => {
@@ -72,8 +86,88 @@ page.on("pageerror", (e) => {
72
86
  uncaughtErrors.push({ a: at(), text: String(e).slice(0, 200) });
73
87
  });
74
88
 
89
+ // ── La coupure réseau : éprouver une REPRISE ────────────────────────────────
90
+ // `setOffline` seul ne suffit PAS : Chromium y coupe le HTTP mais laisse les
91
+ // WebSockets déjà ouverts vivre — mesuré, aucun `close` n'est émis, et l'on
92
+ // « éprouverait » une reprise sans qu'aucune connexion ait été perdue. Les
93
+ // sockets passent donc par `routeWebSocket` : à la coupure, la sonde ferme
94
+ // chaque connexion des deux côtés — 1006 vers la page, ce que voit un client
95
+ // qui perd le réseau ; 1000 vers le serveur, qui libère la connexion au lieu de
96
+ // la garder à demi ouverte — et refuse toute réouverture jusqu'au
97
+ // rétablissement.
98
+ // Le relais Vite (`/_vite/`) est épargné : son client de rechargement à chaud
99
+ // RECHARGE la page quand il retrouve le serveur, et la reprise observée serait
100
+ // celle d'une page neuve, pas celle de l'application.
101
+ let cut = null;
102
+ let cutActive = false;
103
+ const cutTimers = [];
104
+ const routed = new Set();
105
+ // Le côté SERVEUR d'une route est un vrai WebSocket de navigateur : son code
106
+ // de fermeture passe par `sendableCloseCode` (cf son TSDoc).
107
+ if (CUT) {
108
+ cut = { requested: CUT, refused: 0 };
109
+ await ctx.routeWebSocket(isRouted, (route) => {
110
+ if (cutActive) {
111
+ cut.refused += 1;
112
+ route.close({ code: 1006, reason: "coupure réseau (sonde)" });
113
+ return;
114
+ }
115
+ const server = route.connectToServer();
116
+ const rec = { url: route.url(), openedAt: at(), sent: [], received: [] };
117
+ sockets.push(rec);
118
+ const pair = { route, server, rec };
119
+ routed.add(pair);
120
+ // Poser `onMessage` coupe le relais automatique : on relaie à la main.
121
+ route.onMessage((m) => {
122
+ if (rec.sent.length < MAX)
123
+ rec.sent.push({ a: at(), payload: String(m).slice(0, 160) });
124
+ server.send(m);
125
+ });
126
+ server.onMessage((m) => {
127
+ if (rec.received.length < MAX)
128
+ rec.received.push({ a: at(), payload: String(m).slice(0, 160) });
129
+ route.send(m);
130
+ });
131
+ const closed = (code) => {
132
+ rec.closedAt ??= at();
133
+ rec.closeCode ??= code;
134
+ routed.delete(pair);
135
+ };
136
+ route.onClose((code, reason) => {
137
+ closed(code);
138
+ server.close({ code: sendableCloseCode(code), reason });
139
+ });
140
+ server.onClose((code, reason) => {
141
+ closed(code);
142
+ route.close({ code, reason });
143
+ });
144
+ });
145
+ }
146
+
75
147
  await goTo(page, ctx, PAGE, reuse);
76
148
 
149
+ if (CUT) {
150
+ cutTimers.push(
151
+ setTimeout(() => {
152
+ cut.offlineAt = at();
153
+ cutActive = true;
154
+ ctx.setOffline(true).catch((e) => (cut.error = String(e)));
155
+ for (const { route, server, rec } of routed) {
156
+ rec.closedAt ??= at();
157
+ rec.closeCode ??= 1006;
158
+ server.close({ code: 1000, reason: "coupure réseau (sonde)" });
159
+ route.close({ code: 1006, reason: "coupure réseau (sonde)" });
160
+ }
161
+ routed.clear();
162
+ }, CUT.at),
163
+ setTimeout(() => {
164
+ cut.onlineAt = at();
165
+ cutActive = false;
166
+ ctx.setOffline(false).catch((e) => (cut.error = String(e)));
167
+ }, CUT.at + CUT.duration),
168
+ );
169
+ }
170
+
77
171
  // ── Le « point d'arrêt » : une CONDITION, pas une ligne de code ──────────────
78
172
  // `waitForFunction` réévalue l'expression dans la page à chaque animation frame.
79
173
  // C'est ce qui remplace utilement un breakpoint dans un pilotage automatisé :
@@ -100,12 +194,19 @@ if (UNTIL) {
100
194
  await page.waitForTimeout(DURATION);
101
195
  }
102
196
 
197
+ for (const t of cutTimers) clearTimeout(t);
198
+ if (cut && cut.onlineAt === undefined) {
199
+ cutActive = false;
200
+ await ctx.setOffline(false);
201
+ }
202
+
103
203
  console.log(
104
204
  JSON.stringify(
105
205
  {
106
206
  url: page.url(),
107
207
  observeForMs: at(),
108
208
  verdict,
209
+ cut,
109
210
  // Les totaux sont posés sur l'objet accumulé plutôt que recomposés par
110
211
  // étalement dans un `map` (règle `no-map-spread` : allocation inutile).
111
212
  sockets: sockets.map((s) =>