/** * 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', '/local-workspace']; const results = []; const SHOTS = require('path').join(__dirname, 'shots'); require('fs').mkdirSync(SHOTS, { recursive: true }); 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); // Filet : si du contenu brut est peint, on capture pour voir CE qui flash let shot = null; const tEnd = Date.now() + 3200; while (Date.now() < tEnd && !shot) { const last = await page.evaluate(() => { const f = window.__probe.frames; return f.length ? f[f.length - 1] : null; }); if (last && last[2] > 0) { shot = `navflash_${dest.replace(/\W+/g, '')}.png`; await page.screenshot({ path: require('path').join(SHOTS, shot) }); break; } await page.waitForTimeout(25); } const P = await page.evaluate(() => window.__probe); // État final : la zone doit être révélée (opacity>0) ET montée Alpine, // et .app-layout (x-cloak anti-FOUC) doit être retiré → pas de page blanche const final = await page.evaluate(() => { const z = document.querySelector('.main-wrapper'); const a = document.querySelector('.app-layout'); 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'), layout: a ? { cloak: a.hasAttribute('x-cloak'), display: getComputedStyle(a).display } : 'ABSENT', }; }); // 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 || !r.final.layout || r.final.layout.cloak === true || r.final.layout.display === 'none'); 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'); }); // Le geste réel du rapport : clic sur le bouton Home de la sidebar (et non un // ancre synthétique) — on mesure ce qui peint et on garde une capture si flash. test('probe bouton Home', async ({ page }) => { test.setTimeout(120000); const SHOTS = require('path').join(__dirname, 'shots'); require('fs').mkdirSync(SHOTS, { recursive: true }); await login(page); await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'load' }); await page.waitForTimeout(1200); await page.evaluate(INSTALL); const console_ = []; page.on('console', (m) => { if (m.type() !== 'log') console_.push(m.type() + ' | ' + m.text().slice(0, 180).replace(/\s+/g, ' ')); }); page.on('pageerror', (e) => console_.push('PAGEERROR | ' + String(e).slice(0, 180))); const before = await page.evaluate(() => ({ onLine: navigator.onLine, home: !!document.querySelector('.home-btn'), href: (document.querySelector('.home-btn') || {}).href })); console.log('HOME avant clic ' + JSON.stringify(before)); await page.click('.home-btn'); let shot = null; const tEnd = Date.now() + 4000; while (Date.now() < tEnd && !shot) { const last = await page.evaluate(() => { const f = window.__probe.frames; return f.length ? f[f.length - 1] : null; }); if (last && last[2] > 0) { shot = 'home_flash.png'; await page.screenshot({ path: require('path').join(SHOTS, shot) }); break; } await page.waitForTimeout(25); } await page.waitForTimeout(2000); const P = await page.evaluate(() => window.__probe); const rawFrames = P.frames.filter((f) => f[2] > 0); const after = await page.evaluate(() => { const b = document.querySelector('.offline-banner'); const z = document.querySelector('.main-wrapper'); return { url: location.pathname + location.search, onLine: navigator.onLine, banner: b ? { cloak: b.hasAttribute('x-cloak'), display: getComputedStyle(b).display, mounted: !!b._x_marker } : 'absente', zone: z ? { op: getComputedStyle(z).opacity, un: Array.from(z.querySelectorAll('[x-show]')).filter((e) => !e._x_marker).length, xIgnore: z.hasAttribute('x-ignore') } : null, mounts: typeof window.__fdLocalWorkspaceScriptsLoaded !== 'undefined' ? window.__fdLocalWorkspaceScriptsLoaded : 'n/a', }; }); const res = { marks: Object.fromEntries(P.marks), rawVisibleFrames: rawFrames.length, rawFirst: rawFrames[0] || null, rawMax: P.rawMax, shot, shifts: P.shifts, console: console_, after }; console.log('HOME résultat => ' + JSON.stringify(res)); if (res.rawVisibleFrames > 0) throw new Error(`contenu brut peint au clic Home: ${res.rawVisibleFrames} frames (max ${res.rawMax})`); console.log('GATE HOME OK — 0 frame de contenu brut'); }); // Scénario du rapport : la bande rouge dit « hors ligne » → on rejoue le clic // Home AVEC le réseau coupé (SW actif) pour voir ce qui peint. test('probe bouton Home hors ligne', async ({ page, context }) => { test.setTimeout(120000); const SHOTS = require('path').join(__dirname, 'shots'); require('fs').mkdirSync(SHOTS, { recursive: true }); await login(page); await page.addInitScript(INSTALL); await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'load' }); await page.waitForTimeout(1500); // laisse le SW s'installer et cacher les pages await page.evaluate(() => { window.__probe = null; window.__probe = undefined; }); await page.evaluate(INSTALL); await context.setOffline(true); await page.waitForTimeout(200); const onLine = await page.evaluate(() => navigator.onLine); await page.click('.home-btn'); const shots = []; for (const [delay, name] of [[700, 't700'], [2500, 't2500']]) { await page.waitForTimeout(delay === 700 ? 700 : 1800); const p = `home_offline_${name}.png`; await page.screenshot({ path: require('path').join(SHOTS, p) }).catch(() => {}); shots.push(p); } const P = await page.evaluate(() => window.__probe || null).catch(() => null); const after = await page.evaluate(() => { const b = document.querySelector('.offline-banner'); const z = document.querySelector('.main-wrapper'); const visible = (sel) => Array.from(document.querySelectorAll(sel)).filter((e) => e.getClientRects().length && getComputedStyle(e).display !== 'none').map((e) => (e.className || '').toString().split(' ')[0]).slice(0, 12); return { url: location.pathname + location.search, onLine: navigator.onLine, banner: b ? { display: getComputedStyle(b).display, mounted: !!b._x_marker } : 'absente', zone: z ? { op: getComputedStyle(z).opacity, un: Array.from(z.querySelectorAll('[x-show]')).filter((e) => !e._x_marker).length, xIgnore: z.hasAttribute('x-ignore') } : null, overlays: visible('.modal-overlay, .ctx-menu, .context-menu, .user-menu-dropdown, .dropdown-menu, .popover'), }; }).catch((e) => ({ err: String(e) })); const rawFrames = P ? P.frames.filter((f) => f[2] > 0) : []; const res = { onLineBefore: onLine, marks: P ? Object.fromEntries(P.marks) : null, rawVisibleFrames: rawFrames.length, rawFirst: rawFrames[0] || null, rawMax: P ? P.rawMax : null, shots, shifts: P ? P.shifts : null, after }; console.log('HOME OFFLINE => ' + JSON.stringify(res)); }); // Charge COMPLÈTE (F5 / premier visit / SW) : le masquage fdLoad ne s'applique // pas là, seul x-cloak protège — on mesure ce qui peint avant Alpine.start(). test('probe fluidité plein chargement', async ({ page }) => { test.setTimeout(120000); const SHOTS = require('path').join(__dirname, 'shots'); require('fs').mkdirSync(SHOTS, { recursive: true }); await login(page); await page.addInitScript(INSTALL); const OUT = []; for (const dest of ['/local-workspace', '/library', '/workspaces']) { await page.goto(FD_BASE + dest, { waitUntil: 'commit' }); let shot = null; const tEnd = Date.now() + 4000; while (Date.now() < tEnd && !shot) { const last = await page.evaluate(() => { const f = window.__probe && window.__probe.frames; return f && f.length ? f[f.length - 1] : null; }).catch(() => null); if (last && last[2] > 0) { shot = `fullflash_${dest.replace(/\W+/g, '')}.png`; await page.screenshot({ path: require('path').join(SHOTS, shot) }); break; } await page.waitForTimeout(25); } await page.waitForTimeout(1500); const P = await page.evaluate(() => window.__probe); const rawFrames = P.frames.filter((f) => f[2] > 0); const res = { dest, rawVisibleFrames: rawFrames.length, rawFirst: rawFrames[0] || null, rawLast: rawFrames[rawFrames.length - 1] || null, rawMax: P.rawMax, shot, shifts: P.shifts, final: await page.evaluate(() => { const z = document.querySelector('.main-wrapper'); const a = document.querySelector('.app-layout'); return z ? { op: getComputedStyle(z).opacity, un: Array.from(z.querySelectorAll('[x-show]')).filter((e) => !e._x_marker).length, layout: a ? { cloak: a.hasAttribute('x-cloak'), display: getComputedStyle(a).display } : 'ABSENT', } : null; }), }; OUT.push(res); console.log('FULL ' + dest + ' => ' + JSON.stringify(res)); } console.log('SOMMAIRE_FULL ' + JSON.stringify(OUT.map((r) => [r.dest, r.rawVisibleFrames, r.rawMax, r.shot]))); const notRevealed = OUT.filter((r) => !r.final || parseFloat(r.final.op) !== 1 || r.final.un !== 0 || !r.final.layout || r.final.layout.cloak === true || r.final.layout.display === 'none'); if (notRevealed.length) throw new Error('page blanche/non montée au chargement: ' + JSON.stringify(notRevealed.map((r) => [r.dest, r.final]))); if (OUT.some((r) => r.rawVisibleFrames > 0)) { throw new Error('contenu brut peint au plein chargement: ' + OUT.filter((r) => r.rawVisibleFrames > 0).map((r) => `${r.dest} (${r.rawVisibleFrames} frames, max ${r.rawMax})`).join(' ; ')); } console.log('GATE FULL OK — 0 frame de contenu brut au chargement complet'); });