agent-device 0.1.2 → 0.1.3
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 +16 -6
- package/dist/bin/axsnapshot +0 -0
- package/dist/src/861.js +1 -1
- package/dist/src/bin.js +26 -14
- package/dist/src/daemon.js +4 -4
- package/ios-runner/AXSnapshot/Sources/AXSnapshot/main.swift +305 -28
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +59 -20
- package/package.json +4 -3
- package/src/cli.ts +22 -0
- package/src/core/dispatch.ts +66 -2
- package/src/daemon.ts +321 -37
- package/src/platforms/android/index.ts +59 -0
- package/src/platforms/ios/ax-snapshot.ts +6 -8
- package/src/platforms/ios/index.ts +34 -13
- package/src/platforms/ios/runner-client.ts +13 -1
- package/src/utils/args.ts +20 -1
- package/src/utils/exec.ts +74 -3
- package/bin/axsnapshot +0 -0
package/src/daemon.ts
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
type RawSnapshotNode,
|
|
17
17
|
} from './utils/snapshot.ts';
|
|
18
18
|
import { runIosRunnerCommand, stopIosRunnerSession } from './platforms/ios/runner-client.ts';
|
|
19
|
+
import { runCmd, runCmdBackground } from './utils/exec.ts';
|
|
20
|
+
import { snapshotAndroid } from './platforms/android/index.ts';
|
|
19
21
|
|
|
20
22
|
type DaemonRequest = {
|
|
21
23
|
token: string;
|
|
@@ -37,6 +39,13 @@ type SessionState = {
|
|
|
37
39
|
appName?: string;
|
|
38
40
|
snapshot?: SnapshotState;
|
|
39
41
|
actions: SessionAction[];
|
|
42
|
+
recording?: {
|
|
43
|
+
platform: 'ios' | 'android';
|
|
44
|
+
outPath: string;
|
|
45
|
+
remotePath?: string;
|
|
46
|
+
child: ReturnType<typeof import('node:child_process').spawn>;
|
|
47
|
+
wait: Promise<import('./utils/exec.ts').ExecResult>;
|
|
48
|
+
};
|
|
40
49
|
};
|
|
41
50
|
|
|
42
51
|
type SessionAction = {
|
|
@@ -112,6 +121,71 @@ async function handleRequest(req: DaemonRequest): Promise<DaemonResponse> {
|
|
|
112
121
|
return { ok: true, data };
|
|
113
122
|
}
|
|
114
123
|
|
|
124
|
+
if (command === 'devices') {
|
|
125
|
+
try {
|
|
126
|
+
const devices: DeviceInfo[] = [];
|
|
127
|
+
if (req.flags?.platform === 'android') {
|
|
128
|
+
const { listAndroidDevices } = await import('./platforms/android/devices.ts');
|
|
129
|
+
devices.push(...(await listAndroidDevices()));
|
|
130
|
+
} else if (req.flags?.platform === 'ios') {
|
|
131
|
+
const { listIosDevices } = await import('./platforms/ios/devices.ts');
|
|
132
|
+
devices.push(...(await listIosDevices()));
|
|
133
|
+
} else {
|
|
134
|
+
const { listAndroidDevices } = await import('./platforms/android/devices.ts');
|
|
135
|
+
const { listIosDevices } = await import('./platforms/ios/devices.ts');
|
|
136
|
+
try {
|
|
137
|
+
devices.push(...(await listAndroidDevices()));
|
|
138
|
+
} catch {
|
|
139
|
+
// ignore
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
devices.push(...(await listIosDevices()));
|
|
143
|
+
} catch {
|
|
144
|
+
// ignore
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { ok: true, data: { devices } };
|
|
148
|
+
} catch (err) {
|
|
149
|
+
const appErr = asAppError(err);
|
|
150
|
+
return { ok: false, error: { code: appErr.code, message: appErr.message, details: appErr.details } };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (command === 'apps') {
|
|
155
|
+
const session = sessions.get(sessionName);
|
|
156
|
+
const flags = req.flags ?? {};
|
|
157
|
+
if (
|
|
158
|
+
!session &&
|
|
159
|
+
!flags.platform &&
|
|
160
|
+
!flags.device &&
|
|
161
|
+
!flags.udid &&
|
|
162
|
+
!flags.serial
|
|
163
|
+
) {
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
error: {
|
|
167
|
+
code: 'INVALID_ARGS',
|
|
168
|
+
message: 'apps requires an active session or an explicit device selector (e.g. --platform ios).',
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const device = session?.device ?? (await resolveTargetDevice(flags));
|
|
173
|
+
await ensureDeviceReady(device);
|
|
174
|
+
if (device.platform === 'ios') {
|
|
175
|
+
if (device.kind !== 'simulator') {
|
|
176
|
+
return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'apps list is only supported on iOS simulators' } };
|
|
177
|
+
}
|
|
178
|
+
const { listSimulatorApps } = await import('./platforms/ios/index.ts');
|
|
179
|
+
const apps = (await listSimulatorApps(device)).map((app) =>
|
|
180
|
+
app.name && app.name !== app.bundleId ? `${app.name} (${app.bundleId})` : app.bundleId,
|
|
181
|
+
);
|
|
182
|
+
return { ok: true, data: { apps } };
|
|
183
|
+
}
|
|
184
|
+
const { listAndroidApps } = await import('./platforms/android/index.ts');
|
|
185
|
+
const apps = await listAndroidApps(device, req.flags?.appsFilter);
|
|
186
|
+
return { ok: true, data: { apps } };
|
|
187
|
+
}
|
|
188
|
+
|
|
115
189
|
if (command === 'open') {
|
|
116
190
|
if (sessions.has(sessionName)) {
|
|
117
191
|
return {
|
|
@@ -226,15 +300,31 @@ async function handleRequest(req: DaemonRequest): Promise<DaemonResponse> {
|
|
|
226
300
|
await ensureDeviceReady(device);
|
|
227
301
|
}
|
|
228
302
|
const appBundleId = session?.appBundleId;
|
|
303
|
+
let snapshotScope = req.flags?.snapshotScope;
|
|
304
|
+
if (snapshotScope && snapshotScope.trim().startsWith('@')) {
|
|
305
|
+
if (!session?.snapshot) {
|
|
306
|
+
return { ok: false, error: { code: 'INVALID_ARGS', message: 'Ref scope requires an existing snapshot in session.' } };
|
|
307
|
+
}
|
|
308
|
+
const ref = normalizeRef(snapshotScope.trim());
|
|
309
|
+
if (!ref) {
|
|
310
|
+
return { ok: false, error: { code: 'INVALID_ARGS', message: `Invalid ref scope: ${snapshotScope}` } };
|
|
311
|
+
}
|
|
312
|
+
const node = findNodeByRef(session.snapshot.nodes, ref);
|
|
313
|
+
const resolved = node ? resolveRefLabel(node, session.snapshot.nodes) : undefined;
|
|
314
|
+
if (!resolved) {
|
|
315
|
+
return { ok: false, error: { code: 'COMMAND_FAILED', message: `Ref ${snapshotScope} not found or has no label` } };
|
|
316
|
+
}
|
|
317
|
+
snapshotScope = resolved;
|
|
318
|
+
}
|
|
229
319
|
const data = (await dispatchCommand(device, 'snapshot', [], req.flags?.out, {
|
|
230
|
-
...contextFromFlags(req.flags, appBundleId),
|
|
320
|
+
...contextFromFlags({ ...req.flags, snapshotScope }, appBundleId),
|
|
231
321
|
})) as {
|
|
232
322
|
nodes?: RawSnapshotNode[];
|
|
233
323
|
truncated?: boolean;
|
|
234
324
|
backend?: 'ax' | 'xctest' | 'android';
|
|
235
325
|
};
|
|
236
|
-
const
|
|
237
|
-
const nodes = attachRefs(
|
|
326
|
+
const rawNodes = data?.nodes ?? [];
|
|
327
|
+
const nodes = attachRefs(req.flags?.snapshotRaw ? rawNodes : pruneGroupNodes(rawNodes));
|
|
238
328
|
const snapshot: SnapshotState = {
|
|
239
329
|
nodes,
|
|
240
330
|
truncated: data?.truncated,
|
|
@@ -267,6 +357,229 @@ async function handleRequest(req: DaemonRequest): Promise<DaemonResponse> {
|
|
|
267
357
|
};
|
|
268
358
|
}
|
|
269
359
|
|
|
360
|
+
if (command === 'wait') {
|
|
361
|
+
const session = sessions.get(sessionName);
|
|
362
|
+
const device = session?.device ?? (await resolveTargetDevice(req.flags ?? {}));
|
|
363
|
+
if (!session) {
|
|
364
|
+
await ensureDeviceReady(device);
|
|
365
|
+
}
|
|
366
|
+
const args = req.positionals ?? [];
|
|
367
|
+
if (args.length === 0) {
|
|
368
|
+
return { ok: false, error: { code: 'INVALID_ARGS', message: 'wait requires a duration or text' } };
|
|
369
|
+
}
|
|
370
|
+
const parseTimeout = (value: string | undefined): number | null => {
|
|
371
|
+
if (!value) return null;
|
|
372
|
+
const parsed = Number(value);
|
|
373
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
374
|
+
};
|
|
375
|
+
const sleepMs = parseTimeout(args[0]);
|
|
376
|
+
if (sleepMs !== null) {
|
|
377
|
+
await new Promise((resolve) => setTimeout(resolve, sleepMs));
|
|
378
|
+
if (session) {
|
|
379
|
+
recordAction(session, { command, positionals: req.positionals ?? [], flags: req.flags ?? {}, result: { waitedMs: sleepMs } });
|
|
380
|
+
}
|
|
381
|
+
return { ok: true, data: { waitedMs: sleepMs } };
|
|
382
|
+
}
|
|
383
|
+
let text = '';
|
|
384
|
+
let timeoutMs: number | null = null;
|
|
385
|
+
if (args[0] === 'text') {
|
|
386
|
+
timeoutMs = parseTimeout(args[args.length - 1]);
|
|
387
|
+
text = timeoutMs !== null ? args.slice(1, -1).join(' ') : args.slice(1).join(' ');
|
|
388
|
+
} else if (args[0].startsWith('@')) {
|
|
389
|
+
if (!session?.snapshot) {
|
|
390
|
+
return { ok: false, error: { code: 'INVALID_ARGS', message: 'Ref wait requires an existing snapshot in session.' } };
|
|
391
|
+
}
|
|
392
|
+
const ref = normalizeRef(args[0]);
|
|
393
|
+
if (!ref) {
|
|
394
|
+
return { ok: false, error: { code: 'INVALID_ARGS', message: `Invalid ref: ${args[0]}` } };
|
|
395
|
+
}
|
|
396
|
+
const node = findNodeByRef(session.snapshot.nodes, ref);
|
|
397
|
+
const resolved = node ? resolveRefLabel(node, session.snapshot.nodes) : undefined;
|
|
398
|
+
if (!resolved) {
|
|
399
|
+
return { ok: false, error: { code: 'COMMAND_FAILED', message: `Ref ${args[0]} not found or has no label` } };
|
|
400
|
+
}
|
|
401
|
+
timeoutMs = parseTimeout(args[args.length - 1]);
|
|
402
|
+
text = resolved;
|
|
403
|
+
} else {
|
|
404
|
+
timeoutMs = parseTimeout(args[args.length - 1]);
|
|
405
|
+
text = timeoutMs !== null ? args.slice(0, -1).join(' ') : args.join(' ');
|
|
406
|
+
}
|
|
407
|
+
text = text.trim();
|
|
408
|
+
if (!text) {
|
|
409
|
+
return { ok: false, error: { code: 'INVALID_ARGS', message: 'wait requires text' } };
|
|
410
|
+
}
|
|
411
|
+
const timeout = timeoutMs ?? 10000;
|
|
412
|
+
const start = Date.now();
|
|
413
|
+
while (Date.now() - start < timeout) {
|
|
414
|
+
if (device.platform === 'ios' && device.kind === 'simulator') {
|
|
415
|
+
const result = (await runIosRunnerCommand(
|
|
416
|
+
device,
|
|
417
|
+
{ command: 'findText', text, appBundleId: session?.appBundleId },
|
|
418
|
+
{ verbose: req.flags?.verbose, logPath },
|
|
419
|
+
)) as { found?: boolean };
|
|
420
|
+
if (result?.found) {
|
|
421
|
+
if (session) {
|
|
422
|
+
recordAction(session, { command, positionals: req.positionals ?? [], flags: req.flags ?? {}, result: { text, waitedMs: Date.now() - start } });
|
|
423
|
+
}
|
|
424
|
+
return { ok: true, data: { text, waitedMs: Date.now() - start } };
|
|
425
|
+
}
|
|
426
|
+
} else if (device.platform === 'android') {
|
|
427
|
+
const androidResult = await snapshotAndroid(device, { scope: text });
|
|
428
|
+
if (findNodeByLabel(attachRefs(androidResult.nodes ?? []), text)) {
|
|
429
|
+
if (session) {
|
|
430
|
+
recordAction(session, { command, positionals: req.positionals ?? [], flags: req.flags ?? {}, result: { text, waitedMs: Date.now() - start } });
|
|
431
|
+
}
|
|
432
|
+
return { ok: true, data: { text, waitedMs: Date.now() - start } };
|
|
433
|
+
}
|
|
434
|
+
} else {
|
|
435
|
+
return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'wait is not supported on this device' } };
|
|
436
|
+
}
|
|
437
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
438
|
+
}
|
|
439
|
+
return { ok: false, error: { code: 'COMMAND_FAILED', message: `wait timed out for text: ${text}` } };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (command === 'alert') {
|
|
443
|
+
const session = sessions.get(sessionName);
|
|
444
|
+
const device = session?.device ?? (await resolveTargetDevice(req.flags ?? {}));
|
|
445
|
+
if (!session) {
|
|
446
|
+
await ensureDeviceReady(device);
|
|
447
|
+
}
|
|
448
|
+
const action = (req.positionals?.[0] ?? 'get').toLowerCase();
|
|
449
|
+
const parseTimeout = (value: string | undefined): number | null => {
|
|
450
|
+
if (!value) return null;
|
|
451
|
+
const parsed = Number(value);
|
|
452
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
453
|
+
};
|
|
454
|
+
if (device.platform !== 'ios' || device.kind !== 'simulator') {
|
|
455
|
+
return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'alert is only supported on iOS simulators in v1' } };
|
|
456
|
+
}
|
|
457
|
+
if (action === 'wait') {
|
|
458
|
+
const timeout = parseTimeout(req.positionals?.[1]) ?? 10000;
|
|
459
|
+
const start = Date.now();
|
|
460
|
+
while (Date.now() - start < timeout) {
|
|
461
|
+
try {
|
|
462
|
+
const data = await runIosRunnerCommand(
|
|
463
|
+
device,
|
|
464
|
+
{ command: 'alert', action: 'get', appBundleId: session?.appBundleId },
|
|
465
|
+
{ verbose: req.flags?.verbose, logPath },
|
|
466
|
+
);
|
|
467
|
+
if (session) {
|
|
468
|
+
recordAction(session, {
|
|
469
|
+
command,
|
|
470
|
+
positionals: req.positionals ?? [],
|
|
471
|
+
flags: req.flags ?? {},
|
|
472
|
+
result: data,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
return { ok: true, data };
|
|
476
|
+
} catch {
|
|
477
|
+
// keep waiting
|
|
478
|
+
}
|
|
479
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
480
|
+
}
|
|
481
|
+
return { ok: false, error: { code: 'COMMAND_FAILED', message: 'alert wait timed out' } };
|
|
482
|
+
}
|
|
483
|
+
const data = await runIosRunnerCommand(
|
|
484
|
+
device,
|
|
485
|
+
{
|
|
486
|
+
command: 'alert',
|
|
487
|
+
action: action === 'accept' || action === 'dismiss' ? (action as 'accept' | 'dismiss') : 'get',
|
|
488
|
+
appBundleId: session?.appBundleId,
|
|
489
|
+
},
|
|
490
|
+
{ verbose: req.flags?.verbose, logPath },
|
|
491
|
+
);
|
|
492
|
+
if (session) {
|
|
493
|
+
recordAction(session, {
|
|
494
|
+
command,
|
|
495
|
+
positionals: req.positionals ?? [],
|
|
496
|
+
flags: req.flags ?? {},
|
|
497
|
+
result: data,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
return { ok: true, data };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (command === 'record') {
|
|
504
|
+
const action = (req.positionals?.[0] ?? '').toLowerCase();
|
|
505
|
+
if (!['start', 'stop'].includes(action)) {
|
|
506
|
+
return { ok: false, error: { code: 'INVALID_ARGS', message: 'record requires start|stop' } };
|
|
507
|
+
}
|
|
508
|
+
const session = sessions.get(sessionName);
|
|
509
|
+
const device = session?.device ?? (await resolveTargetDevice(req.flags ?? {}));
|
|
510
|
+
if (!session) {
|
|
511
|
+
await ensureDeviceReady(device);
|
|
512
|
+
}
|
|
513
|
+
const activeSession = session ?? {
|
|
514
|
+
name: sessionName,
|
|
515
|
+
device,
|
|
516
|
+
createdAt: Date.now(),
|
|
517
|
+
actions: [],
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
if (action === 'start') {
|
|
521
|
+
if (activeSession.recording) {
|
|
522
|
+
return { ok: false, error: { code: 'INVALID_ARGS', message: 'recording already in progress' } };
|
|
523
|
+
}
|
|
524
|
+
const outPath = req.positionals?.[1] ?? `./recording-${Date.now()}.mp4`;
|
|
525
|
+
const resolvedOut = path.resolve(outPath);
|
|
526
|
+
const outDir = path.dirname(resolvedOut);
|
|
527
|
+
if (!fs.existsSync(outDir)) {
|
|
528
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
529
|
+
}
|
|
530
|
+
if (device.platform === 'ios') {
|
|
531
|
+
if (device.kind !== 'simulator') {
|
|
532
|
+
return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'record is only supported on iOS simulators in v1' } };
|
|
533
|
+
}
|
|
534
|
+
const { child, wait } = runCmdBackground('xcrun', ['simctl', 'io', device.id, 'recordVideo', resolvedOut], {
|
|
535
|
+
allowFailure: true,
|
|
536
|
+
});
|
|
537
|
+
activeSession.recording = { platform: 'ios', outPath: resolvedOut, child, wait };
|
|
538
|
+
} else {
|
|
539
|
+
const remotePath = `/sdcard/agent-device-recording-${Date.now()}.mp4`;
|
|
540
|
+
const { child, wait } = runCmdBackground('adb', ['-s', device.id, 'shell', 'screenrecord', remotePath], {
|
|
541
|
+
allowFailure: true,
|
|
542
|
+
});
|
|
543
|
+
activeSession.recording = { platform: 'android', outPath: resolvedOut, remotePath, child, wait };
|
|
544
|
+
}
|
|
545
|
+
sessions.set(sessionName, activeSession);
|
|
546
|
+
recordAction(activeSession, {
|
|
547
|
+
command,
|
|
548
|
+
positionals: req.positionals ?? [],
|
|
549
|
+
flags: req.flags ?? {},
|
|
550
|
+
result: { action: 'start' },
|
|
551
|
+
});
|
|
552
|
+
return { ok: true, data: { recording: 'started', outPath } };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (!activeSession.recording) {
|
|
556
|
+
return { ok: false, error: { code: 'INVALID_ARGS', message: 'no active recording' } };
|
|
557
|
+
}
|
|
558
|
+
const recording = activeSession.recording;
|
|
559
|
+
recording.child.kill('SIGINT');
|
|
560
|
+
try {
|
|
561
|
+
await recording.wait;
|
|
562
|
+
} catch {
|
|
563
|
+
// ignore
|
|
564
|
+
}
|
|
565
|
+
if (recording.platform === 'android' && recording.remotePath) {
|
|
566
|
+
try {
|
|
567
|
+
await runCmd('adb', ['-s', device.id, 'pull', recording.remotePath, recording.outPath], { allowFailure: true });
|
|
568
|
+
await runCmd('adb', ['-s', device.id, 'shell', 'rm', '-f', recording.remotePath], { allowFailure: true });
|
|
569
|
+
} catch {
|
|
570
|
+
// ignore
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
activeSession.recording = undefined;
|
|
574
|
+
recordAction(activeSession, {
|
|
575
|
+
command,
|
|
576
|
+
positionals: req.positionals ?? [],
|
|
577
|
+
flags: req.flags ?? {},
|
|
578
|
+
result: { action: 'stop', outPath: recording.outPath },
|
|
579
|
+
});
|
|
580
|
+
return { ok: true, data: { recording: 'stopped', outPath: recording.outPath } };
|
|
581
|
+
}
|
|
582
|
+
|
|
270
583
|
if (command === 'click') {
|
|
271
584
|
const session = sessions.get(sessionName);
|
|
272
585
|
if (!session?.snapshot) {
|
|
@@ -410,39 +723,6 @@ async function handleRequest(req: DaemonRequest): Promise<DaemonResponse> {
|
|
|
410
723
|
return { ok: true, data: { ref, text, node } };
|
|
411
724
|
}
|
|
412
725
|
|
|
413
|
-
if (command === 'rect') {
|
|
414
|
-
const session = sessions.get(sessionName);
|
|
415
|
-
if (!session?.snapshot) {
|
|
416
|
-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'No snapshot in session. Run snapshot first.' } };
|
|
417
|
-
}
|
|
418
|
-
const target = req.positionals?.[0] ?? '';
|
|
419
|
-
const ref = normalizeRef(target);
|
|
420
|
-
let label = '';
|
|
421
|
-
if (ref) {
|
|
422
|
-
const node = findNodeByRef(session.snapshot.nodes, ref);
|
|
423
|
-
label = node?.label?.trim() ?? '';
|
|
424
|
-
} else {
|
|
425
|
-
label = req.positionals.join(' ').trim();
|
|
426
|
-
}
|
|
427
|
-
if (!label) {
|
|
428
|
-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'rect requires a label or ref with label' } };
|
|
429
|
-
}
|
|
430
|
-
if (session.device.platform !== 'ios' || session.device.kind !== 'simulator') {
|
|
431
|
-
return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'rect is only supported on iOS simulators' } };
|
|
432
|
-
}
|
|
433
|
-
const data = await runIosRunnerCommand(
|
|
434
|
-
session.device,
|
|
435
|
-
{ command: 'rect', text: label, appBundleId: session.appBundleId },
|
|
436
|
-
{ verbose: req.flags?.verbose, logPath },
|
|
437
|
-
);
|
|
438
|
-
recordAction(session, {
|
|
439
|
-
command,
|
|
440
|
-
positionals: req.positionals ?? [],
|
|
441
|
-
flags: req.flags ?? {},
|
|
442
|
-
result: { label, rect: data?.rect },
|
|
443
|
-
});
|
|
444
|
-
return { ok: true, data: { label, rect: data?.rect } };
|
|
445
|
-
}
|
|
446
726
|
|
|
447
727
|
const session = sessions.get(sessionName);
|
|
448
728
|
if (!session) {
|
|
@@ -836,7 +1116,11 @@ function pruneGroupNodes(nodes: RawSnapshotNode[]): RawSnapshotNode[] {
|
|
|
836
1116
|
skippedDepths.pop();
|
|
837
1117
|
}
|
|
838
1118
|
const type = normalizeType(node.type ?? '');
|
|
839
|
-
|
|
1119
|
+
const labelCandidate = [node.label, node.value, node.identifier]
|
|
1120
|
+
.map((value) => (typeof value === 'string' ? value.trim() : ''))
|
|
1121
|
+
.find((value) => value && value.length > 0);
|
|
1122
|
+
const hasMeaningfulLabel = labelCandidate ? isMeaningfulLabel(labelCandidate) : false;
|
|
1123
|
+
if ((type === 'group' || type === 'ioscontentgroup') && !hasMeaningfulLabel) {
|
|
840
1124
|
skippedDepths.push(depth);
|
|
841
1125
|
continue;
|
|
842
1126
|
}
|
|
@@ -43,6 +43,53 @@ export async function resolveAndroidApp(
|
|
|
43
43
|
throw new AppError('APP_NOT_INSTALLED', `No package found matching "${app}"`);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
export async function listAndroidApps(
|
|
47
|
+
device: DeviceInfo,
|
|
48
|
+
filter: 'launchable' | 'user-installed' | 'all' = 'launchable',
|
|
49
|
+
): Promise<string[]> {
|
|
50
|
+
if (filter === 'launchable') {
|
|
51
|
+
const result = await runCmd(
|
|
52
|
+
'adb',
|
|
53
|
+
adbArgs(device, [
|
|
54
|
+
'shell',
|
|
55
|
+
'cmd',
|
|
56
|
+
'package',
|
|
57
|
+
'query-activities',
|
|
58
|
+
'--brief',
|
|
59
|
+
'-a',
|
|
60
|
+
'android.intent.action.MAIN',
|
|
61
|
+
'-c',
|
|
62
|
+
'android.intent.category.LAUNCHER',
|
|
63
|
+
]),
|
|
64
|
+
{ allowFailure: true },
|
|
65
|
+
);
|
|
66
|
+
if (result.exitCode === 0 && result.stdout.trim().length > 0) {
|
|
67
|
+
const packages = new Set<string>();
|
|
68
|
+
for (const line of result.stdout.split('\n')) {
|
|
69
|
+
const trimmed = line.trim();
|
|
70
|
+
if (!trimmed) continue;
|
|
71
|
+
const firstToken = trimmed.split(/\s+/)[0];
|
|
72
|
+
const pkg = firstToken.includes('/') ? firstToken.split('/')[0] : firstToken;
|
|
73
|
+
if (pkg) packages.add(pkg);
|
|
74
|
+
}
|
|
75
|
+
if (packages.size > 0) {
|
|
76
|
+
return Array.from(packages);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// fallback: list all if query-activities not available
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const args =
|
|
83
|
+
filter === 'user-installed'
|
|
84
|
+
? ['shell', 'pm', 'list', 'packages', '-3']
|
|
85
|
+
: ['shell', 'pm', 'list', 'packages'];
|
|
86
|
+
const result = await runCmd('adb', adbArgs(device, args));
|
|
87
|
+
return result.stdout
|
|
88
|
+
.split('\n')
|
|
89
|
+
.map((line: string) => line.replace('package:', '').trim())
|
|
90
|
+
.filter(Boolean);
|
|
91
|
+
}
|
|
92
|
+
|
|
46
93
|
export async function openAndroidApp(device: DeviceInfo, app: string): Promise<void> {
|
|
47
94
|
if (!device.booted) {
|
|
48
95
|
await waitForAndroidBoot(device.id);
|
|
@@ -89,6 +136,18 @@ export async function pressAndroid(device: DeviceInfo, x: number, y: number): Pr
|
|
|
89
136
|
await runCmd('adb', adbArgs(device, ['shell', 'input', 'tap', String(x), String(y)]));
|
|
90
137
|
}
|
|
91
138
|
|
|
139
|
+
export async function backAndroid(device: DeviceInfo): Promise<void> {
|
|
140
|
+
await runCmd('adb', adbArgs(device, ['shell', 'input', 'keyevent', '4']));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function homeAndroid(device: DeviceInfo): Promise<void> {
|
|
144
|
+
await runCmd('adb', adbArgs(device, ['shell', 'input', 'keyevent', '3']));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function appSwitcherAndroid(device: DeviceInfo): Promise<void> {
|
|
148
|
+
await runCmd('adb', adbArgs(device, ['shell', 'input', 'keyevent', '187']));
|
|
149
|
+
}
|
|
150
|
+
|
|
92
151
|
export async function longPressAndroid(
|
|
93
152
|
device: DeviceInfo,
|
|
94
153
|
x: number,
|
|
@@ -32,6 +32,10 @@ export async function snapshotAx(
|
|
|
32
32
|
' Enable Accessibility for your terminal in System Settings > Privacy & Security > Accessibility, ' +
|
|
33
33
|
'or use --backend xctest (slower snapshots via XCTest).';
|
|
34
34
|
}
|
|
35
|
+
if (stderrText.toLowerCase().includes('could not find ios app content')) {
|
|
36
|
+
hint =
|
|
37
|
+
' AX snapshot sometimes caches empty content. Try restarting the Simulator app.';
|
|
38
|
+
}
|
|
35
39
|
throw new AppError('COMMAND_FAILED', 'AX snapshot failed', {
|
|
36
40
|
stderr: `${stderrText}${hint}`,
|
|
37
41
|
stdout: result.stdout,
|
|
@@ -127,14 +131,8 @@ async function ensureAxSnapshotBinary(): Promise<string> {
|
|
|
127
131
|
const packageDir = path.join(projectRoot, 'ios-runner', 'AXSnapshot');
|
|
128
132
|
const envPath = process.env.AGENT_DEVICE_AX_BINARY;
|
|
129
133
|
if (envPath && fs.existsSync(envPath)) return envPath;
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
path.join(projectRoot, 'dist', 'bin', 'axsnapshot'),
|
|
133
|
-
path.join(projectRoot, 'dist', 'axsnapshot'),
|
|
134
|
-
];
|
|
135
|
-
for (const candidate of packagedCandidates) {
|
|
136
|
-
if (fs.existsSync(candidate)) return candidate;
|
|
137
|
-
}
|
|
134
|
+
const packagedPath = path.join(projectRoot, 'dist', 'bin', 'axsnapshot');
|
|
135
|
+
if (fs.existsSync(packagedPath)) return packagedPath;
|
|
138
136
|
const binaryPath = path.join(packageDir, '.build', 'release', 'axsnapshot');
|
|
139
137
|
if (fs.existsSync(binaryPath)) return binaryPath;
|
|
140
138
|
const result = await runCmd('swift', ['build', '-c', 'release'], {
|
|
@@ -163,24 +163,45 @@ function ensureSimulator(device: DeviceInfo, command: string): void {
|
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
async function listSimulatorApps(
|
|
166
|
+
export async function listSimulatorApps(
|
|
167
167
|
device: DeviceInfo,
|
|
168
168
|
): Promise<{ bundleId: string; name: string }[]> {
|
|
169
169
|
const result = await runCmd('xcrun', ['simctl', 'listapps', device.id], { allowFailure: true });
|
|
170
170
|
const stdout = result.stdout as string;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
171
|
+
const trimmed = stdout.trim();
|
|
172
|
+
if (!trimmed) return [];
|
|
173
|
+
let parsed: Record<string, { CFBundleDisplayName?: string; CFBundleName?: string }> | null = null;
|
|
174
|
+
if (trimmed.startsWith('{')) {
|
|
175
|
+
try {
|
|
176
|
+
parsed = JSON.parse(trimmed) as Record<
|
|
177
|
+
string,
|
|
178
|
+
{ CFBundleDisplayName?: string; CFBundleName?: string }
|
|
179
|
+
>;
|
|
180
|
+
} catch {
|
|
181
|
+
parsed = null;
|
|
182
|
+
}
|
|
183
183
|
}
|
|
184
|
+
if (!parsed && trimmed.startsWith('{')) {
|
|
185
|
+
try {
|
|
186
|
+
const converted = await runCmd('plutil', ['-convert', 'json', '-o', '-', '-'], {
|
|
187
|
+
allowFailure: true,
|
|
188
|
+
stdin: trimmed,
|
|
189
|
+
});
|
|
190
|
+
if (converted.exitCode === 0 && converted.stdout.trim().startsWith('{')) {
|
|
191
|
+
parsed = JSON.parse(converted.stdout) as Record<
|
|
192
|
+
string,
|
|
193
|
+
{ CFBundleDisplayName?: string; CFBundleName?: string }
|
|
194
|
+
>;
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
197
|
+
parsed = null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (!parsed) return [];
|
|
201
|
+
return Object.entries(parsed).map(([bundleId, info]) => ({
|
|
202
|
+
bundleId,
|
|
203
|
+
name: info.CFBundleDisplayName ?? info.CFBundleName ?? bundleId,
|
|
204
|
+
}));
|
|
184
205
|
}
|
|
185
206
|
|
|
186
207
|
export async function ensureBootedSimulator(device: DeviceInfo): Promise<void> {
|
|
@@ -8,9 +8,21 @@ import type { DeviceInfo } from '../../utils/device.ts';
|
|
|
8
8
|
import net from 'node:net';
|
|
9
9
|
|
|
10
10
|
export type RunnerCommand = {
|
|
11
|
-
command:
|
|
11
|
+
command:
|
|
12
|
+
| 'tap'
|
|
13
|
+
| 'type'
|
|
14
|
+
| 'swipe'
|
|
15
|
+
| 'findText'
|
|
16
|
+
| 'listTappables'
|
|
17
|
+
| 'snapshot'
|
|
18
|
+
| 'back'
|
|
19
|
+
| 'home'
|
|
20
|
+
| 'appSwitcher'
|
|
21
|
+
| 'alert'
|
|
22
|
+
| 'shutdown';
|
|
12
23
|
appBundleId?: string;
|
|
13
24
|
text?: string;
|
|
25
|
+
action?: 'get' | 'accept' | 'dismiss';
|
|
14
26
|
x?: number;
|
|
15
27
|
y?: number;
|
|
16
28
|
direction?: 'up' | 'down' | 'left' | 'right';
|
package/src/utils/args.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type ParsedArgs = {
|
|
|
18
18
|
snapshotScope?: string;
|
|
19
19
|
snapshotRaw?: boolean;
|
|
20
20
|
snapshotBackend?: 'ax' | 'xctest';
|
|
21
|
+
appsFilter?: 'launchable' | 'user-installed' | 'all';
|
|
21
22
|
noRecord?: boolean;
|
|
22
23
|
recordJson?: boolean;
|
|
23
24
|
help: boolean;
|
|
@@ -62,6 +63,14 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
62
63
|
flags.recordJson = true;
|
|
63
64
|
continue;
|
|
64
65
|
}
|
|
66
|
+
if (arg === '--user-installed') {
|
|
67
|
+
flags.appsFilter = 'user-installed';
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (arg === '--all') {
|
|
71
|
+
flags.appsFilter = 'all';
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
65
74
|
if (arg.startsWith('--backend')) {
|
|
66
75
|
const value = arg.includes('=')
|
|
67
76
|
? arg.split('=')[1]
|
|
@@ -156,8 +165,14 @@ Commands:
|
|
|
156
165
|
--raw Raw node output
|
|
157
166
|
--backend ax|xctest ax: macOS Accessibility tree (fast, recommended, needs permissions)
|
|
158
167
|
xctest: XCTest snapshot (slower, no permissions)
|
|
168
|
+
devices List available devices
|
|
169
|
+
apps [--user-installed|--all] List installed apps (Android launchable by default, iOS simulator)
|
|
170
|
+
back Navigate back (where supported)
|
|
171
|
+
home Go to home screen (where supported)
|
|
172
|
+
app-switcher Open app switcher (where supported)
|
|
173
|
+
wait <ms>|text <text>|@ref [timeoutMs] Wait for duration or text to appear
|
|
174
|
+
alert [get|accept|dismiss|wait] [timeout] Inspect or handle alert (iOS simulator)
|
|
159
175
|
click <@ref> Click element by snapshot ref
|
|
160
|
-
rect <label|@ref> Fetch element frame by label or ref (iOS sim)
|
|
161
176
|
get text <@ref> Return element text by ref
|
|
162
177
|
get attrs <@ref> Return element attributes by ref
|
|
163
178
|
replay <path> Replay a recorded session
|
|
@@ -169,6 +184,8 @@ Commands:
|
|
|
169
184
|
scroll <direction> [amount] Scroll in direction (0-1 amount)
|
|
170
185
|
scrollintoview <text> Scroll until text appears (Android only)
|
|
171
186
|
screenshot [--out path] Capture screenshot
|
|
187
|
+
record start [path] Start screen recording
|
|
188
|
+
record stop Stop screen recording
|
|
172
189
|
session list List active sessions
|
|
173
190
|
|
|
174
191
|
Flags:
|
|
@@ -182,5 +199,7 @@ Flags:
|
|
|
182
199
|
--json JSON output
|
|
183
200
|
--no-record Do not record this action
|
|
184
201
|
--record-json Record JSON session log
|
|
202
|
+
--user-installed Apps: list user-installed packages (Android only)
|
|
203
|
+
--all Apps: list all packages (Android only)
|
|
185
204
|
`;
|
|
186
205
|
}
|