Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
- Session: daemon-owned state for a selected target and opened app or surface.
- Command surface: catalog of public command identity, interface exposure, adapter policy, and shared command metadata across CLI, Node.js, MCP, and batch entrypoints.
- Daemon command registry: daemon-side source of truth for command route ownership and request-policy traits, including admission exemptions, session locking, selector validation, replay-scoped actions, recording invalidation, Android dialog guards, and request provider device resolution.
- Runner command traits: the iOS XCTest runner's per-command-type classification across three independent axes — interaction (gates the foreground-guard and stabilization preflight), read-only (gates the session-invalidating retry; the alert command is read-only only for its `get` action), and runner-lifecycle (skips the app-activation preflight). One source of truth keyed by command type, distinct from the public command surface and daemon command registry.
- Runner command traits: per-command-type classification for iOS/macOS runner lifecycle behavior, distinct from the public command surface and daemon command registry. The Swift runner traits classify interaction, read-only, and runner-lifecycle axes for XCTest execution; Swift resolves the alert command as read-only only for its `get` action. The TypeScript runner command traits classify daemon-side runner send/recovery policy such as read-only retry routing, readiness probes, and recent-healthy-mutation preflight skips; the TypeScript table is command-type keyed and currently classifies alert as read-only for daemon retry policy. Each side keeps one source of truth keyed by runner command type.
- Coordinate-first resolved element activation: iOS/macOS runner interaction pattern where a selector or text query resolves the semantic `XCUIElement`, then activation uses the element's resolved center coordinate when a frame is available. This keeps target selection semantic while avoiding `XCUIElement.tap()` post-action element re-resolution after normal navigation. tvOS remains focus/remote-driven.
- Snapshot capture plan: per-strategy ordered chain of iOS snapshot capture backends (recursive tree, query sweep, private AX) run by one plan runner under a shared wall-clock budget; recovery ordering is declared data, never a per-call-site branch.
- Snapshot quality verdict: structured outcome (state, backend, reason code, effective depth, collapsed leaves) computed once by the plan runner and shipped with every planned snapshot payload; the daemon and CLI render it instead of re-deriving degradation from node shapes.
Expand Down
8 changes: 4 additions & 4 deletions src/platforms/ios/__tests__/runner-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ vi.mock('../runner-macos-products.ts', async () => {
import type { DeviceInfo } from '../../../utils/device.ts';
import { flushDiagnosticsToSessionFile, withDiagnosticsScope } from '../../../utils/diagnostics.ts';
import { AppError } from '../../../utils/errors.ts';
import type { RunnerCommand } from '../runner-contract.ts';
import { isReadOnlyRunnerCommand, withRunnerCommandId } from '../runner-contract.ts';
import { isReadOnlyRunnerCommand } from '../runner-command-traits.ts';
import { withRunnerCommandId, type RunnerCommand } from '../runner-contract.ts';
import {
assertSafeDerivedCleanup,
isRetryableRunnerError,
Expand Down Expand Up @@ -361,8 +361,8 @@ test('withRunnerCommandId preserves existing command ids', () => {
});

test('scroll is a mutating, command-id-tracked runner command', () => {
// Omission from isReadOnlyRunnerCommand classifies the fused scroll as mutating, routing it
// through single-send (no transport retry), command-id tracking, and status recovery.
// Runner command traits classify fused scroll as mutating, routing it through single-send
// (no transport retry), command-id tracking, and status recovery.
assert.equal(isReadOnlyRunnerCommand('scroll'), false);

const command = withRunnerCommandId({ command: 'scroll', direction: 'down', pixels: 120 });
Expand Down
96 changes: 96 additions & 0 deletions src/platforms/ios/__tests__/runner-command-traits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import type { RunnerCommand } from '../runner-contract.ts';
import {
canSkipRunnerReadinessPreflightAfterHealthyMutation,
isReadOnlyRunnerCommand,
isRunnerReadinessProbeCommand,
readRunnerCommandTraits,
type RunnerCommandTraits,
} from '../runner-command-traits.ts';

const EXPECTED_RUNNER_COMMAND_TRAITS = {
tap: hotMutation(),
mouseClick: defaults(),
longPress: hotMutation(),
drag: hotMutation(),
remotePress: defaults(),
type: defaults(),
swipe: hotMutation(),
scroll: hotMutation(),
findText: readOnly(),
querySelector: readOnly(),
readText: readOnly(),
snapshot: readOnly(),
screenshot: readOnly(),
back: defaults(),
backInApp: defaults(),
backSystem: defaults(),
home: defaults(),
rotate: defaults(),
rotateGesture: defaults(),
transformGesture: defaults(),
appSwitcher: defaults(),
keyboardDismiss: defaults(),
keyboardReturn: defaults(),
alert: readOnly(),
pinch: defaults(),
sequence: hotMutation(),
recordStart: defaults(),
recordStop: defaults(),
status: readOnlyReadinessProbe(),
uptime: readOnlyReadinessProbe(),
shutdown: defaults(),
} satisfies Record<RunnerCommand['command'], RunnerCommandTraits>;

test('runner command traits classify every runner command in one table', () => {
for (const [command, expectedTraits] of Object.entries(EXPECTED_RUNNER_COMMAND_TRAITS) as Array<
[RunnerCommand['command'], RunnerCommandTraits]
>) {
assert.deepEqual(readRunnerCommandTraits(command), expectedTraits, command);
}
});

test('runner command trait helpers read from the shared trait table', () => {
for (const command of Object.keys(EXPECTED_RUNNER_COMMAND_TRAITS) as Array<
RunnerCommand['command']
>) {
const traits = EXPECTED_RUNNER_COMMAND_TRAITS[command];
assert.equal(isReadOnlyRunnerCommand(command), traits.readOnly, command);
assert.equal(isRunnerReadinessProbeCommand(command), traits.readinessProbe, command);
assert.equal(
canSkipRunnerReadinessPreflightAfterHealthyMutation(command),
traits.readinessPreflightSkipEligibleAfterHealthyMutation,
command,
);
}
});

function defaults(): RunnerCommandTraits {
return {
readOnly: false,
readinessProbe: false,
readinessPreflightSkipEligibleAfterHealthyMutation: false,
};
}

function readOnly(): RunnerCommandTraits {
return {
...defaults(),
readOnly: true,
};
}

function readOnlyReadinessProbe(): RunnerCommandTraits {
return {
...readOnly(),
readinessProbe: true,
};
}

function hotMutation(): RunnerCommandTraits {
return {
...defaults(),
readinessPreflightSkipEligibleAfterHealthyMutation: true,
};
}
2 changes: 1 addition & 1 deletion src/platforms/ios/runner-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import { emitDiagnostic } from '../../utils/diagnostics.ts';
import { type RunnerSessionOptions, validateRunnerDevice } from './runner-session.ts';
import {
assertRunnerRequestActive,
isReadOnlyRunnerCommand,
isRetryableRunnerError,
withRunnerCommandId,
type RunnerCommand,
} from './runner-contract.ts';
import { isReadOnlyRunnerCommand } from './runner-command-traits.ts';
import {
createLocalAppleRunnerProvider,
resolveAppleRunnerProvider,
Expand Down
3 changes: 2 additions & 1 deletion src/platforms/ios/runner-command-recovery.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { AppError, toAppErrorCode } from '../../utils/errors.ts';
import type { DeviceInfo } from '../../utils/device.ts';
import { emitDiagnostic } from '../../utils/diagnostics.ts';
import { isReadOnlyRunnerCommand, type RunnerCommand } from './runner-contract.ts';
import type { RunnerCommand } from './runner-contract.ts';
import { isReadOnlyRunnerCommand } from './runner-command-traits.ts';
import type { AppleRunnerCommandOptions } from './runner-provider.ts';
import { executeRunnerCommandWithSession, type RunnerSession } from './runner-session.ts';

Expand Down
86 changes: 86 additions & 0 deletions src/platforms/ios/runner-command-traits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type { RunnerCommand } from './runner-contract.ts';

export type RunnerCommandTraits = Readonly<{
readOnly: boolean;
readinessProbe: boolean;
readinessPreflightSkipEligibleAfterHealthyMutation: boolean;
}>;

const DEFAULT_TRAITS: RunnerCommandTraits = {
readOnly: false,
readinessProbe: false,
readinessPreflightSkipEligibleAfterHealthyMutation: false,
};

const READ_ONLY_TRAITS: RunnerCommandTraits = {
...DEFAULT_TRAITS,
readOnly: true,
};

const READ_ONLY_READINESS_PROBE_TRAITS: RunnerCommandTraits = {
...READ_ONLY_TRAITS,
readinessProbe: true,
};

// Only runner commands this daemon actually sends should become preflight-skip eligible.
// The retired tapSeries/dragSeries/interactionFrame wire commands were removed from both
// daemon and runner; an old daemon paired with a new runner gets a decode rejection and
// rebuilds via the source fingerprint. Keep this set narrow: eligibility is not inferred from
// every mutating or touch command, only commands whose healthy response currently proves enough
// runner/app liveness to skip the next uptime preflight.
const PREFLIGHT_SKIPPABLE_TOUCH_MUTATION_TRAITS: RunnerCommandTraits = {
...DEFAULT_TRAITS,
readinessPreflightSkipEligibleAfterHealthyMutation: true,
};

const RUNNER_COMMAND_TRAITS = {
tap: PREFLIGHT_SKIPPABLE_TOUCH_MUTATION_TRAITS,
mouseClick: DEFAULT_TRAITS,
longPress: PREFLIGHT_SKIPPABLE_TOUCH_MUTATION_TRAITS,
drag: PREFLIGHT_SKIPPABLE_TOUCH_MUTATION_TRAITS,
remotePress: DEFAULT_TRAITS,
type: DEFAULT_TRAITS,
swipe: PREFLIGHT_SKIPPABLE_TOUCH_MUTATION_TRAITS,
scroll: PREFLIGHT_SKIPPABLE_TOUCH_MUTATION_TRAITS,
findText: READ_ONLY_TRAITS,
querySelector: READ_ONLY_TRAITS,
readText: READ_ONLY_TRAITS,
snapshot: READ_ONLY_TRAITS,
screenshot: READ_ONLY_TRAITS,
back: DEFAULT_TRAITS,
backInApp: DEFAULT_TRAITS,
backSystem: DEFAULT_TRAITS,
home: DEFAULT_TRAITS,
rotate: DEFAULT_TRAITS,
rotateGesture: DEFAULT_TRAITS,
transformGesture: DEFAULT_TRAITS,
appSwitcher: DEFAULT_TRAITS,
keyboardDismiss: DEFAULT_TRAITS,
keyboardReturn: DEFAULT_TRAITS,
alert: READ_ONLY_TRAITS,
pinch: DEFAULT_TRAITS,
sequence: PREFLIGHT_SKIPPABLE_TOUCH_MUTATION_TRAITS,
recordStart: DEFAULT_TRAITS,
recordStop: DEFAULT_TRAITS,
status: READ_ONLY_READINESS_PROBE_TRAITS,
uptime: READ_ONLY_READINESS_PROBE_TRAITS,
shutdown: DEFAULT_TRAITS,
} satisfies Record<RunnerCommand['command'], RunnerCommandTraits>;

export function readRunnerCommandTraits(command: RunnerCommand['command']): RunnerCommandTraits {
return RUNNER_COMMAND_TRAITS[command];
}

export function isReadOnlyRunnerCommand(command: RunnerCommand['command']): boolean {
return readRunnerCommandTraits(command).readOnly;
}

export function isRunnerReadinessProbeCommand(command: RunnerCommand['command']): boolean {
return readRunnerCommandTraits(command).readinessProbe;
}

export function canSkipRunnerReadinessPreflightAfterHealthyMutation(
command: RunnerCommand['command'],
): boolean {
return readRunnerCommandTraits(command).readinessPreflightSkipEligibleAfterHealthyMutation;
}
19 changes: 3 additions & 16 deletions src/platforms/ios/runner-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ export type RunnerCommand = {
| 'remotePress'
| 'type'
| 'swipe'
// Fused frame-resolve + drag scroll (non-tvOS). Intentionally mutating: omitted from
// isReadOnlyRunnerCommand so it routes through single-send, command-id tracking, and
// lost-response status recovery like other gestures.
// Fused frame-resolve + drag scroll (non-tvOS). Intentionally mutating in runner command
// traits so it routes through single-send, command-id tracking, and lost-response status
// recovery like other gestures.
| 'scroll'
| 'findText'
| 'querySelector'
Expand Down Expand Up @@ -216,19 +216,6 @@ export function resolveRunnerBuildFailureHint(error: AppError): string {
return resolveSigningFailureHint(error) ?? RUNNER_CACHE_RECOVERY_HINT;
}

export function isReadOnlyRunnerCommand(command: RunnerCommand['command']): boolean {
return (
command === 'snapshot' ||
command === 'screenshot' ||
command === 'findText' ||
command === 'querySelector' ||
command === 'readText' ||
command === 'alert' ||
command === 'status' ||
command === 'uptime'
);
}

export function withRunnerCommandId(command: RunnerCommand): RunnerCommand {
if (command.command === 'status') return command;
if (command.commandId?.trim()) return command;
Expand Down
26 changes: 6 additions & 20 deletions src/platforms/ios/runner-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,12 @@ import {
resolveRunnerDestination,
resolveRunnerMaxConcurrentDestinationsFlag,
} from './runner-xctestrun.ts';
import { withRunnerCommandId, type RunnerCommand } from './runner-contract.ts';
import {
canSkipRunnerReadinessPreflightAfterHealthyMutation,
isReadOnlyRunnerCommand,
withRunnerCommandId,
type RunnerCommand,
} from './runner-contract.ts';
isRunnerReadinessProbeCommand,
} from './runner-command-traits.ts';
import {
buildRunnerLease,
prepareRunnerLeaseForStartup,
Expand Down Expand Up @@ -58,17 +59,6 @@ const runnerSessionLocks = new Map<string, Promise<unknown>>();
const RUNNER_READY_PREFLIGHT_TIMEOUT_MS = 1_000;
const RUNNER_STALE_BUNDLE_UNINSTALL_TIMEOUT_MS = 10_000;
const RUNNER_PREFLIGHT_SKIP_FRESHNESS_MS = 5_000;
// Only commands this daemon actually sends belong here. The retired tapSeries/dragSeries/
// interactionFrame wire commands were removed from both daemon and runner; an old daemon
// paired with a new runner gets a decode rejection and rebuilds via the source fingerprint.
const PREFLIGHT_SKIP_ELIGIBLE_RUNNER_COMMANDS = new Set<RunnerCommand['command']>([
'tap',
'longPress',
'drag',
'swipe',
'scroll',
'sequence',
]);

type RunnerReadinessPreflightDecision =
| {
Expand Down Expand Up @@ -539,7 +529,7 @@ export async function executeRunnerCommandWithSession(
if (runnerFatalReason) {
session.lastHealthyMutation = undefined;
await invalidateRunnerSession(session, runnerFatalReason);
} else if (PREFLIGHT_SKIP_ELIGIBLE_RUNNER_COMMANDS.has(runnerCommand.command)) {
} else if (canSkipRunnerReadinessPreflightAfterHealthyMutation(runnerCommand.command)) {
session.lastHealthyMutation = {
atMs: Date.now(),
appBundleId: runnerCommand.appBundleId,
Expand Down Expand Up @@ -795,7 +785,7 @@ function resolveRunnerReadinessPreflightDecision(
reason: 'readiness_probe_command',
};
}
if (!PREFLIGHT_SKIP_ELIGIBLE_RUNNER_COMMANDS.has(command.command)) {
if (!canSkipRunnerReadinessPreflightAfterHealthyMutation(command.command)) {
return {
action: 'run',
reason: 'conservative_command',
Expand Down Expand Up @@ -829,10 +819,6 @@ function resolveRunnerReadinessPreflightDecision(
};
}

function isRunnerReadinessProbeCommand(command: RunnerCommand['command']): boolean {
return command === 'uptime' || command === 'status';
}

function markRunnerReadinessPreflightError(error: unknown): AppError {
return markRunnerPreflightError(error, {
runnerReadinessPreflightFailed: true,
Expand Down
Loading