@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.
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.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/profileRoute.js +28 -9
- package/dist/nodefony/service/DevkitService.js +6 -6
- package/docs/index.md +1 -1
- package/package.json +13 -13
- package/skills/nodefony-add-crud/SKILL.md +38 -23
- package/skills/nodefony-add-grpc-service/SKILL.md +179 -0
- package/skills/nodefony-add-grpc-service/scripts/call.mjs +218 -0
- package/skills/nodefony-add-realtime-binary/SKILL.md +301 -0
- package/skills/nodefony-add-realtime-binary/references/messages.md +20 -0
- package/skills/nodefony-add-realtime-binary/references/mesures.md +59 -0
- package/skills/nodefony-add-realtime-channel/SKILL.md +452 -105
- package/skills/nodefony-add-realtime-channel/references/contrat.md +22 -0
- package/skills/nodefony-add-realtime-channel/references/production.md +140 -0
- package/skills/nodefony-add-realtime-presence/SKILL.md +328 -0
- package/skills/nodefony-authentication/SKILL.md +2 -2
- package/skills/nodefony-browser/SKILL.md +37 -2
- package/skills/nodefony-browser/references/sondes.md +19 -0
- package/skills/nodefony-browser/scripts/audit.mjs +17 -2
- package/skills/nodefony-browser/scripts/inspect.mjs +108 -87
- package/skills/nodefony-browser/scripts/lib/actions.mjs +164 -0
- package/skills/nodefony-browser/scripts/lib/browser.mjs +67 -11
- package/skills/nodefony-browser/scripts/lib/probes.mjs +108 -3
- package/skills/nodefony-browser/scripts/watch.mjs +27 -11
- package/skills/nodefony-dev/SKILL.md +31 -15
- package/skills/nodefony-dev/references/rapport-de-build.md +19 -0
- package/skills/nodefony-devops/SKILL.md +28 -21
- package/skills/nodefony-devops/references/dimensionnement.md +1 -1
- package/skills/nodefony-devops/references/frontal.md +5 -2
- package/skills/nodefony-devops/references/image.md +23 -15
- package/skills/nodefony-migrate-schema/SKILL.md +110 -20
- package/skills/nodefony-migrate-schema/references/verdicts.md +23 -20
- 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(
|
|
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`
|
|
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
|
-
* | `
|
|
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
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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(
|
|
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(
|
|
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
|
|
44
|
+
let launchers;
|
|
42
45
|
try {
|
|
43
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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(
|
|
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) {
|