Added :
- Onglets dans la palette (markup + CSS + wiring IIFE) :
· Pages — comportement inchange (recherche /api/search + actions).
· ✨ Réponses IA — POST /api/v2/search/ask (debounce 150 ms, CSRF via
getCsrf(), gardes staleness onglet+requete) → answer_markdown rendu :
echappement AVANT injection, [[fdpage:ID]] → lien citation (ids =
chiffres, type = [a-z]+ — pas d'injection), **gras**, bloc « Sources »
avec liens /pages/{id} · /db/{id} ; etats hint/chargement/erreur.
- Gate E2E etendu : ouverture → clic onglet IA → reponse non-echappee.
(probe : ask = 200 en 1,6s, 8 citations, provider actif)
Reporte (backend absent) : onglet Fichiers — /api/search ne renvoie que
pages/collections → endpoint a creer d'abord (ROADMAP, entree mise a jour).
Notes : 401 transitoire sur le 1er ask d'un run E2E observe une fois
(session fraiche), non reproductible ensuite — signale CHANGELOG.
suite **1094/1094** · ruff OK · E2E **7/7** · docs a jour
187 lines
7.4 KiB
JavaScript
187 lines
7.4 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);
|
||
|
||
// onglet ✨ Réponses IA : POST /api/v2/search/ask (extractif hors-LM)
|
||
await page.keyboard.press('Control+k');
|
||
await page.waitForTimeout(300);
|
||
await page.click('.cp-tab[data-tab="ai"]');
|
||
await page.waitForSelector('.cmd-palette-ai', { timeout: 10000 });
|
||
await page.waitForFunction(
|
||
() => {
|
||
const el = document.querySelector('.cmd-palette-ai');
|
||
return el && !el.querySelector('.cp-loading');
|
||
},
|
||
null,
|
||
{ timeout: 15000 }
|
||
);
|
||
const ai = await page.evaluate(() => {
|
||
const el = document.querySelector('.cmd-palette-ai');
|
||
return { text: ((el && el.textContent) || '').trim() };
|
||
});
|
||
expect(ai.text.length).toBeGreaterThan(5);
|
||
});
|