agent-device 0.2.4 → 0.2.5

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 (46) hide show
  1. package/README.md +41 -4
  2. package/dist/src/bin.js +26 -21
  3. package/dist/src/daemon.js +9 -8
  4. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj +2 -0
  5. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +15 -0
  6. package/package.json +3 -2
  7. package/skills/agent-device/SKILL.md +22 -6
  8. package/skills/agent-device/references/session-management.md +9 -0
  9. package/skills/agent-device/references/snapshot-refs.md +18 -5
  10. package/skills/agent-device/references/video-recording.md +2 -2
  11. package/src/cli.ts +6 -0
  12. package/src/core/__tests__/capabilities.test.ts +67 -0
  13. package/src/core/capabilities.ts +49 -0
  14. package/src/core/dispatch.ts +29 -118
  15. package/src/daemon/__tests__/is-predicates.test.ts +68 -0
  16. package/src/daemon/__tests__/selectors.test.ts +128 -0
  17. package/src/daemon/__tests__/session-routing.test.ts +108 -0
  18. package/src/daemon/__tests__/session-selector.test.ts +64 -0
  19. package/src/daemon/__tests__/session-store.test.ts +95 -0
  20. package/src/daemon/__tests__/snapshot-processing.test.ts +47 -0
  21. package/src/daemon/action-utils.ts +29 -0
  22. package/src/daemon/app-state.ts +66 -0
  23. package/src/daemon/context.ts +36 -0
  24. package/src/daemon/device-ready.ts +13 -0
  25. package/src/daemon/handlers/__tests__/find.test.ts +99 -0
  26. package/src/daemon/handlers/__tests__/replay-heal.test.ts +364 -0
  27. package/src/daemon/handlers/__tests__/snapshot.test.ts +128 -0
  28. package/src/daemon/handlers/find.ts +304 -0
  29. package/src/daemon/handlers/interaction.ts +510 -0
  30. package/src/daemon/handlers/parse-utils.ts +8 -0
  31. package/src/daemon/handlers/record-trace.ts +154 -0
  32. package/src/daemon/handlers/session.ts +732 -0
  33. package/src/daemon/handlers/snapshot.ts +396 -0
  34. package/src/daemon/is-predicates.ts +46 -0
  35. package/src/daemon/selectors.ts +423 -0
  36. package/src/daemon/session-routing.ts +22 -0
  37. package/src/daemon/session-selector.ts +39 -0
  38. package/src/daemon/session-store.ts +275 -0
  39. package/src/daemon/snapshot-processing.ts +127 -0
  40. package/src/daemon/types.ts +55 -0
  41. package/src/daemon.ts +66 -1592
  42. package/src/platforms/ios/index.ts +0 -62
  43. package/src/platforms/ios/runner-client.ts +2 -0
  44. package/src/utils/args.ts +19 -10
  45. package/src/utils/interactors.ts +102 -16
  46. package/src/utils/snapshot.ts +1 -0
package/src/daemon.ts CHANGED
@@ -4,110 +4,36 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import crypto from 'node:crypto';
6
6
  import { fileURLToPath } from 'node:url';
7
- import { dispatchCommand, resolveTargetDevice, type CommandFlags } from './core/dispatch.ts';
7
+ import { dispatchCommand, type CommandFlags } from './core/dispatch.ts';
8
+ import { isCommandSupportedOnDevice } from './core/capabilities.ts';
8
9
  import { asAppError, AppError } from './utils/errors.ts';
9
- import type { DeviceInfo } from './utils/device.ts';
10
- import {
11
- attachRefs,
12
- centerOfRect,
13
- findNodeByRef,
14
- normalizeRef,
15
- type SnapshotState,
16
- type RawSnapshotNode,
17
- } from './utils/snapshot.ts';
18
- import { findNodeByLocator, type FindLocator } from './utils/finders.ts';
19
- import { runIosRunnerCommand, stopIosRunnerSession } from './platforms/ios/runner-client.ts';
20
- import { runCmd, runCmdBackground } from './utils/exec.ts';
21
- import { snapshotAndroid } from './platforms/android/index.ts';
10
+ import { stopIosRunnerSession } from './platforms/ios/runner-client.ts';
11
+ import type { DaemonRequest, DaemonResponse } from './daemon/types.ts';
12
+ import { SessionStore } from './daemon/session-store.ts';
13
+ import { contextFromFlags as contextFromFlagsWithLog, type DaemonCommandContext } from './daemon/context.ts';
14
+ import { handleSessionCommands } from './daemon/handlers/session.ts';
15
+ import { handleSnapshotCommands } from './daemon/handlers/snapshot.ts';
16
+ import { handleFindCommands } from './daemon/handlers/find.ts';
17
+ import { handleRecordTraceCommands } from './daemon/handlers/record-trace.ts';
18
+ import { handleInteractionCommands } from './daemon/handlers/interaction.ts';
19
+ import { assertSessionSelectorMatches } from './daemon/session-selector.ts';
20
+ import { resolveEffectiveSessionName } from './daemon/session-routing.ts';
22
21
 
23
- type DaemonRequest = {
24
- token: string;
25
- session: string;
26
- command: string;
27
- positionals: string[];
28
- flags?: CommandFlags;
29
- };
30
-
31
- type DaemonResponse =
32
- | { ok: true; data?: Record<string, unknown> }
33
- | { ok: false; error: { code: string; message: string; details?: Record<string, unknown> } };
34
-
35
- type SessionState = {
36
- name: string;
37
- device: DeviceInfo;
38
- createdAt: number;
39
- appBundleId?: string;
40
- appName?: string;
41
- snapshot?: SnapshotState;
42
- trace?: {
43
- outPath: string;
44
- startedAt: number;
45
- };
46
- actions: SessionAction[];
47
- recording?: {
48
- platform: 'ios' | 'android';
49
- outPath: string;
50
- remotePath?: string;
51
- child: ReturnType<typeof import('node:child_process').spawn>;
52
- wait: Promise<import('./utils/exec.ts').ExecResult>;
53
- };
54
- };
55
-
56
- type SessionAction = {
57
- ts: number;
58
- command: string;
59
- positionals: string[];
60
- flags: Partial<CommandFlags> & {
61
- snapshotInteractiveOnly?: boolean;
62
- snapshotCompact?: boolean;
63
- snapshotDepth?: number;
64
- snapshotScope?: string;
65
- snapshotRaw?: boolean;
66
- snapshotBackend?: 'ax' | 'xctest';
67
- noRecord?: boolean;
68
- recordJson?: boolean;
69
- };
70
- result?: Record<string, unknown>;
71
- };
72
-
73
- const sessions = new Map<string, SessionState>();
74
22
  const baseDir = path.join(os.homedir(), '.agent-device');
75
23
  const infoPath = path.join(baseDir, 'daemon.json');
76
24
  const logPath = path.join(baseDir, 'daemon.log');
77
25
  const sessionsDir = path.join(baseDir, 'sessions');
26
+ const sessionStore = new SessionStore(sessionsDir);
78
27
  const version = readVersion();
79
28
  const token = crypto.randomBytes(24).toString('hex');
29
+ const selectorValidationExemptCommands = new Set(['session_list', 'devices']);
80
30
 
81
31
  function contextFromFlags(
82
32
  flags: CommandFlags | undefined,
83
33
  appBundleId?: string,
84
34
  traceLogPath?: string,
85
- ): {
86
- appBundleId?: string;
87
- activity?: string;
88
- verbose?: boolean;
89
- logPath?: string;
90
- traceLogPath?: string;
91
- snapshotInteractiveOnly?: boolean;
92
- snapshotCompact?: boolean;
93
- snapshotDepth?: number;
94
- snapshotScope?: string;
95
- snapshotBackend?: 'ax' | 'xctest';
96
- snapshotRaw?: boolean;
97
- } {
98
- return {
99
- appBundleId,
100
- activity: flags?.activity,
101
- verbose: flags?.verbose,
102
- logPath,
103
- traceLogPath,
104
- snapshotInteractiveOnly: flags?.snapshotInteractiveOnly,
105
- snapshotCompact: flags?.snapshotCompact,
106
- snapshotDepth: flags?.snapshotDepth,
107
- snapshotScope: flags?.snapshotScope,
108
- snapshotRaw: flags?.snapshotRaw,
109
- snapshotBackend: flags?.snapshotBackend,
110
- };
35
+ ): DaemonCommandContext {
36
+ return contextFromFlagsWithLog(logPath, flags, appBundleId, traceLogPath);
111
37
  }
112
38
 
