Skip to content

Commit 8bc1675

Browse files
Heal numeric get-text drift in replay update (#51)
* Refine selector resolution and simplify replay healing * Address review feedback on selector internals * Heal numeric get-text drift in replay update * Remove redundant find and dead assignment in selector internals Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2dd2cad commit 8bc1675

4 files changed

Lines changed: 142 additions & 3 deletions

File tree

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

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,87 @@ test('replay --update heals selector in is command', async () => {
365365
assert.ok(rewrittenSelector.includes('auth_continue'));
366366
});
367367

368+
test('replay --update heals numeric get text drift when numeric candidate value is unique', async () => {
369+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-heal-get-numeric-'));
370+
const sessionsDir = path.join(tempRoot, 'sessions');
371+
const replayPath = path.join(tempRoot, 'replay.ad');
372+
const sessionStore = new SessionStore(sessionsDir);
373+
const sessionName = 'heal-get-numeric-session';
374+
sessionStore.set(sessionName, makeSession(sessionName));
375+
376+
writeReplayFile(replayPath, {
377+
ts: Date.now(),
378+
command: 'get',
379+
positionals: ['text', 'role="statictext" label="2" || label="2"'],
380+
flags: {},
381+
result: {},
382+
});
383+
384+
const invokeCalls: string[] = [];
385+
const invoke = async (request: DaemonRequest): Promise<DaemonResponse> => {
386+
if (request.command !== 'get') {
387+
return { ok: false, error: { code: 'INVALID_ARGS', message: `unexpected command ${request.command}` } };
388+
}
389+
const selector = request.positionals?.[1] ?? '';
390+
invokeCalls.push(selector);
391+
if (selector.includes('label="2"')) {
392+
return { ok: false, error: { code: 'COMMAND_FAILED', message: 'selector stale' } };
393+
}
394+
if (selector.includes('label="20"')) {
395+
return { ok: true, data: { text: '20' } };
396+
}
397+
return { ok: false, error: { code: 'COMMAND_FAILED', message: 'unexpected selector' } };
398+
};
399+
400+
const dispatch = async (): Promise<Record<string, unknown> | void> => {
401+
return {
402+
nodes: [
403+
{
404+
index: 0,
405+
type: 'XCUIElementTypeStaticText',
406+
label: '20',
407+
rect: { x: 0, y: 100, width: 100, height: 24 },
408+
enabled: true,
409+
hittable: true,
410+
},
411+
{
412+
index: 1,
413+
type: 'XCUIElementTypeStaticText',
414+
label: 'Version: 0.84.0',
415+
rect: { x: 0, y: 200, width: 220, height: 17 },
416+
enabled: true,
417+
hittable: true,
418+
},
419+
],
420+
truncated: false,
421+
backend: 'xctest',
422+
};
423+
};
424+
425+
const response = await handleSessionCommands({
426+
req: {
427+
token: 't',
428+
session: sessionName,
429+
command: 'replay',
430+
positionals: [replayPath],
431+
flags: { replayUpdate: true },
432+
},
433+
sessionName,
434+
logPath: path.join(tempRoot, 'daemon.log'),
435+
sessionStore,
436+
invoke,
437+
dispatch,
438+
});
439+
440+
assert.ok(response);
441+
assert.equal(response.ok, true, JSON.stringify(response));
442+
if (response.ok) {
443+
assert.equal(response.data?.healed, 1);
444+
assert.equal(response.data?.replayed, 1);
445+
}
446+
assert.equal(invokeCalls.length, 2);
447+
});
448+
368449
test('replay rejects legacy JSON payload files', async () => {
369450
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-json-rejected-'));
370451
const sessionsDir = path.join(tempRoot, 'sessions');

src/daemon/handlers/interaction.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,7 @@ export async function handleInteractionCommands(params: {
310310
platform: session.device.platform,
311311
requireRect: false,
312312
requireUnique: true,
313+
disambiguateAmbiguous: sub === 'text',
313314
});
314315
if (!resolved) {
315316
return {

src/daemon/handlers/session.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { ensureDeviceReady } from '../device-ready.ts';
1010
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';
13-
import { pruneGroupNodes } from '../snapshot-processing.ts';
13+
import { extractNodeText, normalizeType, pruneGroupNodes } from '../snapshot-processing.ts';
1414
import {
1515
buildSelectorChainForNode,
1616
resolveSelectorChain,
@@ -517,6 +517,10 @@ async function healReplayAction(params: {
517517
const session = sessionStore.get(sessionName);
518518
if (!session) return null;
519519
const requiresRect = action.command === 'click' || action.command === 'fill';
520+
const allowDisambiguation =
521+
action.command === 'click' ||
522+
action.command === 'fill' ||
523+
(action.command === 'get' && action.positionals?.[0] === 'text');
520524
const snapshot = await captureSnapshotForReplay(session, action, logPath, requiresRect, dispatch, sessionStore);
521525
const selectorCandidates = collectReplaySelectorCandidates(action);
522526
for (const candidate of selectorCandidates) {
@@ -526,7 +530,7 @@ async function healReplayAction(params: {
526530
platform: session.device.platform,
527531
requireRect: requiresRect,
528532
requireUnique: true,
529-
disambiguateAmbiguous: requiresRect,
533+
disambiguateAmbiguous: allowDisambiguation,
530534
});
531535
if (!resolved) continue;
532536
const selectorChain = buildSelectorChainForNode(resolved.node, session.device.platform, {
@@ -580,6 +584,10 @@ async function healReplayAction(params: {
580584
};
581585
}
582586
}
587+
const numericDriftHeal = healNumericGetTextDrift(action, snapshot, session);
588+
if (numericDriftHeal) {
589+
return numericDriftHeal;
590+
}
583591
return null;
584592
}
585593

@@ -697,6 +705,56 @@ function parseSelectorWaitPositionals(positionals: string[]): {
697705
};
698706
}
699707

708+
function healNumericGetTextDrift(
709+
action: SessionAction,
710+
snapshot: SnapshotState,
711+
session: SessionState,
712+
): SessionAction | null {
713+
if (action.command !== 'get') return null;
714+
if (action.positionals?.[0] !== 'text') return null;
715+
const selectorExpression = action.positionals?.[1];
716+
if (!selectorExpression) return null;
717+
const chain = tryParseSelectorChain(selectorExpression);
718+
if (!chain) return null;
719+
720+
const roleFilters = new Set<string>();
721+
let hasNumericTerm = false;
722+
for (const selector of chain.selectors) {
723+
for (const term of selector.terms) {
724+
if (term.key === 'role' && typeof term.value === 'string') {
725+
roleFilters.add(normalizeType(term.value));
726+
}
727+
if (
728+
(term.key === 'text' || term.key === 'label' || term.key === 'value') &&
729+
typeof term.value === 'string' &&
730+
/^\d+$/.test(term.value.trim())
731+
) {
732+
hasNumericTerm = true;
733+
}
734+
}
735+
}
736+
if (!hasNumericTerm) return null;
737+
738+
const numericNodes = snapshot.nodes.filter((node) => {
739+
const text = extractNodeText(node).trim();
740+
if (!/^\d+$/.test(text)) return false;
741+
if (roleFilters.size === 0) return true;
742+
return roleFilters.has(normalizeType(node.type ?? ''));
743+
});
744+
if (numericNodes.length === 0) return null;
745+
const numericValues = uniqueStrings(numericNodes.map((node) => extractNodeText(node).trim()));
746+
if (numericValues.length !== 1) return null;
747+
748+
const targetNode = numericNodes[0];
749+
if (!targetNode) return null;
750+
const selectorChain = buildSelectorChainForNode(targetNode, session.device.platform, { action: 'get' });
751+
if (selectorChain.length === 0) return null;
752+
return {
753+
...action,
754+
positionals: ['text', selectorChain.join(' || ')],
755+
};
756+
}
757+
700758
function parseReplayScript(script: string): SessionAction[] {
701759
const actions: SessionAction[] = [];
702760
const lines = script.split(/\r?\n/);

src/daemon/selectors.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -483,7 +483,6 @@ function analyzeSelectorMatches(
483483
}
484484
if (!best) {
485485
best = node;
486-
tie = false;
487486
continue;
488487
}
489488
const comparison = compareDisambiguationCandidates(node, best);

0 commit comments

Comments
 (0)