diff --git a/src/cmd/internal/script/scripttest/doc.go b/src/cmd/internal/script/scripttest/doc.go new file mode 100644 index 0000000000..0ad6d69639 --- /dev/null +++ b/src/cmd/internal/script/scripttest/doc.go @@ -0,0 +1,17 @@ +// Copyright 2022 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 scripttest adapts the script engine for use in tests. +package scripttest + +// This package provides APIs for executing "script" tests; this +// way of writing Go tests originated with the Go command, and has +// since been generalized to work with other commands, such as the +// compiler, linker, and other tools. +// +// The top level entry point for this package is "Test", which +// accepts a previously configured script engine and pattern (typically +// by convention this will be "testdata/script/*.txt") +// then kicks off the engine on each file that matches the +// pattern. diff --git a/src/cmd/internal/script/scripttest/run.go b/src/cmd/internal/script/scripttest/run.go new file mode 100644 index 0000000000..d2f3ed8ca9 --- /dev/null +++ b/src/cmd/internal/script/scripttest/run.go @@ -0,0 +1,259 @@ +// Copyright 2022 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 scripttest adapts the script engine for use in tests. +package scripttest + +import ( + "bytes" + "cmd/internal/script" + "context" + "fmt" + "internal/testenv" + "internal/txtar" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +// ToolReplacement records the name of a tool to replace +// within a given GOROOT for script testing purposes. +type ToolReplacement struct { + ToolName string // e.g. compile, link, addr2line, etc + ReplacementPath string // path to replacement tool exe + EnvVar string // env var setting (e.g. "FOO=BAR") +} + +// RunToolScriptTest kicks off a set of script tests runs for +// a tool of some sort (compiler, linker, etc). The expectation +// is that we'll be called from the top level cmd/X dir for tool X, +// and that instead of executing the install tool X we'll use the +// test binary instead. +func RunToolScriptTest(t *testing.T, repls []ToolReplacement, pattern string) { + // Nearly all script tests involve doing builds, so don't + // bother here if we don't have "go build". + testenv.MustHaveGoBuild(t) + + // Skip this path on plan9, which doesn't support symbolic + // links (we would have to copy too much). + if runtime.GOOS == "plan9" { + t.Skipf("no symlinks on plan9") + } + + // Locate our Go tool. + gotool, err := testenv.GoTool() + if err != nil { + t.Fatalf("locating go tool: %v", err) + } + + goEnv := func(name string) string { + out, err := exec.Command(gotool, "env", name).CombinedOutput() + if err != nil { + t.Fatalf("go env %s: %v\n%s", name, err, out) + } + return strings.TrimSpace(string(out)) + } + + // Construct an initial set of commands + conditions to make available + // to the script tests. + cmds := DefaultCmds() + conds := DefaultConds() + + addcmd := func(name string, cmd script.Cmd) { + if _, ok := cmds[name]; ok { + panic(fmt.Sprintf("command %q is already registered", name)) + } + cmds[name] = cmd + } + + addcond := func(name string, cond script.Cond) { + if _, ok := conds[name]; ok { + panic(fmt.Sprintf("condition %q is already registered", name)) + } + conds[name] = cond + } + + prependToPath := func(env []string, dir string) { + found := false + for k := range env { + ev := env[k] + if !strings.HasPrefix(ev, "PATH=") { + continue + } + oldpath := ev[5:] + env[k] = "PATH=" + dir + string(filepath.ListSeparator) + oldpath + found = true + break + } + if !found { + t.Fatalf("could not update PATH") + } + } + + setenv := func(env []string, varname, val string) []string { + pref := varname + "=" + found := false + for k := range env { + if !strings.HasPrefix(env[k], pref) { + continue + } + env[k] = pref + val + found = true + break + } + if !found { + env = append(env, varname+"="+val) + } + return env + } + + interrupt := func(cmd *exec.Cmd) error { + return cmd.Process.Signal(os.Interrupt) + } + gracePeriod := 60 * time.Second // arbitrary + + // Set up an alternate go root for running script tests, since it + // is possible that we might want to replace one of the installed + // tools with a unit test executable. + goroot := goEnv("GOROOT") + tmpdir := t.TempDir() + tgr := SetupTestGoRoot(t, tmpdir, goroot) + + // Replace tools if appropriate + for _, repl := range repls { + ReplaceGoToolInTestGoRoot(t, tgr, repl.ToolName, repl.ReplacementPath) + } + + // Add in commands for "go" and "cc". + testgo := filepath.Join(tgr, "bin", "go") + gocmd := script.Program(testgo, interrupt, gracePeriod) + cccmd := script.Program(goEnv("CC"), interrupt, gracePeriod) + addcmd("go", gocmd) + addcmd("cc", cccmd) + addcond("cgo", script.BoolCondition("host CGO_ENABLED", testenv.HasCGO())) + + // Environment setup. + env := os.Environ() + prependToPath(env, filepath.Join(tgr, "bin")) + env = setenv(env, "GOROOT", tgr) + for _, repl := range repls { + // consistency check + chunks := strings.Split(repl.EnvVar, "=") + if len(chunks) != 2 { + t.Fatalf("malformed env var setting: %s", repl.EnvVar) + } + env = append(env, repl.EnvVar) + } + + // Manufacture engine... + engine := &script.Engine{ + Conds: conds, + Cmds: cmds, + Quiet: !testing.Verbose(), + } + + // ... and kick off tests. + ctx := context.Background() + RunTests(t, ctx, engine, env, pattern) +} + +// RunTests kicks off one or more script-based tests using the +// specified engine, running all test files that match pattern. +// This function adapted from Russ's rsc.io/script/scripttest#Run +// function, which was in turn forked off cmd/go's runner. +func RunTests(t *testing.T, ctx context.Context, engine *script.Engine, env []string, pattern string) { + gracePeriod := 100 * time.Millisecond + if deadline, ok := 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 + } + + // When we run commands that execute subprocesses, we want to + // reserve two grace periods to clean up. We will send the + // first termination signal when the context expires, then + // wait one grace period for the process to produce whatever + // useful output it can (such as a stack trace). After the + // first grace period expires, we'll escalate to os.Kill, + // leaving the second grace period for the test function to + // record its output before the test process itself + // terminates. + timeout -= 2 * gracePeriod + + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, timeout) + t.Cleanup(cancel) + } + + files, _ := filepath.Glob(pattern) + if len(files) == 0 { + t.Fatal("no testdata") + } + for _, file := range files { + file := file + name := strings.TrimSuffix(filepath.Base(file), ".txt") + t.Run(name, func(t *testing.T) { + t.Parallel() + + workdir := t.TempDir() + s, err := script.NewState(ctx, workdir, env) + if err != nil { + t.Fatal(err) + } + + // Unpack archive. + a, err := txtar.ParseFile(file) + if err != nil { + t.Fatal(err) + } + initScriptDirs(t, s) + if err := s.ExtractFiles(a); err != nil { + t.Fatal(err) + } + + t.Log(time.Now().UTC().Format(time.RFC3339)) + work, _ := s.LookupEnv("WORK") + t.Logf("$WORK=%s", work) + + // Note: Do not use filepath.Base(file) here: + // editors that can jump to file:line references in the output + // will work better seeing the full path relative to the + // directory containing the command being tested + // (e.g. where "go test" command is usually run). + Run(t, engine, s, file, bytes.NewReader(a.Comment)) + }) + } +} + +func initScriptDirs(t testing.TB, s *script.State) { + must := func(err error) { + if err != nil { + t.Helper() + t.Fatal(err) + } + } + + work := s.Getwd() + must(s.Setenv("WORK", work)) + must(os.MkdirAll(filepath.Join(work, "tmp"), 0777)) + must(s.Setenv(tempEnvName(), filepath.Join(work, "tmp"))) +} + +func tempEnvName() string { + switch runtime.GOOS { + case "windows": + return "TMP" + case "plan9": + return "TMPDIR" // actually plan 9 doesn't have one at all but this is fine + default: + return "TMPDIR" + } +} diff --git a/src/cmd/internal/script/scripttest/setup.go b/src/cmd/internal/script/scripttest/setup.go new file mode 100644 index 0000000000..d430367c12 --- /dev/null +++ b/src/cmd/internal/script/scripttest/setup.go @@ -0,0 +1,105 @@ +// Copyright 2024 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 scripttest adapts the script engine for use in tests. +package scripttest + +import ( + "io" + "os" + "path/filepath" + "runtime" + "testing" +) + +// SetupTestGoRoot sets up a temporary GOROOT for use with script test +// execution. It copies the existing goroot bin and pkg dirs using +// symlinks (if possible) or raw copying. Return value is the path to +// the newly created testgoroot dir. +func SetupTestGoRoot(t *testing.T, tmpdir string, goroot string) string { + mustMkdir := func(path string) { + if err := os.MkdirAll(path, 0777); err != nil { + t.Fatalf("SetupTestGoRoot mkdir %s failed: %v", path, err) + } + } + + replicateDir := func(srcdir, dstdir string) { + files, err := os.ReadDir(srcdir) + if err != nil { + t.Fatalf("inspecting %s: %v", srcdir, err) + } + for _, file := range files { + fn := file.Name() + linkOrCopy(t, filepath.Join(srcdir, fn), filepath.Join(dstdir, fn)) + } + } + + // Create various dirs in testgoroot. + toolsub := filepath.Join("tool", runtime.GOOS+"_"+runtime.GOARCH) + tomake := []string{ + "bin", + "src", + "pkg", + filepath.Join("pkg", "include"), + filepath.Join("pkg", toolsub), + } + made := []string{} + tgr := filepath.Join(tmpdir, "testgoroot") + mustMkdir(tgr) + for _, targ := range tomake { + path := filepath.Join(tgr, targ) + mustMkdir(path) + made = append(made, path) + } + + // Replicate selected portions of the content. + replicateDir(filepath.Join(goroot, "bin"), made[0]) + replicateDir(filepath.Join(goroot, "src"), made[1]) + replicateDir(filepath.Join(goroot, "pkg", "include"), made[3]) + replicateDir(filepath.Join(goroot, "pkg", toolsub), made[4]) + + return tgr +} + +// ReplaceGoToolInTestGoRoot replaces the go tool binary toolname with +// an alternate executable newtoolpath within a test GOROOT directory +// previously created by SetupTestGoRoot. +func ReplaceGoToolInTestGoRoot(t *testing.T, testgoroot, toolname, newtoolpath string) { + toolsub := filepath.Join("pkg", "tool", runtime.GOOS+"_"+runtime.GOARCH) + exename := toolname + if runtime.GOOS == "windows" { + exename += ".exe" + } + toolpath := filepath.Join(testgoroot, toolsub, exename) + if err := os.Remove(toolpath); err != nil { + t.Fatalf("removing %s: %v", toolpath, err) + } + linkOrCopy(t, newtoolpath, toolpath) +} + +// linkOrCopy creates a link to src at dst, or if the symlink fails +// (platform doesn't support) then copies src to dst. +func linkOrCopy(t *testing.T, src, dst string) { + err := os.Symlink(src, dst) + if err == nil { + return + } + srcf, err := os.Open(src) + if err != nil { + t.Fatalf("copying %s to %s: %v", src, dst, err) + } + defer srcf.Close() + perm := os.O_WRONLY | os.O_CREATE | os.O_EXCL + dstf, err := os.OpenFile(dst, perm, 0o777) + if err != nil { + t.Fatalf("copying %s to %s: %v", src, dst, err) + } + _, err = io.Copy(dstf, srcf) + if closeErr := dstf.Close(); err == nil { + err = closeErr + } + if err != nil { + t.Fatalf("copying %s to %s: %v", src, dst, err) + } +}