mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
d26b0786fb
* perf(ios): derive runner demand for Simulator opens and stop observation from awaiting the runner Local Simulator opens now decide how much the XCTest runner is needed from the runtime operations declared by the steps still ahead in the same batch: an observation-only plan starts no runner, an unknown plan keeps the speculative prewarm without ever awaiting it, and a plan with an interaction prepares readiness for that step. open --relaunch no longer waits for runner readiness on a Simulator and resets the runner target only when a session is already alive. The Apple find ports report not-proven instead of starting a runner on a Simulator without a live session, so wait and read-only find observe through the canonical AX-bridge tree. Physical devices keep their lifecycle unchanged. The plan travels through the server-private internal request channel, never the wire; the Apple owner maps declared operations to a runner demand through a record complete over the runtime operation union. Refs #2198 * test(fixtures): share one inert audio-probe host across the platform runtime fixtures The Apple and Android runtime fixtures carried identical audio-probe doubles; host-kit now owns the one copy and both fixtures import it. Also folds the two Apple native-find ports onto one admission helper and lifts the Simulator runner prewarm policy out of the open sequence, keeping both under the complexity gate. * fix(ios): answer runner liveness through the runner provider seam The find ports and the relaunch target reset asked the local session registry whether a runner was alive, which misreads scripted and request-scoped runner providers as absent. Liveness is now a provider question: the local provider consults its session registry, a provider without startup cost counts as live, and an awaited prewarm proves liveness without asking. * perf(ios): select plan uses from step input and give young Simulator targets a bounded bridge grace A snapshot, diff, or find step now selects the runtime uses its structured input reaches, the way its handler does, so a plain snapshot no longer counts the custom-actions alternative and an observation-only batch resolves runner demand none. The descriptor declares the selector next to its alternatives; the daemon plan derivation honors it and keeps the union for every other command. Without the runner wait, the first snapshot after an open reached the AX bridge while the app was still becoming the primary foreground owner or registering its accessibility server, and the typed fallback then started the runner the plan had just avoided. A target younger than ten seconds is re-read for a bounded grace measured from the first such failure: five seconds for a missing AX server, one second for an ownership miss so a launch-time system dialog still reaches the fallback quickly. Established targets get no grace. * fix(ios): a registered runner session counts as live only once it has answered A session record exists while xcodebuild is still connecting, so an alive child pid is not a runner that can answer. Treating it as live sent the relaunch target reset into a starting runner, queued behind its connection retries, and the failed reset invalidated the very session the prewarm was building. Liveness now also requires the session's readiness flag, which the first successful runner response sets. * refactor(ios): lift the bridge launch grace out of the snapshot route capture * test(descriptors): pin the snapshot, diff, and find step-use selectors * test: stub runner operations in the replay test-runner suite and keep runner-session tests within the size ratchet A Simulator open schedules a best-effort runner prewarm that outlives its request. The replay test-runner suite opened a Simulator with the real Apple tools, so the prewarm's deferred import resolved after the file finished and spawned into whichever file the worker ran next, where the hermetic signal guard failed an unrelated test. * fix(plan): count only required operations and read find and snapshot steps the way their handlers do Runner demand now counts a command's required operations only: a preferred or conditional operation is a measured fast path the command succeeds without, so get, wait, and read-only find stay observation-only. The step selectors for snapshot, diff, and find live next to the registry and read the daemon step exactly as the handlers do: the daemon flag for custom actions, and find's positionals through the same parser, where a missing action is a click and an unparseable step keeps every declared alternative. The handler and the selector share one action-to-intent map. The batch runner hands each step its remaining steps in handler shape, and the derived operations reach the platform as a typed list on the lifecycle execution instead of an untyped plan on every open. * perf(ios): let open wait for the launched app to become observable, and make runner liveness explicit The snapshot route no longer infers a launch from process start text and retries inside its own capture. Open owns launch timing instead: a local Simulator open asks the AX bridge whether the launched app is observable, bounded by per-code windows measured from the first typed launch-transition failure and never extended, so an ownership miss seen after an AX-server miss shrinks the deadline to the ownership window and a launch-time system dialog still reaches the typed fallback quickly. Any other device, or a bridge that cannot answer, keeps the fixed settle. The open response reports what it learned. Every runner provider now states whether it can answer without a startup wait; a bare executor answers directly by construction and scripted providers say so. The runner prewarm policy and the observation settle move out of the open sequence into their own module, and the native find admission is named for what it admits. * docs(context): keep the runner-demand vocabulary within the guidance budget The enumeration and the no-public-flag rule live on the contract type that owns them; CONTEXT.md keeps the term itself, and two neighbouring entries lose words that carried no meaning. * refactor(contracts): name the runtime operation vocabulary below the operations union The lifecycle execution carries the operations a plan requires, but typing that list with the operations union closed a 36-file type cycle: the operations types depend on the lifecycle types. The vocabulary now lives as a const list below both, proven equal to the union by a type test, so the plan is typed end to end, the Apple host table indexes it without casts, and the daemon narrows descriptor names through a guard instead of a cast. * fix(apple): reach runner liveness through the memoized operations loader Every Apple tool port loads the runner operations through the one memoized loader (#2314): a port that opens its own dynamic import can resolve the unmocked module while a test's mock factory is still loading and let a real local runner escape. The liveness port now uses the loader like its siblings; the facade members consumed only through the loader are declared to fallow, and the plan resolver reads one step per helper to stay under the complexity threshold. * fix(ios): keep bridge-only behavior to iOS Simulators The launch observation, the runner-free find admission, and the relaunch policy apply only where the host AX bridge exists: iOS Simulators. A tvOS Simulator keeps its awaited prewarm and asks for no observation, which the tvOS provider scenario now pins. * bench(ios): add a first-interaction cell to the snapshot convergence harness An open that defers runner readiness moves its cost to the first runner-dependent command. The cell starts each sample like cold, opens the fixture untimed, then times the first press that follows (the deep-link confirmation when the launch URL raises it, otherwise the screen anchor). * bench(ios): read the deep-link confirmation from a snapshot and by node type The open response carries no tree and regular snapshots publish the node type, so the confirmation iOS raises for a launch URL was never seen on this runtime and every deep-linked cell failed its anchor check. * refactor(plan): keep the step-use selectors inside the registry The eager-closure ratchet counts every module the registry loads; the selectors need nothing the registry does not already import, so they live beside find's recording-effect reader instead of adding a module to every entry that loads the registry. * feat(apple): release a speculative runner when the plan is proven observation-only #2198 requires a `none` runner demand to retain no runner, not only to start none. A runner a prewarm started that no command has used yet is speculative: the session records that mark at creation, the first command that is not a readiness probe clears it, and a Simulator open whose plan is proven observation-only asks the runner owner to release a speculative session in the background, so the observation path never waits for a runner to stop either. A runner that has served a command is the session's working runner and stays under the existing idle-stop policy, so a mixed workload does not pay a cold runner start at every observation-only open. The release goes through the runner provider seam: the local provider stops its own speculative session; a provider that never starts speculative work omits the operation and releases nothing. * bench(ios): press an unambiguous target on the catalog and Settings screens The first-interaction cell pressed the screen's anchor text, which on the catalog and iOS Settings screens names two actionable elements (the native tab and the screen title); the CLI refuses that as AMBIGUOUS_MATCH by design, so those two cells could never measure anything. Each such screen now names the element the cell presses. * fix(ios): keep observation on the bridge while app discovery is pending and no runner is live #2331 bounds one capture's wait for the Simulator app discovery and takes the XCTest fallback past it; #2198 stops a Simulator open from awaiting the runner. Together, a `wait` right after a relaunch on a loaded host fell back to XCTest while the runner was still starting, spent its poll budget on that start, and timed out (the iOS smoke lane after the main merge). A capture with no live runner now stays on the single-flight discovery, one wait slice at a time, until the discovery's own deadline or the request signal ends it; a runner that is already live still takes the fallback at once, the cheaper route #2331 chose. * fix(apple): queue a speculative-runner release behind a start that is still in flight A `possible` open's prewarm registers its session only when the start completes, so a `none` open that released in that window found nothing and the runner it meant to release survived as a retained speculative session. The release now takes the runner session lock: it queues behind the in-flight start, sees the registered speculative session, and stops it; a start a command asked for is left alone. Two deferred-start regressions pin both outcomes.
346 lines
12 KiB
TypeScript
346 lines
12 KiB
TypeScript
import { asRecord, readString } from './result-values.ts';
|
|
import { runCli, runCliAsync, type CliContext, type CliResult } from './cli-process.ts';
|
|
import type { Failure, FirstTreeStatus, RawSample, ScreenFixture } from './types.ts';
|
|
|
|
const APP_MOUNT_REASONS = new Set([
|
|
'app_mount_race',
|
|
'app_not_mounted',
|
|
'active_ax_application_missing',
|
|
'first_tree_empty',
|
|
'first_tree_unreadable',
|
|
]);
|
|
const BRIDGE_REASONS = new Set([
|
|
'bridge_unavailable',
|
|
'bridge_request_failed',
|
|
'metro_bridge_failed',
|
|
]);
|
|
const STALE_REASONS = new Set(['ref_frame_expired', 'stale_generation', 'stale_snapshot']);
|
|
const TIMEOUT_REASONS = new Set([
|
|
'request_timeout',
|
|
'command_timeout',
|
|
'prepare_deadline_expired',
|
|
'runner_main_thread_execution_timeout',
|
|
]);
|
|
const RUNNER_REASONS = new Set([
|
|
'runner_health_failed',
|
|
'runner_connect_failed_before_command_send',
|
|
'runner_readiness_preflight_failed_before_command_send',
|
|
'runner_reported_failure',
|
|
'command_still_in_flight',
|
|
]);
|
|
const REASON_CATEGORIES: ReadonlyArray<[Set<string>, Failure['category']]> = [
|
|
[STALE_REASONS, 'stale-generation'],
|
|
[APP_MOUNT_REASONS, 'app-mount'],
|
|
[BRIDGE_REASONS, 'bridge'],
|
|
[TIMEOUT_REASONS, 'timeout'],
|
|
[RUNNER_REASONS, 'runner'],
|
|
];
|
|
const TIMEOUT_CODES = new Set(['TIMEOUT', 'REQUEST_TIMEOUT', 'ETIMEDOUT']);
|
|
|
|
export type { CliContext, CliResult } from './cli-process.ts';
|
|
|
|
export function openFixture(
|
|
context: CliContext,
|
|
fixture: ScreenFixture,
|
|
options: { relaunch?: boolean } = {},
|
|
): CliResult {
|
|
const opened = runCli(context, [
|
|
'open',
|
|
fixture.app,
|
|
...(options.relaunch ? ['--relaunch'] : []),
|
|
...(fixture.launchUrl ? ['--launch-url', fixture.launchUrl] : []),
|
|
'--foreground',
|
|
]);
|
|
if (!fixture.launchUrl || !deepLinkConfirmationShown(context, opened)) return opened;
|
|
const accepted = pressFixtureTarget(context, 'label="Open"');
|
|
if (accepted.ok) return opened;
|
|
return {
|
|
...opened,
|
|
ok: false,
|
|
stderr: [opened.stderr, accepted.stderr].filter(Boolean).join('\n'),
|
|
payload: accepted.payload,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The first runner-dependent command after an open, timed on its own: the open itself is untimed
|
|
* setup (the `cold` and `relaunch` cells measure it). When the launch URL raises the deep-link
|
|
* confirmation, that dialog's `Open` press is the first interaction; otherwise the screen anchor
|
|
* is pressed. Either way the sample records whatever runner readiness the open deferred.
|
|
*/
|
|
export function firstInteractionAfterOpen(context: CliContext, fixture: ScreenFixture): CliResult {
|
|
const opened = runCli(context, [
|
|
'open',
|
|
fixture.app,
|
|
'--relaunch',
|
|
...(fixture.launchUrl ? ['--launch-url', fixture.launchUrl] : []),
|
|
'--foreground',
|
|
]);
|
|
if (!opened.ok) return opened;
|
|
const selector =
|
|
fixture.launchUrl && deepLinkConfirmationShown(context, opened)
|
|
? 'label="Open"'
|
|
: (fixture.interactionTarget ?? `text=${JSON.stringify(fixture.anchorText)}`);
|
|
return pressFixtureTarget(context, selector);
|
|
}
|
|
|
|
export async function openFixtureAsync(
|
|
context: CliContext,
|
|
fixture: ScreenFixture,
|
|
options: { relaunch?: boolean } = {},
|
|
): Promise<CliResult> {
|
|
const opened = await runCliAsync(context, [
|
|
'open',
|
|
fixture.app,
|
|
...(options.relaunch ? ['--relaunch'] : []),
|
|
...(fixture.launchUrl ? ['--launch-url', fixture.launchUrl] : []),
|
|
'--foreground',
|
|
]);
|
|
if (
|
|
!fixture.launchUrl ||
|
|
!(
|
|
hasDeepLinkConfirmation(opened.payload) ||
|
|
hasDeepLinkConfirmation((await snapshotFixtureAsync(context)).payload)
|
|
)
|
|
) {
|
|
return opened;
|
|
}
|
|
const accepted = await pressFixtureTargetAsync(context, 'label="Open"');
|
|
if (accepted.ok) return opened;
|
|
return {
|
|
...opened,
|
|
ok: false,
|
|
stderr: [opened.stderr, accepted.stderr].filter(Boolean).join('\n'),
|
|
payload: accepted.payload,
|
|
};
|
|
}
|
|
|
|
export function snapshotFixture(context: CliContext): CliResult {
|
|
return runCli(context, [
|
|
'batch',
|
|
'--steps',
|
|
JSON.stringify([{ command: 'snapshot', input: { interactiveOnly: true } }]),
|
|
]);
|
|
}
|
|
|
|
export async function snapshotFixtureAsync(context: CliContext): Promise<CliResult> {
|
|
return await runCliAsync(context, [
|
|
'batch',
|
|
'--steps',
|
|
JSON.stringify([{ command: 'snapshot', input: { interactiveOnly: true } }]),
|
|
]);
|
|
}
|
|
|
|
export function pressFixtureTarget(context: CliContext, selector: string): CliResult {
|
|
return runCli(context, ['click', selector]);
|
|
}
|
|
|
|
export function scrollFixtureSetup(context: CliContext): CliResult {
|
|
return runCli(context, ['scroll', 'bottom']);
|
|
}
|
|
|
|
export async function pressFixtureTargetAsync(
|
|
context: CliContext,
|
|
selector: string,
|
|
): Promise<CliResult> {
|
|
return await runCliAsync(context, ['click', selector]);
|
|
}
|
|
|
|
export async function scrollFixtureSetupAsync(context: CliContext): Promise<CliResult> {
|
|
return await runCliAsync(context, ['scroll', 'bottom']);
|
|
}
|
|
|
|
export async function closeSessionAsync(context: CliContext): Promise<void> {
|
|
await runCliAsync(context, ['close']);
|
|
}
|
|
|
|
export function snapshotHasAnchor(payload: unknown, anchorText: string): boolean {
|
|
return snapshotNodes(payload).some((record) => {
|
|
return record.label === anchorText || record.value === anchorText;
|
|
});
|
|
}
|
|
|
|
export function hasDeepLinkConfirmation(payload: unknown): boolean {
|
|
return snapshotNodes(payload).some((record) => {
|
|
// Regular snapshots publish the node `type` ('Alert'); older projections used `role`.
|
|
const role = (readString(record.role) ?? readString(record.type))?.toLowerCase();
|
|
const label = readString(record.label);
|
|
return role === 'alert' && label?.startsWith('Open in ') === true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Whether iOS is asking to confirm the deep link the open just raised. The open response carries
|
|
* no tree, so the dialog is read from a snapshot; the caller decides whether that read is setup or
|
|
* the measured first interaction.
|
|
*/
|
|
function deepLinkConfirmationShown(context: CliContext, opened: CliResult): boolean {
|
|
if (hasDeepLinkConfirmation(opened.payload)) return true;
|
|
return hasDeepLinkConfirmation(snapshotFixture(context).payload);
|
|
}
|
|
|
|
function snapshotNodes(payload: unknown): Record<string, unknown>[] {
|
|
const snapshot = readSnapshotRecord(payload);
|
|
if (!snapshot || !Array.isArray(snapshot.nodes)) return [];
|
|
return snapshot.nodes.flatMap((node) => {
|
|
const record = asRecord(node);
|
|
return record ? [record] : [];
|
|
});
|
|
}
|
|
|
|
export function sampleFromCli(
|
|
result: CliResult,
|
|
operation: RawSample['operation'],
|
|
index: number,
|
|
responseBytes = Buffer.byteLength(result.stdout),
|
|
): RawSample {
|
|
const snapshot = readSnapshot(result.payload);
|
|
const failure = result.ok ? undefined : classifyFailure(result.payload, result);
|
|
const daemonDurationMs = readDaemonDuration(result.payload, operation);
|
|
return {
|
|
index: index + 1,
|
|
startedAt: result.startedAt,
|
|
finishedAt: result.finishedAt,
|
|
operation,
|
|
wallClockMs: result.wallClockMs,
|
|
...(daemonDurationMs === undefined ? {} : { daemonDurationMs }),
|
|
responseBytes,
|
|
...snapshotFields(snapshot),
|
|
targetGeneration: snapshot?.targetGeneration ?? null,
|
|
firstTree: firstTreeStatusForSample(snapshot, failure),
|
|
ok: result.ok,
|
|
outlier: false,
|
|
...(failure ? { failure } : {}),
|
|
};
|
|
}
|
|
|
|
function snapshotFields(snapshot: { nodeCount: number } | undefined): { nodeCount?: number } {
|
|
return snapshot ? { nodeCount: snapshot.nodeCount } : {};
|
|
}
|
|
|
|
function firstTreeStatusForSample(
|
|
snapshot: { nodeCount: number } | undefined,
|
|
failure: Failure | undefined,
|
|
): FirstTreeStatus {
|
|
if (snapshot) return snapshot.nodeCount === 0 ? 'empty' : 'readable';
|
|
if (failure?.reason === 'first_tree_empty') return 'empty';
|
|
return failure?.category === 'app-mount' ? 'unreadable' : 'not-observed';
|
|
}
|
|
|
|
export function classifyFailure(
|
|
payload: unknown,
|
|
result?: Pick<CliResult, 'stderr' | 'spawnErrorCode'>,
|
|
): Failure {
|
|
const error = readError(payload);
|
|
const code = readString(error?.code) ?? result?.spawnErrorCode;
|
|
const details = asRecord(error?.details);
|
|
const reason = readString(details?.reason);
|
|
const category = failureCategory(code, reason);
|
|
const message = readString(error?.message) ?? readString(result?.stderr);
|
|
return {
|
|
category,
|
|
...(code ? { code } : {}),
|
|
...(reason ? { reason } : {}),
|
|
...(message ? { message: message.slice(0, 240) } : {}),
|
|
};
|
|
}
|
|
|
|
export function formatCliFailure(
|
|
operation: string,
|
|
failure: Failure,
|
|
result: Pick<CliResult, 'stderr' | 'exitCode'>,
|
|
): string {
|
|
return `${operation} failed [${failureLabel(failure)}]: ${failureMessage(failure, result)}`;
|
|
}
|
|
|
|
function failureLabel(failure: Failure): string {
|
|
return failure.code ? `${failure.category}/${failure.code}` : failure.category;
|
|
}
|
|
|
|
function failureMessage(failure: Failure, result: Pick<CliResult, 'stderr' | 'exitCode'>): string {
|
|
if (failure.message) return failure.message;
|
|
if (result.stderr.trim()) return result.stderr.trim();
|
|
return `exit ${result.exitCode}`;
|
|
}
|
|
|
|
export function firstTreeStatus(payload: unknown): FirstTreeStatus {
|
|
const snapshot = readSnapshot(payload);
|
|
if (snapshot) return snapshot.nodeCount === 0 ? 'empty' : 'readable';
|
|
const error = readError(payload);
|
|
const reason = readString(asRecord(error?.details)?.reason);
|
|
return firstTreeStatusFromReason(reason);
|
|
}
|
|
|
|
function firstTreeStatusFromReason(reason: string | undefined): FirstTreeStatus {
|
|
if (reason === 'first_tree_empty') return 'empty';
|
|
return APP_MOUNT_REASONS.has(reason ?? '') ? 'unreadable' : 'not-observed';
|
|
}
|
|
|
|
function readDaemonDuration(
|
|
payload: unknown,
|
|
operation: RawSample['operation'],
|
|
): number | undefined {
|
|
const record = asRecord(payload);
|
|
const data = asRecord(record?.data);
|
|
if (operation === 'snapshot') {
|
|
const results = Array.isArray(data?.results) ? data.results : [];
|
|
const first = asRecord(results[0]);
|
|
return readFiniteNumber(first?.durationMs);
|
|
}
|
|
return readFiniteNumber(asRecord(data?.startup)?.durationMs);
|
|
}
|
|
|
|
function readSnapshot(
|
|
payload: unknown,
|
|
): { nodeCount: number; targetGeneration: number | null } | undefined {
|
|
const snapshot = readSnapshotRecord(payload);
|
|
if (!snapshot) return undefined;
|
|
const nodeCount = readSnapshotNodeCount(snapshot);
|
|
if (nodeCount === undefined) return undefined;
|
|
const generation = readFiniteNumber(snapshot.refsGeneration ?? snapshot.targetGeneration);
|
|
return { nodeCount, targetGeneration: generation ?? null };
|
|
}
|
|
|
|
function readSnapshotRecord(payload: unknown): Record<string, unknown> | undefined {
|
|
const record = asRecord(payload);
|
|
const data = asRecord(record?.data) ?? record;
|
|
const batchResults = Array.isArray(data?.results) ? data.results : [];
|
|
const firstResult = asRecord(batchResults[0]);
|
|
const stepData = asRecord(firstResult?.data) ?? data;
|
|
return asRecord(stepData?.snapshot) ?? stepData;
|
|
}
|
|
|
|
function readSnapshotNodeCount(snapshot: Record<string, unknown>): number | undefined {
|
|
const nodes = snapshot.nodes;
|
|
return Array.isArray(nodes) ? nodes.length : readFiniteNumber(snapshot.nodeCount);
|
|
}
|
|
|
|
function failureCategory(
|
|
code: string | undefined,
|
|
reason: string | undefined,
|
|
): Failure['category'] {
|
|
const category = REASON_CATEGORIES.find(
|
|
([reasons]) => reason !== undefined && reasons.has(reason),
|
|
);
|
|
return category?.[1] ?? (code && TIMEOUT_CODES.has(code) ? 'timeout' : 'other');
|
|
}
|
|
|
|
function readError(
|
|
payload: unknown,
|
|
): { code?: unknown; message?: string; details?: unknown } | undefined {
|
|
const record = asRecord(payload);
|
|
const direct = asRecord(record?.error);
|
|
if (direct) {
|
|
return { code: direct.code, message: readString(direct.message), details: direct.details };
|
|
}
|
|
const initial = asRecord(asRecord(record?.data)?.initialSnapshotError);
|
|
if (initial) {
|
|
return { code: initial.code, message: readString(initial.message), details: initial.details };
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function readFiniteNumber(value: unknown): number | undefined {
|
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
}
|