Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/daemon/__tests__/session-selector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
);
});
36 changes: 36 additions & 0 deletions src/daemon/handlers/__tests__/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
22 changes: 20 additions & 2 deletions src/daemon/handlers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<name>" <app>).';
const BATCH_PARENT_FLAG_KEYS: Array<keyof CommandFlags> = ['platform', 'device', 'udid', 'serial', 'verbose', 'out'];
const REPLAY_PARENT_FLAG_KEYS: Array<keyof CommandFlags> = ['platform', 'device', 'udid', 'serial', 'verbose', 'out'];

function requireSessionOrExplicitSelector(
command: string,
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -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<string, unknown>;
const parentRecord = (parentFlags ?? {}) as Record<string, unknown>;
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);
}
Expand Down
4 changes: 4 additions & 0 deletions src/daemon/session-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading