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
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { dispatchCommand, resolveTargetDevice } from '../../core/dispatch.ts';
|
|
3
|
+
import { isCommandSupportedOnDevice } from '../../core/capabilities.ts';
|
|
4
|
+
import { AppError, asAppError } from '../../utils/errors.ts';
|
|
5
|
+
import type { DeviceInfo } from '../../utils/device.ts';
|
|
6
|
+
import type { DaemonRequest, DaemonResponse, SessionAction, SessionState } from '../types.ts';
|
|
7
|
+
import { SessionStore } from '../session-store.ts';
|
|
8
|
+
import { contextFromFlags } from '../context.ts';
|
|
9
|
+
import { ensureDeviceReady } from '../device-ready.ts';
|
|
10
|
+
import { resolveIosAppStateFromSnapshots } from '../app-state.ts';
|
|
11
|
+
import { stopIosRunnerSession } from '../../platforms/ios/runner-client.ts';
|
|
12
|
+
import { attachRefs, type RawSnapshotNode, type SnapshotState } from '../../utils/snapshot.ts';
|
|
13
|
+
import { pruneGroupNodes } from '../snapshot-processing.ts';
|
|
14
|
+
import { buildSelectorChainForNode, parseSelectorChain, resolveSelectorChain, splitSelectorFromArgs } from '../selectors.ts';
|
|
15
|
+
import { inferFillText, uniqueStrings } from '../action-utils.ts';
|
|
16
|
+
|
|
17
|
+
export async function handleSessionCommands(params: {
|
|
18
|
+
req: DaemonRequest;
|
|
19
|
+
sessionName: string;
|
|
20
|
+
logPath: string;
|
|
21
|
+
sessionStore: SessionStore;
|
|
22
|
+
invoke: (req: DaemonRequest) => Promise<DaemonResponse>;
|
|
23
|
+
dispatch?: typeof dispatchCommand;
|
|
24
|
+
}): Promise<DaemonResponse | null> {
|
|
25
|
+
const { req, sessionName, logPath, sessionStore, invoke, dispatch: dispatchOverride } = params;
|
|
26
|
+
const dispatch = dispatchOverride ?? dispatchCommand;
|
|
27
|
+
const command = req.command;
|
|
28
|
+
|
|
29
|
+
if (command === 'session_list') {
|
|
30
|
+
const data = {
|
|
31
|
+
sessions: sessionStore.toArray().map((s) => ({
|
|
32
|
+
name: s.name,
|
|
33
|
+
platform: s.device.platform,
|
|
34
|
+
device: s.device.name,
|
|
35
|
+
id: s.device.id,
|
|
36
|
+
createdAt: s.createdAt,
|
|
37
|
+
})),
|
|
38
|
+
};
|
|
39
|
+
return { ok: true, data };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (command === 'devices') {
|
|
43
|
+
try {
|
|
44
|
+
const devices: DeviceInfo[] = [];
|
|
45
|
+
if (req.flags?.platform === 'android') {
|
|
46
|
+
const { listAndroidDevices } = await import('../../platforms/android/devices.ts');
|
|
47
|
+
devices.push(...(await listAndroidDevices()));
|
|
48
|
+
} else if (req.flags?.platform === 'ios') {
|
|
49
|
+
const { listIosDevices } = await import('../../platforms/ios/devices.ts');
|
|
50
|
+
devices.push(...(await listIosDevices()));
|
|
51
|
+
} else {
|
|
52
|
+
const { listAndroidDevices } = await import('../../platforms/android/devices.ts');
|
|
53
|
+
const { listIosDevices } = await import('../../platforms/ios/devices.ts');
|
|
54
|
+
try {
|
|
55
|
+
devices.push(...(await listAndroidDevices()));
|
|
56
|
+
} catch {
|
|
57
|
+
// ignore
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
devices.push(...(await listIosDevices()));
|
|
61
|
+
} catch {
|
|
62
|
+
// ignore
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { ok: true, data: { devices } };
|
|
66
|
+
} catch (err) {
|
|
67
|
+
const appErr = asAppError(err);
|
|
68
|
+
return { ok: false, error: { code: appErr.code, message: appErr.message, details: appErr.details } };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (command === 'apps') {
|
|
73
|
+
const session = sessionStore.get(sessionName);
|
|
74
|
+
const flags = req.flags ?? {};
|
|
75
|
+
if (!session && !flags.platform && !flags.device && !flags.udid && !flags.serial) {
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
error: {
|
|
79
|
+
code: 'INVALID_ARGS',
|
|
80
|
+
message: 'apps requires an active session or an explicit device selector (e.g. --platform ios).',
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const device = session?.device ?? (await resolveTargetDevice(flags));
|
|
85
|
+
await ensureDeviceReady(device);
|
|
86
|
+
if (!isCommandSupportedOnDevice('apps', device)) {
|
|
87
|
+
return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'apps is not supported on this device' } };
|
|
88
|
+
}
|
|
89
|
+
if (device.platform === 'ios') {
|
|
90
|
+
const { listSimulatorApps } = await import('../../platforms/ios/index.ts');
|
|
91
|
+
const apps = await listSimulatorApps(device);
|
|
92
|
+
if (req.flags?.appsMetadata) {
|
|
93
|
+
return { ok: true, data: { apps } };
|
|
94
|
+
}
|
|
95
|
+
const formatted = apps.map((app) =>
|
|
96
|
+
app.name && app.name !== app.bundleId ? `${app.name} (${app.bundleId})` : app.bundleId,
|
|
97
|
+
);
|
|
98
|
+
return { ok: true, data: { apps: formatted } };
|
|
99
|
+
}
|
|
100
|
+
const { listAndroidApps, listAndroidAppsMetadata } = await import('../../platforms/android/index.ts');
|
|
101
|
+
if (req.flags?.appsMetadata) {
|
|
102
|
+
const apps = await listAndroidAppsMetadata(device, req.flags?.appsFilter);
|
|
103
|
+
return { ok: true, data: { apps } };
|
|
104
|
+
}
|
|
105
|
+
const apps = await listAndroidApps(device, req.flags?.appsFilter);
|
|
106
|
+
return { ok: true, data: { apps } };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (command === 'appstate') {
|
|
110
|
+
const session = sessionStore.get(sessionName);
|
|
111
|
+
const flags = req.flags ?? {};
|
|
112
|
+
const device = session?.device ?? (await resolveTargetDevice(flags));
|
|
113
|
+
await ensureDeviceReady(device);
|
|
114
|
+
if (device.platform === 'ios') {
|
|
115
|
+
if (session?.appBundleId) {
|
|
116
|
+
return {
|
|
117
|
+
ok: true,
|
|
118
|
+
data: {
|
|
119
|
+
platform: 'ios',
|
|
120
|
+
appBundleId: session.appBundleId,
|
|
121
|
+
appName: session.appName ?? session.appBundleId,
|
|
122
|
+
source: 'session',
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const snapshotResult = await resolveIosAppStateFromSnapshots(
|
|
127
|
+
device,
|
|
128
|
+
logPath,
|
|
129
|
+
session?.trace?.outPath,
|
|
130
|
+
req.flags,
|
|
131
|
+
);
|
|
132
|
+
return {
|
|
133
|
+
ok: true,
|
|
134
|
+
data: {
|
|
135
|
+
platform: 'ios',
|
|
136
|
+
appName: snapshotResult.appName,
|
|
137
|
+
appBundleId: snapshotResult.appBundleId,
|
|
138
|
+
source: snapshotResult.source,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const { getAndroidAppState } = await import('../../platforms/android/index.ts');
|
|
143
|
+
const state = await getAndroidAppState(device);
|
|
144
|
+
return {
|
|
145
|
+
ok: true,
|
|
146
|
+
data: {
|
|
147
|
+
platform: 'android',
|
|
148
|
+
package: state.package,
|
|
149
|
+
activity: state.activity,
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (command === 'open') {
|
|
155
|
+
if (sessionStore.has(sessionName)) {
|
|
156
|
+
const session = sessionStore.get(sessionName);
|
|
157
|
+
const appName = req.positionals?.[0];
|
|
158
|
+
if (!session || !appName) {
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
error: {
|
|
162
|
+
code: 'INVALID_ARGS',
|
|
163
|
+
message: 'Session already active. Close it first or pass a new --session name.',
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
let appBundleId: string | undefined;
|
|
168
|
+
if (session.device.platform === 'ios') {
|
|
169
|
+
try {
|
|
170
|
+
const { resolveIosApp } = await import('../../platforms/ios/index.ts');
|
|
171
|
+
appBundleId = await resolveIosApp(session.device, appName);
|
|
172
|
+
} catch {
|
|
173
|
+
appBundleId = undefined;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
await dispatch(session.device, 'open', req.positionals ?? [], req.flags?.out, {
|
|
177
|
+
...contextFromFlags(logPath, req.flags, appBundleId),
|
|
178
|
+
});
|
|
179
|
+
const nextSession: SessionState = {
|
|
180
|
+
...session,
|
|
181
|
+
appBundleId,
|
|
182
|
+
appName,
|
|
183
|
+
recordSession: session.recordSession || req.flags?.saveScript === true,
|
|
184
|
+
snapshot: undefined,
|
|
185
|
+
};
|
|
186
|
+
sessionStore.recordAction(nextSession, {
|
|
187
|
+
command,
|
|
188
|
+
positionals: req.positionals ?? [],
|
|
189
|
+
flags: req.flags ?? {},
|
|
190
|
+
result: { session: sessionName, appName, appBundleId },
|
|
191
|
+
});
|
|
192
|
+
sessionStore.set(sessionName, nextSession);
|
|
193
|
+
return { ok: true, data: { session: sessionName, appName, appBundleId } };
|
|
194
|
+
}
|
|
195
|
+
const device = await resolveTargetDevice(req.flags ?? {});
|
|
196
|
+
await ensureDeviceReady(device);
|
|
197
|
+
const inUse = sessionStore.toArray().find((s) => s.device.id === device.id);
|
|
198
|
+
if (inUse) {
|
|
199
|
+
return {
|
|
200
|
+
ok: false,
|
|
201
|
+
error: {
|
|
202
|
+
code: 'DEVICE_IN_USE',
|
|
203
|
+
message: `Device is already in use by session "${inUse.name}".`,
|
|
204
|
+
details: { session: inUse.name, deviceId: device.id, deviceName: device.name },
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
let appBundleId: string | undefined;
|
|
209
|
+
const appName = req.positionals?.[0];
|
|
210
|
+
if (device.platform === 'ios') {
|
|
211
|
+
try {
|
|
212
|
+
const { resolveIosApp } = await import('../../platforms/ios/index.ts');
|
|
213
|
+
appBundleId = await resolveIosApp(device, req.positionals?.[0] ?? '');
|
|
214
|
+
} catch {
|
|
215
|
+
appBundleId = undefined;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
await dispatch(device, 'open', req.positionals ?? [], req.flags?.out, {
|
|
219
|
+
...contextFromFlags(logPath, req.flags, appBundleId),
|
|
220
|
+
});
|
|
221
|
+
const session: SessionState = {
|
|
222
|
+
name: sessionName,
|
|
223
|
+
device,
|
|
224
|
+
createdAt: Date.now(),
|
|
225
|
+
appBundleId,
|
|
226
|
+
appName,
|
|
227
|
+
recordSession: req.flags?.saveScript === true,
|
|
228
|
+
actions: [],
|
|
229
|
+
};
|
|
230
|
+
sessionStore.recordAction(session, {
|
|
231
|
+
command,
|
|
232
|
+
positionals: req.positionals ?? [],
|
|
233
|
+
flags: req.flags ?? {},
|
|
234
|
+
result: { session: sessionName },
|
|
235
|
+
});
|
|
236
|
+
sessionStore.set(sessionName, session);
|
|
237
|
+
return { ok: true, data: { session: sessionName } };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (command === 'replay') {
|
|
241
|
+
const filePath = req.positionals?.[0];
|
|
242
|
+
if (!filePath) {
|
|
243
|
+
return { ok: false, error: { code: 'INVALID_ARGS', message: 'replay requires a path' } };
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
const resolved = SessionStore.expandHome(filePath);
|
|
247
|
+
const script = fs.readFileSync(resolved, 'utf8');
|
|
248
|
+
const firstNonWhitespace = script.trimStart()[0];
|
|
249
|
+
if (firstNonWhitespace === '{' || firstNonWhitespace === '[') {
|
|
250
|
+
return {
|
|
251
|
+
ok: false,
|
|
252
|
+
error: {
|
|
253
|
+
code: 'INVALID_ARGS',
|
|
254
|
+
message: 'replay accepts .ad script files. JSON replay payloads are no longer supported.',
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
const actions = parseReplayScript(script);
|
|
259
|
+
const shouldUpdate = req.flags?.replayUpdate === true;
|
|
260
|
+
let healed = 0;
|
|
261
|
+
for (let index = 0; index < actions.length; index += 1) {
|
|
262
|
+
const action = actions[index];
|
|
263
|
+
if (!action || action.command === 'replay') continue;
|
|
264
|
+
let response = await invoke({
|
|
265
|
+
token: req.token,
|
|
266
|
+
session: sessionName,
|
|
267
|
+
command: action.command,
|
|
268
|
+
positionals: action.positionals ?? [],
|
|
269
|
+
flags: action.flags ?? {},
|
|
270
|
+
});
|
|
271
|
+
if (response.ok) continue;
|
|
272
|
+
if (!shouldUpdate) return response;
|
|
273
|
+
const nextAction = await healReplayAction({
|
|
274
|
+
action,
|
|
275
|
+
sessionName,
|
|
276
|
+
logPath,
|
|
277
|
+
sessionStore,
|
|
278
|
+
dispatch,
|
|
279
|
+
});
|
|
280
|
+
if (!nextAction) {
|
|
281
|
+
return response;
|
|
282
|
+
}
|
|
283
|
+
actions[index] = nextAction;
|
|
284
|
+
response = await invoke({
|
|
285
|
+
token: req.token,
|
|
286
|
+
session: sessionName,
|
|
287
|
+
command: nextAction.command,
|
|
288
|
+
positionals: nextAction.positionals ?? [],
|
|
289
|
+
flags: nextAction.flags ?? {},
|
|
290
|
+
});
|
|
291
|
+
if (!response.ok) {
|
|
292
|
+
return response;
|
|
293
|
+
}
|
|
294
|
+
healed += 1;
|
|
295
|
+
}
|
|
296
|
+
if (shouldUpdate && healed > 0) {
|
|
297
|
+
const session = sessionStore.get(sessionName);
|
|
298
|
+
writeReplayScript(resolved, actions, session);
|
|
299
|
+
}
|
|
300
|
+
return { ok: true, data: { replayed: actions.length, healed, session: sessionName } };
|
|
301
|
+
} catch (err) {
|
|
302
|
+
const appErr = asAppError(err);
|
|
303
|
+
return { ok: false, error: { code: appErr.code, message: appErr.message } };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (command === 'close') {
|
|
308
|
+
const session = sessionStore.get(sessionName);
|
|
309
|
+
if (!session) {
|
|
310
|
+
return { ok: false, error: { code: 'SESSION_NOT_FOUND', message: 'No active session' } };
|
|
311
|
+
}
|
|
312
|
+
if (req.positionals && req.positionals.length > 0) {
|
|
313
|
+
await dispatch(session.device, 'close', req.positionals ?? [], req.flags?.out, {
|
|
314
|
+
...contextFromFlags(logPath, req.flags, session.appBundleId, session.trace?.outPath),
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
if (session.device.platform === 'ios' && session.device.kind === 'simulator') {
|
|
318
|
+
await stopIosRunnerSession(session.device.id);
|
|
319
|
+
}
|
|
320
|
+
sessionStore.recordAction(session, {
|
|
321
|
+
command,
|
|
322
|
+
positionals: req.positionals ?? [],
|
|
323
|
+
flags: req.flags ?? {},
|
|
324
|
+
result: { session: sessionName },
|
|
325
|
+
});
|
|
326
|
+
if (req.flags?.saveScript) {
|
|
327
|
+
session.recordSession = true;
|
|
328
|
+
}
|
|
329
|
+
sessionStore.writeSessionLog(session);
|
|
330
|
+
sessionStore.delete(sessionName);
|
|
331
|
+
return { ok: true, data: { session: sessionName } };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function healReplayAction(params: {
|
|
338
|
+
action: SessionAction;
|
|
339
|
+
sessionName: string;
|
|
340
|
+
logPath: string;
|
|
341
|
+
sessionStore: SessionStore;
|
|
342
|
+
dispatch: typeof dispatchCommand;
|
|
343
|
+
}): Promise<SessionAction | null> {
|
|
344
|
+
const { action, sessionName, logPath, sessionStore, dispatch } = params;
|
|
345
|
+
if (!['click', 'fill', 'get', 'is', 'wait'].includes(action.command)) return null;
|
|
346
|
+
const session = sessionStore.get(sessionName);
|
|
347
|
+
if (!session) return null;
|
|
348
|
+
const requiresRect = action.command === 'click' || action.command === 'fill';
|
|
349
|
+
const snapshot = await captureSnapshotForReplay(session, action, logPath, requiresRect, dispatch, sessionStore);
|
|
350
|
+
const selectorCandidates = collectReplaySelectorCandidates(action);
|
|
351
|
+
for (const candidate of selectorCandidates) {
|
|
352
|
+
const chain = parseSelectorChain(candidate);
|
|
353
|
+
const resolved = resolveSelectorChain(snapshot.nodes, chain, {
|
|
354
|
+
platform: session.device.platform,
|
|
355
|
+
requireRect: requiresRect,
|
|
356
|
+
requireUnique: true,
|
|
357
|
+
});
|
|
358
|
+
if (!resolved) continue;
|
|
359
|
+
const selectorChain = buildSelectorChainForNode(resolved.node, session.device.platform, {
|
|
360
|
+
action: action.command === 'click' ? 'click' : action.command === 'fill' ? 'fill' : 'get',
|
|
361
|
+
});
|
|
362
|
+
const selectorExpression = selectorChain.join(' || ');
|
|
363
|
+
if (action.command === 'click') {
|
|
364
|
+
return {
|
|
365
|
+
...action,
|
|
366
|
+
positionals: [selectorExpression],
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
if (action.command === 'fill') {
|
|
370
|
+
const fillText = inferFillText(action);
|
|
371
|
+
if (!fillText) continue;
|
|
372
|
+
return {
|
|
373
|
+
...action,
|
|
374
|
+
positionals: [selectorExpression, fillText],
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
if (action.command === 'get') {
|
|
378
|
+
const sub = action.positionals?.[0];
|
|
379
|
+
if (sub !== 'text' && sub !== 'attrs') continue;
|
|
380
|
+
return {
|
|
381
|
+
...action,
|
|
382
|
+
positionals: [sub, selectorExpression],
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
if (action.command === 'is') {
|
|
386
|
+
const predicate = action.positionals?.[0];
|
|
387
|
+
if (!predicate) continue;
|
|
388
|
+
const split = splitSelectorFromArgs(action.positionals.slice(1));
|
|
389
|
+
const expectedText = split?.rest.join(' ').trim() ?? '';
|
|
390
|
+
const nextPositionals = [predicate, selectorExpression];
|
|
391
|
+
if (predicate === 'text' && expectedText.length > 0) {
|
|
392
|
+
nextPositionals.push(expectedText);
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
...action,
|
|
396
|
+
positionals: nextPositionals,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
if (action.command === 'wait') {
|
|
400
|
+
const { selectorTimeout } = parseSelectorWaitPositionals(action.positionals ?? []);
|
|
401
|
+
const nextPositionals = [selectorExpression];
|
|
402
|
+
if (selectorTimeout) {
|
|
403
|
+
nextPositionals.push(selectorTimeout);
|
|
404
|
+
}
|
|
405
|
+
return {
|
|
406
|
+
...action,
|
|
407
|
+
positionals: nextPositionals,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function captureSnapshotForReplay(
|
|
415
|
+
session: SessionState,
|
|
416
|
+
action: SessionAction,
|
|
417
|
+
logPath: string,
|
|
418
|
+
interactiveOnly: boolean,
|
|
419
|
+
dispatch: typeof dispatchCommand,
|
|
420
|
+
sessionStore: SessionStore,
|
|
421
|
+
): Promise<SnapshotState> {
|
|
422
|
+
const data = (await dispatch(session.device, 'snapshot', [], action.flags?.out, {
|
|
423
|
+
...contextFromFlags(
|
|
424
|
+
logPath,
|
|
425
|
+
{
|
|
426
|
+
...(action.flags ?? {}),
|
|
427
|
+
snapshotInteractiveOnly: interactiveOnly,
|
|
428
|
+
snapshotCompact: interactiveOnly,
|
|
429
|
+
},
|
|
430
|
+
session.appBundleId,
|
|
431
|
+
session.trace?.outPath,
|
|
432
|
+
),
|
|
433
|
+
})) as {
|
|
434
|
+
nodes?: RawSnapshotNode[];
|
|
435
|
+
truncated?: boolean;
|
|
436
|
+
backend?: 'ax' | 'xctest' | 'android';
|
|
437
|
+
};
|
|
438
|
+
const rawNodes = data?.nodes ?? [];
|
|
439
|
+
const nodes = attachRefs(action.flags?.snapshotRaw ? rawNodes : pruneGroupNodes(rawNodes));
|
|
440
|
+
const snapshot: SnapshotState = {
|
|
441
|
+
nodes,
|
|
442
|
+
truncated: data?.truncated,
|
|
443
|
+
createdAt: Date.now(),
|
|
444
|
+
backend: data?.backend,
|
|
445
|
+
};
|
|
446
|
+
session.snapshot = snapshot;
|
|
447
|
+
sessionStore.set(session.name, session);
|
|
448
|
+
return snapshot;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function collectReplaySelectorCandidates(action: SessionAction): string[] {
|
|
452
|
+
const result: string[] = [];
|
|
453
|
+
const explicitChain =
|
|
454
|
+
Array.isArray(action.result?.selectorChain) &&
|
|
455
|
+
action.result?.selectorChain.every((entry) => typeof entry === 'string')
|
|
456
|
+
? (action.result.selectorChain as string[])
|
|
457
|
+
: [];
|
|
458
|
+
result.push(...explicitChain);
|
|
459
|
+
|
|
460
|
+
if (action.command === 'click') {
|
|
461
|
+
const first = action.positionals?.[0] ?? '';
|
|
462
|
+
if (first && !first.startsWith('@')) {
|
|
463
|
+
result.push(action.positionals.join(' '));
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (action.command === 'fill') {
|
|
467
|
+
const first = action.positionals?.[0] ?? '';
|
|
468
|
+
if (first && !first.startsWith('@') && Number.isNaN(Number(first))) {
|
|
469
|
+
result.push(first);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (action.command === 'get') {
|
|
473
|
+
const selector = action.positionals?.[1] ?? '';
|
|
474
|
+
if (selector && !selector.startsWith('@')) {
|
|
475
|
+
result.push(action.positionals.slice(1).join(' '));
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (action.command === 'is') {
|
|
479
|
+
const split = splitSelectorFromArgs(action.positionals.slice(1));
|
|
480
|
+
if (split) {
|
|
481
|
+
result.push(split.selectorExpression);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
if (action.command === 'wait') {
|
|
485
|
+
const { selectorExpression } = parseSelectorWaitPositionals(action.positionals ?? []);
|
|
486
|
+
if (selectorExpression) {
|
|
487
|
+
result.push(selectorExpression);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const refLabel = typeof action.result?.refLabel === 'string' ? action.result.refLabel.trim() : '';
|
|
492
|
+
if (refLabel.length > 0) {
|
|
493
|
+
const quoted = JSON.stringify(refLabel);
|
|
494
|
+
if (action.command === 'fill') {
|
|
495
|
+
result.push(`id=${quoted} editable=true`);
|
|
496
|
+
result.push(`label=${quoted} editable=true`);
|
|
497
|
+
result.push(`text=${quoted} editable=true`);
|
|
498
|
+
result.push(`value=${quoted} editable=true`);
|
|
499
|
+
} else {
|
|
500
|
+
result.push(`id=${quoted}`);
|
|
501
|
+
result.push(`label=${quoted}`);
|
|
502
|
+
result.push(`text=${quoted}`);
|
|
503
|
+
result.push(`value=${quoted}`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return uniqueStrings(result).filter((entry) => entry.trim().length > 0);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function parseSelectorWaitPositionals(positionals: string[]): {
|
|
511
|
+
selectorExpression: string | null;
|
|
512
|
+
selectorTimeout: string | null;
|
|
513
|
+
} {
|
|
514
|
+
if (positionals.length === 0) return { selectorExpression: null, selectorTimeout: null };
|
|
515
|
+
const maybeTimeout = positionals[positionals.length - 1];
|
|
516
|
+
const hasTimeout = /^\d+$/.test(maybeTimeout ?? '');
|
|
517
|
+
const selectorTokens = hasTimeout ? positionals.slice(0, -1) : positionals.slice();
|
|
518
|
+
const split = splitSelectorFromArgs(selectorTokens);
|
|
519
|
+
if (!split || split.rest.length > 0) {
|
|
520
|
+
return { selectorExpression: null, selectorTimeout: null };
|
|
521
|
+
}
|
|
522
|
+
return {
|
|
523
|
+
selectorExpression: split.selectorExpression,
|
|
524
|
+
selectorTimeout: hasTimeout ? maybeTimeout : null,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function parseReplayScript(script: string): SessionAction[] {
|
|
529
|
+
const actions: SessionAction[] = [];
|
|
530
|
+
const lines = script.split(/\r?\n/);
|
|
531
|
+
for (const line of lines) {
|
|
532
|
+
const parsed = parseReplayScriptLine(line);
|
|
533
|
+
if (parsed) {
|
|
534
|
+
actions.push(parsed);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return actions;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function parseReplayScriptLine(line: string): SessionAction | null {
|
|
541
|
+
const trimmed = line.trim();
|
|
542
|
+
if (trimmed.length === 0 || trimmed.startsWith('#')) return null;
|
|
543
|
+
const tokens = tokenizeReplayLine(trimmed);
|
|
544
|
+
if (tokens.length === 0) return null;
|
|
545
|
+
const [command, ...args] = tokens;
|
|
546
|
+
if (command === 'context') return null;
|
|
547
|
+
|
|
548
|
+
const action: SessionAction = {
|
|
549
|
+
ts: Date.now(),
|
|
550
|
+
command,
|
|
551
|
+
positionals: [],
|
|
552
|
+
flags: {},
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
if (command === 'snapshot') {
|
|
556
|
+
action.positionals = [];
|
|
557
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
558
|
+
const token = args[index];
|
|
559
|
+
if (token === '-i') {
|
|
560
|
+
action.flags.snapshotInteractiveOnly = true;
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
if (token === '-c') {
|
|
564
|
+
action.flags.snapshotCompact = true;
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
if (token === '--raw') {
|
|
568
|
+
action.flags.snapshotRaw = true;
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
if ((token === '-d' || token === '--depth') && index + 1 < args.length) {
|
|
572
|
+
const parsedDepth = Number(args[index + 1]);
|
|
573
|
+
if (Number.isFinite(parsedDepth) && parsedDepth >= 0) {
|
|
574
|
+
action.flags.snapshotDepth = Math.floor(parsedDepth);
|
|
575
|
+
}
|
|
576
|
+
index += 1;
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
if ((token === '-s' || token === '--scope') && index + 1 < args.length) {
|
|
580
|
+
action.flags.snapshotScope = args[index + 1];
|
|
581
|
+
index += 1;
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
if (token === '--backend' && index + 1 < args.length) {
|
|
585
|
+
const backend = args[index + 1];
|
|
586
|
+
if (backend === 'ax' || backend === 'xctest') {
|
|
587
|
+
action.flags.snapshotBackend = backend;
|
|
588
|
+
}
|
|
589
|
+
index += 1;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return action;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (command === 'click') {
|
|
596
|
+
if (args.length === 0) return action;
|
|
597
|
+
const target = args[0];
|
|
598
|
+
if (target.startsWith('@')) {
|
|
599
|
+
action.positionals = [target];
|
|
600
|
+
if (args[1]) {
|
|
601
|
+
action.result = { refLabel: args[1] };
|
|
602
|
+
}
|
|
603
|
+
return action;
|
|
604
|
+
}
|
|
605
|
+
action.positionals = [args.join(' ')];
|
|
606
|
+
return action;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (command === 'fill') {
|
|
610
|
+
if (args.length < 2) {
|
|
611
|
+
action.positionals = args;
|
|
612
|
+
return action;
|
|
613
|
+
}
|
|
614
|
+
const target = args[0];
|
|
615
|
+
if (target.startsWith('@')) {
|
|
616
|
+
if (args.length >= 3) {
|
|
617
|
+
action.positionals = [target, args.slice(2).join(' ')];
|
|
618
|
+
action.result = { refLabel: args[1] };
|
|
619
|
+
return action;
|
|
620
|
+
}
|
|
621
|
+
action.positionals = [target, args[1]];
|
|
622
|
+
return action;
|
|
623
|
+
}
|
|
624
|
+
action.positionals = [target, args.slice(1).join(' ')];
|
|
625
|
+
return action;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (command === 'get') {
|
|
629
|
+
if (args.length < 2) {
|
|
630
|
+
action.positionals = args;
|
|
631
|
+
return action;
|
|
632
|
+
}
|
|
633
|
+
const sub = args[0];
|
|
634
|
+
const target = args[1];
|
|
635
|
+
if (target.startsWith('@')) {
|
|
636
|
+
action.positionals = [sub, target];
|
|
637
|
+
if (args[2]) {
|
|
638
|
+
action.result = { refLabel: args[2] };
|
|
639
|
+
}
|
|
640
|
+
return action;
|
|
641
|
+
}
|
|
642
|
+
action.positionals = [sub, args.slice(1).join(' ')];
|
|
643
|
+
return action;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
action.positionals = args;
|
|
647
|
+
return action;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function tokenizeReplayLine(line: string): string[] {
|
|
651
|
+
const tokens: string[] = [];
|
|
652
|
+
let cursor = 0;
|
|
653
|
+
while (cursor < line.length) {
|
|
654
|
+
while (cursor < line.length && /\s/.test(line[cursor])) {
|
|
655
|
+
cursor += 1;
|
|
656
|
+
}
|
|
657
|
+
if (cursor >= line.length) break;
|
|
658
|
+
if (line[cursor] === '"') {
|
|
659
|
+
let end = cursor + 1;
|
|
660
|
+
let escaped = false;
|
|
661
|
+
while (end < line.length) {
|
|
662
|
+
const char = line[end];
|
|
663
|
+
if (char === '"' && !escaped) break;
|
|
664
|
+
escaped = char === '\\' && !escaped;
|
|
665
|
+
if (char !== '\\') escaped = false;
|
|
666
|
+
end += 1;
|
|
667
|
+
}
|
|
668
|
+
if (end >= line.length) {
|
|
669
|
+
throw new AppError('INVALID_ARGS', `Invalid replay script line: ${line}`);
|
|
670
|
+
}
|
|
671
|
+
const literal = line.slice(cursor, end + 1);
|
|
672
|
+
tokens.push(JSON.parse(literal) as string);
|
|
673
|
+
cursor = end + 1;
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
let end = cursor;
|
|
677
|
+
while (end < line.length && !/\s/.test(line[end])) {
|
|
678
|
+
end += 1;
|
|
679
|
+
}
|
|
680
|
+
tokens.push(line.slice(cursor, end));
|
|
681
|
+
cursor = end;
|
|
682
|
+
}
|
|
683
|
+
return tokens;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function writeReplayScript(filePath: string, actions: SessionAction[], session?: SessionState) {
|
|
687
|
+
const lines: string[] = [];
|
|
688
|
+
// Session can be missing if the replay session is closed/deleted between execution and update write.
|
|
689
|
+
// In that case we still persist healed actions and omit only the context header.
|
|
690
|
+
if (session) {
|
|
691
|
+
const deviceLabel = session.device.name.replace(/"/g, '\\"');
|
|
692
|
+
const kind = session.device.kind ? ` kind=${session.device.kind}` : '';
|
|
693
|
+
lines.push(`context platform=${session.device.platform} device="${deviceLabel}"${kind} theme=unknown`);
|
|
694
|
+
}
|
|
695
|
+
for (const action of actions) {
|
|
696
|
+
lines.push(formatReplayActionLine(action));
|
|
697
|
+
}
|
|
698
|
+
const serialized = `${lines.join('\n')}\n`;
|
|
699
|
+
const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
700
|
+
fs.writeFileSync(tmpPath, serialized);
|
|
701
|
+
fs.renameSync(tmpPath, filePath);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function formatReplayActionLine(action: SessionAction): string {
|
|
705
|
+
const parts: string[] = [action.command];
|
|
706
|
+
if (action.command === 'snapshot') {
|
|
707
|
+
if (action.flags?.snapshotInteractiveOnly) parts.push('-i');
|
|
708
|
+
if (action.flags?.snapshotCompact) parts.push('-c');
|
|
709
|
+
if (typeof action.flags?.snapshotDepth === 'number') {
|
|
710
|
+
parts.push('-d', String(action.flags.snapshotDepth));
|
|
711
|
+
}
|
|
712
|
+
if (action.flags?.snapshotScope) {
|
|
713
|
+
parts.push('-s', formatReplayArg(action.flags.snapshotScope));
|
|
714
|
+
}
|
|
715
|
+
if (action.flags?.snapshotRaw) parts.push('--raw');
|
|
716
|
+
if (action.flags?.snapshotBackend) {
|
|
717
|
+
parts.push('--backend', action.flags.snapshotBackend);
|
|
718
|
+
}
|
|
719
|
+
return parts.join(' ');
|
|
720
|
+
}
|
|
721
|
+
for (const positional of action.positionals ?? []) {
|
|
722
|
+
parts.push(formatReplayArg(positional));
|
|
723
|
+
}
|
|
724
|
+
return parts.join(' ');
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function formatReplayArg(value: string): string {
|
|
728
|
+
const trimmed = value.trim();
|
|
729
|
+
if (trimmed.startsWith('@')) return trimmed;
|
|
730
|
+
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed;
|
|
731
|
+
return JSON.stringify(trimmed);
|
|
732
|
+
}
|