Added — Settings → Automations, pipeline visuel (API steps v7.0) :
- Cartes ordinees (trigger/condition/delay/action) : resume + edition
TYPÉE par kind/type (datalist evenements, 7 ops, 8 types d'action avec
leurs champs reels), ajout/edition/suppression/haut-bas via
POST/PUT/DELETE /workspace/automations[/steps]/...
- ✨ Convertir le JSON en pipeline (legacy → steps ordonnes) ; textareas
JSON masques des qu'un step existe.
- Gate E2E « editeur visuel de steps » : creation → edition → ajout →
carte « Action · webhook » sous CSP reel.
Fixed (trouve par le gate) — 51 expressions Alpine MULTI-INSTRUCTIONS
(`a=1; b()`) = interdites par le parseur CSP (une seule expression par
directive ; ';' = token inattendu) — INVISIBLE pour le scan par tokens :
- Conversion en methodes dans 11 fichiers : nav settings x8 (navTo),
menu section base x7 (closeAndSetCount/Move/...), parts+editeur x13
(setSharePerm, more*, markAndSave...), breadcrumb x5 (hover*/goClose),
library/local x6 (menus popup), board x3 (pickStatus/...), ctx-menu x2
(addTagAndClear), agent/card/gitea/workspaces x6.
- Scanner dedie scan_semi (inventaire ';' hors chaines) ajoute au lot.
Verifs : 39 templates Jinja parse OK · scan expressions = 0 incompatible
(4 faux positifs en chaines) · **E2E 8/8** · suite **1094/1094** ·
ruff OK · docs a jour
238 lines
9.7 KiB
JavaScript
238 lines
9.7 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;
|
|
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('<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);
|
|
});
|