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(` 323 ✓ Created directory gh-test 324 ✓ Initialized git repository 325 ✓ Set 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(` 352 ✓ Created directory gh-test 353 ✓ Initialized git repository 354 ✓ Set up extension scaffolding 355 ✓ Downloaded Go dependencies 356 ✓ Built 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(` 384 ✓ Created directory gh-test 385 ✓ Initialized git repository 386 ✓ Set 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(` 415 ✓ Created directory gh-test 416 ✓ Initialized git repository 417 ✓ Set 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: ®} 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