Description
Follow-up to #65 (closed, marked fixed in v1.1.13 by commit 8b33eb2).
The 1.1.13 patch added same-origin iframe traversal to the JS helper, which fixes the trivial "Flutter Web inside iframe" case where the accessibility tree lives in light DOM. But Flutter Web actually mounts its accessibility tree inside an open shadow root attached to <flt-glass-pane>, and the patched _collectDocs() walks iframe.contentDocument only — it does not descend into shadowRoot. As a result, real Flutter Web targets still fail on 1.1.13, and the same failure also reproduces at the top frame with no iframe at all.
Environment
- maestro-runner: 1.1.13 (commit
7addd21, built 2026-05-05T14:07:52Z, go1.26.0)
- OS: macOS 15 (Darwin 25.2.0, arm64)
- Executor: web (Chrome stable, headed via CDP)
- Comparison: Maestro (mobile-dev-inc) 2.5.1 — equivalent flow against equivalent DOM resolves and proceeds.
One-line repro
A single self-contained HTML page is hosted on Cloudflare R2 — no app login, no third-party site, no local HTTP server. Save the yaml below and run:
# repro.yaml
url: "https://pub-4e93ec0f0add49d78a646e058039eb5b.r2.dev/repro.html"
name: shadow-dom-iframe-repro
---
- launchApp
- assertVisible: "PARENT_HEADER_TEXT" # top frame — OK on 1.1.13
- extendedWaitUntil:
visible: "DIALOG_BODY_TEXT" # iframe (srcdoc) + shadow DOM — FAILS on 1.1.13
timeout: 8000
- tapOn: "Close"
- extendedWaitUntil:
notVisible: "DIALOG_BODY_TEXT"
timeout: 5000
maestro-runner --platform web test repro.yaml
Actual output (verbatim)
maestro-runner 1.1.13 - by DeviceLab.dev
[1/1] shadow-dom-iframe-repro (repro.yaml)
────────────────────────────────────────────────────────────
✓ launchApp (529ms)
✓ assertVisible: text="PARENT_HEADER_TEXT" (4ms)
✗ extendedWaitUntil: visible text="DIALOG_BODY_TEXT" (8.0s)
╰─ Wait condition not met within 8s
✗ tapOn: text="Close" (skipped)
✗ extendedWaitUntil: notVisible text="DIALOG_BODY_TEXT" (skipped)
✗ shadow-dom-iframe-repro 8.7s
shadow-dom-iframe-repro ✗ FAIL 5 steps 2 pass 1 fail 2 skip 8.7s Chrome (web)
TOTAL 0/1 5 steps 2 pass 1 fail 2 skip 8.7s
The dialog renders visually (a screenshot or a real-browser visit to the R2 URL confirms the blue Close button is on screen), but the selector engine cannot resolve DIALOG_BODY_TEXT because the matching <flt-semantics aria-label="DIALOG_BODY_TEXT"> lives inside a shadow root.
Repro page source
The hosted page is the self-contained file below — copy it into repro.html and serve from anywhere if you'd rather not hit R2. The R2 host is just a convenience.
repro.html — single file, ~3 KB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>maestro-runner shadow DOM repro</title>
<style>
body { font-family: sans-serif; margin: 24px; background: #fff; color: #222; }
iframe { width: 800px; height: 480px; border: 1px solid #999; }
code { background: #eee; padding: 1px 4px; border-radius: 3px; }
</style>
</head>
<body>
<h1>PARENT_HEADER_TEXT</h1>
<p>
The header above is in the top frame and resolves on 1.1.13. The iframe below uses
<code>srcdoc</code> so the entire repro is a single file. Inside, a Flutter-Web-like
<code><flt-glass-pane></code> attaches an open shadow root containing
<code><flt-semantics aria-label="..."></code> nodes — selectors targeting those
nodes still fail on 1.1.13.
</p>
<iframe id="flutter-host" title="Flutter iframe host" srcdoc='
<!DOCTYPE html>
<html lang="en">
<body style="margin:0;font-family:sans-serif;background:#f5f7fb;height:100vh">
<flutter-view><flt-glass-pane id="g"></flt-glass-pane></flutter-view>
<template id="dlg">
<style>
flt-semantics-host, flt-semantics, flt-semantics-container { display: block; }
flt-semantics[role="dialog"] {
position:absolute; left:50%; top:40%;
transform:translate(-50%,-50%);
width:360px; padding:24px; background:#fff;
border:1px solid #ddd; border-radius:8px;
box-shadow:0 8px 24px rgba(0,0,0,0.12);
}
flt-semantics[role="button"] {
display:inline-block; margin-top:16px; padding:8px 16px;
background:#1976d2; color:#fff; border-radius:4px;
cursor:pointer; user-select:none;
}
</style>
<flt-semantics-host>
<flt-semantics role="dialog" aria-label="Welcome dialog">
<flt-semantics-container>
<flt-semantics aria-label="DIALOG_BODY_TEXT">DIALOG_BODY_TEXT</flt-semantics>
<flt-semantics role="button" aria-label="Close" tabindex="0">Close</flt-semantics>
</flt-semantics-container>
</flt-semantics>
</flt-semantics-host>
</template>
<script>
// Mount the Flutter-like accessibility tree inside an OPEN SHADOW ROOT
// attached to <flt-glass-pane>. This is the structural shape that real
// Flutter Web emits once accessibility is enabled.
const host = document.getElementById("g");
const sr = host.attachShadow({ mode: "open" });
sr.appendChild(document.getElementById("dlg").content.cloneNode(true));
// Wire the Close button so the test can verify the click landed.
const closeBtn = sr.querySelector("flt-semantics[role=\"button\"]");
closeBtn.addEventListener("click", () => {
sr.querySelector("flt-semantics[role=\"dialog\"]").remove();
document.title = "dialog-closed";
});
</script>
</body>
</html>
'></iframe>
</body>
</html>
Bug is broader than iframe — top-frame shadow DOM also fails
To prove the gap is shadow DOM and not iframe, the same content rendered at the top frame with no iframe also fails:
topframe-shadow.html
<!DOCTYPE html>
<html><body>
<h1>PARENT_HEADER_TEXT</h1>
<flutter-view><flt-glass-pane id="g"></flt-glass-pane></flutter-view>
<script>
const sr = document.getElementById("g").attachShadow({ mode: "open" });
sr.innerHTML = `
<flt-semantics-host>
<flt-semantics role="dialog" aria-label="Welcome dialog">
<flt-semantics aria-label="DIALOG_BODY_TEXT">DIALOG_BODY_TEXT</flt-semantics>
<flt-semantics role="button" aria-label="Close" tabindex="0">Close</flt-semantics>
</flt-semantics>
</flt-semantics-host>`;
</script>
</body></html>
url: "<host>/topframe-shadow.html"
name: topframe-shadow-only
---
- launchApp
- assertVisible: "PARENT_HEADER_TEXT"
- extendedWaitUntil: { visible: "DIALOG_BODY_TEXT", timeout: 4000 }
✓ launchApp (522ms)
✓ assertVisible: text="PARENT_HEADER_TEXT" (4ms)
✗ extendedWaitUntil: visible text="DIALOG_BODY_TEXT" (4.0s)
topframe-shadow-only ✗ FAIL 3 steps 2 pass 1 fail 0 skip 4.7s Chrome (web)
For comparison, removing the shadow root (rendering the same <flt-semantics> tree as light DOM children of <flt-glass-pane>, inside an iframe) makes the same flow PASS on 1.1.13 — i.e. iframe traversal itself is working; shadow DOM is the missing piece.
Expected
Selectors traverse open shadow roots, so any text rendered as <flt-semantics aria-label="…"> (or any aria-label / textContent / placeholder match) inside an open shadow root resolves regardless of whether that shadow root is at the top frame or inside a same-origin iframe. This is what is required to drive Flutter Web targets, and it matches what upstream Maestro 2.5.1 does.
Root cause
pkg/driver/browser/cdp/jshelper.js — _collectDocs() (added in 8b33eb2) walks document → iframe.contentDocument recursively, but never descends into element.shadowRoot:
_collectDocs: function() {
var docs = [document];
function walk(doc) {
var frames = doc.querySelectorAll('iframe, frame');
for (var i = 0; i < frames.length; i++) {
var inner = frames[i].contentDocument;
if (inner && docs.indexOf(inner) === -1) { docs.push(inner); walk(inner); }
}
}
walk(document);
return docs;
}
_findMatchingElements and findByText then call doc.querySelectorAll('*') per document, which by spec does not pierce shadow boundaries. So any element behind a shadowRoot is invisible to every JS-helper-backed selector strategy. The Stage-2 cascade through page.Search() (CDP DOMPerformSearch) does pierce shadow DOM in principle, but in this run it returns either nothing usable or an outer wrapper whose textContent matches transitively, so the subsequent visibility/click step still fails.
Suggested fix
In _collectDocs(), recursively collect open shadow roots in addition to iframe documents, and in _findMatchingElements / findByText use a shadow-piercing walker (e.g. recursive * query that descends into el.shadowRoot for every node) instead of plain doc.querySelectorAll('*'). The same change should land in the per-strategy CSS / ID / testId / placeholder branches so attribute-based selectors also pierce shadow DOM.
Happy to open a PR — the patch is small and pkg/driver/browser/cdp/finder_iframe_test.go gives a natural place to add a finder_shadow_test.go peer with the same fixture style.
Description
Follow-up to #65 (closed, marked fixed in v1.1.13 by commit
8b33eb2).The 1.1.13 patch added same-origin iframe traversal to the JS helper, which fixes the trivial "Flutter Web inside iframe" case where the accessibility tree lives in light DOM. But Flutter Web actually mounts its accessibility tree inside an open shadow root attached to
<flt-glass-pane>, and the patched_collectDocs()walksiframe.contentDocumentonly — it does not descend intoshadowRoot. As a result, real Flutter Web targets still fail on 1.1.13, and the same failure also reproduces at the top frame with no iframe at all.Environment
7addd21, built2026-05-05T14:07:52Z,go1.26.0)One-line repro
A single self-contained HTML page is hosted on Cloudflare R2 — no app login, no third-party site, no local HTTP server. Save the yaml below and run:
maestro-runner --platform web test repro.yamlActual output (verbatim)
The dialog renders visually (a screenshot or a real-browser visit to the R2 URL confirms the blue Close button is on screen), but the selector engine cannot resolve
DIALOG_BODY_TEXTbecause the matching<flt-semantics aria-label="DIALOG_BODY_TEXT">lives inside a shadow root.Repro page source
The hosted page is the self-contained file below — copy it into
repro.htmland serve from anywhere if you'd rather not hit R2. The R2 host is just a convenience.repro.html— single file, ~3 KBBug is broader than iframe — top-frame shadow DOM also fails
To prove the gap is shadow DOM and not iframe, the same content rendered at the top frame with no iframe also fails:
topframe-shadow.htmlFor comparison, removing the shadow root (rendering the same
<flt-semantics>tree as light DOM children of<flt-glass-pane>, inside an iframe) makes the same flow PASS on 1.1.13 — i.e. iframe traversal itself is working; shadow DOM is the missing piece.Expected
Selectors traverse open shadow roots, so any text rendered as
<flt-semantics aria-label="…">(or any aria-label / textContent / placeholder match) inside an open shadow root resolves regardless of whether that shadow root is at the top frame or inside a same-origin iframe. This is what is required to drive Flutter Web targets, and it matches what upstream Maestro 2.5.1 does.Root cause
pkg/driver/browser/cdp/jshelper.js—_collectDocs()(added in8b33eb2) walksdocument→iframe.contentDocumentrecursively, but never descends intoelement.shadowRoot:_findMatchingElementsandfindByTextthen calldoc.querySelectorAll('*')per document, which by spec does not pierce shadow boundaries. So any element behind ashadowRootis invisible to every JS-helper-backed selector strategy. The Stage-2 cascade throughpage.Search()(CDPDOMPerformSearch) does pierce shadow DOM in principle, but in this run it returns either nothing usable or an outer wrapper whosetextContentmatches transitively, so the subsequent visibility/click step still fails.Suggested fix
In
_collectDocs(), recursively collect open shadow roots in addition to iframe documents, and in_findMatchingElements/findByTextuse a shadow-piercing walker (e.g. recursive*query that descends intoel.shadowRootfor every node) instead of plaindoc.querySelectorAll('*'). The same change should land in the per-strategy CSS / ID / testId / placeholder branches so attribute-based selectors also pierce shadow DOM.Happy to open a PR — the patch is small and
pkg/driver/browser/cdp/finder_iframe_test.gogives a natural place to add afinder_shadow_test.gopeer with the same fixture style.