@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.
- package/LICENSE +544 -0
- package/README.md +318 -0
- package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateParam.js +8 -0
- package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorateParam.js +8 -0
- package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorateParam.js +8 -0
- package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorateParam.js +8 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateParam.js +8 -0
- package/dist/index.js +47 -0
- package/dist/nodefony/command/CardCommand.js +70 -0
- package/dist/nodefony/config/config.js +200 -0
- package/dist/nodefony/config/defineModuleConfig.js +36 -0
- package/dist/nodefony/controllers/DevkitController.js +60 -0
- package/dist/nodefony/controllers/McpController.js +223 -0
- package/dist/nodefony/controllers/OAuthMetadataController.js +89 -0
- package/dist/nodefony/interfaces/IDevkitService.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/service/DevkitService.js +198 -0
- package/dist/nodefony/src/card.js +2 -0
- package/dist/nodefony/src/errors/DevkitError.js +21 -0
- package/dist/nodefony/src/mcp/guard.js +51 -0
- package/dist/nodefony/src/mcp/protocol.js +127 -0
- package/dist/nodefony/src/mcp/server.js +133 -0
- package/dist/nodefony/src/mcp/tools.js +163 -0
- package/dist/types/index.d.ts +52 -0
- package/dist/types/nodefony/command/CardCommand.d.ts +33 -0
- package/dist/types/nodefony/config/config.d.ts +25 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
- package/dist/types/nodefony/controllers/DevkitController.d.ts +37 -0
- package/dist/types/nodefony/controllers/McpController.d.ts +70 -0
- package/dist/types/nodefony/controllers/OAuthMetadataController.d.ts +39 -0
- package/dist/types/nodefony/interfaces/IDevkitService.d.ts +69 -0
- package/dist/types/nodefony/interfaces/index.d.ts +1 -0
- package/dist/types/nodefony/service/DevkitService.d.ts +143 -0
- package/dist/types/nodefony/src/card.d.ts +18 -0
- package/dist/types/nodefony/src/errors/DevkitError.d.ts +14 -0
- package/dist/types/nodefony/src/mcp/guard.d.ts +66 -0
- package/dist/types/nodefony/src/mcp/protocol.d.ts +139 -0
- package/dist/types/nodefony/src/mcp/server.d.ts +48 -0
- package/dist/types/nodefony/src/mcp/tools.d.ts +81 -0
- package/docs/index.md +358 -0
- package/package.json +77 -0
- package/skills/nodefony-add-crud/SKILL.md +199 -0
- package/skills/nodefony-add-realtime-channel/SKILL.md +95 -0
- package/skills/nodefony-add-service/SKILL.md +90 -0
- package/skills/nodefony-browser/SKILL.md +416 -0
- package/skills/nodefony-browser/references/socket.md +115 -0
- package/skills/nodefony-browser/references/sondes.md +175 -0
- package/skills/nodefony-browser/scripts/audit.mjs +169 -0
- package/skills/nodefony-browser/scripts/inspect.mjs +903 -0
- package/skills/nodefony-browser/scripts/lib/browser.mjs +357 -0
- package/skills/nodefony-browser/scripts/lib/probes.mjs +501 -0
- package/skills/nodefony-browser/scripts/lib/wcag.mjs +153 -0
- package/skills/nodefony-browser/scripts/socket.mjs +354 -0
- package/skills/nodefony-browser/scripts/watch.mjs +125 -0
- package/skills/nodefony-migrate-schema/SKILL.md +359 -0
- package/skills/nodefony-migrate-schema/references/verdicts.md +139 -0
- 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();
|