diff --git a/cmd/entire/cli/versioncheck/autoupdate.go b/cmd/entire/cli/versioncheck/autoupdate.go index 14b6e33d7f..bfe3ae2b08 100644 --- a/cmd/entire/cli/versioncheck/autoupdate.go +++ b/cmd/entire/cli/versioncheck/autoupdate.go @@ -1,7 +1,6 @@ package versioncheck import ( - "bufio" "context" "errors" "fmt" @@ -9,7 +8,6 @@ import ( "os" "os/exec" "runtime" - "strings" "github.com/charmbracelet/huh" @@ -29,17 +27,26 @@ const ( autoUpdateActionSkipUntilNextVersion AutoUpdateAction = "skip_until_next_version" ) +// chooseUpdateFn is the signature for the update-prompt seam. The +// concrete implementation renders a huh.Select with the installer +// command interpolated into option 1. +type chooseUpdateFn func(ctx context.Context, currentVersion, latestVersion, cmdStr string) (AutoUpdateAction, error) + // Test seams. var ( - runInstaller = realRunInstaller - confirmUpdate = realConfirmUpdate - chooseBrewUpdate = realChooseBrewUpdate - isTerminalOut = interactive.IsTerminalWriter + runInstaller = realRunInstaller + chooseUpdate chooseUpdateFn = realChooseUpdate + isTerminalOut = interactive.IsTerminalWriter ) // MaybeAutoUpdate prints an update notification and offers an interactive // upgrade. Silent on every failure path — it must never interrupt the CLI. // +// The same 3-option prompt (update / skip / skip until next version) is +// shown for every install manager that supports auto-installation +// (brew, mise, scoop, curl-bash). The only thing that varies between +// installers is the shell command interpolated into option 1. +// // 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 @@ -49,45 +56,17 @@ var ( // 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. +// +// On Windows + unknown install manager the POSIX curl-pipe-bash fallback +// can't auto-run and there's no native equivalent, so we point the user +// at the releases download page instead. func MaybeAutoUpdate(ctx context.Context, w io.Writer, currentVersion, latestVersion string) AutoUpdateAction { - if installManagerForCurrentBinary() == installManagerBrew { - return maybeBrewAutoUpdate(ctx, w, currentVersion, latestVersion) - } - - printNotification(w, currentVersion, latestVersion) - - // 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() { + printNotification(w, currentVersion, latestVersion) fmt.Fprintf(w, "To update, download the latest release from:\n %s\n", downloadsURL) return autoUpdateActionSkip } - if os.Getenv(envKillSwitch) != "" || !interactive.CanPromptInteractively() || !isTerminalOut(w) { - fmt.Fprintf(w, "To update, run:\n %s\n", updateCommand(currentVersion)) - return autoUpdateActionSkip - } - - confirmed, err := confirmUpdate() - if err != nil { - logging.Debug(ctx, "auto-update: prompt failed", "error", err.Error()) - return autoUpdateActionSkip - } - if !confirmed { - return autoUpdateActionSkip - } - - 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 autoUpdateActionUpdate - } - fmt.Fprintln(w, "Update complete. Re-run entire to use the new version.") - return autoUpdateActionUpdate -} -func maybeBrewAutoUpdate(ctx context.Context, w io.Writer, currentVersion, latestVersion string) AutoUpdateAction { cmdStr := updateCommand(currentVersion) if os.Getenv(envKillSwitch) != "" || !interactive.CanPromptInteractively() || !isTerminalOut(w) { @@ -96,11 +75,9 @@ func maybeBrewAutoUpdate(ctx context.Context, w io.Writer, currentVersion, lates return autoUpdateActionSkip } - printBrewUpdateMessage(w, currentVersion, latestVersion, cmdStr) - - action, err := chooseBrewUpdate(w) + action, err := chooseUpdate(ctx, currentVersion, latestVersion, cmdStr) if err != nil { - logging.Debug(ctx, "auto-update: brew prompt failed", "error", err.Error()) + logging.Debug(ctx, "auto-update: prompt failed", "error", err.Error()) return autoUpdateActionSkip } @@ -122,73 +99,32 @@ func maybeBrewAutoUpdate(ctx context.Context, w io.Writer, currentVersion, lates } } -func printBrewUpdateMessage(w io.Writer, currentVersion, latestVersion, cmdStr string) { - fmt.Fprintf(w, "\nUpdate available! %s -> %s\nRelease notes: %s\n1. Update now (runs `%s`)\n2. Skip\n3. Skip until next version\n\nPress enter to continue\n", - displayVersion(currentVersion), displayVersion(latestVersion), releaseNotesURL(latestVersion), cmdStr) -} - -func realChooseBrewUpdate(w io.Writer) (AutoUpdateAction, error) { - return chooseBrewUpdateFromReader(w, os.Stdin) -} - -func chooseBrewUpdateFromReader(w io.Writer, input io.Reader) (AutoUpdateAction, error) { - reader := bufio.NewReader(input) - for { - fmt.Fprint(w, "Choose an option [1]: ") - line, err := reader.ReadString('\n') - if err != nil && !errors.Is(err, io.EOF) { - return autoUpdateActionSkip, fmt.Errorf("read update choice: %w", err) - } - if errors.Is(err, io.EOF) && strings.TrimSpace(line) == "" { - return autoUpdateActionSkip, nil - } - - action, ok := parseBrewUpdateChoice(line) - if ok { - return action, nil - } - if errors.Is(err, io.EOF) { - return autoUpdateActionSkip, nil - } - fmt.Fprintln(w, "Please choose 1, 2, or 3.") - } -} - -func parseBrewUpdateChoice(input string) (AutoUpdateAction, bool) { - switch strings.TrimSpace(input) { - case "", "1": - return autoUpdateActionUpdate, true - case "2": - return autoUpdateActionSkip, true - case "3": - return autoUpdateActionSkipUntilNextVersion, true - default: - return autoUpdateActionSkip, false - } -} - -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()) +// realChooseUpdate renders a huh.Select with the three update actions. +// In normal mode this is an arrow-key TUI; when ACCESSIBLE is set huh +// falls back to a plain numbered prompt readable by screen readers. +func realChooseUpdate(ctx context.Context, currentVersion, latestVersion, cmdStr string) (AutoUpdateAction, error) { + action := autoUpdateActionUpdate + sel := huh.NewSelect[AutoUpdateAction](). + Title(fmt.Sprintf("Update available! %s -> %s", + displayVersion(currentVersion), displayVersion(latestVersion))). + Description("Release notes: "+releaseNotesURL(latestVersion)). + Options( + huh.NewOption(fmt.Sprintf("Update now (runs `%s`)", cmdStr), autoUpdateActionUpdate), + huh.NewOption("Skip", autoUpdateActionSkip), + huh.NewOption("Skip until next version", autoUpdateActionSkipUntilNextVersion), + ). + Value(&action) + form := huh.NewForm(huh.NewGroup(sel)).WithTheme(huh.ThemeDracula()) if os.Getenv("ACCESSIBLE") != "" { form = form.WithAccessible(true) } - if err := form.Run(); err != nil { + if err := form.RunWithContext(ctx); err != nil { if errors.Is(err, huh.ErrUserAborted) || errors.Is(err, huh.ErrTimeout) { - return false, nil + return autoUpdateActionSkip, nil } - return false, fmt.Errorf("confirm form: %w", err) + return autoUpdateActionSkip, fmt.Errorf("update prompt: %w", err) } - return confirmed, nil + return action, nil } // realRunInstaller shells out to the installer command, streaming stdin/stdout/stderr diff --git a/cmd/entire/cli/versioncheck/autoupdate_test.go b/cmd/entire/cli/versioncheck/autoupdate_test.go index a4ab4daa51..c409cfe57e 100644 --- a/cmd/entire/cli/versioncheck/autoupdate_test.go +++ b/cmd/entire/cli/versioncheck/autoupdate_test.go @@ -14,10 +14,9 @@ type autoUpdateFixture struct { installCalls int installErr error lastCommand string - confirmValue bool - confirmErr error chooseValue AutoUpdateAction chooseErr error + lastCmdStr string } func newAutoUpdateFixture(t *testing.T) *autoUpdateFixture { @@ -27,7 +26,7 @@ func newAutoUpdateFixture(t *testing.T) *autoUpdateFixture { // Force interactive mode on by default; individual tests can opt out. t.Setenv("ENTIRE_TEST_TTY", "1") - f := &autoUpdateFixture{confirmValue: true, chooseValue: autoUpdateActionUpdate} + f := &autoUpdateFixture{chooseValue: autoUpdateActionUpdate} origRun := runInstaller runInstaller = func(_ context.Context, cmd string) error { @@ -35,17 +34,17 @@ func newAutoUpdateFixture(t *testing.T) *autoUpdateFixture { f.lastCommand = cmd return f.installErr } - origConfirm := confirmUpdate - confirmUpdate = func() (bool, error) { return f.confirmValue, f.confirmErr } - origChoose := chooseBrewUpdate - chooseBrewUpdate = func(io.Writer) (AutoUpdateAction, error) { return f.chooseValue, f.chooseErr } + origChoose := chooseUpdate + chooseUpdate = func(_ context.Context, _, _, cmdStr string) (AutoUpdateAction, error) { + f.lastCmdStr = cmdStr + return f.chooseValue, f.chooseErr + } origIsTerminalOut := isTerminalOut isTerminalOut = func(_ io.Writer) bool { return true } t.Cleanup(func() { runInstaller = origRun - confirmUpdate = origConfirm - chooseBrewUpdate = origChoose + chooseUpdate = origChoose isTerminalOut = origIsTerminalOut }) return f @@ -61,21 +60,58 @@ func useBrewExecutable(t *testing.T) { 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) { +// useMiseExecutable points the install-manager detector at a mise install path. +func useMiseExecutable(t *testing.T) { + t.Helper() + orig := executablePath + executablePath = func() (string, error) { + return "/home/user/.local/share/mise/installs/entire/1.0.0/bin/entire", nil + } + t.Cleanup(func() { executablePath = orig }) +} + +// useScoopExecutable points the install-manager detector at a scoop install path. +func useScoopExecutable(t *testing.T) { + t.Helper() + orig := executablePath + executablePath = func() (string, error) { + return `C:\Users\test\scoop\apps\cli\current\entire.exe`, nil + } + t.Cleanup(func() { executablePath = orig }) +} + +// useUnknownExecutable points the install-manager detector at a plain path +// with no recognised manager prefix (curl-bash fallback). +func useUnknownExecutable(t *testing.T) { + t.Helper() + orig := executablePath + executablePath = func() (string, error) { + return "/usr/local/bin/entire", nil + } + t.Cleanup(func() { executablePath = orig }) +} + +// pinNonWindowsGOOS pins the goos seam to a non-Windows value so the +// table-driven tests below pass on Windows hosts. canAutoInstall() blocks +// brew and the curl-bash fallback on Windows; without this pin those +// installer cases would short-circuit to the downloads-page path. +func pinNonWindowsGOOS(t *testing.T) { + t.Helper() + orig := goos + goos = "darwin" + t.Cleanup(func() { goos = orig }) +} + +// assertManualHint checks that the "To update, run:\n " hint +// was printed when the prompt couldn't be shown, and that the wantCmd +// installer command is included. +func assertManualHint(t *testing.T, out, wantCmd string) { t.Helper() if !strings.Contains(out, "To update, run:") { t.Errorf("missing manual-update hint: %q", out) } - if !strings.Contains(out, "brew upgrade entire") { - t.Errorf("manual hint missing installer command: %q", out) - } - if strings.Contains(out, "1. Update now") || - strings.Contains(out, "2. Skip") || - strings.Contains(out, "3. Skip until next version") || - strings.Contains(out, "Press enter to continue") { - t.Errorf("non-interactive output included interactive menu: %q", out) + if !strings.Contains(out, wantCmd) { + t.Errorf("manual hint missing installer command %q: %q", wantCmd, out) } } @@ -90,7 +126,7 @@ func TestMaybeAutoUpdate_KillSwitch(t *testing.T) { if f.installCalls != 0 { t.Errorf("installer called with kill-switch set") } - assertManualHint(t, buf.String()) + assertManualHint(t, buf.String(), "brew upgrade entire") } func TestMaybeAutoUpdate_NoTTY(t *testing.T) { @@ -105,7 +141,7 @@ func TestMaybeAutoUpdate_NoTTY(t *testing.T) { if f.installCalls != 0 { t.Errorf("installer called without TTY") } - assertManualHint(t, buf.String()) + assertManualHint(t, buf.String(), "brew upgrade entire") } func TestMaybeAutoUpdate_CIEnv(t *testing.T) { @@ -121,7 +157,7 @@ func TestMaybeAutoUpdate_CIEnv(t *testing.T) { if f.installCalls != 0 { t.Errorf("installer called on CI (CI=true)") } - assertManualHint(t, buf.String()) + assertManualHint(t, buf.String(), "brew upgrade entire") } func TestMaybeAutoUpdate_NonTerminalWriter(t *testing.T) { @@ -135,7 +171,7 @@ func TestMaybeAutoUpdate_NonTerminalWriter(t *testing.T) { if f.installCalls != 0 { t.Errorf("installer called with non-terminal output writer") } - assertManualHint(t, buf.String()) + assertManualHint(t, buf.String(), "brew upgrade entire") } // TestMaybeAutoUpdate_WindowsUnknownInstallerNoAutoRun verifies that on @@ -177,11 +213,7 @@ func TestMaybeAutoUpdate_WindowsUnknownInstallerNoAutoRun(t *testing.T) { // 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 }) + useScoopExecutable(t) origGOOS := goos goos = goosWindows @@ -214,35 +246,6 @@ func TestMaybeAutoUpdate_UserDeclines(t *testing.T) { } } -func TestMaybeAutoUpdate_BrewSkipUntilNextVersion(t *testing.T) { - f := newAutoUpdateFixture(t) - useBrewExecutable(t) - f.chooseValue = autoUpdateActionSkipUntilNextVersion - - var buf bytes.Buffer - action := MaybeAutoUpdate(context.Background(), &buf, "1.0.0", "v2.0.0") - - if f.installCalls != 0 { - t.Errorf("installer called after skip-until-next-version") - } - if action != autoUpdateActionSkipUntilNextVersion { - t.Errorf("action = %q, want %q", action, autoUpdateActionSkipUntilNextVersion) - } - out := buf.String() - for _, want := range []string{ - "Update available! 1.0.0 -> 2.0.0", - "Release notes: https://github.com/entireio/cli/releases/tag/v2.0.0", - "1. Update now (runs `brew upgrade entire`)", - "2. Skip", - "3. Skip until next version", - "Press enter to continue", - } { - if !strings.Contains(out, want) { - t.Errorf("missing %q in output: %q", want, out) - } - } -} - func TestMaybeAutoUpdate_HappyPath(t *testing.T) { f := newAutoUpdateFixture(t) useBrewExecutable(t) @@ -288,49 +291,114 @@ func TestMaybeAutoUpdate_InstallerFailurePrintedToUser(t *testing.T) { } } -func TestParseBrewUpdateChoice(t *testing.T) { - tests := []struct { - input string - want AutoUpdateAction - ok bool - }{ - {input: "", want: autoUpdateActionUpdate, ok: true}, - {input: "\n", want: autoUpdateActionUpdate, ok: true}, - {input: "1", want: autoUpdateActionUpdate, ok: true}, - {input: "2", want: autoUpdateActionSkip, ok: true}, - {input: "3", want: autoUpdateActionSkipUntilNextVersion, ok: true}, - {input: "nope", want: autoUpdateActionSkip, ok: false}, - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - got, ok := parseBrewUpdateChoice(tt.input) - if got != tt.want || ok != tt.ok { - t.Errorf("parseBrewUpdateChoice(%q) = (%q, %v), want (%q, %v)", - tt.input, got, ok, tt.want, tt.ok) +// installerCase covers the same prompt contract for every install manager +// that supports auto-installation. +type installerCase struct { + name string + setup func(*testing.T) + wantCmd string +} + +func nonWindowsAutoInstallers() []installerCase { + return []installerCase{ + {name: "brew", setup: useBrewExecutable, wantCmd: "brew upgrade entire"}, + {name: "mise", setup: useMiseExecutable, wantCmd: "mise upgrade entire"}, + {name: "scoop", setup: useScoopExecutable, wantCmd: "scoop update entire/cli"}, + {name: "unknown_curl_bash", setup: useUnknownExecutable, wantCmd: "curl -fsSL https://entire.io/install.sh | bash"}, + } +} + +// TestMaybeAutoUpdate_AllInstallers_PromptReceivesCorrectCommand verifies +// that the prompt seam is invoked with the right shell command for every +// install manager. The huh.Select itself is exercised by the manual +// smoke script (test-auto.sh); here we only check that the cmd we build +// from updateCommand() is what reaches the prompt. +func TestMaybeAutoUpdate_AllInstallers_PromptReceivesCorrectCommand(t *testing.T) { + pinNonWindowsGOOS(t) + for _, tt := range nonWindowsAutoInstallers() { + t.Run(tt.name, func(t *testing.T) { + f := newAutoUpdateFixture(t) + tt.setup(t) + f.chooseValue = autoUpdateActionSkipUntilNextVersion + + var buf bytes.Buffer + action := MaybeAutoUpdate(context.Background(), &buf, "1.0.0", "v2.0.0") + + if f.installCalls != 0 { + t.Errorf("installer called after skip-until-next-version") + } + if action != autoUpdateActionSkipUntilNextVersion { + t.Errorf("action = %q, want %q", action, autoUpdateActionSkipUntilNextVersion) + } + if f.lastCmdStr != tt.wantCmd { + t.Errorf("prompt got cmd %q, want %q", f.lastCmdStr, tt.wantCmd) } }) } } -func TestChooseBrewUpdateFromReader_EmptyEOFSkips(t *testing.T) { - var buf bytes.Buffer - action, err := chooseBrewUpdateFromReader(&buf, strings.NewReader("")) - if err != nil { - t.Fatalf("chooseBrewUpdateFromReader() error = %v", err) - } - if action != autoUpdateActionSkip { - t.Errorf("action = %q, want %q", action, autoUpdateActionSkip) +func TestMaybeAutoUpdate_AllInstallers_HappyPathRunsInstaller(t *testing.T) { + pinNonWindowsGOOS(t) + for _, tt := range nonWindowsAutoInstallers() { + t.Run(tt.name, func(t *testing.T) { + f := newAutoUpdateFixture(t) + tt.setup(t) + + var buf bytes.Buffer + action := MaybeAutoUpdate(context.Background(), &buf, "1.0.0", "v2.0.0") + + if f.installCalls != 1 { + t.Fatalf("installer called %d times, want 1", f.installCalls) + } + if f.lastCommand != tt.wantCmd { + t.Errorf("installer got %q, want %q", f.lastCommand, tt.wantCmd) + } + if action != autoUpdateActionUpdate { + t.Errorf("action = %q, want %q", action, autoUpdateActionUpdate) + } + if !strings.Contains(buf.String(), "Update complete") { + t.Errorf("missing success message: %q", buf.String()) + } + }) } } -func TestChooseBrewUpdateFromReader_EnterUpdates(t *testing.T) { - var buf bytes.Buffer - action, err := chooseBrewUpdateFromReader(&buf, strings.NewReader("\n")) - if err != nil { - t.Fatalf("chooseBrewUpdateFromReader() error = %v", err) +func TestMaybeAutoUpdate_AllInstallers_KillSwitchPrintsManualHint(t *testing.T) { + pinNonWindowsGOOS(t) + for _, tt := range nonWindowsAutoInstallers() { + t.Run(tt.name, func(t *testing.T) { + f := newAutoUpdateFixture(t) + tt.setup(t) + t.Setenv(envKillSwitch, "1") + + var buf bytes.Buffer + MaybeAutoUpdate(context.Background(), &buf, "1.0.0", "v2.0.0") + + if f.installCalls != 0 { + t.Errorf("installer called with kill-switch set") + } + assertManualHint(t, buf.String(), tt.wantCmd) + }) } - if action != autoUpdateActionUpdate { - t.Errorf("action = %q, want %q", action, autoUpdateActionUpdate) +} + +func TestMaybeAutoUpdate_AllInstallers_UserSkips(t *testing.T) { + pinNonWindowsGOOS(t) + for _, tt := range nonWindowsAutoInstallers() { + t.Run(tt.name, func(t *testing.T) { + f := newAutoUpdateFixture(t) + tt.setup(t) + f.chooseValue = autoUpdateActionSkip + + var buf bytes.Buffer + action := MaybeAutoUpdate(context.Background(), &buf, "1.0.0", "v2.0.0") + + if f.installCalls != 0 { + t.Errorf("installer called after user chose skip") + } + if action != autoUpdateActionSkip { + t.Errorf("action = %q, want %q", action, autoUpdateActionSkip) + } + }) } } diff --git a/cmd/entire/cli/versioncheck/versioncheck_test.go b/cmd/entire/cli/versioncheck/versioncheck_test.go index 4b1e8a9035..88b67574ab 100644 --- a/cmd/entire/cli/versioncheck/versioncheck_test.go +++ b/cmd/entire/cli/versioncheck/versioncheck_test.go @@ -310,6 +310,11 @@ func TestParseGitHubRelease(t *testing.T) { } } +// brewUpgradeCmd is the install command produced for any brew-installed +// binary on a stable channel. Hoisted to a const so tests can reference +// it without tripping goconst on repeated string literals. +const brewUpgradeCmd = "brew upgrade entire" + func TestUpdateCommand(t *testing.T) { const plainBinPath = "/usr/local/bin/entire" tests := []struct { @@ -322,13 +327,13 @@ func TestUpdateCommand(t *testing.T) { name: "homebrew stable cellar path uses brew command", currentVersion: "1.0.0", execPath: func() (string, error) { return "/opt/homebrew/Cellar/entire/1.0.0/bin/entire", nil }, - want: "brew upgrade entire", + want: brewUpgradeCmd, }, { name: "homebrew stable cask path uses brew command", currentVersion: "1.0.0", execPath: func() (string, error) { return "/opt/homebrew/bin/entire", nil }, - want: "brew upgrade entire", + want: brewUpgradeCmd, }, { name: "homebrew nightly path uses brew command", @@ -340,7 +345,7 @@ func TestUpdateCommand(t *testing.T) { name: "linuxbrew path", currentVersion: "1.0.0", execPath: func() (string, error) { return "/home/linuxbrew/.linuxbrew/bin/entire", nil }, - want: "brew upgrade entire", + want: brewUpgradeCmd, }, { name: "mise path", @@ -492,7 +497,7 @@ func TestCheckAndNotify_PrintsNotificationWhenOutdated(t *testing.T) { func TestCheckAndNotify_BrewSkipUntilNextVersionCachesLatest(t *testing.T) { server := newVersionServer(t, "v2.0.0") - cmd, buf := setupCheckAndNotifyTest(t, server.URL) + cmd, _ := setupCheckAndNotifyTest(t, server.URL) f := newAutoUpdateFixture(t) useBrewExecutable(t) f.chooseValue = autoUpdateActionSkipUntilNextVersion @@ -509,8 +514,35 @@ func TestCheckAndNotify_BrewSkipUntilNextVersionCachesLatest(t *testing.T) { if cache.SkippedVersion != "v2.0.0" { t.Errorf("SkippedVersion = %q, want v2.0.0", cache.SkippedVersion) } - if !strings.Contains(buf.String(), "3. Skip until next version") { - t.Errorf("expected brew update options, got %q", buf.String()) + if f.lastCmdStr != brewUpgradeCmd { + t.Errorf("prompt got cmd %q, want brew upgrade entire", f.lastCmdStr) + } +} + +// TestCheckAndNotify_MiseSkipUntilNextVersionCachesLatest verifies the +// skip-until-next-version persistence works for non-brew installers too. +// The cache flow is installer-agnostic; this locks that contract in. +func TestCheckAndNotify_MiseSkipUntilNextVersionCachesLatest(t *testing.T) { + server := newVersionServer(t, "v2.0.0") + cmd, _ := setupCheckAndNotifyTest(t, server.URL) + f := newAutoUpdateFixture(t) + useMiseExecutable(t) + f.chooseValue = autoUpdateActionSkipUntilNextVersion + + CheckAndNotify(context.Background(), cmd.OutOrStdout(), "1.0.0") + + if f.installCalls != 0 { + t.Fatalf("installer called %d times, want 0", f.installCalls) + } + cache, err := loadCache() + if err != nil { + t.Fatalf("loadCache() error = %v", err) + } + if cache.SkippedVersion != "v2.0.0" { + t.Errorf("SkippedVersion = %q, want v2.0.0", cache.SkippedVersion) + } + if f.lastCmdStr != "mise upgrade entire" { + t.Errorf("prompt got cmd %q, want mise upgrade entire", f.lastCmdStr) } } @@ -554,15 +586,11 @@ func TestCheckAndNotify_InstallerFailureKeepsCacheFresh(t *testing.T) { t.Setenv("ENTIRE_TEST_TTY", "1") useBrewExecutable(t) - origConfirm := confirmUpdate - confirmUpdate = func() (bool, error) { return true, nil } - t.Cleanup(func() { confirmUpdate = origConfirm }) - - origChoose := chooseBrewUpdate - chooseBrewUpdate = func(io.Writer) (AutoUpdateAction, error) { + origChoose := chooseUpdate + chooseUpdate = func(_ context.Context, _, _, _ string) (AutoUpdateAction, error) { return autoUpdateActionUpdate, nil } - t.Cleanup(func() { chooseBrewUpdate = origChoose }) + t.Cleanup(func() { chooseUpdate = origChoose }) origRun := runInstaller runInstaller = func(_ context.Context, _ string) error { return errors.New("boom") }