fix: anti-FOUC au chargement complet (x-cloak sur .app-layout) + nettoyage (v7.45.4)
FlowDeck CI / docker (push) Successful in 1m52s
FlowDeck CI / lint (push) Successful in 2m15s
FlowDeck CI / test (push) Successful in 15m22s

- 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.
This commit is contained in:
2026-10-03 00:54:28 -04:00
parent b2ea8ec537
commit aa88cf6665
9 changed files with 223 additions and 12 deletions
+180 -4
View File
@@ -89,8 +89,10 @@ test('probe fluidité nav', async ({ page }) => {
test.setTimeout(120000);
await login(page);
const NAVS = ['/library', '/settings', '/workspaces', '/trash'];
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' });
@@ -103,17 +105,34 @@ test('probe fluidité nav', async ({ page }) => {
document.body.appendChild(a);
a.click();
}, dest);
await page.waitForTimeout(3200);
// 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
// É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',
};
});
@@ -138,9 +157,166 @@ test('probe fluidité nav', async ({ page }) => {
// 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);
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');
});