462 lines
15 KiB
Go
462 lines
15 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 TestCheckStopsBeforeProductAndBuildCallbacksWhenGoFormatFails(t *testing.T) {
|
|
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
|
|
writeTestFile(t, repository, "unformatted.go", "package candidate\nfunc Value( ) int{return 1}\n")
|
|
gitRun(t, repository, "add", "unformatted.go")
|
|
gitRun(t, repository, "commit", "-m", "add unformatted Go source")
|
|
productCalls := 0
|
|
buildCalls := 0
|
|
_, err := Check(context.Background(), Request{
|
|
RepositoryDir: repository,
|
|
Product: "Example Product",
|
|
Version: testVersion,
|
|
Validation: validation.Settings{GoFormat: true},
|
|
AdditionalChecks: func(context.Context, Candidate) error {
|
|
productCalls++
|
|
return nil
|
|
},
|
|
AfterValidate: func(context.Context, Candidate) error {
|
|
buildCalls++
|
|
return nil
|
|
},
|
|
})
|
|
var failure *validation.Failure
|
|
if !errors.As(err, &failure) || failure.Check != validation.GoFormat {
|
|
t.Fatalf("Check error = %v, want go-format failure", err)
|
|
}
|
|
if productCalls != 0 || buildCalls != 0 {
|
|
t.Fatalf("product callback calls = %d, build callback calls = %d; want neither after formatting failure", productCalls, buildCalls)
|
|
}
|
|
}
|
|
|
|
func TestCheckReportsCommittedWorkspaceFilesWhenGoWorkspaceIsDisabled(t *testing.T) {
|
|
t.Setenv("GOWORK", "off")
|
|
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
|
|
writeTestFile(t, repository, "go.work", "go 1.25.0\n")
|
|
writeTestFile(t, repository, "nested/go.work.sum", "workspace-sum\n")
|
|
if err := os.MkdirAll(filepath.Join(repository, "linked"), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink("../go.work", filepath.Join(repository, "linked", "go.work")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
gitRun(t, repository, "add", "go.work", "nested/go.work.sum", "linked/go.work")
|
|
gitRun(t, repository, "commit", "-m", "add workspace files")
|
|
_, err := Check(context.Background(), Request{
|
|
RepositoryDir: repository,
|
|
Product: "Example Product",
|
|
Version: testVersion,
|
|
Validation: validation.Settings{ModuleDistribution: validation.ModuleDistributionSettings{
|
|
WorkspaceFiles: true,
|
|
}},
|
|
})
|
|
var failure *validation.Failure
|
|
if !errors.As(err, &failure) || failure.Check != validation.WorkspaceFiles {
|
|
t.Fatalf("Check error = %v, want workspace-files failure", err)
|
|
}
|
|
want := map[string]bool{
|
|
"go.work": false,
|
|
"linked/go.work": false,
|
|
"nested/go.work.sum": false,
|
|
}
|
|
if len(failure.Findings) != len(want) {
|
|
t.Fatalf("workspace findings = %#v, want %d paths", failure.Findings, len(want))
|
|
}
|
|
for _, finding := range failure.Findings {
|
|
if _, ok := want[finding.Path]; !ok {
|
|
t.Errorf("unexpected workspace path %q", finding.Path)
|
|
}
|
|
want[finding.Path] = true
|
|
}
|
|
for path, found := range want {
|
|
if !found {
|
|
t.Errorf("workspace path %q was not reported", path)
|
|
}
|
|
}
|
|
}
|
|
|
|
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))
|
|
}
|