- Symptôme : à chaque changement de page/section, une rafale de fenêtres / menus / états s'affiche une fraction de seconde avant la page voulue. - Cause : pendant la fenêtre x-ignore (swap fdLoad → Alpine.initTree, mise en place en v7.45.2), .main-wrapper était peint NON montée : tous ses [x-show] visibles à leur valeur brute, puis masqués d'un coup au montage. Mesure (e2e/probe_nav_perf.spec.js sur l'instance locale) : 19 éléments bruts visibles 87 ms sur /library (CLS 0.149, sources lib-loading/lib-empty/ lib-table), 80 éléments pendant 732 ms sur /settings, 7 sur /workspaces. - Fix (app.js, handler htmx:afterSwap) : opacity:0 + pointer-events:none posés dans la même task que le swap (aucun paint intermédiaire possible), retirés APRÈS Alpine.initTree sur les 3 chemins de démontage (scripts chargés, zéro script, filet 4 s). reveal() est appelé avant les early-returns de done() → impossible de rester bloqué invisible. - Après : 0 frame de contenu brut peint sur les 4 navigations du probe, 1–2 frames masquées, zone révélée (opacity:1, pointer-events:auto) et montée (0 [x-show] non montés, x-ignore absent) à chaque fois. - Gate : e2e/probe_nav_perf.spec.js — marques htmx, fenêtre x-ignore, échantillon par frame des [x-show] bruts peints, layout-shift + assertion (0 brut / zone révélée et montée), exécutable en CI. - Reste (non traité, voir ROADMAP) : CLS résiduel 0.057 sur /library (états loading → table), fenêtre x-ignore de ~250 ms sur /settings (coût des scripts de page), topbar incluse dans .main-wrapper re-swappée à chaque nav. Gates : probe_nav_perf + regression_partial_nav + regression_editor_mount + regression_logout_dnd + smoke · pytest 1094 passed · ruff OK · OpenAPI 7.45.3.
147 lines
6.6 KiB
JavaScript
147 lines
6.6 KiB
JavaScript
/**
|
|
* 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 || '[email protected]';
|
|
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');
|
|
});
|