fix(spec): a boolean state selector names what the live element reports

clickable, enabled, focused, checked and selected are derived from the
element rather than written by the markup, so matching them as raw
attributes built [clickable="true"] and reached nothing: the keys are
accepted, no unknown-key error fires, and the worked example in
docs/manual/spec-language.md found no element on web and passed having
checked nothing.

Each key is answered by the same function elementHandle derives the fact
with, since no CSS says what any of them says: :focus names the shadow
host of a focused field as well, :checked misses a checked custom
element and answers for a selected option besides, and [checked] is the
state the page loaded with rather than the one the user left it in.
This commit is contained in:
pj committed 2026-08-18 20:43:09 +05:30
1 parent 4bcd82a8a6
commit f7135b81d8
3 files changed
+212 -47

No files matched your search

+113 -47
View File
@@ -143,6 +143,49 @@ function secureSelector(value: string): string {
return `:is(input${textInput}, textarea, [contenteditable]:not([contenteditable="false"]))`; return `:is(input${textInput}, textarea, [contenteditable]:not([contenteditable="false"]))`;
} }
// The other five boolean states are derived from the live element too, and
// matching them as an attribute reached nothing at all: `[clickable="true"]` is
// a match no page carries, the key is accepted so no unknown-key error fires,
// and a spec naming a control that way, as the worked example in
// docs/manual/spec-language.md does, finds none and passes having checked
// nothing. They are answered against the element rather than compiled into CSS
// because no CSS says what any of them says: `:focus` names the shadow HOST of a
// focused field as well, and never the field Compose keeps behind its caret,
// `:checked` misses a checked custom element and answers for a selected <option>
// besides, and `[checked]` is the state the page loaded with rather than the one
// the user left it in. Each reads the SAME function elementHandle derives the
// fact with, so a selector cannot name an element this host calls something else.
const KNOWN_KEY_TO_STATE: Record<
string,
(value: string) => (element: Element) => boolean
> = {
clickable: stateMatcher(isClickable),
enabled: stateMatcher(isEnabled),
focused: focusedMatcher,
checked: stateMatcher(isChecked),
selected: stateMatcher(isSelected),
};
// A value that is neither true nor false can match nothing, the way a CSS part
// built from one resolves to `:not(*)`.
function stateMatcher(
fact: (element: Element) => boolean,
): (value: string) => (element: Element) => boolean {
return (value) => {
if (value !== "true" && value !== "false") return () => false;
const wanted = value === "true";
return (element) => fact(element) === wanted;
};
}
// The focused element is resolved once per selector rather than once per
// element: finding it descends every shadow root and, on a Compose page, sweeps
// the editable fields for the box the caret sits in.
function focusedMatcher(value: string): (element: Element) => boolean {
const focusedElement = deepestActiveElement();
return stateMatcher((element) => element === focusedElement)(value);
}
// cssEscape delegates to the platform CSS.escape (per CSSOM spec). It produces // cssEscape delegates to the platform CSS.escape (per CSSOM spec). It produces
// output safe for both identifier and string contexts, since CSS string // output safe for both identifier and string contexts, since CSS string
// literals accept the same `\HEX ` and `\X` escape sequences as identifiers. // literals accept the same `\HEX ` and `\X` escape sequences as identifiers.
@@ -247,15 +290,24 @@ function cssPart(key: string, value: string): string {
return `[${key}${operator}"${cssEscape(value)}"]`; return `[${key}${operator}"${cssEscape(value)}"]`;
} }
// A compiled selector is what a lookup resolves: the CSS or XPath a document
// query takes, and the states no query can express, which the elements it
// answered with are held against afterwards.
interface CompiledSelector {
css?: string;
xpath?: string;
match?: (element: Element) => boolean;
}
// A selector key that can never match yields an empty result, which reads // 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, // 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 // the runner waits out the step, and the run ends clean having explored
// nothing. Throwing is what makes the mistake visible. // nothing. Throwing is what makes the mistake visible.
function selectorFromObject(selector: Record<string, string | boolean | undefined>): { function selectorFromObject(
css?: string; selector: Record<string, string | boolean | undefined>,
xpath?: string; ): CompiledSelector {
} {
const parts: string[] = []; const parts: string[] = [];
const stateMatchers: Array<(element: Element) => boolean> = [];
let textValue: string | undefined; let textValue: string | undefined;
const unknown: string[] = []; const unknown: string[] = [];
for (const key of Object.keys(selector)) { for (const key of Object.keys(selector)) {
@@ -270,15 +322,31 @@ function selectorFromObject(selector: Record<string, string | boolean | undefine
textValue = value; textValue = value;
continue; continue;
} }
const state = KNOWN_KEY_TO_STATE[key];
if (state) {
stateMatchers.push(state(value));
continue;
}
parts.push(cssPart(key, value)); parts.push(cssPart(key, value));
} }
if (unknown.length > 0) { if (unknown.length > 0) {
throw new Error(unknownSelectorKeyMessage(unknown)); throw new Error(unknownSelectorKeyMessage(unknown));
} }
if (textValue !== undefined && parts.length === 0) { if (textValue !== undefined && parts.length === 0) {
return { xpath: innermostTextXPath(textValue) }; return withStates({ xpath: innermostTextXPath(textValue) }, stateMatchers);
} }
return { css: parts.join("") }; // A selector that names states alone still has to name a document query to
// hold them against, and every element is what it asks about.
const css = parts.length === 0 && stateMatchers.length > 0 ? "*" : parts.join("");
return withStates({ css }, stateMatchers);
}
function withStates(
compiled: CompiledSelector,
stateMatchers: Array<(element: Element) => boolean>,
): CompiledSelector {
if (stateMatchers.length === 0) return compiled;
return { ...compiled, match: (element) => stateMatchers.every((state) => state(element)) };
} }
// innermostTextXPath matches an element whose text contains value and whose // innermostTextXPath matches an element whose text contains value and whose
@@ -301,7 +369,7 @@ function xpathStringLiteral(value: string): string {
return `concat(${parts.map((p) => `"${p}"`).join(`, '"', `)})`; return `concat(${parts.map((p) => `"${p}"`).join(`, '"', `)})`;
} }
function selectorFromString(selector: string): { css?: string; xpath?: string } { function selectorFromString(selector: string): CompiledSelector {
const colon = selector.indexOf(":"); const colon = selector.indexOf(":");
if (colon <= 0) { if (colon <= 0) {
return { css: selector }; return { css: selector };
@@ -317,6 +385,8 @@ function selectorFromString(selector: string): { css?: string; xpath?: string }
if (kind === "text") { if (kind === "text") {
return { xpath: innermostTextXPath(value) }; return { xpath: innermostTextXPath(value) };
} }
const state = KNOWN_KEY_TO_STATE[kind];
if (state) return { css: "*", match: state(value) };
// The string form's kind space stays open: "<attr>:<value>" is the documented // 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 // 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 // unknown kind to an empty result rather than an error. Only the object form
@@ -348,24 +418,22 @@ function deepQueryAll(selector: string, root: ParentNode): Element[] {
return found; return found;
} }
// matchedElements resolves one compiled selector: the document query first, then
// the states it named, which no query can express.
function matchedElements(root: ParentNode, compiled: CompiledSelector): Element[] {
const { css, xpath, match } = compiled;
let found: Element[] = [];
if (css) found = deepQueryAll(css, root);
else if (xpath) found = evaluateXPathAll(xpath, root as Node);
return match === undefined ? found : found.filter(match);
}
function queryElement( function queryElement(
root: ParentNode, root: ParentNode,
selector: unknown, selector: unknown,
): Element | null { ): Element | null {
if (typeof selector === "string") { if (typeof selector === "string") {
const { css, xpath } = selectorFromString(selector); return matchedElements(root, selectorFromString(selector))[0] ?? null;
if (css) return deepQueryAll(css, root)[0] ?? null;
if (xpath) {
const result = document.evaluate(
xpath,
root as Node,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null,
);
return result.singleNodeValue as Element | null;
}
return null;
} }
if (Array.isArray(selector)) { if (Array.isArray(selector)) {
let node: ParentNode | null = root; let node: ParentNode | null = root;
@@ -378,28 +446,15 @@ function queryElement(
return node as Element; return node as Element;
} }
if (selector && typeof selector === "object") { if (selector && typeof selector === "object") {
const { css, xpath } = selectorFromObject(selector as Record<string, string | boolean | undefined>); const compiled = selectorFromObject(selector as Record<string, string | boolean | undefined>);
if (css) return deepQueryAll(css, root)[0] ?? null; return matchedElements(root, compiled)[0] ?? null;
if (xpath) {
const result = document.evaluate(
xpath,
root as Node,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null,
);
return result.singleNodeValue as Element | null;
}
} }
return null; return null;
} }
function queryAllElements(root: ParentNode, selector: unknown): Element[] { function queryAllElements(root: ParentNode, selector: unknown): Element[] {
if (typeof selector === "string") { if (typeof selector === "string") {
const { css, xpath } = selectorFromString(selector); return matchedElements(root, selectorFromString(selector));
if (css) return deepQueryAll(css, root);
if (xpath) return evaluateXPathAll(xpath, root as Node);
return [];
} }
// A selector path: every match of the first segment is searched for the rest, // A selector path: every match of the first segment is searched for the rest,
// concatenated in walk order, mirroring FindAllBySelectorPath in // concatenated in walk order, mirroring FindAllBySelectorPath in
@@ -416,9 +471,8 @@ function queryAllElements(root: ParentNode, selector: unknown): Element[] {
return heads.flatMap((element) => queryAllElements(element, rest)); return heads.flatMap((element) => queryAllElements(element, rest));
} }
if (selector && typeof selector === "object" && !Array.isArray(selector)) { if (selector && typeof selector === "object" && !Array.isArray(selector)) {
const { css, xpath } = selectorFromObject(selector as Record<string, string | boolean | undefined>); const compiled = selectorFromObject(selector as Record<string, string | boolean | undefined>);
if (css) return deepQueryAll(css, root); return matchedElements(root, compiled);
if (xpath) return evaluateXPathAll(xpath, root as Node);
} }
return []; return [];
} }
@@ -526,6 +580,23 @@ function isEnabled(element: Element): boolean {
return element.getAttribute("aria-disabled") !== "true"; return element.getAttribute("aria-disabled") !== "true";
} }
function isClickable(element: Element): boolean {
return element.matches(TAPPABLE_SELECTOR);
}
// Checkbox and option state lives in the DOM PROPERTY: the markup attribute
// records only what the page started with, so anything reading it reports a
// box's initial state however often the user ticks it.
// internal/driver/chrome/driver.go reads the same two properties for the dump
// the goja host gets.
function isChecked(element: Element): boolean {
return (element as Partial<HTMLInputElement>).checked === true;
}
function isSelected(element: Element): boolean {
return (element as Partial<HTMLOptionElement>).selected === true;
}
// document.activeElement stops at a shadow boundary and names the HOST, so a // document.activeElement stops at a shadow boundary and names the HOST, so a
// Compose for Web page reported focus on its mount element and never on the // Compose for Web page reported focus on its mount element and never on the
// field. selectAllScript in internal/driver/chrome/driver.go carries the rest of // field. selectAllScript in internal/driver/chrome/driver.go carries the rest of
@@ -598,7 +669,7 @@ function elementHandle(
// The selector collectTargets and the hierarchy dump (driver.go) both // The selector collectTargets and the hierarchy dump (driver.go) both
// resolve clickable through. Hardcoded true here, every text node and // resolve clickable through. Hardcoded true here, every text node and
// container a spec reached through state.ax claimed to be a tap target. // container a spec reached through state.ax claimed to be a tap target.
clickable: element.matches(TAPPABLE_SELECTOR), clickable: isClickable(element),
enabled: isEnabled(element), enabled: isEnabled(element),
// isContentEditable is inherited, so reading it alone made every span inside // isContentEditable is inherited, so reading it alone made every span inside
// a contenteditable container typeable here while collectTargets and the // a contenteditable container typeable here while collectTargets and the
@@ -606,13 +677,8 @@ function elementHandle(
// EDITABLE_SELECTOR, called the same span inert. // EDITABLE_SELECTOR, called the same span inert.
editable, editable,
focused: focusedElement === element, focused: focusedElement === element,
// Checkbox and option state lives in the DOM PROPERTY: the markup attribute checked: isChecked(element),
// records only what the page started with, so a handle reading it reports a selected: isSelected(element),
// box's initial state however often the user ticks it.
// internal/driver/chrome/driver.go reads the same two properties for the
// dump the goja host gets.
checked: state.checked === true,
selected: state.selected === true,
// Three-valued, unlike the other state flags: null on anything that is not // Three-valued, unlike the other state flags: null on anything that is not
// a field, matching the hierarchy dump in internal/driver/chrome/driver.go. // a field, matching the hierarchy dump in internal/driver/chrome/driver.go.
// A consumer deciding what a typed value may be written into a record has // A consumer deciding what a typed value may be written into a record has
+5
View File
@@ -50,6 +50,11 @@ export interface FakeElementSpec {
clickable?: boolean; clickable?: boolean;
editable?: boolean; editable?: boolean;
disabled?: boolean; disabled?: boolean;
// checked/selected are DOM properties rather than markup attributes on a real
// element, which is the whole point of them: the attribute records what the
// page started with and stops tracking the first time a user acts.
checked?: boolean;
selected?: boolean;
// overflows makes the element's content taller than its box, which is how the // overflows makes the element's content taller than its box, which is how the
// host decides an element is scrollable. // host decides an element is scrollable.
overflows?: boolean; overflows?: boolean;
+94
View File
@@ -766,6 +766,100 @@ test("secure selects the field this host reports secure", () => {
}); });
}); });
// The other five boolean states have the same hole secure had, and the same
// silence around it: the key is accepted, so no unknown-key error fires, and a
// key naming no rule builds `[clickable="true"]`, which no page carries. Every
// one of them selected NOTHING on web while resolving against the dump on the
// goja host, docs/manual/spec-language.md's own worked example
// (`find({ testTag: "AccountCard", clickable: true })`) included, and a property
// over an element that was never found passes having checked nothing.
//
// Each state is asserted against the value the SAME handle reports for it, so a
// selector can never name an element this host calls something else.
function statesMatched(selector: unknown, fact: string): unknown[][] {
const ax = __testing__.buildAx() as { findAll(selector: unknown): Record<string, unknown>[] };
return ax.findAll(selector).map((element) => [element.id, element[fact]]);
}
test("clickable selects what this host reports clickable", () => {
const save = fakeElement({
tag: "button", x: 0, y: 0, width: 60, height: 20, id: "save", clickable: true,
});
const total = fakeElement({ tag: "div", x: 0, y: 20, width: 60, height: 20, id: "total" });
withFakeDocument([save, total], () => {
assert.deepEqual(statesMatched({ clickable: true }, "clickable"), [["save", true]]);
assert.deepEqual(statesMatched({ clickable: false }, "clickable"), [["total", false]]);
});
});
test("enabled selects what this host reports enabled", () => {
const save = fakeElement({
tag: "button", x: 0, y: 0, width: 60, height: 20, id: "save", clickable: true,
});
const cancel = fakeElement({
tag: "button", x: 0, y: 20, width: 60, height: 20, id: "cancel", clickable: true,
disabled: true,
});
// A role-based control carries no disabled property, so aria-disabled is the
// only thing that marks it, and both producers read it.
const submit = fakeElement({
tag: "div", x: 0, y: 40, width: 60, height: 20, id: "submit", clickable: true,
attrs: { role: "button", "aria-disabled": "true" },
});
withFakeDocument([save, cancel, submit], () => {
assert.deepEqual(statesMatched({ enabled: true }, "enabled"), [["save", true]]);
assert.deepEqual(statesMatched({ enabled: false }, "enabled"), [
["cancel", false],
["submit", false],
]);
});
});
test("focused selects what this host reports focused", () => {
const amount = fakeElement({
tag: "input", x: 0, y: 0, width: 60, height: 20, id: "amount", editable: true, focused: true,
});
const note = fakeElement({
tag: "input", x: 0, y: 20, width: 60, height: 20, id: "note", editable: true,
});
withFakeDocument([amount, note], () => {
assert.deepEqual(statesMatched({ focused: true }, "focused"), [["amount", true]]);
assert.deepEqual(statesMatched({ focused: false }, "focused"), [["note", false]]);
});
});
// Both producers read the checked PROPERTY, so the selector has to read it too:
// the markup attribute records only what the page started with, and a box the
// user ticked answers to `[checked]` never and to `{checked: true}` always.
test("checked selects the live property, not the markup attribute", () => {
const remember = fakeElement({
tag: "input", x: 0, y: 0, width: 20, height: 20, id: "remember",
attrs: { type: "checkbox" }, checked: true,
});
const agree = fakeElement({
tag: "input", x: 0, y: 20, width: 20, height: 20, id: "agree",
attrs: { type: "checkbox", checked: "" }, checked: false,
});
withFakeDocument([remember, agree], () => {
assert.deepEqual(statesMatched({ checked: true }, "checked"), [["remember", true]]);
assert.deepEqual(statesMatched({ checked: false }, "checked"), [["agree", false]]);
});
});
test("selected selects the live property, not the markup attribute", () => {
const january = fakeElement({
tag: "option", x: 0, y: 0, width: 60, height: 20, id: "january", selected: true,
});
const february = fakeElement({
tag: "option", x: 0, y: 20, width: 60, height: 20, id: "february",
attrs: { selected: "" },
});
withFakeDocument([january, february], () => {
assert.deepEqual(statesMatched({ selected: true }, "selected"), [["january", true]]);
assert.deepEqual(statesMatched({ selected: false }, "selected"), [["february", false]]);
});
});
test("attrs carries every other attribute alongside tag and aria-label", () => { test("attrs carries every other attribute alongside tag and aria-label", () => {
const attrs = attrsOf( const attrs = attrsOf(
domElement({ tag: "input", attributes: { id: "txn-note", placeholder: "What's this for?" } }), domElement({ tag: "input", attributes: { id: "txn-note", placeholder: "What's this for?" } }),