outils(layout-bench) : temps et empreinte du placement, temps d'ouverture d'un arbre

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-27 16:10:26 +01:00
1 parent d7eac40cff
commit ec4b0a1428
3 files changed
+139

No files matched your search

+2
View File
@@ -33,5 +33,7 @@ La largeur totale ne départage qu'une égalité parfaite. Pas de score composit
## Performance, parcours compte, landing ## Performance, parcours compte, landing
- `TREE_PASSWORD='...' node perf-probe.mjs <url d'un arbre>` : 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. - `TREE_PASSWORD='...' node perf-probe.mjs <url d'un arbre>` : 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 <url> [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 <front> <api>` : scénario complet sur un backend local (lien partagé, compte créé, rattachement, contributions). `CLEANUP=1` supprime l'arbre de test. - `node e2e-account.mjs <front> <api>` : scénario complet sur un backend local (lien partagé, compte créé, rattachement, contributions). `CLEANUP=1` supprime l'arbre de test.
- `node landing-check.mjs <url>` : titres, liens, formulaires et erreurs JS d'une page, avec capture. - `node landing-check.mjs <url>` : titres, liens, formulaires et erreurs JS d'une page, avec capture.
+46
View File
@@ -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=<dossier frontend/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(' '),
}))
}
+91
View File
@@ -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 <url> [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 <url> [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)