#!/usr/bin/env node import fs from "node:fs"; import http from "node:http"; import path from "node:path"; import { performance } from "node:perf_hooks"; import { fileURLToPath } from "node:url"; const DEFAULT_URL = "http://127.0.0.1:3100/rustui/terminal"; const DEFAULT_OUTPUT = "target/terminal-bench"; const DEFAULT_LINES = 10000; const DEFAULT_VIEWPORTS = [ { name: "200x80", width: 1760, height: 1600 }, { name: "240x100", width: 2080, height: 1960 }, ]; export function parseArgs(argv) { const options = { url: DEFAULT_URL, outputDir: DEFAULT_OUTPUT, lines: DEFAULT_LINES, viewports: [...DEFAULT_VIEWPORTS], timeoutMs: 120000, screenshot: true, browserChannel: process.env.PLAYWRIGHT_BROWSER_CHANNEL || "", browserExecutable: process.env.PLAYWRIGHT_BROWSER_EXECUTABLE || "", renderer: "webgl", }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; const next = () => { index += 1; if (index >= argv.length) { throw new Error(`Missing value for ${arg}`); } return argv[index]; }; if (arg === "--url") { options.url = next(); } else if (arg === "--output") { options.outputDir = next(); } else if (arg === "--lines") { options.lines = positiveInt(next(), "--lines"); } else if (arg === "--timeout-ms") { options.timeoutMs = positiveInt(next(), "--timeout-ms"); } else if (arg === "--viewport") { options.viewports = [parseViewport(next())]; } else if (arg === "--browser-channel") { options.browserChannel = next(); } else if (arg === "--browser-executable") { options.browserExecutable = next(); } else if (arg === "--renderer") { options.renderer = parseRenderer(next()); } else if (arg === "--no-screenshot") { options.screenshot = false; } else if (arg === "--help" || arg === "-h") { options.help = true; } else { throw new Error(`Unknown argument: ${arg}`); } } if (options.browserChannel && options.browserExecutable) { throw new Error("Use only one of --browser-channel or --browser-executable"); } return options; } export function parseViewport(value) { const match = /^(\d+)x(\d+)(?::(\d+)x(\d+))?$/.exec(value); if (!match) { throw new Error( `Invalid viewport "${value}". Use colsxrows or colsxrows:widthxheight.`, ); } const cols = Number(match[1]); const rows = Number(match[2]); return { name: `${cols}x${rows}`, width: match[3] ? Number(match[3]) : Math.max(960, cols * 9 + 64), height: match[4] ? Number(match[4]) : Math.max(720, rows * 19 + 160), }; } export function summarizeSamples(samples) { if (samples.length === 0) { return { count: 0, avg: 0, p95: 0, max: 0 }; } const sorted = [...samples].sort((a, b) => a - b); const sum = sorted.reduce((acc, value) => acc + value, 0); const p95Index = Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1); return { count: sorted.length, avg: round(sum / sorted.length), p95: round(sorted[p95Index]), max: round(sorted[sorted.length - 1]), }; } export function pixelSmokeFromSamples(samples, width, height) { const total = Math.max(1, width * height); let nonTransparent = 0; let nonBlack = 0; for (let index = 0; index < samples.length; index += 4) { const r = samples[index]; const g = samples[index + 1]; const b = samples[index + 2]; const a = samples[index + 3]; if (a > 0) { nonTransparent += 1; } if (a > 0 && (r > 8 || g > 8 || b > 8)) { nonBlack += 1; } } return { total, nonTransparent, nonBlack, nonTransparentRatio: round(nonTransparent / total), nonBlackRatio: round(nonBlack / total), passed: nonTransparent > 0 && nonBlack > 0, }; } export function buildReport(results) { const generatedAt = new Date().toISOString(); return { generatedAt, scenarios: results, passed: results.every((result) => result.pixelSmoke.passed), }; } async function main() { const options = parseArgs(process.argv.slice(2)); if (options.help) { printHelp(); return; } await assertServerReachable(options.url, options.timeoutMs); const { chromium } = await importPlaywright(); fs.mkdirSync(options.outputDir, { recursive: true }); console.error(`[terminal-bench] launching browser${browserLaunchLabel(options)}`); const browser = await chromium.launch(browserLaunchOptions(options)); const results = []; try { for (const viewport of options.viewports) { results.push(await runScenario(browser, options, viewport)); } } finally { await browser.close(); } const report = buildReport(results); const reportJson = `${JSON.stringify(report, null, 2)}\n`; const reportPath = terminalBenchReportPath(options); const latestReportPath = path.join(options.outputDir, "terminal-bench-latest.json"); fs.writeFileSync(reportPath, reportJson); fs.writeFileSync(latestReportPath, reportJson); console.error(`[terminal-bench] wrote ${reportPath}`); console.error(`[terminal-bench] wrote ${latestReportPath}`); console.log(JSON.stringify(report, null, 2)); if (!report.passed) { process.exitCode = 1; } } async function runScenario(browser, options, viewport) { console.error( `[terminal-bench] ${viewport.name}: starting ${options.lines} line output`, ); const context = await browser.newContext({ viewport: { width: viewport.width, height: viewport.height }, deviceScaleFactor: 1, }); const page = await context.newPage(); const frameSamples = []; try { await page.goto(benchUrl(options), { waitUntil: "networkidle", timeout: options.timeoutMs, }); await page.waitForSelector(".terminal-screen", { timeout: options.timeoutMs }); if (options.renderer === "dom") { await page.waitForSelector(".terminal-row", { timeout: options.timeoutMs }); } else { await page.waitForSelector("canvas.terminal-canvas:not([hidden])", { timeout: options.timeoutMs, }); } await page.evaluate(() => { window.__terminalBenchFrames = []; let previous = performance.now(); function sample(now) { window.__terminalBenchFrames.push(now - previous); previous = now; if (window.__terminalBenchFrames.length < 360) { requestAnimationFrame(sample); } } requestAnimationFrame(sample); }); const command = `for i in $(seq 1 ${options.lines}); do printf 'bench-%05d abcdefghijklmnopqrstuvwxyz\\n' "$i"; done`; const startedAt = performance.now(); await page.locator(".terminal-screen").click(); await page.keyboard.insertText(command); await page.keyboard.press("Enter"); await waitForBenchLine(page, options.lines, options.timeoutMs, viewport.name); const elapsedMs = round(performance.now() - startedAt); console.error( `[terminal-bench] ${viewport.name}: output observed in ${elapsedMs}ms`, ); const probe = await page.evaluate(() => window.__terminalBench || {}); frameSamples.push( ...(await page.evaluate(() => window.__terminalBenchFrames || [])), ); const canvasPixelSmoke = await page.evaluate(() => { const canvas = document.querySelector("canvas.terminal-canvas:not([hidden])"); if (!canvas) { return null; } const context = canvas.getContext("2d", { willReadFrequently: true }); if (!context) { return { width: canvas.width, height: canvas.height, samples: [], readable: false, }; } const width = Math.min(canvas.width, 320); const height = Math.min(canvas.height, 180); const data = context.getImageData(0, 0, width, height).data; return { width, height, samples: Array.from(data), readable: true, }; }); let screenshotPath = null; const screenshot = await page.screenshot({ fullPage: false }); const screenshotSmoke = pngSmokeFromBuffer(screenshot); if (options.screenshot) { screenshotPath = path.join( options.outputDir, `terminal-${options.renderer}-${viewport.name}.png`, ); fs.writeFileSync(screenshotPath, screenshot); } const pixelResult = canvasPixelSmoke?.readable ? { source: "canvas", ...pixelSmokeFromSamples( canvasPixelSmoke.samples, canvasPixelSmoke.width, canvasPixelSmoke.height, ), } : { source: "screenshot", ...screenshotSmoke, }; return { viewport, requestedRenderer: options.renderer, lines: options.lines, elapsedMs, rowsPerSecond: round(options.lines / (elapsedMs / 1000)), frameMs: summarizeSamples(frameSamples), probe, pixelSmoke: pixelResult, screenshotPath, }; } finally { await context.close(); } } function benchUrl(options) { const url = new URL(options.url); url.searchParams.set("renderer", options.renderer); return url.toString(); } function terminalBenchReportPath(options) { return path.join(options.outputDir, `terminal-bench-${options.renderer}.json`); } function browserLaunchOptions(options) { if (options.browserChannel) { return { channel: options.browserChannel }; } if (options.browserExecutable) { return { executablePath: options.browserExecutable }; } return {}; } function browserLaunchLabel(options) { if (options.browserChannel) { return ` channel=${options.browserChannel}`; } if (options.browserExecutable) { return ` executable=${options.browserExecutable}`; } return ""; } export function pngSmokeFromBuffer(buffer) { const signature = buffer.subarray(0, 8).toString("hex"); const isPng = signature === "89504e470d0a1a0a"; const distinctBytes = new Set(buffer).size; return { total: buffer.length, nonTransparent: 0, nonBlack: 0, nonTransparentRatio: 0, nonBlackRatio: 0, distinctBytes, passed: isPng && buffer.length > 1024 && distinctBytes > 16, }; } async function waitForBenchLine(page, lines, timeoutMs, viewportName) { const marker = `bench-${String(lines).padStart(5, "0")}`; const minHistoryRows = Math.max(1, lines - 200); const deadline = performance.now() + timeoutMs; let lastLogAt = 0; let lastStatus = null; while (performance.now() < deadline) { const status = await page.evaluate( ({ text, minHistoryRows }) => { const probe = window.__terminalBench || {}; const bodyText = document.body?.innerText || ""; const historyRows = Number(probe.historyRows || 0); return { matched: bodyText.includes(text) || historyRows >= minHistoryRows, renderCount: Number(probe.renderCount || 0), historyRows, screenRows: Number(probe.screenRows || 0), renderer: String(probe.renderer || ""), mode: String(probe.mode || ""), }; }, { text: marker, minHistoryRows }, ); lastStatus = status; if (status.matched) { return status; } if (performance.now() - lastLogAt >= 5000) { console.error( `[terminal-bench] ${viewportName}: waiting for ${marker}; ` + `historyRows=${status.historyRows}/${minHistoryRows}, ` + `screenRows=${status.screenRows}, renderCount=${status.renderCount}`, ); lastLogAt = performance.now(); } await page.waitForTimeout(1000); } throw new Error( `Timed out waiting for terminal output ${marker}. Last status: ${JSON.stringify( lastStatus, )}`, ); } async function importPlaywright() { try { return await import("playwright"); } catch (error) { throw new Error( "Playwright is required for terminal bench. Install it in this workspace, then rerun the script.", { cause: error }, ); } } async function assertServerReachable(url, timeoutMs) { const parsed = new URL(url); await new Promise((resolve, reject) => { const request = http.get(parsed, (response) => { response.resume(); if (response.statusCode && response.statusCode < 500) { resolve(); } else { reject(new Error(`Server returned HTTP ${response.statusCode}`)); } }); request.setTimeout(Math.min(timeoutMs, 5000), () => { request.destroy(new Error(`Timed out connecting to ${url}`)); }); request.on("error", reject); }).catch((error) => { throw new Error( `Terminal bench expects an already-running dev server at ${url}. Start it yourself, then rerun.`, { cause: error }, ); }); } function positiveInt(value, label) { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed <= 0) { throw new Error(`${label} must be a positive integer`); } return parsed; } function parseRenderer(value) { const normalized = value.toLowerCase(); if (["webgl", "canvas", "dom"].includes(normalized)) { return normalized; } throw new Error(`--renderer must be one of: webgl, canvas, dom`); } function round(value) { return Math.round(value * 100) / 100; } function printHelp() { console.log(`Usage: node scripts/terminal-bench.mjs [options] Connects to an already-running BASE_PATH demo server and runs terminal renderer performance + pixel smoke checks. The script does not start or stop services. Options: --url Default: ${DEFAULT_URL} --output Default: ${DEFAULT_OUTPUT} --lines Default: ${DEFAULT_LINES} --viewport x Run one scenario with inferred browser size --viewport x:x Run one scenario with explicit browser size --renderer webgl, canvas, or dom. Default: webgl --browser-channel Use an installed browser channel, e.g. chrome --browser-executable Use a local Chromium/Chrome executable path --timeout-ms Default: 120000 --no-screenshot Skip screenshot capture --help Show this help `); } const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); if (isCli) { main().catch((error) => { console.error(error.message); if (error.cause) { console.error(`Cause: ${error.cause.message}`); } process.exit(1); }); }