package extension import ( "bytes" "fmt" "net/http" "os" "os/exec" "path/filepath" "runtime" "sort" "testing" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/extensions" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" ) func TestHelperProcess(t *testing.T) { if os.Getenv("GH_WANT_HELPER_PROCESS") != "1" { return } if err := func(args []string) error { fmt.Fprintf(os.Stdout, "%v\n", args) return nil }(os.Args[3:]); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } os.Exit(0) } func newTestManager(dir string, client *http.Client, gitClient gitClient, ios *iostreams.IOStreams) *Manager { return &Manager{ dataDir: func() string { return dir }, lookPath: func(exe string) (string, error) { return exe, nil }, findSh: func() (string, error) { return "sh", nil }, newCommand: func(exe string, args ...string) *exec.Cmd { args = append([]string{os.Args[0], "-test.run=TestHelperProcess", "--", exe}, args...) cmd := exec.Command(args[0], args[1:]...) if ios != nil { cmd.Stdout = ios.Out cmd.Stderr = ios.ErrOut } cmd.Env = []string{"GH_WANT_HELPER_PROCESS=1"} return cmd }, config: config.NewBlankConfig(), io: ios, client: client, gitClient: gitClient, platform: func() (string, string) { return "windows-amd64", ".exe" }, } } func TestManager_List(t *testing.T) { tempDir := t.TempDir() assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-hello", "gh-hello"))) assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-two", "gh-two"))) assert.NoError(t, stubBinaryExtension( filepath.Join(tempDir, "extensions", "gh-bin-ext"), binManifest{ Owner: "owner", Name: "gh-bin-ext", Host: "example.com", Tag: "v1.0.1", })) dirOne := filepath.Join(tempDir, "extensions", "gh-hello") dirTwo := filepath.Join(tempDir, "extensions", "gh-two") gc, gcOne, gcTwo := &mockGitClient{}, &mockGitClient{}, &mockGitClient{} gc.On("ForRepo", dirOne).Return(gcOne).Once() gc.On("ForRepo", dirTwo).Return(gcTwo).Once() m := newTestManager(tempDir, nil, gc, nil) exts := m.List() assert.Equal(t, 3, len(exts)) assert.Equal(t, "bin-ext", exts[0].Name()) assert.Equal(t, "hello", exts[1].Name()) assert.Equal(t, "two", exts[2].Name()) gc.AssertExpectations(t) gcOne.AssertExpectations(t) gcTwo.AssertExpectations(t) } func TestManager_list_includeMetadata(t *testing.T) { tempDir := t.TempDir() assert.NoError(t, stubBinaryExtension( filepath.Join(tempDir, "extensions", "gh-bin-ext"), binManifest{ Owner: "owner", Name: "gh-bin-ext", Host: "example.com", Tag: "v1.0.1", })) reg := httpmock.Registry{} defer reg.Verify(t) client := http.Client{Transport: ®} reg.Register( httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext/releases/latest"), httpmock.JSONResponse( release{ Tag: "v1.0.2", Assets: []releaseAsset{ { Name: "gh-bin-ext-windows-amd64", APIURL: "https://example.com/release/cool2", }, }, })) m := newTestManager(tempDir, &client, nil, nil) exts, err := m.list(true) assert.NoError(t, err) assert.Equal(t, 1, len(exts)) assert.Equal(t, "bin-ext", exts[0].Name()) assert.True(t, exts[0].UpdateAvailable()) assert.Equal(t, "https://example.com/owner/gh-bin-ext", exts[0].URL()) } func TestManager_Dispatch(t *testing.T) { tempDir := t.TempDir() extDir := filepath.Join(tempDir, "extensions", "gh-hello") extPath := filepath.Join(tempDir, "extensions", "gh-hello", "gh-hello") assert.NoError(t, stubExtension(extPath)) gc, gcOne := &mockGitClient{}, &mockGitClient{} gc.On("ForRepo", extDir).Return(gcOne).Once() m := newTestManager(tempDir, nil, gc, nil) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} found, err := m.Dispatch([]string{"hello", "one", "two"}, nil, stdout, stderr) assert.NoError(t, err) assert.True(t, found) if runtime.GOOS == "windows" { assert.Equal(t, fmt.Sprintf("[sh -c command \"$@\" -- %s one two]\n", extPath), stdout.String()) } else { assert.Equal(t, fmt.Sprintf("[%s one two]\n", extPath), stdout.String()) } assert.Equal(t, "", stderr.String()) gc.AssertExpectations(t) gcOne.AssertExpectations(t) } func TestManager_Dispatch_binary(t *testing.T) { tempDir := t.TempDir() extPath := filepath.Join(tempDir, "extensions", "gh-hello") exePath := filepath.Join(extPath, "gh-hello") bm := binManifest{ Owner: "owner", Name: "gh-hello", Host: "github.com", Tag: "v1.0.0", } assert.NoError(t, stubBinaryExtension(extPath, bm)) m := newTestManager(tempDir, nil, nil, nil) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} found, err := m.Dispatch([]string{"hello", "one", "two"}, nil, stdout, stderr) assert.NoError(t, err) assert.True(t, found) assert.Equal(t, fmt.Sprintf("[%s one two]\n", exePath), stdout.String()) assert.Equal(t, "", stderr.String()) } func TestManager_Remove(t *testing.T) { tempDir := t.TempDir() assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-hello", "gh-hello"))) assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-two", "gh-two"))) m := newTestManager(tempDir, nil, nil, nil) err := m.Remove("hello") assert.NoError(t, err) items, err := os.ReadDir(filepath.Join(tempDir, "extensions")) assert.NoError(t, err) assert.Equal(t, 1, len(items)) assert.Equal(t, "gh-two", items[0].Name()) } func TestManager_Upgrade_NoExtensions(t *testing.T) { tempDir := t.TempDir() ios, _, stdout, stderr := iostreams.Test() m := newTestManager(tempDir, nil, nil, ios) err := m.Upgrade("", false) assert.EqualError(t, err, "no extensions installed") assert.Equal(t, "", stdout.String()) assert.Equal(t, "", stderr.String()) } func TestManager_Upgrade_NoMatchingExtension(t *testing.T) { tempDir := t.TempDir() extDir := filepath.Join(tempDir, "extensions", "gh-hello") assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-hello", "gh-hello"))) ios, _, stdout, stderr := iostreams.Test() gc, gcOne := &mockGitClient{}, &mockGitClient{} gc.On("ForRepo", extDir).Return(gcOne).Once() m := newTestManager(tempDir, nil, gc, ios) err := m.Upgrade("invalid", false) assert.EqualError(t, err, `no extension matched "invalid"`) assert.Equal(t, "", stdout.String()) assert.Equal(t, "", stderr.String()) gc.AssertExpectations(t) gcOne.AssertExpectations(t) } func TestManager_UpgradeExtensions(t *testing.T) { tempDir := t.TempDir() dirOne := filepath.Join(tempDir, "extensions", "gh-hello") dirTwo := filepath.Join(tempDir, "extensions", "gh-two") assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-hello", "gh-hello"))) assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-two", "gh-two"))) assert.NoError(t, stubLocalExtension(tempDir, filepath.Join(tempDir, "extensions", "gh-local", "gh-local"))) ios, _, stdout, stderr := iostreams.Test() gc, gcOne, gcTwo := &mockGitClient{}, &mockGitClient{}, &mockGitClient{} gc.On("ForRepo", dirOne).Return(gcOne).Times(3) gc.On("ForRepo", dirTwo).Return(gcTwo).Times(3) gcOne.On("Remotes").Return(nil, nil).Once() gcTwo.On("Remotes").Return(nil, nil).Once() gcOne.On("Pull", "", "").Return(nil).Once() gcTwo.On("Pull", "", "").Return(nil).Once() m := newTestManager(tempDir, nil, gc, ios) exts, err := m.list(false) assert.NoError(t, err) assert.Equal(t, 3, len(exts)) for i := 0; i < 3; i++ { exts[i].currentVersion = "old version" exts[i].latestVersion = "new version" } err = m.upgradeExtensions(exts, false) assert.NoError(t, err) assert.Equal(t, heredoc.Doc( ` [hello]: upgraded from old vers to new vers [local]: local extensions can not be upgraded [two]: upgraded from old vers to new vers `, ), stdout.String()) assert.Equal(t, "", stderr.String()) gc.AssertExpectations(t) gcOne.AssertExpectations(t) gcTwo.AssertExpectations(t) } func TestManager_UpgradeExtensions_DryRun(t *testing.T) { tempDir := t.TempDir() dirOne := filepath.Join(tempDir, "extensions", "gh-hello") dirTwo := filepath.Join(tempDir, "extensions", "gh-two") assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-hello", "gh-hello"))) assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-two", "gh-two"))) assert.NoError(t, stubLocalExtension(tempDir, filepath.Join(tempDir, "extensions", "gh-local", "gh-local"))) ios, _, stdout, stderr := iostreams.Test() gc, gcOne, gcTwo := &mockGitClient{}, &mockGitClient{}, &mockGitClient{} gc.On("ForRepo", dirOne).Return(gcOne).Twice() gc.On("ForRepo", dirTwo).Return(gcTwo).Twice() gcOne.On("Remotes").Return(nil, nil).Once() gcTwo.On("Remotes").Return(nil, nil).Once() m := newTestManager(tempDir, nil, gc, ios) m.EnableDryRunMode() exts, err := m.list(false) assert.NoError(t, err) assert.Equal(t, 3, len(exts)) for i := 0; i < 3; i++ { exts[i].currentVersion = fmt.Sprintf("%d", i) exts[i].latestVersion = fmt.Sprintf("%d", i+1) } err = m.upgradeExtensions(exts, false) assert.NoError(t, err) assert.Equal(t, heredoc.Doc( ` [hello]: would have upgraded from 0 to 1 [local]: local extensions can not be upgraded [two]: would have upgraded from 2 to 3 `, ), stdout.String()) assert.Equal(t, "", stderr.String()) gc.AssertExpectations(t) gcOne.AssertExpectations(t) gcTwo.AssertExpectations(t) } func TestManager_UpgradeExtension_LocalExtension(t *testing.T) { tempDir := t.TempDir() assert.NoError(t, stubLocalExtension(tempDir, filepath.Join(tempDir, "extensions", "gh-local", "gh-local"))) ios, _, stdout, stderr := iostreams.Test() m := newTestManager(tempDir, nil, nil, ios) exts, err := m.list(false) assert.NoError(t, err) assert.Equal(t, 1, len(exts)) err = m.upgradeExtension(exts[0], false) assert.EqualError(t, err, "local extensions can not be upgraded") assert.Equal(t, "", stdout.String()) assert.Equal(t, "", stderr.String()) } func TestManager_UpgradeExtension_LocalExtension_DryRun(t *testing.T) { tempDir := t.TempDir() assert.NoError(t, stubLocalExtension(tempDir, filepath.Join(tempDir, "extensions", "gh-local", "gh-local"))) ios, _, stdout, stderr := iostreams.Test() m := newTestManager(tempDir, nil, nil, ios) m.EnableDryRunMode() exts, err := m.list(false) assert.NoError(t, err) assert.Equal(t, 1, len(exts)) err = m.upgradeExtension(exts[0], false) assert.EqualError(t, err, "local extensions can not be upgraded") assert.Equal(t, "", stdout.String()) assert.Equal(t, "", stderr.String()) } func TestManager_UpgradeExtension_GitExtension(t *testing.T) { tempDir := t.TempDir() extensionDir := filepath.Join(tempDir, "extensions", "gh-remote") assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-remote", "gh-remote"))) ios, _, stdout, stderr := iostreams.Test() gc, gcOne := &mockGitClient{}, &mockGitClient{} gc.On("ForRepo", extensionDir).Return(gcOne).Times(3) gcOne.On("Remotes").Return(nil, nil).Once() gcOne.On("Pull", "", "").Return(nil).Once() m := newTestManager(tempDir, nil, gc, ios) exts, err := m.list(false) assert.NoError(t, err) assert.Equal(t, 1, len(exts)) ext := exts[0] ext.currentVersion = "old version" ext.latestVersion = "new version" err = m.upgradeExtension(ext, false) assert.NoError(t, err) assert.Equal(t, "", stdout.String()) assert.Equal(t, "", stderr.String()) gc.AssertExpectations(t) gcOne.AssertExpectations(t) } func TestManager_UpgradeExtension_GitExtension_DryRun(t *testing.T) { tempDir := t.TempDir() extDir := filepath.Join(tempDir, "extensions", "gh-remote") assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-remote", "gh-remote"))) ios, _, stdout, stderr := iostreams.Test() gc, gcOne := &mockGitClient{}, &mockGitClient{} gc.On("ForRepo", extDir).Return(gcOne).Twice() gcOne.On("Remotes").Return(nil, nil).Once() m := newTestManager(tempDir, nil, gc, ios) m.EnableDryRunMode() exts, err := m.list(false) assert.NoError(t, err) assert.Equal(t, 1, len(exts)) ext := exts[0] ext.currentVersion = "old version" ext.latestVersion = "new version" err = m.upgradeExtension(ext, false) assert.NoError(t, err) assert.Equal(t, "", stdout.String()) assert.Equal(t, "", stderr.String()) gc.AssertExpectations(t) gcOne.AssertExpectations(t) } func TestManager_UpgradeExtension_GitExtension_Force(t *testing.T) { tempDir := t.TempDir() extensionDir := filepath.Join(tempDir, "extensions", "gh-remote") assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-remote", "gh-remote"))) ios, _, stdout, stderr := iostreams.Test() gc, gcOne := &mockGitClient{}, &mockGitClient{} gc.On("ForRepo", extensionDir).Return(gcOne).Times(3) gcOne.On("Remotes").Return(nil, nil).Once() gcOne.On("Fetch", "origin", "HEAD").Return(nil).Once() gcOne.On("CommandOutput", []string{"reset", "--hard", "origin/HEAD"}).Return("", nil).Once() m := newTestManager(tempDir, nil, gc, ios) exts, err := m.list(false) assert.NoError(t, err) assert.Equal(t, 1, len(exts)) ext := exts[0] ext.currentVersion = "old version" ext.latestVersion = "new version" err = m.upgradeExtension(ext, true) assert.NoError(t, err) assert.Equal(t, "", stdout.String()) assert.Equal(t, "", stderr.String()) gc.AssertExpectations(t) gcOne.AssertExpectations(t) } func TestManager_MigrateToBinaryExtension(t *testing.T) { tempDir := t.TempDir() assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-remote", "gh-remote"))) ios, _, stdout, stderr := iostreams.Test() reg := httpmock.Registry{} defer reg.Verify(t) client := http.Client{Transport: ®} gc := &gitExecuter{client: &git.Client{}} m := newTestManager(tempDir, &client, gc, ios) exts, err := m.list(false) assert.NoError(t, err) assert.Equal(t, 1, len(exts)) ext := exts[0] ext.currentVersion = "old version" ext.latestVersion = "new version" rs, restoreRun := run.Stub() defer restoreRun(t) rs.Register(`git -C.*?gh-remote remote -v`, 0, "origin git@github.com:owner/gh-remote.git (fetch)\norigin git@github.com:owner/gh-remote.git (push)") rs.Register(`git -C.*?gh-remote config --get-regexp \^.*`, 0, "remote.origin.gh-resolve base") reg.Register( httpmock.REST("GET", "repos/owner/gh-remote/releases/latest"), httpmock.JSONResponse( release{ Tag: "v1.0.2", Assets: []releaseAsset{ { Name: "gh-remote-windows-amd64.exe", APIURL: "/release/cool", }, }, })) reg.Register( httpmock.REST("GET", "repos/owner/gh-remote/releases/latest"), httpmock.JSONResponse( release{ Tag: "v1.0.2", Assets: []releaseAsset{ { Name: "gh-remote-windows-amd64.exe", APIURL: "/release/cool", }, }, })) reg.Register( httpmock.REST("GET", "release/cool"), httpmock.StringResponse("FAKE UPGRADED BINARY")) err = m.upgradeExtension(ext, false) assert.NoError(t, err) assert.Equal(t, "", stdout.String()) assert.Equal(t, "", stderr.String()) manifest, err := os.ReadFile(filepath.Join(tempDir, "extensions/gh-remote", manifestName)) assert.NoError(t, err) var bm binManifest err = yaml.Unmarshal(manifest, &bm) assert.NoError(t, err) assert.Equal(t, binManifest{ Name: "gh-remote", Owner: "owner", Host: "github.com", Tag: "v1.0.2", Path: filepath.Join(tempDir, "extensions/gh-remote/gh-remote.exe"), }, bm) fakeBin, err := os.ReadFile(filepath.Join(tempDir, "extensions/gh-remote/gh-remote.exe")) assert.NoError(t, err) assert.Equal(t, "FAKE UPGRADED BINARY", string(fakeBin)) } func TestManager_UpgradeExtension_BinaryExtension(t *testing.T) { tempDir := t.TempDir() reg := httpmock.Registry{} defer reg.Verify(t) assert.NoError(t, stubBinaryExtension( filepath.Join(tempDir, "extensions", "gh-bin-ext"), binManifest{ Owner: "owner", Name: "gh-bin-ext", Host: "example.com", Tag: "v1.0.1", })) ios, _, stdout, stderr := iostreams.Test() m := newTestManager(tempDir, &http.Client{Transport: ®}, nil, ios) reg.Register( httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext/releases/latest"), httpmock.JSONResponse( release{ Tag: "v1.0.2", Assets: []releaseAsset{ { Name: "gh-bin-ext-windows-amd64.exe", APIURL: "https://example.com/release/cool2", }, }, })) reg.Register( httpmock.REST("GET", "release/cool2"), httpmock.StringResponse("FAKE UPGRADED BINARY")) exts, err := m.list(false) assert.NoError(t, err) assert.Equal(t, 1, len(exts)) ext := exts[0] ext.latestVersion = "v1.0.2" err = m.upgradeExtension(ext, false) assert.NoError(t, err) manifest, err := os.ReadFile(filepath.Join(tempDir, "extensions/gh-bin-ext", manifestName)) assert.NoError(t, err) var bm binManifest err = yaml.Unmarshal(manifest, &bm) assert.NoError(t, err) assert.Equal(t, binManifest{ Name: "gh-bin-ext", Owner: "owner", Host: "example.com", Tag: "v1.0.2", Path: filepath.Join(tempDir, "extensions/gh-bin-ext/gh-bin-ext.exe"), }, bm) fakeBin, err := os.ReadFile(filepath.Join(tempDir, "extensions/gh-bin-ext/gh-bin-ext.exe")) assert.NoError(t, err) assert.Equal(t, "FAKE UPGRADED BINARY", string(fakeBin)) assert.Equal(t, "", stdout.String()) assert.Equal(t, "", stderr.String()) } func TestManager_UpgradeExtension_BinaryExtension_Pinned_Force(t *testing.T) { tempDir := t.TempDir() reg := httpmock.Registry{} defer reg.Verify(t) assert.NoError(t, stubBinaryExtension( filepath.Join(tempDir, "extensions", "gh-bin-ext"), binManifest{ Owner: "owner", Name: "gh-bin-ext", Host: "example.com", Tag: "v1.0.1", IsPinned: true, })) ios, _, stdout, stderr := iostreams.Test() m := newTestManager(tempDir, &http.Client{Transport: ®}, nil, ios) reg.Register( httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext/releases/latest"), httpmock.JSONResponse( release{ Tag: "v1.0.2", Assets: []releaseAsset{ { Name: "gh-bin-ext-windows-amd64.exe", APIURL: "https://example.com/release/cool2", }, }, })) reg.Register( httpmock.REST("GET", "release/cool2"), httpmock.StringResponse("FAKE UPGRADED BINARY")) exts, err := m.list(false) assert.NoError(t, err) assert.Equal(t, 1, len(exts)) ext := exts[0] ext.latestVersion = "v1.0.2" err = m.upgradeExtension(ext, true) assert.NoError(t, err) manifest, err := os.ReadFile(filepath.Join(tempDir, "extensions/gh-bin-ext", manifestName)) assert.NoError(t, err) var bm binManifest err = yaml.Unmarshal(manifest, &bm) assert.NoError(t, err) assert.Equal(t, binManifest{ Name: "gh-bin-ext", Owner: "owner", Host: "example.com", Tag: "v1.0.2", Path: filepath.Join(tempDir, "extensions/gh-bin-ext/gh-bin-ext.exe"), }, bm) fakeBin, err := os.ReadFile(filepath.Join(tempDir, "extensions/gh-bin-ext/gh-bin-ext.exe")) assert.NoError(t, err) assert.Equal(t, "FAKE UPGRADED BINARY", string(fakeBin)) assert.Equal(t, "", stdout.String()) assert.Equal(t, "", stderr.String()) } func TestManager_UpgradeExtension_BinaryExtension_DryRun(t *testing.T) { tempDir := t.TempDir() reg := httpmock.Registry{} defer reg.Verify(t) assert.NoError(t, stubBinaryExtension( filepath.Join(tempDir, "extensions", "gh-bin-ext"), binManifest{ Owner: "owner", Name: "gh-bin-ext", Host: "example.com", Tag: "v1.0.1", })) ios, _, stdout, stderr := iostreams.Test() m := newTestManager(tempDir, &http.Client{Transport: ®}, nil, ios) m.EnableDryRunMode() reg.Register( httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext/releases/latest"), httpmock.JSONResponse( release{ Tag: "v1.0.2", Assets: []releaseAsset{ { Name: "gh-bin-ext-windows-amd64.exe", APIURL: "https://example.com/release/cool2", }, }, })) exts, err := m.list(false) assert.NoError(t, err) assert.Equal(t, 1, len(exts)) ext := exts[0] ext.latestVersion = "v1.0.2" err = m.upgradeExtension(ext, false) assert.NoError(t, err) manifest, err := os.ReadFile(filepath.Join(tempDir, "extensions/gh-bin-ext", manifestName)) assert.NoError(t, err) var bm binManifest err = yaml.Unmarshal(manifest, &bm) assert.NoError(t, err) assert.Equal(t, binManifest{ Name: "gh-bin-ext", Owner: "owner", Host: "example.com", Tag: "v1.0.1", }, bm) assert.Equal(t, "", stdout.String()) assert.Equal(t, "", stderr.String()) } func TestManager_UpgradeExtension_BinaryExtension_Pinned(t *testing.T) { tempDir := t.TempDir() assert.NoError(t, stubBinaryExtension( filepath.Join(tempDir, "extensions", "gh-bin-ext"), binManifest{ Owner: "owner", Name: "gh-bin-ext", Host: "example.com", Tag: "v1.6.3", IsPinned: true, })) ios, _, _, _ := iostreams.Test() m := newTestManager(tempDir, nil, nil, ios) exts, err := m.list(false) assert.Nil(t, err) assert.Equal(t, 1, len(exts)) ext := exts[0] err = m.upgradeExtension(ext, false) assert.NotNil(t, err) assert.Equal(t, err, pinnedExtensionUpgradeError) } func TestManager_UpgradeExtension_GitExtension_Pinned(t *testing.T) { tempDir := t.TempDir() extDir := filepath.Join(tempDir, "extensions", "gh-remote") assert.NoError(t, stubPinnedExtension(filepath.Join(extDir, "gh-remote"), "abcd1234")) ios, _, _, _ := iostreams.Test() gc, gcOne := &mockGitClient{}, &mockGitClient{} gc.On("ForRepo", extDir).Return(gcOne).Once() m := newTestManager(tempDir, nil, gc, ios) exts, err := m.list(false) assert.NoError(t, err) assert.Equal(t, 1, len(exts)) ext := exts[0] pinnedTrue := true ext.isPinned = &pinnedTrue ext.latestVersion = "new version" err = m.upgradeExtension(ext, false) assert.NotNil(t, err) assert.Equal(t, err, pinnedExtensionUpgradeError) gc.AssertExpectations(t) gcOne.AssertExpectations(t) } func TestManager_Install_git(t *testing.T) { tempDir := t.TempDir() reg := httpmock.Registry{} defer reg.Verify(t) client := http.Client{Transport: ®} ios, _, stdout, stderr := iostreams.Test() extensionDir := filepath.Join(tempDir, "extensions", "gh-some-ext") gc := &mockGitClient{} gc.On("Clone", "https://github.com/owner/gh-some-ext.git", []string{extensionDir}).Return("", nil).Once() m := newTestManager(tempDir, &client, gc, ios) reg.Register( httpmock.REST("GET", "repos/owner/gh-some-ext/releases/latest"), httpmock.JSONResponse( release{ Assets: []releaseAsset{ { Name: "not-a-binary", APIURL: "https://example.com/release/cool", }, }, })) reg.Register( httpmock.REST("GET", "repos/owner/gh-some-ext/contents/gh-some-ext"), httpmock.StringResponse("script")) repo := ghrepo.New("owner", "gh-some-ext") err := m.Install(repo, "") assert.NoError(t, err) assert.Equal(t, "", stdout.String()) assert.Equal(t, "", stderr.String()) gc.AssertExpectations(t) } func TestManager_Install_git_pinned(t *testing.T) { tempDir := t.TempDir() reg := httpmock.Registry{} defer reg.Verify(t) client := http.Client{Transport: ®} ios, _, stdout, stderr := iostreams.Test() extensionDir := filepath.Join(tempDir, "extensions", "gh-cool-ext") gc, gcOne := &mockGitClient{}, &mockGitClient{} gc.On("ForRepo", extensionDir).Return(gcOne).Once() gc.On("Clone", "https://github.com/owner/gh-cool-ext.git", []string{extensionDir}).Return("", nil).Once() gcOne.On("CheckoutBranch", "abcd1234").Return(nil).Once() m := newTestManager(tempDir, &client, gc, ios) reg.Register( httpmock.REST("GET", "repos/owner/gh-cool-ext/releases/latest"), httpmock.JSONResponse( release{ Assets: []releaseAsset{ { Name: "not-a-binary", APIURL: "https://example.com/release/cool", }, }, })) reg.Register( httpmock.REST("GET", "repos/owner/gh-cool-ext/commits/some-ref"), httpmock.StringResponse("abcd1234")) reg.Register( httpmock.REST("GET", "repos/owner/gh-cool-ext/contents/gh-cool-ext"), httpmock.StringResponse("script")) _ = os.MkdirAll(filepath.Join(m.installDir(), "gh-cool-ext"), 0700) repo := ghrepo.New("owner", "gh-cool-ext") err := m.Install(repo, "some-ref") assert.NoError(t, err) assert.Equal(t, "", stderr.String()) assert.Equal(t, "", stdout.String()) gc.AssertExpectations(t) gcOne.AssertExpectations(t) } func TestManager_Install_binary_pinned(t *testing.T) { repo := ghrepo.NewWithHost("owner", "gh-bin-ext", "example.com") reg := httpmock.Registry{} defer reg.Verify(t) reg.Register( httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext/releases/latest"), httpmock.JSONResponse( release{ Assets: []releaseAsset{ { Name: "gh-bin-ext-windows-amd64.exe", APIURL: "https://example.com/release/cool", }, }, })) reg.Register( httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext/releases/tags/v1.6.3-pre"), httpmock.JSONResponse( release{ Tag: "v1.6.3-pre", Assets: []releaseAsset{ { Name: "gh-bin-ext-windows-amd64.exe", APIURL: "https://example.com/release/cool", }, }, })) reg.Register( httpmock.REST("GET", "release/cool"), httpmock.StringResponse("FAKE BINARY")) ios, _, stdout, stderr := iostreams.Test() tempDir := t.TempDir() m := newTestManager(tempDir, &http.Client{Transport: ®}, nil, ios) err := m.Install(repo, "v1.6.3-pre") assert.NoError(t, err) manifest, err := os.ReadFile(filepath.Join(tempDir, "extensions/gh-bin-ext", manifestName)) assert.NoError(t, err) var bm binManifest err = yaml.Unmarshal(manifest, &bm) assert.NoError(t, err) assert.Equal(t, binManifest{ Name: "gh-bin-ext", Owner: "owner", Host: "example.com", Tag: "v1.6.3-pre", IsPinned: true, Path: filepath.Join(tempDir, "extensions/gh-bin-ext/gh-bin-ext.exe"), }, bm) fakeBin, err := os.ReadFile(filepath.Join(tempDir, "extensions/gh-bin-ext/gh-bin-ext.exe")) assert.NoError(t, err) assert.Equal(t, "FAKE BINARY", string(fakeBin)) assert.Equal(t, "", stdout.String()) assert.Equal(t, "", stderr.String()) } func TestManager_Install_binary_unsupported(t *testing.T) { repo := ghrepo.NewWithHost("owner", "gh-bin-ext", "example.com") reg := httpmock.Registry{} defer reg.Verify(t) client := http.Client{Transport: ®} reg.Register( httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext/releases/latest"), httpmock.JSONResponse( release{ Assets: []releaseAsset{ { Name: "gh-bin-ext-linux-amd64", APIURL: "https://example.com/release/cool", }, }, })) reg.Register( httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext/releases/latest"), httpmock.JSONResponse( release{ Tag: "v1.0.1", Assets: []releaseAsset{ { Name: "gh-bin-ext-linux-amd64", APIURL: "https://example.com/release/cool", }, }, })) ios, _, stdout, stderr := iostreams.Test() tempDir := t.TempDir() m := newTestManager(tempDir, &client, nil, ios) err := m.Install(repo, "") assert.EqualError(t, err, "gh-bin-ext unsupported for windows-amd64. Open an issue: `gh issue create -R owner/gh-bin-ext -t'Support windows-amd64'`") assert.Equal(t, "", stdout.String()) assert.Equal(t, "", stderr.String()) } func TestManager_Install_binary(t *testing.T) { repo := ghrepo.NewWithHost("owner", "gh-bin-ext", "example.com") reg := httpmock.Registry{} defer reg.Verify(t) reg.Register( httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext/releases/latest"), httpmock.JSONResponse( release{ Assets: []releaseAsset{ { Name: "gh-bin-ext-windows-amd64.exe", APIURL: "https://example.com/release/cool", }, }, })) reg.Register( httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext/releases/latest"), httpmock.JSONResponse( release{ Tag: "v1.0.1", Assets: []releaseAsset{ { Name: "gh-bin-ext-windows-amd64.exe", APIURL: "https://example.com/release/cool", }, }, })) reg.Register( httpmock.REST("GET", "release/cool"), httpmock.StringResponse("FAKE BINARY")) ios, _, stdout, stderr := iostreams.Test() tempDir := t.TempDir() m := newTestManager(tempDir, &http.Client{Transport: ®}, nil, ios) err := m.Install(repo, "") assert.NoError(t, err) manifest, err := os.ReadFile(filepath.Join(tempDir, "extensions/gh-bin-ext", manifestName)) assert.NoError(t, err) var bm binManifest err = yaml.Unmarshal(manifest, &bm) assert.NoError(t, err) assert.Equal(t, binManifest{ Name: "gh-bin-ext", Owner: "owner", Host: "example.com", Tag: "v1.0.1", Path: filepath.Join(tempDir, "extensions/gh-bin-ext/gh-bin-ext.exe"), }, bm) fakeBin, err := os.ReadFile(filepath.Join(tempDir, "extensions/gh-bin-ext/gh-bin-ext.exe")) assert.NoError(t, err) assert.Equal(t, "FAKE BINARY", string(fakeBin)) assert.Equal(t, "", stdout.String()) assert.Equal(t, "", stderr.String()) } func TestManager_repo_not_found(t *testing.T) { repo := ghrepo.NewWithHost("owner", "gh-bin-ext", "example.com") reg := httpmock.Registry{} defer reg.Verify(t) reg.Register( httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext/releases/latest"), httpmock.StatusStringResponse(404, `{}`)) reg.Register( httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext"), httpmock.StatusStringResponse(404, `{}`)) ios, _, stdout, stderr := iostreams.Test() tempDir := t.TempDir() m := newTestManager(tempDir, &http.Client{Transport: ®}, nil, ios) if err := m.Install(repo, ""); err != repositoryNotFoundErr { t.Errorf("expected repositoryNotFoundErr, got: %v", err) } assert.Equal(t, "", stdout.String()) assert.Equal(t, "", stderr.String()) } func TestManager_Create(t *testing.T) { chdirTemp(t) err := os.MkdirAll("gh-test", 0755) assert.NoError(t, err) ios, _, stdout, stderr := iostreams.Test() gc, gcOne := &mockGitClient{}, &mockGitClient{} gc.On("ForRepo", "gh-test").Return(gcOne).Once() gc.On("CommandOutput", []string{"init", "--quiet", "gh-test"}).Return("", nil).Once() gcOne.On("CommandOutput", []string{"add", "gh-test", "--chmod=+x"}).Return("", nil).Once() gcOne.On("CommandOutput", []string{"commit", "-m", "initial commit"}).Return("", nil).Once() m := newTestManager(".", nil, gc, ios) err = m.Create("gh-test", extensions.GitTemplateType) assert.NoError(t, err) files, err := os.ReadDir("gh-test") assert.NoError(t, err) assert.Equal(t, []string{"gh-test"}, fileNames(files)) assert.Equal(t, "", stdout.String()) assert.Equal(t, "", stderr.String()) gc.AssertExpectations(t) gcOne.AssertExpectations(t) } func TestManager_Create_go_binary(t *testing.T) { chdirTemp(t) err := os.MkdirAll("gh-test", 0755) assert.NoError(t, err) reg := httpmock.Registry{} defer reg.Verify(t) reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`)) ios, _, stdout, stderr := iostreams.Test() gc, gcOne := &mockGitClient{}, &mockGitClient{} gc.On("ForRepo", "gh-test").Return(gcOne).Once() gc.On("CommandOutput", []string{"init", "--quiet", "gh-test"}).Return("", nil).Once() gcOne.On("CommandOutput", []string{"add", "."}).Return("", nil).Once() gcOne.On("CommandOutput", []string{"commit", "-m", "initial commit"}).Return("", nil).Once() m := newTestManager(".", &http.Client{Transport: ®}, gc, ios) err = m.Create("gh-test", extensions.GoBinTemplateType) require.NoError(t, err) files, err := os.ReadDir("gh-test") require.NoError(t, err) assert.Equal(t, []string{".github", ".gitignore", "main.go"}, fileNames(files)) gitignore, err := os.ReadFile(filepath.Join("gh-test", ".gitignore")) require.NoError(t, err) assert.Equal(t, heredoc.Doc(` /gh-test /gh-test.exe `), string(gitignore)) files, err = os.ReadDir(filepath.Join("gh-test", ".github", "workflows")) require.NoError(t, err) assert.Equal(t, []string{"release.yml"}, fileNames(files)) assert.Equal(t, heredoc.Doc(` [go mod init github.com/jillv/gh-test] [go mod tidy] [go build] `), stdout.String()) assert.Equal(t, "", stderr.String()) gc.AssertExpectations(t) gcOne.AssertExpectations(t) } func TestManager_Create_other_binary(t *testing.T) { chdirTemp(t) err := os.MkdirAll("gh-test", 0755) assert.NoError(t, err) ios, _, stdout, stderr := iostreams.Test() gc, gcOne := &mockGitClient{}, &mockGitClient{} gc.On("ForRepo", "gh-test").Return(gcOne).Once() gc.On("CommandOutput", []string{"init", "--quiet", "gh-test"}).Return("", nil).Once() gcOne.On("CommandOutput", []string{"add", filepath.Join("script", "build.sh"), "--chmod=+x"}).Return("", nil).Once() gcOne.On("CommandOutput", []string{"add", "."}).Return("", nil).Once() gcOne.On("CommandOutput", []string{"commit", "-m", "initial commit"}).Return("", nil).Once() m := newTestManager(".", nil, gc, ios) err = m.Create("gh-test", extensions.OtherBinTemplateType) assert.NoError(t, err) files, err := os.ReadDir("gh-test") assert.NoError(t, err) assert.Equal(t, 2, len(files)) files, err = os.ReadDir(filepath.Join("gh-test", ".github", "workflows")) assert.NoError(t, err) assert.Equal(t, []string{"release.yml"}, fileNames(files)) files, err = os.ReadDir(filepath.Join("gh-test", "script")) assert.NoError(t, err) assert.Equal(t, []string{"build.sh"}, fileNames(files)) assert.Equal(t, "", stdout.String()) assert.Equal(t, "", stderr.String()) gc.AssertExpectations(t) gcOne.AssertExpectations(t) } // chdirTemp changes the current working directory to a temporary directory for the duration of the test. func chdirTemp(t *testing.T) { oldWd, _ := os.Getwd() tempDir := t.TempDir() if err := os.Chdir(tempDir); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = os.Chdir(oldWd) }) } func fileNames(files []os.DirEntry) []string { names := make([]string, len(files)) for i, f := range files { names[i] = f.Name() } sort.Strings(names) return names } func stubExtension(path string) error { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } f, err := os.OpenFile(path, os.O_CREATE, 0755) if err != nil { return err } return f.Close() } func stubPinnedExtension(path string, pinnedVersion string) error { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } f, err := os.OpenFile(path, os.O_CREATE, 0755) if err != nil { return err } f.Close() pinPath := filepath.Join(filepath.Dir(path), fmt.Sprintf(".pin-%s", pinnedVersion)) f, err = os.OpenFile(pinPath, os.O_WRONLY|os.O_CREATE, 0600) if err != nil { return err } return f.Close() } func stubLocalExtension(tempDir, path string) error { extDir, err := os.MkdirTemp(tempDir, "local-ext") if err != nil { return err } extFile, err := os.OpenFile(filepath.Join(extDir, filepath.Base(path)), os.O_CREATE, 0755) if err != nil { return err } if err := extFile.Close(); err != nil { return err } linkPath := filepath.Dir(path) if err := os.MkdirAll(filepath.Dir(linkPath), 0755); err != nil { return err } f, err := os.OpenFile(linkPath, os.O_WRONLY|os.O_CREATE, 0644) if err != nil { return err } _, err = f.WriteString(extDir) if err != nil { return err } return f.Close() } // Given the path where an extension should be installed and a manifest struct, creates a fake binary extension on disk func stubBinaryExtension(installPath string, bm binManifest) error { if err := os.MkdirAll(installPath, 0755); err != nil { return err } fakeBinaryPath := filepath.Join(installPath, filepath.Base(installPath)) fb, err := os.OpenFile(fakeBinaryPath, os.O_CREATE, 0755) if err != nil { return err } err = fb.Close() if err != nil { return err } bs, err := yaml.Marshal(bm) if err != nil { return fmt.Errorf("failed to serialize manifest: %w", err) } manifestPath := filepath.Join(installPath, manifestName) fm, err := os.OpenFile(manifestPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { return fmt.Errorf("failed to open manifest for writing: %w", err) } _, err = fm.Write(bs) if err != nil { return fmt.Errorf("failed write manifest file: %w", err) } return fm.Close() }