Skip to content

Commit 0d8aa57

Browse files
committed
cmd/internal/script/scriptest: add new apis for tool test use
Add top level apis to provide a general-purpose "script test" runner for clients within cmd, e.g. tools such as compile, link, nm, and so on. This patch doesn't add any uses of the new apis, this will happen in follow-on CLs. Updates #68606. Change-Id: Ib7200a75d4dc7dc50897628f1a6269937be15a76 Reviewed-on: https://go-review.googlesource.com/c/go/+/601359 Reviewed-by: David Chase <[email protected]> LUCI-TryBot-Result: Go LUCI <[email protected]> Reviewed-by: Cherry Mui <[email protected]>
1 parent 32b55ed commit 0d8aa57

File tree

3 files changed

+381
-0
lines changed

3 files changed

+381
-0
lines changed
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
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+
// This package provides APIs for executing "script" tests; this
9+
// way of writing Go tests originated with the Go command, and has
10+
// since been generalized to work with other commands, such as the
11+
// compiler, linker, and other tools.
12+
//
13+
// The top level entry point for this package is "Test", which
14+
// accepts a previously configured script engine and pattern (typically
15+
// by convention this will be "testdata/script/*.txt")
16+
// then kicks off the engine on each file that matches the
17+
// pattern.
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
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+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Copyright 2024 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+
"io"
10+
"os"
11+
"path/filepath"
12+
"runtime"
13+
"testing"
14+
)
15+
16+
// SetupTestGoRoot sets up a temporary GOROOT for use with script test
17+
// execution. It copies the existing goroot bin and pkg dirs using
18+
// symlinks (if possible) or raw copying. Return value is the path to
19+
// the newly created testgoroot dir.
20+
func SetupTestGoRoot(t *testing.T, tmpdir string, goroot string) string {
21+
mustMkdir := func(path string) {
22+
if err := os.MkdirAll(path, 0777); err != nil {
23+
t.Fatalf("SetupTestGoRoot mkdir %s failed: %v", path, err)
24+
}
25+
}
26+
27+
replicateDir := func(srcdir, dstdir string) {
28+
files, err := os.ReadDir(srcdir)
29+
if err != nil {
30+
t.Fatalf("inspecting %s: %v", srcdir, err)
31+
}
32+
for _, file := range files {
33+
fn := file.Name()
34+
linkOrCopy(t, filepath.Join(srcdir, fn), filepath.Join(dstdir, fn))
35+
}
36+
}
37+
38+
// Create various dirs in testgoroot.
39+
toolsub := filepath.Join("tool", runtime.GOOS+"_"+runtime.GOARCH)
40+
tomake := []string{
41+
"bin",
42+
"src",
43+
"pkg",
44+
filepath.Join("pkg", "include"),
45+
filepath.Join("pkg", toolsub),
46+
}
47+
made := []string{}
48+
tgr := filepath.Join(tmpdir, "testgoroot")
49+
mustMkdir(tgr)
50+
for _, targ := range tomake {
51+
path := filepath.Join(tgr, targ)
52+
mustMkdir(path)
53+
made = append(made, path)
54+
}
55+
56+
// Replicate selected portions of the content.
57+
replicateDir(filepath.Join(goroot, "bin"), made[0])
58+
replicateDir(filepath.Join(goroot, "src"), made[1])
59+
replicateDir(filepath.Join(goroot, "pkg", "include"), made[3])
60+
replicateDir(filepath.Join(goroot, "pkg", toolsub), made[4])
61+
62+
return tgr
63+
}
64+
65+
// ReplaceGoToolInTestGoRoot replaces the go tool binary toolname with
66+
// an alternate executable newtoolpath within a test GOROOT directory
67+
// previously created by SetupTestGoRoot.
68+
func ReplaceGoToolInTestGoRoot(t *testing.T, testgoroot, toolname, newtoolpath string) {
69+
toolsub := filepath.Join("pkg", "tool", runtime.GOOS+"_"+runtime.GOARCH)
70+
exename := toolname
71+
if runtime.GOOS == "windows" {
72+
exename += ".exe"
73+
}
74+
toolpath := filepath.Join(testgoroot, toolsub, exename)
75+
if err := os.Remove(toolpath); err != nil {
76+
t.Fatalf("removing %s: %v", toolpath, err)
77+
}
78+
linkOrCopy(t, newtoolpath, toolpath)
79+
}
80+
81+
// linkOrCopy creates a link to src at dst, or if the symlink fails
82+
// (platform doesn't support) then copies src to dst.
83+
func linkOrCopy(t *testing.T, src, dst string) {
84+
err := os.Symlink(src, dst)
85+
if err == nil {
86+
return
87+
}
88+
srcf, err := os.Open(src)
89+
if err != nil {
90+
t.Fatalf("copying %s to %s: %v", src, dst, err)
91+
}
92+
defer srcf.Close()
93+
perm := os.O_WRONLY | os.O_CREATE | os.O_EXCL
94+
dstf, err := os.OpenFile(dst, perm, 0o777)
95+
if err != nil {
96+
t.Fatalf("copying %s to %s: %v", src, dst, err)
97+
}
98+
_, err = io.Copy(dstf, srcf)
99+
if closeErr := dstf.Close(); err == nil {
100+
err = closeErr
101+
}
102+
if err != nil {
103+
t.Fatalf("copying %s to %s: %v", src, dst, err)
104+
}
105+
}

0 commit comments

Comments
 (0)