// 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 @v // 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 @v (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) } }