Files
glint/internal/linter/variables_test.go
T
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

250 lines
6.1 KiB
Go

package linter
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
func TestExtractIfVars(t *testing.T) {
cases := []struct {
name string
expr string
want []string
}{
{
name: "simple variable",
expr: `$MY_VAR == "value"`,
want: []string{"MY_VAR"},
},
{
name: "curly brace syntax",
expr: `${MY_VAR} != null`,
want: []string{"MY_VAR"},
},
{
name: "multiple variables",
expr: `$BRANCH == "main" && $DEPLOY_ENV == "prod"`,
want: []string{"BRANCH", "DEPLOY_ENV"},
},
{
name: "dollar sign inside string literal is skipped",
expr: `$REAL_VAR == "$not_a_var"`,
want: []string{"REAL_VAR"},
},
{
name: "no variables",
expr: `"main" == "main"`,
want: nil,
},
{
name: "regex rhs variable",
expr: `$BRANCH =~ $PATTERN`,
want: []string{"BRANCH", "PATTERN"},
},
{
name: "multiline expression",
expr: "$BRANCH == \"main\" ||\n$BRANCH == \"develop\"",
want: []string{"BRANCH", "BRANCH"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := extractIfVars(tc.expr)
if len(got) != len(tc.want) {
t.Fatalf("extractIfVars(%q) = %v, want %v", tc.expr, got, tc.want)
}
for i, v := range got {
if v != tc.want[i] {
t.Errorf("[%d] got %q, want %q", i, v, tc.want[i])
}
}
})
}
}
func TestIsPredefinedVar(t *testing.T) {
cases := []struct {
input string
want bool
}{
{"CI_COMMIT_BRANCH", true},
{"CI_JOB_TOKEN", true},
{"GITLAB_USER_ID", true},
{"GITLAB_FEATURES", true},
{"FF_SOME_FLAG", true},
{"RUNNER_ID", true},
{"TRIGGER_PAYLOAD", true},
{"CHAT_INPUT", true},
{"MY_CUSTOM_VAR", false},
{"DEPLOY_ENV", false},
{"FEATURE_ENABLED", false},
}
for _, tc := range cases {
t.Run(tc.input, func(t *testing.T) {
if got := isPredefinedVar(tc.input); got != tc.want {
t.Errorf("isPredefinedVar(%q) = %v, want %v", tc.input, got, tc.want)
}
})
}
}
func TestCheckVariableRefs(t *testing.T) {
cases := []struct {
name string
pipeline *model.Pipeline
wantWarnings int
}{
{
name: "declared pipeline variable — no warning",
pipeline: &model.Pipeline{
Variables: map[string]any{"MY_VAR": "value"},
Jobs: map[string]model.Job{
"job-a": {Rules: []model.Rule{{If: `$MY_VAR == "value"`}}},
},
},
wantWarnings: 0,
},
{
name: "predefined CI variable — no warning",
pipeline: &model.Pipeline{
Jobs: map[string]model.Job{
"job-a": {Rules: []model.Rule{{If: `$CI_COMMIT_BRANCH == "main"`}}},
},
},
wantWarnings: 0,
},
{
name: "undeclared variable — one warning",
pipeline: &model.Pipeline{
Jobs: map[string]model.Job{
"job-a": {Rules: []model.Rule{{If: `$UNDEFINED_VAR == "yes"`}}},
},
},
wantWarnings: 1,
},
{
name: "same undeclared var in multiple rules — one warning per job",
pipeline: &model.Pipeline{
Jobs: map[string]model.Job{
"job-a": {
Rules: []model.Rule{
{If: `$UNDEFINED_VAR == "yes"`},
{If: `$UNDEFINED_VAR == "no"`},
},
},
},
},
wantWarnings: 1,
},
{
name: "job-level variable — no warning",
pipeline: &model.Pipeline{
Jobs: map[string]model.Job{
"job-a": {
Variables: map[string]any{"LOCAL_VAR": "value"},
Rules: []model.Rule{{If: `$LOCAL_VAR == "value"`}},
},
},
},
wantWarnings: 0,
},
{
name: "workflow rule variable available to job rules — no warning",
pipeline: &model.Pipeline{
Workflow: &model.Workflow{
Rules: []model.Rule{
{
If: `$CI_COMMIT_BRANCH == "main"`,
Variables: map[string]any{"DEPLOY_ENV": "production"},
},
},
},
Jobs: map[string]model.Job{
"job-a": {Rules: []model.Rule{{If: `$DEPLOY_ENV == "production"`}}},
},
},
wantWarnings: 0,
},
{
name: "undeclared variable in workflow rules:if",
pipeline: &model.Pipeline{
Workflow: &model.Workflow{
Rules: []model.Rule{{If: `$UNDECLARED == "main"`}},
},
Jobs: map[string]model.Job{},
},
wantWarnings: 1,
},
{
name: "two jobs each with a different undeclared variable — two warnings",
pipeline: &model.Pipeline{
Jobs: map[string]model.Job{
"job-a": {Rules: []model.Rule{{If: `$UNDEF_A == "x"`}}},
"job-b": {Rules: []model.Rule{{If: `$UNDEF_B == "y"`}}},
},
},
wantWarnings: 2,
},
{
name: "no rules — no warnings",
pipeline: &model.Pipeline{
Jobs: map[string]model.Job{
"job-a": {Script: "echo ok"},
},
},
wantWarnings: 0,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
findings := checkVariableRefs(tc.pipeline)
count := 0
for _, f := range findings {
if f.Severity == Warning && f.Rule == RuleUndeclaredVariable {
count++
}
}
if count != tc.wantWarnings {
t.Errorf("got %d GL032 warnings, want %d; findings: %v", count, tc.wantWarnings, findings)
}
})
}
}
// TestCheckVariableRefs_WorkflowRuleEmptyIf covers the `if rule.If == ""` early
// continue at variables.go line 109-110 (workflow rule with no if: expression).
func TestCheckVariableRefs_WorkflowRuleEmptyIf(t *testing.T) {
p := &model.Pipeline{
Workflow: &model.Workflow{
Rules: []model.Rule{
{When: "always"}, // no If → triggers the continue
{If: `$UNDECLARED == "yes"`}, // undeclared → warning
},
},
Jobs: map[string]model.Job{},
}
findings := checkVariableRefs(p)
count := 0
for _, f := range findings {
if f.Rule == RuleUndeclaredVariable {
count++
}
}
if count != 1 {
t.Errorf("expected 1 GL032 warning for UNDECLARED, got %d; findings: %v", count, findings)
}
}
// TestExtractIfVars_StringEscape covers the backslash-escape branch (line 42-44)
// in extractIfVars when scanning a quoted string literal.
func TestExtractIfVars_StringEscape(t *testing.T) {
// `$BRANCH == "de\velop"` — the `\v` inside the string literal triggers the
// escape-character skip in the scanning loop.
got := extractIfVars(`$BRANCH == "de\velop"`)
if len(got) != 1 || got[0] != "BRANCH" {
t.Errorf("extractIfVars with escape in string: got %v, want [BRANCH]", got)
}
}