Skip to content

Commit 4f4bf7b

Browse files
authored
perf: cache successful device readiness checks (#465)
1 parent 994ee78 commit 4f4bf7b

2 files changed

Lines changed: 139 additions & 2 deletions

File tree

src/daemon/__tests__/device-ready.test.ts

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,106 @@
1-
import { test } from 'vitest';
1+
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
22
import assert from 'node:assert/strict';
3-
import { parseIosReadyPayload, resolveIosReadyHint } from '../device-ready.ts';
3+
import { promises as fs } from 'node:fs';
4+
import type { DeviceInfo } from '../../utils/device.ts';
5+
6+
vi.mock('../../utils/exec.ts', () => ({
7+
runCmd: vi.fn(),
8+
}));
9+
vi.mock('../../platforms/ios/index.ts', () => ({
10+
ensureBootedSimulator: vi.fn(async () => {}),
11+
}));
12+
vi.mock('../../platforms/android/devices.ts', () => ({
13+
waitForAndroidBoot: vi.fn(async () => {}),
14+
}));
15+
16+
import { runCmd } from '../../utils/exec.ts';
17+
import { waitForAndroidBoot } from '../../platforms/android/devices.ts';
18+
import { ensureBootedSimulator } from '../../platforms/ios/index.ts';
19+
import { ANDROID_EMULATOR, IOS_DEVICE, IOS_SIMULATOR } from '../../__tests__/test-utils/index.ts';
20+
import {
21+
clearDeviceReadyCacheForTests,
22+
DEVICE_READY_CACHE_TTL_MS,
23+
ensureDeviceReady,
24+
parseIosReadyPayload,
25+
resolveIosReadyHint,
26+
} from '../device-ready.ts';
27+
28+
const mockRunCmd = vi.mocked(runCmd);
29+
const mockEnsureBootedSimulator = vi.mocked(ensureBootedSimulator);
30+
const mockWaitForAndroidBoot = vi.mocked(waitForAndroidBoot);
31+
32+
beforeEach(() => {
33+
vi.useFakeTimers();
34+
vi.setSystemTime(new Date('2026-04-01T10:00:00.000Z'));
35+
clearDeviceReadyCacheForTests();
36+
mockRunCmd.mockReset();
37+
mockEnsureBootedSimulator.mockReset();
38+
mockEnsureBootedSimulator.mockResolvedValue(undefined);
39+
mockWaitForAndroidBoot.mockReset();
40+
mockWaitForAndroidBoot.mockResolvedValue(undefined);
41+
});
42+
43+
afterEach(() => {
44+
clearDeviceReadyCacheForTests();
45+
vi.useRealTimers();
46+
});
47+
48+
test('ensureDeviceReady caches successful simulator readiness checks', async () => {
49+
const device: DeviceInfo = { ...IOS_SIMULATOR, simulatorSetPath: '/tmp/simset-a' };
50+
51+
await ensureDeviceReady(device);
52+
await ensureDeviceReady({ ...device });
53+
54+
expect(mockEnsureBootedSimulator).toHaveBeenCalledTimes(1);
55+
});
56+
57+
test('ensureDeviceReady caches successful iOS physical device readiness checks', async () => {
58+
mockRunCmd.mockImplementation(async (_cmd, args) => {
59+
const jsonPath = args[args.indexOf('--json-output') + 1];
60+
await fs.writeFile(
61+
jsonPath,
62+
JSON.stringify({
63+
result: {
64+
connectionProperties: {
65+
tunnelState: 'connected',
66+
},
67+
},
68+
}),
69+
);
70+
return { stdout: '', stderr: '', exitCode: 0 };
71+
});
72+
73+
await ensureDeviceReady(IOS_DEVICE);
74+
await ensureDeviceReady({ ...IOS_DEVICE, simulatorSetPath: '/ignored-for-physical-device' });
75+
76+
expect(mockRunCmd).toHaveBeenCalledTimes(1);
77+
});
78+
79+
test('ensureDeviceReady includes simulator set path in the cache key', async () => {
80+
await ensureDeviceReady({ ...IOS_SIMULATOR, simulatorSetPath: '/tmp/simset-a' });
81+
await ensureDeviceReady({ ...IOS_SIMULATOR, simulatorSetPath: '/tmp/simset-b' });
82+
83+
expect(mockEnsureBootedSimulator).toHaveBeenCalledTimes(2);
84+
});
85+
86+
test('ensureDeviceReady expires cached readiness checks after the ttl', async () => {
87+
await ensureDeviceReady(ANDROID_EMULATOR);
88+
vi.setSystemTime(new Date(Date.now() + DEVICE_READY_CACHE_TTL_MS - 1));
89+
await ensureDeviceReady({ ...ANDROID_EMULATOR });
90+
vi.setSystemTime(new Date(Date.now() + 1));
91+
await ensureDeviceReady({ ...ANDROID_EMULATOR });
92+
93+
expect(mockWaitForAndroidBoot).toHaveBeenCalledTimes(2);
94+
});
95+
96+
test('ensureDeviceReady does not cache failed readiness checks', async () => {
97+
mockEnsureBootedSimulator.mockRejectedValueOnce(new Error('boot failed'));
98+
99+
await expect(ensureDeviceReady(IOS_SIMULATOR)).rejects.toThrow('boot failed');
100+
await ensureDeviceReady(IOS_SIMULATOR);
101+
102+
expect(mockEnsureBootedSimulator).toHaveBeenCalledTimes(2);
103+
});
4104

5105
test('parseIosReadyPayload reads tunnelState from direct connectionProperties', () => {
6106
const parsed = parseIosReadyPayload({

src/daemon/device-ready.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,24 +14,61 @@ const IOS_DEVICE_READY_TIMEOUT_MS = resolveTimeoutMs(
1414
);
1515
const IOS_DEVICE_READY_COMMAND_TIMEOUT_BUFFER_MS = 3_000;
1616

17+
// Exported so unit tests can assert TTL behavior without duplicating the value.
18+
export const DEVICE_READY_CACHE_TTL_MS = 5_000;
19+
20+
const readyCache = new Map<string, number>();
21+
1722
export async function ensureDeviceReady(device: DeviceInfo): Promise<void> {
23+
const cacheKey = deviceReadyCacheKey(device);
24+
const cachedUntil = readyCache.get(cacheKey);
25+
if (cachedUntil !== undefined) {
26+
if (cachedUntil > Date.now()) {
27+
return;
28+
}
29+
readyCache.delete(cacheKey);
30+
}
31+
1832
if (device.platform === 'ios') {
1933
if (device.kind === 'simulator') {
2034
const { ensureBootedSimulator } = await import('../platforms/ios/index.ts');
2135
await ensureBootedSimulator(device);
36+
markDeviceReady(cacheKey);
2237
return;
2338
}
2439
if (device.kind === 'device') {
2540
await ensureIosDeviceReady(device.id);
41+
markDeviceReady(cacheKey);
2642
return;
2743
}
2844
}
2945
if (device.platform === 'android') {
3046
const { waitForAndroidBoot } = await import('../platforms/android/devices.ts');
3147
await waitForAndroidBoot(device.id);
48+
markDeviceReady(cacheKey);
3249
}
3350
}
3451

52+
// Test-only reset hook for this daemon-local cache.
53+
export function clearDeviceReadyCacheForTests(): void {
54+
readyCache.clear();
55+
}
56+
57+
function markDeviceReady(cacheKey: string): void {
58+
readyCache.set(cacheKey, Date.now() + DEVICE_READY_CACHE_TTL_MS);
59+
}
60+
61+
function deviceReadyCacheKey(device: DeviceInfo): string {
62+
const simulatorSetPath = device.kind === 'simulator' ? (device.simulatorSetPath ?? '') : '';
63+
return JSON.stringify([
64+
device.platform,
65+
device.kind,
66+
device.id,
67+
device.target ?? '',
68+
simulatorSetPath,
69+
]);
70+
}
71+
3572
async function ensureIosDeviceReady(deviceId: string): Promise<void> {
3673
const jsonPath = path.join(
3774
os.tmpdir(),

0 commit comments

Comments
 (0)