v0.5.0 c37833b

web-scroll-bench.cjs RawHistoryFolder

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});

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close