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:
2026-07-01 01:00:20 +02:00
parent c323d879d0
commit a9534ec2c1
8 changed files with 727 additions and 95 deletions

View File

@@ -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)
}