golang.org/x/exp@v0.0.0-20240506185415-9bf2ced13842/cmd/gorelease/gorelease_test.go (about)

     1  // Copyright 2019 The Go Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  package main
     6  
     7  import (
     8  	"bytes"
     9  	"context"
    10  	"flag"
    11  	"fmt"
    12  	"os"
    13  	"os/exec"
    14  	"path/filepath"
    15  	"strconv"
    16  	"strings"
    17  	"sync"
    18  	"testing"
    19  
    20  	"golang.org/x/mod/module"
    21  	"golang.org/x/tools/txtar"
    22  )
    23  
    24  var (
    25  	testwork     = flag.Bool("testwork", false, "preserve work directory")
    26  	updateGolden = flag.Bool("u", false, "update expected text in test files instead of failing")
    27  )
    28  
    29  var hasGitCache struct {
    30  	once  sync.Once
    31  	found bool
    32  }
    33  
    34  // hasGit reports whether the git executable exists on the PATH.
    35  func hasGit() bool {
    36  	hasGitCache.once.Do(func() {
    37  		if _, err := exec.LookPath("git"); err != nil {
    38  			return
    39  		}
    40  		hasGitCache.found = true
    41  	})
    42  	return hasGitCache.found
    43  }
    44  
    45  // prepareProxy creates a proxy dir and returns an associated ctx.
    46  //
    47  // proxyVersions must be a map of module version to true. If proxyVersions is
    48  // empty, all modules in mod/ will be included in the proxy list. If proxy
    49  // versions is non-empty, only those modules in mod/ that match an entry in
    50  // proxyVersions will be included.
    51  //
    52  // ctx must be used in runRelease.
    53  // cleanup must be called when the relevant tests are finished.
    54  func prepareProxy(proxyVersions map[module.Version]bool, tests []*test) (ctx context.Context, cleanup func(), _ error) {
    55  	env := append(os.Environ(), "GO111MODULE=on", "GOSUMDB=off")
    56  
    57  	proxyDir, proxyURL, err := buildProxyDir(proxyVersions, tests)
    58  	if err != nil {
    59  		return nil, nil, fmt.Errorf("error building proxy dir: %v", err)
    60  	}
    61  	env = append(env, fmt.Sprintf("GOPROXY=%s", proxyURL))
    62  
    63  	cacheDir, err := os.MkdirTemp("", "gorelease_test-gocache")
    64  	if err != nil {
    65  		return nil, nil, err
    66  	}
    67  	env = append(env, fmt.Sprintf("GOPATH=%s", cacheDir))
    68  
    69  	return context.WithValue(context.Background(), "env", env), func() {
    70  		if *testwork {
    71  			fmt.Fprintf(os.Stderr, "test cache dir: %s\n", cacheDir)
    72  			fmt.Fprintf(os.Stderr, "test proxy dir: %s\ntest proxy URL: %s\n", proxyDir, proxyURL)
    73  		} else {
    74  			cmd := exec.Command("go", "clean", "-modcache")
    75  			cmd.Env = env
    76  			if err := cmd.Run(); err != nil {
    77  				fmt.Fprintln(os.Stderr, fmt.Errorf("error running go clean: %v", err))
    78  			}
    79  
    80  			if err := os.RemoveAll(cacheDir); err != nil {
    81  				fmt.Fprintln(os.Stderr, fmt.Errorf("error removing cache dir %s: %v", cacheDir, err))
    82  			}
    83  			if err := os.RemoveAll(proxyDir); err != nil {
    84  				fmt.Fprintln(os.Stderr, fmt.Errorf("error removing proxy dir %s: %v", proxyDir, err))
    85  			}
    86  		}
    87  	}, nil
    88  }
    89  
    90  // test describes an individual test case, written as a .test file in the
    91  // testdata directory.
    92  //
    93  // Each test is a txtar archive (see golang.org/x/tools/txtar). The comment
    94  // section (before the first file) contains a sequence of key=value pairs
    95  // (one per line) that configure the test.
    96  //
    97  // Most tests include a file named "want". The output of gorelease is compared
    98  // against this file. If the -u flag is set, this file is replaced with the
    99  // actual output of gorelease, and the test is written back to disk. This is
   100  // useful for updating tests after cosmetic changes.
   101  type test struct {
   102  	txtar.Archive
   103  
   104  	// testPath is the name of the .test file describing the test.
   105  	testPath string
   106  
   107  	// modPath (set with mod=...) is the path of the module being tested. Used
   108  	// to retrieve files from the test proxy.
   109  	modPath string
   110  
   111  	// version (set with version=...) is the name of a version to check out
   112  	// from the test proxy into the working directory. Some tests use this
   113  	// instead of specifying files they need in the txtar archive.
   114  	version string
   115  
   116  	// baseVersion (set with base=...) is the value of the -base flag to pass
   117  	// to gorelease.
   118  	baseVersion string
   119  
   120  	// releaseVersion (set with release=...) is the value of the -version flag
   121  	// to pass to gorelease.
   122  	releaseVersion string
   123  
   124  	// dir (set with dir=...) is the directory where gorelease should be invoked.
   125  	// If unset, gorelease is invoked in the directory where the txtar archive
   126  	// is unpacked. This is useful for invoking gorelease in a subdirectory.
   127  	dir string
   128  
   129  	// wantError (set with error=...) is true if the test expects a hard error
   130  	// (returned by runRelease).
   131  	wantError bool
   132  
   133  	// wantSuccess (set with success=...) is true if the test expects a report
   134  	// to be returned without errors or diagnostics. True by default.
   135  	wantSuccess bool
   136  
   137  	// skip (set with skip=...) is non-empty if the test should be skipped.
   138  	skip string
   139  
   140  	// want is set to the contents of the file named "want" in the txtar archive.
   141  	want []byte
   142  
   143  	// proxyVersions is used to set the exact contents of the GOPROXY.
   144  	//
   145  	// If empty, all of testadata/mod/ will be included in the proxy.
   146  	// If it is not empty, each entry must be of the form <modpath>@v<version>
   147  	// and exist in testdata/mod/.
   148  	proxyVersions map[module.Version]bool
   149  
   150  	// vcs is used to set the VCS that the root of the test should
   151  	// emulate. Allowed values are git, and hg.
   152  	vcs string
   153  }
   154  
   155  // readTest reads and parses a .test file with the given name.
   156  func readTest(testPath string) (*test, error) {
   157  	arc, err := txtar.ParseFile(testPath)
   158  	if err != nil {
   159  		return nil, err
   160  	}
   161  	t := &test{
   162  		Archive:     *arc,
   163  		testPath:    testPath,
   164  		wantSuccess: true,
   165  	}
   166  
   167  	for n, line := range bytes.Split(t.Comment, []byte("\n")) {
   168  		lineNum := n + 1
   169  		if i := bytes.IndexByte(line, '#'); i >= 0 {
   170  			line = line[:i]
   171  		}
   172  		line = bytes.TrimSpace(line)
   173  		if len(line) == 0 {
   174  			continue
   175  		}
   176  
   177  		var key, value string
   178  		if i := bytes.IndexByte(line, '='); i < 0 {
   179  			return nil, fmt.Errorf("%s:%d: no '=' found", testPath, lineNum)
   180  		} else {
   181  			key = strings.TrimSpace(string(line[:i]))
   182  			value = strings.TrimSpace(string(line[i+1:]))
   183  		}
   184  		switch key {
   185  		case "mod":
   186  			t.modPath = value
   187  		case "version":
   188  			t.version = value
   189  		case "base":
   190  			t.baseVersion = value
   191  		case "release":
   192  			t.releaseVersion = value
   193  		case "dir":
   194  			t.dir = value
   195  		case "skip":
   196  			t.skip = value
   197  		case "success":
   198  			t.wantSuccess, err = strconv.ParseBool(value)
   199  			if err != nil {
   200  				return nil, fmt.Errorf("%s:%d: %v", testPath, lineNum, err)
   201  			}
   202  		case "error":
   203  			t.wantError, err = strconv.ParseBool(value)
   204  			if err != nil {
   205  				return nil, fmt.Errorf("%s:%d: %v", testPath, lineNum, err)
   206  			}
   207  		case "proxyVersions":
   208  			if len(value) == 0 {
   209  				break
   210  			}
   211  			proxyVersions := make(map[module.Version]bool)
   212  			parts := strings.Split(value, ",")
   213  			for _, modpathWithVersion := range parts {
   214  				vParts := strings.Split(modpathWithVersion, "@")
   215  				if len(vParts) != 2 {
   216  					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)
   217  				}
   218  				modPath, version := vParts[0], vParts[1]
   219  				mv := module.Version{
   220  					Path:    modPath,
   221  					Version: version,
   222  				}
   223  				proxyVersions[mv] = true
   224  			}
   225  			t.proxyVersions = proxyVersions
   226  		case "vcs":
   227  			t.vcs = value
   228  		default:
   229  			return nil, fmt.Errorf("%s:%d: unknown key: %q", testPath, lineNum, key)
   230  		}
   231  	}
   232  	if t.modPath == "" && (t.version != "" || (t.baseVersion != "" && t.baseVersion != "none")) {
   233  		return nil, fmt.Errorf("%s: version or base was set but mod was not set", testPath)
   234  	}
   235  
   236  	haveFiles := false
   237  	for _, f := range t.Files {
   238  		if f.Name == "want" {
   239  			t.want = bytes.TrimSpace(f.Data)
   240  			continue
   241  		}
   242  		haveFiles = true
   243  	}
   244  
   245  	if haveFiles && t.version != "" {
   246  		return nil, fmt.Errorf("%s: version is set but files are present", testPath)
   247  	}
   248  
   249  	return t, nil
   250  }
   251  
   252  // updateTest replaces the contents of the file named "want" within a test's
   253  // txtar archive, then formats and writes the test file.
   254  func updateTest(t *test, want []byte) error {
   255  	var wantFile *txtar.File
   256  	for i := range t.Files {
   257  		if t.Files[i].Name == "want" {
   258  			wantFile = &t.Files[i]
   259  			break
   260  		}
   261  	}
   262  	if wantFile == nil {
   263  		t.Files = append(t.Files, txtar.File{Name: "want"})
   264  		wantFile = &t.Files[len(t.Files)-1]
   265  	}
   266  
   267  	wantFile.Data = want
   268  	data := txtar.Format(&t.Archive)
   269  	return os.WriteFile(t.testPath, data, 0666)
   270  }
   271  
   272  func TestRelease(t *testing.T) {
   273  	testPaths, err := filepath.Glob(filepath.FromSlash("testdata/*/*.test"))
   274  	if err != nil {
   275  		t.Fatal(err)
   276  	}
   277  	if len(testPaths) == 0 {
   278  		t.Fatal("no .test files found in testdata directory")
   279  	}
   280  
   281  	var tests []*test
   282  	for _, testPath := range testPaths {
   283  		test, err := readTest(testPath)
   284  		if err != nil {
   285  			t.Fatal(err)
   286  		}
   287  		tests = append(tests, test)
   288  	}
   289  
   290  	defaultContext, cleanup, err := prepareProxy(nil, tests)
   291  	if err != nil {
   292  		t.Fatalf("preparing test proxy: %v", err)
   293  	}
   294  	t.Cleanup(cleanup)
   295  
   296  	for _, test := range tests {
   297  		testName := strings.TrimSuffix(strings.TrimPrefix(filepath.ToSlash(test.testPath), "testdata/"), ".test")
   298  		t.Run(testName, testRelease(defaultContext, tests, test))
   299  	}
   300  }
   301  
   302  func TestRelease_gitRepo_uncommittedChanges(t *testing.T) {
   303  	ctx := context.Background()
   304  	buf := &bytes.Buffer{}
   305  	releaseDir, err := os.MkdirTemp("", "")
   306  	if err != nil {
   307  		t.Fatal(err)
   308  	}
   309  
   310  	goModInit(t, releaseDir)
   311  	gitInit(t, releaseDir)
   312  
   313  	// Create an uncommitted change.
   314  	bContents := `package b
   315  const B = "b"`
   316  	if err := os.WriteFile(filepath.Join(releaseDir, "b.go"), []byte(bContents), 0644); err != nil {
   317  		t.Fatal(err)
   318  	}
   319  
   320  	success, err := runRelease(ctx, buf, releaseDir, nil)
   321  	if got, want := err.Error(), fmt.Sprintf("repo %s has uncommitted changes", releaseDir); got != want {
   322  		t.Errorf("runRelease:\ngot error:\n%q\nwant error\n%q", got, want)
   323  	}
   324  	if success {
   325  		t.Errorf("runRelease: expected failure, got success")
   326  	}
   327  }
   328  
   329  func testRelease(ctx context.Context, tests []*test, test *test) func(t *testing.T) {
   330  	return func(t *testing.T) {
   331  		if test.skip != "" {
   332  			t.Skip(test.skip)
   333  		}
   334  
   335  		t.Parallel()
   336  
   337  		if len(test.proxyVersions) > 0 {
   338  			var cleanup func()
   339  			var err error
   340  			ctx, cleanup, err = prepareProxy(test.proxyVersions, tests)
   341  			if err != nil {
   342  				t.Fatalf("preparing test proxy: %v", err)
   343  			}
   344  			t.Cleanup(cleanup)
   345  		}
   346  
   347  		// Extract the files in the release version. They may be part of the
   348  		// test archive or in testdata/mod.
   349  		testDir, err := os.MkdirTemp("", "")
   350  		if err != nil {
   351  			t.Fatal(err)
   352  		}
   353  		if *testwork {
   354  			fmt.Fprintf(os.Stderr, "test dir: %s\n", testDir)
   355  		} else {
   356  			t.Cleanup(func() {
   357  				os.RemoveAll(testDir)
   358  			})
   359  		}
   360  
   361  		var arc *txtar.Archive
   362  		if test.version != "" {
   363  			arcBase := fmt.Sprintf("%s_%s.txt", strings.ReplaceAll(test.modPath, "/", "_"), test.version)
   364  			arcPath := filepath.Join("testdata/mod", arcBase)
   365  			var err error
   366  			arc, err = txtar.ParseFile(arcPath)
   367  			if err != nil {
   368  				t.Fatal(err)
   369  			}
   370  		} else {
   371  			arc = &test.Archive
   372  		}
   373  		if err := extractTxtar(testDir, arc); err != nil {
   374  			t.Fatal(err)
   375  		}
   376  
   377  		switch test.vcs {
   378  		case "git":
   379  			// Convert testDir to a git repository with a single commit, to
   380  			// simulate a real user's module-in-a-git-repo.
   381  			gitInit(t, testDir)
   382  		case "hg":
   383  			// Convert testDir to a mercurial repository to simulate a real
   384  			// user's module-in-a-hg-repo.
   385  			hgInit(t, testDir)
   386  		case "":
   387  			// No VCS.
   388  		default:
   389  			t.Fatalf("unknown vcs %q", test.vcs)
   390  		}
   391  
   392  		// Generate the report and compare it against the expected text.
   393  		var args []string
   394  		if test.baseVersion != "" {
   395  			args = append(args, "-base="+test.baseVersion)
   396  		}
   397  		if test.releaseVersion != "" {
   398  			args = append(args, "-version="+test.releaseVersion)
   399  		}
   400  		buf := &bytes.Buffer{}
   401  		releaseDir := filepath.Join(testDir, test.dir)
   402  		success, err := runRelease(ctx, buf, releaseDir, args)
   403  		if err != nil {
   404  			if !test.wantError {
   405  				t.Fatalf("unexpected error: %v", err)
   406  			}
   407  			if errMsg := []byte(err.Error()); !bytes.Equal(errMsg, bytes.TrimSpace(test.want)) {
   408  				if *updateGolden {
   409  					if err := updateTest(test, errMsg); err != nil {
   410  						t.Fatal(err)
   411  					}
   412  				} else {
   413  					t.Fatalf("got error: %s; want error: %s", errMsg, test.want)
   414  				}
   415  			}
   416  			return
   417  		}
   418  		if test.wantError {
   419  			t.Fatalf("got success; want error %s", test.want)
   420  		}
   421  
   422  		got := bytes.TrimSpace(buf.Bytes())
   423  		if filepath.Separator != '/' {
   424  			got = bytes.ReplaceAll(got, []byte{filepath.Separator}, []byte{'/'})
   425  		}
   426  		if !bytes.Equal(got, test.want) {
   427  			if *updateGolden {
   428  				if err := updateTest(test, got); err != nil {
   429  					t.Fatal(err)
   430  				}
   431  			} else {
   432  				t.Fatalf("got:\n%s\n\nwant:\n%s", got, test.want)
   433  			}
   434  		}
   435  		if success != test.wantSuccess {
   436  			t.Fatalf("got success: %v; want success %v", success, test.wantSuccess)
   437  		}
   438  	}
   439  }
   440  
   441  // hgInit initialises a directory as a mercurial repo.
   442  func hgInit(t *testing.T, dir string) {
   443  	t.Helper()
   444  
   445  	if err := os.Mkdir(filepath.Join(dir, ".hg"), 0777); err != nil {
   446  		t.Fatal(err)
   447  	}
   448  
   449  	if err := os.WriteFile(filepath.Join(dir, ".hg", "branch"), []byte("default"), 0777); err != nil {
   450  		t.Fatal(err)
   451  	}
   452  }
   453  
   454  // gitInit initialises a directory as a git repo, and adds a simple commit.
   455  func gitInit(t *testing.T, dir string) {
   456  	t.Helper()
   457  
   458  	if !hasGit() {
   459  		t.Skip("PATH does not contain git")
   460  	}
   461  
   462  	stdout := &bytes.Buffer{}
   463  	stderr := &bytes.Buffer{}
   464  
   465  	for _, args := range [][]string{
   466  		{"git", "init"},
   467  		{"git", "config", "user.name", "Gopher"},
   468  		{"git", "config", "user.email", "gopher@golang.org"},
   469  		{"git", "checkout", "-b", "test"},
   470  		{"git", "add", "-A"},
   471  		{"git", "commit", "-m", "test"},
   472  	} {
   473  		cmd := exec.Command(args[0], args[1:]...)
   474  		cmd.Dir = dir
   475  		cmd.Stdout = stdout
   476  		cmd.Stderr = stderr
   477  		if err := cmd.Run(); err != nil {
   478  			cmdArgs := strings.Join(args, " ")
   479  			t.Fatalf("%s\n%s\nerror running %q on dir %s: %v", stdout.String(), stderr.String(), cmdArgs, dir, err)
   480  		}
   481  	}
   482  }
   483  
   484  // goModInit runs `go mod init` in the given directory.
   485  func goModInit(t *testing.T, dir string) {
   486  	t.Helper()
   487  
   488  	aContents := `package a
   489  const A = "a"`
   490  	if err := os.WriteFile(filepath.Join(dir, "a.go"), []byte(aContents), 0644); err != nil {
   491  		t.Fatal(err)
   492  	}
   493  
   494  	stdout := &bytes.Buffer{}
   495  	stderr := &bytes.Buffer{}
   496  	cmd := exec.Command("go", "mod", "init", "example.com/uncommitted")
   497  	cmd.Stdout = stdout
   498  	cmd.Stderr = stderr
   499  	cmd.Dir = dir
   500  	if err := cmd.Run(); err != nil {
   501  		t.Fatalf("error running `go mod init`: %s, %v", stderr.String(), err)
   502  	}
   503  }