feat(spec): match idPrefix in the web runtime

The DOM has no package prefix, so the native rule reduces to [id^=]. Both
prefix kinds now go through the one key table, which drops the separate
descPrefix branch that string and object selectors each carried.
This commit is contained in:
pj committed 2026-08-13 00:02:21 +05:30
1 parent 59975231ce
commit a8444cc34f
3 files changed
+49 -13

No files matched your search

+15 -2
View File
@@ -44,14 +44,27 @@ export interface KnownAttrSelectors {
editable?: boolean;
}
/**
* Keys that name a matching rule rather than an attribute a driver reports.
* They belong to the selector surface only, which is why they are not part of
* `KnownAttrSelectors` (and so never appear in `RawAttrs`).
*/
export interface PrefixSelectors {
/** Identifier starts with this, after Android's "<package>:id/" if present. */
idPrefix?: string;
/** Accessibility description starts with this. */
descPrefix?: string;
}
/**
* Object-form selector for `find` / `findAll`. Known attributes are typed
* via `KnownAttrSelectors`; arbitrary string keys are still allowed for
* raw driver attributes the typed surface doesn't yet cover.
*/
export type AttrSelector = KnownAttrSelectors & {
export type AttrSelector = KnownAttrSelectors &
PrefixSelectors & {
[key: string]: string | boolean | undefined;
};
};
export type SelectorPath = readonly AttrSelector[];
+4 -11
View File
@@ -85,6 +85,10 @@ function noopFormula(): unknown {
const KNOWN_KEY_TO_CSS: Record<string, (value: string) => string> = {
id: (v) => `[id="${cssEscape(v)}"]`,
"resource-id": (v) => `[id="${cssEscape(v)}"]`,
// The native rule also accepts the local name after Android's "<package>:id/".
// The DOM has no such prefix, so a plain starts-with is the same rule here.
idPrefix: (v) => `[id^="${cssEscape(v)}"]`,
descPrefix: (v) => `[aria-label^="${cssEscape(v)}"]`,
// The native table aliases testTag onto resource-id, which the host DOM walk
// fills from el.id, so the native path already accepts a testTag emitted as
// an id (what Compose Multiplatform does on web). Accept both here so the
@@ -131,7 +135,6 @@ function selectorFromObject(selector: Record<string, string | boolean | undefine
} {
const parts: string[] = [];
let textValue: string | undefined;
let descPrefix: string | undefined;
for (const key of Object.keys(selector)) {
const raw = selector[key];
if (raw === undefined) continue;
@@ -140,10 +143,6 @@ function selectorFromObject(selector: Record<string, string | boolean | undefine
textValue = value;
continue;
}
if (key === "descPrefix") {
descPrefix = value;
continue;
}
const builder = KNOWN_KEY_TO_CSS[key];
if (builder) {
parts.push(builder(value));
@@ -151,9 +150,6 @@ function selectorFromObject(selector: Record<string, string | boolean | undefine
parts.push(`[${key}="${cssEscape(value)}"]`);
}
}
if (descPrefix !== undefined) {
parts.push(`[aria-label^="${cssEscape(descPrefix)}"]`);
}
if (textValue !== undefined && parts.length === 0) {
return {
xpath: `//*[normalize-space(text())=${xpathStringLiteral(textValue)}]`,
@@ -182,9 +178,6 @@ function selectorFromString(selector: string): { css?: string; xpath?: string }
if (kind === "text") {
return { xpath: `//*[normalize-space(text())=${xpathStringLiteral(value)}]` };
}
if (kind === "descPrefix") {
return { css: `[aria-label^="${cssEscape(value)}"]` };
}
return selectorFromObject({ [kind]: value });
}
+30
View File
@@ -327,6 +327,36 @@ test("selectorFromObject composes testTag with a second key", () => {
});
});
// A list whose rows are named <role>_<record id> is only reachable by the role
// half. internal/driver/chrome/translate.go builds the same CSS for the same
// selector, and internal/hierarchy resolves it against the dump of this page.
test("selectorFromString routes idPrefix to a starts-with id match", () => {
assert.deepEqual(selectorFromString("idPrefix:customer_row_"), {
css: `[id^="customer_row_"]`,
});
});
test("selectorFromObject routes idPrefix to a starts-with id match", () => {
assert.deepEqual(selectorFromObject({ idPrefix: "customer_row_" }), {
css: `[id^="customer_row_"]`,
});
});
test("selectorFromString and selectorFromObject agree on descPrefix", () => {
assert.deepEqual(selectorFromString("descPrefix:account:"), {
css: `[aria-label^="account\\:"]`,
});
assert.deepEqual(selectorFromObject({ descPrefix: "account:" }), {
css: `[aria-label^="account\\:"]`,
});
});
test("selectorFromObject composes idPrefix with a second key", () => {
assert.deepEqual(selectorFromObject({ idPrefix: "customer_row_", "aria-label": "first" }), {
css: `[id^="customer_row_"][aria-label="first"]`,
});
});
test("selectorFromObject falls back to a literal attribute for unknown keys", () => {
assert.deepEqual(selectorFromObject({ "data-foo": "bar" }), {
css: `[data-foo="bar"]`,