feat: fondations E2E + 2 bugs trouvés (onglets ?view=, Inter CSP) (v7.36.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 3m26s

Ajout — e2e/smoke.spec.js (2 gates verts contre l'instance de test) :
- gate A39 : bascule de vues d'une collection (clic onglet Calendar →
  ?view_type=calendar, grille .calendar + .cal-header rendue ; collection
  créée puis SUPPRIMÉE = répétable)
- gate A20 : palette Ctrl+K (ouverture Alpine .open, recherche GET rend
  .cmd-palette-item, fermeture Échap)
- filet console : 0 erreur JS/CSP (bruit Failed to load resource 401/403
  filtré)
- Service Workers bloqués : /sw.js sert sa page « hors ligne » sur les
  navigations redirigées (redirect:'manual') — pwa_offline.spec.js couvre
  le SW
- bootstrap autonome : login OU création du compte e2e documenté (jamais
  de mot de passe deviné), workspace si absent
- commande : cd e2e && node node_modules/@playwright/test/cli.js test smoke.spec.js

Fixed — trouvés par les gates :
1. Bascule de vues standalone JAMAIS fonctionnelle : les onglets
   émettaient ?view=… mais la route lit `view_type` (FastAPI) → l'onglet
   restait sur Table quel que soit le clic (bug pré-existant, A28 n'y est
   pour rien). Onglets → ?view_type= ; test_all_view_tabs_present adapté +
   assertion comportementale (GET ?view_type=calendar rend .calendar).
2. Inter bloqué par la CSP depuis v7.27 : app.css importait encore
   Google Fonts (@import raté par le grep de la passe v7.27) → violation
   style-src sur chaque page + police en fallback. Inter auto-hébergé :
   2 faces variables (100-900, latin + latin-ext) dans static/fonts/,
   @import supprimé (8 fichiers dupliqués dédupliqués → 2).

suite **1093/1093** · ruff OK · E2E **2/2** · docs à jour
This commit is contained in:
2026-10-02 10:23:34 -04:00
parent 3a74ea8bbd
commit ab6ac1e84c
12 changed files with 315 additions and 20 deletions
+153
View File
@@ -0,0 +1,153 @@
const { test, expect } = require('@playwright/test');
/**
* Smoke E2E — fondations vérifiant les portes des reports d'audit :
* - A39 : bascule de vues (création d'une vue Board depuis la barre de
* vues d'une collection → rendu de la grille)
* - A20 : Alpine + palette de commandes (Ctrl+K, recherche GET, fermeture)
* - filet : 0 erreur console (les violations CSP atterrissent ici)
*
* READ-ONLY sur les données existantes : crée puis SUPPRIME sa collection
* (répétable). Instance de test attendue sur FD_BASE_URL (défaut 8080).
*/
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
// Service Workers BLOQUÉS : /sw.js sert sa page « hors ligne » quand la
// réponse de navigation est une redirection (redirect:'manual' sur les
// requêtes navigate) — bruit PWA hors sujet ici (pwa_offline.spec.js
// couvre le SW). On interroge le serveur directement.
test.use({ serviceWorkers: 'block' });
const consoleErrors = [];
test.beforeEach(async ({ page }) => {
consoleErrors.length = 0;
page.on('console', (m) => {
if (m.type() !== 'error') return;
// les 401 de ressources (checks de session sur login) sont du bruit
// navigateur, pas une erreur JS/CSP — le reste compte
if (/Failed to load resource/.test(m.text())) return;
consoleErrors.push(m.text());
});
page.on('pageerror', (e) => consoleErrors.push('pageerror: ' + e.message));
});
test.afterEach(() => {
// Aucune erreur JS/CSP pendant le scénario
expect(consoleErrors).toEqual([]);
});
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, {
waitUntil: 'domcontentloaded',
});
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await Promise.race([
page
.waitForURL('**/workspaces', { timeout: 8000 })
.then(() => true)
.catch(() => false),
]);
if (!ok) {
// Compte absent de l'instance de test → création (bootstrap du harness,
// pas un mot de passe deviné : c'est le compte e2e documenté du repo).
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
data: { email: USER, password: PASS, name: 'E2E' },
});
if (resp.status() === 409) {
throw new Error(
'compte e2e existant mais mot de passe refusé — définir FD_USER/FD_PASS'
);
}
if (!resp.ok()) {
throw new Error(`register ${resp.status()}: ${await resp.text()}`);
}
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
await page.waitForURL('**/workspaces', { timeout: 15000 });
}
// workspace requis pour créer une collection (compte neuf = aucun ws)
const ws = await page.evaluate(async () => (await fetch('/api/workspaces')).json());
if (!ws.workspaces || ws.workspaces.length === 0) {
await page.evaluate(async () => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
const r = await fetch('/api/workspaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ name: 'E2E workspace' }),
});
const w = await r.json();
await fetch(`/api/workspaces/${w.id}/select`, { method: 'POST' });
});
}
}
test('gate A39 : bascule de vues (table → Calendar, rendu par onglet)', async ({ page }) => {
await login(page);
// collection jetable (créée puis supprimée = répétable). /db/{id} est une
// page STANDALONE (hors base.html) : les onglets .view-tabs naviguent en
// ?view=… et le corps est rendu côté serveur par _render_view().
const coll = await page.evaluate(async () => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
const r = await fetch('/db/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ name: 'e2e-view-switch' }),
});
return r.json();
});
expect(coll.id, `création collection: ${JSON.stringify(coll)}`).toBeTruthy();
try {
await page.goto(`${FD_BASE}/db/${coll.id}`, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.view-tabs a.tab')).toHaveCount(11);
await expect(page.locator('.calendar')).toHaveCount(0); // vue table par défaut
// bascule réelle : clic sur l'onglet Calendar → navigation ?view=calendar
await page.click('.view-tabs a.tab:has-text("Calendar")');
await page.waitForURL(/view_type=calendar/, { timeout: 10000 });
await expect(page.locator('.view-tabs a.tab.active')).toContainText('Calendar');
// corps Calendar rendu par _render_calendar (grille 6×7)
await expect(page.locator('.calendar')).toHaveCount(1);
expect(await page.locator('.cal-header').count()).toBeGreaterThanOrEqual(7);
} finally {
await page.evaluate(async (id) => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
await fetch(`/db/api/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf } });
}, coll.id);
}
});
test('gate A20 : palette Ctrl+K (Alpine + recherche GET)', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(800);
await page.keyboard.press('Control+k');
await page.waitForTimeout(400);
// overlay ouvert (classe .open pilotée par l'IIFE de base.html)
const open = await page.evaluate(() => {
const ov = document.querySelector('#fd-command-palette');
return !!ov && ov.classList.contains('open');
});
expect(open).toBe(true);
// tape une requête → la recherche GET répond et rend des résultats
await page.keyboard.type('a');
await page.waitForTimeout(900);
const items = await page.evaluate(
() => document.querySelectorAll('.cmd-palette-item').length
);
expect(items).toBeGreaterThan(0);
// Échap ferme la palette
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
const closed = await page.evaluate(() => {
const ov = document.querySelector('#fd-command-palette');
return !ov || !ov.classList.contains('open');
});
expect(closed).toBe(true);
});