From e0aedd4c3a6f7ca8682ec17031ebbfa330a75dbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 1 Jun 2026 15:49:37 +0200 Subject: [PATCH 1/3] refactor(ios): single CommandTraits table for runner command classification Replace the three hand-maintained switches in RunnerTests+Lifecycle.swift (isInteractionCommand / isReadOnlyCommand / isRunnerLifecycleCommand) with one source of truth: CommandType.traits, an exhaustive switch returning a CommandTraits struct (interaction / readOnly / lifecycle axes), collocated with CommandType in RunnerTests+Models.swift. Pure refactor: every command's classification is reproduced verbatim, and the three predicates become one-line lookups with unchanged signatures, so call sites are untouched. The exhaustive switch makes it a compile error to add a CommandType without classifying it, closing the drift that historically let tapSeries/dragSeries/keyboardReturn fall out of isInteractionCommand. readOnly is a 3-state enum (.always/.never/.conditional); .conditional preserves alert's action-dependent read-only behavior, resolved in isReadOnlyCommand. Classification feeds ADR-0002 session invalidation (the read-only retry that nulls currentApp/currentBundleId), so behavior is intentionally unchanged. Adds the "Runner command traits" term to CONTEXT.md. --- CONTEXT.md | 1 + .../RunnerTests+Lifecycle.swift | 41 +++--------- .../RunnerTests+Models.swift | 63 +++++++++++++++++++ 3 files changed, 72 insertions(+), 33 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index fb96db71b..74c790d4b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -14,6 +14,7 @@ - Modality: broad supported device family, such as mobile, tv, or desktop. - 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. +- 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 daemon-side Command surface. ## Testing Principles diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift index d10dfbd22..5ad33a75b 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift @@ -216,14 +216,14 @@ extension RunnerTests { // MARK: - Command Classification func isReadOnlyCommand(_ command: Command) -> Bool { - switch command.command { - case .interactionFrame, .findText, .readText, .snapshot, .screenshot: + switch command.command.traits.readOnly { + case .always: return true - case .alert: - let action = (command.action ?? "get").lowercased() - return action == "get" - default: + case .never: return false + case .conditional: + // Today only `alert` is conditional: read-only when getting, mutating otherwise. + return (command.action ?? "get").lowercased() == "get" } } @@ -234,36 +234,11 @@ extension RunnerTests { } func isInteractionCommand(_ command: CommandType) -> Bool { - switch command { - case - .tap, - .longPress, - .drag, - .remotePress, - .type, - .swipe, - .back, - .backInApp, - .backSystem, - .rotate, - .appSwitcher, - .keyboardDismiss, - .pinch, - .rotateGesture, - .transformGesture: - return true - default: - return false - } + return command.traits.isInteraction } func isRunnerLifecycleCommand(_ command: CommandType) -> Bool { - switch command { - case .shutdown, .recordStop, .screenshot, .uptime: - return true - default: - return false - } + return command.traits.isLifecycle } // MARK: - Interaction Stabilization diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift index 091798d85..8b15bf55c 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift @@ -34,6 +34,69 @@ enum CommandType: String, Codable { case shutdown } +/// Runner command traits — see CONTEXT.md ("Runner command traits"). +/// +/// Single source of truth for how the runner classifies a command across three +/// independent axes, replacing the three hand-maintained switches that used to live +/// in RunnerTests+Lifecycle.swift (isInteractionCommand / isReadOnlyCommand / +/// isRunnerLifecycleCommand). The classification is load-bearing for ADR-0002 session +/// invalidation: `readOnly` gates the retry that nulls currentApp/currentBundleId. +struct CommandTraits { + /// Whether the command needs the foreground-guard + stabilization preflight before running. + let isInteraction: Bool + /// Whether the command is eligible for the session-invalidating retry. + /// `.conditional` is resolved against the request (alert is read-only only for its `get` action). + let readOnly: ReadOnly + /// Whether the command skips the app-activation preflight entirely. + let isLifecycle: Bool + + enum ReadOnly { + case always + case never + case conditional + } +} + +extension CommandType { + /// The classification for this command. Exhaustive by construction: a new CommandType + /// cannot compile without being classified here, so commands can no longer silently drift + /// out of classification the way the parallel switches allowed. + var traits: CommandTraits { + switch self { + // Interaction commands: require the foreground-guard + stabilization preflight. + case .tap, .longPress, .drag, .remotePress, .type, .swipe, + .back, .backInApp, .backSystem, .rotate, .appSwitcher, + .keyboardDismiss, .pinch, .rotateGesture, .transformGesture: + return CommandTraits(isInteraction: true, readOnly: .never, isLifecycle: false) + + // Read-only reads: eligible for the session-invalidating retry. + case .interactionFrame, .findText, .readText, .snapshot: + return CommandTraits(isInteraction: false, readOnly: .always, isLifecycle: false) + + // Screenshot is both a read and a runner-lifecycle command (skips app-activation preflight). + case .screenshot: + return CommandTraits(isInteraction: false, readOnly: .always, isLifecycle: true) + + // Alert is read-only only for its `get` action (resolved by isReadOnlyCommand). + case .alert: + return CommandTraits(isInteraction: false, readOnly: .conditional, isLifecycle: false) + + // Runner-lifecycle commands: skip the app-activation preflight. + case .recordStop, .uptime, .shutdown: + return CommandTraits(isInteraction: false, readOnly: .never, isLifecycle: true) + + // Normal preflight, not retried. + // NOTE — pre-existing classifications preserved verbatim (candidates for a later, separate + // normalization PR, not this refactor): mouseClick / tapSeries / dragSeries are NOT interaction + // commands; keyboardReturn is NOT an interaction command (unlike its sibling keyboardDismiss); + // querySelector is NOT read-only; recordStart is NOT a lifecycle command. + case .mouseClick, .tapSeries, .dragSeries, .querySelector, + .home, .keyboardReturn, .recordStart: + return CommandTraits(isInteraction: false, readOnly: .never, isLifecycle: false) + } + } +} + struct Command: Codable { let command: CommandType let appBundleId: String? From b0c628a4a9a46b6b351a3388daf278976de56032 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 1 Jun 2026 16:00:32 +0200 Subject: [PATCH 2/3] docs(ios): note CommandTraits.readOnly .conditional is alert-only (review follow-up) --- .../AgentDeviceRunnerUITests/RunnerTests+Models.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift index 8b15bf55c..105a366fa 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift @@ -53,6 +53,9 @@ struct CommandTraits { enum ReadOnly { case always case never + /// Alert-only today. Resolved in `isReadOnlyCommand` with alert's rule (read-only for the + /// `get` action, mutating otherwise). A new `.conditional` command would inherit that rule + /// until the resolver is generalized — give it explicit handling there if its semantics differ. case conditional } } From a8b03eba0abeb664b01d6278623a3b4f6b098346 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 1 Jun 2026 17:07:24 +0200 Subject: [PATCH 3/3] fix(ios): classify tapSeries/dragSeries/keyboardReturn as interaction commands (#643) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ios): classify tapSeries/dragSeries/keyboardReturn as interaction commands tapSeries and dragSeries are the series forms of tap/drag (already interaction commands); keyboardReturn is the sibling of keyboardDismiss (already an interaction command). All three were missing from the historical isInteractionCommand switch — a drift the new CommandTraits table (#642) makes visible. Classifying them as interaction commands gives them the foreground-guard + stabilization preflight that their single-shot/sibling forms already get. Behavior change: these three commands now re-activate a backgrounded target to foreground and pay the stabilization delays before running. Ships separately from the CommandTraits refactor (#642) and should land after that bakes. mouseClick left unchanged: macOS-only and the foreground guard interacts with bespoke macOS activation, so it needs a macOS smoke check first. * test: cover iOS runner series commands in perf harness --- .../RunnerTests+Models.swift | 18 ++-- scripts/perf/scenario.ts | 85 +++++++++++++++++-- 2 files changed, 86 insertions(+), 17 deletions(-) diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift index 105a366fa..6424b0363 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift @@ -67,9 +67,12 @@ extension CommandType { var traits: CommandTraits { switch self { // Interaction commands: require the foreground-guard + stabilization preflight. - case .tap, .longPress, .drag, .remotePress, .type, .swipe, + // tapSeries/dragSeries are the series forms of tap/drag; keyboardReturn is the sibling + // of keyboardDismiss — all three were missing from the historical switch (drift the + // table now prevents) and are classified as interactions here. + case .tap, .tapSeries, .longPress, .drag, .dragSeries, .remotePress, .type, .swipe, .back, .backInApp, .backSystem, .rotate, .appSwitcher, - .keyboardDismiss, .pinch, .rotateGesture, .transformGesture: + .keyboardDismiss, .keyboardReturn, .pinch, .rotateGesture, .transformGesture: return CommandTraits(isInteraction: true, readOnly: .never, isLifecycle: false) // Read-only reads: eligible for the session-invalidating retry. @@ -89,12 +92,11 @@ extension CommandType { return CommandTraits(isInteraction: false, readOnly: .never, isLifecycle: true) // Normal preflight, not retried. - // NOTE — pre-existing classifications preserved verbatim (candidates for a later, separate - // normalization PR, not this refactor): mouseClick / tapSeries / dragSeries are NOT interaction - // commands; keyboardReturn is NOT an interaction command (unlike its sibling keyboardDismiss); - // querySelector is NOT read-only; recordStart is NOT a lifecycle command. - case .mouseClick, .tapSeries, .dragSeries, .querySelector, - .home, .keyboardReturn, .recordStart: + // NOTE: mouseClick stays non-interaction for now — it is macOS-only and the foreground + // guard interacts with bespoke macOS activation, so classifying it needs a macOS smoke + // check first (tracked as a follow-up). Also preserved: querySelector is NOT read-only; + // recordStart is NOT a lifecycle command; home/alert remain non-interaction by design. + case .mouseClick, .querySelector, .home, .recordStart: return CommandTraits(isInteraction: false, readOnly: .never, isLifecycle: false) } } diff --git a/scripts/perf/scenario.ts b/scripts/perf/scenario.ts index 4d44b82e2..4ba1338c4 100644 --- a/scripts/perf/scenario.ts +++ b/scripts/perf/scenario.ts @@ -53,35 +53,102 @@ export function buildSettingsTour(p: ResolvedProfile, ctx: StepContext): Scenari const textEntry: ScenarioStep[] = p.selectors.searchEditableAtRoot ? [ // iOS: editable search field exists at root; fill it directly (freshRoot resets scroll). - bat('fill search', 'fill', { command: 'fill', positionals: [s.searchFieldEditable, 'general'] }, { freshRoot: true }), + bat( + 'fill search', + 'fill', + { command: 'fill', positionals: [s.searchFieldEditable, 'general'] }, + { freshRoot: true }, + ), bat('type', 'type', { command: 'type', positionals: ['wifi'] }), - bat('get editable text', 'get', { command: 'get', positionals: ['text', s.searchFieldEditable] }), + bat('get editable text', 'get', { + command: 'get', + positionals: ['text', s.searchFieldEditable], + }), + bat('keyboard return', 'keyboard', { command: 'keyboard', positionals: ['return'] }), ] : [ // Android: tap the search entry first to reveal the editable, then type/fill it. - bat('press search field', 'press', { command: 'press', positionals: [s.searchField] }, { freshRoot: true }), + bat( + 'press search field', + 'press', + { command: 'press', positionals: [s.searchField] }, + { freshRoot: true }, + ), bat('type', 'type', { command: 'type', positionals: ['wifi'] }), - bat('fill search', 'fill', { command: 'fill', positionals: [s.searchFieldEditable, 'general'] }), - bat('get editable text', 'get', { command: 'get', positionals: ['text', s.searchFieldEditable] }), + bat('fill search', 'fill', { + command: 'fill', + positionals: [s.searchFieldEditable, 'general'], + }), + bat('get editable text', 'get', { + command: 'get', + positionals: ['text', s.searchFieldEditable], + }), ]; + // These iOS-only repeated gesture forms route to dedicated XCTest runner commands: + // press --count > 1 -> tapSeries; swipe --count > 1 -> dragSeries. + const iosRunnerSeries: ScenarioStep[] = + p.platform === 'ios' + ? [ + bat( + 'press series (tapSeries)', + 'press', + { command: 'press', positionals: ['200', '95'], flags: { count: 2, intervalMs: 50 } }, + { freshRoot: true }, + ), + bat( + 'swipe series (dragSeries)', + 'swipe', + { + command: 'swipe', + positionals: ['200', '650', '200', '450', '120'], + flags: { count: 2, pauseMs: 50, pattern: 'ping-pong' }, + }, + { freshRoot: true }, + ), + ] + : []; + return [ // --- reset to root via relaunch --- std('open (relaunch → root)', 'open', ['open', p.appTarget, '--relaunch']), // --- reads on the root tree (snapshots first; anchor label is visible here) --- - bat('snapshot -i (root)', 'snapshot', { command: 'snapshot', flags: { snapshotInteractiveOnly: true } }, { isSnapshot: true }), + bat( + 'snapshot -i (root)', + 'snapshot', + { command: 'snapshot', flags: { snapshotInteractiveOnly: true } }, + { isSnapshot: true }, + ), bat('snapshot (root)', 'snapshot', { command: 'snapshot' }, { isSnapshot: true }), // --- navigate into a sub-screen from a fresh root (freshRoot resets scroll so the // deep-screen row is in view), read it, then return --- - bat('press → deep screen', 'press', { command: 'press', positionals: [s.deepScreen] }, { freshRoot: true }), + bat( + 'press → deep screen', + 'press', + { command: 'press', positionals: [s.deepScreen] }, + { freshRoot: true }, + ), bat('snapshot (deep)', 'snapshot', { command: 'snapshot' }, { isSnapshot: true }), - bat('snapshot -i (deep)', 'snapshot', { command: 'snapshot', flags: { snapshotInteractiveOnly: true } }, { isSnapshot: true }), + bat( + 'snapshot -i (deep)', + 'snapshot', + { command: 'snapshot', flags: { snapshotInteractiveOnly: true } }, + { isSnapshot: true }, + ), bat('back', 'back', { command: 'back' }), + // --- iOS runner series commands surfaced by PR #643 --- + ...iosRunnerSeries, + // --- targeted reads against the visible anchor (freshRoot so the anchor is on screen) --- - bat('wait text', 'wait', { command: 'wait', positionals: ['text', s.anchorText, '3000'] }, { freshRoot: true }), + bat( + 'wait text', + 'wait', + { command: 'wait', positionals: ['text', s.anchorText, '3000'] }, + { freshRoot: true }, + ), bat('find', 'find', { command: 'find', positionals: [s.anchorText] }), bat('get text', 'get', { command: 'get', positionals: ['text', s.anchorLabel] }), bat('is visible', 'is', { command: 'is', positionals: ['visible', s.anchorLabel] }),