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)
}
}
}
}
}