From 21019d82e005291d365a415b712d1fdbff615c9c Mon Sep 17 00:00:00 2001 From: zhangheng Date: Wed, 24 Jun 2026 14:13:09 +0800 Subject: [PATCH] test(terminal): add renderer bench smoke script --- Cargo.lock | 1 + README.md | 19 ++ TERMINAL_ROADMAP.md | 6 +- app/Cargo.toml | 1 + app/src/pages/terminal.rs | 77 +++++++- package.json | 4 + scripts/terminal-bench.mjs | 360 +++++++++++++++++++++++++++++++++++++ 7 files changed, 465 insertions(+), 3 deletions(-) create mode 100644 scripts/terminal-bench.mjs 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); + }); +}