agent-device 0.3.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -16
- package/dist/src/bin.js +35 -96
- package/dist/src/daemon.js +16 -15
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +24 -0
- package/ios-runner/README.md +1 -1
- package/package.json +1 -1
- package/skills/agent-device/SKILL.md +32 -14
- package/skills/agent-device/references/permissions.md +15 -1
- package/skills/agent-device/references/session-management.md +2 -0
- package/skills/agent-device/references/snapshot-refs.md +2 -0
- package/skills/agent-device/references/video-recording.md +2 -0
- package/src/cli.ts +7 -3
- package/src/core/__tests__/capabilities.test.ts +11 -6
- package/src/core/__tests__/open-target.test.ts +16 -0
- package/src/core/capabilities.ts +26 -20
- package/src/core/dispatch.ts +110 -31
- package/src/core/open-target.ts +13 -0
- package/src/daemon/__tests__/app-state.test.ts +138 -0
- package/src/daemon/__tests__/session-store.test.ts +24 -0
- package/src/daemon/app-state.ts +37 -38
- package/src/daemon/context.ts +12 -0
- package/src/daemon/handlers/__tests__/interaction.test.ts +22 -0
- package/src/daemon/handlers/__tests__/session.test.ts +226 -5
- package/src/daemon/handlers/__tests__/snapshot-handler.test.ts +92 -0
- package/src/daemon/handlers/interaction.ts +37 -0
- package/src/daemon/handlers/record-trace.ts +1 -1
- package/src/daemon/handlers/session.ts +96 -26
- package/src/daemon/handlers/snapshot.ts +21 -3
- package/src/daemon/session-store.ts +11 -0
- package/src/daemon-client.ts +14 -6
- package/src/daemon.ts +1 -1
- package/src/platforms/android/__tests__/index.test.ts +67 -1
- package/src/platforms/android/index.ts +41 -0
- package/src/platforms/ios/__tests__/index.test.ts +24 -0
- package/src/platforms/ios/__tests__/runner-client.test.ts +113 -0
- package/src/platforms/ios/devices.ts +40 -18
- package/src/platforms/ios/index.ts +70 -5
- package/src/platforms/ios/runner-client.ts +329 -42
- package/src/utils/__tests__/args.test.ts +175 -0
- package/src/utils/args.ts +174 -212
- package/src/utils/command-schema.ts +591 -0
- package/src/utils/interactors.ts +13 -3
|
@@ -41,6 +41,8 @@ export async function handleInteractionCommands(params: {
|
|
|
41
41
|
}
|
|
42
42
|
const refInput = req.positionals?.[0] ?? '';
|
|
43
43
|
if (refInput.startsWith('@')) {
|
|
44
|
+
const invalidRefFlagsResponse = refSnapshotFlagGuardResponse('click', req.flags);
|
|
45
|
+
if (invalidRefFlagsResponse) return invalidRefFlagsResponse;
|
|
44
46
|
if (!session.snapshot) {
|
|
45
47
|
return { ok: false, error: { code: 'INVALID_ARGS', message: 'No snapshot in session. Run snapshot first.' } };
|
|
46
48
|
}
|
|
@@ -126,6 +128,8 @@ export async function handleInteractionCommands(params: {
|
|
|
126
128
|
if (command === 'fill') {
|
|
127
129
|
const session = sessionStore.get(sessionName);
|
|
128
130
|
if (req.positionals?.[0]?.startsWith('@')) {
|
|
131
|
+
const invalidRefFlagsResponse = refSnapshotFlagGuardResponse('fill', req.flags);
|
|
132
|
+
if (invalidRefFlagsResponse) return invalidRefFlagsResponse;
|
|
129
133
|
if (!session?.snapshot) {
|
|
130
134
|
return { ok: false, error: { code: 'INVALID_ARGS', message: 'No snapshot in session. Run snapshot first.' } };
|
|
131
135
|
}
|
|
@@ -258,6 +262,8 @@ export async function handleInteractionCommands(params: {
|
|
|
258
262
|
}
|
|
259
263
|
const refInput = req.positionals?.[1] ?? '';
|
|
260
264
|
if (refInput.startsWith('@')) {
|
|
265
|
+
const invalidRefFlagsResponse = refSnapshotFlagGuardResponse('get', req.flags);
|
|
266
|
+
if (invalidRefFlagsResponse) return invalidRefFlagsResponse;
|
|
261
267
|
if (!session.snapshot) {
|
|
262
268
|
return { ok: false, error: { code: 'INVALID_ARGS', message: 'No snapshot in session. Run snapshot first.' } };
|
|
263
269
|
}
|
|
@@ -511,3 +517,34 @@ async function captureSnapshotForSession(
|
|
|
511
517
|
sessionStore.set(session.name, session);
|
|
512
518
|
return session.snapshot;
|
|
513
519
|
}
|
|
520
|
+
|
|
521
|
+
const REF_UNSUPPORTED_FLAG_MAP: ReadonlyArray<[keyof CommandFlags, string]> = [
|
|
522
|
+
['snapshotDepth', '--depth'],
|
|
523
|
+
['snapshotScope', '--scope'],
|
|
524
|
+
['snapshotRaw', '--raw'],
|
|
525
|
+
['snapshotBackend', '--backend'],
|
|
526
|
+
];
|
|
527
|
+
|
|
528
|
+
function refSnapshotFlagGuardResponse(
|
|
529
|
+
command: 'click' | 'fill' | 'get',
|
|
530
|
+
flags: CommandFlags | undefined,
|
|
531
|
+
): DaemonResponse | null {
|
|
532
|
+
const unsupported = unsupportedRefSnapshotFlags(flags);
|
|
533
|
+
if (unsupported.length === 0) return null;
|
|
534
|
+
return {
|
|
535
|
+
ok: false,
|
|
536
|
+
error: {
|
|
537
|
+
code: 'INVALID_ARGS',
|
|
538
|
+
message: `${command} @ref does not support ${unsupported.join(', ')}.`,
|
|
539
|
+
},
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export function unsupportedRefSnapshotFlags(flags: CommandFlags | undefined): string[] {
|
|
544
|
+
if (!flags) return [];
|
|
545
|
+
const unsupported: string[] = [];
|
|
546
|
+
for (const [key, label] of REF_UNSUPPORTED_FLAG_MAP) {
|
|
547
|
+
if (flags[key] !== undefined) unsupported.push(label);
|
|
548
|
+
}
|
|
549
|
+
return unsupported;
|
|
550
|
+
}
|
|
@@ -47,7 +47,7 @@ export async function handleRecordTraceCommands(params: {
|
|
|
47
47
|
if (!isCommandSupportedOnDevice('record', device)) {
|
|
48
48
|
return {
|
|
49
49
|
ok: false,
|
|
50
|
-
error: { code: 'UNSUPPORTED_OPERATION', message: 'record is only supported on iOS simulators
|
|
50
|
+
error: { code: 'UNSUPPORTED_OPERATION', message: 'record is only supported on iOS simulators' },
|
|
51
51
|
};
|
|
52
52
|
}
|
|
53
53
|
if (device.platform === 'ios') {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import { dispatchCommand, resolveTargetDevice } from '../../core/dispatch.ts';
|
|
3
3
|
import { isCommandSupportedOnDevice } from '../../core/capabilities.ts';
|
|
4
|
+
import { isDeepLinkTarget } from '../../core/open-target.ts';
|
|
4
5
|
import { AppError, asAppError } from '../../utils/errors.ts';
|
|
5
6
|
import type { DeviceInfo } from '../../utils/device.ts';
|
|
6
7
|
import type { DaemonRequest, DaemonResponse, SessionAction, SessionState } from '../types.ts';
|
|
@@ -53,6 +54,18 @@ const defaultReinstallOps: ReinstallOps = {
|
|
|
53
54
|
},
|
|
54
55
|
};
|
|
55
56
|
|
|
57
|
+
async function resolveIosBundleIdForOpen(device: DeviceInfo, openTarget: string | undefined): Promise<string | undefined> {
|
|
58
|
+
if (device.platform !== 'ios' || !openTarget || isDeepLinkTarget(openTarget)) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const { resolveIosApp } = await import('../../platforms/ios/index.ts');
|
|
63
|
+
return await resolveIosApp(device, openTarget);
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
56
69
|
export async function handleSessionCommands(params: {
|
|
57
70
|
req: DaemonRequest;
|
|
58
71
|
sessionName: string;
|
|
@@ -286,10 +299,21 @@ export async function handleSessionCommands(params: {
|
|
|
286
299
|
}
|
|
287
300
|
|
|
288
301
|
if (command === 'open') {
|
|
302
|
+
const shouldRelaunch = req.flags?.relaunch === true;
|
|
289
303
|
if (sessionStore.has(sessionName)) {
|
|
290
304
|
const session = sessionStore.get(sessionName);
|
|
291
|
-
const
|
|
292
|
-
|
|
305
|
+
const requestedOpenTarget = req.positionals?.[0];
|
|
306
|
+
const openTarget = requestedOpenTarget ?? (shouldRelaunch ? session?.appName : undefined);
|
|
307
|
+
if (!session || !openTarget) {
|
|
308
|
+
if (shouldRelaunch) {
|
|
309
|
+
return {
|
|
310
|
+
ok: false,
|
|
311
|
+
error: {
|
|
312
|
+
code: 'INVALID_ARGS',
|
|
313
|
+
message: 'open --relaunch requires an app name or an active session app.',
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
}
|
|
293
317
|
return {
|
|
294
318
|
ok: false,
|
|
295
319
|
error: {
|
|
@@ -298,33 +322,60 @@ export async function handleSessionCommands(params: {
|
|
|
298
322
|
},
|
|
299
323
|
};
|
|
300
324
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
}
|
|
325
|
+
if (shouldRelaunch && isDeepLinkTarget(openTarget)) {
|
|
326
|
+
return {
|
|
327
|
+
ok: false,
|
|
328
|
+
error: {
|
|
329
|
+
code: 'INVALID_ARGS',
|
|
330
|
+
message: 'open --relaunch does not support URL targets.',
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
const appBundleId = await resolveIosBundleIdForOpen(session.device, openTarget);
|
|
335
|
+
const openPositionals = requestedOpenTarget ? (req.positionals ?? []) : [openTarget];
|
|
336
|
+
if (shouldRelaunch) {
|
|
337
|
+
const closeTarget = appBundleId ?? openTarget;
|
|
338
|
+
await dispatch(session.device, 'close', [closeTarget], req.flags?.out, {
|
|
339
|
+
...contextFromFlags(logPath, req.flags, appBundleId ?? session.appBundleId, session.trace?.outPath),
|
|
340
|
+
});
|
|
309
341
|
}
|
|
310
|
-
await dispatch(session.device, 'open',
|
|
342
|
+
await dispatch(session.device, 'open', openPositionals, req.flags?.out, {
|
|
311
343
|
...contextFromFlags(logPath, req.flags, appBundleId),
|
|
312
344
|
});
|
|
313
345
|
const nextSession: SessionState = {
|
|
314
346
|
...session,
|
|
315
347
|
appBundleId,
|
|
316
|
-
appName,
|
|
348
|
+
appName: openTarget,
|
|
317
349
|
recordSession: session.recordSession || req.flags?.saveScript === true,
|
|
318
350
|
snapshot: undefined,
|
|
319
351
|
};
|
|
320
352
|
sessionStore.recordAction(nextSession, {
|
|
321
353
|
command,
|
|
322
|
-
positionals:
|
|
354
|
+
positionals: openPositionals,
|
|
323
355
|
flags: req.flags ?? {},
|
|
324
|
-
result: { session: sessionName, appName, appBundleId },
|
|
356
|
+
result: { session: sessionName, appName: openTarget, appBundleId },
|
|
325
357
|
});
|
|
326
358
|
sessionStore.set(sessionName, nextSession);
|
|
327
|
-
return { ok: true, data: { session: sessionName, appName, appBundleId } };
|
|
359
|
+
return { ok: true, data: { session: sessionName, appName: openTarget, appBundleId } };
|
|
360
|
+
}
|
|
361
|
+
const openTarget = req.positionals?.[0];
|
|
362
|
+
if (shouldRelaunch && !openTarget) {
|
|
363
|
+
return {
|
|
364
|
+
ok: false,
|
|
365
|
+
error: {
|
|
366
|
+
code: 'INVALID_ARGS',
|
|
367
|
+
message: 'open --relaunch requires an app argument.',
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
if (shouldRelaunch && openTarget && isDeepLinkTarget(openTarget)) {
|
|
372
|
+
return {
|
|
373
|
+
ok: false,
|
|
374
|
+
error: {
|
|
375
|
+
code: 'INVALID_ARGS',
|
|
376
|
+
message: 'open --relaunch does not support URL targets.',
|
|
377
|
+
},
|
|
378
|
+
};
|
|
328
379
|
}
|
|
329
380
|
const device = await resolveTargetDevice(req.flags ?? {});
|
|
330
381
|
const inUse = sessionStore.toArray().find((s) => s.device.id === device.id);
|
|
@@ -338,15 +389,12 @@ export async function handleSessionCommands(params: {
|
|
|
338
389
|
},
|
|
339
390
|
};
|
|
340
391
|
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
} catch {
|
|
348
|
-
appBundleId = undefined;
|
|
349
|
-
}
|
|
392
|
+
const appBundleId = await resolveIosBundleIdForOpen(device, openTarget);
|
|
393
|
+
if (shouldRelaunch && openTarget) {
|
|
394
|
+
const closeTarget = appBundleId ?? openTarget;
|
|
395
|
+
await dispatch(device, 'close', [closeTarget], req.flags?.out, {
|
|
396
|
+
...contextFromFlags(logPath, req.flags, appBundleId),
|
|
397
|
+
});
|
|
350
398
|
}
|
|
351
399
|
await dispatch(device, 'open', req.positionals ?? [], req.flags?.out, {
|
|
352
400
|
...contextFromFlags(logPath, req.flags, appBundleId),
|
|
@@ -356,7 +404,7 @@ export async function handleSessionCommands(params: {
|
|
|
356
404
|
device,
|
|
357
405
|
createdAt: Date.now(),
|
|
358
406
|
appBundleId,
|
|
359
|
-
appName,
|
|
407
|
+
appName: openTarget,
|
|
360
408
|
recordSession: req.flags?.saveScript === true,
|
|
361
409
|
actions: [],
|
|
362
410
|
};
|
|
@@ -449,7 +497,7 @@ export async function handleSessionCommands(params: {
|
|
|
449
497
|
...contextFromFlags(logPath, req.flags, session.appBundleId, session.trace?.outPath),
|
|
450
498
|
});
|
|
451
499
|
}
|
|
452
|
-
if (session.device.platform === 'ios'
|
|
500
|
+
if (session.device.platform === 'ios') {
|
|
453
501
|
await stopIosRunnerSession(session.device.id);
|
|
454
502
|
}
|
|
455
503
|
sessionStore.recordAction(session, {
|
|
@@ -822,6 +870,19 @@ function parseReplayScriptLine(line: string): SessionAction | null {
|
|
|
822
870
|
return action;
|
|
823
871
|
}
|
|
824
872
|
|
|
873
|
+
if (command === 'open') {
|
|
874
|
+
action.positionals = [];
|
|
875
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
876
|
+
const token = args[index];
|
|
877
|
+
if (token === '--relaunch') {
|
|
878
|
+
action.flags.relaunch = true;
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
action.positionals.push(token);
|
|
882
|
+
}
|
|
883
|
+
return action;
|
|
884
|
+
}
|
|
885
|
+
|
|
825
886
|
if (command === 'click') {
|
|
826
887
|
if (args.length === 0) return action;
|
|
827
888
|
const target = args[0];
|
|
@@ -948,6 +1009,15 @@ function formatReplayActionLine(action: SessionAction): string {
|
|
|
948
1009
|
}
|
|
949
1010
|
return parts.join(' ');
|
|
950
1011
|
}
|
|
1012
|
+
if (action.command === 'open') {
|
|
1013
|
+
for (const positional of action.positionals ?? []) {
|
|
1014
|
+
parts.push(formatReplayArg(positional));
|
|
1015
|
+
}
|
|
1016
|
+
if (action.flags?.relaunch) {
|
|
1017
|
+
parts.push('--relaunch');
|
|
1018
|
+
}
|
|
1019
|
+
return parts.join(' ');
|
|
1020
|
+
}
|
|
951
1021
|
for (const positional of action.positionals ?? []) {
|
|
952
1022
|
parts.push(formatReplayArg(positional));
|
|
953
1023
|
}
|
|
@@ -33,7 +33,16 @@ export async function handleSnapshotCommands(params: {
|
|
|
33
33
|
ok: false,
|
|
34
34
|
error: {
|
|
35
35
|
code: 'UNSUPPORTED_OPERATION',
|
|
36
|
-
message: 'snapshot is
|
|
36
|
+
message: 'snapshot is not supported on this device',
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (device.platform === 'ios' && device.kind === 'device' && req.flags?.snapshotBackend === 'ax') {
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
error: {
|
|
44
|
+
code: 'UNSUPPORTED_OPERATION',
|
|
45
|
+
message: 'AX snapshot backend is not supported on iOS physical devices; use --backend xctest',
|
|
37
46
|
},
|
|
38
47
|
};
|
|
39
48
|
}
|
|
@@ -227,7 +236,7 @@ export async function handleSnapshotCommands(params: {
|
|
|
227
236
|
const timeout = timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
228
237
|
const start = Date.now();
|
|
229
238
|
while (Date.now() - start < timeout) {
|
|
230
|
-
if (device.platform === 'ios'
|
|
239
|
+
if (device.platform === 'ios') {
|
|
231
240
|
const result = (await runIosRunnerCommand(
|
|
232
241
|
device,
|
|
233
242
|
{ command: 'findText', text, appBundleId: session?.appBundleId },
|
|
@@ -260,7 +269,7 @@ export async function handleSnapshotCommands(params: {
|
|
|
260
269
|
ok: false,
|
|
261
270
|
error: {
|
|
262
271
|
code: 'UNSUPPORTED_OPERATION',
|
|
263
|
-
message: 'alert is only supported on iOS simulators
|
|
272
|
+
message: 'alert is only supported on iOS simulators',
|
|
264
273
|
},
|
|
265
274
|
};
|
|
266
275
|
}
|
|
@@ -310,6 +319,15 @@ export async function handleSnapshotCommands(params: {
|
|
|
310
319
|
};
|
|
311
320
|
}
|
|
312
321
|
const { session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags);
|
|
322
|
+
if (!isCommandSupportedOnDevice('settings', device)) {
|
|
323
|
+
return {
|
|
324
|
+
ok: false,
|
|
325
|
+
error: {
|
|
326
|
+
code: 'UNSUPPORTED_OPERATION',
|
|
327
|
+
message: 'settings is not supported on this device',
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
}
|
|
313
331
|
const appBundleId = session?.appBundleId;
|
|
314
332
|
const data = await dispatchCommand(
|
|
315
333
|
device,
|
|
@@ -166,6 +166,7 @@ function sanitizeFlags(flags: CommandFlags | undefined): SessionAction['flags']
|
|
|
166
166
|
snapshotRaw,
|
|
167
167
|
snapshotBackend,
|
|
168
168
|
appsMetadata,
|
|
169
|
+
relaunch,
|
|
169
170
|
saveScript,
|
|
170
171
|
noRecord,
|
|
171
172
|
} = flags;
|
|
@@ -183,6 +184,7 @@ function sanitizeFlags(flags: CommandFlags | undefined): SessionAction['flags']
|
|
|
183
184
|
snapshotRaw,
|
|
184
185
|
snapshotBackend,
|
|
185
186
|
appsMetadata,
|
|
187
|
+
relaunch,
|
|
186
188
|
saveScript,
|
|
187
189
|
noRecord,
|
|
188
190
|
};
|
|
@@ -261,6 +263,15 @@ function formatActionLine(action: SessionAction): string {
|
|
|
261
263
|
}
|
|
262
264
|
return parts.join(' ');
|
|
263
265
|
}
|
|
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
|
+
}
|
|
264
275
|
for (const positional of action.positionals ?? []) {
|
|
265
276
|
parts.push(formatArg(positional));
|
|
266
277
|
}
|
package/src/daemon-client.ts
CHANGED
|
@@ -35,8 +35,9 @@ export async function sendToDaemon(req: Omit<DaemonRequest, 'token'>): Promise<D
|
|
|
35
35
|
async function ensureDaemon(): Promise<DaemonInfo> {
|
|
36
36
|
const existing = readDaemonInfo();
|
|
37
37
|
const localVersion = readVersion();
|
|
38
|
-
|
|
39
|
-
if (existing &&
|
|
38
|
+
const existingReachable = existing ? await canConnect(existing) : false;
|
|
39
|
+
if (existing && existing.version === localVersion && existingReachable) return existing;
|
|
40
|
+
if (existing && (existing.version !== localVersion || !existingReachable)) {
|
|
40
41
|
removeDaemonInfo();
|
|
41
42
|
}
|
|
42
43
|
|
|
@@ -67,7 +68,11 @@ function readDaemonInfo(): DaemonInfo | null {
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
function removeDaemonInfo(): void {
|
|
70
|
-
|
|
71
|
+
try {
|
|
72
|
+
if (fs.existsSync(infoPath)) fs.unlinkSync(infoPath);
|
|
73
|
+
} catch {
|
|
74
|
+
// Best-effort cleanup only; daemon can still overwrite this file on startup.
|
|
75
|
+
}
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
async function canConnect(info: DaemonInfo): Promise<boolean> {
|
|
@@ -87,11 +92,14 @@ async function startDaemon(): Promise<void> {
|
|
|
87
92
|
const distPath = path.join(root, 'dist', 'src', 'daemon.js');
|
|
88
93
|
const srcPath = path.join(root, 'src', 'daemon.ts');
|
|
89
94
|
|
|
90
|
-
const
|
|
91
|
-
|
|
95
|
+
const hasDist = fs.existsSync(distPath);
|
|
96
|
+
const hasSrc = fs.existsSync(srcPath);
|
|
97
|
+
if (!hasDist && !hasSrc) {
|
|
92
98
|
throw new AppError('COMMAND_FAILED', 'Daemon entry not found', { distPath, srcPath });
|
|
93
99
|
}
|
|
94
|
-
const
|
|
100
|
+
const runningFromSource = process.execArgv.includes('--experimental-strip-types');
|
|
101
|
+
const useSrc = runningFromSource ? hasSrc : !hasDist && hasSrc;
|
|
102
|
+
const args = useSrc ? ['--experimental-strip-types', srcPath] : [distPath];
|
|
95
103
|
|
|
96
104
|
runCmdDetached(process.execPath, args);
|
|
97
105
|
}
|
package/src/daemon.ts
CHANGED
|
@@ -175,7 +175,7 @@ function start(): void {
|
|
|
175
175
|
const shutdown = async () => {
|
|
176
176
|
const sessionsToStop = sessionStore.toArray();
|
|
177
177
|
for (const session of sessionsToStop) {
|
|
178
|
-
if (session.device.platform === 'ios'
|
|
178
|
+
if (session.device.platform === 'ios') {
|
|
179
179
|
await stopIosRunnerSession(session.device.id);
|
|
180
180
|
}
|
|
181
181
|
sessionStore.writeSessionLog(session);
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import test from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import {
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { openAndroidApp, parseAndroidLaunchComponent, swipeAndroid } from '../index.ts';
|
|
7
|
+
import type { DeviceInfo } from '../../../utils/device.ts';
|
|
8
|
+
import { AppError } from '../../../utils/errors.ts';
|
|
4
9
|
import { findBounds, parseUiHierarchy } from '../ui-hierarchy.ts';
|
|
5
10
|
|
|
6
11
|
test('parseUiHierarchy reads double-quoted Android node attributes', () => {
|
|
@@ -89,3 +94,64 @@ test('parseAndroidLaunchComponent returns null when no component is present', ()
|
|
|
89
94
|
const stdout = 'No activity found';
|
|
90
95
|
assert.equal(parseAndroidLaunchComponent(stdout), null);
|
|
91
96
|
});
|
|
97
|
+
|
|
98
|
+
test('openAndroidApp rejects activity override for deep link URLs', async () => {
|
|
99
|
+
const device: DeviceInfo = {
|
|
100
|
+
platform: 'android',
|
|
101
|
+
id: 'emulator-5554',
|
|
102
|
+
name: 'Pixel',
|
|
103
|
+
kind: 'emulator',
|
|
104
|
+
booted: true,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
await assert.rejects(
|
|
108
|
+
() => openAndroidApp(device, ' https://example.com/path ', '.MainActivity'),
|
|
109
|
+
(error: unknown) => {
|
|
110
|
+
assert.equal(error instanceof AppError, true);
|
|
111
|
+
assert.equal((error as AppError).code, 'INVALID_ARGS');
|
|
112
|
+
return true;
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('swipeAndroid invokes adb input swipe with duration', async () => {
|
|
118
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-swipe-test-'));
|
|
119
|
+
const adbPath = path.join(tmpDir, 'adb');
|
|
120
|
+
const argsLogPath = path.join(tmpDir, 'args.log');
|
|
121
|
+
await fs.writeFile(
|
|
122
|
+
adbPath,
|
|
123
|
+
'#!/bin/sh\nprintf "%s\\n" "$@" > "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
|
|
124
|
+
'utf8',
|
|
125
|
+
);
|
|
126
|
+
await fs.chmod(adbPath, 0o755);
|
|
127
|
+
|
|
128
|
+
const previousPath = process.env.PATH;
|
|
129
|
+
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
|
|
130
|
+
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
|
|
131
|
+
process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath;
|
|
132
|
+
|
|
133
|
+
const device: DeviceInfo = {
|
|
134
|
+
platform: 'android',
|
|
135
|
+
id: 'emulator-5554',
|
|
136
|
+
name: 'Pixel',
|
|
137
|
+
kind: 'emulator',
|
|
138
|
+
booted: true,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
await swipeAndroid(device, 10, 20, 30, 40, 250);
|
|
143
|
+
const args = (await fs.readFile(argsLogPath, 'utf8'))
|
|
144
|
+
.trim()
|
|
145
|
+
.split('\n')
|
|
146
|
+
.filter(Boolean);
|
|
147
|
+
assert.deepEqual(args, ['-s', 'emulator-5554', 'shell', 'input', 'swipe', '10', '20', '30', '40', '250']);
|
|
148
|
+
} finally {
|
|
149
|
+
process.env.PATH = previousPath;
|
|
150
|
+
if (previousArgsFile === undefined) {
|
|
151
|
+
delete process.env.AGENT_DEVICE_TEST_ARGS_FILE;
|
|
152
|
+
} else {
|
|
153
|
+
process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile;
|
|
154
|
+
}
|
|
155
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
@@ -4,6 +4,7 @@ import { withRetry } from '../../utils/retry.ts';
|
|
|
4
4
|
import { AppError } from '../../utils/errors.ts';
|
|
5
5
|
import type { DeviceInfo } from '../../utils/device.ts';
|
|
6
6
|
import type { RawSnapshotNode, SnapshotOptions } from '../../utils/snapshot.ts';
|
|
7
|
+
import { isDeepLinkTarget } from '../../core/open-target.ts';
|
|
7
8
|
import { waitForAndroidBoot } from './devices.ts';
|
|
8
9
|
import { findBounds, parseBounds, parseUiHierarchy, readNodeAttributes } from './ui-hierarchy.ts';
|
|
9
10
|
|
|
@@ -157,6 +158,23 @@ export async function openAndroidApp(
|
|
|
157
158
|
if (!device.booted) {
|
|
158
159
|
await waitForAndroidBoot(device.id);
|
|
159
160
|
}
|
|
161
|
+
const deepLinkTarget = app.trim();
|
|
162
|
+
if (isDeepLinkTarget(deepLinkTarget)) {
|
|
163
|
+
if (activity) {
|
|
164
|
+
throw new AppError('INVALID_ARGS', 'Activity override is not supported when opening a deep link URL');
|
|
165
|
+
}
|
|
166
|
+
await runCmd('adb', adbArgs(device, [
|
|
167
|
+
'shell',
|
|
168
|
+
'am',
|
|
169
|
+
'start',
|
|
170
|
+
'-W',
|
|
171
|
+
'-a',
|
|
172
|
+
'android.intent.action.VIEW',
|
|
173
|
+
'-d',
|
|
174
|
+
deepLinkTarget,
|
|
175
|
+
]));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
160
178
|
const resolved = await resolveAndroidApp(device, app);
|
|
161
179
|
if (resolved.type === 'intent') {
|
|
162
180
|
if (activity) {
|
|
@@ -315,6 +333,29 @@ export async function pressAndroid(device: DeviceInfo, x: number, y: number): Pr
|
|
|
315
333
|
await runCmd('adb', adbArgs(device, ['shell', 'input', 'tap', String(x), String(y)]));
|
|
316
334
|
}
|
|
317
335
|
|
|
336
|
+
export async function swipeAndroid(
|
|
337
|
+
device: DeviceInfo,
|
|
338
|
+
x1: number,
|
|
339
|
+
y1: number,
|
|
340
|
+
x2: number,
|
|
341
|
+
y2: number,
|
|
342
|
+
durationMs = 250,
|
|
343
|
+
): Promise<void> {
|
|
344
|
+
await runCmd(
|
|
345
|
+
'adb',
|
|
346
|
+
adbArgs(device, [
|
|
347
|
+
'shell',
|
|
348
|
+
'input',
|
|
349
|
+
'swipe',
|
|
350
|
+
String(x1),
|
|
351
|
+
String(y1),
|
|
352
|
+
String(x2),
|
|
353
|
+
String(y2),
|
|
354
|
+
String(durationMs),
|
|
355
|
+
]),
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
318
359
|
export async function backAndroid(device: DeviceInfo): Promise<void> {
|
|
319
360
|
await runCmd('adb', adbArgs(device, ['shell', 'input', 'keyevent', '4']));
|
|
320
361
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { openIosApp } from '../index.ts';
|
|
4
|
+
import type { DeviceInfo } from '../../../utils/device.ts';
|
|
5
|
+
import { AppError } from '../../../utils/errors.ts';
|
|
6
|
+
|
|
7
|
+
test('openIosApp rejects deep links on iOS physical devices', async () => {
|
|
8
|
+
const device: DeviceInfo = {
|
|
9
|
+
platform: 'ios',
|
|
10
|
+
id: 'ios-device-1',
|
|
11
|
+
name: 'iPhone Device',
|
|
12
|
+
kind: 'device',
|
|
13
|
+
booted: true,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
await assert.rejects(
|
|
17
|
+
() => openIosApp(device, 'https://example.com/path'),
|
|
18
|
+
(error: unknown) => {
|
|
19
|
+
assert.equal(error instanceof AppError, true);
|
|
20
|
+
assert.equal((error as AppError).code, 'UNSUPPORTED_OPERATION');
|
|
21
|
+
return true;
|
|
22
|
+
},
|
|
23
|
+
);
|
|
24
|
+
});
|