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
165 changes: 165 additions & 0 deletions src/daemon/__tests__/interaction-surface-baseline-evidence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import type { SnapshotNode } from '@agent-device/kernel/snapshot';
import {
buildInteractionSurfaceSignature,
classifyBaselineSurfaceEvidence,
} from '../interaction-outcome-policy.ts';

// #1569: node shapes below are transcribed from a live iPhone 17 Pro capture of
// examples/test-app's checkout form, before and after `scroll down 0.6`. Two
// properties of that real screen are what these tests exist to hold:
//
// - the scroll REPLACED the content: nothing from the form section survives
// into the post capture, and the only identifiers common to both are the
// tab-bar entries, which by construction never move;
// - the captures disagree about which anonymous layout containers exist (the
// live pair had 8 and 10 of them), so any rule that matches those by
// ordinal position is reading noise.

const tabBar = (): SnapshotNode[] =>
[
{ label: 'Home', type: 'Other', rect: { x: 21, y: 791, width: 360, height: 62 } },
{ label: 'Home', type: 'Button', rect: { x: 25, y: 795, width: 74, height: 54 } },
{
identifier: 'house.fill',
label: 'home',
type: 'Image',
rect: { x: 45, y: 801, width: 33, height: 28 },
},
] as SnapshotNode[];

const anonymousContainers = (count: number, baseY: number): SnapshotNode[] =>
Array.from({ length: count }, (_, index) => ({
type: 'Other',
rect: { x: 0, y: baseY + index * 40, width: 402, height: 120 },
})) as SnapshotNode[];

const formSection = (): SnapshotNode[] =>
[
{ label: 'Full name', type: 'StaticText', rect: { x: 34, y: 319, width: 333, height: 17 } },
{
identifier: 'field-name',
label: 'Full name',
type: 'TextField',
rect: { x: 35, y: 344, width: 333, height: 51 },
},
{ label: 'Email', type: 'StaticText', rect: { x: 34, y: 410, width: 333, height: 17 } },
{
identifier: 'field-email',
label: 'Email',
type: 'TextField',
rect: { x: 35, y: 435, width: 333, height: 51 },
},
] as SnapshotNode[];

const deliverySection = (): SnapshotNode[] =>
[
{
identifier: 'shipping-pickup',
label: 'Pickup',
type: 'Button',
rect: { x: 126, y: 137, width: 75, height: 38 },
},
{
identifier: 'checkbox-agree',
label: 'I confirm the order details',
type: 'Other',
rect: { x: 34, y: 543, width: 333, height: 24 },
},
] as SnapshotNode[];

const classify = (before: SnapshotNode[], after: SnapshotNode[]) =>
classifyBaselineSurfaceEvidence(
buildInteractionSurfaceSignature(before),
buildInteractionSurfaceSignature(after),
);

test('a scroll that replaces the content is changed, though every shared element sat still', () => {
// The live shape of #1569. Shared identifiers: only the tab bar, all at dy=0.
// Asking those whether the screen moved is asking the one part of the surface
// guaranteed not to.
assert.equal(
classify(
[...formSection(), ...anonymousContainers(8, 60), ...tabBar()],
[...deliverySection(), ...anonymousContainers(10, 40), ...tabBar()],
),
'changed',
);
});

test('the verdict does not depend on which anonymous containers a capture happened to include', () => {
// The defect: the previous rule matched anonymous nodes by ordinal position,
// so its entire movement evidence came from containers that alias. Strip them
// and it reported 'unchanged' for the very same pair of screens. Every
// composition of the same two screens must agree.
const compositions: [string, SnapshotNode[], SnapshotNode[]][] = [
['no containers', [...formSection(), ...tabBar()], [...deliverySection(), ...tabBar()]],
[
'matched container counts',
[...formSection(), ...anonymousContainers(8, 60), ...tabBar()],
[...deliverySection(), ...anonymousContainers(8, 60), ...tabBar()],
],
[
'mismatched container counts',
[...formSection(), ...anonymousContainers(8, 60), ...tabBar()],
[...deliverySection(), ...anonymousContainers(10, 40), ...tabBar()],
],
];

for (const [label, before, after] of compositions) {
assert.equal(classify(before, after), 'changed', label);
}
});

