Files
rustui-playground/scripts/terminal-bench.mjs

468 lines
14 KiB
JavaScript

#!/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 <url> Default: ${DEFAULT_URL}
--output <dir> Default: ${DEFAULT_OUTPUT}
--lines <n> Default: ${DEFAULT_LINES}
--viewport <cols>x<rows> Run one scenario with inferred browser size
--viewport <c>x<r>:<w>x<h> Run one scenario with explicit browser size
--renderer <name> webgl, canvas, or dom. Default: webgl
--browser-channel <name> Use an installed browser channel, e.g. chrome
--browser-executable <path> Use a local Chromium/Chrome executable path
--timeout-ms <n> 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);
});
}