Skip to content
138 changes: 138 additions & 0 deletions packages/selectors/src/internal/find.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { test } from 'vitest';
import assert from 'node:assert/strict';
import type { SnapshotNode } from '@agent-device/kernel/snapshot';
import { findBestMatchesByLocator } from './find.ts';
import { parseSelectorChain } from './parse.ts';
import { resolveSelectorChain } from './resolve.ts';
import { formatRole } from '@agent-device/kernel/snapshot';

function makeNode(ref: string, label?: string, identifier?: string): SnapshotNode {
Expand All @@ -28,3 +30,139 @@ test('findBestMatchesByLocator returns all best-scored matches', () => {
assert.equal(result.matches[0]?.ref, 'e1');
assert.equal(result.matches[1]?.ref, 'e2');
});

// ── #3021: the `find role=` locator shares the matcher's kind vocabulary ──

function makeTypedNode(ref: string, type: string): SnapshotNode {
return {
index: Number(ref.replace('e', '')) || 0,
ref,
type,
kind: formatRole(type),
rect: { x: 0, y: 0, width: 100, height: 20 },
};
}

function roleMatches(query: string, types: string[]) {
const nodes = types.map((type, idx) => makeTypedNode(`e${idx + 1}`, type));
return findBestMatchesByLocator(nodes, 'role', query);
}

test('find role= scores the coarse kind vocabulary exactly', () => {
// Forward direction: the kind a snapshot publishes for the node.
const text = roleMatches('text', ['android.widget.TextView', 'XCUIElementTypeStaticText']);
assert.equal(text.score, 2);
assert.deepEqual(
text.matches.map((node) => node.ref),
['e1', 'e2'],
);
});

test('find role= still scores legacy leaf spellings exactly during the window', () => {
// Compatibility direction: `textview` was the old exact leaf for both class
// spellings; the kind is context-coarse (`text` vs `text-view`), and the
// window covers both.
const textView = roleMatches('textview', ['android.widget.TextView', 'XCUIElementTypeTextView']);
assert.equal(textView.score, 2);
assert.deepEqual(
textView.matches.map((node) => node.ref),
['e1', 'e2'],
);
const editText = roleMatches('edittext', ['android.widget.EditText']);
assert.equal(editText.score, 2);
});

test('find role= keeps the historical substring score for legacy prefixes', () => {
// Before #3021 the locator substring-matched the leaf (`statictext`.includes
// ('static')); the alias window must keep that ranking, at score 1, not
// promote it to an exact hit.
const partial = roleMatches('static', ['XCUIElementTypeStaticText']);
assert.equal(partial.score, 1);
assert.equal(partial.matches[0]?.ref, 'e1');
});

test('find role= substring-scores the leaf segment, never a dotted kind package path', () => {
// An unrecognized custom class keeps its full dotted lowercase as its kind.
// The retired locator substring-matched the LAST segment only, so a released
// `find role=fenix` refused `org.mozilla.fenix.ReaderView`; substringing the
// whole kind would silently pull such nodes into the best set. Exact-kind
// scoring still answers the full spelling when the query names it.
const type = 'org.mozilla.fenix.ReaderView';
assert.equal(roleMatches('fenix', [type]).score, 0);
assert.equal(roleMatches('readerview', [type]).score, 2); // windowed legacy leaf
assert.equal(roleMatches(type.toLowerCase(), [type]).score, 2); // the exact kind
});

test('find role= reaches AX-stripped queries through the exact window alias', () => {
// Pinned parity decision (#3021 finding): the retired selector TERM stripped
// a leading `AX` (`contracts` normalizeType) while the retired LOCATOR did
// not, so `find role link click` on a macOS-helper `AXLink` node scored 1
// there while `role=link` matched exactly. Adopting one shared authority
// promotes the locator's AX-stripped query to the exact score — the locator
// and the term cannot disagree about a node's role anymore.
const axNode = roleMatches('link', ['AXLink']);
assert.equal(axNode.score, 2);
// The prefix-kept spelling keeps its historical exact score.
assert.equal(roleMatches('axlink', ['AXLink']).score, 2);
});

test('find role= exact-scores whitespace-normalized spellings like the term does', () => {
// Same parity rule for the term's other normalization step: the selector
// term compared `normalizeText` on both sides, so `role="some widget"`
// always matched the class `Some Widget`; the retired locator scored that
// query 0. One shared authority means the locator agrees — exact score, not
// a substring rescue.
assert.equal(roleMatches('some widget', ['Some Widget']).score, 2);
});

test('find role= refuses sibling vocabulary words with no kind or alias match', () => {
// Nearest-negative: a kind one dash away and an unrelated kind score 0.
assert.equal(roleMatches('text-field', ['XCUIElementTypeStaticText']).score, 0);
assert.equal(roleMatches('switch', ['XCUIElementTypeStaticText']).score, 0);
});

test('find role= does not widen a legacy leaf onto kind siblings that never carried it', () => {
// The node-scoped window at the locator: `linearlayout` and `framelayout`
// share the coarse kind `group`, but the legacy leaf was the EXACT class
// leaf, and widening it onto the kind's other members would silently change
// what a released `find role linearlayout` click targets.
const both = roleMatches('linearlayout', [
'android.widget.LinearLayout',
'android.widget.FrameLayout',
]);
assert.equal(both.score, 2);
assert.deepEqual(
both.matches.map((node) => node.ref),
['e1'],
);
});

test('find role= and the role= selector term agree on every kind, alias, and negative', () => {
// Locator/matcher parity is the issue's both-paths-reachable proof: the same
// (query, type) pairs resolve through `resolveSelectorChain` exactly where
// the locator scored them exactly 2.
const cases: readonly [string, string][] = [
['text', 'XCUIElementTypeStaticText'],
['statictext', 'XCUIElementTypeStaticText'],
['textview', 'android.widget.TextView'],
['text-field', 'android.widget.EditText'],
['edittext', 'android.widget.EditText'],
['button', 'XCUIElementTypeButton'],
['imagebutton', 'XCUIElementTypeOther'], // kind of Other is `other`; must NOT match
['text-field', 'XCUIElementTypeStaticText'], // sibling kind: must NOT match
];
for (const [query, type] of cases) {
const nodes = [makeTypedNode('e1', type)];
const locatorScore = findBestMatchesByLocator(nodes, 'role', query).score;
const chain = parseSelectorChain(`role=${query}`);
const resolved = resolveSelectorChain(nodes, chain, {
platform: 'android',
requireUnique: true,
});
assert.equal(
Boolean(resolved),
locatorScore === 2,
`query "${query}" against type "${type}" diverged: locator score ${locatorScore}, selector ${resolved ? 'matched' : 'missed'}`,
);
}
});
49 changes: 35 additions & 14 deletions packages/selectors/src/internal/find.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { SnapshotNode } from '@agent-device/kernel/snapshot';
import { AppError } from '@agent-device/kernel/errors';
import { roleSpellingsOfNode } from './node.ts';
import { tryParseSelectorChain } from './parse.ts';

export const FIND_LOCATORS = ['any', 'text', 'label', 'value', 'role', 'id'] as const;
Expand Down Expand Up @@ -95,7 +96,7 @@ export function findBestMatchesByLocator(
function matchNode(node: SnapshotNode, locator: FindLocator, query: string): number {
switch (locator) {
case 'role':
return matchRole(node.type, query);
return matchRole(node, query);
case 'label':
return matchText(node.label, query);
case 'value':
Expand All @@ -121,24 +122,44 @@ function matchText(value: string | undefined, query: string): number {
return 0;
}

function matchRole(value: string | undefined, query: string): number {
const normalized = normalizeRole(value ?? '');
if (!normalized) return 0;
if (normalized === query) return 2;
if (normalized.includes(query)) return 1;
function matchRole(node: SnapshotNode, query: string): number {
// The package's one role-spelling reader (#3021, `node.ts`): the node's
// canonical `kind` first, then its windowed legacy spelling. Exact scoring
// is against the whole spellings; substring scoring stays on the leaf
// segment because the retired locator substring-matched a last-`.`-segment
// leaf, and an unrecognized dotted class keeps its package path in its
// `kind` — substringing the full kind would let `find role=fenix` score
// `org.mozilla.fenix.ReaderView` where the released query refused it.
const spellings = roleSpellingsOfNode(node);
// The query arrives `normalizeText`-ed, so the exact compare normalizes the
// spellings the same way the selector term's `textEquals` does — that is the
// locator/term agreement #3021 demands for whitespace-bearing class names.
// Substring scoring stays on the raw spelling (the retired locator's leaf).
if (spellings.some((spelling) => normalizeText(spelling) === query)) return 2;
if (spellings.some((spelling) => roleLeafSegment(spelling).includes(query))) return 1;
return 0;
}

export function normalizeText(value: string): string {
return value.trim().toLowerCase().replaceAll(/\s+/g, ' ');
/**
* The substring-scoring view of one role spelling: its last `.`-separated
* segment. This is deliberately the RETIRED LOCATOR's leaf (`split('.').pop()`
* only), not the retired term spelling (`normalizeType`) — the term also
* reduced a trailing `/` segment, and mirroring that here would drop substring
* hits the released locator kept (query `y/z` still scores 1 against kind
* `x.y/z` today). The term's `/`-reduced spelling rides as the windowed exact
* alias instead, so term/locator agreement comes from the exact side, and each
* surface's historical substring meaning is preserved. An AX-stripped query
* (`link` against an `AXLink` node) likewise reaches the exact score through
* the windowed alias, not through substring — the retired term already matched
* that spelling exactly, so promoting it here is the parity #3021 requires.
*/
function roleLeafSegment(spelling: string): string {
const lastDot = spelling.lastIndexOf('.');
return lastDot === -1 ? spelling : spelling.slice(lastDot + 1);
Comment thread
thymikee marked this conversation as resolved.
}

function normalizeRole(value: string): string {
let normalized = value.trim();
if (!normalized) return '';
const lastSegment = normalized.split('.').pop() ?? normalized;
normalized = lastSegment.replaceAll(/XCUIElementType/gi, '').toLowerCase();
return normalized;
export function normalizeText(value: string): string {
return value.trim().toLowerCase().replaceAll(/\s+/g, ' ');
}

export type ParsedFindArgs = {
Expand Down
115 changes: 115 additions & 0 deletions packages/selectors/src/internal/match.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,121 @@ test('role selector normalization matches Android class names by leaf type', ()
assert.equal(resolved.node.ref, 'a1');
});

// ── #3021: `role=` shares `kind`'s vocabulary, with a legacy-alias window ──

// One tree covering the three vocabulary decisions at once: the coarse kind
// `kind` publishes, the legacy leaf still windowed for it, and a sibling
// vocabulary word that must match NOTHING here.
const ROLE_VOCABULARY_NODES: SnapshotState['nodes'] = [
{
ref: 'text1',
index: 0,
type: 'XCUIElementTypeStaticText',
kind: formatRole('XCUIElementTypeStaticText'),
label: 'Total',
rect: { x: 0, y: 0, width: 120, height: 20 },
hittable: true,
},
{
ref: 'field1',
index: 1,
type: 'android.widget.EditText',
kind: formatRole('android.widget.EditText'),
label: 'Email',
rect: { x: 0, y: 30, width: 120, height: 44 },
hittable: true,
},
{
ref: 'button1',
index: 2,
type: 'XCUIElementTypeButton',
kind: formatRole('XCUIElementTypeButton'),
label: 'Continue',
rect: { x: 0, y: 90, width: 120, height: 44 },
hittable: true,
},
];

function resolveRole(role: string, platform: 'ios' | 'android' = 'ios') {
const chain = parseSelectorChain(`role=${role}`);
return resolveSelectorChain(ROLE_VOCABULARY_NODES, chain, {
platform,
requireRect: true,
requireUnique: true,
});
}

test('role= matches the coarse kind vocabulary: kind=text and kind=text-field resolve', () => {
// The migration's forward direction: these DID NOT match before #3021,
// because the matcher saw the raw leaf (`statictext`, `edittext`).
assert.equal(resolveRole('text')?.node.ref, 'text1');
assert.equal(resolveRole('text-field', 'android')?.node.ref, 'field1');
});

test('role= keeps matching legacy leaf spellings during the alias window', () => {
// The compatibility direction: released scripts write these; they must keep
// resolving to the same nodes they resolved to before #3021.
assert.equal(resolveRole('statictext')?.node.ref, 'text1');
assert.equal(resolveRole('edittext', 'android')?.node.ref, 'field1');
assert.equal(resolveRole('button')?.node.ref, 'button1');
// The window is the node's OWN old spelling: `textfield` was the old leaf of
// the iOS `TextField` class, never of `EditText`, and must not widen onto it.
assert.equal(resolveRole('textfield', 'android'), null);
});

test('role= refuses a sibling vocabulary word that matches no kind or windowed alias', () => {
// Nearest-negatives inside the same vocabulary: `text-view` is one dash from
// `text`, and `switch`/`searchfield` are vocabulary entries (a kind and a
// leaf windowed under kind `search`) that no node here carries.
assert.equal(resolveRole('text-view'), null);
assert.equal(resolveRole('switch'), null);
assert.equal(resolveRole('searchfield'), null);
});

test('role= does not widen a legacy leaf onto kind siblings that never carried it', () => {
// The window is the node's OWN old spelling. `linearlayout` and
// `framelayout` share the coarse kind `group`; matching the leaf may not
// pull in the kind's other members, or every released `role=linearlayout`
// script would silently start hitting FrameLayout rows.
const layoutNodes: SnapshotState['nodes'] = [
'android.widget.LinearLayout',
'android.widget.FrameLayout',
].map((type, idx) => ({
ref: `row${idx + 1}`,
index: idx,
type,
kind: formatRole(type),
rect: { x: 0, y: idx * 50, width: 300, height: 48 },
hittable: true,
}));
const chain = parseSelectorChain('role=linearlayout');
const resolved = resolveSelectorChain(layoutNodes, chain, {
platform: 'android',
requireRect: true,
requireUnique: true,
});
assert.ok(resolved);
assert.equal(resolved.node.ref, 'row1');
});

test('role= agrees with the published kind for every node it matches (#3021 parity)', () => {
// The architecture pin: for every node in the tree, matching by that node's
// own published `kind` must resolve to exactly that node. This is what makes
// the selector/kind divergence that #3021 closes impossible to reintroduce
// silently for any vocabulary entry.
for (const node of ROLE_VOCABULARY_NODES) {
assert.ok(node.kind, 'fixture nodes carry a published kind');
const chain = parseSelectorChain(`role=${JSON.stringify(node.kind)}`);
const resolved = resolveSelectorChain(ROLE_VOCABULARY_NODES, chain, {
platform: 'ios',
requireRect: true,
requireUnique: false,
});
assert.ok(resolved, `role=${node.kind} resolved nothing`);
assert.equal(resolved.node.ref, node.ref, `role=${node.kind} resolved the wrong node`);
}
});

test('focused selector matches snapshot focus state', () => {
const tvNodes: SnapshotState['nodes'] = [
{
Expand Down
19 changes: 16 additions & 3 deletions packages/selectors/src/internal/match.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Platform, PublicPlatform } from '@agent-device/kernel/device';
import type { SnapshotNode } from '@agent-device/kernel/snapshot';
import { isNodeEditable, isNodeVisible } from './node.ts';
import { extractNodeText, normalizeType } from '@agent-device/contracts/snapshot';
import { isNodeEditable, isNodeVisible, roleSpellingsOfNode } from './node.ts';
import { extractNodeText } from '@agent-device/contracts/snapshot';
import { normalizeText } from './find.ts';
import type { Selector, SelectorTerm } from './parse.ts';

Expand All @@ -22,7 +22,7 @@ function matchesTerm(
case 'id':
return textEquals(node.identifier, String(term.value));
case 'role':
return textEquals(normalizeType(node.type ?? ''), String(term.value));
return matchesRoleKind(node, String(term.value));
case 'label':
return textEquals(node.label, String(term.value));
case 'value':
Expand Down Expand Up @@ -55,3 +55,16 @@ function matchesTerm(
function textEquals(value: string | undefined, query: string): boolean {
return normalizeText(value ?? '') === normalizeText(query);
}

/**
* `role=` matches the canonical `kind` vocabulary (#3021): a term value equals
* the node's role kind, or one of the legacy spellings still windowed for that
* node. Both come from {@link roleSpellingsOfNode} — the same `kind`
* `attachRefs` publishes and the same `normalizeType` that RECORDS selector
* chains — so the selector, the `find role=` locator, snapshot output, and
* recorded scripts cannot disagree about a node's role.
*/
function matchesRoleKind(node: SnapshotNode, query: string): boolean {
const normalizedQuery = normalizeText(query);
return roleSpellingsOfNode(node).some((spelling) => normalizeText(spelling) === normalizedQuery);
}
Loading
Loading