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.
- package/dist/bin/axsnapshot +0 -0
- package/dist/src/daemon.js +8 -6
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +2 -2
- package/package.json +2 -2
- package/src/platforms/android/__tests__/index.test.ts +74 -0
- package/src/platforms/android/index.ts +47 -293
- package/src/platforms/android/ui-hierarchy.ts +312 -0
- package/src/platforms/ios/runner-client.ts +115 -55
|
@@ -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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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 =
|
|
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 <
|
|
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',
|