From a8444cc34f95700b077b6cba41d10d1e0b786084 Mon Sep 17 00:00:00 2001 From: PJ Date: Thu, 13 Aug 2026 00:02:21 +0530 Subject: [PATCH] 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. --- pkg/spec/src/types.ts | 19 ++++++++++++++++--- pkg/spec/src/web-runtime.ts | 15 ++++----------- pkg/spec/test/web-runtime.test.ts | 30 ++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 14 deletions(-) diff --git a/pkg/spec/src/types.ts b/pkg/spec/src/types.ts index f748d83..66394a6 100644 --- a/pkg/spec/src/types.ts +++ b/pkg/spec/src/types.ts @@ -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 ":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 & { - [key: string]: string | boolean | undefined; -}; +export type AttrSelector = KnownAttrSelectors & + PrefixSelectors & { + [key: string]: string | boolean | undefined; + }; export type SelectorPath = readonly AttrSelector[]; diff --git a/pkg/spec/src/web-runtime.ts b/pkg/spec/src/web-runtime.ts index 40e18b8..14f4b64 100644 --- a/pkg/spec/src/web-runtime.ts +++ b/pkg/spec/src/web-runtime.ts @@ -85,6 +85,10 @@ function noopFormula(): unknown { const KNOWN_KEY_TO_CSS: Record string> = { id: (v) => `[id="${cssEscape(v)}"]`, "resource-id": (v) => `[id="${cssEscape(v)}"]`, + // The native rule also accepts the local name after Android's ":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 { }); }); +// A list whose rows are named _ 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"]`,