diff --git a/internal/driver/chrome/driver.go b/internal/driver/chrome/driver.go index 95c8fae..cd5cb0a 100644 --- a/internal/driver/chrome/driver.go +++ b/internal/driver/chrome/driver.go @@ -649,6 +649,36 @@ func (d *Driver) Hierarchy(ctx context.Context) (string, error) { while (focusedElement && focusedElement.shadowRoot && focusedElement.shadowRoot.activeElement) { focusedElement = focusedElement.shadowRoot.activeElement; } + // Descending is still not enough on Compose for Web: it takes keystrokes on a + // 1px transparent input pinned to the caret, and that input is a SIBLING of + // the accessibility tree rather than a node in it. DOM focus therefore never + // reaches the semantics element carrying the test tag, so confirmFocus in + // internal/runner/runner.go saw an unnamed element hold focus after every + // focus tap and refused to type. Compose declares the caret's box in these + // custom properties, which the input inherits from the container that + // positions it, so the field being typed into is the innermost editable box + // that caret sits in. + const CARET_ORIGIN_PROPERTY = '--compose-internal-web-backing-input-left'; + function fieldBehindTheCaret(caretInput) { + if (!caretInput || caretInput.tagName !== 'INPUT') return null; + if (!getComputedStyle(caretInput).getPropertyValue(CARET_ORIGIN_PROPERTY).trim()) return null; + const caret = caretInput.getBoundingClientRect(); + const x = (caret.left + caret.right) / 2; + const y = (caret.top + caret.bottom) / 2; + let field = null; + let fieldArea = Infinity; + for (const candidate of editableSet) { + if (candidate === caretInput) continue; + const box = candidate.getBoundingClientRect(); + const area = box.width * box.height; + if (area <= 0 || area >= fieldArea) continue; + if (x < box.left || x > box.right || y < box.top || y > box.bottom) continue; + field = candidate; + fieldArea = area; + } + return field; + } + focusedElement = fieldBehindTheCaret(focusedElement) || focusedElement; function buildTree(el, isRoot) { const rect = el.getBoundingClientRect(); // Every attribute the markup wrote, keyed as written, which is what attrs diff --git a/internal/driver/chrome/element_state_test.go b/internal/driver/chrome/element_state_test.go index dd4adae..ed62e5e 100644 --- a/internal/driver/chrome/element_state_test.go +++ b/internal/driver/chrome/element_state_test.go @@ -225,6 +225,69 @@ func TestElementState_FocusDescendsIntoTheShadowRoot(t *testing.T) { } } +// Focus belongs to the field the caret sits in, not to the input the caret is. +// +// Compose for Web takes keystrokes on a 1px transparent input pinned to the +// caret, and that input is a sibling of the accessibility tree rather than a +// node in it. Descending activeElement through the shadow roots therefore lands +// on a node no selector can name, and every semantics element reads unfocused, +// so confirmFocus in internal/runner/runner.go rejected each focus tap with "an +// unnamed element holds focus" and no InputText step ever ran. +// +// Both fields are tapped, because reporting the first editable in the tree +// would satisfy the email half of this and still type into the wrong field. +func TestElementState_FocusFollowsTheCaretToItsField(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+"/compose-backing-input.html", false, nil); err != nil { + t.Fatalf("Launch: %v", err) + } + + for _, field := range []string{"EmailField", "PasswordField"} { + tapped := elementInHierarchyDump(ctx, t, d, "id:"+field) + x, y := tapped.Bounds.Center() + if err := d.Tap(ctx, x, y); err != nil { + t.Fatalf("Tap %s: %v", field, err) + } + + if focused := elementInHierarchyDump(ctx, t, d, "id:"+field); !focused.Focused { + t.Errorf("%s reports no focus after being tapped", field) + } + if caret := elementInHierarchyDump(ctx, t, d, "id:caret-input"); caret.Focused { + t.Errorf("the hidden caret input reports focus after tapping %s, "+ + "and no selector can name it", field) + } + if held := focusedElements(ctx, t, d); len(held) != 1 { + t.Errorf("after tapping %s the dump reports %d focused elements %v, want 1", + field, len(held), held) + } + } +} + +func focusedElements(ctx context.Context, t *testing.T, d *Driver) []string { + 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) + } + var held []string + for _, element := range tree.Elements { + if element.Focused { + held = append(held, element.ResourceID+"/"+element.Attributes["tag"]) + } + } + return held +} + func elementInHierarchyDump( ctx context.Context, t *testing.T, diff --git a/internal/driver/chrome/testdata/compose-backing-input.html b/internal/driver/chrome/testdata/compose-backing-input.html new file mode 100644 index 0000000..f68a5be --- /dev/null +++ b/internal/driver/chrome/testdata/compose-backing-input.html @@ -0,0 +1,80 @@ + + + + + compose backing input + + + +
+ + +