fix(spec): and text with the keys written beside it

a compound object selector dropped text and matched on the other keys
alone, so {testTag: "Row", text: "Alice"} selected every row carrying
the tag where internal/hierarchy selects the one row the author named.
matching more than the spec said is silent: the find lands on a row
nobody wrote and every property over it still passes.

text is answered against the element the way the boolean states are,
since css cannot ask what an element's text says and the xpath that can
cannot ask about the rest, and the innermost rule now holds over what
the whole selector matched, where internal/hierarchy holds it. a
text-only selector still compiles to the same innermost xpath.
This commit is contained in:
pj committed 2026-08-19 09:41:31 +05:30
1 parent f8a025eb83
commit 4768b8048b
1 file changed
+54 -18
+54 -18
View File
@@ -297,12 +297,14 @@ function cssPart(key: string, value: string): string {
} }
// A compiled selector is what a lookup resolves: the CSS or XPath a document // 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 // query takes, the facts no query can express, which the elements it answered
// answered with are held against afterwards. // with are held against afterwards, and whether the matches an outer element
// makes are the ones a descendant already made.
interface CompiledSelector { interface CompiledSelector {
css?: string; css?: string;
xpath?: string; xpath?: string;
match?: (element: Element) => boolean; match?: (element: Element) => boolean;
innermost?: 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
@@ -313,7 +315,7 @@ function selectorFromObject(
selector: Record<string, string | boolean | undefined>, selector: Record<string, string | boolean | undefined>,
): CompiledSelector { ): CompiledSelector {
const parts: string[] = []; const parts: string[] = [];
const stateMatchers: Array<(element: Element) => boolean> = []; const matchers: 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)) {
@@ -330,7 +332,7 @@ function selectorFromObject(
} }
const state = KNOWN_KEY_TO_STATE[key]; const state = KNOWN_KEY_TO_STATE[key];
if (state) { if (state) {
stateMatchers.push(state(value)); matchers.push(state(value));
continue; continue;
} }
parts.push(cssPart(key, value)); parts.push(cssPart(key, value));
@@ -338,21 +340,38 @@ function selectorFromObject(
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 && matchers.length === 0) {
return withStates({ xpath: innermostTextXPath(textValue) }, stateMatchers); return { xpath: innermostTextXPath(textValue) };
} }
// A selector that names states alone still has to name a document query to // text beside another key was DROPPED here, and the selector then matched on
// hold them against, and every element is what it asks about. // the other keys alone: `{testTag: "Row", text: "Alice"}` selected every row
const css = parts.length === 0 && stateMatchers.length > 0 ? "*" : parts.join(""); // carrying the tag, where internal/hierarchy ANDs the two and selects the one
return withStates({ css }, stateMatchers); // the author named. Matching MORE than the spec said is silent: the find
// lands on a row nobody wrote and every property over it still passes.
//
// It is answered against the element rather than compiled into the query
// because the two halves share no query language: CSS cannot ask what an
// element's text says, and the XPath that can cannot ask about the rest.
if (textValue !== undefined) matchers.push(textMatcher(textValue));
// A selector that names no query of its own still has to name one to hold its
// matchers against, and every element is what it asks about.
const css = parts.length === 0 && matchers.length > 0 ? "*" : parts.join("");
const compiled: CompiledSelector = { css };
if (matchers.length > 0) {
compiled.match = (element) => matchers.every((matcher) => matcher(element));
}
// Held over what the WHOLE selector matched, where internal/hierarchy holds
// it: an ancestor whose only matching descendant a sibling key excludes was
// never a match to drop it by.
if (textValue !== undefined) compiled.innermost = true;
return compiled;
} }
function withStates( // The substring rule innermostTextXPath applies, read off the element: an
compiled: CompiledSelector, // element's text is its whole subtree's text, whitespace collapsed the way
stateMatchers: Array<(element: Element) => boolean>, // normalize-space collapses it.
): CompiledSelector { function textMatcher(value: string): (element: Element) => boolean {
if (stateMatchers.length === 0) return compiled; return (element) => (element.textContent ?? "").replace(/\s+/g, " ").trim().includes(value);
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
@@ -433,15 +452,32 @@ function deepQueryAll(selector: string, root: ParentNode): Element[] {
// goja host cannot see at all. document.head is absent only from the small fake // goja host cannot see at all. document.head is absent only from the small fake
// documents the unit tests install. // documents the unit tests install.
function matchedElements(root: ParentNode, compiled: CompiledSelector): Element[] { function matchedElements(root: ParentNode, compiled: CompiledSelector): Element[] {
const { css, xpath, match } = compiled; const { css, xpath, match, innermost } = compiled;
let found: Element[] = []; let found: Element[] = [];
if (css) found = deepQueryAll(css, root); if (css) found = deepQueryAll(css, root);
else if (xpath) found = evaluateXPathAll(xpath, root as Node); else if (xpath) found = evaluateXPathAll(xpath, root as Node);
const head: Element | undefined = document.head; const head: Element | undefined = document.head;
return found.filter( const matched = found.filter(
(element) => (element) =>
!(head !== undefined && head.contains(element)) && (match === undefined || match(element)), !(head !== undefined && head.contains(element)) && (match === undefined || match(element)),
); );
return innermost ? innermostMatches(matched) : matched;
}
// innermostMatches drops a match a descendant of it also makes, which is what
// innermostMatches in internal/hierarchy drops and what the not() clause of
// innermostTextXPath keeps out. An element's text is its whole subtree's text,
// so every ancestor of a match up to the root matches too, and the deepest one
// is the element the author named.
function innermostMatches(elements: Element[]): Element[] {
const matched = new Set(elements);
const outer = new Set<Element>();
for (const element of elements) {
for (let node = element.parentElement; node; node = node.parentElement) {
if (matched.has(node)) outer.add(node);
}
}
return elements.filter((element) => !outer.has(element));
} }
function queryElement( function queryElement(