From 4768b8048be172d3d925729d20bb5b261f897b9f Mon Sep 17 00:00:00 2001 From: PJ Date: Wed, 19 Aug 2026 09:41:31 +0530 Subject: [PATCH] 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. --- pkg/spec/src/web-runtime.ts | 72 +++++++++++++++++++++++++++---------- 1 file changed, 54 insertions(+), 18 deletions(-) diff --git a/pkg/spec/src/web-runtime.ts b/pkg/spec/src/web-runtime.ts index 0d99d5b..b0fe2b4 100644 --- a/pkg/spec/src/web-runtime.ts +++ b/pkg/spec/src/web-runtime.ts @@ -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 -// query takes, and the states no query can express, which the elements it -// answered with are held against afterwards. +// query takes, the facts no query can express, which the elements it answered +// with are held against afterwards, and whether the matches an outer element +// makes are the ones a descendant already made. interface CompiledSelector { css?: string; xpath?: string; match?: (element: Element) => boolean; + innermost?: boolean; } // A selector key that can never match yields an empty result, which reads @@ -313,7 +315,7 @@ function selectorFromObject( selector: Record, ): CompiledSelector { const parts: string[] = []; - const stateMatchers: Array<(element: Element) => boolean> = []; + const matchers: Array<(element: Element) => boolean> = []; let textValue: string | undefined; const unknown: string[] = []; for (const key of Object.keys(selector)) { @@ -330,7 +332,7 @@ function selectorFromObject( } const state = KNOWN_KEY_TO_STATE[key]; if (state) { - stateMatchers.push(state(value)); + matchers.push(state(value)); continue; } parts.push(cssPart(key, value)); @@ -338,21 +340,38 @@ function selectorFromObject( if (unknown.length > 0) { throw new Error(unknownSelectorKeyMessage(unknown)); } - if (textValue !== undefined && parts.length === 0) { - return withStates({ xpath: innermostTextXPath(textValue) }, stateMatchers); + if (textValue !== undefined && parts.length === 0 && matchers.length === 0) { + return { xpath: innermostTextXPath(textValue) }; } - // 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); + // text beside another key was DROPPED here, and the selector then matched on + // the other keys alone: `{testTag: "Row", text: "Alice"}` selected every row + // carrying the tag, where internal/hierarchy ANDs the two and selects the one + // 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( - compiled: CompiledSelector, - stateMatchers: Array<(element: Element) => boolean>, -): CompiledSelector { - if (stateMatchers.length === 0) return compiled; - return { ...compiled, match: (element) => stateMatchers.every((state) => state(element)) }; +// The substring rule innermostTextXPath applies, read off the element: an +// element's text is its whole subtree's text, whitespace collapsed the way +// normalize-space collapses it. +function textMatcher(value: string): (element: Element) => boolean { + return (element) => (element.textContent ?? "").replace(/\s+/g, " ").trim().includes(value); } // 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 // documents the unit tests install. function matchedElements(root: ParentNode, compiled: CompiledSelector): Element[] { - const { css, xpath, match } = compiled; + const { css, xpath, match, innermost } = compiled; let found: Element[] = []; if (css) found = deepQueryAll(css, root); else if (xpath) found = evaluateXPathAll(xpath, root as Node); const head: Element | undefined = document.head; - return found.filter( + const matched = found.filter( (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(); + 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(