const { test, expect } = require('@playwright/test'); /** * Aperçu CSP strict (A20 phase 3) : charge la page avec le build CSP * d'Alpine (fichier officiel `@alpinejs/csp`, 0 eval) servi à la place de * alpine.min.js via interception — SANS déployer. Toute expression que le * parseur maison ne digère pas = pageerror « CSP Parser Error » (filet) ; * les x-html restants = directive interdite du build (console error). * Quand toutes les surfaces passent ici → bascule réelle + retrait * d'unsafe-eval (ROADMAP A20 phase 3). */ 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'; test.use({ serviceWorkers: 'block' }); const errors = []; let currentUrl = ''; test.beforeEach(async ({ page }) => { errors.length = 0; await page.route('**/static/js/alpine.min.js', (route) => route.fulfill({ path: require('path').join(__dirname, 'fixtures', 'alpine.csp.js'), contentType: 'application/javascript', }) ); page.on('console', (m) => { if (m.type() !== 'error') return; if (/Failed to load resource/.test(m.text())) return; errors.push(m.text()); }); page.on('pageerror', (e) => errors.push('pageerror@' + (currentUrl || '?') + ': ' + e.message) ); }); test.afterEach(() => expect(errors).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 page .waitForURL('**/workspaces', { timeout: 10000 }) .then(() => true) .catch(() => false); if (!ok) { 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 — FD_USER/FD_PASS incorrects'); 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 }); } } async function assertBound(page) { return page.evaluate(() => { const el = document.querySelector('[x-data]'); if (!el || !window.Alpine) return 'absent'; try { const d = window.Alpine.$data(el); return d && typeof d === 'object' ? 'ok' : 'vide'; } catch (e) { return 'throw:' + e.message; } }); } test('A20-ph3 : surfaces simples sous build CSP (welcome/trash/accounts/workspace)', async ({ page }) => { // scan statique = 0 expression/x-html sur ces gabarits → ici on traque // les échecs RUNTIME (globales, timing de registre, scope de structure) // /welcome est anonyme (avant login aussi) mais login() ne gêne pas await login(page); for (const url of ['/welcome', '/trash', '/accounts', '/workspace', '/import']) { currentUrl = url; await page.goto(FD_BASE + url, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(500); expect(await assertBound(page), `x-data non lié sur ${url}`).toBe('ok'); } }); test('A20-ph3 : settings sous build Alpine CSP', async ({ page }) => { await login(page); await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(600); expect(await assertBound(page)).toBe('ok'); await expect(page.locator('.settings-overlay')).toBeVisible(); }); test('A20-ph3 : local workspace sous build Alpine CSP', async ({ page }) => { await login(page); await page.goto(`${FD_BASE}/local-workspace`, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(600); expect(await assertBound(page)).toBe('ok'); // recherche : toggleSearch() (méthode réelle) + focus Alpine.nextTick await page.click('button.ws-icon-btn[title="Search"]'); await page.waitForTimeout(400); const focused = await page.evaluate( () => document.activeElement && document.activeElement.getAttribute('x-ref') === 'searchInput' ); expect(focused).toBe(true); // chips filtre : bindSvg() via x-init (x-html interdit en CSP) — // x-init tourne même si la rangée est masquée (x-show=filterType) const svg = await page.evaluate( () => (document.querySelector('button.filter-chip[title="Folders"]') || {}).innerHTML || '' ); expect(svg).toContain(' { await login(page); await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(600); // le composant est lié par le registre Alpine.data (scope CSP) expect(await assertBound(page)).toBe('ok'); // icône du empty-state : x-html remplacé par x-init + Alpine.effect await expect(page.locator('#lib-empty .empty-icon')).toBeVisible({ timeout: 8000 }); const svg = await page.evaluate( () => document.querySelector('#lib-empty .empty-icon').innerHTML ); expect(svg).toContain(' document.activeElement && document.activeElement.id === 'lib-search-input' ); expect(focused).toBe(true); });