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

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 (38) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorateParam.js +1 -1
  3. package/dist/index.js +3 -3
  4. package/dist/nodefony/controllers/DevkitController.js +1 -1
  5. package/dist/nodefony/controllers/McpController.js +2 -2
  6. package/dist/nodefony/controllers/ProfileSessionController.js +1 -1
  7. package/dist/nodefony/mcp/profileRoute.js +28 -9
  8. package/dist/nodefony/service/DevkitService.js +6 -6
  9. package/docs/index.md +1 -1
  10. package/package.json +13 -13
  11. package/skills/nodefony-add-crud/SKILL.md +38 -23
  12. package/skills/nodefony-add-grpc-service/SKILL.md +179 -0
  13. package/skills/nodefony-add-grpc-service/scripts/call.mjs +218 -0
  14. package/skills/nodefony-add-realtime-binary/SKILL.md +301 -0
  15. package/skills/nodefony-add-realtime-binary/references/messages.md +20 -0
  16. package/skills/nodefony-add-realtime-binary/references/mesures.md +59 -0
  17. package/skills/nodefony-add-realtime-channel/SKILL.md +452 -105
  18. package/skills/nodefony-add-realtime-channel/references/contrat.md +22 -0
  19. package/skills/nodefony-add-realtime-channel/references/production.md +140 -0
  20. package/skills/nodefony-add-realtime-presence/SKILL.md +328 -0
  21. package/skills/nodefony-authentication/SKILL.md +2 -2
  22. package/skills/nodefony-browser/SKILL.md +37 -2
  23. package/skills/nodefony-browser/references/sondes.md +19 -0
  24. package/skills/nodefony-browser/scripts/audit.mjs +17 -2
  25. package/skills/nodefony-browser/scripts/inspect.mjs +108 -87
  26. package/skills/nodefony-browser/scripts/lib/actions.mjs +164 -0
  27. package/skills/nodefony-browser/scripts/lib/browser.mjs +67 -11
  28. package/skills/nodefony-browser/scripts/lib/probes.mjs +108 -3
  29. package/skills/nodefony-browser/scripts/watch.mjs +27 -11
  30. package/skills/nodefony-dev/SKILL.md +31 -15
  31. package/skills/nodefony-dev/references/rapport-de-build.md +19 -0
  32. package/skills/nodefony-devops/SKILL.md +28 -21
  33. package/skills/nodefony-devops/references/dimensionnement.md +1 -1
  34. package/skills/nodefony-devops/references/frontal.md +5 -2
  35. package/skills/nodefony-devops/references/image.md +23 -15
  36. package/skills/nodefony-migrate-schema/SKILL.md +110 -20
  37. package/skills/nodefony-migrate-schema/references/verdicts.md +23 -20
  38. package/skills/nodefony-protect-route/SKILL.md +27 -32
@@ -41,9 +41,11 @@ import {
41
41
  USER,
42
42
  } from "./lib/browser.mjs";
43
43
  import {
44
+ browserEngine,
44
45
  browserOrder,
45
46
  captureSlug,
46
47
  colorSchemeLaunchArgs,
48
+ stampedName,
47
49
  summarizeLighthouse,
48
50
  } from "./lib/probes.mjs";
49
51
 
@@ -104,6 +106,17 @@ const profile = mkdtempSync(path.join(tmpdir(), "nf-audit-"));
104
106
  // seul Chrome est installé — là même où `inspect.mjs` passait.
105
107
  let ctx = null;
106
108
  const failures = [];
