github.com/tilt-dev/tilt@v0.36.0/internal/tiltfile/tiltextension/plugin_test.go (about) 1 package tiltextension 2 3 import ( 4 "fmt" 5 "path/filepath" 6 "runtime" 7 "strings" 8 "testing" 9 10 "github.com/stretchr/testify/assert" 11 12 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 14 "github.com/tilt-dev/tilt/internal/testutils/tempdir" 15 "github.com/tilt-dev/tilt/internal/tiltfile/include" 16 "github.com/tilt-dev/tilt/internal/tiltfile/starkit" 17 tiltfilev1alpha1 "github.com/tilt-dev/tilt/internal/tiltfile/v1alpha1" 18 "github.com/tilt-dev/tilt/pkg/apis" 19 "github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1" 20 ) 21 22 func TestFetchableAlreadyPresentWorks(t *testing.T) { 23 f := newExtensionFixture(t) 24 25 f.tiltfile(` 26 load("ext://fetchable", "printFoo") 27 printFoo() 28 `) 29 f.writeModuleLocally("fetchable", libText) 30 31 res := f.assertExecOutput("foo") 32 f.assertLoadRecorded(res, "fetchable") 33 } 34 35 func TestAlreadyPresentWorks(t *testing.T) { 36 f := newExtensionFixture(t) 37 38 f.tiltfile(` 39 load("ext://unfetchable", "printFoo") 40 printFoo() 41 `) 42 f.writeModuleLocally("unfetchable", libText) 43 44 res := f.assertExecOutput("foo") 45 f.assertLoadRecorded(res, "unfetchable") 46 } 47 48 func TestExtensionRepoApplyFails(t *testing.T) { 49 f := newExtensionFixture(t) 50 51 f.tiltfile(` 52 load("ext://module", "printFoo") 53 printFoo() 54 `) 55 f.extrr.Error = "repo can't be fetched" 56 57 res := f.assertError("loading extension repo default: repo can't be fetched") 58 f.assertNoLoadsRecorded(res) 59 } 60 61 func TestExtensionApplyFails(t *testing.T) { 62 f := newExtensionFixture(t) 63 64 f.tiltfile(` 65 load("ext://module", "printFoo") 66 printFoo() 67 `) 68 f.extr.Error = "ext can't be fetched" 69 70 res := f.assertError("loading extension module: ext can't be fetched") 71 f.assertNoLoadsRecorded(res) 72 } 73 74 func TestIncludedFileMayIncludeExtension(t *testing.T) { 75 f := newExtensionFixture(t) 76 77 f.tiltfile(`include('Tiltfile.prime')`) 78 79 f.skf.File("Tiltfile.prime", ` 80 load("ext://fetchable", "printFoo") 81 printFoo() 82 `) 83 84 f.writeModuleLocally("fetchable", libText) 85 86 res := f.assertExecOutput("foo") 87 f.assertLoadRecorded(res, "fetchable") 88 } 89 90 func TestExtensionMayLoadExtension(t *testing.T) { 91 f := newExtensionFixture(t) 92 93 f.tiltfile(` 94 load("ext://fooExt", "printFoo") 95 printFoo() 96 `) 97 f.writeModuleLocally("fooExt", extensionThatLoadsExtension) 98 f.writeModuleLocally("barExt", printBar) 99 100 res := f.assertExecOutput("foo\nbar") 101 f.assertLoadRecorded(res, "fooExt", "barExt") 102 } 103 104 func TestLoadedFilesResolveExtensionsFromRootTiltfile(t *testing.T) { 105 f := newExtensionFixture(t) 106 107 f.tiltfile(`include('./nested/Tiltfile')`) 108 109 f.tmp.MkdirAll("nested") 110 f.skf.File("nested/Tiltfile", ` 111 load("ext://unfetchable", "printFoo") 112 printFoo() 113 `) 114 115 // Note that the extension lives in the tilt_modules directory of the 116 // root Tiltfile. (If we look for this extension in the wrong place and 117 // try to fetch this extension into ./nested/tilt_modules, 118 // the fake fetcher will error.) 119 f.writeModuleLocally("unfetchable", libText) 120 121 res := f.assertExecOutput("foo") 122 f.assertLoadRecorded(res, "unfetchable") 123 } 124 125 func TestRepoAndExtOverride(t *testing.T) { 126 if runtime.GOOS == "windows" { 127 // We don't want to have to bother with file:// escaping on windows. 128 // The repo reconciler already tests this. 129 t.Skip() 130 } 131 132 f := newExtensionFixture(t) 133 134 f.tiltfile(fmt.Sprintf(` 135 v1alpha1.extension_repo(name='default', url='file://%s/my-custom-repo') 136 v1alpha1.extension(name='my-extension', repo_name='default', repo_path='my-custom-path') 137 138 load("ext://my-extension", "printFoo") 139 printFoo() 140 `, f.tmp.Path())) 141 142 f.tmp.WriteFile(filepath.Join("my-custom-repo", "my-custom-path", "Tiltfile"), libText) 143 144 res := f.assertExecOutput("foo") 145 f.assertLoadRecorded(res, "my-extension") 146 } 147 148 func TestRepoOverride(t *testing.T) { 149 if runtime.GOOS == "windows" { 150 // We don't want to have to bother with file:// escaping on windows. 151 // The repo reconciler already tests this. 152 t.Skip() 153 } 154 155 f := newExtensionFixture(t) 156 157 f.tiltfile(fmt.Sprintf(` 158 v1alpha1.extension_repo(name='default', url='file://%s/my-custom-repo') 159 160 load("ext://my-extension", "printFoo") 161 printFoo() 162 `, f.tmp.Path())) 163 164 f.tmp.WriteFile(filepath.Join("my-custom-repo", "my-extension", "Tiltfile"), libText) 165 166 res := f.assertExecOutput("foo") 167 f.assertLoadRecorded(res, "my-extension") 168 } 169 170 func TestLoadedExtensionTwiceDifferentFiles(t *testing.T) { 171 if runtime.GOOS == "windows" { 172 // We don't want to have to bother with file:// escaping on windows. 173 // The repo reconciler already tests this. 174 t.Skip() 175 } 176 177 f := newExtensionFixture(t) 178 179 f.tmp.WriteFile(filepath.Join("my-custom-repo", "my-custom-path", "Tiltfile"), libText) 180 181 subfileContent := fmt.Sprintf(` 182 v1alpha1.extension_repo(name='my-extension-repo', url='file://%s/my-custom-repo') 183 v1alpha1.extension(name='my-extension', repo_name='my-extension-repo', repo_path='my-custom-path') 184 load('ext://my-extension', 'printFoo') 185 printFoo() 186 `, f.tmp.Path()) 187 188 f.skf.File("Tiltfile.a", subfileContent) 189 f.skf.File("Tiltfile.b", subfileContent) 190 f.tiltfile(` 191 include('Tiltfile.a') 192 include('Tiltfile.b') 193 `) 194 res := f.assertExecOutput("foo\nfoo") 195 f.assertLoadRecorded(res, "my-extension") 196 } 197 198 func TestNestingDefaultBehavior(t *testing.T) { 199 if runtime.GOOS == "windows" { 200 // We don't want to have to bother with file:// escaping on windows. 201 // The repo reconciler already tests this. 202 t.Skip() 203 } 204 205 // The default behavior of the extension loading mechanism converts slashes in extension names 206 // to an _, but retains the original extension name as the path within the extension repository. 207 // You can leverage this for nested extensions by defining an extension with an underscore and 208 // then loading it with a slash. 209 f := newExtensionFixture(t) 210 211 f.tiltfile(fmt.Sprintf(` 212 v1alpha1.extension_repo(name='custom', url='file://%s/my-custom-repo') 213 v1alpha1.extension(name='nested_fake', repo_name='custom', repo_path='fake') 214 v1alpha1.extension(name='nested_real', repo_name='custom', repo_path='nested/real') 215 216 load("ext://nested/fake", "printFake") 217 printFake() 218 219 load("ext://nested/real", "printReal") 220 printReal() 221 `, f.tmp.Path())) 222 223 fakeContent := ` 224 def printFake(): 225 print("fake") 226 ` 227 228 realContent := ` 229 def printReal(): 230 print("real") 231 ` 232 233 f.tmp.WriteFile(filepath.Join("my-custom-repo", "fake", "Tiltfile"), fakeContent) 234 f.tmp.WriteFile(filepath.Join("my-custom-repo", "nested", "real", "Tiltfile"), realContent) 235 236 res := f.assertExecOutput("fake\nreal") 237 f.assertLoadRecorded(res, "nested/fake", "nested/real") 238 } 239 240 func TestRepoLoadHost(t *testing.T) { 241 if runtime.GOOS == "windows" { 242 // We don't want to have to bother with file:// escaping on windows. 243 // The repo reconciler already tests this. 244 t.Skip() 245 } 246 247 // Assert that extension repositories with a load_host allow "autoregistration" of extensions if 248 // the extension path starts with the registered repository load_host. 249 f := newExtensionFixture(t) 250 251 f.tiltfile(fmt.Sprintf(` 252 v1alpha1.extension_repo(name='custom', url='file://%s/ext-repo', load_host='custom') 253 254 load("ext://custom/ext", "printFoo") 255 printFoo() 256 `, f.tmp.Path())) 257 258 f.tmp.WriteFile(filepath.Join("ext-repo", "ext", "Tiltfile"), libText) 259 260 res := f.assertExecOutput("foo") 261 f.assertLoadRecorded(res, "custom/ext") 262 } 263 264 func TestRepoGitSubpath(t *testing.T) { 265 if runtime.GOOS == "windows" { 266 // We don't want to have to bother with file:// escaping on windows. 267 // The repo reconciler already tests this. 268 t.Skip() 269 } 270 271 // Assert that extension repositories with a defined subpath load registered extensions 272 // from that subpath 273 f := newExtensionFixture(t) 274 275 f.tiltfile(` 276 v1alpha1.extension_repo( 277 name='custom', 278 url='https://github.com/tilt-dev/ext-repo', 279 git_subpath='subdir') 280 v1alpha1.extension(name='my-ext', repo_name='custom') 281 v1alpha1.extension(name='my-ext-with-path', repo_name='custom', repo_path='subdir2') 282 283 # Assert that loading an extension without a repo_path loads from the repo-wide path 284 load("ext://my-ext", "printExt") 285 printExt() 286 287 load("ext://my-ext-with-path", "printExt2") 288 printExt2() 289 `) 290 291 extContent := ` 292 def printExt(): 293 print("main ext") 294 ` 295 296 extContent2 := ` 297 def printExt2(): 298 print("sub ext") 299 ` 300 301 f.tmp.WriteFile(filepath.Join("ext-repo", "subdir", "Tiltfile"), extContent) 302 f.tmp.WriteFile(filepath.Join("ext-repo", "subdir", "subdir2", "Tiltfile"), extContent2) 303 304 res := f.assertExecOutput("main ext\nsub ext") 305 f.assertLoadRecorded(res, "my-ext", "my-ext-with-path") 306 } 307 308 func TestFileGitSubpath(t *testing.T) { 309 if runtime.GOOS == "windows" { 310 // We don't want to have to bother with file:// escaping on windows. 311 // The repo reconciler already tests this. 312 t.Skip() 313 } 314 315 // Assert that extension repositories with a defined subpath load registered extensions 316 // from that subpath 317 f := newExtensionFixture(t) 318 319 f.tiltfile(fmt.Sprintf(` 320 v1alpha1.extension_repo( 321 name='custom', 322 url='file://%s/ext-repo', 323 git_subpath='subdir') 324 `, f.tmp.Path())) 325 f.assertError("cannot use git_subpath for file:// URL extension repositories") 326 } 327 328 func TestRepoLoadHostAndSubpath(t *testing.T) { 329 if runtime.GOOS == "windows" { 330 // We don't want to have to bother with file:// escaping on windows. 331 // The repo reconciler already tests this. 332 t.Skip() 333 } 334 335 // Assert that extension repositories with a defined subpath load registered extensions 336 // from that subpath, including autoregistration by host match 337 f := newExtensionFixture(t) 338 339 f.tiltfile(` 340 v1alpha1.extension_repo( 341 name='custom', 342 url='https://github.com/tilt-dev/ext-repo', 343 load_host='custom', 344 git_subpath='subdir') 345 346 # Should load an extension from the custom repo at <repo.path>/my-ext 347 load("ext://custom/my-ext", "printExt") 348 printExt() 349 350 # Should load from <repo.path>/my-ext/subext 351 load("ext://custom/my-ext/subext", "printSub") 352 printSub() 353 `) 354 355 extContent := ` 356 def printExt(): 357 print("main ext") 358 ` 359 360 subExtContent := ` 361 def printSub(): 362 print("sub ext") 363 ` 364 365 f.tmp.WriteFile(filepath.Join("ext-repo", "subdir", "my-ext", "Tiltfile"), extContent) 366 f.tmp.WriteFile(filepath.Join("ext-repo", "subdir", "my-ext", "subext", "Tiltfile"), subExtContent) 367 368 res := f.assertExecOutput("main ext\nsub ext") 369 f.assertLoadRecorded(res, "custom/my-ext", "custom/my-ext/subext") 370 } 371 372 // Verifies behavior around registering an extension using the default repository as a fallback 373 func TestRegisterDefaultExtension(t *testing.T) { 374 if runtime.GOOS == "windows" { 375 // We don't want to have to bother with file:// escaping on windows. 376 // The repo reconciler already tests this. 377 t.Skip() 378 } 379 380 f := newExtensionFixture(t) 381 382 p := NewFakePlugin(f.extrr, f.extr) 383 384 f.tiltfile(`print("hello")`) 385 model, _ := f.skf.ExecFile("Tiltfile") 386 objSet := tiltfilev1alpha1.MustState(model) 387 388 moduleName := "tests/golang" 389 extName := apis.SanitizeName(moduleName) 390 extSet := objSet.GetOrCreateTypedSet(&v1alpha1.Extension{}) 391 392 ext := p.registerDefaultExtension(nil /* *starlark.Thread */, extSet, extName, moduleName) 393 394 if ext.GetName() != extName { 395 f.t.Fatalf("want name %s, got %s", extName, ext.GetName()) 396 } 397 398 if ext.Spec.RepoName != defaultRepoName { 399 f.t.Fatalf("want repo name %s, got %s", defaultRepoName, ext.Spec.RepoName) 400 } 401 402 // And look in the extension set to make sure it exists 403 if existing, exists := extSet[extName]; !exists { 404 f.t.Fatal("expected extension to exist in object set") 405 } else if existing != ext { 406 f.t.Fatalf("expected registered extension to be identical to returned extension") 407 } 408 } 409 410 // Verifies the behavior of p.registerExtension 411 func TestRegisterExtension(t *testing.T) { 412 if runtime.GOOS == "windows" { 413 // We don't want to have to bother with file:// escaping on windows. 414 // The repo reconciler already tests this. 415 t.Skip() 416 } 417 418 // Assert that extension repositories with a defined subpath load registered extensions 419 // from that subpath, including autoregistration by host match 420 f := newExtensionFixture(t) 421 422 p := NewFakePlugin(f.extrr, f.extr) 423 424 f.tiltfile(`print("hello")`) 425 model, _ := f.skf.ExecFile("Tiltfile") 426 objSet := tiltfilev1alpha1.MustState(model) 427 extSet := objSet.GetOrCreateTypedSet(&v1alpha1.Extension{}) 428 repoSet := objSet.GetOrCreateTypedSet(&v1alpha1.ExtensionRepo{}) 429 430 repo := &v1alpha1.ExtensionRepo{ 431 ObjectMeta: metav1.ObjectMeta{ 432 Name: "custom", 433 }, 434 Spec: v1alpha1.ExtensionRepoSpec{ 435 URL: fmt.Sprintf("file:///%s/my-custom-repo", f.tmp.Path()), 436 LoadHost: "custom", 437 }, 438 } 439 440 repoSet[repo.GetName()] = repo 441 442 moduleName := "custom/ext" 443 extName := apis.SanitizeName(moduleName) 444 445 ext := p.registerExtension(nil /* *starlark.Thread */, extSet, repoSet, extName, moduleName) 446 447 if ext.GetName() != extName { 448 f.t.Fatalf("want name %s, got %s", extName, ext.GetName()) 449 } 450 451 if ext.Spec.RepoName != repo.GetName() { 452 f.t.Fatalf("want repo name %s, got %s", repo.GetName(), ext.Spec.RepoName) 453 } 454 455 // And look in the extension set to make sure it exists 456 if existing, exists := extSet[extName]; !exists { 457 f.t.Fatal("expected extension to exist in object set") 458 } else if existing != ext { 459 f.t.Fatalf("expected registered extension to be identical to returned extension") 460 } 461 } 462 463 // Verifies the behavior of p.registerExtension when there's no matching repository 464 func TestRegisterExtensionNoMatchingRepo(t *testing.T) { 465 if runtime.GOOS == "windows" { 466 // We don't want to have to bother with file:// escaping on windows. 467 // The repo reconciler already tests this. 468 t.Skip() 469 } 470 471 f := newExtensionFixture(t) 472 473 p := NewFakePlugin(f.extrr, f.extr) 474 475 f.tiltfile(`print("hello")`) 476 model, _ := f.skf.ExecFile("Tiltfile") 477 objSet := tiltfilev1alpha1.MustState(model) 478 repo := &v1alpha1.ExtensionRepo{ 479 ObjectMeta: metav1.ObjectMeta{ 480 Name: "custom", 481 }, 482 Spec: v1alpha1.ExtensionRepoSpec{ 483 URL: fmt.Sprintf("file:///%s/my-custom-repo", f.tmp.Path()), 484 LoadHost: "custom", 485 }, 486 } 487 488 extSet := objSet.GetOrCreateTypedSet(&v1alpha1.Extension{}) 489 repoSet := objSet.GetOrCreateTypedSet(&v1alpha1.ExtensionRepo{}) 490 491 repoSet[repo.GetName()] = repo 492 493 moduleName := "tests/golang" 494 extName := apis.SanitizeName(moduleName) 495 ext := p.registerExtension(nil /* *starlark.Thread */, extSet, repoSet, extName, moduleName) 496 497 if ext.GetName() != extName { 498 f.t.Fatalf("want name %s, got %s", extName, ext.GetName()) 499 } 500 501 // Because our repository prefix is "custom", it should *not* be used for this extension 502 if ext.Spec.RepoName != defaultRepoName { 503 f.t.Fatalf("want repo name %s, got %s", defaultRepoName, ext.Spec.RepoName) 504 } 505 506 // And look in the extension set to make sure it exists 507 if existing, exists := extSet[extName]; !exists { 508 f.t.Fatal("expected extension to exist in object set") 509 } else if existing != ext { 510 f.t.Fatalf("expected registered extension to be identical to returned extension") 511 } 512 } 513 514 type extensionFixture struct { 515 t *testing.T 516 skf *starkit.Fixture 517 tmp *tempdir.TempDirFixture 518 extr *FakeExtReconciler 519 extrr *FakeExtRepoReconciler 520 } 521 522 func newExtensionFixture(t *testing.T) *extensionFixture { 523 tmp := tempdir.NewTempDirFixture(t) 524 extr := NewFakeExtReconciler(tmp.Path()) 525 extrr := NewFakeExtRepoReconciler(tmp.Path()) 526 527 ext := NewFakePlugin( 528 extrr, 529 extr, 530 ) 531 skf := starkit.NewFixture(t, ext, include.IncludeFn{}, tiltfilev1alpha1.NewPlugin()) 532 skf.UseRealFS() 533 534 return &extensionFixture{ 535 t: t, 536 skf: skf, 537 tmp: tmp, 538 extr: extr, 539 extrr: extrr, 540 } 541 } 542 543 func (f *extensionFixture) tiltfile(contents string) { 544 f.skf.File("Tiltfile", contents) 545 } 546 547 func (f *extensionFixture) assertExecOutput(expected string) starkit.Model { 548 result, err := f.skf.ExecFile("Tiltfile") 549 if err != nil { 550 f.t.Fatalf("unexpected error %v", err) 551 } 552 if !strings.Contains(f.skf.PrintOutput(), expected) { 553 f.t.Fatalf("output %q doesn't contain expected output %q", f.skf.PrintOutput(), expected) 554 } 555 return result 556 } 557 558 func (f *extensionFixture) assertError(expected string) starkit.Model { 559 result, err := f.skf.ExecFile("Tiltfile") 560 if err == nil { 561 f.t.Fatalf("expected error; got none (output %q)", f.skf.PrintOutput()) 562 } 563 if !strings.Contains(err.Error(), expected) { 564 f.t.Fatalf("error %v doesn't contain expected text %q", err, expected) 565 } 566 return result 567 } 568 569 func (f *extensionFixture) assertLoadRecorded(model starkit.Model, expected ...string) { 570 state := MustState(model) 571 572 expectedSet := map[string]bool{} 573 for _, exp := range expected { 574 expectedSet[exp] = true 575 } 576 577 assert.Equal(f.t, expectedSet, state.ExtsLoaded) 578 } 579 580 func (f *extensionFixture) assertNoLoadsRecorded(model starkit.Model) { 581 f.assertLoadRecorded(model) 582 } 583 584 func (f *extensionFixture) writeModuleLocally(name string, contents string) { 585 f.tmp.WriteFile(filepath.Join("tilt-extensions", name, "Tiltfile"), contents) 586 } 587 588 const libText = ` 589 def printFoo(): 590 print("foo") 591 ` 592 593 const printBar = ` 594 def printBar(): 595 print("bar") 596 ` 597 598 const extensionThatLoadsExtension = ` 599 load("ext://barExt", "printBar") 600 601 def printFoo(): 602 print("foo") 603 printBar() 604 `