- Vue d'ensemble
+
+ Pilotage
Groupes
- Activité
- Actions
- LLM
+ Localisations
+ Journal
+ LLM & coûts
diff --git a/apps/admin/site/styles.css b/apps/admin/site/styles.css
index e539d5c..bef9970 100644
--- a/apps/admin/site/styles.css
+++ b/apps/admin/site/styles.css
@@ -10,6 +10,12 @@
--ok: #3fae5a;
--warn: #d99a2b;
--err: #c61a1a;
+ /* --err sert aux bordures et aux fonds. Pour du TEXTE sur fond sombre il
+ échoue au contraste WCAG AA (3,4:1) : les messages d'erreur du dashboard
+ étaient difficiles à lire. --err-text monte à ~6,5:1. Détecté par les
+ tests axe sur les vues rendues (apps/admin/test/e2e/a11y.spec.js). */
+ --err-text: #ff6b6b;
+ --ok-text: #5fd47c;
}
* { box-sizing: border-box; }
@@ -99,7 +105,7 @@ a { color: inherit; }
}
.login-error {
- color: var(--err);
+ color: var(--err-text);
font-size: 12px;
margin-top: 12px;
min-height: 14px;
@@ -207,9 +213,9 @@ a { color: inherit; }
.stat-card .label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.4px; font-weight: 700; }
.stat-card .value { font-size: 30px; font-weight: 800; margin-top: 6px; color: var(--bone); }
-.stat-card .value.ok { color: var(--ok); }
+.stat-card .value.ok { color: var(--ok-text); }
.stat-card .value.warn { color: var(--warn); }
-.stat-card .value.err { color: var(--err); }
+.stat-card .value.err { color: var(--err-text); }
h2 { font-size: 16px; margin: 0 0 14px; color: var(--bone); }
@@ -254,9 +260,9 @@ tbody tr:hover { background: rgba(198,26,26,0.05); }
tbody tr.clickable { cursor: pointer; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 700; }
-.badge.ok { background: rgba(63,174,90,0.15); color: var(--ok); }
+.badge.ok { background: rgba(63,174,90,0.15); color: var(--ok-text); }
.badge.warn { background: rgba(217,154,43,0.15); color: var(--warn); }
-.badge.err { background: rgba(198,26,26,0.15); color: var(--err); }
+.badge.err { background: rgba(198,26,26,0.15); color: var(--err-text); }
.badge.muted { background: rgba(162,176,194,0.15); color: var(--muted); }
.pager { display: flex; align-items: center; gap: 10px; margin-top: 14px; font-size: 12px; color: var(--muted); }
@@ -279,7 +285,7 @@ tbody tr.clickable { cursor: pointer; }
font-size: 13px; outline: none; font-family: inherit;
}
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 18px; }
-.modal-error { color: var(--err); font-size: 12px; margin-top: 10px; }
+.modal-error { color: var(--err-text); font-size: 12px; margin-top: 10px; }
.modal-close { float: right; cursor: pointer; color: var(--muted); font-size: 18px; line-height: 1; }
.log-line { font-family: ui-monospace, Consolas, monospace; font-size: 12px; padding: 4px 0; border-top: 1px solid rgba(255,255,255,0.04); display: flex; gap: 10px; }
@@ -287,7 +293,7 @@ tbody tr.clickable { cursor: pointer; }
.log-line .lvl { flex-shrink: 0; width: 56px; font-weight: 700; }
.log-line .lvl.info { color: var(--muted); }
.log-line .lvl.warning { color: var(--warn); }
-.log-line .lvl.error { color: var(--err); }
+.log-line .lvl.error { color: var(--err-text); }
.empty { color: var(--muted); font-size: 13px; padding: 20px; text-align: center; }
.loading { color: var(--muted); font-size: 13px; padding: 20px; text-align: center; }
@@ -349,3 +355,178 @@ tbody tr.clickable { cursor: pointer; }
border-top: 1px solid rgba(255,255,255,0.05);
}
.mon-run-row:first-child { border-top: none; padding-top: 0; }
+
+/* ==================================================================
+ Refonte : composants du découpage par intention
+ ================================================================== */
+
+/* --- En-tête de vue --- */
+.view-head {
+ display: flex; align-items: baseline; justify-content: space-between;
+ gap: 16px; margin-bottom: 4px; flex-wrap: wrap;
+}
+.view-head h1 { font-size: 20px; margin: 0 0 8px; color: var(--bone); letter-spacing: .2px; }
+.refresh-note { font-size: 11px; color: var(--muted); }
+.muted-note { font-size: 12px; color: var(--muted); margin: 0 0 14px; line-height: 1.5; }
+.mt { margin-top: 18px; }
+.count { font-weight: 400; color: var(--muted); }
+.sub { font-size: 11px; color: var(--muted); }
+.mono { font-family: ui-monospace, Consolas, monospace; font-size: 12px; }
+.nowrap { white-space: nowrap; }
+.truncate { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.err-text { color: var(--err-text); }
+.warn-text { color: var(--warn); }
+.empty { padding: 18px; text-align: center; color: var(--muted); font-size: 13px; }
+.err-box { color: var(--err-text); }
+.loading { padding: 18px; color: var(--muted); font-size: 13px; }
+
+/* --- Bandeau de santé --- */
+.health-strip {
+ display: grid; gap: 12px; margin-bottom: 16px;
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+}
+.health-card {
+ background: rgba(255,255,255,0.03); border: 1px solid var(--border);
+ border-radius: 14px; padding: 12px 14px;
+ border-left: 3px solid var(--muted);
+}
+.health-card.ok { border-left-color: var(--ok); }
+.health-card.warn { border-left-color: var(--warn); }
+.health-card.err { border-left-color: var(--err); }
+.health-card .h-label {
+ font-size: 10px; text-transform: uppercase; letter-spacing: .5px;
+ color: var(--muted); font-weight: 700;
+}
+.health-card .h-value { font-size: 24px; font-weight: 800; color: var(--bone); margin: 4px 0 2px; }
+.health-card .h-sub { font-size: 11px; color: var(--muted); }
+
+/* --- Alertes actionnables : le chiffre et son remède côte à côte --- */
+.alerts { display: flex; flex-direction: column; gap: 8px; margin-bottom: 20px; }
+.alert {
+ display: flex; align-items: center; justify-content: space-between; gap: 12px;
+ flex-wrap: wrap;
+ padding: 10px 14px; border-radius: 12px; font-size: 13px;
+ background: rgba(255,255,255,0.03); border: 1px solid var(--border);
+ border-left: 3px solid var(--muted);
+}
+.alert-err { border-left-color: var(--err); background: rgba(198,26,26,0.07); }
+.alert-warn { border-left-color: var(--warn); background: rgba(217,154,43,0.06); }
+.alert-ok { border-left-color: var(--ok); background: rgba(63,174,90,0.06); }
+.alert-actions { display: flex; gap: 8px; flex-shrink: 0; }
+.alert-actions .btn { text-decoration: none; }
+
+/* --- Boutons --- */
+.btn-row { display: flex; flex-wrap: wrap; gap: 8px; }
+.btn-col { display: flex; flex-direction: column; gap: 8px; }
+.btn-col .btn { justify-content: flex-start; text-align: left; }
+.btn .hint { font-weight: 400; font-size: 11px; color: var(--muted); margin-left: 8px; }
+.btn-danger { border-color: rgba(198,26,26,0.5); color: #f0b8b8; }
+.btn-danger:hover { background: rgba(198,26,26,0.18); }
+.btn-primary-sm {
+ background: rgba(198,26,26,0.18); border-color: rgba(198,26,26,0.45);
+ padding: 8px 14px; font-size: 13px;
+}
+.btn:disabled { opacity: .5; cursor: not-allowed; transform: none; }
+.link-btn {
+ background: none; border: 0; padding: 0; cursor: pointer;
+ color: var(--bone); font: inherit; text-align: left; text-decoration: underline;
+ text-decoration-color: rgba(255,255,255,0.25);
+}
+.link-btn:hover { color: #fff; }
+.th-sort {
+ background: none; border: 0; padding: 0; cursor: pointer;
+ color: inherit; font: inherit; font-weight: 700;
+}
+
+/* --- Recherche et chips --- */
+.search-bar { display: flex; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; align-items: center; }
+.search-bar input[type="search"], .search-bar input[type="text"], .search-bar select {
+ flex: 1 1 240px; min-width: 140px;
+ background: rgba(255,255,255,0.04); border: 1px solid var(--border);
+ color: var(--text); padding: 9px 12px; border-radius: 10px; font-size: 13px;
+}
+.search-bar input:focus, .search-bar select:focus {
+ outline: none; border-color: rgba(198,26,26,0.45);
+}
+.inline-check { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
+
+.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; align-items: center; }
+.chip {
+ background: rgba(255,255,255,0.04); border: 1px solid var(--border);
+ color: var(--muted); padding: 6px 12px; border-radius: 999px;
+ font-size: 12px; font-weight: 600; cursor: pointer;
+}
+.chip:hover { color: var(--text); border-color: rgba(255,255,255,0.2); }
+.chip.on {
+ background: rgba(198,26,26,0.18); border-color: rgba(198,26,26,0.5); color: var(--bone);
+}
+.chip-clear { margin-left: auto; }
+.chip-sep { width: 1px; height: 20px; background: var(--border); }
+
+/* --- Traitements en cours --- */
+.run-row {
+ display: flex; align-items: center; justify-content: space-between; gap: 12px;
+ padding: 10px 0; border-top: 1px solid rgba(255,255,255,0.05);
+}
+.run-row:first-of-type { border-top: 0; }
+.run-meta { font-size: 11px; color: var(--muted); margin-top: 5px; }
+.run-cancel-note { font-size: 11px; color: var(--warn); margin-top: 3px; }
+
+/* --- Zone dangereuse --- */
+.danger-zone { border-color: rgba(198,26,26,0.3); margin-top: 20px; }
+.danger-zone summary {
+ cursor: pointer; font-weight: 700; color: var(--err-text); font-size: 13px;
+}
+.danger-zone[open] summary { margin-bottom: 10px; }
+
+.feedback { font-size: 12px; margin-top: 10px; color: var(--muted); min-height: 1em; }
+.feedback.ok { color: var(--ok-text); }
+.feedback.err { color: var(--err-text); }
+
+/* --- Tables : lignes activables au clavier --- */
+tr.clickable { cursor: pointer; }
+tr.clickable:hover { background: rgba(255,255,255,0.03); }
+tr.clickable:focus-visible { outline: 2px solid var(--blood); outline-offset: -2px; }
+.lock { color: var(--blood); }
+
+/* --- Modales --- */
+.modal-wide { max-width: 900px; }
+.modal-section { margin-bottom: 20px; }
+.modal-section > h4 { font-size: 13px; margin: 0 0 10px; color: var(--bone); }
+.modal-section > summary { cursor: pointer; font-weight: 700; color: var(--muted); font-size: 13px; }
+.modal-section.conflicts {
+ border: 1px solid rgba(198,26,26,0.35); border-radius: 12px; padding: 12px;
+}
+.modal-section.conflicts h4 { color: var(--err-text); }
+.field-grid { display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
+.field-grid label { margin-bottom: 0; }
+.src-grid { display: flex; flex-direction: column; gap: 4px; margin-top: 10px; }
+.src-grid > div { display: flex; justify-content: space-between; gap: 12px; font-size: 12px; }
+.src-grid > div > span:first-child { color: var(--muted); }
+.modal-close {
+ float: right; background: none; border: 0; cursor: pointer;
+ color: var(--muted); font-size: 22px; line-height: 1; padding: 0 4px;
+}
+.modal-close:hover { color: var(--text); }
+.json, .llm-detail {
+ font-size: 11px; white-space: pre-wrap; word-break: break-word;
+ background: rgba(255,255,255,0.03); padding: 8px; border-radius: 6px;
+ max-height: 300px; overflow: auto; color: var(--muted); margin: 6px 0 0;
+}
+.llm-item { margin: 4px 0; background: rgba(255,255,255,0.03); border-radius: 6px; padding: 6px 8px; }
+.llm-item summary { cursor: pointer; font-size: 12px; }
+.detail-row td { padding-top: 0; }
+.detail-row summary { cursor: pointer; font-size: 11px; color: var(--muted); }
+
+/* --- Logs --- */
+.log-stream { max-height: 400px; overflow-y: auto; }
+.log-line .msg { word-break: break-word; }
+.lvl-error { color: var(--err-text); }
+.lvl-warning { color: var(--warn); }
+.lvl-info { color: var(--muted); }
+
+@media (max-width: 720px) {
+ .view-head { flex-direction: column; align-items: flex-start; }
+ .alert { flex-direction: column; align-items: flex-start; }
+ .truncate { max-width: 140px; }
+}
diff --git a/apps/admin/test/e2e/a11y.spec.js b/apps/admin/test/e2e/a11y.spec.js
new file mode 100644
index 0000000..b5e9f45
--- /dev/null
+++ b/apps/admin/test/e2e/a11y.spec.js
@@ -0,0 +1,89 @@
+import { test, expect } from "@playwright/test";
+import AxeBuilder from "@axe-core/playwright";
+
+/**
+ * Accessibilité des vues RENDUES.
+ *
+ * Le test statique (apps/web/test/a11y.test.js) analyse le HTML livré : pour le
+ * dashboard, ce n'est qu'une coquille vide, puisque tout est construit en
+ * JavaScript. C'est donc ici, dans un vrai navigateur après rendu, que
+ * l'accessibilité du dashboard est réellement vérifiée — contraste des couleurs
+ * compris, ce que jsdom ne sait pas calculer.
+ */
+
+const VIEWS = [
+ ["pilotage", "Pilotage"],
+ ["bands", "Groupes"],
+ ["locations", "Localisations"],
+ ["activity", "Journal"],
+ ["llm", "LLM & coûts"],
+];
+
+async function analyse(page) {
+ return new AxeBuilder({ page })
+ .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
+ .analyze();
+}
+
+function describeViolations(results) {
+ return results.violations.map((v) =>
+ `[${v.impact}] ${v.id}: ${v.help}\n ${v.nodes.map((n) => n.target.join(" ")).join("\n ")}`);
+}
+
+test.describe("Accessibilité des vues", () => {
+ for (const [view, title] of VIEWS) {
+ test(`${title} — aucune violation critique ou sérieuse`, async ({ page }) => {
+ await page.goto("/");
+ await page.click(`.nav a[data-view="${view}"]`);
+ await expect(page.locator(".view-head h1")).toHaveText(title);
+ // Laisse les chargements asynchrones peindre leur contenu.
+ await page.waitForLoadState("networkidle");
+
+ const results = await analyse(page);
+ const blocking = results.violations.filter((v) => ["critical", "serious"].includes(v.impact));
+ expect(describeViolations({ violations: blocking }), describeViolations({ violations: blocking }).join("\n")).toEqual([]);
+ });
+ }
+
+ test("l'écran de connexion est accessible", async ({ page }) => {
+ await page.context().clearCookies();
+ await page.goto("/");
+ await expect(page.locator("#login-screen")).toBeVisible();
+ const results = await analyse(page);
+ const blocking = results.violations.filter((v) => ["critical", "serious"].includes(v.impact));
+ expect(describeViolations({ violations: blocking }), describeViolations({ violations: blocking }).join("\n")).toEqual([]);
+ });
+
+ test("la fiche d'un groupe reste accessible une fois ouverte", async ({ page }) => {
+ await page.goto("/");
+ await page.click('.nav a[data-view="bands"]');
+ await page.locator('tr[data-ma-id="1"]').click();
+ await expect(page.locator(".modal-backdrop .modal")).toBeVisible();
+
+ const results = await analyse(page);
+ const blocking = results.violations.filter((v) => ["critical", "serious"].includes(v.impact));
+ expect(describeViolations({ violations: blocking }), describeViolations({ violations: blocking }).join("\n")).toEqual([]);
+ });
+});
+
+test.describe("Navigation au clavier", () => {
+ test("on atteint la navigation puis le contenu à la tabulation", async ({ page }) => {
+ await page.goto("/");
+ await page.keyboard.press("Tab");
+ const firstFocus = await page.evaluate(() => document.activeElement?.getAttribute("data-view"));
+ expect(firstFocus).toBe("pilotage");
+ });
+
+ test("le focus est piégé dans la fiche ouverte", async ({ page }) => {
+ await page.goto("/");
+ await page.click('.nav a[data-view="bands"]');
+ await page.locator('tr[data-ma-id="1"]').click();
+ await expect(page.locator(".modal-backdrop")).toBeVisible();
+
+ // Vingt tabulations doivent toutes rester à l'intérieur de la modale :
+ // sinon le focus repart derrière le voile, invisible pour l'utilisateur.
+ for (let i = 0; i < 20; i++) await page.keyboard.press("Tab");
+ const inside = await page.evaluate(() => !!document.activeElement?.closest(".modal-backdrop"));
+ expect(inside).toBe(true);
+ });
+});
diff --git a/apps/admin/test/e2e/admin.spec.js b/apps/admin/test/e2e/admin.spec.js
new file mode 100644
index 0000000..8f0695c
--- /dev/null
+++ b/apps/admin/test/e2e/admin.spec.js
@@ -0,0 +1,431 @@
+import { test, expect } from "@playwright/test";
+
+/**
+ * Parcours du dashboard admin.
+ *
+ * Ces tests décrivent ce qu'un administrateur vient faire, dans l'ordre où il
+ * le fait — c'est le critère qui a guidé le redécoupage des onglets.
+ */
+
+const USER = "nico";
+const PASSWORD = "motdepasse-e2e";
+
+/** Connexion explicite — utilisée uniquement par le bloc Authentification. */
+async function login(page) {
+ await page.goto("/");
+ await page.fill("#login-username", USER);
+ await page.fill("#login-password", PASSWORD);
+ await page.click('#login-form button[type="submit"]');
+ await expect(page.locator("#app")).toBeVisible();
+}
+
+// ------------------------------------------------------------------
+test.describe("Authentification", () => {
+ // Seul bloc à repartir sans session : c'est le parcours de connexion qu'il teste.
+ test.use({ storageState: { cookies: [], origins: [] } });
+
+ test("l'écran de connexion s'affiche tant qu'on n'est pas identifié", async ({ page }) => {
+ await page.goto("/");
+ await expect(page.locator("#login-screen")).toBeVisible();
+ await expect(page.locator("#app")).toBeHidden();
+ });
+
+ test("un mauvais mot de passe affiche une erreur et ne laisse pas entrer", async ({ page }) => {
+ await page.goto("/");
+ await page.fill("#login-username", USER);
+ await page.fill("#login-password", "mauvais");
+ await page.click('#login-form button[type="submit"]');
+
+ await expect(page.locator("#login-error")).toHaveText(/identifiants invalides/i);
+ await expect(page.locator("#app")).toBeHidden();
+ });
+
+ test("la connexion donne accès au dashboard et affiche l'utilisateur", async ({ page }) => {
+ await login(page);
+ await expect(page.locator("#whoami")).toHaveText(USER);
+ });
+
+ test("le champ mot de passe est vidé après connexion", async ({ page }) => {
+ await login(page);
+ await expect(page.locator("#login-password")).toHaveValue("");
+ });
+
+ test("la déconnexion ramène à l'écran de connexion", async ({ page }) => {
+ // Ce bloc n'a pas de session partagée : il faut se connecter d'abord.
+ await login(page);
+ await page.click("#logout-btn");
+ await expect(page.locator("#login-screen")).toBeVisible();
+ await expect(page.locator("#app")).toBeHidden();
+ });
+});
+
+// ------------------------------------------------------------------
+test.describe("Navigation", () => {
+ test.beforeEach(async ({ page }) => { await page.goto("/"); });
+
+ test("les cinq sections sont accessibles et marquent l'onglet actif", async ({ page }) => {
+ const sections = [
+ ["pilotage", "Pilotage"],
+ ["bands", "Groupes"],
+ ["locations", "Localisations"],
+ ["activity", "Journal"],
+ ["llm", "LLM & coûts"],
+ ];
+ for (const [view, title] of sections) {
+ await page.click(`.nav a[data-view="${view}"]`);
+ await expect(page.locator(".view-head h1")).toHaveText(title);
+ await expect(page.locator(`.nav a[data-view="${view}"]`)).toHaveAttribute("aria-current", "page");
+ }
+ });
+
+ test("l'ancienne URL #/actions redirige vers Pilotage (favoris préservés)", async ({ page }) => {
+ await page.goto("/#/actions");
+ await expect(page.locator(".view-head h1")).toHaveText("Pilotage");
+ });
+
+ test("une URL inconnue retombe sur Pilotage", async ({ page }) => {
+ await page.goto("/#/nimportequoi");
+ await expect(page.locator(".view-head h1")).toHaveText("Pilotage");
+ });
+});
+
+// ------------------------------------------------------------------
+test.describe("Pilotage — est-ce que ça tourne ?", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto("/");
+ await page.click('.nav a[data-view="pilotage"]');
+ });
+
+ test("le bandeau de santé résume l'état du pipeline", async ({ page }) => {
+ const strip = page.locator("#p-health");
+ await expect(strip.locator(".health-card")).toHaveCount(5);
+ await expect(strip).toContainText("Géocodage");
+ await expect(strip).toContainText("Coût LLM");
+ });
+
+ // Le cœur de la refonte : plus de « je constate ici, j'agis ailleurs ».
+ test("chaque problème est affiché avec le bouton qui le résout", async ({ page }) => {
+ const alert = page.locator(".alert-err").filter({ hasText: "erreur de géocodage" });
+ await expect(alert).toBeVisible();
+ await expect(alert).toContainText("12");
+ await expect(alert.getByRole("button", { name: "Tout remettre en file" })).toBeVisible();
+ await expect(alert.getByRole("link", { name: "Examiner" })).toBeVisible();
+ });
+
+ test("« Examiner » amène sur les localisations filtrées sur le bon statut", async ({ page }) => {
+ await page.locator(".alert").filter({ hasText: "erreur de géocodage" })
+ .getByRole("link", { name: "Examiner" }).click();
+
+ await expect(page.locator(".view-head h1")).toHaveText("Localisations");
+ await expect(page.locator('.chip[data-status="error"]')).toHaveAttribute("aria-pressed", "true");
+ });
+
+ test("les traitements peuvent être lancés depuis le pilotage", async ({ page }) => {
+ await page.click('[data-job="enrich"]');
+ await expect(page.locator("#p-job-feedback")).toContainText("Demande #77 enregistrée");
+ await expect(page.locator("#p-job-feedback")).toHaveClass(/ok/);
+ });
+
+ test("les opérations destructives sont repliées et prévenues", async ({ page }) => {
+ const danger = page.locator("#p-danger");
+ await expect(danger.locator(".btn-danger").first()).toBeHidden();
+ await danger.locator("summary").click();
+ await expect(danger.locator('[data-danger="reset-all"]')).toBeVisible();
+ await expect(danger).toContainText("facturés");
+ });
+
+ test("une opération destructive demande confirmation et respecte le refus", async ({ page }) => {
+ page.on("dialog", (d) => d.dismiss());
+ await page.locator("#p-danger summary").click();
+ await page.click('[data-danger="reset-all"]');
+ await expect(page.locator("#p-danger-feedback")).toBeEmpty();
+ });
+});
+
+// ------------------------------------------------------------------
+test.describe("Pilotage — annulation coopérative", () => {
+ test("un run actif peut être arrêté, et l'état intermédiaire est explicite", async ({ page }) => {
+ // Un run actif depuis 5 min, non encore annulé.
+ await page.route("**/admin/api/live", async (route) => {
+ const res = await route.fetch();
+ const body = await res.json();
+ body.active_runs = [{
+ id: 5, run_type: "enrich", status: "running",
+ started_at: new Date(Date.now() - 5 * 60000).toISOString(),
+ bands_seen: 120, bands_new: 3, bands_updated: 0, bands_enriched: 118,
+ error: null, cancel_requested: false,
+ }];
+ await route.fulfill({ response: res, json: body });
+ });
+
+ await page.goto("/");
+ await page.click('.nav a[data-view="pilotage"]');
+ await expect(page.locator(".run-row")).toContainText("enrich");
+
+ // Le libellé doit dire que l'arrêt est demandé, pas immédiat : le crawler
+ // ne s'interrompt qu'à son prochain point de contrôle.
+ const dialog = new Promise((resolve) => page.once("dialog", (d) => { resolve(d.message()); d.accept(); }));
+ await page.click("[data-cancel-run]");
+ expect(await dialog).toMatch(/point de contrôle/i);
+ });
+
+ test("un run dont l'arrêt est déjà demandé affiche l'attente et désactive le bouton", async ({ page }) => {
+ await page.route("**/admin/api/live", async (route) => {
+ const res = await route.fetch();
+ const body = await res.json();
+ body.active_runs = [{
+ id: 5, run_type: "full_europe", status: "running",
+ started_at: new Date(Date.now() - 60000).toISOString(),
+ bands_seen: 10, bands_new: 0, bands_updated: 0, bands_enriched: 0,
+ error: null, cancel_requested: true, cancel_requested_by: "nico",
+ }];
+ await route.fulfill({ response: res, json: body });
+ });
+
+ await page.goto("/");
+ await page.click('.nav a[data-view="pilotage"]');
+ await expect(page.locator(".run-cancel-note")).toContainText("Arrêt demandé");
+ await expect(page.locator(".run-cancel-note")).toContainText("nico");
+ await expect(page.locator(".run-row button")).toBeDisabled();
+ });
+});
+
+// ------------------------------------------------------------------
+test.describe("Groupes — trouver et corriger", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto("/");
+ await page.click('.nav a[data-view="bands"]');
+ });
+
+ test("la liste s'affiche avec le nombre total", async ({ page }) => {
+ await expect(page.locator("#b-table tbody tr")).toHaveCount(3);
+ await expect(page.locator("#b-count")).toContainText("3");
+ });
+
+ test("la recherche part sur Entrée et transmet le terme à l'API", async ({ page }) => {
+ const request = page.waitForRequest((r) => r.url().includes("q=mayhem"));
+ await page.fill("#b-q", "mayhem");
+ await page.press("#b-q", "Enter");
+ await request;
+ });
+
+ test("les filtres rapides sont des bascules", async ({ page }) => {
+ const chip = page.locator('.chip[data-chip="has_conflict"]');
+ await expect(chip).toHaveAttribute("aria-pressed", "false");
+ await chip.click();
+ await expect(page.locator('.chip[data-chip="has_conflict"]')).toHaveAttribute("aria-pressed", "true");
+ await page.locator('.chip[data-chip="has_conflict"]').click();
+ await expect(page.locator('.chip[data-chip="has_conflict"]')).toHaveAttribute("aria-pressed", "false");
+ });
+
+ test("les filtres avancés sont masqués par défaut", async ({ page }) => {
+ await expect(page.locator("#b-advanced")).toBeHidden();
+ await page.click("#b-advanced-toggle");
+ await expect(page.locator("#b-advanced")).toBeVisible();
+ await expect(page.locator("#b-advanced-toggle")).toHaveAttribute("aria-expanded", "true");
+ });
+
+ test("un groupe sans coordonnées est signalé dans la liste", async ({ page }) => {
+ const row = page.locator('tr[data-ma-id="2"]');
+ await expect(row.locator('[title="Pas de coordonnées"]')).toBeVisible();
+ });
+
+ test("un groupe avec des champs verrouillés porte un cadenas", async ({ page }) => {
+ await expect(page.locator('tr[data-ma-id="2"] .lock')).toBeVisible();
+ });
+
+ test("cliquer une ligne ouvre la fiche du groupe", async ({ page }) => {
+ await page.locator('tr[data-ma-id="1"]').click();
+ const modal = page.locator(".modal-backdrop .modal");
+ await expect(modal).toBeVisible();
+ await expect(modal.locator("h3")).toContainText("Mayhem");
+ await expect(modal.locator("#m-name")).toHaveValue("Mayhem");
+ });
+
+ // Les lignes de tableau sont souvent inaccessibles au clavier : ici elles
+ // portent role=button et tabindex, et répondent à Entrée.
+ test("une ligne s'ouvre aussi au clavier", async ({ page }) => {
+ await page.locator('tr[data-ma-id="1"]').focus();
+ await page.keyboard.press("Enter");
+ await expect(page.locator(".modal-backdrop .modal")).toBeVisible();
+ });
+
+ test("la fiche se ferme avec Échap et rend le focus", async ({ page }) => {
+ await page.locator('tr[data-ma-id="1"]').click();
+ await expect(page.locator(".modal-backdrop")).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(page.locator(".modal-backdrop")).toHaveCount(0);
+ await expect(page.locator('tr[data-ma-id="1"]')).toBeFocused();
+ });
+
+ test("l'édition envoie bien les champs modifiés", async ({ page }) => {
+ await page.locator('tr[data-ma-id="1"]').click();
+ await page.fill("#m-name", "Mayhem (corrigé)");
+
+ const request = page.waitForRequest(
+ (r) => r.url().includes("/admin/api/bands/1") && r.method() === "PATCH");
+ await page.click("#m-save");
+ const body = JSON.parse((await request).postData());
+ expect(body.name).toBe("Mayhem (corrigé)");
+ await expect(page.locator(".modal-backdrop")).toHaveCount(0);
+ });
+
+ test("une erreur serveur reste dans la fiche au lieu de la fermer", async ({ page }) => {
+ await page.route("**/admin/api/bands/1", async (route) => {
+ if (route.request().method() === "PATCH") {
+ return route.fulfill({ status: 400, json: { ok: false, error: "formed_year invalide" } });
+ }
+ return route.continue();
+ });
+ await page.locator('tr[data-ma-id="1"]').click();
+ await page.click("#m-save");
+ await expect(page.locator("#m-error")).toHaveText("formed_year invalide");
+ await expect(page.locator(".modal-backdrop")).toBeVisible();
+ });
+});
+
+// ------------------------------------------------------------------
+test.describe("Localisations — débloquer le géocodage", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto("/");
+ await page.click('.nav a[data-view="locations"]');
+ });
+
+ test("la vue s'ouvre sur ce qui est à débloquer", async ({ page }) => {
+ await expect(page.locator('.chip[data-status="error,llm_needed"]')).toHaveAttribute("aria-pressed", "true");
+ await expect(page.locator("#l-table tbody tr")).toHaveCount(2);
+ });
+
+ test("chaque ligne montre le diagnostic sans avoir à cliquer", async ({ page }) => {
+ const row = page.locator("tr[data-loc-id='11']");
+ await expect(row).toContainText("Kolbotn");
+ await expect(row).toContainText("erreur");
+ await expect(row).toContainText("3 géo · 0 LLM"); // essais géocodeur / LLM
+ await expect(row).toContainText("seuil de confiance"); // message d'erreur réel
+ });
+
+ test("un filtre de statut recharge la liste avec le bon paramètre", async ({ page }) => {
+ const request = page.waitForRequest((r) => r.url().includes("status=llm_needed"));
+ await page.locator('.chip[data-status="llm_needed"]').click();
+ await request;
+ });
+
+ // Auparavant il fallait relancer des milliers d'appels facturés pour
+ // débloquer un seul lieu.
+ test("une localisation peut être relancée seule", async ({ page }) => {
+ const request = page.waitForRequest(
+ (r) => r.url().includes("/admin/api/locations/11/requeue") && r.method() === "POST");
+ await page.locator("tr[data-loc-id='11'] [data-requeue]").click();
+ await request;
+ });
+
+ test("les coordonnées peuvent être saisies à la main", async ({ page }) => {
+ await page.locator("tr[data-loc-id='11'] [data-setcoords]").click();
+ const modal = page.locator(".modal-backdrop .modal");
+ await expect(modal).toContainText("Kolbotn");
+
+ await modal.locator("#c-lat").fill("59.7955");
+ await modal.locator("#c-lon").fill("10.8");
+
+ const request = page.waitForRequest(
+ (r) => r.url().includes("/admin/api/locations/11") && r.method() === "PATCH");
+ await modal.locator("#c-save").click();
+ const body = JSON.parse((await request).postData());
+ expect(body).toEqual({ lat: "59.7955", lon: "10.8" });
+ });
+
+ test("des coordonnées invalides sont refusées et le message reste affiché", async ({ page }) => {
+ await page.locator("tr[data-loc-id='11'] [data-setcoords]").click();
+ await page.locator("#c-lat").fill("999");
+ await page.locator("#c-lon").fill("10");
+ await page.locator("#c-save").click();
+ await expect(page.locator("#c-error")).toHaveText(/lat invalide/);
+ await expect(page.locator(".modal-backdrop")).toBeVisible();
+ });
+
+ test("le nom du groupe renvoie vers sa fiche", async ({ page }) => {
+ await page.locator("tr[data-loc-id='11'] [data-open-band]").click();
+ await expect(page.locator(".modal-backdrop .modal h3")).toContainText("Darkthrone");
+ });
+});
+
+// ------------------------------------------------------------------
+test.describe("Journal", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto("/");
+ await page.click('.nav a[data-view="activity"]');
+ });
+
+ test("traitements et actions admin apparaissent dans une même chronologie", async ({ page }) => {
+ await expect(page.locator("#a-table tbody tr")).toHaveCount(2);
+ await expect(page.locator("#a-table")).toContainText("Traitement");
+ await expect(page.locator("#a-table")).toContainText("Admin");
+ });
+
+ test("ouvrir un traitement affiche son journal détaillé", async ({ page }) => {
+ await page.locator("#a-table tbody tr").first().click();
+ const modal = page.locator(".modal-backdrop .modal");
+ await expect(modal.locator("h3")).toContainText("Traitement #5");
+ await expect(modal.locator("#act-logs")).toContainText("enrich done");
+ });
+
+ test("ouvrir une action admin montre l'avant et l'après", async ({ page }) => {
+ await page.locator("#a-table tbody tr").nth(1).click();
+ const modal = page.locator(".modal-backdrop .modal");
+ await expect(modal.locator("h3")).toContainText("Action admin");
+ await expect(modal).toContainText("Avant");
+ await expect(modal).toContainText("Après");
+ });
+
+ test("le filtre par type recharge la liste", async ({ page }) => {
+ const request = page.waitForRequest((r) => r.url().includes("type=run"));
+ await page.locator('.chip[data-atype="run"]').click();
+ await request;
+ });
+});
+
+// ------------------------------------------------------------------
+test.describe("LLM & coûts", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto("/");
+ await page.click('.nav a[data-view="llm"]');
+ });
+
+ test("le coût est mis en avant, pas enterré", async ({ page }) => {
+ await expect(page.locator("#llm-summary")).toContainText("Coût total");
+ await expect(page.locator("#llm-summary")).toContainText("$0.0412");
+ });
+
+ test("chaque appel expose son prompt et sa réponse brute", async ({ page }) => {
+ await expect(page.locator("#llm-list")).toContainText("Bayonne");
+ await page.locator("#llm-list details summary").first().click();
+ await expect(page.locator(".llm-detail").first()).toContainText("PROMPT:");
+ });
+});
+
+// ------------------------------------------------------------------
+test.describe("Robustesse", () => {
+ test("une session expirée renvoie à l'écran de connexion", async ({ page }) => {
+ await page.goto("/");
+ await page.route("**/admin/api/**", (route) =>
+ route.fulfill({ status: 401, json: { ok: false, error: "unauthorized" } }));
+ await page.click('.nav a[data-view="bands"]');
+ await expect(page.locator("#login-screen")).toBeVisible();
+ });
+
+ test("une erreur serveur est affichée, pas avalée en silence", async ({ page }) => {
+ await page.goto("/");
+ await page.route("**/admin/api/bands**", (route) =>
+ route.fulfill({ status: 500, json: { ok: false, error: "Erreur recherche bands" } }));
+ await page.click('.nav a[data-view="bands"]');
+ await expect(page.locator("#b-table .err-box")).toContainText("Erreur recherche bands");
+ });
+
+ test("une liste vide affiche un message, pas un tableau vide", async ({ page }) => {
+ await page.goto("/");
+ await page.route("**/admin/api/locations?*", (route) =>
+ route.fulfill({ status: 200, json: { ok: true, items: [], total: 0, page: 1, pageSize: 50 } }));
+ await page.click('.nav a[data-view="locations"]');
+ await expect(page.locator("#l-table")).toContainText("Aucune localisation");
+ });
+});
diff --git a/apps/admin/test/e2e/auth.setup.js b/apps/admin/test/e2e/auth.setup.js
new file mode 100644
index 0000000..d2aa4d4
--- /dev/null
+++ b/apps/admin/test/e2e/auth.setup.js
@@ -0,0 +1,21 @@
+import { test as setup, expect } from "@playwright/test";
+import path from "node:path";
+
+/**
+ * Se connecte une fois et enregistre le cookie de session.
+ *
+ * Sans ça, chacun des ~45 scénarios repayait un aller-retour complet
+ * (chargement + bcrypt + rendu), soit l'essentiel de la durée de la suite.
+ * Le parcours de connexion lui-même reste testé explicitement dans
+ * admin.spec.js, qui repart d'un contexte vierge.
+ */
+export const STORAGE_STATE = path.join(process.cwd(), "node_modules/.cache/playwright/admin-auth.json");
+
+setup("authentification", async ({ page }) => {
+ await page.goto("/");
+ await page.fill("#login-username", "nico");
+ await page.fill("#login-password", "motdepasse-e2e");
+ await page.click('#login-form button[type="submit"]');
+ await expect(page.locator("#app")).toBeVisible();
+ await page.context().storageState({ path: STORAGE_STATE });
+});
diff --git a/apps/admin/test/e2e/fixtures.mjs b/apps/admin/test/e2e/fixtures.mjs
new file mode 100644
index 0000000..48171f0
--- /dev/null
+++ b/apps/admin/test/e2e/fixtures.mjs
@@ -0,0 +1,140 @@
+/**
+ * Jeu de données du serveur e2e.
+ *
+ * Le faux pool associe un fragment de SQL à un résultat : l'ordre compte, le
+ * premier fragment qui correspond gagne. Les entrées les plus spécifiques
+ * doivent donc précéder les plus générales.
+ */
+import bcrypt from "bcryptjs";
+import { rows } from "../../../api/test/helpers/fakePool.js";
+
+export const E2E_USER = "nico";
+export const E2E_PASSWORD = "motdepasse-e2e";
+// Coût 4 : suffisant pour exercer le vrai chemin bcrypt sans les ~330 ms du
+// coût 12 utilisé en production.
+const PASSWORD_HASH = bcrypt.hashSync(E2E_PASSWORD, 4);
+
+const BANDS = [
+ { ma_id: 1, name: "Mayhem", country: "NO", status: "Active", genre: "Black Metal",
+ location_text: "Oslo", themes: "Death, Satanism", formed_year: 1984, enriched: true,
+ lat: 59.91, lon: 10.75, crawled_at: "2026-08-01T10:00:00Z", updated_at: "2026-08-10T10:00:00Z",
+ first_seen_at: "2026-01-01T00:00:00Z", locked_fields: {} },
+ { ma_id: 2, name: "Darkthrone", country: "NO", status: "Active", genre: "Black Metal",
+ location_text: "Kolbotn", themes: "Cold, Winter", formed_year: 1986, enriched: true,
+ lat: null, lon: null, crawled_at: "2026-08-02T10:00:00Z", updated_at: "2026-08-11T10:00:00Z",
+ first_seen_at: "2026-01-01T00:00:00Z", locked_fields: { name: true } },
+ { ma_id: 3, name: "Gojira", country: "FR", status: "Active", genre: "Progressive Death Metal",
+ location_text: "Bayonne", themes: "Nature", formed_year: 1996, enriched: false,
+ lat: 43.49, lon: -1.47, crawled_at: null, updated_at: "2026-08-12T10:00:00Z",
+ first_seen_at: "2026-02-01T00:00:00Z", locked_fields: {} },
+];
+
+const LOCATIONS = [
+ { id: 11, ma_id: 2, band_name: "Darkthrone", country: "NO", step_order: 0, step_label: null,
+ location_raw: "Kolbotn", is_country_only: false, lat: null, lon: null,
+ geocode_status: "error", geocode_provider: null, geocode_confidence: null,
+ geocode_granularity: null, geocode_query: "Kolbotn, Norway",
+ geocode_error: "Aucun résultat au-dessus du seuil de confiance",
+ geocode_tries_geo: 3, geocode_tries_llm: 0,
+ geocode_next_at: "2026-08-12T09:00:00Z", updated_at: "2026-08-12T09:00:00Z" },
+ { id: 12, ma_id: 3, band_name: "Gojira", country: "FR", step_order: 0, step_label: "early",
+ location_raw: "Ondres / Bayonne", is_country_only: false, lat: null, lon: null,
+ geocode_status: "llm_needed", geocode_provider: null, geocode_confidence: null,
+ geocode_granularity: null, geocode_query: null, geocode_error: null,
+ geocode_tries_geo: 3, geocode_tries_llm: 1,
+ geocode_next_at: "2026-08-12T08:00:00Z", updated_at: "2026-08-12T08:00:00Z" },
+];
+
+/**
+ * @param {object} [over] surcharges par scénario (ex. aucun run actif)
+ */
+export function seedHandlers(over = {}) {
+ const {
+ activeRuns = [],
+ pendingJobs = [],
+ geoStatuses = [
+ { status: "done", n: 1840 },
+ { status: "error", n: 12 },
+ { status: "llm_needed", n: 5 },
+ { status: "queued", n: 3 },
+ ],
+ bands = BANDS,
+ locations = LOCATIONS,
+ neverEnriched = 1,
+ } = over;
+
+ return [
+ // ---- Authentification ----
+ // Fragment volontairement précis : « SELECT count(*)::int AS n » seul
+ // capturait aussi le comptage du cache de géocodage.
+ { match: "FROM admin_login_attempts\n WHERE success = false", result: rows({ n: 0 }) },
+ { match: "FROM admin_users", result: rows({ password_hash: PASSWORD_HASH }) },
+ { match: "INSERT INTO admin_login_attempts", result: rows() },
+ { match: "UPDATE admin_users", result: rows() },
+
+ // ---- Écritures ----
+ { match: "INSERT INTO admin_audit_log", result: rows() },
+ { match: "INSERT INTO crawl_log", result: rows() },
+ { match: "INSERT INTO job_triggers", result: rows({ id: 77 }) },
+ { match: "UPDATE crawl_run", result: rows({ id: 5, run_type: "enrich" }) },
+ { match: "UPDATE job_triggers", result: rows({ id: 7, job_type: "enrich" }) },
+
+ // ---- Détail d'un groupe (avant les listes : fragment plus spécifique) ----
+ { match: "SELECT * FROM bands WHERE ma_id", result: (_s, v) => rows(bands.find((b) => b.ma_id === Number(v[0])) || bands[0]) },
+ { match: "UPDATE bands SET", result: (_s, v) => rows({ ...bands[0], ma_id: Number(v[0]), name: v[1] ?? bands[0].name }) },
+
+ // ---- Localisations ----
+ { match: "SELECT bl.id, bl.ma_id", result: rows(...locations) },
+ { match: "count(*)::int AS total\n FROM band_locations", result: rows({ total: locations.length }) },
+ { match: "SELECT * FROM band_locations", result: rows(locations[0]) },
+ { match: "UPDATE band_locations", result: rows({ ...locations[0], lat: 59.79, lon: 10.8, geocode_status: "done" }) },
+ { match: "FROM band_locations\n WHERE ma_id", result: rows(locations[0]) },
+
+ // ---- Statistiques ----
+ { match: "never_enriched", result: rows({
+ total: 1950, enriched: 1200, not_enriched: 750, geocoded: 1840, not_geocoded: 110,
+ crawled_by_current_system: 1900, never_enriched: neverEnriched, stale: 4 }) },
+ { match: "new_bands", result: rows({ new_bands: 3, modified_since_enrich: 1, legacy_pending: 0, stale: 4 }) },
+ { match: "run_type = 'enrich'", result: rows() },
+ { match: "AS status, count(*)::int AS total", result: rows({ status: "Active", total: 1500 }, { status: "Split-up", total: 450 }) },
+ { match: "AS country, count", result: rows({ country: "NO", total: 900 }, { country: "FR", total: 400 }) },
+ { match: "SELECT genre, count", result: rows({ genre: "Black Metal", total: 800 }) },
+
+ // ---- Supervision ----
+ { match: "WHERE status = 'running'", result: rows(...activeRuns) },
+ { match: "WHERE status = 'pending'", result: rows(...pendingJobs) },
+ { match: "geocode_status = 'processing'", result: rows() },
+ { match: "GROUP BY geocode_status", result: rows(...geoStatuses) },
+ { match: "FROM crawl_checkpoint", result: rows({ key: "last_full_crawl_at", value: "2026-08-01", updated_at: "2026-08-01T00:00:00Z" }) },
+
+ // ---- Géocodage / LLM ----
+ { match: "FROM geocode_cache", result: rows({ n: 1500 }) },
+ { match: "b.geocoded_at IS NOT NULL", result: rows() },
+ { match: "sum(cost_usd)", result: rows({ n: 320, total_cost_usd: "0.041200", n_null: 18 }) },
+ { match: "COALESCE(geocode_provider", result: rows({ provider: "geoapify", n: 1800, avg_conf: "0.91" }) },
+ { match: "SELECT model,", result: rows({ model: "llama-3.3-70b-versatile", n: 320, n_null: 18, cost: "0.041200" }) },
+ { match: "FROM llm_cache lc", result: rows({
+ id: 3, ma_id: 3, band_name: "Gojira", model: "llama-3.3-70b-versatile",
+ location_raw: "Ondres / Bayonne", country: "FR", parsed_city: "Bayonne",
+ parsed_country: "FR", is_null: false, tokens_in: 180, tokens_out: 12,
+ cost_usd: "0.000090", prompt: "Extract the city…", response: '{"city":"Bayonne"}',
+ created_at: "2026-08-12T08:00:00Z" }) },
+
+ // ---- Journal ----
+ { match: "UNION ALL", result: (sql) => sql.includes("count(*)")
+ ? rows({ total: 2 })
+ : rows(
+ { type: "run", id: 5, subtype: "enrich", status: "done", actor: null,
+ ts: "2026-08-12T07:00:00Z", finished_at: "2026-08-12T07:30:00Z",
+ summary: { bands_seen: 500, bands_new: 12, bands_updated: 30, bands_enriched: 480, error: null, countries: null } },
+ { type: "admin_action", id: 2, subtype: "update", status: "done", actor: "nico",
+ ts: "2026-08-12T06:00:00Z", finished_at: "2026-08-12T06:00:00Z",
+ summary: { target_table: "bands", target_id: "1", before: { name: "Mayhem" }, after: { name: "Mayhem" } } }) },
+ { match: "FROM crawl_log", result: rows({ id: 1, run_id: 5, level: "info", message: "enrich done", ma_id: null, created_at: "2026-08-12T07:30:00Z" }) },
+
+ // ---- Liste des groupes (fragments génériques : en dernier) ----
+ { match: "SELECT ma_id, name, country", result: rows(...bands) },
+ { match: "count(*)::int AS total FROM bands", result: rows({ total: bands.length }) },
+ { match: "FROM llm_cache", result: rows() },
+ ];
+}
diff --git a/apps/admin/test/e2e/server.mjs b/apps/admin/test/e2e/server.mjs
new file mode 100644
index 0000000..dd3d281
--- /dev/null
+++ b/apps/admin/test/e2e/server.mjs
@@ -0,0 +1,119 @@
+/**
+ * Serveur de test pour les parcours Playwright.
+ *
+ * Reproduit la topologie de production : nginx sert les fichiers statiques du
+ * dashboard et proxifie `/admin/api/*` et `/admin/auth/*` vers l'API (voir
+ * apps/admin/nginx.conf). Ici, un petit serveur HTTP joue le rôle de nginx et
+ * transmet à la VRAIE application Fastify — routage, cookies, JWT, bcrypt,
+ * validation et génération SQL sont donc ceux de production — avec un faux pool
+ * à la place de Postgres.
+ *
+ * Ce qui est réellement exercé : les parcours d'interface, l'authentification,
+ * les états de chargement et d'erreur, l'accessibilité au clavier.
+ * Ce qui ne l'est pas : la validité du SQL pour Postgres et le comportement de
+ * PostGIS — affaire de tests d'intégration, pas de tests d'interface.
+ */
+import { createServer } from "node:http";
+import { readFile } from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { buildServer } from "../../../api/src/app.js";
+import { makeFakePool, rows } from "../../../api/test/helpers/fakePool.js";
+import { seedHandlers } from "./fixtures.mjs";
+
+const SITE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../site");
+
+// Secret de session propre aux tests. Sans lui, signAdminSession() lève et la
+// connexion renvoie un 500 opaque — exactement le piège documenté dans
+// infra/.env.example pour la production.
+process.env.ADMIN_JWT_SECRET ||= "secret-e2e-suffisamment-long-pour-passer-32";
+
+const MIME = {
+ ".html": "text/html; charset=utf-8",
+ ".js": "text/javascript; charset=utf-8",
+ ".css": "text/css; charset=utf-8",
+ ".svg": "image/svg+xml",
+ ".ico": "image/x-icon",
+ ".png": "image/png",
+};
+
+/** Les mêmes préfixes que ceux proxifiés par nginx.conf. */
+const API_PREFIXES = ["/admin/api/", "/admin/auth/", "/api/"];
+
+export async function startTestServer({ port = 0, handlers = seedHandlers() } = {}) {
+ const pool = makeFakePool(handlers);
+
+ const app = await buildServer({
+ pool,
+ logger: false,
+ importToken: "jeton-e2e",
+ corsOrigins: ["http://127.0.0.1"],
+ seedAdmin: false,
+ // Les parcours enchaînent beaucoup d'appels ; les plafonds réels sont
+ // vérifiés séparément dans apps/api/test/rateLimit.test.js.
+ globalRateLimitMax: 1e9,
+ adminRateLimitMax: 1e9,
+ authRateLimitMax: 1e9,
+ });
+
+ const front = createServer(async (req, res) => {
+ const url = req.url || "/";
+
+ if (API_PREFIXES.some((p) => url.startsWith(p))) {
+ const chunks = [];
+ for await (const c of req) chunks.push(c);
+ const payload = chunks.length ? Buffer.concat(chunks) : undefined;
+
+ // `inject()` recalcule content-length à partir du payload : retransmettre
+ // celui du client donne un corps tronqué, donc un 400 « champ requis ».
+ const headers = { ...req.headers };
+ delete headers["content-length"];
+ delete headers["transfer-encoding"];
+
+ // inject() traverse tout le cycle de vie Fastify (hooks, plugins,
+ // sérialisation) : c'est la même exécution qu'une requête réseau.
+ const reply = await app.inject({ method: req.method, url, headers, payload });
+ res.writeHead(reply.statusCode, reply.headers);
+ res.end(reply.rawPayload);
+ return;
+ }
+
+ const rel = url.split("?")[0].replace(/^\/+/, "") || "index.html";
+ const file = path.resolve(SITE, rel);
+ if (!file.startsWith(SITE)) {
+ res.writeHead(403).end("forbidden");
+ return;
+ }
+ try {
+ const buf = await readFile(file);
+ res.writeHead(200, { "content-type": MIME[path.extname(file)] || "application/octet-stream" });
+ res.end(buf);
+ } catch {
+ // Application monopage : toute route inconnue retombe sur index.html
+ res.writeHead(200, { "content-type": MIME[".html"] });
+ res.end(await readFile(path.join(SITE, "index.html")));
+ }
+ });
+
+ await new Promise((resolve) => front.listen(port, "127.0.0.1", resolve));
+
+ return {
+ app,
+ pool,
+ url: `http://127.0.0.1:${front.address().port}`,
+ /** Reprogramme les réponses de la base entre deux scénarios. */
+ setHandlers: (h) => pool.reset(h),
+ close: async () => {
+ await new Promise((r) => front.close(r));
+ await app.close();
+ },
+ };
+}
+
+export { rows };
+
+// Démarrage autonome, utilisé par `webServer` dans playwright.config.js.
+if (process.env.E2E_STANDALONE === "1") {
+ const srv = await startTestServer({ port: Number(process.env.E2E_PORT || 4319) });
+ console.log(`e2e server: ${srv.url}`);
+}
diff --git a/apps/api/src/adminRoutes.js b/apps/api/src/adminRoutes.js
index eddcab5..4409e1c 100644
--- a/apps/api/src/adminRoutes.js
+++ b/apps/api/src/adminRoutes.js
@@ -630,6 +630,164 @@ export default async function adminRoutes(fastify, opts) {
}
});
+ // ------------------------------------------------------------------
+ // Localisations — consultation ligne par ligne.
+ //
+ // L'admin ne disposait que d'actions EN MASSE sur band_locations (tout
+ // remettre en file, tout réinitialiser) et d'aucun moyen de voir CE qui
+ // coince. Débloquer un lieu supposait donc de relancer des milliers d'appels
+ // Geoapify/Groq payants pour en corriger un seul.
+ // ------------------------------------------------------------------
+ const LOCATION_STATUSES = new Set([
+ "queued", "processing", "done", "country_only", "error", "llm_needed", "manual",
+ ]);
+
+ fastify.get("/admin/api/locations", async (req, reply) => {
+ try {
+ const { status, q, country, provider } = req.query || {};
+ const { page, pageSize, offset } = pagination(req.query || {});
+
+ const where = [];
+ const vals = [];
+ let i = 1;
+
+ if (status) {
+ // Allowlist : `status` finit dans un ANY($n) paramétré, mais on refuse
+ // tôt les valeurs inconnues pour éviter des filtres silencieusement vides.
+ const wanted = String(status).split(",").map((s) => s.trim()).filter(Boolean);
+ const invalid = wanted.filter((s) => !LOCATION_STATUSES.has(s));
+ if (invalid.length) {
+ return reply.code(400).send({ ok: false, error: `statut inconnu: ${invalid.join(", ")}` });
+ }
+ if (wanted.length) {
+ where.push(`bl.geocode_status = ANY($${i}::text[])`);
+ vals.push(wanted);
+ i++;
+ }
+ }
+ if (q) {
+ const needle = String(q).trim().slice(0, 100);
+ if (needle.length >= 1) {
+ where.push(`(bl.location_raw ILIKE $${i} OR b.name ILIKE $${i})`);
+ vals.push(`%${needle}%`);
+ i++;
+ }
+ }
+ if (country) {
+ where.push(`b.country = $${i}`);
+ vals.push(String(country).toUpperCase());
+ i++;
+ }
+ if (provider) {
+ where.push(`bl.geocode_provider = $${i}`);
+ vals.push(String(provider));
+ i++;
+ }
+
+ const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
+
+ const [rowsRes, countRes] = await Promise.all([
+ pool.query(`
+ SELECT bl.id, bl.ma_id, b.name AS band_name, b.country,
+ bl.step_order, bl.step_label, bl.location_raw, bl.is_country_only,
+ bl.lat, bl.lon, bl.geocode_status, bl.geocode_provider,
+ bl.geocode_confidence, bl.geocode_granularity, bl.geocode_query,
+ bl.geocode_error, bl.geocode_tries_geo, bl.geocode_tries_llm,
+ bl.geocode_next_at, bl.updated_at
+ FROM band_locations bl
+ JOIN bands b ON b.ma_id = bl.ma_id
+ ${whereSql}
+ ORDER BY bl.updated_at DESC, bl.id DESC
+ LIMIT $${i} OFFSET $${i + 1}
+ `, [...vals, pageSize, offset]),
+ pool.query(`
+ SELECT count(*)::int AS total
+ FROM band_locations bl JOIN bands b ON b.ma_id = bl.ma_id
+ ${whereSql}
+ `, vals),
+ ]);
+
+ return { ok: true, items: rowsRes.rows, total: countRes.rows[0].total, page, pageSize };
+ } catch (err) {
+ fastify.log.error(err);
+ return reply.code(500).send({ ok: false, error: "Erreur liste localisations" });
+ }
+ });
+
+ // Remet UNE localisation en file (au lieu des milliers du bouton global).
+ fastify.post("/admin/api/locations/:id/requeue", async (req, reply) => {
+ try {
+ const id = Number(req.params.id);
+ if (!Number.isInteger(id) || id < 0) {
+ return reply.code(400).send({ ok: false, error: "bad id" });
+ }
+ const r = await pool.query(`
+ UPDATE band_locations
+ SET geocode_status='queued',
+ geocode_tries_geo=0, geocode_tries_llm=0,
+ geocode_query=NULL, geocode_error=NULL,
+ geocode_next_at=now(), updated_at=now()
+ WHERE id = $1
+ RETURNING id, ma_id, location_raw
+ `, [id]);
+ if (!r.rows.length) return reply.code(404).send({ ok: false, error: "not found" });
+
+ await writeAuditLog(pool, req.adminUsername, "requeue_location", "band_locations", id, null, r.rows[0]);
+ return { ok: true, item: r.rows[0] };
+ } catch (err) {
+ fastify.log.error(err);
+ return reply.code(500).send({ ok: false, error: "Erreur requeue localisation" });
+ }
+ });
+
+ // Saisie manuelle des coordonnées d'une localisation que le pipeline n'a pas
+ // su résoudre. Marque 'manual' (et non 'done') pour rester distinguable d'un
+ // résultat automatique dans les statistiques.
+ fastify.patch("/admin/api/locations/:id", async (req, reply) => {
+ try {
+ const id = Number(req.params.id);
+ if (!Number.isInteger(id) || id < 0) {
+ return reply.code(400).send({ ok: false, error: "bad id" });
+ }
+ const { lat, lon } = req.body || {};
+ const latN = lat === null || lat === undefined || lat === "" ? null : Number(lat);
+ const lonN = lon === null || lon === undefined || lon === "" ? null : Number(lon);
+ if (latN === null || lonN === null) {
+ return reply.code(400).send({ ok: false, error: "lat et lon requis" });
+ }
+ if (!Number.isFinite(latN) || latN < -90 || latN > 90) {
+ return reply.code(400).send({ ok: false, error: "lat invalide" });
+ }
+ if (!Number.isFinite(lonN) || lonN < -180 || lonN > 180) {
+ return reply.code(400).send({ ok: false, error: "lon invalide" });
+ }
+
+ const before = await pool.query(`SELECT * FROM band_locations WHERE id = $1`, [id]);
+ if (!before.rows.length) return reply.code(404).send({ ok: false, error: "not found" });
+
+ const r = await pool.query(`
+ UPDATE band_locations
+ SET lat = $2, lon = $3,
+ geocode_status = 'done',
+ geocode_provider = 'admin',
+ geocode_confidence = 1.0,
+ geocode_error = NULL,
+ updated_at = now()
+ WHERE id = $1
+ RETURNING *
+ `, [id, latN, lonN]);
+
+ await writeAuditLog(
+ pool, req.adminUsername, "set_location_coords", "band_locations", id,
+ before.rows[0], r.rows[0]
+ );
+ return { ok: true, item: r.rows[0] };
+ } catch (err) {
+ fastify.log.error(err);
+ return reply.code(500).send({ ok: false, error: "Erreur mise à jour localisation" });
+ }
+ });
+
// Purge les entrées de cache issues de l'ancien pipeline Nominatim (raw avec
// place_rank). Force le worker à re-géocoder ces lieux via Geoapify.
fastify.post("/admin/api/geocode-cache/purge-nominatim", async (req, reply) => {
diff --git a/apps/api/test/locations.test.js b/apps/api/test/locations.test.js
new file mode 100644
index 0000000..c6d594a
--- /dev/null
+++ b/apps/api/test/locations.test.js
@@ -0,0 +1,232 @@
+import { describe, it, expect, beforeAll, afterAll } from "vitest";
+
+process.env.ADMIN_JWT_SECRET = "secret-de-test-suffisamment-long-pour-passer-32";
+
+const { signAdminSession, ADMIN_COOKIE_NAME } = await import("../src/adminAuth.js");
+const { rows } = await import("./helpers/fakePool.js");
+const { createAdminApp } = await import("./helpers/testApp.js");
+
+let auth, app, pool;
+beforeAll(async () => {
+ auth = { cookie: `${ADMIN_COOKIE_NAME}=${signAdminSession("nico")}` };
+ ({ app, pool } = await createAdminApp());
+});
+afterAll(async () => { await app.close(); });
+
+function buildApp(handlers) {
+ pool.reset(handlers ?? []);
+ return app;
+}
+
+const listHandlers = () => [
+ { match: "SELECT bl.id, bl.ma_id", result: rows({ id: 9, ma_id: 1, band_name: "Mayhem", location_raw: "Oslo" }) },
+ { match: "count(*)::int AS total", result: rows({ total: 1 }) },
+];
+
+/**
+ * Ces routes existent pour éviter le seul recours disponible auparavant :
+ * relancer des milliers d'appels Geoapify/Groq payants pour débloquer un lieu.
+ */
+describe("GET /admin/api/locations", () => {
+ it("exige une session et ne touche pas la base sinon", async () => {
+ const a = buildApp([]);
+ expect((await a.inject({ method: "GET", url: "/admin/api/locations" })).statusCode).toBe(401);
+ expect(pool.calls).toHaveLength(0);
+ });
+
+ it("joint le nom et le pays du groupe", async () => {
+ const a = buildApp(listHandlers());
+ const body = (await a.inject({ method: "GET", url: "/admin/api/locations", headers: auth })).json();
+ expect(body.items[0].band_name).toBe("Mayhem");
+ expect(pool.find("SELECT bl.id").sql).toContain("JOIN bands b ON b.ma_id = bl.ma_id");
+ });
+
+ it("filtre sur une liste de statuts, en paramètre lié", async () => {
+ const a = buildApp(listHandlers());
+ await a.inject({ method: "GET", url: "/admin/api/locations?status=error,llm_needed", headers: auth });
+ const call = pool.find("SELECT bl.id");
+ expect(call.sql).toContain("bl.geocode_status = ANY($1::text[])");
+ expect(call.values[0]).toEqual(["error", "llm_needed"]);
+ });
+
+ // Sans allowlist, une faute de frappe donnerait une liste vide sans expliquer
+ // pourquoi — le pire cas pour une vue de diagnostic.
+ it("rejette un statut inconnu au lieu de renvoyer une liste vide", async () => {
+ const a = buildApp(listHandlers());
+ const res = await a.inject({ method: "GET", url: "/admin/api/locations?status=erreur", headers: auth });
+ expect(res.statusCode).toBe(400);
+ expect(res.json().error).toMatch(/statut inconnu/);
+ expect(pool.find("SELECT bl.id")).toBeUndefined();
+ });
+
+ it.each(["queued", "processing", "done", "country_only", "error", "llm_needed", "manual"])(
+ "accepte le statut %s",
+ async (status) => {
+ const a = buildApp(listHandlers());
+ const res = await a.inject({ method: "GET", url: `/admin/api/locations?status=${status}`, headers: auth });
+ expect(res.statusCode).toBe(200);
+ }
+ );
+
+ it("cherche à la fois dans le lieu brut et le nom du groupe", async () => {
+ const a = buildApp(listHandlers());
+ await a.inject({ method: "GET", url: "/admin/api/locations?q=oslo", headers: auth });
+ const call = pool.find("SELECT bl.id");
+ expect(call.sql).toContain("bl.location_raw ILIKE $1 OR b.name ILIKE $1");
+ expect(call.values[0]).toBe("%oslo%");
+ });
+
+ it("normalise le pays en majuscules", async () => {
+ const a = buildApp(listHandlers());
+ await a.inject({ method: "GET", url: "/admin/api/locations?country=no", headers: auth });
+ expect(pool.find("SELECT bl.id").values[0]).toBe("NO");
+ });
+
+ it("le compte applique le même filtre que la page", async () => {
+ const a = buildApp(listHandlers());
+ await a.inject({ method: "GET", url: "/admin/api/locations?status=error", headers: auth });
+ const count = pool.find("count(*)::int AS total");
+ expect(count.sql).toContain("bl.geocode_status = ANY($1::text[])");
+ expect(count.values).toEqual([["error"]]);
+ });
+
+ it("trie par date de mise à jour décroissante", async () => {
+ const a = buildApp(listHandlers());
+ await a.inject({ method: "GET", url: "/admin/api/locations", headers: auth });
+ expect(pool.find("SELECT bl.id").sql).toContain("ORDER BY bl.updated_at DESC");
+ });
+
+ it("expose l'erreur et le nombre d'essais (le diagnostic tient dans la liste)", async () => {
+ const a = buildApp(listHandlers());
+ await a.inject({ method: "GET", url: "/admin/api/locations", headers: auth });
+ const sql = pool.find("SELECT bl.id").sql;
+ for (const col of ["geocode_error", "geocode_tries_geo", "geocode_tries_llm", "geocode_query"]) {
+ expect(sql).toContain(col);
+ }
+ });
+
+ it("borne pageSize", async () => {
+ const a = buildApp(listHandlers());
+ const body = (await a.inject({ method: "GET", url: "/admin/api/locations?pageSize=9999", headers: auth })).json();
+ expect(body.pageSize).toBe(200);
+ });
+});
+
+describe("POST /admin/api/locations/:id/requeue", () => {
+ const okHandlers = () => [
+ { match: "UPDATE band_locations", result: rows({ id: 9, ma_id: 1, location_raw: "Oslo" }) },
+ { match: "INSERT INTO admin_audit_log", result: rows() },
+ ];
+
+ it("exige une session", async () => {
+ const a = buildApp([]);
+ expect((await a.inject({ method: "POST", url: "/admin/api/locations/9/requeue", payload: {} })).statusCode).toBe(401);
+ expect(pool.calls).toHaveLength(0);
+ });
+
+ it("ne remet en file QUE la ligne visée", async () => {
+ const a = buildApp(okHandlers());
+ const res = await a.inject({ method: "POST", url: "/admin/api/locations/9/requeue", headers: auth, payload: {} });
+ expect(res.statusCode).toBe(200);
+ const upd = pool.find("UPDATE band_locations");
+ expect(upd.sql).toContain("WHERE id = $1");
+ expect(upd.values).toEqual([9]);
+ });
+
+ it("réinitialise les compteurs d'essais et l'erreur", async () => {
+ const a = buildApp(okHandlers());
+ await a.inject({ method: "POST", url: "/admin/api/locations/9/requeue", headers: auth, payload: {} });
+ const sql = pool.find("UPDATE band_locations").sql;
+ expect(sql).toContain("geocode_status='queued'");
+ expect(sql).toContain("geocode_tries_geo=0");
+ expect(sql).toContain("geocode_tries_llm=0");
+ expect(sql).toContain("geocode_error=NULL");
+ });
+
+ it("trace l'action dans le journal d'audit", async () => {
+ const a = buildApp(okHandlers());
+ await a.inject({ method: "POST", url: "/admin/api/locations/9/requeue", headers: auth, payload: {} });
+ const audit = pool.find("INSERT INTO admin_audit_log");
+ expect(audit.values[0]).toBe("nico");
+ expect(audit.values[1]).toBe("requeue_location");
+ });
+
+ it.each(["abc", "-1", "1.5"])("refuse l'identifiant %s", async (bad) => {
+ const a = buildApp(okHandlers());
+ const res = await a.inject({ method: "POST", url: `/admin/api/locations/${bad}/requeue`, headers: auth, payload: {} });
+ expect(res.statusCode).toBe(400);
+ });
+
+ it("404 quand la localisation n'existe pas", async () => {
+ const a = buildApp([{ match: "UPDATE band_locations", result: rows() }]);
+ const res = await a.inject({ method: "POST", url: "/admin/api/locations/9/requeue", headers: auth, payload: {} });
+ expect(res.statusCode).toBe(404);
+ });
+});
+
+describe("PATCH /admin/api/locations/:id — coordonnées manuelles", () => {
+ const okHandlers = () => [
+ { match: "SELECT * FROM band_locations", result: rows({ id: 9, lat: null, lon: null }) },
+ { match: "UPDATE band_locations", result: rows({ id: 9, lat: 59.9, lon: 10.7 }) },
+ { match: "INSERT INTO admin_audit_log", result: rows() },
+ ];
+
+ it("exige une session", async () => {
+ const a = buildApp([]);
+ const res = await a.inject({ method: "PATCH", url: "/admin/api/locations/9", payload: { lat: 1, lon: 2 } });
+ expect(res.statusCode).toBe(401);
+ expect(pool.calls).toHaveLength(0);
+ });
+
+ it("enregistre les coordonnées et marque le lieu résolu par admin", async () => {
+ const a = buildApp(okHandlers());
+ const res = await a.inject({
+ method: "PATCH", url: "/admin/api/locations/9", headers: auth,
+ payload: { lat: 59.9, lon: 10.7 },
+ });
+ expect(res.statusCode).toBe(200);
+ const upd = pool.find("UPDATE band_locations");
+ expect(upd.values).toEqual([9, 59.9, 10.7]);
+ expect(upd.sql).toContain("geocode_status = 'done'");
+ // 'admin' distingue une saisie manuelle d'un résultat automatique dans les
+ // statistiques par provider.
+ expect(upd.sql).toContain("geocode_provider = 'admin'");
+ expect(upd.sql).toContain("geocode_error = NULL");
+ });
+
+ it.each([
+ ["lat hors bornes", { lat: 91, lon: 0 }],
+ ["lon hors bornes", { lat: 0, lon: 181 }],
+ ["lat non numérique", { lat: "nord", lon: 0 }],
+ ["lat absente", { lon: 10 }],
+ ["lon absente", { lat: 59 }],
+ ["corps vide", {}],
+ ])("refuse %s", async (_label, payload) => {
+ const a = buildApp(okHandlers());
+ const res = await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload });
+ expect(res.statusCode).toBe(400);
+ expect(pool.find("UPDATE band_locations")).toBeUndefined();
+ });
+
+ it("accepte le point (0, 0), qui est une coordonnée valide", async () => {
+ const a = buildApp(okHandlers());
+ const res = await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload: { lat: 0, lon: 0 } });
+ expect(res.statusCode).toBe(200);
+ expect(pool.find("UPDATE band_locations").values).toEqual([9, 0, 0]);
+ });
+
+ it("404 quand la localisation n'existe pas", async () => {
+ const a = buildApp([{ match: "SELECT * FROM band_locations", result: rows() }]);
+ const res = await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload: { lat: 1, lon: 2 } });
+ expect(res.statusCode).toBe(404);
+ });
+
+ it("journalise l'avant et l'après", async () => {
+ const a = buildApp(okHandlers());
+ await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload: { lat: 1, lon: 2 } });
+ const audit = pool.find("INSERT INTO admin_audit_log");
+ expect(audit.values[1]).toBe("set_location_coords");
+ expect(audit.values[4]).toBeTruthy();
+ expect(audit.values[5]).toBeTruthy();
+ });
+});
diff --git a/eslint.config.js b/eslint.config.js
index 32883d0..e44f388 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -41,9 +41,11 @@ export default [
},
},
- // ---- Tests (vitest). Certains tournent sous jsdom → globales navigateur aussi. ----
+ // ---- Tests (vitest + Playwright). Certains tournent sous jsdom ou dans un
+ // navigateur → globales navigateur en plus de Node. Le harnais e2e est en
+ // .mjs (modules ES chargés directement par node). ----
{
- files: ["apps/*/test/**/*.js"],
+ files: ["apps/*/test/**/*.{js,mjs}"],
languageOptions: {
ecmaVersion: 2023,
sourceType: "module",
diff --git a/package-lock.json b/package-lock.json
index 901a15a..27c5413 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,7 +11,9 @@
"apps/api"
],
"devDependencies": {
+ "@axe-core/playwright": "^4.13.0",
"@eslint/js": "^9.17.0",
+ "@playwright/test": "^1.62.1",
"@stryker-mutator/core": "^8.7.1",
"@stryker-mutator/vitest-runner": "^8.7.1",
"@vitest/coverage-v8": "^2.1.8",
@@ -71,6 +73,19 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/@axe-core/playwright": {
+ "version": "4.13.0",
+ "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz",
+ "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "axe-core": "~4.13.0"
+ },
+ "peerDependencies": {
+ "playwright-core": ">= 1.0.0"
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -1922,6 +1937,22 @@
"node": ">=14"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
+ "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.62.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
@@ -5267,6 +5298,53 @@
],
"license": "MIT"
},
+ "node_modules/playwright": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
+ "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.62.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
+ "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
diff --git a/package.json b/package.json
index 0b914a7..31c8286 100644
--- a/package.json
+++ b/package.json
@@ -25,14 +25,20 @@
"audit:py": "pip-audit -r apps/api/../crawler/requirements.txt -r apps/geocoder/requirements.txt",
"_comment_check": "`check` = la porte de qualite lancee avant chaque push. Les cinq verifications tournent en parallele (voir scripts/check.mjs). Mutation et audits de dependances en sont exclus : trop lents / dependants du reseau — voir `check:full`.",
"check": "node scripts/check.mjs",
- "check:full": "npm run check && npm run audit:js && npm run audit:py && npm run test:mutation:full",
+ "check:full": "npm run check && npm run test:e2e && npm run audit:js && npm run audit:py && npm run test:mutation:full",
"hooks:install": "git config core.hooksPath .githooks",
"_comment_cache": "lint et typecheck utilisent un cache dans node_modules/.cache : `check` tourne des dizaines de fois par jour, refaire l'analyse complete de fichiers inchanges a chaque fois n'apporte rien. Les caches sont invalides par mtime+contenu ; `npm run check:clean` les purge.",
"check:clean": "node -e \"require('fs').rmSync('node_modules/.cache',{recursive:true,force:true})\"",
- "check:sequential": "node scripts/check.mjs --sequential"
+ "check:sequential": "node scripts/check.mjs --sequential",
+ "test:e2e": "playwright test",
+ "test:e2e:ui": "playwright test --ui",
+ "test:e2e:install": "playwright install chromium",
+ "_comment_e2e": "Les parcours Playwright (~22 s) ne sont PAS dans `check` : celui-ci tourne des dizaines de fois par jour et doit rester sous 10 s. Ils sont lances par le hook pre-push, granularite correcte pour ce cout. `npm run test:e2e:install` recupere Chromium sur un clone neuf."
},
"devDependencies": {
+ "@axe-core/playwright": "^4.13.0",
"@eslint/js": "^9.17.0",
+ "@playwright/test": "^1.62.1",
"@stryker-mutator/core": "^8.7.1",
"@stryker-mutator/vitest-runner": "^8.7.1",
"@vitest/coverage-v8": "^2.1.8",
diff --git a/playwright.config.js b/playwright.config.js
new file mode 100644
index 0000000..e7a0cb1
--- /dev/null
+++ b/playwright.config.js
@@ -0,0 +1,57 @@
+import { defineConfig, devices } from "@playwright/test";
+import path from "node:path";
+
+/**
+ * Parcours de bout en bout du dashboard admin.
+ *
+ * Le serveur de test (apps/admin/test/e2e/server.mjs) reproduit la topologie de
+ * production — statique servi + `/admin/*` proxifié vers la VRAIE application
+ * Fastify — avec un faux pool à la place de Postgres. Démarrage ~1 s, aucun
+ * conteneur, conformément au principe suivi par le reste de la suite.
+ *
+ * Performance : les scénarios tournent en parallèle et partagent une session
+ * enregistrée une fois par le projet `setup`. Ils ne se marchent pas dessus car
+ * aucun ne reprogramme le faux pool : les variations de données passent par
+ * `page.route`, qui est propre à chaque onglet.
+ */
+const PORT = Number(process.env.E2E_PORT || 4319);
+const STORAGE_STATE = path.join(process.cwd(), "node_modules/.cache/playwright/admin-auth.json");
+
+export default defineConfig({
+ testDir: "apps/admin/test/e2e",
+ fullyParallel: true,
+ workers: process.env.CI ? 2 : 4,
+ forbidOnly: !!process.env.CI,
+ retries: 0,
+ reporter: [["list", { printSteps: false }]],
+ timeout: 15000,
+ expect: { timeout: 5000 },
+ outputDir: "node_modules/.cache/playwright/results",
+
+ use: {
+ baseURL: `http://127.0.0.1:${PORT}`,
+ trace: "retain-on-failure",
+ screenshot: "only-on-failure",
+ video: "off",
+ },
+
+ projects: [
+ { name: "setup", testMatch: /auth\.setup\.js/ },
+ {
+ name: "chromium",
+ testMatch: /.*\.spec\.js/,
+ use: { ...devices["Desktop Chrome"], storageState: STORAGE_STATE },
+ dependencies: ["setup"],
+ },
+ ],
+
+ webServer: {
+ command: "node apps/admin/test/e2e/server.mjs",
+ url: `http://127.0.0.1:${PORT}/`,
+ env: { E2E_STANDALONE: "1", E2E_PORT: String(PORT) },
+ reuseExistingServer: !process.env.CI,
+ timeout: 30000,
+ stdout: "ignore",
+ stderr: "pipe",
+ },
+});