mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
48 of 51 call sites shared one eight-line block that opened a fixture server, launched chrome and checked the error. The three left are the launch tests themselves, where what the helper hardcodes is the subject. The audit called four tests superseded by fact_parity_test.go. They were not: parity asserts the two producers agree and that each fact appears both ways, never that either reading is right, so deleting them would have removed the only thing anchoring the pair to reality. The values they pinned are now asserted inside parity against both producers, and the shadow fixture has ground truth where it had none.
489 lines
14 KiB
Go
489 lines
14 KiB
Go
//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)
|
|
}
|
|
}
|