package e2e import ( "archive/tar" "archive/zip" "bytes" "compress/gzip" "context" "database/sql" "encoding/json" "io" "net/http" "net/url" "os" "path/filepath" "slices" "strconv" "strings" "testing" "time" "github.com/klauspost/compress/zstd" ) // The CI suite drives the real pipeline runner against a mock Docker Engine // on a unix socket, so no container engine is needed. const ciSimpleTOML = ` image = "debian:latest" [on] manual = true push = ["main"] [[steps]] name = "hello" run_sh = "echo hello" ` const ciArtifactTOML = ` image = "debian:latest" work_dir = "/ci" [on] manual = true [[steps]] name = "build" run_sh = "echo building" publish_file = ["/ci/output.txt"] ` // ciEnv starts a server wired to a fresh mock engine and seeds "ci-repo". // extraEnv holds further environment pairs for newEnv. func ciEnv(t *testing.T, extraEnv ...string) (*env, *mockDocker, *session) { t.Helper() m := newMockDocker(t) e := newEnv(t, append([]string{"CI_DOCKER_SOCKET", m.sock}, extraEnv...)...) admin := e.admin() e.createRepo(admin, "ci-repo") e.seedRepo("ci-repo", nil) return e, m, admin } // ciSeedToml pushes a .hearthforge-ci.toml into ci-repo and returns the // commit sha. Re-seeding the same content is a no-op commit, so a test can // put its config back without failing. func ciSeedToml(e *env, toml string) string { t := e.t t.Helper() work := t.TempDir() gitRun(t, work, "clone", "-q", e.repoPath("ci-repo"), ".") if err := os.WriteFile(filepath.Join(work, ".hearthforge-ci.toml"), []byte(toml), 0o644); err != nil { t.Fatal(err) } gitRun(t, work, "add", ".hearthforge-ci.toml") // Nothing to commit when the config is unchanged. _, _ = gitTry(work, "commit", "-q", "-m", "Add CI config") gitRun(t, work, "push", "-q", "origin", "HEAD:main") e.Srv.Git.InvalidateRefCache("ci-repo") return gitRun(t, work, "rev-parse", "HEAD") } // ciTrigger posts the manual trigger and returns the new run id. Without a // branch field the route builds HEAD of the default branch, so the expected // sha is checked and a stale fixture fails loudly. func ciTrigger(e *env, admin *session, sha string, overrides url.Values) int64 { t := e.t t.Helper() if head := e.headCommit("ci-repo"); head != sha { t.Fatalf("ciTrigger: expected HEAD %s, repo HEAD is %s", sha, head) } if overrides == nil { overrides = url.Values{} } loc := admin.post("/ci-repo/ci/run", overrides).mustRedirect("/ci-repo/ci/") id, err := strconv.ParseInt(idFromPath(t, loc), 10, 64) if err != nil { t.Fatalf("run id in %q: %v", loc, err) } return id } // ciLatestRunID is the highest run id in the database, or 0. func ciLatestRunID(e *env) int64 { var id sql.NullInt64 if err := e.DB.QueryRowContext(context.Background(), `SELECT MAX(id) FROM ci_runs`).Scan(&id); err != nil { e.t.Fatal(err) } return id.Int64 } // ciPushRun pushes a commit to a branch over HTTP and returns the run the // push trigger created. func ciPushRun(e *env, sha, branch string) int64 { t := e.t t.Helper() before := ciLatestRunID(e) gitRun(t, e.repoPath("ci-repo"), "push", "--force", e.authURL("ci-repo"), sha+":refs/heads/"+branch) e.Srv.Git.InvalidateRefCache("ci-repo") var id int64 ciWaitFor(t, "a run from the push to "+branch, func() bool { id = ciLatestRunID(e) return id > before }) return id } // ciWaitForRun polls until the run leaves pending/running/queued. func ciWaitForRun(e *env, runID int64, timeout ...time.Duration) string { t := e.t t.Helper() limit := 15 * time.Second if len(timeout) > 0 { limit = timeout[0] } var status string ciWaitFor(t, "run "+strconv.FormatInt(runID, 10)+" to complete", func() bool { status = ciRunStatus(e, runID) return status != "" && status != "pending" && status != "running" && status != "queued" && !e.Srv.CI.Active(runID) }, limit) return status } func ciRunStatus(e *env, runID int64) string { var status string err := e.DB.QueryRowContext(context.Background(), `SELECT status FROM ci_runs WHERE id = ?`, runID).Scan(&status) if err != nil { e.t.Fatalf("run %d: %v", runID, err) } return status } // ciStepRow is one row of ci_steps. type ciStepRow struct { Status string Log string } // ciSteps returns every step of a run with that name, in insertion order. func ciSteps(e *env, runID int64, name string) []ciStepRow { rows, err := e.DB.QueryContext(context.Background(), `SELECT status, log FROM ci_steps WHERE run_id = ? AND name = ? ORDER BY id ASC`, runID, name) if err != nil { e.t.Fatal(err) } defer rows.Close() var out []ciStepRow for rows.Next() { var s ciStepRow if err := rows.Scan(&s.Status, &s.Log); err != nil { e.t.Fatal(err) } out = append(out, s) } return out } // ciStep returns the first step of a run with that name. func ciStep(e *env, runID int64, name string) ciStepRow { steps := ciSteps(e, runID, name) if len(steps) == 0 { e.t.Fatalf("run %d has no step %q", runID, name) } return steps[0] } // ciOverrides decodes the stored variable overrides of a run. func ciOverrides(e *env, runID int64) map[string]string { var raw sql.NullString if err := e.DB.QueryRowContext(context.Background(), `SELECT variable_overrides FROM ci_runs WHERE id = ?`, runID).Scan(&raw); err != nil { e.t.Fatal(err) } out := map[string]string{} if raw.String != "" { if err := json.Unmarshal([]byte(raw.String), &out); err != nil { e.t.Fatalf("overrides %q: %v", raw.String, err) } } return out } func ciRunPath(runID int64) string { return "/ci-repo/ci/" + strconv.FormatInt(runID, 10) } // eqStrings compares two string lists. func eqStrings(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } // ── pipelines tab ──────────────────────────────────────────────────────── func TestCIPipelinesTab(t *testing.T) { e, m, admin := ciEnv(t) t.Run("tab is visible in repo nav", func(t *testing.T) { r := admin.get("/ci-repo").mustStatus(200) if !contains(r.Texts(".repo-tab"), "Pipelines") { t.Errorf("repo tabs = %v", r.Texts(".repo-tab")) } }) t.Run("history page shows empty state when no runs", func(t *testing.T) { r := admin.get("/ci-repo/ci").mustStatus(200) if !r.Has(".empty-state") { t.Fatal("empty state missing") } if !strings.Contains(r.Text(".empty-state"), "No pipeline runs yet") { t.Errorf("empty state = %q", r.Text(".empty-state")) } }) t.Run("the run form posts and overrides a declared variable", func(t *testing.T) { // Regression: an input-less form posted an empty body and the route // crashed whenever the config declared a variable. sha := ciSeedToml(e, ` image = "debian:latest" [on] manual = true [variables] [variables.GREETING] default = "hello" description = "What to echo" [[steps]] name = "say" run_sh = "echo $GREETING" `) m.reset() m.queueExec(execResp{output: "hi\n"}) r := admin.get("/ci-repo/ci").mustStatus(200) if got := r.Value(`input[name="var_GREETING"]`); got != "hello" { t.Fatalf("var_GREETING default = %q", got) } runID := ciTrigger(e, admin, sha, url.Values{"var_GREETING": {"goodbye"}}) ciWaitForRun(e, runID) got := ciOverrides(e, runID) if len(got) != 1 || got["GREETING"] != "goodbye" { t.Errorf("overrides = %v", got) } }) t.Run("an untouched variable field is not recorded as an override", func(t *testing.T) { sha := ciSeedToml(e, ` image = "debian:latest" [on] manual = true [variables] [variables.GREETING] default = "hello" [[steps]] name = "say" run_sh = "echo $GREETING" `) m.reset() m.queueExec(execResp{output: "hi\n"}) runID := ciTrigger(e, admin, sha, url.Values{"var_GREETING": {"hello"}}) ciWaitForRun(e, runID) if got := ciOverrides(e, runID); len(got) != 0 { t.Errorf("overrides = %v, want none", got) } }) t.Run("an empty POST to the run route does not crash", func(t *testing.T) { sha := ciSeedToml(e, ` image = "debian:latest" [on] manual = true [variables] [variables.GREETING] default = "hello" [[steps]] name = "say" run_sh = "echo $GREETING" `) _ = sha m.reset() m.queueExec(execResp{output: "hi\n"}) r := admin.post("/ci-repo/ci/run", url.Values{}) if r.Code != http.StatusFound { t.Fatalf("status = %d, body %s", r.Code, r.BodyString()) } id, err := strconv.ParseInt(idFromPath(t, r.Location()), 10, 64) if err != nil { t.Fatal(err) } ciWaitForRun(e, id) }) t.Run("the run form builds the selected branch with its own config", func(t *testing.T) { mainSHA := ciSeedToml(e, ciSimpleTOML) work := t.TempDir() gitRun(t, work, "clone", "-q", e.repoPath("ci-repo"), ".") if err := os.WriteFile(filepath.Join(work, ".hearthforge-ci.toml"), []byte(` image = "debian:latest" [variables] [variables.FEATURE_ONLY] default = "on" [[steps]] name = "say" run_sh = "echo $FEATURE_ONLY" `), 0o644); err != nil { t.Fatal(err) } gitRun(t, work, "commit", "-q", "-am", "Feature CI config") gitRun(t, work, "push", "-q", "origin", "HEAD:refs/heads/feature") e.Srv.Git.InvalidateRefCache("ci-repo") featureSHA := gitRun(t, work, "rev-parse", "HEAD") defer func() { gitRun(t, e.repoPath("ci-repo"), "branch", "-D", "feature") e.Srv.Git.InvalidateRefCache("ci-repo") }() r := admin.get("/ci-repo/ci").mustStatus(200) if got := r.Value(`input[name="branch"]`); got != "main" { t.Errorf("default branch field = %q", got) } if r.Has(`input[name="var_FEATURE_ONLY"]`) { t.Error("main shows the feature branch variable") } r = admin.get("/ci-repo/ci?branch=feature").mustStatus(200) if got := r.Value(`input[name="branch"]`); got != "feature" { t.Errorf("branch field = %q", got) } if got := r.Value(`select[name="branch"]`); got != "feature" { t.Errorf("selected branch = %q", got) } if got := r.Value(`input[name="var_FEATURE_ONLY"]`); got != "on" { t.Errorf("var_FEATURE_ONLY default = %q", got) } m.reset() m.queueExec(execResp{output: "off\n"}) loc := admin.post("/ci-repo/ci/run", url.Values{ "branch": {"feature"}, "var_FEATURE_ONLY": {"off"}, }).mustRedirect("/ci-repo/ci/") runID, err := strconv.ParseInt(idFromPath(t, loc), 10, 64) if err != nil { t.Fatal(err) } ciWaitForRun(e, runID) var sha, branch string if err := e.DB.QueryRowContext(context.Background(), `SELECT commit_sha, commit_branch FROM ci_runs WHERE id = ?`, runID). Scan(&sha, &branch); err != nil { t.Fatal(err) } if sha != featureSHA || sha == mainSHA || branch != "feature" { t.Errorf("run built %s on %q, want %s on feature", sha, branch, featureSHA) } if got := ciOverrides(e, runID); got["FEATURE_ONLY"] != "off" { t.Errorf("overrides = %v", got) } for _, b := range []string{"nope", mainSHA, "HEAD~1"} { r := admin.post("/ci-repo/ci/run", url.Values{"branch": {b}}) if r.Code != http.StatusBadRequest { t.Errorf("branch %q: status = %d", b, r.Code) } } }) t.Run("help section is collapsible and contains template download", func(t *testing.T) { r := admin.get("/ci-repo/ci").mustStatus(200) if !r.Has("details.ci-help") { t.Error("help section missing") } if !r.Has(`a[download=".hearthforge-ci.toml"]`) { t.Error("template download link missing") } }) } // ── successful run ─────────────────────────────────────────────────────── func TestCISuccessfulRun(t *testing.T) { e, m, admin := ciEnv(t) m.queueExec(execResp{output: "hello from mock CI\n"}) sha := ciSeedToml(e, ciSimpleTOML) runID := ciTrigger(e, admin, sha, nil) ciWaitForRun(e, runID) t.Run("run status is success", func(t *testing.T) { if got := ciRunStatus(e, runID); got != "success" { t.Errorf("status = %q", got) } }) t.Run("step status is success and log is captured", func(t *testing.T) { step := ciStep(e, runID, "hello") if step.Status != "success" { t.Errorf("step status = %q", step.Status) } if !strings.Contains(step.Log, "hello from mock CI") { t.Errorf("step log = %q", step.Log) } }) t.Run("history page shows the completed run", func(t *testing.T) { r := admin.get("/ci-repo/ci").mustStatus(200) if r.Count(".ci-status-pill.ci-status-success") == 0 { t.Error("no success pill on the history page") } if r.Count(`a.commit-hash[href^="/ci-repo/commit/"]`) == 0 { t.Error("commit hash is not a link to the commit page") } // The trigger label uses the owner display name, like every other // place that names the admin. if !contains(r.Texts(".text-muted"), "by "+e.Cfg.OwnerDisplayName) || contains(r.Texts(".text-muted"), "by admin") { t.Errorf("trigger labels = %v", r.Texts(".text-muted")) } }) t.Run("run detail page shows step and log", func(t *testing.T) { r := admin.get(ciRunPath(runID)).mustStatus(200) steps := r.Texts(".ci-step") if len(steps) < 2 { t.Fatalf("steps = %v", steps) } // Setup is a real step and sorts before the config's steps. if !strings.Contains(steps[0], "pipeline setup") || !strings.Contains(steps[0], "success") { t.Errorf("first step = %q", steps[0]) } if !strings.Contains(steps[1], "hello") { t.Errorf("second step = %q", steps[1]) } if !contains(r.Texts(".ci-step-log"), "hello from mock CI") { t.Errorf("logs = %v", r.Texts(".ci-step-log")) } }) t.Run("retry re-executes the same run in-place", func(t *testing.T) { r := admin.post(ciRunPath(runID)+"/retry", url.Values{}) if got := r.mustRedirect(ciRunPath(runID)); got != ciRunPath(runID) { t.Errorf("redirect = %q", got) } if got := ciWaitForRun(e, runID); got != "success" { t.Errorf("status after retry = %q", got) } // No new run was created: the row still exists under the same id. if ciRunStatus(e, runID) != "success" { t.Error("run row changed") } }) } // ── failing run ────────────────────────────────────────────────────────── func TestCIFailingRun(t *testing.T) { e, m, admin := ciEnv(t) m.queueExec(execResp{output: "build error: file not found\n", exitCode: 1}) sha := ciSeedToml(e, ciSimpleTOML) runID := ciTrigger(e, admin, sha, nil) ciWaitForRun(e, runID) t.Run("run status is failure", func(t *testing.T) { if got := ciRunStatus(e, runID); got != "failure" { t.Errorf("status = %q", got) } }) t.Run("step status is failure and error log captured", func(t *testing.T) { step := ciStep(e, runID, "hello") if step.Status != "failure" { t.Errorf("step status = %q", step.Status) } if !strings.Contains(step.Log, "build error") { t.Errorf("step log = %q", step.Log) } }) t.Run("run detail page shows failure status", func(t *testing.T) { r := admin.get(ciRunPath(runID)).mustStatus(200) if r.Count(".ci-status-pill.ci-status-failure") == 0 { t.Error("no failure pill on the run page") } }) } // ── cancel ─────────────────────────────────────────────────────────────── func TestCICancel(t *testing.T) { e, _, admin := ciEnv(t) t.Run("cancelling a pending run marks it cancelled", func(t *testing.T) { sha := ciSeedToml(e, ciSimpleTOML) runID := ciTrigger(e, admin, sha, nil) admin.post(ciRunPath(runID)+"/cancel", url.Values{}).mustRedirect(ciRunPath(runID)) status := ciWaitForRun(e, runID) switch status { case "cancelled", "success", "failure": default: t.Fatalf("status = %q", status) } }) } const ciSlowTOML = ` image = "debian:latest" [on] manual = true [[steps]] name = "slow" run_sh = "sleep 60" [[steps]] name = "after" run_sh = "echo after" ` // ciStepStatus is the status of the first step with that name, or "". func ciStepStatus(e *env, runID int64, name string) string { if steps := ciSteps(e, runID, name); len(steps) > 0 { return steps[0].Status } return "" } // ciWaitFor polls cond for up to ten seconds, or the given timeout. func ciWaitFor(t *testing.T, what string, cond func() bool, timeout ...time.Duration) { t.Helper() limit := 10 * time.Second if len(timeout) > 0 { limit = timeout[0] } for deadline := time.Now().Add(limit); time.Now().Before(deadline); { if cond() { return } time.Sleep(20 * time.Millisecond) } t.Fatalf("timed out waiting for %s", what) } func TestCICancelMidRun(t *testing.T) { e, m, admin := ciEnv(t) t.Run("the interrupted step reads cancelled", func(t *testing.T) { sha := ciSeedToml(e, ciSlowTOML) m.reset() m.queueExec(execResp{delay: 30 * time.Second}) runID := ciTrigger(e, admin, sha, nil) ciWaitFor(t, "slow step to run", func() bool { return ciStepStatus(e, runID, "slow") == "running" }) admin.post(ciRunPath(runID)+"/cancel", url.Values{}).mustRedirect(ciRunPath(runID)) ciWaitFor(t, "slow step to settle", func() bool { return ciStepStatus(e, runID, "slow") != "running" }) if got := ciStep(e, runID, "slow").Status; got != "cancelled" { t.Errorf("slow status = %q", got) } if got := ciStep(e, runID, "after").Status; got != "skipped" { t.Errorf("after status = %q", got) } if got := ciRunStatus(e, runID); got != "cancelled" { t.Errorf("run status = %q", got) } }) t.Run("a cancel during artifact collection is not overwritten", func(t *testing.T) { sha := ciSeedToml(e, ciArtifactTOML) m.reset() m.archiveDelay = 30 * time.Second runID := ciTrigger(e, admin, sha, nil) ciWaitFor(t, "artifact download", func() bool { return slices.Contains(m.containerRequests(), "GET archive") }) admin.post(ciRunPath(runID)+"/cancel", url.Values{}).mustRedirect(ciRunPath(runID)) ciWaitFor(t, "build step to settle", func() bool { return ciStepStatus(e, runID, "build") != "running" }) // The final status write follows the step write within milliseconds. time.Sleep(300 * time.Millisecond) if got := ciRunStatus(e, runID); got != "cancelled" { t.Errorf("run status = %q", got) } }) } func TestCIContainerLifecycle(t *testing.T) { e, m, admin := ciEnv(t) sha := ciSeedToml(e, ciSimpleTOML) m.reset() runID := ciTrigger(e, admin, sha, nil) ciWaitForRun(e, runID) name := "hearthforge-ci-" + strconv.FormatInt(runID, 10) reqs := m.containerRequests() // A leftover from a cancelled create must not block the name. if len(reqs) < 2 || reqs[0] != "DELETE "+name || reqs[1] != "POST create?name="+name { t.Errorf("requests = %v", reqs) } labels, _ := m.createBody()["Labels"].(map[string]any) if labels["com.hearthforge.ci"] != "1" { t.Errorf("labels = %v", labels) } } func TestCIRepoDeleteStopsRuns(t *testing.T) { e, m, admin := ciEnv(t) sha := ciSeedToml(e, ciArtifactTOML) m.reset() done := ciTrigger(e, admin, sha, nil) ciWaitForRun(e, done) artifacts := filepath.Join(e.Cfg.CIArtifactsDir(), strconv.FormatInt(done, 10)) if _, err := os.Stat(artifacts); err != nil { t.Fatalf("artifacts of run %d: %v", done, err) } m.queueExec(execResp{delay: 30 * time.Second}) active := ciTrigger(e, admin, sha, nil) ciWaitFor(t, "build step to run", func() bool { return ciStepStatus(e, active, "build") == "running" }) m.setVolumesOnHost("hearthforge-ci-cache-old") admin.post("/ci-repo/settings/delete", nil).mustRedirect("/") if !slices.Contains(m.containerRequests(), "DELETE hearthforge-ci-"+strconv.FormatInt(active, 10)) { t.Errorf("active container not removed: %v", m.containerRequests()) } if _, err := os.Stat(artifacts); !os.IsNotExist(err) { t.Errorf("artifacts still there: %v", err) } if !slices.Contains(m.deletedVolumes(), "hearthforge-ci-cache-old") { t.Errorf("volumes deleted = %v", m.deletedVolumes()) } } // ── artifacts ──────────────────────────────────────────────────────────── func TestCIArtifacts(t *testing.T) { e, _, admin := ciEnv(t) sha := ciSeedToml(e, ciArtifactTOML) runID := ciTrigger(e, admin, sha, nil) ciWaitForRun(e, runID) var artifactID int64 var filename string var count int rows, err := e.DB.QueryContext(context.Background(), `SELECT id, filename FROM ci_artifacts WHERE run_id = ? ORDER BY id`, runID) if err != nil { t.Fatal(err) } for rows.Next() { if err := rows.Scan(&artifactID, &filename); err != nil { t.Fatal(err) } count++ } rows.Close() t.Run("artifact row created in DB", func(t *testing.T) { if count != 1 { t.Fatalf("artifacts = %d", count) } if filename != "output.txt" { t.Errorf("filename = %q", filename) } }) t.Run("artifact is downloadable via HTTP", func(t *testing.T) { if artifactID <= 0 { t.Fatal("no artifact id") } r := e.anon().get(ciRunPath(runID) + "/artifacts/" + strconv.FormatInt(artifactID, 10)) r.mustStatus(200) if r.BodyString() != "artifact-content-123" { t.Errorf("body = %q", r.BodyString()) } }) t.Run("run detail page shows artifact list", func(t *testing.T) { r := admin.get(ciRunPath(runID)).mustStatus(200) if r.Count(".ci-artifact-item") == 0 { t.Fatal("no artifact items") } if !strings.Contains(r.Text(".ci-artifact-name"), "output.txt") { t.Errorf("artifact name = %q", r.Text(".ci-artifact-name")) } }) } // ── badge ──────────────────────────────────────────────────────────────── func TestCIBadge(t *testing.T) { e, m, admin := ciEnv(t) m.queueExec(execResp{output: "ok\n"}) sha := ciSeedToml(e, ciSimpleTOML) runID := ciTrigger(e, admin, sha, nil) ciWaitForRun(e, runID) t.Run("badge SVG returns success status after successful run", func(t *testing.T) { r := e.anon().get("/ci-repo/ci/badge.svg").mustStatus(200) if !strings.Contains(r.Header.Get("Content-Type"), "image/svg+xml") { t.Errorf("content type = %q", r.Header.Get("Content-Type")) } if !r.Contains(" 0 && (c[0] == "tar" || c[0] == "zip") { t.Errorf("archive tool run in the container: %v", c) } } artifacts := map[string]int64{} rows, err := e.DB.QueryContext(context.Background(), `SELECT id, filename FROM ci_artifacts WHERE run_id = ?`, runID) if err != nil { t.Fatal(err) } for rows.Next() { var id int64 var name string if err := rows.Scan(&id, &name); err != nil { t.Fatal(err) } artifacts[name] = id } rows.Close() if len(artifacts) != 4 { t.Fatalf("artifacts = %v", artifacts) } download := func(name string) []byte { t.Helper() id, ok := artifacts[name] if !ok { t.Fatalf("artifact %s missing from %v", name, artifacts) } return admin.get(ciRunPath(runID) + "/artifacts/" + strconv.FormatInt(id, 10)).mustStatus(200).Body } // The mock answers every archive request with one file "dist" holding // artifact-content-123. checkTar := func(name string, r io.Reader) { t.Helper() tr := tar.NewReader(r) h, err := tr.Next() if err != nil || h.Name != "dist" { t.Fatalf("%s: first entry %v, err %v", name, h, err) } data, _ := io.ReadAll(tr) if string(data) != "artifact-content-123" { t.Errorf("%s: content = %q", name, data) } } t.Run("tar", func(t *testing.T) { checkTar("dist.tar", bytes.NewReader(download("dist.tar"))) }) t.Run("gzip", func(t *testing.T) { gz, err := gzip.NewReader(bytes.NewReader(download("dist.tar.gz"))) if err != nil { t.Fatal(err) } checkTar("dist.tar.gz", gz) }) t.Run("zstd", func(t *testing.T) { dec, err := zstd.NewReader(bytes.NewReader(download("dist.tar.zst"))) if err != nil { t.Fatal(err) } defer dec.Close() checkTar("dist.tar.zst", dec) }) t.Run("zip", func(t *testing.T) { data := download("dist.zip") zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { t.Fatal(err) } if len(zr.File) != 1 || zr.File[0].Name != "dist" { t.Fatalf("zip entries = %v", zr.File) } f, _ := zr.File[0].Open() body, _ := io.ReadAll(f) if string(body) != "artifact-content-123" { t.Errorf("zip content = %q", body) } }) }