From 9eeef2d4a240a2ac35920aa5e1f11d9f5e85b21c Mon Sep 17 00:00:00 2001 From: Radu Lucut <radulucut@pm.me> Date: Fri, 16 Feb 2024 21:59:48 +0200 Subject: [PATCH] Refactoring: Remove global variables & add more tests --- cmd/common.go | 131 +++++++++-- cmd/common_test.go | 192 ++++++++++++++-- cmd/dns.go | 117 +++++----- cmd/dns_test.go | 92 ++++++++ cmd/http.go | 214 ++++++++---------- cmd/http_test.go | 140 +++++++++--- cmd/install_probe.go | 56 +++-- cmd/install_probe_test.go | 56 +++++ cmd/mtr.go | 112 ++++----- cmd/mtr_test.go | 85 +++++++ cmd/ping.go | 8 +- cmd/ping_test.go | 48 ++-- cmd/root.go | 189 ++++------------ cmd/root_test.go | 63 ------ cmd/traceroute.go | 108 ++++----- cmd/traceroute_test.go | 82 +++++++ cmd/version.go | 18 +- cmd/version_test.go | 32 +++ {lib => globalping}/probe/container_engine.go | 2 +- {lib => globalping}/probe/probe.go | 62 +++-- go.mod | 2 +- lib/target_query.go | 71 ------ lib/target_query_test.go | 91 -------- mocks/gen_mocks.sh | 1 + mocks/mock_probe.go | 83 +++++++ view/context.go | 29 +-- view/default.go | 4 +- view/default_test.go | 34 +-- view/infinite.go | 14 +- view/infinite_test.go | 12 +- view/json.go | 4 +- view/json_test.go | 2 +- view/latency.go | 24 +- view/latency_test.go | 18 +- view/output.go | 16 +- view/output_test.go | 8 +- view/printer.go | 22 +- view/summary.go | 2 +- view/summary_test.go | 14 +- view/viewer.go | 18 +- 40 files changed, 1355 insertions(+), 921 deletions(-) create mode 100644 cmd/dns_test.go create mode 100644 cmd/install_probe_test.go create mode 100644 cmd/mtr_test.go delete mode 100644 cmd/root_test.go create mode 100644 cmd/traceroute_test.go create mode 100644 cmd/version_test.go rename {lib => globalping}/probe/container_engine.go (93%) rename {lib => globalping}/probe/probe.go (72%) delete mode 100644 lib/target_query.go delete mode 100644 lib/target_query_test.go create mode 100644 mocks/mock_probe.go diff --git a/cmd/common.go b/cmd/common.go index 551f38c..656f6b7 100644 --- a/cmd/common.go +++ b/cmd/common.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "runtime" + "slices" "strconv" "strings" @@ -25,28 +26,62 @@ var ( var SESSION_PATH string -func inProgressUpdates(ci bool) bool { - return !(ci) +func (r *Root) updateContext(cmd string, args []string) error { + r.ctx.Cmd = cmd // Get the command name + + targetQuery, err := parseTargetQuery(cmd, args) + if err != nil { + return err + } + + r.ctx.Target = targetQuery.Target + + if targetQuery.From != "" { + r.ctx.From = targetQuery.From + } + + if targetQuery.Resolver != "" { + r.ctx.Resolver = targetQuery.Resolver + } + + // Check env for CI + if os.Getenv("CI") != "" { + r.ctx.CIMode = true + } + + // Check if it is a terminal or being piped/redirected + // We want to disable realtime updates if that is the case + f, ok := r.printer.OutWriter.(*os.File) + if ok { + stdoutFileInfo, err := f.Stat() + if err != nil { + return fmt.Errorf("stdout stat failed: %s", err) + } + if (stdoutFileInfo.Mode() & os.ModeCharDevice) == 0 { + // stdout is piped, run in ci mode + r.ctx.CIMode = true + } + } else { + r.ctx.CIMode = true + } + + return nil } func createLocations(from string) ([]globalping.Locations, bool, error) { fromArr := strings.Split(from, ",") if len(fromArr) == 1 { - mId, err := mapToMeasurementID(fromArr[0]) + mId, err := mapFromHistory(fromArr[0]) if err != nil { return nil, false, err } - isPreviousMeasurementId := false + isFromHistory := false if mId == "" { mId = strings.TrimSpace(fromArr[0]) } else { - isPreviousMeasurementId = true + isFromHistory = true } - return []globalping.Locations{ - { - Magic: mId, - }, - }, isPreviousMeasurementId, nil + return []globalping.Locations{{Magic: mId}}, isFromHistory, nil } locations := make([]globalping.Locations, len(fromArr)) for i, v := range fromArr { @@ -57,8 +92,70 @@ func createLocations(from string) ([]globalping.Locations, bool, error) { return locations, false, nil } -// Maps a location to a measurement ID if possible -func mapToMeasurementID(location string) (string, error) { +type TargetQuery struct { + Target string + From string + Resolver string +} + +var commandsWithResolver = []string{ + "dns", + "http", +} + +func parseTargetQuery(cmd string, args []string) (*TargetQuery, error) { + targetQuery := &TargetQuery{} + if len(args) == 0 { + return nil, errors.New("provided target is empty") + } + + resolver, argsWithoutResolver := findAndRemoveResolver(args) + if resolver != "" { + // resolver was found + if !slices.Contains(commandsWithResolver, cmd) { + return nil, fmt.Errorf("command %s does not accept a resolver argument. @%s was provided", cmd, resolver) + } + + targetQuery.Resolver = resolver + } + + targetQuery.Target = argsWithoutResolver[0] + + if len(argsWithoutResolver) > 1 { + if argsWithoutResolver[1] == "from" { + targetQuery.From = strings.TrimSpace(strings.Join(argsWithoutResolver[2:], " ")) + } else { + return nil, errors.New("invalid command format") + } + } + + return targetQuery, nil +} + +func findAndRemoveResolver(args []string) (string, []string) { + var resolver string + resolverIndex := -1 + for i := 0; i < len(args); i++ { + if len(args[i]) > 0 && args[i][0] == '@' && args[i-1] != "from" { + resolver = args[i][1:] + resolverIndex = i + break + } + } + + if resolverIndex == -1 { + // resolver was not found + return "", args + } + + argsClone := slices.Clone(args) + argsWithoutResolver := slices.Delete(argsClone, resolverIndex, resolverIndex+1) + + return resolver, argsWithoutResolver +} + +// Maps a location to a measurement ID from history, if possible. +func mapFromHistory(location string) (string, error) { if location == "" { return "", nil } @@ -67,19 +164,19 @@ func mapToMeasurementID(location string) (string, error) { if err != nil { return "", ErrInvalidIndex } - return getMeasurementID(index) + return getIdFromHistory(index) } if location == "first" { - return getMeasurementID(1) + return getIdFromHistory(1) } if location == "last" || location == "previous" { - return getMeasurementID(-1) + return getIdFromHistory(-1) } return "", nil } // Returns the measurement ID at the given index from the session history -func getMeasurementID(index int) (string, error) { +func getIdFromHistory(index int) (string, error) { if index == 0 { return "", ErrInvalidIndex } @@ -130,7 +227,7 @@ func getMeasurementID(index int) (string, error) { } // Saves the measurement ID to the session history -func saveMeasurementID(id string) error { +func saveIdToHistory(id string) error { _, err := os.Stat(getSessionPath()) if err != nil { if errors.Is(err, fs.ErrNotExist) { diff --git a/cmd/common_test.go b/cmd/common_test.go index daa0ea2..c697e90 100644 --- a/cmd/common_test.go +++ b/cmd/common_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/jsdelivr/globalping-cli/globalping" + "github.com/jsdelivr/globalping-cli/view" "github.com/stretchr/testify/assert" ) @@ -20,14 +21,165 @@ var ( defaultCurrentTime = time.Unix(0, 0) ) -func Test_InProgressUpdates_CI(t *testing.T) { - ci := true - assert.Equal(t, false, inProgressUpdates(ci)) +func Test_UpdateContext(t *testing.T) { + for scenario, fn := range map[string]func(t *testing.T){ + "no_arg": test_updateContext_NoArg, + "country": test_updateContext_Country, + "country_whitespace": test_updateContext_CountryWhitespace, + "no_target": test_updateContext_NoTarget, + "ci_env": test_uodateContext_CIEnv, + } { + t.Run(scenario, func(t *testing.T) { + fn(t) + }) + } +} + +func test_updateContext_NoArg(t *testing.T) { + ctx := &view.Context{} + printer := view.NewPrinter(nil, nil, nil) + root := NewRoot(printer, ctx, nil, nil, nil, nil) + + err := root.updateContext("test", []string{"1.1.1.1"}) + assert.Equal(t, "test", ctx.Cmd) + assert.Equal(t, "1.1.1.1", ctx.Target) + assert.Equal(t, "world", ctx.From) + assert.NoError(t, err) +} + +func test_updateContext_Country(t *testing.T) { + ctx := &view.Context{} + printer := view.NewPrinter(nil, nil, nil) + root := NewRoot(printer, ctx, nil, nil, nil, nil) + + err := root.updateContext("test", []string{"1.1.1.1", "from", "Germany"}) + assert.Equal(t, "test", ctx.Cmd) + assert.Equal(t, "1.1.1.1", ctx.Target) + assert.Equal(t, "Germany", ctx.From) + assert.NoError(t, err) +} + +// Check if country with whitespace is parsed correctly +func test_updateContext_CountryWhitespace(t *testing.T) { + ctx := &view.Context{} + printer := view.NewPrinter(nil, nil, nil) + root := NewRoot(printer, ctx, nil, nil, nil, nil) + + err := root.updateContext("test", []string{"1.1.1.1", "from", " Germany, France"}) + assert.Equal(t, "test", ctx.Cmd) + assert.Equal(t, "1.1.1.1", ctx.Target) + assert.Equal(t, "Germany, France", ctx.From) + assert.NoError(t, err) +} + +func test_updateContext_NoTarget(t *testing.T) { + ctx := &view.Context{} + printer := view.NewPrinter(nil, nil, nil) + root := NewRoot(printer, ctx, nil, nil, nil, nil) + + err := root.updateContext("test", []string{}) + assert.Error(t, err) +} + +func test_uodateContext_CIEnv(t *testing.T) { + oldCI := os.Getenv("CI") + t.Setenv("CI", "true") + defer t.Setenv("CI", oldCI) + + ctx := &view.Context{} + printer := view.NewPrinter(nil, nil, nil) + root := NewRoot(printer, ctx, nil, nil, nil, nil) + + err := root.updateContext("test", []string{"1.1.1.1"}) + assert.Equal(t, "test", ctx.Cmd) + assert.Equal(t, "1.1.1.1", ctx.Target) + assert.Equal(t, "world", ctx.From) + assert.True(t, ctx.CIMode) + assert.NoError(t, err) +} + +func Test_ParseTargetQuery_Simple(t *testing.T) { + cmd := "ping" + args := []string{"example.com"} + + q, err := parseTargetQuery(cmd, args) + assert.NoError(t, err) + + assert.Equal(t, TargetQuery{Target: "example.com", From: ""}, *q) +} + +func Test_ParseTargetQuery_SimpleWithResolver(t *testing.T) { + cmd := "dns" + args := []string{"example.com", "@1.1.1.1"} + + q, err := parseTargetQuery(cmd, args) + assert.NoError(t, err) + + assert.Equal(t, TargetQuery{Target: "example.com", From: "", Resolver: "1.1.1.1"}, *q) } -func Test_InProgressUpdates_NotCI(t *testing.T) { - ci := false - assert.Equal(t, true, inProgressUpdates(ci)) +func Test_ParseTargetQuery_ResolverNotAllowed(t *testing.T) { + cmd := "ping" + args := []string{"example.com", "@1.1.1.1"} + + _, err := parseTargetQuery(cmd, args) + assert.ErrorContains(t, err, "does not accept a resolver argument") +} + +func Test_ParseTargetQuery_TargetFromX(t *testing.T) { + cmd := "ping" + args := []string{"example.com", "from", "London"} + + q, err := parseTargetQuery(cmd, args) + assert.NoError(t, err) + + assert.Equal(t, TargetQuery{Target: "example.com", From: "London"}, *q) +} + +func Test_ParseTargetQuery_TargetFromXWithResolver(t *testing.T) { + cmd := "http" + args := []string{"example.com", "from", "London", "@1.1.1.1"} + + q, err := parseTargetQuery(cmd, args) + assert.NoError(t, err) + + assert.Equal(t, TargetQuery{Target: "example.com", From: "London", Resolver: "1.1.1.1"}, *q) +} + +func Test_FindAndRemoveResolver_SimpleNoResolver(t *testing.T) { + args := []string{"example.com"} + + resolver, argsWithoutResolver := findAndRemoveResolver(args) + + assert.Equal(t, "", resolver) + assert.Equal(t, args, argsWithoutResolver) +} + +func Test_FindAndRemoveResolver_NoResolver(t *testing.T) { + args := []string{"example.com", "from", "London"} + + resolver, argsWithoutResolver := findAndRemoveResolver(args) + + assert.Equal(t, "", resolver) + assert.Equal(t, args, argsWithoutResolver) +} + +func Test_FindAndRemoveResolver_ResolverAndFrom(t *testing.T) { + args := []string{"example.com", "@1.1.1.1", "from", "London"} + + resolver, argsWithoutResolver := findAndRemoveResolver(args) + + assert.Equal(t, "1.1.1.1", resolver) + assert.Equal(t, []string{"example.com", "from", "London"}, argsWithoutResolver) +} + +func Test_FindAndRemoveResolver_ResolverOnly(t *testing.T) { + args := []string{"example.com", "@1.1.1.1"} + + resolver, argsWithoutResolver := findAndRemoveResolver(args) + + assert.Equal(t, "1.1.1.1", resolver) + assert.Equal(t, []string{"example.com"}, argsWithoutResolver) } func Test_CreateLocations(t *testing.T) { @@ -62,7 +214,6 @@ func test_CreateLocations_Multiple(t *testing.T) { assert.Nil(t, err) } -// Check if multiple locations with whitespace are parsed correctly func test_CreateLocations_Multiple_Whitespace(t *testing.T) { locations, isPreviousMeasurementId, err := createLocations("New York, Los Angeles ") assert.Equal(t, []globalping.Locations{{Magic: "New York"}, {Magic: "Los Angeles"}}, locations) @@ -71,7 +222,7 @@ func test_CreateLocations_Multiple_Whitespace(t *testing.T) { } func test_CreateLocations_Session_Last_Measurement(t *testing.T) { - _ = saveMeasurementID(measurementID1) + _ = saveIdToHistory(measurementID1) locations, isPreviousMeasurementId, err := createLocations("@1") assert.Equal(t, []globalping.Locations{{Magic: measurementID1}}, locations) assert.True(t, isPreviousMeasurementId) @@ -89,8 +240,8 @@ func test_CreateLocations_Session_Last_Measurement(t *testing.T) { } func test_CreateLocations_Session_First_Measurement(t *testing.T) { - _ = saveMeasurementID(measurementID1) - _ = saveMeasurementID(measurementID2) + _ = saveIdToHistory(measurementID1) + _ = saveIdToHistory(measurementID2) locations, isPreviousMeasurementId, err := createLocations("@-1") assert.Equal(t, []globalping.Locations{{Magic: measurementID2}}, locations) assert.True(t, isPreviousMeasurementId) @@ -103,10 +254,10 @@ func test_CreateLocations_Session_First_Measurement(t *testing.T) { } func test_CreateLocations_Session_Measurement_At_Index(t *testing.T) { - _ = saveMeasurementID(measurementID1) - _ = saveMeasurementID(measurementID2) - _ = saveMeasurementID(measurementID3) - _ = saveMeasurementID(measurementID4) + _ = saveIdToHistory(measurementID1) + _ = saveIdToHistory(measurementID2) + _ = saveIdToHistory(measurementID3) + _ = saveIdToHistory(measurementID4) locations, isPreviousMeasurementId, err := createLocations("@2") assert.Equal(t, []globalping.Locations{{Magic: measurementID2}}, locations) assert.True(t, isPreviousMeasurementId) @@ -146,7 +297,7 @@ func test_CreateLocations_Session_Invalid_Index(t *testing.T) { assert.False(t, isPreviousMeasurementId) assert.Equal(t, ErrInvalidIndex, err) - _ = saveMeasurementID(measurementID1) + _ = saveIdToHistory(measurementID1) locations, isPreviousMeasurementId, err = createLocations("@2") assert.Nil(t, locations) assert.False(t, isPreviousMeasurementId) @@ -171,7 +322,7 @@ func Test_SaveMeasurementID(t *testing.T) { } func test_SaveMeasurementID_New_Session(t *testing.T) { - _ = saveMeasurementID(measurementID1) + _ = saveIdToHistory(measurementID1) assert.FileExists(t, getMeasurementsPath()) b, err := os.ReadFile(getMeasurementsPath()) assert.NoError(t, err) @@ -188,7 +339,7 @@ func test_SaveMeasurementID_Existing_Session(t *testing.T) { if err != nil { t.Fatalf("Failed to create measurements file: %s", err) } - _ = saveMeasurementID(measurementID2) + _ = saveIdToHistory(measurementID2) b, err := os.ReadFile(getMeasurementsPath()) assert.NoError(t, err) expected := []byte(measurementID1 + "\n" + measurementID2 + "\n") @@ -202,3 +353,10 @@ func sessionCleanup() { panic("Failed to remove session path: " + err.Error()) } } + +func getMeasurementCreateResponse(id string) *globalping.MeasurementCreateResponse { + return &globalping.MeasurementCreateResponse{ + ID: id, + ProbesCount: 1, + } +} diff --git a/cmd/dns.go b/cmd/dns.go index 1b28ebc..6f60c08 100644 --- a/cmd/dns.go +++ b/cmd/dns.go @@ -1,18 +1,17 @@ package cmd import ( - "fmt" - "github.com/jsdelivr/globalping-cli/globalping" "github.com/spf13/cobra" ) -// dnsCmd represents the dns command -var dnsCmd = &cobra.Command{ - Use: "dns [target] from [location | measurement ID | @1 | first | @-1 | last | previous]", - GroupID: "Measurements", - Short: "Resolve a DNS record similarly to dig", - Long: `Performs DNS lookups and displays the answers that are returned from the name server(s) that were queried. +func (r *Root) initDNS() { + dnsCmd := &cobra.Command{ + RunE: r.RunDNS, + Use: "dns [target] from [location | measurement ID | @1 | first | @-1 | last | previous]", + GroupID: "Measurements", + Short: "Resolve a DNS record similarly to dig", + Long: `Performs DNS lookups and displays the answers that are returned from the name server(s) that were queried. The default nameserver depends on the probe and is defined by the user's local settings or DHCP. This command provides 2 different ways to provide the dns resolver: Using the --resolver argument. For example: @@ -50,65 +49,63 @@ Using the dig format @resolver. For example: # Resolve jsdelivr.com from a probe in ASN 123 with json output dns jsdelivr.com from 123 --json`, - RunE: func(cmd *cobra.Command, args []string) error { - // Create context + } - err := createContext(cmd.CalledAs(), args) - if err != nil { - return err - } + // dns specific flags + flags := dnsCmd.Flags() + flags.StringVar(&r.ctx.Protocol, "protocol", "", "Specifies the protocol to use for the DNS query (TCP or UDP) (default \"udp\")") + flags.IntVar(&r.ctx.Port, "port", 0, "Send the query to a non-standard port on the server (default 53)") + flags.StringVar(&r.ctx.Resolver, "resolver", "", "Resolver is the hostname or IP address of the name server to use (default empty)") + flags.StringVar(&r.ctx.QueryType, "type", "", "Specifies the type of DNS query to perform (default \"A\")") + flags.BoolVar(&r.ctx.Trace, "trace", false, "Toggle tracing of the delegation path from the root name servers (default false)") + + r.Cmd.AddCommand(dnsCmd) +} - // Make post struct - opts = globalping.MeasurementCreate{ - Type: "dns", - Target: ctx.Target, - Limit: ctx.Limit, - InProgressUpdates: inProgressUpdates(ctx.CI), - Options: &globalping.MeasurementOptions{ - Protocol: protocol, - Port: port, - Resolver: overrideOpt(ctx.Resolver, resolver), - Query: &globalping.QueryOptions{ - Type: queryType, - }, - Trace: trace, +func (r *Root) RunDNS(cmd *cobra.Command, args []string) error { + err := r.updateContext(cmd.CalledAs(), args) + if err != nil { + return err + } + + opts := &globalping.MeasurementCreate{ + Type: "dns", + Target: r.ctx.Target, + Limit: r.ctx.Limit, + InProgressUpdates: !r.ctx.CIMode, + Options: &globalping.MeasurementOptions{ + Protocol: r.ctx.Protocol, + Port: r.ctx.Port, + Resolver: r.ctx.Resolver, + Query: &globalping.QueryOptions{ + Type: r.ctx.QueryType, }, - } - isPreviousMeasurementId := false - opts.Locations, isPreviousMeasurementId, err = createLocations(ctx.From) - if err != nil { + Trace: r.ctx.Trace, + }, + } + isPreviousMeasurementId := false + opts.Locations, isPreviousMeasurementId, err = createLocations(r.ctx.From) + if err != nil { + cmd.SilenceUsage = true + return err + } + + res, showHelp, err := r.client.CreateMeasurement(opts) + if err != nil { + if !showHelp { cmd.SilenceUsage = true - return err } + return err + } - res, showHelp, err := gp.CreateMeasurement(&opts) + // Save measurement ID to history + if !isPreviousMeasurementId { + err := saveIdToHistory(res.ID) if err != nil { - if !showHelp { - cmd.SilenceUsage = true - } - return err + r.printer.Printf("Warning: %s\n", err) } + } - // Save measurement ID to history - if !isPreviousMeasurementId { - err := saveMeasurementID(res.ID) - if err != nil { - fmt.Printf("Warning: %s\n", err) - } - } - - viewer.Output(res.ID, &opts) - return nil - }, -} - -func init() { - rootCmd.AddCommand(dnsCmd) - - // dns specific flags - dnsCmd.Flags().StringVar(&protocol, "protocol", "", "Specifies the protocol to use for the DNS query (TCP or UDP) (default \"udp\")") - dnsCmd.Flags().IntVar(&port, "port", 0, "Send the query to a non-standard port on the server (default 53)") - dnsCmd.Flags().StringVar(&resolver, "resolver", "", "Resolver is the hostname or IP address of the name server to use (default empty)") - dnsCmd.Flags().StringVar(&queryType, "type", "", "Specifies the type of DNS query to perform (default \"A\")") - dnsCmd.Flags().BoolVar(&trace, "trace", false, "Toggle tracing of the delegation path from the root name servers (default false)") + r.viewer.Output(res.ID, opts) + return nil } diff --git a/cmd/dns_test.go b/cmd/dns_test.go new file mode 100644 index 0000000..ef8bd64 --- /dev/null +++ b/cmd/dns_test.go @@ -0,0 +1,92 @@ +package cmd + +import ( + "context" + "io" + "os" + "testing" + + "github.com/jsdelivr/globalping-cli/globalping" + "github.com/jsdelivr/globalping-cli/mocks" + "github.com/jsdelivr/globalping-cli/view" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func Test_Execute_DNS_Default(t *testing.T) { + t.Cleanup(sessionCleanup) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + expectedOpts := &globalping.MeasurementCreate{ + Type: "dns", + Target: "jsdelivr.com", + Limit: 2, + Options: &globalping.MeasurementOptions{ + Protocol: "tcp", + Port: 99, + Resolver: "1.1.1.1", + Query: &globalping.QueryOptions{ + Type: "MX", + }, + Trace: true, + }, + Locations: []globalping.Locations{ + {Magic: "Berlin"}, + }, + } + expectedResponse := getMeasurementCreateResponse(measurementID1) + + gbMock := mocks.NewMockClient(ctrl) + gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, false, nil) + + viewerMock := mocks.NewMockViewer(ctrl) + viewerMock.EXPECT().Output(measurementID1, expectedOpts).Times(1).Return(nil) + + ctx := &view.Context{ + MaxHistory: 1, + } + r, w, err := os.Pipe() + assert.NoError(t, err) + defer r.Close() + defer w.Close() + + printer := view.NewPrinter(nil, w, w) + root := NewRoot(printer, ctx, viewerMock, nil, gbMock, nil) + os.Args = []string{"globalping", "dns", "jsdelivr.com", + "from", "Berlin", + "--limit", "2", + "--type", "MX", + "--resolver", "1.1.1.1", + "--port", "99", + "--protocol", "tcp", + "--trace"} + err = root.Cmd.ExecuteContext(context.TODO()) + assert.NoError(t, err) + w.Close() + + output, err := io.ReadAll(r) + assert.NoError(t, err) + assert.Equal(t, "", string(output)) + + expectedCtx := &view.Context{ + Cmd: "dns", + Target: "jsdelivr.com", + From: "Berlin", + Limit: 2, + Resolver: "1.1.1.1", + QueryType: "MX", + Protocol: "tcp", + Trace: true, + Port: 99, + CIMode: true, + MaxHistory: 1, + } + assert.Equal(t, expectedCtx, ctx) + + b, err := os.ReadFile(getMeasurementsPath()) + assert.NoError(t, err) + expectedHistory := []byte(measurementID1 + "\n") + assert.Equal(t, expectedHistory, b) +} diff --git a/cmd/http.go b/cmd/http.go index f1c11e1..58e9517 100644 --- a/cmd/http.go +++ b/cmd/http.go @@ -12,77 +12,13 @@ import ( "github.com/spf13/cobra" ) -type UrlData struct { - Protocol string - Path string - Query string - Host string - Port int -} - -// parse url data from user text input -func parseUrlData(input string) (*UrlData, error) { - var urlData UrlData - - // add url scheme if missing - if !strings.HasPrefix(input, "http://") && !strings.HasPrefix(input, "https://") { - input = "http://" + input - } - - // Parse input - u, err := url.Parse(input) - if err != nil { - return nil, errors.Wrapf(err, "failed to parse url input") - } - - urlData.Protocol = u.Scheme - urlData.Path = u.Path - urlData.Query = u.RawQuery - - h, p, err := net.SplitHostPort(u.Host) - if err != nil { - if strings.Contains(err.Error(), "missing port in address") { - // u.Host is not in the format "host:port" - h = u.Host - } else { - return nil, errors.Wrapf(err, "failed to parse url host/port") - } - } - - urlData.Host = h - - if p != "" { - // parse port if present - urlData.Port, err = strconv.Atoi(p) - if err != nil { - return nil, errors.Wrapf(err, "failed to parse url port number: %s", p) - } - } - - return &urlData, nil -} - -// Helper functions to override flags in command -func overrideOpt(orig, new string) string { - if new != "" { - return new - } - return orig -} - -func overrideOptInt(orig, new int) int { - if new != 0 { - return new - } - return orig -} - -// httpCmd represents the http command -var httpCmd = &cobra.Command{ - Use: "http [target] from [location | measurement ID | @1 | first | @-1 | last | previous]", - GroupID: "Measurements", - Short: "Perform a HEAD or GET request to a host", - Long: `The http command sends an HTTP request to a host and can perform HEAD or GET operations. GET is limited to 10KB responses, everything above will be cut by the API. Detailed performance stats as available for every request. +func (r *Root) initHTTP() { + httpCmd := &cobra.Command{ + RunE: r.RunHTTP, + Use: "http [target] from [location | measurement ID | @1 | first | @-1 | last | previous]", + GroupID: "Measurements", + Short: "Perform a HEAD or GET request to a host", + Long: `The http command sends an HTTP request to a host and can perform HEAD or GET operations. GET is limited to 10KB responses, everything above will be cut by the API. Detailed performance stats as available for every request. The tool supports 2 formats for this command: When the full url is supplied, the tool autoparses the scheme, host, port, domain, path and query. For example: http https://www.jsdelivr.com:443/package/npm/test?nav=stats @@ -128,30 +64,42 @@ Examples: # HTTP GET request google.com from a probe in ASN 123 with a dns resolver 1.1.1.1 and json output http google.com from 123 --resolver 1.1.1.1 --json`, - RunE: httpCmdRun, + } + + // http specific flags + flags := httpCmd.Flags() + flags.StringVar(&r.ctx.Protocol, "protocol", "", "Specifies the query protocol (HTTP, HTTPS, HTTP2) (default \"HTTP\")") + flags.IntVar(&r.ctx.Port, "port", 0, "Specifies the port to use (default 80 for HTTP, 443 for HTTPS and HTTP2)") + flags.StringVar(&r.ctx.Resolver, "resolver", "", "Specifies the resolver server used for DNS lookup (default is defined by the probe's network)") + flags.StringVar(&r.ctx.Host, "host", "", "Specifies the Host header, which is going to be added to the request (default host defined in target)") + flags.StringVar(&r.ctx.Path, "path", "", "A URL pathname (default \"/\")") + flags.StringVar(&r.ctx.Query, "query", "", "A query-string") + flags.StringVar(&r.ctx.Method, "method", "", "Specifies the HTTP method to use (HEAD or GET) (default \"HEAD\")") + flags.StringArrayVarP(&r.ctx.Headers, "header", "H", nil, "Specifies a HTTP header to be added to the request, in the format \"Key: Value\". Multiple headers can be added by adding multiple flags") + flags.BoolVar(&r.ctx.Full, "full", false, "Full output. Uses an HTTP GET request, and outputs the status, headers and body to the output") + + r.Cmd.AddCommand(httpCmd) } -// httpCmdRun is the cobra run function for the http command -func httpCmdRun(cmd *cobra.Command, args []string) error { - // Create context - err := createContext(cmd.CalledAs(), args) +func (r *Root) RunHTTP(cmd *cobra.Command, args []string) error { + err := r.updateContext(cmd.CalledAs(), args) if err != nil { return err } - // build http measurement - opts, err := buildHttpMeasurementRequest() + opts, err := r.buildHttpMeasurementRequest() if err != nil { return err } + isPreviousMeasurementId := false - opts.Locations, isPreviousMeasurementId, err = createLocations(ctx.From) + opts.Locations, isPreviousMeasurementId, err = createLocations(r.ctx.From) if err != nil { cmd.SilenceUsage = true return err } - res, showHelp, err := gp.CreateMeasurement(opts) + res, showHelp, err := r.client.CreateMeasurement(opts) if err != nil { if !showHelp { cmd.SilenceUsage = true @@ -161,50 +109,50 @@ func httpCmdRun(cmd *cobra.Command, args []string) error { // Save measurement ID to history if !isPreviousMeasurementId { - err := saveMeasurementID(res.ID) + err := saveIdToHistory(res.ID) if err != nil { - fmt.Printf("Warning: %s\n", err) + r.printer.Printf("Warning: %s\n", err) } } - viewer.Output(res.ID, opts) + r.viewer.Output(res.ID, opts) return nil } const PostMeasurementTypeHttp = "http" // buildHttpMeasurementRequest builds the measurement request for the http type -func buildHttpMeasurementRequest() (*globalping.MeasurementCreate, error) { +func (r *Root) buildHttpMeasurementRequest() (*globalping.MeasurementCreate, error) { opts := &globalping.MeasurementCreate{ Type: PostMeasurementTypeHttp, - Limit: ctx.Limit, - InProgressUpdates: inProgressUpdates(ctx.CI), + Limit: r.ctx.Limit, + InProgressUpdates: !r.ctx.CIMode, } - urlData, err := parseUrlData(ctx.Target) + urlData, err := parseUrlData(r.ctx.Target) if err != nil { return nil, err } - headers, err := parseHttpHeaders(httpCmdOpts.Headers) + headers, err := parseHttpHeaders(r.ctx.Headers) if err != nil { return nil, err } - method := strings.ToUpper(httpCmdOpts.Method) - if ctx.Full { + method := strings.ToUpper(r.ctx.Method) + if r.ctx.Full { // override method to GET method = "GET" } opts.Target = urlData.Host opts.Options = &globalping.MeasurementOptions{ - Protocol: overrideOpt(urlData.Protocol, httpCmdOpts.Protocol), - Port: overrideOptInt(urlData.Port, httpCmdOpts.Port), + Protocol: overrideOpt(urlData.Protocol, r.ctx.Protocol), + Port: overrideOptInt(urlData.Port, r.ctx.Port), Request: &globalping.RequestOptions{ - Path: overrideOpt(urlData.Path, httpCmdOpts.Path), - Query: overrideOpt(urlData.Query, httpCmdOpts.Query), - Host: overrideOpt(urlData.Host, httpCmdOpts.Host), + Path: overrideOpt(urlData.Path, r.ctx.Path), + Query: overrideOpt(urlData.Query, r.ctx.Query), + Host: overrideOpt(urlData.Host, r.ctx.Host), Headers: headers, Method: method, }, - Resolver: overrideOpt(ctx.Resolver, httpCmdOpts.Resolver), + Resolver: r.ctx.Resolver, } return opts, nil } @@ -224,31 +172,67 @@ func parseHttpHeaders(headerStrings []string) (map[string]string, error) { return h, nil } -// HttpCmdOpts represents the parsed http command line opts -type HttpCmdOpts struct { +type UrlData struct { + Protocol string Path string Query string Host string - Method string - Protocol string Port int - Resolver string - Headers []string } -func init() { - rootCmd.AddCommand(httpCmd) +// parse url data from user text input +func parseUrlData(input string) (*UrlData, error) { + var urlData UrlData + + // add url scheme if missing + if !strings.HasPrefix(input, "http://") && !strings.HasPrefix(input, "https://") { + input = "http://" + input + } + + // Parse input + u, err := url.Parse(input) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse url input") + } - httpCmdOpts = &HttpCmdOpts{} + urlData.Protocol = u.Scheme + urlData.Path = u.Path + urlData.Query = u.RawQuery - // http specific flags - httpCmd.Flags().StringVar(&httpCmdOpts.Path, "path", "", "A URL pathname (default \"/\")") - httpCmd.Flags().StringVar(&httpCmdOpts.Query, "query", "", "A query-string") - httpCmd.Flags().StringVar(&httpCmdOpts.Host, "host", "", "Specifies the Host header, which is going to be added to the request (default host defined in target)") - httpCmd.Flags().StringVar(&httpCmdOpts.Method, "method", "", "Specifies the HTTP method to use (HEAD or GET) (default \"HEAD\")") - httpCmd.Flags().StringVar(&httpCmdOpts.Protocol, "protocol", "", "Specifies the query protocol (HTTP, HTTPS, HTTP2) (default \"HTTP\")") - httpCmd.Flags().IntVar(&httpCmdOpts.Port, "port", 0, "Specifies the port to use (default 80 for HTTP, 443 for HTTPS and HTTP2)") - httpCmd.Flags().StringVar(&httpCmdOpts.Resolver, "resolver", "", "Specifies the resolver server used for DNS lookup (default is defined by the probe's network)") - httpCmd.Flags().StringArrayVarP(&httpCmdOpts.Headers, "header", "H", nil, "Specifies a HTTP header to be added to the request, in the format \"Key: Value\". Multiple headers can be added by adding multiple flags") - httpCmd.Flags().BoolVar(&ctx.Full, "full", false, "Full output. Uses an HTTP GET request, and outputs the status, headers and body to the output") + h, p, err := net.SplitHostPort(u.Host) + if err != nil { + if strings.Contains(err.Error(), "missing port in address") { + // u.Host is not in the format "host:port" + h = u.Host + } else { + return nil, errors.Wrapf(err, "failed to parse url host/port") + } + } + + urlData.Host = h + + if p != "" { + // parse port if present + urlData.Port, err = strconv.Atoi(p) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse url port number: %s", p) + } + } + + return &urlData, nil +} + +// Helper functions to override flags in command +func overrideOpt(orig, new string) string { + if new != "" { + return new + } + return orig +} + +func overrideOptInt(orig, new int) int { + if new != 0 { + return new + } + return orig } diff --git a/cmd/http_test.go b/cmd/http_test.go index 0904862..3741786 100644 --- a/cmd/http_test.go +++ b/cmd/http_test.go @@ -1,13 +1,107 @@ package cmd import ( + "context" + "io" + "os" "testing" "github.com/jsdelivr/globalping-cli/globalping" + "github.com/jsdelivr/globalping-cli/mocks" "github.com/jsdelivr/globalping-cli/view" "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" ) +func Test_Execute_HTTP_Default(t *testing.T) { + t.Cleanup(sessionCleanup) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + expectedOpts := &globalping.MeasurementCreate{ + Type: "http", + Target: "jsdelivr.com", + Limit: 1, + Options: &globalping.MeasurementOptions{ + Protocol: "HTTPS", + Port: 99, + Resolver: "1.1.1.1", + Request: &globalping.RequestOptions{ + Host: "example.com", + Path: "/robots.txt", + Query: "test=1", + Method: "GET", + Headers: map[string]string{"X-Test": "1"}, + }, + }, + Locations: []globalping.Locations{ + {Magic: "Berlin"}, + }, + } + expectedResponse := getMeasurementCreateResponse(measurementID1) + + gbMock := mocks.NewMockClient(ctrl) + gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, false, nil) + + viewerMock := mocks.NewMockViewer(ctrl) + viewerMock.EXPECT().Output(measurementID1, expectedOpts).Times(1).Return(nil) + + ctx := &view.Context{ + MaxHistory: 1, + } + r, w, err := os.Pipe() + assert.NoError(t, err) + defer r.Close() + defer w.Close() + + printer := view.NewPrinter(nil, w, w) + root := NewRoot(printer, ctx, viewerMock, nil, gbMock, nil) + os.Args = []string{"globalping", "http", "jsdelivr.com", + "from", "Berlin", + "--protocol", "HTTPS", + "--method", "GET", + "--host", "example.com", + "--path", "/robots.txt", + "--query", "test=1", + "--header", "X-Test: 1", + "--resolver", "1.1.1.1", + "--port", "99", + "--full", + } + err = root.Cmd.ExecuteContext(context.TODO()) + assert.NoError(t, err) + w.Close() + + output, err := io.ReadAll(r) + assert.NoError(t, err) + assert.Equal(t, "", string(output)) + + expectedCtx := &view.Context{ + Cmd: "http", + Target: "jsdelivr.com", + From: "Berlin", + Limit: 1, + Host: "example.com", + Resolver: "1.1.1.1", + Protocol: "HTTPS", + Method: "GET", + Query: "test=1", + Path: "/robots.txt", + Headers: []string{"X-Test: 1"}, + Full: true, + Port: 99, + CIMode: true, + MaxHistory: 1, + } + assert.Equal(t, expectedCtx, ctx) + + b, err := os.ReadFile(getMeasurementsPath()) + assert.NoError(t, err) + expectedHistory := []byte(measurementID1 + "\n") + assert.Equal(t, expectedHistory, b) +} + func TestParseUrlData(t *testing.T) { urlData, err := parseUrlData("https://cdn.jsdelivr.net:8080/npm/react/?query=3") assert.NoError(t, err) @@ -79,22 +173,21 @@ func TestParseHttpHeaders_Invalid(t *testing.T) { assert.ErrorContains(t, err, "invalid header") } -func TestBuildHttpMeasurementRequest_FULL(t *testing.T) { - ctx = &view.Context{ - Target: "https://example.com/my/path?x=123&yz=abc", - From: "london", - Full: true, - } +func Test_BuildHttpMeasurementRequest_FULL(t *testing.T) { + ctx := &view.Context{} + printer := view.NewPrinter(nil, nil, nil) + root := NewRoot(printer, ctx, nil, nil, nil, nil) - httpCmdOpts = &HttpCmdOpts{ - Method: "HEAD", - } + ctx.Target = "https://example.com/my/path?x=123&yz=abc" + ctx.From = "london" + ctx.Full = true + ctx.Method = "HEAD" - m, err := buildHttpMeasurementRequest() + m, err := root.buildHttpMeasurementRequest() assert.NoError(t, err) expectedM := &globalping.MeasurementCreate{ - Limit: 0, + Limit: 1, Type: "http", Target: "example.com", InProgressUpdates: true, @@ -111,27 +204,21 @@ func TestBuildHttpMeasurementRequest_FULL(t *testing.T) { } assert.Equal(t, expectedM, m) - - // restore - httpCmdOpts = &HttpCmdOpts{} - ctx = &view.Context{} } func TestBuildHttpMeasurementRequest_HEAD(t *testing.T) { - ctx = &view.Context{ - Target: "https://example.com/my/path?x=123&yz=abc", - From: "london", - } + ctx := &view.Context{} + printer := view.NewPrinter(nil, nil, nil) + root := NewRoot(printer, ctx, nil, nil, nil, nil) - httpCmdOpts = &HttpCmdOpts{ - Method: "HEAD", - } + ctx.Target = "https://example.com/my/path?x=123&yz=abc" + ctx.From = "london" - m, err := buildHttpMeasurementRequest() + m, err := root.buildHttpMeasurementRequest() assert.NoError(t, err) expectedM := &globalping.MeasurementCreate{ - Limit: 0, + Limit: 1, Type: "http", Target: "example.com", InProgressUpdates: true, @@ -142,14 +229,9 @@ func TestBuildHttpMeasurementRequest_HEAD(t *testing.T) { Path: "/my/path", Host: "example.com", Query: "x=123&yz=abc", - Method: "HEAD", }, }, } assert.Equal(t, expectedM, m) - - // restore - httpCmdOpts = &HttpCmdOpts{} - ctx = &view.Context{} } diff --git a/cmd/install_probe.go b/cmd/install_probe.go index 943097c..f0b0266 100644 --- a/cmd/install_probe.go +++ b/cmd/install_probe.go @@ -2,56 +2,54 @@ package cmd import ( "bufio" - "fmt" - "os" - "github.com/jsdelivr/globalping-cli/lib/probe" + "github.com/jsdelivr/globalping-cli/globalping/probe" "github.com/spf13/cobra" ) -func init() { - rootCmd.AddCommand(installProbeCmd) -} +func (r *Root) initInstallProbe() { + installProbeCmd := &cobra.Command{ + Use: "install-probe", + Short: "Join the community powered Globalping platform by running a Docker container.", + Long: `Pull and run the Globalping probe Docker container on this machine. It requires Docker to be installed.`, + Run: r.RunInstallProbe, + } -var installProbeCmd = &cobra.Command{ - Use: "install-probe", - Short: "Join the community powered Globalping platform by running a Docker container.", - Long: `Pull and run the Globalping probe Docker container on this machine. It requires Docker to be installed.`, - Run: installProbeCmdRun, + r.Cmd.AddCommand(installProbeCmd) } -func installProbeCmdRun(cmd *cobra.Command, args []string) { - containerEngine, err := probe.DetectContainerEngine() +func (r *Root) RunInstallProbe(cmd *cobra.Command, args []string) { + containerEngine, err := r.probe.DetectContainerEngine() if err != nil { - fmt.Printf("docker info command failed: %v\n\n", err) - fmt.Println("Docker was not detected on your system and it is required to run the Globalping probe. Please install Docker and try again.") + r.printer.Printf("docker info command failed: %v\n\n", err) + r.printer.Println("Docker was not detected on your system and it is required to run the Globalping probe. Please install Docker and try again.") return } - fmt.Printf("Detected container engine: %s\n\n", containerEngine) + r.printer.Printf("Detected container engine: %s\n\n", containerEngine) - err = probe.InspectContainer(containerEngine) + err = r.probe.InspectContainer(containerEngine) if err != nil { - fmt.Println(err) + r.printer.Println(err) return } - ok := askUser(containerPullMessage(containerEngine)) + ok := r.askUser(containerPullMessage(containerEngine)) if !ok { - fmt.Println("You can also run a probe manually, check our GitHub for detailed instructions. Exited without changes.") + r.printer.Println("You can also run a probe manually, check our GitHub for detailed instructions. Exited without changes.") return } - err = probe.RunContainer(containerEngine) + err = r.probe.RunContainer(containerEngine) if err != nil { - fmt.Println(err) + r.printer.Println(err) return } - fmt.Printf("The Globalping probe started successfully. Thank you for joining our community! \n") + r.printer.Printf("The Globalping probe started successfully. Thank you for joining our community! \n") if containerEngine == probe.ContainerEnginePodman { - fmt.Printf("When you are using Podman, you also need to install a service to make sure the container starts on boot. Please see our instructions here: https://github.com/jsdelivr/globalping-probe/blob/master/README.md#podman-alternative\n") + r.printer.Printf("When you are using Podman, you also need to install a service to make sure the container starts on boot. Please see our instructions here: https://github.com/jsdelivr/globalping-probe/blob/master/README.md#podman-alternative\n") } } @@ -67,14 +65,14 @@ func containerPullMessage(containerEngine probe.ContainerEngine) string { return pre + mid + post } -func askUser(s string) bool { - fmt.Printf("%s [Y/n] ", s) +func (r *Root) askUser(s string) bool { + r.printer.Printf("%s [Y/n] ", s) - r := bufio.NewReader(os.Stdin) + reader := bufio.NewReader(r.printer.InReader) - c, _, err := r.ReadRune() + c, _, err := reader.ReadRune() if err != nil { - fmt.Printf("failed to read character %v", err) + r.printer.Printf("failed to read character %v", err) return false } diff --git a/cmd/install_probe_test.go b/cmd/install_probe_test.go new file mode 100644 index 0000000..b8ce820 --- /dev/null +++ b/cmd/install_probe_test.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "bytes" + "context" + "io" + "os" + "testing" + + "github.com/jsdelivr/globalping-cli/globalping/probe" + "github.com/jsdelivr/globalping-cli/mocks" + "github.com/jsdelivr/globalping-cli/view" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func Test_Execute_Install_Probe_Docker(t *testing.T) { + t.Cleanup(sessionCleanup) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + probeMock := mocks.NewMockProbe(ctrl) + probeMock.EXPECT().DetectContainerEngine().Times(1).Return(probe.ContainerEngineDocker, nil) + probeMock.EXPECT().InspectContainer(probe.ContainerEngineDocker).Times(1).Return(nil) + probeMock.EXPECT().RunContainer(probe.ContainerEngineDocker).Times(1).Return(nil) + + ctx := &view.Context{} + r, w, err := os.Pipe() + assert.NoError(t, err) + defer r.Close() + defer w.Close() + + reader := bytes.NewReader([]byte("Y\n")) + + printer := view.NewPrinter(reader, w, w) + root := NewRoot(printer, ctx, nil, nil, nil, probeMock) + os.Args = []string{"globalping", "install-probe"} + err = root.Cmd.ExecuteContext(context.TODO()) + assert.NoError(t, err) + w.Close() + + output, err := io.ReadAll(r) + assert.NoError(t, err) + assert.Equal(t, `Detected container engine: Docker + +The Globalping platform is a community powered project and relies on individuals like yourself to host our probes and make them accessible to everyone else. +Please confirm to pull and run our Docker container (ghcr.io/jsdelivr/globalping-probe) [Y/n] The Globalping probe started successfully. Thank you for joining our community! +`, string(output)) + + expectedCtx := &view.Context{ + From: "world", + Limit: 1, + } + assert.Equal(t, expectedCtx, ctx) +} diff --git a/cmd/mtr.go b/cmd/mtr.go index 532ccdf..0b52fab 100644 --- a/cmd/mtr.go +++ b/cmd/mtr.go @@ -7,12 +7,13 @@ import ( "github.com/spf13/cobra" ) -// mtrCmd represents the mtr command -var mtrCmd = &cobra.Command{ - Use: "mtr [target] from [location | measurement ID | @1 | first | @-1 | last | previous]", - GroupID: "Measurements", - Short: "Run an MTR test, similar to traceroute", - Long: `mtr combines the functionality of the traceroute and ping programs in a single network diagnostic tool. +func (r *Root) initMTR() { + mtrCmd := &cobra.Command{ + RunE: r.RunMTR, + Use: "mtr [target] from [location | measurement ID | @1 | first | @-1 | last | previous]", + GroupID: "Measurements", + Short: "Run an MTR test, similar to traceroute", + Long: `mtr combines the functionality of the traceroute and ping programs in a single network diagnostic tool. Examples: # MTR google.com from 2 probes in New York @@ -38,62 +39,61 @@ Examples: # MTR jsdelivr.com from a probe in ASN 123 with json output mtr jsdelivr.com from 123 --json`, - RunE: func(cmd *cobra.Command, args []string) error { - // Create context - err := createContext(cmd.CalledAs(), args) - if err != nil { - return err - } + } - if ctx.ToLatency { - return fmt.Errorf("the latency flag is not supported by the mtr command") - } + // mtr specific flags + flags := mtrCmd.Flags() + flags.StringVar(&r.ctx.Protocol, "protocol", "", "Specifies the protocol used (ICMP, TCP or UDP) (default \"icmp\")") + flags.IntVar(&r.ctx.Port, "port", 0, "Specifies the port to use. Only applicable for TCP protocol (default 53)") + flags.IntVar(&r.ctx.Packets, "packets", 0, "Specifies the number of packets to send to each hop (default 3)") - // Make post struct - opts = globalping.MeasurementCreate{ - Type: "mtr", - Target: ctx.Target, - Limit: ctx.Limit, - InProgressUpdates: inProgressUpdates(ctx.CI), - Options: &globalping.MeasurementOptions{ - Protocol: protocol, - Port: port, - Packets: ctx.Packets, - }, - } - isPreviousMeasurementId := false - opts.Locations, isPreviousMeasurementId, err = createLocations(ctx.From) - if err != nil { + r.Cmd.AddCommand(mtrCmd) +} + +func (r *Root) RunMTR(cmd *cobra.Command, args []string) error { + err := r.updateContext(cmd.CalledAs(), args) + if err != nil { + return err + } + + if r.ctx.ToLatency { + return fmt.Errorf("the latency flag is not supported by the mtr command") + } + + opts := &globalping.MeasurementCreate{ + Type: "mtr", + Target: r.ctx.Target, + Limit: r.ctx.Limit, + InProgressUpdates: !r.ctx.CIMode, + Options: &globalping.MeasurementOptions{ + Protocol: r.ctx.Protocol, + Port: r.ctx.Port, + Packets: r.ctx.Packets, + }, + } + isPreviousMeasurementId := false + opts.Locations, isPreviousMeasurementId, err = createLocations(r.ctx.From) + if err != nil { + cmd.SilenceUsage = true + return err + } + + res, showHelp, err := r.client.CreateMeasurement(opts) + if err != nil { + if !showHelp { cmd.SilenceUsage = true - return err } + return err + } - res, showHelp, err := gp.CreateMeasurement(&opts) + // Save measurement ID to history + if !isPreviousMeasurementId { + err := saveIdToHistory(res.ID) if err != nil { - if !showHelp { - cmd.SilenceUsage = true - } - return err - } - - // Save measurement ID to history - if !isPreviousMeasurementId { - err := saveMeasurementID(res.ID) - if err != nil { - fmt.Printf("Warning: %s\n", err) - } + r.printer.Printf("Warning: %s\n", err) } + } - viewer.Output(res.ID, &opts) - return nil - }, -} - -func init() { - rootCmd.AddCommand(mtrCmd) - - // mtr specific flags - mtrCmd.Flags().StringVar(&protocol, "protocol", "", "Specifies the protocol used (ICMP, TCP or UDP) (default \"icmp\")") - mtrCmd.Flags().IntVar(&port, "port", 0, "Specifies the port to use. Only applicable for TCP protocol (default 53)") - mtrCmd.Flags().IntVar(&ctx.Packets, "packets", 0, "Specifies the number of packets to send to each hop (default 3)") + r.viewer.Output(res.ID, opts) + return nil } diff --git a/cmd/mtr_test.go b/cmd/mtr_test.go new file mode 100644 index 0000000..f0c20da --- /dev/null +++ b/cmd/mtr_test.go @@ -0,0 +1,85 @@ +package cmd + +import ( + "context" + "io" + "os" + "testing" + + "github.com/jsdelivr/globalping-cli/globalping" + "github.com/jsdelivr/globalping-cli/mocks" + "github.com/jsdelivr/globalping-cli/view" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func Test_Execute_MTR_Default(t *testing.T) { + t.Cleanup(sessionCleanup) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + expectedOpts := &globalping.MeasurementCreate{ + Type: "mtr", + Target: "jsdelivr.com", + Limit: 2, + Options: &globalping.MeasurementOptions{ + Protocol: "tcp", + Port: 99, + Packets: 16, + }, + Locations: []globalping.Locations{ + {Magic: "Berlin"}, + }, + } + expectedResponse := getMeasurementCreateResponse(measurementID1) + + gbMock := mocks.NewMockClient(ctrl) + gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, false, nil) + + viewerMock := mocks.NewMockViewer(ctrl) + viewerMock.EXPECT().Output(measurementID1, expectedOpts).Times(1).Return(nil) + + ctx := &view.Context{ + MaxHistory: 1, + } + r, w, err := os.Pipe() + assert.NoError(t, err) + defer r.Close() + defer w.Close() + + printer := view.NewPrinter(nil, w, w) + root := NewRoot(printer, ctx, viewerMock, nil, gbMock, nil) + os.Args = []string{"globalping", "mtr", "jsdelivr.com", + "from", "Berlin", + "--limit", "2", + "--protocol", "tcp", + "--port", "99", + "--packets", "16", + } + err = root.Cmd.ExecuteContext(context.TODO()) + assert.NoError(t, err) + w.Close() + + output, err := io.ReadAll(r) + assert.NoError(t, err) + assert.Equal(t, "", string(output)) + + expectedCtx := &view.Context{ + Cmd: "mtr", + Target: "jsdelivr.com", + From: "Berlin", + Limit: 2, + Protocol: "tcp", + Port: 99, + Packets: 16, + CIMode: true, + MaxHistory: 1, + } + assert.Equal(t, expectedCtx, ctx) + + b, err := os.ReadFile(getMeasurementsPath()) + assert.NoError(t, err) + expectedHistory := []byte(measurementID1 + "\n") + assert.Equal(t, expectedHistory, b) +} diff --git a/cmd/ping.go b/cmd/ping.go index 7859f3a..b95437a 100644 --- a/cmd/ping.go +++ b/cmd/ping.go @@ -12,6 +12,7 @@ import ( func (r *Root) initPing() { pingCmd := &cobra.Command{ + RunE: r.RunPing, Use: "ping [target] from [location | measurement ID | @1 | first | @-1 | last | previous]", GroupID: "Measurements", Short: "Run a ping test", @@ -44,7 +45,6 @@ Examples: # Continuously ping google.com from New York ping google.com from New York --infinite`, - RunE: r.RunPing, } // ping specific flags @@ -72,7 +72,7 @@ func (r *Root) ping() (string, error) { Type: "ping", Target: r.ctx.Target, Limit: r.ctx.Limit, - InProgressUpdates: inProgressUpdates(r.ctx.CI), + InProgressUpdates: !r.ctx.CIMode, Options: &globalping.MeasurementOptions{ Packets: r.ctx.Packets, }, @@ -89,7 +89,7 @@ func (r *Root) ping() (string, error) { opts.Locations = []globalping.Locations{{Magic: r.ctx.From}} } - res, showHelp, err := r.gp.CreateMeasurement(opts) + res, showHelp, err := r.client.CreateMeasurement(opts) if err != nil { if !showHelp { r.Cmd.SilenceUsage = true @@ -102,7 +102,7 @@ func (r *Root) ping() (string, error) { // Save measurement ID to history if !isPreviousMeasurementId { - err := saveMeasurementID(res.ID) + err := saveIdToHistory(res.ID) if err != nil { r.printer.Printf("Warning: %s\n", err) } diff --git a/cmd/ping_test.go b/cmd/ping_test.go index 1b48231..8bce18e 100644 --- a/cmd/ping_test.go +++ b/cmd/ping_test.go @@ -14,7 +14,6 @@ import ( "github.com/jsdelivr/globalping-cli/globalping" "github.com/jsdelivr/globalping-cli/mocks" "github.com/jsdelivr/globalping-cli/view" - "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" ) @@ -25,7 +24,7 @@ func Test_Execute_Ping_Default(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - expectedOpts := getMeasurementCreate() + expectedOpts := getPingMeasurementCreate() expectedResponse := getMeasurementCreateResponse(measurementID1) gbMock := mocks.NewMockClient(ctrl) @@ -37,7 +36,7 @@ func Test_Execute_Ping_Default(t *testing.T) { timeMock := mocks.NewMockTime(ctrl) timeMock.EXPECT().Now().Return(defaultCurrentTime) - ctx = &view.Context{ + ctx := &view.Context{ MaxHistory: 1, } r, w, err := os.Pipe() @@ -45,8 +44,8 @@ func Test_Execute_Ping_Default(t *testing.T) { defer r.Close() defer w.Close() - printer := view.NewPrinter(w) - root := NewRoot(w, w, printer, ctx, viewerMock, timeMock, gbMock, &cobra.Command{}) + printer := view.NewPrinter(nil, w, w) + root := NewRoot(printer, ctx, viewerMock, timeMock, gbMock, nil) os.Args = []string{"globalping", "ping", "jsdelivr.com"} err = root.Cmd.ExecuteContext(context.TODO()) assert.NoError(t, err) @@ -56,7 +55,7 @@ func Test_Execute_Ping_Default(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "", string(output)) - expectedCtx := getExpectedViewContext() + expectedCtx := getExpectedPingViewContext() assert.Equal(t, expectedCtx, ctx) b, err := os.ReadFile(getMeasurementsPath()) @@ -74,12 +73,12 @@ func Test_Execute_Ping_Infinite(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - expectedOpts1 := getMeasurementCreate() + expectedOpts1 := getPingMeasurementCreate() expectedOpts1.Options.Packets = 16 - expectedOpts2 := getMeasurementCreate() + expectedOpts2 := getPingMeasurementCreate() expectedOpts2.Options.Packets = 16 expectedOpts2.Locations[0].Magic = measurementID1 - expectedOpts3 := getMeasurementCreate() + expectedOpts3 := getPingMeasurementCreate() expectedOpts3.Options.Packets = 16 expectedOpts3.Locations[0].Magic = measurementID2 @@ -110,14 +109,14 @@ func Test_Execute_Ping_Infinite(t *testing.T) { timeMock := mocks.NewMockTime(ctrl) timeMock.EXPECT().Now().Return(defaultCurrentTime).Times(3) - ctx = &view.Context{} + ctx := &view.Context{} r, w, err := os.Pipe() assert.NoError(t, err) defer r.Close() defer w.Close() - printer := view.NewPrinter(w) - root := NewRoot(w, w, printer, ctx, viewerMock, timeMock, gbMock, &cobra.Command{}) + printer := view.NewPrinter(nil, w, w) + root := NewRoot(printer, ctx, viewerMock, timeMock, gbMock, nil) os.Args = []string{"globalping", "ping", "jsdelivr.com", "--infinite"} sig := make(chan os.Signal, 1) @@ -141,7 +140,7 @@ func Test_Execute_Ping_Infinite(t *testing.T) { Cmd: "ping", Target: "jsdelivr.com", Limit: 1, - CI: true, + CIMode: true, Infinite: true, CallCount: 3, From: measurementID2, @@ -162,7 +161,7 @@ func Test_Execute_Ping_Infinite_Output_Error(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - expectedOpts1 := getMeasurementCreate() + expectedOpts1 := getPingMeasurementCreate() expectedOpts1.Options.Packets = 16 expectedResponse1 := getMeasurementCreateResponse(measurementID1) @@ -177,14 +176,14 @@ func Test_Execute_Ping_Infinite_Output_Error(t *testing.T) { timeMock := mocks.NewMockTime(ctrl) timeMock.EXPECT().Now().Return(defaultCurrentTime) - ctx = &view.Context{} + ctx := &view.Context{} r, w, err := os.Pipe() assert.NoError(t, err) defer r.Close() defer w.Close() - printer := view.NewPrinter(w) - root := NewRoot(w, w, printer, ctx, viewerMock, timeMock, gbMock, &cobra.Command{}) + printer := view.NewPrinter(nil, w, w) + root := NewRoot(printer, ctx, viewerMock, timeMock, gbMock, nil) os.Args = []string{"globalping", "ping", "jsdelivr.com", "--infinite"} err = root.Cmd.ExecuteContext(context.TODO()) assert.Equal(t, "error message", err.Error()) @@ -198,7 +197,7 @@ func Test_Execute_Ping_Infinite_Output_Error(t *testing.T) { Cmd: "ping", Target: "jsdelivr.com", Limit: 1, - CI: true, + CIMode: true, Infinite: true, CallCount: 1, From: measurementID1, @@ -213,12 +212,12 @@ func Test_Execute_Ping_Infinite_Output_Error(t *testing.T) { assert.Equal(t, expectedHistory, b) } -func getExpectedViewContext() *view.Context { +func getExpectedPingViewContext() *view.Context { return &view.Context{ Cmd: "ping", Target: "jsdelivr.com", Limit: 1, - CI: true, + CIMode: true, CallCount: 1, From: "world", MaxHistory: 1, @@ -226,7 +225,7 @@ func getExpectedViewContext() *view.Context { } } -func getMeasurementCreate() *globalping.MeasurementCreate { +func getPingMeasurementCreate() *globalping.MeasurementCreate { return &globalping.MeasurementCreate{ Type: "ping", Target: "jsdelivr.com", @@ -237,10 +236,3 @@ func getMeasurementCreate() *globalping.MeasurementCreate { }, } } - -func getMeasurementCreateResponse(id string) *globalping.MeasurementCreateResponse { - return &globalping.MeasurementCreateResponse{ - ID: id, - ProbesCount: 1, - } -} diff --git a/cmd/root.go b/cmd/root.go index 88cec44..112ab4f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,87 +1,72 @@ package cmd import ( - "io" "os" "github.com/jsdelivr/globalping-cli/globalping" - "github.com/jsdelivr/globalping-cli/lib" + "github.com/jsdelivr/globalping-cli/globalping/probe" "github.com/jsdelivr/globalping-cli/utils" "github.com/jsdelivr/globalping-cli/view" - "github.com/pkg/errors" "github.com/spf13/cobra" ) -// TODO: Remove global variables - -var ( - // Global flags - // cfgFile string - - // Additional flags - protocol string - port int - resolver string - trace bool - queryType string - - httpCmdOpts *HttpCmdOpts - - opts = globalping.MeasurementCreate{} - ctx = &view.Context{ - APIMinInterval: globalping.API_MIN_INTERVAL, - MaxHistory: 10, - } - utime = utils.NewTime() - gp = globalping.NewClient(globalping.API_URL) - outW = os.Stdout - errW = os.Stderr - printer = view.NewPrinter(outW) - viewer = view.NewViewer(ctx, printer, utime, gp) -) - -// rootCmd represents the base command when called without any subcommands -var rootCmd = &cobra.Command{ - Use: "globalping", - Short: "A global network of probes to run network tests like ping, traceroute and DNS resolve.", - Long: `Globalping is a platform that allows anyone to run networking commands such as ping, traceroute, dig and mtr on probes distributed all around the world. -The CLI tool allows you to interact with the API in a simple and human-friendly way to debug networking issues like anycast routing and script automated tests and benchmarks.`, -} - -var root = NewRoot(outW, errW, printer, ctx, viewer, utime, gp, rootCmd) - type Root struct { - outW io.Writer printer *view.Printer ctx *view.Context viewer view.Viewer - gp globalping.Client + client globalping.Client + probe probe.Probe time utils.Time Cmd *cobra.Command } +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + utime := utils.NewTime() + printer := view.NewPrinter(os.Stdin, os.Stdout, os.Stderr) + ctx := &view.Context{ + APIMinInterval: globalping.API_MIN_INTERVAL, + MaxHistory: 10, + } + globalpingClient := globalping.NewClient(globalping.API_URL) + globalpingProbe := probe.NewProbe() + viewer := view.NewViewer(ctx, printer, utime, globalpingClient) + root := NewRoot(printer, ctx, viewer, utime, globalpingClient, globalpingProbe) + + err := root.Cmd.Execute() + if err != nil { + os.Exit(1) + } +} + func NewRoot( - outW io.Writer, - errW io.Writer, printer *view.Printer, ctx *view.Context, viewer view.Viewer, time utils.Time, - gp globalping.Client, - cmd *cobra.Command, + globalpingClient globalping.Client, + globalpingProbe probe.Probe, ) *Root { root := &Root{ - Cmd: cmd, - outW: outW, printer: printer, ctx: ctx, viewer: viewer, time: time, - gp: gp, + client: globalpingClient, + probe: globalpingProbe, } - root.Cmd.SetOut(outW) - root.Cmd.SetErr(errW) + // rootCmd represents the base command when called without any subcommands + root.Cmd = &cobra.Command{ + Use: "globalping", + Short: "A global network of probes to run network tests like ping, traceroute and DNS resolve.", + Long: `Globalping is a platform that allows anyone to run networking commands such as ping, traceroute, dig and mtr on probes distributed all around the world. +The CLI tool allows you to interact with the API in a simple and human-friendly way to debug networking issues like anycast routing and script automated tests and benchmarks.`, + } + + root.Cmd.SetOut(printer.OutWriter) + root.Cmd.SetErr(printer.ErrWriter) // Global flags flags := root.Cmd.PersistentFlags() @@ -90,105 +75,19 @@ func NewRoot( Or use [@1 | first, @2 ... @-2, @-1 | last | previous] to run with the probes from previous measurements.`) flags.IntVarP(&ctx.Limit, "limit", "L", 1, "Limit the number of probes to use") flags.BoolVarP(&ctx.ToJSON, "json", "J", false, "Output results in JSON format (default false)") - flags.BoolVarP(&ctx.CI, "ci", "C", false, "Disable realtime terminal updates and color suitable for CI and scripting (default false)") + flags.BoolVarP(&ctx.CIMode, "ci", "C", false, "Disable realtime terminal updates and color suitable for CI and scripting (default false)") flags.BoolVar(&ctx.ToLatency, "latency", false, "Output only the stats of a measurement (default false). Only applies to the dns, http and ping commands") flags.BoolVar(&ctx.Share, "share", false, "Prints a link at the end the results, allowing to vizualize the results online (default false)") root.Cmd.AddGroup(&cobra.Group{ID: "Measurements", Title: "Measurement Commands:"}) + root.initDNS() + root.initHTTP() + root.initMTR() root.initPing() + root.initTraceroute() + root.initInstallProbe() + root.initVersion() return root } - -// Execute adds all child commands to the root command and sets flags appropriately. -// This is called by main.main(). It only needs to happen once to the rootCmd. -func Execute() { - err := root.Cmd.Execute() - if err != nil { - os.Exit(1) - } -} - -func (c *Root) updateContext(cmd string, args []string) error { - c.ctx.Cmd = cmd // Get the command name - - // parse target query - targetQuery, err := lib.ParseTargetQuery(cmd, args) - if err != nil { - return err - } - - c.ctx.Target = targetQuery.Target - - if targetQuery.From != "" { - c.ctx.From = targetQuery.From - } - - if targetQuery.Resolver != "" { - c.ctx.Resolver = targetQuery.Resolver - } - - // Check env for CI - if os.Getenv("CI") != "" { - c.ctx.CI = true - } - - // Check if it is a terminal or being piped/redirected - // We want to disable realtime updates if that is the case - f, ok := c.outW.(*os.File) - if ok { - stdoutFileInfo, err := f.Stat() - if err != nil { - return errors.Wrapf(err, "stdout stat failed") - } - if (stdoutFileInfo.Mode() & os.ModeCharDevice) == 0 { - // stdout is piped, run in ci mode - c.ctx.CI = true - } - } else { - c.ctx.CI = true - } - - return nil -} - -// Todo: Remove this function -func createContext(cmd string, args []string) error { - ctx.Cmd = cmd // Get the command name - - // parse target query - targetQuery, err := lib.ParseTargetQuery(cmd, args) - if err != nil { - return err - } - - ctx.Target = targetQuery.Target - - if targetQuery.From != "" { - ctx.From = targetQuery.From - } - - if targetQuery.Resolver != "" { - ctx.Resolver = targetQuery.Resolver - } - - // Check env for CI - if os.Getenv("CI") != "" { - ctx.CI = true - } - - // Check if it is a terminal or being piped/redirected - // We want to disable realtime updates if that is the case - stdoutFileInfo, err := outW.Stat() - if err != nil { - return errors.Wrapf(err, "stdout stat failed") - } - - if (stdoutFileInfo.Mode() & os.ModeCharDevice) == 0 { - // stdout is piped, run in ci mode - ctx.CI = true - } - - return nil -} diff --git a/cmd/root_test.go b/cmd/root_test.go deleted file mode 100644 index f19757c..0000000 --- a/cmd/root_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package cmd - -import ( - "testing" - - "github.com/jsdelivr/globalping-cli/view" - "github.com/stretchr/testify/assert" -) - -func TestCreateContext(t *testing.T) { - for scenario, fn := range map[string]func(t *testing.T){ - "no_arg": testContextNoArg, - "country": testContextCountry, - "country_whitespace": testContextCountryWhitespace, - "no_target": testContextNoTarget, - "ci_env": testContextCIEnv, - } { - t.Run(scenario, func(t *testing.T) { - ctx = &view.Context{} - fn(t) - }) - } -} - -func testContextNoArg(t *testing.T) { - err := createContext("test", []string{"1.1.1.1"}) - assert.Equal(t, "test", ctx.Cmd) - assert.Equal(t, "1.1.1.1", ctx.Target) - assert.Equal(t, "", ctx.From) - assert.NoError(t, err) -} - -func testContextCountry(t *testing.T) { - err := createContext("test", []string{"1.1.1.1", "from", "Germany"}) - assert.Equal(t, "test", ctx.Cmd) - assert.Equal(t, "1.1.1.1", ctx.Target) - assert.Equal(t, "Germany", ctx.From) - assert.NoError(t, err) -} - -// Check if country with whitespace is parsed correctly -func testContextCountryWhitespace(t *testing.T) { - err := createContext("test", []string{"1.1.1.1", "from", " Germany, France"}) - assert.Equal(t, "test", ctx.Cmd) - assert.Equal(t, "1.1.1.1", ctx.Target) - assert.Equal(t, "Germany, France", ctx.From) - assert.NoError(t, err) -} - -func testContextNoTarget(t *testing.T) { - err := createContext("test", []string{}) - assert.Error(t, err) -} - -func testContextCIEnv(t *testing.T) { - t.Setenv("CI", "true") - err := createContext("test", []string{"1.1.1.1"}) - assert.Equal(t, "test", ctx.Cmd) - assert.Equal(t, "1.1.1.1", ctx.Target) - assert.Equal(t, "", ctx.From) - assert.True(t, ctx.CI) - assert.NoError(t, err) -} diff --git a/cmd/traceroute.go b/cmd/traceroute.go index 32770c6..e2a5846 100644 --- a/cmd/traceroute.go +++ b/cmd/traceroute.go @@ -7,12 +7,13 @@ import ( "github.com/spf13/cobra" ) -// tracerouteCmd represents the traceroute command -var tracerouteCmd = &cobra.Command{ - Use: "traceroute [target] from [location | measurement ID | @1 | first | @-1 | last | previous]", - GroupID: "Measurements", - Short: "Run a traceroute test", - Long: `traceroute tracks the route packets take from an IP network on their way to a given host. +func (r *Root) initTraceroute() { + var tracerouteCmd = &cobra.Command{ + RunE: r.RunTraceroute, + Use: "traceroute [target] from [location | measurement ID | @1 | first | @-1 | last | previous]", + GroupID: "Measurements", + Short: "Run a traceroute test", + Long: `traceroute tracks the route packets take from an IP network on their way to a given host. Examples: # Traceroute google.com from 2 probes in New York @@ -41,60 +42,59 @@ Examples: # Traceroute jsdelivr.com from a probe in ASN 123 with json output traceroute jsdelivr.com from 123 --json`, - RunE: func(cmd *cobra.Command, args []string) error { - // Create context - err := createContext(cmd.CalledAs(), args) - if err != nil { - return err - } + } - if ctx.ToLatency { - return fmt.Errorf("the latency flag is not supported by the traceroute command") - } + // traceroute specific flags + flags := tracerouteCmd.Flags() + flags.StringVar(&r.ctx.Protocol, "protocol", "", "Specifies the protocol used for tracerouting (ICMP, TCP or UDP) (default \"icmp\")") + flags.IntVar(&r.ctx.Port, "port", 0, "Specifies the port to use for the traceroute. Only applicable for TCP protocol (default 80)") - // Make post struct - opts = globalping.MeasurementCreate{ - Type: "traceroute", - Target: ctx.Target, - Limit: ctx.Limit, - InProgressUpdates: inProgressUpdates(ctx.CI), - Options: &globalping.MeasurementOptions{ - Protocol: protocol, - Port: port, - }, - } - isPreviousMeasurementId := false - opts.Locations, isPreviousMeasurementId, err = createLocations(ctx.From) - if err != nil { + r.Cmd.AddCommand(tracerouteCmd) +} + +func (r *Root) RunTraceroute(cmd *cobra.Command, args []string) error { + err := r.updateContext(cmd.CalledAs(), args) + if err != nil { + return err + } + + if r.ctx.ToLatency { + return fmt.Errorf("the latency flag is not supported by the traceroute command") + } + + opts := &globalping.MeasurementCreate{ + Type: "traceroute", + Target: r.ctx.Target, + Limit: r.ctx.Limit, + InProgressUpdates: !r.ctx.CIMode, + Options: &globalping.MeasurementOptions{ + Protocol: r.ctx.Protocol, + Port: r.ctx.Port, + }, + } + isPreviousMeasurementId := false + opts.Locations, isPreviousMeasurementId, err = createLocations(r.ctx.From) + if err != nil { + cmd.SilenceUsage = true + return err + } + + res, showHelp, err := r.client.CreateMeasurement(opts) + if err != nil { + if !showHelp { cmd.SilenceUsage = true - return err } + return err + } - res, showHelp, err := gp.CreateMeasurement(&opts) + // Save measurement ID to history + if !isPreviousMeasurementId { + err := saveIdToHistory(res.ID) if err != nil { - if !showHelp { - cmd.SilenceUsage = true - } - return err - } - - // Save measurement ID to history - if !isPreviousMeasurementId { - err := saveMeasurementID(res.ID) - if err != nil { - fmt.Printf("Warning: %s\n", err) - } + r.printer.Printf("Warning: %s\n", err) } + } - viewer.Output(res.ID, &opts) - return nil - }, -} - -func init() { - rootCmd.AddCommand(tracerouteCmd) - - // traceroute specific flags - tracerouteCmd.Flags().StringVar(&protocol, "protocol", "", "Specifies the protocol used for tracerouting (ICMP, TCP or UDP) (default \"icmp\")") - tracerouteCmd.Flags().IntVar(&port, "port", 0, "Specifies the port to use for the traceroute. Only applicable for TCP protocol (default 80)") + r.viewer.Output(res.ID, opts) + return nil } diff --git a/cmd/traceroute_test.go b/cmd/traceroute_test.go new file mode 100644 index 0000000..ca52e0f --- /dev/null +++ b/cmd/traceroute_test.go @@ -0,0 +1,82 @@ +package cmd + +import ( + "context" + "io" + "os" + "testing" + + "github.com/jsdelivr/globalping-cli/globalping" + "github.com/jsdelivr/globalping-cli/mocks" + "github.com/jsdelivr/globalping-cli/view" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func Test_Execute_Traceroute_Default(t *testing.T) { + t.Cleanup(sessionCleanup) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + expectedOpts := &globalping.MeasurementCreate{ + Type: "traceroute", + Target: "jsdelivr.com", + Limit: 2, + Options: &globalping.MeasurementOptions{ + Protocol: "tcp", + Port: 99, + }, + Locations: []globalping.Locations{ + {Magic: "Berlin"}, + }, + } + expectedResponse := getMeasurementCreateResponse(measurementID1) + + gbMock := mocks.NewMockClient(ctrl) + gbMock.EXPECT().CreateMeasurement(expectedOpts).Times(1).Return(expectedResponse, false, nil) + + viewerMock := mocks.NewMockViewer(ctrl) + viewerMock.EXPECT().Output(measurementID1, expectedOpts).Times(1).Return(nil) + + ctx := &view.Context{ + MaxHistory: 1, + } + r, w, err := os.Pipe() + assert.NoError(t, err) + defer r.Close() + defer w.Close() + + printer := view.NewPrinter(nil, w, w) + root := NewRoot(printer, ctx, viewerMock, nil, gbMock, nil) + os.Args = []string{"globalping", "traceroute", "jsdelivr.com", + "from", "Berlin", + "--limit", "2", + "--protocol", "tcp", + "--port", "99", + } + err = root.Cmd.ExecuteContext(context.TODO()) + assert.NoError(t, err) + w.Close() + + output, err := io.ReadAll(r) + assert.NoError(t, err) + assert.Equal(t, "", string(output)) + + expectedCtx := &view.Context{ + Cmd: "traceroute", + Target: "jsdelivr.com", + From: "Berlin", + Limit: 2, + Protocol: "tcp", + Port: 99, + CIMode: true, + MaxHistory: 1, + } + assert.Equal(t, expectedCtx, ctx) + + b, err := os.ReadFile(getMeasurementsPath()) + assert.NoError(t, err) + expectedHistory := []byte(measurementID1 + "\n") + assert.Equal(t, expectedHistory, b) +} diff --git a/cmd/version.go b/cmd/version.go index 9a21fa6..1739a75 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -1,20 +1,18 @@ package cmd import ( - "fmt" - "github.com/jsdelivr/globalping-cli/version" "github.com/spf13/cobra" ) -func init() { - rootCmd.AddCommand(versionCmd) +func (r *Root) initVersion() { + r.Cmd.AddCommand(&cobra.Command{ + Run: r.RunVersion, + Use: "version", + Short: "Print the version number of Globalping CLI", + }) } -var versionCmd = &cobra.Command{ - Use: "version", - Short: "Print the version number of Globalping CLI", - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("Globalping CLI v" + version.Version) - }, +func (r *Root) RunVersion(cmd *cobra.Command, args []string) { + r.printer.Println("Globalping CLI v" + version.Version) } diff --git a/cmd/version_test.go b/cmd/version_test.go new file mode 100644 index 0000000..1d24dab --- /dev/null +++ b/cmd/version_test.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "context" + "io" + "os" + "testing" + + "github.com/jsdelivr/globalping-cli/version" + "github.com/jsdelivr/globalping-cli/view" + "github.com/stretchr/testify/assert" +) + +func Test_Execute_Version_Default(t *testing.T) { + version.Version = "1.0.0" + r, w, err := os.Pipe() + assert.NoError(t, err) + defer r.Close() + defer w.Close() + + printer := view.NewPrinter(nil, w, w) + root := NewRoot(printer, &view.Context{}, nil, nil, nil, nil) + + os.Args = []string{"globalping", "version"} + err = root.Cmd.ExecuteContext(context.TODO()) + assert.NoError(t, err) + w.Close() + + output, err := io.ReadAll(r) + assert.NoError(t, err) + assert.Equal(t, "Globalping CLI v1.0.0\n", string(output)) +} diff --git a/lib/probe/container_engine.go b/globalping/probe/container_engine.go similarity index 93% rename from lib/probe/container_engine.go rename to globalping/probe/container_engine.go index a8c1afd..e9a9819 100644 --- a/lib/probe/container_engine.go +++ b/globalping/probe/container_engine.go @@ -14,7 +14,7 @@ const ( ContainerEnginePodman ContainerEngine = "Podman" ) -func DetectContainerEngine() (ContainerEngine, error) { +func (p *probe) DetectContainerEngine() (ContainerEngine, error) { // check if docker is installed dockerInfoCmd := exec.Command("docker", "info") dockerInfoCmd.Stderr = os.Stderr diff --git a/lib/probe/probe.go b/globalping/probe/probe.go similarity index 72% rename from lib/probe/probe.go rename to globalping/probe/probe.go index 1098544..508e41d 100644 --- a/lib/probe/probe.go +++ b/globalping/probe/probe.go @@ -7,7 +7,19 @@ import ( "os/exec" ) -func InspectContainer(containerEngine ContainerEngine) error { +type Probe interface { + DetectContainerEngine() (ContainerEngine, error) + InspectContainer(containerEngine ContainerEngine) error + RunContainer(containerEngine ContainerEngine) error +} + +type probe struct{} + +func NewProbe() Probe { + return &probe{} +} + +func (p *probe) InspectContainer(containerEngine ContainerEngine) error { switch containerEngine { case ContainerEngineDocker: err := inspectContainerDocker() @@ -20,7 +32,26 @@ func InspectContainer(containerEngine ContainerEngine) error { return err } default: - return fmt.Errorf("Unknown container engine %s", containerEngine) + return fmt.Errorf("unknown container engine %s", containerEngine) + } + + return nil +} + +func (p *probe) RunContainer(containerEngine ContainerEngine) error { + switch containerEngine { + case ContainerEngineDocker: + err := runContainerDocker() + if err != nil { + return err + } + case ContainerEnginePodman: + err := runContainerPodman() + if err != nil { + return err + } + default: + return fmt.Errorf("unknown container engine %s", containerEngine) } return nil @@ -31,7 +62,7 @@ func inspectContainerDocker() error { containerStatus, err := cmd.Output() if err == nil { containerStatusStr := string(bytes.TrimSpace(containerStatus)) - return fmt.Errorf("The globalping-probe container is already installed on your system. Current status: %s", containerStatusStr) + return fmt.Errorf("the globalping-probe container is already installed on your system. Current status: %s", containerStatusStr) } return nil @@ -47,26 +78,7 @@ func inspectContainerPodman() error { return nil } - return fmt.Errorf("The globalping-probe container is already installed on your system. Current status: %s", containerStatusStr) - } - - return nil -} - -func RunContainer(containerEngine ContainerEngine) error { - switch containerEngine { - case ContainerEngineDocker: - err := runContainerDocker() - if err != nil { - return err - } - case ContainerEnginePodman: - err := runContainerPodman() - if err != nil { - return err - } - default: - return fmt.Errorf("Unknown container engine %s", containerEngine) + return fmt.Errorf("the globalping-probe container is already installed on your system. Current status: %s", containerStatusStr) } return nil @@ -78,7 +90,7 @@ func runContainerDocker() error { cmd.Stderr = os.Stderr err := cmd.Run() if err != nil { - return fmt.Errorf("Failed to run container: %v", err) + return fmt.Errorf("failed to run container: %v", err) } return nil @@ -90,7 +102,7 @@ func runContainerPodman() error { cmd.Stderr = os.Stderr err := cmd.Run() if err != nil { - return fmt.Errorf("Failed to run container: %v", err) + return fmt.Errorf("failed to run container: %v", err) } return nil diff --git a/go.mod b/go.mod index ef556a0..f550336 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,6 @@ require ( github.com/spf13/cobra v1.8.0 github.com/stretchr/testify v1.8.4 go.uber.org/mock v0.4.0 - golang.org/x/exp v0.0.0-20240119083558-1b970713d09a ) require ( @@ -41,6 +40,7 @@ require ( github.com/tklauser/numcpus v0.7.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yusufpapurcu/wmi v1.2.3 // indirect + golang.org/x/exp v0.0.0-20240119083558-1b970713d09a // indirect golang.org/x/sys v0.16.0 // indirect golang.org/x/term v0.16.0 // indirect golang.org/x/text v0.14.0 // indirect diff --git a/lib/target_query.go b/lib/target_query.go deleted file mode 100644 index b83fa95..0000000 --- a/lib/target_query.go +++ /dev/null @@ -1,71 +0,0 @@ -package lib - -import ( - "errors" - "fmt" - "strings" - - "golang.org/x/exp/slices" -) - -type TargetQuery struct { - Target string - From string - Resolver string -} - -var commandsWithResolver = []string{ - "dns", - "http", -} - -func ParseTargetQuery(cmd string, args []string) (*TargetQuery, error) { - targetQuery := &TargetQuery{} - if len(args) == 0 { - return nil, errors.New("provided target is empty") - } - - resolver, argsWithoutResolver := findAndRemoveResolver(args) - if resolver != "" { - // resolver was found - if !slices.Contains(commandsWithResolver, cmd) { - return nil, fmt.Errorf("command %s does not accept a resolver argument. @%s was provided", cmd, resolver) - } - - targetQuery.Resolver = resolver - } - - targetQuery.Target = argsWithoutResolver[0] - - if len(argsWithoutResolver) > 1 { - if argsWithoutResolver[1] == "from" { - targetQuery.From = strings.TrimSpace(strings.Join(argsWithoutResolver[2:], " ")) - } else { - return nil, errors.New("invalid command format") - } - } - - return targetQuery, nil -} - -func findAndRemoveResolver(args []string) (string, []string) { - var resolver string - resolverIndex := -1 - for i := 0; i < len(args); i++ { - if len(args[i]) > 0 && args[i][0] == '@' && args[i-1] != "from" { - resolver = args[i][1:] - resolverIndex = i - break - } - } - - if resolverIndex == -1 { - // resolver was not found - return "", args - } - - argsClone := slices.Clone(args) - argsWithoutResolver := slices.Delete(argsClone, resolverIndex, resolverIndex+1) - - return resolver, argsWithoutResolver -} diff --git a/lib/target_query_test.go b/lib/target_query_test.go deleted file mode 100644 index 9ee6420..0000000 --- a/lib/target_query_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package lib - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestParseTargetQuery_Simple(t *testing.T) { - cmd := "ping" - args := []string{"example.com"} - - q, err := ParseTargetQuery(cmd, args) - assert.NoError(t, err) - - assert.Equal(t, TargetQuery{Target: "example.com", From: ""}, *q) -} - -func TestParseTargetQuery_SimpleWithResolver(t *testing.T) { - cmd := "dns" - args := []string{"example.com", "@1.1.1.1"} - - q, err := ParseTargetQuery(cmd, args) - assert.NoError(t, err) - - assert.Equal(t, TargetQuery{Target: "example.com", From: "", Resolver: "1.1.1.1"}, *q) -} - -func TestParseTargetQuery_ResolverNotAllowed(t *testing.T) { - cmd := "ping" - args := []string{"example.com", "@1.1.1.1"} - - _, err := ParseTargetQuery(cmd, args) - assert.ErrorContains(t, err, "does not accept a resolver argument") -} - -func TestParseTargetQuery_TargetFromX(t *testing.T) { - cmd := "ping" - args := []string{"example.com", "from", "London"} - - q, err := ParseTargetQuery(cmd, args) - assert.NoError(t, err) - - assert.Equal(t, TargetQuery{Target: "example.com", From: "London"}, *q) -} - -func TestParseTargetQuery_TargetFromXWithResolver(t *testing.T) { - cmd := "http" - args := []string{"example.com", "from", "London", "@1.1.1.1"} - - q, err := ParseTargetQuery(cmd, args) - assert.NoError(t, err) - - assert.Equal(t, TargetQuery{Target: "example.com", From: "London", Resolver: "1.1.1.1"}, *q) -} - -func TestFindAndRemoveResolver_SimpleNoResolver(t *testing.T) { - args := []string{"example.com"} - - resolver, argsWithoutResolver := findAndRemoveResolver(args) - - assert.Equal(t, "", resolver) - assert.Equal(t, args, argsWithoutResolver) -} - -func TestFindAndRemoveResolver_NoResolver(t *testing.T) { - args := []string{"example.com", "from", "London"} - - resolver, argsWithoutResolver := findAndRemoveResolver(args) - - assert.Equal(t, "", resolver) - assert.Equal(t, args, argsWithoutResolver) -} - -func TestFindAndRemoveResolver_ResolverAndFrom(t *testing.T) { - args := []string{"example.com", "@1.1.1.1", "from", "London"} - - resolver, argsWithoutResolver := findAndRemoveResolver(args) - - assert.Equal(t, "1.1.1.1", resolver) - assert.Equal(t, []string{"example.com", "from", "London"}, argsWithoutResolver) -} - -func TestFindAndRemoveResolver_ResolverOnly(t *testing.T) { - args := []string{"example.com", "@1.1.1.1"} - - resolver, argsWithoutResolver := findAndRemoveResolver(args) - - assert.Equal(t, "1.1.1.1", resolver) - assert.Equal(t, []string{"example.com"}, argsWithoutResolver) -} diff --git a/mocks/gen_mocks.sh b/mocks/gen_mocks.sh index 05ffabb..8452e6f 100755 --- a/mocks/gen_mocks.sh +++ b/mocks/gen_mocks.sh @@ -1,5 +1,6 @@ rm -rf mocks/mock_*.go bin/mockgen -source globalping/client.go -destination mocks/mock_client.go -package mocks +bin/mockgen -source globalping/probe/probe.go -destination mocks/mock_probe.go -package mocks bin/mockgen -source view/viewer.go -destination mocks/mock_viewer.go -package mocks bin/mockgen -source utils/time.go -destination mocks/mock_time.go -package mocks diff --git a/mocks/mock_probe.go b/mocks/mock_probe.go new file mode 100644 index 0000000..47d685a --- /dev/null +++ b/mocks/mock_probe.go @@ -0,0 +1,83 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: globalping/probe/probe.go +// +// Generated by this command: +// +// mockgen -source globalping/probe/probe.go -destination mocks/mock_probe.go -package mocks +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + reflect "reflect" + + probe "github.com/jsdelivr/globalping-cli/globalping/probe" + gomock "go.uber.org/mock/gomock" +) + +// MockProbe is a mock of Probe interface. +type MockProbe struct { + ctrl *gomock.Controller + recorder *MockProbeMockRecorder +} + +// MockProbeMockRecorder is the mock recorder for MockProbe. +type MockProbeMockRecorder struct { + mock *MockProbe +} + +// NewMockProbe creates a new mock instance. +func NewMockProbe(ctrl *gomock.Controller) *MockProbe { + mock := &MockProbe{ctrl: ctrl} + mock.recorder = &MockProbeMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockProbe) EXPECT() *MockProbeMockRecorder { + return m.recorder +} + +// DetectContainerEngine mocks base method. +func (m *MockProbe) DetectContainerEngine() (probe.ContainerEngine, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DetectContainerEngine") + ret0, _ := ret[0].(probe.ContainerEngine) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DetectContainerEngine indicates an expected call of DetectContainerEngine. +func (mr *MockProbeMockRecorder) DetectContainerEngine() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DetectContainerEngine", reflect.TypeOf((*MockProbe)(nil).DetectContainerEngine)) +} + +// InspectContainer mocks base method. +func (m *MockProbe) InspectContainer(containerEngine probe.ContainerEngine) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InspectContainer", containerEngine) + ret0, _ := ret[0].(error) + return ret0 +} + +// InspectContainer indicates an expected call of InspectContainer. +func (mr *MockProbeMockRecorder) InspectContainer(containerEngine any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InspectContainer", reflect.TypeOf((*MockProbe)(nil).InspectContainer), containerEngine) +} + +// RunContainer mocks base method. +func (m *MockProbe) RunContainer(containerEngine probe.ContainerEngine) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RunContainer", containerEngine) + ret0, _ := ret[0].(error) + return ret0 +} + +// RunContainer indicates an expected call of RunContainer. +func (mr *MockProbeMockRecorder) RunContainer(containerEngine any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RunContainer", reflect.TypeOf((*MockProbe)(nil).RunContainer), containerEngine) +} diff --git a/view/context.go b/view/context.go index ad14c84..8c829ac 100644 --- a/view/context.go +++ b/view/context.go @@ -8,24 +8,27 @@ import ( ) type Context struct { - Cmd string - Target string - From string + Cmd string + Target string + From string + Limit int // Number of probes to use + CIMode bool // Determine whether the output should be in a format that is easy to parse by a CI tool + ToJSON bool // Determines whether the output should be in JSON format. + ToLatency bool // Determines whether the output should be only the stats of a measurement + Share bool // Display share message - Protocol string + Packets int // Number of packets to send Port int + Protocol string Resolver string - Trace bool QueryType string - - Limit int // Number of probes to use - Packets int // Number of packets to send - - ToJSON bool // Determines whether the output should be in JSON format. - ToLatency bool // Determines whether the output should be only the stats of a measurement - CI bool // Determine whether the output should be in a format that is easy to parse by a CI tool + Host string + Path string + Query string + Method string + Headers []string + Trace bool Full bool // Full output - Share bool // Display share message Infinite bool // Infinite flag APIMinInterval time.Duration // Minimum interval between API calls diff --git a/view/default.go b/view/default.go index 3b2b721..c08cfbc 100644 --- a/view/default.go +++ b/view/default.go @@ -16,7 +16,7 @@ func (v *viewer) outputDefault(id string, data *globalping.Measurement, m *globa } // Output slightly different format if state is available - v.printer.Println(generateProbeInfo(result, !v.ctx.CI)) + v.printer.Println(generateProbeInfo(result, !v.ctx.CIMode)) if v.isBodyOnlyHttpGet(m) { v.printer.Println(strings.TrimSpace(result.Result.RawBody)) @@ -26,6 +26,6 @@ func (v *viewer) outputDefault(id string, data *globalping.Measurement, m *globa } if v.ctx.Share { - v.printer.Println(formatWithLeadingArrow(shareMessage(id), !v.ctx.CI)) + v.printer.Println(formatWithLeadingArrow(shareMessage(id), !v.ctx.CIMode)) } } diff --git a/view/default_test.go b/view/default_test.go index db8122a..d20c67b 100644 --- a/view/default_test.go +++ b/view/default_test.go @@ -67,9 +67,9 @@ func Test_Output_Default_HTTP_Get(t *testing.T) { } viewer := NewViewer(&Context{ - Cmd: "http", - CI: true, - }, NewPrinter(w), nil, gbMock) + Cmd: "http", + CIMode: true, + }, NewPrinter(nil, w, w), nil, gbMock) viewer.Output(measurementID1, m) w.Close() @@ -140,10 +140,10 @@ func Test_Output_Default_HTTP_Get_Share(t *testing.T) { } viewer := NewViewer(&Context{ - Cmd: "http", - CI: true, - Share: true, - }, NewPrinter(w), nil, gbMock) + Cmd: "http", + CIMode: true, + Share: true, + }, NewPrinter(nil, w, w), nil, gbMock) viewer.Output(measurementID1, m) w.Close() @@ -215,10 +215,10 @@ func Test_Output_Default_HTTP_Get_Full(t *testing.T) { } viewer := NewViewer(&Context{ - Cmd: "http", - CI: true, - Full: true, - }, NewPrinter(w), nil, gbMock) + Cmd: "http", + CIMode: true, + Full: true, + }, NewPrinter(nil, w, w), nil, gbMock) viewer.Output(measurementID1, m) w.Close() @@ -289,9 +289,9 @@ func Test_Output_Default_HTTP_Head(t *testing.T) { } viewer := NewViewer(&Context{ - Cmd: "http", - CI: true, - }, NewPrinter(w), nil, gbMock) + Cmd: "http", + CIMode: true, + }, NewPrinter(nil, w, w), nil, gbMock) viewer.Output(measurementID1, m) w.Close() @@ -352,9 +352,9 @@ func Test_Output_Default_Ping(t *testing.T) { m := &globalping.MeasurementCreate{} viewer := NewViewer(&Context{ - Cmd: "ping", - CI: true, - }, NewPrinter(w), nil, gbMock) + Cmd: "ping", + CIMode: true, + }, NewPrinter(nil, w, w), nil, gbMock) viewer.Output(measurementID1, m) w.Close() diff --git a/view/infinite.go b/view/infinite.go index 6bce59d..f7f1647 100644 --- a/view/infinite.go +++ b/view/infinite.go @@ -25,14 +25,14 @@ func (v *viewer) OutputInfinite(id string) error { } v.ctx.History.Push(id) - res, err := v.gp.GetMeasurement(id) + res, err := v.globalping.GetMeasurement(id) if err != nil { return err } // Probe may not have started yet for len(res.Results) == 0 { time.Sleep(v.ctx.APIMinInterval) - res, err = v.gp.GetMeasurement(id) + res, err = v.globalping.GetMeasurement(id) if err != nil { return err } @@ -41,7 +41,7 @@ func (v *viewer) OutputInfinite(id string) error { if v.ctx.ToJSON { for res.Status == globalping.StatusInProgress { time.Sleep(v.ctx.APIMinInterval) - res, err = v.gp.GetMeasurement(res.ID) + res, err = v.globalping.GetMeasurement(res.ID) if err != nil { return err } @@ -75,7 +75,7 @@ func (v *viewer) outputStreamingPackets(res *globalping.Measurement) error { parsedOutput := v.parsePingRawOutput(measurement, v.ctx.CompletedStats[0].Sent) if printHeader && v.ctx.CompletedStats[0].Sent == 0 { v.ctx.Hostname = parsedOutput.Hostname - v.printer.Println(generateProbeInfo(measurement, !v.ctx.CI)) + v.printer.Println(generateProbeInfo(measurement, !v.ctx.CIMode)) v.printer.Printf("PING %s (%s) %s bytes of data.\n", parsedOutput.Hostname, parsedOutput.Address, @@ -96,7 +96,7 @@ func (v *viewer) outputStreamingPackets(res *globalping.Measurement) error { break } time.Sleep(v.ctx.APIMinInterval) - res, err = v.gp.GetMeasurement(res.ID) + res, err = v.globalping.GetMeasurement(res.ID) if err != nil { return err } @@ -139,7 +139,7 @@ func (v *viewer) outputTableView(res *globalping.Measurement) error { break } time.Sleep(v.ctx.APIMinInterval) - res, err = v.gp.GetMeasurement(res.ID) + res, err = v.globalping.GetMeasurement(res.ID) if err != nil { return err } @@ -149,7 +149,7 @@ func (v *viewer) outputTableView(res *globalping.Measurement) error { func (v *viewer) outputFailSummary(res *globalping.Measurement) error { for i := range res.Results { - v.printer.Println(generateProbeInfo(&res.Results[i], !v.ctx.CI)) + v.printer.Println(generateProbeInfo(&res.Results[i], !v.ctx.CIMode)) v.printer.Println(res.Results[i].Result.RawOutput) } return errors.New("all probes failed") diff --git a/view/infinite_test.go b/view/infinite_test.go index 6d22536..5c868ce 100644 --- a/view/infinite_test.go +++ b/view/infinite_test.go @@ -67,7 +67,7 @@ rtt min/avg/max/mdev = 12.711/12.854/12.952/0.103 ms` MaxHistory: 3, MStartedAt: defaultCurrentTime, } - viewer := NewViewer(ctx, NewPrinter(w), timeMock, gbMock) + viewer := NewViewer(ctx, NewPrinter(nil, w, w), timeMock, gbMock) measurement.Status = globalping.StatusInProgress measurement.Results[0].Result.Status = globalping.StatusInProgress @@ -115,7 +115,7 @@ func Test_OutputInfinite_SingleProbe_Failed(t *testing.T) { Cmd: "ping", MaxHistory: 3, } - viewer := NewViewer(ctx, NewPrinter(w), nil, gbMock) + viewer := NewViewer(ctx, NewPrinter(nil, w, w), nil, gbMock) err = viewer.OutputInfinite(measurement.ID) assert.Equal(t, "all probes failed", err.Error()) w.Close() @@ -151,7 +151,7 @@ func Test_OutputInfinite_SingleProbe_MultipleCalls(t *testing.T) { Cmd: "ping", MaxHistory: 3, } - viewer := NewViewer(ctx, NewPrinter(w), nil, gbMock) + viewer := NewViewer(ctx, NewPrinter(nil, w, w), nil, gbMock) err = viewer.OutputInfinite(measurement.ID) assert.NoError(t, err) @@ -245,7 +245,7 @@ rtt min/avg/max/mdev = 17.006/17.333/17.648/0.321 ms` MaxHistory: 3, MStartedAt: defaultCurrentTime, } - viewer := NewViewer(ctx, NewPrinter(w), timeMock, gbMock) + viewer := NewViewer(ctx, NewPrinter(nil, w, w), timeMock, gbMock) os.Stdout = w err = viewer.OutputInfinite(measurementID1) assert.NoError(t, err) @@ -323,7 +323,7 @@ func Test_OutputInfinite_MultipleProbes(t *testing.T) { Cmd: "ping", MaxHistory: 3, } - v := NewViewer(ctx, NewPrinter(w), nil, gbMock) + v := NewViewer(ctx, NewPrinter(nil, w, w), nil, gbMock) os.Stdout = w err = v.OutputInfinite(measurementID1) assert.NoError(t, err) @@ -388,7 +388,7 @@ func Test_OutputInfinite_MultipleProbes_All_Failed(t *testing.T) { Cmd: "ping", MaxHistory: 3, } - v := NewViewer(ctx, NewPrinter(w), nil, gbMock) + v := NewViewer(ctx, NewPrinter(nil, w, w), nil, gbMock) os.Stdout = w err = v.OutputInfinite(measurementID1) os.Stdout = osStdout diff --git a/view/json.go b/view/json.go index a6d2457..33d9b32 100644 --- a/view/json.go +++ b/view/json.go @@ -2,14 +2,14 @@ package view // Outputs the raw JSON for a measurement func (v *viewer) OutputJson(id string) error { - output, err := v.gp.GetMeasurementRaw(id) + output, err := v.globalping.GetMeasurementRaw(id) if err != nil { return err } v.printer.Println(string(output)) if v.ctx.Share { - v.printer.Println(formatWithLeadingArrow(shareMessage(id), !v.ctx.CI)) + v.printer.Println(formatWithLeadingArrow(shareMessage(id), !v.ctx.CIMode)) } v.printer.Println() diff --git a/view/json_test.go b/view/json_test.go index 7d5c95d..8b73179 100644 --- a/view/json_test.go +++ b/view/json_test.go @@ -32,7 +32,7 @@ func Test_Output_Json(t *testing.T) { ToJSON: true, Share: true, }, - NewPrinter(w), + NewPrinter(nil, w, w), nil, gbMock, ) diff --git a/view/latency.go b/view/latency.go index a4b587e..fd6af74 100644 --- a/view/latency.go +++ b/view/latency.go @@ -16,7 +16,7 @@ func (v *viewer) OutputLatency(id string, data *globalping.Measurement) error { v.printer.Println() } - v.printer.Println(generateProbeInfo(&result, !v.ctx.CI)) + v.printer.Println(generateProbeInfo(&result, !v.ctx.CIMode)) switch v.ctx.Cmd { case "ping": @@ -24,33 +24,33 @@ func (v *viewer) OutputLatency(id string, data *globalping.Measurement) error { if err != nil { return err } - v.printer.Println(v.latencyStatHeader("Min", v.ctx.CI) + fmt.Sprintf("%.2f ms", stats.Min)) - v.printer.Println(v.latencyStatHeader("Max", v.ctx.CI) + fmt.Sprintf("%.2f ms", stats.Max)) - v.printer.Println(v.latencyStatHeader("Avg", v.ctx.CI) + fmt.Sprintf("%.2f ms", stats.Avg)) + v.printer.Println(v.latencyStatHeader("Min", v.ctx.CIMode) + fmt.Sprintf("%.2f ms", stats.Min)) + v.printer.Println(v.latencyStatHeader("Max", v.ctx.CIMode) + fmt.Sprintf("%.2f ms", stats.Max)) + v.printer.Println(v.latencyStatHeader("Avg", v.ctx.CIMode) + fmt.Sprintf("%.2f ms", stats.Avg)) case "dns": timings, err := globalping.DecodeDNSTimings(result.Result.TimingsRaw) if err != nil { return err } - v.printer.Println(v.latencyStatHeader("Total", v.ctx.CI) + fmt.Sprintf("%v ms", timings.Total)) + v.printer.Println(v.latencyStatHeader("Total", v.ctx.CIMode) + fmt.Sprintf("%v ms", timings.Total)) case "http": timings, err := globalping.DecodeHTTPTimings(result.Result.TimingsRaw) if err != nil { return err } - v.printer.Println(v.latencyStatHeader("Total", v.ctx.CI) + fmt.Sprintf("%v ms", timings.Total)) - v.printer.Println(v.latencyStatHeader("Download", v.ctx.CI) + fmt.Sprintf("%v ms", timings.Download)) - v.printer.Println(v.latencyStatHeader("First byte", v.ctx.CI) + fmt.Sprintf("%v ms", timings.FirstByte)) - v.printer.Println(v.latencyStatHeader("DNS", v.ctx.CI) + fmt.Sprintf("%v ms", timings.DNS)) - v.printer.Println(v.latencyStatHeader("TLS", v.ctx.CI) + fmt.Sprintf("%v ms", timings.TLS)) - v.printer.Println(v.latencyStatHeader("TCP", v.ctx.CI) + fmt.Sprintf("%v ms", timings.TCP)) + v.printer.Println(v.latencyStatHeader("Total", v.ctx.CIMode) + fmt.Sprintf("%v ms", timings.Total)) + v.printer.Println(v.latencyStatHeader("Download", v.ctx.CIMode) + fmt.Sprintf("%v ms", timings.Download)) + v.printer.Println(v.latencyStatHeader("First byte", v.ctx.CIMode) + fmt.Sprintf("%v ms", timings.FirstByte)) + v.printer.Println(v.latencyStatHeader("DNS", v.ctx.CIMode) + fmt.Sprintf("%v ms", timings.DNS)) + v.printer.Println(v.latencyStatHeader("TLS", v.ctx.CIMode) + fmt.Sprintf("%v ms", timings.TLS)) + v.printer.Println(v.latencyStatHeader("TCP", v.ctx.CIMode) + fmt.Sprintf("%v ms", timings.TCP)) default: return errors.New("unexpected command for latency output: " + v.ctx.Cmd) } } if v.ctx.Share { - v.printer.Println(formatWithLeadingArrow(shareMessage(id), !v.ctx.CI)) + v.printer.Println(formatWithLeadingArrow(shareMessage(id), !v.ctx.CIMode)) } v.printer.Println() diff --git a/view/latency_test.go b/view/latency_test.go index 9afbb50..2519af6 100644 --- a/view/latency_test.go +++ b/view/latency_test.go @@ -62,7 +62,7 @@ func Test_Output_Latency_Ping_Not_CI(t *testing.T) { Cmd: "ping", ToLatency: true, }, - NewPrinter(w), + NewPrinter(nil, w, w), nil, gbMock, ) @@ -121,9 +121,9 @@ func Test_Output_Latency_Ping_CI(t *testing.T) { &Context{ Cmd: "ping", ToLatency: true, - CI: true, + CIMode: true, }, - NewPrinter(w), + NewPrinter(nil, w, w), nil, gbMock, ) @@ -178,7 +178,7 @@ func Test_Output_Latency_DNS_Not_CI(t *testing.T) { Cmd: "dns", ToLatency: true, }, - NewPrinter(w), + NewPrinter(nil, w, w), nil, gbMock, ) @@ -230,9 +230,9 @@ func Test_Output_Latency_DNS_CI(t *testing.T) { &Context{ Cmd: "dns", ToLatency: true, - CI: true, + CIMode: true, }, - NewPrinter(w), + NewPrinter(nil, w, w), nil, gbMock, ) @@ -285,7 +285,7 @@ func Test_Output_Latency_Http_Not_CI(t *testing.T) { Cmd: "http", ToLatency: true, }, - NewPrinter(w), + NewPrinter(nil, w, w), nil, gbMock, ) @@ -342,9 +342,9 @@ func Test_Output_Latency_Http_CI(t *testing.T) { &Context{ Cmd: "http", ToLatency: true, - CI: true, + CIMode: true, }, - NewPrinter(w), + NewPrinter(nil, w, w), nil, gbMock, ) diff --git a/view/output.go b/view/output.go index 74cc1cd..ca78336 100644 --- a/view/output.go +++ b/view/output.go @@ -24,24 +24,24 @@ var ( func (v *viewer) Output(id string, m *globalping.MeasurementCreate) error { // Wait for first result to arrive from a probe before starting display (can be in-progress) - data, err := v.gp.GetMeasurement(id) + data, err := v.globalping.GetMeasurement(id) if err != nil { return err } // Probe may not have started yet for len(data.Results) == 0 { time.Sleep(v.ctx.APIMinInterval) - data, err = v.gp.GetMeasurement(id) + data, err = v.globalping.GetMeasurement(id) if err != nil { return err } } - if v.ctx.CI || v.ctx.ToJSON || v.ctx.ToLatency { + if v.ctx.CIMode || v.ctx.ToJSON || v.ctx.ToLatency { // Poll API until the measurement is complete for data.Status == globalping.StatusInProgress { time.Sleep(v.ctx.APIMinInterval) - data, err = v.gp.GetMeasurement(id) + data, err = v.globalping.GetMeasurement(id) if err != nil { return err } @@ -55,7 +55,7 @@ func (v *viewer) Output(id string, m *globalping.MeasurementCreate) error { return v.OutputJson(id) } - if v.ctx.CI { + if v.ctx.CIMode { v.outputDefault(id, data, m) return nil } @@ -78,7 +78,7 @@ func (v *viewer) liveView(id string, data *globalping.Measurement, m *globalping // Stop area printer and clear area if not already done err := areaPrinter.Stop() if err != nil { - fmt.Printf("failed to stop writer: %v", err) + v.printer.Printf("failed to stop writer: %v", err) } }() @@ -93,7 +93,7 @@ func (v *viewer) liveView(id string, data *globalping.Measurement, m *globalping // Poll API until the measurement is complete for data.Status == globalping.StatusInProgress { time.Sleep(v.ctx.APIMinInterval) - data, err = v.gp.GetMeasurement(id) + data, err = v.globalping.GetMeasurement(id) if err != nil { return fmt.Errorf("failed to get data: %v", err) } @@ -105,7 +105,7 @@ func (v *viewer) liveView(id string, data *globalping.Measurement, m *globalping for i := range data.Results { result := &data.Results[i] // Output slightly different format if state is available - output.WriteString(generateProbeInfo(result, !v.ctx.CI) + "\n") + output.WriteString(generateProbeInfo(result, !v.ctx.CIMode) + "\n") if v.isBodyOnlyHttpGet(m) { output.WriteString(strings.TrimSpace(result.Result.RawBody) + "\n\n") diff --git a/view/output_test.go b/view/output_test.go index 9c2a0d8..0640705 100644 --- a/view/output_test.go +++ b/view/output_test.go @@ -11,7 +11,7 @@ var ( testContext = Context{ From: "New York", Target: "1.1.1.1", - CI: true, + CIMode: true, } testResult = globalping.ProbeMeasurement{ Probe: globalping.ProbeDetails{ @@ -27,17 +27,17 @@ var ( ) func Test_HeadersBase(t *testing.T) { - assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network", generateProbeInfo(&testResult, !testContext.CI)) + assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network", generateProbeInfo(&testResult, !testContext.CIMode)) } func Test_HeadersTags(t *testing.T) { newResult := testResult newResult.Probe.Tags = []string{"tag1", "tag2"} - assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network (tag1)", generateProbeInfo(&newResult, !testContext.CI)) + assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network (tag1)", generateProbeInfo(&newResult, !testContext.CIMode)) newResult.Probe.Tags = []string{"tag", "tag2"} - assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network (tag2)", generateProbeInfo(&newResult, !testContext.CI)) + assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network (tag2)", generateProbeInfo(&newResult, !testContext.CIMode)) } func Test_TrimOutput(t *testing.T) { diff --git a/view/printer.go b/view/printer.go index 14a4737..44f62ef 100644 --- a/view/printer.go +++ b/view/printer.go @@ -8,24 +8,32 @@ import ( ) type Printer struct { - w io.Writer + InReader io.Reader + OutWriter io.Writer + ErrWriter io.Writer } -func NewPrinter(writer io.Writer) *Printer { - pterm.SetDefaultOutput(writer) // TODO: Set writer for AreaPrinter +func NewPrinter( + inReader io.Reader, + outWriter io.Writer, + errWriter io.Writer, +) *Printer { + pterm.SetDefaultOutput(outWriter) // TODO: Set writer for AreaPrinter return &Printer{ - w: writer, + InReader: inReader, + OutWriter: outWriter, + ErrWriter: errWriter, } } func (p *Printer) Print(a ...any) { - fmt.Fprint(p.w, a...) + fmt.Fprint(p.OutWriter, a...) } func (p *Printer) Println(a ...any) { - fmt.Fprintln(p.w, a...) + fmt.Fprintln(p.OutWriter, a...) } func (p *Printer) Printf(format string, a ...any) { - fmt.Fprintf(p.w, format, a...) + fmt.Fprintf(p.OutWriter, format, a...) } diff --git a/view/summary.go b/view/summary.go index babcf96..d70b41b 100644 --- a/view/summary.go +++ b/view/summary.go @@ -45,7 +45,7 @@ func (v *viewer) OutputSummary() { } ids := v.ctx.History.ToString("+") if ids != "" { - v.printer.Println(formatWithLeadingArrow(shareMessage(ids), !v.ctx.CI)) + v.printer.Println(formatWithLeadingArrow(shareMessage(ids), !v.ctx.CIMode)) } if v.ctx.CallCount > v.ctx.MaxHistory { v.printer.Printf("For long-running continuous mode measurements, only the last %d packets are shared.\n", diff --git a/view/summary_test.go b/view/summary_test.go index 76f9ba0..0944f91 100644 --- a/view/summary_test.go +++ b/view/summary_test.go @@ -16,7 +16,7 @@ func Test_OutputSummary(t *testing.T) { defer r.Close() defer w.Close() - viewer := NewViewer(&Context{}, NewPrinter(w), nil, nil) + viewer := NewViewer(&Context{}, NewPrinter(nil, w, w), nil, nil) viewer.OutputSummary() w.Close() @@ -36,7 +36,7 @@ func Test_OutputSummary(t *testing.T) { {Sent: 10, Rcv: 9, Lost: 1, Loss: 10, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 1000, Mdev: 0.001}, }, } - viewer := NewViewer(ctx, NewPrinter(w), nil, nil) + viewer := NewViewer(ctx, NewPrinter(nil, w, w), nil, nil) viewer.OutputSummary() w.Close() @@ -61,7 +61,7 @@ rtt min/avg/max/mdev = 0.770/0.770/0.770/0.001 ms {Sent: 1, Rcv: 0, Lost: 1, Loss: 100, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1, Time: 0}, }, } - viewer := NewViewer(ctx, NewPrinter(w), nil, nil) + viewer := NewViewer(ctx, NewPrinter(nil, w, w), nil, nil) viewer.OutputSummary() w.Close() @@ -87,7 +87,7 @@ rtt min/avg/max/mdev = -/-/-/- ms NewMeasurementStats(), }, } - viewer := NewViewer(ctx, NewPrinter(w), nil, nil) + viewer := NewViewer(ctx, NewPrinter(nil, w, w), nil, nil) viewer.OutputSummary() w.Close() @@ -112,7 +112,7 @@ rtt min/avg/max/mdev = -/-/-/- ms }, Share: true, } - viewer := NewViewer(ctx, NewPrinter(w), nil, nil) + viewer := NewViewer(ctx, NewPrinter(nil, w, w), nil, nil) viewer.OutputSummary() w.Close() @@ -145,7 +145,7 @@ rtt min/avg/max/mdev = -/-/-/- ms }, Share: true, } - viewer := NewViewer(ctx, NewPrinter(w), nil, nil) + viewer := NewViewer(ctx, NewPrinter(nil, w, w), nil, nil) viewer.OutputSummary() w.Close() @@ -176,7 +176,7 @@ rtt min/avg/max/mdev = -/-/-/- ms MaxHistory: 1, Packets: 16, } - viewer := NewViewer(ctx, NewPrinter(w), nil, nil) + viewer := NewViewer(ctx, NewPrinter(nil, w, w), nil, nil) viewer.OutputSummary() w.Close() diff --git a/view/viewer.go b/view/viewer.go index 0c6a015..3743299 100644 --- a/view/viewer.go +++ b/view/viewer.go @@ -12,22 +12,22 @@ type Viewer interface { } type viewer struct { - ctx *Context - printer *Printer - time utils.Time - gp globalping.Client + ctx *Context + printer *Printer + time utils.Time + globalping globalping.Client } func NewViewer( ctx *Context, printer *Printer, time utils.Time, - gp globalping.Client, + globalpingClient globalping.Client, ) Viewer { return &viewer{ - ctx: ctx, - printer: printer, - time: time, - gp: gp, + ctx: ctx, + printer: printer, + time: time, + globalping: globalpingClient, } }