agent-device 0.1.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/LICENSE +21 -0
- package/README.md +99 -0
- package/bin/agent-device.mjs +14 -0
- package/bin/axsnapshot +0 -0
- package/dist/src/861.js +1 -0
- package/dist/src/bin.js +50 -0
- package/dist/src/daemon.js +5 -0
- package/ios-runner/AXSnapshot/Package.swift +18 -0
- package/ios-runner/AXSnapshot/Sources/AXSnapshot/main.swift +167 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.swift +17 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/AccentColor.colorset/Contents.json +11 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/AppIcon.appiconset/Contents.json +36 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/AppIcon.appiconset/logo.jpg +0 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/Contents.json +6 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/Logo.imageset/Contents.json +21 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/Logo.imageset/logo.jpg +0 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/PoweredBy.imageset/Contents.json +21 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/PoweredBy.imageset/powered-by.png +0 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunner/ContentView.swift +34 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj +461 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/xcshareddata/xcschemes/AgentDeviceRunner.xcscheme +102 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +696 -0
- package/ios-runner/README.md +11 -0
- package/package.json +66 -0
- package/src/bin.ts +3 -0
- package/src/cli.ts +160 -0
- package/src/core/dispatch.ts +259 -0
- package/src/daemon-client.ts +166 -0
- package/src/daemon.ts +842 -0
- package/src/platforms/android/devices.ts +59 -0
- package/src/platforms/android/index.ts +442 -0
- package/src/platforms/ios/ax-snapshot.ts +154 -0
- package/src/platforms/ios/devices.ts +65 -0
- package/src/platforms/ios/index.ts +218 -0
- package/src/platforms/ios/runner-client.ts +534 -0
- package/src/utils/args.ts +175 -0
- package/src/utils/device.ts +84 -0
- package/src/utils/errors.ts +35 -0
- package/src/utils/exec.ts +229 -0
- package/src/utils/interactive.ts +4 -0
- package/src/utils/interactors.ts +72 -0
- package/src/utils/output.ts +146 -0
- package/src/utils/snapshot.ts +63 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { runCmd, whichCmd } from '../../utils/exec.ts';
|
|
2
|
+
import { AppError } from '../../utils/errors.ts';
|
|
3
|
+
import type { DeviceInfo } from '../../utils/device.ts';
|
|
4
|
+
|
|
5
|
+
export async function listAndroidDevices(): Promise<DeviceInfo[]> {
|
|
6
|
+
const adbAvailable = await whichCmd('adb');
|
|
7
|
+
if (!adbAvailable) {
|
|
8
|
+
throw new AppError('TOOL_MISSING', 'adb not found in PATH');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const result = await runCmd('adb', ['devices', '-l']);
|
|
12
|
+
const lines = result.stdout.split('\n').map((l: string) => l.trim());
|
|
13
|
+
const devices: DeviceInfo[] = [];
|
|
14
|
+
|
|
15
|
+
for (const line of lines) {
|
|
16
|
+
if (!line || line.startsWith('List of devices')) continue;
|
|
17
|
+
const parts = line.split(/\s+/);
|
|
18
|
+
const serial = parts[0];
|
|
19
|
+
const state = parts[1];
|
|
20
|
+
if (state !== 'device') continue;
|
|
21
|
+
|
|
22
|
+
const modelPart = parts.find((p: string) => p.startsWith('model:')) ?? '';
|
|
23
|
+
const rawModel = modelPart.replace('model:', '').replace(/_/g, ' ').trim();
|
|
24
|
+
let name = rawModel || serial;
|
|
25
|
+
|
|
26
|
+
if (serial.startsWith('emulator-')) {
|
|
27
|
+
const avd = await runCmd('adb', ['-s', serial, 'emu', 'avd', 'name'], {
|
|
28
|
+
allowFailure: true,
|
|
29
|
+
});
|
|
30
|
+
const avdName = (avd.stdout as string).trim();
|
|
31
|
+
if (avd.exitCode === 0 && avdName) {
|
|
32
|
+
name = avdName.replace(/_/g, ' ');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const booted = await isAndroidBooted(serial);
|
|
37
|
+
|
|
38
|
+
devices.push({
|
|
39
|
+
platform: 'android',
|
|
40
|
+
id: serial,
|
|
41
|
+
name,
|
|
42
|
+
kind: serial.startsWith('emulator-') ? 'emulator' : 'device',
|
|
43
|
+
booted,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return devices;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function isAndroidBooted(serial: string): Promise<boolean> {
|
|
51
|
+
try {
|
|
52
|
+
const result = await runCmd('adb', ['-s', serial, 'shell', 'getprop', 'sys.boot_completed'], {
|
|
53
|
+
allowFailure: true,
|
|
54
|
+
});
|
|
55
|
+
return (result.stdout as string).trim() === '1';
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { runCmd, whichCmd } from '../../utils/exec.ts';
|
|
3
|
+
import { AppError } from '../../utils/errors.ts';
|
|
4
|
+
import type { DeviceInfo } from '../../utils/device.ts';
|
|
5
|
+
import type { RawSnapshotNode, Rect, SnapshotOptions } from '../../utils/snapshot.ts';
|
|
6
|
+
|
|
7
|
+
const ALIASES: Record<string, { type: 'intent' | 'package'; value: string }> = {
|
|
8
|
+
settings: { type: 'intent', value: 'android.settings.SETTINGS' },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function adbArgs(device: DeviceInfo, args: string[]): string[] {
|
|
12
|
+
return ['-s', device.id, ...args];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function resolveAndroidApp(
|
|
16
|
+
device: DeviceInfo,
|
|
17
|
+
app: string,
|
|
18
|
+
): Promise<{ type: 'intent' | 'package'; value: string }> {
|
|
19
|
+
const trimmed = app.trim();
|
|
20
|
+
if (trimmed.includes('.')) return { type: 'package', value: trimmed };
|
|
21
|
+
|
|
22
|
+
const alias = ALIASES[trimmed.toLowerCase()];
|
|
23
|
+
if (alias) return alias;
|
|
24
|
+
|
|
25
|
+
const result = await runCmd('adb', adbArgs(device, ['shell', 'pm', 'list', 'packages']));
|
|
26
|
+
const packages = result.stdout
|
|
27
|
+
.split('\n')
|
|
28
|
+
.map((line: string) => line.replace('package:', '').trim())
|
|
29
|
+
.filter(Boolean);
|
|
30
|
+
|
|
31
|
+
const matches = packages.filter((pkg: string) =>
|
|
32
|
+
pkg.toLowerCase().includes(trimmed.toLowerCase()),
|
|
33
|
+
);
|
|
34
|
+
if (matches.length === 1) {
|
|
35
|
+
return { type: 'package', value: matches[0] };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (matches.length > 1) {
|
|
39
|
+
throw new AppError('INVALID_ARGS', `Multiple packages matched "${app}"`, { matches });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
throw new AppError('APP_NOT_INSTALLED', `No package found matching "${app}"`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function openAndroidApp(device: DeviceInfo, app: string): Promise<void> {
|
|
46
|
+
const resolved = await resolveAndroidApp(device, app);
|
|
47
|
+
if (resolved.type === 'intent') {
|
|
48
|
+
await runCmd('adb', adbArgs(device, ['shell', 'am', 'start', '-a', resolved.value]));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
await runCmd(
|
|
52
|
+
'adb',
|
|
53
|
+
adbArgs(device, [
|
|
54
|
+
'shell',
|
|
55
|
+
'monkey',
|
|
56
|
+
'-p',
|
|
57
|
+
resolved.value,
|
|
58
|
+
'-c',
|
|
59
|
+
'android.intent.category.LAUNCHER',
|
|
60
|
+
'1',
|
|
61
|
+
]),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function closeAndroidApp(device: DeviceInfo, app: string): Promise<void> {
|
|
66
|
+
const trimmed = app.trim();
|
|
67
|
+
if (trimmed.toLowerCase() === 'settings') {
|
|
68
|
+
await runCmd('adb', adbArgs(device, ['shell', 'am', 'force-stop', 'com.android.settings']));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const resolved = await resolveAndroidApp(device, app);
|
|
72
|
+
if (resolved.type === 'intent') {
|
|
73
|
+
throw new AppError('INVALID_ARGS', 'Close requires a package name, not an intent');
|
|
74
|
+
}
|
|
75
|
+
await runCmd('adb', adbArgs(device, ['shell', 'am', 'force-stop', resolved.value]));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function pressAndroid(device: DeviceInfo, x: number, y: number): Promise<void> {
|
|
79
|
+
await runCmd('adb', adbArgs(device, ['shell', 'input', 'tap', String(x), String(y)]));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function longPressAndroid(
|
|
83
|
+
device: DeviceInfo,
|
|
84
|
+
x: number,
|
|
85
|
+
y: number,
|
|
86
|
+
durationMs = 800,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
await runCmd(
|
|
89
|
+
'adb',
|
|
90
|
+
adbArgs(device, [
|
|
91
|
+
'shell',
|
|
92
|
+
'input',
|
|
93
|
+
'swipe',
|
|
94
|
+
String(x),
|
|
95
|
+
String(y),
|
|
96
|
+
String(x),
|
|
97
|
+
String(y),
|
|
98
|
+
String(durationMs),
|
|
99
|
+
]),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function typeAndroid(device: DeviceInfo, text: string): Promise<void> {
|
|
104
|
+
const encoded = text.replace(/ /g, '%s');
|
|
105
|
+
await runCmd('adb', adbArgs(device, ['shell', 'input', 'text', encoded]));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function focusAndroid(device: DeviceInfo, x: number, y: number): Promise<void> {
|
|
109
|
+
await pressAndroid(device, x, y);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function fillAndroid(
|
|
113
|
+
device: DeviceInfo,
|
|
114
|
+
x: number,
|
|
115
|
+
y: number,
|
|
116
|
+
text: string,
|
|
117
|
+
): Promise<void> {
|
|
118
|
+
await focusAndroid(device, x, y);
|
|
119
|
+
await typeAndroid(device, text);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function scrollAndroid(
|
|
123
|
+
device: DeviceInfo,
|
|
124
|
+
direction: string,
|
|
125
|
+
amount = 0.6,
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
const size = await getAndroidScreenSize(device);
|
|
128
|
+
const { width, height } = size;
|
|
129
|
+
const distanceX = Math.floor(width * amount);
|
|
130
|
+
const distanceY = Math.floor(height * amount);
|
|
131
|
+
|
|
132
|
+
const centerX = Math.floor(width / 2);
|
|
133
|
+
const centerY = Math.floor(height / 2);
|
|
134
|
+
|
|
135
|
+
let x1 = centerX;
|
|
136
|
+
let y1 = centerY;
|
|
137
|
+
let x2 = centerX;
|
|
138
|
+
let y2 = centerY;
|
|
139
|
+
|
|
140
|
+
switch (direction) {
|
|
141
|
+
case 'up':
|
|
142
|
+
// Content moves up -> swipe down.
|
|
143
|
+
y1 = centerY - Math.floor(distanceY / 2);
|
|
144
|
+
y2 = centerY + Math.floor(distanceY / 2);
|
|
145
|
+
break;
|
|
146
|
+
case 'down':
|
|
147
|
+
// Content moves down -> swipe up.
|
|
148
|
+
y1 = centerY + Math.floor(distanceY / 2);
|
|
149
|
+
y2 = centerY - Math.floor(distanceY / 2);
|
|
150
|
+
break;
|
|
151
|
+
case 'left':
|
|
152
|
+
// Content moves left -> swipe right.
|
|
153
|
+
x1 = centerX - Math.floor(distanceX / 2);
|
|
154
|
+
x2 = centerX + Math.floor(distanceX / 2);
|
|
155
|
+
break;
|
|
156
|
+
case 'right':
|
|
157
|
+
// Content moves right -> swipe left.
|
|
158
|
+
x1 = centerX + Math.floor(distanceX / 2);
|
|
159
|
+
x2 = centerX - Math.floor(distanceX / 2);
|
|
160
|
+
break;
|
|
161
|
+
default:
|
|
162
|
+
throw new AppError('INVALID_ARGS', `Unknown direction: ${direction}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await runCmd(
|
|
166
|
+
'adb',
|
|
167
|
+
adbArgs(device, [
|
|
168
|
+
'shell',
|
|
169
|
+
'input',
|
|
170
|
+
'swipe',
|
|
171
|
+
String(x1),
|
|
172
|
+
String(y1),
|
|
173
|
+
String(x2),
|
|
174
|
+
String(y2),
|
|
175
|
+
'300',
|
|
176
|
+
]),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function scrollIntoViewAndroid(device: DeviceInfo, text: string): Promise<void> {
|
|
181
|
+
const maxAttempts = 8;
|
|
182
|
+
for (let i = 0; i < maxAttempts; i += 1) {
|
|
183
|
+
let xml = '';
|
|
184
|
+
try {
|
|
185
|
+
xml = await dumpUiHierarchy(device);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
188
|
+
throw new AppError('UNSUPPORTED_OPERATION', `uiautomator dump failed: ${message}`);
|
|
189
|
+
}
|
|
190
|
+
if (findBounds(xml, text)) return;
|
|
191
|
+
await scrollAndroid(device, 'down', 0.5);
|
|
192
|
+
}
|
|
193
|
+
throw new AppError(
|
|
194
|
+
'COMMAND_FAILED',
|
|
195
|
+
`Could not find element containing "${text}" after scrolling`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function screenshotAndroid(device: DeviceInfo, outPath: string): Promise<void> {
|
|
200
|
+
const result = await runCmd('adb', adbArgs(device, ['exec-out', 'screencap', '-p']), {
|
|
201
|
+
binaryStdout: true,
|
|
202
|
+
});
|
|
203
|
+
if (!result.stdoutBuffer) {
|
|
204
|
+
throw new AppError('COMMAND_FAILED', 'Failed to capture screenshot');
|
|
205
|
+
}
|
|
206
|
+
await fs.writeFile(outPath, result.stdoutBuffer);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function snapshotAndroid(
|
|
210
|
+
device: DeviceInfo,
|
|
211
|
+
options: SnapshotOptions = {},
|
|
212
|
+
): Promise<{
|
|
213
|
+
nodes: RawSnapshotNode[];
|
|
214
|
+
truncated?: boolean;
|
|
215
|
+
}> {
|
|
216
|
+
const xml = await dumpUiHierarchy(device);
|
|
217
|
+
return parseUiHierarchy(xml, 800, options);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function ensureAdb(): Promise<void> {
|
|
221
|
+
const adbAvailable = await whichCmd('adb');
|
|
222
|
+
if (!adbAvailable) throw new AppError('TOOL_MISSING', 'adb not found in PATH');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function getAndroidScreenSize(
|
|
226
|
+
device: DeviceInfo,
|
|
227
|
+
): Promise<{ width: number; height: number }> {
|
|
228
|
+
const result = await runCmd('adb', adbArgs(device, ['shell', 'wm', 'size']));
|
|
229
|
+
const match = result.stdout.match(/Physical size:\s*(\d+)x(\d+)/);
|
|
230
|
+
if (!match) throw new AppError('COMMAND_FAILED', 'Unable to read screen size');
|
|
231
|
+
return { width: Number(match[1]), height: Number(match[2]) };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function dumpUiHierarchy(device: DeviceInfo): Promise<string> {
|
|
235
|
+
await runCmd(
|
|
236
|
+
'adb',
|
|
237
|
+
adbArgs(device, ['shell', 'uiautomator', 'dump', '/sdcard/window_dump.xml']),
|
|
238
|
+
);
|
|
239
|
+
const result = await runCmd('adb', adbArgs(device, ['shell', 'cat', '/sdcard/window_dump.xml']));
|
|
240
|
+
return result.stdout;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function findBounds(xml: string, query: string): { x: number; y: number } | null {
|
|
244
|
+
const q = query.toLowerCase();
|
|
245
|
+
const nodeRegex = /<node[^>]+>/g;
|
|
246
|
+
let match = nodeRegex.exec(xml);
|
|
247
|
+
while (match) {
|
|
248
|
+
const node = match[0];
|
|
249
|
+
const textMatch = /text="([^"]*)"/.exec(node);
|
|
250
|
+
const descMatch = /content-desc="([^"]*)"/.exec(node);
|
|
251
|
+
const textVal = (textMatch?.[1] ?? '').toLowerCase();
|
|
252
|
+
const descVal = (descMatch?.[1] ?? '').toLowerCase();
|
|
253
|
+
if (textVal.includes(q) || descVal.includes(q)) {
|
|
254
|
+
const boundsMatch = /bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/.exec(node);
|
|
255
|
+
if (boundsMatch) {
|
|
256
|
+
const x1 = Number(boundsMatch[1]);
|
|
257
|
+
const y1 = Number(boundsMatch[2]);
|
|
258
|
+
const x2 = Number(boundsMatch[3]);
|
|
259
|
+
const y2 = Number(boundsMatch[4]);
|
|
260
|
+
return { x: Math.floor((x1 + x2) / 2), y: Math.floor((y1 + y2) / 2) };
|
|
261
|
+
}
|
|
262
|
+
return { x: 0, y: 0 };
|
|
263
|
+
}
|
|
264
|
+
match = nodeRegex.exec(xml);
|
|
265
|
+
}
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function parseUiHierarchy(
|
|
270
|
+
xml: string,
|
|
271
|
+
maxNodes: number,
|
|
272
|
+
options: SnapshotOptions,
|
|
273
|
+
): { nodes: RawSnapshotNode[]; truncated?: boolean } {
|
|
274
|
+
const tree = parseUiHierarchyTree(xml);
|
|
275
|
+
const nodes: RawSnapshotNode[] = [];
|
|
276
|
+
let truncated = false;
|
|
277
|
+
const maxDepth = options.depth ?? Number.POSITIVE_INFINITY;
|
|
278
|
+
const scopedRoot = options.scope ? findScopeNode(tree, options.scope) : null;
|
|
279
|
+
const roots = scopedRoot ? [scopedRoot] : tree.children;
|
|
280
|
+
|
|
281
|
+
const walk = (node: AndroidNode, depth: number) => {
|
|
282
|
+
if (nodes.length >= maxNodes) {
|
|
283
|
+
truncated = true;
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (depth > maxDepth) return;
|
|
287
|
+
|
|
288
|
+
const include = options.raw ? true : shouldIncludeAndroidNode(node, options);
|
|
289
|
+
if (include) {
|
|
290
|
+
nodes.push({
|
|
291
|
+
index: nodes.length,
|
|
292
|
+
type: node.type ?? undefined,
|
|
293
|
+
label: node.label ?? undefined,
|
|
294
|
+
value: node.value ?? undefined,
|
|
295
|
+
identifier: node.identifier ?? undefined,
|
|
296
|
+
rect: node.rect,
|
|
297
|
+
enabled: node.enabled,
|
|
298
|
+
hittable: node.hittable,
|
|
299
|
+
depth,
|
|
300
|
+
parentIndex: node.parentIndex,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
for (const child of node.children) {
|
|
304
|
+
walk(child, depth + 1);
|
|
305
|
+
if (truncated) return;
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
for (const root of roots) {
|
|
310
|
+
walk(root, 0);
|
|
311
|
+
if (truncated) break;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return truncated ? { nodes, truncated } : { nodes };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function readNodeAttributes(node: string): {
|
|
318
|
+
text: string | null;
|
|
319
|
+
desc: string | null;
|
|
320
|
+
resourceId: string | null;
|
|
321
|
+
className: string | null;
|
|
322
|
+
bounds: string | null;
|
|
323
|
+
clickable?: boolean;
|
|
324
|
+
enabled?: boolean;
|
|
325
|
+
focusable?: boolean;
|
|
326
|
+
} {
|
|
327
|
+
const getAttr = (name: string): string | null => {
|
|
328
|
+
const regex = new RegExp(`${name}="([^"]*)"`);
|
|
329
|
+
const match = regex.exec(node);
|
|
330
|
+
return match ? match[1] : null;
|
|
331
|
+
};
|
|
332
|
+
const boolAttr = (name: string): boolean | undefined => {
|
|
333
|
+
const raw = getAttr(name);
|
|
334
|
+
if (raw === null) return undefined;
|
|
335
|
+
return raw === 'true';
|
|
336
|
+
};
|
|
337
|
+
return {
|
|
338
|
+
text: getAttr('text'),
|
|
339
|
+
desc: getAttr('content-desc'),
|
|
340
|
+
resourceId: getAttr('resource-id'),
|
|
341
|
+
className: getAttr('class'),
|
|
342
|
+
bounds: getAttr('bounds'),
|
|
343
|
+
clickable: boolAttr('clickable'),
|
|
344
|
+
enabled: boolAttr('enabled'),
|
|
345
|
+
focusable: boolAttr('focusable'),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function parseBounds(bounds: string | null): Rect | undefined {
|
|
350
|
+
if (!bounds) return undefined;
|
|
351
|
+
const match = /\[(\d+),(\d+)\]\[(\d+),(\d+)\]/.exec(bounds);
|
|
352
|
+
if (!match) return undefined;
|
|
353
|
+
const x1 = Number(match[1]);
|
|
354
|
+
const y1 = Number(match[2]);
|
|
355
|
+
const x2 = Number(match[3]);
|
|
356
|
+
const y2 = Number(match[4]);
|
|
357
|
+
return { x: x1, y: y1, width: Math.max(0, x2 - x1), height: Math.max(0, y2 - y1) };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
type AndroidNode = {
|
|
361
|
+
type: string | null;
|
|
362
|
+
label: string | null;
|
|
363
|
+
value: string | null;
|
|
364
|
+
identifier: string | null;
|
|
365
|
+
rect?: Rect;
|
|
366
|
+
enabled?: boolean;
|
|
367
|
+
hittable?: boolean;
|
|
368
|
+
depth: number;
|
|
369
|
+
parentIndex?: number;
|
|
370
|
+
children: AndroidNode[];
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
function parseUiHierarchyTree(xml: string): AndroidNode {
|
|
374
|
+
const root: AndroidNode = {
|
|
375
|
+
type: null,
|
|
376
|
+
label: null,
|
|
377
|
+
value: null,
|
|
378
|
+
identifier: null,
|
|
379
|
+
depth: -1,
|
|
380
|
+
children: [],
|
|
381
|
+
};
|
|
382
|
+
const stack: AndroidNode[] = [root];
|
|
383
|
+
const tokenRegex = /<node\b[^>]*>|<\/node>/g;
|
|
384
|
+
let match = tokenRegex.exec(xml);
|
|
385
|
+
while (match) {
|
|
386
|
+
const token = match[0];
|
|
387
|
+
if (token.startsWith('</node')) {
|
|
388
|
+
if (stack.length > 1) stack.pop();
|
|
389
|
+
match = tokenRegex.exec(xml);
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
const attrs = readNodeAttributes(token);
|
|
393
|
+
const rect = parseBounds(attrs.bounds);
|
|
394
|
+
const parent = stack[stack.length - 1];
|
|
395
|
+
const node: AndroidNode = {
|
|
396
|
+
type: attrs.className,
|
|
397
|
+
label: attrs.text || attrs.desc,
|
|
398
|
+
value: attrs.text,
|
|
399
|
+
identifier: attrs.resourceId,
|
|
400
|
+
rect,
|
|
401
|
+
enabled: attrs.enabled,
|
|
402
|
+
hittable: attrs.clickable ?? attrs.focusable,
|
|
403
|
+
depth: parent.depth + 1,
|
|
404
|
+
parentIndex: undefined,
|
|
405
|
+
children: [],
|
|
406
|
+
};
|
|
407
|
+
parent.children.push(node);
|
|
408
|
+
if (!token.endsWith('/>')) {
|
|
409
|
+
stack.push(node);
|
|
410
|
+
}
|
|
411
|
+
match = tokenRegex.exec(xml);
|
|
412
|
+
}
|
|
413
|
+
return root;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function shouldIncludeAndroidNode(node: AndroidNode, options: SnapshotOptions): boolean {
|
|
417
|
+
if (options.interactiveOnly) {
|
|
418
|
+
return Boolean(node.hittable);
|
|
419
|
+
}
|
|
420
|
+
if (options.compact) {
|
|
421
|
+
const hasText = Boolean(node.label && node.label.trim().length > 0);
|
|
422
|
+
const hasId = Boolean(node.identifier && node.identifier.trim().length > 0);
|
|
423
|
+
return hasText || hasId || Boolean(node.hittable);
|
|
424
|
+
}
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function findScopeNode(root: AndroidNode, scope: string): AndroidNode | null {
|
|
429
|
+
const query = scope.toLowerCase();
|
|
430
|
+
const stack: AndroidNode[] = [...root.children];
|
|
431
|
+
while (stack.length > 0) {
|
|
432
|
+
const node = stack.shift() as AndroidNode;
|
|
433
|
+
const label = node.label?.toLowerCase() ?? '';
|
|
434
|
+
const value = node.value?.toLowerCase() ?? '';
|
|
435
|
+
const identifier = node.identifier?.toLowerCase() ?? '';
|
|
436
|
+
if (label.includes(query) || value.includes(query) || identifier.includes(query)) {
|
|
437
|
+
return node;
|
|
438
|
+
}
|
|
439
|
+
stack.push(...node.children);
|
|
440
|
+
}
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { AppError } from '../../utils/errors.ts';
|
|
4
|
+
import { runCmd } from '../../utils/exec.ts';
|
|
5
|
+
import type { DeviceInfo } from '../../utils/device.ts';
|
|
6
|
+
import type { RawSnapshotNode } from '../../utils/snapshot.ts';
|
|
7
|
+
|
|
8
|
+
type AXFrame = { x: number; y: number; width: number; height: number };
|
|
9
|
+
type AXNode = {
|
|
10
|
+
role?: string;
|
|
11
|
+
subrole?: string;
|
|
12
|
+
label?: string;
|
|
13
|
+
value?: string;
|
|
14
|
+
identifier?: string;
|
|
15
|
+
frame?: AXFrame;
|
|
16
|
+
children?: AXNode[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export async function snapshotAx(
|
|
20
|
+
device: DeviceInfo,
|
|
21
|
+
): Promise<{ nodes: RawSnapshotNode[]; rootRect?: AXFrame }> {
|
|
22
|
+
if (device.platform !== 'ios' || device.kind !== 'simulator') {
|
|
23
|
+
throw new AppError('UNSUPPORTED_OPERATION', 'AX snapshot is only supported on iOS simulators');
|
|
24
|
+
}
|
|
25
|
+
const binary = await ensureAxSnapshotBinary();
|
|
26
|
+
const result = await runCmd(binary, [], { allowFailure: true });
|
|
27
|
+
if (result.exitCode !== 0) {
|
|
28
|
+
throw new AppError('COMMAND_FAILED', 'AX snapshot failed', {
|
|
29
|
+
stderr: result.stderr,
|
|
30
|
+
stdout: result.stdout,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
let tree: AXNode;
|
|
34
|
+
let originFrame: AXFrame | undefined;
|
|
35
|
+
try {
|
|
36
|
+
const payload = JSON.parse(result.stdout) as
|
|
37
|
+
| AXNode
|
|
38
|
+
| { root?: AXNode; windowFrame?: AXFrame | null };
|
|
39
|
+
if (payload && typeof payload === 'object' && 'root' in payload) {
|
|
40
|
+
const snapshot = payload as { root?: AXNode; windowFrame?: AXFrame | null };
|
|
41
|
+
if (!snapshot.root) throw new Error('AX snapshot missing root');
|
|
42
|
+
tree = snapshot.root;
|
|
43
|
+
originFrame = snapshot.windowFrame ?? undefined;
|
|
44
|
+
} else {
|
|
45
|
+
tree = payload as AXNode;
|
|
46
|
+
}
|
|
47
|
+
} catch (err) {
|
|
48
|
+
throw new AppError('COMMAND_FAILED', 'Invalid AX snapshot JSON', { error: String(err) });
|
|
49
|
+
}
|
|
50
|
+
const rootFrame = tree.frame ?? originFrame;
|
|
51
|
+
const frameSamples: AXFrame[] = [];
|
|
52
|
+
const nodes: Array<AXNode & { depth: number }> = [];
|
|
53
|
+
const walk = (node: AXNode, depth: number) => {
|
|
54
|
+
if (node.frame) frameSamples.push(node.frame);
|
|
55
|
+
const frame = node.frame && rootFrame
|
|
56
|
+
? {
|
|
57
|
+
x: node.frame.x - rootFrame.x,
|
|
58
|
+
y: node.frame.y - rootFrame.y,
|
|
59
|
+
width: node.frame.width,
|
|
60
|
+
height: node.frame.height,
|
|
61
|
+
}
|
|
62
|
+
: node.frame;
|
|
63
|
+
nodes.push({ ...node, frame, children: undefined, depth });
|
|
64
|
+
for (const child of node.children ?? []) {
|
|
65
|
+
walk(child, depth + 1);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
walk(tree, 0);
|
|
69
|
+
const normalized = normalizeFrames(nodes, rootFrame, frameSamples);
|
|
70
|
+
const mapped = normalized.map((node, index) => ({
|
|
71
|
+
index,
|
|
72
|
+
type: node.subrole ?? node.role,
|
|
73
|
+
label: node.label,
|
|
74
|
+
value: node.value,
|
|
75
|
+
identifier: node.identifier,
|
|
76
|
+
rect: node.frame
|
|
77
|
+
? {
|
|
78
|
+
x: node.frame.x,
|
|
79
|
+
y: node.frame.y,
|
|
80
|
+
width: node.frame.width,
|
|
81
|
+
height: node.frame.height,
|
|
82
|
+
}
|
|
83
|
+
: undefined,
|
|
84
|
+
depth: node.depth,
|
|
85
|
+
}));
|
|
86
|
+
return { nodes: mapped, rootRect: rootFrame };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function normalizeFrames(
|
|
90
|
+
nodes: Array<AXNode & { depth: number }>,
|
|
91
|
+
originFrame: AXFrame | undefined,
|
|
92
|
+
frames: AXFrame[],
|
|
93
|
+
): Array<AXNode & { depth: number }> {
|
|
94
|
+
if (!originFrame || frames.length === 0) return nodes;
|
|
95
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
96
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
97
|
+
for (const frame of frames) {
|
|
98
|
+
if (frame.x < minX) minX = frame.x;
|
|
99
|
+
if (frame.y < minY) minY = frame.y;
|
|
100
|
+
}
|
|
101
|
+
const nearZero = minX <= 5 && minY <= 5;
|
|
102
|
+
if (nearZero) {
|
|
103
|
+
return nodes.map((node) => ({
|
|
104
|
+
...node,
|
|
105
|
+
frame: node.frame
|
|
106
|
+
? {
|
|
107
|
+
x: node.frame.x + originFrame.x,
|
|
108
|
+
y: node.frame.y + originFrame.y,
|
|
109
|
+
width: node.frame.width,
|
|
110
|
+
height: node.frame.height,
|
|
111
|
+
}
|
|
112
|
+
: undefined,
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
return nodes;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function ensureAxSnapshotBinary(): Promise<string> {
|
|
119
|
+
const projectRoot = findProjectRoot();
|
|
120
|
+
const packageDir = path.join(projectRoot, 'ios-runner', 'AXSnapshot');
|
|
121
|
+
const envPath = process.env.AGENT_DEVICE_AX_BINARY;
|
|
122
|
+
if (envPath && fs.existsSync(envPath)) return envPath;
|
|
123
|
+
const packagedCandidates = [
|
|
124
|
+
path.join(projectRoot, 'bin', 'axsnapshot'),
|
|
125
|
+
path.join(projectRoot, 'dist', 'bin', 'axsnapshot'),
|
|
126
|
+
path.join(projectRoot, 'dist', 'axsnapshot'),
|
|
127
|
+
];
|
|
128
|
+
for (const candidate of packagedCandidates) {
|
|
129
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
130
|
+
}
|
|
131
|
+
const binaryPath = path.join(packageDir, '.build', 'release', 'axsnapshot');
|
|
132
|
+
if (fs.existsSync(binaryPath)) return binaryPath;
|
|
133
|
+
const result = await runCmd('swift', ['build', '-c', 'release'], {
|
|
134
|
+
cwd: packageDir,
|
|
135
|
+
allowFailure: true,
|
|
136
|
+
});
|
|
137
|
+
if (result.exitCode !== 0 || !fs.existsSync(binaryPath)) {
|
|
138
|
+
throw new AppError('COMMAND_FAILED', 'Failed to build AX snapshot tool', {
|
|
139
|
+
stderr: result.stderr,
|
|
140
|
+
stdout: result.stdout,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return binaryPath;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function findProjectRoot(): string {
|
|
147
|
+
let current = process.cwd();
|
|
148
|
+
for (let i = 0; i < 6; i += 1) {
|
|
149
|
+
const pkgPath = path.join(current, 'package.json');
|
|
150
|
+
if (fs.existsSync(pkgPath)) return current;
|
|
151
|
+
current = path.dirname(current);
|
|
152
|
+
}
|
|
153
|
+
return process.cwd();
|
|
154
|
+
}
|