diff --git a/cmd/deploy/deploy.go b/cmd/deploy/deploy.go index 897ecff..9b751fa 100644 --- a/cmd/deploy/deploy.go +++ b/cmd/deploy/deploy.go @@ -41,7 +41,7 @@ type AppService interface { Create(ctx context.Context, input app.CreateAppInput) (app.CreateAppOutput, error) CreateVersion(ctx context.Context, input app.CreateAppVersionInput) (app.CreateAppVersionOutput, error) AppVersionUploadURL(ctx context.Context, input app.AppVersionUploadURLInput) (app.AppVersionUploadURLOutput, error) - UploadAppSource(uploadURL string, archive io.Reader) error + UploadAppSource(uploadURL string, archive app.UploadArchive) error DeployApp(ctx context.Context, input app.DeployAppInput) (app.DeployAppOutput, error) DeployEvents(ctx context.Context, input app.DeployEventsInput) error AppDeployLogs(appident.AppIdentifier) (chan app.AppDeployLogEntry, error) @@ -269,6 +269,35 @@ func readOrCreateApp(ctx context.Context, apps AppService, ai appident.AppIdenti return appCreateOutput.AppID, nil } +type progressReader struct { + r io.Reader + bytesSent int + task *output.Task + totalSize int64 +} + +func (pr *progressReader) Read(p []byte) (int, error) { + n, err := pr.r.Read(p) + pr.bytesSent += n + pr.report(errors.Is(err, io.EOF)) + + return n, err +} + +func (pr *progressReader) report(eof bool) { + if eof { + pr.task.Progress(100.0) // nolint:mnd + return + } + + percent := float32(0.0) + if pr.bytesSent >= 0 { + percent = 100.0 * float32(pr.bytesSent) / float32(pr.totalSize) // nolint:mnd + } + + pr.task.Progress(percent) +} + func uploadAppArchive(ctx context.Context, apps AppService, archive *os.File, appVersionID string) error { task := output.StartTask("Uploading app archive") uploadURLInput := app.AppVersionUploadURLInput(app.AppVersionUploadURLInput{AppVersionID: appVersionID}) @@ -280,7 +309,8 @@ func uploadAppArchive(ctx context.Context, apps AppService, archive *os.File, ap return err } - if stat, err := archive.Stat(); err != nil { + stat, err := archive.Stat() + if err != nil { task.Error() output.PrintErrorDetails("Error checking archive size", err) @@ -292,7 +322,12 @@ func uploadAppArchive(ctx context.Context, apps AppService, archive *os.File, ap return ErrArchiveTooLarge } - err = apps.UploadAppSource(uploadURLOutput.UploadURL, archive) + uploadArchive := app.UploadArchive{ + Reader: &progressReader{r: archive, totalSize: stat.Size(), task: task}, + Size: stat.Size(), + } + + err = apps.UploadAppSource(uploadURLOutput.UploadURL, uploadArchive) var appSourceUploadErr *app.AppSourceUploadError if errors.As(err, &appSourceUploadErr) { task.Error() diff --git a/cmd/deploy/mock.go b/cmd/deploy/mock.go index 77e6b08..c575146 100644 --- a/cmd/deploy/mock.go +++ b/cmd/deploy/mock.go @@ -2,7 +2,6 @@ package deploy import ( "context" - "io" "numerous.com/cli/internal/app" "numerous.com/cli/internal/appident" @@ -53,7 +52,7 @@ func (m *mockAppService) CreateVersion(ctx context.Context, input app.CreateAppV } // UploadAppSource implements AppService. -func (m *mockAppService) UploadAppSource(uploadURL string, archive io.Reader) error { +func (m *mockAppService) UploadAppSource(uploadURL string, archive app.UploadArchive) error { args := m.Called(uploadURL, archive) return args.Error(0) } diff --git a/cmd/output/task.go b/cmd/output/task.go index 0baa2df..2df43f5 100644 --- a/cmd/output/task.go +++ b/cmd/output/task.go @@ -9,7 +9,7 @@ import ( const ( fallbackTaskLineWidth = 60 maxTaskLineWidth = 120 - minDots = 3 + defaultMinDots = 3 ) type lineWidthFunc func() int @@ -18,12 +18,13 @@ type Task struct { msg string lineAdded bool lineUpdating bool + progress bool lineWidth lineWidthFunc w io.Writer } func (t *Task) line(icon string) string { - dotCount, msg := t.trimMessage() + dotCount, msg := t.trimMessage(defaultMinDots) line := icon + " " + msg + AnsiFaint + "..." line += strings.Repeat(".", dotCount) @@ -31,7 +32,7 @@ func (t *Task) line(icon string) string { return line + AnsiReset } -func (t *Task) trimMessage() (int, string) { +func (t *Task) trimMessage(minDots int) (int, string) { w := t.lineWidth() errLineLen := 2 + len(t.msg) + minDots + len("Error") // +2 for icon and space @@ -82,6 +83,25 @@ func (t *Task) EndUpdateLine() { } } +func (t *Task) Progress(percent float32) { + if t.lineAdded || t.lineUpdating { + return + } + + t.progress = true + progressWidth, msg := t.trimMessage(0) + line := "\r" + hourglassIcon + " " + msg + AnsiFaint + + if progressWidth > 0 { + completedWidth := int((float32)(progressWidth) / 100.0 * percent) + remainingWidth := progressWidth - completedWidth + line += strings.Repeat("#", completedWidth) + strings.Repeat(".", remainingWidth) + } + line += AnsiReset + + fmt.Fprint(t.w, line) +} + func (t *Task) Done() { t.terminate(checkmarkIcon, AnsiGreen+"OK"+AnsiReset) } diff --git a/cmd/output/task_test.go b/cmd/output/task_test.go index 31b27cd..9bdc004 100644 --- a/cmd/output/task_test.go +++ b/cmd/output/task_test.go @@ -314,6 +314,28 @@ func TestTask(t *testing.T) { }) }) + t.Run("Progress", func(t *testing.T) { + buf := bytes.NewBuffer(nil) + term := &stubTerminal{buf: buf, width: 19} // width: task message + 10 spaces for progress + max suffix length + + task := StartTaskWithTerminal("message", term) + for i := float32(0.0); i <= 100.0; i += 20.0 { + task.Progress(i) + } + actual := buf.String() + + expected := strings.Join([]string{ + hourglassIcon + " message" + AnsiFaint + "....." + AnsiReset, + "\r" + hourglassIcon + " message" + AnsiFaint + "....." + AnsiReset, + "\r" + hourglassIcon + " message" + AnsiFaint + "#...." + AnsiReset, + "\r" + hourglassIcon + " message" + AnsiFaint + "##..." + AnsiReset, + "\r" + hourglassIcon + " message" + AnsiFaint + "###.." + AnsiReset, + "\r" + hourglassIcon + " message" + AnsiFaint + "####." + AnsiReset, + "\r" + hourglassIcon + " message" + AnsiFaint + "#####" + AnsiReset, + }, "") + assert.Equal(t, expected, actual) + }) + t.Run("StartTask", func(t *testing.T) { t.Run("writes expected output to stdout", func(t *testing.T) { stdoutR := test.RunWithPatchedStdout(t, func() { diff --git a/internal/app/upload_app_source.go b/internal/app/upload_app_source.go index 92d2545..63364ec 100644 --- a/internal/app/upload_app_source.go +++ b/internal/app/upload_app_source.go @@ -1,7 +1,6 @@ package app import ( - "bytes" "fmt" "io" "net/http" @@ -18,17 +17,19 @@ func (e *AppSourceUploadError) Error() string { return fmt.Sprintf("http %d: %q uploading app source file to %q ", e.HTTPStatusCode, e.HTTPStatus, e.UploadURL) } -func (s *Service) UploadAppSource(uploadURL string, archive io.Reader) error { - var buf bytes.Buffer - if _, err := io.Copy(&buf, archive); err != nil { - return err - } +type UploadArchive struct { + Reader io.Reader + Size int64 +} - req, err := http.NewRequest(http.MethodPut, uploadURL, &buf) +func (s *Service) UploadAppSource(uploadURL string, archive UploadArchive) error { + req, err := http.NewRequest(http.MethodPut, uploadURL, archive.Reader) if err != nil { return err } + req.ContentLength = archive.Size + resp, err := s.uploadDoer.Do(req) if err != nil { return err diff --git a/internal/app/upload_app_source_test.go b/internal/app/upload_app_source_test.go index a2a1876..65973e6 100644 --- a/internal/app/upload_app_source_test.go +++ b/internal/app/upload_app_source_test.go @@ -13,8 +13,10 @@ import ( "github.com/stretchr/testify/mock" ) +var dummyData = []byte("some data") + func dummyReader() io.Reader { - return bytes.NewBuffer([]byte("some data")) + return bytes.NewBuffer(dummyData) } func TestUploadAppSource(t *testing.T) { @@ -26,7 +28,7 @@ func TestUploadAppSource(t *testing.T) { doer.On("Do", mock.Anything).Return(nilResp, testError) s := Service{uploadDoer: &doer} - err := s.UploadAppSource("http://some-upload-url", dummyReader()) + err := s.UploadAppSource("http://some-upload-url", UploadArchive{Reader: dummyReader(), Size: int64(len(dummyData))}) assert.ErrorIs(t, err, testError) }) @@ -38,7 +40,7 @@ func TestUploadAppSource(t *testing.T) { doer.On("Do", mock.Anything).Return(&resp, nil) s := Service{uploadDoer: &doer} - err := s.UploadAppSource("http://some-upload-url", dummyReader()) + err := s.UploadAppSource("http://some-upload-url", UploadArchive{Reader: dummyReader(), Size: int64(len(dummyData))}) expected := AppSourceUploadError{ HTTPStatusCode: http.StatusBadRequest, @@ -55,7 +57,7 @@ func TestUploadAppSource(t *testing.T) { t.Run("given invalid upload URL then it returns error", func(t *testing.T) { s := Service{uploadDoer: &test.MockDoer{}} - err := s.UploadAppSource("://invalid-url", dummyReader()) + err := s.UploadAppSource("://invalid-url", UploadArchive{Reader: dummyReader(), Size: int64(len(dummyData))}) assert.Error(t, err) }) @@ -66,7 +68,7 @@ func TestUploadAppSource(t *testing.T) { doer.On("Do", mock.Anything).Return(&resp, nil) s := Service{uploadDoer: &doer} - err := s.UploadAppSource("http://some-upload-url", bytes.NewReader([]byte(""))) + err := s.UploadAppSource("http://some-upload-url", UploadArchive{Reader: bytes.NewReader([]byte("")), Size: 0}) assert.NoError(t, err) }) @@ -76,7 +78,8 @@ func TestUploadAppSource(t *testing.T) { resp := http.Response{Status: "OK", StatusCode: http.StatusOK} doer.On("Do", mock.Anything).Return(&resp, nil) s := Service{uploadDoer: &doer} - err := s.UploadAppSource("http://some-upload-url", bytes.NewReader([]byte("some data"))) + data := []byte("some data") + err := s.UploadAppSource("http://some-upload-url", UploadArchive{Reader: bytes.NewReader(data), Size: int64(len(data))}) assert.NoError(t, err) doer.AssertCalled(t, "Do", mock.MatchedBy(func(r *http.Request) bool {