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
package/src/daemon.ts
ADDED
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { dispatchCommand, resolveTargetDevice, type CommandFlags } from './core/dispatch.ts';
|
|
8
|
+
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 { runIosRunnerCommand, stopIosRunnerSession } from './platforms/ios/runner-client.ts';
|
|
19
|
+
|
|
20
|
+
type DaemonRequest = {
|
|
21
|
+
token: string;
|
|
22
|
+
session: string;
|
|
23
|
+
command: string;
|
|
24
|
+
positionals: string[];
|
|
25
|
+
flags?: CommandFlags;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type DaemonResponse =
|
|
29
|
+
| { ok: true; data?: Record<string, unknown> }
|
|
30
|
+
| { ok: false; error: { code: string; message: string; details?: Record<string, unknown> } };
|
|
31
|
+
|
|
32
|
+
type SessionState = {
|
|
33
|
+
name: string;
|
|
34
|
+
device: DeviceInfo;
|
|
35
|
+
createdAt: number;
|
|
36
|
+
appBundleId?: string;
|
|
37
|
+
appName?: string;
|
|
38
|
+
snapshot?: SnapshotState;
|
|
39
|
+
actions: SessionAction[];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type SessionAction = {
|
|
43
|
+
ts: number;
|
|
44
|
+
command: string;
|
|
45
|
+
positionals: string[];
|
|
46
|
+
flags: Partial<CommandFlags> & {
|
|
47
|
+
snapshotInteractiveOnly?: boolean;
|
|
48
|
+
snapshotCompact?: boolean;
|
|
49
|
+
snapshotDepth?: number;
|
|
50
|
+
snapshotScope?: string;
|
|
51
|
+
snapshotRaw?: boolean;
|
|
52
|
+
snapshotBackend?: 'ax' | 'xctest';
|
|
53
|
+
noRecord?: boolean;
|
|
54
|
+
recordJson?: boolean;
|
|
55
|
+
};
|
|
56
|
+
result?: Record<string, unknown>;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const sessions = new Map<string, SessionState>();
|
|
60
|
+
const baseDir = path.join(os.homedir(), '.agent-device');
|
|
61
|
+
const infoPath = path.join(baseDir, 'daemon.json');
|
|
62
|
+
const logPath = path.join(baseDir, 'daemon.log');
|
|
63
|
+
const sessionsDir = path.join(baseDir, 'sessions');
|
|
64
|
+
const version = readVersion();
|
|
65
|
+
const token = crypto.randomBytes(24).toString('hex');
|
|
66
|
+
|
|
67
|
+
function contextFromFlags(
|
|
68
|
+
flags: CommandFlags | undefined,
|
|
69
|
+
appBundleId?: string,
|
|
70
|
+
): {
|
|
71
|
+
appBundleId?: string;
|
|
72
|
+
verbose?: boolean;
|
|
73
|
+
logPath?: string;
|
|
74
|
+
snapshotInteractiveOnly?: boolean;
|
|
75
|
+
snapshotCompact?: boolean;
|
|
76
|
+
snapshotDepth?: number;
|
|
77
|
+
snapshotScope?: string;
|
|
78
|
+
snapshotBackend?: 'ax' | 'xctest';
|
|
79
|
+
} {
|
|
80
|
+
return {
|
|
81
|
+
appBundleId,
|
|
82
|
+
verbose: flags?.verbose,
|
|
83
|
+
logPath,
|
|
84
|
+
snapshotInteractiveOnly: flags?.snapshotInteractiveOnly,
|
|
85
|
+
snapshotCompact: flags?.snapshotCompact,
|
|
86
|
+
snapshotDepth: flags?.snapshotDepth,
|
|
87
|
+
snapshotScope: flags?.snapshotScope,
|
|
88
|
+
snapshotRaw: flags?.snapshotRaw,
|
|
89
|
+
snapshotBackend: flags?.snapshotBackend,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function handleRequest(req: DaemonRequest): Promise<DaemonResponse> {
|
|
94
|
+
if (req.token !== token) {
|
|
95
|
+
return { ok: false, error: { code: 'UNAUTHORIZED', message: 'Invalid token' } };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const command = req.command;
|
|
99
|
+
const sessionName = req.session || 'default';
|
|
100
|
+
|
|
101
|
+
if (command === 'session_list') {
|
|
102
|
+
const data = {
|
|
103
|
+
sessions: Array.from(sessions.values()).map((s) => ({
|
|
104
|
+
name: s.name,
|
|
105
|
+
platform: s.device.platform,
|
|
106
|
+
device: s.device.name,
|
|
107
|
+
id: s.device.id,
|
|
108
|
+
createdAt: s.createdAt,
|
|
109
|
+
})),
|
|
110
|
+
};
|
|
111
|
+
return { ok: true, data };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (command === 'open') {
|
|
115
|
+
const device = await resolveTargetDevice(req.flags ?? {});
|
|
116
|
+
let appBundleId: string | undefined;
|
|
117
|
+
const appName = req.positionals?.[0];
|
|
118
|
+
if (device.platform === 'ios') {
|
|
119
|
+
try {
|
|
120
|
+
const { resolveIosApp } = await import('./platforms/ios/index.ts');
|
|
121
|
+
appBundleId = await resolveIosApp(device, req.positionals?.[0] ?? '');
|
|
122
|
+
} catch {
|
|
123
|
+
appBundleId = undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
await dispatchCommand(device, 'open', req.positionals ?? [], req.flags?.out, {
|
|
127
|
+
...contextFromFlags(req.flags, appBundleId),
|
|
128
|
+
});
|
|
129
|
+
const session: SessionState = {
|
|
130
|
+
name: sessionName,
|
|
131
|
+
device,
|
|
132
|
+
createdAt: Date.now(),
|
|
133
|
+
appBundleId,
|
|
134
|
+
appName,
|
|
135
|
+
actions: [],
|
|
136
|
+
};
|
|
137
|
+
recordAction(session, {
|
|
138
|
+
command,
|
|
139
|
+
positionals: req.positionals ?? [],
|
|
140
|
+
flags: req.flags ?? {},
|
|
141
|
+
result: { session: sessionName },
|
|
142
|
+
});
|
|
143
|
+
sessions.set(sessionName, session);
|
|
144
|
+
return { ok: true, data: { session: sessionName } };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (command === 'replay') {
|
|
148
|
+
const filePath = req.positionals?.[0];
|
|
149
|
+
if (!filePath) {
|
|
150
|
+
return { ok: false, error: { code: 'INVALID_ARGS', message: 'replay requires a path' } };
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
const resolved = expandHome(filePath);
|
|
154
|
+
const payload = JSON.parse(fs.readFileSync(resolved, 'utf8')) as {
|
|
155
|
+
actions?: SessionAction[];
|
|
156
|
+
optimizedActions?: SessionAction[];
|
|
157
|
+
};
|
|
158
|
+
const actions = payload.optimizedActions ?? payload.actions ?? [];
|
|
159
|
+
for (const action of actions) {
|
|
160
|
+
if (!action || action.command === 'replay') continue;
|
|
161
|
+
await handleRequest({
|
|
162
|
+
token,
|
|
163
|
+
session: sessionName,
|
|
164
|
+
command: action.command,
|
|
165
|
+
positionals: action.positionals ?? [],
|
|
166
|
+
flags: action.flags ?? {},
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return { ok: true, data: { replayed: actions.length, session: sessionName } };
|
|
170
|
+
} catch (err) {
|
|
171
|
+
const appErr = asAppError(err);
|
|
172
|
+
return { ok: false, error: { code: appErr.code, message: appErr.message } };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (command === 'close') {
|
|
177
|
+
const session = sessions.get(sessionName);
|
|
178
|
+
if (!session) {
|
|
179
|
+
return { ok: false, error: { code: 'SESSION_NOT_FOUND', message: 'No active session' } };
|
|
180
|
+
}
|
|
181
|
+
if (req.positionals && req.positionals.length > 0) {
|
|
182
|
+
await dispatchCommand(session.device, 'close', req.positionals ?? [], req.flags?.out, {
|
|
183
|
+
...contextFromFlags(req.flags, session.appBundleId),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
if (session.device.platform === 'ios' && session.device.kind === 'simulator') {
|
|
187
|
+
await stopIosRunnerSession(session.device.id);
|
|
188
|
+
}
|
|
189
|
+
recordAction(session, {
|
|
190
|
+
command,
|
|
191
|
+
positionals: req.positionals ?? [],
|
|
192
|
+
flags: req.flags ?? {},
|
|
193
|
+
result: { session: sessionName },
|
|
194
|
+
});
|
|
195
|
+
writeSessionLog(session);
|
|
196
|
+
sessions.delete(sessionName);
|
|
197
|
+
return { ok: true, data: { session: sessionName } };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (command === 'snapshot') {
|
|
201
|
+
const session = sessions.get(sessionName);
|
|
202
|
+
const device = session?.device ?? (await resolveTargetDevice(req.flags ?? {}));
|
|
203
|
+
const appBundleId = session?.appBundleId;
|
|
204
|
+
const data = (await dispatchCommand(device, 'snapshot', [], req.flags?.out, {
|
|
205
|
+
...contextFromFlags(req.flags, appBundleId),
|
|
206
|
+
})) as {
|
|
207
|
+
nodes?: RawSnapshotNode[];
|
|
208
|
+
truncated?: boolean;
|
|
209
|
+
backend?: 'ax' | 'xctest' | 'android';
|
|
210
|
+
rootRect?: { width: number; height: number };
|
|
211
|
+
};
|
|
212
|
+
const pruned = pruneGroupNodes(data?.nodes ?? []);
|
|
213
|
+
const nodes = attachRefs(pruned);
|
|
214
|
+
const snapshot: SnapshotState = {
|
|
215
|
+
nodes,
|
|
216
|
+
truncated: data?.truncated,
|
|
217
|
+
createdAt: Date.now(),
|
|
218
|
+
backend: data?.backend,
|
|
219
|
+
};
|
|
220
|
+
const nextSession: SessionState = {
|
|
221
|
+
name: sessionName,
|
|
222
|
+
device,
|
|
223
|
+
createdAt: session?.createdAt ?? Date.now(),
|
|
224
|
+
appBundleId,
|
|
225
|
+
snapshot,
|
|
226
|
+
actions: session?.actions ?? [],
|
|
227
|
+
};
|
|
228
|
+
recordAction(nextSession, {
|
|
229
|
+
command,
|
|
230
|
+
positionals: req.positionals ?? [],
|
|
231
|
+
flags: req.flags ?? {},
|
|
232
|
+
result: { nodes: nodes.length, truncated: data?.truncated ?? false },
|
|
233
|
+
});
|
|
234
|
+
sessions.set(sessionName, nextSession);
|
|
235
|
+
return {
|
|
236
|
+
ok: true,
|
|
237
|
+
data: {
|
|
238
|
+
nodes,
|
|
239
|
+
truncated: data?.truncated ?? false,
|
|
240
|
+
appName: session?.appName ?? appBundleId ?? device.name,
|
|
241
|
+
appBundleId: appBundleId,
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (command === 'click') {
|
|
247
|
+
const session = sessions.get(sessionName);
|
|
248
|
+
if (!session?.snapshot) {
|
|
249
|
+
return { ok: false, error: { code: 'INVALID_ARGS', message: 'No snapshot in session. Run snapshot first.' } };
|
|
250
|
+
}
|
|
251
|
+
const refInput = req.positionals?.[0] ?? '';
|
|
252
|
+
const ref = normalizeRef(refInput);
|
|
253
|
+
if (!ref) {
|
|
254
|
+
return { ok: false, error: { code: 'INVALID_ARGS', message: 'click requires a ref like @e2' } };
|
|
255
|
+
}
|
|
256
|
+
let node = findNodeByRef(session.snapshot.nodes, ref);
|
|
257
|
+
if (!node?.rect && req.positionals.length > 1) {
|
|
258
|
+
const fallbackLabel = req.positionals.slice(1).join(' ').trim();
|
|
259
|
+
if (fallbackLabel.length > 0) {
|
|
260
|
+
node = findNodeByLabel(session.snapshot.nodes, fallbackLabel);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (!node?.rect) {
|
|
264
|
+
return { ok: false, error: { code: 'COMMAND_FAILED', message: `Ref ${refInput} not found or has no bounds` } };
|
|
265
|
+
}
|
|
266
|
+
const refLabel = resolveRefLabel(node, session.snapshot.nodes);
|
|
267
|
+
const label = node.label?.trim();
|
|
268
|
+
if (
|
|
269
|
+
session.device.platform === 'ios' &&
|
|
270
|
+
session.device.kind === 'simulator' &&
|
|
271
|
+
label &&
|
|
272
|
+
isLabelUnique(session.snapshot.nodes, label)
|
|
273
|
+
) {
|
|
274
|
+
await runIosRunnerCommand(
|
|
275
|
+
session.device,
|
|
276
|
+
{ command: 'tap', text: label, appBundleId: session.appBundleId },
|
|
277
|
+
{ verbose: req.flags?.verbose, logPath },
|
|
278
|
+
);
|
|
279
|
+
recordAction(session, {
|
|
280
|
+
command,
|
|
281
|
+
positionals: req.positionals ?? [],
|
|
282
|
+
flags: req.flags ?? {},
|
|
283
|
+
result: { ref, refLabel: label, mode: 'text' },
|
|
284
|
+
});
|
|
285
|
+
return { ok: true, data: { ref, mode: 'text' } };
|
|
286
|
+
}
|
|
287
|
+
const { x, y } = centerOfRect(node.rect);
|
|
288
|
+
await dispatchCommand(session.device, 'press', [String(x), String(y)], req.flags?.out, {
|
|
289
|
+
...contextFromFlags(req.flags, session.appBundleId),
|
|
290
|
+
});
|
|
291
|
+
recordAction(session, {
|
|
292
|
+
command,
|
|
293
|
+
positionals: req.positionals ?? [],
|
|
294
|
+
flags: req.flags ?? {},
|
|
295
|
+
result: { ref, x, y, refLabel },
|
|
296
|
+
});
|
|
297
|
+
return { ok: true, data: { ref, x, y } };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (command === 'fill') {
|
|
301
|
+
const session = sessions.get(sessionName);
|
|
302
|
+
if (req.positionals?.[0]?.startsWith('@')) {
|
|
303
|
+
if (!session?.snapshot) {
|
|
304
|
+
return { ok: false, error: { code: 'INVALID_ARGS', message: 'No snapshot in session. Run snapshot first.' } };
|
|
305
|
+
}
|
|
306
|
+
const ref = normalizeRef(req.positionals[0]);
|
|
307
|
+
if (!ref) {
|
|
308
|
+
return { ok: false, error: { code: 'INVALID_ARGS', message: 'fill requires a ref like @e2' } };
|
|
309
|
+
}
|
|
310
|
+
const labelCandidate = req.positionals.length >= 3 ? req.positionals[1] : '';
|
|
311
|
+
const text = req.positionals.length >= 3 ? req.positionals.slice(2).join(' ') : req.positionals.slice(1).join(' ');
|
|
312
|
+
if (!text) {
|
|
313
|
+
return { ok: false, error: { code: 'INVALID_ARGS', message: 'fill requires text after ref' } };
|
|
314
|
+
}
|
|
315
|
+
let node = findNodeByRef(session.snapshot.nodes, ref);
|
|
316
|
+
if (!node?.rect && labelCandidate) {
|
|
317
|
+
node = findNodeByLabel(session.snapshot.nodes, labelCandidate);
|
|
318
|
+
}
|
|
319
|
+
if (!node?.rect) {
|
|
320
|
+
return { ok: false, error: { code: 'COMMAND_FAILED', message: `Ref ${req.positionals[0]} not found or has no bounds` } };
|
|
321
|
+
}
|
|
322
|
+
const refLabel = resolveRefLabel(node, session.snapshot.nodes);
|
|
323
|
+
const { x, y } = centerOfRect(node.rect);
|
|
324
|
+
const data = await dispatchCommand(
|
|
325
|
+
session.device,
|
|
326
|
+
'fill',
|
|
327
|
+
[String(x), String(y), text],
|
|
328
|
+
req.flags?.out,
|
|
329
|
+
{
|
|
330
|
+
...contextFromFlags(req.flags, session.appBundleId),
|
|
331
|
+
},
|
|
332
|
+
);
|
|
333
|
+
recordAction(session, {
|
|
334
|
+
command,
|
|
335
|
+
positionals: req.positionals ?? [],
|
|
336
|
+
flags: req.flags ?? {},
|
|
337
|
+
result: data ?? { ref, x, y, refLabel },
|
|
338
|
+
});
|
|
339
|
+
return { ok: true, data: data ?? { ref, x, y } };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (command === 'get') {
|
|
344
|
+
const sub = req.positionals?.[0];
|
|
345
|
+
const refInput = req.positionals?.[1];
|
|
346
|
+
if (sub !== 'text' && sub !== 'attrs') {
|
|
347
|
+
return { ok: false, error: { code: 'INVALID_ARGS', message: 'get only supports text or attrs' } };
|
|
348
|
+
}
|
|
349
|
+
const session = sessions.get(sessionName);
|
|
350
|
+
if (!session?.snapshot) {
|
|
351
|
+
return { ok: false, error: { code: 'INVALID_ARGS', message: 'No snapshot in session. Run snapshot first.' } };
|
|
352
|
+
}
|
|
353
|
+
const ref = normalizeRef(refInput ?? '');
|
|
354
|
+
if (!ref) {
|
|
355
|
+
return { ok: false, error: { code: 'INVALID_ARGS', message: 'get text requires a ref like @e2' } };
|
|
356
|
+
}
|
|
357
|
+
let node = findNodeByRef(session.snapshot.nodes, ref);
|
|
358
|
+
if (!node && req.positionals.length > 2) {
|
|
359
|
+
const labelCandidate = req.positionals.slice(2).join(' ').trim();
|
|
360
|
+
if (labelCandidate.length > 0) {
|
|
361
|
+
node = findNodeByLabel(session.snapshot.nodes, labelCandidate);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (!node) {
|
|
365
|
+
return { ok: false, error: { code: 'COMMAND_FAILED', message: `Ref ${refInput} not found` } };
|
|
366
|
+
}
|
|
367
|
+
if (sub === 'attrs') {
|
|
368
|
+
recordAction(session, {
|
|
369
|
+
command,
|
|
370
|
+
positionals: req.positionals ?? [],
|
|
371
|
+
flags: req.flags ?? {},
|
|
372
|
+
result: { ref },
|
|
373
|
+
});
|
|
374
|
+
return { ok: true, data: { ref, node } };
|
|
375
|
+
}
|
|
376
|
+
const candidates = [node.label, node.value, node.identifier]
|
|
377
|
+
.map((value) => (typeof value === 'string' ? value.trim() : ''))
|
|
378
|
+
.filter((value) => value.length > 0);
|
|
379
|
+
const text = candidates[0] ?? '';
|
|
380
|
+
recordAction(session, {
|
|
381
|
+
command,
|
|
382
|
+
positionals: req.positionals ?? [],
|
|
383
|
+
flags: req.flags ?? {},
|
|
384
|
+
result: { ref, text, refLabel: text || undefined },
|
|
385
|
+
});
|
|
386
|
+
return { ok: true, data: { ref, text, node } };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (command === 'rect') {
|
|
390
|
+
const session = sessions.get(sessionName);
|
|
391
|
+
if (!session?.snapshot) {
|
|
392
|
+
return { ok: false, error: { code: 'INVALID_ARGS', message: 'No snapshot in session. Run snapshot first.' } };
|
|
393
|
+
}
|
|
394
|
+
const target = req.positionals?.[0] ?? '';
|
|
395
|
+
const ref = normalizeRef(target);
|
|
396
|
+
let label = '';
|
|
397
|
+
if (ref) {
|
|
398
|
+
const node = findNodeByRef(session.snapshot.nodes, ref);
|
|
399
|
+
label = node?.label?.trim() ?? '';
|
|
400
|
+
} else {
|
|
401
|
+
label = req.positionals.join(' ').trim();
|
|
402
|
+
}
|
|
403
|
+
if (!label) {
|
|
404
|
+
return { ok: false, error: { code: 'INVALID_ARGS', message: 'rect requires a label or ref with label' } };
|
|
405
|
+
}
|
|
406
|
+
if (session.device.platform !== 'ios' || session.device.kind !== 'simulator') {
|
|
407
|
+
return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'rect is only supported on iOS simulators' } };
|
|
408
|
+
}
|
|
409
|
+
const data = await runIosRunnerCommand(
|
|
410
|
+
session.device,
|
|
411
|
+
{ command: 'rect', text: label, appBundleId: session.appBundleId },
|
|
412
|
+
{ verbose: req.flags?.verbose, logPath },
|
|
413
|
+
);
|
|
414
|
+
recordAction(session, {
|
|
415
|
+
command,
|
|
416
|
+
positionals: req.positionals ?? [],
|
|
417
|
+
flags: req.flags ?? {},
|
|
418
|
+
result: { label, rect: data?.rect },
|
|
419
|
+
});
|
|
420
|
+
return { ok: true, data: { label, rect: data?.rect } };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const session = sessions.get(sessionName);
|
|
424
|
+
if (!session) {
|
|
425
|
+
return {
|
|
426
|
+
ok: false,
|
|
427
|
+
error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' },
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const data = await dispatchCommand(session.device, command, req.positionals ?? [], req.flags?.out, {
|
|
432
|
+
...contextFromFlags(req.flags, session.appBundleId),
|
|
433
|
+
});
|
|
434
|
+
recordAction(session, {
|
|
435
|
+
command,
|
|
436
|
+
positionals: req.positionals ?? [],
|
|
437
|
+
flags: req.flags ?? {},
|
|
438
|
+
result: data ?? {},
|
|
439
|
+
});
|
|
440
|
+
return { ok: true, data: data ?? {} };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function writeInfo(port: number): void {
|
|
444
|
+
if (!fs.existsSync(baseDir)) fs.mkdirSync(baseDir, { recursive: true });
|
|
445
|
+
fs.writeFileSync(logPath, '');
|
|
446
|
+
fs.writeFileSync(
|
|
447
|
+
infoPath,
|
|
448
|
+
JSON.stringify({ port, token, pid: process.pid, version }, null, 2),
|
|
449
|
+
{
|
|
450
|
+
mode: 0o600,
|
|
451
|
+
},
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function removeInfo(): void {
|
|
456
|
+
if (fs.existsSync(infoPath)) fs.unlinkSync(infoPath);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function start(): void {
|
|
460
|
+
const server = net.createServer((socket) => {
|
|
461
|
+
let buffer = '';
|
|
462
|
+
socket.setEncoding('utf8');
|
|
463
|
+
socket.on('data', async (chunk) => {
|
|
464
|
+
buffer += chunk;
|
|
465
|
+
let idx = buffer.indexOf('\n');
|
|
466
|
+
while (idx !== -1) {
|
|
467
|
+
const line = buffer.slice(0, idx).trim();
|
|
468
|
+
buffer = buffer.slice(idx + 1);
|
|
469
|
+
if (line.length === 0) {
|
|
470
|
+
idx = buffer.indexOf('\n');
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
let response: DaemonResponse;
|
|
474
|
+
try {
|
|
475
|
+
const req = JSON.parse(line) as DaemonRequest;
|
|
476
|
+
response = await handleRequest(req);
|
|
477
|
+
} catch (err) {
|
|
478
|
+
const appErr = asAppError(err);
|
|
479
|
+
response = {
|
|
480
|
+
ok: false,
|
|
481
|
+
error: { code: appErr.code, message: appErr.message, details: appErr.details },
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
socket.write(`${JSON.stringify(response)}\n`);
|
|
485
|
+
idx = buffer.indexOf('\n');
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
server.listen(0, '127.0.0.1', () => {
|
|
491
|
+
const address = server.address();
|
|
492
|
+
if (typeof address === 'object' && address?.port) {
|
|
493
|
+
writeInfo(address.port);
|
|
494
|
+
process.stdout.write(`AGENT_DEVICE_DAEMON_PORT=${address.port}\n`);
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
const shutdown = async () => {
|
|
499
|
+
const sessionsToStop = Array.from(sessions.values());
|
|
500
|
+
for (const session of sessionsToStop) {
|
|
501
|
+
if (session.device.platform === 'ios' && session.device.kind === 'simulator') {
|
|
502
|
+
await stopIosRunnerSession(session.device.id);
|
|
503
|
+
}
|
|
504
|
+
writeSessionLog(session);
|
|
505
|
+
}
|
|
506
|
+
server.close(() => {
|
|
507
|
+
removeInfo();
|
|
508
|
+
process.exit(0);
|
|
509
|
+
});
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
process.on('SIGINT', () => {
|
|
513
|
+
void shutdown();
|
|
514
|
+
});
|
|
515
|
+
process.on('SIGTERM', () => {
|
|
516
|
+
void shutdown();
|
|
517
|
+
});
|
|
518
|
+
process.on('SIGHUP', () => {
|
|
519
|
+
void shutdown();
|
|
520
|
+
});
|
|
521
|
+
process.on('uncaughtException', (err) => {
|
|
522
|
+
const appErr = err instanceof AppError ? err : asAppError(err);
|
|
523
|
+
process.stderr.write(`Daemon error: ${appErr.message}\n`);
|
|
524
|
+
void shutdown();
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
start();
|
|
529
|
+
|
|
530
|
+
function recordAction(
|
|
531
|
+
session: SessionState,
|
|
532
|
+
entry: {
|
|
533
|
+
command: string;
|
|
534
|
+
positionals: string[];
|
|
535
|
+
flags: CommandFlags;
|
|
536
|
+
result?: Record<string, unknown>;
|
|
537
|
+
},
|
|
538
|
+
): void {
|
|
539
|
+
if (entry.flags?.noRecord) return;
|
|
540
|
+
session.actions.push({
|
|
541
|
+
ts: Date.now(),
|
|
542
|
+
command: entry.command,
|
|
543
|
+
positionals: entry.positionals,
|
|
544
|
+
flags: sanitizeFlags(entry.flags),
|
|
545
|
+
result: entry.result,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function sanitizeFlags(flags: CommandFlags | undefined): SessionAction['flags'] {
|
|
550
|
+
if (!flags) return {};
|
|
551
|
+
const {
|
|
552
|
+
platform,
|
|
553
|
+
device,
|
|
554
|
+
udid,
|
|
555
|
+
serial,
|
|
556
|
+
out,
|
|
557
|
+
verbose,
|
|
558
|
+
snapshotInteractiveOnly,
|
|
559
|
+
snapshotCompact,
|
|
560
|
+
snapshotDepth,
|
|
561
|
+
snapshotScope,
|
|
562
|
+
snapshotRaw,
|
|
563
|
+
snapshotBackend,
|
|
564
|
+
noRecord,
|
|
565
|
+
recordJson,
|
|
566
|
+
} = flags as any;
|
|
567
|
+
return {
|
|
568
|
+
platform,
|
|
569
|
+
device,
|
|
570
|
+
udid,
|
|
571
|
+
serial,
|
|
572
|
+
out,
|
|
573
|
+
verbose,
|
|
574
|
+
snapshotInteractiveOnly,
|
|
575
|
+
snapshotCompact,
|
|
576
|
+
snapshotDepth,
|
|
577
|
+
snapshotScope,
|
|
578
|
+
snapshotRaw,
|
|
579
|
+
snapshotBackend,
|
|
580
|
+
noRecord,
|
|
581
|
+
recordJson,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function writeSessionLog(session: SessionState): void {
|
|
586
|
+
try {
|
|
587
|
+
if (!fs.existsSync(sessionsDir)) fs.mkdirSync(sessionsDir, { recursive: true });
|
|
588
|
+
const safeName = session.name.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
589
|
+
const timestamp = new Date(session.createdAt).toISOString().replace(/[:.]/g, '-');
|
|
590
|
+
const scriptPath = path.join(sessionsDir, `${safeName}-${timestamp}.ad`);
|
|
591
|
+
const filePath = path.join(sessionsDir, `${safeName}-${timestamp}.json`);
|
|
592
|
+
const payload = {
|
|
593
|
+
name: session.name,
|
|
594
|
+
device: session.device,
|
|
595
|
+
createdAt: session.createdAt,
|
|
596
|
+
appBundleId: session.appBundleId,
|
|
597
|
+
actions: session.actions,
|
|
598
|
+
optimizedActions: buildOptimizedActions(session),
|
|
599
|
+
};
|
|
600
|
+
const script = formatScript(session, payload.optimizedActions);
|
|
601
|
+
fs.writeFileSync(scriptPath, script);
|
|
602
|
+
if (session.actions.some((action) => action.flags?.recordJson)) {
|
|
603
|
+
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2));
|
|
604
|
+
}
|
|
605
|
+
} catch {
|
|
606
|
+
// ignore
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function expandHome(filePath: string): string {
|
|
611
|
+
if (filePath.startsWith('~/')) {
|
|
612
|
+
return path.join(os.homedir(), filePath.slice(2));
|
|
613
|
+
}
|
|
614
|
+
return path.resolve(filePath);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function buildOptimizedActions(session: SessionState): SessionAction[] {
|
|
618
|
+
const optimized: SessionAction[] = [];
|
|
619
|
+
for (const action of session.actions) {
|
|
620
|
+
if (action.command === 'snapshot') {
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
if (action.command === 'click' || action.command === 'fill' || action.command === 'get') {
|
|
624
|
+
const refLabel = action.result?.refLabel;
|
|
625
|
+
if (typeof refLabel === 'string' && refLabel.trim().length > 0) {
|
|
626
|
+
optimized.push({
|
|
627
|
+
ts: action.ts,
|
|
628
|
+
command: 'snapshot',
|
|
629
|
+
positionals: [],
|
|
630
|
+
flags: {
|
|
631
|
+
platform: session.device.platform,
|
|
632
|
+
snapshotInteractiveOnly: true,
|
|
633
|
+
snapshotCompact: true,
|
|
634
|
+
snapshotScope: refLabel.trim(),
|
|
635
|
+
},
|
|
636
|
+
result: { scope: refLabel.trim() },
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
optimized.push(action);
|
|
641
|
+
}
|
|
642
|
+
return optimized;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function formatScript(session: SessionState, actions: SessionAction[]): string {
|
|
646
|
+
const lines: string[] = [];
|
|
647
|
+
const deviceLabel = session.device.name.replace(/"/g, '\\"');
|
|
648
|
+
const kind = session.device.kind ? ` kind=${session.device.kind}` : '';
|
|
649
|
+
const theme = 'unknown';
|
|
650
|
+
lines.push(`context platform=${session.device.platform} device="${deviceLabel}"${kind} theme=${theme}`);
|
|
651
|
+
for (const action of actions) {
|
|
652
|
+
if (action.flags?.noRecord) continue;
|
|
653
|
+
lines.push(formatActionLine(action));
|
|
654
|
+
}
|
|
655
|
+
return `${lines.join('\n')}\n`;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function formatActionLine(action: SessionAction): string {
|
|
659
|
+
const parts: string[] = [action.command];
|
|
660
|
+
if (action.command === 'click') {
|
|
661
|
+
const ref = action.positionals?.[0];
|
|
662
|
+
if (ref) {
|
|
663
|
+
parts.push(formatArg(ref));
|
|
664
|
+
const refLabel = action.result?.refLabel;
|
|
665
|
+
if (typeof refLabel === 'string' && refLabel.trim().length > 0) {
|
|
666
|
+
parts.push(formatArg(refLabel));
|
|
667
|
+
}
|
|
668
|
+
return parts.join(' ');
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (action.command === 'fill') {
|
|
672
|
+
const ref = action.positionals?.[0];
|
|
673
|
+
if (ref && ref.startsWith('@')) {
|
|
674
|
+
parts.push(formatArg(ref));
|
|
675
|
+
const refLabel = action.result?.refLabel;
|
|
676
|
+
const text = action.positionals.slice(1).join(' ');
|
|
677
|
+
if (typeof refLabel === 'string' && refLabel.trim().length > 0) {
|
|
678
|
+
parts.push(formatArg(refLabel));
|
|
679
|
+
}
|
|
680
|
+
if (text) {
|
|
681
|
+
parts.push(formatArg(text));
|
|
682
|
+
}
|
|
683
|
+
return parts.join(' ');
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
if (action.command === 'get') {
|
|
687
|
+
const sub = action.positionals?.[0];
|
|
688
|
+
const ref = action.positionals?.[1];
|
|
689
|
+
if (sub && ref) {
|
|
690
|
+
parts.push(formatArg(sub));
|
|
691
|
+
parts.push(formatArg(ref));
|
|
692
|
+
const refLabel = action.result?.refLabel;
|
|
693
|
+
if (typeof refLabel === 'string' && refLabel.trim().length > 0) {
|
|
694
|
+
parts.push(formatArg(refLabel));
|
|
695
|
+
}
|
|
696
|
+
return parts.join(' ');
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
if (action.command === 'snapshot') {
|
|
700
|
+
if (action.flags?.snapshotInteractiveOnly) parts.push('-i');
|
|
701
|
+
if (action.flags?.snapshotCompact) parts.push('-c');
|
|
702
|
+
if (typeof action.flags?.snapshotDepth === 'number') {
|
|
703
|
+
parts.push('-d', String(action.flags.snapshotDepth));
|
|
704
|
+
}
|
|
705
|
+
if (action.flags?.snapshotScope) {
|
|
706
|
+
parts.push('-s', formatArg(action.flags.snapshotScope));
|
|
707
|
+
}
|
|
708
|
+
if (action.flags?.snapshotRaw) parts.push('--raw');
|
|
709
|
+
if (action.flags?.snapshotBackend) {
|
|
710
|
+
parts.push(`--backend`, action.flags.snapshotBackend);
|
|
711
|
+
}
|
|
712
|
+
return parts.join(' ');
|
|
713
|
+
}
|
|
714
|
+
for (const positional of action.positionals ?? []) {
|
|
715
|
+
parts.push(formatArg(positional));
|
|
716
|
+
}
|
|
717
|
+
return parts.join(' ');
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function formatArg(value: string): string {
|
|
721
|
+
const trimmed = value.trim();
|
|
722
|
+
if (trimmed.startsWith('@')) return trimmed;
|
|
723
|
+
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed;
|
|
724
|
+
return JSON.stringify(trimmed);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function findNodeByLabel(nodes: SnapshotState['nodes'], label: string) {
|
|
728
|
+
const query = label.toLowerCase();
|
|
729
|
+
return (
|
|
730
|
+
nodes.find((node) => {
|
|
731
|
+
const labelValue = (node.label ?? '').toLowerCase();
|
|
732
|
+
const valueValue = (node.value ?? '').toLowerCase();
|
|
733
|
+
const idValue = (node.identifier ?? '').toLowerCase();
|
|
734
|
+
return labelValue.includes(query) || valueValue.includes(query) || idValue.includes(query);
|
|
735
|
+
}) ?? null
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function resolveRefLabel(
|
|
740
|
+
node: SnapshotState['nodes'][number],
|
|
741
|
+
nodes: SnapshotState['nodes'],
|
|
742
|
+
): string | undefined {
|
|
743
|
+
const primary = [node.label, node.value, node.identifier]
|
|
744
|
+
.map((value) => (typeof value === 'string' ? value.trim() : ''))
|
|
745
|
+
.find((value) => value && value.length > 0);
|
|
746
|
+
if (primary && isMeaningfulLabel(primary)) return primary;
|
|
747
|
+
const fallback = findNearestMeaningfulLabel(node, nodes);
|
|
748
|
+
return fallback ?? (primary && isMeaningfulLabel(primary) ? primary : undefined);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function isMeaningfulLabel(value: string): boolean {
|
|
752
|
+
const trimmed = value.trim();
|
|
753
|
+
if (!trimmed) return false;
|
|
754
|
+
if (/^(true|false)$/i.test(trimmed)) return false;
|
|
755
|
+
if (/^\d+$/.test(trimmed)) return false;
|
|
756
|
+
return true;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function findNearestMeaningfulLabel(
|
|
760
|
+
target: SnapshotState['nodes'][number],
|
|
761
|
+
nodes: SnapshotState['nodes'],
|
|
762
|
+
): string | undefined {
|
|
763
|
+
if (!target.rect) return undefined;
|
|
764
|
+
const targetY = target.rect.y + target.rect.height / 2;
|
|
765
|
+
let best: { label: string; distance: number } | null = null;
|
|
766
|
+
for (const node of nodes) {
|
|
767
|
+
if (!node.rect) continue;
|
|
768
|
+
const label = [node.label, node.value, node.identifier]
|
|
769
|
+
.map((value) => (typeof value === 'string' ? value.trim() : ''))
|
|
770
|
+
.find((value) => value && value.length > 0);
|
|
771
|
+
if (!label || !isMeaningfulLabel(label)) continue;
|
|
772
|
+
const nodeY = node.rect.y + node.rect.height / 2;
|
|
773
|
+
const distance = Math.abs(nodeY - targetY);
|
|
774
|
+
if (!best || distance < best.distance) {
|
|
775
|
+
best = { label, distance };
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
return best?.label;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function isLabelUnique(nodes: SnapshotState['nodes'], label: string): boolean {
|
|
782
|
+
const target = label.trim().toLowerCase();
|
|
783
|
+
if (!target) return false;
|
|
784
|
+
let count = 0;
|
|
785
|
+
for (const node of nodes) {
|
|
786
|
+
if ((node.label ?? '').trim().toLowerCase() === target) {
|
|
787
|
+
count += 1;
|
|
788
|
+
if (count > 1) return false;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
return count === 1;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function pruneGroupNodes(nodes: RawSnapshotNode[]): RawSnapshotNode[] {
|
|
795
|
+
const skippedDepths: number[] = [];
|
|
796
|
+
const result: RawSnapshotNode[] = [];
|
|
797
|
+
for (const node of nodes) {
|
|
798
|
+
const depth = node.depth ?? 0;
|
|
799
|
+
while (skippedDepths.length > 0 && depth <= skippedDepths[skippedDepths.length - 1]) {
|
|
800
|
+
skippedDepths.pop();
|
|
801
|
+
}
|
|
802
|
+
const type = normalizeType(node.type ?? '');
|
|
803
|
+
if (type === 'group' || type === 'ioscontentgroup') {
|
|
804
|
+
skippedDepths.push(depth);
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
const adjustedDepth = Math.max(0, depth - skippedDepths.length);
|
|
808
|
+
result.push({ ...node, depth: adjustedDepth });
|
|
809
|
+
}
|
|
810
|
+
return result;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function normalizeType(type: string): string {
|
|
814
|
+
let value = type.replace(/XCUIElementType/gi, '').toLowerCase();
|
|
815
|
+
if (value.startsWith('ax')) {
|
|
816
|
+
value = value.replace(/^ax/, '');
|
|
817
|
+
}
|
|
818
|
+
return value;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function readVersion(): string {
|
|
822
|
+
try {
|
|
823
|
+
const root = findProjectRoot();
|
|
824
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) as {
|
|
825
|
+
version?: string;
|
|
826
|
+
};
|
|
827
|
+
return pkg.version ?? '0.0.0';
|
|
828
|
+
} catch {
|
|
829
|
+
return '0.0.0';
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function findProjectRoot(): string {
|
|
834
|
+
const start = path.dirname(fileURLToPath(import.meta.url));
|
|
835
|
+
let current = start;
|
|
836
|
+
for (let i = 0; i < 6; i += 1) {
|
|
837
|
+
const pkgPath = path.join(current, 'package.json');
|
|
838
|
+
if (fs.existsSync(pkgPath)) return current;
|
|
839
|
+
current = path.dirname(current);
|
|
840
|
+
}
|
|
841
|
+
return start;
|
|
842
|
+
}
|