test('an inert gesture stays unchanged while the anonymous container count fluctuates', () => {
// The live pair carried 8 anonymous containers on one side and 10 on the
// other for the SAME screen. Admitting them to the comparison makes that
// fluctuation look like content both leaving and arriving — a false 'changed'
// on a screen where nothing happened, which would settle a genuinely stale
// capture on the strength of layout noise.
assert.equal(
classify(
[...formSection(), ...anonymousContainers(8, 60), ...tabBar()],
[...formSection(), ...anonymousContainers(10, 40), ...tabBar()],
),
'unchanged',
);
});

test('an inert gesture is unchanged even when volatile hittability flipped', () => {
// `hittable` is viewport-derived, so it flips without the element moving.
// Keying identity on it would drop this element from the comparison entirely.
const before = [
...formSection().map((node) => ({ ...node, hittable: true })),
...tabBar(),
] as SnapshotNode[];
const after = [
...formSection().map((node) => ({ ...node, hittable: false })),
...tabBar(),
] as SnapshotNode[];

assert.equal(classify(before, after), 'unchanged');
});

test('a narrower capture of the same screen is scope drift, not movement', () => {
// Baseline from a broad text search, quiet capture interactive-only: the
// second is a strict subset. Nothing arrived, so nothing was replaced.
assert.equal(classify([...formSection(), ...tabBar()], [...formSection()]), 'unchanged');
assert.equal(classify([...formSection()], [...formSection(), ...tabBar()]), 'unchanged');
});

test('movement of a surviving element is still changed', () => {
const moved = formSection().map((node) => ({
...node,
rect: { ...node.rect!, y: node.rect!.y - 120 },
})) as SnapshotNode[];

assert.equal(classify([...formSection(), ...tabBar()], [...moved, ...tabBar()]), 'changed');
});

test('sharing only the tab bar is ambiguous once the content sets are disjoint on one side', () => {
// Chrome alone carries no evidence: it reads identically whatever happened.
assert.equal(classify(tabBar(), tabBar()), 'unchanged');
assert.equal(classify([...formSection()], [...deliverySection()]), 'ambiguous');
});
138 changes: 132 additions & 6 deletions src/daemon/__tests__/post-gesture-stabilization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,12 +158,18 @@ test('capturePostGestureStabilizedResult keeps polling past the normal deadline
assert.ok(captureCount > 8, `expected sustained polling, saw ${captureCount} captures`);
});

