From 0d6f32f8a4feb21451c580e7a951ab8c164c451a Mon Sep 17 00:00:00 2001 From: PJ Date: Mon, 27 Apr 2026 18:42:14 +0700 Subject: [PATCH] feat(verifier): OverrideExtractorValues for V8-driven extractors Web tick path runs extractor bodies in V8 against the real DOM, then overrides goja-side .current slots so LTL predicates evaluate against those values. Mobile callers can pass nil for a no-op. --- internal/verifier/verifier_test.go | 55 ++++++++++++++++++++++++++++++ internal/verifier/worker.go | 25 ++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/internal/verifier/verifier_test.go b/internal/verifier/verifier_test.go index 47dbb68..e4a1c4a 100644 --- a/internal/verifier/verifier_test.go +++ b/internal/verifier/verifier_test.go @@ -532,3 +532,58 @@ globalThis.properties = { } } } + +func TestOverrideExtractorValues_PreservesPrevious(t *testing.T) { + verifier := newVerifier(t) + mustLoad(t, verifier, helloSpec) + + if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"ledger.balance": json.RawMessage(`100`)}}); err != nil { + t.Fatal(err) + } + if err := verifier.OverrideExtractorValues(map[int]json.RawMessage{1: json.RawMessage(`777`)}); err != nil { + t.Fatal(err) + } + balance := verifier.runtime.GlobalObject().Get("balance").ToObject(verifier.runtime) + if balance.Get("current").ToInteger() != 777 { + t.Errorf("override didn't take: current=%v", balance.Get("current")) + } + + // Next push: previous mirrors the *override*, not the snapshot value. + if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"ledger.balance": json.RawMessage(`200`)}}); err != nil { + t.Fatal(err) + } + balance = verifier.runtime.GlobalObject().Get("balance").ToObject(verifier.runtime) + if balance.Get("previous").ToInteger() != 777 { + t.Errorf("previous should reflect override, got %v", balance.Get("previous")) + } +} + +func TestOverrideExtractorValues_NilIsNoop(t *testing.T) { + verifier := newVerifier(t) + mustLoad(t, verifier, helloSpec) + if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"ledger.balance": json.RawMessage(`42`)}}); err != nil { + t.Fatal(err) + } + if err := verifier.OverrideExtractorValues(nil); err != nil { + t.Fatal(err) + } + if err := verifier.OverrideExtractorValues(map[int]json.RawMessage{}); err != nil { + t.Fatal(err) + } + balance := verifier.runtime.GlobalObject().Get("balance").ToObject(verifier.runtime) + if balance.Get("current").ToInteger() != 42 { + t.Errorf("expected snapshot-driven current to remain 42, got %v", balance.Get("current")) + } +} + +func TestOverrideExtractorValues_UnknownIndexErrors(t *testing.T) { + verifier := newVerifier(t) + mustLoad(t, verifier, helloSpec) + if err := verifier.PushSnapshot(SnapshotInput{}); err != nil { + t.Fatal(err) + } + err := verifier.OverrideExtractorValues(map[int]json.RawMessage{99: json.RawMessage(`1`)}) + if err == nil || !strings.Contains(err.Error(), "out of range") { + t.Errorf("expected out-of-range error, got %v", err) + } +} diff --git a/internal/verifier/worker.go b/internal/verifier/worker.go index 0ac7729..3c29291 100644 --- a/internal/verifier/worker.go +++ b/internal/verifier/worker.go @@ -1,6 +1,7 @@ package verifier import ( + "encoding/json" "errors" "fmt" "math/rand/v2" @@ -238,6 +239,30 @@ func (v *Verifier) PushSnapshot(input SnapshotInput) error { return nil } +// OverrideExtractorValues replaces each extractor's `current` slot with a +// caller-supplied value, keyed by registration index. Used by the web tick +// path so extractor bodies that ran in V8 (against the real DOM) drive the +// goja-side LTL predicates without re-running the getter against an empty +// state.ax shim. Passing a nil/empty map is a no-op so the mobile path can +// call this unconditionally. The override must run *after* PushSnapshot +// (which advanced `previous`) and *before* EvaluateProperties. +func (v *Verifier) OverrideExtractorValues(overrides map[int]json.RawMessage) error { + if len(overrides) == 0 { + return nil + } + for index, raw := range overrides { + if index < 0 || index >= len(v.extractors) { + return fmt.Errorf("extractor override index %d out of range (have %d)", index, len(v.extractors)) + } + value, err := jsonToJSValue(v.runtime, raw) + if err != nil { + return fmt.Errorf("extractor override %d: %w", index, err) + } + _ = v.extractors[index].handle.Set("current", value) + } + return nil +} + // SnapshotInput bundles everything a step feeds into the verifier. Fields // other than Snapshots are optional; callers that only have snapshots can // populate Snapshots alone and leave the rest zero.