113
39
  async function handleRequest(req: DaemonRequest): Promise<DaemonResponse> {
@@ -116,1016 +42,73 @@ async function handleRequest(req: DaemonRequest): Promise<DaemonResponse> {
116
42
  }
117
43
 
118
44
  const command = req.command;
119
- const sessionName = req.session || 'default';
120
-
121
- if (command === 'session_list') {
122
- const data = {
123
- sessions: Array.from(sessions.values()).map((s) => ({
124
- name: s.name,
125
- platform: s.device.platform,
126
- device: s.device.name,
127
- id: s.device.id,
128
- createdAt: s.createdAt,
129
- })),
130
- };
131
- return { ok: true, data };
45
+ const sessionName = resolveEffectiveSessionName(req, sessionStore);
46
+ const existingSession = sessionStore.get(sessionName);
47
+ if (existingSession && !selectorValidationExemptCommands.has(command)) {
48
+ assertSessionSelectorMatches(existingSession, req.flags);
132
49
  }
133
50
 
134
- if (command === 'devices') {
135
- try {
136
- const devices: DeviceInfo[] = [];
137
- if (req.flags?.platform === 'android') {
138
- const { listAndroidDevices } = await import('./platforms/android/devices.ts');
139
- devices.push(...(await listAndroidDevices()));
140
- } else if (req.flags?.platform === 'ios') {
141
- const { listIosDevices } = await import('./platforms/ios/devices.ts');
142
- devices.push(...(await listIosDevices()));
143
- } else {
144
- const { listAndroidDevices } = await import('./platforms/android/devices.ts');
145
- const { listIosDevices } = await import('./platforms/ios/devices.ts');
146
- try {
147
- devices.push(...(await listAndroidDevices()));
148
- } catch {
149
- // ignore
150
- }
151
- try {
152
- devices.push(...(await listIosDevices()));
153
- } catch {
154
- // ignore
155
- }
156
- }
157
- return { ok: true, data: { devices } };
158
- } catch (err) {
159
- const appErr = asAppError(err);
160
- return { ok: false, error: { code: appErr.code, message: appErr.message, details: appErr.details } };
161
- }
162
- }
51
+ const sessionResponse = await handleSessionCommands({
52
+ req,
53
+ sessionName,
54
+ logPath,
55
+ sessionStore,
56
+ invoke: handleRequest,
57
+ });
58
+ if (sessionResponse) return sessionResponse;
163
59
 
164
- if (command === 'apps') {
165
- const session = sessions.get(sessionName);
166
- const flags = req.flags ?? {};
167
- if (
168
- !session &&
169
- !flags.platform &&
170
- !flags.device &&
171
- !flags.udid &&
172
- !flags.serial
173
- ) {
174
- return {
175
- ok: false,
176
- error: {
177
- code: 'INVALID_ARGS',
178
- message: 'apps requires an active session or an explicit device selector (e.g. --platform ios).',
179
- },
180
- };
181
- }
182
- const device = session?.device ?? (await resolveTargetDevice(flags));
183
- await ensureDeviceReady(device);
184
- if (device.platform === 'ios') {
185
- if (device.kind !== 'simulator') {
186
- return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'apps list is only supported on iOS simulators' } };
187
- }
188
- const { listSimulatorApps } = await import('./platforms/ios/index.ts');
189
- const apps = await listSimulatorApps(device);
190
- if (req.flags?.appsMetadata) {
191
- return { ok: true, data: { apps } };
192
- }
193
- const formatted = apps.map((app) =>
194
- app.name && app.name !== app.bundleId ? `${app.name} (${app.bundleId})` : app.bundleId,
195
- );
196
- return { ok: true, data: { apps: formatted } };
197
- }
198
- const { listAndroidApps, listAndroidAppsMetadata } = await import('./platforms/android/index.ts');
199
- if (req.flags?.appsMetadata) {
200
- const apps = await listAndroidAppsMetadata(device, req.flags?.appsFilter);
201
- return { ok: true, data: { apps } };
202
- }
203
- const apps = await listAndroidApps(device, req.flags?.appsFilter);
204
- return { ok: true, data: { apps } };
205
- }
60
+ const snapshotResponse = await handleSnapshotCommands({
61
+ req,
62
+ sessionName,
63
+ logPath,
64
+ sessionStore,
65
+ });
66
+ if (snapshotResponse) return snapshotResponse;
206
67
 
207
- if (command === 'appstate') {
208
- const session = sessions.get(sessionName);
209
- const flags = req.flags ?? {};
210
- const device = session?.device ?? (await resolveTargetDevice(flags));
211
- await ensureDeviceReady(device);
212
- if (device.platform === 'ios') {
213
- if (session?.appBundleId) {
214
- return {
215
- ok: true,
216
- data: {
217
- platform: 'ios',
218
- appBundleId: session.appBundleId,
219
- appName: session.appName ?? session.appBundleId,
220
- source: 'session',
221
- },
222
- };
223
- }
224
- const snapshotResult = await resolveIosAppStateFromSnapshots(device, session?.trace?.outPath, req.flags);
225
- return {
226
- ok: true,
227
- data: {
228
- platform: 'ios',
229
- appName: snapshotResult.appName,
230
- appBundleId: snapshotResult.appBundleId,
231
- source: snapshotResult.source,
232
- },
233
- };
234
- }
235
- const { getAndroidAppState } = await import('./platforms/android/index.ts');
236
- const state = await getAndroidAppState(device);
237
- return {
238
- ok: true,
239
- data: {
240
- platform: 'android',
241
- package: state.package,
242
- activity: state.activity,
243
- },
244
- };
245
- }
68
+ const recordTraceResponse = await handleRecordTraceCommands({
69
+ req,
70
+ sessionName,
71
+ sessionStore,
72
+ });
73
+ if (recordTraceResponse) return recordTraceResponse;
246
74
 
247
- if (command === 'open') {
248
- if (sessions.has(sessionName)) {
249
- const session = sessions.get(sessionName);
250
- const appName = req.positionals?.[0];
251
- if (!session || !appName) {
252
- return {
253
- ok: false,
254
- error: {
255
- code: 'INVALID_ARGS',
256
- message: 'Session already active. Close it first or pass a new --session name.',
257
- },
258
- };
259
- }
260
- let appBundleId: string | undefined;
261
- if (session.device.platform === 'ios') {
262
- try {
263
- const { resolveIosApp } = await import('./platforms/ios/index.ts');
264
- appBundleId = await resolveIosApp(session.device, appName);
265
- } catch {
266
- appBundleId = undefined;
267
- }
268
- }
269
- await dispatchCommand(session.device, 'open', req.positionals ?? [], req.flags?.out, {
270
- ...contextFromFlags(req.flags, appBundleId),
271
- });
272
- const nextSession: SessionState = {
273
- ...session,
274
- appBundleId,
275
- appName,
276
- snapshot: undefined,
277
- };
278
- recordAction(nextSession, {
279
- command,
280
- positionals: req.positionals ?? [],
281
- flags: req.flags ?? {},
282
- result: { session: sessionName, appName, appBundleId },
283
- });
284
- sessions.set(sessionName, nextSession);
285
- return { ok: true, data: { session: sessionName, appName, appBundleId } };
286
- }
287
- const device = await resolveTargetDevice(req.flags ?? {});
288
- await ensureDeviceReady(device);
289
- const inUse = Array.from(sessions.values()).find((s) => s.device.id === device.id);
290
- if (inUse) {
291
- return {
292
- ok: false,
293
- error: {
294
- code: 'DEVICE_IN_USE',
295
- message: `Device is already in use by session "${inUse.name}".`,
296
- details: { session: inUse.name, deviceId: device.id, deviceName: device.name },
297
- },
298
- };
299
- }
300
- let appBundleId: string | undefined;
301
- const appName = req.positionals?.[0];
302
- if (device.platform === 'ios') {
303
- try {
304
- const { resolveIosApp } = await import('./platforms/ios/index.ts');
305
- appBundleId = await resolveIosApp(device, req.positionals?.[0] ?? '');
306
- } catch {
307
- appBundleId = undefined;
308
- }
309
- }
310
- await dispatchCommand(device, 'open', req.positionals ?? [], req.flags?.out, {
311
- ...contextFromFlags(req.flags, appBundleId),
312
- });
313
- const session: SessionState = {
314
- name: sessionName,
315
- device,
316
- createdAt: Date.now(),
317
- appBundleId,
318
- appName,
319
- actions: [],
320
- };
321
- recordAction(session, {
322
- command,
323
- positionals: req.positionals ?? [],
324
- flags: req.flags ?? {},
325
- result: { session: sessionName },
326
- });
327
- sessions.set(sessionName, session);
328
- return { ok: true, data: { session: sessionName } };
329
- }
75
+ const findResponse = await handleFindCommands({
76
+ req,
77
+ sessionName,
78
+ logPath,
79
+ sessionStore,
80
+ invoke: handleRequest,
81
+ });
82
+ if (findResponse) return findResponse;
330
83
 
331
- if (command === 'replay') {
332
- const filePath = req.positionals?.[0];
333
- if (!filePath) {
334
- return { ok: false, error: { code: 'INVALID_ARGS', message: 'replay requires a path' } };
335
- }
336
- try {
337
- const resolved = expandHome(filePath);
338
- const payload = JSON.parse(fs.readFileSync(resolved, 'utf8')) as {
339
- actions?: SessionAction[];
340
- optimizedActions?: SessionAction[];
341
- };
342
- const actions = payload.optimizedActions ?? payload.actions ?? [];
343
- for (const action of actions) {
344
- if (!action || action.command === 'replay') continue;
345
- await handleRequest({
346
- token,
347
- session: sessionName,
348
- command: action.command,
349
- positionals: action.positionals ?? [],
350
- flags: action.flags ?? {},
351
- });
352
- }
353
- return { ok: true, data: { replayed: actions.length, session: sessionName } };
354
- } catch (err) {
355
- const appErr = asAppError(err);
356
- return { ok: false, error: { code: appErr.code, message: appErr.message } };
357
- }
358
- }
84
+ const interactionResponse = await handleInteractionCommands({
85
+ req,
86
+ sessionName,
87
+ sessionStore,
88
+ contextFromFlags,
89
+ });
90
+ if (interactionResponse) return interactionResponse;
359
91
 
