Skip to content

Commit 70ab1a8

Browse files
authored
Improve Android open fallback, find ambiguity handling, and replay diagnostics (#45)
* Improve Android open fallback, find ambiguity handling, and replay diagnostics * Fix iOS integration step to use unique selector for General
1 parent d0525cd commit 70ab1a8

8 files changed

Lines changed: 209 additions & 29 deletions

File tree

src/daemon/handlers/__tests__/replay-heal.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,11 @@ test('replay without --update does not heal or rewrite', async () => {
225225

226226
assert.ok(response);
227227
assert.equal(response.ok, false);
228+
if (!response.ok) {
229+
assert.match(response.error.message, /Replay failed at step 1/);
230+
assert.equal(response.error.details?.step, 1);
231+
assert.equal(response.error.details?.action, 'click');
232+
}
228233
assert.equal(snapshotDispatchCalls, 0);
229234
assert.equal(fs.readFileSync(replayPath, 'utf8'), originalPayload);
230235
});

src/daemon/handlers/find.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { dispatchCommand, resolveTargetDevice } from '../../core/dispatch.ts';
2-
import { findNodeByLocator, type FindLocator } from '../../utils/finders.ts';
2+
import { findBestMatchesByLocator, findNodeByLocator, type FindLocator } from '../../utils/finders.ts';
33
import { attachRefs, centerOfRect, type RawSnapshotNode, type SnapshotState } from '../../utils/snapshot.ts';
44
import { AppError } from '../../utils/errors.ts';
55
import type { DaemonRequest, DaemonResponse } from '../types.ts';
@@ -114,6 +114,26 @@ export async function handleFindCommands(params: {
114114
return { ok: false, error: { code: 'COMMAND_FAILED', message: 'find wait timed out' } };
115115
}
116116
const { nodes } = await fetchNodes();
117+
const bestMatches = findBestMatchesByLocator(nodes, locator, query, { requireRect: requiresRect });
118+
if (requiresRect && bestMatches.matches.length > 1) {
119+
const candidates = bestMatches.matches.slice(0, 8).map((candidate) => {
120+
const label = extractNodeText(candidate) || candidate.label || candidate.identifier || candidate.type || '';
121+
return `@${candidate.ref}${label ? `(${label})` : ''}`;
122+
});
123+
return {
124+
ok: false,
125+
error: {
126+
code: 'AMBIGUOUS_MATCH',
127+
message: `find matched ${bestMatches.matches.length} elements for ${locator} "${query}". Use a more specific locator or selector.`,
128+
details: {
129+
locator,
130+
query,
131+
matches: bestMatches.matches.length,
132+
candidates,
133+
},
134+
},
135+
};
136+
}
117137
const node = findNodeByLocator(nodes, locator, query, { requireRect: requiresRect });
118138
if (!node) {
119139
return { ok: false, error: { code: 'COMMAND_FAILED', message: 'find did not match any element' } };

src/daemon/handlers/session.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,9 @@ export async function handleSessionCommands(params: {
269269
flags: action.flags ?? {},
270270
});
271271
if (response.ok) continue;
272-
if (!shouldUpdate) return response;
272+
if (!shouldUpdate) {
273+
return withReplayFailureContext(response, action, index, resolved);
274+
}
273275
const nextAction = await healReplayAction({
274276
action,
275277
sessionName,
@@ -278,7 +280,7 @@ export async function handleSessionCommands(params: {
278280
dispatch,
279281
});
280282
if (!nextAction) {
281-
return response;
283+
return withReplayFailureContext(response, action, index, resolved);
282284
}
283285
actions[index] = nextAction;
284286
response = await invoke({
@@ -289,7 +291,7 @@ export async function handleSessionCommands(params: {
289291
flags: nextAction.flags ?? {},
290292
});
291293
if (!response.ok) {
292-
return response;
294+
return withReplayFailureContext(response, nextAction, index, resolved);
293295
}
294296
healed += 1;
295297
}
@@ -334,6 +336,42 @@ export async function handleSessionCommands(params: {
334336
return null;
335337
}
336338

339+
function withReplayFailureContext(
340+
response: DaemonResponse,
341+
action: SessionAction,
342+
index: number,
343+
replayPath: string,
344+
): DaemonResponse {
345+
if (response.ok) return response;
346+
const step = index + 1;
347+
const summary = formatReplayActionSummary(action);
348+
const details = {
349+
...(response.error.details ?? {}),
350+
replayPath,
351+
step,
352+
action: action.command,
353+
positionals: action.positionals ?? [],
354+
};
355+
return {
356+
ok: false,
357+
error: {
358+
code: response.error.code,
359+
message: `Replay failed at step ${step} (${summary}): ${response.error.message}`,
360+
details,
361+
},
362+
};
363+
}
364+
365+
function formatReplayActionSummary(action: SessionAction): string {
366+
const values = (action.positionals ?? []).map((value) => {
367+
const trimmed = value.trim();
368+
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed;
369+
if (trimmed.startsWith('@')) return trimmed;
370+
return JSON.stringify(trimmed);
371+
});
372+
return [action.command, ...values].join(' ');
373+
}
374+
337375
async function healReplayAction(params: {
338376
action: SessionAction;
339377
sessionName: string;

src/platforms/android/__tests__/index.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import test from 'node:test';
22
import assert from 'node:assert/strict';
3+
import { parseAndroidLaunchComponent } from '../index.ts';
34
import { findBounds, parseUiHierarchy } from '../ui-hierarchy.ts';
45

56
test('parseUiHierarchy reads double-quoted Android node attributes', () => {
@@ -72,3 +73,19 @@ test('findBounds ignores bounds-like fragments inside other attribute values', (
7273

7374
assert.deepEqual(findBounds(xml, 'target'), { x: 200, y: 350 });
7475
});
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+
});

src/platforms/android/index.ts

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -187,22 +187,70 @@ export async function openAndroidApp(
187187
);
188188
return;
189189
}
190-
await runCmd(
190+
try {
191+
await runCmd(
192+
'adb',
193+
adbArgs(device, [
194+
'shell',
195+
'am',
196+
'start',
197+
'-a',
198+
'android.intent.action.MAIN',
199+
'-c',
200+
'android.intent.category.DEFAULT',
201+
'-c',
202+
'android.intent.category.LAUNCHER',
203+
'-p',
204+
resolved.value,
205+
]),
206+
);
207+
return;
208+
} catch (initialError) {
209+
const component = await resolveAndroidLaunchComponent(device, resolved.value);
210+
if (!component) throw initialError;
211+
await runCmd(
212+
'adb',
213+
adbArgs(device, [
214+
'shell',
215+
'am',
216+
'start',
217+
'-a',
218+
'android.intent.action.MAIN',
219+
'-c',
220+
'android.intent.category.DEFAULT',
221+
'-c',
222+
'android.intent.category.LAUNCHER',
223+
'-n',
224+
component,
225+
]),
226+
);
227+
}
228+
}
229+
230+
async function resolveAndroidLaunchComponent(
231+
device: DeviceInfo,
232+
packageName: string,
233+
): Promise<string | null> {
234+
const result = await runCmd(
191235
'adb',
192-
adbArgs(device, [
193-
'shell',
194-
'am',
195-
'start',
196-
'-a',
197-
'android.intent.action.MAIN',
198-
'-c',
199-
'android.intent.category.DEFAULT',
200-
'-c',
201-
'android.intent.category.LAUNCHER',
202-
'-p',
203-
resolved.value,
204-
]),
236+
adbArgs(device, ['shell', 'cmd', 'package', 'resolve-activity', '--brief', packageName]),
237+
{ allowFailure: true },
205238
);
239+
if (result.exitCode !== 0) return null;
240+
return parseAndroidLaunchComponent(result.stdout);
241+
}
242+
243+
export function parseAndroidLaunchComponent(stdout: string): string | null {
244+
const lines = stdout
245+
.split('\n')
246+
.map((line: string) => line.trim())
247+
.filter(Boolean);
248+
for (let index = lines.length - 1; index >= 0; index -= 1) {
249+
const line = lines[index];
250+
if (!line.includes('/')) continue;
251+
return line.split(/\s+/)[0];
252+
}
253+
return null;
206254
}
207255

208256
export async function openAndroidDevice(device: DeviceInfo): Promise<void> {
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { findBestMatchesByLocator, findNodeByLocator } from '../finders.ts';
4+
import type { SnapshotNode } from '../snapshot.ts';
5+
6+
function makeNode(ref: string, label?: string, identifier?: string): SnapshotNode {
7+
return {
8+
index: Number(ref.replace('e', '')) || 0,
9+
ref,
10+
type: 'android.widget.TextView',
11+
label,
12+
identifier,
13+
rect: { x: 0, y: 0, width: 100, height: 20 },
14+
};
15+
}
16+
17+
test('findBestMatchesByLocator returns all best-scored matches', () => {
18+
const nodes: SnapshotNode[] = [
19+
makeNode('e1', 'Continue'),
20+
makeNode('e2', 'Continue'),
21+
makeNode('e3', 'Continue later'),
22+
];
23+
const result = findBestMatchesByLocator(nodes, 'label', 'Continue', { requireRect: true });
24+
assert.equal(result.score, 2);
25+
assert.equal(result.matches.length, 2);
26+
assert.equal(result.matches[0]?.ref, 'e1');
27+
assert.equal(result.matches[1]?.ref, 'e2');
28+
});
29+
30+
test('findNodeByLocator preserves first best match behavior', () => {
31+
const nodes: SnapshotNode[] = [makeNode('e1', 'Continue'), makeNode('e2', 'Continue')];
32+
const match = findNodeByLocator(nodes, 'label', 'Continue', { requireRect: true });
33+
assert.equal(match?.ref, 'e1');
34+
});

src/utils/finders.ts

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,46 @@ export type FindMatchOptions = {
66
requireRect?: boolean;
77
};
88

9+
export type FindBestMatches = {
10+
matches: SnapshotNode[];
11+
score: number;
12+
};
13+
914
export function findNodeByLocator(
1015
nodes: SnapshotNode[],
1116
locator: FindLocator,
1217
query: string,
1318
options: FindMatchOptions = {},
1419
): SnapshotNode | null {
20+
const best = findBestMatchesByLocator(nodes, locator, query, options);
21+
return best.matches[0] ?? null;
22+
}
23+
24+
export function findBestMatchesByLocator(
25+
nodes: SnapshotNode[],
26+
locator: FindLocator,
27+
query: string,
28+
options: FindMatchOptions = {},
29+
): FindBestMatches {
1530
const normalizedQuery = normalizeText(query);
16-
if (!normalizedQuery) return null;
17-
let best: { node: SnapshotNode; score: number } | null = null;
31+
if (!normalizedQuery) return { matches: [], score: 0 };
32+
let bestScore = 0;
33+
const matches: SnapshotNode[] = [];
1834
for (const node of nodes) {
1935
if (options.requireRect && !node.rect) continue;
2036
const score = matchNode(node, locator, normalizedQuery);
2137
if (score <= 0) continue;
22-
if (!best || score > best.score) {
23-
best = { node, score };
24-
if (score >= 2) {
25-
// exact match, keep first exact match
26-
break;
27-
}
38+
if (score > bestScore) {
39+
bestScore = score;
40+
matches.length = 0;
41+
matches.push(node);
42+
continue;
43+
}
44+
if (score === bestScore) {
45+
matches.push(node);
2846
}
2947
}
30-
return best?.node ?? null;
48+
return { matches, score: bestScore };
3149
}
3250

3351
function matchNode(node: SnapshotNode, locator: FindLocator, query: string): number {

test/integration/ios.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,14 @@ test('ios settings commands', { skip: shouldSkipIos() }, async () => {
3030
detail: 'expected snapshot to include a nodes array',
3131
});
3232

33-
const openGeneralArgs = ['find', 'text', 'General', 'click', '--json', ...session];
33+
const openGeneralArgs = ['click', 'role=cell', 'label=General', '--json', ...session];
3434
const openGeneral = integration.runStep('open general', openGeneralArgs);
3535
integration.assertResult(
3636
openGeneral.json?.success,
3737
'open general success',
3838
openGeneralArgs,
3939
openGeneral,
40-
{ detail: 'expected find General click to return success=true' },
40+
{ detail: 'expected click role=cell label=General to return success=true' },
4141
);
4242

4343
const snapshotGeneralArgs = ['snapshot', '--json', ...session];

0 commit comments

Comments
 (0)