Files
k3nny 04f17f8616
ci / vet, staticcheck, test, build (push) Successful in 2m25s
test(coverage): add unit tests across all packages; remove dead code
- 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>
2026-06-14 22:03:46 +02:00

331 lines
15 KiB
Go

package cicontext
import "testing"
func TestEvalIf(t *testing.T) {
vars := func(key string) string {
m := map[string]string{
"CI_COMMIT_BRANCH": "develop",
"CI_COMMIT_TAG": "",
"CI_PIPELINE_SOURCE": "push",
"DEPLOY_ENV": "staging",
"BRANCH_PATTERN": "/^dev/",
"BRANCH_PATTERN_CI": "/^DEV/i",
"EMPTY_PATTERN": "",
"PLAIN_PATTERN": "develop",
}
return m[key]
}
tests := []struct {
name string
expr string
want bool
}{
// ── Equality ──────────────────────────────────────────────────────────
{"eq match", `$CI_COMMIT_BRANCH == "develop"`, true},
{"eq no match", `$CI_COMMIT_BRANCH == "main"`, false},
{"eq single-quote", `$CI_COMMIT_BRANCH == 'develop'`, true},
{"neq match", `$CI_COMMIT_BRANCH != "main"`, true},
{"neq no match", `$CI_COMMIT_BRANCH != "develop"`, false},
// ── Null checks ───────────────────────────────────────────────────────
{"tag undefined eq null", `$CI_COMMIT_TAG == null`, true},
{"tag undefined neq null", `$CI_COMMIT_TAG != null`, false},
{"branch defined neq null", `$CI_COMMIT_BRANCH != null`, true},
{"branch defined eq null", `$CI_COMMIT_BRANCH == null`, false},
{"null eq null", `null == null`, true},
// ── Variable truthiness (no operator) ────────────────────────────────
{"truthy branch", `$CI_COMMIT_BRANCH`, true},
{"falsy tag", `$CI_COMMIT_TAG`, false},
// ── Logical NOT ───────────────────────────────────────────────────────
{"not false", `!$CI_COMMIT_TAG`, true},
{"not true", `!$CI_COMMIT_BRANCH`, false},
{"double not", `!!$CI_COMMIT_BRANCH`, true},
// ── Boolean AND ───────────────────────────────────────────────────────
{"and both true", `$CI_COMMIT_BRANCH == "develop" && $CI_PIPELINE_SOURCE == "push"`, true},
{"and left false", `$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"`, false},
{"and right false", `$CI_COMMIT_BRANCH == "develop" && $CI_PIPELINE_SOURCE == "schedule"`, false},
{"and both false", `$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "schedule"`, false},
// ── Boolean OR ────────────────────────────────────────────────────────
{"or both true", `$CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "main"`, true},
{"or left true", `$CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "nope"`, true},
{"or right true", `$CI_COMMIT_BRANCH == "nope" || $CI_COMMIT_BRANCH == "develop"`, true},
{"or both false", `$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "nope"`, false},
// ── Parentheses ───────────────────────────────────────────────────────
{"paren or+and", `($CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "main") && $CI_COMMIT_TAG == null`, true},
{"paren or+and false", `($CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "nope") && $CI_COMMIT_TAG == null`, false},
// ── Regex match ───────────────────────────────────────────────────────
{"regex match", `$CI_COMMIT_BRANCH =~ /^dev/`, true},
{"regex no match", `$CI_COMMIT_BRANCH =~ /^main/`, false},
{"regex not match", `$CI_COMMIT_BRANCH !~ /^main/`, true},
{"regex not no match", `$CI_COMMIT_BRANCH !~ /^dev/`, false},
{"regex case sensitive", `$CI_COMMIT_BRANCH =~ /^DEV/`, false},
{"regex feat branch", `$CI_COMMIT_BRANCH =~ /^feat\//`, false},
// ── Extra whitespace ──────────────────────────────────────────────────
{"extra spaces", ` $CI_COMMIT_BRANCH == "develop" `, true},
{"tabs", "$CI_COMMIT_BRANCH\t==\t\"develop\"", true},
// ── Multi-line expressions (newlines between tokens) ──────────────────
{"multiline or true", "$CI_COMMIT_BRANCH == \"develop\" ||\n$CI_COMMIT_TAG != null", true},
{"multiline or false", "$CI_COMMIT_BRANCH == \"main\" ||\n$CI_COMMIT_TAG != null", false},
{"multiline and true", "$CI_COMMIT_BRANCH == \"develop\" &&\n$CI_PIPELINE_SOURCE == \"push\"", true},
{"multiline and false", "$CI_COMMIT_BRANCH == \"main\" &&\n$CI_PIPELINE_SOURCE == \"push\"", false},
{"multiline with crlf", "$CI_COMMIT_BRANCH == \"develop\" ||\r\n$CI_COMMIT_TAG != null", true},
// ── ${VAR} curly-brace syntax ─────────────────────────────────────────
{"curly var eq match", `${CI_COMMIT_BRANCH} == "develop"`, true},
{"curly var eq no match", `${CI_COMMIT_BRANCH} == "main"`, false},
{"curly var truthiness", `${CI_COMMIT_BRANCH}`, true},
{"curly var falsy", `${CI_COMMIT_TAG}`, false},
{"curly var neq null", `${CI_COMMIT_BRANCH} != null`, true},
{"curly mixed", `${CI_COMMIT_BRANCH} == "develop" && $CI_PIPELINE_SOURCE == "push"`, true},
// ── Regex flags (/pattern/i etc.) ─────────────────────────────────────
{"regex flag i match", `$CI_COMMIT_BRANCH =~ /^DEV/i`, true},
{"regex flag i no match", `$CI_COMMIT_BRANCH =~ /^MAIN/i`, false},
{"regex flag i not match", `$CI_COMMIT_BRANCH !~ /^MAIN/i`, true},
{"regex no flag case sensitive", `$CI_COMMIT_BRANCH =~ /^DEV/`, false},
{"regex flag i version tag", `$CI_PIPELINE_SOURCE =~ /^PUSH$/i`, true},
// ── Variable on right side of =~ ──────────────────────────────────────
{"var regex rhs match", `$CI_COMMIT_BRANCH =~ $BRANCH_PATTERN`, true},
{"var regex rhs no match", `$CI_PIPELINE_SOURCE =~ $BRANCH_PATTERN`, false},
{"var regex rhs ci flag match", `$CI_COMMIT_BRANCH =~ $BRANCH_PATTERN_CI`, true},
{"var regex rhs empty permissive", `$CI_COMMIT_BRANCH =~ $EMPTY_PATTERN`, true},
{"var regex rhs plain permissive", `$CI_COMMIT_BRANCH =~ $PLAIN_PATTERN`, true},
{"var regex rhs not match", `$CI_COMMIT_BRANCH !~ $BRANCH_PATTERN`, false},
// ── Bare true/false keywords ─────────────────────────────────────────
// GitLab CI treats true/false as the string values "true"/"false".
{"bare true match", `$CI_PIPELINE_SOURCE == true`, false}, // "push" != "true"
{"bare false match", `$CI_COMMIT_TAG == false`, false}, // "" != "false"
{"bare true var set to true", `$DEPLOY_ENV == true`, false}, // "staging" != "true"
{"bare false neq", `$CI_COMMIT_BRANCH != false`, true}, // "develop" != "false"
{"bare true in compound", `$CI_COMMIT_BRANCH != null && $CI_COMMIT_TAG == false`, false},
// ── Integer literals ──────────────────────────────────────────────────
// Compared as decimal strings (GitLab CI converts integers to strings).
{"int eq match", `$CI_PIPELINE_SOURCE != 0`, true}, // "push" != "0"
{"int eq no match", `$CI_COMMIT_TAG == 0`, false}, // "" != "0"
{"int in compound", `$CI_COMMIT_BRANCH != null && $CI_COMMIT_BRANCH != 0`, true},
// ── Permissive fallback ───────────────────────────────────────────────
{"unparseable returns true", `this is not valid syntax %%%`, true},
{"empty expr returns true", ``, true},
// ── Single = as alias for == ──────────────────────────────────────────
{"single eq match", `$CI_COMMIT_BRANCH = "develop"`, true},
{"single eq no match", `$CI_COMMIT_BRANCH = "main"`, false},
{"single eq in compound", `$CI_COMMIT_BRANCH = "develop" && $CI_PIPELINE_SOURCE = "push"`, true},
{"single eq compound false", `$CI_COMMIT_BRANCH = "main" && $CI_PIPELINE_SOURCE = "push"`, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := EvalIf(tc.expr, vars)
if got != tc.want {
t.Errorf("EvalIf(%q) = %v, want %v", tc.expr, got, tc.want)
}
})
}
}
// 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{
"CI_COMMIT_BRANCH": "develop",
"CI_PIPELINE_SOURCE": "push",
"WORKFLOW": "",
}
return m[key]
}
tests := []struct {
name string
expr string
want bool
}{
// Parseable expressions behave identically to EvalIf.
{"parseable match", `$CI_COMMIT_BRANCH == "develop"`, true},
{"parseable no match", `$CI_COMMIT_BRANCH == "main"`, false},
{"single eq match", `$CI_COMMIT_BRANCH = "develop"`, true},
// Empty expression: ruleIfMatchesStrict handles the empty→true case
// before calling EvalIfStrict, so empty falls through to false here.
{"empty expr", ``, false},
// Unparseable expressions return false (strict) instead of true (permissive).
{"unparseable returns false", `this is not valid syntax %%%`, false},
// The key workflow-rule scenario: a complex condition with an
// unevaluable sub-expression should not match (strict=false) so that
// later workflow rules can be evaluated.
{"workflow rule complex no match", `$WORKFLOW = "gitflow" && $CI_PIPELINE_SOURCE == /(push|web)/`, false},
// Compound with a bad second operand: strict returns false.
{"and with bad rhs strict false", `$CI_COMMIT_BRANCH == "develop" && !(((`, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := EvalIfStrict(tc.expr, vars)
if got != tc.want {
t.Errorf("EvalIfStrict(%q) = %v, want %v", tc.expr, got, tc.want)
}
})
}
}