diff options
Diffstat (limited to '')
-rw-r--r-- | src/cmd/go/script_test.go | 1448 |
1 files changed, 1448 insertions, 0 deletions
diff --git a/src/cmd/go/script_test.go b/src/cmd/go/script_test.go new file mode 100644 index 0000000..dfaa405 --- /dev/null +++ b/src/cmd/go/script_test.go @@ -0,0 +1,1448 @@ +// Copyright 2018 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. + +// Script-driven tests. +// See testdata/script/README for an overview. + +package main_test + +import ( + "bytes" + "context" + "errors" + "fmt" + "go/build" + "internal/testenv" + "io/fs" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + "sync" + "testing" + "time" + + "cmd/go/internal/cfg" + "cmd/go/internal/imports" + "cmd/go/internal/par" + "cmd/go/internal/robustio" + "cmd/go/internal/txtar" + "cmd/go/internal/work" + "cmd/internal/objabi" + "cmd/internal/sys" +) + +// TestScript runs the tests in testdata/script/*.txt. +func TestScript(t *testing.T) { + testenv.MustHaveGoBuild(t) + testenv.SkipIfShortAndSlow(t) + + files, err := filepath.Glob("testdata/script/*.txt") + if err != nil { + t.Fatal(err) + } + for _, file := range files { + file := file + name := strings.TrimSuffix(filepath.Base(file), ".txt") + t.Run(name, func(t *testing.T) { + t.Parallel() + ts := &testScript{t: t, name: name, file: file} + ts.setup() + if !*testWork { + defer removeAll(ts.workdir) + } + ts.run() + }) + } +} + +// A testScript holds execution state for a single test script. +type testScript struct { + t *testing.T + workdir string // temporary work dir ($WORK) + log bytes.Buffer // test execution log (printed at end of test) + mark int // offset of next log truncation + cd string // current directory during test execution; initially $WORK/gopath/src + name string // short name of test ("foo") + file string // full file name ("testdata/script/foo.txt") + lineno int // line number currently executing + line string // line currently executing + env []string // environment list (for os/exec) + envMap map[string]string // environment mapping (matches env) + stdout string // standard output from last 'go' command; for 'stdout' command + stderr string // standard error from last 'go' command; for 'stderr' command + stopped bool // test wants to stop early + start time.Time // time phase started + background []*backgroundCmd // backgrounded 'exec' and 'go' commands +} + +type backgroundCmd struct { + want simpleStatus + args []string + cancel context.CancelFunc + done <-chan struct{} + err error + stdout, stderr strings.Builder +} + +type simpleStatus string + +const ( + success simpleStatus = "" + failure simpleStatus = "!" + successOrFailure simpleStatus = "?" +) + +var extraEnvKeys = []string{ + "SYSTEMROOT", // must be preserved on Windows to find DLLs; golang.org/issue/25210 + "WINDIR", // must be preserved on Windows to be able to run PowerShell command; golang.org/issue/30711 + "LD_LIBRARY_PATH", // must be preserved on Unix systems to find shared libraries + "CC", // don't lose user settings when invoking cgo + "GO_TESTING_GOTOOLS", // for gccgo testing + "GCCGO", // for gccgo testing + "GCCGOTOOLDIR", // for gccgo testing +} + +// setup sets up the test execution temporary directory and environment. +func (ts *testScript) setup() { + StartProxy() + ts.workdir = filepath.Join(testTmpDir, "script-"+ts.name) + ts.check(os.MkdirAll(filepath.Join(ts.workdir, "tmp"), 0777)) + ts.check(os.MkdirAll(filepath.Join(ts.workdir, "gopath/src"), 0777)) + ts.cd = filepath.Join(ts.workdir, "gopath/src") + ts.env = []string{ + "WORK=" + ts.workdir, // must be first for ts.abbrev + "PATH=" + testBin + string(filepath.ListSeparator) + os.Getenv("PATH"), + homeEnvName() + "=/no-home", + "CCACHE_DISABLE=1", // ccache breaks with non-existent HOME + "GOARCH=" + runtime.GOARCH, + "GOCACHE=" + testGOCACHE, + "GODEBUG=" + os.Getenv("GODEBUG"), + "GOEXE=" + cfg.ExeSuffix, + "GOEXPSTRING=" + objabi.Expstring()[2:], + "GOOS=" + runtime.GOOS, + "GOPATH=" + filepath.Join(ts.workdir, "gopath"), + "GOPROXY=" + proxyURL, + "GOPRIVATE=", + "GOROOT=" + testGOROOT, + "GOROOT_FINAL=" + os.Getenv("GOROOT_FINAL"), // causes spurious rebuilds and breaks the "stale" built-in if not propagated + "TESTGO_GOROOT=" + testGOROOT, + "GOSUMDB=" + testSumDBVerifierKey, + "GONOPROXY=", + "GONOSUMDB=", + "GOVCS=*:all", + "PWD=" + ts.cd, + tempEnvName() + "=" + filepath.Join(ts.workdir, "tmp"), + "devnull=" + os.DevNull, + "goversion=" + goVersion(ts), + ":=" + string(os.PathListSeparator), + } + if !testenv.HasExternalNetwork() { + ts.env = append(ts.env, "TESTGONETWORK=panic", "TESTGOVCS=panic") + } + + if runtime.GOOS == "plan9" { + ts.env = append(ts.env, "path="+testBin+string(filepath.ListSeparator)+os.Getenv("path")) + } + + for _, key := range extraEnvKeys { + if val := os.Getenv(key); val != "" { + ts.env = append(ts.env, key+"="+val) + } + } + + ts.envMap = make(map[string]string) + for _, kv := range ts.env { + if i := strings.Index(kv, "="); i >= 0 { + ts.envMap[kv[:i]] = kv[i+1:] + } + } +} + +// goVersion returns the current Go version. +func goVersion(ts *testScript) string { + tags := build.Default.ReleaseTags + version := tags[len(tags)-1] + if !regexp.MustCompile(`^go([1-9][0-9]*)\.(0|[1-9][0-9]*)$`).MatchString(version) { + ts.fatalf("invalid go version %q", version) + } + return version[2:] +} + +var execCache par.Cache + +// run runs the test script. +func (ts *testScript) run() { + // Truncate log at end of last phase marker, + // discarding details of successful phase. + rewind := func() { + if !testing.Verbose() { + ts.log.Truncate(ts.mark) + } + } + + // Insert elapsed time for phase at end of phase marker + markTime := func() { + if ts.mark > 0 && !ts.start.IsZero() { + afterMark := append([]byte{}, ts.log.Bytes()[ts.mark:]...) + ts.log.Truncate(ts.mark - 1) // cut \n and afterMark + fmt.Fprintf(&ts.log, " (%.3fs)\n", time.Since(ts.start).Seconds()) + ts.log.Write(afterMark) + } + ts.start = time.Time{} + } + + defer func() { + // On a normal exit from the test loop, background processes are cleaned up + // before we print PASS. If we return early (e.g., due to a test failure), + // don't print anything about the processes that were still running. + for _, bg := range ts.background { + bg.cancel() + } + for _, bg := range ts.background { + <-bg.done + } + ts.background = nil + + markTime() + // Flush testScript log to testing.T log. + ts.t.Log("\n" + ts.abbrev(ts.log.String())) + }() + + // Unpack archive. + a, err := txtar.ParseFile(ts.file) + ts.check(err) + for _, f := range a.Files { + name := ts.mkabs(ts.expand(f.Name, false)) + ts.check(os.MkdirAll(filepath.Dir(name), 0777)) + ts.check(os.WriteFile(name, f.Data, 0666)) + } + + // With -v or -testwork, start log with full environment. + if *testWork || testing.Verbose() { + // Display environment. + ts.cmdEnv(success, nil) + fmt.Fprintf(&ts.log, "\n") + ts.mark = ts.log.Len() + } + + // Run script. + // See testdata/script/README for documentation of script form. + script := string(a.Comment) +Script: + for script != "" { + // Extract next line. + ts.lineno++ + var line string + if i := strings.Index(script, "\n"); i >= 0 { + line, script = script[:i], script[i+1:] + } else { + line, script = script, "" + } + + // # is a comment indicating the start of new phase. + if strings.HasPrefix(line, "#") { + // If there was a previous phase, it succeeded, + // so rewind the log to delete its details (unless -v is in use). + // If nothing has happened at all since the mark, + // rewinding is a no-op and adding elapsed time + // for doing nothing is meaningless, so don't. + if ts.log.Len() > ts.mark { + rewind() + markTime() + } + // Print phase heading and mark start of phase output. + fmt.Fprintf(&ts.log, "%s\n", line) + ts.mark = ts.log.Len() + ts.start = time.Now() + continue + } + + // Parse input line. Ignore blanks entirely. + parsed := ts.parse(line) + if parsed.name == "" { + if parsed.want != "" || len(parsed.conds) > 0 { + ts.fatalf("missing command") + } + continue + } + + // Echo command to log. + fmt.Fprintf(&ts.log, "> %s\n", line) + + for _, cond := range parsed.conds { + // Known conds are: $GOOS, $GOARCH, runtime.Compiler, and 'short' (for testing.Short). + // + // NOTE: If you make changes here, update testdata/script/README too! + // + ok := false + switch cond.tag { + case runtime.GOOS, runtime.GOARCH, runtime.Compiler: + ok = true + case "short": + ok = testing.Short() + case "cgo": + ok = canCgo + case "msan": + ok = canMSan + case "race": + ok = canRace + case "net": + ok = testenv.HasExternalNetwork() + case "link": + ok = testenv.HasLink() + case "root": + ok = os.Geteuid() == 0 + case "symlink": + ok = testenv.HasSymlink() + case "case-sensitive": + ok = isCaseSensitive(ts.t) + default: + if strings.HasPrefix(cond.tag, "exec:") { + prog := cond.tag[len("exec:"):] + ok = execCache.Do(prog, func() interface{} { + if runtime.GOOS == "plan9" && prog == "git" { + // The Git command is usually not the real Git on Plan 9. + // See https://golang.org/issues/29640. + return false + } + _, err := exec.LookPath(prog) + return err == nil + }).(bool) + break + } + if strings.HasPrefix(cond.tag, "GODEBUG:") { + value := strings.TrimPrefix(cond.tag, "GODEBUG:") + parts := strings.Split(os.Getenv("GODEBUG"), ",") + for _, p := range parts { + if strings.TrimSpace(p) == value { + ok = true + break + } + } + break + } + if strings.HasPrefix(cond.tag, "buildmode:") { + value := strings.TrimPrefix(cond.tag, "buildmode:") + ok = sys.BuildModeSupported(runtime.Compiler, value, runtime.GOOS, runtime.GOARCH) + break + } + if !imports.KnownArch[cond.tag] && !imports.KnownOS[cond.tag] && cond.tag != "gc" && cond.tag != "gccgo" { + ts.fatalf("unknown condition %q", cond.tag) + } + } + if ok != cond.want { + // Don't run rest of line. + continue Script + } + } + + // Run command. + cmd := scriptCmds[parsed.name] + if cmd == nil { + ts.fatalf("unknown command %q", parsed.name) + } + cmd(ts, parsed.want, parsed.args) + + // Command can ask script to stop early. + if ts.stopped { + // Break instead of returning, so that we check the status of any + // background processes and print PASS. + break + } + } + + for _, bg := range ts.background { + bg.cancel() + } + ts.cmdWait(success, nil) + + // Final phase ended. + rewind() + markTime() + if !ts.stopped { + fmt.Fprintf(&ts.log, "PASS\n") + } +} + +var ( + onceCaseSensitive sync.Once + caseSensitive bool +) + +func isCaseSensitive(t *testing.T) bool { + onceCaseSensitive.Do(func() { + tmpdir, err := os.MkdirTemp("", "case-sensitive") + if err != nil { + t.Fatal("failed to create directory to determine case-sensitivity:", err) + } + defer os.RemoveAll(tmpdir) + + fcap := filepath.Join(tmpdir, "FILE") + if err := os.WriteFile(fcap, []byte{}, 0644); err != nil { + t.Fatal("error writing file to determine case-sensitivity:", err) + } + + flow := filepath.Join(tmpdir, "file") + _, err = os.ReadFile(flow) + switch { + case err == nil: + caseSensitive = false + return + case os.IsNotExist(err): + caseSensitive = true + return + default: + t.Fatal("unexpected error reading file when determining case-sensitivity:", err) + } + }) + + return caseSensitive +} + +// scriptCmds are the script command implementations. +// Keep list and the implementations below sorted by name. +// +// NOTE: If you make changes here, update testdata/script/README too! +// +var scriptCmds = map[string]func(*testScript, simpleStatus, []string){ + "addcrlf": (*testScript).cmdAddcrlf, + "cc": (*testScript).cmdCc, + "cd": (*testScript).cmdCd, + "chmod": (*testScript).cmdChmod, + "cmp": (*testScript).cmdCmp, + "cmpenv": (*testScript).cmdCmpenv, + "cp": (*testScript).cmdCp, + "env": (*testScript).cmdEnv, + "exec": (*testScript).cmdExec, + "exists": (*testScript).cmdExists, + "go": (*testScript).cmdGo, + "grep": (*testScript).cmdGrep, + "mkdir": (*testScript).cmdMkdir, + "rm": (*testScript).cmdRm, + "skip": (*testScript).cmdSkip, + "stale": (*testScript).cmdStale, + "stderr": (*testScript).cmdStderr, + "stdout": (*testScript).cmdStdout, + "stop": (*testScript).cmdStop, + "symlink": (*testScript).cmdSymlink, + "wait": (*testScript).cmdWait, +} + +// When expanding shell variables for these commands, we apply regexp quoting to +// expanded strings within the first argument. +var regexpCmd = map[string]bool{ + "grep": true, + "stderr": true, + "stdout": true, +} + +// addcrlf adds CRLF line endings to the named files. +func (ts *testScript) cmdAddcrlf(want simpleStatus, args []string) { + if len(args) == 0 { + ts.fatalf("usage: addcrlf file...") + } + + for _, file := range args { + file = ts.mkabs(file) + data, err := os.ReadFile(file) + ts.check(err) + ts.check(os.WriteFile(file, bytes.ReplaceAll(data, []byte("\n"), []byte("\r\n")), 0666)) + } +} + +// cc runs the C compiler along with platform specific options. +func (ts *testScript) cmdCc(want simpleStatus, args []string) { + if len(args) < 1 || (len(args) == 1 && args[0] == "&") { + ts.fatalf("usage: cc args... [&]") + } + + var b work.Builder + b.Init() + ts.cmdExec(want, append(b.GccCmd(".", ""), args...)) + robustio.RemoveAll(b.WorkDir) +} + +// cd changes to a different directory. +func (ts *testScript) cmdCd(want simpleStatus, args []string) { + if want != success { + ts.fatalf("unsupported: %v cd", want) + } + if len(args) != 1 { + ts.fatalf("usage: cd dir") + } + + dir := args[0] + if !filepath.IsAbs(dir) { + dir = filepath.Join(ts.cd, dir) + } + info, err := os.Stat(dir) + if os.IsNotExist(err) { + ts.fatalf("directory %s does not exist", dir) + } + ts.check(err) + if !info.IsDir() { + ts.fatalf("%s is not a directory", dir) + } + ts.cd = dir + ts.envMap["PWD"] = dir + fmt.Fprintf(&ts.log, "%s\n", ts.cd) +} + +// chmod changes permissions for a file or directory. +func (ts *testScript) cmdChmod(want simpleStatus, args []string) { + if want != success { + ts.fatalf("unsupported: %v chmod", want) + } + if len(args) < 2 { + ts.fatalf("usage: chmod perm paths...") + } + perm, err := strconv.ParseUint(args[0], 0, 32) + if err != nil || perm&uint64(fs.ModePerm) != perm { + ts.fatalf("invalid mode: %s", args[0]) + } + for _, arg := range args[1:] { + path := arg + if !filepath.IsAbs(path) { + path = filepath.Join(ts.cd, arg) + } + err := os.Chmod(path, fs.FileMode(perm)) + ts.check(err) + } +} + +// cmp compares two files. +func (ts *testScript) cmdCmp(want simpleStatus, args []string) { + if want != success { + // It would be strange to say "this file can have any content except this precise byte sequence". + ts.fatalf("unsupported: %v cmp", want) + } + quiet := false + if len(args) > 0 && args[0] == "-q" { + quiet = true + args = args[1:] + } + if len(args) != 2 { + ts.fatalf("usage: cmp file1 file2") + } + ts.doCmdCmp(args, false, quiet) +} + +// cmpenv compares two files with environment variable substitution. +func (ts *testScript) cmdCmpenv(want simpleStatus, args []string) { + if want != success { + ts.fatalf("unsupported: %v cmpenv", want) + } + quiet := false + if len(args) > 0 && args[0] == "-q" { + quiet = true + args = args[1:] + } + if len(args) != 2 { + ts.fatalf("usage: cmpenv file1 file2") + } + ts.doCmdCmp(args, true, quiet) +} + +func (ts *testScript) doCmdCmp(args []string, env, quiet bool) { + name1, name2 := args[0], args[1] + var text1, text2 string + if name1 == "stdout" { + text1 = ts.stdout + } else if name1 == "stderr" { + text1 = ts.stderr + } else { + data, err := os.ReadFile(ts.mkabs(name1)) + ts.check(err) + text1 = string(data) + } + + data, err := os.ReadFile(ts.mkabs(name2)) + ts.check(err) + text2 = string(data) + + if env { + text1 = ts.expand(text1, false) + text2 = ts.expand(text2, false) + } + + if text1 == text2 { + return + } + + if !quiet { + fmt.Fprintf(&ts.log, "[diff -%s +%s]\n%s\n", name1, name2, diff(text1, text2)) + } + ts.fatalf("%s and %s differ", name1, name2) +} + +// cp copies files, maybe eventually directories. +func (ts *testScript) cmdCp(want simpleStatus, args []string) { + if len(args) < 2 { + ts.fatalf("usage: cp src... dst") + } + + dst := ts.mkabs(args[len(args)-1]) + info, err := os.Stat(dst) + dstDir := err == nil && info.IsDir() + if len(args) > 2 && !dstDir { + ts.fatalf("cp: destination %s is not a directory", dst) + } + + for _, arg := range args[:len(args)-1] { + var ( + src string + data []byte + mode fs.FileMode + ) + switch arg { + case "stdout": + src = arg + data = []byte(ts.stdout) + mode = 0666 + case "stderr": + src = arg + data = []byte(ts.stderr) + mode = 0666 + default: + src = ts.mkabs(arg) + info, err := os.Stat(src) + ts.check(err) + mode = info.Mode() & 0777 + data, err = os.ReadFile(src) + ts.check(err) + } + targ := dst + if dstDir { + targ = filepath.Join(dst, filepath.Base(src)) + } + err := os.WriteFile(targ, data, mode) + switch want { + case failure: + if err == nil { + ts.fatalf("unexpected command success") + } + case success: + ts.check(err) + } + } +} + +// env displays or adds to the environment. +func (ts *testScript) cmdEnv(want simpleStatus, args []string) { + if want != success { + ts.fatalf("unsupported: %v env", want) + } + + conv := func(s string) string { return s } + if len(args) > 0 && args[0] == "-r" { + conv = regexp.QuoteMeta + args = args[1:] + } + + var out strings.Builder + if len(args) == 0 { + printed := make(map[string]bool) // env list can have duplicates; only print effective value (from envMap) once + for _, kv := range ts.env { + k := kv[:strings.Index(kv, "=")] + if !printed[k] { + fmt.Fprintf(&out, "%s=%s\n", k, ts.envMap[k]) + } + } + } else { + for _, env := range args { + i := strings.Index(env, "=") + if i < 0 { + // Display value instead of setting it. + fmt.Fprintf(&out, "%s=%s\n", env, ts.envMap[env]) + continue + } + key, val := env[:i], conv(env[i+1:]) + ts.env = append(ts.env, key+"="+val) + ts.envMap[key] = val + } + } + if out.Len() > 0 || len(args) > 0 { + ts.stdout = out.String() + ts.log.WriteString(out.String()) + } +} + +// exec runs the given command. +func (ts *testScript) cmdExec(want simpleStatus, args []string) { + if len(args) < 1 || (len(args) == 1 && args[0] == "&") { + ts.fatalf("usage: exec program [args...] [&]") + } + + background := false + if len(args) > 0 && args[len(args)-1] == "&" { + background = true + args = args[:len(args)-1] + } + + bg, err := ts.startBackground(want, args[0], args[1:]...) + if err != nil { + ts.fatalf("unexpected error starting command: %v", err) + } + if background { + ts.stdout, ts.stderr = "", "" + ts.background = append(ts.background, bg) + return + } + + <-bg.done + ts.stdout = bg.stdout.String() + ts.stderr = bg.stderr.String() + if ts.stdout != "" { + fmt.Fprintf(&ts.log, "[stdout]\n%s", ts.stdout) + } + if ts.stderr != "" { + fmt.Fprintf(&ts.log, "[stderr]\n%s", ts.stderr) + } + if bg.err != nil { + fmt.Fprintf(&ts.log, "[%v]\n", bg.err) + } + ts.checkCmd(bg) +} + +// exists checks that the list of files exists. +func (ts *testScript) cmdExists(want simpleStatus, args []string) { + if want == successOrFailure { + ts.fatalf("unsupported: %v exists", want) + } + var readonly, exec bool +loop: + for len(args) > 0 { + switch args[0] { + case "-readonly": + readonly = true + args = args[1:] + case "-exec": + exec = true + args = args[1:] + default: + break loop + } + } + if len(args) == 0 { + ts.fatalf("usage: exists [-readonly] [-exec] file...") + } + + for _, file := range args { + file = ts.mkabs(file) + info, err := os.Stat(file) + if err == nil && want == failure { + what := "file" + if info.IsDir() { + what = "directory" + } + ts.fatalf("%s %s unexpectedly exists", what, file) + } + if err != nil && want == success { + ts.fatalf("%s does not exist", file) + } + if err == nil && want == success && readonly && info.Mode()&0222 != 0 { + ts.fatalf("%s exists but is writable", file) + } + if err == nil && want == success && exec && runtime.GOOS != "windows" && info.Mode()&0111 == 0 { + ts.fatalf("%s exists but is not executable", file) + } + } +} + +// go runs the go command. +func (ts *testScript) cmdGo(want simpleStatus, args []string) { + ts.cmdExec(want, append([]string{testGo}, args...)) +} + +// mkdir creates directories. +func (ts *testScript) cmdMkdir(want simpleStatus, args []string) { + if want != success { + ts.fatalf("unsupported: %v mkdir", want) + } + if len(args) < 1 { + ts.fatalf("usage: mkdir dir...") + } + for _, arg := range args { + ts.check(os.MkdirAll(ts.mkabs(arg), 0777)) + } +} + +// rm removes files or directories. +func (ts *testScript) cmdRm(want simpleStatus, args []string) { + if want != success { + ts.fatalf("unsupported: %v rm", want) + } + if len(args) < 1 { + ts.fatalf("usage: rm file...") + } + for _, arg := range args { + file := ts.mkabs(arg) + removeAll(file) // does chmod and then attempts rm + ts.check(robustio.RemoveAll(file)) // report error + } +} + +// skip marks the test skipped. +func (ts *testScript) cmdSkip(want simpleStatus, args []string) { + if len(args) > 1 { + ts.fatalf("usage: skip [msg]") + } + if want != success { + ts.fatalf("unsupported: %v skip", want) + } + + // Before we mark the test as skipped, shut down any background processes and + // make sure they have returned the correct status. + for _, bg := range ts.background { + bg.cancel() + } + ts.cmdWait(success, nil) + + if len(args) == 1 { + ts.t.Skip(args[0]) + } + ts.t.Skip() +} + +// stale checks that the named build targets are stale. +func (ts *testScript) cmdStale(want simpleStatus, args []string) { + if len(args) == 0 { + ts.fatalf("usage: stale target...") + } + tmpl := "{{if .Error}}{{.ImportPath}}: {{.Error.Err}}{{else}}" + switch want { + case failure: + tmpl += "{{if .Stale}}{{.ImportPath}} is unexpectedly stale{{end}}" + case success: + tmpl += "{{if not .Stale}}{{.ImportPath}} is unexpectedly NOT stale{{end}}" + default: + ts.fatalf("unsupported: %v stale", want) + } + tmpl += "{{end}}" + goArgs := append([]string{"list", "-e", "-f=" + tmpl}, args...) + stdout, stderr, err := ts.exec(testGo, goArgs...) + if err != nil { + ts.fatalf("go list: %v\n%s%s", err, stdout, stderr) + } + if stdout != "" { + ts.fatalf("%s", stdout) + } +} + +// stdout checks that the last go command standard output matches a regexp. +func (ts *testScript) cmdStdout(want simpleStatus, args []string) { + scriptMatch(ts, want, args, ts.stdout, "stdout") +} + +// stderr checks that the last go command standard output matches a regexp. +func (ts *testScript) cmdStderr(want simpleStatus, args []string) { + scriptMatch(ts, want, args, ts.stderr, "stderr") +} + +// grep checks that file content matches a regexp. +// Like stdout/stderr and unlike Unix grep, it accepts Go regexp syntax. +func (ts *testScript) cmdGrep(want simpleStatus, args []string) { + scriptMatch(ts, want, args, "", "grep") +} + +// scriptMatch implements both stdout and stderr. +func scriptMatch(ts *testScript, want simpleStatus, args []string, text, name string) { + if want == successOrFailure { + ts.fatalf("unsupported: %v %s", want, name) + } + + n := 0 + if len(args) >= 1 && strings.HasPrefix(args[0], "-count=") { + if want == failure { + ts.fatalf("cannot use -count= with negated match") + } + var err error + n, err = strconv.Atoi(args[0][len("-count="):]) + if err != nil { + ts.fatalf("bad -count=: %v", err) + } + if n < 1 { + ts.fatalf("bad -count=: must be at least 1") + } + args = args[1:] + } + quiet := false + if len(args) >= 1 && args[0] == "-q" { + quiet = true + args = args[1:] + } + + extraUsage := "" + wantArgs := 1 + if name == "grep" { + extraUsage = " file" + wantArgs = 2 + } + if len(args) != wantArgs { + ts.fatalf("usage: %s [-count=N] 'pattern'%s", name, extraUsage) + } + + pattern := `(?m)` + args[0] + re, err := regexp.Compile(pattern) + if err != nil { + ts.fatalf("regexp.Compile(%q): %v", pattern, err) + } + + isGrep := name == "grep" + if isGrep { + name = args[1] // for error messages + data, err := os.ReadFile(ts.mkabs(args[1])) + ts.check(err) + text = string(data) + } + + // Matching against workdir would be misleading. + text = strings.ReplaceAll(text, ts.workdir, "$WORK") + + switch want { + case failure: + if re.MatchString(text) { + if isGrep && !quiet { + fmt.Fprintf(&ts.log, "[%s]\n%s\n", name, text) + } + ts.fatalf("unexpected match for %#q found in %s: %s", pattern, name, re.FindString(text)) + } + + case success: + if !re.MatchString(text) { + if isGrep && !quiet { + fmt.Fprintf(&ts.log, "[%s]\n%s\n", name, text) + } + ts.fatalf("no match for %#q found in %s", pattern, name) + } + if n > 0 { + count := len(re.FindAllString(text, -1)) + if count != n { + if isGrep && !quiet { + fmt.Fprintf(&ts.log, "[%s]\n%s\n", name, text) + } + ts.fatalf("have %d matches for %#q, want %d", count, pattern, n) + } + } + } +} + +// stop stops execution of the test (marking it passed). +func (ts *testScript) cmdStop(want simpleStatus, args []string) { + if want != success { + ts.fatalf("unsupported: %v stop", want) + } + if len(args) > 1 { + ts.fatalf("usage: stop [msg]") + } + if len(args) == 1 { + fmt.Fprintf(&ts.log, "stop: %s\n", args[0]) + } else { + fmt.Fprintf(&ts.log, "stop\n") + } + ts.stopped = true +} + +// symlink creates a symbolic link. +func (ts *testScript) cmdSymlink(want simpleStatus, args []string) { + if want != success { + ts.fatalf("unsupported: %v symlink", want) + } + if len(args) != 3 || args[1] != "->" { + ts.fatalf("usage: symlink file -> target") + } + // Note that the link target args[2] is not interpreted with mkabs: + // it will be interpreted relative to the directory file is in. + ts.check(os.Symlink(args[2], ts.mkabs(args[0]))) +} + +// wait waits for background commands to exit, setting stderr and stdout to their result. +func (ts *testScript) cmdWait(want simpleStatus, args []string) { + if want != success { + ts.fatalf("unsupported: %v wait", want) + } + if len(args) > 0 { + ts.fatalf("usage: wait") + } + + var stdouts, stderrs []string + for _, bg := range ts.background { + <-bg.done + + args := append([]string{filepath.Base(bg.args[0])}, bg.args[1:]...) + fmt.Fprintf(&ts.log, "[background] %s: %v\n", strings.Join(args, " "), bg.err) + + cmdStdout := bg.stdout.String() + if cmdStdout != "" { + fmt.Fprintf(&ts.log, "[stdout]\n%s", cmdStdout) + stdouts = append(stdouts, cmdStdout) + } + + cmdStderr := bg.stderr.String() + if cmdStderr != "" { + fmt.Fprintf(&ts.log, "[stderr]\n%s", cmdStderr) + stderrs = append(stderrs, cmdStderr) + } + + ts.checkCmd(bg) + } + + ts.stdout = strings.Join(stdouts, "") + ts.stderr = strings.Join(stderrs, "") + ts.background = nil +} + +// Helpers for command implementations. + +// abbrev abbreviates the actual work directory in the string s to the literal string "$WORK". +func (ts *testScript) abbrev(s string) string { + s = strings.ReplaceAll(s, ts.workdir, "$WORK") + if *testWork { + // Expose actual $WORK value in environment dump on first line of work script, + // so that the user can find out what directory -testwork left behind. + s = "WORK=" + ts.workdir + "\n" + strings.TrimPrefix(s, "WORK=$WORK\n") + } + return s +} + +// check calls ts.fatalf if err != nil. +func (ts *testScript) check(err error) { + if err != nil { + ts.fatalf("%v", err) + } +} + +func (ts *testScript) checkCmd(bg *backgroundCmd) { + select { + case <-bg.done: + default: + panic("checkCmd called when not done") + } + + if bg.err == nil { + if bg.want == failure { + ts.fatalf("unexpected command success") + } + return + } + + if errors.Is(bg.err, context.DeadlineExceeded) { + ts.fatalf("test timed out while running command") + } + + if errors.Is(bg.err, context.Canceled) { + // The process was still running at the end of the test. + // The test must not depend on its exit status. + if bg.want != successOrFailure { + ts.fatalf("unexpected background command remaining at test end") + } + return + } + + if bg.want == success { + ts.fatalf("unexpected command failure") + } +} + +// exec runs the given command line (an actual subprocess, not simulated) +// in ts.cd with environment ts.env and then returns collected standard output and standard error. +func (ts *testScript) exec(command string, args ...string) (stdout, stderr string, err error) { + bg, err := ts.startBackground(success, command, args...) + if err != nil { + return "", "", err + } + <-bg.done + return bg.stdout.String(), bg.stderr.String(), bg.err +} + +// startBackground starts the given command line (an actual subprocess, not simulated) +// in ts.cd with environment ts.env. +func (ts *testScript) startBackground(want simpleStatus, command string, args ...string) (*backgroundCmd, error) { + done := make(chan struct{}) + bg := &backgroundCmd{ + want: want, + args: append([]string{command}, args...), + done: done, + cancel: func() {}, + } + + ctx := context.Background() + gracePeriod := 100 * time.Millisecond + if deadline, ok := ts.t.Deadline(); ok { + timeout := time.Until(deadline) + // If time allows, increase the termination grace period to 5% of the + // remaining time. + if gp := timeout / 20; gp > gracePeriod { + gracePeriod = gp + } + + // Send the first termination signal with two grace periods remaining. + // If it still hasn't finished after the first period has elapsed, + // we'll escalate to os.Kill with a second period remaining until the + // test deadline.. + timeout -= 2 * gracePeriod + + if timeout <= 0 { + // The test has less than the grace period remaining. There is no point in + // even starting the command, because it will be terminated immediately. + // Save the expense of starting it in the first place. + bg.err = context.DeadlineExceeded + close(done) + return bg, nil + } + + ctx, bg.cancel = context.WithTimeout(ctx, timeout) + } + + cmd := exec.Command(command, args...) + cmd.Dir = ts.cd + cmd.Env = append(ts.env, "PWD="+ts.cd) + cmd.Stdout = &bg.stdout + cmd.Stderr = &bg.stderr + if err := cmd.Start(); err != nil { + bg.cancel() + return nil, err + } + + go func() { + bg.err = waitOrStop(ctx, cmd, stopSignal(), gracePeriod) + close(done) + }() + return bg, nil +} + +// stopSignal returns the appropriate signal to use to request that a process +// stop execution. +func stopSignal() os.Signal { + if runtime.GOOS == "windows" { + // Per https://golang.org/pkg/os/#Signal, “Interrupt is not implemented on + // Windows; using it with os.Process.Signal will return an error.” + // Fall back to Kill instead. + return os.Kill + } + return os.Interrupt +} + +// waitOrStop waits for the already-started command cmd by calling its Wait method. +// +// If cmd does not return before ctx is done, waitOrStop sends it the given interrupt signal. +// If killDelay is positive, waitOrStop waits that additional period for Wait to return before sending os.Kill. +// +// This function is copied from the one added to x/playground/internal in +// http://golang.org/cl/228438. +func waitOrStop(ctx context.Context, cmd *exec.Cmd, interrupt os.Signal, killDelay time.Duration) error { + if cmd.Process == nil { + panic("waitOrStop called with a nil cmd.Process — missing Start call?") + } + if interrupt == nil { + panic("waitOrStop requires a non-nil interrupt signal") + } + + errc := make(chan error) + go func() { + select { + case errc <- nil: + return + case <-ctx.Done(): + } + + err := cmd.Process.Signal(interrupt) + if err == nil { + err = ctx.Err() // Report ctx.Err() as the reason we interrupted. + } else if err.Error() == "os: process already finished" { + errc <- nil + return + } + + if killDelay > 0 { + timer := time.NewTimer(killDelay) + select { + // Report ctx.Err() as the reason we interrupted the process... + case errc <- ctx.Err(): + timer.Stop() + return + // ...but after killDelay has elapsed, fall back to a stronger signal. + case <-timer.C: + } + + // Wait still hasn't returned. + // Kill the process harder to make sure that it exits. + // + // Ignore any error: if cmd.Process has already terminated, we still + // want to send ctx.Err() (or the error from the Interrupt call) + // to properly attribute the signal that may have terminated it. + _ = cmd.Process.Kill() + } + + errc <- err + }() + + waitErr := cmd.Wait() + if interruptErr := <-errc; interruptErr != nil { + return interruptErr + } + return waitErr +} + +// expand applies environment variable expansion to the string s. +func (ts *testScript) expand(s string, inRegexp bool) string { + return os.Expand(s, func(key string) string { + e := ts.envMap[key] + if inRegexp { + // Replace workdir with $WORK, since we have done the same substitution in + // the text we're about to compare against. + e = strings.ReplaceAll(e, ts.workdir, "$WORK") + + // Quote to literal strings: we want paths like C:\work\go1.4 to remain + // paths rather than regular expressions. + e = regexp.QuoteMeta(e) + } + return e + }) +} + +// fatalf aborts the test with the given failure message. +func (ts *testScript) fatalf(format string, args ...interface{}) { + fmt.Fprintf(&ts.log, "FAIL: %s:%d: %s\n", ts.file, ts.lineno, fmt.Sprintf(format, args...)) + ts.t.FailNow() +} + +// mkabs interprets file relative to the test script's current directory +// and returns the corresponding absolute path. +func (ts *testScript) mkabs(file string) string { + if filepath.IsAbs(file) { + return file + } + return filepath.Join(ts.cd, file) +} + +// A condition guards execution of a command. +type condition struct { + want bool + tag string +} + +// A command is a complete command parsed from a script. +type command struct { + want simpleStatus + conds []condition // all must be satisfied + name string // the name of the command; must be non-empty + args []string // shell-expanded arguments following name +} + +// parse parses a single line as a list of space-separated arguments +// subject to environment variable expansion (but not resplitting). +// Single quotes around text disable splitting and expansion. +// To embed a single quote, double it: 'Don''t communicate by sharing memory.' +func (ts *testScript) parse(line string) command { + ts.line = line + + var ( + cmd command + arg string // text of current arg so far (need to add line[start:i]) + start = -1 // if >= 0, position where current arg text chunk starts + quoted = false // currently processing quoted text + isRegexp = false // currently processing unquoted regular expression + ) + + flushArg := func() { + defer func() { + arg = "" + start = -1 + }() + + if cmd.name != "" { + cmd.args = append(cmd.args, arg) + // Commands take only one regexp argument (after the optional flags), + // so no subsequent args are regexps. Liberally assume an argument that + // starts with a '-' is a flag. + if len(arg) == 0 || arg[0] != '-' { + isRegexp = false + } + return + } + + // Command prefix ! means negate the expectations about this command: + // go command should fail, match should not be found, etc. + // Prefix ? means allow either success or failure. + switch want := simpleStatus(arg); want { + case failure, successOrFailure: + if cmd.want != "" { + ts.fatalf("duplicated '!' or '?' token") + } + cmd.want = want + return + } + + // Command prefix [cond] means only run this command if cond is satisfied. + if strings.HasPrefix(arg, "[") && strings.HasSuffix(arg, "]") { + want := true + arg = strings.TrimSpace(arg[1 : len(arg)-1]) + if strings.HasPrefix(arg, "!") { + want = false + arg = strings.TrimSpace(arg[1:]) + } + if arg == "" { + ts.fatalf("empty condition") + } + cmd.conds = append(cmd.conds, condition{want: want, tag: arg}) + return + } + + cmd.name = arg + isRegexp = regexpCmd[cmd.name] + } + + for i := 0; ; i++ { + if !quoted && (i >= len(line) || line[i] == ' ' || line[i] == '\t' || line[i] == '\r' || line[i] == '#') { + // Found arg-separating space. + if start >= 0 { + arg += ts.expand(line[start:i], isRegexp) + flushArg() + } + if i >= len(line) || line[i] == '#' { + break + } + continue + } + if i >= len(line) { + ts.fatalf("unterminated quoted argument") + } + if line[i] == '\'' { + if !quoted { + // starting a quoted chunk + if start >= 0 { + arg += ts.expand(line[start:i], isRegexp) + } + start = i + 1 + quoted = true + continue + } + // 'foo''bar' means foo'bar, like in rc shell and Pascal. + if i+1 < len(line) && line[i+1] == '\'' { + arg += line[start:i] + start = i + 1 + i++ // skip over second ' before next iteration + continue + } + // ending a quoted chunk + arg += line[start:i] + start = i + 1 + quoted = false + continue + } + // found character worth saving; make sure we're saving + if start < 0 { + start = i + } + } + return cmd +} + +// diff returns a formatted diff of the two texts, +// showing the entire text and the minimum line-level +// additions and removals to turn text1 into text2. +// (That is, lines only in text1 appear with a leading -, +// and lines only in text2 appear with a leading +.) +func diff(text1, text2 string) string { + if text1 != "" && !strings.HasSuffix(text1, "\n") { + text1 += "(missing final newline)" + } + lines1 := strings.Split(text1, "\n") + lines1 = lines1[:len(lines1)-1] // remove empty string after final line + if text2 != "" && !strings.HasSuffix(text2, "\n") { + text2 += "(missing final newline)" + } + lines2 := strings.Split(text2, "\n") + lines2 = lines2[:len(lines2)-1] // remove empty string after final line + + // Naive dynamic programming algorithm for edit distance. + // https://en.wikipedia.org/wiki/Wagner–Fischer_algorithm + // dist[i][j] = edit distance between lines1[:len(lines1)-i] and lines2[:len(lines2)-j] + // (The reversed indices make following the minimum cost path + // visit lines in the same order as in the text.) + dist := make([][]int, len(lines1)+1) + for i := range dist { + dist[i] = make([]int, len(lines2)+1) + if i == 0 { + for j := range dist[0] { + dist[0][j] = j + } + continue + } + for j := range dist[i] { + if j == 0 { + dist[i][0] = i + continue + } + cost := dist[i][j-1] + 1 + if cost > dist[i-1][j]+1 { + cost = dist[i-1][j] + 1 + } + if lines1[len(lines1)-i] == lines2[len(lines2)-j] { + if cost > dist[i-1][j-1] { + cost = dist[i-1][j-1] + } + } + dist[i][j] = cost + } + } + + var buf strings.Builder + i, j := len(lines1), len(lines2) + for i > 0 || j > 0 { + cost := dist[i][j] + if i > 0 && j > 0 && cost == dist[i-1][j-1] && lines1[len(lines1)-i] == lines2[len(lines2)-j] { + fmt.Fprintf(&buf, " %s\n", lines1[len(lines1)-i]) + i-- + j-- + } else if i > 0 && cost == dist[i-1][j]+1 { + fmt.Fprintf(&buf, "-%s\n", lines1[len(lines1)-i]) + i-- + } else { + fmt.Fprintf(&buf, "+%s\n", lines2[len(lines2)-j]) + j-- + } + } + return buf.String() +} + +var diffTests = []struct { + text1 string + text2 string + diff string +}{ + {"a b c", "a b d e f", "a b -c +d +e +f"}, + {"", "a b c", "+a +b +c"}, + {"a b c", "", "-a -b -c"}, + {"a b c", "d e f", "-a -b -c +d +e +f"}, + {"a b c d e f", "a b d e f", "a b -c d e f"}, + {"a b c e f", "a b c d e f", "a b c +d e f"}, +} + +func TestDiff(t *testing.T) { + t.Parallel() + + for _, tt := range diffTests { + // Turn spaces into \n. + text1 := strings.ReplaceAll(tt.text1, " ", "\n") + if text1 != "" { + text1 += "\n" + } + text2 := strings.ReplaceAll(tt.text2, " ", "\n") + if text2 != "" { + text2 += "\n" + } + out := diff(text1, text2) + // Cut final \n, cut spaces, turn remaining \n into spaces. + out = strings.ReplaceAll(strings.ReplaceAll(strings.TrimSuffix(out, "\n"), " ", ""), "\n", " ") + if out != tt.diff { + t.Errorf("diff(%q, %q) = %q, want %q", text1, text2, out, tt.diff) + } + } +} |