109
+ // Lighthouse parle au navigateur par le protocole de débogage de Chromium :
110
+ // Firefox et WebKit ne l'exposent pas. Refuser en le disant, plutôt que de se
111
+ // rabattre sur Chromium et d'attribuer l'audit au moteur qu'on n'a pas lancé.
112
+ const wanted = (process.env.NF_BROWSER_ENGINE ?? "").trim();
113
+ if (wanted && browserEngine(wanted).engine !== "chromium") {
114
+ console.error(
115
+ `L'audit exige un navigateur de la famille Chromium (protocole de débogage) — « ${wanted} » n'en est pas un.\n` +
116
+ "Pour regarder la page sous ce moteur : inspect.mjs avec le même NF_BROWSER_ENGINE.",
117
+ );
118
+ process.exit(64); // EX_USAGE
119
+ }
107
120
  for (const channel of browserOrder(process.env.NF_BROWSER_ENGINE)) {
108
121
  try {
109
122
  ctx = await chromium.launchPersistentContext(profile, {
@@ -194,9 +207,11 @@ try {
194
207
  // Le rapport COMPLET est conservé : le résumé sert à décider, l'original à
195
208
  // vérifier — et à comparer dans le temps.
196
209
  mkdirSync(OUTPUT, { recursive: true });
197
- const stamp = new Date().toISOString().replace(/[:.]/gu, "-").slice(0, 19);
198
210
  const slug = captureSlug(PAGE);
199
- const fullPath = path.join(OUTPUT, `lighthouse-${slug}-${stamp}.json`);
211
+ const fullPath = path.join(
212
+ OUTPUT,
213
+ stampedName(`lighthouse-${slug}`, new Date(), process.pid, "json"),
214
+ );
200
215
  writeFileSync(fullPath, JSON.stringify(lhr), "utf8");
201
216
 
202
217
  console.log(
@@ -15,16 +15,18 @@
15
15
  * `@env` NF_BROWSER_OUT dossier des captures et de l'état d'authentification (défaut constaté de la même façon)
16
16
  * `@env` NF_BROWSER_PAGE chemin de la page à ouvrir (défaut /)
17
17
  * `@env` NF_BROWSER_EXPECT texte DISCRIMINANT attendu avant de mesurer (défaut : aucun, on mesure après domcontentloaded)
18
- * `@env` NF_BROWSER_FAMILIES familles de sondes à activer, séparées par des virgules (a11y, axe, rendu, reseau, perf, stockage, responsive — ou « toutes ») ; défaut : aucune, le socle seul
19
- * `@env` NF_BROWSER_ENGINE navigateur imposé (chromium, chrome, msedge) ; sans lui, le premier qui répond
18
+ * `@env` NF_BROWSER_FAMILIES familles de sondes à activer, séparées par des virgules (a11y, axe, rendu, reseau, perf, stockage, responsive, pli, notifications — ou « toutes ») ; défaut : aucune, le socle seul
19
+ * `@env` NF_BROWSER_AWAY `1` : la page se croit AILLEURS (onglet caché, autre application au premier plan) — pour mesurer ce qu'elle ne fait qu'en l'absence de la personne
20
+ * `@env` NF_BROWSER_ENGINE navigateur imposé (chromium, chrome, msedge, firefox, webkit) ; sans lui, le premier qui répond
20
21
  * `@env` NF_BROWSER_COLOR_SCHEME schéma de couleurs émulé (light, dark, no-preference) — un défaut peut n'exister que dans UN thème
22
+ * `@env` NF_BROWSER_REDUCED_MOTION préférence de mouvement émulée (reduce, no-preference) — `reduce` mesure au repos une page à fondus au défilement
21
23
  * `@env` NF_BROWSER_STORAGE entrées de stockage local posées AVANT chargement (`clé=valeur`) — pour une application qui MÉMORISE son thème
22
24
  * `@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é
23
25
  * `@env` NF_BROWSER_USER identifiant de connexion ; si absent, aucune authentification n'est tentée
24
26
  * `@env` NF_BROWSER_PASSWORD mot de passe associé
25
27
  * `@env` NF_BROWSER_ACTIONS séquence d'interactions AVANT mesure, séparées par `|` —
26
28
  * `verbe:cible[=valeur]` : clic (défaut), double, droit, survol,
27
- * saisir, choisir, touche, voir, defiler, attendre. Un écran qui se déplie
29
+ * saisir, choisir, deposer, touche, voir, defiler, molette, attendre. Un écran qui se déplie
28
30
  * au clic n'existe pas tant qu'on ne l'a pas ouvert.
29
31
  * `@env` NF_BROWSER_FULLPAGE 1 = capture la page ENTIÈRE (défaut : la fenêtre)
30
32
  * `@env` NF_BROWSER_PROBES sélecteurs CSS à sonder, séparés par des virgules (`libellé=sélecteur`) ;
@@ -34,11 +36,14 @@
34
36
  * `@env` NF_BROWSER_PRIMARY sélecteur de l'action principale pour la famille pli (défaut : le plus grand bouton de validation visible)
35
37
  * `@env` NF_BROWSER_SEUIL_LOURD bytes au-delà desquels une ressource est « lourde » (défaut 512000)
36
38
  * `@env` NF_BROWSER_SEUIL_LENT millisecondes au-delà desquelles une réponse est « lente » (défaut 1000)
39
+ * `@env` NF_BROWSER_EVAL expression JS évaluée DANS la page après les actions et les mesures (une forme fonction est appelée) ;
40
+ * son résultat sérialisé sort dans `evaluated` — l'état propre à l'application (store, socket)
37
41
  * `@requires` conteneur du profil `browser` démarré · serveur joignable depuis le conteneur
38
42
  * `@output` un objet JSON sur stdout + une capture PNG horodatée dans /output
39
43
  * `@exit` 0 mesure rendue (le verdict est une DONNÉE, pas un code de retour) · 64 usage (famille inconnue, identifiant sans chemin de connexion) · 65 texte attendu jamais apparu
40
44
  */
41
45
  import { existsSync, readFileSync } from "node:fs";
46
+ import { playActions } from "./lib/actions.mjs";
42
47
  import path from "node:path";
43
48
  import * as decor from "./lib/browser.mjs";
44
49
  import { open, goTo, LOGIN, OUTPUT } from "./lib/browser.mjs";
@@ -48,14 +53,18 @@ import {
48
53
  captureSlug,
49
54
  parseActions,
50
55
  parseFamilies,
56
+ signsInMidway,
51
57
  parseProbes,
58
+ stampedName,
52
59
  parseWidths,
60
+ pageExpression,
53
61
  summarizeAxe,
54
62
  verdictGlobal,
55
63
  } from "./lib/probes.mjs";
56
64
 
57
65
  const PAGE = process.argv[2] ?? process.env.NF_BROWSER_PAGE ?? "/";
58
66
  const EXPECT = process.argv[3] ?? process.env.NF_BROWSER_EXPECT ?? "";
67
+ const EVAL = pageExpression(process.env.NF_BROWSER_EVAL);
59
68
  /**
60
69
  * Ce qu'il faut FAIRE avant de mesurer — une séquence, pas un geste.
61
70
  *
@@ -77,10 +86,13 @@ const EXPECT = process.argv[3] ?? process.env.NF_BROWSER_EXPECT ?? "";
77
86
  * | `survol` | survol : révèle une infobulle, un menu déroulant |
78
87
  * | `saisir` | remplit un champ (`saisir:Nom=mon-app`) |
79
88
  * | `choisir` | choisit une option d'une liste (`choisir:Habillage=dots`) |
80
- * | `touche` | frappe une touche (`touche:Enter`, `touche:Escape`) |
89
+ * | `deposer` | joint un fichier à un champ, même caché (`deposer:input[type=file]=tmp/a.png`) |
90
+ * | `touche` | frappe une touche sur le focus (`touche:Escape`), ou sur une cible (`touche:#onglet=End`) |
81
91
  * | `voir` | amène dans la vue SANS cliquer |
82
92
  * | `defiler` | fait défiler de N pixels (`defiler:600`), page ou conteneur |
93
+ * | `molette` | une vraie molette de N pixels sous le pointeur (`survol:` avant) |
83
94
  * | `attendre` | attend qu'un texte APPARAISSE (après une action lente) |
95
+ * | `connecter` | se connecte dans un AUTRE onglet (`connecter:`), sans recharger |
84
96
  *
85
97
  * 🔴 `voir` existe parce qu'un clic n'est pas neutre : sur un formulaire, le
86
98
  * texte d'une question est un `label` — cliquer dessus COCHE la case qu'il
@@ -107,6 +119,8 @@ if (unknown.length > 0) {
107
119
  process.exit(64); // EX_USAGE
108
120
  }
109
121
  const active = new Set(kept);
122
+ /** `NF_BROWSER_AWAY=1` : la page se croit cachée et sans le focus. */
123
+ const AWAY = process.env.NF_BROWSER_AWAY === "1";
110
124
 
111
125
  /**
112
126
  * Sondes de style par défaut — surchargées par NF_BROWSER_PROBES.
@@ -146,9 +160,12 @@ async function axeSource() {
146
160
  return axe.source;
147
161
  }
148
162
 
149
- const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
150
-
151
- const { browser, ctx, page, reuse } = await open();
163
+ // `connecter:` dans la séquence ⇒ la page s'ouvre ANONYME (cf signsInMidway).
164
+ const ANONYMOUS = signsInMidway(ACTIONS);
165
+ const { browser, ctx, page, reuse } = await open({
166
+ anonymous: ANONYMOUS,
167
+ permissions: active.has("notifications") ? ["notifications"] : [],
168
+ });
152
169
 
153
170
  // ── Collecteurs — posés AVANT toute navigation ──────────────────────────────
154
171
  const consoleErrors = [];
@@ -166,6 +183,39 @@ page.on("pageerror", (e) => {
166
183
  // Les violations CSP ne sont visibles QUE depuis la page : le réseau montre la
167
184
  // requête absente, jamais la raison. L'init script est réinjecté à chaque
168
185
  // navigation — c'est l'état de la DERNIÈRE page qu'on lit, celle qu'on mesure.
186
+ // Famille notifications : chaque `new Notification()` est RELEVÉE, puis
187
+ // créée pour de vrai — la page garde son comportement, on lit ce qu'elle a
188
+ // levé. Le pilote n'affiche aucune notification : sans ce relevé, une
189
+ // notification réussie et une notification jamais levée se ressemblent.
190
+ if (active.has("notifications")) {
191
+ await page.addInitScript(() => {
192
+ window.__nfNotifications = [];
193
+ const Native = window.Notification;
194
+ if (typeof Native !== "function") return;
195
+ class Recorded extends Native {
196
+ constructor(title, options) {
197
+ super(title, options);
198
+ if (window.__nfNotifications.length < 20)
199
+ window.__nfNotifications.push({
200
+ title: String(title).slice(0, 140),
201
+ body: String(options?.body ?? "").slice(0, 140),
202
+ tag: options?.tag ?? null,
203
+ });
204
+ }
205
+ }
206
+ window.Notification = Recorded;
207
+ });
208
+ }
209
+ // `NF_BROWSER_AWAY=1` : la personne est AILLEURS — onglet caché, autre
210
+ // application au premier plan. Une page qui ne notifie qu'en son absence ne
211
+ // se mesure pas autrement : la fenêtre pilotée est toujours au premier plan.
212
+ if (AWAY) {
213
+ await page.addInitScript(() => {
214
+ Object.defineProperty(document, "visibilityState", { get: () => "hidden" });
215
+ Object.defineProperty(document, "hidden", { get: () => true });
216
+ document.hasFocus = () => false;
217
+ });
218
+ }
169
219
  await page.addInitScript(() => {
170
220
  window.__nfCsp = [];
171
221
  document.addEventListener("securitypolicyviolation", (e) => {
@@ -230,7 +280,7 @@ if (active.has("reseau")) {
230
280
  });
231
281
  }
232
282
 
233
- await goTo(page, ctx, PAGE, reuse);
283
+ await goTo(page, ctx, PAGE, reuse, { anonymous: ANONYMOUS });
234
284
 
235
285
  // Attendre un texte DISCRIMINANT, jamais `networkidle` : une application qui se
236
286
  // monte puis demande ses données passe par un état « réseau calme » où l'écran
@@ -259,84 +309,12 @@ if (EXPECT) {
259
309
  // toute mesure. Une action qui ne trouve pas sa cible ARRÊTE la sonde : la
260
310
  // mesure qui suivrait porterait sur un écran qu'on n'a pas ouvert, et rien ne
261
311
  // le dirait.
262
- for (const { verb, target, value } of ACTIONS) {
263
- if (verb === "defiler") {
264
- const pixels = Number.parseInt(target, 10);
265
- if (!Number.isFinite(pixels)) {
266
- console.error(`defiler attend un nombre de pixels, reçu « ${target} »`);
267
- await browser.close();
268
- process.exit(64); // EX_USAGE
269
- }
270
- // Le conteneur qui défile RÉELLEMENT, pas la fenêtre : sur une console à
271
- // barre latérale fixe, `window.scrollBy` ne bouge rien du tout.
272
- await page.evaluate((dy) => {
273
- const scrollable = [...document.querySelectorAll("*")].find(
274
- (el) => el.scrollHeight > el.clientHeight + 40 && el.clientHeight > 200,
275
- );
276
- (scrollable ?? window).scrollBy(0, dy);
277
- }, pixels);
278
- continue;
279
- }
280
- // Le premier candidat VISIBLE, pas le premier du DOM : un libellé apparaît
281
- // souvent d'abord dans un menu replié ou un gabarit caché, et agir là ne fait
282
- // rien tout en passant pour un succès.
283
- const candidates = page.getByText(target, { exact: false });
284
- let locator = null;
285
- const total = await candidates.count();
286
- for (let i = 0; i < total; i += 1) {
287
- const c = candidates.nth(i);
288
- if (await c.isVisible().catch(() => false)) {
289
- locator = c;
290
- break;
291
- }
292
- }
293
- // Aucun candidat visible À CET INSTANT ne dit pas que la cible est un
294
- // sélecteur : juste après un clic qui navigue, la page suivante n'est pas
295
- // encore rendue. On attend donc le texte OU le sélecteur — et le texte seul
296
- // quand la cible n'est pas du CSS valide. Trancher sur l'instant faisait
297
- // chercher une balise `<FreeOTP>` une fois sur trois.
298
- if (locator === null) {
299
- // Une navigation en vol détruirait le contexte de l'évaluation ci-dessous.
300
- await page.waitForLoadState("domcontentloaded").catch(() => {});
301
- const isCss = await page
302
- .evaluate((s) => {
303
- try {
304
- document.querySelector(s);
305
- return true;
306
- } catch {
307
- return false;
308
- }
309
- }, target)
310
- .catch(() => false);
311
- locator = isCss
312
- ? candidates.or(page.locator(target)).first()
313
- : candidates.first();
314
- }
315
- try {
316
- await locator.waitFor({ timeout: 15000 });
317
- if (verb === "clic") await locator.click();
318
- else if (verb === "double") await locator.dblclick();
319
- else if (verb === "droit") await locator.click({ button: "right" });
320
- else if (verb === "survol") await locator.hover();
321
- else if (verb === "saisir") await locator.fill(value);
322
- // `choisir` : la cible peut être le `<label>` qui ENVELOPPE la liste —
323
- // Playwright reporte alors le choix sur son contrôle. La valeur est celle
324
- // de l'option (`value`), sinon son libellé.
325
- else if (verb === "choisir") {
326
- const chosen = await locator.selectOption(value);
327
- if (chosen.length === 0) throw new Error(`option « ${value} » absente`);
328
- } else if (verb === "touche") await locator.press(value || "Enter");
329
- else if (verb === "voir") await locator.scrollIntoViewIfNeeded();
330
- // `attendre` : le `waitFor` ci-dessus EST l'action.
331
- } catch {
332
- console.error(
333
- `Action « ${verb}:${target} » impossible\n` +
334
- `Page réellement ouverte : ${page.url()}\n` +
335
- "→ le libellé a changé, l'élément n'est pas encore rendu, ou il faut " +
336
- "agir sur autre chose avant lui (NF_BROWSER_ACTIONS accepte une séquence, séparée par « | »).",
337
- );
312
+ {
313
+ const failed = await playActions(page, ACTIONS);
314
+ if (failed !== null) {
315
+ console.error(failed.message);
338
316
  await browser.close();
339
- process.exit(65); // EX_DATAERR
317
+ process.exit(failed.code);
340
318
  }
341
319
  }
342
320
 
@@ -890,6 +868,24 @@ if (active.has("reseau")) {
890
868
  };
891
869
  }
892
870
 
871
+ // ── Famille notifications — ce que la page a levé ──────────────────────────
872
+ if (active.has("notifications")) {
873
+ const raised = await page.evaluate(() => ({
874
+ permission:
875
+ typeof Notification === "function" ? Notification.permission : "absente",
876
+ list: window.__nfNotifications ?? [],
877
+ }));
878
+ measured.notifications = {
879
+ // Une DONNÉE, pas un jugement : aucune notification peut être le bon
880
+ // résultat (la personne était là).
881
+ verdict: raised.list.length > 0 ? "LEVÉES" : "AUCUNE",
882
+ permission: raised.permission,
883
+ away: AWAY,
884
+ count: raised.list.length,
885
+ list: raised.list.slice(0, 3),
886
+ };
887
+ }
888
+
893
889
  // ── Famille stockage — volet cookies, lu hors page ──────────────────────────
894
890
  if (active.has("stockage")) {
895
891
  const cookies = await ctx.cookies();
@@ -998,12 +994,37 @@ if (active.has("pli")) {
998
994
  }, primarySelector);
999
995
  }
1000
996
 
997
+ // ── Lire un ÉTAT de l'application — `NF_BROWSER_EVAL` ───────────────────────
998
+ // Ce qu'aucune famille ne mesure parce que c'est propre à l'application : l'état
999
+ // d'un store, l'instantané d'une socket, une valeur calculée par la page. Évalué
1000
+ // APRÈS les actions et les mesures, sur l'écran qu'on décrit. Une expression qui
1001
+ // lève rend son erreur, jamais un champ absent : un « rien » se lirait comme
1002
+ // « la page n'a pas cet état ».
1003
+ if (EVAL) {
1004
+ try {
1005
+ measured.evaluated = await page.evaluate(EVAL);
1006
+ } catch (e) {
1007
+ measured.evaluated = { error: String(e).slice(0, 300) };
1008
+ }
1009
+ }
1010
+
1001
1011
  // ── Capture — AVANT la famille responsive, qui déforme le viewport ──────────
1002
1012
  const slug = captureSlug(PAGE);
1003
- const shot = path.join(OUTPUT, `${slug}-${stamp}.png`);
1013
+ const shot = path.join(
1014
+ OUTPUT,
1015
+ stampedName(slug, new Date(), process.pid, "png"),
1016
+ );
1004
1017
  // `NF_BROWSER_FULLPAGE=1` : la page ENTIÈRE, pas la fenêtre. Un formulaire long
1005
1018
  // (ou une page qu'on vient de déplier) a l'essentiel SOUS la ligne de flottaison
1006
1019
  // — la capture par défaut laisse alors conclure « ce n'est pas là ».
1020
+ //
1021
+ // La console se FIGE avant la capture : sous WebKit, le pilote injecte un
1022
+ // `<style>` pour capturer, et une politique de sécurité du contenu stricte le
1023
+ // refuse. Ce refus appartient à l'instrument, pas à la page — compté, il ferait
1024
+ // accuser l'application d'une violation qu'elle ne commet pas. (Le rapport, lui,
1025
+ // part quand même vers le serveur : une collecte de rapports sous WebKit doit
1026
+ // écarter les `style-src-elem` `inline` émis à l'instant de la capture.)
1027
+ const consoleFrozen = consoleErrors.slice();
1007
1028
  await page.screenshot({
1008
1029
  path: shot,
1009
1030
  fullPage: process.env.NF_BROWSER_FULLPAGE === "1",
@@ -1071,7 +1092,7 @@ console.log(
1071
1092
  // L'écran mesuré : une proportion sans son écran ne conclut rien.
1072
1093
  viewport: `${decor.VIEWPORT.name}${decor.VIEWPORT.mobile ? " (téléphone)" : ""}`,
1073
1094
  ...measured,
1074
- consoleErrors,
1095
+ consoleErrors: consoleFrozen,
1075
1096
  uncaughtErrors,
1076
1097
  // Le chemin RENDU est celui où l'appelant trouvera l'image. Dans un
1077
1098
  // conteneur, `/output` est un volume monté et ne veut rien dire au
@@ -0,0 +1,164 @@
1
+ import { USER, signInElsewhere } from "./browser.mjs";
2
+
3
+ /**
4
+ * Attend que le défilement se pose : la position de la fenêtre ne bouge plus
5
+ * pendant trois images (une molette peut défiler en douceur). Borné à une
6
+ * seconde environ — jamais une attente aveugle.
7
+ *
8
+ * @param {import("playwright").Page} page - la page pilotée.
9
+ */
10
+ async function settleScroll(page) {
11
+ await page.evaluate(
12
+ () =>
13
+ new Promise((resolve) => {
14
+ let last = window.scrollY;
15
+ let still = 0;
16
+ let frames = 0;
17
+ const tick = () => {
18
+ frames += 1;
19
+ still = window.scrollY === last ? still + 1 : 0;
20
+ last = window.scrollY;
21
+ if (still >= 3 || frames >= 60) resolve(undefined);
22
+ else requestAnimationFrame(tick);
23
+ };
24
+ requestAnimationFrame(tick);
25
+ }),
26
+ );
27
+ }
28
+
29
+ /**
30
+ * Joue une séquence d'interactions (`NF_BROWSER_ACTIONS`, analysée par
31
+ * `parseActions`) sur une page — la seule implémentation, partagée par
32
+ * `inspect.mjs` (agir PUIS photographier) et `watch.mjs` (agir PENDANT qu'on
33
+ * observe le trafic).
34
+ *
35
+ * Une action qui ne trouve pas sa cible ARRÊTE la séquence : une mesure faite
36
+ * sur un écran qu'on n'a pas ouvert est pire qu'aucune mesure.
37
+ *
38
+ * @param {import("playwright").Page} page - la page pilotée.
39
+ * @param {{ verb: string, target: string, value?: string }[]} actions - la séquence.
40
+ * @returns {Promise<{ code: number, message: string } | null>} `null` si tout a
41
+ * été joué ; sinon le code de sortie à rendre (64 usage, 65 cible
42
+ * introuvable) et le message qui nomme l'action.
43
+ */
44
+ export async function playActions(page, actions) {
45
+ for (const { verb, target, value } of actions) {
46
+ if (verb === "connecter") {
47
+ if (!USER) {
48
+ return {
49
+ code: 64, // EX_USAGE
50
+ message:
51
+ "connecter: exige NF_BROWSER_USER, NF_BROWSER_PASSWORD et NF_BROWSER_LOGIN",
52
+ };
53
+ }
54
+ await signInElsewhere(page.context());
55
+ continue;
56
+ }
57
+ if (verb === "defiler" || verb === "molette") {
58
+ const pixels = Number.parseInt(target, 10);
59
+ if (!Number.isFinite(pixels)) {
60
+ return {
61
+ code: 64, // EX_USAGE
62
+ message: `${verb} attend un nombre de pixels, reçu « ${target} »`,
63
+ };
64
+ }
65
+ if (verb === "molette") {
66
+ // Une VRAIE molette, là où est le pointeur (`survol:` avant) : c'est
67
+ // le navigateur qui choisit quoi faire défiler, et qui passe à la page
68
+ // quand le conteneur est en bout — ce que `defiler`, qui appelle
69
+ // `scrollBy` sur un élément choisi, ne peut pas montrer.
70
+ await page.mouse.wheel(0, pixels);
71
+ await settleScroll(page);
72
+ continue;
73
+ }
74
+ // Le conteneur qui défile RÉELLEMENT, pas la fenêtre : sur une console à
75
+ // barre latérale fixe, `window.scrollBy` ne bouge rien du tout.
76
+ await page.evaluate((dy) => {
77
+ const scrollable = [...document.querySelectorAll("*")].find(
78
+ (el) =>
79
+ el.scrollHeight > el.clientHeight + 40 && el.clientHeight > 200,
80
+ );
81
+ (scrollable ?? window).scrollBy(0, dy);
82
+ }, pixels);
83
+ continue;
84
+ }
85
+ // Une touche sans cible part à l'élément qui a le focus (onglets,
86
+ // listes, menus : c'est lui que le clavier pilote).
87
+ if (verb === "touche" && target === "") {
88
+ await page.keyboard.press(value || "Enter");
89
+ continue;
90
+ }
91
+ // Le premier candidat VISIBLE, pas le premier du DOM : un libellé apparaît
92
+ // souvent d'abord dans un menu replié ou un gabarit caché, et agir là ne fait
93
+ // rien tout en passant pour un succès.
94
+ const candidates = page.getByText(target, { exact: false });
95
+ let locator = null;
96
+ const total = await candidates.count();
97
+ for (let i = 0; i < total; i += 1) {
98
+ const c = candidates.nth(i);
99
+ if (await c.isVisible().catch(() => false)) {
100
+ locator = c;
101
+ break;
102
+ }
103
+ }
104
+ // Aucun candidat visible À CET INSTANT ne dit pas que la cible est un
105
+ // sélecteur : juste après un clic qui navigue, la page suivante n'est pas
106
+ // encore rendue. On attend donc le texte OU le sélecteur — et le texte seul
107
+ // quand la cible n'est pas du CSS valide. Trancher sur l'instant faisait
108
+ // chercher une balise `<FreeOTP>` une fois sur trois.
109
+ if (locator === null) {
110
+ // Une navigation en vol détruirait le contexte de l'évaluation ci-dessous.
111
+ await page.waitForLoadState("domcontentloaded").catch(() => {});
112
+ const isCss = await page
113
+ .evaluate((s) => {
114
+ try {
115
+ document.querySelector(s);
116
+ return true;
117
+ } catch {
118
+ return false;
119
+ }
120
+ }, target)
121
+ .catch(() => false);
122
+ locator = isCss
123
+ ? candidates.or(page.locator(target)).first()
124
+ : candidates.first();
125
+ }
126
+ try {
127
+ // Un champ de fichier est presque toujours CACHÉ derrière un bouton
128
+ // (trombone, zone de dépôt) : l'attendre visible échouerait sur un champ
129
+ // pourtant présent. Il suffit qu'il soit attaché au document.
130
+ await locator.waitFor({
131
+ state: verb === "deposer" ? "attached" : "visible",
132
+ timeout: 15000,
133
+ });
134
+ if (verb === "clic") await locator.click();
135
+ else if (verb === "double") await locator.dblclick();
136
+ else if (verb === "droit") await locator.click({ button: "right" });
137
+ else if (verb === "survol") await locator.hover();
138
+ else if (verb === "saisir") await locator.fill(value);
139
+ // `choisir` : la cible peut être le `<label>` qui ENVELOPPE la liste —
140
+ // Playwright reporte alors le choix sur son contrôle. La valeur est celle
141
+ // de l'option (`value`), sinon son libellé.
142
+ else if (verb === "choisir") {
143
+ const chosen = await locator.selectOption(value);
144
+ if (chosen.length === 0) throw new Error(`option « ${value} » absente`);
145
+ } else if (verb === "touche") await locator.press(value || "Enter");
146
+ else if (verb === "voir") await locator.scrollIntoViewIfNeeded();
147
+ // `deposer` : le fichier passe par le champ lui-même, comme un choix dans
148
+ // la boîte du système — la page reçoit le même `change` qu'à la main. Un
149
+ // chemin relatif part du répertoire courant.
150
+ else if (verb === "deposer") await locator.setInputFiles(value);
151
+ // `attendre` : le `waitFor` ci-dessus EST l'action.
152
+ } catch {
153
+ return {
154
+ code: 65, // EX_DATAERR
155
+ message:
156
+ `Action « ${verb}:${target} » impossible\n` +
157
+ `Page réellement ouverte : ${page.url()}\n` +
158
+ "→ le libellé a changé, l'élément n'est pas encore rendu, ou il faut " +
159
+ "agir sur autre chose avant lui (NF_BROWSER_ACTIONS accepte une séquence, séparée par « | »).",
160
+ };
161
+ }
162
+ }
163
+ return null;
164
+ }
@@ -13,8 +13,9 @@
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_WHOAMI chemin qui répond 2xx à une session authentifiée, et 401/403 ou un 2xx portant `{ user: null }` sinon — prouve une session reprise (défaut `/nodefony/security/api/auth/me` ; vide = aucune reprise)
16
- * `@env` NF_BROWSER_ENGINE navigateur imposé (chromium, chrome, msedge) ; sans lui, le premier qui répond
16
+ * `@env` NF_BROWSER_ENGINE navigateur imposé (chromium, chrome, msedge, firefox, webkit) ; sans lui, le premier qui répond
17
17
  * `@env` NF_BROWSER_COLOR_SCHEME schéma de couleurs émulé (light, dark, no-preference) ; sans lui, celui du navigateur
18
+ * `@env` NF_BROWSER_REDUCED_MOTION préférence de mouvement émulée (reduce, no-preference) — `reduce` mesure une page à fondus au repos
18
19
  * `@env` NF_BROWSER_STORAGE entrées de stockage local posées AVANT chargement (`clé=valeur`, séparées par des virgules)
19
20
  */
20
21
  import { existsSync, mkdirSync, rmSync } from "node:fs";
@@ -22,9 +23,11 @@ import path from "node:path";
22
23
  import {
23
24
  environmentDefaults,
24
25
  authStateName,
26
+ browserEngine,
25
27
  browserOrder,
26
28
  sessionVerdict,
27
29
  parseColorScheme,
30
+ parseReducedMotion,
28
31
  parseStorage,
29
32
  parseViewport,
30
33
  } from "./probes.mjs";
@@ -38,9 +41,10 @@ import {
38
41
  * en pair optionnel — et un `MODULE_NOT_FOUND` nu, qui ne dit ni quoi
39
42
  * installer ni pourquoi, n'est pas une réponse acceptable.
40
43
  */
41
- let chromium;
44
+ let launchers;
42
45
  try {
43
- ({ chromium } = await import("playwright"));
46
+ const { chromium, firefox, webkit } = await import("playwright");
47
+ launchers = { chromium, firefox, webkit };
44
48
  } catch {
45
49
  console.error(
46
50
  "Playwright est absent — c'est lui qui pilote le navigateur.\n\n" +
@@ -191,6 +195,16 @@ if (invalidScheme) {
191
195
  );
192
196
  process.exit(64); // EX_USAGE
193
197
  }
198
+ const { motion: REDUCED_MOTION, invalid: invalidMotion } = parseReducedMotion(
199
+ process.env.NF_BROWSER_REDUCED_MOTION,
200
+ );
201
+ if (invalidMotion) {
202
+ console.error(
203
+ `NF_BROWSER_REDUCED_MOTION inconnu : « ${invalidMotion} ».\n` +
204
+ "Valeurs acceptées (celles de la média query standard) : reduce, no-preference.",
205
+ );
206
+ process.exit(64); // EX_USAGE
207
+ }
194
208
  const { entries: STORAGE, rejected: storageRejected } = parseStorage(
195
209
  process.env.NF_BROWSER_STORAGE,
196
210
  );
@@ -205,10 +219,15 @@ if (storageRejected.length > 0) {
205
219
  /**
206
220
  * Ouvre un navigateur et un contexte prêts à mesurer.
207
221
  *
222
+ * @param options.anonymous - ne reprendre AUCUN état d'authentification : la
223
+ * séquence se connecte elle-même en route (`connecter:`).
224
+ * @param options.permissions - permissions accordées d'avance à toutes les
225
+ * origines (`notifications`…) : une page qui les DEMANDE les reçoit sans
226
+ * boîte de dialogue — un navigateur piloté n'en montre aucune.
208
227
  * @returns `{ browser, ctx, page, reuse }` — `reuse` dit si un état
209
228
  * d'authentification a été repris, ce qui décide s'il faut se connecter.
210
229
  */
211
- export async function open() {
230
+ export async function open({ anonymous = false, permissions = [] } = {}) {
212
231
  // On CONSTATE quel navigateur répond, au lieu de supposer lequel est là.
213
232
  //
214
233
  // `channel` demande un navigateur COMPLET plutôt que le
@@ -222,10 +241,14 @@ export async function open() {
222
241
  const failures = [];
223
242
  for (const channel of BROWSERS) {
224
243
  try {
225
- browser = await chromium.launch({
226
- channel: channel,
227
- args: ["--no-sandbox"],
228
- });
244
+ // `--no-sandbox` est un drapeau de Chromium : Firefox et WebKit n'en
245
+ // ont pas l'usage, et le pilote ne leur passe aucun canal.
246
+ const { engine, channel: variant } = browserEngine(channel);
247
+ browser = await launchers[engine].launch(
248
+ engine === "chromium"
249
+ ? { channel: variant, args: ["--no-sandbox"] }
250
+ : {},
251
+ );
229
252
  browserUsed = channel;
230
253
  break;
231
254
  } catch (e) {
@@ -238,7 +261,8 @@ export async function open() {
238
261
  `${failures.join("\n")}\n\n` +
239
262
  "Installer celui du pilote (une fois par machine, partagé par tous tes projets) :\n\n" +
240
263
  " npx playwright install chromium\n\n" +
241
- "Ou viser un navigateur déjà présent : NF_BROWSER_ENGINE=chrome (ou msedge).",
264
+ "Ou viser un navigateur déjà présent : NF_BROWSER_ENGINE=chrome (ou msedge).\n" +
265
+ "Autre moteur : NF_BROWSER_ENGINE=firefox (ou webkit), après npx playwright install firefox.",
242
266
  );
243
267
  process.exit(69); // EX_UNAVAILABLE
244
268
  }
@@ -252,8 +276,10 @@ export async function open() {
252
276
  ? { isMobile: true, hasTouch: true, deviceScaleFactor: 2 }
253
277
  : {}),
254
278
  ...(COLOR_SCHEME ? { colorScheme: COLOR_SCHEME } : {}),
279
+ ...(REDUCED_MOTION ? { reducedMotion: REDUCED_MOTION } : {}),
280
+ ...(permissions.length > 0 ? { permissions } : {}),
255
281
  };
256
- let reuse = Boolean(USER) && existsSync(STATE);
282
+ let reuse = !anonymous && Boolean(USER) && existsSync(STATE);
257
283
  let ctx = null;
258
284
  if (reuse) {
259
285
  try {
@@ -411,6 +437,24 @@ export async function signIn(page, ctx) {
411
437
  await ctx.storageState({ path: STATE });
412
438
  }
413
439
 
440
+ /**
441
+ * Se connecte dans un AUTRE onglet du même contexte, puis le referme — la page
442
+ * mesurée n'est ni rechargée ni quittée. C'est le geste d'un utilisateur qui
443
+ * ouvre sa session ailleurs (console, autre onglet) pendant qu'une page déjà
444
+ * ouverte doit le constater d'elle-même : les cookies sont partagés, rien
445
+ * d'autre.
446
+ *
447
+ * @param ctx - le contexte de la page mesurée.
448
+ */
449
+ export async function signInElsewhere(ctx) {
450
+ const tab = await ctx.newPage();
451
+ try {
452
+ await signIn(tab, ctx);
453
+ } finally {
454
+ await tab.close();
455
+ }
456
+ }
457
+
414
458
  /**
415
459
  * Ouvre la page demandée, en garantissant que c'est bien ELLE qu'on mesure.
416
460
  *
@@ -424,8 +468,20 @@ export async function signIn(page, ctx) {
424
468
  * @param ctx - son contexte.
425
469
  * @param pathname - le chemin à ouvrir, relatif à l'origine.
426
470
  * @param reuse - un état d'authentification a-t-il été repris au lancement.
471
+ * @param options.anonymous - ouvrir la page SANS se connecter d'abord : la
472
+ * séquence s'en charge en route (`connecter:`).
427
473
  */
428
- export async function goTo(page, ctx, pathname, reuse) {
474
+ export async function goTo(
475
+ page,
476
+ ctx,
477
+ pathname,
478
+ reuse,
479
+ { anonymous = false } = {},
480
+ ) {
481
+ if (anonymous) {
482
+ await page.goto(`${BASE}${pathname}`, { waitUntil: "domcontentloaded" });
483
+ return;
484
+ }
429
485
  if (USER && !reuse) await signIn(page, ctx);
430
486
  await page.goto(`${BASE}${pathname}`, { waitUntil: "domcontentloaded" });
431
487
  if (USER && reuse) {