fix(chrome): focus descends into the shadow root

document.activeElement names the host, not the node focused inside it, so
a Compose-for-wasm app that mounts its tree in a shadow root reported
focus on div#app forever. confirmFocus could never be satisfied and every
InputText step aborted the run after three tries.

selectAllScript already descends the boundary; the tree builder did not.
This commit is contained in:
pj committed 2026-08-17 23:44:21 +05:30
1 parent 0bfd194332
commit 071f9c7f52
3 files changed
+81 -1

No files matched your search

+8 -1
View File
@@ -642,6 +642,13 @@ func (d *Driver) Hierarchy(ctx context.Context) (string, error) {
', [onclick]'));
const editableSet = new Set(deepQuery(
'input, textarea, [contenteditable]').filter(isEditableElement));
// Descended once for the whole dump, for the reason selectAllScript above
// descends: document.activeElement names the shadow host, so a Compose for
// Web app reported focus on its mount element and never on the field.
let focusedElement = document.activeElement;
while (focusedElement && focusedElement.shadowRoot && focusedElement.shadowRoot.activeElement) {
focusedElement = focusedElement.shadowRoot.activeElement;
}
function buildTree(el, isRoot) {
const rect = el.getBoundingClientRect();
// Every attribute the markup wrote, keyed as written, which is what attrs
@@ -704,7 +711,7 @@ func (d *Driver) Hierarchy(ctx context.Context) (string, error) {
children: children,
clickable: isClickable || null,
enabled: isEnabled(el) || null,
focused: document.activeElement === el || null,
focused: focusedElement === el || null,
// A component keeps what it likes in these two properties, so what is
// emitted is the flag the field declares and not the property's value.
checked: el.checked === true || null,
@@ -191,6 +191,62 @@ func TestElementState_ReportsTheOtherDocumentedBooleans(t *testing.T) {
)
}
// Focus belongs to the node the user is typing into, not to the element the
// shadow tree is mounted on.
//
// document.activeElement stops at a shadow boundary and names the host, so a
// Compose for Web app reports focus on its mount element forever. confirmFocus
// in internal/runner/runner.go re-reads the dump after a focus tap and refuses
// to type when the field it tapped is not the one holding focus, so every
// InputText step on such an app failed and the run aborted.
func TestElementState_FocusDescendsIntoTheShadowRoot(t *testing.T) {
server := httptest.NewServer(http.FileServer(http.Dir("testdata")))
defer server.Close()
d := New()
defer d.Terminate(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if err := d.Launch(ctx, server.URL+"/shadow-focus.html", false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
field := elementInHierarchyDump(ctx, t, d, "id:shadow-field")
x, y := field.Bounds.Center()
if err := d.Tap(ctx, x, y); err != nil {
t.Fatalf("Tap the field: %v", err)
}
if tapped := elementInHierarchyDump(ctx, t, d, "id:shadow-field"); !tapped.Focused {
t.Error("the field inside the shadow root reports no focus after being tapped")
}
if host := elementInHierarchyDump(ctx, t, d, "id:app"); host.Focused {
t.Error("the shadow host reports focus, so the text would land there")
}
}
func elementInHierarchyDump(
ctx context.Context,
t *testing.T,
d *Driver,
selector string,
) *hierarchy.Element {
t.Helper()
dump, err := d.Hierarchy(ctx)
if err != nil {
t.Fatalf("Hierarchy: %v", err)
}
tree, err := hierarchy.Parse(dump)
if err != nil {
t.Fatalf("parse hierarchy: %v", err)
}
element := tree.Find(selector)
if element == nil {
t.Fatalf("the hierarchy dump holds no element matching %q", selector)
}
return element
}
func requireBoolean(
ctx context.Context,
t *testing.T,
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html id="page">
<head id="page-head">
<meta id="page-charset" charset="utf-8" />
<title id="page-title">shadow focus</title>
</head>
<body id="page-body">
<!-- Shaped like Compose for Web: the app mounts its accessibility tree
inside a shadow root on this element, so document.activeElement answers
with the host for every field the user is typing into. -->
<div id="app"></div>
<script id="page-script">
const root = document.getElementById("app").attachShadow({ mode: "open" });
root.innerHTML = `<input id="shadow-field" type="text" />`;
</script>
</body>
</html>