diff --git a/internal/driver/chrome/driver.go b/internal/driver/chrome/driver.go
index b58b2b2..bdf8b90 100644
--- a/internal/driver/chrome/driver.go
+++ b/internal/driver/chrome/driver.go
@@ -357,6 +357,24 @@ func (d *Driver) Hierarchy(ctx context.Context) (string, error) {
script := `
(function() {
const route = window.location.hash.replace(/^#/, '').split('?')[0] || '/';
+ // clickable and editable are resolved through the SAME selector sets
+ // pkg/spec/src/web-runtime.ts uses, so the goja host (which reads this dump)
+ // and the V8 host (which reads the DOM directly) cannot mean different things
+ // by one fact on one platform. Testing el.onclick instead made every React
+ // root a full-viewport tap target here and nowhere else.
+ const NON_TEXT_INPUT_TYPES =
+ ['button','submit','checkbox','radio','range','color','file','image','reset'];
+ function isEditableElement(el) {
+ if (el.isContentEditable) return true;
+ const tag = el.tagName.toLowerCase();
+ if (tag === 'textarea') return true;
+ if (tag === 'input') return !NON_TEXT_INPUT_TYPES.includes((el.type || '').toLowerCase());
+ return false;
+ }
+ const clickableSet = new Set(document.querySelectorAll(
+ 'a, button, input, select, textarea, [role="button"], [onclick]'));
+ const editableSet = new Set(Array.from(
+ document.querySelectorAll('input, textarea, [contenteditable]')).filter(isEditableElement));
function buildTree(el, isRoot) {
const rect = el.getBoundingClientRect();
const attrs = {};
@@ -373,15 +391,19 @@ func (d *Driver) Hierarchy(ctx context.Context) (string, error) {
if (el.className && typeof el.className === 'string' && el.className.trim()) {
attrs['class'] = el.className.trim();
}
+ // The goja host reads scrollable off this attribute (internal/verifier
+ // worker.go targets). Without it every web element looks unscrollable there,
+ // so the goja-side enumeration offers no scroll while the V8 picker, which
+ // computes the same overflow test in web-runtime.ts, offers plenty.
+ if (el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth) {
+ attrs['scrollable'] = 'true';
+ }
if (isRoot) attrs['sanderling-screen'] = route;
- const isClickable = !!(el.onclick || el.tagName === 'A' || el.tagName === 'BUTTON' ||
- el.tagName === 'INPUT' || el.tagName === 'SELECT' ||
- el.getAttribute('role') === 'button' || el.getAttribute('onclick'));
- const isEditable = el.isContentEditable || tag === 'textarea' ||
- (tag === 'input' && !['button','submit','checkbox','radio','range','color','file','image','reset']
- .includes((el.type || '').toLowerCase()));
+ const isClickable = clickableSet.has(el);
+ const isEditable = editableSet.has(el);
const children = [];
for (const child of el.children) {
+ if (child.tagName === 'HEAD') continue;
children.push(buildTree(child, false));
}
return {
@@ -395,7 +417,14 @@ func (d *Driver) Hierarchy(ctx context.Context) (string, error) {
editable: isEditable || null,
};
}
- return buildTree(document.body, true);
+ // Rooted at documentElement, not body, because collectTargets in
+ // pkg/spec/src/web-runtime.ts walks querySelectorAll("*") and therefore sees
+ // html. Page-level scrolling lives on html on a standard page, so a dump
+ // rooted at body hides it from the goja host and the two enumerations
+ // disagree on exactly the page scroll. The head subtree is skipped: it is all
+ // zero-bounds, so it changes no eligible set, and it would otherwise pull
+ // script and title text into the trace and the replay view.
+ return buildTree(document.documentElement, true);
})()`
var result any
diff --git a/internal/driver/chrome/driver_test.go b/internal/driver/chrome/driver_test.go
index 15ea5e5..7f21087 100644
--- a/internal/driver/chrome/driver_test.go
+++ b/internal/driver/chrome/driver_test.go
@@ -185,6 +185,125 @@ func TestHierarchy_EditableFlag(t *testing.T) {
}
}
+// TestHierarchy_ClickableMatchesTheWebRuntimeSelector pins clickable to the
+// same membership test pkg/spec/src/web-runtime.ts applies. The dump used to
+// test el.onclick, which React sets on its root container for event delegation,
+// so the whole viewport became a tap target in this dump and in no other
+// enumeration of the same page.
+func TestHierarchy_ClickableMatchesTheWebRuntimeSelector(t *testing.T) {
+ const html = `
` +
+ `` +
+ `` +
+ `
text
` +
+ `
act
` +
+ `` +
+ ``
+
+ 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)
+ }
+
+ type node struct {
+ Attributes map[string]string `json:"attributes"`
+ Children []node `json:"children"`
+ Clickable *bool `json:"clickable"`
+ }
+ var root node
+ if err := json.Unmarshal([]byte(dump), &root); err != nil {
+ t.Fatalf("unmarshal hierarchy: %v", err)
+ }
+ clickableByID := map[string]*bool{}
+ var walk func(n node)
+ walk = func(n node) {
+ if id := n.Attributes["resource-id"]; id != "" {
+ clickableByID[id] = n.Clickable
+ }
+ for _, c := range n.Children {
+ walk(c)
+ }
+ }
+ walk(root)
+
+ isClickable := func(id string) bool {
+ return clickableByID[id] != nil && *clickableByID[id]
+ }
+ for _, id := range []string{"go", "bio", "rolebutton"} {
+ if !isClickable(id) {
+ t.Errorf("%q: clickable = %v, want true", id, clickableByID[id])
+ }
+ }
+ for _, id := range []string{"root", "plain"} {
+ if isClickable(id) {
+ t.Errorf("%q: clickable = true, want false/absent (an onclick property is not a target)", id)
+ }
+ }
+}
+
+// TestHierarchy_ScrollableAttribute covers the fact the goja host reads off the
+// attributes map. The V8 picker computes the same overflow test in
+// web-runtime.ts, so leaving it out of the dump made the two enumerations
+// disagree on web: the model policy could never be offered a scroll the seeded
+// policy could draw.
+func TestHierarchy_ScrollableAttribute(t *testing.T) {
+ const html = `` +
+ `
` +
+ `
` +
+ `
` +
+ `
` +
+ `
` +
+ `
` +
+ ``
+
+ 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)
+ }
+
+ type node struct {
+ Attributes map[string]string `json:"attributes"`
+ Children []node `json:"children"`
+ }
+ var root node
+ if err := json.Unmarshal([]byte(dump), &root); err != nil {
+ t.Fatalf("unmarshal hierarchy: %v", err)
+ }
+ scrollableByID := map[string]string{}
+ var walk func(n node)
+ walk = func(n node) {
+ if id := n.Attributes["resource-id"]; id != "" {
+ scrollableByID[id] = n.Attributes["scrollable"]
+ }
+ for _, c := range n.Children {
+ walk(c)
+ }
+ }
+ walk(root)
+
+ for _, id := range []string{"overflowing", "sideways"} {
+ if scrollableByID[id] != "true" {
+ t.Errorf("%q: scrollable = %q, want \"true\"", id, scrollableByID[id])
+ }
+ }
+ if scrollableByID["fits"] != "" {
+ t.Errorf("%q: scrollable = %q, want absent", "fits", scrollableByID["fits"])
+ }
+}
+
// TestRunCtx_CallerCancelPropagates confirms that cancelling the caller's
// context cancels the chromedp-bound context returned by runCtx. This is the
// channel by which step deadlines and Ctrl-C reach in-flight CDP calls.
@@ -221,3 +340,61 @@ func TestRunCtx_TabCancelPropagates(t *testing.T) {
t.Fatal("derived ctx did not cancel after tab cancellation")
}
}
+
+// TestHierarchy_RootsAtDocumentElementWithoutHead pins the dump's root to html,
+// because collectTargets in pkg/spec/src/web-runtime.ts walks
+// querySelectorAll("*") and therefore sees html. A dump rooted at body hides
+// page-level scrolling, which lives on html on a standard page, so the two
+// enumerations disagree on exactly that candidate. The head subtree stays out:
+// it is all zero-bounds, so it changes no eligible set, and it would otherwise
+// carry script and title text into the trace.
+func TestHierarchy_RootsAtDocumentElementWithoutHead(t *testing.T) {
+ const html = `secret title` +
+ `` +
+ `
hello
`
+
+ 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)
+ }
+
+ type node struct {
+ Attributes map[string]string `json:"attributes"`
+ Children []node `json:"children"`
+ }
+ var root node
+ if err := json.Unmarshal([]byte(dump), &root); err != nil {
+ t.Fatalf("unmarshal hierarchy: %v", err)
+ }
+ if root.Attributes["tag"] != "html" {
+ t.Errorf("root tag: got %q, want html", root.Attributes["tag"])
+ }
+ if root.Attributes["sanderling-screen"] == "" {
+ t.Error("the root must still carry sanderling-screen")
+ }
+
+ tags := map[string]bool{}
+ var walk func(n node)
+ walk = func(n node) {
+ tags[n.Attributes["tag"]] = true
+ for _, child := range n.Children {
+ walk(child)
+ }
+ }
+ walk(root)
+ if !tags["body"] {
+ t.Error("body must appear under the root")
+ }
+ for _, tag := range []string{"head", "title", "script"} {
+ if tags[tag] {
+ t.Errorf("the head subtree must stay out of the dump, found %q", tag)
+ }
+ }
+}