feat(chrome): the web host applies the same action-encoding rule

The web bundle is resolved from whatever @sanderling/spec the spec's project
has installed, so the page can carry a runtime that encodes an action
differently from the runner dispatching it. InstallBundle now reads the
declaration and exempts a bundle with no picker, which is the rule
Verifier.checkActionEncoding states.
This commit is contained in:
pj committed 2026-08-22 21:04:23 +05:30
1 parent 81713fa183
commit d57bd0e508
2 files changed
+155 -2

No files matched your search

+39 -2
View File
@@ -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.
//
+116
View File
@@ -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(`<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)
}
}