172 lines
5.1 KiB
JavaScript
172 lines
5.1 KiB
JavaScript
import { basename } from 'node:path'
|
|
import { collectTerminalPerfRows, readJsonReport } from './terminal-perf-report-annotations.mjs'
|
|
|
|
const reportPaths = process.argv.slice(2)
|
|
if (reportPaths[0] === '--') {
|
|
reportPaths.shift()
|
|
}
|
|
|
|
if (reportPaths.length === 0) {
|
|
console.error(
|
|
'Usage: node config/scripts/check-terminal-perf-report-budgets.mjs <playwright-json>...'
|
|
)
|
|
process.exit(1)
|
|
}
|
|
|
|
// Why: these mirror the e2e regression ceilings so saved JSON reports can fail
|
|
// in automation without rerunning Electron or changing the human summary table.
|
|
const BUDGETS = {
|
|
maxMedianKeyLatencyMs: 75,
|
|
maxWorstKeyLatencyMs: 300,
|
|
maxRevisitLatencyMs: 300,
|
|
maxTimerDriftMs: 150,
|
|
// Why: mirrors MAX_TIMER_DRIFT_UNDER_LOAD_MS in artificial-opencode-terminal-load.spec.ts
|
|
// so injected multi-pane redraw rows are not judged against the unloaded ceiling.
|
|
maxTimerDriftUnderLoadMs: 2_500,
|
|
maxScrollLatencyMs: 150,
|
|
maxRestoreLatencyMs: 1000,
|
|
maxRendererQueuedChars: 2 * 1024 * 1024,
|
|
maxRendererPeakQueuedChars: 2 * 1024 * 1024,
|
|
maxRendererDroppedBacklogs: 0
|
|
}
|
|
|
|
// Why: only these annotation types assert against MAX_TIMER_DRIFT_UNDER_LOAD_MS
|
|
// in the e2e suite; other rows keep the unloaded smoke ceiling.
|
|
function isUnderLoadTimerDriftScenario(scenario) {
|
|
return (
|
|
scenario === 'opencode-same-workspace-typing' ||
|
|
scenario === 'opencode-cross-workspace-typing' ||
|
|
scenario.startsWith('opencode-scale-same-workspace-') ||
|
|
scenario.startsWith('opencode-scale-cross-workspace-')
|
|
)
|
|
}
|
|
|
|
function parseMs(value, fieldName, row, failures) {
|
|
if (value == null || value === '') {
|
|
return null
|
|
}
|
|
const match = String(value).match(/^(-?\d+(?:\.\d+)?)ms$/)
|
|
if (!match) {
|
|
failures.push(`${row.source} ${row.scenario}: ${fieldName} value "${value}" is malformed`)
|
|
return null
|
|
}
|
|
return Number(match[1])
|
|
}
|
|
|
|
function parseCount(value, fieldName, row, failures) {
|
|
if (value == null || value === '') {
|
|
return null
|
|
}
|
|
const parsed = Number(value)
|
|
if (!Number.isFinite(parsed)) {
|
|
failures.push(`${row.source} ${row.scenario}: ${fieldName} value "${value}" is malformed`)
|
|
return null
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
function addMaxFailure(failures, row, label, actual, budget, unit = '') {
|
|
if (actual == null || actual <= budget) {
|
|
return
|
|
}
|
|
failures.push(
|
|
`${row.source} ${row.scenario}: ${label} ${actual}${unit} exceeded budget ${budget}${unit}`
|
|
)
|
|
}
|
|
|
|
function validateRow(row) {
|
|
const failures = []
|
|
let checkedMetricCount = 0
|
|
const addBudgetCheck = (label, actual, budget, unit = '') => {
|
|
if (actual != null) {
|
|
checkedMetricCount += 1
|
|
}
|
|
addMaxFailure(failures, row, label, actual, budget, unit)
|
|
}
|
|
addBudgetCheck(
|
|
'median typing latency',
|
|
parseMs(row.median, 'median', row, failures),
|
|
BUDGETS.maxMedianKeyLatencyMs,
|
|
'ms'
|
|
)
|
|
addBudgetCheck(
|
|
'worst typing latency',
|
|
parseMs(row.worst, 'worst', row, failures),
|
|
BUDGETS.maxWorstKeyLatencyMs,
|
|
'ms'
|
|
)
|
|
addBudgetCheck(
|
|
'revisit latency',
|
|
parseMs(row.revisit, 'revisit', row, failures),
|
|
BUDGETS.maxRevisitLatencyMs,
|
|
'ms'
|
|
)
|
|
addBudgetCheck(
|
|
'timer drift',
|
|
parseMs(row.maxTimerDrift, 'maxTimerDrift', row, failures),
|
|
isUnderLoadTimerDriftScenario(row.scenario)
|
|
? BUDGETS.maxTimerDriftUnderLoadMs
|
|
: BUDGETS.maxTimerDriftMs,
|
|
'ms'
|
|
)
|
|
addBudgetCheck(
|
|
'scroll latency',
|
|
parseMs(row.scroll, 'scroll', row, failures),
|
|
BUDGETS.maxScrollLatencyMs,
|
|
'ms'
|
|
)
|
|
addBudgetCheck(
|
|
'restore latency',
|
|
parseMs(row.restore, 'restore', row, failures),
|
|
BUDGETS.maxRestoreLatencyMs,
|
|
'ms'
|
|
)
|
|
addBudgetCheck(
|
|
'renderer queued chars',
|
|
parseCount(row.rendererQueuedChars, 'rendererQueuedChars', row, failures),
|
|
BUDGETS.maxRendererQueuedChars
|
|
)
|
|
addBudgetCheck(
|
|
'renderer peak queued chars',
|
|
parseCount(row.rendererPeakQueuedChars, 'rendererPeakQueuedChars', row, failures),
|
|
BUDGETS.maxRendererPeakQueuedChars
|
|
)
|
|
addBudgetCheck(
|
|
'renderer dropped backlogs',
|
|
parseCount(row.rendererDroppedBacklogs, 'rendererDroppedBacklogs', row, failures),
|
|
BUDGETS.maxRendererDroppedBacklogs
|
|
)
|
|
// Why: parked-memory rows carry heap/view-count metrics with no latency
|
|
// budget; recognize them so memory-only scenarios pass the gate instead of
|
|
// tripping the "no recognized budget metrics" guard.
|
|
for (const fieldName of ['heapUsedMB', 'liveTerminals', 'livePaneManagers']) {
|
|
if (parseCount(row[fieldName], fieldName, row, failures) != null) {
|
|
checkedMetricCount += 1
|
|
}
|
|
}
|
|
if (checkedMetricCount === 0) {
|
|
failures.push(`${row.source} ${row.scenario}: no recognized budget metrics found`)
|
|
}
|
|
return failures
|
|
}
|
|
|
|
const rows = reportPaths.flatMap((path) =>
|
|
collectTerminalPerfRows(readJsonReport(path), basename(path))
|
|
)
|
|
|
|
if (rows.length === 0) {
|
|
console.error('No OpenCode terminal perf annotations found.')
|
|
process.exit(1)
|
|
}
|
|
|
|
const failures = rows.flatMap(validateRow)
|
|
if (failures.length > 0) {
|
|
console.error(`Terminal perf budget check failed with ${failures.length} violation(s):`)
|
|
for (const failure of failures) {
|
|
console.error(`- ${failure}`)
|
|
}
|
|
process.exit(1)
|
|
}
|
|
|
|
console.log(`Terminal perf budget check passed for ${rows.length} annotation row(s).`)
|