360
- if (command === 'close') {
361
- const session = sessions.get(sessionName);
362
- if (!session) {
363
- return { ok: false, error: { code: 'SESSION_NOT_FOUND', message: 'No active session' } };
364
- }
365
- if (req.positionals && req.positionals.length > 0) {
366
- await dispatchCommand(session.device, 'close', req.positionals ?? [], req.flags?.out, {
367
- ...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath),
368
- });
369
- }
370
- if (session.device.platform === 'ios' && session.device.kind === 'simulator') {
371
- await stopIosRunnerSession(session.device.id);
372
- }
373
- recordAction(session, {
374
- command,
375
- positionals: req.positionals ?? [],
376
- flags: req.flags ?? {},
377
- result: { session: sessionName },
378
- });
379
- writeSessionLog(session);
380
- sessions.delete(sessionName);
381
- return { ok: true, data: { session: sessionName } };
382
- }
383
92
 
384
- if (command === 'snapshot') {
385
- const session = sessions.get(sessionName);
386
- const device = session?.device ?? (await resolveTargetDevice(req.flags ?? {}));
387
- if (!session) {
388
- await ensureDeviceReady(device);
389
- }
390
- const appBundleId = session?.appBundleId;
391
- let snapshotScope = req.flags?.snapshotScope;
392
- if (snapshotScope && snapshotScope.trim().startsWith('@')) {
393
- if (!session?.snapshot) {
394
- return { ok: false, error: { code: 'INVALID_ARGS', message: 'Ref scope requires an existing snapshot in session.' } };
395
- }
396
- const ref = normalizeRef(snapshotScope.trim());
397
- if (!ref) {
398
- return { ok: false, error: { code: 'INVALID_ARGS', message: `Invalid ref scope: ${snapshotScope}` } };
399
- }
400
- const node = findNodeByRef(session.snapshot.nodes, ref);
401
- const resolved = node ? resolveRefLabel(node, session.snapshot.nodes) : undefined;
402
- if (!resolved) {
403
- return { ok: false, error: { code: 'COMMAND_FAILED', message: `Ref ${snapshotScope} not found or has no label` } };
404
- }
405
- snapshotScope = resolved;
406
- }
407
- const data = (await dispatchCommand(device, 'snapshot', [], req.flags?.out, {
408
- ...contextFromFlags({ ...req.flags, snapshotScope }, appBundleId, session?.trace?.outPath),
409
- })) as {
410
- nodes?: RawSnapshotNode[];
411
- truncated?: boolean;
412
- backend?: 'ax' | 'xctest' | 'android';
413
- };
414
- const rawNodes = data?.nodes ?? [];
415
- const nodes = attachRefs(req.flags?.snapshotRaw ? rawNodes : pruneGroupNodes(rawNodes));
416
- const snapshot: SnapshotState = {
417
- nodes,
418
- truncated: data?.truncated,
419
- createdAt: Date.now(),
420
- backend: data?.backend,
421
- };
422
- const nextSession: SessionState = {
423
- name: sessionName,
424
- device,
425
- createdAt: session?.createdAt ?? Date.now(),
426
- appBundleId: session?.appBundleId ?? appBundleId,
427
- snapshot,
428
- actions: session?.actions ?? [],
429
- appName: session?.appName,
430
- };
431
- recordAction(nextSession, {
432
- command,
433
- positionals: req.positionals ?? [],
434
- flags: req.flags ?? {},
435
- result: { nodes: nodes.length, truncated: data?.truncated ?? false },
436
- });
437
- sessions.set(sessionName, nextSession);
93
+ const session = sessionStore.get(sessionName);
94
+ if (!session) {
438
95
  return {
439
- ok: true,
440
- data: {
441
- nodes,
442
- truncated: data?.truncated ?? false,
443
- appName: nextSession.appBundleId ? (nextSession.appName ?? nextSession.appBundleId) : undefined,
444
- appBundleId: nextSession.appBundleId,
445
- },
446
- };
447
- }
448
-
449
- if (command === 'wait') {
450
- const session = sessions.get(sessionName);
451
- const device = session?.device ?? (await resolveTargetDevice(req.flags ?? {}));
452
- if (!session) {
453
- await ensureDeviceReady(device);
454
- }
455
- const args = req.positionals ?? [];
456
- if (args.length === 0) {
457
- return { ok: false, error: { code: 'INVALID_ARGS', message: 'wait requires a duration or text' } };
458
- }
459
- const parseTimeout = (value: string | undefined): number | null => {
460
- if (!value) return null;
461
- const parsed = Number(value);
462
- return Number.isFinite(parsed) ? parsed : null;
463
- };
464
- const sleepMs = parseTimeout(args[0]);
465
- if (sleepMs !== null) {
466
- await new Promise((resolve) => setTimeout(resolve, sleepMs));
467
- if (session) {
468
- recordAction(session, { command, positionals: req.positionals ?? [], flags: req.flags ?? {}, result: { waitedMs: sleepMs } });
469
- }
470
- return { ok: true, data: { waitedMs: sleepMs } };
471
- }
472
- let text = '';
473
- let timeoutMs: number | null = null;
474
- if (args[0] === 'text') {
475
- timeoutMs = parseTimeout(args[args.length - 1]);
476
- text = timeoutMs !== null ? args.slice(1, -1).join(' ') : args.slice(1).join(' ');
477
- } else if (args[0].startsWith('@')) {
478
- if (!session?.snapshot) {
479
- return { ok: false, error: { code: 'INVALID_ARGS', message: 'Ref wait requires an existing snapshot in session.' } };
480
- }
481
- const ref = normalizeRef(args[0]);
482
- if (!ref) {
483
- return { ok: false, error: { code: 'INVALID_ARGS', message: `Invalid ref: ${args[0]}` } };
484
- }
485
- const node = findNodeByRef(session.snapshot.nodes, ref);
486
- const resolved = node ? resolveRefLabel(node, session.snapshot.nodes) : undefined;
487
- if (!resolved) {
488
- return { ok: false, error: { code: 'COMMAND_FAILED', message: `Ref ${args[0]} not found or has no label` } };
489
- }
490
- timeoutMs = parseTimeout(args[args.length - 1]);
491
- text = resolved;
492
- } else {
493
- timeoutMs = parseTimeout(args[args.length - 1]);
494
- text = timeoutMs !== null ? args.slice(0, -1).join(' ') : args.join(' ');
495
- }
496
- text = text.trim();
497
- if (!text) {
498
- return { ok: false, error: { code: 'INVALID_ARGS', message: 'wait requires text' } };
499
- }
500
- const timeout = timeoutMs ?? 10000;
501
- const start = Date.now();
502
- while (Date.now() - start < timeout) {
503
- if (device.platform === 'ios' && device.kind === 'simulator') {
504
- const result = (await runIosRunnerCommand(
505
- device,
506
- { command: 'findText', text, appBundleId: session?.appBundleId },
507
- { verbose: req.flags?.verbose, logPath, traceLogPath: session?.trace?.outPath },
508
- )) as { found?: boolean };
509
- if (result?.found) {
510
- if (session) {
511
- recordAction(session, { command, positionals: req.positionals ?? [], flags: req.flags ?? {}, result: { text, waitedMs: Date.now() - start } });
512
- }
513
- return { ok: true, data: { text, waitedMs: Date.now() - start } };
514
- }
515
- } else if (device.platform === 'android') {
516
- const androidResult = await snapshotAndroid(device, { scope: text });
517
- if (findNodeByLabel(attachRefs(androidResult.nodes ?? []), text)) {
518
- if (session) {
519
- recordAction(session, { command, positionals: req.positionals ?? [], flags: req.flags ?? {}, result: { text, waitedMs: Date.now() - start } });
520
- }
521
- return { ok: true, data: { text, waitedMs: Date.now() - start } };
522
- }
523
- } else {
524
- return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'wait is not supported on this device' } };
525
- }
526
- await new Promise((resolve) => setTimeout(resolve, 300));
527
- }
528
- return { ok: false, error: { code: 'COMMAND_FAILED', message: `wait timed out for text: ${text}` } };
529
- }
530
-
531
- if (command === 'alert') {
532
- const session = sessions.get(sessionName);
533
- const device = session?.device ?? (await resolveTargetDevice(req.flags ?? {}));
534
- if (!session) {
535
- await ensureDeviceReady(device);
536
- }
537
- const action = (req.positionals?.[0] ?? 'get').toLowerCase();
538
- const parseTimeout = (value: string | undefined): number | null => {
539
- if (!value) return null;
540
- const parsed = Number(value);
541
- return Number.isFinite(parsed) ? parsed : null;
542
- };
543
- if (device.platform !== 'ios' || device.kind !== 'simulator') {
544
- return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'alert is only supported on iOS simulators in v1' } };
545
- }
546
- if (action === 'wait') {
547
- const timeout = parseTimeout(req.positionals?.[1]) ?? 10000;
548
- const start = Date.now();
549
- while (Date.now() - start < timeout) {
550
- try {
551
- const data = await runIosRunnerCommand(
552
- device,
553
- { command: 'alert', action: 'get', appBundleId: session?.appBundleId },
554
- { verbose: req.flags?.verbose, logPath, traceLogPath: session?.trace?.outPath },
555
- );
556
- if (session) {
557
- recordAction(session, {
558
- command,
559
- positionals: req.positionals ?? [],
560
- flags: req.flags ?? {},
561
- result: data,
562
- });
563
- }
564
- return { ok: true, data };
565
- } catch {
566
- // keep waiting
567
- }
568
- await new Promise((resolve) => setTimeout(resolve, 300));
569
- }
570
- return { ok: false, error: { code: 'COMMAND_FAILED', message: 'alert wait timed out' } };
571
- }
572
- const data = await runIosRunnerCommand(
573
- device,
574
- {
575
- command: 'alert',
576
- action: action === 'accept' || action === 'dismiss' ? (action as 'accept' | 'dismiss') : 'get',
577
- appBundleId: session?.appBundleId,
578
- },
579
- { verbose: req.flags?.verbose, logPath, traceLogPath: session?.trace?.outPath },
580
- );
581
- if (session) {
582
- recordAction(session, {
583
- command,
584
- positionals: req.positionals ?? [],
585
- flags: req.flags ?? {},
586
- result: data,
587
- });
588
- }
589
- return { ok: true, data };
590
- }
591
-
592
- if (command === 'record') {
593
- const action = (req.positionals?.[0] ?? '').toLowerCase();
594
- if (!['start', 'stop'].includes(action)) {
595
- return { ok: false, error: { code: 'INVALID_ARGS', message: 'record requires start|stop' } };
596
- }
597
- const session = sessions.get(sessionName);
598
- const device = session?.device ?? (await resolveTargetDevice(req.flags ?? {}));
599
- if (!session) {
600
- await ensureDeviceReady(device);
601
- }
602
- const activeSession = session ?? {
603
- name: sessionName,
604
- device,
605
- createdAt: Date.now(),
606
- actions: [],
96
+ ok: false,
97
+ error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' },
607
98
  };
608
-
609
- if (action === 'start') {
610
- if (activeSession.recording) {
611
- return { ok: false, error: { code: 'INVALID_ARGS', message: 'recording already in progress' } };
612
- }
613
- const outPath = req.positionals?.[1] ?? `./recording-${Date.now()}.mp4`;
614
- const resolvedOut = path.resolve(outPath);
615
- const outDir = path.dirname(resolvedOut);
616
- if (!fs.existsSync(outDir)) {
617
- fs.mkdirSync(outDir, { recursive: true });
618
- }
619
- if (device.platform === 'ios') {
620
- if (device.kind !== 'simulator') {
621
- return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'record is only supported on iOS simulators in v1' } };
622
- }
623
- const { child, wait } = runCmdBackground('xcrun', ['simctl', 'io', device.id, 'recordVideo', resolvedOut], {
624
- allowFailure: true,
625
- });
626
- activeSession.recording = { platform: 'ios', outPath: resolvedOut, child, wait };
627
- } else {
628
- const remotePath = `/sdcard/agent-device-recording-${Date.now()}.mp4`;
629
- const { child, wait } = runCmdBackground('adb', ['-s', device.id, 'shell', 'screenrecord', remotePath], {
630
- allowFailure: true,
631
- });
632
- activeSession.recording = { platform: 'android', outPath: resolvedOut, remotePath, child, wait };
633
- }
634
- sessions.set(sessionName, activeSession);
635
- recordAction(activeSession, {
636
- command,
637
- positionals: req.positionals ?? [],
638
- flags: req.flags ?? {},
639
- result: { action: 'start' },
640
- });
641
- return { ok: true, data: { recording: 'started', outPath } };
642
- }
643
-
644
- if (!activeSession.recording) {
645
- return { ok: false, error: { code: 'INVALID_ARGS', message: 'no active recording' } };
646
- }
647
- const recording = activeSession.recording;
648
- recording.child.kill('SIGINT');
649
- try {
650
- await recording.wait;
651
- } catch {
652
- // ignore
653
- }
654
- if (recording.platform === 'android' && recording.remotePath) {
655
- try {
656
- await runCmd('adb', ['-s', device.id, 'pull', recording.remotePath, recording.outPath], { allowFailure: true });
657
- await runCmd('adb', ['-s', device.id, 'shell', 'rm', '-f', recording.remotePath], { allowFailure: true });
658
- } catch {
659
- // ignore
660
- }
661
- }
662
- activeSession.recording = undefined;
663
- recordAction(activeSession, {
664
- command,
665
- positionals: req.positionals ?? [],
666
- flags: req.flags ?? {},
667
- result: { action: 'stop', outPath: recording.outPath },
668
- });
669
- return { ok: true, data: { recording: 'stopped', outPath: recording.outPath } };
670
99
  }
