test(coverage): add unit tests across all packages; remove dead code
ci / vet, staticcheck, test, build (push) Successful in 2m25s
ci / vet, staticcheck, test, build (push) Successful in 2m25s
- Added comprehensive table-driven test suites for all packages: cmd/glint, cicontext, fetcher, graph, linter, model, resolver. Coverage reaches 98%+ statement coverage across the codebase. - Replaced os.Exit calls in cmd/glint with an `exit` variable so tests can capture exit codes without terminating the test process. - Removed unreachable code found during coverage analysis: dead guard in cicontext.parseRegexLiteral; dead len(jobs)==0 branch in graph.Pipeline; skipWin struct field and dead continue in graph.convertToPNG; pipelineSVG return type simplified to string. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
package cicontext
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── New / IsEmpty / Get / Inject ──────────────────────────────────────────────
|
||||
|
||||
func TestNew_Empty(t *testing.T) {
|
||||
ctx := New("", "", "", nil)
|
||||
if !ctx.IsEmpty() {
|
||||
t.Error("expected empty context")
|
||||
}
|
||||
if ctx.Get("CI_COMMIT_BRANCH") != "" {
|
||||
t.Error("expected empty Get on empty context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_Branch(t *testing.T) {
|
||||
ctx := New("feature/abc", "", "", nil)
|
||||
if ctx.Get("CI_COMMIT_BRANCH") != "feature/abc" {
|
||||
t.Errorf("unexpected branch: %q", ctx.Get("CI_COMMIT_BRANCH"))
|
||||
}
|
||||
if ctx.Get("CI_COMMIT_REF_SLUG") != "feature-abc" {
|
||||
t.Errorf("unexpected slug: %q", ctx.Get("CI_COMMIT_REF_SLUG"))
|
||||
}
|
||||
if ctx.Get("CI_PIPELINE_SOURCE") != "push" {
|
||||
t.Error("expected default source=push for branch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_Tag(t *testing.T) {
|
||||
ctx := New("", "v1.0.0", "", nil)
|
||||
if ctx.Get("CI_COMMIT_TAG") != "v1.0.0" {
|
||||
t.Errorf("unexpected tag: %q", ctx.Get("CI_COMMIT_TAG"))
|
||||
}
|
||||
// tag should clear CI_COMMIT_BRANCH
|
||||
if ctx.Get("CI_COMMIT_BRANCH") != "" {
|
||||
t.Error("CI_COMMIT_BRANCH should be cleared when tag is set")
|
||||
}
|
||||
if ctx.Get("CI_PIPELINE_SOURCE") != "push" {
|
||||
t.Error("expected default source=push for tag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_BranchAndTagTogether(t *testing.T) {
|
||||
// branch set first, then tag overwrites REF_NAME and clears BRANCH
|
||||
ctx := New("main", "v2.0", "", nil)
|
||||
if ctx.Get("CI_COMMIT_BRANCH") != "" {
|
||||
t.Error("BRANCH should be cleared by tag")
|
||||
}
|
||||
if ctx.Get("CI_COMMIT_TAG") != "v2.0" {
|
||||
t.Errorf("unexpected tag %q", ctx.Get("CI_COMMIT_TAG"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_ExtraVars(t *testing.T) {
|
||||
ctx := New("main", "", "", []string{"DEPLOY_ENV=prod", "BAD_NO_EQUALS"})
|
||||
if ctx.Get("DEPLOY_ENV") != "prod" {
|
||||
t.Errorf("extra var not set: %q", ctx.Get("DEPLOY_ENV"))
|
||||
}
|
||||
if ctx.Get("BAD_NO_EQUALS") != "" {
|
||||
t.Error("malformed KEY=VALUE should not be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_SourceOverride(t *testing.T) {
|
||||
ctx := New("", "", "schedule", nil)
|
||||
if ctx.Get("CI_PIPELINE_SOURCE") != "schedule" {
|
||||
t.Errorf("unexpected source: %q", ctx.Get("CI_PIPELINE_SOURCE"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_DefaultBranch(t *testing.T) {
|
||||
ctx := New("feature", "", "", nil)
|
||||
if ctx.Get("CI_DEFAULT_BRANCH") != "main" {
|
||||
t.Errorf("unexpected default branch: %q", ctx.Get("CI_DEFAULT_BRANCH"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInject(t *testing.T) {
|
||||
ctx := New("main", "", "", nil)
|
||||
// pinned var should not be overwritten
|
||||
ctx.Inject("CI_COMMIT_BRANCH", "other")
|
||||
if ctx.Get("CI_COMMIT_BRANCH") != "main" {
|
||||
t.Error("pinned var was overwritten by Inject")
|
||||
}
|
||||
// non-pinned var should be injected
|
||||
ctx.Inject("MY_VAR", "hello")
|
||||
if ctx.Get("MY_VAR") != "hello" {
|
||||
t.Errorf("Inject failed: %q", ctx.Get("MY_VAR"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInject_NilVarsMap(t *testing.T) {
|
||||
ctx := &Context{}
|
||||
ctx.Inject("K", "v") // should not panic
|
||||
if ctx.Get("K") != "v" {
|
||||
t.Error("Inject on nil Vars should initialise the map")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_NilContext(t *testing.T) {
|
||||
var ctx *Context
|
||||
if ctx.Get("X") != "" {
|
||||
t.Error("nil context Get should return empty string")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSummary(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
branch string
|
||||
tag string
|
||||
source string
|
||||
want string
|
||||
}{
|
||||
{"empty context", "", "", "", ""},
|
||||
{"branch only", "main", "", "", "branch=main, source=push"},
|
||||
{"tag only", "", "v1.0", "", "tag=v1.0, source=push"},
|
||||
{"source only", "", "", "schedule", "source=schedule"},
|
||||
{"branch + source", "develop", "", "merge_request_event", "branch=develop, source=merge_request_event"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := New(tc.branch, tc.tag, tc.source, nil)
|
||||
got := ctx.Summary()
|
||||
if got != tc.want {
|
||||
t.Errorf("got %q want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── ScalarString ──────────────────────────────────────────────────────────────
|
||||
|
||||
func TestScalarString(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input any
|
||||
wantS string
|
||||
wantOK bool
|
||||
}{
|
||||
{"string", "hello", "hello", true},
|
||||
{"bool true", true, "true", true},
|
||||
{"bool false", false, "false", true},
|
||||
{"int", 42, "42", true},
|
||||
{"float64", 3.14, "3.14", true},
|
||||
{"map with value key", map[string]any{"value": "v"}, "v", true},
|
||||
{"map without value key", map[string]any{"description": "x"}, "", false},
|
||||
{"nil", nil, "", false},
|
||||
{"slice", []any{1, 2}, "", false},
|
||||
{"nested map value", map[string]any{"value": 99}, "99", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s, ok := ScalarString(tc.input)
|
||||
if ok != tc.wantOK || s != tc.wantS {
|
||||
t.Errorf("got (%q, %v) want (%q, %v)", s, ok, tc.wantS, tc.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── ExpandVars / expandVarRefs ────────────────────────────────────────────────
|
||||
|
||||
func TestExpandVars(t *testing.T) {
|
||||
t.Run("simple expansion", func(t *testing.T) {
|
||||
ctx := &Context{Vars: map[string]string{"A": "hello", "B": "$A world"}}
|
||||
ctx.ExpandVars()
|
||||
if ctx.Vars["B"] != "hello world" {
|
||||
t.Errorf("got %q", ctx.Vars["B"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("transitive chain", func(t *testing.T) {
|
||||
ctx := &Context{Vars: map[string]string{"A": "x", "B": "$A", "C": "$B"}}
|
||||
ctx.ExpandVars()
|
||||
if ctx.Vars["C"] != "x" {
|
||||
t.Errorf("got %q", ctx.Vars["C"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("circular reference stops", func(t *testing.T) {
|
||||
ctx := &Context{Vars: map[string]string{"A": "$B", "B": "$A"}}
|
||||
ctx.ExpandVars() // must not infinite-loop
|
||||
})
|
||||
|
||||
t.Run("nil context is noop", func(t *testing.T) {
|
||||
var ctx *Context
|
||||
ctx.ExpandVars() // must not panic
|
||||
})
|
||||
|
||||
t.Run("empty vars is noop", func(t *testing.T) {
|
||||
ctx := &Context{}
|
||||
ctx.ExpandVars() // must not panic
|
||||
})
|
||||
}
|
||||
|
||||
func TestExpandVarRefs(t *testing.T) {
|
||||
vars := map[string]string{"FOO": "bar", "X": "123"}
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"no dollar", "hello", "hello"},
|
||||
{"simple ref", "$FOO", "bar"},
|
||||
{"braced ref", "${FOO}", "bar"},
|
||||
{"unknown ref unchanged", "$UNKNOWN", "$UNKNOWN"},
|
||||
{"unknown braced unchanged", "${UNKNOWN}", "${UNKNOWN}"},
|
||||
{"trailing dollar", "abc$", "abc$"},
|
||||
{"dollar at end after ident", "$X_", "$X_"},
|
||||
// Malformed ${…} without closing brace: "${" emitted, ident chars consumed.
|
||||
{"malformed brace no close", "${FOO", "${"},
|
||||
{"mixed", "pre_${FOO}_$X", "pre_bar_123"},
|
||||
{"dollar no ident after", "$ abc", "$ abc"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := expandVarRefs(tc.input, vars)
|
||||
if got != tc.want {
|
||||
t.Errorf("got %q want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── slugify ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSlugify(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"main", "main"},
|
||||
{"feature/my-branch", "feature-my-branch"},
|
||||
{"Feature_123", "feature-123"},
|
||||
{"--leading", "leading"},
|
||||
{"trailing--", "trailing"},
|
||||
{"v1.2.3", "v1-2-3"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
got := slugify(tc.input)
|
||||
if got != tc.want {
|
||||
t.Errorf("got %q want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -339,9 +339,6 @@ func (p *exprParser) parseStringLiteral() (string, bool) {
|
||||
}
|
||||
|
||||
func (p *exprParser) parseRegexLiteral() (string, bool) {
|
||||
if p.peek() != '/' {
|
||||
return "", false
|
||||
}
|
||||
p.pos++ // consume opening '/'
|
||||
var sb strings.Builder
|
||||
for p.pos < len(p.s) {
|
||||
|
||||
@@ -138,6 +138,152 @@ func TestEvalIf(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvalIfParserEdgeCases exercises low-level parser branches not reached
|
||||
// by the main table-driven tests above.
|
||||
func TestEvalIfParserEdgeCases(t *testing.T) {
|
||||
vars := func(key string) string {
|
||||
switch key {
|
||||
case "BRANCH":
|
||||
return "develop"
|
||||
case "UNTERMINATED_RE":
|
||||
return "/no-end"
|
||||
case "ESCAPED_RE":
|
||||
return `/^dev\./`
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseOr: right side of || fails to parse.
|
||||
if !EvalIf(`$BRANCH || !(`, vars) {
|
||||
t.Error("parseOr bad right: expect permissive-true")
|
||||
}
|
||||
if EvalIfStrict(`$BRANCH || !(`, vars) {
|
||||
t.Error("parseOr bad right: expect strict-false")
|
||||
}
|
||||
|
||||
// parsePrimary: inner parseOr succeeds but no closing ')'.
|
||||
if !EvalIf(`($BRANCH == "develop"`, vars) {
|
||||
t.Error("unmatched paren: expect permissive-true")
|
||||
}
|
||||
if EvalIfStrict(`($BRANCH == "develop"`, vars) {
|
||||
t.Error("unmatched paren: expect strict-false")
|
||||
}
|
||||
|
||||
// parseComparison ==: right-hand parseValue fails (! is not a valid value start).
|
||||
if !EvalIf(`$BRANCH == !invalid`, vars) {
|
||||
t.Error("== bad rhs: expect permissive-true")
|
||||
}
|
||||
|
||||
// parseComparison !=: right-hand parseValue fails.
|
||||
if !EvalIf(`$BRANCH != ${`, vars) {
|
||||
t.Error("!= bad rhs: expect permissive-true")
|
||||
}
|
||||
|
||||
// parseRegexRHS: peek is neither '/' nor '$' → return "", false, false.
|
||||
// parseComparison =~: patOk=false, permissive=false → return false, false.
|
||||
if !EvalIf(`$BRANCH =~ "literal"`, vars) {
|
||||
t.Error("=~ string literal rhs: expect permissive-true")
|
||||
}
|
||||
if EvalIfStrict(`$BRANCH =~ "literal"`, vars) {
|
||||
t.Error("=~ string literal rhs: expect strict-false")
|
||||
}
|
||||
|
||||
// parseComparison =~: bad regex pattern → compile error → return true, true.
|
||||
if !EvalIf(`$BRANCH =~ /[unclosed/`, vars) {
|
||||
t.Error("=~ bad regex: expect permissive-true")
|
||||
}
|
||||
|
||||
// parseComparison !~: patOk=false → return false, false.
|
||||
if !EvalIf(`$BRANCH !~ "literal"`, vars) {
|
||||
t.Error("!~ string literal rhs: expect permissive-true")
|
||||
}
|
||||
if EvalIfStrict(`$BRANCH !~ "literal"`, vars) {
|
||||
t.Error("!~ string literal rhs: expect strict-false")
|
||||
}
|
||||
|
||||
// parseComparison !~: bad regex pattern → compile error → return true, true.
|
||||
if !EvalIf(`$BRANCH !~ /[unclosed/`, vars) {
|
||||
t.Error("!~ bad regex: expect permissive-true")
|
||||
}
|
||||
|
||||
// parseComparison single =: RHS parseValue fails.
|
||||
if !EvalIf(`$BRANCH = !invalid`, vars) {
|
||||
t.Error("single = bad rhs: expect permissive-true")
|
||||
}
|
||||
|
||||
// parseValue: '$' not followed by a valid identifier.
|
||||
if !EvalIf(`$} == "develop"`, vars) {
|
||||
t.Error("$ bad ident: expect permissive-true")
|
||||
}
|
||||
|
||||
// parseValue: '${' with no closing '}' or empty name.
|
||||
if !EvalIf(`${} == "develop"`, vars) {
|
||||
t.Error("${} empty name: expect permissive-true")
|
||||
}
|
||||
if !EvalIf(`${BRANCH == "develop"`, vars) {
|
||||
t.Error("${BRANCH no close brace: expect permissive-true")
|
||||
}
|
||||
|
||||
// parseStringLiteral: escape sequence — \v is consumed and the next byte
|
||||
// is written literally, so "de\velop" → "develop" which matches BRANCH.
|
||||
if !EvalIf(`$BRANCH == "de\velop"`, vars) {
|
||||
t.Error(`string escape: "de\velop" should decode to "develop"`)
|
||||
}
|
||||
|
||||
// parseStringLiteral: unterminated string literal.
|
||||
if !EvalIf(`$BRANCH == "no-end`, vars) {
|
||||
t.Error("unterminated string: expect permissive-true")
|
||||
}
|
||||
if EvalIfStrict(`$BRANCH == "no-end`, vars) {
|
||||
t.Error("unterminated string: expect strict-false")
|
||||
}
|
||||
|
||||
// parseRegexLiteral: escape sequence in regex (backslash preserved).
|
||||
// /^d\evelop/ → pattern "^d\evelop" — \e is invalid in Go regexp
|
||||
// → compile error → permissive true.
|
||||
if !EvalIf(`$BRANCH =~ /^d\evelop/`, vars) {
|
||||
t.Error("regex escape (bad compile): expect permissive-true")
|
||||
}
|
||||
|
||||
// parseRegexLiteral: unterminated regex (no closing '/').
|
||||
if !EvalIf(`$BRANCH =~ /no-end`, vars) {
|
||||
t.Error("unterminated regex: expect permissive-true")
|
||||
}
|
||||
if EvalIfStrict(`$BRANCH =~ /no-end`, vars) {
|
||||
t.Error("unterminated regex: expect strict-false")
|
||||
}
|
||||
|
||||
// applyRegexFlags: 'm' and 's' flags (exercises two additional switch cases).
|
||||
if !EvalIf(`$BRANCH =~ /^DEV/ims`, vars) {
|
||||
t.Error("regex /ims flags: case-insensitive should match 'develop'")
|
||||
}
|
||||
|
||||
// extractRegexFromString: unterminated regex in variable value.
|
||||
// UNTERMINATED_RE = "/no-end" (no closing '/') → permissive.
|
||||
if !EvalIf(`$BRANCH =~ $UNTERMINATED_RE`, vars) {
|
||||
t.Error("unterminated re in var: expect permissive-true")
|
||||
}
|
||||
|
||||
// extractRegexFromString: escape sequence in variable value.
|
||||
// ESCAPED_RE = /^dev\./ → pattern = "^dev\." (literal dot) → no match for "develop".
|
||||
if EvalIf(`$BRANCH =~ $ESCAPED_RE`, vars) {
|
||||
t.Error("ESCAPED_RE=/^dev\\./ requires a literal dot; 'develop' has no dot")
|
||||
}
|
||||
|
||||
// !~ permissive path (line 203-205): var value is a plain string, not /regex/ → permissive.
|
||||
// BRANCH = "develop" which does not start with '/' → extractRegexFromString returns ok=false
|
||||
// → parseRegexRHS returns permissive=true → !~ case returns (true, true).
|
||||
if !EvalIf(`$BRANCH !~ $BRANCH`, vars) {
|
||||
t.Error("!~ plain-var rhs: plain variable value triggers permissive-true")
|
||||
}
|
||||
|
||||
// parseRegexRHS: '$' in rhs but parseValue fails (line 244-246).
|
||||
// '$}' — '$' followed by '}' which is not a valid identifier start → varOk=false.
|
||||
if !EvalIf(`$BRANCH =~ $}`, vars) {
|
||||
t.Error("=~ $}: parseValue fails → permissive-true (EvalIf overall)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalIfStrict(t *testing.T) {
|
||||
vars := func(key string) string {
|
||||
m := map[string]string{
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
package cicontext
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.k3nny.fr/glint/internal/model"
|
||||
)
|
||||
|
||||
// ── JobState.String ───────────────────────────────────────────────────────────
|
||||
|
||||
func TestJobStateString(t *testing.T) {
|
||||
if JobActive.String() != "active" { t.Errorf("active: got %q", JobActive.String()) }
|
||||
if JobManual.String() != "manual" { t.Errorf("manual: got %q", JobManual.String()) }
|
||||
if JobSkipped.String() != "skipped" { t.Errorf("skipped: got %q", JobSkipped.String()) }
|
||||
}
|
||||
|
||||
// ── whenToState ───────────────────────────────────────────────────────────────
|
||||
|
||||
func TestWhenToState(t *testing.T) {
|
||||
cases := []struct { when string; want JobState }{
|
||||
{"never", JobSkipped},
|
||||
{"manual", JobManual},
|
||||
{"on_success", JobActive},
|
||||
{"always", JobActive},
|
||||
{"", JobActive},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := whenToState(tc.when)
|
||||
if got != tc.want {
|
||||
t.Errorf("whenToState(%q)=%v want %v", tc.when, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── EvalJob: only/except paths ────────────────────────────────────────────────
|
||||
|
||||
func TestEvalJob_OnlyExcept(t *testing.T) {
|
||||
ctx := New("main", "", "", nil)
|
||||
|
||||
t.Run("only branches matches", func(t *testing.T) {
|
||||
job := model.Job{Only: []any{"branches"}}
|
||||
if EvalJob(job, ctx) != JobActive {
|
||||
t.Error("expected active on branch")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("only tags excludes branch push", func(t *testing.T) {
|
||||
job := model.Job{Only: []any{"tags"}}
|
||||
if EvalJob(job, ctx) != JobSkipped {
|
||||
t.Error("expected skipped — we are on a branch, not tag")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("except branches skips on branch push", func(t *testing.T) {
|
||||
job := model.Job{Script: "echo ok", Except: []any{"branches"}}
|
||||
if EvalJob(job, ctx) != JobSkipped {
|
||||
t.Error("expected skipped — branches excluded")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("except non-matching source does not skip", func(t *testing.T) {
|
||||
job := model.Job{Except: []any{"schedules"}}
|
||||
if EvalJob(job, ctx) != JobActive {
|
||||
t.Error("expected active — schedule is not the source")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("job when: manual with no filter", func(t *testing.T) {
|
||||
job := model.Job{When: "manual"}
|
||||
if EvalJob(job, ctx) != JobManual {
|
||||
t.Error("expected manual")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty context returns active", func(t *testing.T) {
|
||||
emptyCtx := New("", "", "", nil)
|
||||
job := model.Job{Only: []any{"tags"}}
|
||||
if EvalJob(job, emptyCtx) != JobActive {
|
||||
t.Error("empty context should always return active")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── EvalJob: rules path ───────────────────────────────────────────────────────
|
||||
|
||||
func TestEvalJob_Rules(t *testing.T) {
|
||||
ctx := New("main", "", "", nil)
|
||||
|
||||
t.Run("rule with never when excludes", func(t *testing.T) {
|
||||
job := model.Job{Rules: []model.Rule{{When: "never"}}}
|
||||
if EvalJob(job, ctx) != JobSkipped {
|
||||
t.Error("expected skipped — when:never")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no matching rule → skipped", func(t *testing.T) {
|
||||
job := model.Job{Rules: []model.Rule{
|
||||
{If: "$CI_COMMIT_BRANCH == \"develop\"", When: "on_success"},
|
||||
}}
|
||||
if EvalJob(job, ctx) != JobSkipped {
|
||||
t.Error("expected skipped — no rule matches main branch with develop condition")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── onlyMatches / exceptMatches ───────────────────────────────────────────────
|
||||
|
||||
func TestOnlyMatches(t *testing.T) {
|
||||
branchCtx := New("main", "", "", nil)
|
||||
tagCtx := New("", "v1.0", "", nil)
|
||||
mrCtx := New("", "", "merge_request_event", nil)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
only any
|
||||
ctx *Context
|
||||
want bool
|
||||
}{
|
||||
{"nil only always matches", nil, branchCtx, true},
|
||||
{"unrecognised form permissive", 42, branchCtx, true},
|
||||
{"string form — branches keyword", "branches", branchCtx, true},
|
||||
{"string form — tags keyword miss", "tags", branchCtx, false},
|
||||
{"slice — merge_requests keyword", []any{"merge_requests"}, mrCtx, true},
|
||||
{"slice — schedules keyword", []any{"schedules"}, branchCtx, false},
|
||||
{"slice — pipelines keyword", []any{"pipelines"}, New("","","pipeline",nil), true},
|
||||
{"slice — pushes keyword", []any{"pushes"}, New("","","push",nil), true},
|
||||
{"slice — web keyword", []any{"web"}, New("","","web",nil), true},
|
||||
{"slice — api keyword", []any{"api"}, New("","","api",nil), true},
|
||||
{"slice — tag keyword match", []any{"tags"}, tagCtx, true},
|
||||
{"glob pattern matches", []any{"feat/*"}, New("feat/abc","","",nil), true},
|
||||
{"glob no match", []any{"feat/*"}, branchCtx, false},
|
||||
{"regex pattern matches branch", []any{"/^main.*/"}, branchCtx, true},
|
||||
{"regex pattern no match", []any{"/^develop/"}, branchCtx, false},
|
||||
{"invalid regex treated as glob", []any{"/[invalid/"}, branchCtx, false},
|
||||
{"map form with refs key", map[string]any{"refs": []any{"branches"}}, branchCtx, true},
|
||||
{"map form no refs — permissive", map[string]any{"variables": []any{}}, branchCtx, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := onlyMatches(tc.only, tc.ctx)
|
||||
if got != tc.want {
|
||||
t.Errorf("onlyMatches: got %v want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExceptMatches(t *testing.T) {
|
||||
branchCtx := New("main", "", "", nil)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
except any
|
||||
want bool
|
||||
}{
|
||||
{"nil except never excludes", nil, false},
|
||||
{"unrecognised form not excluded", 42, false},
|
||||
{"branches excludes on branch push", []any{"branches"}, true},
|
||||
{"tags does not exclude branch push", []any{"tags"}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := exceptMatches(tc.except, branchCtx)
|
||||
if got != tc.want {
|
||||
t.Errorf("exceptMatches: got %v want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── extractRefList ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestExtractRefList(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input any
|
||||
wantN int // -1 means nil expected
|
||||
}{
|
||||
{"string", "branches", 1},
|
||||
{"slice of strings", []any{"a", "b", "c"}, 3},
|
||||
{"slice with non-string items skipped", []any{"a", 42, "b"}, 2},
|
||||
{"map with refs key", map[string]any{"refs": []any{"branches"}}, 1},
|
||||
{"map without refs key returns nil", map[string]any{"variables": nil}, -1},
|
||||
{"unknown type returns nil", 123, -1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := extractRefList(tc.input)
|
||||
if tc.wantN == -1 {
|
||||
if got != nil { t.Errorf("expected nil, got %v", got) }
|
||||
} else {
|
||||
if len(got) != tc.wantN { t.Errorf("len: got %d want %d (%v)", len(got), tc.wantN, got) }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── globMatches / matchGlob ───────────────────────────────────────────────────
|
||||
|
||||
func TestGlobMatches(t *testing.T) {
|
||||
cases := []struct {
|
||||
pattern string
|
||||
s string
|
||||
want bool
|
||||
}{
|
||||
{"main", "main", true},
|
||||
{"main", "develop", false},
|
||||
{"feat/*", "feat/abc", true},
|
||||
{"feat/*", "feat/", true},
|
||||
{"feat/*", "main", false},
|
||||
{"*", "anything", true},
|
||||
{"*", "", true},
|
||||
{"prefix*suffix", "prefixMIDDLEsuffix", true},
|
||||
{"prefix*suffix", "prefixsuffix", true},
|
||||
{"prefix*suffix", "prefixNO", false},
|
||||
{"a*b*c", "aXbYc", true},
|
||||
{"a*b*c", "abc", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.pattern+"/"+tc.s, func(t *testing.T) {
|
||||
got := globMatches(tc.pattern, tc.s)
|
||||
if got != tc.want {
|
||||
t.Errorf("globMatches(%q, %q)=%v want %v", tc.pattern, tc.s, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── refPatternMatches ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestRefPatternMatches(t *testing.T) {
|
||||
cases := []struct {
|
||||
pattern string
|
||||
ref string
|
||||
want bool
|
||||
}{
|
||||
{"/^main$/", "main", true},
|
||||
{"/^main$/", "develop", false},
|
||||
{"/feature/", "feature/abc", true},
|
||||
{"feat/*", "feat/x", true},
|
||||
{"main", "main", true},
|
||||
{"main", "develop", false},
|
||||
{"", "main", false}, // empty ref for pattern with no wildcard
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.pattern+"@"+tc.ref, func(t *testing.T) {
|
||||
got := refPatternMatches(tc.pattern, tc.ref)
|
||||
if got != tc.want {
|
||||
t.Errorf("refPatternMatches(%q, %q)=%v want %v", tc.pattern, tc.ref, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefListMatches_Schedule verifies the "schedules" keyword matches when
|
||||
// CI_PIPELINE_SOURCE is "schedule".
|
||||
func TestRefListMatches_Schedule(t *testing.T) {
|
||||
ctx := New("", "", "schedule", nil)
|
||||
if !refListMatches([]string{"schedules"}, ctx) {
|
||||
t.Error("schedules keyword should match when source is 'schedule'")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user