// How a pull's files page does in a browser: its first load, then a // reader going through it to the end, a screen every 150 ms, as each // file's rows are fetched and drawn. Where the time goes, from Chromium's // own counts: script, layout and style, the long tasks that froze the // page and the longest, the getPullDiff calls made, and how large the // page grew. // // node harness/files-bench.mjs [--runs ] [--throttle ] [--nohl] [--profile] // // Any page will do, a local one or knotless.dev's. `--runs` takes the // median of several (3); `--throttle` slows the CPU that many times, as // a slower laptop or a phone; `--nohl` turns the highlighting off, for // the floor the rest is measured against; `--profile` instead profiles // one pass and prints the functions that took longest, by their own // time and with what they called, keeping the profile in // target/files-bench.cpuprofile to open in Chrome's devtools. import { chromium } from '@playwright/test' import { mkdirSync, writeFileSync } from 'node:fs' const args = process.argv.slice(2) const url = args.find((a) => !a.startsWith('--') && /^https?:/.test(a)) if (!url) { console.error( 'usage: node harness/files-bench.mjs [--runs ] [--throttle ] [--nohl] [--profile]', ) process.exit(2) } const flag = (name) => args.includes(name) const option = (name, value) => (flag(name) ? Number(args[args.indexOf(name) + 1]) : value) const runs = option('--runs', 3) const throttle = option('--throttle', 1) /** Chromium's counts, by name. */ async function counts(cdp) { const { metrics } = await cdp.send('Performance.getMetrics') return Object.fromEntries(metrics.map((m) => [m.name, m.value])) } /** The milliseconds counted as `name` between `a` and `b`. */ const spent = (a, b, name) => Math.round(((b[name] ?? 0) - (a[name] ?? 0)) * 1000) /** A page open at `url`, its long tasks kept, and a wait for the getPullDiff calls under way. */ async function opened(browser) { const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }) const cdp = await page.context().newCDPSession(page) await cdp.send('Performance.enable') if (throttle > 1) await cdp.send('Emulation.setCPUThrottlingRate', { rate: throttle }) await page.addInitScript(() => { window.__long = [] const seen = (list) => list.getEntries().forEach((e) => window.__long.push(e.duration)) new PerformanceObserver(seen).observe({ type: 'longtask', buffered: true }) }) if (flag('--nohl')) await page.addInitScript(() => Object.defineProperty(CSS, 'highlights', { value: undefined })) const diffs = [] let going = 0 const isDiff = (r) => r.url().includes('dev.knotless.getPullDiff') page.on('request', (r) => isDiff(r) && going++) page.on('requestfailed', (r) => isDiff(r) && going--) page.on('requestfinished', async (r) => { if (!isDiff(r)) return going-- const sizes = await r.sizes().catch(() => ({})) diffs.push({ ms: Math.round(r.timing().responseEnd), bytes: sizes.responseBodySize ?? 0 }) }) // The page keeps a connection open for what changes, so it is never // idle: what it waits on is its diffs. const settled = async () => { await page.waitForTimeout(300) while (going > 0) await page.waitForTimeout(50) await page.waitForTimeout(100) } return { page, cdp, diffs, settled } } /** Scrolls `page` a screen at a time to its end, waiting for what that fetches; the longest a step lagged. */ async function read(page, settled) { const started = Date.now() let lag = 0 for (;;) { const step = Date.now() const end = await page.evaluate(() => { window.scrollBy(0, innerHeight) return innerHeight + scrollY >= document.body.scrollHeight - 2 }) await page.waitForTimeout(150) lag = Math.max(lag, Date.now() - step - 150) if (end) { await settled() const still = await page.evaluate( () => innerHeight + scrollY >= document.body.scrollHeight - 2, ) if (still) break } if (Date.now() - started > 180_000) break } return lag } const longTasks = (page) => page.evaluate(() => ({ total: Math.round(window.__long.reduce((s, d) => s + d, 0)), most: Math.round(Math.max(0, ...window.__long)), })) const nodes = (page) => page.evaluate(() => document.querySelectorAll('*').length) async function once(browser) { const { page, cdp, diffs, settled } = await opened(browser) const before = await counts(cdp) const response = await page.goto(url, { waitUntil: 'load' }) const html = (await response.body()).length await settled() const nav = await page.evaluate(() => { const n = performance.getEntriesByType('navigation')[0] const fcp = performance.getEntriesByName('first-contentful-paint')[0] return { ttfb: Math.round(n.responseStart), fcp: Math.round(fcp?.startTime ?? 0), load: Math.round(n.loadEventEnd), } }) const loaded = await counts(cdp) const atLoad = await longTasks(page) const nodesAtLoad = await nodes(page) const files = await page.evaluate( () => document.querySelectorAll('.pull-files-diffs > .anchor').length, ) const started = Date.now() const lag = await read(page, settled) const scrolled = Date.now() - started const done = await counts(cdp) const atEnd = await longTasks(page) const result = { files, html_kb: Math.round(html / 1024), ...nav, load_script_ms: spent(before, loaded, 'ScriptDuration'), load_layout_ms: spent(before, loaded, 'LayoutDuration'), load_style_ms: spent(before, loaded, 'RecalcStyleDuration'), load_long_ms: atLoad.total, nodes_at_load: nodesAtLoad, read_ms: scrolled, read_script_ms: spent(loaded, done, 'ScriptDuration'), read_layout_ms: spent(loaded, done, 'LayoutDuration'), read_style_ms: spent(loaded, done, 'RecalcStyleDuration'), read_long_ms: atEnd.total - atLoad.total, longest_task_ms: atEnd.most, worst_step_lag_ms: lag, diffs: diffs.length, diff_median_ms: diffs.map((d) => d.ms).sort((a, b) => a - b)[diffs.length >> 1] ?? 0, diff_kb: Math.round(diffs.reduce((s, d) => s + d.bytes, 0) / 1024), nodes_at_end: await nodes(page), heap_mb: Math.round((done.JSHeapUsedSize ?? 0) / 1e6), } await page.close() return result } /** One pass read under the profiler, and its slowest functions printed. */ async function profiled(browser) { const { page, cdp, settled } = await opened(browser) await page.goto(url, { waitUntil: 'load' }) await settled() await cdp.send('Profiler.enable') await cdp.send('Profiler.setSamplingInterval', { interval: 500 }) await cdp.send('Profiler.start') await read(page, settled) const { profile } = await cdp.send('Profiler.stop') await page.close() mkdirSync('target', { recursive: true }) writeFileSync('target/files-bench.cpuprofile', JSON.stringify(profile)) const byId = new Map(profile.nodes.map((n) => [n.id, n])) const parent = new Map() for (const n of profile.nodes) for (const c of n.children ?? []) parent.set(c, n.id) const name = (id) => { const f = byId.get(id).callFrame return `${f.functionName || '(anonymous)'} ${f.url.split('/').pop()}:${f.lineNumber + 1}` } const own = new Map() const all = new Map() profile.samples.forEach((id, i) => { const dt = profile.timeDeltas[i] ?? 0 own.set(name(id), (own.get(name(id)) ?? 0) + dt) const seen = new Set() for (let at = id; at; at = parent.get(at)) seen.add(name(at)) for (const n of seen) all.set(n, (all.get(n) ?? 0) + dt) }) const top = (map, n) => [...map] .sort((a, b) => b[1] - a[1]) .slice(0, n) .map(([k, v]) => `${String(Math.round(v / 1000)).padStart(8)} ms ${k}`) console.log( `by own time:\n${top(own, 20).join('\n')}\n\nwith what each called:\n${top(all, 30).join('\n')}`, ) console.log('\nkept in target/files-bench.cpuprofile') } const browser = await chromium.launch() if (flag('--profile')) await profiled(browser) else { const results = [] for (let i = 0; i < runs; i++) results.push(await once(browser)) const median = (k) => results.map((r) => r[k]).sort((a, b) => a - b)[results.length >> 1] const out = Object.fromEntries(Object.keys(results[0]).map((k) => [k, median(k)])) console.log(JSON.stringify({ url, runs, throttle, nohl: flag('--nohl'), ...out }, null, 2)) } await browser.close()