382 lines
11 KiB
JavaScript
382 lines
11 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 || "",
|
|
};
|
|
|
|
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 === "--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 });
|
|
|
|
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 reportPath = path.join(options.outputDir, "terminal-bench.json");
|
|
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
console.log(JSON.stringify(report, null, 2));
|
|
if (!report.passed) {
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
async function runScenario(browser, options, viewport) {
|
|
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(options.url, { waitUntil: "networkidle", timeout: options.timeoutMs });
|
|
await page.waitForSelector(".terminal-screen", { timeout: options.timeoutMs });
|
|
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.type(command);
|
|
await page.keyboard.press("Enter");
|
|
await waitForBenchLine(page, options.lines, options.timeoutMs);
|
|
const elapsedMs = round(performance.now() - startedAt);
|
|
|
|
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-${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,
|
|
lines: options.lines,
|
|
elapsedMs,
|
|
rowsPerSecond: round(options.lines / (elapsedMs / 1000)),
|
|
frameMs: summarizeSamples(frameSamples),
|
|
probe,
|
|
pixelSmoke: pixelResult,
|
|
screenshotPath,
|
|
};
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
}
|
|
|
|
function browserLaunchOptions(options) {
|
|
if (options.browserChannel) {
|
|
return { channel: options.browserChannel };
|
|
}
|
|
if (options.browserExecutable) {
|
|
return { executablePath: 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) {
|
|
const marker = `bench-${String(lines).padStart(5, "0")}`;
|
|
const minHistoryRows = Math.max(1, lines - 200);
|
|
await page.waitForFunction(
|
|
({ text, minHistoryRows }) => {
|
|
const probe = window.__terminalBench || {};
|
|
return (
|
|
document.body.innerText.includes(text) ||
|
|
(probe.historyRows || 0) >= minHistoryRows
|
|
);
|
|
},
|
|
{ text: marker, minHistoryRows },
|
|
{ timeout: timeoutMs },
|
|
);
|
|
}
|
|
|
|
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 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
|
|
--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);
|
|
});
|
|
}
|