Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/daemon/handlers/__tests__/replay-heal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,11 @@ test('replay without --update does not heal or rewrite', async () => {

assert.ok(response);
assert.equal(response.ok, false);
if (!response.ok) {
assert.match(response.error.message, /Replay failed at step 1/);
assert.equal(response.error.details?.step, 1);
assert.equal(response.error.details?.action, 'click');
}
assert.equal(snapshotDispatchCalls, 0);
assert.equal(fs.readFileSync(replayPath, 'utf8'), originalPayload);
});
Expand Down
22 changes: 21 additions & 1 deletion src/daemon/handlers/find.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { dispatchCommand, resolveTargetDevice } from '../../core/dispatch.ts';
import { findNodeByLocator, type FindLocator } from '../../utils/finders.ts';
import { findBestMatchesByLocator, findNodeByLocator, type FindLocator } from '../../utils/finders.ts';
import { attachRefs, centerOfRect, type RawSnapshotNode, type SnapshotState } from '../../utils/snapshot.ts';
import { AppError } from '../../utils/errors.ts';
import type { DaemonRequest, DaemonResponse } from '../types.ts';
Expand Down Expand Up @@ -114,6 +114,26 @@ export async function handleFindCommands(params: {
return { ok: false, error: { code: 'COMMAND_FAILED', message: 'find wait timed out' } };
}
const { nodes } = await fetchNodes();
const bestMatches = findBestMatchesByLocator(nodes, locator, query, { requireRect: requiresRect });
if (requiresRect && bestMatches.matches.length > 1) {
const candidates = bestMatches.matches.slice(0, 8).map((candidate) => {
const label = extractNodeText(candidate) || candidate.label || candidate.identifier || candidate.type || '';
return `@${candidate.ref}${label ? `(${label})` : ''}`;
});
return {
ok: false,
error: {
code: 'AMBIGUOUS_MATCH',
message: `find matched ${bestMatches.matches.length} elements for ${locator} "${query}". Use a more specific locator or selector.`,
details: {
locator,
query,
matches: bestMatches.matches.length,
candidates,
},
},
};
}
const node = findNodeByLocator(nodes, locator, query, { requireRect: requiresRect });
if (!node) {
return { ok: false, error: { code: 'COMMAND_FAILED', message: 'find did not match any element' } };
Expand Down
44 changes: 41 additions & 3 deletions src/daemon/handlers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,9 @@ export async function handleSessionCommands(params: {
flags: action.flags ?? {},
});
if (response.ok) continue;
if (!shouldUpdate) return response;
if (!shouldUpdate) {
return withReplayFailureContext(response, action, index, resolved);
}
const nextAction = await healReplayAction({
action,
sessionName,
Expand All @@ -278,7 +280,7 @@ export async function handleSessionCommands(params: {
dispatch,
});
if (!nextAction) {
return response;
return withReplayFailureContext(response, action, index, resolved);
}
actions[index] = nextAction;
response = await invoke({
Expand All @@ -289,7 +291,7 @@ export async function handleSessionCommands(params: {
flags: nextAction.flags ?? {},
});
if (!response.ok) {
return response;
return withReplayFailureContext(response, nextAction, index, resolved);
}
healed += 1;
}
Expand Down Expand Up @@ -334,6 +336,42 @@ export async function handleSessionCommands(params: {
return null;
}

function withReplayFailureContext(
response: DaemonResponse,
action: SessionAction,
index: number,
replayPath: string,
): DaemonResponse {
if (response.ok) return response;
const step = index + 1;
const summary = formatReplayActionSummary(action);
const details = {
...(response.error.details ?? {}),
replayPath,
step,
action: action.command,
positionals: action.positionals ?? [],
};
return {
ok: false,
error: {
code: response.error.code,
message: `Replay failed at step ${step} (${summary}): ${response.error.message}`,
details,
},
};
}

function formatReplayActionSummary(action: SessionAction): string {
const values = (action.positionals ?? []).map((value) => {
const trimmed = value.trim();
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed;
if (trimmed.startsWith('@')) return trimmed;
return JSON.stringify(trimmed);
});
return [action.command, ...values].join(' ');
}

async function healReplayAction(params: {
action: SessionAction;
sessionName: string;
Expand Down
17 changes: 17 additions & 0 deletions src/platforms/android/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseAndroidLaunchComponent } from '../index.ts';
import { findBounds, parseUiHierarchy } from '../ui-hierarchy.ts';

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

assert.deepEqual(findBounds(xml, 'target'), { x: 200, y: 350 });
});

test('parseAndroidLaunchComponent extracts final resolved component', () => {
const stdout = [
'priority=0 preferredOrder=0 match=0x108000 specificIndex=-1 isDefault=true',
'com.boatsgroup.boattrader/com.boatsgroup.boattrader.MainActivity',
].join('\n');
assert.equal(
parseAndroidLaunchComponent(stdout),
'com.boatsgroup.boattrader/com.boatsgroup.boattrader.MainActivity',
);
});

test('parseAndroidLaunchComponent returns null when no component is present', () => {
const stdout = 'No activity found';
assert.equal(parseAndroidLaunchComponent(stdout), null);
});
76 changes: 62 additions & 14 deletions src/platforms/android/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,22 +187,70 @@ export async function openAndroidApp(
);
return;
}
await runCmd(
try {
await runCmd(
'adb',
adbArgs(device, [
'shell',
'am',
'start',
'-a',
'android.intent.action.MAIN',
'-c',
'android.intent.category.DEFAULT',
'-c',
'android.intent.category.LAUNCHER',
'-p',
resolved.value,
]),
);
return;
} catch (initialError) {
const component = await resolveAndroidLaunchComponent(device, resolved.value);
if (!component) throw initialError;
await runCmd(
'adb',
adbArgs(device, [
'shell',
'am',
'start',
'-a',
'android.intent.action.MAIN',
'-c',
'android.intent.category.DEFAULT',
'-c',
'android.intent.category.LAUNCHER',
'-n',
component,
]),
);
}
}

async function resolveAndroidLaunchComponent(
device: DeviceInfo,
packageName: string,
): Promise<string | null> {
const result = await runCmd(
'adb',
adbArgs(device, [
'shell',
'am',
'start',
'-a',
'android.intent.action.MAIN',
'-c',
'android.intent.category.DEFAULT',
'-c',
'android.intent.category.LAUNCHER',
'-p',
resolved.value,
]),
adbArgs(device, ['shell', 'cmd', 'package', 'resolve-activity', '--brief', packageName]),
{ allowFailure: true },
);
if (result.exitCode !== 0) return null;
return parseAndroidLaunchComponent(result.stdout);
}

export function parseAndroidLaunchComponent(stdout: string): string | null {
const lines = stdout
.split('\n')
.map((line: string) => line.trim())
.filter(Boolean);
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index];
if (!line.includes('/')) continue;
return line.split(/\s+/)[0];
}
return null;
}

export async function openAndroidDevice(device: DeviceInfo): Promise<void> {
Expand Down
34 changes: 34 additions & 0 deletions src/utils/__tests__/finders.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { findBestMatchesByLocator, findNodeByLocator } from '../finders.ts';
import type { SnapshotNode } from '../snapshot.ts';

function makeNode(ref: string, label?: string, identifier?: string): SnapshotNode {
return {
index: Number(ref.replace('e', '')) || 0,
ref,
type: 'android.widget.TextView',
label,
identifier,
rect: { x: 0, y: 0, width: 100, height: 20 },
};
}

test('findBestMatchesByLocator returns all best-scored matches', () => {
const nodes: SnapshotNode[] = [
makeNode('e1', 'Continue'),
makeNode('e2', 'Continue'),
makeNode('e3', 'Continue later'),
];
const result = findBestMatchesByLocator(nodes, 'label', 'Continue', { requireRect: true });
assert.equal(result.score, 2);
assert.equal(result.matches.length, 2);
assert.equal(result.matches[0]?.ref, 'e1');
assert.equal(result.matches[1]?.ref, 'e2');
});

test('findNodeByLocator preserves first best match behavior', () => {
const nodes: SnapshotNode[] = [makeNode('e1', 'Continue'), makeNode('e2', 'Continue')];
const match = findNodeByLocator(nodes, 'label', 'Continue', { requireRect: true });
assert.equal(match?.ref, 'e1');
});
36 changes: 27 additions & 9 deletions src/utils/finders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,46 @@ export type FindMatchOptions = {
requireRect?: boolean;
};

export type FindBestMatches = {
matches: SnapshotNode[];
score: number;
};

export function findNodeByLocator(
nodes: SnapshotNode[],
locator: FindLocator,
query: string,
options: FindMatchOptions = {},
): SnapshotNode | null {
const best = findBestMatchesByLocator(nodes, locator, query, options);
return best.matches[0] ?? null;
}

export function findBestMatchesByLocator(
nodes: SnapshotNode[],
locator: FindLocator,
query: string,
options: FindMatchOptions = {},
): FindBestMatches {
const normalizedQuery = normalizeText(query);
if (!normalizedQuery) return null;
let best: { node: SnapshotNode; score: number } | null = null;
if (!normalizedQuery) return { matches: [], score: 0 };
let bestScore = 0;
const matches: SnapshotNode[] = [];
for (const node of nodes) {
if (options.requireRect && !node.rect) continue;
const score = matchNode(node, locator, normalizedQuery);
if (score <= 0) continue;
if (!best || score > best.score) {
best = { node, score };
if (score >= 2) {
// exact match, keep first exact match
break;
}
if (score > bestScore) {
bestScore = score;
matches.length = 0;
matches.push(node);
continue;
}
if (score === bestScore) {
matches.push(node);
}
}
return best?.node ?? null;
return { matches, score: bestScore };
}

function matchNode(node: SnapshotNode, locator: FindLocator, query: string): number {
Expand Down
4 changes: 2 additions & 2 deletions test/integration/ios.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,14 @@ test('ios settings commands', { skip: shouldSkipIos() }, async () => {
detail: 'expected snapshot to include a nodes array',
});

const openGeneralArgs = ['find', 'text', 'General', 'click', '--json', ...session];
const openGeneralArgs = ['click', 'role=cell', 'label=General', '--json', ...session];
const openGeneral = integration.runStep('open general', openGeneralArgs);
integration.assertResult(
openGeneral.json?.success,
'open general success',
openGeneralArgs,
openGeneral,
{ detail: 'expected find General click to return success=true' },
{ detail: 'expected click role=cell label=General to return success=true' },
);

const snapshotGeneralArgs = ['snapshot', '--json', ...session];
Expand Down
Loading