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
104 changes: 102 additions & 2 deletions src/daemon/__tests__/device-ready.test.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down
37 changes: 37 additions & 0 deletions src/daemon/device-ready.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>();

export async function ensureDeviceReady(device: DeviceInfo): Promise<void> {
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<void> {
const jsonPath = path.join(
os.tmpdir(),
Expand Down
Loading