diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e091016..099109b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,15 +2,66 @@ name: Release on: push: - tags: - - 'v*' + branches: + - main + workflow_dispatch: permissions: contents: write + issues: write + pull-requests: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: - release: - name: Release + release-please: + name: Release Please + runs-on: ubuntu-latest + outputs: + should_release: ${{ steps.detect.outputs.should_release }} + version: ${{ steps.detect.outputs.version }} + tag_name: ${{ steps.detect.outputs.tag_name }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Run release-please + id: release + uses: googleapis/release-please-action@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + - name: Detect merged release PR + id: detect + shell: bash + run: | + changed_files="$(git show --pretty='' --name-only "$GITHUB_SHA")" + + if printf '%s\n' "$changed_files" | grep -qx 'version.txt' && \ + printf '%s\n' "$changed_files" | grep -qx 'CHANGELOG.md'; then + version="$(tr -d '[:space:]' < version.txt)" + if [ -z "$version" ]; then + echo "version.txt is empty" >&2 + exit 1 + fi + + echo "should_release=true" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag_name=v$version" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "should_release=false" >> "$GITHUB_OUTPUT" + + goreleaser: + name: GoReleaser + needs: release-please + if: needs.release-please.outputs.should_release == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -23,11 +74,33 @@ jobs: go-version-file: go.mod cache: true + - name: Verify release commit + run: | + go mod verify + go vet ./... + go test ./... + + - name: Create release tag + shell: bash + run: | + tag="${{ needs.release-please.outputs.tag_name }}" + + if git rev-parse "$tag" >/dev/null 2>&1; then + existing_sha="$(git rev-list -n 1 "$tag")" + if [ "$existing_sha" != "$GITHUB_SHA" ]; then + echo "tag $tag already exists on $existing_sha, expected $GITHUB_SHA" >&2 + exit 1 + fi + else + git tag "$tag" "$GITHUB_SHA" + git push origin "$tag" + fi + - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 with: distribution: goreleaser - version: '~> v2' + version: "~> v2" args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 1bef88d..d68cab4 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ vendor/ # OS .DS_Store Thumbs.db + +# Local agent/project metadata +.serena/ +docs/plans/ diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..466df71 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.0" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fec8507 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## [0.1.0] - 2026-03-15 + +- Initial release. diff --git a/README.md b/README.md index e177695..54bed91 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ unispeedtest -pretty ## JSON output shape +On partial failures, affected metrics are emitted as `null` and a `warnings` array is added so callers can distinguish missing data from real zero values. + ```json { "download_mbps": 225.14, @@ -71,7 +73,10 @@ unispeedtest -pretty "server_colo": "Tokyo", "network_asn": "AS2516", "network_as_org": "KDDI CORPORATION", - "ip": "203.0.113.10" + "ip": "203.0.113.10", + "warnings": [ + "upload loaded latency unavailable: no samples collected" + ] } ``` @@ -93,6 +98,13 @@ Tips: - Set `NO_COLOR=1` to disable ANSI color output. +## Releasing + +- Follow Conventional Commits in merged PRs (`fix:`, `feat:`, `feat!:` / `BREAKING CHANGE:`). +- Pushes to `main` update or create a release PR via `release-please`. +- Merging that release PR updates `version.txt` and `CHANGELOG.md`. +- The release workflow then creates the corresponding `vX.Y.Z` tag and publishes artifacts with GoReleaser. + ## License MIT diff --git a/cmd/unispeedtest/main.go b/cmd/unispeedtest/main.go index afe72d9..29655f4 100644 --- a/cmd/unispeedtest/main.go +++ b/cmd/unispeedtest/main.go @@ -3,6 +3,7 @@ package main import ( "flag" "fmt" + "io" "os" "os/signal" @@ -13,6 +14,10 @@ import ( ) func main() { + os.Exit(run()) +} + +func run() int { jsonOut := flag.Bool("json", false, "Output results in JSON format") prettyOut := flag.Bool("pretty", false, "Output pretty-printed JSON (implies -json)") flag.Parse() @@ -34,6 +39,7 @@ func main() { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt) + defer signal.Stop(sigCh) go func() { <-sigCh fmt.Fprintln(os.Stderr, "\nInterrupted.") @@ -42,55 +48,108 @@ func main() { progress("%sInitializing Cloudflare Speed Test...%s\n\n", color.Bold, color.Reset) + var result reporter.Result + var warnings []string + meta, err := cloudflare.FetchMeta() if err != nil { - fmt.Fprintf(os.Stderr, "Warning: could not fetch network metadata: %v\n", err) + warnings = append(warnings, fmt.Sprintf("network metadata unavailable: %v", err)) + } else { + result.ServerColo = stringPtr(meta.Colo.City) + result.NetworkASN = stringPtr(fmt.Sprintf("AS%d", meta.ASN)) + result.NetworkASOrg = stringPtr(meta.ASOrganization) + result.IP = stringPtr(meta.ClientIP) } - unloadedLatencies := cloudflare.MeasureLatency() - unloadedMed := stats.Median(unloadedLatencies) - unloadedJitter := stats.Jitter(unloadedLatencies) + latencyMeasurement := cloudflare.MeasureLatency() + warnings = append(warnings, latencyMeasurement.Warnings...) + if len(latencyMeasurement.Samples) == 0 { + fmt.Fprintln(os.Stderr, "error: unloaded latency measurement produced no successful samples") + return 1 + } + result.UnloadedLatency = float64Ptr(stats.Median(latencyMeasurement.Samples)) + if len(latencyMeasurement.Samples) > 1 { + result.Jitter = float64Ptr(stats.Jitter(latencyMeasurement.Samples)) + } else { + warnings = append(warnings, "jitter unavailable: need at least 2 unloaded latency samples") + } progress("%s[ Download Measurements ]%s\n", color.Bold, color.Reset) - var progressWriter *os.File + var progressWriter io.Writer if verbose { progressWriter = os.Stdout } - testSizes := []int{101000, 1001000, 10001000, 25001000} - downCounts := []int{10, 8, 6, 4} - downSpeeds, downLatencies := cloudflare.MeasurePhase("download", testSizes, downCounts, progressWriter) - downOverall := stats.Quartile(downSpeeds, 0.90) + downloadSpecs := []cloudflare.PhaseSpec{ + {SizeBytes: 101000, Count: 10}, + {SizeBytes: 1001000, Count: 8}, + {SizeBytes: 10001000, Count: 6}, + {SizeBytes: 25001000, Count: 4}, + } + downloadMeasurement, err := cloudflare.MeasurePhase("download", downloadSpecs, progressWriter) + if err != nil { + fmt.Fprintf(os.Stderr, "error: download measurement failed: %v\n", err) + return 1 + } + warnings = append(warnings, downloadMeasurement.Warnings...) + if len(downloadMeasurement.Speeds) == 0 { + fmt.Fprintln(os.Stderr, "error: download measurement produced no successful samples") + return 1 + } + result.DownloadMbps = float64Ptr(stats.Quartile(downloadMeasurement.Speeds, 0.90)) + if len(downloadMeasurement.LoadedLatencies) > 0 { + result.LoadedDownLatency = float64Ptr(stats.Median(downloadMeasurement.LoadedLatencies)) + } else { + warnings = append(warnings, "download loaded latency unavailable: no samples collected") + } progress("\n%s[ Upload Measurements ]%s\n", color.Bold, color.Reset) - upCounts := []int{8, 6, 4, 4} - upSpeeds, upLatencies := cloudflare.MeasurePhase("upload", testSizes, upCounts, progressWriter) - upOverall := stats.Quartile(upSpeeds, 0.90) + uploadSpecs := []cloudflare.PhaseSpec{ + {SizeBytes: 101000, Count: 8}, + {SizeBytes: 1001000, Count: 6}, + {SizeBytes: 10001000, Count: 4}, + {SizeBytes: 25001000, Count: 4}, + } + uploadMeasurement, err := cloudflare.MeasurePhase("upload", uploadSpecs, progressWriter) + if err != nil { + fmt.Fprintf(os.Stderr, "error: upload measurement failed: %v\n", err) + return 1 + } + warnings = append(warnings, uploadMeasurement.Warnings...) + if len(uploadMeasurement.Speeds) == 0 { + fmt.Fprintln(os.Stderr, "error: upload measurement produced no successful samples") + return 1 + } + result.UploadMbps = float64Ptr(stats.Quartile(uploadMeasurement.Speeds, 0.90)) + if len(uploadMeasurement.LoadedLatencies) > 0 { + result.LoadedUpLatency = float64Ptr(stats.Median(uploadMeasurement.LoadedLatencies)) + } else { + warnings = append(warnings, "upload loaded latency unavailable: no samples collected") + } progress("\n%s[ Packet Loss Test ]%s Running 1000 requests...\n", color.Bold, color.Reset) lossPercent, received, total := cloudflare.MeasurePacketLoss() - result := reporter.Result{ - DownloadMbps: downOverall, - UploadMbps: upOverall, - UnloadedLatency: unloadedMed, - LoadedDownLatency: stats.Median(downLatencies), - LoadedUpLatency: stats.Median(upLatencies), - Jitter: unloadedJitter, - PacketLoss: lossPercent, - Received: received, - Total: total, - ServerColo: meta.Colo.City, - NetworkASN: fmt.Sprintf("AS%d", meta.ASN), - NetworkASOrg: meta.ASOrganization, - IP: meta.ClientIP, - } + result.PacketLoss = float64Ptr(lossPercent) + result.Received = received + result.Total = total + result.Warnings = warnings if verbose { reporter.PrintHuman(os.Stdout, result) } else { if err := reporter.PrintJSON(os.Stdout, result, *prettyOut); err != nil { fmt.Fprintf(os.Stderr, "error encoding JSON: %v\n", err) - os.Exit(1) + return 1 } } + + return 0 +} + +func float64Ptr(value float64) *float64 { + return &value +} + +func stringPtr(value string) *string { + return &value } diff --git a/docs/README/ja.md b/docs/README/ja.md index ba0384a..bbafbb9 100644 --- a/docs/README/ja.md +++ b/docs/README/ja.md @@ -57,6 +57,8 @@ unispeedtest -pretty ## JSON 出力形式 +一部の測定だけ失敗した場合、該当メトリクスは `null` で出力され、`warnings` 配列に理由が入ります。これにより、本当の `0` と欠損値を区別できます。 + ```json { "download_mbps": 225.14, @@ -71,7 +73,10 @@ unispeedtest -pretty "server_colo": "Tokyo", "network_asn": "AS2516", "network_as_org": "KDDI CORPORATION", - "ip": "203.0.113.10" + "ip": "203.0.113.10", + "warnings": [ + "upload loaded latency unavailable: no samples collected" + ] } ``` diff --git a/internal/cloudflare/client.go b/internal/cloudflare/client.go index 0bb3ba5..50ae9cc 100644 --- a/internal/cloudflare/client.go +++ b/internal/cloudflare/client.go @@ -2,6 +2,8 @@ package cloudflare import ( "bytes" + "errors" + "fmt" "io" "net/http" "net/http/httptrace" @@ -10,7 +12,7 @@ import ( "time" ) -const baseURL = "https://speed.cloudflare.com" +var baseURL = "https://speed.cloudflare.com" var defaultClient = &http.Client{ Transport: &http.Transport{ @@ -43,17 +45,36 @@ func MakeRequest(method, path string, payload []byte) (PerfData, error) { } defer resp.Body.Close() - io.Copy(io.Discard, resp.Body) + if resp.StatusCode != http.StatusOK { + return pd, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + if _, err := io.Copy(io.Discard, resp.Body); err != nil { + return pd, fmt.Errorf("drain response body: %w", err) + } pd.Ended = time.Now() + pd.ServerTimingHeader = resp.Header.Get("server-timing") - for _, part := range strings.Split(resp.Header.Get("server-timing"), ",") { + return pd, nil +} + +// ParseServerTiming extracts the cfRequestDuration value from a Server-Timing header. +func ParseServerTiming(header string) (float64, error) { + if header == "" { + return 0, errors.New("missing server-timing header") + } + + for _, part := range strings.Split(header, ",") { part = strings.TrimSpace(part) if strings.HasPrefix(part, "cfRequestDuration;dur=") { valStr := strings.TrimPrefix(part, "cfRequestDuration;dur=") - pd.ServerTiming, _ = strconv.ParseFloat(valStr, 64) - break + value, err := strconv.ParseFloat(valStr, 64) + if err != nil { + return 0, fmt.Errorf("parse server timing %q: %w", valStr, err) + } + return value, nil } } - return pd, nil + return 0, fmt.Errorf("cfRequestDuration not found in server-timing header %q", header) } diff --git a/internal/cloudflare/client_test.go b/internal/cloudflare/client_test.go new file mode 100644 index 0000000..0b381b4 --- /dev/null +++ b/internal/cloudflare/client_test.go @@ -0,0 +1,190 @@ +package cloudflare + +import ( + "errors" + "io" + "net/http" + "strings" + "testing" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +type errReadCloser struct { + err error +} + +func (r errReadCloser) Read(_ []byte) (int, error) { + return 0, r.err +} + +func (r errReadCloser) Close() error { + return nil +} + +func useTestHTTPClient(t *testing.T, transport http.RoundTripper) { + t.Helper() + + originalClient := defaultClient + originalBaseURL := baseURL + defaultClient = &http.Client{Transport: transport} + baseURL = "https://example.test" + + t.Cleanup(func() { + defaultClient = originalClient + baseURL = originalBaseURL + }) +} + +func TestParseServerTiming(t *testing.T) { + tests := []struct { + name string + header string + want float64 + wantErr string + }{ + { + name: "valid value", + header: "cfRequestDuration;dur=12.5", + want: 12.5, + }, + { + name: "missing header", + header: "", + wantErr: "missing server-timing header", + }, + { + name: "invalid number", + header: "cfRequestDuration;dur=oops", + wantErr: "parse server timing", + }, + { + name: "missing cfRequestDuration segment", + header: "cache;desc=HIT", + wantErr: "cfRequestDuration not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseServerTiming(tt.header) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("ParseServerTiming() error = %v", err) + } + if got != tt.want { + t.Fatalf("ParseServerTiming() = %v, want %v", got, tt.want) + } + return + } + + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("ParseServerTiming() error = %v, want substring %q", err, tt.wantErr) + } + }) + } +} + +func TestMakeRequestReturnsDrainError(t *testing.T) { + useTestHTTPClient(t, roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Server-Timing": []string{"cfRequestDuration;dur=5"}}, + Body: errReadCloser{err: errors.New("boom")}, + Request: req, + }, nil + })) + + _, err := MakeRequest(http.MethodGet, "/__down?bytes=0", nil) + if err == nil || !strings.Contains(err.Error(), "drain response body") { + t.Fatalf("MakeRequest() error = %v, want drain response body error", err) + } +} + +func TestMeasurePhaseRejectsInvalidSpecs(t *testing.T) { + tests := []struct { + name string + phaseType string + specs []PhaseSpec + wantErr string + }{ + { + name: "empty specs", + phaseType: "download", + specs: nil, + wantErr: "at least one phase spec is required", + }, + { + name: "invalid size", + phaseType: "download", + specs: []PhaseSpec{{SizeBytes: 0, Count: 1}}, + wantErr: "phase spec size must be positive", + }, + { + name: "invalid count", + phaseType: "upload", + specs: []PhaseSpec{{SizeBytes: 1000, Count: 0}}, + wantErr: "phase spec count must be positive", + }, + { + name: "unknown phase", + phaseType: "bogus", + specs: []PhaseSpec{{SizeBytes: 1000, Count: 1}}, + wantErr: "unsupported phase type", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := MeasurePhase(tt.phaseType, tt.specs, io.Discard) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("MeasurePhase() error = %v, want substring %q", err, tt.wantErr) + } + }) + } +} + +func TestMeasurePhaseFallsBackWhenUploadServerTimingIsInvalid(t *testing.T) { + var uploadCalls int + useTestHTTPClient(t, roundTripFunc(func(req *http.Request) (*http.Response, error) { + header := http.Header{} + switch { + case req.Method == http.MethodPost && req.URL.Path == "/__up": + uploadCalls++ + if uploadCalls == 1 { + header.Set("Server-Timing", "cfRequestDuration;dur=10") + } else { + header.Set("Server-Timing", "cfRequestDuration;dur=oops") + } + case req.Method == http.MethodGet && req.URL.Path == "/__down": + header.Set("Server-Timing", "cfRequestDuration;dur=1") + default: + return nil, errors.New("unexpected request") + } + + return &http.Response{ + StatusCode: http.StatusOK, + Header: header, + Body: io.NopCloser(strings.NewReader("ok")), + Request: req, + }, nil + })) + + result, err := MeasurePhase("upload", []PhaseSpec{{SizeBytes: 1000, Count: 2}}, io.Discard) + if err != nil { + t.Fatalf("MeasurePhase() error = %v", err) + } + if len(result.Speeds) != 2 { + t.Fatalf("len(result.Speeds) = %d, want 2", len(result.Speeds)) + } + if result.FailedSamples != 0 { + t.Fatalf("result.FailedSamples = %d, want 0", result.FailedSamples) + } + if len(result.Warnings) == 0 || !strings.Contains(result.Warnings[0], "upload throughput measurement used fallback timing 1 time") { + t.Fatalf("result.Warnings = %v, want upload fallback warning", result.Warnings) + } +} diff --git a/internal/cloudflare/measure.go b/internal/cloudflare/measure.go index d839441..8691f46 100644 --- a/internal/cloudflare/measure.go +++ b/internal/cloudflare/measure.go @@ -3,6 +3,7 @@ package cloudflare import ( "bytes" "context" + "errors" "fmt" "io" "math" @@ -13,29 +14,152 @@ import ( "github.com/hsblabs/universal-speedtest-cli/internal/stats" ) +const unloadedLatencySampleCount = 20 + +type sampleFailureTracker struct { + count int + first error +} + +func (t *sampleFailureTracker) Record(err error) { + if err == nil { + return + } + + t.count++ + if t.first == nil { + t.first = err + } +} + +func (t sampleFailureTracker) Message(format string) []string { + if t.count == 0 { + return nil + } + + return []string{ + fmt.Sprintf(format, t.count, t.first), + } +} + // MeasureSpeed converts a transfer of byteCount bytes completed in durationMs milliseconds // into megabits per second. func MeasureSpeed(byteCount int, durationMs float64) float64 { return (float64(byteCount*8) / (durationMs / 1000)) / 1e6 } -// MeasureLatency sends 20 lightweight requests and returns the unloaded latency samples in ms. -func MeasureLatency() []float64 { +func latencySample() (float64, error) { + pd, err := MakeRequest("GET", "/__down?bytes=0", nil) + if err != nil { + return 0, err + } + if pd.TTFB.IsZero() { + return 0, errors.New("missing first response byte timing") + } + + serverTiming, err := ParseServerTiming(pd.ServerTimingHeader) + if err != nil { + return 0, err + } + + dur := pd.TTFB.Sub(pd.Started).Seconds()*1000 - serverTiming + if dur <= 0 { + return 0, fmt.Errorf("computed non-positive latency: %.2f ms", dur) + } + + return dur, nil +} + +// MeasureLatency sends lightweight requests and returns the unloaded latency samples in ms. +func MeasureLatency() LatencyMeasurement { var measurements []float64 - for i := 0; i < 20; i++ { - pd, err := MakeRequest("GET", "/__down?bytes=0", nil) - if err == nil && !pd.TTFB.IsZero() { - dur := pd.TTFB.Sub(pd.Started).Seconds()*1000 - pd.ServerTiming - if dur > 0 { - measurements = append(measurements, dur) - } + + var failures sampleFailureTracker + for i := 0; i < unloadedLatencySampleCount; i++ { + sample, err := latencySample() + if err != nil { + failures.Record(err) + continue + } + + measurements = append(measurements, sample) + } + + return LatencyMeasurement{ + Samples: measurements, + FailedSamples: failures.count, + Warnings: failures.Message("unloaded latency measurement failed %d time(s); first error: %v"), + } +} + +func downloadSample(size int) (float64, error) { + pd, err := MakeRequest("GET", fmt.Sprintf("/__down?bytes=%d", size), nil) + if err != nil { + return 0, err + } + if pd.TTFB.IsZero() { + return 0, errors.New("missing first response byte timing") + } + + transferTime := pd.Ended.Sub(pd.TTFB).Seconds() * 1000 + if transferTime <= 0 { + return 0, fmt.Errorf("computed non-positive transfer time: %.2f ms", transferTime) + } + + return MeasureSpeed(size, transferTime), nil +} + +func uploadSample(size int, payload []byte) (float64, error, error) { + pd, err := MakeRequest("POST", "/__up", payload) + if err != nil { + return 0, nil, err + } + + serverTiming, err := ParseServerTiming(pd.ServerTimingHeader) + if err == nil && serverTiming > 0 { + return MeasureSpeed(size, serverTiming), nil, nil + } + + fallbackDuration := pd.Ended.Sub(pd.Started).Seconds() * 1000 + if fallbackDuration <= 0 { + if err != nil { + return 0, nil, err + } + return 0, nil, fmt.Errorf("computed non-positive upload duration: %.2f ms", fallbackDuration) + } + + if err != nil { + return MeasureSpeed(size, fallbackDuration), fmt.Errorf("upload server timing unavailable, fell back to end-to-end duration: %w", err), nil + } + + return MeasureSpeed(size, fallbackDuration), fmt.Errorf("upload server timing was non-positive (%.2f ms), fell back to end-to-end duration", serverTiming), nil +} + +func validatePhaseSpecs(phaseType string, specs []PhaseSpec) error { + switch phaseType { + case "download", "upload": + default: + return fmt.Errorf("unsupported phase type %q", phaseType) + } + + if len(specs) == 0 { + return errors.New("at least one phase spec is required") + } + + for _, spec := range specs { + if spec.SizeBytes <= 0 { + return fmt.Errorf("phase spec size must be positive: %d", spec.SizeBytes) + } + if spec.Count <= 0 { + return fmt.Errorf("phase spec count must be positive for size %d: %d", spec.SizeBytes, spec.Count) } } - return measurements + + return nil } // backgroundLatencyMonitor periodically samples latency while other measurements run. -func backgroundLatencyMonitor(ctx context.Context, results *[]float64, mu *sync.Mutex) { +func backgroundLatencyMonitor(ctx context.Context, results *[]float64, failures *sampleFailureTracker) { ticker := time.NewTicker(200 * time.Millisecond) defer ticker.Stop() for { @@ -43,67 +167,92 @@ func backgroundLatencyMonitor(ctx context.Context, results *[]float64, mu *sync. case <-ctx.Done(): return case <-ticker.C: - pd, err := MakeRequest("GET", "/__down?bytes=0", nil) - if err == nil && !pd.TTFB.IsZero() { - dur := pd.TTFB.Sub(pd.Started).Seconds()*1000 - pd.ServerTiming - if dur > 0 { - mu.Lock() - *results = append(*results, dur) - mu.Unlock() - } + sample, err := latencySample() + if err != nil { + failures.Record(err) + continue } + + *results = append(*results, sample) } } } // MeasurePhase runs either a download or upload measurement phase across the given sizes and -// repetition counts. Progress is written to w if non-nil. -// Returns all sampled speeds (Mbps) and the loaded latency samples collected during the phase. -func MeasurePhase(phaseType string, sizes []int, counts []int, w io.Writer) ([]float64, []float64) { +// sample specs. Progress is written to w if non-nil. +func MeasurePhase(phaseType string, specs []PhaseSpec, w io.Writer) (PhaseMeasurement, error) { + if err := validatePhaseSpecs(phaseType, specs); err != nil { + return PhaseMeasurement{}, err + } + var allSpeeds []float64 var loadedLatencies []float64 - var mu sync.Mutex + var throughputFailures sampleFailureTracker + var throughputWarnings sampleFailureTracker + var loadedLatencyFailures sampleFailureTracker ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go backgroundLatencyMonitor(ctx, &loadedLatencies, &mu) + var monitorWG sync.WaitGroup + monitorWG.Add(1) + go func() { + defer monitorWG.Done() + backgroundLatencyMonitor(ctx, &loadedLatencies, &loadedLatencyFailures) + }() - for i, size := range sizes { - count := counts[i] + for _, spec := range specs { var speeds []float64 var payload []byte if phaseType != "download" { - payload = bytes.Repeat([]byte("0"), size) + payload = bytes.Repeat([]byte("0"), spec.SizeBytes) } - for j := 0; j < count; j++ { - var pd PerfData - var err error + + for j := 0; j < spec.Count; j++ { + var ( + speed float64 + err error + ) if phaseType == "download" { - pd, err = MakeRequest("GET", fmt.Sprintf("/__down?bytes=%d", size), nil) - if err == nil && !pd.TTFB.IsZero() { - transferTime := pd.Ended.Sub(pd.TTFB).Seconds() * 1000 - speeds = append(speeds, MeasureSpeed(size, transferTime)) - } + speed, err = downloadSample(spec.SizeBytes) } else { - pd, err = MakeRequest("POST", "/__up", payload) - if err == nil && pd.ServerTiming > 0 { - speeds = append(speeds, MeasureSpeed(size, pd.ServerTiming)) + var warning error + speed, warning, err = uploadSample(spec.SizeBytes, payload) + if warning != nil { + throughputWarnings.Record(warning) } } + if err != nil { + throughputFailures.Record(err) + continue + } + + speeds = append(speeds, speed) } if len(speeds) > 0 { allSpeeds = append(allSpeeds, speeds...) if w != nil { - label := sizeLabel(size) - fmt.Fprintf(w, " %-8s test (%d/%d): %.2f Mbps\n", label, len(speeds), count, stats.Median(speeds)) + label := sizeLabel(spec.SizeBytes) + fmt.Fprintf(w, " %-8s test (%d/%d): %.2f Mbps\n", label, len(speeds), spec.Count, stats.Median(speeds)) } } } - return allSpeeds, loadedLatencies + cancel() + monitorWG.Wait() + + warnings := append([]string{}, throughputFailures.Message(fmt.Sprintf("%s throughput measurement failed %%d time(s); first error: %%v", phaseType))...) + warnings = append(warnings, throughputWarnings.Message(fmt.Sprintf("%s throughput measurement used fallback timing %%d time(s); first reason: %%v", phaseType))...) + warnings = append(warnings, loadedLatencyFailures.Message(fmt.Sprintf("%s loaded latency monitor failed %%d time(s); first error: %%v", phaseType))...) + + return PhaseMeasurement{ + Speeds: allSpeeds, + LoadedLatencies: loadedLatencies, + FailedSamples: throughputFailures.count, + LoadedLatencyFailures: loadedLatencyFailures.count, + Warnings: warnings, + }, nil } func sizeLabel(size int) string { @@ -141,20 +290,28 @@ func MeasurePacketLoss() (lossPercent float64, received int, total int) { } resp, err := defaultClient.Do(req) + if err != nil { + mu.Lock() + failed++ + mu.Unlock() + return + } - mu.Lock() - defer mu.Unlock() - if err == nil { - if resp.StatusCode == 200 { - io.Copy(io.Discard, resp.Body) - success++ - } else { - failed++ + ok := false + if resp.StatusCode == http.StatusOK { + if _, err := io.Copy(io.Discard, resp.Body); err == nil { + ok = true } - resp.Body.Close() + } + resp.Body.Close() + + mu.Lock() + if ok { + success++ } else { failed++ } + mu.Unlock() }() } wg.Wait() diff --git a/internal/cloudflare/types.go b/internal/cloudflare/types.go index 0d2d705..e78a05d 100644 --- a/internal/cloudflare/types.go +++ b/internal/cloudflare/types.go @@ -18,10 +18,32 @@ type CloudFlareMeta struct { } `json:"colo"` } +// PhaseSpec describes one throughput sample group for a measurement phase. +type PhaseSpec struct { + SizeBytes int + Count int +} + +// LatencyMeasurement contains unloaded latency samples and any non-fatal warnings. +type LatencyMeasurement struct { + Samples []float64 + FailedSamples int + Warnings []string +} + +// PhaseMeasurement contains throughput samples, loaded latency samples, and warnings. +type PhaseMeasurement struct { + Speeds []float64 + LoadedLatencies []float64 + FailedSamples int + LoadedLatencyFailures int + Warnings []string +} + // PerfData holds timing data captured during an HTTP request. type PerfData struct { - Started time.Time - TTFB time.Time - Ended time.Time - ServerTiming float64 + Started time.Time + TTFB time.Time + Ended time.Time + ServerTimingHeader string } diff --git a/internal/reporter/printer.go b/internal/reporter/printer.go index df7c5fd..8e20ee3 100644 --- a/internal/reporter/printer.go +++ b/internal/reporter/printer.go @@ -10,72 +10,85 @@ import ( // Result holds all measurements from a speed test run. type Result struct { - DownloadMbps float64 - UploadMbps float64 - UnloadedLatency float64 - LoadedDownLatency float64 - LoadedUpLatency float64 - Jitter float64 - PacketLoss float64 - Received int - Total int - ServerColo string - NetworkASN string - NetworkASOrg string - IP string + DownloadMbps *float64 + UploadMbps *float64 + UnloadedLatency *float64 + LoadedDownLatency *float64 + LoadedUpLatency *float64 + Jitter *float64 + PacketLoss *float64 + Received int + Total int + ServerColo *string + NetworkASN *string + NetworkASOrg *string + IP *string + Warnings []string } // PrintHuman writes a human-readable speed test report to w. func PrintHuman(w io.Writer, r Result) { - score := EvaluateQuality(r.DownloadMbps, r.UploadMbps, r.UnloadedLatency, r.Jitter, r.PacketLoss) + score, ok := evaluateQualityScore(r) fmt.Fprintf(w, "\n======================================================\n") fmt.Fprintf(w, "%s YOUR INTERNET SPEED REPORT%s\n", color.Bold, color.Reset) fmt.Fprintf(w, "======================================================\n\n") - fmt.Fprintf(w, "%sOverall Download:%s %s%.2f Mbps%s\n", color.Bold, color.Reset, color.Green, r.DownloadMbps, color.Reset) - fmt.Fprintf(w, "%sOverall Upload:%s %s%.2f Mbps%s\n\n", color.Bold, color.Reset, color.Green, r.UploadMbps, color.Reset) + fmt.Fprintf(w, "%sOverall Download:%s %s\n", color.Bold, color.Reset, formatMetric(r.DownloadMbps, "Mbps", color.Green)) + fmt.Fprintf(w, "%sOverall Upload:%s %s\n\n", color.Bold, color.Reset, formatMetric(r.UploadMbps, "Mbps", color.Green)) fmt.Fprintf(w, "%sLatency & Jitter:%s\n", color.Bold, color.Reset) - fmt.Fprintf(w, " Unloaded Latency: %s%.2f ms%s (Jitter: %s%.2f ms%s)\n", - color.Magenta, r.UnloadedLatency, color.Reset, - color.Magenta, r.Jitter, color.Reset) - fmt.Fprintf(w, " Down-Loaded Latency: %.2f ms\n", r.LoadedDownLatency) - fmt.Fprintf(w, " Up-Loaded Latency: %.2f ms\n\n", r.LoadedUpLatency) + fmt.Fprintf(w, " Unloaded Latency: %s (Jitter: %s)\n", + formatMetric(r.UnloadedLatency, "ms", color.Magenta), + formatMetric(r.Jitter, "ms", color.Magenta), + ) + fmt.Fprintf(w, " Down-Loaded Latency: %s\n", formatMetric(r.LoadedDownLatency, "ms", "")) + fmt.Fprintf(w, " Up-Loaded Latency: %s\n\n", formatMetric(r.LoadedUpLatency, "ms", "")) fmt.Fprintf(w, "%sPacket Loss:%s\n", color.Bold, color.Reset) - fmt.Fprintf(w, " %.1f%% (Received %d / %d)\n\n", r.PacketLoss, r.Received, r.Total) + fmt.Fprintf(w, " %s (Received %d / %d)\n\n", formatPercent(r.PacketLoss), r.Received, r.Total) fmt.Fprintf(w, "%sNetwork Quality Score:%s\n", color.Bold, color.Reset) - fmt.Fprintf(w, " Video Streaming: %s | Online Gaming: %s | Video Chatting: %s\n\n", - qualityLabel(score.Streaming), - qualityLabel(score.Gaming), - qualityLabel(score.Chatting), - ) + if ok { + fmt.Fprintf(w, " Video Streaming: %s | Online Gaming: %s | Video Chatting: %s\n\n", + qualityLabel(score.Streaming), + qualityLabel(score.Gaming), + qualityLabel(score.Chatting), + ) + } else { + fmt.Fprintf(w, " N/A (insufficient data)\n\n") + } fmt.Fprintf(w, "%sServer & Connection:%s\n", color.Bold, color.Reset) - fmt.Fprintf(w, " Server Location: %s\n", r.ServerColo) - fmt.Fprintf(w, " Your Network: %s (%s)\n", r.NetworkASOrg, r.NetworkASN) - fmt.Fprintf(w, " Your IP address: %s\n", r.IP) + fmt.Fprintf(w, " Server Location: %s\n", displayString(r.ServerColo)) + fmt.Fprintf(w, " Your Network: %s (%s)\n", displayString(r.NetworkASOrg), displayString(r.NetworkASN)) + fmt.Fprintf(w, " Your IP address: %s\n", displayString(r.IP)) + if len(r.Warnings) > 0 { + fmt.Fprintf(w, "\n%sWarnings:%s\n", color.Bold, color.Reset) + for _, warning := range r.Warnings { + fmt.Fprintf(w, " - %s%s%s\n", color.Yellow, warning, color.Reset) + } + } fmt.Fprintf(w, "======================================================\n") } type jsonLatency struct { - Unloaded float64 `json:"unloaded"` - LoadedDown float64 `json:"loaded_down"` - LoadedUp float64 `json:"loaded_up"` - Jitter float64 `json:"jitter"` + Unloaded *float64 `json:"unloaded"` + LoadedDown *float64 `json:"loaded_down"` + LoadedUp *float64 `json:"loaded_up"` + Jitter *float64 `json:"jitter"` } type jsonResult struct { - DownloadMbps float64 `json:"download_mbps"` - UploadMbps float64 `json:"upload_mbps"` + DownloadMbps *float64 `json:"download_mbps"` + UploadMbps *float64 `json:"upload_mbps"` LatencyMs jsonLatency `json:"latency_ms"` - PacketLossPercent float64 `json:"packet_loss_percent"` - ServerColo string `json:"server_colo"` - NetworkASN string `json:"network_asn"` - NetworkASOrg string `json:"network_as_org"` - IP string `json:"ip"` + PacketLossPercent *float64 `json:"packet_loss_percent"` + ServerColo *string `json:"server_colo"` + NetworkASN *string `json:"network_asn"` + NetworkASOrg *string `json:"network_as_org"` + IP *string `json:"ip"` + Warnings []string `json:"warnings,omitempty"` } // PrintJSON writes the result as a JSON object to w. Set pretty=true for indented output. @@ -94,6 +107,7 @@ func PrintJSON(w io.Writer, r Result, pretty bool) error { NetworkASN: r.NetworkASN, NetworkASOrg: r.NetworkASOrg, IP: r.IP, + Warnings: append([]string(nil), r.Warnings...), } var out []byte @@ -117,3 +131,40 @@ func qualityLabel(good bool) string { } return color.Red + "Poor" + color.Reset } + +func evaluateQualityScore(r Result) (QualityScore, bool) { + if r.DownloadMbps == nil || r.UploadMbps == nil || r.UnloadedLatency == nil || r.Jitter == nil || r.PacketLoss == nil { + return QualityScore{}, false + } + + return EvaluateQuality(*r.DownloadMbps, *r.UploadMbps, *r.UnloadedLatency, *r.Jitter, *r.PacketLoss), true +} + +func formatMetric(value *float64, unit string, colorCode string) string { + if value == nil { + return "N/A" + } + + rendered := fmt.Sprintf("%.2f %s", *value, unit) + if colorCode == "" { + return rendered + } + + return colorCode + rendered + color.Reset +} + +func formatPercent(value *float64) string { + if value == nil { + return "N/A" + } + + return fmt.Sprintf("%.1f%%", *value) +} + +func displayString(value *string) string { + if value == nil || *value == "" { + return "N/A" + } + + return *value +} diff --git a/internal/reporter/printer_test.go b/internal/reporter/printer_test.go new file mode 100644 index 0000000..4ab1133 --- /dev/null +++ b/internal/reporter/printer_test.go @@ -0,0 +1,66 @@ +package reporter_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/hsblabs/universal-speedtest-cli/internal/reporter" +) + +func TestPrintJSONIncludesWarningsAndNulls(t *testing.T) { + download := 123.4 + packetLoss := 0.5 + + result := reporter.Result{ + DownloadMbps: &download, + PacketLoss: &packetLoss, + Received: 99, + Total: 100, + Warnings: []string{"upload measurement failed 1 time(s); first error: boom"}, + } + + var buf bytes.Buffer + if err := reporter.PrintJSON(&buf, result, true); err != nil { + t.Fatalf("PrintJSON() error = %v", err) + } + + output := buf.String() + for _, want := range []string{ + "\"download_mbps\": 123.4", + "\"upload_mbps\": null", + "\"packet_loss_percent\": 0.5", + "\"warnings\": [", + "upload measurement failed 1 time(s); first error: boom", + } { + if !strings.Contains(output, want) { + t.Fatalf("PrintJSON() output missing %q\n%s", want, output) + } + } +} + +func TestPrintHumanShowsWarningsAndMissingValues(t *testing.T) { + packetLoss := 1.2 + + result := reporter.Result{ + PacketLoss: &packetLoss, + Received: 98, + Total: 100, + Warnings: []string{"network metadata unavailable: timeout"}, + } + + var buf bytes.Buffer + reporter.PrintHuman(&buf, result) + + output := buf.String() + for _, want := range []string{ + "N/A (insufficient data)", + "Server Location: N/A", + "Warnings:", + "network metadata unavailable: timeout", + } { + if !strings.Contains(output, want) { + t.Fatalf("PrintHuman() output missing %q\n%s", want, output) + } + } +} diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..b12d36a --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "simple", + "include-v-in-tag": true, + "skip-github-release": true + } + } +} diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +0.1.0