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
4 changes: 4 additions & 0 deletions docs/agents/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ Before creating fixtures, look in `src/__tests__/test-utils/`. Import named buil
that defines them (`session-factories.ts`, `device-fixtures.ts`, `store-factory.ts`); avoid importing
unrelated helpers through a barrel. Shared `DeviceInfo`, session, snapshot, store, runtime-fact,
and mocked-binary values belong in a sibling fixture module, not in repeated test literals.
`PROPERTY_RUNS` and the interaction touch-point/rect arbitraries live in
`@agent-device/selectors/snapshot-geometry-fixtures` — the canonical location root tests and the
selectors package both build on — not in `src/__tests__/test-utils/`. `makeSnapshotState` itself
canonically lives in `@agent-device/capture-kit/snapshot-state-fixtures`, re-exported from there.

Use `mkdtempForTest` or `mkdtempForTestSync`. Global setup redirects `TMPDIR` for the run and
removes it after every worker exits — skip per-test cleanup. An interrupted run may leave a
Expand Down
4 changes: 4 additions & 0 deletions packages/capture-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@
"types": "./src/snapshot-state.ts",
"default": "./src/snapshot-state.ts"
},
"./snapshot-state-fixtures": {
"types": "./src/snapshot-state.fixtures.ts",
"default": "./src/snapshot-state.fixtures.ts"
},
"./snapshot-timeout-policy": {
"types": "./src/snapshot/snapshot-timeout-policy.ts",
"default": "./src/snapshot/snapshot-timeout-policy.ts"
Expand Down
6 changes: 5 additions & 1 deletion packages/selectors/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"private": true,
"sideEffects": false,
"type": "module",
"description": "Shared selector matching, argument, and replay semantics for agent-device. `.` is string-only; `./ast` is the published parser surface behind `agent-device/selectors`; `./engine` is the resolve/list surface reserved for the selector-pipeline owner (R19); `./parameterized-recorded-fill` parameterizes recorded fill payloads against their selectors; the interaction-resolution subpaths (`./selector-pipeline`, `./interaction-targeting`, `./interaction-touch-point`, `./press-retarget`, `./absence-observation*`, …) host the engine execution surface owned by the pipeline.",
"description": "Shared selector matching, argument, and replay semantics for agent-device. `.` is string-only; `./ast` is the published parser surface behind `agent-device/selectors`; `./engine` is the resolve/list surface reserved for the selector-pipeline owner (R19); `./parameterized-recorded-fill` parameterizes recorded fill payloads against their selectors; the interaction-resolution subpaths (`./selector-pipeline`, `./interaction-targeting`, `./interaction-touch-point`, `./press-retarget`, `./absence-observation*`, …) host the engine execution surface owned by the pipeline; `./snapshot-geometry-fixtures` is the canonical home for the geometry/touch-point test fixtures this package and root tests both build on (it re-exports `makeSnapshotState` from `@agent-device/capture-kit/snapshot-state-fixtures`, its own canonical home).",
"dependencies": {
"@agent-device/ad-script": "workspace:*",
"@agent-device/capture-kit": "workspace:*",
Expand Down Expand Up @@ -72,6 +72,10 @@
"types": "./src/selector-pipeline-policy.ts",
"default": "./src/selector-pipeline-policy.ts"
},
"./snapshot-geometry-fixtures": {
"types": "./src/snapshot-geometry.fixtures.ts",
"default": "./src/snapshot-geometry.fixtures.ts"
},
"./touch-semantics": {
"types": "./src/touch-semantics.ts",
"default": "./src/touch-semantics.ts"
Expand Down
2 changes: 1 addition & 1 deletion packages/selectors/src/absence-observation.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { makeSnapshotState } from './internal/__tests__/snapshot-builders.ts';
import { makeSnapshotState } from './snapshot-geometry.fixtures.ts';
import {
absenceCaptureOptionMessage,
absenceCaptureOptionRefusal,
Expand Down
4 changes: 2 additions & 2 deletions packages/selectors/src/interaction-targeting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import fc from 'fast-check';
import { test } from 'vitest';
import {
distinctRectPairArb,
makeSnapshotState,
PROPERTY_RUNS,
scrollingContainerTypeArb,
} from './internal/__tests__/property-arbitraries.ts';
import { makeSnapshotState } from './internal/__tests__/snapshot-builders.ts';
} from './snapshot-geometry.fixtures.ts';
import {
classifyActionableTouchCandidates,
createActionableTouchResolver,
Expand Down
4 changes: 2 additions & 2 deletions packages/selectors/src/interaction-touch-point.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import type { Point, Rect } from '@agent-device/kernel/snapshot';
import { test } from 'vitest';
import {
interactionTouchPointScenarioArb,
makeSnapshotState,
PROPERTY_RUNS,
} from './internal/__tests__/property-arbitraries.ts';
import { makeSnapshotState } from './internal/__tests__/snapshot-builders.ts';
} from './snapshot-geometry.fixtures.ts';
import { resolveInteractionTouchPoint } from './interaction-touch-point.ts';

function containsPoint(rect: Rect, point: Point): boolean {
Expand Down
120 changes: 0 additions & 120 deletions packages/selectors/src/internal/__tests__/property-arbitraries.ts
Original file line number Diff line number Diff line change
@@ -1,126 +1,6 @@
import fc from 'fast-check';
import { attachRefs, type Rect, type SnapshotNode } from '@agent-device/kernel/snapshot';
import type { SelectorKey, SelectorTerm } from '../parse.ts';

export const PROPERTY_RUNS = 100;

// ---------------------------------------------------------------------------
// Rects / viewports
// ---------------------------------------------------------------------------

export const scrollingContainerTypeArb = fc.constantFrom(
'XCUIElementTypeScrollView',
'XCUIElementTypeTable',
'XCUIElementTypeCollectionView',
'android.widget.ListView',
'androidx.recyclerview.widget.RecyclerView',
);

export const distinctRectPairArb: fc.Arbitrary<{ ancestor: Rect; target: Rect }> = fc
.record({
x: fc.integer({ min: -200, max: 200 }),
y: fc.integer({ min: -200, max: 200 }),
width: fc.integer({ min: 1, max: 1200 }),
height: fc.integer({ min: 1, max: 1200 }),
})
.chain((ancestor) =>
fc.constantFrom<keyof Rect>('x', 'y', 'width', 'height').map((field) => ({
ancestor,
target: { ...ancestor, [field]: ancestor[field] + 1 },
})),
);

export type InteractionTouchPointScenario = {
nodes: SnapshotNode[];
permutedNodes: SnapshotNode[];
target: SnapshotNode;
bound: Rect;
competitorRects: Rect[];
};

const halfPixel = (value: number): number => value / 2;
const touchPointAxisStepsArb = fc.oneof(
fc.integer({ min: 4, max: 46 }),
fc.integer({ min: 48, max: 800 }),
);

const touchPointTargetRectArb = fc
.record({
x: fc.integer({ min: -200, max: 200 }),
y: fc.integer({ min: -200, max: 200 }),
// Exercise dense desktop rows as well as standard mobile touch targets.
width: touchPointAxisStepsArb,
height: touchPointAxisStepsArb,
})
.map(({ x, y, width, height }) => ({
x: halfPixel(x),
y: halfPixel(y),
width: halfPixel(width),
height: halfPixel(height),
}));

function containedRectArb(container: Rect): fc.Arbitrary<Rect> {
const widthSteps = Math.round(container.width * 2);
const heightSteps = Math.round(container.height * 2);
return fc
.record({
width: fc.integer({ min: 2, max: widthSteps - 2 }),
height: fc.integer({ min: 2, max: heightSteps - 2 }),
})
.chain(({ width, height }) =>
fc
.record({
x: fc.integer({ min: 0, max: widthSteps - width }),
y: fc.integer({ min: 0, max: heightSteps - height }),
})
.map(({ x, y }) => ({
x: container.x + halfPixel(x),
y: container.y + halfPixel(y),
width: halfPixel(width),
height: halfPixel(height),
})),
);
}

export const interactionTouchPointScenarioArb: fc.Arbitrary<InteractionTouchPointScenario> =
touchPointTargetRectArb.chain((targetRect) =>
fc
.tuple(
fc.array(containedRectArb(targetRect), { minLength: 1, maxLength: 6 }),
containedRectArb(targetRect),
)
.chain(([competitorRects, bound]) => {
const nodes = attachRefs([
{
index: 0,
depth: 0,
type: 'Link',
label: 'Generated parent',
rect: targetRect,
hittable: true,
},
...competitorRects.map((rect, offset) => ({
index: offset + 1,
depth: 1,
parentIndex: 0,
type: 'Button',
label: `Generated child ${offset + 1}`,
rect,
hittable: true,
})),
]);
return fc
.shuffledSubarray(nodes, { minLength: nodes.length, maxLength: nodes.length })
.map((permutedNodes) => ({
nodes,
permutedNodes,
target: nodes[0]!,
bound,
competitorRects,
}));
}),
);

const SELECTOR_KEY_VALUE_KINDS = {
id: 'text',
role: 'text',
Expand Down
19 changes: 0 additions & 19 deletions packages/selectors/src/internal/__tests__/snapshot-builders.ts

This file was deleted.

2 changes: 1 addition & 1 deletion packages/selectors/src/internal/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { test } from 'vitest';
import assert from 'node:assert/strict';
import {
formatSelectorChainExpression,
PROPERTY_RUNS,
selectorChainArb,
} from './__tests__/property-arbitraries.ts';
import { PROPERTY_RUNS } from '../snapshot-geometry.fixtures.ts';
import { parseSelectorChain } from './parse.ts';
import type { SelectorTerm } from './parse.ts';

Expand Down
2 changes: 1 addition & 1 deletion packages/selectors/src/selector-pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import { test } from 'vitest';
import type { RawSnapshotNode, SnapshotNode } from '@agent-device/kernel/snapshot';
import { SELECTOR_RESOLUTION_POLICIES } from '@agent-device/selectors';
import { makeSnapshotState } from './internal/__tests__/snapshot-builders.ts';
import { makeSnapshotState } from './snapshot-geometry.fixtures.ts';
import {
listSelectorPipelineMatches,
resolveSelectorPipeline,
Expand Down
129 changes: 129 additions & 0 deletions packages/selectors/src/snapshot-geometry.fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import fc from 'fast-check';
import { attachRefs, type Rect, type SnapshotNode } from '@agent-device/kernel/snapshot';

export { makeSnapshotState } from '@agent-device/capture-kit/snapshot-state-fixtures';

/**
* Run budget for every property in the unit suite. Properties share the unit
* slow-test budget (2.5s per file, see docs/agents/testing.md), so the count is
* bounded here rather than per call site — raise it in one place, and only with
* a measured file duration.
*/
export const PROPERTY_RUNS = 100;

// ---------------------------------------------------------------------------
// Rects / viewports
// ---------------------------------------------------------------------------

export const scrollingContainerTypeArb = fc.constantFrom(
'XCUIElementTypeScrollView',
'XCUIElementTypeTable',
'XCUIElementTypeCollectionView',
'android.widget.ListView',
'androidx.recyclerview.widget.RecyclerView',
);

export const distinctRectPairArb: fc.Arbitrary<{ ancestor: Rect; target: Rect }> = fc
.record({
x: fc.integer({ min: -200, max: 200 }),
y: fc.integer({ min: -200, max: 200 }),
width: fc.integer({ min: 1, max: 1200 }),
height: fc.integer({ min: 1, max: 1200 }),
})
.chain((ancestor) =>
fc.constantFrom<keyof Rect>('x', 'y', 'width', 'height').map((field) => ({
ancestor,
target: { ...ancestor, [field]: ancestor[field] + 1 },
})),
);

export type InteractionTouchPointScenario = {
nodes: SnapshotNode[];
permutedNodes: SnapshotNode[];
target: SnapshotNode;
bound: Rect;
competitorRects: Rect[];
};

const halfPixel = (value: number): number => value / 2;
const touchPointAxisStepsArb = fc.oneof(
fc.integer({ min: 4, max: 46 }),
fc.integer({ min: 48, max: 800 }),
);

const touchPointTargetRectArb = fc
.record({
x: fc.integer({ min: -200, max: 200 }),
y: fc.integer({ min: -200, max: 200 }),
// Exercise dense desktop rows as well as standard mobile touch targets.
width: touchPointAxisStepsArb,
height: touchPointAxisStepsArb,
})
.map(({ x, y, width, height }) => ({
x: halfPixel(x),
y: halfPixel(y),
width: halfPixel(width),
height: halfPixel(height),
}));

function containedRectArb(container: Rect): fc.Arbitrary<Rect> {
const widthSteps = Math.round(container.width * 2);
const heightSteps = Math.round(container.height * 2);
return fc
.record({
width: fc.integer({ min: 2, max: widthSteps - 2 }),
height: fc.integer({ min: 2, max: heightSteps - 2 }),
})
.chain(({ width, height }) =>
fc
.record({
x: fc.integer({ min: 0, max: widthSteps - width }),
y: fc.integer({ min: 0, max: heightSteps - height }),
})
.map(({ x, y }) => ({
x: container.x + halfPixel(x),
y: container.y + halfPixel(y),
width: halfPixel(width),
height: halfPixel(height),
})),
);
}

export const interactionTouchPointScenarioArb: fc.Arbitrary<InteractionTouchPointScenario> =
touchPointTargetRectArb.chain((targetRect) =>
fc
.tuple(
fc.array(containedRectArb(targetRect), { minLength: 1, maxLength: 6 }),
containedRectArb(targetRect),
)
.chain(([competitorRects, bound]) => {
const nodes = attachRefs([
{
index: 0,
depth: 0,
type: 'Link',
label: 'Generated parent',
rect: targetRect,
hittable: true,
},
...competitorRects.map((rect, offset) => ({
index: offset + 1,
depth: 1,
parentIndex: 0,
type: 'Button',
label: `Generated child ${offset + 1}`,
rect,
hittable: true,
})),
]);
return fc
.shuffledSubarray(nodes, { minLength: nodes.length, maxLength: nodes.length })
.map((permutedNodes) => ({
nodes,
permutedNodes,
target: nodes[0]!,
bound,
competitorRects,
}));
}),
);
Loading
Loading