mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
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:
1 parent
de654334b0
commit
05c8523762
8 files changed
+605
-2
No files matched your search
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -499,6 +499,7 @@ function isEnabled(element: Element): boolean {
|
||||
}
|
||||
|
||||
function elementHandle(element: Element, selector: unknown): Record<string, unknown> {
|
||||
const state = element as Partial<HTMLInputElement & HTMLOptionElement>;
|
||||
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<string, unkn
|
||||
enabled: isEnabled(element),
|
||||
editable: isEditableElement(element as HTMLElement),
|
||||
focused: document.activeElement === element,
|
||||
// Checkbox and option state lives in the DOM PROPERTY: the markup attribute
|
||||
// records only what the page started with, so a handle reading it reports a
|
||||
// box's initial state however often the user ticks it.
|
||||
// internal/driver/chrome/driver.go reads the same two properties for the
|
||||
// dump the goja host gets.
|
||||
checked: state.checked === true,
|
||||
selected: state.selected === true,
|
||||
x,
|
||||
y,
|
||||
bounds: {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/// <reference lib="dom" />
|
||||
|
||||
// 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<string, string>;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -547,12 +547,14 @@ function domElement(spec: {
|
||||
attributes?: Record<string, string>;
|
||||
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?" } }),
|
||||
|
||||
Reference in new issue
Block a user