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

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 (65) hide show
  1. package/README.md +151 -5
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  3. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorateParam.js +1 -2
  4. package/dist/index.js +24 -11
  5. package/dist/nodefony/config/config.js +16 -137
  6. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  7. package/dist/nodefony/controllers/DevkitController.js +7 -14
  8. package/dist/nodefony/controllers/McpController.js +27 -163
  9. package/dist/nodefony/controllers/ProfileSessionController.js +47 -0
  10. package/dist/nodefony/mcp/observe.js +235 -0
  11. package/dist/nodefony/mcp/profileReport.js +242 -0
  12. package/dist/nodefony/mcp/profileRoute.js +521 -0
  13. package/dist/nodefony/mcp/shotClient.js +84 -0
  14. package/dist/nodefony/mcp/shutdown.js +44 -0
  15. package/dist/nodefony/mcp/tools.js +696 -0
  16. package/dist/nodefony/service/DevkitService.js +93 -29
  17. package/dist/nodefony/src/card.js +1 -2
  18. package/dist/nodefony/src/errors/DevkitError.js +1 -2
  19. package/dist/nodefony/src/profileSession.js +14 -0
  20. package/dist/types/index.d.ts +15 -1
  21. package/dist/types/nodefony/config/config.d.ts +1 -1
  22. package/dist/types/nodefony/controllers/McpController.d.ts +13 -17
  23. package/dist/types/nodefony/controllers/ProfileSessionController.d.ts +31 -0
  24. package/dist/types/nodefony/interfaces/IDevkitService.d.ts +22 -1
  25. package/dist/types/nodefony/mcp/observe.d.ts +83 -0
  26. package/dist/types/nodefony/mcp/profileReport.d.ts +138 -0
  27. package/dist/types/nodefony/mcp/profileRoute.d.ts +87 -0
  28. package/dist/types/nodefony/mcp/shotClient.d.ts +53 -0
  29. package/dist/types/nodefony/mcp/shutdown.d.ts +25 -0
  30. package/dist/types/nodefony/mcp/tools.d.ts +109 -0
  31. package/dist/types/nodefony/service/DevkitService.d.ts +41 -4
  32. package/dist/types/nodefony/src/profileSession.d.ts +12 -0
  33. package/docs/index.md +217 -20
  34. package/package.json +17 -12
  35. package/skills/nodefony-add-crud/SKILL.md +5 -0
  36. package/skills/nodefony-add-realtime-channel/SKILL.md +66 -9
  37. package/skills/nodefony-add-service/SKILL.md +14 -3
  38. package/skills/nodefony-authentication/SKILL.md +234 -0
  39. package/skills/nodefony-browser/SKILL.md +65 -21
  40. package/skills/nodefony-browser/references/socket.md +13 -4
  41. package/skills/nodefony-browser/references/sondes.md +13 -0
  42. package/skills/nodefony-browser/scripts/audit.mjs +59 -10
  43. package/skills/nodefony-browser/scripts/inspect.mjs +123 -5
  44. package/skills/nodefony-browser/scripts/lib/browser.mjs +112 -6
  45. package/skills/nodefony-browser/scripts/lib/probes.mjs +219 -7
  46. package/skills/nodefony-browser/scripts/socket.mjs +70 -4
  47. package/skills/nodefony-browser/scripts/watch.mjs +101 -0
  48. package/skills/nodefony-dev/SKILL.md +78 -82
  49. package/skills/nodefony-dev/scripts/docs.mjs +24 -8
  50. package/skills/nodefony-devops/SKILL.md +98 -74
  51. package/skills/nodefony-devops/references/compose.md +8 -0
  52. package/skills/nodefony-devops/references/dimensionnement.md +122 -0
  53. package/skills/nodefony-devops/references/frontal.md +282 -48
  54. package/skills/nodefony-devops/references/kubernetes.md +41 -14
  55. package/skills/nodefony-devops/references/secrets.md +30 -5
  56. package/skills/nodefony-devops/references/variables.md +66 -31
  57. package/skills/nodefony-migrate-schema/SKILL.md +5 -2
  58. package/skills/nodefony-migrate-schema/references/verdicts.md +20 -19
  59. package/skills/nodefony-protect-route/SKILL.md +124 -21
  60. package/skills/nodefony-security-review/SKILL.md +127 -0
  61. package/skills/nodefony-security-review/scripts/review-diff.mjs +1090 -0
  62. package/skills/nodefony-troubleshoot/SKILL.md +225 -0
  63. package/skills/nodefony-troubleshoot/scripts/log-errors.mjs +465 -0
  64. package/skills/nodefony-upgrade/SKILL.md +130 -0
  65. package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
