diff --git a/internal/driver/chrome/driver.go b/internal/driver/chrome/driver.go index 861bbf7..95c8fae 100644 --- a/internal/driver/chrome/driver.go +++ b/internal/driver/chrome/driver.go @@ -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, diff --git a/internal/driver/chrome/element_state_test.go b/internal/driver/chrome/element_state_test.go index 6511115..dd4adae 100644 --- a/internal/driver/chrome/element_state_test.go +++ b/internal/driver/chrome/element_state_test.go @@ -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, diff --git a/internal/driver/chrome/testdata/shadow-focus.html b/internal/driver/chrome/testdata/shadow-focus.html new file mode 100644 index 0000000..7066972 --- /dev/null +++ b/internal/driver/chrome/testdata/shadow-focus.html @@ -0,0 +1,17 @@ + + +
+ +