@nodefony/devkit 10.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +318 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateParam.js +8 -0
  6. package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorate.js +9 -0
  7. package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorateMetadata.js +6 -0
  8. package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorateParam.js +8 -0
  9. package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorate.js +9 -0
  10. package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorateMetadata.js +6 -0
  11. package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorateParam.js +8 -0
  12. package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorate.js +9 -0
  13. package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorateMetadata.js +6 -0
  14. package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorateParam.js +8 -0
  15. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  16. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  17. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateParam.js +8 -0
  18. package/dist/index.js +47 -0
  19. package/dist/nodefony/command/CardCommand.js +70 -0
  20. package/dist/nodefony/config/config.js +200 -0
  21. package/dist/nodefony/config/defineModuleConfig.js +36 -0
  22. package/dist/nodefony/controllers/DevkitController.js +60 -0
  23. package/dist/nodefony/controllers/McpController.js +223 -0
  24. package/dist/nodefony/controllers/OAuthMetadataController.js +89 -0
  25. package/dist/nodefony/interfaces/IDevkitService.js +1 -0
  26. package/dist/nodefony/interfaces/index.js +1 -0
  27. package/dist/nodefony/service/DevkitService.js +198 -0
  28. package/dist/nodefony/src/card.js +2 -0
  29. package/dist/nodefony/src/errors/DevkitError.js +21 -0
  30. package/dist/nodefony/src/mcp/guard.js +51 -0
  31. package/dist/nodefony/src/mcp/protocol.js +127 -0
  32. package/dist/nodefony/src/mcp/server.js +133 -0
  33. package/dist/nodefony/src/mcp/tools.js +163 -0
  34. package/dist/types/index.d.ts +52 -0
  35. package/dist/types/nodefony/command/CardCommand.d.ts +33 -0
  36. package/dist/types/nodefony/config/config.d.ts +25 -0
  37. package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
  38. package/dist/types/nodefony/controllers/DevkitController.d.ts +37 -0
  39. package/dist/types/nodefony/controllers/McpController.d.ts +70 -0
  40. package/dist/types/nodefony/controllers/OAuthMetadataController.d.ts +39 -0
  41. package/dist/types/nodefony/interfaces/IDevkitService.d.ts +69 -0
  42. package/dist/types/nodefony/interfaces/index.d.ts +1 -0
  43. package/dist/types/nodefony/service/DevkitService.d.ts +143 -0
  44. package/dist/types/nodefony/src/card.d.ts +18 -0
  45. package/dist/types/nodefony/src/errors/DevkitError.d.ts +14 -0
  46. package/dist/types/nodefony/src/mcp/guard.d.ts +66 -0
  47. package/dist/types/nodefony/src/mcp/protocol.d.ts +139 -0
  48. package/dist/types/nodefony/src/mcp/server.d.ts +48 -0
  49. package/dist/types/nodefony/src/mcp/tools.d.ts +81 -0
  50. package/docs/index.md +358 -0
  51. package/package.json +77 -0
  52. package/skills/nodefony-add-crud/SKILL.md +199 -0
  53. package/skills/nodefony-add-realtime-channel/SKILL.md +95 -0
  54. package/skills/nodefony-add-service/SKILL.md +90 -0
  55. package/skills/nodefony-browser/SKILL.md +416 -0
  56. package/skills/nodefony-browser/references/socket.md +115 -0
  57. package/skills/nodefony-browser/references/sondes.md +175 -0
  58. package/skills/nodefony-browser/scripts/audit.mjs +169 -0
  59. package/skills/nodefony-browser/scripts/inspect.mjs +903 -0
  60. package/skills/nodefony-browser/scripts/lib/browser.mjs +357 -0
  61. package/skills/nodefony-browser/scripts/lib/probes.mjs +501 -0
  62. package/skills/nodefony-browser/scripts/lib/wcag.mjs +153 -0
  63. package/skills/nodefony-browser/scripts/socket.mjs +354 -0
  64. package/skills/nodefony-browser/scripts/watch.mjs +125 -0
  65. package/skills/nodefony-migrate-schema/SKILL.md +359 -0
  66. package/skills/nodefony-migrate-schema/references/verdicts.md +139 -0
  67. package/skills/nodefony-protect-route/SKILL.md +195 -0
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Pilote un socket applicatif Nodefony DE BOUT EN BOUT, depuis une vraie page :
3
+ * accueil, abonnement à un canal, action RPC, latence aller-retour, pont API,
4
+ * reconnexion — et rend un verdict par étape.
5
+ *
6
+ * Le scénario s'exécute DANS la page (WebSocket du navigateur) : la connexion
7
+ * porte les cookies de session et l'Origin réels — un client Node « à côté »
8
+ * n'aurait ni l'un ni l'autre, et l'on croirait à un refus d'authentification
9
+ * là où il n'y a qu'un décor faux. Le protocole parlé est celui du fil :
10
+ * JSON-RPC 2.0, tel que documenté dans `references/socket.md` du skill.
11
+ *
12
+ * `@usage` docker exec <app>-browser node /app/see-screen/socket.mjs /chat/realtime
13
+ * `@env` NF_BROWSER_BASE origine vue DEPUIS le conteneur (défaut https://host.docker.internal:5152)
14
+ * `@env` NF_BROWSER_PAGE page ouverte AVANT le socket (défaut /) — c'est elle qui porte cookies et Origin
15
+ * `@env` NF_BROWSER_SOCKET chemin du endpoint WebSocket (ou 1er argument) — REQUIS, rien n'est deviné
16
+ * `@env` NF_BROWSER_LOGIN chemin du formulaire de connexion — requis dès qu'un identifiant est donné
17
+ * `@env` NF_BROWSER_USER identifiant ; sans lui, aucune authentification n'est tentée
18
+ * `@env` NF_BROWSER_PASSWORD mot de passe associé
19
+ * `@env` NF_BROWSER_CHANNEL canal à écouter (défaut : le PREMIER canal annoncé par l'accueil)
20
+ * `@env` NF_BROWSER_ACTION action RPC à appeler (facultatif — la latence la réutilise)
21
+ * `@env` NF_BROWSER_ACTION_PARAMS paramètres JSON de l'action (défaut : aucun)
22
+ * `@env` NF_BROWSER_API chemin d'une route à rejouer par le pont `api.request` (facultatif)
23
+ * `@env` NF_BROWSER_SOCKET_WAIT fenêtre d'écoute du canal en ms (défaut 4000)
24
+ * `@env` NF_BROWSER_PINGS nombre de mesures de latence (défaut 5)
25
+ * `@requires` conteneur du profil `browser` démarré · serveur joignable · un endpoint temps réel exposé
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
28
+ */
29
+ import { open, goTo } from "./lib/browser.mjs";
30
+ import { median } from "./lib/probes.mjs";
31
+
32
+ const ENDPOINT = process.argv[2] ?? process.env.NF_BROWSER_SOCKET ?? "";
33
+ if (!ENDPOINT.startsWith("/")) {
34
+ // Rien n'est deviné : un endpoint temps réel est une route de TON
35
+ // application. Une supposition enverrait la sonde ouvrir un socket sur une
36
+ // route inexistante et conclure à une panne du serveur.
37
+ console.error(
38
+ "Donne le chemin du endpoint WebSocket (1er argument ou NF_BROWSER_SOCKET), par exemple /chat/realtime.",
39
+ );
40
+ process.exit(64); // EX_USAGE
41
+ }
42
+
43
+ let actionParams = null;
44
+ if (process.env.NF_BROWSER_ACTION_PARAMS) {
45
+ try {
46
+ actionParams = JSON.parse(process.env.NF_BROWSER_ACTION_PARAMS);
47
+ } catch (e) {
48
+ console.error(
49
+ `NF_BROWSER_ACTION_PARAMS n'est pas du JSON : ${String(e).slice(0, 120)}`,
50
+ );
51
+ process.exit(64);
52
+ }
53
+ }
54
+
55
+ const cfg = {
56
+ endpoint: ENDPOINT,
57
+ channel: process.env.NF_BROWSER_CHANNEL ?? "",
58
+ action: process.env.NF_BROWSER_ACTION ?? "",
59
+ actionParams,
60
+ api: process.env.NF_BROWSER_API ?? "",
61
+ waitMs: Number(process.env.NF_BROWSER_SOCKET_WAIT ?? 4000),
62
+ pings: Math.max(1, Number(process.env.NF_BROWSER_PINGS ?? 5)),
63
+ timeoutMs: 8000,
64
+ maxPushes: 10,
65
+ truncate: 200,
66
+ };
67
+
68
+ /**
69
+ * Le scénario complet, exécuté dans la page — AUTOSUFFISANT (sérialisé par le
70
+ * pilote, aucune fermeture sur ce module). Chaque attente est bornée : une
71
+ * étape qui ne vient pas rend un verdict, jamais une sonde suspendue.
72
+ */
73
+ async function scenarioSocket(conf) {
74
+ const t0 = performance.now();
75
+ const a = () => Math.round(performance.now() - t0);
76
+ const url = location.origin.replace(/^http/, "ws") + conf.endpoint;
77
+ const result = {
78
+ endpoint: url,
79
+ welcome: null,
80
+ subscription: null,
81
+ latency: null,
82
+ api: null,
83
+ reconnection: null,
84
+ };
85
+ let idCounter = 0;
86
+ const pending = new Map();
87
+ const pushes = [];
88
+ const denials = [];
89
+ const closes = [];
90
+
91
+ // Ouvre un socket et attend l'ACCUEIL — la première notification poussée par
92
+ // le serveur (méthode `realtime:welcome`). C'est la carte du territoire :
93
+ // canaux, actions, identité résolue. Tant qu'il n'est pas là, rien d'autre
94
+ // n'a de sens.
95
+ const openSocket = () =>
96
+ new Promise((resolve, reject) => {
97
+ const ws = new WebSocket(url);
98
+ const guard = setTimeout(() => {
99
+ try {
100
+ ws.close();
101
+ } catch {}
102
+ reject(
103
+ new Error(`accueil jamais reçu en ${conf.timeoutMs} ms sur ${url}`),
104
+ );
105
+ }, conf.timeoutMs);
106
+ ws.addEventListener("close", (ev) => {
107
+ closes.push({ a: a(), code: ev.code, reason: ev.reason });
108
+ });
109
+ ws.addEventListener("message", (ev) => {
110
+ let frame;
111
+ try {
112
+ frame = JSON.parse(ev.data);
113
+ } catch {
114
+ return; // une frame illisible n'est pas à nous de la juger ici
115
+ }
116
+ if (frame.method === "realtime:welcome") {
117
+ clearTimeout(guard);
118
+ resolve({ ws, welcome: frame.params ?? {} });
119
+ return;
120
+ }
121
+ if (frame.method === "realtime:denied") {
122
+ // Le refus d'une notification n'a pas de canal de réponse : le
123
+ // serveur le rend OBSERVABLE par cette notification dédiée. Sans
124
+ // elle, « zéro poussée » se lirait comme un canal silencieux.
125
+ denials.push({ a: a(), ...frame.params });
126
+ return;
127
+ }
128
+ if (frame.id != null && frame.method === undefined) {
129
+ // `frame.id` arrive du fil : il ne sert de clé qu'après avoir été
130
+ // reconnu pour ce que NOUS émettons — un entier de `compteurId`. Et
131
+ // le rappel retrouvé n'est appelé qu'une fois CONSTATÉ appelable.
132
+ // Une `Map` n'expose aucun prototype, donc l'exécution ne risquait
133
+ // rien ; c'est l'analyse statique qui ne pouvait pas le savoir, et
134
+ // une garde de type dit l'intention aussi bien qu'elle la prouve.
135
+ const waiter =
136
+ typeof frame.id === "number" ? pending.get(frame.id) : undefined;
137
+ if (typeof waiter === "function") {
138
+ pending.delete(frame.id);
139
+ waiter(frame);
140
+ }
141
+ return;
142
+ }
143
+ if (frame.method) {
144
+ pushes.push({
145
+ a: a(),
146
+ method: frame.method,
147
+ payload: JSON.stringify(frame.params ?? null).slice(
148
+ 0,
149
+ conf.truncate,
150
+ ),
151
+ });
152
+ }
153
+ });
154
+ });
155
+
156
+ // Une REQUÊTE corrélée : `id` attribué, réponse attendue, latence mesurée.
157
+ // L'expiration rend un verdict local — aucun octet de plus sur le fil.
158
+ const request = (ws, method, params, deadlineMs) =>
159
+ new Promise((resolve) => {
160
+ const id = ++idCounter;
161
+ const start = performance.now();
162
+ // Une seule sortie, quel que soit le vainqueur de la course entre la
163
+ // réponse et l'expiration : `clearTimeout` suffirait à l'exécution, mais
164
+ // un verrou explicite dit l'intention à qui relit — et à l'analyseur.
165
+ let settled = false;
166
+ const settle = (value) => {
167
+ if (settled) return;
168
+ settled = true;
169
+ resolve({ ...value, ms: Math.round(performance.now() - start) });
170
+ };
171
+ const guard = setTimeout(() => {
172
+ pending.delete(id);
173
+ settle({ timedOut: true });
174
+ }, deadlineMs);
175
+ pending.set(id, (frame) => {
176
+ clearTimeout(guard);
177
+ settle({ frame });
178
+ });
179
+ const frame = { jsonrpc: "2.0", id, method };
180
+ if (params !== null && params !== undefined) frame.params = params;
181
+ ws.send(JSON.stringify(frame));
182
+ });
183
+
184
+ const { ws, welcome } = await openSocket();
185
+ result.welcome = {
186
+ receivedAfterMs: a(),
187
+ protocol: welcome.protocol ?? null,
188
+ channels: welcome.channels ?? [],
189
+ methods: welcome.methods ?? [],
190
+ identity: welcome.identity ?? null,
191
+ };
192
+
193
+ // ── Abonnement — notification SANS id : avec un id elle serait classée
194
+ // requête, ne trouverait aucun handler, et récolterait un -32601. ──────────
195
+ const channel = conf.channel || (welcome.channels ?? [])[0] || null;
196
+ if (channel) {
197
+ ws.send(
198
+ JSON.stringify({
199
+ jsonrpc: "2.0",
200
+ method: "subscribe",
201
+ params: { channel },
202
+ }),
203
+ );
204
+ await new Promise((r) => setTimeout(r, conf.waitMs));
205
+ const received = pushes.filter((p) => p.method === channel);
206
+ const channelDenial = denials.find((r2) => r2.channel === channel) ?? null;
207
+ result.subscription = {
208
+ channel,
209
+ windowMs: conf.waitMs,
210
+ total: received.length,
211
+ pushes: received.slice(0, conf.maxPushes),
212
+ denial: channelDenial,
213
+ // « SILENCIEUX » n'est pas « cassé » : un canal d'événements ne pousse
214
+ // que quand il se passe quelque chose. Le refus, lui, est un verdict.
215
+ verdict: channelDenial
216
+ ? "REFUSÉ"
217
+ : received.length > 0
218
+ ? "OK"
219
+ : "SILENCIEUX — aucune poussée dans la fenêtre, pas forcément une panne",
220
+ };
221
+ ws.send(
222
+ JSON.stringify({
223
+ jsonrpc: "2.0",
224
+ method: "unsubscribe",
225
+ params: { channel },
226
+ }),
227
+ );
228
+ } else {
229
+ result.subscription = {
230
+ verdict: "AUCUN CANAL — rien d'annoncé par l'accueil, rien de demandé",
231
+ };
232
+ }
233
+
234
+ // ── Latence — sur une méthode CORRÉLÉE uniquement. La notification `ping`
235
+ // du battement de cœur est un no-op serveur : aucun pong n'en revient. ─────
236
+ const latencyMethod = conf.action || (conf.api ? "api.request" : "");
237
+ const latencyParams = conf.action
238
+ ? conf.actionParams
239
+ : conf.api
240
+ ? { path: conf.api }
241
+ : null;
242
+ if (latencyMethod) {
243
+ const measuresMs = [];
244
+ let timeouts = 0;
245
+ let lastError = null;
246
+ for (let i = 0; i < conf.pings; i++) {
247
+ const rep = await request(
248
+ ws,
249
+ latencyMethod,
250
+ latencyParams,
251
+ conf.timeoutMs,
252
+ );
253
+ measuresMs.push(rep.ms);
254
+ if (rep.timedOut) timeouts += 1;
255
+ else if (rep.frame.error) lastError = rep.frame.error;
256
+ }
257
+ result.latency = {
258
+ method: latencyMethod,
259
+ measuresMs,
260
+ timeouts,
261
+ error: lastError,
262
+ // Une erreur corrélée reste un aller-retour COMPLET : la latence est
263
+ // mesurée, mais le verdict la nomme — un RTT sur -32601 ne valide pas
264
+ // l'action, seulement le fil.
265
+ verdict:
266
+ timeouts > 0
267
+ ? "EXPIRATIONS"
268
+ : lastError
269
+ ? `RÉPOND EN ERREUR ${lastError.code} — ${String(lastError.message).slice(0, 80)}`
270
+ : "OK",
271
+ };
272
+ } else {
273
+ result.latency = {
274
+ verdict:
275
+ "NON MESURÉE — aucune méthode corrélée fournie (NF_BROWSER_ACTION ou NF_BROWSER_API)",
276
+ };
277
+ }
278
+
279
+ // ── Pont API — la même route qu'en HTTP, rejouée sur le socket ─────────────
280
+ if (conf.api) {
281
+ const rep = await request(
282
+ ws,
283
+ "api.request",
284
+ { path: conf.api },
285
+ conf.timeoutMs,
286
+ );
287
+ result.api = rep.timedOut
288
+ ? { path: conf.api, verdict: "EXPIRÉE" }
289
+ : {
290
+ path: conf.api,
291
+ ms: rep.ms,
292
+ verdict: rep.frame.error
293
+ ? `ERREUR ${rep.frame.error.code} — ${String(rep.frame.error.message).slice(0, 80)}`
294
+ : "OK",
295
+ meta: rep.frame.meta ?? null,
296
+ excerpt: JSON.stringify(
297
+ rep.frame.result ?? rep.frame.error ?? null,
298
+ ).slice(0, conf.truncate),
299
+ };
300
+ }
301
+
302
+ // ── Reconnexion — fermer, rouvrir, comparer l'identité ─────────────────────
303
+ // L'identité est résolue au HANDSHAKE, jamais dans les frames : si elle
304
+ // survit à la reconnexion, c'est que le cookie de session la porte — la
305
+ // preuve qui compte pour une application qui reconnecte en production.
306
+ const closedAt = a();
307
+ ws.close(1000, "reconnexion volontaire");
308
+ try {
309
+ const second = await openSocket();
310
+ const before = result.welcome.identity;
311
+ const after = second.welcome.identity ?? null;
312
+ result.reconnection = {
313
+ closedAt,
314
+ reopenedAfterMs: a() - closedAt,
315
+ sameIdentity:
316
+ (before?.userIdentifier ?? null) === (after?.userIdentifier ?? null),
317
+ verdict: "OK",
318
+ };
319
+ second.ws.close(1000, "fin de scénario");
320
+ } catch (e) {
321
+ result.reconnection = {
322
+ closedAt,
323
+ verdict: `ÉCHEC — ${String(e && e.message ? e.message : e).slice(0, 140)}`,
324
+ };
325
+ }
326
+ result.closes = closes;
327
+ return result;
328
+ }
329
+
330
+ const { browser, ctx, page, reuse } = await open();
331
+ await goTo(page, ctx, process.env.NF_BROWSER_PAGE ?? "/", reuse);
332
+
333
+ let result;
334
+ try {
335
+ result = await page.evaluate(scenarioSocket, cfg);
336
+ } catch (e) {
337
+ // L'accueil qui ne vient jamais est LE symptôme à diagnostiquer en premier :
338
+ // endpoint faux, page non authentifiée, ou Origin refusé par le serveur.
339
+ console.error(
340
+ `Scénario interrompu : ${String(e && e.message ? e.message : e).slice(0, 300)}\n` +
341
+ `→ vérifier le chemin du endpoint, l'authentification (NF_BROWSER_USER + NF_BROWSER_LOGIN), et que la page ${process.env.NF_BROWSER_PAGE ?? "/"} appartient bien à la même origine.`,
342
+ );
343
+ await browser.close();
344
+ process.exit(65); // EX_DATAERR
345
+ }
346
+
347
+ // La médiane se calcule ici, avec la fonction que les tests éprouvent — la
348
+ // moyenne serait déplacée par un seul aller-retour aberrant.
349
+ if (Array.isArray(result.latency?.measuresMs)) {
350
+ result.latency.medianMs = median(result.latency.measuresMs);
351
+ }
352
+
353
+ console.log(JSON.stringify(result, null, 2));
354
+ await browser.close();
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Observe une page VIVANTE : trafic WebSocket, requêtes réseau, console, et
3
+ * arrêt sur CONDITION applicative.
4
+ *
5
+ * Complète `inspect.mjs`, qui photographie un instant. Celui-ci regarde ce qui
6
+ * se PASSE — indispensable pour un framework dont le temps réel est le cœur :
7
+ * une frame qui n'arrive pas, un canal qui pousse trop, une reconnexion en
8
+ * boucle ne se voient sur aucune capture d'écran.
9
+ *
10
+ * `@usage` docker exec <app>-browser node /app/watch.mjs /tableau-de-bord 8000
11
+ * `@env` NF_BROWSER_BASE origine vue DEPUIS le conteneur (défaut https://host.docker.internal:5152)
12
+ * `@env` NF_BROWSER_LOGIN chemin du formulaire de connexion de TON application — requis dès qu'un identifiant est donné, aucun défaut n'est deviné
13
+ * `@env` NF_BROWSER_USER identifiant ; sans lui, aucune authentification n'est tentée
14
+ * `@env` NF_BROWSER_PASSWORD mot de passe associé
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_MAXFRAMES nombre de frames WebSocket conservées par sens (défaut 12)
17
+ * `@requires` conteneur du profil `browser` démarré · serveur Nodefony joignable
18
+ * `@output` JSON : sockets et leurs frames (horodatées), requêtes non-2xx, erreurs console, verdict de la condition
19
+ */
20
+ import { open, goTo } from "./lib/browser.mjs";
21
+
22
+ const PAGE = process.argv[2] ?? process.env.NF_BROWSER_PAGE ?? "/";
23
+ const DURATION = Number(process.argv[3] ?? 8000);
24
+ const UNTIL = process.env.NF_BROWSER_UNTIL ?? "";
25
+ const MAX = Number(process.env.NF_BROWSER_MAXFRAMES ?? 12);
26
+
27
+ const { browser, ctx, page, reuse } = await open();
28
+
29
+ const t0 = Date.now();
30
+ const at = () => Date.now() - t0;
31
+
32
+ // ── Ce qu'on observe ────────────────────────────────────────────────────────
33
+ const sockets = [];
34
+ const httpErrors = [];
35
+ const consoleErrors = [];
36
+ const uncaughtErrors = [];
37
+
38
+ // Les frames sont TRONQUÉES et PLAFONNÉES : un canal temps réel pousse plus vite
39
+ // qu'on ne lit, et une sortie de plusieurs mégaoctets serait illisible — donc
40
+ // inexploitable pour décider. On garde les premières de chaque sens.
41
+ page.on("websocket", (ws) => {
42
+ const rec = { url: ws.url(), openedAt: at(), sent: [], received: [] };
43
+ sockets.push(rec);
44
+ ws.on("framesent", (f) => {
45
+ if (rec.sent.length < MAX)
46
+ rec.sent.push({ a: at(), payload: String(f.payload).slice(0, 160) });
47
+ });
48
+ ws.on("framereceived", (f) => {
49
+ if (rec.received.length < MAX)
50
+ rec.received.push({ a: at(), payload: String(f.payload).slice(0, 160) });
51
+ });
52
+ ws.on("close", () => (rec.closedAt = at()));
53
+ ws.on("socketerror", (e) => (rec.error = String(e)));
54
+ });
55
+
56
+ page.on("response", (r) => {
57
+ if (r.status() >= 400)
58
+ httpErrors.push({
59
+ a: at(),
60
+ status: r.status(),
61
+ url: r.url().slice(0, 120),
62
+ });
63
+ });
64
+ page.on("console", (m) => {
65
+ if (m.type() === "error")
66
+ consoleErrors.push({ a: at(), text: m.text().slice(0, 200) });
67
+ });
68
+ // `pageerror` en plus de la console : une exception non capturée qui tue
69
+ // l'application ne passe pas toujours par console.error.
70
+ page.on("pageerror", (e) => {
71
+ if (uncaughtErrors.length < 20)
72
+ uncaughtErrors.push({ a: at(), text: String(e).slice(0, 200) });
73
+ });
74
+
75
+ await goTo(page, ctx, PAGE, reuse);
76
+
77
+ // ── Le « point d'arrêt » : une CONDITION, pas une ligne de code ──────────────
78
+ // `waitForFunction` réévalue l'expression dans la page à chaque animation frame.
79
+ // C'est ce qui remplace utilement un breakpoint dans un pilotage automatisé :
80
+ // on ne suspend pas l'exécution, on attend un ÉTAT — « le compteur a bougé »,
81
+ // « le socket est connecté », « la table contient 3 lignes ». Sans condition, on
82
+ // observe simplement pendant la durée demandée.
83
+ let verdict = "durée écoulée";
84
+ if (UNTIL) {
85
+ // ⚠️ Une chaîne passée à `waitForFunction` est évaluée comme une EXPRESSION.
86
+ // Donner « () => x » y définit une fonction sans jamais l'appeler : l'objet
87
+ // fonction est truthy, donc l'attente réussit TOUJOURS — y compris sur une
88
+ // condition impossible. Faux vert vécu, découvert seulement en éprouvant le
89
+ // sens négatif. On invoque donc explicitement les formes fonction.
90
+ const expr = /^\s*(\(|function\b|async\b)/.test(UNTIL)
91
+ ? `(${UNTIL})()`
92
+ : UNTIL;
93
+ try {
94
+ await page.waitForFunction(expr, null, { timeout: DURATION });
95
+ verdict = `condition VRAIE après ${at()} ms`;
96
+ } catch {
97
+ verdict = `condition JAMAIS vraie en ${DURATION} ms — ${UNTIL}`;
98
+ }
99
+ } else {
100
+ await page.waitForTimeout(DURATION);
101
+ }
102
+
103
+ console.log(
104
+ JSON.stringify(
105
+ {
106
+ url: page.url(),
107
+ observeForMs: at(),
108
+ verdict,
109
+ // Les totaux sont posés sur l'objet accumulé plutôt que recomposés par
110
+ // étalement dans un `map` (règle `no-map-spread` : allocation inutile).
111
+ sockets: sockets.map((s) =>
112
+ Object.assign(s, {
113
+ totalSent: s.sent.length,
114
+ totalReceived: s.received.length,
115
+ }),
116
+ ),
117
+ httpErrors,
118
+ consoleErrors,
119
+ uncaughtErrors,
120
+ },
121
+ null,
122
+ 2,
123
+ ),
124
+ );
125
+ await browser.close();