Skip to content

Commit 852c680

Browse files
committed
fix: record iOS selector target evidence
1 parent 06eea52 commit 852c680

7 files changed

Lines changed: 172 additions & 144 deletions

File tree

src/daemon/__tests__/session-target-evidence.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,21 @@ test('computeTargetEvidence: scrollRegion is the nearest scrollable ancestor loc
110110
assert.equal(evidence.verification, 'verified');
111111
});
112112

113+
test('computeTargetEvidence: a stable scroll-region ID takes precedence over a changed label', () => {
114+
const recordedNodes = scrollableListFixture();
115+
recordedNodes[1]!.label = 'Inbox';
116+
const recorded = computeTargetEvidence({ node: recordedNodes[2]!, nodes: recordedNodes });
117+
118+
const currentNodes = scrollableListFixture();
119+
currentNodes[1]!.label = 'Messages';
120+
const current = computeTargetEvidence({ node: currentNodes[2]!, nodes: currentNodes });
121+
122+
assert.ok(recorded);
123+
assert.ok(current);
124+
assert.deepEqual(recorded.scrollRegion, { role: 'scrollview', id: 'editor-scroll' });
125+
assert.deepEqual(current.scrollRegion, recorded.scrollRegion);
126+
});
127+
113128
// ---------------------------------------------------------------------------
114129
// Duplicate identity resolved by sibling / viewportOrder, still verified
115130
// (decision 3's self-check succeeds by construction whenever the capture

