Skip to content

Commit 5c47756

Browse files
committed
testscript: add GoTool to expose go tool X as a $PATH command
`go get -tool` paired with `go tool X` is a helpful feature added in Go 1.24, and it's really useful to testscript users given that it tracks the dependency in go.mod and caches the built binaries too. Its current implementation is a bit limited given the API for Main. See the added comments and TODO for future tweaks.
1 parent 96a3d36 commit 5c47756

File tree

6 files changed

+81
-0
lines changed

6 files changed

+81
-0
lines changed

go.mod

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,7 @@ require (
77
golang.org/x/sys v0.34.0
88
golang.org/x/tools v0.34.0
99
)
10+
11+
require golang.org/x/sync v0.15.0 // indirect
12+
13+
tool golang.org/x/tools/cmd/stringer

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
2+
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
13
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
24
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
5+
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
6+
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
37
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
48
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
59
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=

testscript/exe.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@
55
package testscript
66

77
import (
8+
"bytes"
9+
"fmt"
810
"io"
911
"log"
1012
"os"
13+
"os/exec"
1114
"path/filepath"
1215
"runtime"
1316
"strings"
@@ -58,6 +61,49 @@ func Main(m TestingM, commands map[string]func()) {
5861
os.Exit(0)
5962
}
6063

64+
// GoTool exposes a Go program added to the module being tested via `go get -tool`,
65+
// which can then be run via `go tool $name` and leverage Go's module and build caches.
66+
// This function must be run as part of [Main]; for example, after setting up the tool
67+
// via `go get -tool golang.org/x/tools/cmd/stringer`:
68+
//
69+
// testscript.Main(m, map[string]func(){
70+
// "stringer": testscript.GoTool("stringer"),
71+
// })
72+
func GoTool(name string) func() {
73+
// Since [Main] only takes a map[string]func() as a parameter, we cannot store the path
74+
// to the cached tool anywhere, so we resort to setting one env var per tool.
75+
// This is not ideal, but it works.
76+
//
77+
// We could also directly copy the cached tool binary into the $PATH that testscript sets up,
78+
// to avoid an indirection via the test binary to use os/exec below.
79+
// However, this again is very difficult given the current API of [Main].
80+
//
81+
// TODO: rethink in a future iteration of the API.
82+
envName := "TESTSCRIPT_GO_TOOL_" + name
83+
cachedBin := os.Getenv(envName)
84+
if cachedBin == "" {
85+
cmd := exec.Command("go", "tool", "-n", name)
86+
out, err := cmd.CombinedOutput()
87+
if err != nil {
88+
log.Fatalf("failed to run %v: %v\n%s", strings.Join(cmd.Args, " "), err, out)
89+
}
90+
os.Setenv(envName, string(bytes.TrimSpace(out)))
91+
}
92+
return func() {
93+
cmd := exec.Command(cachedBin, os.Args[1:]...)
94+
cmd.Stdin = os.Stdin
95+
cmd.Stdout = os.Stdout
96+
cmd.Stderr = os.Stderr
97+
if err := cmd.Run(); err != nil {
98+
if err, ok := err.(*exec.ExitError); ok {
99+
os.Exit(err.ExitCode())
100+
}
101+
fmt.Fprintln(os.Stderr, err)
102+
os.Exit(1)
103+
}
104+
}
105+
}
106+
61107
// testingMRun exists just so that we can use `defer`, given that [Main] above uses [os.Exit].
62108
func testingMRun(m TestingM, commands map[string]func()) int {
63109
// Set up all commands in a directory, added in $PATH.

testscript/testdata/go_tool.txtar

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# We test the integration with `go tool X` with stringer, because it's contained
2+
# by an existing dependency of ours in the form of x/tools.
3+
# Moreover, it's a fairly simple tool to use, and given that it takes a relative
4+
# path as an argument, it will catch whether the current directory is correct.
5+
6+
env GOCACHE=${WORK}/.gocache
7+
8+
stringer -type Foo foo.go
9+
exists foo_string.go
10+
11+
-- foo.go --
12+
package foo
13+
14+
type Foo int
15+
16+
const (
17+
_ Foo = iota
18+
Foo1
19+
Foo2
20+
)

testscript/testscript.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,12 @@ func (ts *TestScript) setup() string {
494494
env.Vars = append(env.Vars, name+"="+val)
495495
}
496496
}
497+
// For [GoTool] to work, we must pass its env vars through.
498+
for _, kv := range os.Environ() {
499+
if strings.HasPrefix(kv, "TESTSCRIPT_GO_TOOL_") {
500+
env.Vars = append(env.Vars, kv)
501+
}
502+
}
497503
// Must preserve SYSTEMROOT on Windows: https://github.com/golang/go/issues/25513 et al
498504
if runtime.GOOS == "windows" {
499505
env.Vars = append(env.Vars,

testscript/testscript_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ func TestMain(m *testing.M) {
8181
"status": exitWithStatus,
8282
"signalcatcher": signalCatcher,
8383
"terminalprompt": terminalPrompt,
84+
"stringer": GoTool("stringer"),
8485
})
8586
}
8687

0 commit comments

Comments
 (0)