fix(chrome): emit every markup attribute and read checked and selected off the property

the dump emitted a fixed standard attribute set, so a spec reading data-cents or data-account-id saw undefined on the goja host and nothing at all in the trace. it now keys every attribute by the name the markup writes, derived keys overwriting. checked and selected come from the dom property rather than whatever a component left on the object, which is also what the page-side element handle now reports, so a ticked box reads as ticked instead of reporting its starting state forever.
This commit is contained in:
pj committed 2026-08-16 17:44:04 +05:30
1 parent de654334b0
commit 05c8523762
8 files changed
+605 -2

No files matched your search

+14 -2
View File
@@ -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
+46
View File
@@ -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 = `<body>` +
`<div id="total-balance" data-cents="125000">$1,250.00</div>` +
`<div id="card" data-testid="account-card" data-account-id="acct-7" data-balance="4200">Tim</div>` +
`</body>`
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)
}
}
@@ -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)
}
}
@@ -0,0 +1,22 @@
<!doctype html>
<html id="page">
<head id="page-head">
<meta id="page-charset" charset="utf-8" />
<title id="page-title">custom element flags</title>
</head>
<body id="page-body">
<div id="tabs">
<a id="tab-all" href="#/">All</a>
<a id="tab-active" href="#/active">Active</a>
</div>
<div id="picker"></div>
<input id="toggle" type="checkbox" checked />
<script id="page-script">
// What a component keeps in these properties is whatever the component
// means by them: a selector element names the selected item here rather
// than saying whether it is itself selected.
document.getElementById('tabs').selected = 'active';
document.getElementById('picker').checked = 'partial';
</script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html id="page">
<head id="page-head">
<meta id="page-charset" charset="utf-8" />
<title id="page-title">element state</title>
</head>
<body id="page-body">
<!-- toggle-all starts unchecked in the markup and toggle-done starts
checked, so a reader of the `checked` ATTRIBUTE reports each one's
starting value forever and disagrees with both after one click. -->
<input id="toggle-all" type="checkbox" />
<input id="toggle-done" type="checkbox" checked />
<input id="editing" type="text" value="buy milk" />
<button id="save">save</button>
<button id="cancel" disabled>cancel</button>
<select id="filter">
<option id="filter-all">all</option>
<option id="filter-active" selected>active</option>
</select>
<script id="page-script">
window.__keys__ = [];
document.addEventListener('keydown', function (event) {
window.__keys__.push(event.key);
});
</script>
</body>
</html>