@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.
- package/README.md +151 -5
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorateParam.js +1 -2
- package/dist/index.js +24 -11
- package/dist/nodefony/config/config.js +16 -137
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/controllers/DevkitController.js +7 -14
- package/dist/nodefony/controllers/McpController.js +27 -163
- package/dist/nodefony/controllers/ProfileSessionController.js +47 -0
- package/dist/nodefony/mcp/observe.js +235 -0
- package/dist/nodefony/mcp/profileReport.js +242 -0
- package/dist/nodefony/mcp/profileRoute.js +521 -0
- package/dist/nodefony/mcp/shotClient.js +84 -0
- package/dist/nodefony/mcp/shutdown.js +44 -0
- package/dist/nodefony/mcp/tools.js +696 -0
- package/dist/nodefony/service/DevkitService.js +93 -29
- package/dist/nodefony/src/card.js +1 -2
- package/dist/nodefony/src/errors/DevkitError.js +1 -2
- package/dist/nodefony/src/profileSession.js +14 -0
- package/dist/types/index.d.ts +15 -1
- package/dist/types/nodefony/config/config.d.ts +1 -1
- package/dist/types/nodefony/controllers/McpController.d.ts +13 -17
- package/dist/types/nodefony/controllers/ProfileSessionController.d.ts +31 -0
- package/dist/types/nodefony/interfaces/IDevkitService.d.ts +22 -1
- package/dist/types/nodefony/mcp/observe.d.ts +83 -0
- package/dist/types/nodefony/mcp/profileReport.d.ts +138 -0
- package/dist/types/nodefony/mcp/profileRoute.d.ts +87 -0
- package/dist/types/nodefony/mcp/shotClient.d.ts +53 -0
- package/dist/types/nodefony/mcp/shutdown.d.ts +25 -0
- package/dist/types/nodefony/mcp/tools.d.ts +109 -0
- package/dist/types/nodefony/service/DevkitService.d.ts +41 -4
- package/dist/types/nodefony/src/profileSession.d.ts +12 -0
- package/docs/index.md +217 -20
- package/package.json +17 -12
- package/skills/nodefony-add-crud/SKILL.md +5 -0
- package/skills/nodefony-add-realtime-channel/SKILL.md +66 -9
- package/skills/nodefony-add-service/SKILL.md +14 -3
- package/skills/nodefony-authentication/SKILL.md +234 -0
- package/skills/nodefony-browser/SKILL.md +65 -21
- package/skills/nodefony-browser/references/socket.md +13 -4
- package/skills/nodefony-browser/references/sondes.md +13 -0
- package/skills/nodefony-browser/scripts/audit.mjs +59 -10
- package/skills/nodefony-browser/scripts/inspect.mjs +123 -5
- package/skills/nodefony-browser/scripts/lib/browser.mjs +112 -6
- package/skills/nodefony-browser/scripts/lib/probes.mjs +219 -7
- package/skills/nodefony-browser/scripts/socket.mjs +70 -4
- package/skills/nodefony-browser/scripts/watch.mjs +101 -0
- package/skills/nodefony-dev/SKILL.md +78 -82
- package/skills/nodefony-dev/scripts/docs.mjs +24 -8
- package/skills/nodefony-devops/SKILL.md +98 -74
- package/skills/nodefony-devops/references/compose.md +8 -0
- package/skills/nodefony-devops/references/dimensionnement.md +122 -0
- package/skills/nodefony-devops/references/frontal.md +282 -48
- package/skills/nodefony-devops/references/kubernetes.md +41 -14
- package/skills/nodefony-devops/references/secrets.md +30 -5
- package/skills/nodefony-devops/references/variables.md +66 -31
- package/skills/nodefony-migrate-schema/SKILL.md +5 -2
- package/skills/nodefony-migrate-schema/references/verdicts.md +20 -19
- package/skills/nodefony-protect-route/SKILL.md +124 -21
- package/skills/nodefony-security-review/SKILL.md +127 -0
- package/skills/nodefony-security-review/scripts/review-diff.mjs +1090 -0
- package/skills/nodefony-troubleshoot/SKILL.md +225 -0
- package/skills/nodefony-troubleshoot/scripts/log-errors.mjs +465 -0
- package/skills/nodefony-upgrade/SKILL.md +130 -0
- 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
|
|
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
|
-
*
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|