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
140 changes: 6 additions & 134 deletions src/commands/interaction/runtime/selector-read.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ import {
createSelectorDevice,
selectorReadSnapshot,
} from './__tests__/test-utils/index.ts';
import { computeTargetEvidence } from '../../../daemon/session-target-evidence.ts';
import { WAIT_LANDMARK_MISMATCH_REASON } from '../../../replay/target-identity-node.ts';
import { AppError } from '@agent-device/kernel/errors';

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

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

function landmarkScreen(parentLabel: string) {
Expand All @@ -518,134 +518,6 @@ function landmarkScreen(parentLabel: string) {
]);
}

function recordedLandmarkFor(snapshot: ReturnType<typeof landmarkScreen>) {
const node = snapshot.nodes[1]!;
const evidence = computeTargetEvidence(
{ node, preActionNodes: snapshot.nodes },
{ mode: 'landmark' },
);
assert.ok(evidence);
assert.equal(evidence.verification, 'verified');
return evidence;
}

function landmarkWaitDevice(captures: Array<ReturnType<typeof landmarkScreen>>) {
let call = 0;
const initial = captures[0]!;
const device = createAgentDevice({
backend: {
platform: 'ios',
captureSnapshot: async () => {
const snapshot = captures[Math.min(call, captures.length - 1)]!;
call += 1;
return { snapshot };
},
} satisfies AgentDeviceBackend,
artifacts: createLocalArtifactAdapter(),
sessions: createMemorySessionStore([{ name: 'default', snapshot: initial }]),
policy: localCommandPolicy(),
clock: createFakeClock(),
});
return device;
}

test('runtime wait keeps polling past a same-selector impostor and succeeds on the recorded landmark', async () => {
const recordTime = landmarkScreen('Detail Screen');
const recorded = recordedLandmarkFor(recordTime);
const impostor = landmarkScreen('List Screen');
const empty = makeSnapshotState([{ index: 0, depth: 0, type: 'Other', label: 'Loading' }]);
const device = landmarkWaitDevice([empty, impostor, landmarkScreen('Detail Screen')]);

const result = await device.selectors.wait({
session: 'default',
target: {
kind: 'selector',
selector: 'label="Screen X"',
timeoutMs: 10_000,
recordedLandmark: recorded,
},
});

assert.equal(result.kind, 'selector');
if (result.kind !== 'selector') throw new Error('unreachable');
// Two rejected polls (absent, then impostor) before the landmark appeared.
assert.equal(result.waitedMs >= 600, true);
assert.equal(result.node?.label, 'Screen X');
assert.equal(result.preActionNodes?.length, 2);
});

test('runtime wait fails closed at the deadline when only impostors matched the selector', async () => {
const recorded = recordedLandmarkFor(landmarkScreen('Detail Screen'));
const device = landmarkWaitDevice([landmarkScreen('List Screen')]);

const error = await device.selectors
.wait({
session: 'default',
target: {
kind: 'selector',
selector: 'label="Screen X"',
timeoutMs: 1000,
recordedLandmark: recorded,
},
})
.then(
() => undefined,
(thrown: unknown) => thrown,
);

assert.ok(error instanceof AppError);
assert.equal(error.details?.reason, WAIT_LANDMARK_MISMATCH_REASON);
assert.equal(error.details?.matchCount, 1);
const observed = error.details?.observed as { role: string; label?: string };
assert.equal(observed.label, 'Screen X');
const ancestry = error.details?.observedAncestry as Array<{ role: string; label?: string }>;
assert.equal(ancestry[0]?.label, 'List Screen');
});

test('runtime wait with a recorded landmark keeps the plain timeout when the selector never matched', async () => {
const recorded = recordedLandmarkFor(landmarkScreen('Detail Screen'));
const empty = makeSnapshotState([{ index: 0, depth: 0, type: 'Other', label: 'Loading' }]);
const device = landmarkWaitDevice([empty]);

await assert.rejects(
device.selectors.wait({
session: 'default',
target: {
kind: 'selector',
selector: 'label="Screen X"',
timeoutMs: 1000,
recordedLandmark: recorded,
},
}),
(thrown: unknown) => {
assert.ok(thrown instanceof AppError);
assert.match(thrown.message, /wait timed out for selector/);
assert.equal(thrown.details?.reason, undefined);
return true;
},
);
});

test('runtime wait without a recorded landmark returns the satisfying match for record-time evidence', async () => {
const device = landmarkWaitDevice([landmarkScreen('Detail Screen')]);

const result = await device.selectors.wait({
session: 'default',
target: { kind: 'selector', selector: 'label="Screen X"', timeoutMs: 1000 },
});

assert.equal(result.kind, 'selector');
if (result.kind !== 'selector') throw new Error('unreachable');
assert.equal(result.node?.label, 'Screen X');
assert.equal(result.preActionNodes?.length, 2);
});

// ---------------------------------------------------------------------------
// Wait polls ride out captures that judged the screen unreadable (the
// mid-transition Android helper content verdicts) instead of aborting the
// wait — the live-validated destination-guard gap from #1349's PR review.
// ---------------------------------------------------------------------------

function unreadableCaptureError() {
return new AppError(
'COMMAND_FAILED',
Expand Down
157 changes: 156 additions & 1 deletion src/commands/interaction/runtime/selector-wait.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@ import {
localCommandPolicy,
} from '../../../runtime.ts';
import { makeSnapshotState } from '../../../__tests__/test-utils/index.ts';
import { createSelectorDevice, selectorReadSnapshot } from './__tests__/test-utils/index.ts';
import {
createFakeClock,
createSelectorDevice,
selectorReadSnapshot,
} from './__tests__/test-utils/index.ts';
import { computeTargetEvidence } from '../../../daemon/session-target-evidence.ts';
import { WAIT_LANDMARK_MISMATCH_REASON } from '../../../replay/target-identity-node.ts';
import { AppError } from '@agent-device/kernel/errors';

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

assert.deepEqual(result, { kind: 'text', text: 'Ready', waitedMs: 0 });
});

