fix(web): enumerate and query across shadow roots in both producers

This commit is contained in:
pj committed 2026-08-13 00:42:02 +05:30
1 parent ee234674ce
commit f58ab5f0db
2 files changed
+70 -13

No files matched your search

+24 -3
View File
@@ -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()); if (tag === 'input') return !NON_TEXT_INPUT_TYPES.includes((el.type || '').toLowerCase());
return false; 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]')); 'a, button, input, select, textarea, [role="button"], [onclick]'));
const editableSet = new Set(Array.from( const editableSet = new Set(deepQuery(
document.querySelectorAll('input, textarea, [contenteditable]')).filter(isEditableElement)); 'input, textarea, [contenteditable]').filter(isEditableElement));
function buildTree(el, isRoot) { function buildTree(el, isRoot) {
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const attrs = {}; const attrs = {};
@@ -414,6 +427,14 @@ func (d *Driver) Hierarchy(ctx context.Context) (string, error) {
const isClickable = clickableSet.has(el); const isClickable = clickableSet.has(el);
const isEditable = editableSet.has(el); const isEditable = editableSet.has(el);
const children = []; 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) { for (const child of el.children) {
if (child.tagName === 'HEAD') continue; if (child.tagName === 'HEAD') continue;
children.push(buildTree(child, false)); children.push(buildTree(child, false));
+46 -10
View File
@@ -188,13 +188,32 @@ function selectorFromString(selector: string): { css?: string; xpath?: string }
return selectorFromObject({ [kind]: value }); 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<HTMLElement>("*"))) {
if (element.shadowRoot) visit(element.shadowRoot);
}
};
visit(root);
return found;
}
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); const { css, xpath } = selectorFromString(selector);
if (css) return root.querySelector(css); if (css) return deepQueryAll(css, root)[0] ?? null;
if (xpath) { if (xpath) {
const result = document.evaluate( const result = document.evaluate(
xpath, xpath,
@@ -219,7 +238,7 @@ function queryElement(
} }
if (selector && typeof selector === "object") { if (selector && typeof selector === "object") {
const { css, xpath } = selectorFromObject(selector as Record<string, string | boolean | undefined>); const { css, xpath } = selectorFromObject(selector as Record<string, string | boolean | undefined>);
if (css) return root.querySelector(css); if (css) return deepQueryAll(css, root)[0] ?? null;
if (xpath) { if (xpath) {
const result = document.evaluate( const result = document.evaluate(
xpath, xpath,
@@ -237,13 +256,13 @@ function queryElement(
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); 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); if (xpath) return evaluateXPathAll(xpath, root as Node);
return []; return [];
} }
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 { css, xpath } = selectorFromObject(selector as Record<string, string | boolean | undefined>);
if (css) return Array.from(root.querySelectorAll(css)); if (css) return deepQueryAll(css, root);
if (xpath) return evaluateXPathAll(xpath, root as Node); if (xpath) return evaluateXPathAll(xpath, root as Node);
} }
return []; return [];
@@ -521,12 +540,31 @@ function pointOf(element: Element): Candidate {
const HEAD_SELECTOR = "head, head *"; const HEAD_SELECTOR = "head, head *";
// targetElements is the walk the target list is built from: the document in // 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[] { function targetElements(): HTMLElement[] {
const inHead = new Set<Element>(Array.from(document.querySelectorAll(HEAD_SELECTOR))); const inHead = new Set<Element>(Array.from(document.querySelectorAll(HEAD_SELECTOR)));
return Array.from(document.querySelectorAll<HTMLElement>("*")).filter( const walked: HTMLElement[] = [];
expandShadowContent(
Array.from(document.querySelectorAll<HTMLElement>("*")).filter(
(element) => !inHead.has(element), (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<HTMLElement>("*")), into);
}
} }
// collectTargets walks the document ONCE and reports every element with the facts // 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" // resolved by selector first so the DOM's answer to "clickable" and "editable"
// stays expressed in CSS, as it always was. // stays expressed in CSS, as it always was.
function collectTargets(): TargetElement[] { function collectTargets(): TargetElement[] {
const clickable = new Set<Element>(Array.from(document.querySelectorAll(TAPPABLE_SELECTOR))); const clickable = new Set<Element>(deepQueryAll(TAPPABLE_SELECTOR, document));
const editable = new Set<Element>( const editable = new Set<Element>(
Array.from(document.querySelectorAll<HTMLElement>(EDITABLE_SELECTOR)).filter( (deepQueryAll(EDITABLE_SELECTOR, document) as HTMLElement[]).filter(isEditableElement),
isEditableElement,
),
); );
return targetElements().map((element) => ({ return targetElements().map((element) => ({
...pointOf(element), ...pointOf(element),