package ui import ( "fmt" "os" "path/filepath" "strings" "testing" "time" "errors" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" "updater-tui/internal/config" "updater-tui/internal/plan" ) // testConfig isolates the config so a test never touches the real ignore list. func testConfig(t *testing.T) *config.Config { t.Helper() dir := t.TempDir() os.Setenv("XDG_CONFIG_HOME", dir) t.Cleanup(func() { os.Unsetenv("XDG_CONFIG_HOME") }) return config.Load() } // plain strips styling so assertions are about content, not escape sequences. func plain(s string) string { return ansi.Strip(s) } func sample() *plan.Plan { return &plan.Plan{ Repo: []plan.Upgrade{ {Name: "linux", Old: "6.16.7-1", New: "6.16.8-1", Repo: "core", Download: 1468006, Delta: -1048576}, }, Aur: []plan.AurUpdate{ {Name: "enca-bin", Old: "1.19-4", New: "1.19-5", Tag: "!!", Note: "will be skipped"}, {Name: "demo-bin", Old: "1-1", New: "2-1", Tag: "U"}, }, Vcs: []plan.Vcs{{Name: "helix-git", Old: "r9021", New: "r9100"}}, Checks: []plan.Check{{OK: false, Label: "databases", Detail: "2d old"}}, Audits: map[string][]plan.Finding{ "enca-bin": {{Rule: "pipe-to-shell", Severity: "BLOCK", Title: "pipes a download into a shell", Evidence: "curl x | sh"}}, }, } } func loaded(t *testing.T, doc *plan.Plan, w, h int) Model { t.Helper() m := New(plan.Backend{}, testConfig(t)) next, _ := m.Update(tea.WindowSizeMsg{Width: w, Height: h}) next, _ = next.(Model).Update(loadedMsg{plan: doc}) final := next.(Model) if final.err != nil { t.Fatalf("model reported an error: %v", final.err) } return final } func TestTreeContainsEverySection(t *testing.T) { m := loaded(t, sample(), 120, 40) labels := make([]string, 0, len(m.roots)) for _, r := range m.roots { labels = append(labels, r.Title) } joined := strings.Join(labels, "\n") for _, want := range []string{"Repository", "AUR", "Checks"} { if !strings.Contains(joined, want) { t.Errorf("section %q missing from:\n%s", want, joined) } } } func TestFindingsNestUnderTheirPackage(t *testing.T) { m := loaded(t, sample(), 120, 40) var aur *Node for _, r := range m.roots { if r.Title == "AUR" { aur = r } } if aur == nil { t.Fatal("no AUR section") } var enca *Node for _, c := range aur.Children { if c.Name == "enca-bin" { enca = c } } if enca == nil { t.Fatal("enca-bin not in the AUR section") } if len(enca.Children) != 1 { t.Fatalf("enca-bin has %d children, want 1 finding", len(enca.Children)) } if !strings.Contains(enca.Children[0].Text, "pipes a download") { t.Errorf("finding text = %q", enca.Children[0].Text) } if !strings.Contains(enca.Children[0].Evidence, "curl x | sh") { t.Errorf("evidence missing: %q", enca.Children[0].Evidence) } // and it must actually appear on the rendered line if !strings.Contains(plain(enca.Children[0].Render(NewColumns(100), false)), "pipes a download") { t.Error("the finding did not render") } } func TestTopLevelSectionsAlwaysStartExpanded(t *testing.T) { // the updates a section holds are the whole point of looking, so a section // must never hide them behind a collapse just because it is long doc := sample() for i := 0; i < 30; i++ { doc.Vcs = append(doc.Vcs, plan.Vcs{Name: "pkg-git", Old: "r1", New: "r2"}) } m := loaded(t, doc, 120, 40) for _, r := range m.roots { if r.Kind != KindSection { continue // spacers are not sections } if !r.Expanded { t.Errorf("section %q started collapsed", r.Title) } } } func TestTheThresholdStillAppliesBelowTheSections(t *testing.T) { // a package with many findings is a detail; it may start collapsed doc := sample() findings := make([]plan.Finding, 12) for i := range findings { findings[i] = plan.Finding{Rule: "r", Severity: "LOOK", Title: "t"} } doc.Audits = map[string][]plan.Finding{"enca-bin": findings} m := loaded(t, doc, 120, 40) for _, r := range m.roots { for _, c := range r.Children { if c.Name == "enca-bin" && c.Expanded { t.Error("a package with 12 findings should start collapsed") } } } } func TestVcsPackagesAppearInTheAurSection(t *testing.T) { // a separate section implied they could not be acted on doc := sample() doc.Vcs = []plan.Vcs{{Name: "helix-git", Old: "r2200.abc1234-1", New: "r2300.def5678-1"}} m := loaded(t, doc, 120, 40) var inAur bool for _, r := range m.roots { if r.Title == "AUR" { for _, c := range r.Children { if c.Name == "helix-git" { inAur = true } } } if strings.Contains(r.Title, "VCS") { t.Error("VCS still has its own section") } } if !inAur { t.Error("helix-git did not join the AUR section") } } func TestEnterExpandsThenOpens(t *testing.T) { // enter must never dead-end: collapsed opens, expanded goes deeper m := loaded(t, sample(), 120, 40) var aur *Node for _, r := range m.roots { if r.Title == "AUR" { aur = r } } m.cursor = indexOf(m.visible, aur) collapsed, _ := m.Update(tea.KeyMsg{Type: tea.KeyLeft}) m2 := collapsed.(Model) if m2.visible[m2.cursor].Expanded { t.Fatal("left did not collapse the section") } reopened, _ := m2.Update(tea.KeyMsg{Type: tea.KeyEnter}) if !reopened.(Model).visible[reopened.(Model).cursor].Expanded { t.Error("enter did not re-expand a collapsed section") } // on a package, an expanded node opens the overlay instead of dead-ending var pkg *Node for _, c := range aur.Children { if c.Name == "demo-bin" { pkg = c } } atPkg := reopened.(Model) atPkg.cursor = indexOf(atPkg.visible, pkg) deeper, _ := atPkg.Update(tea.KeyMsg{Type: tea.KeyEnter}) if deeper.(Model).modal == nil { t.Error("enter on an expanded package should open the overlay") } } func TestExpandCollapseChangesVisibility(t *testing.T) { m := loaded(t, sample(), 120, 40) before := len(m.visible) var target *Node for _, r := range m.roots { if r.Title == "AUR" { target = r } } m.cursor = indexOf(m.visible, target) if m.cursor < 0 { t.Fatal("AUR section not visible") } // `left` collapses; `enter` would now drill in instead next, _ := m.Update(tea.KeyMsg{Type: tea.KeyLeft}) collapsed := next.(Model) if len(collapsed.visible) >= before { t.Errorf("collapsing did not hide rows: %d -> %d", before, len(collapsed.visible)) } reopened, _ := collapsed.Update(tea.KeyMsg{Type: tea.KeyEnter}) if got := len(reopened.(Model).visible); got != before { t.Errorf("re-expanding gave %d rows, want %d", got, before) } } func TestCursorMovesAndClampsAtBothEnds(t *testing.T) { m := loaded(t, sample(), 120, 40) atTop := m for i := 0; i < 5; i++ { next, _ := atTop.Update(tea.KeyMsg{Type: tea.KeyUp}) atTop = next.(Model) } if atTop.cursor != 0 { t.Errorf("cursor went above the first row: %d", atTop.cursor) } atEnd := m for i := 0; i < len(m.visible)+10; i++ { next, _ := atEnd.Update(tea.KeyMsg{Type: tea.KeyDown}) atEnd = next.(Model) } if atEnd.cursor != len(atEnd.visible)-1 { t.Errorf("cursor = %d, want %d", atEnd.cursor, len(atEnd.visible)-1) } } func TestQuitKeysQuit(t *testing.T) { m := loaded(t, sample(), 120, 40) for _, key := range []tea.KeyType{tea.KeyCtrlC} { if _, cmd := m.Update(tea.KeyMsg{Type: key}); cmd == nil { t.Errorf("%v did not produce a command", key) } } if _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}); cmd == nil { t.Error("q did not quit") } } func TestEscClosesRatherThanQuits(t *testing.T) { // esc belongs to popups now; quitting on it would close a modal and the app m := loaded(t, sample(), 120, 40) m.modal = newHelp() next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) final := next.(Model) if final.modal != nil { t.Error("esc did not close the modal") } if cmd != nil { t.Error("esc produced a quit command") } } // -- decisions ------------------------------------------------------------ func selectKind(m Model, name string) Model { for i, n := range m.visible { if n.Name == name { m.cursor = i return m } } return m } func TestSpaceSkipsAnAurPackage(t *testing.T) { m := selectKind(loaded(t, sample(), 120, 40), "demo-bin") next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) final := next.(Model) if !final.skipped["demo-bin"] { t.Fatal("space did not skip the AUR package") } for _, n := range final.visible { if n.Name == "demo-bin" && !n.Skipped { t.Error("the row was not marked as skipped") } } // and again to unskip again, _ := final.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) if again.(Model).skipped["demo-bin"] { t.Error("space did not unskip") } } func TestSpaceHoldsARepoPackageInsteadOfSkippingIt(t *testing.T) { // a repo package cannot simply be dropped from pacman -Syu m := selectKind(loaded(t, sample(), 120, 40), "linux") next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) final := next.(Model) if final.held["linux"] != true { t.Error("space on a repo package should hold it") } if final.skipped["linux"] { t.Error("a repo package must not be recorded as a plain skip") } } func TestIgnoreIsPersistedThroughTheConfig(t *testing.T) { m := selectKind(loaded(t, sample(), 120, 40), "demo-bin") next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}}) final := next.(Model) if !final.cfg.IsIgnored("demo-bin") { t.Fatal("i did not ignore the package") } if !config.Load().IsIgnored("demo-bin") { t.Error("the ignore was not written to the config file") } } func TestUndoReversesTheLastDecision(t *testing.T) { m := selectKind(loaded(t, sample(), 120, 40), "demo-bin") skipped, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) undone, _ := skipped.(Model).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'u'}}) if undone.(Model).skipped["demo-bin"] { t.Error("u did not undo the skip") } if len(undone.(Model).undo) != 0 { t.Error("the undo stack was not popped") } } func TestUndoWithNothingToUndoIsHarmless(t *testing.T) { m := loaded(t, sample(), 120, 40) next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'u'}}) if next.(Model).status == "" { t.Error("undo with an empty stack should say so") } } func TestIgnoreToggleIsReportedInTheStatusLine(t *testing.T) { m := loaded(t, sample(), 120, 40) m = selectKind(m, "demo-bin") next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}}) final := next.(Model) if !strings.Contains(final.status, "ignoring") { t.Errorf("status = %q", final.status) } if !strings.Contains(plain(final.renderHeader()), "ignored") { t.Errorf("header does not mention the ignore: %q", plain(final.renderHeader())) } } // -- modals --------------------------------------------------------------- func indexOf(nodes []*Node, want *Node) int { for i, n := range nodes { if n == want { return i } } return -1 } func TestQuestionMarkOpensHelpAndEscClosesIt(t *testing.T) { m := loaded(t, sample(), 120, 40) next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}) opened := next.(Model) if opened.modal == nil { t.Fatal("? did not open help") } view := plain(opened.View()) for _, want := range []string{"KEYMAP", "NAV", "move", "esc"} { if !strings.Contains(view, want) { t.Errorf("help is missing %q", want) } } closed, _ := opened.Update(tea.KeyMsg{Type: tea.KeyEsc}) if closed.(Model).modal != nil { t.Error("esc did not close help") } } func TestHelpTabsCycle(t *testing.T) { h := newHelp() for i := 0; i < len(listHelpTabs); i++ { if h.tab != i { t.Fatalf("tab = %d, want %d", h.tab, i) } h.Update(tea.KeyMsg{Type: tea.KeyTab}) if h.tab != (i+1)%len(listHelpTabs) { t.Fatalf("after tab, tab = %d", h.tab) } } back, _ := newHelp().Update(tea.KeyMsg{Type: tea.KeyShiftTab}) if back.(*helpModal).tab != len(listHelpTabs)-1 { t.Error("shift+tab should wrap backwards") } } func TestTheKeymapDoesNotChangeHeightBetweenTabs(t *testing.T) { // a box that resizes as you switch tabs is the thing this avoids h := newHelp() heights := map[int]int{} for i, tab := range listHelpTabs { h.tab = i heights[i] = lipgloss.Height(h.View(120, 40)) _ = tab } for i := 1; i < len(heights); i++ { if heights[i] != heights[0] { t.Errorf("tab %d renders %d rows, tab 0 renders %d", i, heights[i], heights[0]) } } } func TestConfirmModalWarnsAboutAPartialUpgrade(t *testing.T) { m := selectKind(loaded(t, sample(), 120, 40), "linux") held, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) next, _ := held.(Model).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) modal := next.(Model).modal if modal == nil { t.Fatal("a did not open the confirm modal") } view := plain(modal.View(120, 40)) if !strings.Contains(view, "partial upgrade") { t.Errorf("no partial-upgrade warning in:\n%s", view) } if !strings.Contains(view, "--ignore=linux") { t.Errorf("the held package is not shown in the command:\n%s", view) } } func TestConfirmModalNamesTheCommands(t *testing.T) { m := loaded(t, sample(), 120, 40) next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) view := plain(next.(Model).modal.View(120, 40)) for _, want := range []string{"pacman -Syu", "paru -Sua"} { if !strings.Contains(view, want) { t.Errorf("confirm modal is missing %q", want) } } } func TestConfirmAnsweredNoClosesWithoutApplying(t *testing.T) { m := loaded(t, sample(), 120, 40) next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) closed, _ := next.(Model).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}) if closed.(Model).modal != nil { t.Error("n did not close the confirm modal") } } // -- filter --------------------------------------------------------------- func TestFilterNarrowsTheVisibleRows(t *testing.T) { m := loaded(t, sample(), 120, 40) before := len(m.visible) filtering, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) filtered, _ := filtering.(Model).Update( tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("enca")}) got := filtered.(Model) if len(got.visible) >= before { t.Errorf("filter did not narrow anything: %d -> %d", before, len(got.visible)) } joined := plain(joinRows(got.visible)) if !strings.Contains(joined, "enca-bin") { t.Errorf("the matching row is not visible:\n%s", joined) } if strings.Contains(joined, "helix-git") { t.Errorf("a non-matching row survived:\n%s", joined) } } func TestFilterWithNoMatchesSaysSoRatherThanShowingNothing(t *testing.T) { m := loaded(t, sample(), 120, 40) filtering, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) filtered, _ := filtering.(Model).Update( tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("zzzzz")}) view := plain(filtered.(Model).View()) if !strings.Contains(view, "no matches") { t.Errorf("an empty filter result should say so:\n%s", view) } } func TestEscDuringFilterClearsIt(t *testing.T) { m := loaded(t, sample(), 120, 40) before := len(m.visible) filtering, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) typed, _ := filtering.(Model).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("enca")}) cleared, _ := typed.(Model).Update(tea.KeyMsg{Type: tea.KeyEsc}) got := cleared.(Model) if got.filtering || got.filter != "" { t.Errorf("esc left the filter active: %q", got.filter) } if len(got.visible) != before { t.Errorf("rows not restored: %d vs %d", len(got.visible), before) } } func joinRows(nodes []*Node) string { var b strings.Builder for _, n := range nodes { b.WriteString(n.Render(NewColumns(120), false)) b.WriteString("\n") } return b.String() } // -- spacing -------------------------------------------------------------- func TestSectionsAreSeparatedByBlankRows(t *testing.T) { // cramming every heading against the previous section is what made the first // version feel dense m := loaded(t, sample(), 120, 40) spacers := 0 for _, n := range m.visible { if n.Kind == KindSpacer { spacers++ } } if spacers < 2 { t.Errorf("expected a spacer between sections, found %d", spacers) } } func TestTheCursorNeverRestsOnASpacer(t *testing.T) { m := loaded(t, sample(), 120, 40) for i := 0; i < len(m.visible)+5; i++ { next, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) m = next.(Model) if m.visible[m.cursor].Kind == KindSpacer { t.Fatalf("cursor landed on a spacer at %d", m.cursor) } } } func TestViewRendersAtManySizes(t *testing.T) { doc := sample() for _, size := range [][2]int{{40, 8}, {60, 12}, {80, 24}, {120, 40}, {200, 60}} { m := loaded(t, doc, size[0], size[1]) view := m.View() if view == "" { t.Fatalf("empty view at %dx%d", size[0], size[1]) } if strings.Contains(view, "panic") { t.Fatalf("panic text at %dx%d", size[0], size[1]) } } } func TestViewRefusesToRenderInATinyTerminal(t *testing.T) { m := loaded(t, sample(), 20, 4) if !strings.Contains(m.View(), "too small") { t.Errorf("a 20x4 terminal should ask for more room, got: %q", m.View()) } } func TestViewShowsTheBackendErrorRatherThanNothing(t *testing.T) { m := New(plan.Backend{}, testConfig(t)) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) next, _ = next.(Model).Update(loadedMsg{err: &plan.Error{ Args: []string{"python3", "-m", "updater"}, Stderr: "no module named updater"}}) view := plain(next.(Model).View()) if !strings.Contains(view, "no module named updater") { t.Errorf("the error was swallowed: %q", view) } } func TestTidyRemovesEmptyEscapeSegments(t *testing.T) { // truncating a styled label can leave the escapes with no text between them dirty := "\x1b[36m\x1b[0m text" if got := tidy(dirty); got != " text" { t.Errorf("tidy = %q, want %q", got, " text") } keep := "\x1b[36mcyan\x1b[0m" if got := tidy(keep); got != keep { t.Errorf("tidy damaged real content: %q", got) } } func TestFlattenRespectsExpansionAndAssignsDepth(t *testing.T) { leaf := &Node{Text: "leaf"} parent := &Node{Text: "parent", Children: []*Node{leaf}} root := &Node{Text: "root", Children: []*Node{parent}, Expanded: true} if got := len(Flatten([]*Node{root})); got != 2 { t.Errorf("collapsed parent should hide its child: got %d nodes", got) } parent.Expanded = true flat := Flatten([]*Node{root}) if len(flat) != 3 { t.Fatalf("expected 3 nodes, got %d", len(flat)) } if flat[0].depth != 0 || flat[1].depth != 1 || flat[2].depth != 2 { t.Errorf("depths = %d,%d,%d", flat[0].depth, flat[1].depth, flat[2].depth) } } func TestThePagerMessageIsHandled(t *testing.T) { // a message with no handler is silently dropped, which is how `o` would fail m := loaded(t, sample(), 120, 40) next, cmd := m.Update(pagerMsg{path: "/tmp/nonexistent.diff"}) if cmd == nil && next.(Model).status == "" { t.Error("pagerMsg was neither handled nor reported") } } func TestOpenDiffOnANonPackageIsANoOp(t *testing.T) { m := loaded(t, sample(), 120, 40) m.cursor = 0 // a section header if _, cmd := m.openDiff(); cmd != nil { t.Error("openDiff should do nothing on a non-package row") } } func TestClipboardFailureIsReportedNotSwallowed(t *testing.T) { // no clipboard helper is a normal state on a headless box err := writeClipboard("hello") if err == nil { return // a helper exists here; nothing to assert } if !strings.Contains(err.Error(), "clipboard") { t.Errorf("unhelpful clipboard error: %v", err) } } func stripANSI(s string) string { return ansi.Strip(s) } // -- VCS packages are first-class AUR rows now ---------------------------- func TestVcsPackagesCanBeSkipped(t *testing.T) { doc := sample() doc.Vcs = []plan.Vcs{{Name: "helix-git", Old: "r1", New: "r2"}} m := selectKind(loaded(t, doc, 120, 40), "helix-git") if m.selected() == nil || m.selected().Name != "helix-git" { t.Fatal("could not put the cursor on the VCS package") } next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) final := next.(Model) if !final.skipped["helix-git"] { t.Error("a VCS package could not be skipped") } } func TestVcsPackagesCanBeIgnored(t *testing.T) { doc := sample() doc.Vcs = []plan.Vcs{{Name: "helix-git", Old: "r1", New: "r2"}} m := selectKind(loaded(t, doc, 120, 40), "helix-git") next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}}) if !next.(Model).cfg.IsIgnored("helix-git") { t.Error("a VCS package could not be ignored") } } func TestADecisionIsVisibleInTheRow(t *testing.T) { // the tag column changes shape, which is what makes a decision obvious m := selectKind(loaded(t, sample(), 120, 40), "demo-bin") before := plain(joinRows(m.visible)) skipped, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) after := plain(joinRows(skipped.(Model).visible)) if before == after { t.Fatal("skipping changed nothing on screen") } if !strings.Contains(after, "skipped") { t.Errorf("the row does not say it is skipped:\n%s", after) } // and the tag column reflects it rather than still reading U for _, n := range skipped.(Model).visible { if n.Name == "demo-bin" { line := plain(n.Render(NewColumns(120), false)) if !strings.Contains(line, "⊘") { t.Errorf("no state glyph on the row: %q", line) } } } } func TestIgnoringAndSkippingLookDifferent(t *testing.T) { m := selectKind(loaded(t, sample(), 120, 40), "demo-bin") skipped, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) skipGlyph := plain(joinRows(skipped.(Model).visible)) ignored, _ := skipped.(Model).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}}) ignoreGlyph := plain(joinRows(ignored.(Model).visible)) if skipGlyph == ignoreGlyph { t.Error("a skip and an ignore render identically; they mean different things") } if !strings.Contains(ignoreGlyph, "ignored") { t.Errorf("the row does not say it is ignored:\n%s", ignoreGlyph) } } func TestTheHeaderCountsVcsPackagesAsAur(t *testing.T) { // the count has to match what the backend will be asked to build doc := sample() doc.Vcs = []plan.Vcs{{Name: "a-git", Old: "r1", New: "r2"}, {Name: "b-git", Old: "r3", New: "r4"}} m := loaded(t, doc, 120, 40) head := plain(m.renderHeader()) if !strings.Contains(head, "4 AUR") { t.Errorf("header = %q, want it to include the two VCS packages", head) } } func TestTheConfirmDialogCountsVcsPackages(t *testing.T) { doc := sample() doc.Vcs = []plan.Vcs{{Name: "a-git", Old: "r1", New: "r2"}} m := loaded(t, doc, 120, 40) commands, notes, _ := m.planLines() if len(commands) == 0 { t.Fatal("no commands planned") } joined := strings.Join(notes, " ") if !strings.Contains(joined, "3 AUR") { t.Errorf("notes = %q, want 3 AUR (2 aur + 1 vcs)", joined) } } // -- fetch-backed modals -------------------------------------------------- func TestEnteringAnAdvisoryShowsALoadingModalThenItsRecord(t *testing.T) { m := loaded(t, sample(), 120, 40) m.modal = newTextLoading("AVG-1", "fetching") view := plain(m.modal.View(120, 40)) if !strings.Contains(view, "fetching") { t.Errorf("a loading modal must say what it is waiting for:\n%s", view) } next, _ := m.modal.Update(textBodyMsg{title: "AVG-1", lines: []string{"CVE-2022-1"}, foot: "https://security.archlinux.org/AVG-1"}) got := plain(next.View(120, 40)) if !strings.Contains(got, "CVE-2022-1") { t.Errorf("the fetched record did not render:\n%s", got) } } func TestAStaleFetchDoesNotReplaceTheCurrentModal(t *testing.T) { // a slow fetch for a package you have moved on from must not clobber the view modal := newTextLoading("AVG-2", "fetching") next, _ := modal.Update(textBodyMsg{title: "AVG-1", lines: []string{"WRONG"}}) if strings.Contains(plain(next.View(120, 40)), "WRONG") { t.Error("a fetch for a different advisory was applied") } } func TestAFailedFetchSaysSoRatherThanShowingNothing(t *testing.T) { modal := newTextLoading("AVG-1", "fetching") next, _ := modal.Update(textBodyMsg{title: "AVG-1", err: errString("could not reach security.archlinux.org")}) view := plain(next.View(120, 40)) if !strings.Contains(view, "could not reach") { t.Errorf("a failed fetch was not explained:\n%s", view) } } func TestAdvisoryLinesCarryEverythingNeededToSkipTheLink(t *testing.T) { lines := advisoryLines(&plan.AdvisoryDetail{ ID: "AVG-2842", Available: true, Severity: "High risk", Kind: "rce", Affected: "4.4.0-1", Fixed: "", Packages: []string{"libtiff"}, Issues: []string{"CVE-2022-48281", "CVE-2022-3450"}, Notes: "bulk add", URL: "https://security.archlinux.org/AVG-2842", }) text := plain(strings.Join(lines, "\n")) for _, want := range []string{"AVG-2842", "High risk", "4.4.0-1", "libtiff", "CVE-2022-48281", "bulk add", "no fix available"} { if !strings.Contains(text, want) { t.Errorf("the advisory view is missing %q:\n%s", want, text) } } } func TestSecurityLinesPutFixableFirst(t *testing.T) { lines := securityLines(&plan.SecurityReport{ Available: true, Total: 2, Fixable: 1, Unfixable: 1, Advisories: []plan.Advisory{ {ID: "AVG-1", Severity: "High risk", Fixed: "6.16-1", Packages: []string{"linux"}}, {ID: "AVG-2", Severity: "Unknown", Packages: []string{"libtiff"}}, }, }) text := plain(strings.Join(lines, "\n")) if !strings.Contains(text, "fixed in 6.16-1") { t.Errorf("the fixable advisory lost its fix:\n%s", text) } if !strings.Contains(text, "no fix available") { t.Errorf("the unfixable advisory is not marked:\n%s", text) } } func TestTheSpinnerAdvances(t *testing.T) { l := newLoading("working", "fetching things") if got := plain(l.View(80, 24)); !strings.Contains(got, spinnerFrames[0]) { t.Errorf("first frame missing:\n%s", got) } l.Update(spinnerTickMsg{}) if got := plain(l.View(80, 24)); !strings.Contains(got, spinnerFrames[1]) { t.Errorf("the spinner did not advance:\n%s", got) } // The tick chain belongs to the model, not to the modal: a chain each // screen re-armed for itself could be dropped by whichever forgot, and a // modal cannot know whether the loader behind it still needs animating. m := Model{modal: l, loading: true, width: 80, height: 24, cfg: testConfig(t)} next, cmd := m.Update(spinnerTickMsg{}) if cmd == nil { t.Error("the chain stopped while something still needed animating") } // and it stops once nothing does done := next.(Model) done.loading = false done.modal = nil if _, cmd := done.Update(spinnerTickMsg{}); cmd != nil { t.Error("the chain kept ticking with nothing to animate") } } func TestALoadingModalCannotBeDismissedAsIfItWereCancelled(t *testing.T) { // esc while waiting would imply a cancellation that does not happen l := newLoading("arch-audit", "running") next, _ := l.Update(tea.KeyMsg{Type: tea.KeyEsc}) if next == nil { t.Error("esc closed a modal for work that is still running") } } func TestTheStartupViewNamesWhatItIsWaitingOn(t *testing.T) { m := New(plan.Backend{}, testConfig(t)) m.width, m.height = 100, 30 if !m.loading { t.Fatal("a fresh model should be loading") } view := plain(m.View()) if !strings.Contains(view, "updater") { t.Errorf("no branding while loading:\n%s", view) } // "pending" is gone on purpose. The screen used to name the phases it // expected to run before it had read the plan, so the list was a guess // dressed as progress. It says that it is loading and how long for; the // phases arrive when they are known. if !strings.Contains(view, "loading") { t.Errorf("the loading view does not say what it is doing:\n%s", view) } } // -- the size column ------------------------------------------------------ func TestSizeDeltaSigns(t *testing.T) { cases := map[int64]string{ 0: "0 B", 1048576: "+1.0 MiB", -1048576: "-1.0 MiB", -2150: "-2.1 KiB", } for in, want := range cases { if got := sizeDelta(in); got != want { t.Errorf("sizeDelta(%d) = %q, want %q", in, got, want) } } } // displayWidth measures a rendered line the way a terminal would. func displayWidth(s string) int { return ansi.StringWidth(s) } // -- acknowledging advisories --------------------------------------------- func advisoryModel(t *testing.T, acked int) Model { t.Helper() doc := sample() doc.Advisories = []plan.Advisory{ {ID: "AVG-1", Severity: "High risk", Packages: []string{"linux"}}, {ID: "AVG-2", Severity: "Low", Packages: []string{"vim"}}, } doc.AckedCount = acked m := loaded(t, doc, 120, 40) // the config is what actually gets written, so acknowledge there too for i := 0; i < acked; i++ { m.cfg.Ack(fmt.Sprintf("AVG-OLD-%d", i)) } for i, n := range m.visible { if n.Kind == KindAdvisory { m.cursor = i break } } return m } func TestAcknowledgingAnAdvisoryRemovesItAndPersists(t *testing.T) { m := advisoryModel(t, 0) if m.selected() == nil || m.selected().Kind != KindAdvisory { t.Fatal("cursor is not on an advisory") } id := m.selected().Name next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) final := next.(Model) acks := config.Load().AckedList() if len(acks) != 1 || acks[0] != id { t.Errorf("acknowledgement was not written: %v", acks) } for _, n := range final.visible { if n.Kind == KindAdvisory && n.Name == id { t.Errorf("%s is still listed after being acknowledged", id) } } } func TestAcknowledgingANonAdvisoryDoesNothing(t *testing.T) { m := selectKind(loaded(t, sample(), 120, 40), "demo-bin") next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) if len(next.(Model).cfg.AckedList()) != 0 { t.Error("a package row was treated as an advisory") } } func TestClearingAcksAsksFirstAndSaysSoWhenThereAreNone(t *testing.T) { m := loaded(t, sample(), 120, 40) if got, _ := press(m, 'C'); got.modal != nil { t.Error("a confirmation appeared with nothing to clear") } m2 := advisoryModel(t, 3) asked, _ := press(m2, 'C') if asked.modal == nil { t.Fatal("clearing acks should ask first") } final := answer(asked, 'y') if acks := final.cfg.AckedList(); len(acks) != 0 { t.Errorf("acks survived the clear: %v", acks) } if len(config.Load().AckedList()) != 0 { t.Error("the cleared acks were not written") } } func TestTheSecurityHeaderCountsAcknowledgedAdvisories(t *testing.T) { m := advisoryModel(t, 4) joined := plain(joinRows(m.visible)) if !strings.Contains(joined, "4 acknowledged") { t.Errorf("the count is not shown:\n%s", joined) } } // -- answering a confirmation --------------------------------------------- // press runs one key and returns the model plus whatever command it produced. func press(m Model, key rune) (Model, tea.Cmd) { next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{key}}) return next.(Model), cmd } // enterKey presses return, which is not a rune and so cannot go through press. func enterKey(m Model) (Model, tea.Cmd) { next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) return next.(Model), cmd } // answer runs a key and then delivers the message that key produced, which is // how a confirmation actually reaches the model: the modal closes on the press // and the answer arrives as a separate message. func answer(m Model, key rune) Model { closed, cmd := press(m, key) if cmd == nil { return closed } settled, _ := closed.Update(cmd()) return settled.(Model) } // -- running a check's suggested command ---------------------------------- func checkModel(t *testing.T, label string, action []string) Model { t.Helper() doc := sample() doc.Checks = []plan.Check{{OK: false, Label: label, Detail: "needs doing", Details: []string{"why it matters"}, Action: action, Warning: "removes things"}} m := loaded(t, doc, 120, 40) for i, n := range m.visible { if n.Kind == KindCheck && n.Text == label { m.cursor = i break } } return m } func TestRunOffersTheCommandForConfirmation(t *testing.T) { m := checkModel(t, "cache", []string{"paccache", "-rk3"}) final, _ := press(m, 'R') if final.modal == nil { t.Fatal("R did not ask first") } view := plain(final.modal.View(120, 40)) for _, want := range []string{"paccache -rk3", "removes things"} { if !strings.Contains(view, want) { t.Errorf("the confirmation is missing %q:\n%s", want, view) } } if len(final.pendingCommand) == 0 { t.Error("the command was not remembered") } } func TestDecliningDoesNotRunAnything(t *testing.T) { m := checkModel(t, "cache", []string{"paccache", "-rk3"}) asked, _ := press(m, 'R') final := answer(asked, 'n') if final.modal != nil { t.Error("n did not close the confirmation") } if final.pendingCommand != nil { t.Error("the command survived a decline") } if final.build != nil { t.Error("declining started something") } } func TestRunOnARowWithNothingToRunSaysSo(t *testing.T) { m := checkModel(t, "pacnew", nil) final, _ := press(m, 'R') if final.modal != nil { t.Error("a confirmation appeared with no command") } if !strings.Contains(final.status, "nothing to run") { t.Errorf("status = %q", final.status) } } func TestTheFooterOffersRunOnlyWhereItApplies(t *testing.T) { withAction := checkModel(t, "cache", []string{"paccache", "-rk3"}) if !strings.Contains(plain(withAction.renderFooter()), "run the fix") { t.Errorf("the footer does not offer R:\n%s", plain(withAction.renderFooter())) } without := checkModel(t, "pacnew", nil) if strings.Contains(plain(without.renderFooter()), "run the fix") { t.Errorf("the footer offers R where there is nothing to run:\n%s", plain(without.renderFooter())) } } func TestTheFooterOffersMarkReadOnTheNewsRowOnly(t *testing.T) { news := checkModel(t, "news", nil) if !strings.Contains(plain(news.renderFooter()), "mark read") { t.Errorf("the news row does not offer n:\n%s", plain(news.renderFooter())) } other := checkModel(t, "cache", nil) if strings.Contains(plain(other.renderFooter()), "mark read") { t.Error("a non-news row offers n") } } func TestMarkingNewsReadIsConfirmedAndPersisted(t *testing.T) { m := checkModel(t, "news", nil) asked, _ := press(m, 'n') if asked.modal == nil { t.Fatal("n did not ask first") } final := answer(asked, 'y') if final.pendingNews { t.Error("the pending flag was not cleared") } if !config.Load().HasSeenNews() { t.Error("the news timestamp was not written") } } func TestMarkingNewsReadWithNoNewsRowIsHarmless(t *testing.T) { m := checkModel(t, "cache", nil) final, _ := press(m, 'n') if final.modal != nil { t.Error("a confirmation appeared with no news row") } if !strings.Contains(final.status, "no news") { t.Errorf("status = %q", final.status) } } // -- the confirmation must actually reach the model ------------------------ // stubApplyBackend writes a Python module that emits a valid event stream, so // "did the apply start" can be answered without running an update. func stubApplyBackend(t *testing.T) plan.Backend { t.Helper() dir := t.TempDir() mod := filepath.Join(dir, "stubupdater") if err := os.MkdirAll(mod, 0o755); err != nil { t.Fatal(err) } os.WriteFile(filepath.Join(mod, "__init__.py"), []byte(""), 0o644) os.WriteFile(filepath.Join(mod, "__main__.py"), []byte(` import json, sys, time def emit(**kw): sys.stdout.write(json.dumps(kw) + "\n"); sys.stdout.flush() emit(event="start", phases=["aur"], total=1) emit(event="phase_start", phase="aur", command="paru -S --needed", total=1) emit(event="finished", name="demo-bin", elapsed=1.0, state="done", version="2-1") emit(event="phase_end", phase="aur", returncode=0, elapsed=1.0) emit(event="done", returncode=0, elapsed=1.0) `), 0o644) return plan.Backend{Python: "python3", Module: "stubupdater", Env: []string{"PYTHONPATH=" + dir}} } func TestConfirmingAnApplyActuallyStartsIt(t *testing.T) { // The confirmation answers with a message rather than the keypress, so the // model has to handle it after the modal closes. It used to be looked for // while the modal was still open, which meant "y" closed the dialog and did // nothing at all -- the apply path could never run. m := New(stubApplyBackend(t), testConfig(t)) next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) m = next.(Model) next, _ = m.Update(loadedMsg{plan: sample()}) m = next.(Model) asked, _ := press(m, 'a') if asked.modal == nil { t.Fatal("a did not open the confirmation") } final := answer(asked, 'y') // Confirming now authenticates first: sudo cannot prompt through the closed // input the phases run with, so the credentials are cached while the real // terminal is still available. if final.build == nil { if !final.pendingApply { t.Fatalf("confirming neither ran nor asked to authenticate; modal %T", final.modal) } authed, _ := final.Update(sudoMsg{err: nil}) final = authed.(Model) } if final.build == nil { t.Fatalf("the apply still did not start; modal %T, status %q", final.modal, final.status) } if final.modal != nil { t.Errorf("a modal is still open after confirming: %T", final.modal) } // and the stream it started is readable var events int deadline := time.After(20 * time.Second) for events < 5 { select { case ev, ok := <-final.stream.Events: if !ok { t.Fatal("the stream closed early") } events++ final.build.Apply(ev) case <-deadline: t.Fatalf("only %d events arrived", events) } } if len(final.build.Feed) != 1 { t.Errorf("feed = %+v", final.build.Feed) } _ = final.stream.Wait() } func TestDecliningAnApplyStartsNothing(t *testing.T) { m := New(stubApplyBackend(t), testConfig(t)) next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) m = next.(Model) next, _ = m.Update(loadedMsg{plan: sample()}) m = next.(Model) asked, _ := press(m, 'a') final := answer(asked, 'n') if final.build != nil { t.Error("declining started an apply") } if final.modal != nil { t.Errorf("a modal is still open: %T", final.modal) } } // -- expansion survives a reload ------------------------------------------ func TestAReloadKeepsWhatWasOpen(t *testing.T) { // Rebuilding produces fresh nodes, which would snap everything back to its // default. Reloading should show updated facts, not undo navigation. m := loaded(t, sample(), 120, 40) var aur *Node for _, r := range m.roots { if r.Title == "AUR" { aur = r } } if aur == nil { t.Fatal("no AUR section") } m.cursor = indexOf(m.visible, aur) // collapse it next, _ := m.Update(tea.KeyMsg{Type: tea.KeyLeft}) m = next.(Model) for _, r := range m.roots { if r.Title == "AUR" && r.Expanded { t.Fatal("the section did not collapse") } } // now reload with the same document next, _ = m.Update(loadedMsg{plan: sample()}) m = next.(Model) for _, r := range m.roots { if r.Title == "AUR" && r.Expanded { t.Error("the reload re-expanded a section the reader had closed") } } } func TestAReloadOfTheChecksKeepsExpansion(t *testing.T) { m := loaded(t, sample(), 120, 40) var checks *Node for _, r := range m.roots { if r.Title == "Checks" { checks = r } } m.cursor = indexOf(m.visible, checks) next, _ := m.Update(tea.KeyMsg{Type: tea.KeyLeft}) m = next.(Model) m.mergeChecks([]plan.Check{{OK: true, Label: "pacnew", Detail: "clean"}}, []string{"pacnew"}) for _, r := range m.roots { if r.Title == "Checks" && r.Expanded { t.Error("merging refreshed checks re-expanded the section") } } } func TestRefreshedChecksReplaceOnlyTheirOwnRows(t *testing.T) { m := loaded(t, sample(), 120, 40) before := map[string]string{} for _, c := range m.doc.Checks { before[c.Label] = c.Detail } m.mergeChecks([]plan.Check{ {OK: true, Label: "databases", Detail: "refreshed"}, {OK: true, Label: "cache", Detail: "nothing worth reclaiming"}, }, []string{"databases", "cache"}) after := map[string]string{} for _, c := range m.doc.Checks { after[c.Label] = c.Detail } if after["databases"] != "refreshed" { t.Errorf("databases was not updated: %q", after["databases"]) } if after["cache"] != "nothing worth reclaiming" { t.Errorf("cache was not updated: %q", after["cache"]) } // and nothing else changed for label, detail := range before { if label == "databases" || label == "cache" { continue } if after[label] != detail { t.Errorf("%s changed unexpectedly: %q -> %q", label, detail, after[label]) } } } func TestARefreshedCheckAppearsIfItIsNew(t *testing.T) { m := loaded(t, sample(), 120, 40) m.mergeChecks([]plan.Check{{OK: false, Label: "brand-new", Detail: "hello"}}, []string{"brand-new"}) found := false for _, c := range m.doc.Checks { if c.Label == "brand-new" { found = true } } if !found { t.Error("a check that appeared during the refresh was dropped") } } // -- a plan step's command queue ------------------------------------------ func stepModel(t *testing.T, stages []plan.Stage) Model { t.Helper() doc := sample() doc.Steps = []plan.Step{{Index: "1", Command: "paru -S --needed", Detail: "3 AUR packages to build", Details: []string{"the prose follows"}, Queue: stages}} m := loaded(t, doc, 120, 40) for _, r := range m.roots { if r.Title == "Plan" { r.Expanded = true } } m.rebuildView() for i, n := range m.visible { if n.Kind == KindStep { m.cursor = i break } } return m } func sampleStages() []plan.Stage { return []plan.Stage{ {Phase: "snapshot", Command: "/usr/share/libalpm/scripts/snap-pac pre", What: "snapper takes a pre-transaction snapshot", Level: "note"}, {Phase: "build:helix-git", Command: "cargo build --release", What: "compiles helix-git", Level: "step"}, {Phase: "build:helix-git", Command: "curl -s https://x | bash", What: "pipes a download into a shell", Level: "danger", Finding: "pipes a download straight into a shell"}, {Phase: "hooks", Command: "/usr/share/libalpm/scripts/systemd-hook daemon-reload", What: "Reloading system manager configuration", Level: "hook"}, } } func TestAStepOpensItsCommandQueue(t *testing.T) { m := stepModel(t, sampleStages()) final, _ := enterKey(m) if final.modal == nil { t.Fatal("enter on a step opened nothing") } view := plain(final.modal.View(110, 40)) for _, want := range []string{ "snap-pac pre", // the snapshot hook "cargo build --release", // a PKGBUILD command "daemon-reload", // an alpm hook "SNAPSHOT", "BUILD", "HOOKS", // phase headings } { if !strings.Contains(view, want) { t.Errorf("the queue is missing %q:\n%s", want, view) } } } func TestAFlaggedCommandIsCalledOutWithItsRule(t *testing.T) { m := stepModel(t, sampleStages()) final, _ := enterKey(m) view := plain(final.modal.View(110, 40)) if !strings.Contains(view, "!!") { t.Error("a dangerous stage is not marked") } if !strings.Contains(view, "pipes a download straight into a shell") { t.Errorf("the rule that flagged it is not named:\n%s", view) } if !strings.Contains(view, "1 command(s) flagged") { t.Errorf("the count of flagged commands is missing:\n%s", view) } } func TestAQueueWithNothingFlaggedSaysSo(t *testing.T) { clean := []plan.Stage{{Phase: "build:x", Command: "make", What: "builds x"}} m := stepModel(t, clean) final, _ := enterKey(m) view := plain(final.modal.View(110, 30)) if strings.Contains(view, "flagged by the audit") { t.Error("a clean queue does not claim to be flagged") } if !strings.Contains(view, "Nothing in this queue matched") { t.Errorf("a clean queue should say so:\n%s", view) } } func TestAStepWithoutAQueueFallsBackToItsProse(t *testing.T) { m := stepModel(t, nil) final, _ := enterKey(m) if final.modal == nil { t.Fatal("a step with prose but no queue opened nothing") } if !strings.Contains(plain(final.modal.View(110, 30)), "the prose follows") { t.Error("the prose was not shown") } } // -- searching inside a long listing -------------------------------------- func TestSearchingAListingJumpsToTheMatch(t *testing.T) { modal := newText("q") lines := make([]string, 60) for i := range lines { lines[i] = fmt.Sprintf("line %d", i) } lines[42] = "the needle is here" modal.Update(textBodyMsg{title: "q", lines: lines}) // open the search and type modal.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) for _, r := range "needle" { modal.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) } if len(modal.matches) != 1 { t.Fatalf("matches = %v", modal.matches) } if modal.scroll != 42 { t.Errorf("scroll = %d, want the matching line (42)", modal.scroll) } view := plain(modal.View(100, 30)) if !strings.Contains(view, "1 match") { t.Errorf("the match count is not shown:\n%s", view) } } func TestASearchThatFindsNothingSaysSo(t *testing.T) { modal := newText("q") modal.Update(textBodyMsg{title: "q", lines: []string{"alpha", "beta"}}) modal.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) for _, r := range "zzz" { modal.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) } modal.Update(tea.KeyMsg{Type: tea.KeyEnter}) if !strings.Contains(plain(modal.View(100, 20)), "no match") { t.Error("a fruitless search should say so") } } func TestEscapeLeavesTheSearchBeforeClosingTheModal(t *testing.T) { modal := newText("q") modal.Update(textBodyMsg{title: "q", lines: []string{"alpha"}}) modal.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) modal.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) back, _ := modal.Update(tea.KeyMsg{Type: tea.KeyEsc}) if back == nil { t.Fatal("esc in the search box closed the whole modal") } if back.(*textModal).query != "" { t.Error("esc did not clear the query") } closed, _ := back.Update(tea.KeyMsg{Type: tea.KeyEsc}) if closed != nil { t.Error("esc outside the search box should close the modal") } } func TestBackspaceEditsTheQuery(t *testing.T) { modal := newText("q") modal.Update(textBodyMsg{title: "q", lines: []string{"alpha", "beta"}}) modal.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) for _, r := range "alphaz" { modal.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) } if len(modal.matches) != 0 { t.Fatalf("expected no match for alphaz, got %v", modal.matches) } modal.Update(tea.KeyMsg{Type: tea.KeyBackspace}) if modal.query != "alpha" { t.Errorf("query = %q, want alpha", modal.query) } if len(modal.matches) != 1 { t.Errorf("matches after backspace = %v", modal.matches) } } // -- folding a queue ------------------------------------------------------ func queueStages() []plan.Stage { return []plan.Stage{ {Phase: "download", Command: "download a-2-1", What: "a 1-1 -> 2-1", Level: "step"}, {Phase: "download", Command: "download b-2-1", What: "b 1-1 -> 2-1", Level: "step"}, {Phase: "install", Command: "install a", What: "unpacked", Level: "step"}, {Phase: "hooks", Command: "mkinitcpio install", What: "Updating initcpios", Level: "hook"}, } } func TestAPhaseCanBeFolded(t *testing.T) { q := newQueue("pacman -Syu", queueStages()) if len(q.rows) != 3+4 { // 3 headings + 4 stages t.Fatalf("rows = %d", len(q.rows)) } // fold the first heading (downloads) q.cursor = 0 q.toggle() if !q.collapsed["download"] { t.Fatal("the phase did not fold") } view := plain(q.View(100, 30)) if strings.Contains(view, "download a-2-1") { t.Error("a folded command is still rendered") } if !strings.Contains(strings.ToUpper(view), "DOWNLOAD") { t.Errorf("the heading disappeared with its contents:\n%s", view) } } func TestFoldingCollapsesEverythingUnderIt(t *testing.T) { q := newQueue("q", queueStages()) q.cursor = 0 q.toggle() // only the heading for download should remain from that phase remaining := 0 for _, row := range q.rows { if row.phase == "download" && !row.header { remaining++ } } if remaining != 0 { t.Errorf("%d download rows survived the fold", remaining) } } func TestFoldAllAndUnfoldAll(t *testing.T) { q := newQueue("q", queueStages()) q.foldAll() if len(q.collapsed) != 3 { t.Fatalf("folded %d phases, want 3", len(q.collapsed)) } if len(q.rows) != 3 { // headings only t.Errorf("rows = %d, want just the headings", len(q.rows)) } q.collapsed = map[string]bool{} q.rebuild() if len(q.rows) != 7 { t.Errorf("unfolding left %d rows", len(q.rows)) } } func TestFoldedPhaseHeadingsShowTheirSize(t *testing.T) { q := newQueue("q", queueStages()) q.foldAll() view := plain(q.View(100, 30)) if !strings.Contains(view, "2") { t.Errorf("the folded heading does not say how much it hides:\n%s", view) } } func TestSearchingUnfoldsWhatItMatches(t *testing.T) { // a match hidden inside a fold is the same as no match q := newQueue("q", queueStages()) q.foldAll() q.query = "mkinitcpio" q.recompute() if q.collapsed["hooks"] { t.Error("the phase holding the match stayed folded") } if len(q.matches) != 1 { t.Fatalf("matches = %v", q.matches) } } func TestTheCursorCannotLeaveTheList(t *testing.T) { q := newQueue("q", queueStages()) for i := 0; i < 40; i++ { q.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) } if q.cursor >= len(q.rows) { t.Errorf("cursor %d past the end (%d rows)", q.cursor, len(q.rows)) } for i := 0; i < 60; i++ { q.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) } if q.cursor < 0 { t.Errorf("cursor went negative: %d", q.cursor) } } func TestFoldingPutsTheCursorOnTheHeading(t *testing.T) { q := newQueue("q", queueStages()) q.cursor = 1 // inside download q.toggle() if q.cursor >= len(q.rows) || !q.rows[q.cursor].header { t.Error("the cursor was left on a row that is no longer visible") } } func TestAPhaseCountMatchesWhatItHolds(t *testing.T) { q := newQueue("q", queueStages()) if got := q.phaseCount("download"); got != 2 { t.Errorf("download holds %d, want 2", got) } if got := q.distinctPhases(); got != 3 { t.Errorf("distinctPhases = %d, want 3", got) } } // -- tabbing through warnings --------------------------------------------- func warningTabs() []tabSection { return []tabSection{ {title: "left the AUR", note: "6 package(s) left the AUR", lines: []string{"alpha", "beta"}, flagged: 6}, {title: "general", note: "something else", lines: []string{"gamma"}}, } } func TestWarningsGetATabEach(t *testing.T) { m := newTabs("warnings", warningTabs()) view := plain(m.View(100, 30)) for _, want := range []string{"left the AUR", "general", "alpha"} { if !strings.Contains(view, want) { t.Errorf("missing %q:\n%s", want, view) } } if strings.Contains(view, "gamma") { t.Error("another tab's contents are showing") } } func TestTabSwitchingWraps(t *testing.T) { m := newTabs("warnings", warningTabs()) m.next(1) if m.current != 1 { t.Fatalf("current = %d", m.current) } if !strings.Contains(plain(m.View(100, 30)), "gamma") { t.Error("the second tab's contents are not shown") } m.next(1) if m.current != 0 { t.Errorf("tabbing past the end did not wrap: %d", m.current) } m.next(-1) if m.current != 1 { t.Errorf("tabbing before the start did not wrap: %d", m.current) } } func TestADigitJumpsStraightToATab(t *testing.T) { m := newTabs("warnings", warningTabs()) m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'2'}}) if m.current != 1 { t.Errorf("current = %d, want 1", m.current) } } func TestTheFlaggedCountIsOnTheTab(t *testing.T) { m := newTabs("warnings", warningTabs()) view := plain(m.View(100, 30)) if !strings.Contains(view, "left the AUR 6") { t.Errorf("the flagged count is not on the tab:\n%s", view) } } func TestASingleWarningHidesTheTabBar(t *testing.T) { m := newTabs("warnings", []tabSection{{title: "only", lines: []string{"x"}}}) if strings.Contains(plain(m.View(100, 30)), "only") { t.Error("a lone tab still renders a bar with nothing to switch to") } } func TestSearchingWithinATabMarksMatches(t *testing.T) { m := newTabs("warnings", []tabSection{ {title: "only", lines: []string{"alpha", "beta", "alpha again"}}}) m.query = "alpha" m.recompute() if len(m.matches) != 2 { t.Fatalf("matches = %v", m.matches) } if m.scroll != 0 { t.Errorf("scroll = %d, want the first match", m.scroll) } m.nextMatch(1) if m.scroll != 2 { t.Errorf("scroll = %d, want the second match", m.scroll) } } func TestTheWarningRowOpensTheTabbedPage(t *testing.T) { doc := sample() doc.Checks = []plan.Check{{OK: false, Label: "warning", Detail: "6 package(s) left the AUR"}} doc.Warnings = []plan.Warning{ {Title: "left the AUR", Headline: "6 package(s) left the AUR", Details: []string{"alpha"}, Flagged: 6}, {Title: "general", Headline: "something else", Details: []string{"beta"}}, } m := loaded(t, doc, 120, 40) for i, n := range m.visible { if n.Kind == KindCheck && n.Text == "warning" { m.cursor = i break } } final, _ := enterKey(m) if final.modal == nil { t.Fatal("the warning row opened nothing") } view := plain(final.modal.View(110, 34)) if !strings.Contains(view, "alpha") { t.Errorf("the warning details are not shown:\n%s", view) } if !strings.Contains(view, "general") { t.Errorf("there is no way to reach the other warning kind:\n%s", view) } } func TestACheckDoesNotRepeatItsOwnLabelInTheBody(t *testing.T) { // the row says the label and the modal is titled with it; a third copy at // the top of the body was noise on every check that had anything to say doc := sample() doc.Checks = []plan.Check{{OK: false, Label: "databases", Detail: "2d old · refresh failed", Details: []string{"pacman could not refresh its databases"}}} m := loaded(t, doc, 120, 40) for i, n := range m.visible { if n.Kind == KindCheck && n.Text == "databases" { m.cursor = i break } } final, _ := enterKey(m) view := plain(final.modal.View(110, 30)) // the title is the label; the body must not open by repeating it or the // row's headline if strings.Contains(view, "! databases") { t.Errorf("the label is repeated inside its own overlay:\n%s", view) } if strings.Contains(view, "2d old · refresh failed") { t.Errorf("the row's headline is repeated in the body:\n%s", view) } if !strings.Contains(view, "pacman could not refresh") { t.Errorf("the explanation is missing:\n%s", view) } } // -- the body must fit its window ----------------------------------------- func tallQueue(n int) []plan.Stage { stages := make([]plan.Stage, 0, n) for i := 0; i < n; i++ { stages = append(stages, plan.Stage{ Phase: fmt.Sprintf("phase%d", i/4), Command: fmt.Sprintf("command number %d --flag", i), What: "a description that takes a second line", Level: "step", }) } return stages } func TestTheQueueBodyFitsTheWindow(t *testing.T) { // Every row is two lines tall, so scrolling by rows showed about twice the // window and the box overflowed: the top of the queue was clipped away // wherever the cursor was. q := newQueue("q", tallQueue(60)) for _, h := range []int{12, 20, 30, 45} { view := q.View(100, h) if lines := strings.Count(plain(view), "\n") + 1; lines != h { t.Errorf("height %d produced a %d-line box", h, lines) } } } func TestTheTopOfTheQueueIsVisibleWhenTheCursorIsAtTheTop(t *testing.T) { q := newQueue("q", tallQueue(60)) q.cursor = 0 q.scroll = 0 q.scrollToCursor() view := plain(q.View(100, 30)) if !strings.Contains(view, "command number 0 ") { t.Errorf("the first command is not on screen:\n%s", view) } } func TestTheLastRowCanBeReached(t *testing.T) { q := newQueue("q", tallQueue(60)) q.cursor = len(q.rows) - 1 q.scrollToCursor() view := plain(q.View(100, 30)) if !strings.Contains(view, "command number 59 ") { t.Errorf("the last command cannot be reached:\n%s", view) } } func TestScrollingNeverLeavesBlankSpaceAtTheEnd(t *testing.T) { q := newQueue("q", tallQueue(8)) q.cursor = len(q.rows) - 1 view := plain(q.View(100, 30)) if !strings.Contains(view, "command number 7 ") { t.Errorf("the tail of a short queue is not shown:\n%s", view) } } func TestTheWarningsBodyFitsTheWindow(t *testing.T) { var lines []string for i := 0; i < 80; i++ { lines = append(lines, fmt.Sprintf("warning line %d", i)) } m := newTabs("warnings", []tabSection{ {title: "one", lines: lines, flagged: 3}, {title: "two", lines: lines}, }) for _, h := range []int{16, 24, 34, 50} { view := m.View(100, h) if got := strings.Count(plain(view), "\n") + 1; got != h { t.Errorf("height %d produced a %d-line box", h, got) } } } // -- solo selection ------------------------------------------------------- func TestSoloHoldsEverythingElseBack(t *testing.T) { m := loaded(t, sample(), 120, 40) m = selectKind(m, "demo-bin") picked, _ := press(m, 's') if !picked.solo["demo-bin"] { t.Fatal("s did not pick the package") } // every other package must now be excluded from the run for _, r := range picked.roots { for _, n := range r.Children { if n.Kind != KindPackage || n.Name == "demo-bin" { continue } if picked.willApply(n.Name) { t.Errorf("%s still applies despite the solo", n.Name) } } } if !picked.willApply("demo-bin") { t.Error("the picked package does not apply") } } func TestSoloIsTheInverseOfSkipping(t *testing.T) { // picking one of several is the same as skipping all the others m := loaded(t, sample(), 120, 40) m = selectKind(m, "demo-bin") picked, _ := press(m, 's') other := loaded(t, sample(), 120, 40) for _, r := range other.roots { for _, n := range r.Children { if n.Kind == KindPackage && n.Name != "demo-bin" { other.skipped[n.Name] = true } } } other.rebuildTree() a, _, _ := picked.planLines() b, _, _ := other.planLines() if strings.Join(a, "|") != strings.Join(b, "|") { t.Errorf("solo and skip disagree:\n solo: %v\n skip: %v", a, b) } } func TestSoloScopesTheSecuritySection(t *testing.T) { doc := sample() doc.Advisories = []plan.Advisory{ {ID: "AVG-1", Severity: "High", Packages: []string{"demo-bin"}}, {ID: "AVG-2", Severity: "High", Packages: []string{"other-pkg"}}, } m := loaded(t, doc, 120, 40) if !strings.Contains(plain(m.renderBody()), "AVG-2") { t.Fatal("both advisories should show before any solo") } m = selectKind(m, "demo-bin") picked, _ := press(m, 's') body := plain(picked.renderBody()) if !strings.Contains(body, "AVG-1") { t.Error("the advisory for the picked package disappeared") } if strings.Contains(body, "AVG-2") { t.Error("an advisory for a package that will not be applied is still listed") } } func TestSoloWithNothingInScopeSaysSo(t *testing.T) { doc := sample() doc.Advisories = []plan.Advisory{ {ID: "AVG-9", Severity: "High", Packages: []string{"unrelated"}}, } m := loaded(t, doc, 120, 40) m = selectKind(m, "demo-bin") picked, _ := press(m, 's') body := plain(picked.renderBody()) if !strings.Contains(body, "nothing affects the packages selected") { t.Errorf("the empty security section is not explained:\n%s", body) } } func TestSoloIsClearedByShiftS(t *testing.T) { m := loaded(t, sample(), 120, 40) m = selectKind(m, "demo-bin") picked, _ := press(m, 's') cleared, _ := press(picked, 'S') if len(cleared.solo) != 0 { t.Errorf("solo survived: %v", cleared.solo) } if !cleared.willApply("other-pkg") && len(cleared.doc.Aur) > 1 { t.Error("clearing solo did not restore the other packages") } } func TestSoloOnANonPackageSaysSo(t *testing.T) { m := loaded(t, sample(), 120, 40) m.cursor = 0 // a section heading next, _ := press(m, 's') if len(next.solo) != 0 { t.Error("a section was treated as a package") } if !strings.Contains(next.status, "cursor") { t.Errorf("status = %q", next.status) } } func TestTheHeaderSaysHowMuchIsSelected(t *testing.T) { m := loaded(t, sample(), 120, 40) m = selectKind(m, "demo-bin") picked, _ := press(m, 's') line := plain(picked.statusLine()) if !strings.Contains(line, "solo") { t.Errorf("the header does not say solo is on: %q", line) } } func TestSoloIsExpressedAsHoldsForTheApply(t *testing.T) { // "apply only these" and "hold everything else" are the same instruction, // and the backend already understands the second m := loaded(t, sample(), 120, 40) m = selectKind(m, "demo-bin") picked, _ := press(m, 's') var holds []string for _, r := range picked.roots { for _, n := range r.Children { if n.Kind == KindPackage && n.Name != "demo-bin" { holds = append(holds, n.Name) } } } if len(holds) == 0 { t.Skip("the sample has only one package") } // the plan must name the ignore list, proving the holds reach the command commands, _, _ := picked.planLines() joined := strings.Join(commands, " ") if !strings.Contains(joined, "--ignore=") { t.Errorf("the holds do not reach the command: %v", commands) } } func TestSoloingEveryPackageIsTheSameAsSoloingNone(t *testing.T) { m := loaded(t, sample(), 120, 40) for _, r := range m.roots { for _, n := range r.Children { if n.Kind == KindPackage { m.solo[n.Name] = true } } } all := loaded(t, sample(), 120, 40) a, _, _ := m.planLines() b, _, _ := all.planLines() if strings.Join(a, "|") != strings.Join(b, "|") { t.Errorf("soloing everything differs from soloing nothing:\n %v\n %v", a, b) } } // -- sudo must be satisfied before the phases run ------------------------- func TestAnApplyThatNeedsRootAuthenticatesFirst(t *testing.T) { // The phases run with their input closed, so sudo cannot prompt. Without a // pre-authentication step the run exited 1 in under a second having done // nothing, and the reason sat in a log file nobody was shown. m := loaded(t, sample(), 120, 40) asked, cmd := m.confirmApply() if cmd != nil { t.Fatal("confirming should not start work on its own") } final := asked.(Model) next, cmd := final.confirmed() final = next.(Model) if !final.pendingApply { t.Fatal("the apply did not stop to authenticate") } if cmd == nil { t.Fatal("no sudo command was produced") } if final.build != nil { t.Error("the apply started before authenticating") } } func TestASuccessfulAuthenticationStartsTheApply(t *testing.T) { m := loaded(t, sample(), 120, 40) asked, _ := m.confirmApply() waiting := asked.(Model) // the confirmation closes itself before its answer arrives, so by the time // sudo reports back there is no modal owning the keyboard waiting.modal = nil waiting.pendingApply = true next, _ := waiting.Update(sudoMsg{err: nil}) final := next.(Model) if !final.sudoReady { t.Error("the credentials were not recorded as satisfied") } if final.pendingApply { t.Error("the pending flag was not cleared") } } func TestAFailedAuthenticationExplainsItselfAndRunsNothing(t *testing.T) { m := loaded(t, sample(), 120, 40) m.pendingApply = true m.modal = nil next, _ := m.Update(sudoMsg{err: fmt.Errorf("exit status 1")}) final := next.(Model) if final.build != nil { t.Error("the apply ran despite failing to authenticate") } if final.modal == nil { t.Fatal("no explanation was shown") } if !strings.Contains(plain(final.modal.View(110, 30)), "sudo -v") { t.Error("the message does not say what to do about it") } } func TestAFlatpakOnlyRunDoesNotDemandRoot(t *testing.T) { // a user installation needs no root, and requiring it would block a run // that would have worked doc := sample() doc.Repo = nil doc.Aur = nil doc.Vcs = nil doc.Flatpak = []plan.Flatpak{{Application: "org.example.App", Version: "1"}} m := loaded(t, doc, 120, 40) if m.needsRoot() { t.Error("a flatpak-only run asked for root") } } func TestRootIsNeededWhenOnlyAnAurPackageIsPicked(t *testing.T) { m := loaded(t, sample(), 120, 40) m = selectKind(m, "demo-bin") picked, _ := press(m, 's') if !picked.needsRoot() { t.Error("an AUR install needs root: paru runs pacman -U") } } // -- a finding's evidence belongs next to the finding --------------------- func findingLine(t *testing.T, width int, evidence string) string { t.Helper() n := &Node{ Kind: KindFinding, Glyph: "!", GlyphRole: "warn", Text: "fetches from the network while building", Evidence: evidence, } line := n.render(NewColumns(width)) if got := ansi.StringWidth(line); got > width { t.Errorf("the line is %d cells wide, the terminal is %d", got, width) } return ansi.Strip(line) } func TestFindingEvidenceIsNotFlungToTheFarEdge(t *testing.T) { // It used to be spread across the full width, so on a wide terminal the // evidence sat a hand's width away from the warning it belonged to. evidence := `(curl --silent --fail "${url_base}/sha256sums-signed-build.txt" || \` line := findingLine(t, 200, evidence) at := strings.Index(line, "(curl") if at < 0 { t.Fatalf("the evidence is missing entirely:\n%s", line) } gap := at - len(" ! fetches from the network while building") if gap > 4 { t.Errorf("there is a %d-column gap before the evidence:\n%s", gap, line) } } func TestFindingEvidenceIsDroppedWhenThereIsNoRoom(t *testing.T) { evidence := `(curl --silent --fail "${url_base}/sha256sums-signed-build.txt" || \` line := findingLine(t, 60, evidence) if strings.Contains(line, "curl") && !strings.Contains(line, "…") { t.Errorf("a squeezed line should truncate, not overflow:\n%s", line) } } func TestAFindingWithNoEvidenceIsJustTheText(t *testing.T) { line := findingLine(t, 120, "") if strings.Contains(line, " ") && strings.HasSuffix(line, " ") { t.Errorf("trailing padding with nothing to pad to:\n%q", line) } } func TestARefreshedCheckSetDropsWhatIsGone(t *testing.T) { // `updater checks --json` reports every machine-local check, so what it // returns is the whole truth. Folding the new set in left a resolved check // on screen forever, with its stale glyph and a "run the fix" action. doc := sample() doc.Checks = []plan.Check{ {Label: "pacnew files", OK: false, Detail: "3 to review"}, {Label: "orphaned packages", OK: false, Detail: "12"}, } m := loaded(t, doc, 100, 30) m.mergeChecks([]plan.Check{{Label: "orphaned packages", OK: true}}, []string{"pacnew files", "orphaned packages"}) if len(m.doc.Checks) != 1 { t.Fatalf("checks = %+v", m.doc.Checks) } if m.doc.Checks[0].Label != "orphaned packages" || !m.doc.Checks[0].OK { t.Errorf("the surviving check is stale: %+v", m.doc.Checks[0]) } } // -- the drill-down actually receives what it asked for -------------------- func TestAPayloadReachesTheModalThatAskedForIt(t *testing.T) { // Non-key messages are handled by the model before a modal sees them, // which is right for plan loading and wrong for a fetch: the drill-down // opened the modal and then its payload was discarded, so it sat on // "loading…" for good. doc := sample() m := loaded(t, doc, 100, 30) for i, n := range m.visible { if n.Kind == KindPackage { m.cursor = i break } } pkg := m.visible[m.cursor].Name updated, _ := m.drillDown() m = updated.(Model) if m.modal == nil { t.Fatal("drill-down did not open a modal") } next, _ := m.Update(detailMsg{pkg: pkg, detail: &plan.Detail{ Package: pkg, Installed: "1-1", Available: "2-1"}}) view := plain(next.(Model).modal.View(90, 26)) if strings.Contains(view, "loading…") { t.Errorf("the payload never arrived:\n%s", view) } } func TestTextBodyReachesTheModal(t *testing.T) { doc := sample() m := loaded(t, doc, 100, 30) m.modal = newText("advisory") next, _ := m.Update(textBodyMsg{title: "advisory", lines: []string{"the record"}}) view := plain(next.(Model).modal.View(90, 26)) if !strings.Contains(view, "the record") { t.Errorf("the body never arrived:\n%s", view) } } // -- undo restores rather than toggles ------------------------------------- func TestUndoPutsBackASkipThatWasThenUnskipped(t *testing.T) { // The map key is present after either toggle, so undo has to be told the // prior state; inferring it left "skip then unskip" unable to restore. doc := sample() m := loaded(t, doc, 100, 30) for i, n := range m.visible { if n.Kind == KindPackage && n.Section == "AUR" { m.cursor = i break } } name := m.visible[m.cursor].Name updated, _ := m.toggleSkip() // skip m = updated.(Model) if !m.skipped[name] { t.Fatalf("%s was not skipped", name) } updated, _ = m.toggleSkip() // unskip m = updated.(Model) if m.skipped[name] { t.Fatalf("%s was not unskipped", name) } updated, _ = m.undoLast() // should put the skip back m = updated.(Model) if !m.skipped[name] { t.Errorf("undo did not restore the skip; skipped=%v", m.skipped[name]) } } // -- the abort key does not arm a second reader ---------------------------- func TestAbortDoesNotArmASecondReader(t *testing.T) { // Two readers on one channel run closeStream twice; the second Wait fails // and overwrites a clean exit 0 with "exited 1". events := make(chan plan.Event, 4) b := NewBuildView([]string{"aur"}, 2) b.Apply(plan.Event{Event: "start", Phases: []string{"aur"}, Total: 2}) m := Model{build: b, stream: &plan.Stream{Abort: func() {}}, width: 100, height: 30, cfg: testConfig(t)} next, cmd := m.Update(keyRunes("ctrl+c")) m = next.(Model) if !m.abortArmed { t.Fatal("the first press did not arm") } _ = events if cmd != nil { t.Error("the first press armed a reader") } next, cmd = m.Update(keyRunes("ctrl+c")) if cmd != nil { t.Error("the abort press armed a second reader on the same stream") } } func keyRunes(s string) tea.KeyMsg { if s == "ctrl+c" { return tea.KeyMsg{Type: tea.KeyCtrlC} } return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} } // fakeBackend points the model at a stand-in interpreter that answers with a // plan of the shape a real one would, so the preview path is exercised whole. func fakeBackend(t *testing.T, planJSON string) plan.Backend { t.Helper() dir := t.TempDir() script := filepath.Join(dir, "python3") if err := os.WriteFile(script, []byte("#!/bin/sh\ncat <<'JSON'\n"+ planJSON+"\nJSON\n"), 0o755); err != nil { t.Fatalf("could not write the stand-in: %v", err) } return plan.Backend{Python: script, Module: "updater"} } func TestAPreviewWithNothingToShowIsNotAFailure(t *testing.T) { // An up-to-date system has no command queue. "There is nothing here" is an // answer to "show me the queue", not an error to exit on -- but it is still // an error value, so a caller that cannot tell the difference exits 1. m := New(fakeBackend(t, `{"repo":[],"aur":[],"vcs":[],"checks":[],"steps":[]}`), testConfig(t)) _, err := m.RenderOnce(100, 30, "plan", "") if err == nil { t.Fatal("nothing to show was reported as success") } if !errors.Is(err, ErrNothingToShow) { t.Errorf("not distinguishable from a real failure: %v", err) } if !strings.Contains(err.Error(), "nothing to queue") { t.Errorf("the message does not explain itself: %v", err) } } func TestARealFailureIsNotMistakenForNothingToShow(t *testing.T) { // The sentinel must not swallow a genuine problem, or the preview would // exit 0 on a broken checkout. m := New(fakeBackend(t, `{"repo":[],"aur":[],"vcs":[],"checks":[],"steps":[]}`), testConfig(t)) _, err := m.RenderOnce(100, 30, "no-such-overlay", "") if err == nil { t.Fatal("an unknown overlay was reported as success") } if errors.Is(err, ErrNothingToShow) { t.Errorf("a real failure reads as an empty preview: %v", err) } } func TestTheLoadingScreenDoesNotClaimToKnowWhatIsHappening(t *testing.T) { // It used to list the phases it expected to run as static dim lines that // never changed, so they read as progress while measuring nothing -- and // the plan has not been read yet, so it genuinely does not know. m := Model{loading: true, loadingAt: time.Now().Add(-3 * time.Second), width: 70, height: 20, cfg: testConfig(t)} view := plain(m.loadingView()) if !strings.Contains(view, "loading") { t.Errorf("it does not say what it is doing:\n%s", view) } for _, claim := range []string{ "reading the package database", "refreshing repository databases", "auditing AUR PKGBUILDs", "checking advisories", } { if strings.Contains(view, claim) { t.Errorf("it still claims %q:\n%s", claim, view) } } if !strings.Contains(view, "3s") { t.Errorf("it does not say how long it has been:\n%s", view) } } func TestTheLoadingScreenAnimates(t *testing.T) { // One thing moves now, and it must actually move: six frames that render // the same are a still image, not a loading screen. m := Model{loading: true, loadingAt: time.Now(), width: 70, height: 20, cfg: testConfig(t)} seen := map[string]bool{} for spin := 0; spin < 6; spin++ { m.spin = spin seen[plain(m.loadingView())] = true } if len(seen) < 4 { t.Errorf("six frames produced only %d distinct screens", len(seen)) } } func TestTheLoadingScreenFitsAnyTerminal(t *testing.T) { // It must fit rather than be clipped. `place` keeps the centre of an // over-tall box, and on a bordered box that means losing the border, which // reads as a rendering fault rather than as a small terminal. m := Model{loading: true, loadingAt: time.Now(), cfg: testConfig(t)} for height := 8; height <= 60; height += 2 { for _, width := range []int{40, 50, 60, 80, 120, 200} { m.width, m.height = width, height for spin := 0; spin < 4; spin++ { m.spin = spin view := m.loadingView() lines := strings.Split(view, "\n") if len(lines) > height { t.Errorf("%dx%d: %d lines", width, height, len(lines)) } for _, line := range lines { if w := ansi.StringWidth(line); w > width { t.Errorf("%dx%d: a line is %d cells", width, height, w) } } plain := plain(view) if !strings.Contains(plain, "╭") || !strings.Contains(plain, "╰") { t.Errorf("%dx%d: the box lost its border:\n%s", width, height, plain) } } } } }