diff --git a/src/daemon/__tests__/device-ready.test.ts b/src/daemon/__tests__/device-ready.test.ts index 3246e6d78..14f0d1e55 100644 --- a/src/daemon/__tests__/device-ready.test.ts +++ b/src/daemon/__tests__/device-ready.test.ts @@ -1,6 +1,106 @@ -import { test } from 'vitest'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import assert from 'node:assert/strict'; -import { parseIosReadyPayload, resolveIosReadyHint } from '../device-ready.ts'; +import { promises as fs } from 'node:fs'; +import type { DeviceInfo } from '../../utils/device.ts'; + +vi.mock('../../utils/exec.ts', () => ({ + runCmd: vi.fn(), +})); +vi.mock('../../platforms/ios/index.ts', () => ({ + ensureBootedSimulator: vi.fn(async () => {}), +})); +vi.mock('../../platforms/android/devices.ts', () => ({ + waitForAndroidBoot: vi.fn(async () => {}), +})); + +import { runCmd } from '../../utils/exec.ts'; +import { waitForAndroidBoot } from '../../platforms/android/devices.ts'; +import { ensureBootedSimulator } from '../../platforms/ios/index.ts'; +import { ANDROID_EMULATOR, IOS_DEVICE, IOS_SIMULATOR } from '../../__tests__/test-utils/index.ts'; +import { + clearDeviceReadyCacheForTests, + DEVICE_READY_CACHE_TTL_MS, + ensureDeviceReady, + parseIosReadyPayload, + resolveIosReadyHint, +} from '../device-ready.ts'; + +const mockRunCmd = vi.mocked(runCmd); +const mockEnsureBootedSimulator = vi.mocked(ensureBootedSimulator); +const mockWaitForAndroidBoot = vi.mocked(waitForAndroidBoot); + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-01T10:00:00.000Z')); + clearDeviceReadyCacheForTests(); + mockRunCmd.mockReset(); + mockEnsureBootedSimulator.mockReset(); + mockEnsureBootedSimulator.mockResolvedValue(undefined); + mockWaitForAndroidBoot.mockReset(); + mockWaitForAndroidBoot.mockResolvedValue(undefined); +}); + +afterEach(() => { + clearDeviceReadyCacheForTests(); + vi.useRealTimers(); +}); + +test('ensureDeviceReady caches successful simulator readiness checks', async () => { + const device: DeviceInfo = { ...IOS_SIMULATOR, simulatorSetPath: '/tmp/simset-a' }; + + await ensureDeviceReady(device); + await ensureDeviceReady({ ...device }); + + expect(mockEnsureBootedSimulator).toHaveBeenCalledTimes(1); +}); + +test('ensureDeviceReady caches successful iOS physical device readiness checks', async () => { + mockRunCmd.mockImplementation(async (_cmd, args) => { + const jsonPath = args[args.indexOf('--json-output') + 1]; + await fs.writeFile( + jsonPath, + JSON.stringify({ + result: { + connectionProperties: { + tunnelState: 'connected', + }, + }, + }), + ); + return { stdout: '', stderr: '', exitCode: 0 }; + }); + + await ensureDeviceReady(IOS_DEVICE); + await ensureDeviceReady({ ...IOS_DEVICE, simulatorSetPath: '/ignored-for-physical-device' }); + + expect(mockRunCmd).toHaveBeenCalledTimes(1); +}); + +test('ensureDeviceReady includes simulator set path in the cache key', async () => { + await ensureDeviceReady({ ...IOS_SIMULATOR, simulatorSetPath: '/tmp/simset-a' }); + await ensureDeviceReady({ ...IOS_SIMULATOR, simulatorSetPath: '/tmp/simset-b' }); + + expect(mockEnsureBootedSimulator).toHaveBeenCalledTimes(2); +}); + +test('ensureDeviceReady expires cached readiness checks after the ttl', async () => { + await ensureDeviceReady(ANDROID_EMULATOR); + vi.setSystemTime(new Date(Date.now() + DEVICE_READY_CACHE_TTL_MS - 1)); + await ensureDeviceReady({ ...ANDROID_EMULATOR }); + vi.setSystemTime(new Date(Date.now() + 1)); + await ensureDeviceReady({ ...ANDROID_EMULATOR }); + + expect(mockWaitForAndroidBoot).toHaveBeenCalledTimes(2); +}); + +test('ensureDeviceReady does not cache failed readiness checks', async () => { + mockEnsureBootedSimulator.mockRejectedValueOnce(new Error('boot failed')); + + await expect(ensureDeviceReady(IOS_SIMULATOR)).rejects.toThrow('boot failed'); + await ensureDeviceReady(IOS_SIMULATOR); + + expect(mockEnsureBootedSimulator).toHaveBeenCalledTimes(2); +}); test('parseIosReadyPayload reads tunnelState from direct connectionProperties', () => { const parsed = parseIosReadyPayload({ diff --git a/src/daemon/device-ready.ts b/src/daemon/device-ready.ts index 4ea934b83..4a16a006a 100644 --- a/src/daemon/device-ready.ts +++ b/src/daemon/device-ready.ts @@ -14,24 +14,61 @@ const IOS_DEVICE_READY_TIMEOUT_MS = resolveTimeoutMs( ); const IOS_DEVICE_READY_COMMAND_TIMEOUT_BUFFER_MS = 3_000; +// Exported so unit tests can assert TTL behavior without duplicating the value. +export const DEVICE_READY_CACHE_TTL_MS = 5_000; + +const readyCache = new Map(); + export async function ensureDeviceReady(device: DeviceInfo): Promise { + const cacheKey = deviceReadyCacheKey(device); + const cachedUntil = readyCache.get(cacheKey); + if (cachedUntil !== undefined) { + if (cachedUntil > Date.now()) { + return; + } + readyCache.delete(cacheKey); + } + if (device.platform === 'ios') { if (device.kind === 'simulator') { const { ensureBootedSimulator } = await import('../platforms/ios/index.ts'); await ensureBootedSimulator(device); + markDeviceReady(cacheKey); return; } if (device.kind === 'device') { await ensureIosDeviceReady(device.id); + markDeviceReady(cacheKey); return; } } if (device.platform === 'android') { const { waitForAndroidBoot } = await import('../platforms/android/devices.ts'); await waitForAndroidBoot(device.id); + markDeviceReady(cacheKey); } } +// Test-only reset hook for this daemon-local cache. +export function clearDeviceReadyCacheForTests(): void { + readyCache.clear(); +} + +function markDeviceReady(cacheKey: string): void { + readyCache.set(cacheKey, Date.now() + DEVICE_READY_CACHE_TTL_MS); +} + +function deviceReadyCacheKey(device: DeviceInfo): string { + const simulatorSetPath = device.kind === 'simulator' ? (device.simulatorSetPath ?? '') : ''; + return JSON.stringify([ + device.platform, + device.kind, + device.id, + device.target ?? '', + simulatorSetPath, + ]); +} + async function ensureIosDeviceReady(deviceId: string): Promise { const jsonPath = path.join( os.tmpdir(),