/** * Probe de fluidité de navigation partielle (fdLoad) — mesure brute, pas un test gate. * * Pour chaque navigation il enregistre : * - les marques htmx (beforeRequest / beforeSwap / afterSwap / afterSettle) * - la fenêtre x-ignore (zone non montée Alpine) * - un échantillon par frame : nb d'éléments [x-show] ENCORE VISIBLES non montés * (= le contenu « brut » qui se voit avant qu'Alpine ne le masque → les flashes * de fenêtres) et nb d'éléments [x-cloak] encore masqués (= contenu qui pop) * - les layout-shift (CLS) et leur source * * Lancement : npx playwright test probe_nav_perf --reporter=list */ const { test } = require('@playwright/test'); 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'; 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: 8000 }).then(() => true).catch(() => false); if (!ok) { await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' } }); await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' }); await page.waitForURL('**/workspaces', { timeout: 15000 }); } } // Instrumentation posée UNE fois dans la page (avant le clic). const INSTALL = () => { const P = (window.__probe = { t0: performance.now(), marks: [], frames: [], shifts: [], rawMax: 0, rawFrames: 0 }); const mark = (n) => P.marks.push([n, Math.round(performance.now() - P.t0)]); ['htmx:beforeRequest', 'htmx:beforeSwap', 'htmx:afterSwap', 'htmx:afterSettle'].forEach((ev) => document.addEventListener(ev, () => mark(ev)) ); // Fenêtre x-ignore sur .main-wrapper (le nœud change à chaque swap → on observe tout le doc) new MutationObserver((muts) => { for (const m of muts) { if (m.type !== 'attributes' || m.attributeName !== 'x-ignore') continue; const t = m.target; if (!(t instanceof Element) || !t.classList.contains('main-wrapper')) continue; mark(t.hasAttribute('x-ignore') ? 'x-ignore ON' : 'x-ignore OFF'); } }).observe(document.documentElement, { attributes: true, attributeFilter: ['x-ignore'], subtree: true }); new PerformanceObserver((list) => { for (const e of list.getEntries()) { P.shifts.push({ v: +e.value.toFixed(4), t: Math.round(e.startTime - P.t0), sources: (e.sources || []).map((s) => { const n = s.node; return n ? `${n.nodeName}.${(n.className || '').toString().split(' ')[0]}` : '?'; }), }); } }).observe({ type: 'layout-shift', buffered: false }); // Échantillonnage par frame. « visible » = peint à l'écran : la zone peut // porter opacity:0 (fix v7.45.3) → rects toujours là, mais rien n'est peint. const zonePainted = (z) => z && parseFloat(getComputedStyle(z).opacity || '1') > 0; const tick = () => { let raw = 0, rawVisible = 0, cloak = 0; const zone = document.querySelector('.main-wrapper'); const painted = zonePainted(zone); if (zone) { for (const el of zone.querySelectorAll('[x-show]')) { if (el._x_marker) continue; // monté par Alpine raw++; if (painted && el.getClientRects().length) rawVisible++; // peint ET en flow } cloak = zone.querySelectorAll('[x-cloak]').length; } P.rawMax = Math.max(P.rawMax, rawVisible); if (rawVisible) P.rawFrames++; P.frames.push([Math.round(performance.now() - P.t0), raw, rawVisible, cloak, painted ? 1 : 0]); if (performance.now() - P.t0 < 3000) requestAnimationFrame(tick); }; requestAnimationFrame(tick); return true; }; test('probe fluidité nav', async ({ page }) => { test.setTimeout(120000); await login(page); const NAVS = ['/library', '/settings', '/workspaces', '/trash']; const results = []; for (const dest of NAVS) { await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'load' }); await page.waitForTimeout(1200); await page.evaluate(INSTALL); await page.evaluate((p) => { const a = document.createElement('a'); a.href = p; a.textContent = 'probe'; document.body.appendChild(a); a.click(); }, dest); await page.waitForTimeout(3200); const P = await page.evaluate(() => window.__probe); // État final : la zone doit être révélée (opacity>0) ET montée Alpine const final = await page.evaluate(() => { const z = document.querySelector('.main-wrapper'); if (!z) return { opacity: 'NO-ZONE', unmounted: -1, xIgnore: null }; return { opacity: getComputedStyle(z).opacity, pointerEvents: getComputedStyle(z).pointerEvents, unmounted: Array.from(z.querySelectorAll('[x-show]')).filter((e) => !e._x_marker).length, xIgnore: z.hasAttribute('x-ignore'), }; }); // Résumé : première et dernière frame où du contenu brut visible subsiste const rawFrames = P.frames.filter((f) => f[2] > 0); const results_summary = { dest, marks: Object.fromEntries(P.marks), rawVisibleFrames: rawFrames.length, rawFirst: rawFrames.length ? rawFrames[0] : null, rawLast: rawFrames.length ? rawFrames[rawFrames.length - 1] : null, rawMaxElements: P.rawMax, hiddenFrames: P.frames.filter((f) => f[4] === 0).length, cloakMax: Math.max(0, ...P.frames.map((f) => f[3])), shifts: P.shifts, final, frameCount: P.frames.length, }; results.push(results_summary); console.log('NAV ' + dest + ' => ' + JSON.stringify(results_summary)); } // GATE : aucun contenu brut peint + zone révélée et montée à la fin const paintedRaw = results.filter((r) => r.rawVisibleFrames > 0); const notRevealed = results.filter((r) => parseFloat(r.final.opacity) !== 1 || r.final.unmounted !== 0 || r.final.xIgnore); console.log('SOMMAIRE ' + JSON.stringify(results.map((r) => ({ d: r.dest, raw: r.rawVisibleFrames, hidden: r.hiddenFrames, op: r.final.opacity, un: r.final.unmounted, shift: r.shifts.length })))); if (paintedRaw.length) throw new Error('contenu brut peint: ' + paintedRaw.map((r) => `${r.dest} (${r.rawVisibleFrames} frames, max ${r.rawMaxElements})`).join(' ; ')); if (notRevealed.length) throw new Error('zone non révélée/montée: ' + JSON.stringify(notRevealed.map((r) => [r.dest, r.final]))); console.log('GATE OK — 0 frame de contenu brut, zone révélée et montée sur ' + results.length + ' navigations'); });