Skip to content

Commit 7c065bc

Browse files
committed
feat: add ios runner prepare command
1 parent 6247a3d commit 7c065bc

15 files changed

Lines changed: 265 additions & 2 deletions

src/client-types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,11 @@ export type ReactNativeCommandOptions = ClientCommandBaseOptions & {
478478
action: 'dismiss-overlay';
479479
};
480480

481+
export type PrepareCommandOptions = ClientCommandBaseOptions & {
482+
action: 'ios-runner';
483+
timeoutMs?: number;
484+
};
485+
481486
export type AgentDeviceCommandClient = {
482487
wait: (options: WaitCommandOptions) => Promise<WaitCommandResult>;
483488
alert: (options?: AlertCommandOptions) => Promise<AlertCommandResult>;
@@ -489,6 +494,7 @@ export type AgentDeviceCommandClient = {
489494
keyboard: (options?: KeyboardCommandOptions) => Promise<KeyboardCommandResult>;
490495
clipboard: (options: ClipboardCommandOptions) => Promise<ClipboardCommandResult>;
491496
reactNative: (options: ReactNativeCommandOptions) => Promise<CommandRequestResult>;
497+
prepare: (options: PrepareCommandOptions) => Promise<CommandRequestResult>;
492498
};
493499

494500
type SelectorSnapshotCommandOptions = Pick<CaptureSnapshotOptions, 'depth' | 'scope' | 'raw'>;

src/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export function createAgentDeviceClient(
9696
keyboard: async (options = {}) => await executeCommand('keyboard', options),
9797
clipboard: async (options) => await executeCommand('clipboard', options),
9898
reactNative: async (options) => await executeCommand('react-native', options),
99+
prepare: async (options) => await executeCommand('prepare', options),
99100
},
100101
devices: {
101102
list: async (options = {}) => {

src/command-catalog.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export const PUBLIC_COMMANDS = {
2626
network: 'network',
2727
open: 'open',
2828
perf: 'perf',
29+
prepare: 'prepare',
2930
press: 'press',
3031
push: 'push',
3132
record: 'record',
@@ -141,6 +142,7 @@ const CAPABILITY_EXEMPT_CLI_COMMANDS = commandSet(
141142
LOCAL_CLI_COMMANDS.reactDevtools,
142143
LOCAL_CLI_COMMANDS.session,
143144
PUBLIC_COMMANDS.appState,
145+
PUBLIC_COMMANDS.prepare,
144146
PUBLIC_COMMANDS.batch,
145147
PUBLIC_COMMANDS.devices,
146148
PUBLIC_COMMANDS.gesture,
@@ -156,6 +158,7 @@ export const DAEMON_COMMAND_GROUPS = {
156158
PUBLIC_COMMANDS.apps,
157159
),
158160
state: commandSet(PUBLIC_COMMANDS.boot, PUBLIC_COMMANDS.appState),
161+
prepare: commandSet(PUBLIC_COMMANDS.prepare),
159162
observability: commandSet(PUBLIC_COMMANDS.perf, PUBLIC_COMMANDS.logs, PUBLIC_COMMANDS.network),
160163
replay: commandSet(PUBLIC_COMMANDS.replay, PUBLIC_COMMANDS.test),
161164
snapshot: commandSet(
@@ -249,6 +252,7 @@ export const DAEMON_COMMAND_GROUPS = {
249252
PUBLIC_COMMANDS.network,
250253
PUBLIC_COMMANDS.open,
251254
PUBLIC_COMMANDS.perf,
255+
PUBLIC_COMMANDS.prepare,
252256
PUBLIC_COMMANDS.push,
253257
PUBLIC_COMMANDS.reinstall,
254258
PUBLIC_COMMANDS.replay,

src/commands/cli-grammar/apps.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ export const appCliReaders = {
2929
...commonInputFromFlags(flags),
3030
headless: flags.headless,
3131
}),
32+
prepare: (positionals, flags) => ({
33+
...commonInputFromFlags(flags),
34+
action: requiredString(positionals[0], 'prepare requires subcommand'),
35+
timeoutMs: flags.timeoutMs,
36+
}),
3237
open: (positionals, flags) => ({
3338
...commonInputFromFlags(flags),
3439
app: positionals[0],
@@ -72,6 +77,9 @@ export const appCliReaders = {
7277
export const appDaemonWriters = {
7378
devices: direct(PUBLIC_COMMANDS.devices),
7479
boot: direct(PUBLIC_COMMANDS.boot),
80+
prepare: direct(PUBLIC_COMMANDS.prepare, (input) => [
81+
requiredDaemonString(input.action, 'prepare requires subcommand'),
82+
]),
7583
apps: direct(PUBLIC_COMMANDS.apps),
7684
open: direct(PUBLIC_COMMANDS.open, openPositionals),
7785
close: direct(PUBLIC_COMMANDS.close, (input) => optionalString(input.app)),

src/commands/cli-output.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ const cliOutputFormatters: Partial<Record<CommandName, CliOutputFormatter>> = {
8282
is: resultOutput(isCliOutput),
8383
find: resultOutput(findCliOutput),
8484
perf: resultOutput(perfCliOutput),
85+
prepare: messageOutput,
8586
logs: resultOutput(logsCliOutput),
8687
network: resultOutput(networkCliOutput),
8788
record: resultOutput(recordCliOutput),

src/commands/client-command-contracts.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export const clientCommandDefinitions = [
6262
defineExecutableCommand(metadata('react-native'), (client, input) =>
6363
client.command.reactNative(input),
6464
),
65+
defineExecutableCommand(metadata('prepare'), (client, input) => client.command.prepare(input)),
6566
defineExecutableCommand(metadata('replay'), (client, input) => client.replay.run(input)),
6667
defineExecutableCommand(metadata('test'), (client, input) => client.replay.test(input)),
6768
defineExecutableCommand(metadata('perf'), (client, input) => client.observability.perf(input)),

src/commands/client-command-metadata.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,17 @@ const NETWORK_INCLUDE_VALUES = ['summary', 'headers', 'body', 'all'] as const;
3636
const START_STOP_VALUES = ['start', 'stop'] as const;
3737
const REACT_NATIVE_ACTION_VALUES = ['dismiss-overlay'] as const;
3838
const METRO_ACTION_VALUES = ['prepare', 'reload'] as const;
39+
const PREPARE_ACTION_VALUES = ['ios-runner'] as const;
3940

4041
export const clientCommandMetadata = [
4142
defineClientCommandMetadata('devices', {}),
4243
defineClientCommandMetadata('boot', {
4344
headless: booleanField('Boot without showing simulator UI when supported.'),
4445
}),
46+
defineClientCommandMetadata('prepare', {
47+
action: requiredField(enumField(PREPARE_ACTION_VALUES)),
48+
timeoutMs: integerField('Maximum wall-clock time for the prepare command.'),
49+
}),
4550
defineClientCommandMetadata('apps', {
4651
appsFilter: enumField(['user-installed', 'all']),
4752
}),

src/commands/command-descriptions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const COMMAND_DESCRIPTIONS = {
44
apps: 'List installed apps.',
55
session: 'List active sessions.',
66
open: 'Open an app, deep link, URL, or platform surface.',
7+
prepare: 'Prepare platform helper infrastructure.',
78
close: 'Close an app or end the active session.',
89
install: 'Install an app binary.',
910
reinstall: 'Reinstall an app binary.',

src/daemon-client.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,10 @@ export async function sendToDaemon(req: Omit<DaemonRequest, 'token'>): Promise<D
194194

195195
function resolveDaemonRequestTimeoutMs(req: Omit<DaemonRequest, 'token'>): number | undefined {
196196
if (req.command === PUBLIC_COMMANDS.test) return undefined;
197-
if (req.command === PUBLIC_COMMANDS.replay && typeof req.flags?.timeoutMs === 'number') {
197+
if (
198+
(req.command === PUBLIC_COMMANDS.replay || req.command === PUBLIC_COMMANDS.prepare) &&
199+
typeof req.flags?.timeoutMs === 'number'
200+
) {
198201
return req.flags.timeoutMs;
199202
}
200203
return REQUEST_TIMEOUT_MS;

src/daemon/handlers/__tests__/session.test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ vi.mock('../../../platforms/ios/runner-client.ts', async (importOriginal) => {
1818
return {
1919
...actual,
2020
prewarmIosRunnerSession: vi.fn(),
21+
runIosRunnerCommand: vi.fn(async () => ({ currentUptimeMs: 42 })),
2122
stopIosRunnerSession: vi.fn(async () => {}),
2223
};
2324
});
@@ -96,6 +97,7 @@ import { ensureDeviceReady } from '../../device-ready.ts';
9697
import { applyRuntimeHintsToApp, clearRuntimeHintsFromApp } from '../../runtime-hints.ts';
9798
import {
9899
prewarmIosRunnerSession,
100+
runIosRunnerCommand,
99101
stopIosRunnerSession,
100102
} from '../../../platforms/ios/runner-client.ts';
101103
import { runMacOsAlertAction } from '../../../platforms/ios/macos-helper.ts';
@@ -118,6 +120,7 @@ const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady);
118120
const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintsToApp);
119121
const mockClearRuntimeHints = vi.mocked(clearRuntimeHintsFromApp);
120122
const mockPrewarmIosRunnerSession = vi.mocked(prewarmIosRunnerSession);
123+
const mockRunIosRunnerCommand = vi.mocked(runIosRunnerCommand);
121124
const mockStopIosRunner = vi.mocked(stopIosRunnerSession);
122125
const mockDismissMacOsAlert = vi.mocked(runMacOsAlertAction);
123126
const mockSettleSimulator = vi.mocked(settleIosSimulator);
@@ -148,6 +151,8 @@ beforeEach(() => {
148151
mockClearRuntimeHints.mockReset();
149152
mockClearRuntimeHints.mockResolvedValue(undefined);
150153
mockPrewarmIosRunnerSession.mockReset();
154+
mockRunIosRunnerCommand.mockReset();
155+
mockRunIosRunnerCommand.mockResolvedValue({ currentUptimeMs: 42 });
151156
mockStopIosRunner.mockReset();
152157
mockStopIosRunner.mockResolvedValue(undefined);
153158
mockDismissMacOsAlert.mockReset();
@@ -2094,6 +2099,118 @@ test('open iOS URL without app bundle id skips runner prewarm', async () => {
20942099
expect(mockPrewarmIosRunnerSession).not.toHaveBeenCalled();
20952100
});
20962101

2102+
test('prepare ios-runner starts the XCTest runner on an explicit iOS selector', async () => {
2103+
const sessionStore = makeSessionStore();
2104+
const sessionName = 'prepare-ios-runner';
2105+
mockResolveTargetDevice.mockResolvedValue({
2106+
platform: 'ios',
2107+
id: 'sim-1',
2108+
name: 'iPhone 17 Pro',
2109+
kind: 'simulator',
2110+
booted: true,
2111+
});
2112+
2113+
const response = await handleSessionCommands({
2114+
req: {
2115+
token: 't',
2116+
session: sessionName,
2117+
command: 'prepare',
2118+
positionals: ['ios-runner'],
2119+
flags: { platform: 'ios', udid: 'sim-1', timeoutMs: 240000 },
2120+
meta: { requestId: 'prepare-request' },
2121+
},
2122+
sessionName,
2123+
logPath: path.join(os.tmpdir(), 'daemon.log'),
2124+
sessionStore,
2125+
invoke: noopInvoke,
2126+
});
2127+
2128+
expect(response).toBeTruthy();
2129+
expect(response?.ok).toBe(true);
2130+
expect(mockEnsureDeviceReady).toHaveBeenCalledWith(
2131+
expect.objectContaining({ platform: 'ios', id: 'sim-1' }),
2132+
);
2133+
expect(mockRunIosRunnerCommand).toHaveBeenCalledTimes(1);
2134+
expect(mockRunIosRunnerCommand).toHaveBeenCalledWith(
2135+
expect.objectContaining({ platform: 'ios', id: 'sim-1' }),
2136+
{ command: 'uptime' },
2137+
expect.objectContaining({
2138+
logPath: expect.stringMatching(/daemon\.log$/),
2139+
requestId: 'prepare-request',
2140+
}),
2141+
);
2142+
expect((response as any).data).toMatchObject({
2143+
action: 'ios-runner',
2144+
platform: 'ios',
2145+
deviceId: 'sim-1',
2146+
deviceName: 'iPhone 17 Pro',
2147+
kind: 'simulator',
2148+
runner: { currentUptimeMs: 42 },
2149+
message: 'Prepared iOS runner: iPhone 17 Pro',
2150+
});
2151+
expect(sessionStore.get(sessionName)).toBeUndefined();
2152+
});
2153+
2154+
test('prepare ios-runner rejects non-iOS devices', async () => {
2155+
const sessionStore = makeSessionStore();
2156+
mockResolveTargetDevice.mockResolvedValue({
2157+
platform: 'android',
2158+
id: 'emulator-5554',
2159+
name: 'Pixel 9 Pro XL',
2160+
kind: 'emulator',
2161+
booted: true,
2162+
});
2163+
2164+
const response = await handleSessionCommands({
2165+
req: {
2166+
token: 't',
2167+
session: 'prepare-android',
2168+
command: 'prepare',
2169+
positionals: ['ios-runner'],
2170+
flags: { platform: 'android', serial: 'emulator-5554' },
2171+
},
2172+
sessionName: 'prepare-android',
2173+
logPath: path.join(os.tmpdir(), 'daemon.log'),
2174+
sessionStore,
2175+
invoke: noopInvoke,
2176+
});
2177+
2178+
expect(response).toBeTruthy();
2179+
expect(response?.ok).toBe(false);
2180+
if (response && !response.ok) {
2181+
expect(response.error.code).toBe('UNSUPPORTED_OPERATION');
2182+
expect(response.error.message).toBe('prepare ios-runner is only supported on iOS');
2183+
}
2184+
expect(mockRunIosRunnerCommand).not.toHaveBeenCalled();
2185+
});
2186+
2187+
test('prepare requires the ios-runner subcommand', async () => {
2188+
const sessionStore = makeSessionStore();
2189+
2190+
const response = await handleSessionCommands({
2191+
req: {
2192+
token: 't',
2193+
session: 'prepare-invalid',
2194+
command: 'prepare',
2195+
positionals: [],
2196+
flags: { platform: 'ios' },
2197+
},
2198+
sessionName: 'prepare-invalid',
2199+
logPath: path.join(os.tmpdir(), 'daemon.log'),
2200+
sessionStore,
2201+
invoke: noopInvoke,
2202+
});
2203+
2204+
expect(response).toBeTruthy();
2205+
expect(response?.ok).toBe(false);
2206+
if (response && !response.ok) {
2207+
expect(response.error.code).toBe('INVALID_ARGS');
2208+
expect(response.error.message).toBe('prepare requires a subcommand: ios-runner');
2209+
}
2210+
expect(mockResolveTargetDevice).not.toHaveBeenCalled();
2211+
expect(mockRunIosRunnerCommand).not.toHaveBeenCalled();
2212+
});
2213+
20972214
test('open web URL on iOS device session without active app falls back to Safari', async () => {
20982215
const sessionStore = makeSessionStore();
20992216
const sessionName = 'ios-device-session';

0 commit comments

Comments
 (0)