From fcc30aeb6af45931e16d7aa84ae41b21143524f7 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Thu, 16 Jul 2026 01:55:13 -0400 Subject: [PATCH] test(cmd): prove the verify exit-code contract through exitCodeFor and an exit-2 scenario step Signed-off-by: Joshua Temple (cherry picked from commit 1e5e80366de26c3596286024161fe0079d9bd63c) --- cmd/cascade/exitcode_test.go | 91 ++++++++++++++++++++++++++++++ e2e/harness/multistep.go | 5 +- e2e/scenarios/22-verify-drift.yaml | 13 ++++- 3 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 cmd/cascade/exitcode_test.go diff --git a/cmd/cascade/exitcode_test.go b/cmd/cascade/exitcode_test.go new file mode 100644 index 0000000..65d7720 --- /dev/null +++ b/cmd/cascade/exitcode_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "errors" + "fmt" + "io" + "path/filepath" + "testing" + + "github.com/stablekernel/cascade/internal/verify" +) + +// codedError is a test double for a command error that opts into a specific +// process exit code via the ExitCode() int interface main.go reads. +type codedError struct { + code int +} + +func (e *codedError) Error() string { return fmt.Sprintf("coded error %d", e.code) } + +func (e *codedError) ExitCode() int { return e.code } + +// TestExitCodeFor pins the documented exit-code contract for the glue that +// maps a command error to the process exit code: errors carrying an +// ExitCode() int method return that code (verify returns 1 for drift and 2 +// for an operational failure), and every other error keeps cobra's default +// exit code 1. +func TestExitCodeFor(t *testing.T) { + tests := []struct { + name string + err error + want int + }{ + { + name: "plain error defaults to 1", + err: errors.New("boom"), + want: 1, + }, + { + name: "verify drift sentinel maps to 1", + err: verify.ErrDrift, + want: 1, + }, + { + name: "wrapped verify drift sentinel maps to 1", + err: fmt.Errorf("running verify: %w", verify.ErrDrift), + want: 1, + }, + { + name: "error with ExitCode 2 maps to 2", + err: &codedError{code: 2}, + want: 2, + }, + { + name: "wrapped error with ExitCode 2 maps to 2", + err: fmt.Errorf("running verify: %w", &codedError{code: 2}), + want: 2, + }, + { + name: "error with ExitCode 3 keeps its own code", + err: &codedError{code: 3}, + want: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := exitCodeFor(tt.err); got != tt.want { + t.Errorf("exitCodeFor(%v) = %d, want %d", tt.err, got, tt.want) + } + }) + } +} + +// TestExitCodeFor_VerifyOperationalError proves the full in-process link for +// the only distinct mapping in the documented table: a real operational +// failure from verify.Run (here, a missing manifest) carries exit code 2 +// through exitCodeFor, not the generic 1. +func TestExitCodeFor_VerifyOperationalError(t *testing.T) { + missing := filepath.Join(t.TempDir(), "does-not-exist.yaml") + err := verify.Run(verify.Options{ConfigPath: missing}, io.Discard, io.Discard) + if err == nil { + t.Fatal("verify.Run with a missing manifest returned nil, want operational error") + } + if errors.Is(err, verify.ErrDrift) { + t.Fatalf("verify.Run with a missing manifest returned drift, want operational: %v", err) + } + if got := exitCodeFor(err); got != 2 { + t.Errorf("exitCodeFor(operational verify error) = %d, want 2", got) + } +} diff --git a/e2e/harness/multistep.go b/e2e/harness/multistep.go index 7af6f1d..6cf2df3 100644 --- a/e2e/harness/multistep.go +++ b/e2e/harness/multistep.go @@ -326,9 +326,10 @@ type RollbackStep struct { // manifest would generate. Regenerate, when set, runs `cascade generate-workflow // -f` first so verify checks pristine generated output rather than the harness's // localized copies. Mutate optionally overwrites one generated file with the -// given content before verifying, so a scenario can drive the drift path. +// given content before verifying, so a scenario can drive the drift path +// (mutating the manifest itself drives the operational-failure path instead). // ExpectExit is the exit code `cascade verify` must return (0 = no drift, -// non-zero = drift). +// 1 = drift, 2 = operational failure such as an unreadable manifest). type VerifyStep struct { Regenerate bool `yaml:"regenerate,omitempty"` MutatePath string `yaml:"mutate_path,omitempty"` diff --git a/e2e/scenarios/22-verify-drift.yaml b/e2e/scenarios/22-verify-drift.yaml index 0b3f796..3bcc0e4 100644 --- a/e2e/scenarios/22-verify-drift.yaml +++ b/e2e/scenarios/22-verify-drift.yaml @@ -3,8 +3,10 @@ description: | Exercises the read-only `cascade verify` command end to end. After generating workflows from a two-environment manifest, verify against pristine generated output reports no drift (exit 0). Mutating a committed workflow makes verify - report drift (exit non-zero). Regenerating restores the workflows and verify - is clean again (exit 0). + report drift (exit 1). Corrupting the manifest itself makes verify fail + operationally (exit 2), proving the third row of the documented exit-code + table through the shipped binary. Regenerating restores the workflows and + verify is clean again (exit 0). config: trunk_branch: main @@ -42,6 +44,13 @@ steps: mutate_append: "\n# drift\n" expect_exit: 1 + - name: "Corrupt the manifest; verify fails operationally with exit 2" + action: verify + verify: + mutate_path: ".github/manifest.yaml" + mutate_append: "\nci: [" + expect_exit: 2 + - name: "Regenerate restores workflows; verify is clean again" action: verify verify: