Files
iris-context/root/autoupdate_test.go
VERSE 87b91c45d7 feat: update changelog (#131)
closes #130 

- CHANGELOG.md file for people who can view changelog from github
---
`iris changelog` command:
`iris changelog [version]` (e.g. iris changelog v0.5.1)
`iris changelog -n 3` (see changelog of 3 latest releaseas)

=> `iris changelog` looks just like CHANGELOG.md, grouped headers and
all. It only stops looking that way if we personally overwrite that
release's description with something else
=> With 1-hour local cache (invalidated on updater-channel change),
`--refresh` flag to bypass, graceful fallback to stale cache on GitHub
rate-limiting

---
- New auto update config
```toml
# 0 = off (default, notify only), 1 = auto-install, 2 = always confirm first
auto-update = 1
```

- New just debug-changelog and just debug-autoupdate recipes (fully
offline, mocked HTTP servers, no real network/config touched)
2026-08-07 17:16:18 +07:00

210 lines
7.0 KiB
Go

package root
import (
"io"
"os"
"strings"
"testing"
"github.com/versenilvis/iris/internal/config"
)
// captureStdout redirects os.Stdout for the duration of fn and returns
// everything written to it. writeStdout writes to os.Stdout directly, so
// this is the only way to observe it without a real terminal.
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
original := os.Stdout
os.Stdout = w
fn()
_ = w.Close()
os.Stdout = original
out, _ := io.ReadAll(r)
return string(out)
}
func TestDecideAutoUpdateActionOff(t *testing.T) {
st := config.UpdaterState{AutoUpdateTarget: "v1.1.0", AutoUpdateAttempt: 5}
if got := decideAutoUpdateAction(0, "v1.1.0", st); got != autoUpdateNotifyOnly {
t.Errorf("mode 0: expected notifyOnly, got %v", got)
}
}
func TestDecideAutoUpdateActionDeclinedVersionNeverRePrompted(t *testing.T) {
st := config.UpdaterState{DeclinedVersion: "v1.1.0"}
if got := decideAutoUpdateAction(1, "v1.1.0", st); got != autoUpdateNotifyOnly {
t.Errorf("mode 1, declined: expected notifyOnly, got %v", got)
}
if got := decideAutoUpdateAction(2, "v1.1.0", st); got != autoUpdateNotifyOnly {
t.Errorf("mode 2, declined: expected notifyOnly, got %v", got)
}
}
func TestDecideAutoUpdateActionModeTwoAlwaysConfirms(t *testing.T) {
cases := []config.UpdaterState{
{},
{AutoUpdateTarget: "v1.1.0", AutoUpdateAttempt: 0},
{AutoUpdateTarget: "v1.1.0", AutoUpdateAttempt: 1},
{AutoUpdateTarget: "v1.1.0", AutoUpdateAttempt: 3},
}
for _, st := range cases {
if got := decideAutoUpdateAction(2, "v1.1.0", st); got != autoUpdateConfirm {
t.Errorf("mode 2, state %+v: expected confirm, got %v", st, got)
}
}
}
func TestDecideAutoUpdateActionModeOneEscalationLadder(t *testing.T) {
// a different or absent target: this is a fresh version, try #1
if got := decideAutoUpdateAction(1, "v1.1.0", config.UpdaterState{}); got != autoUpdateInstallSilently {
t.Errorf("fresh target: expected installSilently, got %v", got)
}
if got := decideAutoUpdateAction(1, "v1.1.0", config.UpdaterState{AutoUpdateTarget: "v1.0.5", AutoUpdateAttempt: 2}); got != autoUpdateInstallSilently {
t.Errorf("different target: expected installSilently (attempt resets), got %v", got)
}
// same target, one attempt already made: this is try #2
st := config.UpdaterState{AutoUpdateTarget: "v1.1.0", AutoUpdateAttempt: 1}
if got := decideAutoUpdateAction(1, "v1.1.0", st); got != autoUpdateConfirm {
t.Errorf("attempt 1 stored: expected confirm, got %v", got)
}
// same target, two attempts already made: this is try #3, give up
st = config.UpdaterState{AutoUpdateTarget: "v1.1.0", AutoUpdateAttempt: 2}
if got := decideAutoUpdateAction(1, "v1.1.0", st); got != autoUpdateGiveUp {
t.Errorf("attempt 2 stored: expected giveUp, got %v", got)
}
// already given up: stays given up, doesn't loop back to installing
st = config.UpdaterState{AutoUpdateTarget: "v1.1.0", AutoUpdateAttempt: 3}
if got := decideAutoUpdateAction(1, "v1.1.0", st); got != autoUpdateGiveUp {
t.Errorf("attempt 3 stored: expected giveUp (stays given up), got %v", got)
}
}
func TestDecideAutoUpdateActionNewerVersionResetsLadder(t *testing.T) {
// gave up on v1.1.0, but v1.2.0 is a different target entirely - fresh start
st := config.UpdaterState{AutoUpdateTarget: "v1.1.0", AutoUpdateAttempt: 3}
if got := decideAutoUpdateAction(1, "v1.2.0", st); got != autoUpdateInstallSilently {
t.Errorf("newer target after giving up on an older one: expected installSilently, got %v", got)
}
}
func TestPrintAutoUpdateInstalledNotice(t *testing.T) {
originalVersion := Version
Version = "v1.0.0"
t.Cleanup(func() { Version = originalVersion })
out := captureStdout(t, func() { printAutoUpdateInstalledNotice("v1.1.0") })
if !strings.Contains(out, "v1.0.0") || !strings.Contains(out, "v1.1.0") {
t.Errorf("expected the version transition, got %q", out)
}
if !strings.Contains(out, "restart your terminal") {
t.Errorf("expected a restart notice, got %q", out)
}
}
func TestPrintAutoUpdateConfirmPrompt(t *testing.T) {
originalVersion := Version
Version = "v1.0.0"
t.Cleanup(func() { Version = originalVersion })
out := captureStdout(t, func() {
printAutoUpdateConfirmPrompt("v1.1.0", "## Changelog\n### Bug fixes\n* abc1234 fix something\n")
})
if !strings.Contains(out, "v1.1.0") {
t.Errorf("expected the target version, got %q", out)
}
if !strings.Contains(out, "fix something") {
t.Errorf("expected the changelog summary, got %q", out)
}
if !strings.Contains(out, "install now?") {
t.Errorf("expected the confirm prompt text, got %q", out)
}
}
func TestPrintAutoUpdateGiveUpNotice(t *testing.T) {
out := captureStdout(t, func() { printAutoUpdateGiveUpNotice("v1.1.0") })
if !strings.Contains(out, "v1.1.0") {
t.Errorf("expected the target version, got %q", out)
}
if !strings.Contains(out, "iris update") {
t.Errorf("expected a pointer to manual iris update, got %q", out)
}
}
func TestDeclineAutoUpdateRecordsState(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("HOME", tmpDir)
t.Setenv("XDG_DATA_HOME", tmpDir+"/.local/share")
state := config.LoadState()
state.Updater.AutoUpdateTarget = "v1.1.0"
state.Updater.AutoUpdateAttempt = 2
if err := config.SaveState(state); err != nil {
t.Fatalf("SaveState: %v", err)
}
captureStdout(t, func() { declineAutoUpdate("v1.1.0") })
got := config.LoadState()
if got.Updater.DeclinedVersion != "v1.1.0" {
t.Errorf("expected DeclinedVersion v1.1.0, got %q", got.Updater.DeclinedVersion)
}
if got.Updater.AutoUpdateTarget != "" || got.Updater.AutoUpdateAttempt != 0 {
t.Errorf("expected the escalation ladder to be reset, got target=%q attempt=%d",
got.Updater.AutoUpdateTarget, got.Updater.AutoUpdateAttempt)
}
}
func TestHandleAutoUpdateConfirmKeyNotArmedIsNoop(t *testing.T) {
pendingAutoUpdateConfirm.Store(false)
if handleAutoUpdateConfirmKey('y') {
t.Error("expected handleAutoUpdateConfirmKey to report unhandled when nothing is pending")
}
}
func TestHandleAutoUpdateConfirmKeyDeclineFlow(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("HOME", tmpDir)
t.Setenv("XDG_DATA_HOME", tmpDir+"/.local/share")
t.Cleanup(func() { pendingAutoUpdateConfirm.Store(false) })
armAutoUpdateConfirm("v1.1.0")
captureStdout(t, func() {
if !handleAutoUpdateConfirmKey('n') {
t.Error("expected 'n' to be reported as handled while a confirm is pending")
}
})
if pendingAutoUpdateConfirm.Load() {
t.Error("expected the pending confirm flag to clear after declining")
}
if got := config.LoadState().Updater.DeclinedVersion; got != "v1.1.0" {
t.Errorf("expected the decline to be recorded, got %q", got)
}
}
func TestHandleAutoUpdateConfirmKeySwallowsUnrecognizedBytes(t *testing.T) {
t.Cleanup(func() { pendingAutoUpdateConfirm.Store(false) })
armAutoUpdateConfirm("v1.1.0")
if !handleAutoUpdateConfirmKey('x') {
t.Error("expected an unrecognized byte to still be reported as consumed while pending")
}
if !pendingAutoUpdateConfirm.Load() {
t.Error("expected the prompt to stay armed until y/n/Esc/Ctrl+C")
}
}