github.com/tilt-dev/tilt@v0.36.0/internal/controllers/core/cmd/execer_test.go (about) 1 package cmd 2 3 import ( 4 "context" 5 "io" 6 "os/exec" 7 "runtime" 8 "strings" 9 "testing" 10 "time" 11 12 "github.com/stretchr/testify/assert" 13 "github.com/stretchr/testify/require" 14 15 "github.com/tilt-dev/tilt/internal/localexec" 16 "github.com/tilt-dev/tilt/internal/testutils" 17 "github.com/tilt-dev/tilt/internal/testutils/bufsync" 18 "github.com/tilt-dev/tilt/internal/testutils/tempdir" 19 "github.com/tilt-dev/tilt/pkg/logger" 20 "github.com/tilt-dev/tilt/pkg/model" 21 ) 22 23 func TestTrue(t *testing.T) { 24 f := newProcessExecFixture(t) 25 26 f.start("exit 0") 27 28 f.assertCmdSucceeds() 29 } 30 31 func TestWorkdir(t *testing.T) { 32 f := newProcessExecFixture(t) 33 34 d := tempdir.NewTempDirFixture(t) 35 36 cmd := "pwd" 37 if runtime.GOOS == "windows" { 38 cmd = "cd" 39 } 40 41 f.startWithWorkdir(cmd, d.Path()) 42 43 f.assertCmdSucceeds() 44 f.assertLogContains(d.Path()) 45 } 46 47 func TestSleep(t *testing.T) { 48 f := newProcessExecFixture(t) 49 50 cmd := "sleep 1" 51 if runtime.GOOS == "windows" { 52 // believe it or not, this is the idiomatic way to sleep on windows 53 // https://www.ibm.com/support/pages/timeout-command-run-batch-job-exits-immediately-and-returns-error-input-redirection-not-supported-exiting-process-immediately 54 cmd = "ping -n 1 127.0.0.1" 55 } 56 f.start(cmd) 57 58 f.waitForStatus(Running) 59 60 time.Sleep(time.Second) 61 62 f.assertCmdSucceeds() 63 } 64 65 func TestShutdownOnCancel(t *testing.T) { 66 if runtime.GOOS == "windows" { 67 t.Skip("no bash on windows") 68 } 69 f := newProcessExecFixture(t) 70 71 cmd := ` 72 function cleanup() 73 { 74 echo "cleanup time!" 75 } 76 77 trap cleanup EXIT 78 sleep 100 79 ` 80 f.start(cmd) 81 f.cancel() 82 f.waitForStatus(Done) 83 f.assertLogContains("cleanup time") 84 } 85 86 func TestPrintsLogs(t *testing.T) { 87 f := newProcessExecFixture(t) 88 89 f.start("echo testing123456") 90 f.assertCmdSucceeds() 91 f.assertLogContains("testing123456") 92 } 93 94 func TestHandlesExits(t *testing.T) { 95 f := newProcessExecFixture(t) 96 97 f.start("exit 1") 98 99 f.waitForError() 100 f.assertLogContains("exited with exit code 1") 101 } 102 103 func TestStopsGrandchildren(t *testing.T) { 104 if runtime.GOOS == "windows" { 105 t.Skip("no bash on windows") 106 } 107 f := newProcessExecFixture(t) 108 109 f.start("bash -c '(for i in $(seq 1 20); do echo loop$i; sleep 1; done)'") 110 f.waitForStatus(Running) 111 112 // wait until there's log output 113 timeout := time.After(time.Second) 114 for { 115 if strings.Contains(f.testWriter.String(), "loop1") { 116 break 117 } 118 select { 119 case <-timeout: 120 t.Fatal("never saw any process output") 121 case <-time.After(20 * time.Millisecond): 122 } 123 } 124 125 // cancel the context 126 f.cancel() 127 f.waitForStatus(Done) 128 } 129 130 func TestHandlesProcessThatFailsToStart(t *testing.T) { 131 f := newProcessExecFixture(t) 132 133 f.startMalformedCommand() 134 f.waitForError() 135 f.assertLogContains("failed to start: ") 136 } 137 138 func TestExecEmpty(t *testing.T) { 139 f := newProcessExecFixture(t) 140 141 f.start("") 142 f.waitForError() 143 f.assertLogContains("empty cmd") 144 } 145 146 func TestExecCmd(t *testing.T) { 147 testCases := execTestCases() 148 149 l := logger.NewLogger(logger.NoneLvl, io.Discard) 150 151 for _, tc := range testCases { 152 t.Run(tc.name, func(t *testing.T) { 153 c, err := localexec.EmptyEnv().ExecCmd(tc.cmd, l) 154 require.NoError(t, err) 155 assertCommandEqual(t, tc.cmd, c) 156 }) 157 } 158 } 159 160 type execTestCase struct { 161 name string 162 cmd model.Cmd 163 } 164 165 func execTestCases() []execTestCase { 166 // these need to appear as actual paths or exec.Command will attempt to resolve them 167 // (their actual existence is irrelevant since they won't actually execute; similarly, 168 // it won't matter that they're unix paths even on Windows) 169 return []execTestCase{ 170 {"command only", model.Cmd{Argv: []string{"/bin/ls"}}}, 171 {"command array", model.Cmd{Argv: []string{"/bin/echo", "hi"}}}, 172 {"current working directory", model.Cmd{Argv: []string{"/bin/echo", "hi"}, Dir: "/foo"}}, 173 {"env", model.Cmd{Argv: []string{"/bin/echo", "hi"}, Env: []string{"FOO=bar"}}}, 174 } 175 } 176 177 func assertCommandEqual(t *testing.T, expected model.Cmd, actual *exec.Cmd) { 178 t.Helper() 179 assert.Equal(t, expected.Argv[0], actual.Path) 180 assert.Equal(t, expected.Argv, actual.Args) 181 assert.Equal(t, expected.Dir, actual.Dir) 182 for _, e := range expected.Env { 183 assert.Contains(t, actual.Env, e) 184 } 185 } 186 187 type processExecFixture struct { 188 t *testing.T 189 ctx context.Context 190 cancel context.CancelFunc 191 execer *processExecer 192 testWriter *bufsync.ThreadSafeBuffer 193 statusCh chan statusAndMetadata 194 } 195 196 func newProcessExecFixture(t *testing.T) *processExecFixture { 197 execer := NewProcessExecer(localexec.EmptyEnv()) 198 execer.gracePeriod = time.Second 199 testWriter := bufsync.NewThreadSafeBuffer() 200 ctx, _, _ := testutils.ForkedCtxAndAnalyticsForTest(testWriter) 201 ctx, cancel := context.WithCancel(ctx) 202 203 ret := &processExecFixture{ 204 t: t, 205 ctx: ctx, 206 cancel: cancel, 207 execer: execer, 208 testWriter: testWriter, 209 } 210 211 t.Cleanup(ret.tearDown) 212 return ret 213 } 214 215 func (f *processExecFixture) tearDown() { 216 f.cancel() 217 } 218 219 func (f *processExecFixture) startMalformedCommand() { 220 c := model.Cmd{Argv: []string{"\""}, Dir: "."} 221 f.statusCh = f.execer.Start(f.ctx, c, f.testWriter) 222 } 223 224 func (f *processExecFixture) startWithWorkdir(cmd string, workdir string) { 225 c := model.ToHostCmd(cmd) 226 c.Dir = workdir 227 f.statusCh = f.execer.Start(f.ctx, c, f.testWriter) 228 } 229 230 func (f *processExecFixture) start(cmd string) { 231 f.startWithWorkdir(cmd, ".") 232 } 233 234 func (f *processExecFixture) assertCmdSucceeds() { 235 f.waitForStatus(Done) 236 } 237 238 func (f *processExecFixture) waitForStatus(expectedStatus status) { 239 deadlineCh := time.After(2 * time.Second) 240 for { 241 select { 242 case sm, ok := <-f.statusCh: 243 if !ok { 244 f.t.Fatal("statusCh closed") 245 } 246 if expectedStatus == sm.status { 247 return 248 } 249 if sm.status == Error { 250 f.t.Error("Unexpected Error") 251 return 252 } 253 if sm.status == Done { 254 f.t.Error("Unexpected Done") 255 return 256 } 257 case <-deadlineCh: 258 f.t.Fatal("Timed out waiting for cmd sm") 259 } 260 } 261 } 262 263 func (f *processExecFixture) assertLogContains(s string) { 264 require.Eventuallyf(f.t, func() bool { 265 return strings.Contains(f.testWriter.String(), s) 266 }, time.Second, 5*time.Millisecond, "log contains %q", s) 267 } 268 269 func (f *processExecFixture) waitForError() { 270 f.waitForStatus(Error) 271 }