- Symptôme rapporté : clic sur Home → rafale de menus/fenêtres + bande rouge « You are offline. Changes will sync when connection is restored. ». - Constat : le masquage de v7.45.3 ne couvrait QUE les swaps partiels fdLoad. Un chargement COMPLET de document (F5, première visite, navigation servie par le service worker) peignait toute l'app tant qu'Alpine n'avait pas initialisé .app-layout : sidebar brute (sections ouvertes, menu utilisateur) + zone brute dont la bande hors ligne (aucun x-cloak dessus). Le symptôme Home n'est par ailleurs PAS rejouable en navigateur frais (nav, clic Home en ligne/hors ligne, plein chargement → 0 frame de contenu brut, 0 erreur console). - Fix : x-cloak sur le nœud racine Alpine .app-layout (base.html) — sidebar + zone + bandeau masqués jusqu'au montage d'appState(), retrait par Alpine à l'init. Le probe asserte que l'attribut est bien retiré et que le node est visible en fin de parcours (garde-fou contre la page blanche). - Suppression de la classe fd-navigating (posée à chaque fdLoad, aucune règle CSS dans le dépôt → code mort). - e2e/probe_nav_perf.spec.js : 4 scénarios avec assertions (nav 5 pages, clic Home réel, Home hors ligne avec SW, plein chargement 3 pages) + capture screenshot auto si contenu brut + collecte console. Gates : probe_nav_perf 4/4 · regression_partial_nav + regression_editor_mount + regression_logout_dnd + smoke = 6 passed (errs=0 sur 7 pages × 3 passages) · pytest 1094 passed · ruff OK · OpenAPI 7.45.4.
323 lines
16 KiB
JavaScript
323 lines
16 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', '/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');
|
|
});
|