github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/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 exit 1 76 } 77 78 trap cleanup EXIT 79 sleep 100 80 ` 81 f.start(cmd) 82 f.cancel() 83 84 time.Sleep(time.Second) 85 f.waitForStatus(Done) 86 f.assertLogContains("cleanup time") 87 } 88 89 func TestPrintsLogs(t *testing.T) { 90 f := newProcessExecFixture(t) 91 92 f.start("echo testing123456") 93 f.assertCmdSucceeds() 94 f.assertLogContains("testing123456") 95 } 96 97 func TestHandlesExits(t *testing.T) { 98 f := newProcessExecFixture(t) 99 100 f.start("exit 1") 101 102 f.waitForError() 103 f.assertLogContains("exited with exit code 1") 104 } 105 106 func TestStopsGrandchildren(t *testing.T) { 107 if runtime.GOOS == "windows" { 108 t.Skip("no bash on windows") 109 } 110 f := newProcessExecFixture(t) 111 112 f.start("bash -c '(for i in $(seq 1 20); do echo loop$i; sleep 1; done)'") 113 f.waitForStatus(Running) 114 115 // wait until there's log output 116 timeout := time.After(time.Second) 117 for { 118 if strings.Contains(f.testWriter.String(), "loop1") { 119 break 120 } 121 select { 122 case <-timeout: 123 t.Fatal("never saw any process output") 124 case <-time.After(20 * time.Millisecond): 125 } 126 } 127 128 // cancel the context 129 f.cancel() 130 f.waitForStatus(Done) 131 } 132 133 func TestHandlesProcessThatFailsToStart(t *testing.T) { 134 f := newProcessExecFixture(t) 135 136 f.startMalformedCommand() 137 f.waitForError() 138 f.assertLogContains("failed to start: ") 139 } 140 141 func TestExecEmpty(t *testing.T) { 142 f := newProcessExecFixture(t) 143 144 f.start("") 145 f.waitForError() 146 f.assertLogContains("empty cmd") 147 } 148 149 func TestExecCmd(t *testing.T) { 150 testCases := execTestCases() 151 152 l := logger.NewLogger(logger.NoneLvl, io.Discard) 153 154 for _, tc := range testCases { 155 t.Run(tc.name, func(t *testing.T) { 156 c, err := localexec.EmptyEnv().ExecCmd(tc.cmd, l) 157 require.NoError(t, err) 158 assertCommandEqual(t, tc.cmd, c) 159 }) 160 } 161 } 162 163 type execTestCase struct { 164 name string 165 cmd model.Cmd 166 } 167 168 func execTestCases() []execTestCase { 169 // these need to appear as actual paths or exec.Command will attempt to resolve them 170 // (their actual existence is irrelevant since they won't actually execute; similarly, 171 // it won't matter that they're unix paths even on Windows) 172 return []execTestCase{ 173 {"command only", model.Cmd{Argv: []string{"/bin/ls"}}}, 174 {"command array", model.Cmd{Argv: []string{"/bin/echo", "hi"}}}, 175 {"current working directory", model.Cmd{Argv: []string{"/bin/echo", "hi"}, Dir: "/foo"}}, 176 {"env", model.Cmd{Argv: []string{"/bin/echo", "hi"}, Env: []string{"FOO=bar"}}}, 177 } 178 } 179 180 func assertCommandEqual(t *testing.T, expected model.Cmd, actual *exec.Cmd) { 181 t.Helper() 182 assert.Equal(t, expected.Argv[0], actual.Path) 183 assert.Equal(t, expected.Argv, actual.Args) 184 assert.Equal(t, expected.Dir, actual.Dir) 185 for _, e := range expected.Env { 186 assert.Contains(t, actual.Env, e) 187 } 188 } 189 190 type processExecFixture struct { 191 t *testing.T 192 ctx context.Context 193 cancel context.CancelFunc 194 execer *processExecer 195 testWriter *bufsync.ThreadSafeBuffer 196 statusCh chan statusAndMetadata 197 } 198 199 func newProcessExecFixture(t *testing.T) *processExecFixture { 200 execer := NewProcessExecer(localexec.EmptyEnv()) 201 execer.gracePeriod = time.Second 202 testWriter := bufsync.NewThreadSafeBuffer() 203 ctx, _, _ := testutils.ForkedCtxAndAnalyticsForTest(testWriter) 204 ctx, cancel := context.WithCancel(ctx) 205 206 ret := &processExecFixture{ 207 t: t, 208 ctx: ctx, 209 cancel: cancel, 210 execer: execer, 211 testWriter: testWriter, 212 } 213 214 t.Cleanup(ret.tearDown) 215 return ret 216 } 217 218 func (f *processExecFixture) tearDown() { 219 f.cancel() 220 } 221 222 func (f *processExecFixture) startMalformedCommand() { 223 c := model.Cmd{Argv: []string{"\""}, Dir: "."} 224 f.statusCh = f.execer.Start(f.ctx, c, f.testWriter) 225 } 226 227 func (f *processExecFixture) startWithWorkdir(cmd string, workdir string) { 228 c := model.ToHostCmd(cmd) 229 c.Dir = workdir 230 f.statusCh = f.execer.Start(f.ctx, c, f.testWriter) 231 } 232 233 func (f *processExecFixture) start(cmd string) { 234 f.startWithWorkdir(cmd, ".") 235 } 236 237 func (f *processExecFixture) assertCmdSucceeds() { 238 f.waitForStatus(Done) 239 } 240 241 func (f *processExecFixture) waitForStatus(expectedStatus status) { 242 deadlineCh := time.After(2 * time.Second) 243 for { 244 select { 245 case sm, ok := <-f.statusCh: 246 if !ok { 247 f.t.Fatal("statusCh closed") 248 } 249 if expectedStatus == sm.status { 250 return 251 } 252 if sm.status == Error { 253 f.t.Error("Unexpected Error") 254 return 255 } 256 if sm.status == Done { 257 f.t.Error("Unexpected Done") 258 return 259 } 260 case <-deadlineCh: 261 f.t.Fatal("Timed out waiting for cmd sm") 262 } 263 } 264 } 265 266 func (f *processExecFixture) assertLogContains(s string) { 267 require.Eventuallyf(f.t, func() bool { 268 return strings.Contains(f.testWriter.String(), s) 269 }, time.Second, 5*time.Millisecond, "log contains %q", s) 270 } 271 272 func (f *processExecFixture) waitForError() { 273 f.waitForStatus(Error) 274 }