diff --git a/src/daemon/handlers/__tests__/session-device-utils.test.ts b/src/daemon/handlers/__tests__/session-device-utils.test.ts index 121ecb1623..d439bc7391 100644 --- a/src/daemon/handlers/__tests__/session-device-utils.test.ts +++ b/src/daemon/handlers/__tests__/session-device-utils.test.ts @@ -1,10 +1,28 @@ -import { test, expect } from 'vitest'; +import { test, expect, vi, beforeEach } from 'vitest'; import type { SessionState } from '../../types.ts'; import { refreshSessionDeviceIfNeeded, selectorTargetsSessionDevice, } from '../session-device-utils.ts'; +import { getRunnerSessionSnapshot } from '../../../platforms/apple/core/runner/runner-client.ts'; +import { resolveTargetDevice } from '../../../core/dispatch.ts'; + +vi.mock('../../../platforms/apple/core/runner/runner-client.ts', () => ({ + getRunnerSessionSnapshot: vi.fn(() => null), +})); +vi.mock('../../../core/dispatch.ts', () => ({ + resolveTargetDevice: vi.fn(), +})); + +const mockGetRunnerSessionSnapshot = vi.mocked(getRunnerSessionSnapshot); +const mockResolveTargetDevice = vi.mocked(resolveTargetDevice); + +beforeEach(() => { + mockGetRunnerSessionSnapshot.mockReset(); + mockGetRunnerSessionSnapshot.mockReturnValue(null); + mockResolveTargetDevice.mockReset(); +}); const iosSimulatorSession: SessionState = { name: 'ios-sim', @@ -37,6 +55,30 @@ test('refreshSessionDeviceIfNeeded keeps iOS simulator session device on non-mac expect(device).toBe(iosSimulatorSession.device); }); +test('refreshSessionDeviceIfNeeded skips re-resolve while the iOS runner session is alive', async () => { + mockGetRunnerSessionSnapshot.mockReturnValue({ sessionId: 'sim-1:1234:1', alive: true }); + + const device = await withMockedPlatform('darwin', async () => + refreshSessionDeviceIfNeeded(iosSimulatorSession.device), + ); + + expect(device).toEqual({ ...iosSimulatorSession.device, booted: true }); + expect(mockResolveTargetDevice).not.toHaveBeenCalled(); +}); + +test('refreshSessionDeviceIfNeeded re-resolves when the iOS runner session is gone', async () => { + mockGetRunnerSessionSnapshot.mockReturnValue({ sessionId: 'sim-1:1234:1', alive: false }); + const resolved = { ...iosSimulatorSession.device, booted: true, name: 'renamed' }; + mockResolveTargetDevice.mockResolvedValue(resolved); + + const device = await withMockedPlatform('darwin', async () => + refreshSessionDeviceIfNeeded(iosSimulatorSession.device), + ); + + expect(device).toBe(resolved); + expect(mockResolveTargetDevice).toHaveBeenCalledTimes(1); +}); + test('selectorTargetsSessionDevice uses session selector conflicts for simulator set selectors', () => { const session: SessionState = { ...iosSimulatorSession, diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index 8e708c606c..6ce1809ae3 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -3237,7 +3237,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 stops runner before close/open', async () => { +test('open --relaunch on iOS simulator keeps runner hot across close/open', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-simulator-session'; sessionStore.set(sessionName, { @@ -3283,7 +3283,7 @@ test('open --relaunch on iOS simulator stops runner before close/open', async () expect(response).toBeTruthy(); expect(response?.ok).toBe(true); - expect(calls).toEqual(['stop-runner', 'close:com.example.app', 'open:com.example.app']); + expect(calls).toEqual(['close:com.example.app', 'open:com.example.app']); }); test('open --relaunch includes timing and waits for iOS runner prewarm after opening app', async () => { diff --git a/src/daemon/handlers/session-device-utils.ts b/src/daemon/handlers/session-device-utils.ts index 12deae796a..e87fb39d97 100644 --- a/src/daemon/handlers/session-device-utils.ts +++ b/src/daemon/handlers/session-device-utils.ts @@ -1,6 +1,7 @@ import { isIosFamily, type DeviceInfo } from '../../kernel/device.ts'; import { AppError } from '../../kernel/errors.ts'; import { ensureDeviceReady } from '../device-ready.ts'; +import { getRunnerSessionSnapshot } from '../../platforms/apple/core/runner/runner-client.ts'; import { resolveTargetDevice } from '../../core/dispatch.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { hasExplicitDeviceSelector } from '../device-selector-intent.ts'; @@ -63,6 +64,12 @@ export async function refreshSessionDeviceIfNeeded(device: DeviceInfo): Promise< if (process.platform !== 'darwin') { return device; } + // A live XCUITest runner session is attached to this exact UDID, which + // proves the simulator still exists and is booted — the two facts the + // ~0.7s re-resolve inventory listing exists to establish. + if (getRunnerSessionSnapshot(device.id)?.alive) { + return { ...device, booted: true }; + } const exactSelector: NonNullable = { platform: 'ios', diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index 95ffbc0cd8..1e3e17720c 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -22,6 +22,7 @@ import { import { IOS_SIMULATOR_POST_CLOSE_SETTLE_MS, IOS_SIMULATOR_POST_OPEN_SETTLE_MS, + isIosSimulator, refreshSessionDeviceIfNeeded, settleIosSimulator, } from './session-device-utils.ts'; @@ -71,7 +72,13 @@ async function relaunchCloseApp(params: { context: Parameters[4]; }): Promise { const { device, closeTarget, outFlag, context } = params; - if (device.platform !== 'android') { + // Simulator close/open go through simctl and never touch the XCUITest + // runner, so a healthy runner session survives the relaunch (~6s saved per + // open --relaunch). A runner that does go stale is caught by the readiness + // preflight and restarted via invalidateRunnerSession/restart-and-replay. + // Real devices keep the conservative teardown: their runner transport rides + // the device tunnel, which app relaunches can disturb. + if (device.platform !== 'android' && !isIosSimulator(device)) { await stopIosRunnerSession(device.id); } await dispatchCommand(device, 'close', [closeTarget], outFlag, context); diff --git a/src/platforms/apple/core/__tests__/index.test.ts b/src/platforms/apple/core/__tests__/index.test.ts index 24d94fdfca..9452121b58 100644 --- a/src/platforms/apple/core/__tests__/index.test.ts +++ b/src/platforms/apple/core/__tests__/index.test.ts @@ -164,6 +164,7 @@ const mockPrepareStatusBarForScreenshot = vi.mocked(prepareStatusBarForScreensho beforeEach(() => { vi.resetAllMocks(); + simulatorActual.__resetSimulatorBootedMemoForTests(); invalidateSimulatorStatusBarOverrideCache(IOS_TEST_SIMULATOR); mockRunCmd.mockImplementation(execActual.runCmd); mockRetryWithPolicy.mockImplementation(retryActual.retryWithPolicy); diff --git a/src/platforms/apple/core/__tests__/simulator-booted-memo.test.ts b/src/platforms/apple/core/__tests__/simulator-booted-memo.test.ts new file mode 100644 index 0000000000..5f615b1b57 --- /dev/null +++ b/src/platforms/apple/core/__tests__/simulator-booted-memo.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import type { DeviceInfo } from '../../../../kernel/device.ts'; +import { + __resetSimulatorBootedMemoForTests, + ensureBootedSimulator, + shutdownSimulator, + SIMULATOR_BOOTED_MEMO_TTL_MS, +} from '../simulator.ts'; +import { runXcrun } from '../tool-provider.ts'; + +vi.mock('../tool-provider.ts', () => ({ + runAppleToolCommand: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })), + runXcrun: vi.fn(), +})); + +const mockRunXcrun = vi.mocked(runXcrun); + +const simulator: DeviceInfo = { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + target: 'mobile', + booted: true, +}; + +function bootedListResult() { + return { + stdout: JSON.stringify({ + devices: { 'iOS 26.2': [{ udid: 'sim-1', state: 'Booted' }] }, + }), + stderr: '', + exitCode: 0, + }; +} + +function countSimctlListCalls(): number { + return mockRunXcrun.mock.calls.filter(([args]) => args.includes('list')).length; +} + +beforeEach(() => { + vi.useFakeTimers({ now: 1_000 }); + __resetSimulatorBootedMemoForTests(); + mockRunXcrun.mockReset(); + mockRunXcrun.mockImplementation(async () => bootedListResult()); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +test('ensureBootedSimulator skips the state listing within the booted memo TTL', async () => { + await ensureBootedSimulator(simulator); + expect(countSimctlListCalls()).toBe(1); + + await ensureBootedSimulator(simulator); + expect(countSimctlListCalls()).toBe(1); + + vi.advanceTimersByTime(SIMULATOR_BOOTED_MEMO_TTL_MS + 1); + await ensureBootedSimulator(simulator); + expect(countSimctlListCalls()).toBe(2); +}); + +test('shutdownSimulator invalidates the booted memo', async () => { + await ensureBootedSimulator(simulator); + expect(countSimctlListCalls()).toBe(1); + + await shutdownSimulator(simulator); + + await ensureBootedSimulator(simulator); + expect(countSimctlListCalls()).toBe(2); +}); + +test('booted memo is scoped per simulator set path', async () => { + await ensureBootedSimulator(simulator); + expect(countSimctlListCalls()).toBe(1); + + await ensureBootedSimulator({ ...simulator, simulatorSetPath: '/custom/set' }); + expect(countSimctlListCalls()).toBe(2); +}); diff --git a/src/platforms/apple/core/simulator.ts b/src/platforms/apple/core/simulator.ts index 0e2df46816..82ce13eadb 100644 --- a/src/platforms/apple/core/simulator.ts +++ b/src/platforms/apple/core/simulator.ts @@ -25,6 +25,42 @@ type EnsureBootedSimulatorOptions = { onColdBootStart?: (device: DeviceInfo) => void; }; +// Recently-observed-Booted memo. `simctl list devices -j` costs ~0.7s per +// spawn, and a single open --relaunch used to pay it three times (resolve, +// close, launch). Mirrors the DEVICE_READY_CACHE_TTL_MS tradeoff at the daemon +// layer: a simulator shut down externally inside the window surfaces the raw +// simctl error instead of an auto-boot. Transitions we own update the memo. +// Exported so unit tests can assert TTL behavior without duplicating the value. +export const SIMULATOR_BOOTED_MEMO_TTL_MS = 5_000; +const simulatorBootedMemo = new Map(); + +function simulatorBootedMemoKey(device: DeviceInfo): string { + return `${device.id}|${device.simulatorSetPath ?? ''}`; +} + +function readSimulatorBootedMemo(device: DeviceInfo): boolean { + const key = simulatorBootedMemoKey(device); + const observedAt = simulatorBootedMemo.get(key); + if (observedAt === undefined) return false; + if (Date.now() - observedAt > SIMULATOR_BOOTED_MEMO_TTL_MS) { + simulatorBootedMemo.delete(key); + return false; + } + return true; +} + +function markSimulatorBooted(device: DeviceInfo): void { + simulatorBootedMemo.set(simulatorBootedMemoKey(device), Date.now()); +} + +function clearSimulatorBootedMemo(device: DeviceInfo): void { + simulatorBootedMemo.delete(simulatorBootedMemoKey(device)); +} + +export function __resetSimulatorBootedMemoForTests(): void { + simulatorBootedMemo.clear(); +} + export function requireSimulatorDevice(device: DeviceInfo, command: string): void { if (device.kind !== 'simulator') { throw new AppError('UNSUPPORTED_OPERATION', `${command} is only supported on iOS simulators`); @@ -49,8 +85,9 @@ export async function ensureBootedSimulator( ): Promise { if (device.kind !== 'simulator') return; - const state = await getSimulatorState(device); + const state = readSimulatorBootedMemo(device) ? 'Booted' : await getSimulatorState(device); if (state === 'Booted') { + markSimulatorBooted(device); if (options.focusExisting) { await openIosSimulatorApp({ background: options.deviceHub, @@ -182,6 +219,7 @@ export async function ensureBootedSimulator( }); } + markSimulatorBooted(device); await openIosSimulatorApp({ deviceHub: options.deviceHub }); } @@ -191,6 +229,7 @@ export async function shutdownSimulator(device: DeviceInfo): Promise<{ stdout: string; stderr: string; }> { + clearSimulatorBootedMemo(device); const args = buildSimctlArgsForDevice(device, ['shutdown', device.id]); const result = await runXcrun(args, { allowFailure: true, timeoutMs: 15_000 }); return { diff --git a/test/integration/provider-scenarios/tvos-remote.test.ts b/test/integration/provider-scenarios/tvos-remote.test.ts index 19381bc973..0f1d83257c 100644 --- a/test/integration/provider-scenarios/tvos-remote.test.ts +++ b/test/integration/provider-scenarios/tvos-remote.test.ts @@ -84,11 +84,11 @@ test('Provider-backed integration tvOS remote flow maps navigation commands to r assertRpcOk(close); runnerTranscript.assertComplete(); + // One state listing per flow: the launch and terminate boot checks hit + // the recently-observed-Booted memo instead of re-listing devices. assert.deepEqual(appleTool.calls, [ - ['simctl', 'list', 'devices', '-j'], ['simctl', 'list', 'devices', '-j'], ['simctl', 'launch', 'tv-sim-1', 'com.example.tv'], - ['simctl', 'list', 'devices', '-j'], ['simctl', 'terminate', 'tv-sim-1', 'com.example.tv'], ]); },