From baac1faaa3e85a669c84aca5584295445bce1849 Mon Sep 17 00:00:00 2001 From: PJ Date: Mon, 27 Apr 2026 00:23:35 +0700 Subject: [PATCH] feat(verifier): dispatch setup generator before actions root Setup is consulted every step; when it returns ErrNoAction the call falls through to the existing actionGenerator retry loop. This lets specs split deterministic preconditions (login, onboarding) out of the weighted action pool while auto-reengaging if state regresses (e.g. logout under fuzz). --- internal/verifier/verifier_test.go | 102 +++++++++++++++++++++++++++++ internal/verifier/worker.go | 30 +++++++-- 2 files changed, 125 insertions(+), 7 deletions(-) diff --git a/internal/verifier/verifier_test.go b/internal/verifier/verifier_test.go index 4f5161a..523349a 100644 --- a/internal/verifier/verifier_test.go +++ b/internal/verifier/verifier_test.go @@ -179,6 +179,108 @@ func TestNextAction_EmptyGeneratorReturnsErrNoAction(t *testing.T) { } } +func TestNextAction_SetupTakesPrecedenceWhenYielding(t *testing.T) { + verifier := newVerifier(t) + mustLoad(t, verifier, ` + globalThis.setup = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:setup" })]); + globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:main" })]); + `) + _ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}}) + + action, err := verifier.NextAction() + if err != nil { + t.Fatal(err) + } + if action.On != "id:setup" { + t.Errorf("setup precedence: got %q, want id:setup", action.On) + } +} + +func TestNextAction_FallsThroughToActionsWhenSetupEmpty(t *testing.T) { + verifier := newVerifier(t) + mustLoad(t, verifier, ` + globalThis.setup = __sanderling__.actions(() => []); + globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:main" })]); + `) + _ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}}) + + action, err := verifier.NextAction() + if err != nil { + t.Fatal(err) + } + if action.On != "id:main" { + t.Errorf("fallthrough: got %q, want id:main", action.On) + } +} + +func TestNextAction_SetupReengagesAfterRegression(t *testing.T) { + verifier := newVerifier(t) + mustLoad(t, verifier, ` + globalThis.loggedIn = __sanderling__.extract(state => state.snapshots["loggedIn"] === true); + globalThis.setup = __sanderling__.actions(() => { + if (loggedIn.current) return []; + return [__sanderling__.tap({ on: "id:login" })]; + }); + globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:main" })]); + `) + + push := func(loggedIn bool) { + raw := json.RawMessage(`false`) + if loggedIn { + raw = json.RawMessage(`true`) + } + if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"loggedIn": raw}}); err != nil { + t.Fatal(err) + } + } + + push(false) + action, err := verifier.NextAction() + if err != nil { + t.Fatal(err) + } + if action.On != "id:login" { + t.Fatalf("step 1 (logged out): got %q, want id:login", action.On) + } + + push(true) + action, err = verifier.NextAction() + if err != nil { + t.Fatal(err) + } + if action.On != "id:main" { + t.Fatalf("step 2 (logged in): got %q, want id:main", action.On) + } + + push(false) + action, err = verifier.NextAction() + if err != nil { + t.Fatal(err) + } + if action.On != "id:login" { + t.Fatalf("step 3 (regressed): got %q, want id:login", action.On) + } +} + +func TestNextAction_NoSetupRegistered(t *testing.T) { + verifier := newVerifier(t) + mustLoad(t, verifier, ` + globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:main" })]); + `) + _ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}}) + + if verifier.setupGenerator != nil { + t.Errorf("setupGenerator should be nil when spec does not export setup") + } + action, err := verifier.NextAction() + if err != nil { + t.Fatal(err) + } + if action.On != "id:main" { + t.Errorf("got %q, want id:main", action.On) + } +} + func TestInputText_RoundTrip(t *testing.T) { verifier := newVerifier(t) mustLoad(t, verifier, ` diff --git a/internal/verifier/worker.go b/internal/verifier/worker.go index 3e2c319..0ac7729 100644 --- a/internal/verifier/worker.go +++ b/internal/verifier/worker.go @@ -20,6 +20,7 @@ type Verifier struct { properties map[string]int // property name -> formula-spec index actionGenerator goja.Value + setupGenerator goja.Value evaluators map[string]*ltl.Evaluator @@ -56,8 +57,9 @@ func New(options ...Option) (*Verifier, error) { } // Load executes the bundled spec source. The spec is expected to assign its -// property formulas to globalThis.properties and its root action generator -// to globalThis.actions. +// property formulas to globalThis.properties, its root action generator to +// globalThis.actions, and optionally a setup (precondition) action generator +// to globalThis.setup. func (v *Verifier) Load(source string) error { if _, err := v.runtime.RunString(source); err != nil { return fmt.Errorf("run spec: %w", err) @@ -88,6 +90,10 @@ func (v *Verifier) Load(source string) error { v.actionGenerator = actionsValue } + if setupValue := v.runtime.GlobalObject().Get("setup"); setupValue != nil && !goja.IsUndefined(setupValue) && !goja.IsNull(setupValue) { + v.setupGenerator = setupValue + } + return nil } @@ -278,12 +284,22 @@ func (v *Verifier) Residuals() map[string]ltl.Formula { return residuals } -// NextAction resolves the root action generator into a single Action. -// Returns ErrNoAction when no branch of the generator produces one after a -// small number of retries. Retrying avoids wedging when most branches of a -// weighted generator produce no action on the current screen (e.g. a gated -// login-phone generator when the app is already past login). +// NextAction resolves an action for the current step. The setup generator, +// when registered, runs first; if it yields an action, that wins. When setup +// returns ErrNoAction (all branches empty) the call falls through to the +// root action generator with the existing retry semantics. Setup is consulted +// every step, so state regression (e.g. a logout under fuzz) automatically +// re-engages the precondition. func (v *Verifier) NextAction() (Action, error) { + if v.setupGenerator != nil { + action, err := v.resolveGenerator(v.setupGenerator) + if err == nil { + return action, nil + } + if !errors.Is(err, ErrNoAction) { + return Action{}, err + } + } if v.actionGenerator == nil { return Action{}, ErrNoAction }