1package extension
2
3import (
4	"errors"
5	"io/ioutil"
6	"net/http"
7	"os"
8	"strings"
9	"testing"
10
11	"github.com/MakeNowJust/heredoc"
12	"github.com/cli/cli/v2/internal/config"
13	"github.com/cli/cli/v2/internal/ghrepo"
14	"github.com/cli/cli/v2/pkg/cmdutil"
15	"github.com/cli/cli/v2/pkg/extensions"
16	"github.com/cli/cli/v2/pkg/httpmock"
17	"github.com/cli/cli/v2/pkg/iostreams"
18	"github.com/cli/cli/v2/pkg/prompt"
19	"github.com/spf13/cobra"
20	"github.com/stretchr/testify/assert"
21)
22
23func TestNewCmdExtension(t *testing.T) {
24	tempDir := t.TempDir()
25	oldWd, _ := os.Getwd()
26	assert.NoError(t, os.Chdir(tempDir))
27	t.Cleanup(func() { _ = os.Chdir(oldWd) })
28
29	tests := []struct {
30		name         string
31		args         []string
32		managerStubs func(em *extensions.ExtensionManagerMock) func(*testing.T)
33		askStubs     func(as *prompt.AskStubber)
34		isTTY        bool
35		wantErr      bool
36		errMsg       string
37		wantStdout   string
38		wantStderr   string
39	}{
40		{
41			name: "install an extension",
42			args: []string{"install", "owner/gh-some-ext"},
43			managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
44				em.ListFunc = func(bool) []extensions.Extension {
45					return []extensions.Extension{}
46				}
47				em.InstallFunc = func(_ ghrepo.Interface) error {
48					return nil
49				}
50				return func(t *testing.T) {
51					installCalls := em.InstallCalls()
52					assert.Equal(t, 1, len(installCalls))
53					assert.Equal(t, "gh-some-ext", installCalls[0].InterfaceMoqParam.RepoName())
54					listCalls := em.ListCalls()
55					assert.Equal(t, 1, len(listCalls))
56				}
57			},
58		},
59		{
60			name: "install an extension with same name as existing extension",
61			args: []string{"install", "owner/gh-existing-ext"},
62			managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
63				em.ListFunc = func(bool) []extensions.Extension {
64					e := &Extension{path: "owner2/gh-existing-ext"}
65					return []extensions.Extension{e}
66				}
67				return func(t *testing.T) {
68					calls := em.ListCalls()
69					assert.Equal(t, 1, len(calls))
70				}
71			},
72			wantErr: true,
73			errMsg:  "there is already an installed extension that provides the \"existing-ext\" command",
74		},
75		{
76			name: "install local extension",
77			args: []string{"install", "."},
78			managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
79				em.InstallLocalFunc = func(dir string) error {
80					return nil
81				}
82				return func(t *testing.T) {
83					calls := em.InstallLocalCalls()
84					assert.Equal(t, 1, len(calls))
85					assert.Equal(t, tempDir, normalizeDir(calls[0].Dir))
86				}
87			},
88		},
89		{
90			name:    "upgrade argument error",
91			args:    []string{"upgrade"},
92			wantErr: true,
93			errMsg:  "specify an extension to upgrade or `--all`",
94		},
95		{
96			name: "upgrade an extension",
97			args: []string{"upgrade", "hello"},
98			managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
99				em.UpgradeFunc = func(name string, force bool) error {
100					return nil
101				}
102				return func(t *testing.T) {
103					calls := em.UpgradeCalls()
104					assert.Equal(t, 1, len(calls))
105					assert.Equal(t, "hello", calls[0].Name)
106				}
107			},
108			isTTY:      true,
109			wantStdout: "✓ Successfully upgraded extension hello\n",
110		},
111		{
112			name: "upgrade an extension notty",
113			args: []string{"upgrade", "hello"},
114			managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
115				em.UpgradeFunc = func(name string, force bool) error {
116					return nil
117				}
118				return func(t *testing.T) {
119					calls := em.UpgradeCalls()
120					assert.Equal(t, 1, len(calls))
121					assert.Equal(t, "hello", calls[0].Name)
122				}
123			},
124			isTTY: false,
125		},
126		{
127			name: "upgrade an up-to-date extension",
128			args: []string{"upgrade", "hello"},
129			managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
130				em.UpgradeFunc = func(name string, force bool) error {
131					return upToDateError
132				}
133				return func(t *testing.T) {
134					calls := em.UpgradeCalls()
135					assert.Equal(t, 1, len(calls))
136					assert.Equal(t, "hello", calls[0].Name)
137				}
138			},
139			isTTY:      true,
140			wantStdout: "✓ Extension already up to date\n",
141			wantStderr: "",
142		},
143		{
144			name: "upgrade extension error",
145			args: []string{"upgrade", "hello"},
146			managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
147				em.UpgradeFunc = func(name string, force bool) error {
148					return errors.New("oh no")
149				}
150				return func(t *testing.T) {
151					calls := em.UpgradeCalls()
152					assert.Equal(t, 1, len(calls))
153					assert.Equal(t, "hello", calls[0].Name)
154				}
155			},
156			isTTY:      false,
157			wantErr:    true,
158			errMsg:     "SilentError",
159			wantStdout: "",
160			wantStderr: "X Failed upgrading extension hello: oh no\n",
161		},
162		{
163			name: "upgrade an extension gh-prefix",
164			args: []string{"upgrade", "gh-hello"},
165			managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
166				em.UpgradeFunc = func(name string, force bool) error {
167					return nil
168				}
169				return func(t *testing.T) {
170					calls := em.UpgradeCalls()
171					assert.Equal(t, 1, len(calls))
172					assert.Equal(t, "hello", calls[0].Name)
173				}
174			},
175			isTTY:      true,
176			wantStdout: "✓ Successfully upgraded extension hello\n",
177		},
178		{
179			name: "upgrade an extension full name",
180			args: []string{"upgrade", "monalisa/gh-hello"},
181			managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
182				em.UpgradeFunc = func(name string, force bool) error {
183					return nil
184				}
185				return func(t *testing.T) {
186					calls := em.UpgradeCalls()
187					assert.Equal(t, 1, len(calls))
188					assert.Equal(t, "hello", calls[0].Name)
189				}
190			},
191			isTTY:      true,
192			wantStdout: "✓ Successfully upgraded extension hello\n",
193		},
194		{
195			name: "upgrade all",
196			args: []string{"upgrade", "--all"},
197			managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
198				em.UpgradeFunc = func(name string, force bool) error {
199					return nil
200				}
201				return func(t *testing.T) {
202					calls := em.UpgradeCalls()
203					assert.Equal(t, 1, len(calls))
204					assert.Equal(t, "", calls[0].Name)
205				}
206			},
207			isTTY:      true,
208			wantStdout: "✓ Successfully upgraded extensions\n",
209		},
210		{
211			name: "upgrade all notty",
212			args: []string{"upgrade", "--all"},
213			managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
214				em.UpgradeFunc = func(name string, force bool) error {
215					return nil
216				}
217				return func(t *testing.T) {
218					calls := em.UpgradeCalls()
219					assert.Equal(t, 1, len(calls))
220					assert.Equal(t, "", calls[0].Name)
221				}
222			},
223			isTTY: false,
224		},
225		{
226			name: "remove extension tty",
227			args: []string{"remove", "hello"},
228			managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
229				em.RemoveFunc = func(name string) error {
230					return nil
231				}
232				return func(t *testing.T) {
233					calls := em.RemoveCalls()
234					assert.Equal(t, 1, len(calls))
235					assert.Equal(t, "hello", calls[0].Name)
236				}
237			},
238			isTTY:      true,
239			wantStdout: "✓ Removed extension hello\n",
240		},
241		{
242			name: "remove extension nontty",
243			args: []string{"remove", "hello"},
244			managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
245				em.RemoveFunc = func(name string) error {
246					return nil
247				}
248				return func(t *testing.T) {
249					calls := em.RemoveCalls()
250					assert.Equal(t, 1, len(calls))
251					assert.Equal(t, "hello", calls[0].Name)
252				}
253			},
254			isTTY:      false,
255			wantStdout: "",
256		},
257		{
258			name: "remove extension gh-prefix",
259			args: []string{"remove", "gh-hello"},
260			managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
261				em.RemoveFunc = func(name string) error {
262					return nil
263				}
264				return func(t *testing.T) {
265					calls := em.RemoveCalls()
266					assert.Equal(t, 1, len(calls))
267					assert.Equal(t, "hello", calls[0].Name)
268				}
269			},
270			isTTY:      false,
271			wantStdout: "",
272		},
273		{
274			name: "remove extension full name",
275			args: []string{"remove", "monalisa/gh-hello"},
276			managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
277				em.RemoveFunc = func(name string) error {
278					return nil
279				}
280				return func(t *testing.T) {
281					calls := em.RemoveCalls()
282					assert.Equal(t, 1, len(calls))
283					assert.Equal(t, "hello", calls[0].Name)
284				}
285			},
286			isTTY:      false,
287			wantStdout: "",
288		},
289		{
290			name: "list extensions",
291			args: []string{"list"},
292			managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
293				em.ListFunc = func(bool) []extensions.Extension {
294					ex1 := &Extension{path: "cli/gh-test", url: "https://github.com/cli/gh-test", currentVersion: "1", latestVersion: "1"}
295					ex2 := &Extension{path: "cli/gh-test2", url: "https://github.com/cli/gh-test2", currentVersion: "1", latestVersion: "2"}
296					return []extensions.Extension{ex1, ex2}
297				}
298				return func(t *testing.T) {
299					assert.Equal(t, 1, len(em.ListCalls()))
300				}
301			},
302			wantStdout: "gh test\tcli/gh-test\t\ngh test2\tcli/gh-test2\tUpgrade available\n",
303		},
304		{
305			name: "create extension interactive",
306			args: []string{"create"},
307			managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
308				em.CreateFunc = func(name string, tmplType extensions.ExtTemplateType) error {
309					return nil
310				}
311				return func(t *testing.T) {
312					calls := em.CreateCalls()
313					assert.Equal(t, 1, len(calls))
314					assert.Equal(t, "gh-test", calls[0].Name)
315				}
316			},
317			isTTY: true,
318			askStubs: func(as *prompt.AskStubber) {
319				as.StubOne("test")
320				as.StubOne(0)
321			},
322			wantStdout: heredoc.Doc(`
323Created directory gh-test
324Initialized git repository
325Set up extension scaffolding
326
327				gh-test is ready for development!
328
329				Next Steps
330				- run 'cd gh-test; gh extension install .; gh test' to see your new extension in action
331				- commit and use 'gh repo create' to share your extension with others
332
333				For more information on writing extensions:
334				https://docs.github.com/github-cli/github-cli/creating-github-cli-extensions
335			`),
336		},
337		{
338			name: "create extension with arg, --precompiled=go",
339			args: []string{"create", "test", "--precompiled", "go"},
340			managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
341				em.CreateFunc = func(name string, tmplType extensions.ExtTemplateType) error {
342					return nil
343				}
344				return func(t *testing.T) {
345					calls := em.CreateCalls()
346					assert.Equal(t, 1, len(calls))
347					assert.Equal(t, "gh-test", calls[0].Name)
348				}
349			},
350			isTTY: true,
351			wantStdout: heredoc.Doc(`
352Created directory gh-test
353Initialized git repository
354Set up extension scaffolding
355Downloaded Go dependencies
356Built gh-test binary
357
358				gh-test is ready for development!
359
360				Next Steps
361				- run 'cd gh-test; gh extension install .; gh test' to see your new extension in action
362				- use 'go build && gh test' to see changes in your code as you develop
363				- commit and use 'gh repo create' to share your extension with others
364
365				For more information on writing extensions:
366				https://docs.github.com/github-cli/github-cli/creating-github-cli-extensions
367			`),
368		},
369		{
370			name: "create extension with arg, --precompiled=other",
371			args: []string{"create", "test", "--precompiled", "other"},
372			managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
373				em.CreateFunc = func(name string, tmplType extensions.ExtTemplateType) error {
374					return nil
375				}
376				return func(t *testing.T) {
377					calls := em.CreateCalls()
378					assert.Equal(t, 1, len(calls))
379					assert.Equal(t, "gh-test", calls[0].Name)
380				}
381			},
382			isTTY: true,
383			wantStdout: heredoc.Doc(`
384Created directory gh-test
385Initialized git repository
386Set up extension scaffolding
387
388				gh-test is ready for development!
389
390				Next Steps
391				- run 'cd gh-test; gh extension install .' to install your extension locally
392				- fill in script/build.sh with your compilation script for automated builds
393				- compile a gh-test binary locally and run 'gh test' to see changes
394				- commit and use 'gh repo create' to share your extension with others
395
396				For more information on writing extensions:
397				https://docs.github.com/github-cli/github-cli/creating-github-cli-extensions
398			`),
399		},
400		{
401			name: "create extension tty with argument",
402			args: []string{"create", "test"},
403			managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
404				em.CreateFunc = func(name string, tmplType extensions.ExtTemplateType) error {
405					return nil
406				}
407				return func(t *testing.T) {
408					calls := em.CreateCalls()
409					assert.Equal(t, 1, len(calls))
410					assert.Equal(t, "gh-test", calls[0].Name)
411				}
412			},
413			isTTY: true,
414			wantStdout: heredoc.Doc(`
415Created directory gh-test
416Initialized git repository
417Set up extension scaffolding
418
419				gh-test is ready for development!
420
421				Next Steps
422				- run 'cd gh-test; gh extension install .; gh test' to see your new extension in action
423				- commit and use 'gh repo create' to share your extension with others
424
425				For more information on writing extensions:
426				https://docs.github.com/github-cli/github-cli/creating-github-cli-extensions
427			`),
428		},
429		{
430			name: "create extension notty",
431			args: []string{"create", "gh-test"},
432			managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
433				em.CreateFunc = func(name string, tmplType extensions.ExtTemplateType) error {
434					return nil
435				}
436				return func(t *testing.T) {
437					calls := em.CreateCalls()
438					assert.Equal(t, 1, len(calls))
439					assert.Equal(t, "gh-test", calls[0].Name)
440				}
441			},
442			isTTY:      false,
443			wantStdout: "",
444		},
445	}
446
447	for _, tt := range tests {
448		t.Run(tt.name, func(t *testing.T) {
449			ios, _, stdout, stderr := iostreams.Test()
450			ios.SetStdoutTTY(tt.isTTY)
451			ios.SetStderrTTY(tt.isTTY)
452
453			var assertFunc func(*testing.T)
454			em := &extensions.ExtensionManagerMock{}
455			if tt.managerStubs != nil {
456				assertFunc = tt.managerStubs(em)
457			}
458
459			as, teardown := prompt.InitAskStubber()
460			defer teardown()
461			if tt.askStubs != nil {
462				tt.askStubs(as)
463			}
464
465			reg := httpmock.Registry{}
466			defer reg.Verify(t)
467			client := http.Client{Transport: &reg}
468
469			f := cmdutil.Factory{
470				Config: func() (config.Config, error) {
471					return config.NewBlankConfig(), nil
472				},
473				IOStreams:        ios,
474				ExtensionManager: em,
475				HttpClient: func() (*http.Client, error) {
476					return &client, nil
477				},
478			}
479
480			cmd := NewCmdExtension(&f)
481			cmd.SetArgs(tt.args)
482			cmd.SetOut(ioutil.Discard)
483			cmd.SetErr(ioutil.Discard)
484
485			_, err := cmd.ExecuteC()
486			if tt.wantErr {
487				assert.EqualError(t, err, tt.errMsg)
488			} else {
489				assert.NoError(t, err)
490			}
491
492			if assertFunc != nil {
493				assertFunc(t)
494			}
495
496			assert.Equal(t, tt.wantStdout, stdout.String())
497			assert.Equal(t, tt.wantStderr, stderr.String())
498		})
499	}
500}
501
502func normalizeDir(d string) string {
503	return strings.TrimPrefix(d, "/private")
504}
505
506func Test_checkValidExtension(t *testing.T) {
507	rootCmd := &cobra.Command{}
508	rootCmd.AddCommand(&cobra.Command{Use: "help"})
509	rootCmd.AddCommand(&cobra.Command{Use: "auth"})
510
511	m := &extensions.ExtensionManagerMock{
512		ListFunc: func(bool) []extensions.Extension {
513			return []extensions.Extension{
514				&extensions.ExtensionMock{
515					NameFunc: func() string { return "screensaver" },
516				},
517				&extensions.ExtensionMock{
518					NameFunc: func() string { return "triage" },
519				},
520			}
521		},
522	}
523
524	type args struct {
525		rootCmd *cobra.Command
526		manager extensions.ExtensionManager
527		extName string
528	}
529	tests := []struct {
530		name      string
531		args      args
532		wantError string
533	}{
534		{
535			name: "valid extension",
536			args: args{
537				rootCmd: rootCmd,
538				manager: m,
539				extName: "gh-hello",
540			},
541		},
542		{
543			name: "invalid extension name",
544			args: args{
545				rootCmd: rootCmd,
546				manager: m,
547				extName: "gherkins",
548			},
549			wantError: "extension repository name must start with `gh-`",
550		},
551		{
552			name: "clashes with built-in command",
553			args: args{
554				rootCmd: rootCmd,
555				manager: m,
556				extName: "gh-auth",
557			},
558			wantError: "\"auth\" matches the name of a built-in command",
559		},
560		{
561			name: "clashes with an installed extension",
562			args: args{
563				rootCmd: rootCmd,
564				manager: m,
565				extName: "gh-triage",
566			},
567			wantError: "there is already an installed extension that provides the \"triage\" command",
568		},
569	}
570	for _, tt := range tests {
571		t.Run(tt.name, func(t *testing.T) {
572			err := checkValidExtension(tt.args.rootCmd, tt.args.manager, tt.args.extName)
573			if tt.wantError == "" {
574				assert.NoError(t, err)
575			} else {
576				assert.EqualError(t, err, tt.wantError)
577			}
578		})
579	}
580}
581