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 || 'e2e@flowdeck.local'; 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); // Alpine doit être lié (build CSP : le x-data + ses expressions évalués // SANS eval) sur un composant réel de la page const alpine = await page.evaluate(() => { const el = document.querySelector('[x-data]'); if (!el || !window.Alpine) return 'absent'; try { const data = window.Alpine.$data(el); return data && typeof data === 'object' ? 'ok:' + Object.keys(data).slice(0, 3).join(',') : 'vide'; } catch (e) { return 'throw:' + e.message; } }); expect(alpine).toMatch(/^ok:/); // É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); // onglet ✨ Réponses IA : POST /api/v2/search/ask (extractif hors-LM) await page.keyboard.press('Control+k'); await page.waitForTimeout(300); await page.click('.cp-tab[data-tab="ai"]'); await page.waitForSelector('.cmd-palette-ai', { timeout: 10000 }); await page.waitForFunction( () => { const el = document.querySelector('.cmd-palette-ai'); return el && !el.querySelector('.cp-loading'); }, null, { timeout: 15000 } ); const ai = await page.evaluate(() => { const el = document.querySelector('.cmd-palette-ai'); return { text: ((el && el.textContent) || '').trim() }; }); expect(ai.text.length).toBeGreaterThan(5); });