package ci
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"slices"
"sync"
"testing"
)
// startedEngine accepts every registration and start, and records which
// pipeline each started run was for, at which check-in.
func startedEngine(t *testing.T) (*httptest.Server, func() []string) {
t.Helper()
var mu sync.Mutex
var started []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/workflows":
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"status":"registered"}`))
case r.URL.Path == "/runs" && r.Method == http.MethodGet:
fmt.Fprint(w, `[]`)
case r.URL.Path == "/runs":
var body struct {
Input map[string]any `json:"input"`
}
json.NewDecoder(r.Body).Decode(&body)
mu.Lock()
started = append(started, fmt.Sprint(body.Input["checkin"], " ", body.Input["pipeline"]))
n := len(started)
mu.Unlock()
w.WriteHeader(http.StatusCreated)
fmt.Fprintf(w, `{"run_id":"r-%d"}`, n)
}
}))
t.Cleanup(srv.Close)
return srv, func() []string {
mu.Lock()
defer mu.Unlock()
out := append([]string(nil), started...)
started = started[:0]
slices.Sort(out)
return out
}
}
// A PIPELINE MARKED "when": "landing" RUNS ON TRUNK AND ON LANDING TIPS,
// NEVER ON A BARE BRANCH PUSH.
//
// The race detector is the slow step, and a branch nobody has proposed
// does not need it; trunk and every proposed tip do. A landing opened
// after its tip was pushed gets the pipeline then, once, without
// re-running the rest; a re-run somebody asks for runs everything.
func TestALandingPipelineRunsOnTrunkAndLandingTipsOnly(t *testing.T) {
srv, started := startedEngine(t)
e := New(srv.URL)
st := &fakeStore{mark: map[string]string{"alpha": "old"}}
repo := &fakeRepo{
files: []string{"ci/quarry.json", "ci/race.json"},
blobs: map[string][]byte{
"ci/quarry.json": []byte(`{"name":"quarry","version":"1.0","steps":[]}`),
"ci/race.json": []byte(`{"name":"race","version":"1.0","when":"landing","steps":[]}`),
},
landings: map[string]bool{},
}
round := func(news ...Checkin) []string {
repo.news = news
if err := Trigger(e, st, "alpha", repo, "http://x/r/alpha"); err != nil {
t.Fatal(err)
}
return started()
}
if got := round(Checkin{Hash: "b1", Branch: "feat"}); !slices.Equal(got, []string{"b1 ci/quarry.json"}) {
t.Errorf("FAIL: a bare branch push started %v; want only ci/quarry.json", got)
}
repo.landings["feat"] = true
repo.tips = []Checkin{{Hash: "b1", Branch: "feat"}}
if got := round(); !slices.Equal(got, []string{"b1 ci/race.json"}) {
t.Errorf("FAIL: opening a landing on b1 started %v; want only ci/race.json at b1", got)
}
if got := round(); len(got) != 0 {
t.Errorf("FAIL: a later round started %v again", got)
}
if got := round(Checkin{Hash: "b2", Branch: "feat"}); !slices.Equal(got, []string{"b2 ci/quarry.json", "b2 ci/race.json"}) {
t.Errorf("FAIL: a push to a proposed branch started %v; want both pipelines", got)
}
repo.tips = []Checkin{{Hash: "b2", Branch: "feat"}}
if got := round(); len(got) != 0 {
t.Errorf("FAIL: the landing pass re-ran %v at a tip that already had them", got)
}
repo.tips = nil
if got := round(Checkin{Hash: "t1", Branch: "trunk"}); !slices.Equal(got, []string{"t1 ci/quarry.json", "t1 ci/race.json"}) {
t.Errorf("FAIL: a trunk check-in started %v; want both pipelines", got)
}
repo.news = nil
if err := RunAt(e, st, "alpha", repo, "http://x/r/alpha", "b9"); err != nil {
t.Fatal(err)
}
if got := started(); !slices.Equal(got, []string{"b9 ci/quarry.json", "b9 ci/race.json"}) {
t.Errorf("FAIL: an asked-for re-run started %v; want every pipeline", got)
}
t.Log("ok: the landing pipeline runs on trunk, on proposed tips once, and on asked-for re-runs")
}