diff --git a/docs/adr/0001-provider-first-integration-scenarios.md b/docs/adr/0001-provider-first-integration-scenarios.md index a932f960b..96f779732 100644 --- a/docs/adr/0001-provider-first-integration-scenarios.md +++ b/docs/adr/0001-provider-first-integration-scenarios.md @@ -33,7 +33,7 @@ Request provider scoping is descriptor-driven inside the request router layer. A Synchronous host-tool calls are intentionally not part of the provider seam. Any remaining sync Apple helper is local-only and must be converted before a remote/cloud provider can own that path. -Remaining generic Apple host-tool calls are intentionally local-only unless a new adapter creates pressure to promote them: perf process discovery/sampling (`mdfind`, `ps`), launch diagnostics (`plutil`, `otool`), local runner product build/signing (`swift`, `codesign`, sync `plutil`), local simulator UI launch (`open -a Simulator`), plist compatibility fallbacks, and fallback implementations behind semantic macOS providers. Provider-backed integration scenarios should avoid scripting those host commands for user workflows; a user-facing workflow that depends on one of them is naming pressure for a semantic provider method. +Remaining generic Apple host-tool calls are intentionally local-only unless a new adapter creates pressure to promote them: perf process discovery/sampling (`mdfind`, `ps`), launch diagnostics (`plutil`, `otool`), local runner product build/signing (`swift`, `codesign`, sync `plutil`), local simulator UI launch (`open -a "Device Hub"` or `open -a Simulator`), plist compatibility fallbacks, and fallback implementations behind semantic macOS providers. Provider-backed integration scenarios should avoid scripting those host commands for user workflows; a user-facing workflow that depends on one of them is naming pressure for a semantic provider method. ## Alternatives Considered diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index ea5e249d5..2fe4857b8 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -67,11 +67,13 @@ test('apps.open resolves session device identifiers from open response', async ( app: 'Settings', platform: 'ios', relaunch: true, + noDeviceHub: true, }); assert.equal(setup.calls.length, 1); assert.equal(setup.calls[0]?.command, 'open'); assert.deepEqual(setup.calls[0]?.positionals, ['Settings']); + assert.equal(setup.calls[0]?.flags?.noDeviceHub, true); assert.equal(result.identifiers.session, 'qa'); assert.equal(result.identifiers.deviceId, 'SIM-001'); assert.equal(result.identifiers.udid, 'SIM-001'); diff --git a/src/client-normalizers.ts b/src/client-normalizers.ts index 25cd3d796..a4d16ebde 100644 --- a/src/client-normalizers.ts +++ b/src/client-normalizers.ts @@ -282,6 +282,7 @@ export function buildFlags(options: InternalRequestOptions): CommandFlags { relaunch: options.relaunch, shutdown: options.shutdown, saveScript: options.saveScript, + noDeviceHub: options.noDeviceHub, noRecord: options.noRecord, backMode: options.backMode, metroHost: options.metroHost, diff --git a/src/client-types.ts b/src/client-types.ts index ed9ea1bd1..fbeff5646 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -193,6 +193,7 @@ export type AppOpenOptions = AgentDeviceRequestOverrides & launchArgs?: string[]; relaunch?: boolean; saveScript?: boolean | string; + noDeviceHub?: boolean; noRecord?: boolean; runtime?: SessionRuntimeHints; }; @@ -883,6 +884,7 @@ export type InternalRequestOptions = AgentDeviceClientConfig & relaunch?: boolean; shutdown?: boolean; saveScript?: boolean | string; + noDeviceHub?: boolean; noRecord?: boolean; backMode?: BackMode; metroHost?: string; diff --git a/src/commands/cli-grammar/apps.ts b/src/commands/cli-grammar/apps.ts index 7aa390e85..882ce5a1a 100644 --- a/src/commands/cli-grammar/apps.ts +++ b/src/commands/cli-grammar/apps.ts @@ -45,6 +45,7 @@ export const appCliReaders = { launchArgs: flags.launchArgs, relaunch: flags.relaunch, saveScript: flags.saveScript, + noDeviceHub: flags.noDeviceHub, noRecord: flags.noRecord, }), close: (positionals, flags) => ({ diff --git a/src/commands/client-command-metadata.ts b/src/commands/client-command-metadata.ts index 718a232d8..35c213080 100644 --- a/src/commands/client-command-metadata.ts +++ b/src/commands/client-command-metadata.ts @@ -60,6 +60,9 @@ export const clientCommandMetadata = [ ), relaunch: booleanField('Force relaunch.'), saveScript: jsonSchemaField({ oneOf: [booleanSchema(), stringSchema()] }), + noDeviceHub: booleanField( + 'Skip Xcode Device Hub and use the standalone Simulator app when surfacing Apple simulators.', + ), noRecord: booleanField('Do not record this action.'), }), defineClientCommandMetadata('close', { diff --git a/src/daemon-client.ts b/src/daemon-client.ts index 981b8b815..22a9693d7 100644 --- a/src/daemon-client.ts +++ b/src/daemon-client.ts @@ -58,6 +58,7 @@ export type OpenAppOptions = { launchArgs?: NonNullable['launchArgs']; out?: NonNullable['out']; saveScript?: NonNullable['saveScript']; + noDeviceHub?: NonNullable['noDeviceHub']; relaunch?: boolean; runtime?: DaemonRequest['runtime']; meta?: Omit, 'uploadedArtifactId' | 'clientArtifactPaths'>; @@ -230,6 +231,7 @@ export async function openApp(options: OpenAppOptions = {}): Promise { + const device: DeviceInfo = { ...IOS_SIMULATOR, simulatorSetPath: '/tmp/simset-a' }; + + await ensureDeviceReady(device); + await ensureDeviceReady({ ...device }, { focusExisting: true, noDeviceHub: true }); + + expect(mockEnsureBootedSimulator).toHaveBeenCalledTimes(2); + expect(mockEnsureBootedSimulator).toHaveBeenLastCalledWith( + { ...device }, + { focusExisting: true, preferStandalone: true }, + ); }); test('ensureDeviceReady caches successful iOS physical device readiness checks', async () => { diff --git a/src/daemon/device-ready.ts b/src/daemon/device-ready.ts index 05ae9c2c3..5aed29ec7 100644 --- a/src/daemon/device-ready.ts +++ b/src/daemon/device-ready.ts @@ -14,11 +14,19 @@ export const DEVICE_READY_CACHE_TTL_MS = 5_000; const readyCache = new Map(); -export async function ensureDeviceReady(device: DeviceInfo): Promise { +export type DeviceReadyOptions = { + focusExisting?: boolean; + noDeviceHub?: boolean; +}; + +export async function ensureDeviceReady( + device: DeviceInfo, + options: DeviceReadyOptions = {}, +): Promise { const cacheKey = deviceReadyCacheKey(device); const cachedUntil = readyCache.get(cacheKey); if (cachedUntil !== undefined) { - if (cachedUntil > Date.now()) { + if (cachedUntil > Date.now() && !options.focusExisting) { return; } readyCache.delete(cacheKey); @@ -27,7 +35,10 @@ export async function ensureDeviceReady(device: DeviceInfo): Promise { if (device.platform === 'ios') { if (device.kind === 'simulator') { const { ensureBootedSimulator } = await import('../platforms/ios/simulator.ts'); - await ensureBootedSimulator(device); + await ensureBootedSimulator(device, { + focusExisting: options.focusExisting, + preferStandalone: options.noDeviceHub, + }); markDeviceReady(cacheKey); return; } diff --git a/src/daemon/handlers/session-open-prepare.ts b/src/daemon/handlers/session-open-prepare.ts index 9a4409bdb..91b95aa3e 100644 --- a/src/daemon/handlers/session-open-prepare.ts +++ b/src/daemon/handlers/session-open-prepare.ts @@ -108,7 +108,10 @@ export async function prepareOpenCommandDetails(params: { existingSession?: SessionState; }): Promise { const { req, sessionName, sessionStore, device, surface, openTarget, existingSession } = params; - await ensureDeviceReady(device); + await ensureDeviceReady(device, { + focusExisting: true, + noDeviceHub: req.flags?.noDeviceHub === true, + }); const { appBundleId, appName } = await resolvePreparedOpenIdentity({ device, surface, diff --git a/src/platforms/ios/__tests__/index.test.ts b/src/platforms/ios/__tests__/index.test.ts index 28a6426b1..6922cbe7b 100644 --- a/src/platforms/ios/__tests__/index.test.ts +++ b/src/platforms/ios/__tests__/index.test.ts @@ -410,7 +410,7 @@ test('openIosApp custom scheme deep links on iOS devices require app bundle cont ); }); -test('ensureBootedSimulator opens Simulator app after cold boot', async () => { +test('ensureBootedSimulator opens Device Hub after cold boot when available', async () => { let listCallCount = 0; mockRunCmd.mockImplementation(async (cmd, args) => { if (cmd === 'xcrun' && args.join(' ') === 'simctl list devices -j') { @@ -432,23 +432,45 @@ test('ensureBootedSimulator opens Simulator app after cold boot', async () => { if (cmd === 'xcrun' && args.join(' ') === 'simctl bootstatus sim-1 -b') { return { exitCode: 0, stdout: '', stderr: '' }; } - if (cmd === 'open' && args.join(' ') === '-a Simulator') { + if (cmd === 'open' && args.join(' ') === '-a Device Hub') { return { exitCode: 0, stdout: '', stderr: '' }; } throw new Error(`Unexpected command: ${cmd} ${args.join(' ')}`); }); - await ensureBootedSimulator(IOS_TEST_SIMULATOR); + await ensureBootedSimulator(IOS_TEST_SIMULATOR, { focusExisting: true }); assert.equal( mockRunCmd.mock.calls.some( - ([cmd, args]) => cmd === 'open' && args.join(' ') === '-a Simulator', + ([cmd, args]) => cmd === 'open' && args.join(' ') === '-a Device Hub', ), true, ); }); -test('ensureBootedSimulator skips opening Simulator app when already booted', async () => { +test('openIosSimulatorApp falls back to Simulator when Device Hub is unavailable', async () => { + mockRunCmd.mockImplementation(async (cmd, args) => { + if (cmd === 'open' && args.join(' ') === '-a Device Hub') { + return { exitCode: 1, stdout: '', stderr: 'Unable to find application named Device Hub' }; + } + if (cmd === 'open' && args.join(' ') === '-a Simulator') { + return { exitCode: 0, stdout: '', stderr: '' }; + } + throw new Error(`Unexpected command: ${cmd} ${args.join(' ')}`); + }); + + await openIosSimulatorApp(); + + assert.deepEqual( + mockRunCmd.mock.calls.map(([cmd, args]) => [cmd, args.join(' ')]), + [ + ['open', '-a Device Hub'], + ['open', '-a Simulator'], + ], + ); +}); + +test('ensureBootedSimulator opens Device Hub when already booted and available', async () => { mockRunCmd.mockImplementation(async (cmd, args) => { if (cmd === 'xcrun' && args.join(' ') === 'simctl list devices -j') { return { @@ -461,11 +483,20 @@ test('ensureBootedSimulator skips opening Simulator app when already booted', as stderr: '', }; } + if (cmd === 'open' && args.join(' ') === '-a Device Hub') { + return { exitCode: 0, stdout: '', stderr: '' }; + } throw new Error(`Unexpected command: ${cmd} ${args.join(' ')}`); }); - await ensureBootedSimulator(IOS_TEST_SIMULATOR); + await ensureBootedSimulator(IOS_TEST_SIMULATOR, { focusExisting: true }); + assert.equal( + mockRunCmd.mock.calls.some( + ([cmd, args]) => cmd === 'open' && args.join(' ') === '-a Device Hub', + ), + true, + ); assert.equal( mockRunCmd.mock.calls.some( ([cmd, args]) => cmd === 'open' && args.join(' ') === '-a Simulator', @@ -474,6 +505,39 @@ test('ensureBootedSimulator skips opening Simulator app when already booted', as ); }); +test('ensureBootedSimulator honors standalone Simulator preference when already booted', async () => { + mockRunCmd.mockImplementation(async (cmd, args) => { + if (cmd === 'xcrun' && args.join(' ') === 'simctl list devices -j') { + return { + exitCode: 0, + stdout: JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-18-6': [{ udid: 'sim-1', state: 'Booted' }], + }, + }), + stderr: '', + }; + } + if (cmd === 'open' && args.join(' ') === '-a Simulator') { + return { exitCode: 0, stdout: '', stderr: '' }; + } + throw new Error(`Unexpected command: ${cmd} ${args.join(' ')}`); + }); + + await ensureBootedSimulator(IOS_TEST_SIMULATOR, { + focusExisting: true, + preferStandalone: true, + }); + + assert.deepEqual( + mockRunCmd.mock.calls.map(([cmd, args]) => [cmd, args.join(' ')]), + [ + ['xcrun', 'simctl list devices -j'], + ['open', '-a Simulator'], + ], + ); +}); + test('shouldFallbackToRunnerForIosScreenshot detects removed devicectl subcommand output', () => { const error = new AppError('COMMAND_FAILED', 'Failed to capture iOS screenshot', { stderr: "error: Unknown option '--device'", @@ -922,9 +986,11 @@ test('screenshotIos retries simulator capture timeouts and eventually succeeds', 'should retry screenshot command until success', ); assert.equal( - logLines.filter((line) => line === '__OPEN__ -a Simulator').length, + logLines.filter( + (line) => line === '__OPEN__ -a Device Hub' || line === '__OPEN__ -a Simulator', + ).length, 0, - 'should not focus Simulator while retrying screenshots', + 'should not focus simulator host app while retrying screenshots', ); } finally { process.env.PATH = previousPath; diff --git a/src/platforms/ios/simulator.ts b/src/platforms/ios/simulator.ts index 0db7c1595..c893c1675 100644 --- a/src/platforms/ios/simulator.ts +++ b/src/platforms/ios/simulator.ts @@ -11,24 +11,50 @@ import { import { buildSimctlArgs, buildSimctlArgsForDevice } from './simctl.ts'; import { runAppleToolCommand, runXcrun } from './tool-provider.ts'; +const IOS_SIMULATOR_HOST_APPS = ['Device Hub', 'Simulator'] as const; +const IOS_SIMULATOR_STANDALONE_HOST_APPS = ['Simulator'] as const; + +type OpenIosSimulatorAppOptions = { + preferStandalone?: boolean; +}; + +type EnsureBootedSimulatorOptions = { + focusExisting?: boolean; + preferStandalone?: boolean; +}; + export function requireSimulatorDevice(device: DeviceInfo, command: string): void { if (device.kind !== 'simulator') { throw new AppError('UNSUPPORTED_OPERATION', `${command} is only supported on iOS simulators`); } } -export async function openIosSimulatorApp(): Promise { - await runAppleToolCommand('open', ['-a', 'Simulator'], { - allowFailure: true, - timeoutMs: IOS_SIMULATOR_FOCUS_TIMEOUT_MS, - }); +export async function openIosSimulatorApp(options: OpenIosSimulatorAppOptions = {}): Promise { + const appNames = options.preferStandalone + ? IOS_SIMULATOR_STANDALONE_HOST_APPS + : IOS_SIMULATOR_HOST_APPS; + for (const appName of appNames) { + const result = await runAppleToolCommand('open', ['-a', appName], { + allowFailure: true, + timeoutMs: IOS_SIMULATOR_FOCUS_TIMEOUT_MS, + }); + if (result.exitCode === 0) return; + } } -export async function ensureBootedSimulator(device: DeviceInfo): Promise { +export async function ensureBootedSimulator( + device: DeviceInfo, + options: EnsureBootedSimulatorOptions = {}, +): Promise { if (device.kind !== 'simulator') return; const state = await getSimulatorState(device); - if (state === 'Booted') return; + if (state === 'Booted') { + if (options.focusExisting) { + await openIosSimulatorApp({ preferStandalone: options.preferStandalone }); + } + return; + } const deadline = Deadline.fromTimeoutMs(IOS_BOOT_TIMEOUT_MS); let bootResult: @@ -151,7 +177,7 @@ export async function ensureBootedSimulator(device: DeviceInfo): Promise { }); } - await openIosSimulatorApp(); + await openIosSimulatorApp({ preferStandalone: options.preferStandalone }); } export async function shutdownSimulator(device: DeviceInfo): Promise<{ diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index 6f12b100f..9d337d17c 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -575,6 +575,15 @@ test('parseArgs recognizes explicit config file flag', () => { assert.equal(parsed.flags.config, './agent-device.json'); }); +test('parseArgs recognizes open Device Hub opt-out flag', () => { + const parsed = parseArgs(['open', 'settings', '--platform', 'ios', '--no-device-hub'], { + strictFlags: true, + }); + assert.equal(parsed.command, 'open'); + assert.equal(parsed.flags.platform, 'ios'); + assert.equal(parsed.flags.noDeviceHub, true); +}); + test('parseArgs recognizes session lock policy flag', () => { const parsed = parseArgs(['snapshot', '--session-lock', 'strip'], { strictFlags: true }); assert.equal(parsed.command, 'snapshot'); @@ -1576,6 +1585,8 @@ test('open command usage documents surface and console log flags', () => { assert.match(help, /macOS also supports --surface/); assert.match(help, /--launch-console /); assert.match(help, /iOS simulator launch console/); + assert.match(help, /--no-device-hub/); + assert.match(help, /skip Xcode Device Hub/); }); test('command usage shows record touch-overlay opt-out flag', () => { diff --git a/src/utils/cli-command-overrides.ts b/src/utils/cli-command-overrides.ts index 0b1769526..b0fa1a481 100644 --- a/src/utils/cli-command-overrides.ts +++ b/src/utils/cli-command-overrides.ts @@ -82,7 +82,15 @@ const CLI_COMMAND_OVERRIDES = { 'Boot device/simulator; optionally launch app or deep link URL (macOS also supports --surface app|frontmost-app|desktop|menubar)', summary: 'Open an app, deep link or URL, save replays', positionalArgs: ['appOrUrl?', 'url?'], - allowedFlags: ['activity', 'launchConsole', 'launchArgs', 'saveScript', 'relaunch', 'surface'], + allowedFlags: [ + 'activity', + 'launchConsole', + 'launchArgs', + 'noDeviceHub', + 'saveScript', + 'relaunch', + 'surface', + ], }, close: { positionalArgs: ['app?'], diff --git a/src/utils/cli-flags.ts b/src/utils/cli-flags.ts index 4a9ce9a37..dea1f5b81 100644 --- a/src/utils/cli-flags.ts +++ b/src/utils/cli-flags.ts @@ -47,6 +47,7 @@ export type CliFlags = RemoteConfigMetroOptions & udid?: string; serial?: string; iosSimulatorDeviceSet?: string; + noDeviceHub?: boolean; androidDeviceAllowlist?: string; session?: string; metroHost?: string; @@ -493,6 +494,14 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ usageLabel: '--ios-simulator-device-set ', usageDescription: 'Scope iOS simulator discovery/commands to this simulator device set', }, + { + key: 'noDeviceHub', + names: ['--no-device-hub'], + type: 'boolean', + usageLabel: '--no-device-hub', + usageDescription: + 'open: skip Xcode Device Hub and use the standalone Simulator app when surfacing Apple simulators', + }, { key: 'androidDeviceAllowlist', names: ['--android-device-allowlist'], diff --git a/test/integration/provider-scenarios/providers.ts b/test/integration/provider-scenarios/providers.ts index 262911849..07e19d8e5 100644 --- a/test/integration/provider-scenarios/providers.ts +++ b/test/integration/provider-scenarios/providers.ts @@ -54,6 +54,9 @@ export function createRecordingAppleToolProvider(handlers: RecordingAppleToolHan whichCommand: async () => true, runCommand: async (cmd, args) => { calls.push([cmd, ...args]); + if (isSimulatorHostOpenCommand(cmd, args)) { + return { stdout: '', stderr: '', exitCode: 0 }; + } return await missingHandler([cmd, ...args].join(' ')); }, simctl: { @@ -93,6 +96,13 @@ export function createRecordingAppleToolProvider(handlers: RecordingAppleToolHan }; } +function isSimulatorHostOpenCommand(cmd: string, args: string[]): boolean { + if (cmd !== 'open') return false; + return ( + args.length === 2 && args[0] === '-a' && (args[1] === 'Device Hub' || args[1] === 'Simulator') + ); +} + function createRecordingMacOsHostProvider( calls: FlatToolCall[], host: AppleMacOsHostProvider | undefined, diff --git a/test/integration/provider-scenarios/tvos-remote.test.ts b/test/integration/provider-scenarios/tvos-remote.test.ts index 9910818c9..a2fd1bea4 100644 --- a/test/integration/provider-scenarios/tvos-remote.test.ts +++ b/test/integration/provider-scenarios/tvos-remote.test.ts @@ -86,6 +86,7 @@ test('Provider-backed integration tvOS remote flow maps navigation commands to r runnerTranscript.assertComplete(); assert.deepEqual(appleTool.calls, [ ['simctl', 'list', 'devices', '-j'], + ['open', '-a', 'Device Hub'], ['simctl', 'list', 'devices', '-j'], ['simctl', 'launch', 'tv-sim-1', 'com.example.tv'], ['simctl', 'list', 'devices', '-j'], diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index efa594947..f3b046ae1 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -120,6 +120,9 @@ await client.sessions.close(); For direct iOS simulator app launches, `client.apps.open({ app, platform: 'ios', launchConsole: './artifacts/app.console.log' })` captures launch-time stdout/stderr. The option mirrors `open --launch-console` and is not valid for URL opens or non-simulator targets. +When surfacing Apple simulators, `client.apps.open({ noDeviceHub: true })` mirrors `open --no-device-hub` and forces the standalone Simulator app +instead of Xcode Device Hub. + ## Android snapshot helper providers Remote Android providers should import `agent-device/android-snapshot-helper` and inject their own diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 1e59dafa6..bec930e24 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -68,6 +68,7 @@ agent-device app-switcher - `open ` opens a deep link on iOS. - `open --launch-console ` captures launch-time stdout/stderr for direct iOS simulator app launches. It is not valid for URL opens or non-simulator targets. +- `open --no-device-hub` skips Xcode Device Hub and uses the standalone Simulator app when surfacing Apple simulators. - `open --platform macos --surface app|frontmost-app|desktop|menubar` selects the macOS session surface explicitly. `app` is the default when an app argument is provided. - `back` now defaults to app-owned back navigation. On Apple targets that means visible in-app back UI only. On Android this currently maps to the same back keyevent because Android routes in-app back through that platform event. - `back --in-app` is an explicit alias for the default app-owned behavior.