Skip to content

Commit e8f626f

Browse files
committed
add test suite for runner
1 parent 5ebe694 commit e8f626f

File tree

3 files changed

+353
-0
lines changed

3 files changed

+353
-0
lines changed

runner/runner_darwin_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
//go:build darwin
2+
3+
package runner_test
4+
5+
import (
6+
"context"
7+
"os"
8+
"path/filepath"
9+
"testing"
10+
11+
"github.com/itchio/smaug/runner"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func TestAppBundle(t *testing.T) {
16+
bundleDir := filepath.Join(t.TempDir(), "TestHelper.app")
17+
macosDir := filepath.Join(bundleDir, "Contents", "MacOS")
18+
require.NoError(t, os.MkdirAll(macosDir, 0755))
19+
20+
// Symlink the test helper binary into the bundle
21+
require.NoError(t, os.Symlink(testHelperPath, filepath.Join(macosDir, "testhelper")))
22+
23+
// Write minimal Info.plist
24+
plist := `<?xml version="1.0" encoding="UTF-8"?>
25+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
26+
<plist version="1.0">
27+
<dict>
28+
<key>CFBundleExecutable</key>
29+
<string>testhelper</string>
30+
</dict>
31+
</plist>`
32+
require.NoError(t, os.WriteFile(
33+
filepath.Join(bundleDir, "Contents", "Info.plist"),
34+
[]byte(plist),
35+
0644,
36+
))
37+
38+
params := runner.RunnerParams{
39+
Consumer: newTestConsumer(t),
40+
Ctx: context.Background(),
41+
FullTargetPath: bundleDir,
42+
Args: []string{"echo", "hello"},
43+
InstallFolder: filepath.Dir(bundleDir),
44+
}
45+
46+
r, err := runner.GetRunner(params)
47+
require.NoError(t, err)
48+
49+
require.NoError(t, r.Prepare())
50+
51+
// open -W does not relay stdout/stderr, so we can only verify
52+
// that the bundle launches and exits without error.
53+
require.NoError(t, r.Run())
54+
}

runner/runner_test.go

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
package runner_test
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"errors"
7+
"os"
8+
"os/exec"
9+
"path/filepath"
10+
"runtime"
11+
"strings"
12+
"testing"
13+
"time"
14+
15+
"github.com/itchio/headway/state"
16+
"github.com/itchio/smaug/runner"
17+
"github.com/stretchr/testify/assert"
18+
"github.com/stretchr/testify/require"
19+
)
20+
21+
var testHelperPath string
22+
23+
func TestMain(m *testing.M) {
24+
tmpDir, err := os.MkdirTemp("", "smaug-test-*")
25+
if err != nil {
26+
panic(err)
27+
}
28+
defer os.RemoveAll(tmpDir)
29+
30+
binaryName := "testhelper"
31+
if runtime.GOOS == "windows" {
32+
binaryName += ".exe"
33+
}
34+
testHelperPath = filepath.Join(tmpDir, binaryName)
35+
36+
cmd := exec.Command("go", "build", "-o", testHelperPath, "./testdata/testhelper")
37+
cmd.Stderr = os.Stderr
38+
cmd.Stdout = os.Stdout
39+
if err := cmd.Run(); err != nil {
40+
panic("failed to build test helper: " + err.Error())
41+
}
42+
43+
os.Exit(m.Run())
44+
}
45+
46+
func newTestConsumer(t *testing.T) *state.Consumer {
47+
return &state.Consumer{
48+
OnMessage: func(lvl string, msg string) {
49+
t.Logf("[%s] %s", lvl, msg)
50+
},
51+
}
52+
}
53+
54+
func newTestParams(t *testing.T, args ...string) runner.RunnerParams {
55+
return runner.RunnerParams{
56+
Consumer: newTestConsumer(t),
57+
Ctx: context.Background(),
58+
FullTargetPath: testHelperPath,
59+
Args: args,
60+
InstallFolder: filepath.Dir(testHelperPath),
61+
}
62+
}
63+
64+
func TestBasicExecution(t *testing.T) {
65+
var stdout bytes.Buffer
66+
params := newTestParams(t, "echo", "hello")
67+
params.Stdout = &stdout
68+
69+
r, err := runner.GetRunner(params)
70+
require.NoError(t, err)
71+
72+
require.NoError(t, r.Prepare())
73+
74+
require.NoError(t, r.Run())
75+
76+
assert.Equal(t, "hello\n", stdout.String())
77+
}
78+
79+
func TestArgumentPassing(t *testing.T) {
80+
var stdout bytes.Buffer
81+
params := newTestParams(t, "echo", "hello world", "foo\tbar", "baz\"qux")
82+
params.Stdout = &stdout
83+
84+
r, err := runner.GetRunner(params)
85+
require.NoError(t, err)
86+
require.NoError(t, r.Prepare())
87+
require.NoError(t, r.Run())
88+
89+
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
90+
require.Len(t, lines, 3)
91+
assert.Equal(t, "hello world", lines[0])
92+
assert.Equal(t, "foo\tbar", lines[1])
93+
assert.Equal(t, "baz\"qux", lines[2])
94+
}
95+
96+
func TestEnvironmentVariables(t *testing.T) {
97+
var stdout bytes.Buffer
98+
params := newTestParams(t, "env", "TEST_VAR_A", "TEST_VAR_B")
99+
params.Stdout = &stdout
100+
params.Env = []string{
101+
"TEST_VAR_A=alpha",
102+
"TEST_VAR_B=beta",
103+
}
104+
105+
r, err := runner.GetRunner(params)
106+
require.NoError(t, err)
107+
require.NoError(t, r.Prepare())
108+
require.NoError(t, r.Run())
109+
110+
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
111+
require.Len(t, lines, 2)
112+
assert.Equal(t, "alpha", lines[0])
113+
assert.Equal(t, "beta", lines[1])
114+
}
115+
116+
func TestStdoutStderr(t *testing.T) {
117+
var stdout, stderr bytes.Buffer
118+
params := newTestParams(t, "output", "stdout", "out-msg", "stderr", "err-msg")
119+
params.Stdout = &stdout
120+
params.Stderr = &stderr
121+
122+
r, err := runner.GetRunner(params)
123+
require.NoError(t, err)
124+
require.NoError(t, r.Prepare())
125+
require.NoError(t, r.Run())
126+
127+
assert.Equal(t, "out-msg\n", stdout.String())
128+
assert.Equal(t, "err-msg\n", stderr.String())
129+
}
130+
131+
func TestWorkingDirectory(t *testing.T) {
132+
var stdout bytes.Buffer
133+
dir := t.TempDir()
134+
params := newTestParams(t, "cwd")
135+
params.Stdout = &stdout
136+
params.Dir = dir
137+
138+
r, err := runner.GetRunner(params)
139+
require.NoError(t, err)
140+
require.NoError(t, r.Prepare())
141+
require.NoError(t, r.Run())
142+
143+
// On macOS, /tmp may resolve to /private/tmp via symlink
144+
got, err := filepath.EvalSymlinks(strings.TrimSpace(stdout.String()))
145+
require.NoError(t, err)
146+
expected, err := filepath.EvalSymlinks(dir)
147+
require.NoError(t, err)
148+
assert.Equal(t, expected, got)
149+
}
150+
151+
func TestExitCodeZero(t *testing.T) {
152+
params := newTestParams(t, "exit", "0")
153+
154+
r, err := runner.GetRunner(params)
155+
require.NoError(t, err)
156+
require.NoError(t, r.Prepare())
157+
158+
assert.NoError(t, r.Run())
159+
}
160+
161+
func TestExitCodeNonZero(t *testing.T) {
162+
params := newTestParams(t, "exit", "42")
163+
164+
r, err := runner.GetRunner(params)
165+
require.NoError(t, err)
166+
require.NoError(t, r.Prepare())
167+
168+
err = r.Run()
169+
170+
if runtime.GOOS == "windows" {
171+
// On Windows, the job object completion port reports success
172+
// when all processes exit, regardless of individual exit codes.
173+
// See processgroup_windows.go:130
174+
assert.NoError(t, err)
175+
} else {
176+
require.Error(t, err)
177+
var exitErr *exec.ExitError
178+
require.True(t, errors.As(err, &exitErr), "expected *exec.ExitError, got %T: %v", err, err)
179+
assert.Equal(t, 42, exitErr.ExitCode())
180+
}
181+
}
182+
183+
func TestContextCancellation(t *testing.T) {
184+
ctx, cancel := context.WithCancel(context.Background())
185+
defer cancel()
186+
187+
params := newTestParams(t, "sleep", "30000")
188+
params.Ctx = ctx
189+
190+
r, err := runner.GetRunner(params)
191+
require.NoError(t, err)
192+
require.NoError(t, r.Prepare())
193+
194+
done := make(chan error, 1)
195+
go func() {
196+
done <- r.Run()
197+
}()
198+
199+
// Give the process time to start
200+
time.Sleep(200 * time.Millisecond)
201+
cancel()
202+
203+
select {
204+
case <-done:
205+
// Run returned promptly after cancellation
206+
case <-time.After(5 * time.Second):
207+
t.Fatal("Run() did not return within 5 seconds after context cancellation")
208+
}
209+
}
210+
211+
func TestInvalidExecutable(t *testing.T) {
212+
consumer := newTestConsumer(t)
213+
params := runner.RunnerParams{
214+
Consumer: consumer,
215+
Ctx: context.Background(),
216+
FullTargetPath: filepath.Join(t.TempDir(), "nonexistent-binary"),
217+
InstallFolder: t.TempDir(),
218+
}
219+
220+
r, err := runner.GetRunner(params)
221+
if err == nil {
222+
if err = r.Prepare(); err == nil {
223+
err = r.Run()
224+
}
225+
}
226+
require.Error(t, err, "expected an error for nonexistent executable")
227+
}

