Files
flowdeck/e2e/csp_preview.spec.js
T
bruno d7d9966edf
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
feat: A20 phase 3 LOT 3a — 5 surfaces CSP vertes + fix bug /import (v7.40.0)
Ajout :
- Gate csp_preview « surfaces simples » : /welcome, /trash, /accounts,
  /workspace, /import — 0 modification necessaire sur les 4 premieres
  (scan statique 0 expression/x-html + registres Alpine.data du lot 1).
  8 surfaces couvertes au total.

Fixed (pre-existant, visible sous les DEUX builds) :
- /import : x-text "'🔗 '+report.relations…" evalue avec report=null
  (le x-show parent ne masque pas, il initialise quand meme) →
  pageerror « Cannot read property ... 'relations' » → garde
  report && report.relations.

Reste ph3 documente dans ROADMAP : page_editor (12 sites window.E dans
right_actions), gitea_workspace (new Date), agent_panel (x-text+x-html
markdown), board/table_view/teamload/card_detail (scan propre, gates lies
au contexte Gitea) → bascule reel ensuite.

suite **1093/1093** · ruff OK · eslint 0/0 · E2E **6/6** (4 csp_preview +
2 smoke) · docs a jour
2026-10-02 13:49:11 -04:00

140 lines
5.4 KiB
JavaScript

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 || '[email protected]';
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('<svg');
});
test('A20-ph3 : library sous build Alpine CSP', async ({ page }) => {
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('<svg');
// bouton recherche : @click.stop → toggleSearch() (méthode réelle,
// Alpine.nextTick pour le focus) — l'expression inline arrow n'existe plus
await page.click('.lib-icon-btn[title="Search"]');
await expect(page.locator('#lib-search-input')).toBeVisible();
await page.waitForTimeout(300);
const focused = await page.evaluate(
() => document.activeElement && document.activeElement.id === 'lib-search-input'
);
expect(focused).toBe(true);
});