Files
ReleaseKit/internal/candidate/check_test.go

386 lines
12 KiB
Go

package candidate
import (
"bytes"
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation"
)
const testVersion = "v1.2.3"
func TestValidStableVersion(t *testing.T) {
for _, version := range []string{
"v0.0.0",
"v1.2.3",
"v123456789012345678901234567890.0.9",
} {
if !validStableVersion(version) {
t.Errorf("validStableVersion(%q) = false", version)
}
}
for _, version := range []string{
"",
"1.2.3",
"V1.2.3",
"v01.2.3",
"v1.02.3",
"v1.2.03",
"v1.2",
"v1.2.3-alpha",
"v1..3",
"v+1.2.3",
} {
if validStableVersion(version) {
t.Errorf("validStableVersion(%q) = true", version)
}
}
}
func TestCheckCapturesCandidateAndCallsAdditionalChecksOnce(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
calls := 0
var seen Candidate
candidate, err := Check(context.Background(), Request{
RepositoryDir: repository,
Product: "Example Product",
Version: testVersion,
AdditionalChecks: func(_ context.Context, captured Candidate) error {
calls++
seen = captured
return nil
},
})
if err != nil {
t.Fatal(err)
}
commit := gitOutputForTest(t, repository, "rev-parse", "HEAD")
if calls != 1 {
t.Fatalf("validation callback called %d times, want 1", calls)
}
if seen != candidate {
t.Fatalf("callback candidate = %#v, returned candidate = %#v", seen, candidate)
}
if candidate.RepositoryDir != repository || candidate.Commit != commit || candidate.Version != testVersion {
t.Fatalf("Check() candidate = %#v", candidate)
}
if tags := gitOutputForTest(t, repository, "tag", "--list"); tags != "" {
t.Fatalf("Check created local tags: %q", tags)
}
if refs := gitOutputForTest(t, repository, "for-each-ref", "--format=%(refname)", "refs/remotes"); refs != "" {
t.Fatalf("Check created remote-tracking refs: %q", refs)
}
}
func TestCheckRunsDefaultOfflineGoValidation(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
var output bytes.Buffer
_, err := Check(context.Background(), Request{
RepositoryDir: repository,
Product: "Example Product",
Version: testVersion,
Output: &output,
})
if err != nil {
t.Fatalf("Check() with default validation: %v\n%s", err, output.String())
}
}
func TestDefaultValidationDoesNotInheritGOFLAGSRunFilter(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
writeTestFile(t, repository, "failure_test.go", "package candidate\nimport \"testing\"\nfunc TestExpectedFailure(t *testing.T) { t.Fatal(\"expected fixture failure\") }\n")
gitRun(t, repository, "add", "failure_test.go")
gitRun(t, repository, "commit", "-m", "add failing validation fixture")
for _, source := range []string{"process environment", "Go environment file"} {
t.Run(source, func(t *testing.T) {
unsetCandidateEnv(t, "GOFLAGS", "GOOS", "GOARCH")
goEnv := filepath.Join(t.TempDir(), "go-env")
if source == "Go environment file" {
if err := os.WriteFile(goEnv, []byte("GOFLAGS=-run=^$\nGOOS=windows\nGOARCH=arm64\n"), 0o600); err != nil {
t.Fatal(err)
}
} else {
if err := os.WriteFile(goEnv, nil, 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("GOFLAGS", "-run=^$")
t.Setenv("GOOS", "windows")
t.Setenv("GOARCH", "arm64")
}
t.Setenv("GOENV", goEnv)
var output bytes.Buffer
_, err := Check(context.Background(), Request{
RepositoryDir: repository,
Product: "Example Product",
Version: testVersion,
Output: &output,
})
if err == nil || !strings.Contains(output.String(), "TestExpectedFailure") {
t.Fatalf("Check() error = %v, output = %q; wanted the failing standard test to run", err, output.String())
}
var failure *validation.Failure
if !errors.As(err, &failure) || failure.Check != validation.GoTest {
t.Fatalf("Check() error = %v; want a go-test validation failure", err)
}
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("Check() error = %v; want the Go process exit error preserved", err)
}
})
}
}
func unsetCandidateEnv(t *testing.T, keys ...string) {
t.Helper()
for _, key := range keys {
value, exists := os.LookupEnv(key)
if err := os.Unsetenv(key); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if exists {
_ = os.Setenv(key, value)
} else {
_ = os.Unsetenv(key)
}
})
}
}
func TestCheckRejectsInvalidVersionsBeforeGitAccess(t *testing.T) {
for _, version := range []string{"v01.2.3", "v1.2.3-alpha", "1.2.3"} {
t.Run(version, func(t *testing.T) {
_, err := Check(context.Background(), Request{Version: version})
if !errors.Is(err, ErrInvalidVersion) {
t.Fatalf("Check() error = %v, want ErrInvalidVersion", err)
}
})
}
}
func TestCheckRequiresMatchingCommittedNote(t *testing.T) {
tests := []struct {
name string
note string
}{
{name: "missing note"},
{name: "wrong product", note: "# Other Product v1.2.3\n"},
{name: "wrong version", note: "# Example Product v1.2.4\n"},
{name: "embedded product text", note: "# Example Products v1.2.3\n"},
{name: "not a level one heading", note: "## Example Product v1.2.3\n"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
repository := newRepository(t, test.note)
called := false
_, err := Check(context.Background(), Request{
RepositoryDir: repository,
Product: "Example Product",
Version: testVersion,
AdditionalChecks: func(context.Context, Candidate) error {
called = true
return nil
},
})
if err == nil {
t.Fatal("Check() succeeded with an invalid committed note")
}
if called {
t.Fatal("validation ran before the committed note passed")
}
})
}
}
func TestCheckRejectsDirtyTrackedAndUntrackedState(t *testing.T) {
for _, test := range []struct {
name string
path string
value string
}{
{name: "tracked", path: "tracked.txt", value: "changed\n"},
{name: "untracked", path: "new file.txt", value: "untracked\n"},
} {
t.Run(test.name, func(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n")
if err := os.WriteFile(filepath.Join(repository, test.path), []byte(test.value), 0o600); err != nil {
t.Fatal(err)
}
_, err := Check(context.Background(), validRequest(repository))
if err == nil || !strings.Contains(err.Error(), "must be clean") {
t.Fatalf("Check() error = %v, want dirty state error", err)
}
})
}
}
func TestCheckRequiresMainBranchAndUnusedTag(t *testing.T) {
t.Run("detached HEAD", func(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n")
gitRun(t, repository, "checkout", "--detach", "HEAD")
_, err := Check(context.Background(), validRequest(repository))
if err == nil || !strings.Contains(err.Error(), "must be on main") {
t.Fatalf("Check() error = %v, want branch error", err)
}
})
t.Run("existing local tag", func(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n")
gitRun(t, repository, "tag", testVersion)
_, err := Check(context.Background(), validRequest(repository))
if err == nil || !strings.Contains(err.Error(), "already exists") {
t.Fatalf("Check() error = %v, want existing-tag error", err)
}
})
}
func TestCheckStopsOnAdditionalCheckFailure(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n")
want := errors.New("selected check failed")
calls := 0
_, err := Check(context.Background(), Request{
RepositoryDir: repository,
Product: "Example Product",
Version: testVersion,
AdditionalChecks: func(context.Context, Candidate) error {
calls++
return want
},
})
if !errors.Is(err, want) {
t.Fatalf("Check() error = %v, want wrapped callback error", err)
}
if calls != 1 {
t.Fatalf("validation callback called %d times, want 1", calls)
}
}
func TestCheckRechecksHeadAndWorktreeAfterCallback(t *testing.T) {
tests := []struct {
name string
validate func(*testing.T, string)
want string
}{
{
name: "HEAD changes",
validate: func(t *testing.T, repository string) {
t.Helper()
if err := os.WriteFile(filepath.Join(repository, "later.txt"), []byte("later\n"), 0o600); err != nil {
t.Fatal(err)
}
gitRun(t, repository, "add", "later.txt")
gitRun(t, repository, "-c", "commit.gpgsign=false", "commit", "-m", "advance HEAD")
},
want: "HEAD changed from",
},
{
name: "worktree changes",
validate: func(t *testing.T, repository string) {
t.Helper()
if err := os.WriteFile(filepath.Join(repository, "later.txt"), []byte("later\n"), 0o600); err != nil {
t.Fatal(err)
}
},
want: "worktree or index changed",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n")
_, err := Check(context.Background(), Request{
RepositoryDir: repository,
Product: "Example Product",
Version: testVersion,
AdditionalChecks: func(context.Context, Candidate) error {
test.validate(t, repository)
return nil
},
})
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("Check() error = %v, want text %q", err, test.want)
}
})
}
}
func TestCheckRechecksLocalTagAfterCallback(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n")
_, err := Check(context.Background(), Request{
RepositoryDir: repository,
Product: "Example Product",
Version: testVersion,
AdditionalChecks: func(context.Context, Candidate) error {
gitRun(t, repository, "tag", testVersion)
return nil
},
})
if err == nil || !strings.Contains(err.Error(), "appeared during validation") {
t.Fatalf("Check() error = %v, want tag recheck error", err)
}
}
func validRequest(repository string) Request {
return Request{RepositoryDir: repository, Product: "Example Product", Version: testVersion, AdditionalChecks: func(context.Context, Candidate) error { return nil }}
}
func newRepository(t *testing.T, note string) string {
t.Helper()
repository := filepath.Join(t.TempDir(), "repository with spaces")
if err := os.Mkdir(repository, 0o700); err != nil {
t.Fatal(err)
}
gitRun(t, repository, "init", "--initial-branch=main")
gitRun(t, repository, "config", "user.name", "ReleaseKit Test")
gitRun(t, repository, "config", "user.email", "releasekit@example.test")
gitRun(t, repository, "config", "commit.gpgsign", "false")
writeTestFile(t, repository, "go.mod", "module example.com/candidate\n\ngo 1.25.0\n")
writeTestFile(t, repository, "candidate_test.go", "package candidate\nimport \"testing\"\nfunc TestFixture(t *testing.T) {}\n")
writeTestFile(t, repository, "tracked.txt", "tracked\n")
if note != "" {
writeTestFile(t, repository, "docs/releases/"+testVersion+".md", note)
}
gitRun(t, repository, "add", "--all")
gitRun(t, repository, "commit", "-m", "candidate")
return repository
}
func writeTestFile(t *testing.T, repository, name, contents string) {
t.Helper()
path := filepath.Join(repository, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}
func gitRun(t *testing.T, repository string, args ...string) {
t.Helper()
commandArgs := append([]string{"git"}, args...)
command := exec.Command(commandArgs[0], commandArgs[1:]...)
command.Dir = repository
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("%v: %v\n%s", commandArgs, err, output)
}
}
func gitOutputForTest(t *testing.T, repository string, args ...string) string {
t.Helper()
commandArgs := append([]string{"git"}, args...)
command := exec.Command(commandArgs[0], commandArgs[1:]...)
command.Dir = repository
output, err := command.Output()
if err != nil {
t.Fatalf("%v: %v", commandArgs, err)
}
return strings.TrimSpace(string(output))
}