@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.
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorateParam.js +1 -1
- package/dist/index.js +3 -3
- package/dist/nodefony/controllers/DevkitController.js +1 -1
- package/dist/nodefony/controllers/McpController.js +2 -2
- package/dist/nodefony/controllers/ProfileSessionController.js +1 -1
- package/dist/nodefony/mcp/profileReport.js +1 -1
- package/dist/nodefony/mcp/profileRoute.js +28 -9
- package/dist/nodefony/service/DevkitService.js +4 -4
- package/docs/index.md +1 -1
- package/package.json +12 -12
- package/skills/nodefony-add-crud/SKILL.md +43 -24
- package/skills/nodefony-add-realtime-channel/SKILL.md +376 -111
- package/skills/nodefony-add-realtime-channel/references/binaire.md +191 -0
- package/skills/nodefony-add-realtime-channel/references/production.md +121 -0
- package/skills/nodefony-browser/SKILL.md +27 -2
- package/skills/nodefony-browser/references/sondes.md +13 -1
- package/skills/nodefony-browser/scripts/audit.mjs +12 -0
- package/skills/nodefony-browser/scripts/inspect.mjs +94 -88
- package/skills/nodefony-browser/scripts/lib/actions.mjs +113 -0
- package/skills/nodefony-browser/scripts/lib/browser.mjs +50 -11
- package/skills/nodefony-browser/scripts/lib/probes.mjs +56 -0
- package/skills/nodefony-browser/scripts/watch.mjs +27 -11
- package/skills/nodefony-dev/SKILL.md +11 -0
- package/skills/nodefony-dev/references/rapport-de-build.md +19 -0
- package/skills/nodefony-devops/SKILL.md +28 -21
- package/skills/nodefony-migrate-schema/SKILL.md +110 -20
- 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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
42
|
+
let launchers;
|
|
42
43
|
try {
|
|
43
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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(
|
|
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
|
+
}
|