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