From c2899b1bc48d9a0259e05063202e77828b271094 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Wed, 24 Jun 2026 19:37:15 -0400 Subject: [PATCH] test(cli): cover init, status, and generate-flag CLI gaps Adds cmd-level integration coverage for previously-unasserted CLI behaviors: cascade init topology/envs/dir/name scaffolding; status env/build/deploy/consistency including --json and required --env negatives; and generate non-default flags (--output, --promote-output, --orchestrate-only, --promote-only, --action-folder) plus schema, version, branch-protection, and quiet verify. Fixes a test that leaked generated artifacts into the source tree by anchoring generation to a temp dir. Closes #329, closes #330, closes #331. Signed-off-by: Joshua Temple --- cmd/cascade/main_test.go | 407 +++++++++++++++++++++++++++++++ internal/initcmd/command_test.go | 108 ++++++++ 2 files changed, 515 insertions(+) diff --git a/cmd/cascade/main_test.go b/cmd/cascade/main_test.go index ff14d19b..858ce8e5 100644 --- a/cmd/cascade/main_test.go +++ b/cmd/cascade/main_test.go @@ -36,6 +36,26 @@ func runCLI(args ...string) (string, string, error) { return stdout.String(), stderr.String(), err } +// runCLIIn runs the test binary with its working directory set to dir, so that +// relative output paths (the generator's default workflow locations) resolve +// under dir, and commands that read the process working directory (the status +// consistency branch lister) operate inside that repository rather than the +// cmd/cascade source dir. The binary path is made absolute because dir is not +// the build dir. +func runCLIIn(dir string, args ...string) (string, string, error) { + bin, err := filepath.Abs("./cascade-test") + if err != nil { + return "", "", err + } + cmd := exec.Command(bin, args...) + cmd.Dir = dir + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err = cmd.Run() + return stdout.String(), stderr.String(), err +} + func TestVersionCommand(t *testing.T) { stdout, _, err := runCLI("version") if err != nil { @@ -383,6 +403,393 @@ func TestStatusCommand_BuildMissingEnvFlag(t *testing.T) { } } +func TestStatusCommand_DeployMissingEnvFlag(t *testing.T) { + manifest := fixtureManifestPath(t) + _, stderr, err := runCLI("status", "deploy", "services", "--config", manifest) + if err == nil { + t.Error("expected error when --env flag is missing") + } + if !contains(stderr, "env") { + t.Errorf("expected --env mentioned in error, got: %s", stderr) + } +} + +// gitRun runs a git command in dir and fails the test on error. It is used to +// stand up a throwaway repository with an origin remote so the consistency +// command's branch lister has refs/remotes/origin/* to read. +func gitRun(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, out) + } +} + +// consistencyRepo builds a temp work repo wired to a bare origin remote that +// carries an orphan env/ branch (a branch with no matching divergence in +// the manifest). It returns the work-tree path and the manifest path inside it. +// The manifest records only an undiverged dev env, so env/orphan is surfaced as +// an orphan integration branch. +func consistencyRepo(t *testing.T) (workDir, manifestPath string) { + t.Helper() + root := t.TempDir() + bare := filepath.Join(root, "origin.git") + work := filepath.Join(root, "work") + + gitRun(t, root, "init", "--bare", "-q", bare) + if err := os.MkdirAll(work, 0o755); err != nil { + t.Fatalf("mkdir work: %v", err) + } + gitRun(t, work, "init", "-q") + gitRun(t, work, "config", "user.email", "test@example.com") + gitRun(t, work, "config", "user.name", "test") + gitRun(t, work, "config", "commit.gpgsign", "false") + gitRun(t, work, "checkout", "-q", "-b", "main") + + manifestPath = filepath.Join(work, "manifest.yaml") + content := "ci:\n config:\n environments: [dev]\n state:\n dev:\n sha: trunkhead\n" + if err := os.WriteFile(manifestPath, []byte(content), 0o644); err != nil { + t.Fatalf("write manifest: %v", err) + } + + gitRun(t, work, "add", "manifest.yaml") + gitRun(t, work, "commit", "-q", "-m", "init") + gitRun(t, work, "remote", "add", "origin", bare) + gitRun(t, work, "push", "-q", "origin", "main") + gitRun(t, work, "checkout", "-q", "-b", "env/orphan") + gitRun(t, work, "push", "-q", "origin", "env/orphan") + gitRun(t, work, "checkout", "-q", "main") + gitRun(t, work, "fetch", "-q", "origin") + + return work, manifestPath +} + +func TestStatusCommand_Consistency_JSON(t *testing.T) { + work, manifest := consistencyRepo(t) + + stdout, stderr, err := runCLIIn(work, "status", "consistency", "--config", manifest, "--json") + if err != nil { + t.Fatalf("status consistency --json failed: %v\nstderr: %s", err, stderr) + } + + var result struct { + OrphanEnvBranches []string `json:"orphan_env_branches"` + } + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("JSON parse error: %v\noutput: %s", err, stdout) + } + if len(result.OrphanEnvBranches) != 1 || result.OrphanEnvBranches[0] != "env/orphan" { + t.Errorf("expected [env/orphan], got %v", result.OrphanEnvBranches) + } +} + +func TestStatusCommand_Consistency_HumanReadable(t *testing.T) { + work, manifest := consistencyRepo(t) + + stdout, stderr, err := runCLIIn(work, "status", "consistency", "--config", manifest) + if err != nil { + t.Fatalf("status consistency failed: %v\nstderr: %s", err, stderr) + } + if !contains(stdout, "env/orphan") { + t.Errorf("expected orphan branch in output, got:\n%s", stdout) + } +} + +// -------- generate-workflow flag coverage -------- + +// genFixtureManifest writes a multi-environment manifest under /.github/ +// manifest.yaml and returns its path. The generator resolves output paths +// relative to the .github parent (the repo root), so callers can assert on +// files written under tmpDir. +func genFixtureManifest(t *testing.T, tmpDir string) string { + t.Helper() + githubDir := filepath.Join(tmpDir, ".github") + if err := os.MkdirAll(githubDir, 0o755); err != nil { + t.Fatalf("mkdir .github: %v", err) + } + manifest := `ci: + config: + trunk_branch: main + environments: + - dev + - prod + builds: + - name: app + workflow: .github/workflows/build.yaml + triggers: + - "src/**" + deploys: + - name: services + workflow: .github/workflows/deploy.yaml + triggers: + - "deploy/**" + depends_on: + - app +` + path := filepath.Join(githubDir, "manifest.yaml") + if err := os.WriteFile(path, []byte(manifest), 0o644); err != nil { + t.Fatalf("write manifest: %v", err) + } + + // The orchestrate generator reads each referenced build/deploy workflow to + // discover its job outputs, so the files the manifest points at must exist. + workflowsDir := filepath.Join(githubDir, "workflows") + if err := os.MkdirAll(workflowsDir, 0o755); err != nil { + t.Fatalf("mkdir workflows: %v", err) + } + stub := `name: stub +on: + workflow_call: +jobs: + run: + runs-on: ubuntu-latest + steps: + - run: "true" +` + for _, name := range []string{"build.yaml", "deploy.yaml"} { + if err := os.WriteFile(filepath.Join(workflowsDir, name), []byte(stub), 0o644); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + return path +} + +func fileExists(t *testing.T, path string) bool { + t.Helper() + _, err := os.Stat(path) + return err == nil +} + +func TestGenerateWorkflow_Output_WritesCustomOrchestratePath(t *testing.T) { + tmpDir := t.TempDir() + manifest := genFixtureManifest(t, tmpDir) + customPath := filepath.Join(tmpDir, ".github", "workflows", "custom-orchestrate.yaml") + defaultPath := filepath.Join(tmpDir, ".github", "workflows", "orchestrate.yaml") + + // Run in tmpDir so the non-overridden default-path artifacts (promote, + // hotfix, rollback) land under tmpDir rather than polluting the source tree. + _, stderr, err := runCLIIn(tmpDir, "generate-workflow", "--config", manifest, "--output", customPath, "--force") + if err != nil { + t.Fatalf("generate-workflow --output failed: %v\nstderr: %s", err, stderr) + } + + if !fileExists(t, customPath) { + t.Errorf("expected custom orchestrate file at %s", customPath) + } + body, rerr := os.ReadFile(customPath) + if rerr != nil { + t.Fatalf("reading custom output: %v", rerr) + } + if !contains(string(body), "on:") || !contains(string(body), "name:") { + t.Errorf("custom orchestrate file lacks workflow content, got:\n%s", body) + } + if fileExists(t, defaultPath) { + t.Errorf("orchestrate should not have been written to the default path %s", defaultPath) + } +} + +func TestGenerateWorkflow_PromoteOutput_WritesCustomPromotePath(t *testing.T) { + tmpDir := t.TempDir() + manifest := genFixtureManifest(t, tmpDir) + customPromote := filepath.Join(tmpDir, ".github", "workflows", "custom-promote.yaml") + defaultPromote := filepath.Join(tmpDir, ".github", "workflows", "promote.yaml") + + // Run in tmpDir so non-overridden default-path artifacts land under tmpDir. + _, stderr, err := runCLIIn(tmpDir, "generate-workflow", "--config", manifest, "--promote-output", customPromote, "--force") + if err != nil { + t.Fatalf("generate-workflow --promote-output failed: %v\nstderr: %s", err, stderr) + } + + if !fileExists(t, customPromote) { + t.Errorf("expected custom promote file at %s", customPromote) + } + body, rerr := os.ReadFile(customPromote) + if rerr != nil { + t.Fatalf("reading custom promote output: %v", rerr) + } + if !contains(string(body), "on:") { + t.Errorf("custom promote file lacks workflow content, got:\n%s", body) + } + if fileExists(t, defaultPromote) { + t.Errorf("promote should not have been written to the default path %s", defaultPromote) + } +} + +func TestGenerateWorkflow_OrchestrateOnly_OmitsPromote(t *testing.T) { + tmpDir := t.TempDir() + manifest := genFixtureManifest(t, tmpDir) + // Anchor both outputs to absolute paths under tmpDir; the default flag + // values are relative to the process working directory, not the manifest. + orchestrate := filepath.Join(tmpDir, ".github", "workflows", "orchestrate.yaml") + promote := filepath.Join(tmpDir, ".github", "workflows", "promote.yaml") + + // Run in tmpDir so non-overridden default-path artifacts land under tmpDir. + _, stderr, err := runCLIIn(tmpDir, "generate-workflow", "--config", manifest, + "--output", orchestrate, "--promote-output", promote, + "--orchestrate-only", "--force") + if err != nil { + t.Fatalf("generate-workflow --orchestrate-only failed: %v\nstderr: %s", err, stderr) + } + + if !fileExists(t, orchestrate) { + t.Errorf("expected orchestrate file at %s", orchestrate) + } + if fileExists(t, promote) { + t.Errorf("promote file should not exist with --orchestrate-only, found %s", promote) + } +} + +func TestGenerateWorkflow_PromoteOnly_OmitsOrchestrate(t *testing.T) { + tmpDir := t.TempDir() + manifest := genFixtureManifest(t, tmpDir) + orchestrate := filepath.Join(tmpDir, ".github", "workflows", "orchestrate.yaml") + promote := filepath.Join(tmpDir, ".github", "workflows", "promote.yaml") + + // Run in tmpDir so non-overridden default-path artifacts land under tmpDir. + _, stderr, err := runCLIIn(tmpDir, "generate-workflow", "--config", manifest, + "--output", orchestrate, "--promote-output", promote, + "--promote-only", "--force") + if err != nil { + t.Fatalf("generate-workflow --promote-only failed: %v\nstderr: %s", err, stderr) + } + + if !fileExists(t, promote) { + t.Errorf("expected promote file at %s", promote) + } + if fileExists(t, orchestrate) { + t.Errorf("orchestrate file should not exist with --promote-only, found %s", orchestrate) + } +} + +func TestGenerateWorkflow_ActionFolder_NamesCompositeActionDir(t *testing.T) { + tmpDir := t.TempDir() + manifest := genFixtureManifest(t, tmpDir) + customAction := filepath.Join(tmpDir, ".github", "actions", "custom-release-action", "action.yaml") + defaultAction := filepath.Join(tmpDir, ".github", "actions", "manage-release", "action.yaml") + + // Run in tmpDir so the default-path workflow artifacts land under tmpDir. + _, stderr, err := runCLIIn(tmpDir, "generate-workflow", "--config", manifest, "--action-folder", "custom-release-action", "--force") + if err != nil { + t.Fatalf("generate-workflow --action-folder failed: %v\nstderr: %s", err, stderr) + } + + if !fileExists(t, customAction) { + t.Errorf("expected composite action at %s", customAction) + } + if fileExists(t, defaultAction) { + t.Errorf("default action folder should not exist with --action-folder, found %s", defaultAction) + } +} + +// -------- version command format -------- + +func TestVersionCommand_ContainsVersionString(t *testing.T) { + stdout, _, err := runCLI("version") + if err != nil { + t.Fatalf("version command failed: %v", err) + } + if !contains(stdout, "cascade") { + t.Errorf("expected version output to contain \"cascade\", got: %q", stdout) + } +} + +// -------- schema command -------- + +func TestSchemaCommand_EmitsEmbeddedJSONSchema(t *testing.T) { + stdout, stderr, err := runCLI("schema") + if err != nil { + t.Fatalf("schema command failed: %v\nstderr: %s", err, stderr) + } + if stdout == "" { + t.Fatal("schema command returned empty output") + } + var doc map[string]interface{} + if uerr := json.Unmarshal([]byte(stdout), &doc); uerr != nil { + t.Fatalf("schema output is not valid JSON: %v\noutput: %s", uerr, stdout) + } + if _, ok := doc["$schema"]; !ok { + t.Errorf("expected \"$schema\" key in JSON Schema output, keys: %v", keysOf(doc)) + } +} + +// -------- branch-protection command -------- + +func TestBranchProtectionCommand_EmitsProtectionAndOperatorTodo(t *testing.T) { + manifest := genFixtureManifest(t, t.TempDir()) + + stdout, stderr, err := runCLI("branch-protection", "--config", manifest) + if err != nil { + t.Fatalf("branch-protection command failed: %v\nstderr: %s", err, stderr) + } + var doc map[string]interface{} + if uerr := json.Unmarshal([]byte(stdout), &doc); uerr != nil { + t.Fatalf("branch-protection output is not valid JSON: %v\noutput: %s", uerr, stdout) + } + if _, ok := doc["protection"]; !ok { + t.Errorf("expected \"protection\" key, keys: %v", keysOf(doc)) + } + if _, ok := doc["operator_todo"]; !ok { + t.Errorf("expected \"operator_todo\" key, keys: %v", keysOf(doc)) + } +} + +// -------- verify --quiet -------- + +func TestVerifyCommand_QuietClean_NoStdoutExitZero(t *testing.T) { + tmpDir := t.TempDir() + genFixtureManifest(t, tmpDir) + // Run both commands with the working directory at tmpDir so the generator's + // relative default output paths (orchestrate, promote, hotfix, rollback, the + // composite action) and verify's planned paths resolve to the same files. + manifestRel := filepath.Join(".github", "manifest.yaml") + + if _, stderr, err := runCLIIn(tmpDir, "generate-workflow", "--config", manifestRel, "--force"); err != nil { + t.Fatalf("seed generate failed: %v\nstderr: %s", err, stderr) + } + + stdout, stderr, err := runCLIIn(tmpDir, "verify", "--config", manifestRel, "--quiet") + if err != nil { + t.Fatalf("verify --quiet on clean tree should exit 0: %v\nstderr: %s", err, stderr) + } + // Quiet suppresses the "verify: N files, no drift" body on a clean tree. + if stdout != "" { + t.Errorf("expected no stdout in quiet clean mode, got: %q", stdout) + } +} + +func TestVerifyCommand_QuietDrift_NoReportBodyExitNonZero(t *testing.T) { + tmpDir := t.TempDir() + manifest := genFixtureManifest(t, tmpDir) + + // No workflows generated, so every planned file is missing => drift. + stdout, stderr, err := runCLI("verify", "--config", manifest, "--quiet") + if err == nil { + t.Fatal("verify --quiet with drift should exit non-zero") + } + if stdout != "" { + t.Errorf("expected no stdout in quiet drift mode, got: %q", stdout) + } + // Quiet suppresses verify's per-file drift report and the drift summary + // line. The process still surfaces the wrapped error from main, but none of + // the report body should appear. + if contains(stderr, "(missing)") { + t.Errorf("expected per-file drift report suppressed in quiet mode, got: %q", stderr) + } + if contains(stderr, "file(s) drifted") { + t.Errorf("expected drift summary suppressed in quiet mode, got: %q", stderr) + } +} + +// keysOf returns the top-level keys of a decoded JSON object, for diagnostics. +func keysOf(m map[string]interface{}) []string { + ks := make([]string, 0, len(m)) + for k := range m { + ks = append(ks, k) + } + return ks +} + func contains(s, sub string) bool { return len(s) >= len(sub) && (s == sub || len(sub) == 0 || func() bool { diff --git a/internal/initcmd/command_test.go b/internal/initcmd/command_test.go index 4c92a5de..263f462c 100644 --- a/internal/initcmd/command_test.go +++ b/internal/initcmd/command_test.go @@ -215,6 +215,114 @@ func TestRun_CLIVersionOverride(t *testing.T) { assert.Contains(t, string(got), "v9.9.9") } +// TestRun_AllTopologiesOrderedManifestEnvs drives the cobra command end-to-end +// for every built-in topology and asserts the manifest that lands on disk +// carries exactly the preset's ordered environment list (release stage last). +// This exercises cli.init.topology and cli.init.envs ordering through the real +// command and the real parser, for all four presets, not just the default. +func TestRun_AllTopologiesOrderedManifestEnvs(t *testing.T) { + cases := []struct { + topology string + want []string + }{ + {"no-env", nil}, + {"two-env", []string{"dev", "prod"}}, + {"three-env", []string{"dev", "staging", "prod"}}, + {"four-env", []string{"dev", "test", "uat", "prod"}}, + } + for _, tc := range cases { + t.Run(tc.topology, func(t *testing.T) { + dir := t.TempDir() + _, err := runInit(t, "--topology", tc.topology, "--name", "svc", "--dir", dir) + require.NoError(t, err) + + parsed, err := config.ParseManifestFile(manifestPathFor(dir), config.DefaultManifestKey) + require.NoError(t, err) + require.NotNil(t, parsed.Config) + + if len(tc.want) == 0 { + assert.Empty(t, parsed.Config.Environments, "release-only manifest omits environments") + assert.Empty(t, parsed.Config.Deploys, "release-only manifest has no deploys") + } else { + // Exact ordered equality: order is load-bearing because the last + // environment is the release stage. + assert.Equal(t, tc.want, parsed.Config.Environments) + } + + // The manifest that landed on disk must survive the real generator. + parsedAsMap := map[string]string{} + b, readErr := os.ReadFile(manifestPathFor(dir)) + require.NoError(t, readErr) + parsedAsMap[config.DefaultManifestFile] = string(b) + for _, rel := range []string{".github/workflows/build.yaml", ".github/workflows/deploy.yaml"} { + wb, statErr := os.ReadFile(filepath.Join(dir, rel)) + if statErr == nil { + parsedAsMap[rel] = string(wb) + } + } + require.NoError(t, scaffold.SelfCheck(parsedAsMap)) + }) + } +} + +// TestRun_DirLandsFilesInTargetDir asserts cli.init.dir: a non-current target +// directory receives the scaffold, and the current working directory is left +// untouched. +func TestRun_DirLandsFilesInTargetDir(t *testing.T) { + parent := t.TempDir() + target := filepath.Join(parent, "nested", "service") + require.NoError(t, os.MkdirAll(target, 0o755)) + + _, err := runInit(t, "--topology", "two-env", "--name", "svc", "--dir", target) + require.NoError(t, err) + + // Files land under the target directory. + for _, rel := range []string{ + config.DefaultManifestFile, + ".github/workflows/build.yaml", + ".github/workflows/deploy.yaml", + } { + _, statErr := os.Stat(filepath.Join(target, rel)) + require.NoError(t, statErr, "expected %s under target dir", rel) + } + + // The parent (outside the target) is not polluted with a manifest. + _, statErr := os.Stat(filepath.Join(parent, config.DefaultManifestFile)) + assert.True(t, os.IsNotExist(statErr), "no manifest should be written outside the target dir") +} + +// TestRun_NameWovenIntoStubs asserts cli.init.name: the project name reaches the +// rendered callback stubs on disk, and defaults to the target directory base +// name when --name is omitted. +func TestRun_NameWovenIntoStubs(t *testing.T) { + t.Run("explicit", func(t *testing.T) { + dir := t.TempDir() + _, err := runInit(t, "--topology", "two-env", "--name", "payments-api", "--dir", dir) + require.NoError(t, err) + + build, readErr := os.ReadFile(filepath.Join(dir, ".github/workflows/build.yaml")) + require.NoError(t, readErr) + assert.Contains(t, string(build), "Build payments-api") + + deploy, readErr := os.ReadFile(filepath.Join(dir, ".github/workflows/deploy.yaml")) + require.NoError(t, readErr) + assert.Contains(t, string(deploy), "Deploy payments-api") + }) + + t.Run("defaults-to-dir-base", func(t *testing.T) { + parent := t.TempDir() + dir := filepath.Join(parent, "billing-svc") + require.NoError(t, os.MkdirAll(dir, 0o755)) + + _, err := runInit(t, "--topology", "two-env", "--dir", dir) + require.NoError(t, err) + + build, readErr := os.ReadFile(filepath.Join(dir, ".github/workflows/build.yaml")) + require.NoError(t, readErr) + assert.Contains(t, string(build), "Build billing-svc") + }) +} + func TestRun_ScaffoldFailureWritesNothing(t *testing.T) { dir := t.TempDir() // An environment name containing a dot is not job-ID-safe; the scaffold