mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
fix(web): enumerate and query across shadow roots in both producers
This commit is contained in:
1 parent
ee234674ce
commit
f58ab5f0db
2 files changed
+71
-14
No files matched your search
@@ -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));
|
||||||
|
|||||||
+47
-11
@@ -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[] = [];
|
||||||
(element) => !inHead.has(element),
|
expandShadowContent(
|
||||||
|
Array.from(document.querySelectorAll<HTMLElement>("*")).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<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),
|
||||||
|
|||||||
Reference in new issue
Block a user