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  }