- canonicalheader: fix cacheTTL header value to canonical form - embeddedstructfieldcheck: move embedded fields before regular fields in 4 structs - iotamixing: split const blocks mixing iota with non-iota constants - makezero: use make([]T, 0, n) instead of make([]T, n) before appending - testableexamples: add missing Output: comment to ExampleOption_UnwrapOrZero - wastedassign: remove wasted initial assignments in 4 locations - usetesting: replace os.MkdirTemp/os.Setenv with t.TempDir/t.Setenv in tests - tparallel: add t.Parallel() to 8 top-level test functions - unconvert: remove 16 unnecessary type conversions - intrange: convert 3 for loops to use integer range syntax - iface: consolidate identical EditPrompter and Prompt interfaces via type alias Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
492 lines
13 KiB
Go
492 lines
13 KiB
Go
package update
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/cli/cli/v2/pkg/cmd/extension"
|
|
"github.com/cli/cli/v2/pkg/extensions"
|
|
"github.com/cli/cli/v2/pkg/httpmock"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestCheckForUpdate(t *testing.T) {
|
|
scenarios := []struct {
|
|
Name string
|
|
CurrentVersion string
|
|
LatestVersion string
|
|
LatestURL string
|
|
ExpectsResult bool
|
|
}{
|
|
{
|
|
Name: "latest is newer",
|
|
CurrentVersion: "v0.0.1",
|
|
LatestVersion: "v1.0.0",
|
|
LatestURL: "https://www.spacejam.com/archive/spacejam/movie/jam.htm",
|
|
ExpectsResult: true,
|
|
},
|
|
{
|
|
Name: "current is prerelease",
|
|
CurrentVersion: "v1.0.0-pre.1",
|
|
LatestVersion: "v1.0.0",
|
|
LatestURL: "https://www.spacejam.com/archive/spacejam/movie/jam.htm",
|
|
ExpectsResult: true,
|
|
},
|
|
{
|
|
Name: "current is built from source",
|
|
CurrentVersion: "v1.2.3-123-gdeadbeef",
|
|
LatestVersion: "v1.2.3",
|
|
LatestURL: "https://www.spacejam.com/archive/spacejam/movie/jam.htm",
|
|
ExpectsResult: false,
|
|
},
|
|
{
|
|
Name: "current is built from source after a prerelease",
|
|
CurrentVersion: "v1.2.3-rc.1-123-gdeadbeef",
|
|
LatestVersion: "v1.2.3",
|
|
LatestURL: "https://www.spacejam.com/archive/spacejam/movie/jam.htm",
|
|
ExpectsResult: true,
|
|
},
|
|
{
|
|
Name: "latest is newer than version build from source",
|
|
CurrentVersion: "v1.2.3-123-gdeadbeef",
|
|
LatestVersion: "v1.2.4",
|
|
LatestURL: "https://www.spacejam.com/archive/spacejam/movie/jam.htm",
|
|
ExpectsResult: true,
|
|
},
|
|
{
|
|
Name: "latest is current",
|
|
CurrentVersion: "v1.0.0",
|
|
LatestVersion: "v1.0.0",
|
|
LatestURL: "https://www.spacejam.com/archive/spacejam/movie/jam.htm",
|
|
ExpectsResult: false,
|
|
},
|
|
{
|
|
Name: "latest is older",
|
|
CurrentVersion: "v0.10.0-pre.1",
|
|
LatestVersion: "v0.9.0",
|
|
LatestURL: "https://www.spacejam.com/archive/spacejam/movie/jam.htm",
|
|
ExpectsResult: false,
|
|
},
|
|
}
|
|
|
|
for _, s := range scenarios {
|
|
t.Run(s.Name, func(t *testing.T) {
|
|
reg := &httpmock.Registry{}
|
|
httpClient := &http.Client{}
|
|
httpmock.ReplaceTripper(httpClient, reg)
|
|
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/OWNER/REPO/releases/latest"),
|
|
httpmock.StringResponse(fmt.Sprintf(`{
|
|
"tag_name": "%s",
|
|
"html_url": "%s"
|
|
}`, s.LatestVersion, s.LatestURL)),
|
|
)
|
|
|
|
rel, err := CheckForUpdate(context.TODO(), httpClient, tempFilePath(), "OWNER/REPO", s.CurrentVersion)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if len(reg.Requests) != 1 {
|
|
t.Fatalf("expected 1 HTTP request, got %d", len(reg.Requests))
|
|
}
|
|
requestPath := reg.Requests[0].URL.Path
|
|
if requestPath != "/repos/OWNER/REPO/releases/latest" {
|
|
t.Errorf("HTTP path: %q", requestPath)
|
|
}
|
|
|
|
if !s.ExpectsResult {
|
|
if rel != nil {
|
|
t.Fatal("expected no new release")
|
|
}
|
|
return
|
|
}
|
|
if rel == nil {
|
|
t.Fatal("expected to report new release")
|
|
}
|
|
|
|
if rel.Version != s.LatestVersion {
|
|
t.Errorf("Version: %q", rel.Version)
|
|
}
|
|
if rel.URL != s.LatestURL {
|
|
t.Errorf("URL: %q", rel.URL)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckForExtensionUpdate(t *testing.T) {
|
|
now := time.Date(2024, 12, 17, 12, 0, 0, 0, time.UTC)
|
|
previousTooSoon := now.Add(-23 * time.Hour).Add(-59 * time.Minute).Add(-59 * time.Second)
|
|
previousOldEnough := now.Add(-24 * time.Hour)
|
|
|
|
tests := []struct {
|
|
name string
|
|
extCurrentVersion string
|
|
extLatestVersion string
|
|
extKind extension.ExtensionKind
|
|
extURL string
|
|
previousStateEntry *StateEntry
|
|
expectedStateEntry *StateEntry
|
|
expectedReleaseInfo *ReleaseInfo
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "return latest release given git extension is out of date and no state entry",
|
|
extCurrentVersion: "v0.1.0",
|
|
extLatestVersion: "v1.0.0",
|
|
extKind: extension.GitKind,
|
|
extURL: "http://example.com",
|
|
expectedStateEntry: &StateEntry{
|
|
CheckedForUpdateAt: now,
|
|
LatestRelease: ReleaseInfo{
|
|
Version: "v1.0.0",
|
|
URL: "http://example.com",
|
|
},
|
|
},
|
|
expectedReleaseInfo: &ReleaseInfo{
|
|
Version: "v1.0.0",
|
|
URL: "http://example.com",
|
|
},
|
|
},
|
|
{
|
|
name: "return latest release given git extension is out of date and state entry is old enough",
|
|
extCurrentVersion: "v0.1.0",
|
|
extLatestVersion: "v1.0.0",
|
|
extKind: extension.GitKind,
|
|
extURL: "http://example.com",
|
|
previousStateEntry: &StateEntry{
|
|
CheckedForUpdateAt: previousOldEnough,
|
|
LatestRelease: ReleaseInfo{
|
|
Version: "v0.1.0",
|
|
URL: "http://example.com",
|
|
},
|
|
},
|
|
expectedStateEntry: &StateEntry{
|
|
CheckedForUpdateAt: now,
|
|
LatestRelease: ReleaseInfo{
|
|
Version: "v1.0.0",
|
|
URL: "http://example.com",
|
|
},
|
|
},
|
|
expectedReleaseInfo: &ReleaseInfo{
|
|
Version: "v1.0.0",
|
|
URL: "http://example.com",
|
|
},
|
|
},
|
|
{
|
|
name: "return nothing given git extension is out of date but state entry is too recent",
|
|
extCurrentVersion: "v0.1.0",
|
|
extLatestVersion: "v1.0.0",
|
|
extKind: extension.GitKind,
|
|
extURL: "http://example.com",
|
|
previousStateEntry: &StateEntry{
|
|
CheckedForUpdateAt: previousTooSoon,
|
|
LatestRelease: ReleaseInfo{
|
|
Version: "v0.1.0",
|
|
URL: "http://example.com",
|
|
},
|
|
},
|
|
expectedStateEntry: &StateEntry{
|
|
CheckedForUpdateAt: previousTooSoon,
|
|
LatestRelease: ReleaseInfo{
|
|
Version: "v0.1.0",
|
|
URL: "http://example.com",
|
|
},
|
|
},
|
|
expectedReleaseInfo: nil,
|
|
},
|
|
{
|
|
name: "return latest release given binary extension is out of date and no state entry",
|
|
extCurrentVersion: "v0.1.0",
|
|
extLatestVersion: "v1.0.0",
|
|
extKind: extension.BinaryKind,
|
|
extURL: "http://example.com",
|
|
expectedStateEntry: &StateEntry{
|
|
CheckedForUpdateAt: now,
|
|
LatestRelease: ReleaseInfo{
|
|
Version: "v1.0.0",
|
|
URL: "http://example.com",
|
|
},
|
|
},
|
|
expectedReleaseInfo: &ReleaseInfo{
|
|
Version: "v1.0.0",
|
|
URL: "http://example.com",
|
|
},
|
|
},
|
|
{
|
|
name: "return latest release given binary extension is out of date and state entry is old enough",
|
|
extCurrentVersion: "v0.1.0",
|
|
extLatestVersion: "v1.0.0",
|
|
extKind: extension.BinaryKind,
|
|
extURL: "http://example.com",
|
|
previousStateEntry: &StateEntry{
|
|
CheckedForUpdateAt: previousOldEnough,
|
|
LatestRelease: ReleaseInfo{
|
|
Version: "v0.1.0",
|
|
URL: "http://example.com",
|
|
},
|
|
},
|
|
expectedStateEntry: &StateEntry{
|
|
CheckedForUpdateAt: now,
|
|
LatestRelease: ReleaseInfo{
|
|
Version: "v1.0.0",
|
|
URL: "http://example.com",
|
|
},
|
|
},
|
|
expectedReleaseInfo: &ReleaseInfo{
|
|
Version: "v1.0.0",
|
|
URL: "http://example.com",
|
|
},
|
|
},
|
|
{
|
|
name: "return nothing given binary extension is out of date but state entry is too recent",
|
|
extCurrentVersion: "v0.1.0",
|
|
extLatestVersion: "v1.0.0",
|
|
extKind: extension.BinaryKind,
|
|
extURL: "http://example.com",
|
|
previousStateEntry: &StateEntry{
|
|
CheckedForUpdateAt: previousTooSoon,
|
|
LatestRelease: ReleaseInfo{
|
|
Version: "v0.1.0",
|
|
URL: "http://example.com",
|
|
},
|
|
},
|
|
expectedStateEntry: &StateEntry{
|
|
CheckedForUpdateAt: previousTooSoon,
|
|
LatestRelease: ReleaseInfo{
|
|
Version: "v0.1.0",
|
|
URL: "http://example.com",
|
|
},
|
|
},
|
|
expectedReleaseInfo: nil,
|
|
},
|
|
{
|
|
name: "return nothing given local extension with no state entry",
|
|
extCurrentVersion: "v0.1.0",
|
|
extLatestVersion: "v1.0.0",
|
|
extKind: extension.LocalKind,
|
|
extURL: "http://example.com",
|
|
expectedStateEntry: nil,
|
|
expectedReleaseInfo: nil,
|
|
},
|
|
{
|
|
name: "return nothing given local extension despite state entry is old enough",
|
|
extCurrentVersion: "v0.1.0",
|
|
extLatestVersion: "v1.0.0",
|
|
extKind: extension.LocalKind,
|
|
extURL: "http://example.com",
|
|
previousStateEntry: &StateEntry{
|
|
CheckedForUpdateAt: previousOldEnough,
|
|
LatestRelease: ReleaseInfo{
|
|
Version: "v0.1.0",
|
|
URL: "http://example.com",
|
|
},
|
|
},
|
|
expectedStateEntry: &StateEntry{
|
|
CheckedForUpdateAt: previousOldEnough,
|
|
LatestRelease: ReleaseInfo{
|
|
Version: "v0.1.0",
|
|
URL: "http://example.com",
|
|
},
|
|
},
|
|
expectedReleaseInfo: nil,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
updateDir := t.TempDir()
|
|
em := &extensions.ExtensionManagerMock{
|
|
UpdateDirFunc: func(name string) string {
|
|
return filepath.Join(updateDir, name)
|
|
},
|
|
}
|
|
|
|
ext := &extensions.ExtensionMock{
|
|
NameFunc: func() string {
|
|
return "extension-update-test"
|
|
},
|
|
CurrentVersionFunc: func() string {
|
|
return tt.extCurrentVersion
|
|
},
|
|
LatestVersionFunc: func() string {
|
|
return tt.extLatestVersion
|
|
},
|
|
IsLocalFunc: func() bool {
|
|
return tt.extKind == extension.LocalKind
|
|
},
|
|
IsBinaryFunc: func() bool {
|
|
return tt.extKind == extension.BinaryKind
|
|
},
|
|
URLFunc: func() string {
|
|
return tt.extURL
|
|
},
|
|
}
|
|
|
|
// UpdateAvailable is arguably code under test but moq does not support partial mocks so this is a little brittle.
|
|
ext.UpdateAvailableFunc = func() bool {
|
|
if ext.IsLocal() {
|
|
panic("Local extensions do not get update notices")
|
|
}
|
|
|
|
// Actual extension versions should drive tests instead of managing UpdateAvailable separately.
|
|
current := ext.CurrentVersion()
|
|
latest := ext.LatestVersion()
|
|
return current != "" && latest != "" && current != latest
|
|
}
|
|
|
|
// Setup previous state file for test as necessary
|
|
stateFilePath := filepath.Join(em.UpdateDir(ext.Name()), "state.yml")
|
|
if tt.previousStateEntry != nil {
|
|
require.NoError(t, setStateEntry(stateFilePath, tt.previousStateEntry.CheckedForUpdateAt, tt.previousStateEntry.LatestRelease))
|
|
}
|
|
|
|
actual, err := CheckForExtensionUpdate(em, ext, now)
|
|
if tt.wantErr {
|
|
require.Error(t, err)
|
|
return
|
|
}
|
|
|
|
require.Equal(t, tt.expectedReleaseInfo, actual)
|
|
|
|
if tt.expectedStateEntry == nil {
|
|
require.NoFileExists(t, stateFilePath)
|
|
} else {
|
|
stateEntry, err := getStateEntry(stateFilePath)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tt.expectedStateEntry, stateEntry)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestShouldCheckForUpdate(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
env map[string]string
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "should not check when user has explicitly disable notifications",
|
|
env: map[string]string{
|
|
"GH_NO_UPDATE_NOTIFIER": "1",
|
|
},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "should not check when user is in codespace",
|
|
env: map[string]string{
|
|
"CODESPACES": "1",
|
|
},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "should not check when in GitHub Actions / Travis / Circle / Cirrus / GitLab / AppVeyor / CodeShip / dsari",
|
|
env: map[string]string{
|
|
"CI": "1",
|
|
},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "should not check when in Jenkins / TeamCity",
|
|
env: map[string]string{
|
|
"BUILD_NUMBER": "1",
|
|
},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "should not check when in TaskCluster / dsari",
|
|
env: map[string]string{
|
|
"RUN_ID": "1",
|
|
},
|
|
expected: false,
|
|
},
|
|
// TODO: Figure out how to refactor IsTerminal() to be testable
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
os.Clearenv()
|
|
for k, v := range tt.env {
|
|
t.Setenv(k, v)
|
|
}
|
|
|
|
actual := ShouldCheckForUpdate()
|
|
require.Equal(t, tt.expected, actual)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestShouldCheckForExtensionUpdate(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
env map[string]string
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "should not check when user has explicitly disable notifications",
|
|
env: map[string]string{
|
|
"GH_NO_EXTENSION_UPDATE_NOTIFIER": "1",
|
|
},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "should not check when user is in codespace",
|
|
env: map[string]string{
|
|
"CODESPACES": "1",
|
|
},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "should not check when in GitHub Actions / Travis / Circle / Cirrus / GitLab / AppVeyor / CodeShip / dsari",
|
|
env: map[string]string{
|
|
"CI": "1",
|
|
},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "should not check when in Jenkins / TeamCity",
|
|
env: map[string]string{
|
|
"BUILD_NUMBER": "1",
|
|
},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "should not check when in TaskCluster / dsari",
|
|
env: map[string]string{
|
|
"RUN_ID": "1",
|
|
},
|
|
expected: false,
|
|
},
|
|
// TODO: Figure out how to refactor IsTerminal() to be testable
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
os.Clearenv()
|
|
for k, v := range tt.env {
|
|
t.Setenv(k, v)
|
|
}
|
|
|
|
actual := ShouldCheckForExtensionUpdate()
|
|
require.Equal(t, tt.expected, actual)
|
|
})
|
|
}
|
|
}
|
|
|
|
func tempFilePath() string {
|
|
file, err := os.CreateTemp("", "")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
os.Remove(file.Name())
|
|
return file.Name()
|
|
}
|