Changed :
- htmx `allowEval: false` dans le meta htmx-config (base.html) : plus
d'évaluation JS côté htmx (hx-on/hx-vars/hx-vals = 0 usage grep → zéro
régression possible) ; unsafe-eval reste UNIQUEMENT pour Alpine standard.
- Gate E20 renforcée : le smoke vérifie que `Alpine.$data()` lie un vrai
composant [x-data] de la page (lien composant = cœur de toute bascule CSP).
- sw.js : cache bump flowdeck-v7 (purge + re-precache après Inter).
Probes (non conservés, retirés après mesure) — A20 phase 3 scopée :
- Build `@alpinejs/csp` téléchargé et TESTÉ : 72 Ko, 0 eval/new Function,
parseur d'expressions maison, tourne sous CSP strict (meta sans
unsafe-eval) — le lint sélectif fonctionne.
- Mais bloqué sur FlowDeck :
(a) 13 expressions non parsables par la grammaire restreinte
(arrows ×2, typeof ×1, new Date ×4, optional-chaining ×6 ;
base, library, local_workspace, settings, gitea_workspace) —
le gate E2E a attrapé la première : `CSP Parser Error: Unexpected
token: PUNCTUATION ")"` ;
(b) 24 `x-html` réactifs (icônes SVG + markdown agent + preview) =
INTERDITS par le build CSP (innerHTML) → architecture d'icônes à
reposer ;
(c) scope des expressions CSP = données du composant uniquement
(probe : `Undefined variable: fmtDate` / `document`) → chaque site
devient une méthode Alpine.data enregistrée.
- Conséquence : build CSP reverté (alpine.min.js ×3 templates + sw),
unsafe-eval maintenu, fichier alpine.csp.min.js retiré (re-téléchargeable),
assert test CSP de nouveau `in`. Plan de migration composant par composant
(library → settings → local_workspace → gitea → base) + gate E2E par
surface documenté dans ROADMAP (A20 phase 3).
suite **1093/1093** · ruff OK · E2E **2/2** (dont assertion Alpine.$data)
· docs à jour (ROADMAP A20 phase 3, CHANGELOG, WORKLOAD, VERSION)
168 lines
6.7 KiB
JavaScript
168 lines
6.7 KiB
JavaScript
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);
|
||
|
||
// 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);
|
||
});
|