diff --git a/cmd/common.go b/cmd/common.go index c2b0b0a..3a9420a 100644 --- a/cmd/common.go +++ b/cmd/common.go @@ -7,6 +7,7 @@ import ( "io" "io/fs" "net" + "net/http" "os" "path/filepath" "runtime" @@ -303,3 +304,16 @@ func getMeasurementsPath() string { func getHistoryPath() string { return filepath.Join(getSessionPath(), "history") } + +func silenceUsageOnCreateMeasurementError(err error) bool { + e, ok := err.(*globalping.MeasurementError) + if ok { + switch e.Code { + case http.StatusBadRequest, http.StatusUnprocessableEntity: + return false + default: + return true + } + } + return true +} diff --git a/cmd/dns.go b/cmd/dns.go index 3e58765..970d1b3 100644 --- a/cmd/dns.go +++ b/cmd/dns.go @@ -99,11 +99,9 @@ func (r *Root) RunDNS(cmd *cobra.Command, args []string) error { opts.Options.IPVersion = globalping.IPVersion6 } - res, showHelp, err := r.client.CreateMeasurement(opts) + res, err := r.client.CreateMeasurement(opts) if err != nil { - if !showHelp { - cmd.SilenceUsage = true - } + cmd.SilenceUsage = silenceUsageOnCreateMeasurementError(err) return err } diff --git a/cmd/dns_test.go b/cmd/dns_test.go index 1aa850e..eff8ead 100644 --- a/cmd/dns_test.go +++ b/cmd/dns_test.go @@ -32,7 +32,7 @@ func Test_Execute_DNS_Default(t *testing.T) { expectedResponse := createDefaultMeasurementCreateResponse() gbMock := mocks.NewMockClient(ctrl) - gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, false, nil) + gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, nil) viewerMock := mocks.NewMockViewer(ctrl) viewerMock.EXPECT().Output(measurementID1, expectedOpts).Times(1).Return(nil) @@ -96,7 +96,7 @@ func Test_Execute_DNS_IPv4(t *testing.T) { expectedResponse := createDefaultMeasurementCreateResponse() gbMock := mocks.NewMockClient(ctrl) - gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, false, nil) + gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, nil) viewerMock := mocks.NewMockViewer(ctrl) viewerMock.EXPECT().Output(measurementID1, expectedOpts).Times(1).Return(nil) @@ -136,7 +136,7 @@ func Test_Execute_DNS_IPv6(t *testing.T) { expectedResponse := createDefaultMeasurementCreateResponse() gbMock := mocks.NewMockClient(ctrl) - gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, false, nil) + gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, nil) viewerMock := mocks.NewMockViewer(ctrl) viewerMock.EXPECT().Output(measurementID1, expectedOpts).Times(1).Return(nil) diff --git a/cmd/http.go b/cmd/http.go index 042e689..2dfc208 100644 --- a/cmd/http.go +++ b/cmd/http.go @@ -108,11 +108,9 @@ func (r *Root) RunHTTP(cmd *cobra.Command, args []string) error { opts.Options.IPVersion = globalping.IPVersion6 } - res, showHelp, err := r.client.CreateMeasurement(opts) + res, err := r.client.CreateMeasurement(opts) if err != nil { - if !showHelp { - cmd.SilenceUsage = true - } + cmd.SilenceUsage = silenceUsageOnCreateMeasurementError(err) return err } diff --git a/cmd/http_test.go b/cmd/http_test.go index d144f8c..9a43c2c 100644 --- a/cmd/http_test.go +++ b/cmd/http_test.go @@ -34,7 +34,7 @@ func Test_Execute_HTTP_Default(t *testing.T) { expectedResponse := createDefaultMeasurementCreateResponse() gbMock := mocks.NewMockClient(ctrl) - gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, false, nil) + gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, nil) viewerMock := mocks.NewMockViewer(ctrl) viewerMock.EXPECT().Output(measurementID1, expectedOpts).Times(1).Return(nil) @@ -108,7 +108,7 @@ func Test_Execute_HTTP_IPv4(t *testing.T) { expectedResponse := createDefaultMeasurementCreateResponse() gbMock := mocks.NewMockClient(ctrl) - gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, false, nil) + gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, nil) viewerMock := mocks.NewMockViewer(ctrl) viewerMock.EXPECT().Output(measurementID1, expectedOpts).Times(1).Return(nil) @@ -152,7 +152,7 @@ func Test_Execute_HTTP_IPv6(t *testing.T) { expectedResponse := createDefaultMeasurementCreateResponse() gbMock := mocks.NewMockClient(ctrl) - gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, false, nil) + gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, nil) viewerMock := mocks.NewMockViewer(ctrl) viewerMock.EXPECT().Output(measurementID1, expectedOpts).Times(1).Return(nil) diff --git a/cmd/install_probe_test.go b/cmd/install_probe_test.go index bea4321..94217e2 100644 --- a/cmd/install_probe_test.go +++ b/cmd/install_probe_test.go @@ -41,9 +41,10 @@ Please confirm to pull and run our Docker container (ghcr.io/jsdelivr/globalping `, w.String()) expectedCtx := &view.Context{ - History: view.NewHistoryBuffer(1), - From: "world", - Limit: 1, + History: view.NewHistoryBuffer(1), + From: "world", + Limit: 1, + RunSessionStartedAt: defaultCurrentTime, } assert.Equal(t, expectedCtx, ctx) } diff --git a/cmd/mtr.go b/cmd/mtr.go index f2d3b6e..504b470 100644 --- a/cmd/mtr.go +++ b/cmd/mtr.go @@ -90,11 +90,9 @@ func (r *Root) RunMTR(cmd *cobra.Command, args []string) error { opts.Options.IPVersion = globalping.IPVersion6 } - res, showHelp, err := r.client.CreateMeasurement(opts) + res, err := r.client.CreateMeasurement(opts) if err != nil { - if !showHelp { - cmd.SilenceUsage = true - } + cmd.SilenceUsage = silenceUsageOnCreateMeasurementError(err) return err } diff --git a/cmd/mtr_test.go b/cmd/mtr_test.go index 5933184..b45931c 100644 --- a/cmd/mtr_test.go +++ b/cmd/mtr_test.go @@ -28,7 +28,7 @@ func Test_Execute_MTR_Default(t *testing.T) { expectedResponse := createDefaultMeasurementCreateResponse() gbMock := mocks.NewMockClient(ctrl) - gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, false, nil) + gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, nil) viewerMock := mocks.NewMockViewer(ctrl) viewerMock.EXPECT().Output(measurementID1, expectedOpts).Times(1).Return(nil) @@ -87,7 +87,7 @@ func Test_Execute_MTR_IPv4(t *testing.T) { expectedResponse := createDefaultMeasurementCreateResponse() gbMock := mocks.NewMockClient(ctrl) - gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, false, nil) + gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, nil) viewerMock := mocks.NewMockViewer(ctrl) viewerMock.EXPECT().Output(measurementID1, expectedOpts).Times(1).Return(nil) @@ -126,7 +126,7 @@ func Test_Execute_MTR_IPv6(t *testing.T) { expectedResponse := createDefaultMeasurementCreateResponse() gbMock := mocks.NewMockClient(ctrl) - gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, false, nil) + gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, nil) viewerMock := mocks.NewMockViewer(ctrl) viewerMock.EXPECT().Output(measurementID1, expectedOpts).Times(1).Return(nil) diff --git a/cmd/ping.go b/cmd/ping.go index a9f3609..cdb4bc4 100644 --- a/cmd/ping.go +++ b/cmd/ping.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "net/http" "syscall" "time" @@ -117,15 +118,26 @@ func (r *Root) pingInfinite(opts *globalping.MeasurementCreate) error { }() <-r.cancel - if err == nil { - r.viewer.OutputSummary() + r.viewer.OutputSummary() + if err != nil && r.ctx.MeasurementsCreated > 0 { + e, ok := err.(*globalping.MeasurementError) + if ok && e.Code == http.StatusTooManyRequests { + r.Cmd.SilenceErrors = true + if r.ctx.CIMode { + r.printer.Printf("> %s\n", e.Message) + } else { + r.printer.Printf(r.printer.Color("> "+e.Message, view.ColorLightYellow) + "\n") + } + } } + r.viewer.OutputShare() return err } func (r *Root) ping(opts *globalping.MeasurementCreate) error { var runErr error mbuf := NewMeasurementsBuffer(10) // 10 is the maximum number of measurements that can be in progress at the same time + r.ctx.RunSessionStartedAt = r.time.Now() for { mbuf.Restart() elapsedTime := time.Duration(0) @@ -160,8 +172,9 @@ func (r *Root) ping(opts *globalping.MeasurementCreate) error { hm, err := r.createMeasurement(opts) if err != nil { runErr = err // Return the error after all measurements have finished + } else { + mbuf.Append(hm) } - mbuf.Append(hm) elapsedTime += r.time.Now().Sub(start) } el = mbuf.Next() @@ -186,11 +199,9 @@ func (r *Root) ping(opts *globalping.MeasurementCreate) error { } func (r *Root) createMeasurement(opts *globalping.MeasurementCreate) (*view.HistoryItem, error) { - res, showHelp, err := r.client.CreateMeasurement(opts) + res, err := r.client.CreateMeasurement(opts) if err != nil { - if !showHelp { - r.Cmd.SilenceUsage = true - } + r.Cmd.SilenceUsage = silenceUsageOnCreateMeasurementError(err) return nil, err } r.ctx.MeasurementsCreated++ diff --git a/cmd/ping_test.go b/cmd/ping_test.go index 24fcf64..505146d 100644 --- a/cmd/ping_test.go +++ b/cmd/ping_test.go @@ -27,7 +27,7 @@ func Test_Execute_Ping_Default(t *testing.T) { expectedResponse := createDefaultMeasurementCreateResponse() gbMock := mocks.NewMockClient(ctrl) - gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, false, nil) + gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, nil) viewerMock := mocks.NewMockViewer(ctrl) viewerMock.EXPECT().Output(measurementID1, expectedOpts).Times(1).Return(nil) @@ -77,7 +77,7 @@ func Test_Execute_Ping_Locations_And_Session(t *testing.T) { totalCalls := 10 gbMock := mocks.NewMockClient(ctrl) - gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(totalCalls).Return(expectedResponse, false, nil) + gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(totalCalls).Return(expectedResponse, nil) viewerMock := mocks.NewMockViewer(ctrl) c1 := viewerMock.EXPECT().Output(measurementID1, expectedOpts).Times(4).Return(nil) @@ -294,10 +294,10 @@ func Test_Execute_Ping_Infinite(t *testing.T) { expectedResponse4.ID = measurementID4 gbMock := mocks.NewMockClient(ctrl) - createCall1 := gbMock.EXPECT().CreateMeasurement(expectedOpts1).Return(expectedResponse1, false, nil) - createCall2 := gbMock.EXPECT().CreateMeasurement(expectedOpts2).Return(expectedResponse2, false, nil).After(createCall1) - createCall3 := gbMock.EXPECT().CreateMeasurement(expectedOpts3).Return(expectedResponse3, false, nil).After(createCall2) - gbMock.EXPECT().CreateMeasurement(expectedOpts4).Return(expectedResponse4, false, nil).After(createCall3) + createCall1 := gbMock.EXPECT().CreateMeasurement(expectedOpts1).Return(expectedResponse1, nil) + createCall2 := gbMock.EXPECT().CreateMeasurement(expectedOpts2).Return(expectedResponse2, nil).After(createCall1) + createCall3 := gbMock.EXPECT().CreateMeasurement(expectedOpts3).Return(expectedResponse3, nil).After(createCall2) + gbMock.EXPECT().CreateMeasurement(expectedOpts4).Return(expectedResponse4, nil).After(createCall3) expectedMeasurement1 := createDefaultMeasurement_MultipleProbes("ping", globalping.StatusFinished) expectedMeasurement2 := createDefaultMeasurement_MultipleProbes("ping", globalping.StatusInProgress) @@ -332,6 +332,7 @@ func Test_Execute_Ping_Infinite(t *testing.T) { }).After(outputCall6) viewerMock.EXPECT().OutputSummary().Times(1) + viewerMock.EXPECT().OutputShare().Times(1) timeMock := mocks.NewMockTime(ctrl) timeMock.EXPECT().Now().Return(defaultCurrentTime).AnyTimes() @@ -364,6 +365,7 @@ func Test_Execute_Ping_Infinite(t *testing.T) { Infinite: true, CIMode: true, MeasurementsCreated: 4, + RunSessionStartedAt: defaultCurrentTime, } expectedCtx.History = &view.HistoryBuffer{ Index: 4, @@ -435,14 +437,15 @@ func Test_Execute_Ping_Infinite_Output_Error(t *testing.T) { expectedResponse1 := createDefaultMeasurementCreateResponse() gbMock := mocks.NewMockClient(ctrl) - gbMock.EXPECT().CreateMeasurement(expectedOpts1).Return(expectedResponse1, false, nil) + gbMock.EXPECT().CreateMeasurement(expectedOpts1).Return(expectedResponse1, nil) expectedMeasurement := createDefaultMeasurement("ping") gbMock.EXPECT().GetMeasurement(measurementID1).Return(expectedMeasurement, nil) viewerMock := mocks.NewMockViewer(ctrl) viewerMock.EXPECT().OutputInfinite(expectedMeasurement).Return(errors.New("error message")) - viewerMock.EXPECT().OutputSummary().Times(0) + viewerMock.EXPECT().OutputSummary().Times(1) + viewerMock.EXPECT().OutputShare().Times(1) timeMock := mocks.NewMockTime(ctrl) timeMock.EXPECT().Now().Return(defaultCurrentTime).AnyTimes() @@ -478,6 +481,72 @@ func Test_Execute_Ping_Infinite_Output_Error(t *testing.T) { assert.Equal(t, expectedHistory, string(b)) } +func Test_Execute_Ping_Infinite_Output_TooManyRequests_Error(t *testing.T) { + t.Cleanup(sessionCleanup) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + expectedOpts1 := createDefaultMeasurementCreate("ping") + expectedOpts1.Options.Packets = 16 + expectedOpts2 := createDefaultMeasurementCreate("ping") + expectedOpts2.Options.Packets = 16 + expectedOpts2.Locations[0].Magic = measurementID1 + + expectedResponse1 := createDefaultMeasurementCreateResponse() + + gbMock := mocks.NewMockClient(ctrl) + createCall1 := gbMock.EXPECT().CreateMeasurement(expectedOpts1).Return(expectedResponse1, nil) + gbMock.EXPECT().CreateMeasurement(expectedOpts2).Return(nil, &globalping.MeasurementError{ + Code: 429, + Message: "too many requests", + }).After(createCall1) + + expectedMeasurement := createDefaultMeasurement("ping") + gbMock.EXPECT().GetMeasurement(measurementID1).Return(expectedMeasurement, nil) + + viewerMock := mocks.NewMockViewer(ctrl) + waitFn := func(m *globalping.Measurement) error { time.Sleep(5 * time.Millisecond); return nil } + viewerMock.EXPECT().OutputInfinite(expectedMeasurement).DoAndReturn(waitFn) + + viewerMock.EXPECT().OutputSummary().Times(1) + viewerMock.EXPECT().OutputShare().Times(1) + + timeMock := mocks.NewMockTime(ctrl) + timeMock.EXPECT().Now().Return(defaultCurrentTime).AnyTimes() + + w := new(bytes.Buffer) + printer := view.NewPrinter(nil, w, w) + ctx := createDefaultContext("ping") + root := NewRoot(printer, ctx, viewerMock, timeMock, gbMock, nil) + os.Args = []string{"globalping", "ping", "jsdelivr.com", "from", "Berlin", "--infinite", "--share"} + err := root.Cmd.ExecuteContext(context.TODO()) + assert.Equal(t, "too many requests", err.Error()) + + assert.Equal(t, "> too many requests\n", w.String()) + + expectedCtx := createDefaultExpectedContext("ping") + expectedCtx.History.Find(measurementID1).Status = globalping.StatusFinished + expectedCtx.Packets = 16 + expectedCtx.Infinite = true + expectedCtx.Share = true + assert.Equal(t, expectedCtx, ctx) + + b, err := os.ReadFile(getMeasurementsPath()) + assert.NoError(t, err) + expectedHistory := measurementID1 + "\n" + assert.Equal(t, expectedHistory, string(b)) + + b, err = os.ReadFile(getHistoryPath()) + assert.NoError(t, err) + expectedHistory = createDefaultExpectedHistoryLogItem( + "1", + measurementID1, + "ping jsdelivr.com from Berlin --infinite --share", + ) + assert.Equal(t, expectedHistory, string(b)) +} + func Test_Execute_Ping_IPv4(t *testing.T) { t.Cleanup(sessionCleanup) @@ -490,7 +559,7 @@ func Test_Execute_Ping_IPv4(t *testing.T) { expectedResponse := createDefaultMeasurementCreateResponse() gbMock := mocks.NewMockClient(ctrl) - gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, false, nil) + gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, nil) viewerMock := mocks.NewMockViewer(ctrl) viewerMock.EXPECT().Output(measurementID1, expectedOpts).Times(1).Return(nil) @@ -527,7 +596,7 @@ func Test_Execute_Ping_IPv6(t *testing.T) { expectedResponse := createDefaultMeasurementCreateResponse() gbMock := mocks.NewMockClient(ctrl) - gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, false, nil) + gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, nil) viewerMock := mocks.NewMockViewer(ctrl) viewerMock.EXPECT().Output(measurementID1, expectedOpts).Times(1).Return(nil) diff --git a/cmd/root.go b/cmd/root.go index d54aa06..09e10f0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -131,7 +131,12 @@ func wrappedFlagUsages(cmd *pflag.FlagSet) string { // Identical to the default cobra usage template, // but utilizes wrappedFlagUsages to ensure flag usages don't wrap around -var usageTemplate = `Usage:{{if .Runnable}} +var usageTemplate = ` +Use '{{.CommandPath}} --help' for more information about the command.` + +var helpTemplate = `{{with (or .Long .Short)}}{{. | trimTrailingWhitespaces}} + +{{end}}{{if or .Runnable .HasSubCommands}}Usage:{{if .Runnable}} {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} {{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}} @@ -159,9 +164,5 @@ Global Flags: Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}} -Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}} +Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}{{end}} ` - -var helpTemplate = `{{with (or .Long .Short)}}{{. | trimTrailingWhitespaces}} - -{{end}}{{if or .Runnable .HasSubCommands}}{{.UsageString}}{{end}}` diff --git a/cmd/traceroute.go b/cmd/traceroute.go index 7f3ff05..9a7df80 100644 --- a/cmd/traceroute.go +++ b/cmd/traceroute.go @@ -91,11 +91,9 @@ func (r *Root) RunTraceroute(cmd *cobra.Command, args []string) error { opts.Options.IPVersion = globalping.IPVersion6 } - res, showHelp, err := r.client.CreateMeasurement(opts) + res, err := r.client.CreateMeasurement(opts) if err != nil { - if !showHelp { - cmd.SilenceUsage = true - } + cmd.SilenceUsage = silenceUsageOnCreateMeasurementError(err) return err } diff --git a/cmd/traceroute_test.go b/cmd/traceroute_test.go index b9c42af..c941632 100644 --- a/cmd/traceroute_test.go +++ b/cmd/traceroute_test.go @@ -27,7 +27,7 @@ func Test_Execute_Traceroute_Default(t *testing.T) { expectedResponse := createDefaultMeasurementCreateResponse() gbMock := mocks.NewMockClient(ctrl) - gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, false, nil) + gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, nil) viewerMock := mocks.NewMockViewer(ctrl) viewerMock.EXPECT().Output(measurementID1, expectedOpts).Times(1).Return(nil) @@ -83,7 +83,7 @@ func Test_Execute_Traceroute_IPv4(t *testing.T) { expectedResponse := createDefaultMeasurementCreateResponse() gbMock := mocks.NewMockClient(ctrl) - gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, false, nil) + gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, nil) viewerMock := mocks.NewMockViewer(ctrl) viewerMock.EXPECT().Output(measurementID1, expectedOpts).Times(1).Return(nil) @@ -121,7 +121,7 @@ func Test_Execute_Traceroute_IPv6(t *testing.T) { expectedResponse := createDefaultMeasurementCreateResponse() gbMock := mocks.NewMockClient(ctrl) - gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, false, nil) + gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, nil) viewerMock := mocks.NewMockViewer(ctrl) viewerMock.EXPECT().Output(measurementID1, expectedOpts).Times(1).Return(nil) diff --git a/cmd/utils_test.go b/cmd/utils_test.go index e8089ac..a1877ba 100644 --- a/cmd/utils_test.go +++ b/cmd/utils_test.go @@ -91,9 +91,10 @@ func createDefaultMeasurement_MultipleProbes(cmd string, status globalping.Measu func createDefaultContext(_ string) *view.Context { ctx := &view.Context{ - History: view.NewHistoryBuffer(1), - From: "world", - Limit: 1, + History: view.NewHistoryBuffer(1), + From: "world", + Limit: 1, + RunSessionStartedAt: defaultCurrentTime, } return ctx } @@ -107,6 +108,7 @@ func createDefaultExpectedContext(cmd string) *view.Context { CIMode: true, History: view.NewHistoryBuffer(1), MeasurementsCreated: 1, + RunSessionStartedAt: defaultCurrentTime, } ctx.History.Push(&view.HistoryItem{ Id: measurementID1, diff --git a/globalping/client.go b/globalping/client.go index 792f35f..c60cd2f 100644 --- a/globalping/client.go +++ b/globalping/client.go @@ -8,8 +8,17 @@ import ( ) type Client interface { - CreateMeasurement(measurement *MeasurementCreate) (*MeasurementCreateResponse, bool, error) + // Creates a new measurement with parameters set in the request body. The measurement runs asynchronously and you can retrieve its current state at the URL returned in the Location header. + // + // https://www.jsdelivr.com/docs/api.globalping.io#post-/v1/measurements + CreateMeasurement(measurement *MeasurementCreate) (*MeasurementCreateResponse, error) + // Returns the status and results of an existing measurement. Measurements are typically available for up to 7 days after creation. + // + // https://www.jsdelivr.com/docs/api.globalping.io#get-/v1/measurements/-id- GetMeasurement(id string) (*Measurement, error) + // Returns the status and results of an existing measurement. Measurements are typically available for up to 7 days after creation. + // + // https://www.jsdelivr.com/docs/api.globalping.io#get-/v1/measurements/-id- GetMeasurementRaw(id string) ([]byte, error) } diff --git a/globalping/globalping.go b/globalping/globalping.go index 0f66aa0..54ea416 100644 --- a/globalping/globalping.go +++ b/globalping/globalping.go @@ -3,26 +3,32 @@ package globalping import ( "bytes" "encoding/json" - "errors" "fmt" "io" "net/http" + "strconv" "github.com/andybalholm/brotli" + "github.com/jsdelivr/globalping-cli/utils" "github.com/jsdelivr/globalping-cli/version" ) -// boolean indicates whether to print CLI help on error -func (c *client) CreateMeasurement(measurement *MeasurementCreate) (*MeasurementCreateResponse, bool, error) { +var ( + moreCreditsRequiredNoAuthErr = "You only have %s remaining, and %d were required. Try requesting fewer probes or wait %s for the rate limit to reset. You can get higher limits by creating an account. Sign up at https://globalping.io" + moreCreditsRequiredAuthErr = "You only have %s remaining, and %d were required. Try requesting fewer probes or wait %s for the rate limit to reset. You can get higher limits by sponsoring us or hosting probes." + noCreditsNoAuthErr = "You have run out of credits for this session. You can wait %s for the rate limit to reset or get higher limits by creating an account. Sign up at https://globalping.io" + noCreditsAuthErr = "You have run out of credits for this session. You can wait %s for the rate limit to reset or get higher limits by sponsoring us or hosting probes." +) + +func (c *client) CreateMeasurement(measurement *MeasurementCreate) (*MeasurementCreateResponse, error) { postData, err := json.Marshal(measurement) if err != nil { - return nil, false, errors.New("failed to marshal post data - please report this bug") + return nil, &MeasurementError{Message: "failed to marshal post data - please report this bug"} } - // Create a new request req, err := http.NewRequest("POST", c.config.GlobalpingAPIURL+"/measurements", bytes.NewBuffer(postData)) if err != nil { - return nil, false, errors.New("failed to create request - please report this bug") + return nil, &MeasurementError{Message: "failed to create request - please report this bug"} } req.Header.Set("User-Agent", userAgent()) req.Header.Set("Accept-Encoding", "br") @@ -32,52 +38,77 @@ func (c *client) CreateMeasurement(measurement *MeasurementCreate) (*Measurement req.Header.Set("Authorization", "Bearer "+c.config.GlobalpingToken) } - // Make the request resp, err := c.http.Do(req) if err != nil { - return nil, false, errors.New("request failed - please try again later") + return nil, &MeasurementError{Message: "request failed - please try again later"} } defer resp.Body.Close() - // If an error is returned if resp.StatusCode != http.StatusAccepted { - // Decode the response body as JSON var data MeasurementCreateError - err = json.NewDecoder(resp.Body).Decode(&data) if err != nil { - return nil, false, errors.New("invalid error format returned - please report this bug") + return nil, &MeasurementError{Message: "invalid error format returned - please report this bug"} } - - // 422 error - if data.Error.Type == "no_probes_found" { - return nil, true, errors.New("no suitable probes found - please choose a different location") + err := &MeasurementError{ + Code: resp.StatusCode, } - - // 400 error - if data.Error.Type == "validation_error" { + if resp.StatusCode == http.StatusBadRequest { resErr := "" for _, v := range data.Error.Params { resErr += fmt.Sprintf(" - %s\n", v) } - return nil, true, fmt.Errorf("invalid parameters\n%sPlease check the help for more information", resErr) + // Remove the last \n + if len(resErr) > 0 { + resErr = resErr[:len(resErr)-1] + } + err.Message = fmt.Sprintf("invalid parameters\n%s", resErr) + return nil, err } - // 401 error - if data.Error.Type == "unauthorized" { - return nil, false, fmt.Errorf("unauthorized: %s", data.Error.Message) + if resp.StatusCode == http.StatusUnauthorized { + err.Message = fmt.Sprintf("unauthorized: %s", data.Error.Message) + return nil, err } - // 500 error - if data.Error.Type == "api_error" { - return nil, false, errors.New("internal server error - please try again later") + if resp.StatusCode == http.StatusUnprocessableEntity { + err.Message = "no suitable probes found - please choose a different location" + return nil, err } - // If the error type is unknown - return nil, false, fmt.Errorf("unknown error response: %s", data.Error.Type) - } + if resp.StatusCode == http.StatusTooManyRequests { + rateLimitRemaining, _ := strconv.ParseInt(resp.Header.Get("X-RateLimit-Remaining"), 10, 64) + rateLimitReset, _ := strconv.ParseInt(resp.Header.Get("X-RateLimit-Reset"), 10, 64) + creditsRemaining, _ := strconv.ParseInt(resp.Header.Get("X-Credits-Remaining"), 10, 64) + creditsRequired, _ := strconv.ParseInt(resp.Header.Get("X-Credits-Required"), 10, 64) + remaining := rateLimitRemaining + creditsRemaining + required := rateLimitRemaining + creditsRequired + if c.config.GlobalpingToken == "" { + if remaining > 0 { + err.Message = fmt.Sprintf(moreCreditsRequiredNoAuthErr, utils.Pluralize(remaining, "credit"), required, utils.FormatSeconds(rateLimitReset)) + return nil, err + } + err.Message = fmt.Sprintf(noCreditsNoAuthErr, utils.FormatSeconds(rateLimitReset)) + return nil, err + + } else { + if remaining > 0 { + err.Message = fmt.Sprintf(moreCreditsRequiredAuthErr, utils.Pluralize(remaining, "credit"), required, utils.FormatSeconds(rateLimitReset)) + return nil, err + } + err.Message = fmt.Sprintf(noCreditsAuthErr, utils.FormatSeconds(rateLimitReset)) + return nil, err + } + } - // Read the response body + if resp.StatusCode == http.StatusInternalServerError { + err.Message = "internal server error - please try again later" + return nil, err + } + + err.Message = fmt.Sprintf("unknown error response: %s", data.Error.Type) + return nil, err + } var bodyReader io.Reader = resp.Body if resp.Header.Get("Content-Encoding") == "br" { @@ -87,13 +118,14 @@ func (c *client) CreateMeasurement(measurement *MeasurementCreate) (*Measurement res := &MeasurementCreateResponse{} err = json.NewDecoder(bodyReader).Decode(res) if err != nil { - return nil, false, fmt.Errorf("invalid post measurement format returned - please report this bug: %s", err) + return nil, &MeasurementError{ + Message: fmt.Sprintf("invalid post measurement format returned - please report this bug: %s", err), + } } - return res, false, nil + return res, nil } -// GetRawMeasurement returns API response as a GetMeasurement object func (c *client) GetMeasurement(id string) (*Measurement, error) { respBytes, err := c.GetMeasurementRaw(id) if err != nil { @@ -102,17 +134,17 @@ func (c *client) GetMeasurement(id string) (*Measurement, error) { m := &Measurement{} err = json.Unmarshal(respBytes, m) if err != nil { - return nil, fmt.Errorf("invalid get measurement format returned: %v %s", err, string(respBytes)) + return nil, &MeasurementError{ + Message: fmt.Sprintf("invalid get measurement format returned: %v %s", err, string(respBytes)), + } } return m, nil } -// GetMeasurementRaw returns the API response's raw json response func (c *client) GetMeasurementRaw(id string) ([]byte, error) { - // Create a new request req, err := http.NewRequest("GET", c.config.GlobalpingAPIURL+"/measurements/"+id, nil) if err != nil { - return nil, errors.New("err: failed to create request") + return nil, &MeasurementError{Message: "failed to create request"} } req.Header.Set("User-Agent", userAgent()) @@ -123,38 +155,37 @@ func (c *client) GetMeasurementRaw(id string) ([]byte, error) { req.Header.Set("If-None-Match", etag) } - // Make the request resp, err := c.http.Do(req) if err != nil { - return nil, errors.New("err: request failed") + return nil, &MeasurementError{Message: "request failed"} } defer resp.Body.Close() - // 404 not found - if resp.StatusCode == http.StatusNotFound { - return nil, errors.New("err: measurement not found") - } + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNotModified { + err := &MeasurementError{ + Code: resp.StatusCode, + } + if resp.StatusCode == http.StatusNotFound { + err.Message = "measurement not found" + return nil, err + } - // 500 error - if resp.StatusCode == http.StatusInternalServerError { - return nil, errors.New("err: internal server error - please try again later") + if resp.StatusCode == http.StatusInternalServerError { + err.Message = "internal server error - please try again later" + return nil, err + } + err.Message = fmt.Sprintf("response code %d", resp.StatusCode) + return nil, err } - // 304 not modified if resp.StatusCode == http.StatusNotModified { - // get response bytes from cache respBytes := c.measurements[etag] if respBytes == nil { - return nil, errors.New("err: response not found in etags cache") + return nil, &MeasurementError{Message: "response not found in etags cache"} } - return respBytes, nil } - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("err: response code %d", resp.StatusCode) - } - var bodyReader io.Reader = resp.Body if resp.Header.Get("Content-Encoding") == "br" { @@ -164,7 +195,7 @@ func (c *client) GetMeasurementRaw(id string) ([]byte, error) { // Read the response body respBytes, err := io.ReadAll(bodyReader) if err != nil { - return nil, errors.New("err: failed to read response body") + return nil, &MeasurementError{Message: "failed to read response body"} } // save etag and response to cache @@ -179,7 +210,7 @@ func DecodeDNSTimings(timings json.RawMessage) (*DNSTimings, error) { t := &DNSTimings{} err := json.Unmarshal(timings, t) if err != nil { - return nil, errors.New("invalid timings format returned (other)") + return nil, &MeasurementError{Message: "invalid timings format returned (other)"} } return t, nil } @@ -188,7 +219,7 @@ func DecodeHTTPTimings(timings json.RawMessage) (*HTTPTimings, error) { t := &HTTPTimings{} err := json.Unmarshal(timings, t) if err != nil { - return nil, errors.New("invalid timings format returned (other)") + return nil, &MeasurementError{Message: "invalid timings format returned (other)"} } return t, nil } @@ -197,7 +228,7 @@ func DecodePingTimings(timings json.RawMessage) ([]PingTiming, error) { t := []PingTiming{} err := json.Unmarshal(timings, &t) if err != nil { - return nil, errors.New("invalid timings format returned (ping)") + return nil, &MeasurementError{Message: "invalid timings format returned (ping)"} } return t, nil } @@ -206,7 +237,7 @@ func DecodePingStats(stats json.RawMessage) (*PingStats, error) { s := &PingStats{} err := json.Unmarshal(stats, s) if err != nil { - return nil, errors.New("invalid stats format returned") + return nil, &MeasurementError{Message: "invalid stats format returned"} } return s, nil } diff --git a/globalping/globalping_test.go b/globalping/globalping_test.go index 4fdb739..d4ee97f 100644 --- a/globalping/globalping_test.go +++ b/globalping/globalping_test.go @@ -2,6 +2,7 @@ package globalping import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "os" @@ -20,12 +21,16 @@ func TestPostAPI(t *testing.T) { // Suppress error outputs os.Stdout, _ = os.Open(os.DevNull) for scenario, fn := range map[string]func(t *testing.T){ - "valid": testPostValid, - "authorized": testPostAuthorized, - "auth_error": testPostAuthorizedError, - "no_probes": testPostNoProbes, - "validation": testPostValidation, - "api_error": testPostInternalError, + "valid": testPostValid, + "authorized": testPostAuthorized, + "auth_error": testPostAuthorizedError, + "more_credits_no_auth_error": testPostMoreCreditsRequiredNoAuthError, + "more_credits_auth_error": testPostMoreCreditsRequiredAuthError, + "no_credits_no_auth_error": testPostNoCreditsNoAuthError, + "no_credits_auth_error": testPostNoCreditsAuthError, + "no_probes": testPostNoProbes, + "validation": testPostValidation, + "api_error": testPostInternalError, } { t.Run(scenario, func(t *testing.T) { fn(t) @@ -35,16 +40,15 @@ func TestPostAPI(t *testing.T) { // Test a valid call of PostAPI func testPostValid(t *testing.T) { - server := generateServer(`{"id":"abcd","probesCount":1}`) + server := generateServer(`{"id":"abcd","probesCount":1}`, http.StatusAccepted) defer server.Close() client := NewClient(&utils.Config{GlobalpingAPIURL: server.URL}) opts := &MeasurementCreate{} - res, showHelp, err := client.CreateMeasurement(opts) + res, err := client.CreateMeasurement(opts) assert.Equal(t, "abcd", res.ID) assert.Equal(t, 1, res.ProbesCount) - assert.False(t, showHelp) assert.NoError(t, err) } @@ -57,11 +61,10 @@ func testPostAuthorized(t *testing.T) { }) opts := &MeasurementCreate{} - res, showHelp, err := client.CreateMeasurement(opts) + res, err := client.CreateMeasurement(opts) assert.Equal(t, "abcd", res.ID) assert.Equal(t, 1, res.ProbesCount) - assert.False(t, showHelp) assert.NoError(t, err) } @@ -73,15 +76,127 @@ func testPostAuthorizedError(t *testing.T) { }) opts := &MeasurementCreate{} - res, showHelp, err := client.CreateMeasurement(opts) + res, err := client.CreateMeasurement(opts) assert.Nil(t, res) - assert.False(t, showHelp) assert.EqualError(t, err, "unauthorized: Unauthorized.") } +func testPostMoreCreditsRequiredNoAuthError(t *testing.T) { + rateLimitReset := "61" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-RateLimit-Remaining", "1") + w.Header().Set("X-RateLimit-Reset", rateLimitReset) + w.Header().Set("X-Credits-Remaining", "1") + w.Header().Set("X-Credits-Required", "2") + w.WriteHeader(429) + _, err := w.Write([]byte(`{ + "error": { + "message": "API rate limit exceeded.", + "type": "rate_limit_exceeded" + }}`)) + if err != nil { + panic(err) + } + })) + defer server.Close() + + client := NewClient(&utils.Config{GlobalpingAPIURL: server.URL}) + opts := &MeasurementCreate{} + _, err := client.CreateMeasurement(opts) + assert.EqualError(t, err, fmt.Sprintf(moreCreditsRequiredNoAuthErr, "2 credits", 3, "1 minute")) + + rateLimitReset = "100" + _, err = client.CreateMeasurement(opts) + assert.EqualError(t, err, fmt.Sprintf(moreCreditsRequiredNoAuthErr, "2 credits", 3, "2 minutes")) +} + +func testPostMoreCreditsRequiredAuthError(t *testing.T) { + rateLimitReset := "40" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-RateLimit-Remaining", "0") + w.Header().Set("X-RateLimit-Reset", rateLimitReset) + w.Header().Set("X-Credits-Remaining", "1") + w.Header().Set("X-Credits-Required", "2") + w.WriteHeader(429) + _, err := w.Write([]byte(`{ + "error": { + "message": "API rate limit exceeded.", + "type": "rate_limit_exceeded" + }}`)) + if err != nil { + panic(err) + } + })) + defer server.Close() + + client := NewClient(&utils.Config{ + GlobalpingToken: "secret", + GlobalpingAPIURL: server.URL, + }) + opts := &MeasurementCreate{} + + _, err := client.CreateMeasurement(opts) + assert.EqualError(t, err, fmt.Sprintf(moreCreditsRequiredAuthErr, "1 credit", 2, "40 seconds")) + + rateLimitReset = "1" + _, err = client.CreateMeasurement(opts) + assert.EqualError(t, err, fmt.Sprintf(moreCreditsRequiredAuthErr, "1 credit", 2, "1 second")) +} + +func testPostNoCreditsNoAuthError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-RateLimit-Remaining", "0") + w.Header().Set("X-RateLimit-Reset", "5") + w.Header().Set("X-Credits-Remaining", "0") + w.WriteHeader(429) + _, err := w.Write([]byte(`{ + "error": { + "message": "API rate limit exceeded.", + "type": "rate_limit_exceeded" + }}`)) + if err != nil { + panic(err) + } + })) + defer server.Close() + + client := NewClient(&utils.Config{GlobalpingAPIURL: server.URL}) + opts := &MeasurementCreate{} + _, err := client.CreateMeasurement(opts) + + assert.EqualError(t, err, fmt.Sprintf(noCreditsNoAuthErr, "5 seconds")) +} + +func testPostNoCreditsAuthError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-RateLimit-Remaining", "0") + w.Header().Set("X-RateLimit-Reset", "5") + w.Header().Set("X-Credits-Remaining", "0") + w.WriteHeader(429) + _, err := w.Write([]byte(`{ + "error": { + "message": "API rate limit exceeded.", + "type": "rate_limit_exceeded" + }}`)) + if err != nil { + panic(err) + } + })) + defer server.Close() + + client := NewClient(&utils.Config{ + GlobalpingToken: "secret", + GlobalpingAPIURL: server.URL, + }) + opts := &MeasurementCreate{} + _, err := client.CreateMeasurement(opts) + + assert.EqualError(t, err, fmt.Sprintf(noCreditsAuthErr, "5 seconds")) +} + func testPostNoProbes(t *testing.T) { - server := generateServerError(`{ + server := generateServer(`{ "error": { "message": "No suitable probes found", "type": "no_probes_found" @@ -90,19 +205,20 @@ func testPostNoProbes(t *testing.T) { client := NewClient(&utils.Config{GlobalpingAPIURL: server.URL}) opts := &MeasurementCreate{} - _, showHelp, err := client.CreateMeasurement(opts) + _, err := client.CreateMeasurement(opts) - assert.EqualError(t, err, "no suitable probes found - please choose a different location") - assert.True(t, showHelp) + assert.Equal(t, &MeasurementError{ + Code: 422, + Message: "no suitable probes found - please choose a different location", + }, err) } func testPostValidation(t *testing.T) { - server := generateServerError(`{ + server := generateServer(`{ "error": { "message": "Validation Failed", "type": "validation_error", "params": { - "measurement": "\"measurement\" does not match any of the allowed types", "target": "\"target\" does not match any of the allowed types" } }}`, 400) @@ -110,24 +226,17 @@ func testPostValidation(t *testing.T) { client := NewClient(&utils.Config{GlobalpingAPIURL: server.URL}) opts := &MeasurementCreate{} - _, showHelp, err := client.CreateMeasurement(opts) - - // Key order is not guaranteed - expectedErrV1 := `invalid parameters - - "measurement" does not match any of the allowed types - - "target" does not match any of the allowed types -Please check the help for more information` - if err.Error() != expectedErrV1 { - assert.EqualError(t, err, `invalid parameters - - "target" does not match any of the allowed types - - "measurement" does not match any of the allowed types -Please check the help for more information`) - } - assert.True(t, showHelp) + _, err := client.CreateMeasurement(opts) + + assert.Equal(t, &MeasurementError{ + Code: 400, + Message: `invalid parameters + - "target" does not match any of the allowed types`, + }, err) } func testPostInternalError(t *testing.T) { - server := generateServerError(`{ + server := generateServer(`{ "error": { "message": "Internal Server Error", "type": "api_error" @@ -136,9 +245,8 @@ func testPostInternalError(t *testing.T) { client := NewClient(&utils.Config{GlobalpingAPIURL: server.URL}) opts := &MeasurementCreate{} - _, showHelp, err := client.CreateMeasurement(opts) + _, err := client.CreateMeasurement(opts) assert.EqualError(t, err, "internal server error - please try again later") - assert.False(t, showHelp) } // GetAPI tests @@ -159,7 +267,7 @@ func TestGetAPI(t *testing.T) { } func testGetValid(t *testing.T) { - server := generateServer(`{"id":"abcd"}`) + server := generateServer(`{"id":"abcd"}`, http.StatusOK) defer server.Close() client := NewClient(&utils.Config{GlobalpingAPIURL: server.URL}) res, err := client.GetMeasurement("abcd") @@ -170,7 +278,7 @@ func testGetValid(t *testing.T) { } func testGetJson(t *testing.T) { - server := generateServer(`{"id":"abcd"}`) + server := generateServer(`{"id":"abcd"}`, http.StatusOK) defer server.Close() client := NewClient(&utils.Config{GlobalpingAPIURL: server.URL}) res, err := client.GetMeasurementRaw("abcd") @@ -223,7 +331,7 @@ func testGetPing(t *testing.T) { "drop": 0 } } - }]}`) + }]}`, http.StatusOK) defer server.Close() client := NewClient(&utils.Config{GlobalpingAPIURL: server.URL}) @@ -318,7 +426,7 @@ func testGetTraceroute(t *testing.T) { ] } ] - }}]}`) + }}]}`, http.StatusOK) defer server.Close() client := NewClient(&utils.Config{GlobalpingAPIURL: server.URL}) @@ -395,7 +503,7 @@ func testGetDns(t *testing.T) { }, "resolver": "185.31.172.240" } - }]}`) + }]}`, http.StatusOK) defer server.Close() client := NewClient(&utils.Config{GlobalpingAPIURL: server.URL}) @@ -517,7 +625,7 @@ func testGetMtr(t *testing.T) { } ] } - }]}`) + }]}`, http.StatusOK) defer server.Close() client := NewClient(&utils.Config{GlobalpingAPIURL: server.URL}) @@ -622,7 +730,7 @@ func testGetHttp(t *testing.T) { }, "rawOutput": "HTTP" } - }]}`) + }]}`, http.StatusOK) defer server.Close() client := NewClient(&utils.Config{GlobalpingAPIURL: server.URL}) @@ -758,9 +866,9 @@ func TestUserAgent(t *testing.T) { } // Generate server for testing -func generateServer(json string) *httptest.Server { +func generateServer(json string, statusCode int) *httptest.Server { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusAccepted) + w.WriteHeader(statusCode) _, err := w.Write([]byte(json)) if err != nil { panic(err) @@ -784,14 +892,3 @@ func generateServerAuthorized(json string) *httptest.Server { })) return server } - -func generateServerError(json string, statusCode int) *httptest.Server { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(statusCode) - _, err := w.Write([]byte(json)) - if err != nil { - panic(err) - } - })) - return server -} diff --git a/globalping/models.go b/globalping/models.go index b0a269d..2afd0c0 100644 --- a/globalping/models.go +++ b/globalping/models.go @@ -47,6 +47,15 @@ type MeasurementCreate struct { Options *MeasurementOptions `json:"measurementOptions,omitempty"` } +type MeasurementError struct { + Code int + Message string +} + +func (e *MeasurementError) Error() string { + return e.Message +} + type MeasurementCreateResponse struct { ID string `json:"id"` ProbesCount int `json:"probesCount"` diff --git a/mocks/mock_client.go b/mocks/mock_client.go index 225fd9d..e7849e7 100644 --- a/mocks/mock_client.go +++ b/mocks/mock_client.go @@ -40,13 +40,12 @@ func (m *MockClient) EXPECT() *MockClientMockRecorder { } // CreateMeasurement mocks base method. -func (m *MockClient) CreateMeasurement(measurement *globalping.MeasurementCreate) (*globalping.MeasurementCreateResponse, bool, error) { +func (m *MockClient) CreateMeasurement(measurement *globalping.MeasurementCreate) (*globalping.MeasurementCreateResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateMeasurement", measurement) ret0, _ := ret[0].(*globalping.MeasurementCreateResponse) - ret1, _ := ret[1].(bool) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 + ret1, _ := ret[1].(error) + return ret0, ret1 } // CreateMeasurement indicates an expected call of CreateMeasurement. diff --git a/mocks/mock_viewer.go b/mocks/mock_viewer.go index 13cc07c..e26badb 100644 --- a/mocks/mock_viewer.go +++ b/mocks/mock_viewer.go @@ -67,6 +67,18 @@ func (mr *MockViewerMockRecorder) OutputInfinite(m any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OutputInfinite", reflect.TypeOf((*MockViewer)(nil).OutputInfinite), m) } +// OutputShare mocks base method. +func (m *MockViewer) OutputShare() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "OutputShare") +} + +// OutputShare indicates an expected call of OutputShare. +func (mr *MockViewerMockRecorder) OutputShare() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OutputShare", reflect.TypeOf((*MockViewer)(nil).OutputShare)) +} + // OutputSummary mocks base method. func (m *MockViewer) OutputSummary() { m.ctrl.T.Helper() diff --git a/utils/text.go b/utils/text.go new file mode 100644 index 0000000..799e18d --- /dev/null +++ b/utils/text.go @@ -0,0 +1,10 @@ +package utils + +import "fmt" + +func Pluralize(count int64, singular string) string { + if count == 1 { + return fmt.Sprintf("%d %s", count, singular) + } + return fmt.Sprintf("%d %ss", count, singular) +} diff --git a/utils/time.go b/utils/time.go index 941a7fd..cb4e4b3 100644 --- a/utils/time.go +++ b/utils/time.go @@ -1,6 +1,9 @@ package utils -import _time "time" +import ( + "math" + _time "time" +) type Time interface { Now() _time.Time @@ -15,3 +18,16 @@ func NewTime() Time { func (d *time) Now() _time.Time { return _time.Now() } + +func FormatSeconds(seconds int64) string { + if seconds < 60 { + return Pluralize(seconds, "second") + } + if seconds < 3600 { + return Pluralize(int64(math.Round(float64(seconds)/60)), "minute") + } + if seconds < 86400 { + return Pluralize(int64(math.Round(float64(seconds)/3600)), "hour") + } + return Pluralize(int64(math.Round(float64(seconds)/86400)), "day") +} diff --git a/view/context.go b/view/context.go index 98645d3..03258d0 100644 --- a/view/context.go +++ b/view/context.go @@ -44,6 +44,7 @@ type Context struct { AggregatedStats []*MeasurementStats MeasurementsCreated int History *HistoryBuffer // History of measurements + RunSessionStartedAt time.Time } type MeasurementStats struct { diff --git a/view/infinite.go b/view/infinite.go index 8c99c7f..88b8d7d 100644 --- a/view/infinite.go +++ b/view/infinite.go @@ -9,12 +9,18 @@ import ( "strings" "github.com/jsdelivr/globalping-cli/globalping" + "github.com/jsdelivr/globalping-cli/utils" "github.com/mattn/go-runewidth" ) -// Table defaults var ( + // Table defaults colSeparator = " | " + + apiCreditInfo = "Consuming 1 API credit for every 16 packets until stopped.\n" + apiCreditConsumptionInfo = "Consuming ~%s/minute.\n" + apiCreditLastConsumptionInfo = "" + apiCreditLastMeasurementCount = 0 ) func (v *viewer) OutputInfinite(m *globalping.Measurement) error { @@ -41,6 +47,7 @@ func (v *viewer) OutputInfinite(m *globalping.Measurement) error { func (v *viewer) outputStreamingPackets(m *globalping.Measurement) error { if len(v.ctx.AggregatedStats) == 0 { v.ctx.AggregatedStats = []*MeasurementStats{NewMeasurementStats()} + v.printer.Print(v.getAPICreditInfo()) } probeMeasurement := &m.Results[0] hm := v.ctx.History.Find(m.ID) @@ -84,7 +91,8 @@ func (v *viewer) outputTableView(m *globalping.Measurement) error { width, _ := v.printer.GetSize() o, newStats, newAggregatedStats := v.generateTable(hm, m, width-2) hm.Stats = newStats - v.printer.AreaUpdate(o) + output := *o + v.getAPICreditConsumptionInfo(width) + v.printer.AreaUpdate(&output) if m.Status != globalping.StatusInProgress { v.ctx.AggregatedStats = newAggregatedStats } @@ -381,3 +389,32 @@ func computeMdev(tsum float64, tsum2 float64, rcv int, avg float64) float64 { } return math.Sqrt(tsum2/float64(rcv) - avg*avg) } + +func (v *viewer) getAPICreditInfo() string { + if v.ctx.CIMode { + return apiCreditInfo + } + return v.printer.Color(apiCreditInfo, ColorLightYellow) +} + +func (v *viewer) getAPICreditConsumptionInfo(width int) string { + if v.ctx.MeasurementsCreated < 2 { + return "" + } + if v.ctx.MeasurementsCreated == apiCreditLastMeasurementCount { + return apiCreditLastConsumptionInfo + } + apiCreditLastMeasurementCount = v.ctx.MeasurementsCreated + elapsedMinutes := v.time.Now().Sub(v.ctx.RunSessionStartedAt).Minutes() + consumption := int64(math.Ceil(float64((apiCreditLastMeasurementCount-1)*(len(v.ctx.AggregatedStats))) / elapsedMinutes)) + info := fmt.Sprintf(apiCreditConsumptionInfo, utils.Pluralize(consumption, "API credit")) + if len(info) > width-4 { + info = info[:max(width-5, 0)] + "..." + } + if v.ctx.CIMode { + apiCreditLastConsumptionInfo = info + } else { + apiCreditLastConsumptionInfo = v.printer.Color(info, ColorLightYellow) + } + return apiCreditLastConsumptionInfo +} diff --git a/view/infinite_test.go b/view/infinite_test.go index 417bff9..2015ccf 100644 --- a/view/infinite_test.go +++ b/view/infinite_test.go @@ -34,7 +34,8 @@ func Test_OutputInfinite_SingleProbe_InProgress(t *testing.T) { assert.NoError(t, err) assert.Equal(t, - `> Berlin, DE, EU, Deutsche Telekom AG (AS3320) + apiCreditInfo+ + `> Berlin, DE, EU, Deutsche Telekom AG (AS3320) PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. `, w.String(), @@ -47,7 +48,8 @@ PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. assert.NoError(t, err) assert.Equal(t, - `> Berlin, DE, EU, Deutsche Telekom AG (AS3320) + apiCreditInfo+ + `> Berlin, DE, EU, Deutsche Telekom AG (AS3320) PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. 64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=56 time=12.9 ms `, @@ -62,7 +64,8 @@ PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. assert.NoError(t, err) assert.Equal(t, - `> Berlin, DE, EU, Deutsche Telekom AG (AS3320) + apiCreditInfo+ + `> Berlin, DE, EU, Deutsche Telekom AG (AS3320) PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. 64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=56 time=12.9 ms 64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=2 ttl=56 time=12.7 ms @@ -89,7 +92,8 @@ rtt min/avg/max/mdev = 12.711/12.854/12.952/0.103 ms` assert.NoError(t, err) assert.Equal(t, - `> Berlin, DE, EU, Deutsche Telekom AG (AS3320) + apiCreditInfo+ + `> Berlin, DE, EU, Deutsche Telekom AG (AS3320) PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. 64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=56 time=12.9 ms 64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=2 ttl=56 time=12.7 ms @@ -112,7 +116,8 @@ PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. assert.NoError(t, err) assert.Equal(t, - `> Berlin, DE, EU, Deutsche Telekom AG (AS3320) + apiCreditInfo+ + `> Berlin, DE, EU, Deutsche Telekom AG (AS3320) PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. 64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=56 time=12.9 ms 64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=2 ttl=56 time=12.7 ms @@ -157,7 +162,7 @@ func Test_OutputInfinite_MultipleProbes_MultipleCalls(t *testing.T) { defer ctrl.Finish() timeMock := mocks.NewMockTime(ctrl) - timeMock.EXPECT().Now().Return(defaultCurrentTime.Add(1 * time.Millisecond)).AnyTimes() + timeMock.EXPECT().Now().Return(defaultCurrentTime.Add(500 * time.Millisecond)).AnyTimes() measurement := createPingMeasurement_MultipleProbes(measurementID1) measurement.Status = globalping.StatusInProgress @@ -273,7 +278,7 @@ func Test_OutputInfinite_MultipleProbes_MultipleConcurrentCalls(t *testing.T) { defer ctrl.Finish() timeMock := mocks.NewMockTime(ctrl) - timeMock.EXPECT().Now().Return(defaultCurrentTime.Add(1 * time.Millisecond)).AnyTimes() + timeMock.EXPECT().Now().Return(defaultCurrentTime.Add(500 * time.Millisecond)).AnyTimes() // Call 1 measurement1 := createPingMeasurement_MultipleProbes(measurementID1) @@ -313,12 +318,14 @@ Nuremberg, DE, EU, Hetzner Online GmbH (AS0) | 1 | 0.00% | 4.07 ms | 4. Status: globalping.StatusInProgress, StartedAt: defaultCurrentTime.Add(1 * time.Millisecond), }) + ctx.MeasurementsCreated = 2 expectedOutput += "\033[4A\033[0J" + `Location | Sent | Loss | Last | Min | Avg | Max London, GB, EU, OVH SAS (AS0) | 1 | 0.00% | 10.0 ms | 10.0 ms | 10.0 ms | 10.0 ms Falkenstein, DE, EU, Hetzner Online GmbH (AS0) | 1 | 0.00% | 20.0 ms | 20.0 ms | 20.0 ms | 20.0 ms Nuremberg, DE, EU, Hetzner Online GmbH (AS0) | 2 | 0.00% | 4.07 ms | 4.07 ms | 4.07 ms | 4.07 ms +Consuming ~360 API credits/minute. ` err = viewer.OutputInfinite(measurement2) @@ -329,11 +336,12 @@ Nuremberg, DE, EU, Hetzner Online GmbH (AS0) | 2 | 0.00% | 4.07 ms | 4. 64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=60 time=20 ms 64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=2 ttl=30 time=25 ms` - expectedOutput += "\033[4A\033[0J" + + expectedOutput += "\033[5A\033[0J" + `Location | Sent | Loss | Last | Min | Avg | Max London, GB, EU, OVH SAS (AS0) | 1 | 0.00% | 10.0 ms | 10.0 ms | 10.0 ms | 10.0 ms Falkenstein, DE, EU, Hetzner Online GmbH (AS0) | 3 | 0.00% | 20.0 ms | 20.0 ms | 21.7 ms | 25.0 ms Nuremberg, DE, EU, Hetzner Online GmbH (AS0) | 2 | 0.00% | 4.07 ms | 4.07 ms | 4.07 ms | 4.07 ms +Consuming ~360 API credits/minute. ` err = viewer.OutputInfinite(measurement1) @@ -361,11 +369,12 @@ rtt min/avg/max/mdev = 10/15/25/5 ms` rtt min/avg/max/mdev = 20/25/30/5 ms` hm1.Status = globalping.StatusFinished - expectedOutput += "\033[4A\033[0J" + + expectedOutput += "\033[5A\033[0J" + `Location | Sent | Loss | Last | Min | Avg | Max London, GB, EU, OVH SAS (AS0) | 3 | 0.00% | 25.0 ms | 10.0 ms | 16.7 ms | 25.0 ms Falkenstein, DE, EU, Hetzner Online GmbH (AS0) | 4 | 0.00% | 20.0 ms | 20.0 ms | 23.8 ms | 30.0 ms Nuremberg, DE, EU, Hetzner Online GmbH (AS0) | 2 | 0.00% | 4.07 ms | 4.07 ms | 4.07 ms | 4.07 ms +Consuming ~360 API credits/minute. ` err = viewer.OutputInfinite(measurement1) @@ -385,11 +394,12 @@ rtt min/avg/max/mdev = 10/15/25/5 ms` err = viewer.OutputInfinite(measurement2) assert.NoError(t, err) - expectedOutput += "\033[4A\033[0J" + + expectedOutput += "\033[5A\033[0J" + `Location | Sent | Loss | Last | Min | Avg | Max London, GB, EU, OVH SAS (AS0) | 6 | 0.00% | 25.0 ms | 10.0 ms | 16.7 ms | 25.0 ms Falkenstein, DE, EU, Hetzner Online GmbH (AS0) | 4 | 0.00% | 20.0 ms | 20.0 ms | 23.8 ms | 30.0 ms Nuremberg, DE, EU, Hetzner Online GmbH (AS0) | 2 | 0.00% | 4.07 ms | 4.07 ms | 4.07 ms | 4.07 ms +Consuming ~360 API credits/minute. ` assert.Equal(t, expectedOutput, w.String()) } @@ -400,9 +410,12 @@ func Test_OutputInfinite_MultipleProbes(t *testing.T) { measurement := createPingMeasurement_MultipleProbes(measurementID1) + timeMock := mocks.NewMockTime(ctrl) + timeMock.EXPECT().Now().Return(defaultCurrentTime.Add(500 * time.Millisecond)).AnyTimes() + ctx := createDefaultContext("ping") w := new(bytes.Buffer) - v := NewViewer(ctx, NewPrinter(nil, w, w), nil, nil) + v := NewViewer(ctx, NewPrinter(nil, w, w), timeMock, nil) err := v.OutputInfinite(measurement) assert.NoError(t, err) diff --git a/view/printer.go b/view/printer.go index bc7c17a..ff0d961 100644 --- a/view/printer.go +++ b/view/printer.go @@ -13,9 +13,10 @@ import ( type Color string const ( - ColorNone Color = "" - ColorLightCyan Color = "96" - ColorHighlight Color = "38;2;23;212;167" + ColorNone Color = "" + ColorLightYellow Color = "93" + ColorLightCyan Color = "96" + ColorHighlight Color = "38;2;23;212;167" ) type Printer struct { diff --git a/view/share.go b/view/share.go new file mode 100644 index 0000000..f08611c --- /dev/null +++ b/view/share.go @@ -0,0 +1,22 @@ +package view + +func (v *viewer) OutputShare() { + if !v.ctx.Share { + return + } + if v.ctx.History == nil { + return + } + + if len(v.ctx.AggregatedStats) > 1 { + v.printer.Println() // Add a newline in table view + } + ids := v.ctx.History.ToString(".") + if ids != "" { + v.printer.Println(v.getShareMessage(ids)) + } + if v.ctx.MeasurementsCreated > v.ctx.History.Capacity() { + v.printer.Printf("For long-running continuous mode measurements, only the last %d packets are shared.\n", + v.ctx.Packets*v.ctx.History.Capacity()) + } +} diff --git a/view/share_test.go b/view/share_test.go new file mode 100644 index 0000000..0d256f4 --- /dev/null +++ b/view/share_test.go @@ -0,0 +1,67 @@ +package view + +import ( + "bytes" + "fmt" + "math" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_OutputShare(t *testing.T) { + t.Run("Single_location", func(t *testing.T) { + w := new(bytes.Buffer) + ctx := createDefaultContext("ping") + ctx.AggregatedStats = []*MeasurementStats{ + {Sent: 1, Rcv: 0, Lost: 1, Loss: 100, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1, Time: 0}, + } + ctx.Share = true + viewer := NewViewer(ctx, NewPrinter(nil, w, w), nil, nil) + viewer.OutputShare() + + expectedOutput := fmt.Sprintf("\033[1;38;2;23;212;167m> View the results online: https://www.jsdelivr.com/globalping?measurement=%s\033[0m\n", measurementID1) + + assert.Equal(t, expectedOutput, w.String()) + }) + + t.Run("Multiple_locations", func(t *testing.T) { + ctx := createDefaultContext("ping") + ctx.AggregatedStats = []*MeasurementStats{ + NewMeasurementStats(), + NewMeasurementStats(), + } + ctx.History.Push(&HistoryItem{Id: measurementID2}) + ctx.Share = true + ctx.CIMode = true + w := new(bytes.Buffer) + viewer := NewViewer(ctx, NewPrinter(nil, w, w), nil, nil) + viewer.OutputShare() + + expectedOutput := fmt.Sprintf("\n> View the results online: https://www.jsdelivr.com/globalping?measurement=%s.%s\n", measurementID1, measurementID2) + assert.Equal(t, expectedOutput, w.String()) + }) + + t.Run("Multiple_locations_More_calls_than_MaxHistory", func(t *testing.T) { + history := NewHistoryBuffer(1) + history.Push(&HistoryItem{Id: measurementID2}) + ctx := &Context{ + AggregatedStats: []*MeasurementStats{ + NewMeasurementStats(), + NewMeasurementStats(), + }, + History: history, + Share: true, + CIMode: true, + MeasurementsCreated: 2, + Packets: 16, + } + w := new(bytes.Buffer) + viewer := NewViewer(ctx, NewPrinter(nil, w, w), nil, nil) + viewer.OutputShare() + + expectedOutput := fmt.Sprintf("\n> View the results online: https://www.jsdelivr.com/globalping?measurement=%s", measurementID2) + + "\nFor long-running continuous mode measurements, only the last 16 packets are shared.\n" + assert.Equal(t, expectedOutput, w.String()) + }) +} diff --git a/view/summary.go b/view/summary.go index 7185904..d628a08 100644 --- a/view/summary.go +++ b/view/summary.go @@ -6,50 +6,34 @@ import ( ) func (v *viewer) OutputSummary() { - if len(v.ctx.AggregatedStats) == 0 { + if len(v.ctx.AggregatedStats) != 1 { return } - if len(v.ctx.AggregatedStats) == 1 { - stats := v.aggregateConcurrentStats(v.ctx.AggregatedStats[0], 0, "") + stats := v.aggregateConcurrentStats(v.ctx.AggregatedStats[0], 0, "") - v.printer.Printf("\n--- %s ping statistics ---\n", v.ctx.Hostname) - v.printer.Printf("%d packets transmitted, %d received, %.2f%% packet loss, time %.0fms\n", - stats.Sent, - stats.Rcv, - stats.Loss, - stats.Time, - ) - min := "-" - avg := "-" - max := "-" - mdev := "-" - if stats.Min != math.MaxFloat64 { - min = fmt.Sprintf("%.3f", stats.Min) - } - if stats.Avg != -1 { - avg = fmt.Sprintf("%.3f", stats.Avg) - } - if stats.Max != -1 { - max = fmt.Sprintf("%.3f", stats.Max) - } - if stats.Mdev != 0 { - mdev = fmt.Sprintf("%.3f", stats.Mdev) - } - v.printer.Printf("rtt min/avg/max/mdev = %s/%s/%s/%s ms\n", min, avg, max, mdev) + v.printer.Printf("\n--- %s ping statistics ---\n", v.ctx.Hostname) + v.printer.Printf("%d packets transmitted, %d received, %.2f%% packet loss, time %.0fms\n", + stats.Sent, + stats.Rcv, + stats.Loss, + stats.Time, + ) + min := "-" + avg := "-" + max := "-" + mdev := "-" + if stats.Min != math.MaxFloat64 { + min = fmt.Sprintf("%.3f", stats.Min) } - - if v.ctx.Share && v.ctx.History != nil { - if len(v.ctx.AggregatedStats) > 1 { - v.printer.Println() // Add a newline in table view - } - ids := v.ctx.History.ToString(".") - if ids != "" { - v.printer.Println(v.getShareMessage(ids)) - } - if v.ctx.MeasurementsCreated > v.ctx.History.Capacity() { - v.printer.Printf("For long-running continuous mode measurements, only the last %d packets are shared.\n", - v.ctx.Packets*v.ctx.History.Capacity()) - } + if stats.Avg != -1 { + avg = fmt.Sprintf("%.3f", stats.Avg) + } + if stats.Max != -1 { + max = fmt.Sprintf("%.3f", stats.Max) + } + if stats.Mdev != 0 { + mdev = fmt.Sprintf("%.3f", stats.Mdev) } + v.printer.Printf("rtt min/avg/max/mdev = %s/%s/%s/%s ms\n", min, avg, max, mdev) } diff --git a/view/summary_test.go b/view/summary_test.go index 28a8dd9..a22162e 100644 --- a/view/summary_test.go +++ b/view/summary_test.go @@ -2,8 +2,6 @@ package view import ( "bytes" - "fmt" - "math" "testing" "github.com/jsdelivr/globalping-cli/globalping" @@ -62,63 +60,4 @@ rtt min/avg/max/mdev = 0.770/0.770/0.770/0.000 ms assert.Equal(t, "", w.String()) }) - - t.Run("Single_location_Share", func(t *testing.T) { - w := new(bytes.Buffer) - ctx := createDefaultContext("ping") - ctx.AggregatedStats = []*MeasurementStats{ - {Sent: 1, Rcv: 0, Lost: 1, Loss: 100, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1, Time: 0}, - } - ctx.Share = true - viewer := NewViewer(ctx, NewPrinter(nil, w, w), nil, nil) - viewer.OutputSummary() - - expectedOutput := ` ---- ping statistics --- -1 packets transmitted, 0 received, 100.00% packet loss, time 0ms -rtt min/avg/max/mdev = -/-/-/- ms -` + fmt.Sprintf("\033[1;38;2;23;212;167m> View the results online: https://www.jsdelivr.com/globalping?measurement=%s\033[0m\n", measurementID1) - - assert.Equal(t, expectedOutput, w.String()) - }) - - t.Run("Multiple_locations_Share", func(t *testing.T) { - ctx := createDefaultContext("ping") - ctx.AggregatedStats = []*MeasurementStats{ - NewMeasurementStats(), - NewMeasurementStats(), - } - ctx.History.Push(&HistoryItem{Id: measurementID2}) - ctx.Share = true - ctx.CIMode = true - w := new(bytes.Buffer) - viewer := NewViewer(ctx, NewPrinter(nil, w, w), nil, nil) - viewer.OutputSummary() - - expectedOutput := fmt.Sprintf("\n> View the results online: https://www.jsdelivr.com/globalping?measurement=%s.%s\n", measurementID1, measurementID2) - assert.Equal(t, expectedOutput, w.String()) - }) - - t.Run("Multiple_locations_Share_More_calls_than_MaxHistory", func(t *testing.T) { - history := NewHistoryBuffer(1) - history.Push(&HistoryItem{Id: measurementID2}) - ctx := &Context{ - AggregatedStats: []*MeasurementStats{ - NewMeasurementStats(), - NewMeasurementStats(), - }, - History: history, - Share: true, - CIMode: true, - MeasurementsCreated: 2, - Packets: 16, - } - w := new(bytes.Buffer) - viewer := NewViewer(ctx, NewPrinter(nil, w, w), nil, nil) - viewer.OutputSummary() - - expectedOutput := fmt.Sprintf("\n> View the results online: https://www.jsdelivr.com/globalping?measurement=%s", measurementID2) + - "\nFor long-running continuous mode measurements, only the last 16 packets are shared.\n" - assert.Equal(t, expectedOutput, w.String()) - }) } diff --git a/view/utils_test.go b/view/utils_test.go index b2743c8..2ee52c0 100644 --- a/view/utils_test.go +++ b/view/utils_test.go @@ -145,6 +145,7 @@ func createDefaultContext(cmd string) *Context { Cmd: cmd, MeasurementsCreated: 1, History: NewHistoryBuffer(3), + RunSessionStartedAt: defaultCurrentTime, } if cmd == "ping" { ctx.History.Push(&HistoryItem{ diff --git a/view/viewer.go b/view/viewer.go index 97ce369..9e400c7 100644 --- a/view/viewer.go +++ b/view/viewer.go @@ -9,6 +9,7 @@ type Viewer interface { Output(id string, m *globalping.MeasurementCreate) error OutputInfinite(m *globalping.Measurement) error OutputSummary() + OutputShare() } type viewer struct {