Files
2026-06-01 20:10:09 -05:00

204 lines
7.4 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,
};
}
function setupMeasurementFailed(measurement: Measurement): boolean {
return measurement.samples.some((sample) => !sample.ok);
}
// 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);
if (setupMeasurementFailed(boot) || setupMeasurementFailed(establish)) {
log('setup failed; skipping timed tour');
return [boot, establish];
}
// 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];
}