Skip to content

Commit d0e4c23

Browse files
authored
boxcli: add basic tests for devbox shell (#83)
Add tests that check that `devbox shell` launches successfully on the platforms (Linux and macOS) and shells (bash, dash, and zsh) that we support. Specifically, each test runs the equivalent of typing the following into your terminal: cd `mktemp -d` devbox init devbox add hello devbox shell hello exit exit The bulk of this change is the addition of some test helpers that handle launching shells and interacting with them. For now they live in the same file as the `boxcli` shell tests, but we can move them into their own `shelltest` package later on if necessary. With these test helpers in place, it should be a lot easier to add tests for more complicated scenarios. In order to run these tests in CI, the GitHub test jobs now install Nix along with some additional shells. They also run on both ubuntu-latest and macos-12 to make sure things work on both systems. The lint job also runs on Ubuntu and macOS to make sure that we don't inadvertently add platform-specific code that won't compile.
1 parent 5b494ed commit d0e4c23

File tree

4 files changed

+336
-9
lines changed

4 files changed

+336
-9
lines changed

.github/workflows/tests.yaml

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@ permissions:
1616

1717
jobs:
1818
golangci-lint:
19-
runs-on: ubuntu-latest
19+
strategy:
20+
matrix:
21+
os: [ubuntu-latest, macos-12]
22+
runs-on: ${{ matrix.os }}
2023
steps:
2124
- uses: actions/checkout@v3
22-
- name: Set up go
23-
uses: actions/setup-go@v3
25+
- uses: actions/setup-go@v3
2426
with:
2527
go-version-file: ./go.mod
2628
cache: false # use golangci cache instead
@@ -29,15 +31,52 @@ jobs:
2931
with:
3032
args: --timeout=10m
3133

32-
golang-tests:
34+
test-linux:
3335
runs-on: ubuntu-latest
36+
needs: golangci-lint
37+
steps:
38+
- uses: actions/checkout@v3
39+
- uses: actions/setup-go@v3
40+
with:
41+
go-version-file: ./go.mod
42+
cache: true
43+
- name: Build devbox
44+
run: go install ./cmd/devbox
45+
- name: Install additional shells
46+
run: |
47+
sudo apt-get update
48+
sudo apt-get install dash zsh
49+
- name: Install Nix
50+
run: sh <(curl -L https://nixos.org/nix/install) --daemon
51+
- name: Run tests
52+
run: |
53+
. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
54+
go test ./boxcli
55+
56+
test-darwin:
57+
runs-on: macos-12
58+
needs: golangci-lint
3459
steps:
3560
- uses: actions/checkout@v3
3661
- uses: actions/setup-go@v3
3762
with:
3863
go-version-file: ./go.mod
3964
cache: true
40-
- name: Build the module
41-
run: go build -v ./...
42-
- name: Run all tests
43-
run: go test -v ./...
65+
- name: Build devbox
66+
run: go install ./cmd/devbox
67+
- name: Install additional shells
68+
env:
69+
HOMEBREW_NO_ANALYTICS: 1
70+
HOMEBREW_NO_AUTO_UPDATE: 1
71+
HOMEBREW_NO_EMOJI: 1
72+
HOMEBREW_NO_ENV_HINTS: 1
73+
HOMEBREW_NO_INSTALL_CLEANUP: 1
74+
run: |
75+
brew update
76+
brew install dash zsh
77+
- name: Install Nix
78+
run: sh <(curl -L https://nixos.org/nix/install) --daemon
79+
- name: Run tests
80+
run: |
81+
. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
82+
go test ./boxcli

.golangci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ linters-settings:
4848
- ns string
4949
- r *http.Request
5050
- sh *Shell
51+
- sh *shell
5152
- sh *shell.Shell
53+
- sh shell
5254
- t testing.T
5355
- w http.ResponseWriter
5456
- w io.Writer

boxcli/shell_test.go

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
// Copyright 2022 Jetpack Technologies Inc and contributors. All rights reserved.
2+
// Use of this source code is governed by the license in the LICENSE file.
3+
package boxcli
4+
5+
import (
6+
"bufio"
7+
"errors"
8+
"fmt"
9+
"io"
10+
"os"
11+
"os/exec"
12+
"strconv"
13+
"strings"
14+
"testing"
15+
"time"
16+
)
17+
18+
func testShellHello(t *testing.T, name string) {
19+
// Skip this test if the required shell isn't installed, unless we're
20+
// running in CI.
21+
ci, _ := strconv.ParseBool(os.Getenv("CI"))
22+
if _, err := exec.LookPath(name); err != nil && !ci {
23+
t.Skipf("Skipping because %s isn't installed or in your PATH.", name)
24+
}
25+
26+
sh := newShell(t, name)
27+
sh.parentIO.write(t, "devbox init")
28+
sh.parentIO.write(t, "devbox add hello")
29+
sh.startDevboxShell(t)
30+
31+
sh.devboxIO.write(t, `echo "My name is: $0"`)
32+
out := sh.devboxIO.read(t)
33+
if !strings.HasSuffix(out, name) {
34+
t.Errorf("Shell says its name is %q, but want it to contain %q.", out, name)
35+
}
36+
37+
sh.devboxIO.write(t, "hello")
38+
out = sh.devboxIO.read(t)
39+
want := "Hello, world!"
40+
if out != "Hello, world!" {
41+
t.Errorf("Got hello command output %q, want %q.", out, want)
42+
}
43+
}
44+
45+
func TestShellHelloBash(t *testing.T) { testShellHello(t, "bash") }
46+
func TestShellHelloDash(t *testing.T) { testShellHello(t, "dash") }
47+
func TestShellHelloZsh(t *testing.T) { testShellHello(t, "zsh") }
48+
49+
const (
50+
// shellMaxStartupReads is the maximum number of lines to read when
51+
// waiting for a shell prompt.
52+
shellMaxStartupReads = 10_000
53+
54+
shellReadTimeout = 2 * time.Minute
55+
shellWriteTimeout = 2 * time.Minute
56+
)
57+
58+
// shellIO allows tests to write input and read output to and from a shell.
59+
type shellIO struct {
60+
// errPrefix is an arbitrary string to include in test errors and logs.
61+
errPrefix string
62+
63+
// inR and inW are the read and write ends of the shell's standard input
64+
// pipe.
65+
inR *os.File
66+
inW *os.File
67+
68+
// outR and outW are the read and write ends of the shell's standard
69+
// output and error pipe.
70+
outR *os.File
71+
outW *os.File
72+
73+
// out buffers outR for delimiting lines.
74+
out *bufio.Reader
75+
}
76+
77+
// newShellIO creates the necessary pipes for communicating with a shell. Test
78+
// errors and logs will include the provided prefix to help differentiate
79+
// between multiple shells in a single test.
80+
func newShellIO(t *testing.T, errPrefix string) shellIO {
81+
t.Helper()
82+
shio := shellIO{errPrefix: errPrefix}
83+
84+
var err error
85+
shio.inR, shio.inW, err = os.Pipe()
86+
if err != nil {
87+
t.Fatal("Error creating shell input pipe:", err)
88+
}
89+
t.Cleanup(func() {
90+
shio.inR.Close()
91+
shio.inW.Close()
92+
})
93+
94+
shio.outR, shio.outW, err = os.Pipe()
95+
if err != nil {
96+
t.Fatal("Error creating shell output pipe:", err)
97+
}
98+
t.Cleanup(func() {
99+
shio.outR.Close()
100+
shio.outW.Close()
101+
})
102+
shio.out = bufio.NewReader(shio.outR)
103+
return shio
104+
}
105+
106+
// read reads a single line of output from the shell. It strips any leading or
107+
// trailing whitespace, including the trailing newline.
108+
func (s shellIO) read(t *testing.T) string {
109+
t.Helper()
110+
111+
start := time.Now()
112+
err := s.outR.SetReadDeadline(start.Add(shellReadTimeout))
113+
if err != nil {
114+
t.Fatalf("%s/read(%s): error setting timeout: %v", s.errPrefix, time.Since(start), err)
115+
}
116+
defer func() {
117+
err := s.outR.SetReadDeadline(time.Time{})
118+
if err != nil {
119+
t.Fatalf("%s/read(%s): error resetting timeout: %v", s.errPrefix, time.Since(start), err)
120+
}
121+
}()
122+
123+
line, err := s.out.ReadString('\n')
124+
if err != nil {
125+
if errors.Is(err, os.ErrDeadlineExceeded) {
126+
t.Fatalf("%s/read(%s): timed out after %s", s.errPrefix, time.Since(start), shellReadTimeout)
127+
}
128+
t.Fatalf("%s/read(%s): error: %v", s.errPrefix, time.Since(start), err)
129+
}
130+
line = strings.TrimSpace(line)
131+
t.Logf("%s/read(%s): %s", s.errPrefix, time.Since(start), line)
132+
return line
133+
}
134+
135+
// write writes one ore more lines of input to the shell. It strips any leading
136+
// or trailing whitespace and ensures that there is a single trailing newline
137+
// before writing.
138+
func (s shellIO) write(t *testing.T, line string) {
139+
t.Helper()
140+
141+
start := time.Now()
142+
err := s.outR.SetWriteDeadline(start.Add(shellWriteTimeout))
143+
if err != nil {
144+
t.Fatalf("%s/write(%s): error setting timeout: %v", s.errPrefix, time.Since(start), err)
145+
}
146+
defer func() {
147+
err := s.outR.SetWriteDeadline(time.Time{})
148+
if err != nil {
149+
t.Fatalf("%s/write(%s): error resetting timeout: %v", s.errPrefix, time.Since(start), err)
150+
}
151+
}()
152+
153+
line = strings.TrimSpace(line) + "\n"
154+
_, err = io.WriteString(s.inW, line)
155+
if err != nil {
156+
if errors.Is(err, os.ErrDeadlineExceeded) {
157+
t.Fatalf("%s/write(%s): timed out after %s", s.errPrefix, time.Since(start), shellWriteTimeout)
158+
}
159+
t.Fatalf("%s/write(%s): error: %v", s.errPrefix, time.Since(start), err)
160+
}
161+
t.Logf("%s/write(%s): %s", s.errPrefix, time.Since(start), line)
162+
}
163+
164+
// writef formats a fmt.Printf string and writes it to the shell.
165+
func (s shellIO) writef(t *testing.T, format string, a ...any) {
166+
t.Helper()
167+
s.write(t, fmt.Sprintf(format, a...))
168+
}
169+
170+
// doneWriting closes the shell's standard input, indicating that the test
171+
// doesn't have any additional input. This will also cause the shell to exit
172+
// after its last command terminates.
173+
func (s shellIO) doneWriting(t *testing.T) { //nolint:unused
174+
t.Helper()
175+
176+
err := s.inR.Close()
177+
if err != nil {
178+
t.Fatalf("Error closing input reader for %s shell: %v", s.errPrefix, err)
179+
}
180+
}
181+
182+
// close closes the shell's input and output pipes.
183+
func (s shellIO) close(t *testing.T) {
184+
t.Helper()
185+
186+
if err := s.inW.Close(); err != nil && !errors.Is(err, os.ErrClosed) {
187+
t.Fatalf("Error closing input writer for %s shell: %v", s.errPrefix, err)
188+
}
189+
if err := s.inR.Close(); err != nil && !errors.Is(err, os.ErrClosed) {
190+
t.Fatalf("Error closing input reader for %s shell: %v", s.errPrefix, err)
191+
}
192+
if err := s.outR.Close(); err != nil && !errors.Is(err, os.ErrClosed) {
193+
t.Fatalf("Error closing output reader for %s shell: %v", s.errPrefix, err)
194+
}
195+
if err := s.outW.Close(); err != nil && !errors.Is(err, os.ErrClosed) {
196+
t.Fatalf("Error closing output writer for %s shell: %v", s.errPrefix, err)
197+
}
198+
}
199+
200+
// shell controls external shell processes to aid in testing interactive devbox
201+
// shells.
202+
type shell struct {
203+
cmd *exec.Cmd
204+
parentIO shellIO
205+
exited bool
206+
207+
devboxIO shellIO
208+
devboxInFd uintptr
209+
devboxOutFd uintptr
210+
}
211+
212+
// newShell spawns a new shell process. It allocates 2 additional file
213+
// descriptors for use with a devbox subshell.
214+
func newShell(t *testing.T, name string) *shell {
215+
t.Helper()
216+
217+
sh := shell{
218+
parentIO: newShellIO(t, "parent"),
219+
cmd: exec.Command(name, "-s"),
220+
}
221+
sh.cmd.Dir = t.TempDir()
222+
sh.cmd.Stdin = sh.parentIO.inR
223+
sh.cmd.Stdout = sh.parentIO.outW
224+
sh.cmd.Stderr = sh.parentIO.outW
225+
sh.cmd.Env = append(os.Environ(), "SHELL="+name)
226+
227+
// We need to preallocate a pipe for a devbox subshell so that parent
228+
// shell process has the file descriptors to pass to the devbox shell.
229+
//
230+
// The file descriptor for each file in cmd.ExtraFiles becomes its
231+
// index + 1. In startDevBoxShell we execute a command that redirects
232+
// to these descriptors.
233+
sh.devboxIO = newShellIO(t, "devbox")
234+
sh.cmd.ExtraFiles = []*os.File{sh.devboxIO.inR, sh.devboxIO.outW}
235+
sh.devboxInFd = 3
236+
sh.devboxOutFd = 4
237+
238+
if err := sh.cmd.Start(); err != nil {
239+
t.Fatal("Error starting shell:", err)
240+
}
241+
t.Cleanup(func() {
242+
sh.exit(t)
243+
})
244+
return &sh
245+
}
246+
247+
// startDevboxShell writes a command to the parent shell's input to start a new
248+
// devbox subshell. It redirects the subshell's standard streams so that tests
249+
// can communicate with the devbox shell via sh.devboxIO.
250+
//
251+
// After issuing the devbox shell command in the parent shell, startDevboxShell
252+
// writes a test command to the child devbox shell and waits for its output by
253+
// repeatedly calling read. It fails the test if a read times out, or if it
254+
// reads more than shellMaxStartupReads lines without seeing the expected
255+
// output.
256+
func (sh *shell) startDevboxShell(t *testing.T) {
257+
t.Helper()
258+
259+
sh.parentIO.writef(t, "devbox shell <&%d >&%d 2>&1", sh.devboxInFd, sh.devboxOutFd)
260+
echo := "Devbox started successfully!"
261+
sh.devboxIO.writef(t, `echo "%s"`, echo)
262+
263+
i := 0
264+
for i = 0; i < shellMaxStartupReads; i++ {
265+
if strings.Contains(sh.devboxIO.read(t), echo) {
266+
return
267+
}
268+
}
269+
t.Fatalf("Didn't get a devbox shell prompt after reading %d lines.", i)
270+
}
271+
272+
// exit closes the devbox and parent shell IO streams and waits for the parent
273+
// shell to exit.
274+
func (sh *shell) exit(t *testing.T) {
275+
t.Helper()
276+
277+
if sh.exited {
278+
return
279+
}
280+
sh.devboxIO.close(t)
281+
sh.parentIO.close(t)
282+
if err := sh.cmd.Wait(); err != nil {
283+
t.Fatal("Error waiting for shell to exit:", err)
284+
}
285+
sh.exited = true
286+
}

shell/shell.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ func (s *Shell) buildInitFile() ([]byte, error) {
134134
if posthook != "" {
135135
buf.WriteString(`
136136
137-
# Begin Devbox Pre-init Hook
137+
# Begin Devbox Post-init Hook
138138
139139
`)
140140
buf.WriteString(posthook)

0 commit comments

Comments
 (0)