agent-device 0.2.6 → 0.3.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.
@@ -0,0 +1,312 @@
1
+ import type { RawSnapshotNode, Rect, SnapshotOptions } from '../../utils/snapshot.ts';
2
+
3
+ export function findBounds(xml: string, query: string): { x: number; y: number } | null {
4
+ const q = query.toLowerCase();
5
+ const nodeRegex = /<node[^>]+>/g;
6
+ let match = nodeRegex.exec(xml);
7
+ while (match) {
8
+ const node = match[0];
9
+ const attrs = parseXmlNodeAttributes(node);
10
+ const textVal = (readXmlAttr(attrs, 'text') ?? '').toLowerCase();
11
+ const descVal = (readXmlAttr(attrs, 'content-desc') ?? '').toLowerCase();
12
+ if (textVal.includes(q) || descVal.includes(q)) {
13
+ const rect = parseBounds(readXmlAttr(attrs, 'bounds'));
14
+ if (rect) {
15
+ return {
16
+ x: Math.floor(rect.x + rect.width / 2),
17
+ y: Math.floor(rect.y + rect.height / 2),
18
+ };
19
+ }
20
+ return { x: 0, y: 0 };
21
+ }
22
+ match = nodeRegex.exec(xml);
23
+ }
24
+ return null;
25
+ }
26
+
27
+ export function parseUiHierarchy(
28
+ xml: string,
29
+ maxNodes: number,
30
+ options: SnapshotOptions,
31
+ ): { nodes: RawSnapshotNode[]; truncated?: boolean } {
32
+ const tree = parseUiHierarchyTree(xml);
33
+ const nodes: RawSnapshotNode[] = [];
34
+ let truncated = false;
35
+ const maxDepth = options.depth ?? Number.POSITIVE_INFINITY;
36
+ const scopedRoot = options.scope ? findScopeNode(tree, options.scope) : null;
37
+ const roots = scopedRoot ? [scopedRoot] : tree.children;
38
+
39
+ const interactiveDescendantMemo = new Map<AndroidNode, boolean>();
40
+ const hasInteractiveDescendant = (node: AndroidNode): boolean => {
41
+ const cached = interactiveDescendantMemo.get(node);
42
+ if (cached !== undefined) return cached;
43
+ for (const child of node.children) {
44
+ if (child.hittable || hasInteractiveDescendant(child)) {
45
+ interactiveDescendantMemo.set(node, true);
46
+ return true;
47
+ }
48
+ }
49
+ interactiveDescendantMemo.set(node, false);
50
+ return false;
51
+ };
52
+
53
+ const walk = (
54
+ node: AndroidNode,
55
+ depth: number,
56
+ parentIndex?: number,
57
+ ancestorHittable: boolean = false,
58
+ ancestorCollection: boolean = false,
59
+ ) => {
60
+ if (nodes.length >= maxNodes) {
61
+ truncated = true;
62
+ return;
63
+ }
64
+ if (depth > maxDepth) return;
65
+
66
+ const include = options.raw
67
+ ? true
68
+ : shouldIncludeAndroidNode(
69
+ node,
70
+ options,
71
+ ancestorHittable,
72
+ hasInteractiveDescendant(node),
73
+ ancestorCollection,
74
+ );
75
+ let currentIndex = parentIndex;
76
+ if (include) {
77
+ currentIndex = nodes.length;
78
+ nodes.push({
79
+ index: currentIndex,
80
+ type: node.type ?? undefined,
81
+ label: node.label ?? undefined,
82
+ value: node.value ?? undefined,
83
+ identifier: node.identifier ?? undefined,
84
+ rect: node.rect,
85
+ enabled: node.enabled,
86
+ hittable: node.hittable,
87
+ depth,
88
+ parentIndex,
89
+ });
90
+ }
91
+ const nextAncestorHittable = ancestorHittable || Boolean(node.hittable);
92
+ const nextAncestorCollection = ancestorCollection || isCollectionContainerType(node.type);
93
+ for (const child of node.children) {
94
+ walk(child, depth + 1, currentIndex, nextAncestorHittable, nextAncestorCollection);
95
+ if (truncated) return;
96
+ }
97
+ };
98
+
99
+ for (const root of roots) {
100
+ walk(root, 0, undefined, false, false);
101
+ if (truncated) break;
102
+ }
103
+
104
+ return truncated ? { nodes, truncated } : { nodes };
105
+ }
106
+
107
+ export function readNodeAttributes(node: string): {
108
+ text: string | null;
109
+ desc: string | null;
110
+ resourceId: string | null;
111
+ className: string | null;
112
+ bounds: string | null;
113
+ clickable?: boolean;
114
+ enabled?: boolean;
115
+ focusable?: boolean;
116
+ focused?: boolean;
117
+ } {
118
+ const attrs = parseXmlNodeAttributes(node);
119
+ const getAttr = (name: string): string | null => readXmlAttr(attrs, name);
120
+ const boolAttr = (name: string): boolean | undefined => {
121
+ const raw = getAttr(name);
122
+ if (raw === null) return undefined;
123
+ return raw === 'true';
124
+ };
125
+ return {
126
+ text: getAttr('text'),
127
+ desc: getAttr('content-desc'),
128
+ resourceId: getAttr('resource-id'),
129
+ className: getAttr('class'),
130
+ bounds: getAttr('bounds'),
131
+ clickable: boolAttr('clickable'),
132
+ enabled: boolAttr('enabled'),
133
+ focusable: boolAttr('focusable'),
134
+ focused: boolAttr('focused'),
135
+ };
136
+ }
137
+
138
+ function parseXmlNodeAttributes(node: string): Map<string, string> {
139
+ const attrs = new Map<string, string>();
140
+ const start = node.indexOf(' ');
141
+ const end = node.lastIndexOf('>');
142
+ if (start < 0 || end <= start) return attrs;
143
+
144
+ const attrRegex = /([^\s=/>]+)\s*=\s*(["'])([\s\S]*?)\2/y;
145
+ let cursor = start;
146
+ while (cursor < end) {
147
+ while (cursor < end) {
148
+ const char = node[cursor];
149
+ if (char !== ' ' && char !== '\n' && char !== '\r' && char !== '\t') break;
150
+ cursor += 1;
151
+ }
152
+ if (cursor >= end) break;
153
+ const char = node[cursor];
154
+ if (char === '/' || char === '>') break;
155
+
156
+ attrRegex.lastIndex = cursor;
157
+ const match = attrRegex.exec(node);
158
+ if (!match) break;
159
+ attrs.set(match[1], match[3]);
160
+ cursor = attrRegex.lastIndex;
161
+ }
162
+
163
+ return attrs;
164
+ }
165
+
166
+ function readXmlAttr(attrs: Map<string, string>, name: string): string | null {
167
+ return attrs.get(name) ?? null;
168
+ }
169
+
170
+ export function parseBounds(bounds: string | null): Rect | undefined {
171
+ if (!bounds) return undefined;
172
+ const match = /\[(\d+),(\d+)\]\[(\d+),(\d+)\]/.exec(bounds);
173
+ if (!match) return undefined;
174
+ const x1 = Number(match[1]);
175
+ const y1 = Number(match[2]);
176
+ const x2 = Number(match[3]);
177
+ const y2 = Number(match[4]);
178
+ return { x: x1, y: y1, width: Math.max(0, x2 - x1), height: Math.max(0, y2 - y1) };
179
+ }
180
+
181
+ type AndroidNode = {
182
+ type: string | null;
183
+ label: string | null;
184
+ value: string | null;
185
+ identifier: string | null;
186
+ rect?: Rect;
187
+ enabled?: boolean;
188
+ hittable?: boolean;
189
+ depth: number;
190
+ parentIndex?: number;
191
+ children: AndroidNode[];
192
+ };
193
+
194
+ function parseUiHierarchyTree(xml: string): AndroidNode {
195
+ const root: AndroidNode = {
196
+ type: null,
197
+ label: null,
198
+ value: null,
199
+ identifier: null,
200
+ depth: -1,
201
+ children: [],
202
+ };
203
+ const stack: AndroidNode[] = [root];
204
+ const tokenRegex = /<node\b[^>]*>|<\/node>/g;
205
+ let match = tokenRegex.exec(xml);
206
+ while (match) {
207
+ const token = match[0];
208
+ if (token.startsWith('</node')) {
209
+ if (stack.length > 1) stack.pop();
210
+ match = tokenRegex.exec(xml);
211
+ continue;
212
+ }
213
+ const attrs = readNodeAttributes(token);
214
+ const rect = parseBounds(attrs.bounds);
215
+ const parent = stack[stack.length - 1];
216
+ const node: AndroidNode = {
217
+ type: attrs.className,
218
+ label: attrs.text || attrs.desc,
219
+ value: attrs.text,
220
+ identifier: attrs.resourceId,
221
+ rect,
222
+ enabled: attrs.enabled,
223
+ hittable: attrs.clickable ?? attrs.focusable,
224
+ depth: parent.depth + 1,
225
+ parentIndex: undefined,
226
+ children: [],
227
+ };
228
+ parent.children.push(node);
229
+ if (!token.endsWith('/>')) {
230
+ stack.push(node);
231
+ }
232
+ match = tokenRegex.exec(xml);
233
+ }
234
+ return root;
235
+ }
236
+
237
+ function shouldIncludeAndroidNode(
238
+ node: AndroidNode,
239
+ options: SnapshotOptions,
240
+ ancestorHittable: boolean,
241
+ descendantHittable: boolean,
242
+ ancestorCollection: boolean,
243
+ ): boolean {
244
+ const type = normalizeAndroidType(node.type);
245
+ const hasText = Boolean(node.label && node.label.trim().length > 0);
246
+ const hasId = Boolean(node.identifier && node.identifier.trim().length > 0);
247
+ const hasMeaningfulText = hasText && !isGenericAndroidId(node.label ?? '');
248
+ const hasMeaningfulId = hasId && !isGenericAndroidId(node.identifier ?? '');
249
+ const isStructural = isStructuralAndroidType(type);
250
+ const isVisual = type === 'imageview' || type === 'imagebutton';
251
+ if (options.interactiveOnly) {
252
+ if (node.hittable) return true;
253
+ // Keep text proxies for tappable rows while dropping structural noise.
254
+ const proxyCandidate = hasMeaningfulText || hasMeaningfulId;
255
+ if (!proxyCandidate) return false;
256
+ if (isVisual) return false;
257
+ if (isStructural && !ancestorCollection) return false;
258
+ return ancestorHittable || descendantHittable || ancestorCollection;
259
+ }
260
+ if (options.compact) {
261
+ return hasMeaningfulText || hasMeaningfulId || Boolean(node.hittable);
262
+ }
263
+ if (isStructural || isVisual) {
264
+ if (node.hittable) return true;
265
+ if (hasMeaningfulText) return true;
266
+ if (hasMeaningfulId && descendantHittable) return true;
267
+ return descendantHittable;
268
+ }
269
+ return true;
270
+ }
271
+
272
+ function isCollectionContainerType(type: string | null): boolean {
273
+ if (!type) return false;
274
+ const normalized = normalizeAndroidType(type);
275
+ return (
276
+ normalized.includes('recyclerview') ||
277
+ normalized.includes('listview') ||
278
+ normalized.includes('gridview')
279
+ );
280
+ }
281
+
282
+ function normalizeAndroidType(type: string | null): string {
283
+ if (!type) return '';
284
+ return type.toLowerCase();
285
+ }
286
+
287
+ function isStructuralAndroidType(type: string): boolean {
288
+ const short = type.split('.').pop() ?? type;
289
+ return short.includes('layout') || short === 'viewgroup' || short === 'view';
290
+ }
291
+
292
+ function isGenericAndroidId(value: string): boolean {
293
+ const trimmed = value.trim();
294
+ if (!trimmed) return false;
295
+ return /^[\w.]+:id\/[\w.-]+$/i.test(trimmed);
296
+ }
297
+
298
+ function findScopeNode(root: AndroidNode, scope: string): AndroidNode | null {
299
+ const query = scope.toLowerCase();
300
+ const stack: AndroidNode[] = [...root.children];
301
+ while (stack.length > 0) {
302
+ const node = stack.shift() as AndroidNode;
303
+ const label = node.label?.toLowerCase() ?? '';
304
+ const value = node.value?.toLowerCase() ?? '';
305
+ const identifier = node.identifier?.toLowerCase() ?? '';
306
+ if (label.includes(query) || value.includes(query) || identifier.includes(query)) {
307
+ return node;
308
+ }
309
+ stack.push(...node.children);
310
+ }
311
+ return null;
312
+ }
@@ -3,7 +3,7 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { AppError } from '../../utils/errors.ts';
6
- import { runCmd, runCmdStreaming, type ExecResult } from '../../utils/exec.ts';
6
+ import { runCmd, runCmdStreaming, runCmdBackground, type ExecResult, type ExecBackgroundResult } from '../../utils/exec.ts';
7
7
  import { withRetry } from '../../utils/retry.ts';
8
8
  import type { DeviceInfo } from '../../utils/device.ts';
9
9
  import net from 'node:net';
@@ -46,9 +46,30 @@ export type RunnerSession = {
46
46
  xctestrunPath: string;
47
47
  jsonPath: string;
48
48
  testPromise: Promise<ExecResult>;
49
+ child: ExecBackgroundResult['child'];
50
+ ready: boolean;
49
51
  };
50
52
 
51
53
  const runnerSessions = new Map<string, RunnerSession>();
54
+ const RUNNER_STARTUP_TIMEOUT_MS = resolveTimeoutMs(
55
+ process.env.AGENT_DEVICE_RUNNER_STARTUP_TIMEOUT_MS,
56
+ 120_000,
57
+ 5_000,
58
+ );
59
+ const RUNNER_COMMAND_TIMEOUT_MS = resolveTimeoutMs(
60
+ process.env.AGENT_DEVICE_RUNNER_COMMAND_TIMEOUT_MS,
61
+ 15_000,
62
+ 1_000,
63
+ );
64
+ const RUNNER_STOP_WAIT_TIMEOUT_MS = 10_000;
65
+ const RUNNER_SHUTDOWN_TIMEOUT_MS = 15_000;
66
+
67
+ function resolveTimeoutMs(raw: string | undefined, fallback: number, min: number): number {
68
+ if (!raw) return fallback;
69
+ const parsed = Number(raw);
70
+ if (!Number.isFinite(parsed)) return fallback;
71
+ return Math.max(min, Math.floor(parsed));
72
+ }
52
73
 
53
74
  export type RunnerSnapshotNode = {
54
75
  index: number;
@@ -87,29 +108,14 @@ async function executeRunnerCommand(
87
108
 
88
109
  try {
89
110
  const session = await ensureRunnerSession(device, options);
90
- const response = await waitForRunner(device, session.port, command, options.logPath);
91
- const text = await response.text();
92
-
93
- let json: any = {};
94
- try {
95
- json = JSON.parse(text);
96
- } catch {
97
- throw new AppError('COMMAND_FAILED', 'Invalid runner response', { text });
98
- }
99
-
100
- if (!json.ok) {
101
- throw new AppError('COMMAND_FAILED', json.error?.message ?? 'Runner error', {
102
- runner: json,
103
- xcodebuild: {
104
- exitCode: 1,
105
- stdout: '',
106
- stderr: '',
107
- },
108
- logPath: options.logPath,
109
- });
110
- }
111
-
112
- return json.data ?? {};
111
+ const timeoutMs = session.ready ? RUNNER_COMMAND_TIMEOUT_MS : RUNNER_STARTUP_TIMEOUT_MS;
112
+ return await executeRunnerCommandWithSession(
113
+ device,
114
+ session,
115
+ command,
116
+ options.logPath,
117
+ timeoutMs,
118
+ );
113
119
  } catch (err) {
114
120
  const appErr = err instanceof AppError ? err : new AppError('COMMAND_FAILED', String(err));
115
121
  if (
@@ -119,46 +125,79 @@ async function executeRunnerCommand(
119
125
  ) {
120
126
  await stopIosRunnerSession(device.id);
121
127
  const session = await ensureRunnerSession(device, options);
122
- const response = await waitForRunner(device, session.port, command, options.logPath);
123
- const text = await response.text();
124
- let json: any = {};
125
- try {
126
- json = JSON.parse(text);
127
- } catch {
128
- throw new AppError('COMMAND_FAILED', 'Invalid runner response', { text });
129
- }
130
- if (!json.ok) {
131
- throw new AppError('COMMAND_FAILED', json.error?.message ?? 'Runner error', {
132
- runner: json,
133
- xcodebuild: {
134
- exitCode: 1,
135
- stdout: '',
136
- stderr: '',
137
- },
138
- logPath: options.logPath,
139
- });
140
- }
141
- return json.data ?? {};
128
+ const response = await waitForRunner(
129
+ session.device,
130
+ session.port,
131
+ command,
132
+ options.logPath,
133
+ RUNNER_STARTUP_TIMEOUT_MS,
134
+ );
135
+ return await parseRunnerResponse(response, session, options.logPath);
142
136
  }
143
137
  throw err;
144
138
  }
145
139
  }
146
140
 
141
+ async function executeRunnerCommandWithSession(
142
+ device: DeviceInfo,
143
+ session: RunnerSession,
144
+ command: RunnerCommand,
145
+ logPath: string | undefined,
146
+ timeoutMs: number,
147
+ ): Promise<Record<string, unknown>> {
148
+ const response = await waitForRunner(device, session.port, command, logPath, timeoutMs);
149
+ return await parseRunnerResponse(response, session, logPath);
150
+ }
151
+
152
+ async function parseRunnerResponse(
153
+ response: Response,
154
+ session: RunnerSession,
155
+ logPath?: string,
156
+ ): Promise<Record<string, unknown>> {
157
+ const text = await response.text();
158
+ let json: any = {};
159
+ try {
160
+ json = JSON.parse(text);
161
+ } catch {
162
+ throw new AppError('COMMAND_FAILED', 'Invalid runner response', { text });
163
+ }
164
+ if (!json.ok) {
165
+ throw new AppError('COMMAND_FAILED', json.error?.message ?? 'Runner error', {
166
+ runner: json,
167
+ xcodebuild: {
168
+ exitCode: 1,
169
+ stdout: '',
170
+ stderr: '',
171
+ },
172
+ logPath,
173
+ });
174
+ }
175
+ session.ready = true;
176
+ return json.data ?? {};
177
+ }
178
+
147
179
  export async function stopIosRunnerSession(deviceId: string): Promise<void> {
148
180
  const session = runnerSessions.get(deviceId);
149
181
  if (!session) return;
150
182
  try {
151
183
  await waitForRunner(session.device, session.port, {
152
184
  command: 'shutdown',
153
- } as RunnerCommand);
185
+ } as RunnerCommand, undefined, RUNNER_SHUTDOWN_TIMEOUT_MS);
154
186
  } catch {
155
- // ignore
187
+ // Runner not responsive — send SIGTERM so we don't hang on testPromise
188
+ await killRunnerProcessTree(session.child.pid, 'SIGTERM');
156
189
  }
157
190
  try {
158
- await session.testPromise;
191
+ // Bound the wait so we never hang if xcodebuild refuses to exit
192
+ await Promise.race([
193
+ session.testPromise,
194
+ new Promise<void>((resolve) => setTimeout(resolve, RUNNER_STOP_WAIT_TIMEOUT_MS)),
195
+ ]);
159
196
  } catch {
160
197
  // ignore
161
198
  }
199
+ // Force-kill if still alive (harmless if already exited)
200
+ await killRunnerProcessTree(session.child.pid, 'SIGKILL');
162
201
  cleanupTempFile(session.xctestrunPath);
163
202
  cleanupTempFile(session.jsonPath);
164
203
  runnerSessions.delete(deviceId);
@@ -183,7 +222,7 @@ async function ensureRunnerSession(
183
222
  { AGENT_DEVICE_RUNNER_PORT: String(port) },
184
223
  `session-${device.id}-${port}`,
185
224
  );
186
- const testPromise = runCmdStreaming(
225
+ const { child, wait: testPromise } = runCmdBackground(
187
226
  'xcodebuild',
188
227
  [
189
228
  'test-without-building',
@@ -201,16 +240,16 @@ async function ensureRunnerSession(
201
240
  `platform=iOS Simulator,id=${device.id}`,
202
241
  ],
203
242
  {
204
- onStdoutChunk: (chunk) => {
205
- logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
206
- },
207
- onStderrChunk: (chunk) => {
208
- logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
209
- },
210
243
  allowFailure: true,
211
244
  env: { ...process.env, AGENT_DEVICE_RUNNER_PORT: String(port) },
212
245
  },
213
246
  );
247
+ child.stdout?.on('data', (chunk: string) => {
248
+ logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
249
+ });
250
+ child.stderr?.on('data', (chunk: string) => {
251
+ logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
252
+ });
214
253
 
215
254
  const session: RunnerSession = {
216
255
  device,
@@ -219,11 +258,31 @@ async function ensureRunnerSession(
219
258
  xctestrunPath,
220
259
  jsonPath,
221
260
  testPromise,
261
+ child,
262
+ ready: false,
222
263
  };
223
264
  runnerSessions.set(device.id, session);
224
265
  return session;
225
266
  }
226
267
 
268
+ async function killRunnerProcessTree(
269
+ pid: number | undefined,
270
+ signal: 'SIGTERM' | 'SIGKILL',
271
+ ): Promise<void> {
272
+ if (!pid || pid <= 0) return;
273
+ try {
274
+ process.kill(pid, signal);
275
+ } catch {
276
+ // ignore
277
+ }
278
+ const pkillSignal = signal === 'SIGTERM' ? 'TERM' : 'KILL';
279
+ try {
280
+ await runCmd('pkill', [`-${pkillSignal}`, '-P', String(pid)], { allowFailure: true });
281
+ } catch {
282
+ // ignore
283
+ }
284
+ }
285
+
227
286
 
228
287
  async function ensureXctestrun(
229
288
  udid: string,
@@ -364,10 +423,11 @@ async function waitForRunner(
364
423
  port: number,
365
424
  command: RunnerCommand,
366
425
  logPath?: string,
426
+ timeoutMs: number = RUNNER_STARTUP_TIMEOUT_MS,
367
427
  ): Promise<Response> {
368
428
  const start = Date.now();
369
429
  let lastError: unknown = null;
370
- while (Date.now() - start < 15000) {
430
+ while (Date.now() - start < timeoutMs) {
371
431
  try {
372
432
  const response = await fetch(`http://127.0.0.1:${port}/command`, {
373
433
  method: 'POST',