@@ -24,11 +24,13 @@
24
24
  * `@env` NF_BROWSER_PASSWORD mot de passe associé
25
25
  * `@env` NF_BROWSER_ACTIONS séquence d'interactions AVANT mesure, séparées par `|` —
26
26
  * `verbe:cible[=valeur]` : clic (défaut), double, droit, survol,
27
- * saisir, touche, voir, defiler, attendre. Un écran qui se déplie
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
30
  * `@env` NF_BROWSER_PROBES sélecteurs CSS à sonder, séparés par des virgules (`libellé=sélecteur`)
31
31
  * `@env` NF_BROWSER_WIDTHS largeurs de la famille responsive (défaut 360,768,1280)
32
+ * `@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
+ * `@env` NF_BROWSER_PRIMARY sélecteur de l'action principale pour la famille pli (défaut : le plus grand bouton de validation visible)
32
34
  * `@env` NF_BROWSER_SEUIL_LOURD bytes au-delà desquels une ressource est « lourde » (défaut 512000)
33
35
  * `@env` NF_BROWSER_SEUIL_LENT millisecondes au-delà desquelles une réponse est « lente » (défaut 1000)
34
36
  * `@requires` conteneur du profil `browser` démarré · serveur joignable depuis le conteneur
@@ -42,6 +44,7 @@ import { open, goTo, LOGIN, OUTPUT } from "./lib/browser.mjs";
42
44
  import { sourceWcag } from "./lib/wcag.mjs";
43
45
  import {
44
46
  FAMILIES,
47
+ captureSlug,
45
48
  parseActions,
46
49
  parseFamilies,
47
50
  parseProbes,
@@ -72,6 +75,7 @@ const EXPECT = process.argv[3] ?? process.env.NF_BROWSER_EXPECT ?? "";
72
75
  * | `droit` | clic droit — ouvre un menu contextuel applicatif |
73
76
  * | `survol` | survol : révèle une infobulle, un menu déroulant |
74
77
  * | `saisir` | remplit un champ (`saisir:Nom=mon-app`) |
78
+ * | `choisir` | choisit une option d'une liste (`choisir:Habillage=dots`) |
75
79
  * | `touche` | frappe une touche (`touche:Enter`, `touche:Escape`) |
76
80
  * | `voir` | amène dans la vue SANS cliquer |
77
81
  * | `defiler` | fait défiler de N pixels (`defiler:600`), page ou conteneur |
@@ -285,7 +289,28 @@ for (const { verb, target, value } of ACTIONS) {
285
289
  break;
286
290
  }
287
291
  }
288
- locator ??= page.locator(target).first();
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
+ }
289
314
  try {
290
315
  await locator.waitFor({ timeout: 15000 });
291
316
  if (verb === "clic") await locator.click();
@@ -293,7 +318,13 @@ for (const { verb, target, value } of ACTIONS) {
293
318
  else if (verb === "droit") await locator.click({ button: "right" });
294
319
  else if (verb === "survol") await locator.hover();
295
320
  else if (verb === "saisir") await locator.fill(value);
296
- else if (verb === "touche") await locator.press(value || "Enter");
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");
297
328
  else if (verb === "voir") await locator.scrollIntoViewIfNeeded();
298
329
  // `attendre` : le `waitFor` ci-dessus EST l'action.
299
330
  } catch {
@@ -758,7 +789,7 @@ if (active.has("reseau")) {
758
789
  const slowThreshold = Number(process.env.NF_BROWSER_SEUIL_LENT ?? 1000);
759
790
  const items = await Promise.all(
760
791
  finishedRequests.map(async (rq) => {
761
- let bytes = null;
792
+ let bytes;
762
793
  try {
763
794
  // `sizes()` rend la taille RÉELLEMENT transférée ; l'en-tête
764
795
  // content-length manque sur les réponses en flux.
@@ -835,8 +866,93 @@ if (active.has("stockage")) {
835
866
  delete measured.stockageWeb;
836
867
  }
837
868
 
869
+ // ── Famille pli — les proportions, sur l'écran DEMANDÉ ──────────────────────
870
+ // Deux questions qu'aucune autre famille ne pose : faut-il défiler pour AGIR
871
+ // (l'action principale est-elle au-dessus du bas de l'écran ?), et quelle place
872
+ // le bloc qui la porte prend-il dans l'écran ? Le défilement seul ne tranche
873
+ // pas : un pied de page sous le pli est normal, un bouton sous le pli ne l'est
874
+ // pas. Mesurée AVANT la famille responsive, qui redimensionne la fenêtre.
875
+ if (active.has("pli")) {
876
+ const primarySelector = process.env.NF_BROWSER_PRIMARY ?? "";
877
+ measured.pli = await page.evaluate((selector) => {
878
+ const visible = (el) => {
879
+ const r = el.getBoundingClientRect();
880
+ const st = getComputedStyle(el);
881
+ return r.width > 0 && r.height > 0 && st.visibility !== "hidden";
882
+ };
883
+ let primary = null;
884
+ if (selector) {
885
+ primary = [...document.querySelectorAll(selector)].find(visible) ?? null;
886
+ } else {
887
+ // Le plus GRAND bouton de validation visible : un bouton « retour » est
888
+ // aussi de type submit, mais jamais le plus large.
889
+ let best = 0;
890
+ for (const el of document.querySelectorAll(
891
+ 'button[type="submit"], input[type="submit"], button:not([type])',
892
+ )) {
893
+ if (!visible(el)) continue;
894
+ const r = el.getBoundingClientRect();
895
+ if (r.width * r.height > best) {
896
+ best = r.width * r.height;
897
+ primary = el;
898
+ }
899
+ }
900
+ }
901
+ const doc = document.scrollingElement ?? document.documentElement;
902
+ const view = { width: innerWidth, height: innerHeight };
903
+ const out = {
904
+ view: `${view.width}×${view.height}`,
905
+ pageHeight: doc.scrollHeight,
906
+ scrollPx: Math.max(0, doc.scrollHeight - view.height),
907
+ overflowXPx: Math.max(0, doc.scrollWidth - view.width),
908
+ };
909
+ if (primary === null) {
910
+ return {
911
+ ...out,
912
+ verdict: "ALERTE",
913
+ reason: "aucune action principale trouvée (NF_BROWSER_PRIMARY)",
914
+ };
915
+ }
916
+ const r = primary.getBoundingClientRect();
917
+ const bottom = Math.round(r.bottom + scrollY);
918
+ // Le BLOC principal : le plus grand ancêtre de l'action qui reste plus
919
+ // étroit que l'écran — une carte, une colonne de formulaire.
920
+ let frame = null;
921
+ for (
922
+ let el = primary.parentElement;
923
+ el && el !== document.body;
924
+ el = el.parentElement
925
+ ) {
926
+ const fr = el.getBoundingClientRect();
927
+ if (fr.width < view.width * 0.96) frame = el;
928
+ }
929
+ const fr = frame?.getBoundingClientRect();
930
+ const label = (primary.innerText || primary.value || "")
931
+ .trim()
932
+ .slice(0, 40);
933
+ const marginPx = view.height - bottom;
934
+ return {
935
+ ...out,
936
+ primary: { label, bottom, marginPx },
937
+ frame: fr
938
+ ? {
939
+ size: `${Math.round(fr.width)}×${Math.round(fr.height)}`,
940
+ widthShare: Math.round((fr.width / view.width) * 100),
941
+ heightShare: Math.round((fr.height / view.height) * 100),
942
+ }
943
+ : null,
944
+ verdict: marginPx < 0 || out.overflowXPx > 0 ? "ALERTE" : "OK",
945
+ ...(marginPx < 0
946
+ ? { reason: `défiler ${-marginPx} px pour atteindre « ${label} »` }
947
+ : out.overflowXPx > 0
948
+ ? { reason: `la page déborde de ${out.overflowXPx} px en largeur` }
949
+ : {}),
950
+ };
951
+ }, primarySelector);
952
+ }
953
+
838
954
  // ── Capture — AVANT la famille responsive, qui déforme le viewport ──────────
839
- const slug = PAGE.replace(/\//g, "-").replace(/^-/, "") || "racine";
955
+ const slug = captureSlug(PAGE);
840
956
  const shot = path.join(OUTPUT, `${slug}-${stamp}.png`);
841
957
  // `NF_BROWSER_FULLPAGE=1` : la page ENTIÈRE, pas la fenêtre. Un formulaire long
842
958
  // (ou une page qu'on vient de déplier) a l'essentiel SOUS la ligne de flottaison
@@ -905,6 +1021,8 @@ console.log(
905
1021
  // Le navigateur qui a produit ces chiffres — un Chrome de système et le
906
1022
  // Chromium du pilote n'ont pas la même version.
907
1023
  browserName: decor.browserUsed,
1024
+ // L'écran mesuré : une proportion sans son écran ne conclut rien.
1025
+ viewport: `${decor.VIEWPORT.name}${decor.VIEWPORT.mobile ? " (téléphone)" : ""}`,
908
1026
  ...measured,
909
1027
  consoleErrors,
910
1028
  uncaughtErrors,
@@ -12,6 +12,7 @@
12
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
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
+ * `@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)
15
16
  * `@env` NF_BROWSER_ENGINE navigateur imposé (chromium, chrome, msedge) ; sans lui, le premier qui répond
16
17
  * `@env` NF_BROWSER_COLOR_SCHEME schéma de couleurs émulé (light, dark, no-preference) ; sans lui, celui du navigateur
17
18
  * `@env` NF_BROWSER_STORAGE entrées de stockage local posées AVANT chargement (`clé=valeur`, séparées par des virgules)
@@ -22,8 +23,10 @@ import {
22
23
  environmentDefaults,
23
24
  authStateName,
24
25
  browserOrder,
26
+ sessionVerdict,
25
27
  parseColorScheme,
26
28
  parseStorage,
29
+ parseViewport,
27
30
  } from "./probes.mjs";
28
31
 
29
32
  /**
@@ -61,12 +64,38 @@ try {
61
64
  * en croyant que l'application est en panne.
62
65
  */
63
66
  const IN_CONTAINER = existsSync("/.dockerenv");
67
+ /**
68
+ * La racine de l'application, pour ancrer `tmp/browser/` — par le sous-chemin
69
+ * léger du cœur (`nodefony/project-root`), et SEULEMENT hors conteneur :
70
+ * là-bas les sondes sont copiées seules, sans `nodefony`, et la sortie est le
71
+ * volume `/output`. Paquet introuvable → `null`, sortie relative au dossier
72
+ * courant.
73
+ */
74
+ const APP_ROOT = IN_CONTAINER
75
+ ? null
76
+ : await import("nodefony/project-root").then(
77
+ (m) => m.findProjectRoot(process.cwd()),
78
+ () => null,
79
+ );
64
80
  const { base: baseUrl, out: OUT } = environmentDefaults({
65
81
  inContainer: IN_CONTAINER,
82
+ root: APP_ROOT,
66
83
  base: process.env.NF_BROWSER_BASE,
67
84
  out: process.env.NF_BROWSER_OUT,
68
85
  });
69
86
 
87
+ /**
88
+ * L'écran mesuré (`NF_BROWSER_VIEWPORT`) — `bureau` (1 440 × 900) par défaut.
89
+ * Un nom inconnu est REFUSÉ (code 64) : mesurer sur un autre écran que celui
90
+ * demandé ferait conclure sur une page qu'on n'a pas regardée.
91
+ */
92
+ const viewport = parseViewport(process.env.NF_BROWSER_VIEWPORT);
93
+ if ("error" in viewport) {
94
+ console.error(`NF_BROWSER_VIEWPORT : ${viewport.error}`);
95
+ process.exit(64); // EX_USAGE
96
+ }
97
+ export const VIEWPORT = viewport;
98
+
70
99
  /** Où atterrissent captures et état d'authentification. */
71
100
  export const OUTPUT = OUT;
72
101
  export const BASE = baseUrl;
@@ -103,6 +132,15 @@ if (USER && !LOGIN) {
103
132
  process.exit(64); // EX_USAGE
104
133
  }
105
134
 
135
+ /**
136
+ * Le point qui PROUVE qu'une session reprise est vivante : 2xx avec un `user`,
137
+ * `{ user: null }` (ou 401/403) sinon. Défaut : celui de `@nodefony/security`, présent dans toute
138
+ * application qui l'installe. Chaîne VIDE = rien pour prouver, donc aucune
139
+ * session n'est reprise — la sonde se connecte à chaque lancement.
140
+ */
141
+ export const WHOAMI =
142
+ process.env.NF_BROWSER_WHOAMI ?? "/nodefony/security/api/auth/me";
143
+
106
144
  /**
107
145
  * L'état d'authentification, SAUVEGARDÉ puis réutilisé d'une sonde à l'autre.
108
146
  *
@@ -111,12 +149,15 @@ if (USER && !LOGIN) {
111
149
  * Sans lui, chaque inspection rejoue le parcours de connexion — quelques
112
150
  * secondes perdues et une occasion d'échec de plus à chaque exécution.
113
151
  *
114
- * Son nom porte l'IDENTIFIANT (cf {@link authStateName}) : un état est la session
152
+ * Son nom porte l'IDENTIFIANT et l'ORIGINE (cf {@link authStateName}) : un état est la session
115
153
  * de quelqu'un, et le réutiliser pour un autre compte fait mesurer une identité
116
154
  * qu'on n'a pas demandée. Effet de bord bienvenu — deux comptes gardent chacun
117
155
  * leur session, donc aucun des deux ne se reconnecte à cause de l'autre.
118
156
  */
119
- const STATE = path.join(OUT, authStateName(process.env.NF_BROWSER_USER));
157
+ const STATE = path.join(
158
+ OUT,
159
+ authStateName(process.env.NF_BROWSER_USER, baseUrl),
160
+ );
120
161
  // Créé AVANT la première écriture : en local, le dossier n'existe pas encore,
121
162
  // et l'échec ne surviendrait qu'à la sauvegarde — après la connexion, donc
122
163
  // après avoir fait croire que tout allait bien.
@@ -141,6 +182,8 @@ mkdirSync(OUT, { recursive: true });
141
182
  const { schema: COLOR_SCHEME, invalid: invalidScheme } = parseColorScheme(
142
183
  process.env.NF_BROWSER_COLOR_SCHEME,
143
184
  );
185
+ /** Schéma de couleurs demandé, `null` sans demande — l'audit le pose aussi au moteur. */
186
+ export { COLOR_SCHEME };
144
187
  if (invalidScheme) {
145
188
  console.error(
146
189
  `NF_BROWSER_COLOR_SCHEME inconnu : « ${invalidScheme} ».\n` +
@@ -201,7 +244,13 @@ export async function open() {
201
244
  }
202
245
  const options = {
203
246
  ignoreHTTPSErrors: true, // certificat de développement auto-signé
204
- viewport: { width: 1440, height: 900 },
247
+ viewport: { width: VIEWPORT.width, height: VIEWPORT.height },
248
+ // Un écran de téléphone se dit TACTILE et dense : des pages en dépendent
249
+ // (`pointer: coarse`, `hover: none`), et une fenêtre étroite seule ne
250
+ // l'annoncerait pas.
251
+ ...(VIEWPORT.mobile
252
+ ? { isMobile: true, hasTouch: true, deviceScaleFactor: 2 }
253
+ : {}),
205
254
  ...(COLOR_SCHEME ? { colorScheme: COLOR_SCHEME } : {}),
206
255
  };
207
256
  let reuse = Boolean(USER) && existsSync(STATE);
@@ -220,6 +269,22 @@ export async function open() {
220
269
  reuse = false;
221
270
  }
222
271
  }
272
+ if (reuse && ctx) {
273
+ const why = await staleSessionReason(ctx);
274
+ if (why !== null) {
275
+ // Un contexte NEUF plutôt que des cookies effacés : le stockage local
276
+ // peut avoir mémorisé un identifiant, et le jeton anti-CSRF est lié à la
277
+ // session morte. La reprise se DIT — rejouée en silence, elle cacherait
278
+ // qu'il y avait quelque chose à rejouer.
279
+ console.error(
280
+ `Session reprise ${why} — l'état est jeté et le parcours de connexion est rejoué.`,
281
+ );
282
+ await ctx.close();
283
+ rmSync(STATE, { force: true });
284
+ ctx = null;
285
+ reuse = false;
286
+ }
287
+ }
223
288
  if (!ctx) ctx = await browser.newContext(options);
224
289
  if (STORAGE.length > 0) {
225
290
  // AVANT tout script de la page, et à CHAQUE navigation : une application
@@ -231,8 +296,7 @@ export async function open() {
231
296
  // prime sur ce qu'une session précédente avait laissé.
232
297
  await ctx.addInitScript((entries) => {
233
298
  try {
234
- for (const { cle: key, valeur: value } of entries)
235
- localStorage.setItem(key, value);
299
+ for (const { key, value } of entries) localStorage.setItem(key, value);
236
300
  } catch {
237
301
  // Stockage refusé (mode privé, origine opaque) : la sonde continue —
238
302
  // le thème sera celui par défaut, et la mesure le DIRA (champ `theme`).
@@ -242,6 +306,39 @@ export async function open() {
242
306
  return { browser, ctx, page: await ctx.newPage(), reuse };
243
307
  }
244
308
 
309
+ /**
310
+ * Prouve qu'une session reprise est vivante, AVANT toute navigation.
311
+ *
312
+ * Sans cette preuve, la sonde ne constatait une session morte que si la page
313
+ * renvoyait vers le formulaire de connexion — ce qu'une page PUBLIQUE ne fait
314
+ * jamais : elle mesurait alors en anonyme, sans le dire.
315
+ *
316
+ * @param ctx - le contexte ouvert sur l'état repris (ses cookies servent la requête).
317
+ * @returns `null` si la session est prouvée vivante, sinon la raison de la jeter.
318
+ */
319
+ async function staleSessionReason(ctx) {
320
+ if (WHOAMI === "") return "non prouvable (NF_BROWSER_WHOAMI vide)";
321
+ let status;
322
+ let body;
323
+ try {
324
+ const answer = await ctx.request.get(`${BASE}${WHOAMI}`, {
325
+ failOnStatusCode: false,
326
+ maxRedirects: 0,
327
+ timeout: 10000,
328
+ });
329
+ status = answer.status();
330
+ // Le corps dit l'anonymat quand le statut ne le dit pas (`{ user: null }`).
331
+ body = await answer.json().catch(() => undefined);
332
+ } catch (e) {
333
+ return `non prouvable (${WHOAMI} injoignable : ${String(e).split("\n")[0].slice(0, 120)})`;
334
+ }
335
+ const verdict = sessionVerdict(status, body);
336
+ if (verdict === "alive") return null;
337
+ return verdict === "dead"
338
+ ? `PÉRIMÉE (${WHOAMI} → ${status}${status < 300 ? ", user: null" : ""})`
339
+ : `non prouvable (${WHOAMI} → ${status}, attendu 2xx, ou 401/403 — NF_BROWSER_WHOAMI)`;
340
+ }
341
+
245
342
  /**
246
343
  * Connexion par le formulaire, en deux temps (identifiant, puis mot de passe).
247
344
  *
@@ -301,7 +398,16 @@ export async function signIn(page, ctx) {
301
398
  }
302
399
  await pw.fill(PASSWORD, { timeout: 15000 });
303
400
  await pw.press("Enter");
304
- await page.waitForURL((u) => !u.pathname.endsWith(LOGIN), { timeout: 20000 });
401
+ // Connexion aboutie = on a quitté l'écran de connexion, OU le formulaire a
402
+ // disparu. Le second cas est celui d'un fournisseur d'identité (OpenID
403
+ // Connect) : la page demandée renvoie chez lui, et il RAMÈNE sur cette même
404
+ // page — l'URL finit donc comme elle a commencé, et attendre de la quitter
405
+ // expirait sur une connexion réussie. Un mot de passe refusé laisse le champ
406
+ // en place : les deux attentes expirent, comme avant.
407
+ await Promise.any([
408
+ page.waitForURL((u) => !u.pathname.endsWith(LOGIN), { timeout: 20000 }),
409
+ pw.waitFor({ state: "detached", timeout: 20000 }),
410
+ ]);
305
411
  await ctx.storageState({ path: STATE });
306
412
  }
307
413