Files
callstack__agent-device/scripts/perf/scenario.ts
Michał Pierzchała 5492cf4642 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
2026-06-01 18:11:04 +02:00

172 lines
6.7 KiB
TypeScript

import path from 'node:path';
import type { ResolvedProfile } from './platform-profiles.ts';
// A legacy-form batch step: maps through the exact documented CLI grammar.
// `flags` uses internal CliFlags field names (e.g. snapshotInteractiveOnly).
export type BatchStepSpec = {
command: string;
positionals?: string[];
flags?: Record<string, unknown>;
};
type ScenarioStepBase = {
label: string;
command: string;
// When set, the harness runs an untimed `open --relaunch` (reset to root, top of list)
// before timing this step. Used for steps whose precondition is a clean root, since
// earlier commands (find/is, search) leave the list scrolled or in a different surface.
freshRoot?: boolean;
};
// Discriminated on execMode so the invoker gets the right payload without `!`/`?? []`:
// standalone carries full CLI args; batch carries one legacy batch step.
export type ScenarioStep =
| (ScenarioStepBase & { execMode: 'standalone'; args: string[] })
| (ScenarioStepBase & { execMode: 'batch'; step: BatchStepSpec; isSnapshot?: boolean });
export type StepContext = { artifactsDir: string };
function std(label: string, command: string, args: string[]): ScenarioStep {
return { label, command, execMode: 'standalone', args };
}
function bat(
label: string,
command: string,
step: BatchStepSpec,
opts: { isSnapshot?: boolean; freshRoot?: boolean } = {},
): ScenarioStep {
return { label, command, execMode: 'batch' as const, step, ...opts };
}
// One ordered pass over Settings. The harness repeats this N (+warmup) times;
// the leading `open --relaunch` resets the app to its root each round, so every
// round starts from a known state while commands run in their natural order.
export function buildSettingsTour(p: ResolvedProfile, ctx: StepContext): ScenarioStep[] {
const s = p.selectors;
const shot = path.join(ctx.artifactsDir, 'shot.png');
const rec = path.join(ctx.artifactsDir, 'rec.mp4');
const trace = path.join(ctx.artifactsDir, 'trace.log');
// Text entry differs per platform: iOS fills the root search field directly (focusing it
// first can hang); Android must open the search screen before an editable field exists.
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('type', 'type', { command: 'type', positionals: ['wifi'] }),
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('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],
}),
];
// 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 (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('snapshot (deep)', 'snapshot', { command: 'snapshot' }, { 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('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] }),
// --- text entry (platform-specific order; see textEntry above) then scroll results ---
...textEntry,
bat('scroll down', 'scroll', { command: 'scroll', positionals: ['down'] }),
// --- artifact-producing commands; record brackets the rest so the clip has >1s of
// footage (an instant start→stop makes simctl recordVideo fail to finalize) ---
std('record start', 'record', ['record', 'start', rec, '--hide-touches']),
bat('screenshot', 'screenshot', { command: 'screenshot', positionals: [shot] }),
bat('logs mark', 'logs', { command: 'logs', positionals: ['mark', 'perf-mark'] }),
bat('logs clear', 'logs', { command: 'logs', positionals: ['clear'] }),
std('trace start', 'trace', ['trace', 'start', trace]),
std('trace stop', 'trace', ['trace', 'stop']),
bat('perf', 'perf', { command: 'perf' }),
std('record stop', 'record', ['record', 'stop']),
];
}