|
| 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 | + "os" |
| 16 | + "os/exec" |
| 17 | + "path/filepath" |
| 18 | + "runtime" |
| 19 | + "strings" |
| 20 | + "testing" |
| 21 | + "time" |
| 22 | +) |
| 23 | + |
| 24 | +// ToolReplacement records the name of a tool to replace |
| 25 | +// within a given GOROOT for script testing purposes. |
| 26 | +type ToolReplacement struct { |
| 27 | + ToolName string // e.g. compile, link, addr2line, etc |
| 28 | + ReplacementPath string // path to replacement tool exe |
| 29 | + EnvVar string // env var setting (e.g. "FOO=BAR") |
| 30 | +} |
| 31 | + |
| 32 | +// RunToolScriptTest kicks off a set of script tests runs for |
| 33 | +// a tool of some sort (compiler, linker, etc). The expectation |
| 34 | +// is that we'll be called from the top level cmd/X dir for tool X, |
| 35 | +// and that instead of executing the install tool X we'll use the |
| 36 | +// test binary instead. |
| 37 | +func RunToolScriptTest(t *testing.T, repls []ToolReplacement, pattern string) { |
| 38 | + // Nearly all script tests involve doing builds, so don't |
| 39 | + // bother here if we don't have "go build". |
| 40 | + testenv.MustHaveGoBuild(t) |
| 41 | + |
| 42 | + // Skip this path on plan9, which doesn't support symbolic |
| 43 | + // links (we would have to copy too much). |
| 44 | + if runtime.GOOS == "plan9" { |
| 45 | + t.Skipf("no symlinks on plan9") |
| 46 | + } |
| 47 | + |
| 48 | + // Locate our Go tool. |
| 49 | + gotool, err := testenv.GoTool() |
| 50 | + if err != nil { |
| 51 | + t.Fatalf("locating go tool: %v", err) |
| 52 | + } |
| 53 | + |
| 54 | + goEnv := func(name string) string { |
| 55 | + out, err := exec.Command(gotool, "env", name).CombinedOutput() |
| 56 | + if err != nil { |
| 57 | + t.Fatalf("go env %s: %v\n%s", name, err, out) |
| 58 | + } |
| 59 | + return strings.TrimSpace(string(out)) |
| 60 | + } |
| 61 | + |
| 62 | + // Construct an initial set of commands + conditions to make available |
| 63 | + // to the script tests. |
| 64 | + cmds := DefaultCmds() |
| 65 | + conds := DefaultConds() |
| 66 | + |
| 67 | + addcmd := func(name string, cmd script.Cmd) { |
| 68 | + if _, ok := cmds[name]; ok { |
| 69 | + panic(fmt.Sprintf("command %q is already registered", name)) |
| 70 | + } |
| 71 | + cmds[name] = cmd |
| 72 | + } |
| 73 | + |
| 74 | + addcond := func(name string, cond script.Cond) { |
| 75 | + if _, ok := conds[name]; ok { |
| 76 | + panic(fmt.Sprintf("condition %q is already registered", name)) |
| 77 | + } |
| 78 | + conds[name] = cond |
| 79 | + } |
| 80 | + |
| 81 | + prependToPath := func(env []string, dir string) { |
| 82 | + found := false |
| 83 | + for k := range env { |
| 84 | + ev := env[k] |
| 85 | + if !strings.HasPrefix(ev, "PATH=") { |
| 86 | + continue |
| 87 | + } |
| 88 | + oldpath := ev[5:] |
| 89 | + env[k] = "PATH=" + dir + string(filepath.ListSeparator) + oldpath |
| 90 | + found = true |
| 91 | + break |
| 92 | + } |
| 93 | + if !found { |
| 94 | + t.Fatalf("could not update PATH") |
| 95 | + } |
| 96 | + } |
| 97 | + |
| 98 | + setenv := func(env []string, varname, val string) []string { |
| 99 | + pref := varname + "=" |
| 100 | + found := false |
| 101 | + for k := range env { |
| 102 | + if !strings.HasPrefix(env[k], pref) { |
| 103 | + continue |
| 104 | + } |
| 105 | + env[k] = pref + val |
| 106 | + found = true |
| 107 | + break |
| 108 | + } |
| 109 | + if !found { |
| 110 | + env = append(env, varname+"="+val) |
| 111 | + } |
| 112 | + return env |
| 113 | + } |
| 114 | + |
| 115 | + interrupt := func(cmd *exec.Cmd) error { |
| 116 | + return cmd.Process.Signal(os.Interrupt) |
| 117 | + } |
| 118 | + gracePeriod := 60 * time.Second // arbitrary |
| 119 | + |
| 120 | + // Set up an alternate go root for running script tests, since it |
| 121 | + // is possible that we might want to replace one of the installed |
| 122 | + // tools with a unit test executable. |
| 123 | + goroot := goEnv("GOROOT") |
| 124 | + tmpdir := t.TempDir() |
| 125 | + tgr := SetupTestGoRoot(t, tmpdir, goroot) |
| 126 | + |
| 127 | + // Replace tools if appropriate |
| 128 | + for _, repl := range repls { |
| 129 | + ReplaceGoToolInTestGoRoot(t, tgr, repl.ToolName, repl.ReplacementPath) |
| 130 | + } |
| 131 | + |
| 132 | + // Add in commands for "go" and "cc". |
| 133 | + testgo := filepath.Join(tgr, "bin", "go") |
| 134 | + gocmd := script.Program(testgo, interrupt, gracePeriod) |
| 135 | + cccmd := script.Program(goEnv("CC"), interrupt, gracePeriod) |
| 136 | + addcmd("go", gocmd) |
| 137 | + addcmd("cc", cccmd) |
| 138 | + addcond("cgo", script.BoolCondition("host CGO_ENABLED", testenv.HasCGO())) |
| 139 | + |
| 140 | + // Environment setup. |
| 141 | + env := os.Environ() |
| 142 | + prependToPath(env, filepath.Join(tgr, "bin")) |
| 143 | + env = setenv(env, "GOROOT", tgr) |
| 144 | + for _, repl := range repls { |
| 145 | + // consistency check |
| 146 | + chunks := strings.Split(repl.EnvVar, "=") |
| 147 | + if len(chunks) != 2 { |
| 148 | + t.Fatalf("malformed env var setting: %s", repl.EnvVar) |
| 149 | + } |
| 150 | + env = append(env, repl.EnvVar) |
| 151 | + } |
| 152 | + |
| 153 | + // Manufacture engine... |
| 154 | + engine := &script.Engine{ |
| 155 | + Conds: conds, |
| 156 | + Cmds: cmds, |
| 157 | + Quiet: !testing.Verbose(), |
| 158 | + } |
| 159 | + |
| 160 | + // ... and kick off tests. |
| 161 | + ctx := context.Background() |
| 162 | + RunTests(t, ctx, engine, env, pattern) |
| 163 | +} |
| 164 | + |
| 165 | +// RunTests kicks off one or more script-based tests using the |
| 166 | +// specified engine, running all test files that match pattern. |
| 167 | +// This function adapted from Russ's rsc.io/script/scripttest#Run |
| 168 | +// function, which was in turn forked off cmd/go's runner. |
| 169 | +func RunTests(t *testing.T, ctx context.Context, engine *script.Engine, env []string, pattern string) { |
| 170 | + gracePeriod := 100 * time.Millisecond |
| 171 | + if deadline, ok := t.Deadline(); ok { |
| 172 | + timeout := time.Until(deadline) |
| 173 | + |
| 174 | + // If time allows, increase the termination grace period to 5% of the |
| 175 | + // remaining time. |
| 176 | + if gp := timeout / 20; gp > gracePeriod { |
| 177 | + gracePeriod = gp |
| 178 | + } |
| 179 | + |
| 180 | + // When we run commands that execute subprocesses, we want to |
| 181 | + // reserve two grace periods to clean up. We will send the |
| 182 | + // first termination signal when the context expires, then |
| 183 | + // wait one grace period for the process to produce whatever |
| 184 | + // useful output it can (such as a stack trace). After the |
| 185 | + // first grace period expires, we'll escalate to os.Kill, |
| 186 | + // leaving the second grace period for the test function to |
| 187 | + // record its output before the test process itself |
| 188 | + // terminates. |
| 189 | + timeout -= 2 * gracePeriod |
| 190 | + |
| 191 | + var cancel context.CancelFunc |
| 192 | + ctx, cancel = context.WithTimeout(ctx, timeout) |
| 193 | + t.Cleanup(cancel) |
| 194 | + } |
| 195 | + |
| 196 | + files, _ := filepath.Glob(pattern) |
| 197 | + if len(files) == 0 { |
| 198 | + t.Fatal("no testdata") |
| 199 | + } |
| 200 | + for _, file := range files { |
| 201 | + file := file |
| 202 | + name := strings.TrimSuffix(filepath.Base(file), ".txt") |
| 203 | + t.Run(name, func(t *testing.T) { |
| 204 | + t.Parallel() |
| 205 | + |
| 206 | + workdir := t.TempDir() |
| 207 | + s, err := script.NewState(ctx, workdir, env) |
| 208 | + if err != nil { |
| 209 | + t.Fatal(err) |
| 210 | + } |
| 211 | + |
| 212 | + // Unpack archive. |
| 213 | + a, err := txtar.ParseFile(file) |
| 214 | + if err != nil { |
| 215 | + t.Fatal(err) |
| 216 | + } |
| 217 | + initScriptDirs(t, s) |
| 218 | + if err := s.ExtractFiles(a); err != nil { |
| 219 | + t.Fatal(err) |
| 220 | + } |
| 221 | + |
| 222 | + t.Log(time.Now().UTC().Format(time.RFC3339)) |
| 223 | + work, _ := s.LookupEnv("WORK") |
| 224 | + t.Logf("$WORK=%s", work) |
| 225 | + |
| 226 | + // Note: Do not use filepath.Base(file) here: |
| 227 | + // editors that can jump to file:line references in the output |
| 228 | + // will work better seeing the full path relative to the |
| 229 | + // directory containing the command being tested |
| 230 | + // (e.g. where "go test" command is usually run). |
| 231 | + Run(t, engine, s, file, bytes.NewReader(a.Comment)) |
| 232 | + }) |
| 233 | + } |
| 234 | +} |
| 235 | + |
| 236 | +func initScriptDirs(t testing.TB, s *script.State) { |
| 237 | + must := func(err error) { |
| 238 | + if err != nil { |
| 239 | + t.Helper() |
| 240 | + t.Fatal(err) |
| 241 | + } |
| 242 | + } |
| 243 | + |
| 244 | + work := s.Getwd() |
| 245 | + must(s.Setenv("WORK", work)) |
| 246 | + must(os.MkdirAll(filepath.Join(work, "tmp"), 0777)) |
| 247 | + must(s.Setenv(tempEnvName(), filepath.Join(work, "tmp"))) |
| 248 | +} |
| 249 | + |
| 250 | +func tempEnvName() string { |
| 251 | + switch runtime.GOOS { |
| 252 | + case "windows": |
| 253 | + return "TMP" |
| 254 | + case "plan9": |
| 255 | + return "TMPDIR" // actually plan 9 doesn't have one at all but this is fine |
| 256 | + default: |
| 257 | + return "TMPDIR" |
| 258 | + } |
| 259 | +} |
0 commit comments