router_test.go

  1package engine
  2
  3import (
  4	"encoding/json"
  5	"strings"
  6	"testing"
  7
  8	"git.kilimanjaro.io/ci/internal/lex"
  9	"git.kilimanjaro.io/ci/internal/manifest"
 10)
 11
 12const routerWF = `
 13engine: devenv
 14steps: [test, plan, deploy, health-check, record]
 15actions:
 16  test:         { command: x }
 17  plan:         { command: x }
 18  deploy:       { command: x, gate: { prompt: "Deploy?" } }
 19  health-check: { command: x }
 20  record:       { command: x }
 21  artifacts:    { command: x }
 22  rollback:     { command: x, gate: { prompt: "Roll back?", timeout: 1h } }
 23  notify:       { command: x }
 24`
 25
 26func newRouter(t *testing.T) *Router {
 27	t.Helper()
 28	m, err := manifest.Parse("w", []byte(routerWF))
 29	if err != nil {
 30		t.Fatal(err)
 31	}
 32	return &Router{M: m}
 33}
 34
 35// ran simulates the engine executing p: it moves the cursor.
 36func ran(r *Router, st *State, p Pending) {
 37	if i, ok := r.M.IsStep(p.Action); ok {
 38		st.Cursor = i + 1
 39	}
 40}
 41
 42func res(status string, next string) lex.Result {
 43	r := lex.Result{Version: 1, Status: status}
 44	if next != "" {
 45		var n lex.Next
 46		if err := json.Unmarshal([]byte(next), &n); err != nil {
 47			panic(err)
 48		}
 49		r.Next = &n
 50	}
 51	return r
 52}
 53
 54func mustRun(t *testing.T, d Decision, action, then string) Pending {
 55	t.Helper()
 56	if d.Run == nil || d.Run.Action != action || d.Run.Then != then {
 57		t.Fatalf("want run %s (then %s), got %s", action, then, d)
 58	}
 59	return *d.Run
 60}
 61
 62func mustGate(t *testing.T, d Decision, actions ...string) *PendingGate {
 63	t.Helper()
 64	if d.Gate == nil {
 65		t.Fatalf("want gate %v, got %s", actions, d)
 66	}
 67	var got []string
 68	for _, o := range d.Gate.Options {
 69		got = append(got, o.Action)
 70	}
 71	if strings.Join(got, ",") != strings.Join(actions, ",") {
 72		t.Fatalf("gate options %v, want %v", got, actions)
 73	}
 74	return d.Gate
 75}
 76
 77func mustEnd(t *testing.T, d Decision, status string) *End {
 78	t.Helper()
 79	if d.End == nil || d.End.Status != status {
 80		t.Fatalf("want end %s, got %s", status, d)
 81	}
 82	return d.End
 83}
 84
 85func TestHappyPathWithDeclaredGate(t *testing.T) {
 86	r := newRouter(t)
 87	st := &State{}
 88	p := mustRun(t, r.Start(), "test", "continue")
 89	ran(r, st, p)
 90	p = mustRun(t, r.After(st, p, res("success", ""), "r1"), "plan", "continue")
 91	ran(r, st, p)
 92	g := mustGate(t, r.After(st, p, res("warning", ""), "r2"), "deploy")
 93	if g.From != "" || g.Prompt != "Deploy?" {
 94		t.Errorf("declared gate: %+v", g)
 95	}
 96	d, err := r.Choose(g, 0)
 97	if err != nil {
 98		t.Fatal(err)
 99	}
100	p = mustRun(t, d, "deploy", "continue")
101	if p.Reason != lex.ReasonChoice {
102		t.Errorf("reason %s", p.Reason)
103	}
104	ran(r, st, p)
105	p = mustRun(t, r.After(st, p, res("success", ""), "r3"), "health-check", "continue")
106	ran(r, st, p)
107	p = mustRun(t, r.After(st, p, res("success", ""), "r4"), "record", "continue")
108	ran(r, st, p)
109	e := mustEnd(t, r.After(st, p, res("success", ""), "r5"), StatusSuccess)
110	if e.Reason != lex.ReasonDefault {
111		t.Errorf("reason %s", e.Reason)
112	}
113}
114
115func TestFailureStops(t *testing.T) {
116	r := newRouter(t)
117	st := &State{}
118	p := mustRun(t, r.Start(), "test", "continue")
119	ran(r, st, p)
120	mustEnd(t, r.After(st, p, res("failure", ""), "r1"), StatusFailure)
121}
122
123func TestInsertedStepResumesHappyPath(t *testing.T) {
124	r := newRouter(t)
125	st := &State{}
126	p := mustRun(t, r.Start(), "test", "continue")
127	ran(r, st, p)
128	p = mustRun(t, r.After(st, p, res("success", `{"action":"artifacts","then":"continue","inputs":{"k":1}}`), "results/001-test.json"), "artifacts", "continue")
129	if p.TriggerResult != "results/001-test.json" || string(p.Inputs) != `{"k":1}` || p.From != "test" {
130		t.Errorf("pending %+v", p)
131	}
132	ran(r, st, p)
133	mustRun(t, r.After(st, p, res("success", ""), "r2"), "plan", "continue")
134}
135
136func TestOffPathDefaultsToStop(t *testing.T) {
137	r := newRouter(t)
138	st := &State{}
139	p := mustRun(t, r.Start(), "test", "continue")
140	ran(r, st, p)
141	p = mustRun(t, r.After(st, p, res("success", `"notify"`), "r1"), "notify", "stop")
142	ran(r, st, p)
143	e := mustEnd(t, r.After(st, p, res("success", ""), "r2"), StatusFailure)
144	if !strings.Contains(e.Detail, "before plan") {
145		t.Errorf("detail %q", e.Detail)
146	}
147}
148
149func TestJumpForwardSkipsAndBackRepeats(t *testing.T) {
150	r := newRouter(t)
151	st := &State{}
152	p := mustRun(t, r.Start(), "test", "continue")
153	ran(r, st, p)
154	// jump forward to record: a happy-path step, so then defaults to continue
155	p = mustRun(t, r.After(st, p, res("success", `"record"`), "r1"), "record", "continue")
156	ran(r, st, p)
157	mustEnd(t, r.After(st, p, res("success", ""), "r2"), StatusSuccess)
158
159	// jump back from health-check to plan repeats plan and what follows
160	st = &State{Cursor: 4}
161	p = Pending{Action: "health-check", Then: "continue"}
162	p = mustRun(t, r.After(st, p, res("success", `"plan"`), "r3"), "plan", "continue")
163	ran(r, st, p)
164	mustGate(t, r.After(st, p, res("success", ""), "r4"), "deploy")
165}
166
167func TestResultGates(t *testing.T) {
168	r := newRouter(t)
169	st := &State{Cursor: 4}
170	hc := Pending{Action: "health-check", Then: "continue"}
171
172	// a declared gate on the routed-to action gates even without gated:true
173	g := mustGate(t, r.After(st, hc, res("failure", `{"action":"rollback","then":"stop"}`), "r"), "rollback")
174	if g.From != "health-check" || g.FromStatus != "failure" || g.Prompt != "Roll back?" {
175		t.Errorf("gate %+v", g)
176	}
177
178	// gated:true on an ungated action
179	rr := res("warning", `"notify"`)
180	rr.Gated = true
181	mustGate(t, r.After(st, hc, rr, "r"), "notify")
182
183	// more than one option is always a choice
184	g = mustGate(t, r.After(st, hc, res("failure", `[{"action":"rollback"},{"action":"health-check","label":"Check again"}]`), "r"), "rollback", "health-check")
185	if g.Options[0].Then != "stop" || g.Options[1].Then != "continue" {
186		t.Errorf("default thens: %+v", g.Options)
187	}
188
189	// choosing runs the option without gating again
190	d, _ := r.Choose(g, 1)
191	mustRun(t, d, "health-check", "continue")
192	if _, err := r.Choose(g, 5); err == nil {
193		t.Error("choose out of range")
194	}
195}
196
197func TestGateTimeout(t *testing.T) {
198	r := newRouter(t)
199	g := mustGate(t, r.After(&State{}, Pending{Action: "health-check"}, res("failure", `"rollback"`), "r"), "rollback")
200	if g.ExpiresAt == "" || g.CreatedAt == "" || g.ExpiresAt <= g.CreatedAt {
201		t.Errorf("times %+v", g)
202	}
203}
204
205func TestDismiss(t *testing.T) {
206	r := newRouter(t)
207	declared := mustGate(t, r.reach(Pending{Action: "deploy", Then: "continue"}), "deploy")
208	mustEnd(t, r.Dismiss(declared), StatusCancelled)
209
210	raised := mustGate(t, r.After(&State{}, Pending{Action: "health-check"}, res("failure", `"rollback"`), "r"), "rollback")
211	mustEnd(t, r.Dismiss(raised), StatusFailure)
212
213	warn := mustGate(t, r.After(&State{}, Pending{Action: "health-check"}, res("warning", `[{"action":"rollback"},{"action":"notify"}]`), "r"), "rollback", "notify")
214	mustEnd(t, r.Dismiss(warn), StatusSuccess)
215
216	mustEnd(t, r.Expire(declared), StatusCancelled)
217}
218
219func TestRollbackAfterLastStepIsFailure(t *testing.T) {
220	// health-check is the last step here; its failure routes to rollback
221	m := newRouter(t).M
222	m.Steps = []string{"test", "health-check"}
223	r := &Router{M: m}
224	st := &State{Cursor: 2}
225	p := mustRun(t, r.After(st, Pending{Action: "health-check", Then: "continue"}, res("failure", `"notify"`), "r"), "notify", "stop")
226	e := mustEnd(t, r.After(st, p, res("success", ""), "r2"), StatusFailure)
227	if !strings.Contains(e.Detail, "diverted") {
228		t.Errorf("detail %q", e.Detail)
229	}
230	// a happy-path step that stops at the end is a success
231	mustEnd(t, r.After(st, Pending{Action: "health-check", Then: "stop"}, res("success", ""), "r3"), StatusSuccess)
232}
233
234func TestExplicitStop(t *testing.T) {
235	r := newRouter(t)
236	mustEnd(t, r.After(&State{}, Pending{Action: "test"}, res("success", `"stop"`), "r"), StatusSuccess)
237	mustEnd(t, r.After(&State{}, Pending{Action: "test"}, res("warning", `"stop"`), "r"), StatusSuccess)
238	mustEnd(t, r.After(&State{}, Pending{Action: "test"}, res("failure", `"stop"`), "r"), StatusFailure)
239}
240
241func TestFailureWithNextRoutes(t *testing.T) {
242	// a failing result that names a next action is routed, not stopped
243	r := newRouter(t)
244	mustRun(t, r.After(&State{}, Pending{Action: "test"}, res("failure", `"notify"`), "r"), "notify", "stop")
245}
246
247func TestBeginAppliesDeclaredGate(t *testing.T) {
248	r := newRouter(t)
249	mustGate(t, r.Begin("rollback"), "rollback")
250	mustRun(t, r.Begin("notify"), "notify", "stop")
251	mustRun(t, r.Begin("plan"), "plan", "continue")
252}