From 555b8f1bf984d36927d7b3595002ded236ed4596 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 4 Jul 2025 14:12:07 +0100 Subject: [PATCH] test(run view): update tests Signed-off-by: Babak K. Shandiz --- pkg/cmd/run/view/logs_test.go | 542 ++++++++++++++++++++ pkg/cmd/run/view/view_test.go | 929 +++++++++++++++++++++++----------- 2 files changed, 1177 insertions(+), 294 deletions(-) create mode 100644 pkg/cmd/run/view/logs_test.go diff --git a/pkg/cmd/run/view/logs_test.go b/pkg/cmd/run/view/logs_test.go new file mode 100644 index 000000000..b49a605ab --- /dev/null +++ b/pkg/cmd/run/view/logs_test.go @@ -0,0 +1,542 @@ +package view + +import ( + "archive/zip" + "bytes" + "io" + "net/http" + "testing" + + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/run/shared" + "github.com/cli/cli/v2/pkg/httpmock" + ghAPI "github.com/cli/go-gh/v2/pkg/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestZipLogFetcher(t *testing.T) { + zr := createZipReader(t, map[string]string{ + "foo.txt": "blah blah", + }) + + fetcher := &zipLogFetcher{ + File: zr.File[0], + } + + rc, err := fetcher.GetLog() + assert.NoError(t, err) + + defer rc.Close() + + content, err := io.ReadAll(rc) + assert.NoError(t, err) + assert.Equal(t, "blah blah", string(content)) +} + +func TestApiLogFetcher(t *testing.T) { + tests := []struct { + name string + httpStubs func(reg *httpmock.Registry) + wantErr string + wantContent string + }{ + { + // This is the real flow as of now. When we call the `/logs` + // endpoint, the server will respond with a 302 redirect, pointing + // to the actual log file URL. + name: "successful with redirect (HTTP 302, then HTTP 200)", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/123/logs"), + httpmock.WithHeader( + httpmock.StatusStringResponse(http.StatusFound, ""), + "Location", + "https://some.domain/the-actual-log", + ), + ) + reg.Register( + httpmock.REST("GET", "the-actual-log"), + httpmock.StringResponse("blah blah"), + ) + }, + wantContent: "blah blah", + }, + { + name: "successful without redirect (HTTP 200)", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/123/logs"), + httpmock.StatusStringResponse(http.StatusOK, "blah blah"), + ) + }, + wantContent: "blah blah", + }, + { + name: "failed with not found error (HTTP 404)", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/123/logs"), + httpmock.StatusStringResponse(http.StatusNotFound, ""), + ) + }, + wantErr: "log not found: 123", + }, + { + name: "failed with server error (HTTP 500)", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/123/logs"), + httpmock.JSONErrorResponse(http.StatusInternalServerError, ghAPI.HTTPError{ + Message: "blah blah", + StatusCode: http.StatusInternalServerError, + }), + ) + }, + wantErr: "HTTP 500: blah blah (https://api.github.com/repos/OWNER/REPO/actions/jobs/123/logs)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + tt.httpStubs(reg) + + httpClient := &http.Client{Transport: reg} + + fetcher := &apiLogFetcher{ + httpClient: httpClient, + repo: ghrepo.New("OWNER", "REPO"), + jobID: 123, + } + + rc, err := fetcher.GetLog() + + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + assert.Nil(t, rc) + return + } + + assert.NoError(t, err) + assert.NotNil(t, rc) + + content, err := io.ReadAll(rc) + assert.NoError(t, err) + + assert.NoError(t, rc.Close()) + assert.Equal(t, tt.wantContent, string(content)) + }) + } +} + +func TestGetZipLogMap(t *testing.T) { + tests := []struct { + name string + job shared.Job + zipReader *zip.Reader + // wantJobLog can be nil (i.e. not found) or string + wantJobLog any + // wantStepLogs elements can be nil (i.e. not found) or string + wantStepLogs []any + }{ + { + name: "job log missing from zip, but step log present", + job: shared.Job{ + ID: 123, + Name: "job foo", + Steps: []shared.Step{{ + Name: "step one", + Number: 1, + }}, + }, + zipReader: createZipReader(t, map[string]string{ + "job foo/1_step one.txt": "step one log", + }), + wantJobLog: nil, + wantStepLogs: []any{ + "step one log", + }, + }, + { + name: "matching job name and step number 1", + job: shared.Job{ + ID: 123, + Name: "job foo", + Steps: []shared.Step{{ + Name: "step one", + Number: 1, + }}, + }, + zipReader: createZipReader(t, map[string]string{ + "0_job foo.txt": "job log", + "job foo/1_step one.txt": "step one log", + }), + wantJobLog: "job log", + wantStepLogs: []any{ + "step one log", + }, + }, + { + name: "matching job name and step number 2", + job: shared.Job{ + ID: 123, + Name: "job foo", + Steps: []shared.Step{{ + Name: "step two", + Number: 2, + }}, + }, + zipReader: createZipReader(t, map[string]string{ + "0_job foo.txt": "job log", + "job foo/2_step two.txt": "step two log", + }), + wantJobLog: "job log", + wantStepLogs: []any{ + nil, // no log for step 1 + "step two log", + }, + }, + { + // We should just look for the step number and not the step name. + name: "matching job name and step number and mismatch step name", + job: shared.Job{ + ID: 123, + Name: "job foo", + Steps: []shared.Step{{ + Name: "mismatch", + Number: 1, + }}, + }, + zipReader: createZipReader(t, map[string]string{ + "0_job foo.txt": "job log", + "job foo/1_step one.txt": "step one log", + }), + wantJobLog: "job log", + wantStepLogs: []any{ + "step one log", + }, + }, + { + name: "matching job name and mismatch step number", + job: shared.Job{ + ID: 123, + Name: "job foo", + Steps: []shared.Step{{ + Name: "step two", + Number: 2, + }}, + }, + zipReader: createZipReader(t, map[string]string{ + "0_job foo.txt": "job log", + "job foo/1_step one.txt": "step one log", + }), + wantJobLog: "job log", + wantStepLogs: []any{ + nil, // no log for step 1 + nil, // no log for step 2 + }, + }, + { + name: "matching job name with no step logs in zip", + job: shared.Job{ + ID: 123, + Name: "job foo", + Steps: []shared.Step{{ + Name: "step one", + Number: 1, + }}, + }, + zipReader: createZipReader(t, map[string]string{ + "0_job foo.txt": "job log", + }), + wantJobLog: "job log", + wantStepLogs: []any{ + nil, // no log for step 1 + }, + }, + { + name: "matching job name with no step data", + job: shared.Job{ + ID: 123, + Name: "job foo", + }, + zipReader: createZipReader(t, map[string]string{ + "0_job foo.txt": "job log", + }), + wantJobLog: "job log", + wantStepLogs: []any{ + nil, // no log for step 1 + }, + }, + { + name: "matching job name with random prefix and no step logs in zip", + job: shared.Job{ + ID: 123, + Name: "job foo", + Steps: []shared.Step{{ + Name: "step one", + Number: 1, + }}, + }, + zipReader: createZipReader(t, map[string]string{ + "999999999_job foo.txt": "job log", + }), + wantJobLog: "job log", + wantStepLogs: []any{ + nil, // no log for step 1 + }, + }, + { + name: "matching job name with legacy filename and no step logs in zip", + job: shared.Job{ + ID: 123, + Name: "job foo", + Steps: []shared.Step{{ + Name: "step one", + Number: 1, + }}, + }, + zipReader: createZipReader(t, map[string]string{ + "-9999999999_job foo.txt": "job log", + }), + wantJobLog: "job log", + wantStepLogs: []any{ + nil, // no log for step 1 + }, + }, + { + name: "matching job name with legacy filename and no step data", + job: shared.Job{ + ID: 123, + Name: "job foo", + }, + zipReader: createZipReader(t, map[string]string{ + "-9999999999_job foo.txt": "job log", + }), + wantJobLog: "job log", + wantStepLogs: []any{ + nil, // no log for step 1 + }, + }, + { + name: "matching job name with both normal and legacy filename", + job: shared.Job{ + ID: 123, + Name: "job foo", + }, + zipReader: createZipReader(t, map[string]string{ + "0_job foo.txt": "job log", + "-9999999999_job foo.txt": "legacy job log", + }), + wantJobLog: "job log", + wantStepLogs: []any{ + nil, // no log for step 1 + }, + }, + { + name: "one job name is a suffix of another", + job: shared.Job{ + ID: 123, + Name: "job foo", + Steps: []shared.Step{{ + Name: "step one", + Number: 1, + }}, + }, + zipReader: createZipReader(t, map[string]string{ + "0_jjob foo.txt": "the other job log", + "jjob foo/1_step one.txt": "the other step one log", + "1_job foo.txt": "job log", + "job foo/1_step one.txt": "step one log", + }), + wantJobLog: "job log", + wantStepLogs: []any{ + "step one log", + }, + }, + { + name: "escape metacharacters in job name", + job: shared.Job{ + ID: 123, + Name: "metacharacters .+*?()|[]{}^$ job", + Steps: []shared.Step{{ + Name: "step one", + Number: 1, + }}, + }, + zipReader: createZipReader(t, nil), + wantJobLog: nil, + wantStepLogs: []any{ + nil, // no log for step 1 + }, + }, + { + name: "mismatching job name", + job: shared.Job{ + ID: 123, + Name: "mismatch", + Steps: []shared.Step{{ + Name: "step one", + Number: 1, + }}, + }, + zipReader: createZipReader(t, nil), + wantJobLog: nil, + wantStepLogs: []any{ + nil, // no log for step 1 + }, + }, + { + name: "job name with forward slash matches dir with slash removed", + job: shared.Job{ + ID: 123, + Name: "job foo / with slash", + Steps: []shared.Step{{ + Name: "step one", + Number: 1, + }}, + }, + zipReader: createZipReader(t, map[string]string{ + "0_job foo with slash.txt": "job log", + "job foo with slash/1_step one.txt": "step one log", + }), + wantJobLog: "job log", + wantStepLogs: []any{ + "step one log", + }, + }, + { + name: "job name with colon matches dir with colon removed", + job: shared.Job{ + ID: 123, + Name: "job foo : with colon", + Steps: []shared.Step{{ + Name: "step one", + Number: 1, + }}, + }, + zipReader: createZipReader(t, map[string]string{ + "0_job foo with colon.txt": "job log", + "job foo with colon/1_step one.txt": "step one log", + }), + wantJobLog: "job log", + wantStepLogs: []any{ + "step one log", + }, + }, + { + name: "job name with really long name (over the ZIP limit)", + job: shared.Job{ + ID: 123, + Name: "thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_", + Steps: []shared.Step{{ + Name: "long name job", + Number: 1, + }}, + }, + zipReader: createZipReader(t, map[string]string{ + "thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnine/1_long name job.txt": "step one log", + }), + wantJobLog: nil, + wantStepLogs: []any{ + "step one log", + }, + }, + { + name: "job name that would be truncated by the C# server to split a grapheme", + job: shared.Job{ + ID: 123, + Name: "emoji test 😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅", + Steps: []shared.Step{{ + Name: "emoji job", + Number: 1, + }}, + }, + zipReader: createZipReader(t, map[string]string{ + "emoji test 😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅�/1_emoji job.txt": "step one log", + }), + wantJobLog: nil, + wantStepLogs: []any{ + "step one log", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + logMap := getZipLogMap(tt.zipReader, []shared.Job{tt.job}) + + jobLogFile, ok := logMap.forJob(tt.job.ID) + + switch want := tt.wantJobLog.(type) { + case nil: + require.False(t, ok) + require.Nil(t, jobLogFile) + case string: + require.True(t, ok) + require.NotNil(t, jobLogFile) + require.Equal(t, want, string(readZipFile(t, jobLogFile))) + default: + t.Fatal("wantJobLog must be nil or string") + } + + for i, wantStepLog := range tt.wantStepLogs { + stepLogFile, ok := logMap.forStep(tt.job.ID, 1+i) // Step numbers start from 1 + + switch want := wantStepLog.(type) { + case nil: + require.False(t, ok) + require.Nil(t, stepLogFile) + case string: + require.True(t, ok) + require.NotNil(t, stepLogFile) + + gotStepLog := readZipFile(t, stepLogFile) + require.Equal(t, want, string(gotStepLog)) + default: + t.Fatal("wantStepLog must be nil or string") + } + } + }) + } +} + +func readZipFile(t *testing.T, zf *zip.File) []byte { + rc, err := zf.Open() + assert.NoError(t, err) + defer rc.Close() + + content, err := io.ReadAll(rc) + assert.NoError(t, err) + return content +} + +func createZipReader(t *testing.T, files map[string]string) *zip.Reader { + raw := createZipArchive(t, files) + + zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + assert.NoError(t, err) + + return zr +} + +func createZipArchive(t *testing.T, files map[string]string) []byte { + buf := bytes.NewBuffer(nil) + zw := zip.NewWriter(buf) + + for name, content := range files { + fileWriter, err := zw.Create(name) + assert.NoError(t, err) + + _, err = fileWriter.Write([]byte(content)) + assert.NoError(t, err) + } + + err := zw.Close() + assert.NoError(t, err) + + return buf.Bytes() +} diff --git a/pkg/cmd/run/view/view_test.go b/pkg/cmd/run/view/view_test.go index 2d150934f..4c3a6a471 100644 --- a/pkg/cmd/run/view/view_test.go +++ b/pkg/cmd/run/view/view_test.go @@ -1,13 +1,12 @@ package view import ( - "archive/zip" "bytes" "fmt" "io" "net/http" "net/url" - "os" + "slices" "strings" "testing" "time" @@ -177,6 +176,65 @@ func TestNewCmdView(t *testing.T) { } func TestViewRun(t *testing.T) { + emptyZipArchive := createZipArchive(t, map[string]string{}) + zipArchive := createZipArchive(t, map[string]string{ + "0_cool job.txt": heredoc.Doc(` + log line 1 + log line 2 + log line 3 + log line 1 + log line 2 + log line 3`), + "cool job/1_fob the barz.txt": heredoc.Doc(` + log line 1 + log line 2 + log line 3 + `), + "cool job/2_barz the fob.txt": heredoc.Doc(` + log line 1 + log line 2 + log line 3 + `), + "1_sad job.txt": heredoc.Doc(` + log line 1 + log line 2 + log line 3 + log line 1 + log line 2 + log line 3 + `), + "sad job/1_barf the quux.txt": heredoc.Doc(` + log line 1 + log line 2 + log line 3 + `), + "sad job/2_quuz the barf.txt": heredoc.Doc(` + log line 1 + log line 2 + log line 3 + `), + "2_cool job with no step logs.txt": heredoc.Doc(` + log line 1 + log line 2 + log line 3 + `), + "3_sad job with no step logs.txt": heredoc.Doc(` + log line 1 + log line 2 + log line 3 + `), + "-9999999999_legacy cool job with no step logs.txt": heredoc.Doc(` + log line 1 + log line 2 + log line 3 + `), + "-9999999999_legacy sad job with no step logs.txt": heredoc.Doc(` + log line 1 + log line 2 + log line 3 + `), + }) + tests := []struct { name string httpStubs func(*httpmock.Registry) @@ -579,7 +637,7 @@ func TestViewRun(t *testing.T) { })) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), - httpmock.FileResponse("./fixtures/run_log.zip")) + httpmock.BinaryResponse(zipArchive)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), httpmock.JSONResponse(shared.TestWorkflow)) @@ -632,7 +690,7 @@ func TestViewRun(t *testing.T) { })) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/attempts/3/logs"), - httpmock.FileResponse("./fixtures/run_log.zip")) + httpmock.BinaryResponse(zipArchive)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), httpmock.JSONResponse(shared.TestWorkflow)) @@ -673,7 +731,7 @@ func TestViewRun(t *testing.T) { httpmock.JSONResponse(shared.SuccessfulRun)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), - httpmock.FileResponse("./fixtures/run_log.zip")) + httpmock.BinaryResponse(zipArchive)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), httpmock.JSONResponse(shared.TestWorkflow)) @@ -696,7 +754,7 @@ func TestViewRun(t *testing.T) { httpmock.JSONResponse(shared.SuccessfulRun)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/attempts/3/logs"), - httpmock.FileResponse("./fixtures/run_log.zip")) + httpmock.BinaryResponse(zipArchive)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), httpmock.JSONResponse(shared.TestWorkflow)) @@ -729,7 +787,7 @@ func TestViewRun(t *testing.T) { })) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), - httpmock.FileResponse("./fixtures/run_log.zip")) + httpmock.BinaryResponse(zipArchive)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), httpmock.JSONResponse(shared.TestWorkflow)) @@ -776,7 +834,7 @@ func TestViewRun(t *testing.T) { })) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), - httpmock.FileResponse("./fixtures/run_log.zip")) + httpmock.BinaryResponse(zipArchive)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), httpmock.JSONResponse(shared.TestWorkflow)) @@ -809,7 +867,7 @@ func TestViewRun(t *testing.T) { })) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), - httpmock.FileResponse("./fixtures/run_log.zip")) + httpmock.BinaryResponse(zipArchive)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), httpmock.JSONResponse(shared.TestWorkflow)) @@ -862,7 +920,7 @@ func TestViewRun(t *testing.T) { })) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/attempts/3/logs"), - httpmock.FileResponse("./fixtures/run_log.zip")) + httpmock.BinaryResponse(zipArchive)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), httpmock.JSONResponse(shared.TestWorkflow)) @@ -903,7 +961,7 @@ func TestViewRun(t *testing.T) { httpmock.JSONResponse(shared.FailedRun)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), - httpmock.FileResponse("./fixtures/run_log.zip")) + httpmock.BinaryResponse(zipArchive)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), httpmock.JSONResponse(shared.TestWorkflow)) @@ -936,7 +994,7 @@ func TestViewRun(t *testing.T) { })) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), - httpmock.FileResponse("./fixtures/run_log.zip")) + httpmock.BinaryResponse(zipArchive)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), httpmock.JSONResponse(workflowShared.WorkflowsPayload{ @@ -983,7 +1041,7 @@ func TestViewRun(t *testing.T) { })) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), - httpmock.FileResponse("./fixtures/run_log.zip")) + httpmock.BinaryResponse(zipArchive)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), httpmock.JSONResponse(shared.TestWorkflow)) @@ -1016,7 +1074,7 @@ func TestViewRun(t *testing.T) { })) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), - httpmock.FileResponse("./fixtures/run_log.zip")) + httpmock.BinaryResponse(zipArchive)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), httpmock.JSONResponse(shared.TestWorkflow)) @@ -1057,7 +1115,7 @@ func TestViewRun(t *testing.T) { httpmock.JSONResponse(shared.SuccessfulRun)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), - httpmock.FileResponse("./fixtures/run_log.zip")) + httpmock.BinaryResponse(zipArchive)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), httpmock.JSONResponse(shared.TestWorkflow)) @@ -1090,7 +1148,7 @@ func TestViewRun(t *testing.T) { })) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), - httpmock.FileResponse("./fixtures/run_log.zip")) + httpmock.BinaryResponse(zipArchive)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), httpmock.JSONResponse(shared.TestWorkflow)) @@ -1131,7 +1189,7 @@ func TestViewRun(t *testing.T) { httpmock.JSONResponse(shared.FailedRun)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), - httpmock.FileResponse("./fixtures/run_log.zip")) + httpmock.BinaryResponse(zipArchive)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), httpmock.JSONResponse(shared.TestWorkflow)) @@ -1164,7 +1222,7 @@ func TestViewRun(t *testing.T) { })) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), - httpmock.FileResponse("./fixtures/run_log.zip")) + httpmock.BinaryResponse(zipArchive)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), httpmock.JSONResponse(shared.TestWorkflow)) @@ -1210,7 +1268,7 @@ func TestViewRun(t *testing.T) { })) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), - httpmock.FileResponse("./fixtures/run_log.zip")) + httpmock.BinaryResponse(zipArchive)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), httpmock.JSONResponse(shared.TestWorkflow)) @@ -1243,7 +1301,7 @@ func TestViewRun(t *testing.T) { })) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), - httpmock.FileResponse("./fixtures/run_log.zip")) + httpmock.BinaryResponse(zipArchive)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), httpmock.JSONResponse(shared.TestWorkflow)) @@ -1289,7 +1347,7 @@ func TestViewRun(t *testing.T) { })) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), - httpmock.FileResponse("./fixtures/run_log.zip")) + httpmock.BinaryResponse(zipArchive)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), httpmock.JSONResponse(shared.TestWorkflow)) @@ -1322,7 +1380,7 @@ func TestViewRun(t *testing.T) { })) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), - httpmock.FileResponse("./fixtures/run_log.zip")) + httpmock.BinaryResponse(zipArchive)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), httpmock.JSONResponse(shared.TestWorkflow)) @@ -1363,7 +1421,7 @@ func TestViewRun(t *testing.T) { httpmock.JSONResponse(shared.SuccessfulRun)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), - httpmock.FileResponse("./fixtures/run_log.zip")) + httpmock.BinaryResponse(zipArchive)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), httpmock.JSONResponse(shared.TestWorkflow)) @@ -1397,7 +1455,7 @@ func TestViewRun(t *testing.T) { })) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), - httpmock.FileResponse("./fixtures/run_log.zip")) + httpmock.BinaryResponse(zipArchive)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), httpmock.JSONResponse(shared.TestWorkflow)) @@ -1438,7 +1496,7 @@ func TestViewRun(t *testing.T) { httpmock.JSONResponse(shared.FailedRun)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), - httpmock.FileResponse("./fixtures/run_log.zip")) + httpmock.BinaryResponse(zipArchive)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), httpmock.JSONResponse(shared.TestWorkflow)) @@ -1471,7 +1529,7 @@ func TestViewRun(t *testing.T) { })) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), - httpmock.FileResponse("./fixtures/run_log.zip")) + httpmock.BinaryResponse(zipArchive)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), httpmock.JSONResponse(shared.TestWorkflow)) @@ -1517,7 +1575,7 @@ func TestViewRun(t *testing.T) { })) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), - httpmock.FileResponse("./fixtures/run_log.zip")) + httpmock.BinaryResponse(zipArchive)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), httpmock.JSONResponse(shared.TestWorkflow)) @@ -1550,7 +1608,7 @@ func TestViewRun(t *testing.T) { })) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), - httpmock.FileResponse("./fixtures/run_log.zip")) + httpmock.BinaryResponse(zipArchive)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), httpmock.JSONResponse(shared.TestWorkflow)) @@ -1596,13 +1654,558 @@ func TestViewRun(t *testing.T) { })) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), - httpmock.FileResponse("./fixtures/run_log.zip")) + httpmock.BinaryResponse(zipArchive)) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), httpmock.JSONResponse(shared.TestWorkflow)) }, wantOut: legacySadJobRunWithNoStepLogsLogOutput, }, + { + name: "interactive with log, fallback to retrieve job logs from API (#11169)", + tty: true, + opts: &ViewOptions{ + Prompt: true, + Log: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: shared.TestRuns, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "runs/3/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.SuccessfulJob, + shared.FailedJob, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), + httpmock.BinaryResponse(emptyZipArchive)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(workflowShared.WorkflowsPayload{ + Workflows: []workflowShared.Workflow{ + shared.TestWorkflow, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/10/logs"), + httpmock.StringResponse("blah blah")) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow run", + []string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021") + }) + pm.RegisterSelect("View a specific job in this run?", + []string{"View all jobs in this run", "✓ cool job", "X sad job"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "✓ cool job") + }) + }, + wantOut: "cool job\tUNKNOWN STEP\tblah blah\n", + }, + { + name: "noninteractive with log, fallback to retrieve job logs from API (#11169)", + opts: &ViewOptions{ + JobID: "10", + Log: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/10"), + httpmock.JSONResponse(shared.SuccessfulJob)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), + httpmock.BinaryResponse(emptyZipArchive)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/10/logs"), + httpmock.StringResponse("blah blah")) + }, + wantOut: "cool job\tUNKNOWN STEP\tblah blah\n", + }, + { + name: "interactive with run log, fallback to retrieve job logs from API (#11169)", + tty: true, + opts: &ViewOptions{ + Prompt: true, + Log: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: shared.TestRuns, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "runs/3/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.SuccessfulJob, + shared.FailedJob, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), + httpmock.BinaryResponse(emptyZipArchive)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(workflowShared.WorkflowsPayload{ + Workflows: []workflowShared.Workflow{ + shared.TestWorkflow, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/10/logs"), + httpmock.StringResponse("blah blah")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/20/logs"), + httpmock.StringResponse("yo yo")) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow run", + []string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021") + }) + pm.RegisterSelect("View a specific job in this run?", + []string{"View all jobs in this run", "✓ cool job", "X sad job"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "View all jobs in this run") + }) + }, + wantOut: "cool job\tUNKNOWN STEP\tblah blah\nsad job\tUNKNOWN STEP\tyo yo\n", + }, + { + name: "noninteractive with run log, fallback to retrieve job logs from API (#11169)", + opts: &ViewOptions{ + RunID: "3", + Log: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "runs/3/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.SuccessfulJob, + shared.FailedJob, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), + httpmock.BinaryResponse(emptyZipArchive)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/10/logs"), + httpmock.StringResponse("blah blah")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/20/logs"), + httpmock.StringResponse("yo yo")) + }, + wantOut: "cool job\tUNKNOWN STEP\tblah blah\nsad job\tUNKNOWN STEP\tyo yo\n", + }, + { + name: "interactive with log-failed, fallback to retrieve job logs from API (#11169)", + tty: true, + opts: &ViewOptions{ + Prompt: true, + LogFailed: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: shared.TestRuns, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "runs/3/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.SuccessfulJob, + shared.FailedJob, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), + httpmock.BinaryResponse(emptyZipArchive)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(workflowShared.WorkflowsPayload{ + Workflows: []workflowShared.Workflow{ + shared.TestWorkflow, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/20/logs"), + httpmock.StringResponse("yo yo")) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow run", + []string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021") + }) + pm.RegisterSelect("View a specific job in this run?", + []string{"View all jobs in this run", "✓ cool job", "X sad job"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "X sad job") + }) + }, + wantOut: "sad job\tUNKNOWN STEP\tyo yo\n", + }, + { + name: "noninteractive with log-failed, fallback to retrieve job logs from API (#11169)", + opts: &ViewOptions{ + JobID: "20", + LogFailed: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/20"), + httpmock.JSONResponse(shared.FailedJob)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(shared.FailedRun)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), + httpmock.BinaryResponse(emptyZipArchive)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/20/logs"), + httpmock.StringResponse("yo yo")) + }, + wantOut: "sad job\tUNKNOWN STEP\tyo yo\n", + }, + { + name: "interactive with run log-failed, fallback to retrieve job logs from API (#11169)", + tty: true, + opts: &ViewOptions{ + Prompt: true, + LogFailed: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: shared.TestRuns, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(shared.FailedRun)) + reg.Register( + httpmock.REST("GET", "runs/1234/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.SuccessfulJob, + shared.FailedJob, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), + httpmock.BinaryResponse(emptyZipArchive)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(workflowShared.WorkflowsPayload{ + Workflows: []workflowShared.Workflow{ + shared.TestWorkflow, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/20/logs"), + httpmock.StringResponse("yo yo")) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow run", + []string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"}, + func(_, _ string, opts []string) (int, error) { + return 4, nil + }) + pm.RegisterSelect("View a specific job in this run?", + []string{"View all jobs in this run", "✓ cool job", "X sad job"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "View all jobs in this run") + }) + }, + wantOut: "sad job\tUNKNOWN STEP\tyo yo\n", + }, + { + name: "noninteractive with run log-failed, fallback to retrieve job logs from API (#11169)", + opts: &ViewOptions{ + RunID: "1234", + LogFailed: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(shared.FailedRun)) + reg.Register( + httpmock.REST("GET", "runs/1234/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.SuccessfulJob, + shared.FailedJob, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), + httpmock.BinaryResponse(emptyZipArchive)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/20/logs"), + httpmock.StringResponse("yo yo")) + }, + wantOut: "sad job\tUNKNOWN STEP\tyo yo\n", + }, + { + name: "interactive with run log, too many API calls required error (#11169)", + tty: true, + opts: &ViewOptions{ + Prompt: true, + Log: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: shared.TestRuns, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + + tooManyJobs := make([]shared.Job, 1+maxAPILogFetchers) + for i := range tooManyJobs { + tooManyJobs[i] = shared.SuccessfulJob + tooManyJobs[i].ID = int64(i + 100) + } + reg.Register( + httpmock.REST("GET", "runs/3/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: tooManyJobs, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), + httpmock.BinaryResponse(emptyZipArchive)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(workflowShared.WorkflowsPayload{ + Workflows: []workflowShared.Workflow{ + shared.TestWorkflow, + }, + })) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow run", + []string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021") + }) + pm.RegisterSelect("View a specific job in this run?", + slices.Concat([]string{"View all jobs in this run"}, slices.Repeat([]string{"✓ cool job"}, 26)), + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "View all jobs in this run") + }) + }, + wantErr: true, + errMsg: "too many API requests needed to fetch logs; try narrowing down to a specific job with the `--job` option", + }, + { + name: "noninteractive with run log, too many API calls required error (#11169)", + opts: &ViewOptions{ + RunID: "3", + Log: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + + tooManyJobs := make([]shared.Job, 1+maxAPILogFetchers) + for i := range tooManyJobs { + tooManyJobs[i] = shared.SuccessfulJob + tooManyJobs[i].ID = int64(i + 100) + } + reg.Register( + httpmock.REST("GET", "runs/3/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: tooManyJobs, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), + httpmock.BinaryResponse(emptyZipArchive)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + }, + wantErr: true, + errMsg: "too many API requests needed to fetch logs; try narrowing down to a specific job with the `--job` option", + }, + { + name: "interactive with run log-failed, too many API calls required error (#11169)", + tty: true, + opts: &ViewOptions{ + Prompt: true, + LogFailed: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: shared.TestRuns, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(shared.FailedRun)) + + tooManyJobs := make([]shared.Job, 1+maxAPILogFetchers) + for i := range tooManyJobs { + tooManyJobs[i] = shared.FailedJob + tooManyJobs[i].ID = int64(i + 100) + } + reg.Register( + httpmock.REST("GET", "runs/1234/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: tooManyJobs, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), + httpmock.BinaryResponse(emptyZipArchive)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(workflowShared.WorkflowsPayload{ + Workflows: []workflowShared.Workflow{ + shared.TestWorkflow, + }, + })) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow run", + []string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"}, + func(_, _ string, opts []string) (int, error) { + return 4, nil + }) + pm.RegisterSelect("View a specific job in this run?", + slices.Concat([]string{"View all jobs in this run"}, slices.Repeat([]string{"X sad job"}, 26)), + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "View all jobs in this run") + }) + }, + wantErr: true, + errMsg: "too many API requests needed to fetch logs; try narrowing down to a specific job with the `--job` option", + }, + { + name: "noninteractive with run log-failed, too many API calls required error (#11169)", + opts: &ViewOptions{ + RunID: "1234", + LogFailed: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(shared.FailedRun)) + + tooManyJobs := make([]shared.Job, 1+maxAPILogFetchers) + for i := range tooManyJobs { + tooManyJobs[i] = shared.FailedJob + tooManyJobs[i].ID = int64(i + 100) + } + reg.Register( + httpmock.REST("GET", "runs/1234/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: tooManyJobs, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), + httpmock.BinaryResponse(emptyZipArchive)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + }, + wantErr: true, + errMsg: "too many API requests needed to fetch logs; try narrowing down to a specific job with the `--job` option", + }, + { + name: "noninteractive with run log-failed, maximum API calls allowed (#11169)", + opts: &ViewOptions{ + RunID: "1234", + LogFailed: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(shared.FailedRun)) + + tooManyJobs := make([]shared.Job, maxAPILogFetchers) + for i := range tooManyJobs { + tooManyJobs[i] = shared.FailedJob + tooManyJobs[i].ID = int64(i + 100) + } + reg.Register( + httpmock.REST("GET", "runs/1234/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: tooManyJobs, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), + httpmock.BinaryResponse(emptyZipArchive)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + for i := range tooManyJobs { + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/OWNER/REPO/actions/jobs/%d/logs", i+100)), + httpmock.StringResponse("yo yo")) + } + }, + wantOut: strings.Repeat("sad job\tUNKNOWN STEP\tyo yo\n", maxAPILogFetchers), + }, { name: "run log but run is not done", tty: true, @@ -2021,268 +2624,6 @@ func TestViewRun(t *testing.T) { } } -// Structure of fixture zip file -// To see the structure of fixture zip file, run: -// `❯ unzip -lv pkg/cmd/run/view/fixtures/run_log.zip` -// -// run log/ -// ├── cool job/ -// │ ├── 1_fob the barz.txt -// │ └── 2_barz the fob.txt -// ├── sad job/ -// │ ├── 1_barf the quux.txt -// │ └── 2_quux the barf.txt -// ├── ad job/ -// | └── 1_barf the quux.txt -// ├── 0_cool job.txt -// ├── 1_sad job.txt -// ├── 2_cool job with no step logs.txt -// ├── 3_sad job with no step logs.txt -// ├── -9999999999_legacy cool job with no step logs.txt -// ├── -9999999999_legacy sad job with no step logs.txt -// ├── 4_cool job with both legacy and new logs.txt -// └── -9999999999_cool job with both legacy and new logs.txt -func Test_attachRunLog(t *testing.T) { - tests := []struct { - name string - job shared.Job - wantJobMatch bool - wantJobFilename string - wantStepMatch bool - wantStepFilename string - }{ - { - name: "matching job name and step number 1", - job: shared.Job{ - Name: "cool job", - Steps: []shared.Step{{ - Name: "fob the barz", - Number: 1, - }}, - }, - wantJobMatch: true, - wantJobFilename: "0_cool job.txt", - wantStepMatch: true, - wantStepFilename: "cool job/1_fob the barz.txt", - }, - { - name: "matching job name and step number 2", - job: shared.Job{ - Name: "cool job", - Steps: []shared.Step{{ - Name: "barz the fob", - Number: 2, - }}, - }, - wantJobMatch: true, - wantJobFilename: "0_cool job.txt", - wantStepMatch: true, - wantStepFilename: "cool job/2_barz the fob.txt", - }, - { - name: "matching job name and step number and mismatch step name", - job: shared.Job{ - Name: "cool job", - Steps: []shared.Step{{ - Name: "mismatch", - Number: 1, - }}, - }, - wantJobMatch: true, - wantJobFilename: "0_cool job.txt", - wantStepMatch: true, - wantStepFilename: "cool job/1_fob the barz.txt", - }, - { - name: "matching job name and mismatch step number", - job: shared.Job{ - Name: "cool job", - Steps: []shared.Step{{ - Name: "fob the barz", - Number: 3, - }}, - }, - wantJobMatch: true, - wantJobFilename: "0_cool job.txt", - wantStepMatch: false, - }, - { - name: "matching job name with no step logs", - job: shared.Job{ - Name: "cool job with no step logs", - Steps: []shared.Step{{ - Name: "fob the barz", - Number: 1, - }}, - }, - wantJobMatch: true, - wantJobFilename: "2_cool job with no step logs.txt", - wantStepMatch: false, - }, - { - name: "matching job name with no step data", - job: shared.Job{ - Name: "cool job with no step logs", - }, - wantJobMatch: true, - wantJobFilename: "2_cool job with no step logs.txt", - wantStepMatch: false, - }, - { - name: "matching job name with legacy filename and no step logs", - job: shared.Job{ - Name: "legacy cool job with no step logs", - Steps: []shared.Step{{ - Name: "fob the barz", - Number: 1, - }}, - }, - wantJobMatch: true, - wantJobFilename: "-9999999999_legacy cool job with no step logs.txt", - wantStepMatch: false, - }, - { - name: "matching job name with legacy filename and no step data", - job: shared.Job{ - Name: "legacy cool job with no step logs", - }, - wantJobMatch: true, - wantJobFilename: "-9999999999_legacy cool job with no step logs.txt", - wantStepMatch: false, - }, - { - name: "matching job name with both normal and legacy filename", - job: shared.Job{ - Name: "cool job with both legacy and new logs", - }, - wantJobMatch: true, - wantJobFilename: "4_cool job with both legacy and new logs.txt", - wantStepMatch: false, - }, - { - name: "one job name is a suffix of another", - job: shared.Job{ - Name: "ad job", - Steps: []shared.Step{{ - Name: "barf the quux", - Number: 1, - }}, - }, - wantStepMatch: true, - wantStepFilename: "ad job/1_barf the quux.txt", - }, - { - name: "escape metacharacters in job name", - job: shared.Job{ - Name: "metacharacters .+*?()|[]{}^$ job", - Steps: []shared.Step{{ - Name: "fob the barz", - Number: 0, - }}, - }, - wantJobMatch: false, - wantStepMatch: false, - }, - { - name: "mismatching job name", - job: shared.Job{ - Name: "mismatch", - Steps: []shared.Step{{ - Name: "fob the barz", - Number: 1, - }}, - }, - wantJobMatch: false, - wantStepMatch: false, - }, - { - name: "job name with forward slash matches dir with slash removed", - job: shared.Job{ - Name: "cool job / with slash", - Steps: []shared.Step{{ - Name: "fob the barz", - Number: 1, - }}, - }, - wantJobMatch: false, - wantStepMatch: true, - // not the double space in the dir name, as the slash has been removed - wantStepFilename: "cool job with slash/1_fob the barz.txt", - }, - { - name: "job name with colon matches dir with colon removed", - job: shared.Job{ - Name: "cool job : with colon", - Steps: []shared.Step{{ - Name: "fob the barz", - Number: 1, - }}, - }, - wantJobMatch: false, - wantStepMatch: true, - wantStepFilename: "cool job with colon/1_fob the barz.txt", - }, - { - name: "Job name with really long name (over the ZIP limit)", - job: shared.Job{ - Name: "thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_", - Steps: []shared.Step{{ - Name: "Long Name Job", - Number: 1, - }}, - }, - wantJobMatch: false, - wantStepMatch: true, - wantStepFilename: "thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnine/1_Long Name Job.txt", - }, - { - name: "Job name that would be truncated by the C# server to split a grapheme", - job: shared.Job{ - Name: "Emoji Test 😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅", - Steps: []shared.Step{{ - Name: "Emoji Job", - Number: 1, - }}, - }, - wantJobMatch: false, - wantStepMatch: true, - wantStepFilename: "Emoji Test 😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅�/1_Emoji Job.txt", - }, - } - - run_log_zip_reader, err := zip.OpenReader("./fixtures/run_log.zip") - require.NoError(t, err) - defer run_log_zip_reader.Close() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - jobs := []shared.Job{tt.job} - - attachRunLog(&run_log_zip_reader.Reader, jobs) - - t.Logf("Job details: ") - - job := jobs[0] - - jobLog := job.Log - jobLogPresent := jobLog != nil - require.Equal(t, tt.wantJobMatch, jobLogPresent, "job log not present") - if jobLogPresent { - require.Equal(t, tt.wantJobFilename, jobLog.Name, "job log filename mismatch") - } - - for _, step := range job.Steps { - stepLog := step.Log - stepLogPresent := stepLog != nil - require.Equal(t, tt.wantStepMatch, stepLogPresent, "step log not present") - if stepLogPresent { - require.Equal(t, tt.wantStepFilename, stepLog.Name, "step log filename mismatch") - } - } - }) - } -} - var barfTheFobLogOutput = heredoc.Doc(` cool job barz the fob log line 1 cool job barz the fob log line 2 @@ -2382,9 +2723,8 @@ func TestRunLog(t *testing.T) { cacheDir := t.TempDir() rlc := RunLogCache{cacheDir: cacheDir} - f, err := os.Open("./fixtures/run_log.zip") - require.NoError(t, err) - defer f.Close() + raw := createZipArchive(t, map[string]string{"foo": "bar"}) + f := bytes.NewReader(raw) require.NoError(t, rlc.Create("key", f)) @@ -2392,5 +2732,6 @@ func TestRunLog(t *testing.T) { require.NoError(t, err) defer zipReader.Close() require.NotEmpty(t, zipReader.File) + require.Equal(t, "foo", zipReader.File[0].Name) }) }