Skip to content

Commit d34d113

Browse files
committed
fix(replay): preserve authored Android selection
1 parent 88c8b4e commit d34d113

7 files changed

Lines changed: 216 additions & 35 deletions

src/daemon/__tests__/replay-device-selection.test.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@ import { test, expect } from 'vitest';
22
import fs from 'node:fs';
33
import os from 'node:os';
44
import path from 'node:path';
5-
import { buildReplayTargetDeviceResolutionOptions } from '../replay-device-selection.ts';
5+
import { buildReplayTargetDeviceResolution } from '../replay-device-selection.ts';
66

77
test('replay leaves deep-link opens to normal device resolution', () => {
88
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-device-selection-'));
99
const replayPath = path.join(root, 'deep-link.ad');
1010
fs.writeFileSync(replayPath, 'open demo://checkout\n');
1111

1212
expect(
13-
buildReplayTargetDeviceResolutionOptions({
13+
buildReplayTargetDeviceResolution({
1414
token: 'test-token',
1515
session: 'default',
1616
command: 'replay',
@@ -19,3 +19,22 @@ test('replay leaves deep-link opens to normal device resolution', () => {
1919
}),
2020
).toBeUndefined();
2121
});
22+
23+
test('native replay uses its authored Android runtime setting without an iOS app probe', () => {
24+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-device-selection-'));
25+
const replayPath = path.join(root, 'android.ad');
26+
fs.writeFileSync(
27+
replayPath,
28+
'runtime set --platform android --metro-port 8081\nopen com.example.demo\n',
29+
);
30+
31+
expect(
32+
buildReplayTargetDeviceResolution({
33+
token: 'test-token',
34+
session: 'default',
35+
command: 'replay',
36+
positionals: [replayPath],
37+
meta: { cwd: root },
38+
}),
39+
).toEqual({ flags: { platform: 'android' }, options: undefined });
40+
});

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

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,17 @@ function makeIosDevice(id: string): DeviceInfo {
3030
};
3131
}
3232

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+
3344
function createOpenHandler(
3445
sessionStore: ReturnType<typeof makeSessionStore>,
3546
leaseRegistry = new LeaseRegistry(),
@@ -152,11 +163,39 @@ test('fresh replay reserves its authored app simulator before any replay step',
152163

153164
expect(keys).toEqual(['session:fresh-replay', 'device:SIM-WITH-APP']);
154165
expect(mockResolveTargetDevice).toHaveBeenCalledWith(
155-
{},
166+
{ platform: 'ios' },
156167
{ appleSimulatorAppTarget: 'com.example.demo' },
157168
);
158169
});
159170

171+
test('fresh replay preserves an authored Android platform before advisory locking', async () => {
172+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-android-lock-'));
173+
const replayPath = path.join(root, 'flow.ad');
174+
fs.writeFileSync(
175+
replayPath,
176+
'runtime set --platform android --metro-port 8081\nopen com.example.demo\n',
177+
);
178+
const sessionStore = makeSessionStore('agent-device-router-replay-android-lock-');
179+
const androidDevice = makeAndroidDevice('ANDROID-EMULATOR');
180+
mockResolveTargetDevice.mockResolvedValue(androidDevice);
181+
182+
const keys = await resolveRequestExecutionLockKeys({
183+
req: {
184+
token: 'test-token',
185+
session: 'fresh-replay-android',
186+
command: 'replay',
187+
positionals: [replayPath],
188+
flags: {},
189+
meta: { cwd: root },
190+
},
191+
sessionName: 'fresh-replay-android',
192+
sessionStore,
193+
});
194+
195+
expect(keys).toEqual(['session:fresh-replay-android', 'device:ANDROID-EMULATOR']);
196+
expect(mockResolveTargetDevice).toHaveBeenCalledWith({ platform: 'android' }, undefined);
197+
});
198+
160199
test('open --debug writes bounded open timing diagnostics to requestLogPath', async () => {
161200
const sessionStore = makeSessionStore('agent-device-router-open-');
162201
const device = makeIosDevice('SIM-DEBUG');

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

Lines changed: 65 additions & 3 deletions
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,
@@ -410,7 +413,7 @@ test('fresh typed Maestro replay resolves its configured app before runtime defa
410413
const response = await runReplayScriptFile({
411414
req: baseReq({
412415
positionals: [flowPath],
413-
flags: { replayBackend: 'maestro' },
416+
flags: { replayBackend: 'maestro', platform: 'ios' },
414417
runtime: { metroPort: 8081 },
415418
}),
416419
sessionName: 'default',
@@ -421,7 +424,7 @@ test('fresh typed Maestro replay resolves its configured app before runtime defa
421424

422425
expect(response.ok).toBe(true);
423426
expect(mockResolveTargetDevice).toHaveBeenCalledWith(
424-
{ replayBackend: 'maestro' },
427+
{ replayBackend: 'maestro', platform: 'ios' },
425428
{ appleSimulatorAppTarget: 'com.example.demo' },
426429
);
427430
expect(invoke).toHaveBeenCalledWith(
@@ -432,6 +435,65 @@ test('fresh typed Maestro replay resolves its configured app before runtime defa
432435
);
433436
});
434437

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+
435497
test('typed Maestro resume digest binds effective stored runtime hints', async () => {
436498
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-maestro-session-runtime-'));
437499
const sessionStore = new SessionStore(path.join(root, 'sessions'));

src/daemon/handlers/session-replay-maestro-runtime.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ async function resolveMaestroReplayBinding(params: {
216216
? undefined
217217
: await resolveTargetDevice(
218218
req.flags ?? {},
219-
buildMaestroReplayTargetDeviceResolutionOptions(program),
219+
buildMaestroReplayTargetDeviceResolutionOptions(program, requestedPlatform),
220220
));
221221
const platform = resolveMaestroPlatform(req, device);
222222
const runtimeHints = resolveEffectiveOpenRuntimeHints({
@@ -249,7 +249,7 @@ async function completeMaestroRuntimeBinding(
249249
if (params.device || !requiresDeviceRuntimeDefaults(params.runtimeHints)) return params;
250250
const device = await resolveTargetDevice(
251251
params.req.flags ?? {},
252-
buildMaestroReplayTargetDeviceResolutionOptions(params.program),
252+
buildMaestroReplayTargetDeviceResolutionOptions(params.program, params.platform),
253253
);
254254
return {
255255
device,

src/daemon/handlers/session-replay-runtime.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from '../../request/progress.ts';
1717
import { SessionStore } from '../session-store.ts';
1818
import { expandSessionPath } from '../session-paths.ts';
19+
import { buildReplayScriptPlatformFlags } from '../replay-device-selection.ts';
1920
import { applySaveScriptRetarget } from '../session-action-recorder.ts';
2021
import { computeReplayPlanDigest } from '../../replay/plan-digest.ts';
2122
import { errorResponse, noActiveSessionError } from './response.ts';
@@ -491,7 +492,10 @@ function prepareReplayPlan(params: {
491492
if (!parsedResult.ok) return parsedResult;
492493
const parsed = parsedResult.value;
493494
const { metadata, actions, actionLines, actionSourcePaths } = parsed;
494-
const replayReq = applyReplayMetadata(req, metadata);
495+
const replayReq = applyReplayMetadata(
496+
{ ...req, flags: buildReplayScriptPlatformFlags(req.flags, actions) },
497+
metadata,
498+
);
495499
const planDigest = computeReplayPlanDigest({
496500
actions,
497501
actionLines,

src/daemon/replay-device-selection.ts

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,47 @@ import type { ResolveTargetDeviceOptions } from '../core/dispatch-resolve.ts';
55
import type { MaestroProgram } from '../compat/maestro/program-ir.ts';
66
import { appleSimulatorAppTargetForOpenTarget } from './open-device-selection.ts';
77
import { SessionStore } from './session-store.ts';
8+
import type { CommandFlags } from '../core/dispatch.ts';
89
import type { DaemonRequest, SessionAction } from './types.ts';
910

11+
export type ReplayTargetDeviceResolution = {
12+
flags: CommandFlags;
13+
options: ResolveTargetDeviceOptions | undefined;
14+
};
15+
1016
/**
1117
* Finds the first static app target a fresh replay will open. Request binding
1218
* uses this before any replay action can cache an unqualified device choice.
1319
*/
14-
export function buildReplayTargetDeviceResolutionOptions(
20+
export function buildReplayTargetDeviceResolution(
1521
req: DaemonRequest,
16-
): ResolveTargetDeviceOptions | undefined {
22+
): ReplayTargetDeviceResolution | undefined {
1723
if (req.command !== 'replay' || req.flags?.replayFrom !== undefined) return undefined;
1824
const filePath = req.positionals?.[0];
1925
if (!filePath) return undefined;
2026

2127
try {
2228
const resolved = SessionStore.expandHome(filePath, req.meta?.cwd);
2329
const source = fs.readFileSync(resolved, 'utf8');
24-
const appTarget = isMaestroReplay(req, resolved)
25-
? readMaestroReplayAppTarget(parseMaestroProgram(source, { sourcePath: resolved }))
26-
: readScriptReplayAppTarget(parseReplayInput(source, req.flags).actions);
27-
return appTargetResolutionOptions(appTarget);
30+
if (isMaestroReplay(req, resolved)) {
31+
return {
32+
flags: req.flags ?? {},
33+
options: buildMaestroReplayTargetDeviceResolutionOptions(
34+
parseMaestroProgram(source, { sourcePath: resolved }),
35+
req.flags?.platform,
36+
),
37+
};
38+
}
39+
const parsed = parseReplayInput(source, req.flags);
40+
const selection = readScriptReplaySelection(parsed.actions);
41+
if (!selection.appTarget) return undefined;
42+
const scriptFlags = buildReplayScriptPlatformFlags(req.flags, parsed.actions);
43+
const platform = scriptFlags.platform ?? parsed.metadata.platform;
44+
return {
45+
flags:
46+
platform && scriptFlags.platform === undefined ? { ...scriptFlags, platform } : scriptFlags,
47+
options: platform === 'ios' ? appTargetResolutionOptions(selection.appTarget) : undefined,
48+
};
2849
} catch {
2950
// Parsing and validation stay in the replay handler. Lock binding is only
3051
// advisory, so an unreadable/invalid plan must not mask its real error.
@@ -34,7 +55,9 @@ export function buildReplayTargetDeviceResolutionOptions(
3455

3556
export function buildMaestroReplayTargetDeviceResolutionOptions(
3657
program: MaestroProgram,
58+
platform: CommandFlags['platform'] | undefined,
3759
): ResolveTargetDeviceOptions {
60+
if (platform !== 'ios') return {};
3861
const appTarget = readMaestroReplayAppTarget(program);
3962
return appTargetResolutionOptions(appTarget) ?? {};
4063
}
@@ -46,9 +69,34 @@ function isMaestroReplay(req: DaemonRequest, filePath: string): boolean {
4669
);
4770
}
4871

49-
function readScriptReplayAppTarget(actions: SessionAction[]): string | undefined {
50-
const target = actions.find((action) => action.command === 'open')?.positionals?.[0];
51-
return isStaticAppTarget(target) ? target : undefined;
72+
function readScriptReplaySelection(actions: SessionAction[]): {
73+
appTarget: string | undefined;
74+
platform: CommandFlags['platform'] | undefined;
75+
} {
76+
let platform: CommandFlags['platform'] | undefined;
77+
for (const action of actions) {
78+
if (action.command === 'runtime' && action.flags.platform) {
79+
platform = action.flags.platform;
80+
continue;
81+
}
82+
if (action.command !== 'open') continue;
83+
platform = action.runtime?.platform ?? platform;
84+
const target = action.positionals?.[0];
85+
if (isStaticAppTarget(target)) return { appTarget: target, platform };
86+
}
87+
return { appTarget: undefined, platform };
88+
}
89+
90+
/** Applies a platform configured before the first static app open to replay dispatch. */
91+
export function buildReplayScriptPlatformFlags(
92+
flags: CommandFlags | undefined,
93+
actions: SessionAction[],
94+
): CommandFlags {
95+
const selection = readScriptReplaySelection(actions);
96+
if (flags?.platform !== undefined || !selection.appTarget || !selection.platform) {
97+
return flags ?? {};
98+
}
99+
return { ...flags, platform: selection.platform };
52100
}
53101

54102
function readMaestroReplayAppTarget(program: MaestroProgram): string | undefined {

0 commit comments

Comments
 (0)