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
1 change: 1 addition & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,74 @@ 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
/// 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
}
}

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.
// 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, .keyboardReturn, .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: 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)
}
}
}

struct Command: Codable {
let command: CommandType
let appBundleId: String?
Expand Down
85 changes: 76 additions & 9 deletions scripts/perf/scenario.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] }),
Expand Down
Loading