package root_test import ( "fmt" "io" "testing" "time" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/update" "github.com/cli/cli/v2/pkg/cmd/root" "github.com/cli/cli/v2/pkg/extensions" "github.com/cli/cli/v2/pkg/iostreams" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewCmdExtension_Updates(t *testing.T) { tests := []struct { name string extCurrentVersion string extIsPinned bool extLatestVersion string extName string extUpdateAvailable bool extURL string wantStderr string }{ { name: "no update available", extName: "no-update", extUpdateAvailable: false, extCurrentVersion: "1.0.0", extLatestVersion: "1.0.0", extURL: "https//github.com/dne/no-update", }, { name: "major update", extName: "major-update", extUpdateAvailable: true, extCurrentVersion: "1.0.0", extLatestVersion: "2.0.0", extURL: "https//github.com/dne/major-update", wantStderr: heredoc.Doc(` A new release of major-update is available: 1.0.0 → 2.0.0 To upgrade, run: gh extension upgrade major-update https//github.com/dne/major-update `), }, { name: "major update, pinned", extName: "major-update-pin", extUpdateAvailable: true, extCurrentVersion: "1.0.0", extLatestVersion: "2.0.0", extIsPinned: true, extURL: "https//github.com/dne/major-update", wantStderr: heredoc.Doc(` A new release of major-update-pin is available: 1.0.0 → 2.0.0 To upgrade, run: gh extension upgrade major-update-pin --force https//github.com/dne/major-update `), }, { name: "minor update", extName: "minor-update", extUpdateAvailable: true, extCurrentVersion: "1.0.0", extLatestVersion: "1.1.0", extURL: "https//github.com/dne/minor-update", wantStderr: heredoc.Doc(` A new release of minor-update is available: 1.0.0 → 1.1.0 To upgrade, run: gh extension upgrade minor-update https//github.com/dne/minor-update `), }, { name: "minor update, pinned", extName: "minor-update-pin", extUpdateAvailable: true, extCurrentVersion: "1.0.0", extLatestVersion: "1.1.0", extURL: "https//github.com/dne/minor-update", extIsPinned: true, wantStderr: heredoc.Doc(` A new release of minor-update-pin is available: 1.0.0 → 1.1.0 To upgrade, run: gh extension upgrade minor-update-pin --force https//github.com/dne/minor-update `), }, { name: "patch update", extName: "patch-update", extUpdateAvailable: true, extCurrentVersion: "1.0.0", extLatestVersion: "1.0.1", extURL: "https//github.com/dne/patch-update", wantStderr: heredoc.Doc(` A new release of patch-update is available: 1.0.0 → 1.0.1 To upgrade, run: gh extension upgrade patch-update https//github.com/dne/patch-update `), }, { name: "patch update, pinned", extName: "patch-update-pin", extUpdateAvailable: true, extCurrentVersion: "1.0.0", extLatestVersion: "1.0.1", extURL: "https//github.com/dne/patch-update", extIsPinned: true, wantStderr: heredoc.Doc(` A new release of patch-update-pin is available: 1.0.0 → 1.0.1 To upgrade, run: gh extension upgrade patch-update-pin --force https//github.com/dne/patch-update `), }, } for _, tt := range tests { ios, _, _, stderr := iostreams.Test() em := &extensions.ExtensionManagerMock{ DispatchFunc: func(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) (bool, error) { // Assume extension executed / dispatched without problems as test is focused on upgrade checking. // Sleep for 100 milliseconds to allow update checking logic to complete. This would be better // served by making the behaviour controllable by channels, but it's a larger change than desired // just to improve the test. time.Sleep(100 * time.Millisecond) return true, nil }, } ext := &extensions.ExtensionMock{ CurrentVersionFunc: func() string { return tt.extCurrentVersion }, IsPinnedFunc: func() bool { return tt.extIsPinned }, LatestVersionFunc: func() string { return tt.extLatestVersion }, NameFunc: func() string { return tt.extName }, OwnerFunc: func() string { return "" }, UpdateAvailableFunc: func() bool { return tt.extUpdateAvailable }, URLFunc: func() string { return tt.extURL }, } checkFunc := func(em extensions.ExtensionManager, ext extensions.Extension) (*update.ReleaseInfo, error) { if !tt.extUpdateAvailable { return nil, nil } return &update.ReleaseInfo{ Version: tt.extLatestVersion, URL: tt.extURL, }, nil } cmd := root.NewCmdExtension(ios, em, ext, checkFunc) _, err := cmd.ExecuteC() require.NoError(t, err) if tt.wantStderr == "" { assert.Emptyf(t, stderr.String(), "executing extension command should output nothing to stderr") } else { assert.Containsf(t, stderr.String(), tt.wantStderr, "executing extension command should output message about upgrade to stderr") } } } func TestNewCmdExtension_UpdateCheckIsNonblocking(t *testing.T) { ios, _, _, _ := iostreams.Test() em := &extensions.ExtensionManagerMock{ DispatchFunc: func(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) (bool, error) { // Assume extension executed / dispatched without problems as test is focused on upgrade checking. return true, nil }, } ext := &extensions.ExtensionMock{ CurrentVersionFunc: func() string { return "1.0.0" }, IsPinnedFunc: func() bool { return false }, LatestVersionFunc: func() string { return "2.0.0" }, NameFunc: func() string { return "major-update" }, OwnerFunc: func() string { return "" }, UpdateAvailableFunc: func() bool { return true }, URLFunc: func() string { return "https//github.com/dne/major-update" }, } // When the extension command is executed, the checkFunc will run in the background longer than the extension dispatch. // If the update check is non-blocking, then the extension command will complete immediately while checkFunc is still running. checkFunc := func(em extensions.ExtensionManager, ext extensions.Extension) (*update.ReleaseInfo, error) { time.Sleep(30 * time.Second) return nil, fmt.Errorf("update check should not have completed") } cmd := root.NewCmdExtension(ios, em, ext, checkFunc) // The test whether update check is non-blocking is based on how long it takes for the extension command execution. // If there is no wait time as checkFunc is sleeping sufficiently long, we can trust update check is non-blocking. // Otherwise, if any amount of wait is encountered, it is a decent indicator that update checking is blocking. // This is not an ideal test and indicates the update design should be revisited to be easier to understand and manage. completed := make(chan struct{}) go func() { _, err := cmd.ExecuteC() require.NoError(t, err) close(completed) }() select { case <-completed: // Expected behavior assuming extension dispatch exits immediately while checkFunc is still running. case <-time.After(1 * time.Second): t.Fatal("extension update check should have exited") } } func TestNewCmdExtension_TelemetryEnabledForOfficialExtensions(t *testing.T) { tests := []struct { name string extName string extOwner string wantTelemetryOff bool }{ { name: "official extension records telemetry", extName: "stack", extOwner: "github", wantTelemetryOff: false, }, { name: "official name with third-party owner disables telemetry", extName: "stack", extOwner: "williammartin", wantTelemetryOff: true, }, { name: "official name with empty owner disables telemetry", extName: "stack", extOwner: "", wantTelemetryOff: true, }, { name: "official extension name with mixed case disables telemetry", extName: "STACK", extOwner: "github", wantTelemetryOff: true, }, { name: "third-party extension disables telemetry", extName: "my-custom-ext", extOwner: "someone", wantTelemetryOff: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ios, _, _, _ := iostreams.Test() em := &extensions.ExtensionManagerMock{} ext := &extensions.ExtensionMock{ NameFunc: func() string { return tt.extName }, OwnerFunc: func() string { return tt.extOwner }, } cmd := root.NewCmdExtension(ios, em, ext, func(extensions.ExtensionManager, extensions.Extension) (*update.ReleaseInfo, error) { return nil, nil }) assert.Equal(t, tt.wantTelemetryOff, cmd.Annotations["telemetry"] == "disabled") }) } }