From ec4b0a1428ca512b26ce09ef40894b7599ef8d14 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Tue, 27 Oct 2026 16:10:26 +0100 Subject: [PATCH] outils(layout-bench) : temps et empreinte du placement, temps d'ouverture d'un arbre Co-Authored-By: Claude Opus 5.5 --- tools/layout-bench/README.md | 2 + tools/layout-bench/layout-time.mjs | 46 +++++++++++++++ tools/layout-bench/load-probe.mjs | 91 ++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 tools/layout-bench/layout-time.mjs create mode 100644 tools/layout-bench/load-probe.mjs diff --git a/tools/layout-bench/README.md b/tools/layout-bench/README.md index 41384bc..4c47ea2 100644 --- a/tools/layout-bench/README.md +++ b/tools/layout-bench/README.md @@ -33,5 +33,7 @@ La largeur totale ne départage qu'une égalité parfaite. Pas de score composit ## Performance, parcours compte, landing - `TREE_PASSWORD='...' node perf-probe.mjs ` : temps main thread par frame au repos, en drag et en zoom, appels canvas par frame, profil CPU. Brave ou Chrome headless piloté en CDP. +- `node --import ./register.mjs layout-time.mjs [arbre ...]` : temps du placement sans écran (liste des onglets, arbre de départ, arbre entier, chaque onglet) et une empreinte des positions, arêtes, couples et onglets. Avant toute optimisation du placement, relever les empreintes, puis vérifier qu'elles sortent identiques (`SRC=` pour mesurer l'autre version). +- `node load-probe.mjs [nom] [répétitions]` : temps jusqu'à l'arbre utilisable et jusqu'à la barre des onglets, médiane des répétitions, fonctions les plus coûteuses au chargement. Sur un build (`vite build --minify false`, `vite preview`), pas en mode dev. `CPU=4` simule un téléphone moyen. Sur une machine occupée, les temps varient du simple au double : comparer deux versions en alternant les passes. - `node e2e-account.mjs ` : scénario complet sur un backend local (lien partagé, compte créé, rattachement, contributions). `CLEANUP=1` supprime l'arbre de test. - `node landing-check.mjs ` : titres, liens, formulaires et erreurs JS d'une page, avec capture. diff --git a/tools/layout-bench/layout-time.mjs b/tools/layout-bench/layout-time.mjs new file mode 100644 index 0000000..d5263dc --- /dev/null +++ b/tools/layout-bench/layout-time.mjs @@ -0,0 +1,46 @@ +// Temps du placement sans écran, et empreinte du résultat pour prouver qu'une optimisation ne change rien. +// Usage : node --import ./register.mjs layout-time.mjs [arbre ...] (mêmes noms d'arbres que run.mjs) +// SRC= mesure une autre version du code (un autre worktree), comme le banc. +// Par arbre : la liste des onglets (familyTabsOf), l'arbre de départ avec onglets, l'arbre entier sans onglets, puis +// chaque onglet comme l'app l'ouvre. L'empreinte couvre positions, arêtes, couples et onglets : deux versions qui +// rendent les mêmes trois empreintes placent l'arbre à l'identique. +import { createHash } from 'node:crypto' +import { pathToFileURL } from 'node:url' +import { performance } from 'node:perf_hooks' +import { loadTree, SRC, archetypeNames } from './load.mjs' + +const bl = await import(pathToFileURL(`${SRC}components/galaxy/blockLayout.js`).href) +const elk = await import(pathToFileURL(`${SRC}components/galaxy/elkLayout.js`).href) + +const fingerprint = (result) => { + const h = createHash('sha1') + for (const n of result.children) h.update(`${n.id}|${n.x.toFixed(2)}|${n.y.toFixed(2)}|${n.width}|${n._generation}|${n._familyTab ? JSON.stringify(n._familyTab) : ''};`) + for (const e of result.edges || []) h.update(`${e.id}|${e.sources}|${e.targets}|${e._external ? 1 : 0};`) + h.update(JSON.stringify(elk.coupleBarMeta.map((m) => [m.elkId, m.p1Key, m.p2Key]))) + return h.digest('hex').slice(0, 12) +} +const timed = (f) => { const t = performance.now(); const v = f(); return [v, Math.round(performance.now() - t)] } + +const names = process.argv.slice(2) +for (const name of names.length ? names : [...archetypeNames, 'desc-3', 'asc-23']) { + const tree = loadTree(name) + const ref = tree.reference + const [tabs, msTabs] = timed(() => bl.familyTabsOf(tree.graph, ref)) + const [withTabs, msWithTabs] = timed(() => bl.computeBlockLayout(tree.graph, ref, { familyTabs: true })) + const fpTabs = fingerprint(withTabs) + const [whole, msWhole] = timed(() => bl.computeBlockLayout(tree.graph, ref, { familyTabs: false })) + const fpWhole = fingerprint(whole) + const h = createHash('sha1') + h.update(JSON.stringify(tabs.map((tb) => [tb.key, tb.label, tb.count, tb.referenceId, tb.parentKey, tb.hinges, tb.personIds ? [...tb.personIds].sort() : null]))) + let msEachTab = 0, msWorstTab = 0 + for (const tb of tabs) { + if (!tb.personIds) continue + const [r, ms] = timed(() => bl.computeBlockLayout(bl.subgraphOf(tb.personIds, tree.graph), tb.referenceId, { familyTabs: true })) + msEachTab += ms; msWorstTab = Math.max(msWorstTab, ms); h.update(fingerprint(r)) + } + console.log(JSON.stringify({ + name, persons: tree.graph.persons.length, tabs: tabs.length, + ms_familyTabsOf: msTabs, ms_layout_tabs: msWithTabs, ms_layout_whole: msWhole, ms_each_tab: msEachTab, ms_worst_tab: msWorstTab, + fingerprint: [fpTabs, fpWhole, h.digest('hex').slice(0, 12)].join(' '), + })) +} diff --git a/tools/layout-bench/load-probe.mjs b/tools/layout-bench/load-probe.mjs new file mode 100644 index 0000000..bdd20e7 --- /dev/null +++ b/tools/layout-bench/load-probe.mjs @@ -0,0 +1,91 @@ +// Ouverture d'un arbre dans un navigateur headless (CDP, sans dépendance) : temps jusqu'à l'arbre utilisable, +// jusqu'à la barre des onglets, et fonctions les plus coûteuses pendant le chargement. +// Usage : [TREE_PASSWORD='...'] node load-probe.mjs [nom] [répétitions] +// url : un arbre sur un front local, de préférence un build (`vite build --minify false` puis `vite preview` : +// le mode dev fausse les temps, la minification les noms du profil) +// CPU=4 ralentit le processeur comme un téléphone moyen ; W et H fixent la fenêtre (1600 × 900). +// L'arbre est utilisable quand deux images passent après son chargement : le placement bloque le fil principal. +// Avec ?fixture= (chargeur local, jamais commité), on attend window.__pzFixtureLoaded posé par ce chargeur. +import { spawn } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' + +const URL_ = process.argv[2] +const NAME = process.argv[3] || 'arbre' +const RUNS = Number(process.argv[4] || 1) +const PASSWORD = process.env.TREE_PASSWORD +const BRAVE = process.env.BRAVE_PATH || 'C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe' +const CPU = Number(process.env.CPU || 1) +const W = Number(process.env.W || 1600), H = Number(process.env.H || 900) +if (!URL_) { console.error('usage : node load-probe.mjs [nom] [répétitions]'); process.exit(1) } +const FIXTURE = new URL(URL_).searchParams.has('fixture') + +const sleep = (ms) => new Promise(r => setTimeout(r, ms)) +class CDP { + constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map() + ws.onmessage = (m) => { const msg = JSON.parse(m.data); if (msg.id) { const p = this.pending.get(msg.id); this.pending.delete(msg.id); msg.error ? p.reject(new Error(msg.error.message)) : p.resolve(msg.result) } } } + send(method, params = {}) { const id = ++this.id; this.ws.send(JSON.stringify({ id, method, params })); return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject })) } + async eval(expression, awaitPromise = true) { const r = await this.send('Runtime.evaluate', { expression, awaitPromise, returnByValue: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'eval'); return r.result.value } +} + +function topFunctions(profile, n) { + const byNode = new Map(profile.nodes.map(x => [x.id, x])); const self = new Map() + profile.samples.forEach((id, i) => { const cf = byNode.get(id).callFrame + const key = `${cf.functionName || '(anonyme)'} ${cf.url.split('/').slice(-1)[0].split('?')[0]}:${cf.lineNumber + 1}` + self.set(key, (self.get(key) || 0) + (profile.timeDeltas[i] || 0)) }) + return [...self.entries()].sort((a, b) => b[1] - a[1]).slice(0, n).map(([fn, us]) => ({ fn, ms: Math.round(us / 1000) })) +} + +async function once() { + const port = 9333 + Math.floor(Math.random() * 400) + const profileDir = mkdtempSync(path.join(tmpdir(), 'parenthese-load-')) + const browser = spawn(BRAVE, ['--headless=new', `--remote-debugging-port=${port}`, `--user-data-dir=${profileDir}`, `--window-size=${W},${H}`, + '--no-first-run', '--disable-extensions', '--disable-features=Translate,BraveRewards,BraveAds', 'about:blank'], { stdio: 'ignore' }) + try { + let version + for (let i = 0; i < 100 && !version; i++) { try { const r = await fetch(`http://127.0.0.1:${port}/json/version`); if (r.ok) version = await r.json() } catch {} if (!version) await sleep(200) } + const bws = new WebSocket(version.webSocketDebuggerUrl); await new Promise(r => bws.onopen = r) + const { targetId } = await new CDP(bws).send('Target.createTarget', { url: 'about:blank' }) + const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json() + const pws = new WebSocket(list.find(t => t.id === targetId).webSocketDebuggerUrl); await new Promise(r => pws.onopen = r) + const p = new CDP(pws) + await p.send('Page.enable'); await p.send('Runtime.enable') + await p.send('Emulation.setDeviceMetricsOverride', { width: W, height: H, deviceScaleFactor: 1, mobile: false }) + if (CPU > 1) await p.send('Emulation.setCPUThrottlingRate', { rate: CPU }) + await p.send('Profiler.enable'); await p.send('Profiler.setSamplingInterval', { interval: 250 }) + await p.send('Profiler.start') + const t0 = Date.now() + await p.send('Page.navigate', { url: URL_ }) + if (PASSWORD) { + for (let i = 0; i < 100; i++) { if (await p.eval(`!!document.querySelector('input[type=password]')`, false).catch(() => false)) break; await sleep(100) } + await p.eval(`(() => { + const input = document.querySelector('input[type=password]') + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, ${JSON.stringify(PASSWORD)}) + input.dispatchEvent(new Event('input', { bubbles: true })) + ;[...document.querySelectorAll('button')].find(b => /ouvrir/i.test(b.textContent))?.click() + })()`, false) + } + const loaded = FIXTURE ? `window.__pzFixtureLoaded === true` : `!!document.querySelector('canvas.galaxy-canvas')` + for (let i = 0; i < 600; i++) { if (await p.eval(loaded, false).catch(() => false)) break; await sleep(50) } + await p.eval(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))`) + const tree = Date.now() - t0 + let tabs = null + for (let i = 0; i < 600; i++) { if (await p.eval(`document.querySelectorAll('.family-tab').length > 1`, false).catch(() => false)) { tabs = Date.now() - t0; break } await sleep(50) } + const { profile } = await p.send('Profiler.stop') + return { tree, tabs, top: topFunctions(profile, 10) } + } finally { + try { browser.kill() } catch {} + try { rmSync(profileDir, { recursive: true, force: true }) } catch {} + } +} + +const runs = [] +for (let i = 0; i < RUNS; i++) runs.push(await once()) +const median = (xs) => { const s = xs.filter(x => x != null).sort((a, b) => a - b); return s.length ? s[Math.floor(s.length / 2)] : null } +console.log(JSON.stringify({ + name: NAME, url: URL_, cpu: CPU, runs: RUNS, + tree_ms: median(runs.map(r => r.tree)), tabs_ms: median(runs.map(r => r.tabs)), + each: runs.map(r => [r.tree, r.tabs]), cpu_top_first_run: runs[0].top, +}, null, 1)) +process.exit(0)