Files
callstack__agent-device/scripts/perf/harness.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

196 lines
7.1 KiB
TypeScript

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
countElements,
invokeBatchStep,
invokeCli,
readBatchStepDurationMs,
readBatchStepError,
} from './cli.ts';
import type { PerfConfig } from './config.ts';
import { resolveProfile, type ResolvedProfile } from './platform-profiles.ts';
import { buildSettingsTour, type ScenarioStep } from './scenario.ts';
import { summarize } from './stats.ts';
import type { CliResult, Measurement, Sample } from './types.ts';
export type IsolationContext = {
stateDir: string;
artifactsDir: string;
baseFlags: string[];
profile: ResolvedProfile;
};
function log(msg: string): void {
process.stderr.write(`[perf] ${msg}\n`);
}
export function setupIsolation(cfg: PerfConfig): IsolationContext {
const profile = resolveProfile(cfg);
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-perf-'));
const artifactsDir = path.join(stateDir, 'artifacts');
fs.mkdirSync(artifactsDir, { recursive: true });
const baseFlags = ['--state-dir', stateDir, '--session', 'perf', ...profile.platformFlags];
log(`state-dir: ${stateDir}`);
log(`device: ${profile.deviceName} (${profile.udid ?? profile.serial})`);
return { stateDir, artifactsDir, baseFlags, profile };
}
export function teardownIsolation(ctx: IsolationContext, cfg: PerfConfig): void {
log('teardown: closing session');
try {
const args = ['close'];
if (!cfg.keepArtifacts) args.push('--shutdown');
invokeCli(args, ctx.baseFlags);
} catch {
/* best-effort */
}
if (cfg.keepArtifacts) {
log(`keep-artifacts: leaving ${ctx.stateDir} and device in place`);
return;
}
try {
fs.rmSync(ctx.stateDir, { recursive: true, force: true });
log('teardown: removed temp state dir');
} catch {
/* best-effort */
}
}
function sampleError(r: CliResult): Pick<Sample, 'errorCode' | 'errorMessage'> {
const err = readBatchStepError(r);
return {
errorCode: err.code ?? `exit:${r.exitCode}`,
errorMessage: (err.message ?? r.stderr.trim().split('\n').pop() ?? '').slice(0, 200),
};
}
// Base sample (timing + ok + error note on failure) shared by every measured invocation.
function toSample(r: CliResult, round: number): Sample {
const sample: Sample = { round, wallClockMs: r.wallClockMs, ok: r.ok };
if (!r.ok) Object.assign(sample, sampleError(r));
return sample;
}
// The first interaction after open/relaunch pays the iOS XCUITest runner startup (~10s+ cold)
// and a per-relaunch first-AX-query settle cost. Run an untimed throwaway interaction so that
// cost is never attributed to a measured command.
function warmRunner(ctx: IsolationContext): void {
invokeCli(['snapshot', '-i'], ctx.baseFlags);
}
function runStep(step: ScenarioStep, ctx: IsolationContext, round: number): Sample {
// Untimed reset to root for steps whose precondition is a clean, top-of-list root.
if (step.freshRoot) {
invokeCli(['open', ctx.profile.appTarget, '--relaunch'], ctx.baseFlags);
warmRunner(ctx);
}
const r =
step.execMode === 'standalone'
? invokeCli(step.args, ctx.baseFlags)
: invokeBatchStep(step.step, ctx.baseFlags);
const sample = toSample(r, round);
if (step.execMode === 'batch') {
sample.daemonDurationMs = readBatchStepDurationMs(r);
if (step.isSnapshot) sample.elementCount = countElements(r);
}
return sample;
}
function buildMeasurement(
step: Pick<ScenarioStep, 'command' | 'label' | 'execMode'>,
platform: ResolvedProfile['platform'],
samples: Sample[],
warmupDropped: number,
): Measurement {
const ok = samples.filter((s) => s.ok);
const failures = samples.length - ok.length;
const notes: string[] = [];
if (failures > 0) {
const codes = [...new Set(samples.filter((s) => !s.ok).map((s) => s.errorCode))].join(', ');
notes.push(`${failures}/${samples.length} samples failed: ${codes}`);
}
const num = (xs: (number | undefined)[]) => xs.filter((n): n is number => typeof n === 'number');
return {
command: step.command,
label: step.label,
platform,
execMode: step.execMode,
samples,
warmupDropped,
wallClock: summarize(ok.map((s) => s.wallClockMs)),
daemonDuration: summarize(num(ok.map((s) => s.daemonDurationMs))),
elementCount: summarize(num(ok.map((s) => s.elementCount))),
failures,
notes,
};
}
// Boot the device once and time it. Runs WITHOUT --session so no session lock policy
// applies and the device selectors are honored (selectors are rejected on locked sessions).
function bootOnce(ctx: IsolationContext): Measurement {
log('booting device (no session lock; sampled once)');
const bootFlags = ['--state-dir', ctx.stateDir, ...ctx.profile.platformFlags];
const r = invokeCli(['boot', ...ctx.profile.selectorFlags], bootFlags);
const sample = toSample(r, 0);
return buildMeasurement(
{ command: 'boot', label: 'boot device', execMode: 'standalone' },
ctx.profile.platform,
[sample],
0,
);
}
// Establish the session by opening Settings WITH device selectors (open is the only
// interaction command allowed to carry selectors on a fresh session). Locks the session
// to our device so every later call targets it via --session alone.
function establishSession(ctx: IsolationContext): Measurement {
log('establishing session (open with device selectors)');
const r = invokeCli(['open', ctx.profile.appTarget, ...ctx.profile.selectorFlags], ctx.baseFlags);
const sample = toSample(r, 0);
return buildMeasurement(
{ command: 'open', label: 'open (establish + cold)', execMode: 'standalone' },
ctx.profile.platform,
[sample],
0,
);
}
export function runScenario(ctx: IsolationContext, cfg: PerfConfig): Measurement[] {
const steps = buildSettingsTour(ctx.profile, { artifactsDir: ctx.artifactsDir });
const acc = new Map<string, Sample[]>();
for (const step of steps) acc.set(step.label, []);
const boot = bootOnce(ctx);
const establish = establishSession(ctx);
// Absorb the one-time runner startup before any round so it isn't charged to a measurement.
warmRunner(ctx);
// Android accessibility dumps time out while the UI is animating; disable animations
// up front (untimed) so snapshot/get/is/fill can read an idle hierarchy.
if (ctx.profile.platform === 'android') {
log('disabling animations (android)');
invokeCli(['settings', 'animations', 'off'], ctx.baseFlags);
}
const totalRounds = cfg.warmup + cfg.rounds;
for (let round = 0; round < totalRounds; round++) {
const measured = round >= cfg.warmup;
log(`round ${round + 1}/${totalRounds}${measured ? '' : ' (warmup, dropped)'}`);
for (const step of steps) {
const sample = runStep(step, ctx, round);
if (measured) acc.get(step.label)!.push(sample);
// After the round's reset-open relaunch, warm the runner (untimed) so the first measured
// read (snapshot -i) doesn't pay the post-relaunch first-AX-query cost.
if (step.command === 'open' && step.execMode === 'standalone') {
warmRunner(ctx);
}
}
}
const tourMeasurements = steps.map((step) =>
buildMeasurement(step, ctx.profile.platform, acc.get(step.label)!, cfg.warmup),
);
return [boot, establish, ...tourMeasurements];
}