agent-device 0.3.0 → 0.3.2

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 (34) hide show
  1. package/README.md +26 -2
  2. package/dist/src/274.js +1 -0
  3. package/dist/src/bin.js +27 -22
  4. package/dist/src/daemon.js +15 -10
  5. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj +4 -2
  6. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +149 -0
  7. package/package.json +2 -2
  8. package/skills/agent-device/SKILL.md +8 -1
  9. package/src/cli.ts +13 -0
  10. package/src/core/__tests__/capabilities.test.ts +2 -0
  11. package/src/core/capabilities.ts +2 -0
  12. package/src/daemon/handlers/__tests__/replay-heal.test.ts +5 -0
  13. package/src/daemon/handlers/__tests__/session-reinstall.test.ts +219 -0
  14. package/src/daemon/handlers/__tests__/session.test.ts +122 -0
  15. package/src/daemon/handlers/find.ts +23 -3
  16. package/src/daemon/handlers/session.ts +175 -10
  17. package/src/daemon-client.ts +1 -24
  18. package/src/daemon.ts +1 -24
  19. package/src/platforms/__tests__/boot-diagnostics.test.ts +59 -0
  20. package/src/platforms/android/__tests__/index.test.ts +17 -0
  21. package/src/platforms/android/devices.ts +167 -42
  22. package/src/platforms/android/index.ts +101 -14
  23. package/src/platforms/boot-diagnostics.ts +128 -0
  24. package/src/platforms/ios/index.ts +161 -2
  25. package/src/platforms/ios/runner-client.ts +19 -1
  26. package/src/utils/__tests__/exec.test.ts +16 -0
  27. package/src/utils/__tests__/finders.test.ts +34 -0
  28. package/src/utils/__tests__/retry.test.ts +44 -0
  29. package/src/utils/args.ts +9 -1
  30. package/src/utils/exec.ts +39 -0
  31. package/src/utils/finders.ts +27 -9
  32. package/src/utils/retry.ts +143 -13
  33. package/src/utils/version.ts +26 -0
  34. package/dist/src/861.js +0 -1
@@ -1,5 +1,5 @@
1
1
  import { dispatchCommand, resolveTargetDevice } from '../../core/dispatch.ts';
2
- import { findNodeByLocator, type FindLocator } from '../../utils/finders.ts';
2
+ import { findBestMatchesByLocator, type FindLocator } from '../../utils/finders.ts';
3
3
  import { attachRefs, centerOfRect, type RawSnapshotNode, type SnapshotState } from '../../utils/snapshot.ts';
4
4
  import { AppError } from '../../utils/errors.ts';
5
5
  import type { DaemonRequest, DaemonResponse } from '../types.ts';
