github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/integration/fixture_test.go (about)

     1  //go:build integration
     2  // +build integration
     3  
     4  package integration
     5  
     6  import (
     7  	"bytes"
     8  	"context"
     9  	"encoding/json"
    10  	"fmt"
    11  	"go/build"
    12  	"io"
    13  	"io/ioutil"
    14  	"net/http"
    15  	"os"
    16  	"os/exec"
    17  	"path/filepath"
    18  	"runtime"
    19  	"strconv"
    20  	"strings"
    21  	"testing"
    22  	"time"
    23  
    24  	"github.com/pkg/errors"
    25  	"github.com/stretchr/testify/require"
    26  
    27  	"github.com/tilt-dev/tilt/internal/testutils/bufsync"
    28  	"github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1"
    29  )
    30  
    31  var packageDir string
    32  var installed bool
    33  
    34  const namespaceFlag = "-n=tilt-integration"
    35  
    36  func init() {
    37  	_, file, _, ok := runtime.Caller(0)
    38  	if !ok {
    39  		panic(fmt.Errorf("Could not locate path to Tilt integration tests"))
    40  	}
    41  
    42  	packageDir = filepath.Dir(file)
    43  }
    44  
    45  type fixture struct {
    46  	t             *testing.T
    47  	ctx           context.Context
    48  	cancel        func()
    49  	dir           string
    50  	logs          *bufsync.ThreadSafeBuffer
    51  	originalFiles map[string]string
    52  	tilt          *TiltDriver
    53  	activeTiltUp  *TiltUpResponse
    54  	tearingDown   bool
    55  	skipTiltDown  bool
    56  }
    57  
    58  func newFixture(t *testing.T, dir string) *fixture {
    59  	if dir == "" {
    60  		// test doesn't require any in-repo assets, so chdir to a tempdir
    61  		// to prevent accidentally overwriting repo files with Tilt commands
    62  		dir = t.TempDir()
    63  	} else {
    64  		// checking for `..` is heavy-handed, but there's no valid reason for
    65  		// an integration test to use it
    66  		if filepath.IsAbs(dir) || strings.Contains(dir, "..") {
    67  			t.Fatalf("dir %q should be a relative path under the integration/ directory", dir)
    68  		}
    69  		dir = filepath.Join(packageDir, dir)
    70  	}
    71  	err := os.Chdir(dir)
    72  	if err != nil {
    73  		t.Fatal(err)
    74  	}
    75  
    76  	client := NewTiltDriver(t, TiltDriverUseRandomFreePort)
    77  	client.Environ["TILT_DISABLE_ANALYTICS"] = "true"
    78  
    79  	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
    80  	f := &fixture{
    81  		t:             t,
    82  		ctx:           ctx,
    83  		cancel:        cancel,
    84  		dir:           dir,
    85  		logs:          bufsync.NewThreadSafeBuffer(),
    86  		originalFiles: make(map[string]string),
    87  		tilt:          client,
    88  	}
    89  
    90  	if !installed {
    91  		// Install tilt on the first test run.
    92  		f.installTilt()
    93  		installed = true
    94  	}
    95  
    96  	t.Cleanup(f.TearDown)
    97  	return f
    98  }
    99  
   100  func (f *fixture) testDirPath(s string) string {
   101  	return filepath.Join(f.dir, s)
   102  }
   103  
   104  func (f *fixture) installTilt() {
   105  	f.t.Helper()
   106  	// use the current GOROOT to pick which Go to build with
   107  	goBin := filepath.Join(build.Default.GOROOT, "bin", "go")
   108  	cmd := exec.CommandContext(f.ctx, goBin, "install", "-mod", "vendor", "github.com/tilt-dev/tilt/cmd/tilt")
   109  	cmd.Dir = packageDir
   110  	f.runOrFail(cmd, "Building tilt")
   111  }
   112  
   113  func (f *fixture) runOrFail(cmd *exec.Cmd, msg string) {
   114  	f.t.Helper()
   115  	// Use Output() instead of Run() because that captures Stderr in the ExitError.
   116  	_, err := cmd.Output()
   117  	if err == nil {
   118  		return
   119  	}
   120  
   121  	exitErr, isExitErr := err.(*exec.ExitError)
   122  	if isExitErr {
   123  		f.t.Fatalf("%s\nError: %v\nStderr:\n%s\n", msg, err, string(exitErr.Stderr))
   124  		return
   125  	}
   126  	f.t.Fatalf("%s. Error: %v", msg, err)
   127  }
   128  
   129  func (f *fixture) DumpLogs() {
   130  	_, _ = os.Stdout.Write([]byte(f.logs.String()))
   131  }
   132  
   133  func (f *fixture) Curl(url string) (int, string, error) {
   134  	resp, err := http.Get(url)
   135  	if err != nil {
   136  		return -1, "", errors.Wrap(err, "Curl")
   137  	}
   138  	defer resp.Body.Close()
   139  
   140  	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
   141  		f.t.Errorf("Error fetching %s: %s", url, resp.Status)
   142  	}
   143  
   144  	body, err := ioutil.ReadAll(resp.Body)
   145  	if err != nil {
   146  		return -1, "", errors.Wrap(err, "Curl")
   147  	}
   148  	return resp.StatusCode, string(body), nil
   149  }
   150  
   151  func (f *fixture) CurlUntil(ctx context.Context, url string, expectedContents string) {
   152  	f.t.Helper()
   153  	f.WaitUntil(ctx, fmt.Sprintf("curl(%s)", url), func() (string, error) {
   154  		_, body, err := f.Curl(url)
   155  		return body, err
   156  	}, expectedContents)
   157  }
   158  
   159  func (f *fixture) CurlUntilStatusCode(ctx context.Context, url string, expectedStatusCode int) {
   160  	f.t.Helper()
   161  	const prefix = "HTTP Status Code: "
   162  	f.WaitUntil(ctx, fmt.Sprintf("curl(%s)", url), func() (string, error) {
   163  		code, _, err := f.Curl(url)
   164  		return prefix + strconv.Itoa(code), err
   165  	}, prefix+strconv.Itoa(expectedStatusCode))
   166  }
   167  
   168  func (f *fixture) WaitUntil(ctx context.Context, msg string, fun func() (string, error), expectedContents string) {
   169  	f.t.Helper()
   170  	for {
   171  		actualContents, err := fun()
   172  		if err == nil && strings.Contains(actualContents, expectedContents) {
   173  			return
   174  		}
   175  
   176  		select {
   177  		case <-f.activeTiltDone():
   178  			f.t.Fatalf("Tilt died while waiting: %v", f.activeTiltErr())
   179  		case <-ctx.Done():
   180  			f.t.Fatalf("Timed out waiting for expected result (%s)\n"+
   181  				"Expected: %s\n"+
   182  				"Actual: %s\n"+
   183  				"Current error: %v\n",
   184  				msg, expectedContents, actualContents, err)
   185  		case <-time.After(200 * time.Millisecond):
   186  		}
   187  	}
   188  }
   189  
   190  func (f *fixture) activeTiltDone() <-chan struct{} {
   191  	if f.activeTiltUp != nil {
   192  		return f.activeTiltUp.Done()
   193  	}
   194  	neverDone := make(chan struct{})
   195  	return neverDone
   196  }
   197  
   198  func (f *fixture) activeTiltErr() error {
   199  	if f.activeTiltUp != nil {
   200  		return f.activeTiltUp.Err()
   201  	}
   202  	return nil
   203  }
   204  
   205  func (f *fixture) LogWriter() io.Writer {
   206  	return io.MultiWriter(f.logs, os.Stdout)
   207  }
   208  
   209  func (f *fixture) TiltCI(args ...string) {
   210  	err := f.tilt.CI(f.ctx, f.LogWriter(), args...)
   211  	if err != nil {
   212  		f.t.Fatalf("TiltCI: %v", err)
   213  	}
   214  }
   215  
   216  func (f *fixture) TiltUp(args ...string) {
   217  	response, err := f.tilt.Up(f.ctx, UpCommandUp, f.LogWriter(), args...)
   218  	if err != nil {
   219  		f.t.Fatalf("TiltUp: %v", err)
   220  	}
   221  	f.activeTiltUp = response
   222  }
   223  
   224  func (f *fixture) TiltDemo(args ...string) {
   225  	response, err := f.tilt.Up(f.ctx, UpCommandDemo, f.LogWriter(), args...)
   226  	if err != nil {
   227  		f.t.Fatalf("TiltDemo: %v", err)
   228  	}
   229  	f.activeTiltUp = response
   230  }
   231  
   232  func (f *fixture) TiltSession() v1alpha1.Session {
   233  	response, err := f.tilt.Get(f.ctx, "session", "Tiltfile")
   234  	require.NoError(f.t, err, "error getting Tiltfile session")
   235  	result := v1alpha1.Session{}
   236  	decoder := json.NewDecoder(bytes.NewReader(response))
   237  	decoder.DisallowUnknownFields()
   238  	err = decoder.Decode(&result)
   239  	require.NoError(f.t, err)
   240  	return result
   241  }
   242  
   243  func (f *fixture) TargetStatus(name string) v1alpha1.Target {
   244  	var targetNames []string
   245  	sess := f.TiltSession()
   246  	for _, target := range sess.Status.Targets {
   247  		if target.Name == name {
   248  			return target
   249  		}
   250  		targetNames = append(targetNames, target.Name)
   251  	}
   252  	f.t.Fatalf("No target named %s. Targets in session: %v\n", name, targetNames)
   253  	return v1alpha1.Target{}
   254  }
   255  
   256  func (f *fixture) Touch(fileBaseName string) {
   257  	f.t.Helper()
   258  	filename := f.testDirPath(fileBaseName)
   259  	_, err := os.Stat(filename)
   260  	if os.IsNotExist(err) {
   261  		file, err := os.Create(filename)
   262  		require.NoError(f.t, err, "Failed to create %q", filename)
   263  		_ = file.Close()
   264  		f.t.Cleanup(
   265  			func() {
   266  				if err := os.Remove(filename); err != nil && !os.IsNotExist(err) {
   267  					f.t.Fatalf("Failed to remove created file %q: %v", filename, err)
   268  				}
   269  			})
   270  	} else {
   271  		now := time.Now().Local()
   272  		err := os.Chtimes(filename, now, now)
   273  		require.NoError(f.t, err, "Failed to update times on %q", filename)
   274  	}
   275  }
   276  
   277  func (f *fixture) ReplaceContents(fileBaseName, original, replacement string) {
   278  	f.t.Helper()
   279  	file := f.testDirPath(fileBaseName)
   280  	contentsBytes, err := ioutil.ReadFile(file)
   281  	if err != nil {
   282  		f.t.Fatal(err)
   283  	}
   284  
   285  	contents := string(contentsBytes)
   286  	_, hasStoredContents := f.originalFiles[file]
   287  	if !hasStoredContents {
   288  		f.originalFiles[file] = contents
   289  	}
   290  
   291  	newContents := strings.ReplaceAll(contents, original, replacement)
   292  	if newContents == contents {
   293  		f.t.Fatalf("Could not find contents %q to replace in file %s: %s", original, fileBaseName, contents)
   294  	}
   295  
   296  	err = ioutil.WriteFile(file, []byte(newContents), os.FileMode(0777))
   297  	if err != nil {
   298  		f.t.Fatal(err)
   299  	}
   300  }
   301  
   302  func (f *fixture) StartTearDown() {
   303  	if f.tearingDown {
   304  		return
   305  	}
   306  
   307  	isTiltStillUp := f.activeTiltUp != nil && f.activeTiltUp.Err() == nil
   308  	if f.t.Failed() && isTiltStillUp {
   309  		fmt.Printf("Test failed, dumping internals\n----\n")
   310  		fmt.Printf("Engine\n----\n")
   311  		err := f.tilt.DumpEngine(f.ctx, os.Stdout)
   312  		if err != nil {
   313  			fmt.Printf("Error dumping engine: %v", err)
   314  		}
   315  
   316  		fmt.Printf("\n----\nAPI Server\n----\n")
   317  		apiTypes, err := f.tilt.APIResources(f.ctx)
   318  		if err != nil {
   319  			fmt.Printf("Error determining available API resources: %v\n", err)
   320  		} else {
   321  			for _, apiType := range apiTypes {
   322  				fmt.Printf("\n----\n%s\n----\n", strings.ToUpper(apiType))
   323  				getOut, err := f.tilt.Get(f.ctx, apiType)
   324  				fmt.Print(string(getOut))
   325  				if err != nil {
   326  					fmt.Printf("Error getting %s: %v", apiType, err)
   327  				}
   328  				fmt.Printf("\n----\n")
   329  			}
   330  		}
   331  
   332  		err = f.activeTiltUp.KillAndDumpThreads()
   333  		if err != nil {
   334  			fmt.Printf("error killing tilt: %v\n", err)
   335  		}
   336  	}
   337  
   338  	f.tearingDown = true
   339  }
   340  
   341  func (f *fixture) KillProcs() {
   342  	if f.activeTiltUp != nil {
   343  		err := f.activeTiltUp.TriggerExit()
   344  		if err != nil && err.Error() != "os: process already finished" {
   345  			fmt.Printf("error killing tilt: %v\n", err)
   346  		}
   347  	}
   348  }
   349  
   350  func (f *fixture) TearDown() {
   351  	f.StartTearDown()
   352  
   353  	// give `tilt up` a chance to exit gracefully
   354  	// (once the context is canceled, it will be immediately SIGKILL'd)
   355  	f.KillProcs()
   356  	f.cancel()
   357  	f.ctx = context.Background()
   358  
   359  	// This is a hack.
   360  	//
   361  	// Deleting a namespace is slow. Doing it on every test case makes
   362  	// the tests more accurate. We believe that in this particular case,
   363  	// the trade-off of speed over accuracy is worthwhile, so
   364  	// we add this hack so that we can `tilt down` without deleting
   365  	// the namespace.
   366  	//
   367  	// Each Tiltfile reads this environment variable, and skips loading the namespace
   368  	// into Tilt, so that Tilt doesn't delete it.
   369  	//
   370  	// If users want to do the same thing in practice, it might be worth
   371  	// adding better in-product hooks (e.g., `tilt down --preserve-namespace`),
   372  	// or more scriptability in the Tiltfile.
   373  	f.tilt.Environ["SKIP_NAMESPACE"] = "true"
   374  
   375  	if !f.skipTiltDown {
   376  		ctx, cancel := context.WithTimeout(f.ctx, 30*time.Second)
   377  		defer cancel()
   378  		err := f.tilt.Down(ctx, os.Stdout)
   379  		if err != nil {
   380  			f.t.Errorf("Running tilt down: %v", err)
   381  		}
   382  	}
   383  
   384  	for k, v := range f.originalFiles {
   385  		_ = ioutil.WriteFile(k, []byte(v), os.FileMode(0777))
   386  	}
   387  }