blob: 47d08923e4eca903f1a1ebdaffed3885d3afc65c [file] [log] [blame]
// Copyright 2019 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"bytes"
"context"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"golang.org/x/mod/module"
"golang.org/x/tools/txtar"
)
var (
testwork = flag.Bool("testwork", false, "preserve work directory")
updateGolden = flag.Bool("u", false, "update expected text in test files instead of failing")
)
var hasGitCache struct {
once sync.Once
found bool
}
// hasGit reports whether the git executable exists on the PATH.
func hasGit() bool {
hasGitCache.once.Do(func() {
if _, err := exec.LookPath("git"); err != nil {
return
}
hasGitCache.found = true
})
return hasGitCache.found
}
// prepareProxy creates a proxy dir and returns an associated ctx.
//
// proxyVersions must be a map of module version to true. If proxyVersions is
// empty, all modules in mod/ will be included in the proxy list. If proxy
// versions is non-empty, only those modules in mod/ that match an entry in
// proxyVersions will be included.
//
// ctx must be used in runRelease.
// cleanup must be called when the relevant tests are finished.
func prepareProxy(proxyVersions map[module.Version]bool, tests []*test) (ctx context.Context, cleanup func(), _ error) {
env := append(os.Environ(), "GO111MODULE=on", "GOSUMDB=off")
proxyDir, proxyURL, err := buildProxyDir(proxyVersions, tests)
if err != nil {
return nil, nil, fmt.Errorf("error building proxy dir: %v", err)
}
env = append(env, fmt.Sprintf("GOPROXY=%s", proxyURL))
cacheDir, err := os.MkdirTemp("", "gorelease_test-gocache")
if err != nil {
return nil, nil, err
}
env = append(env, fmt.Sprintf("GOPATH=%s", cacheDir))
return context.WithValue(context.Background(), "env", env), func() {
if *testwork {
fmt.Fprintf(os.Stderr, "test cache dir: %s\n", cacheDir)
fmt.Fprintf(os.Stderr, "test proxy dir: %s\ntest proxy URL: %s\n", proxyDir, proxyURL)
} else {
cmd := exec.Command("go", "clean", "-modcache")
cmd.Env = env
if err := cmd.Run(); err != nil {
fmt.Fprintln(os.Stderr, fmt.Errorf("error running go clean: %v", err))
}
if err := os.RemoveAll(cacheDir); err != nil {
fmt.Fprintln(os.Stderr, fmt.Errorf("error removing cache dir %s: %v", cacheDir, err))
}
if err := os.RemoveAll(proxyDir); err != nil {
fmt.Fprintln(os.Stderr, fmt.Errorf("error removing proxy dir %s: %v", proxyDir, err))
}
}
}, nil
}
// test describes an individual test case, written as a .test file in the
// testdata directory.
//
// Each test is a txtar archive (see golang.org/x/tools/txtar). The comment
// section (before the first file) contains a sequence of key=value pairs
// (one per line) that configure the test.
//
// Most tests include a file named "want". The output of gorelease is compared
// against this file. If the -u flag is set, this file is replaced with the
// actual output of gorelease, and the test is written back to disk. This is
// useful for updating tests after cosmetic changes.
type test struct {
txtar.Archive
// testPath is the name of the .test file describing the test.
testPath string
// modPath (set with mod=...) is the path of the module being tested. Used
// to retrieve files from the test proxy.
modPath string
// version (set with version=...) is the name of a version to check out
// from the test proxy into the working directory. Some tests use this
// instead of specifying files they need in the txtar archive.
version string
// baseVersion (set with base=...) is the value of the -base flag to pass
// to gorelease.
baseVersion string
// releaseVersion (set with release=...) is the value of the -version flag
// to pass to gorelease.
releaseVersion string
// dir (set with dir=...) is the directory where gorelease should be invoked.
// If unset, gorelease is invoked in the directory where the txtar archive
// is unpacked. This is useful for invoking gorelease in a subdirectory.
dir string
// wantError (set with error=...) is true if the test expects a hard error
// (returned by runRelease).
wantError bool
// wantSuccess (set with success=...) is true if the test expects a report
// to be returned without errors or diagnostics. True by default.
wantSuccess bool
// skip (set with skip=...) is non-empty if the test should be skipped.
skip string
// want is set to the contents of the file named "want" in the txtar archive.
want []byte
// proxyVersions is used to set the exact contents of the GOPROXY.
//
// If empty, all of testadata/mod/ will be included in the proxy.
// If it is not empty, each entry must be of the form <modpath>@v<version>
// and exist in testdata/mod/.
proxyVersions map[module.Version]bool
// vcs is used to set the VCS that the root of the test should
// emulate. Allowed values are git, and hg.
vcs string
}
// readTest reads and parses a .test file with the given name.
func readTest(testPath string) (*test, error) {
arc, err := txtar.ParseFile(testPath)
if err != nil {
return nil, err
}
t := &test{
Archive: *arc,
testPath: testPath,
wantSuccess: true,
}
for n, line := range bytes.Split(t.Comment, []byte("\n")) {
lineNum := n + 1
if i := bytes.IndexByte(line, '#'); i >= 0 {
line = line[:i]
}
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
var key, value string
if i := bytes.IndexByte(line, '='); i < 0 {
return nil, fmt.Errorf("%s:%d: no '=' found", testPath, lineNum)
} else {
key = strings.TrimSpace(string(line[:i]))
value = strings.TrimSpace(string(line[i+1:]))
}
switch key {
case "mod":
t.modPath = value
case "version":
t.version = value
case "base":
t.baseVersion = value
case "release":
t.releaseVersion = value
case "dir":
t.dir = value
case "skip":
t.skip = value
case "success":
t.wantSuccess, err = strconv.ParseBool(value)
if err != nil {
return nil, fmt.Errorf("%s:%d: %v", testPath, lineNum, err)
}
case "error":
t.wantError, err = strconv.ParseBool(value)
if err != nil {
return nil, fmt.Errorf("%s:%d: %v", testPath, lineNum, err)
}
case "proxyVersions":
if len(value) == 0 {
break
}
proxyVersions := make(map[module.Version]bool)
parts := strings.Split(value, ",")
for _, modpathWithVersion := range parts {
vParts := strings.Split(modpathWithVersion, "@")
if len(vParts) != 2 {
return nil, fmt.Errorf("proxyVersions entry %s is invalid: it should be of the format <modpath>@v<semver> (ex: github.com/foo/bar@v1.2.3)", modpathWithVersion)
}
modPath, version := vParts[0], vParts[1]
mv := module.Version{
Path: modPath,
Version: version,
}
proxyVersions[mv] = true
}
t.proxyVersions = proxyVersions
case "vcs":
t.vcs = value
default:
return nil, fmt.Errorf("%s:%d: unknown key: %q", testPath, lineNum, key)
}
}
if t.modPath == "" && (t.version != "" || (t.baseVersion != "" && t.baseVersion != "none")) {
return nil, fmt.Errorf("%s: version or base was set but mod was not set", testPath)
}
haveFiles := false
for _, f := range t.Files {
if f.Name == "want" {
t.want = bytes.TrimSpace(f.Data)
continue
}
haveFiles = true
}
if haveFiles && t.version != "" {
return nil, fmt.Errorf("%s: version is set but files are present", testPath)
}
return t, nil
}
// updateTest replaces the contents of the file named "want" within a test's
// txtar archive, then formats and writes the test file.
func updateTest(t *test, want []byte) error {
var wantFile *txtar.File
for i := range t.Files {
if t.Files[i].Name == "want" {
wantFile = &t.Files[i]
break
}
}
if wantFile == nil {
t.Files = append(t.Files, txtar.File{Name: "want"})
wantFile = &t.Files[len(t.Files)-1]
}
wantFile.Data = want
data := txtar.Format(&t.Archive)
return os.WriteFile(t.testPath, data, 0666)
}
func TestRelease(t *testing.T) {
testPaths, err := filepath.Glob(filepath.FromSlash("testdata/*/*.test"))
if err != nil {
t.Fatal(err)
}
if len(testPaths) == 0 {
t.Fatal("no .test files found in testdata directory")
}
var tests []*test
for _, testPath := range testPaths {
test, err := readTest(testPath)
if err != nil {
t.Fatal(err)
}
tests = append(tests, test)
}
defaultContext, cleanup, err := prepareProxy(nil, tests)
if err != nil {
t.Fatalf("preparing test proxy: %v", err)
}
t.Cleanup(cleanup)
for _, test := range tests {
testName := strings.TrimSuffix(strings.TrimPrefix(filepath.ToSlash(test.testPath), "testdata/"), ".test")
t.Run(testName, testRelease(defaultContext, tests, test))
}
}
func TestRelease_gitRepo_uncommittedChanges(t *testing.T) {
ctx := context.Background()
buf := &bytes.Buffer{}
releaseDir, err := os.MkdirTemp("", "")
if err != nil {
t.Fatal(err)
}
goModInit(t, releaseDir)
gitInit(t, releaseDir)
// Create an uncommitted change.
bContents := `package b
const B = "b"`
if err := os.WriteFile(filepath.Join(releaseDir, "b.go"), []byte(bContents), 0644); err != nil {
t.Fatal(err)
}
success, err := runRelease(ctx, buf, releaseDir, nil)
if got, want := err.Error(), fmt.Sprintf("repo %s has uncommitted changes", releaseDir); got != want {
t.Errorf("runRelease:\ngot error:\n%q\nwant error\n%q", got, want)
}
if success {
t.Errorf("runRelease: expected failure, got success")
}
}
func testRelease(ctx context.Context, tests []*test, test *test) func(t *testing.T) {
return func(t *testing.T) {
if test.skip != "" {
t.Skip(test.skip)
}
t.Parallel()
if len(test.proxyVersions) > 0 {
var cleanup func()
var err error
ctx, cleanup, err = prepareProxy(test.proxyVersions, tests)
if err != nil {
t.Fatalf("preparing test proxy: %v", err)
}
t.Cleanup(cleanup)
}
// Extract the files in the release version. They may be part of the
// test archive or in testdata/mod.
testDir, err := os.MkdirTemp("", "")
if err != nil {
t.Fatal(err)
}
if *testwork {
fmt.Fprintf(os.Stderr, "test dir: %s\n", testDir)
} else {
t.Cleanup(func() {
os.RemoveAll(testDir)
})
}
var arc *txtar.Archive
if test.version != "" {
arcBase := fmt.Sprintf("%s_%s.txt", strings.ReplaceAll(test.modPath, "/", "_"), test.version)
arcPath := filepath.Join("testdata/mod", arcBase)
var err error
arc, err = txtar.ParseFile(arcPath)
if err != nil {
t.Fatal(err)
}
} else {
arc = &test.Archive
}
if err := extractTxtar(testDir, arc); err != nil {
t.Fatal(err)
}
switch test.vcs {
case "git":
// Convert testDir to a git repository with a single commit, to
// simulate a real user's module-in-a-git-repo.
gitInit(t, testDir)
case "hg":
// Convert testDir to a mercurial repository to simulate a real
// user's module-in-a-hg-repo.
hgInit(t, testDir)
case "":
// No VCS.
default:
t.Fatalf("unknown vcs %q", test.vcs)
}
// Generate the report and compare it against the expected text.
var args []string
if test.baseVersion != "" {
args = append(args, "-base="+test.baseVersion)
}
if test.releaseVersion != "" {
args = append(args, "-version="+test.releaseVersion)
}
buf := &bytes.Buffer{}
releaseDir := filepath.Join(testDir, test.dir)
success, err := runRelease(ctx, buf, releaseDir, args)
if err != nil {
if !test.wantError {
t.Fatalf("unexpected error: %v", err)
}
if errMsg := []byte(err.Error()); !bytes.Equal(errMsg, bytes.TrimSpace(test.want)) {
if *updateGolden {
if err := updateTest(test, errMsg); err != nil {
t.Fatal(err)
}
} else {
t.Fatalf("got error: %s; want error: %s", errMsg, test.want)
}
}
return
}
if test.wantError {
t.Fatalf("got success; want error %s", test.want)
}
got := bytes.TrimSpace(buf.Bytes())
if filepath.Separator != '/' {
got = bytes.ReplaceAll(got, []byte{filepath.Separator}, []byte{'/'})
}
if !bytes.Equal(got, test.want) {
if *updateGolden {
if err := updateTest(test, got); err != nil {
t.Fatal(err)
}
} else {
t.Fatalf("got:\n%s\n\nwant:\n%s", got, test.want)
}
}
if success != test.wantSuccess {
t.Fatalf("got success: %v; want success %v", success, test.wantSuccess)
}
}
}
// hgInit initialises a directory as a mercurial repo.
func hgInit(t *testing.T, dir string) {
t.Helper()
if err := os.Mkdir(filepath.Join(dir, ".hg"), 0777); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ".hg", "branch"), []byte("default"), 0777); err != nil {
t.Fatal(err)
}
}
// gitInit initialises a directory as a git repo, and adds a simple commit.
func gitInit(t *testing.T, dir string) {
t.Helper()
if !hasGit() {
t.Skip("PATH does not contain git")
}
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
for _, args := range [][]string{
{"git", "init"},
{"git", "config", "user.name", "Gopher"},
{"git", "config", "user.email", "gopher@golang.org"},
{"git", "checkout", "-b", "test"},
{"git", "add", "-A"},
{"git", "commit", "-m", "test"},
} {
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = dir
cmd.Stdout = stdout
cmd.Stderr = stderr
if err := cmd.Run(); err != nil {
cmdArgs := strings.Join(args, " ")
t.Fatalf("%s\n%s\nerror running %q on dir %s: %v", stdout.String(), stderr.String(), cmdArgs, dir, err)
}
}
}
// goModInit runs `go mod init` in the given directory.
func goModInit(t *testing.T, dir string) {
t.Helper()
aContents := `package a
const A = "a"`
if err := os.WriteFile(filepath.Join(dir, "a.go"), []byte(aContents), 0644); err != nil {
t.Fatal(err)
}
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
cmd := exec.Command("go", "mod", "init", "example.com/uncommitted")
cmd.Stdout = stdout
cmd.Stderr = stderr
cmd.Dir = dir
if err := cmd.Run(); err != nil {
t.Fatalf("error running `go mod init`: %s, %v", stderr.String(), err)
}
}