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
44 changes: 43 additions & 1 deletion src/daemon/handlers/__tests__/session-device-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/daemon/handlers/__tests__/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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 () => {
Expand Down
7 changes: 7 additions & 0 deletions src/daemon/handlers/session-device-utils.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<DaemonRequest['flags']> = {
platform: 'ios',
Expand Down
9 changes: 8 additions & 1 deletion src/daemon/handlers/session-open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -71,7 +72,13 @@ async function relaunchCloseApp(params: {
context: Parameters<typeof dispatchCommand>[4];
}): Promise<void> {
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);
Expand Down
1 change: 1 addition & 0 deletions src/platforms/apple/core/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
80 changes: 80 additions & 0 deletions src/platforms/apple/core/__tests__/simulator-booted-memo.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
41 changes: 40 additions & 1 deletion src/platforms/apple/core/simulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>();

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`);
Expand All @@ -49,8 +85,9 @@ export async function ensureBootedSimulator(
): Promise<void> {
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,
Expand Down Expand Up @@ -182,6 +219,7 @@ export async function ensureBootedSimulator(
});
}

markSimulatorBooted(device);
await openIosSimulatorApp({ deviceHub: options.deviceHub });
}

Expand All @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions test/integration/provider-scenarios/tvos-remote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
]);
},
Expand Down
Loading