Summary
Rustwright pierces open shadow roots when a CSS selector is used on its own, but not when the same selector is given any ancestor context — either a descendant combinator (.host [role=treeitem]) or a chained Locator.locator(...). Playwright pierces open shadow roots in all three forms.
The inconsistency is the real problem: page.locator(SEL) finding the element while page.locator(".host").locator(SEL) finds nothing means scoping a selector to a container silently changes whether it can see shadow content.
This is painful in practice because it fails silently. The locator resolves to zero elements, so click() raises a timeout rather than an error naming the cause. Any test that wraps clicks in a retry/suppress loop just spins until its deadline and reports a misleading downstream assertion.
Repro
Deterministic, local, no network. 3/3 runs.
import asyncio, http.server, socket, sys, threading
PAGE = b"""<!doctype html>
<html><body>
<div class="host"><my-tree></my-tree></div>
<script>
class MyTree extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' }).innerHTML =
'<div role="treeitem" data-path="a.py">a.py</div>';
}
}
customElements.define('my-tree', MyTree);
</script>
</body></html>"""
SEL = '[role="treeitem"][data-path="a.py"]'
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.send_header("Content-Length", str(len(PAGE)))
self.end_headers()
self.wfile.write(PAGE)
def log_message(self, *a): pass
def serve():
s = socket.socket(); s.bind(("127.0.0.1", 0)); port = s.getsockname()[1]; s.close()
threading.Thread(target=http.server.ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever, daemon=True).start()
return port
async def run(module, port):
api = __import__(f"{module}.async_api", fromlist=["async_playwright"])
async with api.async_playwright() as pw:
b = await pw.chromium.launch(headless=True)
page = await b.new_page()
await page.goto(f"http://127.0.0.1:{port}/")
print(f"{module:11s} unanchored={await page.locator(SEL).count()} "
f"'.host {{sel}}'={await page.locator(f'.host {SEL}').count()} "
f"chained={await page.locator('.host').locator(SEL).count()}")
await b.close()
PORT = serve()
for m in sys.argv[1:] or ["playwright", "rustwright"]:
asyncio.run(run(m, PORT))
Result
playwright unanchored=1 '.host {sel}'=1 chained=1
rustwright unanchored=1 '.host {sel}'=0 chained=0
Expected: rustwright matches playwright on all three (1 / 1 / 1).
With click() instead of count(), the anchored forms fail as:
TimeoutError: timed out waiting for locator to be actionable while trying to click; no element matched
Environment
- rustwright 0.1.1 (PyPI) and git
ec130a64 — both reproduce identically
- playwright 1.61.0 for comparison
- Chromium headless shell 1228, Linux x86_64, Python 3.14
Why it matters
Found while evaluating rustwright as a drop-in for a ~67-test Playwright suite. The app's file tree is @pierre/trees, which renders rows into a custom element's open shadow root. Helpers written the natural way —
tree = page.locator(".pierre-tree-host")
row = tree.locator(f'[role="treeitem"][data-item-path="{filename}"]').first
await row.click()
— resolve to nothing, so every file-tree interaction in the suite failed. Dropping the ancestor context works around it, but that means container-scoped selectors, which are the idiomatic way to disambiguate, can't be used against shadow content.
Summary
Rustwright pierces open shadow roots when a CSS selector is used on its own, but not when the same selector is given any ancestor context — either a descendant combinator (
.host [role=treeitem]) or a chainedLocator.locator(...). Playwright pierces open shadow roots in all three forms.The inconsistency is the real problem:
page.locator(SEL)finding the element whilepage.locator(".host").locator(SEL)finds nothing means scoping a selector to a container silently changes whether it can see shadow content.This is painful in practice because it fails silently. The locator resolves to zero elements, so
click()raises a timeout rather than an error naming the cause. Any test that wraps clicks in a retry/suppress loop just spins until its deadline and reports a misleading downstream assertion.Repro
Deterministic, local, no network. 3/3 runs.
Result
Expected: rustwright matches playwright on all three (
1 / 1 / 1).With
click()instead ofcount(), the anchored forms fail as:Environment
ec130a64— both reproduce identicallyWhy it matters
Found while evaluating rustwright as a drop-in for a ~67-test Playwright suite. The app's file tree is
@pierre/trees, which renders rows into a custom element's open shadow root. Helpers written the natural way —— resolve to nothing, so every file-tree interaction in the suite failed. Dropping the ancestor context works around it, but that means container-scoped selectors, which are the idiomatic way to disambiguate, can't be used against shadow content.