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
84 changes: 78 additions & 6 deletions packages/selectors/src/internal/predicates.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { createSnapshotVisibility } from '@agent-device/contracts/snapshot';
import type { SnapshotNode } from '@agent-device/kernel/snapshot';
import { evaluateIsPredicate, normalizeIsPositionals } from './predicates.ts';

Expand Down Expand Up @@ -57,7 +58,7 @@ test('focused predicate reads snapshot focus state', () => {
const result = evaluateIsPredicate({
predicate: 'focused',
node,
nodes: [node],
visibility: createSnapshotVisibility([node]),
platform: 'android',
});

Expand Down Expand Up @@ -88,7 +89,7 @@ test('visible predicate treats zero-height hittable Android nodes as hidden', ()
const result = evaluateIsPredicate({
predicate: 'visible',
node: nodes[1]!,
nodes,
visibility: createSnapshotVisibility(nodes),
platform: 'android',
});

Expand All @@ -115,7 +116,7 @@ test('visible predicate treats rectless hittable Android nodes as hidden', () =>
const result = evaluateIsPredicate({
predicate: 'visible',
node: nodes[1]!,
nodes,
visibility: createSnapshotVisibility(nodes),
platform: 'android',
});

Expand Down Expand Up @@ -152,7 +153,7 @@ test('visible predicate uses visible Android ancestor geometry for rectless text
const result = evaluateIsPredicate({
predicate: 'visible',
node: nodes[2]!,
nodes,
visibility: createSnapshotVisibility(nodes),
platform: 'android',
});

Expand All @@ -175,7 +176,7 @@ test('visible predicate treats Android nodes hidden from users as hidden', () =>
const result = evaluateIsPredicate({
predicate: 'visible',
node: nodes[0]!,
nodes,
visibility: createSnapshotVisibility(nodes),
platform: 'android',
});

Expand Down Expand Up @@ -219,9 +220,80 @@ test('visible predicate does not use non-hittable Android layout ancestors for r
const result = evaluateIsPredicate({
predicate: 'visible',
node: nodes[3]!,
nodes,
visibility: createSnapshotVisibility(nodes),
platform: 'android',
});

assert.equal(result.pass, false);
});

/** One capture whose two rows share a label: one on screen, one below the fold. */
const SHARED_CAPTURE: SnapshotNode[] = [
{ index: 0, ref: 'e0', type: 'Application', rect: { x: 0, y: 0, width: 400, height: 800 } },
{
index: 1,
parentIndex: 0,
ref: 'e1',
type: 'TextField',
label: 'Email',
rect: { x: 0, y: 200, width: 400, height: 40 },
},
{
index: 2,
parentIndex: 0,
ref: 'e2',
type: 'TextField',
label: 'Email',
rect: { x: 0, y: 2400, width: 400, height: 40 },
},
];

/**
* `visible` answers from the index it is handed: two candidates of one capture read the one index the
* caller built, and a predicate that built its own would leave these counters at zero. That an index
* serves many nodes is `snapshot-visibility.test.ts`'s claim; this is the predicate's half of #1970.
*/
test('the visible predicate answers from the visibility index its caller built', () => {
const materialized = { nodeMap: 0, viewportRects: 0 };
const visibility = createSnapshotVisibility(SHARED_CAPTURE, {
onNodeMapBuilt: () => (materialized.nodeMap += 1),
onViewportRectsCollected: () => (materialized.viewportRects += 1),
});

const onScreen = evaluateIsPredicate({
predicate: 'visible',
node: SHARED_CAPTURE[1]!,
visibility,
platform: 'ios',
});
const scrolledOut = evaluateIsPredicate({
predicate: 'visible',
node: SHARED_CAPTURE[2]!,
visibility,
platform: 'ios',
});

assert.equal(onScreen.pass, true);
assert.equal(scrolledOut.pass, false);
assert.deepEqual(materialized, { nodeMap: 1, viewportRects: 1 });
});

/** The closest negative: `text` answers from the node alone and never consults the index. */
test('the text predicate never consults the visibility index', () => {
const materialized = { nodeMap: 0, viewportRects: 0 };
const visibility = createSnapshotVisibility(SHARED_CAPTURE, {
onNodeMapBuilt: () => (materialized.nodeMap += 1),
onViewportRectsCollected: () => (materialized.viewportRects += 1),
});

const match = evaluateIsPredicate({
predicate: 'text',
node: SHARED_CAPTURE[1]!,
visibility,
expectedText: 'Email',
platform: 'ios',
});

assert.equal(match.pass, true);
assert.deepEqual(materialized, { nodeMap: 0, viewportRects: 0 });
});
15 changes: 9 additions & 6 deletions packages/selectors/src/internal/predicates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import type { Platform, PublicPlatform } from '@agent-device/kernel/device';
import type { SnapshotState } from '@agent-device/kernel/snapshot';
import { isPositiveFiniteRect } from '@agent-device/kernel/rect';
import {
createSnapshotVisibility,
extractNodeText,
isUsefulVisibilityAnchor,
type SnapshotVisibility,
Expand Down Expand Up @@ -62,19 +61,23 @@ export function normalizeIsPositionals(positionals: string[]): string[] {
export function evaluateIsPredicate(params: {
predicate: Exclude<IsPredicate, 'exists' | 'absent'>;
node: SnapshotState['nodes'][number];
nodes: SnapshotState['nodes'];
/**
* The visibility index of the tree `node` belongs to — same `index`/`parentIndex` space, so the
* ancestor walk and viewport roots it resolves against are that tree's. A caller that asks about
* several nodes of one capture passes the SAME index to every call, so the tree is indexed once
* instead of once per candidate (#1970).
*/
visibility: SnapshotVisibility;
expectedText?: string;
platform: Platform | PublicPlatform;
}): { pass: boolean; actualText: string; details: string } {
const { predicate, node, nodes, expectedText, platform } = params;
const { predicate, node, visibility, expectedText, platform } = params;
const actualText = extractNodeText(node);
const editable = isNodeEditable(node, platform);
const selected = node.selected === true;
const focused = node.focused === true;
const visible =
predicate === 'text'
? isNodeVisible(node)
: isAssertionVisible(node, createSnapshotVisibility(nodes), platform);
predicate === 'text' ? isNodeVisible(node) : isAssertionVisible(node, visibility, platform);
let pass = false;
switch (predicate) {
case 'visible':
Expand Down
3 changes: 2 additions & 1 deletion src/commands/interaction/runtime/selector-is.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { resolveSelectorPipeline } from '@agent-device/selectors/selector-pipeline';
import { SELECTOR_PIPELINE_POLICIES } from '@agent-device/selectors/selector-pipeline-policy';
import type { SnapshotNode } from '@agent-device/kernel/snapshot';
import { createSnapshotVisibility } from '@agent-device/contracts/snapshot';
import type { AgentDeviceRuntime, CommandContext } from '../../../runtime-contract.ts';
import { AppError, isRequestCanceledError } from '@agent-device/kernel/errors';
import type { SelectorTarget } from '@agent-device/contracts/interaction';
Expand Down Expand Up @@ -164,7 +165,7 @@ async function resolveAssertedPredicate(
const result = evaluateIsPredicate({
predicate,
node: outcome.node,
nodes: capture.snapshot.nodes,
visibility: createSnapshotVisibility(capture.snapshot.nodes),
expectedText: options.expectedText,
platform: runtime.backend.platform,
});
Expand Down
26 changes: 15 additions & 11 deletions src/daemon/__tests__/is-predicates.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { evaluateIsPredicate } from '@agent-device/selectors';
import { createSnapshotVisibility } from '@agent-device/contracts/snapshot';

const viewportNode = {
ref: 'e1',
Expand Down Expand Up @@ -28,13 +29,16 @@ test('evaluateIsPredicate visible and hidden', () => {
const visible = evaluateIsPredicate({
predicate: 'visible',
node: baseNode,
nodes,
visibility: createSnapshotVisibility(nodes),
platform: 'ios',
});
const hidden = evaluateIsPredicate({
predicate: 'hidden',
node: { ...baseNode, rect: { ...baseNode.rect, width: 0 }, hittable: false },
nodes: [viewportNode, { ...baseNode, rect: { ...baseNode.rect, width: 0 }, hittable: false }],
visibility: createSnapshotVisibility([
viewportNode,
{ ...baseNode, rect: { ...baseNode.rect, width: 0 }, hittable: false },
]),
platform: 'ios',
});
assert.equal(visible.pass, true);
Expand All @@ -61,7 +65,7 @@ test('evaluateIsPredicate visible uses ancestor rect for visible list text', ()
const visible = evaluateIsPredicate({
predicate: 'visible',
node: listText,
nodes: [viewportNode, listItem, listText],
visibility: createSnapshotVisibility([viewportNode, listItem, listText]),
platform: 'ios',
});
assert.equal(visible.pass, true);
Expand All @@ -76,7 +80,7 @@ test('evaluateIsPredicate visible fails for off-screen nodes', () => {
const visible = evaluateIsPredicate({
predicate: 'visible',
node: offscreenNode,
nodes: [viewportNode, offscreenNode],
visibility: createSnapshotVisibility([viewportNode, offscreenNode]),
platform: 'ios',
});
assert.equal(visible.pass, false);
Expand All @@ -91,7 +95,7 @@ test('evaluateIsPredicate visible fails for zero-size nodes', () => {
const visible = evaluateIsPredicate({
predicate: 'visible',
node: zeroRectNode,
nodes: [viewportNode, zeroRectNode],
visibility: createSnapshotVisibility([viewportNode, zeroRectNode]),
platform: 'ios',
});
assert.equal(visible.pass, false);
Expand Down Expand Up @@ -124,7 +128,7 @@ test('evaluateIsPredicate visible does not inherit viewport visibility from gene
const visible = evaluateIsPredicate({
predicate: 'visible',
node: listText,
nodes: [viewportNode, scrollView, wrapper, listText],
visibility: createSnapshotVisibility([viewportNode, scrollView, wrapper, listText]),
platform: 'ios',
});
assert.equal(visible.pass, false);
Expand All @@ -150,7 +154,7 @@ test('evaluateIsPredicate visible resolves parent links by node index instead of
const visible = evaluateIsPredicate({
predicate: 'visible',
node: listText,
nodes: [viewportNode, listText, listItem],
visibility: createSnapshotVisibility([viewportNode, listText, listItem]),
platform: 'ios',
});
assert.equal(visible.pass, true);
Expand All @@ -160,13 +164,13 @@ test('evaluateIsPredicate editable and selected', () => {
const editable = evaluateIsPredicate({
predicate: 'editable',
node: baseNode,
nodes: [viewportNode, baseNode],
visibility: createSnapshotVisibility([viewportNode, baseNode]),
platform: 'ios',
});
const selected = evaluateIsPredicate({
predicate: 'selected',
node: { ...baseNode, selected: true },
nodes: [viewportNode, { ...baseNode, selected: true }],
visibility: createSnapshotVisibility([viewportNode, { ...baseNode, selected: true }]),
platform: 'ios',
});
assert.equal(editable.pass, true);
Expand All @@ -177,14 +181,14 @@ test('evaluateIsPredicate text uses equality', () => {
const match = evaluateIsPredicate({
predicate: 'text',
node: baseNode,
nodes: [viewportNode, baseNode],
visibility: createSnapshotVisibility([viewportNode, baseNode]),
expectedText: 'Email',
platform: 'ios',
});
const mismatch = evaluateIsPredicate({
predicate: 'text',
node: baseNode,
nodes: [viewportNode, baseNode],
visibility: createSnapshotVisibility([viewportNode, baseNode]),
expectedText: 'email',
platform: 'ios',
});
Expand Down
Loading
Loading