Skip to content

Commit b8cc9f7

Browse files
committed
Add open --relaunch and persist replay semantics
1 parent a739120 commit b8cc9f7

7 files changed

Lines changed: 219 additions & 6 deletions

File tree

src/core/dispatch.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export type CommandFlags = {
3434
snapshotRaw?: boolean;
3535
snapshotBackend?: 'ax' | 'xctest';
3636
saveScript?: boolean;
37+
relaunch?: boolean;
3738
noRecord?: boolean;
3839
appsFilter?: 'launchable' | 'user-installed' | 'all';
3940
appsMetadata?: boolean;

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,27 @@ test('saveScript flag enables .ad session log writing', () => {
9393
const files = fs.readdirSync(root);
9494
assert.equal(files.filter((file) => file.endsWith('.ad')).length, 1);
9595
});
96+
97+
test('writeSessionLog persists open --relaunch in script output', () => {
98+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-log-relaunch-'));
99+
const store = new SessionStore(root);
100+
const session = makeSession('default');
101+
store.recordAction(session, {
102+
command: 'open',
103+
positionals: ['Settings'],
104+
flags: { platform: 'ios', saveScript: true, relaunch: true },
105+
result: {},
106+
});
107+
store.recordAction(session, {
108+
command: 'close',
109+
positionals: [],
110+
flags: { platform: 'ios' },
111+
result: {},
112+
});
113+
114+
store.writeSessionLog(session);
115+
const scriptFile = fs.readdirSync(root).find((file) => file.endsWith('.ad'));
116+
assert.ok(scriptFile);
117+
const script = fs.readFileSync(path.join(root, scriptFile!), 'utf8');
118+
assert.match(script, /open "Settings" --relaunch/);
119+
});

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

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,105 @@ test('boot succeeds for supported device in session', async () => {
120120
assert.equal(response.data?.booted, true);
121121
}
122122
});
123+
124+
test('open --relaunch closes and reopens active session app', async () => {
125+
const sessionStore = makeSessionStore();
126+
const sessionName = 'android-session';
127+
sessionStore.set(
128+
sessionName,
129+
{
130+
...makeSession(sessionName, {
131+
platform: 'android',
132+
id: 'emulator-5554',
133+
name: 'Pixel Emulator',
134+
kind: 'emulator',
135+
booted: true,
136+
}),
137+
appName: 'com.example.app',
138+
},
139+
);
140+
141+
const calls: Array<{ command: string; positionals: string[] }> = [];
142+
const response = await handleSessionCommands({
143+
req: {
144+
token: 't',
145+
session: sessionName,
146+
command: 'open',
147+
positionals: [],
148+
flags: { relaunch: true },
149+
},
150+
sessionName,
151+
logPath: path.join(os.tmpdir(), 'daemon.log'),
152+
sessionStore,
153+
invoke: noopInvoke,
154+
dispatch: async (_device, command, positionals) => {
155+
calls.push({ command, positionals });
156+
return {};
157+
},
158+
});
159+
160+
assert.ok(response);
161+
assert.equal(response?.ok, true);
162+
assert.equal(calls.length, 2);
163+
assert.deepEqual(calls[0], { command: 'close', positionals: ['com.example.app'] });
164+
assert.deepEqual(calls[1], { command: 'open', positionals: ['com.example.app'] });
165+
});
166+
167+
test('open --relaunch fails without app when no session exists', async () => {
168+
const sessionStore = makeSessionStore();
169+
const response = await handleSessionCommands({
170+
req: {
171+
token: 't',
172+
session: 'default',
173+
command: 'open',
174+
positionals: [],
175+
flags: { relaunch: true },
176+
},
177+
sessionName: 'default',
178+
logPath: path.join(os.tmpdir(), 'daemon.log'),
179+
sessionStore,
180+
invoke: noopInvoke,
181+
});
182+
183+
assert.ok(response);
184+
assert.equal(response?.ok, false);
185+
if (response && !response.ok) {
186+
assert.equal(response.error.code, 'INVALID_ARGS');
187+
assert.match(response.error.message, /requires an app argument/i);
188+
}
189+
});
190+
191+
test('replay parses open --relaunch flag and replays open with relaunch semantics', async () => {
192+
const sessionStore = makeSessionStore();
193+
const replayRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-relaunch-'));
194+
const replayPath = path.join(replayRoot, 'relaunch.ad');
195+
fs.writeFileSync(replayPath, 'open "Settings" --relaunch\n');
196+
197+
const invoked: DaemonRequest[] = [];
198+
const response = await handleSessionCommands({
199+
req: {
200+
token: 't',
201+
session: 'default',
202+
command: 'replay',
203+
positionals: [replayPath],
204+
flags: {},
205+
},
206+
sessionName: 'default',
207+
logPath: path.join(os.tmpdir(), 'daemon.log'),
208+
sessionStore,
209+
invoke: async (req) => {
210+
invoked.push(req);
211+
return { ok: true, data: {} };
212+
},
213+
});
214+
215+
assert.ok(response);
216+
assert.equal(response?.ok, true);
217+
if (response && response.ok) {
218+
assert.equal(response.data?.replayed, 1);
219+
}
220+
assert.equal(invoked.length, 1);
221+
assert.equal(invoked[0]?.command, 'open');
222+
assert.deepEqual(invoked[0]?.positionals, ['Settings']);
223+
assert.equal(invoked[0]?.flags?.relaunch, true);
224+
});

