Skip to content

Commit 4e1c76e

Browse files
authored
fix: honor replay device selectors and validate session device mismatch (#76)
1 parent 74e3753 commit 4e1c76e

4 files changed

Lines changed: 80 additions & 2 deletions

File tree

src/daemon/__tests__/session-selector.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,23 @@ test('rejects udid selector for android session', () => {
6262
err.message.includes('--udid=ABC-123'),
6363
);
6464
});
65+
66+
test('accepts matching device selector (case-insensitive)', () => {
67+
const session = makeSession();
68+
assert.doesNotThrow(() =>
69+
assertSessionSelectorMatches(session, {
70+
device: 'pixel 9',
71+
}),
72+
);
73+
});
74+
75+
test('rejects mismatched device selector', () => {
76+
const session = makeSession();
77+
assert.throws(
78+
() => assertSessionSelectorMatches(session, { device: 'thymikee-iphone' }),
79+
(err: unknown) =>
80+
err instanceof AppError &&
81+
err.code === 'INVALID_ARGS' &&
82+
err.message.includes('--device=thymikee-iphone'),
83+
);
84+
});

src/daemon/handlers/__tests__/session.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,3 +1049,39 @@ test('replay parses press series flags and passes them to invoke', async () => {
10491049
assert.equal(invoked[0]?.flags?.jitterPx, 3);
10501050
assert.equal(invoked[0]?.flags?.doubleTap, true);
10511051
});
1052+
1053+
test('replay inherits parent device selectors for each invoked step', async () => {
1054+
const sessionStore = makeSessionStore();
1055+
const replayRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-parent-selectors-'));
1056+
const replayPath = path.join(replayRoot, 'selectors.ad');
1057+
fs.writeFileSync(replayPath, 'open "com.whoop.iphone"\n');
1058+
1059+
const invoked: DaemonRequest[] = [];
1060+
const response = await handleSessionCommands({
1061+
req: {
1062+
token: 't',
1063+
session: 'default',
1064+
command: 'replay',
1065+
positionals: [replayPath],
1066+
flags: {
1067+
platform: 'ios',
1068+
device: 'thymikee-iphone',
1069+
udid: '00008150-001849640CF8401C',
1070+
},
1071+
},
1072+
sessionName: 'default',
1073+
logPath: path.join(os.tmpdir(), 'daemon.log'),
1074+
sessionStore,
1075+
invoke: async (req) => {
1076+
invoked.push(req);
1077+
return { ok: true, data: {} };
1078+
},
1079+
});
1080+
1081+
assert.ok(response);
1082+
assert.equal(response?.ok, true);
1083+
assert.equal(invoked.length, 1);
1084+
assert.equal(invoked[0]?.flags?.platform, 'ios');
1085+
assert.equal(invoked[0]?.flags?.device, 'thymikee-iphone');
1086+
assert.equal(invoked[0]?.flags?.udid, '00008150-001849640CF8401C');
1087+
});

src/daemon/handlers/session.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ type ReinstallOps = {
4141
const IOS_APPSTATE_SESSION_REQUIRED_MESSAGE =
4242
'iOS appstate requires an active session on the target device. Run open first (for example: open --session sim --platform ios --device "<name>" <app>).';
4343
const BATCH_PARENT_FLAG_KEYS: Array<keyof CommandFlags> = ['platform', 'device', 'udid', 'serial', 'verbose', 'out'];
44+
const REPLAY_PARENT_FLAG_KEYS: Array<keyof CommandFlags> = ['platform', 'device', 'udid', 'serial', 'verbose', 'out'];
4445

4546
function requireSessionOrExplicitSelector(
4647
command: string,
@@ -566,7 +567,8 @@ export async function handleSessionCommands(params: {
566567
session: sessionName,
567568
command: action.command,
568569
positionals: action.positionals ?? [],
569-
flags: action.flags ?? {},
570+
flags: buildReplayActionFlags(req.flags, action.flags),
571+
meta: req.meta,
570572
});
571573
if (response.ok) continue;
572574
if (!shouldUpdate) {
@@ -588,7 +590,8 @@ export async function handleSessionCommands(params: {
588590
session: sessionName,
589591
command: nextAction.command,
590592
positionals: nextAction.positionals ?? [],
591-
flags: nextAction.flags ?? {},
593+
flags: buildReplayActionFlags(req.flags, nextAction.flags),
594+
meta: req.meta,
592595
});
593596
if (!response.ok) {
594597
return withReplayFailureContext(response, nextAction, index, resolved);
@@ -807,6 +810,21 @@ function withReplayFailureContext(
807810
};
808811
}
809812

813+
function buildReplayActionFlags(
814+
parentFlags: CommandFlags | undefined,
815+
actionFlags: SessionAction['flags'] | undefined,
816+
): CommandFlags {
817+
const merged: CommandFlags = { ...(actionFlags ?? {}) };
818+
const mergedRecord = merged as Record<string, unknown>;
819+
const parentRecord = (parentFlags ?? {}) as Record<string, unknown>;
820+
for (const key of REPLAY_PARENT_FLAG_KEYS) {
821+
if (mergedRecord[key] === undefined && parentRecord[key] !== undefined) {
822+
mergedRecord[key] = parentRecord[key];
823+
}
824+
}
825+
return merged;
826+
}
827+
810828
function formatReplayActionSummary(action: SessionAction): string {
811829
return formatScriptActionSummary(action);
812830
}

src/daemon/session-selector.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ export function assertSessionSelectorMatches(
2323
mismatches.push(`--serial=${flags.serial}`);
2424
}
2525

26+
if (flags.device && flags.device.trim().toLowerCase() !== device.name.trim().toLowerCase()) {
27+
mismatches.push(`--device=${flags.device}`);
28+
}
29+
2630
if (mismatches.length === 0) return;
2731

2832
throw new AppError(

0 commit comments

Comments
 (0)