// ---------------------------------------------------------------------------
// #1349 (relocated from `selector-read.test.ts` — this is the 1:1 topology
// location for `selector-wait.ts`, and #1478 P5 step 2 cell 7's pin):
// wait's in-loop landmark identity verification, threaded as
// `target.recordedLandmark`. Polling semantics are preserved — a
// same-selector impostor never aborts the wait; only the deadline turns
// rejected candidates into the fail-closed landmark refusal
// (`WAIT_LANDMARK_MISMATCH_REASON`), and a plain "the selector never matched
// at all" timeout stays undifferentiated. This is the root seam the future
// `resolveRecordedTarget` port operation must preserve.
// ---------------------------------------------------------------------------

function landmarkScreen(parentLabel: string) {
return makeSnapshotState([
{ index: 0, depth: 0, type: 'Other', label: parentLabel },
{
index: 1,
depth: 1,
parentIndex: 0,
type: 'StaticText',
label: 'Screen X',
rect: { x: 0, y: 0, width: 100, height: 20 },
},
]);
}

function recordedLandmarkFor(snapshot: ReturnType<typeof landmarkScreen>) {
const node = snapshot.nodes[1]!;
const evidence = computeTargetEvidence(
{ node, preActionNodes: snapshot.nodes },
{ mode: 'landmark' },
);
assert.ok(evidence);
assert.equal(evidence.verification, 'verified');
return evidence;
}

function landmarkWaitDevice(captures: Array<ReturnType<typeof landmarkScreen>>) {
let call = 0;
const initial = captures[0]!;
const device = createAgentDevice({
backend: {
platform: 'ios',
captureSnapshot: async () => {
const snapshot = captures[Math.min(call, captures.length - 1)]!;
call += 1;
return { snapshot };
},
} satisfies AgentDeviceBackend,
artifacts: createLocalArtifactAdapter(),
sessions: createMemorySessionStore([{ name: 'default', snapshot: initial }]),
policy: localCommandPolicy(),
clock: createFakeClock(),
});
return device;
}

test('runtime wait keeps polling past a same-selector impostor and succeeds on the recorded landmark', async () => {
const recordTime = landmarkScreen('Detail Screen');
const recorded = recordedLandmarkFor(recordTime);
const impostor = landmarkScreen('List Screen');
const empty = makeSnapshotState([{ index: 0, depth: 0, type: 'Other', label: 'Loading' }]);
const device = landmarkWaitDevice([empty, impostor, landmarkScreen('Detail Screen')]);

const result = await device.selectors.wait({
session: 'default',
target: {
kind: 'selector',
selector: 'label="Screen X"',
timeoutMs: 10_000,
recordedLandmark: recorded,
},
});

assert.equal(result.kind, 'selector');
if (result.kind !== 'selector') throw new Error('unreachable');
// Two rejected polls (absent, then impostor) before the landmark appeared.
assert.equal(result.waitedMs >= 600, true);
assert.equal(result.node?.label, 'Screen X');
assert.equal(result.preActionNodes?.length, 2);
});

test('runtime wait fails closed at the deadline when only impostors matched the selector', async () => {
const recorded = recordedLandmarkFor(landmarkScreen('Detail Screen'));
const device = landmarkWaitDevice([landmarkScreen('List Screen')]);

const error = await device.selectors
.wait({
session: 'default',
target: {
kind: 'selector',
selector: 'label="Screen X"',
timeoutMs: 1000,
recordedLandmark: recorded,
},
})
.then(
() => undefined,
(thrown: unknown) => thrown,
);

assert.ok(error instanceof AppError);
assert.equal(error.details?.reason, WAIT_LANDMARK_MISMATCH_REASON);
assert.equal(error.details?.matchCount, 1);
const observed = error.details?.observed as { role: string; label?: string };
assert.equal(observed.label, 'Screen X');
const ancestry = error.details?.observedAncestry as Array<{ role: string; label?: string }>;
assert.equal(ancestry[0]?.label, 'List Screen');
});

test('runtime wait with a recorded landmark keeps the plain timeout when the selector never matched', async () => {
const recorded = recordedLandmarkFor(landmarkScreen('Detail Screen'));
const empty = makeSnapshotState([{ index: 0, depth: 0, type: 'Other', label: 'Loading' }]);
const device = landmarkWaitDevice([empty]);

await assert.rejects(
device.selectors.wait({
session: 'default',
target: {
kind: 'selector',
selector: 'label="Screen X"',
timeoutMs: 1000,
recordedLandmark: recorded,
},
}),
(thrown: unknown) => {
assert.ok(thrown instanceof AppError);
assert.match(thrown.message, /wait timed out for selector/);
assert.equal(thrown.details?.reason, undefined);
return true;
},
);
});

test('runtime wait without a recorded landmark returns the satisfying match for record-time evidence', async () => {
const device = landmarkWaitDevice([landmarkScreen('Detail Screen')]);

const result = await device.selectors.wait({
session: 'default',
target: { kind: 'selector', selector: 'label="Screen X"', timeoutMs: 1000 },
});

assert.equal(result.kind, 'selector');
if (result.kind !== 'selector') throw new Error('unreachable');
assert.equal(result.node?.label, 'Screen X');
assert.equal(result.preActionNodes?.length, 2);
});
Loading
Loading