test(coverage): add unit tests across all packages; remove dead code
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:
2026-06-14 22:03:46 +02:00
parent 7f7e2bf77b
commit 04f17f8616
27 changed files with 4716 additions and 40 deletions
+2 -1
View File
@@ -18,7 +18,8 @@ func cmdExplain(args []string) {
entry, ok := linter.RuleCatalog[ruleID]
if !ok {
fmt.Fprintf(os.Stderr, "glint explain: unknown rule %q\n\nRun 'glint explain' to list all rules.\n", ruleID)
os.Exit(2)
exit(2)
return
}
printRuleEntry(ruleID, entry)
}
+23 -11
View File
@@ -20,6 +20,9 @@ import (
// version is set at build time via -ldflags "-X main.version=vX.Y.Z".
var version = "dev"
// exit is a variable so tests can capture exit calls without terminating.
var exit = os.Exit
// defaultCacheDir returns the platform-default glint cache directory:
// $XDG_CACHE_HOME/glint or ~/.cache/glint.
func defaultCacheDir() string {
@@ -51,7 +54,8 @@ For help with a specific command, see: ` + "`glint <command> --help`" + `.
func main() {
if len(os.Args) < 2 {
fmt.Fprint(os.Stderr, globalUsage)
os.Exit(2)
exit(2)
return
}
switch os.Args[1] {
case "check":
@@ -67,7 +71,7 @@ func main() {
fmt.Printf("glint %s\n", version)
default:
fmt.Fprintf(os.Stderr, "glint: unknown command %q\n\n%s", os.Args[1], globalUsage)
os.Exit(2)
exit(2)
}
}
@@ -189,12 +193,14 @@ Examples:
}
if !validFormats[*format] {
fmt.Fprintf(os.Stderr, "glint: unknown format %q; valid: text, json, sarif, junit, github\n", *format)
os.Exit(2)
exit(2)
return
}
if fs.NArg() != 1 {
fs.Usage()
os.Exit(2)
exit(2)
return
}
path := fs.Arg(0)
rootDir := filepath.Dir(filepath.Clean(path))
@@ -229,7 +235,8 @@ Examples:
p, err := model.Parse(path)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(2)
exit(2)
return
}
// Merge config-defined stages into the pipeline before linting so that
@@ -253,7 +260,8 @@ Examples:
extWarnings, err := resolver.Resolve(p)
if err != nil {
fmt.Fprintf(os.Stderr, "error: resolving extends: %v\n", err)
os.Exit(2)
exit(2)
return
}
for _, w := range extWarnings {
fmt.Fprintf(os.Stderr, "%s: [warning] job %q extends unknown job %q; extends chain skipped\n", path, w.Job, w.Base)
@@ -306,7 +314,7 @@ Examples:
}
if errCount > 0 {
os.Exit(1)
exit(1)
}
}
@@ -411,7 +419,8 @@ Examples:
if fs.NArg() != 1 {
fs.Usage()
os.Exit(2)
exit(2)
return
}
path := fs.Arg(0)
@@ -425,7 +434,8 @@ Examples:
p, err := model.Parse(path)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(2)
exit(2)
return
}
rootDir := filepath.Dir(filepath.Clean(path))
@@ -453,7 +463,8 @@ Examples:
outPath, err := graph.RenderPipeline(p, *out)
if err != nil {
fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err)
os.Exit(2)
exit(2)
return
}
fmt.Println(outPath)
case "all":
@@ -461,7 +472,8 @@ Examples:
outPath, err := graph.RenderPipeline(p, *out)
if err != nil {
fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err)
os.Exit(2)
exit(2)
return
}
fmt.Fprintln(os.Stderr, outPath)
}
+406
View File
@@ -0,0 +1,406 @@
package main
import (
"os"
"path/filepath"
"testing"
"git.k3nny.fr/glint/internal/cicontext"
"git.k3nny.fr/glint/internal/model"
)
// captureExit replaces the exit variable with a function that records the code,
// and restores it after the test. Call the returned cleanup func in defer.
func captureExit(t *testing.T) *int {
t.Helper()
orig := exit
code := -1
exit = func(c int) { code = c }
t.Cleanup(func() { exit = orig })
return &code
}
// writePipeline writes a minimal valid pipeline file to a temp dir and returns its path.
func writePipeline(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, ".gitlab-ci.yml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
const minimalPipeline = `
stages: [build]
build-job:
stage: build
script: make
`
// ── main() ───────────────────────────────────────────────────────────────────
func TestMain_NoArgs(t *testing.T) {
code := captureExit(t)
os.Args = []string{"glint"}
main()
if *code != 2 { t.Errorf("no args: want exit(2), got %d", *code) }
}
func TestMain_HelpFlag(t *testing.T) {
captureExit(t) // should not exit
os.Args = []string{"glint", "--help"}
main()
}
func TestMain_VersionFlag(t *testing.T) {
captureExit(t)
os.Args = []string{"glint", "--version"}
main()
}
func TestMain_HelpAlias(t *testing.T) {
captureExit(t)
os.Args = []string{"glint", "help"}
main()
}
func TestMain_VersionAlias(t *testing.T) {
captureExit(t)
os.Args = []string{"glint", "version"}
main()
}
func TestMain_UnknownCommand(t *testing.T) {
code := captureExit(t)
os.Args = []string{"glint", "badcmd"}
main()
if *code != 2 { t.Errorf("unknown cmd: want exit(2), got %d", *code) }
}
// ── cmdCheck ─────────────────────────────────────────────────────────────────
func TestCmdCheck_ValidPipeline(t *testing.T) {
code := captureExit(t)
path := writePipeline(t, minimalPipeline)
os.Args = []string{"glint", "check", path}
cmdCheck([]string{path})
if *code != -1 { t.Errorf("valid pipeline: want no exit, got %d", *code) }
}
func TestCmdCheck_NoArgs(t *testing.T) {
code := captureExit(t)
cmdCheck([]string{})
if *code != 2 { t.Errorf("no args: want exit(2), got %d", *code) }
}
func TestCmdCheck_InvalidFormat(t *testing.T) {
code := captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdCheck([]string{"--format", "badformat", path})
if *code != 2 { t.Errorf("bad format: want exit(2), got %d", *code) }
}
func TestCmdCheck_MissingFile(t *testing.T) {
code := captureExit(t)
cmdCheck([]string{"/nonexistent/pipeline.yml"})
if *code != 2 { t.Errorf("missing file: want exit(2), got %d", *code) }
}
func TestCmdCheck_WithErrors_ExitsOne(t *testing.T) {
code := captureExit(t)
// Pipeline with an error finding (invalid stage reference)
content := `
stages: [build]
test-job:
stage: nonexistent
script: echo
`
path := writePipeline(t, content)
cmdCheck([]string{path})
if *code != 1 { t.Errorf("pipeline with errors: want exit(1), got %d", *code) }
}
func TestCmdCheck_FormatJSON(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdCheck([]string{"--format", "json", path})
}
func TestCmdCheck_FormatSARIF(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdCheck([]string{"--format", "sarif", path})
}
func TestCmdCheck_FormatJUnit(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdCheck([]string{"--format", "junit", path})
}
func TestCmdCheck_FormatGitHub(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdCheck([]string{"--format", "github", path})
}
func TestCmdCheck_WithBranch(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdCheck([]string{"--branch", "develop", path})
}
func TestCmdCheck_WithTag(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdCheck([]string{"--tag", "v1.0.0", path})
}
func TestCmdCheck_WithSource(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdCheck([]string{"--source", "schedule", path})
}
func TestCmdCheck_WithVar(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdCheck([]string{"--var", "MY_VAR=hello", path})
}
func TestCmdCheck_ListVars(t *testing.T) {
captureExit(t)
content := `
stages: [build]
variables:
MY_VAR: hello
build-job:
stage: build
script: echo
`
path := writePipeline(t, content)
cmdCheck([]string{"--list-vars", "--branch", "main", path})
}
func TestCmdCheck_WithOffline(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdCheck([]string{"--offline", path})
}
func TestCmdCheck_WorkflowRulesExclude(t *testing.T) {
captureExit(t)
content := `
stages: [build]
workflow:
rules:
- if: '$CI_COMMIT_TAG'
when: always
- when: never
build-job:
stage: build
script: echo
`
path := writePipeline(t, content)
// branch push — workflow rule won't match a tag, so pipeline won't start
cmdCheck([]string{"--branch", "main", path})
}
// ── cmdGraph ─────────────────────────────────────────────────────────────────
func TestCmdGraph_NoArgs(t *testing.T) {
code := captureExit(t)
cmdGraph([]string{})
if *code != 2 { t.Errorf("no args: want exit(2), got %d", *code) }
}
func TestCmdGraph_Tree(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdGraph([]string{"tree", path})
}
func TestCmdGraph_Includes(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdGraph([]string{"includes", path})
}
func TestCmdGraph_Default(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdGraph([]string{path})
}
func TestCmdGraph_Pipeline(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
outDir := t.TempDir()
cmdGraph([]string{"pipeline", "--out", outDir, path})
}
func TestCmdGraph_All(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
outDir := t.TempDir()
cmdGraph([]string{"all", "--out", outDir, path})
}
func TestCmdGraph_MissingFile(t *testing.T) {
code := captureExit(t)
cmdGraph([]string{"/nonexistent.yml"})
if *code != 2 { t.Errorf("missing file: want exit(2), got %d", *code) }
}
func TestCmdGraph_WithBranch(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdGraph([]string{"tree", "--branch", "develop", path})
}
func TestCmdGraph_WithTag(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdGraph([]string{"tree", "--tag", "v1.0.0", path})
}
func TestCmdGraph_WithListVars(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdGraph([]string{"tree", "--list-vars", "--branch", "main", path})
}
func TestCmdGraph_Offline(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdGraph([]string{"tree", "--offline", path})
}
// ── cmdExplain ────────────────────────────────────────────────────────────────
func TestCmdExplain_NoArgs_ListsRules(t *testing.T) {
captureExit(t)
cmdExplain([]string{})
}
func TestCmdExplain_ValidRule(t *testing.T) {
captureExit(t)
cmdExplain([]string{"GL001"})
}
func TestCmdExplain_UnknownRule(t *testing.T) {
code := captureExit(t)
cmdExplain([]string{"GL999"})
if *code != 2 { t.Errorf("unknown rule: want exit(2), got %d", *code) }
}
func TestCmdExplain_LowercaseRule(t *testing.T) {
captureExit(t)
// lowercase should work (converted to upper inside)
cmdExplain([]string{"gl001"})
}
// ── defaultCacheDir ───────────────────────────────────────────────────────────
func TestDefaultCacheDir(t *testing.T) {
// Should return a non-empty string (either XDG or ~/.cache/glint)
got := defaultCacheDir()
if got == "" { t.Error("expected non-empty cache dir") }
}
func TestDefaultCacheDir_XDG(t *testing.T) {
orig := os.Getenv("XDG_CACHE_HOME")
os.Setenv("XDG_CACHE_HOME", "/tmp/xdg-cache")
defer os.Setenv("XDG_CACHE_HOME", orig)
got := defaultCacheDir()
if got != "/tmp/xdg-cache/glint" {
t.Errorf("XDG_CACHE_HOME: got %q want /tmp/xdg-cache/glint", got)
}
}
// ── multiFlag ────────────────────────────────────────────────────────────────
func TestMultiFlag(t *testing.T) {
var f multiFlag
if f.String() != "" { t.Error("empty: expected empty string") }
if err := f.Set("a"); err != nil { t.Fatal(err) }
if err := f.Set("b"); err != nil { t.Fatal(err) }
if f.String() != "a, b" { t.Errorf("got %q", f.String()) }
}
// ── printVars ─────────────────────────────────────────────────────────────────
func TestPrintVars(t *testing.T) {
p := &model.Pipeline{
Variables: map[string]any{"KEY": "val", "OTHER": map[string]any{"value": "v2"}},
Workflow: &model.Workflow{
Rules: []model.Rule{{Variables: map[string]any{"RULE_VAR": "x"}}},
},
}
ctx := cicontext.New("main", "", "", nil)
printVars(p, ctx) // should not panic
printVars(p, nil) // nil ctx: no context vars printed
}
func TestPrintVarMap(t *testing.T) {
printVarMap(nil) // nil map: prints (none)
printVarMap(map[string]any{}) // empty: prints (none)
printVarMap(map[string]any{"A": "1", "B": map[string]any{"value": "2"}})
printVarMap(map[string]any{"C": 42}) // non-scalar: (complex)
}
func TestVarValueString(t *testing.T) {
if varValueString("hello") != "hello" { t.Error("string") }
if varValueString(map[string]any{"value": "v"}) != "v" { t.Error("map with value") }
if varValueString(map[string]any{"other": "x"}) != "(complex)" { t.Error("complex") }
}
// ── enrichContext ─────────────────────────────────────────────────────────────
func TestEnrichContext(t *testing.T) {
p := &model.Pipeline{
Variables: map[string]any{"ENV": "prod"},
}
ctx := cicontext.New("main", "", "", nil)
runs := enrichContext(ctx, p)
if !runs { t.Error("expected pipeline to run on main branch with no workflow rules") }
if ctx.Get("ENV") != "prod" { t.Error("pipeline var should be injected") }
}
// ── printContext ──────────────────────────────────────────────────────────────
func TestPrintContext(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"active-job": {Name: "active-job", Script: []any{"echo"}, Stage: "build"},
"manual-job": {Name: "manual-job", Script: []any{"echo"}, When: "manual"},
"skipped-job": {Name: "skipped-job", Rules: []model.Rule{{If: `$CI_COMMIT_TAG != ""`, When: "on_success"}}},
".hidden": {Name: ".hidden"},
},
}
ctx := cicontext.New("main", "", "", nil)
printContext(p, ctx) // should not panic
}
// ── printJobGroup ─────────────────────────────────────────────────────────────
func TestPrintJobGroup(t *testing.T) {
printJobGroup("Active ", []string{"a", "b"}) // should not panic
printJobGroup("Empty ", []string{}) // empty: no output
}
// ── isSuppressed ─────────────────────────────────────────────────────────────
func TestIsSuppressed(t *testing.T) {
suppressions := map[string][]string{
"build-job": {"GL001", "GL002"},
"all-job": {"*"},
}
if isSuppressed("", "GL001", suppressions) { t.Error("empty job: not suppressed") }
if isSuppressed("build-job", "GL003", suppressions) { t.Error("GL003 not in list") }
if !isSuppressed("build-job", "GL001", suppressions) { t.Error("GL001 should be suppressed") }
if !isSuppressed("all-job", "GL042", suppressions) { t.Error("wildcard should suppress") }
if isSuppressed("unknown-job", "GL001", suppressions) { t.Error("unknown job: not suppressed") }
}