agent-device 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -9
- package/dist/src/797.js +1 -1
- package/dist/src/bin.js +5 -5
- package/dist/src/daemon.js +16 -20
- package/package.json +7 -9
- package/skills/agent-device/SKILL.md +3 -6
- package/skills/agent-device/references/permissions.md +3 -15
- package/skills/agent-device/references/snapshot-refs.md +1 -4
- package/dist/bin/axsnapshot +0 -0
- package/ios-runner/AXSnapshot/Package.swift +0 -18
- package/ios-runner/AXSnapshot/Sources/AXSnapshot/main.swift +0 -444
- package/src/__tests__/cli-close.test.ts +0 -155
- package/src/__tests__/cli-help.test.ts +0 -102
- package/src/bin.ts +0 -3
- package/src/cli.ts +0 -305
- package/src/core/__tests__/capabilities.test.ts +0 -75
- package/src/core/__tests__/dispatch-open.test.ts +0 -25
- package/src/core/__tests__/open-target.test.ts +0 -55
- package/src/core/capabilities.ts +0 -57
- package/src/core/dispatch.ts +0 -382
- package/src/core/open-target.ts +0 -27
- package/src/daemon/__tests__/device-ready.test.ts +0 -52
- package/src/daemon/__tests__/is-predicates.test.ts +0 -68
- package/src/daemon/__tests__/selectors.test.ts +0 -261
- package/src/daemon/__tests__/session-routing.test.ts +0 -108
- package/src/daemon/__tests__/session-selector.test.ts +0 -64
- package/src/daemon/__tests__/session-store.test.ts +0 -142
- package/src/daemon/__tests__/snapshot-processing.test.ts +0 -47
- package/src/daemon/action-utils.ts +0 -29
- package/src/daemon/context.ts +0 -48
- package/src/daemon/device-ready.ts +0 -155
- package/src/daemon/handlers/__tests__/find.test.ts +0 -99
- package/src/daemon/handlers/__tests__/interaction.test.ts +0 -22
- package/src/daemon/handlers/__tests__/replay-heal.test.ts +0 -509
- package/src/daemon/handlers/__tests__/session-reinstall.test.ts +0 -219
- package/src/daemon/handlers/__tests__/session.test.ts +0 -820
- package/src/daemon/handlers/__tests__/snapshot-handler.test.ts +0 -92
- package/src/daemon/handlers/__tests__/snapshot.test.ts +0 -128
- package/src/daemon/handlers/find.ts +0 -324
- package/src/daemon/handlers/interaction.ts +0 -550
- package/src/daemon/handlers/parse-utils.ts +0 -8
- package/src/daemon/handlers/record-trace.ts +0 -154
- package/src/daemon/handlers/session.ts +0 -1137
- package/src/daemon/handlers/snapshot.ts +0 -439
- package/src/daemon/is-predicates.ts +0 -46
- package/src/daemon/selectors.ts +0 -540
- package/src/daemon/session-routing.ts +0 -22
- package/src/daemon/session-selector.ts +0 -39
- package/src/daemon/session-store.ts +0 -296
- package/src/daemon/snapshot-processing.ts +0 -131
- package/src/daemon/types.ts +0 -56
- package/src/daemon-client.ts +0 -272
- package/src/daemon.ts +0 -295
- package/src/platforms/__tests__/boot-diagnostics.test.ts +0 -59
- package/src/platforms/android/__tests__/index.test.ts +0 -274
- package/src/platforms/android/devices.ts +0 -196
- package/src/platforms/android/index.ts +0 -784
- package/src/platforms/android/ui-hierarchy.ts +0 -312
- package/src/platforms/boot-diagnostics.ts +0 -128
- package/src/platforms/ios/__tests__/index.test.ts +0 -312
- package/src/platforms/ios/__tests__/runner-client.test.ts +0 -155
- package/src/platforms/ios/apps.ts +0 -358
- package/src/platforms/ios/ax-snapshot.ts +0 -207
- package/src/platforms/ios/config.ts +0 -28
- package/src/platforms/ios/devicectl.ts +0 -134
- package/src/platforms/ios/devices.ts +0 -100
- package/src/platforms/ios/index.ts +0 -20
- package/src/platforms/ios/runner-client.ts +0 -994
- package/src/platforms/ios/simulator.ts +0 -164
- package/src/utils/__tests__/args.test.ts +0 -239
- package/src/utils/__tests__/daemon-client.test.ts +0 -95
- package/src/utils/__tests__/exec.test.ts +0 -16
- package/src/utils/__tests__/finders.test.ts +0 -34
- package/src/utils/__tests__/keyed-lock.test.ts +0 -55
- package/src/utils/__tests__/process-identity.test.ts +0 -33
- package/src/utils/__tests__/retry.test.ts +0 -44
- package/src/utils/args.ts +0 -239
- package/src/utils/command-schema.ts +0 -622
- package/src/utils/device.ts +0 -84
- package/src/utils/errors.ts +0 -35
- package/src/utils/exec.ts +0 -339
- package/src/utils/finders.ts +0 -101
- package/src/utils/interactive.ts +0 -4
- package/src/utils/interactors.ts +0 -173
- package/src/utils/keyed-lock.ts +0 -14
- package/src/utils/output.ts +0 -204
- package/src/utils/process-identity.ts +0 -100
- package/src/utils/retry.ts +0 -180
- package/src/utils/snapshot.ts +0 -64
- package/src/utils/timeouts.ts +0 -9
- package/src/utils/version.ts +0 -26
|
@@ -1,1137 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import { dispatchCommand, resolveTargetDevice } from '../../core/dispatch.ts';
|
|
3
|
-
import { isCommandSupportedOnDevice } from '../../core/capabilities.ts';
|
|
4
|
-
import { isDeepLinkTarget, resolveIosDeviceDeepLinkBundleId } from '../../core/open-target.ts';
|
|
5
|
-
import { AppError, asAppError } from '../../utils/errors.ts';
|
|
6
|
-
import type { DeviceInfo } from '../../utils/device.ts';
|
|
7
|
-
import type { DaemonRequest, DaemonResponse, SessionAction, SessionState } from '../types.ts';
|
|
8
|
-
import { SessionStore } from '../session-store.ts';
|
|
9
|
-
import { contextFromFlags } from '../context.ts';
|
|
10
|
-
import { ensureDeviceReady } from '../device-ready.ts';
|
|
11
|
-
import { stopIosRunnerSession } from '../../platforms/ios/runner-client.ts';
|
|
12
|
-
import { attachRefs, type RawSnapshotNode, type SnapshotState } from '../../utils/snapshot.ts';
|
|
13
|
-
import { extractNodeText, normalizeType, pruneGroupNodes } from '../snapshot-processing.ts';
|
|
14
|
-
import {
|
|
15
|
-
buildSelectorChainForNode,
|
|
16
|
-
resolveSelectorChain,
|
|
17
|
-
splitIsSelectorArgs,
|
|
18
|
-
splitSelectorFromArgs,
|
|
19
|
-
tryParseSelectorChain,
|
|
20
|
-
} from '../selectors.ts';
|
|
21
|
-
import { inferFillText, uniqueStrings } from '../action-utils.ts';
|
|
22
|
-
|
|
23
|
-
type ReinstallOps = {
|
|
24
|
-
ios: (device: DeviceInfo, app: string, appPath: string) => Promise<{ bundleId: string }>;
|
|
25
|
-
android: (device: DeviceInfo, app: string, appPath: string) => Promise<{ package: string }>;
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
const IOS_APPSTATE_SESSION_REQUIRED_MESSAGE =
|
|
29
|
-
'iOS appstate requires an active session on the target device. Run open first (for example: open --session sim --platform ios --device "<name>" <app>).';
|
|
30
|
-
|
|
31
|
-
function requireSessionOrExplicitSelector(
|
|
32
|
-
command: string,
|
|
33
|
-
session: SessionState | undefined,
|
|
34
|
-
flags: DaemonRequest['flags'] | undefined,
|
|
35
|
-
): DaemonResponse | null {
|
|
36
|
-
if (session || hasExplicitDeviceSelector(flags)) {
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
39
|
-
return {
|
|
40
|
-
ok: false,
|
|
41
|
-
error: {
|
|
42
|
-
code: 'INVALID_ARGS',
|
|
43
|
-
message: `${command} requires an active session or an explicit device selector (e.g. --platform ios).`,
|
|
44
|
-
},
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function hasExplicitDeviceSelector(flags: DaemonRequest['flags'] | undefined): boolean {
|
|
49
|
-
return Boolean(flags?.platform || flags?.device || flags?.udid || flags?.serial);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function hasExplicitSessionFlag(flags: DaemonRequest['flags'] | undefined): boolean {
|
|
53
|
-
return typeof flags?.session === 'string' && flags.session.trim().length > 0;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function selectorTargetsSessionDevice(
|
|
57
|
-
flags: DaemonRequest['flags'] | undefined,
|
|
58
|
-
session: SessionState | undefined,
|
|
59
|
-
): boolean {
|
|
60
|
-
if (!session) return false;
|
|
61
|
-
if (!hasExplicitDeviceSelector(flags)) return true;
|
|
62
|
-
if (flags?.platform && flags.platform !== session.device.platform) return false;
|
|
63
|
-
if (flags?.udid && flags.udid !== session.device.id) return false;
|
|
64
|
-
if (flags?.serial && flags.serial !== session.device.id) return false;
|
|
65
|
-
if (flags?.device) {
|
|
66
|
-
return flags.device.trim().toLowerCase() === session.device.name.trim().toLowerCase();
|
|
67
|
-
}
|
|
68
|
-
return true;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
async function resolveCommandDevice(params: {
|
|
72
|
-
session: SessionState | undefined;
|
|
73
|
-
flags: DaemonRequest['flags'] | undefined;
|
|
74
|
-
ensureReadyFn: typeof ensureDeviceReady;
|
|
75
|
-
resolveTargetDeviceFn: typeof resolveTargetDevice;
|
|
76
|
-
ensureReady?: boolean;
|
|
77
|
-
}): Promise<DeviceInfo> {
|
|
78
|
-
const shouldUseExplicitSelector = hasExplicitDeviceSelector(params.flags);
|
|
79
|
-
const device =
|
|
80
|
-
shouldUseExplicitSelector || !params.session
|
|
81
|
-
? await params.resolveTargetDeviceFn(params.flags ?? {})
|
|
82
|
-
: params.session.device;
|
|
83
|
-
if (params.ensureReady !== false) {
|
|
84
|
-
await params.ensureReadyFn(device);
|
|
85
|
-
}
|
|
86
|
-
return device;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const defaultReinstallOps: ReinstallOps = {
|
|
90
|
-
ios: async (device, app, appPath) => {
|
|
91
|
-
const { reinstallIosApp } = await import('../../platforms/ios/index.ts');
|
|
92
|
-
return await reinstallIosApp(device, app, appPath);
|
|
93
|
-
},
|
|
94
|
-
android: async (device, app, appPath) => {
|
|
95
|
-
const { reinstallAndroidApp } = await import('../../platforms/android/index.ts');
|
|
96
|
-
return await reinstallAndroidApp(device, app, appPath);
|
|
97
|
-
},
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
async function resolveIosBundleIdForOpen(
|
|
101
|
-
device: DeviceInfo,
|
|
102
|
-
openTarget: string | undefined,
|
|
103
|
-
currentAppBundleId?: string,
|
|
104
|
-
): Promise<string | undefined> {
|
|
105
|
-
if (device.platform !== 'ios' || !openTarget) return undefined;
|
|
106
|
-
if (isDeepLinkTarget(openTarget)) {
|
|
107
|
-
if (device.kind === 'device') {
|
|
108
|
-
return resolveIosDeviceDeepLinkBundleId(currentAppBundleId, openTarget);
|
|
109
|
-
}
|
|
110
|
-
return undefined;
|
|
111
|
-
}
|
|
112
|
-
return await tryResolveIosAppBundleId(device, openTarget);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
async function tryResolveIosAppBundleId(device: DeviceInfo, openTarget: string): Promise<string | undefined> {
|
|
116
|
-
try {
|
|
117
|
-
const { resolveIosApp } = await import('../../platforms/ios/index.ts');
|
|
118
|
-
return await resolveIosApp(device, openTarget);
|
|
119
|
-
} catch {
|
|
120
|
-
return undefined;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
async function handleAppStateCommand(params: {
|
|
125
|
-
req: DaemonRequest;
|
|
126
|
-
sessionName: string;
|
|
127
|
-
sessionStore: SessionStore;
|
|
128
|
-
ensureReady: typeof ensureDeviceReady;
|
|
129
|
-
resolveDevice: typeof resolveTargetDevice;
|
|
130
|
-
}): Promise<DaemonResponse> {
|
|
131
|
-
const { req, sessionName, sessionStore, ensureReady, resolveDevice } = params;
|
|
132
|
-
const session = sessionStore.get(sessionName);
|
|
133
|
-
const flags = req.flags ?? {};
|
|
134
|
-
if (!session && hasExplicitSessionFlag(flags)) {
|
|
135
|
-
const iOSSessionHint =
|
|
136
|
-
flags.platform === 'ios'
|
|
137
|
-
? `No active session "${sessionName}". Run open with --session ${sessionName} first.`
|
|
138
|
-
: `No active session "${sessionName}". Run open with --session ${sessionName} first, or omit --session to query by device selector.`;
|
|
139
|
-
return {
|
|
140
|
-
ok: false,
|
|
141
|
-
error: {
|
|
142
|
-
code: 'SESSION_NOT_FOUND',
|
|
143
|
-
message: iOSSessionHint,
|
|
144
|
-
},
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
const guard = requireSessionOrExplicitSelector('appstate', session, flags);
|
|
148
|
-
if (guard) return guard;
|
|
149
|
-
|
|
150
|
-
const shouldUseSessionStateForIos = session?.device.platform === 'ios' && selectorTargetsSessionDevice(flags, session);
|
|
151
|
-
const targetsIos = flags.platform === 'ios';
|
|
152
|
-
if (targetsIos && !shouldUseSessionStateForIos) {
|
|
153
|
-
return {
|
|
154
|
-
ok: false,
|
|
155
|
-
error: {
|
|
156
|
-
code: 'SESSION_NOT_FOUND',
|
|
157
|
-
message: IOS_APPSTATE_SESSION_REQUIRED_MESSAGE,
|
|
158
|
-
},
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
if (shouldUseSessionStateForIos) {
|
|
162
|
-
const appName = session.appName ?? session.appBundleId;
|
|
163
|
-
if (!session.appName && !session.appBundleId) {
|
|
164
|
-
return {
|
|
165
|
-
ok: false,
|
|
166
|
-
error: {
|
|
167
|
-
code: 'COMMAND_FAILED',
|
|
168
|
-
message: 'No foreground app is tracked for this iOS session. Open an app in the session, then retry appstate.',
|
|
169
|
-
},
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
return {
|
|
173
|
-
ok: true,
|
|
174
|
-
data: {
|
|
175
|
-
platform: 'ios',
|
|
176
|
-
appName: appName ?? 'unknown',
|
|
177
|
-
appBundleId: session.appBundleId,
|
|
178
|
-
source: 'session',
|
|
179
|
-
},
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
const device = await resolveCommandDevice({
|
|
183
|
-
session,
|
|
184
|
-
flags,
|
|
185
|
-
ensureReadyFn: ensureReady,
|
|
186
|
-
resolveTargetDeviceFn: resolveDevice,
|
|
187
|
-
ensureReady: true,
|
|
188
|
-
});
|
|
189
|
-
if (device.platform === 'ios') {
|
|
190
|
-
return {
|
|
191
|
-
ok: false,
|
|
192
|
-
error: {
|
|
193
|
-
code: 'SESSION_NOT_FOUND',
|
|
194
|
-
message: IOS_APPSTATE_SESSION_REQUIRED_MESSAGE,
|
|
195
|
-
},
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
const { getAndroidAppState } = await import('../../platforms/android/index.ts');
|
|
199
|
-
const state = await getAndroidAppState(device);
|
|
200
|
-
return {
|
|
201
|
-
ok: true,
|
|
202
|
-
data: {
|
|
203
|
-
platform: 'android',
|
|
204
|
-
package: state.package,
|
|
205
|
-
activity: state.activity,
|
|
206
|
-
},
|
|
207
|
-
};
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
export async function handleSessionCommands(params: {
|
|
211
|
-
req: DaemonRequest;
|
|
212
|
-
sessionName: string;
|
|
213
|
-
logPath: string;
|
|
214
|
-
sessionStore: SessionStore;
|
|
215
|
-
invoke: (req: DaemonRequest) => Promise<DaemonResponse>;
|
|
216
|
-
dispatch?: typeof dispatchCommand;
|
|
217
|
-
ensureReady?: typeof ensureDeviceReady;
|
|
218
|
-
resolveTargetDevice?: typeof resolveTargetDevice;
|
|
219
|
-
reinstallOps?: ReinstallOps;
|
|
220
|
-
}): Promise<DaemonResponse | null> {
|
|
221
|
-
const {
|
|
222
|
-
req,
|
|
223
|
-
sessionName,
|
|
224
|
-
logPath,
|
|
225
|
-
sessionStore,
|
|
226
|
-
invoke,
|
|
227
|
-
dispatch: dispatchOverride,
|
|
228
|
-
ensureReady: ensureReadyOverride,
|
|
229
|
-
resolveTargetDevice: resolveTargetDeviceOverride,
|
|
230
|
-
reinstallOps = defaultReinstallOps,
|
|
231
|
-
} = params;
|
|
232
|
-
const dispatch = dispatchOverride ?? dispatchCommand;
|
|
233
|
-
const ensureReady = ensureReadyOverride ?? ensureDeviceReady;
|
|
234
|
-
const resolveDevice = resolveTargetDeviceOverride ?? resolveTargetDevice;
|
|
235
|
-
const command = req.command;
|
|
236
|
-
|
|
237
|
-
if (command === 'session_list') {
|
|
238
|
-
const data = {
|
|
239
|
-
sessions: sessionStore.toArray().map((s) => ({
|
|
240
|
-
name: s.name,
|
|
241
|
-
platform: s.device.platform,
|
|
242
|
-
device: s.device.name,
|
|
243
|
-
id: s.device.id,
|
|
244
|
-
createdAt: s.createdAt,
|
|
245
|
-
})),
|
|
246
|
-
};
|
|
247
|
-
return { ok: true, data };
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
if (command === 'devices') {
|
|
251
|
-
try {
|
|
252
|
-
const devices: DeviceInfo[] = [];
|
|
253
|
-
if (req.flags?.platform === 'android') {
|
|
254
|
-
const { listAndroidDevices } = await import('../../platforms/android/devices.ts');
|
|
255
|
-
devices.push(...(await listAndroidDevices()));
|
|
256
|
-
} else if (req.flags?.platform === 'ios') {
|
|
257
|
-
const { listIosDevices } = await import('../../platforms/ios/devices.ts');
|
|
258
|
-
devices.push(...(await listIosDevices()));
|
|
259
|
-
} else {
|
|
260
|
-
const { listAndroidDevices } = await import('../../platforms/android/devices.ts');
|
|
261
|
-
const { listIosDevices } = await import('../../platforms/ios/devices.ts');
|
|
262
|
-
try {
|
|
263
|
-
devices.push(...(await listAndroidDevices()));
|
|
264
|
-
} catch {
|
|
265
|
-
// ignore
|
|
266
|
-
}
|
|
267
|
-
try {
|
|
268
|
-
devices.push(...(await listIosDevices()));
|
|
269
|
-
} catch {
|
|
270
|
-
// ignore
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
return { ok: true, data: { devices } };
|
|
274
|
-
} catch (err) {
|
|
275
|
-
const appErr = asAppError(err);
|
|
276
|
-
return { ok: false, error: { code: appErr.code, message: appErr.message, details: appErr.details } };
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
if (command === 'apps') {
|
|
281
|
-
const session = sessionStore.get(sessionName);
|
|
282
|
-
const flags = req.flags ?? {};
|
|
283
|
-
const guard = requireSessionOrExplicitSelector(command, session, flags);
|
|
284
|
-
if (guard) return guard;
|
|
285
|
-
const device = await resolveCommandDevice({
|
|
286
|
-
session,
|
|
287
|
-
flags,
|
|
288
|
-
ensureReadyFn: ensureReady,
|
|
289
|
-
resolveTargetDeviceFn: resolveDevice,
|
|
290
|
-
ensureReady: true,
|
|
291
|
-
});
|
|
292
|
-
if (!isCommandSupportedOnDevice('apps', device)) {
|
|
293
|
-
return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'apps is not supported on this device' } };
|
|
294
|
-
}
|
|
295
|
-
const appsFilter = req.flags?.appsFilter ?? 'all';
|
|
296
|
-
if (device.platform === 'ios') {
|
|
297
|
-
const { listIosApps } = await import('../../platforms/ios/index.ts');
|
|
298
|
-
const apps = await listIosApps(device, appsFilter);
|
|
299
|
-
const formatted = apps.map((app) =>
|
|
300
|
-
app.name && app.name !== app.bundleId ? `${app.name} (${app.bundleId})` : app.bundleId,
|
|
301
|
-
);
|
|
302
|
-
return { ok: true, data: { apps: formatted } };
|
|
303
|
-
}
|
|
304
|
-
const { listAndroidApps } = await import('../../platforms/android/index.ts');
|
|
305
|
-
const apps = await listAndroidApps(device, appsFilter);
|
|
306
|
-
const formatted = apps.map((app) =>
|
|
307
|
-
app.name && app.name !== app.package ? `${app.name} (${app.package})` : app.package,
|
|
308
|
-
);
|
|
309
|
-
return { ok: true, data: { apps: formatted } };
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
if (command === 'boot') {
|
|
313
|
-
const session = sessionStore.get(sessionName);
|
|
314
|
-
const flags = req.flags ?? {};
|
|
315
|
-
const guard = requireSessionOrExplicitSelector(command, session, flags);
|
|
316
|
-
if (guard) return guard;
|
|
317
|
-
const device = await resolveCommandDevice({
|
|
318
|
-
session,
|
|
319
|
-
flags,
|
|
320
|
-
ensureReadyFn: ensureReady,
|
|
321
|
-
resolveTargetDeviceFn: resolveDevice,
|
|
322
|
-
ensureReady: true,
|
|
323
|
-
});
|
|
324
|
-
if (!isCommandSupportedOnDevice('boot', device)) {
|
|
325
|
-
return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'boot is not supported on this device' } };
|
|
326
|
-
}
|
|
327
|
-
return {
|
|
328
|
-
ok: true,
|
|
329
|
-
data: {
|
|
330
|
-
platform: device.platform,
|
|
331
|
-
device: device.name,
|
|
332
|
-
id: device.id,
|
|
333
|
-
kind: device.kind,
|
|
334
|
-
booted: true,
|
|
335
|
-
},
|
|
336
|
-
};
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
if (command === 'appstate') {
|
|
340
|
-
return await handleAppStateCommand({
|
|
341
|
-
req,
|
|
342
|
-
sessionName,
|
|
343
|
-
sessionStore,
|
|
344
|
-
ensureReady,
|
|
345
|
-
resolveDevice,
|
|
346
|
-
});
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
if (command === 'reinstall') {
|
|
350
|
-
const session = sessionStore.get(sessionName);
|
|
351
|
-
const flags = req.flags ?? {};
|
|
352
|
-
const guard = requireSessionOrExplicitSelector(command, session, flags);
|
|
353
|
-
if (guard) return guard;
|
|
354
|
-
const app = req.positionals?.[0]?.trim();
|
|
355
|
-
const appPathInput = req.positionals?.[1]?.trim();
|
|
356
|
-
if (!app || !appPathInput) {
|
|
357
|
-
return {
|
|
358
|
-
ok: false,
|
|
359
|
-
error: { code: 'INVALID_ARGS', message: 'reinstall requires: reinstall <app> <path-to-app-binary>' },
|
|
360
|
-
};
|
|
361
|
-
}
|
|
362
|
-
const appPath = SessionStore.expandHome(appPathInput);
|
|
363
|
-
if (!fs.existsSync(appPath)) {
|
|
364
|
-
return {
|
|
365
|
-
ok: false,
|
|
366
|
-
error: { code: 'INVALID_ARGS', message: `App binary not found: ${appPath}` },
|
|
367
|
-
};
|
|
368
|
-
}
|
|
369
|
-
const device = await resolveCommandDevice({
|
|
370
|
-
session,
|
|
371
|
-
flags,
|
|
372
|
-
ensureReadyFn: ensureReady,
|
|
373
|
-
resolveTargetDeviceFn: resolveDevice,
|
|
374
|
-
ensureReady: false,
|
|
375
|
-
});
|
|
376
|
-
if (!isCommandSupportedOnDevice('reinstall', device)) {
|
|
377
|
-
return {
|
|
378
|
-
ok: false,
|
|
379
|
-
error: { code: 'UNSUPPORTED_OPERATION', message: 'reinstall is not supported on this device' },
|
|
380
|
-
};
|
|
381
|
-
}
|
|
382
|
-
let reinstallData:
|
|
383
|
-
| { platform: 'ios'; appId: string; bundleId: string }
|
|
384
|
-
| { platform: 'android'; appId: string; package: string };
|
|
385
|
-
if (device.platform === 'ios') {
|
|
386
|
-
const iosResult = await reinstallOps.ios(device, app, appPath);
|
|
387
|
-
reinstallData = { platform: 'ios', appId: iosResult.bundleId, bundleId: iosResult.bundleId };
|
|
388
|
-
} else {
|
|
389
|
-
const androidResult = await reinstallOps.android(device, app, appPath);
|
|
390
|
-
reinstallData = { platform: 'android', appId: androidResult.package, package: androidResult.package };
|
|
391
|
-
}
|
|
392
|
-
const result = { app, appPath, ...reinstallData };
|
|
393
|
-
if (session) {
|
|
394
|
-
sessionStore.recordAction(session, {
|
|
395
|
-
command,
|
|
396
|
-
positionals: req.positionals ?? [],
|
|
397
|
-
flags: req.flags ?? {},
|
|
398
|
-
result,
|
|
399
|
-
});
|
|
400
|
-
}
|
|
401
|
-
return { ok: true, data: result };
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
if (command === 'open') {
|
|
405
|
-
const shouldRelaunch = req.flags?.relaunch === true;
|
|
406
|
-
if (sessionStore.has(sessionName)) {
|
|
407
|
-
const session = sessionStore.get(sessionName);
|
|
408
|
-
const requestedOpenTarget = req.positionals?.[0];
|
|
409
|
-
const openTarget = requestedOpenTarget ?? (shouldRelaunch ? session?.appName : undefined);
|
|
410
|
-
if (!session || !openTarget) {
|
|
411
|
-
if (shouldRelaunch) {
|
|
412
|
-
return {
|
|
413
|
-
ok: false,
|
|
414
|
-
error: {
|
|
415
|
-
code: 'INVALID_ARGS',
|
|
416
|
-
message: 'open --relaunch requires an app name or an active session app.',
|
|
417
|
-
},
|
|
418
|
-
};
|
|
419
|
-
}
|
|
420
|
-
return {
|
|
421
|
-
ok: false,
|
|
422
|
-
error: {
|
|
423
|
-
code: 'INVALID_ARGS',
|
|
424
|
-
message: 'Session already active. Close it first or pass a new --session name.',
|
|
425
|
-
},
|
|
426
|
-
};
|
|
427
|
-
}
|
|
428
|
-
if (shouldRelaunch && isDeepLinkTarget(openTarget)) {
|
|
429
|
-
return {
|
|
430
|
-
ok: false,
|
|
431
|
-
error: {
|
|
432
|
-
code: 'INVALID_ARGS',
|
|
433
|
-
message: 'open --relaunch does not support URL targets.',
|
|
434
|
-
},
|
|
435
|
-
};
|
|
436
|
-
}
|
|
437
|
-
await ensureReady(session.device);
|
|
438
|
-
const appBundleId = await resolveIosBundleIdForOpen(session.device, openTarget, session.appBundleId);
|
|
439
|
-
const openPositionals = requestedOpenTarget ? (req.positionals ?? []) : [openTarget];
|
|
440
|
-
if (shouldRelaunch) {
|
|
441
|
-
const closeTarget = appBundleId ?? openTarget;
|
|
442
|
-
await dispatch(session.device, 'close', [closeTarget], req.flags?.out, {
|
|
443
|
-
...contextFromFlags(logPath, req.flags, appBundleId ?? session.appBundleId, session.trace?.outPath),
|
|
444
|
-
});
|
|
445
|
-
}
|
|
446
|
-
await dispatch(session.device, 'open', openPositionals, req.flags?.out, {
|
|
447
|
-
...contextFromFlags(logPath, req.flags, appBundleId),
|
|
448
|
-
});
|
|
449
|
-
const nextSession: SessionState = {
|
|
450
|
-
...session,
|
|
451
|
-
appBundleId,
|
|
452
|
-
appName: openTarget,
|
|
453
|
-
recordSession: session.recordSession || Boolean(req.flags?.saveScript),
|
|
454
|
-
snapshot: undefined,
|
|
455
|
-
};
|
|
456
|
-
sessionStore.recordAction(nextSession, {
|
|
457
|
-
command,
|
|
458
|
-
positionals: openPositionals,
|
|
459
|
-
flags: req.flags ?? {},
|
|
460
|
-
result: { session: sessionName, appName: openTarget, appBundleId },
|
|
461
|
-
});
|
|
462
|
-
sessionStore.set(sessionName, nextSession);
|
|
463
|
-
return { ok: true, data: { session: sessionName, appName: openTarget, appBundleId } };
|
|
464
|
-
}
|
|
465
|
-
const openTarget = req.positionals?.[0];
|
|
466
|
-
if (shouldRelaunch && !openTarget) {
|
|
467
|
-
return {
|
|
468
|
-
ok: false,
|
|
469
|
-
error: {
|
|
470
|
-
code: 'INVALID_ARGS',
|
|
471
|
-
message: 'open --relaunch requires an app argument.',
|
|
472
|
-
},
|
|
473
|
-
};
|
|
474
|
-
}
|
|
475
|
-
if (shouldRelaunch && openTarget && isDeepLinkTarget(openTarget)) {
|
|
476
|
-
return {
|
|
477
|
-
ok: false,
|
|
478
|
-
error: {
|
|
479
|
-
code: 'INVALID_ARGS',
|
|
480
|
-
message: 'open --relaunch does not support URL targets.',
|
|
481
|
-
},
|
|
482
|
-
};
|
|
483
|
-
}
|
|
484
|
-
const device = await resolveDevice(req.flags ?? {});
|
|
485
|
-
const inUse = sessionStore.toArray().find((s) => s.device.id === device.id);
|
|
486
|
-
if (inUse) {
|
|
487
|
-
return {
|
|
488
|
-
ok: false,
|
|
489
|
-
error: {
|
|
490
|
-
code: 'DEVICE_IN_USE',
|
|
491
|
-
message: `Device is already in use by session "${inUse.name}".`,
|
|
492
|
-
details: { session: inUse.name, deviceId: device.id, deviceName: device.name },
|
|
493
|
-
},
|
|
494
|
-
};
|
|
495
|
-
}
|
|
496
|
-
await ensureReady(device);
|
|
497
|
-
const appBundleId = await resolveIosBundleIdForOpen(device, openTarget);
|
|
498
|
-
if (shouldRelaunch && openTarget) {
|
|
499
|
-
const closeTarget = appBundleId ?? openTarget;
|
|
500
|
-
await dispatch(device, 'close', [closeTarget], req.flags?.out, {
|
|
501
|
-
...contextFromFlags(logPath, req.flags, appBundleId),
|
|
502
|
-
});
|
|
503
|
-
}
|
|
504
|
-
await dispatch(device, 'open', req.positionals ?? [], req.flags?.out, {
|
|
505
|
-
...contextFromFlags(logPath, req.flags, appBundleId),
|
|
506
|
-
});
|
|
507
|
-
const session: SessionState = {
|
|
508
|
-
name: sessionName,
|
|
509
|
-
device,
|
|
510
|
-
createdAt: Date.now(),
|
|
511
|
-
appBundleId,
|
|
512
|
-
appName: openTarget,
|
|
513
|
-
recordSession: Boolean(req.flags?.saveScript),
|
|
514
|
-
actions: [],
|
|
515
|
-
};
|
|
516
|
-
sessionStore.recordAction(session, {
|
|
517
|
-
command,
|
|
518
|
-
positionals: req.positionals ?? [],
|
|
519
|
-
flags: req.flags ?? {},
|
|
520
|
-
result: { session: sessionName },
|
|
521
|
-
});
|
|
522
|
-
sessionStore.set(sessionName, session);
|
|
523
|
-
return { ok: true, data: { session: sessionName } };
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
if (command === 'replay') {
|
|
527
|
-
const filePath = req.positionals?.[0];
|
|
528
|
-
if (!filePath) {
|
|
529
|
-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'replay requires a path' } };
|
|
530
|
-
}
|
|
531
|
-
try {
|
|
532
|
-
const resolved = SessionStore.expandHome(filePath);
|
|
533
|
-
const script = fs.readFileSync(resolved, 'utf8');
|
|
534
|
-
const firstNonWhitespace = script.trimStart()[0];
|
|
535
|
-
if (firstNonWhitespace === '{' || firstNonWhitespace === '[') {
|
|
536
|
-
return {
|
|
537
|
-
ok: false,
|
|
538
|
-
error: {
|
|
539
|
-
code: 'INVALID_ARGS',
|
|
540
|
-
message: 'replay accepts .ad script files. JSON replay payloads are no longer supported.',
|
|
541
|
-
},
|
|
542
|
-
};
|
|
543
|
-
}
|
|
544
|
-
const actions = parseReplayScript(script);
|
|
545
|
-
const shouldUpdate = req.flags?.replayUpdate === true;
|
|
546
|
-
let healed = 0;
|
|
547
|
-
for (let index = 0; index < actions.length; index += 1) {
|
|
548
|
-
const action = actions[index];
|
|
549
|
-
if (!action || action.command === 'replay') continue;
|
|
550
|
-
let response = await invoke({
|
|
551
|
-
token: req.token,
|
|
552
|
-
session: sessionName,
|
|
553
|
-
command: action.command,
|
|
554
|
-
positionals: action.positionals ?? [],
|
|
555
|
-
flags: action.flags ?? {},
|
|
556
|
-
});
|
|
557
|
-
if (response.ok) continue;
|
|
558
|
-
if (!shouldUpdate) {
|
|
559
|
-
return withReplayFailureContext(response, action, index, resolved);
|
|
560
|
-
}
|
|
561
|
-
const nextAction = await healReplayAction({
|
|
562
|
-
action,
|
|
563
|
-
sessionName,
|
|
564
|
-
logPath,
|
|
565
|
-
sessionStore,
|
|
566
|
-
dispatch,
|
|
567
|
-
});
|
|
568
|
-
if (!nextAction) {
|
|
569
|
-
return withReplayFailureContext(response, action, index, resolved);
|
|
570
|
-
}
|
|
571
|
-
actions[index] = nextAction;
|
|
572
|
-
response = await invoke({
|
|
573
|
-
token: req.token,
|
|
574
|
-
session: sessionName,
|
|
575
|
-
command: nextAction.command,
|
|
576
|
-
positionals: nextAction.positionals ?? [],
|
|
577
|
-
flags: nextAction.flags ?? {},
|
|
578
|
-
});
|
|
579
|
-
if (!response.ok) {
|
|
580
|
-
return withReplayFailureContext(response, nextAction, index, resolved);
|
|
581
|
-
}
|
|
582
|
-
healed += 1;
|
|
583
|
-
}
|
|
584
|
-
if (shouldUpdate && healed > 0) {
|
|
585
|
-
const session = sessionStore.get(sessionName);
|
|
586
|
-
writeReplayScript(resolved, actions, session);
|
|
587
|
-
}
|
|
588
|
-
return { ok: true, data: { replayed: actions.length, healed, session: sessionName } };
|
|
589
|
-
} catch (err) {
|
|
590
|
-
const appErr = asAppError(err);
|
|
591
|
-
return { ok: false, error: { code: appErr.code, message: appErr.message } };
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
if (command === 'close') {
|
|
596
|
-
const session = sessionStore.get(sessionName);
|
|
597
|
-
if (!session) {
|
|
598
|
-
return { ok: false, error: { code: 'SESSION_NOT_FOUND', message: 'No active session' } };
|
|
599
|
-
}
|
|
600
|
-
if (req.positionals && req.positionals.length > 0) {
|
|
601
|
-
await dispatch(session.device, 'close', req.positionals ?? [], req.flags?.out, {
|
|
602
|
-
...contextFromFlags(logPath, req.flags, session.appBundleId, session.trace?.outPath),
|
|
603
|
-
});
|
|
604
|
-
}
|
|
605
|
-
if (session.device.platform === 'ios') {
|
|
606
|
-
await stopIosRunnerSession(session.device.id);
|
|
607
|
-
}
|
|
608
|
-
sessionStore.recordAction(session, {
|
|
609
|
-
command,
|
|
610
|
-
positionals: req.positionals ?? [],
|
|
611
|
-
flags: req.flags ?? {},
|
|
612
|
-
result: { session: sessionName },
|
|
613
|
-
});
|
|
614
|
-
if (req.flags?.saveScript) {
|
|
615
|
-
session.recordSession = true;
|
|
616
|
-
}
|
|
617
|
-
sessionStore.writeSessionLog(session);
|
|
618
|
-
sessionStore.delete(sessionName);
|
|
619
|
-
return { ok: true, data: { session: sessionName } };
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
return null;
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
function withReplayFailureContext(
|
|
626
|
-
response: DaemonResponse,
|
|
627
|
-
action: SessionAction,
|
|
628
|
-
index: number,
|
|
629
|
-
replayPath: string,
|
|
630
|
-
): DaemonResponse {
|
|
631
|
-
if (response.ok) return response;
|
|
632
|
-
const step = index + 1;
|
|
633
|
-
const summary = formatReplayActionSummary(action);
|
|
634
|
-
const details = {
|
|
635
|
-
...(response.error.details ?? {}),
|
|
636
|
-
replayPath,
|
|
637
|
-
step,
|
|
638
|
-
action: action.command,
|
|
639
|
-
positionals: action.positionals ?? [],
|
|
640
|
-
};
|
|
641
|
-
return {
|
|
642
|
-
ok: false,
|
|
643
|
-
error: {
|
|
644
|
-
code: response.error.code,
|
|
645
|
-
message: `Replay failed at step ${step} (${summary}): ${response.error.message}`,
|
|
646
|
-
details,
|
|
647
|
-
},
|
|
648
|
-
};
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
function formatReplayActionSummary(action: SessionAction): string {
|
|
652
|
-
const values = (action.positionals ?? []).map((value) => {
|
|
653
|
-
const trimmed = value.trim();
|
|
654
|
-
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed;
|
|
655
|
-
if (trimmed.startsWith('@')) return trimmed;
|
|
656
|
-
return JSON.stringify(trimmed);
|
|
657
|
-
});
|
|
658
|
-
return [action.command, ...values].join(' ');
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
async function healReplayAction(params: {
|
|
662
|
-
action: SessionAction;
|
|
663
|
-
sessionName: string;
|
|
664
|
-
logPath: string;
|
|
665
|
-
sessionStore: SessionStore;
|
|
666
|
-
dispatch: typeof dispatchCommand;
|
|
667
|
-
}): Promise<SessionAction | null> {
|
|
668
|
-
const { action, sessionName, logPath, sessionStore, dispatch } = params;
|
|
669
|
-
if (!['click', 'fill', 'get', 'is', 'wait'].includes(action.command)) return null;
|
|
670
|
-
const session = sessionStore.get(sessionName);
|
|
671
|
-
if (!session) return null;
|
|
672
|
-
const requiresRect = action.command === 'click' || action.command === 'fill';
|
|
673
|
-
const allowDisambiguation =
|
|
674
|
-
action.command === 'click' ||
|
|
675
|
-
action.command === 'fill' ||
|
|
676
|
-
(action.command === 'get' && action.positionals?.[0] === 'text');
|
|
677
|
-
const snapshot = await captureSnapshotForReplay(session, action, logPath, requiresRect, dispatch, sessionStore);
|
|
678
|
-
const selectorCandidates = collectReplaySelectorCandidates(action);
|
|
679
|
-
for (const candidate of selectorCandidates) {
|
|
680
|
-
const chain = tryParseSelectorChain(candidate);
|
|
681
|
-
if (!chain) continue;
|
|
682
|
-
const resolved = resolveSelectorChain(snapshot.nodes, chain, {
|
|
683
|
-
platform: session.device.platform,
|
|
684
|
-
requireRect: requiresRect,
|
|
685
|
-
requireUnique: true,
|
|
686
|
-
disambiguateAmbiguous: allowDisambiguation,
|
|
687
|
-
});
|
|
688
|
-
if (!resolved) continue;
|
|
689
|
-
const selectorChain = buildSelectorChainForNode(resolved.node, session.device.platform, {
|
|
690
|
-
action: action.command === 'click' ? 'click' : action.command === 'fill' ? 'fill' : 'get',
|
|
691
|
-
});
|
|
692
|
-
const selectorExpression = selectorChain.join(' || ');
|
|
693
|
-
if (action.command === 'click') {
|
|
694
|
-
return {
|
|
695
|
-
...action,
|
|
696
|
-
positionals: [selectorExpression],
|
|
697
|
-
};
|
|
698
|
-
}
|
|
699
|
-
if (action.command === 'fill') {
|
|
700
|
-
const fillText = inferFillText(action);
|
|
701
|
-
if (!fillText) continue;
|
|
702
|
-
return {
|
|
703
|
-
...action,
|
|
704
|
-
positionals: [selectorExpression, fillText],
|
|
705
|
-
};
|
|
706
|
-
}
|
|
707
|
-
if (action.command === 'get') {
|
|
708
|
-
const sub = action.positionals?.[0];
|
|
709
|
-
if (sub !== 'text' && sub !== 'attrs') continue;
|
|
710
|
-
return {
|
|
711
|
-
...action,
|
|
712
|
-
positionals: [sub, selectorExpression],
|
|
713
|
-
};
|
|
714
|
-
}
|
|
715
|
-
if (action.command === 'is') {
|
|
716
|
-
const { predicate, split } = splitIsSelectorArgs(action.positionals);
|
|
717
|
-
if (!predicate) continue;
|
|
718
|
-
const expectedText = split?.rest.join(' ').trim() ?? '';
|
|
719
|
-
const nextPositionals = [predicate, selectorExpression];
|
|
720
|
-
if (predicate === 'text' && expectedText.length > 0) {
|
|
721
|
-
nextPositionals.push(expectedText);
|
|
722
|
-
}
|
|
723
|
-
return {
|
|
724
|
-
...action,
|
|
725
|
-
positionals: nextPositionals,
|
|
726
|
-
};
|
|
727
|
-
}
|
|
728
|
-
if (action.command === 'wait') {
|
|
729
|
-
const { selectorTimeout } = parseSelectorWaitPositionals(action.positionals ?? []);
|
|
730
|
-
const nextPositionals = [selectorExpression];
|
|
731
|
-
if (selectorTimeout) {
|
|
732
|
-
nextPositionals.push(selectorTimeout);
|
|
733
|
-
}
|
|
734
|
-
return {
|
|
735
|
-
...action,
|
|
736
|
-
positionals: nextPositionals,
|
|
737
|
-
};
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
const numericDriftHeal = healNumericGetTextDrift(action, snapshot, session);
|
|
741
|
-
if (numericDriftHeal) {
|
|
742
|
-
return numericDriftHeal;
|
|
743
|
-
}
|
|
744
|
-
return null;
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
async function captureSnapshotForReplay(
|
|
748
|
-
session: SessionState,
|
|
749
|
-
action: SessionAction,
|
|
750
|
-
logPath: string,
|
|
751
|
-
interactiveOnly: boolean,
|
|
752
|
-
dispatch: typeof dispatchCommand,
|
|
753
|
-
sessionStore: SessionStore,
|
|
754
|
-
): Promise<SnapshotState> {
|
|
755
|
-
const data = (await dispatch(session.device, 'snapshot', [], action.flags?.out, {
|
|
756
|
-
...contextFromFlags(
|
|
757
|
-
logPath,
|
|
758
|
-
{
|
|
759
|
-
...(action.flags ?? {}),
|
|
760
|
-
snapshotInteractiveOnly: interactiveOnly,
|
|
761
|
-
snapshotCompact: interactiveOnly,
|
|
762
|
-
},
|
|
763
|
-
session.appBundleId,
|
|
764
|
-
session.trace?.outPath,
|
|
765
|
-
),
|
|
766
|
-
})) as {
|
|
767
|
-
nodes?: RawSnapshotNode[];
|
|
768
|
-
truncated?: boolean;
|
|
769
|
-
backend?: 'ax' | 'xctest' | 'android';
|
|
770
|
-
};
|
|
771
|
-
const rawNodes = data?.nodes ?? [];
|
|
772
|
-
const nodes = attachRefs(action.flags?.snapshotRaw ? rawNodes : pruneGroupNodes(rawNodes));
|
|
773
|
-
const snapshot: SnapshotState = {
|
|
774
|
-
nodes,
|
|
775
|
-
truncated: data?.truncated,
|
|
776
|
-
createdAt: Date.now(),
|
|
777
|
-
backend: data?.backend,
|
|
778
|
-
};
|
|
779
|
-
session.snapshot = snapshot;
|
|
780
|
-
sessionStore.set(session.name, session);
|
|
781
|
-
return snapshot;
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
function collectReplaySelectorCandidates(action: SessionAction): string[] {
|
|
785
|
-
const result: string[] = [];
|
|
786
|
-
const explicitChain =
|
|
787
|
-
Array.isArray(action.result?.selectorChain) &&
|
|
788
|
-
action.result?.selectorChain.every((entry) => typeof entry === 'string')
|
|
789
|
-
? (action.result.selectorChain as string[])
|
|
790
|
-
: [];
|
|
791
|
-
result.push(...explicitChain);
|
|
792
|
-
|
|
793
|
-
if (action.command === 'click') {
|
|
794
|
-
const first = action.positionals?.[0] ?? '';
|
|
795
|
-
if (first && !first.startsWith('@')) {
|
|
796
|
-
result.push(action.positionals.join(' '));
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
if (action.command === 'fill') {
|
|
800
|
-
const first = action.positionals?.[0] ?? '';
|
|
801
|
-
if (first && !first.startsWith('@') && Number.isNaN(Number(first))) {
|
|
802
|
-
result.push(first);
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
if (action.command === 'get') {
|
|
806
|
-
const selector = action.positionals?.[1] ?? '';
|
|
807
|
-
if (selector && !selector.startsWith('@')) {
|
|
808
|
-
result.push(action.positionals.slice(1).join(' '));
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
if (action.command === 'is') {
|
|
812
|
-
const { split } = splitIsSelectorArgs(action.positionals);
|
|
813
|
-
if (split) {
|
|
814
|
-
result.push(split.selectorExpression);
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
if (action.command === 'wait') {
|
|
818
|
-
const { selectorExpression } = parseSelectorWaitPositionals(action.positionals ?? []);
|
|
819
|
-
if (selectorExpression) {
|
|
820
|
-
result.push(selectorExpression);
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
const refLabel = typeof action.result?.refLabel === 'string' ? action.result.refLabel.trim() : '';
|
|
825
|
-
if (refLabel.length > 0) {
|
|
826
|
-
const quoted = JSON.stringify(refLabel);
|
|
827
|
-
if (action.command === 'fill') {
|
|
828
|
-
result.push(`id=${quoted} editable=true`);
|
|
829
|
-
result.push(`label=${quoted} editable=true`);
|
|
830
|
-
result.push(`text=${quoted} editable=true`);
|
|
831
|
-
result.push(`value=${quoted} editable=true`);
|
|
832
|
-
} else {
|
|
833
|
-
result.push(`id=${quoted}`);
|
|
834
|
-
result.push(`label=${quoted}`);
|
|
835
|
-
result.push(`text=${quoted}`);
|
|
836
|
-
result.push(`value=${quoted}`);
|
|
837
|
-
}
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
return uniqueStrings(result).filter((entry) => entry.trim().length > 0);
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
function parseSelectorWaitPositionals(positionals: string[]): {
|
|
844
|
-
selectorExpression: string | null;
|
|
845
|
-
selectorTimeout: string | null;
|
|
846
|
-
} {
|
|
847
|
-
if (positionals.length === 0) return { selectorExpression: null, selectorTimeout: null };
|
|
848
|
-
const maybeTimeout = positionals[positionals.length - 1];
|
|
849
|
-
const hasTimeout = /^\d+$/.test(maybeTimeout ?? '');
|
|
850
|
-
const selectorTokens = hasTimeout ? positionals.slice(0, -1) : positionals.slice();
|
|
851
|
-
const split = splitSelectorFromArgs(selectorTokens);
|
|
852
|
-
if (!split || split.rest.length > 0) {
|
|
853
|
-
return { selectorExpression: null, selectorTimeout: null };
|
|
854
|
-
}
|
|
855
|
-
return {
|
|
856
|
-
selectorExpression: split.selectorExpression,
|
|
857
|
-
selectorTimeout: hasTimeout ? maybeTimeout : null,
|
|
858
|
-
};
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
function healNumericGetTextDrift(
|
|
862
|
-
action: SessionAction,
|
|
863
|
-
snapshot: SnapshotState,
|
|
864
|
-
session: SessionState,
|
|
865
|
-
): SessionAction | null {
|
|
866
|
-
if (action.command !== 'get') return null;
|
|
867
|
-
if (action.positionals?.[0] !== 'text') return null;
|
|
868
|
-
const selectorExpression = action.positionals?.[1];
|
|
869
|
-
if (!selectorExpression) return null;
|
|
870
|
-
const chain = tryParseSelectorChain(selectorExpression);
|
|
871
|
-
if (!chain) return null;
|
|
872
|
-
|
|
873
|
-
const roleFilters = new Set<string>();
|
|
874
|
-
let hasNumericTerm = false;
|
|
875
|
-
for (const selector of chain.selectors) {
|
|
876
|
-
for (const term of selector.terms) {
|
|
877
|
-
if (term.key === 'role' && typeof term.value === 'string') {
|
|
878
|
-
roleFilters.add(normalizeType(term.value));
|
|
879
|
-
}
|
|
880
|
-
if (
|
|
881
|
-
(term.key === 'text' || term.key === 'label' || term.key === 'value') &&
|
|
882
|
-
typeof term.value === 'string' &&
|
|
883
|
-
/^\d+$/.test(term.value.trim())
|
|
884
|
-
) {
|
|
885
|
-
hasNumericTerm = true;
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
if (!hasNumericTerm) return null;
|
|
890
|
-
|
|
891
|
-
const numericNodes = snapshot.nodes.filter((node) => {
|
|
892
|
-
const text = extractNodeText(node).trim();
|
|
893
|
-
if (!/^\d+$/.test(text)) return false;
|
|
894
|
-
if (roleFilters.size === 0) return true;
|
|
895
|
-
return roleFilters.has(normalizeType(node.type ?? ''));
|
|
896
|
-
});
|
|
897
|
-
if (numericNodes.length === 0) return null;
|
|
898
|
-
const numericValues = uniqueStrings(numericNodes.map((node) => extractNodeText(node).trim()));
|
|
899
|
-
if (numericValues.length !== 1) return null;
|
|
900
|
-
|
|
901
|
-
const targetNode = numericNodes[0];
|
|
902
|
-
if (!targetNode) return null;
|
|
903
|
-
const selectorChain = buildSelectorChainForNode(targetNode, session.device.platform, { action: 'get' });
|
|
904
|
-
if (selectorChain.length === 0) return null;
|
|
905
|
-
return {
|
|
906
|
-
...action,
|
|
907
|
-
positionals: ['text', selectorChain.join(' || ')],
|
|
908
|
-
};
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
function parseReplayScript(script: string): SessionAction[] {
|
|
912
|
-
const actions: SessionAction[] = [];
|
|
913
|
-
const lines = script.split(/\r?\n/);
|
|
914
|
-
for (const line of lines) {
|
|
915
|
-
const parsed = parseReplayScriptLine(line);
|
|
916
|
-
if (parsed) {
|
|
917
|
-
actions.push(parsed);
|
|
918
|
-
}
|
|
919
|
-
}
|
|
920
|
-
return actions;
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
function parseReplayScriptLine(line: string): SessionAction | null {
|
|
924
|
-
const trimmed = line.trim();
|
|
925
|
-
if (trimmed.length === 0 || trimmed.startsWith('#')) return null;
|
|
926
|
-
const tokens = tokenizeReplayLine(trimmed);
|
|
927
|
-
if (tokens.length === 0) return null;
|
|
928
|
-
const [command, ...args] = tokens;
|
|
929
|
-
if (command === 'context') return null;
|
|
930
|
-
|
|
931
|
-
const action: SessionAction = {
|
|
932
|
-
ts: Date.now(),
|
|
933
|
-
command,
|
|
934
|
-
positionals: [],
|
|
935
|
-
flags: {},
|
|
936
|
-
};
|
|
937
|
-
|
|
938
|
-
if (command === 'snapshot') {
|
|
939
|
-
action.positionals = [];
|
|
940
|
-
for (let index = 0; index < args.length; index += 1) {
|
|
941
|
-
const token = args[index];
|
|
942
|
-
if (token === '-i') {
|
|
943
|
-
action.flags.snapshotInteractiveOnly = true;
|
|
944
|
-
continue;
|
|
945
|
-
}
|
|
946
|
-
if (token === '-c') {
|
|
947
|
-
action.flags.snapshotCompact = true;
|
|
948
|
-
continue;
|
|
949
|
-
}
|
|
950
|
-
if (token === '--raw') {
|
|
951
|
-
action.flags.snapshotRaw = true;
|
|
952
|
-
continue;
|
|
953
|
-
}
|
|
954
|
-
if ((token === '-d' || token === '--depth') && index + 1 < args.length) {
|
|
955
|
-
const parsedDepth = Number(args[index + 1]);
|
|
956
|
-
if (Number.isFinite(parsedDepth) && parsedDepth >= 0) {
|
|
957
|
-
action.flags.snapshotDepth = Math.floor(parsedDepth);
|
|
958
|
-
}
|
|
959
|
-
index += 1;
|
|
960
|
-
continue;
|
|
961
|
-
}
|
|
962
|
-
if ((token === '-s' || token === '--scope') && index + 1 < args.length) {
|
|
963
|
-
action.flags.snapshotScope = args[index + 1];
|
|
964
|
-
index += 1;
|
|
965
|
-
continue;
|
|
966
|
-
}
|
|
967
|
-
if (token === '--backend' && index + 1 < args.length) {
|
|
968
|
-
const backend = args[index + 1];
|
|
969
|
-
if (backend === 'ax' || backend === 'xctest') {
|
|
970
|
-
action.flags.snapshotBackend = backend;
|
|
971
|
-
}
|
|
972
|
-
index += 1;
|
|
973
|
-
}
|
|
974
|
-
}
|
|
975
|
-
return action;
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
if (command === 'open') {
|
|
979
|
-
action.positionals = [];
|
|
980
|
-
for (let index = 0; index < args.length; index += 1) {
|
|
981
|
-
const token = args[index];
|
|
982
|
-
if (token === '--relaunch') {
|
|
983
|
-
action.flags.relaunch = true;
|
|
984
|
-
continue;
|
|
985
|
-
}
|
|
986
|
-
action.positionals.push(token);
|
|
987
|
-
}
|
|
988
|
-
return action;
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
if (command === 'click') {
|
|
992
|
-
if (args.length === 0) return action;
|
|
993
|
-
const target = args[0];
|
|
994
|
-
if (target.startsWith('@')) {
|
|
995
|
-
action.positionals = [target];
|
|
996
|
-
if (args[1]) {
|
|
997
|
-
action.result = { refLabel: args[1] };
|
|
998
|
-
}
|
|
999
|
-
return action;
|
|
1000
|
-
}
|
|
1001
|
-
action.positionals = [args.join(' ')];
|
|
1002
|
-
return action;
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
if (command === 'fill') {
|
|
1006
|
-
if (args.length < 2) {
|
|
1007
|
-
action.positionals = args;
|
|
1008
|
-
return action;
|
|
1009
|
-
}
|
|
1010
|
-
const target = args[0];
|
|
1011
|
-
if (target.startsWith('@')) {
|
|
1012
|
-
if (args.length >= 3) {
|
|
1013
|
-
action.positionals = [target, args.slice(2).join(' ')];
|
|
1014
|
-
action.result = { refLabel: args[1] };
|
|
1015
|
-
return action;
|
|
1016
|
-
}
|
|
1017
|
-
action.positionals = [target, args[1]];
|
|
1018
|
-
return action;
|
|
1019
|
-
}
|
|
1020
|
-
action.positionals = [target, args.slice(1).join(' ')];
|
|
1021
|
-
return action;
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
if (command === 'get') {
|
|
1025
|
-
if (args.length < 2) {
|
|
1026
|
-
action.positionals = args;
|
|
1027
|
-
return action;
|
|
1028
|
-
}
|
|
1029
|
-
const sub = args[0];
|
|
1030
|
-
const target = args[1];
|
|
1031
|
-
if (target.startsWith('@')) {
|
|
1032
|
-
action.positionals = [sub, target];
|
|
1033
|
-
if (args[2]) {
|
|
1034
|
-
action.result = { refLabel: args[2] };
|
|
1035
|
-
}
|
|
1036
|
-
return action;
|
|
1037
|
-
}
|
|
1038
|
-
action.positionals = [sub, args.slice(1).join(' ')];
|
|
1039
|
-
return action;
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
action.positionals = args;
|
|
1043
|
-
return action;
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
function tokenizeReplayLine(line: string): string[] {
|
|
1047
|
-
const tokens: string[] = [];
|
|
1048
|
-
let cursor = 0;
|
|
1049
|
-
while (cursor < line.length) {
|
|
1050
|
-
while (cursor < line.length && /\s/.test(line[cursor])) {
|
|
1051
|
-
cursor += 1;
|
|
1052
|
-
}
|
|
1053
|
-
if (cursor >= line.length) break;
|
|
1054
|
-
if (line[cursor] === '"') {
|
|
1055
|
-
let end = cursor + 1;
|
|
1056
|
-
let escaped = false;
|
|
1057
|
-
while (end < line.length) {
|
|
1058
|
-
const char = line[end];
|
|
1059
|
-
if (char === '"' && !escaped) break;
|
|
1060
|
-
escaped = char === '\\' && !escaped;
|
|
1061
|
-
if (char !== '\\') escaped = false;
|
|
1062
|
-
end += 1;
|
|
1063
|
-
}
|
|
1064
|
-
if (end >= line.length) {
|
|
1065
|
-
throw new AppError('INVALID_ARGS', `Invalid replay script line: ${line}`);
|
|
1066
|
-
}
|
|
1067
|
-
const literal = line.slice(cursor, end + 1);
|
|
1068
|
-
tokens.push(JSON.parse(literal) as string);
|
|
1069
|
-
cursor = end + 1;
|
|
1070
|
-
continue;
|
|
1071
|
-
}
|
|
1072
|
-
let end = cursor;
|
|
1073
|
-
while (end < line.length && !/\s/.test(line[end])) {
|
|
1074
|
-
end += 1;
|
|
1075
|
-
}
|
|
1076
|
-
tokens.push(line.slice(cursor, end));
|
|
1077
|
-
cursor = end;
|
|
1078
|
-
}
|
|
1079
|
-
return tokens;
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
function writeReplayScript(filePath: string, actions: SessionAction[], session?: SessionState) {
|
|
1083
|
-
const lines: string[] = [];
|
|
1084
|
-
// Session can be missing if the replay session is closed/deleted between execution and update write.
|
|
1085
|
-
// In that case we still persist healed actions and omit only the context header.
|
|
1086
|
-
if (session) {
|
|
1087
|
-
const deviceLabel = session.device.name.replace(/"/g, '\\"');
|
|
1088
|
-
const kind = session.device.kind ? ` kind=${session.device.kind}` : '';
|
|
1089
|
-
lines.push(`context platform=${session.device.platform} device="${deviceLabel}"${kind} theme=unknown`);
|
|
1090
|
-
}
|
|
1091
|
-
for (const action of actions) {
|
|
1092
|
-
lines.push(formatReplayActionLine(action));
|
|
1093
|
-
}
|
|
1094
|
-
const serialized = `${lines.join('\n')}\n`;
|
|
1095
|
-
const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
1096
|
-
fs.writeFileSync(tmpPath, serialized);
|
|
1097
|
-
fs.renameSync(tmpPath, filePath);
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
function formatReplayActionLine(action: SessionAction): string {
|
|
1101
|
-
const parts: string[] = [action.command];
|
|
1102
|
-
if (action.command === 'snapshot') {
|
|
1103
|
-
if (action.flags?.snapshotInteractiveOnly) parts.push('-i');
|
|
1104
|
-
if (action.flags?.snapshotCompact) parts.push('-c');
|
|
1105
|
-
if (typeof action.flags?.snapshotDepth === 'number') {
|
|
1106
|
-
parts.push('-d', String(action.flags.snapshotDepth));
|
|
1107
|
-
}
|
|
1108
|
-
if (action.flags?.snapshotScope) {
|
|
1109
|
-
parts.push('-s', formatReplayArg(action.flags.snapshotScope));
|
|
1110
|
-
}
|
|
1111
|
-
if (action.flags?.snapshotRaw) parts.push('--raw');
|
|
1112
|
-
if (action.flags?.snapshotBackend) {
|
|
1113
|
-
parts.push('--backend', action.flags.snapshotBackend);
|
|
1114
|
-
}
|
|
1115
|
-
return parts.join(' ');
|
|
1116
|
-
}
|
|
1117
|
-
if (action.command === 'open') {
|
|
1118
|
-
for (const positional of action.positionals ?? []) {
|
|
1119
|
-
parts.push(formatReplayArg(positional));
|
|
1120
|
-
}
|
|
1121
|
-
if (action.flags?.relaunch) {
|
|
1122
|
-
parts.push('--relaunch');
|
|
1123
|
-
}
|
|
1124
|
-
return parts.join(' ');
|
|
1125
|
-
}
|
|
1126
|
-
for (const positional of action.positionals ?? []) {
|
|
1127
|
-
parts.push(formatReplayArg(positional));
|
|
1128
|
-
}
|
|
1129
|
-
return parts.join(' ');
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
function formatReplayArg(value: string): string {
|
|
1133
|
-
const trimmed = value.trim();
|
|
1134
|
-
if (trimmed.startsWith('@')) return trimmed;
|
|
1135
|
-
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed;
|
|
1136
|
-
return JSON.stringify(trimmed);
|
|
1137
|
-
}
|