test(go): step 6 — integration tests covering all exit codes and output
Extract run(args, stdout, stderr) int from main() for in-process testability. Fix TLS failure classification (tlsErr now captured from TLSHandshakeDone hook). Add run_test.go with 14 table-driven in-process tests and cli_test.go with TestMain + 3 subprocess smoke tests. All servers use httptest; .invalid TLD for deterministic DNS failures. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
105
go/cli_test.go
Normal file
105
go/cli_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// binaryPath is set once by TestMain and used by all CLI smoke tests.
|
||||
var binaryPath string
|
||||
|
||||
// TestMain builds the real binary once, runs all tests, then cleans up.
|
||||
func TestMain(m *testing.M) {
|
||||
tmp, err := os.MkdirTemp("", "latprobe-cli-*")
|
||||
if err != nil {
|
||||
panic("TestMain: MkdirTemp: " + err.Error())
|
||||
}
|
||||
|
||||
binaryPath = filepath.Join(tmp, "latprobe")
|
||||
out, err := exec.Command("go", "build", "-o", binaryPath, ".").CombinedOutput()
|
||||
if err != nil {
|
||||
panic("TestMain: go build failed:\n" + string(out))
|
||||
}
|
||||
|
||||
code := m.Run()
|
||||
os.RemoveAll(tmp)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// cli runs the compiled binary and returns stdout, stderr, and the exit code.
|
||||
func cli(args ...string) (stdout, stderr string, code int) {
|
||||
cmd := exec.Command(binaryPath, args...)
|
||||
var outBuf, errBuf strings.Builder
|
||||
cmd.Stdout = &outBuf
|
||||
cmd.Stderr = &errBuf
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
if ex, ok := err.(*exec.ExitError); ok {
|
||||
return outBuf.String(), errBuf.String(), ex.ExitCode()
|
||||
}
|
||||
}
|
||||
return outBuf.String(), errBuf.String(), 0
|
||||
}
|
||||
|
||||
// ── smoke tests ───────────────────────────────────────────────────────────────
|
||||
|
||||
// TestCLISuccess verifies the happy path against a real local server:
|
||||
// all phases shown, status 200, exit 0.
|
||||
func TestCLISuccess(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
stdout, stderr, code := cli(srv.URL)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0\nstdout: %s\nstderr: %s", code, stdout, stderr)
|
||||
}
|
||||
// httptest uses 127.0.0.1 — Go skips DNS for loopback, so no DNS row.
|
||||
for _, want := range []string{"(200)", "TCP connect", "Total"} {
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Errorf("stdout missing %q\nstdout: %s", want, stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCLIDNSFailure verifies that an NXDOMAIN resolves to exit 2 in the
|
||||
// real binary (exercises the actual os.Exit path).
|
||||
func TestCLIDNSFailure(t *testing.T) {
|
||||
_, _, code := cli("https://no.such.host.for.cli.smoke.invalid")
|
||||
if code != exitDNS {
|
||||
t.Errorf("exit code = %d, want %d (dns)", code, exitDNS)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCLIJSONDNSFailure checks that --json + a DNS failure produces valid JSON
|
||||
// with a correctly classified error entry — exercising the full output pipeline.
|
||||
func TestCLIJSONDNSFailure(t *testing.T) {
|
||||
stdout, _, code := cli("--json", "https://no.such.host.json.cli.invalid")
|
||||
if code != exitDNS {
|
||||
t.Fatalf("exit code = %d, want %d\nstdout: %s", code, exitDNS, stdout)
|
||||
}
|
||||
|
||||
var results []struct {
|
||||
Succeeded int `json:"succeeded"`
|
||||
Failed int `json:"failed"`
|
||||
Errors []struct {
|
||||
Phase string `json:"phase"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout), &results); err != nil {
|
||||
t.Fatalf("invalid JSON: %v\nstdout: %s", err, stdout)
|
||||
}
|
||||
if len(results) == 0 || len(results[0].Errors) == 0 {
|
||||
t.Fatalf("expected non-empty errors in JSON\nstdout: %s", stdout)
|
||||
}
|
||||
if results[0].Errors[0].Phase != "dns" {
|
||||
t.Errorf("error phase = %q, want \"dns\"", results[0].Errors[0].Phase)
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ func Measure(url string, opts Options) Result {
|
||||
wroteRequest time.Time
|
||||
firstByte time.Time
|
||||
dnsErr error
|
||||
tlsErr error
|
||||
)
|
||||
|
||||
trace := &httptrace.ClientTrace{
|
||||
@@ -74,9 +75,12 @@ func Measure(url string, opts Options) Result {
|
||||
connectStart = time.Now()
|
||||
}
|
||||
},
|
||||
ConnectDone: func(_, _ string, _ error) { connectDone = time.Now() },
|
||||
ConnectDone: func(_, _ string, _ error) { connectDone = time.Now() },
|
||||
TLSHandshakeStart: func() { tlsStart = time.Now() },
|
||||
TLSHandshakeDone: func(_ tls.ConnectionState, _ error) { tlsDone = time.Now() },
|
||||
TLSHandshakeDone: func(_ tls.ConnectionState, err error) {
|
||||
tlsDone = time.Now()
|
||||
tlsErr = err
|
||||
},
|
||||
WroteRequest: func(_ httptrace.WroteRequestInfo) { wroteRequest = time.Now() },
|
||||
GotFirstResponseByte: func() { firstByte = time.Now() },
|
||||
}
|
||||
@@ -100,7 +104,7 @@ func Measure(url string, opts Options) Result {
|
||||
if err != nil {
|
||||
end := time.Now()
|
||||
r.Err = err
|
||||
r.FailPhase = classifyErr(err, dnsErr, tlsStart, tlsDone)
|
||||
r.FailPhase = classifyErr(err, dnsErr, tlsErr, tlsStart)
|
||||
r.Total = Phase{Duration: end.Sub(start), Present: true}
|
||||
r.DNS = makePhase(dnsStart, dnsDone)
|
||||
r.Connect = makePhase(connectStart, connectDone)
|
||||
@@ -137,7 +141,10 @@ func makePhase(start, end time.Time) Phase {
|
||||
return Phase{Duration: end.Sub(start), Present: true}
|
||||
}
|
||||
|
||||
func classifyErr(err, dnsErr error, tlsStart, tlsDone time.Time) string {
|
||||
// classifyErr maps a Do() error to the phase that caused it.
|
||||
// Order: dns → timeout → tls → connect.
|
||||
// Timeout during TLS still classifies as timeout, not tls.
|
||||
func classifyErr(err, dnsErr, tlsErr error, tlsStart time.Time) string {
|
||||
if dnsErr != nil {
|
||||
return "dns"
|
||||
}
|
||||
@@ -152,8 +159,11 @@ func classifyErr(err, dnsErr error, tlsStart, tlsDone time.Time) string {
|
||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||
return "timeout"
|
||||
}
|
||||
// TLS started but handshake never completed
|
||||
if !tlsStart.IsZero() && tlsDone.IsZero() {
|
||||
if tlsErr != nil {
|
||||
return "tls"
|
||||
}
|
||||
// TLS started but handshake was interrupted (e.g. context cancelled mid-handshake)
|
||||
if !tlsStart.IsZero() {
|
||||
return "tls"
|
||||
}
|
||||
return "connect"
|
||||
|
||||
182
go/main.go
182
go/main.go
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -67,32 +68,42 @@ func failPhaseCode(fp string) int {
|
||||
}
|
||||
|
||||
func main() {
|
||||
count := flag.Int("count", 1, "number of requests per URL")
|
||||
flag.IntVar(count, "n", 1, "number of requests per URL (shorthand)")
|
||||
timeout := flag.Duration("timeout", 10*time.Second, "request timeout per sample")
|
||||
fail := flag.Bool("fail", false, "exit non-zero on HTTP status >= 400")
|
||||
jsonOut := flag.Bool("json", false, "output results as JSON instead of text")
|
||||
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
|
||||
}
|
||||
|
||||
flag.Usage = func() { fmt.Fprint(os.Stderr, usageText) }
|
||||
flag.Parse()
|
||||
func run(args []string, stdout, stderr io.Writer) int {
|
||||
fs := flag.NewFlagSet("latprobe", flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
|
||||
urls := flag.Args()
|
||||
count := fs.Int("count", 1, "number of requests per URL")
|
||||
fs.IntVar(count, "n", 1, "number of requests per URL (shorthand)")
|
||||
timeout := fs.Duration("timeout", 10*time.Second, "request timeout per sample")
|
||||
fail := fs.Bool("fail", false, "exit non-zero on HTTP status >= 400")
|
||||
jsonOut := fs.Bool("json", false, "output results as JSON instead of text")
|
||||
fs.Usage = func() { fmt.Fprint(stderr, usageText) }
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return exitOK
|
||||
}
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
urls := fs.Args()
|
||||
if len(urls) == 0 {
|
||||
fmt.Fprint(os.Stderr, usageText)
|
||||
os.Exit(exitUsage)
|
||||
fmt.Fprint(stderr, usageText)
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
opts := probe.Options{Timeout: *timeout}
|
||||
|
||||
worstCode := exitOK
|
||||
var jsonEntries []jsonEntry
|
||||
|
||||
for i, rawURL := range urls {
|
||||
succeeded, failures := runSamples(rawURL, *count, opts)
|
||||
succeeded, failed := runSamples(rawURL, *count, opts)
|
||||
|
||||
// determine exit code contribution from this URL
|
||||
for _, f := range failures {
|
||||
if c := failPhaseCode(f.phase); c > worstCode {
|
||||
for _, r := range failed {
|
||||
if c := failPhaseCode(r.FailPhase); c > worstCode {
|
||||
worstCode = c
|
||||
}
|
||||
}
|
||||
@@ -105,43 +116,35 @@ func main() {
|
||||
}
|
||||
|
||||
if *jsonOut {
|
||||
jsonEntries = append(jsonEntries, buildJSONEntry(rawURL, succeeded, failures))
|
||||
jsonEntries = append(jsonEntries, buildJSONEntry(rawURL, succeeded, failed))
|
||||
continue
|
||||
}
|
||||
|
||||
if i > 0 {
|
||||
fmt.Println()
|
||||
fmt.Fprintln(stdout)
|
||||
}
|
||||
printURL(rawURL, succeeded, failures, *count, *fail)
|
||||
printURL(stdout, rawURL, succeeded, failed, *count, *fail)
|
||||
}
|
||||
|
||||
if *jsonOut {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc := json.NewEncoder(stdout)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(jsonEntries); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "json encode: %v\n", err)
|
||||
os.Exit(exitConnect)
|
||||
fmt.Fprintf(stderr, "json encode: %v\n", err)
|
||||
return exitConnect
|
||||
}
|
||||
}
|
||||
|
||||
os.Exit(worstCode)
|
||||
return worstCode
|
||||
}
|
||||
|
||||
// ── sampling ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type failItem struct {
|
||||
phase string
|
||||
message string
|
||||
}
|
||||
|
||||
func runSamples(rawURL string, count int, opts probe.Options) (succeeded []probe.Result, failures []failItem) {
|
||||
func runSamples(rawURL string, count int, opts probe.Options) (succeeded, failed []probe.Result) {
|
||||
for range count {
|
||||
r := probe.Measure(rawURL, opts)
|
||||
if r.Err != nil {
|
||||
failures = append(failures, failItem{
|
||||
phase: r.FailPhase,
|
||||
message: unwrapMsg(r.Err),
|
||||
})
|
||||
failed = append(failed, r)
|
||||
} else {
|
||||
succeeded = append(succeeded, r)
|
||||
}
|
||||
@@ -159,100 +162,102 @@ func unwrapMsg(err error) string {
|
||||
|
||||
// ── text output ───────────────────────────────────────────────────────────────
|
||||
|
||||
func printURL(rawURL string, succeeded []probe.Result, failures []failItem, total int, fail bool) {
|
||||
func printURL(w io.Writer, rawURL string, succeeded, failed []probe.Result, total int, fail bool) {
|
||||
nOK := len(succeeded)
|
||||
nFail := len(failures)
|
||||
nFail := len(failed)
|
||||
|
||||
switch {
|
||||
case nFail == 0 && total == 1:
|
||||
// single sample, full success
|
||||
printResult(succeeded[0], fail)
|
||||
printResult(w, succeeded[0], fail)
|
||||
|
||||
case nFail == 0:
|
||||
// multi-sample, all succeeded
|
||||
printAggregate(probe.Summarize(succeeded), nil, fail)
|
||||
printAggregate(w, probe.Summarize(succeeded), nil, fail)
|
||||
|
||||
case nOK == 0:
|
||||
// all failed — show header + partial phases from last failure result
|
||||
header := fmt.Sprintf("%s (FAILED", rawURL)
|
||||
// All samples failed — print header then partial timing from last failure.
|
||||
header := rawURL + " (FAILED"
|
||||
if total > 1 {
|
||||
header += fmt.Sprintf(", 0/%d succeeded", total)
|
||||
}
|
||||
header += ")"
|
||||
fmt.Println(header)
|
||||
// re-run just to get partial phases from the last failure
|
||||
last := probe.Measure(rawURL, probe.Options{Timeout: 1 * time.Millisecond})
|
||||
// use the first failure's phase data instead (stored in failures[0])
|
||||
// we can't recover partial timing here, so skip phases and go straight to errors
|
||||
_ = last
|
||||
printFailureSummary(failures)
|
||||
fmt.Fprintln(w, header+")")
|
||||
last := failed[len(failed)-1]
|
||||
anyPhase := false
|
||||
for _, ph := range singlePhaseList(last) {
|
||||
if ph.p.Present {
|
||||
fmt.Fprintf(w, " %s : %8.2f ms\n", ph.label, ms(ph.p.Duration))
|
||||
anyPhase = true
|
||||
}
|
||||
}
|
||||
if last.Total.Present {
|
||||
if anyPhase {
|
||||
fmt.Fprintln(w, " "+strings.Repeat("─", 29))
|
||||
}
|
||||
fmt.Fprintf(w, " %s : %8.2f ms\n", "Total ", ms(last.Total.Duration))
|
||||
}
|
||||
printFailureSummary(w, failed)
|
||||
|
||||
default:
|
||||
// mixed: some succeeded, some failed
|
||||
printAggregate(probe.Summarize(succeeded), failures, fail)
|
||||
// Mixed: some succeeded, some failed.
|
||||
printAggregate(w, probe.Summarize(succeeded), failed, fail)
|
||||
}
|
||||
}
|
||||
|
||||
func printResult(r probe.Result, fail bool) {
|
||||
func printResult(w io.Writer, r probe.Result, fail bool) {
|
||||
status := fmt.Sprintf("%d", r.StatusCode)
|
||||
if fail && r.StatusCode >= 400 {
|
||||
status += " ✗"
|
||||
}
|
||||
fmt.Printf("%s (%s)\n", r.URL, status)
|
||||
fmt.Fprintf(w, "%s (%s)\n", r.URL, status)
|
||||
|
||||
for _, ph := range singlePhaseList(r) {
|
||||
if ph.p.Present {
|
||||
fmt.Printf(" %s : %8.2f ms\n", ph.label, ms(ph.p.Duration))
|
||||
fmt.Fprintf(w, " %s : %8.2f ms\n", ph.label, ms(ph.p.Duration))
|
||||
}
|
||||
}
|
||||
fmt.Println(" " + strings.Repeat("─", 29))
|
||||
fmt.Fprintln(w, " "+strings.Repeat("─", 29))
|
||||
if r.Total.Present {
|
||||
fmt.Printf(" %s : %8.2f ms\n", "Total ", ms(r.Total.Duration))
|
||||
fmt.Fprintf(w, " %s : %8.2f ms\n", "Total ", ms(r.Total.Duration))
|
||||
}
|
||||
if r.Err != nil {
|
||||
fmt.Printf(" ✗ %s: %s\n", r.FailPhase, unwrapMsg(r.Err))
|
||||
fmt.Fprintf(w, " ✗ %s: %s\n", r.FailPhase, unwrapMsg(r.Err))
|
||||
}
|
||||
}
|
||||
|
||||
func printAggregate(a probe.Aggregate, failures []failItem, fail bool) {
|
||||
func printAggregate(w io.Writer, a probe.Aggregate, failed []probe.Result, fail bool) {
|
||||
status := fmt.Sprintf("%d", a.StatusCode)
|
||||
if fail && a.StatusCode >= 400 {
|
||||
status += " ✗"
|
||||
}
|
||||
|
||||
header := fmt.Sprintf("%s (%s, %d samples", a.URL, status, a.Count)
|
||||
if len(failures) > 0 {
|
||||
header += fmt.Sprintf(", %d failed", len(failures))
|
||||
if len(failed) > 0 {
|
||||
header += fmt.Sprintf(", %d failed", len(failed))
|
||||
}
|
||||
header += ")"
|
||||
fmt.Println(header)
|
||||
fmt.Fprintln(w, header+")")
|
||||
|
||||
if a.Total.Present {
|
||||
fmt.Printf(" %-14s %9s %9s %9s\n", "", "min", "avg", "max")
|
||||
fmt.Fprintf(w, " %-14s %9s %9s %9s\n", "", "min", "avg", "max")
|
||||
for _, ph := range aggPhaseList(a) {
|
||||
if ph.p.Present {
|
||||
fmt.Printf(" %s : %6.2f ms %6.2f ms %6.2f ms\n",
|
||||
fmt.Fprintf(w, " %s : %6.2f ms %6.2f ms %6.2f ms\n",
|
||||
ph.label, ms(ph.p.Min), ms(ph.p.Avg), ms(ph.p.Max))
|
||||
}
|
||||
}
|
||||
fmt.Println(" " + strings.Repeat("─", 49))
|
||||
fmt.Printf(" %s : %6.2f ms %6.2f ms %6.2f ms\n",
|
||||
fmt.Fprintln(w, " "+strings.Repeat("─", 49))
|
||||
fmt.Fprintf(w, " %s : %6.2f ms %6.2f ms %6.2f ms\n",
|
||||
"Total ", ms(a.Total.Min), ms(a.Total.Avg), ms(a.Total.Max))
|
||||
}
|
||||
|
||||
printFailureSummary(failures)
|
||||
printFailureSummary(w, failed)
|
||||
}
|
||||
|
||||
func printFailureSummary(failures []failItem) {
|
||||
if len(failures) == 0 {
|
||||
func printFailureSummary(w io.Writer, failed []probe.Result) {
|
||||
if len(failed) == 0 {
|
||||
return
|
||||
}
|
||||
// group by (phase, message)
|
||||
type key struct{ phase, msg string }
|
||||
counts := map[key]int{}
|
||||
order := []key{}
|
||||
for _, f := range failures {
|
||||
k := key{f.phase, f.message}
|
||||
var order []key
|
||||
for _, r := range failed {
|
||||
k := key{r.FailPhase, unwrapMsg(r.Err)}
|
||||
if counts[k] == 0 {
|
||||
order = append(order, k)
|
||||
}
|
||||
@@ -261,9 +266,9 @@ func printFailureSummary(failures []failItem) {
|
||||
for _, k := range order {
|
||||
n := counts[k]
|
||||
if n == 1 {
|
||||
fmt.Printf(" ✗ %s: %s\n", k.phase, k.msg)
|
||||
fmt.Fprintf(w, " ✗ %s: %s\n", k.phase, k.msg)
|
||||
} else {
|
||||
fmt.Printf(" ✗ %d × %s: %s\n", n, k.phase, k.msg)
|
||||
fmt.Fprintf(w, " ✗ %d × %s: %s\n", n, k.phase, k.msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,36 +332,35 @@ type jsonEntry struct {
|
||||
Errors []jsonError `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
func buildJSONEntry(rawURL string, succeeded []probe.Result, failures []failItem) jsonEntry {
|
||||
func buildJSONEntry(rawURL string, succeeded, failed []probe.Result) jsonEntry {
|
||||
e := jsonEntry{
|
||||
URL: rawURL,
|
||||
Succeeded: len(succeeded),
|
||||
Failed: len(failures),
|
||||
Failed: len(failed),
|
||||
}
|
||||
|
||||
if len(succeeded) > 0 {
|
||||
a := probe.Summarize(succeeded)
|
||||
e.Status = a.StatusCode
|
||||
e.Phases = make(map[string]jsonPhase)
|
||||
addJSONPhase := func(name string, s probe.PhaseStats) {
|
||||
add := func(name string, s probe.PhaseStats) {
|
||||
if s.Present {
|
||||
e.Phases[name] = jsonPhase{MinMS: ms(s.Min), AvgMS: ms(s.Avg), MaxMS: ms(s.Max)}
|
||||
}
|
||||
}
|
||||
addJSONPhase("dns", a.DNS)
|
||||
addJSONPhase("connect", a.Connect)
|
||||
addJSONPhase("tls", a.TLS)
|
||||
addJSONPhase("ttfb", a.TTFB)
|
||||
addJSONPhase("transfer", a.Transfer)
|
||||
addJSONPhase("total", a.Total)
|
||||
add("dns", a.DNS)
|
||||
add("connect", a.Connect)
|
||||
add("tls", a.TLS)
|
||||
add("ttfb", a.TTFB)
|
||||
add("transfer", a.Transfer)
|
||||
add("total", a.Total)
|
||||
}
|
||||
|
||||
// group failures
|
||||
type key struct{ phase, msg string }
|
||||
counts := map[key]int{}
|
||||
order := []key{}
|
||||
for _, f := range failures {
|
||||
k := key{f.phase, f.message}
|
||||
var order []key
|
||||
for _, r := range failed {
|
||||
k := key{r.FailPhase, unwrapMsg(r.Err)}
|
||||
if counts[k] == 0 {
|
||||
order = append(order, k)
|
||||
}
|
||||
|
||||
287
go/run_test.go
Normal file
287
go/run_test.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// statusSrv starts a server that always replies with the given HTTP status.
|
||||
func statusSrv(code int) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(code)
|
||||
}))
|
||||
}
|
||||
|
||||
// blockingSrv starts a server whose handler blocks until the client disconnects.
|
||||
// Using r.Context().Done() means the handler exits cleanly when the client drops,
|
||||
// so srv.Close() never hangs.
|
||||
func blockingSrv() *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
}
|
||||
|
||||
// refusedURL returns an http:// URL on a port where nothing is listening.
|
||||
// It binds a listener to get a free port, closes it immediately, then hands
|
||||
// back that address — so any connect attempt is immediately refused.
|
||||
func refusedURL() string {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
panic("refusedURL: " + err.Error())
|
||||
}
|
||||
addr := l.Addr().String()
|
||||
l.Close()
|
||||
return "http://" + addr
|
||||
}
|
||||
|
||||
// invoke calls run() and returns stdout, stderr, and the exit code.
|
||||
func invoke(args ...string) (stdout, stderr string, code int) {
|
||||
var outBuf, errBuf bytes.Buffer
|
||||
code = run(args, &outBuf, &errBuf)
|
||||
return outBuf.String(), errBuf.String(), code
|
||||
}
|
||||
|
||||
// ── matrix ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestRunMatrix(t *testing.T) {
|
||||
ok200 := statusSrv(200)
|
||||
defer ok200.Close()
|
||||
|
||||
ok500 := statusSrv(500)
|
||||
defer ok500.Close()
|
||||
|
||||
ok404 := statusSrv(404)
|
||||
defer ok404.Close()
|
||||
|
||||
tlsSrv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer tlsSrv.Close()
|
||||
|
||||
hangSrv := blockingSrv()
|
||||
defer hangSrv.Close()
|
||||
|
||||
refused := refusedURL()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantCode int
|
||||
wantOut []string // substrings required in stdout
|
||||
wantErr []string // substrings required in stderr
|
||||
}{
|
||||
{
|
||||
// httptest uses 127.0.0.1 — Go skips DNS for loopback, so no DNS row.
|
||||
name: "success 200",
|
||||
args: []string{ok200.URL},
|
||||
wantCode: exitOK,
|
||||
wantOut: []string{"(200)", "TCP connect", "Total"},
|
||||
},
|
||||
{
|
||||
name: "HTTP 500 without --fail",
|
||||
args: []string{ok500.URL},
|
||||
wantCode: exitOK,
|
||||
wantOut: []string{"(500)", "Total"},
|
||||
},
|
||||
{
|
||||
name: "HTTP 404 with --fail",
|
||||
args: []string{"--fail", ok404.URL},
|
||||
wantCode: exitHTTP,
|
||||
wantOut: []string{"404 ✗"},
|
||||
},
|
||||
{
|
||||
name: "DNS failure",
|
||||
args: []string{"https://this.will.never.resolve.invalid"},
|
||||
wantCode: exitDNS,
|
||||
wantOut: []string{"✗ dns:"},
|
||||
},
|
||||
{
|
||||
name: "connection refused",
|
||||
args: []string{refused},
|
||||
wantCode: exitConnect,
|
||||
wantOut: []string{"✗ connect:"},
|
||||
},
|
||||
{
|
||||
name: "timeout",
|
||||
args: []string{"--timeout", "200ms", hangSrv.URL},
|
||||
wantCode: exitTimeout,
|
||||
wantOut: []string{"✗ timeout:"},
|
||||
},
|
||||
{
|
||||
name: "TLS failure (self-signed cert rejected by default client)",
|
||||
args: []string{tlsSrv.URL},
|
||||
wantCode: exitTLS,
|
||||
wantOut: []string{"✗ tls:"},
|
||||
},
|
||||
{
|
||||
name: "multiple URLs — highest exit code wins",
|
||||
args: []string{ok200.URL, "https://no.such.host.for.test.invalid"},
|
||||
wantCode: exitDNS, // 2 > 0
|
||||
wantOut: []string{"(200)", "✗ dns:"},
|
||||
},
|
||||
{
|
||||
name: "sampling -n 3 all success",
|
||||
args: []string{"-n", "3", ok200.URL},
|
||||
wantCode: exitOK,
|
||||
wantOut: []string{"3 samples", "min", "avg", "max"},
|
||||
},
|
||||
{
|
||||
name: "no args → usage",
|
||||
args: []string{},
|
||||
wantCode: exitUsage,
|
||||
wantErr: []string{"Usage:"},
|
||||
},
|
||||
{
|
||||
name: "-h → usage exit 0",
|
||||
args: []string{"-h"},
|
||||
wantCode: exitOK,
|
||||
wantErr: []string{"Usage:"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
stdout, stderr, code := invoke(tc.args...)
|
||||
|
||||
if code != tc.wantCode {
|
||||
t.Errorf("exit code = %d, want %d\nstdout:\n%s\nstderr:\n%s",
|
||||
code, tc.wantCode, stdout, stderr)
|
||||
}
|
||||
for _, s := range tc.wantOut {
|
||||
if !strings.Contains(stdout, s) {
|
||||
t.Errorf("stdout missing %q\nstdout:\n%s", s, stdout)
|
||||
}
|
||||
}
|
||||
for _, s := range tc.wantErr {
|
||||
if !strings.Contains(stderr, s) {
|
||||
t.Errorf("stderr missing %q\nstderr:\n%s", s, stderr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── JSON-specific assertions ──────────────────────────────────────────────────
|
||||
|
||||
func TestJSONSuccess(t *testing.T) {
|
||||
srv := statusSrv(200)
|
||||
defer srv.Close()
|
||||
|
||||
stdout, _, code := invoke("--json", srv.URL)
|
||||
if code != exitOK {
|
||||
t.Fatalf("exit code = %d, want 0\nstdout: %s", code, stdout)
|
||||
}
|
||||
|
||||
var results []struct {
|
||||
URL string `json:"url"`
|
||||
Status int `json:"status"`
|
||||
Succeeded int `json:"succeeded"`
|
||||
Failed int `json:"failed"`
|
||||
Phases map[string]any `json:"phases"`
|
||||
Errors []any `json:"errors"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout), &results); err != nil {
|
||||
t.Fatalf("invalid JSON: %v\nstdout: %s", err, stdout)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(results))
|
||||
}
|
||||
r := results[0]
|
||||
if r.Status != 200 {
|
||||
t.Errorf("status = %d, want 200", r.Status)
|
||||
}
|
||||
if r.Succeeded != 1 {
|
||||
t.Errorf("succeeded = %d, want 1", r.Succeeded)
|
||||
}
|
||||
if r.Failed != 0 {
|
||||
t.Errorf("failed = %d, want 0", r.Failed)
|
||||
}
|
||||
if r.Phases["total"] == nil {
|
||||
t.Error("phases.total missing")
|
||||
}
|
||||
if len(r.Errors) > 0 {
|
||||
t.Errorf("unexpected errors: %v", r.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONDNSFailure(t *testing.T) {
|
||||
stdout, _, code := invoke("--json", "https://no.such.host.json.test.invalid")
|
||||
if code != exitDNS {
|
||||
t.Fatalf("exit code = %d, want %d\nstdout: %s", code, exitDNS, stdout)
|
||||
}
|
||||
|
||||
var results []struct {
|
||||
Succeeded int `json:"succeeded"`
|
||||
Failed int `json:"failed"`
|
||||
Errors []struct {
|
||||
Phase string `json:"phase"`
|
||||
Count int `json:"count"`
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout), &results); err != nil {
|
||||
t.Fatalf("invalid JSON: %v\nstdout: %s", err, stdout)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(results))
|
||||
}
|
||||
r := results[0]
|
||||
if r.Succeeded != 0 {
|
||||
t.Errorf("succeeded = %d, want 0", r.Succeeded)
|
||||
}
|
||||
if r.Failed != 1 {
|
||||
t.Errorf("failed = %d, want 1", r.Failed)
|
||||
}
|
||||
if len(r.Errors) == 0 {
|
||||
t.Fatal("errors array is empty")
|
||||
}
|
||||
if r.Errors[0].Phase != "dns" {
|
||||
t.Errorf("error phase = %q, want \"dns\"", r.Errors[0].Phase)
|
||||
}
|
||||
if r.Errors[0].Count != 1 {
|
||||
t.Errorf("error count = %d, want 1", r.Errors[0].Count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONSampling(t *testing.T) {
|
||||
srv := statusSrv(200)
|
||||
defer srv.Close()
|
||||
|
||||
stdout, _, code := invoke("--json", "-n", "3", srv.URL)
|
||||
if code != exitOK {
|
||||
t.Fatalf("exit code = %d, want 0\nstdout: %s", code, stdout)
|
||||
}
|
||||
|
||||
var results []struct {
|
||||
Succeeded int `json:"succeeded"`
|
||||
Phases map[string]struct {
|
||||
MinMS float64 `json:"min_ms"`
|
||||
AvgMS float64 `json:"avg_ms"`
|
||||
MaxMS float64 `json:"max_ms"`
|
||||
} `json:"phases"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout), &results); err != nil {
|
||||
t.Fatalf("invalid JSON: %v\nstdout: %s", err, stdout)
|
||||
}
|
||||
r := results[0]
|
||||
if r.Succeeded != 3 {
|
||||
t.Errorf("succeeded = %d, want 3", r.Succeeded)
|
||||
}
|
||||
total, ok := r.Phases["total"]
|
||||
if !ok {
|
||||
t.Fatal("phases.total missing")
|
||||
}
|
||||
if total.MinMS <= 0 {
|
||||
t.Errorf("total.min_ms = %f, want > 0", total.MinMS)
|
||||
}
|
||||
if total.MaxMS < total.MinMS {
|
||||
t.Errorf("total.max_ms (%f) < total.min_ms (%f)", total.MaxMS, total.MinMS)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user