Skip to content

Commit 5492cf4

Browse files
authored
refactor(ios): single CommandTraits table for runner command classification (#642)
* 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. * docs(ios): note CommandTraits.readOnly .conditional is alert-only (review follow-up) * fix(ios): classify tapSeries/dragSeries/keyboardReturn as interaction commands (#643) * 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
1 parent 66ec92f commit 5492cf4

4 files changed

Lines changed: 153 additions & 42 deletions

File tree

CONTEXT.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
- Modality: broad supported device family, such as mobile, tv, or desktop.
1515
- Session: daemon-owned state for a selected target and opened app or surface.
1616
- Command surface: catalog of public command identity, interface exposure, adapter policy, and shared command metadata across CLI, Node.js, MCP, and batch entrypoints.
17+
- 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.
1718

1819
## Testing Principles
1920

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift

Lines changed: 8 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -216,14 +216,14 @@ extension RunnerTests {
216216
// MARK: - Command Classification
217217

218218
func isReadOnlyCommand(_ command: Command) -> Bool {
219-
switch command.command {
220-
case .interactionFrame, .findText, .readText, .snapshot, .screenshot:
219+
switch command.command.traits.readOnly {
220+
case .always:
221221
return true
222-
case .alert:
223-
let action = (command.action ?? "get").lowercased()
224-
return action == "get"
225-
default:
222+
case .never:
226223
return false
224+
case .conditional:
225+
// Today only `alert` is conditional: read-only when getting, mutating otherwise.
226+
return (command.action ?? "get").lowercased() == "get"
227227
}
228228
}
229229

@@ -234,36 +234,11 @@ extension RunnerTests {
234234
}
235235

236236
func isInteractionCommand(_ command: CommandType) -> Bool {
237-
switch command {
238-
case
239-
.tap,
240-
.longPress,
241-
.drag,
242-
.remotePress,
243-
.type,
244-
.swipe,
245-
.back,
246-
.backInApp,
247-
.backSystem,
248-
.rotate,
249-
.appSwitcher,
250-
.keyboardDismiss,
251-
.pinch,
252-
.rotateGesture,
253-
.transformGesture:
254-
return true
255-
default:
256-
return false
257-
}
237+
return command.traits.isInteraction
258238
}
259239

260240
func isRunnerLifecycleCommand(_ command: CommandType) -> Bool {
261-
switch command {
262-
case .shutdown, .recordStop, .screenshot, .uptime:
263-
return true
264-
default:
265-
return false
266-
}
241+
return command.traits.isLifecycle
267242
}
268243

269244
// MARK: - Interaction Stabilization

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,74 @@ enum CommandType: String, Codable {
3434
case shutdown
3535
}
3636

37+
/// Runner command traits — see CONTEXT.md ("Runner command traits").
38+
///
39+
/// Single source of truth for how the runner classifies a command across three
40+
/// independent axes, replacing the three hand-maintained switches that used to live
41+
/// in RunnerTests+Lifecycle.swift (isInteractionCommand / isReadOnlyCommand /
42+
/// isRunnerLifecycleCommand). The classification is load-bearing for ADR-0002 session
43+
/// invalidation: `readOnly` gates the retry that nulls currentApp/currentBundleId.
44+
struct CommandTraits {
45+
/// Whether the command needs the foreground-guard + stabilization preflight before running.
46+
let isInteraction: Bool
47+
/// Whether the command is eligible for the session-invalidating retry.
48+
/// `.conditional` is resolved against the request (alert is read-only only for its `get` action).
49+
let readOnly: ReadOnly
50+
/// Whether the command skips the app-activation preflight entirely.
51+
let isLifecycle: Bool
52+
53+
enum ReadOnly {
54+
case always
55+
case never
56+
/// Alert-only today. Resolved in `isReadOnlyCommand` with alert's rule (read-only for the
57+
/// `get` action, mutating otherwise). A new `.conditional` command would inherit that rule
58+
/// until the resolver is generalized — give it explicit handling there if its semantics differ.
59+
case conditional
60+
}
61+
}
62+
63+
extension CommandType {
64+
/// The classification for this command. Exhaustive by construction: a new CommandType
65+
/// cannot compile without being classified here, so commands can no longer silently drift
66+
/// out of classification the way the parallel switches allowed.
67+
var traits: CommandTraits {
68+
switch self {
69+
// Interaction commands: require the foreground-guard + stabilization preflight.
70+
// tapSeries/dragSeries are the series forms of tap/drag; keyboardReturn is the sibling
71+
// of keyboardDismiss — all three were missing from the historical switch (drift the
72+
// table now prevents) and are classified as interactions here.
73+
case .tap, .tapSeries, .longPress, .drag, .dragSeries, .remotePress, .type, .swipe,
74+
.back, .backInApp, .backSystem, .rotate, .appSwitcher,
75+
.keyboardDismiss, .keyboardReturn, .pinch, .rotateGesture, .transformGesture:
76+
return CommandTraits(isInteraction: true, readOnly: .never, isLifecycle: false)
77+
78+
// Read-only reads: eligible for the session-invalidating retry.
79+
case .interactionFrame, .findText, .readText, .snapshot:
80+
return CommandTraits(isInteraction: false, readOnly: .always, isLifecycle: false)
81+
82+
// Screenshot is both a read and a runner-lifecycle command (skips app-activation preflight).
83+
case .screenshot:
84+
return CommandTraits(isInteraction: false, readOnly: .always, isLifecycle: true)
85+
86+
// Alert is read-only only for its `get` action (resolved by isReadOnlyCommand).
87+
case .alert:
88+
return CommandTraits(isInteraction: false, readOnly: .conditional, isLifecycle: false)
89+
90+
// Runner-lifecycle commands: skip the app-activation preflight.
91+
case .recordStop, .uptime, .shutdown:
92+
return CommandTraits(isInteraction: false, readOnly: .never, isLifecycle: true)
93+
94+
// Normal preflight, not retried.
95+
// NOTE: mouseClick stays non-interaction for now — it is macOS-only and the foreground
96+
// guard interacts with bespoke macOS activation, so classifying it needs a macOS smoke
97+
// check first (tracked as a follow-up). Also preserved: querySelector is NOT read-only;
98+
// recordStart is NOT a lifecycle command; home/alert remain non-interaction by design.
99+
case .mouseClick, .querySelector, .home, .recordStart:
100+
return CommandTraits(isInteraction: false, readOnly: .never, isLifecycle: false)
101+
}
102+
}
103+
}
104+
37105
struct Command: Codable {
38106
let command: CommandType
39107
let appBundleId: String?

scripts/perf/scenario.ts

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -53,35 +53,102 @@ export function buildSettingsTour(p: ResolvedProfile, ctx: StepContext): Scenari
5353
const textEntry: ScenarioStep[] = p.selectors.searchEditableAtRoot
5454
? [
5555
// iOS: editable search field exists at root; fill it directly (freshRoot resets scroll).
56-
bat('fill search', 'fill', { command: 'fill', positionals: [s.searchFieldEditable, 'general'] }, { freshRoot: true }),
56+
bat(
57+
'fill search',
58+
'fill',
59+
{ command: 'fill', positionals: [s.searchFieldEditable, 'general'] },
60+
{ freshRoot: true },
61+
),
5762
bat('type', 'type', { command: 'type', positionals: ['wifi'] }),
58-
bat('get editable text', 'get', { command: 'get', positionals: ['text', s.searchFieldEditable] }),
63+
bat('get editable text', 'get', {
64+
command: 'get',
65+
positionals: ['text', s.searchFieldEditable],
66+
}),
67+
bat('keyboard return', 'keyboard', { command: 'keyboard', positionals: ['return'] }),
5968
]
6069
: [
6170
// Android: tap the search entry first to reveal the editable, then type/fill it.
62-
bat('press search field', 'press', { command: 'press', positionals: [s.searchField] }, { freshRoot: true }),
71+
bat(
72+
'press search field',
73+
'press',
74+
{ command: 'press', positionals: [s.searchField] },
75+
{ freshRoot: true },
76+
),
6377
bat('type', 'type', { command: 'type', positionals: ['wifi'] }),
64-
bat('fill search', 'fill', { command: 'fill', positionals: [s.searchFieldEditable, 'general'] }),
65-
bat('get editable text', 'get', { command: 'get', positionals: ['text', s.searchFieldEditable] }),
78+
bat('fill search', 'fill', {
79+
command: 'fill',
80+
positionals: [s.searchFieldEditable, 'general'],
81+
}),
82+
bat('get editable text', 'get', {
83+
command: 'get',
84+
positionals: ['text', s.searchFieldEditable],
85+
}),
6686
];
6787

88+
// These iOS-only repeated gesture forms route to dedicated XCTest runner commands:
89+
// press --count > 1 -> tapSeries; swipe --count > 1 -> dragSeries.
90+
const iosRunnerSeries: ScenarioStep[] =
91+
p.platform === 'ios'
92+
? [
93+
bat(
94+
'press series (tapSeries)',
95+
'press',
96+
{ command: 'press', positionals: ['200', '95'], flags: { count: 2, intervalMs: 50 } },
97+
{ freshRoot: true },
98+
),
99+
bat(
100+
'swipe series (dragSeries)',
101+
'swipe',
102+
{
103+
command: 'swipe',
104+
positionals: ['200', '650', '200', '450', '120'],
105+
flags: { count: 2, pauseMs: 50, pattern: 'ping-pong' },
106+
},
107+
{ freshRoot: true },
108+
),
109+
]
110+
: [];
111+
68112
return [
69113
// --- reset to root via relaunch ---
70114
std('open (relaunch → root)', 'open', ['open', p.appTarget, '--relaunch']),
71115

72116
// --- reads on the root tree (snapshots first; anchor label is visible here) ---
73-
bat('snapshot -i (root)', 'snapshot', { command: 'snapshot', flags: { snapshotInteractiveOnly: true } }, { isSnapshot: true }),
117+
bat(
118+
'snapshot -i (root)',
119+
'snapshot',
120+
{ command: 'snapshot', flags: { snapshotInteractiveOnly: true } },
121+
{ isSnapshot: true },
122+
),
74123
bat('snapshot (root)', 'snapshot', { command: 'snapshot' }, { isSnapshot: true }),
75124

76125
// --- navigate into a sub-screen from a fresh root (freshRoot resets scroll so the
77126
// deep-screen row is in view), read it, then return ---
78-
bat('press → deep screen', 'press', { command: 'press', positionals: [s.deepScreen] }, { freshRoot: true }),
127+
bat(
128+
'press → deep screen',
129+
'press',
130+
{ command: 'press', positionals: [s.deepScreen] },
131+
{ freshRoot: true },
132+
),
79133
bat('snapshot (deep)', 'snapshot', { command: 'snapshot' }, { isSnapshot: true }),
80-
bat('snapshot -i (deep)', 'snapshot', { command: 'snapshot', flags: { snapshotInteractiveOnly: true } }, { isSnapshot: true }),
134+
bat(
135+
'snapshot -i (deep)',
136+
'snapshot',
137+
{ command: 'snapshot', flags: { snapshotInteractiveOnly: true } },
138+
{ isSnapshot: true },
139+
),
81140
bat('back', 'back', { command: 'back' }),
82141

142+
// --- iOS runner series commands surfaced by PR #643 ---
143+
...iosRunnerSeries,
144+
83145
// --- targeted reads against the visible anchor (freshRoot so the anchor is on screen) ---
84-
bat('wait text', 'wait', { command: 'wait', positionals: ['text', s.anchorText, '3000'] }, { freshRoot: true }),
146+
bat(
147+
'wait text',
148+
'wait',
149+
{ command: 'wait', positionals: ['text', s.anchorText, '3000'] },
150+
{ freshRoot: true },
151+
),
85152
bat('find', 'find', { command: 'find', positionals: [s.anchorText] }),
86153
bat('get text', 'get', { command: 'get', positionals: ['text', s.anchorLabel] }),
87154
bat('is visible', 'is', { command: 'is', positionals: ['visible', s.anchorLabel] }),

0 commit comments

Comments
 (0)