test(coverage): add unit tests across all packages; remove dead code
ci / vet, staticcheck, test, build (push) Successful in 2m25s
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:
@@ -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{}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>", "<tag>"},
|
||||
{"a&b<c>d", "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, "<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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user