From f577a85a0bd93a83ae18323ee9ea6596eee9f6b7 Mon Sep 17 00:00:00 2001 From: Radu Lucut Date: Mon, 15 Jan 2024 20:26:38 +0200 Subject: [PATCH 01/27] Add --infinite flag --- README.md | 9 +++++ client/client.go | 1 + cmd/ping.go | 80 ++++++++++++++++++++++++++------------------ cmd/root.go | 1 + view/latency.go | 1 + view/latency_test.go | 12 +++---- view/view.go | 1 + view/view_test.go | 2 +- 8 files changed, 68 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index a3fde80..5697495 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,15 @@ Max: 7.413 ms Avg: 7.359 ms ``` +#### Continuously ping + +Use the `--infinite` flag to continuously ping a host. + +```bash +globalping ping google.com from USA --infinite +... +``` + #### Learn about available flags Most commands have shared and unique flags. We recommend that you familiarize yourself with these so that you can run and automate your network testsĀ in powerful ways. diff --git a/client/client.go b/client/client.go index e23d4b1..6bfa0ce 100644 --- a/client/client.go +++ b/client/client.go @@ -13,6 +13,7 @@ import ( ) var ApiUrl = "https://api.globalping.io/v1/measurements" +var PacketsMax = 16 // Post measurement to Globalping API - boolean indicates whether to print CLI help on error func PostAPI(measurement model.PostMeasurement) (model.PostResponse, bool, error) { diff --git a/cmd/ping.go b/cmd/ping.go index aab9add..b8507aa 100644 --- a/cmd/ping.go +++ b/cmd/ping.go @@ -39,50 +39,65 @@ Examples: ping jsdelivr.com from aws+montreal --latency # Ping jsdelivr.com from a probe in ASN 123 with json output - ping jsdelivr.com from 123 --json`, + ping jsdelivr.com from 123 --json + + # Continuously ping google.com from New York + ping google.com from New York --infinite`, RunE: func(cmd *cobra.Command, args []string) error { - // Create context err := createContext(cmd.CalledAs(), args) if err != nil { return err } - - // Make post struct - opts = model.PostMeasurement{ - Type: "ping", - Target: ctx.Target, - Limit: ctx.Limit, - InProgressUpdates: inProgressUpdates(ctx.CI), - Options: &model.MeasurementOptions{ - Packets: packets, - }, + if infinite { + packets = client.PacketsMax + for { + ctx.From, err = ping(cmd) + if err != nil { + return err + } + } } - isPreviousMeasurementId := false - opts.Locations, isPreviousMeasurementId, err = createLocations(ctx.From) - if err != nil { + + _, err = ping(cmd) + return err + }, +} + +func ping(cmd *cobra.Command) (string, error) { + opts = model.PostMeasurement{ + Type: "ping", + Target: ctx.Target, + Limit: ctx.Limit, + InProgressUpdates: inProgressUpdates(ctx.CI), + Options: &model.MeasurementOptions{ + Packets: packets, + }, + } + locations, isPreviousMeasurementId, err := createLocations(ctx.From) + if err != nil { + cmd.SilenceUsage = true + return "", err + } + opts.Locations = locations + + res, showHelp, err := client.PostAPI(opts) + if err != nil { + if !showHelp { cmd.SilenceUsage = true - return err } + return "", err + } - res, showHelp, err := client.PostAPI(opts) + // Save measurement ID to history + if !isPreviousMeasurementId { + err := saveMeasurementID(res.ID) if err != nil { - if !showHelp { - cmd.SilenceUsage = true - } - return err + fmt.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) - } - } - - view.OutputResults(res.ID, ctx, opts) - return nil - }, + view.OutputResults(res.ID, ctx, opts) + return res.ID, nil } func init() { @@ -90,4 +105,5 @@ func init() { // ping specific flags pingCmd.Flags().IntVar(&packets, "packets", 0, "Specifies the desired amount of ECHO_REQUEST packets to be sent (default 3)") + pingCmd.Flags().BoolVar(&infinite, "infinite", false, "Continuously send ping request to a target (default false)") } diff --git a/cmd/root.go b/cmd/root.go index d37f38e..cf8b6e4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -20,6 +20,7 @@ var ( resolver string trace bool queryType string + infinite bool httpCmdOpts *HttpCmdOpts diff --git a/view/latency.go b/view/latency.go index 0220f7b..d435b99 100644 --- a/view/latency.go +++ b/view/latency.go @@ -52,6 +52,7 @@ func OutputLatency(id string, data *model.GetMeasurement, ctx model.Context) { if ctx.Share { fmt.Fprintln(os.Stderr, formatWithLeadingArrow(ctx, shareMessage(id))) } + fmt.Println() } func latencyStatHeader(title string, ci bool) string { diff --git a/view/latency_test.go b/view/latency_test.go index fe2d27e..28a2515 100644 --- a/view/latency_test.go +++ b/view/latency_test.go @@ -84,7 +84,7 @@ func TestOutputLatency_Ping_Not_CI(t *testing.T) { outContent, err := io.ReadAll(rStdOut) assert.NoError(t, err) - assert.Equal(t, "Min: 8 ms\nMax: 20 ms\nAvg: 12 ms\n\nMin: 9 ms\nMax: 22 ms\nAvg: 15 ms\n", string(outContent)) + assert.Equal(t, "Min: 8 ms\nMax: 20 ms\nAvg: 12 ms\n\nMin: 9 ms\nMax: 22 ms\nAvg: 15 ms\n\n", string(outContent)) } func TestOutputLatency_Ping_CI(t *testing.T) { @@ -145,7 +145,7 @@ func TestOutputLatency_Ping_CI(t *testing.T) { outContent, err := io.ReadAll(rStdOut) assert.NoError(t, err) - assert.Equal(t, "Min: 8 ms\nMax: 20 ms\nAvg: 12 ms\n", string(outContent)) + assert.Equal(t, "Min: 8 ms\nMax: 20 ms\nAvg: 12 ms\n\n", string(outContent)) } func TestOutputLatency_DNS_Not_CI(t *testing.T) { @@ -201,7 +201,7 @@ func TestOutputLatency_DNS_Not_CI(t *testing.T) { outContent, err := io.ReadAll(rStdOut) assert.NoError(t, err) - assert.Equal(t, "Total: 44 ms\n", string(outContent)) + assert.Equal(t, "Total: 44 ms\n\n", string(outContent)) } func TestOutputLatency_DNS_CI(t *testing.T) { @@ -258,7 +258,7 @@ func TestOutputLatency_DNS_CI(t *testing.T) { outContent, err := io.ReadAll(rStdOut) assert.NoError(t, err) - assert.Equal(t, "Total: 44 ms\n", string(outContent)) + assert.Equal(t, "Total: 44 ms\n\n", string(outContent)) } func TestOutputLatency_Http_Not_CI(t *testing.T) { @@ -314,7 +314,7 @@ func TestOutputLatency_Http_Not_CI(t *testing.T) { outContent, err := io.ReadAll(rStdOut) assert.NoError(t, err) - assert.Equal(t, "Total: 44 ms\nDownload: 11 ms\nFirst byte: 20 ms\nDNS: 5 ms\nTLS: 2 ms\nTCP: 4 ms\n", string(outContent)) + assert.Equal(t, "Total: 44 ms\nDownload: 11 ms\nFirst byte: 20 ms\nDNS: 5 ms\nTLS: 2 ms\nTCP: 4 ms\n\n", string(outContent)) } func TestOutputLatency_Http_CI(t *testing.T) { @@ -371,5 +371,5 @@ func TestOutputLatency_Http_CI(t *testing.T) { outContent, err := io.ReadAll(rStdOut) assert.NoError(t, err) - assert.Equal(t, "Total: 44 ms\nDownload: 11 ms\nFirst byte: 20 ms\nDNS: 5 ms\nTLS: 2 ms\nTCP: 4 ms\n", string(outContent)) + assert.Equal(t, "Total: 44 ms\nDownload: 11 ms\nFirst byte: 20 ms\nDNS: 5 ms\nTLS: 2 ms\nTCP: 4 ms\n\n", string(outContent)) } diff --git a/view/view.go b/view/view.go index e384d39..7a819c5 100644 --- a/view/view.go +++ b/view/view.go @@ -174,6 +174,7 @@ func OutputJson(id string, fetcher client.MeasurementsFetcher, ctx model.Context if ctx.Share { fmt.Fprintln(os.Stderr, formatWithLeadingArrow(ctx, shareMessage(id))) } + fmt.Println() } // Prints non-json non-latency results to the screen diff --git a/view/view_test.go b/view/view_test.go index 2d4501a..a378860 100644 --- a/view/view_test.go +++ b/view/view_test.go @@ -538,5 +538,5 @@ func TestOutputJson(t *testing.T) { outContent, err := io.ReadAll(rStdOut) assert.NoError(t, err) - assert.Equal(t, "{\"fake\": \"results\"}\n", string(outContent)) + assert.Equal(t, "{\"fake\": \"results\"}\n\n", string(outContent)) } From 3bbc717ba5443c03493f1998d7155f413ce628bc Mon Sep 17 00:00:00 2001 From: Radu Lucut Date: Wed, 17 Jan 2024 17:30:19 +0200 Subject: [PATCH 02/27] Add infinite output view & refactoring --- client/client.go | 44 +++++++---- client/client_test.go | 36 ++++----- cmd/ping.go | 19 +++-- cmd/root.go | 1 - model/get.go | 48 +++++++++--- model/root.go | 36 ++++++--- view/latency.go | 34 ++++---- view/latency_test.go | 23 ++---- view/view.go | 177 +++++++++++++++++++++++++++++++++++++----- view/view_test.go | 6 +- 10 files changed, 307 insertions(+), 117 deletions(-) diff --git a/client/client.go b/client/client.go index 6bfa0ce..0a220cd 100644 --- a/client/client.go +++ b/client/client.go @@ -89,20 +89,38 @@ func PostAPI(measurement model.PostMeasurement) (model.PostResponse, bool, error return data, false, nil } -func DecodeTimings(cmd string, timings json.RawMessage) (model.Timings, error) { - var data model.Timings +func DecodeDNSTimings(timings json.RawMessage) (*model.DNSTimings, error) { + t := &model.DNSTimings{} + err := json.Unmarshal(timings, t) + if err != nil { + return nil, errors.New("invalid timings format returned (other)") + } + return t, nil +} - if cmd == "ping" { - err := json.Unmarshal(timings, &data.Arr) - if err != nil { - return model.Timings{}, errors.New("invalid timings format returned (ping)") - } - } else { - err := json.Unmarshal(timings, &data.Interface) - if err != nil { - return model.Timings{}, errors.New("invalid timings format returned (other)") - } +func DecodeHTTPTimings(timings json.RawMessage) (*model.HTTPTimings, error) { + t := &model.HTTPTimings{} + err := json.Unmarshal(timings, t) + if err != nil { + return nil, errors.New("invalid timings format returned (other)") } + return t, nil +} - return data, nil +func DecodePingTimings(timings json.RawMessage) ([]model.PingTiming, error) { + t := []model.PingTiming{} + err := json.Unmarshal(timings, &t) + if err != nil { + return nil, errors.New("invalid timings format returned (ping)") + } + return t, nil +} + +func DecodePingStats(stats json.RawMessage) (*model.PingStats, error) { + s := &model.PingStats{} + err := json.Unmarshal(stats, s) + if err != nil { + return nil, errors.New("invalid stats format returned") + } + return s, nil } diff --git a/client/client_test.go b/client/client_test.go index 150b010..c996cb4 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -239,13 +239,15 @@ func testGetPing(t *testing.T) { assert.Equal(t, "PING", res.Results[0].Result.RawOutput) assert.Equal(t, "1.1.1.1", res.Results[0].Result.ResolvedAddress) - assert.Equal(t, 27.088, res.Results[0].Result.Stats["avg"]) - assert.Equal(t, 28.193, res.Results[0].Result.Stats["max"]) - assert.Equal(t, 24.891, res.Results[0].Result.Stats["min"]) - assert.Equal(t, float64(3), res.Results[0].Result.Stats["total"]) - assert.Equal(t, float64(3), res.Results[0].Result.Stats["rcv"]) - assert.Equal(t, float64(0), res.Results[0].Result.Stats["loss"]) - assert.Equal(t, float64(0), res.Results[0].Result.Stats["drop"]) + stats, err := client.DecodePingStats(res.Results[0].Result.StatsRaw) + assert.NoError(t, err) + assert.Equal(t, float64(27.088), stats.Avg) + assert.Equal(t, float64(28.193), stats.Max) + assert.Equal(t, float64(24.891), stats.Min) + assert.Equal(t, 3, stats.Total) + assert.Equal(t, 3, stats.Rcv) + assert.Equal(t, 0, stats.Drop) + assert.Equal(t, float64(0), stats.Loss) } func testGetTraceroute(t *testing.T) { @@ -415,9 +417,8 @@ func testGetDns(t *testing.T) { assert.IsType(t, json.RawMessage{}, res.Results[0].Result.TimingsRaw) // Test timings - timings, _ := client.DecodeTimings("dns", res.Results[0].Result.TimingsRaw) - assert.Equal(t, float64(15), timings.Interface["total"]) - assert.Nil(t, timings.Arr) + timings, _ := client.DecodeDNSTimings(res.Results[0].Result.TimingsRaw) + assert.Equal(t, float64(15), timings.Total) } func testGetMtr(t *testing.T) { @@ -647,12 +648,11 @@ func testGetHttp(t *testing.T) { assert.IsType(t, json.RawMessage{}, res.Results[0].Result.TimingsRaw) // Test timings - timings, _ := client.DecodeTimings("dns", res.Results[0].Result.TimingsRaw) - assert.Nil(t, timings.Arr) - assert.Equal(t, float64(583), timings.Interface["total"]) - assert.Equal(t, float64(18), timings.Interface["download"]) - assert.Equal(t, float64(450), timings.Interface["firstByte"]) - assert.Equal(t, float64(24), timings.Interface["dns"]) - assert.Equal(t, float64(70), timings.Interface["tls"]) - assert.Equal(t, float64(19), timings.Interface["tcp"]) + timings, _ := client.DecodeHTTPTimings(res.Results[0].Result.TimingsRaw) + assert.Equal(t, 583, timings.Total) + assert.Equal(t, 18, timings.Download) + assert.Equal(t, 450, timings.FirstByte) + assert.Equal(t, 24, timings.DNS) + assert.Equal(t, 70, timings.TLS) + assert.Equal(t, 19, timings.TCP) } diff --git a/cmd/ping.go b/cmd/ping.go index b8507aa..abab014 100644 --- a/cmd/ping.go +++ b/cmd/ping.go @@ -48,8 +48,8 @@ Examples: if err != nil { return err } - if infinite { - packets = client.PacketsMax + if ctx.Infinite { + ctx.Packets = 1 // We sent only one packet first and overwrite it later based on the view type for { ctx.From, err = ping(cmd) if err != nil { @@ -57,7 +57,6 @@ Examples: } } } - _, err = ping(cmd) return err }, @@ -70,7 +69,7 @@ func ping(cmd *cobra.Command) (string, error) { Limit: ctx.Limit, InProgressUpdates: inProgressUpdates(ctx.CI), Options: &model.MeasurementOptions{ - Packets: packets, + Packets: ctx.Packets, }, } locations, isPreviousMeasurementId, err := createLocations(ctx.From) @@ -96,14 +95,18 @@ func ping(cmd *cobra.Command) (string, error) { } } - view.OutputResults(res.ID, ctx, opts) - return res.ID, nil + if ctx.Infinite { + err = view.OutputInfinite(res.ID, &ctx) + } else { + view.OutputResults(res.ID, ctx, opts) + } + return res.ID, err } func init() { rootCmd.AddCommand(pingCmd) // ping specific flags - pingCmd.Flags().IntVar(&packets, "packets", 0, "Specifies the desired amount of ECHO_REQUEST packets to be sent (default 3)") - pingCmd.Flags().BoolVar(&infinite, "infinite", false, "Continuously send ping request to a target (default false)") + pingCmd.Flags().IntVar(&ctx.Packets, "packets", 0, "Specifies the desired amount of ECHO_REQUEST packets to be sent (default 3)") + pingCmd.Flags().BoolVar(&ctx.Infinite, "infinite", false, "Continuously send ping request to a target (default false)") } diff --git a/cmd/root.go b/cmd/root.go index cf8b6e4..d37f38e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -20,7 +20,6 @@ var ( resolver string trace bool queryType string - infinite bool httpCmdOpts *HttpCmdOpts diff --git a/model/get.go b/model/get.go index 92e59b9..8dc1fbf 100644 --- a/model/get.go +++ b/model/get.go @@ -2,7 +2,7 @@ package model import "encoding/json" -// Modeled from https://github.com/jsdelivr/globalping/blob/master/docs/measurement/get.md +// Modeled from https://www.jsdelivr.com/docs/api.globalping.io type ProbeData struct { Continent string `json:"continent"` @@ -16,19 +16,42 @@ type ProbeData struct { } type ResultData struct { - Status string `json:"status"` - RawOutput string `json:"rawOutput"` - RawHeaders string `json:"rawHeaders"` - RawBody string `json:"rawBody"` - ResolvedAddress string `json:"resolvedAddress"` - ResolvedHostname string `json:"resolvedHostname"` - Stats map[string]interface{} `json:"stats,omitempty"` - TimingsRaw json.RawMessage `json:"timings,omitempty"` + Status string `json:"status"` + RawOutput string `json:"rawOutput"` + RawHeaders string `json:"rawHeaders"` + RawBody string `json:"rawBody"` + ResolvedAddress string `json:"resolvedAddress"` + ResolvedHostname string `json:"resolvedHostname"` + StatsRaw json.RawMessage `json:"stats,omitempty"` + TimingsRaw json.RawMessage `json:"timings,omitempty"` } -type Timings struct { - Arr []map[string]interface{} - Interface map[string]interface{} +type PingStats struct { + Min float64 `json:"min"` // The lowest rtt value. + Avg float64 `json:"avg"` // The average rtt value. + Max float64 `json:"max"` // The highest rtt value. + Total int `json:"total"` // The number of sent packets. + Rcv int `json:"rcv"` // The number of received packets. + Drop int `json:"drop"` // The number of dropped packets (total - rcv). + Loss float64 `json:"loss"` // The percentage of dropped packets. +} + +type PingTiming struct { + RTT float64 `json:"rtt"` // The round-trip time for this packet. + TTL int `json:"ttl"` // The packet time-to-live value. +} + +type DNSTimings struct { + Total float64 `json:"total"` // The total query time in milliseconds. +} + +type HTTPTimings struct { + Total int `json:"total"` // The total HTTP request time + DNS int `json:"dns"` // The time required to perform the DNS lookup. + TCP int `json:"tcp"` // The time from performing the DNS lookup to establishing the TCP connection. + TLS int `json:"tls"` // The time from establishing the TCP connection to establishing the TLS session. + FirstByte int `json:"firstByte"` // The time from establishing the TCP/TLS connection to the first response byte. + Download int `json:"download"` // The time from the first byte to downloading the whole response. } // Nested structs @@ -44,6 +67,7 @@ type GetMeasurement struct { Status string `json:"status"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` + Target string `json:"target"` ProbesCount int `json:"probesCount"` Results []MeasurementResponse `json:"results"` } diff --git a/model/root.go b/model/root.go index 0ebdd3e..d26d243 100644 --- a/model/root.go +++ b/model/root.go @@ -1,20 +1,34 @@ package model +import "github.com/pterm/pterm" + // Used in thc client TUI type Context struct { Cmd string Target string From string - Limit int Resolver string - // JsonOutput is a flag that determines whether the output should be in JSON format. - JsonOutput bool - // Latency is a flag that outputs only stats of a measurement - Latency bool - // CI flag is used to determine whether the output should be in a format that is easy to parse by a CI tool - CI bool - // Full output - Full bool - // Display share message - Share bool + + Limit int + Packets int // Number of packets to send + + JsonOutput bool // JsonOutput is a flag that determines whether the output should be in JSON format. + Latency bool // Latency is a flag that outputs only stats of a measurement + CI bool // CI flag is used to determine whether the output should be in a format that is easy to parse by a CI tool + Full bool // Full output + Share bool // Display share message + Infinite bool // Infinite flag + + Area *pterm.AreaPrinter + Stats []MeasurementStats +} + +type MeasurementStats struct { + Sent int // Number of packets sent + Lost int // Number of packets lost + Loss float64 // Percentage of packets lost + Last float64 // Last RTT + Min float64 // Minimum RTT + Avg float64 // Average RTT + Max float64 // Maximum RTT } diff --git a/view/latency.go b/view/latency.go index d435b99..45eddb2 100644 --- a/view/latency.go +++ b/view/latency.go @@ -9,6 +9,7 @@ import ( ) // Output latency values +// TODO: return errors instead of printing them func OutputLatency(id string, data *model.GetMeasurement, ctx model.Context) { // Output every result in case of multiple probes for i, result := range data.Results { @@ -17,32 +18,37 @@ func OutputLatency(id string, data *model.GetMeasurement, ctx model.Context) { fmt.Println() } - fmt.Fprintln(os.Stderr, generateHeader(result, ctx)) + fmt.Fprintln(os.Stderr, generateHeader(&result, !ctx.CI)) switch ctx.Cmd { case "ping": - fmt.Println(latencyStatHeader("Min", ctx.CI) + fmt.Sprintf("%v ms", result.Result.Stats["min"])) - fmt.Println(latencyStatHeader("Max", ctx.CI) + fmt.Sprintf("%v ms", result.Result.Stats["max"])) - fmt.Println(latencyStatHeader("Avg", ctx.CI) + fmt.Sprintf("%v ms", result.Result.Stats["avg"])) + stats, err := client.DecodePingStats(result.Result.StatsRaw) + if err != nil { + fmt.Println(err) + return + } + fmt.Println(latencyStatHeader("Min", ctx.CI) + fmt.Sprintf("%.2f ms", stats.Min)) + fmt.Println(latencyStatHeader("Max", ctx.CI) + fmt.Sprintf("%.2f ms", stats.Max)) + fmt.Println(latencyStatHeader("Avg", ctx.CI) + fmt.Sprintf("%.2f ms", stats.Avg)) case "dns": - timings, err := client.DecodeTimings(ctx.Cmd, result.Result.TimingsRaw) + timings, err := client.DecodeDNSTimings(result.Result.TimingsRaw) if err != nil { fmt.Println(err) return } - fmt.Println(latencyStatHeader("Total", ctx.CI) + fmt.Sprintf("%v ms", timings.Interface["total"])) + fmt.Println(latencyStatHeader("Total", ctx.CI) + fmt.Sprintf("%v ms", timings.Total)) case "http": - timings, err := client.DecodeTimings(ctx.Cmd, result.Result.TimingsRaw) + timings, err := client.DecodeHTTPTimings(result.Result.TimingsRaw) if err != nil { fmt.Println(err) return } - fmt.Println(latencyStatHeader("Total", ctx.CI) + fmt.Sprintf("%v ms", timings.Interface["total"])) - fmt.Println(latencyStatHeader("Download", ctx.CI) + fmt.Sprintf("%v ms", timings.Interface["download"])) - fmt.Println(latencyStatHeader("First byte", ctx.CI) + fmt.Sprintf("%v ms", timings.Interface["firstByte"])) - fmt.Println(latencyStatHeader("DNS", ctx.CI) + fmt.Sprintf("%v ms", timings.Interface["dns"])) - fmt.Println(latencyStatHeader("TLS", ctx.CI) + fmt.Sprintf("%v ms", timings.Interface["tls"])) - fmt.Println(latencyStatHeader("TCP", ctx.CI) + fmt.Sprintf("%v ms", timings.Interface["tcp"])) + fmt.Println(latencyStatHeader("Total", ctx.CI) + fmt.Sprintf("%v ms", timings.Total)) + fmt.Println(latencyStatHeader("Download", ctx.CI) + fmt.Sprintf("%v ms", timings.Download)) + fmt.Println(latencyStatHeader("First byte", ctx.CI) + fmt.Sprintf("%v ms", timings.FirstByte)) + fmt.Println(latencyStatHeader("DNS", ctx.CI) + fmt.Sprintf("%v ms", timings.DNS)) + fmt.Println(latencyStatHeader("TLS", ctx.CI) + fmt.Sprintf("%v ms", timings.TLS)) + fmt.Println(latencyStatHeader("TCP", ctx.CI) + fmt.Sprintf("%v ms", timings.TCP)) default: panic("unexpected command for latency output: " + ctx.Cmd) } @@ -50,7 +56,7 @@ func OutputLatency(id string, data *model.GetMeasurement, ctx model.Context) { } if ctx.Share { - fmt.Fprintln(os.Stderr, formatWithLeadingArrow(ctx, shareMessage(id))) + fmt.Fprintln(os.Stderr, formatWithLeadingArrow(shareMessage(id), !ctx.CI)) } fmt.Println() } diff --git a/view/latency_test.go b/view/latency_test.go index 28a2515..1cbe371 100644 --- a/view/latency_test.go +++ b/view/latency_test.go @@ -1,6 +1,7 @@ package view import ( + "encoding/json" "io" "os" "testing" @@ -43,11 +44,7 @@ func TestOutputLatency_Ping_Not_CI(t *testing.T) { Tags: []string{"tag-1"}, }, Result: model.ResultData{ - Stats: map[string]interface{}{ - "min": 8, - "avg": 12, - "max": 20, - }, + StatsRaw: json.RawMessage(`{"min":8,"avg":12,"max":20}`), }, }, { @@ -61,11 +58,7 @@ func TestOutputLatency_Ping_Not_CI(t *testing.T) { Tags: []string{"tag B"}, }, Result: model.ResultData{ - Stats: map[string]interface{}{ - "min": 9, - "avg": 15, - "max": 22, - }, + StatsRaw: json.RawMessage(`{"min":9,"avg":15,"max":22}`), }, }, }, @@ -84,7 +77,7 @@ func TestOutputLatency_Ping_Not_CI(t *testing.T) { outContent, err := io.ReadAll(rStdOut) assert.NoError(t, err) - assert.Equal(t, "Min: 8 ms\nMax: 20 ms\nAvg: 12 ms\n\nMin: 9 ms\nMax: 22 ms\nAvg: 15 ms\n\n", string(outContent)) + assert.Equal(t, "Min: 8.00 ms\nMax: 20.00 ms\nAvg: 12.00 ms\n\nMin: 9.00 ms\nMax: 22.00 ms\nAvg: 15.00 ms\n\n", string(outContent)) } func TestOutputLatency_Ping_CI(t *testing.T) { @@ -121,11 +114,7 @@ func TestOutputLatency_Ping_CI(t *testing.T) { Tags: []string{"tag"}, }, Result: model.ResultData{ - Stats: map[string]interface{}{ - "min": 8, - "avg": 12, - "max": 20, - }, + StatsRaw: json.RawMessage(`{"min":8,"avg":12,"max":20}`), }, }, }, @@ -145,7 +134,7 @@ func TestOutputLatency_Ping_CI(t *testing.T) { outContent, err := io.ReadAll(rStdOut) assert.NoError(t, err) - assert.Equal(t, "Min: 8 ms\nMax: 20 ms\nAvg: 12 ms\n\n", string(outContent)) + assert.Equal(t, "Min: 8.00 ms\nMax: 20.00 ms\nAvg: 12.00 ms\n\n", string(outContent)) } func TestOutputLatency_DNS_Not_CI(t *testing.T) { diff --git a/view/view.go b/view/view.go index 7a819c5..8f52eea 100644 --- a/view/view.go +++ b/view/view.go @@ -1,7 +1,9 @@ package view import ( + "errors" "fmt" + "math" "os" "strconv" "strings" @@ -24,6 +26,8 @@ var ( terminalLayoutBold = lipgloss.NewStyle().Bold(true) ) +var apiPollInterval = 500 * time.Millisecond + // Used to trim the output to fit the terminal in live view func trimOutput(output string, terminalW, terminalH int) string { maxW := terminalW - 4 // 4 extra chars to be safe from overflow @@ -59,15 +63,11 @@ func trimOutput(output string, terminalW, terminalH int) string { } // Generate header that also checks if the probe has a state in it in the form %s, %s, (%s), %s, ASN:%d -func generateHeader(result model.MeasurementResponse, ctx model.Context) string { +func generateHeader(result *model.MeasurementResponse, useStyling bool) string { var output strings.Builder // Continent + Country + (State) + City + ASN + Network + (Region Tag) - output.WriteString(result.Probe.Continent + ", " + result.Probe.Country + ", ") - if result.Probe.State != "" { - output.WriteString("(" + result.Probe.State + "), ") - } - output.WriteString(result.Probe.City + ", ASN:" + fmt.Sprint(result.Probe.ASN) + ", " + result.Probe.Network) + output.WriteString(getLocationText(result)) // Check tags to see if there's a region code if len(result.Probe.Tags) > 0 { @@ -80,20 +80,17 @@ func generateHeader(result model.MeasurementResponse, ctx model.Context) string } } - headerWithFormat := formatWithLeadingArrow(ctx, output.String()) + headerWithFormat := formatWithLeadingArrow(output.String(), useStyling) return headerWithFormat } -func formatWithLeadingArrow(ctx model.Context, text string) string { - if ctx.CI { - return "> " + text - } else { +func formatWithLeadingArrow(text string, useStyling bool) string { + if useStyling { return terminalLayoutArrow + terminalLayoutHighlight.Render(text) } + return "> " + text } -var apiPollInterval = 500 * time.Millisecond - func LiveView(id string, data *model.GetMeasurement, ctx model.Context, m model.PostMeasurement) { var err error @@ -137,9 +134,10 @@ func LiveView(id string, data *model.GetMeasurement, ctx model.Context, m model. output.Reset() // Output every result in case of multiple probes - for _, result := range data.Results { + for i := range data.Results { + result := &data.Results[i] // Output slightly different format if state is available - output.WriteString(generateHeader(result, ctx) + "\n") + output.WriteString(generateHeader(result, !ctx.CI) + "\n") if isBodyOnlyHttpGet(ctx, m) { output.WriteString(strings.TrimSpace(result.Result.RawBody) + "\n\n") @@ -162,7 +160,133 @@ func LiveView(id string, data *model.GetMeasurement, ctx model.Context, m model. } } +func OutputInfinite(id string, ctx *model.Context) error { + fetcher := client.NewMeasurementsFetcher(client.ApiUrl) + res, err := fetcher.GetMeasurement(id) + if err != nil { + return err + } + // Probe may not have started yet + for len(res.Results) == 0 { + time.Sleep(apiPollInterval) + res, err = fetcher.GetMeasurement(id) + if err != nil { + return err + } + } + // Wait for results to be complete + for res.Status == "in-progress" { + time.Sleep(apiPollInterval) + res, err = fetcher.GetMeasurement(res.ID) + if err != nil { + return err + } + } + if ctx.Latency { + OutputLatency(id, res, *ctx) + return nil + } + + if ctx.JsonOutput { + OutputJson(id, fetcher, *ctx) + return nil + } + + // One location view + if len(res.Results) == 1 { + if len(ctx.Stats) == 0 { + // Initialize state + ctx.Stats = []model.MeasurementStats{ + { + Sent: ctx.Packets, + }, + } + ctx.Packets = client.PacketsMax + // Print header + fmt.Println(generateHeader(&res.Results[0], !ctx.CI)) + fmt.Printf("PING %s (%s)\n", res.Target, res.Results[0].Result.ResolvedAddress) + } + timings, err := client.DecodePingTimings(res.Results[0].Result.TimingsRaw) + if err != nil { + return err + } + for i := range timings { + ctx.Stats[0].Sent++ + t := timings[i] + fmt.Printf("%s: icmp_seq=%d ttl=%d time=%.2f ms\n", + res.Results[0].Result.ResolvedAddress, + ctx.Stats[0].Sent, + t.TTL, + t.RTT) + } + return nil + } + + // Multiple location view + if len(ctx.Stats) == 0 { + // Initialize state + ctx.Stats = make([]model.MeasurementStats, len(res.Results)) + for i := range ctx.Stats { + ctx.Stats[i].Min = math.MaxFloat64 + } + // Create new writer + ctx.Area, err = pterm.DefaultArea.Start() + if err != nil { + return errors.New("failed to start writer: " + err.Error()) + } + } + tableData := pterm.TableData{ + {"Location", "Loss", "Sent", "Last", "Avg", "Min", "Max"}, + } + for i := range res.Results { + result := &res.Results[i] + localStats := &ctx.Stats[i] + updateMeasurementStats(localStats, result) + tableData = append(tableData, []string{ + getLocationText(result), + fmt.Sprintf("%.2f", localStats.Loss) + "%", + fmt.Sprintf("%d", localStats.Sent), + fmt.Sprintf("%.2f ms", localStats.Last), + fmt.Sprintf("%.2f ms", localStats.Avg), + fmt.Sprintf("%.2f ms", localStats.Min), + fmt.Sprintf("%.2f ms", localStats.Max), + }) + } + t, err := pterm.DefaultTable.WithHasHeader().WithData(tableData).Srender() + if err != nil { + return err + } + ctx.Area.Update(t) + return err +} + +func updateMeasurementStats(localStats *model.MeasurementStats, result *model.MeasurementResponse) error { + stats, err := client.DecodePingStats(result.Result.StatsRaw) + if err != nil { + return err + } + timings, err := client.DecodePingTimings(result.Result.TimingsRaw) + if err != nil { + return err + } + localStats.Lost += stats.Drop + if stats.Min < localStats.Min { + localStats.Min = stats.Min + } + if stats.Max > localStats.Max { + localStats.Max = stats.Max + } + localStats.Avg = (localStats.Avg*float64(localStats.Sent) + stats.Avg*float64(stats.Total)) / float64(localStats.Sent+stats.Total) + localStats.Sent += stats.Total + if len(timings) != 0 { + localStats.Last = timings[len(timings)-1].RTT + } + localStats.Loss = float64(localStats.Lost) / float64(localStats.Sent) * 100 + return nil +} + // If json flag is used, only output json +// TODO: Return errors instead of printing them func OutputJson(id string, fetcher client.MeasurementsFetcher, ctx model.Context) { output, err := fetcher.GetRawMeasurement(id) if err != nil { @@ -172,21 +296,22 @@ func OutputJson(id string, fetcher client.MeasurementsFetcher, ctx model.Context fmt.Println(string(output)) if ctx.Share { - fmt.Fprintln(os.Stderr, formatWithLeadingArrow(ctx, shareMessage(id))) + fmt.Fprintln(os.Stderr, formatWithLeadingArrow(shareMessage(id), !ctx.CI)) } fmt.Println() } // Prints non-json non-latency results to the screen func PrintStandardResults(id string, data *model.GetMeasurement, ctx model.Context, m model.PostMeasurement) { - for i, result := range data.Results { + for i := range data.Results { + result := &data.Results[i] if i > 0 { // new line as separator if more than 1 result fmt.Println() } // Output slightly different format if state is available - fmt.Fprintln(os.Stderr, generateHeader(result, ctx)) + fmt.Fprintln(os.Stderr, generateHeader(result, !ctx.CI)) if isBodyOnlyHttpGet(ctx, m) { fmt.Println(strings.TrimSpace(result.Result.RawBody)) @@ -196,7 +321,7 @@ func PrintStandardResults(id string, data *model.GetMeasurement, ctx model.Conte } if ctx.Share { - fmt.Fprintln(os.Stderr, formatWithLeadingArrow(ctx, shareMessage(id))) + fmt.Fprintln(os.Stderr, formatWithLeadingArrow(shareMessage(id), !ctx.CI)) } } @@ -204,6 +329,7 @@ func isBodyOnlyHttpGet(ctx model.Context, m model.PostMeasurement) bool { return ctx.Cmd == "http" && m.Options != nil && m.Options.Request != nil && m.Options.Request.Method == "GET" && !ctx.Full } +// TODO: Return errors instead of printing them func OutputResults(id string, ctx model.Context, m model.PostMeasurement) { fetcher := client.NewMeasurementsFetcher(client.ApiUrl) @@ -213,7 +339,6 @@ func OutputResults(id string, ctx model.Context, m model.PostMeasurement) { fmt.Println(err) return } - // Probe may not have started yet for len(data.Results) == 0 { time.Sleep(apiPollInterval) @@ -259,3 +384,15 @@ func OutputResults(id string, ctx model.Context, m model.PostMeasurement) { func shareMessage(id string) string { return fmt.Sprintf("View the results online: https://www.jsdelivr.com/globalping?measurement=%s", id) } + +func getLocationText(m *model.MeasurementResponse) string { + state := "" + if m.Probe.State != "" { + state = "(" + m.Probe.State + "), " + } + return m.Probe.Continent + + ", " + m.Probe.Country + + ", " + state + m.Probe.City + + ", ASN:" + fmt.Sprint(m.Probe.ASN) + + ", " + m.Probe.Network +} diff --git a/view/view_test.go b/view/view_test.go index a378860..5da9fff 100644 --- a/view/view_test.go +++ b/view/view_test.go @@ -31,17 +31,17 @@ var ( ) func TestHeadersBase(t *testing.T) { - assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network", generateHeader(testResult, testContext)) + assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network", generateHeader(&testResult, !testContext.CI)) } func TestHeadersTags(t *testing.T) { newResult := testResult newResult.Probe.Tags = []string{"tag1", "tag2"} - assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network (tag1)", generateHeader(newResult, testContext)) + assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network (tag1)", generateHeader(&newResult, !testContext.CI)) newResult.Probe.Tags = []string{"tag", "tag2"} - assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network (tag2)", generateHeader(newResult, testContext)) + assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network (tag2)", generateHeader(&newResult, !testContext.CI)) } func TestPrintStandardResultsHTTPGet(t *testing.T) { From ca862810c46cfc7558fdde828c90a491d98bab9f Mon Sep 17 00:00:00 2001 From: Radu Lucut Date: Wed, 17 Jan 2024 17:45:36 +0200 Subject: [PATCH 03/27] Fix Sent count --- view/view.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/view/view.go b/view/view.go index 8f52eea..df65254 100644 --- a/view/view.go +++ b/view/view.go @@ -196,11 +196,7 @@ func OutputInfinite(id string, ctx *model.Context) error { if len(res.Results) == 1 { if len(ctx.Stats) == 0 { // Initialize state - ctx.Stats = []model.MeasurementStats{ - { - Sent: ctx.Packets, - }, - } + ctx.Stats = make([]model.MeasurementStats, 1) ctx.Packets = client.PacketsMax // Print header fmt.Println(generateHeader(&res.Results[0], !ctx.CI)) From b5da380419b26a85a65d3e77f0f74eb32363d21d Mon Sep 17 00:00:00 2001 From: Dmitriy Akulov Date: Wed, 17 Jan 2024 19:43:04 +0100 Subject: [PATCH 04/27] More docs --- README.md | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5697495..f66fe35 100644 --- a/README.md +++ b/README.md @@ -276,13 +276,35 @@ Max: 7.413 ms Avg: 7.359 ms ``` -#### Continuously ping +#### Continuous non-stop measurements -Use the `--infinite` flag to continuously ping a host. +> [!IMPORTANT] +> Currently this feature is limited to the ping command + +You can use the `--infinite` flag to continuously ping a host, just like on Linux or MacOS. +Note that while it looks like a single measurement, in actuality its multiple measurements from the same probes combined into a single output. +This means that eventually you will run out of credits and the test will stop. + +```bash +globalping ping cdn.jsdelivr.net from Europe --infinite +> EU, SE, Stockholm, ASN:42708, GleSYS AB +PING cdn.jsdelivr.net (151.101.1.229) +151.101.1.229: icmp_seq=1 ttl=59 time=0.85 ms +151.101.1.229: icmp_seq=2 ttl=59 time=5.86 ms +^C +``` + +If you select multiple probes when using `--infinite` the output will change to a summary comparison table. ```bash -globalping ping google.com from USA --infinite -... +globalping ping cdn.jsdelivr.net from Europe --limit 5 --infinite +Location | Loss | Sent | Last | Avg | Min | Max +EU, DE, Falkenstein, ASN:24940, Hetzner Online GmbH | 0.00% | 21 | 5.43 ms | 5.71 ms | 5.30 ms | 11.98 ms +EU, NL, Rotterdam, ASN:210630, IncogNET LLC | 0.00% | 21 | 1.76 ms | 1.81 ms | 1.76 ms | 1.96 ms +EU, LU, Luxembourg, ASN:53667, FranTech Solutions | 0.00% | 21 | 5.14 ms | 13.03 ms | 4.80 ms | 75.71 ms +EU, ES, Madrid, ASN:20473, The Constant Company, LLC | 0.00% | 21 | 0.67 ms | 0.73 ms | 0.59 ms | 1.08 ms +EU, DE, Frankfurt, ASN:16276, OVH SAS | 0.00% | 21 | 1.47 ms | 1.43 ms | 1.35 ms | 1.51 ms +^C ``` #### Learn about available flags From b9e9d4e4cfb380a35321c9d18d0d795b4958af8e Mon Sep 17 00:00:00 2001 From: Radu Lucut Date: Thu, 18 Jan 2024 18:25:58 +0200 Subject: [PATCH 05/27] Add tests + refactoring --- cmd/ping.go | 2 +- view/default.go | 33 +++ view/default_test.go | 415 ++++++++++++++++++++++++++++++++++++++ view/infinite.go | 150 ++++++++++++++ view/infinite_test.go | 167 ++++++++++++++++ view/json.go | 25 +++ view/json_test.go | 60 ++++++ view/latency.go | 20 +- view/latency_test.go | 18 +- view/utils_test.go | 118 +++++++++++ view/view.go | 342 ++++++++----------------------- view/view_test.go | 456 ------------------------------------------ 12 files changed, 1073 insertions(+), 733 deletions(-) create mode 100644 view/default.go create mode 100644 view/default_test.go create mode 100644 view/infinite.go create mode 100644 view/infinite_test.go create mode 100644 view/json.go create mode 100644 view/json_test.go create mode 100644 view/utils_test.go diff --git a/cmd/ping.go b/cmd/ping.go index abab014..1141117 100644 --- a/cmd/ping.go +++ b/cmd/ping.go @@ -49,7 +49,7 @@ Examples: return err } if ctx.Infinite { - ctx.Packets = 1 // We sent only one packet first and overwrite it later based on the view type + ctx.Packets = 1 for { ctx.From, err = ping(cmd) if err != nil { diff --git a/view/default.go b/view/default.go new file mode 100644 index 0000000..6230983 --- /dev/null +++ b/view/default.go @@ -0,0 +1,33 @@ +package view + +import ( + "fmt" + "os" + "strings" + + "github.com/jsdelivr/globalping-cli/model" +) + +// Outputs non-json non-latency results for a measurement +func OutputDefault(id string, data *model.GetMeasurement, ctx model.Context, m model.PostMeasurement) { + for i := range data.Results { + result := &data.Results[i] + if i > 0 { + // new line as separator if more than 1 result + fmt.Println() + } + + // Output slightly different format if state is available + fmt.Fprintln(os.Stderr, generateHeader(result, !ctx.CI)) + + if isBodyOnlyHttpGet(ctx, m) { + fmt.Println(strings.TrimSpace(result.Result.RawBody)) + } else { + fmt.Println(strings.TrimSpace(result.Result.RawOutput)) + } + } + + if ctx.Share { + fmt.Fprintln(os.Stderr, formatWithLeadingArrow(shareMessage(id), !ctx.CI)) + } +} diff --git a/view/default_test.go b/view/default_test.go new file mode 100644 index 0000000..7aea7ec --- /dev/null +++ b/view/default_test.go @@ -0,0 +1,415 @@ +package view + +import ( + "io" + "os" + "testing" + + "github.com/jsdelivr/globalping-cli/model" + "github.com/stretchr/testify/assert" +) + +func TestOutputDefaultHTTPGet(t *testing.T) { + osStdErr := os.Stderr + osStdOut := os.Stdout + + rStdErr, myStdErr, err := os.Pipe() + assert.NoError(t, err) + defer rStdErr.Close() + + rStdOut, myStdOut, err := os.Pipe() + assert.NoError(t, err) + defer rStdOut.Close() + + os.Stderr = myStdErr + os.Stdout = myStdOut + + defer func() { + os.Stderr = osStdErr + os.Stdout = osStdOut + }() + + ctx := model.Context{ + Cmd: "http", + CI: true, + } + + m := model.PostMeasurement{ + Options: &model.MeasurementOptions{ + Request: &model.RequestOptions{ + Method: "GET", + }, + }, + } + + id := "123abc" + + data := &model.GetMeasurement{ + Results: []model.MeasurementResponse{ + { + Probe: model.ProbeData{ + Continent: "EU", + Country: "DE", + City: "Berlin", + ASN: 123, + Network: "Network 1", + }, + Result: model.ResultData{ + RawOutput: "Headers 1\nBody 1", + RawHeaders: "Headers 1", + RawBody: "Body 1", + }, + }, + + { + Probe: model.ProbeData{ + Continent: "NA", + Country: "US", + City: "New York", + State: "NY", + ASN: 567, + Network: "Network 2", + }, + Result: model.ResultData{ + RawOutput: "Headers 2\nBody 2", + RawHeaders: "Headers 2", + RawBody: "Body 2", + }, + }, + }, + } + + OutputDefault(id, data, ctx, m) + myStdOut.Close() + myStdErr.Close() + + errContent, err := io.ReadAll(rStdErr) + assert.NoError(t, err) + assert.Equal(t, "> EU, DE, Berlin, ASN:123, Network 1\n> NA, US, (NY), New York, ASN:567, Network 2\n", string(errContent)) + + outContent, err := io.ReadAll(rStdOut) + assert.NoError(t, err) + assert.Equal(t, "Body 1\n\nBody 2\n", string(outContent)) +} + +func TestOutputDefaultHTTPGetShare(t *testing.T) { + osStdErr := os.Stderr + osStdOut := os.Stdout + + rStdErr, myStdErr, err := os.Pipe() + assert.NoError(t, err) + defer rStdErr.Close() + + rStdOut, myStdOut, err := os.Pipe() + assert.NoError(t, err) + defer rStdOut.Close() + + os.Stderr = myStdErr + os.Stdout = myStdOut + + defer func() { + os.Stderr = osStdErr + os.Stdout = osStdOut + }() + + ctx := model.Context{ + Cmd: "http", + CI: true, + Share: true, + } + + m := model.PostMeasurement{ + Options: &model.MeasurementOptions{ + Request: &model.RequestOptions{ + Method: "GET", + }, + }, + } + + id := "123abc" + + data := &model.GetMeasurement{ + Results: []model.MeasurementResponse{ + { + Probe: model.ProbeData{ + Continent: "EU", + Country: "DE", + City: "Berlin", + ASN: 123, + Network: "Network 1", + }, + Result: model.ResultData{ + RawOutput: "Headers 1\nBody 1", + RawHeaders: "Headers 1", + RawBody: "Body 1", + }, + }, + + { + Probe: model.ProbeData{ + Continent: "NA", + Country: "US", + City: "New York", + State: "NY", + ASN: 567, + Network: "Network 2", + }, + Result: model.ResultData{ + RawOutput: "Headers 2\nBody 2", + RawHeaders: "Headers 2", + RawBody: "Body 2", + }, + }, + }, + } + + OutputDefault(id, data, ctx, m) + myStdOut.Close() + myStdErr.Close() + + errContent, err := io.ReadAll(rStdErr) + assert.NoError(t, err) + assert.Equal(t, "> EU, DE, Berlin, ASN:123, Network 1\n> NA, US, (NY), New York, ASN:567, Network 2\n> View the results online: https://www.jsdelivr.com/globalping?measurement=123abc\n", string(errContent)) + + outContent, err := io.ReadAll(rStdOut) + assert.NoError(t, err) + assert.Equal(t, "Body 1\n\nBody 2\n", string(outContent)) +} + +func TestOutputDefaultHTTPGetFull(t *testing.T) { + osStdErr := os.Stderr + osStdOut := os.Stdout + + rStdErr, myStdErr, err := os.Pipe() + assert.NoError(t, err) + defer rStdErr.Close() + + rStdOut, myStdOut, err := os.Pipe() + assert.NoError(t, err) + defer rStdOut.Close() + + os.Stderr = myStdErr + os.Stdout = myStdOut + + defer func() { + os.Stderr = osStdErr + os.Stdout = osStdOut + }() + + ctx := model.Context{ + Cmd: "http", + CI: true, + Full: true, + } + + m := model.PostMeasurement{ + Options: &model.MeasurementOptions{ + Request: &model.RequestOptions{ + Method: "GET", + }, + }, + } + + id := "123abc" + + data := &model.GetMeasurement{ + Results: []model.MeasurementResponse{ + { + Probe: model.ProbeData{ + Continent: "EU", + Country: "DE", + City: "Berlin", + ASN: 123, + Network: "Network 1", + }, + Result: model.ResultData{ + RawOutput: "Headers 1\nBody 1", + RawHeaders: "Headers 1", + RawBody: "Body 1", + }, + }, + + { + Probe: model.ProbeData{ + Continent: "NA", + Country: "US", + City: "New York", + State: "NY", + ASN: 567, + Network: "Network 2", + }, + Result: model.ResultData{ + RawOutput: "Headers 2\nBody 2", + RawHeaders: "Headers 2", + RawBody: "Body 2", + }, + }, + }, + } + + OutputDefault(id, data, ctx, m) + myStdOut.Close() + myStdErr.Close() + + errContent, err := io.ReadAll(rStdErr) + assert.NoError(t, err) + assert.Equal(t, "> EU, DE, Berlin, ASN:123, Network 1\n> NA, US, (NY), New York, ASN:567, Network 2\n", string(errContent)) + + outContent, err := io.ReadAll(rStdOut) + assert.NoError(t, err) + assert.Equal(t, "Headers 1\nBody 1\n\nHeaders 2\nBody 2\n", string(outContent)) +} + +func TestOutputDefaultHTTPHead(t *testing.T) { + osStdErr := os.Stderr + osStdOut := os.Stdout + + rStdErr, myStdErr, err := os.Pipe() + assert.NoError(t, err) + defer rStdErr.Close() + + rStdOut, myStdOut, err := os.Pipe() + assert.NoError(t, err) + defer rStdOut.Close() + + os.Stderr = myStdErr + os.Stdout = myStdOut + + defer func() { + os.Stderr = osStdErr + os.Stdout = osStdOut + }() + + ctx := model.Context{ + Cmd: "http", + CI: true, + } + + m := model.PostMeasurement{ + Options: &model.MeasurementOptions{ + Request: &model.RequestOptions{ + Method: "HEAD", + }, + }, + } + + id := "123abc" + + data := &model.GetMeasurement{ + Results: []model.MeasurementResponse{ + { + Probe: model.ProbeData{ + Continent: "EU", + Country: "DE", + City: "Berlin", + ASN: 123, + Network: "Network 1", + }, + Result: model.ResultData{ + RawOutput: "Headers 1", + RawHeaders: "Headers 1", + }, + }, + + { + Probe: model.ProbeData{ + Continent: "NA", + Country: "US", + City: "New York", + State: "NY", + ASN: 567, + Network: "Network 2", + }, + Result: model.ResultData{ + RawOutput: "Headers 2", + RawHeaders: "Headers 2", + }, + }, + }, + } + + OutputDefault(id, data, ctx, m) + myStdOut.Close() + myStdErr.Close() + + errContent, err := io.ReadAll(rStdErr) + assert.NoError(t, err) + assert.Equal(t, "> EU, DE, Berlin, ASN:123, Network 1\n> NA, US, (NY), New York, ASN:567, Network 2\n", string(errContent)) + + outContent, err := io.ReadAll(rStdOut) + assert.NoError(t, err) + assert.Equal(t, "Headers 1\n\nHeaders 2\n", string(outContent)) +} + +func TestOutputDefaultPing(t *testing.T) { + osStdErr := os.Stderr + osStdOut := os.Stdout + + rStdErr, myStdErr, err := os.Pipe() + assert.NoError(t, err) + defer rStdErr.Close() + + rStdOut, myStdOut, err := os.Pipe() + assert.NoError(t, err) + defer rStdOut.Close() + + os.Stderr = myStdErr + os.Stdout = myStdOut + + defer func() { + os.Stderr = osStdErr + os.Stdout = osStdOut + }() + + ctx := model.Context{ + Cmd: "ping", + CI: true, + } + + m := model.PostMeasurement{} + + id := "123abc" + + data := &model.GetMeasurement{ + Results: []model.MeasurementResponse{ + { + Probe: model.ProbeData{ + Continent: "EU", + Country: "DE", + City: "Berlin", + ASN: 123, + Network: "Network 1", + }, + Result: model.ResultData{ + RawOutput: "Ping Results 1", + }, + }, + + { + Probe: model.ProbeData{ + Continent: "NA", + Country: "US", + City: "New York", + State: "NY", + ASN: 567, + Network: "Network 2", + }, + Result: model.ResultData{ + RawOutput: "Ping Results 2", + }, + }, + }, + } + + OutputDefault(id, data, ctx, m) + myStdOut.Close() + myStdErr.Close() + + errContent, err := io.ReadAll(rStdErr) + assert.NoError(t, err) + assert.Equal(t, "> EU, DE, Berlin, ASN:123, Network 1\n> NA, US, (NY), New York, ASN:567, Network 2\n", string(errContent)) + + outContent, err := io.ReadAll(rStdOut) + assert.NoError(t, err) + assert.Equal(t, "Ping Results 1\n\nPing Results 2\n", string(outContent)) +} diff --git a/view/infinite.go b/view/infinite.go new file mode 100644 index 0000000..fd49735 --- /dev/null +++ b/view/infinite.go @@ -0,0 +1,150 @@ +package view + +import ( + "errors" + "fmt" + "math" + "time" + + "github.com/jsdelivr/globalping-cli/client" + "github.com/jsdelivr/globalping-cli/model" + "github.com/pterm/pterm" +) + +func OutputInfinite(id string, ctx *model.Context) error { + fetcher := client.NewMeasurementsFetcher(client.ApiUrl) + res, err := fetcher.GetMeasurement(id) + if err != nil { + return err + } + // Probe may not have started yet + for len(res.Results) == 0 { + time.Sleep(apiPollInterval) + res, err = fetcher.GetMeasurement(id) + if err != nil { + return err + } + } + // Wait for results to be complete + for res.Status == "in-progress" { + time.Sleep(apiPollInterval) + res, err = fetcher.GetMeasurement(res.ID) + if err != nil { + return err + } + } + if ctx.Latency { + return OutputLatency(id, res, *ctx) + } + + if ctx.JsonOutput { + return OutputJson(id, fetcher, *ctx) + } + + if len(res.Results) == 1 { + return outputSingleLocation(res, ctx) + } + return outputMultipleLocations(res, ctx) +} + +func outputSingleLocation(res *model.GetMeasurement, ctx *model.Context) error { + measurement := &res.Results[0] + if len(ctx.Stats) == 0 { + // Initialize state + ctx.Stats = make([]model.MeasurementStats, 1) + // Print header + fmt.Println(generateHeader(measurement, !ctx.CI)) + fmt.Printf("PING %s (%s)\n", res.Target, measurement.Result.ResolvedAddress) + } + timings, err := client.DecodePingTimings(measurement.Result.TimingsRaw) + if err != nil { + return err + } + for i := range timings { + ctx.Stats[0].Sent++ + t := timings[i] + fmt.Printf("%s: icmp_seq=%d ttl=%d time=%.2f ms\n", + measurement.Result.ResolvedAddress, + ctx.Stats[0].Sent, + t.TTL, + t.RTT) + } + return nil +} + +func outputMultipleLocations(res *model.GetMeasurement, ctx *model.Context) error { + var err error + if len(ctx.Stats) == 0 { + // Initialize state + ctx.Stats = make([]model.MeasurementStats, len(res.Results)) + for i := range ctx.Stats { + ctx.Stats[i].Min = math.MaxFloat64 + } + // Create new writer + ctx.Area, err = pterm.DefaultArea.Start() + if err != nil { + return errors.New("failed to start writer: " + err.Error()) + } + } + tableData := pterm.TableData{ + {"Location", "Loss", "Sent", "Last", "Avg", "Min", "Max"}, + } + for i := range res.Results { + result := &res.Results[i] + localStats := &ctx.Stats[i] + updateMeasurementStats(localStats, result) + tableData = append(tableData, []string{ + getLocationText(result), + fmt.Sprintf("%.2f", localStats.Loss) + "%", + fmt.Sprintf("%d", localStats.Sent), + formatDuration(localStats.Last), + formatDuration(localStats.Avg), + formatDuration(localStats.Min), + formatDuration(localStats.Max), + }) + } + t, err := pterm.DefaultTable.WithHasHeader().WithData(tableData).Srender() + if err != nil { + return err + } + ctx.Area.Update(t) + + return nil +} + +func formatDuration(ms float64) string { + if ms < 10 { + return fmt.Sprintf("%.2f ms", ms) + } + if ms < 100 { + return fmt.Sprintf("%.1f ms", ms) + } + return fmt.Sprintf("%.0f ms", ms) +} + +func updateMeasurementStats(localStats *model.MeasurementStats, result *model.MeasurementResponse) error { + stats, err := client.DecodePingStats(result.Result.StatsRaw) + if err != nil { + return err + } + timings, err := client.DecodePingTimings(result.Result.TimingsRaw) + if err != nil { + return err + } + if stats.Min < localStats.Min && stats.Min != 0 { + localStats.Min = stats.Min + } + if stats.Max > localStats.Max { + localStats.Max = stats.Max + } + if stats.Avg != 0 { + localStats.Avg = (localStats.Avg*float64(localStats.Sent) + stats.Avg*float64(stats.Total)) / float64(localStats.Sent+stats.Total) + } + if len(timings) != 0 { + localStats.Last = timings[len(timings)-1].RTT + } + localStats.Sent += stats.Total + localStats.Lost += stats.Drop + localStats.Loss = float64(localStats.Lost) / float64(localStats.Sent) * 100 + return nil +} diff --git a/view/infinite_test.go b/view/infinite_test.go new file mode 100644 index 0000000..e7710b5 --- /dev/null +++ b/view/infinite_test.go @@ -0,0 +1,167 @@ +package view + +import ( + "encoding/json" + "io" + "os" + "testing" + + "github.com/jsdelivr/globalping-cli/model" + "github.com/pterm/pterm" + "github.com/stretchr/testify/assert" +) + +func TestOutputInfinite_SingleLocation(t *testing.T) { + osStdErr := os.Stderr + osStdOut := os.Stdout + + rStdErr, myStdErr, err := os.Pipe() + assert.NoError(t, err) + defer rStdErr.Close() + + rStdOut, myStdOut, err := os.Pipe() + assert.NoError(t, err) + defer rStdOut.Close() + + os.Stderr = myStdErr + os.Stdout = myStdOut + + defer func() { + os.Stderr = osStdErr + os.Stdout = osStdOut + }() + + ctx := &model.Context{ + Cmd: "ping", + } + measurement := getPingGetMeasurement(MeasurementID1) + + err = outputSingleLocation(measurement, ctx) + assert.NoError(t, err) + + err = outputSingleLocation(measurement, ctx) + assert.NoError(t, err) + err = outputSingleLocation(measurement, ctx) + assert.NoError(t, err) + + myStdErr.Close() + myStdOut.Close() + + errOutput, err := io.ReadAll(rStdErr) + assert.NoError(t, err) + assert.Equal(t, "", string(errOutput)) + + output, err := io.ReadAll(rStdOut) + assert.NoError(t, err) + assert.Equal(t, + `> EU, DE, Berlin, ASN:3320, Deutsche Telekom AG +PING cdn.jsdelivr.net (151.101.1.229) +151.101.1.229: icmp_seq=1 ttl=60 time=17.64 ms +151.101.1.229: icmp_seq=2 ttl=60 time=17.64 ms +151.101.1.229: icmp_seq=3 ttl=60 time=17.64 ms +`, + string(output)) +} + +func TestOutputInfinite_MultipleLocations(t *testing.T) { + osStdErr := os.Stderr + osStdOut := os.Stdout + + rStdErr, myStdErr, err := os.Pipe() + assert.NoError(t, err) + defer rStdErr.Close() + + rStdOut, myStdOut, err := os.Pipe() + assert.NoError(t, err) + defer rStdOut.Close() + + os.Stderr = myStdErr + os.Stdout = myStdOut + + defer func() { + os.Stderr = osStdErr + os.Stdout = osStdOut + }() + + ctx := &model.Context{ + Cmd: "ping", + } + measurement := getPingGetMeasurementMultipleLocations(MeasurementID1) + + err = outputMultipleLocations(measurement, ctx) + assert.NoError(t, err) + + myStdErr.Close() + myStdOut.Close() + + errOutput, err := io.ReadAll(rStdErr) + assert.NoError(t, err) + assert.Equal(t, "", string(errOutput)) + + output, err := io.ReadAll(rStdOut) + assert.NoError(t, err) + + expectedTableData := pterm.TableData{ + {"Location", "Loss", "Sent", "Last", "Avg", "Min", "Max"}, + {"EU, GB, London, ASN:0, OVH SAS", "0.00%", "1", "0.77 ms", "0.77 ms", "0.77 ms", "0.77 ms"}, + {"EU, DE, Falkenstein, ASN:0, Hetzner Online GmbH", "0.00%", "1", "5.46 ms", "5.46 ms", "5.46 ms", "5.46 ms"}, + {"EU, DE, Nuremberg, ASN:0, Hetzner Online GmbH", "0.00%", "1", "4.07 ms", "4.07 ms", "4.07 ms", "4.07 ms"}, + } + expectedTable, _ := pterm.DefaultTable.WithHasHeader().WithData(expectedTableData).Srender() + assert.Equal(t, "\n\n\n\n\n\n\n"+expectedTable+"\n", string(output)) +} + +func TestFormatDuration(t *testing.T) { + d := formatDuration(1.2345) + assert.Equal(t, "1.23 ms", d) + d = formatDuration(12.345) + assert.Equal(t, "12.3 ms", d) + d = formatDuration(123.4567) + assert.Equal(t, "123 ms", d) +} + +func TestUpdateMeasurementStats(t *testing.T) { + stats := model.MeasurementStats{ + Sent: 2, + Lost: 0, + Loss: 0, + Last: 1, + Min: 1, + Avg: 1.5, + Max: 2, + } + result := model.MeasurementResponse{ + Result: model.ResultData{ + StatsRaw: json.RawMessage(`{"min":6,"avg":6,"max":6,"total":1,"rcv":1,"drop":0,"loss":0}`), + TimingsRaw: json.RawMessage(`[{"ttl":60,"rtt":6}]`), + }, + } + err := updateMeasurementStats(&stats, &result) + assert.NoError(t, err) + assert.Equal(t, model.MeasurementStats{ + Sent: 3, + Lost: 0, + Loss: 0, + Last: 6, + Min: 1, + Avg: 3, + Max: 6, + }, stats) + result = model.MeasurementResponse{ + Result: model.ResultData{ + StatsRaw: json.RawMessage(`{"min":0,"avg":0,"max":0,"total":1,"rcv":0,"drop":1,"loss":100}`), + TimingsRaw: json.RawMessage(`[]`), + }, + } + err = updateMeasurementStats(&stats, &result) + assert.NoError(t, err) + assert.Equal(t, model.MeasurementStats{ + Sent: 4, + Lost: 1, + Loss: 25, + Last: 6, + Min: 1, + Avg: 3, + Max: 6, + }, stats) +} diff --git a/view/json.go b/view/json.go new file mode 100644 index 0000000..5cb1f42 --- /dev/null +++ b/view/json.go @@ -0,0 +1,25 @@ +package view + +import ( + "fmt" + "os" + + "github.com/jsdelivr/globalping-cli/client" + "github.com/jsdelivr/globalping-cli/model" +) + +// Outputs the raw JSON for a measurement +func OutputJson(id string, fetcher client.MeasurementsFetcher, ctx model.Context) error { + output, err := fetcher.GetRawMeasurement(id) + if err != nil { + return err + } + fmt.Println(string(output)) + + if ctx.Share { + fmt.Fprintln(os.Stderr, formatWithLeadingArrow(shareMessage(id), !ctx.CI)) + } + fmt.Println() + + return nil +} diff --git a/view/json_test.go b/view/json_test.go new file mode 100644 index 0000000..95b2942 --- /dev/null +++ b/view/json_test.go @@ -0,0 +1,60 @@ +package view + +import ( + "io" + "os" + "testing" + + "github.com/golang/mock/gomock" + "github.com/jsdelivr/globalping-cli/mocks" + "github.com/jsdelivr/globalping-cli/model" + "github.com/stretchr/testify/assert" +) + +func TestOutputJson(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + id := "my-id" + + b := []byte(`{"fake": "results"}`) + + fetcher := mocks.NewMockMeasurementsFetcher(ctrl) + fetcher.EXPECT().GetRawMeasurement(id).Times(1).Return(b, nil) + + ctx := model.Context{ + JsonOutput: true, + Share: true, + } + osStdErr := os.Stderr + osStdOut := os.Stdout + + rStdErr, myStdErr, err := os.Pipe() + assert.NoError(t, err) + defer rStdErr.Close() + + rStdOut, myStdOut, err := os.Pipe() + assert.NoError(t, err) + defer rStdOut.Close() + + os.Stderr = myStdErr + os.Stdout = myStdOut + + defer func() { + os.Stderr = osStdErr + os.Stdout = osStdOut + }() + + err = OutputJson(id, fetcher, ctx) + assert.NoError(t, err) + myStdOut.Close() + myStdErr.Close() + + errContent, err := io.ReadAll(rStdErr) + assert.NoError(t, err) + assert.Equal(t, "> View the results online: https://www.jsdelivr.com/globalping?measurement=my-id\n", string(errContent)) + + outContent, err := io.ReadAll(rStdOut) + assert.NoError(t, err) + assert.Equal(t, "{\"fake\": \"results\"}\n\n", string(outContent)) +} diff --git a/view/latency.go b/view/latency.go index 45eddb2..c531bae 100644 --- a/view/latency.go +++ b/view/latency.go @@ -1,6 +1,7 @@ package view import ( + "errors" "fmt" "os" @@ -8,9 +9,8 @@ import ( "github.com/jsdelivr/globalping-cli/model" ) -// Output latency values -// TODO: return errors instead of printing them -func OutputLatency(id string, data *model.GetMeasurement, ctx model.Context) { +// Outputs the latency stats for a measurement +func OutputLatency(id string, data *model.GetMeasurement, ctx model.Context) error { // Output every result in case of multiple probes for i, result := range data.Results { if i > 0 { @@ -24,8 +24,7 @@ func OutputLatency(id string, data *model.GetMeasurement, ctx model.Context) { case "ping": stats, err := client.DecodePingStats(result.Result.StatsRaw) if err != nil { - fmt.Println(err) - return + return err } fmt.Println(latencyStatHeader("Min", ctx.CI) + fmt.Sprintf("%.2f ms", stats.Min)) fmt.Println(latencyStatHeader("Max", ctx.CI) + fmt.Sprintf("%.2f ms", stats.Max)) @@ -33,15 +32,13 @@ func OutputLatency(id string, data *model.GetMeasurement, ctx model.Context) { case "dns": timings, err := client.DecodeDNSTimings(result.Result.TimingsRaw) if err != nil { - fmt.Println(err) - return + return err } fmt.Println(latencyStatHeader("Total", ctx.CI) + fmt.Sprintf("%v ms", timings.Total)) case "http": timings, err := client.DecodeHTTPTimings(result.Result.TimingsRaw) if err != nil { - fmt.Println(err) - return + return err } fmt.Println(latencyStatHeader("Total", ctx.CI) + fmt.Sprintf("%v ms", timings.Total)) fmt.Println(latencyStatHeader("Download", ctx.CI) + fmt.Sprintf("%v ms", timings.Download)) @@ -50,15 +47,16 @@ func OutputLatency(id string, data *model.GetMeasurement, ctx model.Context) { fmt.Println(latencyStatHeader("TLS", ctx.CI) + fmt.Sprintf("%v ms", timings.TLS)) fmt.Println(latencyStatHeader("TCP", ctx.CI) + fmt.Sprintf("%v ms", timings.TCP)) default: - panic("unexpected command for latency output: " + ctx.Cmd) + return errors.New("unexpected command for latency output: " + ctx.Cmd) } - } if ctx.Share { fmt.Fprintln(os.Stderr, formatWithLeadingArrow(shareMessage(id), !ctx.CI)) } fmt.Println() + + return nil } func latencyStatHeader(title string, ci bool) string { diff --git a/view/latency_test.go b/view/latency_test.go index 1cbe371..415eb23 100644 --- a/view/latency_test.go +++ b/view/latency_test.go @@ -67,7 +67,8 @@ func TestOutputLatency_Ping_Not_CI(t *testing.T) { Cmd: "ping", } - OutputLatency(id, data, ctx) + err = OutputLatency(id, data, ctx) + assert.NoError(t, err) myStdOut.Close() myStdErr.Close() @@ -124,7 +125,8 @@ func TestOutputLatency_Ping_CI(t *testing.T) { CI: true, } - OutputLatency(id, data, ctx) + err = OutputLatency(id, data, ctx) + assert.NoError(t, err) myStdOut.Close() myStdErr.Close() @@ -180,7 +182,8 @@ func TestOutputLatency_DNS_Not_CI(t *testing.T) { Cmd: "dns", } - OutputLatency(id, data, ctx) + err = OutputLatency(id, data, ctx) + assert.NoError(t, err) myStdOut.Close() myStdErr.Close() @@ -237,7 +240,8 @@ func TestOutputLatency_DNS_CI(t *testing.T) { CI: true, } - OutputLatency(id, data, ctx) + err = OutputLatency(id, data, ctx) + assert.NoError(t, err) myStdOut.Close() myStdErr.Close() @@ -293,7 +297,8 @@ func TestOutputLatency_Http_Not_CI(t *testing.T) { Cmd: "http", } - OutputLatency(id, data, ctx) + err = OutputLatency(id, data, ctx) + assert.NoError(t, err) myStdOut.Close() myStdErr.Close() @@ -350,7 +355,8 @@ func TestOutputLatency_Http_CI(t *testing.T) { CI: true, } - OutputLatency(id, data, ctx) + err = OutputLatency(id, data, ctx) + assert.NoError(t, err) myStdOut.Close() myStdErr.Close() diff --git a/view/utils_test.go b/view/utils_test.go new file mode 100644 index 0000000..b235bb2 --- /dev/null +++ b/view/utils_test.go @@ -0,0 +1,118 @@ +package view + +import ( + "encoding/json" + + "github.com/jsdelivr/globalping-cli/model" +) + +var ( + MeasurementID1 = "nzGzfAGL7sZfUs3c" + MeasurementID2 = "A2ZfUs3cnzGzfAGL" + MeasurementID3 = "7sZfUs3cnzGz1I20" +) + +func getPingGetMeasurement(id string) *model.GetMeasurement { + return &model.GetMeasurement{ + ID: id, + Type: "ping", + Status: "finished", + CreatedAt: "2024-01-18T14:09:41.250Z", + UpdatedAt: "2024-01-18T14:09:41.488Z", + Target: "cdn.jsdelivr.net", + ProbesCount: 1, + Results: []model.MeasurementResponse{ + { + Probe: model.ProbeData{ + Continent: "EU", + Region: "Western Europe", + Country: "DE", + State: "", + City: "Berlin", + ASN: 3320, + Network: "Deutsche Telekom AG", + Tags: []string{"eyeball-network"}, + }, + Result: model.ResultData{ + Status: "finished", + RawOutput: "PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data.\n64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=60 time=17.6 ms\n\n--- jsdelivr.map.fastly.net ping statistics ---\n1 packets transmitted, 1 received, 0% packet loss, time 0ms\nrtt min/avg/max/mdev = 17.639/17.639/17.639/0.000 ms", + ResolvedAddress: "151.101.1.229", + ResolvedHostname: "151.101.1.229", + StatsRaw: json.RawMessage(`{"min":17.639,"avg":17.639,"max":17.639,"total":1,"rcv":1,"drop":0,"loss":0}`), + TimingsRaw: json.RawMessage(`[{"ttl":60,"rtt":17.639}]`), + }, + }, + }, + } +} + +func getPingGetMeasurementMultipleLocations(id string) *model.GetMeasurement { + return &model.GetMeasurement{ + ID: id, + Type: "ping", + Status: "finished", + CreatedAt: "2024-01-18T14:17:41.471Z", + UpdatedAt: "2024-01-18T14:17:41.571Z", + Target: "cdn.jsdelivr.net", + ProbesCount: 3, + Results: []model.MeasurementResponse{ + { + Probe: model.ProbeData{ + Continent: "EU", + Region: "Northern Europe", + Country: "GB", + State: "", + City: "London", + Network: "OVH SAS", + Tags: []string{"datacenter-network"}, + }, + Result: model.ResultData{ + Status: "finished", + RawOutput: "PING (146.75.73.229) 56(84) bytes of data.\n64 bytes from 146.75.73.229 (146.75.73.229): icmp_seq=1 ttl=52 time=0.770 ms\n\n--- ping statistics ---\n1 packets transmitted, 1 received, 0% packet loss, time 0ms\nrtt min/avg/max/mdev = 0.770/0.770/0.770/0.000 ms", + ResolvedAddress: "146.75.73.229", + ResolvedHostname: "146.75.73.229", + StatsRaw: json.RawMessage(`{"min":0.77,"avg":0.77,"max":0.77,"total":1,"rcv":1,"drop":0,"loss":0}`), + TimingsRaw: json.RawMessage(`[{"ttl":52,"rtt":0.77}]`), + }, + }, + { + Probe: model.ProbeData{ + Continent: "EU", + Region: "Western Europe", + Country: "DE", + State: "", + City: "Falkenstein", + Network: "Hetzner Online GmbH", + Tags: []string{"datacenter-network"}, + }, + Result: model.ResultData{ + Status: "finished", + RawOutput: "PING (104.16.85.20) 56(84) bytes of data.\n64 bytes from 104.16.85.20 (104.16.85.20): icmp_seq=1 ttl=55 time=5.46 ms\n\n--- ping statistics ---\n1 packets transmitted, 1 received, 0% packet loss, time 0ms\nrtt min/avg/max/mdev = 5.457/5.457/5.457/0.000 ms", + ResolvedAddress: "104.16.85.20", + ResolvedHostname: "104.16.85.20", + StatsRaw: json.RawMessage(`{"min":5.457,"avg":5.457,"max":5.457,"total":1,"rcv":1,"drop":0,"loss":0}`), + TimingsRaw: json.RawMessage(`[{"ttl":55,"rtt":5.46}]`), + }, + }, + { + Probe: model.ProbeData{ + Continent: "EU", + Region: "Western Europe", + Country: "DE", + State: "", + City: "Nuremberg", + Network: "Hetzner Online GmbH", + Tags: []string{"datacenter-network"}, + }, + Result: model.ResultData{ + Status: "finished", + RawOutput: "PING (104.16.88.20) 56(84) bytes of data.\n64 bytes from 104.16.88.20 (104.16.88.20): icmp_seq=1 ttl=58 time=4.07 ms\n\n--- ping statistics ---\n1 packets transmitted, 1 received, 0% packet loss, time 0ms\nrtt min/avg/max/mdev = 4.069/4.069/4.069/0.000 ms", + ResolvedAddress: "104.16.88.20", + ResolvedHostname: "104.16.88.20", + StatsRaw: json.RawMessage(`{"min":4.069,"avg":4.069,"max":4.069,"total":1,"rcv":1,"drop":0,"loss":0}`), + TimingsRaw: json.RawMessage(`[{"ttl":58,"rtt":4.07}]`), + }, + }, + }, + } +} diff --git a/view/view.go b/view/view.go index df65254..9332644 100644 --- a/view/view.go +++ b/view/view.go @@ -1,10 +1,7 @@ package view import ( - "errors" "fmt" - "math" - "os" "strconv" "strings" "time" @@ -28,77 +25,57 @@ var ( var apiPollInterval = 500 * time.Millisecond -// Used to trim the output to fit the terminal in live view -func trimOutput(output string, terminalW, terminalH int) string { - maxW := terminalW - 4 // 4 extra chars to be safe from overflow - maxH := terminalH - 4 // 4 extra lines to be safe from overflow - - if maxW <= 0 || maxH <= 0 { - panic("terminal width / height too limited to display results") - } - - text := strings.ReplaceAll(output, "\t", " ") - - // Split output into lines - lines := strings.Split(text, "\n") +func OutputResults(id string, ctx model.Context, m model.PostMeasurement) error { + fetcher := client.NewMeasurementsFetcher(client.ApiUrl) - if len(lines) > maxH { - // too many lines, trim first lines - lines = lines[len(lines)-maxH:] + // Wait for first result to arrive from a probe before starting display (can be in-progress) + data, err := fetcher.GetMeasurement(id) + if err != nil { + return err } - - for i := 0; i < len(lines); i++ { - rWidth := runewidth.StringWidth(lines[i]) - if rWidth > maxW { - line := lines[i] - trimmedLine := string(lines[i][:len(line)-rWidth+maxW]) - lines[i] = trimmedLine + // Probe may not have started yet + for len(data.Results) == 0 { + time.Sleep(apiPollInterval) + data, err = fetcher.GetMeasurement(id) + if err != nil { + return err } } - // Join lines back into a string - txt := strings.Join(lines, "\n") - - return txt -} + if ctx.CI || ctx.JsonOutput || ctx.Latency { + // Poll API until the measurement is complete + for data.Status == "in-progress" { + time.Sleep(apiPollInterval) + data, err = fetcher.GetMeasurement(id) + if err != nil { + return err + } + } -// Generate header that also checks if the probe has a state in it in the form %s, %s, (%s), %s, ASN:%d -func generateHeader(result *model.MeasurementResponse, useStyling bool) string { - var output strings.Builder + if ctx.Latency { + return OutputLatency(id, data, ctx) + } - // Continent + Country + (State) + City + ASN + Network + (Region Tag) - output.WriteString(getLocationText(result)) + if ctx.JsonOutput { + return OutputJson(id, fetcher, ctx) + } - // Check tags to see if there's a region code - if len(result.Probe.Tags) > 0 { - for _, tag := range result.Probe.Tags { - // If tag ends in a number, it's likely a region code and should be displayed - if _, err := strconv.Atoi(tag[len(tag)-1:]); err == nil { - output.WriteString(" (" + tag + ")") - break - } + if ctx.CI { + OutputDefault(id, data, ctx, m) + return nil } } - headerWithFormat := formatWithLeadingArrow(output.String(), useStyling) - return headerWithFormat -} - -func formatWithLeadingArrow(text string, useStyling bool) string { - if useStyling { - return terminalLayoutArrow + terminalLayoutHighlight.Render(text) - } - return "> " + text + return liveView(id, data, ctx, m) } -func LiveView(id string, data *model.GetMeasurement, ctx model.Context, m model.PostMeasurement) { +func liveView(id string, data *model.GetMeasurement, ctx model.Context, m model.PostMeasurement) error { var err error // Create new writer areaPrinter, err := pterm.DefaultArea.Start() if err != nil { - fmt.Printf("failed to start writer: %v\n", err) - return + return fmt.Errorf("failed to start writer: %v", err) } areaPrinter.RemoveWhenDone = true @@ -106,14 +83,13 @@ func LiveView(id string, data *model.GetMeasurement, ctx model.Context, m model. // Stop area printer and clear area if not already done err := areaPrinter.Stop() if err != nil { - fmt.Printf("failed to stop writer: %v\n", err) + fmt.Printf("failed to stop writer: %v", err) } }() w, h, err := pterm.GetTerminalSize() if err != nil { - fmt.Printf("failed to get terminal size: %v\n", err) - return + return fmt.Errorf("failed to get terminal size: %v", err) } // String builder for output @@ -126,8 +102,7 @@ func LiveView(id string, data *model.GetMeasurement, ctx model.Context, m model. time.Sleep(apiPollInterval) data, err = fetcher.GetMeasurement(id) if err != nil { - fmt.Printf("failed to get data: %v\n", err) - return + return fmt.Errorf("failed to get data: %v", err) } // Reset string builder @@ -152,229 +127,78 @@ func LiveView(id string, data *model.GetMeasurement, ctx model.Context, m model. // Stop area printer and clear area err = areaPrinter.Stop() if err != nil { - fmt.Printf("failed to stop writer: %v\n", err) + return fmt.Errorf("failed to stop writer: %v", err) } - if os.Getenv("LIVE_DEBUG") != "1" { - PrintStandardResults(id, data, ctx, m) - } + OutputDefault(id, data, ctx, m) + return nil } -func OutputInfinite(id string, ctx *model.Context) error { - fetcher := client.NewMeasurementsFetcher(client.ApiUrl) - res, err := fetcher.GetMeasurement(id) - if err != nil { - return err - } - // Probe may not have started yet - for len(res.Results) == 0 { - time.Sleep(apiPollInterval) - res, err = fetcher.GetMeasurement(id) - if err != nil { - return err - } - } - // Wait for results to be complete - for res.Status == "in-progress" { - time.Sleep(apiPollInterval) - res, err = fetcher.GetMeasurement(res.ID) - if err != nil { - return err - } - } - if ctx.Latency { - OutputLatency(id, res, *ctx) - return nil - } - - if ctx.JsonOutput { - OutputJson(id, fetcher, *ctx) - return nil - } - - // One location view - if len(res.Results) == 1 { - if len(ctx.Stats) == 0 { - // Initialize state - ctx.Stats = make([]model.MeasurementStats, 1) - ctx.Packets = client.PacketsMax - // Print header - fmt.Println(generateHeader(&res.Results[0], !ctx.CI)) - fmt.Printf("PING %s (%s)\n", res.Target, res.Results[0].Result.ResolvedAddress) - } - timings, err := client.DecodePingTimings(res.Results[0].Result.TimingsRaw) - if err != nil { - return err - } - for i := range timings { - ctx.Stats[0].Sent++ - t := timings[i] - fmt.Printf("%s: icmp_seq=%d ttl=%d time=%.2f ms\n", - res.Results[0].Result.ResolvedAddress, - ctx.Stats[0].Sent, - t.TTL, - t.RTT) - } - return nil - } +// Used to trim the output to fit the terminal in live view +func trimOutput(output string, terminalW, terminalH int) string { + maxW := terminalW - 4 // 4 extra chars to be safe from overflow + maxH := terminalH - 4 // 4 extra lines to be safe from overflow - // Multiple location view - if len(ctx.Stats) == 0 { - // Initialize state - ctx.Stats = make([]model.MeasurementStats, len(res.Results)) - for i := range ctx.Stats { - ctx.Stats[i].Min = math.MaxFloat64 - } - // Create new writer - ctx.Area, err = pterm.DefaultArea.Start() - if err != nil { - return errors.New("failed to start writer: " + err.Error()) - } - } - tableData := pterm.TableData{ - {"Location", "Loss", "Sent", "Last", "Avg", "Min", "Max"}, - } - for i := range res.Results { - result := &res.Results[i] - localStats := &ctx.Stats[i] - updateMeasurementStats(localStats, result) - tableData = append(tableData, []string{ - getLocationText(result), - fmt.Sprintf("%.2f", localStats.Loss) + "%", - fmt.Sprintf("%d", localStats.Sent), - fmt.Sprintf("%.2f ms", localStats.Last), - fmt.Sprintf("%.2f ms", localStats.Avg), - fmt.Sprintf("%.2f ms", localStats.Min), - fmt.Sprintf("%.2f ms", localStats.Max), - }) - } - t, err := pterm.DefaultTable.WithHasHeader().WithData(tableData).Srender() - if err != nil { - return err + if maxW <= 0 || maxH <= 0 { + panic("terminal width / height too limited to display results") } - ctx.Area.Update(t) - return err -} -func updateMeasurementStats(localStats *model.MeasurementStats, result *model.MeasurementResponse) error { - stats, err := client.DecodePingStats(result.Result.StatsRaw) - if err != nil { - return err - } - timings, err := client.DecodePingTimings(result.Result.TimingsRaw) - if err != nil { - return err - } - localStats.Lost += stats.Drop - if stats.Min < localStats.Min { - localStats.Min = stats.Min - } - if stats.Max > localStats.Max { - localStats.Max = stats.Max - } - localStats.Avg = (localStats.Avg*float64(localStats.Sent) + stats.Avg*float64(stats.Total)) / float64(localStats.Sent+stats.Total) - localStats.Sent += stats.Total - if len(timings) != 0 { - localStats.Last = timings[len(timings)-1].RTT - } - localStats.Loss = float64(localStats.Lost) / float64(localStats.Sent) * 100 - return nil -} + text := strings.ReplaceAll(output, "\t", " ") -// If json flag is used, only output json -// TODO: Return errors instead of printing them -func OutputJson(id string, fetcher client.MeasurementsFetcher, ctx model.Context) { - output, err := fetcher.GetRawMeasurement(id) - if err != nil { - fmt.Println(err) - return - } - fmt.Println(string(output)) + // Split output into lines + lines := strings.Split(text, "\n") - if ctx.Share { - fmt.Fprintln(os.Stderr, formatWithLeadingArrow(shareMessage(id), !ctx.CI)) + if len(lines) > maxH { + // too many lines, trim first lines + lines = lines[len(lines)-maxH:] } - fmt.Println() -} - -// Prints non-json non-latency results to the screen -func PrintStandardResults(id string, data *model.GetMeasurement, ctx model.Context, m model.PostMeasurement) { - for i := range data.Results { - result := &data.Results[i] - if i > 0 { - // new line as separator if more than 1 result - fmt.Println() - } - - // Output slightly different format if state is available - fmt.Fprintln(os.Stderr, generateHeader(result, !ctx.CI)) - if isBodyOnlyHttpGet(ctx, m) { - fmt.Println(strings.TrimSpace(result.Result.RawBody)) - } else { - fmt.Println(strings.TrimSpace(result.Result.RawOutput)) + for i := 0; i < len(lines); i++ { + rWidth := runewidth.StringWidth(lines[i]) + if rWidth > maxW { + line := lines[i] + trimmedLine := string(lines[i][:len(line)-rWidth+maxW]) + lines[i] = trimmedLine } } - if ctx.Share { - fmt.Fprintln(os.Stderr, formatWithLeadingArrow(shareMessage(id), !ctx.CI)) - } -} + // Join lines back into a string + txt := strings.Join(lines, "\n") -func isBodyOnlyHttpGet(ctx model.Context, m model.PostMeasurement) bool { - return ctx.Cmd == "http" && m.Options != nil && m.Options.Request != nil && m.Options.Request.Method == "GET" && !ctx.Full + return txt } -// TODO: Return errors instead of printing them -func OutputResults(id string, ctx model.Context, m model.PostMeasurement) { - fetcher := client.NewMeasurementsFetcher(client.ApiUrl) +// Generate header that also checks if the probe has a state in it in the form %s, %s, (%s), %s, ASN:%d +func generateHeader(result *model.MeasurementResponse, useStyling bool) string { + var output strings.Builder - // Wait for first result to arrive from a probe before starting display (can be in-progress) - data, err := fetcher.GetMeasurement(id) - if err != nil { - fmt.Println(err) - return - } - // Probe may not have started yet - for len(data.Results) == 0 { - time.Sleep(apiPollInterval) - data, err = fetcher.GetMeasurement(id) - if err != nil { - fmt.Println(err) - return - } - } + // Continent + Country + (State) + City + ASN + Network + (Region Tag) + output.WriteString(getLocationText(result)) - if ctx.CI || ctx.JsonOutput || ctx.Latency { - // Poll API until the measurement is complete - for data.Status == "in-progress" { - time.Sleep(apiPollInterval) - data, err = fetcher.GetMeasurement(id) - if err != nil { - fmt.Println(err) - return + // Check tags to see if there's a region code + if len(result.Probe.Tags) > 0 { + for _, tag := range result.Probe.Tags { + // If tag ends in a number, it's likely a region code and should be displayed + if _, err := strconv.Atoi(tag[len(tag)-1:]); err == nil { + output.WriteString(" (" + tag + ")") + break } } + } - if ctx.Latency { - OutputLatency(id, data, ctx) - return - } - - if ctx.JsonOutput { - OutputJson(id, fetcher, ctx) - return - } - - if ctx.CI { - PrintStandardResults(id, data, ctx, m) - return - } + headerWithFormat := formatWithLeadingArrow(output.String(), useStyling) + return headerWithFormat +} - panic(fmt.Sprintf("case not handled. %+v", ctx)) +func formatWithLeadingArrow(text string, useStyling bool) string { + if useStyling { + return terminalLayoutArrow + terminalLayoutHighlight.Render(text) } + return "> " + text +} - LiveView(id, data, ctx, m) +func isBodyOnlyHttpGet(ctx model.Context, m model.PostMeasurement) bool { + return ctx.Cmd == "http" && m.Options != nil && m.Options.Request != nil && m.Options.Request.Method == "GET" && !ctx.Full } func shareMessage(id string) string { diff --git a/view/view_test.go b/view/view_test.go index 5da9fff..8006c76 100644 --- a/view/view_test.go +++ b/view/view_test.go @@ -1,12 +1,8 @@ package view import ( - "io" - "os" "testing" - "github.com/golang/mock/gomock" - "github.com/jsdelivr/globalping-cli/mocks" "github.com/jsdelivr/globalping-cli/model" "github.com/stretchr/testify/assert" ) @@ -44,411 +40,6 @@ func TestHeadersTags(t *testing.T) { assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network (tag2)", generateHeader(&newResult, !testContext.CI)) } -func TestPrintStandardResultsHTTPGet(t *testing.T) { - osStdErr := os.Stderr - osStdOut := os.Stdout - - rStdErr, myStdErr, err := os.Pipe() - assert.NoError(t, err) - defer rStdErr.Close() - - rStdOut, myStdOut, err := os.Pipe() - assert.NoError(t, err) - defer rStdOut.Close() - - os.Stderr = myStdErr - os.Stdout = myStdOut - - defer func() { - os.Stderr = osStdErr - os.Stdout = osStdOut - }() - - ctx := model.Context{ - Cmd: "http", - CI: true, - } - - m := model.PostMeasurement{ - Options: &model.MeasurementOptions{ - Request: &model.RequestOptions{ - Method: "GET", - }, - }, - } - - id := "123abc" - - data := &model.GetMeasurement{ - Results: []model.MeasurementResponse{ - { - Probe: model.ProbeData{ - Continent: "EU", - Country: "DE", - City: "Berlin", - ASN: 123, - Network: "Network 1", - }, - Result: model.ResultData{ - RawOutput: "Headers 1\nBody 1", - RawHeaders: "Headers 1", - RawBody: "Body 1", - }, - }, - - { - Probe: model.ProbeData{ - Continent: "NA", - Country: "US", - City: "New York", - State: "NY", - ASN: 567, - Network: "Network 2", - }, - Result: model.ResultData{ - RawOutput: "Headers 2\nBody 2", - RawHeaders: "Headers 2", - RawBody: "Body 2", - }, - }, - }, - } - - PrintStandardResults(id, data, ctx, m) - myStdOut.Close() - myStdErr.Close() - - errContent, err := io.ReadAll(rStdErr) - assert.NoError(t, err) - assert.Equal(t, "> EU, DE, Berlin, ASN:123, Network 1\n> NA, US, (NY), New York, ASN:567, Network 2\n", string(errContent)) - - outContent, err := io.ReadAll(rStdOut) - assert.NoError(t, err) - assert.Equal(t, "Body 1\n\nBody 2\n", string(outContent)) -} - -func TestPrintStandardResultsHTTPGetShare(t *testing.T) { - osStdErr := os.Stderr - osStdOut := os.Stdout - - rStdErr, myStdErr, err := os.Pipe() - assert.NoError(t, err) - defer rStdErr.Close() - - rStdOut, myStdOut, err := os.Pipe() - assert.NoError(t, err) - defer rStdOut.Close() - - os.Stderr = myStdErr - os.Stdout = myStdOut - - defer func() { - os.Stderr = osStdErr - os.Stdout = osStdOut - }() - - ctx := model.Context{ - Cmd: "http", - CI: true, - Share: true, - } - - m := model.PostMeasurement{ - Options: &model.MeasurementOptions{ - Request: &model.RequestOptions{ - Method: "GET", - }, - }, - } - - id := "123abc" - - data := &model.GetMeasurement{ - Results: []model.MeasurementResponse{ - { - Probe: model.ProbeData{ - Continent: "EU", - Country: "DE", - City: "Berlin", - ASN: 123, - Network: "Network 1", - }, - Result: model.ResultData{ - RawOutput: "Headers 1\nBody 1", - RawHeaders: "Headers 1", - RawBody: "Body 1", - }, - }, - - { - Probe: model.ProbeData{ - Continent: "NA", - Country: "US", - City: "New York", - State: "NY", - ASN: 567, - Network: "Network 2", - }, - Result: model.ResultData{ - RawOutput: "Headers 2\nBody 2", - RawHeaders: "Headers 2", - RawBody: "Body 2", - }, - }, - }, - } - - PrintStandardResults(id, data, ctx, m) - myStdOut.Close() - myStdErr.Close() - - errContent, err := io.ReadAll(rStdErr) - assert.NoError(t, err) - assert.Equal(t, "> EU, DE, Berlin, ASN:123, Network 1\n> NA, US, (NY), New York, ASN:567, Network 2\n> View the results online: https://www.jsdelivr.com/globalping?measurement=123abc\n", string(errContent)) - - outContent, err := io.ReadAll(rStdOut) - assert.NoError(t, err) - assert.Equal(t, "Body 1\n\nBody 2\n", string(outContent)) -} - -func TestPrintStandardResultsHTTPGetFull(t *testing.T) { - osStdErr := os.Stderr - osStdOut := os.Stdout - - rStdErr, myStdErr, err := os.Pipe() - assert.NoError(t, err) - defer rStdErr.Close() - - rStdOut, myStdOut, err := os.Pipe() - assert.NoError(t, err) - defer rStdOut.Close() - - os.Stderr = myStdErr - os.Stdout = myStdOut - - defer func() { - os.Stderr = osStdErr - os.Stdout = osStdOut - }() - - ctx := model.Context{ - Cmd: "http", - CI: true, - Full: true, - } - - m := model.PostMeasurement{ - Options: &model.MeasurementOptions{ - Request: &model.RequestOptions{ - Method: "GET", - }, - }, - } - - id := "123abc" - - data := &model.GetMeasurement{ - Results: []model.MeasurementResponse{ - { - Probe: model.ProbeData{ - Continent: "EU", - Country: "DE", - City: "Berlin", - ASN: 123, - Network: "Network 1", - }, - Result: model.ResultData{ - RawOutput: "Headers 1\nBody 1", - RawHeaders: "Headers 1", - RawBody: "Body 1", - }, - }, - - { - Probe: model.ProbeData{ - Continent: "NA", - Country: "US", - City: "New York", - State: "NY", - ASN: 567, - Network: "Network 2", - }, - Result: model.ResultData{ - RawOutput: "Headers 2\nBody 2", - RawHeaders: "Headers 2", - RawBody: "Body 2", - }, - }, - }, - } - - PrintStandardResults(id, data, ctx, m) - myStdOut.Close() - myStdErr.Close() - - errContent, err := io.ReadAll(rStdErr) - assert.NoError(t, err) - assert.Equal(t, "> EU, DE, Berlin, ASN:123, Network 1\n> NA, US, (NY), New York, ASN:567, Network 2\n", string(errContent)) - - outContent, err := io.ReadAll(rStdOut) - assert.NoError(t, err) - assert.Equal(t, "Headers 1\nBody 1\n\nHeaders 2\nBody 2\n", string(outContent)) -} - -func TestPrintStandardResultsHTTPHead(t *testing.T) { - osStdErr := os.Stderr - osStdOut := os.Stdout - - rStdErr, myStdErr, err := os.Pipe() - assert.NoError(t, err) - defer rStdErr.Close() - - rStdOut, myStdOut, err := os.Pipe() - assert.NoError(t, err) - defer rStdOut.Close() - - os.Stderr = myStdErr - os.Stdout = myStdOut - - defer func() { - os.Stderr = osStdErr - os.Stdout = osStdOut - }() - - ctx := model.Context{ - Cmd: "http", - CI: true, - } - - m := model.PostMeasurement{ - Options: &model.MeasurementOptions{ - Request: &model.RequestOptions{ - Method: "HEAD", - }, - }, - } - - id := "123abc" - - data := &model.GetMeasurement{ - Results: []model.MeasurementResponse{ - { - Probe: model.ProbeData{ - Continent: "EU", - Country: "DE", - City: "Berlin", - ASN: 123, - Network: "Network 1", - }, - Result: model.ResultData{ - RawOutput: "Headers 1", - RawHeaders: "Headers 1", - }, - }, - - { - Probe: model.ProbeData{ - Continent: "NA", - Country: "US", - City: "New York", - State: "NY", - ASN: 567, - Network: "Network 2", - }, - Result: model.ResultData{ - RawOutput: "Headers 2", - RawHeaders: "Headers 2", - }, - }, - }, - } - - PrintStandardResults(id, data, ctx, m) - myStdOut.Close() - myStdErr.Close() - - errContent, err := io.ReadAll(rStdErr) - assert.NoError(t, err) - assert.Equal(t, "> EU, DE, Berlin, ASN:123, Network 1\n> NA, US, (NY), New York, ASN:567, Network 2\n", string(errContent)) - - outContent, err := io.ReadAll(rStdOut) - assert.NoError(t, err) - assert.Equal(t, "Headers 1\n\nHeaders 2\n", string(outContent)) -} - -func TestPrintStandardResultsPing(t *testing.T) { - osStdErr := os.Stderr - osStdOut := os.Stdout - - rStdErr, myStdErr, err := os.Pipe() - assert.NoError(t, err) - defer rStdErr.Close() - - rStdOut, myStdOut, err := os.Pipe() - assert.NoError(t, err) - defer rStdOut.Close() - - os.Stderr = myStdErr - os.Stdout = myStdOut - - defer func() { - os.Stderr = osStdErr - os.Stdout = osStdOut - }() - - ctx := model.Context{ - Cmd: "ping", - CI: true, - } - - m := model.PostMeasurement{} - - id := "123abc" - - data := &model.GetMeasurement{ - Results: []model.MeasurementResponse{ - { - Probe: model.ProbeData{ - Continent: "EU", - Country: "DE", - City: "Berlin", - ASN: 123, - Network: "Network 1", - }, - Result: model.ResultData{ - RawOutput: "Ping Results 1", - }, - }, - - { - Probe: model.ProbeData{ - Continent: "NA", - Country: "US", - City: "New York", - State: "NY", - ASN: 567, - Network: "Network 2", - }, - Result: model.ResultData{ - RawOutput: "Ping Results 2", - }, - }, - }, - } - - PrintStandardResults(id, data, ctx, m) - myStdOut.Close() - myStdErr.Close() - - errContent, err := io.ReadAll(rStdErr) - assert.NoError(t, err) - assert.Equal(t, "> EU, DE, Berlin, ASN:123, Network 1\n> NA, US, (NY), New York, ASN:567, Network 2\n", string(errContent)) - - outContent, err := io.ReadAll(rStdOut) - assert.NoError(t, err) - assert.Equal(t, "Ping Results 1\n\nPing Results 2\n", string(outContent)) -} - func TestTrimOutput(t *testing.T) { output := `> EU, GB, London, ASN:12345 TEST CONTENT @@ -493,50 +84,3 @@ some text f` assert.Equal(t, expectedRes, res) } - -func TestOutputJson(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - id := "my-id" - - b := []byte(`{"fake": "results"}`) - - fetcher := mocks.NewMockMeasurementsFetcher(ctrl) - fetcher.EXPECT().GetRawMeasurement(id).Times(1).Return(b, nil) - - ctx := model.Context{ - JsonOutput: true, - Share: true, - } - osStdErr := os.Stderr - osStdOut := os.Stdout - - rStdErr, myStdErr, err := os.Pipe() - assert.NoError(t, err) - defer rStdErr.Close() - - rStdOut, myStdOut, err := os.Pipe() - assert.NoError(t, err) - defer rStdOut.Close() - - os.Stderr = myStdErr - os.Stdout = myStdOut - - defer func() { - os.Stderr = osStdErr - os.Stdout = osStdOut - }() - - OutputJson(id, fetcher, ctx) - myStdOut.Close() - myStdErr.Close() - - errContent, err := io.ReadAll(rStdErr) - assert.NoError(t, err) - assert.Equal(t, "> View the results online: https://www.jsdelivr.com/globalping?measurement=my-id\n", string(errContent)) - - outContent, err := io.ReadAll(rStdOut) - assert.NoError(t, err) - assert.Equal(t, "{\"fake\": \"results\"}\n\n", string(outContent)) -} From 20c83e7c1eb732e26e4f0c17e0593c2fb57ec639 Mon Sep 17 00:00:00 2001 From: Radu Lucut Date: Thu, 18 Jan 2024 21:38:45 +0200 Subject: [PATCH 06/27] Update formating, fix test --- README.md | 23 ++++----- view/infinite.go | 24 ++++++---- view/infinite_test.go | 106 ++++++++++++++++++++++++++---------------- 3 files changed, 94 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index f66fe35..8f499be 100644 --- a/README.md +++ b/README.md @@ -287,23 +287,24 @@ This means that eventually you will run out of credits and the test will stop. ```bash globalping ping cdn.jsdelivr.net from Europe --infinite -> EU, SE, Stockholm, ASN:42708, GleSYS AB -PING cdn.jsdelivr.net (151.101.1.229) -151.101.1.229: icmp_seq=1 ttl=59 time=0.85 ms -151.101.1.229: icmp_seq=2 ttl=59 time=5.86 ms +> EU, GB, London, ASN:40676, Psychz Networks +PING cdn.jsdelivr.net (151.101.1.229) 56(84) bytes of data. +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=59 time=0.54 ms +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=2 ttl=59 time=0.42 ms ^C ``` If you select multiple probes when using `--infinite` the output will change to a summary comparison table. ```bash -globalping ping cdn.jsdelivr.net from Europe --limit 5 --infinite -Location | Loss | Sent | Last | Avg | Min | Max -EU, DE, Falkenstein, ASN:24940, Hetzner Online GmbH | 0.00% | 21 | 5.43 ms | 5.71 ms | 5.30 ms | 11.98 ms -EU, NL, Rotterdam, ASN:210630, IncogNET LLC | 0.00% | 21 | 1.76 ms | 1.81 ms | 1.76 ms | 1.96 ms -EU, LU, Luxembourg, ASN:53667, FranTech Solutions | 0.00% | 21 | 5.14 ms | 13.03 ms | 4.80 ms | 75.71 ms -EU, ES, Madrid, ASN:20473, The Constant Company, LLC | 0.00% | 21 | 0.67 ms | 0.73 ms | 0.59 ms | 1.08 ms -EU, DE, Frankfurt, ASN:16276, OVH SAS | 0.00% | 21 | 1.47 ms | 1.43 ms | 1.35 ms | 1.51 ms +globalping ping cdn.jsdelivr.net from Europe --limit 5 --infinite +Location | Loss | Sent | Last | Avg | Min | Max +EU, NL, Dronten, ASN:41608, NextGenWebs, S.L. | 0.00% | 12 | 25.3 ms | 25.3 ms | 25.2 ms | 25.9 ms +EU, GB, London, ASN:200904, FoxCloud LLP | 0.00% | 12 | 3.21 ms | 2.90 ms | 2.32 ms | 3.27 ms +EU, AT, Vienna, ASN:9009, M247 Europe SRL | 0.00% | 12 | 6.60 ms | 3.85 ms | 0.30 ms | 11.1 ms +EU, GB, London, ASN:60841, BerryByte Limited | 0.00% | 12 | 0.38 ms | 0.41 ms | 0.34 ms | 0.64 ms +EU, NL, Amsterdam, ASN:199959, GWY IT PTY LTD | 0.00% | 12 | 2.07 ms | 2.32 ms | 1.71 ms | 4.56 ms + ^C ``` diff --git a/view/infinite.go b/view/infinite.go index fd49735..8aae711 100644 --- a/view/infinite.go +++ b/view/infinite.go @@ -54,7 +54,7 @@ func outputSingleLocation(res *model.GetMeasurement, ctx *model.Context) error { ctx.Stats = make([]model.MeasurementStats, 1) // Print header fmt.Println(generateHeader(measurement, !ctx.CI)) - fmt.Printf("PING %s (%s)\n", res.Target, measurement.Result.ResolvedAddress) + fmt.Printf("PING %s (%s) 56(84) bytes of data.\n", res.Target, measurement.Result.ResolvedAddress) } timings, err := client.DecodePingTimings(measurement.Result.TimingsRaw) if err != nil { @@ -63,7 +63,8 @@ func outputSingleLocation(res *model.GetMeasurement, ctx *model.Context) error { for i := range timings { ctx.Stats[0].Sent++ t := timings[i] - fmt.Printf("%s: icmp_seq=%d ttl=%d time=%.2f ms\n", + fmt.Printf("64 bytes from %s (%s): icmp_seq=%d ttl=%d time=%.2f ms\n", + measurement.Result.ResolvedHostname, measurement.Result.ResolvedAddress, ctx.Stats[0].Sent, t.TTL, @@ -95,12 +96,12 @@ func outputMultipleLocations(res *model.GetMeasurement, ctx *model.Context) erro updateMeasurementStats(localStats, result) tableData = append(tableData, []string{ getLocationText(result), - fmt.Sprintf("%.2f", localStats.Loss) + "%", - fmt.Sprintf("%d", localStats.Sent), - formatDuration(localStats.Last), - formatDuration(localStats.Avg), - formatDuration(localStats.Min), - formatDuration(localStats.Max), + formatValue(fmt.Sprintf("%.2f", localStats.Loss)+"%", 6), + formatValue(fmt.Sprintf("%d", localStats.Sent), 3), + formatValue(formatDuration(localStats.Last), 7), + formatValue(formatDuration(localStats.Avg), 7), + formatValue(formatDuration(localStats.Min), 7), + formatValue(formatDuration(localStats.Max), 7), }) } t, err := pterm.DefaultTable.WithHasHeader().WithData(tableData).Srender() @@ -122,6 +123,13 @@ func formatDuration(ms float64) string { return fmt.Sprintf("%.0f ms", ms) } +func formatValue(v string, width int) string { + for len(v) < width { + v = " " + v + } + return pterm.NewStyle(pterm.FgDefault).Sprint(v) +} + func updateMeasurementStats(localStats *model.MeasurementStats, result *model.MeasurementResponse) error { stats, err := client.DecodePingStats(result.Result.StatsRaw) if err != nil { diff --git a/view/infinite_test.go b/view/infinite_test.go index e7710b5..5ada2ea 100644 --- a/view/infinite_test.go +++ b/view/infinite_test.go @@ -15,16 +15,16 @@ func TestOutputInfinite_SingleLocation(t *testing.T) { osStdErr := os.Stderr osStdOut := os.Stdout - rStdErr, myStdErr, err := os.Pipe() + rErr, wErr, err := os.Pipe() assert.NoError(t, err) - defer rStdErr.Close() + defer rErr.Close() - rStdOut, myStdOut, err := os.Pipe() + rOut, wOut, err := os.Pipe() assert.NoError(t, err) - defer rStdOut.Close() + defer rOut.Close() - os.Stderr = myStdErr - os.Stdout = myStdOut + os.Stderr = wErr + os.Stdout = wOut defer func() { os.Stderr = osStdErr @@ -44,44 +44,30 @@ func TestOutputInfinite_SingleLocation(t *testing.T) { err = outputSingleLocation(measurement, ctx) assert.NoError(t, err) - myStdErr.Close() - myStdOut.Close() + wErr.Close() + wOut.Close() - errOutput, err := io.ReadAll(rStdErr) + errOutput, err := io.ReadAll(rErr) assert.NoError(t, err) assert.Equal(t, "", string(errOutput)) - output, err := io.ReadAll(rStdOut) + output, err := io.ReadAll(rOut) assert.NoError(t, err) assert.Equal(t, `> EU, DE, Berlin, ASN:3320, Deutsche Telekom AG -PING cdn.jsdelivr.net (151.101.1.229) -151.101.1.229: icmp_seq=1 ttl=60 time=17.64 ms -151.101.1.229: icmp_seq=2 ttl=60 time=17.64 ms -151.101.1.229: icmp_seq=3 ttl=60 time=17.64 ms +PING cdn.jsdelivr.net (151.101.1.229) 56(84) bytes of data. +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=60 time=17.64 ms +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=2 ttl=60 time=17.64 ms +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=3 ttl=60 time=17.64 ms `, string(output)) } func TestOutputInfinite_MultipleLocations(t *testing.T) { - osStdErr := os.Stderr osStdOut := os.Stdout - - rStdErr, myStdErr, err := os.Pipe() - assert.NoError(t, err) - defer rStdErr.Close() - - rStdOut, myStdOut, err := os.Pipe() + r, w, err := os.Pipe() assert.NoError(t, err) - defer rStdOut.Close() - - os.Stderr = myStdErr - os.Stdout = myStdOut - - defer func() { - os.Stderr = osStdErr - os.Stdout = osStdOut - }() + os.Stdout = w ctx := &model.Context{ Cmd: "ping", @@ -91,24 +77,64 @@ func TestOutputInfinite_MultipleLocations(t *testing.T) { err = outputMultipleLocations(measurement, ctx) assert.NoError(t, err) - myStdErr.Close() - myStdOut.Close() + w.Close() + os.Stdout = osStdOut - errOutput, err := io.ReadAll(rStdErr) + output, err := io.ReadAll(r) assert.NoError(t, err) - assert.Equal(t, "", string(errOutput)) + r.Close() - output, err := io.ReadAll(rStdOut) + r, w, err = os.Pipe() assert.NoError(t, err) - + defer r.Close() + os.Stdout = w + defer func() { + os.Stdout = osStdOut + }() expectedTableData := pterm.TableData{ {"Location", "Loss", "Sent", "Last", "Avg", "Min", "Max"}, - {"EU, GB, London, ASN:0, OVH SAS", "0.00%", "1", "0.77 ms", "0.77 ms", "0.77 ms", "0.77 ms"}, - {"EU, DE, Falkenstein, ASN:0, Hetzner Online GmbH", "0.00%", "1", "5.46 ms", "5.46 ms", "5.46 ms", "5.46 ms"}, - {"EU, DE, Nuremberg, ASN:0, Hetzner Online GmbH", "0.00%", "1", "4.07 ms", "4.07 ms", "4.07 ms", "4.07 ms"}, + { + "EU, GB, London, ASN:0, OVH SAS", + formatValue("0.00%", 6), + formatValue("1", 3), + formatValue("0.77 ms", 7), + formatValue("0.77 ms", 7), + formatValue("0.77 ms", 7), + formatValue("0.77 ms", 7), + }, + { + "EU, DE, Falkenstein, ASN:0, Hetzner Online GmbH", + formatValue("0.00%", 6), + formatValue("1", 3), + formatValue("5.46 ms", 7), + formatValue("5.46 ms", 7), + formatValue("5.46 ms", 7), + formatValue("5.46 ms", 7), + }, + { + "EU, DE, Nuremberg, ASN:0, Hetzner Online GmbH", + formatValue("0.00%", 6), + formatValue("1", 3), + formatValue("4.07 ms", 7), + formatValue("4.07 ms", 7), + formatValue("4.07 ms", 7), + formatValue("4.07 ms", 7), + }, } expectedTable, _ := pterm.DefaultTable.WithHasHeader().WithData(expectedTableData).Srender() - assert.Equal(t, "\n\n\n\n\n\n\n"+expectedTable+"\n", string(output)) + + area, err := pterm.DefaultArea.Start() + assert.NoError(t, err) + area.Update(expectedTable) + area.Stop() + w.Close() + os.Stdout = osStdOut + + expectedOutput, err := io.ReadAll(r) + assert.NoError(t, err) + r.Close() + + assert.Equal(t, string(expectedOutput), string(output)) } func TestFormatDuration(t *testing.T) { From 0c774f75c9d22c4dc0da13c92fb3c8d7802e08bc Mon Sep 17 00:00:00 2001 From: Radu Lucut Date: Thu, 18 Jan 2024 22:00:21 +0200 Subject: [PATCH 07/27] Fix test --- client/client_test.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/client/client_test.go b/client/client_test.go index c996cb4..d69e27d 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -97,10 +97,18 @@ func testPostValidation(t *testing.T) { client.ApiUrl = server.URL _, showHelp, err := client.PostAPI(opts) - assert.EqualError(t, err, `invalid parameters + + // Key order is not guaranteed + expectedErrV1 := `invalid parameters - "measurement" does not match any of the allowed types - "target" does not match any of the allowed types +Please check the help for more information` + if err.Error() != expectedErrV1 { + assert.EqualError(t, err, `invalid parameters + - "target" does not match any of the allowed types + - "measurement" does not match any of the allowed types Please check the help for more information`) + } assert.True(t, showHelp) } From 1ae9a0f02db26b5669f926702af67ac9e63e4145 Mon Sep 17 00:00:00 2001 From: Radu Lucut Date: Fri, 19 Jan 2024 16:14:18 +0200 Subject: [PATCH 08/27] Update Go version and dependencies --- .github/workflows/release.yml | 12 ++--- .github/workflows/tests.yml | 2 +- go.mod | 39 ++++++++------- go.sum | 92 ++++++++++++++++++++++------------- 4 files changed, 85 insertions(+), 60 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9eecb4c..7381003 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,7 @@ on: push: # run only against tags tags: - - '*' + - "*" permissions: contents: write @@ -22,12 +22,12 @@ jobs: - uses: actions/setup-go@v3 with: - go-version: '>=1.20.0' + go-version: ">=1.21.3" cache: true - uses: goreleaser/goreleaser-action@v4 with: - version: v1.20.0 + version: v1.21.3 args: release --clean env: GITHUB_TOKEN: ${{ secrets.GHTOKEN_GORELEASER }} @@ -50,7 +50,7 @@ jobs: path: | dist/globalping_Windows_arm64.zip dist/globalping_Windows_x86_64.zip - dist/globalping_Windows_i386.zip + dist/globalping_Windows_i386.zip deploy: needs: goreleaser @@ -65,7 +65,7 @@ jobs: - uses: actions/download-artifact@v3 with: name: goreleaser-windows - + - run: echo "VERSION_NAME=${GITHUB_REF_NAME:1}" >> $GITHUB_ENV - run: ls -la @@ -82,7 +82,7 @@ jobs: uses: vedantmgoyal2009/winget-releaser@v2 with: identifier: jsdelivr.Globalping - installers-regex: 'Windows_(arm64|x86_64|i386).zip' + installers-regex: "Windows_(arm64|x86_64|i386).zip" version: ${{ env.VERSION_NAME }} max-versions-to-keep: 5 token: ${{ secrets.GHTOKEN_WINGET }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d17e27a..87d84c3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,7 +12,7 @@ jobs: strategy: fail-fast: false matrix: - go: ["1.20"] + go: ["1.21"] os: [ubuntu-latest, macOS-latest, windows-latest] name: ${{ matrix.os }} Go ${{ matrix.go }} Tests steps: diff --git a/go.mod b/go.mod index b8f0922..45731b5 100644 --- a/go.mod +++ b/go.mod @@ -1,46 +1,49 @@ module github.com/jsdelivr/globalping-cli -go 1.20 +go 1.21 + +toolchain go1.21.3 require ( - github.com/andybalholm/brotli v1.0.5 - github.com/charmbracelet/lipgloss v0.7.1 + github.com/andybalholm/brotli v1.1.0 + github.com/charmbracelet/lipgloss v0.9.1 github.com/golang/mock v1.6.0 github.com/icza/backscanner v0.0.0-20230330133933-bf6beb754c70 - github.com/mattn/go-runewidth v0.0.14 + github.com/mattn/go-runewidth v0.0.15 github.com/pkg/errors v0.9.1 - github.com/pterm/pterm v0.12.58 + github.com/pterm/pterm v0.12.75 github.com/shirou/gopsutil v3.21.11+incompatible - github.com/spf13/cobra v1.7.0 + github.com/spf13/cobra v1.8.0 github.com/stretchr/testify v1.8.4 - golang.org/x/exp v0.0.0-20230321023759-10a507213a29 + golang.org/x/exp v0.0.0-20240119083558-1b970713d09a ) require ( - atomicgo.dev/cursor v0.1.1 // indirect + atomicgo.dev/cursor v0.2.0 // indirect atomicgo.dev/keyboard v0.2.9 // indirect + atomicgo.dev/schedule v0.1.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/containerd/console v1.0.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/go-ole/go-ole v1.2.6 // indirect - github.com/gookit/color v1.5.3 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/gookit/color v1.5.4 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/pretty v0.3.1 // indirect - github.com/lithammer/fuzzysearch v1.1.5 // indirect + github.com/lithammer/fuzzysearch v1.1.8 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.18 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/muesli/reflow v0.3.0 // indirect - github.com/muesli/termenv v0.15.1 // indirect + github.com/muesli/termenv v0.15.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.4 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/tklauser/go-sysconf v0.3.12 // indirect - github.com/tklauser/numcpus v0.6.1 // indirect + github.com/tklauser/go-sysconf v0.3.13 // indirect + 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/sys v0.15.0 // indirect - golang.org/x/term v0.7.0 // indirect - golang.org/x/text v0.9.0 // 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 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 3881503..afc52cd 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,11 @@ atomicgo.dev/assert v0.0.2 h1:FiKeMiZSgRrZsPo9qn/7vmr7mCsh5SZyXY4YGYiYwrg= -atomicgo.dev/cursor v0.1.1 h1:0t9sxQomCTRh5ug+hAMCs59x/UmC9QL6Ci5uosINKD4= -atomicgo.dev/cursor v0.1.1/go.mod h1:Lr4ZJB3U7DfPPOkbH7/6TOtJ4vFGHlgj1nc+n900IpU= +atomicgo.dev/assert v0.0.2/go.mod h1:ut4NcI3QDdJtlmAxQULOmA13Gz6e2DWbSAS8RUOmNYQ= +atomicgo.dev/cursor v0.2.0 h1:H6XN5alUJ52FZZUkI7AlJbUc1aW38GWZalpYRPpoPOw= +atomicgo.dev/cursor v0.2.0/go.mod h1:Lr4ZJB3U7DfPPOkbH7/6TOtJ4vFGHlgj1nc+n900IpU= atomicgo.dev/keyboard v0.2.9 h1:tOsIid3nlPLZ3lwgG8KZMp/SFmr7P0ssEN5JUsm78K8= atomicgo.dev/keyboard v0.2.9/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ= +atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs= +atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU= github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs= github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8= github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII= @@ -11,28 +14,30 @@ github.com/MarvinJWendt/testza v0.2.12/go.mod h1:JOIegYyV7rX+7VZ9r77L/eH6CfJHHzX github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/2oUqKc6bF2c= github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE= github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4= -github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= -github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= -github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= +github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= +github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= -github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ= github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo= -github.com/gookit/color v1.5.3 h1:twfIhZs4QLCtimkP7MOxlF3A0U/5cDPseRT9M/+2SCE= -github.com/gookit/color v1.5.3/go.mod h1:NUzwzeehUfl7GIb36pqId+UGmRfQcU/WiiyTTeNjHtE= +github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= +github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= github.com/icza/backscanner v0.0.0-20230330133933-bf6beb754c70 h1:xrd41BUTgqxyYFfFwGdt/bnwS8KNYzPraj8WgvJ5NWk= github.com/icza/backscanner v0.0.0-20230330133933-bf6beb754c70/go.mod h1:GYeBD1CF7AqnKZK+UCytLcY3G+UKo0ByXX/3xfdNyqQ= github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k= @@ -43,6 +48,7 @@ github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02 github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU= +github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -51,20 +57,20 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lithammer/fuzzysearch v1.1.5 h1:Ag7aKU08wp0R9QCfF4GoGST9HbmAIeLP7xwMrOBEp1c= -github.com/lithammer/fuzzysearch v1.1.5/go.mod h1:1R1LRNk7yKid1BaQkmuLQaHruxcC4HmAH30Dh61Ih1Q= +github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= +github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= -github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= -github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs= -github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -77,8 +83,8 @@ github.com/pterm/pterm v0.12.31/go.mod h1:32ZAWZVXD7ZfG0s8qqHXePte42kdz8ECtRyEej github.com/pterm/pterm v0.12.33/go.mod h1:x+h2uL+n7CP/rel9+bImHD5lF3nM9vJj80k9ybiiTTE= github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5bUw8T8= github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s= -github.com/pterm/pterm v0.12.58 h1:MEImvkbvty8JvoJH64bJ+CvoCkcuRw2iBIJvRwAEgHI= -github.com/pterm/pterm v0.12.58/go.mod h1:Ro9CV954hiaxt3mcpDx4a8XF5EmRDlIIpPdlfCKF9fE= +github.com/pterm/pterm v0.12.75 h1:sRoDOqowp0lOr2SBREsxLRzLOUmwBWfyOflsGnVIIbo= +github.com/pterm/pterm v0.12.75/go.mod h1:1v/gzOF1N0FsjbgTHZ1wVycRkKiatFvJSJC4IGaQAAo= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= @@ -90,8 +96,8 @@ github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -100,26 +106,35 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= -github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= -github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= -github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/tklauser/go-sysconf v0.3.13 h1:GBUpcahXSpR2xN01jhkNAbTLRk2Yzgggk8IM08lq3r4= +github.com/tklauser/go-sysconf v0.3.13/go.mod h1:zwleP4Q4OehZHGn4CYZDipCgg9usW5IJePewFCGVEa0= +github.com/tklauser/numcpus v0.7.0 h1:yjuerZP127QG9m5Zh/mSO4wqurYil27tHrqwRoRjpr4= +github.com/tklauser/numcpus v0.7.0/go.mod h1:bb6dMVcj8A42tSE7i32fsIUCbQNllK5iDguyOZRUzAY= github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/exp v0.0.0-20230321023759-10a507213a29 h1:ooxPy7fPvB4kwsA2h+iBNHkAbp/4JxTSwCmvdjEYmug= -golang.org/x/exp v0.0.0-20230321023759-10a507213a29/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA= +golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -130,25 +145,32 @@ golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.7.0 h1:BEvjmm5fURWqcfbSKTdpkDXYBrUS1c0m8agp14W48vQ= -golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From f527b86cfd025c76dbe8324a1a47cee5a99d77cd Mon Sep 17 00:00:00 2001 From: Radu Lucut Date: Fri, 19 Jan 2024 19:19:31 +0200 Subject: [PATCH 09/27] Update table format --- README.md | 13 +++-- view/infinite.go | 71 +++++++++++++++++++-------- view/infinite_test.go | 108 ++++++++++++++++++++++++++++++++++-------- 3 files changed, 145 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 8f499be..63c5e8d 100644 --- a/README.md +++ b/README.md @@ -298,13 +298,12 @@ If you select multiple probes when using `--infinite` the output will change to ```bash globalping ping cdn.jsdelivr.net from Europe --limit 5 --infinite -Location | Loss | Sent | Last | Avg | Min | Max -EU, NL, Dronten, ASN:41608, NextGenWebs, S.L. | 0.00% | 12 | 25.3 ms | 25.3 ms | 25.2 ms | 25.9 ms -EU, GB, London, ASN:200904, FoxCloud LLP | 0.00% | 12 | 3.21 ms | 2.90 ms | 2.32 ms | 3.27 ms -EU, AT, Vienna, ASN:9009, M247 Europe SRL | 0.00% | 12 | 6.60 ms | 3.85 ms | 0.30 ms | 11.1 ms -EU, GB, London, ASN:60841, BerryByte Limited | 0.00% | 12 | 0.38 ms | 0.41 ms | 0.34 ms | 0.64 ms -EU, NL, Amsterdam, ASN:199959, GWY IT PTY LTD | 0.00% | 12 | 2.07 ms | 2.32 ms | 1.71 ms | 4.56 ms - +Location | Sent | Loss | Last | Min | Avg | Max +EU, GB, London, ASN:16276, OVH SAS | 22 | 0.00% | 3.33 ms | 3.07 ms | 3.20 ms | 3.33 ms +EU, DE, Falkenstein, ASN:24940, Hetzner Online GmbH | 22 | 0.00% | 5.41 ms | 5.30 ms | 5.78 ms | 13.1 ms +EU, AT, Vienna, ASN:57169, EDIS GmbH | 22 | 0.00% | 0.47 ms | 0.46 ms | 0.56 ms | 0.88 ms +EU, SE, Stockholm, ASN:20473, The Constant Company, LLC | 22 | 0.00% | 1.03 ms | 0.83 ms | 1.15 ms | 4.66 ms +EU, ES, Madrid, ASN:47787, EDGOO NETWORKS LLC | 22 | 0.00% | 0.24 ms | 0.13 ms | 0.26 ms | 0.42 ms ^C ``` diff --git a/view/infinite.go b/view/infinite.go index 8aae711..5b43584 100644 --- a/view/infinite.go +++ b/view/infinite.go @@ -79,7 +79,10 @@ func outputMultipleLocations(res *model.GetMeasurement, ctx *model.Context) erro // Initialize state ctx.Stats = make([]model.MeasurementStats, len(res.Results)) for i := range ctx.Stats { + ctx.Stats[i].Last = -1 ctx.Stats[i].Min = math.MaxFloat64 + ctx.Stats[i].Avg = -1 + ctx.Stats[i].Max = -1 } // Create new writer ctx.Area, err = pterm.DefaultArea.Start() @@ -88,21 +91,21 @@ func outputMultipleLocations(res *model.GetMeasurement, ctx *model.Context) erro } } tableData := pterm.TableData{ - {"Location", "Loss", "Sent", "Last", "Avg", "Min", "Max"}, + { + "Location", + formatValue("Sent", 4, pterm.FgLightCyan), + formatValue("Loss", 7, pterm.FgLightCyan), + formatValue("Last", 8, pterm.FgLightCyan), + formatValue("Min", 8, pterm.FgLightCyan), + formatValue("Avg", 8, pterm.FgLightCyan), + formatValue("Max", 8, pterm.FgLightCyan), + }, } for i := range res.Results { result := &res.Results[i] localStats := &ctx.Stats[i] updateMeasurementStats(localStats, result) - tableData = append(tableData, []string{ - getLocationText(result), - formatValue(fmt.Sprintf("%.2f", localStats.Loss)+"%", 6), - formatValue(fmt.Sprintf("%d", localStats.Sent), 3), - formatValue(formatDuration(localStats.Last), 7), - formatValue(formatDuration(localStats.Avg), 7), - formatValue(formatDuration(localStats.Min), 7), - formatValue(formatDuration(localStats.Max), 7), - }) + tableData = append(tableData, getRowValues(result, localStats)) } t, err := pterm.DefaultTable.WithHasHeader().WithData(tableData).Srender() if err != nil { @@ -113,6 +116,34 @@ func outputMultipleLocations(res *model.GetMeasurement, ctx *model.Context) erro return nil } +func getRowValues(res *model.MeasurementResponse, stats *model.MeasurementStats) []string { + last := "-" + min := "-" + avg := "-" + max := "-" + if stats.Last != -1 { + last = formatDuration(stats.Last) + } + if stats.Min != math.MaxFloat64 { + min = formatDuration(stats.Min) + } + if stats.Avg != -1 { + avg = formatDuration(stats.Avg) + } + if stats.Max != -1 { + max = formatDuration(stats.Max) + } + return []string{ + getLocationText(res), + formatValue(fmt.Sprintf("%d", stats.Sent), 4, pterm.FgDefault), + formatValue(fmt.Sprintf("%.2f", stats.Loss)+"%", 7, pterm.FgDefault), + formatValue(last, 8, pterm.FgDefault), + formatValue(min, 8, pterm.FgDefault), + formatValue(avg, 8, pterm.FgDefault), + formatValue(max, 8, pterm.FgDefault), + } +} + func formatDuration(ms float64) string { if ms < 10 { return fmt.Sprintf("%.2f ms", ms) @@ -123,11 +154,11 @@ func formatDuration(ms float64) string { return fmt.Sprintf("%.0f ms", ms) } -func formatValue(v string, width int) string { +func formatValue(v string, width int, color pterm.Color) string { for len(v) < width { v = " " + v } - return pterm.NewStyle(pterm.FgDefault).Sprint(v) + return pterm.NewStyle(color).Sprint(v) } func updateMeasurementStats(localStats *model.MeasurementStats, result *model.MeasurementResponse) error { @@ -139,16 +170,14 @@ func updateMeasurementStats(localStats *model.MeasurementStats, result *model.Me if err != nil { return err } - if stats.Min < localStats.Min && stats.Min != 0 { - localStats.Min = stats.Min - } - if stats.Max > localStats.Max { - localStats.Max = stats.Max - } - if stats.Avg != 0 { + if stats.Rcv > 0 { + if stats.Min < localStats.Min && stats.Min != 0 { + localStats.Min = stats.Min + } + if stats.Max > localStats.Max { + localStats.Max = stats.Max + } localStats.Avg = (localStats.Avg*float64(localStats.Sent) + stats.Avg*float64(stats.Total)) / float64(localStats.Sent+stats.Total) - } - if len(timings) != 0 { localStats.Last = timings[len(timings)-1].RTT } localStats.Sent += stats.Total diff --git a/view/infinite_test.go b/view/infinite_test.go index 5ada2ea..2bb31a3 100644 --- a/view/infinite_test.go +++ b/view/infinite_test.go @@ -3,6 +3,7 @@ package view import ( "encoding/json" "io" + "math" "os" "testing" @@ -92,33 +93,40 @@ func TestOutputInfinite_MultipleLocations(t *testing.T) { os.Stdout = osStdOut }() expectedTableData := pterm.TableData{ - {"Location", "Loss", "Sent", "Last", "Avg", "Min", "Max"}, + { + "Location", + formatValue("Sent", 4, pterm.FgLightCyan), + formatValue("Loss", 7, pterm.FgLightCyan), + formatValue("Last", 8, pterm.FgLightCyan), + formatValue("Min", 8, pterm.FgLightCyan), + formatValue("Avg", 8, pterm.FgLightCyan), + formatValue("Max", 8, pterm.FgLightCyan)}, { "EU, GB, London, ASN:0, OVH SAS", - formatValue("0.00%", 6), - formatValue("1", 3), - formatValue("0.77 ms", 7), - formatValue("0.77 ms", 7), - formatValue("0.77 ms", 7), - formatValue("0.77 ms", 7), + formatValue("1", 4, pterm.FgDefault), + formatValue("0.00%", 7, pterm.FgDefault), + formatValue("0.77 ms", 8, pterm.FgDefault), + formatValue("0.77 ms", 8, pterm.FgDefault), + formatValue("0.77 ms", 8, pterm.FgDefault), + formatValue("0.77 ms", 8, pterm.FgDefault), }, { "EU, DE, Falkenstein, ASN:0, Hetzner Online GmbH", - formatValue("0.00%", 6), - formatValue("1", 3), - formatValue("5.46 ms", 7), - formatValue("5.46 ms", 7), - formatValue("5.46 ms", 7), - formatValue("5.46 ms", 7), + formatValue("1", 4, pterm.FgDefault), + formatValue("0.00%", 7, pterm.FgDefault), + formatValue("5.46 ms", 8, pterm.FgDefault), + formatValue("5.46 ms", 8, pterm.FgDefault), + formatValue("5.46 ms", 8, pterm.FgDefault), + formatValue("5.46 ms", 8, pterm.FgDefault), }, { "EU, DE, Nuremberg, ASN:0, Hetzner Online GmbH", - formatValue("0.00%", 6), - formatValue("1", 3), - formatValue("4.07 ms", 7), - formatValue("4.07 ms", 7), - formatValue("4.07 ms", 7), - formatValue("4.07 ms", 7), + formatValue("1", 4, pterm.FgDefault), + formatValue("0.00%", 7, pterm.FgDefault), + formatValue("4.07 ms", 8, pterm.FgDefault), + formatValue("4.07 ms", 8, pterm.FgDefault), + formatValue("4.07 ms", 8, pterm.FgDefault), + formatValue("4.07 ms", 8, pterm.FgDefault), }, } expectedTable, _ := pterm.DefaultTable.WithHasHeader().WithData(expectedTableData).Srender() @@ -191,3 +199,65 @@ func TestUpdateMeasurementStats(t *testing.T) { Max: 6, }, stats) } + +func TestGetRowValuesNoPacketsRcv(t *testing.T) { + stats := model.MeasurementStats{ + Sent: 1, + Lost: -1, + Loss: 0, + Last: -1, + Min: math.MaxFloat64, + Avg: -1, + Max: -1, + } + result := model.MeasurementResponse{ + Probe: model.ProbeData{ + Continent: "EU", + Country: "GB", + City: "London", + Network: "OVH SAS", + }, + } + rowValues := getRowValues(&result, &stats) + assert.Equal(t, []string{ + "EU, GB, London, ASN:0, OVH SAS", + formatValue("1", 4, pterm.FgDefault), + formatValue("0.00%", 7, pterm.FgDefault), + formatValue("-", 8, pterm.FgDefault), + formatValue("-", 8, pterm.FgDefault), + formatValue("-", 8, pterm.FgDefault), + formatValue("-", 8, pterm.FgDefault), + }, + rowValues) +} + +func TestGetRowValues(t *testing.T) { + stats := model.MeasurementStats{ + Sent: 100, + Lost: 10, + Loss: 10, + Last: 12.345, + Min: 1.2345, + Avg: 8.3456, + Max: 123.4567, + } + result := model.MeasurementResponse{ + Probe: model.ProbeData{ + Continent: "EU", + Country: "GB", + City: "London", + Network: "OVH SAS", + }, + } + rowValues := getRowValues(&result, &stats) + assert.Equal(t, []string{ + "EU, GB, London, ASN:0, OVH SAS", + formatValue("100", 4, pterm.FgDefault), + formatValue("10.00%", 7, pterm.FgDefault), + formatValue("12.3 ms", 8, pterm.FgDefault), + formatValue("1.23 ms", 8, pterm.FgDefault), + formatValue("8.35 ms", 8, pterm.FgDefault), + formatValue("123 ms", 8, pterm.FgDefault), + }, + rowValues) +} From a99d83da8c38e081a94ce1658e58b9475598ca29 Mon Sep 17 00:00:00 2001 From: Radu Lucut Date: Tue, 23 Jan 2024 10:41:32 +0200 Subject: [PATCH 10/27] Add table truncation --- cmd/ping.go | 3 +- view/infinite.go | 171 +++++++++++++++++++++++++++++------------- view/infinite_test.go | 140 +++++++++++++++++----------------- view/utils_test.go | 14 ++++ 4 files changed, 201 insertions(+), 127 deletions(-) diff --git a/cmd/ping.go b/cmd/ping.go index 1141117..3657539 100644 --- a/cmd/ping.go +++ b/cmd/ping.go @@ -49,7 +49,8 @@ Examples: return err } if ctx.Infinite { - ctx.Packets = 1 + ctx.Limit = max(ctx.Limit, 5) // Limit to 5 probes + ctx.Packets = 16 // Default to 16 packets for { ctx.From, err = ping(cmd) if err != nil { diff --git a/view/infinite.go b/view/infinite.go index 5b43584..428dca3 100644 --- a/view/infinite.go +++ b/view/infinite.go @@ -4,13 +4,20 @@ import ( "errors" "fmt" "math" + "strings" "time" "github.com/jsdelivr/globalping-cli/client" "github.com/jsdelivr/globalping-cli/model" + "github.com/mattn/go-runewidth" "github.com/pterm/pterm" ) +// Table defaults +var ( + colSeparator = " | " +) + func OutputInfinite(id string, ctx *model.Context) error { fetcher := client.NewMeasurementsFetcher(client.ApiUrl) res, err := fetcher.GetMeasurement(id) @@ -90,60 +97,11 @@ func outputMultipleLocations(res *model.GetMeasurement, ctx *model.Context) erro return errors.New("failed to start writer: " + err.Error()) } } - tableData := pterm.TableData{ - { - "Location", - formatValue("Sent", 4, pterm.FgLightCyan), - formatValue("Loss", 7, pterm.FgLightCyan), - formatValue("Last", 8, pterm.FgLightCyan), - formatValue("Min", 8, pterm.FgLightCyan), - formatValue("Avg", 8, pterm.FgLightCyan), - formatValue("Max", 8, pterm.FgLightCyan), - }, - } - for i := range res.Results { - result := &res.Results[i] - localStats := &ctx.Stats[i] - updateMeasurementStats(localStats, result) - tableData = append(tableData, getRowValues(result, localStats)) - } - t, err := pterm.DefaultTable.WithHasHeader().WithData(tableData).Srender() - if err != nil { - return err - } - ctx.Area.Update(t) + ctx.Area.Update(generateTable(res, ctx, pterm.GetTerminalWidth()-4)) return nil } -func getRowValues(res *model.MeasurementResponse, stats *model.MeasurementStats) []string { - last := "-" - min := "-" - avg := "-" - max := "-" - if stats.Last != -1 { - last = formatDuration(stats.Last) - } - if stats.Min != math.MaxFloat64 { - min = formatDuration(stats.Min) - } - if stats.Avg != -1 { - avg = formatDuration(stats.Avg) - } - if stats.Max != -1 { - max = formatDuration(stats.Max) - } - return []string{ - getLocationText(res), - formatValue(fmt.Sprintf("%d", stats.Sent), 4, pterm.FgDefault), - formatValue(fmt.Sprintf("%.2f", stats.Loss)+"%", 7, pterm.FgDefault), - formatValue(last, 8, pterm.FgDefault), - formatValue(min, 8, pterm.FgDefault), - formatValue(avg, 8, pterm.FgDefault), - formatValue(max, 8, pterm.FgDefault), - } -} - func formatDuration(ms float64) string { if ms < 10 { return fmt.Sprintf("%.2f ms", ms) @@ -154,11 +112,74 @@ func formatDuration(ms float64) string { return fmt.Sprintf("%.0f ms", ms) } -func formatValue(v string, width int, color pterm.Color) string { - for len(v) < width { - v = " " + v +func generateTable(res *model.GetMeasurement, ctx *model.Context, areaWidth int) string { + table := [][7]string{{"Location", "Sent", "Loss", "Last", "Min", "Avg", "Max"}} + // Calculate max column width and max line width + // We handle multi-line values only for the first column + maxLineWidth := 0 + colMax := [7]int{ + len(table[0][0]), + 4, + 7, + 8, + 8, + 8, + 8, + } + for i := 1; i < len(table[0]); i++ { + maxLineWidth += len(table[i]) + len(colSeparator) + } + for i := range res.Results { + result := &res.Results[i] + stats := &ctx.Stats[i] + updateMeasurementStats(stats, result) + row := getRowValues(stats) + rowWidth := 0 + for j := 1; j < len(row); j++ { + rowWidth += len(row[j]) + len(colSeparator) + colMax[j] = max(colMax[j], len(row[j])) + } + maxLineWidth = max(maxLineWidth, rowWidth) + row[0] = getLocationText(result) + colMax[0] = max(colMax[0], len(row[0])) + table = append(table, row) + } + remainingWidth := max(areaWidth-maxLineWidth, 6) // Remaining width for first column + colMax[0] = min(colMax[0], remainingWidth) // Truncate first column if necessary + // Generate table string + output := "" + for i := range table { + table[i][0] = strings.ReplaceAll(table[i][0], "\t", " ") // Replace tabs with spaces + lines := strings.Split(table[i][0], "\n") // Split first column into lines + color := pterm.Reset // No color + if i == 0 { + color = pterm.FgLightCyan + } + for k := range lines { + width := runewidth.StringWidth(lines[k]) + if colMax[0] < width { + lines[k] = runewidth.FillRight( + runewidth.Truncate(lines[k], colMax[0], "..."), + colMax[0], + ) + } else if colMax[0] > width { + lines[k] = runewidth.FillRight(lines[k], colMax[0]) + } + if color != 0 { + lines[k] = pterm.NewStyle(color).Sprint(lines[k]) + } + } + for j := 1; j < len(table[i]); j++ { + lines[0] += colSeparator + formatValue(table[i][j], color, colMax[j], j != 0) + for k := 1; k < len(lines); k++ { + lines[k] += colSeparator + formatValue("", 0, colMax[j], false) + } + } + for j := 0; j < len(lines); j++ { + output += lines[j] + "\n" + } } - return pterm.NewStyle(color).Sprint(v) + return output } func updateMeasurementStats(localStats *model.MeasurementStats, result *model.MeasurementResponse) error { @@ -185,3 +206,45 @@ func updateMeasurementStats(localStats *model.MeasurementStats, result *model.Me localStats.Loss = float64(localStats.Lost) / float64(localStats.Sent) * 100 return nil } + +func getRowValues(stats *model.MeasurementStats) [7]string { + last := "-" + min := "-" + avg := "-" + max := "-" + if stats.Last != -1 { + last = formatDuration(stats.Last) + } + if stats.Min != math.MaxFloat64 { + min = formatDuration(stats.Min) + } + if stats.Avg != -1 { + avg = formatDuration(stats.Avg) + } + if stats.Max != -1 { + max = formatDuration(stats.Max) + } + return [7]string{ + "", + fmt.Sprintf("%d", stats.Sent), + fmt.Sprintf("%.2f", stats.Loss) + "%", + last, + min, + avg, + max, + } +} + +func formatValue(v string, color pterm.Color, width int, toRight bool) string { + for len(v) < width { + if toRight { + v = " " + v + } else { + v = v + " " + } + } + if color != 0 { + v = pterm.NewStyle(color).Sprint(v) + } + return v +} diff --git a/view/infinite_test.go b/view/infinite_test.go index 2bb31a3..e34f86f 100644 --- a/view/infinite_test.go +++ b/view/infinite_test.go @@ -92,45 +92,9 @@ func TestOutputInfinite_MultipleLocations(t *testing.T) { defer func() { os.Stdout = osStdOut }() - expectedTableData := pterm.TableData{ - { - "Location", - formatValue("Sent", 4, pterm.FgLightCyan), - formatValue("Loss", 7, pterm.FgLightCyan), - formatValue("Last", 8, pterm.FgLightCyan), - formatValue("Min", 8, pterm.FgLightCyan), - formatValue("Avg", 8, pterm.FgLightCyan), - formatValue("Max", 8, pterm.FgLightCyan)}, - { - "EU, GB, London, ASN:0, OVH SAS", - formatValue("1", 4, pterm.FgDefault), - formatValue("0.00%", 7, pterm.FgDefault), - formatValue("0.77 ms", 8, pterm.FgDefault), - formatValue("0.77 ms", 8, pterm.FgDefault), - formatValue("0.77 ms", 8, pterm.FgDefault), - formatValue("0.77 ms", 8, pterm.FgDefault), - }, - { - "EU, DE, Falkenstein, ASN:0, Hetzner Online GmbH", - formatValue("1", 4, pterm.FgDefault), - formatValue("0.00%", 7, pterm.FgDefault), - formatValue("5.46 ms", 8, pterm.FgDefault), - formatValue("5.46 ms", 8, pterm.FgDefault), - formatValue("5.46 ms", 8, pterm.FgDefault), - formatValue("5.46 ms", 8, pterm.FgDefault), - }, - { - "EU, DE, Nuremberg, ASN:0, Hetzner Online GmbH", - formatValue("1", 4, pterm.FgDefault), - formatValue("0.00%", 7, pterm.FgDefault), - formatValue("4.07 ms", 8, pterm.FgDefault), - formatValue("4.07 ms", 8, pterm.FgDefault), - formatValue("4.07 ms", 8, pterm.FgDefault), - formatValue("4.07 ms", 8, pterm.FgDefault), - }, - } - expectedTable, _ := pterm.DefaultTable.WithHasHeader().WithData(expectedTableData).Srender() + expectedCtx := getDefaultPingCtx(len(measurement.Results)) + expectedTable := generateTable(measurement, expectedCtx, 76) // 80 - 4. pterm defaults to 80 when terminal size is not detected. area, err := pterm.DefaultArea.Start() assert.NoError(t, err) area.Update(expectedTable) @@ -154,6 +118,54 @@ func TestFormatDuration(t *testing.T) { assert.Equal(t, "123 ms", d) } +func TestGenerateTableFull(t *testing.T) { + measurement := getPingGetMeasurementMultipleLocations(MeasurementID1) + ctx := getDefaultPingCtx(len(measurement.Results)) + expectedTable := "\x1b[96m\x1b[96mLocation \x1b[0m\x1b[0m | \x1b[96m\x1b[96mSent\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Loss\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Last\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Min\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Avg\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Max\x1b[0m\x1b[0m\n" + + "EU, GB, London, ASN:0, OVH SAS | 1 | 0.00% | 0.77 ms | 0.77 ms | 0.77 ms | 0.77 ms\n" + + "EU, DE, Falkenstein, ASN:0, Hetzner Online GmbH | 1 | 0.00% | 5.46 ms | 5.46 ms | 5.46 ms | 5.46 ms\n" + + "EU, DE, Nuremberg, ASN:0, Hetzner Online GmbH | 1 | 0.00% | 4.07 ms | 4.07 ms | 4.07 ms | 4.07 ms\n" + table := generateTable(measurement, ctx, 500) + assert.Equal(t, expectedTable, table) +} + +func TestGenerateTableOneRowTruncated(t *testing.T) { + measurement := getPingGetMeasurementMultipleLocations(MeasurementID1) + measurement.Results[1].Probe.Network = "ä½œč€…čšé›†ēš„原创内容平台äŗŽ201 1幓1ęœˆę­£å¼äøŠēŗæ让äŗŗ们ꛓ" + ctx := getDefaultPingCtx(len(measurement.Results)) + expectedTable := "\x1b[96m\x1b[96mLocation \x1b[0m\x1b[0m | \x1b[96m\x1b[96mSent\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Loss\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Last\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Min\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Avg\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Max\x1b[0m\x1b[0m\n" + + "EU, GB, London, ASN:0, OVH SAS | 1 | 0.00% | 0.77 ms | 0.77 ms | 0.77 ms | 0.77 ms\n" + + "EU, DE, Falkenstein, ASN:0, ä½œč€…čšé›†ēš„原创... | 1 | 0.00% | 5.46 ms | 5.46 ms | 5.46 ms | 5.46 ms\n" + + "EU, DE, Nuremberg, ASN:0, Hetzner Online GmbH | 1 | 0.00% | 4.07 ms | 4.07 ms | 4.07 ms | 4.07 ms\n" + table := generateTable(measurement, ctx, 106) + assert.Equal(t, expectedTable, table) +} + +func TestGenerateTableMultiLineTruncated(t *testing.T) { + measurement := getPingGetMeasurementMultipleLocations(MeasurementID1) + measurement.Results[1].Probe.Network = "Hetzner Online GmbH\nLorem ipsum\nLorem ipsum dolor sit amet" + ctx := getDefaultPingCtx(len(measurement.Results)) + expectedTable := "\x1b[96m\x1b[96mLocation \x1b[0m\x1b[0m | \x1b[96m\x1b[96mSent\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Loss\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Last\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Min\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Avg\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Max\x1b[0m\x1b[0m\n" + + "EU, GB, London, ASN:0, OVH SAS | 1 | 0.00% | 0.77 ms | 0.77 ms | 0.77 ms | 0.77 ms\n" + + "EU, DE, Falkenstein, ASN:0, Hetzner Online ... | 1 | 0.00% | 5.46 ms | 5.46 ms | 5.46 ms | 5.46 ms\n" + + "Lorem ipsum | | | | | | \n" + + "Lorem ipsum dolor sit amet | | | | | | \n" + + "EU, DE, Nuremberg, ASN:0, Hetzner Online GmbH | 1 | 0.00% | 4.07 ms | 4.07 ms | 4.07 ms | 4.07 ms\n" + table := generateTable(measurement, ctx, 106) + assert.Equal(t, expectedTable, table) +} + +func TestGenerateTableMaxTruncated(t *testing.T) { + measurement := getPingGetMeasurementMultipleLocations(MeasurementID1) + ctx := getDefaultPingCtx(len(measurement.Results)) + expectedTable := "\x1b[96m\x1b[96mLoc...\x1b[0m\x1b[0m | \x1b[96m\x1b[96mSent\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Loss\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Last\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Min\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Avg\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Max\x1b[0m\x1b[0m\n" + + "EU,... | 1 | 0.00% | 0.77 ms | 0.77 ms | 0.77 ms | 0.77 ms\n" + + "EU,... | 1 | 0.00% | 5.46 ms | 5.46 ms | 5.46 ms | 5.46 ms\n" + + "EU,... | 1 | 0.00% | 4.07 ms | 4.07 ms | 4.07 ms | 4.07 ms\n" + table := generateTable(measurement, ctx, 0) + assert.Equal(t, expectedTable, table) +} + func TestUpdateMeasurementStats(t *testing.T) { stats := model.MeasurementStats{ Sent: 2, @@ -210,23 +222,15 @@ func TestGetRowValuesNoPacketsRcv(t *testing.T) { Avg: -1, Max: -1, } - result := model.MeasurementResponse{ - Probe: model.ProbeData{ - Continent: "EU", - Country: "GB", - City: "London", - Network: "OVH SAS", - }, - } - rowValues := getRowValues(&result, &stats) - assert.Equal(t, []string{ - "EU, GB, London, ASN:0, OVH SAS", - formatValue("1", 4, pterm.FgDefault), - formatValue("0.00%", 7, pterm.FgDefault), - formatValue("-", 8, pterm.FgDefault), - formatValue("-", 8, pterm.FgDefault), - formatValue("-", 8, pterm.FgDefault), - formatValue("-", 8, pterm.FgDefault), + rowValues := getRowValues(&stats) + assert.Equal(t, [7]string{ + "", + "1", + "0.00%", + "-", + "-", + "-", + "-", }, rowValues) } @@ -241,23 +245,15 @@ func TestGetRowValues(t *testing.T) { Avg: 8.3456, Max: 123.4567, } - result := model.MeasurementResponse{ - Probe: model.ProbeData{ - Continent: "EU", - Country: "GB", - City: "London", - Network: "OVH SAS", - }, - } - rowValues := getRowValues(&result, &stats) - assert.Equal(t, []string{ - "EU, GB, London, ASN:0, OVH SAS", - formatValue("100", 4, pterm.FgDefault), - formatValue("10.00%", 7, pterm.FgDefault), - formatValue("12.3 ms", 8, pterm.FgDefault), - formatValue("1.23 ms", 8, pterm.FgDefault), - formatValue("8.35 ms", 8, pterm.FgDefault), - formatValue("123 ms", 8, pterm.FgDefault), + rowValues := getRowValues(&stats) + assert.Equal(t, [7]string{ + "", + "100", + "10.00%", + "12.3 ms", + "1.23 ms", + "8.35 ms", + "123 ms", }, rowValues) } diff --git a/view/utils_test.go b/view/utils_test.go index b235bb2..5740dee 100644 --- a/view/utils_test.go +++ b/view/utils_test.go @@ -2,6 +2,7 @@ package view import ( "encoding/json" + "math" "github.com/jsdelivr/globalping-cli/model" ) @@ -116,3 +117,16 @@ func getPingGetMeasurementMultipleLocations(id string) *model.GetMeasurement { }, } } + +func getDefaultPingCtx(size int) *model.Context { + ctx := &model.Context{ + Stats: make([]model.MeasurementStats, size), + } + for i := range ctx.Stats { + ctx.Stats[i].Last = -1 + ctx.Stats[i].Min = math.MaxFloat64 + ctx.Stats[i].Avg = -1 + ctx.Stats[i].Max = -1 + } + return ctx +} From 2e3e18dfc53ed24d7f8e335b9c39afe72c390d49 Mon Sep 17 00:00:00 2001 From: Radu Lucut Date: Tue, 23 Jan 2024 10:54:05 +0200 Subject: [PATCH 11/27] Fix limit flag --- cmd/ping.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/ping.go b/cmd/ping.go index 3657539..a1e96f2 100644 --- a/cmd/ping.go +++ b/cmd/ping.go @@ -49,7 +49,7 @@ Examples: return err } if ctx.Infinite { - ctx.Limit = max(ctx.Limit, 5) // Limit to 5 probes + ctx.Limit = min(ctx.Limit, 5) // Limit to 5 probes ctx.Packets = 16 // Default to 16 packets for { ctx.From, err = ping(cmd) From a291e5f2b12442c633302d1bb6b5689f061bd7f7 Mon Sep 17 00:00:00 2001 From: Radu Lucut Date: Thu, 25 Jan 2024 21:58:38 +0200 Subject: [PATCH 12/27] Parse and handle in-progress measurement --- client/client_test.go | 16 +-- cmd/http.go | 1 - cmd/mtr.go | 4 +- cmd/root.go | 1 - model/get.go | 29 ++-- model/post.go | 2 +- view/default.go | 2 +- view/infinite.go | 320 +++++++++++++++++++++++++++++++++--------- view/infinite_test.go | 308 ++++++++++++++++++++++++---------------- view/latency.go | 2 +- view/utils_test.go | 12 +- view/view.go | 10 +- view/view_test.go | 6 +- 13 files changed, 481 insertions(+), 232 deletions(-) diff --git a/client/client_test.go b/client/client_test.go index d69e27d..8b20f09 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -230,7 +230,7 @@ func testGetPing(t *testing.T) { assert.Equal(t, "abcd", res.ID) assert.Equal(t, "ping", res.Type) - assert.Equal(t, "finished", res.Status) + assert.Equal(t, model.StatusFinished, res.Status) assert.Equal(t, "2023-02-17T18:11:52.825Z", res.CreatedAt) assert.Equal(t, "2023-02-17T18:11:53.969Z", res.UpdatedAt) assert.Equal(t, 1, res.ProbesCount) @@ -327,7 +327,7 @@ func testGetTraceroute(t *testing.T) { assert.Equal(t, "abcd", res.ID) assert.Equal(t, "traceroute", res.Type) - assert.Equal(t, "finished", res.Status) + assert.Equal(t, model.StatusFinished, res.Status) assert.Equal(t, "2023-02-23T07:55:23.414Z", res.CreatedAt) assert.Equal(t, "2023-02-23T07:55:25.496Z", res.UpdatedAt) assert.Equal(t, 1, res.ProbesCount) @@ -405,7 +405,7 @@ func testGetDns(t *testing.T) { assert.Equal(t, "abcd", res.ID) assert.Equal(t, "dns", res.Type) - assert.Equal(t, "finished", res.Status) + assert.Equal(t, model.StatusFinished, res.Status) assert.Equal(t, "2023-02-23T08:00:37.431Z", res.CreatedAt) assert.Equal(t, "2023-02-23T08:00:37.640Z", res.UpdatedAt) assert.Equal(t, 1, res.ProbesCount) @@ -421,7 +421,7 @@ func testGetDns(t *testing.T) { assert.Equal(t, 0, len(res.Results[0].Probe.Tags)) assert.Equal(t, "DNS", res.Results[0].Result.RawOutput) - assert.Equal(t, "finished", res.Results[0].Result.Status) + assert.Equal(t, model.StatusFinished, res.Results[0].Result.Status) assert.IsType(t, json.RawMessage{}, res.Results[0].Result.TimingsRaw) // Test timings @@ -529,7 +529,7 @@ func testGetMtr(t *testing.T) { assert.Equal(t, "abcd", res.ID) assert.Equal(t, "mtr", res.Type) - assert.Equal(t, "finished", res.Status) + assert.Equal(t, model.StatusFinished, res.Status) assert.Equal(t, "2023-02-23T08:08:25.187Z", res.CreatedAt) assert.Equal(t, "2023-02-23T08:08:29.829Z", res.UpdatedAt) assert.Equal(t, 1, res.ProbesCount) @@ -545,7 +545,7 @@ func testGetMtr(t *testing.T) { assert.Equal(t, 0, len(res.Results[0].Probe.Tags)) assert.Equal(t, "MTR", res.Results[0].Result.RawOutput) - assert.Equal(t, "finished", res.Results[0].Result.Status) + assert.Equal(t, model.StatusFinished, res.Results[0].Result.Status) assert.IsType(t, json.RawMessage{}, res.Results[0].Result.TimingsRaw) } @@ -636,7 +636,7 @@ func testGetHttp(t *testing.T) { assert.Equal(t, "abcd", res.ID) assert.Equal(t, "http", res.Type) - assert.Equal(t, "finished", res.Status) + assert.Equal(t, model.StatusFinished, res.Status) assert.Equal(t, "2023-02-23T08:16:11.335Z", res.CreatedAt) assert.Equal(t, "2023-02-23T08:16:12.548Z", res.UpdatedAt) assert.Equal(t, 1, res.ProbesCount) @@ -652,7 +652,7 @@ func testGetHttp(t *testing.T) { assert.Equal(t, 0, len(res.Results[0].Probe.Tags)) assert.Equal(t, "HTTP", res.Results[0].Result.RawOutput) - assert.Equal(t, "finished", res.Results[0].Result.Status) + assert.Equal(t, model.StatusFinished, res.Results[0].Result.Status) assert.IsType(t, json.RawMessage{}, res.Results[0].Result.TimingsRaw) // Test timings diff --git a/cmd/http.go b/cmd/http.go index a385e1e..55b6f0b 100644 --- a/cmd/http.go +++ b/cmd/http.go @@ -199,7 +199,6 @@ func buildHttpMeasurementRequest() (*model.PostMeasurement, error) { opts.Options = &model.MeasurementOptions{ Protocol: overrideOpt(urlData.Protocol, httpCmdOpts.Protocol), Port: overrideOptInt(urlData.Port, httpCmdOpts.Port), - Packets: packets, Request: &model.RequestOptions{ Path: overrideOpt(urlData.Path, httpCmdOpts.Path), Query: overrideOpt(urlData.Query, httpCmdOpts.Query), diff --git a/cmd/mtr.go b/cmd/mtr.go index 5c86b65..a925823 100644 --- a/cmd/mtr.go +++ b/cmd/mtr.go @@ -60,7 +60,7 @@ Examples: Options: &model.MeasurementOptions{ Protocol: protocol, Port: port, - Packets: packets, + Packets: ctx.Packets, }, } isPreviousMeasurementId := false @@ -97,5 +97,5 @@ func init() { // 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(&packets, "packets", 0, "Specifies the number of packets to send to each hop (default 3)") + mtrCmd.Flags().IntVar(&ctx.Packets, "packets", 0, "Specifies the number of packets to send to each hop (default 3)") } diff --git a/cmd/root.go b/cmd/root.go index d37f38e..48d337a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,7 +14,6 @@ var ( // cfgFile string // Additional flags - packets int protocol string port int resolver string diff --git a/model/get.go b/model/get.go index 8dc1fbf..3595fc1 100644 --- a/model/get.go +++ b/model/get.go @@ -2,7 +2,7 @@ package model import "encoding/json" -// Modeled from https://www.jsdelivr.com/docs/api.globalping.io +// Docs: https://www.jsdelivr.com/docs/api.globalping.io type ProbeData struct { Continent string `json:"continent"` @@ -15,15 +15,24 @@ type ProbeData struct { Tags []string `json:"tags,omitempty"` } +type MeasurementStatus string + +const ( + StatusInProgress MeasurementStatus = "in-progress" + StatusFailed MeasurementStatus = "failed" + StatusOffline MeasurementStatus = "offline" + StatusFinished MeasurementStatus = "finished" +) + type ResultData struct { - Status string `json:"status"` - RawOutput string `json:"rawOutput"` - RawHeaders string `json:"rawHeaders"` - RawBody string `json:"rawBody"` - ResolvedAddress string `json:"resolvedAddress"` - ResolvedHostname string `json:"resolvedHostname"` - StatsRaw json.RawMessage `json:"stats,omitempty"` - TimingsRaw json.RawMessage `json:"timings,omitempty"` + Status MeasurementStatus `json:"status"` + RawOutput string `json:"rawOutput"` + RawHeaders string `json:"rawHeaders"` + RawBody string `json:"rawBody"` + ResolvedAddress string `json:"resolvedAddress"` + ResolvedHostname string `json:"resolvedHostname"` + StatsRaw json.RawMessage `json:"stats,omitempty"` + TimingsRaw json.RawMessage `json:"timings,omitempty"` } type PingStats struct { @@ -64,7 +73,7 @@ type MeasurementResponse struct { type GetMeasurement struct { ID string `json:"id"` Type string `json:"type"` - Status string `json:"status"` + Status MeasurementStatus `json:"status"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` Target string `json:"target"` diff --git a/model/post.go b/model/post.go index 565bf23..570d558 100644 --- a/model/post.go +++ b/model/post.go @@ -1,6 +1,6 @@ package model -// Modeled from https://github.com/jsdelivr/globalping/blob/master/docs/measurement/post-create.md +// Docs: https://www.jsdelivr.com/docs/api.globalping.io // Nested structs type Locations struct { diff --git a/view/default.go b/view/default.go index 6230983..fe9c3c6 100644 --- a/view/default.go +++ b/view/default.go @@ -18,7 +18,7 @@ func OutputDefault(id string, data *model.GetMeasurement, ctx model.Context, m m } // Output slightly different format if state is available - fmt.Fprintln(os.Stderr, generateHeader(result, !ctx.CI)) + fmt.Fprintln(os.Stderr, generateProbeInfo(result, !ctx.CI)) if isBodyOnlyHttpGet(ctx, m) { fmt.Println(strings.TrimSpace(result.Result.RawBody)) diff --git a/view/infinite.go b/view/infinite.go index 428dca3..24d7862 100644 --- a/view/infinite.go +++ b/view/infinite.go @@ -1,9 +1,11 @@ package view import ( + "bufio" "errors" "fmt" "math" + "strconv" "strings" "time" @@ -32,55 +34,80 @@ func OutputInfinite(id string, ctx *model.Context) error { return err } } - // Wait for results to be complete - for res.Status == "in-progress" { - time.Sleep(apiPollInterval) - res, err = fetcher.GetMeasurement(res.ID) - if err != nil { - return err + + if ctx.Latency || ctx.JsonOutput { + for res.Status == model.StatusInProgress { + time.Sleep(apiPollInterval) + res, err = fetcher.GetMeasurement(res.ID) + if err != nil { + return err + } + } + if ctx.Latency { + return OutputLatency(id, res, *ctx) } - } - if ctx.Latency { - return OutputLatency(id, res, *ctx) - } - if ctx.JsonOutput { - return OutputJson(id, fetcher, *ctx) + if ctx.JsonOutput { + return OutputJson(id, fetcher, *ctx) + } } if len(res.Results) == 1 { - return outputSingleLocation(res, ctx) + return outputSingleLocation(fetcher, res, ctx) } - return outputMultipleLocations(res, ctx) + return outputMultipleLocations(fetcher, res, ctx) } -func outputSingleLocation(res *model.GetMeasurement, ctx *model.Context) error { - measurement := &res.Results[0] +func outputSingleLocation( + fetcher client.MeasurementsFetcher, + res *model.GetMeasurement, + ctx *model.Context, +) error { if len(ctx.Stats) == 0 { - // Initialize state ctx.Stats = make([]model.MeasurementStats, 1) - // Print header - fmt.Println(generateHeader(measurement, !ctx.CI)) - fmt.Printf("PING %s (%s) 56(84) bytes of data.\n", res.Target, measurement.Result.ResolvedAddress) } - timings, err := client.DecodePingTimings(measurement.Result.TimingsRaw) - if err != nil { - return err - } - for i := range timings { - ctx.Stats[0].Sent++ - t := timings[i] - fmt.Printf("64 bytes from %s (%s): icmp_seq=%d ttl=%d time=%.2f ms\n", - measurement.Result.ResolvedHostname, - measurement.Result.ResolvedAddress, - ctx.Stats[0].Sent, - t.TTL, - t.RTT) + printHeader := true + linesPrinted := 0 + var err error + for { + measurement := &res.Results[0] + if measurement.Result.RawOutput != "" { + parsedOutput, err := parsePingRawOutput(measurement, ctx.Stats[0].Sent) + if err != nil { + return err + } + if printHeader && ctx.Stats[0].Sent == 0 { + fmt.Println(generateProbeInfo(measurement, !ctx.CI)) + fmt.Printf("PING %s (%s) 56(84) bytes of data.\n", + measurement.Result.ResolvedHostname, + measurement.Result.ResolvedAddress, + ) + printHeader = false + } + for linesPrinted < len(parsedOutput.RawPacketLines) { + fmt.Println(parsedOutput.RawPacketLines[linesPrinted]) + linesPrinted++ + } + if res.Status != model.StatusInProgress { + ctx.Stats[0].Sent += parsedOutput.Stats.Total + } + } + if res.Status != model.StatusInProgress { + break + } + time.Sleep(apiPollInterval) + res, err = fetcher.GetMeasurement(res.ID) + if err != nil { + return err + } } return nil } -func outputMultipleLocations(res *model.GetMeasurement, ctx *model.Context) error { +func outputMultipleLocations( + fetcher client.MeasurementsFetcher, + res *model.GetMeasurement, + ctx *model.Context) error { var err error if len(ctx.Stats) == 0 { // Initialize state @@ -97,8 +124,20 @@ func outputMultipleLocations(res *model.GetMeasurement, ctx *model.Context) erro return errors.New("failed to start writer: " + err.Error()) } } - ctx.Area.Update(generateTable(res, ctx, pterm.GetTerminalWidth()-4)) - + for { + o := generateTable(res, ctx, pterm.GetTerminalWidth()-4) + if o != nil { + ctx.Area.Update(*o) + } + if res.Status != model.StatusInProgress { + break + } + time.Sleep(apiPollInterval) + res, err = fetcher.GetMeasurement(res.ID) + if err != nil { + return err + } + } return nil } @@ -112,27 +151,26 @@ func formatDuration(ms float64) string { return fmt.Sprintf("%.0f ms", ms) } -func generateTable(res *model.GetMeasurement, ctx *model.Context, areaWidth int) string { +func generateTable(res *model.GetMeasurement, ctx *model.Context, areaWidth int) *string { table := [][7]string{{"Location", "Sent", "Loss", "Last", "Min", "Avg", "Max"}} // Calculate max column width and max line width // We handle multi-line values only for the first column maxLineWidth := 0 - colMax := [7]int{ - len(table[0][0]), - 4, - 7, - 8, - 8, - 8, - 8, - } + colMax := [7]int{len(table[0][0]), 4, 7, 8, 8, 8, 8} for i := 1; i < len(table[0]); i++ { maxLineWidth += len(table[i]) + len(colSeparator) } + skip := false for i := range res.Results { - result := &res.Results[i] - stats := &ctx.Stats[i] - updateMeasurementStats(stats, result) + measurement := &res.Results[i] + if measurement.Result.RawOutput == "" { + skip = true + break + } + stats, _ := mergeMeasurementStats(ctx.Stats[i], measurement) + if measurement.Result.Status != model.StatusInProgress { + ctx.Stats[i] = *stats + } row := getRowValues(stats) rowWidth := 0 for j := 1; j < len(row); j++ { @@ -140,10 +178,13 @@ func generateTable(res *model.GetMeasurement, ctx *model.Context, areaWidth int) colMax[j] = max(colMax[j], len(row[j])) } maxLineWidth = max(maxLineWidth, rowWidth) - row[0] = getLocationText(result) + row[0] = getLocationText(measurement) colMax[0] = max(colMax[0], len(row[0])) table = append(table, row) } + if skip { + return nil + } remainingWidth := max(areaWidth-maxLineWidth, 6) // Remaining width for first column colMax[0] = min(colMax[0], remainingWidth) // Truncate first column if necessary // Generate table string @@ -179,32 +220,44 @@ func generateTable(res *model.GetMeasurement, ctx *model.Context, areaWidth int) output += lines[j] + "\n" } } - return output + return &output } -func updateMeasurementStats(localStats *model.MeasurementStats, result *model.MeasurementResponse) error { - stats, err := client.DecodePingStats(result.Result.StatsRaw) - if err != nil { - return err - } - timings, err := client.DecodePingTimings(result.Result.TimingsRaw) - if err != nil { - return err +func mergeMeasurementStats(mStats model.MeasurementStats, measurement *model.MeasurementResponse) (*model.MeasurementStats, error) { + var pStats *model.PingStats + var timings []model.PingTiming + var err error + if measurement.Result.Status == model.StatusInProgress { + o, err := parsePingRawOutput(measurement, mStats.Sent) + if err != nil { + return nil, err + } + pStats = o.Stats + timings = o.Timings + } else { + pStats, err = client.DecodePingStats(measurement.Result.StatsRaw) + if err != nil { + return nil, err + } + timings, err = client.DecodePingTimings(measurement.Result.TimingsRaw) + if err != nil { + return nil, err + } } - if stats.Rcv > 0 { - if stats.Min < localStats.Min && stats.Min != 0 { - localStats.Min = stats.Min + if pStats.Rcv > 0 { + if pStats.Min < mStats.Min && pStats.Min != 0 { + mStats.Min = pStats.Min } - if stats.Max > localStats.Max { - localStats.Max = stats.Max + if pStats.Max > mStats.Max { + mStats.Max = pStats.Max } - localStats.Avg = (localStats.Avg*float64(localStats.Sent) + stats.Avg*float64(stats.Total)) / float64(localStats.Sent+stats.Total) - localStats.Last = timings[len(timings)-1].RTT + mStats.Avg = (mStats.Avg*float64(mStats.Sent) + pStats.Avg*float64(pStats.Total)) / float64(mStats.Sent+pStats.Total) + mStats.Last = timings[len(timings)-1].RTT } - localStats.Sent += stats.Total - localStats.Lost += stats.Drop - localStats.Loss = float64(localStats.Lost) / float64(localStats.Sent) * 100 - return nil + mStats.Sent += pStats.Total + mStats.Lost += pStats.Drop + mStats.Loss = float64(mStats.Lost) / float64(mStats.Sent) * 100 + return &mStats, nil } func getRowValues(stats *model.MeasurementStats) [7]string { @@ -248,3 +301,130 @@ func formatValue(v string, color pterm.Color, width int, toRight bool) string { } return v } + +type ParsedPingOutput struct { + RawPacketLines []string + Timings []model.PingTiming + Stats *model.PingStats +} + +// If startIncmpSeq is -1, RawPacketLines will be empty +func parsePingRawOutput(m *model.MeasurementResponse, startIncmpSeq int) (*ParsedPingOutput, error) { + scanner := bufio.NewScanner(strings.NewReader(m.Result.RawOutput)) + scanner.Scan() + header := scanner.Text() + words := strings.Split(header, " ") + if len(words) > 2 { + m.Result.ResolvedHostname = words[1] + if len(words[2]) < 2 { + return nil, errors.New("could not parse ping header") + } + m.Result.ResolvedAddress = words[2][1 : len(words[2])-1] + } else { + return nil, errors.New("could not parse ping header") + } + + res := &ParsedPingOutput{ + Timings: make([]model.PingTiming, 0), + Stats: &model.PingStats{ + Min: math.MaxFloat64, + Max: -1, + Avg: -1, + }, + } + sentMap := make([]bool, 0) + for scanner.Scan() { + line := scanner.Text() + if len(line) == 0 { + break + } + // Find icmp_seq + icmp_seq := -1 + icmp_seq_index := 0 + var err error + words := strings.Split(line, " ") + for icmp_seq_index < len(words) { + if strings.HasPrefix(words[icmp_seq_index], "icmp_seq=") { + icmp_seq, err = strconv.Atoi(words[icmp_seq_index][9:]) + icmp_seq-- // icmp_seq starts at 1 + if err != nil { + return nil, errors.New("could not parse ping header: " + err.Error()) + } + break + } + icmp_seq_index++ + } + if icmp_seq >= len(sentMap) { + sentMap = append(sentMap, false) + } + // Get timing + if icmp_seq != -1 { + if words[1] == "bytes" && words[2] == "from" { + if !sentMap[icmp_seq] { + res.Stats.Total++ + } + res.Stats.Rcv++ + ttl, _ := strconv.Atoi(words[icmp_seq_index+1][4:]) + rtt, _ := strconv.ParseFloat(words[icmp_seq_index+2][5:], 64) + res.Stats.Min = math.Min(res.Stats.Min, rtt) + res.Stats.Max = math.Max(res.Stats.Max, rtt) + if res.Stats.Rcv == 1 { + res.Stats.Avg = rtt + } else { + res.Stats.Avg = (res.Stats.Avg*float64(res.Stats.Rcv-1) + rtt) / float64(res.Stats.Rcv) + } + res.Timings = append(res.Timings, model.PingTiming{ + TTL: ttl, + RTT: rtt, + }) + } else { + if !sentMap[icmp_seq] { + res.Stats.Total++ + } + sentMap[icmp_seq] = true + } + if startIncmpSeq != -1 { + words[icmp_seq_index] = "icmp_seq=" + strconv.Itoa(startIncmpSeq+icmp_seq+1) + line = strings.Join(words, " ") + } + } + if startIncmpSeq != -1 { + res.RawPacketLines = append(res.RawPacketLines, line) + } + } + // Parse summary + hasSummary := scanner.Scan() + if !hasSummary { + res.Stats.Drop = res.Stats.Total - res.Stats.Rcv + res.Stats.Loss = float64(res.Stats.Drop) / float64(res.Stats.Total) * 100 + return res, nil + } + scanner.Scan() // skip --- ping statistics --- + line := scanner.Text() + words = strings.Split(line, " ") + if len(words) < 3 { + return res, nil + } + if words[1] == "packets" && words[2] == "transmitted," { + res.Stats.Total, _ = strconv.Atoi(words[0]) + res.Stats.Rcv, _ = strconv.Atoi(words[3]) + res.Stats.Loss, _ = strconv.ParseFloat(words[5][:len(words[5])-1], 64) + res.Stats.Drop = res.Stats.Total - res.Stats.Rcv + } + hasSummary = scanner.Scan() + if !hasSummary { + return res, nil + } + line = scanner.Text() + words = strings.Split(line, " ") + if len(words) < 2 { + return res, nil + } + if words[0] == "rtt" && words[1] == "min/avg/max/mdev" { + words = strings.Split(words[3], "/") + res.Stats.Min, _ = strconv.ParseFloat(words[0], 64) + res.Stats.Avg, _ = strconv.ParseFloat(words[1], 64) + res.Stats.Max, _ = strconv.ParseFloat(words[2], 64) + } + return res, nil +} diff --git a/view/infinite_test.go b/view/infinite_test.go index e34f86f..fbb9f6c 100644 --- a/view/infinite_test.go +++ b/view/infinite_test.go @@ -2,112 +2,109 @@ package view import ( "encoding/json" - "io" "math" - "os" "testing" "github.com/jsdelivr/globalping-cli/model" - "github.com/pterm/pterm" "github.com/stretchr/testify/assert" ) -func TestOutputInfinite_SingleLocation(t *testing.T) { - osStdErr := os.Stderr - osStdOut := os.Stdout +// func TestOutputInfinite_SingleLocation(t *testing.T) { +// osStdErr := os.Stderr +// osStdOut := os.Stdout - rErr, wErr, err := os.Pipe() - assert.NoError(t, err) - defer rErr.Close() +// rErr, wErr, err := os.Pipe() +// assert.NoError(t, err) +// defer rErr.Close() - rOut, wOut, err := os.Pipe() - assert.NoError(t, err) - defer rOut.Close() +// rOut, wOut, err := os.Pipe() +// assert.NoError(t, err) +// defer rOut.Close() - os.Stderr = wErr - os.Stdout = wOut +// os.Stderr = wErr +// os.Stdout = wOut - defer func() { - os.Stderr = osStdErr - os.Stdout = osStdOut - }() +// defer func() { +// os.Stderr = osStdErr +// os.Stdout = osStdOut +// }() - ctx := &model.Context{ - Cmd: "ping", - } - measurement := getPingGetMeasurement(MeasurementID1) +// ctx := &model.Context{ +// Cmd: "ping", +// } +// measurement := getPingGetMeasurement(MeasurementID1) - err = outputSingleLocation(measurement, ctx) - assert.NoError(t, err) +// err = outputSingleLocation(measurement, ctx) +// assert.NoError(t, err) - err = outputSingleLocation(measurement, ctx) - assert.NoError(t, err) - err = outputSingleLocation(measurement, ctx) - assert.NoError(t, err) +// err = outputSingleLocation(measurement, ctx) +// assert.NoError(t, err) +// err = outputSingleLocation(measurement, ctx) +// assert.NoError(t, err) - wErr.Close() - wOut.Close() +// wErr.Close() +// wOut.Close() - errOutput, err := io.ReadAll(rErr) - assert.NoError(t, err) - assert.Equal(t, "", string(errOutput)) +// errOutput, err := io.ReadAll(rErr) +// assert.NoError(t, err) +// assert.Equal(t, "", string(errOutput)) - output, err := io.ReadAll(rOut) - assert.NoError(t, err) - assert.Equal(t, - `> EU, DE, Berlin, ASN:3320, Deutsche Telekom AG -PING cdn.jsdelivr.net (151.101.1.229) 56(84) bytes of data. -64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=60 time=17.64 ms -64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=2 ttl=60 time=17.64 ms -64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=3 ttl=60 time=17.64 ms -`, - string(output)) -} +// output, err := io.ReadAll(rOut) +// assert.NoError(t, err) +// assert.Equal(t, +// `> EU, DE, Berlin, ASN:3320, Deutsche Telekom AG +// PING cdn.jsdelivr.net (151.101.1.229) 56(84) bytes of data. +// 64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=60 time=17.64 ms +// 64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=2 ttl=60 time=17.64 ms +// 64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=3 ttl=60 time=17.64 ms +// `, +// string(output)) +// } -func TestOutputInfinite_MultipleLocations(t *testing.T) { - osStdOut := os.Stdout - r, w, err := os.Pipe() - assert.NoError(t, err) - os.Stdout = w +// func TestOutputInfinite_MultipleLocations(t *testing.T) { +// osStdOut := os.Stdout +// r, w, err := os.Pipe() +// assert.NoError(t, err) +// os.Stdout = w - ctx := &model.Context{ - Cmd: "ping", - } - measurement := getPingGetMeasurementMultipleLocations(MeasurementID1) +// ctx := &model.Context{ +// Cmd: "ping", +// } +// measurement := getPingGetMeasurementMultipleLocations(MeasurementID1) - err = outputMultipleLocations(measurement, ctx) - assert.NoError(t, err) +// err = outputMultipleLocations(measurement, ctx) +// assert.NoError(t, err) - w.Close() - os.Stdout = osStdOut +// w.Close() +// os.Stdout = osStdOut - output, err := io.ReadAll(r) - assert.NoError(t, err) - r.Close() +// output, err := io.ReadAll(r) +// assert.NoError(t, err) +// r.Close() - r, w, err = os.Pipe() - assert.NoError(t, err) - defer r.Close() - os.Stdout = w - defer func() { - os.Stdout = osStdOut - }() - - expectedCtx := getDefaultPingCtx(len(measurement.Results)) - expectedTable := generateTable(measurement, expectedCtx, 76) // 80 - 4. pterm defaults to 80 when terminal size is not detected. - area, err := pterm.DefaultArea.Start() - assert.NoError(t, err) - area.Update(expectedTable) - area.Stop() - w.Close() - os.Stdout = osStdOut +// r, w, err = os.Pipe() +// assert.NoError(t, err) +// defer r.Close() +// os.Stdout = w +// defer func() { +// os.Stdout = osStdOut +// }() - expectedOutput, err := io.ReadAll(r) - assert.NoError(t, err) - r.Close() +// expectedCtx := getDefaultPingCtx(len(measurement.Results)) +// expectedTable := generateTable(measurement, expectedCtx, 76) // 80 - 4. pterm defaults to 80 when terminal size is not detected. +// area, err := pterm.DefaultArea.Start() +// assert.NoError(t, err) +// area.Update(expectedTable) +// area.Stop() +// w.Close() +// os.Stdout = osStdOut - assert.Equal(t, string(expectedOutput), string(output)) -} +// expectedOutput, err := io.ReadAll(r) +// assert.NoError(t, err) +// r.Close() + +// assert.Equal(t, string(expectedOutput), string(output)) +// } func TestFormatDuration(t *testing.T) { d := formatDuration(1.2345) @@ -126,7 +123,7 @@ func TestGenerateTableFull(t *testing.T) { "EU, DE, Falkenstein, ASN:0, Hetzner Online GmbH | 1 | 0.00% | 5.46 ms | 5.46 ms | 5.46 ms | 5.46 ms\n" + "EU, DE, Nuremberg, ASN:0, Hetzner Online GmbH | 1 | 0.00% | 4.07 ms | 4.07 ms | 4.07 ms | 4.07 ms\n" table := generateTable(measurement, ctx, 500) - assert.Equal(t, expectedTable, table) + assert.Equal(t, expectedTable, *table) } func TestGenerateTableOneRowTruncated(t *testing.T) { @@ -138,7 +135,7 @@ func TestGenerateTableOneRowTruncated(t *testing.T) { "EU, DE, Falkenstein, ASN:0, ä½œč€…čšé›†ēš„原创... | 1 | 0.00% | 5.46 ms | 5.46 ms | 5.46 ms | 5.46 ms\n" + "EU, DE, Nuremberg, ASN:0, Hetzner Online GmbH | 1 | 0.00% | 4.07 ms | 4.07 ms | 4.07 ms | 4.07 ms\n" table := generateTable(measurement, ctx, 106) - assert.Equal(t, expectedTable, table) + assert.Equal(t, expectedTable, *table) } func TestGenerateTableMultiLineTruncated(t *testing.T) { @@ -152,7 +149,7 @@ func TestGenerateTableMultiLineTruncated(t *testing.T) { "Lorem ipsum dolor sit amet | | | | | | \n" + "EU, DE, Nuremberg, ASN:0, Hetzner Online GmbH | 1 | 0.00% | 4.07 ms | 4.07 ms | 4.07 ms | 4.07 ms\n" table := generateTable(measurement, ctx, 106) - assert.Equal(t, expectedTable, table) + assert.Equal(t, expectedTable, *table) } func TestGenerateTableMaxTruncated(t *testing.T) { @@ -163,65 +160,43 @@ func TestGenerateTableMaxTruncated(t *testing.T) { "EU,... | 1 | 0.00% | 5.46 ms | 5.46 ms | 5.46 ms | 5.46 ms\n" + "EU,... | 1 | 0.00% | 4.07 ms | 4.07 ms | 4.07 ms | 4.07 ms\n" table := generateTable(measurement, ctx, 0) - assert.Equal(t, expectedTable, table) + assert.Equal(t, expectedTable, *table) } func TestUpdateMeasurementStats(t *testing.T) { - stats := model.MeasurementStats{ - Sent: 2, - Lost: 0, - Loss: 0, - Last: 1, - Min: 1, - Avg: 1.5, - Max: 2, - } result := model.MeasurementResponse{ Result: model.ResultData{ + Status: model.StatusFinished, StatsRaw: json.RawMessage(`{"min":6,"avg":6,"max":6,"total":1,"rcv":1,"drop":0,"loss":0}`), TimingsRaw: json.RawMessage(`[{"ttl":60,"rtt":6}]`), }, } - err := updateMeasurementStats(&stats, &result) + newStats, err := mergeMeasurementStats( + model.MeasurementStats{Sent: 2, Lost: 0, Loss: 0, Last: 1, Min: 1, Avg: 1.5, Max: 2}, + &result, + ) assert.NoError(t, err) - assert.Equal(t, model.MeasurementStats{ - Sent: 3, - Lost: 0, - Loss: 0, - Last: 6, - Min: 1, - Avg: 3, - Max: 6, - }, stats) + assert.Equal(t, + &model.MeasurementStats{Sent: 3, Lost: 0, Loss: 0, Last: 6, Min: 1, Avg: 3, Max: 6}, + newStats, + ) result = model.MeasurementResponse{ Result: model.ResultData{ + Status: model.StatusFinished, StatsRaw: json.RawMessage(`{"min":0,"avg":0,"max":0,"total":1,"rcv":0,"drop":1,"loss":100}`), TimingsRaw: json.RawMessage(`[]`), }, } - err = updateMeasurementStats(&stats, &result) + newStats, err = mergeMeasurementStats(*newStats, &result) assert.NoError(t, err) - assert.Equal(t, model.MeasurementStats{ - Sent: 4, - Lost: 1, - Loss: 25, - Last: 6, - Min: 1, - Avg: 3, - Max: 6, - }, stats) + assert.Equal(t, + &model.MeasurementStats{Sent: 4, Lost: 1, Loss: 25, Last: 6, Min: 1, Avg: 3, Max: 6}, + newStats, + ) } func TestGetRowValuesNoPacketsRcv(t *testing.T) { - stats := model.MeasurementStats{ - Sent: 1, - Lost: -1, - Loss: 0, - Last: -1, - Min: math.MaxFloat64, - Avg: -1, - Max: -1, - } + stats := model.MeasurementStats{Sent: 1, Lost: -1, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1} rowValues := getRowValues(&stats) assert.Equal(t, [7]string{ "", @@ -257,3 +232,90 @@ func TestGetRowValues(t *testing.T) { }, rowValues) } + +func TestParsePingRawOutputFull(t *testing.T) { + m := &model.MeasurementResponse{ + Result: model.ResultData{ + RawOutput: `PING (142.250.65.174) 56(84) bytes of data. +64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=1 ttl=59 time=1.06 ms +64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=2 ttl=59 time=1.10 ms +64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=3 ttl=59 time=1.11 ms + +--- ping statistics --- +3 packets transmitted, 3 received, 0% packet loss, time 1002ms +rtt min/avg/max/mdev = 1.061/1.090/1.108/0.020 ms`, + }, + } + res, err := parsePingRawOutput(m, -1) + assert.NoError(t, err) + assert.Equal(t, &ParsedPingOutput{ + Timings: []model.PingTiming{ + {RTT: 1.06, TTL: 59}, + {RTT: 1.10, TTL: 59}, + {RTT: 1.11, TTL: 59}, + }, + Stats: &model.PingStats{ + Min: 1.061, Avg: 1.090, Max: 1.108, Total: 3, Rcv: 3, Drop: 0, Loss: 0, + }, + }, res) +} + +func TestParsePingRawOutputNoStats(t *testing.T) { + m := &model.MeasurementResponse{ + Result: model.ResultData{ + RawOutput: `PING (142.250.65.174) 56(84) bytes of data. +no answer yet for icmp_seq=1 +64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=1 ttl=59 time=1.06 ms +no answer yet for icmp_seq=2 +64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=2 ttl=59 time=1.10 ms +64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=3 ttl=59 time=1.11 ms +no answer yet for icmp_seq=4`, + }, + } + res, err := parsePingRawOutput(m, -1) + assert.NoError(t, err) + assert.Equal(t, &ParsedPingOutput{ + Timings: []model.PingTiming{ + {RTT: 1.06, TTL: 59}, + {RTT: 1.10, TTL: 59}, + {RTT: 1.11, TTL: 59}, + }, + Stats: &model.PingStats{ + Min: 1.06, Avg: 1.09, Max: 1.11, Total: 4, Rcv: 3, Drop: 1, Loss: 25, + }, + }, res) +} + +func TestParsePingRawOutputNoStatsWithStartIncmpSeq(t *testing.T) { + m := &model.MeasurementResponse{ + Result: model.ResultData{ + RawOutput: `PING (142.250.65.174) 56(84) bytes of data. +no answer yet for icmp_seq=1 +64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=1 ttl=59 time=1.06 ms +no answer yet for icmp_seq=2 +64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=2 ttl=59 time=1.10 ms +64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=3 ttl=59 time=1.11 ms +no answer yet for icmp_seq=4`, + }, + } + res, err := parsePingRawOutput(m, 4) + assert.NoError(t, err) + assert.Equal(t, &ParsedPingOutput{ + RawPacketLines: []string{ + "no answer yet for icmp_seq=5", + "64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=5 ttl=59 time=1.06 ms", + "no answer yet for icmp_seq=6", + "64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=6 ttl=59 time=1.10 ms", + "64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=7 ttl=59 time=1.11 ms", + "no answer yet for icmp_seq=8", + }, + Timings: []model.PingTiming{ + {RTT: 1.06, TTL: 59}, + {RTT: 1.10, TTL: 59}, + {RTT: 1.11, TTL: 59}, + }, + Stats: &model.PingStats{ + Min: 1.06, Avg: 1.09, Max: 1.11, Total: 4, Rcv: 3, Drop: 1, Loss: 25, + }, + }, res) +} diff --git a/view/latency.go b/view/latency.go index c531bae..2bcd8c8 100644 --- a/view/latency.go +++ b/view/latency.go @@ -18,7 +18,7 @@ func OutputLatency(id string, data *model.GetMeasurement, ctx model.Context) err fmt.Println() } - fmt.Fprintln(os.Stderr, generateHeader(&result, !ctx.CI)) + fmt.Fprintln(os.Stderr, generateProbeInfo(&result, !ctx.CI)) switch ctx.Cmd { case "ping": diff --git a/view/utils_test.go b/view/utils_test.go index 5740dee..043f0c2 100644 --- a/view/utils_test.go +++ b/view/utils_test.go @@ -17,7 +17,7 @@ func getPingGetMeasurement(id string) *model.GetMeasurement { return &model.GetMeasurement{ ID: id, Type: "ping", - Status: "finished", + Status: model.StatusFinished, CreatedAt: "2024-01-18T14:09:41.250Z", UpdatedAt: "2024-01-18T14:09:41.488Z", Target: "cdn.jsdelivr.net", @@ -35,7 +35,7 @@ func getPingGetMeasurement(id string) *model.GetMeasurement { Tags: []string{"eyeball-network"}, }, Result: model.ResultData{ - Status: "finished", + Status: model.StatusFinished, RawOutput: "PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data.\n64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=60 time=17.6 ms\n\n--- jsdelivr.map.fastly.net ping statistics ---\n1 packets transmitted, 1 received, 0% packet loss, time 0ms\nrtt min/avg/max/mdev = 17.639/17.639/17.639/0.000 ms", ResolvedAddress: "151.101.1.229", ResolvedHostname: "151.101.1.229", @@ -51,7 +51,7 @@ func getPingGetMeasurementMultipleLocations(id string) *model.GetMeasurement { return &model.GetMeasurement{ ID: id, Type: "ping", - Status: "finished", + Status: model.StatusFinished, CreatedAt: "2024-01-18T14:17:41.471Z", UpdatedAt: "2024-01-18T14:17:41.571Z", Target: "cdn.jsdelivr.net", @@ -68,7 +68,7 @@ func getPingGetMeasurementMultipleLocations(id string) *model.GetMeasurement { Tags: []string{"datacenter-network"}, }, Result: model.ResultData{ - Status: "finished", + Status: model.StatusFinished, RawOutput: "PING (146.75.73.229) 56(84) bytes of data.\n64 bytes from 146.75.73.229 (146.75.73.229): icmp_seq=1 ttl=52 time=0.770 ms\n\n--- ping statistics ---\n1 packets transmitted, 1 received, 0% packet loss, time 0ms\nrtt min/avg/max/mdev = 0.770/0.770/0.770/0.000 ms", ResolvedAddress: "146.75.73.229", ResolvedHostname: "146.75.73.229", @@ -87,7 +87,7 @@ func getPingGetMeasurementMultipleLocations(id string) *model.GetMeasurement { Tags: []string{"datacenter-network"}, }, Result: model.ResultData{ - Status: "finished", + Status: model.StatusFinished, RawOutput: "PING (104.16.85.20) 56(84) bytes of data.\n64 bytes from 104.16.85.20 (104.16.85.20): icmp_seq=1 ttl=55 time=5.46 ms\n\n--- ping statistics ---\n1 packets transmitted, 1 received, 0% packet loss, time 0ms\nrtt min/avg/max/mdev = 5.457/5.457/5.457/0.000 ms", ResolvedAddress: "104.16.85.20", ResolvedHostname: "104.16.85.20", @@ -106,7 +106,7 @@ func getPingGetMeasurementMultipleLocations(id string) *model.GetMeasurement { Tags: []string{"datacenter-network"}, }, Result: model.ResultData{ - Status: "finished", + Status: model.StatusFinished, RawOutput: "PING (104.16.88.20) 56(84) bytes of data.\n64 bytes from 104.16.88.20 (104.16.88.20): icmp_seq=1 ttl=58 time=4.07 ms\n\n--- ping statistics ---\n1 packets transmitted, 1 received, 0% packet loss, time 0ms\nrtt min/avg/max/mdev = 4.069/4.069/4.069/0.000 ms", ResolvedAddress: "104.16.88.20", ResolvedHostname: "104.16.88.20", diff --git a/view/view.go b/view/view.go index 9332644..7ea56d7 100644 --- a/view/view.go +++ b/view/view.go @@ -44,7 +44,7 @@ func OutputResults(id string, ctx model.Context, m model.PostMeasurement) error if ctx.CI || ctx.JsonOutput || ctx.Latency { // Poll API until the measurement is complete - for data.Status == "in-progress" { + for data.Status == model.StatusInProgress { time.Sleep(apiPollInterval) data, err = fetcher.GetMeasurement(id) if err != nil { @@ -98,7 +98,7 @@ func liveView(id string, data *model.GetMeasurement, ctx model.Context, m model. fetcher := client.NewMeasurementsFetcher(client.ApiUrl) // Poll API until the measurement is complete - for data.Status == "in-progress" { + for data.Status == model.StatusInProgress { time.Sleep(apiPollInterval) data, err = fetcher.GetMeasurement(id) if err != nil { @@ -112,7 +112,7 @@ func liveView(id string, data *model.GetMeasurement, ctx model.Context, m model. for i := range data.Results { result := &data.Results[i] // Output slightly different format if state is available - output.WriteString(generateHeader(result, !ctx.CI) + "\n") + output.WriteString(generateProbeInfo(result, !ctx.CI) + "\n") if isBodyOnlyHttpGet(ctx, m) { output.WriteString(strings.TrimSpace(result.Result.RawBody) + "\n\n") @@ -168,8 +168,8 @@ func trimOutput(output string, terminalW, terminalH int) string { return txt } -// Generate header that also checks if the probe has a state in it in the form %s, %s, (%s), %s, ASN:%d -func generateHeader(result *model.MeasurementResponse, useStyling bool) string { +// Also checks if the probe has a state in it in the form %s, %s, (%s), %s, ASN:%d +func generateProbeInfo(result *model.MeasurementResponse, useStyling bool) string { var output strings.Builder // Continent + Country + (State) + City + ASN + Network + (Region Tag) diff --git a/view/view_test.go b/view/view_test.go index 8006c76..07d0bf3 100644 --- a/view/view_test.go +++ b/view/view_test.go @@ -27,17 +27,17 @@ var ( ) func TestHeadersBase(t *testing.T) { - assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network", generateHeader(&testResult, !testContext.CI)) + assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network", generateProbeInfo(&testResult, !testContext.CI)) } func TestHeadersTags(t *testing.T) { newResult := testResult newResult.Probe.Tags = []string{"tag1", "tag2"} - assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network (tag1)", generateHeader(&newResult, !testContext.CI)) + assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network (tag1)", generateProbeInfo(&newResult, !testContext.CI)) newResult.Probe.Tags = []string{"tag", "tag2"} - assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network (tag2)", generateHeader(&newResult, !testContext.CI)) + assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network (tag2)", generateProbeInfo(&newResult, !testContext.CI)) } func TestTrimOutput(t *testing.T) { From 225d384b8922d8425a25210f177aba9b610cb295 Mon Sep 17 00:00:00 2001 From: Radu Lucut Date: Fri, 26 Jan 2024 12:37:48 +0200 Subject: [PATCH 13/27] Add more tests --- cmd/root.go | 5 +- model/root.go | 11 +- view/infinite.go | 13 +- view/infinite_test.go | 412 +++++++++++++++++++++++++++++++----------- view/utils_test.go | 42 +++-- view/view.go | 8 +- 6 files changed, 364 insertions(+), 127 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 48d337a..4a2ceb6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,6 +2,7 @@ package cmd import ( "os" + "time" "github.com/jsdelivr/globalping-cli/lib" "github.com/jsdelivr/globalping-cli/model" @@ -23,7 +24,9 @@ var ( httpCmdOpts *HttpCmdOpts opts = model.PostMeasurement{} - ctx = model.Context{} + ctx = model.Context{ + APIMinInterval: 500 * time.Millisecond, + } ) // rootCmd represents the base command when called without any subcommands diff --git a/model/root.go b/model/root.go index d26d243..7a0e118 100644 --- a/model/root.go +++ b/model/root.go @@ -1,6 +1,10 @@ package model -import "github.com/pterm/pterm" +import ( + "time" + + "github.com/pterm/pterm" +) // Used in thc client TUI type Context struct { @@ -19,8 +23,9 @@ type Context struct { Share bool // Display share message Infinite bool // Infinite flag - Area *pterm.AreaPrinter - Stats []MeasurementStats + APIMinInterval time.Duration // Minimum interval between API calls + Area *pterm.AreaPrinter + Stats []MeasurementStats } type MeasurementStats struct { diff --git a/view/infinite.go b/view/infinite.go index 24d7862..365e226 100644 --- a/view/infinite.go +++ b/view/infinite.go @@ -28,7 +28,7 @@ func OutputInfinite(id string, ctx *model.Context) error { } // Probe may not have started yet for len(res.Results) == 0 { - time.Sleep(apiPollInterval) + time.Sleep(ctx.APIMinInterval) res, err = fetcher.GetMeasurement(id) if err != nil { return err @@ -37,7 +37,7 @@ func OutputInfinite(id string, ctx *model.Context) error { if ctx.Latency || ctx.JsonOutput { for res.Status == model.StatusInProgress { - time.Sleep(apiPollInterval) + time.Sleep(ctx.APIMinInterval) res, err = fetcher.GetMeasurement(res.ID) if err != nil { return err @@ -46,7 +46,6 @@ func OutputInfinite(id string, ctx *model.Context) error { if ctx.Latency { return OutputLatency(id, res, *ctx) } - if ctx.JsonOutput { return OutputJson(id, fetcher, *ctx) } @@ -95,7 +94,7 @@ func outputSingleLocation( if res.Status != model.StatusInProgress { break } - time.Sleep(apiPollInterval) + time.Sleep(ctx.APIMinInterval) res, err = fetcher.GetMeasurement(res.ID) if err != nil { return err @@ -132,7 +131,7 @@ func outputMultipleLocations( if res.Status != model.StatusInProgress { break } - time.Sleep(apiPollInterval) + time.Sleep(ctx.APIMinInterval) res, err = fetcher.GetMeasurement(res.ID) if err != nil { return err @@ -256,7 +255,9 @@ func mergeMeasurementStats(mStats model.MeasurementStats, measurement *model.Mea } mStats.Sent += pStats.Total mStats.Lost += pStats.Drop - mStats.Loss = float64(mStats.Lost) / float64(mStats.Sent) * 100 + if mStats.Sent > 0 { + mStats.Loss = float64(mStats.Lost) / float64(mStats.Sent) * 100 + } return &mStats, nil } diff --git a/view/infinite_test.go b/view/infinite_test.go index fbb9f6c..074c5e1 100644 --- a/view/infinite_test.go +++ b/view/infinite_test.go @@ -2,109 +2,277 @@ package view import ( "encoding/json" + "io" "math" + "os" "testing" + "github.com/golang/mock/gomock" + "github.com/jsdelivr/globalping-cli/mocks" "github.com/jsdelivr/globalping-cli/model" + "github.com/pterm/pterm" "github.com/stretchr/testify/assert" ) -// func TestOutputInfinite_SingleLocation(t *testing.T) { -// osStdErr := os.Stderr -// osStdOut := os.Stdout - -// rErr, wErr, err := os.Pipe() -// assert.NoError(t, err) -// defer rErr.Close() - -// rOut, wOut, err := os.Pipe() -// assert.NoError(t, err) -// defer rOut.Close() - -// os.Stderr = wErr -// os.Stdout = wOut - -// defer func() { -// os.Stderr = osStdErr -// os.Stdout = osStdOut -// }() - -// ctx := &model.Context{ -// Cmd: "ping", -// } -// measurement := getPingGetMeasurement(MeasurementID1) - -// err = outputSingleLocation(measurement, ctx) -// assert.NoError(t, err) - -// err = outputSingleLocation(measurement, ctx) -// assert.NoError(t, err) -// err = outputSingleLocation(measurement, ctx) -// assert.NoError(t, err) - -// wErr.Close() -// wOut.Close() - -// errOutput, err := io.ReadAll(rErr) -// assert.NoError(t, err) -// assert.Equal(t, "", string(errOutput)) - -// output, err := io.ReadAll(rOut) -// assert.NoError(t, err) -// assert.Equal(t, -// `> EU, DE, Berlin, ASN:3320, Deutsche Telekom AG -// PING cdn.jsdelivr.net (151.101.1.229) 56(84) bytes of data. -// 64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=60 time=17.64 ms -// 64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=2 ttl=60 time=17.64 ms -// 64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=3 ttl=60 time=17.64 ms -// `, -// string(output)) -// } - -// func TestOutputInfinite_MultipleLocations(t *testing.T) { -// osStdOut := os.Stdout -// r, w, err := os.Pipe() -// assert.NoError(t, err) -// os.Stdout = w - -// ctx := &model.Context{ -// Cmd: "ping", -// } -// measurement := getPingGetMeasurementMultipleLocations(MeasurementID1) - -// err = outputMultipleLocations(measurement, ctx) -// assert.NoError(t, err) - -// w.Close() -// os.Stdout = osStdOut - -// output, err := io.ReadAll(r) -// assert.NoError(t, err) -// r.Close() - -// r, w, err = os.Pipe() -// assert.NoError(t, err) -// defer r.Close() -// os.Stdout = w -// defer func() { -// os.Stdout = osStdOut -// }() - -// expectedCtx := getDefaultPingCtx(len(measurement.Results)) -// expectedTable := generateTable(measurement, expectedCtx, 76) // 80 - 4. pterm defaults to 80 when terminal size is not detected. -// area, err := pterm.DefaultArea.Start() -// assert.NoError(t, err) -// area.Update(expectedTable) -// area.Stop() -// w.Close() -// os.Stdout = osStdOut - -// expectedOutput, err := io.ReadAll(r) -// assert.NoError(t, err) -// r.Close() - -// assert.Equal(t, string(expectedOutput), string(output)) -// } +func TestOutputSingleLocationInProgress(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + osStdErr := os.Stderr + osStdOut := os.Stdout + + rErr, wErr, err := os.Pipe() + assert.NoError(t, err) + defer rErr.Close() + + rOut, wOut, err := os.Pipe() + assert.NoError(t, err) + defer rOut.Close() + + os.Stderr = wErr + os.Stdout = wOut + + defer func() { + os.Stderr = osStdErr + os.Stdout = osStdOut + }() + + fetcher := mocks.NewMockMeasurementsFetcher(ctrl) + measurement := getPingGetMeasurement(measurementID1) + measurement.Status = model.StatusInProgress + measurement.Results[0].Result.Status = model.StatusInProgress + measurement.Results[0].Result.RawOutput = `PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data.` + callCount := 1 // 1st call is done in the caller. + fetcher.EXPECT().GetMeasurement(measurementID1).DoAndReturn(func(id string) (*model.GetMeasurement, error) { + callCount++ + if callCount == 2 { + measurement.Results[0].Result.RawOutput = `PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=60 time=17.6 ms` + } + if callCount == 3 { + measurement.Results[0].Result.RawOutput = `PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=60 time=17.6 ms +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=2 ttl=30 time=17.3 ms` + } + if callCount == 4 { + measurement.Status = model.StatusFinished + measurement.Results[0].Result.Status = model.StatusFinished + measurement.Results[0].Result.RawOutput = `PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=60 time=17.6 ms +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=2 ttl=30 time=17.3 ms +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=3 ttl=10 time=17.0 ms` + } + return measurement, nil + }).Times(3) + + ctx := &model.Context{ + Cmd: "ping", + APIMinInterval: 0, + } + + err = outputSingleLocation(fetcher, measurement, ctx) + assert.NoError(t, err) + + wErr.Close() + wOut.Close() + + errOutput, err := io.ReadAll(rErr) + assert.NoError(t, err) + assert.Equal(t, "", string(errOutput)) + + output, err := io.ReadAll(rOut) + assert.NoError(t, err) + assert.Equal(t, + `> EU, DE, Berlin, ASN:3320, Deutsche Telekom AG +PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=60 time=17.6 ms +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=2 ttl=30 time=17.3 ms +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=3 ttl=10 time=17.0 ms +`, + string(output)) +} + +func TestOutputSingleLocationMultipleCalls(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + osStdErr := os.Stderr + osStdOut := os.Stdout + + rErr, wErr, err := os.Pipe() + assert.NoError(t, err) + defer rErr.Close() + + rOut, wOut, err := os.Pipe() + assert.NoError(t, err) + defer rOut.Close() + + os.Stderr = wErr + os.Stdout = wOut + + defer func() { + os.Stderr = osStdErr + os.Stdout = osStdOut + }() + + fetcher := mocks.NewMockMeasurementsFetcher(ctrl) + measurement := getPingGetMeasurement(measurementID1) + fetcher.EXPECT().GetMeasurement(measurementID1).Times(0).Return(measurement, nil) + + ctx := &model.Context{ + Cmd: "ping", + } + + err = outputSingleLocation(fetcher, measurement, ctx) + assert.NoError(t, err) + err = outputSingleLocation(fetcher, measurement, ctx) + assert.NoError(t, err) + err = outputSingleLocation(fetcher, measurement, ctx) + assert.NoError(t, err) + + wErr.Close() + wOut.Close() + + errOutput, err := io.ReadAll(rErr) + assert.NoError(t, err) + assert.Equal(t, "", string(errOutput)) + + output, err := io.ReadAll(rOut) + assert.NoError(t, err) + assert.Equal(t, + `> EU, DE, Berlin, ASN:3320, Deutsche Telekom AG +PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=60 time=17.6 ms +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=2 ttl=60 time=17.6 ms +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=3 ttl=60 time=17.6 ms +`, + string(output)) +} + +func TestOutputMultipleLocationsInProgress(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + osStdOut := os.Stdout + r, w, err := os.Pipe() + assert.NoError(t, err) + os.Stdout = w + + ctx := &model.Context{ + Cmd: "ping", + APIMinInterval: 0, + } + fetcher := mocks.NewMockMeasurementsFetcher(ctrl) + measurement := getPingGetMeasurementMultipleLocations(measurementID1) + measurement.Status = model.StatusInProgress + measurement.Results[0].Result.Status = model.StatusInProgress + measurement.Results[0].Result.RawOutput = `PING (146.75.73.229) 56(84) bytes of data.` + measurement.Results[0].Result.StatsRaw = json.RawMessage(`{}`) + measurement.Results[0].Result.TimingsRaw = json.RawMessage(`[]`) + + expectedCtx := getDefaultPingCtx(len(measurement.Results)) + var t1, t2, t3 *string + t1 = generateTable(measurement, expectedCtx, 76) // 80 - 4. pterm defaults to 80 when terminal size is not detected. + + callCount := 1 // 1st call is done in the caller. + fetcher.EXPECT().GetMeasurement(measurementID1).DoAndReturn(func(id string) (*model.GetMeasurement, error) { + callCount++ + if callCount == 2 { + measurement.Results[0].Result.RawOutput = `PING (146.75.73.229) 56(84) bytes of data. +64 bytes from 146.75.73.229 (146.75.73.229): icmp_seq=1 ttl=52 time=0.7 ms +no answer yet for icmp_seq=2` + t2 = generateTable(measurement, expectedCtx, 76) + } + if callCount == 3 { + measurement.Status = model.StatusFinished + measurement.Results[0].Result.Status = model.StatusFinished + measurement.Results[0].Result.RawOutput = "_" + measurement.Results[0].Result.StatsRaw = json.RawMessage(`{"min":0.7,"avg":0.75,"max":0.8,"total":4,"rcv":2,"drop":2,"loss":2}`) + measurement.Results[0].Result.TimingsRaw = json.RawMessage(`[{"ttl":52,"rtt":0.7},{"ttl":52,"rtt":0.8}]`) + t3 = generateTable(measurement, expectedCtx, 76) + } + return measurement, nil + }).Times(2) + err = outputMultipleLocations(fetcher, measurement, ctx) + assert.NoError(t, err) + + w.Close() + os.Stdout = osStdOut + + output, err := io.ReadAll(r) + assert.NoError(t, err) + r.Close() + + r, w, err = os.Pipe() + assert.NoError(t, err) + defer r.Close() + os.Stdout = w + defer func() { + os.Stdout = osStdOut + }() + + area, err := pterm.DefaultArea.Start() + assert.NoError(t, err) + area.Update(*t1 + *t2 + *t3) + area.Stop() + w.Close() + os.Stdout = osStdOut + + expectedOutput, err := io.ReadAll(r) + assert.NoError(t, err) + r.Close() + + assert.Equal(t, string(expectedOutput), string(output)) +} + +func TestOutputMultipleLocations(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + osStdOut := os.Stdout + r, w, err := os.Pipe() + assert.NoError(t, err) + os.Stdout = w + + ctx := &model.Context{ + Cmd: "ping", + } + fetcher := mocks.NewMockMeasurementsFetcher(ctrl) + measurement := getPingGetMeasurementMultipleLocations(measurementID1) + fetcher.EXPECT().GetMeasurement(measurementID1).Times(0).Return(measurement, nil) + err = outputMultipleLocations(fetcher, measurement, ctx) + assert.NoError(t, err) + + w.Close() + os.Stdout = osStdOut + + output, err := io.ReadAll(r) + assert.NoError(t, err) + r.Close() + + r, w, err = os.Pipe() + assert.NoError(t, err) + defer r.Close() + os.Stdout = w + defer func() { + os.Stdout = osStdOut + }() + + expectedCtx := getDefaultPingCtx(len(measurement.Results)) + expectedTable := generateTable(measurement, expectedCtx, 76) // 80 - 4. pterm defaults to 80 when terminal size is not detected. + area, err := pterm.DefaultArea.Start() + assert.NoError(t, err) + area.Update(*expectedTable) + area.Stop() + w.Close() + os.Stdout = osStdOut + + expectedOutput, err := io.ReadAll(r) + assert.NoError(t, err) + r.Close() + + assert.Equal(t, string(expectedOutput), string(output)) +} func TestFormatDuration(t *testing.T) { d := formatDuration(1.2345) @@ -116,7 +284,7 @@ func TestFormatDuration(t *testing.T) { } func TestGenerateTableFull(t *testing.T) { - measurement := getPingGetMeasurementMultipleLocations(MeasurementID1) + measurement := getPingGetMeasurementMultipleLocations(measurementID1) ctx := getDefaultPingCtx(len(measurement.Results)) expectedTable := "\x1b[96m\x1b[96mLocation \x1b[0m\x1b[0m | \x1b[96m\x1b[96mSent\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Loss\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Last\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Min\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Avg\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Max\x1b[0m\x1b[0m\n" + "EU, GB, London, ASN:0, OVH SAS | 1 | 0.00% | 0.77 ms | 0.77 ms | 0.77 ms | 0.77 ms\n" + @@ -127,7 +295,7 @@ func TestGenerateTableFull(t *testing.T) { } func TestGenerateTableOneRowTruncated(t *testing.T) { - measurement := getPingGetMeasurementMultipleLocations(MeasurementID1) + measurement := getPingGetMeasurementMultipleLocations(measurementID1) measurement.Results[1].Probe.Network = "ä½œč€…čšé›†ēš„原创内容平台äŗŽ201 1幓1ęœˆę­£å¼äøŠēŗæ让äŗŗ们ꛓ" ctx := getDefaultPingCtx(len(measurement.Results)) expectedTable := "\x1b[96m\x1b[96mLocation \x1b[0m\x1b[0m | \x1b[96m\x1b[96mSent\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Loss\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Last\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Min\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Avg\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Max\x1b[0m\x1b[0m\n" + @@ -139,7 +307,7 @@ func TestGenerateTableOneRowTruncated(t *testing.T) { } func TestGenerateTableMultiLineTruncated(t *testing.T) { - measurement := getPingGetMeasurementMultipleLocations(MeasurementID1) + measurement := getPingGetMeasurementMultipleLocations(measurementID1) measurement.Results[1].Probe.Network = "Hetzner Online GmbH\nLorem ipsum\nLorem ipsum dolor sit amet" ctx := getDefaultPingCtx(len(measurement.Results)) expectedTable := "\x1b[96m\x1b[96mLocation \x1b[0m\x1b[0m | \x1b[96m\x1b[96mSent\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Loss\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Last\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Min\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Avg\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Max\x1b[0m\x1b[0m\n" + @@ -153,7 +321,7 @@ func TestGenerateTableMultiLineTruncated(t *testing.T) { } func TestGenerateTableMaxTruncated(t *testing.T) { - measurement := getPingGetMeasurementMultipleLocations(MeasurementID1) + measurement := getPingGetMeasurementMultipleLocations(measurementID1) ctx := getDefaultPingCtx(len(measurement.Results)) expectedTable := "\x1b[96m\x1b[96mLoc...\x1b[0m\x1b[0m | \x1b[96m\x1b[96mSent\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Loss\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Last\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Min\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Avg\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Max\x1b[0m\x1b[0m\n" + "EU,... | 1 | 0.00% | 0.77 ms | 0.77 ms | 0.77 ms | 0.77 ms\n" + @@ -163,6 +331,48 @@ func TestGenerateTableMaxTruncated(t *testing.T) { assert.Equal(t, expectedTable, *table) } +func TestUpdateMeasurementStatsInProgress(t *testing.T) { + result := model.MeasurementResponse{ + Result: model.ResultData{ + Status: model.StatusInProgress, + RawOutput: `PING (142.250.65.174) 56(84) bytes of data.`, + StatsRaw: json.RawMessage(`{}`), + TimingsRaw: json.RawMessage(`[]`), + }, + } + newStats, err := mergeMeasurementStats( + model.MeasurementStats{Sent: 0, Lost: 0, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1}, + &result, + ) + assert.NoError(t, err) + assert.Equal(t, + &model.MeasurementStats{Sent: 0, Lost: 0, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1}, + newStats, + ) + result = model.MeasurementResponse{ + Result: model.ResultData{ + Status: model.StatusInProgress, + RawOutput: `PING (142.250.65.174) 56(84) bytes of data. +no answer yet for icmp_seq=1 +64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=1 ttl=59 time=1.06 ms +no answer yet for icmp_seq=2 +64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=2 ttl=59 time=1.10 ms +64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=3 ttl=59 time=1.11 ms +no answer yet for icmp_seq=4`, + StatsRaw: json.RawMessage(`{}`), + TimingsRaw: json.RawMessage(`[]`), + }, + } + newStats, err = mergeMeasurementStats( + model.MeasurementStats{Sent: 0, Lost: 0, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1}, + &result) + assert.NoError(t, err) + assert.Equal(t, + &model.MeasurementStats{Sent: 4, Lost: 1, Loss: 25, Last: 1.11, Min: 1.06, Avg: 1.09, Max: 1.11}, + newStats, + ) +} + func TestUpdateMeasurementStats(t *testing.T) { result := model.MeasurementResponse{ Result: model.ResultData{ @@ -196,7 +406,7 @@ func TestUpdateMeasurementStats(t *testing.T) { } func TestGetRowValuesNoPacketsRcv(t *testing.T) { - stats := model.MeasurementStats{Sent: 1, Lost: -1, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1} + stats := model.MeasurementStats{Sent: 1, Lost: 0, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1} rowValues := getRowValues(&stats) assert.Equal(t, [7]string{ "", diff --git a/view/utils_test.go b/view/utils_test.go index 043f0c2..241b66a 100644 --- a/view/utils_test.go +++ b/view/utils_test.go @@ -8,9 +8,9 @@ import ( ) var ( - MeasurementID1 = "nzGzfAGL7sZfUs3c" - MeasurementID2 = "A2ZfUs3cnzGzfAGL" - MeasurementID3 = "7sZfUs3cnzGz1I20" + measurementID1 = "nzGzfAGL7sZfUs3c" + // measurementID2 = "A2ZfUs3cnzGzfAGL" + // measurementID3 = "7sZfUs3cnzGz1I20" ) func getPingGetMeasurement(id string) *model.GetMeasurement { @@ -35,8 +35,13 @@ func getPingGetMeasurement(id string) *model.GetMeasurement { Tags: []string{"eyeball-network"}, }, Result: model.ResultData{ - Status: model.StatusFinished, - RawOutput: "PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data.\n64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=60 time=17.6 ms\n\n--- jsdelivr.map.fastly.net ping statistics ---\n1 packets transmitted, 1 received, 0% packet loss, time 0ms\nrtt min/avg/max/mdev = 17.639/17.639/17.639/0.000 ms", + Status: model.StatusFinished, + RawOutput: `PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=60 time=17.6 ms + +--- jsdelivr.map.fastly.net ping statistics --- +1 packets transmitted, 1 received, 0% packet loss, time 0ms +rtt min/avg/max/mdev = 17.639/17.639/17.639/0.000 ms`, ResolvedAddress: "151.101.1.229", ResolvedHostname: "151.101.1.229", StatsRaw: json.RawMessage(`{"min":17.639,"avg":17.639,"max":17.639,"total":1,"rcv":1,"drop":0,"loss":0}`), @@ -68,8 +73,13 @@ func getPingGetMeasurementMultipleLocations(id string) *model.GetMeasurement { Tags: []string{"datacenter-network"}, }, Result: model.ResultData{ - Status: model.StatusFinished, - RawOutput: "PING (146.75.73.229) 56(84) bytes of data.\n64 bytes from 146.75.73.229 (146.75.73.229): icmp_seq=1 ttl=52 time=0.770 ms\n\n--- ping statistics ---\n1 packets transmitted, 1 received, 0% packet loss, time 0ms\nrtt min/avg/max/mdev = 0.770/0.770/0.770/0.000 ms", + Status: model.StatusFinished, + RawOutput: `PING (146.75.73.229) 56(84) bytes of data. +64 bytes from 146.75.73.229 (146.75.73.229): icmp_seq=1 ttl=52 time=0.770 ms + +-- ping statistics --- +1 packets transmitted, 1 received, 0% packet loss, time 0ms +rtt min/avg/max/mdev = 0.770/0.770/0.770/0.000 ms`, ResolvedAddress: "146.75.73.229", ResolvedHostname: "146.75.73.229", StatsRaw: json.RawMessage(`{"min":0.77,"avg":0.77,"max":0.77,"total":1,"rcv":1,"drop":0,"loss":0}`), @@ -87,8 +97,13 @@ func getPingGetMeasurementMultipleLocations(id string) *model.GetMeasurement { Tags: []string{"datacenter-network"}, }, Result: model.ResultData{ - Status: model.StatusFinished, - RawOutput: "PING (104.16.85.20) 56(84) bytes of data.\n64 bytes from 104.16.85.20 (104.16.85.20): icmp_seq=1 ttl=55 time=5.46 ms\n\n--- ping statistics ---\n1 packets transmitted, 1 received, 0% packet loss, time 0ms\nrtt min/avg/max/mdev = 5.457/5.457/5.457/0.000 ms", + Status: model.StatusFinished, + RawOutput: `PING (104.16.85.20) 56(84) bytes of data. +64 bytes from 104.16.85.20 (104.16.85.20): icmp_seq=1 ttl=55 time=5.46 ms + +--- ping statistics --- +1 packets transmitted, 1 received, 0% packet loss, time 0ms +rtt min/avg/max/mdev = 5.457/5.457/5.457/0.000 ms`, ResolvedAddress: "104.16.85.20", ResolvedHostname: "104.16.85.20", StatsRaw: json.RawMessage(`{"min":5.457,"avg":5.457,"max":5.457,"total":1,"rcv":1,"drop":0,"loss":0}`), @@ -106,8 +121,13 @@ func getPingGetMeasurementMultipleLocations(id string) *model.GetMeasurement { Tags: []string{"datacenter-network"}, }, Result: model.ResultData{ - Status: model.StatusFinished, - RawOutput: "PING (104.16.88.20) 56(84) bytes of data.\n64 bytes from 104.16.88.20 (104.16.88.20): icmp_seq=1 ttl=58 time=4.07 ms\n\n--- ping statistics ---\n1 packets transmitted, 1 received, 0% packet loss, time 0ms\nrtt min/avg/max/mdev = 4.069/4.069/4.069/0.000 ms", + Status: model.StatusFinished, + RawOutput: `PING (104.16.88.20) 56(84) bytes of data. +64 bytes from 104.16.88.20 (104.16.88.20): icmp_seq=1 ttl=58 time=4.07 ms + +--- ping statistics --- +1 packets transmitted, 1 received, 0% packet loss, time 0ms +rtt min/avg/max/mdev = 4.069/4.069/4.069/0.000 ms`, ResolvedAddress: "104.16.88.20", ResolvedHostname: "104.16.88.20", StatsRaw: json.RawMessage(`{"min":4.069,"avg":4.069,"max":4.069,"total":1,"rcv":1,"drop":0,"loss":0}`), diff --git a/view/view.go b/view/view.go index 7ea56d7..9869741 100644 --- a/view/view.go +++ b/view/view.go @@ -23,8 +23,6 @@ var ( terminalLayoutBold = lipgloss.NewStyle().Bold(true) ) -var apiPollInterval = 500 * time.Millisecond - func OutputResults(id string, ctx model.Context, m model.PostMeasurement) error { fetcher := client.NewMeasurementsFetcher(client.ApiUrl) @@ -35,7 +33,7 @@ func OutputResults(id string, ctx model.Context, m model.PostMeasurement) error } // Probe may not have started yet for len(data.Results) == 0 { - time.Sleep(apiPollInterval) + time.Sleep(ctx.APIMinInterval) data, err = fetcher.GetMeasurement(id) if err != nil { return err @@ -45,7 +43,7 @@ func OutputResults(id string, ctx model.Context, m model.PostMeasurement) error if ctx.CI || ctx.JsonOutput || ctx.Latency { // Poll API until the measurement is complete for data.Status == model.StatusInProgress { - time.Sleep(apiPollInterval) + time.Sleep(ctx.APIMinInterval) data, err = fetcher.GetMeasurement(id) if err != nil { return err @@ -99,7 +97,7 @@ func liveView(id string, data *model.GetMeasurement, ctx model.Context, m model. // Poll API until the measurement is complete for data.Status == model.StatusInProgress { - time.Sleep(apiPollInterval) + time.Sleep(ctx.APIMinInterval) data, err = fetcher.GetMeasurement(id) if err != nil { return fmt.Errorf("failed to get data: %v", err) From 45d9151e753681459aeb14877b715c7c089cdb2b Mon Sep 17 00:00:00 2001 From: Radu Lucut Date: Tue, 30 Jan 2024 15:44:24 +0200 Subject: [PATCH 14/27] Add support for share & summary + fixes & tests --- cmd/ping.go | 42 ++- cmd/root.go | 1 + model/get.go | 1 + model/root.go | 56 +++- model/root_test.go | 40 +++ view/infinite.go | 208 ++++++++------ view/infinite_test.go | 652 +++++++++++++++++++++++++++++++----------- view/utils_test.go | 30 +- 8 files changed, 755 insertions(+), 275 deletions(-) create mode 100644 model/root_test.go diff --git a/cmd/ping.go b/cmd/ping.go index a1e96f2..3fe4991 100644 --- a/cmd/ping.go +++ b/cmd/ping.go @@ -2,6 +2,9 @@ package cmd import ( "fmt" + "os" + "os/signal" + "syscall" "github.com/jsdelivr/globalping-cli/client" "github.com/jsdelivr/globalping-cli/model" @@ -49,20 +52,41 @@ Examples: return err } if ctx.Infinite { - ctx.Limit = min(ctx.Limit, 5) // Limit to 5 probes - ctx.Packets = 16 // Default to 16 packets - for { - ctx.From, err = ping(cmd) - if err != nil { - return err - } - } + return infinitePing(cmd) } _, err = ping(cmd) return err }, } +func infinitePing(cmd *cobra.Command) error { + var err error + if ctx.Limit > 5 { + return fmt.Errorf("continous mode is currently limited to 5 probes") + } + ctx.Packets = 16 // Default to 16 packets + + // Trap sigterm or interupt to display info on exit + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + + go func() { + for { + ctx.From, err = ping(cmd) + if err != nil { + sig <- syscall.SIGINT + return + } + } + }() + + <-sig + if err == nil { + view.OutputSummary(&ctx) + } + return err +} + func ping(cmd *cobra.Command) (string, error) { opts = model.PostMeasurement{ Type: "ping", @@ -88,6 +112,8 @@ func ping(cmd *cobra.Command) (string, error) { return "", err } + ctx.CallCount++ + // Save measurement ID to history if !isPreviousMeasurementId { err := saveMeasurementID(res.ID) diff --git a/cmd/root.go b/cmd/root.go index 4a2ceb6..750ffc1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -26,6 +26,7 @@ var ( opts = model.PostMeasurement{} ctx = model.Context{ APIMinInterval: 500 * time.Millisecond, + MaxHistory: 10, } ) diff --git a/model/get.go b/model/get.go index 3595fc1..54816da 100644 --- a/model/get.go +++ b/model/get.go @@ -43,6 +43,7 @@ type PingStats struct { Rcv int `json:"rcv"` // The number of received packets. Drop int `json:"drop"` // The number of dropped packets (total - rcv). Loss float64 `json:"loss"` // The percentage of dropped packets. + Mdev float64 `json:"mdev"` // The mean deviation of the rtt values. } type PingTiming struct { diff --git a/model/root.go b/model/root.go index 7a0e118..6d2fd56 100644 --- a/model/root.go +++ b/model/root.go @@ -1,6 +1,7 @@ package model import ( + "math" "time" "github.com/pterm/pterm" @@ -24,16 +25,67 @@ type Context struct { Infinite bool // Infinite flag APIMinInterval time.Duration // Minimum interval between API calls - Area *pterm.AreaPrinter - Stats []MeasurementStats + + Area *pterm.AreaPrinter + Hostname string + CompletedStats []MeasurementStats + InProgressStats []MeasurementStats + CallCount int // Number of measurements created + MaxHistory int // Maximum number of measurements to keep in history + History *Rbuffer // History of measurements } type MeasurementStats struct { Sent int // Number of packets sent + Rcv int // Number of packets received Lost int // Number of packets lost Loss float64 // Percentage of packets lost Last float64 // Last RTT Min float64 // Minimum RTT Avg float64 // Average RTT Max float64 // Maximum RTT + Mdev float64 // Mean deviation of RTT + Time float64 // Total time +} + +func NewMeasurementStats() MeasurementStats { + return MeasurementStats{Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1} +} + +type Rbuffer struct { + Index int + Slice []string +} + +func (q *Rbuffer) Push(id string) { + q.Slice[q.Index] = id + q.Index = (q.Index + 1) % len(q.Slice) +} + +func (q *Rbuffer) ToString(sep string) string { + s := "" + i := q.Index + isFirst := true + for { + if q.Slice[i] != "" { + if isFirst { + isFirst = false + s += q.Slice[i] + } else { + s += sep + q.Slice[i] + } + } + i = (i + 1) % len(q.Slice) + if i == q.Index { + break + } + } + return s +} + +func NewRbuffer(size int) *Rbuffer { + return &Rbuffer{ + Index: 0, + Slice: make([]string, size), + } } diff --git a/model/root_test.go b/model/root_test.go new file mode 100644 index 0000000..8234aa7 --- /dev/null +++ b/model/root_test.go @@ -0,0 +1,40 @@ +package model + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRBuffer(t *testing.T) { + t.Run("Push", func(t *testing.T) { + b := NewRbuffer(3) + assert.Equal(t, 0, b.Index) + assert.Equal(t, []string{"", "", ""}, b.Slice) + b.Push("a") + assert.Equal(t, 1, b.Index) + assert.Equal(t, []string{"a", "", ""}, b.Slice) + b.Push("b") + assert.Equal(t, 2, b.Index) + assert.Equal(t, []string{"a", "b", ""}, b.Slice) + b.Push("c") + assert.Equal(t, 0, b.Index) + assert.Equal(t, []string{"a", "b", "c"}, b.Slice) + b.Push("d") + assert.Equal(t, 1, b.Index) + assert.Equal(t, []string{"d", "b", "c"}, b.Slice) + }) + + t.Run("ToString", func(t *testing.T) { + b := NewRbuffer(3) + assert.Equal(t, "", b.ToString("+")) + b.Push("a") + assert.Equal(t, "a", b.ToString("+")) + b.Push("b") + assert.Equal(t, "a+b", b.ToString("+")) + b.Push("c") + assert.Equal(t, "a+b+c", b.ToString("+")) + b.Push("d") + assert.Equal(t, "b+c+d", b.ToString("+")) + }) +} diff --git a/view/infinite.go b/view/infinite.go index 365e226..6278200 100644 --- a/view/infinite.go +++ b/view/infinite.go @@ -21,6 +21,11 @@ var ( ) func OutputInfinite(id string, ctx *model.Context) error { + if ctx.History == nil { + ctx.History = model.NewRbuffer(ctx.MaxHistory) + } + ctx.History.Push(id) + fetcher := client.NewMeasurementsFetcher(client.ApiUrl) res, err := fetcher.GetMeasurement(id) if err != nil { @@ -57,13 +62,59 @@ func OutputInfinite(id string, ctx *model.Context) error { return outputMultipleLocations(fetcher, res, ctx) } +func OutputSummary(ctx *model.Context) { + if len(ctx.InProgressStats) == 0 { + return + } + + if len(ctx.InProgressStats) == 1 { + stats := ctx.InProgressStats[0] + + fmt.Printf("\n--- %s ping statistics ---\n", ctx.Hostname) + fmt.Printf("%d packets transmitted, %d received, %.2f%% packet loss, time %.0fms\n", + stats.Sent, + stats.Rcv, + stats.Loss, + stats.Time, + ) + // TODO: Add mdev + min := "-" + avg := "-" + max := "-" + if stats.Min != math.MaxFloat64 { + min = fmt.Sprintf("%.3f", stats.Min) + } + if stats.Avg != -1 { + avg = fmt.Sprintf("%.3f", stats.Avg) + } + if stats.Max != -1 { + max = fmt.Sprintf("%.3f", stats.Max) + } + fmt.Printf("rtt min/avg/max = %s/%s/%s ms\n", min, avg, max) + } + + if ctx.Share && ctx.History != nil { + if len(ctx.InProgressStats) > 1 { + fmt.Println() + } + ids := ctx.History.ToString("+") + if ids != "" { + fmt.Println(formatWithLeadingArrow(shareMessage(ids), !ctx.CI)) + } + if ctx.CallCount > ctx.MaxHistory { + fmt.Printf("For long-running continuous mode measurements, only the last %d packets are shared.\n", ctx.Packets*ctx.MaxHistory) + } + } +} + func outputSingleLocation( fetcher client.MeasurementsFetcher, res *model.GetMeasurement, ctx *model.Context, ) error { - if len(ctx.Stats) == 0 { - ctx.Stats = make([]model.MeasurementStats, 1) + if len(ctx.CompletedStats) == 0 { + ctx.CompletedStats = []model.MeasurementStats{model.NewMeasurementStats()} + ctx.InProgressStats = []model.MeasurementStats{model.NewMeasurementStats()} } printHeader := true linesPrinted := 0 @@ -71,15 +122,14 @@ func outputSingleLocation( for { measurement := &res.Results[0] if measurement.Result.RawOutput != "" { - parsedOutput, err := parsePingRawOutput(measurement, ctx.Stats[0].Sent) - if err != nil { - return err - } - if printHeader && ctx.Stats[0].Sent == 0 { + parsedOutput := parsePingRawOutput(measurement, ctx.CompletedStats[0].Sent) + if printHeader && ctx.CompletedStats[0].Sent == 0 { + ctx.Hostname = parsedOutput.Hostname fmt.Println(generateProbeInfo(measurement, !ctx.CI)) - fmt.Printf("PING %s (%s) 56(84) bytes of data.\n", - measurement.Result.ResolvedHostname, - measurement.Result.ResolvedAddress, + fmt.Printf("PING %s (%s) %s bytes of data.\n", + parsedOutput.Hostname, + parsedOutput.Address, + parsedOutput.BytesOfData, ) printHeader = false } @@ -87,8 +137,9 @@ func outputSingleLocation( fmt.Println(parsedOutput.RawPacketLines[linesPrinted]) linesPrinted++ } + ctx.InProgressStats[0] = mergeMeasurementStats(ctx.CompletedStats[0], measurement) if res.Status != model.StatusInProgress { - ctx.Stats[0].Sent += parsedOutput.Stats.Total + ctx.CompletedStats[0] = ctx.InProgressStats[0] } } if res.Status != model.StatusInProgress { @@ -108,14 +159,14 @@ func outputMultipleLocations( res *model.GetMeasurement, ctx *model.Context) error { var err error - if len(ctx.Stats) == 0 { + if len(ctx.CompletedStats) == 0 { // Initialize state - ctx.Stats = make([]model.MeasurementStats, len(res.Results)) - for i := range ctx.Stats { - ctx.Stats[i].Last = -1 - ctx.Stats[i].Min = math.MaxFloat64 - ctx.Stats[i].Avg = -1 - ctx.Stats[i].Max = -1 + ctx.CompletedStats = make([]model.MeasurementStats, len(res.Results)) + for i := range ctx.CompletedStats { + ctx.CompletedStats[i].Last = -1 + ctx.CompletedStats[i].Min = math.MaxFloat64 + ctx.CompletedStats[i].Avg = -1 + ctx.CompletedStats[i].Max = -1 } // Create new writer ctx.Area, err = pterm.DefaultArea.Start() @@ -124,11 +175,17 @@ func outputMultipleLocations( } } for { - o := generateTable(res, ctx, pterm.GetTerminalWidth()-4) + o, stats := generateTable(res, ctx, pterm.GetTerminalWidth()-4) if o != nil { ctx.Area.Update(*o) } + if stats != nil { + ctx.InProgressStats = stats + } if res.Status != model.StatusInProgress { + if stats != nil { + ctx.CompletedStats = stats + } break } time.Sleep(ctx.APIMinInterval) @@ -150,7 +207,7 @@ func formatDuration(ms float64) string { return fmt.Sprintf("%.0f ms", ms) } -func generateTable(res *model.GetMeasurement, ctx *model.Context, areaWidth int) *string { +func generateTable(res *model.GetMeasurement, ctx *model.Context, areaWidth int) (*string, []model.MeasurementStats) { table := [][7]string{{"Location", "Sent", "Loss", "Last", "Min", "Avg", "Max"}} // Calculate max column width and max line width // We handle multi-line values only for the first column @@ -160,17 +217,15 @@ func generateTable(res *model.GetMeasurement, ctx *model.Context, areaWidth int) maxLineWidth += len(table[i]) + len(colSeparator) } skip := false + newStats := make([]model.MeasurementStats, len(res.Results)) for i := range res.Results { measurement := &res.Results[i] if measurement.Result.RawOutput == "" { skip = true break } - stats, _ := mergeMeasurementStats(ctx.Stats[i], measurement) - if measurement.Result.Status != model.StatusInProgress { - ctx.Stats[i] = *stats - } - row := getRowValues(stats) + newStats[i] = mergeMeasurementStats(ctx.CompletedStats[i], measurement) + row := getRowValues(&newStats[i]) rowWidth := 0 for j := 1; j < len(row); j++ { rowWidth += len(row[j]) + len(colSeparator) @@ -182,7 +237,7 @@ func generateTable(res *model.GetMeasurement, ctx *model.Context, areaWidth int) table = append(table, row) } if skip { - return nil + return nil, nil } remainingWidth := max(areaWidth-maxLineWidth, 6) // Remaining width for first column colMax[0] = min(colMax[0], remainingWidth) // Truncate first column if necessary @@ -219,46 +274,30 @@ func generateTable(res *model.GetMeasurement, ctx *model.Context, areaWidth int) output += lines[j] + "\n" } } - return &output + return &output, newStats } -func mergeMeasurementStats(mStats model.MeasurementStats, measurement *model.MeasurementResponse) (*model.MeasurementStats, error) { - var pStats *model.PingStats - var timings []model.PingTiming - var err error - if measurement.Result.Status == model.StatusInProgress { - o, err := parsePingRawOutput(measurement, mStats.Sent) - if err != nil { - return nil, err +func mergeMeasurementStats(mStats model.MeasurementStats, measurement *model.MeasurementResponse) model.MeasurementStats { + o := parsePingRawOutput(measurement, mStats.Sent) + if o.Stats.Rcv > 0 { + if o.Stats.Min < mStats.Min && o.Stats.Min != 0 { + mStats.Min = o.Stats.Min } - pStats = o.Stats - timings = o.Timings - } else { - pStats, err = client.DecodePingStats(measurement.Result.StatsRaw) - if err != nil { - return nil, err - } - timings, err = client.DecodePingTimings(measurement.Result.TimingsRaw) - if err != nil { - return nil, err - } - } - if pStats.Rcv > 0 { - if pStats.Min < mStats.Min && pStats.Min != 0 { - mStats.Min = pStats.Min - } - if pStats.Max > mStats.Max { - mStats.Max = pStats.Max + if o.Stats.Max > mStats.Max { + mStats.Max = o.Stats.Max } - mStats.Avg = (mStats.Avg*float64(mStats.Sent) + pStats.Avg*float64(pStats.Total)) / float64(mStats.Sent+pStats.Total) - mStats.Last = timings[len(timings)-1].RTT + mStats.Avg = (mStats.Avg*float64(mStats.Sent) + o.Stats.Avg*float64(o.Stats.Total)) / float64(mStats.Sent+o.Stats.Total) + mStats.Last = o.Timings[len(o.Timings)-1].RTT } - mStats.Sent += pStats.Total - mStats.Lost += pStats.Drop + mStats.Sent += o.Stats.Total + mStats.Lost += o.Stats.Drop + mStats.Time += o.Time + mStats.Rcv += o.Stats.Rcv if mStats.Sent > 0 { mStats.Loss = float64(mStats.Lost) / float64(mStats.Sent) * 100 } - return &mStats, nil + // TODO: Add mdev + return mStats } func getRowValues(stats *model.MeasurementStats) [7]string { @@ -304,27 +343,17 @@ func formatValue(v string, color pterm.Color, width int, toRight bool) string { } type ParsedPingOutput struct { + Hostname string + Address string + BytesOfData string RawPacketLines []string Timings []model.PingTiming Stats *model.PingStats + Time float64 } // If startIncmpSeq is -1, RawPacketLines will be empty -func parsePingRawOutput(m *model.MeasurementResponse, startIncmpSeq int) (*ParsedPingOutput, error) { - scanner := bufio.NewScanner(strings.NewReader(m.Result.RawOutput)) - scanner.Scan() - header := scanner.Text() - words := strings.Split(header, " ") - if len(words) > 2 { - m.Result.ResolvedHostname = words[1] - if len(words[2]) < 2 { - return nil, errors.New("could not parse ping header") - } - m.Result.ResolvedAddress = words[2][1 : len(words[2])-1] - } else { - return nil, errors.New("could not parse ping header") - } - +func parsePingRawOutput(m *model.MeasurementResponse, startIncmpSeq int) *ParsedPingOutput { res := &ParsedPingOutput{ Timings: make([]model.PingTiming, 0), Stats: &model.PingStats{ @@ -333,6 +362,20 @@ func parsePingRawOutput(m *model.MeasurementResponse, startIncmpSeq int) (*Parse Avg: -1, }, } + scanner := bufio.NewScanner(strings.NewReader(m.Result.RawOutput)) + scanner.Scan() + header := scanner.Text() + words := strings.Split(header, " ") + if len(words) > 2 { + res.Hostname = words[1] + if len(words[2]) > 1 && words[2][0] == '(' { + res.Address = words[2][1 : len(words[2])-1] + } else { + res.Address = words[2] + } + res.BytesOfData = words[3] + } + sentMap := make([]bool, 0) for scanner.Scan() { line := scanner.Text() @@ -342,14 +385,13 @@ func parsePingRawOutput(m *model.MeasurementResponse, startIncmpSeq int) (*Parse // Find icmp_seq icmp_seq := -1 icmp_seq_index := 0 - var err error words := strings.Split(line, " ") for icmp_seq_index < len(words) { if strings.HasPrefix(words[icmp_seq_index], "icmp_seq=") { - icmp_seq, err = strconv.Atoi(words[icmp_seq_index][9:]) - icmp_seq-- // icmp_seq starts at 1 + n, err := strconv.Atoi(words[icmp_seq_index][9:]) if err != nil { - return nil, errors.New("could not parse ping header: " + err.Error()) + } else { + icmp_seq = n - 1 // icmp_seq starts at 1 } break } @@ -398,34 +440,36 @@ func parsePingRawOutput(m *model.MeasurementResponse, startIncmpSeq int) (*Parse if !hasSummary { res.Stats.Drop = res.Stats.Total - res.Stats.Rcv res.Stats.Loss = float64(res.Stats.Drop) / float64(res.Stats.Total) * 100 - return res, nil + return res } scanner.Scan() // skip --- ping statistics --- line := scanner.Text() words = strings.Split(line, " ") if len(words) < 3 { - return res, nil + return res } if words[1] == "packets" && words[2] == "transmitted," { res.Stats.Total, _ = strconv.Atoi(words[0]) res.Stats.Rcv, _ = strconv.Atoi(words[3]) res.Stats.Loss, _ = strconv.ParseFloat(words[5][:len(words[5])-1], 64) res.Stats.Drop = res.Stats.Total - res.Stats.Rcv + res.Time, _ = strconv.ParseFloat(words[9][:len(words[9])-2], 64) } hasSummary = scanner.Scan() if !hasSummary { - return res, nil + return res } line = scanner.Text() words = strings.Split(line, " ") if len(words) < 2 { - return res, nil + return res } if words[0] == "rtt" && words[1] == "min/avg/max/mdev" { words = strings.Split(words[3], "/") res.Stats.Min, _ = strconv.ParseFloat(words[0], 64) res.Stats.Avg, _ = strconv.ParseFloat(words[1], 64) res.Stats.Max, _ = strconv.ParseFloat(words[2], 64) + res.Stats.Mdev, _ = strconv.ParseFloat(words[3], 64) } - return res, nil + return res } diff --git a/view/infinite_test.go b/view/infinite_test.go index 074c5e1..e10f454 100644 --- a/view/infinite_test.go +++ b/view/infinite_test.go @@ -1,7 +1,6 @@ package view import ( - "encoding/json" "io" "math" "os" @@ -18,49 +17,41 @@ func TestOutputSingleLocationInProgress(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - osStdErr := os.Stderr osStdOut := os.Stdout - - rErr, wErr, err := os.Pipe() - assert.NoError(t, err) - defer rErr.Close() - - rOut, wOut, err := os.Pipe() - assert.NoError(t, err) - defer rOut.Close() - - os.Stderr = wErr - os.Stdout = wOut - defer func() { - os.Stderr = osStdErr os.Stdout = osStdOut }() + rawOutput1 := `PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data.` + rawOutput2 := `PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=60 time=17.6 ms` + rawOutput3 := `PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=60 time=17.6 ms +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=2 ttl=30 time=17.3 ms` + rawOutput4 := `PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=60 time=17.6 ms +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=2 ttl=30 time=17.3 ms +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=3 ttl=10 time=17.0 ms + +--- ping statistics --- +3 packets transmitted, 3 received, 0% packet loss, time 2002ms +rtt min/avg/max/mdev = 17.006/17.333/17.648/0.321 ms` + fetcher := mocks.NewMockMeasurementsFetcher(ctrl) measurement := getPingGetMeasurement(measurementID1) - measurement.Status = model.StatusInProgress - measurement.Results[0].Result.Status = model.StatusInProgress - measurement.Results[0].Result.RawOutput = `PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data.` + callCount := 1 // 1st call is done in the caller. fetcher.EXPECT().GetMeasurement(measurementID1).DoAndReturn(func(id string) (*model.GetMeasurement, error) { callCount++ - if callCount == 2 { - measurement.Results[0].Result.RawOutput = `PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. -64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=60 time=17.6 ms` - } - if callCount == 3 { - measurement.Results[0].Result.RawOutput = `PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. -64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=60 time=17.6 ms -64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=2 ttl=30 time=17.3 ms` - } - if callCount == 4 { + switch callCount { + case 2: + measurement.Results[0].Result.RawOutput = rawOutput2 + case 3: + measurement.Results[0].Result.RawOutput = rawOutput3 + case 4: measurement.Status = model.StatusFinished measurement.Results[0].Result.Status = model.StatusFinished - measurement.Results[0].Result.RawOutput = `PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. -64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=60 time=17.6 ms -64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=2 ttl=30 time=17.3 ms -64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=3 ttl=10 time=17.0 ms` + measurement.Results[0].Result.RawOutput = rawOutput4 } return measurement, nil }).Times(3) @@ -70,17 +61,25 @@ func TestOutputSingleLocationInProgress(t *testing.T) { APIMinInterval: 0, } - err = outputSingleLocation(fetcher, measurement, ctx) + measurement.Status = model.StatusInProgress + measurement.Results[0].Result.Status = model.StatusInProgress + measurement.Results[0].Result.RawOutput = rawOutput1 + + r, w, err := os.Pipe() assert.NoError(t, err) + defer func() { + w.Close() + r.Close() + }() + os.Stdout = w - wErr.Close() - wOut.Close() + err = outputSingleLocation(fetcher, measurement, ctx) + w.Close() + os.Stdout = osStdOut - errOutput, err := io.ReadAll(rErr) assert.NoError(t, err) - assert.Equal(t, "", string(errOutput)) - - output, err := io.ReadAll(rOut) + output, err := io.ReadAll(r) + r.Close() assert.NoError(t, err) assert.Equal(t, `> EU, DE, Berlin, ASN:3320, Deutsche Telekom AG @@ -89,29 +88,21 @@ PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. 64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=2 ttl=30 time=17.3 ms 64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=3 ttl=10 time=17.0 ms `, - string(output)) + string(output), + ) + + assert.Equal(t, + []model.MeasurementStats{{Sent: 3, Rcv: 3, Lost: 0, Loss: 0, Last: 17, Min: 17.006, Avg: 17.333, Max: 17.648, Time: 2002}}, + ctx.CompletedStats, + ) } func TestOutputSingleLocationMultipleCalls(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - osStdErr := os.Stderr osStdOut := os.Stdout - - rErr, wErr, err := os.Pipe() - assert.NoError(t, err) - defer rErr.Close() - - rOut, wOut, err := os.Pipe() - assert.NoError(t, err) - defer rOut.Close() - - os.Stderr = wErr - os.Stdout = wOut - defer func() { - os.Stderr = osStdErr os.Stdout = osStdOut }() @@ -120,24 +111,29 @@ func TestOutputSingleLocationMultipleCalls(t *testing.T) { fetcher.EXPECT().GetMeasurement(measurementID1).Times(0).Return(measurement, nil) ctx := &model.Context{ - Cmd: "ping", + Cmd: "ping", + MaxHistory: 3, } - err = outputSingleLocation(fetcher, measurement, ctx) + r, w, err := os.Pipe() assert.NoError(t, err) + defer func() { + w.Close() + r.Close() + }() + os.Stdout = w + err = outputSingleLocation(fetcher, measurement, ctx) assert.NoError(t, err) err = outputSingleLocation(fetcher, measurement, ctx) assert.NoError(t, err) - - wErr.Close() - wOut.Close() - - errOutput, err := io.ReadAll(rErr) + err = outputSingleLocation(fetcher, measurement, ctx) assert.NoError(t, err) - assert.Equal(t, "", string(errOutput)) + w.Close() + os.Stdout = osStdOut - output, err := io.ReadAll(rOut) + output, err := io.ReadAll(r) + r.Close() assert.NoError(t, err) assert.Equal(t, `> EU, DE, Berlin, ASN:3320, Deutsche Telekom AG @@ -147,6 +143,10 @@ PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. 64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=3 ttl=60 time=17.6 ms `, string(output)) + + expectedStats := []model.MeasurementStats{{Sent: 3, Rcv: 3, Lost: 0, Loss: 0, Last: 17.6, Min: 17.639, Avg: 17.639, Max: 17.639, Time: 3000}} + assert.Equal(t, expectedStats, ctx.InProgressStats) + assert.Equal(t, expectedStats, ctx.CompletedStats) } func TestOutputMultipleLocationsInProgress(t *testing.T) { @@ -154,73 +154,117 @@ func TestOutputMultipleLocationsInProgress(t *testing.T) { defer ctrl.Finish() osStdOut := os.Stdout - r, w, err := os.Pipe() - assert.NoError(t, err) - os.Stdout = w + defer func() { + os.Stdout = osStdOut + }() ctx := &model.Context{ Cmd: "ping", APIMinInterval: 0, } fetcher := mocks.NewMockMeasurementsFetcher(ctrl) - measurement := getPingGetMeasurementMultipleLocations(measurementID1) - measurement.Status = model.StatusInProgress - measurement.Results[0].Result.Status = model.StatusInProgress - measurement.Results[0].Result.RawOutput = `PING (146.75.73.229) 56(84) bytes of data.` - measurement.Results[0].Result.StatsRaw = json.RawMessage(`{}`) - measurement.Results[0].Result.TimingsRaw = json.RawMessage(`[]`) + res := getPingGetMeasurementMultipleLocations(measurementID1) - expectedCtx := getDefaultPingCtx(len(measurement.Results)) - var t1, t2, t3 *string - t1 = generateTable(measurement, expectedCtx, 76) // 80 - 4. pterm defaults to 80 when terminal size is not detected. + rawOutput1 := `PING (146.75.73.229) 56(84) bytes of data.` + rawOutput2 := `PING (146.75.73.229) 56(84) bytes of data. +64 bytes from 146.75.73.229 (146.75.73.229): icmp_seq=1 ttl=52 time=17.6 ms +no answer yet for icmp_seq=2` + rawOutputFinal := `PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=60 time=17.6 ms +no answer yet for icmp_seq=2 +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=2 ttl=30 time=17.3 ms +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=3 ttl=10 time=17.0 ms + +--- ping statistics --- +3 packets transmitted, 3 received, 0% packet loss, time 2002ms +rtt min/avg/max/mdev = 17.006/17.333/17.648/0.321 ms` + expectedCtx := getDefaultPingCtx(len(res.Results)) + expectedTables := [6]*string{} callCount := 1 // 1st call is done in the caller. fetcher.EXPECT().GetMeasurement(measurementID1).DoAndReturn(func(id string) (*model.GetMeasurement, error) { callCount++ - if callCount == 2 { - measurement.Results[0].Result.RawOutput = `PING (146.75.73.229) 56(84) bytes of data. -64 bytes from 146.75.73.229 (146.75.73.229): icmp_seq=1 ttl=52 time=0.7 ms -no answer yet for icmp_seq=2` - t2 = generateTable(measurement, expectedCtx, 76) - } - if callCount == 3 { - measurement.Status = model.StatusFinished - measurement.Results[0].Result.Status = model.StatusFinished - measurement.Results[0].Result.RawOutput = "_" - measurement.Results[0].Result.StatsRaw = json.RawMessage(`{"min":0.7,"avg":0.75,"max":0.8,"total":4,"rcv":2,"drop":2,"loss":2}`) - measurement.Results[0].Result.TimingsRaw = json.RawMessage(`[{"ttl":52,"rtt":0.7},{"ttl":52,"rtt":0.8}]`) - t3 = generateTable(measurement, expectedCtx, 76) + switch callCount { + case 2, 5: + res.Results[0].Result.RawOutput = rawOutput2 + expectedTables[callCount-1], _ = generateTable(res, expectedCtx, 76) + case 3, 6: + res.Status = model.StatusFinished + res.Results[0].Result.Status = model.StatusFinished + res.Results[0].Result.RawOutput = rawOutputFinal + expectedTables[callCount-1], _ = generateTable(res, expectedCtx, 76) } - return measurement, nil - }).Times(2) - err = outputMultipleLocations(fetcher, measurement, ctx) + return res, nil + }).Times(4) + + // 1st call + res.Status = model.StatusInProgress + res.Results[0].Result.Status = model.StatusInProgress + res.Results[0].Result.RawOutput = rawOutput1 + expectedTables[0], _ = generateTable(res, expectedCtx, 76) + + r, w, err := os.Pipe() + assert.NoError(t, err) + defer func() { + w.Close() + r.Close() + }() + os.Stdout = w + + err = outputMultipleLocations(fetcher, res, ctx) assert.NoError(t, err) + firstCallStats := []model.MeasurementStats{ + {Sent: 3, Rcv: 3, Lost: 0, Loss: 0, Last: 17, Min: 17.006, Avg: 17.333, Max: 17.648, Time: 2002}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Time: 2}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Time: 3}, + } + assert.Equal(t, firstCallStats, ctx.InProgressStats) + assert.Equal(t, firstCallStats, ctx.CompletedStats) + + // 2nd call + res.Status = model.StatusInProgress + res.Results[0].Result.Status = model.StatusInProgress + res.Results[0].Result.RawOutput = rawOutput1 + expectedCtx.CompletedStats = firstCallStats + + callCount++ + expectedTables[3], _ = generateTable(res, expectedCtx, 76) + err = outputMultipleLocations(fetcher, res, ctx) + assert.NoError(t, err) w.Close() - os.Stdout = osStdOut + os.Stdout = osStdOut output, err := io.ReadAll(r) assert.NoError(t, err) - r.Close() - r, w, err = os.Pipe() + secondCallStats := []model.MeasurementStats{ + {Sent: 6, Rcv: 6, Lost: 0, Loss: 0, Last: 17, Min: 17.006, Avg: 17.333, Max: 17.648, Time: 4004}, + {Sent: 2, Rcv: 2, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Time: 4}, + {Sent: 2, Rcv: 2, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Time: 6}, + } + assert.Equal(t, secondCallStats, ctx.InProgressStats) + assert.Equal(t, secondCallStats, ctx.CompletedStats) + + rr, ww, err := os.Pipe() assert.NoError(t, err) - defer r.Close() - os.Stdout = w defer func() { - os.Stdout = osStdOut + ww.Close() + rr.Close() }() - area, err := pterm.DefaultArea.Start() - assert.NoError(t, err) - area.Update(*t1 + *t2 + *t3) + os.Stdout = ww + area, _ := pterm.DefaultArea.Start() + for i := range expectedTables { + area.Update(*expectedTables[i]) + } area.Stop() - w.Close() + ww.Close() os.Stdout = osStdOut - expectedOutput, err := io.ReadAll(r) + expectedOutput, err := io.ReadAll(rr) assert.NoError(t, err) - r.Close() + rr.Close() assert.Equal(t, string(expectedOutput), string(output)) } @@ -230,48 +274,311 @@ func TestOutputMultipleLocations(t *testing.T) { defer ctrl.Finish() osStdOut := os.Stdout - r, w, err := os.Pipe() - assert.NoError(t, err) - os.Stdout = w + defer func() { + os.Stdout = osStdOut + }() ctx := &model.Context{ Cmd: "ping", } - fetcher := mocks.NewMockMeasurementsFetcher(ctrl) measurement := getPingGetMeasurementMultipleLocations(measurementID1) + fetcher := mocks.NewMockMeasurementsFetcher(ctrl) fetcher.EXPECT().GetMeasurement(measurementID1).Times(0).Return(measurement, nil) - err = outputMultipleLocations(fetcher, measurement, ctx) + + r, w, err := os.Pipe() assert.NoError(t, err) + defer func() { + w.Close() + r.Close() + }() + os.Stdout = w + err = outputMultipleLocations(fetcher, measurement, ctx) + assert.NoError(t, err) w.Close() + os.Stdout = osStdOut output, err := io.ReadAll(r) assert.NoError(t, err) r.Close() - r, w, err = os.Pipe() + rr, ww, err := os.Pipe() assert.NoError(t, err) - defer r.Close() - os.Stdout = w defer func() { - os.Stdout = osStdOut + ww.Close() + rr.Close() }() + os.Stdout = ww expectedCtx := getDefaultPingCtx(len(measurement.Results)) - expectedTable := generateTable(measurement, expectedCtx, 76) // 80 - 4. pterm defaults to 80 when terminal size is not detected. - area, err := pterm.DefaultArea.Start() - assert.NoError(t, err) + expectedTable, _ := generateTable(measurement, expectedCtx, 76) // 80 - 4. pterm defaults to 80 when terminal size is not detected. + area, _ := pterm.DefaultArea.Start() area.Update(*expectedTable) area.Stop() - w.Close() + ww.Close() os.Stdout = osStdOut - expectedOutput, err := io.ReadAll(r) + expectedOutput, err := io.ReadAll(rr) assert.NoError(t, err) - r.Close() + rr.Close() assert.Equal(t, string(expectedOutput), string(output)) + assert.Equal(t, + []model.MeasurementStats{ + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 1}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Time: 2}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Time: 3}, + }, + ctx.CompletedStats, + ) +} + +func TestOutputSummary(t *testing.T) { + + t.Run("No_stats", func(t *testing.T) { + osStdOut := os.Stdout + defer func() { + os.Stdout = osStdOut + }() + + r, w, err := os.Pipe() + assert.NoError(t, err) + defer func() { + w.Close() + r.Close() + }() + + ctx := &model.Context{} + os.Stdout = w + OutputSummary(ctx) + w.Close() + os.Stdout = osStdOut + + output, err := io.ReadAll(r) + assert.NoError(t, err) + r.Close() + assert.Equal(t, "", string(output)) + }) + + t.Run("With_stats_Single_location", func(t *testing.T) { + osStdOut := os.Stdout + defer func() { + os.Stdout = osStdOut + }() + + r, w, err := os.Pipe() + assert.NoError(t, err) + defer func() { + w.Close() + r.Close() + }() + + ctx := &model.Context{ + InProgressStats: []model.MeasurementStats{ + {Sent: 10, Rcv: 9, Lost: 1, Loss: 10, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 1000}, + }, + } + os.Stdout = w + OutputSummary(ctx) + w.Close() + os.Stdout = osStdOut + + output, err := io.ReadAll(r) + assert.NoError(t, err) + r.Close() + assert.Equal(t, ` +--- ping statistics --- +10 packets transmitted, 9 received, 10.00% packet loss, time 1000ms +rtt min/avg/max = 0.770/0.770/0.770 ms +`, + string(output)) + }) + + t.Run("With_stats_In_progress", func(t *testing.T) { + osStdOut := os.Stdout + defer func() { + os.Stdout = osStdOut + }() + + r, w, err := os.Pipe() + assert.NoError(t, err) + defer func() { + w.Close() + r.Close() + }() + + ctx := &model.Context{ + InProgressStats: []model.MeasurementStats{ + {Sent: 1, Rcv: 0, Lost: 1, Loss: 100, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1, Time: 0}, + }, + } + os.Stdout = w + OutputSummary(ctx) + w.Close() + os.Stdout = osStdOut + + output, err := io.ReadAll(r) + assert.NoError(t, err) + r.Close() + assert.Equal(t, ` +--- ping statistics --- +1 packets transmitted, 0 received, 100.00% packet loss, time 0ms +rtt min/avg/max = -/-/- ms +`, + string(output)) + }) + + t.Run("Multiple_locations", func(t *testing.T) { + osStdOut := os.Stdout + defer func() { + os.Stdout = osStdOut + }() + + r, w, err := os.Pipe() + assert.NoError(t, err) + defer func() { + w.Close() + r.Close() + }() + + ctx := &model.Context{ + InProgressStats: []model.MeasurementStats{ + model.NewMeasurementStats(), + model.NewMeasurementStats(), + }, + } + os.Stdout = w + OutputSummary(ctx) + w.Close() + os.Stdout = osStdOut + + output, err := io.ReadAll(r) + assert.NoError(t, err) + r.Close() + assert.Equal(t, "", string(output)) + }) + + t.Run("Single_location_Share", func(t *testing.T) { + osStdOut := os.Stdout + defer func() { + os.Stdout = osStdOut + }() + + r, w, err := os.Pipe() + assert.NoError(t, err) + defer func() { + w.Close() + r.Close() + }() + + ctx := &model.Context{ + History: &model.Rbuffer{ + Index: 0, + Slice: []string{measurementID1}, + }, + InProgressStats: []model.MeasurementStats{ + {Sent: 1, Rcv: 0, Lost: 1, Loss: 100, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1, Time: 0}, + }, + Share: true, + } + os.Stdout = w + OutputSummary(ctx) + w.Close() + os.Stdout = osStdOut + + output, err := io.ReadAll(r) + assert.NoError(t, err) + r.Close() + + expectedOutput := ` +--- ping statistics --- +1 packets transmitted, 0 received, 100.00% packet loss, time 0ms +rtt min/avg/max = -/-/- ms +` + formatWithLeadingArrow(shareMessage(measurementID1), true) + "\n" + + assert.Equal(t, expectedOutput, string(output)) + }) + + t.Run("Multiple_locations_Share", func(t *testing.T) { + osStdOut := os.Stdout + defer func() { + os.Stdout = osStdOut + }() + + r, w, err := os.Pipe() + assert.NoError(t, err) + defer func() { + w.Close() + r.Close() + }() + + ctx := &model.Context{ + History: &model.Rbuffer{ + Index: 0, + Slice: []string{measurementID1, measurementID2}, + }, + InProgressStats: []model.MeasurementStats{ + model.NewMeasurementStats(), + model.NewMeasurementStats(), + }, + Share: true, + } + os.Stdout = w + OutputSummary(ctx) + w.Close() + os.Stdout = osStdOut + + output, err := io.ReadAll(r) + assert.NoError(t, err) + r.Close() + + expectedOutput := "\n" + formatWithLeadingArrow(shareMessage(measurementID1+"+"+measurementID2), true) + "\n" + + assert.Equal(t, expectedOutput, string(output)) + }) + + t.Run("Multiple_locations_Share_More_calls_than_MaxHistory", func(t *testing.T) { + osStdOut := os.Stdout + defer func() { + os.Stdout = osStdOut + }() + + r, w, err := os.Pipe() + assert.NoError(t, err) + defer func() { + w.Close() + r.Close() + }() + + ctx := &model.Context{ + History: &model.Rbuffer{ + Index: 0, + Slice: []string{measurementID2}, + }, + InProgressStats: []model.MeasurementStats{ + model.NewMeasurementStats(), + model.NewMeasurementStats(), + }, + Share: true, + CallCount: 2, + MaxHistory: 1, + Packets: 16, + } + os.Stdout = w + OutputSummary(ctx) + w.Close() + os.Stdout = osStdOut + + output, err := io.ReadAll(r) + assert.NoError(t, err) + r.Close() + + expectedOutput := "\n" + formatWithLeadingArrow(shareMessage(measurementID2), true) + + "\nFor long-running continuous mode measurements, only the last 16 packets are shared.\n" + + assert.Equal(t, expectedOutput, string(output)) + }) } func TestFormatDuration(t *testing.T) { @@ -290,8 +597,13 @@ func TestGenerateTableFull(t *testing.T) { "EU, GB, London, ASN:0, OVH SAS | 1 | 0.00% | 0.77 ms | 0.77 ms | 0.77 ms | 0.77 ms\n" + "EU, DE, Falkenstein, ASN:0, Hetzner Online GmbH | 1 | 0.00% | 5.46 ms | 5.46 ms | 5.46 ms | 5.46 ms\n" + "EU, DE, Nuremberg, ASN:0, Hetzner Online GmbH | 1 | 0.00% | 4.07 ms | 4.07 ms | 4.07 ms | 4.07 ms\n" - table := generateTable(measurement, ctx, 500) + table, stats := generateTable(measurement, ctx, 500) assert.Equal(t, expectedTable, *table) + assert.Equal(t, []model.MeasurementStats{ + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 1}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Time: 2}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Time: 3}, + }, stats) } func TestGenerateTableOneRowTruncated(t *testing.T) { @@ -302,8 +614,13 @@ func TestGenerateTableOneRowTruncated(t *testing.T) { "EU, GB, London, ASN:0, OVH SAS | 1 | 0.00% | 0.77 ms | 0.77 ms | 0.77 ms | 0.77 ms\n" + "EU, DE, Falkenstein, ASN:0, ä½œč€…čšé›†ēš„原创... | 1 | 0.00% | 5.46 ms | 5.46 ms | 5.46 ms | 5.46 ms\n" + "EU, DE, Nuremberg, ASN:0, Hetzner Online GmbH | 1 | 0.00% | 4.07 ms | 4.07 ms | 4.07 ms | 4.07 ms\n" - table := generateTable(measurement, ctx, 106) + table, stats := generateTable(measurement, ctx, 106) assert.Equal(t, expectedTable, *table) + assert.Equal(t, []model.MeasurementStats{ + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 1}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Time: 2}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Time: 3}, + }, stats) } func TestGenerateTableMultiLineTruncated(t *testing.T) { @@ -316,8 +633,13 @@ func TestGenerateTableMultiLineTruncated(t *testing.T) { "Lorem ipsum | | | | | | \n" + "Lorem ipsum dolor sit amet | | | | | | \n" + "EU, DE, Nuremberg, ASN:0, Hetzner Online GmbH | 1 | 0.00% | 4.07 ms | 4.07 ms | 4.07 ms | 4.07 ms\n" - table := generateTable(measurement, ctx, 106) + table, stats := generateTable(measurement, ctx, 106) assert.Equal(t, expectedTable, *table) + assert.Equal(t, []model.MeasurementStats{ + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 1}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Time: 2}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Time: 3}, + }, stats) } func TestGenerateTableMaxTruncated(t *testing.T) { @@ -327,31 +649,31 @@ func TestGenerateTableMaxTruncated(t *testing.T) { "EU,... | 1 | 0.00% | 0.77 ms | 0.77 ms | 0.77 ms | 0.77 ms\n" + "EU,... | 1 | 0.00% | 5.46 ms | 5.46 ms | 5.46 ms | 5.46 ms\n" + "EU,... | 1 | 0.00% | 4.07 ms | 4.07 ms | 4.07 ms | 4.07 ms\n" - table := generateTable(measurement, ctx, 0) + table, stats := generateTable(measurement, ctx, 0) assert.Equal(t, expectedTable, *table) + assert.Equal(t, []model.MeasurementStats{ + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 1}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Time: 2}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Time: 3}, + }, stats) } -func TestUpdateMeasurementStatsInProgress(t *testing.T) { +func TestMergeMeasurementStats(t *testing.T) { result := model.MeasurementResponse{ Result: model.ResultData{ - Status: model.StatusInProgress, - RawOutput: `PING (142.250.65.174) 56(84) bytes of data.`, - StatsRaw: json.RawMessage(`{}`), - TimingsRaw: json.RawMessage(`[]`), + RawOutput: `PING (142.250.65.174) 56(84) bytes of data.`, }, } - newStats, err := mergeMeasurementStats( + newStats := mergeMeasurementStats( model.MeasurementStats{Sent: 0, Lost: 0, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1}, &result, ) - assert.NoError(t, err) assert.Equal(t, - &model.MeasurementStats{Sent: 0, Lost: 0, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1}, + model.MeasurementStats{Sent: 0, Lost: 0, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1}, newStats, ) result = model.MeasurementResponse{ Result: model.ResultData{ - Status: model.StatusInProgress, RawOutput: `PING (142.250.65.174) 56(84) bytes of data. no answer yet for icmp_seq=1 64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=1 ttl=59 time=1.06 ms @@ -359,48 +681,35 @@ no answer yet for icmp_seq=2 64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=2 ttl=59 time=1.10 ms 64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=3 ttl=59 time=1.11 ms no answer yet for icmp_seq=4`, - StatsRaw: json.RawMessage(`{}`), - TimingsRaw: json.RawMessage(`[]`), }, } - newStats, err = mergeMeasurementStats( + newStats = mergeMeasurementStats( model.MeasurementStats{Sent: 0, Lost: 0, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1}, &result) - assert.NoError(t, err) - assert.Equal(t, - &model.MeasurementStats{Sent: 4, Lost: 1, Loss: 25, Last: 1.11, Min: 1.06, Avg: 1.09, Max: 1.11}, - newStats, - ) -} - -func TestUpdateMeasurementStats(t *testing.T) { - result := model.MeasurementResponse{ - Result: model.ResultData{ - Status: model.StatusFinished, - StatsRaw: json.RawMessage(`{"min":6,"avg":6,"max":6,"total":1,"rcv":1,"drop":0,"loss":0}`), - TimingsRaw: json.RawMessage(`[{"ttl":60,"rtt":6}]`), - }, - } - newStats, err := mergeMeasurementStats( - model.MeasurementStats{Sent: 2, Lost: 0, Loss: 0, Last: 1, Min: 1, Avg: 1.5, Max: 2}, - &result, - ) - assert.NoError(t, err) assert.Equal(t, - &model.MeasurementStats{Sent: 3, Lost: 0, Loss: 0, Last: 6, Min: 1, Avg: 3, Max: 6}, + model.MeasurementStats{Sent: 4, Rcv: 3, Lost: 1, Loss: 25, Last: 1.11, Min: 1.06, Avg: 1.09, Max: 1.11}, newStats, ) result = model.MeasurementResponse{ Result: model.ResultData{ - Status: model.StatusFinished, - StatsRaw: json.RawMessage(`{"min":0,"avg":0,"max":0,"total":1,"rcv":0,"drop":1,"loss":100}`), - TimingsRaw: json.RawMessage(`[]`), + RawOutput: `PING (142.250.65.174) 56(84) bytes of data. +no answer yet for icmp_seq=1 +64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=1 ttl=59 time=1.06 ms +no answer yet for icmp_seq=2 +64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=2 ttl=59 time=1.10 ms +64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=3 ttl=59 time=1.11 ms +no answer yet for icmp_seq=4 + +--- ping statistics --- +4 packets transmitted, 4 received, 0% packet loss, time 1002ms +rtt min/avg/max/mdev = 1.061/1.090/1.108/0.020 ms`, }, } - newStats, err = mergeMeasurementStats(*newStats, &result) - assert.NoError(t, err) + newStats = mergeMeasurementStats( + model.MeasurementStats{Sent: 0, Lost: 0, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1}, + &result) assert.Equal(t, - &model.MeasurementStats{Sent: 4, Lost: 1, Loss: 25, Last: 6, Min: 1, Avg: 3, Max: 6}, + model.MeasurementStats{Sent: 4, Rcv: 4, Lost: 0, Loss: 0, Last: 1.11, Min: 1.061, Avg: 1.09, Max: 1.108, Time: 1002}, newStats, ) } @@ -446,7 +755,7 @@ func TestGetRowValues(t *testing.T) { func TestParsePingRawOutputFull(t *testing.T) { m := &model.MeasurementResponse{ Result: model.ResultData{ - RawOutput: `PING (142.250.65.174) 56(84) bytes of data. + RawOutput: `PING cdn.jsdelivr.net (142.250.65.174) 56(84) bytes of data. 64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=1 ttl=59 time=1.06 ms 64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=2 ttl=59 time=1.10 ms 64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=3 ttl=59 time=1.11 ms @@ -456,17 +765,20 @@ func TestParsePingRawOutputFull(t *testing.T) { rtt min/avg/max/mdev = 1.061/1.090/1.108/0.020 ms`, }, } - res, err := parsePingRawOutput(m, -1) - assert.NoError(t, err) + res := parsePingRawOutput(m, -1) assert.Equal(t, &ParsedPingOutput{ + Hostname: "cdn.jsdelivr.net", + Address: "142.250.65.174", + BytesOfData: "56(84)", Timings: []model.PingTiming{ {RTT: 1.06, TTL: 59}, {RTT: 1.10, TTL: 59}, {RTT: 1.11, TTL: 59}, }, Stats: &model.PingStats{ - Min: 1.061, Avg: 1.090, Max: 1.108, Total: 3, Rcv: 3, Drop: 0, Loss: 0, + Min: 1.061, Avg: 1.090, Max: 1.108, Total: 3, Rcv: 3, Drop: 0, Loss: 0, Mdev: 0.020, }, + Time: 1002, }, res) } @@ -482,9 +794,10 @@ no answer yet for icmp_seq=2 no answer yet for icmp_seq=4`, }, } - res, err := parsePingRawOutput(m, -1) - assert.NoError(t, err) + res := parsePingRawOutput(m, -1) assert.Equal(t, &ParsedPingOutput{ + Address: "142.250.65.174", + BytesOfData: "56(84)", Timings: []model.PingTiming{ {RTT: 1.06, TTL: 59}, {RTT: 1.10, TTL: 59}, @@ -508,9 +821,10 @@ no answer yet for icmp_seq=2 no answer yet for icmp_seq=4`, }, } - res, err := parsePingRawOutput(m, 4) - assert.NoError(t, err) + res := parsePingRawOutput(m, 4) assert.Equal(t, &ParsedPingOutput{ + Address: "142.250.65.174", + BytesOfData: "56(84)", RawPacketLines: []string{ "no answer yet for icmp_seq=5", "64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=5 ttl=59 time=1.06 ms", diff --git a/view/utils_test.go b/view/utils_test.go index 241b66a..39f31ef 100644 --- a/view/utils_test.go +++ b/view/utils_test.go @@ -9,7 +9,7 @@ import ( var ( measurementID1 = "nzGzfAGL7sZfUs3c" - // measurementID2 = "A2ZfUs3cnzGzfAGL" + measurementID2 = "A2ZfUs3cnzGzfAGL" // measurementID3 = "7sZfUs3cnzGz1I20" ) @@ -40,7 +40,7 @@ func getPingGetMeasurement(id string) *model.GetMeasurement { 64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=60 time=17.6 ms --- jsdelivr.map.fastly.net ping statistics --- -1 packets transmitted, 1 received, 0% packet loss, time 0ms +1 packets transmitted, 1 received, 0% packet loss, time 1000ms rtt min/avg/max/mdev = 17.639/17.639/17.639/0.000 ms`, ResolvedAddress: "151.101.1.229", ResolvedHostname: "151.101.1.229", @@ -78,8 +78,8 @@ func getPingGetMeasurementMultipleLocations(id string) *model.GetMeasurement { 64 bytes from 146.75.73.229 (146.75.73.229): icmp_seq=1 ttl=52 time=0.770 ms -- ping statistics --- -1 packets transmitted, 1 received, 0% packet loss, time 0ms -rtt min/avg/max/mdev = 0.770/0.770/0.770/0.000 ms`, +1 packets transmitted, 1 received, 0% packet loss, time 1ms +rtt min/avg/max/mdev = 0.770/0.770/0.770/0.001 ms`, ResolvedAddress: "146.75.73.229", ResolvedHostname: "146.75.73.229", StatsRaw: json.RawMessage(`{"min":0.77,"avg":0.77,"max":0.77,"total":1,"rcv":1,"drop":0,"loss":0}`), @@ -102,8 +102,8 @@ rtt min/avg/max/mdev = 0.770/0.770/0.770/0.000 ms`, 64 bytes from 104.16.85.20 (104.16.85.20): icmp_seq=1 ttl=55 time=5.46 ms --- ping statistics --- -1 packets transmitted, 1 received, 0% packet loss, time 0ms -rtt min/avg/max/mdev = 5.457/5.457/5.457/0.000 ms`, +1 packets transmitted, 1 received, 0% packet loss, time 2ms +rtt min/avg/max/mdev = 5.457/5.457/5.457/0.002 ms`, ResolvedAddress: "104.16.85.20", ResolvedHostname: "104.16.85.20", StatsRaw: json.RawMessage(`{"min":5.457,"avg":5.457,"max":5.457,"total":1,"rcv":1,"drop":0,"loss":0}`), @@ -126,8 +126,8 @@ rtt min/avg/max/mdev = 5.457/5.457/5.457/0.000 ms`, 64 bytes from 104.16.88.20 (104.16.88.20): icmp_seq=1 ttl=58 time=4.07 ms --- ping statistics --- -1 packets transmitted, 1 received, 0% packet loss, time 0ms -rtt min/avg/max/mdev = 4.069/4.069/4.069/0.000 ms`, +1 packets transmitted, 1 received, 0% packet loss, time 3ms +rtt min/avg/max/mdev = 4.069/4.069/4.069/0.003 ms`, ResolvedAddress: "104.16.88.20", ResolvedHostname: "104.16.88.20", StatsRaw: json.RawMessage(`{"min":4.069,"avg":4.069,"max":4.069,"total":1,"rcv":1,"drop":0,"loss":0}`), @@ -140,13 +140,15 @@ rtt min/avg/max/mdev = 4.069/4.069/4.069/0.000 ms`, func getDefaultPingCtx(size int) *model.Context { ctx := &model.Context{ - Stats: make([]model.MeasurementStats, size), + Cmd: "ping", + APIMinInterval: 0, + CompletedStats: make([]model.MeasurementStats, size), } - for i := range ctx.Stats { - ctx.Stats[i].Last = -1 - ctx.Stats[i].Min = math.MaxFloat64 - ctx.Stats[i].Avg = -1 - ctx.Stats[i].Max = -1 + for i := range ctx.CompletedStats { + ctx.CompletedStats[i].Last = -1 + ctx.CompletedStats[i].Min = math.MaxFloat64 + ctx.CompletedStats[i].Avg = -1 + ctx.CompletedStats[i].Max = -1 } return ctx } From 8542e5ae48f913f5ea1120a21fdc7bad44423ff9 Mon Sep 17 00:00:00 2001 From: Radu Lucut Date: Wed, 31 Jan 2024 11:41:54 +0200 Subject: [PATCH 15/27] Add combined mdev, change --infinite + --latency --- view/infinite.go | 43 +++++++++++++------- view/infinite_test.go | 93 +++++++++++++++++++++++++------------------ 2 files changed, 83 insertions(+), 53 deletions(-) diff --git a/view/infinite.go b/view/infinite.go index 6278200..77321a0 100644 --- a/view/infinite.go +++ b/view/infinite.go @@ -40,7 +40,7 @@ func OutputInfinite(id string, ctx *model.Context) error { } } - if ctx.Latency || ctx.JsonOutput { + if ctx.JsonOutput { for res.Status == model.StatusInProgress { time.Sleep(ctx.APIMinInterval) res, err = fetcher.GetMeasurement(res.ID) @@ -48,18 +48,16 @@ func OutputInfinite(id string, ctx *model.Context) error { return err } } - if ctx.Latency { - return OutputLatency(id, res, *ctx) - } - if ctx.JsonOutput { - return OutputJson(id, fetcher, *ctx) - } + return OutputJson(id, fetcher, *ctx) } if len(res.Results) == 1 { - return outputSingleLocation(fetcher, res, ctx) + if ctx.Latency { + return outputTableView(fetcher, res, ctx) + } + return outputStreamingPackets(fetcher, res, ctx) } - return outputMultipleLocations(fetcher, res, ctx) + return outputTableView(fetcher, res, ctx) } func OutputSummary(ctx *model.Context) { @@ -77,10 +75,10 @@ func OutputSummary(ctx *model.Context) { stats.Loss, stats.Time, ) - // TODO: Add mdev min := "-" avg := "-" max := "-" + mdev := "-" if stats.Min != math.MaxFloat64 { min = fmt.Sprintf("%.3f", stats.Min) } @@ -90,7 +88,10 @@ func OutputSummary(ctx *model.Context) { if stats.Max != -1 { max = fmt.Sprintf("%.3f", stats.Max) } - fmt.Printf("rtt min/avg/max = %s/%s/%s ms\n", min, avg, max) + if stats.Mdev != 0 { + mdev = fmt.Sprintf("%.3f", stats.Mdev) + } + fmt.Printf("rtt min/avg/max/mdev = %s/%s/%s/%s ms\n", min, avg, max, mdev) } if ctx.Share && ctx.History != nil { @@ -107,7 +108,7 @@ func OutputSummary(ctx *model.Context) { } } -func outputSingleLocation( +func outputStreamingPackets( fetcher client.MeasurementsFetcher, res *model.GetMeasurement, ctx *model.Context, @@ -154,7 +155,7 @@ func outputSingleLocation( return nil } -func outputMultipleLocations( +func outputTableView( fetcher client.MeasurementsFetcher, res *model.GetMeasurement, ctx *model.Context) error { @@ -286,7 +287,20 @@ func mergeMeasurementStats(mStats model.MeasurementStats, measurement *model.Mea if o.Stats.Max > mStats.Max { mStats.Max = o.Stats.Max } - mStats.Avg = (mStats.Avg*float64(mStats.Sent) + o.Stats.Avg*float64(o.Stats.Total)) / float64(mStats.Sent+o.Stats.Total) + combinedRcv := mStats.Rcv + o.Stats.Rcv + combinedMean := (mStats.Avg*float64(mStats.Rcv) + o.Stats.Avg*float64(o.Stats.Rcv)) / float64(combinedRcv) + d1 := mStats.Avg - combinedMean + d2 := o.Stats.Avg - combinedMean + if mStats.Mdev == 0 { + mStats.Mdev = o.Stats.Mdev + } else if o.Stats.Mdev != 0 { + mStats.Mdev = math.Sqrt( + (float64(mStats.Rcv)*(mStats.Mdev*mStats.Mdev) + + float64(o.Stats.Rcv)*(o.Stats.Mdev*o.Stats.Mdev) + + float64(mStats.Rcv)*(d1*d1) + + float64(o.Stats.Rcv)*(d2*d2)) / float64(combinedRcv)) + } + mStats.Avg = combinedMean mStats.Last = o.Timings[len(o.Timings)-1].RTT } mStats.Sent += o.Stats.Total @@ -296,7 +310,6 @@ func mergeMeasurementStats(mStats model.MeasurementStats, measurement *model.Mea if mStats.Sent > 0 { mStats.Loss = float64(mStats.Lost) / float64(mStats.Sent) * 100 } - // TODO: Add mdev return mStats } diff --git a/view/infinite_test.go b/view/infinite_test.go index e10f454..e8e65d6 100644 --- a/view/infinite_test.go +++ b/view/infinite_test.go @@ -13,7 +13,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestOutputSingleLocationInProgress(t *testing.T) { +func TestStreamingPacketsInProgress(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -73,7 +73,7 @@ rtt min/avg/max/mdev = 17.006/17.333/17.648/0.321 ms` }() os.Stdout = w - err = outputSingleLocation(fetcher, measurement, ctx) + err = outputStreamingPackets(fetcher, measurement, ctx) w.Close() os.Stdout = osStdOut @@ -92,12 +92,12 @@ PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. ) assert.Equal(t, - []model.MeasurementStats{{Sent: 3, Rcv: 3, Lost: 0, Loss: 0, Last: 17, Min: 17.006, Avg: 17.333, Max: 17.648, Time: 2002}}, + []model.MeasurementStats{{Sent: 3, Rcv: 3, Lost: 0, Loss: 0, Last: 17, Min: 17.006, Avg: 17.333, Max: 17.648, Time: 2002, Mdev: 0.321}}, ctx.CompletedStats, ) } -func TestOutputSingleLocationMultipleCalls(t *testing.T) { +func TestStreamingPacketsMultipleCalls(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -123,11 +123,11 @@ func TestOutputSingleLocationMultipleCalls(t *testing.T) { }() os.Stdout = w - err = outputSingleLocation(fetcher, measurement, ctx) + err = outputStreamingPackets(fetcher, measurement, ctx) assert.NoError(t, err) - err = outputSingleLocation(fetcher, measurement, ctx) + err = outputStreamingPackets(fetcher, measurement, ctx) assert.NoError(t, err) - err = outputSingleLocation(fetcher, measurement, ctx) + err = outputStreamingPackets(fetcher, measurement, ctx) assert.NoError(t, err) w.Close() os.Stdout = osStdOut @@ -149,7 +149,7 @@ PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. assert.Equal(t, expectedStats, ctx.CompletedStats) } -func TestOutputMultipleLocationsInProgress(t *testing.T) { +func TestOutputTableViewMultipleCalls(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -211,13 +211,13 @@ rtt min/avg/max/mdev = 17.006/17.333/17.648/0.321 ms` }() os.Stdout = w - err = outputMultipleLocations(fetcher, res, ctx) + err = outputTableView(fetcher, res, ctx) assert.NoError(t, err) firstCallStats := []model.MeasurementStats{ - {Sent: 3, Rcv: 3, Lost: 0, Loss: 0, Last: 17, Min: 17.006, Avg: 17.333, Max: 17.648, Time: 2002}, - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Time: 2}, - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Time: 3}, + {Sent: 3, Rcv: 3, Lost: 0, Loss: 0, Last: 17, Min: 17.006, Avg: 17.333, Max: 17.648, Time: 2002, Mdev: 0.321}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Time: 2, Mdev: 0.002}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Time: 3, Mdev: 0.003}, } assert.Equal(t, firstCallStats, ctx.InProgressStats) assert.Equal(t, firstCallStats, ctx.CompletedStats) @@ -230,7 +230,7 @@ rtt min/avg/max/mdev = 17.006/17.333/17.648/0.321 ms` callCount++ expectedTables[3], _ = generateTable(res, expectedCtx, 76) - err = outputMultipleLocations(fetcher, res, ctx) + err = outputTableView(fetcher, res, ctx) assert.NoError(t, err) w.Close() @@ -239,9 +239,9 @@ rtt min/avg/max/mdev = 17.006/17.333/17.648/0.321 ms` assert.NoError(t, err) secondCallStats := []model.MeasurementStats{ - {Sent: 6, Rcv: 6, Lost: 0, Loss: 0, Last: 17, Min: 17.006, Avg: 17.333, Max: 17.648, Time: 4004}, - {Sent: 2, Rcv: 2, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Time: 4}, - {Sent: 2, Rcv: 2, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Time: 6}, + {Sent: 6, Rcv: 6, Lost: 0, Loss: 0, Last: 17, Min: 17.006, Avg: 17.333, Max: 17.648, Time: 4004, Mdev: 0.321}, + {Sent: 2, Rcv: 2, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Time: 4, Mdev: 0.002}, + {Sent: 2, Rcv: 2, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Time: 6, Mdev: 0.003}, } assert.Equal(t, secondCallStats, ctx.InProgressStats) assert.Equal(t, secondCallStats, ctx.CompletedStats) @@ -269,7 +269,7 @@ rtt min/avg/max/mdev = 17.006/17.333/17.648/0.321 ms` assert.Equal(t, string(expectedOutput), string(output)) } -func TestOutputMultipleLocations(t *testing.T) { +func TestOutputTableView(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -293,7 +293,7 @@ func TestOutputMultipleLocations(t *testing.T) { }() os.Stdout = w - err = outputMultipleLocations(fetcher, measurement, ctx) + err = outputTableView(fetcher, measurement, ctx) assert.NoError(t, err) w.Close() @@ -326,9 +326,9 @@ func TestOutputMultipleLocations(t *testing.T) { assert.Equal(t, string(expectedOutput), string(output)) assert.Equal(t, []model.MeasurementStats{ - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 1}, - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Time: 2}, - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Time: 3}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 1, Mdev: 0.001}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Time: 2, Mdev: 0.002}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Time: 3, Mdev: 0.003}, }, ctx.CompletedStats, ) @@ -376,7 +376,7 @@ func TestOutputSummary(t *testing.T) { ctx := &model.Context{ InProgressStats: []model.MeasurementStats{ - {Sent: 10, Rcv: 9, Lost: 1, Loss: 10, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 1000}, + {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}, }, } os.Stdout = w @@ -390,7 +390,7 @@ func TestOutputSummary(t *testing.T) { assert.Equal(t, ` --- ping statistics --- 10 packets transmitted, 9 received, 10.00% packet loss, time 1000ms -rtt min/avg/max = 0.770/0.770/0.770 ms +rtt min/avg/max/mdev = 0.770/0.770/0.770/0.001 ms `, string(output)) }) @@ -424,7 +424,7 @@ rtt min/avg/max = 0.770/0.770/0.770 ms assert.Equal(t, ` --- ping statistics --- 1 packets transmitted, 0 received, 100.00% packet loss, time 0ms -rtt min/avg/max = -/-/- ms +rtt min/avg/max/mdev = -/-/-/- ms `, string(output)) }) @@ -494,7 +494,7 @@ rtt min/avg/max = -/-/- ms expectedOutput := ` --- ping statistics --- 1 packets transmitted, 0 received, 100.00% packet loss, time 0ms -rtt min/avg/max = -/-/- ms +rtt min/avg/max/mdev = -/-/-/- ms ` + formatWithLeadingArrow(shareMessage(measurementID1), true) + "\n" assert.Equal(t, expectedOutput, string(output)) @@ -600,9 +600,9 @@ func TestGenerateTableFull(t *testing.T) { table, stats := generateTable(measurement, ctx, 500) assert.Equal(t, expectedTable, *table) assert.Equal(t, []model.MeasurementStats{ - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 1}, - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Time: 2}, - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Time: 3}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 1, Mdev: 0.001}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Time: 2, Mdev: 0.002}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Time: 3, Mdev: 0.003}, }, stats) } @@ -617,9 +617,9 @@ func TestGenerateTableOneRowTruncated(t *testing.T) { table, stats := generateTable(measurement, ctx, 106) assert.Equal(t, expectedTable, *table) assert.Equal(t, []model.MeasurementStats{ - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 1}, - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Time: 2}, - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Time: 3}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 1, Mdev: 0.001}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Time: 2, Mdev: 0.002}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Time: 3, Mdev: 0.003}, }, stats) } @@ -636,9 +636,9 @@ func TestGenerateTableMultiLineTruncated(t *testing.T) { table, stats := generateTable(measurement, ctx, 106) assert.Equal(t, expectedTable, *table) assert.Equal(t, []model.MeasurementStats{ - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 1}, - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Time: 2}, - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Time: 3}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 1, Mdev: 0.001}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Time: 2, Mdev: 0.002}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Time: 3, Mdev: 0.003}, }, stats) } @@ -652,9 +652,9 @@ func TestGenerateTableMaxTruncated(t *testing.T) { table, stats := generateTable(measurement, ctx, 0) assert.Equal(t, expectedTable, *table) assert.Equal(t, []model.MeasurementStats{ - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 1}, - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Time: 2}, - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Time: 3}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 1, Mdev: 0.001}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Time: 2, Mdev: 0.002}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Time: 3, Mdev: 0.003}, }, stats) } @@ -709,7 +709,24 @@ rtt min/avg/max/mdev = 1.061/1.090/1.108/0.020 ms`, model.MeasurementStats{Sent: 0, Lost: 0, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1}, &result) assert.Equal(t, - model.MeasurementStats{Sent: 4, Rcv: 4, Lost: 0, Loss: 0, Last: 1.11, Min: 1.061, Avg: 1.09, Max: 1.108, Time: 1002}, + model.MeasurementStats{Sent: 4, Rcv: 4, Lost: 0, Loss: 0, Last: 1.11, Min: 1.061, Avg: 1.09, Max: 1.108, Time: 1002, Mdev: 0.020}, + newStats, + ) + result = model.MeasurementResponse{ + Result: model.ResultData{ + RawOutput: `PING (142.250.65.174) 56(84) bytes of data. +64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=1 ttl=59 time=1 ms + +--- ping statistics --- +20 packets transmitted, 20 received, 0% packet loss, time 1000ms +rtt min/avg/max/mdev = 10/30/40/5 ms`, + }, + } + newStats = mergeMeasurementStats( + model.MeasurementStats{Sent: 30, Rcv: 30, Lost: 0, Loss: 0, Last: 10, Min: 10, Avg: 20, Max: 30, Mdev: 4}, + &result) + assert.Equal(t, + model.MeasurementStats{Sent: 50, Rcv: 50, Lost: 0, Loss: 0, Last: 1, Min: 10, Avg: 24, Max: 40, Time: 1000, Mdev: 6.603029607687671}, newStats, ) } From 180a8ba3f2c86f1c135d14b46bf243126dca9a55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kol=C3=A1rik?= Date: Fri, 2 Feb 2024 15:03:00 +0100 Subject: [PATCH 16/27] Update ping.go --- cmd/ping.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/ping.go b/cmd/ping.go index 3fe4991..e6e069d 100644 --- a/cmd/ping.go +++ b/cmd/ping.go @@ -135,5 +135,5 @@ func init() { // ping specific flags pingCmd.Flags().IntVar(&ctx.Packets, "packets", 0, "Specifies the desired amount of ECHO_REQUEST packets to be sent (default 3)") - pingCmd.Flags().BoolVar(&ctx.Infinite, "infinite", false, "Continuously send ping request to a target (default false)") + pingCmd.Flags().BoolVar(&ctx.Infinite, "infinite", false, "Keep pinging the target continuously until stopped (default false)") } From 3ca1801bb3763a5bbcef2e790325bdd715ace116 Mon Sep 17 00:00:00 2001 From: Radu Lucut Date: Mon, 5 Feb 2024 16:49:54 +0200 Subject: [PATCH 17/27] Update mdev logic & term padding, fix history --- cmd/ping.go | 15 ++-- model/root.go | 22 ++--- view/infinite.go | 145 +++++++++++++++----------------- view/infinite_test.go | 189 ++++++++++++++++++++++++++---------------- view/utils_test.go | 6 +- 5 files changed, 208 insertions(+), 169 deletions(-) diff --git a/cmd/ping.go b/cmd/ping.go index e6e069d..d64a72d 100644 --- a/cmd/ping.go +++ b/cmd/ping.go @@ -97,12 +97,17 @@ func ping(cmd *cobra.Command) (string, error) { Packets: ctx.Packets, }, } - locations, isPreviousMeasurementId, err := createLocations(ctx.From) - if err != nil { - cmd.SilenceUsage = true - return "", err + var err error + isPreviousMeasurementId := true + if ctx.CallCount == 0 { + opts.Locations, isPreviousMeasurementId, err = createLocations(ctx.From) + if err != nil { + cmd.SilenceUsage = true + return "", err + } + } else { + opts.Locations = []model.Locations{{Magic: ctx.From}} } - opts.Locations = locations res, showHelp, err := client.PostAPI(opts) if err != nil { diff --git a/model/root.go b/model/root.go index 6d2fd56..11b49c7 100644 --- a/model/root.go +++ b/model/root.go @@ -36,16 +36,18 @@ type Context struct { } type MeasurementStats struct { - Sent int // Number of packets sent - Rcv int // Number of packets received - Lost int // Number of packets lost - Loss float64 // Percentage of packets lost - Last float64 // Last RTT - Min float64 // Minimum RTT - Avg float64 // Average RTT - Max float64 // Maximum RTT - Mdev float64 // Mean deviation of RTT - Time float64 // Total time + Sent int // Number of packets sent + Rcv int // Number of packets received + Lost int // Number of packets lost + Loss float64 // Percentage of packets lost + Last float64 // Last RTT + Min float64 // Minimum RTT + Avg float64 // Average RTT + Max float64 // Maximum RTT + Mdev float64 // Mean deviation of RTT + Time float64 // Total time + Tsum float64 // Total sum of RTT + Tsum2 float64 // Total sum of RTT squared } func NewMeasurementStats() MeasurementStats { diff --git a/view/infinite.go b/view/infinite.go index 77321a0..ca16106 100644 --- a/view/infinite.go +++ b/view/infinite.go @@ -138,7 +138,7 @@ func outputStreamingPackets( fmt.Println(parsedOutput.RawPacketLines[linesPrinted]) linesPrinted++ } - ctx.InProgressStats[0] = mergeMeasurementStats(ctx.CompletedStats[0], measurement) + ctx.InProgressStats[0] = mergeMeasurementStats(ctx.CompletedStats[0], parsedOutput) if res.Status != model.StatusInProgress { ctx.CompletedStats[0] = ctx.InProgressStats[0] } @@ -176,7 +176,7 @@ func outputTableView( } } for { - o, stats := generateTable(res, ctx, pterm.GetTerminalWidth()-4) + o, stats := generateTable(res, ctx, pterm.GetTerminalWidth()-2) if o != nil { ctx.Area.Update(*o) } @@ -225,7 +225,8 @@ func generateTable(res *model.GetMeasurement, ctx *model.Context, areaWidth int) skip = true break } - newStats[i] = mergeMeasurementStats(ctx.CompletedStats[i], measurement) + parsedOutput := parsePingRawOutput(measurement, ctx.CompletedStats[i].Sent) + newStats[i] = mergeMeasurementStats(ctx.CompletedStats[i], parsedOutput) row := getRowValues(&newStats[i]) rowWidth := 0 for j := 1; j < len(row); j++ { @@ -278,39 +279,28 @@ func generateTable(res *model.GetMeasurement, ctx *model.Context, areaWidth int) return &output, newStats } -func mergeMeasurementStats(mStats model.MeasurementStats, measurement *model.MeasurementResponse) model.MeasurementStats { - o := parsePingRawOutput(measurement, mStats.Sent) +func mergeMeasurementStats(stats model.MeasurementStats, o *ParsedPingOutput) model.MeasurementStats { if o.Stats.Rcv > 0 { - if o.Stats.Min < mStats.Min && o.Stats.Min != 0 { - mStats.Min = o.Stats.Min - } - if o.Stats.Max > mStats.Max { - mStats.Max = o.Stats.Max - } - combinedRcv := mStats.Rcv + o.Stats.Rcv - combinedMean := (mStats.Avg*float64(mStats.Rcv) + o.Stats.Avg*float64(o.Stats.Rcv)) / float64(combinedRcv) - d1 := mStats.Avg - combinedMean - d2 := o.Stats.Avg - combinedMean - if mStats.Mdev == 0 { - mStats.Mdev = o.Stats.Mdev - } else if o.Stats.Mdev != 0 { - mStats.Mdev = math.Sqrt( - (float64(mStats.Rcv)*(mStats.Mdev*mStats.Mdev) + - float64(o.Stats.Rcv)*(o.Stats.Mdev*o.Stats.Mdev) + - float64(mStats.Rcv)*(d1*d1) + - float64(o.Stats.Rcv)*(d2*d2)) / float64(combinedRcv)) - } - mStats.Avg = combinedMean - mStats.Last = o.Timings[len(o.Timings)-1].RTT - } - mStats.Sent += o.Stats.Total - mStats.Lost += o.Stats.Drop - mStats.Time += o.Time - mStats.Rcv += o.Stats.Rcv - if mStats.Sent > 0 { - mStats.Loss = float64(mStats.Lost) / float64(mStats.Sent) * 100 - } - return mStats + if o.Stats.Min < stats.Min && o.Stats.Min != 0 { + stats.Min = o.Stats.Min + } + if o.Stats.Max > stats.Max { + stats.Max = o.Stats.Max + } + stats.Tsum += o.Stats.Tsum + stats.Tsum2 += o.Stats.Tsum2 + stats.Rcv += o.Stats.Rcv + stats.Avg = stats.Tsum / float64(stats.Rcv) + stats.Mdev = computeMdev(stats.Tsum, stats.Tsum2, stats.Rcv, stats.Avg) + stats.Last = o.Timings[len(o.Timings)-1].RTT + } + stats.Sent += o.Stats.Sent + stats.Lost += o.Stats.Lost + stats.Time += o.Time + if stats.Sent > 0 { + stats.Loss = float64(stats.Lost) / float64(stats.Sent) * 100 + } + return stats } func getRowValues(stats *model.MeasurementStats) [7]string { @@ -361,15 +351,19 @@ type ParsedPingOutput struct { BytesOfData string RawPacketLines []string Timings []model.PingTiming - Stats *model.PingStats + Stats *model.MeasurementStats Time float64 } -// If startIncmpSeq is -1, RawPacketLines will be empty +// Parse ping's raw output. Adapted from iputils ping: https://github.com/iputils/iputils/tree/1c08152/ping +// +// - If startIncmpSeq is -1, RawPacketLines will be empty +// +// - Stats.Time will be 0 if no summary is found func parsePingRawOutput(m *model.MeasurementResponse, startIncmpSeq int) *ParsedPingOutput { res := &ParsedPingOutput{ Timings: make([]model.PingTiming, 0), - Stats: &model.PingStats{ + Stats: &model.MeasurementStats{ Min: math.MaxFloat64, Max: -1, Avg: -1, @@ -417,25 +411,22 @@ func parsePingRawOutput(m *model.MeasurementResponse, startIncmpSeq int) *Parsed if icmp_seq != -1 { if words[1] == "bytes" && words[2] == "from" { if !sentMap[icmp_seq] { - res.Stats.Total++ + res.Stats.Sent++ } res.Stats.Rcv++ ttl, _ := strconv.Atoi(words[icmp_seq_index+1][4:]) rtt, _ := strconv.ParseFloat(words[icmp_seq_index+2][5:], 64) res.Stats.Min = math.Min(res.Stats.Min, rtt) res.Stats.Max = math.Max(res.Stats.Max, rtt) - if res.Stats.Rcv == 1 { - res.Stats.Avg = rtt - } else { - res.Stats.Avg = (res.Stats.Avg*float64(res.Stats.Rcv-1) + rtt) / float64(res.Stats.Rcv) - } + res.Stats.Tsum += rtt + res.Stats.Tsum2 += rtt * rtt res.Timings = append(res.Timings, model.PingTiming{ TTL: ttl, RTT: rtt, }) } else { if !sentMap[icmp_seq] { - res.Stats.Total++ + res.Stats.Sent++ } sentMap[icmp_seq] = true } @@ -448,41 +439,37 @@ func parsePingRawOutput(m *model.MeasurementResponse, startIncmpSeq int) *Parsed res.RawPacketLines = append(res.RawPacketLines, line) } } - // Parse summary hasSummary := scanner.Scan() - if !hasSummary { - res.Stats.Drop = res.Stats.Total - res.Stats.Rcv - res.Stats.Loss = float64(res.Stats.Drop) / float64(res.Stats.Total) * 100 - return res - } - scanner.Scan() // skip --- ping statistics --- - line := scanner.Text() - words = strings.Split(line, " ") - if len(words) < 3 { - return res - } - if words[1] == "packets" && words[2] == "transmitted," { - res.Stats.Total, _ = strconv.Atoi(words[0]) - res.Stats.Rcv, _ = strconv.Atoi(words[3]) - res.Stats.Loss, _ = strconv.ParseFloat(words[5][:len(words[5])-1], 64) - res.Stats.Drop = res.Stats.Total - res.Stats.Rcv - res.Time, _ = strconv.ParseFloat(words[9][:len(words[9])-2], 64) - } - hasSummary = scanner.Scan() - if !hasSummary { - return res - } - line = scanner.Text() - words = strings.Split(line, " ") - if len(words) < 2 { - return res - } - if words[0] == "rtt" && words[1] == "min/avg/max/mdev" { - words = strings.Split(words[3], "/") - res.Stats.Min, _ = strconv.ParseFloat(words[0], 64) - res.Stats.Avg, _ = strconv.ParseFloat(words[1], 64) - res.Stats.Max, _ = strconv.ParseFloat(words[2], 64) - res.Stats.Mdev, _ = strconv.ParseFloat(words[3], 64) + if hasSummary { + // Parse summary + scanner.Scan() // skip --- ping statistics --- + line := scanner.Text() + words = strings.Split(line, " ") + if len(words) < 3 { + return res + } + if words[1] == "packets" && words[2] == "transmitted," { + res.Stats.Sent, _ = strconv.Atoi(words[0]) + res.Stats.Rcv, _ = strconv.Atoi(words[3]) + res.Time, _ = strconv.ParseFloat(words[9][:len(words[9])-2], 64) + } + } + if res.Stats.Sent > 0 { + res.Stats.Lost = res.Stats.Sent - res.Stats.Rcv + res.Stats.Loss = float64(res.Stats.Lost) / float64(res.Stats.Sent) * 100 + if res.Stats.Rcv > 0 { + res.Stats.Avg = res.Stats.Tsum / float64(res.Stats.Rcv) + res.Stats.Mdev = computeMdev(res.Stats.Tsum, res.Stats.Tsum2, res.Stats.Rcv, res.Stats.Avg) + res.Stats.Last = res.Timings[len(res.Timings)-1].RTT + } } return res } + +// https://github.com/iputils/iputils/tree/1c08152/ping/ping_common.c#L917 +func computeMdev(tsum float64, tsum2 float64, rcv int, avg float64) float64 { + if tsum < math.MaxInt32 { + return math.Sqrt((tsum2 - ((tsum * tsum) / float64(rcv))) / float64(rcv)) + } + return math.Sqrt(tsum2/float64(rcv) - avg*avg) +} diff --git a/view/infinite_test.go b/view/infinite_test.go index e8e65d6..5c1a327 100644 --- a/view/infinite_test.go +++ b/view/infinite_test.go @@ -24,18 +24,18 @@ func TestStreamingPacketsInProgress(t *testing.T) { rawOutput1 := `PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data.` rawOutput2 := `PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. -64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=60 time=17.6 ms` +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=56 time=12.9 ms` rawOutput3 := `PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. -64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=60 time=17.6 ms -64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=2 ttl=30 time=17.3 ms` +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=56 time=12.9 ms +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=2 ttl=56 time=12.7 ms` rawOutput4 := `PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. -64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=60 time=17.6 ms -64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=2 ttl=30 time=17.3 ms -64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=3 ttl=10 time=17.0 ms +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=56 time=12.9 ms +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=2 ttl=56 time=12.7 ms +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=3 ttl=56 time=13.0 ms --- ping statistics --- -3 packets transmitted, 3 received, 0% packet loss, time 2002ms -rtt min/avg/max/mdev = 17.006/17.333/17.648/0.321 ms` +3 packets transmitted, 3 received, 0% packet loss, time 1001ms +rtt min/avg/max/mdev = 12.711/12.854/12.952/0.103 ms` fetcher := mocks.NewMockMeasurementsFetcher(ctrl) measurement := getPingGetMeasurement(measurementID1) @@ -84,15 +84,15 @@ rtt min/avg/max/mdev = 17.006/17.333/17.648/0.321 ms` assert.Equal(t, `> EU, DE, Berlin, ASN:3320, Deutsche Telekom AG PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. -64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=60 time=17.6 ms -64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=2 ttl=30 time=17.3 ms -64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=3 ttl=10 time=17.0 ms +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=56 time=12.9 ms +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=2 ttl=56 time=12.7 ms +64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=3 ttl=56 time=13.0 ms `, string(output), ) assert.Equal(t, - []model.MeasurementStats{{Sent: 3, Rcv: 3, Lost: 0, Loss: 0, Last: 17, Min: 17.006, Avg: 17.333, Max: 17.648, Time: 2002, Mdev: 0.321}}, + []model.MeasurementStats{{Sent: 3, Rcv: 3, Lost: 0, Loss: 0, Last: 13, Min: 12.7, Avg: 12.866666666666667, Max: 13, Time: 1001, Tsum: 38.6, Tsum2: 496.7, Mdev: 0.124721912892408}}, ctx.CompletedStats, ) } @@ -144,7 +144,7 @@ PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. `, string(output)) - expectedStats := []model.MeasurementStats{{Sent: 3, Rcv: 3, Lost: 0, Loss: 0, Last: 17.6, Min: 17.639, Avg: 17.639, Max: 17.639, Time: 3000}} + expectedStats := []model.MeasurementStats{{Sent: 3, Rcv: 3, Lost: 0, Loss: 0, Last: 17.6, Min: 17.6, Avg: 17.6, Max: 17.6, Time: 3000, Tsum: 52.800000000000004, Tsum2: 929.2800000000002, Mdev: 0}} assert.Equal(t, expectedStats, ctx.InProgressStats) assert.Equal(t, expectedStats, ctx.CompletedStats) } @@ -187,12 +187,12 @@ rtt min/avg/max/mdev = 17.006/17.333/17.648/0.321 ms` switch callCount { case 2, 5: res.Results[0].Result.RawOutput = rawOutput2 - expectedTables[callCount-1], _ = generateTable(res, expectedCtx, 76) + expectedTables[callCount-1], _ = generateTable(res, expectedCtx, 78) case 3, 6: res.Status = model.StatusFinished res.Results[0].Result.Status = model.StatusFinished res.Results[0].Result.RawOutput = rawOutputFinal - expectedTables[callCount-1], _ = generateTable(res, expectedCtx, 76) + expectedTables[callCount-1], _ = generateTable(res, expectedCtx, 78) } return res, nil }).Times(4) @@ -201,7 +201,7 @@ rtt min/avg/max/mdev = 17.006/17.333/17.648/0.321 ms` res.Status = model.StatusInProgress res.Results[0].Result.Status = model.StatusInProgress res.Results[0].Result.RawOutput = rawOutput1 - expectedTables[0], _ = generateTable(res, expectedCtx, 76) + expectedTables[0], _ = generateTable(res, expectedCtx, 78) r, w, err := os.Pipe() assert.NoError(t, err) @@ -215,9 +215,9 @@ rtt min/avg/max/mdev = 17.006/17.333/17.648/0.321 ms` assert.NoError(t, err) firstCallStats := []model.MeasurementStats{ - {Sent: 3, Rcv: 3, Lost: 0, Loss: 0, Last: 17, Min: 17.006, Avg: 17.333, Max: 17.648, Time: 2002, Mdev: 0.321}, - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Time: 2, Mdev: 0.002}, - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Time: 3, Mdev: 0.003}, + {Sent: 3, Rcv: 3, Lost: 0, Loss: 0, Last: 17, Min: 17, Avg: 17.3, Max: 17.6, Time: 2002, Tsum: 51.900000000000006, Tsum2: 898.0500000000001, Mdev: 0.24494897427820642}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.46, Avg: 5.46, Max: 5.46, Time: 200, Tsum: 5.46, Tsum2: 29.8116}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.07, Avg: 4.07, Max: 4.07, Time: 300, Tsum: 4.07, Tsum2: 16.5649}, } assert.Equal(t, firstCallStats, ctx.InProgressStats) assert.Equal(t, firstCallStats, ctx.CompletedStats) @@ -229,7 +229,7 @@ rtt min/avg/max/mdev = 17.006/17.333/17.648/0.321 ms` expectedCtx.CompletedStats = firstCallStats callCount++ - expectedTables[3], _ = generateTable(res, expectedCtx, 76) + expectedTables[3], _ = generateTable(res, expectedCtx, 78) err = outputTableView(fetcher, res, ctx) assert.NoError(t, err) w.Close() @@ -239,9 +239,9 @@ rtt min/avg/max/mdev = 17.006/17.333/17.648/0.321 ms` assert.NoError(t, err) secondCallStats := []model.MeasurementStats{ - {Sent: 6, Rcv: 6, Lost: 0, Loss: 0, Last: 17, Min: 17.006, Avg: 17.333, Max: 17.648, Time: 4004, Mdev: 0.321}, - {Sent: 2, Rcv: 2, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Time: 4, Mdev: 0.002}, - {Sent: 2, Rcv: 2, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Time: 6, Mdev: 0.003}, + {Sent: 6, Rcv: 6, Lost: 0, Loss: 0, Last: 17, Min: 17, Avg: 17.3, Max: 17.6, Time: 4004, Tsum: 103.80000000000001, Tsum2: 1796.1000000000001, Mdev: 0.24494897427820642}, + {Sent: 2, Rcv: 2, Lost: 0, Loss: 0, Last: 5.46, Min: 5.46, Avg: 5.46, Max: 5.46, Time: 400, Tsum: 10.92, Tsum2: 59.6232}, + {Sent: 2, Rcv: 2, Lost: 0, Loss: 0, Last: 4.07, Min: 4.07, Avg: 4.07, Max: 4.07, Time: 600, Tsum: 8.14, Tsum2: 33.1298}, } assert.Equal(t, secondCallStats, ctx.InProgressStats) assert.Equal(t, secondCallStats, ctx.CompletedStats) @@ -312,7 +312,7 @@ func TestOutputTableView(t *testing.T) { os.Stdout = ww expectedCtx := getDefaultPingCtx(len(measurement.Results)) - expectedTable, _ := generateTable(measurement, expectedCtx, 76) // 80 - 4. pterm defaults to 80 when terminal size is not detected. + expectedTable, _ := generateTable(measurement, expectedCtx, 78) // 80 - 2. pterm defaults to 80 when terminal size is not detected. area, _ := pterm.DefaultArea.Start() area.Update(*expectedTable) area.Stop() @@ -326,9 +326,9 @@ func TestOutputTableView(t *testing.T) { assert.Equal(t, string(expectedOutput), string(output)) assert.Equal(t, []model.MeasurementStats{ - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 1, Mdev: 0.001}, - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Time: 2, Mdev: 0.002}, - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Time: 3, Mdev: 0.003}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 100, Tsum: 0.77, Tsum2: 0.5929}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.46, Avg: 5.46, Max: 5.46, Time: 200, Tsum: 5.46, Tsum2: 29.8116}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.07, Avg: 4.07, Max: 4.07, Time: 300, Tsum: 4.07, Tsum2: 16.5649}, }, ctx.CompletedStats, ) @@ -600,9 +600,9 @@ func TestGenerateTableFull(t *testing.T) { table, stats := generateTable(measurement, ctx, 500) assert.Equal(t, expectedTable, *table) assert.Equal(t, []model.MeasurementStats{ - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 1, Mdev: 0.001}, - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Time: 2, Mdev: 0.002}, - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Time: 3, Mdev: 0.003}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 100, Tsum: 0.77, Tsum2: 0.5929}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.46, Avg: 5.46, Max: 5.46, Time: 200, Tsum: 5.46, Tsum2: 29.8116}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.07, Avg: 4.07, Max: 4.07, Time: 300, Tsum: 4.07, Tsum2: 16.5649}, }, stats) } @@ -617,9 +617,9 @@ func TestGenerateTableOneRowTruncated(t *testing.T) { table, stats := generateTable(measurement, ctx, 106) assert.Equal(t, expectedTable, *table) assert.Equal(t, []model.MeasurementStats{ - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 1, Mdev: 0.001}, - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Time: 2, Mdev: 0.002}, - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Time: 3, Mdev: 0.003}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 100, Tsum: 0.77, Tsum2: 0.5929}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.46, Avg: 5.46, Max: 5.46, Time: 200, Tsum: 5.46, Tsum2: 29.8116}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.07, Avg: 4.07, Max: 4.07, Time: 300, Tsum: 4.07, Tsum2: 16.5649}, }, stats) } @@ -636,9 +636,9 @@ func TestGenerateTableMultiLineTruncated(t *testing.T) { table, stats := generateTable(measurement, ctx, 106) assert.Equal(t, expectedTable, *table) assert.Equal(t, []model.MeasurementStats{ - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 1, Mdev: 0.001}, - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Time: 2, Mdev: 0.002}, - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Time: 3, Mdev: 0.003}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 100, Tsum: 0.77, Tsum2: 0.5929}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.46, Avg: 5.46, Max: 5.46, Time: 200, Tsum: 5.46, Tsum2: 29.8116}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.07, Avg: 4.07, Max: 4.07, Time: 300, Tsum: 4.07, Tsum2: 16.5649}, }, stats) } @@ -652,81 +652,84 @@ func TestGenerateTableMaxTruncated(t *testing.T) { table, stats := generateTable(measurement, ctx, 0) assert.Equal(t, expectedTable, *table) assert.Equal(t, []model.MeasurementStats{ - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 1, Mdev: 0.001}, - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Time: 2, Mdev: 0.002}, - {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Time: 3, Mdev: 0.003}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 100, Tsum: 0.77, Tsum2: 0.5929}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.46, Avg: 5.46, Max: 5.46, Time: 200, Tsum: 5.46, Tsum2: 29.8116}, + {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.07, Avg: 4.07, Max: 4.07, Time: 300, Tsum: 4.07, Tsum2: 16.5649}, }, stats) } func TestMergeMeasurementStats(t *testing.T) { - result := model.MeasurementResponse{ + o := parsePingRawOutput(&model.MeasurementResponse{ Result: model.ResultData{ RawOutput: `PING (142.250.65.174) 56(84) bytes of data.`, }, - } + }, 0) newStats := mergeMeasurementStats( model.MeasurementStats{Sent: 0, Lost: 0, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1}, - &result, + o, ) assert.Equal(t, model.MeasurementStats{Sent: 0, Lost: 0, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1}, newStats, ) - result = model.MeasurementResponse{ + o = parsePingRawOutput(&model.MeasurementResponse{ Result: model.ResultData{ RawOutput: `PING (142.250.65.174) 56(84) bytes of data. no answer yet for icmp_seq=1 -64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=1 ttl=59 time=1.06 ms +64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=1 ttl=59 time=10 ms no answer yet for icmp_seq=2 -64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=2 ttl=59 time=1.10 ms -64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=3 ttl=59 time=1.11 ms +64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=2 ttl=59 time=20 ms +64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=3 ttl=59 time=30 ms no answer yet for icmp_seq=4`, }, - } + }, 0) newStats = mergeMeasurementStats( model.MeasurementStats{Sent: 0, Lost: 0, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1}, - &result) + o) assert.Equal(t, - model.MeasurementStats{Sent: 4, Rcv: 3, Lost: 1, Loss: 25, Last: 1.11, Min: 1.06, Avg: 1.09, Max: 1.11}, + model.MeasurementStats{Sent: 4, Rcv: 3, Lost: 1, Loss: 25, Last: 30, Min: 10, Avg: 20, Max: 30, Tsum: 60, Tsum2: 1400, Mdev: 8.16496580927726}, newStats, ) - result = model.MeasurementResponse{ + o = parsePingRawOutput(&model.MeasurementResponse{ Result: model.ResultData{ RawOutput: `PING (142.250.65.174) 56(84) bytes of data. no answer yet for icmp_seq=1 -64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=1 ttl=59 time=1.06 ms +64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=1 ttl=59 time=10 ms no answer yet for icmp_seq=2 -64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=2 ttl=59 time=1.10 ms -64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=3 ttl=59 time=1.11 ms +64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=2 ttl=59 time=10 ms +64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=3 ttl=59 time=30 ms no answer yet for icmp_seq=4 +64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=3 ttl=59 time=30 ms --- ping statistics --- -4 packets transmitted, 4 received, 0% packet loss, time 1002ms -rtt min/avg/max/mdev = 1.061/1.090/1.108/0.020 ms`, +4 packets transmitted, 4 received, 0% packet loss, time 1000ms +rtt min/avg/max/mdev = 10/20/30/0 ms`, }, - } + }, 0) newStats = mergeMeasurementStats( model.MeasurementStats{Sent: 0, Lost: 0, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1}, - &result) + o) assert.Equal(t, - model.MeasurementStats{Sent: 4, Rcv: 4, Lost: 0, Loss: 0, Last: 1.11, Min: 1.061, Avg: 1.09, Max: 1.108, Time: 1002, Mdev: 0.020}, + model.MeasurementStats{Sent: 4, Rcv: 4, Lost: 0, Loss: 0, Last: 30, Min: 10, Avg: 20, Max: 30, Time: 1000, Tsum: 80, Tsum2: 2000, Mdev: 10}, newStats, ) - result = model.MeasurementResponse{ + o = parsePingRawOutput(&model.MeasurementResponse{ Result: model.ResultData{ RawOutput: `PING (142.250.65.174) 56(84) bytes of data. -64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=1 ttl=59 time=1 ms +64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=1 ttl=59 time=10 ms +64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=1 ttl=59 time=20 ms +64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=1 ttl=59 time=30 ms --- ping statistics --- -20 packets transmitted, 20 received, 0% packet loss, time 1000ms -rtt min/avg/max/mdev = 10/30/40/5 ms`, +3 packets transmitted, 3 received, 0% packet loss, time 1000ms +rtt min/avg/max/mdev = 10/20/30/0 ms`, }, - } + }, 0) newStats = mergeMeasurementStats( - model.MeasurementStats{Sent: 30, Rcv: 30, Lost: 0, Loss: 0, Last: 10, Min: 10, Avg: 20, Max: 30, Mdev: 4}, - &result) + model.MeasurementStats{Sent: 5, Rcv: 4, Lost: 1, Loss: 20, Last: 30, Min: 10, Avg: 20, Max: 30, Time: 1000, Tsum: 80, Tsum2: 2000, Mdev: 10}, + o) assert.Equal(t, - model.MeasurementStats{Sent: 50, Rcv: 50, Lost: 0, Loss: 0, Last: 1, Min: 10, Avg: 24, Max: 40, Time: 1000, Mdev: 6.603029607687671}, + model.MeasurementStats{Sent: 8, Rcv: 7, Lost: 1, Loss: 12.5, Last: 30, Min: 10, Avg: 20, Max: 30, Time: 2000, Tsum: 140, Tsum2: 3400, Mdev: 9.258200997725515}, newStats, ) } @@ -792,8 +795,18 @@ rtt min/avg/max/mdev = 1.061/1.090/1.108/0.020 ms`, {RTT: 1.10, TTL: 59}, {RTT: 1.11, TTL: 59}, }, - Stats: &model.PingStats{ - Min: 1.061, Avg: 1.090, Max: 1.108, Total: 3, Rcv: 3, Drop: 0, Loss: 0, Mdev: 0.020, + Stats: &model.MeasurementStats{ + Sent: 3, + Rcv: 3, + Lost: 0, + Loss: 0, + Last: 1.11, + Min: 1.06, + Avg: 1.09, + Max: 1.11, + Tsum: 3.2700000000000005, + Tsum2: 3.565700000000001, + Mdev: 0.02160246899469168, }, Time: 1002, }, res) @@ -820,8 +833,18 @@ no answer yet for icmp_seq=4`, {RTT: 1.10, TTL: 59}, {RTT: 1.11, TTL: 59}, }, - Stats: &model.PingStats{ - Min: 1.06, Avg: 1.09, Max: 1.11, Total: 4, Rcv: 3, Drop: 1, Loss: 25, + Stats: &model.MeasurementStats{ + Sent: 4, + Rcv: 3, + Lost: 1, + Loss: 25, + Last: 1.11, + Min: 1.06, + Avg: 1.09, + Max: 1.11, + Tsum: 3.2700000000000005, + Tsum2: 3.565700000000001, + Mdev: 0.02160246899469168, }, }, res) } @@ -855,8 +878,30 @@ no answer yet for icmp_seq=4`, {RTT: 1.10, TTL: 59}, {RTT: 1.11, TTL: 59}, }, - Stats: &model.PingStats{ - Min: 1.06, Avg: 1.09, Max: 1.11, Total: 4, Rcv: 3, Drop: 1, Loss: 25, + Stats: &model.MeasurementStats{ + Sent: 4, + Rcv: 3, + Lost: 1, + Loss: 25, + Last: 1.11, + Min: 1.06, + Avg: 1.09, + Max: 1.11, + Tsum: 3.2700000000000005, + Tsum2: 3.565700000000001, + Mdev: 0.02160246899469168, }, }, res) } + +func TestComputeMdev(t *testing.T) { + rtt1 := 10.0 + rtt2 := 10.0 + rtt3 := 30.0 + rtt4 := 30.0 + tsum := rtt1 + rtt2 + rtt3 + rtt4 + tsum2 := rtt1*rtt1 + rtt2*rtt2 + rtt3*rtt3 + rtt4*rtt4 + avg := tsum / 4 + mdev := computeMdev(tsum, tsum2, 4, avg) + assert.Equal(t, 10.0, mdev) +} diff --git a/view/utils_test.go b/view/utils_test.go index 39f31ef..5ae36c6 100644 --- a/view/utils_test.go +++ b/view/utils_test.go @@ -78,7 +78,7 @@ func getPingGetMeasurementMultipleLocations(id string) *model.GetMeasurement { 64 bytes from 146.75.73.229 (146.75.73.229): icmp_seq=1 ttl=52 time=0.770 ms -- ping statistics --- -1 packets transmitted, 1 received, 0% packet loss, time 1ms +1 packets transmitted, 1 received, 0% packet loss, time 100ms rtt min/avg/max/mdev = 0.770/0.770/0.770/0.001 ms`, ResolvedAddress: "146.75.73.229", ResolvedHostname: "146.75.73.229", @@ -102,7 +102,7 @@ rtt min/avg/max/mdev = 0.770/0.770/0.770/0.001 ms`, 64 bytes from 104.16.85.20 (104.16.85.20): icmp_seq=1 ttl=55 time=5.46 ms --- ping statistics --- -1 packets transmitted, 1 received, 0% packet loss, time 2ms +1 packets transmitted, 1 received, 0% packet loss, time 200ms rtt min/avg/max/mdev = 5.457/5.457/5.457/0.002 ms`, ResolvedAddress: "104.16.85.20", ResolvedHostname: "104.16.85.20", @@ -126,7 +126,7 @@ rtt min/avg/max/mdev = 5.457/5.457/5.457/0.002 ms`, 64 bytes from 104.16.88.20 (104.16.88.20): icmp_seq=1 ttl=58 time=4.07 ms --- ping statistics --- -1 packets transmitted, 1 received, 0% packet loss, time 3ms +1 packets transmitted, 1 received, 0% packet loss, time 300ms rtt min/avg/max/mdev = 4.069/4.069/4.069/0.003 ms`, ResolvedAddress: "104.16.88.20", ResolvedHostname: "104.16.88.20", From 940e0a4cae28020557f5c00ced30bfb314942bb3 Mon Sep 17 00:00:00 2001 From: Radu Lucut Date: Mon, 5 Feb 2024 19:50:56 +0200 Subject: [PATCH 18/27] Fix tests --- client/client_test.go | 3 - view/infinite_test.go | 228 +++++++++++++++++++++++------------------- 2 files changed, 125 insertions(+), 106 deletions(-) diff --git a/client/client_test.go b/client/client_test.go index 8b20f09..13d5524 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -154,9 +154,6 @@ func testGetValid(t *testing.T) { if err != nil { t.Error(err) } - - t.Logf("%+v", res) - assert.Equal(t, "abcd", res.ID) } diff --git a/view/infinite_test.go b/view/infinite_test.go index 5c1a327..10ecfa4 100644 --- a/view/infinite_test.go +++ b/view/infinite_test.go @@ -91,10 +91,10 @@ PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. string(output), ) - assert.Equal(t, - []model.MeasurementStats{{Sent: 3, Rcv: 3, Lost: 0, Loss: 0, Last: 13, Min: 12.7, Avg: 12.866666666666667, Max: 13, Time: 1001, Tsum: 38.6, Tsum2: 496.7, Mdev: 0.124721912892408}}, - ctx.CompletedStats, - ) + expectedStats := []model.MeasurementStats{{Sent: 3, Rcv: 3, Lost: 0, Loss: 0, Last: 13, Min: 12.7, + Avg: 12.8666, Max: 13, Time: 1001, Tsum: 38.6, Tsum2: 496.7, Mdev: 0.1247}} + assertMeasurementStats(t, &expectedStats[0], &ctx.InProgressStats[0]) + assertMeasurementStats(t, &expectedStats[0], &ctx.CompletedStats[0]) } func TestStreamingPacketsMultipleCalls(t *testing.T) { @@ -144,9 +144,10 @@ PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. `, string(output)) - expectedStats := []model.MeasurementStats{{Sent: 3, Rcv: 3, Lost: 0, Loss: 0, Last: 17.6, Min: 17.6, Avg: 17.6, Max: 17.6, Time: 3000, Tsum: 52.800000000000004, Tsum2: 929.2800000000002, Mdev: 0}} - assert.Equal(t, expectedStats, ctx.InProgressStats) - assert.Equal(t, expectedStats, ctx.CompletedStats) + expectedStats := []model.MeasurementStats{{Sent: 3, Rcv: 3, Lost: 0, Loss: 0, Last: 17.6, Min: 17.6, + Avg: 17.6, Max: 17.6, Time: 3000, Tsum: 52.8, Tsum2: 929.28, Mdev: 0}} + assertMeasurementStats(t, &expectedStats[0], &ctx.InProgressStats[0]) + assertMeasurementStats(t, &expectedStats[0], &ctx.CompletedStats[0]) } func TestOutputTableViewMultipleCalls(t *testing.T) { @@ -215,12 +216,14 @@ rtt min/avg/max/mdev = 17.006/17.333/17.648/0.321 ms` assert.NoError(t, err) firstCallStats := []model.MeasurementStats{ - {Sent: 3, Rcv: 3, Lost: 0, Loss: 0, Last: 17, Min: 17, Avg: 17.3, Max: 17.6, Time: 2002, Tsum: 51.900000000000006, Tsum2: 898.0500000000001, Mdev: 0.24494897427820642}, + {Sent: 3, Rcv: 3, Lost: 0, Loss: 0, Last: 17, Min: 17, Avg: 17.3, Max: 17.6, Time: 2002, Tsum: 51.9, Tsum2: 898.05, Mdev: 0.2449}, {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.46, Avg: 5.46, Max: 5.46, Time: 200, Tsum: 5.46, Tsum2: 29.8116}, {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.07, Avg: 4.07, Max: 4.07, Time: 300, Tsum: 4.07, Tsum2: 16.5649}, } - assert.Equal(t, firstCallStats, ctx.InProgressStats) - assert.Equal(t, firstCallStats, ctx.CompletedStats) + for i := range firstCallStats { + assertMeasurementStats(t, &firstCallStats[i], &ctx.InProgressStats[i]) + assertMeasurementStats(t, &firstCallStats[i], &ctx.CompletedStats[i]) + } // 2nd call res.Status = model.StatusInProgress @@ -239,12 +242,14 @@ rtt min/avg/max/mdev = 17.006/17.333/17.648/0.321 ms` assert.NoError(t, err) secondCallStats := []model.MeasurementStats{ - {Sent: 6, Rcv: 6, Lost: 0, Loss: 0, Last: 17, Min: 17, Avg: 17.3, Max: 17.6, Time: 4004, Tsum: 103.80000000000001, Tsum2: 1796.1000000000001, Mdev: 0.24494897427820642}, + {Sent: 6, Rcv: 6, Lost: 0, Loss: 0, Last: 17, Min: 17, Avg: 17.3, Max: 17.6, Time: 4004, Tsum: 103.8, Tsum2: 1796.1, Mdev: 0.2449}, {Sent: 2, Rcv: 2, Lost: 0, Loss: 0, Last: 5.46, Min: 5.46, Avg: 5.46, Max: 5.46, Time: 400, Tsum: 10.92, Tsum2: 59.6232}, {Sent: 2, Rcv: 2, Lost: 0, Loss: 0, Last: 4.07, Min: 4.07, Avg: 4.07, Max: 4.07, Time: 600, Tsum: 8.14, Tsum2: 33.1298}, } - assert.Equal(t, secondCallStats, ctx.InProgressStats) - assert.Equal(t, secondCallStats, ctx.CompletedStats) + for i := range secondCallStats { + assertMeasurementStats(t, &secondCallStats[i], &ctx.InProgressStats[i]) + assertMeasurementStats(t, &secondCallStats[i], &ctx.CompletedStats[i]) + } rr, ww, err := os.Pipe() assert.NoError(t, err) @@ -686,10 +691,9 @@ no answer yet for icmp_seq=4`, newStats = mergeMeasurementStats( model.MeasurementStats{Sent: 0, Lost: 0, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1}, o) - assert.Equal(t, - model.MeasurementStats{Sent: 4, Rcv: 3, Lost: 1, Loss: 25, Last: 30, Min: 10, Avg: 20, Max: 30, Tsum: 60, Tsum2: 1400, Mdev: 8.16496580927726}, - newStats, - ) + assertMeasurementStats(t, &model.MeasurementStats{Sent: 4, Rcv: 3, Lost: 1, Loss: 25, Last: 30, Min: 10, + Avg: 20, Max: 30, Tsum: 60, Tsum2: 1400, Mdev: 8.1649}, + &newStats) o = parsePingRawOutput(&model.MeasurementResponse{ Result: model.ResultData{ RawOutput: `PING (142.250.65.174) 56(84) bytes of data. @@ -709,10 +713,8 @@ rtt min/avg/max/mdev = 10/20/30/0 ms`, newStats = mergeMeasurementStats( model.MeasurementStats{Sent: 0, Lost: 0, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1}, o) - assert.Equal(t, - model.MeasurementStats{Sent: 4, Rcv: 4, Lost: 0, Loss: 0, Last: 30, Min: 10, Avg: 20, Max: 30, Time: 1000, Tsum: 80, Tsum2: 2000, Mdev: 10}, - newStats, - ) + assertMeasurementStats(t, &model.MeasurementStats{Sent: 4, Rcv: 4, Lost: 0, Loss: 0, Last: 30, Min: 10, + Avg: 20, Max: 30, Time: 1000, Tsum: 80, Tsum2: 2000, Mdev: 10}, &newStats) o = parsePingRawOutput(&model.MeasurementResponse{ Result: model.ResultData{ RawOutput: `PING (142.250.65.174) 56(84) bytes of data. @@ -726,12 +728,23 @@ rtt min/avg/max/mdev = 10/20/30/0 ms`, }, }, 0) newStats = mergeMeasurementStats( - model.MeasurementStats{Sent: 5, Rcv: 4, Lost: 1, Loss: 20, Last: 30, Min: 10, Avg: 20, Max: 30, Time: 1000, Tsum: 80, Tsum2: 2000, Mdev: 10}, + model.MeasurementStats{Sent: 5, Rcv: 4, Lost: 1, Loss: 20, Last: 30, Min: 10, Avg: 20, Max: 30, + Time: 1000, Tsum: 80, Tsum2: 2000, Mdev: 10}, o) - assert.Equal(t, - model.MeasurementStats{Sent: 8, Rcv: 7, Lost: 1, Loss: 12.5, Last: 30, Min: 10, Avg: 20, Max: 30, Time: 2000, Tsum: 140, Tsum2: 3400, Mdev: 9.258200997725515}, - newStats, - ) + assertMeasurementStats(t, &model.MeasurementStats{ + Sent: 8, + Rcv: 7, + Lost: 1, + Loss: 12.5, + Last: 30, + Min: 10, + Avg: 20, + Max: 30, + Time: 2000, + Tsum: 140, + Tsum2: 3400, + Mdev: 9.2582, + }, &newStats) } func TestGetRowValuesNoPacketsRcv(t *testing.T) { @@ -786,30 +799,27 @@ rtt min/avg/max/mdev = 1.061/1.090/1.108/0.020 ms`, }, } res := parsePingRawOutput(m, -1) - assert.Equal(t, &ParsedPingOutput{ - Hostname: "cdn.jsdelivr.net", - Address: "142.250.65.174", - BytesOfData: "56(84)", - Timings: []model.PingTiming{ - {RTT: 1.06, TTL: 59}, - {RTT: 1.10, TTL: 59}, - {RTT: 1.11, TTL: 59}, - }, - Stats: &model.MeasurementStats{ - Sent: 3, - Rcv: 3, - Lost: 0, - Loss: 0, - Last: 1.11, - Min: 1.06, - Avg: 1.09, - Max: 1.11, - Tsum: 3.2700000000000005, - Tsum2: 3.565700000000001, - Mdev: 0.02160246899469168, - }, - Time: 1002, - }, res) + assert.Equal(t, "142.250.65.174", res.Address) + assert.Equal(t, "56(84)", res.BytesOfData) + assert.Nil(t, res.RawPacketLines) + assert.Equal(t, []model.PingTiming{ + {RTT: 1.06, TTL: 59}, + {RTT: 1.10, TTL: 59}, + {RTT: 1.11, TTL: 59}, + }, res.Timings) + assertMeasurementStats(t, &model.MeasurementStats{ + Sent: 3, + Rcv: 3, + Lost: 0, + Loss: 0, + Last: 1.11, + Min: 1.06, + Avg: 1.09, + Max: 1.11, + Tsum: 3.2700, + Tsum2: 3.5657, + Mdev: 0.0216, + }, res.Stats) } func TestParsePingRawOutputNoStats(t *testing.T) { @@ -825,28 +835,27 @@ no answer yet for icmp_seq=4`, }, } res := parsePingRawOutput(m, -1) - assert.Equal(t, &ParsedPingOutput{ - Address: "142.250.65.174", - BytesOfData: "56(84)", - Timings: []model.PingTiming{ - {RTT: 1.06, TTL: 59}, - {RTT: 1.10, TTL: 59}, - {RTT: 1.11, TTL: 59}, - }, - Stats: &model.MeasurementStats{ - Sent: 4, - Rcv: 3, - Lost: 1, - Loss: 25, - Last: 1.11, - Min: 1.06, - Avg: 1.09, - Max: 1.11, - Tsum: 3.2700000000000005, - Tsum2: 3.565700000000001, - Mdev: 0.02160246899469168, - }, - }, res) + assert.Equal(t, "142.250.65.174", res.Address) + assert.Equal(t, "56(84)", res.BytesOfData) + assert.Nil(t, res.RawPacketLines) + assert.Equal(t, []model.PingTiming{ + {RTT: 1.06, TTL: 59}, + {RTT: 1.10, TTL: 59}, + {RTT: 1.11, TTL: 59}, + }, res.Timings) + assertMeasurementStats(t, &model.MeasurementStats{ + Sent: 4, + Rcv: 3, + Lost: 1, + Loss: 25, + Last: 1.11, + Min: 1.06, + Avg: 1.09, + Max: 1.11, + Tsum: 3.2700, + Tsum2: 3.5657, + Mdev: 0.0216, + }, res.Stats) } func TestParsePingRawOutputNoStatsWithStartIncmpSeq(t *testing.T) { @@ -862,36 +871,34 @@ no answer yet for icmp_seq=4`, }, } res := parsePingRawOutput(m, 4) - assert.Equal(t, &ParsedPingOutput{ - Address: "142.250.65.174", - BytesOfData: "56(84)", - RawPacketLines: []string{ - "no answer yet for icmp_seq=5", - "64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=5 ttl=59 time=1.06 ms", - "no answer yet for icmp_seq=6", - "64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=6 ttl=59 time=1.10 ms", - "64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=7 ttl=59 time=1.11 ms", - "no answer yet for icmp_seq=8", - }, - Timings: []model.PingTiming{ - {RTT: 1.06, TTL: 59}, - {RTT: 1.10, TTL: 59}, - {RTT: 1.11, TTL: 59}, - }, - Stats: &model.MeasurementStats{ - Sent: 4, - Rcv: 3, - Lost: 1, - Loss: 25, - Last: 1.11, - Min: 1.06, - Avg: 1.09, - Max: 1.11, - Tsum: 3.2700000000000005, - Tsum2: 3.565700000000001, - Mdev: 0.02160246899469168, - }, - }, res) + assert.Equal(t, "142.250.65.174", res.Address) + assert.Equal(t, "56(84)", res.BytesOfData) + assert.Equal(t, []string{ + "no answer yet for icmp_seq=5", + "64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=5 ttl=59 time=1.06 ms", + "no answer yet for icmp_seq=6", + "64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=6 ttl=59 time=1.10 ms", + "64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=7 ttl=59 time=1.11 ms", + "no answer yet for icmp_seq=8", + }, res.RawPacketLines) + assert.Equal(t, []model.PingTiming{ + {RTT: 1.06, TTL: 59}, + {RTT: 1.10, TTL: 59}, + {RTT: 1.11, TTL: 59}, + }, res.Timings) + assertMeasurementStats(t, &model.MeasurementStats{ + Sent: 4, + Rcv: 3, + Lost: 1, + Loss: 25, + Last: 1.11, + Min: 1.06, + Avg: 1.09, + Max: 1.11, + Tsum: 3.27, + Tsum2: 3.5657, + Mdev: 0.0216, + }, res.Stats) } func TestComputeMdev(t *testing.T) { @@ -903,5 +910,20 @@ func TestComputeMdev(t *testing.T) { tsum2 := rtt1*rtt1 + rtt2*rtt2 + rtt3*rtt3 + rtt4*rtt4 avg := tsum / 4 mdev := computeMdev(tsum, tsum2, 4, avg) - assert.Equal(t, 10.0, mdev) + assert.InDelta(t, 10.0, mdev, 0.0001) +} + +func assertMeasurementStats(t *testing.T, expected *model.MeasurementStats, actual *model.MeasurementStats) { + assert.Equal(t, expected.Sent, actual.Sent) + assert.Equal(t, expected.Rcv, actual.Rcv) + assert.Equal(t, expected.Lost, actual.Lost) + assert.InDelta(t, expected.Loss, actual.Loss, 0.0001) + assert.Equal(t, expected.Last, actual.Last) + assert.Equal(t, expected.Min, actual.Min) + assert.InDelta(t, expected.Avg, actual.Avg, 0.0001) + assert.Equal(t, expected.Max, actual.Max) + assert.Equal(t, expected.Time, actual.Time) + assert.InDelta(t, expected.Tsum, actual.Tsum, 0.0001) + assert.InDelta(t, expected.Tsum2, actual.Tsum2, 0.0001) + assert.InDelta(t, expected.Mdev, actual.Mdev, 0.0001) } From 416ea8fa7ff184943b14b25de667fee03fb68f05 Mon Sep 17 00:00:00 2001 From: Radu Lucut Date: Tue, 6 Feb 2024 16:08:28 +0200 Subject: [PATCH 19/27] Refactor code structure to improve testability --- client/client.go | 126 ----- client/measurements_fetcher.go | 124 ----- client/measurements_fetcher_test.go | 103 ---- client/user_agent.go | 11 - client/user_agent_test.go | 13 - cmd/common.go | 10 +- cmd/common_test.go | 24 +- cmd/dns.go | 14 +- cmd/http.go | 16 +- cmd/http_test.go | 23 +- cmd/mtr.go | 14 +- cmd/ping.go | 18 +- cmd/root.go | 16 +- cmd/root_test.go | 5 +- cmd/traceroute.go | 14 +- globalping/globalping.go | 240 +++++++++ .../globalping_test.go | 237 ++++++--- model/get.go => globalping/models.go | 80 ++- mocks/gen_mocks.sh | 2 +- mocks/mock_globalping.go | 81 +++ mocks/mock_measurements_fetcher.go | 65 --- model/post.go | 53 -- model/root.go => view/context.go | 41 +- model/root_test.go => view/context_test.go | 4 +- view/default.go | 12 +- view/default_test.go | 330 ++++++------ view/infinite.go | 175 +++--- view/infinite_test.go | 497 +++++------------- view/json.go | 11 +- view/json_test.go | 26 +- view/latency.go | 43 +- view/latency_test.go | 332 +++++++----- view/summary.go | 55 ++ view/summary_test.go | 262 +++++++++ view/utils_test.go | 48 +- view/view.go | 78 +-- view/view_test.go | 16 +- 37 files changed, 1649 insertions(+), 1570 deletions(-) delete mode 100644 client/client.go delete mode 100644 client/measurements_fetcher.go delete mode 100644 client/measurements_fetcher_test.go delete mode 100644 client/user_agent.go delete mode 100644 client/user_agent_test.go create mode 100644 globalping/globalping.go rename client/client_test.go => globalping/globalping_test.go (80%) rename model/get.go => globalping/models.go (53%) create mode 100644 mocks/mock_globalping.go delete mode 100644 mocks/mock_measurements_fetcher.go delete mode 100644 model/post.go rename model/root.go => view/context.go (70%) rename model/root_test.go => view/context_test.go (95%) create mode 100644 view/summary.go create mode 100644 view/summary_test.go diff --git a/client/client.go b/client/client.go deleted file mode 100644 index 0a220cd..0000000 --- a/client/client.go +++ /dev/null @@ -1,126 +0,0 @@ -package client - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - - "github.com/andybalholm/brotli" - "github.com/jsdelivr/globalping-cli/model" -) - -var ApiUrl = "https://api.globalping.io/v1/measurements" -var PacketsMax = 16 - -// Post measurement to Globalping API - boolean indicates whether to print CLI help on error -func PostAPI(measurement model.PostMeasurement) (model.PostResponse, bool, error) { - // Format post data - postData, err := json.Marshal(measurement) - if err != nil { - return model.PostResponse{}, false, errors.New("failed to marshal post data - please report this bug") - } - - // Create a new request - req, err := http.NewRequest("POST", ApiUrl, bytes.NewBuffer(postData)) - if err != nil { - return model.PostResponse{}, false, errors.New("failed to create request - please report this bug") - } - req.Header.Set("User-Agent", userAgent()) - req.Header.Set("Accept-Encoding", "br") - req.Header.Set("Content-Type", "application/json") - - // Make the request - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return model.PostResponse{}, false, errors.New("request failed - please try again later") - } - defer resp.Body.Close() - - // If an error is returned - if resp.StatusCode != http.StatusAccepted { - // Decode the response body as JSON - var data model.PostError - - err = json.NewDecoder(resp.Body).Decode(&data) - if err != nil { - return model.PostResponse{}, false, errors.New("invalid error format returned - please report this bug") - } - - // 422 error - if data.Error.Type == "no_probes_found" { - return model.PostResponse{}, true, errors.New("no suitable probes found - please choose a different location") - } - - // 400 error - if data.Error.Type == "validation_error" { - resErr := "" - for _, v := range data.Error.Params { - resErr += fmt.Sprintf(" - %s\n", v) - } - return model.PostResponse{}, true, fmt.Errorf("invalid parameters\n%sPlease check the help for more information", resErr) - } - - // 500 error - if data.Error.Type == "api_error" { - return model.PostResponse{}, false, errors.New("internal server error - please try again later") - } - - // If the error type is unknown - return model.PostResponse{}, false, fmt.Errorf("unknown error response: %s", data.Error.Type) - } - - // Read the response body - - var bodyReader io.Reader = resp.Body - if resp.Header.Get("Content-Encoding") == "br" { - bodyReader = brotli.NewReader(bodyReader) - } - - var data model.PostResponse - err = json.NewDecoder(bodyReader).Decode(&data) - if err != nil { - return model.PostResponse{}, false, fmt.Errorf("invalid post measurement format returned - please report this bug: %s", err) - } - - return data, false, nil -} - -func DecodeDNSTimings(timings json.RawMessage) (*model.DNSTimings, error) { - t := &model.DNSTimings{} - err := json.Unmarshal(timings, t) - if err != nil { - return nil, errors.New("invalid timings format returned (other)") - } - return t, nil -} - -func DecodeHTTPTimings(timings json.RawMessage) (*model.HTTPTimings, error) { - t := &model.HTTPTimings{} - err := json.Unmarshal(timings, t) - if err != nil { - return nil, errors.New("invalid timings format returned (other)") - } - return t, nil -} - -func DecodePingTimings(timings json.RawMessage) ([]model.PingTiming, error) { - t := []model.PingTiming{} - err := json.Unmarshal(timings, &t) - if err != nil { - return nil, errors.New("invalid timings format returned (ping)") - } - return t, nil -} - -func DecodePingStats(stats json.RawMessage) (*model.PingStats, error) { - s := &model.PingStats{} - err := json.Unmarshal(stats, s) - if err != nil { - return nil, errors.New("invalid stats format returned") - } - return s, nil -} diff --git a/client/measurements_fetcher.go b/client/measurements_fetcher.go deleted file mode 100644 index 7c123e4..0000000 --- a/client/measurements_fetcher.go +++ /dev/null @@ -1,124 +0,0 @@ -package client - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - - "github.com/andybalholm/brotli" - "github.com/jsdelivr/globalping-cli/model" -) - -type MeasurementsFetcher interface { - GetMeasurement(id string) (*model.GetMeasurement, error) - GetRawMeasurement(id string) ([]byte, error) -} - -type measurementsFetcher struct { - // The api url endpoint - apiUrl string - - // http client - cl *http.Client - - // caches Etags by measurement id - etags map[string]string - - // caches Measurements by ETag - measurements map[string][]byte -} - -func NewMeasurementsFetcher(apiUrl string) *measurementsFetcher { - return &measurementsFetcher{ - apiUrl: apiUrl, - cl: &http.Client{}, - etags: map[string]string{}, - measurements: map[string][]byte{}, - } -} - -// GetRawMeasurement returns API response as a GetMeasurement object -func (f *measurementsFetcher) GetMeasurement(id string) (*model.GetMeasurement, error) { - respBytes, err := f.GetRawMeasurement(id) - if err != nil { - return nil, err - } - - var m model.GetMeasurement - err = json.Unmarshal(respBytes, &m) - if err != nil { - return nil, fmt.Errorf("invalid get measurement format returned: %v %s", err, string(respBytes)) - } - - return &m, nil -} - -// GetRawMeasurement returns the API response's raw json response -func (f *measurementsFetcher) GetRawMeasurement(id string) ([]byte, error) { - // Create a new request - req, err := http.NewRequest("GET", f.apiUrl+"/"+id, nil) - if err != nil { - return nil, errors.New("err: failed to create request") - } - - req.Header.Set("User-Agent", userAgent()) - req.Header.Set("Accept-Encoding", "br") - - etag := f.etags[id] - if etag != "" { - req.Header.Set("If-None-Match", etag) - } - - // Make the request - resp, err := f.cl.Do(req) - if err != nil { - return nil, errors.New("err: request failed") - } - defer resp.Body.Close() - - // 404 not found - if resp.StatusCode == http.StatusNotFound { - return nil, errors.New("err: measurement not found") - } - - // 500 error - if resp.StatusCode == http.StatusInternalServerError { - return nil, errors.New("err: internal server error - please try again later") - } - - // 304 not modified - if resp.StatusCode == http.StatusNotModified { - // get response bytes from cache - respBytes := f.measurements[etag] - if respBytes == nil { - return nil, errors.New("err: response not found in etags cache") - } - - return respBytes, nil - } - - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("err: response code %d", resp.StatusCode) - } - - var bodyReader io.Reader = resp.Body - - if resp.Header.Get("Content-Encoding") == "br" { - bodyReader = brotli.NewReader(bodyReader) - } - - // Read the response body - respBytes, err := io.ReadAll(bodyReader) - if err != nil { - return nil, errors.New("err: failed to read response body") - } - - // save etag and response to cache - etag = resp.Header.Get("ETag") - f.etags[id] = etag - f.measurements[etag] = respBytes - - return respBytes, nil -} diff --git a/client/measurements_fetcher_test.go b/client/measurements_fetcher_test.go deleted file mode 100644 index f61785c..0000000 --- a/client/measurements_fetcher_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package client - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/andybalholm/brotli" - "github.com/jsdelivr/globalping-cli/model" - "github.com/stretchr/testify/assert" -) - -func TestFetchWithEtag(t *testing.T) { - id1 := "123abc" - id2 := "567xyz" - - cacheMissCount := 0 - cacheHitCount := 0 - - s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - parts := strings.Split(r.URL.Path, "/") - id := parts[len(parts)-1] - - etag := func(id string) string { - return "etag-" + id - } - - if r.Header.Get("If-None-Match") == etag(id) { - // cache hit - cacheHitCount++ - w.Header().Set("ETag", etag(id)) - w.WriteHeader(http.StatusNotModified) - - return - } - - // cache miss, return full response - cacheMissCount++ - m := &model.GetMeasurement{ - ID: id, - } - - w.Header().Set("ETag", etag(id)) - - err := json.NewEncoder(w).Encode(m) - assert.NoError(t, err) - })) - - f := NewMeasurementsFetcher(s.URL) - - // first request for id1 - m, err := f.GetMeasurement(id1) - assert.NoError(t, err) - - assert.Equal(t, id1, m.ID) - - // first request for id1 - m, err = f.GetMeasurement(id2) - assert.NoError(t, err) - - assert.Equal(t, id2, m.ID) - - // second request for id1 - m, err = f.GetMeasurement(id2) - assert.NoError(t, err) - - assert.Equal(t, id2, m.ID) - - assert.Equal(t, 1, cacheHitCount) - assert.Equal(t, 2, cacheMissCount) -} - -func TestFetchWithBrotli(t *testing.T) { - id := "123abc" - - s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - parts := strings.Split(r.URL.Path, "/") - id := parts[len(parts)-1] - - assert.Equal(t, "br", r.Header.Get("Accept-Encoding")) - - m := &model.GetMeasurement{ - ID: id, - } - - w.Header().Set("Content-Encoding", "br") - - rW := brotli.NewWriter(w) - defer rW.Close() - - err := json.NewEncoder(rW).Encode(m) - assert.NoError(t, err) - })) - - f := NewMeasurementsFetcher(s.URL) - - m, err := f.GetMeasurement(id) - assert.NoError(t, err) - - assert.Equal(t, id, m.ID) -} diff --git a/client/user_agent.go b/client/user_agent.go deleted file mode 100644 index 825f6c7..0000000 --- a/client/user_agent.go +++ /dev/null @@ -1,11 +0,0 @@ -package client - -import ( - "fmt" - - "github.com/jsdelivr/globalping-cli/version" -) - -func userAgent() string { - return fmt.Sprintf("globalping-cli/v%s (https://github.com/jsdelivr/globalping-cli)", version.Version) -} diff --git a/client/user_agent_test.go b/client/user_agent_test.go deleted file mode 100644 index 96b5a18..0000000 --- a/client/user_agent_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package client - -import ( - "testing" - - "github.com/jsdelivr/globalping-cli/version" - "github.com/stretchr/testify/assert" -) - -func TestUserAgent(t *testing.T) { - version.Version = "x.y.z" - assert.Equal(t, "globalping-cli/vx.y.z (https://github.com/jsdelivr/globalping-cli)", userAgent()) -} diff --git a/cmd/common.go b/cmd/common.go index 4730471..551f38c 100644 --- a/cmd/common.go +++ b/cmd/common.go @@ -13,7 +13,7 @@ import ( "strings" "github.com/icza/backscanner" - "github.com/jsdelivr/globalping-cli/model" + "github.com/jsdelivr/globalping-cli/globalping" "github.com/shirou/gopsutil/process" ) @@ -29,7 +29,7 @@ func inProgressUpdates(ci bool) bool { return !(ci) } -func createLocations(from string) ([]model.Locations, bool, error) { +func createLocations(from string) ([]globalping.Locations, bool, error) { fromArr := strings.Split(from, ",") if len(fromArr) == 1 { mId, err := mapToMeasurementID(fromArr[0]) @@ -42,15 +42,15 @@ func createLocations(from string) ([]model.Locations, bool, error) { } else { isPreviousMeasurementId = true } - return []model.Locations{ + return []globalping.Locations{ { Magic: mId, }, }, isPreviousMeasurementId, nil } - locations := make([]model.Locations, len(fromArr)) + locations := make([]globalping.Locations, len(fromArr)) for i, v := range fromArr { - locations[i] = model.Locations{ + locations[i] = globalping.Locations{ Magic: strings.TrimSpace(v), } } diff --git a/cmd/common_test.go b/cmd/common_test.go index c41d2b5..3b9cff9 100644 --- a/cmd/common_test.go +++ b/cmd/common_test.go @@ -6,7 +6,7 @@ import ( "os" "testing" - "github.com/jsdelivr/globalping-cli/model" + "github.com/jsdelivr/globalping-cli/globalping" "github.com/stretchr/testify/assert" ) @@ -53,14 +53,14 @@ func TestCreateLocations(t *testing.T) { func testLocationsSingle(t *testing.T) { locations, isPreviousMeasurementId, err := createLocations("New York") - assert.Equal(t, []model.Locations{{Magic: "New York"}}, locations) + assert.Equal(t, []globalping.Locations{{Magic: "New York"}}, locations) assert.False(t, isPreviousMeasurementId) assert.Nil(t, err) } func testLocationsMultiple(t *testing.T) { locations, isPreviousMeasurementId, err := createLocations("New York,Los Angeles") - assert.Equal(t, []model.Locations{{Magic: "New York"}, {Magic: "Los Angeles"}}, locations) + assert.Equal(t, []globalping.Locations{{Magic: "New York"}, {Magic: "Los Angeles"}}, locations) assert.False(t, isPreviousMeasurementId) assert.Nil(t, err) } @@ -68,7 +68,7 @@ func testLocationsMultiple(t *testing.T) { // Check if multiple locations with whitespace are parsed correctly func testLocationsMultipleWhitespace(t *testing.T) { locations, isPreviousMeasurementId, err := createLocations("New York, Los Angeles ") - assert.Equal(t, []model.Locations{{Magic: "New York"}, {Magic: "Los Angeles"}}, locations) + assert.Equal(t, []globalping.Locations{{Magic: "New York"}, {Magic: "Los Angeles"}}, locations) assert.False(t, isPreviousMeasurementId) assert.Nil(t, err) } @@ -76,17 +76,17 @@ func testLocationsMultipleWhitespace(t *testing.T) { func testCreateLocationsSessionLastMeasurement(t *testing.T) { _ = saveMeasurementID(measurementID1) locations, isPreviousMeasurementId, err := createLocations("@1") - assert.Equal(t, []model.Locations{{Magic: measurementID1}}, locations) + assert.Equal(t, []globalping.Locations{{Magic: measurementID1}}, locations) assert.True(t, isPreviousMeasurementId) assert.Nil(t, err) locations, isPreviousMeasurementId, err = createLocations("last") - assert.Equal(t, []model.Locations{{Magic: measurementID1}}, locations) + assert.Equal(t, []globalping.Locations{{Magic: measurementID1}}, locations) assert.True(t, isPreviousMeasurementId) assert.Nil(t, err) locations, isPreviousMeasurementId, err = createLocations("previous") - assert.Equal(t, []model.Locations{{Magic: measurementID1}}, locations) + assert.Equal(t, []globalping.Locations{{Magic: measurementID1}}, locations) assert.True(t, isPreviousMeasurementId) assert.Nil(t, err) } @@ -95,12 +95,12 @@ func testCreateLocationsSessionFirstMeasurement(t *testing.T) { _ = saveMeasurementID(measurementID1) _ = saveMeasurementID(measurementID2) locations, isPreviousMeasurementId, err := createLocations("@-1") - assert.Equal(t, []model.Locations{{Magic: measurementID2}}, locations) + assert.Equal(t, []globalping.Locations{{Magic: measurementID2}}, locations) assert.True(t, isPreviousMeasurementId) assert.Nil(t, err) locations, isPreviousMeasurementId, err = createLocations("last") - assert.Equal(t, []model.Locations{{Magic: measurementID2}}, locations) + assert.Equal(t, []globalping.Locations{{Magic: measurementID2}}, locations) assert.True(t, isPreviousMeasurementId) assert.Nil(t, err) } @@ -111,17 +111,17 @@ func testCreateLocationsSessionMeasurementAtIndex(t *testing.T) { _ = saveMeasurementID(measurementID3) _ = saveMeasurementID(measurementID4) locations, isPreviousMeasurementId, err := createLocations("@2") - assert.Equal(t, []model.Locations{{Magic: measurementID2}}, locations) + assert.Equal(t, []globalping.Locations{{Magic: measurementID2}}, locations) assert.True(t, isPreviousMeasurementId) assert.Nil(t, err) locations, isPreviousMeasurementId, err = createLocations("@-2") - assert.Equal(t, []model.Locations{{Magic: measurementID3}}, locations) + assert.Equal(t, []globalping.Locations{{Magic: measurementID3}}, locations) assert.True(t, isPreviousMeasurementId) assert.Nil(t, err) locations, isPreviousMeasurementId, err = createLocations("@-4") - assert.Equal(t, []model.Locations{{Magic: measurementID1}}, locations) + assert.Equal(t, []globalping.Locations{{Magic: measurementID1}}, locations) assert.True(t, isPreviousMeasurementId) assert.Nil(t, err) } diff --git a/cmd/dns.go b/cmd/dns.go index f7dcf27..1b28ebc 100644 --- a/cmd/dns.go +++ b/cmd/dns.go @@ -3,9 +3,7 @@ package cmd import ( "fmt" - "github.com/jsdelivr/globalping-cli/client" - "github.com/jsdelivr/globalping-cli/model" - "github.com/jsdelivr/globalping-cli/view" + "github.com/jsdelivr/globalping-cli/globalping" "github.com/spf13/cobra" ) @@ -61,16 +59,16 @@ Using the dig format @resolver. For example: } // Make post struct - opts = model.PostMeasurement{ + opts = globalping.MeasurementCreate{ Type: "dns", Target: ctx.Target, Limit: ctx.Limit, InProgressUpdates: inProgressUpdates(ctx.CI), - Options: &model.MeasurementOptions{ + Options: &globalping.MeasurementOptions{ Protocol: protocol, Port: port, Resolver: overrideOpt(ctx.Resolver, resolver), - Query: &model.QueryOptions{ + Query: &globalping.QueryOptions{ Type: queryType, }, Trace: trace, @@ -83,7 +81,7 @@ Using the dig format @resolver. For example: return err } - res, showHelp, err := client.PostAPI(opts) + res, showHelp, err := gp.CreateMeasurement(&opts) if err != nil { if !showHelp { cmd.SilenceUsage = true @@ -99,7 +97,7 @@ Using the dig format @resolver. For example: } } - view.OutputResults(res.ID, ctx, opts) + viewer.Output(res.ID, &opts) return nil }, } diff --git a/cmd/http.go b/cmd/http.go index 55b6f0b..f1c11e1 100644 --- a/cmd/http.go +++ b/cmd/http.go @@ -7,9 +7,7 @@ import ( "strconv" "strings" - "github.com/jsdelivr/globalping-cli/client" - "github.com/jsdelivr/globalping-cli/model" - "github.com/jsdelivr/globalping-cli/view" + "github.com/jsdelivr/globalping-cli/globalping" "github.com/pkg/errors" "github.com/spf13/cobra" ) @@ -153,7 +151,7 @@ func httpCmdRun(cmd *cobra.Command, args []string) error { return err } - res, showHelp, err := client.PostAPI(*opts) + res, showHelp, err := gp.CreateMeasurement(opts) if err != nil { if !showHelp { cmd.SilenceUsage = true @@ -169,15 +167,15 @@ func httpCmdRun(cmd *cobra.Command, args []string) error { } } - view.OutputResults(res.ID, ctx, *opts) + viewer.Output(res.ID, opts) return nil } const PostMeasurementTypeHttp = "http" // buildHttpMeasurementRequest builds the measurement request for the http type -func buildHttpMeasurementRequest() (*model.PostMeasurement, error) { - opts := &model.PostMeasurement{ +func buildHttpMeasurementRequest() (*globalping.MeasurementCreate, error) { + opts := &globalping.MeasurementCreate{ Type: PostMeasurementTypeHttp, Limit: ctx.Limit, InProgressUpdates: inProgressUpdates(ctx.CI), @@ -196,10 +194,10 @@ func buildHttpMeasurementRequest() (*model.PostMeasurement, error) { method = "GET" } opts.Target = urlData.Host - opts.Options = &model.MeasurementOptions{ + opts.Options = &globalping.MeasurementOptions{ Protocol: overrideOpt(urlData.Protocol, httpCmdOpts.Protocol), Port: overrideOptInt(urlData.Port, httpCmdOpts.Port), - Request: &model.RequestOptions{ + Request: &globalping.RequestOptions{ Path: overrideOpt(urlData.Path, httpCmdOpts.Path), Query: overrideOpt(urlData.Query, httpCmdOpts.Query), Host: overrideOpt(urlData.Host, httpCmdOpts.Host), diff --git a/cmd/http_test.go b/cmd/http_test.go index 550e248..0904862 100644 --- a/cmd/http_test.go +++ b/cmd/http_test.go @@ -3,7 +3,8 @@ package cmd import ( "testing" - "github.com/jsdelivr/globalping-cli/model" + "github.com/jsdelivr/globalping-cli/globalping" + "github.com/jsdelivr/globalping-cli/view" "github.com/stretchr/testify/assert" ) @@ -79,7 +80,7 @@ func TestParseHttpHeaders_Invalid(t *testing.T) { } func TestBuildHttpMeasurementRequest_FULL(t *testing.T) { - ctx = model.Context{ + ctx = &view.Context{ Target: "https://example.com/my/path?x=123&yz=abc", From: "london", Full: true, @@ -92,14 +93,14 @@ func TestBuildHttpMeasurementRequest_FULL(t *testing.T) { m, err := buildHttpMeasurementRequest() assert.NoError(t, err) - expectedM := &model.PostMeasurement{ + expectedM := &globalping.MeasurementCreate{ Limit: 0, Type: "http", Target: "example.com", InProgressUpdates: true, - Options: &model.MeasurementOptions{ + Options: &globalping.MeasurementOptions{ Protocol: "https", - Request: &model.RequestOptions{ + Request: &globalping.RequestOptions{ Headers: map[string]string{}, Path: "/my/path", Host: "example.com", @@ -113,11 +114,11 @@ func TestBuildHttpMeasurementRequest_FULL(t *testing.T) { // restore httpCmdOpts = &HttpCmdOpts{} - ctx = model.Context{} + ctx = &view.Context{} } func TestBuildHttpMeasurementRequest_HEAD(t *testing.T) { - ctx = model.Context{ + ctx = &view.Context{ Target: "https://example.com/my/path?x=123&yz=abc", From: "london", } @@ -129,14 +130,14 @@ func TestBuildHttpMeasurementRequest_HEAD(t *testing.T) { m, err := buildHttpMeasurementRequest() assert.NoError(t, err) - expectedM := &model.PostMeasurement{ + expectedM := &globalping.MeasurementCreate{ Limit: 0, Type: "http", Target: "example.com", InProgressUpdates: true, - Options: &model.MeasurementOptions{ + Options: &globalping.MeasurementOptions{ Protocol: "https", - Request: &model.RequestOptions{ + Request: &globalping.RequestOptions{ Headers: map[string]string{}, Path: "/my/path", Host: "example.com", @@ -150,5 +151,5 @@ func TestBuildHttpMeasurementRequest_HEAD(t *testing.T) { // restore httpCmdOpts = &HttpCmdOpts{} - ctx = model.Context{} + ctx = &view.Context{} } diff --git a/cmd/mtr.go b/cmd/mtr.go index a925823..532ccdf 100644 --- a/cmd/mtr.go +++ b/cmd/mtr.go @@ -3,9 +3,7 @@ package cmd import ( "fmt" - "github.com/jsdelivr/globalping-cli/client" - "github.com/jsdelivr/globalping-cli/model" - "github.com/jsdelivr/globalping-cli/view" + "github.com/jsdelivr/globalping-cli/globalping" "github.com/spf13/cobra" ) @@ -47,17 +45,17 @@ Examples: return err } - if ctx.Latency { + if ctx.ToLatency { return fmt.Errorf("the latency flag is not supported by the mtr command") } // Make post struct - opts = model.PostMeasurement{ + opts = globalping.MeasurementCreate{ Type: "mtr", Target: ctx.Target, Limit: ctx.Limit, InProgressUpdates: inProgressUpdates(ctx.CI), - Options: &model.MeasurementOptions{ + Options: &globalping.MeasurementOptions{ Protocol: protocol, Port: port, Packets: ctx.Packets, @@ -70,7 +68,7 @@ Examples: return err } - res, showHelp, err := client.PostAPI(opts) + res, showHelp, err := gp.CreateMeasurement(&opts) if err != nil { if !showHelp { cmd.SilenceUsage = true @@ -86,7 +84,7 @@ Examples: } } - view.OutputResults(res.ID, ctx, opts) + viewer.Output(res.ID, &opts) return nil }, } diff --git a/cmd/ping.go b/cmd/ping.go index d64a72d..658ff17 100644 --- a/cmd/ping.go +++ b/cmd/ping.go @@ -6,9 +6,7 @@ import ( "os/signal" "syscall" - "github.com/jsdelivr/globalping-cli/client" - "github.com/jsdelivr/globalping-cli/model" - "github.com/jsdelivr/globalping-cli/view" + "github.com/jsdelivr/globalping-cli/globalping" "github.com/spf13/cobra" ) @@ -82,18 +80,18 @@ func infinitePing(cmd *cobra.Command) error { <-sig if err == nil { - view.OutputSummary(&ctx) + viewer.OutputSummary() } return err } func ping(cmd *cobra.Command) (string, error) { - opts = model.PostMeasurement{ + opts = globalping.MeasurementCreate{ Type: "ping", Target: ctx.Target, Limit: ctx.Limit, InProgressUpdates: inProgressUpdates(ctx.CI), - Options: &model.MeasurementOptions{ + Options: &globalping.MeasurementOptions{ Packets: ctx.Packets, }, } @@ -106,10 +104,10 @@ func ping(cmd *cobra.Command) (string, error) { return "", err } } else { - opts.Locations = []model.Locations{{Magic: ctx.From}} + opts.Locations = []globalping.Locations{{Magic: ctx.From}} } - res, showHelp, err := client.PostAPI(opts) + res, showHelp, err := gp.CreateMeasurement(&opts) if err != nil { if !showHelp { cmd.SilenceUsage = true @@ -128,9 +126,9 @@ func ping(cmd *cobra.Command) (string, error) { } if ctx.Infinite { - err = view.OutputInfinite(res.ID, &ctx) + err = viewer.OutputInfinite(res.ID) } else { - view.OutputResults(res.ID, ctx, opts) + viewer.Output(res.ID, &opts) } return res.ID, err } diff --git a/cmd/root.go b/cmd/root.go index 750ffc1..12ee238 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,10 +2,10 @@ package cmd import ( "os" - "time" + "github.com/jsdelivr/globalping-cli/globalping" "github.com/jsdelivr/globalping-cli/lib" - "github.com/jsdelivr/globalping-cli/model" + "github.com/jsdelivr/globalping-cli/view" "github.com/pkg/errors" "github.com/spf13/cobra" ) @@ -23,11 +23,13 @@ var ( httpCmdOpts *HttpCmdOpts - opts = model.PostMeasurement{} - ctx = model.Context{ - APIMinInterval: 500 * time.Millisecond, + opts = globalping.MeasurementCreate{} + ctx = &view.Context{ + APIMinInterval: globalping.API_MIN_INTERVAL, MaxHistory: 10, } + gp = globalping.NewClient(globalping.API_URL) + viewer = view.NewViewer(ctx, gp) ) // rootCmd represents the base command when called without any subcommands @@ -54,9 +56,9 @@ func init() { For example, the partial or full name of a continent, region (e.g eastern europe), country, US state, city or network Or use [@1 | first, @2 ... @-2, @-1 | last | previous] to run with the probes from previous measurements.`) rootCmd.PersistentFlags().IntVarP(&ctx.Limit, "limit", "L", 1, "Limit the number of probes to use") - rootCmd.PersistentFlags().BoolVarP(&ctx.JsonOutput, "json", "J", false, "Output results in JSON format (default false)") + rootCmd.PersistentFlags().BoolVarP(&ctx.ToJSON, "json", "J", false, "Output results in JSON format (default false)") rootCmd.PersistentFlags().BoolVarP(&ctx.CI, "ci", "C", false, "Disable realtime terminal updates and color suitable for CI and scripting (default false)") - rootCmd.PersistentFlags().BoolVar(&ctx.Latency, "latency", false, "Output only the stats of a measurement (default false). Only applies to the dns, http and ping commands") + rootCmd.PersistentFlags().BoolVar(&ctx.ToLatency, "latency", false, "Output only the stats of a measurement (default false). Only applies to the dns, http and ping commands") rootCmd.PersistentFlags().BoolVar(&ctx.Share, "share", false, "Prints a link at the end the results, allowing to vizualize the results online (default false)") } diff --git a/cmd/root_test.go b/cmd/root_test.go index d6135e5..f19757c 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -3,8 +3,7 @@ package cmd import ( "testing" - "github.com/jsdelivr/globalping-cli/model" - + "github.com/jsdelivr/globalping-cli/view" "github.com/stretchr/testify/assert" ) @@ -17,7 +16,7 @@ func TestCreateContext(t *testing.T) { "ci_env": testContextCIEnv, } { t.Run(scenario, func(t *testing.T) { - ctx = model.Context{} + ctx = &view.Context{} fn(t) }) } diff --git a/cmd/traceroute.go b/cmd/traceroute.go index 823695b..32770c6 100644 --- a/cmd/traceroute.go +++ b/cmd/traceroute.go @@ -3,9 +3,7 @@ package cmd import ( "fmt" - "github.com/jsdelivr/globalping-cli/client" - "github.com/jsdelivr/globalping-cli/model" - "github.com/jsdelivr/globalping-cli/view" + "github.com/jsdelivr/globalping-cli/globalping" "github.com/spf13/cobra" ) @@ -50,17 +48,17 @@ Examples: return err } - if ctx.Latency { + if ctx.ToLatency { return fmt.Errorf("the latency flag is not supported by the traceroute command") } // Make post struct - opts = model.PostMeasurement{ + opts = globalping.MeasurementCreate{ Type: "traceroute", Target: ctx.Target, Limit: ctx.Limit, InProgressUpdates: inProgressUpdates(ctx.CI), - Options: &model.MeasurementOptions{ + Options: &globalping.MeasurementOptions{ Protocol: protocol, Port: port, }, @@ -72,7 +70,7 @@ Examples: return err } - res, showHelp, err := client.PostAPI(opts) + res, showHelp, err := gp.CreateMeasurement(&opts) if err != nil { if !showHelp { cmd.SilenceUsage = true @@ -88,7 +86,7 @@ Examples: } } - view.OutputResults(res.ID, ctx, opts) + viewer.Output(res.ID, &opts) return nil }, } diff --git a/globalping/globalping.go b/globalping/globalping.go new file mode 100644 index 0000000..f318456 --- /dev/null +++ b/globalping/globalping.go @@ -0,0 +1,240 @@ +package globalping + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/andybalholm/brotli" + "github.com/jsdelivr/globalping-cli/version" +) + +var ( + API_URL = "https://api.globalping.io/v1/measurements" + API_MIN_INTERVAL = 500 * time.Millisecond +) + +type Client interface { + CreateMeasurement(measurement *MeasurementCreate) (*MeasurementCreateResponse, bool, error) + GetMeasurement(id string) (*Measurement, error) + GetRawMeasurement(id string) ([]byte, error) +} + +type client struct { + http *http.Client + apiUrl string // The api url endpoint + PacketsMax int // Maximum number of packets to send + + etags map[string]string // caches Etags by measurement id + measurements map[string][]byte // caches Measurements by ETag +} + +func NewClient(url string) Client { + return &client{ + http: &http.Client{ + Timeout: 30 * time.Second, + }, + apiUrl: url, + PacketsMax: 16, + etags: map[string]string{}, + measurements: map[string][]byte{}, + } +} + +// boolean indicates whether to print CLI help on error +func (c *client) CreateMeasurement(measurement *MeasurementCreate) (*MeasurementCreateResponse, bool, error) { + postData, err := json.Marshal(measurement) + if err != nil { + return nil, false, errors.New("failed to marshal post data - please report this bug") + } + + // Create a new request + req, err := http.NewRequest("POST", c.apiUrl, bytes.NewBuffer(postData)) + if err != nil { + return nil, false, errors.New("failed to create request - please report this bug") + } + req.Header.Set("User-Agent", userAgent()) + req.Header.Set("Accept-Encoding", "br") + req.Header.Set("Content-Type", "application/json") + + // Make the request + resp, err := c.http.Do(req) + if err != nil { + return nil, false, errors.New("request failed - please try again later") + } + defer resp.Body.Close() + + // If an error is returned + if resp.StatusCode != http.StatusAccepted { + // Decode the response body as JSON + var data MeasurementCreateError + + err = json.NewDecoder(resp.Body).Decode(&data) + if err != nil { + return nil, false, errors.New("invalid error format returned - please report this bug") + } + + // 422 error + if data.Error.Type == "no_probes_found" { + return nil, true, errors.New("no suitable probes found - please choose a different location") + } + + // 400 error + if data.Error.Type == "validation_error" { + resErr := "" + for _, v := range data.Error.Params { + resErr += fmt.Sprintf(" - %s\n", v) + } + return nil, true, fmt.Errorf("invalid parameters\n%sPlease check the help for more information", resErr) + } + + // 500 error + if data.Error.Type == "api_error" { + return nil, false, errors.New("internal server error - please try again later") + } + + // If the error type is unknown + return nil, false, fmt.Errorf("unknown error response: %s", data.Error.Type) + } + + // Read the response body + + var bodyReader io.Reader = resp.Body + if resp.Header.Get("Content-Encoding") == "br" { + bodyReader = brotli.NewReader(bodyReader) + } + + res := &MeasurementCreateResponse{} + err = json.NewDecoder(bodyReader).Decode(res) + if err != nil { + return nil, false, fmt.Errorf("invalid post measurement format returned - please report this bug: %s", err) + } + + return res, false, nil +} + +// GetRawMeasurement returns API response as a GetMeasurement object +func (c *client) GetMeasurement(id string) (*Measurement, error) { + respBytes, err := c.GetRawMeasurement(id) + if err != nil { + return nil, err + } + m := &Measurement{} + err = json.Unmarshal(respBytes, m) + if err != nil { + return nil, fmt.Errorf("invalid get measurement format returned: %v %s", err, string(respBytes)) + } + return m, nil +} + +// GetRawMeasurement returns the API response's raw json response +func (c *client) GetRawMeasurement(id string) ([]byte, error) { + // Create a new request + req, err := http.NewRequest("GET", c.apiUrl+"/"+id, nil) + if err != nil { + return nil, errors.New("err: failed to create request") + } + + req.Header.Set("User-Agent", userAgent()) + req.Header.Set("Accept-Encoding", "br") + + etag := c.etags[id] + if etag != "" { + req.Header.Set("If-None-Match", etag) + } + + // Make the request + resp, err := c.http.Do(req) + if err != nil { + return nil, errors.New("err: request failed") + } + defer resp.Body.Close() + + // 404 not found + if resp.StatusCode == http.StatusNotFound { + return nil, errors.New("err: measurement not found") + } + + // 500 error + if resp.StatusCode == http.StatusInternalServerError { + return nil, errors.New("err: internal server error - please try again later") + } + + // 304 not modified + if resp.StatusCode == http.StatusNotModified { + // get response bytes from cache + respBytes := c.measurements[etag] + if respBytes == nil { + return nil, errors.New("err: response not found in etags cache") + } + + return respBytes, nil + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("err: response code %d", resp.StatusCode) + } + + var bodyReader io.Reader = resp.Body + + if resp.Header.Get("Content-Encoding") == "br" { + bodyReader = brotli.NewReader(bodyReader) + } + + // Read the response body + respBytes, err := io.ReadAll(bodyReader) + if err != nil { + return nil, errors.New("err: failed to read response body") + } + + // save etag and response to cache + etag = resp.Header.Get("ETag") + c.etags[id] = etag + c.measurements[etag] = respBytes + + return respBytes, nil +} + +func DecodeDNSTimings(timings json.RawMessage) (*DNSTimings, error) { + t := &DNSTimings{} + err := json.Unmarshal(timings, t) + if err != nil { + return nil, errors.New("invalid timings format returned (other)") + } + return t, nil +} + +func DecodeHTTPTimings(timings json.RawMessage) (*HTTPTimings, error) { + t := &HTTPTimings{} + err := json.Unmarshal(timings, t) + if err != nil { + return nil, errors.New("invalid timings format returned (other)") + } + return t, nil +} + +func DecodePingTimings(timings json.RawMessage) ([]PingTiming, error) { + t := []PingTiming{} + err := json.Unmarshal(timings, &t) + if err != nil { + return nil, errors.New("invalid timings format returned (ping)") + } + return t, nil +} + +func DecodePingStats(stats json.RawMessage) (*PingStats, error) { + s := &PingStats{} + err := json.Unmarshal(stats, s) + if err != nil { + return nil, errors.New("invalid stats format returned") + } + return s, nil +} + +func userAgent() string { + return fmt.Sprintf("globalping-cli/v%s (https://github.com/jsdelivr/globalping-cli)", version.Version) +} diff --git a/client/client_test.go b/globalping/globalping_test.go similarity index 80% rename from client/client_test.go rename to globalping/globalping_test.go index 13d5524..ae9336c 100644 --- a/client/client_test.go +++ b/globalping/globalping_test.go @@ -1,44 +1,19 @@ -package client_test +package globalping import ( "encoding/json" "net/http" "net/http/httptest" "os" + "strings" "testing" - "github.com/jsdelivr/globalping-cli/client" - "github.com/jsdelivr/globalping-cli/model" + "github.com/andybalholm/brotli" + "github.com/jsdelivr/globalping-cli/version" "github.com/stretchr/testify/assert" ) -// Generate server for testing -func generateServer(json string) *httptest.Server { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusAccepted) - _, err := w.Write([]byte(json)) - if err != nil { - panic(err) - } - })) - return server -} - -func generateServerError(json string, statusCode int) *httptest.Server { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(statusCode) - _, err := w.Write([]byte(json)) - if err != nil { - panic(err) - } - })) - return server -} - -// Dummy interface since we have mock responses -var opts = model.PostMeasurement{} - // PostAPI tests func TestPostAPI(t *testing.T) { // Suppress error outputs @@ -59,9 +34,10 @@ func TestPostAPI(t *testing.T) { func testPostValid(t *testing.T) { server := generateServer(`{"id":"abcd","probesCount":1}`) defer server.Close() - client.ApiUrl = server.URL + client := NewClient(server.URL) - res, showHelp, err := client.PostAPI(opts) + opts := &MeasurementCreate{} + res, showHelp, err := client.CreateMeasurement(opts) assert.Equal(t, "abcd", res.ID) assert.Equal(t, 1, res.ProbesCount) @@ -76,9 +52,11 @@ func testPostNoProbes(t *testing.T) { "type": "no_probes_found" }}`, 422) defer server.Close() - client.ApiUrl = server.URL - _, showHelp, err := client.PostAPI(opts) + client := NewClient(server.URL) + opts := &MeasurementCreate{} + _, showHelp, err := client.CreateMeasurement(opts) + assert.EqualError(t, err, "no suitable probes found - please choose a different location") assert.True(t, showHelp) } @@ -94,9 +72,10 @@ func testPostValidation(t *testing.T) { } }}`, 400) defer server.Close() - client.ApiUrl = server.URL + client := NewClient(server.URL) - _, showHelp, err := client.PostAPI(opts) + opts := &MeasurementCreate{} + _, showHelp, err := client.CreateMeasurement(opts) // Key order is not guaranteed expectedErrV1 := `invalid parameters @@ -119,9 +98,10 @@ func testPostInternalError(t *testing.T) { "type": "api_error" }}`, 500) defer server.Close() - client.ApiUrl = server.URL + client := NewClient(server.URL) - _, showHelp, err := client.PostAPI(opts) + opts := &MeasurementCreate{} + _, showHelp, err := client.CreateMeasurement(opts) assert.EqualError(t, err, "internal server error - please try again later") assert.False(t, showHelp) } @@ -146,11 +126,8 @@ func TestGetAPI(t *testing.T) { func testGetValid(t *testing.T) { server := generateServer(`{"id":"abcd"}`) defer server.Close() - client.ApiUrl = server.URL - - fetcher := client.NewMeasurementsFetcher(server.URL) - - res, err := fetcher.GetMeasurement("abcd") + client := NewClient(server.URL) + res, err := client.GetMeasurement("abcd") if err != nil { t.Error(err) } @@ -160,11 +137,8 @@ func testGetValid(t *testing.T) { func testGetJson(t *testing.T) { server := generateServer(`{"id":"abcd"}`) defer server.Close() - client.ApiUrl = server.URL - - fetcher := client.NewMeasurementsFetcher(server.URL) - - res, err := fetcher.GetRawMeasurement("abcd") + client := NewClient(server.URL) + res, err := client.GetRawMeasurement("abcd") if err != nil { t.Error(err) } @@ -216,18 +190,16 @@ func testGetPing(t *testing.T) { } }]}`) defer server.Close() - client.ApiUrl = server.URL - - fetcher := client.NewMeasurementsFetcher(server.URL) + client := NewClient(server.URL) - res, err := fetcher.GetMeasurement("abcd") + res, err := client.GetMeasurement("abcd") if err != nil { t.Error(err) } assert.Equal(t, "abcd", res.ID) assert.Equal(t, "ping", res.Type) - assert.Equal(t, model.StatusFinished, res.Status) + assert.Equal(t, StatusFinished, res.Status) assert.Equal(t, "2023-02-17T18:11:52.825Z", res.CreatedAt) assert.Equal(t, "2023-02-17T18:11:53.969Z", res.UpdatedAt) assert.Equal(t, 1, res.ProbesCount) @@ -244,7 +216,7 @@ func testGetPing(t *testing.T) { assert.Equal(t, "PING", res.Results[0].Result.RawOutput) assert.Equal(t, "1.1.1.1", res.Results[0].Result.ResolvedAddress) - stats, err := client.DecodePingStats(res.Results[0].Result.StatsRaw) + stats, err := DecodePingStats(res.Results[0].Result.StatsRaw) assert.NoError(t, err) assert.Equal(t, float64(27.088), stats.Avg) assert.Equal(t, float64(28.193), stats.Max) @@ -313,18 +285,17 @@ func testGetTraceroute(t *testing.T) { ] }}]}`) defer server.Close() - client.ApiUrl = server.URL - fetcher := client.NewMeasurementsFetcher(server.URL) + client := NewClient(server.URL) - res, err := fetcher.GetMeasurement("abcd") + res, err := client.GetMeasurement("abcd") if err != nil { t.Error(err) } assert.Equal(t, "abcd", res.ID) assert.Equal(t, "traceroute", res.Type) - assert.Equal(t, model.StatusFinished, res.Status) + assert.Equal(t, StatusFinished, res.Status) assert.Equal(t, "2023-02-23T07:55:23.414Z", res.CreatedAt) assert.Equal(t, "2023-02-23T07:55:25.496Z", res.UpdatedAt) assert.Equal(t, 1, res.ProbesCount) @@ -391,18 +362,16 @@ func testGetDns(t *testing.T) { } }]}`) defer server.Close() - client.ApiUrl = server.URL - - fetcher := client.NewMeasurementsFetcher(server.URL) + client := NewClient(server.URL) - res, err := fetcher.GetMeasurement("abcd") + res, err := client.GetMeasurement("abcd") if err != nil { t.Error(err) } assert.Equal(t, "abcd", res.ID) assert.Equal(t, "dns", res.Type) - assert.Equal(t, model.StatusFinished, res.Status) + assert.Equal(t, StatusFinished, res.Status) assert.Equal(t, "2023-02-23T08:00:37.431Z", res.CreatedAt) assert.Equal(t, "2023-02-23T08:00:37.640Z", res.UpdatedAt) assert.Equal(t, 1, res.ProbesCount) @@ -418,11 +387,11 @@ func testGetDns(t *testing.T) { assert.Equal(t, 0, len(res.Results[0].Probe.Tags)) assert.Equal(t, "DNS", res.Results[0].Result.RawOutput) - assert.Equal(t, model.StatusFinished, res.Results[0].Result.Status) + assert.Equal(t, StatusFinished, res.Results[0].Result.Status) assert.IsType(t, json.RawMessage{}, res.Results[0].Result.TimingsRaw) // Test timings - timings, _ := client.DecodeDNSTimings(res.Results[0].Result.TimingsRaw) + timings, _ := DecodeDNSTimings(res.Results[0].Result.TimingsRaw) assert.Equal(t, float64(15), timings.Total) } @@ -515,18 +484,16 @@ func testGetMtr(t *testing.T) { } }]}`) defer server.Close() - client.ApiUrl = server.URL + client := NewClient(server.URL) - fetcher := client.NewMeasurementsFetcher(server.URL) - - res, err := fetcher.GetMeasurement("abcd") + res, err := client.GetMeasurement("abcd") if err != nil { t.Error(err) } assert.Equal(t, "abcd", res.ID) assert.Equal(t, "mtr", res.Type) - assert.Equal(t, model.StatusFinished, res.Status) + assert.Equal(t, StatusFinished, res.Status) assert.Equal(t, "2023-02-23T08:08:25.187Z", res.CreatedAt) assert.Equal(t, "2023-02-23T08:08:29.829Z", res.UpdatedAt) assert.Equal(t, 1, res.ProbesCount) @@ -542,7 +509,7 @@ func testGetMtr(t *testing.T) { assert.Equal(t, 0, len(res.Results[0].Probe.Tags)) assert.Equal(t, "MTR", res.Results[0].Result.RawOutput) - assert.Equal(t, model.StatusFinished, res.Results[0].Result.Status) + assert.Equal(t, StatusFinished, res.Results[0].Result.Status) assert.IsType(t, json.RawMessage{}, res.Results[0].Result.TimingsRaw) } @@ -622,18 +589,16 @@ func testGetHttp(t *testing.T) { } }]}`) defer server.Close() - client.ApiUrl = server.URL - - fetcher := client.NewMeasurementsFetcher(server.URL) + client := NewClient(server.URL) - res, err := fetcher.GetMeasurement("abcd") + res, err := client.GetMeasurement("abcd") if err != nil { t.Error(err) } assert.Equal(t, "abcd", res.ID) assert.Equal(t, "http", res.Type) - assert.Equal(t, model.StatusFinished, res.Status) + assert.Equal(t, StatusFinished, res.Status) assert.Equal(t, "2023-02-23T08:16:11.335Z", res.CreatedAt) assert.Equal(t, "2023-02-23T08:16:12.548Z", res.UpdatedAt) assert.Equal(t, 1, res.ProbesCount) @@ -649,11 +614,11 @@ func testGetHttp(t *testing.T) { assert.Equal(t, 0, len(res.Results[0].Probe.Tags)) assert.Equal(t, "HTTP", res.Results[0].Result.RawOutput) - assert.Equal(t, model.StatusFinished, res.Results[0].Result.Status) + assert.Equal(t, StatusFinished, res.Results[0].Result.Status) assert.IsType(t, json.RawMessage{}, res.Results[0].Result.TimingsRaw) // Test timings - timings, _ := client.DecodeHTTPTimings(res.Results[0].Result.TimingsRaw) + timings, _ := DecodeHTTPTimings(res.Results[0].Result.TimingsRaw) assert.Equal(t, 583, timings.Total) assert.Equal(t, 18, timings.Download) assert.Equal(t, 450, timings.FirstByte) @@ -661,3 +626,121 @@ func testGetHttp(t *testing.T) { assert.Equal(t, 70, timings.TLS) assert.Equal(t, 19, timings.TCP) } + +func TestFetchWithEtag(t *testing.T) { + id1 := "123abc" + id2 := "567xyz" + + cacheMissCount := 0 + cacheHitCount := 0 + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(r.URL.Path, "/") + id := parts[len(parts)-1] + + etag := func(id string) string { + return "etag-" + id + } + + if r.Header.Get("If-None-Match") == etag(id) { + // cache hit + cacheHitCount++ + w.Header().Set("ETag", etag(id)) + w.WriteHeader(http.StatusNotModified) + + return + } + + // cache miss, return full response + cacheMissCount++ + m := &Measurement{ + ID: id, + } + + w.Header().Set("ETag", etag(id)) + + err := json.NewEncoder(w).Encode(m) + assert.NoError(t, err) + })) + + client := NewClient(s.URL) + + // first request for id1 + m, err := client.GetMeasurement(id1) + assert.NoError(t, err) + + assert.Equal(t, id1, m.ID) + + // first request for id1 + m, err = client.GetMeasurement(id2) + assert.NoError(t, err) + + assert.Equal(t, id2, m.ID) + + // second request for id1 + m, err = client.GetMeasurement(id2) + assert.NoError(t, err) + + assert.Equal(t, id2, m.ID) + + assert.Equal(t, 1, cacheHitCount) + assert.Equal(t, 2, cacheMissCount) +} + +func TestFetchWithBrotli(t *testing.T) { + id := "123abc" + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(r.URL.Path, "/") + id := parts[len(parts)-1] + + assert.Equal(t, "br", r.Header.Get("Accept-Encoding")) + + m := &Measurement{ + ID: id, + } + + w.Header().Set("Content-Encoding", "br") + + rW := brotli.NewWriter(w) + defer rW.Close() + + err := json.NewEncoder(rW).Encode(m) + assert.NoError(t, err) + })) + + client := NewClient(s.URL) + + m, err := client.GetMeasurement(id) + assert.NoError(t, err) + + assert.Equal(t, id, m.ID) +} + +func TestUserAgent(t *testing.T) { + version.Version = "x.y.z" + assert.Equal(t, "globalping-cli/vx.y.z (https://github.com/jsdelivr/globalping-cli)", userAgent()) +} + +// Generate server for testing +func generateServer(json string) *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusAccepted) + _, err := w.Write([]byte(json)) + if err != nil { + panic(err) + } + })) + return server +} + +func generateServerError(json string, statusCode int) *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(statusCode) + _, err := w.Write([]byte(json)) + if err != nil { + panic(err) + } + })) + return server +} diff --git a/model/get.go b/globalping/models.go similarity index 53% rename from model/get.go rename to globalping/models.go index 54816da..68d2503 100644 --- a/model/get.go +++ b/globalping/models.go @@ -1,10 +1,58 @@ -package model +package globalping import "encoding/json" // Docs: https://www.jsdelivr.com/docs/api.globalping.io -type ProbeData struct { +type Locations struct { + Magic string `json:"magic"` +} + +type QueryOptions struct { + Type string `json:"type,omitempty"` +} + +type RequestOptions struct { + Headers map[string]string `json:"headers,omitempty"` + Path string `json:"path,omitempty"` + Host string `json:"host,omitempty"` + Query string `json:"query,omitempty"` + Method string `json:"method,omitempty"` +} + +type MeasurementOptions struct { + Query *QueryOptions `json:"query,omitempty"` + Request *RequestOptions `json:"request,omitempty"` + Protocol string `json:"protocol,omitempty"` + Port int `json:"port,omitempty"` + Resolver string `json:"resolver,omitempty"` + Trace bool `json:"trace,omitempty"` + Packets int `json:"packets,omitempty"` +} + +type MeasurementCreate struct { + Limit int `json:"limit"` + Locations []Locations `json:"locations"` + Type string `json:"type"` + Target string `json:"target"` + InProgressUpdates bool `json:"inProgressUpdates"` + Options *MeasurementOptions `json:"measurementOptions,omitempty"` +} + +type MeasurementCreateResponse struct { + ID string `json:"id"` + ProbesCount int `json:"probesCount"` +} + +type MeasurementCreateError struct { + Error struct { + Message string `json:"message"` + Type string `json:"type"` + Params map[string]interface{} `json:"params,omitempty"` + } `json:"error"` +} + +type ProbeDetails struct { Continent string `json:"continent"` Region string `json:"region"` Country string `json:"country"` @@ -24,7 +72,7 @@ const ( StatusFinished MeasurementStatus = "finished" ) -type ResultData struct { +type ProbeResult struct { Status MeasurementStatus `json:"status"` RawOutput string `json:"rawOutput"` RawHeaders string `json:"rawHeaders"` @@ -64,20 +112,18 @@ type HTTPTimings struct { Download int `json:"download"` // The time from the first byte to downloading the whole response. } -// Nested structs -type MeasurementResponse struct { - Probe ProbeData `json:"probe"` - Result ResultData `json:"result"` +type ProbeMeasurement struct { + Probe ProbeDetails `json:"probe"` + Result ProbeResult `json:"result"` } -// Main struct -type GetMeasurement struct { - ID string `json:"id"` - Type string `json:"type"` - Status MeasurementStatus `json:"status"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - Target string `json:"target"` - ProbesCount int `json:"probesCount"` - Results []MeasurementResponse `json:"results"` +type Measurement struct { + ID string `json:"id"` + Type string `json:"type"` + Status MeasurementStatus `json:"status"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + Target string `json:"target"` + ProbesCount int `json:"probesCount"` + Results []ProbeMeasurement `json:"results"` } diff --git a/mocks/gen_mocks.sh b/mocks/gen_mocks.sh index 6b465f8..4b83089 100755 --- a/mocks/gen_mocks.sh +++ b/mocks/gen_mocks.sh @@ -1,3 +1,3 @@ rm -rf mocks/mock_*.go -bin/mockgen -source client/measurements_fetcher.go -destination mocks/mock_measurements_fetcher.go -package mocks +bin/mockgen -source globalping/globalping.go -destination mocks/mock_globalping.go -package mocks diff --git a/mocks/mock_globalping.go b/mocks/mock_globalping.go new file mode 100644 index 0000000..f975a37 --- /dev/null +++ b/mocks/mock_globalping.go @@ -0,0 +1,81 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: globalping/globalping.go + +// Package mocks is a generated GoMock package. +package mocks + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + globalping "github.com/jsdelivr/globalping-cli/globalping" +) + +// MockClient is a mock of Client interface. +type MockClient struct { + ctrl *gomock.Controller + recorder *MockClientMockRecorder +} + +// MockClientMockRecorder is the mock recorder for MockClient. +type MockClientMockRecorder struct { + mock *MockClient +} + +// NewMockClient creates a new mock instance. +func NewMockClient(ctrl *gomock.Controller) *MockClient { + mock := &MockClient{ctrl: ctrl} + mock.recorder = &MockClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockClient) EXPECT() *MockClientMockRecorder { + return m.recorder +} + +// CreateMeasurement mocks base method. +func (m *MockClient) CreateMeasurement(measurement *globalping.MeasurementCreate) (*globalping.MeasurementCreateResponse, bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateMeasurement", measurement) + ret0, _ := ret[0].(*globalping.MeasurementCreateResponse) + ret1, _ := ret[1].(bool) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// CreateMeasurement indicates an expected call of CreateMeasurement. +func (mr *MockClientMockRecorder) CreateMeasurement(measurement interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateMeasurement", reflect.TypeOf((*MockClient)(nil).CreateMeasurement), measurement) +} + +// GetMeasurement mocks base method. +func (m *MockClient) GetMeasurement(id string) (*globalping.Measurement, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetMeasurement", id) + ret0, _ := ret[0].(*globalping.Measurement) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetMeasurement indicates an expected call of GetMeasurement. +func (mr *MockClientMockRecorder) GetMeasurement(id interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMeasurement", reflect.TypeOf((*MockClient)(nil).GetMeasurement), id) +} + +// GetRawMeasurement mocks base method. +func (m *MockClient) GetRawMeasurement(id string) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRawMeasurement", id) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetRawMeasurement indicates an expected call of GetRawMeasurement. +func (mr *MockClientMockRecorder) GetRawMeasurement(id interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRawMeasurement", reflect.TypeOf((*MockClient)(nil).GetRawMeasurement), id) +} diff --git a/mocks/mock_measurements_fetcher.go b/mocks/mock_measurements_fetcher.go deleted file mode 100644 index e57a04d..0000000 --- a/mocks/mock_measurements_fetcher.go +++ /dev/null @@ -1,65 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: client/measurements_fetcher.go - -// Package mocks is a generated GoMock package. -package mocks - -import ( - reflect "reflect" - - gomock "github.com/golang/mock/gomock" - model "github.com/jsdelivr/globalping-cli/model" -) - -// MockMeasurementsFetcher is a mock of MeasurementsFetcher interface. -type MockMeasurementsFetcher struct { - ctrl *gomock.Controller - recorder *MockMeasurementsFetcherMockRecorder -} - -// MockMeasurementsFetcherMockRecorder is the mock recorder for MockMeasurementsFetcher. -type MockMeasurementsFetcherMockRecorder struct { - mock *MockMeasurementsFetcher -} - -// NewMockMeasurementsFetcher creates a new mock instance. -func NewMockMeasurementsFetcher(ctrl *gomock.Controller) *MockMeasurementsFetcher { - mock := &MockMeasurementsFetcher{ctrl: ctrl} - mock.recorder = &MockMeasurementsFetcherMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockMeasurementsFetcher) EXPECT() *MockMeasurementsFetcherMockRecorder { - return m.recorder -} - -// GetMeasurement mocks base method. -func (m *MockMeasurementsFetcher) GetMeasurement(id string) (*model.GetMeasurement, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetMeasurement", id) - ret0, _ := ret[0].(*model.GetMeasurement) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetMeasurement indicates an expected call of GetMeasurement. -func (mr *MockMeasurementsFetcherMockRecorder) GetMeasurement(id interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMeasurement", reflect.TypeOf((*MockMeasurementsFetcher)(nil).GetMeasurement), id) -} - -// GetRawMeasurement mocks base method. -func (m *MockMeasurementsFetcher) GetRawMeasurement(id string) ([]byte, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetRawMeasurement", id) - ret0, _ := ret[0].([]byte) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetRawMeasurement indicates an expected call of GetRawMeasurement. -func (mr *MockMeasurementsFetcherMockRecorder) GetRawMeasurement(id interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRawMeasurement", reflect.TypeOf((*MockMeasurementsFetcher)(nil).GetRawMeasurement), id) -} diff --git a/model/post.go b/model/post.go deleted file mode 100644 index 570d558..0000000 --- a/model/post.go +++ /dev/null @@ -1,53 +0,0 @@ -package model - -// Docs: https://www.jsdelivr.com/docs/api.globalping.io - -// Nested structs -type Locations struct { - Magic string `json:"magic"` -} - -type QueryOptions struct { - Type string `json:"type,omitempty"` -} - -type RequestOptions struct { - Headers map[string]string `json:"headers,omitempty"` - Path string `json:"path,omitempty"` - Host string `json:"host,omitempty"` - Query string `json:"query,omitempty"` - Method string `json:"method,omitempty"` -} - -type MeasurementOptions struct { - Query *QueryOptions `json:"query,omitempty"` - Request *RequestOptions `json:"request,omitempty"` - Protocol string `json:"protocol,omitempty"` - Port int `json:"port,omitempty"` - Resolver string `json:"resolver,omitempty"` - Trace bool `json:"trace,omitempty"` - Packets int `json:"packets,omitempty"` -} - -// Main struct -type PostMeasurement struct { - Limit int `json:"limit"` - Locations []Locations `json:"locations"` - Type string `json:"type"` - Target string `json:"target"` - InProgressUpdates bool `json:"inProgressUpdates"` - Options *MeasurementOptions `json:"measurementOptions,omitempty"` -} - -type PostResponse struct { - ID string `json:"id"` - ProbesCount int `json:"probesCount"` -} - -type PostError struct { - Error struct { - Message string `json:"message"` - Type string `json:"type"` - Params map[string]interface{} `json:"params,omitempty"` - } `json:"error"` -} diff --git a/model/root.go b/view/context.go similarity index 70% rename from model/root.go rename to view/context.go index 11b49c7..50d8372 100644 --- a/model/root.go +++ b/view/context.go @@ -1,4 +1,4 @@ -package model +package view import ( "math" @@ -7,22 +7,26 @@ import ( "github.com/pterm/pterm" ) -// Used in thc client TUI type Context struct { - Cmd string - Target string - From string - Resolver string + Cmd string + Target string + From string + + Protocol string + Port int + Resolver string + Trace bool + QueryType string - Limit int + Limit int // Number of probes to use Packets int // Number of packets to send - JsonOutput bool // JsonOutput is a flag that determines whether the output should be in JSON format. - Latency bool // Latency is a flag that outputs only stats of a measurement - CI bool // CI flag is used to determine whether the output should be in a format that is easy to parse by a CI tool - Full bool // Full output - Share bool // Display share message - Infinite bool // Infinite flag + 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 + Full bool // Full output + Share bool // Display share message + Infinite bool // Infinite flag APIMinInterval time.Duration // Minimum interval between API calls @@ -35,6 +39,17 @@ type Context struct { History *Rbuffer // History of measurements } +type HTTPOpts struct { + Path string + Query string + Host string + Method string + Protocol string + Port int + Resolver string + Headers []string +} + type MeasurementStats struct { Sent int // Number of packets sent Rcv int // Number of packets received diff --git a/model/root_test.go b/view/context_test.go similarity index 95% rename from model/root_test.go rename to view/context_test.go index 8234aa7..e379a45 100644 --- a/model/root_test.go +++ b/view/context_test.go @@ -1,4 +1,4 @@ -package model +package view import ( "testing" @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestRBuffer(t *testing.T) { +func Test_RBuffer(t *testing.T) { t.Run("Push", func(t *testing.T) { b := NewRbuffer(3) assert.Equal(t, 0, b.Index) diff --git a/view/default.go b/view/default.go index fe9c3c6..aa7778e 100644 --- a/view/default.go +++ b/view/default.go @@ -5,11 +5,11 @@ import ( "os" "strings" - "github.com/jsdelivr/globalping-cli/model" + "github.com/jsdelivr/globalping-cli/globalping" ) // Outputs non-json non-latency results for a measurement -func OutputDefault(id string, data *model.GetMeasurement, ctx model.Context, m model.PostMeasurement) { +func (v *viewer) outputDefault(id string, data *globalping.Measurement, m *globalping.MeasurementCreate) { for i := range data.Results { result := &data.Results[i] if i > 0 { @@ -18,16 +18,16 @@ func OutputDefault(id string, data *model.GetMeasurement, ctx model.Context, m m } // Output slightly different format if state is available - fmt.Fprintln(os.Stderr, generateProbeInfo(result, !ctx.CI)) + fmt.Fprintln(os.Stderr, generateProbeInfo(result, !v.ctx.CI)) - if isBodyOnlyHttpGet(ctx, m) { + if v.isBodyOnlyHttpGet(m) { fmt.Println(strings.TrimSpace(result.Result.RawBody)) } else { fmt.Println(strings.TrimSpace(result.Result.RawOutput)) } } - if ctx.Share { - fmt.Fprintln(os.Stderr, formatWithLeadingArrow(shareMessage(id), !ctx.CI)) + if v.ctx.Share { + fmt.Fprintln(os.Stderr, formatWithLeadingArrow(shareMessage(id), !v.ctx.CI)) } } diff --git a/view/default_test.go b/view/default_test.go index 7aea7ec..f5a5074 100644 --- a/view/default_test.go +++ b/view/default_test.go @@ -5,56 +5,27 @@ import ( "os" "testing" - "github.com/jsdelivr/globalping-cli/model" + "github.com/golang/mock/gomock" + "github.com/jsdelivr/globalping-cli/globalping" + "github.com/jsdelivr/globalping-cli/mocks" "github.com/stretchr/testify/assert" ) -func TestOutputDefaultHTTPGet(t *testing.T) { - osStdErr := os.Stderr - osStdOut := os.Stdout - - rStdErr, myStdErr, err := os.Pipe() - assert.NoError(t, err) - defer rStdErr.Close() +func Test_Output_Default_HTTP_Get(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() - rStdOut, myStdOut, err := os.Pipe() - assert.NoError(t, err) - defer rStdOut.Close() - - os.Stderr = myStdErr - os.Stdout = myStdOut - - defer func() { - os.Stderr = osStdErr - os.Stdout = osStdOut - }() - - ctx := model.Context{ - Cmd: "http", - CI: true, - } - - m := model.PostMeasurement{ - Options: &model.MeasurementOptions{ - Request: &model.RequestOptions{ - Method: "GET", - }, - }, - } - - id := "123abc" - - data := &model.GetMeasurement{ - Results: []model.MeasurementResponse{ + measurement := &globalping.Measurement{ + Results: []globalping.ProbeMeasurement{ { - Probe: model.ProbeData{ + Probe: globalping.ProbeDetails{ Continent: "EU", Country: "DE", City: "Berlin", ASN: 123, Network: "Network 1", }, - Result: model.ResultData{ + Result: globalping.ProbeResult{ RawOutput: "Headers 1\nBody 1", RawHeaders: "Headers 1", RawBody: "Body 1", @@ -62,7 +33,7 @@ func TestOutputDefaultHTTPGet(t *testing.T) { }, { - Probe: model.ProbeData{ + Probe: globalping.ProbeDetails{ Continent: "NA", Country: "US", City: "New York", @@ -70,7 +41,7 @@ func TestOutputDefaultHTTPGet(t *testing.T) { ASN: 567, Network: "Network 2", }, - Result: model.ResultData{ + Result: globalping.ProbeResult{ RawOutput: "Headers 2\nBody 2", RawHeaders: "Headers 2", RawBody: "Body 2", @@ -79,20 +50,9 @@ func TestOutputDefaultHTTPGet(t *testing.T) { }, } - OutputDefault(id, data, ctx, m) - myStdOut.Close() - myStdErr.Close() - - errContent, err := io.ReadAll(rStdErr) - assert.NoError(t, err) - assert.Equal(t, "> EU, DE, Berlin, ASN:123, Network 1\n> NA, US, (NY), New York, ASN:567, Network 2\n", string(errContent)) - - outContent, err := io.ReadAll(rStdOut) - assert.NoError(t, err) - assert.Equal(t, "Body 1\n\nBody 2\n", string(outContent)) -} + gbMock := mocks.NewMockClient(ctrl) + gbMock.EXPECT().GetMeasurement(measurementID1).Times(1).Return(measurement, nil) -func TestOutputDefaultHTTPGetShare(t *testing.T) { osStdErr := os.Stderr osStdOut := os.Stdout @@ -112,33 +72,47 @@ func TestOutputDefaultHTTPGetShare(t *testing.T) { os.Stdout = osStdOut }() - ctx := model.Context{ - Cmd: "http", - CI: true, - Share: true, - } - - m := model.PostMeasurement{ - Options: &model.MeasurementOptions{ - Request: &model.RequestOptions{ + m := &globalping.MeasurementCreate{ + Options: &globalping.MeasurementOptions{ + Request: &globalping.RequestOptions{ Method: "GET", }, }, } - id := "123abc" + viewer := NewViewer(&Context{ + Cmd: "http", + CI: true, + }, gbMock) + + viewer.Output(measurementID1, m) + myStdOut.Close() + myStdErr.Close() + + errContent, err := io.ReadAll(rStdErr) + assert.NoError(t, err) + assert.Equal(t, "> EU, DE, Berlin, ASN:123, Network 1\n> NA, US, (NY), New York, ASN:567, Network 2\n", string(errContent)) + + outContent, err := io.ReadAll(rStdOut) + assert.NoError(t, err) + assert.Equal(t, "Body 1\n\nBody 2\n", string(outContent)) +} + +func Test_Output_Default_HTTP_Get_Share(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() - data := &model.GetMeasurement{ - Results: []model.MeasurementResponse{ + measurement := &globalping.Measurement{ + Results: []globalping.ProbeMeasurement{ { - Probe: model.ProbeData{ + Probe: globalping.ProbeDetails{ Continent: "EU", Country: "DE", City: "Berlin", ASN: 123, Network: "Network 1", }, - Result: model.ResultData{ + Result: globalping.ProbeResult{ RawOutput: "Headers 1\nBody 1", RawHeaders: "Headers 1", RawBody: "Body 1", @@ -146,7 +120,7 @@ func TestOutputDefaultHTTPGetShare(t *testing.T) { }, { - Probe: model.ProbeData{ + Probe: globalping.ProbeDetails{ Continent: "NA", Country: "US", City: "New York", @@ -154,7 +128,7 @@ func TestOutputDefaultHTTPGetShare(t *testing.T) { ASN: 567, Network: "Network 2", }, - Result: model.ResultData{ + Result: globalping.ProbeResult{ RawOutput: "Headers 2\nBody 2", RawHeaders: "Headers 2", RawBody: "Body 2", @@ -163,20 +137,9 @@ func TestOutputDefaultHTTPGetShare(t *testing.T) { }, } - OutputDefault(id, data, ctx, m) - myStdOut.Close() - myStdErr.Close() - - errContent, err := io.ReadAll(rStdErr) - assert.NoError(t, err) - assert.Equal(t, "> EU, DE, Berlin, ASN:123, Network 1\n> NA, US, (NY), New York, ASN:567, Network 2\n> View the results online: https://www.jsdelivr.com/globalping?measurement=123abc\n", string(errContent)) - - outContent, err := io.ReadAll(rStdOut) - assert.NoError(t, err) - assert.Equal(t, "Body 1\n\nBody 2\n", string(outContent)) -} + gbMock := mocks.NewMockClient(ctrl) + gbMock.EXPECT().GetMeasurement(measurementID1).Times(1).Return(measurement, nil) -func TestOutputDefaultHTTPGetFull(t *testing.T) { osStdErr := os.Stderr osStdOut := os.Stdout @@ -196,33 +159,48 @@ func TestOutputDefaultHTTPGetFull(t *testing.T) { os.Stdout = osStdOut }() - ctx := model.Context{ - Cmd: "http", - CI: true, - Full: true, - } - - m := model.PostMeasurement{ - Options: &model.MeasurementOptions{ - Request: &model.RequestOptions{ + m := &globalping.MeasurementCreate{ + Options: &globalping.MeasurementOptions{ + Request: &globalping.RequestOptions{ Method: "GET", }, }, } - id := "123abc" + viewer := NewViewer(&Context{ + Cmd: "http", + CI: true, + Share: true, + }, gbMock) + + viewer.Output(measurementID1, m) + myStdOut.Close() + myStdErr.Close() + + errContent, err := io.ReadAll(rStdErr) + assert.NoError(t, err) + assert.Equal(t, "> EU, DE, Berlin, ASN:123, Network 1\n> NA, US, (NY), New York, ASN:567, Network 2\n> View the results online: https://www.jsdelivr.com/globalping?measurement=nzGzfAGL7sZfUs3c\n", string(errContent)) + + outContent, err := io.ReadAll(rStdOut) + assert.NoError(t, err) + assert.Equal(t, "Body 1\n\nBody 2\n", string(outContent)) +} + +func Test_Output_Default_HTTP_Get_Full(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() - data := &model.GetMeasurement{ - Results: []model.MeasurementResponse{ + measurement := &globalping.Measurement{ + Results: []globalping.ProbeMeasurement{ { - Probe: model.ProbeData{ + Probe: globalping.ProbeDetails{ Continent: "EU", Country: "DE", City: "Berlin", ASN: 123, Network: "Network 1", }, - Result: model.ResultData{ + Result: globalping.ProbeResult{ RawOutput: "Headers 1\nBody 1", RawHeaders: "Headers 1", RawBody: "Body 1", @@ -230,7 +208,7 @@ func TestOutputDefaultHTTPGetFull(t *testing.T) { }, { - Probe: model.ProbeData{ + Probe: globalping.ProbeDetails{ Continent: "NA", Country: "US", City: "New York", @@ -238,7 +216,7 @@ func TestOutputDefaultHTTPGetFull(t *testing.T) { ASN: 567, Network: "Network 2", }, - Result: model.ResultData{ + Result: globalping.ProbeResult{ RawOutput: "Headers 2\nBody 2", RawHeaders: "Headers 2", RawBody: "Body 2", @@ -247,20 +225,9 @@ func TestOutputDefaultHTTPGetFull(t *testing.T) { }, } - OutputDefault(id, data, ctx, m) - myStdOut.Close() - myStdErr.Close() - - errContent, err := io.ReadAll(rStdErr) - assert.NoError(t, err) - assert.Equal(t, "> EU, DE, Berlin, ASN:123, Network 1\n> NA, US, (NY), New York, ASN:567, Network 2\n", string(errContent)) + gbMock := mocks.NewMockClient(ctrl) + gbMock.EXPECT().GetMeasurement(measurementID1).Times(1).Return(measurement, nil) - outContent, err := io.ReadAll(rStdOut) - assert.NoError(t, err) - assert.Equal(t, "Headers 1\nBody 1\n\nHeaders 2\nBody 2\n", string(outContent)) -} - -func TestOutputDefaultHTTPHead(t *testing.T) { osStdErr := os.Stderr osStdOut := os.Stdout @@ -280,39 +247,56 @@ func TestOutputDefaultHTTPHead(t *testing.T) { os.Stdout = osStdOut }() - ctx := model.Context{ - Cmd: "http", - CI: true, - } - - m := model.PostMeasurement{ - Options: &model.MeasurementOptions{ - Request: &model.RequestOptions{ - Method: "HEAD", + m := &globalping.MeasurementCreate{ + Options: &globalping.MeasurementOptions{ + Request: &globalping.RequestOptions{ + Method: "GET", }, }, } - id := "123abc" + viewer := NewViewer(&Context{ + Cmd: "http", + CI: true, + Full: true, + }, gbMock) + + viewer.Output(measurementID1, m) - data := &model.GetMeasurement{ - Results: []model.MeasurementResponse{ + myStdOut.Close() + myStdErr.Close() + + errContent, err := io.ReadAll(rStdErr) + assert.NoError(t, err) + assert.Equal(t, "> EU, DE, Berlin, ASN:123, Network 1\n> NA, US, (NY), New York, ASN:567, Network 2\n", string(errContent)) + + outContent, err := io.ReadAll(rStdOut) + assert.NoError(t, err) + assert.Equal(t, "Headers 1\nBody 1\n\nHeaders 2\nBody 2\n", string(outContent)) +} + +func Test_Output_Default_HTTP_Head(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + measurement := &globalping.Measurement{ + Results: []globalping.ProbeMeasurement{ { - Probe: model.ProbeData{ + Probe: globalping.ProbeDetails{ Continent: "EU", Country: "DE", City: "Berlin", ASN: 123, Network: "Network 1", }, - Result: model.ResultData{ + Result: globalping.ProbeResult{ RawOutput: "Headers 1", RawHeaders: "Headers 1", }, }, { - Probe: model.ProbeData{ + Probe: globalping.ProbeDetails{ Continent: "NA", Country: "US", City: "New York", @@ -320,7 +304,7 @@ func TestOutputDefaultHTTPHead(t *testing.T) { ASN: 567, Network: "Network 2", }, - Result: model.ResultData{ + Result: globalping.ProbeResult{ RawOutput: "Headers 2", RawHeaders: "Headers 2", }, @@ -328,20 +312,9 @@ func TestOutputDefaultHTTPHead(t *testing.T) { }, } - OutputDefault(id, data, ctx, m) - myStdOut.Close() - myStdErr.Close() - - errContent, err := io.ReadAll(rStdErr) - assert.NoError(t, err) - assert.Equal(t, "> EU, DE, Berlin, ASN:123, Network 1\n> NA, US, (NY), New York, ASN:567, Network 2\n", string(errContent)) - - outContent, err := io.ReadAll(rStdOut) - assert.NoError(t, err) - assert.Equal(t, "Headers 1\n\nHeaders 2\n", string(outContent)) -} + gbMock := mocks.NewMockClient(ctrl) + gbMock.EXPECT().GetMeasurement(measurementID1).Times(1).Return(measurement, nil) -func TestOutputDefaultPing(t *testing.T) { osStdErr := os.Stderr osStdOut := os.Stdout @@ -361,32 +334,54 @@ func TestOutputDefaultPing(t *testing.T) { os.Stdout = osStdOut }() - ctx := model.Context{ - Cmd: "ping", - CI: true, + m := &globalping.MeasurementCreate{ + Options: &globalping.MeasurementOptions{ + Request: &globalping.RequestOptions{ + Method: "HEAD", + }, + }, } - m := model.PostMeasurement{} + viewer := NewViewer(&Context{ + Cmd: "http", + CI: true, + }, gbMock) + + viewer.Output(measurementID1, m) + + myStdOut.Close() + myStdErr.Close() + + errContent, err := io.ReadAll(rStdErr) + assert.NoError(t, err) + assert.Equal(t, "> EU, DE, Berlin, ASN:123, Network 1\n> NA, US, (NY), New York, ASN:567, Network 2\n", string(errContent)) - id := "123abc" + outContent, err := io.ReadAll(rStdOut) + assert.NoError(t, err) + assert.Equal(t, "Headers 1\n\nHeaders 2\n", string(outContent)) +} + +func Test_Output_Default_Ping(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() - data := &model.GetMeasurement{ - Results: []model.MeasurementResponse{ + measurement := &globalping.Measurement{ + Results: []globalping.ProbeMeasurement{ { - Probe: model.ProbeData{ + Probe: globalping.ProbeDetails{ Continent: "EU", Country: "DE", City: "Berlin", ASN: 123, Network: "Network 1", }, - Result: model.ResultData{ + Result: globalping.ProbeResult{ RawOutput: "Ping Results 1", }, }, { - Probe: model.ProbeData{ + Probe: globalping.ProbeDetails{ Continent: "NA", Country: "US", City: "New York", @@ -394,14 +389,43 @@ func TestOutputDefaultPing(t *testing.T) { ASN: 567, Network: "Network 2", }, - Result: model.ResultData{ + Result: globalping.ProbeResult{ RawOutput: "Ping Results 2", }, }, }, } - OutputDefault(id, data, ctx, m) + gbMock := mocks.NewMockClient(ctrl) + gbMock.EXPECT().GetMeasurement(measurementID1).Times(1).Return(measurement, nil) + + osStdErr := os.Stderr + osStdOut := os.Stdout + + rStdErr, myStdErr, err := os.Pipe() + assert.NoError(t, err) + defer rStdErr.Close() + + rStdOut, myStdOut, err := os.Pipe() + assert.NoError(t, err) + defer rStdOut.Close() + + os.Stderr = myStdErr + os.Stdout = myStdOut + + defer func() { + os.Stderr = osStdErr + os.Stdout = osStdOut + }() + + m := &globalping.MeasurementCreate{} + + viewer := NewViewer(&Context{ + Cmd: "ping", + CI: true, + }, gbMock) + + viewer.Output(measurementID1, m) myStdOut.Close() myStdErr.Close() diff --git a/view/infinite.go b/view/infinite.go index ca16106..55a31f3 100644 --- a/view/infinite.go +++ b/view/infinite.go @@ -9,8 +9,7 @@ import ( "strings" "time" - "github.com/jsdelivr/globalping-cli/client" - "github.com/jsdelivr/globalping-cli/model" + "github.com/jsdelivr/globalping-cli/globalping" "github.com/mattn/go-runewidth" "github.com/pterm/pterm" ) @@ -20,102 +19,49 @@ var ( colSeparator = " | " ) -func OutputInfinite(id string, ctx *model.Context) error { - if ctx.History == nil { - ctx.History = model.NewRbuffer(ctx.MaxHistory) +func (v *viewer) OutputInfinite(id string) error { + if v.ctx.History == nil { + v.ctx.History = NewRbuffer(v.ctx.MaxHistory) } - ctx.History.Push(id) + v.ctx.History.Push(id) - fetcher := client.NewMeasurementsFetcher(client.ApiUrl) - res, err := fetcher.GetMeasurement(id) + res, err := v.gp.GetMeasurement(id) if err != nil { return err } // Probe may not have started yet for len(res.Results) == 0 { - time.Sleep(ctx.APIMinInterval) - res, err = fetcher.GetMeasurement(id) + time.Sleep(v.ctx.APIMinInterval) + res, err = v.gp.GetMeasurement(id) if err != nil { return err } } - if ctx.JsonOutput { - for res.Status == model.StatusInProgress { - time.Sleep(ctx.APIMinInterval) - res, err = fetcher.GetMeasurement(res.ID) + if v.ctx.ToJSON { + for res.Status == globalping.StatusInProgress { + time.Sleep(v.ctx.APIMinInterval) + res, err = v.gp.GetMeasurement(res.ID) if err != nil { return err } } - return OutputJson(id, fetcher, *ctx) + return v.OutputJson(id) } if len(res.Results) == 1 { - if ctx.Latency { - return outputTableView(fetcher, res, ctx) + if v.ctx.ToLatency { + return v.outputTableView(res) } - return outputStreamingPackets(fetcher, res, ctx) + return v.outputStreamingPackets(res) } - return outputTableView(fetcher, res, ctx) + return v.outputTableView(res) } -func OutputSummary(ctx *model.Context) { - if len(ctx.InProgressStats) == 0 { - return - } - - if len(ctx.InProgressStats) == 1 { - stats := ctx.InProgressStats[0] - - fmt.Printf("\n--- %s ping statistics ---\n", ctx.Hostname) - fmt.Printf("%d packets transmitted, %d received, %.2f%% packet loss, time %.0fms\n", - stats.Sent, - stats.Rcv, - stats.Loss, - stats.Time, - ) - min := "-" - avg := "-" - max := "-" - mdev := "-" - if stats.Min != math.MaxFloat64 { - min = fmt.Sprintf("%.3f", stats.Min) - } - if stats.Avg != -1 { - avg = fmt.Sprintf("%.3f", stats.Avg) - } - if stats.Max != -1 { - max = fmt.Sprintf("%.3f", stats.Max) - } - if stats.Mdev != 0 { - mdev = fmt.Sprintf("%.3f", stats.Mdev) - } - fmt.Printf("rtt min/avg/max/mdev = %s/%s/%s/%s ms\n", min, avg, max, mdev) - } - - if ctx.Share && ctx.History != nil { - if len(ctx.InProgressStats) > 1 { - fmt.Println() - } - ids := ctx.History.ToString("+") - if ids != "" { - fmt.Println(formatWithLeadingArrow(shareMessage(ids), !ctx.CI)) - } - if ctx.CallCount > ctx.MaxHistory { - fmt.Printf("For long-running continuous mode measurements, only the last %d packets are shared.\n", ctx.Packets*ctx.MaxHistory) - } - } -} - -func outputStreamingPackets( - fetcher client.MeasurementsFetcher, - res *model.GetMeasurement, - ctx *model.Context, -) error { - if len(ctx.CompletedStats) == 0 { - ctx.CompletedStats = []model.MeasurementStats{model.NewMeasurementStats()} - ctx.InProgressStats = []model.MeasurementStats{model.NewMeasurementStats()} +func (v *viewer) outputStreamingPackets(res *globalping.Measurement) error { + if len(v.ctx.CompletedStats) == 0 { + v.ctx.CompletedStats = []MeasurementStats{NewMeasurementStats()} + v.ctx.InProgressStats = []MeasurementStats{NewMeasurementStats()} } printHeader := true linesPrinted := 0 @@ -123,10 +69,10 @@ func outputStreamingPackets( for { measurement := &res.Results[0] if measurement.Result.RawOutput != "" { - parsedOutput := parsePingRawOutput(measurement, ctx.CompletedStats[0].Sent) - if printHeader && ctx.CompletedStats[0].Sent == 0 { - ctx.Hostname = parsedOutput.Hostname - fmt.Println(generateProbeInfo(measurement, !ctx.CI)) + parsedOutput := parsePingRawOutput(measurement, v.ctx.CompletedStats[0].Sent) + if printHeader && v.ctx.CompletedStats[0].Sent == 0 { + v.ctx.Hostname = parsedOutput.Hostname + fmt.Println(generateProbeInfo(measurement, !v.ctx.CI)) fmt.Printf("PING %s (%s) %s bytes of data.\n", parsedOutput.Hostname, parsedOutput.Address, @@ -138,16 +84,16 @@ func outputStreamingPackets( fmt.Println(parsedOutput.RawPacketLines[linesPrinted]) linesPrinted++ } - ctx.InProgressStats[0] = mergeMeasurementStats(ctx.CompletedStats[0], parsedOutput) - if res.Status != model.StatusInProgress { - ctx.CompletedStats[0] = ctx.InProgressStats[0] + v.ctx.InProgressStats[0] = mergeMeasurementStats(v.ctx.CompletedStats[0], parsedOutput) + if res.Status != globalping.StatusInProgress { + v.ctx.CompletedStats[0] = v.ctx.InProgressStats[0] } } - if res.Status != model.StatusInProgress { + if res.Status != globalping.StatusInProgress { break } - time.Sleep(ctx.APIMinInterval) - res, err = fetcher.GetMeasurement(res.ID) + time.Sleep(v.ctx.APIMinInterval) + res, err = v.gp.GetMeasurement(res.ID) if err != nil { return err } @@ -155,42 +101,39 @@ func outputStreamingPackets( return nil } -func outputTableView( - fetcher client.MeasurementsFetcher, - res *model.GetMeasurement, - ctx *model.Context) error { +func (v *viewer) outputTableView(res *globalping.Measurement) error { var err error - if len(ctx.CompletedStats) == 0 { + if len(v.ctx.CompletedStats) == 0 { // Initialize state - ctx.CompletedStats = make([]model.MeasurementStats, len(res.Results)) - for i := range ctx.CompletedStats { - ctx.CompletedStats[i].Last = -1 - ctx.CompletedStats[i].Min = math.MaxFloat64 - ctx.CompletedStats[i].Avg = -1 - ctx.CompletedStats[i].Max = -1 + v.ctx.CompletedStats = make([]MeasurementStats, len(res.Results)) + for i := range v.ctx.CompletedStats { + v.ctx.CompletedStats[i].Last = -1 + v.ctx.CompletedStats[i].Min = math.MaxFloat64 + v.ctx.CompletedStats[i].Avg = -1 + v.ctx.CompletedStats[i].Max = -1 } // Create new writer - ctx.Area, err = pterm.DefaultArea.Start() + v.ctx.Area, err = pterm.DefaultArea.Start() if err != nil { return errors.New("failed to start writer: " + err.Error()) } } for { - o, stats := generateTable(res, ctx, pterm.GetTerminalWidth()-2) + o, stats := v.generateTable(res, pterm.GetTerminalWidth()-2) if o != nil { - ctx.Area.Update(*o) + v.ctx.Area.Update(*o) } if stats != nil { - ctx.InProgressStats = stats + v.ctx.InProgressStats = stats } - if res.Status != model.StatusInProgress { + if res.Status != globalping.StatusInProgress { if stats != nil { - ctx.CompletedStats = stats + v.ctx.CompletedStats = stats } break } - time.Sleep(ctx.APIMinInterval) - res, err = fetcher.GetMeasurement(res.ID) + time.Sleep(v.ctx.APIMinInterval) + res, err = v.gp.GetMeasurement(res.ID) if err != nil { return err } @@ -208,7 +151,7 @@ func formatDuration(ms float64) string { return fmt.Sprintf("%.0f ms", ms) } -func generateTable(res *model.GetMeasurement, ctx *model.Context, areaWidth int) (*string, []model.MeasurementStats) { +func (v *viewer) generateTable(res *globalping.Measurement, areaWidth int) (*string, []MeasurementStats) { table := [][7]string{{"Location", "Sent", "Loss", "Last", "Min", "Avg", "Max"}} // Calculate max column width and max line width // We handle multi-line values only for the first column @@ -218,15 +161,15 @@ func generateTable(res *model.GetMeasurement, ctx *model.Context, areaWidth int) maxLineWidth += len(table[i]) + len(colSeparator) } skip := false - newStats := make([]model.MeasurementStats, len(res.Results)) + newStats := make([]MeasurementStats, len(res.Results)) for i := range res.Results { measurement := &res.Results[i] if measurement.Result.RawOutput == "" { skip = true break } - parsedOutput := parsePingRawOutput(measurement, ctx.CompletedStats[i].Sent) - newStats[i] = mergeMeasurementStats(ctx.CompletedStats[i], parsedOutput) + parsedOutput := parsePingRawOutput(measurement, v.ctx.CompletedStats[i].Sent) + newStats[i] = mergeMeasurementStats(v.ctx.CompletedStats[i], parsedOutput) row := getRowValues(&newStats[i]) rowWidth := 0 for j := 1; j < len(row); j++ { @@ -279,7 +222,7 @@ func generateTable(res *model.GetMeasurement, ctx *model.Context, areaWidth int) return &output, newStats } -func mergeMeasurementStats(stats model.MeasurementStats, o *ParsedPingOutput) model.MeasurementStats { +func mergeMeasurementStats(stats MeasurementStats, o *ParsedPingOutput) MeasurementStats { if o.Stats.Rcv > 0 { if o.Stats.Min < stats.Min && o.Stats.Min != 0 { stats.Min = o.Stats.Min @@ -303,7 +246,7 @@ func mergeMeasurementStats(stats model.MeasurementStats, o *ParsedPingOutput) mo return stats } -func getRowValues(stats *model.MeasurementStats) [7]string { +func getRowValues(stats *MeasurementStats) [7]string { last := "-" min := "-" avg := "-" @@ -350,8 +293,8 @@ type ParsedPingOutput struct { Address string BytesOfData string RawPacketLines []string - Timings []model.PingTiming - Stats *model.MeasurementStats + Timings []globalping.PingTiming + Stats *MeasurementStats Time float64 } @@ -360,10 +303,10 @@ type ParsedPingOutput struct { // - If startIncmpSeq is -1, RawPacketLines will be empty // // - Stats.Time will be 0 if no summary is found -func parsePingRawOutput(m *model.MeasurementResponse, startIncmpSeq int) *ParsedPingOutput { +func parsePingRawOutput(m *globalping.ProbeMeasurement, startIncmpSeq int) *ParsedPingOutput { res := &ParsedPingOutput{ - Timings: make([]model.PingTiming, 0), - Stats: &model.MeasurementStats{ + Timings: make([]globalping.PingTiming, 0), + Stats: &MeasurementStats{ Min: math.MaxFloat64, Max: -1, Avg: -1, @@ -420,7 +363,7 @@ func parsePingRawOutput(m *model.MeasurementResponse, startIncmpSeq int) *Parsed res.Stats.Max = math.Max(res.Stats.Max, rtt) res.Stats.Tsum += rtt res.Stats.Tsum2 += rtt * rtt - res.Timings = append(res.Timings, model.PingTiming{ + res.Timings = append(res.Timings, globalping.PingTiming{ TTL: ttl, RTT: rtt, }) diff --git a/view/infinite_test.go b/view/infinite_test.go index 10ecfa4..32fb382 100644 --- a/view/infinite_test.go +++ b/view/infinite_test.go @@ -7,13 +7,13 @@ import ( "testing" "github.com/golang/mock/gomock" + "github.com/jsdelivr/globalping-cli/globalping" "github.com/jsdelivr/globalping-cli/mocks" - "github.com/jsdelivr/globalping-cli/model" "github.com/pterm/pterm" "github.com/stretchr/testify/assert" ) -func TestStreamingPacketsInProgress(t *testing.T) { +func Test_OutputInfinite_SingleProbe_InProgress(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -37,32 +37,35 @@ func TestStreamingPacketsInProgress(t *testing.T) { 3 packets transmitted, 3 received, 0% packet loss, time 1001ms rtt min/avg/max/mdev = 12.711/12.854/12.952/0.103 ms` - fetcher := mocks.NewMockMeasurementsFetcher(ctrl) + gbMock := mocks.NewMockClient(ctrl) measurement := getPingGetMeasurement(measurementID1) - callCount := 1 // 1st call is done in the caller. - fetcher.EXPECT().GetMeasurement(measurementID1).DoAndReturn(func(id string) (*model.GetMeasurement, error) { + callCount := 0 // 1st call is done in the caller. + gbMock.EXPECT().GetMeasurement(measurementID1).DoAndReturn(func(id string) (*globalping.Measurement, error) { callCount++ switch callCount { + case 1: + measurement.Results[0].Result.RawOutput = rawOutput1 case 2: measurement.Results[0].Result.RawOutput = rawOutput2 case 3: measurement.Results[0].Result.RawOutput = rawOutput3 case 4: - measurement.Status = model.StatusFinished - measurement.Results[0].Result.Status = model.StatusFinished + measurement.Status = globalping.StatusFinished + measurement.Results[0].Result.Status = globalping.StatusFinished measurement.Results[0].Result.RawOutput = rawOutput4 } return measurement, nil - }).Times(3) + }).Times(4) - ctx := &model.Context{ - Cmd: "ping", - APIMinInterval: 0, + ctx := &Context{ + Cmd: "ping", + MaxHistory: 3, } + viewer := NewViewer(ctx, gbMock) - measurement.Status = model.StatusInProgress - measurement.Results[0].Result.Status = model.StatusInProgress + measurement.Status = globalping.StatusInProgress + measurement.Results[0].Result.Status = globalping.StatusInProgress measurement.Results[0].Result.RawOutput = rawOutput1 r, w, err := os.Pipe() @@ -73,7 +76,7 @@ rtt min/avg/max/mdev = 12.711/12.854/12.952/0.103 ms` }() os.Stdout = w - err = outputStreamingPackets(fetcher, measurement, ctx) + err = viewer.OutputInfinite(measurement.ID) w.Close() os.Stdout = osStdOut @@ -91,13 +94,13 @@ PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. string(output), ) - expectedStats := []model.MeasurementStats{{Sent: 3, Rcv: 3, Lost: 0, Loss: 0, Last: 13, Min: 12.7, + expectedStats := []MeasurementStats{{Sent: 3, Rcv: 3, Lost: 0, Loss: 0, Last: 13, Min: 12.7, Avg: 12.8666, Max: 13, Time: 1001, Tsum: 38.6, Tsum2: 496.7, Mdev: 0.1247}} assertMeasurementStats(t, &expectedStats[0], &ctx.InProgressStats[0]) assertMeasurementStats(t, &expectedStats[0], &ctx.CompletedStats[0]) } -func TestStreamingPacketsMultipleCalls(t *testing.T) { +func Test_OutputInfinite_SingleProbe_MultipleCalls(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -106,14 +109,15 @@ func TestStreamingPacketsMultipleCalls(t *testing.T) { os.Stdout = osStdOut }() - fetcher := mocks.NewMockMeasurementsFetcher(ctrl) + gbMock := mocks.NewMockClient(ctrl) measurement := getPingGetMeasurement(measurementID1) - fetcher.EXPECT().GetMeasurement(measurementID1).Times(0).Return(measurement, nil) + gbMock.EXPECT().GetMeasurement(measurementID1).Times(3).Return(measurement, nil) - ctx := &model.Context{ + ctx := &Context{ Cmd: "ping", MaxHistory: 3, } + viewer := NewViewer(ctx, gbMock) r, w, err := os.Pipe() assert.NoError(t, err) @@ -123,11 +127,11 @@ func TestStreamingPacketsMultipleCalls(t *testing.T) { }() os.Stdout = w - err = outputStreamingPackets(fetcher, measurement, ctx) + err = viewer.OutputInfinite(measurement.ID) assert.NoError(t, err) - err = outputStreamingPackets(fetcher, measurement, ctx) + err = viewer.OutputInfinite(measurement.ID) assert.NoError(t, err) - err = outputStreamingPackets(fetcher, measurement, ctx) + err = viewer.OutputInfinite(measurement.ID) assert.NoError(t, err) w.Close() os.Stdout = osStdOut @@ -144,13 +148,13 @@ PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. `, string(output)) - expectedStats := []model.MeasurementStats{{Sent: 3, Rcv: 3, Lost: 0, Loss: 0, Last: 17.6, Min: 17.6, + expectedStats := []MeasurementStats{{Sent: 3, Rcv: 3, Lost: 0, Loss: 0, Last: 17.6, Min: 17.6, Avg: 17.6, Max: 17.6, Time: 3000, Tsum: 52.8, Tsum2: 929.28, Mdev: 0}} assertMeasurementStats(t, &expectedStats[0], &ctx.InProgressStats[0]) assertMeasurementStats(t, &expectedStats[0], &ctx.CompletedStats[0]) } -func TestOutputTableViewMultipleCalls(t *testing.T) { +func Test_OutputInfinite_MultipleProbes_MultipleCalls(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -159,11 +163,7 @@ func TestOutputTableViewMultipleCalls(t *testing.T) { os.Stdout = osStdOut }() - ctx := &model.Context{ - Cmd: "ping", - APIMinInterval: 0, - } - fetcher := mocks.NewMockMeasurementsFetcher(ctrl) + gbMock := mocks.NewMockClient(ctrl) res := getPingGetMeasurementMultipleLocations(measurementID1) rawOutput1 := `PING (146.75.73.229) 56(84) bytes of data.` @@ -179,30 +179,35 @@ no answer yet for icmp_seq=2 --- ping statistics --- 3 packets transmitted, 3 received, 0% packet loss, time 2002ms rtt min/avg/max/mdev = 17.006/17.333/17.648/0.321 ms` - expectedCtx := getDefaultPingCtx(len(res.Results)) + + expectedViewer := &viewer{ + ctx: getDefaultPingCtx(len(res.Results)), + } expectedTables := [6]*string{} - callCount := 1 // 1st call is done in the caller. - fetcher.EXPECT().GetMeasurement(measurementID1).DoAndReturn(func(id string) (*model.GetMeasurement, error) { + callCount := 0 // 1st call is done in the caller. + gbMock.EXPECT().GetMeasurement(measurementID1).DoAndReturn(func(id string) (*globalping.Measurement, error) { callCount++ switch callCount { + case 1, 4: + res.Results[0].Result.RawOutput = rawOutput1 case 2, 5: res.Results[0].Result.RawOutput = rawOutput2 - expectedTables[callCount-1], _ = generateTable(res, expectedCtx, 78) + expectedTables[callCount-1], _ = expectedViewer.generateTable(res, 78) case 3, 6: - res.Status = model.StatusFinished - res.Results[0].Result.Status = model.StatusFinished + res.Status = globalping.StatusFinished + res.Results[0].Result.Status = globalping.StatusFinished res.Results[0].Result.RawOutput = rawOutputFinal - expectedTables[callCount-1], _ = generateTable(res, expectedCtx, 78) + expectedTables[callCount-1], _ = expectedViewer.generateTable(res, 78) } return res, nil - }).Times(4) + }).Times(6) // 1st call - res.Status = model.StatusInProgress - res.Results[0].Result.Status = model.StatusInProgress + res.Status = globalping.StatusInProgress + res.Results[0].Result.Status = globalping.StatusInProgress res.Results[0].Result.RawOutput = rawOutput1 - expectedTables[0], _ = generateTable(res, expectedCtx, 78) + expectedTables[0], _ = expectedViewer.generateTable(res, 78) r, w, err := os.Pipe() assert.NoError(t, err) @@ -212,10 +217,15 @@ rtt min/avg/max/mdev = 17.006/17.333/17.648/0.321 ms` }() os.Stdout = w - err = outputTableView(fetcher, res, ctx) + ctx := &Context{ + Cmd: "ping", + MaxHistory: 3, + } + viewer := NewViewer(ctx, gbMock) + err = viewer.OutputInfinite(measurementID1) assert.NoError(t, err) - firstCallStats := []model.MeasurementStats{ + firstCallStats := []MeasurementStats{ {Sent: 3, Rcv: 3, Lost: 0, Loss: 0, Last: 17, Min: 17, Avg: 17.3, Max: 17.6, Time: 2002, Tsum: 51.9, Tsum2: 898.05, Mdev: 0.2449}, {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.46, Avg: 5.46, Max: 5.46, Time: 200, Tsum: 5.46, Tsum2: 29.8116}, {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.07, Avg: 4.07, Max: 4.07, Time: 300, Tsum: 4.07, Tsum2: 16.5649}, @@ -226,14 +236,13 @@ rtt min/avg/max/mdev = 17.006/17.333/17.648/0.321 ms` } // 2nd call - res.Status = model.StatusInProgress - res.Results[0].Result.Status = model.StatusInProgress + res.Status = globalping.StatusInProgress + res.Results[0].Result.Status = globalping.StatusInProgress res.Results[0].Result.RawOutput = rawOutput1 - expectedCtx.CompletedStats = firstCallStats + expectedViewer.ctx.CompletedStats = firstCallStats - callCount++ - expectedTables[3], _ = generateTable(res, expectedCtx, 78) - err = outputTableView(fetcher, res, ctx) + expectedTables[3], _ = expectedViewer.generateTable(res, 78) + err = viewer.OutputInfinite(measurementID1) assert.NoError(t, err) w.Close() @@ -241,7 +250,7 @@ rtt min/avg/max/mdev = 17.006/17.333/17.648/0.321 ms` output, err := io.ReadAll(r) assert.NoError(t, err) - secondCallStats := []model.MeasurementStats{ + secondCallStats := []MeasurementStats{ {Sent: 6, Rcv: 6, Lost: 0, Loss: 0, Last: 17, Min: 17, Avg: 17.3, Max: 17.6, Time: 4004, Tsum: 103.8, Tsum2: 1796.1, Mdev: 0.2449}, {Sent: 2, Rcv: 2, Lost: 0, Loss: 0, Last: 5.46, Min: 5.46, Avg: 5.46, Max: 5.46, Time: 400, Tsum: 10.92, Tsum2: 59.6232}, {Sent: 2, Rcv: 2, Lost: 0, Loss: 0, Last: 4.07, Min: 4.07, Avg: 4.07, Max: 4.07, Time: 600, Tsum: 8.14, Tsum2: 33.1298}, @@ -274,7 +283,7 @@ rtt min/avg/max/mdev = 17.006/17.333/17.648/0.321 ms` assert.Equal(t, string(expectedOutput), string(output)) } -func TestOutputTableView(t *testing.T) { +func Test_OutputInfinite_MultipleProbes(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -283,12 +292,9 @@ func TestOutputTableView(t *testing.T) { os.Stdout = osStdOut }() - ctx := &model.Context{ - Cmd: "ping", - } measurement := getPingGetMeasurementMultipleLocations(measurementID1) - fetcher := mocks.NewMockMeasurementsFetcher(ctrl) - fetcher.EXPECT().GetMeasurement(measurementID1).Times(0).Return(measurement, nil) + gbMock := mocks.NewMockClient(ctrl) + gbMock.EXPECT().GetMeasurement(measurementID1).Times(1).Return(measurement, nil) r, w, err := os.Pipe() assert.NoError(t, err) @@ -298,7 +304,12 @@ func TestOutputTableView(t *testing.T) { }() os.Stdout = w - err = outputTableView(fetcher, measurement, ctx) + ctx := &Context{ + Cmd: "ping", + MaxHistory: 3, + } + v := NewViewer(ctx, gbMock) + err = v.OutputInfinite(measurementID1) assert.NoError(t, err) w.Close() @@ -316,8 +327,10 @@ func TestOutputTableView(t *testing.T) { }() os.Stdout = ww - expectedCtx := getDefaultPingCtx(len(measurement.Results)) - expectedTable, _ := generateTable(measurement, expectedCtx, 78) // 80 - 2. pterm defaults to 80 when terminal size is not detected. + expectedViewer := &viewer{ + ctx: getDefaultPingCtx(len(measurement.Results)), + } + expectedTable, _ := expectedViewer.generateTable(measurement, 78) area, _ := pterm.DefaultArea.Start() area.Update(*expectedTable) area.Stop() @@ -330,7 +343,7 @@ func TestOutputTableView(t *testing.T) { assert.Equal(t, string(expectedOutput), string(output)) assert.Equal(t, - []model.MeasurementStats{ + []MeasurementStats{ {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 100, Tsum: 0.77, Tsum2: 0.5929}, {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.46, Avg: 5.46, Max: 5.46, Time: 200, Tsum: 5.46, Tsum2: 29.8116}, {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.07, Avg: 4.07, Max: 4.07, Time: 300, Tsum: 4.07, Tsum2: 16.5649}, @@ -339,254 +352,7 @@ func TestOutputTableView(t *testing.T) { ) } -func TestOutputSummary(t *testing.T) { - - t.Run("No_stats", func(t *testing.T) { - osStdOut := os.Stdout - defer func() { - os.Stdout = osStdOut - }() - - r, w, err := os.Pipe() - assert.NoError(t, err) - defer func() { - w.Close() - r.Close() - }() - - ctx := &model.Context{} - os.Stdout = w - OutputSummary(ctx) - w.Close() - os.Stdout = osStdOut - - output, err := io.ReadAll(r) - assert.NoError(t, err) - r.Close() - assert.Equal(t, "", string(output)) - }) - - t.Run("With_stats_Single_location", func(t *testing.T) { - osStdOut := os.Stdout - defer func() { - os.Stdout = osStdOut - }() - - r, w, err := os.Pipe() - assert.NoError(t, err) - defer func() { - w.Close() - r.Close() - }() - - ctx := &model.Context{ - InProgressStats: []model.MeasurementStats{ - {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}, - }, - } - os.Stdout = w - OutputSummary(ctx) - w.Close() - os.Stdout = osStdOut - - output, err := io.ReadAll(r) - assert.NoError(t, err) - r.Close() - assert.Equal(t, ` ---- ping statistics --- -10 packets transmitted, 9 received, 10.00% packet loss, time 1000ms -rtt min/avg/max/mdev = 0.770/0.770/0.770/0.001 ms -`, - string(output)) - }) - - t.Run("With_stats_In_progress", func(t *testing.T) { - osStdOut := os.Stdout - defer func() { - os.Stdout = osStdOut - }() - - r, w, err := os.Pipe() - assert.NoError(t, err) - defer func() { - w.Close() - r.Close() - }() - - ctx := &model.Context{ - InProgressStats: []model.MeasurementStats{ - {Sent: 1, Rcv: 0, Lost: 1, Loss: 100, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1, Time: 0}, - }, - } - os.Stdout = w - OutputSummary(ctx) - w.Close() - os.Stdout = osStdOut - - output, err := io.ReadAll(r) - assert.NoError(t, err) - r.Close() - assert.Equal(t, ` ---- ping statistics --- -1 packets transmitted, 0 received, 100.00% packet loss, time 0ms -rtt min/avg/max/mdev = -/-/-/- ms -`, - string(output)) - }) - - t.Run("Multiple_locations", func(t *testing.T) { - osStdOut := os.Stdout - defer func() { - os.Stdout = osStdOut - }() - - r, w, err := os.Pipe() - assert.NoError(t, err) - defer func() { - w.Close() - r.Close() - }() - - ctx := &model.Context{ - InProgressStats: []model.MeasurementStats{ - model.NewMeasurementStats(), - model.NewMeasurementStats(), - }, - } - os.Stdout = w - OutputSummary(ctx) - w.Close() - os.Stdout = osStdOut - - output, err := io.ReadAll(r) - assert.NoError(t, err) - r.Close() - assert.Equal(t, "", string(output)) - }) - - t.Run("Single_location_Share", func(t *testing.T) { - osStdOut := os.Stdout - defer func() { - os.Stdout = osStdOut - }() - - r, w, err := os.Pipe() - assert.NoError(t, err) - defer func() { - w.Close() - r.Close() - }() - - ctx := &model.Context{ - History: &model.Rbuffer{ - Index: 0, - Slice: []string{measurementID1}, - }, - InProgressStats: []model.MeasurementStats{ - {Sent: 1, Rcv: 0, Lost: 1, Loss: 100, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1, Time: 0}, - }, - Share: true, - } - os.Stdout = w - OutputSummary(ctx) - w.Close() - os.Stdout = osStdOut - - output, err := io.ReadAll(r) - assert.NoError(t, err) - r.Close() - - expectedOutput := ` ---- ping statistics --- -1 packets transmitted, 0 received, 100.00% packet loss, time 0ms -rtt min/avg/max/mdev = -/-/-/- ms -` + formatWithLeadingArrow(shareMessage(measurementID1), true) + "\n" - - assert.Equal(t, expectedOutput, string(output)) - }) - - t.Run("Multiple_locations_Share", func(t *testing.T) { - osStdOut := os.Stdout - defer func() { - os.Stdout = osStdOut - }() - - r, w, err := os.Pipe() - assert.NoError(t, err) - defer func() { - w.Close() - r.Close() - }() - - ctx := &model.Context{ - History: &model.Rbuffer{ - Index: 0, - Slice: []string{measurementID1, measurementID2}, - }, - InProgressStats: []model.MeasurementStats{ - model.NewMeasurementStats(), - model.NewMeasurementStats(), - }, - Share: true, - } - os.Stdout = w - OutputSummary(ctx) - w.Close() - os.Stdout = osStdOut - - output, err := io.ReadAll(r) - assert.NoError(t, err) - r.Close() - - expectedOutput := "\n" + formatWithLeadingArrow(shareMessage(measurementID1+"+"+measurementID2), true) + "\n" - - assert.Equal(t, expectedOutput, string(output)) - }) - - t.Run("Multiple_locations_Share_More_calls_than_MaxHistory", func(t *testing.T) { - osStdOut := os.Stdout - defer func() { - os.Stdout = osStdOut - }() - - r, w, err := os.Pipe() - assert.NoError(t, err) - defer func() { - w.Close() - r.Close() - }() - - ctx := &model.Context{ - History: &model.Rbuffer{ - Index: 0, - Slice: []string{measurementID2}, - }, - InProgressStats: []model.MeasurementStats{ - model.NewMeasurementStats(), - model.NewMeasurementStats(), - }, - Share: true, - CallCount: 2, - MaxHistory: 1, - Packets: 16, - } - os.Stdout = w - OutputSummary(ctx) - w.Close() - os.Stdout = osStdOut - - output, err := io.ReadAll(r) - assert.NoError(t, err) - r.Close() - - expectedOutput := "\n" + formatWithLeadingArrow(shareMessage(measurementID2), true) + - "\nFor long-running continuous mode measurements, only the last 16 packets are shared.\n" - - assert.Equal(t, expectedOutput, string(output)) - }) -} - -func TestFormatDuration(t *testing.T) { +func Test_FormatDuration(t *testing.T) { d := formatDuration(1.2345) assert.Equal(t, "1.23 ms", d) d = formatDuration(12.345) @@ -595,90 +361,99 @@ func TestFormatDuration(t *testing.T) { assert.Equal(t, "123 ms", d) } -func TestGenerateTableFull(t *testing.T) { +func Test_GenerateTable_Full(t *testing.T) { measurement := getPingGetMeasurementMultipleLocations(measurementID1) - ctx := getDefaultPingCtx(len(measurement.Results)) expectedTable := "\x1b[96m\x1b[96mLocation \x1b[0m\x1b[0m | \x1b[96m\x1b[96mSent\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Loss\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Last\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Min\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Avg\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Max\x1b[0m\x1b[0m\n" + "EU, GB, London, ASN:0, OVH SAS | 1 | 0.00% | 0.77 ms | 0.77 ms | 0.77 ms | 0.77 ms\n" + "EU, DE, Falkenstein, ASN:0, Hetzner Online GmbH | 1 | 0.00% | 5.46 ms | 5.46 ms | 5.46 ms | 5.46 ms\n" + "EU, DE, Nuremberg, ASN:0, Hetzner Online GmbH | 1 | 0.00% | 4.07 ms | 4.07 ms | 4.07 ms | 4.07 ms\n" - table, stats := generateTable(measurement, ctx, 500) + + expectedViewer := &viewer{ + ctx: getDefaultPingCtx(len(measurement.Results)), + } + table, stats := expectedViewer.generateTable(measurement, 500) assert.Equal(t, expectedTable, *table) - assert.Equal(t, []model.MeasurementStats{ + assert.Equal(t, []MeasurementStats{ {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 100, Tsum: 0.77, Tsum2: 0.5929}, {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.46, Avg: 5.46, Max: 5.46, Time: 200, Tsum: 5.46, Tsum2: 29.8116}, {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.07, Avg: 4.07, Max: 4.07, Time: 300, Tsum: 4.07, Tsum2: 16.5649}, }, stats) } -func TestGenerateTableOneRowTruncated(t *testing.T) { +func Test_GenerateTable_OneRow_Truncated(t *testing.T) { measurement := getPingGetMeasurementMultipleLocations(measurementID1) measurement.Results[1].Probe.Network = "ä½œč€…čšé›†ēš„原创内容平台äŗŽ201 1幓1ęœˆę­£å¼äøŠēŗæ让äŗŗ们ꛓ" - ctx := getDefaultPingCtx(len(measurement.Results)) expectedTable := "\x1b[96m\x1b[96mLocation \x1b[0m\x1b[0m | \x1b[96m\x1b[96mSent\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Loss\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Last\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Min\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Avg\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Max\x1b[0m\x1b[0m\n" + "EU, GB, London, ASN:0, OVH SAS | 1 | 0.00% | 0.77 ms | 0.77 ms | 0.77 ms | 0.77 ms\n" + "EU, DE, Falkenstein, ASN:0, ä½œč€…čšé›†ēš„原创... | 1 | 0.00% | 5.46 ms | 5.46 ms | 5.46 ms | 5.46 ms\n" + "EU, DE, Nuremberg, ASN:0, Hetzner Online GmbH | 1 | 0.00% | 4.07 ms | 4.07 ms | 4.07 ms | 4.07 ms\n" - table, stats := generateTable(measurement, ctx, 106) + expectedViewer := &viewer{ + ctx: getDefaultPingCtx(len(measurement.Results)), + } + table, stats := expectedViewer.generateTable(measurement, 106) assert.Equal(t, expectedTable, *table) - assert.Equal(t, []model.MeasurementStats{ + assert.Equal(t, []MeasurementStats{ {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 100, Tsum: 0.77, Tsum2: 0.5929}, {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.46, Avg: 5.46, Max: 5.46, Time: 200, Tsum: 5.46, Tsum2: 29.8116}, {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.07, Avg: 4.07, Max: 4.07, Time: 300, Tsum: 4.07, Tsum2: 16.5649}, }, stats) } -func TestGenerateTableMultiLineTruncated(t *testing.T) { +func Test_GenerateTable_MultiLine_Truncated(t *testing.T) { measurement := getPingGetMeasurementMultipleLocations(measurementID1) measurement.Results[1].Probe.Network = "Hetzner Online GmbH\nLorem ipsum\nLorem ipsum dolor sit amet" - ctx := getDefaultPingCtx(len(measurement.Results)) expectedTable := "\x1b[96m\x1b[96mLocation \x1b[0m\x1b[0m | \x1b[96m\x1b[96mSent\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Loss\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Last\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Min\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Avg\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Max\x1b[0m\x1b[0m\n" + "EU, GB, London, ASN:0, OVH SAS | 1 | 0.00% | 0.77 ms | 0.77 ms | 0.77 ms | 0.77 ms\n" + "EU, DE, Falkenstein, ASN:0, Hetzner Online ... | 1 | 0.00% | 5.46 ms | 5.46 ms | 5.46 ms | 5.46 ms\n" + "Lorem ipsum | | | | | | \n" + "Lorem ipsum dolor sit amet | | | | | | \n" + "EU, DE, Nuremberg, ASN:0, Hetzner Online GmbH | 1 | 0.00% | 4.07 ms | 4.07 ms | 4.07 ms | 4.07 ms\n" - table, stats := generateTable(measurement, ctx, 106) + expectedViewer := &viewer{ + ctx: getDefaultPingCtx(len(measurement.Results)), + } + table, stats := expectedViewer.generateTable(measurement, 106) assert.Equal(t, expectedTable, *table) - assert.Equal(t, []model.MeasurementStats{ + assert.Equal(t, []MeasurementStats{ {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 100, Tsum: 0.77, Tsum2: 0.5929}, {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.46, Avg: 5.46, Max: 5.46, Time: 200, Tsum: 5.46, Tsum2: 29.8116}, {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.07, Avg: 4.07, Max: 4.07, Time: 300, Tsum: 4.07, Tsum2: 16.5649}, }, stats) } -func TestGenerateTableMaxTruncated(t *testing.T) { +func Test_GenerateTable_MaxTruncated(t *testing.T) { measurement := getPingGetMeasurementMultipleLocations(measurementID1) - ctx := getDefaultPingCtx(len(measurement.Results)) expectedTable := "\x1b[96m\x1b[96mLoc...\x1b[0m\x1b[0m | \x1b[96m\x1b[96mSent\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Loss\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Last\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Min\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Avg\x1b[0m\x1b[0m | \x1b[96m\x1b[96m Max\x1b[0m\x1b[0m\n" + "EU,... | 1 | 0.00% | 0.77 ms | 0.77 ms | 0.77 ms | 0.77 ms\n" + "EU,... | 1 | 0.00% | 5.46 ms | 5.46 ms | 5.46 ms | 5.46 ms\n" + "EU,... | 1 | 0.00% | 4.07 ms | 4.07 ms | 4.07 ms | 4.07 ms\n" - table, stats := generateTable(measurement, ctx, 0) + expectedViewer := &viewer{ + ctx: getDefaultPingCtx(len(measurement.Results)), + } + table, stats := expectedViewer.generateTable(measurement, 0) assert.Equal(t, expectedTable, *table) - assert.Equal(t, []model.MeasurementStats{ + assert.Equal(t, []MeasurementStats{ {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 100, Tsum: 0.77, Tsum2: 0.5929}, {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.46, Avg: 5.46, Max: 5.46, Time: 200, Tsum: 5.46, Tsum2: 29.8116}, {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.07, Avg: 4.07, Max: 4.07, Time: 300, Tsum: 4.07, Tsum2: 16.5649}, }, stats) } -func TestMergeMeasurementStats(t *testing.T) { - o := parsePingRawOutput(&model.MeasurementResponse{ - Result: model.ResultData{ +func Test_MergeMeasurementStats(t *testing.T) { + o := parsePingRawOutput(&globalping.ProbeMeasurement{ + Result: globalping.ProbeResult{ RawOutput: `PING (142.250.65.174) 56(84) bytes of data.`, }, }, 0) newStats := mergeMeasurementStats( - model.MeasurementStats{Sent: 0, Lost: 0, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1}, + MeasurementStats{Sent: 0, Lost: 0, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1}, o, ) assert.Equal(t, - model.MeasurementStats{Sent: 0, Lost: 0, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1}, + MeasurementStats{Sent: 0, Lost: 0, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1}, newStats, ) - o = parsePingRawOutput(&model.MeasurementResponse{ - Result: model.ResultData{ + o = parsePingRawOutput(&globalping.ProbeMeasurement{ + Result: globalping.ProbeResult{ RawOutput: `PING (142.250.65.174) 56(84) bytes of data. no answer yet for icmp_seq=1 64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=1 ttl=59 time=10 ms @@ -689,13 +464,13 @@ no answer yet for icmp_seq=4`, }, }, 0) newStats = mergeMeasurementStats( - model.MeasurementStats{Sent: 0, Lost: 0, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1}, + MeasurementStats{Sent: 0, Lost: 0, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1}, o) - assertMeasurementStats(t, &model.MeasurementStats{Sent: 4, Rcv: 3, Lost: 1, Loss: 25, Last: 30, Min: 10, + assertMeasurementStats(t, &MeasurementStats{Sent: 4, Rcv: 3, Lost: 1, Loss: 25, Last: 30, Min: 10, Avg: 20, Max: 30, Tsum: 60, Tsum2: 1400, Mdev: 8.1649}, &newStats) - o = parsePingRawOutput(&model.MeasurementResponse{ - Result: model.ResultData{ + o = parsePingRawOutput(&globalping.ProbeMeasurement{ + Result: globalping.ProbeResult{ RawOutput: `PING (142.250.65.174) 56(84) bytes of data. no answer yet for icmp_seq=1 64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=1 ttl=59 time=10 ms @@ -711,12 +486,12 @@ rtt min/avg/max/mdev = 10/20/30/0 ms`, }, }, 0) newStats = mergeMeasurementStats( - model.MeasurementStats{Sent: 0, Lost: 0, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1}, + MeasurementStats{Sent: 0, Lost: 0, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1}, o) - assertMeasurementStats(t, &model.MeasurementStats{Sent: 4, Rcv: 4, Lost: 0, Loss: 0, Last: 30, Min: 10, + assertMeasurementStats(t, &MeasurementStats{Sent: 4, Rcv: 4, Lost: 0, Loss: 0, Last: 30, Min: 10, Avg: 20, Max: 30, Time: 1000, Tsum: 80, Tsum2: 2000, Mdev: 10}, &newStats) - o = parsePingRawOutput(&model.MeasurementResponse{ - Result: model.ResultData{ + o = parsePingRawOutput(&globalping.ProbeMeasurement{ + Result: globalping.ProbeResult{ RawOutput: `PING (142.250.65.174) 56(84) bytes of data. 64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=1 ttl=59 time=10 ms 64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=1 ttl=59 time=20 ms @@ -728,10 +503,10 @@ rtt min/avg/max/mdev = 10/20/30/0 ms`, }, }, 0) newStats = mergeMeasurementStats( - model.MeasurementStats{Sent: 5, Rcv: 4, Lost: 1, Loss: 20, Last: 30, Min: 10, Avg: 20, Max: 30, + MeasurementStats{Sent: 5, Rcv: 4, Lost: 1, Loss: 20, Last: 30, Min: 10, Avg: 20, Max: 30, Time: 1000, Tsum: 80, Tsum2: 2000, Mdev: 10}, o) - assertMeasurementStats(t, &model.MeasurementStats{ + assertMeasurementStats(t, &MeasurementStats{ Sent: 8, Rcv: 7, Lost: 1, @@ -747,8 +522,8 @@ rtt min/avg/max/mdev = 10/20/30/0 ms`, }, &newStats) } -func TestGetRowValuesNoPacketsRcv(t *testing.T) { - stats := model.MeasurementStats{Sent: 1, Lost: 0, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1} +func Test_GetRowValues_NoPacketsRcv(t *testing.T) { + stats := MeasurementStats{Sent: 1, Lost: 0, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1} rowValues := getRowValues(&stats) assert.Equal(t, [7]string{ "", @@ -762,8 +537,8 @@ func TestGetRowValuesNoPacketsRcv(t *testing.T) { rowValues) } -func TestGetRowValues(t *testing.T) { - stats := model.MeasurementStats{ +func Test_GetRowValues(t *testing.T) { + stats := MeasurementStats{ Sent: 100, Lost: 10, Loss: 10, @@ -785,9 +560,9 @@ func TestGetRowValues(t *testing.T) { rowValues) } -func TestParsePingRawOutputFull(t *testing.T) { - m := &model.MeasurementResponse{ - Result: model.ResultData{ +func Test_ParsePingRawOutput_Full(t *testing.T) { + m := &globalping.ProbeMeasurement{ + Result: globalping.ProbeResult{ RawOutput: `PING cdn.jsdelivr.net (142.250.65.174) 56(84) bytes of data. 64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=1 ttl=59 time=1.06 ms 64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=2 ttl=59 time=1.10 ms @@ -802,12 +577,12 @@ rtt min/avg/max/mdev = 1.061/1.090/1.108/0.020 ms`, assert.Equal(t, "142.250.65.174", res.Address) assert.Equal(t, "56(84)", res.BytesOfData) assert.Nil(t, res.RawPacketLines) - assert.Equal(t, []model.PingTiming{ + assert.Equal(t, []globalping.PingTiming{ {RTT: 1.06, TTL: 59}, {RTT: 1.10, TTL: 59}, {RTT: 1.11, TTL: 59}, }, res.Timings) - assertMeasurementStats(t, &model.MeasurementStats{ + assertMeasurementStats(t, &MeasurementStats{ Sent: 3, Rcv: 3, Lost: 0, @@ -822,9 +597,9 @@ rtt min/avg/max/mdev = 1.061/1.090/1.108/0.020 ms`, }, res.Stats) } -func TestParsePingRawOutputNoStats(t *testing.T) { - m := &model.MeasurementResponse{ - Result: model.ResultData{ +func Test_ParsePingRawOutput_NoStats(t *testing.T) { + m := &globalping.ProbeMeasurement{ + Result: globalping.ProbeResult{ RawOutput: `PING (142.250.65.174) 56(84) bytes of data. no answer yet for icmp_seq=1 64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=1 ttl=59 time=1.06 ms @@ -838,12 +613,12 @@ no answer yet for icmp_seq=4`, assert.Equal(t, "142.250.65.174", res.Address) assert.Equal(t, "56(84)", res.BytesOfData) assert.Nil(t, res.RawPacketLines) - assert.Equal(t, []model.PingTiming{ + assert.Equal(t, []globalping.PingTiming{ {RTT: 1.06, TTL: 59}, {RTT: 1.10, TTL: 59}, {RTT: 1.11, TTL: 59}, }, res.Timings) - assertMeasurementStats(t, &model.MeasurementStats{ + assertMeasurementStats(t, &MeasurementStats{ Sent: 4, Rcv: 3, Lost: 1, @@ -858,9 +633,9 @@ no answer yet for icmp_seq=4`, }, res.Stats) } -func TestParsePingRawOutputNoStatsWithStartIncmpSeq(t *testing.T) { - m := &model.MeasurementResponse{ - Result: model.ResultData{ +func Test_ParsePingRawOutput_NoStats_WithStartIncmpSeq(t *testing.T) { + m := &globalping.ProbeMeasurement{ + Result: globalping.ProbeResult{ RawOutput: `PING (142.250.65.174) 56(84) bytes of data. no answer yet for icmp_seq=1 64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=1 ttl=59 time=1.06 ms @@ -881,12 +656,12 @@ no answer yet for icmp_seq=4`, "64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=7 ttl=59 time=1.11 ms", "no answer yet for icmp_seq=8", }, res.RawPacketLines) - assert.Equal(t, []model.PingTiming{ + assert.Equal(t, []globalping.PingTiming{ {RTT: 1.06, TTL: 59}, {RTT: 1.10, TTL: 59}, {RTT: 1.11, TTL: 59}, }, res.Timings) - assertMeasurementStats(t, &model.MeasurementStats{ + assertMeasurementStats(t, &MeasurementStats{ Sent: 4, Rcv: 3, Lost: 1, @@ -901,7 +676,7 @@ no answer yet for icmp_seq=4`, }, res.Stats) } -func TestComputeMdev(t *testing.T) { +func Test_ComputeMdev(t *testing.T) { rtt1 := 10.0 rtt2 := 10.0 rtt3 := 30.0 @@ -913,7 +688,7 @@ func TestComputeMdev(t *testing.T) { assert.InDelta(t, 10.0, mdev, 0.0001) } -func assertMeasurementStats(t *testing.T, expected *model.MeasurementStats, actual *model.MeasurementStats) { +func assertMeasurementStats(t *testing.T, expected *MeasurementStats, actual *MeasurementStats) { assert.Equal(t, expected.Sent, actual.Sent) assert.Equal(t, expected.Rcv, actual.Rcv) assert.Equal(t, expected.Lost, actual.Lost) diff --git a/view/json.go b/view/json.go index 5cb1f42..679f136 100644 --- a/view/json.go +++ b/view/json.go @@ -3,21 +3,18 @@ package view import ( "fmt" "os" - - "github.com/jsdelivr/globalping-cli/client" - "github.com/jsdelivr/globalping-cli/model" ) // Outputs the raw JSON for a measurement -func OutputJson(id string, fetcher client.MeasurementsFetcher, ctx model.Context) error { - output, err := fetcher.GetRawMeasurement(id) +func (v *viewer) OutputJson(id string) error { + output, err := v.gp.GetRawMeasurement(id) if err != nil { return err } fmt.Println(string(output)) - if ctx.Share { - fmt.Fprintln(os.Stderr, formatWithLeadingArrow(shareMessage(id), !ctx.CI)) + if v.ctx.Share { + fmt.Fprintln(os.Stderr, formatWithLeadingArrow(shareMessage(id), !v.ctx.CI)) } fmt.Println() diff --git a/view/json_test.go b/view/json_test.go index 95b2942..8273c7c 100644 --- a/view/json_test.go +++ b/view/json_test.go @@ -6,26 +6,27 @@ import ( "testing" "github.com/golang/mock/gomock" + "github.com/jsdelivr/globalping-cli/globalping" "github.com/jsdelivr/globalping-cli/mocks" - "github.com/jsdelivr/globalping-cli/model" "github.com/stretchr/testify/assert" ) -func TestOutputJson(t *testing.T) { +func Test_Output_Json(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - id := "my-id" - b := []byte(`{"fake": "results"}`) - fetcher := mocks.NewMockMeasurementsFetcher(ctrl) - fetcher.EXPECT().GetRawMeasurement(id).Times(1).Return(b, nil) + gbMock := mocks.NewMockClient(ctrl) + measurement := getPingGetMeasurement(measurementID1) + gbMock.EXPECT().GetMeasurement(measurementID1).Times(1).Return(measurement, nil) + gbMock.EXPECT().GetRawMeasurement(measurementID1).Times(1).Return(b, nil) + + viewer := NewViewer(&Context{ + ToJSON: true, + Share: true, + }, gbMock) - ctx := model.Context{ - JsonOutput: true, - Share: true, - } osStdErr := os.Stderr osStdOut := os.Stdout @@ -45,14 +46,15 @@ func TestOutputJson(t *testing.T) { os.Stdout = osStdOut }() - err = OutputJson(id, fetcher, ctx) + m := &globalping.MeasurementCreate{} + err = viewer.Output(measurementID1, m) assert.NoError(t, err) myStdOut.Close() myStdErr.Close() errContent, err := io.ReadAll(rStdErr) assert.NoError(t, err) - assert.Equal(t, "> View the results online: https://www.jsdelivr.com/globalping?measurement=my-id\n", string(errContent)) + assert.Equal(t, "> View the results online: https://www.jsdelivr.com/globalping?measurement=nzGzfAGL7sZfUs3c\n", string(errContent)) outContent, err := io.ReadAll(rStdOut) assert.NoError(t, err) diff --git a/view/latency.go b/view/latency.go index 2bcd8c8..46a6fc8 100644 --- a/view/latency.go +++ b/view/latency.go @@ -5,12 +5,11 @@ import ( "fmt" "os" - "github.com/jsdelivr/globalping-cli/client" - "github.com/jsdelivr/globalping-cli/model" + "github.com/jsdelivr/globalping-cli/globalping" ) // Outputs the latency stats for a measurement -func OutputLatency(id string, data *model.GetMeasurement, ctx model.Context) error { +func (v *viewer) OutputLatency(id string, data *globalping.Measurement) error { // Output every result in case of multiple probes for i, result := range data.Results { if i > 0 { @@ -18,48 +17,48 @@ func OutputLatency(id string, data *model.GetMeasurement, ctx model.Context) err fmt.Println() } - fmt.Fprintln(os.Stderr, generateProbeInfo(&result, !ctx.CI)) + fmt.Fprintln(os.Stderr, generateProbeInfo(&result, !v.ctx.CI)) - switch ctx.Cmd { + switch v.ctx.Cmd { case "ping": - stats, err := client.DecodePingStats(result.Result.StatsRaw) + stats, err := globalping.DecodePingStats(result.Result.StatsRaw) if err != nil { return err } - fmt.Println(latencyStatHeader("Min", ctx.CI) + fmt.Sprintf("%.2f ms", stats.Min)) - fmt.Println(latencyStatHeader("Max", ctx.CI) + fmt.Sprintf("%.2f ms", stats.Max)) - fmt.Println(latencyStatHeader("Avg", ctx.CI) + fmt.Sprintf("%.2f ms", stats.Avg)) + fmt.Println(v.latencyStatHeader("Min", v.ctx.CI) + fmt.Sprintf("%.2f ms", stats.Min)) + fmt.Println(v.latencyStatHeader("Max", v.ctx.CI) + fmt.Sprintf("%.2f ms", stats.Max)) + fmt.Println(v.latencyStatHeader("Avg", v.ctx.CI) + fmt.Sprintf("%.2f ms", stats.Avg)) case "dns": - timings, err := client.DecodeDNSTimings(result.Result.TimingsRaw) + timings, err := globalping.DecodeDNSTimings(result.Result.TimingsRaw) if err != nil { return err } - fmt.Println(latencyStatHeader("Total", ctx.CI) + fmt.Sprintf("%v ms", timings.Total)) + fmt.Println(v.latencyStatHeader("Total", v.ctx.CI) + fmt.Sprintf("%v ms", timings.Total)) case "http": - timings, err := client.DecodeHTTPTimings(result.Result.TimingsRaw) + timings, err := globalping.DecodeHTTPTimings(result.Result.TimingsRaw) if err != nil { return err } - fmt.Println(latencyStatHeader("Total", ctx.CI) + fmt.Sprintf("%v ms", timings.Total)) - fmt.Println(latencyStatHeader("Download", ctx.CI) + fmt.Sprintf("%v ms", timings.Download)) - fmt.Println(latencyStatHeader("First byte", ctx.CI) + fmt.Sprintf("%v ms", timings.FirstByte)) - fmt.Println(latencyStatHeader("DNS", ctx.CI) + fmt.Sprintf("%v ms", timings.DNS)) - fmt.Println(latencyStatHeader("TLS", ctx.CI) + fmt.Sprintf("%v ms", timings.TLS)) - fmt.Println(latencyStatHeader("TCP", ctx.CI) + fmt.Sprintf("%v ms", timings.TCP)) + fmt.Println(v.latencyStatHeader("Total", v.ctx.CI) + fmt.Sprintf("%v ms", timings.Total)) + fmt.Println(v.latencyStatHeader("Download", v.ctx.CI) + fmt.Sprintf("%v ms", timings.Download)) + fmt.Println(v.latencyStatHeader("First byte", v.ctx.CI) + fmt.Sprintf("%v ms", timings.FirstByte)) + fmt.Println(v.latencyStatHeader("DNS", v.ctx.CI) + fmt.Sprintf("%v ms", timings.DNS)) + fmt.Println(v.latencyStatHeader("TLS", v.ctx.CI) + fmt.Sprintf("%v ms", timings.TLS)) + fmt.Println(v.latencyStatHeader("TCP", v.ctx.CI) + fmt.Sprintf("%v ms", timings.TCP)) default: - return errors.New("unexpected command for latency output: " + ctx.Cmd) + return errors.New("unexpected command for latency output: " + v.ctx.Cmd) } } - if ctx.Share { - fmt.Fprintln(os.Stderr, formatWithLeadingArrow(shareMessage(id), !ctx.CI)) + if v.ctx.Share { + fmt.Fprintln(os.Stderr, formatWithLeadingArrow(shareMessage(id), !v.ctx.CI)) } fmt.Println() return nil } -func latencyStatHeader(title string, ci bool) string { +func (v *viewer) latencyStatHeader(title string, ci bool) string { text := fmt.Sprintf("%s: ", title) if ci { return text diff --git a/view/latency_test.go b/view/latency_test.go index 415eb23..d3af9ca 100644 --- a/view/latency_test.go +++ b/view/latency_test.go @@ -6,35 +6,20 @@ import ( "os" "testing" - "github.com/jsdelivr/globalping-cli/model" + "github.com/golang/mock/gomock" + "github.com/jsdelivr/globalping-cli/globalping" + "github.com/jsdelivr/globalping-cli/mocks" "github.com/stretchr/testify/assert" ) -func TestOutputLatency_Ping_Not_CI(t *testing.T) { - osStdErr := os.Stderr - osStdOut := os.Stdout - - rStdErr, myStdErr, err := os.Pipe() - assert.NoError(t, err) - defer rStdErr.Close() +func Test_Output_Latency_Ping_Not_CI(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() - rStdOut, myStdOut, err := os.Pipe() - assert.NoError(t, err) - defer rStdOut.Close() - - os.Stderr = myStdErr - os.Stdout = myStdOut - - defer func() { - os.Stderr = osStdErr - os.Stdout = osStdOut - }() - - id := "abc123" - data := &model.GetMeasurement{ - Results: []model.MeasurementResponse{ + measurement := &globalping.Measurement{ + Results: []globalping.ProbeMeasurement{ { - Probe: model.ProbeData{ + Probe: globalping.ProbeDetails{ Continent: "Continent", Country: "Country", State: "State", @@ -43,12 +28,12 @@ func TestOutputLatency_Ping_Not_CI(t *testing.T) { Network: "Network", Tags: []string{"tag-1"}, }, - Result: model.ResultData{ + Result: globalping.ProbeResult{ StatsRaw: json.RawMessage(`{"min":8,"avg":12,"max":20}`), }, }, { - Probe: model.ProbeData{ + Probe: globalping.ProbeDetails{ Continent: "Continent B", Country: "Country B", State: "State B", @@ -57,31 +42,16 @@ func TestOutputLatency_Ping_Not_CI(t *testing.T) { Network: "Network B", Tags: []string{"tag B"}, }, - Result: model.ResultData{ + Result: globalping.ProbeResult{ StatsRaw: json.RawMessage(`{"min":9,"avg":15,"max":22}`), }, }, }, } - ctx := model.Context{ - Cmd: "ping", - } - err = OutputLatency(id, data, ctx) - assert.NoError(t, err) - myStdOut.Close() - myStdErr.Close() + gbMock := mocks.NewMockClient(ctrl) + gbMock.EXPECT().GetMeasurement(measurementID1).Times(1).Return(measurement, nil) - errContent, err := io.ReadAll(rStdErr) - assert.NoError(t, err) - assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network (tag-1)\n> Continent B, Country B, (State B), City B, ASN:12349, Network B\n", string(errContent)) - - outContent, err := io.ReadAll(rStdOut) - assert.NoError(t, err) - assert.Equal(t, "Min: 8.00 ms\nMax: 20.00 ms\nAvg: 12.00 ms\n\nMin: 9.00 ms\nMax: 22.00 ms\nAvg: 15.00 ms\n\n", string(outContent)) -} - -func TestOutputLatency_Ping_CI(t *testing.T) { osStdErr := os.Stderr osStdOut := os.Stdout @@ -101,11 +71,33 @@ func TestOutputLatency_Ping_CI(t *testing.T) { os.Stdout = osStdOut }() - id := "abc123" - data := &model.GetMeasurement{ - Results: []model.MeasurementResponse{ + viewer := NewViewer(&Context{ + Cmd: "ping", + ToLatency: true, + }, gbMock) + + err = viewer.Output(measurementID1, &globalping.MeasurementCreate{}) + assert.NoError(t, err) + myStdOut.Close() + myStdErr.Close() + + errContent, err := io.ReadAll(rStdErr) + assert.NoError(t, err) + assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network (tag-1)\n> Continent B, Country B, (State B), City B, ASN:12349, Network B\n", string(errContent)) + + outContent, err := io.ReadAll(rStdOut) + assert.NoError(t, err) + assert.Equal(t, "Min: 8.00 ms\nMax: 20.00 ms\nAvg: 12.00 ms\n\nMin: 9.00 ms\nMax: 22.00 ms\nAvg: 15.00 ms\n\n", string(outContent)) +} + +func Test_Output_Latency_Ping_CI(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + measurement := &globalping.Measurement{ + Results: []globalping.ProbeMeasurement{ { - Probe: model.ProbeData{ + Probe: globalping.ProbeDetails{ Continent: "Continent", Country: "Country", State: "State", @@ -114,32 +106,16 @@ func TestOutputLatency_Ping_CI(t *testing.T) { Network: "Network", Tags: []string{"tag"}, }, - Result: model.ResultData{ + Result: globalping.ProbeResult{ StatsRaw: json.RawMessage(`{"min":8,"avg":12,"max":20}`), }, }, }, } - ctx := model.Context{ - Cmd: "ping", - CI: true, - } - - err = OutputLatency(id, data, ctx) - assert.NoError(t, err) - myStdOut.Close() - myStdErr.Close() - errContent, err := io.ReadAll(rStdErr) - assert.NoError(t, err) - assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network\n", string(errContent)) + gbMock := mocks.NewMockClient(ctrl) + gbMock.EXPECT().GetMeasurement(measurementID1).Times(1).Return(measurement, nil) - outContent, err := io.ReadAll(rStdOut) - assert.NoError(t, err) - assert.Equal(t, "Min: 8.00 ms\nMax: 20.00 ms\nAvg: 12.00 ms\n\n", string(outContent)) -} - -func TestOutputLatency_DNS_Not_CI(t *testing.T) { osStdErr := os.Stderr osStdOut := os.Stdout @@ -159,11 +135,34 @@ func TestOutputLatency_DNS_Not_CI(t *testing.T) { os.Stdout = osStdOut }() - id := "abc123" - data := &model.GetMeasurement{ - Results: []model.MeasurementResponse{ + viewer := NewViewer(&Context{ + Cmd: "ping", + ToLatency: true, + CI: true, + }, gbMock) + + err = viewer.Output(measurementID1, &globalping.MeasurementCreate{}) + assert.NoError(t, err) + myStdOut.Close() + myStdErr.Close() + + errContent, err := io.ReadAll(rStdErr) + assert.NoError(t, err) + assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network\n", string(errContent)) + + outContent, err := io.ReadAll(rStdOut) + assert.NoError(t, err) + assert.Equal(t, "Min: 8.00 ms\nMax: 20.00 ms\nAvg: 12.00 ms\n\n", string(outContent)) +} + +func Test_Output_Latency_DNS_Not_CI(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + measurement := &globalping.Measurement{ + Results: []globalping.ProbeMeasurement{ { - Probe: model.ProbeData{ + Probe: globalping.ProbeDetails{ Continent: "Continent", Country: "Country", State: "State", @@ -172,31 +171,16 @@ func TestOutputLatency_DNS_Not_CI(t *testing.T) { Network: "Network", Tags: []string{"tag"}, }, - Result: model.ResultData{ + Result: globalping.ProbeResult{ TimingsRaw: []byte(`{"total": 44}`), }, }, }, } - ctx := model.Context{ - Cmd: "dns", - } - - err = OutputLatency(id, data, ctx) - assert.NoError(t, err) - myStdOut.Close() - myStdErr.Close() - errContent, err := io.ReadAll(rStdErr) - assert.NoError(t, err) - assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network\n", string(errContent)) + gbMock := mocks.NewMockClient(ctrl) + gbMock.EXPECT().GetMeasurement(measurementID1).Times(1).Return(measurement, nil) - outContent, err := io.ReadAll(rStdOut) - assert.NoError(t, err) - assert.Equal(t, "Total: 44 ms\n\n", string(outContent)) -} - -func TestOutputLatency_DNS_CI(t *testing.T) { osStdErr := os.Stderr osStdOut := os.Stdout @@ -216,11 +200,33 @@ func TestOutputLatency_DNS_CI(t *testing.T) { os.Stdout = osStdOut }() - id := "abc123" - data := &model.GetMeasurement{ - Results: []model.MeasurementResponse{ + viewer := NewViewer(&Context{ + Cmd: "dns", + ToLatency: true, + }, gbMock) + + err = viewer.Output(measurementID1, &globalping.MeasurementCreate{}) + assert.NoError(t, err) + myStdOut.Close() + myStdErr.Close() + + errContent, err := io.ReadAll(rStdErr) + assert.NoError(t, err) + assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network\n", string(errContent)) + + outContent, err := io.ReadAll(rStdOut) + assert.NoError(t, err) + assert.Equal(t, "Total: 44 ms\n\n", string(outContent)) +} + +func Test_Output_Latency_DNS_CI(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + measurement := &globalping.Measurement{ + Results: []globalping.ProbeMeasurement{ { - Probe: model.ProbeData{ + Probe: globalping.ProbeDetails{ Continent: "Continent", Country: "Country", State: "State", @@ -229,32 +235,16 @@ func TestOutputLatency_DNS_CI(t *testing.T) { Network: "Network", Tags: []string{"tag"}, }, - Result: model.ResultData{ + Result: globalping.ProbeResult{ TimingsRaw: []byte(`{"total": 44}`), }, }, }, } - ctx := model.Context{ - Cmd: "dns", - CI: true, - } - err = OutputLatency(id, data, ctx) - assert.NoError(t, err) - myStdOut.Close() - myStdErr.Close() + gbMock := mocks.NewMockClient(ctrl) + gbMock.EXPECT().GetMeasurement(measurementID1).Times(1).Return(measurement, nil) - errContent, err := io.ReadAll(rStdErr) - assert.NoError(t, err) - assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network\n", string(errContent)) - - outContent, err := io.ReadAll(rStdOut) - assert.NoError(t, err) - assert.Equal(t, "Total: 44 ms\n\n", string(outContent)) -} - -func TestOutputLatency_Http_Not_CI(t *testing.T) { osStdErr := os.Stderr osStdOut := os.Stdout @@ -274,11 +264,34 @@ func TestOutputLatency_Http_Not_CI(t *testing.T) { os.Stdout = osStdOut }() - id := "abc123" - data := &model.GetMeasurement{ - Results: []model.MeasurementResponse{ + viewer := NewViewer(&Context{ + Cmd: "dns", + ToLatency: true, + CI: true, + }, gbMock) + + err = viewer.Output(measurementID1, &globalping.MeasurementCreate{}) + assert.NoError(t, err) + myStdOut.Close() + myStdErr.Close() + + errContent, err := io.ReadAll(rStdErr) + assert.NoError(t, err) + assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network\n", string(errContent)) + + outContent, err := io.ReadAll(rStdOut) + assert.NoError(t, err) + assert.Equal(t, "Total: 44 ms\n\n", string(outContent)) +} + +func Test_Output_Latency_Http_Not_CI(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + measurement := &globalping.Measurement{ + Results: []globalping.ProbeMeasurement{ { - Probe: model.ProbeData{ + Probe: globalping.ProbeDetails{ Continent: "Continent", Country: "Country", State: "State", @@ -287,31 +300,16 @@ func TestOutputLatency_Http_Not_CI(t *testing.T) { Network: "Network", Tags: []string{"tag"}, }, - Result: model.ResultData{ + Result: globalping.ProbeResult{ TimingsRaw: []byte(`{"total": 44,"download":11,"firstByte":20,"dns":5,"tls":2,"tcp":4}`), }, }, }, } - ctx := model.Context{ - Cmd: "http", - } - - err = OutputLatency(id, data, ctx) - assert.NoError(t, err) - myStdOut.Close() - myStdErr.Close() - errContent, err := io.ReadAll(rStdErr) - assert.NoError(t, err) - assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network\n", string(errContent)) - - outContent, err := io.ReadAll(rStdOut) - assert.NoError(t, err) - assert.Equal(t, "Total: 44 ms\nDownload: 11 ms\nFirst byte: 20 ms\nDNS: 5 ms\nTLS: 2 ms\nTCP: 4 ms\n\n", string(outContent)) -} + gbMock := mocks.NewMockClient(ctrl) + gbMock.EXPECT().GetMeasurement(measurementID1).Times(1).Return(measurement, nil) -func TestOutputLatency_Http_CI(t *testing.T) { osStdErr := os.Stderr osStdOut := os.Stdout @@ -331,11 +329,33 @@ func TestOutputLatency_Http_CI(t *testing.T) { os.Stdout = osStdOut }() - id := "abc123" - data := &model.GetMeasurement{ - Results: []model.MeasurementResponse{ + viewer := NewViewer(&Context{ + Cmd: "http", + ToLatency: true, + }, gbMock) + + err = viewer.Output(measurementID1, &globalping.MeasurementCreate{}) + assert.NoError(t, err) + myStdOut.Close() + myStdErr.Close() + + errContent, err := io.ReadAll(rStdErr) + assert.NoError(t, err) + assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network\n", string(errContent)) + + outContent, err := io.ReadAll(rStdOut) + assert.NoError(t, err) + assert.Equal(t, "Total: 44 ms\nDownload: 11 ms\nFirst byte: 20 ms\nDNS: 5 ms\nTLS: 2 ms\nTCP: 4 ms\n\n", string(outContent)) +} + +func Test_Output_Latency_Http_CI(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + measurement := &globalping.Measurement{ + Results: []globalping.ProbeMeasurement{ { - Probe: model.ProbeData{ + Probe: globalping.ProbeDetails{ Continent: "Continent", Country: "Country", State: "State", @@ -344,18 +364,42 @@ func TestOutputLatency_Http_CI(t *testing.T) { Network: "Network", Tags: []string{"tag"}, }, - Result: model.ResultData{ + Result: globalping.ProbeResult{ TimingsRaw: []byte(`{"total": 44,"download":11,"firstByte":20,"dns":5,"tls":2,"tcp":4}`), }, }, }, } - ctx := model.Context{ - Cmd: "http", - CI: true, - } - err = OutputLatency(id, data, ctx) + gbMock := mocks.NewMockClient(ctrl) + gbMock.EXPECT().GetMeasurement(measurementID1).Times(1).Return(measurement, nil) + + osStdErr := os.Stderr + osStdOut := os.Stdout + + rStdErr, myStdErr, err := os.Pipe() + assert.NoError(t, err) + defer rStdErr.Close() + + rStdOut, myStdOut, err := os.Pipe() + assert.NoError(t, err) + defer rStdOut.Close() + + os.Stderr = myStdErr + os.Stdout = myStdOut + + defer func() { + os.Stderr = osStdErr + os.Stdout = osStdOut + }() + + viewer := NewViewer(&Context{ + Cmd: "http", + ToLatency: true, + CI: true, + }, gbMock) + + err = viewer.Output(measurementID1, &globalping.MeasurementCreate{}) assert.NoError(t, err) myStdOut.Close() myStdErr.Close() diff --git a/view/summary.go b/view/summary.go new file mode 100644 index 0000000..c60c525 --- /dev/null +++ b/view/summary.go @@ -0,0 +1,55 @@ +package view + +import ( + "fmt" + "math" +) + +func (v *viewer) OutputSummary() { + if len(v.ctx.InProgressStats) == 0 { + return + } + + if len(v.ctx.InProgressStats) == 1 { + stats := v.ctx.InProgressStats[0] + + fmt.Printf("\n--- %s ping statistics ---\n", v.ctx.Hostname) + fmt.Printf("%d packets transmitted, %d received, %.2f%% packet loss, time %.0fms\n", + stats.Sent, + stats.Rcv, + stats.Loss, + stats.Time, + ) + min := "-" + avg := "-" + max := "-" + mdev := "-" + if stats.Min != math.MaxFloat64 { + min = fmt.Sprintf("%.3f", stats.Min) + } + if stats.Avg != -1 { + avg = fmt.Sprintf("%.3f", stats.Avg) + } + if stats.Max != -1 { + max = fmt.Sprintf("%.3f", stats.Max) + } + if stats.Mdev != 0 { + mdev = fmt.Sprintf("%.3f", stats.Mdev) + } + fmt.Printf("rtt min/avg/max/mdev = %s/%s/%s/%s ms\n", min, avg, max, mdev) + } + + if v.ctx.Share && v.ctx.History != nil { + if len(v.ctx.InProgressStats) > 1 { + fmt.Println() + } + ids := v.ctx.History.ToString("+") + if ids != "" { + fmt.Println(formatWithLeadingArrow(shareMessage(ids), !v.ctx.CI)) + } + if v.ctx.CallCount > v.ctx.MaxHistory { + fmt.Printf("For long-running continuous mode measurements, only the last %d packets are shared.\n", + v.ctx.Packets*v.ctx.MaxHistory) + } + } +} diff --git a/view/summary_test.go b/view/summary_test.go new file mode 100644 index 0000000..ad849df --- /dev/null +++ b/view/summary_test.go @@ -0,0 +1,262 @@ +package view + +import ( + "io" + "math" + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_OutputSummary(t *testing.T) { + t.Run("No_stats", func(t *testing.T) { + osStdOut := os.Stdout + defer func() { + os.Stdout = osStdOut + }() + + r, w, err := os.Pipe() + assert.NoError(t, err) + defer func() { + w.Close() + r.Close() + }() + + viewer := NewViewer(&Context{}, nil) + os.Stdout = w + viewer.OutputSummary() + w.Close() + os.Stdout = osStdOut + + output, err := io.ReadAll(r) + assert.NoError(t, err) + r.Close() + assert.Equal(t, "", string(output)) + }) + + t.Run("With_stats_Single_location", func(t *testing.T) { + osStdOut := os.Stdout + defer func() { + os.Stdout = osStdOut + }() + + r, w, err := os.Pipe() + assert.NoError(t, err) + defer func() { + w.Close() + r.Close() + }() + + ctx := &Context{ + InProgressStats: []MeasurementStats{ + {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, nil) + os.Stdout = w + viewer.OutputSummary() + w.Close() + os.Stdout = osStdOut + + output, err := io.ReadAll(r) + assert.NoError(t, err) + r.Close() + assert.Equal(t, ` +--- ping statistics --- +10 packets transmitted, 9 received, 10.00% packet loss, time 1000ms +rtt min/avg/max/mdev = 0.770/0.770/0.770/0.001 ms +`, + string(output)) + }) + + t.Run("With_stats_In_progress", func(t *testing.T) { + osStdOut := os.Stdout + defer func() { + os.Stdout = osStdOut + }() + + r, w, err := os.Pipe() + assert.NoError(t, err) + defer func() { + w.Close() + r.Close() + }() + + ctx := &Context{ + InProgressStats: []MeasurementStats{ + {Sent: 1, Rcv: 0, Lost: 1, Loss: 100, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1, Time: 0}, + }, + } + viewer := NewViewer(ctx, nil) + os.Stdout = w + viewer.OutputSummary() + w.Close() + os.Stdout = osStdOut + + output, err := io.ReadAll(r) + assert.NoError(t, err) + r.Close() + assert.Equal(t, ` +--- ping statistics --- +1 packets transmitted, 0 received, 100.00% packet loss, time 0ms +rtt min/avg/max/mdev = -/-/-/- ms +`, + string(output)) + }) + + t.Run("Multiple_locations", func(t *testing.T) { + osStdOut := os.Stdout + defer func() { + os.Stdout = osStdOut + }() + + r, w, err := os.Pipe() + assert.NoError(t, err) + defer func() { + w.Close() + r.Close() + }() + + ctx := &Context{ + InProgressStats: []MeasurementStats{ + NewMeasurementStats(), + NewMeasurementStats(), + }, + } + viewer := NewViewer(ctx, nil) + os.Stdout = w + viewer.OutputSummary() + w.Close() + os.Stdout = osStdOut + + output, err := io.ReadAll(r) + assert.NoError(t, err) + r.Close() + assert.Equal(t, "", string(output)) + }) + + t.Run("Single_location_Share", func(t *testing.T) { + osStdOut := os.Stdout + defer func() { + os.Stdout = osStdOut + }() + + r, w, err := os.Pipe() + assert.NoError(t, err) + defer func() { + w.Close() + r.Close() + }() + + ctx := &Context{ + History: &Rbuffer{ + Index: 0, + Slice: []string{measurementID1}, + }, + InProgressStats: []MeasurementStats{ + {Sent: 1, Rcv: 0, Lost: 1, Loss: 100, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1, Time: 0}, + }, + Share: true, + } + viewer := NewViewer(ctx, nil) + os.Stdout = w + viewer.OutputSummary() + w.Close() + os.Stdout = osStdOut + + output, err := io.ReadAll(r) + assert.NoError(t, err) + r.Close() + + expectedOutput := ` +--- ping statistics --- +1 packets transmitted, 0 received, 100.00% packet loss, time 0ms +rtt min/avg/max/mdev = -/-/-/- ms +` + formatWithLeadingArrow(shareMessage(measurementID1), true) + "\n" + + assert.Equal(t, expectedOutput, string(output)) + }) + + t.Run("Multiple_locations_Share", func(t *testing.T) { + osStdOut := os.Stdout + defer func() { + os.Stdout = osStdOut + }() + + r, w, err := os.Pipe() + assert.NoError(t, err) + defer func() { + w.Close() + r.Close() + }() + + ctx := &Context{ + History: &Rbuffer{ + Index: 0, + Slice: []string{measurementID1, measurementID2}, + }, + InProgressStats: []MeasurementStats{ + NewMeasurementStats(), + NewMeasurementStats(), + }, + Share: true, + } + viewer := NewViewer(ctx, nil) + os.Stdout = w + viewer.OutputSummary() + w.Close() + os.Stdout = osStdOut + + output, err := io.ReadAll(r) + assert.NoError(t, err) + r.Close() + + expectedOutput := "\n" + formatWithLeadingArrow(shareMessage(measurementID1+"+"+measurementID2), true) + "\n" + + assert.Equal(t, expectedOutput, string(output)) + }) + + t.Run("Multiple_locations_Share_More_calls_than_MaxHistory", func(t *testing.T) { + osStdOut := os.Stdout + defer func() { + os.Stdout = osStdOut + }() + + r, w, err := os.Pipe() + assert.NoError(t, err) + defer func() { + w.Close() + r.Close() + }() + + ctx := &Context{ + History: &Rbuffer{ + Index: 0, + Slice: []string{measurementID2}, + }, + InProgressStats: []MeasurementStats{ + NewMeasurementStats(), + NewMeasurementStats(), + }, + Share: true, + CallCount: 2, + MaxHistory: 1, + Packets: 16, + } + viewer := NewViewer(ctx, nil) + os.Stdout = w + viewer.OutputSummary() + w.Close() + os.Stdout = osStdOut + + output, err := io.ReadAll(r) + assert.NoError(t, err) + r.Close() + + expectedOutput := "\n" + formatWithLeadingArrow(shareMessage(measurementID2), true) + + "\nFor long-running continuous mode measurements, only the last 16 packets are shared.\n" + + assert.Equal(t, expectedOutput, string(output)) + }) +} diff --git a/view/utils_test.go b/view/utils_test.go index 5ae36c6..871790f 100644 --- a/view/utils_test.go +++ b/view/utils_test.go @@ -4,7 +4,7 @@ import ( "encoding/json" "math" - "github.com/jsdelivr/globalping-cli/model" + "github.com/jsdelivr/globalping-cli/globalping" ) var ( @@ -13,18 +13,18 @@ var ( // measurementID3 = "7sZfUs3cnzGz1I20" ) -func getPingGetMeasurement(id string) *model.GetMeasurement { - return &model.GetMeasurement{ +func getPingGetMeasurement(id string) *globalping.Measurement { + return &globalping.Measurement{ ID: id, Type: "ping", - Status: model.StatusFinished, + Status: globalping.StatusFinished, CreatedAt: "2024-01-18T14:09:41.250Z", UpdatedAt: "2024-01-18T14:09:41.488Z", Target: "cdn.jsdelivr.net", ProbesCount: 1, - Results: []model.MeasurementResponse{ + Results: []globalping.ProbeMeasurement{ { - Probe: model.ProbeData{ + Probe: globalping.ProbeDetails{ Continent: "EU", Region: "Western Europe", Country: "DE", @@ -34,8 +34,8 @@ func getPingGetMeasurement(id string) *model.GetMeasurement { Network: "Deutsche Telekom AG", Tags: []string{"eyeball-network"}, }, - Result: model.ResultData{ - Status: model.StatusFinished, + Result: globalping.ProbeResult{ + Status: globalping.StatusFinished, RawOutput: `PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. 64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=60 time=17.6 ms @@ -52,18 +52,18 @@ rtt min/avg/max/mdev = 17.639/17.639/17.639/0.000 ms`, } } -func getPingGetMeasurementMultipleLocations(id string) *model.GetMeasurement { - return &model.GetMeasurement{ +func getPingGetMeasurementMultipleLocations(id string) *globalping.Measurement { + return &globalping.Measurement{ ID: id, Type: "ping", - Status: model.StatusFinished, + Status: globalping.StatusFinished, CreatedAt: "2024-01-18T14:17:41.471Z", UpdatedAt: "2024-01-18T14:17:41.571Z", Target: "cdn.jsdelivr.net", ProbesCount: 3, - Results: []model.MeasurementResponse{ + Results: []globalping.ProbeMeasurement{ { - Probe: model.ProbeData{ + Probe: globalping.ProbeDetails{ Continent: "EU", Region: "Northern Europe", Country: "GB", @@ -72,8 +72,8 @@ func getPingGetMeasurementMultipleLocations(id string) *model.GetMeasurement { Network: "OVH SAS", Tags: []string{"datacenter-network"}, }, - Result: model.ResultData{ - Status: model.StatusFinished, + Result: globalping.ProbeResult{ + Status: globalping.StatusFinished, RawOutput: `PING (146.75.73.229) 56(84) bytes of data. 64 bytes from 146.75.73.229 (146.75.73.229): icmp_seq=1 ttl=52 time=0.770 ms @@ -87,7 +87,7 @@ rtt min/avg/max/mdev = 0.770/0.770/0.770/0.001 ms`, }, }, { - Probe: model.ProbeData{ + Probe: globalping.ProbeDetails{ Continent: "EU", Region: "Western Europe", Country: "DE", @@ -96,8 +96,8 @@ rtt min/avg/max/mdev = 0.770/0.770/0.770/0.001 ms`, Network: "Hetzner Online GmbH", Tags: []string{"datacenter-network"}, }, - Result: model.ResultData{ - Status: model.StatusFinished, + Result: globalping.ProbeResult{ + Status: globalping.StatusFinished, RawOutput: `PING (104.16.85.20) 56(84) bytes of data. 64 bytes from 104.16.85.20 (104.16.85.20): icmp_seq=1 ttl=55 time=5.46 ms @@ -111,7 +111,7 @@ rtt min/avg/max/mdev = 5.457/5.457/5.457/0.002 ms`, }, }, { - Probe: model.ProbeData{ + Probe: globalping.ProbeDetails{ Continent: "EU", Region: "Western Europe", Country: "DE", @@ -120,8 +120,8 @@ rtt min/avg/max/mdev = 5.457/5.457/5.457/0.002 ms`, Network: "Hetzner Online GmbH", Tags: []string{"datacenter-network"}, }, - Result: model.ResultData{ - Status: model.StatusFinished, + Result: globalping.ProbeResult{ + Status: globalping.StatusFinished, RawOutput: `PING (104.16.88.20) 56(84) bytes of data. 64 bytes from 104.16.88.20 (104.16.88.20): icmp_seq=1 ttl=58 time=4.07 ms @@ -138,11 +138,11 @@ rtt min/avg/max/mdev = 4.069/4.069/4.069/0.003 ms`, } } -func getDefaultPingCtx(size int) *model.Context { - ctx := &model.Context{ +func getDefaultPingCtx(size int) *Context { + ctx := &Context{ Cmd: "ping", APIMinInterval: 0, - CompletedStats: make([]model.MeasurementStats, size), + CompletedStats: make([]MeasurementStats, size), } for i := range ctx.CompletedStats { ctx.CompletedStats[i].Last = -1 diff --git a/view/view.go b/view/view.go index 9869741..7a29f12 100644 --- a/view/view.go +++ b/view/view.go @@ -7,8 +7,7 @@ import ( "time" "github.com/charmbracelet/lipgloss" - "github.com/jsdelivr/globalping-cli/client" - "github.com/jsdelivr/globalping-cli/model" + "github.com/jsdelivr/globalping-cli/globalping" "github.com/mattn/go-runewidth" "github.com/pterm/pterm" ) @@ -23,51 +22,70 @@ var ( terminalLayoutBold = lipgloss.NewStyle().Bold(true) ) -func OutputResults(id string, ctx model.Context, m model.PostMeasurement) error { - fetcher := client.NewMeasurementsFetcher(client.ApiUrl) +type Viewer interface { + Output(id string, m *globalping.MeasurementCreate) error + OutputInfinite(id string) error + OutputSummary() +} + +type viewer struct { + ctx *Context + gp globalping.Client +} +func NewViewer( + ctx *Context, + gp globalping.Client, +) Viewer { + return &viewer{ + ctx: ctx, + gp: gp, + } +} + +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 := fetcher.GetMeasurement(id) + data, err := v.gp.GetMeasurement(id) if err != nil { return err } // Probe may not have started yet for len(data.Results) == 0 { - time.Sleep(ctx.APIMinInterval) - data, err = fetcher.GetMeasurement(id) + time.Sleep(v.ctx.APIMinInterval) + data, err = v.gp.GetMeasurement(id) if err != nil { return err } } - if ctx.CI || ctx.JsonOutput || ctx.Latency { + if v.ctx.CI || v.ctx.ToJSON || v.ctx.ToLatency { // Poll API until the measurement is complete - for data.Status == model.StatusInProgress { - time.Sleep(ctx.APIMinInterval) - data, err = fetcher.GetMeasurement(id) + for data.Status == globalping.StatusInProgress { + time.Sleep(v.ctx.APIMinInterval) + data, err = v.gp.GetMeasurement(id) if err != nil { return err } } - if ctx.Latency { - return OutputLatency(id, data, ctx) + if v.ctx.ToLatency { + return v.OutputLatency(id, data) } - if ctx.JsonOutput { - return OutputJson(id, fetcher, ctx) + if v.ctx.ToJSON { + return v.OutputJson(id) } - if ctx.CI { - OutputDefault(id, data, ctx, m) + if v.ctx.CI { + v.outputDefault(id, data, m) return nil } } - return liveView(id, data, ctx, m) + return v.liveView(id, data, m) } -func liveView(id string, data *model.GetMeasurement, ctx model.Context, m model.PostMeasurement) error { +func (v *viewer) liveView(id string, data *globalping.Measurement, m *globalping.MeasurementCreate) error { var err error // Create new writer @@ -93,12 +111,10 @@ func liveView(id string, data *model.GetMeasurement, ctx model.Context, m model. // String builder for output var output strings.Builder - fetcher := client.NewMeasurementsFetcher(client.ApiUrl) - // Poll API until the measurement is complete - for data.Status == model.StatusInProgress { - time.Sleep(ctx.APIMinInterval) - data, err = fetcher.GetMeasurement(id) + for data.Status == globalping.StatusInProgress { + time.Sleep(v.ctx.APIMinInterval) + data, err = v.gp.GetMeasurement(id) if err != nil { return fmt.Errorf("failed to get data: %v", err) } @@ -110,9 +126,9 @@ func liveView(id string, data *model.GetMeasurement, ctx model.Context, m model. for i := range data.Results { result := &data.Results[i] // Output slightly different format if state is available - output.WriteString(generateProbeInfo(result, !ctx.CI) + "\n") + output.WriteString(generateProbeInfo(result, !v.ctx.CI) + "\n") - if isBodyOnlyHttpGet(ctx, m) { + if v.isBodyOnlyHttpGet(m) { output.WriteString(strings.TrimSpace(result.Result.RawBody) + "\n\n") } else { output.WriteString(strings.TrimSpace(result.Result.RawOutput) + "\n\n") @@ -128,7 +144,7 @@ func liveView(id string, data *model.GetMeasurement, ctx model.Context, m model. return fmt.Errorf("failed to stop writer: %v", err) } - OutputDefault(id, data, ctx, m) + v.outputDefault(id, data, m) return nil } @@ -167,7 +183,7 @@ func trimOutput(output string, terminalW, terminalH int) string { } // Also checks if the probe has a state in it in the form %s, %s, (%s), %s, ASN:%d -func generateProbeInfo(result *model.MeasurementResponse, useStyling bool) string { +func generateProbeInfo(result *globalping.ProbeMeasurement, useStyling bool) string { var output strings.Builder // Continent + Country + (State) + City + ASN + Network + (Region Tag) @@ -195,15 +211,15 @@ func formatWithLeadingArrow(text string, useStyling bool) string { return "> " + text } -func isBodyOnlyHttpGet(ctx model.Context, m model.PostMeasurement) bool { - return ctx.Cmd == "http" && m.Options != nil && m.Options.Request != nil && m.Options.Request.Method == "GET" && !ctx.Full +func (v *viewer) isBodyOnlyHttpGet(m *globalping.MeasurementCreate) bool { + return v.ctx.Cmd == "http" && m.Options != nil && m.Options.Request != nil && m.Options.Request.Method == "GET" && !v.ctx.Full } func shareMessage(id string) string { return fmt.Sprintf("View the results online: https://www.jsdelivr.com/globalping?measurement=%s", id) } -func getLocationText(m *model.MeasurementResponse) string { +func getLocationText(m *globalping.ProbeMeasurement) string { state := "" if m.Probe.State != "" { state = "(" + m.Probe.State + "), " diff --git a/view/view_test.go b/view/view_test.go index 07d0bf3..9c2a0d8 100644 --- a/view/view_test.go +++ b/view/view_test.go @@ -3,18 +3,18 @@ package view import ( "testing" - "github.com/jsdelivr/globalping-cli/model" + "github.com/jsdelivr/globalping-cli/globalping" "github.com/stretchr/testify/assert" ) var ( - testContext = model.Context{ + testContext = Context{ From: "New York", Target: "1.1.1.1", CI: true, } - testResult = model.MeasurementResponse{ - Probe: model.ProbeData{ + testResult = globalping.ProbeMeasurement{ + Probe: globalping.ProbeDetails{ Continent: "Continent", Country: "Country", State: "State", @@ -26,11 +26,11 @@ var ( } ) -func TestHeadersBase(t *testing.T) { +func Test_HeadersBase(t *testing.T) { assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network", generateProbeInfo(&testResult, !testContext.CI)) } -func TestHeadersTags(t *testing.T) { +func Test_HeadersTags(t *testing.T) { newResult := testResult newResult.Probe.Tags = []string{"tag1", "tag2"} @@ -40,7 +40,7 @@ func TestHeadersTags(t *testing.T) { assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network (tag2)", generateProbeInfo(&newResult, !testContext.CI)) } -func TestTrimOutput(t *testing.T) { +func Test_TrimOutput(t *testing.T) { output := `> EU, GB, London, ASN:12345 TEST CONTENT ABCD @@ -67,7 +67,7 @@ LOREM IPSUM LOREM IPSUM LOREM IPSUM` assert.Equal(t, expectedRes, res) } -func TestTrimOutput_CN(t *testing.T) { +func Test_TrimOutput_CN(t *testing.T) { output := `> EU, GB, London, ASN:12345 some text a äø­ę–‡äŗ’联ꖇäŗ’联ē½‘高č“Ø量ēš„é—®ē­”ē¤¾åŒŗ和创 ä½œč€…čšé›†ēš„原创内容平台äŗŽ201 1幓1ęœˆę­£å¼äøŠēŗæ让äŗŗ们ꛓ 儽ēš„分äŗ« ēŸ„čƆē»éŖŒå’Œč§č§£åˆ°č‡Ŗå·±ēš„č§£ē­”ć€äø­ę–‡äŗ’联ē½‘高č“Ø量ēš„é—®ē­”ē¤¾åŒŗå’Œåˆ›ä½œč€…čšé›†ēš„原创内容平台äø­ę–‡äŗ’联ē½‘高č“Ø量ēš„é—®ē­”ē¤¾åŒŗå’Œåˆ›ä½œč€…čšé›†ēš„原创内容平台äŗŽ2011幓1ęœˆę­£å¼äøŠēŗæ让äŗŗä»¬ę›“å„½ēš„分äŗ«ēŸ„čƆē»éŖŒå’Œč§č§£åˆ°č‡Ŗå·±ēš„č§£ē­”ć€äø­ę–‡äŗ’联ē½‘高č“Ø量ēš„é—®ē­”ē¤¾åŒŗå’Œåˆ›ä½œč€…čšé›†ēš„原创内容平台äŗŽ From c741fd592151298b94648aa819116ef7f34014f4 Mon Sep 17 00:00:00 2001 From: Radu Lucut Date: Wed, 7 Feb 2024 10:07:51 +0200 Subject: [PATCH 20/27] Add printer --- cmd/root.go | 5 +- go.mod | 2 +- go.sum | 4 +- view/default.go | 12 +- view/default_test.go | 187 +++++++++----------------- view/infinite.go | 6 +- view/infinite_test.go | 104 ++++----------- view/json.go | 11 +- view/json_test.go | 46 +++---- view/latency.go | 29 ++-- view/latency_test.go | 299 +++++++++++++++++------------------------- view/printer.go | 31 +++++ view/summary.go | 12 +- view/summary_test.go | 114 +++------------- view/view.go | 11 +- 15 files changed, 318 insertions(+), 555 deletions(-) create mode 100644 view/printer.go diff --git a/cmd/root.go b/cmd/root.go index 12ee238..5a6ff8e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -28,8 +28,9 @@ var ( APIMinInterval: globalping.API_MIN_INTERVAL, MaxHistory: 10, } - gp = globalping.NewClient(globalping.API_URL) - viewer = view.NewViewer(ctx, gp) + gp = globalping.NewClient(globalping.API_URL) + printer = view.NewPrinter(os.Stdout) + viewer = view.NewViewer(ctx, printer, gp) ) // rootCmd represents the base command when called without any subcommands diff --git a/go.mod b/go.mod index 45731b5..6c04627 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/icza/backscanner v0.0.0-20230330133933-bf6beb754c70 github.com/mattn/go-runewidth v0.0.15 github.com/pkg/errors v0.9.1 - github.com/pterm/pterm v0.12.75 + github.com/pterm/pterm v0.12.78 github.com/shirou/gopsutil v3.21.11+incompatible github.com/spf13/cobra v1.8.0 github.com/stretchr/testify v1.8.4 diff --git a/go.sum b/go.sum index afc52cd..2941f01 100644 --- a/go.sum +++ b/go.sum @@ -83,8 +83,8 @@ github.com/pterm/pterm v0.12.31/go.mod h1:32ZAWZVXD7ZfG0s8qqHXePte42kdz8ECtRyEej github.com/pterm/pterm v0.12.33/go.mod h1:x+h2uL+n7CP/rel9+bImHD5lF3nM9vJj80k9ybiiTTE= github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5bUw8T8= github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s= -github.com/pterm/pterm v0.12.75 h1:sRoDOqowp0lOr2SBREsxLRzLOUmwBWfyOflsGnVIIbo= -github.com/pterm/pterm v0.12.75/go.mod h1:1v/gzOF1N0FsjbgTHZ1wVycRkKiatFvJSJC4IGaQAAo= +github.com/pterm/pterm v0.12.78 h1:QTWKaIAa4B32GKwqVXtu9m1DUMgWw3VRljMkMevX+b8= +github.com/pterm/pterm v0.12.78/go.mod h1:1v/gzOF1N0FsjbgTHZ1wVycRkKiatFvJSJC4IGaQAAo= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= diff --git a/view/default.go b/view/default.go index aa7778e..3b2b721 100644 --- a/view/default.go +++ b/view/default.go @@ -1,8 +1,6 @@ package view import ( - "fmt" - "os" "strings" "github.com/jsdelivr/globalping-cli/globalping" @@ -14,20 +12,20 @@ func (v *viewer) outputDefault(id string, data *globalping.Measurement, m *globa result := &data.Results[i] if i > 0 { // new line as separator if more than 1 result - fmt.Println() + v.printer.Println() } // Output slightly different format if state is available - fmt.Fprintln(os.Stderr, generateProbeInfo(result, !v.ctx.CI)) + v.printer.Println(generateProbeInfo(result, !v.ctx.CI)) if v.isBodyOnlyHttpGet(m) { - fmt.Println(strings.TrimSpace(result.Result.RawBody)) + v.printer.Println(strings.TrimSpace(result.Result.RawBody)) } else { - fmt.Println(strings.TrimSpace(result.Result.RawOutput)) + v.printer.Println(strings.TrimSpace(result.Result.RawOutput)) } } if v.ctx.Share { - fmt.Fprintln(os.Stderr, formatWithLeadingArrow(shareMessage(id), !v.ctx.CI)) + v.printer.Println(formatWithLeadingArrow(shareMessage(id), !v.ctx.CI)) } } diff --git a/view/default_test.go b/view/default_test.go index f5a5074..3f036b7 100644 --- a/view/default_test.go +++ b/view/default_test.go @@ -53,24 +53,10 @@ func Test_Output_Default_HTTP_Get(t *testing.T) { gbMock := mocks.NewMockClient(ctrl) gbMock.EXPECT().GetMeasurement(measurementID1).Times(1).Return(measurement, nil) - osStdErr := os.Stderr - osStdOut := os.Stdout - - rStdErr, myStdErr, err := os.Pipe() - assert.NoError(t, err) - defer rStdErr.Close() - - rStdOut, myStdOut, err := os.Pipe() + r, w, err := os.Pipe() assert.NoError(t, err) - defer rStdOut.Close() - - os.Stderr = myStdErr - os.Stdout = myStdOut - - defer func() { - os.Stderr = osStdErr - os.Stdout = osStdOut - }() + defer r.Close() + defer w.Close() m := &globalping.MeasurementCreate{ Options: &globalping.MeasurementOptions{ @@ -83,19 +69,19 @@ func Test_Output_Default_HTTP_Get(t *testing.T) { viewer := NewViewer(&Context{ Cmd: "http", CI: true, - }, gbMock) + }, NewPrinter(w), gbMock) viewer.Output(measurementID1, m) - myStdOut.Close() - myStdErr.Close() + w.Close() - errContent, err := io.ReadAll(rStdErr) + outContent, err := io.ReadAll(r) assert.NoError(t, err) - assert.Equal(t, "> EU, DE, Berlin, ASN:123, Network 1\n> NA, US, (NY), New York, ASN:567, Network 2\n", string(errContent)) + assert.Equal(t, `> EU, DE, Berlin, ASN:123, Network 1 +Body 1 - outContent, err := io.ReadAll(rStdOut) - assert.NoError(t, err) - assert.Equal(t, "Body 1\n\nBody 2\n", string(outContent)) +> NA, US, (NY), New York, ASN:567, Network 2 +Body 2 +`, string(outContent)) } func Test_Output_Default_HTTP_Get_Share(t *testing.T) { @@ -140,24 +126,10 @@ func Test_Output_Default_HTTP_Get_Share(t *testing.T) { gbMock := mocks.NewMockClient(ctrl) gbMock.EXPECT().GetMeasurement(measurementID1).Times(1).Return(measurement, nil) - osStdErr := os.Stderr - osStdOut := os.Stdout - - rStdErr, myStdErr, err := os.Pipe() - assert.NoError(t, err) - defer rStdErr.Close() - - rStdOut, myStdOut, err := os.Pipe() + r, w, err := os.Pipe() assert.NoError(t, err) - defer rStdOut.Close() - - os.Stderr = myStdErr - os.Stdout = myStdOut - - defer func() { - os.Stderr = osStdErr - os.Stdout = osStdOut - }() + defer r.Close() + defer w.Close() m := &globalping.MeasurementCreate{ Options: &globalping.MeasurementOptions{ @@ -171,19 +143,20 @@ func Test_Output_Default_HTTP_Get_Share(t *testing.T) { Cmd: "http", CI: true, Share: true, - }, gbMock) + }, NewPrinter(w), gbMock) viewer.Output(measurementID1, m) - myStdOut.Close() - myStdErr.Close() + w.Close() - errContent, err := io.ReadAll(rStdErr) + outContent, err := io.ReadAll(r) assert.NoError(t, err) - assert.Equal(t, "> EU, DE, Berlin, ASN:123, Network 1\n> NA, US, (NY), New York, ASN:567, Network 2\n> View the results online: https://www.jsdelivr.com/globalping?measurement=nzGzfAGL7sZfUs3c\n", string(errContent)) + assert.Equal(t, `> EU, DE, Berlin, ASN:123, Network 1 +Body 1 - outContent, err := io.ReadAll(rStdOut) - assert.NoError(t, err) - assert.Equal(t, "Body 1\n\nBody 2\n", string(outContent)) +> NA, US, (NY), New York, ASN:567, Network 2 +Body 2 +> View the results online: https://www.jsdelivr.com/globalping?measurement=nzGzfAGL7sZfUs3c +`, string(outContent)) } func Test_Output_Default_HTTP_Get_Full(t *testing.T) { @@ -228,24 +201,10 @@ func Test_Output_Default_HTTP_Get_Full(t *testing.T) { gbMock := mocks.NewMockClient(ctrl) gbMock.EXPECT().GetMeasurement(measurementID1).Times(1).Return(measurement, nil) - osStdErr := os.Stderr - osStdOut := os.Stdout - - rStdErr, myStdErr, err := os.Pipe() + r, w, err := os.Pipe() assert.NoError(t, err) - defer rStdErr.Close() - - rStdOut, myStdOut, err := os.Pipe() - assert.NoError(t, err) - defer rStdOut.Close() - - os.Stderr = myStdErr - os.Stdout = myStdOut - - defer func() { - os.Stderr = osStdErr - os.Stdout = osStdOut - }() + defer r.Close() + defer w.Close() m := &globalping.MeasurementCreate{ Options: &globalping.MeasurementOptions{ @@ -259,20 +218,21 @@ func Test_Output_Default_HTTP_Get_Full(t *testing.T) { Cmd: "http", CI: true, Full: true, - }, gbMock) + }, NewPrinter(w), gbMock) viewer.Output(measurementID1, m) + w.Close() - myStdOut.Close() - myStdErr.Close() - - errContent, err := io.ReadAll(rStdErr) - assert.NoError(t, err) - assert.Equal(t, "> EU, DE, Berlin, ASN:123, Network 1\n> NA, US, (NY), New York, ASN:567, Network 2\n", string(errContent)) - - outContent, err := io.ReadAll(rStdOut) + outContent, err := io.ReadAll(r) assert.NoError(t, err) - assert.Equal(t, "Headers 1\nBody 1\n\nHeaders 2\nBody 2\n", string(outContent)) + assert.Equal(t, `> EU, DE, Berlin, ASN:123, Network 1 +Headers 1 +Body 1 + +> NA, US, (NY), New York, ASN:567, Network 2 +Headers 2 +Body 2 +`, string(outContent)) } func Test_Output_Default_HTTP_Head(t *testing.T) { @@ -315,24 +275,10 @@ func Test_Output_Default_HTTP_Head(t *testing.T) { gbMock := mocks.NewMockClient(ctrl) gbMock.EXPECT().GetMeasurement(measurementID1).Times(1).Return(measurement, nil) - osStdErr := os.Stderr - osStdOut := os.Stdout - - rStdErr, myStdErr, err := os.Pipe() + r, w, err := os.Pipe() assert.NoError(t, err) - defer rStdErr.Close() - - rStdOut, myStdOut, err := os.Pipe() - assert.NoError(t, err) - defer rStdOut.Close() - - os.Stderr = myStdErr - os.Stdout = myStdOut - - defer func() { - os.Stderr = osStdErr - os.Stdout = osStdOut - }() + defer r.Close() + defer w.Close() m := &globalping.MeasurementCreate{ Options: &globalping.MeasurementOptions{ @@ -345,20 +291,19 @@ func Test_Output_Default_HTTP_Head(t *testing.T) { viewer := NewViewer(&Context{ Cmd: "http", CI: true, - }, gbMock) + }, NewPrinter(w), gbMock) viewer.Output(measurementID1, m) + w.Close() - myStdOut.Close() - myStdErr.Close() - - errContent, err := io.ReadAll(rStdErr) + outContent, err := io.ReadAll(r) assert.NoError(t, err) - assert.Equal(t, "> EU, DE, Berlin, ASN:123, Network 1\n> NA, US, (NY), New York, ASN:567, Network 2\n", string(errContent)) + assert.Equal(t, `> EU, DE, Berlin, ASN:123, Network 1 +Headers 1 - outContent, err := io.ReadAll(rStdOut) - assert.NoError(t, err) - assert.Equal(t, "Headers 1\n\nHeaders 2\n", string(outContent)) +> NA, US, (NY), New York, ASN:567, Network 2 +Headers 2 +`, string(outContent)) } func Test_Output_Default_Ping(t *testing.T) { @@ -399,41 +344,27 @@ func Test_Output_Default_Ping(t *testing.T) { gbMock := mocks.NewMockClient(ctrl) gbMock.EXPECT().GetMeasurement(measurementID1).Times(1).Return(measurement, nil) - osStdErr := os.Stderr - osStdOut := os.Stdout - - rStdErr, myStdErr, err := os.Pipe() - assert.NoError(t, err) - defer rStdErr.Close() - - rStdOut, myStdOut, err := os.Pipe() + r, w, err := os.Pipe() assert.NoError(t, err) - defer rStdOut.Close() - - os.Stderr = myStdErr - os.Stdout = myStdOut - - defer func() { - os.Stderr = osStdErr - os.Stdout = osStdOut - }() + defer r.Close() + defer w.Close() m := &globalping.MeasurementCreate{} viewer := NewViewer(&Context{ Cmd: "ping", CI: true, - }, gbMock) + }, NewPrinter(w), gbMock) viewer.Output(measurementID1, m) - myStdOut.Close() - myStdErr.Close() + w.Close() - errContent, err := io.ReadAll(rStdErr) + outContent, err := io.ReadAll(r) assert.NoError(t, err) - assert.Equal(t, "> EU, DE, Berlin, ASN:123, Network 1\n> NA, US, (NY), New York, ASN:567, Network 2\n", string(errContent)) + assert.Equal(t, `> EU, DE, Berlin, ASN:123, Network 1 +Ping Results 1 - outContent, err := io.ReadAll(rStdOut) - assert.NoError(t, err) - assert.Equal(t, "Ping Results 1\n\nPing Results 2\n", string(outContent)) +> NA, US, (NY), New York, ASN:567, Network 2 +Ping Results 2 +`, string(outContent)) } diff --git a/view/infinite.go b/view/infinite.go index 55a31f3..efef38d 100644 --- a/view/infinite.go +++ b/view/infinite.go @@ -72,8 +72,8 @@ func (v *viewer) outputStreamingPackets(res *globalping.Measurement) error { parsedOutput := parsePingRawOutput(measurement, v.ctx.CompletedStats[0].Sent) if printHeader && v.ctx.CompletedStats[0].Sent == 0 { v.ctx.Hostname = parsedOutput.Hostname - fmt.Println(generateProbeInfo(measurement, !v.ctx.CI)) - fmt.Printf("PING %s (%s) %s bytes of data.\n", + v.printer.Println(generateProbeInfo(measurement, !v.ctx.CI)) + v.printer.Printf("PING %s (%s) %s bytes of data.\n", parsedOutput.Hostname, parsedOutput.Address, parsedOutput.BytesOfData, @@ -81,7 +81,7 @@ func (v *viewer) outputStreamingPackets(res *globalping.Measurement) error { printHeader = false } for linesPrinted < len(parsedOutput.RawPacketLines) { - fmt.Println(parsedOutput.RawPacketLines[linesPrinted]) + v.printer.Println(parsedOutput.RawPacketLines[linesPrinted]) linesPrinted++ } v.ctx.InProgressStats[0] = mergeMeasurementStats(v.ctx.CompletedStats[0], parsedOutput) diff --git a/view/infinite_test.go b/view/infinite_test.go index 32fb382..cff093c 100644 --- a/view/infinite_test.go +++ b/view/infinite_test.go @@ -17,11 +17,6 @@ func Test_OutputInfinite_SingleProbe_InProgress(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - osStdOut := os.Stdout - defer func() { - os.Stdout = osStdOut - }() - rawOutput1 := `PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data.` rawOutput2 := `PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. 64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=56 time=12.9 ms` @@ -58,31 +53,26 @@ rtt min/avg/max/mdev = 12.711/12.854/12.952/0.103 ms` return measurement, nil }).Times(4) + r, w, err := os.Pipe() + assert.NoError(t, err) + defer r.Close() + defer w.Close() + ctx := &Context{ Cmd: "ping", MaxHistory: 3, } - viewer := NewViewer(ctx, gbMock) + viewer := NewViewer(ctx, NewPrinter(w), gbMock) measurement.Status = globalping.StatusInProgress measurement.Results[0].Result.Status = globalping.StatusInProgress measurement.Results[0].Result.RawOutput = rawOutput1 - r, w, err := os.Pipe() - assert.NoError(t, err) - defer func() { - w.Close() - r.Close() - }() - os.Stdout = w - err = viewer.OutputInfinite(measurement.ID) + assert.NoError(t, err) w.Close() - os.Stdout = osStdOut - assert.NoError(t, err) output, err := io.ReadAll(r) - r.Close() assert.NoError(t, err) assert.Equal(t, `> EU, DE, Berlin, ASN:3320, Deutsche Telekom AG @@ -104,28 +94,20 @@ func Test_OutputInfinite_SingleProbe_MultipleCalls(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - osStdOut := os.Stdout - defer func() { - os.Stdout = osStdOut - }() - gbMock := mocks.NewMockClient(ctrl) measurement := getPingGetMeasurement(measurementID1) gbMock.EXPECT().GetMeasurement(measurementID1).Times(3).Return(measurement, nil) + r, w, err := os.Pipe() + assert.NoError(t, err) + defer r.Close() + defer w.Close() + ctx := &Context{ Cmd: "ping", MaxHistory: 3, } - viewer := NewViewer(ctx, gbMock) - - r, w, err := os.Pipe() - assert.NoError(t, err) - defer func() { - w.Close() - r.Close() - }() - os.Stdout = w + viewer := NewViewer(ctx, NewPrinter(w), gbMock) err = viewer.OutputInfinite(measurement.ID) assert.NoError(t, err) @@ -134,10 +116,8 @@ func Test_OutputInfinite_SingleProbe_MultipleCalls(t *testing.T) { err = viewer.OutputInfinite(measurement.ID) assert.NoError(t, err) w.Close() - os.Stdout = osStdOut output, err := io.ReadAll(r) - r.Close() assert.NoError(t, err) assert.Equal(t, `> EU, DE, Berlin, ASN:3320, Deutsche Telekom AG @@ -158,11 +138,6 @@ func Test_OutputInfinite_MultipleProbes_MultipleCalls(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - osStdOut := os.Stdout - defer func() { - os.Stdout = osStdOut - }() - gbMock := mocks.NewMockClient(ctrl) res := getPingGetMeasurementMultipleLocations(measurementID1) @@ -211,17 +186,14 @@ rtt min/avg/max/mdev = 17.006/17.333/17.648/0.321 ms` r, w, err := os.Pipe() assert.NoError(t, err) - defer func() { - w.Close() - r.Close() - }() - os.Stdout = w + defer r.Close() + defer w.Close() ctx := &Context{ Cmd: "ping", MaxHistory: 3, } - viewer := NewViewer(ctx, gbMock) + viewer := NewViewer(ctx, NewPrinter(w), gbMock) err = viewer.OutputInfinite(measurementID1) assert.NoError(t, err) @@ -246,7 +218,6 @@ rtt min/avg/max/mdev = 17.006/17.333/17.648/0.321 ms` assert.NoError(t, err) w.Close() - os.Stdout = osStdOut output, err := io.ReadAll(r) assert.NoError(t, err) @@ -262,24 +233,18 @@ rtt min/avg/max/mdev = 17.006/17.333/17.648/0.321 ms` rr, ww, err := os.Pipe() assert.NoError(t, err) - defer func() { - ww.Close() - rr.Close() - }() - - os.Stdout = ww + defer rr.Close() + defer ww.Close() + pterm.DefaultArea.SetWriter(ww) area, _ := pterm.DefaultArea.Start() for i := range expectedTables { area.Update(*expectedTables[i]) } area.Stop() ww.Close() - os.Stdout = osStdOut expectedOutput, err := io.ReadAll(rr) assert.NoError(t, err) - rr.Close() - assert.Equal(t, string(expectedOutput), string(output)) } @@ -287,45 +252,26 @@ func Test_OutputInfinite_MultipleProbes(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - osStdOut := os.Stdout - defer func() { - os.Stdout = osStdOut - }() - measurement := getPingGetMeasurementMultipleLocations(measurementID1) gbMock := mocks.NewMockClient(ctrl) gbMock.EXPECT().GetMeasurement(measurementID1).Times(1).Return(measurement, nil) r, w, err := os.Pipe() assert.NoError(t, err) - defer func() { - w.Close() - r.Close() - }() - os.Stdout = w + defer r.Close() + defer w.Close() ctx := &Context{ Cmd: "ping", MaxHistory: 3, } - v := NewViewer(ctx, gbMock) + v := NewViewer(ctx, NewPrinter(w), gbMock) err = v.OutputInfinite(measurementID1) assert.NoError(t, err) w.Close() - os.Stdout = osStdOut - output, err := io.ReadAll(r) assert.NoError(t, err) - r.Close() - - rr, ww, err := os.Pipe() - assert.NoError(t, err) - defer func() { - ww.Close() - rr.Close() - }() - os.Stdout = ww expectedViewer := &viewer{ ctx: getDefaultPingCtx(len(measurement.Results)), @@ -334,13 +280,9 @@ func Test_OutputInfinite_MultipleProbes(t *testing.T) { area, _ := pterm.DefaultArea.Start() area.Update(*expectedTable) area.Stop() - ww.Close() - os.Stdout = osStdOut - expectedOutput, err := io.ReadAll(rr) + expectedOutput, err := io.ReadAll(r) assert.NoError(t, err) - rr.Close() - assert.Equal(t, string(expectedOutput), string(output)) assert.Equal(t, []MeasurementStats{ diff --git a/view/json.go b/view/json.go index 679f136..ce8a5eb 100644 --- a/view/json.go +++ b/view/json.go @@ -1,22 +1,17 @@ package view -import ( - "fmt" - "os" -) - // Outputs the raw JSON for a measurement func (v *viewer) OutputJson(id string) error { output, err := v.gp.GetRawMeasurement(id) if err != nil { return err } - fmt.Println(string(output)) + v.printer.Println(string(output)) if v.ctx.Share { - fmt.Fprintln(os.Stderr, formatWithLeadingArrow(shareMessage(id), !v.ctx.CI)) + v.printer.Println(formatWithLeadingArrow(shareMessage(id), !v.ctx.CI)) } - fmt.Println() + v.printer.Println() return nil } diff --git a/view/json_test.go b/view/json_test.go index 8273c7c..9c61eb2 100644 --- a/view/json_test.go +++ b/view/json_test.go @@ -22,41 +22,29 @@ func Test_Output_Json(t *testing.T) { gbMock.EXPECT().GetMeasurement(measurementID1).Times(1).Return(measurement, nil) gbMock.EXPECT().GetRawMeasurement(measurementID1).Times(1).Return(b, nil) - viewer := NewViewer(&Context{ - ToJSON: true, - Share: true, - }, gbMock) - - osStdErr := os.Stderr - osStdOut := os.Stdout - - rStdErr, myStdErr, err := os.Pipe() - assert.NoError(t, err) - defer rStdErr.Close() - - rStdOut, myStdOut, err := os.Pipe() + r, w, err := os.Pipe() assert.NoError(t, err) - defer rStdOut.Close() - - os.Stderr = myStdErr - os.Stdout = myStdOut - - defer func() { - os.Stderr = osStdErr - os.Stdout = osStdOut - }() + defer r.Close() + defer w.Close() + + viewer := NewViewer( + &Context{ + ToJSON: true, + Share: true, + }, + NewPrinter(w), + gbMock, + ) m := &globalping.MeasurementCreate{} err = viewer.Output(measurementID1, m) assert.NoError(t, err) - myStdOut.Close() - myStdErr.Close() + w.Close() - errContent, err := io.ReadAll(rStdErr) + outContent, err := io.ReadAll(r) assert.NoError(t, err) - assert.Equal(t, "> View the results online: https://www.jsdelivr.com/globalping?measurement=nzGzfAGL7sZfUs3c\n", string(errContent)) + assert.Equal(t, `{"fake": "results"} +> View the results online: https://www.jsdelivr.com/globalping?measurement=nzGzfAGL7sZfUs3c - outContent, err := io.ReadAll(rStdOut) - assert.NoError(t, err) - assert.Equal(t, "{\"fake\": \"results\"}\n\n", string(outContent)) +`, string(outContent)) } diff --git a/view/latency.go b/view/latency.go index 46a6fc8..a4b587e 100644 --- a/view/latency.go +++ b/view/latency.go @@ -3,7 +3,6 @@ package view import ( "errors" "fmt" - "os" "github.com/jsdelivr/globalping-cli/globalping" ) @@ -14,10 +13,10 @@ func (v *viewer) OutputLatency(id string, data *globalping.Measurement) error { for i, result := range data.Results { if i > 0 { // new line as separator if more than 1 result - fmt.Println() + v.printer.Println() } - fmt.Fprintln(os.Stderr, generateProbeInfo(&result, !v.ctx.CI)) + v.printer.Println(generateProbeInfo(&result, !v.ctx.CI)) switch v.ctx.Cmd { case "ping": @@ -25,35 +24,35 @@ func (v *viewer) OutputLatency(id string, data *globalping.Measurement) error { if err != nil { return err } - fmt.Println(v.latencyStatHeader("Min", v.ctx.CI) + fmt.Sprintf("%.2f ms", stats.Min)) - fmt.Println(v.latencyStatHeader("Max", v.ctx.CI) + fmt.Sprintf("%.2f ms", stats.Max)) - fmt.Println(v.latencyStatHeader("Avg", v.ctx.CI) + fmt.Sprintf("%.2f ms", stats.Avg)) + 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)) case "dns": timings, err := globalping.DecodeDNSTimings(result.Result.TimingsRaw) if err != nil { return err } - fmt.Println(v.latencyStatHeader("Total", v.ctx.CI) + fmt.Sprintf("%v ms", timings.Total)) + v.printer.Println(v.latencyStatHeader("Total", v.ctx.CI) + fmt.Sprintf("%v ms", timings.Total)) case "http": timings, err := globalping.DecodeHTTPTimings(result.Result.TimingsRaw) if err != nil { return err } - fmt.Println(v.latencyStatHeader("Total", v.ctx.CI) + fmt.Sprintf("%v ms", timings.Total)) - fmt.Println(v.latencyStatHeader("Download", v.ctx.CI) + fmt.Sprintf("%v ms", timings.Download)) - fmt.Println(v.latencyStatHeader("First byte", v.ctx.CI) + fmt.Sprintf("%v ms", timings.FirstByte)) - fmt.Println(v.latencyStatHeader("DNS", v.ctx.CI) + fmt.Sprintf("%v ms", timings.DNS)) - fmt.Println(v.latencyStatHeader("TLS", v.ctx.CI) + fmt.Sprintf("%v ms", timings.TLS)) - fmt.Println(v.latencyStatHeader("TCP", v.ctx.CI) + fmt.Sprintf("%v ms", timings.TCP)) + 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)) default: return errors.New("unexpected command for latency output: " + v.ctx.Cmd) } } if v.ctx.Share { - fmt.Fprintln(os.Stderr, formatWithLeadingArrow(shareMessage(id), !v.ctx.CI)) + v.printer.Println(formatWithLeadingArrow(shareMessage(id), !v.ctx.CI)) } - fmt.Println() + v.printer.Println() return nil } diff --git a/view/latency_test.go b/view/latency_test.go index d3af9ca..c583d51 100644 --- a/view/latency_test.go +++ b/view/latency_test.go @@ -52,42 +52,37 @@ func Test_Output_Latency_Ping_Not_CI(t *testing.T) { gbMock := mocks.NewMockClient(ctrl) gbMock.EXPECT().GetMeasurement(measurementID1).Times(1).Return(measurement, nil) - osStdErr := os.Stderr - osStdOut := os.Stdout - - rStdErr, myStdErr, err := os.Pipe() - assert.NoError(t, err) - defer rStdErr.Close() - - rStdOut, myStdOut, err := os.Pipe() + r, w, err := os.Pipe() assert.NoError(t, err) - defer rStdOut.Close() + defer r.Close() + defer w.Close() - os.Stderr = myStdErr - os.Stdout = myStdOut - - defer func() { - os.Stderr = osStdErr - os.Stdout = osStdOut - }() - - viewer := NewViewer(&Context{ - Cmd: "ping", - ToLatency: true, - }, gbMock) + viewer := NewViewer( + &Context{ + Cmd: "ping", + ToLatency: true, + }, + NewPrinter(w), + gbMock, + ) err = viewer.Output(measurementID1, &globalping.MeasurementCreate{}) assert.NoError(t, err) - myStdOut.Close() - myStdErr.Close() + w.Close() - errContent, err := io.ReadAll(rStdErr) + outContent, err := io.ReadAll(r) assert.NoError(t, err) - assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network (tag-1)\n> Continent B, Country B, (State B), City B, ASN:12349, Network B\n", string(errContent)) + assert.Equal(t, `> Continent, Country, (State), City, ASN:12345, Network (tag-1) +Min: 8.00 ms +Max: 20.00 ms +Avg: 12.00 ms - outContent, err := io.ReadAll(rStdOut) - assert.NoError(t, err) - assert.Equal(t, "Min: 8.00 ms\nMax: 20.00 ms\nAvg: 12.00 ms\n\nMin: 9.00 ms\nMax: 22.00 ms\nAvg: 15.00 ms\n\n", string(outContent)) +> Continent B, Country B, (State B), City B, ASN:12349, Network B +Min: 9.00 ms +Max: 22.00 ms +Avg: 15.00 ms + +`, string(outContent)) } func Test_Output_Latency_Ping_CI(t *testing.T) { @@ -116,43 +111,33 @@ func Test_Output_Latency_Ping_CI(t *testing.T) { gbMock := mocks.NewMockClient(ctrl) gbMock.EXPECT().GetMeasurement(measurementID1).Times(1).Return(measurement, nil) - osStdErr := os.Stderr - osStdOut := os.Stdout - - rStdErr, myStdErr, err := os.Pipe() + r, w, err := os.Pipe() assert.NoError(t, err) - defer rStdErr.Close() - - rStdOut, myStdOut, err := os.Pipe() - assert.NoError(t, err) - defer rStdOut.Close() - - os.Stderr = myStdErr - os.Stdout = myStdOut - - defer func() { - os.Stderr = osStdErr - os.Stdout = osStdOut - }() - - viewer := NewViewer(&Context{ - Cmd: "ping", - ToLatency: true, - CI: true, - }, gbMock) + defer r.Close() + defer w.Close() + + viewer := NewViewer( + &Context{ + Cmd: "ping", + ToLatency: true, + CI: true, + }, + NewPrinter(w), + gbMock, + ) err = viewer.Output(measurementID1, &globalping.MeasurementCreate{}) assert.NoError(t, err) - myStdOut.Close() - myStdErr.Close() + w.Close() - errContent, err := io.ReadAll(rStdErr) + outContent, err := io.ReadAll(r) assert.NoError(t, err) - assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network\n", string(errContent)) + assert.Equal(t, `> Continent, Country, (State), City, ASN:12345, Network +Min: 8.00 ms +Max: 20.00 ms +Avg: 12.00 ms - outContent, err := io.ReadAll(rStdOut) - assert.NoError(t, err) - assert.Equal(t, "Min: 8.00 ms\nMax: 20.00 ms\nAvg: 12.00 ms\n\n", string(outContent)) +`, string(outContent)) } func Test_Output_Latency_DNS_Not_CI(t *testing.T) { @@ -181,42 +166,30 @@ func Test_Output_Latency_DNS_Not_CI(t *testing.T) { gbMock := mocks.NewMockClient(ctrl) gbMock.EXPECT().GetMeasurement(measurementID1).Times(1).Return(measurement, nil) - osStdErr := os.Stderr - osStdOut := os.Stdout - - rStdErr, myStdErr, err := os.Pipe() - assert.NoError(t, err) - defer rStdErr.Close() - - rStdOut, myStdOut, err := os.Pipe() + r, w, err := os.Pipe() assert.NoError(t, err) - defer rStdOut.Close() - - os.Stderr = myStdErr - os.Stdout = myStdOut - - defer func() { - os.Stderr = osStdErr - os.Stdout = osStdOut - }() + defer r.Close() + defer w.Close() - viewer := NewViewer(&Context{ - Cmd: "dns", - ToLatency: true, - }, gbMock) + viewer := NewViewer( + &Context{ + Cmd: "dns", + ToLatency: true, + }, + NewPrinter(w), + gbMock, + ) err = viewer.Output(measurementID1, &globalping.MeasurementCreate{}) assert.NoError(t, err) - myStdOut.Close() - myStdErr.Close() + w.Close() - errContent, err := io.ReadAll(rStdErr) + outContent, err := io.ReadAll(r) assert.NoError(t, err) - assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network\n", string(errContent)) + assert.Equal(t, `> Continent, Country, (State), City, ASN:12345, Network +Total: 44 ms - outContent, err := io.ReadAll(rStdOut) - assert.NoError(t, err) - assert.Equal(t, "Total: 44 ms\n\n", string(outContent)) +`, string(outContent)) } func Test_Output_Latency_DNS_CI(t *testing.T) { @@ -245,43 +218,31 @@ func Test_Output_Latency_DNS_CI(t *testing.T) { gbMock := mocks.NewMockClient(ctrl) gbMock.EXPECT().GetMeasurement(measurementID1).Times(1).Return(measurement, nil) - osStdErr := os.Stderr - osStdOut := os.Stdout - - rStdErr, myStdErr, err := os.Pipe() + r, w, err := os.Pipe() assert.NoError(t, err) - defer rStdErr.Close() - - rStdOut, myStdOut, err := os.Pipe() - assert.NoError(t, err) - defer rStdOut.Close() - - os.Stderr = myStdErr - os.Stdout = myStdOut - - defer func() { - os.Stderr = osStdErr - os.Stdout = osStdOut - }() - - viewer := NewViewer(&Context{ - Cmd: "dns", - ToLatency: true, - CI: true, - }, gbMock) + defer r.Close() + defer w.Close() + + viewer := NewViewer( + &Context{ + Cmd: "dns", + ToLatency: true, + CI: true, + }, + NewPrinter(w), + gbMock, + ) err = viewer.Output(measurementID1, &globalping.MeasurementCreate{}) assert.NoError(t, err) - myStdOut.Close() - myStdErr.Close() + w.Close() - errContent, err := io.ReadAll(rStdErr) + outContent, err := io.ReadAll(r) assert.NoError(t, err) - assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network\n", string(errContent)) + assert.Equal(t, `> Continent, Country, (State), City, ASN:12345, Network +Total: 44 ms - outContent, err := io.ReadAll(rStdOut) - assert.NoError(t, err) - assert.Equal(t, "Total: 44 ms\n\n", string(outContent)) +`, string(outContent)) } func Test_Output_Latency_Http_Not_CI(t *testing.T) { @@ -310,42 +271,35 @@ func Test_Output_Latency_Http_Not_CI(t *testing.T) { gbMock := mocks.NewMockClient(ctrl) gbMock.EXPECT().GetMeasurement(measurementID1).Times(1).Return(measurement, nil) - osStdErr := os.Stderr - osStdOut := os.Stdout - - rStdErr, myStdErr, err := os.Pipe() - assert.NoError(t, err) - defer rStdErr.Close() - - rStdOut, myStdOut, err := os.Pipe() + r, w, err := os.Pipe() assert.NoError(t, err) - defer rStdOut.Close() - - os.Stderr = myStdErr - os.Stdout = myStdOut - - defer func() { - os.Stderr = osStdErr - os.Stdout = osStdOut - }() + defer r.Close() + defer w.Close() - viewer := NewViewer(&Context{ - Cmd: "http", - ToLatency: true, - }, gbMock) + viewer := NewViewer( + &Context{ + Cmd: "http", + ToLatency: true, + }, + NewPrinter(w), + gbMock, + ) err = viewer.Output(measurementID1, &globalping.MeasurementCreate{}) assert.NoError(t, err) - myStdOut.Close() - myStdErr.Close() - - errContent, err := io.ReadAll(rStdErr) - assert.NoError(t, err) - assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network\n", string(errContent)) + w.Close() - outContent, err := io.ReadAll(rStdOut) + outContent, err := io.ReadAll(r) assert.NoError(t, err) - assert.Equal(t, "Total: 44 ms\nDownload: 11 ms\nFirst byte: 20 ms\nDNS: 5 ms\nTLS: 2 ms\nTCP: 4 ms\n\n", string(outContent)) + assert.Equal(t, `> Continent, Country, (State), City, ASN:12345, Network +Total: 44 ms +Download: 11 ms +First byte: 20 ms +DNS: 5 ms +TLS: 2 ms +TCP: 4 ms + +`, string(outContent)) } func Test_Output_Latency_Http_CI(t *testing.T) { @@ -374,41 +328,34 @@ func Test_Output_Latency_Http_CI(t *testing.T) { gbMock := mocks.NewMockClient(ctrl) gbMock.EXPECT().GetMeasurement(measurementID1).Times(1).Return(measurement, nil) - osStdErr := os.Stderr - osStdOut := os.Stdout - - rStdErr, myStdErr, err := os.Pipe() - assert.NoError(t, err) - defer rStdErr.Close() - - rStdOut, myStdOut, err := os.Pipe() + r, w, err := os.Pipe() assert.NoError(t, err) - defer rStdOut.Close() - - os.Stderr = myStdErr - os.Stdout = myStdOut - - defer func() { - os.Stderr = osStdErr - os.Stdout = osStdOut - }() - - viewer := NewViewer(&Context{ - Cmd: "http", - ToLatency: true, - CI: true, - }, gbMock) + defer r.Close() + defer w.Close() + + viewer := NewViewer( + &Context{ + Cmd: "http", + ToLatency: true, + CI: true, + }, + NewPrinter(w), + gbMock, + ) err = viewer.Output(measurementID1, &globalping.MeasurementCreate{}) assert.NoError(t, err) - myStdOut.Close() - myStdErr.Close() - - errContent, err := io.ReadAll(rStdErr) - assert.NoError(t, err) - assert.Equal(t, "> Continent, Country, (State), City, ASN:12345, Network\n", string(errContent)) + w.Close() - outContent, err := io.ReadAll(rStdOut) + outContent, err := io.ReadAll(r) assert.NoError(t, err) - assert.Equal(t, "Total: 44 ms\nDownload: 11 ms\nFirst byte: 20 ms\nDNS: 5 ms\nTLS: 2 ms\nTCP: 4 ms\n\n", string(outContent)) + assert.Equal(t, `> Continent, Country, (State), City, ASN:12345, Network +Total: 44 ms +Download: 11 ms +First byte: 20 ms +DNS: 5 ms +TLS: 2 ms +TCP: 4 ms + +`, string(outContent)) } diff --git a/view/printer.go b/view/printer.go new file mode 100644 index 0000000..16950d1 --- /dev/null +++ b/view/printer.go @@ -0,0 +1,31 @@ +package view + +import ( + "fmt" + "io" + + "github.com/pterm/pterm" +) + +type Printer struct { + w io.Writer +} + +func NewPrinter(writer io.Writer) *Printer { + pterm.SetDefaultOutput(writer) + return &Printer{ + w: writer, + } +} + +func (p *Printer) Print(a ...any) { + fmt.Fprint(p.w, a...) +} + +func (p *Printer) Println(a ...any) { + fmt.Fprintln(p.w, a...) +} + +func (p *Printer) Printf(format string, a ...any) { + fmt.Fprintf(p.w, format, a...) +} diff --git a/view/summary.go b/view/summary.go index c60c525..babcf96 100644 --- a/view/summary.go +++ b/view/summary.go @@ -13,8 +13,8 @@ func (v *viewer) OutputSummary() { if len(v.ctx.InProgressStats) == 1 { stats := v.ctx.InProgressStats[0] - fmt.Printf("\n--- %s ping statistics ---\n", v.ctx.Hostname) - fmt.Printf("%d packets transmitted, %d received, %.2f%% packet loss, time %.0fms\n", + v.printer.Printf("\n--- %s ping statistics ---\n", v.ctx.Hostname) + v.printer.Printf("%d packets transmitted, %d received, %.2f%% packet loss, time %.0fms\n", stats.Sent, stats.Rcv, stats.Loss, @@ -36,19 +36,19 @@ func (v *viewer) OutputSummary() { if stats.Mdev != 0 { mdev = fmt.Sprintf("%.3f", stats.Mdev) } - fmt.Printf("rtt min/avg/max/mdev = %s/%s/%s/%s ms\n", min, avg, max, mdev) + v.printer.Printf("rtt min/avg/max/mdev = %s/%s/%s/%s ms\n", min, avg, max, mdev) } if v.ctx.Share && v.ctx.History != nil { if len(v.ctx.InProgressStats) > 1 { - fmt.Println() + v.printer.Println() } ids := v.ctx.History.ToString("+") if ids != "" { - fmt.Println(formatWithLeadingArrow(shareMessage(ids), !v.ctx.CI)) + v.printer.Println(formatWithLeadingArrow(shareMessage(ids), !v.ctx.CI)) } if v.ctx.CallCount > v.ctx.MaxHistory { - fmt.Printf("For long-running continuous mode measurements, only the last %d packets are shared.\n", + v.printer.Printf("For long-running continuous mode measurements, only the last %d packets are shared.\n", v.ctx.Packets*v.ctx.MaxHistory) } } diff --git a/view/summary_test.go b/view/summary_test.go index ad849df..f36ba0e 100644 --- a/view/summary_test.go +++ b/view/summary_test.go @@ -11,57 +11,37 @@ import ( func Test_OutputSummary(t *testing.T) { t.Run("No_stats", func(t *testing.T) { - osStdOut := os.Stdout - defer func() { - os.Stdout = osStdOut - }() - r, w, err := os.Pipe() assert.NoError(t, err) - defer func() { - w.Close() - r.Close() - }() + defer r.Close() + defer w.Close() - viewer := NewViewer(&Context{}, nil) - os.Stdout = w + viewer := NewViewer(&Context{}, NewPrinter(w), nil) viewer.OutputSummary() w.Close() - os.Stdout = osStdOut output, err := io.ReadAll(r) assert.NoError(t, err) - r.Close() assert.Equal(t, "", string(output)) }) t.Run("With_stats_Single_location", func(t *testing.T) { - osStdOut := os.Stdout - defer func() { - os.Stdout = osStdOut - }() - r, w, err := os.Pipe() assert.NoError(t, err) - defer func() { - w.Close() - r.Close() - }() + defer r.Close() + defer w.Close() ctx := &Context{ InProgressStats: []MeasurementStats{ {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, nil) - os.Stdout = w + viewer := NewViewer(ctx, NewPrinter(w), nil) viewer.OutputSummary() w.Close() - os.Stdout = osStdOut output, err := io.ReadAll(r) assert.NoError(t, err) - r.Close() assert.Equal(t, ` --- ping statistics --- 10 packets transmitted, 9 received, 10.00% packet loss, time 1000ms @@ -71,32 +51,22 @@ rtt min/avg/max/mdev = 0.770/0.770/0.770/0.001 ms }) t.Run("With_stats_In_progress", func(t *testing.T) { - osStdOut := os.Stdout - defer func() { - os.Stdout = osStdOut - }() - r, w, err := os.Pipe() assert.NoError(t, err) - defer func() { - w.Close() - r.Close() - }() + defer r.Close() + defer w.Close() ctx := &Context{ InProgressStats: []MeasurementStats{ {Sent: 1, Rcv: 0, Lost: 1, Loss: 100, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1, Time: 0}, }, } - viewer := NewViewer(ctx, nil) - os.Stdout = w + viewer := NewViewer(ctx, NewPrinter(w), nil) viewer.OutputSummary() w.Close() - os.Stdout = osStdOut output, err := io.ReadAll(r) assert.NoError(t, err) - r.Close() assert.Equal(t, ` --- ping statistics --- 1 packets transmitted, 0 received, 100.00% packet loss, time 0ms @@ -106,17 +76,10 @@ rtt min/avg/max/mdev = -/-/-/- ms }) t.Run("Multiple_locations", func(t *testing.T) { - osStdOut := os.Stdout - defer func() { - os.Stdout = osStdOut - }() - r, w, err := os.Pipe() assert.NoError(t, err) - defer func() { - w.Close() - r.Close() - }() + defer r.Close() + defer w.Close() ctx := &Context{ InProgressStats: []MeasurementStats{ @@ -124,30 +87,20 @@ rtt min/avg/max/mdev = -/-/-/- ms NewMeasurementStats(), }, } - viewer := NewViewer(ctx, nil) - os.Stdout = w + viewer := NewViewer(ctx, NewPrinter(w), nil) viewer.OutputSummary() w.Close() - os.Stdout = osStdOut output, err := io.ReadAll(r) assert.NoError(t, err) - r.Close() assert.Equal(t, "", string(output)) }) t.Run("Single_location_Share", func(t *testing.T) { - osStdOut := os.Stdout - defer func() { - os.Stdout = osStdOut - }() - r, w, err := os.Pipe() assert.NoError(t, err) - defer func() { - w.Close() - r.Close() - }() + defer r.Close() + defer w.Close() ctx := &Context{ History: &Rbuffer{ @@ -159,15 +112,12 @@ rtt min/avg/max/mdev = -/-/-/- ms }, Share: true, } - viewer := NewViewer(ctx, nil) - os.Stdout = w + viewer := NewViewer(ctx, NewPrinter(w), nil) viewer.OutputSummary() w.Close() - os.Stdout = osStdOut output, err := io.ReadAll(r) assert.NoError(t, err) - r.Close() expectedOutput := ` --- ping statistics --- @@ -179,17 +129,10 @@ rtt min/avg/max/mdev = -/-/-/- ms }) t.Run("Multiple_locations_Share", func(t *testing.T) { - osStdOut := os.Stdout - defer func() { - os.Stdout = osStdOut - }() - r, w, err := os.Pipe() assert.NoError(t, err) - defer func() { - w.Close() - r.Close() - }() + defer r.Close() + defer w.Close() ctx := &Context{ History: &Rbuffer{ @@ -202,33 +145,22 @@ rtt min/avg/max/mdev = -/-/-/- ms }, Share: true, } - viewer := NewViewer(ctx, nil) - os.Stdout = w + viewer := NewViewer(ctx, NewPrinter(w), nil) viewer.OutputSummary() w.Close() - os.Stdout = osStdOut output, err := io.ReadAll(r) assert.NoError(t, err) - r.Close() expectedOutput := "\n" + formatWithLeadingArrow(shareMessage(measurementID1+"+"+measurementID2), true) + "\n" - assert.Equal(t, expectedOutput, string(output)) }) t.Run("Multiple_locations_Share_More_calls_than_MaxHistory", func(t *testing.T) { - osStdOut := os.Stdout - defer func() { - os.Stdout = osStdOut - }() - r, w, err := os.Pipe() assert.NoError(t, err) - defer func() { - w.Close() - r.Close() - }() + defer r.Close() + defer w.Close() ctx := &Context{ History: &Rbuffer{ @@ -244,19 +176,15 @@ rtt min/avg/max/mdev = -/-/-/- ms MaxHistory: 1, Packets: 16, } - viewer := NewViewer(ctx, nil) - os.Stdout = w + viewer := NewViewer(ctx, NewPrinter(w), nil) viewer.OutputSummary() w.Close() - os.Stdout = osStdOut output, err := io.ReadAll(r) assert.NoError(t, err) - r.Close() expectedOutput := "\n" + formatWithLeadingArrow(shareMessage(measurementID2), true) + "\nFor long-running continuous mode measurements, only the last 16 packets are shared.\n" - assert.Equal(t, expectedOutput, string(output)) }) } diff --git a/view/view.go b/view/view.go index 7a29f12..1e4022e 100644 --- a/view/view.go +++ b/view/view.go @@ -29,17 +29,20 @@ type Viewer interface { } type viewer struct { - ctx *Context - gp globalping.Client + ctx *Context + printer *Printer + gp globalping.Client } func NewViewer( ctx *Context, + printer *Printer, gp globalping.Client, ) Viewer { return &viewer{ - ctx: ctx, - gp: gp, + ctx: ctx, + printer: printer, + gp: gp, } } From 78818886e4a01e22d033990f36c47b0e49496650 Mon Sep 17 00:00:00 2001 From: Radu Lucut Date: Wed, 7 Feb 2024 21:09:24 +0200 Subject: [PATCH 21/27] Refactoring, ping tests & fixes --- cmd/common_test.go | 72 ++++---- cmd/ping.go | 127 ++++++------- cmd/ping_test.go | 177 +++++++++++++++++++ cmd/root.go | 114 ++++++++++-- globalping/client.go | 31 ++++ globalping/globalping.go | 33 +--- globalping/globalping_test.go | 2 +- mocks/gen_mocks.sh | 3 +- mocks/{mock_globalping.go => mock_client.go} | 14 +- mocks/mock_viewer.go | 75 ++++++++ view/infinite_test.go | 26 ++- view/json.go | 2 +- view/json_test.go | 2 +- view/{view.go => output.go} | 24 --- view/{view_test.go => output_test.go} | 0 view/printer.go | 2 +- view/viewer.go | 27 +++ 17 files changed, 547 insertions(+), 184 deletions(-) create mode 100644 cmd/ping_test.go create mode 100644 globalping/client.go rename mocks/{mock_globalping.go => mock_client.go} (83%) create mode 100644 mocks/mock_viewer.go rename view/{view.go => output.go} (93%) rename view/{view_test.go => output_test.go} (100%) create mode 100644 view/viewer.go diff --git a/cmd/common_test.go b/cmd/common_test.go index 3b9cff9..af43d57 100644 --- a/cmd/common_test.go +++ b/cmd/common_test.go @@ -17,48 +17,42 @@ var ( measurementID4 = "hH3tBVPZEj5k6AcW" ) -func TestInProgressUpdates_CI(t *testing.T) { +func Test_InProgressUpdates_CI(t *testing.T) { ci := true assert.Equal(t, false, inProgressUpdates(ci)) } -func TestInProgressUpdates_NotCI(t *testing.T) { +func Test_InProgressUpdates_NotCI(t *testing.T) { ci := false assert.Equal(t, true, inProgressUpdates(ci)) } -func TestCreateLocations(t *testing.T) { +func Test_CreateLocations(t *testing.T) { for scenario, fn := range map[string]func(t *testing.T){ - "valid_single": testLocationsSingle, - "valid_multiple": testLocationsMultiple, - "valid_multiple_whitespace": testLocationsMultipleWhitespace, - "valid_session_last_measurement": testCreateLocationsSessionLastMeasurement, - "valid_session_first_measurement": testCreateLocationsSessionFirstMeasurement, - "valid_session_measurement_at_index": testCreateLocationsSessionMeasurementAtIndex, - "valid_session_no_session": testCreateLocationsSessionNoSession, - "invalid_session_index": testCreateLocationsSessionInvalidIndex, + "valid_single": test_CreateLocations_Single, + "valid_multiple": test_CreateLocations_Multiple, + "valid_multiple_whitespace": test_CreateLocations_Multiple_Whitespace, + "valid_session_last_measurement": test_CreateLocations_Session_Last_Measurement, + "valid_session_first_measurement": test_CreateLocations_Session_First_Measurement, + "valid_session_measurement_at_index": test_CreateLocations_Session_Measurement_At_Index, + "valid_session_no_session": test_CreateLocations_Session_No_Session, + "invalid_session_index": test_CreateLocations_Session_Invalid_Index, } { t.Run(scenario, func(t *testing.T) { fn(t) - t.Cleanup(func() { - sessionPath := getSessionPath() - err := os.RemoveAll(sessionPath) - if err != nil && !errors.Is(err, fs.ErrNotExist) { - t.Fatalf("Failed to remove session path: %s", err) - } - }) + t.Cleanup(sessionCleanup) }) } } -func testLocationsSingle(t *testing.T) { +func test_CreateLocations_Single(t *testing.T) { locations, isPreviousMeasurementId, err := createLocations("New York") assert.Equal(t, []globalping.Locations{{Magic: "New York"}}, locations) assert.False(t, isPreviousMeasurementId) assert.Nil(t, err) } -func testLocationsMultiple(t *testing.T) { +func test_CreateLocations_Multiple(t *testing.T) { locations, isPreviousMeasurementId, err := createLocations("New York,Los Angeles") assert.Equal(t, []globalping.Locations{{Magic: "New York"}, {Magic: "Los Angeles"}}, locations) assert.False(t, isPreviousMeasurementId) @@ -66,14 +60,14 @@ func testLocationsMultiple(t *testing.T) { } // Check if multiple locations with whitespace are parsed correctly -func testLocationsMultipleWhitespace(t *testing.T) { +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) assert.False(t, isPreviousMeasurementId) assert.Nil(t, err) } -func testCreateLocationsSessionLastMeasurement(t *testing.T) { +func test_CreateLocations_Session_Last_Measurement(t *testing.T) { _ = saveMeasurementID(measurementID1) locations, isPreviousMeasurementId, err := createLocations("@1") assert.Equal(t, []globalping.Locations{{Magic: measurementID1}}, locations) @@ -91,7 +85,7 @@ func testCreateLocationsSessionLastMeasurement(t *testing.T) { assert.Nil(t, err) } -func testCreateLocationsSessionFirstMeasurement(t *testing.T) { +func test_CreateLocations_Session_First_Measurement(t *testing.T) { _ = saveMeasurementID(measurementID1) _ = saveMeasurementID(measurementID2) locations, isPreviousMeasurementId, err := createLocations("@-1") @@ -105,7 +99,7 @@ func testCreateLocationsSessionFirstMeasurement(t *testing.T) { assert.Nil(t, err) } -func testCreateLocationsSessionMeasurementAtIndex(t *testing.T) { +func test_CreateLocations_Session_Measurement_At_Index(t *testing.T) { _ = saveMeasurementID(measurementID1) _ = saveMeasurementID(measurementID2) _ = saveMeasurementID(measurementID3) @@ -126,14 +120,14 @@ func testCreateLocationsSessionMeasurementAtIndex(t *testing.T) { assert.Nil(t, err) } -func testCreateLocationsSessionNoSession(t *testing.T) { +func test_CreateLocations_Session_No_Session(t *testing.T) { locations, isPreviousMeasurementId, err := createLocations("@1") assert.Nil(t, locations) assert.False(t, isPreviousMeasurementId) assert.Equal(t, ErrorNoPreviousMeasurements, err) } -func testCreateLocationsSessionInvalidIndex(t *testing.T) { +func test_CreateLocations_Session_Invalid_Index(t *testing.T) { locations, isPreviousMeasurementId, err := createLocations("@0") assert.Nil(t, locations) assert.False(t, isPreviousMeasurementId) @@ -161,25 +155,19 @@ func testCreateLocationsSessionInvalidIndex(t *testing.T) { assert.Equal(t, ErrIndexOutOfRange, err) } -func TestSaveMeasurementID(t *testing.T) { +func Test_SaveMeasurementID(t *testing.T) { for scenario, fn := range map[string]func(t *testing.T){ - "valid_new_session": testSaveMeasurementIDNewSession, - "valid_existing_session": testSaveMeasurementIDExistingSession, + "valid_new_session": test_SaveMeasurementID_New_Session, + "valid_existing_session": test_SaveMeasurementID_Existing_Session, } { t.Run(scenario, func(t *testing.T) { fn(t) - t.Cleanup(func() { - sessionPath := getSessionPath() - err := os.RemoveAll(sessionPath) - if err != nil && !os.IsNotExist(err) { - t.Fatalf("Failed to remove session path: %s", err) - } - }) + t.Cleanup(sessionCleanup) }) } } -func testSaveMeasurementIDNewSession(t *testing.T) { +func test_SaveMeasurementID_New_Session(t *testing.T) { _ = saveMeasurementID(measurementID1) assert.FileExists(t, getMeasurementsPath()) b, err := os.ReadFile(getMeasurementsPath()) @@ -188,7 +176,7 @@ func testSaveMeasurementIDNewSession(t *testing.T) { assert.Equal(t, expected, b) } -func testSaveMeasurementIDExistingSession(t *testing.T) { +func test_SaveMeasurementID_Existing_Session(t *testing.T) { err := os.Mkdir(getSessionPath(), 0755) if err != nil { t.Fatalf("Failed to create session path: %s", err) @@ -203,3 +191,11 @@ func testSaveMeasurementIDExistingSession(t *testing.T) { expected := []byte(measurementID1 + "\n" + measurementID2 + "\n") assert.Equal(t, expected, b) } + +func sessionCleanup() { + sessionPath := getSessionPath() + err := os.RemoveAll(sessionPath) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + panic("Failed to remove session path: " + err.Error()) + } +} diff --git a/cmd/ping.go b/cmd/ping.go index 658ff17..895777c 100644 --- a/cmd/ping.go +++ b/cmd/ping.go @@ -10,12 +10,12 @@ import ( "github.com/spf13/cobra" ) -// pingCmd represents the ping command -var pingCmd = &cobra.Command{ - Use: "ping [target] from [location | measurement ID | @1 | first | @-1 | last | previous]", - GroupID: "Measurements", - Short: "Run a ping test", - Long: `The ping command allows sending ping requests to a target. Often used to test the network latency and stability. +func (r *Root) initPing() { + pingCmd := &cobra.Command{ + Use: "ping [target] from [location | measurement ID | @1 | first | @-1 | last | previous]", + GroupID: "Measurements", + Short: "Run a ping test", + Long: `The ping command allows sending ping requests to a target. Often used to test the network latency and stability. Examples: # Ping google.com from 2 probes in New York @@ -41,102 +41,103 @@ Examples: # Ping jsdelivr.com from a probe in ASN 123 with json output ping jsdelivr.com from 123 --json - + # Continuously ping google.com from New York ping google.com from New York --infinite`, - RunE: func(cmd *cobra.Command, args []string) error { - err := createContext(cmd.CalledAs(), args) - if err != nil { - return err - } - if ctx.Infinite { - return infinitePing(cmd) - } - _, err = ping(cmd) - return err - }, -} - -func infinitePing(cmd *cobra.Command) error { - var err error - if ctx.Limit > 5 { - return fmt.Errorf("continous mode is currently limited to 5 probes") + RunE: r.RunPing, } - ctx.Packets = 16 // Default to 16 packets - // Trap sigterm or interupt to display info on exit - sig := make(chan os.Signal, 1) - signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + // ping specific flags + flags := pingCmd.Flags() + flags.IntVar(&r.ctx.Packets, "packets", 0, "Specifies the desired amount of ECHO_REQUEST packets to be sent (default 3)") + flags.BoolVar(&r.ctx.Infinite, "infinite", false, "Keep pinging the target continuously until stopped (default false)") - go func() { - for { - ctx.From, err = ping(cmd) - if err != nil { - sig <- syscall.SIGINT - return - } - } - }() + r.Cmd.AddCommand(pingCmd) +} - <-sig - if err == nil { - viewer.OutputSummary() +func (r *Root) RunPing(cmd *cobra.Command, args []string) error { + err := r.updateContext(cmd.CalledAs(), args) + if err != nil { + return err + } + if r.ctx.Infinite { + return r.pingInfinite() } + _, err = r.ping() return err } -func ping(cmd *cobra.Command) (string, error) { - opts = globalping.MeasurementCreate{ +func (r *Root) ping() (string, error) { + opts := &globalping.MeasurementCreate{ Type: "ping", - Target: ctx.Target, - Limit: ctx.Limit, - InProgressUpdates: inProgressUpdates(ctx.CI), + Target: r.ctx.Target, + Limit: r.ctx.Limit, + InProgressUpdates: inProgressUpdates(r.ctx.CI), Options: &globalping.MeasurementOptions{ - Packets: ctx.Packets, + Packets: r.ctx.Packets, }, } var err error isPreviousMeasurementId := true - if ctx.CallCount == 0 { - opts.Locations, isPreviousMeasurementId, err = createLocations(ctx.From) + if r.ctx.CallCount == 0 { + opts.Locations, isPreviousMeasurementId, err = createLocations(r.ctx.From) if err != nil { - cmd.SilenceUsage = true + r.Cmd.SilenceUsage = true return "", err } } else { - opts.Locations = []globalping.Locations{{Magic: ctx.From}} + opts.Locations = []globalping.Locations{{Magic: r.ctx.From}} } - res, showHelp, err := gp.CreateMeasurement(&opts) + res, showHelp, err := r.gp.CreateMeasurement(opts) if err != nil { if !showHelp { - cmd.SilenceUsage = true + r.Cmd.SilenceUsage = true } return "", err } - ctx.CallCount++ + r.ctx.CallCount++ // 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) } } - - if ctx.Infinite { - err = viewer.OutputInfinite(res.ID) + if r.ctx.Infinite { + err = r.viewer.OutputInfinite(res.ID) } else { - viewer.Output(res.ID, &opts) + r.viewer.Output(res.ID, opts) } return res.ID, err } -func init() { - rootCmd.AddCommand(pingCmd) +func (r *Root) pingInfinite() error { + var err error + if r.ctx.Limit > 5 { + return fmt.Errorf("continous mode is currently limited to 5 probes") + } + r.ctx.Packets = 16 // Default to 16 packets + + // Trap sigterm or interupt to display info on exit + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) - // ping specific flags - pingCmd.Flags().IntVar(&ctx.Packets, "packets", 0, "Specifies the desired amount of ECHO_REQUEST packets to be sent (default 3)") - pingCmd.Flags().BoolVar(&ctx.Infinite, "infinite", false, "Keep pinging the target continuously until stopped (default false)") + go func() { + for { + r.ctx.From, err = r.ping() + if err != nil { + sig <- syscall.SIGINT + return + } + } + }() + + <-sig + if err == nil { + r.viewer.OutputSummary() + } + return err } diff --git a/cmd/ping_test.go b/cmd/ping_test.go new file mode 100644 index 0000000..3a8e369 --- /dev/null +++ b/cmd/ping_test.go @@ -0,0 +1,177 @@ +package cmd + +import ( + "context" + "io" + "os" + "os/signal" + "syscall" + "testing" + "time" + + "github.com/golang/mock/gomock" + "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" +) + +func Test_Execute_Ping_Default(t *testing.T) { + t.Cleanup(sessionCleanup) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + expectedOpts := getMeasurementCreate() + 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(w) + root := NewRoot(w, w, printer, ctx, viewerMock, gbMock, &cobra.Command{}) + os.Args = []string{"globalping", "ping", "jsdelivr.com"} + 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 := getExpectedViewContext() + assert.Equal(t, expectedCtx, ctx) + + b, err := os.ReadFile(getMeasurementsPath()) + assert.NoError(t, err) + expectedHistory := []byte(measurementID1 + "\n") + assert.Equal(t, expectedHistory, b) +} + +func Test_Execute_Ping_Infinite(t *testing.T) { + t.Cleanup(sessionCleanup) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + expectedOpts1 := getMeasurementCreate() + expectedOpts1.Options.Packets = 16 + expectedOpts2 := getMeasurementCreate() + expectedOpts2.Options.Packets = 16 + expectedOpts2.Locations[0].Magic = measurementID1 + expectedOpts3 := getMeasurementCreate() + expectedOpts3.Options.Packets = 16 + expectedOpts3.Locations[0].Magic = measurementID2 + + expectedResponse1 := getMeasurementCreateResponse(measurementID1) + expectedResponse2 := getMeasurementCreateResponse(measurementID2) + expectedResponse3 := getMeasurementCreateResponse(measurementID3) + + gbMock := mocks.NewMockClient(ctrl) + call1 := gbMock.EXPECT().CreateMeasurement(expectedOpts1).Return(expectedResponse1, false, nil) + call2 := gbMock.EXPECT().CreateMeasurement(expectedOpts2).Return(expectedResponse2, false, nil).After(call1) + gbMock.EXPECT().CreateMeasurement(expectedOpts3).Return(expectedResponse3, false, nil).After(call2) + + viewerMock := mocks.NewMockViewer(ctrl) + outputCall1 := viewerMock.EXPECT().OutputInfinite(measurementID1).DoAndReturn(func(id string) error { + time.Sleep(5 * time.Millisecond) + return nil + }) + outputCall2 := viewerMock.EXPECT().OutputInfinite(measurementID2).DoAndReturn(func(id string) error { + time.Sleep(5 * time.Millisecond) + return nil + }).After(outputCall1) + viewerMock.EXPECT().OutputInfinite(measurementID3).DoAndReturn(func(id string) error { + time.Sleep(5 * time.Millisecond) + return nil + }).After(outputCall2) + + viewerMock.EXPECT().OutputSummary().Times(1) + + 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, gbMock, &cobra.Command{}) + os.Args = []string{"globalping", "ping", "jsdelivr.com", "--infinite"} + + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT) + go func() { + time.Sleep(14 * time.Millisecond) + p, _ := os.FindProcess(os.Getpid()) + p.Signal(os.Interrupt) + }() + err = root.Cmd.ExecuteContext(context.TODO()) + <-sig + + assert.NoError(t, err) + w.Close() + + output, err := io.ReadAll(r) + assert.NoError(t, err) + assert.Equal(t, "", string(output)) + + expectedCtx := &view.Context{ + Cmd: "ping", + Target: "jsdelivr.com", + Limit: 1, + CI: true, + Infinite: true, + CallCount: 3, + From: measurementID2, + Packets: 16, + } + assert.Equal(t, expectedCtx, ctx) + + b, err := os.ReadFile(getMeasurementsPath()) + assert.NoError(t, err) + expectedHistory := []byte(measurementID1 + "\n") + assert.Equal(t, expectedHistory, b) +} + +func getExpectedViewContext() *view.Context { + return &view.Context{ + Cmd: "ping", + Target: "jsdelivr.com", + Limit: 1, + CI: true, + CallCount: 1, + From: "world", + MaxHistory: 1, + } +} + +func getMeasurementCreate() *globalping.MeasurementCreate { + return &globalping.MeasurementCreate{ + Type: "ping", + Target: "jsdelivr.com", + Limit: 1, + Options: &globalping.MeasurementOptions{}, + Locations: []globalping.Locations{ + {Magic: "world"}, + }, + } +} + +func getMeasurementCreateResponse(id string) *globalping.MeasurementCreateResponse { + return &globalping.MeasurementCreateResponse{ + ID: id, + ProbesCount: 1, + } +} diff --git a/cmd/root.go b/cmd/root.go index 5a6ff8e..5b0dd08 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,6 +1,7 @@ package cmd import ( + "io" "os" "github.com/jsdelivr/globalping-cli/globalping" @@ -10,6 +11,8 @@ import ( "github.com/spf13/cobra" ) +// TODO: Remove global variables + var ( // Global flags // cfgFile string @@ -29,7 +32,9 @@ var ( MaxHistory: 10, } gp = globalping.NewClient(globalping.API_URL) - printer = view.NewPrinter(os.Stdout) + outW = os.Stdout + errW = os.Stderr + printer = view.NewPrinter(outW) viewer = view.NewViewer(ctx, printer, gp) ) @@ -41,28 +46,109 @@ var rootCmd = &cobra.Command{ 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, gp, rootCmd) + +type Root struct { + outW io.Writer + printer *view.Printer + ctx *view.Context + viewer view.Viewer + gp globalping.Client + Cmd *cobra.Command +} + +func NewRoot( + outW io.Writer, + errW io.Writer, + printer *view.Printer, + ctx *view.Context, + viewer view.Viewer, + gp globalping.Client, + cmd *cobra.Command, +) *Root { + root := &Root{ + Cmd: cmd, + outW: outW, + printer: printer, + ctx: ctx, + viewer: viewer, + gp: gp, + } + + root.Cmd.SetOut(outW) + root.Cmd.SetErr(errW) + + // Global flags + flags := root.Cmd.PersistentFlags() + flags.StringVarP(&ctx.From, "from", "F", "world", `Comma-separated list of location values to match against or a measurement ID + For example, the partial or full name of a continent, region (e.g eastern europe), country, US state, city or network + 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.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.initPing() + + 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() { - rootCmd.AddGroup(&cobra.Group{ID: "Measurements", Title: "Measurement Commands:"}) - err := rootCmd.Execute() + err := root.Cmd.Execute() if err != nil { os.Exit(1) } } -func init() { - // Global flags - rootCmd.PersistentFlags().StringVarP(&ctx.From, "from", "F", "world", `Comma-separated list of location values to match against or a measurement ID -For example, the partial or full name of a continent, region (e.g eastern europe), country, US state, city or network -Or use [@1 | first, @2 ... @-2, @-1 | last | previous] to run with the probes from previous measurements.`) - rootCmd.PersistentFlags().IntVarP(&ctx.Limit, "limit", "L", 1, "Limit the number of probes to use") - rootCmd.PersistentFlags().BoolVarP(&ctx.ToJSON, "json", "J", false, "Output results in JSON format (default false)") - rootCmd.PersistentFlags().BoolVarP(&ctx.CI, "ci", "C", false, "Disable realtime terminal updates and color suitable for CI and scripting (default false)") - rootCmd.PersistentFlags().BoolVar(&ctx.ToLatency, "latency", false, "Output only the stats of a measurement (default false). Only applies to the dns, http and ping commands") - rootCmd.PersistentFlags().BoolVar(&ctx.Share, "share", false, "Prints a link at the end the results, allowing to vizualize the results online (default false)") +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 @@ -89,7 +175,7 @@ func createContext(cmd string, args []string) error { // Check if it is a terminal or being piped/redirected // We want to disable realtime updates if that is the case - stdoutFileInfo, err := os.Stdout.Stat() + stdoutFileInfo, err := outW.Stat() if err != nil { return errors.Wrapf(err, "stdout stat failed") } diff --git a/globalping/client.go b/globalping/client.go new file mode 100644 index 0000000..a11f364 --- /dev/null +++ b/globalping/client.go @@ -0,0 +1,31 @@ +package globalping + +import ( + "net/http" + "time" +) + +type Client interface { + CreateMeasurement(measurement *MeasurementCreate) (*MeasurementCreateResponse, bool, error) + GetMeasurement(id string) (*Measurement, error) + GetMeasurementRaw(id string) ([]byte, error) +} + +type client struct { + http *http.Client + apiUrl string // The api url endpoint + + etags map[string]string // caches Etags by measurement id + measurements map[string][]byte // caches Measurements by ETag +} + +func NewClient(url string) Client { + return &client{ + http: &http.Client{ + Timeout: 30 * time.Second, + }, + apiUrl: url, + etags: map[string]string{}, + measurements: map[string][]byte{}, + } +} diff --git a/globalping/globalping.go b/globalping/globalping.go index f318456..d7919a1 100644 --- a/globalping/globalping.go +++ b/globalping/globalping.go @@ -18,33 +18,6 @@ var ( API_MIN_INTERVAL = 500 * time.Millisecond ) -type Client interface { - CreateMeasurement(measurement *MeasurementCreate) (*MeasurementCreateResponse, bool, error) - GetMeasurement(id string) (*Measurement, error) - GetRawMeasurement(id string) ([]byte, error) -} - -type client struct { - http *http.Client - apiUrl string // The api url endpoint - PacketsMax int // Maximum number of packets to send - - etags map[string]string // caches Etags by measurement id - measurements map[string][]byte // caches Measurements by ETag -} - -func NewClient(url string) Client { - return &client{ - http: &http.Client{ - Timeout: 30 * time.Second, - }, - apiUrl: url, - PacketsMax: 16, - etags: map[string]string{}, - measurements: map[string][]byte{}, - } -} - // boolean indicates whether to print CLI help on error func (c *client) CreateMeasurement(measurement *MeasurementCreate) (*MeasurementCreateResponse, bool, error) { postData, err := json.Marshal(measurement) @@ -119,7 +92,7 @@ func (c *client) CreateMeasurement(measurement *MeasurementCreate) (*Measurement // GetRawMeasurement returns API response as a GetMeasurement object func (c *client) GetMeasurement(id string) (*Measurement, error) { - respBytes, err := c.GetRawMeasurement(id) + respBytes, err := c.GetMeasurementRaw(id) if err != nil { return nil, err } @@ -131,8 +104,8 @@ func (c *client) GetMeasurement(id string) (*Measurement, error) { return m, nil } -// GetRawMeasurement returns the API response's raw json response -func (c *client) GetRawMeasurement(id string) ([]byte, error) { +// GetMeasurementRaw returns the API response's raw json response +func (c *client) GetMeasurementRaw(id string) ([]byte, error) { // Create a new request req, err := http.NewRequest("GET", c.apiUrl+"/"+id, nil) if err != nil { diff --git a/globalping/globalping_test.go b/globalping/globalping_test.go index ae9336c..0222792 100644 --- a/globalping/globalping_test.go +++ b/globalping/globalping_test.go @@ -138,7 +138,7 @@ func testGetJson(t *testing.T) { server := generateServer(`{"id":"abcd"}`) defer server.Close() client := NewClient(server.URL) - res, err := client.GetRawMeasurement("abcd") + res, err := client.GetMeasurementRaw("abcd") if err != nil { t.Error(err) } diff --git a/mocks/gen_mocks.sh b/mocks/gen_mocks.sh index 4b83089..f409b3e 100755 --- a/mocks/gen_mocks.sh +++ b/mocks/gen_mocks.sh @@ -1,3 +1,4 @@ rm -rf mocks/mock_*.go -bin/mockgen -source globalping/globalping.go -destination mocks/mock_globalping.go -package mocks +bin/mockgen -source globalping/client.go -destination mocks/mock_client.go -package mocks +bin/mockgen -source view/viewer.go -destination mocks/mock_viewer.go -package mocks diff --git a/mocks/mock_globalping.go b/mocks/mock_client.go similarity index 83% rename from mocks/mock_globalping.go rename to mocks/mock_client.go index f975a37..38fb2aa 100644 --- a/mocks/mock_globalping.go +++ b/mocks/mock_client.go @@ -1,5 +1,5 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: globalping/globalping.go +// Source: globalping/client.go // Package mocks is a generated GoMock package. package mocks @@ -65,17 +65,17 @@ func (mr *MockClientMockRecorder) GetMeasurement(id interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMeasurement", reflect.TypeOf((*MockClient)(nil).GetMeasurement), id) } -// GetRawMeasurement mocks base method. -func (m *MockClient) GetRawMeasurement(id string) ([]byte, error) { +// GetMeasurementRaw mocks base method. +func (m *MockClient) GetMeasurementRaw(id string) ([]byte, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetRawMeasurement", id) + ret := m.ctrl.Call(m, "GetMeasurementRaw", id) ret0, _ := ret[0].([]byte) ret1, _ := ret[1].(error) return ret0, ret1 } -// GetRawMeasurement indicates an expected call of GetRawMeasurement. -func (mr *MockClientMockRecorder) GetRawMeasurement(id interface{}) *gomock.Call { +// GetMeasurementRaw indicates an expected call of GetMeasurementRaw. +func (mr *MockClientMockRecorder) GetMeasurementRaw(id interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRawMeasurement", reflect.TypeOf((*MockClient)(nil).GetRawMeasurement), id) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMeasurementRaw", reflect.TypeOf((*MockClient)(nil).GetMeasurementRaw), id) } diff --git a/mocks/mock_viewer.go b/mocks/mock_viewer.go new file mode 100644 index 0000000..9d5de3c --- /dev/null +++ b/mocks/mock_viewer.go @@ -0,0 +1,75 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: view/viewer.go + +// Package mocks is a generated GoMock package. +package mocks + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + globalping "github.com/jsdelivr/globalping-cli/globalping" +) + +// MockViewer is a mock of Viewer interface. +type MockViewer struct { + ctrl *gomock.Controller + recorder *MockViewerMockRecorder +} + +// MockViewerMockRecorder is the mock recorder for MockViewer. +type MockViewerMockRecorder struct { + mock *MockViewer +} + +// NewMockViewer creates a new mock instance. +func NewMockViewer(ctrl *gomock.Controller) *MockViewer { + mock := &MockViewer{ctrl: ctrl} + mock.recorder = &MockViewerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockViewer) EXPECT() *MockViewerMockRecorder { + return m.recorder +} + +// Output mocks base method. +func (m_2 *MockViewer) Output(id string, m *globalping.MeasurementCreate) error { + m_2.ctrl.T.Helper() + ret := m_2.ctrl.Call(m_2, "Output", id, m) + ret0, _ := ret[0].(error) + return ret0 +} + +// Output indicates an expected call of Output. +func (mr *MockViewerMockRecorder) Output(id, m interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Output", reflect.TypeOf((*MockViewer)(nil).Output), id, m) +} + +// OutputInfinite mocks base method. +func (m *MockViewer) OutputInfinite(id string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "OutputInfinite", id) + ret0, _ := ret[0].(error) + return ret0 +} + +// OutputInfinite indicates an expected call of OutputInfinite. +func (mr *MockViewerMockRecorder) OutputInfinite(id interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OutputInfinite", reflect.TypeOf((*MockViewer)(nil).OutputInfinite), id) +} + +// OutputSummary mocks base method. +func (m *MockViewer) OutputSummary() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "OutputSummary") +} + +// OutputSummary indicates an expected call of OutputSummary. +func (mr *MockViewerMockRecorder) OutputSummary() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OutputSummary", reflect.TypeOf((*MockViewer)(nil).OutputSummary)) +} diff --git a/view/infinite_test.go b/view/infinite_test.go index cff093c..7735e27 100644 --- a/view/infinite_test.go +++ b/view/infinite_test.go @@ -138,6 +138,8 @@ func Test_OutputInfinite_MultipleProbes_MultipleCalls(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() + osStdout := os.Stdout + gbMock := mocks.NewMockClient(ctrl) res := getPingGetMeasurementMultipleLocations(measurementID1) @@ -194,6 +196,7 @@ rtt min/avg/max/mdev = 17.006/17.333/17.648/0.321 ms` MaxHistory: 3, } viewer := NewViewer(ctx, NewPrinter(w), gbMock) + os.Stdout = w err = viewer.OutputInfinite(measurementID1) assert.NoError(t, err) @@ -235,12 +238,15 @@ rtt min/avg/max/mdev = 17.006/17.333/17.648/0.321 ms` assert.NoError(t, err) defer rr.Close() defer ww.Close() - pterm.DefaultArea.SetWriter(ww) + os.Stdout = ww + // TODO: fix writer for AreaPrinter + // pterm.DefaultArea.SetWriter(ww) area, _ := pterm.DefaultArea.Start() for i := range expectedTables { area.Update(*expectedTables[i]) } area.Stop() + os.Stdout = osStdout ww.Close() expectedOutput, err := io.ReadAll(rr) @@ -252,6 +258,8 @@ func Test_OutputInfinite_MultipleProbes(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() + osStdout := os.Stdout + measurement := getPingGetMeasurementMultipleLocations(measurementID1) gbMock := mocks.NewMockClient(ctrl) gbMock.EXPECT().GetMeasurement(measurementID1).Times(1).Return(measurement, nil) @@ -266,8 +274,10 @@ func Test_OutputInfinite_MultipleProbes(t *testing.T) { MaxHistory: 3, } v := NewViewer(ctx, NewPrinter(w), gbMock) + os.Stdout = w err = v.OutputInfinite(measurementID1) assert.NoError(t, err) + os.Stdout = osStdout w.Close() output, err := io.ReadAll(r) @@ -276,12 +286,22 @@ func Test_OutputInfinite_MultipleProbes(t *testing.T) { expectedViewer := &viewer{ ctx: getDefaultPingCtx(len(measurement.Results)), } - expectedTable, _ := expectedViewer.generateTable(measurement, 78) + + rr, ww, err := os.Pipe() + assert.NoError(t, err) + defer rr.Close() + defer ww.Close() + os.Stdout = ww + // TODO: fix writer for AreaPrinter + // pterm.DefaultArea.SetWriter(ww) area, _ := pterm.DefaultArea.Start() + expectedTable, _ := expectedViewer.generateTable(measurement, 78) area.Update(*expectedTable) area.Stop() + os.Stdout = osStdout + ww.Close() - expectedOutput, err := io.ReadAll(r) + expectedOutput, err := io.ReadAll(rr) assert.NoError(t, err) assert.Equal(t, string(expectedOutput), string(output)) assert.Equal(t, diff --git a/view/json.go b/view/json.go index ce8a5eb..a6d2457 100644 --- a/view/json.go +++ b/view/json.go @@ -2,7 +2,7 @@ package view // Outputs the raw JSON for a measurement func (v *viewer) OutputJson(id string) error { - output, err := v.gp.GetRawMeasurement(id) + output, err := v.gp.GetMeasurementRaw(id) if err != nil { return err } diff --git a/view/json_test.go b/view/json_test.go index 9c61eb2..1171d6f 100644 --- a/view/json_test.go +++ b/view/json_test.go @@ -20,7 +20,7 @@ func Test_Output_Json(t *testing.T) { gbMock := mocks.NewMockClient(ctrl) measurement := getPingGetMeasurement(measurementID1) gbMock.EXPECT().GetMeasurement(measurementID1).Times(1).Return(measurement, nil) - gbMock.EXPECT().GetRawMeasurement(measurementID1).Times(1).Return(b, nil) + gbMock.EXPECT().GetMeasurementRaw(measurementID1).Times(1).Return(b, nil) r, w, err := os.Pipe() assert.NoError(t, err) diff --git a/view/view.go b/view/output.go similarity index 93% rename from view/view.go rename to view/output.go index 1e4022e..74cc1cd 100644 --- a/view/view.go +++ b/view/output.go @@ -22,30 +22,6 @@ var ( terminalLayoutBold = lipgloss.NewStyle().Bold(true) ) -type Viewer interface { - Output(id string, m *globalping.MeasurementCreate) error - OutputInfinite(id string) error - OutputSummary() -} - -type viewer struct { - ctx *Context - printer *Printer - gp globalping.Client -} - -func NewViewer( - ctx *Context, - printer *Printer, - gp globalping.Client, -) Viewer { - return &viewer{ - ctx: ctx, - printer: printer, - gp: gp, - } -} - 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) diff --git a/view/view_test.go b/view/output_test.go similarity index 100% rename from view/view_test.go rename to view/output_test.go diff --git a/view/printer.go b/view/printer.go index 16950d1..14a4737 100644 --- a/view/printer.go +++ b/view/printer.go @@ -12,7 +12,7 @@ type Printer struct { } func NewPrinter(writer io.Writer) *Printer { - pterm.SetDefaultOutput(writer) + pterm.SetDefaultOutput(writer) // TODO: Set writer for AreaPrinter return &Printer{ w: writer, } diff --git a/view/viewer.go b/view/viewer.go new file mode 100644 index 0000000..d72c0ae --- /dev/null +++ b/view/viewer.go @@ -0,0 +1,27 @@ +package view + +import "github.com/jsdelivr/globalping-cli/globalping" + +type Viewer interface { + Output(id string, m *globalping.MeasurementCreate) error + OutputInfinite(id string) error + OutputSummary() +} + +type viewer struct { + ctx *Context + printer *Printer + gp globalping.Client +} + +func NewViewer( + ctx *Context, + printer *Printer, + gp globalping.Client, +) Viewer { + return &viewer{ + ctx: ctx, + printer: printer, + gp: gp, + } +} From ea5c2b894d6003e30c0b43a3b68fd5dfa13b6854 Mon Sep 17 00:00:00 2001 From: Radu Lucut Date: Thu, 8 Feb 2024 00:10:00 +0200 Subject: [PATCH 22/27] Skip test on windows --- cmd/ping_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cmd/ping_test.go b/cmd/ping_test.go index 3a8e369..11c9373 100644 --- a/cmd/ping_test.go +++ b/cmd/ping_test.go @@ -5,6 +5,7 @@ import ( "io" "os" "os/signal" + "runtime" "syscall" "testing" "time" @@ -61,6 +62,9 @@ func Test_Execute_Ping_Default(t *testing.T) { } func Test_Execute_Ping_Infinite(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skipping test on windows") // Signal(syscall.SIGINT) is not supported on Windows + } t.Cleanup(sessionCleanup) ctrl := gomock.NewController(t) @@ -115,7 +119,7 @@ func Test_Execute_Ping_Infinite(t *testing.T) { go func() { time.Sleep(14 * time.Millisecond) p, _ := os.FindProcess(os.Getpid()) - p.Signal(os.Interrupt) + p.Signal(syscall.SIGINT) }() err = root.Cmd.ExecuteContext(context.TODO()) <-sig From 96a19a821fb98754940f932c0b7569d3f3debf63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kol=C3=A1rik?= Date: Mon, 12 Feb 2024 11:55:06 +0100 Subject: [PATCH 23/27] Update actions --- .github/workflows/release.yml | 4 ++-- .github/workflows/tests.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7381003..7a010f1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,12 +15,12 @@ jobs: goreleaser: runs-on: windows-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - run: git fetch --force --tags - - uses: actions/setup-go@v3 + - uses: actions/setup-go@v5 with: go-version: ">=1.21.3" cache: true diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 87d84c3..e7e8e9e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,9 +16,9 @@ jobs: os: [ubuntu-latest, macOS-latest, windows-latest] name: ${{ matrix.os }} Go ${{ matrix.go }} Tests steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup Go - uses: actions/setup-go@v3 + uses: actions/setup-go@v5 with: go-version: ${{ matrix.go }} cache: true From 877c51a729c251c87d8970e9f77f08c7bf9998b2 Mon Sep 17 00:00:00 2001 From: Radu Lucut Date: Mon, 12 Feb 2024 22:56:41 +0200 Subject: [PATCH 24/27] Handle failed measurements --- cmd/ping.go | 1 + cmd/ping_test.go | 54 +++++++++++++++++++++++++ view/infinite.go | 23 +++++++++++ view/infinite_test.go | 91 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 169 insertions(+) diff --git a/cmd/ping.go b/cmd/ping.go index 895777c..2c16f43 100644 --- a/cmd/ping.go +++ b/cmd/ping.go @@ -108,6 +108,7 @@ func (r *Root) ping() (string, error) { } if r.ctx.Infinite { err = r.viewer.OutputInfinite(res.ID) + r.Cmd.SilenceUsage = true } else { r.viewer.Output(res.ID, opts) } diff --git a/cmd/ping_test.go b/cmd/ping_test.go index 11c9373..06d880a 100644 --- a/cmd/ping_test.go +++ b/cmd/ping_test.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "errors" "io" "os" "os/signal" @@ -149,6 +150,59 @@ func Test_Execute_Ping_Infinite(t *testing.T) { assert.Equal(t, expectedHistory, b) } +func Test_Execute_Ping_Infinite_Output_Error(t *testing.T) { + t.Cleanup(sessionCleanup) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + expectedOpts1 := getMeasurementCreate() + expectedOpts1.Options.Packets = 16 + + expectedResponse1 := getMeasurementCreateResponse(measurementID1) + + gbMock := mocks.NewMockClient(ctrl) + gbMock.EXPECT().CreateMeasurement(expectedOpts1).Return(expectedResponse1, false, nil) + + viewerMock := mocks.NewMockViewer(ctrl) + viewerMock.EXPECT().OutputInfinite(measurementID1).Return(errors.New("error message")) + viewerMock.EXPECT().OutputSummary().Times(0) + + 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, gbMock, &cobra.Command{}) + os.Args = []string{"globalping", "ping", "jsdelivr.com", "--infinite"} + err = root.Cmd.ExecuteContext(context.TODO()) + assert.Equal(t, "error message", err.Error()) + w.Close() + + output, err := io.ReadAll(r) + assert.NoError(t, err) + assert.Equal(t, "Error: error message\n", string(output)) + + expectedCtx := &view.Context{ + Cmd: "ping", + Target: "jsdelivr.com", + Limit: 1, + CI: true, + Infinite: true, + CallCount: 1, + From: measurementID1, + Packets: 16, + } + assert.Equal(t, expectedCtx, ctx) + + b, err := os.ReadFile(getMeasurementsPath()) + assert.NoError(t, err) + expectedHistory := []byte(measurementID1 + "\n") + assert.Equal(t, expectedHistory, b) +} + func getExpectedViewContext() *view.Context { return &view.Context{ Cmd: "ping", diff --git a/view/infinite.go b/view/infinite.go index efef38d..82f227c 100644 --- a/view/infinite.go +++ b/view/infinite.go @@ -68,6 +68,9 @@ func (v *viewer) outputStreamingPackets(res *globalping.Measurement) error { var err error for { measurement := &res.Results[0] + if isFailedMeasurement(res) { + return v.outputFailSummary(res) + } if measurement.Result.RawOutput != "" { parsedOutput := parsePingRawOutput(measurement, v.ctx.CompletedStats[0].Sent) if printHeader && v.ctx.CompletedStats[0].Sent == 0 { @@ -119,6 +122,9 @@ func (v *viewer) outputTableView(res *globalping.Measurement) error { } } for { + if isFailedMeasurement(res) { + return v.outputFailSummary(res) + } o, stats := v.generateTable(res, pterm.GetTerminalWidth()-2) if o != nil { v.ctx.Area.Update(*o) @@ -141,6 +147,23 @@ func (v *viewer) outputTableView(res *globalping.Measurement) error { return nil } +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(res.Results[i].Result.RawOutput) + } + return errors.New("all probes failed") +} + +func isFailedMeasurement(res *globalping.Measurement) bool { + for i := range res.Results { + if res.Results[i].Result.Status != globalping.StatusFailed { + return false + } + } + return true +} + func formatDuration(ms float64) string { if ms < 10 { return fmt.Sprintf("%.2f ms", ms) diff --git a/view/infinite_test.go b/view/infinite_test.go index 7735e27..0247438 100644 --- a/view/infinite_test.go +++ b/view/infinite_test.go @@ -90,6 +90,45 @@ PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. assertMeasurementStats(t, &expectedStats[0], &ctx.CompletedStats[0]) } +func Test_OutputInfinite_SingleProbe_Failed(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + gbMock := mocks.NewMockClient(ctrl) + measurement := getPingGetMeasurement(measurementID1) + measurement.Status = globalping.StatusFailed + measurement.Results[0].Result.Status = globalping.StatusFailed + measurement.Results[0].Result.RawOutput = `ping: cdn.jsdelivr.net.xc: Name or service not known` + gbMock.EXPECT().GetMeasurement(measurementID1).Times(1).Return(measurement, nil) + + r, w, err := os.Pipe() + assert.NoError(t, err) + defer r.Close() + defer w.Close() + + ctx := &Context{ + Cmd: "ping", + MaxHistory: 3, + } + viewer := NewViewer(ctx, NewPrinter(w), gbMock) + err = viewer.OutputInfinite(measurement.ID) + assert.Equal(t, "all probes failed", err.Error()) + w.Close() + + output, err := io.ReadAll(r) + assert.NoError(t, err) + assert.Equal(t, + `> EU, DE, Berlin, ASN:3320, Deutsche Telekom AG +ping: cdn.jsdelivr.net.xc: Name or service not known +`, + string(output), + ) + + expectedStats := []MeasurementStats{NewMeasurementStats()} + assertMeasurementStats(t, &expectedStats[0], &ctx.InProgressStats[0]) + assertMeasurementStats(t, &expectedStats[0], &ctx.CompletedStats[0]) +} + func Test_OutputInfinite_SingleProbe_MultipleCalls(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -314,6 +353,58 @@ func Test_OutputInfinite_MultipleProbes(t *testing.T) { ) } +func Test_OutputInfinite_MultipleProbes_All_Failed(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + osStdout := os.Stdout + + measurement := getPingGetMeasurementMultipleLocations(measurementID1) + measurement.Status = globalping.StatusFinished + for i := range measurement.Results { + measurement.Results[i].Result.Status = globalping.StatusFailed + measurement.Results[i].Result.RawOutput = `ping: cdn.jsdelivr.net.xc: Name or service not known` + } + gbMock := mocks.NewMockClient(ctrl) + gbMock.EXPECT().GetMeasurement(measurementID1).Times(1).Return(measurement, nil) + + r, w, err := os.Pipe() + assert.NoError(t, err) + defer r.Close() + defer w.Close() + + ctx := &Context{ + Cmd: "ping", + MaxHistory: 3, + } + v := NewViewer(ctx, NewPrinter(w), gbMock) + os.Stdout = w + err = v.OutputInfinite(measurementID1) + os.Stdout = osStdout + assert.Equal(t, "all probes failed", err.Error()) + w.Close() + + output, err := io.ReadAll(r) + assert.NoError(t, err) + + assert.Equal(t, `> EU, GB, London, ASN:0, OVH SAS +ping: cdn.jsdelivr.net.xc: Name or service not known +> EU, DE, Falkenstein, ASN:0, Hetzner Online GmbH +ping: cdn.jsdelivr.net.xc: Name or service not known +> EU, DE, Nuremberg, ASN:0, Hetzner Online GmbH +ping: cdn.jsdelivr.net.xc: Name or service not known +`, string(output)) + expectedStats := []MeasurementStats{ + NewMeasurementStats(), + NewMeasurementStats(), + NewMeasurementStats(), + } + assert.Equal(t, + expectedStats, + ctx.CompletedStats, + ) +} + func Test_FormatDuration(t *testing.T) { d := formatDuration(1.2345) assert.Equal(t, "1.23 ms", d) From 5d3121e5fba107a895674cc8cbb1141219fdfbce Mon Sep 17 00:00:00 2001 From: Radu Lucut Date: Tue, 13 Feb 2024 12:07:05 +0200 Subject: [PATCH 25/27] Add time for in progress measurements --- cmd/common_test.go | 3 ++ cmd/ping.go | 1 + cmd/ping_test.go | 51 +++++++++++++++---------- cmd/root.go | 9 ++++- mocks/gen_mocks.sh | 1 + mocks/mock_time.go | 49 ++++++++++++++++++++++++ utils/time.go | 17 +++++++++ view/context.go | 3 +- view/default_test.go | 10 ++--- view/infinite.go | 13 ++++--- view/infinite_test.go | 87 +++++++++++++++++++++++++++++++++++-------- view/json_test.go | 1 + view/latency_test.go | 6 +++ view/summary_test.go | 14 +++---- view/utils_test.go | 3 ++ view/viewer.go | 8 +++- 16 files changed, 218 insertions(+), 58 deletions(-) create mode 100644 mocks/mock_time.go create mode 100644 utils/time.go diff --git a/cmd/common_test.go b/cmd/common_test.go index af43d57..daa0ea2 100644 --- a/cmd/common_test.go +++ b/cmd/common_test.go @@ -5,6 +5,7 @@ import ( "io/fs" "os" "testing" + "time" "github.com/jsdelivr/globalping-cli/globalping" "github.com/stretchr/testify/assert" @@ -15,6 +16,8 @@ var ( measurementID2 = "hhUicONd75250Z1b" measurementID3 = "YPDXL29YeGctf6iJ" measurementID4 = "hH3tBVPZEj5k6AcW" + + defaultCurrentTime = time.Unix(0, 0) ) func Test_InProgressUpdates_CI(t *testing.T) { diff --git a/cmd/ping.go b/cmd/ping.go index 2c16f43..7859f3a 100644 --- a/cmd/ping.go +++ b/cmd/ping.go @@ -97,6 +97,7 @@ func (r *Root) ping() (string, error) { return "", err } + r.ctx.MStartedAt = r.time.Now() r.ctx.CallCount++ // Save measurement ID to history diff --git a/cmd/ping_test.go b/cmd/ping_test.go index 06d880a..7554dd2 100644 --- a/cmd/ping_test.go +++ b/cmd/ping_test.go @@ -34,6 +34,9 @@ func Test_Execute_Ping_Default(t *testing.T) { viewerMock := mocks.NewMockViewer(ctrl) viewerMock.EXPECT().Output(measurementID1, expectedOpts).Times(1).Return(nil) + timeMock := mocks.NewMockTime(ctrl) + timeMock.EXPECT().Now().Return(defaultCurrentTime) + ctx = &view.Context{ MaxHistory: 1, } @@ -43,7 +46,7 @@ func Test_Execute_Ping_Default(t *testing.T) { defer w.Close() printer := view.NewPrinter(w) - root := NewRoot(w, w, printer, ctx, viewerMock, gbMock, &cobra.Command{}) + root := NewRoot(w, w, printer, ctx, viewerMock, timeMock, gbMock, &cobra.Command{}) os.Args = []string{"globalping", "ping", "jsdelivr.com"} err = root.Cmd.ExecuteContext(context.TODO()) assert.NoError(t, err) @@ -102,9 +105,11 @@ func Test_Execute_Ping_Infinite(t *testing.T) { time.Sleep(5 * time.Millisecond) return nil }).After(outputCall2) - viewerMock.EXPECT().OutputSummary().Times(1) + timeMock := mocks.NewMockTime(ctrl) + timeMock.EXPECT().Now().Return(defaultCurrentTime).Times(3) + ctx = &view.Context{} r, w, err := os.Pipe() assert.NoError(t, err) @@ -112,7 +117,7 @@ func Test_Execute_Ping_Infinite(t *testing.T) { defer w.Close() printer := view.NewPrinter(w) - root := NewRoot(w, w, printer, ctx, viewerMock, gbMock, &cobra.Command{}) + root := NewRoot(w, w, printer, ctx, viewerMock, timeMock, gbMock, &cobra.Command{}) os.Args = []string{"globalping", "ping", "jsdelivr.com", "--infinite"} sig := make(chan os.Signal, 1) @@ -133,14 +138,15 @@ func Test_Execute_Ping_Infinite(t *testing.T) { assert.Equal(t, "", string(output)) expectedCtx := &view.Context{ - Cmd: "ping", - Target: "jsdelivr.com", - Limit: 1, - CI: true, - Infinite: true, - CallCount: 3, - From: measurementID2, - Packets: 16, + Cmd: "ping", + Target: "jsdelivr.com", + Limit: 1, + CI: true, + Infinite: true, + CallCount: 3, + From: measurementID2, + Packets: 16, + MStartedAt: defaultCurrentTime, } assert.Equal(t, expectedCtx, ctx) @@ -168,6 +174,9 @@ func Test_Execute_Ping_Infinite_Output_Error(t *testing.T) { viewerMock.EXPECT().OutputInfinite(measurementID1).Return(errors.New("error message")) viewerMock.EXPECT().OutputSummary().Times(0) + timeMock := mocks.NewMockTime(ctrl) + timeMock.EXPECT().Now().Return(defaultCurrentTime) + ctx = &view.Context{} r, w, err := os.Pipe() assert.NoError(t, err) @@ -175,7 +184,7 @@ func Test_Execute_Ping_Infinite_Output_Error(t *testing.T) { defer w.Close() printer := view.NewPrinter(w) - root := NewRoot(w, w, printer, ctx, viewerMock, gbMock, &cobra.Command{}) + root := NewRoot(w, w, printer, ctx, viewerMock, timeMock, gbMock, &cobra.Command{}) os.Args = []string{"globalping", "ping", "jsdelivr.com", "--infinite"} err = root.Cmd.ExecuteContext(context.TODO()) assert.Equal(t, "error message", err.Error()) @@ -186,14 +195,15 @@ func Test_Execute_Ping_Infinite_Output_Error(t *testing.T) { assert.Equal(t, "Error: error message\n", string(output)) expectedCtx := &view.Context{ - Cmd: "ping", - Target: "jsdelivr.com", - Limit: 1, - CI: true, - Infinite: true, - CallCount: 1, - From: measurementID1, - Packets: 16, + Cmd: "ping", + Target: "jsdelivr.com", + Limit: 1, + CI: true, + Infinite: true, + CallCount: 1, + From: measurementID1, + Packets: 16, + MStartedAt: defaultCurrentTime, } assert.Equal(t, expectedCtx, ctx) @@ -212,6 +222,7 @@ func getExpectedViewContext() *view.Context { CallCount: 1, From: "world", MaxHistory: 1, + MStartedAt: defaultCurrentTime, } } diff --git a/cmd/root.go b/cmd/root.go index 5b0dd08..88cec44 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,6 +6,7 @@ import ( "github.com/jsdelivr/globalping-cli/globalping" "github.com/jsdelivr/globalping-cli/lib" + "github.com/jsdelivr/globalping-cli/utils" "github.com/jsdelivr/globalping-cli/view" "github.com/pkg/errors" "github.com/spf13/cobra" @@ -31,11 +32,12 @@ var ( 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, gp) + viewer = view.NewViewer(ctx, printer, utime, gp) ) // rootCmd represents the base command when called without any subcommands @@ -46,7 +48,7 @@ var rootCmd = &cobra.Command{ 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, gp, rootCmd) +var root = NewRoot(outW, errW, printer, ctx, viewer, utime, gp, rootCmd) type Root struct { outW io.Writer @@ -54,6 +56,7 @@ type Root struct { ctx *view.Context viewer view.Viewer gp globalping.Client + time utils.Time Cmd *cobra.Command } @@ -63,6 +66,7 @@ func NewRoot( printer *view.Printer, ctx *view.Context, viewer view.Viewer, + time utils.Time, gp globalping.Client, cmd *cobra.Command, ) *Root { @@ -72,6 +76,7 @@ func NewRoot( printer: printer, ctx: ctx, viewer: viewer, + time: time, gp: gp, } diff --git a/mocks/gen_mocks.sh b/mocks/gen_mocks.sh index f409b3e..05ffabb 100755 --- a/mocks/gen_mocks.sh +++ b/mocks/gen_mocks.sh @@ -2,3 +2,4 @@ rm -rf mocks/mock_*.go bin/mockgen -source globalping/client.go -destination mocks/mock_client.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_time.go b/mocks/mock_time.go new file mode 100644 index 0000000..0529f5b --- /dev/null +++ b/mocks/mock_time.go @@ -0,0 +1,49 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: utils/time.go + +// Package mocks is a generated GoMock package. +package mocks + +import ( + reflect "reflect" + time "time" + + gomock "github.com/golang/mock/gomock" +) + +// MockTime is a mock of Time interface. +type MockTime struct { + ctrl *gomock.Controller + recorder *MockTimeMockRecorder +} + +// MockTimeMockRecorder is the mock recorder for MockTime. +type MockTimeMockRecorder struct { + mock *MockTime +} + +// NewMockTime creates a new mock instance. +func NewMockTime(ctrl *gomock.Controller) *MockTime { + mock := &MockTime{ctrl: ctrl} + mock.recorder = &MockTimeMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockTime) EXPECT() *MockTimeMockRecorder { + return m.recorder +} + +// Now mocks base method. +func (m *MockTime) Now() time.Time { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Now") + ret0, _ := ret[0].(time.Time) + return ret0 +} + +// Now indicates an expected call of Now. +func (mr *MockTimeMockRecorder) Now() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Now", reflect.TypeOf((*MockTime)(nil).Now)) +} diff --git a/utils/time.go b/utils/time.go new file mode 100644 index 0000000..941a7fd --- /dev/null +++ b/utils/time.go @@ -0,0 +1,17 @@ +package utils + +import _time "time" + +type Time interface { + Now() _time.Time +} + +type time struct{} + +func NewTime() Time { + return &time{} +} + +func (d *time) Now() _time.Time { + return _time.Now() +} diff --git a/view/context.go b/view/context.go index 50d8372..ad14c84 100644 --- a/view/context.go +++ b/view/context.go @@ -32,6 +32,7 @@ type Context struct { Area *pterm.AreaPrinter Hostname string + MStartedAt time.Time // Time when the measurement started CompletedStats []MeasurementStats InProgressStats []MeasurementStats CallCount int // Number of measurements created @@ -60,7 +61,7 @@ type MeasurementStats struct { Avg float64 // Average RTT Max float64 // Maximum RTT Mdev float64 // Mean deviation of RTT - Time float64 // Total time + Time float64 // Total time of measurement, in milliseconds Tsum float64 // Total sum of RTT Tsum2 float64 // Total sum of RTT squared } diff --git a/view/default_test.go b/view/default_test.go index 3f036b7..96a7182 100644 --- a/view/default_test.go +++ b/view/default_test.go @@ -69,7 +69,7 @@ func Test_Output_Default_HTTP_Get(t *testing.T) { viewer := NewViewer(&Context{ Cmd: "http", CI: true, - }, NewPrinter(w), gbMock) + }, NewPrinter(w), nil, gbMock) viewer.Output(measurementID1, m) w.Close() @@ -143,7 +143,7 @@ func Test_Output_Default_HTTP_Get_Share(t *testing.T) { Cmd: "http", CI: true, Share: true, - }, NewPrinter(w), gbMock) + }, NewPrinter(w), nil, gbMock) viewer.Output(measurementID1, m) w.Close() @@ -218,7 +218,7 @@ func Test_Output_Default_HTTP_Get_Full(t *testing.T) { Cmd: "http", CI: true, Full: true, - }, NewPrinter(w), gbMock) + }, NewPrinter(w), nil, gbMock) viewer.Output(measurementID1, m) w.Close() @@ -291,7 +291,7 @@ func Test_Output_Default_HTTP_Head(t *testing.T) { viewer := NewViewer(&Context{ Cmd: "http", CI: true, - }, NewPrinter(w), gbMock) + }, NewPrinter(w), nil, gbMock) viewer.Output(measurementID1, m) w.Close() @@ -354,7 +354,7 @@ func Test_Output_Default_Ping(t *testing.T) { viewer := NewViewer(&Context{ Cmd: "ping", CI: true, - }, NewPrinter(w), gbMock) + }, NewPrinter(w), nil, gbMock) viewer.Output(measurementID1, m) w.Close() diff --git a/view/infinite.go b/view/infinite.go index 82f227c..6bce59d 100644 --- a/view/infinite.go +++ b/view/infinite.go @@ -72,7 +72,7 @@ func (v *viewer) outputStreamingPackets(res *globalping.Measurement) error { return v.outputFailSummary(res) } if measurement.Result.RawOutput != "" { - parsedOutput := parsePingRawOutput(measurement, v.ctx.CompletedStats[0].Sent) + 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)) @@ -191,7 +191,7 @@ func (v *viewer) generateTable(res *globalping.Measurement, areaWidth int) (*str skip = true break } - parsedOutput := parsePingRawOutput(measurement, v.ctx.CompletedStats[i].Sent) + parsedOutput := v.parsePingRawOutput(measurement, v.ctx.CompletedStats[i].Sent) newStats[i] = mergeMeasurementStats(v.ctx.CompletedStats[i], parsedOutput) row := getRowValues(&newStats[i]) rowWidth := 0 @@ -318,15 +318,14 @@ type ParsedPingOutput struct { RawPacketLines []string Timings []globalping.PingTiming Stats *MeasurementStats - Time float64 + Time float64 // Total time, in milliseconds } // Parse ping's raw output. Adapted from iputils ping: https://github.com/iputils/iputils/tree/1c08152/ping // // - If startIncmpSeq is -1, RawPacketLines will be empty -// -// - Stats.Time will be 0 if no summary is found -func parsePingRawOutput(m *globalping.ProbeMeasurement, startIncmpSeq int) *ParsedPingOutput { +func (v *viewer) parsePingRawOutput( + m *globalping.ProbeMeasurement, startIncmpSeq int) *ParsedPingOutput { res := &ParsedPingOutput{ Timings: make([]globalping.PingTiming, 0), Stats: &MeasurementStats{ @@ -419,6 +418,8 @@ func parsePingRawOutput(m *globalping.ProbeMeasurement, startIncmpSeq int) *Pars res.Stats.Rcv, _ = strconv.Atoi(words[3]) res.Time, _ = strconv.ParseFloat(words[9][:len(words[9])-2], 64) } + } else { + res.Time = float64(v.time.Now().Sub(v.ctx.MStartedAt).Milliseconds()) } if res.Stats.Sent > 0 { res.Stats.Lost = res.Stats.Sent - res.Stats.Rcv diff --git a/view/infinite_test.go b/view/infinite_test.go index 0247438..0411907 100644 --- a/view/infinite_test.go +++ b/view/infinite_test.go @@ -5,6 +5,7 @@ import ( "math" "os" "testing" + "time" "github.com/golang/mock/gomock" "github.com/jsdelivr/globalping-cli/globalping" @@ -53,6 +54,9 @@ rtt min/avg/max/mdev = 12.711/12.854/12.952/0.103 ms` return measurement, nil }).Times(4) + timeMock := mocks.NewMockTime(ctrl) + timeMock.EXPECT().Now().Return(defaultCurrentTime.Add(1 * time.Millisecond)).Times(3) + r, w, err := os.Pipe() assert.NoError(t, err) defer r.Close() @@ -61,8 +65,9 @@ rtt min/avg/max/mdev = 12.711/12.854/12.952/0.103 ms` ctx := &Context{ Cmd: "ping", MaxHistory: 3, + MStartedAt: defaultCurrentTime, } - viewer := NewViewer(ctx, NewPrinter(w), gbMock) + viewer := NewViewer(ctx, NewPrinter(w), timeMock, gbMock) measurement.Status = globalping.StatusInProgress measurement.Results[0].Result.Status = globalping.StatusInProgress @@ -110,7 +115,7 @@ func Test_OutputInfinite_SingleProbe_Failed(t *testing.T) { Cmd: "ping", MaxHistory: 3, } - viewer := NewViewer(ctx, NewPrinter(w), gbMock) + viewer := NewViewer(ctx, NewPrinter(w), nil, gbMock) err = viewer.OutputInfinite(measurement.ID) assert.Equal(t, "all probes failed", err.Error()) w.Close() @@ -146,7 +151,7 @@ func Test_OutputInfinite_SingleProbe_MultipleCalls(t *testing.T) { Cmd: "ping", MaxHistory: 3, } - viewer := NewViewer(ctx, NewPrinter(w), gbMock) + viewer := NewViewer(ctx, NewPrinter(w), nil, gbMock) err = viewer.OutputInfinite(measurement.ID) assert.NoError(t, err) @@ -182,6 +187,9 @@ func Test_OutputInfinite_MultipleProbes_MultipleCalls(t *testing.T) { gbMock := mocks.NewMockClient(ctrl) res := getPingGetMeasurementMultipleLocations(measurementID1) + timeMock := mocks.NewMockTime(ctrl) + timeMock.EXPECT().Now().Return(defaultCurrentTime.Add(1 * time.Millisecond)).AnyTimes() + rawOutput1 := `PING (146.75.73.229) 56(84) bytes of data.` rawOutput2 := `PING (146.75.73.229) 56(84) bytes of data. 64 bytes from 146.75.73.229 (146.75.73.229): icmp_seq=1 ttl=52 time=17.6 ms @@ -197,8 +205,10 @@ no answer yet for icmp_seq=2 rtt min/avg/max/mdev = 17.006/17.333/17.648/0.321 ms` expectedViewer := &viewer{ - ctx: getDefaultPingCtx(len(res.Results)), + ctx: getDefaultPingCtx(len(res.Results)), + time: timeMock, } + expectedViewer.ctx.MStartedAt = defaultCurrentTime expectedTables := [6]*string{} callCount := 0 // 1st call is done in the caller. @@ -233,8 +243,9 @@ rtt min/avg/max/mdev = 17.006/17.333/17.648/0.321 ms` ctx := &Context{ Cmd: "ping", MaxHistory: 3, + MStartedAt: defaultCurrentTime, } - viewer := NewViewer(ctx, NewPrinter(w), gbMock) + viewer := NewViewer(ctx, NewPrinter(w), timeMock, gbMock) os.Stdout = w err = viewer.OutputInfinite(measurementID1) assert.NoError(t, err) @@ -312,7 +323,7 @@ func Test_OutputInfinite_MultipleProbes(t *testing.T) { Cmd: "ping", MaxHistory: 3, } - v := NewViewer(ctx, NewPrinter(w), gbMock) + v := NewViewer(ctx, NewPrinter(w), nil, gbMock) os.Stdout = w err = v.OutputInfinite(measurementID1) assert.NoError(t, err) @@ -377,7 +388,7 @@ func Test_OutputInfinite_MultipleProbes_All_Failed(t *testing.T) { Cmd: "ping", MaxHistory: 3, } - v := NewViewer(ctx, NewPrinter(w), gbMock) + v := NewViewer(ctx, NewPrinter(w), nil, gbMock) os.Stdout = w err = v.OutputInfinite(measurementID1) os.Stdout = osStdout @@ -492,7 +503,20 @@ func Test_GenerateTable_MaxTruncated(t *testing.T) { } func Test_MergeMeasurementStats(t *testing.T) { - o := parsePingRawOutput(&globalping.ProbeMeasurement{ + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + timeMock := mocks.NewMockTime(ctrl) + timeMock.EXPECT().Now().Return(defaultCurrentTime.Add(1 * time.Millisecond)).Times(2) + + v := viewer{ + ctx: &Context{ + MStartedAt: defaultCurrentTime, + }, + time: timeMock, + } + + o := v.parsePingRawOutput(&globalping.ProbeMeasurement{ Result: globalping.ProbeResult{ RawOutput: `PING (142.250.65.174) 56(84) bytes of data.`, }, @@ -502,10 +526,10 @@ func Test_MergeMeasurementStats(t *testing.T) { o, ) assert.Equal(t, - MeasurementStats{Sent: 0, Lost: 0, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1}, + MeasurementStats{Sent: 0, Lost: 0, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1, Time: 1}, newStats, ) - o = parsePingRawOutput(&globalping.ProbeMeasurement{ + o = v.parsePingRawOutput(&globalping.ProbeMeasurement{ Result: globalping.ProbeResult{ RawOutput: `PING (142.250.65.174) 56(84) bytes of data. no answer yet for icmp_seq=1 @@ -520,9 +544,9 @@ no answer yet for icmp_seq=4`, MeasurementStats{Sent: 0, Lost: 0, Loss: 0, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1}, o) assertMeasurementStats(t, &MeasurementStats{Sent: 4, Rcv: 3, Lost: 1, Loss: 25, Last: 30, Min: 10, - Avg: 20, Max: 30, Tsum: 60, Tsum2: 1400, Mdev: 8.1649}, + Avg: 20, Max: 30, Time: 1, Tsum: 60, Tsum2: 1400, Mdev: 8.1649}, &newStats) - o = parsePingRawOutput(&globalping.ProbeMeasurement{ + o = v.parsePingRawOutput(&globalping.ProbeMeasurement{ Result: globalping.ProbeResult{ RawOutput: `PING (142.250.65.174) 56(84) bytes of data. no answer yet for icmp_seq=1 @@ -543,7 +567,7 @@ rtt min/avg/max/mdev = 10/20/30/0 ms`, o) assertMeasurementStats(t, &MeasurementStats{Sent: 4, Rcv: 4, Lost: 0, Loss: 0, Last: 30, Min: 10, Avg: 20, Max: 30, Time: 1000, Tsum: 80, Tsum2: 2000, Mdev: 10}, &newStats) - o = parsePingRawOutput(&globalping.ProbeMeasurement{ + o = v.parsePingRawOutput(&globalping.ProbeMeasurement{ Result: globalping.ProbeResult{ RawOutput: `PING (142.250.65.174) 56(84) bytes of data. 64 bytes from lga25s71-in-f14.1e100.net (142.250.65.174): icmp_seq=1 ttl=59 time=10 ms @@ -614,6 +638,10 @@ func Test_GetRowValues(t *testing.T) { } func Test_ParsePingRawOutput_Full(t *testing.T) { + v := viewer{ + ctx: &Context{}, + } + m := &globalping.ProbeMeasurement{ Result: globalping.ProbeResult{ RawOutput: `PING cdn.jsdelivr.net (142.250.65.174) 56(84) bytes of data. @@ -626,7 +654,8 @@ func Test_ParsePingRawOutput_Full(t *testing.T) { rtt min/avg/max/mdev = 1.061/1.090/1.108/0.020 ms`, }, } - res := parsePingRawOutput(m, -1) + + res := v.parsePingRawOutput(m, -1) assert.Equal(t, "142.250.65.174", res.Address) assert.Equal(t, "56(84)", res.BytesOfData) assert.Nil(t, res.RawPacketLines) @@ -651,6 +680,19 @@ rtt min/avg/max/mdev = 1.061/1.090/1.108/0.020 ms`, } func Test_ParsePingRawOutput_NoStats(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + timeMock := mocks.NewMockTime(ctrl) + timeMock.EXPECT().Now().Return(defaultCurrentTime.Add(1 * time.Millisecond)) + + v := viewer{ + ctx: &Context{ + MStartedAt: defaultCurrentTime, + }, + time: timeMock, + } + m := &globalping.ProbeMeasurement{ Result: globalping.ProbeResult{ RawOutput: `PING (142.250.65.174) 56(84) bytes of data. @@ -662,7 +704,7 @@ no answer yet for icmp_seq=2 no answer yet for icmp_seq=4`, }, } - res := parsePingRawOutput(m, -1) + res := v.parsePingRawOutput(m, -1) assert.Equal(t, "142.250.65.174", res.Address) assert.Equal(t, "56(84)", res.BytesOfData) assert.Nil(t, res.RawPacketLines) @@ -687,6 +729,19 @@ no answer yet for icmp_seq=4`, } func Test_ParsePingRawOutput_NoStats_WithStartIncmpSeq(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + timeMock := mocks.NewMockTime(ctrl) + timeMock.EXPECT().Now().Return(defaultCurrentTime.Add(1 * time.Millisecond)) + + v := viewer{ + ctx: &Context{ + MStartedAt: defaultCurrentTime, + }, + time: timeMock, + } + m := &globalping.ProbeMeasurement{ Result: globalping.ProbeResult{ RawOutput: `PING (142.250.65.174) 56(84) bytes of data. @@ -698,7 +753,7 @@ no answer yet for icmp_seq=2 no answer yet for icmp_seq=4`, }, } - res := parsePingRawOutput(m, 4) + res := v.parsePingRawOutput(m, 4) assert.Equal(t, "142.250.65.174", res.Address) assert.Equal(t, "56(84)", res.BytesOfData) assert.Equal(t, []string{ diff --git a/view/json_test.go b/view/json_test.go index 1171d6f..9428b26 100644 --- a/view/json_test.go +++ b/view/json_test.go @@ -33,6 +33,7 @@ func Test_Output_Json(t *testing.T) { Share: true, }, NewPrinter(w), + nil, gbMock, ) diff --git a/view/latency_test.go b/view/latency_test.go index c583d51..ce23d36 100644 --- a/view/latency_test.go +++ b/view/latency_test.go @@ -63,6 +63,7 @@ func Test_Output_Latency_Ping_Not_CI(t *testing.T) { ToLatency: true, }, NewPrinter(w), + nil, gbMock, ) @@ -123,6 +124,7 @@ func Test_Output_Latency_Ping_CI(t *testing.T) { CI: true, }, NewPrinter(w), + nil, gbMock, ) @@ -177,6 +179,7 @@ func Test_Output_Latency_DNS_Not_CI(t *testing.T) { ToLatency: true, }, NewPrinter(w), + nil, gbMock, ) @@ -230,6 +233,7 @@ func Test_Output_Latency_DNS_CI(t *testing.T) { CI: true, }, NewPrinter(w), + nil, gbMock, ) @@ -282,6 +286,7 @@ func Test_Output_Latency_Http_Not_CI(t *testing.T) { ToLatency: true, }, NewPrinter(w), + nil, gbMock, ) @@ -340,6 +345,7 @@ func Test_Output_Latency_Http_CI(t *testing.T) { CI: true, }, NewPrinter(w), + nil, gbMock, ) diff --git a/view/summary_test.go b/view/summary_test.go index f36ba0e..76f9ba0 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) + viewer := NewViewer(&Context{}, NewPrinter(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) + viewer := NewViewer(ctx, NewPrinter(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) + viewer := NewViewer(ctx, NewPrinter(w), nil, nil) viewer.OutputSummary() w.Close() @@ -87,7 +87,7 @@ rtt min/avg/max/mdev = -/-/-/- ms NewMeasurementStats(), }, } - viewer := NewViewer(ctx, NewPrinter(w), nil) + viewer := NewViewer(ctx, NewPrinter(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) + viewer := NewViewer(ctx, NewPrinter(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) + viewer := NewViewer(ctx, NewPrinter(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) + viewer := NewViewer(ctx, NewPrinter(w), nil, nil) viewer.OutputSummary() w.Close() diff --git a/view/utils_test.go b/view/utils_test.go index 871790f..22801d3 100644 --- a/view/utils_test.go +++ b/view/utils_test.go @@ -3,6 +3,7 @@ package view import ( "encoding/json" "math" + "time" "github.com/jsdelivr/globalping-cli/globalping" ) @@ -11,6 +12,8 @@ var ( measurementID1 = "nzGzfAGL7sZfUs3c" measurementID2 = "A2ZfUs3cnzGzfAGL" // measurementID3 = "7sZfUs3cnzGz1I20" + + defaultCurrentTime = time.Unix(0, 0) ) func getPingGetMeasurement(id string) *globalping.Measurement { diff --git a/view/viewer.go b/view/viewer.go index d72c0ae..0c6a015 100644 --- a/view/viewer.go +++ b/view/viewer.go @@ -1,6 +1,9 @@ package view -import "github.com/jsdelivr/globalping-cli/globalping" +import ( + "github.com/jsdelivr/globalping-cli/globalping" + "github.com/jsdelivr/globalping-cli/utils" +) type Viewer interface { Output(id string, m *globalping.MeasurementCreate) error @@ -11,17 +14,20 @@ type Viewer interface { type viewer struct { ctx *Context printer *Printer + time utils.Time gp globalping.Client } func NewViewer( ctx *Context, printer *Printer, + time utils.Time, gp globalping.Client, ) Viewer { return &viewer{ ctx: ctx, printer: printer, + time: time, gp: gp, } } From b80540d94fb4eaf5153358eac9ffaf6355bb86bc Mon Sep 17 00:00:00 2001 From: Radu Lucut Date: Tue, 13 Feb 2024 12:35:38 +0200 Subject: [PATCH 26/27] Replace github.com/golang/mock with go.uber.org/mock --- CONTRIBUTING.md | 8 ++++---- cmd/ping_test.go | 2 +- go.mod | 2 +- go.sum | 15 ++------------- mocks/mock_client.go | 13 +++++++++---- mocks/mock_time.go | 7 ++++++- mocks/mock_viewer.go | 11 ++++++++--- view/default_test.go | 2 +- view/infinite_test.go | 2 +- view/json_test.go | 2 +- view/latency_test.go | 2 +- 11 files changed, 35 insertions(+), 31 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2ab34af..ca35e1c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,9 +4,9 @@ Hi! We're really excited that you're interested in contributing! Before submitti ## General guidelines -- Bug fixes and changes discussed in the existing issues are always welcome. -- For new ideas, please open an issue to discuss them before sending a PR. -- Make sure your PR passes `npm test` and has [appropriate commit messages](https://github.com/jsdelivr/globalping-cli/commits/master). +- Bug fixes and changes discussed in the existing issues are always welcome. +- For new ideas, please open an issue to discuss them before sending a PR. +- Make sure your PR passes `go test ./...` and has [appropriate commit messages](https://github.com/jsdelivr/globalping-cli/commits/master). ## Project setup @@ -19,7 +19,7 @@ curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/insta Install mockgen: ```shell -GOBIN=$(pwd)/bin go install github.com/golang/mock/mockgen@v1.6.0 +GOBIN=$(pwd)/bin go install go.uber.org/mock/mockgen@latest ``` Run golangci-lint: diff --git a/cmd/ping_test.go b/cmd/ping_test.go index 7554dd2..ba7beae 100644 --- a/cmd/ping_test.go +++ b/cmd/ping_test.go @@ -11,12 +11,12 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" "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" ) func Test_Execute_Ping_Default(t *testing.T) { diff --git a/go.mod b/go.mod index 6c04627..ef556a0 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,6 @@ toolchain go1.21.3 require ( github.com/andybalholm/brotli v1.1.0 github.com/charmbracelet/lipgloss v0.9.1 - github.com/golang/mock v1.6.0 github.com/icza/backscanner v0.0.0-20230330133933-bf6beb754c70 github.com/mattn/go-runewidth v0.0.15 github.com/pkg/errors v0.9.1 @@ -15,6 +14,7 @@ require ( github.com/shirou/gopsutil v3.21.11+incompatible 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 ) diff --git a/go.sum b/go.sum index 2941f01..602fea7 100644 --- a/go.sum +++ b/go.sum @@ -32,8 +32,6 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= -github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ= github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo= github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= @@ -113,35 +111,29 @@ github.com/tklauser/numcpus v0.7.0/go.mod h1:bb6dMVcj8A42tSE7i32fsIUCbQNllK5iDgu github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= +go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA= golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= -golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -168,12 +160,9 @@ golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/mocks/mock_client.go b/mocks/mock_client.go index 38fb2aa..225fd9d 100644 --- a/mocks/mock_client.go +++ b/mocks/mock_client.go @@ -1,5 +1,10 @@ // Code generated by MockGen. DO NOT EDIT. // Source: globalping/client.go +// +// Generated by this command: +// +// mockgen -source globalping/client.go -destination mocks/mock_client.go -package mocks +// // Package mocks is a generated GoMock package. package mocks @@ -7,8 +12,8 @@ package mocks import ( reflect "reflect" - gomock "github.com/golang/mock/gomock" globalping "github.com/jsdelivr/globalping-cli/globalping" + gomock "go.uber.org/mock/gomock" ) // MockClient is a mock of Client interface. @@ -45,7 +50,7 @@ func (m *MockClient) CreateMeasurement(measurement *globalping.MeasurementCreate } // CreateMeasurement indicates an expected call of CreateMeasurement. -func (mr *MockClientMockRecorder) CreateMeasurement(measurement interface{}) *gomock.Call { +func (mr *MockClientMockRecorder) CreateMeasurement(measurement any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateMeasurement", reflect.TypeOf((*MockClient)(nil).CreateMeasurement), measurement) } @@ -60,7 +65,7 @@ func (m *MockClient) GetMeasurement(id string) (*globalping.Measurement, error) } // GetMeasurement indicates an expected call of GetMeasurement. -func (mr *MockClientMockRecorder) GetMeasurement(id interface{}) *gomock.Call { +func (mr *MockClientMockRecorder) GetMeasurement(id any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMeasurement", reflect.TypeOf((*MockClient)(nil).GetMeasurement), id) } @@ -75,7 +80,7 @@ func (m *MockClient) GetMeasurementRaw(id string) ([]byte, error) { } // GetMeasurementRaw indicates an expected call of GetMeasurementRaw. -func (mr *MockClientMockRecorder) GetMeasurementRaw(id interface{}) *gomock.Call { +func (mr *MockClientMockRecorder) GetMeasurementRaw(id any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMeasurementRaw", reflect.TypeOf((*MockClient)(nil).GetMeasurementRaw), id) } diff --git a/mocks/mock_time.go b/mocks/mock_time.go index 0529f5b..7d5fa74 100644 --- a/mocks/mock_time.go +++ b/mocks/mock_time.go @@ -1,5 +1,10 @@ // Code generated by MockGen. DO NOT EDIT. // Source: utils/time.go +// +// Generated by this command: +// +// mockgen -source utils/time.go -destination mocks/mock_time.go -package mocks +// // Package mocks is a generated GoMock package. package mocks @@ -8,7 +13,7 @@ import ( reflect "reflect" time "time" - gomock "github.com/golang/mock/gomock" + gomock "go.uber.org/mock/gomock" ) // MockTime is a mock of Time interface. diff --git a/mocks/mock_viewer.go b/mocks/mock_viewer.go index 9d5de3c..5b7288c 100644 --- a/mocks/mock_viewer.go +++ b/mocks/mock_viewer.go @@ -1,5 +1,10 @@ // Code generated by MockGen. DO NOT EDIT. // Source: view/viewer.go +// +// Generated by this command: +// +// mockgen -source view/viewer.go -destination mocks/mock_viewer.go -package mocks +// // Package mocks is a generated GoMock package. package mocks @@ -7,8 +12,8 @@ package mocks import ( reflect "reflect" - gomock "github.com/golang/mock/gomock" globalping "github.com/jsdelivr/globalping-cli/globalping" + gomock "go.uber.org/mock/gomock" ) // MockViewer is a mock of Viewer interface. @@ -43,7 +48,7 @@ func (m_2 *MockViewer) Output(id string, m *globalping.MeasurementCreate) error } // Output indicates an expected call of Output. -func (mr *MockViewerMockRecorder) Output(id, m interface{}) *gomock.Call { +func (mr *MockViewerMockRecorder) Output(id, m any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Output", reflect.TypeOf((*MockViewer)(nil).Output), id, m) } @@ -57,7 +62,7 @@ func (m *MockViewer) OutputInfinite(id string) error { } // OutputInfinite indicates an expected call of OutputInfinite. -func (mr *MockViewerMockRecorder) OutputInfinite(id interface{}) *gomock.Call { +func (mr *MockViewerMockRecorder) OutputInfinite(id any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OutputInfinite", reflect.TypeOf((*MockViewer)(nil).OutputInfinite), id) } diff --git a/view/default_test.go b/view/default_test.go index 96a7182..db8122a 100644 --- a/view/default_test.go +++ b/view/default_test.go @@ -5,10 +5,10 @@ import ( "os" "testing" - "github.com/golang/mock/gomock" "github.com/jsdelivr/globalping-cli/globalping" "github.com/jsdelivr/globalping-cli/mocks" "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" ) func Test_Output_Default_HTTP_Get(t *testing.T) { diff --git a/view/infinite_test.go b/view/infinite_test.go index 0411907..6d22536 100644 --- a/view/infinite_test.go +++ b/view/infinite_test.go @@ -7,11 +7,11 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" "github.com/jsdelivr/globalping-cli/globalping" "github.com/jsdelivr/globalping-cli/mocks" "github.com/pterm/pterm" "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" ) func Test_OutputInfinite_SingleProbe_InProgress(t *testing.T) { diff --git a/view/json_test.go b/view/json_test.go index 9428b26..7d5c95d 100644 --- a/view/json_test.go +++ b/view/json_test.go @@ -5,10 +5,10 @@ import ( "os" "testing" - "github.com/golang/mock/gomock" "github.com/jsdelivr/globalping-cli/globalping" "github.com/jsdelivr/globalping-cli/mocks" "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" ) func Test_Output_Json(t *testing.T) { diff --git a/view/latency_test.go b/view/latency_test.go index ce23d36..9afbb50 100644 --- a/view/latency_test.go +++ b/view/latency_test.go @@ -6,10 +6,10 @@ import ( "os" "testing" - "github.com/golang/mock/gomock" "github.com/jsdelivr/globalping-cli/globalping" "github.com/jsdelivr/globalping-cli/mocks" "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" ) func Test_Output_Latency_Ping_Not_CI(t *testing.T) { From 9be6faeed31f062b432ad3a1eb07c25f15ea8a8d Mon Sep 17 00:00:00 2001 From: Radu Lucut Date: Tue, 13 Feb 2024 13:38:43 +0200 Subject: [PATCH 27/27] Update sleep durations in ping_test.go --- cmd/ping_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/ping_test.go b/cmd/ping_test.go index ba7beae..1b48231 100644 --- a/cmd/ping_test.go +++ b/cmd/ping_test.go @@ -94,15 +94,15 @@ func Test_Execute_Ping_Infinite(t *testing.T) { viewerMock := mocks.NewMockViewer(ctrl) outputCall1 := viewerMock.EXPECT().OutputInfinite(measurementID1).DoAndReturn(func(id string) error { - time.Sleep(5 * time.Millisecond) + time.Sleep(2 * time.Millisecond) return nil }) outputCall2 := viewerMock.EXPECT().OutputInfinite(measurementID2).DoAndReturn(func(id string) error { - time.Sleep(5 * time.Millisecond) + time.Sleep(2 * time.Millisecond) return nil }).After(outputCall1) viewerMock.EXPECT().OutputInfinite(measurementID3).DoAndReturn(func(id string) error { - time.Sleep(5 * time.Millisecond) + time.Sleep(10 * time.Millisecond) return nil }).After(outputCall2) viewerMock.EXPECT().OutputSummary().Times(1) @@ -123,7 +123,7 @@ func Test_Execute_Ping_Infinite(t *testing.T) { sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGINT) go func() { - time.Sleep(14 * time.Millisecond) + time.Sleep(7 * time.Millisecond) p, _ := os.FindProcess(os.Getpid()) p.Signal(syscall.SIGINT) }()