From f58ab5f0dbe27710f1b0ae0375d943b62641196c Mon Sep 17 00:00:00 2001 From: PJ Date: Thu, 13 Aug 2026 00:42:02 +0530 Subject: [PATCH] fix(web): enumerate and query across shadow roots in both producers --- internal/driver/chrome/driver.go | 27 +++++++++++++-- pkg/spec/src/web-runtime.ts | 58 ++++++++++++++++++++++++++------ 2 files changed, 71 insertions(+), 14 deletions(-) diff --git a/internal/driver/chrome/driver.go b/internal/driver/chrome/driver.go index 3137afd..363e9b2 100644 --- a/internal/driver/chrome/driver.go +++ b/internal/driver/chrome/driver.go @@ -383,10 +383,23 @@ func (d *Driver) Hierarchy(ctx context.Context) (string, error) { if (tag === 'input') return !NON_TEXT_INPUT_TYPES.includes((el.type || '').toLowerCase()); return false; } - const clickableSet = new Set(document.querySelectorAll( + // Shadow roots are part of the page a user sees, so they are part of the page + // we enumerate. Compose for Web mounts its canvas AND its accessibility tree + // inside a shadow root on the mount element, so a light-DOM-only walk reports + // four nodes for a whole app and offers no action on any of them. + function deepQuery(sel) { + const out = []; + const visit = (root) => { + for (const el of root.querySelectorAll(sel)) out.push(el); + for (const el of root.querySelectorAll('*')) if (el.shadowRoot) visit(el.shadowRoot); + }; + visit(document); + return out; + } + const clickableSet = new Set(deepQuery( 'a, button, input, select, textarea, [role="button"], [onclick]')); - const editableSet = new Set(Array.from( - document.querySelectorAll('input, textarea, [contenteditable]')).filter(isEditableElement)); + const editableSet = new Set(deepQuery( + 'input, textarea, [contenteditable]').filter(isEditableElement)); function buildTree(el, isRoot) { const rect = el.getBoundingClientRect(); const attrs = {}; @@ -414,6 +427,14 @@ func (d *Driver) Hierarchy(ctx context.Context) (string, error) { const isClickable = clickableSet.has(el); const isEditable = editableSet.has(el); const children = []; + // Shadow content first, then light children: the shadow tree is what the + // host actually renders, and targetElements in web-runtime.ts walks the same + // order, which is the order the two enumerations are compared in. + if (el.shadowRoot) { + for (const child of el.shadowRoot.children) { + children.push(buildTree(child, false)); + } + } for (const child of el.children) { if (child.tagName === 'HEAD') continue; children.push(buildTree(child, false)); diff --git a/pkg/spec/src/web-runtime.ts b/pkg/spec/src/web-runtime.ts index 40e18b8..fb26b2c 100644 --- a/pkg/spec/src/web-runtime.ts +++ b/pkg/spec/src/web-runtime.ts @@ -188,13 +188,32 @@ function selectorFromString(selector: string): { css?: string; xpath?: string } return selectorFromObject({ [kind]: value }); } +// deepQueryAll resolves a CSS selector against a root AND every shadow root +// beneath it. querySelectorAll stops dead at a shadow boundary, and a canvas app +// (Compose for Web mounts its canvas and its whole accessibility tree inside a +// shadow root on the mount element) keeps its entire UI on the far side of one: +// without this a spec sees four nodes and can neither enumerate a target nor +// resolve a testTag. Light-DOM matches come first, then shadow content in walk +// order. XPath has no equivalent, so `text:` selectors stop at the boundary. +function deepQueryAll(selector: string, root: ParentNode): Element[] { + const found: Element[] = []; + const visit = (scope: ParentNode): void => { + for (const element of Array.from(scope.querySelectorAll(selector))) found.push(element); + for (const element of Array.from(scope.querySelectorAll("*"))) { + if (element.shadowRoot) visit(element.shadowRoot); + } + }; + visit(root); + return found; +} + function queryElement( root: ParentNode, selector: unknown, ): Element | null { if (typeof selector === "string") { const { css, xpath } = selectorFromString(selector); - if (css) return root.querySelector(css); + if (css) return deepQueryAll(css, root)[0] ?? null; if (xpath) { const result = document.evaluate( xpath, @@ -219,7 +238,7 @@ function queryElement( } if (selector && typeof selector === "object") { const { css, xpath } = selectorFromObject(selector as Record); - if (css) return root.querySelector(css); + if (css) return deepQueryAll(css, root)[0] ?? null; if (xpath) { const result = document.evaluate( xpath, @@ -237,13 +256,13 @@ function queryElement( function queryAllElements(root: ParentNode, selector: unknown): Element[] { if (typeof selector === "string") { const { css, xpath } = selectorFromString(selector); - if (css) return Array.from(root.querySelectorAll(css)); + if (css) return deepQueryAll(css, root); if (xpath) return evaluateXPathAll(xpath, root as Node); return []; } if (selector && typeof selector === "object" && !Array.isArray(selector)) { const { css, xpath } = selectorFromObject(selector as Record); - if (css) return Array.from(root.querySelectorAll(css)); + if (css) return deepQueryAll(css, root); if (xpath) return evaluateXPathAll(xpath, root as Node); } return []; @@ -521,12 +540,31 @@ function pointOf(element: Element): Candidate { const HEAD_SELECTOR = "head, head *"; // targetElements is the walk the target list is built from: the document in -// pre-order, minus the head subtree. +// pre-order, minus the head subtree, with each shadow host's content spliced in +// directly after the host. That is buildTree's order in +// internal/driver/chrome/driver.go, and the two producers are compared element +// by element in enumeration order. function targetElements(): HTMLElement[] { const inHead = new Set(Array.from(document.querySelectorAll(HEAD_SELECTOR))); - return Array.from(document.querySelectorAll("*")).filter( - (element) => !inHead.has(element), + const walked: HTMLElement[] = []; + expandShadowContent( + Array.from(document.querySelectorAll("*")).filter( + (element) => !inHead.has(element), + ), + walked, ); + return walked; +} + +// expandShadowContent copies a tree-ordered element list into `into`, following +// each host into its shadow root (and into nested hosts) as it goes. +function expandShadowContent(elements: HTMLElement[], into: HTMLElement[]): void { + for (const element of elements) { + into.push(element); + const shadow = element.shadowRoot; + if (!shadow) continue; + expandShadowContent(Array.from(shadow.querySelectorAll("*")), into); + } } // collectTargets walks the document ONCE and reports every element with the facts @@ -534,11 +572,9 @@ function targetElements(): HTMLElement[] { // resolved by selector first so the DOM's answer to "clickable" and "editable" // stays expressed in CSS, as it always was. function collectTargets(): TargetElement[] { - const clickable = new Set(Array.from(document.querySelectorAll(TAPPABLE_SELECTOR))); + const clickable = new Set(deepQueryAll(TAPPABLE_SELECTOR, document)); const editable = new Set( - Array.from(document.querySelectorAll(EDITABLE_SELECTOR)).filter( - isEditableElement, - ), + (deepQueryAll(EDITABLE_SELECTOR, document) as HTMLElement[]).filter(isEditableElement), ); return targetElements().map((element) => ({ ...pointOf(element),