From 12f533ef3d4f40686149f6c2e820c48bde2a6d77 Mon Sep 17 00:00:00 2001 From: Aleksey Bykhun Date: Tue, 19 May 2026 12:25:55 -0700 Subject: [PATCH 1/2] fix(dom): include shadow DOM form elements when offsetWidth/Height are 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isElementVisible` in the in-page DOM walker hard-fails when `offsetWidth === 0 || offsetHeight === 0`. Shadow-DOM-hosted form elements (e.g. Stripe Elements, password manager extensions, auth0 login widgets, custom input components) commonly report 0 dimensions on the inner control even when they're real interactive elements visible on screen — visibility is governed by the shadow host's layout, not the element's own offset box. The agent then can't see them in the element index and can't interact with them. Add a SHADOW_DOM_FORM_TAGS whitelist (input, select, textarea, button, a) and an isInsideShadowDom() helper. In buildDomTree, if an element is a form tag inside a shadow root, treat it as visible+top so isInteractiveElement runs and it lands in selector_map regardless of offsetWidth/Height. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/dom/dom_tree/index.js | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/dom/dom_tree/index.js b/src/dom/dom_tree/index.js index 9847ff7..db76180 100644 --- a/src/dom/dom_tree/index.js +++ b/src/dom/dom_tree/index.js @@ -988,6 +988,17 @@ return hasQuickInteractiveAttr; } + // Tags that should remain interactive even when offsetWidth/Height report 0 + // when they live inside a shadow root. Matches the upstream Python fix where + // shadow-DOM form elements lacking CDP DOMSnapshot layout still go into the + // selector_map (e.g. auth0-style login widgets). + const SHADOW_DOM_FORM_TAGS = new Set(['input', 'button', 'select', 'textarea', 'a']); + + function isInsideShadowDom(element) { + let root = element.getRootNode && element.getRootNode(); + return Boolean(root && root instanceof ShadowRoot); + } + // --- Define constants for distinct interaction check --- const DISTINCT_INTERACTIVE_TAGS = new Set([ 'a', 'button', 'input', 'select', 'textarea', 'summary', 'details', 'label', 'option' @@ -1321,13 +1332,25 @@ // Perform visibility, interactivity, and highlighting checks if (node.nodeType === Node.ELEMENT_NODE) { nodeData.isVisible = isElementVisible(node); // isElementVisible uses offsetWidth/Height, which is fine + + // Shadow-DOM exception: form tags inside a shadow root that report + // offsetWidth/Height = 0 are still functional. Treating them as visible + // here lets isInteractiveElement run and the element reach selector_map. + const isShadowDomFormElement = + !nodeData.isVisible && + SHADOW_DOM_FORM_TAGS.has(node.tagName.toLowerCase()) && + isInsideShadowDom(node); + if (isShadowDomFormElement) { + nodeData.isVisible = true; + } + if (nodeData.isVisible) { - nodeData.isTopElement = isTopElement(node); - + nodeData.isTopElement = isShadowDomFormElement ? true : isTopElement(node); + // Special handling for ARIA menu containers - check interactivity even if not top element const role = node.getAttribute('role'); const isMenuContainer = role === 'menu' || role === 'menubar' || role === 'listbox'; - + if (nodeData.isTopElement || isMenuContainer) { nodeData.isInteractive = isInteractiveElement(node); // Call the dedicated highlighting function From 03988725ed5b5b08a9dedc025443376919220687 Mon Sep 17 00:00:00 2001 From: Aleksey Bykhun Date: Tue, 19 May 2026 21:21:17 -0700 Subject: [PATCH 2/2] test(dom): verify shadow-DOM form elements with offsetWidth=0 are discovered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a regression test for the SHADOW_DOM_FORM_TAGS / isInsideShadowDom exception in src/dom/dom_tree/index.js. The test: 1. Navigates (via data: URL — about:blank short-circuits selector_map) to a page hosting a shadow root that contains an and a +
+ + +`; + + it('discovers shadow-DOM input with offsetWidth/Height = 0', async () => { + await page.goto( + 'data:text/html;charset=utf-8,' + encodeURIComponent(SHADOW_FORM_HTML) + ); + + // Sanity check: confirm the shadow input really does report + // offsetWidth/Height = 0 under the chosen CSS. If this ever stops + // being true the test no longer exercises the bug. + const shadowInputDims = await page.evaluate(() => { + const host = document.getElementById('shadow-host'); + const input = host?.shadowRoot?.getElementById('shadow-input') as + | HTMLInputElement + | null; + if (!input) return null; + return { + offsetWidth: input.offsetWidth, + offsetHeight: input.offsetHeight, + display: getComputedStyle(input).display, + visibility: getComputedStyle(input).visibility, + }; + }); + expect(shadowInputDims).not.toBeNull(); + expect(shadowInputDims!.offsetWidth).toBe(0); + expect(shadowInputDims!.offsetHeight).toBe(0); + // The element is still rendered (not display:none / visibility:hidden); + // it's the offsetWidth/Height = 0 short-circuit that loses it + // without the fix. + expect(shadowInputDims!.display).not.toBe('none'); + expect(shadowInputDims!.visibility).not.toBe('hidden'); + + const domService = new DomService(page); + // viewport_expansion=-1 ensures elements are picked regardless of + // viewport intersection — the bug under test is about visibility + // detection inside the shadow root, not viewport intersection. + const state = await domService.get_clickable_elements(true, -1, -1); + + const selectorEntries = Object.values(state.selector_map); + + // The actual regression assertion: the shadow-DOM input must reach + // selector_map. Without the SHADOW_DOM_FORM_TAGS exception this is + // dropped because isElementVisible returns false (offsetWidth/Height = 0). + const shadowInputEntries = selectorEntries.filter( + (node) => + node.tag_name === 'input' && + (node.attributes?.id === 'shadow-input' || + node.attributes?.name === 'username') + ); + expect(shadowInputEntries.length).toBeGreaterThan(0); + + // The submit button inside the shadow root should also be discovered. + const shadowSubmitEntries = selectorEntries.filter( + (node) => + node.tag_name === 'button' && node.attributes?.id === 'shadow-button' + ); + expect(shadowSubmitEntries.length).toBeGreaterThan(0); + }); + + it('still skips non-form shadow-DOM elements with offsetWidth/Height = 0', async () => { + // Guard: the SHADOW_DOM_FORM_TAGS whitelist must remain a *whitelist*. + // A
inside a shadow root with width=0/height=0 should not be + // promoted to selector_map by this code path. (It can still appear + // via other interactivity heuristics, so we assert via highlight_index + // not being assigned through the shadow-DOM exception.) + const DIV_HTML = ` + + +
+ + +`; + await page.goto( + 'data:text/html;charset=utf-8,' + encodeURIComponent(DIV_HTML) + ); + + const domService = new DomService(page); + const state = await domService.get_clickable_elements(true, -1, -1); + + // A plain
inside the shadow root with no interactive cursor / + // event handlers must not land in selector_map via the form-tag + // exception. This guards against widening the whitelist accidentally. + const plainDiv = Object.values(state.selector_map).filter( + (node) => + node.tag_name === 'div' && node.attributes?.id === 'plain-div' + ); + expect(plainDiv.length).toBe(0); + }); + }); + describe('Cross-Origin Iframes', () => { it('detects cross-origin iframes', async () => { await page.setContent(`