Skip to content

Shadow-DOM piercing is inconsistent: works for an unanchored selector, fails with any ancestor context #186

Description

@adhami3310

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions