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
7 changes: 7 additions & 0 deletions src/client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ export type CaptureSnapshotOptions = AgentDeviceRequestOverrides &
scope?: string;
raw?: boolean;
forceFull?: boolean;
timeoutMs?: number;
};

export type CaptureSnapshotResult = {
Expand Down Expand Up @@ -478,6 +479,11 @@ export type ReactNativeCommandOptions = ClientCommandBaseOptions & {
action: 'dismiss-overlay';
};

export type PrepareCommandOptions = ClientCommandBaseOptions & {
action: 'ios-runner';
timeoutMs?: number;
};

export type AgentDeviceCommandClient = {
wait: (options: WaitCommandOptions) => Promise<WaitCommandResult>;
alert: (options?: AlertCommandOptions) => Promise<AlertCommandResult>;
Expand All @@ -489,6 +495,7 @@ export type AgentDeviceCommandClient = {
keyboard: (options?: KeyboardCommandOptions) => Promise<KeyboardCommandResult>;
clipboard: (options: ClipboardCommandOptions) => Promise<ClipboardCommandResult>;
reactNative: (options: ReactNativeCommandOptions) => Promise<CommandRequestResult>;
prepare: (options: PrepareCommandOptions) => Promise<CommandRequestResult>;
};

type SelectorSnapshotCommandOptions = Pick<CaptureSnapshotOptions, 'depth' | 'scope' | 'raw'>;
Expand Down
1 change: 1 addition & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export function createAgentDeviceClient(
keyboard: async (options = {}) => await executeCommand('keyboard', options),
clipboard: async (options) => await executeCommand('clipboard', options),
reactNative: async (options) => await executeCommand('react-native', options),
prepare: async (options) => await executeCommand('prepare', options),
},
devices: {
list: async (options = {}) => {
Expand Down
4 changes: 4 additions & 0 deletions src/command-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const PUBLIC_COMMANDS = {
network: 'network',
open: 'open',
perf: 'perf',
prepare: 'prepare',
press: 'press',
push: 'push',
record: 'record',
Expand Down Expand Up @@ -129,6 +130,7 @@ const MCP_UNEXPOSED_CLI_COMMANDS = commandSet(
LOCAL_CLI_COMMANDS.disconnect,
LOCAL_CLI_COMMANDS.mcp,
LOCAL_CLI_COMMANDS.reactDevtools,
PUBLIC_COMMANDS.prepare,
);

const CAPABILITY_EXEMPT_CLI_COMMANDS = commandSet(
Expand All @@ -141,6 +143,7 @@ const CAPABILITY_EXEMPT_CLI_COMMANDS = commandSet(
LOCAL_CLI_COMMANDS.reactDevtools,
LOCAL_CLI_COMMANDS.session,
PUBLIC_COMMANDS.appState,
PUBLIC_COMMANDS.prepare,
PUBLIC_COMMANDS.batch,
PUBLIC_COMMANDS.devices,
PUBLIC_COMMANDS.gesture,
Expand Down Expand Up @@ -249,6 +252,7 @@ export const DAEMON_COMMAND_GROUPS = {
PUBLIC_COMMANDS.network,
PUBLIC_COMMANDS.open,
PUBLIC_COMMANDS.perf,
PUBLIC_COMMANDS.prepare,
PUBLIC_COMMANDS.push,
PUBLIC_COMMANDS.reinstall,
PUBLIC_COMMANDS.replay,
Expand Down
4 changes: 4 additions & 0 deletions src/commands/__tests__/command-surface-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,7 @@ test('MCP exposed command names have metadata and executable command definitions
assert.ok(executableNames.has(name), `${name} must have an executable command definition`);
}
});

test('CI-only prepare command stays out of MCP tool surface', () => {
assert.equal(listMcpExposedCommandNames().includes('prepare'), false);
});
8 changes: 8 additions & 0 deletions src/commands/cli-grammar/apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ export const appCliReaders = {
...commonInputFromFlags(flags),
headless: flags.headless,
}),
prepare: (positionals, flags) => ({
...commonInputFromFlags(flags),
action: requiredString(positionals[0], 'prepare requires subcommand'),
timeoutMs: flags.timeoutMs,
}),
open: (positionals, flags) => ({
...commonInputFromFlags(flags),
app: positionals[0],
Expand Down Expand Up @@ -72,6 +77,9 @@ export const appCliReaders = {
export const appDaemonWriters = {
devices: direct(PUBLIC_COMMANDS.devices),
boot: direct(PUBLIC_COMMANDS.boot),
prepare: direct(PUBLIC_COMMANDS.prepare, (input) => [
requiredDaemonString(input.action, 'prepare requires subcommand'),
]),
apps: direct(PUBLIC_COMMANDS.apps),
open: direct(PUBLIC_COMMANDS.open, openPositionals),
close: direct(PUBLIC_COMMANDS.close, (input) => optionalString(input.app)),
Expand Down
1 change: 1 addition & 0 deletions src/commands/cli-grammar/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export const captureCliReaders = {
scope: flags.snapshotScope,
raw: flags.snapshotRaw,
forceFull: flags.snapshotForceFull,
timeoutMs: flags.timeoutMs,
}),
screenshot: (positionals, flags) => ({
...commonInputFromFlags(flags),
Expand Down
1 change: 1 addition & 0 deletions src/commands/cli-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ const cliOutputFormatters: Partial<Record<CommandName, CliOutputFormatter>> = {
is: resultOutput(isCliOutput),
find: resultOutput(findCliOutput),
perf: resultOutput(perfCliOutput),
prepare: messageOutput,
logs: resultOutput(logsCliOutput),
network: resultOutput(networkCliOutput),
record: resultOutput(recordCliOutput),
Expand Down
1 change: 1 addition & 0 deletions src/commands/client-command-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export const clientCommandDefinitions = [
defineExecutableCommand(metadata('react-native'), (client, input) =>
client.command.reactNative(input),
),
defineExecutableCommand(metadata('prepare'), (client, input) => client.command.prepare(input)),
defineExecutableCommand(metadata('replay'), (client, input) => client.replay.run(input)),
defineExecutableCommand(metadata('test'), (client, input) => client.replay.test(input)),
defineExecutableCommand(metadata('perf'), (client, input) => client.observability.perf(input)),
Expand Down
6 changes: 6 additions & 0 deletions src/commands/client-command-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,17 @@ const NETWORK_INCLUDE_VALUES = ['summary', 'headers', 'body', 'all'] as const;
const START_STOP_VALUES = ['start', 'stop'] as const;
const REACT_NATIVE_ACTION_VALUES = ['dismiss-overlay'] as const;
const METRO_ACTION_VALUES = ['prepare', 'reload'] as const;
const PREPARE_ACTION_VALUES = ['ios-runner'] as const;

export const clientCommandMetadata = [
defineClientCommandMetadata('devices', {}),
defineClientCommandMetadata('boot', {
headless: booleanField('Boot without showing simulator UI when supported.'),
}),
defineClientCommandMetadata('prepare', {
action: requiredField(enumField(PREPARE_ACTION_VALUES)),
timeoutMs: integerField('Maximum wall-clock time for the prepare command.'),
}),
defineClientCommandMetadata('apps', {
appsFilter: enumField(['user-installed', 'all']),
}),
Expand Down Expand Up @@ -100,6 +105,7 @@ export const clientCommandMetadata = [
scope: stringField(),
raw: booleanField(),
forceFull: booleanField(),
timeoutMs: integerField('Maximum wall-clock time for the snapshot command.'),
}),
defineClientCommandMetadata('screenshot', {
path: stringField('Output path.'),
Expand Down
1 change: 1 addition & 0 deletions src/commands/command-descriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const COMMAND_DESCRIPTIONS = {
apps: 'List installed apps.',
session: 'List active sessions.',
open: 'Open an app, deep link, URL, or platform surface.',
prepare: 'Prepare platform helper infrastructure.',
close: 'Close an app or end the active session.',
install: 'Install an app binary.',
reinstall: 'Reinstall an app binary.',
Expand Down
24 changes: 21 additions & 3 deletions src/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ type EnsuredDaemon = {
type ResolvedDaemonTransport = 'socket' | 'http';

const REQUEST_TIMEOUT_MS = 90_000;
const SNAPSHOT_REQUEST_TIMEOUT_MS = 30_000;
const PREPARE_REQUEST_TIMEOUT_MS = 240_000;
const DAEMON_STARTUP_TIMEOUT_MS = 15_000;
const DAEMON_STARTUP_ATTEMPTS = 2;
const DAEMON_TAKEOVER_TERM_TIMEOUT_MS = 3000;
Expand Down Expand Up @@ -192,14 +194,26 @@ export async function sendToDaemon(req: Omit<DaemonRequest, 'token'>): Promise<D
}
}

function resolveDaemonRequestTimeoutMs(req: Omit<DaemonRequest, 'token'>): number | undefined {
export function resolveDaemonRequestTimeoutMs(
req: Omit<DaemonRequest, 'token'>,
): number | undefined {
if (req.command === PUBLIC_COMMANDS.test) return undefined;
if (req.command === PUBLIC_COMMANDS.replay && typeof req.flags?.timeoutMs === 'number') {
if (typeof req.flags?.timeoutMs === 'number' && isExplicitTimeoutCommand(req.command)) {
return req.flags.timeoutMs;
}
if (req.command === PUBLIC_COMMANDS.prepare) return PREPARE_REQUEST_TIMEOUT_MS;
if (req.command === PUBLIC_COMMANDS.snapshot) return SNAPSHOT_REQUEST_TIMEOUT_MS;
return REQUEST_TIMEOUT_MS;
}

function isExplicitTimeoutCommand(command: string | undefined): boolean {
return (
command === PUBLIC_COMMANDS.prepare ||
command === PUBLIC_COMMANDS.replay ||
command === PUBLIC_COMMANDS.snapshot
);
}

export async function openApp(options: OpenAppOptions = {}): Promise<DaemonResponse> {
const {
session = 'default',
Expand Down Expand Up @@ -1143,7 +1157,11 @@ function resolveRequestTimeoutHint(params: {
return 'Retry with --debug and verify the remote daemon URL, auth token, and remote host logs.';
}
if (!resetDaemon) {
return `Retry with --debug and check daemon diagnostics logs. The timed-out ${command ?? 'request'} request was canceled and iOS runner work was aborted when detected; the daemon was kept alive so the session can still be closed or inspected.`;
const iosPrepareHint =
command === PUBLIC_COMMANDS.snapshot
? ' If this was the first iOS snapshot on the device, run agent-device prepare ios-runner --platform ios before snapshot/test so runner startup is handled explicitly.'
: '';
return `Retry with --debug and check daemon diagnostics logs. The timed-out ${command ?? 'request'} request was canceled and iOS runner work was aborted when detected; the daemon was kept alive so the session can still be closed or inspected.${iosPrepareHint}`;
}
return 'Retry with --debug and check daemon diagnostics logs. Timed-out iOS runner xcodebuild processes were terminated when detected.';
}
Expand Down
117 changes: 117 additions & 0 deletions src/daemon/handlers/__tests__/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ vi.mock('../../../platforms/ios/runner-client.ts', async (importOriginal) => {
return {
...actual,
prewarmIosRunnerSession: vi.fn(),
runIosRunnerCommand: vi.fn(async () => ({ currentUptimeMs: 42 })),
stopIosRunnerSession: vi.fn(async () => {}),
};
});
Expand Down Expand Up @@ -96,6 +97,7 @@ import { ensureDeviceReady } from '../../device-ready.ts';
import { applyRuntimeHintsToApp, clearRuntimeHintsFromApp } from '../../runtime-hints.ts';
import {
prewarmIosRunnerSession,
runIosRunnerCommand,
stopIosRunnerSession,
} from '../../../platforms/ios/runner-client.ts';
import { runMacOsAlertAction } from '../../../platforms/ios/macos-helper.ts';
Expand All @@ -118,6 +120,7 @@ const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady);
const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintsToApp);
const mockClearRuntimeHints = vi.mocked(clearRuntimeHintsFromApp);
const mockPrewarmIosRunnerSession = vi.mocked(prewarmIosRunnerSession);
const mockRunIosRunnerCommand = vi.mocked(runIosRunnerCommand);
const mockStopIosRunner = vi.mocked(stopIosRunnerSession);
const mockDismissMacOsAlert = vi.mocked(runMacOsAlertAction);
const mockSettleSimulator = vi.mocked(settleIosSimulator);
Expand Down Expand Up @@ -148,6 +151,8 @@ beforeEach(() => {
mockClearRuntimeHints.mockReset();
mockClearRuntimeHints.mockResolvedValue(undefined);
mockPrewarmIosRunnerSession.mockReset();
mockRunIosRunnerCommand.mockReset();
mockRunIosRunnerCommand.mockResolvedValue({ currentUptimeMs: 42 });
mockStopIosRunner.mockReset();
mockStopIosRunner.mockResolvedValue(undefined);
mockDismissMacOsAlert.mockReset();
Expand Down Expand Up @@ -2094,6 +2099,118 @@ test('open iOS URL without app bundle id skips runner prewarm', async () => {
expect(mockPrewarmIosRunnerSession).not.toHaveBeenCalled();
});

test('prepare ios-runner starts the XCTest runner on an explicit iOS selector', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'prepare-ios-runner';
mockResolveTargetDevice.mockResolvedValue({
platform: 'ios',
id: 'sim-1',
name: 'iPhone 17 Pro',
kind: 'simulator',
booted: true,
});

const response = await handleSessionCommands({
req: {
token: 't',
session: sessionName,
command: 'prepare',
positionals: ['ios-runner'],
flags: { platform: 'ios', udid: 'sim-1', timeoutMs: 240000 },
meta: { requestId: 'prepare-request' },
},
sessionName,
logPath: path.join(os.tmpdir(), 'daemon.log'),
sessionStore,
invoke: noopInvoke,
});

expect(response).toBeTruthy();
expect(response?.ok).toBe(true);
expect(mockEnsureDeviceReady).toHaveBeenCalledWith(
expect.objectContaining({ platform: 'ios', id: 'sim-1' }),
);
expect(mockRunIosRunnerCommand).toHaveBeenCalledTimes(1);
expect(mockRunIosRunnerCommand).toHaveBeenCalledWith(
expect.objectContaining({ platform: 'ios', id: 'sim-1' }),
{ command: 'uptime' },
expect.objectContaining({
logPath: expect.stringMatching(/daemon\.log$/),
requestId: 'prepare-request',
}),
);
expect((response as any).data).toMatchObject({
action: 'ios-runner',
platform: 'ios',
deviceId: 'sim-1',
deviceName: 'iPhone 17 Pro',
kind: 'simulator',
runner: { currentUptimeMs: 42 },
message: 'Prepared iOS runner: iPhone 17 Pro',
});
expect(sessionStore.get(sessionName)).toBeUndefined();
});

test('prepare ios-runner rejects non-iOS devices', async () => {
const sessionStore = makeSessionStore();
mockResolveTargetDevice.mockResolvedValue({
platform: 'android',
id: 'emulator-5554',
name: 'Pixel 9 Pro XL',
kind: 'emulator',
booted: true,
});

const response = await handleSessionCommands({
req: {
token: 't',
session: 'prepare-android',
command: 'prepare',
positionals: ['ios-runner'],
flags: { platform: 'android', serial: 'emulator-5554' },
},
sessionName: 'prepare-android',
logPath: path.join(os.tmpdir(), 'daemon.log'),
sessionStore,
invoke: noopInvoke,
});

expect(response).toBeTruthy();
expect(response?.ok).toBe(false);
if (response && !response.ok) {
expect(response.error.code).toBe('UNSUPPORTED_OPERATION');
expect(response.error.message).toBe('prepare ios-runner is only supported on iOS');
}
expect(mockRunIosRunnerCommand).not.toHaveBeenCalled();
});

test('prepare requires the ios-runner subcommand', async () => {
const sessionStore = makeSessionStore();

const response = await handleSessionCommands({
req: {
token: 't',
session: 'prepare-invalid',
command: 'prepare',
positionals: [],
flags: { platform: 'ios' },
},
sessionName: 'prepare-invalid',
logPath: path.join(os.tmpdir(), 'daemon.log'),
sessionStore,
invoke: noopInvoke,
});

expect(response).toBeTruthy();
expect(response?.ok).toBe(false);
if (response && !response.ok) {
expect(response.error.code).toBe('INVALID_ARGS');
expect(response.error.message).toBe('prepare requires a subcommand: ios-runner');
}
expect(mockResolveTargetDevice).not.toHaveBeenCalled();
expect(mockRunIosRunnerCommand).not.toHaveBeenCalled();
});

test('open web URL on iOS device session without active app falls back to Safari', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'ios-device-session';
Expand Down
Loading
Loading