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
+
+
+
+
+
+
+
+
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?" } }),