diff --git a/internal/fleetreconcile/cmd/main.go b/internal/fleetreconcile/cmd/main.go index 26c8fd77..2fb5d32c 100644 --- a/internal/fleetreconcile/cmd/main.go +++ b/internal/fleetreconcile/cmd/main.go @@ -13,6 +13,7 @@ import ( "encoding/json" "flag" "fmt" + "io" "os" "os/exec" "sort" @@ -22,13 +23,18 @@ import ( ) func main() { - if err := run(os.Args[1:], os.Stdout); err != nil { + code, err := run(os.Args[1:], os.Stdout) + if err != nil { fmt.Fprintf(os.Stderr, "fleet-reconcile: %v\n", err) - os.Exit(2) // 2 = tool error (bad input); 1 = gate failed; 0 = gate passed } + os.Exit(code) } -func run(args []string, out *os.File) error { +// run executes the reconcile gate and returns the process exit code: +// 0 = gate passed, 1 = gate failed, 2 = tool error (bad input). os.Exit +// lives only in main so tests can assert the code and capture the report +// through any io.Writer. +func run(args []string, out io.Writer) (int, error) { fs := flag.NewFlagSet("fleet-reconcile", flag.ContinueOnError) ledgerPath := fs.String("ledger", "", "path to the run-ledger JSONL file (empty = no registered runs)") runsPath := fs.String("runs", "", "path to a pre-fetched gh-run-list JSON array (mutually exclusive with --window-start)") @@ -39,19 +45,19 @@ func run(args []string, out *os.File) error { selfRunID := fs.Int64("self-run-id", 0, "this reconcile run's own id, excluded from reconciliation") allow := fs.String("allow-workflows", "", "comma-separated workflow names to reconcile; empty = all") if err := fs.Parse(args); err != nil { - return err + return 2, err } if (*runsPath == "") == (*windowStart == "") { - return fmt.Errorf("exactly one of --runs or --window-start is required") + return 2, fmt.Errorf("exactly one of --runs or --window-start is required") } runs, err := loadRuns(*runsPath, *windowStart, *repo, *pageSize, *maxPages) if err != nil { - return fmt.Errorf("loading runs: %w", err) + return 2, fmt.Errorf("loading runs: %w", err) } ledger, err := readLedger(*ledgerPath) if err != nil { - return fmt.Errorf("reading ledger: %w", err) + return 2, fmt.Errorf("reading ledger: %w", err) } opts := fleetreconcile.Options{SelfRunID: *selfRunID} @@ -64,12 +70,12 @@ func run(args []string, out *os.File) error { rep := fleetreconcile.Reconcile(runs, ledger, opts) if _, err := fmt.Fprint(out, fleetreconcile.FormatReport(rep)); err != nil { - return fmt.Errorf("writing report: %w", err) + return 2, fmt.Errorf("writing report: %w", err) } if !rep.Passed() { - os.Exit(1) + return 1, nil } - return nil + return 0, nil } // loadRuns returns the runs to reconcile: either a pre-fetched JSON array @@ -202,9 +208,14 @@ func readLedger(path string) ([]fleetreconcile.LedgerEntry, error) { return nil, err } defer func() { _ = f.Close() }() + return parseLedger(f) +} +// parseLedger decodes JSONL ledger content: one JSON LedgerEntry per non-blank +// line. Malformed lines error with their physical (1-based) line number. +func parseLedger(r io.Reader) ([]fleetreconcile.LedgerEntry, error) { var entries []fleetreconcile.LedgerEntry - sc := bufio.NewScanner(f) + sc := bufio.NewScanner(r) sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) line := 0 for sc.Scan() { diff --git a/internal/fleetreconcile/cmd/main_test.go b/internal/fleetreconcile/cmd/main_test.go index 1599999a..26816052 100644 --- a/internal/fleetreconcile/cmd/main_test.go +++ b/internal/fleetreconcile/cmd/main_test.go @@ -1,6 +1,10 @@ package main import ( + "bytes" + "os" + "path/filepath" + "strings" "testing" "github.com/stablekernel/cascade/internal/fleetreconcile" @@ -88,3 +92,241 @@ func TestSlicePage_SortsNewestFirst(t *testing.T) { } } } + +// writeFile writes content to a file under t.TempDir() and returns its path. +func writeFile(t *testing.T, name, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("writing %s: %v", name, err) + } + return path +} + +// runsJSON is a two-run window: an unregistered success (benign) and a +// completed failure whose accounting depends on the ledger under test. +const runsJSON = `[ + {"databaseId": 11, "workflowName": "e2e", "event": "push", "conclusion": "success", "status": "completed", "headBranch": "main", "createdAt": "2026-07-15T01:00:00Z"}, + {"databaseId": 12, "workflowName": "e2e", "event": "push", "conclusion": "failure", "status": "completed", "headBranch": "main", "createdAt": "2026-07-15T01:01:00Z"} +]` + +func TestRun_FlagValidation(t *testing.T) { + tests := []struct { + name string + args []string + wantErr string + }{ + { + name: "both runs and window-start", + args: []string{"--runs", "runs.json", "--window-start", "2026-07-15T00:00:00Z"}, + wantErr: "exactly one of --runs or --window-start is required", + }, + { + name: "neither runs nor window-start", + args: nil, + wantErr: "exactly one of --runs or --window-start is required", + }, + { + name: "unknown flag", + args: []string{"--bogus"}, + wantErr: "flag provided but not defined: -bogus", + }, + { + name: "window-start without repo", + args: []string{"--window-start", "2026-07-15T00:00:00Z"}, + wantErr: "--repo is required with --window-start", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var out bytes.Buffer + code, err := run(tt.args, &out) + if code != 2 { + t.Errorf("exit code = %d, want 2", code) + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("err = %v, want it to contain %q", err, tt.wantErr) + } + if out.Len() != 0 { + t.Errorf("report written on a validation error: %q", out.String()) + } + }) + } +} + +func TestRun_InputErrors(t *testing.T) { + missing := filepath.Join(t.TempDir(), "does-not-exist.json") + malformedRuns := writeFile(t, "runs.json", "not json") + goodRuns := writeFile(t, "runs.json", runsJSON) + badLedger := writeFile(t, "ledger.jsonl", + "{\"run_id\": 12, \"expected\": \"failure\", \"reason\": \"gated\"}\n\nnot json\n") + + tests := []struct { + name string + args []string + wantErr string + }{ + { + name: "missing runs file", + args: []string{"--runs", missing}, + wantErr: "loading runs:", + }, + { + name: "malformed runs JSON", + args: []string{"--runs", malformedRuns}, + wantErr: "parsing run-list JSON", + }, + { + name: "malformed ledger line reports its line number", + args: []string{"--runs", goodRuns, "--ledger", badLedger}, + wantErr: "reading ledger: ledger line 3:", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var out bytes.Buffer + code, err := run(tt.args, &out) + if code != 2 { + t.Errorf("exit code = %d, want 2", code) + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("err = %v, want it to contain %q", err, tt.wantErr) + } + }) + } +} + +func TestRun_GatePass(t *testing.T) { + runs := writeFile(t, "runs.json", runsJSON) + ledger := writeFile(t, "ledger.jsonl", + "{\"run_id\": 12, \"expected\": \"failure\", \"reason\": \"negative scenario\"}\n") + + var out bytes.Buffer + code, err := run([]string{"--runs", runs, "--ledger", ledger}, &out) + if err != nil { + t.Fatalf("run() error = %v, want nil", err) + } + if code != 0 { + t.Errorf("exit code = %d, want 0", code) + } + report := out.String() + if !strings.Contains(report, "RESULT: PASS") { + t.Errorf("report missing PASS result:\n%s", report) + } + if !strings.Contains(report, "FAILING - coverage gaps (0):") { + t.Errorf("report should list zero coverage gaps:\n%s", report) + } +} + +func TestRun_GateFail(t *testing.T) { + // Run 12 is a completed failure and nothing registers it: the exact + // fire-and-forget gap the gate exists to catch. + runs := writeFile(t, "runs.json", runsJSON) + + var out bytes.Buffer + code, err := run([]string{"--runs", runs}, &out) + if err != nil { + t.Fatalf("run() error = %v, want nil (gate failure is an exit code, not an error)", err) + } + if code != 1 { + t.Errorf("exit code = %d, want 1", code) + } + report := out.String() + if !strings.Contains(report, "RESULT: FAIL") { + t.Errorf("report missing FAIL result:\n%s", report) + } + if !strings.Contains(report, "run 12") { + t.Errorf("report should name the unaccounted run 12:\n%s", report) + } +} + +func TestRun_EmptyAndMissingLedger(t *testing.T) { + // A success-only window passes with no ledger at all: an omitted --ledger + // flag and a --ledger path that does not exist both mean "no registered + // runs". + successOnly := writeFile(t, "runs.json", + `[{"databaseId": 11, "workflowName": "e2e", "event": "push", "conclusion": "success", "status": "completed", "headBranch": "main", "createdAt": "2026-07-15T01:00:00Z"}]`) + missingLedger := filepath.Join(t.TempDir(), "no-ledger.jsonl") + + for _, tt := range []struct { + name string + args []string + }{ + {name: "omitted ledger flag", args: []string{"--runs", successOnly}}, + {name: "missing ledger file", args: []string{"--runs", successOnly, "--ledger", missingLedger}}, + } { + t.Run(tt.name, func(t *testing.T) { + var out bytes.Buffer + code, err := run(tt.args, &out) + if err != nil { + t.Fatalf("run() error = %v, want nil", err) + } + if code != 0 { + t.Errorf("exit code = %d, want 0", code) + } + if !strings.Contains(out.String(), "RESULT: PASS") { + t.Errorf("report missing PASS result:\n%s", out.String()) + } + }) + } +} + +func TestParseLedger(t *testing.T) { + tests := []struct { + name string + input string + wantEntries int + wantErr string + }{ + { + name: "valid entries with blank lines", + input: "{\"run_id\": 1, \"expected\": \"success\", \"reason\": \"a\"}\n\n{\"run_id\": 2, \"expected\": \"failure\", \"reason\": \"b\"}\n", + wantEntries: 2, + }, + { + name: "empty input", + input: "", + }, + { + name: "whitespace-only input", + input: "\n \n\t\n", + }, + { + name: "malformed line numbered physically", + input: "{\"run_id\": 1, \"expected\": \"success\", \"reason\": \"a\"}\n\n{broken\n", + wantErr: "ledger line 3:", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + entries, err := parseLedger(strings.NewReader(tt.input)) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("err = %v, want it to contain %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("parseLedger() error = %v", err) + } + if len(entries) != tt.wantEntries { + t.Fatalf("got %d entries, want %d", len(entries), tt.wantEntries) + } + }) + } + + t.Run("fields decode", func(t *testing.T) { + entries, err := parseLedger(strings.NewReader( + "{\"run_id\": 42, \"expected\": \"failure\", \"reason\": \"hotfix negative\"}\n")) + if err != nil { + t.Fatalf("parseLedger() error = %v", err) + } + if len(entries) != 1 { + t.Fatalf("got %d entries, want 1", len(entries)) + } + e := entries[0] + if e.RunID != 42 || e.Expected != "failure" || e.Reason != "hotfix negative" { + t.Errorf("entry = %+v, want RunID=42 Expected=failure Reason=%q", e, "hotfix negative") + } + }) +}