@nodefony/devkit 10.0.0-beta.4 → 10.0.0-beta.6

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 (28) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.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/profileReport.js +1 -1
  8. package/dist/nodefony/mcp/profileRoute.js +28 -9
  9. package/dist/nodefony/service/DevkitService.js +4 -4
  10. package/docs/index.md +1 -1
  11. package/package.json +12 -12
  12. package/skills/nodefony-add-crud/SKILL.md +43 -24
  13. package/skills/nodefony-add-realtime-channel/SKILL.md +376 -111
  14. package/skills/nodefony-add-realtime-channel/references/binaire.md +191 -0
  15. package/skills/nodefony-add-realtime-channel/references/production.md +121 -0
  16. package/skills/nodefony-browser/SKILL.md +27 -2
  17. package/skills/nodefony-browser/references/sondes.md +13 -1
  18. package/skills/nodefony-browser/scripts/audit.mjs +12 -0
  19. package/skills/nodefony-browser/scripts/inspect.mjs +94 -88
  20. package/skills/nodefony-browser/scripts/lib/actions.mjs +113 -0
  21. package/skills/nodefony-browser/scripts/lib/browser.mjs +50 -11
  22. package/skills/nodefony-browser/scripts/lib/probes.mjs +56 -0
  23. package/skills/nodefony-browser/scripts/watch.mjs +27 -11
  24. package/skills/nodefony-dev/SKILL.md +11 -0
  25. package/skills/nodefony-dev/references/rapport-de-build.md +19 -0
  26. package/skills/nodefony-devops/SKILL.md +28 -21
  27. package/skills/nodefony-migrate-schema/SKILL.md +110 -20
  28. package/skills/nodefony-migrate-schema/references/verdicts.md +22 -20
@@ -16,7 +16,7 @@
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
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
19
+ * `@env` NF_BROWSER_ENGINE navigateur imposé (chromium, chrome, msedge, firefox, webkit) ; sans lui, le premier qui répond
20
20
  * `@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
21
21
  * `@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
22
  * `@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é
@@ -27,17 +27,21 @@
27
27
  * saisir, choisir, touche, voir, defiler, attendre. Un écran qui se déplie
28
28
  * au clic n'existe pas tant qu'on ne l'a pas ouvert.
29
29
  * `@env` NF_BROWSER_FULLPAGE 1 = capture la page ENTIÈRE (défaut : la fenêtre)
30
- * `@env` NF_BROWSER_PROBES sélecteurs CSS à sonder, séparés par des virgules (`libellé=sélecteur`)
30
+ * `@env` NF_BROWSER_PROBES sélecteurs CSS à sonder, séparés par des virgules (`libellé=sélecteur`) ;
31
+ * `hôte >>> sélecteur` entre dans le Shadow DOM ouvert de l'hôte
31
32
  * `@env` NF_BROWSER_WIDTHS largeurs de la famille responsive (défaut 360,768,1280)
32
33
  * `@env` NF_BROWSER_VIEWPORT écran mesuré : mobile, mobile-court, tablette, bureau (défaut), large, tres-large, ou LARGEURxHAUTEUR — sous 600 px, émulé en téléphone (tactile, densité 2)
33
34
  * `@env` NF_BROWSER_PRIMARY sélecteur de l'action principale pour la famille pli (défaut : le plus grand bouton de validation visible)
34
35
  * `@env` NF_BROWSER_SEUIL_LOURD bytes au-delà desquels une ressource est « lourde » (défaut 512000)
35
36
  * `@env` NF_BROWSER_SEUIL_LENT millisecondes au-delà desquelles une réponse est « lente » (défaut 1000)
37
+ * `@env` NF_BROWSER_EVAL expression JS évaluée DANS la page après les actions et les mesures (une forme fonction est appelée) ;
38
+ * son résultat sérialisé sort dans `evaluated` — l'état propre à l'application (store, socket)
36
39
  * `@requires` conteneur du profil `browser` démarré · serveur joignable depuis le conteneur
37
40
  * `@output` un objet JSON sur stdout + une capture PNG horodatée dans /output
38
41
  * `@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
39
42
  */
40
43
  import { existsSync, readFileSync } from "node:fs";
44
+ import { playActions } from "./lib/actions.mjs";
41
45
  import path from "node:path";
42
46
  import * as decor from "./lib/browser.mjs";
43
47
  import { open, goTo, LOGIN, OUTPUT } from "./lib/browser.mjs";
