Files
sanderling/internal/driver/chrome/driver_test.go
T
pj 715d239477 fix(chrome): clear storage over cdp instead of scripting an opaque origin
Launch runs while the tab is still on about:blank, whose opaque origin denies
storage access, so localStorage.clear() threw SecurityError and every web run
died at launch. Storage.clearDataForOrigin needs no navigation. The exception
helper lands here because "Uncaught" is what hid this for so long.

Claude-Session: https://claude.ai/code/session_01Fj4wJUikdABuMQEETwW55J
2026-08-12 16:48:52 +05:30

202 lines
6.4 KiB
Go

//go:build browser
package chrome
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/chromedp/chromedp"
)
// 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)
}
}
// 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)
}
}
}
// 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")
}
}