src/daemon/handlers/interaction-touch.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,7 @@ function readDirectIosSelectorTapTarget(params: {
314314
const { session, commandLabel, target, flags } = params;
315315
if (commandLabel !== 'click') return null;
316316
if (target.kind !== 'selector') return null;
317+
if (session.recordSession) return null;
317318
if (hasNonDefaultClickOptions(flags)) return null;
318319
if (commandSupportsVerifyEvidence(commandLabel) && flags?.verify === true) return null;
319320
if (commandSupportsSettleObservation(commandLabel) && flags?.settle === true) return null;
@@ -634,6 +635,7 @@ function readDirectIosSelectorFillTarget(params: {
634635
}): DirectIosSelectorTarget | null {
635636
const { session, target, flags } = params;
636637
if (target.kind !== 'selector') return null;
638+
if (session.recordSession) return null;
637639
if (commandSupportsVerifyEvidence('fill') && flags?.verify === true) return null;
638640
if (commandSupportsSettleObservation('fill') && flags?.settle === true) return null;
639641
return readDirectSelectorWithMaestroFallback(session, target.selector, flags);

src/daemon/session-target-evidence.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -203,8 +203,11 @@ function computeScrollRegionKey(
203203
const identity = boundedLocalIdentity(container);
204204
return {
205205
role: identity.role,
206-
...(identity.id !== undefined ? { id: identity.id } : {}),
207-
...(identity.label !== undefined ? { label: identity.label } : {}),
206+
...(identity.id !== undefined
207+
? { id: identity.id }
208+
: identity.label !== undefined
209+
? { label: identity.label }
210+
: {}),
208211
};
209212
}
210213

@@ -214,7 +217,7 @@ function scrollRegionKeysEqual(
214217
): boolean {
215218
if (!a && !b) return true;
216219
if (!a || !b) return false;
217-
return a.role === b.role && a.id === b.id && a.label === b.label;
220+
return matchesLocalIdentity(a, b);
218221
}
219222

220223
function boundedRect(node: SnapshotNode): TargetAnnotationV1['rect'] {
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { test } from 'vitest';
2+
import assert from 'node:assert/strict';
3+
import { classifyTargetBindingMatch } from '../target-identity.ts';
4+
5+
// Decision 3's replay-time verification paths 2-6 are shared with the
6+
// writer's record-time self-check and stay isolated from parser coverage.
7+
8+
test('classifyTargetBindingMatch path 2: matchCount 0 is unverifiable (selector-miss)', () => {
9+
const result = classifyTargetBindingMatch({
10+
winnerRef: 'e1', matchedRefs: [], identitySetRefs: [], siblingMatchRefs: [],
11+
regionMemberRefs: undefined, viewportCandidateRef: undefined,
12+
});
13+
assert.deepEqual(result, { path: 2, outcome: 'unverifiable', reason: 'selector-miss' });
14+
});
15+
16+
test('classifyTargetBindingMatch path 3: empty identity set is unverifiable', () => {
17+
const result = classifyTargetBindingMatch({
18+
winnerRef: 'e1', matchedRefs: ['e1'], identitySetRefs: [], siblingMatchRefs: [],
19+
regionMemberRefs: undefined, viewportCandidateRef: undefined,
20+
});
21+
assert.deepEqual(result, { path: 3, outcome: 'unverifiable', reason: 'identity-set-empty' });
22+
});
23+
24+
test('classifyTargetBindingMatch path 4: unique identity-set member equal to winner is verified', () => {
25+
const result = classifyTargetBindingMatch({
26+
winnerRef: 'e1', matchedRefs: ['e1'], identitySetRefs: ['e1'], siblingMatchRefs: ['e1'],
27+
regionMemberRefs: undefined, viewportCandidateRef: undefined,
28+
});
29+
assert.deepEqual(result, { path: 4, outcome: 'verified' });
30+
});
31+
32+
test('classifyTargetBindingMatch path 5: unique identity-set member that is not the winner is unverifiable', () => {
33+
const result = classifyTargetBindingMatch({
34+
winnerRef: 'e1', matchedRefs: ['e1'], identitySetRefs: ['e9'], siblingMatchRefs: [],
35+
regionMemberRefs: undefined, viewportCandidateRef: undefined,
36+
});
37+
assert.deepEqual(result, { path: 5, outcome: 'unverifiable', reason: 'unique-but-wrong' });
38+
});
39+
40+
test('classifyTargetBindingMatch path 6: duplicate identity resolved by a unique sibling match', () => {
41+
const result = classifyTargetBindingMatch({
42+
winnerRef: 'e1', matchedRefs: ['e1', 'e2'], identitySetRefs: ['e1', 'e2'],
43+
siblingMatchRefs: ['e1'], regionMemberRefs: undefined, viewportCandidateRef: undefined,
44+
});
45+
assert.deepEqual(result, { path: 6, outcome: 'verified' });
46+
});
47+
48+
test('classifyTargetBindingMatch path 6: viewport order resolves repeated sibling ordinals', () => {
49+
const result = classifyTargetBindingMatch({
50+
winnerRef: 'e1', matchedRefs: ['e1', 'e2'], identitySetRefs: ['e1', 'e2'],
51+
siblingMatchRefs: ['e1', 'e2'], regionMemberRefs: ['e2', 'e1'], viewportCandidateRef: 'e1',
52+
});
53+
assert.deepEqual(result, { path: 6, outcome: 'verified' });
54+
});
55+
56+
test('classifyTargetBindingMatch path 6: unavailable or out-of-range viewport evidence falls through', () => {
57+
for (const [regionMemberRefs, viewportCandidateRef] of [
58+
[undefined, undefined],
59+
[['e1', 'e2'], undefined],
60+
] as const) {
61+
const result = classifyTargetBindingMatch({
62+
winnerRef: 'e1', matchedRefs: ['e1', 'e2'], identitySetRefs: ['e1', 'e2'],
63+
siblingMatchRefs: ['e1', 'e2'], regionMemberRefs, viewportCandidateRef,
64+
});
65+
assert.deepEqual(result, { path: 6, outcome: 'unverifiable', reason: 'no-signal-isolation' });
66+
}
67+
});
68+
69+
test('classifyTargetBindingMatch path 6: a signal isolating a different node than the winner is distinct from fall-through', () => {
70+
for (const params of [
71+
{ siblingMatchRefs: ['e1', 'e2'], regionMemberRefs: ['e1', 'e2'], viewportCandidateRef: 'e2' },
72+
{ siblingMatchRefs: ['e2'], regionMemberRefs: ['e1', 'e2'], viewportCandidateRef: 'e1' },
73+
]) {
74+
const result = classifyTargetBindingMatch({
75+
winnerRef: 'e1', matchedRefs: ['e1', 'e2'], identitySetRefs: ['e1', 'e2'], ...params,
76+
});
77+
assert.deepEqual(result, { path: 6, outcome: 'unverifiable', reason: 'signal-isolated-wrong' });
78+
}
79+
});

src/replay/__tests__/target-identity.test.ts

Lines changed: 0 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { test } from 'vitest';
22
import assert from 'node:assert/strict';
33
import { AppError } from '../../kernel/errors.ts';
44
import {
5-
classifyTargetBindingMatch,
65
formatTargetAnnotationCommentLine,
76
matchesAncestryPrefix,
87
matchesLocalIdentity,
@@ -365,143 +364,3 @@ test('parser accepts an explicit empty-string role (writer-legal for typeless no
365364
assert.equal(parsed.role, '');
366365
assert.deepEqual(parsed.ancestry, [{ role: '' }]);
367366
});
368-
369-
// ---------------------------------------------------------------------------
370-
// Classification core (decision 3 "Replay-time verification" paths 2-6):
371-
// this is the exact function the writer's record-time self-check calls, and
372-
// what a future replay-enforcement step will reuse — so duplicate/unverifiable
373-
// evidence is exercised directly and generically here.
374-
// ---------------------------------------------------------------------------
375-
376-
test('classifyTargetBindingMatch path 2: matchCount 0 is unverifiable (selector-miss)', () => {
377-
const result = classifyTargetBindingMatch({
378-
winnerRef: 'e1',
379-
matchedRefs: [],
380-
identitySetRefs: [],
381-
siblingMatchRefs: [],
382-
regionMemberRefs: undefined,
383-
viewportCandidateRef: undefined,
384-
});
385-
assert.deepEqual(result, { path: 2, outcome: 'unverifiable', reason: 'selector-miss' });
386-
});
387-
388-
test('classifyTargetBindingMatch path 3: empty identity set is unverifiable', () => {
389-
const result = classifyTargetBindingMatch({
390-
winnerRef: 'e1',
391-
matchedRefs: ['e1'],
392-
identitySetRefs: [],
393-
siblingMatchRefs: [],
394-
regionMemberRefs: undefined,
395-
viewportCandidateRef: undefined,
396-
});
397-
assert.deepEqual(result, { path: 3, outcome: 'unverifiable', reason: 'identity-set-empty' });
398-
});
399-
400-
test('classifyTargetBindingMatch path 4: unique identity-set member equal to winner is verified', () => {
401-
const result = classifyTargetBindingMatch({
402-
winnerRef: 'e1',
403-
matchedRefs: ['e1'],
404-
identitySetRefs: ['e1'],
405-
siblingMatchRefs: ['e1'],
406-
regionMemberRefs: undefined,
407-
viewportCandidateRef: undefined,
408-
});
409-
assert.deepEqual(result, { path: 4, outcome: 'verified' });
410-
});
411-
412-
test('classifyTargetBindingMatch path 5: unique identity-set member that is NOT the winner (unique-but-wrong rebind)', () => {
413-
const result = classifyTargetBindingMatch({
414-
winnerRef: 'e1',
415-
matchedRefs: ['e1'],
416-
identitySetRefs: ['e9'],
417-
siblingMatchRefs: [],
418-
regionMemberRefs: undefined,
419-
viewportCandidateRef: undefined,
420-
});
421-
assert.deepEqual(result, { path: 5, outcome: 'unverifiable', reason: 'unique-but-wrong' });
422-
});
423-
424-
test('classifyTargetBindingMatch path 6: duplicate identity resolved by a unique sibling match', () => {
425-
const verified = classifyTargetBindingMatch({
426-
winnerRef: 'e1',
427-
matchedRefs: ['e1', 'e2'],
428-
identitySetRefs: ['e1', 'e2'],
429-
siblingMatchRefs: ['e1'],
430-
regionMemberRefs: undefined,
431-
viewportCandidateRef: undefined,
432-
});
433-
assert.deepEqual(verified, { path: 6, outcome: 'verified' });
434-
});
435-
436-
test('classifyTargetBindingMatch path 6: same child index recurring under different parents falls through sibling, resolved by region-scoped viewportOrder', () => {
437-
const result = classifyTargetBindingMatch({
438-
winnerRef: 'e1',
439-
matchedRefs: ['e1', 'e2'],
440-
identitySetRefs: ['e1', 'e2'],
441-
// Both members are "child 0" under different parents — sibling alone
442-
// does not isolate.
443-
siblingMatchRefs: ['e1', 'e2'],
444-
regionMemberRefs: ['e2', 'e1'],
445-
viewportCandidateRef: 'e1',
446-
});
447-
assert.deepEqual(result, { path: 6, outcome: 'verified' });
448-
});
449-
450-
test('classifyTargetBindingMatch path 6: a scroll region that no longer exists falls through to unverifiable, never a silent pick', () => {
451-
const result = classifyTargetBindingMatch({
452-
winnerRef: 'e1',
453-
matchedRefs: ['e1', 'e2'],
454-
identitySetRefs: ['e1', 'e2'],
455-
siblingMatchRefs: ['e1', 'e2'],
456-
regionMemberRefs: undefined, // region unavailable
457-
viewportCandidateRef: undefined,
458-
});
459-
assert.deepEqual(result, { path: 6, outcome: 'unverifiable', reason: 'no-signal-isolation' });
460-
});
461-
462-
test('classifyTargetBindingMatch path 6: an out-of-range recorded viewportOrder falls through to unverifiable', () => {
463-
const result = classifyTargetBindingMatch({
464-
winnerRef: 'e1',
465-
matchedRefs: ['e1', 'e2'],
466-
identitySetRefs: ['e1', 'e2'],
467-
siblingMatchRefs: ['e1', 'e2'],
468-
regionMemberRefs: ['e1', 'e2'],
469-
viewportCandidateRef: undefined, // ordinal was out of range
470-
});
471-
assert.deepEqual(result, { path: 6, outcome: 'unverifiable', reason: 'no-signal-isolation' });
472-
});
473-
474-
test('classifyTargetBindingMatch path 6: a signal isolating a DIFFERENT node than the winner is the paths-4/5 comparison class, not fall-through', () => {
475-
// Decision 3 path 6.i/6.ii: when a signal denotes exactly one member,
476-
// "compare with W as in paths 4/5" — an isolated-but-different member is
477-
// the same class as path 5's unique-but-wrong rebind (a future
478-
// identity-mismatch), distinct from true no-isolation fall-through (a
479-
// future identity-unverifiable with candidates).
480-
const viaViewportOrder = classifyTargetBindingMatch({
481-
winnerRef: 'e1',
482-
matchedRefs: ['e1', 'e2'],
483-
identitySetRefs: ['e1', 'e2'],
484-
siblingMatchRefs: ['e1', 'e2'],
485-
regionMemberRefs: ['e1', 'e2'],
486-
viewportCandidateRef: 'e2',
487-
});
488-
assert.deepEqual(viaViewportOrder, {
489-
path: 6,
490-
outcome: 'unverifiable',
491-
reason: 'signal-isolated-wrong',
492-
});
493-
494-
const viaSibling = classifyTargetBindingMatch({
495-
winnerRef: 'e1',
496-
matchedRefs: ['e1', 'e2'],
497-
identitySetRefs: ['e1', 'e2'],
498-
siblingMatchRefs: ['e2'], // sibling isolates e2, not the winner
499-
regionMemberRefs: ['e1', 'e2'],
500-
viewportCandidateRef: 'e1',
501-
});
502-
assert.deepEqual(viaSibling, {
503-
path: 6,
504-
outcome: 'unverifiable',
505-
reason: 'signal-isolated-wrong',
506-
});
507-
});

test/integration/interaction-contract/daemon-harness.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const CONTRACT_DEVICE_ID = PROVIDER_SCENARIO_IOS_SIMULATOR.id;
3030
export async function withIosContractDaemon(
3131
entries: readonly ProviderScenarioProviderEntry[],
3232
run: (daemon: ProviderScenarioHarness, transcript: ProviderScenarioTranscript) => Promise<void>,
33+
options: { saveScript?: boolean | string } = {},
3334
): Promise<void> {
3435
const transcript = createProviderTranscript(entries);
3536
const appleRunnerProvider = createAppleRunnerProviderFromTranscript(transcript, 'ios.runner');
@@ -50,6 +51,7 @@ export async function withIosContractDaemon(
5051
const open = await daemon.callCommand('open', [CONTRACT_APP], {
5152
platform: 'ios',
5253
udid: CONTRACT_DEVICE_ID,
54+
...(options.saveScript !== undefined ? { saveScript: options.saveScript } : {}),
5355
});
5456
assertRpcOk(open);
5557
await run(daemon, transcript);

test/integration/interaction-contract/direct-ios-selector.contract.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
runnerSnapshotEntry,
1717
runnerTapEntry,
1818
runnerTapErrorEntry,
19+
runnerTypeEntry,
1920
withIosContractDaemon,
2021
} from './daemon-harness.ts';
2122

@@ -28,6 +29,30 @@ import {
2829
const scenario = (guarantee: InteractionGuarantee): string =>
2930
scenarioName(DIRECT_IOS_SELECTOR_COVERAGE, guarantee);
3031

32+
const RECORDING_TARGET_NODES = [
33+
{ index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } },
34+
{
35+
index: 1,
36+
parentIndex: 0,
37+
type: 'Button',
38+
identifier: 'continue',
39+
label: 'Continue',
40+
rect: { x: 40, y: 160, width: 120, height: 44 },
41+
enabled: true,
42+
hittable: true,
43+
},
44+
{
45+
index: 2,
46+
parentIndex: 0,
47+
type: 'TextField',
48+
identifier: 'email',
49+
label: 'Email',
50+
rect: { x: 40, y: 240, width: 280, height: 44 },
51+
enabled: true,
52+
hittable: true,
53+
},
54+
] as const;
55+
3156
test(scenario('responseConstruction'), async () => {
3257
await withIosContractDaemon([runnerTapEntry({ x: 150, y: 200 })], async (daemon, transcript) => {
3358
const click = await daemon.callCommand('click', ['label=Continue']);
@@ -48,6 +73,49 @@ test(scenario('responseConstruction'), async () => {
4873
});
4974
});
5075

76+
test('recorded simple iOS selector click and fill use runtime resolution and persist target-v1 evidence', async () => {
77+
await withIosContractDaemon(
78+
[
79+
runnerSnapshotEntry(RECORDING_TARGET_NODES),
80+
runnerTapEntry({ x: 100, y: 182 }),
81+
runnerSnapshotEntry(RECORDING_TARGET_NODES),
82+
runnerTypeEntry({ x: 180, y: 262 }),
83+
],
84+
async (daemon, transcript) => {
85+
assertRpcOk(await daemon.callCommand('click', ['id=continue']));
86+
assertRpcOk(await daemon.callCommand('fill', ['id=email', 'ada@example.com']));
87+
88+
assert.equal(transcript.calls[0]?.command, 'ios.runner.snapshot');
89+
assert.equal(transcript.calls[1]?.command, 'ios.runner.tap');
90+
assert.equal(transcript.calls[2]?.command, 'ios.runner.snapshot');
91+
assert.equal(transcript.calls[3]?.command, 'ios.runner.type');
92+
for (const call of [transcript.calls[1], transcript.calls[3]]) {
93+
const request = call?.request as Record<string, unknown> | undefined;
94+
assert.equal(request?.selectorKey, undefined);
95+
}
96+
97+
const actions = daemon
98+
.session()
99+
?.actions.filter((action) => action.command === 'click' || action.command === 'fill');
100+
assert.deepEqual(actions?.map((action) => action.command), ['click', 'fill']);
101+
const targetIdentities = actions?.map((action) => {
102+
const evidence = action.targetEvidence as Record<string, unknown> | undefined;
103+
return {
104+
id: evidence?.id,
105+
role: evidence?.role,
106+
label: evidence?.label,
107+
verification: evidence?.verification,
108+
};
109+
});
110+
assert.deepEqual(targetIdentities, [
111+
{ id: 'continue', role: 'button', label: 'Continue', verification: 'verified' },
112+
{ id: 'email', role: 'textfield', label: 'Email', verification: 'verified' },
113+
]);
114+
},
115+
{ saveScript: true },
116+
);
117+
});
118+
51119
test(scenario('offscreen'), async () => {
52120
await withIosContractDaemon(
53121
[

0 commit comments

Comments
 (0)