agent-device 0.4.2 → 0.5.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.
Files changed (91) hide show
  1. package/README.md +2 -9
  2. package/dist/src/797.js +1 -1
  3. package/dist/src/bin.js +5 -5
  4. package/dist/src/daemon.js +16 -20
  5. package/package.json +7 -9
  6. package/skills/agent-device/SKILL.md +3 -6
  7. package/skills/agent-device/references/permissions.md +3 -15
  8. package/skills/agent-device/references/snapshot-refs.md +1 -4
  9. package/dist/bin/axsnapshot +0 -0
  10. package/ios-runner/AXSnapshot/Package.swift +0 -18
  11. package/ios-runner/AXSnapshot/Sources/AXSnapshot/main.swift +0 -444
  12. package/src/__tests__/cli-close.test.ts +0 -155
  13. package/src/__tests__/cli-help.test.ts +0 -102
  14. package/src/bin.ts +0 -3
  15. package/src/cli.ts +0 -305
  16. package/src/core/__tests__/capabilities.test.ts +0 -75
  17. package/src/core/__tests__/dispatch-open.test.ts +0 -25
  18. package/src/core/__tests__/open-target.test.ts +0 -55
  19. package/src/core/capabilities.ts +0 -57
  20. package/src/core/dispatch.ts +0 -382
  21. package/src/core/open-target.ts +0 -27
  22. package/src/daemon/__tests__/device-ready.test.ts +0 -52
  23. package/src/daemon/__tests__/is-predicates.test.ts +0 -68
  24. package/src/daemon/__tests__/selectors.test.ts +0 -261
  25. package/src/daemon/__tests__/session-routing.test.ts +0 -108
  26. package/src/daemon/__tests__/session-selector.test.ts +0 -64
  27. package/src/daemon/__tests__/session-store.test.ts +0 -142
  28. package/src/daemon/__tests__/snapshot-processing.test.ts +0 -47
  29. package/src/daemon/action-utils.ts +0 -29
  30. package/src/daemon/context.ts +0 -48
  31. package/src/daemon/device-ready.ts +0 -155
  32. package/src/daemon/handlers/__tests__/find.test.ts +0 -99
  33. package/src/daemon/handlers/__tests__/interaction.test.ts +0 -22
  34. package/src/daemon/handlers/__tests__/replay-heal.test.ts +0 -509
  35. package/src/daemon/handlers/__tests__/session-reinstall.test.ts +0 -219
  36. package/src/daemon/handlers/__tests__/session.test.ts +0 -820
  37. package/src/daemon/handlers/__tests__/snapshot-handler.test.ts +0 -92
  38. package/src/daemon/handlers/__tests__/snapshot.test.ts +0 -128
  39. package/src/daemon/handlers/find.ts +0 -324
  40. package/src/daemon/handlers/interaction.ts +0 -550
  41. package/src/daemon/handlers/parse-utils.ts +0 -8
  42. package/src/daemon/handlers/record-trace.ts +0 -154
  43. package/src/daemon/handlers/session.ts +0 -1137
  44. package/src/daemon/handlers/snapshot.ts +0 -439
  45. package/src/daemon/is-predicates.ts +0 -46
  46. package/src/daemon/selectors.ts +0 -540
  47. package/src/daemon/session-routing.ts +0 -22
  48. package/src/daemon/session-selector.ts +0 -39
  49. package/src/daemon/session-store.ts +0 -296
  50. package/src/daemon/snapshot-processing.ts +0 -131
  51. package/src/daemon/types.ts +0 -56
  52. package/src/daemon-client.ts +0 -272
  53. package/src/daemon.ts +0 -295
  54. package/src/platforms/__tests__/boot-diagnostics.test.ts +0 -59
  55. package/src/platforms/android/__tests__/index.test.ts +0 -274
  56. package/src/platforms/android/devices.ts +0 -196
  57. package/src/platforms/android/index.ts +0 -784
  58. package/src/platforms/android/ui-hierarchy.ts +0 -312
  59. package/src/platforms/boot-diagnostics.ts +0 -128
  60. package/src/platforms/ios/__tests__/index.test.ts +0 -312
  61. package/src/platforms/ios/__tests__/runner-client.test.ts +0 -155
  62. package/src/platforms/ios/apps.ts +0 -358
  63. package/src/platforms/ios/ax-snapshot.ts +0 -207
  64. package/src/platforms/ios/config.ts +0 -28
  65. package/src/platforms/ios/devicectl.ts +0 -134
  66. package/src/platforms/ios/devices.ts +0 -100
  67. package/src/platforms/ios/index.ts +0 -20
  68. package/src/platforms/ios/runner-client.ts +0 -994
  69. package/src/platforms/ios/simulator.ts +0 -164
  70. package/src/utils/__tests__/args.test.ts +0 -239
  71. package/src/utils/__tests__/daemon-client.test.ts +0 -95
  72. package/src/utils/__tests__/exec.test.ts +0 -16
  73. package/src/utils/__tests__/finders.test.ts +0 -34
  74. package/src/utils/__tests__/keyed-lock.test.ts +0 -55
  75. package/src/utils/__tests__/process-identity.test.ts +0 -33
  76. package/src/utils/__tests__/retry.test.ts +0 -44
  77. package/src/utils/args.ts +0 -239
  78. package/src/utils/command-schema.ts +0 -622
  79. package/src/utils/device.ts +0 -84
  80. package/src/utils/errors.ts +0 -35
  81. package/src/utils/exec.ts +0 -339
  82. package/src/utils/finders.ts +0 -101
  83. package/src/utils/interactive.ts +0 -4
  84. package/src/utils/interactors.ts +0 -173
  85. package/src/utils/keyed-lock.ts +0 -14
  86. package/src/utils/output.ts +0 -204
  87. package/src/utils/process-identity.ts +0 -100
  88. package/src/utils/retry.ts +0 -180
  89. package/src/utils/snapshot.ts +0 -64
  90. package/src/utils/timeouts.ts +0 -9
  91. package/src/utils/version.ts +0 -26
