mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
feat(verifier): refuse a bundle built against a different action encoding
ActionWireContract names the encoding wireAction decodes and must equal the one the bundled runtime entry declares. An absent declaration is a mismatch, not a default: it is the package that shipped the zero-distance scroll. A bundle that installs no picker generates no actions and is exempt.
This commit is contained in:
1 parent
912ad71804
commit
81713fa183
3 files changed
+192
No files matched your search
@@ -0,0 +1,125 @@
|
|||||||
|
package verifier
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/priyanshujain/sanderling/internal/bundler"
|
||||||
|
)
|
||||||
|
|
||||||
|
// legacyRuntimeEntry stands in for the @sanderling/spec 0.0.3 runtime entry:
|
||||||
|
// it installs the picker and declares nothing, and its authored Scroll carries
|
||||||
|
// the container's own point as both endpoints. Paired with a binary that
|
||||||
|
// treats pre-computed endpoints as authoritative, every scroll it produces is
|
||||||
|
// a drag from a point to itself.
|
||||||
|
const legacyRuntimeEntry = `
|
||||||
|
const target = globalThis as Record<string, unknown>;
|
||||||
|
Object.defineProperty(target, "__sanderlingNextAction__", {
|
||||||
|
value: () => ({
|
||||||
|
kind: "Scroll",
|
||||||
|
direction: "down",
|
||||||
|
fromX: 540,
|
||||||
|
fromY: 1200,
|
||||||
|
toX: 540,
|
||||||
|
toY: 1200,
|
||||||
|
durationMillis: 250,
|
||||||
|
selector: "id:ledger",
|
||||||
|
}),
|
||||||
|
writable: false,
|
||||||
|
configurable: false,
|
||||||
|
enumerable: false,
|
||||||
|
});
|
||||||
|
`
|
||||||
|
|
||||||
|
func bundleWithRuntimeEntry(t *testing.T, runtimeSource string) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
specPath := filepath.Join(dir, "spec.ts")
|
||||||
|
if err := os.WriteFile(specPath, []byte("globalThis.properties = {};\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runtimePath := filepath.Join(dir, "legacy-runtime.ts")
|
||||||
|
if err := os.WriteFile(runtimePath, []byte(runtimeSource), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bundle, err := bundler.Bundle(bundler.Options{EntryFile: specPath, RuntimeFile: runtimePath})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(bundle.JavaScript)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_RefusesABundleThatDeclaresNoActionEncoding(t *testing.T) {
|
||||||
|
verifier := newVerifier(t)
|
||||||
|
|
||||||
|
err := verifier.Load(bundleWithRuntimeEntry(t, legacyRuntimeEntry))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Load accepted a bundle that declares no action encoding; a spec " +
|
||||||
|
"bundled by a package older than this binary dispatches every scroll " +
|
||||||
|
"successfully and travels zero distance, and the run reports nothing")
|
||||||
|
}
|
||||||
|
message := err.Error()
|
||||||
|
for _, want := range []string{ActionWireContract, "same commit"} {
|
||||||
|
if !strings.Contains(message, want) {
|
||||||
|
t.Errorf("Load error %q does not mention %q; the failure has to name "+
|
||||||
|
"both encodings and what to do about it", message, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_RefusesABundleThatDeclaresADifferentActionEncoding(t *testing.T) {
|
||||||
|
verifier := newVerifier(t)
|
||||||
|
runtime := strings.Replace(legacyRuntimeEntry,
|
||||||
|
`const target = globalThis as Record<string, unknown>;`,
|
||||||
|
`const target = globalThis as Record<string, unknown>;
|
||||||
|
target.__sanderlingActionEncoding__ = "action-wire/1";`, 1)
|
||||||
|
|
||||||
|
err := verifier.Load(bundleWithRuntimeEntry(t, runtime))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Load accepted a bundle built against a different action encoding")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "action-wire/1") {
|
||||||
|
t.Errorf("Load error %q does not name the encoding the spec was built "+
|
||||||
|
"against", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The declaration the shipped runtime entry makes and the one this binary
|
||||||
|
// decodes are one contract. When they drift apart every run refuses to start,
|
||||||
|
// so the drift has to be reported here rather than as a bundling failure in
|
||||||
|
// every other suite.
|
||||||
|
func TestLoad_ShippedRuntimeEntryDeclaresTheContractThisBinaryImplements(t *testing.T) {
|
||||||
|
verifier := newVerifier(t)
|
||||||
|
loadActionSpec(t, verifier, `
|
||||||
|
import { actions, Wait } from "@sanderling/spec";
|
||||||
|
globalThis.actions = actions(Wait({ durationMillis: 1 }));
|
||||||
|
globalThis.properties = {};
|
||||||
|
`)
|
||||||
|
|
||||||
|
declared := verifier.runtime.GlobalObject().Get(actionEncodingGlobal)
|
||||||
|
if declared == nil || declared.String() != ActionWireContract {
|
||||||
|
t.Fatalf("pkg/spec/src/runtime-entry.ts declares %v, this binary implements %q",
|
||||||
|
declared, ActionWireContract)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickerFreeRuntimeEntry stands in for a bundle that registers properties and
|
||||||
|
// generates no actions: a raw-JS fixture, or the bundle-check tool, which
|
||||||
|
// bundles with no runtime entry at all.
|
||||||
|
const pickerFreeRuntimeEntry = `
|
||||||
|
const target = globalThis as Record<string, unknown>;
|
||||||
|
target.__sanderlingBundleCheck__ = true;
|
||||||
|
`
|
||||||
|
|
||||||
|
// A bundle with no picker has no encoding to disagree about, so demanding a
|
||||||
|
// declaration from it would refuse to load specs that were never going to
|
||||||
|
// dispatch anything. chrome.Driver.InstallBundle applies the same rule.
|
||||||
|
func TestLoad_AcceptsABundleThatInstallsNoPicker(t *testing.T) {
|
||||||
|
verifier := newVerifier(t)
|
||||||
|
|
||||||
|
if err := verifier.Load(bundleWithRuntimeEntry(t, pickerFreeRuntimeEntry)); err != nil {
|
||||||
|
t.Fatalf("Load refused a bundle that generates no actions: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -594,6 +595,44 @@ func jsonToJSValue(runtime *goja.Runtime, raw json.RawMessage) (goja.Value, erro
|
|||||||
return runtime.ToValue(generic), nil
|
return runtime.ToValue(generic), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ActionWireContract names the encoding wireAction below reads, and must equal
|
||||||
|
// the ACTION_WIRE_CONTRACT the bundled runtime entry declares. It is versioned
|
||||||
|
// apart from @sanderling/spec because the encoding and the package move
|
||||||
|
// independently: what this binary needs to know is which reading of the fields
|
||||||
|
// it is being handed, not which release shipped it.
|
||||||
|
//
|
||||||
|
// The two halves can disagree without either failing. Revision 1
|
||||||
|
// (@sanderling/spec 0.0.3 and earlier, which declares nothing) sent an authored
|
||||||
|
// Scroll's container point as both endpoints; this binary reads pre-computed
|
||||||
|
// endpoints as authoritative, so every such scroll dispatched successfully as a
|
||||||
|
// 250ms press and hold and travelled zero distance, and no run said so.
|
||||||
|
const ActionWireContract = "action-wire/2"
|
||||||
|
|
||||||
|
// actionEncodingGlobal is where the bundled runtime entry declares its
|
||||||
|
// contract (defineLockedGlobal in pkg/spec/src/runtime-entry.ts).
|
||||||
|
const actionEncodingGlobal = "__sanderlingActionEncoding__"
|
||||||
|
|
||||||
|
// ActionEncodingError reports a bundle whose declared contract is not the one
|
||||||
|
// this binary decodes. declared is empty for a package published before the
|
||||||
|
// declaration existed, which is the pairing that has to fail loudest: it is the
|
||||||
|
// one that already produced a campaign of zero-distance scrolls.
|
||||||
|
func ActionEncodingError(declared string) error {
|
||||||
|
built := strconv.Quote(declared)
|
||||||
|
if declared == "" {
|
||||||
|
built = "an @sanderling/spec that declares none at all (every release before " +
|
||||||
|
strconv.Quote(ActionWireContract) + ")"
|
||||||
|
}
|
||||||
|
return fmt.Errorf(
|
||||||
|
"action encoding mismatch: this specification was bundled against %s and this "+
|
||||||
|
"binary implements %q. Every action would still dispatch successfully while "+
|
||||||
|
"executing a different gesture, so the run stops here instead of producing "+
|
||||||
|
"wrong data. The @sanderling/spec the spec is bundled against and this binary "+
|
||||||
|
"must come from the same commit or a compatible release: re-install "+
|
||||||
|
"@sanderling/spec, or point the spec at the pkg/spec checkout this binary was "+
|
||||||
|
"built from",
|
||||||
|
built, ActionWireContract)
|
||||||
|
}
|
||||||
|
|
||||||
// wireAction is the unified flat wire contract both runtime entries emit
|
// wireAction is the unified flat wire contract both runtime entries emit
|
||||||
// (runtime-entry.ts serializeAction). ONE decoder reads it on both the goja
|
// (runtime-entry.ts serializeAction). ONE decoder reads it on both the goja
|
||||||
// (native) and the runner (web) sides, so a field rename cannot silently turn
|
// (native) and the runner (web) sides, so a field rename cannot silently turn
|
||||||
|
|||||||
@@ -150,6 +150,10 @@ func (v *Verifier) Load(source string) error {
|
|||||||
return fmt.Errorf("run spec: %w", err)
|
return fmt.Errorf("run spec: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := v.checkActionEncoding(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
propertiesValue := v.runtime.GlobalObject().Get("properties")
|
propertiesValue := v.runtime.GlobalObject().Get("properties")
|
||||||
if propertiesValue != nil && !goja.IsUndefined(propertiesValue) && !goja.IsNull(propertiesValue) {
|
if propertiesValue != nil && !goja.IsUndefined(propertiesValue) && !goja.IsNull(propertiesValue) {
|
||||||
propertiesObject := propertiesValue.ToObject(v.runtime)
|
propertiesObject := propertiesValue.ToObject(v.runtime)
|
||||||
@@ -210,6 +214,30 @@ func (v *Verifier) Load(source string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkActionEncoding fails the load when the bundle's action encoding is not
|
||||||
|
// the one this binary decodes. A bundle that installs the picker and declares
|
||||||
|
// nothing is an @sanderling/spec older than the declaration, which is the
|
||||||
|
// pairing that has to be caught: an absent declaration is a mismatch, never a
|
||||||
|
// default. A bundle with no picker (a raw-JS fixture, the bundle-check tool)
|
||||||
|
// generates no actions at all, so it has no encoding to disagree about.
|
||||||
|
//
|
||||||
|
// chrome.Driver.InstallBundle applies this same rule to the web host.
|
||||||
|
func (v *Verifier) checkActionEncoding() error {
|
||||||
|
picker := v.runtime.GlobalObject().Get("__sanderlingNextAction__")
|
||||||
|
if picker == nil || goja.IsUndefined(picker) || goja.IsNull(picker) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
declared := ""
|
||||||
|
if value := v.runtime.GlobalObject().Get(actionEncodingGlobal); value != nil &&
|
||||||
|
!goja.IsUndefined(value) && !goja.IsNull(value) {
|
||||||
|
declared = value.String()
|
||||||
|
}
|
||||||
|
if declared == ActionWireContract {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ActionEncodingError(declared)
|
||||||
|
}
|
||||||
|
|
||||||
// buildFormula walks the formula-spec registry and produces a Go ltl.Formula
|
// buildFormula walks the formula-spec registry and produces a Go ltl.Formula
|
||||||
// tree rooted at the given spec index.
|
// tree rooted at the given spec index.
|
||||||
//
|
//
|
||||||
|
|||||||
Reference in new issue
Block a user