package ci
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"testing"
)
// ACTIONS' BRANCH PATTERNS, AND ON:'S RULES, AS RFC 0019 STATES THEM.
func TestOnFiltersReadAsActionsReadsThem(t *testing.T) {
for _, c := range []struct {
pat, name string
want bool
}{
{"trunk", "trunk", true}, {"trunk", "trunk2", false},
{"release/*", "release/1.2", true}, {"release/*", "release/1/2", false},
{"release/**", "release/1/2", true}, {"feature-?", "feature-a", true},
{"v[12].*", "v2.0", true}, {"v[12].*", "v3.0", false}, {"a+b", "aaab", true},
{"*", "a/b", false}, {"**", "a/b", true}, {"a.b", "axb", false},
} {
got, err := globMatch(c.pat, c.name)
if err != nil || got != c.want {
t.Errorf("FAIL: %q against %q = %v, %v; want %v", c.pat, c.name, got, err, c.want)
}
}
on := func(s string) json.RawMessage { return json.RawMessage(s) }
for _, c := range []struct {
on string
ev event
want bool
}{
{``, event{kind: "push", branch: "feat"}, true},
{`{"push":{}}`, event{kind: "landing", branch: "trunk"}, false},
{`{"push":{"branches":["trunk"]}}`, event{kind: "push", branch: "trunk"}, true},
{`{"push":{"branches":["trunk"]}}`, event{kind: "push", branch: "feat"}, false},
{`{"push":{"branches":["release/**","!release/old/**"]}}`, event{kind: "push", branch: "release/old/1"}, false},
{`{"push":{"branches":["release/**","!release/old/**"]}}`, event{kind: "push", branch: "release/new/1"}, true},
{`{"push":{"branches-ignore":["wip/*"]}}`, event{kind: "push", branch: "wip/x"}, false},
{`{"landing":{"branches":["trunk"]}}`, event{kind: "landing", branch: "trunk"}, true},
{`{"push":{"branches":["trunk"]}}`, event{kind: "workflow_dispatch", pushed: "trunk"}, true},
{`{"push":{"branches":["trunk"]}}`, event{kind: "workflow_dispatch", pushed: "feat"}, false},
{`{"workflow_dispatch":{}}`, event{kind: "workflow_dispatch", pushed: "feat"}, true},
{`{"schedule":{}}`, event{kind: "push", branch: "trunk"}, false},
} {
got, err := runsOn(on(c.on), c.ev)
if err != nil || got != c.want {
t.Errorf("FAIL: on %s for %+v = %v, %v; want %v", c.on, c.ev, got, err, c.want)
}
}
for _, bad := range []string{
`{"pull_request":{}}`,
`{"push":{"paths":["src/**"]}}`,
`{"push":{"tags":["v*"]}}`,
`{"push":{"branches":["a"],"branches-ignore":["b"]}}`,
`{"push":{"branches":["[a"]}}`,
} {
if _, err := runsOn(on(bad), event{kind: "push", branch: "a"}); err == nil {
t.Errorf("FAIL: on %s was accepted; want a refusal that names it", bad)
}
}
t.Log("ok: patterns, negation, ignore lists, re-runs and refusals")
}
// A JOBS: WORKFLOW RUNS WHEN ITS ON: SAYS, BY EVERY DOOR.
//
// The engine compiles the ci.yml with the repository's group as the
// task namespace and hands on: back; the trigger judges it for a push,
// a landing's merge (by its target branch) and a re-run.
func TestAWorkflowRunsWhenItsOnSays(t *testing.T) {
var mu sync.Mutex
var started, namespaces []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/v1/ci/compile":
var body struct {
Name, Spec string
TaskNamespace string `json:"task_namespace"`
}
json.NewDecoder(r.Body).Decode(&body)
mu.Lock()
namespaces = append(namespaces, body.TaskNamespace)
mu.Unlock()
on, _, _ := strings.Cut(body.Spec, "\n") // the fixture's first line is its normalised on:
fmt.Fprintf(w, `{"workflow":{"name":%q},"registered":true,"on":%s}`, body.Name, on)
case r.URL.Path == "/runs" && r.Method == http.MethodPost:
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"]))
mu.Unlock()
w.WriteHeader(http.StatusCreated)
fmt.Fprint(w, `{"run_id":"r"}`)
case r.URL.Path == "/runs":
fmt.Fprint(w, `[]`)
}
}))
defer srv.Close()
take := func() []string {
mu.Lock()
defer mu.Unlock()
out := started
started = nil
slices.Sort(out)
return out
}
e := New(srv.URL)
st := &fakeStore{mark: map[string]string{"alpha": "old"}}
repo := &fakeRepo{
files: []string{"ci/deploy.yml", "ci/test.yml"},
blobs: map[string][]byte{
"ci/test.yml": []byte(`{"push":{},"landing":{}}` + "\njobs: {}"),
"ci/deploy.yml": []byte(`{"push":{"branches":["trunk","release/*"]}}` + "\njobs: {}"),
},
landings: map[string]bool{},
branches: map[string]string{"t1": "trunk", "b1": "feat", "r1": "release/2"},
}
push := func(c Checkin) []string {
repo.news = []Checkin{c}
if err := Trigger(e, st, "alpha", repo, "http://x/r/alpha"); err != nil {
t.Fatal(err)
}
return take()
}
if got := push(Checkin{Hash: "b1", Branch: "feat"}); !slices.Equal(got, []string{"b1 ci/test.yml"}) {
t.Errorf("FAIL: a push to feat started %v; want the tests only", got)
}
if got := push(Checkin{Hash: "t1", Branch: "trunk"}); !slices.Equal(got, []string{"t1 ci/deploy.yml", "t1 ci/test.yml"}) {
t.Errorf("FAIL: a push to trunk started %v; want the deploy too", got)
}
if got := push(Checkin{Hash: "r1", Branch: "release/2"}); !slices.Equal(got, []string{"r1 ci/deploy.yml", "r1 ci/test.yml"}) {
t.Errorf("FAIL: a push to release/2 started %v; want the deploy too (release/*)", got)
}
if _, err := RunPairAt(e, st, "alpha", repo, "http://x/r/alpha", "feat", "b1", "t1"); err != nil {
t.Fatal(err)
}
if got := take(); !slices.Equal(got, []string{Pair("b1", "t1") + " ci/test.yml"}) {
t.Errorf("FAIL: a landing into trunk started %v; want the tests only", got)
}
repo.news = nil
if err := RunAt(e, st, "alpha", repo, "http://x/r/alpha", "t1"); err != nil {
t.Fatal(err)
}
if got := take(); !slices.Equal(got, []string{"t1 ci/deploy.yml", "t1 ci/test.yml"}) {
t.Errorf("FAIL: a re-run of a trunk check-in started %v; want the deploy too (the rollback)", got)
}
if err := RunAt(e, st, "alpha", repo, "http://x/r/alpha", "b1"); err != nil {
t.Fatal(err)
}
if got := take(); !slices.Equal(got, []string{"b1 ci/test.yml"}) {
t.Errorf("FAIL: a re-run of a feat check-in started %v; want no deploy", got)
}
repo.blobs["ci/deploy.yml"] = []byte(`{"pull_request":{}}` + "\njobs: {}")
err := RunAt(e, st, "alpha", repo, "http://x/r/alpha", "t1")
if err == nil || !strings.Contains(err.Error(), "pull_request") {
t.Errorf("FAIL: an on: quarry does not emit answered %v; want a refusal naming it", err)
}
if got := take(); !slices.Equal(got, []string{"t1 ci/test.yml"}) {
t.Errorf("FAIL: beside the refused workflow, started %v; want the tests", got)
}
mu.Lock()
defer mu.Unlock()
for _, ns := range namespaces {
if ns != GroupFor("alpha") {
t.Errorf("FAIL: compiled with task namespace %q; want the repository's group %q", ns, GroupFor("alpha"))
break
}
}
t.Log("ok: on: decides for pushes, landings and re-runs; the namespace is the repository's group")
}