src/daemon/handlers/session.ts

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -286,10 +286,21 @@ export async function handleSessionCommands(params: {
286286
}
287287

288288
if (command === 'open') {
289+
const shouldRelaunch = req.flags?.relaunch === true;
289290
if (sessionStore.has(sessionName)) {
290291
const session = sessionStore.get(sessionName);
291-
const appName = req.positionals?.[0];
292+
const requestedAppName = req.positionals?.[0];
293+
const appName = requestedAppName ?? (shouldRelaunch ? session?.appName : undefined);
292294
if (!session || !appName) {
295+
if (shouldRelaunch) {
296+
return {
297+
ok: false,
298+
error: {
299+
code: 'INVALID_ARGS',
300+
message: 'open --relaunch requires an app name or an active session app.',
301+
},
302+
};
303+
}
293304
return {
294305
ok: false,
295306
error: {
@@ -307,7 +318,13 @@ export async function handleSessionCommands(params: {
307318
appBundleId = undefined;
308319
}
309320
}
310-
await dispatch(session.device, 'open', req.positionals ?? [], req.flags?.out, {
321+
const openPositionals = requestedAppName ? (req.positionals ?? []) : [appName];
322+
if (shouldRelaunch) {
323+
await dispatch(session.device, 'close', [appName], req.flags?.out, {
324+
...contextFromFlags(logPath, req.flags, appBundleId ?? session.appBundleId, session.trace?.outPath),
325+
});
326+
}
327+
await dispatch(session.device, 'open', openPositionals, req.flags?.out, {
311328
...contextFromFlags(logPath, req.flags, appBundleId),
312329
});
313330
const nextSession: SessionState = {
@@ -319,13 +336,23 @@ export async function handleSessionCommands(params: {
319336
};
320337
sessionStore.recordAction(nextSession, {
321338
command,
322-
positionals: req.positionals ?? [],
339+
positionals: openPositionals,
323340
flags: req.flags ?? {},
324341
result: { session: sessionName, appName, appBundleId },
325342
});
326343
sessionStore.set(sessionName, nextSession);
327344
return { ok: true, data: { session: sessionName, appName, appBundleId } };
328345
}
346+
const appName = req.positionals?.[0];
347+
if (shouldRelaunch && !appName) {
348+
return {
349+
ok: false,
350+
error: {
351+
code: 'INVALID_ARGS',
352+
message: 'open --relaunch requires an app argument.',
353+
},
354+
};
355+
}
329356
const device = await resolveTargetDevice(req.flags ?? {});
330357
const inUse = sessionStore.toArray().find((s) => s.device.id === device.id);
331358
if (inUse) {
@@ -339,15 +366,21 @@ export async function handleSessionCommands(params: {
339366
};
340367
}
341368
let appBundleId: string | undefined;
342-
const appName = req.positionals?.[0];
343369
if (device.platform === 'ios') {
344370
try {
345-
const { resolveIosApp } = await import('../../platforms/ios/index.ts');
346-
appBundleId = await resolveIosApp(device, req.positionals?.[0] ?? '');
371+
if (appName) {
372+
const { resolveIosApp } = await import('../../platforms/ios/index.ts');
373+
appBundleId = await resolveIosApp(device, appName);
374+
}
347375
} catch {
348376
appBundleId = undefined;
349377
}
350378
}
379+
if (shouldRelaunch && appName) {
380+
await dispatch(device, 'close', [appName], req.flags?.out, {
381+
...contextFromFlags(logPath, req.flags, appBundleId),
382+
});
383+
}
351384
await dispatch(device, 'open', req.positionals ?? [], req.flags?.out, {
352385
...contextFromFlags(logPath, req.flags, appBundleId),
353386
});
@@ -822,6 +855,19 @@ function parseReplayScriptLine(line: string): SessionAction | null {
822855
return action;
823856
}
824857

858+
if (command === 'open') {
859+
action.positionals = [];
860+
for (let index = 0; index < args.length; index += 1) {
861+
const token = args[index];
862+
if (token === '--relaunch') {
863+
action.flags.relaunch = true;
864+
continue;
865+
}
866+
action.positionals.push(token);
867+
}
868+
return action;
869+
}
870+
825871
if (command === 'click') {
826872
if (args.length === 0) return action;
827873
const target = args[0];
@@ -948,6 +994,15 @@ function formatReplayActionLine(action: SessionAction): string {
948994
}
949995
return parts.join(' ');
950996
}
997+
if (action.command === 'open') {
998+
for (const positional of action.positionals ?? []) {
999+
parts.push(formatReplayArg(positional));
1000+
}
1001+
if (action.flags?.relaunch) {
1002+
parts.push('--relaunch');
1003+
}
1004+
return parts.join(' ');
1005+
}
9511006
for (const positional of action.positionals ?? []) {
9521007
parts.push(formatReplayArg(positional));
9531008
}

src/daemon/session-store.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ function sanitizeFlags(flags: CommandFlags | undefined): SessionAction['flags']
166166
snapshotRaw,
167167
snapshotBackend,
168168
appsMetadata,
169+
relaunch,
169170
saveScript,
170171
noRecord,
171172
} = flags;
@@ -183,6 +184,7 @@ function sanitizeFlags(flags: CommandFlags | undefined): SessionAction['flags']
183184
snapshotRaw,
184185
snapshotBackend,
185186
appsMetadata,
187+
relaunch,
186188
saveScript,
187189
noRecord,
188190
};
@@ -261,6 +263,15 @@ function formatActionLine(action: SessionAction): string {
261263
}
262264
return parts.join(' ');
263265
}
266+
if (action.command === 'open') {
267+
for (const positional of action.positionals ?? []) {
268+
parts.push(formatArg(positional));
269+
}
270+
if (action.flags?.relaunch) {
271+
parts.push('--relaunch');
272+
}
273+
return parts.join(' ');
274+
}
264275
for (const positional of action.positionals ?? []) {
265276
parts.push(formatArg(positional));
266277
}

src/utils/__tests__/args.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { parseArgs, usage } from '../args.ts';
4+
5+
test('parseArgs recognizes --relaunch', () => {
6+
const parsed = parseArgs(['open', 'settings', '--relaunch']);
7+
assert.equal(parsed.command, 'open');
8+
assert.deepEqual(parsed.positionals, ['settings']);
9+
assert.equal(parsed.flags.relaunch, true);
10+
});
11+
12+
test('usage includes --relaunch flag', () => {
13+
assert.match(usage(), /--relaunch/);
14+
});

src/utils/args.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export type ParsedArgs = {
2222
appsMetadata?: boolean;
2323
activity?: string;
2424
saveScript?: boolean;
25+
relaunch?: boolean;
2526
noRecord?: boolean;
2627
replayUpdate?: boolean;
2728
help: boolean;
@@ -71,6 +72,10 @@ export function parseArgs(argv: string[]): ParsedArgs {
7172
flags.saveScript = true;
7273
continue;
7374
}
75+
if (arg === '--relaunch') {
76+
flags.relaunch = true;
77+
continue;
78+
}
7479
if (arg === '--update' || arg === '-u') {
7580
flags.replayUpdate = true;
7681
continue;
@@ -232,6 +237,7 @@ Flags:
232237
--verbose Stream daemon/runner logs
233238
--json JSON output
234239
--save-script Save session script (.ad) on close
240+
--relaunch open: terminate app process before launching it
235241
--no-record Do not record this action
236242
--update, -u Replay: update selectors and rewrite replay file in place
237243
--user-installed Apps: list user-installed packages (Android only)

0 commit comments

Comments
 (0)