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

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 (55) hide show
  1. package/README.md +146 -4
  2. package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  3. package/dist/_virtual/{_@oxc-project_runtime@0.153.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 +25 -226
  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/errors/DevkitError.js +1 -2
  18. package/dist/nodefony/src/profileSession.js +14 -0
  19. package/dist/types/index.d.ts +15 -1
  20. package/dist/types/nodefony/config/config.d.ts +1 -1
  21. package/dist/types/nodefony/controllers/McpController.d.ts +11 -26
  22. package/dist/types/nodefony/controllers/ProfileSessionController.d.ts +31 -0
  23. package/dist/types/nodefony/interfaces/IDevkitService.d.ts +22 -1
  24. package/dist/types/nodefony/mcp/observe.d.ts +83 -0
  25. package/dist/types/nodefony/mcp/profileReport.d.ts +138 -0
  26. package/dist/types/nodefony/mcp/profileRoute.d.ts +87 -0
  27. package/dist/types/nodefony/mcp/shotClient.d.ts +53 -0
  28. package/dist/types/nodefony/mcp/shutdown.d.ts +25 -0
  29. package/dist/types/nodefony/mcp/tools.d.ts +109 -0
  30. package/dist/types/nodefony/service/DevkitService.d.ts +41 -4
  31. package/dist/types/nodefony/src/profileSession.d.ts +12 -0
  32. package/docs/index.md +160 -11
  33. package/package.json +16 -11
  34. package/skills/nodefony-add-crud/SKILL.md +10 -1
  35. package/skills/nodefony-add-service/SKILL.md +3 -3
  36. package/skills/nodefony-authentication/SKILL.md +5 -5
  37. package/skills/nodefony-browser/SKILL.md +20 -0
  38. package/skills/nodefony-browser/references/socket.md +13 -4
  39. package/skills/nodefony-browser/references/sondes.md +13 -1
  40. package/skills/nodefony-browser/scripts/inspect.mjs +55 -8
  41. package/skills/nodefony-browser/scripts/lib/probes.mjs +53 -0
  42. package/skills/nodefony-browser/scripts/socket.mjs +70 -4
  43. package/skills/nodefony-browser/scripts/watch.mjs +101 -0
  44. package/skills/nodefony-dev/SKILL.md +31 -1
  45. package/skills/nodefony-dev/scripts/docs.mjs +3 -3
  46. package/skills/nodefony-devops/SKILL.md +85 -64
  47. package/skills/nodefony-devops/references/dimensionnement.md +122 -0
  48. package/skills/nodefony-devops/references/frontal.md +277 -47
  49. package/skills/nodefony-devops/references/kubernetes.md +41 -14
  50. package/skills/nodefony-devops/references/secrets.md +13 -2
  51. package/skills/nodefony-devops/references/variables.md +14 -12
  52. package/skills/nodefony-protect-route/SKILL.md +6 -4
  53. package/skills/nodefony-security-review/scripts/review-diff.mjs +4 -4
  54. package/skills/nodefony-troubleshoot/SKILL.md +86 -12
  55. package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
@@ -42,6 +42,11 @@ gabarit porte des détails qui ne se devinent pas : le repository résolu au pre
42
42
  se connecte qu'au démarrage), la pagination bornée **et son tri déclaré**, les codes
43
43
  201/204/404/409/422, et l'en-tête `Location`.
44
44
 
45
+ Le controller généré **déclare** ses entrées : chaque route porte un `schema` (`idParams`, le schéma
46
+ de création ou de retouche de l'entité), et l'action reçoit `input` — validé à la porte (422) avant
47
+ tout appel au service, qui revalide pour ses autres portes (CLI, socket). Pour ajouter une action,
48
+ **garde cette forme** : un `@Body` ou un `@Param` sur une action à schéma est refusé au démarrage.
49
+
45
50
  ## La grammaire de champs
46
51
 
47
52
  `nom:type[?][=défaut][:index|:unique]` — **non-null par défaut**, `?` rend facultatif, `=valeur`
@@ -190,7 +195,11 @@ refus tombe tout seul.
190
195
 
191
196
  ## Les trois vérités à savoir avant de livrer (SQL)
192
197
 
193
- 1. **La table naît au prochain démarrage en développement** (`CREATE TABLE IF NOT EXISTS`).
198
+ 1. **La table existe dès la commande quand l'application versionne ses migrations** (toute
199
+ application `complete`) : `create entity` écrit ET applique la migration de la table neuve —
200
+ `--no-migrate` s'en dispense ; si des entités modifiées attendaient, la migration est écrite mais
201
+ PAS appliquée, et la sortie nomme les tables en trop. Sinon, la table naît au prochain démarrage
202
+ en développement (`CREATE TABLE IF NOT EXISTS`).
194
203
  2. **La modifier n'altère rien** — aucun `ALTER` n'est émis. Une colonne ajoutée à une entité déjà
195
204
  créée n'apparaîtra pas dans une base existante.
196
205
  3. **La production ne fabrique JAMAIS le schéma.** Elle l'applique par des migrations, écrites par
@@ -4,7 +4,7 @@ description: >
4
4
  Crée un service injectable dans une application Nodefony par `nodefony create service`, et le
5
5
  fait entrer dans le conteneur — la moitié qu'on oublie. Porte la distinction entre le nom de la
6
6
  CLASSE et le nom de l'INSTANCE, les deux façons d'obtenir un service depuis un autre
7
- (`@inject` au constructeur ou `container.get` à l'usage), le choix de sa durée de vie
7
+ (`@Inject` au constructeur ou `container.get` à l'usage), le choix de sa durée de vie
8
8
  (un exemplaire pour l'application, ou un par requête), et le défaut mesuré qu'un service
9
9
  écrit à la main produit : une classe qui compile, dont les tests passent, et que le conteneur
10
10
  ignore. À charger AVANT d'écrire une classe de service ou d'appeler un service depuis un autre.
@@ -63,7 +63,7 @@ et à l'introspection, et n'est construit qu'à la première requête qui le ré
63
63
  C'est le piège n°1, et il ne produit aucune erreur — juste un `null` :
64
64
 
65
65
  ```ts
66
- @injectable() // ← nomme la CLASSE : @inject("BillingService")
66
+ @injectable() // ← nomme la CLASSE : @Inject("BillingService")
67
67
  export class BillingService extends Service {
68
68
  constructor() {
69
69
  super("billing", ...); // ← nomme l'INSTANCE : container.get("billing")
@@ -103,7 +103,7 @@ Détail : `node_modules/nodefony/docs/request-context.md`.
103
103
 
104
104
  | Voie | Quand |
105
105
  | ------------------------------ | ----------------------------------------------------------------------------- |
106
- | `@inject("X")` au constructeur | **par défaut** — la dépendance est déclarée, l'ordre de démarrage la respecte |
106
+ | `@Inject("X")` au constructeur | **par défaut** — la dépendance est déclarée, l'ordre de démarrage la respecte |
107
107
  | `container.get("x")` à l'usage | quand la dépendance est facultative, tardive, ou choisie à l'exécution |
108
108
 
109
109
  `create service --inject` pose la première. Le second est visible partout dans les exemples, et
@@ -207,11 +207,11 @@ l'audience absente : `--write`, puis réimport. Détail :
207
207
  Un script, une tâche planifiée, un agent n'ont ni navigateur ni cookie : ils présentent une
208
208
  preuve à chaque appel, `Authorization: Bearer …`.
209
209
 
210
- | Preuve | L'obtenir |
211
- | ------------------------------- | --------------------------------------------------------------- |
212
- | clé d'API opaque (`nf_…`) | `POST /nodefony/security/api/keys` (session d'administration) |
213
- | jeton signé par l'application | `npx nodefony security:token [compte] --scope … --ttl … --json` |
214
- | jeton Keycloak (client machine) | `requestClientCredentialsToken` de `@nodefony/security` |
210
+ | Preuve | L'obtenir |
211
+ | ------------------------------- | ------------------------------------------------------------------- |
212
+ | clé d'API opaque (`nf_…`) | `POST /nodefony/security/api/keys` (session d'administration) |
213
+ | jeton signé par l'application | `npx nodefony security:mcp:token [compte] --scope … --ttl … --json` |
214
+ | jeton Keycloak (client machine) | `requestClientCredentialsToken` de `@nodefony/security` |
215
215
 
216
216
  ❌ Un `POST` vers la route de login depuis un script, ou un cookie de session recopié dans sa
217
217
  configuration : le premier fait stocker un mot de passe au programme, le second expire et casse
@@ -381,6 +381,26 @@ en bout : le message part-il, revient-il, et l'écran le reçoit-il ?
381
381
  > lui, vaut pour toute attente que tu écriras : tant qu'elle n'a pas échoué une fois, elle ne
382
382
  > discrimine rien.
383
383
 
384
+ ### Éprouver la REPRISE après une coupure réseau — `NF_BROWSER_CUT`
385
+
386
+ ```bash
387
+ docker exec -e NF_BROWSER_CUT=5000:4000 mon-app-browser node /app/see-screen/watch.mjs /tableau-de-bord 25000
388
+ ```
389
+
390
+ `<début>:<durée>` en millisecondes : le réseau tombe 5 s après l'ouverture de la page, revient 4 s
391
+ plus tard. Chaque socket coupé sort avec `closedAt` et `closeCode` (1006, ce que voit un client
392
+ qui perd le réseau), le bloc `cut` compte les réouvertures refusées pendant la coupure, et le
393
+ socket rouvert montre ses frames : l'accueil, les abonnements rejoués, leur acquittement, puis le
394
+ flux. C'est la seule preuve qu'une page qui perd le réseau **retrouve** ses données.
395
+
396
+ - **Le serveur est prévenu** (fermeture 1000) : la connexion coupée ne reste pas à demi ouverte
397
+ chez lui. Une vraie perte de réseau, elle, ne le prévient pas. La sonde éprouve la reprise du
398
+ client, pas la détection d'un pair mort.
399
+ - **Le relais Vite (`/_vite/`) n'est pas coupé** : son client de rechargement à chaud recharge la
400
+ page quand il retrouve le serveur. Vous observeriez une page neuve, pas sa reprise.
401
+ - **Les `ERR_INTERNET_DISCONNECTED` de la console pendant la coupure sont attendus.**
402
+ - Une valeur malformée est refusée (code 64), jamais ignorée.
403
+
384
404
  ## Piloter le socket de bout en bout — `socket.mjs`
385
405
 
386
406
  `watch.mjs` regarde le trafic d'une page ; celui-ci **conduit** : il ouvre un socket temps réel
@@ -36,10 +36,19 @@ Deux frames servent de repères :
36
36
 
37
37
  ### `welcome`
38
38
 
39
- `receivedAfterMs`, canaux, méthodes, identité. **S'il ne vient jamais** (code de retour 65), les trois
40
- suspects, dans l'ordre : le chemin du endpoint est faux ; la page n'est pas authentifiée (donner
41
- `NF_BROWSER_USER` + `NF_BROWSER_LOGIN`) ; l'`Origin` de la page est refusé par le serveur. Le
42
- réseau qui « passe » n'innocente aucun des trois.
39
+ `verdict`, `receivedAfterMs`, canaux, méthodes, identité. **S'il ne vient jamais** (code de retour
40
+ 65), la sortie dit lequel des deux cas :
41
+
42
+ - **`REFUSÉ AU HANDSHAKE`** — le serveur a répondu autre chose que `101` : la socket ne s'est jamais
43
+ ouverte, et la sonde le rend tout de suite avec `status`, `afterMs` et la `browserReason` du
44
+ navigateur (la page ne voit qu'une fermeture `1006` ; le statut, seul le navigateur le connaît).
45
+ `401` : la page n'est pas authentifiée (donner `NF_BROWSER_USER` + `NF_BROWSER_LOGIN`) ; `403` :
46
+ le compte n'a pas le rôle, ou l'`Origin` de la page est refusé ; `404` : le chemin du endpoint
47
+ est faux. Chromium ne nomme pas le code d'un `401` porteur de `WWW-Authenticate` (« HTTP
48
+ Authentication failed ») : la sonde le traduit.
49
+ - **Rien sur la sortie standard, message « en 8000 ms »** — la socket s'est ouverte mais l'accueil
50
+ n'est jamais venu : l'endpoint n'est pas un endpoint temps réel Nodefony, ou le serveur ferme
51
+ après l'ouverture. Le réseau qui « passe » n'innocente rien.
43
52
 
44
53
  ### `subscription`
45
54
 
@@ -37,6 +37,11 @@ C'est au lecteur de juger — la sonde fournit, elle ne condamne pas ce qu'elle
37
37
  Un sélecteur par élément (`NF_BROWSER_PROBES=libellé=sélecteur,…`) ; pour chacun : texte, couleur,
38
38
  fond effectif, rapport de contraste, police, verdict WCAG, taille rendue.
39
39
 
40
+ - **Un widget en Shadow DOM est invisible à un sélecteur nu** — la sonde rend `absent`, et l'on
41
+ conclut à tort que l'élément n'existe pas. `hôte >>> sélecteur` entre dans la racine fantôme
42
+ OUVERTE de l'hôte (`#ma-barre >>> .bouton`), segment après segment. Le fond effectif traverse la
43
+ frontière de la même façon : au-dessus de la racine fantôme, c'est l'hôte qui fait fond.
44
+
40
45
  - **Le fond effectif empile TOUTES les couches** jusqu'au premier ancêtre opaque, puis les compose.
41
46
  Deux erreurs à ne pas refaire : lire `backgroundColor` sur l'élément rend `rgba(0,0,0,0)` et un
42
47
  contraste faux ; s'arrêter à la première couche non transparente traite un voile à 13 % comme un
@@ -102,9 +107,16 @@ d'un `alt` restent un jugement humain.
102
107
  ## `rendu` — la page tient-elle dans son viewport, ses polices sont-elles là
103
108
 
104
109
  - `horizontalOverflow` : la page dépasse-t-elle la largeur de la fenêtre (le défilement
105
- horizontal accidentel). C'est LUI qui porte le verdict.
110
+ horizontal accidentel). Il porte le verdict.
111
+ - `horizontalScrollers` : les conteneurs défilants (`overflow-x: auto|scroll`) dont le contenu
112
+ déborde — une barre de défilement horizontale DANS la page, que `horizontalOverflow` ne voit
113
+ pas. Il porte AUSSI le verdict. Vécu : une barre de débogage en Shadow DOM débordait de 232 px
114
+ pendant que la page entière se déclarait saine. Cause fréquente : une infobulle invisible
115
+ (`visibility: hidden`) ancrée à gauche sur un bouton de droite — invisible, elle élargit quand
116
+ même la zone défilable.
106
117
  - `elementsOutsideViewport` : une **information**, pas un verdict — carrousels, tiroirs et textes
107
118
  destinés aux lecteurs d'écran sortent du viewport légitimement.
119
+ - Les trois mesures descendent dans les **Shadow DOM ouverts**.
108
120
  - `fonts` : ce que `document.fonts` a RÉELLEMENT chargé (statut par famille + graisse). Une
109
121
  police en échec bascule le verdict — le texte s'affiche alors dans une police de repli, et toutes
110
122
  les mesures de taille en héritent.
@@ -27,7 +27,8 @@
27
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
- * `@env` NF_BROWSER_PROBES sélecteurs CSS à sonder, séparés par des virgules (`libellé=sélecteur`)
30
+ * `@env` NF_BROWSER_PROBES sélecteurs CSS à sonder, séparés par des virgules (`libellé=sélecteur`) ;
31
+ * `hôte >>> sélecteur` entre dans le Shadow DOM ouvert de l'hôte
31
32
  * `@env` NF_BROWSER_WIDTHS largeurs de la famille responsive (défaut 360,768,1280)
32
33
  * `@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
34
  * `@env` NF_BROWSER_PRIMARY sélecteur de l'action principale pour la famille pli (défaut : le plus grand bouton de validation visible)
@@ -384,7 +385,14 @@ function isVisible(el) {
384
385
  function effectiveBackground(el) {
385
386
  const layers = [];
386
387
  let opaqueLayer = null;
387
- for (let n = el; n; n = n.parentElement) {
388
+ // Un élément posé à la racine d'un Shadow DOM n'a pas de parent : son fond
389
+ // est celui de l'HÔTE, et la remontée continue de l'autre côté.
390
+ const up = (n) => {
391
+ if (n.parentElement) return n.parentElement;
392
+ const root = n.getRootNode();
393
+ return root instanceof ShadowRoot ? root.host : null;
394
+ };
395
+ for (let n = el; n; n = up(n)) {
388
396
  const bg = getComputedStyle(n).backgroundColor;
389
397
  if (!bg || bg.includes("transparent")) continue;
390
398
  const { a } = parseColor(bg);
@@ -550,18 +558,44 @@ function probeA11y() {
550
558
  * Famille rendu — la page tient-elle dans son viewport, ses polices sont-elles
551
559
  * VRAIMENT arrivées. Les éléments hors viewport sont une INFO, pas le verdict :
552
560
  * carrousels et textes pour lecteurs d'écran en produisent légitimement.
561
+ *
562
+ * Le débordement se cherche AUSSI dans les Shadow DOM ouverts, et dans chaque
563
+ * conteneur défilant : un widget isolé (une barre de débogage) peut porter sa
564
+ * propre barre de défilement horizontale sans que la page, elle, déborde d'un
565
+ * pixel — la mesure du document seul la déclarait saine.
553
566
  */
554
567
  function probeRendering() {
555
568
  const doc = document.scrollingElement ?? document.documentElement;
556
569
  const overflowPx = Math.max(0, doc.scrollWidth - window.innerWidth);
557
570
  const outsideViewport = [];
558
571
  let totalOutside = 0;
559
- for (const el of document.querySelectorAll("body *")) {
572
+ const scrollersX = [];
573
+ let totalScrollersX = 0;
574
+ const all = (root, out) => {
575
+ for (const el of root.querySelectorAll("*")) {
576
+ out.push(el);
577
+ if (el.shadowRoot) all(el.shadowRoot, out);
578
+ }
579
+ return out;
580
+ };
581
+ for (const el of all(document.body, [])) {
560
582
  const r = el.getBoundingClientRect();
561
- if (r.width > 0 && r.right > window.innerWidth + 1) {
583
+ if (r.width === 0) continue;
584
+ if (r.right > window.innerWidth + 1 && totalOutside < 200) {
562
585
  totalOutside += 1;
563
586
  if (outsideViewport.length < 3) outsideViewport.push(describeElement(el));
564
- if (totalOutside >= 200) break;
587
+ }
588
+ const ox = getComputedStyle(el).overflowX;
589
+ if (
590
+ (ox === "auto" || ox === "scroll") &&
591
+ el.scrollWidth > el.clientWidth + 1
592
+ ) {
593
+ totalScrollersX += 1;
594
+ if (scrollersX.length < 3)
595
+ scrollersX.push({
596
+ element: describeElement(el),
597
+ overflowPx: el.scrollWidth - el.clientWidth,
598
+ });
565
599
  }
566
600
  }
567
601
  const families = {};
@@ -571,8 +605,12 @@ function probeRendering() {
571
605
  if (f.status === "error") failedFonts += 1;
572
606
  }
573
607
  return {
574
- verdict: overflowPx > 0 || failedFonts > 0 ? "ALERTE" : "OK",
608
+ verdict:
609
+ overflowPx > 0 || totalScrollersX > 0 || failedFonts > 0
610
+ ? "ALERTE"
611
+ : "OK",
575
612
  horizontalOverflow: { present: overflowPx > 0, overflowPx },
613
+ horizontalScrollers: { total: totalScrollersX, examples: scrollersX },
576
614
  elementsOutsideViewport: { total: totalOutside, examples: outsideViewport },
577
615
  fonts: {
578
616
  status: document.fonts.status,
@@ -659,7 +697,16 @@ async function measurePage(args) {
659
697
  s.getAttribute("src"),
660
698
  ),
661
699
  probes: args.probes.map(({ label, sel }) => {
662
- const el = document.querySelector(sel);
700
+ // `hôte >>> sélecteur` traverse un Shadow DOM ouvert (un widget isolé,
701
+ // une barre de débogage) : chaque segment se cherche dans la racine
702
+ // fantôme de l'élément trouvé par le précédent.
703
+ let el = null;
704
+ let scope = document;
705
+ for (const part of sel.split(">>>")) {
706
+ el = scope?.querySelector(part.trim()) ?? null;
707
+ scope = el?.shadowRoot ?? null;
708
+ if (el === null) break;
709
+ }
663
710
  if (!el) return { label, absent: true, selector: sel };
664
711
  const cs = getComputedStyle(el);
665
712
  const r = el.getBoundingClientRect();
@@ -789,7 +836,7 @@ if (active.has("reseau")) {
789
836
  const slowThreshold = Number(process.env.NF_BROWSER_SEUIL_LENT ?? 1000);
790
837
  const items = await Promise.all(
791
838
  finishedRequests.map(async (rq) => {
792
- let bytes = null;
839
+ let bytes;
793
840
  try {
794
841
  // `sizes()` rend la taille RÉELLEMENT transférée ; l'en-tête
795
842
  // content-length manque sur les réponses en flux.
@@ -240,6 +240,44 @@ export function parseColorScheme(raw) {
240
240
  return { schema: null, invalid: v };
241
241
  }
242
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
+
243
281
  /**
244
282
  * Arguments de lancement qui imposent le schéma de couleurs AU MOTEUR.
245
283
  *
@@ -483,6 +521,21 @@ export function median(values) {
483
521
  const m = Math.floor(sorted.length / 2);
484
522
  return sorted.length % 2 === 1 ? sorted[m] : (sorted[m - 1] + sorted[m]) / 2;
485
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
+ }
486
539
  /**
487
540
  * Résume un rapport axe-core en un bloc lisible — le tri éditorial, pas la mesure.
488
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) =>