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) + } + } +}