agent-device 0.2.4 → 0.2.5
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/README.md +41 -4
- package/dist/src/bin.js +26 -21
- package/dist/src/daemon.js +9 -8
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj +2 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +15 -0
- package/package.json +3 -2
- package/skills/agent-device/SKILL.md +22 -6
- package/skills/agent-device/references/session-management.md +9 -0
- package/skills/agent-device/references/snapshot-refs.md +18 -5
- package/skills/agent-device/references/video-recording.md +2 -2
- package/src/cli.ts +6 -0
- package/src/core/__tests__/capabilities.test.ts +67 -0
- package/src/core/capabilities.ts +49 -0
- package/src/core/dispatch.ts +29 -118
- package/src/daemon/__tests__/is-predicates.test.ts +68 -0
- package/src/daemon/__tests__/selectors.test.ts +128 -0
- package/src/daemon/__tests__/session-routing.test.ts +108 -0
- package/src/daemon/__tests__/session-selector.test.ts +64 -0
- package/src/daemon/__tests__/session-store.test.ts +95 -0
- package/src/daemon/__tests__/snapshot-processing.test.ts +47 -0
- package/src/daemon/action-utils.ts +29 -0
- package/src/daemon/app-state.ts +66 -0
- package/src/daemon/context.ts +36 -0
- package/src/daemon/device-ready.ts +13 -0
- package/src/daemon/handlers/__tests__/find.test.ts +99 -0
- package/src/daemon/handlers/__tests__/replay-heal.test.ts +364 -0
- package/src/daemon/handlers/__tests__/snapshot.test.ts +128 -0
- package/src/daemon/handlers/find.ts +304 -0
- package/src/daemon/handlers/interaction.ts +510 -0
- package/src/daemon/handlers/parse-utils.ts +8 -0
- package/src/daemon/handlers/record-trace.ts +154 -0
- package/src/daemon/handlers/session.ts +732 -0
- package/src/daemon/handlers/snapshot.ts +396 -0
- package/src/daemon/is-predicates.ts +46 -0
- package/src/daemon/selectors.ts +423 -0
- package/src/daemon/session-routing.ts +22 -0
- package/src/daemon/session-selector.ts +39 -0
- package/src/daemon/session-store.ts +275 -0
- package/src/daemon/snapshot-processing.ts +127 -0
- package/src/daemon/types.ts +55 -0
- package/src/daemon.ts +66 -1592
- package/src/platforms/ios/index.ts +0 -62
- package/src/platforms/ios/runner-client.ts +2 -0
- package/src/utils/args.ts +19 -10
- package/src/utils/interactors.ts +102 -16
- package/src/utils/snapshot.ts +1 -0
package/src/daemon.ts
CHANGED
|
@@ -4,110 +4,36 @@ import os from 'node:os';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import crypto from 'node:crypto';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
|
-
import { dispatchCommand,
|
|
7
|
+
import { dispatchCommand, type CommandFlags } from './core/dispatch.ts';
|
|
8
|
+
import { isCommandSupportedOnDevice } from './core/capabilities.ts';
|
|
8
9
|
import { asAppError, AppError } from './utils/errors.ts';
|
|
9
|
-
import
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
} from './
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import { runCmd, runCmdBackground } from './utils/exec.ts';
|
|
21
|
-
import { snapshotAndroid } from './platforms/android/index.ts';
|
|
10
|
+
import { stopIosRunnerSession } from './platforms/ios/runner-client.ts';
|
|
11
|
+
import type { DaemonRequest, DaemonResponse } from './daemon/types.ts';
|
|
12
|
+
import { SessionStore } from './daemon/session-store.ts';
|
|
13
|
+
import { contextFromFlags as contextFromFlagsWithLog, type DaemonCommandContext } from './daemon/context.ts';
|
|
14
|
+
import { handleSessionCommands } from './daemon/handlers/session.ts';
|
|
15
|
+
import { handleSnapshotCommands } from './daemon/handlers/snapshot.ts';
|
|
16
|
+
import { handleFindCommands } from './daemon/handlers/find.ts';
|
|
17
|
+
import { handleRecordTraceCommands } from './daemon/handlers/record-trace.ts';
|
|
18
|
+
import { handleInteractionCommands } from './daemon/handlers/interaction.ts';
|
|
19
|
+
import { assertSessionSelectorMatches } from './daemon/session-selector.ts';
|
|
20
|
+
import { resolveEffectiveSessionName } from './daemon/session-routing.ts';
|
|
22
21
|
|
|
23
|
-
type DaemonRequest = {
|
|
24
|
-
token: string;
|
|
25
|
-
session: string;
|
|
26
|
-
command: string;
|
|
27
|
-
positionals: string[];
|
|
28
|
-
flags?: CommandFlags;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
type DaemonResponse =
|
|
32
|
-
| { ok: true; data?: Record<string, unknown> }
|
|
33
|
-
| { ok: false; error: { code: string; message: string; details?: Record<string, unknown> } };
|
|
34
|
-
|
|
35
|
-
type SessionState = {
|
|
36
|
-
name: string;
|
|
37
|
-
device: DeviceInfo;
|
|
38
|
-
createdAt: number;
|
|
39
|
-
appBundleId?: string;
|
|
40
|
-
appName?: string;
|
|
41
|
-
snapshot?: SnapshotState;
|
|
42
|
-
trace?: {
|
|
43
|
-
outPath: string;
|
|
44
|
-
startedAt: number;
|
|
45
|
-
};
|
|
46
|
-
actions: SessionAction[];
|
|
47
|
-
recording?: {
|
|
48
|
-
platform: 'ios' | 'android';
|
|
49
|
-
outPath: string;
|
|
50
|
-
remotePath?: string;
|
|
51
|
-
child: ReturnType<typeof import('node:child_process').spawn>;
|
|
52
|
-
wait: Promise<import('./utils/exec.ts').ExecResult>;
|
|
53
|
-
};
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
type SessionAction = {
|
|
57
|
-
ts: number;
|
|
58
|
-
command: string;
|
|
59
|
-
positionals: string[];
|
|
60
|
-
flags: Partial<CommandFlags> & {
|
|
61
|
-
snapshotInteractiveOnly?: boolean;
|
|
62
|
-
snapshotCompact?: boolean;
|
|
63
|
-
snapshotDepth?: number;
|
|
64
|
-
snapshotScope?: string;
|
|
65
|
-
snapshotRaw?: boolean;
|
|
66
|
-
snapshotBackend?: 'ax' | 'xctest';
|
|
67
|
-
noRecord?: boolean;
|
|
68
|
-
recordJson?: boolean;
|
|
69
|
-
};
|
|
70
|
-
result?: Record<string, unknown>;
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
const sessions = new Map<string, SessionState>();
|
|
74
22
|
const baseDir = path.join(os.homedir(), '.agent-device');
|
|
75
23
|
const infoPath = path.join(baseDir, 'daemon.json');
|
|
76
24
|
const logPath = path.join(baseDir, 'daemon.log');
|
|
77
25
|
const sessionsDir = path.join(baseDir, 'sessions');
|
|
26
|
+
const sessionStore = new SessionStore(sessionsDir);
|
|
78
27
|
const version = readVersion();
|
|
79
28
|
const token = crypto.randomBytes(24).toString('hex');
|
|
29
|
+
const selectorValidationExemptCommands = new Set(['session_list', 'devices']);
|
|
80
30
|
|
|
81
31
|
function contextFromFlags(
|
|
82
32
|
flags: CommandFlags | undefined,
|
|
83
33
|
appBundleId?: string,
|
|
84
34
|
traceLogPath?: string,
|
|
85
|
-
): {
|
|
86
|
-
appBundleId
|
|
87
|
-
activity?: string;
|
|
88
|
-
verbose?: boolean;
|
|
89
|
-
logPath?: string;
|
|
90
|
-
traceLogPath?: string;
|
|
91
|
-
snapshotInteractiveOnly?: boolean;
|
|
92
|
-
snapshotCompact?: boolean;
|
|
93
|
-
snapshotDepth?: number;
|
|
94
|
-
snapshotScope?: string;
|
|
95
|
-
snapshotBackend?: 'ax' | 'xctest';
|
|
96
|
-
snapshotRaw?: boolean;
|
|
97
|
-
} {
|
|
98
|
-
return {
|
|
99
|
-
appBundleId,
|
|
100
|
-
activity: flags?.activity,
|
|
101
|
-
verbose: flags?.verbose,
|
|
102
|
-
logPath,
|
|
103
|
-
traceLogPath,
|
|
104
|
-
snapshotInteractiveOnly: flags?.snapshotInteractiveOnly,
|
|
105
|
-
snapshotCompact: flags?.snapshotCompact,
|
|
106
|
-
snapshotDepth: flags?.snapshotDepth,
|
|
107
|
-
snapshotScope: flags?.snapshotScope,
|
|
108
|
-
snapshotRaw: flags?.snapshotRaw,
|
|
109
|
-
snapshotBackend: flags?.snapshotBackend,
|
|
110
|
-
};
|
|
35
|
+
): DaemonCommandContext {
|
|
36
|
+
return contextFromFlagsWithLog(logPath, flags, appBundleId, traceLogPath);
|
|
111
37
|
}
|
|
112
38
|
|
|
113
39
|
async function handleRequest(req: DaemonRequest): Promise<DaemonResponse> {
|
|
@@ -116,1016 +42,73 @@ async function handleRequest(req: DaemonRequest): Promise<DaemonResponse> {
|
|
|
116
42
|
}
|
|
117
43
|
|
|
118
44
|
const command = req.command;
|
|
119
|
-
const sessionName = req
|
|
120
|
-
|
|
121
|
-
if (
|
|
122
|
-
|
|
123
|
-
sessions: Array.from(sessions.values()).map((s) => ({
|
|
124
|
-
name: s.name,
|
|
125
|
-
platform: s.device.platform,
|
|
126
|
-
device: s.device.name,
|
|
127
|
-
id: s.device.id,
|
|
128
|
-
createdAt: s.createdAt,
|
|
129
|
-
})),
|
|
130
|
-
};
|
|
131
|
-
return { ok: true, data };
|
|
45
|
+
const sessionName = resolveEffectiveSessionName(req, sessionStore);
|
|
46
|
+
const existingSession = sessionStore.get(sessionName);
|
|
47
|
+
if (existingSession && !selectorValidationExemptCommands.has(command)) {
|
|
48
|
+
assertSessionSelectorMatches(existingSession, req.flags);
|
|
132
49
|
}
|
|
133
50
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
devices.push(...(await listIosDevices()));
|
|
143
|
-
} else {
|
|
144
|
-
const { listAndroidDevices } = await import('./platforms/android/devices.ts');
|
|
145
|
-
const { listIosDevices } = await import('./platforms/ios/devices.ts');
|
|
146
|
-
try {
|
|
147
|
-
devices.push(...(await listAndroidDevices()));
|
|
148
|
-
} catch {
|
|
149
|
-
// ignore
|
|
150
|
-
}
|
|
151
|
-
try {
|
|
152
|
-
devices.push(...(await listIosDevices()));
|
|
153
|
-
} catch {
|
|
154
|
-
// ignore
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
return { ok: true, data: { devices } };
|
|
158
|
-
} catch (err) {
|
|
159
|
-
const appErr = asAppError(err);
|
|
160
|
-
return { ok: false, error: { code: appErr.code, message: appErr.message, details: appErr.details } };
|
|
161
|
-
}
|
|
162
|
-
}
|
|
51
|
+
const sessionResponse = await handleSessionCommands({
|
|
52
|
+
req,
|
|
53
|
+
sessionName,
|
|
54
|
+
logPath,
|
|
55
|
+
sessionStore,
|
|
56
|
+
invoke: handleRequest,
|
|
57
|
+
});
|
|
58
|
+
if (sessionResponse) return sessionResponse;
|
|
163
59
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
!flags.udid &&
|
|
172
|
-
!flags.serial
|
|
173
|
-
) {
|
|
174
|
-
return {
|
|
175
|
-
ok: false,
|
|
176
|
-
error: {
|
|
177
|
-
code: 'INVALID_ARGS',
|
|
178
|
-
message: 'apps requires an active session or an explicit device selector (e.g. --platform ios).',
|
|
179
|
-
},
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
const device = session?.device ?? (await resolveTargetDevice(flags));
|
|
183
|
-
await ensureDeviceReady(device);
|
|
184
|
-
if (device.platform === 'ios') {
|
|
185
|
-
if (device.kind !== 'simulator') {
|
|
186
|
-
return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'apps list is only supported on iOS simulators' } };
|
|
187
|
-
}
|
|
188
|
-
const { listSimulatorApps } = await import('./platforms/ios/index.ts');
|
|
189
|
-
const apps = await listSimulatorApps(device);
|
|
190
|
-
if (req.flags?.appsMetadata) {
|
|
191
|
-
return { ok: true, data: { apps } };
|
|
192
|
-
}
|
|
193
|
-
const formatted = apps.map((app) =>
|
|
194
|
-
app.name && app.name !== app.bundleId ? `${app.name} (${app.bundleId})` : app.bundleId,
|
|
195
|
-
);
|
|
196
|
-
return { ok: true, data: { apps: formatted } };
|
|
197
|
-
}
|
|
198
|
-
const { listAndroidApps, listAndroidAppsMetadata } = await import('./platforms/android/index.ts');
|
|
199
|
-
if (req.flags?.appsMetadata) {
|
|
200
|
-
const apps = await listAndroidAppsMetadata(device, req.flags?.appsFilter);
|
|
201
|
-
return { ok: true, data: { apps } };
|
|
202
|
-
}
|
|
203
|
-
const apps = await listAndroidApps(device, req.flags?.appsFilter);
|
|
204
|
-
return { ok: true, data: { apps } };
|
|
205
|
-
}
|
|
60
|
+
const snapshotResponse = await handleSnapshotCommands({
|
|
61
|
+
req,
|
|
62
|
+
sessionName,
|
|
63
|
+
logPath,
|
|
64
|
+
sessionStore,
|
|
65
|
+
});
|
|
66
|
+
if (snapshotResponse) return snapshotResponse;
|
|
206
67
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if (session?.appBundleId) {
|
|
214
|
-
return {
|
|
215
|
-
ok: true,
|
|
216
|
-
data: {
|
|
217
|
-
platform: 'ios',
|
|
218
|
-
appBundleId: session.appBundleId,
|
|
219
|
-
appName: session.appName ?? session.appBundleId,
|
|
220
|
-
source: 'session',
|
|
221
|
-
},
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
const snapshotResult = await resolveIosAppStateFromSnapshots(device, session?.trace?.outPath, req.flags);
|
|
225
|
-
return {
|
|
226
|
-
ok: true,
|
|
227
|
-
data: {
|
|
228
|
-
platform: 'ios',
|
|
229
|
-
appName: snapshotResult.appName,
|
|
230
|
-
appBundleId: snapshotResult.appBundleId,
|
|
231
|
-
source: snapshotResult.source,
|
|
232
|
-
},
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
const { getAndroidAppState } = await import('./platforms/android/index.ts');
|
|
236
|
-
const state = await getAndroidAppState(device);
|
|
237
|
-
return {
|
|
238
|
-
ok: true,
|
|
239
|
-
data: {
|
|
240
|
-
platform: 'android',
|
|
241
|
-
package: state.package,
|
|
242
|
-
activity: state.activity,
|
|
243
|
-
},
|
|
244
|
-
};
|
|
245
|
-
}
|
|
68
|
+
const recordTraceResponse = await handleRecordTraceCommands({
|
|
69
|
+
req,
|
|
70
|
+
sessionName,
|
|
71
|
+
sessionStore,
|
|
72
|
+
});
|
|
73
|
+
if (recordTraceResponse) return recordTraceResponse;
|
|
246
74
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
code: 'INVALID_ARGS',
|
|
256
|
-
message: 'Session already active. Close it first or pass a new --session name.',
|
|
257
|
-
},
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
let appBundleId: string | undefined;
|
|
261
|
-
if (session.device.platform === 'ios') {
|
|
262
|
-
try {
|
|
263
|
-
const { resolveIosApp } = await import('./platforms/ios/index.ts');
|
|
264
|
-
appBundleId = await resolveIosApp(session.device, appName);
|
|
265
|
-
} catch {
|
|
266
|
-
appBundleId = undefined;
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
await dispatchCommand(session.device, 'open', req.positionals ?? [], req.flags?.out, {
|
|
270
|
-
...contextFromFlags(req.flags, appBundleId),
|
|
271
|
-
});
|
|
272
|
-
const nextSession: SessionState = {
|
|
273
|
-
...session,
|
|
274
|
-
appBundleId,
|
|
275
|
-
appName,
|
|
276
|
-
snapshot: undefined,
|
|
277
|
-
};
|
|
278
|
-
recordAction(nextSession, {
|
|
279
|
-
command,
|
|
280
|
-
positionals: req.positionals ?? [],
|
|
281
|
-
flags: req.flags ?? {},
|
|
282
|
-
result: { session: sessionName, appName, appBundleId },
|
|
283
|
-
});
|
|
284
|
-
sessions.set(sessionName, nextSession);
|
|
285
|
-
return { ok: true, data: { session: sessionName, appName, appBundleId } };
|
|
286
|
-
}
|
|
287
|
-
const device = await resolveTargetDevice(req.flags ?? {});
|
|
288
|
-
await ensureDeviceReady(device);
|
|
289
|
-
const inUse = Array.from(sessions.values()).find((s) => s.device.id === device.id);
|
|
290
|
-
if (inUse) {
|
|
291
|
-
return {
|
|
292
|
-
ok: false,
|
|
293
|
-
error: {
|
|
294
|
-
code: 'DEVICE_IN_USE',
|
|
295
|
-
message: `Device is already in use by session "${inUse.name}".`,
|
|
296
|
-
details: { session: inUse.name, deviceId: device.id, deviceName: device.name },
|
|
297
|
-
},
|
|
298
|
-
};
|
|
299
|
-
}
|
|
300
|
-
let appBundleId: string | undefined;
|
|
301
|
-
const appName = req.positionals?.[0];
|
|
302
|
-
if (device.platform === 'ios') {
|
|
303
|
-
try {
|
|
304
|
-
const { resolveIosApp } = await import('./platforms/ios/index.ts');
|
|
305
|
-
appBundleId = await resolveIosApp(device, req.positionals?.[0] ?? '');
|
|
306
|
-
} catch {
|
|
307
|
-
appBundleId = undefined;
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
await dispatchCommand(device, 'open', req.positionals ?? [], req.flags?.out, {
|
|
311
|
-
...contextFromFlags(req.flags, appBundleId),
|
|
312
|
-
});
|
|
313
|
-
const session: SessionState = {
|
|
314
|
-
name: sessionName,
|
|
315
|
-
device,
|
|
316
|
-
createdAt: Date.now(),
|
|
317
|
-
appBundleId,
|
|
318
|
-
appName,
|
|
319
|
-
actions: [],
|
|
320
|
-
};
|
|
321
|
-
recordAction(session, {
|
|
322
|
-
command,
|
|
323
|
-
positionals: req.positionals ?? [],
|
|
324
|
-
flags: req.flags ?? {},
|
|
325
|
-
result: { session: sessionName },
|
|
326
|
-
});
|
|
327
|
-
sessions.set(sessionName, session);
|
|
328
|
-
return { ok: true, data: { session: sessionName } };
|
|
329
|
-
}
|
|
75
|
+
const findResponse = await handleFindCommands({
|
|
76
|
+
req,
|
|
77
|
+
sessionName,
|
|
78
|
+
logPath,
|
|
79
|
+
sessionStore,
|
|
80
|
+
invoke: handleRequest,
|
|
81
|
+
});
|
|
82
|
+
if (findResponse) return findResponse;
|
|
330
83
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const payload = JSON.parse(fs.readFileSync(resolved, 'utf8')) as {
|
|
339
|
-
actions?: SessionAction[];
|
|
340
|
-
optimizedActions?: SessionAction[];
|
|
341
|
-
};
|
|
342
|
-
const actions = payload.optimizedActions ?? payload.actions ?? [];
|
|
343
|
-
for (const action of actions) {
|
|
344
|
-
if (!action || action.command === 'replay') continue;
|
|
345
|
-
await handleRequest({
|
|
346
|
-
token,
|
|
347
|
-
session: sessionName,
|
|
348
|
-
command: action.command,
|
|
349
|
-
positionals: action.positionals ?? [],
|
|
350
|
-
flags: action.flags ?? {},
|
|
351
|
-
});
|
|
352
|
-
}
|
|
353
|
-
return { ok: true, data: { replayed: actions.length, session: sessionName } };
|
|
354
|
-
} catch (err) {
|
|
355
|
-
const appErr = asAppError(err);
|
|
356
|
-
return { ok: false, error: { code: appErr.code, message: appErr.message } };
|
|
357
|
-
}
|
|
358
|
-
}
|
|
84
|
+
const interactionResponse = await handleInteractionCommands({
|
|
85
|
+
req,
|
|
86
|
+
sessionName,
|
|
87
|
+
sessionStore,
|
|
88
|
+
contextFromFlags,
|
|
89
|
+
});
|
|
90
|
+
if (interactionResponse) return interactionResponse;
|
|
359
91
|
|
|
360
|
-
if (command === 'close') {
|
|
361
|
-
const session = sessions.get(sessionName);
|
|
362
|
-
if (!session) {
|
|
363
|
-
return { ok: false, error: { code: 'SESSION_NOT_FOUND', message: 'No active session' } };
|
|
364
|
-
}
|
|
365
|
-
if (req.positionals && req.positionals.length > 0) {
|
|
366
|
-
await dispatchCommand(session.device, 'close', req.positionals ?? [], req.flags?.out, {
|
|
367
|
-
...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath),
|
|
368
|
-
});
|
|
369
|
-
}
|
|
370
|
-
if (session.device.platform === 'ios' && session.device.kind === 'simulator') {
|
|
371
|
-
await stopIosRunnerSession(session.device.id);
|
|
372
|
-
}
|
|
373
|
-
recordAction(session, {
|
|
374
|
-
command,
|
|
375
|
-
positionals: req.positionals ?? [],
|
|
376
|
-
flags: req.flags ?? {},
|
|
377
|
-
result: { session: sessionName },
|
|
378
|
-
});
|
|
379
|
-
writeSessionLog(session);
|
|
380
|
-
sessions.delete(sessionName);
|
|
381
|
-
return { ok: true, data: { session: sessionName } };
|
|
382
|
-
}
|
|
383
92
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
const device = session?.device ?? (await resolveTargetDevice(req.flags ?? {}));
|
|
387
|
-
if (!session) {
|
|
388
|
-
await ensureDeviceReady(device);
|
|
389
|
-
}
|
|
390
|
-
const appBundleId = session?.appBundleId;
|
|
391
|
-
let snapshotScope = req.flags?.snapshotScope;
|
|
392
|
-
if (snapshotScope && snapshotScope.trim().startsWith('@')) {
|
|
393
|
-
if (!session?.snapshot) {
|
|
394
|
-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'Ref scope requires an existing snapshot in session.' } };
|
|
395
|
-
}
|
|
396
|
-
const ref = normalizeRef(snapshotScope.trim());
|
|
397
|
-
if (!ref) {
|
|
398
|
-
return { ok: false, error: { code: 'INVALID_ARGS', message: `Invalid ref scope: ${snapshotScope}` } };
|
|
399
|
-
}
|
|
400
|
-
const node = findNodeByRef(session.snapshot.nodes, ref);
|
|
401
|
-
const resolved = node ? resolveRefLabel(node, session.snapshot.nodes) : undefined;
|
|
402
|
-
if (!resolved) {
|
|
403
|
-
return { ok: false, error: { code: 'COMMAND_FAILED', message: `Ref ${snapshotScope} not found or has no label` } };
|
|
404
|
-
}
|
|
405
|
-
snapshotScope = resolved;
|
|
406
|
-
}
|
|
407
|
-
const data = (await dispatchCommand(device, 'snapshot', [], req.flags?.out, {
|
|
408
|
-
...contextFromFlags({ ...req.flags, snapshotScope }, appBundleId, session?.trace?.outPath),
|
|
409
|
-
})) as {
|
|
410
|
-
nodes?: RawSnapshotNode[];
|
|
411
|
-
truncated?: boolean;
|
|
412
|
-
backend?: 'ax' | 'xctest' | 'android';
|
|
413
|
-
};
|
|
414
|
-
const rawNodes = data?.nodes ?? [];
|
|
415
|
-
const nodes = attachRefs(req.flags?.snapshotRaw ? rawNodes : pruneGroupNodes(rawNodes));
|
|
416
|
-
const snapshot: SnapshotState = {
|
|
417
|
-
nodes,
|
|
418
|
-
truncated: data?.truncated,
|
|
419
|
-
createdAt: Date.now(),
|
|
420
|
-
backend: data?.backend,
|
|
421
|
-
};
|
|
422
|
-
const nextSession: SessionState = {
|
|
423
|
-
name: sessionName,
|
|
424
|
-
device,
|
|
425
|
-
createdAt: session?.createdAt ?? Date.now(),
|
|
426
|
-
appBundleId: session?.appBundleId ?? appBundleId,
|
|
427
|
-
snapshot,
|
|
428
|
-
actions: session?.actions ?? [],
|
|
429
|
-
appName: session?.appName,
|
|
430
|
-
};
|
|
431
|
-
recordAction(nextSession, {
|
|
432
|
-
command,
|
|
433
|
-
positionals: req.positionals ?? [],
|
|
434
|
-
flags: req.flags ?? {},
|
|
435
|
-
result: { nodes: nodes.length, truncated: data?.truncated ?? false },
|
|
436
|
-
});
|
|
437
|
-
sessions.set(sessionName, nextSession);
|
|
93
|
+
const session = sessionStore.get(sessionName);
|
|
94
|
+
if (!session) {
|
|
438
95
|
return {
|
|
439
|
-
ok:
|
|
440
|
-
|
|
441
|
-
nodes,
|
|
442
|
-
truncated: data?.truncated ?? false,
|
|
443
|
-
appName: nextSession.appBundleId ? (nextSession.appName ?? nextSession.appBundleId) : undefined,
|
|
444
|
-
appBundleId: nextSession.appBundleId,
|
|
445
|
-
},
|
|
446
|
-
};
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
if (command === 'wait') {
|
|
450
|
-
const session = sessions.get(sessionName);
|
|
451
|
-
const device = session?.device ?? (await resolveTargetDevice(req.flags ?? {}));
|
|
452
|
-
if (!session) {
|
|
453
|
-
await ensureDeviceReady(device);
|
|
454
|
-
}
|
|
455
|
-
const args = req.positionals ?? [];
|
|
456
|
-
if (args.length === 0) {
|
|
457
|
-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'wait requires a duration or text' } };
|
|
458
|
-
}
|
|
459
|
-
const parseTimeout = (value: string | undefined): number | null => {
|
|
460
|
-
if (!value) return null;
|
|
461
|
-
const parsed = Number(value);
|
|
462
|
-
return Number.isFinite(parsed) ? parsed : null;
|
|
463
|
-
};
|
|
464
|
-
const sleepMs = parseTimeout(args[0]);
|
|
465
|
-
if (sleepMs !== null) {
|
|
466
|
-
await new Promise((resolve) => setTimeout(resolve, sleepMs));
|
|
467
|
-
if (session) {
|
|
468
|
-
recordAction(session, { command, positionals: req.positionals ?? [], flags: req.flags ?? {}, result: { waitedMs: sleepMs } });
|
|
469
|
-
}
|
|
470
|
-
return { ok: true, data: { waitedMs: sleepMs } };
|
|
471
|
-
}
|
|
472
|
-
let text = '';
|
|
473
|
-
let timeoutMs: number | null = null;
|
|
474
|
-
if (args[0] === 'text') {
|
|
475
|
-
timeoutMs = parseTimeout(args[args.length - 1]);
|
|
476
|
-
text = timeoutMs !== null ? args.slice(1, -1).join(' ') : args.slice(1).join(' ');
|
|
477
|
-
} else if (args[0].startsWith('@')) {
|
|
478
|
-
if (!session?.snapshot) {
|
|
479
|
-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'Ref wait requires an existing snapshot in session.' } };
|
|
480
|
-
}
|
|
481
|
-
const ref = normalizeRef(args[0]);
|
|
482
|
-
if (!ref) {
|
|
483
|
-
return { ok: false, error: { code: 'INVALID_ARGS', message: `Invalid ref: ${args[0]}` } };
|
|
484
|
-
}
|
|
485
|
-
const node = findNodeByRef(session.snapshot.nodes, ref);
|
|
486
|
-
const resolved = node ? resolveRefLabel(node, session.snapshot.nodes) : undefined;
|
|
487
|
-
if (!resolved) {
|
|
488
|
-
return { ok: false, error: { code: 'COMMAND_FAILED', message: `Ref ${args[0]} not found or has no label` } };
|
|
489
|
-
}
|
|
490
|
-
timeoutMs = parseTimeout(args[args.length - 1]);
|
|
491
|
-
text = resolved;
|
|
492
|
-
} else {
|
|
493
|
-
timeoutMs = parseTimeout(args[args.length - 1]);
|
|
494
|
-
text = timeoutMs !== null ? args.slice(0, -1).join(' ') : args.join(' ');
|
|
495
|
-
}
|
|
496
|
-
text = text.trim();
|
|
497
|
-
if (!text) {
|
|
498
|
-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'wait requires text' } };
|
|
499
|
-
}
|
|
500
|
-
const timeout = timeoutMs ?? 10000;
|
|
501
|
-
const start = Date.now();
|
|
502
|
-
while (Date.now() - start < timeout) {
|
|
503
|
-
if (device.platform === 'ios' && device.kind === 'simulator') {
|
|
504
|
-
const result = (await runIosRunnerCommand(
|
|
505
|
-
device,
|
|
506
|
-
{ command: 'findText', text, appBundleId: session?.appBundleId },
|
|
507
|
-
{ verbose: req.flags?.verbose, logPath, traceLogPath: session?.trace?.outPath },
|
|
508
|
-
)) as { found?: boolean };
|
|
509
|
-
if (result?.found) {
|
|
510
|
-
if (session) {
|
|
511
|
-
recordAction(session, { command, positionals: req.positionals ?? [], flags: req.flags ?? {}, result: { text, waitedMs: Date.now() - start } });
|
|
512
|
-
}
|
|
513
|
-
return { ok: true, data: { text, waitedMs: Date.now() - start } };
|
|
514
|
-
}
|
|
515
|
-
} else if (device.platform === 'android') {
|
|
516
|
-
const androidResult = await snapshotAndroid(device, { scope: text });
|
|
517
|
-
if (findNodeByLabel(attachRefs(androidResult.nodes ?? []), text)) {
|
|
518
|
-
if (session) {
|
|
519
|
-
recordAction(session, { command, positionals: req.positionals ?? [], flags: req.flags ?? {}, result: { text, waitedMs: Date.now() - start } });
|
|
520
|
-
}
|
|
521
|
-
return { ok: true, data: { text, waitedMs: Date.now() - start } };
|
|
522
|
-
}
|
|
523
|
-
} else {
|
|
524
|
-
return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'wait is not supported on this device' } };
|
|
525
|
-
}
|
|
526
|
-
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
527
|
-
}
|
|
528
|
-
return { ok: false, error: { code: 'COMMAND_FAILED', message: `wait timed out for text: ${text}` } };
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
if (command === 'alert') {
|
|
532
|
-
const session = sessions.get(sessionName);
|
|
533
|
-
const device = session?.device ?? (await resolveTargetDevice(req.flags ?? {}));
|
|
534
|
-
if (!session) {
|
|
535
|
-
await ensureDeviceReady(device);
|
|
536
|
-
}
|
|
537
|
-
const action = (req.positionals?.[0] ?? 'get').toLowerCase();
|
|
538
|
-
const parseTimeout = (value: string | undefined): number | null => {
|
|
539
|
-
if (!value) return null;
|
|
540
|
-
const parsed = Number(value);
|
|
541
|
-
return Number.isFinite(parsed) ? parsed : null;
|
|
542
|
-
};
|
|
543
|
-
if (device.platform !== 'ios' || device.kind !== 'simulator') {
|
|
544
|
-
return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'alert is only supported on iOS simulators in v1' } };
|
|
545
|
-
}
|
|
546
|
-
if (action === 'wait') {
|
|
547
|
-
const timeout = parseTimeout(req.positionals?.[1]) ?? 10000;
|
|
548
|
-
const start = Date.now();
|
|
549
|
-
while (Date.now() - start < timeout) {
|
|
550
|
-
try {
|
|
551
|
-
const data = await runIosRunnerCommand(
|
|
552
|
-
device,
|
|
553
|
-
{ command: 'alert', action: 'get', appBundleId: session?.appBundleId },
|
|
554
|
-
{ verbose: req.flags?.verbose, logPath, traceLogPath: session?.trace?.outPath },
|
|
555
|
-
);
|
|
556
|
-
if (session) {
|
|
557
|
-
recordAction(session, {
|
|
558
|
-
command,
|
|
559
|
-
positionals: req.positionals ?? [],
|
|
560
|
-
flags: req.flags ?? {},
|
|
561
|
-
result: data,
|
|
562
|
-
});
|
|
563
|
-
}
|
|
564
|
-
return { ok: true, data };
|
|
565
|
-
} catch {
|
|
566
|
-
// keep waiting
|
|
567
|
-
}
|
|
568
|
-
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
569
|
-
}
|
|
570
|
-
return { ok: false, error: { code: 'COMMAND_FAILED', message: 'alert wait timed out' } };
|
|
571
|
-
}
|
|
572
|
-
const data = await runIosRunnerCommand(
|
|
573
|
-
device,
|
|
574
|
-
{
|
|
575
|
-
command: 'alert',
|
|
576
|
-
action: action === 'accept' || action === 'dismiss' ? (action as 'accept' | 'dismiss') : 'get',
|
|
577
|
-
appBundleId: session?.appBundleId,
|
|
578
|
-
},
|
|
579
|
-
{ verbose: req.flags?.verbose, logPath, traceLogPath: session?.trace?.outPath },
|
|
580
|
-
);
|
|
581
|
-
if (session) {
|
|
582
|
-
recordAction(session, {
|
|
583
|
-
command,
|
|
584
|
-
positionals: req.positionals ?? [],
|
|
585
|
-
flags: req.flags ?? {},
|
|
586
|
-
result: data,
|
|
587
|
-
});
|
|
588
|
-
}
|
|
589
|
-
return { ok: true, data };
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
if (command === 'record') {
|
|
593
|
-
const action = (req.positionals?.[0] ?? '').toLowerCase();
|
|
594
|
-
if (!['start', 'stop'].includes(action)) {
|
|
595
|
-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'record requires start|stop' } };
|
|
596
|
-
}
|
|
597
|
-
const session = sessions.get(sessionName);
|
|
598
|
-
const device = session?.device ?? (await resolveTargetDevice(req.flags ?? {}));
|
|
599
|
-
if (!session) {
|
|
600
|
-
await ensureDeviceReady(device);
|
|
601
|
-
}
|
|
602
|
-
const activeSession = session ?? {
|
|
603
|
-
name: sessionName,
|
|
604
|
-
device,
|
|
605
|
-
createdAt: Date.now(),
|
|
606
|
-
actions: [],
|
|
96
|
+
ok: false,
|
|
97
|
+
error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' },
|
|
607
98
|
};
|
|
608
|
-
|
|
609
|
-
if (action === 'start') {
|
|
610
|
-
if (activeSession.recording) {
|
|
611
|
-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'recording already in progress' } };
|
|
612
|
-
}
|
|
613
|
-
const outPath = req.positionals?.[1] ?? `./recording-${Date.now()}.mp4`;
|
|
614
|
-
const resolvedOut = path.resolve(outPath);
|
|
615
|
-
const outDir = path.dirname(resolvedOut);
|
|
616
|
-
if (!fs.existsSync(outDir)) {
|
|
617
|
-
fs.mkdirSync(outDir, { recursive: true });
|
|
618
|
-
}
|
|
619
|
-
if (device.platform === 'ios') {
|
|
620
|
-
if (device.kind !== 'simulator') {
|
|
621
|
-
return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'record is only supported on iOS simulators in v1' } };
|
|
622
|
-
}
|
|
623
|
-
const { child, wait } = runCmdBackground('xcrun', ['simctl', 'io', device.id, 'recordVideo', resolvedOut], {
|
|
624
|
-
allowFailure: true,
|
|
625
|
-
});
|
|
626
|
-
activeSession.recording = { platform: 'ios', outPath: resolvedOut, child, wait };
|
|
627
|
-
} else {
|
|
628
|
-
const remotePath = `/sdcard/agent-device-recording-${Date.now()}.mp4`;
|
|
629
|
-
const { child, wait } = runCmdBackground('adb', ['-s', device.id, 'shell', 'screenrecord', remotePath], {
|
|
630
|
-
allowFailure: true,
|
|
631
|
-
});
|
|
632
|
-
activeSession.recording = { platform: 'android', outPath: resolvedOut, remotePath, child, wait };
|
|
633
|
-
}
|
|
634
|
-
sessions.set(sessionName, activeSession);
|
|
635
|
-
recordAction(activeSession, {
|
|
636
|
-
command,
|
|
637
|
-
positionals: req.positionals ?? [],
|
|
638
|
-
flags: req.flags ?? {},
|
|
639
|
-
result: { action: 'start' },
|
|
640
|
-
});
|
|
641
|
-
return { ok: true, data: { recording: 'started', outPath } };
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
if (!activeSession.recording) {
|
|
645
|
-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'no active recording' } };
|
|
646
|
-
}
|
|
647
|
-
const recording = activeSession.recording;
|
|
648
|
-
recording.child.kill('SIGINT');
|
|
649
|
-
try {
|
|
650
|
-
await recording.wait;
|
|
651
|
-
} catch {
|
|
652
|
-
// ignore
|
|
653
|
-
}
|
|
654
|
-
if (recording.platform === 'android' && recording.remotePath) {
|
|
655
|
-
try {
|
|
656
|
-
await runCmd('adb', ['-s', device.id, 'pull', recording.remotePath, recording.outPath], { allowFailure: true });
|
|
657
|
-
await runCmd('adb', ['-s', device.id, 'shell', 'rm', '-f', recording.remotePath], { allowFailure: true });
|
|
658
|
-
} catch {
|
|
659
|
-
// ignore
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
activeSession.recording = undefined;
|
|
663
|
-
recordAction(activeSession, {
|
|
664
|
-
command,
|
|
665
|
-
positionals: req.positionals ?? [],
|
|
666
|
-
flags: req.flags ?? {},
|
|
667
|
-
result: { action: 'stop', outPath: recording.outPath },
|
|
668
|
-
});
|
|
669
|
-
return { ok: true, data: { recording: 'stopped', outPath: recording.outPath } };
|
|
670
99
|
}
|
|
671
100
|
|
|
672
|
-
if (command
|
|
673
|
-
const action = (req.positionals?.[0] ?? '').toLowerCase();
|
|
674
|
-
if (!['start', 'stop'].includes(action)) {
|
|
675
|
-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'trace requires start|stop' } };
|
|
676
|
-
}
|
|
677
|
-
const session = sessions.get(sessionName);
|
|
678
|
-
if (!session) {
|
|
679
|
-
return { ok: false, error: { code: 'SESSION_NOT_FOUND', message: 'No active session' } };
|
|
680
|
-
}
|
|
681
|
-
if (action === 'start') {
|
|
682
|
-
if (session.trace) {
|
|
683
|
-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'trace already in progress' } };
|
|
684
|
-
}
|
|
685
|
-
const outPath = req.positionals?.[1] ?? defaultTracePath(session);
|
|
686
|
-
const resolvedOut = expandHome(outPath);
|
|
687
|
-
fs.mkdirSync(path.dirname(resolvedOut), { recursive: true });
|
|
688
|
-
fs.appendFileSync(resolvedOut, '');
|
|
689
|
-
session.trace = { outPath: resolvedOut, startedAt: Date.now() };
|
|
690
|
-
recordAction(session, {
|
|
691
|
-
command,
|
|
692
|
-
positionals: req.positionals ?? [],
|
|
693
|
-
flags: req.flags ?? {},
|
|
694
|
-
result: { action: 'start', outPath: resolvedOut },
|
|
695
|
-
});
|
|
696
|
-
return { ok: true, data: { trace: 'started', outPath: resolvedOut } };
|
|
697
|
-
}
|
|
698
|
-
if (!session.trace) {
|
|
699
|
-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'no active trace' } };
|
|
700
|
-
}
|
|
701
|
-
let outPath = session.trace.outPath;
|
|
702
|
-
if (req.positionals?.[1]) {
|
|
703
|
-
const resolvedOut = expandHome(req.positionals[1]);
|
|
704
|
-
fs.mkdirSync(path.dirname(resolvedOut), { recursive: true });
|
|
705
|
-
if (fs.existsSync(outPath)) {
|
|
706
|
-
fs.renameSync(outPath, resolvedOut);
|
|
707
|
-
} else {
|
|
708
|
-
fs.appendFileSync(resolvedOut, '');
|
|
709
|
-
}
|
|
710
|
-
outPath = resolvedOut;
|
|
711
|
-
}
|
|
712
|
-
session.trace = undefined;
|
|
713
|
-
recordAction(session, {
|
|
714
|
-
command,
|
|
715
|
-
positionals: req.positionals ?? [],
|
|
716
|
-
flags: req.flags ?? {},
|
|
717
|
-
result: { action: 'stop', outPath },
|
|
718
|
-
});
|
|
719
|
-
return { ok: true, data: { trace: 'stopped', outPath } };
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
if (command === 'settings') {
|
|
723
|
-
const setting = req.positionals?.[0];
|
|
724
|
-
const state = req.positionals?.[1];
|
|
725
|
-
if (!setting || !state) {
|
|
726
|
-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'settings requires <wifi|airplane|location> <on|off>' } };
|
|
727
|
-
}
|
|
728
|
-
const session = sessions.get(sessionName);
|
|
729
|
-
const device = session?.device ?? (await resolveTargetDevice(req.flags ?? {}));
|
|
730
|
-
if (!session) {
|
|
731
|
-
await ensureDeviceReady(device);
|
|
732
|
-
}
|
|
733
|
-
const appBundleId = session?.appBundleId;
|
|
734
|
-
const data = await dispatchCommand(
|
|
735
|
-
device,
|
|
736
|
-
'settings',
|
|
737
|
-
[setting, state, appBundleId ?? ''],
|
|
738
|
-
req.flags?.out,
|
|
739
|
-
{ ...contextFromFlags(req.flags, appBundleId, session?.trace?.outPath) },
|
|
740
|
-
);
|
|
741
|
-
if (session) {
|
|
742
|
-
recordAction(session, {
|
|
743
|
-
command,
|
|
744
|
-
positionals: req.positionals ?? [],
|
|
745
|
-
flags: req.flags ?? {},
|
|
746
|
-
result: data ?? { setting, state },
|
|
747
|
-
});
|
|
748
|
-
}
|
|
749
|
-
return { ok: true, data: data ?? { setting, state } };
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
if (command === 'find') {
|
|
753
|
-
const args = req.positionals ?? [];
|
|
754
|
-
if (args.length === 0) {
|
|
755
|
-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'find requires a locator or text' } };
|
|
756
|
-
}
|
|
757
|
-
const { locator, query, action, value, timeoutMs } = parseFindArgs(args);
|
|
758
|
-
if (!query) {
|
|
759
|
-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'find requires a value' } };
|
|
760
|
-
}
|
|
761
|
-
const session = sessions.get(sessionName);
|
|
762
|
-
const isReadOnly = action === 'exists' || action === 'wait' || action === 'get_text' || action === 'get_attrs';
|
|
763
|
-
if (!session && !isReadOnly) {
|
|
764
|
-
return {
|
|
765
|
-
ok: false,
|
|
766
|
-
error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' },
|
|
767
|
-
};
|
|
768
|
-
}
|
|
769
|
-
const device = session?.device ?? (await resolveTargetDevice(req.flags ?? {}));
|
|
770
|
-
if (!session) {
|
|
771
|
-
await ensureDeviceReady(device);
|
|
772
|
-
}
|
|
773
|
-
const appBundleId = session?.appBundleId;
|
|
774
|
-
const scope = shouldScopeFind(locator) ? query : undefined;
|
|
775
|
-
const requiresRect = action === 'click' || action === 'focus' || action === 'fill' || action === 'type';
|
|
776
|
-
const interactiveOnly = requiresRect;
|
|
777
|
-
let lastSnapshotAt = 0;
|
|
778
|
-
let lastNodes: SnapshotState['nodes'] | null = null;
|
|
779
|
-
const fetchNodes = async (): Promise<{
|
|
780
|
-
nodes: SnapshotState['nodes'];
|
|
781
|
-
truncated?: boolean;
|
|
782
|
-
backend?: SnapshotState['backend'];
|
|
783
|
-
}> => {
|
|
784
|
-
const now = Date.now();
|
|
785
|
-
if (lastNodes && now - lastSnapshotAt < 750) {
|
|
786
|
-
return { nodes: lastNodes };
|
|
787
|
-
}
|
|
788
|
-
const data = (await dispatchCommand(device, 'snapshot', [], req.flags?.out, {
|
|
789
|
-
...contextFromFlags(
|
|
790
|
-
{
|
|
791
|
-
...req.flags,
|
|
792
|
-
snapshotScope: scope,
|
|
793
|
-
snapshotInteractiveOnly: interactiveOnly,
|
|
794
|
-
snapshotCompact: interactiveOnly,
|
|
795
|
-
},
|
|
796
|
-
appBundleId,
|
|
797
|
-
session?.trace?.outPath,
|
|
798
|
-
),
|
|
799
|
-
})) as {
|
|
800
|
-
nodes?: RawSnapshotNode[];
|
|
801
|
-
truncated?: boolean;
|
|
802
|
-
backend?: 'ax' | 'xctest' | 'android';
|
|
803
|
-
};
|
|
804
|
-
const rawNodes = data?.nodes ?? [];
|
|
805
|
-
const nodes = attachRefs(req.flags?.snapshotRaw ? rawNodes : pruneGroupNodes(rawNodes));
|
|
806
|
-
lastSnapshotAt = now;
|
|
807
|
-
lastNodes = nodes;
|
|
808
|
-
if (session) {
|
|
809
|
-
const snapshot: SnapshotState = {
|
|
810
|
-
nodes,
|
|
811
|
-
truncated: data?.truncated,
|
|
812
|
-
createdAt: Date.now(),
|
|
813
|
-
backend: data?.backend,
|
|
814
|
-
};
|
|
815
|
-
session.snapshot = snapshot;
|
|
816
|
-
sessions.set(sessionName, session);
|
|
817
|
-
}
|
|
818
|
-
return { nodes, truncated: data?.truncated, backend: data?.backend };
|
|
819
|
-
};
|
|
820
|
-
if (action === 'wait') {
|
|
821
|
-
const timeout = timeoutMs ?? 10000;
|
|
822
|
-
const start = Date.now();
|
|
823
|
-
while (Date.now() - start < timeout) {
|
|
824
|
-
const { nodes } = await fetchNodes();
|
|
825
|
-
const match = findNodeByLocator(nodes, locator, query, { requireRect: false });
|
|
826
|
-
if (match) {
|
|
827
|
-
if (session) {
|
|
828
|
-
recordAction(session, {
|
|
829
|
-
command,
|
|
830
|
-
positionals: req.positionals ?? [],
|
|
831
|
-
flags: req.flags ?? {},
|
|
832
|
-
result: { found: true, waitedMs: Date.now() - start },
|
|
833
|
-
});
|
|
834
|
-
}
|
|
835
|
-
return { ok: true, data: { found: true, waitedMs: Date.now() - start } };
|
|
836
|
-
}
|
|
837
|
-
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
838
|
-
}
|
|
839
|
-
return { ok: false, error: { code: 'COMMAND_FAILED', message: 'find wait timed out' } };
|
|
840
|
-
}
|
|
841
|
-
const { nodes } = await fetchNodes();
|
|
842
|
-
const node = findNodeByLocator(nodes, locator, query, { requireRect: requiresRect });
|
|
843
|
-
if (!node) {
|
|
844
|
-
return { ok: false, error: { code: 'COMMAND_FAILED', message: 'find did not match any element' } };
|
|
845
|
-
}
|
|
846
|
-
const resolvedNode =
|
|
847
|
-
action === 'click' || action === 'focus' || action === 'fill' || action === 'type'
|
|
848
|
-
? findNearestHittableAncestor(nodes, node) ?? node
|
|
849
|
-
: node;
|
|
850
|
-
const ref = `@${resolvedNode.ref}`;
|
|
851
|
-
const actionFlags = { ...(req.flags ?? {}), noRecord: true };
|
|
852
|
-
if (action === 'exists') {
|
|
853
|
-
if (session) {
|
|
854
|
-
recordAction(session, {
|
|
855
|
-
command,
|
|
856
|
-
positionals: req.positionals ?? [],
|
|
857
|
-
flags: req.flags ?? {},
|
|
858
|
-
result: { found: true },
|
|
859
|
-
});
|
|
860
|
-
}
|
|
861
|
-
return { ok: true, data: { found: true } };
|
|
862
|
-
}
|
|
863
|
-
if (action === 'get_text') {
|
|
864
|
-
const text = extractNodeText(node);
|
|
865
|
-
if (session) {
|
|
866
|
-
recordAction(session, {
|
|
867
|
-
command,
|
|
868
|
-
positionals: req.positionals ?? [],
|
|
869
|
-
flags: req.flags ?? {},
|
|
870
|
-
result: { ref, action: 'get text', text },
|
|
871
|
-
});
|
|
872
|
-
}
|
|
873
|
-
return { ok: true, data: { ref, text, node } };
|
|
874
|
-
}
|
|
875
|
-
if (action === 'get_attrs') {
|
|
876
|
-
if (session) {
|
|
877
|
-
recordAction(session, {
|
|
878
|
-
command,
|
|
879
|
-
positionals: req.positionals ?? [],
|
|
880
|
-
flags: req.flags ?? {},
|
|
881
|
-
result: { ref, action: 'get attrs' },
|
|
882
|
-
});
|
|
883
|
-
}
|
|
884
|
-
return { ok: true, data: { ref, node } };
|
|
885
|
-
}
|
|
886
|
-
if (action === 'click') {
|
|
887
|
-
const response = await handleRequest({
|
|
888
|
-
token,
|
|
889
|
-
session: sessionName,
|
|
890
|
-
command: 'click',
|
|
891
|
-
positionals: [ref],
|
|
892
|
-
flags: actionFlags,
|
|
893
|
-
});
|
|
894
|
-
if (!response.ok) return response;
|
|
895
|
-
if (session) {
|
|
896
|
-
recordAction(session, {
|
|
897
|
-
command,
|
|
898
|
-
positionals: req.positionals ?? [],
|
|
899
|
-
flags: req.flags ?? {},
|
|
900
|
-
result: { ref, action: 'click' },
|
|
901
|
-
});
|
|
902
|
-
}
|
|
903
|
-
return response;
|
|
904
|
-
}
|
|
905
|
-
if (action === 'fill') {
|
|
906
|
-
if (!value) {
|
|
907
|
-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'find fill requires text' } };
|
|
908
|
-
}
|
|
909
|
-
const response = await handleRequest({
|
|
910
|
-
token,
|
|
911
|
-
session: sessionName,
|
|
912
|
-
command: 'fill',
|
|
913
|
-
positionals: [ref, value],
|
|
914
|
-
flags: actionFlags,
|
|
915
|
-
});
|
|
916
|
-
if (!response.ok) return response;
|
|
917
|
-
if (session) {
|
|
918
|
-
recordAction(session, {
|
|
919
|
-
command,
|
|
920
|
-
positionals: req.positionals ?? [],
|
|
921
|
-
flags: req.flags ?? {},
|
|
922
|
-
result: { ref, action: 'fill' },
|
|
923
|
-
});
|
|
924
|
-
}
|
|
925
|
-
return response;
|
|
926
|
-
}
|
|
927
|
-
if (action === 'focus') {
|
|
928
|
-
const coords = node.rect ? centerOfRect(node.rect) : null;
|
|
929
|
-
if (!coords) {
|
|
930
|
-
return { ok: false, error: { code: 'COMMAND_FAILED', message: 'matched element has no bounds' } };
|
|
931
|
-
}
|
|
932
|
-
const response = await dispatchCommand(
|
|
933
|
-
device,
|
|
934
|
-
'focus',
|
|
935
|
-
[String(coords.x), String(coords.y)],
|
|
936
|
-
req.flags?.out,
|
|
937
|
-
{ ...contextFromFlags(req.flags, session?.appBundleId, session?.trace?.outPath) },
|
|
938
|
-
);
|
|
939
|
-
if (session) {
|
|
940
|
-
recordAction(session, {
|
|
941
|
-
command,
|
|
942
|
-
positionals: req.positionals ?? [],
|
|
943
|
-
flags: req.flags ?? {},
|
|
944
|
-
result: { ref, action: 'focus' },
|
|
945
|
-
});
|
|
946
|
-
}
|
|
947
|
-
return { ok: true, data: response ?? { ref } };
|
|
948
|
-
}
|
|
949
|
-
if (action === 'type') {
|
|
950
|
-
if (!value) {
|
|
951
|
-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'find type requires text' } };
|
|
952
|
-
}
|
|
953
|
-
const coords = node.rect ? centerOfRect(node.rect) : null;
|
|
954
|
-
if (!coords) {
|
|
955
|
-
return { ok: false, error: { code: 'COMMAND_FAILED', message: 'matched element has no bounds' } };
|
|
956
|
-
}
|
|
957
|
-
await dispatchCommand(
|
|
958
|
-
device,
|
|
959
|
-
'focus',
|
|
960
|
-
[String(coords.x), String(coords.y)],
|
|
961
|
-
req.flags?.out,
|
|
962
|
-
{ ...contextFromFlags(req.flags, session?.appBundleId, session?.trace?.outPath) },
|
|
963
|
-
);
|
|
964
|
-
const response = await dispatchCommand(
|
|
965
|
-
device,
|
|
966
|
-
'type',
|
|
967
|
-
[value],
|
|
968
|
-
req.flags?.out,
|
|
969
|
-
{ ...contextFromFlags(req.flags, session?.appBundleId, session?.trace?.outPath) },
|
|
970
|
-
);
|
|
971
|
-
if (session) {
|
|
972
|
-
recordAction(session, {
|
|
973
|
-
command,
|
|
974
|
-
positionals: req.positionals ?? [],
|
|
975
|
-
flags: req.flags ?? {},
|
|
976
|
-
result: { ref, action: 'type' },
|
|
977
|
-
});
|
|
978
|
-
}
|
|
979
|
-
return { ok: true, data: response ?? { ref } };
|
|
980
|
-
}
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
if (command === 'click') {
|
|
984
|
-
const session = sessions.get(sessionName);
|
|
985
|
-
if (!session?.snapshot) {
|
|
986
|
-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'No snapshot in session. Run snapshot first.' } };
|
|
987
|
-
}
|
|
988
|
-
const refInput = req.positionals?.[0] ?? '';
|
|
989
|
-
const ref = normalizeRef(refInput);
|
|
990
|
-
if (!ref) {
|
|
991
|
-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'click requires a ref like @e2' } };
|
|
992
|
-
}
|
|
993
|
-
let node = findNodeByRef(session.snapshot.nodes, ref);
|
|
994
|
-
if (!node?.rect && req.positionals.length > 1) {
|
|
995
|
-
const fallbackLabel = req.positionals.slice(1).join(' ').trim();
|
|
996
|
-
if (fallbackLabel.length > 0) {
|
|
997
|
-
node = findNodeByLabel(session.snapshot.nodes, fallbackLabel);
|
|
998
|
-
}
|
|
999
|
-
}
|
|
1000
|
-
if (!node?.rect) {
|
|
1001
|
-
return { ok: false, error: { code: 'COMMAND_FAILED', message: `Ref ${refInput} not found or has no bounds` } };
|
|
1002
|
-
}
|
|
1003
|
-
const refLabel = resolveRefLabel(node, session.snapshot.nodes);
|
|
1004
|
-
const { x, y } = centerOfRect(node.rect);
|
|
1005
|
-
await dispatchCommand(session.device, 'press', [String(x), String(y)], req.flags?.out, {
|
|
1006
|
-
...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath),
|
|
1007
|
-
});
|
|
1008
|
-
recordAction(session, {
|
|
1009
|
-
command,
|
|
1010
|
-
positionals: req.positionals ?? [],
|
|
1011
|
-
flags: req.flags ?? {},
|
|
1012
|
-
result: { ref, x, y, refLabel },
|
|
1013
|
-
});
|
|
1014
|
-
return { ok: true, data: { ref, x, y } };
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
if (command === 'fill') {
|
|
1018
|
-
const session = sessions.get(sessionName);
|
|
1019
|
-
if (req.positionals?.[0]?.startsWith('@')) {
|
|
1020
|
-
if (!session?.snapshot) {
|
|
1021
|
-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'No snapshot in session. Run snapshot first.' } };
|
|
1022
|
-
}
|
|
1023
|
-
const ref = normalizeRef(req.positionals[0]);
|
|
1024
|
-
if (!ref) {
|
|
1025
|
-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'fill requires a ref like @e2' } };
|
|
1026
|
-
}
|
|
1027
|
-
const labelCandidate = req.positionals.length >= 3 ? req.positionals[1] : '';
|
|
1028
|
-
const text = req.positionals.length >= 3 ? req.positionals.slice(2).join(' ') : req.positionals.slice(1).join(' ');
|
|
1029
|
-
if (!text) {
|
|
1030
|
-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'fill requires text after ref' } };
|
|
1031
|
-
}
|
|
1032
|
-
let node = findNodeByRef(session.snapshot.nodes, ref);
|
|
1033
|
-
if (!node?.rect && labelCandidate) {
|
|
1034
|
-
node = findNodeByLabel(session.snapshot.nodes, labelCandidate);
|
|
1035
|
-
}
|
|
1036
|
-
if (!node?.rect) {
|
|
1037
|
-
return { ok: false, error: { code: 'COMMAND_FAILED', message: `Ref ${req.positionals[0]} not found or has no bounds` } };
|
|
1038
|
-
}
|
|
1039
|
-
const nodeType = node.type ?? '';
|
|
1040
|
-
if (nodeType && !isFillableType(nodeType, session.device.platform)) {
|
|
1041
|
-
return {
|
|
1042
|
-
ok: false,
|
|
1043
|
-
error: {
|
|
1044
|
-
code: 'INVALID_ARGS',
|
|
1045
|
-
message: `fill requires a text input element, got "${nodeType}" for ${req.positionals[0]}. Select a text input ref or use click/focus + type.`,
|
|
1046
|
-
},
|
|
1047
|
-
};
|
|
1048
|
-
}
|
|
1049
|
-
const refLabel = resolveRefLabel(node, session.snapshot.nodes);
|
|
1050
|
-
const { x, y } = centerOfRect(node.rect);
|
|
1051
|
-
const data = await dispatchCommand(
|
|
1052
|
-
session.device,
|
|
1053
|
-
'fill',
|
|
1054
|
-
[String(x), String(y), text],
|
|
1055
|
-
req.flags?.out,
|
|
1056
|
-
{
|
|
1057
|
-
...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath),
|
|
1058
|
-
},
|
|
1059
|
-
);
|
|
1060
|
-
recordAction(session, {
|
|
1061
|
-
command,
|
|
1062
|
-
positionals: req.positionals ?? [],
|
|
1063
|
-
flags: req.flags ?? {},
|
|
1064
|
-
result: data ?? { ref, x, y, refLabel },
|
|
1065
|
-
});
|
|
1066
|
-
return { ok: true, data: data ?? { ref, x, y } };
|
|
1067
|
-
}
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
if (command === 'get') {
|
|
1071
|
-
const sub = req.positionals?.[0];
|
|
1072
|
-
const refInput = req.positionals?.[1];
|
|
1073
|
-
if (sub !== 'text' && sub !== 'attrs') {
|
|
1074
|
-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'get only supports text or attrs' } };
|
|
1075
|
-
}
|
|
1076
|
-
const session = sessions.get(sessionName);
|
|
1077
|
-
if (!session?.snapshot) {
|
|
1078
|
-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'No snapshot in session. Run snapshot first.' } };
|
|
1079
|
-
}
|
|
1080
|
-
const ref = normalizeRef(refInput ?? '');
|
|
1081
|
-
if (!ref) {
|
|
1082
|
-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'get text requires a ref like @e2' } };
|
|
1083
|
-
}
|
|
1084
|
-
let node = findNodeByRef(session.snapshot.nodes, ref);
|
|
1085
|
-
if (!node && req.positionals.length > 2) {
|
|
1086
|
-
const labelCandidate = req.positionals.slice(2).join(' ').trim();
|
|
1087
|
-
if (labelCandidate.length > 0) {
|
|
1088
|
-
node = findNodeByLabel(session.snapshot.nodes, labelCandidate);
|
|
1089
|
-
}
|
|
1090
|
-
}
|
|
1091
|
-
if (!node) {
|
|
1092
|
-
return { ok: false, error: { code: 'COMMAND_FAILED', message: `Ref ${refInput} not found` } };
|
|
1093
|
-
}
|
|
1094
|
-
if (sub === 'attrs') {
|
|
1095
|
-
recordAction(session, {
|
|
1096
|
-
command,
|
|
1097
|
-
positionals: req.positionals ?? [],
|
|
1098
|
-
flags: req.flags ?? {},
|
|
1099
|
-
result: { ref },
|
|
1100
|
-
});
|
|
1101
|
-
return { ok: true, data: { ref, node } };
|
|
1102
|
-
}
|
|
1103
|
-
const candidates = [node.label, node.value, node.identifier]
|
|
1104
|
-
.map((value) => (typeof value === 'string' ? value.trim() : ''))
|
|
1105
|
-
.filter((value) => value.length > 0);
|
|
1106
|
-
const text = candidates[0] ?? '';
|
|
1107
|
-
recordAction(session, {
|
|
1108
|
-
command,
|
|
1109
|
-
positionals: req.positionals ?? [],
|
|
1110
|
-
flags: req.flags ?? {},
|
|
1111
|
-
result: { ref, text, refLabel: text || undefined },
|
|
1112
|
-
});
|
|
1113
|
-
return { ok: true, data: { ref, text, node } };
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
const session = sessions.get(sessionName);
|
|
1118
|
-
if (!session) {
|
|
101
|
+
if (!isCommandSupportedOnDevice(command, session.device)) {
|
|
1119
102
|
return {
|
|
1120
103
|
ok: false,
|
|
1121
|
-
error: { code: '
|
|
104
|
+
error: { code: 'UNSUPPORTED_OPERATION', message: `${command} is not supported on this device` },
|
|
1122
105
|
};
|
|
1123
106
|
}
|
|
1124
107
|
|
|
1125
108
|
const data = await dispatchCommand(session.device, command, req.positionals ?? [], req.flags?.out, {
|
|
1126
109
|
...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath),
|
|
1127
110
|
});
|
|
1128
|
-
recordAction(session, {
|
|
111
|
+
sessionStore.recordAction(session, {
|
|
1129
112
|
command,
|
|
1130
113
|
positionals: req.positionals ?? [],
|
|
1131
114
|
flags: req.flags ?? {},
|
|
@@ -1190,12 +173,12 @@ function start(): void {
|
|
|
1190
173
|
});
|
|
1191
174
|
|
|
1192
175
|
const shutdown = async () => {
|
|
1193
|
-
const sessionsToStop =
|
|
176
|
+
const sessionsToStop = sessionStore.toArray();
|
|
1194
177
|
for (const session of sessionsToStop) {
|
|
1195
178
|
if (session.device.platform === 'ios' && session.device.kind === 'simulator') {
|
|
1196
179
|
await stopIosRunnerSession(session.device.id);
|
|
1197
180
|
}
|
|
1198
|
-
writeSessionLog(session);
|
|
181
|
+
sessionStore.writeSessionLog(session);
|
|
1199
182
|
}
|
|
1200
183
|
server.close(() => {
|
|
1201
184
|
removeInfo();
|
|
@@ -1221,515 +204,6 @@ function start(): void {
|
|
|
1221
204
|
|
|
1222
205
|
start();
|
|
1223
206
|
|
|
1224
|
-
function recordAction(
|
|
1225
|
-
session: SessionState,
|
|
1226
|
-
entry: {
|
|
1227
|
-
command: string;
|
|
1228
|
-
positionals: string[];
|
|
1229
|
-
flags: CommandFlags;
|
|
1230
|
-
result?: Record<string, unknown>;
|
|
1231
|
-
},
|
|
1232
|
-
): void {
|
|
1233
|
-
if (entry.flags?.noRecord) return;
|
|
1234
|
-
session.actions.push({
|
|
1235
|
-
ts: Date.now(),
|
|
1236
|
-
command: entry.command,
|
|
1237
|
-
positionals: entry.positionals,
|
|
1238
|
-
flags: sanitizeFlags(entry.flags),
|
|
1239
|
-
result: entry.result,
|
|
1240
|
-
});
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
function sanitizeFlags(flags: CommandFlags | undefined): SessionAction['flags'] {
|
|
1244
|
-
if (!flags) return {};
|
|
1245
|
-
const {
|
|
1246
|
-
platform,
|
|
1247
|
-
device,
|
|
1248
|
-
udid,
|
|
1249
|
-
serial,
|
|
1250
|
-
out,
|
|
1251
|
-
verbose,
|
|
1252
|
-
snapshotInteractiveOnly,
|
|
1253
|
-
snapshotCompact,
|
|
1254
|
-
snapshotDepth,
|
|
1255
|
-
snapshotScope,
|
|
1256
|
-
snapshotRaw,
|
|
1257
|
-
snapshotBackend,
|
|
1258
|
-
appsMetadata,
|
|
1259
|
-
noRecord,
|
|
1260
|
-
recordJson,
|
|
1261
|
-
} = flags as any;
|
|
1262
|
-
return {
|
|
1263
|
-
platform,
|
|
1264
|
-
device,
|
|
1265
|
-
udid,
|
|
1266
|
-
serial,
|
|
1267
|
-
out,
|
|
1268
|
-
verbose,
|
|
1269
|
-
snapshotInteractiveOnly,
|
|
1270
|
-
snapshotCompact,
|
|
1271
|
-
snapshotDepth,
|
|
1272
|
-
snapshotScope,
|
|
1273
|
-
snapshotRaw,
|
|
1274
|
-
snapshotBackend,
|
|
1275
|
-
appsMetadata,
|
|
1276
|
-
noRecord,
|
|
1277
|
-
recordJson,
|
|
1278
|
-
};
|
|
1279
|
-
}
|
|
1280
|
-
|
|
1281
|
-
type FindAction =
|
|
1282
|
-
| { kind: 'click' }
|
|
1283
|
-
| { kind: 'focus' }
|
|
1284
|
-
| { kind: 'fill'; value: string }
|
|
1285
|
-
| { kind: 'type'; value: string }
|
|
1286
|
-
| { kind: 'get_text' }
|
|
1287
|
-
| { kind: 'get_attrs' }
|
|
1288
|
-
| { kind: 'exists' }
|
|
1289
|
-
| { kind: 'wait'; timeoutMs?: number };
|
|
1290
|
-
|
|
1291
|
-
function parseFindArgs(args: string[]): {
|
|
1292
|
-
locator: FindLocator;
|
|
1293
|
-
query: string;
|
|
1294
|
-
action: FindAction['kind'];
|
|
1295
|
-
value?: string;
|
|
1296
|
-
timeoutMs?: number;
|
|
1297
|
-
} {
|
|
1298
|
-
const locatorTokens: FindLocator[] = ['text', 'label', 'value', 'role', 'id'];
|
|
1299
|
-
let locator: FindLocator = 'any';
|
|
1300
|
-
let queryIndex = 0;
|
|
1301
|
-
if (locatorTokens.includes(args[0] as FindLocator)) {
|
|
1302
|
-
locator = args[0] as FindLocator;
|
|
1303
|
-
queryIndex = 1;
|
|
1304
|
-
}
|
|
1305
|
-
const query = args[queryIndex] ?? '';
|
|
1306
|
-
const actionTokens = args.slice(queryIndex + 1);
|
|
1307
|
-
if (actionTokens.length === 0) {
|
|
1308
|
-
return { locator, query, action: 'click' };
|
|
1309
|
-
}
|
|
1310
|
-
const action = actionTokens[0].toLowerCase();
|
|
1311
|
-
if (action === 'get') {
|
|
1312
|
-
const sub = actionTokens[1]?.toLowerCase();
|
|
1313
|
-
if (sub === 'text') return { locator, query, action: 'get_text' };
|
|
1314
|
-
if (sub === 'attrs') return { locator, query, action: 'get_attrs' };
|
|
1315
|
-
throw new AppError('INVALID_ARGS', 'find get only supports text or attrs');
|
|
1316
|
-
}
|
|
1317
|
-
if (action === 'wait') {
|
|
1318
|
-
const timeoutMs = parseTimeout(actionTokens[1]);
|
|
1319
|
-
return { locator, query, action: 'wait', timeoutMs: timeoutMs ?? undefined };
|
|
1320
|
-
}
|
|
1321
|
-
if (action === 'exists') return { locator, query, action: 'exists' };
|
|
1322
|
-
if (action === 'click') return { locator, query, action: 'click' };
|
|
1323
|
-
if (action === 'focus') return { locator, query, action: 'focus' };
|
|
1324
|
-
if (action === 'fill') {
|
|
1325
|
-
const value = actionTokens.slice(1).join(' ');
|
|
1326
|
-
return { locator, query, action: 'fill', value };
|
|
1327
|
-
}
|
|
1328
|
-
if (action === 'type') {
|
|
1329
|
-
const value = actionTokens.slice(1).join(' ');
|
|
1330
|
-
return { locator, query, action: 'type', value };
|
|
1331
|
-
}
|
|
1332
|
-
throw new AppError('INVALID_ARGS', `Unsupported find action: ${actionTokens[0]}`);
|
|
1333
|
-
}
|
|
1334
|
-
|
|
1335
|
-
function parseTimeout(value: string | undefined): number | null {
|
|
1336
|
-
if (!value) return null;
|
|
1337
|
-
const parsed = Number(value);
|
|
1338
|
-
return Number.isFinite(parsed) ? parsed : null;
|
|
1339
|
-
}
|
|
1340
|
-
|
|
1341
|
-
function shouldScopeFind(locator: FindLocator): boolean {
|
|
1342
|
-
return locator !== 'role';
|
|
1343
|
-
}
|
|
1344
|
-
|
|
1345
|
-
function extractNodeText(node: SnapshotState['nodes'][number]): string {
|
|
1346
|
-
const candidates = [node.label, node.value, node.identifier]
|
|
1347
|
-
.map((value) => (typeof value === 'string' ? value.trim() : ''))
|
|
1348
|
-
.filter((value) => value.length > 0);
|
|
1349
|
-
return candidates[0] ?? '';
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
async function resolveIosAppStateFromSnapshots(
|
|
1353
|
-
device: DeviceInfo,
|
|
1354
|
-
traceLogPath: string | undefined,
|
|
1355
|
-
flags: CommandFlags | undefined,
|
|
1356
|
-
): Promise<{ appName: string; appBundleId?: string; source: 'snapshot-ax' | 'snapshot-xctest' }> {
|
|
1357
|
-
const axResult = await dispatchCommand(device, 'snapshot', [], flags?.out, {
|
|
1358
|
-
...contextFromFlags(
|
|
1359
|
-
{
|
|
1360
|
-
...flags,
|
|
1361
|
-
snapshotDepth: 1,
|
|
1362
|
-
snapshotCompact: true,
|
|
1363
|
-
snapshotBackend: 'ax',
|
|
1364
|
-
},
|
|
1365
|
-
undefined,
|
|
1366
|
-
traceLogPath,
|
|
1367
|
-
),
|
|
1368
|
-
});
|
|
1369
|
-
const axNode = extractAppNodeFromSnapshot(axResult as { nodes?: RawSnapshotNode[] });
|
|
1370
|
-
if (axNode?.appName || axNode?.appBundleId) {
|
|
1371
|
-
return {
|
|
1372
|
-
appName: axNode.appName ?? axNode.appBundleId ?? 'unknown',
|
|
1373
|
-
appBundleId: axNode.appBundleId,
|
|
1374
|
-
source: 'snapshot-ax',
|
|
1375
|
-
};
|
|
1376
|
-
}
|
|
1377
|
-
const xctestResult = await dispatchCommand(device, 'snapshot', [], flags?.out, {
|
|
1378
|
-
...contextFromFlags(
|
|
1379
|
-
{
|
|
1380
|
-
...flags,
|
|
1381
|
-
snapshotDepth: 1,
|
|
1382
|
-
snapshotCompact: true,
|
|
1383
|
-
snapshotBackend: 'xctest',
|
|
1384
|
-
},
|
|
1385
|
-
undefined,
|
|
1386
|
-
traceLogPath,
|
|
1387
|
-
),
|
|
1388
|
-
});
|
|
1389
|
-
const xcNode = extractAppNodeFromSnapshot(xctestResult as { nodes?: RawSnapshotNode[] });
|
|
1390
|
-
return {
|
|
1391
|
-
appName: xcNode?.appName ?? xcNode?.appBundleId ?? 'unknown',
|
|
1392
|
-
appBundleId: xcNode?.appBundleId,
|
|
1393
|
-
source: 'snapshot-xctest',
|
|
1394
|
-
};
|
|
1395
|
-
}
|
|
1396
|
-
|
|
1397
|
-
function extractAppNodeFromSnapshot(
|
|
1398
|
-
data: { nodes?: RawSnapshotNode[] } | undefined,
|
|
1399
|
-
): { appName?: string; appBundleId?: string } | null {
|
|
1400
|
-
const rawNodes = data?.nodes ?? [];
|
|
1401
|
-
const nodes = attachRefs(rawNodes);
|
|
1402
|
-
const appNode = nodes.find((node) => normalizeType(node.type ?? '') === 'application') ?? nodes[0];
|
|
1403
|
-
if (!appNode) return null;
|
|
1404
|
-
const appName = appNode.label?.trim();
|
|
1405
|
-
const appBundleId = appNode.identifier?.trim();
|
|
1406
|
-
if (!appName && !appBundleId) return null;
|
|
1407
|
-
return { appName: appName || undefined, appBundleId: appBundleId || undefined };
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1410
|
-
function writeSessionLog(session: SessionState): void {
|
|
1411
|
-
try {
|
|
1412
|
-
if (!fs.existsSync(sessionsDir)) fs.mkdirSync(sessionsDir, { recursive: true });
|
|
1413
|
-
const safeName = session.name.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
1414
|
-
const timestamp = new Date(session.createdAt).toISOString().replace(/[:.]/g, '-');
|
|
1415
|
-
const scriptPath = path.join(sessionsDir, `${safeName}-${timestamp}.ad`);
|
|
1416
|
-
const filePath = resolveSessionJsonPath(session, safeName, timestamp);
|
|
1417
|
-
const payload = {
|
|
1418
|
-
name: session.name,
|
|
1419
|
-
device: session.device,
|
|
1420
|
-
createdAt: session.createdAt,
|
|
1421
|
-
appBundleId: session.appBundleId,
|
|
1422
|
-
actions: session.actions,
|
|
1423
|
-
optimizedActions: buildOptimizedActions(session),
|
|
1424
|
-
};
|
|
1425
|
-
const script = formatScript(session, payload.optimizedActions);
|
|
1426
|
-
fs.writeFileSync(scriptPath, script);
|
|
1427
|
-
if (session.actions.some((action) => action.flags?.recordJson)) {
|
|
1428
|
-
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
1429
|
-
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2));
|
|
1430
|
-
}
|
|
1431
|
-
} catch {
|
|
1432
|
-
// ignore
|
|
1433
|
-
}
|
|
1434
|
-
}
|
|
1435
|
-
|
|
1436
|
-
function resolveSessionJsonPath(session: SessionState, safeName: string, timestamp: string): string {
|
|
1437
|
-
const defaultFile = path.join(sessionsDir, `${safeName}-${timestamp}.json`);
|
|
1438
|
-
const actionWithOut = [...session.actions]
|
|
1439
|
-
.reverse()
|
|
1440
|
-
.find((action) => action.flags?.recordJson && typeof action.flags?.out === 'string' && action.flags.out.trim().length > 0);
|
|
1441
|
-
if (!actionWithOut || !actionWithOut.flags?.out) {
|
|
1442
|
-
return defaultFile;
|
|
1443
|
-
}
|
|
1444
|
-
|
|
1445
|
-
const rawOut = actionWithOut.flags.out.trim();
|
|
1446
|
-
const resolvedOut = expandHome(rawOut);
|
|
1447
|
-
const wantsDirectory = rawOut.endsWith('/') || rawOut.endsWith('\\');
|
|
1448
|
-
if (wantsDirectory) {
|
|
1449
|
-
return path.join(resolvedOut, `${safeName}-${timestamp}.json`);
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
|
-
try {
|
|
1453
|
-
if (fs.existsSync(resolvedOut) && fs.statSync(resolvedOut).isDirectory()) {
|
|
1454
|
-
return path.join(resolvedOut, `${safeName}-${timestamp}.json`);
|
|
1455
|
-
}
|
|
1456
|
-
} catch {
|
|
1457
|
-
return defaultFile;
|
|
1458
|
-
}
|
|
1459
|
-
|
|
1460
|
-
return resolvedOut;
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
function defaultTracePath(session: SessionState): string {
|
|
1464
|
-
const safeName = session.name.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
1465
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1466
|
-
return path.join(sessionsDir, `${safeName}-${timestamp}.trace.log`);
|
|
1467
|
-
}
|
|
1468
|
-
|
|
1469
|
-
function expandHome(filePath: string): string {
|
|
1470
|
-
if (filePath.startsWith('~/')) {
|
|
1471
|
-
return path.join(os.homedir(), filePath.slice(2));
|
|
1472
|
-
}
|
|
1473
|
-
return path.resolve(filePath);
|
|
1474
|
-
}
|
|
1475
|
-
|
|
1476
|
-
function buildOptimizedActions(session: SessionState): SessionAction[] {
|
|
1477
|
-
const optimized: SessionAction[] = [];
|
|
1478
|
-
for (const action of session.actions) {
|
|
1479
|
-
if (action.command === 'snapshot') {
|
|
1480
|
-
continue;
|
|
1481
|
-
}
|
|
1482
|
-
if (action.command === 'click' || action.command === 'fill' || action.command === 'get') {
|
|
1483
|
-
const refLabel = action.result?.refLabel;
|
|
1484
|
-
if (typeof refLabel === 'string' && refLabel.trim().length > 0) {
|
|
1485
|
-
optimized.push({
|
|
1486
|
-
ts: action.ts,
|
|
1487
|
-
command: 'snapshot',
|
|
1488
|
-
positionals: [],
|
|
1489
|
-
flags: {
|
|
1490
|
-
platform: session.device.platform,
|
|
1491
|
-
snapshotInteractiveOnly: true,
|
|
1492
|
-
snapshotCompact: true,
|
|
1493
|
-
snapshotScope: refLabel.trim(),
|
|
1494
|
-
},
|
|
1495
|
-
result: { scope: refLabel.trim() },
|
|
1496
|
-
});
|
|
1497
|
-
}
|
|
1498
|
-
}
|
|
1499
|
-
optimized.push(action);
|
|
1500
|
-
}
|
|
1501
|
-
return optimized;
|
|
1502
|
-
}
|
|
1503
|
-
|
|
1504
|
-
function formatScript(session: SessionState, actions: SessionAction[]): string {
|
|
1505
|
-
const lines: string[] = [];
|
|
1506
|
-
const deviceLabel = session.device.name.replace(/"/g, '\\"');
|
|
1507
|
-
const kind = session.device.kind ? ` kind=${session.device.kind}` : '';
|
|
1508
|
-
const theme = 'unknown';
|
|
1509
|
-
lines.push(`context platform=${session.device.platform} device="${deviceLabel}"${kind} theme=${theme}`);
|
|
1510
|
-
for (const action of actions) {
|
|
1511
|
-
if (action.flags?.noRecord) continue;
|
|
1512
|
-
lines.push(formatActionLine(action));
|
|
1513
|
-
}
|
|
1514
|
-
return `${lines.join('\n')}\n`;
|
|
1515
|
-
}
|
|
1516
|
-
|
|
1517
|
-
function formatActionLine(action: SessionAction): string {
|
|
1518
|
-
const parts: string[] = [action.command];
|
|
1519
|
-
if (action.command === 'click') {
|
|
1520
|
-
const ref = action.positionals?.[0];
|
|
1521
|
-
if (ref) {
|
|
1522
|
-
parts.push(formatArg(ref));
|
|
1523
|
-
const refLabel = action.result?.refLabel;
|
|
1524
|
-
if (typeof refLabel === 'string' && refLabel.trim().length > 0) {
|
|
1525
|
-
parts.push(formatArg(refLabel));
|
|
1526
|
-
}
|
|
1527
|
-
return parts.join(' ');
|
|
1528
|
-
}
|
|
1529
|
-
}
|
|
1530
|
-
if (action.command === 'fill') {
|
|
1531
|
-
const ref = action.positionals?.[0];
|
|
1532
|
-
if (ref && ref.startsWith('@')) {
|
|
1533
|
-
parts.push(formatArg(ref));
|
|
1534
|
-
const refLabel = action.result?.refLabel;
|
|
1535
|
-
const text = action.positionals.slice(1).join(' ');
|
|
1536
|
-
if (typeof refLabel === 'string' && refLabel.trim().length > 0) {
|
|
1537
|
-
parts.push(formatArg(refLabel));
|
|
1538
|
-
}
|
|
1539
|
-
if (text) {
|
|
1540
|
-
parts.push(formatArg(text));
|
|
1541
|
-
}
|
|
1542
|
-
return parts.join(' ');
|
|
1543
|
-
}
|
|
1544
|
-
}
|
|
1545
|
-
if (action.command === 'get') {
|
|
1546
|
-
const sub = action.positionals?.[0];
|
|
1547
|
-
const ref = action.positionals?.[1];
|
|
1548
|
-
if (sub && ref) {
|
|
1549
|
-
parts.push(formatArg(sub));
|
|
1550
|
-
parts.push(formatArg(ref));
|
|
1551
|
-
const refLabel = action.result?.refLabel;
|
|
1552
|
-
if (typeof refLabel === 'string' && refLabel.trim().length > 0) {
|
|
1553
|
-
parts.push(formatArg(refLabel));
|
|
1554
|
-
}
|
|
1555
|
-
return parts.join(' ');
|
|
1556
|
-
}
|
|
1557
|
-
}
|
|
1558
|
-
if (action.command === 'snapshot') {
|
|
1559
|
-
if (action.flags?.snapshotInteractiveOnly) parts.push('-i');
|
|
1560
|
-
if (action.flags?.snapshotCompact) parts.push('-c');
|
|
1561
|
-
if (typeof action.flags?.snapshotDepth === 'number') {
|
|
1562
|
-
parts.push('-d', String(action.flags.snapshotDepth));
|
|
1563
|
-
}
|
|
1564
|
-
if (action.flags?.snapshotScope) {
|
|
1565
|
-
parts.push('-s', formatArg(action.flags.snapshotScope));
|
|
1566
|
-
}
|
|
1567
|
-
if (action.flags?.snapshotRaw) parts.push('--raw');
|
|
1568
|
-
if (action.flags?.snapshotBackend) {
|
|
1569
|
-
parts.push(`--backend`, action.flags.snapshotBackend);
|
|
1570
|
-
}
|
|
1571
|
-
return parts.join(' ');
|
|
1572
|
-
}
|
|
1573
|
-
for (const positional of action.positionals ?? []) {
|
|
1574
|
-
parts.push(formatArg(positional));
|
|
1575
|
-
}
|
|
1576
|
-
return parts.join(' ');
|
|
1577
|
-
}
|
|
1578
|
-
|
|
1579
|
-
function formatArg(value: string): string {
|
|
1580
|
-
const trimmed = value.trim();
|
|
1581
|
-
if (trimmed.startsWith('@')) return trimmed;
|
|
1582
|
-
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed;
|
|
1583
|
-
return JSON.stringify(trimmed);
|
|
1584
|
-
}
|
|
1585
|
-
|
|
1586
|
-
function findNodeByLabel(nodes: SnapshotState['nodes'], label: string) {
|
|
1587
|
-
const query = label.toLowerCase();
|
|
1588
|
-
return (
|
|
1589
|
-
nodes.find((node) => {
|
|
1590
|
-
const labelValue = (node.label ?? '').toLowerCase();
|
|
1591
|
-
const valueValue = (node.value ?? '').toLowerCase();
|
|
1592
|
-
const idValue = (node.identifier ?? '').toLowerCase();
|
|
1593
|
-
return labelValue.includes(query) || valueValue.includes(query) || idValue.includes(query);
|
|
1594
|
-
}) ?? null
|
|
1595
|
-
);
|
|
1596
|
-
}
|
|
1597
|
-
|
|
1598
|
-
function resolveRefLabel(
|
|
1599
|
-
node: SnapshotState['nodes'][number],
|
|
1600
|
-
nodes: SnapshotState['nodes'],
|
|
1601
|
-
): string | undefined {
|
|
1602
|
-
const primary = [node.label, node.value, node.identifier]
|
|
1603
|
-
.map((value) => (typeof value === 'string' ? value.trim() : ''))
|
|
1604
|
-
.find((value) => value && value.length > 0);
|
|
1605
|
-
if (primary && isMeaningfulLabel(primary)) return primary;
|
|
1606
|
-
const fallback = findNearestMeaningfulLabel(node, nodes);
|
|
1607
|
-
return fallback ?? (primary && isMeaningfulLabel(primary) ? primary : undefined);
|
|
1608
|
-
}
|
|
1609
|
-
|
|
1610
|
-
function isMeaningfulLabel(value: string): boolean {
|
|
1611
|
-
const trimmed = value.trim();
|
|
1612
|
-
if (!trimmed) return false;
|
|
1613
|
-
if (/^(true|false)$/i.test(trimmed)) return false;
|
|
1614
|
-
if (/^\d+$/.test(trimmed)) return false;
|
|
1615
|
-
return true;
|
|
1616
|
-
}
|
|
1617
|
-
|
|
1618
|
-
function findNearestMeaningfulLabel(
|
|
1619
|
-
target: SnapshotState['nodes'][number],
|
|
1620
|
-
nodes: SnapshotState['nodes'],
|
|
1621
|
-
): string | undefined {
|
|
1622
|
-
if (!target.rect) return undefined;
|
|
1623
|
-
const targetY = target.rect.y + target.rect.height / 2;
|
|
1624
|
-
let best: { label: string; distance: number } | null = null;
|
|
1625
|
-
for (const node of nodes) {
|
|
1626
|
-
if (!node.rect) continue;
|
|
1627
|
-
const label = [node.label, node.value, node.identifier]
|
|
1628
|
-
.map((value) => (typeof value === 'string' ? value.trim() : ''))
|
|
1629
|
-
.find((value) => value && value.length > 0);
|
|
1630
|
-
if (!label || !isMeaningfulLabel(label)) continue;
|
|
1631
|
-
const nodeY = node.rect.y + node.rect.height / 2;
|
|
1632
|
-
const distance = Math.abs(nodeY - targetY);
|
|
1633
|
-
if (!best || distance < best.distance) {
|
|
1634
|
-
best = { label, distance };
|
|
1635
|
-
}
|
|
1636
|
-
}
|
|
1637
|
-
return best?.label;
|
|
1638
|
-
}
|
|
1639
|
-
|
|
1640
|
-
async function ensureDeviceReady(device: DeviceInfo): Promise<void> {
|
|
1641
|
-
if (device.platform === 'ios' && device.kind === 'simulator') {
|
|
1642
|
-
const { ensureBootedSimulator } = await import('./platforms/ios/index.ts');
|
|
1643
|
-
await ensureBootedSimulator(device);
|
|
1644
|
-
return;
|
|
1645
|
-
}
|
|
1646
|
-
if (device.platform === 'android') {
|
|
1647
|
-
const { waitForAndroidBoot } = await import('./platforms/android/devices.ts');
|
|
1648
|
-
await waitForAndroidBoot(device.id);
|
|
1649
|
-
}
|
|
1650
|
-
}
|
|
1651
|
-
|
|
1652
|
-
function isLabelUnique(nodes: SnapshotState['nodes'], label: string): boolean {
|
|
1653
|
-
const target = label.trim().toLowerCase();
|
|
1654
|
-
if (!target) return false;
|
|
1655
|
-
let count = 0;
|
|
1656
|
-
for (const node of nodes) {
|
|
1657
|
-
if ((node.label ?? '').trim().toLowerCase() === target) {
|
|
1658
|
-
count += 1;
|
|
1659
|
-
if (count > 1) return false;
|
|
1660
|
-
}
|
|
1661
|
-
}
|
|
1662
|
-
return count === 1;
|
|
1663
|
-
}
|
|
1664
|
-
|
|
1665
|
-
function pruneGroupNodes(nodes: RawSnapshotNode[]): RawSnapshotNode[] {
|
|
1666
|
-
const skippedDepths: number[] = [];
|
|
1667
|
-
const result: RawSnapshotNode[] = [];
|
|
1668
|
-
for (const node of nodes) {
|
|
1669
|
-
const depth = node.depth ?? 0;
|
|
1670
|
-
while (skippedDepths.length > 0 && depth <= skippedDepths[skippedDepths.length - 1]) {
|
|
1671
|
-
skippedDepths.pop();
|
|
1672
|
-
}
|
|
1673
|
-
const type = normalizeType(node.type ?? '');
|
|
1674
|
-
const labelCandidate = [node.label, node.value, node.identifier]
|
|
1675
|
-
.map((value) => (typeof value === 'string' ? value.trim() : ''))
|
|
1676
|
-
.find((value) => value && value.length > 0);
|
|
1677
|
-
const hasMeaningfulLabel = labelCandidate ? isMeaningfulLabel(labelCandidate) : false;
|
|
1678
|
-
if ((type === 'group' || type === 'ioscontentgroup') && !hasMeaningfulLabel) {
|
|
1679
|
-
skippedDepths.push(depth);
|
|
1680
|
-
continue;
|
|
1681
|
-
}
|
|
1682
|
-
const adjustedDepth = Math.max(0, depth - skippedDepths.length);
|
|
1683
|
-
result.push({ ...node, depth: adjustedDepth });
|
|
1684
|
-
}
|
|
1685
|
-
return result;
|
|
1686
|
-
}
|
|
1687
|
-
|
|
1688
|
-
function normalizeType(type: string): string {
|
|
1689
|
-
let value = type.replace(/XCUIElementType/gi, '').toLowerCase();
|
|
1690
|
-
if (value.startsWith('ax')) {
|
|
1691
|
-
value = value.replace(/^ax/, '');
|
|
1692
|
-
}
|
|
1693
|
-
return value;
|
|
1694
|
-
}
|
|
1695
|
-
|
|
1696
|
-
function isFillableType(type: string, platform: 'ios' | 'android'): boolean {
|
|
1697
|
-
const normalized = normalizeType(type);
|
|
1698
|
-
if (!normalized) return true;
|
|
1699
|
-
if (platform === 'android') {
|
|
1700
|
-
return (
|
|
1701
|
-
normalized.includes('edittext') ||
|
|
1702
|
-
normalized.includes('autocompletetextview')
|
|
1703
|
-
);
|
|
1704
|
-
}
|
|
1705
|
-
return (
|
|
1706
|
-
normalized.includes('textfield') ||
|
|
1707
|
-
normalized.includes('securetextfield') ||
|
|
1708
|
-
normalized.includes('searchfield') ||
|
|
1709
|
-
normalized.includes('textview') ||
|
|
1710
|
-
normalized.includes('textarea') ||
|
|
1711
|
-
normalized === 'search'
|
|
1712
|
-
);
|
|
1713
|
-
}
|
|
1714
|
-
|
|
1715
|
-
function findNearestHittableAncestor(
|
|
1716
|
-
nodes: SnapshotState['nodes'],
|
|
1717
|
-
node: SnapshotState['nodes'][number],
|
|
1718
|
-
): SnapshotState['nodes'][number] | null {
|
|
1719
|
-
if (node.hittable) return node;
|
|
1720
|
-
let current = node;
|
|
1721
|
-
const visited = new Set<string>();
|
|
1722
|
-
while (current.parentIndex !== undefined) {
|
|
1723
|
-
if (visited.has(current.ref)) break;
|
|
1724
|
-
visited.add(current.ref);
|
|
1725
|
-
const parent = nodes[current.parentIndex];
|
|
1726
|
-
if (!parent) break;
|
|
1727
|
-
if (parent.hittable) return parent;
|
|
1728
|
-
current = parent;
|
|
1729
|
-
}
|
|
1730
|
-
return null;
|
|
1731
|
-
}
|
|
1732
|
-
|
|
1733
207
|
function readVersion(): string {
|
|
1734
208
|
try {
|
|
1735
209
|
const root = findProjectRoot();
|