Files
Michał Pierzchała fef0b12cc5 chore: hoist shared snapshot/selector test fixtures into a single canonical location (#2419)
* chore: hoist shared snapshot/selector test fixtures into @agent-device/selectors

PR #2397 left two copies of the snapshot-state builder and duplicated
geometry/touch-point arbitraries (root's src/__tests__/test-utils/ and the
package's internal/__tests__/), because packages cannot import root src/.
Move the canonical versions into a new @agent-device/selectors/test-fixtures
subpath and have both root and the selectors package import from it, leaving
buildNodes and the root-only replay/gesture arbitraries in place.

Fixes #2402

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USabYbQjD16A2UkkvMpf5x

* chore: exempt test-fixtures.ts's test-only arbitraries from dead-code check

PROPERTY_RUNS, scrollingContainerTypeArb, distinctRectPairArb, and
interactionTouchPointScenarioArb are consumed only by *.test.ts files, which
Fallow's --production analysis does not see, matching the existing pattern
for other workspace-package symbols reached only from the test tree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USabYbQjD16A2UkkvMpf5x

* chore: consolidate makeSnapshotState into capture-kit, rename fixtures file

An adversarial review of the #2402 fixture-hoisting change found a third
copy of makeSnapshotState in packages/capture-kit/src/snapshot-state.fixtures.ts,
predating PR #2397. Since @agent-device/selectors already depends on
capture-kit, make capture-kit's copy canonical (exported as
./snapshot-state-fixtures) and have the selectors package's fixtures module
re-export it instead of duplicating it a third time.

Also rename packages/selectors/src/test-fixtures.ts to
snapshot-geometry.fixtures.ts (subpath ./snapshot-geometry-fixtures) to match
every other test-fixture module's *.fixtures.ts convention in this repo,
which lets it fall under .fallowrc.json's existing blanket **/*.fixtures.ts
dead-code exemption instead of needing a bespoke per-symbol entry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USabYbQjD16A2UkkvMpf5x

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-09 13:56:40 +02:00

486 lines
12 KiB
TypeScript

import type { RawSnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot';
import { makeSnapshotState } from '@agent-device/selectors/snapshot-geometry-fixtures';
/**
* The permanent contract fixture trees (ADR 0011 Layer 3): the real
* Bluesky-shaped snapshots that found the offscreen/occlusion/non-hittable
* bugs, kept as the shapes every dispatch path is proven against.
*/
// Closed drawer: the only "Explore" match sits fully left of the Application
// viewport. Tapping it would silently press out-of-viewport coordinates.
export function closedDrawerSnapshot(): SnapshotState {
return makeSnapshotState([
{
index: 0,
depth: 0,
type: 'Application',
rect: { x: 0, y: 0, width: 400, height: 800 },
hittable: true,
},
{
index: 1,
depth: 2,
parentIndex: 0,
type: 'Button',
label: 'Explore',
rect: { x: -320, y: 240, width: 300, height: 50 },
hittable: true,
},
]);
}
// Closed drawer item plus a visible bottom-tab twin: both match
// `label=Profile`, and the on-screen candidate must win disambiguation.
export function drawerWithVisibleTwinSnapshot(): SnapshotState {
return makeSnapshotState([
{
index: 0,
depth: 0,
type: 'Application',
rect: { x: 0, y: 0, width: 400, height: 800 },
hittable: true,
},
{
index: 1,
depth: 2,
parentIndex: 0,
type: 'Button',
label: 'Profile',
rect: { x: 20, y: 740, width: 200, height: 50 },
hittable: true,
},
{
index: 2,
depth: 3,
parentIndex: 0,
type: 'Button',
label: 'Profile',
rect: { x: -320, y: 240, width: 100, height: 20 },
hittable: false,
},
]);
}
// React Native wrapper chain: cell/button/text expose the same label, but all
// three structurally identify the one actionable button.
export function equivalentWrapperChainSnapshot(): SnapshotState {
return makeSnapshotState([
{
index: 0,
depth: 0,
type: 'Application',
rect: { x: 0, y: 0, width: 400, height: 800 },
hittable: true,
},
{
index: 1,
depth: 1,
parentIndex: 0,
type: 'Cell',
label: 'Chat',
rect: { x: 20, y: 740, width: 100, height: 50 },
hittable: false,
},
{
index: 2,
depth: 2,
parentIndex: 1,
type: 'Button',
label: 'Chat',
rect: { x: 20, y: 740, width: 100, height: 50 },
hittable: true,
},
{
index: 3,
depth: 3,
parentIndex: 2,
type: 'StaticText',
label: 'Chat',
rect: { x: 20, y: 740, width: 100, height: 50 },
hittable: false,
},
]);
}
// Bluesky regression: the closed drawer's overlay container pokes a fraction
// of a pixel into the viewport (float rounding), but every tap point is far
// off-screen. Edge overlap must not count as on-screen.
export function edgeGrazingDrawerSnapshot(): SnapshotState {
return makeSnapshotState([
{
index: 0,
depth: 0,
type: 'Application',
rect: { x: 0, y: 0, width: 402, height: 874 },
hittable: true,
},
{
index: 1,
depth: 1,
parentIndex: 0,
type: 'Other',
rect: { x: -321.6, y: 0, width: 321.67, height: 874 },
hittable: false,
},
{
index: 2,
depth: 3,
parentIndex: 1,
type: 'Button',
label: 'Explore',
rect: { x: -321.6, y: 240, width: 321.33, height: 50 },
hittable: false,
},
]);
}
// A button flagged covered by a floating tab bar overlay.
export function coveredButtonSnapshot(): SnapshotState {
return makeSnapshotState([
{
index: 0,
depth: 0,
type: 'Application',
label: 'Example',
rect: { x: 0, y: 0, width: 390, height: 844 },
},
{
index: 1,
depth: 1,
parentIndex: 0,
type: 'Button',
label: 'Save draft',
rect: { x: 16, y: 790, width: 140, height: 44 },
hittable: false,
interactionBlocked: 'covered',
presentationHints: ['covered'],
},
{
index: 2,
depth: 1,
parentIndex: 0,
type: 'TabBar',
rect: { x: 0, y: 760, width: 390, height: 84 },
hittable: true,
},
]);
}
// A semantic parent whose entire surface belongs to independently interactive
// children. Parent-targeted coordinate paths must fail closed and preserve the
// caller's selector/ref context instead of silently activating any child.
export function fullyTiledParentSnapshot(): SnapshotState {
const children = Array.from({ length: 10 }, (_, childIndex) => ({
index: childIndex + 2,
depth: 2,
parentIndex: 1,
type: 'Button',
label: `Action ${childIndex + 1}`,
rect: { x: 20, y: 100 + childIndex * 20, width: 360, height: 20 },
hittable: true,
}));
return makeSnapshotState([
{
index: 0,
depth: 0,
type: 'Application',
label: 'Example',
rect: { x: 0, y: 0, width: 400, height: 800 },
hittable: true,
},
{
index: 1,
depth: 1,
parentIndex: 0,
type: 'Link',
label: 'Card',
rect: { x: 20, y: 100, width: 360, height: 200 },
hittable: true,
},
...children,
]);
}
// A visible list cell that iOS reports as non-hittable (#1037 shape): the
// interaction must proceed but be annotated.
export function nonHittableCellSnapshot(): SnapshotState {
return makeSnapshotState([
{
index: 0,
depth: 0,
type: 'XCUIElementTypeOther',
label: 'Settings list',
rect: { x: 10, y: 20, width: 300, height: 80 },
hittable: true,
},
{
index: 1,
depth: 1,
parentIndex: 0,
type: 'XCUIElementTypeCell',
label: 'Account',
rect: { x: 20, y: 10, width: 100, height: 40 },
hittable: false,
},
]);
}
// Baseline happy-path tree: one hittable button.
export function continueButtonSnapshot(): SnapshotState {
return makeSnapshotState([
{
index: 0,
depth: 0,
type: 'Button',
label: 'Continue',
value: 'Continue',
rect: { x: 10, y: 20, width: 100, height: 40 },
hittable: true,
},
]);
}
// Same button, reported non-hittable.
export function nonHittableButtonSnapshot(): SnapshotState {
return makeSnapshotState([
{
index: 0,
depth: 0,
type: 'Button',
label: 'Continue',
rect: { x: 10, y: 20, width: 100, height: 40 },
hittable: false,
},
]);
}
// Post-action settled tree for --settle scenarios: vs continueButtonSnapshot
// the Continue button is replaced by a Welcome text — exactly one addition and
// one removal in the settled diff, with the added line carrying its ref.
export function settledWelcomeSnapshot(): SnapshotState {
return makeSnapshotState([
{
index: 0,
depth: 0,
type: 'StaticText',
label: 'Welcome!',
rect: { x: 10, y: 20, width: 100, height: 40 },
hittable: true,
},
]);
}
// Viewport-only tree for coordinate scenarios.
export function viewportOnlySnapshot(): SnapshotState {
return makeSnapshotState([
{
index: 0,
depth: 0,
type: 'Application',
rect: { x: 0, y: 0, width: 400, height: 800 },
hittable: true,
},
]);
}
/** Dual-endpoint drag tree with an off-screen destination twin for ranking coverage. */
export function dragEndpointsSnapshot(): SnapshotState {
return makeSnapshotState([
{
index: 0,
depth: 0,
type: 'Application',
rect: { x: 0, y: 0, width: 400, height: 800 },
hittable: true,
},
{
index: 1,
depth: 1,
parentIndex: 0,
type: 'View',
identifier: 'source',
label: 'Source',
rect: { x: 20, y: 100, width: 100, height: 60 },
hittable: true,
},
{
index: 2,
depth: 1,
parentIndex: 0,
type: 'View',
identifier: 'destination',
label: 'Drop',
rect: { x: 240, y: 500, width: 120, height: 80 },
hittable: true,
},
{
index: 3,
depth: 2,
parentIndex: 0,
type: 'View',
label: 'Other drop',
rect: { x: -200, y: 500, width: 120, height: 80 },
hittable: false,
},
{
index: 4,
depth: 2,
parentIndex: 2,
type: 'StaticText',
label: 'Drop',
rect: { x: 240, y: 500, width: 120, height: 80 },
hittable: false,
},
]);
}
export function runnerPresentedDragEndpointsNodes(): RawSnapshotNode[] {
return dragEndpointsSnapshot()
.nodes.filter((node) => node.index !== 3)
.map(({ ref: _ref, ...node }) => node);
}
/**
* Runner-side node payloads (the shape `ios.runner.snapshot` returns) for the
* provider-transcript scenarios.
*/
export const RUNNER_EQUIVALENT_WRAPPER_NODES = [
{ index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } },
{
index: 1,
parentIndex: 0,
type: 'Cell',
label: 'Chat',
rect: { x: 20, y: 740, width: 100, height: 50 },
hittable: false,
},
{
index: 2,
parentIndex: 1,
type: 'Button',
label: 'Chat',
rect: { x: 20, y: 740, width: 100, height: 50 },
hittable: true,
},
{
index: 3,
parentIndex: 2,
type: 'StaticText',
label: 'Chat',
rect: { x: 20, y: 740, width: 100, height: 50 },
hittable: false,
},
];
export const RUNNER_CONTINUE_NODES = [
{
index: 0,
type: 'Application',
label: 'Example',
rect: { x: 0, y: 0, width: 400, height: 800 },
},
{
index: 1,
parentIndex: 0,
type: 'Button',
label: 'Continue',
hittable: true,
rect: { x: 100, y: 300, width: 200, height: 44 },
},
] as const;
export const RUNNER_CHANGED_NODES = [
{
index: 0,
type: 'Application',
label: 'Example',
rect: { x: 0, y: 0, width: 400, height: 800 },
},
{
index: 1,
parentIndex: 0,
type: 'StaticText',
label: 'Welcome!',
rect: { x: 100, y: 300, width: 200, height: 44 },
},
] as const;
// Runner-side closed drawer: the only match is off-screen, so the runtime
// fallback path must refuse it instead of tapping out-of-viewport coordinates.
export const RUNNER_CLOSED_DRAWER_NODES = [
{
index: 0,
type: 'Application',
label: 'Example',
rect: { x: 0, y: 0, width: 400, height: 800 },
},
{
index: 1,
parentIndex: 0,
type: 'Button',
label: 'Explore',
hittable: true,
rect: { x: -320, y: 240, width: 300, height: 50 },
},
] as const;
// Runner-side covered control (#1091 delegation): the runner skips it as
// non-hittable (ELEMENT_NOT_FOUND) and the runtime fallback must refuse with
// the covered shape instead of tapping through the overlay.
export const RUNNER_COVERED_NODES = [
{
index: 0,
type: 'Application',
label: 'Example',
rect: { x: 0, y: 0, width: 400, height: 800 },
},
{
index: 1,
parentIndex: 0,
type: 'Button',
label: 'Save draft',
hittable: false,
interactionBlocked: 'covered',
rect: { x: 16, y: 700, width: 140, height: 44 },
},
] as const;
// Runner-side visible-but-non-hittable cell (#1037 shape): the runner reports
// ELEMENT_NOT_FOUND, the runtime fallback proceeds by coordinates and
// annotates the result instead of failing.
export const RUNNER_NON_HITTABLE_NODES = [
{
index: 0,
type: 'Application',
label: 'Example',
rect: { x: 0, y: 0, width: 400, height: 800 },
},
{
index: 1,
parentIndex: 0,
type: 'Cell',
label: 'Recents row',
hittable: false,
rect: { x: 20, y: 300, width: 360, height: 60 },
},
] as const;
export const RUNNER_NON_HITTABLE_TEXT_INPUT_NODES = [
{
index: 0,
type: 'Application',
label: 'Example',
rect: { x: 0, y: 0, width: 400, height: 800 },
},
{
index: 1,
parentIndex: 0,
type: 'TextField',
label: 'Pin',
hittable: false,
rect: { x: 20, y: 40, width: 160, height: 40 },
},
] as const;