Files
sanderling/internal/driver/chrome/driver_test.go
T
pj dbd57ef989 fix(chrome): a failed metrics round trip is an error, not zero bytes
performance.memory is absent on plenty of pages and zero is the honest answer
there. Swallowing the evaluate error gave that same answer when the round trip
never happened, so a run recorded a memory reading it never took.
2026-08-22 21:07:15 +05:30

1720 lines
63 KiB
Go

//go:build browser
package chrome
import (
"context"
"encoding/json"
"errors"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/chromedp/chromedp"
"github.com/priyanshujain/sanderling/internal/bundler"
"github.com/priyanshujain/sanderling/internal/driver"
"github.com/priyanshujain/sanderling/internal/hierarchy"
"github.com/priyanshujain/sanderling/internal/verifier"
)
// TestLaunch_ClearStateWipesStorageForTheTargetOrigin covers the CLI's default
// path (--clear-data). The tab sits on about:blank when Launch runs, an opaque
// origin that denies storage access, so clearing by script there throws
// SecurityError and kills every web run before the app loads.
func TestLaunch_ClearStateWipesStorageForTheTargetOrigin(t *testing.T) {
const page = `<body><script>
const visits = Number(localStorage.getItem("visits") ?? "0") + 1;
localStorage.setItem("visits", String(visits));
sessionStorage.setItem("tab", "dirty");
</script></body>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(page))
}))
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, true, nil); err != nil {
t.Fatalf("Launch with clearState on a fresh tab: %v", err)
}
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
var visits string
if err := chromedp.Run(d.tabCtx,
chromedp.Evaluate(`localStorage.getItem("visits")`, &visits)); err != nil {
t.Fatalf("read localStorage: %v", err)
}
if visits != "2" {
t.Fatalf("visits = %q, want 2 (two loads, storage kept)", visits)
}
if err := d.Launch(ctx, server.URL, true, nil); err != nil {
t.Fatalf("Launch with clearState on the target origin: %v", err)
}
if err := chromedp.Run(d.tabCtx,
chromedp.Evaluate(`localStorage.getItem("visits")`, &visits)); err != nil {
t.Fatalf("read localStorage: %v", err)
}
if visits != "1" {
t.Errorf("visits = %q, want 1 (storage cleared before the app loaded)", visits)
}
}
// TestLaunch_WebGLContextIsAvailable pins the SwiftShader fallback. Headless
// Chrome runs with --disable-gpu, and without --enable-unsafe-swiftshader it
// refuses the software WebGL backend: getContext returns null, so a
// canvas-rendered app paints nothing and every screenshot is identical black.
func TestLaunch_WebGLContextIsAvailable(t *testing.T) {
d := New()
defer d.Terminate(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if err := d.Launch(ctx, "data:text/html,<body></body>", false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
var hasContext bool
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(
`!!document.createElement("canvas").getContext("webgl2")`, &hasContext)); err != nil {
t.Fatalf("evaluate: %v", err)
}
if !hasContext {
t.Error("webgl2 context is null; a canvas-rendered app would render nothing")
}
}
// TestActionMethods_HonorCallerCancellation confirms the DeviceDriver action
// methods route through runCtx so a cancelled caller context aborts the CDP
// round-trip instead of blocking on d.tabCtx. Without this a hung browser would
// ignore step deadlines and Ctrl-C.
func TestActionMethods_HonorCallerCancellation(t *testing.T) {
d := New()
defer d.Terminate(context.Background())
launchCtx, launchCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer launchCancel()
if err := d.Launch(launchCtx, "data:text/html,<body><button id=go>go</button></body>", false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
cancelled, cancel := context.WithCancel(context.Background())
cancel()
actions := map[string]func() error{
"Tap": func() error { return d.Tap(cancelled, 1, 1) },
"Swipe": func() error { return d.Swipe(cancelled, 1, 1, 2, 2, 50*time.Millisecond) },
"LongPress": func() error { return d.LongPress(cancelled, 1, 1) },
"PressKey": func() error { return d.PressKey(cancelled, "enter") },
"InputText": func() error { return d.InputText(cancelled, "x") },
"EraseText": func() error { return d.EraseText(cancelled, 1) },
"TapSelector": func() error { return d.TapSelector(cancelled, "#go") },
}
for name, action := range actions {
t.Run(name, func(t *testing.T) {
done := make(chan error, 1)
go func() { done <- action() }()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("want context.Canceled, got %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("action ignored cancelled caller ctx and blocked")
}
})
}
}
// TestHierarchy_EditableFlag confirms the injected hierarchy script marks text
// inputs, textareas, and contenteditable elements editable while leaving
// buttons and non-text inputs alone.
func TestHierarchy_EditableFlag(t *testing.T) {
const html = `<body>` +
`<input id="name">` +
`<textarea id="bio"></textarea>` +
`<button id="go">go</button>` +
`<div id="rich" contenteditable="true">x</div>` +
`<input id="chk" type="checkbox">` +
`</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)
}
type node struct {
Attributes map[string]string `json:"attributes"`
Children []node `json:"children"`
Editable *bool `json:"editable"`
}
var root node
if err := json.Unmarshal([]byte(dump), &root); err != nil {
t.Fatalf("unmarshal hierarchy: %v", err)
}
editableByID := map[string]*bool{}
var walk func(n node)
walk = func(n node) {
if id := n.Attributes["resource-id"]; id != "" {
editableByID[id] = n.Editable
}
for _, c := range n.Children {
walk(c)
}
}
walk(root)
isEditable := func(id string) bool {
return editableByID[id] != nil && *editableByID[id]
}
for _, id := range []string{"name", "bio", "rich"} {
if !isEditable(id) {
t.Errorf("%q: editable = %v, want true", id, editableByID[id])
}
}
for _, id := range []string{"go", "chk"} {
if isEditable(id) {
t.Errorf("%q: editable = true, want false/absent", id)
}
}
}
// 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 = `<body>` +
`<div id="root"><button id="go">go</button></div>` +
`<textarea id="bio"></textarea>` +
`<div id="plain">text</div>` +
`<div id="rolebutton" role="button">act</div>` +
`<script>document.getElementById("root").onclick = function () {};</script>` +
`</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)
}
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 = `<body style="margin:0">` +
`<div id="overflowing" style="width:100px;height:100px;overflow:auto">` +
`<div style="width:100px;height:900px"></div></div>` +
`<div id="sideways" style="width:100px;height:50px;overflow:auto">` +
`<div style="width:900px;height:20px"></div></div>` +
`<div id="fits" style="width:100px;height:100px;overflow:auto">` +
`<div style="width:50px;height:50px"></div></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)
}
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"])
}
}
// TestHierarchy_HintTextNamesAnEditableField covers the attribute visibleLabel
// (internal/verifier/llm.go) reads FIRST for an editable element. Without it a
// web field reached the model named by its CSS class, an identifier no user can
// read, on exactly the channel the label-source experiment varies. The ladder is
// fieldHint's in pkg/spec/src/web-runtime.ts, rung for rung.
func TestHierarchy_HintTextNamesAnEditableField(t *testing.T) {
const html = `<body>` +
`<label id="amount-label" for="amount">Amount</label>` +
`<input id="amount" class="input amount-input" placeholder="0.00" name="amount-field">` +
`<input id="search" class="input search-input" aria-label="Search" placeholder="Type here" name="q">` +
`<label id="note-label" for="note"> </label>` +
`<input id="note" class="input note-input" placeholder="What's this for?" name="note-field">` +
`<input id="reference" class="input" name="reference-field">` +
`<input id="unnamed" class="input">` +
`<input id="agree" class="checkbox" type="checkbox" placeholder="ignored">` +
`<button id="go" class="button" placeholder="ignored">go</button>` +
`</body>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(html))
}))
defer server.Close()
d := New()
defer d.Terminate(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := d.Launch(ctx, server.URL, 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"`
Editable bool `json:"editable"`
Children []node `json:"children"`
}
var root node
if err := json.Unmarshal([]byte(dump), &root); err != nil {
t.Fatalf("unmarshal hierarchy: %v", err)
}
fieldByID := map[string]node{}
var walk func(n node)
walk = func(n node) {
if id := n.Attributes["resource-id"]; id != "" {
fieldByID[id] = n
}
for _, c := range n.Children {
walk(c)
}
}
walk(root)
for _, tc := range []struct {
id string
want string
editable bool
}{
{"search", "Search", true},
{"amount", "Amount", true},
{"note", "What's this for?", true},
{"reference", "reference-field", true},
{"unnamed", "", true},
{"agree", "", false},
{"go", "", false},
} {
field := fieldByID[tc.id]
if field.Attributes["hintText"] != tc.want {
t.Errorf("%q: hintText = %q, want %q", tc.id, field.Attributes["hintText"], tc.want)
}
// visibleLabel reaches the hint only for an element the dump calls
// editable, so a field named right and marked wrong is still named by
// its class downstream.
if field.Editable != tc.editable {
t.Errorf("%q: editable = %v, want %v", tc.id, field.Editable, tc.editable)
}
if tc.want != "" && field.Attributes["hintText"] == field.Attributes["class"] {
t.Errorf("%q: named by its CSS class %q", tc.id, field.Attributes["class"])
}
}
}
// 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.
func TestRunCtx_CallerCancelPropagates(t *testing.T) {
tabCtx, tabCancel := context.WithCancel(context.Background())
defer tabCancel()
d := &Driver{tabCtx: tabCtx}
callerCtx, callerCancel := context.WithCancel(context.Background())
derived, cancel := d.runCtx(callerCtx)
defer cancel()
callerCancel()
select {
case <-derived.Done():
case <-time.After(time.Second):
t.Fatal("derived ctx did not cancel after caller cancellation")
}
}
// TestRunCtx_TabCancelPropagates confirms the inverse: tearing down the tab
// also cancels any in-flight derived context.
func TestRunCtx_TabCancelPropagates(t *testing.T) {
tabCtx, tabCancel := context.WithCancel(context.Background())
d := &Driver{tabCtx: tabCtx}
derived, cancel := d.runCtx(context.Background())
defer cancel()
tabCancel()
select {
case <-derived.Done():
case <-time.After(time.Second):
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 = `<html><head><title>secret title</title>` +
`<script>window.marker = 1;</script></head>` +
`<body><div id="content">hello</div></body></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)
}
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)
}
}
}
// TestLaunch_HonorsCallerDeadline points the driver at a listener that accepts
// the connection and never answers. Chrome has no page-load deadline of its
// own, so a Launch that ignored its caller context waited forever: an
// unattended campaign worker aimed at an unreachable target wedged with no
// diagnostic and did not even answer SIGTERM.
func TestLaunch_HonorsCallerDeadline(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer listener.Close()
held := make(chan net.Conn, 8)
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
held <- conn
}
}()
defer func() {
close(held)
for conn := range held {
conn.Close()
}
}()
d := New()
defer d.Terminate(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
done := make(chan error, 1)
go func() { done <- d.Launch(ctx, "http://"+listener.Addr().String(), false, nil) }()
select {
case err := <-done:
if err == nil {
t.Fatal("Launch returned nil against a target that never answers")
}
if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) {
t.Fatalf("Launch error = %v, want a context error", err)
}
case <-time.After(45 * time.Second):
t.Fatal("Launch ignored the caller deadline and blocked")
}
}
// TestLaunch_KeepsBrowserAliveAfterCallerContextEnds guards the trap that made
// Launch use the driver context in the first place: chromedp starts Chrome
// under whichever context runs first, so allocating under the caller's
// short-lived context would kill the browser as soon as Launch returned.
func TestLaunch_KeepsBrowserAliveAfterCallerContextEnds(t *testing.T) {
d := New()
defer d.Terminate(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
if err := d.Launch(ctx, "data:text/html,<body>alive</body>", false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
cancel()
var text string
if err := chromedp.Run(d.tabCtx,
chromedp.Evaluate(`document.body.textContent`, &text)); err != nil {
t.Fatalf("browser died with the caller context: %v", err)
}
if text != "alive" {
t.Errorf("body text = %q, want \"alive\"", text)
}
if err := d.Launch(context.Background(), "data:text/html,<body>again</body>", false, nil); err != nil {
t.Fatalf("second Launch after the first caller context ended: %v", err)
}
}
// TestInputText_ReplacesTextInsideAShadowRoot pins ReplacesTextOnInput's promise
// on the shape a canvas app actually has. Compose for Web draws its text fields
// on a canvas and routes typing through a hidden <input> INSIDE the shadow root
// it mounts, and document.activeElement stops at a shadow boundary: it names the
// host. The select-all therefore ran against a <div> with no select(), every
// InputText appended to the last, and a fuzzer typing twice into one field built
// up text it could never clear (observed on the folio wasm build as
// "0.0000001" -> "0.0000001\t-1").
func TestInputText_ReplacesTextInsideAShadowRoot(t *testing.T) {
const page = `<body><div id="app"></div><script>
const root = document.getElementById("app").attachShadow({mode: "open"});
root.innerHTML = ` + "`" + `
<style>
#surface { position: absolute; left: 0; top: 0; }
#a11y { position: absolute; left: 0; top: 0; pointer-events: none; }
#proxy { position: absolute; left: -9999px; }
</style>
<canvas id="surface" width="300" height="200"></canvas>
<div id="a11y"><div id="field">-</div></div>
<input id="proxy" type="text">` + "`" + `;
const proxy = root.getElementById("proxy");
const field = root.getElementById("field");
// The canvas owns the pointer (the a11y overlay is pointer-events: none)
// and hands focus to the proxy, exactly as a canvas app does.
root.getElementById("surface").addEventListener("click", function () { proxy.focus(); });
proxy.addEventListener("input", function () { field.textContent = proxy.value; });
</script></body>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(page))
}))
defer server.Close()
d := New()
defer d.Terminate(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
if err := d.Tap(ctx, 40, 40); err != nil {
t.Fatalf("Tap: %v", err)
}
if err := d.InputText(ctx, "alpha"); err != nil {
t.Fatalf("InputText: %v", err)
}
if err := d.InputText(ctx, "beta"); err != nil {
t.Fatalf("InputText: %v", err)
}
var shown string
script := `document.getElementById("app").shadowRoot.getElementById("field").textContent`
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &shown)); err != nil {
t.Fatalf("read field: %v", err)
}
if shown != "beta" {
t.Errorf("field holds %q, want %q; the second InputText appended instead of replacing", shown, "beta")
}
if err := d.EraseText(ctx, len("beta")); err != nil {
t.Fatalf("EraseText: %v", err)
}
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &shown)); err != nil {
t.Fatalf("read field: %v", err)
}
if shown != "" {
t.Errorf("field holds %q after EraseText, want empty", shown)
}
}
// TestHierarchy_ScreenFallsBackToThePathname pins the route the goja host reads
// off the dump. Reading location.hash alone reported "/" on every step of a
// path-routed SPA (react-router's BrowserRouter, which the replay UI itself
// uses), so every screen looked like the same screen and no route-scoped
// property or action could tell them apart.
func TestHierarchy_ScreenFallsBackToThePathname(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(`<body><div id="app">app</div></body>`))
}))
defer server.Close()
for _, testCase := range []struct {
name string
path string
want string
}{
{"path-routed", "/runs/20260101-120000/steps/7", "/runs/20260101-120000/steps/7"},
{"hash wins when present", "/runs/1#/detail", "/detail"},
{"root", "/", "/"},
} {
t.Run(testCase.name, func(t *testing.T) {
d := New()
defer d.Terminate(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := d.Launch(ctx, server.URL+testCase.path, false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
dump, err := d.Hierarchy(ctx)
if err != nil {
t.Fatalf("Hierarchy: %v", err)
}
var root struct {
Attributes map[string]string `json:"attributes"`
}
if err := json.Unmarshal([]byte(dump), &root); err != nil {
t.Fatalf("unmarshal hierarchy: %v", err)
}
if got := root.Attributes["sanderling-screen"]; got != testCase.want {
t.Errorf("sanderling-screen: got %q, want %q", got, testCase.want)
}
})
}
}
// TestWaitForIdle_WaitsForWorkTheActionKickedOff pins the settle the runner
// relies on between acting and observing. WaitForIdle used to return the moment
// <body> existed, which is true before the app has reacted at all: measured on
// the folio wasm build, Compose's accessibility DOM lands ~136 ms after an
// InputText, so the next step read the pre-action text and typed into a field
// it believed was still empty.
func TestWaitForIdle_WaitsForWorkTheActionKickedOff(t *testing.T) {
const page = `<body><div id="app"></div><script>
const root = document.getElementById("app").attachShadow({mode: "open"});
root.innerHTML = '<button id="go" style="width:200px;height:80px">go</button><div id="out">pending</div>';
root.getElementById("go").addEventListener("click", function () {
setTimeout(function () { root.getElementById("out").textContent = "settled"; }, 100);
});
</script></body>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(page))
}))
defer server.Close()
d := New()
defer d.Terminate(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
if err := d.Tap(ctx, 40, 40); err != nil {
t.Fatalf("Tap: %v", err)
}
if err := d.WaitForIdle(ctx, time.Second); err != nil {
t.Fatalf("WaitForIdle: %v", err)
}
var shown string
script := `document.getElementById("app").shadowRoot.getElementById("out").textContent`
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &shown)); err != nil {
t.Fatalf("read: %v", err)
}
if shown != "settled" {
t.Errorf("observed %q; WaitForIdle returned before the tap's own work landed", shown)
}
}
// TestWaitForIdle_ReturnsOnABusyPage is the other half: a page that never stops
// mutating (an animation, a polling widget) must not hold the step loop open.
func TestWaitForIdle_ReturnsOnABusyPage(t *testing.T) {
const page = `<body><div id="tick">0</div><script>
let n = 0;
setInterval(function () { document.getElementById("tick").textContent = String(++n); }, 15);
</script></body>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(page))
}))
defer server.Close()
d := New()
defer d.Terminate(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
start := time.Now()
if err := d.WaitForIdle(ctx, time.Second); err != nil {
t.Fatalf("WaitForIdle: %v", err)
}
if elapsed := time.Since(start); elapsed > 2*time.Second {
t.Errorf("WaitForIdle took %s on a busy page; it must return inside its budget", elapsed)
}
}
// TestWaitForIdle_WaitsOutARouteTransition covers the settle case a mutation
// observer cannot see. A canvas app splices the incoming screen's
// accessibility nodes in when its cross-fade STARTS and drops the outgoing
// screen's when it ends; between those two mutations the DOM is quiet with both
// routes live. Returning there hands the next step a tree naming the screen the
// app is leaving, which on the folio wasm build recorded a submit that had
// landed on Home as still being on the transaction screen: the route gate of an
// action-gated property then skipped the very step the action landed on.
//
// The page keeps mutating for 300 ms after the route splice, and the settle
// runs on the timeout production hands it (MinIdleTimeout). Both details are
// load-bearing. A quiet page reaches the transition check immediately, so it
// passes whether the transition window is anchored at the script start or at
// the end of the quiet period; churn is what pushes the check past a
// start-anchored deadline, which then finishes at once with two live screens.
// And a caller timeout below MinIdleTimeout cuts the whole settle off before
// the transition window can be spent, which is the same bug from the other end.
func TestWaitForIdle_WaitsOutARouteTransition(t *testing.T) {
const page = `<body><div id="app"></div><script>
const root = document.getElementById("app").attachShadow({mode: "open"});
root.innerHTML = '<button id="go" style="width:200px;height:80px">go</button>' +
'<div id="LedgerScreen">ledger</div><div id="spinner">0</div>';
root.getElementById("go").addEventListener("click", function () {
const incoming = document.createElement("div");
incoming.id = "HomeScreen";
incoming.textContent = "home";
root.appendChild(incoming);
let frame = 0;
const churn = setInterval(function () {
root.getElementById("spinner").textContent = String(++frame);
}, 30);
setTimeout(function () { clearInterval(churn); }, 300);
setTimeout(function () { root.getElementById("LedgerScreen").remove(); }, 1000);
});
</script></body>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(page))
}))
defer server.Close()
d := New()
defer d.Terminate(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
if err := d.Tap(ctx, 40, 40); err != nil {
t.Fatalf("Tap: %v", err)
}
if err := d.WaitForIdle(ctx, d.MinIdleTimeout()); err != nil {
t.Fatalf("WaitForIdle: %v", err)
}
var live []string
script := `Array.from(document.getElementById("app").shadowRoot
.querySelectorAll('[id$="Screen"]')).map(e => e.id)`
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &live)); err != nil {
t.Fatalf("read: %v", err)
}
if len(live) != 1 || live[0] != "HomeScreen" {
t.Errorf("live screens after the settle = %v, want [HomeScreen]; WaitForIdle "+
"returned mid-transition, so the next step verifies the outgoing route", live)
}
}
// TestWaitForIdle_BoundsTheTransitionWait is the other half of the transition
// wait: a page that shows two *Screen ids at rest is not mid-transition, it
// just matches the heuristic, and it must cost one bounded wait rather than the
// whole step budget on every step.
func TestWaitForIdle_BoundsTheTransitionWait(t *testing.T) {
const page = `<body><div id="HomeScreen">home</div><div id="LedgerScreen">ledger</div></body>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(page))
}))
defer server.Close()
d := New()
defer d.Terminate(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
start := time.Now()
if err := d.WaitForIdle(ctx, 10*time.Second); err != nil {
t.Fatalf("WaitForIdle: %v", err)
}
if elapsed := time.Since(start); elapsed > transitionSettlePeriod+time.Second {
t.Errorf("WaitForIdle took %s on a page with two resting screens; the "+
"transition wait must be bounded by %s", elapsed, transitionSettlePeriod)
}
}
// TestEvaluateExtractors_WaitsOutARouteTransition covers the other sampler. A
// step reads the page twice: the hierarchy dump (which re-fetches while the
// tree looks transitional) and the spec's own extractors in V8. Sampling the
// extractors mid cross-fade reports the route the app is leaving, and an
// action-gated property then skips the one step its action can be judged on:
// on the folio wasm build a double-submit that landed on Home was extracted as
// still being on the transaction screen.
func TestEvaluateExtractors_WaitsOutARouteTransition(t *testing.T) {
const page = `<body><div id="app"></div><script>
const root = document.getElementById("app").attachShadow({mode: "open"});
root.innerHTML = '<div id="LedgerScreen">ledger</div>';
window.__sanderlingExtractors__ = function () {
return {0: {value: Array.from(root.querySelectorAll('[id$="Screen"]')).map(e => e.id).join(",")}};
};
window.startTransition = function () {
const incoming = document.createElement("div");
incoming.id = "HomeScreen";
root.appendChild(incoming);
setTimeout(function () { root.getElementById("LedgerScreen").remove(); }, 400);
};
</script></body>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(page))
}))
defer server.Close()
d := New()
defer d.Terminate(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(`window.startTransition()`, nil)); err != nil {
t.Fatalf("start transition: %v", err)
}
values, err := d.EvaluateExtractors(ctx)
if err != nil {
t.Fatalf("EvaluateExtractors: %v", err)
}
if got := string(values[0]); got != `"HomeScreen"` {
t.Errorf("extractor read %s, want \"HomeScreen\"; the extractors sampled "+
"mid-transition, so the spec sees the route the app is leaving", got)
}
}
// TestSetLastAction_ReportsAPageThatCannotTakeIt covers the install the whole
// web path's action-gated properties hang off. A page without the setter is
// reachable: internal/testrun resolves the web runtime from
// node_modules/@sanderling/spec when no sibling checkout is present, and an
// older published runtime does not define it. Guarded as
// `setter && setter(...)`, that page returns undefined and chromedp reports
// success, so every step silently no-ops and every property gated on the last
// action goes vacuously true - a green run that checked nothing.
func TestSetLastAction_ReportsAPageThatCannotTakeIt(t *testing.T) {
const withSetter = `<body><script>
window.__lastActionSeen = null;
window.__sanderlingSetLastAction__ = function (value) { window.__lastActionSeen = value; };
</script></body>`
const withoutSetter = `<body><div id="app">no sanderling runtime here</div></body>`
pages := map[string]string{"/with": withSetter, "/without": withoutSetter}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(pages[r.URL.Path]))
}))
defer server.Close()
d := New()
defer d.Terminate(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := d.Launch(ctx, server.URL+"/with", false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
action := json.RawMessage(`{"kind":"Tap","on":"id:TxnSubmit"}`)
if err := d.SetLastAction(ctx, action); err != nil {
t.Fatalf("SetLastAction on a page that defines the setter: %v", err)
}
var seen map[string]string
if err := chromedp.Run(d.tabCtx,
chromedp.Evaluate(`window.__lastActionSeen`, &seen)); err != nil {
t.Fatalf("read installed action: %v", err)
}
if seen["on"] != "id:TxnSubmit" {
t.Errorf("the page received %v, want the action the runner applied", seen)
}
if err := d.Launch(ctx, server.URL+"/without", false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
if err := d.SetLastAction(ctx, action); err == nil {
t.Error("SetLastAction reported success on a page with no setter; " +
"a runtime that cannot take lastAction is indistinguishable from one that did")
}
}
// TestSetLogs_ReportsAPageThatCannotTakeThem is the same install on the channel
// the log properties hang off. The driver holding a console error changes
// nothing on web: the page's reading of every extractor replaces the host's, so
// unless the entries are put back into the page, noLogcatErrors counts an empty
// array and stays green through a run full of errors.
func TestSetLogs_ReportsAPageThatCannotTakeThem(t *testing.T) {
const withSetter = `<body><script>
window.__logsSeen = null;
window.__sanderlingSetLogs__ = function (value) { window.__logsSeen = value; };
</script></body>`
const withoutSetter = `<body><div id="app">no sanderling runtime here</div></body>`
pages := map[string]string{"/with": withSetter, "/without": withoutSetter}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(pages[r.URL.Path]))
}))
defer server.Close()
d := New()
defer d.Terminate(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := d.Launch(ctx, server.URL+"/with", false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
logs := json.RawMessage(`[{"unixMillis":1,"level":"E","tag":"console","message":"boom"}]`)
if err := d.SetLogs(ctx, logs); err != nil {
t.Fatalf("SetLogs on a page that defines the setter: %v", err)
}
var seen []map[string]any
if err := chromedp.Run(d.tabCtx,
chromedp.Evaluate(`window.__logsSeen`, &seen)); err != nil {
t.Fatalf("read installed logs: %v", err)
}
if len(seen) != 1 || seen[0]["level"] != "E" || seen[0]["message"] != "boom" {
t.Errorf("the page received %v, want the error-level entry the driver captured", seen)
}
if err := d.Launch(ctx, server.URL+"/without", false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
if err := d.SetLogs(ctx, logs); err == nil {
t.Error("SetLogs reported success on a page with no setter; " +
"a runtime that cannot take the step's logs is indistinguishable from one that did")
}
}
// TestEvaluateExtractors_ReportsAMissingTable is the same failure on the other
// sampler. An empty override map is what a spec with no extractors returns, so
// treating a missing table as {} makes "this page has no sanderling runtime"
// read as an ordinary step - and the verifier then judges the run on goja's
// dump-derived values while believing they came from the page.
func TestEvaluateExtractors_ReportsAMissingTable(t *testing.T) {
const page = `<body><div id="app">no sanderling runtime here</div></body>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(page))
}))
defer server.Close()
d := New()
defer d.Terminate(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
values, err := d.EvaluateExtractors(ctx)
if err == nil {
t.Errorf("EvaluateExtractors returned %v and no error on a page with no "+
"extractor table; a page that cannot be read must not read as empty", values)
}
}
// TestEvaluateExtractors_KeepsUndefinedApartFromNull covers the wire the page's
// readings cross. JSON has no undefined, so the web runtime wraps each reading
// in a {value} envelope: written straight into the table, an extractor that
// returned undefined lost its whole index to JSON.stringify and the host kept
// goja's dump-derived reading for it while the rest held the page's. An absent
// value has to arrive as an empty payload, which is what makes the verifier
// record undefined (the value the native host records for the same getter);
// arriving as JSON null would claim the getter returned null.
func TestEvaluateExtractors_KeepsUndefinedApartFromNull(t *testing.T) {
const page = `<body><script>
window.__sanderlingExtractors__ = function () {
return {0: {}, 1: {value: null}, 2: {value: {balance: 7}}};
};
</script></body>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(page))
}))
defer server.Close()
d := New()
defer d.Terminate(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
values, err := d.EvaluateExtractors(ctx)
if err != nil {
t.Fatalf("EvaluateExtractors: %v", err)
}
if len(values) != 3 {
t.Fatalf("the page reported 3 readings, %d survived the wire: %v", len(values), values)
}
if got := values[0]; len(got) != 0 {
t.Errorf("the undefined reading arrived as %s, want an empty payload; "+
"the verifier records anything else as a value the getter never returned", got)
}
if got := string(values[1]); got != "null" {
t.Errorf("the null reading arrived as %s, want null", got)
}
if got := string(values[2]); got != `{"balance":7}` {
t.Errorf("the object reading arrived as %s, want {\"balance\":7}", got)
}
}
// TestEvaluateExtractors_RejectsAnUnenvelopedReading is the loud failure a page
// running an older @sanderling/spec produces. Its readings are bare values, and
// a bare value is indistinguishable from a reading whose getter returned that
// value, so accepting them silently puts the two engines on different bundles.
func TestEvaluateExtractors_RejectsAnUnenvelopedReading(t *testing.T) {
const page = `<body><script>
window.__sanderlingExtractors__ = function () { return {0: "home"}; };
</script></body>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(page))
}))
defer server.Close()
d := New()
defer d.Terminate(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
values, err := d.EvaluateExtractors(ctx)
if err == nil {
t.Fatalf("EvaluateExtractors accepted %v from a page whose readings are not "+
"enveloped; the page and the host are running different bundles", values)
}
if !strings.Contains(err.Error(), "different bundles") {
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)
}
}
// growingPage serves a page whose content starts shorter than one screen and
// grows past it when its first button is tapped, which is what a chat, feed or
// ledger does as a run drives it.
const growingPage = `<body style="margin:0">
<button id="grow" style="height:80px">grow</button>
<div id="status">idle</div>
<div id="rest"></div>
<script>
document.getElementById('grow').addEventListener('click', function() {
document.getElementById('rest').innerHTML =
'<div style="height:900px"></div>' +
'<button id="below" style="height:60px">below</button>';
document.getElementById('below').addEventListener('click', function() {
document.getElementById('status').textContent = 'below tapped';
});
});
</script></body>`
// TestTap_ActuatesAnElementBelowTheLaunchViewport pins the invariant the whole
// web path rests on: an element the driver reports as present and clickable can
// be acted on. The emulated viewport is sized once at launch, so content the
// app adds afterwards lies below it while getBoundingClientRect keeps reporting
// where it is; a click dispatched there is hit-tested to the document root and
// the element never sees it, with no error anywhere.
func TestTap_ActuatesAnElementBelowTheLaunchViewport(t *testing.T) {
server := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(growingPage))
}),
)
defer server.Close()
d := New()
defer d.Terminate(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
tapByID := func(id string) {
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: %v", err)
}
element := tree.Find("id:" + id)
if element == nil {
t.Fatalf("%s is not in the dump", id)
}
if !element.Clickable {
t.Fatalf("%s is not reported clickable", id)
}
x, y := element.Bounds.Center()
if err := d.Tap(ctx, x, y); err != nil {
t.Fatalf("Tap %s at (%d,%d): %v", id, x, y, err)
}
}
tapByID("grow")
tapByID("below")
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)
}
status := tree.Find("id:status")
if status == nil {
t.Fatal("status is not in the dump")
}
if status.Text != "below tapped" {
t.Errorf(
"status = %q, want %q: the tap reached no element",
status.Text,
"below tapped",
)
}
}
// TestTap_ReportsAGestureThatReachesNoElement covers the half of the same bug
// that no scrolling can fix: a page that cannot scroll leaves the point out of
// reach, and the caller has to hear about it rather than read a clean run.
func TestTap_ReportsAGestureThatReachesNoElement(t *testing.T) {
const page = `<body style="margin:0;overflow:hidden">
<div style="height:80px">top</div>
<div id="rest" style="height:900px;overflow:hidden"></div>
<style>html{overflow:hidden}</style></body>`
server := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(page))
}),
)
defer server.Close()
d := New()
defer d.Terminate(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
err := d.Tap(ctx, 100, 5000)
if !errors.Is(err, driver.ErrGestureUndelivered) {
t.Fatalf(
"Tap far below an unscrollable page: err = %v, want ErrGestureUndelivered",
err,
)
}
}
// TestTapSelector_ReportsASelectorThatMatchesNothing covers the by-selector
// half of a step that reads as dispatched and did nothing: the node the
// selector names is not on the page, so the click has no target at all.
func TestTapSelector_ReportsASelectorThatMatchesNothing(t *testing.T) {
const page = `<body style="margin:0">
<button id="present">here</button>
<div id="status">none</div>
<script>
document.getElementById('present').addEventListener('click', function () {
document.getElementById('status').textContent = 'present tapped';
});
</script></body>`
server := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(page))
}),
)
defer server.Close()
d := New()
defer d.Terminate(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
missCtx, missCancel := context.WithTimeout(ctx, 10*time.Second)
defer missCancel()
if err := d.TapSelector(missCtx, "id:absent"); !errors.Is(err, driver.ErrSelectorMatchedNothing) {
t.Fatalf("TapSelector on an absent element: err = %v, want ErrSelectorMatchedNothing", err)
}
if err := d.DoubleTapSelector(missCtx, "id:absent"); !errors.Is(err, driver.ErrSelectorMatchedNothing) {
t.Fatalf("DoubleTapSelector on an absent element: err = %v, want ErrSelectorMatchedNothing", err)
}
if err := d.TapSelector(ctx, "id:present"); err != nil {
t.Fatalf("TapSelector on the element that is there: %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 status := tree.Find("id:status"); status == nil || status.Text != "present tapped" {
t.Fatalf("status = %+v, want the tap on the present element to have landed", status)
}
}
const doubleClickPage = `<body style="margin:0">
<div id="target" style="width:200px;height:60px">edit me</div>
<div id="status">none</div>
<script>
var box = document.getElementById('target');
var report = document.getElementById('status');
var clicks = 0;
box.addEventListener('click', function () { clicks++; });
box.addEventListener('dblclick', function () {
report.textContent = 'edited after ' + clicks + ' clicks';
});
</script></body>`
func doubleClickStatus(t *testing.T, d *Driver, ctx context.Context) string {
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: %v", err)
}
status := tree.Find("id:status")
if status == nil {
t.Fatal("status is not in the dump")
}
return status.Text
}
// TestDoubleTap_ReachesADoubleClickHandler pins a gesture the web driver had no
// way to deliver. Blink raises dblclick off the click count the second event
// carries, so a pair that both said "first click" arrived as two ordinary
// clicks: every double-click affordance on the web (an editable list row, a
// canvas, a table cell) was unreachable, with no error on any layer.
func TestDoubleTap_ReachesADoubleClickHandler(t *testing.T) {
server := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(doubleClickPage))
}),
)
defer server.Close()
d := New()
defer d.Terminate(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
if err := d.DoubleTap(ctx, 100, 30); err != nil {
t.Fatalf("DoubleTap: %v", err)
}
if status := doubleClickStatus(t, d, ctx); status != "edited after 2 clicks" {
t.Errorf(
"status = %q, want %q: the pair never read as one double click",
status,
"edited after 2 clicks",
)
}
}
// TestDoubleTapSelector_ReachesADoubleClickHandler covers the same gesture on
// the path the runner takes when the action names its target rather than a
// point.
func TestDoubleTapSelector_ReachesADoubleClickHandler(t *testing.T) {
server := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(doubleClickPage))
}),
)
defer server.Close()
d := New()
defer d.Terminate(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
if err := d.DoubleTapSelector(ctx, "id:target"); err != nil {
t.Fatalf("DoubleTapSelector: %v", err)
}
if status := doubleClickStatus(t, d, ctx); status != "edited after 2 clicks" {
t.Errorf(
"status = %q, want %q: the pair never read as one double click",
status,
"edited after 2 clicks",
)
}
}
// gesturesServer serves the fixture both gesture tests measure against: a
// document taller than the emulated viewport, a scrollable container inside it,
// and a row that dismisses on a horizontal drag.
func gesturesServer(t *testing.T) *httptest.Server {
t.Helper()
body, err := os.ReadFile("testdata/gestures.html")
if err != nil {
t.Fatal(err)
}
server := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write(body)
}),
)
t.Cleanup(server.Close)
return server
}
// TestScroll_MovesThePageAndAScrollableContainer covers the verb the runner
// lowers every Scroll action onto. Script-dispatched pointer events are
// untrusted and a browser never scrolls on them, so the web Scroll used to
// leave scrollY and every scrollTop exactly where they were while reporting a
// step that ran. The repeat also pins the distance: a run that scrolls a
// different amount each time explores differently on the same seed.
func TestScroll_MovesThePageAndAScrollableContainer(t *testing.T) {
server := gesturesServer(t)
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, false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
read := func(expression string) int {
t.Helper()
var value int
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(expression, &value)); err != nil {
t.Fatalf("evaluate %s: %v", expression, err)
}
return value
}
const pageScroll = `Math.round(window.scrollY)`
const containerScroll = `Math.round(document.getElementById("inner").scrollTop)`
if before := read(pageScroll); before != 0 {
t.Fatalf("scrollY before = %d, want 0", before)
}
var pageDistances []int
for range 3 {
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(`window.scrollTo(0, 0)`, nil)); err != nil {
t.Fatalf("reset: %v", err)
}
if err := d.Scroll(ctx, 195, 500, 195, 260, 300*time.Millisecond); err != nil {
t.Fatalf("Scroll: %v", err)
}
pageDistances = append(pageDistances, read(pageScroll))
}
if pageDistances[0] <= 0 {
t.Errorf(
"scrollY after = %d, want > 0: the page never scrolled",
pageDistances[0],
)
}
if pageDistances[0] != pageDistances[1] ||
pageDistances[1] != pageDistances[2] {
t.Errorf(
"scrollY over three identical scrolls = %v, want one distance",
pageDistances,
)
}
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(`window.scrollTo(0, 0)`, nil)); err != nil {
t.Fatalf("reset: %v", err)
}
containerY := read(
`Math.round(document.getElementById("inner").getBoundingClientRect().top + 100)`,
)
if before := read(containerScroll); before != 0 {
t.Fatalf("container scrollTop before = %d, want 0", before)
}
if err := d.Scroll(ctx, 195, containerY, 195, containerY-120, 300*time.Millisecond); err != nil {
t.Fatalf("Scroll in the container: %v", err)
}
if after := read(containerScroll); after <= 0 {
t.Errorf(
"container scrollTop after = %d, want > 0: the container never scrolled",
after,
)
}
if after := read(pageScroll); after != 0 {
t.Errorf(
"scrollY = %d, want 0: a scroll inside a container moved the page instead",
after,
)
}
}
// TestScroll_ReportsAGestureThatReachesNoElement keeps the scroll path on the
// same footing as the tap path: a page that cannot bring the point into the
// viewport has to say the gesture reached nothing rather than read as a step
// that scrolled.
func TestScroll_ReportsAGestureThatReachesNoElement(t *testing.T) {
const page = `<body style="margin:0;overflow:hidden">
<div style="height:80px">top</div>
<style>html{overflow:hidden}</style></body>`
server := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(page))
}),
)
defer server.Close()
d := New()
defer d.Terminate(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
err := d.Scroll(ctx, 100, 5000, 100, 4800, 300*time.Millisecond)
if !errors.Is(err, driver.ErrGestureUndelivered) {
t.Fatalf(
"Scroll far below an unscrollable page: err = %v, want ErrGestureUndelivered",
err,
)
}
}
// TestSwipe_DeliversATrustedDragToARowHandler covers what the manual says
// sideways swipes are for. Script-dispatched pointer events carry isTrusted
// false, which is the mark of a gesture the browser never routed: nothing in
// the page's own input pipeline saw it, so scrolling, touch-action and any
// handler that filters on trust behave as if the finger never moved.
func TestSwipe_DeliversATrustedDragToARowHandler(t *testing.T) {
server := gesturesServer(t)
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, false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
if err := d.Swipe(ctx, 300, 40, 100, 40, 300*time.Millisecond); err != nil {
t.Fatalf("Swipe: %v", err)
}
var status string
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(
`document.getElementById("status").textContent`, &status)); err != nil {
t.Fatalf("evaluate: %v", err)
}
if status != "dismissed left trusted" {
t.Errorf("row status = %q, want %q", status, "dismissed left trusted")
}
}
// TestInstallBundle_RefusesARuntimeThatDeclaresADifferentActionEncoding covers
// the pairing that voided a whole campaign: a spec bundled by an older
// @sanderling/spec, run by this binary. internal/testrun resolves the web
// runtime from whatever the spec's project has installed, so the page's picker
// and the runner that dispatches its actions can encode a gesture differently.
// Neither half fails: every action dispatches successfully and executes the
// wrong gesture, and the run reports a full step count of results that mean
// nothing.
func TestInstallBundle_RefusesARuntimeThatDeclaresADifferentActionEncoding(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(`<body><div id="app">app</div></body>`))
}))
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, false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
const legacyRuntime = `window.__sanderlingNextAction__ = function () { return null; };`
if err := d.InstallBundle(ctx, []byte(legacyRuntime)); err == nil {
t.Error("InstallBundle accepted a runtime that declares no action encoding; " +
"the run would dispatch every action successfully and execute the wrong gesture")
} else if !strings.Contains(err.Error(), verifier.ActionWireContract) {
t.Errorf("InstallBundle error %q does not name the encoding this binary implements", err)
}
currentRuntime := legacyRuntime +
`window.__sanderlingActionEncoding__ = ` + strconv.Quote(verifier.ActionWireContract) + `;`
if err := d.InstallBundle(ctx, []byte(currentRuntime)); err != nil {
t.Fatalf("InstallBundle rejected a runtime on this binary's encoding: %v", err)
}
}
// TestInstallBundle_AcceptsTheWebRuntimeThisCheckoutShips is the other half of
// the gate: the encoding pkg/spec/src/web-runtime.ts declares has to be the one
// this binary decodes, or every web run refuses to start.
func TestInstallBundle_AcceptsTheWebRuntimeThisCheckoutShips(t *testing.T) {
directory := t.TempDir()
specPath := filepath.Join(directory, "spec.ts")
const spec = `
import { actions, Wait } from "@sanderling/spec";
export const actionsRoot = actions(Wait({ durationMillis: 1 }));
export const properties = {};
`
if err := os.WriteFile(specPath, []byte(spec), 0o600); err != nil {
t.Fatal(err)
}
apiPath, err := filepath.Abs("../../../pkg/spec/src/index.ts")
if err != nil {
t.Fatal(err)
}
runtimePath, err := filepath.Abs("../../../pkg/spec/src/web-runtime.ts")
if err != nil {
t.Fatal(err)
}
bundle, err := bundler.BundleWeb(bundler.WebOptions{
EntryFile: specPath,
WebRuntimeFile: runtimePath,
Aliases: map[string]string{"@sanderling/spec": apiPath},
})
if err != nil {
t.Fatalf("BundleWeb: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(`<body><div id="app">app</div></body>`))
}))
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, false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
if err := d.InstallBundle(ctx, bundle.JavaScript); err != nil {
t.Fatalf("InstallBundle rejected this checkout's own web runtime: %v", err)
}
}
// TestInstallBundle_AcceptsAPageThatInstallsNoPicker pins the other half of the
// rule Verifier.checkActionEncoding states: a bundle that installs no picker
// generates no actions, so it has no encoding to disagree about and demanding a
// declaration from it would refuse a spec that was never going to dispatch.
func TestInstallBundle_AcceptsAPageThatInstallsNoPicker(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(`<body><div id="app">app</div></body>`))
}))
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, false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
const pickerFree = `window.__sanderlingBundleCheck__ = true;`
if err := d.InstallBundle(ctx, []byte(pickerFree)); err != nil {
t.Fatalf("InstallBundle refused a bundle that generates no actions: %v", err)
}
}
// TestMetrics_AFailedRoundTripIsNotAPageWithoutTheAPI pins the distinction the
// zero-and-nil return erased. performance.memory is absent on plenty of pages
// and zero is the honest answer there, so a round trip that never happened has
// to be an error or a run records a memory reading it never took.
func TestMetrics_AFailedRoundTripIsNotAPageWithoutTheAPI(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(`<body><div id="app">app</div></body>`))
}))
defer server.Close()
d := New()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
if _, err := d.Metrics(ctx, ""); err != nil {
t.Fatalf("Metrics on a live page: %v", err)
}
if err := d.Terminate(context.Background()); err != nil {
t.Fatalf("Terminate: %v", err)
}
metrics, err := d.Metrics(ctx, "")
if err == nil {
t.Errorf("Metrics returned %+v and a nil error after the tab was gone; "+
"the run cannot tell that from a page with no performance.memory", metrics)
}
}