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
+622
View File
@@ -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&#35;hash"},
{`"quoted"#both`, "'quoted'&#35;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{}
-3
View File
@@ -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 {
+120
View File
@@ -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") }
}
+16 -21
View File
@@ -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(`</svg>`)
return sb.String(), nil
return sb.String()
}
// drawChipIcon writes an SVG symbol inside the status circle to help identify
+347
View File
@@ -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&amp;b"},
{"<tag>", "&lt;tag&gt;"},
{"a&b<c>d", "a&amp;b&lt;c&gt;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, "<svg") { t.Error("expected <svg") }
if !strings.Contains(out, "no jobs defined") { t.Error("expected 'no jobs defined'") }
}
// ── chipColor ─────────────────────────────────────────────────────────────────
func TestChipColor(t *testing.T) {
if chipColor(model.Job{Trigger: "x"}) != "#6b4fbb" { t.Error("trigger color") }
if chipColor(model.Job{When: "manual"}) != "#fc6d26" { t.Error("manual color") }
if chipColor(model.Job{When: "delayed"}) != "#fca326" { t.Error("delayed color") }
if chipColor(model.Job{}) != "#1f75cb" { t.Error("regular color") }
}
// ── drawChipIcon ──────────────────────────────────────────────────────────────
func TestDrawChipIcon(t *testing.T) {
cases := []struct {
job model.Job
wantTag string
}{
{model.Job{Trigger: "x"}, "<polyline"},
{model.Job{When: "manual"}, "<polygon"},
{model.Job{When: "delayed"}, "<circle"},
{model.Job{}, "<polyline"},
}
for _, tc := range cases {
var sb strings.Builder
drawChipIcon(&sb, tc.job, 10, 10)
if !strings.Contains(sb.String(), tc.wantTag) {
t.Errorf("drawChipIcon(%q): want %s, got:\n%s", tc.job.When, tc.wantTag, sb.String())
}
}
}
// ── pipelineSVG ───────────────────────────────────────────────────────────────
func TestPipelineSVG_Empty(t *testing.T) {
svg := pipelineSVG(&model.Pipeline{})
if !strings.Contains(svg, "no jobs defined") {
t.Errorf("expected 'no jobs defined', got:\n%s", svg)
}
}
func TestPipelineSVG_ClassicMode(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"},
},
}
svg := pipelineSVG(p)
if !strings.Contains(svg, "<svg") { t.Error("expected <svg") }
if !strings.Contains(svg, "build-job") { t.Error("expected build-job") }
if !strings.Contains(svg, "test-job") { t.Error("expected test-job") }
// Classic mode connector
if !strings.Contains(svg, "<polyline") && !strings.Contains(svg, "<line") {
t.Error("expected connector in classic mode")
}
}
func TestPipelineSVG_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"}},
},
}
svg := pipelineSVG(p)
// DAG mode draws <path> bezier curves
if !strings.Contains(svg, "<path") {
t.Error("expected <path> connector in DAG mode")
}
}
func TestPipelineSVG_DAGMode_StraightConnector(t *testing.T) {
// Two stages at same height → straight <line> 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, "<svg") { t.Error("expected <svg") }
}
func TestPipelineSVG_AllJobTypes(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"deploy"},
Jobs: map[string]model.Job{
"manual-job": {Name: "manual-job", Stage: "deploy", When: "manual"},
"delayed-job": {Name: "delayed-job", Stage: "deploy", When: "delayed"},
"trigger-job": {Name: "trigger-job", Stage: "deploy", Trigger: "other/project"},
},
}
svg := pipelineSVG(p)
// Each type has its own color in the SVG
if !strings.Contains(svg, "#fc6d26") { t.Error("expected manual color") }
if !strings.Contains(svg, "#fca326") { t.Error("expected delayed color") }
if !strings.Contains(svg, "#6b4fbb") { t.Error("expected trigger color") }
}
func TestPipelineSVG_JobNoStage(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"myjob": {Name: "myjob"},
},
}
svg := pipelineSVG(p)
if !strings.Contains(svg, "myjob") { t.Error("expected myjob") }
}
func TestPipelineSVG_CrossPipelineNeed(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"j": {Name: "j", Stage: "build",
Needs: []any{map[string]any{"pipeline": "other", "job": "x"}}},
},
}
svg := pipelineSVG(p)
if !strings.Contains(svg, "<svg") { t.Error("expected <svg") }
}
// ── RenderPipeline ────────────────────────────────────────────────────────────
func TestRenderPipeline(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build"},
},
}
dir := t.TempDir()
path, err := RenderPipeline(p, dir)
if err != nil {
t.Fatalf("RenderPipeline: %v", err)
}
if _, statErr := os.Stat(path); statErr != nil {
t.Errorf("output file not found: %v", statErr)
}
}
func TestRenderPipeline_InvalidDir(t *testing.T) {
// Writing to a path under a file (not a dir) should error.
dir := t.TempDir()
filePath := dir + "/notadir"
if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
_, err := RenderPipeline(&model.Pipeline{}, filePath+"/nested")
if err == nil {
t.Error("expected error writing to path under a file")
}
}
func TestRenderPipeline_WriteFileFails(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("skipping as root — file permissions don't apply")
}
dir := t.TempDir()
if err := os.Chmod(dir, 0o555); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chmod(dir, 0o755) })
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}},
}
_, err := RenderPipeline(p, dir)
if err == nil {
t.Error("expected error when writing SVG to read-only directory")
}
}
// ── convertToPNG ─────────────────────────────────────────────────────────────
func TestRenderPipeline_SVGFallback(t *testing.T) {
// With no converters on PATH, RenderPipeline returns the SVG path (line 53).
origPath := os.Getenv("PATH")
os.Setenv("PATH", "")
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, ".svg") {
t.Errorf("expected .svg output when no converter available, got %q", path)
}
}
func TestConvertToPNG_NoConverter(t *testing.T) {
// In a test environment without rsvg-convert/inkscape/magick, returns false.
// We can't assert false because the CI machine might have one of them,
// but we can assert it doesn't panic.
dir := t.TempDir()
svgPath := dir + "/test.svg"
pngPath := dir + "/test.png"
_ = os.WriteFile(svgPath, []byte(`<svg xmlns="http://www.w3.org/2000/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 <out> <in>\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(`<svg xmlns="http://www.w3.org/2000/svg"/>`), 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, "<polyline") {
t.Error("expected <polyline> 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, "<svg") {
t.Error("expected valid SVG output")
}
}
+154
View File
@@ -0,0 +1,154 @@
package graph
import (
"strings"
"testing"
"git.k3nny.fr/glint/internal/cicontext"
"git.k3nny.fr/glint/internal/model"
)
func makeSimplePipeline() *model.Pipeline {
return &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"},
".template": {Name: ".template", Stage: "build"},
},
}
}
func TestTree_Basic(t *testing.T) {
p := makeSimplePipeline()
out := Tree(p, nil)
if !strings.Contains(out, "pipeline") {
t.Error("expected 'pipeline' root node")
}
if !strings.Contains(out, "build-job") {
t.Error("expected build-job in tree")
}
if !strings.Contains(out, "test-job") {
t.Error("expected test-job in tree")
}
// hidden template jobs should not appear
if strings.Contains(out, ".template") {
t.Error(".template job should be excluded from tree")
}
}
func TestTree_WithContext_Skipped(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"run-on-tag": {Name: "run-on-tag", Stage: "build", Rules: []model.Rule{
{If: `$CI_COMMIT_TAG != ""`, When: "on_success"},
}},
},
}
ctx := cicontext.New("main", "", "", nil)
out := Tree(p, ctx)
if !strings.Contains(out, "[skipped]") {
t.Errorf("expected [skipped] annotation, got:\n%s", out)
}
}
func TestTree_WithContext_Manual(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"deploy"},
Jobs: map[string]model.Job{
"deploy": {Name: "deploy", Stage: "deploy", When: "manual"},
},
}
ctx := cicontext.New("main", "", "", nil)
out := Tree(p, ctx)
if !strings.Contains(out, "[manual]") {
t.Errorf("expected [manual] annotation, got:\n%s", out)
}
}
func TestTree_NoContext_Annotations(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"manual-job": {Name: "manual-job", Stage: "build", When: "manual"},
"delayed-job": {Name: "delayed-job", Stage: "build", When: "delayed"},
"trigger-job": {Name: "trigger-job", Stage: "build", Trigger: "other/project"},
},
}
out := Tree(p, nil)
if !strings.Contains(out, "[manual]") { t.Error("expected [manual]") }
if !strings.Contains(out, "[delayed]") { t.Error("expected [delayed]") }
if !strings.Contains(out, "[trigger]") { t.Error("expected [trigger]") }
}
func TestTree_JobWithNoStage(t *testing.T) {
// Jobs without a stage should default to "test"
p := &model.Pipeline{
Stages: []string{"test"},
Jobs: map[string]model.Job{
"nostage": {Name: "nostage"},
},
}
out := Tree(p, nil)
if !strings.Contains(out, "test") { t.Error("expected default stage 'test'") }
}
func TestTree_UndeclaredStage(t *testing.T) {
// Job in a stage not listed in p.Stages — should still appear
p := &model.Pipeline{
Stages: []string{},
Jobs: map[string]model.Job{
"myjob": {Name: "myjob", Stage: "custom"},
},
}
out := Tree(p, nil)
if !strings.Contains(out, "myjob") { t.Error("expected myjob in output") }
}
// ── branchChars ───────────────────────────────────────────────────────────────
func TestBranchChars(t *testing.T) {
b, c := branchChars(true)
if b != "└── " || c != " " {
t.Errorf("last: branch=%q cont=%q", b, c)
}
b, c = branchChars(false)
if b != "├── " || c != "│ " {
t.Errorf("not-last: branch=%q cont=%q", b, c)
}
}
// ── jobLabel ─────────────────────────────────────────────────────────────────
func TestJobLabel(t *testing.T) {
job := model.Job{Name: "j"}
if jobLabel(job, "j", nil) != "j" {
t.Error("plain job should have plain label")
}
// Multiple tags
job2 := model.Job{Name: "j2", When: "manual", Trigger: "other"}
label := jobLabel(job2, "j2", nil)
if !strings.Contains(label, "manual") || !strings.Contains(label, "trigger") {
t.Errorf("expected manual+trigger in label: %q", label)
}
// With context — active job has no annotation
emptyCtx := &cicontext.Context{}
if jobLabel(model.Job{}, "j", emptyCtx) != "j" {
t.Error("active job with context should have no annotation")
}
}
// TestJobLabel_ActiveWithContext covers the 'return name' path when a job runs
// normally (neither skipped nor manual) with a non-empty context.
func TestJobLabel_ActiveWithContext(t *testing.T) {
// Branch context with a job that has no rules → the job is active.
ctx := cicontext.New("main", "", "", nil)
activeJob := model.Job{Name: "build", Script: []any{"make"}}
label := jobLabel(activeJob, "build", ctx)
if label != "build" {
t.Errorf("active job with context: expected 'build', got %q", label)
}
}