Files
glint/internal/resolver/extends_test.go
T
k3nny b21ef5c0bb
release / Build and publish release (push) Successful in 1m12s
ci / vet, staticcheck, test, build (push) Failing after 1m54s
feat(cicontext): rules:changes: path-glob evaluation; 100% test coverage
- Add --changes PATH and --changes-from REF flags to glint check and glint graph
  for rules:changes: evaluation. --changes marks files explicitly; --changes-from
  runs git diff --name-only <REF> automatically. Both flags can be combined.
- Implement doublestar glob matching (*, ** across path segments) in EvalJob and
  EvalWorkflow; extended {paths, compare_to} map form supported.
- Without --changes/--changes-from the condition stays permissive (existing behaviour).
- Context summary line now shows changed-file count when file data is provided.
- Achieve 100% statement coverage: comprehensive tests added across all packages;
  removed provably dead code; added testability seams (exit, userHomeDirFn,
  execCommandOutput variables) to cover previously unreachable paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:47:32 +02:00

294 lines
8.8 KiB
Go

package resolver
import (
"fmt"
"testing"
"git.k3nny.fr/glint/internal/model"
)
// errorYAMLMarshaler implements yaml.Marshaler and always returns an error,
// allowing tests to trigger the yaml.Marshal failure path in Resolve.
type errorYAMLMarshaler struct{}
func (e errorYAMLMarshaler) MarshalYAML() (interface{}, error) {
return nil, fmt.Errorf("forced marshal error for test")
}
// ── parseExtends ──────────────────────────────────────────────────────────────
func TestParseExtends(t *testing.T) {
cases := []struct {
name string
input any
want []string
wantErr bool
}{
{name: "nil", input: nil, want: nil},
{name: "single string", input: "base", want: []string{"base"}},
{name: "slice of strings", input: []any{"base1", "base2"}, want: []string{"base1", "base2"}},
{name: "empty slice", input: []any{}, want: []string{}},
{name: "invalid item in slice", input: []any{42}, wantErr: true},
{name: "unexpected type", input: 123, wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := parseExtends(tc.input)
if (err != nil) != tc.wantErr {
t.Fatalf("wantErr=%v got err=%v", tc.wantErr, err)
}
if !tc.wantErr {
if len(got) != len(tc.want) {
t.Fatalf("len mismatch: got %v want %v", got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Errorf("[%d] got %q want %q", i, got[i], tc.want[i])
}
}
}
})
}
}
// ── topoSort ──────────────────────────────────────────────────────────────────
func TestTopoSort(t *testing.T) {
t.Run("simple chain", func(t *testing.T) {
// a → b (b must come before a)
graph := map[string][]string{"a": {"b"}, "b": {}}
order, err := topoSort(graph)
if err != nil {
t.Fatal(err)
}
// b must appear before a
pos := func(s string) int {
for i, v := range order { if v == s { return i } }
return -1
}
if pos("b") > pos("a") {
t.Errorf("b should come before a, got %v", order)
}
})
t.Run("no deps", func(t *testing.T) {
graph := map[string][]string{"a": {}, "b": {}}
order, err := topoSort(graph)
if err != nil {
t.Fatal(err)
}
if len(order) != 2 {
t.Fatalf("expected 2 items, got %v", order)
}
})
t.Run("cycle detected", func(t *testing.T) {
graph := map[string][]string{"a": {"b"}, "b": {"a"}}
_, err := topoSort(graph)
if err == nil {
t.Fatal("expected cycle error")
}
})
t.Run("already visited node skipped", func(t *testing.T) {
// diamond: a→b, a→c, b→d, c→d
graph := map[string][]string{
"a": {"b", "c"},
"b": {"d"},
"c": {"d"},
"d": {},
}
order, err := topoSort(graph)
if err != nil {
t.Fatal(err)
}
// d must come first (or at least before b, c, a)
pos := func(s string) int {
for i, v := range order { if v == s { return i } }
return -1
}
if pos("d") > pos("b") || pos("d") > pos("c") {
t.Errorf("d should come before b and c, got %v", order)
}
})
}
// ── deepMerge ─────────────────────────────────────────────────────────────────
func TestDeepMerge(t *testing.T) {
t.Run("override wins on scalar", func(t *testing.T) {
base := map[string]any{"a": "base", "b": "keep"}
over := map[string]any{"a": "new"}
got := deepMerge(base, over)
if got["a"] != "new" || got["b"] != "keep" {
t.Errorf("got %v", got)
}
})
t.Run("nested maps merged recursively", func(t *testing.T) {
base := map[string]any{"m": map[string]any{"x": 1, "y": 2}}
over := map[string]any{"m": map[string]any{"y": 99, "z": 3}}
got := deepMerge(base, over)
m := got["m"].(map[string]any)
if m["x"] != 1 || m["y"] != 99 || m["z"] != 3 {
t.Errorf("nested merge wrong: %v", m)
}
})
t.Run("override scalar wins over base map", func(t *testing.T) {
base := map[string]any{"m": map[string]any{"x": 1}}
over := map[string]any{"m": "scalar"}
got := deepMerge(base, over)
if got["m"] != "scalar" {
t.Errorf("expected scalar to win, got %v", got["m"])
}
})
t.Run("base map + override scalar - base is map, override is scalar", func(t *testing.T) {
base := map[string]any{"k": map[string]any{"a": 1}}
over := map[string]any{"k": "replaced"}
got := deepMerge(base, over)
if got["k"] != "replaced" {
t.Errorf("got %v", got["k"])
}
})
t.Run("empty maps", func(t *testing.T) {
got := deepMerge(map[string]any{}, map[string]any{})
if len(got) != 0 {
t.Errorf("expected empty, got %v", got)
}
})
}
// ── Resolve ───────────────────────────────────────────────────────────────────
func buildPipeline(rawJobs map[string]map[string]any) *model.Pipeline {
p := &model.Pipeline{
Jobs: make(map[string]model.Job),
RawJobs: rawJobs,
}
for name, raw := range rawJobs {
ext := raw["extends"]
p.Jobs[name] = model.Job{Name: name, Extends: ext}
}
return p
}
func TestResolve(t *testing.T) {
t.Run("no extends — noop", func(t *testing.T) {
p := buildPipeline(map[string]map[string]any{
"build": {"script": []any{"make"}},
})
warnings, err := Resolve(p)
if err != nil || len(warnings) != 0 {
t.Fatalf("unexpected: err=%v warnings=%v", err, warnings)
}
})
t.Run("single extends merges fields", func(t *testing.T) {
p := buildPipeline(map[string]map[string]any{
".base": {"image": "alpine", "script": []any{"echo base"}},
"child": {"extends": ".base", "stage": "test"},
})
warnings, err := Resolve(p)
if err != nil || len(warnings) != 0 {
t.Fatalf("unexpected: err=%v warnings=%v", err, warnings)
}
child := p.Jobs["child"]
if child.Stage != "test" {
t.Errorf("stage not preserved: %q", child.Stage)
}
})
t.Run("unknown base produces warning, not error", func(t *testing.T) {
p := buildPipeline(map[string]map[string]any{
"child": {"extends": ".missing", "script": []any{"echo x"}},
})
warnings, err := Resolve(p)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(warnings) != 1 || warnings[0].Job != "child" || warnings[0].Base != ".missing" {
t.Errorf("expected warning about .missing, got %v", warnings)
}
})
t.Run("multi-extend list", func(t *testing.T) {
p := buildPipeline(map[string]map[string]any{
".a": {"image": "alpine"},
".b": {"tags": []any{"docker"}},
"child": {"extends": []any{".a", ".b"}, "script": []any{"echo x"}},
})
warnings, err := Resolve(p)
if err != nil || len(warnings) != 0 {
t.Fatalf("unexpected: %v %v", err, warnings)
}
})
t.Run("cycle returns error", func(t *testing.T) {
p := buildPipeline(map[string]map[string]any{
"a": {"extends": "b"},
"b": {"extends": "a"},
})
_, err := Resolve(p)
if err == nil {
t.Fatal("expected cycle error")
}
})
t.Run("file and line preserved after merge", func(t *testing.T) {
p := buildPipeline(map[string]map[string]any{
".base": {"script": []any{"echo base"}},
"child": {"extends": ".base"},
})
p.Jobs["child"] = model.Job{Name: "child", Extends: ".base", File: "ci.yml", Line: 10}
_, err := Resolve(p)
if err != nil {
t.Fatal(err)
}
if p.Jobs["child"].File != "ci.yml" || p.Jobs["child"].Line != 10 {
t.Errorf("file/line lost after merge: %+v", p.Jobs["child"])
}
})
t.Run("parseExtends invalid type returns error", func(t *testing.T) {
p := buildPipeline(map[string]map[string]any{
"job": {"extends": 123},
})
p.Jobs["job"] = model.Job{Name: "job", Extends: 123}
_, err := Resolve(p)
if err == nil {
t.Fatal("expected error for invalid extends type")
}
})
t.Run("yaml.Marshal fails — errorYAMLMarshaler injected into merged map", func(t *testing.T) {
// Inject a value whose MarshalYAML() returns an error so yaml.Marshal
// fails at extends.go:74-77.
p := buildPipeline(map[string]map[string]any{
".base": {"script": []any{"echo"}},
"child": {"extends": ".base", "bad": errorYAMLMarshaler{}},
})
_, err := Resolve(p)
if err == nil {
t.Fatal("expected error when yaml.Marshal fails")
}
})
t.Run("yaml.Unmarshal fails — map injected where []string expected", func(t *testing.T) {
// Marshal succeeds (maps are valid YAML), but Unmarshal into model.Job
// fails because Dependencies []string cannot hold a mapping.
p := buildPipeline(map[string]map[string]any{
".base": {
"dependencies": map[string]any{"invalid": "not-a-list"},
},
"child": {"extends": ".base", "stage": "build"},
})
_, err := Resolve(p)
if err == nil {
t.Fatal("expected error when yaml.Unmarshal fails on incompatible type")
}
})
}