Files
sanderling/internal/driver/chrome/element_state_test.go
T
pj 5fc91fdca1 fix(chrome): focus follows the caret to the field it types into
Compose for wasm never focuses the semantics node carrying the testTag.
It proxies keystrokes through a hidden 1px backing input that is a
sibling of the a11y tree, so the node the runner tapped never held focus
and confirmFocus refused to type into every Compose text field.

Focus is re-attributed to the smallest editable whose box holds the
caret's centre. Centre-point rather than full containment because the
caret's height comes from the text style and the field's from its layout
box, so a taller font would silently drop back to refusing.
2026-08-18 00:00:24 +05:30

546 lines
16 KiB
Go

//go:build browser
package chrome
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"slices"
"testing"
"time"
"github.com/chromedp/chromedp"
"github.com/priyanshujain/sanderling/internal/bundler"
"github.com/priyanshujain/sanderling/internal/hierarchy"
)
// One page, one checkbox, two readers of its state.
//
// docs/manual/spec-language.md lists `checked` on every element find returns.
// The goja host reads it off the hierarchy dump this driver builds; the V8 host
// reads it off the live DOM through elementHandle in
// pkg/spec/src/web-runtime.ts. A field one host does not expose is silent: the
// property reading it compares undefined and holds on every screen.
//
// The state is read before and after a real click, because HTML keeps checkbox
// state in the DOM property and not in the markup attribute: an implementation
// reading element.getAttribute("checked") reports the starting value forever and
// passes any test that only reads a freshly loaded page.
func TestElementState_ChecksTrackTheLiveDOM(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+"/element-state.html", false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
installStateProbe(ctx, t, d)
requireChecked(ctx, t, d, "toggle-all", false)
requireChecked(ctx, t, d, "toggle-done", true)
clickElement(ctx, t, d, "id:toggle-all")
clickElement(ctx, t, d, "id:toggle-done")
requireChecked(ctx, t, d, "toggle-all", true)
requireChecked(ctx, t, d, "toggle-done", false)
}
// requireChecked holds both hosts to one answer. The dump is re-read per call so
// the goja side is compared at the same page state as the handle.
func requireChecked(
ctx context.Context,
t *testing.T,
d *Driver,
id string,
want bool,
) {
t.Helper()
state := elementStateFromWebRuntime(ctx, t, d, id)
if state.Checked == nil {
t.Fatalf(
"the ax handle for %q exposes no `checked` field; "+
"docs/manual/spec-language.md lists it on every element find returns",
id,
)
}
if *state.Checked != want {
t.Errorf(
"the ax handle reports %q checked=%v, want %v (its markup attribute reads %q)",
id,
*state.Checked,
want,
state.AttrChecked,
)
}
if got := checkedInHierarchyDump(ctx, t, d, id); got != want {
t.Errorf(
"the hierarchy dump reports %q checked=%v, want %v",
id,
got,
want,
)
}
}
// The rest of the boolean state the manual lists, on the same page.
//
// `selected` is read after the selection is moved off the markup's option, for
// the same reason `checked` is: the attribute records only where the page
// started.
func TestElementState_ReportsTheOtherDocumentedBooleans(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+"/element-state.html", false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
installStateProbe(ctx, t, d)
requireBoolean(
ctx,
t,
d,
"save",
"enabled",
func(s elementState) *bool { return s.Enabled },
true,
)
requireBoolean(
ctx,
t,
d,
"cancel",
"enabled",
func(s elementState) *bool { return s.Enabled },
false,
)
requireBoolean(
ctx,
t,
d,
"filter-active",
"selected",
func(s elementState) *bool { return s.Selected },
true,
)
requireBoolean(
ctx,
t,
d,
"filter-all",
"selected",
func(s elementState) *bool { return s.Selected },
false,
)
if err := chromedp.Run(
d.tabCtx,
chromedp.Evaluate(`document.getElementById('filter').selectedIndex = 0`, nil),
); err != nil {
t.Fatalf("move the selection: %v", err)
}
requireBoolean(
ctx,
t,
d,
"filter-active",
"selected",
func(s elementState) *bool { return s.Selected },
false,
)
requireBoolean(
ctx,
t,
d,
"filter-all",
"selected",
func(s elementState) *bool { return s.Selected },
true,
)
clickElement(ctx, t, d, "id:editing")
requireBoolean(
ctx,
t,
d,
"editing",
"focused",
func(s elementState) *bool { return s.Focused },
true,
)
requireBoolean(
ctx,
t,
d,
"save",
"focused",
func(s elementState) *bool { return s.Focused },
false,
)
}
// 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")
}
}
// 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,
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,
d *Driver,
id string,
field string,
read func(elementState) *bool,
want bool,
) {
t.Helper()
got := read(elementStateFromWebRuntime(ctx, t, d, id))
if got == nil {
t.Fatalf(
"the ax handle for %q exposes no `%s` field; "+
"docs/manual/spec-language.md lists it on every element find returns",
id, field,
)
}
if *got != want {
t.Errorf(
"the ax handle reports %q %s=%v, want %v",
id,
field,
*got,
want,
)
}
}
// elementState is the boolean state one element reports, as pointers: a field
// the handle does not expose at all decodes as absent rather than as false.
type elementState struct {
Checked *bool `json:"checked"`
Enabled *bool `json:"enabled"`
Focused *bool `json:"focused"`
Selected *bool `json:"selected"`
AttrChecked string `json:"attrChecked"`
}
func elementStateFromWebRuntime(
ctx context.Context,
t *testing.T,
d *Driver,
id string,
) elementState {
t.Helper()
var encoded string
script := `JSON.stringify(window.__sanderlingElementState__(` + jsArgument(
id,
) + `))`
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &encoded)); err != nil {
t.Fatalf("read web runtime state for %q: %v", id, err)
}
if encoded == "null" {
t.Fatalf("the web runtime resolved no element for id %q", id)
}
var state elementState
if err := json.Unmarshal([]byte(encoded), &state); err != nil {
t.Fatalf("decode web runtime state: %v", err)
}
return state
}
func checkedInHierarchyDump(
ctx context.Context,
t *testing.T,
d *Driver,
id string,
) bool {
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("id:" + id)
if element == nil {
t.Fatalf("the hierarchy dump holds no element with id %q", id)
}
return element.Checked
}
func clickElement(
ctx context.Context,
t *testing.T,
d *Driver,
selector string,
) {
t.Helper()
if err := d.TapSelector(ctx, selector); err != nil {
t.Fatalf("TapSelector(%q): %v", selector, err)
}
}
func installStateProbe(ctx context.Context, t *testing.T, d *Driver) {
t.Helper()
specSource := filepath.Join(repoRootDir(t), "pkg", "spec")
probe, err := bundler.BundleWeb(bundler.WebOptions{
EntryFile: filepath.Join(specSource, "test", "dom-state-probe.ts"),
WebRuntimeFile: filepath.Join(specSource, "src", "web-runtime.ts"),
})
if err != nil {
t.Fatalf("bundle dom state probe: %v", err)
}
if err := d.InstallBundle(ctx, probe.JavaScript); err != nil {
t.Fatalf("install dom state probe: %v", err)
}
}
// Every key the manual offers, measured at the page.
//
// A key name the driver does not map presses nothing, and a spec clause written
// over it ("escape discards the edit in progress") can never fail: the run stays
// green having actuated nothing. The page records its own keydown events, so
// what is asserted here is what the DOM received, not what the driver sent.
func TestPressKey_ArrivesAtThePage(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+"/element-state.html", false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
for _, keyCase := range []struct{ key, want string }{
{"enter", "Enter"},
{"tab", "Tab"},
{"escape", "Escape"},
{"up", "ArrowUp"},
{"down", "ArrowDown"},
{"left", "ArrowLeft"},
{"right", "ArrowRight"},
} {
t.Run(keyCase.key, func(t *testing.T) {
forgetKeys(ctx, t, d)
if err := d.PressKey(ctx, keyCase.key); err != nil {
t.Fatalf("PressKey(%q): %v", keyCase.key, err)
}
got := keysSeenByThePage(ctx, t, d)
if !slices.Equal(got, []string{keyCase.want}) {
t.Errorf("PressKey(%q) reached the page as %v, want [%s]",
keyCase.key, got, keyCase.want)
}
})
}
// back and home have no browser meaning, and reporting that is the whole
// point: a key that quietly presses nothing is indistinguishable from a
// requirement that holds.
for _, key := range []string{"back", "home"} {
t.Run(key+" is reported unsupported", func(t *testing.T) {
forgetKeys(ctx, t, d)
if err := d.PressKey(ctx, key); err == nil {
t.Errorf("PressKey(%q) reported no error on web", key)
}
if got := keysSeenByThePage(ctx, t, d); len(got) != 0 {
t.Errorf("PressKey(%q) reached the page as %v", key, got)
}
})
}
}
func forgetKeys(ctx context.Context, t *testing.T, d *Driver) {
t.Helper()
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(`window.__keys__ = []`, nil)); err != nil {
t.Fatalf("reset recorded keys: %v", err)
}
}
func keysSeenByThePage(ctx context.Context, t *testing.T, d *Driver) []string {
t.Helper()
var encoded string
script := `JSON.stringify(window.__keys__)`
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &encoded)); err != nil {
t.Fatalf("read recorded keys: %v", err)
}
var keys []string
if err := json.Unmarshal([]byte(encoded), &keys); err != nil {
t.Fatalf("decode recorded keys: %v", err)
}
return keys
}
// The dump declares clickable, enabled, checked, selected and editable as
// flags, and a component keeps whatever it likes in the properties two of them
// are read from: a selector element names the selected item, not a boolean.
// Emitting the property raw cost the whole observation, because a dump is
// decoded as one document and one string in it fails all of it.
// pkg/spec/src/web-runtime.ts already answers `state.selected === true`, so the
// two hosts also disagreed about the same fact on the same page.
func TestElementState_AComponentPropertyDoesNotBlankTheTree(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+"/custom-element-flags.html", false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
dump, err := d.Hierarchy(ctx)
if err != nil {
t.Fatalf("Hierarchy: %v", err)
}
tree, err := hierarchy.Parse(dump)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if tree.UnreadableFlags != 0 {
t.Errorf("UnreadableFlags = %d, want 0: the dump must send booleans for the fields it declares as flags", tree.UnreadableFlags)
}
tabs := tree.Find("id:tabs")
if tabs == nil {
t.Fatalf("the element holding the string property is missing from a tree of %d elements", len(tree.Elements))
}
if tabs.Selected {
t.Error("selected must be false: the property holds an item name, not a flag")
}
if picker := tree.Find("id:picker"); picker == nil || picker.Checked {
t.Errorf("checked must be false for a property holding a string, got %+v", picker)
}
if toggle := tree.Find("id:toggle"); toggle == nil || !toggle.Checked {
t.Errorf("a real checkbox must still read checked, got %+v", toggle)
}
}