package ci import ( "reflect" "strings" "testing" ) func mustParse(t *testing.T, src string) *Config { t.Helper() cfg, err := ParseCiConfig(src) if err != nil { t.Fatalf("ParseCiConfig: %v", err) } return cfg } func mustFail(t *testing.T, src string) { t.Helper() if cfg, err := ParseCiConfig(src); err == nil { t.Fatalf("expected an error, got %+v", cfg) } } func TestParseMinimalConfig(t *testing.T) { cfg := mustParse(t, ` image = "debian:latest" [[steps]] name = "build" run_sh = "make all" `) if cfg.Image != "debian:latest" || len(cfg.Steps) != 1 { t.Fatalf("unexpected config %+v", cfg) } if cfg.Steps[0].Name != "build" || cfg.Steps[0].RunSh != "make all" { t.Fatalf("unexpected step %+v", cfg.Steps[0]) } } func TestParseRejects(t *testing.T) { cases := map[string]string{ "missing image": "[[steps]]\nname = \"build\"\nrun_sh = \"make\"", "invalid toml": "image = [unclosed", "missing steps": `image = "alpine"`, "step, no name": "image = \"alpine\"\n[[steps]]\nrun_sh = \"make\"", "partial copy": "image = \"alpine\"\n[[copy]]\nimage = \"busybox\"\nfrom = \"/bin/busybox\"\n[[steps]]\nname = \"build\"", "bad cache size": "image = \"alpine\"\ncache = [{ path = \"/c\", max_size = \"12 GiB\" }]\n[[steps]]\nname = \"build\"", "cache no path": "image = \"alpine\"\ncache = [{ max_size = \"1g\" }]\n[[steps]]\nname = \"build\"", } for name, src := range cases { t.Run(name, func(t *testing.T) { mustFail(t, src) }) } } func TestStepOrderKeepsNumericNames(t *testing.T) { cfg := mustParse(t, ` image = "alpine" [[steps]] name = "setup" [[steps]] name = "2024" [[steps]] name = "test" `) got := []string{cfg.Steps[0].Name, cfg.Steps[1].Name, cfg.Steps[2].Name} if !reflect.DeepEqual(got, []string{"setup", "2024", "test"}) { t.Fatalf("step order lost: %v", got) } } func TestParseCopyEntries(t *testing.T) { cfg := mustParse(t, ` image = "alpine" [[copy]] image = "docker.io/oven/bun:1.4.0-alpine" from = "/usr/local/bin/bun" to = "/usr/local/bin" [[steps]] name = "build" `) want := []Copy{{Image: "docker.io/oven/bun:1.4.0-alpine", From: "/usr/local/bin/bun", To: "/usr/local/bin"}} if !reflect.DeepEqual(cfg.Copy, want) { t.Fatalf("got %+v", cfg.Copy) } } func TestParseTopLevelFields(t *testing.T) { cfg := mustParse(t, ` image = "alpine" work_dir = "/ci" clone_project_to = "/ci/repo" shell_setup = "set -e" timeout = 1800 cpu_limit = 2.0 memory_limit = "1g" cache = ["/root/.npm"] [[steps]] name = "build" `) if cfg.WorkDir != "/ci" || cfg.CloneProjectTo != "/ci/repo" || cfg.ShellSetup != "set -e" { t.Fatalf("got %+v", cfg) } if cfg.Timeout != 1800 || cfg.CPULimit != 2.0 || cfg.MemoryLimit != "1g" { t.Fatalf("got %+v", cfg) } if !reflect.DeepEqual(cfg.Cache, []Cache{{Path: "/root/.npm"}}) { t.Fatalf("got %+v", cfg.Cache) } } func TestParseCacheForms(t *testing.T) { cfg := mustParse(t, ` image = "alpine" cache = [ { path = "/ci/cache/target", max_size = "12g" }, "/usr/local/cargo/registry", { path = "/root/.bun/install/cache" }, ] [[steps]] name = "build" `) want := []Cache{ {Path: "/ci/cache/target", MaxSize: 12 * 1024 * 1024 * 1024}, {Path: "/usr/local/cargo/registry"}, {Path: "/root/.bun/install/cache"}, } if !reflect.DeepEqual(cfg.Cache, want) { t.Fatalf("got %+v", cfg.Cache) } } func TestParseTriggersAndVariables(t *testing.T) { cfg := mustParse(t, ` image = "alpine" [on] push = ["main", "develop"] tag = true manual = true [variables] [variables.DEPLOY_ENV] default = "staging" description = "Target environment" [variables.VERSION] default = "1.0.0" [[steps]] name = "build" `) if !reflect.DeepEqual(cfg.On.Push.Patterns, []string{"main", "develop"}) { t.Fatalf("got %+v", cfg.On.Push) } if !cfg.On.Tag || !cfg.On.Manual { t.Fatalf("got %+v", cfg.On) } if cfg.Variables["DEPLOY_ENV"] != (VariableDef{Default: "staging", Description: "Target environment"}) { t.Fatalf("got %+v", cfg.Variables) } if cfg.Variables["VERSION"].Default != "1.0.0" { t.Fatalf("got %+v", cfg.Variables) } if !reflect.DeepEqual(cfg.VariableOrder, []string{"DEPLOY_ENV", "VERSION"}) { t.Fatalf("variable order lost: %v", cfg.VariableOrder) } } func TestParseStepFields(t *testing.T) { cfg := mustParse(t, ` image = "alpine" [[steps]] name = "test" run_sh = "./run_tests" run_if = 'test -n "${CI_COMMIT_TAG}"' clear = true timeout = 300 publish_file = ["/dist/binary"] publish_gzip = "/dist/" [[steps]] name = "lint" warn_on_fail = true [[steps]] name = "cleanup" always = true `) s := cfg.Steps[0] if s.RunSh != "./run_tests" || s.RunIf != `test -n "${CI_COMMIT_TAG}"` || !s.Clear || s.Timeout != 300 { t.Fatalf("got %+v", s) } if !reflect.DeepEqual([]string(s.PublishFile), []string{"/dist/binary"}) { t.Fatalf("got %+v", s.PublishFile) } // A bare string is accepted where a list is. if !reflect.DeepEqual([]string(s.PublishGzip), []string{"/dist/"}) { t.Fatalf("got %+v", s.PublishGzip) } if !cfg.Steps[1].WarnOnFail || cfg.Steps[1].Always { t.Fatalf("got %+v", cfg.Steps[1]) } if !cfg.Steps[2].Always { t.Fatalf("got %+v", cfg.Steps[2]) } } func TestShouldTriggerPush(t *testing.T) { cfg := func(push string) *Config { return mustParse(t, "image=\"alpine\"\n[[steps]]\nname=\"b\"\n[on]\npush="+push) } cases := []struct { push string branch string want bool }{ {`["main"]`, "main", true}, {`["main"]`, "develop", false}, {`["*"]`, "anything", true}, {`["main","release"]`, "release", true}, {`["main","release"]`, "feature/x", false}, {`["release/*"]`, "release/1.0", true}, {`["release/*"]`, "main", false}, {`true`, "anything", true}, {`false`, "main", false}, } for _, c := range cases { if got := shouldTriggerPush(cfg(c.push), c.branch); got != c.want { t.Errorf("push=%s branch=%s: got %v", c.push, c.branch, got) } } noOn := mustParse(t, "image=\"alpine\"\n[[steps]]\nname=\"b\"") if shouldTriggerPush(noOn, "main") { t.Error("absent on.push must not trigger") } } func TestValidateCachePaths(t *testing.T) { parse := func(clone, cache string) *Config { return mustParse(t, "image=\"alpine\"\nclone_project_to=\""+clone+"\"\ncache="+cache+ "\n[[steps]]\nname=\"build\"") } if got := ValidateCiConfig(parse("/ci/build/project", `["/ci/cache/target","/usr/local/cargo/registry"]`)); got != "" { t.Errorf("outside paths rejected: %s", got) } if got := ValidateCiConfig(parse("/ci/build/project", `["/ci/build/project/target"]`)); !strings.Contains(got, "/ci/build/project/target") { t.Errorf("inside path accepted: %q", got) } if got := ValidateCiConfig(parse("/ci/build/project", `["/ci/build/project"]`)); got == "" { t.Error("equal path accepted") } if got := ValidateCiConfig(parse("/ci/build/project", `["/ci/build/project-cache"]`)); got != "" { t.Errorf("sibling prefix rejected: %s", got) } if got := ValidateCiConfig(parse("/ci/build/project", `[{path="/ci/build/project/target",max_size="1g"}]`)); !strings.Contains(got, "/ci/build/project/target") { t.Errorf("capped inside path accepted: %q", got) } // Cache alone is fine when nothing is cloned. noClone := mustParse(t, "image=\"alpine\"\ncache=[\"/anything\"]\n[[steps]]\nname=\"build\"") if got := ValidateCiConfig(noClone); got != "" { t.Errorf("cache without clone rejected: %s", got) } // A relative clone path names two different directories. rel := mustParse(t, "image=\"alpine\"\nclone_project_to=\"project\"\n[[steps]]\nname=\"build\"") if ValidateCiConfig(rel) == "" { t.Error("relative clone_project_to accepted") } } func TestValidateCopyDestination(t *testing.T) { parse := func(to string) *Config { return mustParse(t, "image=\"alpine\"\n[[copy]]\nimage=\"busybox\"\nfrom=\"/bin/busybox\"\nto=\""+ to+"\"\n[[steps]]\nname=\"build\"") } if got := ValidateCiConfig(parse("/usr/local/bin")); got != "" { t.Errorf("directory destination rejected: %s", got) } // Looks like a rename, silently produces /usr/local/bin/busybox/busybox. if got := ValidateCiConfig(parse("/usr/local/bin/busybox")); !strings.Contains(got, "/usr/local/bin/busybox/busybox") { t.Errorf("repeated basename accepted: %q", got) } } func TestParseMemoryBytes(t *testing.T) { cases := map[string]int64{ "1024": 1024, "1k": 1024, "2M": 2 * 1024 * 1024, "1.5g": 1536 * 1024 * 1024, "12 GiB": 0, "": 0, } for in, want := range cases { if got := parseMemoryBytes(in); got != want { t.Errorf("parseMemoryBytes(%q) = %d, want %d", in, got, want) } } } func TestCacheVolumeNameDiffersPerPath(t *testing.T) { a := cacheVolumeName("repo", "/ci/cache/target") b := cacheVolumeName("repo", "/ci/build/project/target") if a == b { t.Fatal("two paths share a volume name") } if !strings.HasPrefix(a, "hearthforge-ci-cache-") || len(a) != len("hearthforge-ci-cache-")+24 { t.Fatalf("unexpected volume name %q", a) } } func TestSplitImageRef(t *testing.T) { cases := []struct{ in, name, tag string }{ {"debian:stable", "debian", "stable"}, {"docker.io/library/debian", "docker.io/library/debian", "latest"}, {"registry:5000/img", "registry:5000/img", "latest"}, {"registry:5000/img:v2", "registry:5000/img", "v2"}, {"debian@sha256:abc", "debian@sha256:abc", ""}, {"registry:5000/img@sha256:abc", "registry:5000/img@sha256:abc", ""}, } for _, c := range cases { name, tag := splitImageRef(c.in) if name != c.name || tag != c.tag { t.Errorf("splitImageRef(%q) = %q,%q", c.in, name, tag) } } } func TestMaskSecrets(t *testing.T) { got := maskSecrets("token=abc123 again abc123", []string{"abc123", ""}) if got != "token=[MASKED] again [MASKED]" { t.Fatalf("got %q", got) } } func TestLogBufferTruncates(t *testing.T) { var b logBuffer b.append(strings.Repeat("x", ciMaxLogBytes+10)) if !b.truncated || !strings.Contains(b.String(), "[log truncated at") { t.Fatal("log not truncated") } before := b.String() b.append("more") if b.String() != before { t.Fatal("appended after truncation") } }