diff --git a/internal/driver/chrome/driver.go b/internal/driver/chrome/driver.go index e155de9..dcdcfbe 100644 --- a/internal/driver/chrome/driver.go +++ b/internal/driver/chrome/driver.go @@ -449,7 +449,17 @@ func (d *Driver) Hierarchy(ctx context.Context) (string, error) { 'input, textarea, [contenteditable]').filter(isEditableElement)); function buildTree(el, isRoot) { const rect = el.getBoundingClientRect(); + // Every attribute the markup wrote, keyed as written, which is what attrs + // means on the native hosts and what rawAttributes in + // pkg/spec/src/web-runtime.ts already gives the page-side handle. Emitting + // only the standard set left a spec's data-* reads (folio-web's data-cents, + // data-account-id, data-balance) undefined on the goja host and absent from + // the trace, so an offline replay of the same step could not see them at + // all. The derived keys below overwrite anything of the same name. const attrs = {}; + for (const attribute of el.attributes || []) { + attrs[attribute.name] = attribute.value; + } const bounds = '[' + Math.round(rect.left) + ',' + Math.round(rect.top) + ',' + Math.round(rect.right) + ',' + Math.round(rect.bottom) + ']'; if (rect.width > 0 || rect.height > 0) attrs.bounds = bounds; @@ -500,8 +510,10 @@ func (d *Driver) Hierarchy(ctx context.Context) (string, error) { clickable: isClickable || null, enabled: isEnabled(el) || null, focused: document.activeElement === el || null, - checked: el.checked || null, - selected: el.selected || 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, + selected: el.selected === true || null, // Emitted as a plain boolean, never null: internal/hierarchy falls back to // the native heuristic when the field is absent, which reads any class // name containing "EditText" as an Android text widget. On web that is a diff --git a/internal/driver/chrome/driver_test.go b/internal/driver/chrome/driver_test.go index f63c6e6..fa0f9a3 100644 --- a/internal/driver/chrome/driver_test.go +++ b/internal/driver/chrome/driver_test.go @@ -1038,3 +1038,49 @@ func TestEvaluateExtractors_RejectsAnUnenvelopedReading(t *testing.T) { t.Errorf("EvaluateExtractors failed with %q, want it to name the bundle mismatch", err) } } + +// TestHierarchy_CarriesEveryMarkupAttribute covers the data the spec actually +// reads. folio-web's extractors read data-cents, data-account-id and +// data-balance off the elements they find; the dump used to emit a fixed +// standard set, so those values were absent from the goja host and from every +// stored trace, and a selector over them resolved nothing offline. +func TestHierarchy_CarriesEveryMarkupAttribute(t *testing.T) { + const html = `` + + `
$1,250.00
` + + `
Tim
` + + `` + + d := New() + defer d.Terminate(context.Background()) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := d.Launch(ctx, "data:text/html,"+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 hierarchy: %v", err) + } + + total := tree.Find("id:total-balance") + if total == nil { + t.Fatal("total-balance not in the dump") + } + if got := total.Attributes["data-cents"]; got != "125000" { + t.Errorf(`attrs["data-cents"] = %q, want "125000"`, got) + } + card := tree.Find(`data-account-id:acct-7`) + if card == nil { + t.Fatal("no element resolves by a data attribute the markup carries") + } + if got := card.Attributes["data-balance"]; got != "4200" { + t.Errorf(`attrs["data-balance"] = %q, want "4200"`, got) + } + if got := card.Attributes["data-testid"]; got != "account-card" { + t.Errorf(`attrs["data-testid"] = %q, want "account-card"`, got) + } +} diff --git a/internal/driver/chrome/element_state_test.go b/internal/driver/chrome/element_state_test.go new file mode 100644 index 0000000..6511115 --- /dev/null +++ b/internal/driver/chrome/element_state_test.go @@ -0,0 +1,426 @@ +//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, + ) +} + +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) + } +} diff --git a/internal/driver/chrome/testdata/custom-element-flags.html b/internal/driver/chrome/testdata/custom-element-flags.html new file mode 100644 index 0000000..b95e5a8 --- /dev/null +++ b/internal/driver/chrome/testdata/custom-element-flags.html @@ -0,0 +1,22 @@ + + + + + custom element flags + + +
+ All + Active +
+
+ + + + diff --git a/internal/driver/chrome/testdata/element-state.html b/internal/driver/chrome/testdata/element-state.html new file mode 100644 index 0000000..8516c87 --- /dev/null +++ b/internal/driver/chrome/testdata/element-state.html @@ -0,0 +1,27 @@ + + + + + element state + + + + + + + + + + + + diff --git a/pkg/spec/src/web-runtime.ts b/pkg/spec/src/web-runtime.ts index 0c8636a..e73ab1e 100644 --- a/pkg/spec/src/web-runtime.ts +++ b/pkg/spec/src/web-runtime.ts @@ -499,6 +499,7 @@ function isEnabled(element: Element): boolean { } function elementHandle(element: Element, selector: unknown): Record { + const state = element as Partial; const rect = element.getBoundingClientRect(); const x = Math.round(rect.left + rect.width / 2); const y = Math.round(rect.top + rect.height / 2); @@ -529,6 +530,13 @@ function elementHandle(element: Element, selector: unknown): Record + +// The V8 host's element handle, read back state field by state field. +// +// internal/driver/chrome/element_state_test.go bundles this probe into a live +// page and compares what it returns against the hierarchy dump the goja host +// reads off the SAME page, before and after a real click. buildAx is the +// shipped `state.ax`, so the answer comes from production code rather than a +// copy of it. `attrChecked` rides along because the markup attribute is what an +// implementation reading the wrong source would report, and a failure naming it +// says so instead of just naming the wrong boolean. + +import { __testing__ } from "../src/web-runtime.ts"; + +const { buildAx } = __testing__; + +interface Handle { + checked?: boolean; + enabled?: boolean; + focused?: boolean; + selected?: boolean; + attrs?: Record; +} + +interface Ax { + find(selector: unknown): Handle | undefined; +} + +function elementState(id: string): unknown { + const handle = (buildAx() as Ax).find({ id }); + if (!handle) return null; + return { + checked: handle.checked, + enabled: handle.enabled, + focused: handle.focused, + selected: handle.selected, + attrChecked: handle.attrs?.checked ?? null, + }; +} + +type StateGlobal = { __sanderlingElementState__: (id: string) => unknown }; + +(globalThis as unknown as StateGlobal).__sanderlingElementState__ = elementState; diff --git a/pkg/spec/test/web-runtime.test.ts b/pkg/spec/test/web-runtime.test.ts index c064516..a48f0d0 100644 --- a/pkg/spec/test/web-runtime.test.ts +++ b/pkg/spec/test/web-runtime.test.ts @@ -547,12 +547,14 @@ function domElement(spec: { attributes?: Record; labels?: string[]; text?: string; + checked?: boolean; }): unknown { const attributes = spec.attributes ?? {}; return { tagName: spec.tag.toUpperCase(), type: spec.tag === "input" ? "text" : "", isContentEditable: false, + checked: spec.checked, id: attributes.id ?? "", className: attributes.class ?? "", textContent: spec.text ?? "", @@ -620,6 +622,23 @@ test("attrs keys data attributes by the name the markup writes", () => { assert.deepEqual(handleOf(element).dataset, { txnCount: "3", accountId: "a-1" }); }); +// docs/manual/spec-language.md lists `checked` on every element find returns, +// and HTML keeps that state in the DOM property: the markup attribute only +// records what the page started with. A handle reading the attribute reports a +// checkbox's starting state forever, so a property over "the box is ticked" +// holds on a page where nothing was ever ticked. +test("checked reads the live property, not the markup attribute", () => { + const ticked = domElement({ tag: "input", attributes: { id: "toggle-all" }, checked: true }); + assert.equal(handleOf(ticked).checked, true); + + const cleared = domElement({ + tag: "input", + attributes: { id: "toggle-all", checked: "" }, + checked: false, + }); + assert.equal(handleOf(cleared).checked, false); +}); + test("attrs carries every other attribute alongside tag and aria-label", () => { const attrs = attrsOf( domElement({ tag: "input", attributes: { id: "txn-note", placeholder: "What's this for?" } }),