diff --git a/internal/hierarchy/selector_keys_test.go b/internal/hierarchy/selector_keys_test.go new file mode 100644 index 0000000..c0908ef --- /dev/null +++ b/internal/hierarchy/selector_keys_test.go @@ -0,0 +1,39 @@ +package hierarchy + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "testing" +) + +// Both runtimes reject an object-selector key they do not know, so the two key +// lists have to be one list. Were they to drift, a spec would be accepted by +// the runtime that lists the key and fail the run on the one that does not, and +// the difference would only show on the platform nobody ran first. +// pkg/spec/test/selector-keys.test.ts asserts the SAME file from the web side. +func TestSelectorKeysMatchTheCrossRuntimeList(t *testing.T) { + path, err := filepath.Abs("../../pkg/spec/test/fixtures/selector-keys.json") + if err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read selector keys: %v", err) + } + var want []string + if err := json.Unmarshal(body, &want); err != nil { + t.Fatalf("decode selector keys: %v", err) + } + if got := SelectorKeys(); !slices.Equal(got, want) { + t.Errorf("native key list\n got=%v\nwant=%v", got, want) + } +} + +func TestSelectorKeysAreSorted(t *testing.T) { + keys := SelectorKeys() + if !slices.IsSorted(keys) { + t.Errorf("keys must stay sorted so the two lists compare readably: %v", keys) + } +} diff --git a/pkg/spec/src/web-runtime.ts b/pkg/spec/src/web-runtime.ts index 0072926..e088a58 100644 --- a/pkg/spec/src/web-runtime.ts +++ b/pkg/spec/src/web-runtime.ts @@ -94,6 +94,7 @@ const KNOWN_KEY_TO_CSS: Record string> = { // 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)}"]`, + desc: (v) => `[aria-label="${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 @@ -135,26 +136,113 @@ function tagSelector(value: string): string { return value; } +// SELECTOR_KEYS is every key an object selector may use, held identical to the +// native list in internal/hierarchy: test/selector-keys.test.ts and +// internal/hierarchy/selector_keys_test.go each assert their own side against +// test/fixtures/selector-keys.json, so a spec cannot be accepted by one runtime +// and rejected by the other. Keys that mean nothing to a DOM (scrollable, +// package, elementType) stay accepted and simply match nothing here, the way an +// iOS-only key matches nothing on Android. +const SELECTOR_KEYS: readonly string[] = [ + "accessibilityIdentifier", + "accessibilityLabel", + "accessibilityText", + "aria-label", + "ariaLabel", + "bounds", + "checked", + "class", + "className", + "clickable", + "content-desc", + "contentDescription", + "data-testid", + "desc", + "descPrefix", + "editable", + "elementType", + "enabled", + "focused", + "hintText", + "id", + "idPrefix", + "identifier", + "label", + "package", + "placeholder", + "placeholderValue", + "resource-id", + "scrollable", + "selected", + "tag", + "testID", + "testTag", + "text", + "title", + "value", +]; + +const SELECTOR_KEY_SET = new Set(SELECTOR_KEYS); + +const ATTRIBUTE_NAME = /^[a-zA-Z][a-zA-Z0-9_.:-]*$/; + +// domCarriesAttribute is the escape hatch for attributes this list does not +// enumerate: a key some element actually has is a key that can match. A key +// that is not even a legal attribute name can carry no value and would inject +// into the surrounding selector, so it is rejected rather than probed. +function domCarriesAttribute(key: string): boolean { + if (!ATTRIBUTE_NAME.test(key)) return false; + try { + return document.querySelector(`[${key}]`) !== null; + } catch { + return false; + } +} + +// unknownSelectorKeyMessage is character for character what +// hierarchy.UnknownSelectorKeyMessage produces, so one mistake reads the same +// whichever runtime the spec ran on. +function unknownSelectorKeyMessage(keys: string[]): string { + const named = keys.map((key) => JSON.stringify(key)).join(", "); + return ( + `selector key ${named} cannot match: no element carries that attribute, ` + + `and it is not one of the accepted keys: ${SELECTOR_KEYS.join(", ")}` + ); +} + +function cssPart(key: string, value: string): string { + const builder = KNOWN_KEY_TO_CSS[key]; + if (builder) return builder(value); + return `[${key}="${cssEscape(value)}"]`; +} + +// A selector key that can never match yields an empty result, which reads +// exactly like a screen with no such element: the generator declines to act, +// the runner waits out the step, and the run ends clean having explored +// nothing. Throwing is what makes the mistake visible. function selectorFromObject(selector: Record): { css?: string; xpath?: string; } { const parts: string[] = []; let textValue: string | undefined; + const unknown: string[] = []; for (const key of Object.keys(selector)) { const raw = selector[key]; if (raw === undefined) continue; const value = typeof raw === "boolean" ? String(raw) : raw; + if (!SELECTOR_KEY_SET.has(key) && !domCarriesAttribute(key)) { + if (!unknown.includes(key)) unknown.push(key); + continue; + } if (key === "text") { textValue = value; continue; } - const builder = KNOWN_KEY_TO_CSS[key]; - if (builder) { - parts.push(builder(value)); - } else { - parts.push(`[${key}="${cssEscape(value)}"]`); - } + parts.push(cssPart(key, value)); + } + if (unknown.length > 0) { + throw new Error(unknownSelectorKeyMessage(unknown)); } if (textValue !== undefined && parts.length === 0) { return { @@ -184,7 +272,11 @@ function selectorFromString(selector: string): { css?: string; xpath?: string } if (kind === "text") { return { xpath: `//*[normalize-space(text())=${xpathStringLiteral(value)}]` }; } - return selectorFromObject({ [kind]: value }); + // The string form's kind space stays open: ":" is the documented + // way to reach a raw driver attribute, and internal/hierarchy resolves an + // unknown kind to an empty result rather than an error. Only the object form + // validates, on both sides. + return { css: cssPart(kind, value) }; } function queryElement( @@ -601,6 +693,7 @@ export const __testing__ = { evaluateExtractors, selectorFromString, selectorFromObject, + SELECTOR_KEYS, xpathStringLiteral, }; diff --git a/pkg/spec/test/fixtures/selector-keys.json b/pkg/spec/test/fixtures/selector-keys.json new file mode 100644 index 0000000..57886b1 --- /dev/null +++ b/pkg/spec/test/fixtures/selector-keys.json @@ -0,0 +1,38 @@ +[ + "accessibilityIdentifier", + "accessibilityLabel", + "accessibilityText", + "aria-label", + "ariaLabel", + "bounds", + "checked", + "class", + "className", + "clickable", + "content-desc", + "contentDescription", + "data-testid", + "desc", + "descPrefix", + "editable", + "elementType", + "enabled", + "focused", + "hintText", + "id", + "idPrefix", + "identifier", + "label", + "package", + "placeholder", + "placeholderValue", + "resource-id", + "scrollable", + "selected", + "tag", + "testID", + "testTag", + "text", + "title", + "value" +] diff --git a/pkg/spec/test/selector-keys.test.ts b/pkg/spec/test/selector-keys.test.ts new file mode 100644 index 0000000..715292e --- /dev/null +++ b/pkg/spec/test/selector-keys.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; + +import { __testing__ } from "../src/web-runtime.ts"; + +const { SELECTOR_KEYS } = __testing__; + +const goldenPath = fileURLToPath(new URL("./fixtures/selector-keys.json", import.meta.url)); +const golden: string[] = JSON.parse(readFileSync(goldenPath, "utf8")); + +// Both runtimes reject an object-selector key they do not know, so the two key +// lists have to be one list. Were they to drift, a spec would be accepted by +// the runtime that lists the key and fail the run on the one that does not, and +// the difference would only show on the platform nobody ran first. +// internal/hierarchy/selector_keys_test.go asserts the SAME file from the +// native side. +test("the web key list is the cross-runtime list", () => { + assert.deepEqual([...SELECTOR_KEYS], golden); +}); diff --git a/pkg/spec/test/web-runtime.test.ts b/pkg/spec/test/web-runtime.test.ts index 7b827cc..831152c 100644 --- a/pkg/spec/test/web-runtime.test.ts +++ b/pkg/spec/test/web-runtime.test.ts @@ -357,12 +357,50 @@ test("selectorFromObject composes idPrefix with a second key", () => { }); }); -test("selectorFromObject falls back to a literal attribute for unknown keys", () => { - assert.deepEqual(selectorFromObject({ "data-foo": "bar" }), { - css: `[data-foo="bar"]`, +// A key nothing can carry yields no match, which reads exactly like a screen +// with no such element: the generator declines to act, the runner waits out the +// step, and the run ends clean having explored nothing. +test("selectorFromObject rejects a key no element can carry", () => { + assert.throws( + () => selectorFromObject({ descripton: "Supplier" }), + (error: Error) => + error.message.includes('"descripton"') && error.message.includes("accepted keys"), + ); +}); + +// Raw attributes the key list does not enumerate stay reachable when the page +// actually carries them. +test("selectorFromObject accepts a raw attribute the page carries", () => { + withDocumentCarrying(["data-foo"], () => { + assert.deepEqual(selectorFromObject({ "data-foo": "bar" }), { + css: `[data-foo="bar"]`, + }); }); }); +// The string form's kind space stays open on both sides: ":" is +// the documented way to reach a raw driver attribute, and internal/hierarchy +// resolves an unknown kind to an empty result rather than an error. +test("selectorFromString accepts a kind the object form would reject", () => { + assert.deepEqual(selectorFromString("descripton:Supplier"), { + css: `[descripton="Supplier"]`, + }); +}); + +function withDocumentCarrying(attributes: string[], run: () => void): void { + const global = globalThis as Record; + const original = global.document; + global.document = { + querySelector: (selector: string) => + attributes.some((name) => selector === `[${name}]`) ? {} : null, + }; + try { + run(); + } finally { + global.document = original; + } +} + test("selectorFromObject text-only selector becomes an XPath", () => { assert.deepEqual(selectorFromObject({ text: "Go" }), { xpath: `//*[normalize-space(text())="Go"]`,