diff --git a/Cargo.lock b/Cargo.lock
index e2b9369..b8eeec6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -39,6 +39,7 @@ dependencies = [
"console_log",
"dotenvy",
"icons",
+ "js-sys",
"leptos",
"leptos_axum",
"leptos_meta",
diff --git a/README.md b/README.md
index b0fd88a..a03b6f5 100644
--- a/README.md
+++ b/README.md
@@ -15,3 +15,22 @@ Then open:
- `http://127.0.0.1:3100/rustui/deep/nested`
Change `app_config/src/lib.rs` to test another base path.
+
+## Terminal Renderer Bench
+
+The terminal benchmark script connects to an already-running dev server; it does
+not start or stop services.
+
+Install the Node dependency and Chromium browser once before running it:
+
+```bash
+npm install
+npx playwright install chromium
+```
+
+```bash
+npm run terminal:bench -- --url http://127.0.0.1:3100/rustui/terminal
+```
+
+It writes `target/terminal-bench/terminal-bench.json` plus screenshots when
+Playwright is available in the workspace.
diff --git a/TERMINAL_ROADMAP.md b/TERMINAL_ROADMAP.md
index d922ccd..e21f8c0 100644
--- a/TERMINAL_ROADMAP.md
+++ b/TERMINAL_ROADMAP.md
@@ -236,7 +236,11 @@
- context lost 或 WebGL 初始化失败时显示独立 Canvas 2D fallback surface,避免同一 canvas 上 WebGL lost 后无法获取 2D context 导致白屏。
- restore 后清空旧 state 并触发重绘,下一帧重建 shader/program/buffer/texture/glyph atlas。
- context lifecycle listener 由 `WebGlContextLifecycle` 持有,组件卸载或 renderer 切换时自动移除。
-2. 渲染性能 benchmark 和自动截图 / pixel smoke test。
+2. **渲染性能 benchmark 和自动截图 / pixel smoke test** ✅
+ - `scripts/terminal-bench.mjs` 连接已启动的 `/rustui/terminal`,不负责启动/关闭服务。
+ - 固定覆盖 `200x80`、`240x100` viewport,支持 `--lines`、`--viewport`、`--output` 参数。
+ - 记录行输出耗时、rows/sec、requestAnimationFrame frame time 摘要、`window.__terminalBench` render probe。
+ - 截图 smoke 默认走 Playwright screenshot PNG 非空/熵检查;Canvas 2D fallback 可读时额外做像素采样。
3. Canvas 2D / WebGL runtime switch。
4. 与 Canvas 2D 做 benchmark 对比,按 viewport size / output rate 自动选择 renderer。
diff --git a/app/Cargo.toml b/app/Cargo.toml
index f129999..9865d50 100644
--- a/app/Cargo.toml
+++ b/app/Cargo.toml
@@ -18,6 +18,7 @@ leptos.workspace = true
leptos_axum = { workspace = true, optional = true }
leptos_meta.workspace = true
leptos_router.workspace = true
+js-sys.workspace = true
log.workspace = true
redis = { workspace = true, optional = true }
reqwest = { workspace = true, optional = true }
diff --git a/app/src/pages/terminal.rs b/app/src/pages/terminal.rs
index 4c5851f..0e69b69 100644
--- a/app/src/pages/terminal.rs
+++ b/app/src/pages/terminal.rs
@@ -1,16 +1,89 @@
use leptos::prelude::*;
use crate::components::layout::{AdminShell, Breadcrumb};
-use crate::terminal::TerminalPanel;
+use crate::terminal::{TerminalPanel, TerminalRenderEvent};
#[component]
pub fn TerminalPage() -> impl IntoView {
+ let on_render = Callback::new(update_terminal_bench_probe);
+
view! {
}
}
+
+#[cfg(target_arch = "wasm32")]
+fn update_terminal_bench_probe(event: TerminalRenderEvent) {
+ use wasm_bindgen::JsValue;
+
+ let Some(window) = web_sys::window() else {
+ return;
+ };
+ let Ok(probe) = js_sys::Reflect::get(&window, &JsValue::from_str("__terminalBench")) else {
+ return;
+ };
+ let probe = if probe.is_object() {
+ probe
+ } else {
+ let object = js_sys::Object::new();
+ let _ = js_sys::Reflect::set(&window, &JsValue::from_str("__terminalBench"), &object);
+ object.into()
+ };
+
+ let count = js_sys::Reflect::get(&probe, &JsValue::from_str("renderCount"))
+ .ok()
+ .and_then(|value| value.as_f64())
+ .unwrap_or(0.0)
+ + 1.0;
+ let mode = if event.mode.is_live() {
+ "live"
+ } else {
+ "history"
+ };
+ let renderer = format!("{:?}", event.renderer);
+
+ let _ = js_sys::Reflect::set(
+ &probe,
+ &JsValue::from_str("renderCount"),
+ &JsValue::from_f64(count),
+ );
+ let _ = js_sys::Reflect::set(
+ &probe,
+ &JsValue::from_str("lastRenderAt"),
+ &JsValue::from_f64(js_sys::Date::now()),
+ );
+ let _ = js_sys::Reflect::set(
+ &probe,
+ &JsValue::from_str("renderer"),
+ &JsValue::from_str(&renderer),
+ );
+ let _ = js_sys::Reflect::set(&probe, &JsValue::from_str("mode"), &JsValue::from_str(mode));
+ let _ = js_sys::Reflect::set(
+ &probe,
+ &JsValue::from_str("cols"),
+ &JsValue::from_f64(f64::from(event.cols)),
+ );
+ let _ = js_sys::Reflect::set(
+ &probe,
+ &JsValue::from_str("rows"),
+ &JsValue::from_f64(f64::from(event.rows)),
+ );
+ let _ = js_sys::Reflect::set(
+ &probe,
+ &JsValue::from_str("historyRows"),
+ &JsValue::from_f64(event.history_rows as f64),
+ );
+ let _ = js_sys::Reflect::set(
+ &probe,
+ &JsValue::from_str("screenRows"),
+ &JsValue::from_f64(event.screen_rows as f64),
+ );
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+fn update_terminal_bench_probe(_event: TerminalRenderEvent) {}
diff --git a/package.json b/package.json
index 1eb8b74..ad0593c 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,11 @@
{
"private": true,
+ "scripts": {
+ "terminal:bench": "node scripts/terminal-bench.mjs"
+ },
"devDependencies": {
"@tailwindcss/cli": "4.3.0",
+ "playwright": "1.61.1",
"tailwindcss": "4.3.0"
}
}
diff --git a/scripts/terminal-bench.mjs b/scripts/terminal-bench.mjs
new file mode 100644
index 0000000..e7f1b48
--- /dev/null
+++ b/scripts/terminal-bench.mjs
@@ -0,0 +1,360 @@
+#!/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,
+ };
+
+ 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 === "--no-screenshot") {
+ options.screenshot = false;
+ } else if (arg === "--help" || arg === "-h") {
+ options.help = true;
+ } else {
+ throw new Error(`Unknown argument: ${arg}`);
+ }
+ }
+
+ 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();
+ 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();
+ }
+}
+
+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 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
+ --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);
+ });
+}