runner/testdata/testhelper/main.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"strconv"
7+
"time"
8+
)
9+
10+
func main() {
11+
if len(os.Args) < 2 {
12+
fmt.Fprintf(os.Stderr, "usage: testhelper <mode> [args...]\n")
13+
os.Exit(1)
14+
}
15+
16+
mode := os.Args[1]
17+
args := os.Args[2:]
18+
19+
switch mode {
20+
case "echo":
21+
for _, a := range args {
22+
fmt.Println(a)
23+
}
24+
case "env":
25+
for _, name := range args {
26+
fmt.Println(os.Getenv(name))
27+
}
28+
case "output":
29+
for i := 0; i+1 < len(args); i += 2 {
30+
stream := args[i]
31+
msg := args[i+1]
32+
switch stream {
33+
case "stdout":
34+
fmt.Fprintln(os.Stdout, msg)
35+
case "stderr":
36+
fmt.Fprintln(os.Stderr, msg)
37+
}
38+
}
39+
case "exit":
40+
if len(args) != 1 {
41+
fmt.Fprintf(os.Stderr, "exit requires exactly one argument\n")
42+
os.Exit(1)
43+
}
44+
code, err := strconv.Atoi(args[0])
45+
if err != nil {
46+
fmt.Fprintf(os.Stderr, "invalid exit code: %s\n", args[0])
47+
os.Exit(1)
48+
}
49+
os.Exit(code)
50+
case "sleep":
51+
if len(args) != 1 {
52+
fmt.Fprintf(os.Stderr, "sleep requires exactly one argument\n")
53+
os.Exit(1)
54+
}
55+
ms, err := strconv.Atoi(args[0])
56+
if err != nil {
57+
fmt.Fprintf(os.Stderr, "invalid sleep duration: %s\n", args[0])
58+
os.Exit(1)
59+
}
60+
time.Sleep(time.Duration(ms) * time.Millisecond)
61+
case "cwd":
62+
dir, err := os.Getwd()
63+
if err != nil {
64+
fmt.Fprintf(os.Stderr, "getwd: %s\n", err)
65+
os.Exit(1)
66+
}
67+
fmt.Println(dir)
68+
default:
69+
fmt.Fprintf(os.Stderr, "unknown mode: %s\n", mode)
70+
os.Exit(1)
71+
}
72+
}

0 commit comments

Comments
 (0)