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 }