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  `