Skip to content
Draft
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
63 changes: 62 additions & 1 deletion src/__tests__/daemon-composition/generic-settle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import {
} from '@agent-device/contracts/ios-system-surface';
import { makeIosSession } from '@agent-device/daemon/testing/__tests__/support/session-factories';
import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory';
import {
PRESENTED_SHEET_FULL_NODES,
PRESENTED_SHEET_INTERACTIVE_NODES,
SHEET_DISMISSED_NODES,
} from './presented-sheet-trees.ts';
import { activateCompleteRefFrame, refFrameState } from '@agent-device/daemon/testing/ref-frame';
import { setSessionSnapshot } from '@agent-device/daemon/testing/session-snapshot';
import type { SessionStore } from '@agent-device/daemon/testing/session-store';
Expand Down Expand Up @@ -77,6 +82,7 @@ type SettlePayload = {
captures: number;
quietMs: number;
timeoutMs: number;
waitedMs: number;
refsGeneration?: number;
diff?: {
summary: { additions: number; removals: number; unchanged: number };
Expand Down Expand Up @@ -115,7 +121,7 @@ async function emulateCaptureSnapshotForSession(
}

function mockCommandDispatch(
snapshots: Array<typeof BEFORE_NODES>,
snapshots: RawSnapshotNode[][],
producer: NonNullable<GenericSettleTree['producer']> = 'apple-runner',
) {
let snapshotCalls = 0;
Expand Down Expand Up @@ -556,6 +562,61 @@ test('back --settle attaches no diff across a sheet-to-app surface change and di
expect(stored.snapshot?.iosSystemSurfaceBundleId).toBeUndefined();
});

/**
* `back --settle` over a sheet left on the session by a capture of one given flavour, which is how
* the generic route picks its baseline (`generic-settle.ts` reads the stored tree directly). The
* flavour is the only thing the two callers vary; the settle loop's budget arithmetic runs on the
* injected clock, so neither waits real time.
*/
async function dismissSheetFromBaseline(
sessionName: string,
baseline: RawSnapshotNode[],
): Promise<SettlePayload> {
const sessionStore = makeSessionStore();
seedSession(sessionName, sessionStore, { nodes: baseline });
mockCommandDispatch([SHEET_DISMISSED_NODES]);
const response = await dispatchGeneric({
sessionName,
sessionStore,
command: 'back',
flags: { settle: true, settleQuietMs: 25, timeoutMs: 30_000 },
clock: fakeClock(),
});
return expectOkData(response).settle as SettlePayload;
}

// PARITY GAP — ASSERTED, AND CURRENTLY UNMET. Arming the confirmation here reads the baseline's node
// count, so the same sheet disarms when the baseline arrives as a full read (6 nodes) instead of an
// interactive one (4). Both transitions ARE broad — the dismissed screen keeps only the Application
// identity from either baseline — so the only thing that differs is which flavour
// `resolveSettleBaseline` handed the loop. Measured at this head on the injected clock: the
// interactive baseline confirms for 1,502ms, the full-tree baseline for 27ms, and `settled: true`
// either way. Marked `fails` so the suite states the gap without turning the lane red; when #3565's
// producer-stamped `presentedSurface` replaces the count bound, vitest reports this as
// "expected to fail but passed" — that failure is the signal to drop the marker, and the assertion
// is the acceptance criterion for the rewrite.
test.fails('the confirmation arms for one presented sheet whichever baseline flavour the route holds', async () => {
const interactive = await dismissSheetFromBaseline(
'generic-settle-dismiss-sheet-interactive',
PRESENTED_SHEET_INTERACTIVE_NODES,
);
const full = await dismissSheetFromBaseline(
'generic-settle-dismiss-sheet-full',
PRESENTED_SHEET_FULL_NODES,
);

expect(interactive.settled).toBe(true);
expect(full.settled).toBe(true);
expect(
interactive.waitedMs,
'interactive baseline did not confirm the dismissal',
).toBeGreaterThanOrEqual(1_500);
expect(
full.waitedMs,
`full-tree baseline lost the #1831 confirmation: ${full.waitedMs}ms vs the interactive baseline's ${interactive.waitedMs}ms`,
).toBeGreaterThanOrEqual(1_500);
});

test('the settle observation runs after the post-gesture stabilization marker', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'generic-settle-order';
Expand Down
81 changes: 81 additions & 0 deletions src/__tests__/daemon-composition/presented-sheet-trees.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { RawSnapshotNode } from '@agent-device/kernel/snapshot';

/**
* One presented iOS `pageSheet`, in the two flavours a settle baseline can arrive as, plus the app
* screen a dismissal returns to. An interactive capture carries only the surface — the content
* underneath is not hittable, so it drops out of the projection (4 nodes on the iPhone 18 Pro sim,
* October 2026) — while a full read of the very same screen also reports the covered content (6
* nodes). Arming the broad-transition confirmation must not depend on which flavour produced the
* baseline, which is what the caller that reads these asserts.
*/
export const PRESENTED_SHEET_INTERACTIVE_NODES: RawSnapshotNode[] = [
{ index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } },
{
index: 1,
parentIndex: 0,
type: 'StaticText',
label: 'Allow notifications?',
rect: { x: 10, y: 450, width: 370, height: 22 },
},
{
index: 2,
parentIndex: 0,
type: 'Button',
label: 'Continue',
rect: { x: 10, y: 600, width: 370, height: 44 },
hittable: true,
},
{
index: 3,
parentIndex: 0,
type: 'Button',
label: 'Cancel',
rect: { x: 10, y: 660, width: 370, height: 44 },
hittable: true,
},
];

export const PRESENTED_SHEET_FULL_NODES: RawSnapshotNode[] = [
...PRESENTED_SHEET_INTERACTIVE_NODES,
{
index: 4,
parentIndex: 0,
type: 'Other',
label: 'CoveredContent',
rect: { x: 0, y: 0, width: 390, height: 440 },
},
{
index: 5,
parentIndex: 0,
type: 'Button',
label: 'Open settings',
rect: { x: 10, y: 120, width: 370, height: 44 },
},
];

/** The app screen the sheet is dismissed from, which is what every settle capture then reads. */
export const SHEET_DISMISSED_NODES: RawSnapshotNode[] = [
{ index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } },
{
index: 1,
parentIndex: 0,
type: 'StaticText',
label: 'Signed in as demo',
rect: { x: 10, y: 80, width: 200, height: 22 },
},
{
index: 2,
parentIndex: 0,
type: 'Button',
label: 'Home',
rect: { x: 10, y: 120, width: 370, height: 44 },
hittable: true,
},
{
index: 3,
parentIndex: 0,
type: 'TabBar',
label: 'Tab Bar',
rect: { x: 0, y: 800, width: 390, height: 44 },
},
];
177 changes: 176 additions & 1 deletion src/commands/interaction/runtime/stable-capture.fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { SnapshotState } from '@agent-device/kernel/snapshot';
import type { RawSnapshotNode, Rect, SnapshotState } from '@agent-device/kernel/snapshot';
import { makeSnapshotState } from '@agent-device/selectors/snapshot-geometry-fixtures';

export function elementSettingsSnapshot(offscreenRowYs: number[]): SnapshotState {
Expand Down Expand Up @@ -133,3 +133,178 @@ export const elementSettledRoomSnapshot = makeSnapshotState(
],
{ backend: 'xctest', snapshotQuality: { state: 'healthy', backend: 'tree' } },
);

// Screen-replacement fixtures in the SHAPE a live iPhone 17 (iOS 27) session projects — an
// `Application` root, the screen's own content, and a tab bar whose buttons keep the same identities
// across tabs — kept compact on purpose: a live screen of this app reports 24-34 nodes and the same
// proportion here is 18 (`snapshot -i`, October 2026). What the tests need is the ratio, not the
// volume: each fixture is a BROAD replacement of its siblings — fewer than half the node identities
// survive — which is what made the old complete-viewport-projection arm treat an ordinary tab switch
// or push as a screen replacement (#3470).

const TAB_BAR_LABELS = ['Home', 'Catalog', 'Form', 'Settings'];

function iosScreenSnapshot(
content: Array<{ type: string; label: string; rect: Rect }>,
): SnapshotState {
const nodes: RawSnapshotNode[] = [
{
index: 0,
depth: 0,
type: 'Application',
label: 'Agent Device Tester',
rect: { x: 0, y: 0, width: 402, height: 874 },
},
...content.map((node, offset) => ({
index: offset + 1,
depth: 1,
parentIndex: 0,
...node,
})),
];
const tabBarIndex = nodes.length;
nodes.push({
index: tabBarIndex,
depth: 1,
parentIndex: 0,
type: 'TabBar',
label: 'Tab Bar',
rect: { x: 0, y: 791, width: 402, height: 83 },
});
TAB_BAR_LABELS.forEach((label, offset) => {
nodes.push({
index: nodes.length,
depth: 2,
parentIndex: tabBarIndex,
type: 'Button',
label,
rect: { x: 12 + offset * 96, y: 795, width: 92, height: 54 },
});
});
return makeSnapshotState(nodes, {
backend: 'xctest',
snapshotQuality: { state: 'healthy', backend: 'tree' },
});
}

/** A tab's list screen: the shared tab bar plus that tab's own rows. */
export function elementCatalogTabSnapshot(): SnapshotState {
return iosScreenSnapshot(
Array.from({ length: 12 }, (_, offset) => ({
type: 'Cell',
label: `Catalog product ${offset + 1}`,
rect: { x: 0, y: 120 + offset * 90, width: 402, height: 84 },
})),
);
}

/** The tab you switch to: different rows, same tab bar, same app root. */
export function elementSettingsTabSnapshot(): SnapshotState {
return iosScreenSnapshot(
Array.from({ length: 12 }, (_, offset) => ({
type: 'Button',
label: `Open settings fixture ${offset + 1}`,
rect: { x: 16, y: 120 + offset * 60, width: 370, height: 45 },
})),
);
}

/** The screen a push opens: its own nav bar and content, and the tab bar's row is gone. */
export function elementPushedDetailSnapshot(): SnapshotState {
return makeSnapshotState(
[
{
index: 0,
depth: 0,
type: 'Application',
label: 'Agent Device Tester',
rect: { x: 0, y: 0, width: 402, height: 874 },
},
{
index: 1,
depth: 1,
parentIndex: 0,
type: 'NavigationBar',
label: 'Automation',
rect: { x: 0, y: 62, width: 402, height: 54 },
},
{
index: 2,
depth: 2,
parentIndex: 1,
type: 'Button',
label: '(tabs)',
rect: { x: 16, y: 62, width: 44, height: 44 },
},
...['Open automation sheet', 'Press canary', 'Long press canary'].map((label, offset) => ({
index: 3 + offset,
depth: 1,
parentIndex: 0,
type: 'Button',
label,
rect: { x: 16, y: 240 + offset * 120, width: 370, height: 45 },
})),
],
{ backend: 'xctest', snapshotQuality: { state: 'healthy', backend: 'tree' } },
);
}

/**
* A system permission prompt's own projection: the runner roots it at an `Alert` node and the app
* content it presents over is not in the tree. Larger than `TINY_STABLE_TREE_NODE_COUNT`, so only the
* root names it (#2660).
*/
export function systemPermissionPromptSnapshot(): SnapshotState {
return makeSnapshotState(
[
{
index: 0,
depth: 0,
type: 'Alert',
label: 'Allow “Agent Device Tester” to access your microphone?',
rect: { x: 40, y: 309, width: 322, height: 283 },
},
{
index: 1,
depth: 1,
parentIndex: 0,
type: 'StaticText',
label: 'Allow Agent Device Tester to exercise microphone permissions.',
rect: { x: 60, y: 420, width: 282, height: 42 },
},
{
index: 2,
depth: 1,
parentIndex: 0,
type: 'StaticText',
label: 'Microphone access lets the lab record a clip.',
rect: { x: 60, y: 470, width: 282, height: 38 },
},
{
index: 3,
depth: 1,
parentIndex: 0,
type: 'Button',
label: 'Don’t Allow',
rect: { x: 60, y: 528, width: 130, height: 48 },
},
{
index: 4,
depth: 1,
parentIndex: 0,
type: 'Button',
label: 'Allow',
rect: { x: 212, y: 528, width: 130, height: 48 },
},
{
index: 5,
depth: 1,
parentIndex: 0,
type: 'Button',
label: 'Learn More',
rect: { x: 136, y: 580, width: 130, height: 48 },
},
],
{ backend: 'xctest', snapshotQuality: { state: 'healthy', backend: 'tree' } },
);
}
Loading
Loading