diff --git a/internal/driver/chrome/driver.go b/internal/driver/chrome/driver.go index 63aa2b2..220e7a6 100644 --- a/internal/driver/chrome/driver.go +++ b/internal/driver/chrome/driver.go @@ -21,6 +21,7 @@ import ( "github.com/chromedp/chromedp/kb" "github.com/priyanshujain/sanderling/internal/driver" + "github.com/priyanshujain/sanderling/internal/verifier" ) // Driver implements DeviceDriver via chromedp for web platform testing. @@ -1055,10 +1056,19 @@ func (d *Driver) runCtx(ctx context.Context) (context.Context, context.CancelFun // InstallBundle registers the source so it runs at every freshly-navigated // document context, then immediately evaluates it against the current page so // the very first tick has access to the registered globals. +// +// The installed runtime then has to declare the action encoding it serializes, +// and it has to be the one this binary decodes. The web bundle is resolved from +// whatever @sanderling/spec the spec's project has installed, so a page can +// carry a runtime that encodes an action differently from the runner that +// dispatches it: both halves then run to completion, every action reports +// success, and the gestures are wrong. Verifier.checkActionEncoding applies the +// same rule to the goja host. func (d *Driver) InstallBundle(ctx context.Context, source []byte) error { runCtx, cancel := d.runCtx(ctx) defer cancel() - return chromedp.Run(runCtx, + var declaration actionEncodingDeclaration + if err := chromedp.Run(runCtx, chromedp.ActionFunc(func(ctx context.Context) error { if _, err := page.AddScriptToEvaluateOnNewDocument(string(source)).Do(ctx); err != nil { return fmt.Errorf("addScriptToEvaluateOnNewDocument: %w", err) @@ -1072,9 +1082,36 @@ func (d *Driver) InstallBundle(ctx context.Context, source []byte) error { } return nil }), - ) + chromedp.Evaluate(actionEncodingScript, &declaration), + ); err != nil { + return err + } + if !declaration.InstallsPicker { + return nil + } + if declaration.Encoding != verifier.ActionWireContract { + return verifier.ActionEncodingError(declaration.Encoding) + } + return nil } +type actionEncodingDeclaration struct { + InstallsPicker bool `json:"installsPicker"` + Encoding string `json:"encoding"` +} + +// actionEncodingScript reports both halves of the rule in one round trip. A +// bundle that installs no picker generates no actions, so it has no encoding to +// disagree about; a bundle that installs a picker and declares nothing is an +// @sanderling/spec older than the declaration, so an absent declaration is a +// mismatch rather than a default. Coercing to a string keeps that case +// decodable as "". +const actionEncodingScript = `({ + installsPicker: window.__sanderlingNextAction__ !== undefined && + window.__sanderlingNextAction__ !== null, + encoding: String(window.__sanderlingActionEncoding__ ?? ""), +})` + // EvaluateExtractors invokes the bundle-installed extractor table and returns // each extractor's JSON-encoded current value keyed by its registration index. // diff --git a/internal/driver/chrome/driver_test.go b/internal/driver/chrome/driver_test.go index f18f03e..f612010 100644 --- a/internal/driver/chrome/driver_test.go +++ b/internal/driver/chrome/driver_test.go @@ -10,13 +10,17 @@ import ( "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 @@ -1570,3 +1574,115 @@ func TestSwipe_DeliversATrustedDragToARowHandler(t *testing.T) { 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(`
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) + } + + 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(`
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) + } + 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(`
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) + } + + 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) + } +}