From ef4684e7fe3af317699749d59ec284d696ffc0f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 23 Jun 2026 17:52:42 +0200 Subject: [PATCH] refactor: deepen runner command traits --- CONTEXT.md | 2 +- .../ios/__tests__/runner-client.test.ts | 8 +- .../__tests__/runner-command-traits.test.ts | 96 +++++++++++++++++++ src/platforms/ios/runner-client.ts | 2 +- src/platforms/ios/runner-command-recovery.ts | 3 +- src/platforms/ios/runner-command-traits.ts | 86 +++++++++++++++++ src/platforms/ios/runner-contract.ts | 19 +--- src/platforms/ios/runner-session.ts | 26 ++--- 8 files changed, 199 insertions(+), 43 deletions(-) create mode 100644 src/platforms/ios/__tests__/runner-command-traits.test.ts create mode 100644 src/platforms/ios/runner-command-traits.ts diff --git a/CONTEXT.md b/CONTEXT.md index 2d6e19f29..3381cb5a3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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. diff --git a/src/platforms/ios/__tests__/runner-client.test.ts b/src/platforms/ios/__tests__/runner-client.test.ts index 12ff95d89..971f7609a 100644 --- a/src/platforms/ios/__tests__/runner-client.test.ts +++ b/src/platforms/ios/__tests__/runner-client.test.ts @@ -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, @@ -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 }); diff --git a/src/platforms/ios/__tests__/runner-command-traits.test.ts b/src/platforms/ios/__tests__/runner-command-traits.test.ts new file mode 100644 index 000000000..8248c6021 --- /dev/null +++ b/src/platforms/ios/__tests__/runner-command-traits.test.ts @@ -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; + +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, + }; +} diff --git a/src/platforms/ios/runner-client.ts b/src/platforms/ios/runner-client.ts index beee51ffe..dfb323d39 100644 --- a/src/platforms/ios/runner-client.ts +++ b/src/platforms/ios/runner-client.ts @@ -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, diff --git a/src/platforms/ios/runner-command-recovery.ts b/src/platforms/ios/runner-command-recovery.ts index e54d93be8..bb81c8f5b 100644 --- a/src/platforms/ios/runner-command-recovery.ts +++ b/src/platforms/ios/runner-command-recovery.ts @@ -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'; diff --git a/src/platforms/ios/runner-command-traits.ts b/src/platforms/ios/runner-command-traits.ts new file mode 100644 index 000000000..af955ef8e --- /dev/null +++ b/src/platforms/ios/runner-command-traits.ts @@ -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; + +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; +} diff --git a/src/platforms/ios/runner-contract.ts b/src/platforms/ios/runner-contract.ts index 3cd445c13..3cdddfe74 100644 --- a/src/platforms/ios/runner-contract.ts +++ b/src/platforms/ios/runner-contract.ts @@ -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' @@ -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; diff --git a/src/platforms/ios/runner-session.ts b/src/platforms/ios/runner-session.ts index c10007990..8b6e3da18 100644 --- a/src/platforms/ios/runner-session.ts +++ b/src/platforms/ios/runner-session.ts @@ -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, @@ -58,17 +59,6 @@ const runnerSessionLocks = new Map>(); 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([ - 'tap', - 'longPress', - 'drag', - 'swipe', - 'scroll', - 'sequence', -]); type RunnerReadinessPreflightDecision = | { @@ -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, @@ -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', @@ -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,