From 4b7a9e2fe255f38b4c848cafc97c16c683c73747 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 15 Aug 2026 12:45:31 +0530 Subject: [PATCH] feat(verifier): report the registered extractor count the web path needs it to check the page sent one reading per extractor. --- internal/verifier/verifier_test.go | 51 ++++++++++++++++++++++++++++++ internal/verifier/worker.go | 9 ++++++ 2 files changed, 60 insertions(+) diff --git a/internal/verifier/verifier_test.go b/internal/verifier/verifier_test.go index 4adce87..5b7a6f9 100644 --- a/internal/verifier/verifier_test.go +++ b/internal/verifier/verifier_test.go @@ -1382,3 +1382,54 @@ func TestWithPlatform_IOSReachesPicker(t *testing.T) { t.Errorf("key = %q, want back (native press-key pool)", action.Key) } } + +// TestOverrideExtractorValues_EmptyPayloadIsUndefined pins the cross-host +// meaning of a page reading with no value. JSON has no undefined, so the web +// runtime wraps every reading in a {value} envelope and the chrome driver hands +// an absent value through as an empty payload. It has to land here as undefined, +// because that is what PushSnapshot records for a getter that returned undefined +// on native: decoding it as null instead would make `x.current === undefined` +// answer one thing on the native host and another on web, for one spec. +func TestOverrideExtractorValues_EmptyPayloadIsUndefined(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(map[int]json.RawMessage{ + 0: nil, + 1: json.RawMessage(`null`), + }); err != nil { + t.Fatal(err) + } + + for _, want := range []struct { + expression string + result bool + }{ + {"screen.current === undefined", true}, + {"screen.current === null", false}, + {"balance.current === null", true}, + {"balance.current === undefined", false}, + } { + value, err := verifier.runtime.RunString(want.expression) + if err != nil { + t.Fatalf("evaluate %s: %v", want.expression, err) + } + if value.ToBoolean() != want.result { + t.Errorf("a spec reading %s gets %v, want %v", want.expression, value, want.result) + } + } +} + +// TestExtractorCount_ReportsEveryRegisteredExtractor keeps the web path's +// completeness check honest: it compares the page's reading count against this +// number, so a count that ignored an extractor would let a partial table +// through. +func TestExtractorCount_ReportsEveryRegisteredExtractor(t *testing.T) { + verifier := newVerifier(t) + mustLoad(t, verifier, helloSpec) + if got := verifier.ExtractorCount(); got != 2 { + t.Errorf("ExtractorCount() = %d, want 2 (helloSpec registers screen and balance)", got) + } +} diff --git a/internal/verifier/worker.go b/internal/verifier/worker.go index 82ac57b..e891969 100644 --- a/internal/verifier/worker.go +++ b/internal/verifier/worker.go @@ -398,6 +398,15 @@ func (v *Verifier) ChangedExtractors() map[string]ExtractorChange { return changes } +// ExtractorCount reports how many extractors the spec registered. The web path +// compares it against the number of readings the page sent: a page reporting +// fewer leaves the rest holding goja's dump-derived value while the others hold +// the page's, and a property comparing previous to current across that split +// fires on a healthy app. +func (v *Verifier) ExtractorCount() int { + return len(v.extractors) +} + // 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