mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-04 20:17:09 +00:00
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).
This commit is contained in:
1 parent
053ba93bc0
commit
baac1faaa3
2 files changed
+125
-7
No files matched your search
@@ -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) {
|
func TestInputText_RoundTrip(t *testing.T) {
|
||||||
verifier := newVerifier(t)
|
verifier := newVerifier(t)
|
||||||
mustLoad(t, verifier, `
|
mustLoad(t, verifier, `
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type Verifier struct {
|
|||||||
|
|
||||||
properties map[string]int // property name -> formula-spec index
|
properties map[string]int // property name -> formula-spec index
|
||||||
actionGenerator goja.Value
|
actionGenerator goja.Value
|
||||||
|
setupGenerator goja.Value
|
||||||
|
|
||||||
evaluators map[string]*ltl.Evaluator
|
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
|
// Load executes the bundled spec source. The spec is expected to assign its
|
||||||
// property formulas to globalThis.properties and its root action generator
|
// property formulas to globalThis.properties, its root action generator to
|
||||||
// to globalThis.actions.
|
// globalThis.actions, and optionally a setup (precondition) action generator
|
||||||
|
// to globalThis.setup.
|
||||||
func (v *Verifier) Load(source string) error {
|
func (v *Verifier) Load(source string) error {
|
||||||
if _, err := v.runtime.RunString(source); err != nil {
|
if _, err := v.runtime.RunString(source); err != nil {
|
||||||
return fmt.Errorf("run spec: %w", err)
|
return fmt.Errorf("run spec: %w", err)
|
||||||
@@ -88,6 +90,10 @@ func (v *Verifier) Load(source string) error {
|
|||||||
v.actionGenerator = actionsValue
|
v.actionGenerator = actionsValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if setupValue := v.runtime.GlobalObject().Get("setup"); setupValue != nil && !goja.IsUndefined(setupValue) && !goja.IsNull(setupValue) {
|
||||||
|
v.setupGenerator = setupValue
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,12 +284,22 @@ func (v *Verifier) Residuals() map[string]ltl.Formula {
|
|||||||
return residuals
|
return residuals
|
||||||
}
|
}
|
||||||
|
|
||||||
// NextAction resolves the root action generator into a single Action.
|
// NextAction resolves an action for the current step. The setup generator,
|
||||||
// Returns ErrNoAction when no branch of the generator produces one after a
|
// when registered, runs first; if it yields an action, that wins. When setup
|
||||||
// small number of retries. Retrying avoids wedging when most branches of a
|
// returns ErrNoAction (all branches empty) the call falls through to the
|
||||||
// weighted generator produce no action on the current screen (e.g. a gated
|
// root action generator with the existing retry semantics. Setup is consulted
|
||||||
// login-phone generator when the app is already past login).
|
// every step, so state regression (e.g. a logout under fuzz) automatically
|
||||||
|
// re-engages the precondition.
|
||||||
func (v *Verifier) NextAction() (Action, error) {
|
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 {
|
if v.actionGenerator == nil {
|
||||||
return Action{}, ErrNoAction
|
return Action{}, ErrNoAction
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user