diff --git a/cmd/entire/cli/integration_test/interactive.go b/cmd/entire/cli/integration_test/interactive.go index abc5bda5e1..543246b34d 100644 --- a/cmd/entire/cli/integration_test/interactive.go +++ b/cmd/entire/cli/integration_test/interactive.go @@ -26,7 +26,8 @@ func (env *TestEnv) RunCommandInteractive(args []string, respond func(ptyFile *o cmd.Env = append(testutil.GitIsolatedEnv(), "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+env.ClaudeProjectDir, "TERM=xterm", - "ACCESSIBLE=1", // Required: makes huh read from stdin instead of /dev/tty + "ACCESSIBLE=1", // Required: makes huh read from stdin instead of /dev/tty + "ENTIRE_TEST_TTY=1", // Force CanPromptInteractively()=true: the subprocess has a real pty but may inherit CI=true from the runner, which would otherwise short-circuit the interactive gate. ) // Start command with a pty diff --git a/cmd/entire/cli/interactive/interactive.go b/cmd/entire/cli/interactive/interactive.go index f3e57544c2..8433edcdfa 100644 --- a/cmd/entire/cli/interactive/interactive.go +++ b/cmd/entire/cli/interactive/interactive.go @@ -25,7 +25,7 @@ import ( // actual environment, so tests that need a specific answer should set // ENTIRE_TEST_TTY explicitly rather than assume a non-interactive host. func CanPromptInteractively() bool { - if v, ok := os.LookupEnv("ENTIRE_TEST_TTY"); ok { + if v := os.Getenv("ENTIRE_TEST_TTY"); v != "" { return v == "1" } @@ -43,6 +43,15 @@ func CanPromptInteractively() bool { return false } + // CI= is the de-facto CI-provider convention (GitHub Actions, + // CircleCI, GitLab, Travis, Buildkite). Self-hosted runners expose /dev/tty, + // so the probe below isn't enough — an interactive prompt on CI hangs. + // CI=false is the `is-ci` escape hatch for developers who need to override + // an inherited value. + if v := os.Getenv("CI"); v != "" && v != "false" { + return false + } + tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0) if err != nil { return false diff --git a/cmd/entire/cli/interactive/interactive_test.go b/cmd/entire/cli/interactive/interactive_test.go index 5516aebfc1..9bb6cae6f8 100644 --- a/cmd/entire/cli/interactive/interactive_test.go +++ b/cmd/entire/cli/interactive/interactive_test.go @@ -44,6 +44,26 @@ func TestCanPromptInteractively_AgentEnvGuards(t *testing.T) { } } +func TestCanPromptInteractively_CIEnv(t *testing.T) { + t.Setenv("ENTIRE_TEST_TTY", "") + _ = os.Unsetenv("ENTIRE_TEST_TTY") + t.Setenv("CI", "true") + if CanPromptInteractively() { + t.Error("CanPromptInteractively() = true; want false when CI=true") + } +} + +// CI=false is the `is-ci` escape hatch: a dev may set it to override an +// inherited CI=true. Verify the CI branch doesn't short-circuit to false, +// using ENTIRE_TEST_TTY=1 to stand in for a real TTY in the test host. +func TestCanPromptInteractively_CIFalseOverride(t *testing.T) { + t.Setenv("CI", "false") + t.Setenv("ENTIRE_TEST_TTY", "1") + if !CanPromptInteractively() { + t.Error("CanPromptInteractively() = false; want true when CI=false") + } +} + func TestIsTerminalWriter_NonFile(t *testing.T) { t.Parallel() if IsTerminalWriter(&bytes.Buffer{}) { diff --git a/cmd/entire/cli/setup_github.go b/cmd/entire/cli/setup_github.go index 56c01f8be0..c6a0e3c27a 100644 --- a/cmd/entire/cli/setup_github.go +++ b/cmd/entire/cli/setup_github.go @@ -309,7 +309,7 @@ func ghFlagsProvided(opts GitHubBootstrapOptions) bool { // confirmCreateGitHubRepo asks the user whether they want to also create // a matching GitHub repository. Interactive-only; callers gate on -// CanPromptInteractively. +// interactive.CanPromptInteractively. func confirmCreateGitHubRepo() (bool, error) { confirmed := true form := NewAccessibleForm( diff --git a/cmd/entire/cli/versioncheck/autoupdate.go b/cmd/entire/cli/versioncheck/autoupdate.go new file mode 100644 index 0000000000..e99be2dbc7 --- /dev/null +++ b/cmd/entire/cli/versioncheck/autoupdate.go @@ -0,0 +1,112 @@ +package versioncheck + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "runtime" + + "github.com/charmbracelet/huh" + + "github.com/entireio/cli/cmd/entire/cli/interactive" + "github.com/entireio/cli/cmd/entire/cli/logging" +) + +// envKillSwitch disables the interactive update prompt regardless of TTY. +const envKillSwitch = "ENTIRE_NO_AUTO_UPDATE" + +// Test seams. +var ( + runInstaller = realRunInstaller + confirmUpdate = realConfirmUpdate + isTerminalOut = interactive.IsTerminalWriter +) + +// MaybeAutoUpdate offers an interactive upgrade after the standard +// "version available" notification has been printed. Silent on every +// failure path — it must never interrupt the CLI. +// +// If the installer command fails, a hint with the exact command is +// printed so the user can retry manually. The 24h version-check cache +// is not invalidated on failure: we don't want to re-prompt on every +// invocation while an upstream issue (network, auth, repo outage) is +// still in place. +// +// When the prompt cannot be shown (kill switch set, or non-interactive +// environment like CI / agent subprocess / no TTY) the installer +// command is printed so the user still learns what to run manually. +func MaybeAutoUpdate(ctx context.Context, w io.Writer, currentVersion string) { + // Windows + unknown install manager: the POSIX curl-pipe-bash fallback + // would error if auto-run, and there's no safe native equivalent. Point + // the user at the releases page so they can download manually. + if !canAutoInstall() { + fmt.Fprintf(w, "To update, download the latest release from:\n %s\n", downloadsURL) + return + } + if os.Getenv(envKillSwitch) != "" || !interactive.CanPromptInteractively() || !isTerminalOut(w) { + fmt.Fprintf(w, "To update, run:\n %s\n", updateCommand(currentVersion)) + return + } + + confirmed, err := confirmUpdate() + if err != nil { + logging.Debug(ctx, "auto-update: prompt failed", "error", err.Error()) + return + } + if !confirmed { + return + } + + cmdStr := updateCommand(currentVersion) + fmt.Fprintf(w, "\nUpdating Entire CLI: %s\n", cmdStr) + if err := runInstaller(ctx, cmdStr); err != nil { + fmt.Fprintf(w, "Update failed: %v\nTry again later running:\n %s\n", err, cmdStr) + return + } + fmt.Fprintln(w, "Update complete. Re-run entire to use the new version.") +} + +func realConfirmUpdate() (bool, error) { + // Pre-select "Yes" so pressing Enter accepts — matches the (Y/n) UX. + confirmed := true + form := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Install the new version now?"). + Affirmative("Yes"). + Negative("No"). + Value(&confirmed), + ), + ).WithTheme(huh.ThemeDracula()) + if os.Getenv("ACCESSIBLE") != "" { + form = form.WithAccessible(true) + } + if err := form.Run(); err != nil { + if errors.Is(err, huh.ErrUserAborted) || errors.Is(err, huh.ErrTimeout) { + return false, nil + } + return false, fmt.Errorf("confirm form: %w", err) + } + return confirmed, nil +} + +// realRunInstaller shells out to the installer command, streaming stdin/stdout/stderr +// so password prompts and progress output reach the user. +func realRunInstaller(ctx context.Context, cmdStr string) error { + var c *exec.Cmd + if runtime.GOOS == goosWindows { + c = exec.CommandContext(ctx, "cmd", "/C", cmdStr) + } else { + c = exec.CommandContext(ctx, "sh", "-c", cmdStr) + } + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + if err := c.Run(); err != nil { + return fmt.Errorf("installer exited: %w", err) + } + return nil +} diff --git a/cmd/entire/cli/versioncheck/autoupdate_test.go b/cmd/entire/cli/versioncheck/autoupdate_test.go new file mode 100644 index 0000000000..e33c62d56c --- /dev/null +++ b/cmd/entire/cli/versioncheck/autoupdate_test.go @@ -0,0 +1,242 @@ +package versioncheck + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" +) + +// autoUpdateFixture wires the test seams for MaybeAutoUpdate. +type autoUpdateFixture struct { + installCalls int + installErr error + lastCommand string + confirmValue bool + confirmErr error +} + +func newAutoUpdateFixture(t *testing.T) *autoUpdateFixture { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv(envKillSwitch, "") + // Force interactive mode on by default; individual tests can opt out. + t.Setenv("ENTIRE_TEST_TTY", "1") + + f := &autoUpdateFixture{confirmValue: true} + + origRun := runInstaller + runInstaller = func(_ context.Context, cmd string) error { + f.installCalls++ + f.lastCommand = cmd + return f.installErr + } + origConfirm := confirmUpdate + confirmUpdate = func() (bool, error) { return f.confirmValue, f.confirmErr } + origIsTerminalOut := isTerminalOut + isTerminalOut = func(_ io.Writer) bool { return true } + + t.Cleanup(func() { + runInstaller = origRun + confirmUpdate = origConfirm + isTerminalOut = origIsTerminalOut + }) + return f +} + +// useBrewExecutable points the install-manager detector at a brew cellar path. +func useBrewExecutable(t *testing.T) { + t.Helper() + orig := executablePath + executablePath = func() (string, error) { + return "/opt/homebrew/Cellar/entire/1.0.0/bin/entire", nil + } + t.Cleanup(func() { executablePath = orig }) +} + +// assertManualHint checks that the "To update entire run:\n " hint +// was printed when the prompt couldn't be shown. +func assertManualHint(t *testing.T, out string) { + t.Helper() + if !strings.Contains(out, "To update, run:") { + t.Errorf("missing manual-update hint: %q", out) + } + if !strings.Contains(out, "brew upgrade --cask entire") { + t.Errorf("manual hint missing installer command: %q", out) + } +} + +func TestMaybeAutoUpdate_KillSwitch(t *testing.T) { + f := newAutoUpdateFixture(t) + useBrewExecutable(t) + t.Setenv(envKillSwitch, "1") + + var buf bytes.Buffer + MaybeAutoUpdate(context.Background(), &buf, "1.0.0") + + if f.installCalls != 0 { + t.Errorf("installer called with kill-switch set") + } + assertManualHint(t, buf.String()) +} + +func TestMaybeAutoUpdate_NoTTY(t *testing.T) { + f := newAutoUpdateFixture(t) + useBrewExecutable(t) + t.Setenv("ENTIRE_TEST_TTY", "0") + + var buf bytes.Buffer + MaybeAutoUpdate(context.Background(), &buf, "1.0.0") + + if f.installCalls != 0 { + t.Errorf("installer called without TTY") + } + assertManualHint(t, buf.String()) +} + +func TestMaybeAutoUpdate_CIEnv(t *testing.T) { + f := newAutoUpdateFixture(t) + useBrewExecutable(t) + // Clear the test override so the real CanPromptInteractively path runs. + t.Setenv("ENTIRE_TEST_TTY", "") + t.Setenv("CI", "true") + + var buf bytes.Buffer + MaybeAutoUpdate(context.Background(), &buf, "1.0.0") + + if f.installCalls != 0 { + t.Errorf("installer called on CI (CI=true)") + } + assertManualHint(t, buf.String()) +} + +func TestMaybeAutoUpdate_NonTerminalWriter(t *testing.T) { + f := newAutoUpdateFixture(t) + useBrewExecutable(t) + isTerminalOut = func(_ io.Writer) bool { return false } + + var buf bytes.Buffer + MaybeAutoUpdate(context.Background(), &buf, "1.0.0") + + if f.installCalls != 0 { + t.Errorf("installer called with non-terminal output writer") + } + assertManualHint(t, buf.String()) +} + +// TestMaybeAutoUpdate_WindowsUnknownInstallerNoAutoRun verifies that on +// Windows without a detected install manager we never execute the POSIX +// curl-pipe-bash fallback (which would error from cmd.exe). Instead the +// user is pointed at the releases download page. +func TestMaybeAutoUpdate_WindowsUnknownInstallerNoAutoRun(t *testing.T) { + f := newAutoUpdateFixture(t) + // Force unknown install manager: point executablePath at a plain + // Program Files path that matches none of the known prefixes. + orig := executablePath + executablePath = func() (string, error) { + return `C:\Program Files\Entire\entire.exe`, nil + } + t.Cleanup(func() { executablePath = orig }) + + origGOOS := goos + goos = goosWindows + t.Cleanup(func() { goos = origGOOS }) + + var buf bytes.Buffer + MaybeAutoUpdate(context.Background(), &buf, "1.0.0") + + if f.installCalls != 0 { + t.Errorf("installer was auto-run on Windows + unknown install manager") + } + out := buf.String() + if !strings.Contains(out, "download the latest release") || + !strings.Contains(out, "github.com/entireio/cli/releases") { + t.Errorf("expected download-page hint, got: %q", out) + } + if strings.Contains(out, "curl -fsSL") { + t.Errorf("Windows fallback must not show POSIX curl command: %q", out) + } +} + +// TestMaybeAutoUpdate_WindowsScoopStillAutoRuns verifies that a Windows +// scoop install still takes the interactive path — only unknown install +// managers are blocked on Windows. +func TestMaybeAutoUpdate_WindowsScoopStillAutoRuns(t *testing.T) { + f := newAutoUpdateFixture(t) + orig := executablePath + executablePath = func() (string, error) { + return `C:\Users\test\scoop\apps\cli\current\entire.exe`, nil + } + t.Cleanup(func() { executablePath = orig }) + + origGOOS := goos + goos = goosWindows + t.Cleanup(func() { goos = origGOOS }) + + var buf bytes.Buffer + MaybeAutoUpdate(context.Background(), &buf, "1.0.0") + + if f.installCalls != 1 { + t.Fatalf("scoop install should auto-run on Windows; calls=%d", f.installCalls) + } + if f.lastCommand != "scoop update entire/cli" { + t.Errorf("got %q, want scoop update entire/cli", f.lastCommand) + } +} + +func TestMaybeAutoUpdate_UserDeclines(t *testing.T) { + f := newAutoUpdateFixture(t) + useBrewExecutable(t) + f.confirmValue = false + + var buf bytes.Buffer + MaybeAutoUpdate(context.Background(), &buf, "1.0.0") + + if f.installCalls != 0 { + t.Errorf("installer called after user declined") + } +} + +func TestMaybeAutoUpdate_HappyPath(t *testing.T) { + f := newAutoUpdateFixture(t) + useBrewExecutable(t) + + var buf bytes.Buffer + MaybeAutoUpdate(context.Background(), &buf, "1.0.0") + + if f.installCalls != 1 { + t.Fatalf("installer called %d times, want 1", f.installCalls) + } + if f.lastCommand != "brew upgrade --cask entire" { + t.Errorf("installer got %q, want brew upgrade --cask entire", f.lastCommand) + } + if !strings.Contains(buf.String(), "Update complete") { + t.Errorf("missing success message: %q", buf.String()) + } +} + +func TestMaybeAutoUpdate_InstallerFailurePrintedToUser(t *testing.T) { + f := newAutoUpdateFixture(t) + useBrewExecutable(t) + f.installErr = errors.New("boom") + + var buf bytes.Buffer + MaybeAutoUpdate(context.Background(), &buf, "1.0.0") + + if f.installCalls != 1 { + t.Fatalf("installer called %d times, want 1", f.installCalls) + } + out := buf.String() + if !strings.Contains(out, "Update failed") { + t.Errorf("missing failure message: %q", out) + } + // Failure message should include a manual-retry hint with the exact command. + if !strings.Contains(out, "Try again later running:") { + t.Errorf("missing retry hint: %q", out) + } + if !strings.Contains(out, "brew upgrade --cask entire") { + t.Errorf("retry hint missing installer command: %q", out) + } +} diff --git a/cmd/entire/cli/versioncheck/versioncheck.go b/cmd/entire/cli/versioncheck/versioncheck.go index 76391d525c..4f09832ae0 100644 --- a/cmd/entire/cli/versioncheck/versioncheck.go +++ b/cmd/entire/cli/versioncheck/versioncheck.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "path/filepath" + "runtime" "strings" "time" @@ -17,6 +18,12 @@ import ( "golang.org/x/mod/semver" ) +const goosWindows = "windows" + +// goos is a test seam for runtime.GOOS so the Windows-specific auto-install +// gating can be exercised from a non-Windows host. +var goos = runtime.GOOS + const ( installManagerBrew = "brew" installManagerMise = "mise" @@ -73,9 +80,10 @@ func CheckAndNotify(ctx context.Context, w io.Writer, currentVersion string) { return } - // Show notification if outdated + // Show notification and offer an interactive upgrade when outdated if isOutdated(currentVersion, latestVersion) { printNotification(w, currentVersion, latestVersion) + MaybeAutoUpdate(ctx, w, currentVersion) } } @@ -173,10 +181,8 @@ func saveCache(cache *VersionCache) error { return nil } -// fetchLatestVersion fetches the latest version from the GitHub API. -// Returns a timeout-safe version check using the configured HTTP timeout. +// fetchLatestVersion fetches the latest stable version tag from the GitHub API. func fetchLatestVersion(ctx context.Context) (string, error) { - // Create a context with timeout for the HTTP request ctx, cancel := context.WithTimeout(ctx, httpTimeout) defer cancel() @@ -185,7 +191,6 @@ func fetchLatestVersion(ctx context.Context) (string, error) { return "", fmt.Errorf("creating request: %w", err) } - // Set headers to identify as Entire CLI req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("User-Agent", "entire-cli") @@ -200,18 +205,15 @@ func fetchLatestVersion(ctx context.Context) (string, error) { return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) } - // Read response body (limit to 1MB to prevent memory exhaustion) body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return "", fmt.Errorf("reading response: %w", err) } - // Parse GitHub release response version, err := parseGitHubRelease(body) if err != nil { return "", fmt.Errorf("parsing release: %w", err) } - return version, nil } @@ -223,7 +225,7 @@ func isNightly(version string) bool { return strings.Contains(semver.Prerelease(version), "nightly") } -// fetchLatestNightlyVersion fetches the latest nightly version from the GitHub releases list. +// fetchLatestNightlyVersion fetches the latest nightly version tag from the GitHub releases list. func fetchLatestNightlyVersion(ctx context.Context) (string, error) { ctx, cancel := context.WithTimeout(ctx, httpTimeout) defer cancel() @@ -266,7 +268,7 @@ func fetchLatestNightlyVersion(ctx context.Context) (string, error) { return "", errors.New("no nightly release found") } -// parseGitHubRelease parses the GitHub API response and extracts the latest stable version. +// parseGitHubRelease parses the GitHub API response and returns the latest stable version tag. // Filters out prerelease versions. func parseGitHubRelease(body []byte) (string, error) { var release GitHubRelease @@ -274,12 +276,10 @@ func parseGitHubRelease(body []byte) (string, error) { return "", fmt.Errorf("parsing JSON: %w", err) } - // Skip prerelease versions if release.Prerelease { return "", errors.New("only prerelease versions available") } - // Ensure we have a tag name if release.TagName == "" { return "", errors.New("empty tag name") } @@ -347,6 +347,26 @@ func installManagerForCurrentBinary() string { } } +// canAutoInstall reports whether updateCommand(currentVersion) is safe to +// execute on the current OS. Returns false on Windows when the install +// manager is unknown, because the POSIX curl-pipe-bash fallback can't run +// from cmd.exe and there's no Windows-native installer to substitute. +func canAutoInstall() bool { + if goos != goosWindows { + return true + } + switch installManagerForCurrentBinary() { + case installManagerScoop, installManagerMise: + return true + default: + return false + } +} + +// downloadsURL is the public page users visit when we can't offer an +// auto-installable command on their platform. +const downloadsURL = "https://github.com/entireio/cli/releases" + // updateCommand returns the appropriate update instruction based on how the binary was installed. func updateCommand(currentVersion string) string { switch installManagerForCurrentBinary() { @@ -369,7 +389,7 @@ func updateCommand(currentVersion string) string { // printNotification prints the version update notification to the user. func printNotification(w io.Writer, current, latest string) { - msg := fmt.Sprintf("\nA newer version of Entire CLI is available: %s (current: %s)\nRun '%s' to update.\n", - latest, current, updateCommand(current)) + msg := fmt.Sprintf("\nA newer version of Entire CLI is available: %s (current: %s)\n", + latest, current) fmt.Fprint(w, msg) } diff --git a/cmd/entire/cli/versioncheck/versioncheck_test.go b/cmd/entire/cli/versioncheck/versioncheck_test.go index 19b19eaa18..a523792ba4 100644 --- a/cmd/entire/cli/versioncheck/versioncheck_test.go +++ b/cmd/entire/cli/versioncheck/versioncheck_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "io" "net/http" "net/http/httptest" "os" @@ -310,6 +311,7 @@ func TestParseGitHubRelease(t *testing.T) { } func TestUpdateCommand(t *testing.T) { + const plainBinPath = "/usr/local/bin/entire" tests := []struct { name string currentVersion string @@ -355,13 +357,13 @@ func TestUpdateCommand(t *testing.T) { { name: "unknown path stable falls back to stable curl command", currentVersion: "1.0.0", - execPath: func() (string, error) { return "/usr/local/bin/entire", nil }, + execPath: func() (string, error) { return plainBinPath, nil }, want: "curl -fsSL https://entire.io/install.sh | bash", }, { name: "unknown path nightly falls back to nightly curl command", currentVersion: "1.0.1-nightly.202604101200.abc1234", - execPath: func() (string, error) { return "/usr/local/bin/entire", nil }, + execPath: func() (string, error) { return plainBinPath, nil }, want: "curl -fsSL https://entire.io/install.sh | bash -s -- --channel nightly", }, { @@ -393,6 +395,10 @@ func setupCheckAndNotifyTest(t *testing.T, serverURL string) (*cobra.Command, *b tmpHome := t.TempDir() t.Setenv("HOME", tmpHome) + // Prevent MaybeAutoUpdate from opening an interactive prompt when the + // test binary runs in a real terminal (dev laptop, not just CI). + t.Setenv("ENTIRE_TEST_TTY", "0") + origURL := githubAPIURL githubAPIURL = serverURL t.Cleanup(func() { githubAPIURL = origURL }) @@ -489,6 +495,49 @@ func TestCheckAndNotify_NoNotificationWhenUpToDate(t *testing.T) { } } +func TestCheckAndNotify_InstallerFailureKeepsCacheFresh(t *testing.T) { + server := newVersionServer(t, "v2.0.0") + cmd, buf := setupCheckAndNotifyTest(t, server.URL) + + // Simulate an interactive user who accepts the upgrade prompt, and an + // installer that fails (e.g. brew upgrade blew up mid-run). + t.Setenv("ENTIRE_TEST_TTY", "1") + useBrewExecutable(t) + + origConfirm := confirmUpdate + confirmUpdate = func() (bool, error) { return true, nil } + t.Cleanup(func() { confirmUpdate = origConfirm }) + + origRun := runInstaller + runInstaller = func(_ context.Context, _ string) error { return errors.New("boom") } + t.Cleanup(func() { runInstaller = origRun }) + + origIsTerminalOut := isTerminalOut + isTerminalOut = func(_ io.Writer) bool { return true } + t.Cleanup(func() { isTerminalOut = origIsTerminalOut }) + + CheckAndNotify(context.Background(), cmd.OutOrStdout(), "1.0.0") + + // User sees the failure message with a manual-retry hint. + if !strings.Contains(buf.String(), "Try again later running:") { + t.Errorf("missing retry hint in output: %q", buf.String()) + } + + // Cache must remain bumped: we don't want to re-prompt every invocation + // while the upstream issue is still in place. The user already has the + // hint with the exact command to run manually. + cache, err := loadCache() + if err != nil { + t.Fatalf("loadCache() error = %v", err) + } + if cache.LastCheckTime.IsZero() { + t.Errorf("cache LastCheckTime was reset after installer failure; want fresh bump") + } + if time.Since(cache.LastCheckTime) > time.Minute { + t.Errorf("cache LastCheckTime not fresh after installer failure: %v", cache.LastCheckTime) + } +} + func TestCheckAndNotify_FetchFailureUpdatesCacheToPreventRetry(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError)