@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.
- package/README.md +151 -5
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorateParam.js +1 -2
- package/dist/index.js +24 -11
- package/dist/nodefony/config/config.js +16 -137
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/controllers/DevkitController.js +7 -14
- package/dist/nodefony/controllers/McpController.js +27 -163
- package/dist/nodefony/controllers/ProfileSessionController.js +47 -0
- package/dist/nodefony/mcp/observe.js +235 -0
- package/dist/nodefony/mcp/profileReport.js +242 -0
- package/dist/nodefony/mcp/profileRoute.js +521 -0
- package/dist/nodefony/mcp/shotClient.js +84 -0
- package/dist/nodefony/mcp/shutdown.js +44 -0
- package/dist/nodefony/mcp/tools.js +696 -0
- package/dist/nodefony/service/DevkitService.js +93 -29
- package/dist/nodefony/src/card.js +1 -2
- package/dist/nodefony/src/errors/DevkitError.js +1 -2
- package/dist/nodefony/src/profileSession.js +14 -0
- package/dist/types/index.d.ts +15 -1
- package/dist/types/nodefony/config/config.d.ts +1 -1
- package/dist/types/nodefony/controllers/McpController.d.ts +13 -17
- package/dist/types/nodefony/controllers/ProfileSessionController.d.ts +31 -0
- package/dist/types/nodefony/interfaces/IDevkitService.d.ts +22 -1
- package/dist/types/nodefony/mcp/observe.d.ts +83 -0
- package/dist/types/nodefony/mcp/profileReport.d.ts +138 -0
- package/dist/types/nodefony/mcp/profileRoute.d.ts +87 -0
- package/dist/types/nodefony/mcp/shotClient.d.ts +53 -0
- package/dist/types/nodefony/mcp/shutdown.d.ts +25 -0
- package/dist/types/nodefony/mcp/tools.d.ts +109 -0
- package/dist/types/nodefony/service/DevkitService.d.ts +41 -4
- package/dist/types/nodefony/src/profileSession.d.ts +12 -0
- package/docs/index.md +217 -20
- package/package.json +17 -12
- package/skills/nodefony-add-crud/SKILL.md +5 -0
- package/skills/nodefony-add-realtime-channel/SKILL.md +66 -9
- package/skills/nodefony-add-service/SKILL.md +14 -3
- package/skills/nodefony-authentication/SKILL.md +234 -0
- package/skills/nodefony-browser/SKILL.md +65 -21
- package/skills/nodefony-browser/references/socket.md +13 -4
- package/skills/nodefony-browser/references/sondes.md +13 -0
- package/skills/nodefony-browser/scripts/audit.mjs +59 -10
- package/skills/nodefony-browser/scripts/inspect.mjs +123 -5
- package/skills/nodefony-browser/scripts/lib/browser.mjs +112 -6
- package/skills/nodefony-browser/scripts/lib/probes.mjs +219 -7
- package/skills/nodefony-browser/scripts/socket.mjs +70 -4
- package/skills/nodefony-browser/scripts/watch.mjs +101 -0
- package/skills/nodefony-dev/SKILL.md +78 -82
- package/skills/nodefony-dev/scripts/docs.mjs +24 -8
- package/skills/nodefony-devops/SKILL.md +98 -74
- package/skills/nodefony-devops/references/compose.md +8 -0
- package/skills/nodefony-devops/references/dimensionnement.md +122 -0
- package/skills/nodefony-devops/references/frontal.md +282 -48
- package/skills/nodefony-devops/references/kubernetes.md +41 -14
- package/skills/nodefony-devops/references/secrets.md +30 -5
- package/skills/nodefony-devops/references/variables.md +66 -31
- package/skills/nodefony-migrate-schema/SKILL.md +5 -2
- package/skills/nodefony-migrate-schema/references/verdicts.md +20 -19
- package/skills/nodefony-protect-route/SKILL.md +124 -21
- package/skills/nodefony-security-review/SKILL.md +127 -0
- package/skills/nodefony-security-review/scripts/review-diff.mjs +1090 -0
- package/skills/nodefony-troubleshoot/SKILL.md +225 -0
- package/skills/nodefony-troubleshoot/scripts/log-errors.mjs +465 -0
- package/skills/nodefony-upgrade/SKILL.md +130 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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
|
|