671
100
 
672
- if (command === 'trace') {
673
- const action = (req.positionals?.[0] ?? '').toLowerCase();
674
- if (!['start', 'stop'].includes(action)) {
675
- return { ok: false, error: { code: 'INVALID_ARGS', message: 'trace requires start|stop' } };
676
- }
677
- const session = sessions.get(sessionName);
678
- if (!session) {
679
- return { ok: false, error: { code: 'SESSION_NOT_FOUND', message: 'No active session' } };
680
- }
681
- if (action === 'start') {
682
- if (session.trace) {
683
- return { ok: false, error: { code: 'INVALID_ARGS', message: 'trace already in progress' } };
684
- }
685
- const outPath = req.positionals?.[1] ?? defaultTracePath(session);
686
- const resolvedOut = expandHome(outPath);
687
- fs.mkdirSync(path.dirname(resolvedOut), { recursive: true });
688
- fs.appendFileSync(resolvedOut, '');
689
- session.trace = { outPath: resolvedOut, startedAt: Date.now() };
690
- recordAction(session, {
691
- command,
692
- positionals: req.positionals ?? [],
693
- flags: req.flags ?? {},
694
- result: { action: 'start', outPath: resolvedOut },
695
- });
696
- return { ok: true, data: { trace: 'started', outPath: resolvedOut } };
697
- }
698
- if (!session.trace) {
699
- return { ok: false, error: { code: 'INVALID_ARGS', message: 'no active trace' } };
700
- }
701
- let outPath = session.trace.outPath;
702
- if (req.positionals?.[1]) {
703
- const resolvedOut = expandHome(req.positionals[1]);
704
- fs.mkdirSync(path.dirname(resolvedOut), { recursive: true });
705
- if (fs.existsSync(outPath)) {
706
- fs.renameSync(outPath, resolvedOut);
707
- } else {
708
- fs.appendFileSync(resolvedOut, '');
709
- }
710
- outPath = resolvedOut;
711
- }
712
- session.trace = undefined;
713
- recordAction(session, {
714
- command,
715
- positionals: req.positionals ?? [],
716
- flags: req.flags ?? {},
717
- result: { action: 'stop', outPath },
718
- });
719
- return { ok: true, data: { trace: 'stopped', outPath } };
720
- }
721
-
722
- if (command === 'settings') {
723
- const setting = req.positionals?.[0];
724
- const state = req.positionals?.[1];
725
- if (!setting || !state) {
726
- return { ok: false, error: { code: 'INVALID_ARGS', message: 'settings requires <wifi|airplane|location> <on|off>' } };
727
- }
728
- const session = sessions.get(sessionName);
729
- const device = session?.device ?? (await resolveTargetDevice(req.flags ?? {}));
730
- if (!session) {
731
- await ensureDeviceReady(device);
732
- }
733
- const appBundleId = session?.appBundleId;
734
- const data = await dispatchCommand(
735
- device,
736
- 'settings',
737
- [setting, state, appBundleId ?? ''],
738
- req.flags?.out,
739
- { ...contextFromFlags(req.flags, appBundleId, session?.trace?.outPath) },
740
- );
741
- if (session) {
742
- recordAction(session, {
743
- command,
744
- positionals: req.positionals ?? [],
745
- flags: req.flags ?? {},
746
- result: data ?? { setting, state },
747
- });
748
- }
749
- return { ok: true, data: data ?? { setting, state } };
750
- }
751
-
752
- if (command === 'find') {
753
- const args = req.positionals ?? [];
754
- if (args.length === 0) {
755
- return { ok: false, error: { code: 'INVALID_ARGS', message: 'find requires a locator or text' } };
756
- }
757
- const { locator, query, action, value, timeoutMs } = parseFindArgs(args);
758
- if (!query) {
759
- return { ok: false, error: { code: 'INVALID_ARGS', message: 'find requires a value' } };
760
- }
761
- const session = sessions.get(sessionName);
762
- const isReadOnly = action === 'exists' || action === 'wait' || action === 'get_text' || action === 'get_attrs';
763
- if (!session && !isReadOnly) {
764
- return {
765
- ok: false,
766
- error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' },
767
- };
768
- }
769
- const device = session?.device ?? (await resolveTargetDevice(req.flags ?? {}));
770
- if (!session) {
771
- await ensureDeviceReady(device);
772
- }
773
- const appBundleId = session?.appBundleId;
774
- const scope = shouldScopeFind(locator) ? query : undefined;
775
- const requiresRect = action === 'click' || action === 'focus' || action === 'fill' || action === 'type';
776
- const interactiveOnly = requiresRect;
777
- let lastSnapshotAt = 0;
778
- let lastNodes: SnapshotState['nodes'] | null = null;
779
- const fetchNodes = async (): Promise<{
780
- nodes: SnapshotState['nodes'];
781
- truncated?: boolean;
782
- backend?: SnapshotState['backend'];
783
- }> => {
784
- const now = Date.now();
785
- if (lastNodes && now - lastSnapshotAt < 750) {
786
- return { nodes: lastNodes };
787
- }
788
- const data = (await dispatchCommand(device, 'snapshot', [], req.flags?.out, {
789
- ...contextFromFlags(
790
- {
791
- ...req.flags,
792
- snapshotScope: scope,
793
- snapshotInteractiveOnly: interactiveOnly,
794
- snapshotCompact: interactiveOnly,
795
- },
796
- appBundleId,
797
- session?.trace?.outPath,
798
- ),
799
- })) as {
800
- nodes?: RawSnapshotNode[];
801
- truncated?: boolean;
802
- backend?: 'ax' | 'xctest' | 'android';
803
- };
804
- const rawNodes = data?.nodes ?? [];
805
- const nodes = attachRefs(req.flags?.snapshotRaw ? rawNodes : pruneGroupNodes(rawNodes));
806
- lastSnapshotAt = now;
807
- lastNodes = nodes;
808
- if (session) {
809
- const snapshot: SnapshotState = {
810
- nodes,
811
- truncated: data?.truncated,
812
- createdAt: Date.now(),
813
- backend: data?.backend,
814
- };
815
- session.snapshot = snapshot;
816
- sessions.set(sessionName, session);
817
- }
818
- return { nodes, truncated: data?.truncated, backend: data?.backend };
819
- };
820
- if (action === 'wait') {
821
- const timeout = timeoutMs ?? 10000;
822
- const start = Date.now();
823
- while (Date.now() - start < timeout) {
824
- const { nodes } = await fetchNodes();
825
- const match = findNodeByLocator(nodes, locator, query, { requireRect: false });
826
- if (match) {
827
- if (session) {
828
- recordAction(session, {
829
- command,
830
- positionals: req.positionals ?? [],
831
- flags: req.flags ?? {},
832
- result: { found: true, waitedMs: Date.now() - start },
833
- });
834
- }
835
- return { ok: true, data: { found: true, waitedMs: Date.now() - start } };
836
- }
837
- await new Promise((resolve) => setTimeout(resolve, 300));
838
- }
839
- return { ok: false, error: { code: 'COMMAND_FAILED', message: 'find wait timed out' } };
840
- }
841
- const { nodes } = await fetchNodes();
842
- const node = findNodeByLocator(nodes, locator, query, { requireRect: requiresRect });
843
- if (!node) {
844
- return { ok: false, error: { code: 'COMMAND_FAILED', message: 'find did not match any element' } };
845
- }
846
- const resolvedNode =
847
- action === 'click' || action === 'focus' || action === 'fill' || action === 'type'
848
- ? findNearestHittableAncestor(nodes, node) ?? node
849
- : node;
850
- const ref = `@${resolvedNode.ref}`;
851
- const actionFlags = { ...(req.flags ?? {}), noRecord: true };
852
- if (action === 'exists') {
853
- if (session) {
854
- recordAction(session, {
855
- command,
856
- positionals: req.positionals ?? [],
857
- flags: req.flags ?? {},
858
- result: { found: true },
859
- });
860
- }
861
- return { ok: true, data: { found: true } };
862
- }
863
- if (action === 'get_text') {
864
- const text = extractNodeText(node);
865
- if (session) {
866
- recordAction(session, {
867
- command,
868
- positionals: req.positionals ?? [],
869
- flags: req.flags ?? {},
870
- result: { ref, action: 'get text', text },
871
- });
872
- }
873
- return { ok: true, data: { ref, text, node } };
874
- }
875
- if (action === 'get_attrs') {
876
- if (session) {
877
- recordAction(session, {
878
- command,
879
- positionals: req.positionals ?? [],
880
- flags: req.flags ?? {},
881
- result: { ref, action: 'get attrs' },
882
- });
883
- }
884
- return { ok: true, data: { ref, node } };
885
- }
886
- if (action === 'click') {
887
- const response = await handleRequest({
888
- token,
889
- session: sessionName,
890
- command: 'click',
891
- positionals: [ref],
892
- flags: actionFlags,
893
- });
894
- if (!response.ok) return response;
895
- if (session) {
896
- recordAction(session, {
897
- command,
898
- positionals: req.positionals ?? [],
899
- flags: req.flags ?? {},
900
- result: { ref, action: 'click' },
901
- });
902
- }
903
- return response;
904
- }
905
- if (action === 'fill') {
906
- if (!value) {
907
- return { ok: false, error: { code: 'INVALID_ARGS', message: 'find fill requires text' } };
908
- }
909
- const response = await handleRequest({
910
- token,
911
- session: sessionName,
912
- command: 'fill',
913
- positionals: [ref, value],
914
- flags: actionFlags,
915
- });
916
- if (!response.ok) return response;
917
- if (session) {
918
- recordAction(session, {
919
- command,
920
- positionals: req.positionals ?? [],
921
- flags: req.flags ?? {},
922
- result: { ref, action: 'fill' },
923
- });
924
- }
925
- return response;
926
- }
927
- if (action === 'focus') {
928
- const coords = node.rect ? centerOfRect(node.rect) : null;
929
- if (!coords) {
930
- return { ok: false, error: { code: 'COMMAND_FAILED', message: 'matched element has no bounds' } };
931
- }
932
- const response = await dispatchCommand(
933
- device,
934
- 'focus',
935
- [String(coords.x), String(coords.y)],
936
- req.flags?.out,
937
- { ...contextFromFlags(req.flags, session?.appBundleId, session?.trace?.outPath) },
938
- );
939
- if (session) {
940
- recordAction(session, {
941
- command,
942
- positionals: req.positionals ?? [],
943
- flags: req.flags ?? {},
944
- result: { ref, action: 'focus' },
945
- });
946
- }
947
- return { ok: true, data: response ?? { ref } };
948
- }
949
- if (action === 'type') {
950
- if (!value) {
951
- return { ok: false, error: { code: 'INVALID_ARGS', message: 'find type requires text' } };
952
- }
953
- const coords = node.rect ? centerOfRect(node.rect) : null;
954
- if (!coords) {
955
- return { ok: false, error: { code: 'COMMAND_FAILED', message: 'matched element has no bounds' } };
956
- }
957
- await dispatchCommand(
958
- device,
959
- 'focus',
960
- [String(coords.x), String(coords.y)],
961
- req.flags?.out,
962
- { ...contextFromFlags(req.flags, session?.appBundleId, session?.trace?.outPath) },
963
- );
964
- const response = await dispatchCommand(
965
- device,
966
- 'type',
967
- [value],
968
- req.flags?.out,
969
- { ...contextFromFlags(req.flags, session?.appBundleId, session?.trace?.outPath) },
970
- );
971
- if (session) {
972
- recordAction(session, {
973
- command,
974
- positionals: req.positionals ?? [],
975
- flags: req.flags ?? {},
976
- result: { ref, action: 'type' },
977
- });
978
- }
979
- return { ok: true, data: response ?? { ref } };
980
- }
981
- }
982
-
983
- if (command === 'click') {
984
- const session = sessions.get(sessionName);
985
- if (!session?.snapshot) {
986
- return { ok: false, error: { code: 'INVALID_ARGS', message: 'No snapshot in session. Run snapshot first.' } };
987
- }
988
- const refInput = req.positionals?.[0] ?? '';
989
- const ref = normalizeRef(refInput);
990
- if (!ref) {
991
- return { ok: false, error: { code: 'INVALID_ARGS', message: 'click requires a ref like @e2' } };
992
- }
993
- let node = findNodeByRef(session.snapshot.nodes, ref);
994
- if (!node?.rect && req.positionals.length > 1) {
995
- const fallbackLabel = req.positionals.slice(1).join(' ').trim();
996
- if (fallbackLabel.length > 0) {
997
- node = findNodeByLabel(session.snapshot.nodes, fallbackLabel);
998
- }
999
- }
1000
- if (!node?.rect) {
1001
- return { ok: false, error: { code: 'COMMAND_FAILED', message: `Ref ${refInput} not found or has no bounds` } };
1002
- }
1003
- const refLabel = resolveRefLabel(node, session.snapshot.nodes);
1004
- const { x, y } = centerOfRect(node.rect);
1005
- await dispatchCommand(session.device, 'press', [String(x), String(y)], req.flags?.out, {
1006
- ...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath),
1007
- });
1008
- recordAction(session, {
1009
- command,
1010
- positionals: req.positionals ?? [],
1011
- flags: req.flags ?? {},
1012
- result: { ref, x, y, refLabel },
1013
- });
1014
- return { ok: true, data: { ref, x, y } };
1015
- }
1016
-
1017
- if (command === 'fill') {
1018
- const session = sessions.get(sessionName);
1019
- if (req.positionals?.[0]?.startsWith('@')) {
1020
- if (!session?.snapshot) {
1021
- return { ok: false, error: { code: 'INVALID_ARGS', message: 'No snapshot in session. Run snapshot first.' } };
1022
- }
1023
- const ref = normalizeRef(req.positionals[0]);
1024
- if (!ref) {
1025
- return { ok: false, error: { code: 'INVALID_ARGS', message: 'fill requires a ref like @e2' } };
1026
- }
1027
- const labelCandidate = req.positionals.length >= 3 ? req.positionals[1] : '';
1028
- const text = req.positionals.length >= 3 ? req.positionals.slice(2).join(' ') : req.positionals.slice(1).join(' ');
1029
- if (!text) {
1030
- return { ok: false, error: { code: 'INVALID_ARGS', message: 'fill requires text after ref' } };
1031
- }
1032
- let node = findNodeByRef(session.snapshot.nodes, ref);
1033
- if (!node?.rect && labelCandidate) {
1034
- node = findNodeByLabel(session.snapshot.nodes, labelCandidate);
1035
- }
1036
- if (!node?.rect) {
1037
- return { ok: false, error: { code: 'COMMAND_FAILED', message: `Ref ${req.positionals[0]} not found or has no bounds` } };
1038
- }
1039
- const nodeType = node.type ?? '';
1040
- if (nodeType && !isFillableType(nodeType, session.device.platform)) {
1041
- return {
1042
- ok: false,
1043
- error: {
1044
- code: 'INVALID_ARGS',
1045
- message: `fill requires a text input element, got "${nodeType}" for ${req.positionals[0]}. Select a text input ref or use click/focus + type.`,
1046
- },
1047
- };
1048
- }
1049
- const refLabel = resolveRefLabel(node, session.snapshot.nodes);
1050
- const { x, y } = centerOfRect(node.rect);
1051
- const data = await dispatchCommand(
1052
- session.device,
1053
- 'fill',
1054
- [String(x), String(y), text],
1055
- req.flags?.out,
1056
- {
1057
- ...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath),
1058
- },
1059
- );
1060
- recordAction(session, {
1061
- command,
1062
- positionals: req.positionals ?? [],
1063
- flags: req.flags ?? {},
1064
- result: data ?? { ref, x, y, refLabel },
1065
- });
1066
- return { ok: true, data: data ?? { ref, x, y } };
1067
- }
1068
- }
1069
-
1070
- if (command === 'get') {
1071
- const sub = req.positionals?.[0];
1072
- const refInput = req.positionals?.[1];
1073
- if (sub !== 'text' && sub !== 'attrs') {
1074
- return { ok: false, error: { code: 'INVALID_ARGS', message: 'get only supports text or attrs' } };
1075
- }
1076
- const session = sessions.get(sessionName);
1077
- if (!session?.snapshot) {
1078
- return { ok: false, error: { code: 'INVALID_ARGS', message: 'No snapshot in session. Run snapshot first.' } };
1079
- }
1080
- const ref = normalizeRef(refInput ?? '');
1081
- if (!ref) {
1082
- return { ok: false, error: { code: 'INVALID_ARGS', message: 'get text requires a ref like @e2' } };
1083
- }
1084
- let node = findNodeByRef(session.snapshot.nodes, ref);
1085
- if (!node && req.positionals.length > 2) {
1086
- const labelCandidate = req.positionals.slice(2).join(' ').trim();
1087
- if (labelCandidate.length > 0) {
1088
- node = findNodeByLabel(session.snapshot.nodes, labelCandidate);
1089
- }
1090
- }
1091
- if (!node) {
1092
- return { ok: false, error: { code: 'COMMAND_FAILED', message: `Ref ${refInput} not found` } };
1093
- }
1094
- if (sub === 'attrs') {
1095
- recordAction(session, {
1096
- command,
1097
- positionals: req.positionals ?? [],
1098
- flags: req.flags ?? {},
1099
- result: { ref },
1100
- });
1101
- return { ok: true, data: { ref, node } };
1102
- }
1103
- const candidates = [node.label, node.value, node.identifier]
1104
- .map((value) => (typeof value === 'string' ? value.trim() : ''))
1105
- .filter((value) => value.length > 0);
1106
- const text = candidates[0] ?? '';
1107
- recordAction(session, {
1108
- command,
1109
- positionals: req.positionals ?? [],
1110
- flags: req.flags ?? {},
1111
- result: { ref, text, refLabel: text || undefined },
1112
- });
1113
- return { ok: true, data: { ref, text, node } };
1114
- }
1115
-
1116
-
1117
- const session = sessions.get(sessionName);
1118
- if (!session) {
101
+ if (!isCommandSupportedOnDevice(command, session.device)) {
1119
102
  return {
1120
103
  ok: false,
1121
- error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' },
104
+ error: { code: 'UNSUPPORTED_OPERATION', message: `${command} is not supported on this device` },
1122
105
  };
1123
106
  }
1124
107
 
1125
108
  const data = await dispatchCommand(session.device, command, req.positionals ?? [], req.flags?.out, {
1126
109
  ...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath),
1127
110
  });
