diff --git a/src/daemon/__tests__/session-selector.test.ts b/src/daemon/__tests__/session-selector.test.ts index bb9aa0dbb..2c625c0a6 100644 --- a/src/daemon/__tests__/session-selector.test.ts +++ b/src/daemon/__tests__/session-selector.test.ts @@ -62,3 +62,23 @@ test('rejects udid selector for android session', () => { err.message.includes('--udid=ABC-123'), ); }); + +test('accepts matching device selector (case-insensitive)', () => { + const session = makeSession(); + assert.doesNotThrow(() => + assertSessionSelectorMatches(session, { + device: 'pixel 9', + }), + ); +}); + +test('rejects mismatched device selector', () => { + const session = makeSession(); + assert.throws( + () => assertSessionSelectorMatches(session, { device: 'thymikee-iphone' }), + (err: unknown) => + err instanceof AppError && + err.code === 'INVALID_ARGS' && + err.message.includes('--device=thymikee-iphone'), + ); +}); diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index e4c3e4e50..60ad36032 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -1049,3 +1049,39 @@ test('replay parses press series flags and passes them to invoke', async () => { assert.equal(invoked[0]?.flags?.jitterPx, 3); assert.equal(invoked[0]?.flags?.doubleTap, true); }); + +test('replay inherits parent device selectors for each invoked step', async () => { + const sessionStore = makeSessionStore(); + const replayRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-parent-selectors-')); + const replayPath = path.join(replayRoot, 'selectors.ad'); + fs.writeFileSync(replayPath, 'open "com.whoop.iphone"\n'); + + const invoked: DaemonRequest[] = []; + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'replay', + positionals: [replayPath], + flags: { + platform: 'ios', + device: 'thymikee-iphone', + udid: '00008150-001849640CF8401C', + }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: async (req) => { + invoked.push(req); + return { ok: true, data: {} }; + }, + }); + + assert.ok(response); + assert.equal(response?.ok, true); + assert.equal(invoked.length, 1); + assert.equal(invoked[0]?.flags?.platform, 'ios'); + assert.equal(invoked[0]?.flags?.device, 'thymikee-iphone'); + assert.equal(invoked[0]?.flags?.udid, '00008150-001849640CF8401C'); +}); diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 53439ed29..695fc7818 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -41,6 +41,7 @@ type ReinstallOps = { const IOS_APPSTATE_SESSION_REQUIRED_MESSAGE = 'iOS appstate requires an active session on the target device. Run open first (for example: open --session sim --platform ios --device "" ).'; const BATCH_PARENT_FLAG_KEYS: Array = ['platform', 'device', 'udid', 'serial', 'verbose', 'out']; +const REPLAY_PARENT_FLAG_KEYS: Array = ['platform', 'device', 'udid', 'serial', 'verbose', 'out']; function requireSessionOrExplicitSelector( command: string, @@ -566,7 +567,8 @@ export async function handleSessionCommands(params: { session: sessionName, command: action.command, positionals: action.positionals ?? [], - flags: action.flags ?? {}, + flags: buildReplayActionFlags(req.flags, action.flags), + meta: req.meta, }); if (response.ok) continue; if (!shouldUpdate) { @@ -588,7 +590,8 @@ export async function handleSessionCommands(params: { session: sessionName, command: nextAction.command, positionals: nextAction.positionals ?? [], - flags: nextAction.flags ?? {}, + flags: buildReplayActionFlags(req.flags, nextAction.flags), + meta: req.meta, }); if (!response.ok) { return withReplayFailureContext(response, nextAction, index, resolved); @@ -807,6 +810,21 @@ function withReplayFailureContext( }; } +function buildReplayActionFlags( + parentFlags: CommandFlags | undefined, + actionFlags: SessionAction['flags'] | undefined, +): CommandFlags { + const merged: CommandFlags = { ...(actionFlags ?? {}) }; + const mergedRecord = merged as Record; + const parentRecord = (parentFlags ?? {}) as Record; + for (const key of REPLAY_PARENT_FLAG_KEYS) { + if (mergedRecord[key] === undefined && parentRecord[key] !== undefined) { + mergedRecord[key] = parentRecord[key]; + } + } + return merged; +} + function formatReplayActionSummary(action: SessionAction): string { return formatScriptActionSummary(action); } diff --git a/src/daemon/session-selector.ts b/src/daemon/session-selector.ts index e5e79eaac..a54640757 100644 --- a/src/daemon/session-selector.ts +++ b/src/daemon/session-selector.ts @@ -23,6 +23,10 @@ export function assertSessionSelectorMatches( mismatches.push(`--serial=${flags.serial}`); } + if (flags.device && flags.device.trim().toLowerCase() !== device.name.trim().toLowerCase()) { + mismatches.push(`--device=${flags.device}`); + } + if (mismatches.length === 0) return; throw new AppError(