//go:build browser package chrome import ( "context" "encoding/json" "path/filepath" "slices" "testing" "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) { d, ctx := launchChrome(t, testdataServer(t).URL+"/element-state.html") 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) { d, ctx := launchChrome(t, testdataServer(t).URL+"/element-state.html") 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) { d, ctx := launchChrome(t, testdataServer(t).URL+"/shadow-focus.html") 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) { d, ctx := launchChrome(t, testdataServer(t).URL+"/compose-backing-input.html") 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) { d, ctx := launchChrome(t, testdataServer(t).URL+"/element-state.html") 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) { d, ctx := launchChrome(t, testdataServer(t).URL+"/custom-element-flags.html") 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) } }