Skip to content

Commit 14d731c

Browse files
authored
test: pin selector-port behavior ahead of the P5 extraction (#1478) (#1552)
* test: pin selector-port behavior ahead of the P5 extraction (#1478) Pins, at existing root seams, the eight behavior cells the approved P5 amendment (issue #1478 comment 5156017698) requires the future packages/ad-replay selector port (readSelectorExpression / resolveRecordedTarget / buildSelectorCandidates) to preserve. Test-only — no production code changes. * test: consolidate duplicated cell-5/cell-7 coverage per review Cell 7: relocate #1349's wait-landmark cases from selector-read.test.ts to selector-wait.test.ts (the 1:1 topology location for selector-wait.ts), replacing the weaker duplicate cell-7 cases added in the prior commit. The relocated tests keep the stronger assertions (real computeTargetEvidence- derived evidence, an initial no-match poll, observed-ancestry checks, and the plain-timeout-vs-landmark-mismatch distinction). Cell 5: the first case overlapped an existing later-alternative regression in session-replay-target-classification.test.ts. Sharpened it (rather than dropping it, since it is the only counterfactual-sensitive case for the allowDisambiguation=false skip path) to isolate the branch the existing regression's exact-tie fixture cannot reach, and paired it explicitly with the second case as a same-fixture, flag-flipped contrast.
1 parent 4fd0441 commit 14d731c

6 files changed

Lines changed: 642 additions & 135 deletions

File tree

src/commands/interaction/runtime/selector-read.test.ts

Lines changed: 6 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@ import {
1515
createSelectorDevice,
1616
selectorReadSnapshot,
1717
} from './__tests__/test-utils/index.ts';
18-
import { computeTargetEvidence } from '../../../daemon/session-target-evidence.ts';
19-
import { WAIT_LANDMARK_MISMATCH_REASON } from '../../../replay/target-identity-node.ts';
2018
import { AppError } from '@agent-device/kernel/errors';
2119

2220
test('runtime get reads text from a selector target', async () => {
@@ -498,10 +496,12 @@ test('runtime selector convenience methods use explicit target helpers', async (
498496
});
499497

500498
// ---------------------------------------------------------------------------
501-
// #1349: wait's in-loop landmark identity verification (replay-only,
502-
// threaded as `target.recordedLandmark`). Polling semantics are preserved —
503-
// a same-selector impostor never aborts the wait; only the deadline turns
504-
// rejected candidates into the fail-closed landmark refusal.
499+
// Wait polls ride out captures that judged the screen unreadable (the
500+
// mid-transition Android helper content verdicts) instead of aborting the
501+
// wait — the live-validated destination-guard gap from #1349's PR review.
502+
// (#1349's own in-loop landmark identity verification tests — the
503+
// `target.recordedLandmark` cases — moved to `selector-wait.test.ts`, the
504+
// 1:1 topology location for `selector-wait.ts`; #1478 P5 step 2 cell 7.)
505505
// ---------------------------------------------------------------------------
506506

507507
function landmarkScreen(parentLabel: string) {
@@ -518,134 +518,6 @@ function landmarkScreen(parentLabel: string) {
518518
]);
519519
}
520520

521-
function recordedLandmarkFor(snapshot: ReturnType<typeof landmarkScreen>) {
522-
const node = snapshot.nodes[1]!;
523-
const evidence = computeTargetEvidence(
524-
{ node, preActionNodes: snapshot.nodes },
525-
{ mode: 'landmark' },
526-
);
527-
assert.ok(evidence);
528-
assert.equal(evidence.verification, 'verified');
529-
return evidence;
530-
}
531-
532-
function landmarkWaitDevice(captures: Array<ReturnType<typeof landmarkScreen>>) {
533-
let call = 0;
534-
const initial = captures[0]!;
535-
const device = createAgentDevice({
536-
backend: {
537-
platform: 'ios',
538-
captureSnapshot: async () => {
539-
const snapshot = captures[Math.min(call, captures.length - 1)]!;
540-
call += 1;
541-
return { snapshot };
542-
},
543-
} satisfies AgentDeviceBackend,
544-
artifacts: createLocalArtifactAdapter(),
545-
sessions: createMemorySessionStore([{ name: 'default', snapshot: initial }]),
546-
policy: localCommandPolicy(),
547-
clock: createFakeClock(),
548-
});
549-
return device;
550-
}
551-
552-
test('runtime wait keeps polling past a same-selector impostor and succeeds on the recorded landmark', async () => {
553-
const recordTime = landmarkScreen('Detail Screen');
554-
const recorded = recordedLandmarkFor(recordTime);
555-
const impostor = landmarkScreen('List Screen');
556-
const empty = makeSnapshotState([{ index: 0, depth: 0, type: 'Other', label: 'Loading' }]);
557-
const device = landmarkWaitDevice([empty, impostor, landmarkScreen('Detail Screen')]);
558-
559-
const result = await device.selectors.wait({
560-
session: 'default',
561-
target: {
562-
kind: 'selector',
563-
selector: 'label="Screen X"',
564-
timeoutMs: 10_000,
565-
recordedLandmark: recorded,
566-
},
567-
});
568-
569-
assert.equal(result.kind, 'selector');
570-
if (result.kind !== 'selector') throw new Error('unreachable');
571-
// Two rejected polls (absent, then impostor) before the landmark appeared.
572-
assert.equal(result.waitedMs >= 600, true);
573-
assert.equal(result.node?.label, 'Screen X');
574-
assert.equal(result.preActionNodes?.length, 2);
575-
});
576-
577-
test('runtime wait fails closed at the deadline when only impostors matched the selector', async () => {
578-
const recorded = recordedLandmarkFor(landmarkScreen('Detail Screen'));
579-
const device = landmarkWaitDevice([landmarkScreen('List Screen')]);
580-
581-
const error = await device.selectors
582-
.wait({
583-
session: 'default',
584-
target: {
585-
kind: 'selector',
586-
selector: 'label="Screen X"',
587-
timeoutMs: 1000,
588-
recordedLandmark: recorded,
589-
},
590-
})
591-
.then(
592-
() => undefined,
593-
(thrown: unknown) => thrown,
594-
);
595-
596-
assert.ok(error instanceof AppError);
597-
assert.equal(error.details?.reason, WAIT_LANDMARK_MISMATCH_REASON);
598-
assert.equal(error.details?.matchCount, 1);
599-
const observed = error.details?.observed as { role: string; label?: string };
600-
assert.equal(observed.label, 'Screen X');
601-
const ancestry = error.details?.observedAncestry as Array<{ role: string; label?: string }>;
602-
assert.equal(ancestry[0]?.label, 'List Screen');
603-
});
604-
605-
test('runtime wait with a recorded landmark keeps the plain timeout when the selector never matched', async () => {
606-
const recorded = recordedLandmarkFor(landmarkScreen('Detail Screen'));
607-
const empty = makeSnapshotState([{ index: 0, depth: 0, type: 'Other', label: 'Loading' }]);
608-
const device = landmarkWaitDevice([empty]);
609-
610-
await assert.rejects(
611-
device.selectors.wait({
612-
session: 'default',
613-
target: {
614-
kind: 'selector',
615-
selector: 'label="Screen X"',
616-
timeoutMs: 1000,
617-
recordedLandmark: recorded,
618-
},
619-
}),
620-
(thrown: unknown) => {
621-
assert.ok(thrown instanceof AppError);
622-
assert.match(thrown.message, /wait timed out for selector/);
623-
assert.equal(thrown.details?.reason, undefined);
624-
return true;
625-
},
626-
);
627-
});
628-
629-
test('runtime wait without a recorded landmark returns the satisfying match for record-time evidence', async () => {
630-
const device = landmarkWaitDevice([landmarkScreen('Detail Screen')]);
631-
632-
const result = await device.selectors.wait({
633-
session: 'default',
634-
target: { kind: 'selector', selector: 'label="Screen X"', timeoutMs: 1000 },
635-
});
636-
637-
assert.equal(result.kind, 'selector');
638-
if (result.kind !== 'selector') throw new Error('unreachable');
639-
assert.equal(result.node?.label, 'Screen X');
640-
assert.equal(result.preActionNodes?.length, 2);
641-
});
642-
643-
// ---------------------------------------------------------------------------
644-
// Wait polls ride out captures that judged the screen unreadable (the
645-
// mid-transition Android helper content verdicts) instead of aborting the
646-
// wait — the live-validated destination-guard gap from #1349's PR review.
647-
// ---------------------------------------------------------------------------
648-
649521
function unreadableCaptureError() {
650522
return new AppError(
651523
'COMMAND_FAILED',

src/commands/interaction/runtime/selector-wait.test.ts

Lines changed: 156 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@ import {
88
localCommandPolicy,
99
} from '../../../runtime.ts';
1010
import { makeSnapshotState } from '../../../__tests__/test-utils/index.ts';
11-
import { createSelectorDevice, selectorReadSnapshot } from './__tests__/test-utils/index.ts';
11+
import {
12+
createFakeClock,
13+
createSelectorDevice,
14+
selectorReadSnapshot,
15+
} from './__tests__/test-utils/index.ts';
16+
import { computeTargetEvidence } from '../../../daemon/session-target-evidence.ts';
17+
import { WAIT_LANDMARK_MISMATCH_REASON } from '../../../replay/target-identity-node.ts';
18+
import { AppError } from '@agent-device/kernel/errors';
1219

1320
test('runtime focused selector waits against a full snapshot', async () => {
1421
const snapshot = makeSnapshotState([
@@ -58,3 +65,151 @@ test('runtime wait can use backend text search', async () => {
5865

5966
assert.deepEqual(result, { kind: 'text', text: 'Ready', waitedMs: 0 });
6067
});
68+
69+
// ---------------------------------------------------------------------------
70+
// #1349 (relocated from `selector-read.test.ts` — this is the 1:1 topology
71+
// location for `selector-wait.ts`, and #1478 P5 step 2 cell 7's pin):
72+
// wait's in-loop landmark identity verification, threaded as
73+
// `target.recordedLandmark`. Polling semantics are preserved — a
74+
// same-selector impostor never aborts the wait; only the deadline turns
75+
// rejected candidates into the fail-closed landmark refusal
76+
// (`WAIT_LANDMARK_MISMATCH_REASON`), and a plain "the selector never matched
77+
// at all" timeout stays undifferentiated. This is the root seam the future
78+
// `resolveRecordedTarget` port operation must preserve.
79+
// ---------------------------------------------------------------------------
80+
81+
function landmarkScreen(parentLabel: string) {
82+
return makeSnapshotState([
83+
{ index: 0, depth: 0, type: 'Other', label: parentLabel },
84+
{
85+
index: 1,
86+
depth: 1,
87+
parentIndex: 0,
88+
type: 'StaticText',
89+
label: 'Screen X',
90+
rect: { x: 0, y: 0, width: 100, height: 20 },
91+
},
92+
]);
93+
}
94+
95+
function recordedLandmarkFor(snapshot: ReturnType<typeof landmarkScreen>) {
96+
const node = snapshot.nodes[1]!;
97+
const evidence = computeTargetEvidence(
98+
{ node, preActionNodes: snapshot.nodes },
99+
{ mode: 'landmark' },
100+
);
101+
assert.ok(evidence);
102+
assert.equal(evidence.verification, 'verified');
103+
return evidence;
104+
}
105+
106+
function landmarkWaitDevice(captures: Array<ReturnType<typeof landmarkScreen>>) {
107+
let call = 0;
108+
const initial = captures[0]!;
109+
const device = createAgentDevice({
110+
backend: {
111+
platform: 'ios',
112+
captureSnapshot: async () => {
113+
const snapshot = captures[Math.min(call, captures.length - 1)]!;
114+
call += 1;
115+
return { snapshot };
116+
},
117+
} satisfies AgentDeviceBackend,
118+
artifacts: createLocalArtifactAdapter(),
119+
sessions: createMemorySessionStore([{ name: 'default', snapshot: initial }]),
120+
policy: localCommandPolicy(),
121+
clock: createFakeClock(),
122+
});
123+
return device;
124+
}
125+
126+
test('runtime wait keeps polling past a same-selector impostor and succeeds on the recorded landmark', async () => {
127+
const recordTime = landmarkScreen('Detail Screen');
128+
const recorded = recordedLandmarkFor(recordTime);
129+
const impostor = landmarkScreen('List Screen');
130+
const empty = makeSnapshotState([{ index: 0, depth: 0, type: 'Other', label: 'Loading' }]);
131+
const device = landmarkWaitDevice([empty, impostor, landmarkScreen('Detail Screen')]);
132+
133+
const result = await device.selectors.wait({
134+
session: 'default',
135+
target: {
136+
kind: 'selector',
137+
selector: 'label="Screen X"',
138+
timeoutMs: 10_000,
139+
recordedLandmark: recorded,
140+
},
141+
});
142+
143+
assert.equal(result.kind, 'selector');
144+
if (result.kind !== 'selector') throw new Error('unreachable');
145+
// Two rejected polls (absent, then impostor) before the landmark appeared.
146+
assert.equal(result.waitedMs >= 600, true);
147+
assert.equal(result.node?.label, 'Screen X');
148+
assert.equal(result.preActionNodes?.length, 2);
149+
});
150+
151+
test('runtime wait fails closed at the deadline when only impostors matched the selector', async () => {
152+
const recorded = recordedLandmarkFor(landmarkScreen('Detail Screen'));
153+
const device = landmarkWaitDevice([landmarkScreen('List Screen')]);
154+
155+
const error = await device.selectors
156+
.wait({
157+
session: 'default',
158+
target: {
159+
kind: 'selector',
160+
selector: 'label="Screen X"',
161+
timeoutMs: 1000,
162+
recordedLandmark: recorded,
163+
},
164+
})
165+
.then(
166+
() => undefined,
167+
(thrown: unknown) => thrown,
168+
);
169+
170+
assert.ok(error instanceof AppError);
171+
assert.equal(error.details?.reason, WAIT_LANDMARK_MISMATCH_REASON);
172+
assert.equal(error.details?.matchCount, 1);
173+
const observed = error.details?.observed as { role: string; label?: string };
174+
assert.equal(observed.label, 'Screen X');
175+
const ancestry = error.details?.observedAncestry as Array<{ role: string; label?: string }>;
176+
assert.equal(ancestry[0]?.label, 'List Screen');
177+
});
178+
179+
test('runtime wait with a recorded landmark keeps the plain timeout when the selector never matched', async () => {
180+
const recorded = recordedLandmarkFor(landmarkScreen('Detail Screen'));
181+
const empty = makeSnapshotState([{ index: 0, depth: 0, type: 'Other', label: 'Loading' }]);
182+
const device = landmarkWaitDevice([empty]);
183+
184+
await assert.rejects(
185+
device.selectors.wait({
186+
session: 'default',
187+
target: {
188+
kind: 'selector',
189+
selector: 'label="Screen X"',
190+
timeoutMs: 1000,
191+
recordedLandmark: recorded,
192+
},
193+
}),
194+
(thrown: unknown) => {
195+
assert.ok(thrown instanceof AppError);
196+
assert.match(thrown.message, /wait timed out for selector/);
197+
assert.equal(thrown.details?.reason, undefined);
198+
return true;
199+
},
200+
);
201+
});
202+
203+
test('runtime wait without a recorded landmark returns the satisfying match for record-time evidence', async () => {
204+
const device = landmarkWaitDevice([landmarkScreen('Detail Screen')]);
205+
206+
const result = await device.selectors.wait({
207+
session: 'default',
208+
target: { kind: 'selector', selector: 'label="Screen X"', timeoutMs: 1000 },
209+
});
210+
211+
assert.equal(result.kind, 'selector');
212+
if (result.kind !== 'selector') throw new Error('unreachable');
213+
assert.equal(result.node?.label, 'Screen X');
214+
assert.equal(result.preActionNodes?.length, 2);
215+
});

0 commit comments

Comments
 (0)