Source file src/cmd/internal/script/scripttest/run.go

     1  // Copyright 2022 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 scripttest adapts the script engine for use in tests.
     6  package scripttest
     7  
     8  import (
     9  	"bytes"
    10  	"cmd/internal/script"
    11  	"context"
    12  	"fmt"
    13  	"internal/testenv"
    14  	"internal/txtar"
    15  	"io/fs"
    16  	"os"
    17  	"os/exec"
    18  	"path/filepath"
    19  	"runtime"
    20  	"strings"
    21  	"testing"
    22  	"time"
    23  )
    24  
    25  // ToolReplacement records the name of a tool to replace
    26  // within a given GOROOT for script testing purposes.
    27  type ToolReplacement struct {
    28  	ToolName        string // e.g. compile, link, addr2line, etc
    29  	ReplacementPath string // path to replacement tool exe
    30  	EnvVar          string // env var setting (e.g. "FOO=BAR")
    31  }
    32  
    33  // NewEngine constructs a new [script.Engine] and environment to be used with
    34  // [RunTests].
    35  func NewEngine(t *testing.T, repls []ToolReplacement) (*script.Engine, []string) {
    36  	// Nearly all script tests involve doing builds, so don't
    37  	// bother here if we don't have "go build".
    38  	testenv.MustHaveGoBuild(t)
    39  
    40  	// Skip this path on plan9, which doesn't support symbolic
    41  	// links (we would have to copy too much).
    42  	if runtime.GOOS == "plan9" {
    43  		t.Skipf("no symlinks on plan9")
    44  	}
    45  
    46  	// Locate our Go tool.
    47  	gotool, err := testenv.GoTool()
    48  	if err != nil {
    49  		t.Fatalf("locating go tool: %v", err)
    50  	}
    51  
    52  	goEnv := func(name string) string {
    53  		out, err := exec.Command(gotool, "env", name).CombinedOutput()
    54  		if err != nil {
    55  			t.Fatalf("go env %s: %v\n%s", name, err, out)
    56  		}
    57  		return strings.TrimSpace(string(out))
    58  	}
    59  
    60  	// Construct an initial set of commands + conditions to make available
    61  	// to the script tests.
    62  	cmds := DefaultCmds()
    63  	conds := DefaultConds()
    64  
    65  	addcmd := func(name string, cmd script.Cmd) {
    66  		if _, ok := cmds[name]; ok {
    67  			panic(fmt.Sprintf("command %q is already registered", name))
    68  		}
    69  		cmds[name] = cmd
    70  	}
    71  
    72  	prependToPath := func(env []string, dir string) {
    73  		found := false
    74  		for k := range env {
    75  			ev := env[k]
    76  			if !strings.HasPrefix(ev, "PATH=") {
    77  				continue
    78  			}
    79  			oldpath := ev[5:]
    80  			env[k] = "PATH=" + dir + string(filepath.ListSeparator) + oldpath
    81  			found = true
    82  			break
    83  		}
    84  		if !found {
    85  			t.Fatalf("could not update PATH")
    86  		}
    87  	}
    88  
    89  	setenv := func(env []string, varname, val string) []string {
    90  		pref := varname + "="
    91  		found := false
    92  		for k := range env {
    93  			if !strings.HasPrefix(env[k], pref) {
    94  				continue
    95  			}
    96  			env[k] = pref + val
    97  			found = true
    98  			break
    99  		}
   100  		if !found {
   101  			env = append(env, varname+"="+val)
   102  		}
   103  		return env
   104  	}
   105  
   106  	interrupt := func(cmd *exec.Cmd) error {
   107  		// TODO(thepudds): currently cmd/go/script_test.go uses a platform-specific cancel
   108  		// that we could consider emulating here.
   109  		return cmd.Process.Signal(os.Interrupt)
   110  	}
   111  
   112  	// Customize the subprocess termination grace period to reduce flakes on busy builders (#76685).
   113  	// The grace period is the max of 100ms or 5% of the time remaining until any t.Deadline.
   114  	gracePeriod := subprocessGracePeriod(t.Deadline())
   115  
   116  	cmdExec := script.Exec(interrupt, gracePeriod)
   117  	cmds["exec"] = cmdExec
   118  
   119  	// Set up an alternate go root for running script tests, since it
   120  	// is possible that we might want to replace one of the installed
   121  	// tools with a unit test executable.
   122  	goroot := goEnv("GOROOT")
   123  	tmpdir := t.TempDir()
   124  	tgr := SetupTestGoRoot(t, tmpdir, goroot)
   125  
   126  	// Replace tools if appropriate
   127  	for _, repl := range repls {
   128  		ReplaceGoToolInTestGoRoot(t, tgr, repl.ToolName, repl.ReplacementPath)
   129  	}
   130  
   131  	// Add in commands for "go" and "cc".
   132  	testgo := filepath.Join(tgr, "bin", "go")
   133  	gocmd := script.Program(testgo, interrupt, gracePeriod)
   134  	addcmd("go", gocmd)
   135  	addcmd("cc", scriptCC(cmdExec, goEnv("CC")))
   136  
   137  	// Add various helpful conditions related to builds and toolchain use.
   138  	goHostOS, goHostArch := goEnv("GOHOSTOS"), goEnv("GOHOSTARCH")
   139  	AddToolChainScriptConditions(t, conds, goHostOS, goHostArch)
   140  
   141  	// Environment setup.
   142  	env := os.Environ()
   143  	prependToPath(env, filepath.Join(tgr, "bin"))
   144  	env = setenv(env, "GOROOT", tgr)
   145  	// GOOS and GOARCH are expected to be set by the toolchain script conditions.
   146  	env = setenv(env, "GOOS", runtime.GOOS)
   147  	env = setenv(env, "GOARCH", runtime.GOARCH)
   148  	for _, repl := range repls {
   149  		// consistency check
   150  		chunks := strings.Split(repl.EnvVar, "=")
   151  		if len(chunks) != 2 {
   152  			t.Fatalf("malformed env var setting: %s", repl.EnvVar)
   153  		}
   154  		env = append(env, repl.EnvVar)
   155  	}
   156  
   157  	// Manufacture engine...
   158  	engine := &script.Engine{
   159  		Conds: conds,
   160  		Cmds:  cmds,
   161  		Quiet: !testing.Verbose(),
   162  	}
   163  
   164  	return engine, env
   165  }
   166  
   167  // RunToolScriptTest kicks off a set of script tests runs for
   168  // a tool of some sort (compiler, linker, etc). The expectation
   169  // is that we'll be called from the top level cmd/X dir for tool X,
   170  // and that instead of executing the install tool X we'll use the
   171  // test binary instead.
   172  func RunToolScriptTest(t *testing.T, repls []ToolReplacement, scriptsdir string, fixReadme bool) {
   173  	// Locate our Go tool.
   174  	gotool, err := testenv.GoTool()
   175  	if err != nil {
   176  		t.Fatalf("locating go tool: %v", err)
   177  	}
   178  
   179  	engine, env := NewEngine(t, repls)
   180  
   181  	t.Run("README", func(t *testing.T) {
   182  		checkScriptReadme(t, engine, env, scriptsdir, gotool, fixReadme)
   183  	})
   184  
   185  	// ... and kick off tests.
   186  	ctx := context.Background()
   187  	pattern := filepath.Join(scriptsdir, "*.txt")
   188  	RunTests(t, ctx, engine, env, pattern)
   189  }
   190  
   191  // ScriptTestContext returns a context with a grace period for cleaning up
   192  // subprocesses of a script test.
   193  //
   194  // When we run commands that execute subprocesses, we want to reserve two grace
   195  // periods to clean up. We will send the first termination signal when the
   196  // context expires, then wait one grace period for the process to produce
   197  // whatever useful output it can (such as a stack trace). After the first grace
   198  // period expires, we'll escalate to os.Kill, leaving the second grace period
   199  // for the test function to record its output before the test process itself
   200  // terminates.
   201  //
   202  // The grace period is 100ms or 5% of the time remaining until
   203  // [testing.T.Deadline], whichever is greater.
   204  func ScriptTestContext(t *testing.T, ctx context.Context) context.Context {
   205  	deadline, ok := t.Deadline()
   206  	if !ok {
   207  		return ctx
   208  	}
   209  
   210  	gracePeriod := subprocessGracePeriod(deadline, ok)
   211  
   212  	// Reserve two grace periods to clean up
   213  	timeout := time.Until(deadline)
   214  	timeout -= 2 * gracePeriod
   215  
   216  	ctx, cancel := context.WithTimeout(ctx, timeout)
   217  	t.Cleanup(cancel)
   218  	return ctx
   219  }
   220  
   221  // subprocessGracePeriod returns a grace period for terminating subprocesses
   222  // created by the commands of a script test.
   223  func subprocessGracePeriod(deadline time.Time, hasDeadline bool) time.Duration {
   224  	gracePeriod := 100 * time.Millisecond // arbitrary
   225  	if !hasDeadline {
   226  		return gracePeriod
   227  	}
   228  
   229  	// If time allows, increase the termination grace period to 5% of the
   230  	// remaining time.
   231  	timeout := time.Until(deadline)
   232  	return max(gracePeriod, timeout/20)
   233  }
   234  
   235  // RunTests kicks off one or more script-based tests using the
   236  // specified engine, running all test files that match pattern.
   237  // This function adapted from Russ's rsc.io/script/scripttest#Run
   238  // function, which was in turn forked off cmd/go's runner.
   239  func RunTests(t *testing.T, ctx context.Context, engine *script.Engine, env []string, pattern string) {
   240  	ctx = ScriptTestContext(t, ctx)
   241  
   242  	files, _ := filepath.Glob(pattern)
   243  	if len(files) == 0 {
   244  		t.Fatal("no testdata")
   245  	}
   246  	for _, file := range files {
   247  		file := file
   248  		name := strings.TrimSuffix(filepath.Base(file), ".txt")
   249  		t.Run(name, func(t *testing.T) {
   250  			t.Parallel()
   251  
   252  			workdir := t.TempDir()
   253  			s, err := script.NewState(ctx, workdir, env)
   254  			if err != nil {
   255  				t.Fatal(err)
   256  			}
   257  
   258  			// Call fixPermissions at the end of the test case in case
   259  			// it uses the go modcache, which writes read-only files.
   260  			// fixPermissions fixes up the permissions so a later removal can succeed.
   261  			defer fixPermissions(t, workdir)
   262  
   263  			// Unpack archive.
   264  			a, err := txtar.ParseFile(file)
   265  			if err != nil {
   266  				t.Fatal(err)
   267  			}
   268  			InitScriptDirs(t, s)
   269  			if err := s.ExtractFiles(a); err != nil {
   270  				t.Fatal(err)
   271  			}
   272  
   273  			t.Log(time.Now().UTC().Format(time.RFC3339))
   274  			work, _ := s.LookupEnv("WORK")
   275  			t.Logf("$WORK=%s", work)
   276  
   277  			// Note: Do not use filepath.Base(file) here:
   278  			// editors that can jump to file:line references in the output
   279  			// will work better seeing the full path relative to the
   280  			// directory containing the command being tested
   281  			// (e.g. where "go test" command is usually run).
   282  			Run(t, engine, s, file, bytes.NewReader(a.Comment))
   283  		})
   284  	}
   285  }
   286  
   287  func fixPermissions(t *testing.T, dir string) {
   288  	t.Helper()
   289  
   290  	// module cache has 0444 directories;
   291  	// make them writable in order to remove content.
   292  	filepath.WalkDir(dir, func(path string, info fs.DirEntry, err error) error {
   293  		// chmod not only directories, but also things that we couldn't even stat
   294  		// due to permission errors: they may also be unreadable directories.
   295  		if err != nil || info.IsDir() {
   296  			os.Chmod(path, 0777)
   297  		}
   298  		return nil
   299  	})
   300  }
   301  
   302  // InitScriptDirs sets up directories for executing a script test.
   303  //
   304  //   - WORK (env var) is set to the current working directory.
   305  //   - TMPDIR (env var; TMP on Windows) is set to $WORK/tmp.
   306  //   - $TMPDIR is created.
   307  func InitScriptDirs(t testing.TB, s *script.State) {
   308  	must := func(err error) {
   309  		if err != nil {
   310  			t.Helper()
   311  			t.Fatal(err)
   312  		}
   313  	}
   314  
   315  	work := s.Getwd()
   316  	must(s.Setenv("WORK", work))
   317  	must(os.MkdirAll(filepath.Join(work, "tmp"), 0777))
   318  	must(s.Setenv(tempEnvName(), filepath.Join(work, "tmp")))
   319  }
   320  
   321  func tempEnvName() string {
   322  	switch runtime.GOOS {
   323  	case "windows":
   324  		return "TMP"
   325  	case "plan9":
   326  		return "TMPDIR" // actually plan 9 doesn't have one at all but this is fine
   327  	default:
   328  		return "TMPDIR"
   329  	}
   330  }
   331  
   332  // scriptCC runs the platform C compiler.
   333  func scriptCC(cmdExec script.Cmd, ccexe string) script.Cmd {
   334  	return script.Command(
   335  		script.CmdUsage{
   336  			Summary: "run the platform C compiler",
   337  			Args:    "args...",
   338  		},
   339  		func(s *script.State, args ...string) (script.WaitFunc, error) {
   340  			return cmdExec.Run(s, append([]string{ccexe}, args...)...)
   341  		})
   342  }
   343  

View as plain text