Files
orca/config/scripts/generate-terminal-perf-html-report.mjs
T
OrcaWinandOrcaWin 7d6603aa17 [comment audit] Complete repository-wide comment cleanup (#33249)
* [comment audit] Align legacy failure-read counts with cached display

* [comment audit] Condense shared provider lifecycle and browser recovery comments [skip ci]

* [comment audit] Condense shared generation, path and preview comments [skip ci]

* [comment audit] Condense host, file and Git contract rationale [skip ci]

* [comment audit] Tighten Git lifecycle and provider contract comments [skip ci]

* [comment audit] Clarify shared host authority and launch isolation [skip ci]

* [comment audit] simplify local access mobile and native chat contracts [skip ci]

* [comment audit] simplify native chat presentation and state contracts [skip ci]

* [comment audit] simplify native runtime and provider contracts [skip ci]

* [comment audit] simplify migration and orchestration contracts [skip ci]

* [comment audit] simplify plugin trust and process capture rationale [skip ci]

* [comment audit] simplify terminal delivery and remote compatibility rationale [skip ci]

* [comment audit] clarify remote recovery and launch wire rationale [skip ci]

* [comment audit] shorten shared RPC compatibility and runtime evidence comments [skip ci]

* [comment audit] simplify secure storage shell startup and sidebar rationale [skip ci]

* [comment audit] clarify sidebar, skill and SSH invariants [skip ci]

* [comment audit] condense structured chat delivery and timing rationale [skip ci]

* [comment audit] condense terminal replay and telemetry invariants [skip ci]

* [comment audit] clarify launch permission and terminal delivery rationale [skip ci]

* [comment audit] clarify workspace ownership and layout contracts [skip ci]

* [comment audit] wait for fitted and painted SSH terminal links

* [comment audit] clarify layout persistence and session ownership [skip ci]

* [comment audit] clarify host routing and workspace lifecycle contracts [skip ci]

* [comment audit] clarify startup persistence and app lifetime comments [skip ci]

* [comment audit] clarify recovery and activity boundaries [skip ci]

* [comment audit] condense activity and artifact invariants [skip ci]

* [comment audit] condense automation authority contracts [skip ci]

* [comment audit] simplify automation scheduling and history contracts [skip ci]

* [comment audit] clarify browser chrome and annotation lifecycles [skip ci]

* [comment audit] clarify browser routing and retained guest ownership [skip ci]

* [comment audit] clarify browser recovery and document ownership comments [skip ci]

* [comment audit] restore hosted runners required by native IME fixtures

* [comment audit] shorten preview, palette and dashboard invariants [skip ci]

* [comment audit] clarify terminal preview, dashboard and retention comments [skip ci]

* [comment audit] clarify diff-note recovery and editor lifecycle comments [skip ci]

* [comment audit] shorten rich-text and combined-diff ownership comments [skip ci]

* [comment audit] shorten autosave, draft recovery and model ownership comments [skip ci]

* [comment audit] shorten save-queue, notebook and Markdown constraints [skip ci]

* [comment audit] shorten Monaco, PDF and rich Markdown safeguards [skip ci]

* [comment audit] clarify rich Markdown source and selection safeguards [skip ci]

* [comment audit] clarify editor loading and emulator stream lifetimes [skip ci]

* [comment audit] trim emulator and feature tour narration [skip ci]

* [comment audit] clarify floating workspace and GitHub cache ownership [skip ci]

* [comment audit] trim provider controls and Linear filter explanations [skip ci]

* [comment audit] clarify pairing and chat resume lifetimes [skip ci]

* [comment audit] clarify chat delivery and transcript lifetimes [skip ci]

* [comment audit] clarify session options and host-owned status [skip ci]

* [comment audit] clarify draft durability and live-status precedence [skip ci]

* [comment audit] Trim chat send and model ownership explanations [skip ci]

* [comment audit] Shorten transcript ownership and send recovery comments [skip ci]

* [comment audit] Simplify native chat composer and transcript hook comments [skip ci]

* [comment audit] Shorten native chat scroll and structured session explanations [skip ci]

* [comment audit] Trim workspace picker and onboarding commentary [skip ci]

* [comment audit] Simplify pet rendering, review cache and quick open comments [skip ci]

* [comment audit] Trim sidebar and session history explanations [skip ci]

* [comment audit] Condense session resume and review checks comments [skip ci]

* [comment audit] Shorten checks state and file explorer refresh comments [skip ci]

* [comment audit] Trim plugin bridge and source-control action rationale [skip ci]

* [comment audit] Trim source-control listing and review rationale [skip ci]

* [comment audit] Shorten sidebar lifecycle and explorer safeguards [skip ci]

* [comment audit] Condense Settings account and appearance rationale [skip ci]

* [comment audit] Trim pairing and repository Settings explanations [skip ci]

* [comment audit] Condense Settings search SSH and terminal safeguards [skip ci]

* [comment audit] Shorten Settings voice and provider ownership comments [skip ci]

* [comment audit] Condense Settings draft persistence and host-state rationale [skip ci]

* [comment audit] Trim Settings hooks and sidebar setup rationale [skip ci]

* [comment audit] Condense sidebar deletion and board-filter safeguards [skip ci]

* [comment audit] Condense worktree cards, host ownership and Markdown contracts [skip ci]

* [comment audit] Condense sidebar drag, folder and filtering contracts [skip ci]

* [comment audit] Condense sidebar parity, sleep and host ownership contracts [skip ci]

* [comment audit] Prune focus-read baseline after comment cleanup

* [comment audit] Clarify sidebar visibility, board drops and agent evidence [skip ci]

* [comment audit] Tighten sidebar listing, drag and reveal contracts [skip ci]

* [comment audit] Clarify viewport geometry and skill update safeguards [skip ci]

* [comment audit] Tighten skill inventory, usage and host status comments [skip ci]

* [comment audit] Clarify usage ownership and resource kill safeguards [skip ci]

* [comment audit] Tighten tab selection, rename and search contracts [skip ci]

* [comment audit] Tighten tab-strip geometry and drag ownership comments [skip ci]

* [comment audit] Tighten Tasks recovery, pagination and mutation comments [skip ci]

* [comment audit] Tighten provider loading and terminal recovery comments [skip ci]

* [comment audit] Tighten completion, cadence and PTY ownership comments [skip ci]

* [comment audit] Clarify terminal restore and parked-pane boundaries [skip ci]

* [comment audit] Tighten PTY startup and replay explanations [skip ci]

* [comment audit] Clarify PTY identity and delivery ownership [skip ci]

* [comment audit] Shorten remote reconnect and input replay rationale [skip ci]

* [comment audit] Condense terminal parking clipboard and file link comments [skip ci]

* [comment audit] Clarify IME ownership and trim trace history [skip ci]

* [comment audit] Shorten pane recovery and parked watcher explanations [skip ci]

* [comment audit] Condense snapshot replay and visibility recovery rationale [skip ci]

* [comment audit] Shorten terminal parking and keyboard ownership rationale [skip ci]

* [comment audit] Trim renderer lifecycle and menu rationale [skip ci]

* [comment audit] Condense workspace cleanup ownership and settlement notes [skip ci]

* [comment audit] Clarify renderer hook evidence and source ownership [skip ci]

* [comment audit] Tighten IPC ownership and reconnect explanations [skip ci]

* [comment audit] Trim renderer focus, paste and attention comments [skip ci]

* [comment audit] Shorten scroll, locale and agent lifecycle comments [skip ci]

* [comment audit] Trim agent launch, resume and ownership commentary [skip ci]

* [comment audit] Shorten account, browser and editor comments [skip ci]

* [comment audit] Clarify host-mirror waits, previews and input ownership [skip ci]

* [comment audit] Condense launch recovery and editor tokenization comments [skip ci]

* [comment audit] Condense workspace selection and terminal pane rationale [skip ci]

* [comment audit] Condense terminal fitting and rendering recovery comments [skip ci]

* [comment audit] Condense terminal input and host routing explanations [skip ci]

* [comment audit] Condense ownership and sleeping-session recovery rationale [skip ci]

* [comment audit] Condense structured launch and terminal link explanations [skip ci]

* [comment audit] Condense terminal ownership and workspace file routing rationale [skip ci]

* [comment audit] Condense session persistence and workspace activation rationale [skip ci]

* [comment audit] Condense workspace routing and browser ownership rationale [skip ci]

* [comment audit] Condense mirror recovery and layout authority rationale [skip ci]

* [comment audit] Condense runtime transport and file safety rationale [skip ci]

* [comment audit] Condense runtime graph and structured session rationale [skip ci]

* [comment audit] Condense browser creation and remote tab recovery rationale [skip ci]

* [comment audit] Condense snapshot authority and publisher recovery rationale [skip ci]

* [comment audit] Condense frame, startup and provider cache rationale [skip ci]

* [comment audit] Condense host twin and agent lifecycle rationale [skip ci]

* [comment audit] Condense browser authority and editor cleanup rationale [skip ci]

* [comment audit] Condense editor buffer identity and review cache rationale [skip ci]

* [comment audit] Condense provider lifetime and repository ownership rationale [skip ci]

* [comment audit] Condense reconnect, hydration and terminal retirement rationale [skip ci]

* [comment audit] Condense recovery, reconciliation and persistence rationale [skip ci]

* [comment audit] Condense cleanup ownership and listing admission rationale [skip ci]

* [comment audit] Condense teardown ordering and terminal ownership rationale [skip ci]

* [comment audit] Condense recovery ledger and web host-boundary rationale [skip ci]

* [comment audit] Condense web reconnect and credential lifecycle rationale [skip ci]

* [comment audit] Condense cloud rollout and assignment safety rationale [skip ci]

* [comment audit] Condense relay session and database recovery rationale [skip ci]

* [comment audit] Condense relay placement and protocol compatibility rationale [skip ci]

* [comment audit] Condense build and packaging constraints [skip ci]

* [comment audit] Condense CI gates and validation isolation rationale [skip ci]

* [comment audit] Condense catalog identity and browser validation rationale [skip ci]

* [comment audit] Condense mobile browser and frame-budget rationale [skip ci]

* [comment audit] Shorten mobile route and browser test explanations [skip ci]

* [comment audit] Condense editor and session seam test rationale [skip ci]

* [comment audit] Trim session and terminal closure history [skip ci]

* [comment audit] Shorten terminal browser and census explanations [skip ci]

* [comment audit] Preserve root-only E2E diagnostic baseline

* [comment audit] Condense mobile seam and HTML preview test rationale [skip ci]

* [comment audit] Condense preview instrumentation and render harness rationale [skip ci]

* [comment audit] Condense bundle contracts and native-build constraints [skip ci]

* [comment audit] Shorten native packaging and CI routing rationale [skip ci]

* [comment audit] Condense release tooling and benchmark constraints [skip ci]

* [comment audit] Condense packaging and platform gate rationale [skip ci]

* [comment audit] Shorten mobile route and history rationale [skip ci]

* [comment audit] Condense mobile browser and component rationale [skip ci]

* [comment audit] Shorten drawer and workspace-form rationale [skip ci]

* [comment audit] Trim mobile review and editor-document explanations [skip ci]

* [comment audit] Condense editor lifecycle and Markdown explanations [skip ci]

* [comment audit] Trim mobile file, diagnostics and dictation commentary [skip ci]

* [comment audit] Simplify mobile host-list and workspace-view explanations [skip ci]

* [comment audit] Clarify mobile shell lifetime and bridge delivery constraints [skip ci]

* [comment audit] Condense bridge stream, native request and audio rationale [skip ci]

* [comment audit] Reduce bridge schema and page-client commentary [skip ci]

* [comment audit] Tighten mobile page bridge contracts [skip ci]

* [comment audit] Clarify mobile cache and session constraints [skip ci]

* [comment audit] Condense mobile route and lifecycle rationale [skip ci]

* [comment audit] Tighten mobile shell hook timing rationale [skip ci]

* [comment audit] Clarify route handoff and notification compatibility [skip ci]

* [comment audit] Clarify push registration and dictation cleanup constraints [skip ci]

* [comment audit] Condense keyboard geometry and native media ownership rationale [skip ci]

* [comment audit] Tighten mobile chat scroll and composer concurrency rationale [skip ci]

* [comment audit] Condense mobile reply compatibility and upload ownership rationale [skip ci]

* [comment audit] Condense optimistic echo and scroll ownership rationale [skip ci]

* [comment audit] Tighten stream replay and queued session ownership rationale [skip ci]

* [comment audit] Condense mobile composer and reply-schema constraints [skip ci]

* [comment audit] Clarify mobile transcript recovery and session ownership [skip ci]

* [comment audit] Clarify mobile queue and source-control compatibility [skip ci]

* [comment audit] Condense storage and task launch constraints [skip ci]

* [comment audit] Clarify task schemas and terminal document ownership [skip ci]

* [comment audit] Tighten terminal input and viewport rationale [skip ci]

* [comment audit] Shorten terminal and RPC recorder contracts [skip ci]

* [comment audit] Condense recorder engine and connection contracts [skip ci]

* [comment audit] Shorten host credential and relay recovery comments [skip ci]

* [comment audit] Tighten pairing recovery and bundle transport rationale [skip ci]

* [comment audit] Clarify RPC delivery and workspace catalog boundaries [skip ci]

* [comment audit] Condense catalog and relay entry contracts [skip ci]

* [comment audit] Shorten shutdown and SSH relay delivery contracts [skip ci]

* [comment audit] Condense WSL relay and E2E regression rationale [skip ci]

* [comment audit] Shorten cross-version harness and renderer fixture explanations [skip ci]

* [comment audit] Condense paired-host and E2E lifecycle rationale [skip ci]

* [comment audit] Shorten layout oracle and regression explanations [skip ci]

* [comment audit] Condense paired recovery and ownership regression contracts [skip ci]

* [comment audit] Shorten restart, sidebar and SSH lifecycle contracts [skip ci]

* [comment audit] Condense terminal rendering and IME regression rationale [skip ci]

* [comment audit] Shorten terminal recovery and benchmark contracts [skip ci]

* [comment audit] Condense tooling and Windows lifecycle proof rationale [skip ci]

* [comment audit] Condense workflow routing and release rationale [skip ci]

* [comment audit] Condense mobile security and installer contracts [skip ci]

* [comment audit] Shorten native rationale and stylesheet workarounds [skip ci]

* [comment audit] Shorten Windows visibility probe rationale [skip ci]

* [comment audit] Condense embedded release and signing rationale [skip ci]

* [comment audit] Retain current main test behavior in final reconciliation [skip ci]

* [comment audit] Complete final comment review across current source inventory

* [comment audit] Handle consolidated audit reports before final CI

* [comment audit] Preserve credential storage census detail [skip ci]

* [comment audit] Scale broad changed E2E selections with verified timing shards

* [comment audit] Align E2E assertions with browser close and settled session metadata

* [comment audit] Match existing E2Es to current resource and windowing contracts

* [comment audit] Observe the shared server outage notice in reconnect coverage

* [comment audit] Make existing renderer probes measure settled tab and PTY work

* [comment audit] Align palette E2E selector with task search

* [comment audit] Refresh remaining palette search selectors

* [comment audit] Keep selector correction within selected audit specs

* [comment audit] Await emoji fixture settings inside renderer evaluation

* [comment audit] Cancel fixture fit retries between terminal tests

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-10-25 04:05:46 -07:00

475 lines
18 KiB
JavaScript

import { mkdirSync, writeFileSync } from 'node:fs'
import {
budgetFailures,
collectTerminalPerfRows,
compareScenarios,
escapeHtml,
formatLargeValue,
formatMs,
readJsonReport,
scenarioTitle
} from './terminal-perf-report-rows.mjs'
import { basename, dirname } from 'node:path'
const DEFAULT_OUTPUT_PATH = 'test-results/terminal-perf-impact-report.html'
// All metrics are lower-is-better; share direction between delta colors and regression table.
const MS_METRICS = [
{ key: 'medianMs', label: 'Typing median', chart: true },
{ key: 'worstMs', label: 'Typing worst', chart: true },
{ key: 'scrollMs', label: 'Active scroll', chart: true },
{ key: 'restoreMs', label: 'Restore', chart: true },
{ key: 'revisitMs', label: 'Revisit marker', chart: true },
{ key: 'maxTimerDriftMs', label: 'Timer drift', chart: false }
]
const COUNT_METRICS = [
{ key: 'rendererPeakQueuedChars', label: 'Renderer peak queued chars' },
{ key: 'mainPeakInFlightChars', label: 'Main in-flight chars' },
{ key: 'mainPeakPendingChars', label: 'Main pending chars' },
{ key: 'hiddenSkippedChars', label: 'Hidden skipped chars' },
{ key: 'rendererDroppedBacklogs', label: 'Renderer dropped backlogs' },
// Parked-memory heap/view counts belong in tables, not millisecond trend charts.
{ key: 'heapUsedMB', label: 'Renderer JS heap (MB)' },
{ key: 'liveTerminals', label: 'Live xterm instances' },
{ key: 'livePaneManagers', label: 'Live pane managers' }
]
const SERIES_COLORS = {
medianMs: '#2563eb',
worstMs: '#dc2626',
scrollMs: '#d97706',
restoreMs: '#7c3aed',
revisitMs: '#0d9488'
}
const LABELED_INPUT_RE = /^([\w .#@()+-]+)=(.+)$/
export function parseHtmlReportArgs(argv, env = process.env) {
const args = [...argv]
if (args[0] === '--') {
args.shift()
}
const inputs = []
let outputPath = env.ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH || DEFAULT_OUTPUT_PATH
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--output' || arg === '-o') {
const next = args[index + 1]
if (!next || next.startsWith('-')) {
throw new Error(`${arg} requires a path`)
}
outputPath = next
index += 1
continue
}
if (arg.startsWith('--output=')) {
outputPath = arg.slice('--output='.length)
continue
}
const labeled = arg.match(LABELED_INPUT_RE)
if (labeled) {
inputs.push({ label: labeled[1], path: labeled[2] })
} else {
inputs.push({ label: basename(arg).replace(/\.json$/i, ''), path: arg })
}
}
if (inputs.length === 0) {
throw new Error(
'Usage: node config/scripts/generate-terminal-perf-html-report.mjs [label=]<playwright-json>... --output <report.html>'
)
}
return { inputs, outputPath }
}
function buildMatrix(revisions) {
const scenarios = new Map()
for (const revision of revisions) {
for (const row of revision.rows) {
if (!scenarios.has(row.scenario)) {
scenarios.set(row.scenario, new Map())
}
scenarios.get(row.scenario).set(revision.label, row)
}
}
const orderedScenarios = [...scenarios.keys()].sort(compareScenarios)
return { scenarios, orderedScenarios }
}
function niceCeil(value) {
if (value <= 0) {
return 1
}
const magnitude = 10 ** Math.floor(Math.log10(value))
for (const step of [1, 2, 2.5, 5, 10]) {
if (value <= step * magnitude) {
return step * magnitude
}
}
return 10 * magnitude
}
function renderTrendChart({ scenario, byRevision, revisions, title }) {
const metrics = MS_METRICS.filter(
(metric) =>
metric.chart &&
revisions.some((revision) => byRevision.get(revision.label)?.[metric.key] != null)
)
if (metrics.length === 0) {
return ''
}
const width = 560
const height = 230
const pad = { left: 52, right: 14, top: 30, bottom: 38 }
const plotW = width - pad.left - pad.right
const plotH = height - pad.top - pad.bottom
const maxValue = Math.max(
1,
...metrics.flatMap((metric) =>
revisions.map((revision) => byRevision.get(revision.label)?.[metric.key] ?? 0)
)
)
const yMax = niceCeil(maxValue * 1.15)
const xFor = (index) =>
pad.left + (revisions.length === 1 ? plotW / 2 : (plotW * index) / (revisions.length - 1))
const yFor = (value) => pad.top + plotH - (plotH * value) / yMax
const parts = []
parts.push(
`<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeHtml(title)}" class="trend-chart">`
)
parts.push(`<text x="${pad.left}" y="16" class="chart-title">${escapeHtml(title)}</text>`)
const ticks = 4
for (let tick = 0; tick <= ticks; tick += 1) {
const value = (yMax * tick) / ticks
const y = yFor(value)
parts.push(
`<line x1="${pad.left}" y1="${y}" x2="${width - pad.right}" y2="${y}" class="gridline"/>`
)
parts.push(
`<text x="${pad.left - 6}" y="${y + 3}" class="axis-label" text-anchor="end">${value % 1 === 0 ? value : value.toFixed(1)}</text>`
)
}
revisions.forEach((revision, index) => {
parts.push(
`<text x="${xFor(index)}" y="${height - pad.bottom + 16}" class="axis-label" text-anchor="middle">${escapeHtml(revision.label)}</text>`
)
})
for (const metric of metrics) {
const color = SERIES_COLORS[metric.key] ?? '#475569'
const points = revisions
.map((revision, index) => ({ index, value: byRevision.get(revision.label)?.[metric.key] }))
.filter((point) => point.value != null)
if (points.length === 0) {
continue
}
const path = points
.map(
(point, order) =>
`${order === 0 ? 'M' : 'L'}${xFor(point.index).toFixed(1)},${yFor(point.value).toFixed(1)}`
)
.join(' ')
parts.push(`<path d="${path}" fill="none" stroke="${color}" stroke-width="2"/>`)
for (const point of points) {
const x = xFor(point.index)
const y = yFor(point.value)
parts.push(`<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3" fill="${color}"/>`)
parts.push(
`<text x="${x.toFixed(1)}" y="${(y - 7).toFixed(1)}" class="point-label" text-anchor="middle" fill="${color}">${point.value % 1 === 0 ? point.value : point.value.toFixed(1)}</text>`
)
}
}
parts.push('</svg>')
const legend = metrics
.map((metric) => {
const color = SERIES_COLORS[metric.key] ?? '#475569'
return `<span class="legend-item"><span class="legend-swatch" style="background:${color}"></span>${escapeHtml(metric.label)}</span>`
})
.join('')
return `<figure class="chart-card" data-scenario="${escapeHtml(scenario)}">${parts.join('')}<figcaption class="legend">${legend} <span class="legend-unit">ms — lower is better</span></figcaption></figure>`
}
function deltaCell(baseline, latest, { lowerIsBetter = true, zeroBudget = false } = {}) {
if (baseline == null || latest == null) {
return '<td class="delta">—</td>'
}
const diff = latest - baseline
const pct = baseline === 0 ? null : (diff / baseline) * 100
let cls = 'neutral'
if (zeroBudget) {
cls = latest > 0 ? 'worse' : 'better'
} else if (pct != null && Math.abs(pct) >= 5) {
cls = diff < 0 === lowerIsBetter ? 'better' : 'worse'
} else if (baseline === 0 && diff !== 0) {
cls = diff < 0 === lowerIsBetter ? 'better' : 'worse'
}
const pctLabel =
pct == null ? (diff === 0 ? '±0%' : 'new') : `${pct >= 0 ? '+' : ''}${pct.toFixed(0)}%`
const diffLabel = `${diff >= 0 ? '+' : ''}${Math.abs(diff) >= 100 ? Math.round(diff) : diff.toFixed(1)}`
return `<td class="delta ${cls}">${escapeHtml(pctLabel)} <span class="delta-abs">(${escapeHtml(diffLabel)})</span></td>`
}
function renderScenarioTable({ scenario, byRevision, revisions, title }) {
const metricRows = []
const allMetrics = [...MS_METRICS, ...COUNT_METRICS]
for (const metric of allMetrics) {
const values = revisions.map((revision) => byRevision.get(revision.label)?.[metric.key])
if (values.every((value) => value == null)) {
continue
}
const isMs = MS_METRICS.includes(metric)
const format = isMs ? formatMs : formatLargeValue
const cells = values
.map((value) => `<td>${value == null ? '—' : escapeHtml(format(value))}</td>`)
.join('')
const baseline = values.find((value) => value != null)
const latest = values.toReversed().find((value) => value != null)
metricRows.push(
`<tr><th scope="row">${escapeHtml(metric.label)}</th>${cells}${deltaCell(baseline, latest, {
zeroBudget: metric.key === 'rendererDroppedBacklogs'
})}</tr>`
)
}
if (metricRows.length === 0) {
return ''
}
const headers = revisions.map((revision) => `<th>${escapeHtml(revision.label)}</th>`).join('')
return `<section class="scenario-block">
<h3>${escapeHtml(title)} <span class="scenario-id">${escapeHtml(scenario)}</span></h3>
<table class="trend-table">
<thead><tr><th>Metric</th>${headers}<th>Δ first → last</th></tr></thead>
<tbody>${metricRows.join('')}</tbody>
</table>
</section>`
}
function renderHeadline(revisions, matrix) {
if (revisions.length < 2) {
return ''
}
const first = revisions[0]
const last = revisions.at(-1)
const cards = []
for (const scenario of matrix.orderedScenarios) {
const byRevision = matrix.scenarios.get(scenario)
const baseRow = byRevision.get(first.label)
const lastRow = byRevision.get(last.label)
if (!baseRow || !lastRow || baseRow.medianMs == null || lastRow.medianMs == null) {
continue
}
const diff = lastRow.medianMs - baseRow.medianMs
const pct = baseRow.medianMs === 0 ? 0 : (diff / baseRow.medianMs) * 100
const cls = Math.abs(pct) < 5 ? 'neutral' : diff < 0 ? 'better' : 'worse'
cards.push(`<div class="card ${cls}">
<div class="card-title">${escapeHtml(scenarioTitle(scenario, lastRow))}</div>
<div class="card-value">${escapeHtml(formatMs(baseRow.medianMs))} → ${escapeHtml(formatMs(lastRow.medianMs))}</div>
<div class="card-sub">typing median, ${escapeHtml(first.label)} → ${escapeHtml(last.label)} (${pct >= 0 ? '+' : ''}${pct.toFixed(0)}%)</div>
</div>`)
}
if (cards.length === 0) {
return ''
}
return `<section><h2>Baseline vs latest</h2><div class="cards">${cards.join('')}</div></section>`
}
function renderBudgets(latestRevision) {
const failures = []
for (const row of latestRevision.rows) {
for (const failure of budgetFailures(row)) {
failures.push(`${row.scenario}: ${failure}`)
}
}
const status =
failures.length === 0 ? '<span class="pass">Pass</span>' : '<span class="fail">Fail</span>'
const failureList =
failures.length === 0
? ''
: `<ul>${failures.map((failure) => `<li>${escapeHtml(failure)}</li>`).join('')}</ul>`
return `<section><h2>Budget status — ${escapeHtml(latestRevision.label)}</h2>
<p>${latestRevision.rows.length} scenario rows checked: ${status}</p>${failureList}</section>`
}
function renderInputsMeta(revisions) {
const items = revisions
.map((revision) => {
const stats = revision.stats
const statsLabel = stats
? ` — ${stats.expected ?? 0} passed, ${stats.unexpected ?? 0} failed, ${stats.flaky ?? 0} flaky`
: ''
const failNote =
stats && stats.unexpected > 0
? ' <span class="meta-warn">(failed assertions at this revision; metrics still recorded)</span>'
: ''
return `<li><strong>${escapeHtml(revision.label)}</strong> — ${revision.rows.length} scenario rows (${escapeHtml(revision.path)})${escapeHtml(statsLabel)}${failNote}</li>`
})
.join('')
return `<ol class="inputs">${items}</ol>`
}
function renderRawDetails(revisions) {
return revisions
.map((revision) => {
const rows = revision.rows
.map(
(row) =>
`<tr><td>${escapeHtml(row.scenario)}</td><td>${row.panes ?? '—'}</td><td>${escapeHtml(formatMs(row.medianMs))}</td><td>${escapeHtml(formatMs(row.worstMs))}</td><td>${escapeHtml(formatMs(row.scrollMs))}</td><td>${escapeHtml(formatMs(row.restoreMs))}</td><td>${escapeHtml(formatMs(row.revisitMs))}</td><td>${escapeHtml(formatLargeValue(row.rendererPeakQueuedChars))}</td><td>${escapeHtml(formatLargeValue(row.hiddenSkippedChars))}</td><td>${row.rendererDroppedBacklogs ?? '—'}</td></tr>`
)
.join('')
return `<details><summary>Raw rows — ${escapeHtml(revision.label)}</summary>
<table class="trend-table">
<thead><tr><th>Scenario</th><th>Panes</th><th>Median</th><th>Worst</th><th>Scroll</th><th>Restore</th><th>Revisit</th><th>Renderer peak</th><th>Hidden skipped</th><th>Drops</th></tr></thead>
<tbody>${rows}</tbody></table></details>`
})
.join('')
}
const PAGE_CSS = `
:root { color-scheme: light; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 24px auto; max-width: 1240px; padding: 0 16px; color: #0f172a; background: #f8fafc; }
h1 { font-size: 24px; margin-bottom: 4px; }
h2 { font-size: 18px; margin: 28px 0 10px; }
h3 { font-size: 15px; margin: 18px 0 6px; }
.meta { color: #64748b; font-size: 13px; }
.inputs { font-size: 13px; color: #334155; padding-left: 20px; }
.meta-warn { color: #b45309; }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 10px; }
.card { background: #fff; border: 1px solid #e2e8f0; border-left-width: 4px; border-radius: 8px; padding: 10px 12px; }
.card.better { border-left-color: #16a34a; }
.card.worse { border-left-color: #dc2626; }
.card.neutral { border-left-color: #94a3b8; }
.card-title { font-size: 12px; color: #64748b; }
.card-value { font-size: 18px; font-weight: 600; margin: 2px 0; }
.card-sub { font-size: 11px; color: #94a3b8; }
.charts { display: grid; grid-template-columns: repeat(auto-fill, minmax(560px, 1fr)); gap: 14px; }
.chart-card { margin: 0; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px; }
.trend-chart { width: 100%; height: auto; }
.chart-title { font-size: 13px; font-weight: 600; fill: #0f172a; }
.gridline { stroke: #e2e8f0; stroke-width: 1; }
.axis-label { font-size: 10px; fill: #64748b; }
.point-label { font-size: 10px; font-weight: 600; }
.legend { font-size: 11px; color: #475569; margin-top: 2px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.legend-item { display: inline-flex; align-items: center; gap: 4px; }
.legend-swatch { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
.legend-unit { color: #94a3b8; margin-left: auto; }
.scenario-block { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 14px; margin: 10px 0; }
.scenario-id { font-size: 11px; color: #94a3b8; font-weight: 400; margin-left: 6px; }
table.trend-table { border-collapse: collapse; width: 100%; font-size: 12px; }
table.trend-table th, table.trend-table td { border-bottom: 1px solid #e2e8f0; padding: 5px 8px; text-align: right; white-space: nowrap; }
table.trend-table th:first-child, table.trend-table td:first-child { text-align: left; }
table.trend-table thead th { color: #475569; font-weight: 600; background: #f1f5f9; }
td.delta.better { color: #15803d; font-weight: 600; }
td.delta.worse { color: #b91c1c; font-weight: 600; }
td.delta.neutral { color: #64748b; }
.delta-abs { font-weight: 400; color: #94a3b8; }
.pass { color: #15803d; font-weight: 700; }
.fail { color: #b91c1c; font-weight: 700; }
details { margin: 8px 0; }
summary { cursor: pointer; font-size: 13px; color: #334155; }
`
function renderHtml({ generatedAt, revisions }) {
const matrix = buildMatrix(revisions)
const charts =
revisions.length >= 2
? matrix.orderedScenarios
.map((scenario) => {
const byRevision = matrix.scenarios.get(scenario)
const anyRow = [...byRevision.values()][0]
return renderTrendChart({
scenario,
byRevision,
revisions,
title: scenarioTitle(scenario, anyRow)
})
})
.filter(Boolean)
.join('')
: ''
const tables = matrix.orderedScenarios
.map((scenario) => {
const byRevision = matrix.scenarios.get(scenario)
const anyRow = [...byRevision.values()][0]
return renderScenarioTable({
scenario,
byRevision,
revisions,
title: scenarioTitle(scenario, anyRow)
})
})
.filter(Boolean)
.join('')
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Terminal Performance Over Time</title>
<style>${PAGE_CSS}</style>
</head>
<body>
<h1>Terminal Performance Over Time</h1>
<p class="meta">Generated ${escapeHtml(generatedAt)} from ${revisions.length} benchmark run(s), ordered oldest (baseline) to newest. All metrics: lower is better.</p>
${renderInputsMeta(revisions)}
${renderHeadline(revisions, matrix)}
${charts ? `<section><h2>Trends across revisions</h2><div class="charts">${charts}</div></section>` : ''}
<section><h2>Metric detail by scenario</h2>${tables}</section>
${renderBudgets(revisions.at(-1))}
<section><h2>Raw data</h2>${renderRawDetails(revisions)}</section>
</body>
</html>
`
}
export function generateTerminalPerfHtmlReport({
inputs,
inputPaths,
outputPath,
now = new Date()
}) {
// Why: older callers (the scale report gate) pass bare inputPaths.
const resolvedInputs =
inputs ??
(inputPaths ?? []).map((path) => ({
label: basename(path).replace(/\.json$/i, ''),
path
}))
const revisions = resolvedInputs.map(({ label, path }) => {
const report = readJsonReport(path)
return {
label,
path,
stats: report.stats ?? null,
rows: collectTerminalPerfRows(report, label)
}
})
const totalRows = revisions.reduce((sum, revision) => sum + revision.rows.length, 0)
if (totalRows === 0) {
throw new Error('No opencode terminal perf annotations found in the provided reports')
}
const html = renderHtml({ generatedAt: now.toISOString(), revisions })
mkdirSync(dirname(outputPath), { recursive: true })
writeFileSync(outputPath, html)
const latestFailures = revisions
.at(-1)
.rows.reduce((sum, row) => sum + budgetFailures(row).length, 0)
return { outputPath, rowCount: totalRows, budgetFailureCount: latestFailures }
}
const isMain = process.argv[1] && import.meta.filename === process.argv[1]
if (isMain) {
try {
const { inputs, outputPath } = parseHtmlReportArgs(process.argv.slice(2))
const result = generateTerminalPerfHtmlReport({ inputs, outputPath })
console.log(
`Terminal perf HTML report saved to ${result.outputPath} (${result.rowCount} rows, ${result.budgetFailureCount} budget failures).`
)
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
}
}