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 }