1// Browser scroll benchmark for the web terminal. Starts an isolated gateway,
2// fills history, scrolls it fast with the wheel and reports where the
3// browser's main thread spent its time. Linux only. See docs/web-terminal.md.
4const { spawnSync } = require('node:child_process');
5const fs = require('node:fs');
6const os = require('node:os');
7const path = require('node:path');
8
9const options = { lines: 10000, notches: 120, port: 8799, viewport: '1600x1000', scale: 1, cdp: '' };
10for (let i = 2; i < process.argv.length; i += 2) {
11 const key = process.argv[i].replace(/^--/, '');
12 if (!(key in options) || process.argv[i + 1] === undefined) {
13 console.error('usage: web-scroll-bench.cjs [--lines N] [--notches N] [--port N] [--viewport WxH] [--scale F] [--cdp URL]');
14 process.exit(2);
15 }
16 options[key] = typeof options[key] === 'number' ? Number(process.argv[i + 1]) : process.argv[i + 1];
17}
18if (!process.env.VEV_BINARY) {
19 console.error('VEV_BINARY is required');
20 process.exit(2);
21}
22const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
23
24// Main-thread trace events, summed per phase.
25const PHASES = {
26 total: ['RunTask'],
27 script: ['FunctionCall', 'EvaluateScript', 'v8.callFunction'],
28 style: ['UpdateLayoutTree'],
29 layout: ['Layout'],
30 paint: ['Paint', 'PaintImage', 'RasterTask'],
31};
32
33// Kill every process started under root (session daemon and gateway).
34function stopProcesses(root) {
35 for (const pid of fs.readdirSync('/proc').filter(name => /^\d+$/.test(name))) {
36 try {
37 const environ = fs.readFileSync(`/proc/${pid}/environ`, 'utf8').split('\0');
38 if (environ.includes(`VEV_ENV_ROOT=${root}`)) process.kill(Number(pid), 'SIGTERM');
39 } catch {}
40 }
41}
42
43(async () => {
44 const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vev-scroll-bench-'));
45 const env = { ...process.env, VEV_ENV: 'scroll-bench', VEV_ENV_ROOT: root, SHELL: '/bin/bash' };
46 const origin = `http://127.0.0.1:${options.port}`;
47 let browser;
48 try {
49 const started = spawnSync(process.env.VEV_BINARY, ['--web-daemon', '--web-listen', `127.0.0.1:${options.port}`, '--web-origin', origin], { cwd: root, env, encoding: 'utf8', timeout: 20000 });
50 const token = started.stdout?.match(/#token=([A-Za-z0-9_-]+)/)?.[1];
51 if (!token) throw new Error(`gateway did not start: ${started.stderr || started.error}`);
52
53 const [width, height] = options.viewport.split('x').map(Number);
54 let page;
55 if (options.cdp) {
56 browser = await chromium.connectOverCDP(options.cdp);
57 page = await browser.contexts()[0].newPage();
58 await page.bringToFront();
59 } else {
60 browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH });
61 page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: options.scale });
62 }
63 if (process.env.WEB_APP_JS) await page.route('**/app.js', route => route.fulfill({ path: process.env.WEB_APP_JS, contentType: 'text/javascript' }));
64 if (process.env.WEB_RUNTIME_JS) await page.route('**/terminal.js', route => route.fulfill({ path: process.env.WEB_RUNTIME_JS, contentType: 'text/javascript' }));
65
66 // Count WebSocket frames and bytes received by the page.
67 await page.addInitScript(() => {
68 const NativeWebSocket = window.WebSocket;
69 window.wire = { frames: 0, bytes: 0 };
70 window.WebSocket = class extends NativeWebSocket {
71 constructor(...args) {
72 super(...args);
73 this.addEventListener('message', event => {
74 window.wire.frames += 1;
75 window.wire.bytes += typeof event.data === 'string' ? event.data.length : event.data.byteLength ?? event.data.size;
76 });
77 }
78 };
79 });
80 await page.goto(`${origin}/#token=${token}`);
81 await page.waitForFunction(() => document.querySelector('#status').textContent === 'Connected');
82 await page.waitForTimeout(1000);
83 await page.locator('#terminal').click();
84 // Colored, styled and wide content, close to a real TUI.
85 await page.keyboard.type(`for i in $(seq 1 ${options.lines}); do printf '\\e[3%dm%05d\\e[0m │ \\e[1mlorem\\e[0m ipsum \\e[48;5;236mdolor sit\\e[0m amet ✓ consectetur … │ %s\\n' $((i%7+1)) $i "$(printf '%*s' $((i%40)) x)"; done; echo bench-$((6*7))-ready\n`, { delay: 2 });
86 await page.waitForFunction(() => document.querySelector('.vev-terminal__accessible-output').textContent.includes('bench-42-ready'), null, { timeout: 120000 });
87 const geometry = await page.evaluate(() => {
88 const style = getComputedStyle(document.querySelector('.vev-terminal'));
89 return { columns: Number(style.getPropertyValue('--vev-cols')), rows: Number(style.getPropertyValue('--vev-rows')), viewport: [innerWidth, innerHeight], scale: devicePixelRatio };
90 });
91
92 const box = await page.locator('#terminal').boundingBox();
93 await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
94 const cdp = await page.context().newCDPSession(page);
95 const events = [];
96 cdp.on('Tracing.dataCollected', event => events.push(...event.value));
97 const done = new Promise(resolve => cdp.once('Tracing.tracingComplete', resolve));
98 await cdp.send('Tracing.start', { transferMode: 'ReportEvents', traceConfig: { includedCategories: ['devtools.timeline', 'disabled-by-default-devtools.timeline', 'v8'] } });
99 const wireBefore = await page.evaluate(() => ({ ...window.wire }));
100 const start = Date.now();
101 // Half the notches scroll up into history, half come back down.
102 for (let i = 0; i < options.notches; i++) {
103 await page.mouse.wheel(0, i < options.notches / 2 ? -100 : 100);
104 await page.waitForTimeout(16);
105 }
106 await page.waitForTimeout(800);
107 const wall = (Date.now() - start) / 1000;
108 const wireAfter = await page.evaluate(() => ({ ...window.wire }));
109 await cdp.send('Tracing.end');
110 await done;
111
112 // The page's main thread is the one that ran Layout.
113 const layout = events.find(event => event.name === 'Layout' && event.ph === 'X');
114 const frames = wireAfter.frames - wireBefore.frames;
115 const bytes = wireAfter.bytes - wireBefore.bytes;
116 const result = {
117 ...geometry, notches: options.notches, wallSeconds: wall,
118 wireFrames: frames, wireKB: Math.round(bytes / 1024), avgFrameKB: frames ? Number((bytes / frames / 1024).toFixed(1)) : 0
119 };
120 for (const [phase, names] of Object.entries(PHASES)) {
121 let ms = 0;
122 for (const event of events) {
123 if (event.ph === 'X' && event.dur && event.pid === layout?.pid && event.tid === layout?.tid && names.includes(event.name)) ms += event.dur / 1000;
124 }
125 result[`${phase}Ms`] = Math.round(ms);
126 }
127 result.totalMsPerNotch = Number((result.totalMs / options.notches).toFixed(1));
128 console.log(JSON.stringify(result, null, 2));
129 await page.close();
130 } finally {
131 if (browser) await browser.close().catch(() => {});
132 stopProcesses(root);
133 fs.rmSync(root, { recursive: true, force: true });
134 }
135})().catch(error => {
136 console.error(error);
137 process.exit(1);
138});