test('a replaced list under fixed chrome accepts stale but never claims no-effect (#1601 P1)', async () => {
// The reviewer's counterexample: a SUCCESSFUL scroll swapped every list
// cell while the tab-bar chrome (discriminating, shared, unmoved) kept the
// subset-tolerant classifier at 'unchanged'. The loop may still accept the
// stale read — but the agent-facing no-effect claim must be vetoed by the
// unmatched discriminating cells on both sides.
test('a replaced list under fixed chrome now settles outright, and still claims no no-effect (#1601 P1, #1569)', async () => {
// The reviewer's counterexample: a SUCCESSFUL scroll swapped every list cell
// while the tab-bar chrome (discriminating, shared, unmoved) kept the
// classifier at 'unchanged'. #1601 could only veto the agent-facing claim and
// had to let the loop accept the stale read — `staleAccepts` was 1 here.
//
// #1569 removed the premise: the classifier compares identified content by
// set membership, so cells leaving AND arriving is 'changed'. The loop now
// trusts the capture instead of polling to the cap, which is what made every
// checkout-form scroll pay the full distrust budget on live hardware. The
// no-effect claim stays absent — now because there is no accept-stale to
// corroborate at all, which is the stronger reason.
vi.useFakeTimers();
const session = makeSession('ios');
session.snapshot = chromeWithListSnapshot(['row-1', 'row-2']);
Expand All @@ -186,6 +192,45 @@ test('a replaced list under fixed chrome accepts stale but never claims no-effec
await vi.advanceTimersByTimeAsync(10_000);
const { result, staleAccepts } = await resultPromise;

assert.equal(staleAccepts, 0);
assert.equal(result.gestureNoEffect, undefined);
});

test('scope drift accepts stale but is vetoed from claiming no-effect (#1601 P1 gate)', async () => {
// #1601's full-surface gate stays load-bearing, and #1569 makes it more so.
// The classifier treats a one-sided difference as scope drift rather than
// movement, so a narrower quiet capture of an unmoved screen still reaches
// accept-stale — and the agent-facing claim must not follow it there, because
// the missing rows are unexamined, not proven absent.
vi.useFakeTimers();
const session = makeSession('ios');
session.snapshot = chromeWithListSnapshot(['row-1', 'row-2']);
markPostGestureStabilization(session, 'scroll');

// Same screen, but the quiet captures see only the chrome — the shape a
// broad baseline followed by an interactive-only capture produces.
const narrowed = makeSnapshotState(
chromeWithListSnapshot(['row-1', 'row-2']).nodes.filter(
(node) => node.type !== 'Cell',
) as never,
);
const capture = vi.fn(async () => narrowed);

const resultPromise = withDiagnosticsScope({}, async () => {
const result = await capturePostGestureStabilizedResult({
session,
capture,
readSnapshot: (snapshot) => snapshot,
});
return {
result,
staleAccepts: countDiagnosticEventsByPhase(['post_gesture_snapshot_stale_accept']),
};
});

await vi.advanceTimersByTimeAsync(10_000);
const { result, staleAccepts } = await resultPromise;

assert.equal(staleAccepts, 1);
assert.equal(result.gestureNoEffect, undefined);
});
Expand Down Expand Up @@ -431,3 +476,84 @@ test('capturePostGestureStabilizedResult trusts immediately (no cap tax) when th
assert.equal(staleAccepts, 0);
assert.equal(capture.mock.calls.length, 2);
});

// --- #1569: a backend swap mid-poll is not comparable evidence ---

test('capturePostGestureStabilizedResult re-baselines instead of concluding when the backend changes (iOS)', async () => {
// The capture plan can fall back, or the XCTest-channel penalty can pre-empt
// it, at any point during the poll. The backends do not agree on which nodes
// exist — on one live checkout screen private AX returned 139 nodes including
// 43 scrolled off-viewport where the tree backend returned 48 — so a quiet
// capture from a different backend says nothing about the gesture. It must
// become the new baseline, never a verdict.
vi.useFakeTimers();
const session = makeSession('ios');
session.snapshot = makeSnapshotState(pickupSnapshot(500).nodes, {
snapshotQuality: { state: 'healthy', backend: 'tree' },
});
markPostGestureStabilization(session, 'scroll');

// Every capture shows the pre-gesture surface, but on the OTHER backend.
const capture = vi.fn(async () =>
makeSnapshotState(pickupSnapshot(500).nodes, {
snapshotQuality: { state: 'healthy', backend: 'private-ax' },
}),
);

const resultPromise = withDiagnosticsScope({}, async () => {
await capturePostGestureStabilizedResult({
session,
capture,
readSnapshot: (snapshot) => snapshot,
});
return {
rebased: countDiagnosticEventsByPhase(['post_gesture_snapshot_baseline_rebased']),
staleAccepts: countDiagnosticEventsByPhase(['post_gesture_snapshot_stale_accept']),
settled: countDiagnosticEventsByPhase(['post_gesture_snapshot_stabilized']),
};
});

await vi.advanceTimersByTimeAsync(6_000);
const { rebased, staleAccepts, settled } = await resultPromise;

// Rebased once onto private-ax, then compared same-backend and settled. A
// cross-backend comparison would have produced a verdict from incomparable
// node sets instead.
assert.equal(rebased, 1);
assert.equal(staleAccepts + settled, 1);
});

test('capturePostGestureStabilizedResult still distrusts a same-backend baseline match (iOS)', async () => {
// The guard above must not become a blanket escape hatch: when the backend is
// stable, an unchanged surface is still the stale-read signal #1542 added.
vi.useFakeTimers();
const session = makeSession('ios');
session.snapshot = makeSnapshotState(pickupSnapshot(500).nodes, {
snapshotQuality: { state: 'healthy', backend: 'tree' },
});
markPostGestureStabilization(session, 'scroll');

const capture = vi.fn(async () =>
makeSnapshotState(pickupSnapshot(500).nodes, {
snapshotQuality: { state: 'healthy', backend: 'tree' },
}),
);

const resultPromise = withDiagnosticsScope({}, async () => {
await capturePostGestureStabilizedResult({
session,
capture,
readSnapshot: (snapshot) => snapshot,
});
return {
rebased: countDiagnosticEventsByPhase(['post_gesture_snapshot_baseline_rebased']),
staleAccepts: countDiagnosticEventsByPhase(['post_gesture_snapshot_stale_accept']),
};
});

await vi.advanceTimersByTimeAsync(6_000);
const { rebased, staleAccepts } = await resultPromise;

assert.equal(rebased, 0);
assert.equal(staleAccepts, 1);
});
Loading
Loading