mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
45cfad5cc5
* 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.
196 lines
7.1 KiB
TypeScript
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];
|
|
}
|