@@ -1,1137 +0,0 @@
1
- import fs from 'node:fs';
2
- import { dispatchCommand, resolveTargetDevice } from '../../core/dispatch.ts';
3
- import { isCommandSupportedOnDevice } from '../../core/capabilities.ts';
4
- import { isDeepLinkTarget, resolveIosDeviceDeepLinkBundleId } from '../../core/open-target.ts';
5
- import { AppError, asAppError } from '../../utils/errors.ts';
6
- import type { DeviceInfo } from '../../utils/device.ts';
7
- import type { DaemonRequest, DaemonResponse, SessionAction, SessionState } from '../types.ts';
8
- import { SessionStore } from '../session-store.ts';
9
- import { contextFromFlags } from '../context.ts';
10
- import { ensureDeviceReady } from '../device-ready.ts';
11
- import { stopIosRunnerSession } from '../../platforms/ios/runner-client.ts';
12
- import { attachRefs, type RawSnapshotNode, type SnapshotState } from '../../utils/snapshot.ts';
13
- import { extractNodeText, normalizeType, pruneGroupNodes } from '../snapshot-processing.ts';
14
- import {
15
- buildSelectorChainForNode,
16
- resolveSelectorChain,
17
- splitIsSelectorArgs,
18
- splitSelectorFromArgs,
19
- tryParseSelectorChain,
20
- } from '../selectors.ts';
21
- import { inferFillText, uniqueStrings } from '../action-utils.ts';
22
-
23
- type ReinstallOps = {
24
- ios: (device: DeviceInfo, app: string, appPath: string) => Promise<{ bundleId: string }>;
25
- android: (device: DeviceInfo, app: string, appPath: string) => Promise<{ package: string }>;
26
- };
27
-
28
- const IOS_APPSTATE_SESSION_REQUIRED_MESSAGE =
29
- 'iOS appstate requires an active session on the target device. Run open first (for example: open --session sim --platform ios --device "<name>" <app>).';
30
-
31
- function requireSessionOrExplicitSelector(
32
- command: string,
33
- session: SessionState | undefined,
34
- flags: DaemonRequest['flags'] | undefined,
35
- ): DaemonResponse | null {
36
- if (session || hasExplicitDeviceSelector(flags)) {
37
- return null;
38
- }
39
- return {
40
- ok: false,
41
- error: {
42
- code: 'INVALID_ARGS',
43
- message: `${command} requires an active session or an explicit device selector (e.g. --platform ios).`,
44
- },
45
- };
46
- }
47
-
48
- function hasExplicitDeviceSelector(flags: DaemonRequest['flags'] | undefined): boolean {
49
- return Boolean(flags?.platform || flags?.device || flags?.udid || flags?.serial);
50
- }
51
-
52
- function hasExplicitSessionFlag(flags: DaemonRequest['flags'] | undefined): boolean {
53
- return typeof flags?.session === 'string' && flags.session.trim().length > 0;
54
- }
55
-
56
- function selectorTargetsSessionDevice(
57
- flags: DaemonRequest['flags'] | undefined,
58
- session: SessionState | undefined,
59
- ): boolean {
60
- if (!session) return false;
61
- if (!hasExplicitDeviceSelector(flags)) return true;
62
- if (flags?.platform && flags.platform !== session.device.platform) return false;
63
- if (flags?.udid && flags.udid !== session.device.id) return false;
64
- if (flags?.serial && flags.serial !== session.device.id) return false;
65
- if (flags?.device) {
66
- return flags.device.trim().toLowerCase() === session.device.name.trim().toLowerCase();
67
- }
68
- return true;
69
- }
70
-
71
- async function resolveCommandDevice(params: {
72
- session: SessionState | undefined;
73
- flags: DaemonRequest['flags'] | undefined;
74
- ensureReadyFn: typeof ensureDeviceReady;
75
- resolveTargetDeviceFn: typeof resolveTargetDevice;
76
- ensureReady?: boolean;
77
- }): Promise<DeviceInfo> {
78
- const shouldUseExplicitSelector = hasExplicitDeviceSelector(params.flags);
79
- const device =
80
- shouldUseExplicitSelector || !params.session
81
- ? await params.resolveTargetDeviceFn(params.flags ?? {})
82
- : params.session.device;
83
- if (params.ensureReady !== false) {
84
- await params.ensureReadyFn(device);
85
- }
86
- return device;
87
- }
88
-
89
- const defaultReinstallOps: ReinstallOps = {
90
- ios: async (device, app, appPath) => {
91
- const { reinstallIosApp } = await import('../../platforms/ios/index.ts');
92
- return await reinstallIosApp(device, app, appPath);
93
- },
94
- android: async (device, app, appPath) => {
95
- const { reinstallAndroidApp } = await import('../../platforms/android/index.ts');
96
- return await reinstallAndroidApp(device, app, appPath);
97
- },
98
- };
99
-
100
- async function resolveIosBundleIdForOpen(
101
- device: DeviceInfo,
102
- openTarget: string | undefined,
103
- currentAppBundleId?: string,
104
- ): Promise<string | undefined> {
105
- if (device.platform !== 'ios' || !openTarget) return undefined;
106
- if (isDeepLinkTarget(openTarget)) {
107
- if (device.kind === 'device') {
108
- return resolveIosDeviceDeepLinkBundleId(currentAppBundleId, openTarget);
109
- }
110
- return undefined;
111
- }
112
- return await tryResolveIosAppBundleId(device, openTarget);
113
- }
114
-
115
- async function tryResolveIosAppBundleId(device: DeviceInfo, openTarget: string): Promise<string | undefined> {
116
- try {
117
- const { resolveIosApp } = await import('../../platforms/ios/index.ts');
118
- return await resolveIosApp(device, openTarget);
119
- } catch {
120
- return undefined;
121
- }
122
- }
123
-
124
- async function handleAppStateCommand(params: {
125
- req: DaemonRequest;
126
- sessionName: string;
127
- sessionStore: SessionStore;
128
- ensureReady: typeof ensureDeviceReady;
129
- resolveDevice: typeof resolveTargetDevice;
130
- }): Promise<DaemonResponse> {
131
- const { req, sessionName, sessionStore, ensureReady, resolveDevice } = params;
132
- const session = sessionStore.get(sessionName);
133
- const flags = req.flags ?? {};
134
- if (!session && hasExplicitSessionFlag(flags)) {
135
- const iOSSessionHint =
136
- flags.platform === 'ios'
137
- ? `No active session "${sessionName}". Run open with --session ${sessionName} first.`
138
- : `No active session "${sessionName}". Run open with --session ${sessionName} first, or omit --session to query by device selector.`;
139
- return {
140
- ok: false,
141
- error: {
142
- code: 'SESSION_NOT_FOUND',
143
- message: iOSSessionHint,
144
- },
145
- };
146
- }
147
- const guard = requireSessionOrExplicitSelector('appstate', session, flags);
148
- if (guard) return guard;
149
-
150
- const shouldUseSessionStateForIos = session?.device.platform === 'ios' && selectorTargetsSessionDevice(flags, session);
151
- const targetsIos = flags.platform === 'ios';
152
- if (targetsIos && !shouldUseSessionStateForIos) {
153
- return {
154
- ok: false,
155
- error: {
156
- code: 'SESSION_NOT_FOUND',
157
- message: IOS_APPSTATE_SESSION_REQUIRED_MESSAGE,
158
- },
159
- };
160
- }
161
- if (shouldUseSessionStateForIos) {
162
- const appName = session.appName ?? session.appBundleId;
163
- if (!session.appName && !session.appBundleId) {
164
- return {
165
- ok: false,
166
- error: {
167
- code: 'COMMAND_FAILED',
168
- message: 'No foreground app is tracked for this iOS session. Open an app in the session, then retry appstate.',
169
- },
170
- };
171
- }
172
- return {
173
- ok: true,
174
- data: {
175
- platform: 'ios',
176
- appName: appName ?? 'unknown',
177
- appBundleId: session.appBundleId,
178
- source: 'session',
179
- },
180
- };
181
- }
182
- const device = await resolveCommandDevice({
183
- session,
184
- flags,
185
- ensureReadyFn: ensureReady,
186
- resolveTargetDeviceFn: resolveDevice,
187
- ensureReady: true,
188
- });
189
- if (device.platform === 'ios') {
190
- return {
191
- ok: false,
192
- error: {
193
- code: 'SESSION_NOT_FOUND',
194
- message: IOS_APPSTATE_SESSION_REQUIRED_MESSAGE,
195
- },
196
- };
197
- }
198
- const { getAndroidAppState } = await import('../../platforms/android/index.ts');
199
- const state = await getAndroidAppState(device);
200
- return {
201
- ok: true,
202
- data: {
203
- platform: 'android',
204
- package: state.package,
205
- activity: state.activity,
206
- },
207
- };
208
- }
209
-
210
- export async function handleSessionCommands(params: {
211
- req: DaemonRequest;
212
- sessionName: string;
213
- logPath: string;
214
- sessionStore: SessionStore;
215
- invoke: (req: DaemonRequest) => Promise<DaemonResponse>;
216
- dispatch?: typeof dispatchCommand;
217
- ensureReady?: typeof ensureDeviceReady;
218
- resolveTargetDevice?: typeof resolveTargetDevice;
219
- reinstallOps?: ReinstallOps;
220
- }): Promise<DaemonResponse | null> {
221
- const {
222
- req,
223
- sessionName,
224
- logPath,
225
- sessionStore,
226
- invoke,
227
- dispatch: dispatchOverride,
228
- ensureReady: ensureReadyOverride,
229
- resolveTargetDevice: resolveTargetDeviceOverride,
230
- reinstallOps = defaultReinstallOps,
231
- } = params;
232
- const dispatch = dispatchOverride ?? dispatchCommand;
233
- const ensureReady = ensureReadyOverride ?? ensureDeviceReady;
234
- const resolveDevice = resolveTargetDeviceOverride ?? resolveTargetDevice;
235
- const command = req.command;
236
-
237
- if (command === 'session_list') {
238
- const data = {
239
- sessions: sessionStore.toArray().map((s) => ({
240
- name: s.name,
241
- platform: s.device.platform,
242
- device: s.device.name,
243
- id: s.device.id,
244
- createdAt: s.createdAt,
245
- })),
246
- };
247
- return { ok: true, data };
248
- }
249
-
250
- if (command === 'devices') {
251
- try {
252
- const devices: DeviceInfo[] = [];
253
- if (req.flags?.platform === 'android') {
254
- const { listAndroidDevices } = await import('../../platforms/android/devices.ts');
255
- devices.push(...(await listAndroidDevices()));
256
- } else if (req.flags?.platform === 'ios') {
257
- const { listIosDevices } = await import('../../platforms/ios/devices.ts');
258
- devices.push(...(await listIosDevices()));
259
- } else {
260
- const { listAndroidDevices } = await import('../../platforms/android/devices.ts');
261
- const { listIosDevices } = await import('../../platforms/ios/devices.ts');
262
- try {
263
- devices.push(...(await listAndroidDevices()));
264
- } catch {
265
- // ignore
266
- }
267
- try {
268
- devices.push(...(await listIosDevices()));
269
- } catch {
270
- // ignore
271
- }
272
- }
273
- return { ok: true, data: { devices } };
274
- } catch (err) {
275
- const appErr = asAppError(err);
276
- return { ok: false, error: { code: appErr.code, message: appErr.message, details: appErr.details } };
277
- }
278
- }
279
-
280
- if (command === 'apps') {
281
- const session = sessionStore.get(sessionName);
282
- const flags = req.flags ?? {};
283
- const guard = requireSessionOrExplicitSelector(command, session, flags);
284
- if (guard) return guard;
285
- const device = await resolveCommandDevice({
286
- session,
287
- flags,
288
- ensureReadyFn: ensureReady,
289
- resolveTargetDeviceFn: resolveDevice,
290
- ensureReady: true,
291
- });
292
- if (!isCommandSupportedOnDevice('apps', device)) {
293
- return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'apps is not supported on this device' } };
294
- }
295
- const appsFilter = req.flags?.appsFilter ?? 'all';
296
- if (device.platform === 'ios') {
297
- const { listIosApps } = await import('../../platforms/ios/index.ts');
298
- const apps = await listIosApps(device, appsFilter);
299
- const formatted = apps.map((app) =>
300
- app.name && app.name !== app.bundleId ? `${app.name} (${app.bundleId})` : app.bundleId,
301
- );
302
- return { ok: true, data: { apps: formatted } };
303
- }
304
- const { listAndroidApps } = await import('../../platforms/android/index.ts');
305
- const apps = await listAndroidApps(device, appsFilter);
306
- const formatted = apps.map((app) =>
307
- app.name && app.name !== app.package ? `${app.name} (${app.package})` : app.package,
308
- );
309
- return { ok: true, data: { apps: formatted } };
310
- }
311
-
312
- if (command === 'boot') {
313
- const session = sessionStore.get(sessionName);
314
- const flags = req.flags ?? {};
315
- const guard = requireSessionOrExplicitSelector(command, session, flags);
316
- if (guard) return guard;
317
- const device = await resolveCommandDevice({
318
- session,
319
- flags,
320
- ensureReadyFn: ensureReady,
321
- resolveTargetDeviceFn: resolveDevice,
322
- ensureReady: true,
323
- });
324
- if (!isCommandSupportedOnDevice('boot', device)) {
325
- return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'boot is not supported on this device' } };
326
- }
327
- return {
328
- ok: true,
329
- data: {
330
- platform: device.platform,
331
- device: device.name,
332
- id: device.id,
333
- kind: device.kind,
334
- booted: true,
335
- },
336
- };
337
- }
338
-
339
- if (command === 'appstate') {
340
- return await handleAppStateCommand({
341
- req,
342
- sessionName,
343
- sessionStore,
344
- ensureReady,
345
- resolveDevice,
346
- });
347
- }
348
-
349
- if (command === 'reinstall') {
350
- const session = sessionStore.get(sessionName);
351
- const flags = req.flags ?? {};
352
- const guard = requireSessionOrExplicitSelector(command, session, flags);
353
- if (guard) return guard;
354
- const app = req.positionals?.[0]?.trim();
355
- const appPathInput = req.positionals?.[1]?.trim();
356
- if (!app || !appPathInput) {
357
- return {
358
- ok: false,
359
- error: { code: 'INVALID_ARGS', message: 'reinstall requires: reinstall <app> <path-to-app-binary>' },
360
- };
361
- }
362
- const appPath = SessionStore.expandHome(appPathInput);
363
- if (!fs.existsSync(appPath)) {
364
- return {
365
- ok: false,
366
- error: { code: 'INVALID_ARGS', message: `App binary not found: ${appPath}` },
367
- };
368
- }
369
- const device = await resolveCommandDevice({
370
- session,
371
- flags,
372
- ensureReadyFn: ensureReady,
373
- resolveTargetDeviceFn: resolveDevice,
374
- ensureReady: false,
375
- });
376
- if (!isCommandSupportedOnDevice('reinstall', device)) {
377
- return {
378
- ok: false,
379
- error: { code: 'UNSUPPORTED_OPERATION', message: 'reinstall is not supported on this device' },
380
- };
381
- }
382
- let reinstallData:
383
- | { platform: 'ios'; appId: string; bundleId: string }
384
- | { platform: 'android'; appId: string; package: string };
385
- if (device.platform === 'ios') {
386
- const iosResult = await reinstallOps.ios(device, app, appPath);
387
- reinstallData = { platform: 'ios', appId: iosResult.bundleId, bundleId: iosResult.bundleId };
388
- } else {
389
- const androidResult = await reinstallOps.android(device, app, appPath);
390
- reinstallData = { platform: 'android', appId: androidResult.package, package: androidResult.package };
391
- }
392
- const result = { app, appPath, ...reinstallData };
393
- if (session) {
394
- sessionStore.recordAction(session, {
395
- command,
396
- positionals: req.positionals ?? [],
397
- flags: req.flags ?? {},
398
- result,
399
- });
400
- }
401
- return { ok: true, data: result };
402
- }
403
-
404
- if (command === 'open') {
405
- const shouldRelaunch = req.flags?.relaunch === true;
406
- if (sessionStore.has(sessionName)) {
407
- const session = sessionStore.get(sessionName);
408
- const requestedOpenTarget = req.positionals?.[0];
409
- const openTarget = requestedOpenTarget ?? (shouldRelaunch ? session?.appName : undefined);
410
- if (!session || !openTarget) {
411
- if (shouldRelaunch) {
412
- return {
413
- ok: false,
414
- error: {
415
- code: 'INVALID_ARGS',
416
- message: 'open --relaunch requires an app name or an active session app.',
417
- },
418
- };
419
- }
420
- return {
421
- ok: false,
422
- error: {
423
- code: 'INVALID_ARGS',
424
- message: 'Session already active. Close it first or pass a new --session name.',
425
- },
426
- };
427
- }
428
- if (shouldRelaunch && isDeepLinkTarget(openTarget)) {
429
- return {
430
- ok: false,
431
- error: {
432
- code: 'INVALID_ARGS',
433
- message: 'open --relaunch does not support URL targets.',
434
- },
435
- };
436
- }
437
- await ensureReady(session.device);
438
- const appBundleId = await resolveIosBundleIdForOpen(session.device, openTarget, session.appBundleId);
439
- const openPositionals = requestedOpenTarget ? (req.positionals ?? []) : [openTarget];
440
- if (shouldRelaunch) {
441
- const closeTarget = appBundleId ?? openTarget;
442
- await dispatch(session.device, 'close', [closeTarget], req.flags?.out, {
443
- ...contextFromFlags(logPath, req.flags, appBundleId ?? session.appBundleId, session.trace?.outPath),
444
- });
445
- }
446
- await dispatch(session.device, 'open', openPositionals, req.flags?.out, {
447
- ...contextFromFlags(logPath, req.flags, appBundleId),
448
- });
449
- const nextSession: SessionState = {
450
- ...session,
451
- appBundleId,
452
- appName: openTarget,
453
- recordSession: session.recordSession || Boolean(req.flags?.saveScript),
454
- snapshot: undefined,
455
- };
456
- sessionStore.recordAction(nextSession, {
457
- command,
458
- positionals: openPositionals,
459
- flags: req.flags ?? {},
460
- result: { session: sessionName, appName: openTarget, appBundleId },
461
- });
462
- sessionStore.set(sessionName, nextSession);
463
- return { ok: true, data: { session: sessionName, appName: openTarget, appBundleId } };
464
- }
465
- const openTarget = req.positionals?.[0];
466
- if (shouldRelaunch && !openTarget) {
467
- return {
468
- ok: false,
469
- error: {
470
- code: 'INVALID_ARGS',
471
- message: 'open --relaunch requires an app argument.',
472
- },
473
- };
474
- }
475
- if (shouldRelaunch && openTarget && isDeepLinkTarget(openTarget)) {
476
- return {
477
- ok: false,
478
- error: {
479
- code: 'INVALID_ARGS',
480
- message: 'open --relaunch does not support URL targets.',
481
- },
482
- };
483
- }
484
- const device = await resolveDevice(req.flags ?? {});
485
- const inUse = sessionStore.toArray().find((s) => s.device.id === device.id);
486
- if (inUse) {
487
- return {
488
- ok: false,
489
- error: {
490
- code: 'DEVICE_IN_USE',
491
- message: `Device is already in use by session "${inUse.name}".`,
492
- details: { session: inUse.name, deviceId: device.id, deviceName: device.name },
493
- },
494
- };
495
- }
496
- await ensureReady(device);
497
- const appBundleId = await resolveIosBundleIdForOpen(device, openTarget);
498
- if (shouldRelaunch && openTarget) {
499
- const closeTarget = appBundleId ?? openTarget;
500
- await dispatch(device, 'close', [closeTarget], req.flags?.out, {
501
- ...contextFromFlags(logPath, req.flags, appBundleId),
502
- });
503
- }
504
- await dispatch(device, 'open', req.positionals ?? [], req.flags?.out, {
505
- ...contextFromFlags(logPath, req.flags, appBundleId),
506
- });
507
- const session: SessionState = {
508
- name: sessionName,
509
- device,
510
- createdAt: Date.now(),
511
- appBundleId,
512
- appName: openTarget,
513
- recordSession: Boolean(req.flags?.saveScript),
514
- actions: [],
515
- };
516
- sessionStore.recordAction(session, {
517
- command,
518
- positionals: req.positionals ?? [],
519
- flags: req.flags ?? {},
520
- result: { session: sessionName },
521
- });
522
- sessionStore.set(sessionName, session);
523
- return { ok: true, data: { session: sessionName } };
524
- }
525
-
526
- if (command === 'replay') {
527
- const filePath = req.positionals?.[0];
528
- if (!filePath) {
529
- return { ok: false, error: { code: 'INVALID_ARGS', message: 'replay requires a path' } };
530
- }
531
- try {
532
- const resolved = SessionStore.expandHome(filePath);
533
- const script = fs.readFileSync(resolved, 'utf8');
534
- const firstNonWhitespace = script.trimStart()[0];
535
- if (firstNonWhitespace === '{' || firstNonWhitespace === '[') {
536
- return {
537
- ok: false,
538
- error: {
539
- code: 'INVALID_ARGS',
540
- message: 'replay accepts .ad script files. JSON replay payloads are no longer supported.',
541
- },
542
- };
543
- }
544
- const actions = parseReplayScript(script);
545
- const shouldUpdate = req.flags?.replayUpdate === true;
546
- let healed = 0;
547
- for (let index = 0; index < actions.length; index += 1) {
548
- const action = actions[index];
549
- if (!action || action.command === 'replay') continue;
550
- let response = await invoke({
551
- token: req.token,
552
- session: sessionName,
553
- command: action.command,
554
- positionals: action.positionals ?? [],
555
- flags: action.flags ?? {},
556
- });
557
- if (response.ok) continue;
558
- if (!shouldUpdate) {
559
- return withReplayFailureContext(response, action, index, resolved);
560
- }
561
- const nextAction = await healReplayAction({
562
- action,
563
- sessionName,
564
- logPath,
565
- sessionStore,
566
- dispatch,
567
- });
568
- if (!nextAction) {
569
- return withReplayFailureContext(response, action, index, resolved);
570
- }
571
- actions[index] = nextAction;
572
- response = await invoke({
573
- token: req.token,
574
- session: sessionName,
575
- command: nextAction.command,
576
- positionals: nextAction.positionals ?? [],
577
- flags: nextAction.flags ?? {},
578
- });
579
- if (!response.ok) {
580
- return withReplayFailureContext(response, nextAction, index, resolved);
581
- }
582
- healed += 1;
583
- }
584
- if (shouldUpdate && healed > 0) {
585
- const session = sessionStore.get(sessionName);
586
- writeReplayScript(resolved, actions, session);
587
- }
588
- return { ok: true, data: { replayed: actions.length, healed, session: sessionName } };
589
- } catch (err) {
590
- const appErr = asAppError(err);
591
- return { ok: false, error: { code: appErr.code, message: appErr.message } };
592
- }
593
- }
594
-
595
- if (command === 'close') {
596
- const session = sessionStore.get(sessionName);
597
- if (!session) {
598
- return { ok: false, error: { code: 'SESSION_NOT_FOUND', message: 'No active session' } };
599
- }
600
- if (req.positionals && req.positionals.length > 0) {
601
- await dispatch(session.device, 'close', req.positionals ?? [], req.flags?.out, {
602
- ...contextFromFlags(logPath, req.flags, session.appBundleId, session.trace?.outPath),
603
- });
604
- }
605
- if (session.device.platform === 'ios') {
606
- await stopIosRunnerSession(session.device.id);
607
- }
608
- sessionStore.recordAction(session, {
609
- command,
610
- positionals: req.positionals ?? [],
611
- flags: req.flags ?? {},
612
- result: { session: sessionName },
613
- });
614
- if (req.flags?.saveScript) {
615
- session.recordSession = true;
616
- }
617
- sessionStore.writeSessionLog(session);
618
- sessionStore.delete(sessionName);
619
- return { ok: true, data: { session: sessionName } };
620
- }
621
-
622
- return null;
623
- }
624
-
625
- function withReplayFailureContext(
626
- response: DaemonResponse,
627
- action: SessionAction,
628
- index: number,
629
- replayPath: string,
630
- ): DaemonResponse {
631
- if (response.ok) return response;
632
- const step = index + 1;
633
- const summary = formatReplayActionSummary(action);
634
- const details = {
635
- ...(response.error.details ?? {}),
636
- replayPath,
637
- step,
638
- action: action.command,
639
- positionals: action.positionals ?? [],
640
- };
641
- return {
642
- ok: false,
643
- error: {
644
- code: response.error.code,
645
- message: `Replay failed at step ${step} (${summary}): ${response.error.message}`,
646
- details,
647
- },
648
- };
649
- }
650
-
651
- function formatReplayActionSummary(action: SessionAction): string {
652
- const values = (action.positionals ?? []).map((value) => {
653
- const trimmed = value.trim();
654
- if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed;
655
- if (trimmed.startsWith('@')) return trimmed;
656
- return JSON.stringify(trimmed);
657
- });
658
- return [action.command, ...values].join(' ');
659
- }
660
-
661
- async function healReplayAction(params: {
662
- action: SessionAction;
663
- sessionName: string;
664
- logPath: string;
665
- sessionStore: SessionStore;
666
- dispatch: typeof dispatchCommand;
667
- }): Promise<SessionAction | null> {
668
- const { action, sessionName, logPath, sessionStore, dispatch } = params;
669
- if (!['click', 'fill', 'get', 'is', 'wait'].includes(action.command)) return null;
670
- const session = sessionStore.get(sessionName);
671
- if (!session) return null;
672
- const requiresRect = action.command === 'click' || action.command === 'fill';
673
- const allowDisambiguation =
674
- action.command === 'click' ||
675
- action.command === 'fill' ||
676
- (action.command === 'get' && action.positionals?.[0] === 'text');
677
- const snapshot = await captureSnapshotForReplay(session, action, logPath, requiresRect, dispatch, sessionStore);
678
- const selectorCandidates = collectReplaySelectorCandidates(action);
679
- for (const candidate of selectorCandidates) {
680
- const chain = tryParseSelectorChain(candidate);
681
- if (!chain) continue;
682
- const resolved = resolveSelectorChain(snapshot.nodes, chain, {
683
- platform: session.device.platform,
684
- requireRect: requiresRect,
685
- requireUnique: true,
686
- disambiguateAmbiguous: allowDisambiguation,
687
- });
688
- if (!resolved) continue;
689
- const selectorChain = buildSelectorChainForNode(resolved.node, session.device.platform, {
690
- action: action.command === 'click' ? 'click' : action.command === 'fill' ? 'fill' : 'get',
691
- });
692
- const selectorExpression = selectorChain.join(' || ');
693
- if (action.command === 'click') {
694
- return {
695
- ...action,
696
- positionals: [selectorExpression],
697
- };
698
- }
699
- if (action.command === 'fill') {
700
- const fillText = inferFillText(action);
701
- if (!fillText) continue;
702
- return {
703
- ...action,
704
- positionals: [selectorExpression, fillText],
705
- };
706
- }
707
- if (action.command === 'get') {
708
- const sub = action.positionals?.[0];
709
- if (sub !== 'text' && sub !== 'attrs') continue;
710
- return {
711
- ...action,
712
- positionals: [sub, selectorExpression],
713
- };
714
- }
715
- if (action.command === 'is') {
716
- const { predicate, split } = splitIsSelectorArgs(action.positionals);
717
- if (!predicate) continue;
718
- const expectedText = split?.rest.join(' ').trim() ?? '';
719
- const nextPositionals = [predicate, selectorExpression];
720
- if (predicate === 'text' && expectedText.length > 0) {
721
- nextPositionals.push(expectedText);
722
- }
723
- return {
724
- ...action,
725
- positionals: nextPositionals,
726
- };
727
- }
728
- if (action.command === 'wait') {
729
- const { selectorTimeout } = parseSelectorWaitPositionals(action.positionals ?? []);
730
- const nextPositionals = [selectorExpression];
731
- if (selectorTimeout) {
732
- nextPositionals.push(selectorTimeout);
733
- }
734
- return {
735
- ...action,
736
- positionals: nextPositionals,
737
- };
738
- }
739
- }
740
- const numericDriftHeal = healNumericGetTextDrift(action, snapshot, session);
741
- if (numericDriftHeal) {
742
- return numericDriftHeal;
743
- }
744
- return null;
745
- }
746
-
747
- async function captureSnapshotForReplay(
748
- session: SessionState,
749
- action: SessionAction,
750
- logPath: string,
751
- interactiveOnly: boolean,
752
- dispatch: typeof dispatchCommand,
753
- sessionStore: SessionStore,
754
- ): Promise<SnapshotState> {
755
- const data = (await dispatch(session.device, 'snapshot', [], action.flags?.out, {
756
- ...contextFromFlags(
757
- logPath,
758
- {
759
- ...(action.flags ?? {}),
760
- snapshotInteractiveOnly: interactiveOnly,
761
- snapshotCompact: interactiveOnly,
762
- },
763
- session.appBundleId,
764
- session.trace?.outPath,
765
- ),
766
- })) as {
767
- nodes?: RawSnapshotNode[];
768
- truncated?: boolean;
769
- backend?: 'ax' | 'xctest' | 'android';
770
- };
771
- const rawNodes = data?.nodes ?? [];
772
- const nodes = attachRefs(action.flags?.snapshotRaw ? rawNodes : pruneGroupNodes(rawNodes));
773
- const snapshot: SnapshotState = {
774
- nodes,
775
- truncated: data?.truncated,
776
- createdAt: Date.now(),
777
- backend: data?.backend,
778
- };
779
- session.snapshot = snapshot;
780
- sessionStore.set(session.name, session);
781
- return snapshot;
782
- }
783
-
784
- function collectReplaySelectorCandidates(action: SessionAction): string[] {
785
- const result: string[] = [];
786
- const explicitChain =
787
- Array.isArray(action.result?.selectorChain) &&
788
- action.result?.selectorChain.every((entry) => typeof entry === 'string')
789
- ? (action.result.selectorChain as string[])
790
- : [];
791
- result.push(...explicitChain);
792
-
793
- if (action.command === 'click') {
794
- const first = action.positionals?.[0] ?? '';
795
- if (first && !first.startsWith('@')) {
796
- result.push(action.positionals.join(' '));
797
- }
798
- }
799
- if (action.command === 'fill') {
800
- const first = action.positionals?.[0] ?? '';
801
- if (first && !first.startsWith('@') && Number.isNaN(Number(first))) {
802
- result.push(first);
803
- }
804
- }
805
- if (action.command === 'get') {
806
- const selector = action.positionals?.[1] ?? '';
807
- if (selector && !selector.startsWith('@')) {
808
- result.push(action.positionals.slice(1).join(' '));
809
- }
810
- }
811
- if (action.command === 'is') {
812
- const { split } = splitIsSelectorArgs(action.positionals);
813
- if (split) {
814
- result.push(split.selectorExpression);
815
- }
816
- }
817
- if (action.command === 'wait') {
818
- const { selectorExpression } = parseSelectorWaitPositionals(action.positionals ?? []);
819
- if (selectorExpression) {
820
- result.push(selectorExpression);
821
- }
822
- }
823
-
824
- const refLabel = typeof action.result?.refLabel === 'string' ? action.result.refLabel.trim() : '';
825
- if (refLabel.length > 0) {
826
- const quoted = JSON.stringify(refLabel);
827
- if (action.command === 'fill') {
828
- result.push(`id=${quoted} editable=true`);
829
- result.push(`label=${quoted} editable=true`);
830
- result.push(`text=${quoted} editable=true`);
831
- result.push(`value=${quoted} editable=true`);
832
- } else {
833
- result.push(`id=${quoted}`);
834
- result.push(`label=${quoted}`);
835
- result.push(`text=${quoted}`);
836
- result.push(`value=${quoted}`);
837
- }
838
- }
839
-
840
- return uniqueStrings(result).filter((entry) => entry.trim().length > 0);
841
- }
842
-
843
- function parseSelectorWaitPositionals(positionals: string[]): {
844
- selectorExpression: string | null;
845
- selectorTimeout: string | null;
846
- } {
847
- if (positionals.length === 0) return { selectorExpression: null, selectorTimeout: null };
848
- const maybeTimeout = positionals[positionals.length - 1];
849
- const hasTimeout = /^\d+$/.test(maybeTimeout ?? '');
850
- const selectorTokens = hasTimeout ? positionals.slice(0, -1) : positionals.slice();
851
- const split = splitSelectorFromArgs(selectorTokens);
852
- if (!split || split.rest.length > 0) {
853
- return { selectorExpression: null, selectorTimeout: null };
854
- }
855
- return {
856
- selectorExpression: split.selectorExpression,
857
- selectorTimeout: hasTimeout ? maybeTimeout : null,
858
- };
859
- }
860
-
861
- function healNumericGetTextDrift(
862
- action: SessionAction,
863
- snapshot: SnapshotState,
864
- session: SessionState,
865
- ): SessionAction | null {
866
- if (action.command !== 'get') return null;
867
- if (action.positionals?.[0] !== 'text') return null;
868
- const selectorExpression = action.positionals?.[1];
869
- if (!selectorExpression) return null;
870
- const chain = tryParseSelectorChain(selectorExpression);
871
- if (!chain) return null;
872
-
873
- const roleFilters = new Set<string>();
874
- let hasNumericTerm = false;
875
- for (const selector of chain.selectors) {
876
- for (const term of selector.terms) {
877
- if (term.key === 'role' && typeof term.value === 'string') {
878
- roleFilters.add(normalizeType(term.value));
879
- }
880
- if (
881
- (term.key === 'text' || term.key === 'label' || term.key === 'value') &&
882
- typeof term.value === 'string' &&
883
- /^\d+$/.test(term.value.trim())
884
- ) {
885
- hasNumericTerm = true;
886
- }
887
- }
888
- }
889
- if (!hasNumericTerm) return null;
890
-
891
- const numericNodes = snapshot.nodes.filter((node) => {
892
- const text = extractNodeText(node).trim();
893
- if (!/^\d+$/.test(text)) return false;
894
- if (roleFilters.size === 0) return true;
895
- return roleFilters.has(normalizeType(node.type ?? ''));
896
- });
897
- if (numericNodes.length === 0) return null;
898
- const numericValues = uniqueStrings(numericNodes.map((node) => extractNodeText(node).trim()));
899
- if (numericValues.length !== 1) return null;
900
-
901
- const targetNode = numericNodes[0];
902
- if (!targetNode) return null;
903
- const selectorChain = buildSelectorChainForNode(targetNode, session.device.platform, { action: 'get' });
904
- if (selectorChain.length === 0) return null;
905
- return {
906
- ...action,
907
- positionals: ['text', selectorChain.join(' || ')],
908
- };
909
- }
910
-
911
- function parseReplayScript(script: string): SessionAction[] {
912
- const actions: SessionAction[] = [];
913
- const lines = script.split(/\r?\n/);
914
- for (const line of lines) {
915
- const parsed = parseReplayScriptLine(line);
916
- if (parsed) {
917
- actions.push(parsed);
918
- }
919
- }
920
- return actions;
921
- }
922
-
923
- function parseReplayScriptLine(line: string): SessionAction | null {
924
- const trimmed = line.trim();
925
- if (trimmed.length === 0 || trimmed.startsWith('#')) return null;
926
- const tokens = tokenizeReplayLine(trimmed);
927
- if (tokens.length === 0) return null;
928
- const [command, ...args] = tokens;
929
- if (command === 'context') return null;
930
-
931
- const action: SessionAction = {
932
- ts: Date.now(),
933
- command,
934
- positionals: [],
935
- flags: {},
936
- };
937
-
938
- if (command === 'snapshot') {
939
- action.positionals = [];
940
- for (let index = 0; index < args.length; index += 1) {
941
- const token = args[index];
942
- if (token === '-i') {
943
- action.flags.snapshotInteractiveOnly = true;
944
- continue;
945
- }
946
- if (token === '-c') {
947
- action.flags.snapshotCompact = true;
948
- continue;
949
- }
950
- if (token === '--raw') {
951
- action.flags.snapshotRaw = true;
952
- continue;
953
- }
954
- if ((token === '-d' || token === '--depth') && index + 1 < args.length) {
955
- const parsedDepth = Number(args[index + 1]);
956
- if (Number.isFinite(parsedDepth) && parsedDepth >= 0) {
957
- action.flags.snapshotDepth = Math.floor(parsedDepth);
958
- }
959
- index += 1;
960
- continue;
961
- }
962
- if ((token === '-s' || token === '--scope') && index + 1 < args.length) {
963
- action.flags.snapshotScope = args[index + 1];
964
- index += 1;
965
- continue;
966
- }
967
- if (token === '--backend' && index + 1 < args.length) {
968
- const backend = args[index + 1];
969
- if (backend === 'ax' || backend === 'xctest') {
970
- action.flags.snapshotBackend = backend;
971
- }
972
- index += 1;
973
- }
974
- }
975
- return action;
976
- }
977
-
978
- if (command === 'open') {
979
- action.positionals = [];
980
- for (let index = 0; index < args.length; index += 1) {
981
- const token = args[index];
982
- if (token === '--relaunch') {
983
- action.flags.relaunch = true;
984
- continue;
985
- }
986
- action.positionals.push(token);
987
- }
988
- return action;
989
- }
990
-
991
- if (command === 'click') {
992
- if (args.length === 0) return action;
993
- const target = args[0];
994
- if (target.startsWith('@')) {
995
- action.positionals = [target];
996
- if (args[1]) {
997
- action.result = { refLabel: args[1] };
998
- }
999
- return action;
1000
- }
1001
- action.positionals = [args.join(' ')];
1002
- return action;
1003
- }
1004
-
1005
- if (command === 'fill') {
1006
- if (args.length < 2) {
1007
- action.positionals = args;
1008
- return action;
1009
- }
1010
- const target = args[0];
1011
- if (target.startsWith('@')) {
1012
- if (args.length >= 3) {
1013
- action.positionals = [target, args.slice(2).join(' ')];
1014
- action.result = { refLabel: args[1] };
1015
- return action;
1016
- }
1017
- action.positionals = [target, args[1]];
1018
- return action;
1019
- }
1020
- action.positionals = [target, args.slice(1).join(' ')];
1021
- return action;
1022
- }
1023
-
1024
- if (command === 'get') {
1025
- if (args.length < 2) {
1026
- action.positionals = args;
1027
- return action;
1028
- }
1029
- const sub = args[0];
1030
- const target = args[1];
1031
- if (target.startsWith('@')) {
1032
- action.positionals = [sub, target];
1033
- if (args[2]) {
1034
- action.result = { refLabel: args[2] };
1035
- }
1036
- return action;
1037
- }
1038
- action.positionals = [sub, args.slice(1).join(' ')];
1039
- return action;
1040
- }
1041
-
1042
- action.positionals = args;
1043
- return action;
1044
- }
1045
-
1046
- function tokenizeReplayLine(line: string): string[] {
1047
- const tokens: string[] = [];
1048
- let cursor = 0;
1049
- while (cursor < line.length) {
1050
- while (cursor < line.length && /\s/.test(line[cursor])) {
1051
- cursor += 1;
1052
- }
1053
- if (cursor >= line.length) break;
1054
- if (line[cursor] === '"') {
1055
- let end = cursor + 1;
1056
- let escaped = false;
1057
- while (end < line.length) {
1058
- const char = line[end];
1059
- if (char === '"' && !escaped) break;
1060
- escaped = char === '\\' && !escaped;
1061
- if (char !== '\\') escaped = false;
1062
- end += 1;
1063
- }
1064
- if (end >= line.length) {
1065
- throw new AppError('INVALID_ARGS', `Invalid replay script line: ${line}`);
1066
- }
1067
- const literal = line.slice(cursor, end + 1);
1068
- tokens.push(JSON.parse(literal) as string);
1069
- cursor = end + 1;
1070
- continue;
1071
- }
1072
- let end = cursor;
1073
- while (end < line.length && !/\s/.test(line[end])) {
1074
- end += 1;
1075
- }
1076
- tokens.push(line.slice(cursor, end));
1077
- cursor = end;
1078
- }
1079
- return tokens;
1080
- }
1081
-
1082
- function writeReplayScript(filePath: string, actions: SessionAction[], session?: SessionState) {
1083
- const lines: string[] = [];
1084
- // Session can be missing if the replay session is closed/deleted between execution and update write.
1085
- // In that case we still persist healed actions and omit only the context header.
1086
- if (session) {
1087
- const deviceLabel = session.device.name.replace(/"/g, '\\"');
1088
- const kind = session.device.kind ? ` kind=${session.device.kind}` : '';
1089
- lines.push(`context platform=${session.device.platform} device="${deviceLabel}"${kind} theme=unknown`);
1090
- }
1091
- for (const action of actions) {
1092
- lines.push(formatReplayActionLine(action));
1093
- }
1094
- const serialized = `${lines.join('\n')}\n`;
1095
- const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
1096
- fs.writeFileSync(tmpPath, serialized);
1097
- fs.renameSync(tmpPath, filePath);
1098
- }
1099
-
1100
- function formatReplayActionLine(action: SessionAction): string {
1101
- const parts: string[] = [action.command];
1102
- if (action.command === 'snapshot') {
1103
- if (action.flags?.snapshotInteractiveOnly) parts.push('-i');
1104
- if (action.flags?.snapshotCompact) parts.push('-c');
1105
- if (typeof action.flags?.snapshotDepth === 'number') {
1106
- parts.push('-d', String(action.flags.snapshotDepth));
1107
- }
1108
- if (action.flags?.snapshotScope) {
1109
- parts.push('-s', formatReplayArg(action.flags.snapshotScope));
1110
- }
1111
- if (action.flags?.snapshotRaw) parts.push('--raw');
1112
- if (action.flags?.snapshotBackend) {
1113
- parts.push('--backend', action.flags.snapshotBackend);
1114
- }
1115
- return parts.join(' ');
1116
- }
1117
- if (action.command === 'open') {
1118
- for (const positional of action.positionals ?? []) {
1119
- parts.push(formatReplayArg(positional));
1120
- }
1121
- if (action.flags?.relaunch) {
1122
- parts.push('--relaunch');
1123
- }
1124
- return parts.join(' ');
1125
- }
1126
- for (const positional of action.positionals ?? []) {
1127
- parts.push(formatReplayArg(positional));
1128
- }
1129
- return parts.join(' ');
1130
- }
1131
-
1132
- function formatReplayArg(value: string): string {
1133
- const trimmed = value.trim();
1134
- if (trimmed.startsWith('@')) return trimmed;
1135
- if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed;
1136
- return JSON.stringify(trimmed);
1137
- }