Files
callstack__agent-device/scripts/perf/report.ts
Michał Pierzchała 45cfad5cc5 feat: e2e command perf benchmark harness + nightly CI (#630)
* feat: add e2e command perf benchmark harness + nightly CI

Adds scripts/perf, a cheap end-to-end perf benchmark that drives the built
CLI through an ordered Settings tour of ~24 commands for N rounds, on a fully
isolated daemon/state-dir and self-cleaning device, and emits JSON + Markdown
reports. Per-command timing comes from wrapping each batchable command in its
own single-step batch (daemon durationMs) plus wall-clock around the process.

Wires a scheduled + workflow_dispatch CI job (perf-nightly.yml) that reuses the
cached iOS XCUITest runner (setup-apple-replay) and the Android replay host, and
runs the CLI from source via --experimental-strip-types (no dist build).

* refactor(perf): drive the harness CLI via runCmdSync, not spawnSync

Review (P2): repo rule is to spawn processes through src/utils/exec.ts, not
node:child_process directly. Switch the perf harness's invokeCli to runCmdSync
(allowFailure so non-zero exits are recorded as samples) and add a maxBuffer
option to ExecOptions/runCmdSync (snapshot payloads exceed Node's ~1MB default).

* perf(harness): warm the runner after open so the first measured command is clean

The first interaction after open/relaunch pays the one-time iOS XCUITest runner
startup (~10s+ cold) and a per-relaunch first-AX-query settle cost (~4s). That was
landing on the first measured command each round (snapshot -i), inflating it ~10x
vs the next snapshot. Run an untimed warmup snapshot -i after establishSession, after
each round's reset-open, and after every freshRoot relaunch, so no measured command
absorbs runner startup. Noted in the report header.

* refactor(perf): address review + fix Fallow CI

- exec.ts: extract spawnRejectionError + commandCloseFailure helpers, deduping the
  error/close handler clones (Fallow duplication ✗ that surfaced once the maxBuffer
  change pulled exec.ts into the audit scope).
- .fallowrc: exclude scripts/perf/** (non-shipped benchmark tooling, like examples/
  test-app) so its naturally-moderate functions don't trip the complexity gate.
- config.ts: drop unused exports CLI_BIN/DEFAULT_OUT_DIR; add readIntValue so
  --n/--rounds/--warmup report the actual flag + reject non-integers clearly.
- harness.ts: extract toSample(); type sampleError param as CliResult.
- scenario.ts: ScenarioStep is now a discriminated union on execMode (removes step.step!/
  step.args ?? []).
- comment/legend rewords (platform defaults are local-convenience/CI-overridden;
  elements = node count). check:fallow now green; typecheck/lint/unit pass.

* perf(harness): downgrade sample ok when a batch step reports ok:false

Defensive belt-and-suspenders for the Codex review note: stop-only batch already
surfaces a failed step as a top-level failure (caught by invokeCli), but if an
on-error=continue mode ever keeps the batch ok while a step fails, don't silently
count that step as a successful sample — derive ok from the step's own result.ok.
2026-05-31 14:37:59 +02:00

68 lines
3.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'node:fs';
import path from 'node:path';
import type { Measurement, RunResult, Stat } from './types.ts';
function ms(n: number | undefined): string {
return typeof n === 'number' && Number.isFinite(n) ? n.toFixed(0) : '';
}
function wallCells(s: Stat | null): string {
if (!s) return ' | | | ';
return `${ms(s.min)} | ${ms(s.median)} | ${ms(s.p95)} | ${ms(s.max)}`;
}
function stampName(platform: string, startedAt: string): string {
return `perf-${platform}-${startedAt.replace(/[:.]/g, '-')}`;
}
function measurementRow(m: Measurement): string {
const daemon = m.daemonDuration ? ms(m.daemonDuration.median) : '';
const elements = m.elementCount ? ms(m.elementCount.median) : '';
const n = m.wallClock?.n ?? 0;
return `| ${m.label} | ${m.command} | ${m.execMode} | ${n} | ${wallCells(m.wallClock)} | ${daemon} | ${elements} | ${m.notes.join('; ')} |`;
}
function toMarkdown(run: RunResult): string {
const lines: string[] = [];
lines.push(`# agent-device command perf — ${run.platform}`);
lines.push('');
lines.push(`- **Device**: ${run.device.name} (${run.device.udid ?? run.device.serial ?? '?'})`);
lines.push(`- **agent-device**: ${run.agentDeviceVersion}`);
lines.push(`- **Rounds**: ${run.config.rounds} (warmup ${run.config.warmup} dropped)`);
lines.push(`- **Started**: ${run.startedAt}`);
lines.push(`- **Finished**: ${run.finishedAt}`);
lines.push('');
lines.push('All times in milliseconds. `wall-clock` includes process spawn + socket overhead;');
lines.push('`daemon` is the batch step round-trip (spawn overhead ≈ wall-median daemon-median).');
lines.push('`elements` = node count in the snapshot payload (tree-size proxy).');
lines.push('An untimed warmup interaction runs after each open/relaunch, so measured commands');
lines.push('do not pay the one-time iOS-runner startup or post-relaunch first-AX-query cost.');
lines.push('');
lines.push('| command | cli | mode | n | wall min | wall median | wall p95 | wall max | daemon median | elements | notes |');
lines.push('|---|---|---|---|---|---|---|---|---|---|---|');
for (const m of run.measurements) lines.push(measurementRow(m));
lines.push('');
const failed = run.measurements.filter((m) => m.failures > 0);
if (failed.length > 0) {
lines.push('## Failures');
lines.push('');
for (const m of failed) {
const sample = m.samples.find((s) => !s.ok);
lines.push(`- **${m.label}** — ${m.notes.join('; ')}${sample?.errorMessage ? `${sample.errorMessage}` : ''}`);
}
lines.push('');
}
return lines.join('\n');
}
export function writeReports(run: RunResult, outDir: string): { jsonPath: string; mdPath: string } {
fs.mkdirSync(outDir, { recursive: true });
const base = stampName(run.platform, run.startedAt);
const jsonPath = path.join(outDir, `${base}.json`);
const mdPath = path.join(outDir, `${base}.md`);
fs.writeFileSync(jsonPath, JSON.stringify(run, null, 2));
fs.writeFileSync(mdPath, toMarkdown(run));
return { jsonPath, mdPath };
}