diff --git a/internal/driver/chrome/driver.go b/internal/driver/chrome/driver.go index e96f8d3..80ddca2 100644 --- a/internal/driver/chrome/driver.go +++ b/internal/driver/chrome/driver.go @@ -32,6 +32,14 @@ type Driver struct { logsMu sync.Mutex logs []driver.LogEntry + + navigationsMu sync.Mutex + navigations []driver.Navigation + + // pickerState is the seeded picker's draw position, held here rather than + // in the page: a navigation replaces the page's runtime, and a runtime that + // starts over restarts the seed's stream at its first draw. + pickerState string } // New creates a new ChromeDriver. Call Terminate when done. @@ -94,6 +102,19 @@ func New() *Driver { d.logsMu.Unlock() }) + chromedp.ListenTarget(tabCtx, func(ev any) { + e, ok := ev.(*page.EventFrameNavigated) + if !ok || e.Frame == nil || e.Frame.ParentID != "" { + return + } + d.navigationsMu.Lock() + d.navigations = append(d.navigations, driver.Navigation{ + URL: e.Frame.URL, + UnixMillis: time.Now().UnixMilli(), + }) + d.navigationsMu.Unlock() + }) + return d } @@ -133,9 +154,22 @@ func (d *Driver) Launch(ctx context.Context, bundleID string, clearState bool, _ })()`, &dims)); err == nil && dims[0] > 0 && dims[1] > 0 { _ = chromedp.Run(runCtx, chromedp.EmulateViewport(dims[0], dims[1])) } + // The opening navigation is the harness arriving, not the app navigating. + _, _ = d.Navigations(ctx) return nil } +// Navigations returns the document-replacing main-frame navigations seen since +// the last call and forgets them. Each one replaced the page's runtime, which +// is what separates "the app reloaded" from "the picker repeated itself". +func (d *Driver) Navigations(context.Context) ([]driver.Navigation, error) { + d.navigationsMu.Lock() + defer d.navigationsMu.Unlock() + drained := d.navigations + d.navigations = nil + return drained, nil +} + // clearState wipes the target's stored data before the application loads. // Script cannot do it: the tab still sits on about:blank, whose opaque origin // denies storage access, so `localStorage.clear()` throws SecurityError and @@ -1078,19 +1112,87 @@ new Promise((resolve, reject) => { read(); })` +// Exceptions returns the uncaught errors and unhandled rejections the page +// runtime has buffered so far. The buffer is cumulative, which is what +// state.exceptions means inside the page (buildState in +// pkg/spec/src/web-runtime.ts), so the host and the page read one list. +func (d *Driver) Exceptions(ctx context.Context) ([]driver.Exception, error) { + const script = `JSON.stringify(window.__sanderlingExceptions__ ? window.__sanderlingExceptions__() : [])` + var encoded string + runCtx, cancel := d.runCtx(ctx) + defer cancel() + if err := chromedp.Run(runCtx, chromedp.Evaluate(script, &encoded)); err != nil { + return nil, fmt.Errorf("evaluate exceptions: %w", err) + } + if encoded == "" || encoded == "[]" { + return nil, nil + } + var captured []struct { + Class string `json:"class"` + Message string `json:"message"` + StackTrace string `json:"stackTrace"` + UnixMillis int64 `json:"unixMillis"` + } + if err := json.Unmarshal([]byte(encoded), &captured); err != nil { + return nil, fmt.Errorf("decode exceptions %s: %w", encoded, err) + } + result := make([]driver.Exception, 0, len(captured)) + for _, entry := range captured { + result = append(result, driver.Exception{ + Class: entry.Class, + Message: entry.Message, + StackTrace: entry.StackTrace, + UnixMillis: entry.UnixMillis, + }) + } + return result, nil +} + +// nextActionScript puts the carried draw position back before the picker +// decides and reads the new one out afterwards, in the one evaluation, so no +// navigation can land between the restore and the draw. +const nextActionScript = `((carried) => { + if (!window.__sanderlingNextAction__) return "{}"; + if (carried !== "" && window.__sanderlingRestorePickerState__) { + window.__sanderlingRestorePickerState__(carried); + } + const action = window.__sanderlingNextAction__(); + const state = window.__sanderlingPickerState__ ? window.__sanderlingPickerState__() : ""; + return JSON.stringify({action, state}); +})(%s)` + // NextActionFromV8 invokes the bundle-installed action generator and returns // the resulting Action JSON. Returns an empty json.RawMessage when the // generator declines to act this tick. +// +// The picker's draw position rides along: it lives here rather than in the +// page, because a page that navigates gets a fresh runtime whose picker would +// otherwise start the seed's stream over at its first draw on every reload. func (d *Driver) NextActionFromV8(ctx context.Context) (json.RawMessage, error) { - const script = `JSON.stringify(window.__sanderlingNextAction__ ? window.__sanderlingNextAction__() : null)` + script := fmt.Sprintf(nextActionScript, strconv.Quote(d.pickerState)) var encoded string runCtx, cancel := d.runCtx(ctx) defer cancel() if err := chromedp.Run(runCtx, chromedp.Evaluate(script, &encoded)); err != nil { return nil, fmt.Errorf("evaluate next action: %w", err) } - if encoded == "" || encoded == "null" { + if encoded == "" { return nil, nil } - return json.RawMessage(encoded), nil + var decoded struct { + Action json.RawMessage `json:"action"` + State string `json:"state"` + } + if err := json.Unmarshal([]byte(encoded), &decoded); err != nil { + return nil, fmt.Errorf("decode next action %s: %w", encoded, err) + } + // An empty state means the page had no runtime to ask, so the position we + // already hold is still the run's position. + if decoded.State != "" { + d.pickerState = decoded.State + } + if len(decoded.Action) == 0 || string(decoded.Action) == "null" { + return nil, nil + } + return decoded.Action, nil } diff --git a/internal/driver/chrome/picker_stream_test.go b/internal/driver/chrome/picker_stream_test.go new file mode 100644 index 0000000..e924f9c --- /dev/null +++ b/internal/driver/chrome/picker_stream_test.go @@ -0,0 +1,154 @@ +//go:build browser + +package chrome + +import ( + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/chromedp/chromedp" + + "github.com/priyanshujain/sanderling/internal/bundler" +) + +const streamSeed = 1 + +// A seed describes one stream of actions, and a web page can end it. +// +// The picker's draw position lived in the page, and the bundle is registered to +// run at every freshly-navigated document, so any navigation built a new picker +// at the seed's first draw. A page that submits a form, follows a link or +// reloads therefore replayed draw one forever: on the angular-dart TodoMVC +// implementation, whose form GET-submits on Enter, seed 1 chose PressKey enter +// 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" + + const calls = 8 + uninterrupted := pickerStream(t, url, calls, calls) + if distinctActions(uninterrupted) < 2 { + t.Fatalf("the uninterrupted stream never varies (%v); a restart would be invisible", uninterrupted) + } + reloaded := pickerStream(t, url, calls, calls/2) + + for index := range uninterrupted { + if reloaded[index] != uninterrupted[index] { + t.Fatalf("action %d after a reload is %s, uninterrupted the seed chose %s\nreloaded: %v\nuninterrupted: %v", + index, reloaded[index], uninterrupted[index], reloaded, uninterrupted) + } + } +} + +// 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" + + 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) + } + + opening, err := d.Navigations(ctx) + if err != nil { + t.Fatalf("Navigations: %v", err) + } + if len(opening) != 0 { + t.Fatalf("the run's own opening navigation was reported as the app navigating: %v", opening) + } + + if err := chromedp.Run(d.tabCtx, chromedp.Reload()); err != nil { + t.Fatalf("reload: %v", err) + } + reported, err := d.Navigations(ctx) + if err != nil { + t.Fatalf("Navigations: %v", err) + } + if len(reported) != 1 { + t.Fatalf("a reload reported %d navigations, want 1: %v", len(reported), reported) + } + if reported[0].URL != url { + t.Errorf("navigation URL = %q, want %q", reported[0].URL, url) + } + if reported[0].UnixMillis == 0 { + t.Error("navigation carries no timestamp") + } + + drained, err := d.Navigations(ctx) + if err != nil { + t.Fatalf("Navigations: %v", err) + } + if len(drained) != 0 { + t.Errorf("navigations were reported twice: %v", drained) + } +} + +// pickerStream drives the real seeded picker over the page and returns the +// action it chose on each call, reloading the page once after reloadAfter +// 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) + } + installPickerStreamProbe(ctx, t, d) + + chosen := make([]string, 0, calls) + for call := 0; call < calls; call++ { + if call == reloadAfter { + if err := chromedp.Run(d.tabCtx, chromedp.Reload()); err != nil { + t.Fatalf("reload before call %d: %v", call, err) + } + } + action, err := d.NextActionFromV8(ctx) + if err != nil { + t.Fatalf("NextActionFromV8 call %d: %v", call, err) + } + if len(action) == 0 { + t.Fatalf("call %d chose nothing; the page has tappable targets", call) + } + chosen = append(chosen, string(action)) + } + return chosen +} + +func installPickerStreamProbe(ctx context.Context, t *testing.T, d *Driver) { + t.Helper() + specSource := filepath.Join(repoRootDir(t), "pkg", "spec") + probe, err := bundler.BundleWeb(bundler.WebOptions{ + EntryFile: filepath.Join(specSource, "test", "picker-stream-probe.ts"), + WebRuntimeFile: filepath.Join(specSource, "src", "web-runtime.ts"), + Defines: map[string]string{"SANDERLING_SEED": strconv.Itoa(streamSeed)}, + }) + if err != nil { + t.Fatalf("bundle picker stream probe: %v", err) + } + if err := d.InstallBundle(ctx, probe.JavaScript); err != nil { + t.Fatalf("install picker stream probe: %v", err) + } +} + +func distinctActions(actions []string) int { + seen := map[string]struct{}{} + for _, action := range actions { + seen[action] = struct{}{} + } + return len(seen) +} diff --git a/internal/driver/chrome/testdata/picker-stream.html b/internal/driver/chrome/testdata/picker-stream.html new file mode 100644 index 0000000..e2da88f --- /dev/null +++ b/internal/driver/chrome/testdata/picker-stream.html @@ -0,0 +1,14 @@ + + +