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  }