@nodefony/devkit 10.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +318 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateParam.js +8 -0
  6. package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorate.js +9 -0
  7. package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorateMetadata.js +6 -0
  8. package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorateParam.js +8 -0
  9. package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorate.js +9 -0
  10. package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorateMetadata.js +6 -0
  11. package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorateParam.js +8 -0
  12. package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorate.js +9 -0
  13. package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorateMetadata.js +6 -0
  14. package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorateParam.js +8 -0
  15. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  16. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  17. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateParam.js +8 -0
  18. package/dist/index.js +47 -0
  19. package/dist/nodefony/command/CardCommand.js +70 -0
  20. package/dist/nodefony/config/config.js +200 -0
  21. package/dist/nodefony/config/defineModuleConfig.js +36 -0
  22. package/dist/nodefony/controllers/DevkitController.js +60 -0
  23. package/dist/nodefony/controllers/McpController.js +223 -0
  24. package/dist/nodefony/controllers/OAuthMetadataController.js +89 -0
  25. package/dist/nodefony/interfaces/IDevkitService.js +1 -0
  26. package/dist/nodefony/interfaces/index.js +1 -0
  27. package/dist/nodefony/service/DevkitService.js +198 -0
  28. package/dist/nodefony/src/card.js +2 -0
  29. package/dist/nodefony/src/errors/DevkitError.js +21 -0
  30. package/dist/nodefony/src/mcp/guard.js +51 -0
  31. package/dist/nodefony/src/mcp/protocol.js +127 -0
  32. package/dist/nodefony/src/mcp/server.js +133 -0
  33. package/dist/nodefony/src/mcp/tools.js +163 -0
  34. package/dist/types/index.d.ts +52 -0
  35. package/dist/types/nodefony/command/CardCommand.d.ts +33 -0
  36. package/dist/types/nodefony/config/config.d.ts +25 -0
  37. package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
  38. package/dist/types/nodefony/controllers/DevkitController.d.ts +37 -0
  39. package/dist/types/nodefony/controllers/McpController.d.ts +70 -0
  40. package/dist/types/nodefony/controllers/OAuthMetadataController.d.ts +39 -0
  41. package/dist/types/nodefony/interfaces/IDevkitService.d.ts +69 -0
  42. package/dist/types/nodefony/interfaces/index.d.ts +1 -0
  43. package/dist/types/nodefony/service/DevkitService.d.ts +143 -0
  44. package/dist/types/nodefony/src/card.d.ts +18 -0
  45. package/dist/types/nodefony/src/errors/DevkitError.d.ts +14 -0
  46. package/dist/types/nodefony/src/mcp/guard.d.ts +66 -0
  47. package/dist/types/nodefony/src/mcp/protocol.d.ts +139 -0
  48. package/dist/types/nodefony/src/mcp/server.d.ts +48 -0
  49. package/dist/types/nodefony/src/mcp/tools.d.ts +81 -0
  50. package/docs/index.md +358 -0
  51. package/package.json +77 -0
  52. package/skills/nodefony-add-crud/SKILL.md +199 -0
  53. package/skills/nodefony-add-realtime-channel/SKILL.md +95 -0
  54. package/skills/nodefony-add-service/SKILL.md +90 -0
  55. package/skills/nodefony-browser/SKILL.md +416 -0
  56. package/skills/nodefony-browser/references/socket.md +115 -0
  57. package/skills/nodefony-browser/references/sondes.md +175 -0
  58. package/skills/nodefony-browser/scripts/audit.mjs +169 -0
  59. package/skills/nodefony-browser/scripts/inspect.mjs +903 -0
  60. package/skills/nodefony-browser/scripts/lib/browser.mjs +357 -0
  61. package/skills/nodefony-browser/scripts/lib/probes.mjs +501 -0
  62. package/skills/nodefony-browser/scripts/lib/wcag.mjs +153 -0
  63. package/skills/nodefony-browser/scripts/socket.mjs +354 -0
  64. package/skills/nodefony-browser/scripts/watch.mjs +125 -0
  65. package/skills/nodefony-migrate-schema/SKILL.md +359 -0
  66. package/skills/nodefony-migrate-schema/references/verdicts.md +139 -0
  67. package/skills/nodefony-protect-route/SKILL.md +195 -0
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Le décor commun aux sondes : lancer le navigateur, se connecter, ouvrir une
3
+ * page en étant sûr de mesurer CELLE-LÀ.
4
+ *
5
+ * Pourquoi une brique partagée plutôt que deux copies : les deux scripts sont
6
+ * copiés ensemble (`docker cp <dossier>/. <conteneur>:/app/`), donc la partager
7
+ * ne coûte rien — et la duplication précédente avait déjà divergé en silence,
8
+ * une seule des deux copies rattrapant un état d'authentification périmé.
9
+ *
10
+ * `@env` NF_BROWSER_BASE origine à joindre (défaut CONSTATÉ : 127.0.0.1 en local, host.docker.internal en conteneur)
11
+ * `@env` NF_BROWSER_OUT dossier des captures et de l'état d'authentification (défaut constaté de la même façon)
12
+ * `@env` NF_BROWSER_LOGIN chemin du formulaire de connexion — REQUIS dès qu'un identifiant est donné, aucun défaut n'est deviné
13
+ * `@env` NF_BROWSER_USER identifiant ; sans lui, aucune authentification n'est tentée
14
+ * `@env` NF_BROWSER_PASSWORD mot de passe associé
15
+ * `@env` NF_BROWSER_ENGINE navigateur imposé (chromium, chrome, msedge) ; sans lui, le premier qui répond
16
+ * `@env` NF_BROWSER_COLOR_SCHEME schéma de couleurs émulé (light, dark, no-preference) ; sans lui, celui du navigateur
17
+ * `@env` NF_BROWSER_STORAGE entrées de stockage local posées AVANT chargement (`clé=valeur`, séparées par des virgules)
18
+ */
19
+ import { existsSync, mkdirSync, rmSync } from "node:fs";
20
+ import path from "node:path";
21
+ import {
22
+ environmentDefaults,
23
+ authStateName,
24
+ browserOrder,
25
+ parseColorScheme,
26
+ parseStorage,
27
+ } from "./probes.mjs";
28
+
29
+ /**
30
+ * Playwright — chargé À LA DEMANDE, pour pouvoir expliquer son absence.
31
+ *
32
+ * Il porte un navigateur de plus de cent mégaoctets : l'imposer à toute
33
+ * application qui installe cet outillage serait disproportionné, alors que
34
+ * seuls ceux qui veulent REGARDER un écran en ont besoin. Il est donc déclaré
35
+ * en pair optionnel — et un `MODULE_NOT_FOUND` nu, qui ne dit ni quoi
36
+ * installer ni pourquoi, n'est pas une réponse acceptable.
37
+ */
38
+ let chromium;
39
+ try {
40
+ ({ chromium } = await import("playwright"));
41
+ } catch {
42
+ console.error(
43
+ "Playwright est absent — c'est lui qui pilote le navigateur.\n\n" +
44
+ " npm i -D playwright && npx playwright install chromium\n\n" +
45
+ "Autre voie, si tu préfères ne rien poser sur ta machine : exécuter ces\n" +
46
+ "sondes dans un conteneur qui embarque déjà navigateur et pilote.",
47
+ );
48
+ process.exit(69); // EX_UNAVAILABLE
49
+ }
50
+
51
+ /**
52
+ * Le décor : où joindre l'application, et où déposer ce qu'on produit.
53
+ *
54
+ * Les deux se CONSTATENT plutôt que de se supposer — `/.dockerenv` existe
55
+ * quand, et seulement quand, on s'exécute dans un conteneur. Le déduire de la
56
+ * plateforme serait faux dans les deux sens : un conteneur Linux sur un poste
57
+ * macOS, ou un poste Linux nu, rendraient le même `process.platform`.
58
+ *
59
+ * L'enjeu n'est pas cosmétique : `127.0.0.1` désigne le conteneur LUI-MÊME
60
+ * quand on y est enfermé, et la sonde mesurerait alors une connexion refusée
61
+ * en croyant que l'application est en panne.
62
+ */
63
+ const IN_CONTAINER = existsSync("/.dockerenv");
64
+ const { base: baseUrl, out: OUT } = environmentDefaults({
65
+ inContainer: IN_CONTAINER,
66
+ base: process.env.NF_BROWSER_BASE,
67
+ out: process.env.NF_BROWSER_OUT,
68
+ });
69
+
70
+ /** Où atterrissent captures et état d'authentification. */
71
+ export const OUTPUT = OUT;
72
+ export const BASE = baseUrl;
73
+
74
+ /** Les navigateurs à essayer, dans l'ordre — voir `browserOrder`. */
75
+ const BROWSERS = browserOrder(process.env.NF_BROWSER_ENGINE);
76
+
77
+ /**
78
+ * Le navigateur RÉELLEMENT utilisé, renseigné à l'ouverture.
79
+ *
80
+ * Rendu avec la mesure, parce que le décor en fait partie : un Chrome de
81
+ * système et le Chromium du pilote ne sont pas la même version, et deux
82
+ * chiffres de rendu comparés sans savoir cela ne comparent rien.
83
+ */
84
+ export let browserUsed = null;
85
+ export const USER = process.env.NF_BROWSER_USER ?? "";
86
+ export const PASSWORD = process.env.NF_BROWSER_PASSWORD ?? "";
87
+
88
+ /**
89
+ * Le chemin du formulaire de connexion — celui de TON application.
90
+ *
91
+ * Aucun défaut : il n'existe pas d'écran de connexion universel, et deviner en
92
+ * enverrait la sonde sur une page inexistante, où elle « se connecterait »
93
+ * silencieusement avant de mesurer un écran d'erreur. Une ignorance ne doit
94
+ * jamais passer pour un contrôle réussi : on le dit, et on s'arrête.
95
+ */
96
+ export const LOGIN = process.env.NF_BROWSER_LOGIN ?? "";
97
+ if (USER && !LOGIN) {
98
+ console.error(
99
+ "NF_BROWSER_USER est posé mais pas NF_BROWSER_LOGIN : donne le chemin de ton\n" +
100
+ "formulaire de connexion (par exemple /login), sinon la sonde n'a aucun moyen\n" +
101
+ "de savoir où s'authentifier.",
102
+ );
103
+ process.exit(64); // EX_USAGE
104
+ }
105
+
106
+ /**
107
+ * L'état d'authentification, SAUVEGARDÉ puis réutilisé d'une sonde à l'autre.
108
+ *
109
+ * Il vit dans le dossier de sortie — en conteneur, un volume monté, donc il
110
+ * survit à l'arrêt de celui-ci.
111
+ * Sans lui, chaque inspection rejoue le parcours de connexion — quelques
112
+ * secondes perdues et une occasion d'échec de plus à chaque exécution.
113
+ *
114
+ * Son nom porte l'IDENTIFIANT (cf {@link authStateName}) : un état est la session
115
+ * de quelqu'un, et le réutiliser pour un autre compte fait mesurer une identité
116
+ * qu'on n'a pas demandée. Effet de bord bienvenu — deux comptes gardent chacun
117
+ * leur session, donc aucun des deux ne se reconnecte à cause de l'autre.
118
+ */
119
+ const STATE = path.join(OUT, authStateName(process.env.NF_BROWSER_USER));
120
+ // Créé AVANT la première écriture : en local, le dossier n'existe pas encore,
121
+ // et l'échec ne surviendrait qu'à la sauvegarde — après la connexion, donc
122
+ // après avoir fait croire que tout allait bien.
123
+ mkdirSync(OUT, { recursive: true });
124
+
125
+ /**
126
+ * Le schéma de couleurs à émuler, et le stockage à poser avant chargement.
127
+ *
128
+ * Deux leviers plutôt qu'un, parce qu'il existe deux façons de choisir un
129
+ * thème et qu'aucune ne couvre l'autre :
130
+ *
131
+ * • `prefers-color-scheme` — la média query standard, que le navigateur
132
+ * expose et que le CSS peut suivre. Générique par construction : elle ne
133
+ * dépend d'aucune trousse d'interface.
134
+ * • une entrée de stockage — dès que l'application MÉMORISE le choix de
135
+ * l'utilisateur, la média query ne décide plus rien, et la clé employée
136
+ * appartient à l'application. On la reçoit, on ne la devine pas.
137
+ *
138
+ * Un défaut qui n'existe que dans un thème est invisible tant qu'on ne peut pas
139
+ * demander l'autre : c'est ce qui a fait passer un menu à 1,63:1 sous le radar.
140
+ */
141
+ const { schema: COLOR_SCHEME, invalid: invalidScheme } = parseColorScheme(
142
+ process.env.NF_BROWSER_COLOR_SCHEME,
143
+ );
144
+ if (invalidScheme) {
145
+ console.error(
146
+ `NF_BROWSER_COLOR_SCHEME inconnu : « ${invalidScheme} ».\n` +
147
+ "Valeurs acceptées (celles de la média query standard) : light, dark, no-preference.",
148
+ );
149
+ process.exit(64); // EX_USAGE
150
+ }
151
+ const { entries: STORAGE, rejected: storageRejected } = parseStorage(
152
+ process.env.NF_BROWSER_STORAGE,
153
+ );
154
+ if (storageRejected.length > 0) {
155
+ console.error(
156
+ `NF_BROWSER_STORAGE — entrée(s) malformée(s) ignorable(s) en silence, donc REFUSÉE(S) : ${storageRejected.join(", ")}\n` +
157
+ "Forme attendue : clé=valeur, séparées par des virgules.",
158
+ );
159
+ process.exit(64); // EX_USAGE
160
+ }
161
+
162
+ /**
163
+ * Ouvre un navigateur et un contexte prêts à mesurer.
164
+ *
165
+ * @returns `{ browser, ctx, page, reuse }` — `reuse` dit si un état
166
+ * d'authentification a été repris, ce qui décide s'il faut se connecter.
167
+ */
168
+ export async function open() {
169
+ // On CONSTATE quel navigateur répond, au lieu de supposer lequel est là.
170
+ //
171
+ // `channel` demande un navigateur COMPLET plutôt que le
172
+ // `chrome-headless-shell` que le pilote lance par défaut sans interface — une
173
+ // image de conteneur n'embarque souvent que le premier, et sur un poste c'est
174
+ // lui qui rend le plus fidèlement ce qu'un utilisateur verra.
175
+ //
176
+ // L'ordre essaie d'abord celui que le pilote installe, puis ceux DÉJÀ posés
177
+ // sur la machine : la plupart des postes n'ont alors rien à télécharger.
178
+ let browser = null;
179
+ const failures = [];
180
+ for (const channel of BROWSERS) {
181
+ try {
182
+ browser = await chromium.launch({
183
+ channel: channel,
184
+ args: ["--no-sandbox"],
185
+ });
186
+ browserUsed = channel;
187
+ break;
188
+ } catch (e) {
189
+ failures.push(` ${channel} — ${String(e).split("\n")[0].slice(0, 120)}`);
190
+ }
191
+ }
192
+ if (!browser) {
193
+ console.error(
194
+ `Aucun navigateur utilisable parmi : ${BROWSERS.join(", ")}.\n\n` +
195
+ `${failures.join("\n")}\n\n` +
196
+ "Installer celui du pilote (une fois par machine, partagé par tous tes projets) :\n\n" +
197
+ " npx playwright install chromium\n\n" +
198
+ "Ou viser un navigateur déjà présent : NF_BROWSER_ENGINE=chrome (ou msedge).",
199
+ );
200
+ process.exit(69); // EX_UNAVAILABLE
201
+ }
202
+ const options = {
203
+ ignoreHTTPSErrors: true, // certificat de développement auto-signé
204
+ viewport: { width: 1440, height: 900 },
205
+ ...(COLOR_SCHEME ? { colorScheme: COLOR_SCHEME } : {}),
206
+ };
207
+ let reuse = Boolean(USER) && existsSync(STATE);
208
+ let ctx = null;
209
+ if (reuse) {
210
+ try {
211
+ ctx = await browser.newContext({ ...options, storageState: STATE });
212
+ } catch (e) {
213
+ // Un fichier d'état corrompu (tronqué, schéma inattendu) ne doit jamais
214
+ // valoir un crash : on le dit, on le jette, et on rejoue le parcours de
215
+ // connexion complet — l'ignorance ne passe pas pour un contrôle réussi.
216
+ console.error(
217
+ `État d'authentification illisible — il est supprimé et le parcours de connexion est rejoué.\n${String(e).slice(0, 200)}`,
218
+ );
219
+ rmSync(STATE, { force: true });
220
+ reuse = false;
221
+ }
222
+ }
223
+ if (!ctx) ctx = await browser.newContext(options);
224
+ if (STORAGE.length > 0) {
225
+ // AVANT tout script de la page, et à CHAQUE navigation : une application
226
+ // lit son thème mémorisé au tout premier rendu. Poser la valeur après coup
227
+ // obligerait à recharger, et l'on mesurerait l'entre-deux.
228
+ //
229
+ // Cela l'emporte volontairement sur un état d'authentification réutilisé
230
+ // qui porterait l'ancienne valeur : ce que la ligne de commande demande
231
+ // prime sur ce qu'une session précédente avait laissé.
232
+ await ctx.addInitScript((entries) => {
233
+ try {
234
+ for (const { cle: key, valeur: value } of entries)
235
+ localStorage.setItem(key, value);
236
+ } catch {
237
+ // Stockage refusé (mode privé, origine opaque) : la sonde continue —
238
+ // le thème sera celui par défaut, et la mesure le DIRA (champ `theme`).
239
+ }
240
+ }, STORAGE);
241
+ }
242
+ return { browser, ctx, page: await ctx.newPage(), reuse };
243
+ }
244
+
245
+ /**
246
+ * Connexion par le formulaire, en deux temps (identifiant, puis mot de passe).
247
+ *
248
+ * Cible les champs par leur LIBELLÉ, jamais par un sélecteur CSS : « CSS and
249
+ * XPath are not recommended as the DOM can often change » — une classe de
250
+ * composant suit la bibliothèque, le libellé visible est le contrat avec
251
+ * l'utilisateur. On valide par Entrée plutôt que de viser un bouton, dont le
252
+ * texte varie d'une étape à l'autre.
253
+ *
254
+ * `getByRole("textbox", …)` et non `getByLabel(…)` seul : le champ mot de passe
255
+ * partage souvent son libellé avec le bouton « afficher le mot de passe », et le
256
+ * mode strict de Playwright REFUSE alors d'agir (« resolved to 2 elements »)
257
+ * plutôt que de choisir au hasard.
258
+ *
259
+ * @param page - la page à piloter.
260
+ * @param ctx - son contexte, dont l'état est sauvegardé après succès.
261
+ */
262
+ export async function signIn(page, ctx) {
263
+ await page.goto(`${BASE}${LOGIN}`, { waitUntil: "domcontentloaded" });
264
+ const id = page.getByRole("textbox", {
265
+ name: /identifiant|utilisateur|username|e-?mail/i,
266
+ });
267
+ const pw = page.getByRole("textbox", {
268
+ name: /mot de passe|password/i,
269
+ });
270
+ // Un écran de connexion en deux étapes peut MÉMORISER l'identifiant (stockage
271
+ // local) et présenter directement le mot de passe : exiger l'étape 1 faisait
272
+ // expirer la sonde sur un parcours parfaitement sain. On attend la PREMIÈRE
273
+ // des deux étapes qui se présente, et on ne remplit l'identifiant que si son
274
+ // champ existe.
275
+ // Aucun champ trouvé : dire CE QU'ON A CHERCHÉ et où, plutôt que de laisser
276
+ // remonter un dépassement de délai brut. Les deux causes réelles sont
277
+ // banales — le chemin donné n'est pas un écran de connexion (une page
278
+ // d'erreur en rend un 404 tout aussi silencieux), ou les libellés du
279
+ // formulaire ne sont pas ceux qu'on reconnaît. L'un et l'autre se corrigent
280
+ // en une seconde quand on les lit, et coûtent un quart d'heure sinon.
281
+ try {
282
+ await id.or(pw).first().waitFor({ timeout: 15000 });
283
+ } catch {
284
+ const title = await page.title().catch(() => "");
285
+ console.error(
286
+ `Aucun champ de connexion trouvé sur ${BASE}${LOGIN}\n` +
287
+ ` page réellement ouverte : ${page.url()}${title ? ` (« ${title} »)` : ""}\n\n` +
288
+ "Deux causes, à vérifier dans cet ordre :\n" +
289
+ " 1. ce chemin n'est pas ton écran de connexion — une route absente rend\n" +
290
+ " une page d'erreur, où la sonde attendrait indéfiniment ;\n" +
291
+ " 2. tes champs ne portent pas les libellés reconnus (identifiant,\n" +
292
+ " utilisateur, e-mail / mot de passe) — la sonde vise le LIBELLÉ\n" +
293
+ " visible, pas un sélecteur, parce que c'est lui le contrat avec\n" +
294
+ " l'utilisateur. Ajoute un `aria-label` si ton champ n'en a pas.",
295
+ );
296
+ process.exit(65); // EX_DATAERR — l'écran attendu n'est jamais apparu
297
+ }
298
+ if ((await id.count()) > 0) {
299
+ await id.fill(USER);
300
+ await id.press("Enter");
301
+ }
302
+ await pw.fill(PASSWORD, { timeout: 15000 });
303
+ await pw.press("Enter");
304
+ await page.waitForURL((u) => !u.pathname.endsWith(LOGIN), { timeout: 20000 });
305
+ await ctx.storageState({ path: STATE });
306
+ }
307
+
308
+ /**
309
+ * Ouvre la page demandée, en garantissant que c'est bien ELLE qu'on mesure.
310
+ *
311
+ * Un état d'authentification réutilisé peut être PÉRIMÉ (session expirée,
312
+ * serveur redémarré, magasin vidé) : l'application renvoie alors sur le
313
+ * formulaire, et l'on mesurerait l'écran de connexion en croyant tenir la page
314
+ * demandée. On le constate et on refait le parcours plutôt que de rendre une
315
+ * mesure fausse.
316
+ *
317
+ * @param page - la page à piloter.
318
+ * @param ctx - son contexte.
319
+ * @param pathname - le chemin à ouvrir, relatif à l'origine.
320
+ * @param reuse - un état d'authentification a-t-il été repris au lancement.
321
+ */
322
+ export async function goTo(page, ctx, pathname, reuse) {
323
+ if (USER && !reuse) await signIn(page, ctx);
324
+ await page.goto(`${BASE}${pathname}`, { waitUntil: "domcontentloaded" });
325
+ if (USER && reuse) {
326
+ // 🔴 L'URL au `domcontentloaded` MENT sur une application à rendu client :
327
+ // le serveur répond 200 sur toutes les routes, et c'est le code de la
328
+ // page qui, une fois monté, constate la session invalide et renvoie vers
329
+ // le formulaire. Tester l'URL immédiatement laissait donc passer TOUT
330
+ // état périmé — la sonde restait sur l'écran de connexion et concluait
331
+ // « identifiants refusés » sur des identifiants valides. On accorde à ce
332
+ // détour le temps d'avoir lieu ; la fenêtre couvre aussi la redirection
333
+ // serveur (déjà sur le formulaire ⇒ résolue immédiatement), et ne se paie
334
+ // que sur une session REPRISE — jamais après une connexion fraîche.
335
+ const redirected = await page
336
+ .waitForURL((u) => u.pathname.endsWith(LOGIN), { timeout: 3000 })
337
+ .then(
338
+ () => true,
339
+ () => false,
340
+ );
341
+ if (redirected) {
342
+ // Repartir d'un contexte VIERGE avant de rejouer le parcours — cookies
343
+ // ET stockage web. Les cookies : le jeton anti-CSRF est lié à la session
344
+ // morte, la garder fait refuser la soumission et accuser le mot de
345
+ // passe. Le stockage : l'application peut y avoir MÉMORISÉ l'identifiant
346
+ // et présenter un formulaire raccourci — voire connecter un AUTRE
347
+ // utilisateur que celui demandé. Vécu, les deux.
348
+ await ctx.clearCookies();
349
+ await page.evaluate(() => {
350
+ localStorage.clear();
351
+ sessionStorage.clear();
352
+ });
353
+ await signIn(page, ctx);
354
+ await page.goto(`${BASE}${pathname}`, { waitUntil: "domcontentloaded" });
355
+ }
356
+ }
357
+ }