diff --git a/cmd/artifacts/download.go b/cmd/artifacts/download.go index 3bdca5d9..98270f8c 100644 --- a/cmd/artifacts/download.go +++ b/cmd/artifacts/download.go @@ -3,9 +3,11 @@ package artifacts import ( "context" "fmt" + "io" "os" "path/filepath" "strconv" + "strings" "github.com/alecthomas/kong" buildResolver "github.com/buildkite/cli/v3/internal/build/resolver" @@ -20,10 +22,12 @@ import ( ) type DownloadCmd struct { - ArtifactID string `arg:"" optional:"" help:"Artifact ID to download. If omitted, all artifacts are downloaded. Use 'bk artifacts list' to find IDs."` + ArtifactID string `arg:"" optional:"" help:"Artifact ID to download. If omitted, all matching artifacts are downloaded (see --path/--state). Use 'bk artifacts list' to find IDs."` BuildNumber string `help:"Build number containing the artifact. If omitted, the most recent build on the current branch will be used." short:"b" name:"build"` Pipeline string `help:"The pipeline containing the artifact. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}. If omitted, it will be resolved using the current directory." short:"p"` JobUUID string `help:"The job UUID containing the artifact." short:"j" name:"job-uuid"` + Path string `help:"Filter artifacts by path. Supports exact matches and glob patterns using * as a wildcard, e.g. --path \"log/rspec*.json\"."` + State string `help:"Filter artifacts to download by state (e.g. new, finished, error, deleted, expired)."` } func (c *DownloadCmd) Help() string { @@ -48,9 +52,29 @@ Examples: # Specify the pipeline explicitly $ bk artifacts download --build 429 -p monolith + + # Filter artifacts to download by path or state + $ bk artifacts download --build 429 --path "log/rspec*.json" + $ bk artifacts download --build 429 --state finished ` } +// validate checks flag combinations that can be rejected without any API +// calls. --job-uuid is deliberately allowed alongside an ArtifactID: +// findArtifact uses it as the fast path (Artifacts.Get) instead of listing +// and scanning. --path / --state have no meaning when targeting a single ID, +// so reject those combinations up front. +func (c *DownloadCmd) validate() error { + if c.ArtifactID != "" && (c.Path != "" || c.State != "") { + return bkErrors.NewValidationError( + nil, + "--path and --state cannot be used when downloading a specific artifact by ID", + "Omit the artifact ID to filter, or remove --path/--state to download by ID.", + ) + } + return nil +} + func (c *DownloadCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { @@ -65,6 +89,10 @@ func (c *DownloadCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error return err } + if err := c.validate(); err != nil { + return err + } + pipelineRes := pipelineResolver.NewAggregateResolver( pipelineResolver.ResolveFromFlag(c.Pipeline, f.Config), pipelineResolver.ResolveFromConfig(f.Config, pipelineResolver.PickOneWithFactory(f)), @@ -126,14 +154,14 @@ func (c *DownloadCmd) downloadAll(ctx context.Context, f *factory.Factory, org, if err := bkIO.SpinWhile(f, "Loading artifacts", func() error { var err error - artifacts, err = listArtifacts(ctx, f, org, pipeline, build, c.JobUUID) + artifacts, err = listArtifacts(ctx, f, org, pipeline, build, c.JobUUID, c.Path, strings.ToLower(c.State)) return err }); err != nil { return err } if len(artifacts) == 0 { - fmt.Println("No artifacts found.") + writeNoArtifactsMessage(os.Stdout, c.Path, c.State) return nil } @@ -165,7 +193,7 @@ func findArtifact(ctx context.Context, f *factory.Factory, org, pipeline, build, return &artifact, nil } - artifacts, err := listArtifacts(ctx, f, org, pipeline, build, "") + artifacts, err := listArtifacts(ctx, f, org, pipeline, build, "", "", "") if err != nil { return nil, err } @@ -180,9 +208,12 @@ func findArtifact(ctx context.Context, f *factory.Factory, org, pipeline, build, } // listArtifacts fetches all artifacts for a build or job, paginating through all results. -func listArtifacts(ctx context.Context, f *factory.Factory, org, pipeline, build, jobUUID string) ([]buildkite.Artifact, error) { +// path and state are optional filters passed through to the Buildkite API. +func listArtifacts(ctx context.Context, f *factory.Factory, org, pipeline, build, jobUUID, path, state string) ([]buildkite.Artifact, error) { var all []buildkite.Artifact opts := &buildkite.ArtifactListOptions{ + Path: path, + State: state, ListOptions: buildkite.ListOptions{PerPage: 100}, } @@ -191,6 +222,9 @@ func listArtifacts(ctx context.Context, f *factory.Factory, org, pipeline, build var resp *buildkite.Response var err error + // ListByJob and ListByBuild both take *ArtifactListOptions, which carries + // Path and State — so the same filters flow into either endpoint when + // --job-uuid is combined with --path / --state. if jobUUID != "" { artifacts, resp, err = f.RestAPIClient.Artifacts.ListByJob(ctx, org, pipeline, build, jobUUID, opts) } else { @@ -233,3 +267,19 @@ func downloadToFile(ctx context.Context, f *factory.Factory, url, destPath strin _, err = f.RestAPIClient.Artifacts.DownloadArtifactByURL(ctx, url, out) return err } + +// writeNoArtifactsMessage prints a "no artifacts" message tailored to the +// active --path / --state filters, so users see what constraint returned +// nothing. +func writeNoArtifactsMessage(w io.Writer, path, state string) { + switch { + case path != "" && state != "": + fmt.Fprintf(w, "No artifacts found matching path '%s' and state '%s'.\n", path, state) + case path != "": + fmt.Fprintf(w, "No artifacts found matching path '%s'.\n", path) + case state != "": + fmt.Fprintf(w, "No artifacts found matching state '%s'.\n", state) + default: + fmt.Fprintln(w, "No artifacts found.") + } +} diff --git a/cmd/artifacts/download_test.go b/cmd/artifacts/download_test.go new file mode 100644 index 00000000..c67d0658 --- /dev/null +++ b/cmd/artifacts/download_test.go @@ -0,0 +1,437 @@ +package artifacts + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/alecthomas/kong" + bkErrors "github.com/buildkite/cli/v3/internal/errors" + "github.com/buildkite/cli/v3/pkg/cmd/factory" + buildkite "github.com/buildkite/go-buildkite/v5" +) + +func newArtifactsTestFactory(t *testing.T, serverURL string) *factory.Factory { + t.Helper() + client, err := buildkite.NewOpts(buildkite.WithBaseURL(serverURL)) + if err != nil { + t.Fatalf("new buildkite client: %v", err) + } + return &factory.Factory{RestAPIClient: client, Quiet: true, NoInput: true} +} + +func writeArtifactsPage(t *testing.T, w http.ResponseWriter, artifacts []buildkite.Artifact, nextPageURL string) { + t.Helper() + if nextPageURL != "" { + w.Header().Set("Link", fmt.Sprintf(`<%s>; rel="next"`, nextPageURL)) + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(artifacts); err != nil { + t.Fatalf("encode artifacts: %v", err) + } +} + +func TestWriteNoArtifactsMessage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + state string + want string + }{ + {"no filters", "", "", "No artifacts found.\n"}, + {"path only", "coverage/**", "", "No artifacts found matching path 'coverage/**'.\n"}, + {"state only", "", "finished", "No artifacts found matching state 'finished'.\n"}, + {"both", "coverage/**", "finished", "No artifacts found matching path 'coverage/**' and state 'finished'.\n"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + writeNoArtifactsMessage(&buf, tt.path, tt.state) + if got := buf.String(); got != tt.want { + t.Fatalf("writeNoArtifactsMessage() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestListArtifactsHitsBuildEndpoint(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wantPath := "/v2/organizations/acme/pipelines/monolith/builds/429/artifacts" + if r.URL.Path != wantPath { + t.Fatalf("path = %q, want %q", r.URL.Path, wantPath) + } + writeArtifactsPage(t, w, []buildkite.Artifact{{ID: "a1", Path: "coverage.xml"}}, "") + })) + t.Cleanup(server.Close) + + f := newArtifactsTestFactory(t, server.URL) + got, err := listArtifacts(context.Background(), f, "acme", "monolith", "429", "", "", "") + if err != nil { + t.Fatalf("listArtifacts() error = %v", err) + } + if len(got) != 1 || got[0].ID != "a1" { + t.Fatalf("listArtifacts() = %+v, want single artifact a1", got) + } +} + +func TestListArtifactsHitsJobEndpoint(t *testing.T) { + t.Parallel() + + const jobUUID = "0193903e-ecd9-4c51-9156-0738da987e87" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wantPath := fmt.Sprintf("/v2/organizations/acme/pipelines/monolith/builds/429/jobs/%s/artifacts", jobUUID) + if r.URL.Path != wantPath { + t.Fatalf("path = %q, want %q", r.URL.Path, wantPath) + } + writeArtifactsPage(t, w, []buildkite.Artifact{{ID: "a1"}}, "") + })) + t.Cleanup(server.Close) + + f := newArtifactsTestFactory(t, server.URL) + if _, err := listArtifacts(context.Background(), f, "acme", "monolith", "429", jobUUID, "", ""); err != nil { + t.Fatalf("listArtifacts() error = %v", err) + } +} + +func TestListArtifactsPassesFilters(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if got := q.Get("path"); got != "coverage/**" { + t.Fatalf("path = %q, want coverage/**", got) + } + if got := q.Get("state"); got != "finished" { + t.Fatalf("state = %q, want finished", got) + } + if got := q.Get("per_page"); got != "100" { + t.Fatalf("per_page = %q, want 100", got) + } + writeArtifactsPage(t, w, []buildkite.Artifact{}, "") + })) + t.Cleanup(server.Close) + + f := newArtifactsTestFactory(t, server.URL) + if _, err := listArtifacts(context.Background(), f, "acme", "monolith", "429", "", "coverage/**", "finished"); err != nil { + t.Fatalf("listArtifacts() error = %v", err) + } +} + +func TestListArtifactsPassesFiltersOnJobEndpoint(t *testing.T) { + t.Parallel() + + const jobUUID = "0193903e-ecd9-4c51-9156-0738da987e87" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wantPath := fmt.Sprintf("/v2/organizations/acme/pipelines/monolith/builds/429/jobs/%s/artifacts", jobUUID) + if r.URL.Path != wantPath { + t.Fatalf("path = %q, want %q", r.URL.Path, wantPath) + } + q := r.URL.Query() + if got := q.Get("path"); got != "log/rspec*.json" { + t.Fatalf("path = %q, want log/rspec*.json", got) + } + if got := q.Get("state"); got != "finished" { + t.Fatalf("state = %q, want finished", got) + } + writeArtifactsPage(t, w, []buildkite.Artifact{}, "") + })) + t.Cleanup(server.Close) + + f := newArtifactsTestFactory(t, server.URL) + if _, err := listArtifacts(context.Background(), f, "acme", "monolith", "429", jobUUID, "log/rspec*.json", "finished"); err != nil { + t.Fatalf("listArtifacts() error = %v", err) + } +} + +func TestListArtifactsPaginates(t *testing.T) { + t.Parallel() + + var server *httptest.Server + var calls int + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + page := r.URL.Query().Get("page") + switch page { + case "", "1": + next := server.URL + r.URL.Path + "?page=2&per_page=100" + writeArtifactsPage(t, w, []buildkite.Artifact{{ID: "a1"}, {ID: "a2"}}, next) + case "2": + next := server.URL + r.URL.Path + "?page=3&per_page=100" + writeArtifactsPage(t, w, []buildkite.Artifact{{ID: "a3"}}, next) + case "3": + writeArtifactsPage(t, w, []buildkite.Artifact{{ID: "a4"}}, "") + default: + t.Fatalf("unexpected page = %q", page) + } + })) + t.Cleanup(server.Close) + + f := newArtifactsTestFactory(t, server.URL) + got, err := listArtifacts(context.Background(), f, "acme", "monolith", "429", "", "", "") + if err != nil { + t.Fatalf("listArtifacts() error = %v", err) + } + + wantIDs := []string{"a1", "a2", "a3", "a4"} + if len(got) != len(wantIDs) { + t.Fatalf("got %d artifacts, want %d", len(got), len(wantIDs)) + } + for i, id := range wantIDs { + if got[i].ID != id { + t.Fatalf("artifact %d ID = %q, want %q", i, got[i].ID, id) + } + } + if calls != 3 { + t.Fatalf("calls = %d, want 3", calls) + } +} + +func TestListArtifactsPropagatesError(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"message":"boom"}`, http.StatusInternalServerError) + })) + t.Cleanup(server.Close) + + f := newArtifactsTestFactory(t, server.URL) + if _, err := listArtifacts(context.Background(), f, "acme", "monolith", "429", "", "", ""); err == nil { + t.Fatal("listArtifacts() expected error, got nil") + } +} + +func TestFindArtifactWithJobUUIDUsesGetEndpoint(t *testing.T) { + t.Parallel() + + const ( + jobUUID = "0193903e-ecd9-4c51-9156-0738da987e87" + artID = "0191727d-b5ce-4576-b37d-477ae0ca830c" + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wantPath := fmt.Sprintf("/v2/organizations/acme/pipelines/monolith/builds/429/jobs/%s/artifacts/%s", jobUUID, artID) + if r.URL.Path != wantPath { + t.Fatalf("path = %q, want %q", r.URL.Path, wantPath) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(buildkite.Artifact{ID: artID, Path: "coverage.xml"}) + })) + t.Cleanup(server.Close) + + f := newArtifactsTestFactory(t, server.URL) + got, err := findArtifact(context.Background(), f, "acme", "monolith", "429", artID, jobUUID) + if err != nil { + t.Fatalf("findArtifact() error = %v", err) + } + if got == nil || got.ID != artID { + t.Fatalf("findArtifact() = %+v, want ID %q", got, artID) + } +} + +func TestFindArtifactWithoutJobUUIDScansList(t *testing.T) { + t.Parallel() + + const artID = "wanted" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wantPath := "/v2/organizations/acme/pipelines/monolith/builds/429/artifacts" + if r.URL.Path != wantPath { + t.Fatalf("path = %q, want %q", r.URL.Path, wantPath) + } + writeArtifactsPage(t, w, []buildkite.Artifact{ + {ID: "other"}, + {ID: artID, Path: "the-one.txt"}, + }, "") + })) + t.Cleanup(server.Close) + + f := newArtifactsTestFactory(t, server.URL) + got, err := findArtifact(context.Background(), f, "acme", "monolith", "429", artID, "") + if err != nil { + t.Fatalf("findArtifact() error = %v", err) + } + if got == nil || got.Path != "the-one.txt" { + t.Fatalf("findArtifact() = %+v, want the-one.txt", got) + } +} + +func TestFindArtifactNotFoundReturnsResourceError(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeArtifactsPage(t, w, []buildkite.Artifact{{ID: "other"}}, "") + })) + t.Cleanup(server.Close) + + f := newArtifactsTestFactory(t, server.URL) + _, err := findArtifact(context.Background(), f, "acme", "monolith", "429", "missing", "") + if err == nil { + t.Fatal("findArtifact() expected error, got nil") + } + if !errors.Is(err, bkErrors.ErrResourceNotFound) { + t.Fatalf("findArtifact() error = %v, want ErrResourceNotFound", err) + } + if !strings.Contains(err.Error(), "missing") { + t.Fatalf("findArtifact() error = %v, want to mention artifact ID", err) + } +} + +func TestDownloadToFileCreatesParentDirAndWritesBody(t *testing.T) { + t.Parallel() + + const body = "artifact-bytes" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(server.Close) + + f := newArtifactsTestFactory(t, server.URL) + destPath := filepath.Join(t.TempDir(), "nested", "dir", "file.bin") + + if err := downloadToFile(context.Background(), f, server.URL, destPath); err != nil { + t.Fatalf("downloadToFile() error = %v", err) + } + + got, err := os.ReadFile(destPath) + if err != nil { + t.Fatalf("read written file: %v", err) + } + if string(got) != body { + t.Fatalf("file contents = %q, want %q", got, body) + } +} + +func TestDownloadArtifactUsesArtifactPathAsDest(t *testing.T) { + // No t.Parallel(): t.Chdir is incompatible with parallel tests. + const body = "hello" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(server.Close) + + // Run from a temp cwd so the relative destPath lands somewhere isolated. + t.Chdir(t.TempDir()) + + f := newArtifactsTestFactory(t, server.URL) + art := &buildkite.Artifact{Path: "logs/rspec.json", DownloadURL: server.URL} + + dest, err := downloadArtifact(context.Background(), f, art) + if err != nil { + t.Fatalf("downloadArtifact() error = %v", err) + } + if dest != filepath.FromSlash("logs/rspec.json") { + t.Fatalf("dest = %q, want logs/rspec.json (OS-adjusted)", dest) + } + got, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("read written file: %v", err) + } + if string(got) != body { + t.Fatalf("file contents = %q, want %q", got, body) + } +} + +func TestDownloadCmdValidate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cmd DownloadCmd + wantErr bool + }{ + {"no artifact ID, no filters", DownloadCmd{}, false}, + {"filters without artifact ID", DownloadCmd{Path: "coverage/**", State: "finished"}, false}, + {"artifact ID alone", DownloadCmd{ArtifactID: "art-1"}, false}, + {"artifact ID with job UUID (fast path)", DownloadCmd{ArtifactID: "art-1", JobUUID: "job-1"}, false}, + {"artifact ID with path rejected", DownloadCmd{ArtifactID: "art-1", Path: "coverage/**"}, true}, + {"artifact ID with state rejected", DownloadCmd{ArtifactID: "art-1", State: "finished"}, true}, + {"artifact ID with both rejected", DownloadCmd{ArtifactID: "art-1", Path: "coverage/**", State: "finished"}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := tt.cmd.validate() + if tt.wantErr { + if err == nil { + t.Fatal("validate() = nil, want error") + } + if !errors.Is(err, bkErrors.ErrValidation) { + t.Fatalf("validate() error = %v, want ErrValidation", err) + } + if !strings.Contains(err.Error(), "--path and --state") { + t.Errorf("validate() error = %v, want to mention --path and --state", err) + } + return + } + if err != nil { + t.Fatalf("validate() = %v, want nil", err) + } + }) + } +} + +func TestDownloadCmdFlagParsing(t *testing.T) { + t.Parallel() + + var cmd DownloadCmd + parser, err := kong.New(&cmd) + if err != nil { + t.Fatalf("kong.New() error = %v", err) + } + if _, err := parser.Parse([]string{ + "art-123", + "--build", "429", + "-p", "monolith", + "--job-uuid", "job-uuid-1", + "--path", "coverage/**", + "--state", "Finished", + }); err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if cmd.ArtifactID != "art-123" { + t.Errorf("ArtifactID = %q, want art-123", cmd.ArtifactID) + } + if cmd.BuildNumber != "429" { + t.Errorf("BuildNumber = %q, want 429", cmd.BuildNumber) + } + if cmd.Pipeline != "monolith" { + t.Errorf("Pipeline = %q, want monolith", cmd.Pipeline) + } + if cmd.JobUUID != "job-uuid-1" { + t.Errorf("JobUUID = %q, want job-uuid-1", cmd.JobUUID) + } + if cmd.Path != "coverage/**" { + t.Errorf("Path = %q, want coverage/**", cmd.Path) + } + if cmd.State != "Finished" { + t.Errorf("State = %q, want Finished (parser preserves casing)", cmd.State) + } +} + +func TestDownloadCmdHelpMentionsFilters(t *testing.T) { + t.Parallel() + + var cmd DownloadCmd + help := cmd.Help() + for _, want := range []string{"--path", "--state", "log/rspec*.json", "bk artifacts list"} { + if !strings.Contains(help, want) { + t.Errorf("Help() missing %q", want) + } + } +} diff --git a/cmd/artifacts/list.go b/cmd/artifacts/list.go index 8f4c76f1..c477b45f 100644 --- a/cmd/artifacts/list.go +++ b/cmd/artifacts/list.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "strings" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/artifact" @@ -23,6 +24,8 @@ type ListCmd struct { BuildNumber string `arg:"" optional:"" help:"Build number to list artifacts for"` Pipeline string `help:"The pipeline to view. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}. If omitted, it will be resolved using the current directory." short:"p"` JobUUID string `help:"List artifacts for a specific job on the given build." short:"j" name:"job-uuid"` + Path string `help:"Filter artifacts by path. Supports exact matches and glob patterns using * as a wildcard, e.g. --path \"log/rspec*.json\"."` + State string `help:"Filter artifacts by state (e.g. new, finished, error, deleted, expired)."` output.OutputFlags } @@ -44,6 +47,10 @@ Examples: # If not inside a repository or to use a specific pipeline, pass -p $ bk artifacts list 429 -p monolith + + # Filter artifacts by path or state + $ bk artifacts list 429 --path "log/rspec*.json" + $ bk artifacts list 429 --state finished ` } @@ -99,7 +106,7 @@ func (c *ListCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { var buildArtifacts []buildkite.Artifact if err = bkIO.SpinWhile(f, "Loading artifacts information", func() error { - buildArtifacts, err = listArtifacts(ctx, f, bld.Organization, bld.Pipeline, fmt.Sprint(bld.BuildNumber), c.JobUUID) + buildArtifacts, err = listArtifacts(ctx, f, bld.Organization, bld.Pipeline, fmt.Sprint(bld.BuildNumber), c.JobUUID, c.Path, strings.ToLower(c.State)) return err }); err != nil { return err @@ -113,7 +120,7 @@ func (c *ListCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { defer func() { _ = cleanup() }() if len(buildArtifacts) == 0 { - fmt.Fprintln(writer, "No artifacts found.") + writeNoArtifactsMessage(writer, c.Path, c.State) return nil } diff --git a/cmd/artifacts/list_test.go b/cmd/artifacts/list_test.go new file mode 100644 index 00000000..7f621e9d --- /dev/null +++ b/cmd/artifacts/list_test.go @@ -0,0 +1,159 @@ +package artifacts + +import ( + "bytes" + "strings" + "testing" + + "github.com/alecthomas/kong" + buildkite "github.com/buildkite/go-buildkite/v5" +) + +func TestDisplayArtifactsRendersJobIDAsBuildkiteURL(t *testing.T) { + // Wide enough that the URL column doesn't get truncated. + t.Setenv("BUILDKITE_TABLE_MAX_WIDTH", "300") + + artifacts := []buildkite.Artifact{ + {ID: "art-1", Path: "logs/rspec.json", FileSize: 1024, JobID: "job-1"}, + } + const baseURL = "https://buildkite.com/organizations/acme/pipelines/monolith/builds/429" + + var buf bytes.Buffer + if err := displayArtifacts(artifacts, &buf, baseURL); err != nil { + t.Fatalf("displayArtifacts() error = %v", err) + } + + out := buf.String() + for _, want := range []string{ + "ID", "PATH", "SIZE", "URL", + "art-1", "logs/rspec.json", "1.0KB", + baseURL + "/jobs/job-1/artifacts/art-1", + } { + if !strings.Contains(out, want) { + t.Errorf("output missing %q\n---\n%s", want, out) + } + } +} + +func TestDisplayArtifactsFallsBackToArtifactURL(t *testing.T) { + t.Parallel() + + artifacts := []buildkite.Artifact{ + {ID: "art-2", Path: "output.zip", URL: "https://api.example.com/artifacts/art-2"}, + } + + var buf bytes.Buffer + if err := displayArtifacts(artifacts, &buf, "https://buildkite.com/x"); err != nil { + t.Fatalf("displayArtifacts() error = %v", err) + } + if !strings.Contains(buf.String(), "https://api.example.com/artifacts/art-2") { + t.Errorf("expected artifact URL to be used when JobID is empty:\n%s", buf.String()) + } +} + +func TestDisplayArtifactsRendersDashWhenNoURL(t *testing.T) { + t.Parallel() + + artifacts := []buildkite.Artifact{{ID: "art-3", Path: "orphan.txt"}} + + var buf bytes.Buffer + if err := displayArtifacts(artifacts, &buf, "https://buildkite.com/x"); err != nil { + t.Fatalf("displayArtifacts() error = %v", err) + } + // Table columns render "-" for artifacts with no JobID and no URL. + lines := strings.Split(strings.TrimSpace(buf.String()), "\n") + var dataLine string + for _, l := range lines { + if strings.Contains(l, "orphan.txt") { + dataLine = l + break + } + } + if dataLine == "" { + t.Fatalf("no data line for orphan.txt in:\n%s", buf.String()) + } + if !strings.Contains(dataLine, "-") { + t.Errorf("expected '-' placeholder for empty URL in row: %q", dataLine) + } +} + +func TestDisplayArtifactsEmpty(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + if err := displayArtifacts(nil, &buf, "https://buildkite.com/x"); err != nil { + t.Fatalf("displayArtifacts() error = %v", err) + } + // Headers should still render even when there are no rows. + for _, want := range []string{"ID", "PATH", "SIZE", "URL"} { + if !strings.Contains(buf.String(), want) { + t.Errorf("output missing header %q\n%s", want, buf.String()) + } + } +} + +func TestListCmdFlagParsing(t *testing.T) { + t.Parallel() + + var cmd ListCmd + parser, err := kong.New(&cmd, kong.Vars{"output_default_format": ""}) + if err != nil { + t.Fatalf("kong.New() error = %v", err) + } + if _, err := parser.Parse([]string{ + "429", + "-p", "monolith", + "--job-uuid", "job-uuid-1", + "--path", "log/rspec*.json", + "--state", "Finished", + }); err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if cmd.BuildNumber != "429" { + t.Errorf("BuildNumber = %q, want 429", cmd.BuildNumber) + } + if cmd.Pipeline != "monolith" { + t.Errorf("Pipeline = %q, want monolith", cmd.Pipeline) + } + if cmd.JobUUID != "job-uuid-1" { + t.Errorf("JobUUID = %q, want job-uuid-1", cmd.JobUUID) + } + if cmd.Path != "log/rspec*.json" { + t.Errorf("Path = %q, want log/rspec*.json", cmd.Path) + } + if cmd.State != "Finished" { + t.Errorf("State = %q, want Finished (parser preserves casing)", cmd.State) + } +} + +func TestListCmdBuildNumberOptional(t *testing.T) { + t.Parallel() + + var cmd ListCmd + parser, err := kong.New(&cmd, kong.Vars{"output_default_format": ""}) + if err != nil { + t.Fatalf("kong.New() error = %v", err) + } + if _, err := parser.Parse([]string{"--state", "finished"}); err != nil { + t.Fatalf("Parse() error = %v", err) + } + if cmd.BuildNumber != "" { + t.Errorf("BuildNumber = %q, want empty", cmd.BuildNumber) + } + if cmd.State != "finished" { + t.Errorf("State = %q, want finished", cmd.State) + } +} + +func TestListCmdHelpMentionsFilters(t *testing.T) { + t.Parallel() + + var cmd ListCmd + help := cmd.Help() + for _, want := range []string{"--path", "--state", "log/rspec*.json", "bk artifacts list"} { + if !strings.Contains(help, want) { + t.Errorf("Help() missing %q", want) + } + } +}