Skip to content

Commit e9eeb45

Browse files
authored
fix: select app-bearing simulator for replay (#1387)
* fix(replay): bind app-aware simulator before replay * fix(replay): preserve deep-link target resolution * fix(replay): infer iOS app selection without platform * fix(replay): preserve authored Android selection * fix(replay): ignore deep links during device binding * test(replay): cover authored Android platform * fix(replay): defer binding after deep links * fix(replay): retain platform before deep links * fix(open): preserve platformless device resolution
1 parent ab913c9 commit e9eeb45

11 files changed

Lines changed: 462 additions & 20 deletions

src/core/__tests__/dispatch-resolve.test.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,20 @@ test('resolveTargetDevice selects the unique booted simulator with the requested
185185
);
186186
});
187187

188-
test('resolveTargetDevice reuses an app-aware selection for later request resolution', async () => {
188+
test('resolveTargetDevice leaves platform-less static app selection to normal cross-platform resolution', async () => {
189+
const result = await withDeviceInventoryProvider(
190+
async (request) => {
191+
assert.equal(request.platform, undefined);
192+
return [androidEmulator, bootedSimulator, secondBootedSimulator];
193+
},
194+
async () => await resolveTargetDevice({}, { appleSimulatorAppTarget: 'com.example.demo' }),
195+
);
196+
197+
assert.equal(result.id, androidEmulator.id);
198+
assert.equal(mockFindIosSimulatorInstalledApp.mock.calls.length, 0);
199+
});
200+
201+
test('resolveTargetDevice reuses an app-aware iOS selection for later iOS resolution', async () => {
189202
mockListAppleDevices.mockResolvedValue([bootedSimulator, secondBootedSimulator]);
190203
mockFindIosSimulatorInstalledApp.mockImplementation(async (device) =>
191204
device.id === secondBootedSimulator.id ? 'com.example.demo' : undefined,
@@ -233,11 +246,11 @@ test('resolveTargetDevice refuses ambiguous booted simulator app matches', async
233246
assert.equal(error.details?.hint, 'Pass --udid to select the intended simulator explicitly.');
234247
});
235248

236-
test('resolveTargetDevice does not probe when an Apple device is explicitly selected', async () => {
249+
test('resolveTargetDevice preserves an explicit device selector when platform is omitted', async () => {
237250
mockListAppleDevices.mockResolvedValue([bootedSimulator, secondBootedSimulator]);
238251

239252
const result = await resolveTargetDevice(
240-
{ platform: 'ios', udid: bootedSimulator.id },
253+
{ udid: bootedSimulator.id },
241254
{ appleSimulatorAppTarget: 'com.example.demo' },
242255
);
243256

src/core/dispatch-resolve.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ export async function resolveTargetDevice(
211211
}
212212
const injectedDevices = await readInjectedDeviceInventory(inventoryRequest);
213213
if (injectedDevices) {
214-
if (isAppleResolutionSelector(selector)) {
214+
if (shouldUseAppleResolution(selector)) {
215215
return cacheResolvedTargetDevice(
216216
cacheKey,
217217
await resolveAppleDevice(injectedDevices, selector as AppleDeviceSelector, {
@@ -229,7 +229,7 @@ export async function resolveTargetDevice(
229229

230230
const devices = await listLocalDeviceInventory(inventoryRequest);
231231

232-
if (isAppleResolutionSelector(selector)) {
232+
if (shouldUseAppleResolution(selector)) {
233233
return cacheResolvedTargetDevice(
234234
cacheKey,
235235
await resolveAppleDevice(devices, selector as AppleDeviceSelector, {
@@ -325,6 +325,13 @@ function isAppleResolutionSelector(selector: {
325325
return isApplePlatform(selector.platform);
326326
}
327327

328+
function shouldUseAppleResolution(selector: {
329+
platform?: PlatformSelector;
330+
target?: DeviceTarget;
331+
}): boolean {
332+
return isAppleResolutionSelector(selector);
333+
}
334+
328335
function readResolveTargetDeviceCache(cacheKey: string): DeviceInfo | undefined {
329336
const cache = resolveTargetDeviceCacheScope.getStore();
330337
const cached = cache?.get(cacheKey);
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { test, expect } from 'vitest';
2+
import fs from 'node:fs';
3+
import os from 'node:os';
4+
import path from 'node:path';
5+
import { parseReplayInput } from '../../compat/replay-input.ts';
6+
import {
7+
buildReplayScriptPlatformFlags,
8+
buildReplayTargetDeviceResolution,
9+
} from '../replay-device-selection.ts';
10+
11+
test('replay leaves deep-link opens to normal device resolution', () => {
12+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-device-selection-'));
13+
const replayPath = path.join(root, 'deep-link.ad');
14+
fs.writeFileSync(replayPath, 'open demo://checkout\n');
15+
16+
expect(
17+
buildReplayTargetDeviceResolution({
18+
token: 'test-token',
19+
session: 'default',
20+
command: 'replay',
21+
positionals: [replayPath],
22+
meta: { cwd: root },
23+
}),
24+
).toBeUndefined();
25+
});
26+
27+
test('native replay applies its authored platform before a first deep link', () => {
28+
expect(
29+
buildReplayScriptPlatformFlags(
30+
undefined,
31+
parseReplayInput(
32+
'runtime set --platform ios --metro-port 8081\nopen demo://checkout\nopen com.example.demo\n',
33+
undefined,
34+
).actions,
35+
),
36+
).toEqual({ platform: 'ios' });
37+
});
38+
39+
test('native replay uses its authored Android runtime setting without an iOS app probe', () => {
40+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-device-selection-'));
41+
const replayPath = path.join(root, 'android.ad');
42+
fs.writeFileSync(
43+
replayPath,
44+
'runtime set --platform android --metro-port 8081\nopen com.example.demo\n',
45+
);
46+
47+
expect(
48+
buildReplayTargetDeviceResolution({
49+
token: 'test-token',
50+
session: 'default',
51+
command: 'replay',
52+
positionals: [replayPath],
53+
meta: { cwd: root },
54+
}),
55+
).toEqual({ flags: { platform: 'android' }, options: undefined });
56+
});

src/daemon/__tests__/request-router-open.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {})
88

99
import { dispatchCommand } from '../../core/dispatch.ts';
1010
import { createRequestHandler } from '../request-router.ts';
11+
import { resolveRequestExecutionLockKeys } from '../request-binding.ts';
1112
import { LeaseRegistry } from '../lease-registry.ts';
1213
import { ensureDeviceReady } from '../device-ready.ts';
1314
import type { DeviceInfo } from '../../kernel/device.ts';
@@ -29,6 +30,17 @@ function makeIosDevice(id: string): DeviceInfo {
2930
};
3031
}
3132

33+
function makeAndroidDevice(id: string): DeviceInfo {
34+
return {
35+
platform: 'android',
36+
id,
37+
name: `Android ${id}`,
38+
kind: 'emulator',
39+
target: 'mobile',
40+
booted: true,
41+
};
42+
}
43+
3244
function createOpenHandler(
3345
sessionStore: ReturnType<typeof makeSessionStore>,
3446
leaseRegistry = new LeaseRegistry(),
@@ -122,6 +134,94 @@ test('fresh open uses app-aware device selection for advisory locking and dispat
122134
expect(sessionStore.get('session-app-aware')?.device.id).toBe(appDevice.id);
123135
});
124136

137+
test('fresh replay reserves its authored app simulator before any replay step', async () => {
138+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-app-lock-'));
139+
const replayPath = path.join(root, 'flow.ad');
140+
fs.writeFileSync(
141+
replayPath,
142+
'runtime set --platform ios --metro-port 8081\nopen com.example.demo\n',
143+
);
144+
const sessionStore = makeSessionStore('agent-device-router-replay-lock-');
145+
const genericDevice = makeIosDevice('SIM-GENERIC');
146+
const appDevice = makeIosDevice('SIM-WITH-APP');
147+
mockResolveTargetDevice.mockImplementation(async (_flags, options) =>
148+
options?.appleSimulatorAppTarget === 'com.example.demo' ? appDevice : genericDevice,
149+
);
150+
151+
const keys = await resolveRequestExecutionLockKeys({
152+
req: {
153+
token: 'test-token',
154+
session: 'fresh-replay',
155+
command: 'replay',
156+
positionals: [replayPath],
157+
flags: {},
158+
meta: { cwd: root },
159+
},
160+
sessionName: 'fresh-replay',
161+
sessionStore,
162+
});
163+
164+
expect(keys).toEqual(['session:fresh-replay', 'device:SIM-WITH-APP']);
165+
expect(mockResolveTargetDevice).toHaveBeenCalledWith(
166+
{ platform: 'ios' },
167+
{ appleSimulatorAppTarget: 'com.example.demo' },
168+
);
169+
});
170+
171+
test('fresh replay leaves a first deep-link open unbound when a later app target exists', async () => {
172+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-deep-link-lock-'));
173+
const replayPath = path.join(root, 'flow.ad');
174+
fs.writeFileSync(
175+
replayPath,
176+
'runtime set --platform ios --metro-port 8081\nopen demo://checkout\nopen com.example.demo\n',
177+
);
178+
const sessionStore = makeSessionStore('agent-device-router-replay-deep-link-lock-');
179+
180+
const keys = await resolveRequestExecutionLockKeys({
181+
req: {
182+
token: 'test-token',
183+
session: 'fresh-replay-deep-link',
184+
command: 'replay',
185+
positionals: [replayPath],
186+
flags: {},
187+
meta: { cwd: root },
188+
},
189+
sessionName: 'fresh-replay-deep-link',
190+
sessionStore,
191+
});
192+
193+
expect(keys).toEqual(['session:fresh-replay-deep-link']);
194+
expect(mockResolveTargetDevice).not.toHaveBeenCalled();
195+
});
196+
197+
test('fresh replay preserves an authored Android platform before advisory locking', async () => {
198+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-android-lock-'));
199+
const replayPath = path.join(root, 'flow.ad');
200+
fs.writeFileSync(
201+
replayPath,
202+
'runtime set --platform android --metro-port 8081\nopen com.example.demo\n',
203+
);
204+
const sessionStore = makeSessionStore('agent-device-router-replay-android-lock-');
205+
const androidDevice = makeAndroidDevice('ANDROID-EMULATOR');
206+
mockResolveTargetDevice.mockResolvedValue(androidDevice);
207+
208+
const keys = await resolveRequestExecutionLockKeys({
209+
req: {
210+
token: 'test-token',
211+
session: 'fresh-replay-android',
212+
command: 'replay',
213+
positionals: [replayPath],
214+
flags: {},
215+
meta: { cwd: root },
216+
},
217+
sessionName: 'fresh-replay-android',
218+
sessionStore,
219+
});
220+
221+
expect(keys).toEqual(['session:fresh-replay-android', 'device:ANDROID-EMULATOR']);
222+
expect(mockResolveTargetDevice).toHaveBeenCalledWith({ platform: 'android' }, undefined);
223+
});
224+
125225
test('open --debug writes bounded open timing diagnostics to requestLogPath', async () => {
126226
const sessionStore = makeSessionStore('agent-device-router-open-');
127227
const device = makeIosDevice('SIM-DEBUG');

src/daemon/handlers/__tests__/session-command-replay.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ test('replay parses inline open runtime flags and replays open with runtime payl
111111
expect(response?.ok).toBe(true);
112112
expect(invoked[0]?.command).toBe('open');
113113
expect(invoked[0]?.positionals).toEqual(['Demo']);
114-
expect(invoked[0]?.flags).toEqual({ relaunch: true });
114+
expect(invoked[0]?.flags).toEqual({ relaunch: true, platform: 'android' });
115115
expect(invoked[0]?.runtime).toEqual({
116116
platform: 'android',
117117
metroHost: '10.0.0.10',

src/daemon/handlers/__tests__/session-replay-runtime-plan.test.ts

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import path from 'node:path';
1111
import { runReplayScriptFile } from '../session-replay-runtime.ts';
1212
import { SessionStore } from '../../session-store.ts';
1313
import { dispatchCommand, resolveTargetDevice } from '../../../core/dispatch.ts';
14-
import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts';
14+
import {
15+
makeAndroidSession,
16+
makeIosSession,
17+
} from '../../../__tests__/test-utils/session-factories.ts';
1518
import {
1619
baseReplayRequest as baseReq,
1720
writeReplayFile,
@@ -395,6 +398,102 @@ test('typed Maestro rejects selectors that conflict with an active session', asy
395398
expect(invoke).not.toHaveBeenCalled();
396399
});
397400

401+
test('fresh typed Maestro replay resolves its configured app before runtime defaults', async () => {
402+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-maestro-app-selection-'));
403+
const sessionStore = new SessionStore(path.join(root, 'sessions'));
404+
const flowPath = path.join(root, 'flow.yaml');
405+
fs.writeFileSync(flowPath, 'appId: com.example.demo\n---\n- launchApp\n');
406+
const genericDevice = { ...makeIosSession('generic').device, id: 'SIM-GENERIC' };
407+
const appDevice = { ...makeIosSession('app').device, id: 'SIM-WITH-APP' };
408+
mockResolveTargetDevice.mockImplementation(async (_flags, options) =>
409+
options?.appleSimulatorAppTarget === 'com.example.demo' ? appDevice : genericDevice,
410+
);
411+
const invoke = vi.fn(async () => ({ ok: true as const, data: {} }));
412+
413+
const response = await runReplayScriptFile({
414+
req: baseReq({
415+
positionals: [flowPath],
416+
flags: { replayBackend: 'maestro', platform: 'ios' },
417+
runtime: { metroPort: 8081 },
418+
}),
419+
sessionName: 'default',
420+
logPath: path.join(root, 'daemon.log'),
421+
sessionStore,
422+
invoke,
423+
});
424+
425+
expect(response.ok).toBe(true);
426+
expect(mockResolveTargetDevice).toHaveBeenCalledWith(
427+
{ replayBackend: 'maestro', platform: 'ios' },
428+
{ appleSimulatorAppTarget: 'com.example.demo' },
429+
);
430+
expect(invoke).toHaveBeenCalledWith(
431+
expect.objectContaining({
432+
command: 'open',
433+
flags: expect.objectContaining({ udid: 'SIM-WITH-APP' }),
434+
}),
435+
);
436+
});
437+
438+
test('native replay applies an authored Android platform to its static app open', async () => {
439+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-native-android-selection-'));
440+
const sessionStore = new SessionStore(path.join(root, 'sessions'));
441+
const replayPath = path.join(root, 'flow.ad');
442+
fs.writeFileSync(
443+
replayPath,
444+
'runtime set --platform android --metro-port 8081\nopen com.example.demo\n',
445+
);
446+
const invoke = vi.fn(async () => ({ ok: true as const, data: {} }));
447+
448+
const response = await runReplayScriptFile({
449+
req: baseReq({ positionals: [replayPath] }),
450+
sessionName: 'default',
451+
logPath: path.join(root, 'daemon.log'),
452+
sessionStore,
453+
invoke,
454+
});
455+
456+
expect(response.ok).toBe(true);
457+
expect(invoke).toHaveBeenCalledWith(
458+
expect.objectContaining({
459+
command: 'open',
460+
positionals: ['com.example.demo'],
461+
flags: expect.objectContaining({ platform: 'android' }),
462+
}),
463+
);
464+
});
465+
466+
test('platform-less typed Maestro replay preserves a resolved Android device', async () => {
467+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-maestro-android-selection-'));
468+
const sessionStore = new SessionStore(path.join(root, 'sessions'));
469+
const flowPath = path.join(root, 'flow.yaml');
470+
fs.writeFileSync(flowPath, 'appId: com.example.demo\n---\n- launchApp\n');
471+
const androidDevice = { ...makeAndroidSession('android').device, id: 'ANDROID-EMULATOR' };
472+
mockResolveTargetDevice.mockResolvedValue(androidDevice);
473+
const invoke = vi.fn(async () => ({ ok: true as const, data: {} }));
474+
475+
const response = await runReplayScriptFile({
476+
req: baseReq({
477+
positionals: [flowPath],
478+
flags: { replayBackend: 'maestro' },
479+
runtime: { metroPort: 8081 },
480+
}),
481+
sessionName: 'default',
482+
logPath: path.join(root, 'daemon.log'),
483+
sessionStore,
484+
invoke,
485+
});
486+
487+
expect(response.ok).toBe(true);
488+
expect(mockResolveTargetDevice).toHaveBeenCalledWith({ replayBackend: 'maestro' }, {});
489+
expect(invoke).toHaveBeenCalledWith(
490+
expect.objectContaining({
491+
command: 'open',
492+
flags: expect.objectContaining({ platform: 'android', serial: 'ANDROID-EMULATOR' }),
493+
}),
494+
);
495+
});
496+
398497
test('typed Maestro resume digest binds effective stored runtime hints', async () => {
399498
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-maestro-session-runtime-'));
400499
const sessionStore = new SessionStore(path.join(root, 'sessions'));

0 commit comments

Comments
 (0)