From 04f17f861689b3497bc9ea552e83ffe28188ff39 Mon Sep 17 00:00:00 2001 From: k3nny Date: Sun, 14 Jun 2026 22:03:46 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 11 + README.md | 2 +- cmd/glint/explain.go | 3 +- cmd/glint/main.go | 34 +- cmd/glint/main_test.go | 406 +++++++++++ internal/cicontext/context_test.go | 253 +++++++ internal/cicontext/eval.go | 3 - internal/cicontext/eval_test.go | 146 ++++ internal/cicontext/reachability_more_test.go | 262 +++++++ internal/fetcher/cache_test.go | 61 ++ internal/fetcher/gitlab_test.go | 253 +++++++ internal/graph/includes_test.go | 622 ++++++++++++++++ internal/graph/pipeline.go | 3 - internal/graph/pipeline_test.go | 120 ++++ internal/graph/render.go | 37 +- internal/graph/render_test.go | 347 +++++++++ internal/graph/tree_test.go | 154 ++++ internal/linter/if_reachability_test.go | 9 + internal/linter/inherit_completeness_test.go | 71 ++ internal/linter/keywords_test.go | 597 ++++++++++++++++ internal/linter/linter_test.go | 142 ++++ internal/linter/needs_test.go | 38 + internal/linter/variables_test.go | 35 + internal/model/parser_test.go | 168 +++++ internal/model/pipeline_test.go | 22 + internal/resolver/extends_test.go | 256 +++++++ internal/resolver/includes_test.go | 701 +++++++++++++++++++ 27 files changed, 4716 insertions(+), 40 deletions(-) create mode 100644 cmd/glint/main_test.go create mode 100644 internal/cicontext/context_test.go create mode 100644 internal/cicontext/reachability_more_test.go create mode 100644 internal/fetcher/cache_test.go create mode 100644 internal/fetcher/gitlab_test.go create mode 100644 internal/graph/includes_test.go create mode 100644 internal/graph/pipeline_test.go create mode 100644 internal/graph/render_test.go create mode 100644 internal/graph/tree_test.go create mode 100644 internal/linter/keywords_test.go create mode 100644 internal/linter/linter_test.go create mode 100644 internal/linter/needs_test.go create mode 100644 internal/model/parser_test.go create mode 100644 internal/model/pipeline_test.go create mode 100644 internal/resolver/extends_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e37851..ac86ef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project uses [Semantic Versioning](https://semver.org). +## [0.2.21] - 2026-06-14 + +### Changed + +- **Internal:** replaced all `os.Exit` calls in `cmd/glint` with an `exit` variable to enable unit testing without process termination; no behaviour change. +- **Internal:** removed unreachable code paths found during coverage analysis — dead guard in `cicontext.parseRegexLiteral`, unreachable `len(jobs) == 0` branch in `graph.Pipeline`, and the `skipWin` struct field / dead `continue` in `graph.convertToPNG`; `pipelineSVG` return type simplified from `(string, error)` to `string` as it never returned a non-nil error. No behaviour change. + +### Added + +- **Unit test suite** — comprehensive table-driven tests added across all packages (`cmd/glint`, `internal/cicontext`, `internal/fetcher`, `internal/graph`, `internal/linter`, `internal/model`, `internal/resolver`), reaching 98%+ statement coverage. + ## [0.2.20] - 2026-06-14 ### Added diff --git a/README.md b/README.md index 42ddfe3..5f78d51 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

License - Release + Release

> **Disclaimer:** This tool was built through iterative AI-assisted development with [Claude](https://claude.ai). It is experimental, incomplete, and not intended for production use. Coverage of GitLab CI keywords is best-effort and may lag behind GitLab's evolving spec. Use it at your own discretion — no correctness guarantees are made. Contributions and bug reports are welcome. diff --git a/cmd/glint/explain.go b/cmd/glint/explain.go index bafbe5a..ce46e52 100644 --- a/cmd/glint/explain.go +++ b/cmd/glint/explain.go @@ -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) } diff --git a/cmd/glint/main.go b/cmd/glint/main.go index ba71e14..d3d6aa8 100644 --- a/cmd/glint/main.go +++ b/cmd/glint/main.go @@ -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 --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) } diff --git a/cmd/glint/main_test.go b/cmd/glint/main_test.go new file mode 100644 index 0000000..eb8a6fc --- /dev/null +++ b/cmd/glint/main_test.go @@ -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") } +} diff --git a/internal/cicontext/context_test.go b/internal/cicontext/context_test.go new file mode 100644 index 0000000..137b025 --- /dev/null +++ b/internal/cicontext/context_test.go @@ -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) + } + }) + } +} diff --git a/internal/cicontext/eval.go b/internal/cicontext/eval.go index 8cd24b2..3e6bbb1 100644 --- a/internal/cicontext/eval.go +++ b/internal/cicontext/eval.go @@ -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) { diff --git a/internal/cicontext/eval_test.go b/internal/cicontext/eval_test.go index 58c181d..200c1cb 100644 --- a/internal/cicontext/eval_test.go +++ b/internal/cicontext/eval_test.go @@ -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{ diff --git a/internal/cicontext/reachability_more_test.go b/internal/cicontext/reachability_more_test.go new file mode 100644 index 0000000..b1ccbeb --- /dev/null +++ b/internal/cicontext/reachability_more_test.go @@ -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'") + } +} diff --git a/internal/fetcher/cache_test.go b/internal/fetcher/cache_test.go new file mode 100644 index 0000000..185eae0 --- /dev/null +++ b/internal/fetcher/cache_test.go @@ -0,0 +1,61 @@ +package fetcher + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCachePath(t *testing.T) { + p1 := cachePath("/tmp/cache", "key1") + p2 := cachePath("/tmp/cache", "key2") + if p1 == p2 { + t.Error("different keys should produce different paths") + } + if filepath.Dir(p1) != "/tmp/cache" { + t.Errorf("expected /tmp/cache dir, got %q", filepath.Dir(p1)) + } +} + +func TestCacheReadMiss(t *testing.T) { + // empty dir → miss + data, ok := cacheRead("", "key") + if ok || data != nil { + t.Error("empty cacheDir should always miss") + } + + // nonexistent entry → miss + data, ok = cacheRead(t.TempDir(), "nonexistent-key") + if ok || data != nil { + t.Error("missing cache entry should miss") + } +} + +func TestCacheWriteAndRead(t *testing.T) { + dir := t.TempDir() + key := "https://example.com/template.yml" + content := []byte("stages: [build]") + + cacheWrite(dir, key, content) + + got, ok := cacheRead(dir, key) + if !ok { + t.Fatal("expected cache hit after write") + } + if string(got) != string(content) { + t.Errorf("cached content mismatch: got %q want %q", got, content) + } +} + +func TestCacheWrite_EmptyDir(t *testing.T) { + // Should silently do nothing when dir is empty string. + cacheWrite("", "key", []byte("data")) +} + +func TestCacheWrite_MkdirAll(t *testing.T) { + dir := filepath.Join(t.TempDir(), "sub", "dir") + cacheWrite(dir, "k", []byte("v")) + if _, err := os.Stat(dir); err != nil { + t.Errorf("directory not created: %v", err) + } +} diff --git a/internal/fetcher/gitlab_test.go b/internal/fetcher/gitlab_test.go new file mode 100644 index 0000000..532bed1 --- /dev/null +++ b/internal/fetcher/gitlab_test.go @@ -0,0 +1,253 @@ +package fetcher + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" +) + +// ── firstNonEmpty ───────────────────────────────────────────────────────────── + +func TestFirstNonEmpty(t *testing.T) { + if firstNonEmpty("", "", "c") != "c" { t.Error("should return first non-empty") } + if firstNonEmpty("a", "b") != "a" { t.Error("should return first") } + if firstNonEmpty("", "") != "" { t.Error("all empty → empty") } +} + +// ── AutoConfig ──────────────────────────────────────────────────────────────── + +func TestAutoConfig(t *testing.T) { + // Clear relevant env vars + for _, k := range []string{"CI_SERVER_URL", "GITLAB_URL", "GITLAB_TOKEN", "CI_JOB_TOKEN", "GITLAB_PRIVATE_TOKEN"} { + os.Unsetenv(k) + } + cfg := AutoConfig() + if cfg.BaseURL != "https://gitlab.com" { + t.Errorf("default BaseURL: %q", cfg.BaseURL) + } + if cfg.Token != "" { + t.Error("expected no token") + } +} + +func TestAutoConfig_EnvVars(t *testing.T) { + os.Setenv("CI_SERVER_URL", "https://my.gitlab.example.com/") + os.Setenv("GITLAB_TOKEN", "glpat-test") + defer func() { + os.Unsetenv("CI_SERVER_URL") + os.Unsetenv("GITLAB_TOKEN") + }() + cfg := AutoConfig() + if cfg.BaseURL != "https://my.gitlab.example.com" { + t.Errorf("trailing slash not trimmed: %q", cfg.BaseURL) + } + if cfg.Token != "glpat-test" || cfg.Source != TokenPrivate { + t.Errorf("token: %q source: %v", cfg.Token, cfg.Source) + } +} + +func TestAutoConfig_CIJobToken(t *testing.T) { + os.Unsetenv("GITLAB_TOKEN") + os.Setenv("CI_JOB_TOKEN", "job-token-123") + defer os.Unsetenv("CI_JOB_TOKEN") + cfg := AutoConfig() + if cfg.Token != "job-token-123" || cfg.Source != TokenJobToken { + t.Errorf("unexpected: %+v", cfg) + } +} + +func TestAutoConfig_PrivateToken(t *testing.T) { + os.Unsetenv("GITLAB_TOKEN") + os.Unsetenv("CI_JOB_TOKEN") + os.Setenv("GITLAB_PRIVATE_TOKEN", "legacy-token") + defer os.Unsetenv("GITLAB_PRIVATE_TOKEN") + cfg := AutoConfig() + if cfg.Token != "legacy-token" || cfg.Source != TokenPrivate { + t.Errorf("unexpected: %+v", cfg) + } +} + +func TestAutoConfig_GitlabURL(t *testing.T) { + os.Unsetenv("CI_SERVER_URL") + os.Setenv("GITLAB_URL", "https://gl.local") + defer os.Unsetenv("GITLAB_URL") + cfg := AutoConfig() + if cfg.BaseURL != "https://gl.local" { + t.Errorf("unexpected BaseURL: %q", cfg.BaseURL) + } +} + +// ── WithOverrides ───────────────────────────────────────────────────────────── + +func TestWithOverrides(t *testing.T) { + base := GitLabConfig{BaseURL: "https://gitlab.com", Token: "old", Source: TokenPrivate} + got := base.WithOverrides("https://other.com/", "new-token", "/cache", true) + if got.BaseURL != "https://other.com" { t.Errorf("BaseURL: %q", got.BaseURL) } + if got.Token != "new-token" { t.Error("Token not overridden") } + if got.CacheDir != "/cache" { t.Error("CacheDir not set") } + if !got.Offline { t.Error("Offline not set") } + // empty string leaves BaseURL alone + got2 := base.WithOverrides("", "", "", false) + if got2.BaseURL != "https://gitlab.com" { t.Error("empty override should not change BaseURL") } +} + +// ── ForHost ─────────────────────────────────────────────────────────────────── + +func TestForHost(t *testing.T) { + cfg := GitLabConfig{BaseURL: "https://gitlab.com"} + got := cfg.ForHost("my-host.example.com") + if got.BaseURL != "https://my-host.example.com" { + t.Errorf("unexpected BaseURL: %q", got.BaseURL) + } +} + +// ── HasToken ────────────────────────────────────────────────────────────────── + +func TestHasToken(t *testing.T) { + if (GitLabConfig{}).HasToken() { t.Error("empty config should have no token") } + if !(GitLabConfig{Token: "x"}).HasToken() { t.Error("config with token should have token") } +} + +// ── FetchFile ───────────────────────────────────────────────────────────────── + +func TestFetchFile_FromCache(t *testing.T) { + dir := t.TempDir() + key := "https://gitlab.com|my/project|/templates/ci.yml|HEAD" + cacheWrite(dir, key, []byte("cached: true")) + cfg := GitLabConfig{BaseURL: "https://gitlab.com", CacheDir: dir} + data, err := cfg.FetchFile("my/project", "/templates/ci.yml", "") + if err != nil { t.Fatalf("unexpected error: %v", err) } + if string(data) != "cached: true" { t.Errorf("got %q", data) } +} + +func TestFetchFile_Offline_Miss(t *testing.T) { + cfg := GitLabConfig{BaseURL: "https://gitlab.com", Offline: true} + _, err := cfg.FetchFile("p/q", "/f.yml", "main") + if err == nil { t.Fatal("expected error in offline mode") } +} + +func TestFetchFile_OK(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("script: echo ok")) + })) + defer srv.Close() + cfg := GitLabConfig{BaseURL: srv.URL, Token: "tok", Source: TokenPrivate} + data, err := cfg.FetchFile("ns/proj", "/ci.yml", "main") + if err != nil { t.Fatalf("unexpected error: %v", err) } + if string(data) != "script: echo ok" { t.Errorf("got %q", data) } +} + +func TestFetchFile_Unauthorized_NoToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + cfg := GitLabConfig{BaseURL: srv.URL} + _, err := cfg.FetchFile("p/q", "/f.yml", "main") + if err == nil { t.Fatal("expected error") } +} + +func TestFetchFile_Unauthorized_WithToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + cfg := GitLabConfig{BaseURL: srv.URL, Token: "tok", Source: TokenPrivate} + _, err := cfg.FetchFile("p/q", "/f.yml", "main") + if err == nil { t.Fatal("expected error") } +} + +func TestFetchFile_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("not found")) + })) + defer srv.Close() + cfg := GitLabConfig{BaseURL: srv.URL} + _, err := cfg.FetchFile("p/q", "/f.yml", "main") + if err == nil { t.Fatal("expected error") } +} + +func TestFetchFile_OtherStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer srv.Close() + cfg := GitLabConfig{BaseURL: srv.URL} + _, err := cfg.FetchFile("p/q", "/f.yml", "main") + if err == nil { t.Fatal("expected error") } +} + +func TestFetchFile_JobToken(t *testing.T) { + var gotHeader string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeader = r.Header.Get("JOB-TOKEN") + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + })) + defer srv.Close() + cfg := GitLabConfig{BaseURL: srv.URL, Token: "ci-job-tok", Source: TokenJobToken} + cfg.FetchFile("p/q", "/f.yml", "main") + if gotHeader != "ci-job-tok" { + t.Errorf("JOB-TOKEN header not sent, got %q", gotHeader) + } +} + +func TestFetchFile_WritesToCache(t *testing.T) { + dir := t.TempDir() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("stage: build")) + })) + defer srv.Close() + cfg := GitLabConfig{BaseURL: srv.URL, CacheDir: dir} + _, err := cfg.FetchFile("p/q", "/ci.yml", "main") + if err != nil { t.Fatal(err) } + // Second call should hit cache + data, ok := cacheRead(dir, srv.URL+"|p/q|/ci.yml|main") + if !ok { t.Fatal("cache miss after fetch") } + if string(data) != "stage: build" { t.Errorf("unexpected cache content: %q", data) } +} + +// ── FetchURL ────────────────────────────────────────────────────────────────── + +func TestFetchURL_OK(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("remote: content")) + })) + defer srv.Close() + cfg := GitLabConfig{BaseURL: srv.URL} + data, err := cfg.FetchURL(srv.URL + "/template.yml") + if err != nil { t.Fatalf("unexpected error: %v", err) } + if string(data) != "remote: content" { t.Errorf("got %q", data) } +} + +func TestFetchURL_FromCache(t *testing.T) { + dir := t.TempDir() + rawURL := "https://example.com/tmpl.yml" + cacheWrite(dir, rawURL, []byte("cached")) + cfg := GitLabConfig{CacheDir: dir} + data, err := cfg.FetchURL(rawURL) + if err != nil { t.Fatal(err) } + if string(data) != "cached" { t.Errorf("got %q", data) } +} + +func TestFetchURL_Offline(t *testing.T) { + cfg := GitLabConfig{Offline: true} + _, err := cfg.FetchURL("https://example.com/tmpl.yml") + if err == nil { t.Fatal("expected error in offline mode") } +} + +func TestFetchURL_NotOK(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + cfg := GitLabConfig{} + _, err := cfg.FetchURL(srv.URL) + if err == nil { t.Fatal("expected error for non-200 status") } +} diff --git a/internal/graph/includes_test.go b/internal/graph/includes_test.go new file mode 100644 index 0000000..537d10a --- /dev/null +++ b/internal/graph/includes_test.go @@ -0,0 +1,622 @@ +package graph + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "git.k3nny.fr/glint/internal/fetcher" + "git.k3nny.fr/glint/internal/model" +) + +// ── mermaidLabel ────────────────────────────────────────────────────────────── + +func TestMermaidLabel(t *testing.T) { + cases := []struct{ in, want string }{ + {"plain", "plain"}, + {`has "quotes"`, "has 'quotes'"}, + {"has#hash", "has#hash"}, + {`"quoted"#both`, "'quoted'#both"}, + } + for _, tc := range cases { + got := mermaidLabel(tc.in) + if got != tc.want { + t.Errorf("mermaidLabel(%q)=%q want %q", tc.in, got, tc.want) + } + } +} + +// ── includeFileList ─────────────────────────────────────────────────────────── + +func TestIncludeFileList(t *testing.T) { + if got := includeFileList("file.yml"); len(got) != 1 || got[0] != "file.yml" { + t.Error("string form") + } + if got := includeFileList([]any{"a.yml", "b.yml"}); len(got) != 2 { + t.Error("slice form") + } + if got := includeFileList([]any{"ok.yml", 42}); len(got) != 1 { + t.Error("mixed slice drops non-strings") + } + if got := includeFileList(nil); got != nil { + t.Error("nil should return nil") + } + if got := includeFileList(123); got != nil { + t.Error("unknown type returns nil") + } +} + +// ── parseComponentRef ───────────────────────────────────────────────────────── + +func TestParseComponentRef(t *testing.T) { + cases := []struct { + ref string + wantHost string + wantProj string + wantComp string + wantVer string + wantErr bool + }{ + { + ref: "gitlab.com/group/project/component@v1.0", + wantHost: "gitlab.com", wantProj: "group/project", wantComp: "component", wantVer: "v1.0", + }, + { + ref: "gitlab.com/a/b/c/d@main", + wantHost: "gitlab.com", wantProj: "a/b/c", wantComp: "d", wantVer: "main", + }, + {ref: "no-at-sign", wantErr: true}, + {ref: "no-at-sign@", wantErr: true}, + {ref: "host/comp@v1", wantErr: true}, // only 2 parts — need at least 3 + } + for _, tc := range cases { + host, proj, comp, ver, err := parseComponentRef(tc.ref) + if tc.wantErr { + if err == nil { t.Errorf("parseComponentRef(%q): expected error", tc.ref) } + continue + } + if err != nil { t.Errorf("parseComponentRef(%q): %v", tc.ref, err) } + if host != tc.wantHost { t.Errorf("host: got %q want %q", host, tc.wantHost) } + if proj != tc.wantProj { t.Errorf("project: got %q want %q", proj, tc.wantProj) } + if comp != tc.wantComp { t.Errorf("component: got %q want %q", comp, tc.wantComp) } + if ver != tc.wantVer { t.Errorf("version: got %q want %q", ver, tc.wantVer) } + } +} + +// ── jobNames ────────────────────────────────────────────────────────────────── + +func TestJobNames_Empty(t *testing.T) { + p := &model.Pipeline{Jobs: map[string]model.Job{}} + if got := jobNames(p); got != nil { + t.Errorf("empty pipeline: expected nil, got %v", got) + } +} + +// ── treeBuilder helpers ─────────────────────────────────────────────────────── + +func TestNextID(t *testing.T) { + b := &treeBuilder{visited: map[string]bool{}} + if b.nextID() != "inc1" { t.Error("first ID") } + if b.nextID() != "inc2" { t.Error("second ID") } +} + +func TestParseEntry_String(t *testing.T) { + b := &treeBuilder{visited: map[string]bool{}, baseDir: "/tmp"} + nodes := b.parseEntry("some/local.yml") + if len(nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(nodes)) } + if !strings.Contains(nodes[0].label, "local:") { t.Error("expected local: label") } +} + +func TestParseEntry_Unknown(t *testing.T) { + b := &treeBuilder{visited: map[string]bool{}} + nodes := b.parseEntry(42) // unknown type + if len(nodes) != 0 { t.Error("expected no nodes for unknown type") } +} + +func TestParseMap_Template(t *testing.T) { + b := &treeBuilder{visited: map[string]bool{}} + nodes := b.parseMap(map[string]any{"template": "Auto-DevOps"}) + if len(nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(nodes)) } + if !strings.Contains(nodes[0].label, "template:") { t.Error("expected template: label") } +} + +func TestParseMap_Remote(t *testing.T) { + b := &treeBuilder{visited: map[string]bool{}, cfg: fetcher.GitLabConfig{}} + nodes := b.parseMap(map[string]any{"remote": "https://example.com/ci.yml"}) + if len(nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(nodes)) } + if !strings.Contains(nodes[0].label, "remote:") { t.Error("expected remote: label") } +} + +func TestParseMap_Project_NoFiles(t *testing.T) { + b := &treeBuilder{visited: map[string]bool{}, cfg: fetcher.GitLabConfig{}} + nodes := b.parseMap(map[string]any{"project": "g/p"}) + if len(nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(nodes)) } + if !strings.Contains(nodes[0].label, "project:") { t.Error("expected project: label") } +} + +func TestParseMap_Project_WithRef(t *testing.T) { + b := &treeBuilder{visited: map[string]bool{}, cfg: fetcher.GitLabConfig{}} + nodes := b.parseMap(map[string]any{ + "project": "g/p", + "ref": "main", + "file": "templates/ci.yml", + }) + if len(nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(nodes)) } +} + +func TestParseMap_Project_MultipleFiles(t *testing.T) { + b := &treeBuilder{visited: map[string]bool{}, cfg: fetcher.GitLabConfig{}} + nodes := b.parseMap(map[string]any{ + "project": "g/p", + "file": []any{"a.yml", "b.yml"}, + }) + if len(nodes) != 2 { t.Fatalf("expected 2 nodes, got %d", len(nodes)) } +} + +func TestParseMap_Component_WithDollar(t *testing.T) { + b := &treeBuilder{visited: map[string]bool{}} + // Component ref with $ — skipped (dynamic, can't resolve) + nodes := b.parseMap(map[string]any{"component": "${CI_SERVER_HOST}/group/project/comp@v1"}) + if len(nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(nodes)) } + if nodes[0].jobs != nil { t.Error("jobs should be nil for unresolvable component") } +} + +func TestParseMap_Component_BadRef(t *testing.T) { + b := &treeBuilder{visited: map[string]bool{}, cfg: fetcher.GitLabConfig{}} + nodes := b.parseMap(map[string]any{"component": "no-at-sign"}) + if len(nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(nodes)) } +} + +func TestParseMap_Unknown(t *testing.T) { + b := &treeBuilder{visited: map[string]bool{}} + nodes := b.parseMap(map[string]any{"unknown_key": "value"}) + if len(nodes) != 0 { t.Error("expected no nodes for unknown map keys") } +} + +// ── recurseLocal ────────────────────────────────────────────────────────────── + +func TestRecurseLocal_FileNotFound(t *testing.T) { + b := &treeBuilder{visited: map[string]bool{}, baseDir: "/nonexistent"} + node := &treeNode{id: "n1"} + b.recurseLocal(node, "missing.yml") + if node.jobs != nil { t.Error("jobs should remain nil on missing file") } +} + +func TestRecurseLocal_ValidFile(t *testing.T) { + dir := t.TempDir() + ciPath := filepath.Join(dir, "ci.yml") + if err := os.WriteFile(ciPath, []byte("job1:\n script: echo\n"), 0o644); err != nil { + t.Fatal(err) + } + b := &treeBuilder{visited: map[string]bool{}, baseDir: dir} + node := &treeNode{id: "n1"} + b.recurseLocal(node, "ci.yml") + if len(node.jobs) == 0 { t.Error("expected jobs to be populated") } +} + +func TestRecurseLocal_VisitedPrevents_Loop(t *testing.T) { + dir := t.TempDir() + ciPath := filepath.Join(dir, "ci.yml") + if err := os.WriteFile(ciPath, []byte("job1:\n script: echo\n"), 0o644); err != nil { + t.Fatal(err) + } + b := &treeBuilder{ + visited: map[string]bool{"local:" + ciPath: true}, + baseDir: dir, + } + node := &treeNode{id: "n1"} + b.recurseLocal(node, "ci.yml") + if len(node.jobs) != 0 { t.Error("visited node should not be parsed again") } +} + +func TestRecurseLocal_WithNestedIncludes(t *testing.T) { + dir := t.TempDir() + // parent.yml includes child.yml + childPath := filepath.Join(dir, "child.yml") + if err := os.WriteFile(childPath, []byte("child-job:\n script: echo\n"), 0o644); err != nil { + t.Fatal(err) + } + parentPath := filepath.Join(dir, "parent.yml") + parentContent := "include:\n - local: child.yml\nparent-job:\n script: echo\n" + if err := os.WriteFile(parentPath, []byte(parentContent), 0o644); err != nil { + t.Fatal(err) + } + b := &treeBuilder{visited: map[string]bool{}, baseDir: dir} + node := &treeNode{id: "n1"} + b.recurseLocal(node, "parent.yml") + if len(node.children) == 0 { t.Error("expected children from nested include") } +} + +func TestRecurseLocal_InvalidYAML(t *testing.T) { + dir := t.TempDir() + ciPath := filepath.Join(dir, "bad.yml") + if err := os.WriteFile(ciPath, []byte(":\tbad\tyaml\n"), 0o644); err != nil { + t.Fatal(err) + } + b := &treeBuilder{visited: map[string]bool{}, baseDir: dir} + node := &treeNode{id: "n1"} + b.recurseLocal(node, "bad.yml") + if node.jobs != nil { t.Error("invalid YAML should not set jobs") } +} + +// ── directJobs ──────────────────────────────────────────────────────────────── + +func TestDirectJobs_MissingFile(t *testing.T) { + if jobs := directJobs("/nonexistent/ci.yml"); jobs != nil { + t.Error("expected nil for missing file") + } +} + +func TestDirectJobs_ValidFile(t *testing.T) { + dir := t.TempDir() + ciPath := filepath.Join(dir, "ci.yml") + if err := os.WriteFile(ciPath, []byte("build:\n script: make\n"), 0o644); err != nil { + t.Fatal(err) + } + jobs := directJobs(ciPath) + if len(jobs) == 0 { t.Error("expected jobs from valid file") } +} + +// ── renderTree ──────────────────────────────────────────────────────────────── + +func TestRenderTree_Basic(t *testing.T) { + root := &treeNode{ + id: "root", + label: "main.yml", + class: "main", + jobs: []string{"job-a", "job-b"}, + children: []*treeNode{ + {id: "inc1", label: "local: child.yml", class: "local"}, + }, + } + out := renderTree(root) + if !strings.Contains(out, "main.yml") { t.Error("expected root label") } + if !strings.Contains(out, "job-a") { t.Error("expected job-a") } + if !strings.Contains(out, "child.yml") { t.Error("expected child label") } + if !strings.Contains(out, "root --> inc1") { t.Error("expected edge root→inc1") } +} + +// ── Includes (integration) ──────────────────────────────────────────────────── + +func TestIncludes_LocalFile(t *testing.T) { + dir := t.TempDir() + + childPath := filepath.Join(dir, "child.yml") + if err := os.WriteFile(childPath, []byte("child-job:\n script: echo\n"), 0o644); err != nil { + t.Fatal(err) + } + + sourcePath := filepath.Join(dir, "pipeline.yml") + rawIncludes := []any{map[string]any{"local": "child.yml"}} + + out := Includes(sourcePath, rawIncludes, fetcher.GitLabConfig{}) + if !strings.Contains(out, "local:") { t.Error("expected local: node") } +} + +func TestIncludes_NoIncludes(t *testing.T) { + dir := t.TempDir() + sourcePath := filepath.Join(dir, "pipeline.yml") + out := Includes(sourcePath, nil, fetcher.GitLabConfig{}) + if !strings.Contains(out, "flowchart") { t.Error("expected flowchart") } +} + +// ── fetchComponentFile (graph package) ──────────────────────────────────────── + +func TestGraphFetchComponentFile_PrimarySuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + fmt.Fprintln(w, "comp-job:\n script: echo") + })) + defer srv.Close() + cfg := fetcher.GitLabConfig{BaseURL: srv.URL} + data, err := fetchComponentFile(cfg, "g/p", "mycomp", "v1") + if err != nil { + t.Fatalf("primary success: unexpected error: %v", err) + } + if len(data) == 0 { + t.Error("expected non-empty data") + } +} + +// ── recurseRemote ───────────────────────────────────────────────────────────── + +func TestRecurseRemote_AlreadyVisited(t *testing.T) { + b := &treeBuilder{ + visited: map[string]bool{"remote:http://example.com/ci.yml": true}, + cfg: fetcher.GitLabConfig{}, + baseDir: "/tmp", + } + node := &treeNode{id: "n1"} + b.recurseRemote(node, "http://example.com/ci.yml") + // Visited: nothing should be fetched or added. +} + +func TestRecurseRemote_ValidWithJobs(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + fmt.Fprintln(w, "remote-job:\n script: echo") + })) + defer srv.Close() + + b := &treeBuilder{ + visited: map[string]bool{}, + cfg: fetcher.GitLabConfig{}, + baseDir: "/tmp", + } + node := &treeNode{id: "n1"} + b.recurseRemote(node, srv.URL+"/ci.yml") + if len(node.jobs) == 0 { + t.Error("expected job names from remote YAML") + } +} + +func TestRecurseRemote_InvalidYAML(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + fmt.Fprintln(w, ":\tbad\tyaml") + })) + defer srv.Close() + + b := &treeBuilder{ + visited: map[string]bool{}, + cfg: fetcher.GitLabConfig{}, + baseDir: "/tmp", + } + node := &treeNode{id: "n1"} + b.recurseRemote(node, srv.URL+"/bad.yml") + // ParseBytes fails → early return; no panic. +} + +func TestRecurseRemote_WithSubIncludes(t *testing.T) { + // The remote YAML includes a second URL (sub-includes path). + var callCount int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.WriteHeader(200) + if callCount == 1 { + // First call: returns YAML with a remote sub-include. + fmt.Fprintln(w, "include:\n - remote: http://127.0.0.1:0/sub.yml\nparent-job:\n script: echo") + } else { + // Sub-include fetch will fail (connection refused) — that's OK; we + // just need the sub-include routing path to be executed. + w.WriteHeader(500) + } + })) + defer srv.Close() + + b := &treeBuilder{ + visited: map[string]bool{}, + cfg: fetcher.GitLabConfig{}, + baseDir: "/tmp", + counter: 0, + } + node := &treeNode{id: "n1"} + b.recurseRemote(node, srv.URL+"/ci.yml") + // parent-job should be in node.jobs even if sub-include fails. + if len(node.jobs) == 0 { + t.Error("expected job names from remote YAML") + } +} + +// ── recurseComponent ───────────────────────────────────────────────────────── + +// tlsHost returns a helper that creates a TLS test server, swaps http.DefaultTransport +// so the test client trusts the self-signed cert, and returns (close, host). +func tlsComponent(t *testing.T, handler http.HandlerFunc) (host string) { + t.Helper() + srv := httptest.NewTLSServer(handler) + t.Cleanup(srv.Close) + orig := http.DefaultTransport + http.DefaultTransport = srv.Client().Transport + t.Cleanup(func() { http.DefaultTransport = orig }) + return srv.URL[len("https://"):] +} + +func TestRecurseComponent_AlreadyVisited(t *testing.T) { + b := &treeBuilder{ + visited: map[string]bool{"component:gitlab.com/g/p/comp@v1": true}, + cfg: fetcher.GitLabConfig{}, + baseDir: "/tmp", + } + node := &treeNode{id: "n1"} + b.recurseComponent(node, "gitlab.com/g/p/comp@v1") + // Already visited: nothing happens. +} + +func TestRecurseComponent_DollarRef(t *testing.T) { + // ref containing $ → early return (line 196-197) + b := &treeBuilder{visited: map[string]bool{}, cfg: fetcher.GitLabConfig{}} + node := &treeNode{id: "n1"} + b.recurseComponent(node, "$CI_SERVER/g/p/comp@v1") + // no panic, node unchanged +} + +func TestRecurseComponent_BadRef(t *testing.T) { + // ref with no @ → parseComponentRef fails (line 206-208) + b := &treeBuilder{visited: map[string]bool{}, cfg: fetcher.GitLabConfig{}} + node := &treeNode{id: "n1"} + b.recurseComponent(node, "gitlab.com/g/p/mycomp-no-version") + // no panic +} + +func TestRecurseComponent_FetchError(t *testing.T) { + host := tlsComponent(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + }) + b := &treeBuilder{ + visited: map[string]bool{}, + cfg: fetcher.GitLabConfig{}, + baseDir: "/tmp", + } + node := &treeNode{id: "n1"} + b.recurseComponent(node, host+"/g/p/mycomp@v1") + // 404 → fetch error → early return; no panic. +} + +func TestRecurseComponent_InvalidYAML(t *testing.T) { + host := tlsComponent(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + fmt.Fprintln(w, ":\tbad\tyaml") + }) + b := &treeBuilder{ + visited: map[string]bool{}, + cfg: fetcher.GitLabConfig{}, + baseDir: "/tmp", + } + node := &treeNode{id: "n1"} + b.recurseComponent(node, host+"/g/p/mycomp@v1") + // fetch succeeds but ParseBytes fails → early return; no panic. +} + +func TestRecurseComponent_ValidYAML(t *testing.T) { + host := tlsComponent(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + fmt.Fprintln(w, "comp-job:\n script: echo component") + }) + b := &treeBuilder{ + visited: map[string]bool{}, + cfg: fetcher.GitLabConfig{}, + baseDir: "/tmp", + } + node := &treeNode{id: "n1"} + b.recurseComponent(node, host+"/g/p/mycomp@v1") + if len(node.jobs) == 0 { + t.Error("expected job names from component YAML") + } +} + +// ── recurseProject ──────────────────────────────────────────────────────────── + +func TestRecurseProject_AlreadyVisited(t *testing.T) { + b := &treeBuilder{ + visited: map[string]bool{"project:g/p:ci.yml@main": true}, + cfg: fetcher.GitLabConfig{Token: "tok"}, + baseDir: "/tmp", + } + node := &treeNode{id: "n1"} + b.recurseProject(node, "g/p", "ci.yml", "main") + // Already visited: nothing happens. +} + +func TestRecurseProject_WithToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + fmt.Fprintln(w, "proj-job:\n script: echo project") + })) + defer srv.Close() + + b := &treeBuilder{ + visited: map[string]bool{}, + cfg: fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"}, + baseDir: "/tmp", + } + node := &treeNode{id: "n1"} + b.recurseProject(node, "g/p", "ci.yml", "main") + if len(node.jobs) == 0 { + t.Error("expected job names from project YAML") + } +} + +func TestRecurseProject_FetchError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + })) + defer srv.Close() + + b := &treeBuilder{ + visited: map[string]bool{}, + cfg: fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"}, + baseDir: "/tmp", + } + node := &treeNode{id: "n1"} + b.recurseProject(node, "g/p", "ci.yml", "main") + // fetch fails → early return; no panic. +} + +func TestRecurseProject_InvalidYAML(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + fmt.Fprintln(w, ":\tbad\tyaml") + })) + defer srv.Close() + + b := &treeBuilder{ + visited: map[string]bool{}, + cfg: fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"}, + baseDir: "/tmp", + } + node := &treeNode{id: "n1"} + b.recurseProject(node, "g/p", "ci.yml", "main") + // ParseBytes fails → early return; no panic. +} + +func TestRecurseProject_WithSubIncludes(t *testing.T) { + // Project YAML includes sub-includes → covers recurseProject line 170 (buildChildren). + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + fmt.Fprintln(w, "include:\n - remote: http://127.0.0.1:0/sub.yml\nproj-job:\n script: echo") + })) + defer srv.Close() + + b := &treeBuilder{ + visited: map[string]bool{}, + cfg: fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"}, + baseDir: "/tmp", + } + node := &treeNode{id: "n1"} + b.recurseProject(node, "g/p", "ci.yml", "main") + // proj-job should be populated; sub-include fetch fails (port 0) — that's fine. + if len(node.jobs) == 0 { + t.Error("expected job names from project YAML") + } +} + +func TestRecurseComponent_WithSubIncludes(t *testing.T) { + // Component YAML includes sub-includes → covers recurseComponent line 221 (buildChildren). + host := tlsComponent(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + fmt.Fprintln(w, "include:\n - remote: http://127.0.0.1:0/sub.yml\ncomp-job:\n script: echo") + }) + b := &treeBuilder{ + visited: map[string]bool{}, + cfg: fetcher.GitLabConfig{}, + baseDir: "/tmp", + } + node := &treeNode{id: "n1"} + b.recurseComponent(node, host+"/g/p/mycomp@v1") + if len(node.jobs) == 0 { + t.Error("expected job names from component YAML") + } +} + +// ── fetchComponentFile (fallback path) ─────────────────────────────────────── + +func TestGraphFetchComponentFile_FallbackSuccess(t *testing.T) { + // Primary path (templates/comp.yml) returns 404; fallback (templates/comp/template.yml) returns 200. + var reqCount int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reqCount++ + if reqCount == 1 { + w.WriteHeader(404) + } else { + w.WriteHeader(200) + fmt.Fprintln(w, "fallback-job:\n script: echo") + } + })) + defer srv.Close() + cfg := fetcher.GitLabConfig{BaseURL: srv.URL} + data, err := fetchComponentFile(cfg, "g/p", "mycomp", "v1") + if err != nil { + t.Fatalf("fallback success: unexpected error: %v", err) + } + if len(data) == 0 { + t.Error("expected non-empty data from fallback path") + } +} + +// Ensure model import is used. +var _ = model.Job{} diff --git a/internal/graph/pipeline.go b/internal/graph/pipeline.go index 5eaba08..9ba0800 100644 --- a/internal/graph/pipeline.go +++ b/internal/graph/pipeline.go @@ -82,9 +82,6 @@ func Pipeline(p *model.Pipeline) string { // Emit one subgraph per stage. for _, stage := range stages { jobs := byStage[stage] - if len(jobs) == 0 { - continue - } sort.Strings(jobs) wf(" subgraph %s[\"%s\"]", stageID(stage), stage) for _, name := range jobs { diff --git a/internal/graph/pipeline_test.go b/internal/graph/pipeline_test.go new file mode 100644 index 0000000..a4ff06c --- /dev/null +++ b/internal/graph/pipeline_test.go @@ -0,0 +1,120 @@ +package graph + +import ( + "strings" + "testing" + + "git.k3nny.fr/glint/internal/model" +) + +func TestPipeline_EmptyJobs(t *testing.T) { + p := &model.Pipeline{} + out := Pipeline(p) + if !strings.Contains(out, "no jobs defined") { + t.Errorf("expected 'no jobs defined', got:\n%s", out) + } +} + +func TestPipeline_BasicTwoStages(t *testing.T) { + p := &model.Pipeline{ + Stages: []string{"build", "test"}, + Jobs: map[string]model.Job{ + "build-job": {Name: "build-job", Stage: "build"}, + "test-job": {Name: "test-job", Stage: "test"}, + ".tmpl": {Name: ".tmpl", Stage: "build"}, // hidden — excluded + }, + } + out := Pipeline(p) + if !strings.Contains(out, "build-job") { t.Error("expected build-job") } + if !strings.Contains(out, "test-job") { t.Error("expected test-job") } + if strings.Contains(out, ".tmpl") { t.Error(".tmpl should be excluded") } + // Classic mode: stage → stage connector + if !strings.Contains(out, "Classic") { t.Error("expected classic mode comment") } +} + +func TestPipeline_DAGMode(t *testing.T) { + p := &model.Pipeline{ + Stages: []string{"build", "test"}, + Jobs: map[string]model.Job{ + "build-job": {Name: "build-job", Stage: "build"}, + "test-job": {Name: "test-job", Stage: "test", Needs: []any{"build-job"}}, + }, + } + out := Pipeline(p) + if !strings.Contains(out, "DAG") { t.Error("expected DAG mode comment") } +} + +func TestPipeline_DAGMode_CrossPipelineNeed(t *testing.T) { + p := &model.Pipeline{ + Stages: []string{"build"}, + Jobs: map[string]model.Job{ + "build-job": {Name: "build-job", Stage: "build", + Needs: []any{map[string]any{"pipeline": "other", "job": "j"}}}, + }, + } + out := Pipeline(p) + // cross-pipeline need should not add an arrow (job not in local map) + if !strings.Contains(out, "DAG") { t.Error("expected DAG mode comment") } +} + +func TestPipeline_JobNoStage(t *testing.T) { + p := &model.Pipeline{ + Jobs: map[string]model.Job{ + "myjob": {Name: "myjob"}, + }, + } + out := Pipeline(p) + if !strings.Contains(out, "myjob") { t.Error("expected myjob") } +} + +func TestPipeline_SingleStage(t *testing.T) { + // Single stage → no connector + p := &model.Pipeline{ + Stages: []string{"build"}, + Jobs: map[string]model.Job{ + "a": {Name: "a", Stage: "build"}, + }, + } + out := Pipeline(p) + if strings.Contains(out, "-->") { t.Error("single stage should not have connectors") } +} + +// ── stageID / sanitizeID ────────────────────────────────────────────────────── + +func TestStageID(t *testing.T) { + if stageID("build") != "stage_build" { t.Error("plain name") } + if stageID("my-stage") != "stage_my_stage" { t.Error("hyphens replaced") } +} + +func TestSanitizeID(t *testing.T) { + cases := []struct{ in, want string }{ + {"build", "build"}, + {"my-stage", "my_stage"}, + {"with space", "with_space"}, + {"ABC_123", "ABC_123"}, + } + for _, tc := range cases { + if sanitizeID(tc.in) != tc.want { + t.Errorf("sanitizeID(%q)=%q want %q", tc.in, sanitizeID(tc.in), tc.want) + } + } +} + +// ── jobClass ────────────────────────────────────────────────────────────────── + +func TestJobClass(t *testing.T) { + if jobClass(model.Job{When: "manual"}) != "manual" { t.Error("manual") } + if jobClass(model.Job{When: "delayed"}) != "delayed" { t.Error("delayed") } + if jobClass(model.Job{Trigger: "x"}) != "trigger" { t.Error("trigger") } + if jobClass(model.Job{}) != "regular" { t.Error("regular") } +} + +// ── needsJobName ────────────────────────────────────────────────────────────── + +func TestNeedsJobName(t *testing.T) { + if needsJobName("job-a") != "job-a" { t.Error("string form") } + if needsJobName(map[string]any{"job": "job-b"}) != "job-b" { t.Error("map form") } + if needsJobName(map[string]any{"pipeline": "other", "job": "j"}) != "" { t.Error("cross-pipeline should return empty") } + if needsJobName(map[string]any{"no-job": true}) != "" { t.Error("map without job key") } + if needsJobName(42) != "" { t.Error("unexpected type") } +} diff --git a/internal/graph/render.go b/internal/graph/render.go index 1b1fdcb..99d0dcb 100644 --- a/internal/graph/render.go +++ b/internal/graph/render.go @@ -37,10 +37,7 @@ func RenderPipeline(p *model.Pipeline, outDir string) (string, error) { return "", fmt.Errorf("creating output directory %s: %w", outDir, err) } - svg, err := pipelineSVG(p) - if err != nil { - return "", err - } + svg := pipelineSVG(p) ts := time.Now().Format("20060102-150405") svgPath := filepath.Join(outDir, "pipeline-"+ts+".svg") @@ -58,21 +55,19 @@ func RenderPipeline(p *model.Pipeline, outDir string) (string, error) { // convertToPNG tries rsvg-convert, Inkscape, magick, and convert (not on Windows). func convertToPNG(svgPath, pngPath string) bool { - type cand struct { - args []string - skipWin bool + candidates := [][]string{ + {"rsvg-convert", "--output", pngPath, svgPath}, + {"inkscape", "--export-filename=" + pngPath, svgPath}, + {"magick", svgPath, pngPath}, } - for _, c := range []cand{ - {[]string{"rsvg-convert", "--output", pngPath, svgPath}, false}, - {[]string{"inkscape", "--export-filename=" + pngPath, svgPath}, false}, - {[]string{"magick", svgPath, pngPath}, false}, - {[]string{"convert", svgPath, pngPath}, true}, - } { - if c.skipWin && runtime.GOOS == "windows" { - continue - } - if bin, err := exec.LookPath(c.args[0]); err == nil { - if exec.Command(bin, c.args[1:]...).Run() == nil { + // `convert` is the legacy ImageMagick name; skip on Windows where the name + // collides with the built-in FAT→NTFS converter. + if runtime.GOOS != "windows" { + candidates = append(candidates, []string{"convert", svgPath, pngPath}) + } + for _, args := range candidates { + if bin, err := exec.LookPath(args[0]); err == nil { + if exec.Command(bin, args[1:]...).Run() == nil { return true } } @@ -80,7 +75,7 @@ func convertToPNG(svgPath, pngPath string) bool { return false } -func pipelineSVG(p *model.Pipeline) (string, error) { +func pipelineSVG(p *model.Pipeline) string { // Collect visible (non-template) job names in sorted order. var visible []string for name := range p.Jobs { @@ -91,7 +86,7 @@ func pipelineSVG(p *model.Pipeline) (string, error) { sort.Strings(visible) if len(visible) == 0 { - return svgEmpty(), nil + return svgEmpty() } // Group by stage; fall back to "test" (GitLab default) when stage is unset. @@ -290,7 +285,7 @@ func pipelineSVG(p *model.Pipeline) (string, error) { } w(``) - return sb.String(), nil + return sb.String() } // drawChipIcon writes an SVG symbol inside the status circle to help identify diff --git a/internal/graph/render_test.go b/internal/graph/render_test.go new file mode 100644 index 0000000..36c08d5 --- /dev/null +++ b/internal/graph/render_test.go @@ -0,0 +1,347 @@ +package graph + +import ( + "os" + "strings" + "testing" + + "git.k3nny.fr/glint/internal/model" +) + +// ── svgEsc ──────────────────────────────────────────────────────────────────── + +func TestSvgEsc(t *testing.T) { + cases := []struct{ in, want string }{ + {"hello", "hello"}, + {"a&b", "a&b"}, + {"", "<tag>"}, + {"a&bd", "a&b<c>d"}, + } + for _, tc := range cases { + if got := svgEsc(tc.in); got != tc.want { + t.Errorf("svgEsc(%q)=%q want %q", tc.in, got, tc.want) + } + } +} + +// ── svgTrunc ────────────────────────────────────────────────────────────────── + +func TestSvgTrunc(t *testing.T) { + cases := []struct { + s string + maxLen int + want string + }{ + {"short", 20, "short"}, + {"exactly20charslong!", 20, "exactly20charslong!"}, + {"this-is-a-very-long-job-name", 20, "this-is-a-very-long…"}, + {"αβγδεζηθικλμνξο", 5, "αβγδ…"}, + } + for _, tc := range cases { + got := svgTrunc(tc.s, tc.maxLen) + if got != tc.want { + t.Errorf("svgTrunc(%q, %d)=%q want %q", tc.s, tc.maxLen, got, tc.want) + } + } +} + +// ── svgEmpty ────────────────────────────────────────────────────────────────── + +func TestSvgEmpty(t *testing.T) { + out := svgEmpty() + if !strings.Contains(out, " bezier curves + if !strings.Contains(svg, " connector in DAG mode") + } +} + +func TestPipelineSVG_DAGMode_StraightConnector(t *testing.T) { + // Two stages at same height → straight connector in classic mode + p := &model.Pipeline{ + Stages: []string{"a", "b"}, + Jobs: map[string]model.Job{ + "j1": {Name: "j1", Stage: "a"}, + "j2": {Name: "j2", Stage: "b"}, + }, + } + svg := pipelineSVG(p) + if !strings.Contains(svg, "`), 0o644) + // result is either true (converter found) or false (no converter) — just no panic + convertToPNG(svgPath, pngPath) +} + +func TestConvertToPNG_FakeConverter(t *testing.T) { + // Install a fake rsvg-convert that just creates the output file and exits 0. + // This exercises the "return true" branch (line 72) and os.Remove in RenderPipeline. + binDir := t.TempDir() + script := "#!/bin/sh\n# rsvg-convert --output \ncp \"$3\" \"$2\"\n" + fakeBin := binDir + "/rsvg-convert" + if err := os.WriteFile(fakeBin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + origPath := os.Getenv("PATH") + os.Setenv("PATH", binDir+":"+origPath) + t.Cleanup(func() { os.Setenv("PATH", origPath) }) + + svgDir := t.TempDir() + svgPath := svgDir + "/test.svg" + pngPath := svgDir + "/test.png" + if err := os.WriteFile(svgPath, []byte(``), 0o644); err != nil { + t.Fatal(err) + } + ok := convertToPNG(svgPath, pngPath) + if !ok { + t.Error("expected convertToPNG to return true with fake rsvg-convert") + } +} + +func TestRenderPipeline_PNGConversion(t *testing.T) { + // Verify the PNG path is returned (and SVG removed) when converter succeeds. + binDir := t.TempDir() + script := "#!/bin/sh\ncp \"$3\" \"$2\"\n" + if err := os.WriteFile(binDir+"/rsvg-convert", []byte(script), 0o755); err != nil { + t.Fatal(err) + } + origPath := os.Getenv("PATH") + os.Setenv("PATH", binDir+":"+origPath) + t.Cleanup(func() { os.Setenv("PATH", origPath) }) + + p := &model.Pipeline{ + Stages: []string{"build"}, + Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}}, + } + dir := t.TempDir() + path, err := RenderPipeline(p, dir) + if err != nil { + t.Fatalf("RenderPipeline: %v", err) + } + if !strings.HasSuffix(path, ".png") { + t.Errorf("expected .png output, got %q", path) + } +} + +// ── pipelineSVG coverage gaps ───────────────────────────────────────────────── + +func TestPipelineSVG_LShapedElbow(t *testing.T) { + // Classic mode with unequal stage sizes → different column heights → L-shaped elbow + p := &model.Pipeline{ + Stages: []string{"build", "test"}, + Jobs: map[string]model.Job{ + "j1": {Name: "j1", Stage: "build"}, + "j2": {Name: "j2", Stage: "test"}, + "j3": {Name: "j3", Stage: "test"}, + }, + } + svg := pipelineSVG(p) + if !strings.Contains(svg, " for L-shaped elbow connector") + } +} + +func TestPipelineSVG_DAGNeedNotInPositionMap(t *testing.T) { + // DAG mode: job needs a template job (starts with .) → dep not in position maps → continue + p := &model.Pipeline{ + Stages: []string{"build"}, + Jobs: map[string]model.Job{ + "j": {Name: "j", Stage: "build", Needs: []any{".hidden-template"}}, + ".hidden-template": {Name: ".hidden-template", Stage: "build"}, + }, + } + svg := pipelineSVG(p) + // Should render without panic; .hidden-template is not visible so no connector drawn. + if !strings.Contains(svg, "