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; currentUrl = ''; 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', '/gitea-workspace']) { 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'); } // panneau agent (composant de base, x-html markdown migré via bindMarkdown) await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(400); const agent = await page.evaluate(() => { const el = document.querySelector('#fd-agent-panel'); 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; } }); expect(agent).toBe('ok'); }); test('A20-ph3 : éditeur de page (right_actions) sous build CSP', async ({ page }) => { // le topbar vit dans le scope appState : les12 sites window.E ont été // remplacés par edCall/edTimeAgo/edCommentCount/edShared/bindStar — // toute expression non parsable = pageerror (filet). await login(page); 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-csp-editor' }), }); return r.json(); }); expect(coll.id, JSON.stringify(coll)).toBeTruthy(); try { await page.goto(`${FD_BASE}/pages/${coll.id}`, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(600); // le composant éditeur est lié const editor = await page.evaluate(() => { const el = document.querySelector('#page-editor, .page-editor, [x-data]'); const root = document.querySelector('.app-layout'); const d1 = root && window.Alpine ? window.Alpine.$data(root) : null; const hasEd = d1 && typeof d1.edCall === 'function'; let ed = 'absent'; try { const cand = Array.from(document.querySelectorAll('[x-data]')) .map((e) => e.getAttribute('x-data')) .filter((a) => a && a.startsWith('editorState')); ed = cand.length ? 'ok' : 'aucun-editorState'; } catch (e) { ed = 'throw'; } return { ed: ed, delegates: hasEd ? 'ok' : 'absent', el: !!el }; }); expect(editor.delegates).toBe('ok'); expect(editor.ed).toBe('ok'); // les boutons du topbar sont réellement servis (bug A10 « ~ + Markup » // corrigé : block-set) ET leurs expressions passent le filet CSP await expect(page.locator('.star-btn').first()).toBeAttached(); } 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('UI : éditeur visuel de steps automations (API v7.0)', async ({ page }) => { // crée une automation, ouvre l'édition, ajoute une étape (POST /steps), // vérifie la carte résumée — sous CSP réel (expressions du nouveau bloc) await login(page); await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(500); await page.click('.settings-nav-item:has-text("Automations")'); const name = 'e2e-steps-' + Date.now(); await page.fill('input[placeholder*="Notifier le statut"]', name); await page.click('button:has-text("Créer")'); await page.waitForTimeout(700); const row = page.locator('.setting-row', { hasText: name }).last(); await row.locator('button:has-text("✎")').click(); await page.waitForTimeout(700); await expect(page.locator('text=Pipeline visuel (steps)')).toBeVisible(); await page.click('button:has-text("Ajouter l\'étape")'); await page.waitForFunction( () => Array.from(document.querySelectorAll('.setting-label')) .some((e) => (e.textContent || '').includes('Action · webhook')), null, { timeout: 8000 } ); // nettoyage API await page.evaluate(async (n) => { const d = await (await fetch('/workspace/automations')).json(); const a = (d.automations || []).find((x) => x.name === n); if (a) { const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || ''; await fetch('/workspace/automations/' + a.id, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf } }); await fetch('/workspace/automations/' + a.id + '/steps', { method: 'DELETE' }).catch(() => {}); } }, name); }); 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); });