@@ -47,14 +51,17 @@ import {
47
51
  captureSlug,
48
52
  parseActions,
49
53
  parseFamilies,
54
+ signsInMidway,
50
55
  parseProbes,
51
56
  parseWidths,
57
+ pageExpression,
52
58
  summarizeAxe,
53
59
  verdictGlobal,
54
60
  } from "./lib/probes.mjs";
55
61
 
56
62
  const PAGE = process.argv[2] ?? process.env.NF_BROWSER_PAGE ?? "/";
57
63
  const EXPECT = process.argv[3] ?? process.env.NF_BROWSER_EXPECT ?? "";
64
+ const EVAL = pageExpression(process.env.NF_BROWSER_EVAL);
58
65
  /**
59
66
  * Ce qu'il faut FAIRE avant de mesurer — une séquence, pas un geste.
60
67
  *
@@ -80,6 +87,7 @@ const EXPECT = process.argv[3] ?? process.env.NF_BROWSER_EXPECT ?? "";
80
87
  * | `voir` | amène dans la vue SANS cliquer |
81
88
  * | `defiler` | fait défiler de N pixels (`defiler:600`), page ou conteneur |
82
89
  * | `attendre` | attend qu'un texte APPARAISSE (après une action lente) |
90
+ * | `connecter` | se connecte dans un AUTRE onglet (`connecter:`), sans recharger |
83
91
  *
84
92
  * 🔴 `voir` existe parce qu'un clic n'est pas neutre : sur un formulaire, le
85
93
  * texte d'une question est un `label` — cliquer dessus COCHE la case qu'il
@@ -147,7 +155,9 @@ async function axeSource() {
147
155
 
148
156
  const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
149
157
 
150
- const { browser, ctx, page, reuse } = await open();
158
+ // `connecter:` dans la séquence ⇒ la page s'ouvre ANONYME (cf signsInMidway).
159
+ const ANONYMOUS = signsInMidway(ACTIONS);
160
+ const { browser, ctx, page, reuse } = await open({ anonymous: ANONYMOUS });
151
161
 
152
162
  // ── Collecteurs — posés AVANT toute navigation ──────────────────────────────
153
163
  const consoleErrors = [];
@@ -229,7 +239,7 @@ if (active.has("reseau")) {
229
239
  });
230
240
  }
231
241
 
232
- await goTo(page, ctx, PAGE, reuse);
242
+ await goTo(page, ctx, PAGE, reuse, { anonymous: ANONYMOUS });
233
243
 
234
244
  // Attendre un texte DISCRIMINANT, jamais `networkidle` : une application qui se
235
245
  // monte puis demande ses données passe par un état « réseau calme » où l'écran
@@ -258,84 +268,12 @@ if (EXPECT) {
258
268
  // toute mesure. Une action qui ne trouve pas sa cible ARRÊTE la sonde : la
259
269
  // mesure qui suivrait porterait sur un écran qu'on n'a pas ouvert, et rien ne
260
270
  // le dirait.
261
- for (const { verb, target, value } of ACTIONS) {
262
- if (verb === "defiler") {
263
- const pixels = Number.parseInt(target, 10);
264
- if (!Number.isFinite(pixels)) {
265
- console.error(`defiler attend un nombre de pixels, reçu « ${target} »`);
266
- await browser.close();
267
- process.exit(64); // EX_USAGE
268
- }
269
- // Le conteneur qui défile RÉELLEMENT, pas la fenêtre : sur une console à
270
- // barre latérale fixe, `window.scrollBy` ne bouge rien du tout.
271
- await page.evaluate((dy) => {
272
- const scrollable = [...document.querySelectorAll("*")].find(
273
- (el) => el.scrollHeight > el.clientHeight + 40 && el.clientHeight > 200,
274
- );
275
- (scrollable ?? window).scrollBy(0, dy);
276
- }, pixels);
277
- continue;
278
- }
279
- // Le premier candidat VISIBLE, pas le premier du DOM : un libellé apparaît
280
- // souvent d'abord dans un menu replié ou un gabarit caché, et agir là ne fait
281
- // rien tout en passant pour un succès.
282
- const candidates = page.getByText(target, { exact: false });
283
- let locator = null;
284
- const total = await candidates.count();
285
- for (let i = 0; i < total; i += 1) {
286
- const c = candidates.nth(i);
287
- if (await c.isVisible().catch(() => false)) {
288
- locator = c;
289
- break;
290
- }
291
- }
292
- // Aucun candidat visible À CET INSTANT ne dit pas que la cible est un
293
- // sélecteur : juste après un clic qui navigue, la page suivante n'est pas
294
- // encore rendue. On attend donc le texte OU le sélecteur — et le texte seul
295
- // quand la cible n'est pas du CSS valide. Trancher sur l'instant faisait
296
- // chercher une balise `<FreeOTP>` une fois sur trois.
297
- if (locator === null) {
298
- // Une navigation en vol détruirait le contexte de l'évaluation ci-dessous.
299
- await page.waitForLoadState("domcontentloaded").catch(() => {});
300
- const isCss = await page
301
- .evaluate((s) => {
302
- try {
303
- document.querySelector(s);
304
- return true;
305
- } catch {
306
- return false;
307
- }
308
- }, target)
309
- .catch(() => false);
310
- locator = isCss
311
- ? candidates.or(page.locator(target)).first()
312
- : candidates.first();
313
- }
314
- try {
315
- await locator.waitFor({ timeout: 15000 });
316
- if (verb === "clic") await locator.click();
317
- else if (verb === "double") await locator.dblclick();
318
- else if (verb === "droit") await locator.click({ button: "right" });
319
- else if (verb === "survol") await locator.hover();
320
- else if (verb === "saisir") await locator.fill(value);
321
- // `choisir` : la cible peut être le `<label>` qui ENVELOPPE la liste —
322
- // Playwright reporte alors le choix sur son contrôle. La valeur est celle
323
- // de l'option (`value`), sinon son libellé.
324
- else if (verb === "choisir") {
325
- const chosen = await locator.selectOption(value);
326
- if (chosen.length === 0) throw new Error(`option « ${value} » absente`);
327
- } else if (verb === "touche") await locator.press(value || "Enter");
328
- else if (verb === "voir") await locator.scrollIntoViewIfNeeded();
329
- // `attendre` : le `waitFor` ci-dessus EST l'action.
330
- } catch {
331
- console.error(
332
- `Action « ${verb}:${target} » impossible\n` +
333
- `Page réellement ouverte : ${page.url()}\n` +
334
- "→ le libellé a changé, l'élément n'est pas encore rendu, ou il faut " +
335
- "agir sur autre chose avant lui (NF_BROWSER_ACTIONS accepte une séquence, séparée par « | »).",
336
- );
271
+ {
272
+ const failed = await playActions(page, ACTIONS);
273
+ if (failed !== null) {
274
+ console.error(failed.message);
337
275
  await browser.close();
338
- process.exit(65); // EX_DATAERR
276
+ process.exit(failed.code);
339
277
  }
340
278
  }
341
279
 
@@ -384,7 +322,14 @@ function isVisible(el) {
384
322
  function effectiveBackground(el) {
385
323
  const layers = [];
386
324
  let opaqueLayer = null;
387
- for (let n = el; n; n = n.parentElement) {
325
+ // Un élément posé à la racine d'un Shadow DOM n'a pas de parent : son fond
326
+ // est celui de l'HÔTE, et la remontée continue de l'autre côté.
327
+ const up = (n) => {
328
+ if (n.parentElement) return n.parentElement;
329
+ const root = n.getRootNode();
330
+ return root instanceof ShadowRoot ? root.host : null;
331
+ };
332
+ for (let n = el; n; n = up(n)) {
388
333
  const bg = getComputedStyle(n).backgroundColor;
389
334
  if (!bg || bg.includes("transparent")) continue;
390
335
  const { a } = parseColor(bg);
@@ -550,18 +495,44 @@ function probeA11y() {
550
495
  * Famille rendu — la page tient-elle dans son viewport, ses polices sont-elles
551
496
  * VRAIMENT arrivées. Les éléments hors viewport sont une INFO, pas le verdict :
552
497
  * carrousels et textes pour lecteurs d'écran en produisent légitimement.
498
+ *
499
+ * Le débordement se cherche AUSSI dans les Shadow DOM ouverts, et dans chaque
500
+ * conteneur défilant : un widget isolé (une barre de débogage) peut porter sa
501
+ * propre barre de défilement horizontale sans que la page, elle, déborde d'un
502
+ * pixel — la mesure du document seul la déclarait saine.
553
503
  */
