Skip to content

Commit 172968c

Browse files
authored
fix: refine selector resolution and simplify replay healing (#49)
* Refine selector resolution and simplify replay healing * Address review feedback on selector internals
1 parent 733bd8d commit 172968c

6 files changed

Lines changed: 352 additions & 29 deletions

File tree

src/daemon/__tests__/selectors.test.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,97 @@ test('resolveSelectorChain falls back when first selector is ambiguous', () => {
7474
assert.equal(resolved.node.ref, 'e2');
7575
});
7676

77+
test('resolveSelectorChain keeps strict ambiguity behavior by default', () => {
78+
const chain = parseSelectorChain('label="Continue"');
79+
const resolved = resolveSelectorChain(nodes, chain, {
80+
platform: 'ios',
81+
requireRect: true,
82+
requireUnique: true,
83+
});
84+
assert.equal(resolved, null);
85+
});
86+
87+
test('resolveSelectorChain disambiguates to deeper/smaller matching node when enabled', () => {
88+
const disambiguationNodes: SnapshotState['nodes'] = [
89+
{
90+
ref: 'e1',
91+
index: 0,
92+
type: 'Other',
93+
label: 'Press me',
94+
rect: { x: 0, y: 0, width: 300, height: 300 },
95+
depth: 1,
96+
enabled: true,
97+
hittable: true,
98+
},
99+
{
100+
ref: 'e2',
101+
index: 1,
102+
type: 'Other',
103+
label: 'Press me',
104+
rect: { x: 10, y: 10, width: 100, height: 20 },
105+
depth: 2,
106+
enabled: true,
107+
hittable: true,
108+
},
109+
];
110+
const chain = parseSelectorChain('role="other" label="Press me" || label="Press me"');
111+
const resolved = resolveSelectorChain(disambiguationNodes, chain, {
112+
platform: 'ios',
113+
requireRect: true,
114+
requireUnique: true,
115+
disambiguateAmbiguous: true,
116+
});
117+
assert.ok(resolved);
118+
assert.equal(resolved.node.ref, 'e2');
119+
assert.equal(resolved.matches, 2);
120+
});
121+
122+
test('resolveSelectorChain disambiguation tie falls back to next selector', () => {
123+
const tieNodes: SnapshotState['nodes'] = [
124+
{
125+
ref: 'e1',
126+
index: 0,
127+
type: 'Other',
128+
label: 'Press me',
129+
rect: { x: 0, y: 0, width: 100, height: 20 },
130+
depth: 2,
131+
enabled: true,
132+
hittable: true,
133+
},
134+
{
135+
ref: 'e2',
136+
index: 1,
137+
type: 'Other',
138+
label: 'Press me',
139+
rect: { x: 0, y: 40, width: 100, height: 20 },
140+
depth: 2,
141+
enabled: true,
142+
hittable: true,
143+
},
144+
{
145+
ref: 'e3',
146+
index: 2,
147+
type: 'Other',
148+
label: 'Press me',
149+
identifier: 'press_me_unique',
150+
rect: { x: 0, y: 80, width: 100, height: 20 },
151+
depth: 2,
152+
enabled: true,
153+
hittable: true,
154+
},
155+
];
156+
const chain = parseSelectorChain('label="Press me" || id="press_me_unique"');
157+
const resolved = resolveSelectorChain(tieNodes, chain, {
158+
platform: 'ios',
159+
requireRect: true,
160+
requireUnique: true,
161+
disambiguateAmbiguous: true,
162+
});
163+
assert.ok(resolved);
164+
assert.equal(resolved.selectorIndex, 1);
165+
assert.equal(resolved.node.ref, 'e3');
166+
});
167+
77168
test('findSelectorChainMatch returns first matching selector for existence checks', () => {
78169
const chain = parseSelectorChain('label="Continue" || id=auth_continue');
79170
const match = findSelectorChainMatch(nodes, chain, {
@@ -91,12 +182,31 @@ test('splitSelectorFromArgs extracts selector prefix and trailing value', () =>
91182
assert.deepEqual(split.rest, ['qa@example.com']);
92183
});
93184

185+
test('splitSelectorFromArgs prefers trailing token for value when requested', () => {
186+
const split = splitSelectorFromArgs(['label="Filter"', 'visible=true'], { preferTrailingValue: true });
187+
assert.ok(split);
188+
assert.equal(split.selectorExpression, 'label="Filter"');
189+
assert.deepEqual(split.rest, ['visible=true']);
190+
});
191+
192+
test('splitSelectorFromArgs keeps full selector when trailing value preference is disabled', () => {
193+
const split = splitSelectorFromArgs(['label="Filter"', 'visible=true']);
194+
assert.ok(split);
195+
assert.equal(split.selectorExpression, 'label="Filter" visible=true');
196+
assert.deepEqual(split.rest, []);
197+
});
198+
94199
test('parseSelectorChain rejects unknown keys and malformed quotes', () => {
95200
assert.throws(() => parseSelectorChain('foo=bar'), /Unknown selector key/i);
96201
assert.throws(() => parseSelectorChain('label="unclosed'), /Unclosed quote/i);
97202
assert.throws(() => parseSelectorChain(''), /cannot be empty/i);
98203
});
99204

205+
test('parseSelectorChain handles quoted values ending in escaped backslashes', () => {
206+
const chain = parseSelectorChain('label="path\\\\" || id=auth_continue');
207+
assert.equal(chain.selectors.length, 2);
208+
});
209+
100210
test('isSelectorToken only accepts known keys for key=value tokens', () => {
101211
assert.equal(isSelectorToken('id=foo'), true);
102212
assert.equal(isSelectorToken('editable=true'), true);
@@ -126,3 +236,26 @@ test('buildSelectorChainForNode prefers id and adds editable for fill action', (
126236
assert.ok(chain.some((entry) => entry.includes('id=')));
127237
assert.ok(chain.some((entry) => entry.includes('editable=true')));
128238
});
239+
240+
test('role selector normalization matches Android class names by leaf type', () => {
241+
const androidNodes: SnapshotState['nodes'] = [
242+
{
243+
ref: 'a1',
244+
index: 0,
245+
type: 'android.widget.Button',
246+
label: 'Continue',
247+
identifier: 'auth_continue',
248+
rect: { x: 0, y: 0, width: 120, height: 44 },
249+
enabled: true,
250+
hittable: true,
251+
},
252+
];
253+
const chain = parseSelectorChain('role=button label="Continue"');
254+
const resolved = resolveSelectorChain(androidNodes, chain, {
255+
platform: 'android',
256+
requireRect: true,
257+
requireUnique: true,
258+
});
259+
assert.ok(resolved);
260+
assert.equal(resolved.node.ref, 'a1');
261+
});

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,65 @@ test('replay without --update does not heal or rewrite', async () => {
234234
assert.equal(fs.readFileSync(replayPath, 'utf8'), originalPayload);
235235
});
236236

237+
test('replay --update skips malformed selector candidates and preserves replay error context', async () => {
238+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-malformed-candidate-'));
239+
const sessionsDir = path.join(tempRoot, 'sessions');
240+
const replayPath = path.join(tempRoot, 'replay.ad');
241+
const sessionStore = new SessionStore(sessionsDir);
242+
const sessionName = 'malformed-candidate-session';
243+
sessionStore.set(sessionName, makeSession(sessionName));
244+
245+
writeReplayFile(replayPath, {
246+
ts: Date.now(),
247+
command: 'click',
248+
positionals: ['id="old_continue" ||'],
249+
flags: {},
250+
result: {},
251+
});
252+
253+
const dispatch = async (): Promise<Record<string, unknown> | void> => {
254+
return {
255+
nodes: [
256+
{
257+
index: 0,
258+
type: 'XCUIElementTypeButton',
259+
label: 'Continue',
260+
identifier: 'auth_continue',
261+
rect: { x: 10, y: 10, width: 100, height: 44 },
262+
enabled: true,
263+
hittable: true,
264+
},
265+
],
266+
truncated: false,
267+
backend: 'xctest',
268+
};
269+
};
270+
271+
const response = await handleSessionCommands({
272+
req: {
273+
token: 't',
274+
session: sessionName,
275+
command: 'replay',
276+
positionals: [replayPath],
277+
flags: { replayUpdate: true },
278+
},
279+
sessionName,
280+
logPath: path.join(tempRoot, 'daemon.log'),
281+
sessionStore,
282+
invoke: async () => ({ ok: false, error: { code: 'COMMAND_FAILED', message: 'selector stale' } }),
283+
dispatch,
284+
});
285+
286+
assert.ok(response);
287+
assert.equal(response.ok, false);
288+
if (!response.ok) {
289+
assert.equal(response.error.code, 'COMMAND_FAILED');
290+
assert.match(response.error.message, /Replay failed at step 1/);
291+
assert.equal(response.error.details?.step, 1);
292+
assert.equal(response.error.details?.action, 'click');
293+
}
294+
});
295+
237296
test('replay --update heals selector in is command', async () => {
238297
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-heal-is-'));
239298
const sessionsDir = path.join(tempRoot, 'sessions');

src/daemon/handlers/interaction.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
formatSelectorFailure,
1313
parseSelectorChain,
1414
resolveSelectorChain,
15+
splitIsSelectorArgs,
1516
splitSelectorFromArgs,
1617
} from '../selectors.ts';
1718

@@ -90,6 +91,7 @@ export async function handleInteractionCommands(params: {
9091
platform: session.device.platform,
9192
requireRect: true,
9293
requireUnique: true,
94+
disambiguateAmbiguous: true,
9395
});
9496
if (!resolved || !resolved.node.rect) {
9597
return {
@@ -180,7 +182,7 @@ export async function handleInteractionCommands(params: {
180182
error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' },
181183
};
182184
}
183-
const selectorArgs = splitSelectorFromArgs(req.positionals ?? []);
185+
const selectorArgs = splitSelectorFromArgs(req.positionals ?? [], { preferTrailingValue: true });
184186
if (selectorArgs) {
185187
if (selectorArgs.rest.length === 0) {
186188
return { ok: false, error: { code: 'INVALID_ARGS', message: 'fill requires text after selector' } };
@@ -197,6 +199,7 @@ export async function handleInteractionCommands(params: {
197199
platform: session.device.platform,
198200
requireRect: true,
199201
requireUnique: true,
202+
disambiguateAmbiguous: true,
200203
});
201204
if (!resolved || !resolved.node.rect) {
202205
return {
@@ -367,8 +370,7 @@ export async function handleInteractionCommands(params: {
367370
error: { code: 'UNSUPPORTED_OPERATION', message: 'is is not supported on this device' },
368371
};
369372
}
370-
const selectorArgs = req.positionals.slice(1);
371-
const split = splitSelectorFromArgs(selectorArgs);
373+
const { split } = splitIsSelectorArgs(req.positionals);
372374
if (!split) {
373375
return {
374376
ok: false,

src/daemon/handlers/session.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@ import { resolveIosAppStateFromSnapshots } from '../app-state.ts';
1111
import { stopIosRunnerSession } from '../../platforms/ios/runner-client.ts';
1212
import { attachRefs, type RawSnapshotNode, type SnapshotState } from '../../utils/snapshot.ts';
1313
import { pruneGroupNodes } from '../snapshot-processing.ts';
14-
import { buildSelectorChainForNode, parseSelectorChain, resolveSelectorChain, splitSelectorFromArgs } from '../selectors.ts';
14+
import {
15+
buildSelectorChainForNode,
16+
resolveSelectorChain,
17+
splitIsSelectorArgs,
18+
splitSelectorFromArgs,
19+
tryParseSelectorChain,
20+
} from '../selectors.ts';
1521
import { inferFillText, uniqueStrings } from '../action-utils.ts';
1622

1723
type ReinstallOps = {
@@ -514,11 +520,13 @@ async function healReplayAction(params: {
514520
const snapshot = await captureSnapshotForReplay(session, action, logPath, requiresRect, dispatch, sessionStore);
515521
const selectorCandidates = collectReplaySelectorCandidates(action);
516522
for (const candidate of selectorCandidates) {
517-
const chain = parseSelectorChain(candidate);
523+
const chain = tryParseSelectorChain(candidate);
524+
if (!chain) continue;
518525
const resolved = resolveSelectorChain(snapshot.nodes, chain, {
519526
platform: session.device.platform,
520527
requireRect: requiresRect,
521528
requireUnique: true,
529+
disambiguateAmbiguous: requiresRect,
522530
});
523531
if (!resolved) continue;
524532
const selectorChain = buildSelectorChainForNode(resolved.node, session.device.platform, {
@@ -548,9 +556,8 @@ async function healReplayAction(params: {
548556
};
549557
}
550558
if (action.command === 'is') {
551-
const predicate = action.positionals?.[0];
559+
const { predicate, split } = splitIsSelectorArgs(action.positionals);
552560
if (!predicate) continue;
553-
const split = splitSelectorFromArgs(action.positionals.slice(1));
554561
const expectedText = split?.rest.join(' ').trim() ?? '';
555562
const nextPositionals = [predicate, selectorExpression];
556563
if (predicate === 'text' && expectedText.length > 0) {
@@ -641,7 +648,7 @@ function collectReplaySelectorCandidates(action: SessionAction): string[] {
641648
}
642649
}
643650
if (action.command === 'is') {
644-
const split = splitSelectorFromArgs(action.positionals.slice(1));
651+
const { split } = splitIsSelectorArgs(action.positionals);
645652
if (split) {
646653
result.push(split.selectorExpression);
647654
}

0 commit comments

Comments
 (0)