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
2 changes: 1 addition & 1 deletion fallow-baselines/health.json
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,7 @@
"src/daemon/replay/internal/native-command.ts:complexity",
"packages/platform-apple/src/core/debug-symbols/utils.ts:high impact",
"packages/platform-linux/src/snapshot.ts:high impact",
"src/core/interaction-targeting.ts:high impact",
"packages/selectors/src/interaction-targeting.ts:high impact",
"packages/maestro/src/internal/runtime-targets.ts:high impact",
"packages/kernel/src/source-value.ts:high impact",
"src/request/cancel.ts:high impact",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export function idMatchCountInTree(nodes: readonly IdentityTreeNode[], id: strin
* `idMatchCountInTree` predicate `buildSelectorChainForNode`'s
* `selectableId` keys off directly. `computeTargetEvidence` uses this
* whole-identity form; extracted so a third call site (#1280's
* press-retarget identity-empty check, `src/core/press-retarget.ts`)
* press-retarget identity-empty check, `packages/selectors/src/press-retarget.ts`)
* shares it rather than re-deriving the rule a third way. A demoted id
* falls back to role+label, the same shape an unrecorded id already
* computes.
Expand Down
12 changes: 6 additions & 6 deletions packages/contracts/src/interaction-guarantees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,11 +155,11 @@ const RUNTIME_TREE_SHARED_GUARANTEES = {
// predicate it applies (and the annotation contract it reads).
occlusion: {
kind: 'runtime',
via: 'src/core/selector-pipeline.ts#runNodePipelineStages',
via: 'packages/selectors/src/selector-pipeline.ts#runNodePipelineStages',
},
parentOwnedTouchPoint: {
kind: 'runtime',
via: 'src/core/interaction-touch-point.ts#resolveInteractionTouchPoint',
via: 'packages/selectors/src/interaction-touch-point.ts#resolveInteractionTouchPoint',
},
// #1542: the base decision is the contracts-owned snapshot visibility resolver (bulk accessibility
// tree), but throwIfOffscreenInteractionTarget is the actual end-to-end
Expand All @@ -178,7 +178,7 @@ const RUNTIME_TREE_SHARED_GUARANTEES = {
// is still resolveActionableTouchResolution.
nonHittable: {
kind: 'runtime',
via: 'src/core/selector-pipeline.ts#runNodePipelineStages',
via: 'packages/selectors/src/selector-pipeline.ts#runNodePipelineStages',
},
responseConstruction: SHARED_RESPONSE_CONSTRUCTION,
responseIdentity: {
Expand Down Expand Up @@ -254,11 +254,11 @@ export const INTERACTION_DISPATCH_PATHS: Record<InteractionPathId, InteractionPa
},
occlusion: {
kind: 'runtime',
via: 'src/core/selector-pipeline.ts#runNodePipelineStages',
via: 'packages/selectors/src/selector-pipeline.ts#runNodePipelineStages',
},
parentOwnedTouchPoint: {
kind: 'runtime',
via: 'src/core/interaction-touch-point.ts#resolveInteractionTouchPoint',
via: 'packages/selectors/src/interaction-touch-point.ts#resolveInteractionTouchPoint',
},
offscreen: {
kind: 'runtime',
Expand Down Expand Up @@ -312,7 +312,7 @@ export const INTERACTION_DISPATCH_PATHS: Record<InteractionPathId, InteractionPa
},
occlusion: {
kind: 'runtime',
via: 'src/core/selector-pipeline.ts#runNodePipelineStages',
via: 'packages/selectors/src/selector-pipeline.ts#runNodePipelineStages',
},
parentOwnedTouchPoint: {
kind: 'inapplicable',
Expand Down
51 changes: 50 additions & 1 deletion packages/selectors/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
"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.",
"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.",
"dependencies": {
"@agent-device/ad-script": "workspace:*",
"@agent-device/capture-kit": "workspace:*",
"@agent-device/contracts": "workspace:*",
"@agent-device/kernel": "workspace:*"
},
Expand All @@ -26,6 +27,54 @@
"./parameterized-recorded-fill": {
"types": "./src/parameterized-recorded-fill.ts",
"default": "./src/parameterized-recorded-fill.ts"
},
"./absence-observation": {
"types": "./src/absence-observation.ts",
"default": "./src/absence-observation.ts"
},
"./absence-observation-errors": {
"types": "./src/absence-observation-errors.ts",
"default": "./src/absence-observation-errors.ts"
},
"./absence-observation-resolution": {
"types": "./src/absence-observation-resolution.ts",
"default": "./src/absence-observation-resolution.ts"
},
"./interaction-error": {
"types": "./src/interaction-error.ts",
"default": "./src/interaction-error.ts"
},
"./interaction-positionals": {
"types": "./src/interaction-positionals.ts",
"default": "./src/interaction-positionals.ts"
},
"./interaction-targeting": {
"types": "./src/interaction-targeting.ts",
"default": "./src/interaction-targeting.ts"
},
"./interaction-targeting-fixtures": {
"types": "./src/interaction-targeting.fixtures.ts",
"default": "./src/interaction-targeting.fixtures.ts"
},
"./interaction-touch-point": {
"types": "./src/interaction-touch-point.ts",
"default": "./src/interaction-touch-point.ts"
},
"./press-retarget": {
"types": "./src/press-retarget.ts",
"default": "./src/press-retarget.ts"
},
"./selector-pipeline": {
"types": "./src/selector-pipeline.ts",
"default": "./src/selector-pipeline.ts"
},
"./selector-pipeline-policy": {
"types": "./src/selector-pipeline-policy.ts",
"default": "./src/selector-pipeline-policy.ts"
},
"./touch-semantics": {
"types": "./src/touch-semantics.ts",
"default": "./src/touch-semantics.ts"
}
},
"devDependencies": {
Expand Down
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 '../__tests__/test-utils/snapshot-builders.ts';
import { makeSnapshotState } from './internal/__tests__/snapshot-builders.ts';
import {
absenceCaptureOptionMessage,
absenceCaptureOptionRefusal,
Expand Down
2 changes: 1 addition & 1 deletion packages/selectors/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*
* So they live behind a subpath of their own rather than on the root façade,
* and R19 selector-pipeline-ownership admits exactly one importer:
* `src/core/selector-pipeline.ts`. A specifier is what the import graph
* `packages/selectors/src/selector-pipeline.ts`. A specifier is what the import graph
* resolves, so namespace imports, dynamic imports, and re-exports are all the
* same edge and all equally refused — which a name-shaped check could not say.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import {
distinctRectPairArb,
PROPERTY_RUNS,
scrollingContainerTypeArb,
} from '../__tests__/test-utils/property-arbitraries.ts';
import { makeSnapshotState } from '../__tests__/test-utils/snapshot-builders.ts';
} from './internal/__tests__/property-arbitraries.ts';
import { makeSnapshotState } from './internal/__tests__/snapshot-builders.ts';
import {
classifyActionableTouchCandidates,
createActionableTouchResolver,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { test } from 'vitest';
import {
interactionTouchPointScenarioArb,
PROPERTY_RUNS,
} from '../__tests__/test-utils/property-arbitraries.ts';
import { makeSnapshotState } from '../__tests__/test-utils/snapshot-builders.ts';
} from './internal/__tests__/property-arbitraries.ts';
import { makeSnapshotState } from './internal/__tests__/snapshot-builders.ts';
import { resolveInteractionTouchPoint } from './interaction-touch-point.ts';

function containsPoint(rect: Rect, point: Point): boolean {
Expand Down
118 changes: 118 additions & 0 deletions packages/selectors/src/internal/__tests__/property-arbitraries.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,126 @@
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: 19 additions & 0 deletions packages/selectors/src/internal/__tests__/snapshot-builders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {
attachRefs,
type RawSnapshotNode,
type SnapshotState,
type SnapshotStateProvenance,
} from '@agent-device/kernel/snapshot';

export function makeSnapshotState(
raw: RawSnapshotNode[],
// The provenance pair stays correlated: overrides carry it as one value, never as two
// independently typed fields.
overrides?: Omit<Partial<SnapshotState>, 'backend' | 'producer'> & SnapshotStateProvenance,
): SnapshotState {
return {
nodes: attachRefs(raw),
createdAt: Date.now(),
...overrides,
};
}
2 changes: 1 addition & 1 deletion packages/selectors/src/internal/resolution-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import type { SelectorResolutionOptions } from './public-resolution-types.ts';
*
* The surrounding pipeline stages — occlusion, the off-screen guard,
* hittable-ancestor promotion, and the wait poll budget — are declared in the
* companion structural table, `src/core/selector-pipeline-policy.ts` (#1656),
* companion structural table, `packages/selectors/src/selector-pipeline-policy.ts` (#1656),
* whose rows each name one row of this matrix. They live there rather than
* here because this package is deliberately blind to snapshot occlusion
* annotations, backend visibility probes, and the wait clock: a column here
Expand Down
File renamed without changes.
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 '../__tests__/test-utils/snapshot-builders.ts';
import { makeSnapshotState } from './internal/__tests__/snapshot-builders.ts';
import {
listSelectorPipelineMatches,
resolveSelectorPipeline,
Expand Down
File renamed without changes.
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 21 additions & 3 deletions scripts/layering/package-boundaries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -617,23 +617,41 @@ test('the real tree parses, declares, and passes R11', () => {
);
const selectorsPackage = packages.find((pkg) => pkg.name === '@agent-device/selectors');
assert.ok(selectorsPackage, 'selectors package must exist');
// Four subpaths, and each split is the point: `.` is the string-only façade
// Subpaths, and each split is the point: `.` is the string-only façade
// every in-repo consumer uses, `./ast` is the published parser surface that
// `agent-device/selectors` has shipped since before the engine moved into
// this package, `./engine` is the resolve/list surface reserved for the
// selector-pipeline owner (R19, #1656) — a route reaching it skips the
// structural stages its policy row declares — and
// `./parameterized-recorded-fill` is the recorded-fill parameterization the
// daemon used to own (#2340). A fifth subpath, or the AST leaking into `.`,
// fails here.
// daemon used to own (#2340). The per-file subpaths under `./interaction-*`,
// `./selector-pipeline*`, `./press-retarget`, `./touch-semantics` are the
// execution surface the core selector pipeline moved into this package —
// one subpath per module so consumers pull only the stage they run; the
// `-fixtures` entry is the test-fixture surface (host-kit's
// `./audio-probe-fixtures` precedent). Any other subpath, or the AST
// leaking into `.`, fails here.
assert.deepEqual([...selectorsPackage.exportTargets.keys()].sort(), [
'@agent-device/selectors',
'@agent-device/selectors/absence-observation',
'@agent-device/selectors/absence-observation-errors',
'@agent-device/selectors/absence-observation-resolution',
'@agent-device/selectors/ast',
'@agent-device/selectors/engine',
'@agent-device/selectors/interaction-error',
'@agent-device/selectors/interaction-positionals',
'@agent-device/selectors/interaction-targeting',
'@agent-device/selectors/interaction-targeting-fixtures',
'@agent-device/selectors/interaction-touch-point',
'@agent-device/selectors/parameterized-recorded-fill',
'@agent-device/selectors/press-retarget',
'@agent-device/selectors/selector-pipeline',
'@agent-device/selectors/selector-pipeline-policy',
'@agent-device/selectors/touch-semantics',
]);
assert.deepEqual([...selectorsPackage.workspaceDependencies].sort(), [
'@agent-device/ad-script',
'@agent-device/capture-kit',
'@agent-device/contracts',
'@agent-device/kernel',
]);
Expand Down
Loading
Loading