|
| 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 | +} |
0 commit comments