agent-device 0.3.1 → 0.3.3
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 +12 -3
- package/dist/src/274.js +1 -1
- package/dist/src/bin.js +25 -22
- package/dist/src/daemon.js +15 -11
- package/package.json +1 -1
- package/skills/agent-device/SKILL.md +8 -1
- package/src/cli.ts +7 -0
- package/src/core/__tests__/capabilities.test.ts +2 -0
- package/src/core/capabilities.ts +2 -0
- package/src/daemon/__tests__/selectors.test.ts +133 -0
- package/src/daemon/handlers/__tests__/replay-heal.test.ts +64 -0
- package/src/daemon/handlers/__tests__/session-reinstall.test.ts +219 -0
- package/src/daemon/handlers/__tests__/session.test.ts +122 -0
- package/src/daemon/handlers/find.ts +23 -3
- package/src/daemon/handlers/interaction.ts +5 -3
- package/src/daemon/handlers/session.ts +187 -15
- package/src/daemon/selectors.ts +138 -20
- package/src/daemon/snapshot-processing.ts +5 -1
- package/src/platforms/__tests__/boot-diagnostics.test.ts +37 -8
- package/src/platforms/android/__tests__/index.test.ts +17 -0
- package/src/platforms/android/devices.ts +47 -14
- package/src/platforms/android/index.ts +101 -14
- package/src/platforms/boot-diagnostics.ts +78 -17
- package/src/platforms/ios/index.ts +76 -9
- package/src/platforms/ios/runner-client.ts +19 -1
- package/src/utils/__tests__/exec.test.ts +16 -0
- package/src/utils/__tests__/finders.test.ts +34 -0
- package/src/utils/__tests__/retry.test.ts +17 -0
- package/src/utils/args.ts +2 -0
- package/src/utils/exec.ts +39 -0
- package/src/utils/finders.ts +27 -9
- package/src/utils/retry.ts +72 -2
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
formatSelectorFailure,
|
|
13
13
|
parseSelectorChain,
|
|
14
14
|
resolveSelectorChain,
|
|
15
|
+
splitIsSelectorArgs,
|
|
15
16
|
splitSelectorFromArgs,
|
|
16
17
|
} from '../selectors.ts';
|
|
17
18
|
|
|
@@ -90,6 +91,7 @@ export async function handleInteractionCommands(params: {
|
|
|
90
91
|
platform: session.device.platform,
|
|
91
92
|
requireRect: true,
|
|
92
93
|
requireUnique: true,
|
|
94
|
+
disambiguateAmbiguous: true,
|
|
93
95
|
});
|
|
94
96
|
if (!resolved || !resolved.node.rect) {
|
|
95
97
|
return {
|
|
@@ -180,7 +182,7 @@ export async function handleInteractionCommands(params: {
|
|
|
180
182
|
error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' },
|
|
181
183
|
};
|
|
182
184
|
}
|
|
183
|
-
const selectorArgs = splitSelectorFromArgs(req.positionals ?? []);
|
|
185
|
+
const selectorArgs = splitSelectorFromArgs(req.positionals ?? [], { preferTrailingValue: true });
|
|
184
186
|
if (selectorArgs) {
|
|
185
187
|
if (selectorArgs.rest.length === 0) {
|
|
186
188
|
return { ok: false, error: { code: 'INVALID_ARGS', message: 'fill requires text after selector' } };
|
|
@@ -197,6 +199,7 @@ export async function handleInteractionCommands(params: {
|
|
|
197
199
|
platform: session.device.platform,
|
|
198
200
|
requireRect: true,
|
|
199
201
|
requireUnique: true,
|
|
202
|
+
disambiguateAmbiguous: true,
|
|
200
203
|
});
|
|
201
204
|
if (!resolved || !resolved.node.rect) {
|
|
202
205
|
return {
|
|
@@ -367,8 +370,7 @@ export async function handleInteractionCommands(params: {
|
|
|
367
370
|
error: { code: 'UNSUPPORTED_OPERATION', message: 'is is not supported on this device' },
|
|
368
371
|
};
|
|
369
372
|
}
|
|
370
|
-
const
|
|
371
|
-
const split = splitSelectorFromArgs(selectorArgs);
|
|
373
|
+
const { split } = splitIsSelectorArgs(req.positionals);
|
|
372
374
|
if (!split) {
|
|
373
375
|
return {
|
|
374
376
|
ok: false,
|
|
@@ -11,9 +11,48 @@ import { resolveIosAppStateFromSnapshots } from '../app-state.ts';
|
|
|
11
11
|
import { stopIosRunnerSession } from '../../platforms/ios/runner-client.ts';
|
|
12
12
|
import { attachRefs, type RawSnapshotNode, type SnapshotState } from '../../utils/snapshot.ts';
|
|
13
13
|
import { pruneGroupNodes } from '../snapshot-processing.ts';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
buildSelectorChainForNode,
|
|
16
|
+
resolveSelectorChain,
|
|
17
|
+
splitIsSelectorArgs,
|
|
18
|
+
splitSelectorFromArgs,
|
|
19
|
+
tryParseSelectorChain,
|
|
20
|
+
} from '../selectors.ts';
|
|
15
21
|
import { inferFillText, uniqueStrings } from '../action-utils.ts';
|
|
16
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
|
+
function hasExplicitDeviceSelector(flags: DaemonRequest['flags'] | undefined): boolean {
|
|
29
|
+
return Boolean(flags?.platform || flags?.device || flags?.udid || flags?.serial);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function resolveCommandDevice(params: {
|
|
33
|
+
session: SessionState | undefined;
|
|
34
|
+
flags: DaemonRequest['flags'] | undefined;
|
|
35
|
+
ensureReadyFn: typeof ensureDeviceReady;
|
|
36
|
+
ensureReady?: boolean;
|
|
37
|
+
}): Promise<DeviceInfo> {
|
|
38
|
+
const device = params.session?.device ?? (await resolveTargetDevice(params.flags ?? {}));
|
|
39
|
+
if (params.ensureReady !== false) {
|
|
40
|
+
await params.ensureReadyFn(device);
|
|
41
|
+
}
|
|
42
|
+
return device;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const defaultReinstallOps: ReinstallOps = {
|
|
46
|
+
ios: async (device, app, appPath) => {
|
|
47
|
+
const { reinstallIosApp } = await import('../../platforms/ios/index.ts');
|
|
48
|
+
return await reinstallIosApp(device, app, appPath);
|
|
49
|
+
},
|
|
50
|
+
android: async (device, app, appPath) => {
|
|
51
|
+
const { reinstallAndroidApp } = await import('../../platforms/android/index.ts');
|
|
52
|
+
return await reinstallAndroidApp(device, app, appPath);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
17
56
|
export async function handleSessionCommands(params: {
|
|
18
57
|
req: DaemonRequest;
|
|
19
58
|
sessionName: string;
|
|
@@ -21,9 +60,21 @@ export async function handleSessionCommands(params: {
|
|
|
21
60
|
sessionStore: SessionStore;
|
|
22
61
|
invoke: (req: DaemonRequest) => Promise<DaemonResponse>;
|
|
23
62
|
dispatch?: typeof dispatchCommand;
|
|
63
|
+
ensureReady?: typeof ensureDeviceReady;
|
|
64
|
+
reinstallOps?: ReinstallOps;
|
|
24
65
|
}): Promise<DaemonResponse | null> {
|
|
25
|
-
const {
|
|
66
|
+
const {
|
|
67
|
+
req,
|
|
68
|
+
sessionName,
|
|
69
|
+
logPath,
|
|
70
|
+
sessionStore,
|
|
71
|
+
invoke,
|
|
72
|
+
dispatch: dispatchOverride,
|
|
73
|
+
ensureReady: ensureReadyOverride,
|
|
74
|
+
reinstallOps = defaultReinstallOps,
|
|
75
|
+
} = params;
|
|
26
76
|
const dispatch = dispatchOverride ?? dispatchCommand;
|
|
77
|
+
const ensureReady = ensureReadyOverride ?? ensureDeviceReady;
|
|
27
78
|
const command = req.command;
|
|
28
79
|
|
|
29
80
|
if (command === 'session_list') {
|
|
@@ -72,7 +123,7 @@ export async function handleSessionCommands(params: {
|
|
|
72
123
|
if (command === 'apps') {
|
|
73
124
|
const session = sessionStore.get(sessionName);
|
|
74
125
|
const flags = req.flags ?? {};
|
|
75
|
-
if (!session && !flags
|
|
126
|
+
if (!session && !hasExplicitDeviceSelector(flags)) {
|
|
76
127
|
return {
|
|
77
128
|
ok: false,
|
|
78
129
|
error: {
|
|
@@ -81,8 +132,7 @@ export async function handleSessionCommands(params: {
|
|
|
81
132
|
},
|
|
82
133
|
};
|
|
83
134
|
}
|
|
84
|
-
const device = session
|
|
85
|
-
await ensureDeviceReady(device);
|
|
135
|
+
const device = await resolveCommandDevice({ session, flags, ensureReadyFn: ensureReady, ensureReady: true });
|
|
86
136
|
if (!isCommandSupportedOnDevice('apps', device)) {
|
|
87
137
|
return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'apps is not supported on this device' } };
|
|
88
138
|
}
|
|
@@ -106,11 +156,39 @@ export async function handleSessionCommands(params: {
|
|
|
106
156
|
return { ok: true, data: { apps } };
|
|
107
157
|
}
|
|
108
158
|
|
|
109
|
-
if (command === '
|
|
159
|
+
if (command === 'boot') {
|
|
110
160
|
const session = sessionStore.get(sessionName);
|
|
111
161
|
const flags = req.flags ?? {};
|
|
162
|
+
if (!session && !hasExplicitDeviceSelector(flags)) {
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
error: {
|
|
166
|
+
code: 'INVALID_ARGS',
|
|
167
|
+
message: 'boot requires an active session or an explicit device selector (e.g. --platform ios).',
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
112
171
|
const device = session?.device ?? (await resolveTargetDevice(flags));
|
|
113
|
-
|
|
172
|
+
if (!isCommandSupportedOnDevice('boot', device)) {
|
|
173
|
+
return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'boot is not supported on this device' } };
|
|
174
|
+
}
|
|
175
|
+
await ensureReady(device);
|
|
176
|
+
return {
|
|
177
|
+
ok: true,
|
|
178
|
+
data: {
|
|
179
|
+
platform: device.platform,
|
|
180
|
+
device: device.name,
|
|
181
|
+
id: device.id,
|
|
182
|
+
kind: device.kind,
|
|
183
|
+
booted: true,
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (command === 'appstate') {
|
|
189
|
+
const session = sessionStore.get(sessionName);
|
|
190
|
+
const flags = req.flags ?? {};
|
|
191
|
+
const device = await resolveCommandDevice({ session, flags, ensureReadyFn: ensureReady, ensureReady: true });
|
|
114
192
|
if (device.platform === 'ios') {
|
|
115
193
|
if (session?.appBundleId) {
|
|
116
194
|
return {
|
|
@@ -151,6 +229,62 @@ export async function handleSessionCommands(params: {
|
|
|
151
229
|
};
|
|
152
230
|
}
|
|
153
231
|
|
|
232
|
+
if (command === 'reinstall') {
|
|
233
|
+
const session = sessionStore.get(sessionName);
|
|
234
|
+
const flags = req.flags ?? {};
|
|
235
|
+
if (!session && !hasExplicitDeviceSelector(flags)) {
|
|
236
|
+
return {
|
|
237
|
+
ok: false,
|
|
238
|
+
error: {
|
|
239
|
+
code: 'INVALID_ARGS',
|
|
240
|
+
message: 'reinstall requires an active session or an explicit device selector (e.g. --platform ios).',
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
const app = req.positionals?.[0]?.trim();
|
|
245
|
+
const appPathInput = req.positionals?.[1]?.trim();
|
|
246
|
+
if (!app || !appPathInput) {
|
|
247
|
+
return {
|
|
248
|
+
ok: false,
|
|
249
|
+
error: { code: 'INVALID_ARGS', message: 'reinstall requires: reinstall <app> <path-to-app-binary>' },
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
const appPath = SessionStore.expandHome(appPathInput);
|
|
253
|
+
if (!fs.existsSync(appPath)) {
|
|
254
|
+
return {
|
|
255
|
+
ok: false,
|
|
256
|
+
error: { code: 'INVALID_ARGS', message: `App binary not found: ${appPath}` },
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
const device = await resolveCommandDevice({ session, flags, ensureReadyFn: ensureReady, ensureReady: false });
|
|
260
|
+
if (!isCommandSupportedOnDevice('reinstall', device)) {
|
|
261
|
+
return {
|
|
262
|
+
ok: false,
|
|
263
|
+
error: { code: 'UNSUPPORTED_OPERATION', message: 'reinstall is not supported on this device' },
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
let reinstallData:
|
|
267
|
+
| { platform: 'ios'; appId: string; bundleId: string }
|
|
268
|
+
| { platform: 'android'; appId: string; package: string };
|
|
269
|
+
if (device.platform === 'ios') {
|
|
270
|
+
const iosResult = await reinstallOps.ios(device, app, appPath);
|
|
271
|
+
reinstallData = { platform: 'ios', appId: iosResult.bundleId, bundleId: iosResult.bundleId };
|
|
272
|
+
} else {
|
|
273
|
+
const androidResult = await reinstallOps.android(device, app, appPath);
|
|
274
|
+
reinstallData = { platform: 'android', appId: androidResult.package, package: androidResult.package };
|
|
275
|
+
}
|
|
276
|
+
const result = { app, appPath, ...reinstallData };
|
|
277
|
+
if (session) {
|
|
278
|
+
sessionStore.recordAction(session, {
|
|
279
|
+
command,
|
|
280
|
+
positionals: req.positionals ?? [],
|
|
281
|
+
flags: req.flags ?? {},
|
|
282
|
+
result,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
return { ok: true, data: result };
|
|
286
|
+
}
|
|
287
|
+
|
|
154
288
|
if (command === 'open') {
|
|
155
289
|
if (sessionStore.has(sessionName)) {
|
|
156
290
|
const session = sessionStore.get(sessionName);
|
|
@@ -193,7 +327,6 @@ export async function handleSessionCommands(params: {
|
|
|
193
327
|
return { ok: true, data: { session: sessionName, appName, appBundleId } };
|
|
194
328
|
}
|
|
195
329
|
const device = await resolveTargetDevice(req.flags ?? {});
|
|
196
|
-
await ensureDeviceReady(device);
|
|
197
330
|
const inUse = sessionStore.toArray().find((s) => s.device.id === device.id);
|
|
198
331
|
if (inUse) {
|
|
199
332
|
return {
|
|
@@ -269,7 +402,9 @@ export async function handleSessionCommands(params: {
|
|
|
269
402
|
flags: action.flags ?? {},
|
|
270
403
|
});
|
|
271
404
|
if (response.ok) continue;
|
|
272
|
-
if (!shouldUpdate)
|
|
405
|
+
if (!shouldUpdate) {
|
|
406
|
+
return withReplayFailureContext(response, action, index, resolved);
|
|
407
|
+
}
|
|
273
408
|
const nextAction = await healReplayAction({
|
|
274
409
|
action,
|
|
275
410
|
sessionName,
|
|
@@ -278,7 +413,7 @@ export async function handleSessionCommands(params: {
|
|
|
278
413
|
dispatch,
|
|
279
414
|
});
|
|
280
415
|
if (!nextAction) {
|
|
281
|
-
return response;
|
|
416
|
+
return withReplayFailureContext(response, action, index, resolved);
|
|
282
417
|
}
|
|
283
418
|
actions[index] = nextAction;
|
|
284
419
|
response = await invoke({
|
|
@@ -289,7 +424,7 @@ export async function handleSessionCommands(params: {
|
|
|
289
424
|
flags: nextAction.flags ?? {},
|
|
290
425
|
});
|
|
291
426
|
if (!response.ok) {
|
|
292
|
-
return response;
|
|
427
|
+
return withReplayFailureContext(response, nextAction, index, resolved);
|
|
293
428
|
}
|
|
294
429
|
healed += 1;
|
|
295
430
|
}
|
|
@@ -334,6 +469,42 @@ export async function handleSessionCommands(params: {
|
|
|
334
469
|
return null;
|
|
335
470
|
}
|
|
336
471
|
|
|
472
|
+
function withReplayFailureContext(
|
|
473
|
+
response: DaemonResponse,
|
|
474
|
+
action: SessionAction,
|
|
475
|
+
index: number,
|
|
476
|
+
replayPath: string,
|
|
477
|
+
): DaemonResponse {
|
|
478
|
+
if (response.ok) return response;
|
|
479
|
+
const step = index + 1;
|
|
480
|
+
const summary = formatReplayActionSummary(action);
|
|
481
|
+
const details = {
|
|
482
|
+
...(response.error.details ?? {}),
|
|
483
|
+
replayPath,
|
|
484
|
+
step,
|
|
485
|
+
action: action.command,
|
|
486
|
+
positionals: action.positionals ?? [],
|
|
487
|
+
};
|
|
488
|
+
return {
|
|
489
|
+
ok: false,
|
|
490
|
+
error: {
|
|
491
|
+
code: response.error.code,
|
|
492
|
+
message: `Replay failed at step ${step} (${summary}): ${response.error.message}`,
|
|
493
|
+
details,
|
|
494
|
+
},
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function formatReplayActionSummary(action: SessionAction): string {
|
|
499
|
+
const values = (action.positionals ?? []).map((value) => {
|
|
500
|
+
const trimmed = value.trim();
|
|
501
|
+
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed;
|
|
502
|
+
if (trimmed.startsWith('@')) return trimmed;
|
|
503
|
+
return JSON.stringify(trimmed);
|
|
504
|
+
});
|
|
505
|
+
return [action.command, ...values].join(' ');
|
|
506
|
+
}
|
|
507
|
+
|
|
337
508
|
async function healReplayAction(params: {
|
|
338
509
|
action: SessionAction;
|
|
339
510
|
sessionName: string;
|
|
@@ -349,11 +520,13 @@ async function healReplayAction(params: {
|
|
|
349
520
|
const snapshot = await captureSnapshotForReplay(session, action, logPath, requiresRect, dispatch, sessionStore);
|
|
350
521
|
const selectorCandidates = collectReplaySelectorCandidates(action);
|
|
351
522
|
for (const candidate of selectorCandidates) {
|
|
352
|
-
const chain =
|
|
523
|
+
const chain = tryParseSelectorChain(candidate);
|
|
524
|
+
if (!chain) continue;
|
|
353
525
|
const resolved = resolveSelectorChain(snapshot.nodes, chain, {
|
|
354
526
|
platform: session.device.platform,
|
|
355
527
|
requireRect: requiresRect,
|
|
356
528
|
requireUnique: true,
|
|
529
|
+
disambiguateAmbiguous: requiresRect,
|
|
357
530
|
});
|
|
358
531
|
if (!resolved) continue;
|
|
359
532
|
const selectorChain = buildSelectorChainForNode(resolved.node, session.device.platform, {
|
|
@@ -383,9 +556,8 @@ async function healReplayAction(params: {
|
|
|
383
556
|
};
|
|
384
557
|
}
|
|
385
558
|
if (action.command === 'is') {
|
|
386
|
-
const predicate = action.positionals
|
|
559
|
+
const { predicate, split } = splitIsSelectorArgs(action.positionals);
|
|
387
560
|
if (!predicate) continue;
|
|
388
|
-
const split = splitSelectorFromArgs(action.positionals.slice(1));
|
|
389
561
|
const expectedText = split?.rest.join(' ').trim() ?? '';
|
|
390
562
|
const nextPositionals = [predicate, selectorExpression];
|
|
391
563
|
if (predicate === 'text' && expectedText.length > 0) {
|
|
@@ -476,7 +648,7 @@ function collectReplaySelectorCandidates(action: SessionAction): string[] {
|
|
|
476
648
|
}
|
|
477
649
|
}
|
|
478
650
|
if (action.command === 'is') {
|
|
479
|
-
const split =
|
|
651
|
+
const { split } = splitIsSelectorArgs(action.positionals);
|
|
480
652
|
if (split) {
|
|
481
653
|
result.push(split.selectorExpression);
|
|
482
654
|
}
|
package/src/daemon/selectors.ts
CHANGED
|
@@ -85,25 +85,38 @@ export function resolveSelectorChain(
|
|
|
85
85
|
platform: 'ios' | 'android';
|
|
86
86
|
requireRect?: boolean;
|
|
87
87
|
requireUnique?: boolean;
|
|
88
|
+
disambiguateAmbiguous?: boolean;
|
|
88
89
|
},
|
|
89
90
|
): SelectorResolution | null {
|
|
90
91
|
const requireRect = options.requireRect ?? false;
|
|
91
92
|
const requireUnique = options.requireUnique ?? true;
|
|
93
|
+
const disambiguateAmbiguous = options.disambiguateAmbiguous ?? false;
|
|
92
94
|
const diagnostics: SelectorDiagnostics[] = [];
|
|
93
95
|
for (let i = 0; i < chain.selectors.length; i += 1) {
|
|
94
96
|
const selector = chain.selectors[i];
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
97
|
+
const summary = analyzeSelectorMatches(nodes, selector, {
|
|
98
|
+
platform: options.platform,
|
|
99
|
+
requireRect,
|
|
98
100
|
});
|
|
99
|
-
diagnostics.push({ selector: selector.raw, matches:
|
|
100
|
-
if (
|
|
101
|
-
if (requireUnique &&
|
|
101
|
+
diagnostics.push({ selector: selector.raw, matches: summary.count });
|
|
102
|
+
if (summary.count === 0 || !summary.firstNode) continue;
|
|
103
|
+
if (requireUnique && summary.count !== 1) {
|
|
104
|
+
if (!disambiguateAmbiguous) continue;
|
|
105
|
+
const disambiguatedNode = summary.disambiguated;
|
|
106
|
+
if (!disambiguatedNode) continue;
|
|
107
|
+
return {
|
|
108
|
+
node: disambiguatedNode,
|
|
109
|
+
selector,
|
|
110
|
+
selectorIndex: i,
|
|
111
|
+
matches: summary.count,
|
|
112
|
+
diagnostics,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
102
115
|
return {
|
|
103
|
-
node:
|
|
116
|
+
node: summary.firstNode,
|
|
104
117
|
selector,
|
|
105
118
|
selectorIndex: i,
|
|
106
|
-
matches:
|
|
119
|
+
matches: summary.count,
|
|
107
120
|
diagnostics,
|
|
108
121
|
};
|
|
109
122
|
}
|
|
@@ -122,13 +135,13 @@ export function findSelectorChainMatch(
|
|
|
122
135
|
const diagnostics: SelectorDiagnostics[] = [];
|
|
123
136
|
for (let i = 0; i < chain.selectors.length; i += 1) {
|
|
124
137
|
const selector = chain.selectors[i];
|
|
125
|
-
const matches = nodes
|
|
126
|
-
|
|
127
|
-
|
|
138
|
+
const matches = countSelectorMatchesOnly(nodes, selector, {
|
|
139
|
+
platform: options.platform,
|
|
140
|
+
requireRect,
|
|
128
141
|
});
|
|
129
|
-
diagnostics.push({ selector: selector.raw, matches
|
|
130
|
-
if (matches
|
|
131
|
-
return { selectorIndex: i, selector, matches
|
|
142
|
+
diagnostics.push({ selector: selector.raw, matches });
|
|
143
|
+
if (matches > 0) {
|
|
144
|
+
return { selectorIndex: i, selector, matches, diagnostics };
|
|
132
145
|
}
|
|
133
146
|
}
|
|
134
147
|
return null;
|
|
@@ -162,21 +175,51 @@ export function isSelectorToken(token: string): boolean {
|
|
|
162
175
|
return ALL_KEYS.has(trimmed.toLowerCase() as SelectorKey);
|
|
163
176
|
}
|
|
164
177
|
|
|
165
|
-
export function splitSelectorFromArgs(
|
|
178
|
+
export function splitSelectorFromArgs(
|
|
179
|
+
args: string[],
|
|
180
|
+
options: { preferTrailingValue?: boolean } = {},
|
|
181
|
+
): { selectorExpression: string; rest: string[] } | null {
|
|
166
182
|
if (args.length === 0) return null;
|
|
183
|
+
const preferTrailingValue = options.preferTrailingValue ?? false;
|
|
167
184
|
let i = 0;
|
|
185
|
+
const boundaries: number[] = [];
|
|
168
186
|
while (i < args.length && isSelectorToken(args[i])) {
|
|
169
187
|
i += 1;
|
|
188
|
+
const candidate = args.slice(0, i).join(' ').trim();
|
|
189
|
+
if (!candidate) continue;
|
|
190
|
+
if (tryParseSelectorChain(candidate)) {
|
|
191
|
+
boundaries.push(i);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (boundaries.length === 0) return null;
|
|
195
|
+
let boundary = boundaries[boundaries.length - 1];
|
|
196
|
+
if (preferTrailingValue) {
|
|
197
|
+
for (let j = boundaries.length - 1; j >= 0; j -= 1) {
|
|
198
|
+
if (boundaries[j] < args.length) {
|
|
199
|
+
boundary = boundaries[j];
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
170
203
|
}
|
|
171
|
-
|
|
172
|
-
const selectorExpression = args.slice(0, i).join(' ').trim();
|
|
204
|
+
const selectorExpression = args.slice(0, boundary).join(' ').trim();
|
|
173
205
|
if (!selectorExpression) return null;
|
|
174
206
|
return {
|
|
175
207
|
selectorExpression,
|
|
176
|
-
rest: args.slice(
|
|
208
|
+
rest: args.slice(boundary),
|
|
177
209
|
};
|
|
178
210
|
}
|
|
179
211
|
|
|
212
|
+
export function splitIsSelectorArgs(positionals: string[]): {
|
|
213
|
+
predicate: string;
|
|
214
|
+
split: { selectorExpression: string; rest: string[] } | null;
|
|
215
|
+
} {
|
|
216
|
+
const predicate = positionals[0] ?? '';
|
|
217
|
+
const split = splitSelectorFromArgs(positionals.slice(1), {
|
|
218
|
+
preferTrailingValue: predicate === 'text',
|
|
219
|
+
});
|
|
220
|
+
return { predicate, split };
|
|
221
|
+
}
|
|
222
|
+
|
|
180
223
|
export function isNodeVisible(node: SnapshotNode): boolean {
|
|
181
224
|
if (node.hittable === true) return true;
|
|
182
225
|
if (!node.rect) return false;
|
|
@@ -318,7 +361,7 @@ function splitByFallback(expression: string): string[] {
|
|
|
318
361
|
let quote: '"' | "'" | null = null;
|
|
319
362
|
for (let i = 0; i < expression.length; i += 1) {
|
|
320
363
|
const ch = expression[i];
|
|
321
|
-
if ((ch === '"' || ch === "'") && expression
|
|
364
|
+
if ((ch === '"' || ch === "'") && !isEscapedQuote(expression, i)) {
|
|
322
365
|
if (!quote) {
|
|
323
366
|
quote = ch;
|
|
324
367
|
} else if (quote === ch) {
|
|
@@ -353,7 +396,7 @@ function tokenize(segment: string): string[] {
|
|
|
353
396
|
let quote: '"' | "'" | null = null;
|
|
354
397
|
for (let i = 0; i < segment.length; i += 1) {
|
|
355
398
|
const ch = segment[i];
|
|
356
|
-
if ((ch === '"' || ch === "'") && segment
|
|
399
|
+
if ((ch === '"' || ch === "'") && !isEscapedQuote(segment, i)) {
|
|
357
400
|
if (!quote) {
|
|
358
401
|
quote = ch;
|
|
359
402
|
} else if (quote === ch) {
|
|
@@ -421,3 +464,78 @@ function normalizeSelectorText(value: string | undefined): string | null {
|
|
|
421
464
|
if (!trimmed) return null;
|
|
422
465
|
return trimmed;
|
|
423
466
|
}
|
|
467
|
+
|
|
468
|
+
function analyzeSelectorMatches(
|
|
469
|
+
nodes: SnapshotState['nodes'],
|
|
470
|
+
selector: Selector,
|
|
471
|
+
options: { platform: 'ios' | 'android'; requireRect: boolean },
|
|
472
|
+
): { count: number; firstNode: SnapshotNode | null; disambiguated: SnapshotNode | null } {
|
|
473
|
+
let count = 0;
|
|
474
|
+
let firstNode: SnapshotNode | null = null;
|
|
475
|
+
let best: SnapshotNode | null = null;
|
|
476
|
+
let tie = false;
|
|
477
|
+
for (const node of nodes) {
|
|
478
|
+
if (options.requireRect && !node.rect) continue;
|
|
479
|
+
if (!matchesSelector(node, selector, options.platform)) continue;
|
|
480
|
+
count += 1;
|
|
481
|
+
if (!firstNode) {
|
|
482
|
+
firstNode = node;
|
|
483
|
+
}
|
|
484
|
+
if (!best) {
|
|
485
|
+
best = node;
|
|
486
|
+
tie = false;
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
const comparison = compareDisambiguationCandidates(node, best);
|
|
490
|
+
if (comparison > 0) {
|
|
491
|
+
best = node;
|
|
492
|
+
tie = false;
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
if (comparison === 0) {
|
|
496
|
+
tie = true;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return {
|
|
500
|
+
count,
|
|
501
|
+
firstNode,
|
|
502
|
+
disambiguated: tie ? null : best,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function countSelectorMatchesOnly(
|
|
507
|
+
nodes: SnapshotState['nodes'],
|
|
508
|
+
selector: Selector,
|
|
509
|
+
options: { platform: 'ios' | 'android'; requireRect: boolean },
|
|
510
|
+
): number {
|
|
511
|
+
let count = 0;
|
|
512
|
+
for (const node of nodes) {
|
|
513
|
+
if (options.requireRect && !node.rect) continue;
|
|
514
|
+
if (!matchesSelector(node, selector, options.platform)) continue;
|
|
515
|
+
count += 1;
|
|
516
|
+
}
|
|
517
|
+
return count;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function compareDisambiguationCandidates(a: SnapshotNode, b: SnapshotNode): number {
|
|
521
|
+
const depthA = a.depth ?? 0;
|
|
522
|
+
const depthB = b.depth ?? 0;
|
|
523
|
+
if (depthA !== depthB) return depthA > depthB ? 1 : -1;
|
|
524
|
+
const areaA = areaOfNode(a);
|
|
525
|
+
const areaB = areaOfNode(b);
|
|
526
|
+
if (areaA !== areaB) return areaA < areaB ? 1 : -1;
|
|
527
|
+
return 0;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function areaOfNode(node: SnapshotNode): number {
|
|
531
|
+
if (!node.rect) return Number.POSITIVE_INFINITY;
|
|
532
|
+
return node.rect.width * node.rect.height;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function isEscapedQuote(source: string, index: number): boolean {
|
|
536
|
+
let backslashCount = 0;
|
|
537
|
+
for (let i = index - 1; i >= 0 && source[i] === '\\'; i -= 1) {
|
|
538
|
+
backslashCount += 1;
|
|
539
|
+
}
|
|
540
|
+
return backslashCount % 2 === 1;
|
|
541
|
+
}
|
|
@@ -78,10 +78,14 @@ export function pruneGroupNodes(nodes: RawSnapshotNode[]): RawSnapshotNode[] {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
export function normalizeType(type: string): string {
|
|
81
|
-
let value = type.replace(/XCUIElementType/gi, '').toLowerCase();
|
|
81
|
+
let value = type.trim().replace(/XCUIElementType/gi, '').toLowerCase();
|
|
82
82
|
if (value.startsWith('ax')) {
|
|
83
83
|
value = value.replace(/^ax/, '');
|
|
84
84
|
}
|
|
85
|
+
const lastSeparator = Math.max(value.lastIndexOf('.'), value.lastIndexOf('/'));
|
|
86
|
+
if (lastSeparator !== -1) {
|
|
87
|
+
value = value.slice(lastSeparator + 1);
|
|
88
|
+
}
|
|
85
89
|
return value;
|
|
86
90
|
}
|
|
87
91
|
|
|
@@ -1,23 +1,38 @@
|
|
|
1
1
|
import test from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { classifyBootFailure } from '../boot-diagnostics.ts';
|
|
3
|
+
import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
|
|
4
4
|
import { AppError } from '../../utils/errors.ts';
|
|
5
5
|
|
|
6
6
|
test('classifyBootFailure maps timeout errors', () => {
|
|
7
|
-
const reason = classifyBootFailure({
|
|
8
|
-
|
|
7
|
+
const reason = classifyBootFailure({
|
|
8
|
+
message: 'bootstatus timed out after 120s',
|
|
9
|
+
context: { platform: 'ios', phase: 'boot' },
|
|
10
|
+
});
|
|
11
|
+
assert.equal(reason, 'IOS_BOOT_TIMEOUT');
|
|
9
12
|
});
|
|
10
13
|
|
|
11
14
|
test('classifyBootFailure maps adb offline errors', () => {
|
|
12
|
-
const reason = classifyBootFailure({
|
|
13
|
-
|
|
15
|
+
const reason = classifyBootFailure({
|
|
16
|
+
stderr: 'error: device offline',
|
|
17
|
+
context: { platform: 'android', phase: 'transport' },
|
|
18
|
+
});
|
|
19
|
+
assert.equal(reason, 'ADB_TRANSPORT_UNAVAILABLE');
|
|
14
20
|
});
|
|
15
21
|
|
|
16
|
-
test('classifyBootFailure maps tool missing from AppError code', () => {
|
|
22
|
+
test('classifyBootFailure maps tool missing from AppError code (android)', () => {
|
|
17
23
|
const reason = classifyBootFailure({
|
|
18
24
|
error: new AppError('TOOL_MISSING', 'adb not found in PATH'),
|
|
25
|
+
context: { platform: 'android', phase: 'transport' },
|
|
26
|
+
});
|
|
27
|
+
assert.equal(reason, 'ADB_TRANSPORT_UNAVAILABLE');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('classifyBootFailure maps tool missing from AppError code (ios)', () => {
|
|
31
|
+
const reason = classifyBootFailure({
|
|
32
|
+
error: new AppError('TOOL_MISSING', 'xcrun not found in PATH'),
|
|
33
|
+
context: { platform: 'ios', phase: 'boot' },
|
|
19
34
|
});
|
|
20
|
-
assert.equal(reason, '
|
|
35
|
+
assert.equal(reason, 'IOS_TOOL_MISSING');
|
|
21
36
|
});
|
|
22
37
|
|
|
23
38
|
test('classifyBootFailure reads stderr from AppError details', () => {
|
|
@@ -25,6 +40,20 @@ test('classifyBootFailure reads stderr from AppError details', () => {
|
|
|
25
40
|
error: new AppError('COMMAND_FAILED', 'adb failed', {
|
|
26
41
|
stderr: 'error: device unauthorized',
|
|
27
42
|
}),
|
|
43
|
+
context: { platform: 'android', phase: 'transport' },
|
|
44
|
+
});
|
|
45
|
+
assert.equal(reason, 'ADB_TRANSPORT_UNAVAILABLE');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('bootFailureHint returns actionable guidance', () => {
|
|
49
|
+
const hint = bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT');
|
|
50
|
+
assert.equal(hint.includes('xcodebuild logs'), true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('connect phase does not classify non-timeout errors as connect timeout', () => {
|
|
54
|
+
const reason = classifyBootFailure({
|
|
55
|
+
message: 'Runner returned malformed JSON payload',
|
|
56
|
+
context: { platform: 'ios', phase: 'connect' },
|
|
28
57
|
});
|
|
29
|
-
assert.equal(reason, '
|
|
58
|
+
assert.equal(reason, 'BOOT_COMMAND_FAILED');
|
|
30
59
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import test from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
+
import { parseAndroidLaunchComponent } from '../index.ts';
|
|
3
4
|
import { findBounds, parseUiHierarchy } from '../ui-hierarchy.ts';
|
|
4
5
|
|
|
5
6
|
test('parseUiHierarchy reads double-quoted Android node attributes', () => {
|
|
@@ -72,3 +73,19 @@ test('findBounds ignores bounds-like fragments inside other attribute values', (
|
|
|
72
73
|
|
|
73
74
|
assert.deepEqual(findBounds(xml, 'target'), { x: 200, y: 350 });
|
|
74
75
|
});
|
|
76
|
+
|
|
77
|
+
test('parseAndroidLaunchComponent extracts final resolved component', () => {
|
|
78
|
+
const stdout = [
|
|
79
|
+
'priority=0 preferredOrder=0 match=0x108000 specificIndex=-1 isDefault=true',
|
|
80
|
+
'com.boatsgroup.boattrader/com.boatsgroup.boattrader.MainActivity',
|
|
81
|
+
].join('\n');
|
|
82
|
+
assert.equal(
|
|
83
|
+
parseAndroidLaunchComponent(stdout),
|
|
84
|
+
'com.boatsgroup.boattrader/com.boatsgroup.boattrader.MainActivity',
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('parseAndroidLaunchComponent returns null when no component is present', () => {
|
|
89
|
+
const stdout = 'No activity found';
|
|
90
|
+
assert.equal(parseAndroidLaunchComponent(stdout), null);
|
|
91
|
+
});
|