1128
- recordAction(session, {
111
+ sessionStore.recordAction(session, {
1129
112
  command,
1130
113
  positionals: req.positionals ?? [],
1131
114
  flags: req.flags ?? {},
@@ -1190,12 +173,12 @@ function start(): void {
1190
173
  });
1191
174
 
1192
175
  const shutdown = async () => {
1193
- const sessionsToStop = Array.from(sessions.values());
176
+ const sessionsToStop = sessionStore.toArray();
1194
177
  for (const session of sessionsToStop) {
1195
178
  if (session.device.platform === 'ios' && session.device.kind === 'simulator') {
1196
179
  await stopIosRunnerSession(session.device.id);
1197
180
  }
1198
- writeSessionLog(session);
181
+ sessionStore.writeSessionLog(session);
1199
182
  }
1200
183
  server.close(() => {
1201
184
  removeInfo();
@@ -1221,515 +204,6 @@ function start(): void {
1221
204
 
1222
205
  start();
1223
206
 
1224
- function recordAction(
1225
- session: SessionState,
1226
- entry: {
1227
- command: string;
1228
- positionals: string[];
1229
- flags: CommandFlags;
1230
- result?: Record<string, unknown>;
1231
- },
1232
- ): void {
1233
- if (entry.flags?.noRecord) return;
1234
- session.actions.push({
1235
- ts: Date.now(),
1236
- command: entry.command,
1237
- positionals: entry.positionals,
1238
- flags: sanitizeFlags(entry.flags),
1239
- result: entry.result,
1240
- });
1241
- }
1242
-
1243
- function sanitizeFlags(flags: CommandFlags | undefined): SessionAction['flags'] {
1244
- if (!flags) return {};
1245
- const {
1246
- platform,
1247
- device,
1248
- udid,
1249
- serial,
1250
- out,
1251
- verbose,
1252
- snapshotInteractiveOnly,
1253
- snapshotCompact,
1254
- snapshotDepth,
1255
- snapshotScope,
1256
- snapshotRaw,
1257
- snapshotBackend,
1258
- appsMetadata,
1259
- noRecord,
1260
- recordJson,
1261
- } = flags as any;
1262
- return {
1263
- platform,
1264
- device,
1265
- udid,
1266
- serial,
1267
- out,
1268
- verbose,
1269
- snapshotInteractiveOnly,
1270
- snapshotCompact,
1271
- snapshotDepth,
1272
- snapshotScope,
1273
- snapshotRaw,
1274
- snapshotBackend,
1275
- appsMetadata,
1276
- noRecord,
1277
- recordJson,
1278
- };
1279
- }
1280
-
1281
- type FindAction =
1282
- | { kind: 'click' }
1283
- | { kind: 'focus' }
1284
- | { kind: 'fill'; value: string }
1285
- | { kind: 'type'; value: string }
1286
- | { kind: 'get_text' }
1287
- | { kind: 'get_attrs' }
1288
- | { kind: 'exists' }
1289
- | { kind: 'wait'; timeoutMs?: number };
1290
-
1291
- function parseFindArgs(args: string[]): {
1292
- locator: FindLocator;
1293
- query: string;
1294
- action: FindAction['kind'];
1295
- value?: string;
1296
- timeoutMs?: number;
1297
- } {
1298
- const locatorTokens: FindLocator[] = ['text', 'label', 'value', 'role', 'id'];
1299
- let locator: FindLocator = 'any';
1300
- let queryIndex = 0;
1301
- if (locatorTokens.includes(args[0] as FindLocator)) {
1302
- locator = args[0] as FindLocator;
1303
- queryIndex = 1;
1304
- }
1305
- const query = args[queryIndex] ?? '';
1306
- const actionTokens = args.slice(queryIndex + 1);
1307
- if (actionTokens.length === 0) {
1308
- return { locator, query, action: 'click' };
1309
- }
1310
- const action = actionTokens[0].toLowerCase();
1311
- if (action === 'get') {
1312
- const sub = actionTokens[1]?.toLowerCase();
1313
- if (sub === 'text') return { locator, query, action: 'get_text' };
1314
- if (sub === 'attrs') return { locator, query, action: 'get_attrs' };
1315
- throw new AppError('INVALID_ARGS', 'find get only supports text or attrs');
1316
- }
1317
- if (action === 'wait') {
1318
- const timeoutMs = parseTimeout(actionTokens[1]);
1319
- return { locator, query, action: 'wait', timeoutMs: timeoutMs ?? undefined };
1320
- }
1321
- if (action === 'exists') return { locator, query, action: 'exists' };
1322
- if (action === 'click') return { locator, query, action: 'click' };
1323
- if (action === 'focus') return { locator, query, action: 'focus' };
1324
- if (action === 'fill') {
1325
- const value = actionTokens.slice(1).join(' ');
1326
- return { locator, query, action: 'fill', value };
1327
- }
1328
- if (action === 'type') {
1329
- const value = actionTokens.slice(1).join(' ');
1330
- return { locator, query, action: 'type', value };
1331
- }
1332
- throw new AppError('INVALID_ARGS', `Unsupported find action: ${actionTokens[0]}`);
1333
- }
1334
-
1335
- function parseTimeout(value: string | undefined): number | null {
1336
- if (!value) return null;
1337
- const parsed = Number(value);
1338
- return Number.isFinite(parsed) ? parsed : null;
1339
- }
1340
-
1341
- function shouldScopeFind(locator: FindLocator): boolean {
1342
- return locator !== 'role';
1343
- }
1344
-
1345
- function extractNodeText(node: SnapshotState['nodes'][number]): string {
1346
- const candidates = [node.label, node.value, node.identifier]
1347
- .map((value) => (typeof value === 'string' ? value.trim() : ''))
1348
- .filter((value) => value.length > 0);
1349
- return candidates[0] ?? '';
1350
- }
1351
-
1352
- async function resolveIosAppStateFromSnapshots(
1353
- device: DeviceInfo,
1354
- traceLogPath: string | undefined,
1355
- flags: CommandFlags | undefined,
1356
- ): Promise<{ appName: string; appBundleId?: string; source: 'snapshot-ax' | 'snapshot-xctest' }> {
1357
- const axResult = await dispatchCommand(device, 'snapshot', [], flags?.out, {
1358
- ...contextFromFlags(
1359
- {
1360
- ...flags,
1361
- snapshotDepth: 1,
1362
- snapshotCompact: true,
1363
- snapshotBackend: 'ax',
1364
- },
1365
- undefined,
1366
- traceLogPath,
1367
- ),
1368
- });
1369
- const axNode = extractAppNodeFromSnapshot(axResult as { nodes?: RawSnapshotNode[] });
1370
- if (axNode?.appName || axNode?.appBundleId) {
1371
- return {
1372
- appName: axNode.appName ?? axNode.appBundleId ?? 'unknown',
1373
- appBundleId: axNode.appBundleId,
1374
- source: 'snapshot-ax',
1375
- };
1376
- }
1377
- const xctestResult = await dispatchCommand(device, 'snapshot', [], flags?.out, {
1378
- ...contextFromFlags(
1379
- {
1380
- ...flags,
1381
- snapshotDepth: 1,
1382
- snapshotCompact: true,
1383
- snapshotBackend: 'xctest',
1384
- },
1385
- undefined,
1386
- traceLogPath,
1387
- ),
1388
- });
1389
- const xcNode = extractAppNodeFromSnapshot(xctestResult as { nodes?: RawSnapshotNode[] });
1390
- return {
1391
- appName: xcNode?.appName ?? xcNode?.appBundleId ?? 'unknown',
1392
- appBundleId: xcNode?.appBundleId,
1393
- source: 'snapshot-xctest',
1394
- };
1395
- }
1396
-
1397
- function extractAppNodeFromSnapshot(
1398
- data: { nodes?: RawSnapshotNode[] } | undefined,
1399
- ): { appName?: string; appBundleId?: string } | null {
1400
- const rawNodes = data?.nodes ?? [];
1401
- const nodes = attachRefs(rawNodes);
1402
- const appNode = nodes.find((node) => normalizeType(node.type ?? '') === 'application') ?? nodes[0];
1403
- if (!appNode) return null;
1404
- const appName = appNode.label?.trim();
1405
- const appBundleId = appNode.identifier?.trim();
1406
- if (!appName && !appBundleId) return null;
1407
- return { appName: appName || undefined, appBundleId: appBundleId || undefined };
1408
- }
1409
-
1410
- function writeSessionLog(session: SessionState): void {
1411
- try {
1412
- if (!fs.existsSync(sessionsDir)) fs.mkdirSync(sessionsDir, { recursive: true });
1413
- const safeName = session.name.replace(/[^a-zA-Z0-9._-]/g, '_');
1414
- const timestamp = new Date(session.createdAt).toISOString().replace(/[:.]/g, '-');
1415
- const scriptPath = path.join(sessionsDir, `${safeName}-${timestamp}.ad`);
1416
- const filePath = resolveSessionJsonPath(session, safeName, timestamp);
1417
- const payload = {
1418
- name: session.name,
1419
- device: session.device,
1420
- createdAt: session.createdAt,
1421
- appBundleId: session.appBundleId,
1422
- actions: session.actions,
1423
- optimizedActions: buildOptimizedActions(session),
1424
- };
1425
- const script = formatScript(session, payload.optimizedActions);
1426
- fs.writeFileSync(scriptPath, script);
1427
- if (session.actions.some((action) => action.flags?.recordJson)) {
1428
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
1429
- fs.writeFileSync(filePath, JSON.stringify(payload, null, 2));
1430
- }
1431
- } catch {
1432
- // ignore
1433
- }
1434
- }
1435
-
1436
- function resolveSessionJsonPath(session: SessionState, safeName: string, timestamp: string): string {
1437
- const defaultFile = path.join(sessionsDir, `${safeName}-${timestamp}.json`);
1438
- const actionWithOut = [...session.actions]
1439
- .reverse()
1440
- .find((action) => action.flags?.recordJson && typeof action.flags?.out === 'string' && action.flags.out.trim().length > 0);
1441
- if (!actionWithOut || !actionWithOut.flags?.out) {
1442
- return defaultFile;
1443
- }
1444
-
1445
- const rawOut = actionWithOut.flags.out.trim();
1446
- const resolvedOut = expandHome(rawOut);
1447
- const wantsDirectory = rawOut.endsWith('/') || rawOut.endsWith('\\');
1448
- if (wantsDirectory) {
1449
- return path.join(resolvedOut, `${safeName}-${timestamp}.json`);
1450
- }
1451
-
1452
- try {
1453
- if (fs.existsSync(resolvedOut) && fs.statSync(resolvedOut).isDirectory()) {
1454
- return path.join(resolvedOut, `${safeName}-${timestamp}.json`);
1455
- }
1456
- } catch {
1457
- return defaultFile;
1458
- }
1459
-
1460
- return resolvedOut;
1461
- }
1462
-
1463
- function defaultTracePath(session: SessionState): string {
1464
- const safeName = session.name.replace(/[^a-zA-Z0-9._-]/g, '_');
1465
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
1466
- return path.join(sessionsDir, `${safeName}-${timestamp}.trace.log`);
1467
- }
1468
-
1469
- function expandHome(filePath: string): string {
1470
- if (filePath.startsWith('~/')) {
1471
- return path.join(os.homedir(), filePath.slice(2));
1472
- }
1473
- return path.resolve(filePath);
1474
- }
1475
-
1476
- function buildOptimizedActions(session: SessionState): SessionAction[] {
1477
- const optimized: SessionAction[] = [];
1478
- for (const action of session.actions) {
1479
- if (action.command === 'snapshot') {
1480
- continue;
1481
- }
1482
- if (action.command === 'click' || action.command === 'fill' || action.command === 'get') {
1483
- const refLabel = action.result?.refLabel;
1484
- if (typeof refLabel === 'string' && refLabel.trim().length > 0) {
1485
- optimized.push({
1486
- ts: action.ts,
1487
- command: 'snapshot',
1488
- positionals: [],
1489
- flags: {
1490
- platform: session.device.platform,
1491
- snapshotInteractiveOnly: true,
1492
- snapshotCompact: true,
1493
- snapshotScope: refLabel.trim(),
1494
- },
1495
- result: { scope: refLabel.trim() },
1496
- });
1497
- }
1498
- }
1499
- optimized.push(action);
1500
- }
1501
- return optimized;
1502
- }
1503
-
1504
- function formatScript(session: SessionState, actions: SessionAction[]): string {
1505
- const lines: string[] = [];
1506
- const deviceLabel = session.device.name.replace(/"/g, '\\"');
1507
- const kind = session.device.kind ? ` kind=${session.device.kind}` : '';
1508
- const theme = 'unknown';
1509
- lines.push(`context platform=${session.device.platform} device="${deviceLabel}"${kind} theme=${theme}`);
1510
- for (const action of actions) {
1511
- if (action.flags?.noRecord) continue;
1512
- lines.push(formatActionLine(action));
1513
- }
1514
- return `${lines.join('\n')}\n`;
1515
- }
1516
-
1517
- function formatActionLine(action: SessionAction): string {
1518
- const parts: string[] = [action.command];
1519
- if (action.command === 'click') {
1520
- const ref = action.positionals?.[0];
1521
- if (ref) {
1522
- parts.push(formatArg(ref));
1523
- const refLabel = action.result?.refLabel;
1524
- if (typeof refLabel === 'string' && refLabel.trim().length > 0) {
1525
- parts.push(formatArg(refLabel));
1526
- }
1527
- return parts.join(' ');
1528
- }
1529
- }
1530
- if (action.command === 'fill') {
1531
- const ref = action.positionals?.[0];
1532
- if (ref && ref.startsWith('@')) {
1533
- parts.push(formatArg(ref));
1534
- const refLabel = action.result?.refLabel;
1535
- const text = action.positionals.slice(1).join(' ');
1536
- if (typeof refLabel === 'string' && refLabel.trim().length > 0) {
1537
- parts.push(formatArg(refLabel));
1538
- }
1539
- if (text) {
1540
- parts.push(formatArg(text));
1541
- }
1542
- return parts.join(' ');
1543
- }
1544
- }
1545
- if (action.command === 'get') {
1546
- const sub = action.positionals?.[0];
1547
- const ref = action.positionals?.[1];
1548
- if (sub && ref) {
1549
- parts.push(formatArg(sub));
1550
- parts.push(formatArg(ref));
1551
- const refLabel = action.result?.refLabel;
1552
- if (typeof refLabel === 'string' && refLabel.trim().length > 0) {
1553
- parts.push(formatArg(refLabel));
1554
- }
1555
- return parts.join(' ');
1556
- }
1557
- }
1558
- if (action.command === 'snapshot') {
1559
- if (action.flags?.snapshotInteractiveOnly) parts.push('-i');
1560
- if (action.flags?.snapshotCompact) parts.push('-c');
1561
- if (typeof action.flags?.snapshotDepth === 'number') {
1562
- parts.push('-d', String(action.flags.snapshotDepth));
1563
- }
1564
- if (action.flags?.snapshotScope) {
1565
- parts.push('-s', formatArg(action.flags.snapshotScope));
1566
- }
1567
- if (action.flags?.snapshotRaw) parts.push('--raw');
1568
- if (action.flags?.snapshotBackend) {
1569
- parts.push(`--backend`, action.flags.snapshotBackend);
1570
- }
1571
- return parts.join(' ');
1572
- }
1573
- for (const positional of action.positionals ?? []) {
1574
- parts.push(formatArg(positional));
1575
- }
1576
- return parts.join(' ');
1577
- }
1578
-
1579
- function formatArg(value: string): string {
1580
- const trimmed = value.trim();
1581
- if (trimmed.startsWith('@')) return trimmed;
1582
- if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed;
1583
- return JSON.stringify(trimmed);
1584
- }
1585
-
1586
- function findNodeByLabel(nodes: SnapshotState['nodes'], label: string) {
1587
- const query = label.toLowerCase();
1588
- return (
1589
- nodes.find((node) => {
1590
- const labelValue = (node.label ?? '').toLowerCase();
1591
- const valueValue = (node.value ?? '').toLowerCase();
1592
- const idValue = (node.identifier ?? '').toLowerCase();
1593
- return labelValue.includes(query) || valueValue.includes(query) || idValue.includes(query);
1594
- }) ?? null
1595
- );
1596
- }
1597
-
1598
- function resolveRefLabel(
1599
- node: SnapshotState['nodes'][number],
1600
- nodes: SnapshotState['nodes'],
1601
- ): string | undefined {
1602
- const primary = [node.label, node.value, node.identifier]
1603
- .map((value) => (typeof value === 'string' ? value.trim() : ''))
1604
- .find((value) => value && value.length > 0);
1605
- if (primary && isMeaningfulLabel(primary)) return primary;
1606
- const fallback = findNearestMeaningfulLabel(node, nodes);
1607
- return fallback ?? (primary && isMeaningfulLabel(primary) ? primary : undefined);
1608
- }
1609
-
1610
- function isMeaningfulLabel(value: string): boolean {
1611
- const trimmed = value.trim();
1612
- if (!trimmed) return false;
1613
- if (/^(true|false)$/i.test(trimmed)) return false;
1614
- if (/^\d+$/.test(trimmed)) return false;
1615
- return true;
1616
- }
1617
-
1618
- function findNearestMeaningfulLabel(
1619
- target: SnapshotState['nodes'][number],
1620
- nodes: SnapshotState['nodes'],
1621
- ): string | undefined {
1622
- if (!target.rect) return undefined;
1623
- const targetY = target.rect.y + target.rect.height / 2;
1624
- let best: { label: string; distance: number } | null = null;
1625
- for (const node of nodes) {
1626
- if (!node.rect) continue;
1627
- const label = [node.label, node.value, node.identifier]
1628
- .map((value) => (typeof value === 'string' ? value.trim() : ''))
1629
- .find((value) => value && value.length > 0);
1630
- if (!label || !isMeaningfulLabel(label)) continue;
1631
- const nodeY = node.rect.y + node.rect.height / 2;
1632
- const distance = Math.abs(nodeY - targetY);
1633
- if (!best || distance < best.distance) {
1634
- best = { label, distance };
1635
- }
1636
- }
1637
- return best?.label;
1638
- }
1639
-
1640
- async function ensureDeviceReady(device: DeviceInfo): Promise<void> {
1641
- if (device.platform === 'ios' && device.kind === 'simulator') {
1642
- const { ensureBootedSimulator } = await import('./platforms/ios/index.ts');
1643
- await ensureBootedSimulator(device);
1644
- return;
1645
- }
1646
- if (device.platform === 'android') {
1647
- const { waitForAndroidBoot } = await import('./platforms/android/devices.ts');
1648
- await waitForAndroidBoot(device.id);
1649
- }
1650
- }
1651
-
1652
- function isLabelUnique(nodes: SnapshotState['nodes'], label: string): boolean {
1653
- const target = label.trim().toLowerCase();
1654
- if (!target) return false;
1655
- let count = 0;
1656
- for (const node of nodes) {
1657
- if ((node.label ?? '').trim().toLowerCase() === target) {
1658
- count += 1;
1659
- if (count > 1) return false;
1660
- }
1661
- }
1662
- return count === 1;
1663
- }
1664
-
1665
- function pruneGroupNodes(nodes: RawSnapshotNode[]): RawSnapshotNode[] {
1666
- const skippedDepths: number[] = [];
1667
- const result: RawSnapshotNode[] = [];
1668
- for (const node of nodes) {
1669
- const depth = node.depth ?? 0;
1670
- while (skippedDepths.length > 0 && depth <= skippedDepths[skippedDepths.length - 1]) {
1671
- skippedDepths.pop();
1672
- }
1673
- const type = normalizeType(node.type ?? '');
1674
- const labelCandidate = [node.label, node.value, node.identifier]
1675
- .map((value) => (typeof value === 'string' ? value.trim() : ''))
1676
- .find((value) => value && value.length > 0);
1677
- const hasMeaningfulLabel = labelCandidate ? isMeaningfulLabel(labelCandidate) : false;
1678
- if ((type === 'group' || type === 'ioscontentgroup') && !hasMeaningfulLabel) {
1679
- skippedDepths.push(depth);
1680
- continue;
1681
- }
1682
- const adjustedDepth = Math.max(0, depth - skippedDepths.length);
1683
- result.push({ ...node, depth: adjustedDepth });
1684
- }
1685
- return result;
1686
- }
1687
-
1688
- function normalizeType(type: string): string {
1689
- let value = type.replace(/XCUIElementType/gi, '').toLowerCase();
1690
- if (value.startsWith('ax')) {
1691
- value = value.replace(/^ax/, '');
1692
- }
1693
- return value;
1694
- }
1695
-
1696
- function isFillableType(type: string, platform: 'ios' | 'android'): boolean {
1697
- const normalized = normalizeType(type);
1698
- if (!normalized) return true;
1699
- if (platform === 'android') {
1700
- return (
1701
- normalized.includes('edittext') ||
1702
- normalized.includes('autocompletetextview')
1703
- );
1704
- }
1705
- return (
1706
- normalized.includes('textfield') ||
1707
- normalized.includes('securetextfield') ||
1708
- normalized.includes('searchfield') ||
1709
- normalized.includes('textview') ||
1710
- normalized.includes('textarea') ||
1711
- normalized === 'search'
1712
- );
1713
- }
1714
-
1715
- function findNearestHittableAncestor(
1716
- nodes: SnapshotState['nodes'],
1717
- node: SnapshotState['nodes'][number],
1718
- ): SnapshotState['nodes'][number] | null {
1719
- if (node.hittable) return node;
1720
- let current = node;
1721
- const visited = new Set<string>();
1722
- while (current.parentIndex !== undefined) {
1723
- if (visited.has(current.ref)) break;
1724
- visited.add(current.ref);
1725
- const parent = nodes[current.parentIndex];
1726
- if (!parent) break;
1727
- if (parent.hittable) return parent;
1728
- current = parent;
1729
- }
1730
- return null;
1731
- }
1732
-
1733
207
  function readVersion(): string {
1734
208
  try {
1735
209
  const root = findProjectRoot();