@@ -97,7 +97,7 @@ export async function handleFindCommands(params: {
97
97
  const start = Date.now();
98
98
  while (Date.now() - start < timeout) {
99
99
  const { nodes } = await fetchNodes();
100
- const match = findNodeByLocator(nodes, locator, query, { requireRect: false });
100
+ const match = findBestMatchesByLocator(nodes, locator, query, { requireRect: false }).matches[0];
101
101
  if (match) {
102
102
  if (session) {
103
103
  sessionStore.recordAction(session, {
@@ -114,7 +114,27 @@ export async function handleFindCommands(params: {
114
114
  return { ok: false, error: { code: 'COMMAND_FAILED', message: 'find wait timed out' } };
115
115
  }
116
116
  const { nodes } = await fetchNodes();
117
- const node = findNodeByLocator(nodes, locator, query, { requireRect: requiresRect });
117
+ const bestMatches = findBestMatchesByLocator(nodes, locator, query, { requireRect: requiresRect });
118
+ if (requiresRect && bestMatches.matches.length > 1) {
119
+ const candidates = bestMatches.matches.slice(0, 8).map((candidate) => {
120
+ const label = extractNodeText(candidate) || candidate.label || candidate.identifier || candidate.type || '';
121
+ return `@${candidate.ref}${label ? `(${label})` : ''}`;
122
+ });
123
+ return {
124
+ ok: false,
125
+ error: {
126
+ code: 'AMBIGUOUS_MATCH',
127
+ message: `find matched ${bestMatches.matches.length} elements for ${locator} "${query}". Use a more specific locator or selector.`,
128
+ details: {
129
+ locator,
130
+ query,
131
+ matches: bestMatches.matches.length,
132
+ candidates,
133
+ },
134
+ },
135
+ };
136
+ }
137
+ const node = bestMatches.matches[0] ?? null;
118
138
  if (!node) {
119
139
  return { ok: false, error: { code: 'COMMAND_FAILED', message: 'find did not match any element' } };
120
140
  }
@@ -14,6 +14,39 @@ import { pruneGroupNodes } from '../snapshot-processing.ts';
14
14
  import { buildSelectorChainForNode, parseSelectorChain, resolveSelectorChain, splitSelectorFromArgs } from '../selectors.ts';
15
15
  import { inferFillText, uniqueStrings } from '../action-utils.ts';
16
16
 
17
+ type ReinstallOps = {
18
+ ios: (device: DeviceInfo, app: string, appPath: string) => Promise<{ bundleId: string }>;
19
+ android: (device: DeviceInfo, app: string, appPath: string) => Promise<{ package: string }>;
20
+ };
21
+
22
+ function hasExplicitDeviceSelector(flags: DaemonRequest['flags'] | undefined): boolean {
23
+ return Boolean(flags?.platform || flags?.device || flags?.udid || flags?.serial);
24
+ }
25
+
26
+ async function resolveCommandDevice(params: {
27
+ session: SessionState | undefined;
28
+ flags: DaemonRequest['flags'] | undefined;
29
+ ensureReadyFn: typeof ensureDeviceReady;
30
+ ensureReady?: boolean;
31
+ }): Promise<DeviceInfo> {
32
+ const device = params.session?.device ?? (await resolveTargetDevice(params.flags ?? {}));
33
+ if (params.ensureReady !== false) {
34
+ await params.ensureReadyFn(device);
35
+ }
36
+ return device;
37
+ }
38
+
39
+ const defaultReinstallOps: ReinstallOps = {
40
+ ios: async (device, app, appPath) => {
41
+ const { reinstallIosApp } = await import('../../platforms/ios/index.ts');
42
+ return await reinstallIosApp(device, app, appPath);
43
+ },
44
+ android: async (device, app, appPath) => {
45
+ const { reinstallAndroidApp } = await import('../../platforms/android/index.ts');
46
+ return await reinstallAndroidApp(device, app, appPath);
47
+ },
48
+ };
49
+
17
50
  export async function handleSessionCommands(params: {
18
51
  req: DaemonRequest;
19
52
  sessionName: string;
@@ -21,9 +54,21 @@ export async function handleSessionCommands(params: {
21
54
  sessionStore: SessionStore;
22
55
  invoke: (req: DaemonRequest) => Promise<DaemonResponse>;
23
56
  dispatch?: typeof dispatchCommand;
57
+ ensureReady?: typeof ensureDeviceReady;
58
+ reinstallOps?: ReinstallOps;
24
59
  }): Promise<DaemonResponse | null> {
25
- const { req, sessionName, logPath, sessionStore, invoke, dispatch: dispatchOverride } = params;
60
+ const {
61
+ req,
62
+ sessionName,
63
+ logPath,
64
+ sessionStore,
65
+ invoke,
66
+ dispatch: dispatchOverride,
67
+ ensureReady: ensureReadyOverride,
68
+ reinstallOps = defaultReinstallOps,
69
+ } = params;
26
70
  const dispatch = dispatchOverride ?? dispatchCommand;
71
+ const ensureReady = ensureReadyOverride ?? ensureDeviceReady;
27
72
  const command = req.command;
28
73
 
29
74
  if (command === 'session_list') {
@@ -72,7 +117,7 @@ export async function handleSessionCommands(params: {
72
117
  if (command === 'apps') {
73
118
  const session = sessionStore.get(sessionName);
74
119
  const flags = req.flags ?? {};
75
- if (!session && !flags.platform && !flags.device && !flags.udid && !flags.serial) {
120
+ if (!session && !hasExplicitDeviceSelector(flags)) {
76
121
  return {
77
122
  ok: false,
78
123
  error: {
@@ -81,8 +126,7 @@ export async function handleSessionCommands(params: {
81
126
  },
82
127
  };
83
128
  }
84
- const device = session?.device ?? (await resolveTargetDevice(flags));
85
- await ensureDeviceReady(device);
129
+ const device = await resolveCommandDevice({ session, flags, ensureReadyFn: ensureReady, ensureReady: true });
86
130
  if (!isCommandSupportedOnDevice('apps', device)) {
87
131
  return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'apps is not supported on this device' } };
88
132
  }
@@ -106,11 +150,39 @@ export async function handleSessionCommands(params: {
106
150
  return { ok: true, data: { apps } };
107
151
  }
108
152
 
109
- if (command === 'appstate') {
153
+ if (command === 'boot') {
110
154
  const session = sessionStore.get(sessionName);
111
155
  const flags = req.flags ?? {};
156
+ if (!session && !hasExplicitDeviceSelector(flags)) {
157
+ return {
158
+ ok: false,
159
+ error: {
160
+ code: 'INVALID_ARGS',
161
+ message: 'boot requires an active session or an explicit device selector (e.g. --platform ios).',
162
+ },
163
+ };
164
+ }
112
165
  const device = session?.device ?? (await resolveTargetDevice(flags));
113
- await ensureDeviceReady(device);
166
+ if (!isCommandSupportedOnDevice('boot', device)) {
167
+ return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'boot is not supported on this device' } };
168
+ }
169
+ await ensureReady(device);
170
+ return {
171
+ ok: true,
172
+ data: {
173
+ platform: device.platform,
174
+ device: device.name,
175
+ id: device.id,
176
+ kind: device.kind,
177
+ booted: true,
178
+ },
179
+ };
180
+ }
181
+
182
+ if (command === 'appstate') {
183
+ const session = sessionStore.get(sessionName);
184
+ const flags = req.flags ?? {};
185
+ const device = await resolveCommandDevice({ session, flags, ensureReadyFn: ensureReady, ensureReady: true });
114
186
  if (device.platform === 'ios') {
115
187
  if (session?.appBundleId) {
116
188
  return {
@@ -151,6 +223,62 @@ export async function handleSessionCommands(params: {
151
223
  };
152
224
  }
153
225
 
226
+ if (command === 'reinstall') {
227
+ const session = sessionStore.get(sessionName);
228
+ const flags = req.flags ?? {};
229
+ if (!session && !hasExplicitDeviceSelector(flags)) {
230
+ return {
231
+ ok: false,
232
+ error: {
233
+ code: 'INVALID_ARGS',
234
+ message: 'reinstall requires an active session or an explicit device selector (e.g. --platform ios).',
235
+ },
236
+ };
237
+ }
238
+ const app = req.positionals?.[0]?.trim();
239
+ const appPathInput = req.positionals?.[1]?.trim();
240
+ if (!app || !appPathInput) {
241
+ return {
242
+ ok: false,
243
+ error: { code: 'INVALID_ARGS', message: 'reinstall requires: reinstall <app> <path-to-app-binary>' },
244
+ };
245
+ }
246
+ const appPath = SessionStore.expandHome(appPathInput);
247
+ if (!fs.existsSync(appPath)) {
248
+ return {
249
+ ok: false,
250
+ error: { code: 'INVALID_ARGS', message: `App binary not found: ${appPath}` },
251
+ };
252
+ }
253
+ const device = await resolveCommandDevice({ session, flags, ensureReadyFn: ensureReady, ensureReady: false });
254
+ if (!isCommandSupportedOnDevice('reinstall', device)) {
255
+ return {
256
+ ok: false,
257
+ error: { code: 'UNSUPPORTED_OPERATION', message: 'reinstall is not supported on this device' },
258
+ };
259
+ }
260
+ let reinstallData:
261
+ | { platform: 'ios'; appId: string; bundleId: string }
262
+ | { platform: 'android'; appId: string; package: string };
263
+ if (device.platform === 'ios') {
264
+ const iosResult = await reinstallOps.ios(device, app, appPath);
265
+ reinstallData = { platform: 'ios', appId: iosResult.bundleId, bundleId: iosResult.bundleId };
266
+ } else {
267
+ const androidResult = await reinstallOps.android(device, app, appPath);
268
+ reinstallData = { platform: 'android', appId: androidResult.package, package: androidResult.package };
269
+ }
270
+ const result = { app, appPath, ...reinstallData };
271
+ if (session) {
272
+ sessionStore.recordAction(session, {
273
+ command,
274
+ positionals: req.positionals ?? [],
275
+ flags: req.flags ?? {},
276
+ result,
277
+ });
278
+ }
279
+ return { ok: true, data: result };
280
+ }
281
+
154
282
  if (command === 'open') {
155
283
  if (sessionStore.has(sessionName)) {
156
284
  const session = sessionStore.get(sessionName);
@@ -193,7 +321,6 @@ export async function handleSessionCommands(params: {
193
321
  return { ok: true, data: { session: sessionName, appName, appBundleId } };
194
322
  }
195
323
  const device = await resolveTargetDevice(req.flags ?? {});
196
- await ensureDeviceReady(device);
197
324
  const inUse = sessionStore.toArray().find((s) => s.device.id === device.id);
198
325
  if (inUse) {
199
326
  return {
@@ -269,7 +396,9 @@ export async function handleSessionCommands(params: {
269
396
  flags: action.flags ?? {},
270
397
  });
271
398
  if (response.ok) continue;
272
- if (!shouldUpdate) return response;
399
+ if (!shouldUpdate) {
400
+ return withReplayFailureContext(response, action, index, resolved);
401
+ }
273
402
  const nextAction = await healReplayAction({
274
403
  action,
275
404
  sessionName,
@@ -278,7 +407,7 @@ export async function handleSessionCommands(params: {
278
407
  dispatch,
279
408
  });
280
409
  if (!nextAction) {
281
- return response;
410
+ return withReplayFailureContext(response, action, index, resolved);
282
411
  }
283
412
  actions[index] = nextAction;
284
413
  response = await invoke({
@@ -289,7 +418,7 @@ export async function handleSessionCommands(params: {
289
418
  flags: nextAction.flags ?? {},
290
419
  });
291
420
  if (!response.ok) {
292
- return response;
421
+ return withReplayFailureContext(response, nextAction, index, resolved);
293
422
  }
294
423
  healed += 1;
295
424
  }
@@ -334,6 +463,42 @@ export async function handleSessionCommands(params: {
334
463
  return null;
335
464
  }
336
465
 
466
+ function withReplayFailureContext(
467
+ response: DaemonResponse,
468
+ action: SessionAction,
469
+ index: number,
470
+ replayPath: string,
471
+ ): DaemonResponse {
472
+ if (response.ok) return response;
473
+ const step = index + 1;
474
+ const summary = formatReplayActionSummary(action);
475
+ const details = {
476
+ ...(response.error.details ?? {}),
477
+ replayPath,
478
+ step,
479
+ action: action.command,
480
+ positionals: action.positionals ?? [],
481
+ };
482
+ return {
483
+ ok: false,
484
+ error: {
485
+ code: response.error.code,
486
+ message: `Replay failed at step ${step} (${summary}): ${response.error.message}`,
487
+ details,
488
+ },
489
+ };
490
+ }
491
+
492
+ function formatReplayActionSummary(action: SessionAction): string {
493
+ const values = (action.positionals ?? []).map((value) => {
494
+ const trimmed = value.trim();
495
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed;
496
+ if (trimmed.startsWith('@')) return trimmed;
497
+ return JSON.stringify(trimmed);
498
+ });
499
+ return [action.command, ...values].join(' ');
500
+ }
501
+
337
502
  async function healReplayAction(params: {
338
503
  action: SessionAction;
339
504
  sessionName: string;
@@ -2,10 +2,10 @@ import net from 'node:net';
2
2
  import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
- import { fileURLToPath } from 'node:url';
6
5
  import { AppError } from './utils/errors.ts';
7
6
  import type { CommandFlags } from './core/dispatch.ts';
8
7
  import { runCmdDetached } from './utils/exec.ts';
8
+ import { findProjectRoot, readVersion } from './utils/version.ts';
9
9
 
10
10
  export type DaemonRequest = {
11
11
  token: string;
@@ -134,18 +134,6 @@ async function sendRequest(info: DaemonInfo, req: DaemonRequest): Promise<Daemon
134
134
  });
135
135
  }
136
136
 
137
- function readVersion(): string {
138
- try {
139
- const root = findProjectRoot();
140
- const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) as {
141
- version?: string;
142
- };
143
- return pkg.version ?? '0.0.0';
144
- } catch {
145
- return '0.0.0';
146
- }
147
- }
148
-
149
137
  function resolveRequestTimeoutMs(): number {
150
138
  const raw = process.env.AGENT_DEVICE_DAEMON_TIMEOUT_MS;
151
139
  if (!raw) return 60000;
@@ -153,14 +141,3 @@ function resolveRequestTimeoutMs(): number {
153
141
  if (!Number.isFinite(parsed)) return 60000;
154
142
  return Math.max(1000, Math.floor(parsed));
155
143
  }
156
-
157
- function findProjectRoot(): string {
158
- const start = path.dirname(fileURLToPath(import.meta.url));
159
- let current = start;
160
- for (let i = 0; i < 6; i += 1) {
161
- const pkgPath = path.join(current, 'package.json');
162
- if (fs.existsSync(pkgPath)) return current;
163
- current = path.dirname(current);
164
- }
165
- return start;
166
- }
package/src/daemon.ts CHANGED
@@ -3,10 +3,10 @@ import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import crypto from 'node:crypto';
6
- import { fileURLToPath } from 'node:url';
7
6
  import { dispatchCommand, type CommandFlags } from './core/dispatch.ts';
8
7
  import { isCommandSupportedOnDevice } from './core/capabilities.ts';
9
8
  import { asAppError, AppError } from './utils/errors.ts';
9
+ import { readVersion } from './utils/version.ts';
10
10
  import { stopIosRunnerSession } from './platforms/ios/runner-client.ts';
11
11
  import type { DaemonRequest, DaemonResponse } from './daemon/types.ts';
12
12
  import { SessionStore } from './daemon/session-store.ts';
@@ -203,26 +203,3 @@ function start(): void {
203
203
  }
204
204
 
205
205
  start();
206
-
207
- function readVersion(): string {
208
- try {
209
- const root = findProjectRoot();
210
- const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) as {
211
- version?: string;
212
- };
213
- return pkg.version ?? '0.0.0';
214
- } catch {
215
- return '0.0.0';
216
- }
217
- }
218
-
219
- function findProjectRoot(): string {
220
- const start = path.dirname(fileURLToPath(import.meta.url));
221
- let current = start;
222
- for (let i = 0; i < 6; i += 1) {
223
- const pkgPath = path.join(current, 'package.json');
224
- if (fs.existsSync(pkgPath)) return current;
225
- current = path.dirname(current);
226
- }
227
- return start;
228
- }
@@ -0,0 +1,59 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
4
+ import { AppError } from '../../utils/errors.ts';
5
+
6
+ test('classifyBootFailure maps timeout errors', () => {
7
+ const reason = classifyBootFailure({
8
+ message: 'bootstatus timed out after 120s',
9
+ context: { platform: 'ios', phase: 'boot' },
10
+ });
11
+ assert.equal(reason, 'IOS_BOOT_TIMEOUT');
12
+ });
13
+
14
+ test('classifyBootFailure maps adb offline errors', () => {
15
+ const reason = classifyBootFailure({
16
+ stderr: 'error: device offline',
17
+ context: { platform: 'android', phase: 'transport' },
18
+ });
19
+ assert.equal(reason, 'ADB_TRANSPORT_UNAVAILABLE');
20
+ });
21
+
22
+ test('classifyBootFailure maps tool missing from AppError code (android)', () => {
23
+ const reason = classifyBootFailure({
24
+ error: new AppError('TOOL_MISSING', 'adb not found in PATH'),
25
+ context: { platform: 'android', phase: 'transport' },
26
+ });
27
+ assert.equal(reason, 'ADB_TRANSPORT_UNAVAILABLE');
28
+ });
29
+
30
+ test('classifyBootFailure maps tool missing from AppError code (ios)', () => {
31
+ const reason = classifyBootFailure({
32
+ error: new AppError('TOOL_MISSING', 'xcrun not found in PATH'),
33
+ context: { platform: 'ios', phase: 'boot' },
34
+ });
35
+ assert.equal(reason, 'IOS_TOOL_MISSING');
36
+ });
37
+
38
+ test('classifyBootFailure reads stderr from AppError details', () => {
39
+ const reason = classifyBootFailure({
40
+ error: new AppError('COMMAND_FAILED', 'adb failed', {
41
+ stderr: 'error: device unauthorized',
42
+ }),
43
+ context: { platform: 'android', phase: 'transport' },
44
+ });
45
+ assert.equal(reason, 'ADB_TRANSPORT_UNAVAILABLE');
46
+ });
47
+
48
+ test('bootFailureHint returns actionable guidance', () => {
49
+ const hint = bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT');
50
+ assert.equal(hint.includes('xcodebuild logs'), true);
51
+ });
52
+
53
+ test('connect phase does not classify non-timeout errors as connect timeout', () => {
54
+ const reason = classifyBootFailure({
55
+ message: 'Runner returned malformed JSON payload',
56
+ context: { platform: 'ios', phase: 'connect' },
57
+ });
58
+ assert.equal(reason, 'BOOT_COMMAND_FAILED');
59
+ });
@@ -1,5 +1,6 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
+ import { parseAndroidLaunchComponent } from '../index.ts';
3
4
  import { findBounds, parseUiHierarchy } from '../ui-hierarchy.ts';
4
5
 
5
6
  test('parseUiHierarchy reads double-quoted Android node attributes', () => {
@@ -72,3 +73,19 @@ test('findBounds ignores bounds-like fragments inside other attribute values', (
72
73
 
73
74
  assert.deepEqual(findBounds(xml, 'target'), { x: 200, y: 350 });
74
75
  });
76
+
77
+ test('parseAndroidLaunchComponent extracts final resolved component', () => {
78
+ const stdout = [
79
+ 'priority=0 preferredOrder=0 match=0x108000 specificIndex=-1 isDefault=true',
80
+ 'com.boatsgroup.boattrader/com.boatsgroup.boattrader.MainActivity',
81
+ ].join('\n');
82
+ assert.equal(
83
+ parseAndroidLaunchComponent(stdout),
84
+ 'com.boatsgroup.boattrader/com.boatsgroup.boattrader.MainActivity',
85
+ );
86
+ });
87
+
88
+ test('parseAndroidLaunchComponent returns null when no component is present', () => {
89
+ const stdout = 'No activity found';
90
+ assert.equal(parseAndroidLaunchComponent(stdout), null);
91
+ });