From 8002b6e0e33331d370a3fa275d095a906e468e10 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Wed, 15 Jul 2026 09:43:23 -0400 Subject: [PATCH] test(version,changelog): assert concrete command output in RunE tests Signed-off-by: Joshua Temple (cherry picked from commit 9c5edaf52db858558ae07644f78633000e3d9699) --- internal/changelog/command_test.go | 159 ++++++++++++++++++++++++----- internal/version/command_test.go | 148 ++++++++++++++++++++++----- 2 files changed, 251 insertions(+), 56 deletions(-) diff --git a/internal/changelog/command_test.go b/internal/changelog/command_test.go index 1121347..12e3cea 100644 --- a/internal/changelog/command_test.go +++ b/internal/changelog/command_test.go @@ -2,9 +2,16 @@ package changelog import ( "bytes" + "encoding/json" + "io" + "os" + "os/exec" + "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestChangelogNewCommand_Structure(t *testing.T) { @@ -20,50 +27,146 @@ func TestChangelogNewCommand_Structure(t *testing.T) { assert.NotNil(t, cmd.Flags().Lookup("contributors")) } -func TestChangelogNewCommand_RunE_EmptySHARange(t *testing.T) { - // HEAD..HEAD yields zero commits without error; exercises the RunE closure - // body and runGenerateChangelog through the JSON output path. +// changelogRepo builds a hermetic git repo with a chore base commit, a feat +// touching src/, and a fix touching docs/, changes into it, and returns the +// base commit SHA. +func changelogRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Chdir(dir) + gitCL(t, "init", "-b", "main") + gitCL(t, "config", "user.email", "dev@example.com") + gitCL(t, "config", "user.name", "Dev Example") + gitCL(t, "config", "commit.gpgsign", "false") + + commitFileCL(t, "README.md", "seed", "chore: seed") + base := gitOutCL(t, "rev-parse", "HEAD") + commitFileCL(t, filepath.Join("src", "parser.go"), "package src", "feat: add parser") + commitFileCL(t, filepath.Join("docs", "guide.md"), "guide", "fix: correct guide typo") + return base +} + +func gitCL(t *testing.T, args ...string) { + t.Helper() + out, err := exec.Command("git", args...).CombinedOutput() + require.NoError(t, err, "git %v: %s", args, out) +} + +func gitOutCL(t *testing.T, args ...string) string { + t.Helper() + out, err := exec.Command("git", args...).Output() + require.NoError(t, err, "git %v", args) + return strings.TrimSpace(string(out)) +} + +func commitFileCL(t *testing.T, path, content, message string) { + t.Helper() + if d := filepath.Dir(path); d != "." { + require.NoError(t, os.MkdirAll(d, 0o750)) + } + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + gitCL(t, "add", path) + gitCL(t, "commit", "-m", message) +} + +// executeChangelogCapture runs generate-changelog with args and returns what +// it wrote to os.Stdout (the command prints there directly, not to the cobra +// writer) decoded as a ChangelogResult. +func executeChangelogCapture(t *testing.T, args []string) ChangelogResult { + t.Helper() + orig := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stdout = w + defer func() { os.Stdout = orig }() + cmd := NewCommand() - var out bytes.Buffer - cmd.SetOut(&out) - cmd.SetErr(&out) - cmd.SetArgs([]string{ + var cmdOut bytes.Buffer + cmd.SetOut(&cmdOut) + cmd.SetErr(&cmdOut) + cmd.SetArgs(args) + runErr := cmd.Execute() + + require.NoError(t, w.Close()) + data, readErr := io.ReadAll(r) + os.Stdout = orig + require.NoError(t, readErr) + require.NoError(t, runErr) + + var result ChangelogResult + require.NoError(t, json.Unmarshal(data, &result), "stdout is not a ChangelogResult: %s", data) + return result +} + +// TestChangelogNewCommand_RunE_EmptyRangeEmitsEmptyResult pins the empty-range +// contract: HEAD..HEAD succeeds and emits an empty changelog with every +// category flag false, rather than erroring or fabricating content. +func TestChangelogNewCommand_RunE_EmptyRangeEmitsEmptyResult(t *testing.T) { + changelogRepo(t) + + result := executeChangelogCapture(t, []string{ "--base-sha", "HEAD", "--head-sha", "HEAD", "--repo", "owner/repo", }) - // The empty commit range produces valid JSON output; swallow any git failure. - _ = cmd.Execute() + assert.Equal(t, ChangelogResult{}, result) } -func TestChangelogNewCommand_RunE_WithExcludePaths(t *testing.T) { - // Exercises the excludePaths splitting branch in the RunE closure. - cmd := NewCommand() - var out bytes.Buffer - cmd.SetOut(&out) - cmd.SetErr(&out) - cmd.SetArgs([]string{ - "--base-sha", "HEAD", +// TestChangelogNewCommand_RunE_ExcludePathsDropOnlyMatchingCommits proves the +// exclude-paths flag filters commits by pathspec: the docs-only fix disappears +// while the src feat survives. The unfiltered control run shows both, so the +// assertion fails if exclusion stops working (or over-filters). The exclude +// list carries surrounding whitespace to pin the documented trimming. +func TestChangelogNewCommand_RunE_ExcludePathsDropOnlyMatchingCommits(t *testing.T) { + base := changelogRepo(t) + + unfiltered := executeChangelogCapture(t, []string{ + "--base-sha", base, "--head-sha", "HEAD", "--repo", "owner/repo", - "--exclude-paths", "docs/, internal/old/", }) - _ = cmd.Execute() + assert.True(t, unfiltered.HasFeatures) + assert.True(t, unfiltered.HasFixes) + assert.False(t, unfiltered.HasBreaking) + assert.Contains(t, unfiltered.Changelog, "add parser") + assert.Contains(t, unfiltered.Changelog, "correct guide typo") + + filtered := executeChangelogCapture(t, []string{ + "--base-sha", base, + "--head-sha", "HEAD", + "--repo", "owner/repo", + "--exclude-paths", "docs/ , internal/old/", + }) + assert.True(t, filtered.HasFeatures) + assert.False(t, filtered.HasFixes) + assert.Contains(t, filtered.Changelog, "add parser") + assert.NotContains(t, filtered.Changelog, "correct guide typo") } -func TestChangelogNewCommand_RunE_WithContributors(t *testing.T) { - // Exercises the contributors branch in runGenerateChangelog. - cmd := NewCommand() - var out bytes.Buffer - cmd.SetOut(&out) - cmd.SetErr(&out) - cmd.SetArgs([]string{ - "--base-sha", "HEAD", +// TestChangelogNewCommand_RunE_ContributorsAttributeUsernames proves the +// contributors flag flows a looked-up GitHub username into the rendered +// changelog lines. The gh CLI is stubbed with a fixed GraphQL response so the +// lookup is hermetic: the repo's single author resolves to octocat, and both +// the feat and fix lines must carry the attribution. +func TestChangelogNewCommand_RunE_ContributorsAttributeUsernames(t *testing.T) { + base := changelogRepo(t) + + stubDir := t.TempDir() + stub := "#!/bin/sh\n" + + `echo '{"data":{"repository":{"c0":{"author":{"user":{"login":"octocat"}}}}}}'` + "\n" + require.NoError(t, os.WriteFile(filepath.Join(stubDir, "gh"), []byte(stub), 0o755)) //nolint:gosec // test stub must be executable + t.Setenv("PATH", stubDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + result := executeChangelogCapture(t, []string{ + "--base-sha", base, "--head-sha", "HEAD", "--repo", "owner/repo", "--contributors", }) - _ = cmd.Execute() + assert.Contains(t, result.Changelog, "add parser") + assert.Contains(t, result.Changelog, "correct guide typo") + assert.Equal(t, 2, strings.Count(result.Changelog, "(@octocat)"), + "both commit lines should carry the stubbed username attribution") } func TestChangelogNewCommand_MissingRequiredFlags(t *testing.T) { diff --git a/internal/version/command_test.go b/internal/version/command_test.go index 5899ec6..bbf0935 100644 --- a/internal/version/command_test.go +++ b/internal/version/command_test.go @@ -2,8 +2,12 @@ package version import ( "bytes" + "encoding/json" + "io" "os" + "os/exec" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -83,54 +87,142 @@ func TestVersionNewCommand_RunE_EnvironmentNotFound(t *testing.T) { assert.Contains(t, err.Error(), "environment") } -func TestVersionNewCommand_RunE_ValidConfigTextOutput(t *testing.T) { - configPath := minimalVersionConfig(t, []string{"dev", "test"}) +// versionStateRepo builds a hermetic git repo (a chore base commit, then a +// feat commit at HEAD), changes into it, and writes a manifest whose recorded +// state has dev at v1.2.0-rc.1 and test at v1.1.0 anchored to the base commit. +// Returns the manifest path. +func versionStateRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Chdir(dir) + runGitV(t, "init", "-b", "main") + runGitV(t, "config", "user.email", "test@example.com") + runGitV(t, "config", "user.name", "Test User") + runGitV(t, "config", "commit.gpgsign", "false") + + writeCommitV(t, "README.md", "base", "chore: seed") + baseSHA := gitOutV(t, "rev-parse", "HEAD") + writeCommitV(t, "login.go", "package main", "feat: add login flow") + + configPath := filepath.Join(dir, "manifest.yaml") + manifest := "ci:\n" + + " config:\n" + + " trunk_branch: main\n" + + " environments:\n" + + " - dev\n" + + " - test\n" + + " state:\n" + + " dev:\n" + + " version: v1.2.0-rc.1\n" + + " test:\n" + + " version: v1.1.0\n" + + " sha: " + baseSHA + "\n" + require.NoError(t, os.WriteFile(configPath, []byte(manifest), 0o600)) + return configPath +} + +func gitOutV(t *testing.T, args ...string) string { + t.Helper() + out, err := exec.Command("git", args...).Output() + require.NoError(t, err, "git %v", args) + return strings.TrimSpace(string(out)) +} + +// executeVersionCapture runs next-version with args and returns what it wrote +// to os.Stdout (the command prints there directly, not to the cobra writer). +func executeVersionCapture(t *testing.T, args []string) (string, error) { + t.Helper() + orig := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stdout = w + defer func() { os.Stdout = orig }() cmd := NewCommand() - var out bytes.Buffer - cmd.SetOut(&out) - cmd.SetErr(&out) - // HEAD~1..HEAD gets exactly one real commit; exercises the commit-parsing loop. - cmd.SetArgs([]string{ + var cmdOut bytes.Buffer + cmd.SetOut(&cmdOut) + cmd.SetErr(&cmdOut) + cmd.SetArgs(args) + runErr := cmd.Execute() + + require.NoError(t, w.Close()) + data, readErr := io.ReadAll(r) + os.Stdout = orig + require.NoError(t, readErr) + return string(data), runErr +} + +// TestVersionNewCommand_RunE_TextOutput_IncrementsRCOnSameBase pins the text +// path end to end: the feat commit in HEAD~1..HEAD bumps test's v1.1.0 to base +// v1.2.0, and dev's recorded v1.2.0-rc.1 on that same base yields rc.2. +func TestVersionNewCommand_RunE_TextOutput_IncrementsRCOnSameBase(t *testing.T) { + configPath := versionStateRepo(t) + + out, err := executeVersionCapture(t, []string{ "--environment", "dev", "--config", configPath, "--base-sha", "HEAD~1", "--head-sha", "HEAD", }) - // May succeed or fail depending on git availability; exercise the code path. - _ = cmd.Execute() + require.NoError(t, err) + assert.Equal(t, "v1.2.0-rc.2\n", out) } -func TestVersionNewCommand_RunE_ValidConfigJSONOutput(t *testing.T) { - configPath := minimalVersionConfig(t, []string{"dev", "test"}) +// TestVersionNewCommand_RunE_JSONOutput_FullShape pins every field of the JSON +// payload. --base-sha is omitted so the range defaults to the next env's +// recorded SHA (the base commit), which must yield the same single feat commit. +func TestVersionNewCommand_RunE_JSONOutput_FullShape(t *testing.T) { + configPath := versionStateRepo(t) - cmd := NewCommand() - var out bytes.Buffer - cmd.SetOut(&out) - cmd.SetErr(&out) - cmd.SetArgs([]string{ + out, err := executeVersionCapture(t, []string{ "--environment", "dev", "--config", configPath, - "--base-sha", "HEAD~1", - "--head-sha", "HEAD", "--json", }) - _ = cmd.Execute() + require.NoError(t, err) + + var got map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(out), &got)) + assert.Equal(t, map[string]interface{}{ + "version": "v1.2.0-rc.2", + "base_version": "v1.2.0", + "current_dev": "v1.2.0-rc.1", + "next_env": "v1.1.0", + "bump_type": "minor", + "commit_count": float64(1), + "release_candidate": float64(2), + }, got) } -func TestVersionNewCommand_RunE_EmptyRange(t *testing.T) { - configPath := minimalVersionConfig(t, []string{"dev", "test"}) +// TestVersionNewCommand_RunE_NoState_StartsAtFirstRC pins the fallback path: +// with no recorded state and no --base-sha the range starts at the initial +// commit, the fix commit bumps v0.0.0 to a patch, the v0.1.0 minimum floor +// applies, and the empty dev version starts the counter at rc.0. +func TestVersionNewCommand_RunE_NoState_StartsAtFirstRC(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + runGitV(t, "init", "-b", "main") + runGitV(t, "config", "user.email", "test@example.com") + runGitV(t, "config", "user.name", "Test User") + runGitV(t, "config", "commit.gpgsign", "false") + writeCommitV(t, "README.md", "base", "chore: seed") + writeCommitV(t, "main.go", "package main", "fix: correct rounding") - cmd := NewCommand() - var out bytes.Buffer - cmd.SetOut(&out) - cmd.SetErr(&out) - // HEAD..HEAD yields zero commits; exercises the no-baseSHA fallback branch. - cmd.SetArgs([]string{ + configPath := filepath.Join(dir, "manifest.yaml") + manifest := "ci:\n" + + " config:\n" + + " trunk_branch: main\n" + + " environments:\n" + + " - dev\n" + + " - test\n" + require.NoError(t, os.WriteFile(configPath, []byte(manifest), 0o600)) + + out, err := executeVersionCapture(t, []string{ "--environment", "dev", "--config", configPath, }) - _ = cmd.Execute() + require.NoError(t, err) + assert.Equal(t, "v0.1.0-rc.0\n", out) } func TestVersionNewCommand_RunE_NoConfigFlag(t *testing.T) {