diff --git a/packages/selectors/src/ast.ts b/packages/selectors/src/ast.ts index fe34badb54..4bcfa3c12e 100644 --- a/packages/selectors/src/ast.ts +++ b/packages/selectors/src/ast.ts @@ -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'; @@ -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'; /** diff --git a/packages/selectors/src/internal/resolve.ts b/packages/selectors/src/internal/resolve.ts index ebc0cbee3c..55da04c92c 100644 --- a/packages/selectors/src/internal/resolve.ts +++ b/packages/selectors/src/internal/resolve.ts @@ -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'], diff --git a/src/sdk/selectors.test.ts b/src/sdk/selectors.test.ts new file mode 100644 index 0000000000..44876db47b --- /dev/null +++ b/src/sdk/selectors.test.ts @@ -0,0 +1,270 @@ +import assert from 'node:assert/strict'; +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'], + ); +}); diff --git a/src/sdk/selectors.ts b/src/sdk/selectors.ts index 12ccd55db8..0139a27b67 100644 --- a/src/sdk/selectors.ts +++ b/src/sdk/selectors.ts @@ -6,6 +6,7 @@ export { isNodeEditable, isNodeVisible, isSelectorToken, + listSelectorChainMatches, parseSelectorChain, resolveSelectorChain, tryParseSelectorChain, diff --git a/test/integration/installed-package-metro.test.ts b/test/integration/installed-package-metro.test.ts index afe2aa0bb1..0253ca03e6 100644 --- a/test/integration/installed-package-metro.test.ts +++ b/test/integration/installed-package-metro.test.ts @@ -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()) { diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 19aa0b8749..d59051db73 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -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)` @@ -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';