mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
feat(spec): reject an unknown object-selector key in the web runtime
Same rule and the same message as the native side: a key no element can carry throws instead of matching nothing. The accepted list is one list, committed as a fixture both suites assert, so a spec cannot be accepted by one runtime and rejected by the other.
This commit is contained in:
1 parent
1332471b72
commit
269574706d
5 files changed
+239
-10
No files matched your search
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+100
-7
@@ -94,6 +94,7 @@ const KNOWN_KEY_TO_CSS: Record<string, (value: string) => string> = {
|
|||||||
// The native rule also accepts the local name after Android's "<package>:id/".
|
// 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.
|
// The DOM has no such prefix, so a plain starts-with is the same rule here.
|
||||||
idPrefix: (v) => `[id^="${cssEscape(v)}"]`,
|
idPrefix: (v) => `[id^="${cssEscape(v)}"]`,
|
||||||
|
desc: (v) => `[aria-label="${cssEscape(v)}"]`,
|
||||||
descPrefix: (v) => `[aria-label^="${cssEscape(v)}"]`,
|
descPrefix: (v) => `[aria-label^="${cssEscape(v)}"]`,
|
||||||
// The native table aliases testTag onto resource-id, which the host DOM walk
|
// 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
|
// 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;
|
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<string, string | boolean | undefined>): {
|
function selectorFromObject(selector: Record<string, string | boolean | undefined>): {
|
||||||
css?: string;
|
css?: string;
|
||||||
xpath?: string;
|
xpath?: string;
|
||||||
} {
|
} {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
let textValue: string | undefined;
|
let textValue: string | undefined;
|
||||||
|
const unknown: string[] = [];
|
||||||
for (const key of Object.keys(selector)) {
|
for (const key of Object.keys(selector)) {
|
||||||
const raw = selector[key];
|
const raw = selector[key];
|
||||||
if (raw === undefined) continue;
|
if (raw === undefined) continue;
|
||||||
const value = typeof raw === "boolean" ? String(raw) : raw;
|
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") {
|
if (key === "text") {
|
||||||
textValue = value;
|
textValue = value;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const builder = KNOWN_KEY_TO_CSS[key];
|
parts.push(cssPart(key, value));
|
||||||
if (builder) {
|
}
|
||||||
parts.push(builder(value));
|
if (unknown.length > 0) {
|
||||||
} else {
|
throw new Error(unknownSelectorKeyMessage(unknown));
|
||||||
parts.push(`[${key}="${cssEscape(value)}"]`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (textValue !== undefined && parts.length === 0) {
|
if (textValue !== undefined && parts.length === 0) {
|
||||||
return {
|
return {
|
||||||
@@ -184,7 +272,11 @@ function selectorFromString(selector: string): { css?: string; xpath?: string }
|
|||||||
if (kind === "text") {
|
if (kind === "text") {
|
||||||
return { xpath: `//*[normalize-space(text())=${xpathStringLiteral(value)}]` };
|
return { xpath: `//*[normalize-space(text())=${xpathStringLiteral(value)}]` };
|
||||||
}
|
}
|
||||||
return selectorFromObject({ [kind]: value });
|
// The string form's kind space stays open: "<attr>:<value>" 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(
|
function queryElement(
|
||||||
@@ -601,6 +693,7 @@ export const __testing__ = {
|
|||||||
evaluateExtractors,
|
evaluateExtractors,
|
||||||
selectorFromString,
|
selectorFromString,
|
||||||
selectorFromObject,
|
selectorFromObject,
|
||||||
|
SELECTOR_KEYS,
|
||||||
xpathStringLiteral,
|
xpathStringLiteral,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+38
@@ -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"
|
||||||
|
]
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -357,12 +357,50 @@ test("selectorFromObject composes idPrefix with a second key", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("selectorFromObject falls back to a literal attribute for unknown keys", () => {
|
// A key nothing can carry yields no match, which reads exactly like a screen
|
||||||
assert.deepEqual(selectorFromObject({ "data-foo": "bar" }), {
|
// with no such element: the generator declines to act, the runner waits out the
|
||||||
css: `[data-foo="bar"]`,
|
// 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: "<attr>:<value>" 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<string, unknown>;
|
||||||
|
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", () => {
|
test("selectorFromObject text-only selector becomes an XPath", () => {
|
||||||
assert.deepEqual(selectorFromObject({ text: "Go" }), {
|
assert.deepEqual(selectorFromObject({ text: "Go" }), {
|
||||||
xpath: `//*[normalize-space(text())="Go"]`,
|
xpath: `//*[normalize-space(text())="Go"]`,
|
||||||
|
|||||||
Reference in new issue
Block a user