diff --git a/src/core/dispatch-context.ts b/src/core/dispatch-context.ts index 2cfee83e56..4c84eea381 100644 --- a/src/core/dispatch-context.ts +++ b/src/core/dispatch-context.ts @@ -44,6 +44,9 @@ export type DispatchContext = ScreenshotDispatchFlags & { activity?: string; launchConsole?: string; launchArgs?: string[]; + // iOS simulator only: relaunch via a single `simctl launch + // --terminate-running-process` instead of a separate terminate + launch. + terminateRunningApp?: boolean; clearAppState?: boolean; verbose?: boolean; logPath?: string; diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 131ba6c809..643725ebae 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -304,6 +304,7 @@ async function handleOpenCommand( appBundleId: context?.appBundleId, launchConsole, launchArgs, + terminateRunningApp: context?.terminateRunningApp, }); return { app, ...(launchConsole ? { launchConsole } : {}), ...successText(`Opened: ${app}`) }; } diff --git a/src/core/interactor-types.ts b/src/core/interactor-types.ts index d003f73578..ecd1299353 100644 --- a/src/core/interactor-types.ts +++ b/src/core/interactor-types.ts @@ -74,6 +74,7 @@ export type Interactor = { appBundleId?: string; launchConsole?: string; launchArgs?: string[]; + terminateRunningApp?: boolean; url?: string; }, ): Promise; diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index 42b0f29f1b..23c3848096 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -3238,7 +3238,7 @@ test('open --relaunch on iOS stops runner before close/open', async () => { expect(calls).toEqual(['stop-runner', 'close:com.example.app', 'open:com.example.app']); }); -test('open --relaunch on iOS simulator keeps runner hot across close/open', async () => { +test('open --relaunch on iOS simulator collapses into one terminate-running open dispatch', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-simulator-session'; sessionStore.set(sessionName, { @@ -3263,8 +3263,10 @@ test('open --relaunch on iOS simulator keeps runner hot across close/open', asyn mockStopIosRunner.mockImplementation(async () => { calls.push('stop-runner'); }); - mockDispatch.mockImplementation(async (_device, command, positionals) => { + let openContext: Record | undefined; + mockDispatch.mockImplementation(async (_device, command, positionals, _out, context) => { calls.push(`${command}:${positionals.join(' ')}`); + if (command === 'open') openContext = context as Record; return {}; }); @@ -3282,9 +3284,110 @@ test('open --relaunch on iOS simulator keeps runner hot across close/open', asyn invoke: noopInvoke, }); + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(calls).toEqual(['open:com.example.app']); + expect(openContext?.terminateRunningApp).toBe(true); +}); + +test('open --relaunch on iOS simulator keeps close-first ordering', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-simulator-url-relaunch-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }), + appName: 'com.example.app', + }); + + const calls: string[] = []; + mockResolveTargetDevice.mockResolvedValue({ + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }); + let openContext: Record | undefined; + mockDispatch.mockImplementation(async (_device, command, positionals, _out, context) => { + calls.push(`${command}:${positionals.join(' ')}`); + if (command === 'open') openContext = context as Record; + return {}; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['com.example.app', 'https://example.com/deal'], + flags: { relaunch: true }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + // The URL dispatch path cannot carry the terminate, so the relaunch keeps + // the explicit close-then-open sequence. + expect(calls).toEqual(['close:com.example.app', 'open:com.example.app https://example.com/deal']); + expect(openContext?.terminateRunningApp).toBeUndefined(); +}); + +test('open --relaunch --clear-app-state on iOS simulator keeps close-first ordering', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-simulator-clear-state-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }), + appName: 'com.example.app', + }); + + const calls: string[] = []; + mockResolveTargetDevice.mockResolvedValue({ + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }); + let openContext: Record | undefined; + mockDispatch.mockImplementation(async (_device, command, positionals, _out, context) => { + calls.push(`${command}:${positionals.join(' ')}`); + if (command === 'open') openContext = context as Record; + return {}; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: [], + flags: { relaunch: true, clearAppState: true }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + expect(response).toBeTruthy(); expect(response?.ok).toBe(true); expect(calls).toEqual(['close:com.example.app', 'open:com.example.app']); + expect(openContext?.terminateRunningApp).toBeUndefined(); }); test('open --relaunch includes timing and waits for iOS runner prewarm after opening app', async () => { @@ -3395,7 +3498,7 @@ test('open --relaunch on iOS without existing session closes then opens target a expect(calls).toEqual(['stop-runner', 'close:com.example.app', 'open:com.example.app']); }); -test('open --relaunch on iOS simulator reaches settle path for close and open', async () => { +test('open --relaunch on iOS simulator settles once after the collapsed open', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-sim-session'; sessionStore.set(sessionName, { @@ -3437,9 +3540,8 @@ test('open --relaunch on iOS simulator reaches settle path for close and open', expect(response).toBeTruthy(); expect(response?.ok).toBe(true); - expect(settleCalls.length).toBe(2); - expect(settleCalls[0]).toEqual({ deviceId: 'sim-1', delayMs: 300 }); - expect(settleCalls[1]).toEqual({ deviceId: 'sim-1', delayMs: 300 }); + // Collapsed simulator relaunch skips the post-close settle: one settle after open. + expect(settleCalls).toEqual([{ deviceId: 'sim-1', delayMs: 300 }]); }); test('close on macOS session stops runner and dismisses automation alert before delete', async () => { diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index e1d2790d1d..3b4307d213 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -208,7 +208,20 @@ async function completeOpenCommand(params: { schedulePrewarm(); } - if (shouldRelaunch && openTarget) { + // iOS simulators relaunch with one `simctl launch --terminate-running-process` + // instead of terminate + settle + launch (~1s per relaunch). Runtime hints + // written below are user-defaults reads at that launch, so ordering holds. + // Only the single app-launch form collapses: `open ` dispatches + // through the URL path, where a deep-link open never launches the app and + // so cannot carry the terminate; those keep the close-first ordering, as + // does --clear-app-state, which must never mutate a running app's container. + const collapseSimulatorRelaunch = + shouldRelaunch && + Boolean(openTarget) && + openPositionals.length === 1 && + isIosSimulator(device) && + req.flags?.clearAppState !== true; + if (shouldRelaunch && openTarget && !collapseSimulatorRelaunch) { const closeTarget = sessionAppBundleId ?? openTarget; const closeStartedAtMs = Date.now(); await relaunchCloseApp({ @@ -255,6 +268,7 @@ async function completeOpenCommand(params: { const openDispatchSession = provisionalSession.session ?? existingSession; await dispatchCommand(device, 'open', openPositionals, req.flags?.out, { ...contextFromFlags(logPath, req.flags, sessionAppBundleId), + ...(collapseSimulatorRelaunch ? { terminateRunningApp: true } : {}), }); timing.openDispatchDurationMs = Math.max(0, Date.now() - openStartedAtMs); const launchUrlStartedAtMs = Date.now(); diff --git a/src/platforms/apple/core/apps.ts b/src/platforms/apple/core/apps.ts index 065b080eb9..4d3f53e3d8 100644 --- a/src/platforms/apple/core/apps.ts +++ b/src/platforms/apple/core/apps.ts @@ -186,7 +186,13 @@ function parseUrlScheme(url: string): string | undefined { export async function openIosApp( device: DeviceInfo, app: string, - options?: { appBundleId?: string; launchConsole?: string; launchArgs?: string[]; url?: string }, + options?: { + appBundleId?: string; + launchConsole?: string; + launchArgs?: string[]; + terminateRunningApp?: boolean; + url?: string; + }, ): Promise { const launchConsole = options?.launchConsole?.trim(); const launchArgs = options?.launchArgs; @@ -258,6 +264,7 @@ export async function openIosApp( await launchIosSimulatorApp(device, bundleId, { ...(launchConsole ? { launchConsole } : {}), ...(launchArgs ? { launchArgs } : {}), + ...(options?.terminateRunningApp ? { terminateRunningApp: true } : {}), }); return; } @@ -1112,7 +1119,7 @@ function isIosBiometricCapabilityMissing(stdout: string, stderr: string): boolea async function launchIosSimulatorApp( device: DeviceInfo, bundleId: string, - options?: { launchConsole?: string; launchArgs?: string[] }, + options?: { launchConsole?: string; launchArgs?: string[]; terminateRunningApp?: boolean }, ): Promise { await ensureBootedSimulator(device); @@ -1175,10 +1182,11 @@ async function launchIosSimulatorApp( function buildIosSimulatorLaunchArgs( deviceId: string, bundleId: string, - options?: { launchConsole?: string; launchArgs?: string[] }, + options?: { launchConsole?: string; launchArgs?: string[]; terminateRunningApp?: boolean }, ): string[] { const args = ['launch']; if (options?.launchConsole) args.push('--console-pty'); + if (options?.terminateRunningApp) args.push('--terminate-running-process'); args.push(deviceId, bundleId); if (options?.launchArgs && options.launchArgs.length > 0) { args.push(...options.launchArgs); diff --git a/src/platforms/apple/interactor.ts b/src/platforms/apple/interactor.ts index f7931c925a..79d429184d 100644 --- a/src/platforms/apple/interactor.ts +++ b/src/platforms/apple/interactor.ts @@ -42,6 +42,7 @@ export function createAppleInteractor( appBundleId: options?.appBundleId, launchConsole: options?.launchConsole, launchArgs: options?.launchArgs, + terminateRunningApp: options?.terminateRunningApp, url: options?.url, }), openDevice: () => openIosDevice(device),