513 lines
15 KiB
JavaScript
513 lines
15 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 rendererStats = await page.evaluate(() => window.__terminalBench?.webglFrames || []);
|
|
const cleanProbe = { ...probe };
|
|
delete cleanProbe.webglFrames;
|
|
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),
|
|
rendererStats: summarizeRendererStats(rendererStats),
|
|
probe: cleanProbe,
|
|
pixelSmoke: pixelResult,
|
|
screenshotPath,
|
|
};
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
}
|
|
|
|
export function summarizeRendererStats(frames) {
|
|
if (!Array.isArray(frames) || frames.length === 0) {
|
|
return { count: 0 };
|
|
}
|
|
const numericFields = [
|
|
"totalMs",
|
|
"syncDomMs",
|
|
"backdropMs",
|
|
"collectMs",
|
|
"backdropTextureMs",
|
|
"fallbackCanvasMs",
|
|
"atlasMs",
|
|
"atlasUploadMs",
|
|
"vertexBuildMs",
|
|
"vertexUploadMs",
|
|
"drawMs",
|
|
"cells",
|
|
"rowCacheHits",
|
|
"rowCacheMisses",
|
|
"vertexCacheHits",
|
|
"vertexCacheMisses",
|
|
"atlasEntries",
|
|
"atlasInsertions",
|
|
"atlasResets",
|
|
];
|
|
const summary = { count: frames.length };
|
|
for (const field of numericFields) {
|
|
const samples = frames
|
|
.map((frame) => Number(frame[field] || 0))
|
|
.filter((value) => Number.isFinite(value));
|
|
summary[field] = summarizeSamples(samples);
|
|
}
|
|
summary.fallbackFullCanvasCount = frames.filter(
|
|
(frame) => Boolean(frame.fallbackFullCanvas),
|
|
).length;
|
|
summary.glyphDrawFailedCount = frames.filter(
|
|
(frame) => Boolean(frame.glyphDrawFailed),
|
|
).length;
|
|
return summary;
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|