554
504
  function probeRendering() {
555
505
  const doc = document.scrollingElement ?? document.documentElement;
556
506
  const overflowPx = Math.max(0, doc.scrollWidth - window.innerWidth);
557
507
  const outsideViewport = [];
558
508
  let totalOutside = 0;
559
- for (const el of document.querySelectorAll("body *")) {
509
+ const scrollersX = [];
510
+ let totalScrollersX = 0;
511
+ const all = (root, out) => {
512
+ for (const el of root.querySelectorAll("*")) {
513
+ out.push(el);
514
+ if (el.shadowRoot) all(el.shadowRoot, out);
515
+ }
516
+ return out;
517
+ };
518
+ for (const el of all(document.body, [])) {
560
519
  const r = el.getBoundingClientRect();
561
- if (r.width > 0 && r.right > window.innerWidth + 1) {
520
+ if (r.width === 0) continue;
521
+ if (r.right > window.innerWidth + 1 && totalOutside < 200) {
562
522
  totalOutside += 1;
563
523
  if (outsideViewport.length < 3) outsideViewport.push(describeElement(el));
564
- if (totalOutside >= 200) break;
524
+ }
525
+ const ox = getComputedStyle(el).overflowX;
526
+ if (
527
+ (ox === "auto" || ox === "scroll") &&
528
+ el.scrollWidth > el.clientWidth + 1
529
+ ) {
530
+ totalScrollersX += 1;
531
+ if (scrollersX.length < 3)
532
+ scrollersX.push({
533
+ element: describeElement(el),
534
+ overflowPx: el.scrollWidth - el.clientWidth,
535
+ });
565
536
  }
566
537
  }
567
538
  const families = {};
@@ -571,8 +542,12 @@ function probeRendering() {
571
542
  if (f.status === "error") failedFonts += 1;
572
543
  }
573
544
  return {
574
- verdict: overflowPx > 0 || failedFonts > 0 ? "ALERTE" : "OK",
545
+ verdict:
546
+ overflowPx > 0 || totalScrollersX > 0 || failedFonts > 0
547
+ ? "ALERTE"
548
+ : "OK",
575
549
  horizontalOverflow: { present: overflowPx > 0, overflowPx },
550
+ horizontalScrollers: { total: totalScrollersX, examples: scrollersX },
576
551
  elementsOutsideViewport: { total: totalOutside, examples: outsideViewport },
577
552
  fonts: {
578
553
  status: document.fonts.status,
@@ -659,7 +634,16 @@ async function measurePage(args) {
659
634
  s.getAttribute("src"),
660
635
  ),
661
636
  probes: args.probes.map(({ label, sel }) => {
662
- const el = document.querySelector(sel);
637
+ // `hôte >>> sélecteur` traverse un Shadow DOM ouvert (un widget isolé,
638
+ // une barre de débogage) : chaque segment se cherche dans la racine
639
+ // fantôme de l'élément trouvé par le précédent.
640
+ let el = null;
641
+ let scope = document;
642
+ for (const part of sel.split(">>>")) {
643
+ el = scope?.querySelector(part.trim()) ?? null;
644
+ scope = el?.shadowRoot ?? null;
645
+ if (el === null) break;
646
+ }
663
647
  if (!el) return { label, absent: true, selector: sel };
664
648
  const cs = getComputedStyle(el);
665
649
  const r = el.getBoundingClientRect();
@@ -951,12 +935,34 @@ if (active.has("pli")) {
951
935
  }, primarySelector);
952
936
  }
953
937
 
938
+ // ── Lire un ÉTAT de l'application — `NF_BROWSER_EVAL` ───────────────────────
939
+ // Ce qu'aucune famille ne mesure parce que c'est propre à l'application : l'état
940
+ // d'un store, l'instantané d'une socket, une valeur calculée par la page. Évalué
941
+ // APRÈS les actions et les mesures, sur l'écran qu'on décrit. Une expression qui
942
+ // lève rend son erreur, jamais un champ absent : un « rien » se lirait comme
943
+ // « la page n'a pas cet état ».
944
+ if (EVAL) {
945
+ try {
946
+ measured.evaluated = await page.evaluate(EVAL);
947
+ } catch (e) {
948
+ measured.evaluated = { error: String(e).slice(0, 300) };
949
+ }
950
+ }
951
+
954
952
  // ── Capture — AVANT la famille responsive, qui déforme le viewport ──────────
955
953
  const slug = captureSlug(PAGE);
956
954
  const shot = path.join(OUTPUT, `${slug}-${stamp}.png`);
957
955
  // `NF_BROWSER_FULLPAGE=1` : la page ENTIÈRE, pas la fenêtre. Un formulaire long
958
956
  // (ou une page qu'on vient de déplier) a l'essentiel SOUS la ligne de flottaison
959
957
  // — la capture par défaut laisse alors conclure « ce n'est pas là ».
958
+ //
959
+ // La console se FIGE avant la capture : sous WebKit, le pilote injecte un
960
+ // `<style>` pour capturer, et une politique de sécurité du contenu stricte le
961
+ // refuse. Ce refus appartient à l'instrument, pas à la page — compté, il ferait
962
+ // accuser l'application d'une violation qu'elle ne commet pas. (Le rapport, lui,
963
+ // part quand même vers le serveur : une collecte de rapports sous WebKit doit
964
+ // écarter les `style-src-elem` `inline` émis à l'instant de la capture.)
965
+ const consoleFrozen = consoleErrors.slice();
960
966
  await page.screenshot({
961
967
  path: shot,
962
968
  fullPage: process.env.NF_BROWSER_FULLPAGE === "1",
@@ -1024,7 +1030,7 @@ console.log(
1024
1030
  // L'écran mesuré : une proportion sans son écran ne conclut rien.
1025
1031
  viewport: `${decor.VIEWPORT.name}${decor.VIEWPORT.mobile ? " (téléphone)" : ""}`,
1026
1032
  ...measured,
1027
- consoleErrors,
1033
+ consoleErrors: consoleFrozen,
1028
1034
  uncaughtErrors,
1029
1035
  // Le chemin RENDU est celui où l'appelant trouvera l'image. Dans un
1030
1036
  // conteneur, `/output` est un volume monté et ne veut rien dire au
@@ -0,0 +1,113 @@
1
+ import { USER, signInElsewhere } from "./browser.mjs";
2
+
3
+ /**
4
+ * Joue une séquence d'interactions (`NF_BROWSER_ACTIONS`, analysée par
5
+ * `parseActions`) sur une page — la seule implémentation, partagée par
6
+ * `inspect.mjs` (agir PUIS photographier) et `watch.mjs` (agir PENDANT qu'on
7
+ * observe le trafic).
8
+ *
9
+ * Une action qui ne trouve pas sa cible ARRÊTE la séquence : une mesure faite
10
+ * sur un écran qu'on n'a pas ouvert est pire qu'aucune mesure.
11
+ *
12
+ * @param {import("playwright").Page} page - la page pilotée.
13
+ * @param {{ verb: string, target: string, value?: string }[]} actions - la séquence.
14
+ * @returns {Promise<{ code: number, message: string } | null>} `null` si tout a
15
+ * été joué ; sinon le code de sortie à rendre (64 usage, 65 cible
16
+ * introuvable) et le message qui nomme l'action.
17
+ */
18
+ export async function playActions(page, actions) {
19
+ for (const { verb, target, value } of actions) {
20
+ if (verb === "connecter") {
21
+ if (!USER) {
22
+ return {
23
+ code: 64, // EX_USAGE
24
+ message:
25
+ "connecter: exige NF_BROWSER_USER, NF_BROWSER_PASSWORD et NF_BROWSER_LOGIN",
26
+ };
27
+ }
28
+ await signInElsewhere(page.context());
29
+ continue;
30
+ }
31
+ if (verb === "defiler") {
32
+ const pixels = Number.parseInt(target, 10);
33
+ if (!Number.isFinite(pixels)) {
34
+ return {
35
+ code: 64, // EX_USAGE
36
+ message: `defiler attend un nombre de pixels, reçu « ${target} »`,
37
+ };
38
+ }
39
+ // Le conteneur qui défile RÉELLEMENT, pas la fenêtre : sur une console à
40
+ // barre latérale fixe, `window.scrollBy` ne bouge rien du tout.
41
+ await page.evaluate((dy) => {
42
+ const scrollable = [...document.querySelectorAll("*")].find(
43
+ (el) =>
44
+ el.scrollHeight > el.clientHeight + 40 && el.clientHeight > 200,
45
+ );
46
+ (scrollable ?? window).scrollBy(0, dy);
47
+ }, pixels);
48
+ continue;
49
+ }
50
+ // Le premier candidat VISIBLE, pas le premier du DOM : un libellé apparaît
51
+ // souvent d'abord dans un menu replié ou un gabarit caché, et agir là ne fait
52
+ // rien tout en passant pour un succès.
53
+ const candidates = page.getByText(target, { exact: false });
54
+ let locator = null;
55
+ const total = await candidates.count();
56
+ for (let i = 0; i < total; i += 1) {
57
+ const c = candidates.nth(i);
58
+ if (await c.isVisible().catch(() => false)) {
59
+ locator = c;
60
+ break;
61
+ }
62
+ }
63
+ // Aucun candidat visible À CET INSTANT ne dit pas que la cible est un
64
+ // sélecteur : juste après un clic qui navigue, la page suivante n'est pas
65
+ // encore rendue. On attend donc le texte OU le sélecteur — et le texte seul
66
+ // quand la cible n'est pas du CSS valide. Trancher sur l'instant faisait
67
+ // chercher une balise `<FreeOTP>` une fois sur trois.
68
+ if (locator === null) {
69
+ // Une navigation en vol détruirait le contexte de l'évaluation ci-dessous.
70
+ await page.waitForLoadState("domcontentloaded").catch(() => {});
71
+ const isCss = await page
72
+ .evaluate((s) => {
73
+ try {
74
+ document.querySelector(s);
75
+ return true;
76
+ } catch {
77
+ return false;
78
+ }
79
+ }, target)
80
+ .catch(() => false);
81
+ locator = isCss
82
+ ? candidates.or(page.locator(target)).first()
83
+ : candidates.first();
84
+ }
85
+ try {
86
+ await locator.waitFor({ timeout: 15000 });
87
+ if (verb === "clic") await locator.click();
88
+ else if (verb === "double") await locator.dblclick();
89
+ else if (verb === "droit") await locator.click({ button: "right" });
90
+ else if (verb === "survol") await locator.hover();
91
+ else if (verb === "saisir") await locator.fill(value);
92
+ // `choisir` : la cible peut être le `<label>` qui ENVELOPPE la liste —
93
+ // Playwright reporte alors le choix sur son contrôle. La valeur est celle
94
+ // de l'option (`value`), sinon son libellé.
95
+ else if (verb === "choisir") {
96
+ const chosen = await locator.selectOption(value);
97
+ if (chosen.length === 0) throw new Error(`option « ${value} » absente`);
98
+ } else if (verb === "touche") await locator.press(value || "Enter");
99
+ else if (verb === "voir") await locator.scrollIntoViewIfNeeded();
100
+ // `attendre` : le `waitFor` ci-dessus EST l'action.
101
+ } catch {
102
+ return {
103
+ code: 65, // EX_DATAERR
104
+ message:
105
+ `Action « ${verb}:${target} » impossible\n` +
106
+ `Page réellement ouverte : ${page.url()}\n` +
107
+ "→ le libellé a changé, l'élément n'est pas encore rendu, ou il faut " +
108
+ "agir sur autre chose avant lui (NF_BROWSER_ACTIONS accepte une séquence, séparée par « | »).",
109
+ };
110
+ }
111
+ }
112
+ return null;
113
+ }
@@ -13,7 +13,7 @@
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
18
  * `@env` NF_BROWSER_STORAGE entrées de stockage local posées AVANT chargement (`clé=valeur`, séparées par des virgules)
19
19
  */
@@ -22,6 +22,7 @@ import path from "node:path";
22
22
  import {
23
23
  environmentDefaults,
24
24
  authStateName,
25
+ browserEngine,
25
26
  browserOrder,
26
27
  sessionVerdict,
27
28
  parseColorScheme,
@@ -38,9 +39,10 @@ import {
38
39
  * en pair optionnel — et un `MODULE_NOT_FOUND` nu, qui ne dit ni quoi
39
40
  * installer ni pourquoi, n'est pas une réponse acceptable.
40
41
  */
41
- let chromium;
42
+ let launchers;
42
43
  try {
43
- ({ chromium } = await import("playwright"));
44
+ const { chromium, firefox, webkit } = await import("playwright");
45
+ launchers = { chromium, firefox, webkit };
44
46
  } catch {
45
47
  console.error(
46
48
  "Playwright est absent — c'est lui qui pilote le navigateur.\n\n" +
@@ -205,10 +207,12 @@ if (storageRejected.length > 0) {
205
207
  /**
206
208
  * Ouvre un navigateur et un contexte prêts à mesurer.
207
209
  *
210
+ * @param options.anonymous - ne reprendre AUCUN état d'authentification : la
211
+ * séquence se connecte elle-même en route (`connecter:`).
208
212
  * @returns `{ browser, ctx, page, reuse }` — `reuse` dit si un état
209
213
  * d'authentification a été repris, ce qui décide s'il faut se connecter.
210
214
  */
211
- export async function open() {
215
+ export async function open({ anonymous = false } = {}) {
212
216
  // On CONSTATE quel navigateur répond, au lieu de supposer lequel est là.
213
217
  //
214
218
  // `channel` demande un navigateur COMPLET plutôt que le
@@ -222,10 +226,14 @@ export async function open() {
222
226
  const failures = [];
223
227
  for (const channel of BROWSERS) {
224
228
  try {
225
- browser = await chromium.launch({
226
- channel: channel,
227
- args: ["--no-sandbox"],
228
- });
229
+ // `--no-sandbox` est un drapeau de Chromium : Firefox et WebKit n'en
230
+ // ont pas l'usage, et le pilote ne leur passe aucun canal.
231
+ const { engine, channel: variant } = browserEngine(channel);
232
+ browser = await launchers[engine].launch(
233
+ engine === "chromium"
234
+ ? { channel: variant, args: ["--no-sandbox"] }
235
+ : {},
236
+ );
229
237
  browserUsed = channel;
230
238
  break;
231
239
  } catch (e) {
@@ -238,7 +246,8 @@ export async function open() {
238
246
  `${failures.join("\n")}\n\n` +
239
247
  "Installer celui du pilote (une fois par machine, partagé par tous tes projets) :\n\n" +
240
248
  " npx playwright install chromium\n\n" +
241
- "Ou viser un navigateur déjà présent : NF_BROWSER_ENGINE=chrome (ou msedge).",
249
+ "Ou viser un navigateur déjà présent : NF_BROWSER_ENGINE=chrome (ou msedge).\n" +
250
+ "Autre moteur : NF_BROWSER_ENGINE=firefox (ou webkit), après npx playwright install firefox.",
242
251
  );
243
252
  process.exit(69); // EX_UNAVAILABLE
244
253
  }
@@ -253,7 +262,7 @@ export async function open() {
253
262
  : {}),
254
263
  ...(COLOR_SCHEME ? { colorScheme: COLOR_SCHEME } : {}),
255
264
  };
256
- let reuse = Boolean(USER) && existsSync(STATE);
265
+ let reuse = !anonymous && Boolean(USER) && existsSync(STATE);
257
266
  let ctx = null;
258
267
  if (reuse) {
259
268
  try {
@@ -411,6 +420,24 @@ export async function signIn(page, ctx) {
411
420
  await ctx.storageState({ path: STATE });
412
421
  }
413
422
 
423
+ /**
424
+ * Se connecte dans un AUTRE onglet du même contexte, puis le referme — la page
425
+ * mesurée n'est ni rechargée ni quittée. C'est le geste d'un utilisateur qui
426
+ * ouvre sa session ailleurs (console, autre onglet) pendant qu'une page déjà
427
+ * ouverte doit le constater d'elle-même : les cookies sont partagés, rien
428
+ * d'autre.
429
+ *
430
+ * @param ctx - le contexte de la page mesurée.
431
+ */
432
+ export async function signInElsewhere(ctx) {
433
+ const tab = await ctx.newPage();
434
+ try {
435
+ await signIn(tab, ctx);
436
+ } finally {
437
+ await tab.close();
438
+ }
439
+ }
440
+
414
441
  /**
415
442
  * Ouvre la page demandée, en garantissant que c'est bien ELLE qu'on mesure.
416
443
  *
@@ -424,8 +451,20 @@ export async function signIn(page, ctx) {
424
451
  * @param ctx - son contexte.
425
452
  * @param pathname - le chemin à ouvrir, relatif à l'origine.
426
453
  * @param reuse - un état d'authentification a-t-il été repris au lancement.
454
+ * @param options.anonymous - ouvrir la page SANS se connecter d'abord : la
455
+ * séquence s'en charge en route (`connecter:`).
427
456
  */
428
- export async function goTo(page, ctx, pathname, reuse) {
457
+ export async function goTo(
458
+ page,
459
+ ctx,
460
+ pathname,
461
+ reuse,
462
+ { anonymous = false } = {},
463
+ ) {
464
+ if (anonymous) {
465
+ await page.goto(`${BASE}${pathname}`, { waitUntil: "domcontentloaded" });
466
+ return;
467
+ }
429
468
  if (USER && !reuse) await signIn(page, ctx);
430
469
  await page.goto(`${BASE}${pathname}`, { waitUntil: "domcontentloaded" });
431
470
  if (USER && reuse) {
@@ -159,8 +159,24 @@ export const ACTION_VERBS = [
159
159
  "voir",
160
160
  "defiler",
161
161
  "attendre",
162
+ "connecter",
162
163
  ];
163
164
 
165
+ /**
166
+ * La séquence se connecte-t-elle EN COURS de route (`connecter:`) ?
167
+ *
168
+ * Alors la page doit s'ouvrir ANONYME — ni état d'authentification repris, ni
169
+ * connexion préalable : c'est précisément le passage « sans session » →
170
+ * « avec session », sans recharger, qu'on veut observer. Se connecter avant
171
+ * aurait rendu l'action sans objet, et la mesure verte pour la mauvaise raison.
172
+ *
173
+ * @param actions - la séquence analysée par `parseActions`.
174
+ * @returns `true` si une action `connecter` y figure.
175
+ */
176
+ export function signsInMidway(actions) {
177
+ return actions.some((a) => a.verb === "connecter");
178
+ }
179
+
164
180
  /**
165
181
  * Analyse une séquence d'actions (`NF_BROWSER_ACTIONS`).
166
182
  *
@@ -240,6 +256,25 @@ export function parseColorScheme(raw) {
240
256
  return { schema: null, invalid: v };
241
257
  }
242
258
 
259
+ /**
260
+ * Rend une expression JS fournie par l'utilisateur ÉVALUABLE dans la page —
261
+ * une forme fonction (`() => x`, `function`, `async`) y est APPELÉE.
262
+ *
263
+ * Le piège est réel : `page.evaluate` et `waitForFunction` évaluent une chaîne
264
+ * comme une EXPRESSION. Donner « () => x » y définit une fonction sans jamais
265
+ * l'appeler : l'objet fonction est truthy (une attente réussit TOUJOURS) et ne
266
+ * se sérialise pas (une lecture rend `undefined`). Faux vert vécu, découvert
267
+ * seulement en éprouvant le sens négatif.
268
+ *
269
+ * @param {string|undefined} raw - expression brute (`NF_BROWSER_UNTIL`, `NF_BROWSER_EVAL`).
270
+ * @returns {string} l'expression à évaluer, vide si rien n'est demandé.
271
+ */
272
+ export function pageExpression(raw) {
273
+ const v = (raw ?? "").trim();
274
+ if (!v) return "";
275
+ return /^(\(|function\b|async\b)/.test(v) ? `(${v})()` : v;
276
+ }
277
+
243
278
  /**
244
279
  * Analyse une coupure réseau demandée (`NF_BROWSER_CUT`), au format
245
280
  * `<début>:<durée>` en millisecondes — `4000:3000` coupe le réseau 4 s après
@@ -709,3 +744,24 @@ export function browserOrder(explicit) {
709
744
  if (v) return [v];
710
745
  return ["chromium", "chrome", "msedge"];
711
746
  }
747
+
748
+ /**
749
+ * Traduit un nom de navigateur en MOTEUR du pilote et, pour Chromium, en canal.
750
+ *
751
+ * Un comportement que dicte une norme — la politique de sécurité du contenu, le
752
+ * rapport de violation, un cookie `SameSite` — diverge d'un moteur à l'autre :
753
+ * le constater sous Chromium seul ne dit rien de Firefox ni de Safari. `firefox`
754
+ * et `webkit` désignent donc les moteurs que le pilote installe lui-même
755
+ * (`npx playwright install firefox`) ; tout autre nom reste un canal de
756
+ * Chromium (`chrome`, `msedge`, `chrome-beta`…), comme avant.
757
+ *
758
+ * @param {string} name - un nom rendu par `browserOrder`.
759
+ * @returns {{ engine: "chromium"|"firefox"|"webkit", channel: string|undefined }}
760
+ * le moteur à lancer, et le canal Chromium (absent pour les deux autres).
761
+ */
762
+ export function browserEngine(name) {
763
+ const v = name.trim().toLowerCase();
764
+ if (v === "firefox" || v === "webkit")
765
+ return { engine: v, channel: undefined };
766
+ return { engine: "chromium", channel: name.trim() };
767
+ }