blob: 2a909f656d5e896bfba2fe73f0363a28bb0a366f [file] [log] [blame] [edit]
// 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, scriptsdir string, fixReadme bool) {
// 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
}
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)
addcmd("go", gocmd)
cmdExec := cmds["exec"]
addcmd("cc", scriptCC(cmdExec, goEnv("CC")))
// Add various helpful conditions related to builds and toolchain use.
goHostOS, goHostArch := goEnv("GOHOSTOS"), goEnv("GOHOSTARCH")
AddToolChainScriptConditions(t, conds, goHostOS, goHostArch)
// Environment setup.
env := os.Environ()
prependToPath(env, filepath.Join(tgr, "bin"))
env = setenv(env, "GOROOT", tgr)
// GOOS and GOARCH are expected to be set by the toolchain script conditions.
env = setenv(env, "GOOS", runtime.GOOS)
env = setenv(env, "GOARCH", runtime.GOARCH)
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(),
}
t.Run("README", func(t *testing.T) {
checkScriptReadme(t, engine, env, scriptsdir, gotool, fixReadme)
})
// ... and kick off tests.
ctx := context.Background()
pattern := filepath.Join(scriptsdir, "*.txt")
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"
}
}
// scriptCC runs the platform C compiler.
func scriptCC(cmdExec script.Cmd, ccexe string) script.Cmd {
return script.Command(
script.CmdUsage{
Summary: "run the platform C compiler",
Args: "args...",
},
func(s *script.State, args ...string) (script.WaitFunc, error) {
return cmdExec.Run(s, append([]string{ccexe}, args...)...)
})
}