diff --git a/internal/driver/chrome/driver_test.go b/internal/driver/chrome/driver_test.go index 103a8b8..f54d5a4 100644 --- a/internal/driver/chrome/driver_test.go +++ b/internal/driver/chrome/driver_test.go @@ -23,6 +23,47 @@ import ( "github.com/priyanshujain/sanderling/internal/verifier" ) +// launchChrome starts a browser on target and returns it with the context every +// later driver call must use. The browser, and the deadline that bounds a call +// against a wedged one, are torn down when the test ends. +// +// A test whose subject is the launch itself (a clearState wipe, a caller +// deadline, a browser outliving its caller's context) builds this by hand: the +// flag, the timeout and the cancel are what it is measuring. +func launchChrome(t *testing.T, target string) (*Driver, context.Context) { + t.Helper() + d := New() + t.Cleanup(func() { _ = d.Terminate(context.Background()) }) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + t.Cleanup(cancel) + if err := d.Launch(ctx, target, false, nil); err != nil { + t.Fatalf("Launch: %v", err) + } + return d, ctx +} + +// servePage serves html at a real http origin for the duration of the test. A +// data: URL carries the same markup but has an opaque origin, where storage, +// routing and everything else keyed by origin behaves as it never would in an +// app. +func servePage(t *testing.T, html string) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(html)) + })) + t.Cleanup(server.Close) + return server +} + +// testdataServer serves the fixture pages in testdata, each at its own path. +func testdataServer(t *testing.T) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.FileServer(http.Dir("testdata"))) + t.Cleanup(server.Close) + return server +} + // 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 @@ -33,11 +74,7 @@ func TestLaunch_ClearStateWipesStorageForTheTargetOrigin(t *testing.T) { localStorage.setItem("visits", String(visits)); sessionStorage.setItem("tab", "dirty"); ` - 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() + server := servePage(t, page) d := New() defer d.Terminate(context.Background()) @@ -76,13 +113,7 @@ func TestLaunch_ClearStateWipesStorageForTheTargetOrigin(t *testing.T) { // 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,", false, nil); err != nil { - t.Fatalf("Launch: %v", err) - } + d, _ := launchChrome(t, "data:text/html,") var hasContext bool if err := chromedp.Run(d.tabCtx, chromedp.Evaluate( `!!document.createElement("canvas").getContext("webgl2")`, &hasContext)); err != nil { @@ -98,13 +129,7 @@ func TestLaunch_WebGLContextIsAvailable(t *testing.T) { // 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,", false, nil); err != nil { - t.Fatalf("Launch: %v", err) - } + d, _ := launchChrome(t, "data:text/html,") cancelled, cancel := context.WithCancel(context.Background()) cancel() @@ -134,185 +159,6 @@ func TestActionMethods_HonorCallerCancellation(t *testing.T) { } } -// 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 = `` + - `` + - `` + - `` + - `
x
` + - `` + - `` - - 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 = `` + - `
` + - `` + - `
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"]) - } -} - // 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 @@ -330,19 +176,7 @@ func TestHierarchy_HintTextNamesAnEditableField(t *testing.T) { `` + `` + `` - 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) - } + d, ctx := launchChrome(t, servePage(t, html).URL) dump, err := d.Hierarchy(ctx) if err != nil { t.Fatalf("Hierarchy: %v", err) @@ -447,13 +281,7 @@ func TestHierarchy_RootsAtDocumentElementWithoutHead(t *testing.T) { `` + `
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) - } + d, ctx := launchChrome(t, "data:text/html,"+html) dump, err := d.Hierarchy(ctx) if err != nil { t.Fatalf("Hierarchy: %v", err) @@ -595,19 +423,7 @@ func TestInputText_ReplacesTextInsideAShadowRoot(t *testing.T) { root.getElementById("surface").addEventListener("click", function () { proxy.focus(); }); proxy.addEventListener("input", function () { field.textContent = proxy.value; }); ` - 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) - } + d, ctx := launchChrome(t, servePage(t, page).URL) if err := d.Tap(ctx, 40, 40); err != nil { t.Fatalf("Tap: %v", err) } @@ -644,11 +460,7 @@ func TestInputText_ReplacesTextInsideAShadowRoot(t *testing.T) { // 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(`
app
`)) - })) - defer server.Close() + server := servePage(t, `
app
`) for _, testCase := range []struct { name string @@ -660,13 +472,7 @@ func TestHierarchy_ScreenFallsBackToThePathname(t *testing.T) { {"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) - } + d, ctx := launchChrome(t, server.URL+testCase.path) dump, err := d.Hierarchy(ctx) if err != nil { t.Fatalf("Hierarchy: %v", err) @@ -698,19 +504,7 @@ func TestWaitForIdle_WaitsForWorkTheActionKickedOff(t *testing.T) { setTimeout(function () { root.getElementById("out").textContent = "settled"; }, 100); }); ` - 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) - } + d, ctx := launchChrome(t, servePage(t, page).URL) if err := d.Tap(ctx, 40, 40); err != nil { t.Fatalf("Tap: %v", err) } @@ -735,19 +529,7 @@ func TestWaitForIdle_ReturnsOnABusyPage(t *testing.T) { let n = 0; setInterval(function () { document.getElementById("tick").textContent = String(++n); }, 15); ` - 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) - } + d, ctx := launchChrome(t, servePage(t, page).URL) start := time.Now() if err := d.WaitForIdle(ctx, time.Second); err != nil { t.Fatalf("WaitForIdle: %v", err) @@ -792,19 +574,7 @@ func TestWaitForIdle_WaitsOutARouteTransition(t *testing.T) { setTimeout(function () { root.getElementById("LedgerScreen").remove(); }, 1000); }); ` - 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) - } + d, ctx := launchChrome(t, servePage(t, page).URL) if err := d.Tap(ctx, 40, 40); err != nil { t.Fatalf("Tap: %v", err) } @@ -830,19 +600,7 @@ func TestWaitForIdle_WaitsOutARouteTransition(t *testing.T) { // whole step budget on every step. func TestWaitForIdle_BoundsTheTransitionWait(t *testing.T) { const page = `
home
ledger
` - 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) - } + d, ctx := launchChrome(t, servePage(t, page).URL) start := time.Now() if err := d.WaitForIdle(ctx, 10*time.Second); err != nil { t.Fatalf("WaitForIdle: %v", err) @@ -874,19 +632,7 @@ func TestEvaluateExtractors_WaitsOutARouteTransition(t *testing.T) { setTimeout(function () { root.getElementById("LedgerScreen").remove(); }, 400); }; ` - 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) - } + d, ctx := launchChrome(t, servePage(t, page).URL) if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(`window.startTransition()`, nil)); err != nil { t.Fatalf("start transition: %v", err) } @@ -915,21 +661,9 @@ func TestSetLastAction_ReportsAPageThatCannotTakeIt(t *testing.T) { window.__sanderlingSetLastAction__ = function (value) { window.__lastActionSeen = value; }; ` const withoutSetter = `
no sanderling runtime here
` - 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() + without := servePage(t, withoutSetter) - 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) - } + d, ctx := launchChrome(t, servePage(t, withSetter).URL) 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) @@ -943,7 +677,7 @@ func TestSetLastAction_ReportsAPageThatCannotTakeIt(t *testing.T) { 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 { + if err := d.Launch(ctx, without.URL, false, nil); err != nil { t.Fatalf("Launch: %v", err) } if err := d.SetLastAction(ctx, action); err == nil { @@ -963,21 +697,9 @@ func TestSetLogs_ReportsAPageThatCannotTakeThem(t *testing.T) { window.__sanderlingSetLogs__ = function (value) { window.__logsSeen = value; }; ` const withoutSetter = `
no sanderling runtime here
` - 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() + without := servePage(t, withoutSetter) - 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) - } + d, ctx := launchChrome(t, servePage(t, withSetter).URL) 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) @@ -991,7 +713,7 @@ func TestSetLogs_ReportsAPageThatCannotTakeThem(t *testing.T) { 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 { + if err := d.Launch(ctx, without.URL, false, nil); err != nil { t.Fatalf("Launch: %v", err) } if err := d.SetLogs(ctx, logs); err == nil { @@ -1007,19 +729,7 @@ func TestSetLogs_ReportsAPageThatCannotTakeThem(t *testing.T) { // dump-derived values while believing they came from the page. func TestEvaluateExtractors_ReportsAMissingTable(t *testing.T) { const page = `
no sanderling runtime here
` - 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) - } + d, ctx := launchChrome(t, servePage(t, page).URL) values, err := d.EvaluateExtractors(ctx) if err == nil { t.Errorf("EvaluateExtractors returned %v and no error on a page with no "+ @@ -1041,19 +751,7 @@ func TestEvaluateExtractors_KeepsUndefinedApartFromNull(t *testing.T) { return {0: {}, 1: {value: null}, 2: {value: {balance: 7}}}; }; ` - 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) - } + d, ctx := launchChrome(t, servePage(t, page).URL) values, err := d.EvaluateExtractors(ctx) if err != nil { @@ -1082,19 +780,7 @@ func TestEvaluateExtractors_RejectsAnUnenvelopedReading(t *testing.T) { const page = `` - 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) - } + d, ctx := launchChrome(t, servePage(t, page).URL) values, err := d.EvaluateExtractors(ctx) if err == nil { @@ -1117,13 +803,7 @@ func TestHierarchy_CarriesEveryMarkupAttribute(t *testing.T) { `
Tim
` + `` - d := New() - defer d.Terminate(context.Background()) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - if err := d.Launch(ctx, "data:text/html,"+html, false, nil); err != nil { - t.Fatalf("Launch: %v", err) - } + d, ctx := launchChrome(t, "data:text/html,"+html) dump, err := d.Hierarchy(ctx) if err != nil { t.Fatalf("Hierarchy: %v", err) @@ -1177,21 +857,7 @@ document.getElementById('grow').addEventListener('click', function() { // 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) - } + d, ctx := launchChrome(t, servePage(t, growingPage).URL) tapByID := func(id string) { t.Helper() @@ -1247,21 +913,7 @@ func TestTap_ReportsAGestureThatReachesNoElement(t *testing.T) {
top
` - 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) - } + d, ctx := launchChrome(t, servePage(t, page).URL) err := d.Tap(ctx, 100, 5000) if !errors.Is(err, driver.ErrGestureUndelivered) { t.Fatalf( @@ -1283,21 +935,7 @@ func TestTapSelector_ReportsASelectorThatMatchesNothing(t *testing.T) { document.getElementById('status').textContent = 'present tapped'; }); ` - 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) - } + d, ctx := launchChrome(t, servePage(t, page).URL) missCtx, missCancel := context.WithTimeout(ctx, 10*time.Second) defer missCancel() @@ -1359,21 +997,7 @@ func doubleClickStatus(t *testing.T, d *Driver, ctx context.Context) string { // 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) - } + d, ctx := launchChrome(t, servePage(t, doubleClickPage).URL) if err := d.DoubleTap(ctx, 100, 30); err != nil { t.Fatalf("DoubleTap: %v", err) } @@ -1390,21 +1014,7 @@ func TestDoubleTap_ReachesADoubleClickHandler(t *testing.T) { // 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) - } + d, ctx := launchChrome(t, servePage(t, doubleClickPage).URL) if err := d.DoubleTapSelector(ctx, "id:target"); err != nil { t.Fatalf("DoubleTapSelector: %v", err) } @@ -1417,25 +1027,6 @@ func TestDoubleTapSelector_ReachesADoubleClickHandler(t *testing.T) { } } -// 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 @@ -1443,14 +1034,7 @@ func gesturesServer(t *testing.T) *httptest.Server { // 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) - } + d, ctx := launchChrome(t, testdataServer(t).URL+"/gestures.html") read := func(expression string) int { t.Helper() @@ -1524,21 +1108,7 @@ func TestScroll_ReportsAGestureThatReachesNoElement(t *testing.T) { const page = `
top
` - 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) - } + d, ctx := launchChrome(t, servePage(t, page).URL) err := d.Scroll(ctx, 100, 5000, 100, 4800, 300*time.Millisecond) if !errors.Is(err, driver.ErrGestureUndelivered) { t.Fatalf( @@ -1554,14 +1124,7 @@ func TestScroll_ReportsAGestureThatReachesNoElement(t *testing.T) { // 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) - } + d, ctx := launchChrome(t, testdataServer(t).URL+"/gestures.html") if err := d.Swipe(ctx, 300, 40, 100, 40, 300*time.Millisecond); err != nil { t.Fatalf("Swipe: %v", err) } @@ -1584,19 +1147,7 @@ func TestSwipe_DeliversATrustedDragToARowHandler(t *testing.T) { // 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(`
app
`)) - })) - 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) - } + d, ctx := launchChrome(t, servePage(t, `
app
`).URL) const legacyRuntime = `window.__sanderlingNextAction__ = function () { return null; };` if err := d.InstallBundle(ctx, []byte(legacyRuntime)); err == nil { @@ -1644,19 +1195,7 @@ export const properties = {}; 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(`
app
`)) - })) - 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) - } + d, ctx := launchChrome(t, servePage(t, `
app
`).URL) if err := d.InstallBundle(ctx, bundle.JavaScript); err != nil { t.Fatalf("InstallBundle rejected this checkout's own web runtime: %v", err) } @@ -1667,19 +1206,7 @@ export const properties = {}; // 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(`
app
`)) - })) - 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) - } + d, ctx := launchChrome(t, servePage(t, `
app
`).URL) const pickerFree = `window.__sanderlingBundleCheck__ = true;` if err := d.InstallBundle(ctx, []byte(pickerFree)); err != nil { @@ -1692,18 +1219,7 @@ func TestInstallBundle_AcceptsAPageThatInstallsNoPicker(t *testing.T) { // 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(`
app
`)) - })) - 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) - } + d, ctx := launchChrome(t, servePage(t, `
app
`).URL) if _, err := d.Metrics(ctx, ""); err != nil { t.Fatalf("Metrics on a live page: %v", err) } diff --git a/internal/driver/chrome/element_state_test.go b/internal/driver/chrome/element_state_test.go index bd6a8dc..7c7a7c8 100644 --- a/internal/driver/chrome/element_state_test.go +++ b/internal/driver/chrome/element_state_test.go @@ -5,12 +5,9 @@ package chrome import ( "context" "encoding/json" - "net/http" - "net/http/httptest" "path/filepath" "slices" "testing" - "time" "github.com/chromedp/chromedp" @@ -31,16 +28,7 @@ import ( // 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) - } + d, ctx := launchChrome(t, testdataServer(t).URL+"/element-state.html") installStateProbe(ctx, t, d) requireChecked(ctx, t, d, "toggle-all", false) @@ -96,16 +84,7 @@ func requireChecked( // 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) - } + d, ctx := launchChrome(t, testdataServer(t).URL+"/element-state.html") installStateProbe(ctx, t, d) requireBoolean( @@ -191,43 +170,6 @@ func TestElementState_ReportsTheOtherDocumentedBooleans(t *testing.T) { ) } -// Every editable field states `secure`, false included. internal/verifier -// redacts a typed value whenever the target does not positively report "not a -// secure entry", so a dump that omitted the key on an ordinary text field would -// redact the whole recent-action memory on web. -func TestElementState_SecureStatesEveryEditableField(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 _, testCase := range []struct { - selector string - want bool - }{ - {"id:secret", true}, - {"id:editing", false}, - } { - element := elementInHierarchyDump(ctx, t, d, testCase.selector) - if !element.SecureReported() { - t.Errorf("the dump states no secure fact for %s", testCase.selector) - } - if element.Secure != testCase.want { - t.Errorf("%s secure = %v, want %v", testCase.selector, element.Secure, testCase.want) - } - } - - if button := elementInHierarchyDump(ctx, t, d, "id:save"); button.SecureReported() { - t.Error("a button is not a text entry and states nothing") - } -} - // Focus belongs to the node the user is typing into, not to the element the // shadow tree is mounted on. // @@ -237,16 +179,7 @@ func TestElementState_SecureStatesEveryEditableField(t *testing.T) { // to type when the field it tapped is not the one holding focus, so every // InputText step on such an app failed and the run aborted. func TestElementState_FocusDescendsIntoTheShadowRoot(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+"/shadow-focus.html", false, nil); err != nil { - t.Fatalf("Launch: %v", err) - } + d, ctx := launchChrome(t, testdataServer(t).URL+"/shadow-focus.html") field := elementInHierarchyDump(ctx, t, d, "id:shadow-field") x, y := field.Bounds.Center() @@ -274,16 +207,7 @@ func TestElementState_FocusDescendsIntoTheShadowRoot(t *testing.T) { // Both fields are tapped, because reporting the first editable in the tree // would satisfy the email half of this and still type into the wrong field. func TestElementState_FocusFollowsTheCaretToItsField(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+"/compose-backing-input.html", false, nil); err != nil { - t.Fatalf("Launch: %v", err) - } + d, ctx := launchChrome(t, testdataServer(t).URL+"/compose-backing-input.html") for _, field := range []string{"EmailField", "PasswordField"} { tapped := elementInHierarchyDump(ctx, t, d, "id:"+field) @@ -466,16 +390,7 @@ func installStateProbe(ctx context.Context, t *testing.T, d *Driver) { // 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) - } + d, ctx := launchChrome(t, testdataServer(t).URL+"/element-state.html") for _, keyCase := range []struct{ key, want string }{ {"enter", "Enter"}, @@ -544,16 +459,7 @@ func keysSeenByThePage(ctx context.Context, t *testing.T, d *Driver) []string { // 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) - } + d, ctx := launchChrome(t, testdataServer(t).URL+"/custom-element-flags.html") dump, err := d.Hierarchy(ctx) if err != nil { diff --git a/internal/driver/chrome/fact_parity_test.go b/internal/driver/chrome/fact_parity_test.go index fc9be28..e24cb2e 100644 --- a/internal/driver/chrome/fact_parity_test.go +++ b/internal/driver/chrome/fact_parity_test.go @@ -5,14 +5,11 @@ package chrome import ( "context" "encoding/json" - "net/http" - "net/http/httptest" "path/filepath" "runtime" "slices" "strconv" "testing" - "time" "github.com/chromedp/chromedp" @@ -89,19 +86,114 @@ type factRow struct { // producers have to descend into or they enumerate one node for an entire app. var parityPages = []string{"fact-parity.html", "fact-parity-shadow.html"} +// booleanFacts is every boolean fact both producers derive, with the reader +// that takes it off an element. It is ordered so a page reports its facts in +// the same sequence every run. +var booleanFacts = []struct { + name string + read func(elementFacts) bool +}{ + {"clickable", func(f elementFacts) bool { return f.clickable }}, + {"enabled", func(f elementFacts) bool { return f.enabled }}, + {"editable", func(f elementFacts) bool { return f.editable }}, + {"scrollable", func(f elementFacts) bool { return f.scrollable }}, + {"hintText", func(f elementFacts) bool { return f.hintText != "" }}, + {"checked", func(f elementFacts) bool { return f.checked }}, + {"selected", func(f elementFacts) bool { return f.selected }}, + {"focused", func(f elementFacts) bool { return f.focused }}, + {"positiveBounds", func(f elementFacts) bool { return f.positiveBounds }}, +} + +// statedBoolean is one reading a fixture page was built to produce, named by +// the element that holds it. +type statedBoolean struct { + id string + fact string + want bool +} + +// statedFacts is what a page states, as opposed to what the two producers +// happen to agree on. +type statedFacts struct { + booleans []statedBoolean + // secure is three-valued, so it is stated apart from the booleans. A field + // the producer says nothing about is not a field it says is not a password: + // internal/verifier redacts a typed value unless the target positively + // reports "not a secure entry", so a producer that leaves the key off an + // ordinary text field redacts the whole recent-action memory on web. + secure map[string]string +} + +// What each fixture page derives, pinned as values. compareDerivedFacts passes +// whenever the two producers read a fact the same wrong way, which is the shape +// of every producer bug in the header, so the readings themselves are held +// here: an onclick PROPERTY (which React assigns to its root container) is not +// a tap target where an onclick attribute is, a role the ARIA contract says a +// user activates is, contenteditable makes the container typeable and not the +// span that merely inherits it, a checkbox is not a text field, a container is +// scrollable only where its content overflows its box on either axis, and a +// field that is not a password entry states so rather than staying silent. +var factsEachPageStates = map[string]statedFacts{ + "fact-parity.html": { + booleans: []statedBoolean{ + {"amount", "editable", true}, + {"notes", "editable", true}, + {"bio", "editable", true}, + {"bio-word", "editable", false}, + {"agree", "editable", false}, + {"save", "editable", false}, + + {"save", "clickable", true}, + {"notes", "clickable", true}, + {"menu", "clickable", true}, + {"attribute-click", "clickable", true}, + {"delegating-root", "clickable", false}, + {"plain", "clickable", false}, + + {"scroller", "scrollable", true}, + {"sideways", "scrollable", true}, + {"fits", "scrollable", false}, + }, + secure: map[string]string{ + "password": "true", + "amount": "false", + "notes": "false", + "save": "", + }, + }, + "fact-parity-shadow.html": { + booleans: []statedBoolean{ + {"shadow-amount", "editable", true}, + {"shadow-agree", "editable", false}, + {"shadow-save", "editable", false}, + + {"shadow-save", "clickable", true}, + {"shadow-plain", "clickable", false}, + + {"shadow-scroller", "scrollable", true}, + }, + secure: map[string]string{ + "shadow-password": "true", + "shadow-amount": "false", + "shadow-save": "", + }, + }, +} + func TestHierarchy_DerivesTheSameFactsAsTheWebRuntime(t *testing.T) { - server := httptest.NewServer(http.FileServer(http.Dir("testdata"))) - defer server.Close() + server := testdataServer(t) for _, page := range parityPages { t.Run(page, func(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, server.URL+"/"+page, false, nil); err != nil { - t.Fatalf("Launch: %v", err) + stated, ok := factsEachPageStates[page] + if !ok { + t.Fatalf( + "testdata/%s states no facts of its own, so its run compares the "+ + "two producers to each other and to nothing else", + page, + ) } + d, ctx := launchChrome(t, server.URL+"/"+page) dump, err := d.Hierarchy(ctx) if err != nil { @@ -115,12 +207,87 @@ func TestHierarchy_DerivesTheSameFactsAsTheWebRuntime(t *testing.T) { requireBothPolarities(t, fromWebRuntime) requireEverySecureState(t, fromWebRuntime) requireTheHandleAgreesWithTheEnumeration(t, fromWebRuntime) + requireTheFactsThePageStates(t, "the hierarchy dump", stated, fromDump) + requireTheFactsThePageStates(t, "the web runtime", stated, fromWebRuntime) compareEnumeratedElements(t, fromDump, fromWebRuntime) compareDerivedFacts(t, fromDump, fromWebRuntime) }) } } +// requireTheFactsThePageStates holds one producer to the page rather than to +// the other producer, which is what keeps the comparison anchored to something +// outside the pair. +func requireTheFactsThePageStates( + t *testing.T, + producer string, + stated statedFacts, + rows []factRow, +) { + t.Helper() + byID := map[string]elementFacts{} + for _, row := range rows { + byID[row.id] = row.facts + } + for _, boolean := range stated.booleans { + facts, enumerated := byID[boolean.id] + if !enumerated { + t.Errorf( + "%s never enumerated %q, which the fixture states %s=%v for", + producer, + boolean.id, + boolean.fact, + boolean.want, + ) + continue + } + if got := readBooleanFact(t, boolean.fact)(facts); got != boolean.want { + t.Errorf( + "%s derives %s=%v for %q (<%s>), want %v", + producer, + boolean.fact, + got, + boolean.id, + facts.tag, + boolean.want, + ) + } + } + for id, want := range stated.secure { + facts, enumerated := byID[id] + if !enumerated { + t.Errorf( + "%s never enumerated %q, which the fixture states secure=%q for", + producer, + id, + want, + ) + continue + } + if facts.secure != want { + t.Errorf( + "%s derives secure=%q for %q (<%s>), want %q", + producer, + facts.secure, + id, + facts.tag, + want, + ) + } + } +} + +func readBooleanFact(t *testing.T, name string) func(elementFacts) bool { + t.Helper() + for _, fact := range booleanFacts { + if fact.name == name { + return fact.read + } + } + t.Fatalf("%q is not a fact either producer derives", name) + return nil +} + // factsFromHierarchyDump reads the dump the way the goja host does: parse it, // then take exactly the fields internal/verifier/worker.go targets() reads off // each element. @@ -275,20 +442,7 @@ func requireEveryElementNamed(t *testing.T, producer string, rows []factRow) { // false on every element and pass while proving nothing about that fact. func requireBothPolarities(t *testing.T, rows []factRow) { t.Helper() - for _, fact := range []struct { - name string - read func(elementFacts) bool - }{ - {"clickable", func(f elementFacts) bool { return f.clickable }}, - {"enabled", func(f elementFacts) bool { return f.enabled }}, - {"editable", func(f elementFacts) bool { return f.editable }}, - {"scrollable", func(f elementFacts) bool { return f.scrollable }}, - {"hintText", func(f elementFacts) bool { return f.hintText != "" }}, - {"checked", func(f elementFacts) bool { return f.checked }}, - {"selected", func(f elementFacts) bool { return f.selected }}, - {"focused", func(f elementFacts) bool { return f.focused }}, - {"positiveBounds", func(f elementFacts) bool { return f.positiveBounds }}, - } { + for _, fact := range booleanFacts { var sawTrue, sawFalse bool for _, row := range rows { if fact.read(row.facts) { diff --git a/internal/driver/chrome/picker_stream_test.go b/internal/driver/chrome/picker_stream_test.go index e924f9c..f2c5ef9 100644 --- a/internal/driver/chrome/picker_stream_test.go +++ b/internal/driver/chrome/picker_stream_test.go @@ -4,12 +4,9 @@ package chrome import ( "context" - "net/http" - "net/http/httptest" "path/filepath" "strconv" "testing" - "time" "github.com/chromedp/chromedp" @@ -28,9 +25,7 @@ const streamSeed = 1 // on 200 of 200 steps and created no todo at all. The run reported clean, so // nothing but this distinguishes it from a seed that chose badly. func TestNextActionFromV8_ReloadDoesNotRestartTheSeedStream(t *testing.T) { - server := httptest.NewServer(http.FileServer(http.Dir("testdata"))) - defer server.Close() - url := server.URL + "/picker-stream.html" + url := testdataServer(t).URL + "/picker-stream.html" const calls = 8 uninterrupted := pickerStream(t, url, calls, calls) @@ -50,17 +45,9 @@ func TestNextActionFromV8_ReloadDoesNotRestartTheSeedStream(t *testing.T) { // A navigation the trace cannot see is a run nobody can read: an analysis has // no way to tell an app that reloaded from a generator that repeated itself. func TestNavigations_ReportTheDocumentThatReplacedThePage(t *testing.T) { - server := httptest.NewServer(http.FileServer(http.Dir("testdata"))) - defer server.Close() - url := server.URL + "/picker-stream.html" + url := testdataServer(t).URL + "/picker-stream.html" - d := New() - defer d.Terminate(context.Background()) - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - if err := d.Launch(ctx, url, false, nil); err != nil { - t.Fatalf("Launch: %v", err) - } + d, ctx := launchChrome(t, url) opening, err := d.Navigations(ctx) if err != nil { @@ -101,13 +88,7 @@ func TestNavigations_ReportTheDocumentThatReplacedThePage(t *testing.T) { // calls. Reloading past the call count leaves the stream uninterrupted. func pickerStream(t *testing.T, url string, calls, reloadAfter int) []string { t.Helper() - d := New() - defer d.Terminate(context.Background()) - ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) - defer cancel() - if err := d.Launch(ctx, url, false, nil); err != nil { - t.Fatalf("Launch: %v", err) - } + d, ctx := launchChrome(t, url) installPickerStreamProbe(ctx, t, d) chosen := make([]string, 0, calls) diff --git a/internal/driver/chrome/selector_parity_test.go b/internal/driver/chrome/selector_parity_test.go index 8fe625b..4d1d3bf 100644 --- a/internal/driver/chrome/selector_parity_test.go +++ b/internal/driver/chrome/selector_parity_test.go @@ -5,13 +5,10 @@ package chrome import ( "context" "encoding/json" - "net/http" - "net/http/httptest" "path/filepath" "slices" "strings" "testing" - "time" "github.com/chromedp/chromedp" @@ -31,16 +28,7 @@ import ( // This test starts from one real page in a real browser and asks both matchers // the same questions. func TestSelectors_ResolveTheSameElementsAsTheWebRuntime(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+"/selector-parity.html", false, nil); err != nil { - t.Fatalf("Launch: %v", err) - } + d, ctx := launchChrome(t, testdataServer(t).URL+"/selector-parity.html") dump, err := d.Hierarchy(ctx) if err != nil { @@ -381,16 +369,7 @@ func TestSelectors_ResolveTheSameElementsAsTheWebRuntime(t *testing.T) { // turned the whole selector into a parse error: querySelectorAll throws, and // what a spec sees is an exception out of the extractor rather than an element. func TestSelectors_SecureCombinesWithAnotherKey(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+"/selector-parity.html", false, nil); err != nil { - t.Fatalf("Launch: %v", err) - } + d, ctx := launchChrome(t, testdataServer(t).URL+"/selector-parity.html") dump, err := d.Hierarchy(ctx) if err != nil { t.Fatalf("Hierarchy: %v", err) @@ -426,16 +405,7 @@ func TestSelectors_SecureCombinesWithAnotherKey(t *testing.T) { // has an opinion about (everything is enabled, almost nothing is checked) // answers with most of the document and a want list nobody can check by reading. func TestSelectors_BooleanStatesNameWhatBothProducersReport(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+"/selector-parity.html", false, nil); err != nil { - t.Fatalf("Launch: %v", err) - } + d, ctx := launchChrome(t, testdataServer(t).URL+"/selector-parity.html") dump, err := d.Hierarchy(ctx) if err != nil { t.Fatalf("Hierarchy: %v", err) @@ -619,16 +589,7 @@ func TestSelectors_BooleanStatesNameWhatBothProducersReport(t *testing.T) { // '[id="state_month"]select' and querySelectorAll threw a SyntaxError. Which of // the two a spec got depended on the order its author wrote the keys in. func TestSelectors_TagCombinesWithAnotherKey(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+"/selector-parity.html", false, nil); err != nil { - t.Fatalf("Launch: %v", err) - } + d, ctx := launchChrome(t, testdataServer(t).URL+"/selector-parity.html") dump, err := d.Hierarchy(ctx) if err != nil { t.Fatalf("Hierarchy: %v", err) @@ -661,16 +622,7 @@ func TestSelectors_TagCombinesWithAnotherKey(t *testing.T) { // ancestors up to the document root read as matches too, and the deepest one is // the element the author meant. func TestSelectors_TextNamesTheInnermostMatchInEveryResolver(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+"/selector-parity.html", false, nil); err != nil { - t.Fatalf("Launch: %v", err) - } + d, ctx := launchChrome(t, testdataServer(t).URL+"/selector-parity.html") dump, err := d.Hierarchy(ctx) if err != nil { t.Fatalf("Hierarchy: %v", err) @@ -825,16 +777,7 @@ func TestSelectors_TextNamesTheInnermostMatchInEveryResolver(t *testing.T) { // "January" is not clickable: resolving text to its own innermost match before // the other keys narrow anything answers with nothing in both. func TestSelectors_TextCombinesWithAnotherKey(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+"/selector-parity.html", false, nil); err != nil { - t.Fatalf("Launch: %v", err) - } + d, ctx := launchChrome(t, testdataServer(t).URL+"/selector-parity.html") dump, err := d.Hierarchy(ctx) if err != nil { t.Fatalf("Hierarchy: %v", err) @@ -971,16 +914,7 @@ func xpathIDsOverCDP( // step, resolving the target in the dump and tapping it over CDP, so a selector // the two read differently taps one element and reads the text of another. func TestSelectors_DataTestIDNamesTheSameElementInTheDumpAndOverCDP(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+"/selector-parity.html", false, nil); err != nil { - t.Fatalf("Launch: %v", err) - } + d, ctx := launchChrome(t, testdataServer(t).URL+"/selector-parity.html") dump, err := d.Hierarchy(ctx) if err != nil { t.Fatalf("Hierarchy: %v", err) @@ -1026,16 +960,7 @@ func TestSelectors_DataTestIDNamesTheSameElementInTheDumpAndOverCDP(t *testing.T // already does. placeholder keeps its CSS: it is the attribute the markup // writes, which is what all three resolvers read it as. func TestSelectors_HintSelectorsResolveOverCDPToWhatBothMatchersName(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+"/selector-parity.html", false, nil); err != nil { - t.Fatalf("Launch: %v", err) - } + d, ctx := launchChrome(t, testdataServer(t).URL+"/selector-parity.html") dump, err := d.Hierarchy(ctx) if err != nil { t.Fatalf("Hierarchy: %v", err)