Skip to content
quarry Artifact [e8a42efb9f]
Login

Artifact [e8a42efb9f]

Artifact e8a42efb9f1d0d19d42f795bef9e39c67743a5b823c51d35e7517770d0964cfa:


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)
	}
	// The refusal is on the record where checks are read, not only in
	// a log: a workflow quarry will not run must not read as passing.
	if len(st.refusals) != 1 || !strings.HasPrefix(st.refusals[0], "t1 ci/deploy.yml: ") || !strings.Contains(st.refusals[0], "pull_request") {
		t.Errorf("FAIL: recorded refusals %v; want one on t1 for ci/deploy.yml naming pull_request", st.refusals)
	}
	st.refusals = nil
	repo.news = []Checkin{{Hash: "b9", Branch: "feat"}}
	if err := Trigger(e, st, "alpha", repo, "http://x/r/alpha"); err != nil {
		t.Fatal(err)
	}
	take()
	if len(st.refusals) != 1 || !strings.HasPrefix(st.refusals[0], "b9 ci/deploy.yml: ") {
		t.Errorf("FAIL: a PUSH with an unrunnable workflow recorded %v; want its refusal on b9", st.refusals)
	}
	st.refusals = nil
	if _, err := RunPairAt(e, st, "alpha", repo, "http://x/r/alpha", "feat", "b1", "t1"); err == nil || !strings.Contains(err.Error(), "pull_request") {
		t.Errorf("FAIL: a landing's merge with an unrunnable workflow answered %v; want its refusal", err)
	}
	take()
	if len(st.refusals) != 1 || !strings.HasPrefix(st.refusals[0], "b1 ci/deploy.yml: ") {
		t.Errorf("FAIL: a landing's merge recorded %v; want the refusal on the branch tip b1, where the landing reads checks", st.refusals)
	}
	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")
}

// Validate refuses what the trigger would: CheckOn is that check, for
// the pipeline editor.
func TestCheckOnRefusesWhatTheTriggerWould(t *testing.T) {
	for _, bad := range []string{`{"pull_request":{}}`, `{"bogus_event":{}}`, `{"push":{"paths":["src/**"]}}`} {
		if err := CheckOn(json.RawMessage(bad)); err == nil {
			t.Errorf("FAIL: CheckOn passed %s", bad)
		}
	}
	for _, ok := range []string{``, `null`, `{"push":{"branches":["trunk"]},"landing":{}}`, `{"workflow_dispatch":{}}`} {
		if err := CheckOn(json.RawMessage(ok)); err != nil {
			t.Errorf("FAIL: CheckOn refused %s: %v", ok, err)
		}
	}
}

// THE TAG EVENT READS TAG GLOBS (RFC 0020 §releases).
//
// on.tag.tags is Actions' pattern list against the tag's name, version-*
// when absent; tags-ignore excludes from that. A workflow that does not
// name tag never runs on one, the default on: included, since that is
// push and landing.
func TestTheTagEventReadsTagGlobs(t *testing.T) {
	on := func(s string) json.RawMessage { return json.RawMessage(s) }
	tag := func(name string) event { return event{kind: "tag", tag: name, branch: "trunk"} }
	for _, c := range []struct {
		on   string
		ev   event
		want bool
	}{
		{`{"tag":{}}`, tag("version-0.2.0"), true},
		{`{"tag":{}}`, tag("release"), false},
		{`{"tag":{}}`, tag("v0.83"), false},
		{`{"tag":{"tags":["v*"]}}`, tag("v0.83"), true},
		{`{"tag":{"tags":["v*"]}}`, tag("version-0.2.0"), true},
		{`{"tag":{"tags":"core-*"}}`, tag("core-9-1-0"), true},
		{`{"tag":{"tags":["core-*"]}}`, tag("version-0.2.0"), false},
		{`{"tag":{"tags":["version-*","!version-*-rc*"]}}`, tag("version-0.2.0-rc1"), false},
		{`{"tag":{"tags":["version-*","!version-*-rc*"]}}`, tag("version-0.2.0"), true},
		{`{"tag":{"tags-ignore":["*-rc*"]}}`, tag("version-0.2.0-rc1"), false},
		{`{"tag":{"tags-ignore":["*-rc*"]}}`, tag("version-0.2.0"), true},
		{`{"tag":{"tags-ignore":["*-rc*"]}}`, tag("release"), false},
		{``, tag("version-0.2.0"), false},
		{`{"push":{},"landing":{}}`, tag("version-0.2.0"), false},
		{`{"tag":{}}`, event{kind: "push", branch: "trunk"}, false},
		{`{"tag":{}}`, event{kind: "workflow_dispatch", pushed: "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{
		`{"tag":{"branches":["trunk"]}}`,
		`{"tag":{"tags":["a"],"tags-ignore":["b"]}}`,
		`{"tag":{"tags":["[a"]}}`,
		`{"tag":{"paths":["src/**"]}}`,
		`{"tags":{}}`,
	} {
		if _, err := runsOn(on(bad), tag("a")); err == nil {
			t.Errorf("FAIL: on %s was accepted; want a refusal that names it", bad)
		}
		if err := CheckOn(on(bad)); err == nil && bad != `{"tag":{"tags":["[a"]}}` {
			t.Errorf("FAIL: CheckOn passed %s", bad)
		}
	}
	if err := CheckOn(on(`{"tag":{"tags":["version-*"]},"push":{}}`)); err != nil {
		t.Errorf("FAIL: CheckOn refused a tag workflow: %v", err)
	}
	t.Log("ok: tag globs, the version-* default, ignore lists, and no tag without on.tag")
}