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
14 changes: 10 additions & 4 deletions packages/selectors/src/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@
//
// So the AST stays exported, through one named subpath instead of being
// reachable from anywhere: `src/sdk/selectors.ts` is its only consumer, and
// `facade-symbols.ts` pins this list to exactly what v0.20.5 shipped. Widening
// it means widening the public package, and the gate makes that a deliberate
// edit rather than a side effect.
// that file's explicit export list is the pin (the `facade-symbols.ts` table
// #1574 added to hold it was retired in #1614, once every façade named its
// exports itself). Nothing binds this list to `website/docs/docs/client-api.md`
// by gate, so widening it is a deliberate edit whose convention is to record
// the new name in that published API surface too.
import type { SelectorChain } from './internal/parse.ts';
import { formatSelectorFailure as formatSelectorFailureFromText } from './internal/resolve.ts';
import type { SelectorDiagnostics } from './internal/public-resolution-types.ts';
Expand All @@ -22,7 +24,11 @@ export type { SelectorChain } from './internal/parse.ts';
export type { SelectorDiagnostics } from './internal/public-resolution-types.ts';

export { isSelectorToken, parseSelectorChain, tryParseSelectorChain } from './internal/parse.ts';
export { findSelectorChainMatch, resolveSelectorChain } from './internal/resolve.ts';
export {
findSelectorChainMatch,
listSelectorChainMatches,
resolveSelectorChain,
} from './internal/resolve.ts';
export { isNodeEditable, isNodeVisible } from './internal/node.ts';

/**
Expand Down
25 changes: 18 additions & 7 deletions packages/selectors/src/internal/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,24 @@ export type AstSelectorChainMatchList = {
};

/**
* ADR 0012 migration step 4: the raw matched-node list for whichever chain
* alternative `resolveSelectorChain` would pick (first alternative with any
* match, in order) — mirrors `analyzeSelectorMatches`'s alternate-selection
* exactly, decoupled from uniqueness/disambiguation, so a replay-time
* verifier can compute `matchCount` and the recorded-identity set over the
* SAME domain resolution itself used, independent of whether resolution
* could pick a unique winner.
* Every node the winning chain alternative matches, in snapshot order, plus
* that alternative and its index; `null` when no alternative matches.
*
* "Winning" is the SAME alternative `findSelectorChainMatch` picks — the first
* with at least one match — decoupled from uniqueness and disambiguation:
* several matches are reported, never refused or narrowed to one. That is NOT
* always what `resolveSelectorChain` names: under its default
* `requireUnique: true` it skips an ambiguous alternative and resolves from a
* later one, so the indices agree when it passes `requireUnique: false`, when
* the first matching alternative is unique, or when `disambiguateAmbiguous:
* true` resolves that alternative in place (see `firstMatch` vs `matchedNodes`
* above). Published through `agent-device/selectors` (#3180) so a consumer
* applies its OWN strictness to the same matched-node domain the daemon
* resolves against, instead of reimplementing term matching.
*
* ADR 0012 migration step 4 kept this mirroring `analyzeSelectorMatches`'s
* alternate-selection exactly, so a replay-time verifier computes `matchCount`
* and the recorded-identity set over the SAME domain resolution itself used.
*/
export function listSelectorChainMatches(
nodes: SnapshotState['nodes'],
Expand Down
270 changes: 270 additions & 0 deletions src/sdk/selectors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
import assert from 'node:assert/strict';
Comment thread
thymikee marked this conversation as resolved.
import { test } from 'vitest';
import { buildNodes } from '../__tests__/test-utils/snapshot-builders.ts';
import {
findSelectorChainMatch,
listSelectorChainMatches,
parseSelectorChain,
resolveSelectorChain,
} from './selectors.ts';

// Public-entry contract (#3180), through `./selectors.ts` rather than a deep
// import: every node the winning alternative matches, on the same snapshot a
// consumer applies its own strictness to.

const loginNodes = () =>
buildNodes([
{ index: 0, type: 'Window' },
{
index: 1,
type: 'Button',
label: 'Continue',
identifier: 'auth_continue',
rect: { x: 0, y: 80, width: 200, height: 44 },
hittable: true,
},
{ index: 2, type: 'Text', label: 'Skip', rect: { x: 0, y: 124, width: 200, height: 20 } },
{
index: 3,
type: 'Button',
label: 'Continue',
identifier: 'secondary_continue',
rect: { x: 0, y: 140, width: 200, height: 44 },
hittable: true,
},
// Matched by default; refused when the caller requires geometry.
{ index: 4, type: 'Button', label: 'Continue', hittable: true },
]);

test('listSelectorChainMatches returns every node of the winning alternative, in snapshot order (#3180)', () => {
const nodes = loginNodes();
const match = listSelectorChainMatches(nodes, parseSelectorChain('label="Continue"'), {
platform: 'ios',
});
assert.ok(match);
assert.equal(match.selectorIndex, 0);
assert.equal(match.selector.raw, 'label="Continue"');
assert.deepEqual(
match.matchedNodes.map((node) => node.ref),
['e2', 'e4', 'e5'],
);
// Identity, not a copy: consumers compare against their own snapshot.
assert.equal(match.matchedNodes[0], nodes[1]);
});

test('listSelectorChainMatches reports several matches where resolveSelectorChain refuses, from the same chain', () => {
const nodes = loginNodes();
const chain = parseSelectorChain('label="Continue"');
assert.equal(
resolveSelectorChain(nodes, chain, { platform: 'ios', requireUnique: true }),
null,
'uniqueness refusal must not remove the matched nodes from the list',
);
const match = listSelectorChainMatches(nodes, chain, { platform: 'ios' });
assert.equal(match?.matchedNodes.length, 3);
});

test('listSelectorChainMatches walks to the first alternative that matches, as find does and a uniqueness-refusing resolve does not', () => {
const nodes = loginNodes();
const match = listSelectorChainMatches(
nodes,
parseSelectorChain('label="Absent" || id=auth_continue'),
{ platform: 'ios' },
);
assert.ok(match);
assert.equal(match.selectorIndex, 1);
assert.deepEqual(
match.matchedNodes.map((node) => node.ref),
['e2'],
);
// Both alternatives match: `find` shares this answer, a default
// `resolveSelectorChain` refuses ambiguous alternative 0 and names 1.
assert.equal(
findSelectorChainMatch(nodes, parseSelectorChain('label="Continue" || id=auth_continue'), {
platform: 'ios',
})?.selectorIndex,
0,
);
assert.equal(
resolveSelectorChain(nodes, parseSelectorChain('label="Continue" || id=auth_continue'), {
platform: 'ios',
})?.selectorIndex,
1,
);
const both = listSelectorChainMatches(
nodes,
parseSelectorChain('label="Continue" || id=auth_continue'),
{ platform: 'ios' },
);
assert.equal(both?.selectorIndex, 0);
assert.deepEqual(
both?.matchedNodes.map((node) => node.ref),
['e2', 'e4', 'e5'],
);
assert.equal(
listSelectorChainMatches(nodes, parseSelectorChain('label="Absent"'), { platform: 'ios' }),
null,
);
});

test('listSelectorChainMatches honors requireRect against the same geometry the resolver sees', () => {
const nodes = loginNodes();
const match = listSelectorChainMatches(nodes, parseSelectorChain('label="Continue"'), {
platform: 'ios',
requireRect: true,
});
assert.deepEqual(
match?.matchedNodes.map((node) => node.ref),
['e2', 'e4'],
);
});

test('listSelectorChainMatches lets requireRect change WHICH alternative wins, not just its members', () => {
// `requireRect` removes candidates BEFORE alternative selection, so the walk
// can move to a later alternative instead of reporting an empty first one.
const nodes = buildNodes([
{ index: 0, type: 'Button', label: 'NoRect' },
{ index: 1, type: 'Button', identifier: 'foo', rect: { x: 0, y: 10, width: 10, height: 10 } },
]);
const chain = parseSelectorChain('label="NoRect" || id=foo');
assert.equal(
listSelectorChainMatches(nodes, chain, { platform: 'ios' })?.selectorIndex,
0,
'without the geometry requirement the first alternative wins',
);
const required = listSelectorChainMatches(nodes, chain, {
platform: 'ios',
requireRect: true,
});
assert.equal(required?.selectorIndex, 1);
assert.deepEqual(
required?.matchedNodes.map((node) => node.ref),
['e2'],
);
});

test('listSelectorChainMatches returns null for an empty chain and for a total miss', () => {
const nodes = loginNodes();
// No alternative to win, and an empty match set is not representable.
assert.equal(
listSelectorChainMatches(nodes, { raw: '', selectors: [] }, { platform: 'ios' }),
null,
);
});

test('listSelectorChainMatches returns nodes in snapshot-array order, not index order', () => {
// Array order, not `index` order: the answer follows the snapshot passed in.
const nodes = buildNodes([
{ index: 5, type: 'Button', label: 'M' },
{ index: 2, type: 'Button', label: 'M' },
]);
assert.deepEqual(
nodes.map((node) => node.ref),
['e1', 'e2'],
);
const match = listSelectorChainMatches(nodes, parseSelectorChain('label="M"'), {
platform: 'ios',
});
assert.deepEqual(
match?.matchedNodes.map((node) => node.ref),
['e1', 'e2'],
);
assert.deepEqual(
match?.matchedNodes.map((node) => node.index),
[5, 2],
);
});

test('listSelectorChainMatches uses agent-device term semantics (#3180)', () => {
// `hittable` requires an explicit true, so the non-hittable row cannot lead.
const hittableNodes = buildNodes([
{ index: 0, type: 'Button', label: 'Refresh' },
{
index: 1,
type: 'Button',
label: 'Refresh',
rect: { x: 0, y: 40, width: 100, height: 40 },
hittable: true,
},
]);
const hittable = listSelectorChainMatches(
hittableNodes,
parseSelectorChain('hittable=true label="Refresh"'),
{ platform: 'ios' },
);
assert.deepEqual(
hittable?.matchedNodes.map((node) => node.ref),
['e2'],
);

// `text` is extractNodeText — the FIRST non-empty of label/value/identifier —
// so e2, whose label wins with a different value, does not match.
const textNodes = buildNodes([
{ index: 0, type: 'Button', label: 'Sign in' },
{ index: 1, type: 'Button', label: 'Other', value: 'Sign in' },
]);
const text = listSelectorChainMatches(textNodes, parseSelectorChain('text="Sign in"'), {
platform: 'android',
});
assert.deepEqual(
text?.matchedNodes.map((node) => node.ref),
['e1'],
);

// `role` matches the NORMALIZED native class, not the raw type string.
const roleNodes = buildNodes([
{ index: 0, type: 'XCUIElementTypeButton', label: 'OK' },
{ index: 1, type: 'XCUIElementTypeSwitch', label: 'Wi-Fi' },
]);
const role = listSelectorChainMatches(roleNodes, parseSelectorChain('role="button"'), {
platform: 'ios',
});
assert.deepEqual(
role?.matchedNodes.map((node) => node.ref),
['e1'],
);

// `editable` is fillable type + enabled: a TextView role="textbox" is not
// fillable, and a disabled EditText is not enabled.
const editableNodes = buildNodes([
{ index: 0, type: 'android.widget.EditText', label: 'Name', enabled: true },
{ index: 1, type: 'android.widget.TextView', label: 'Note', role: 'textbox' },
{ index: 2, type: 'android.widget.EditText', label: 'Disabled', enabled: false },
]);
const editable = listSelectorChainMatches(editableNodes, parseSelectorChain('editable=true'), {
platform: 'android',
});
assert.deepEqual(
editable?.matchedNodes.map((node) => node.ref),
['e1'],
);

// `visible` is isNodeVisible: hittable OR a non-empty rect. e1 exercises the
// hittable arm with a zero-size rect, e2 the rect arm while non-hittable, and
// e3 has neither, which is what `hidden=true` names.
const visibleNodes = buildNodes([
{
index: 0,
type: 'Button',
label: 'Row',
hittable: true,
rect: { x: 0, y: 0, width: 0, height: 0 },
},
{ index: 1, type: 'Button', label: 'Row', rect: { x: 0, y: 20, width: 50, height: 50 } },
{ index: 2, type: 'Button', label: 'Row' },
]);
const visible = listSelectorChainMatches(visibleNodes, parseSelectorChain('visible=true'), {
platform: 'ios',
});
assert.deepEqual(
visible?.matchedNodes.map((node) => node.ref),
['e1', 'e2'],
);
const hidden = listSelectorChainMatches(visibleNodes, parseSelectorChain('hidden=true'), {
platform: 'ios',
});
assert.deepEqual(
hidden?.matchedNodes.map((node) => node.ref),
['e3'],
);
});
1 change: 1 addition & 0 deletions src/sdk/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export {
isNodeEditable,
isNodeVisible,
isSelectorToken,
listSelectorChainMatches,
parseSelectorChain,
resolveSelectorChain,
tryParseSelectorChain,
Expand Down
4 changes: 3 additions & 1 deletion test/integration/installed-package-metro.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,9 @@ test('installed package exposes Node APIs and packaged companion tunnel entrypoi
'./plugins': (mod) => Object.keys(mod).length === 0,
'./remote-config': (mod) => typeof mod,
'./selectors': (mod) =>
mod.isSelectorToken('||') && typeof mod.parseSelectorChain === 'function',
mod.isSelectorToken('||') &&
typeof mod.parseSelectorChain === 'function' &&
typeof mod.listSelectorChainMatches === 'function',
};
const subpathSmokeResults = {};
for (const subpath of Object.keys(subpathSmoke).sort()) {
Expand Down
3 changes: 3 additions & 0 deletions website/docs/docs/client-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ Supported public entry points for Node consumers:
- `tryParseSelectorChain(expression)`
- `resolveSelectorChain(nodes, chain, options)`
- `findSelectorChainMatch(nodes, chain, options)`
- `listSelectorChainMatches(nodes, chain, options)`
- `formatSelectorFailure(chain, diagnostics, options)`
- `isNodeVisible(node)`
- `isSelectorToken(token)`
Expand Down Expand Up @@ -496,6 +497,8 @@ Use `agent-device/remote-config` for profile loading and path resolution, `agent

Use `agent-device/selectors` when a remote daemon or bridge needs to parse and match selector expressions without deep-importing daemon internals. Matching is platform-aware because role normalization and editability checks differ by backend.

`listSelectorChainMatches(nodes, chain, options)` returns every node the winning selector alternative matches, in snapshot order, plus that alternative and its index — the same first-match domain `findSelectorChainMatch` uses, without uniqueness refusal, so a runner applies its own strictness to the same nodes the CLI matched. `resolveSelectorChain` can name a LATER alternative: by default it refuses an ambiguous one and keeps walking, so the indices agree when it passes `requireUnique: false`, when the first matching alternative is unique, or when `disambiguateAmbiguous: true` resolves that alternative in place. It returns `null` when no alternative matches. `options` is `{ platform, requireRect? }`; the matched `SnapshotNode` objects are the ones passed in.

```ts
import { findSelectorChainMatch, parseSelectorChain } from 'agent-device/selectors';

Expand Down
Loading