package ui
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"strings"
"testing"
"time"
"updater-tui/internal/plan"
)
func source() *plan.Detail { return nil }
func TestBuildViewFollowsTheEventStream(t *testing.T) {
b := NewBuildView([]string{"repo", "aur"}, 10)
b.Apply(plan.Event{Event: "start", Phases: []string{"repo", "aur"}, Total: 10})
b.Apply(plan.Event{Event: "phase_start", Phase: "repo", Command: "pacman -Syu", Total: 46})
b.Apply(plan.Event{Event: "build", Done: 2,
Packages: []plan.BuildState{{Name: "mesa", Phase: "install", Elapsed: 3}},
DownloadBytes: 1024, DownloadRate: 512})
b.Apply(plan.Event{Event: "finished", Name: "mesa", Version: "1-1", Elapsed: 4.2, State: "done"})
if b.Phase != "repo" || b.Command != "pacman -Syu" {
t.Errorf("phase = %q, command = %q", b.Phase, b.Command)
}
// The run total from `start` stands. The phase's own count is smaller, and
// taking it left the bar reading full while a phase was still to come.
if b.Total != 10 {
t.Errorf("total = %d, want the run total 10", b.Total)
}
if len(b.Running) != 1 || b.Running[0].Name != "mesa" {
t.Errorf("running = %+v", b.Running)
}
if len(b.Feed) != 1 || b.Feed[0].name != "mesa" {
t.Errorf("feed = %+v", b.Feed)
}
if b.Downloads != 1024 {
t.Errorf("downloads = %d", b.Downloads)
}
}
func TestTheFeedKeepsArrivalOrder(t *testing.T) {
// newest at the bottom is what makes it readable as a record
b := NewBuildView(nil, 3)
for _, name := range []string{"first", "second", "third"} {
b.Apply(plan.Event{Event: "finished", Name: name, Elapsed: 1})
}
got := []string{b.Feed[0].name, b.Feed[1].name, b.Feed[2].name}
want := []string{"first", "second", "third"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("feed order = %v, want %v", got, want)
}
}
}
func TestAFailedPackageIsMarkedNotCountedAsDone(t *testing.T) {
b := NewBuildView(nil, 2)
b.Apply(plan.Event{Event: "finished", Name: "good", Elapsed: 1})
b.Apply(plan.Event{Event: "failed", Name: "bad", Elapsed: 2})
b.Apply(plan.Event{Event: "build", Done: 1, Total: 2})
view := plain(b.View(100, false, 0))
if !strings.Contains(view, "✗ 1 failed") {
t.Errorf("footer does not report the failure:\n%s", view)
}
if !strings.Contains(view, "✓ 1 done") {
t.Errorf("footer miscounts done:\n%s", view)
}
}
func TestHidingTheDashboardLeavesOnlyTheFeed(t *testing.T) {
b := NewBuildView([]string{"aur"}, 2)
b.Apply(plan.Event{Event: "finished", Name: "pkgA", Version: "1-1", Elapsed: 5})
full := plain(b.View(100, false, 0))
b.Dashboard = false
feed := plain(b.View(100, false, 0))
if len(feed) >= len(full) {
t.Errorf("the feed-only view is not shorter: %d vs %d", len(feed), len(full))
}
if !strings.Contains(feed, "pkgA") {
t.Errorf("the feed lost its content:\n%s", feed)
}
if strings.Contains(feed, "BUILDS") {
t.Errorf("the footer survived hiding the dashboard:\n%s", feed)
}
}
func TestTheBuildViewNeverExceedsTheWidth(t *testing.T) {
b := NewBuildView([]string{"repo", "aur"}, 3)
b.Apply(plan.Event{Event: "phase_start", Phase: "aur",
Command: "paru -S --needed --noconfirm a-very-long-package-name-here"})
b.Apply(plan.Event{Event: "finished", Name: strings.Repeat("x", 120), Elapsed: 3})
b.Apply(plan.Event{Event: "build", Packages: []plan.BuildState{
{Name: strings.Repeat("y", 90), Phase: "cargo", Elapsed: 1, Jobs: 4}}, Done: 1})
for _, width := range []int{60, 80, 100, 140} {
for _, dashboard := range []bool{true, false} {
b.Dashboard = dashboard
for i, line := range strings.Split(b.View(width, false, 0), "\n") {
if got := len([]rune(stripANSI(line))); got > width {
t.Errorf("width %d dashboard=%v line %d is %d cells: %q",
width, dashboard, i, got, stripANSI(line))
}
}
}
}
}
func TestProtocolErrorsAreSurfacedNotSwallowed(t *testing.T) {
b := NewBuildView(nil, 1)
b.AddProtocolError(errString("backend sent a line that is not JSON"))
view := plain(b.View(100, false, 0))
if !strings.Contains(view, "not JSON") {
t.Errorf("a protocol error was hidden:\n%s", view)
}
}
type errString string
func (e errString) Error() string { return string(e) }
func TestExitCodeReadsTheProcessStatus(t *testing.T) {
if got := exitCode(nil); got != 0 {
t.Errorf("nil error should be 0, got %d", got)
}
if got := exitCode(errString("boom")); got != 1 {
t.Errorf("a generic error should be 1, got %d", got)
}
}
func TestARecoverableFailureOffersAWayToFinishIt(t *testing.T) {
// A conflict is a question, not a fault: pacman has to be told whether to
// replace a package, and --noconfirm cannot answer. Leaving the reader at
// a dead end with an exit code is not an option.
b := NewBuildView([]string{"aur"}, 0)
b.Apply(plan.Event{Event: "error", Message: "conflicting packages (scenefx)"})
b.Apply(plan.Event{Event: "recovery", RecoveryLabel: "finish in a terminal",
RecoveryCommand: []string{"paru", "-S", "--needed", "mangowm-git"},
RecoveryNote: "prompts work as usual"})
b.Apply(plan.Event{Event: "done", Returncode: 1})
if b.Recovery == nil {
t.Fatal("the recovery was dropped")
}
view := plain(b.View(110, false, 0))
if !strings.Contains(view, "finish in a terminal") {
t.Errorf("the way out is not offered:\n%s", view)
}
if !strings.Contains(view, "prompts work as usual") {
t.Errorf("what it will do is not explained:\n%s", view)
}
}
func TestASuccessfulRunOffersNoRecovery(t *testing.T) {
b := NewBuildView([]string{"aur"}, 0)
b.Apply(plan.Event{Event: "done", Returncode: 0})
if b.Recovery != nil {
t.Error("a clean run invented a recovery")
}
if strings.Contains(plain(b.View(110, false, 0)), "finish in a terminal") {
t.Error("a clean run offered a terminal fallback")
}
}
func TestAFailedPackageIsCountedAsFailed(t *testing.T) {
// The watcher sees a makepkg exit and files the package as done, because
// it cannot read an exit code. paru's result is the only thing that knows,
// so the interface must be told -- otherwise the failure count sits at
// zero while the log says otherwise.
b := NewBuildView([]string{"aur"}, 3)
b.Apply(plan.Event{Event: "start", Phases: []string{"aur"}, Total: 3})
b.Apply(plan.Event{Event: "finished", Name: "a", State: "done", Elapsed: 6})
b.Apply(plan.Event{Event: "finished", Name: "b", State: "done", Elapsed: 2})
b.Apply(plan.Event{Event: "failed", Name: "b", Elapsed: 2})
done, failed := 0, 0
for _, entry := range b.Feed {
if entry.state == "fail" {
failed++
} else {
done++
}
}
if failed != 1 {
t.Errorf("failed = %d, want 1", failed)
}
if done != 1 {
t.Errorf("done = %d, want 1", done)
}
if len(b.Feed) != 2 {
t.Errorf("the package was counted twice: %d entries", len(b.Feed))
}
view := plain(b.View(110, false, 0))
if !strings.Contains(view, "1 failed") {
t.Errorf("the dashboard does not report the failure:\n%s", view)
}
}
func TestAFailureForAnUnseenPackageStillCounts(t *testing.T) {
b := NewBuildView([]string{"aur"}, 2)
b.Apply(plan.Event{Event: "failed", Name: "ghost", Elapsed: 1})
if len(b.Feed) != 1 || b.Feed[0].state != "fail" {
t.Errorf("feed = %+v", b.Feed)
}
}
func TestAFailedPackageLeavesTheRunningSet(t *testing.T) {
b := NewBuildView([]string{"aur"}, 2)
b.Apply(plan.Event{Event: "build", Packages: []plan.BuildState{
{Name: "a", Phase: "cargo", Elapsed: 3},
}})
if len(b.Running) != 1 {
t.Fatalf("running = %+v", b.Running)
}
b.Apply(plan.Event{Event: "failed", Name: "a", Elapsed: 3})
if len(b.Running) != 0 {
t.Errorf("a failed package is still shown as building: %+v", b.Running)
}
}
func TestTheClockKeepsTickingBetweenPackages(t *testing.T) {
// The backend reports an elapsed figure only when a phase ends, so a clock
// that trusts it alone freezes: the display read 19s while a package had
// been building for nearly two minutes.
b := NewBuildView([]string{"aur"}, 3)
b.Started = time.Now().Add(-90 * time.Second)
b.Elapsed = 19 // stale, from an earlier phase
if got := b.clock(); got < 89 {
t.Errorf("clock = %.0fs, want the wall time (~90s)", got)
}
}
func TestTheClockNeverGoesBackwardsOnAResumedRun(t *testing.T) {
b := NewBuildView([]string{"aur"}, 3)
b.Started = time.Now() // the interface attached late
b.Elapsed = 600 // the run has really been going ten minutes
if got := b.clock(); got < 599 {
t.Errorf("clock = %.0fs, want the backend's figure", got)
}
}
// -- messages can be read and got rid of ----------------------------------
func TestWarningsCanBeWalkedAndDismissed(t *testing.T) {
// They sit on the same lines as the progress they cover, and a warning
// that cannot be got rid of is one people learn to read past.
b := NewBuildView([]string{"aur"}, 3)
b.Apply(plan.Event{Event: "error", Message: "6 package(s) left the AUR",
Details: []string{"grrs-git", "librewolf-bin"}})
b.Apply(plan.Event{Event: "error", Message: "7 are not cloned yet",
Details: []string{"aconfmgr-git"}})
if len(b.Messages) != 2 {
t.Fatalf("messages = %+v", b.Messages)
}
view := plain(b.View(110, true, 0))
if !strings.Contains(view, "6 package(s) left the AUR") {
t.Errorf("the warning is not shown:\n%s", view)
}
// the cursor follows the newest; walking up shows the older one's detail
b.MoveCursor(-1)
if got := plain(b.View(110, true, 0)); !strings.Contains(got, "grrs-git") {
t.Errorf("the selected warning has no detail:\n%s", got)
}
b.MoveCursor(1) // back to the newest, where the dismissal checks expect it
// the cursor starts on the first; moving to the second and dismissing
// removes that one and leaves the first
b.MoveCursor(1)
b.Dismiss()
if len(b.Messages) != 1 || b.Messages[0].Text != "6 package(s) left the AUR" {
t.Errorf("dismissed the wrong one: %+v", b.Messages)
}
b.Dismiss()
if len(b.Messages) != 0 {
t.Errorf("messages survived: %+v", b.Messages)
}
// dismissing everything must not leave the cursor out of range
b.Dismiss()
b.MoveCursor(-1)
if b.Cursor != 0 {
t.Errorf("cursor = %d", b.Cursor)
}
}
func TestTheCursorStaysInsideTheList(t *testing.T) {
b := NewBuildView([]string{"aur"}, 1)
b.Apply(plan.Event{Event: "error", Message: "only one"})
for i := 0; i < 5; i++ {
b.MoveCursor(1)
}
if b.Cursor != 0 {
t.Errorf("cursor ran past the end: %d", b.Cursor)
}
for i := 0; i < 5; i++ {
b.MoveCursor(-1)
}
if b.Cursor != 0 {
t.Errorf("cursor went negative: %d", b.Cursor)
}
}
// -- the output panel -----------------------------------------------------
func TestTheOutputPanelShowsWhatIsRunning(t *testing.T) {
b := NewBuildView([]string{"aur"}, 2)
b.Apply(plan.Event{Event: "build", LogLines: []string{
"==> Making package: helix-git", " -> Compiling helix-term v25.1",
}})
if strings.Contains(plain(b.View(110, true, 0)), "Compiling helix-term") {
t.Error("the output shows while the panel is closed")
}
b.LogOpen = true
view := plain(b.View(110, true, 0))
for _, want := range []string{"OUTPUT", "Making package", "Compiling helix-term"} {
if !strings.Contains(view, want) {
t.Errorf("the output panel is missing %q:\n%s", want, view)
}
}
}
func TestAnEmptyOutputPanelSaysSoRatherThanLookingBroken(t *testing.T) {
b := NewBuildView([]string{"aur"}, 1)
b.LogOpen = true
if !strings.Contains(plain(b.View(110, true, 0)), "nothing yet") {
t.Error("an empty panel gives no explanation")
}
}
// -- the display moves ----------------------------------------------------
func TestTheIndicatorMovesBetweenFrames(t *testing.T) {
// A build that takes minutes and looks identical every second cannot be
// told apart from one that has hung.
b := NewBuildView([]string{"aur"}, 2)
b.Apply(plan.Event{Event: "build", Packages: []plan.BuildState{
{Name: "helix-git", Phase: "cargo"},
}})
first := plain(b.View(110, true, 0))
b.Apply(plan.Event{Event: "build", Packages: []plan.BuildState{
{Name: "helix-git", Phase: "cargo"},
}})
second := plain(b.View(110, true, 0))
if first == second {
t.Error("nothing changed between frames, so a stalled build looks alive")
}
}
// -- the dashboard has to fit the terminal --------------------------------
func busyBuild() *BuildView {
b := NewBuildView([]string{"aur"}, 27)
b.Apply(plan.Event{Event: "start", Phases: []string{"aur"}, Total: 27})
for i := 0; i < 20; i++ {
b.Apply(plan.Event{Event: "finished",
Name: fmt.Sprintf("pkg-%02d", i), State: "done", Elapsed: 3})
}
b.Apply(plan.Event{Event: "build", Packages: []plan.BuildState{
{Name: "helix-git", Phase: "cargo", Elapsed: 40},
}, LogLines: []string{"line a", "line b"}})
for i := 0; i < 6; i++ {
b.Apply(plan.Event{Event: "error",
Message: fmt.Sprintf("warning number %d with a long explanation", i),
Details: []string{"a detail line"}})
}
return b
}
func TestTheControlsStayOnScreenHoweverLongTheRun(t *testing.T) {
// The footer and the key hints are the last lines. Without a budget a long
// feed pushed them off the bottom and the controls vanished mid-run --
// exactly when someone wants to press ctrl+c.
b := busyBuild()
b.LogOpen = true
for _, height := range []int{18, 24, 30, 45} {
view := plain(b.View(110, false, height))
lines := strings.Split(view, "\n")
if len(lines) > height {
t.Errorf("height %d produced %d lines", height, len(lines))
}
for _, want := range []string{"✗", "waiting", "ctrl+c"} {
if !strings.Contains(view, want) {
t.Errorf("height %d lost %q:\n%s", height, want, view)
}
}
}
}
func TestOlderFeedEntriesScrollAwayRatherThanTheControls(t *testing.T) {
b := busyBuild()
view := plain(b.View(110, false, 24))
if !strings.Contains(view, "pkg-19") {
t.Errorf("the newest entry is not shown:\n%s", view)
}
if strings.Contains(view, "pkg-00") {
t.Errorf("an ancient entry is still taking space:\n%s", view)
}
if !strings.Contains(view, "earlier") {
t.Errorf("nothing says entries were dropped:\n%s", view)
}
}
func TestAnUnbudgetedViewStillShowsEverything(t *testing.T) {
// callers that do not know the height (tests, one-shot renders) keep the
// old behaviour rather than being silently truncated
b := busyBuild()
view := plain(b.View(110, false, 0))
if !strings.Contains(view, "pkg-00") {
t.Error("a zero height truncated the view")
}
}
// -- the running line says what the jobs are ------------------------------
func TestTheRunningLineSaysWhatTheJobsAre(t *testing.T) {
// "5 jobs" says something is happening; "rustc ×4, cc1" says what, which
// is the only thing that tells a compiling build from a wedged one.
b := NewBuildView([]string{"aur"}, 2)
b.Apply(plan.Event{Event: "build", Packages: []plan.BuildState{
{Name: "flow-control-git", Phase: "make", Jobs: 5,
Workers: "rustc ×4, cc1"},
}})
view := plain(b.View(110, true, 0))
if !strings.Contains(view, "rustc ×4, cc1") {
t.Errorf("the workers are not named:\n%s", view)
}
if strings.Contains(view, "5 jobs") {
t.Errorf("a bare count is shown even though the workers are known:\n%s", view)
}
}
func TestABuildWithNoProcessNamesFallsBackToTheCount(t *testing.T) {
b := NewBuildView([]string{"aur"}, 2)
b.Apply(plan.Event{Event: "build", Packages: []plan.BuildState{
{Name: "x", Phase: "make", Jobs: 5},
}})
if !strings.Contains(plain(b.View(110, true, 0)), "5 jobs") {
t.Error("the count is dropped when no names are available")
}
}
// -- warnings sit between the output and the counters ---------------------
func TestWarningsComeAfterTheOutputAndBeforeTheCounters(t *testing.T) {
b := NewBuildView([]string{"aur"}, 2)
b.LogOpen = true
b.Apply(plan.Event{Event: "build", LogLines: []string{"compiling thing"}})
b.Apply(plan.Event{Event: "error", Message: "something to say"})
view := plain(b.View(110, true, 0))
outputAt := strings.Index(view, "OUTPUT")
warnAt := strings.Index(view, "something to say")
buildsAt := strings.Index(view, "BUILDS")
if outputAt < 0 || warnAt < 0 || buildsAt < 0 {
t.Fatalf("a section is missing:\n%s", view)
}
if !(outputAt < warnAt && warnAt < buildsAt) {
t.Errorf("wrong order: OUTPUT=%d warning=%d BUILDS=%d", outputAt, warnAt, buildsAt)
}
}
// -- the selected warning looks selected ----------------------------------
func TestTheSelectedWarningLooksLikeASelectedRow(t *testing.T) {
// A different glyph was too quiet to tell apart from the warning's own
// colouring; the list uses a filled bar and so does this.
b := NewBuildView([]string{"aur"}, 2)
b.Apply(plan.Event{Event: "error", Message: "first"})
b.Apply(plan.Event{Event: "error", Message: "second"})
// the cursor follows the newest message, so that is the selected one
lines := strings.Split(b.View(60, true, 0), "\n")
var selected string
for _, line := range lines {
if strings.Contains(line, "second") {
selected = line
}
}
if selected == "" {
t.Fatal("the selected warning is not on screen")
}
if !strings.Contains(selected, Selected.Render("x")) {
// Selected carries a background; compare its escape sequence prefix
marker := strings.SplitN(Selected.Render("x"), "x", 2)[0]
if !strings.Contains(selected, marker) {
t.Errorf("the selected warning is not drawn with the selection style:\n%q",
selected)
}
}
// and it spans the width, so the bar reads as a bar
if ansi.StringWidth(selected) < 59 {
t.Errorf("the selection bar is %d cells wide, not the full row",
ansi.StringWidth(selected))
}
}
func TestTheSameMessageIsNotShownTwice(t *testing.T) {
// A signal reaches the backend and its child, each reports the interrupt,
// and the bottom of the screen became a wall of identical lines.
b := NewBuildView([]string{"aur"}, 2)
b.AddMessage("backend: interrupting…")
b.AddMessage("backend: interrupting…")
if len(b.Messages) != 1 {
t.Errorf("messages = %+v", b.Messages)
}
b.Apply(plan.Event{Event: "error", Message: "a real problem"})
b.Apply(plan.Event{Event: "error", Message: "a real problem"})
if len(b.Messages) != 2 {
t.Errorf("messages = %+v", b.Messages)
}
}
func TestTheMessageListDoesNotGrowWithoutLimit(t *testing.T) {
b := NewBuildView([]string{"aur"}, 2)
for i := 0; i < 30; i++ {
b.AddMessage(fmt.Sprintf("problem %d", i))
}
if len(b.Messages) > 8 {
t.Errorf("%d messages kept", len(b.Messages))
}
// the newest survive, and the cursor stays inside the list
if b.Messages[len(b.Messages)-1].Text != "problem 29" {
t.Errorf("the newest was dropped: %+v", b.Messages)
}
b.MoveCursor(1)
if b.Cursor >= len(b.Messages) {
t.Errorf("cursor %d outside %d messages", b.Cursor, len(b.Messages))
}
}
func TestTheSelectionFollowsTheNewestMessage(t *testing.T) {
// The bar used to sit on the oldest warning, drawing the eye to a stale
// item instead of what had just happened.
b := NewBuildView([]string{"aur"}, 2)
b.AddMessage("old news")
b.AddMessage("just happened")
if b.Messages[b.Cursor].Text != "just happened" {
t.Errorf("cursor is on %q", b.Messages[b.Cursor].Text)
}
}
func TestTheHeaderAndTheFooterAgreeOnWhatIsDone(t *testing.T) {
// They counted from different places -- the header from the backend's own
// tally, the footer from the list it was showing -- so the header read
// "1/21 packages" above a list of two finished ones.
b := NewBuildView([]string{"aur"}, 21)
b.Apply(plan.Event{Event: "start", Phases: []string{"aur"}, Total: 21})
b.Apply(plan.Event{Event: "build", Done: 1, Total: 21})
b.Apply(plan.Event{Event: "finished", Name: "python-fqdn", State: "done", Elapsed: 1})
b.Apply(plan.Event{Event: "finished", Name: "aconfmgr-git", State: "done", Elapsed: 1})
b.Apply(plan.Event{Event: "build", Packages: []plan.BuildState{{Name: "x"}},
Done: 1, Total: 21})
view := plain(b.View(110, true, 0))
// the count lives on the progress line
if !strings.Contains(view, "2/21") {
t.Errorf("the progress line does not match the list beneath it:\n%s", view)
}
if !strings.Contains(view, "✓ 2 done") {
t.Errorf("the footer disagrees:\n%s", view)
}
}
func TestTheHeaderCountsFailuresSeparately(t *testing.T) {
b := NewBuildView([]string{"aur"}, 3)
b.Apply(plan.Event{Event: "start", Phases: []string{"aur"}, Total: 3})
b.Apply(plan.Event{Event: "finished", Name: "a", State: "done"})
b.Apply(plan.Event{Event: "failed", Name: "b"})
view := plain(b.View(110, true, 0))
if !strings.Contains(view, "2/3 · 1 failed") {
t.Errorf("failures are not distinguished:\n%s", view)
}
}
// -- the hints must survive a narrow terminal -----------------------------
func TestTheBarStaysShortHoweverWideTheTerminal(t *testing.T) {
// It used to grow to nine bindings simply because there was room for
// them, which is a bar nobody reads. The rest lives behind ?.
b := busyBuild()
for _, width := range []int{80, 140, 200, 300} {
line := lastHintLine(plain(b.View(width, false, 0)))
if n := strings.Count(line, " · "); n > 6 {
t.Errorf("width %d shows %d bindings:\n%s", width, n+1, line)
}
if !strings.Contains(line, "help") {
t.Errorf("width %d lost the help key:\n%s", width, line)
}
}
}
func TestTheBarFitsTheTerminalAtEveryWidth(t *testing.T) {
// Bounded twice, because either bound alone gets it wrong: a ceiling
// without fitting pushed keys past a narrow edge, and fitting without a
// ceiling grew the bar with the screen.
b := busyBuild()
for width := 50; width <= 240; width += 10 {
line := lastHintLine(plain(b.View(width, false, 0)))
if got := ansi.StringWidth(line); got > width {
t.Errorf("width %d: the bar is %d cells:\n%s", width, got, line)
}
if !strings.Contains(line, "help") {
t.Errorf("width %d lost the help key:\n%s", width, line)
}
}
}
func TestEveryCapabilityIsReachableFromTheBarOrHelp(t *testing.T) {
// A key that is on neither is a key nobody finds.
b := busyBuild()
width := 200
bar := plain(b.View(width, false, 0))
help := plain(newRunHelp().View(width, 30))
for _, key := range []string{"output", "finished", "warnings", "abort"} {
if !strings.Contains(bar, key) && !strings.Contains(help, key) {
t.Errorf("%q is on neither the bar nor in the keymap", key)
}
}
}
func TestTheAbortKeyIsAlwaysOnTheBar(t *testing.T) {
b := busyBuild()
for _, width := range []int{40, 60, 80, 120} {
line := lastHintLine(plain(b.View(width, false, 0)))
if !strings.Contains(line, "ctrl+c") {
t.Errorf("width %d lost the abort key:\n%s", width, line)
}
}
}
// lastHintLine is the final non-empty line carrying bindings.
func lastHintLine(view string) string {
lines := strings.Split(view, "\n")
for i := len(lines) - 1; i >= 0; i-- {
if strings.Contains(lines[i], "ctrl+c") || strings.Contains(lines[i], "help") {
return lines[i]
}
}
return ""
}
func TestTheCommandIsNotRepeatedInTheHintLine(t *testing.T) {
// The hints are the only thing on that line worth reading; what is running
// is already named above it.
b := busyBuild()
b.Command = "paru -S --needed --noconfirm --nobatchinstall helix-git"
view := plain(b.View(200, false, 0))
for _, line := range strings.Split(view, "\n") {
if strings.Contains(line, "ctrl+c") && strings.Contains(line, "paru -S") {
t.Errorf("the command is repeated on the hint line:\n%s", line)
}
}
}
// -- the output can be read in full ---------------------------------------
func TestTheOutputCanBeOpenedFullScreen(t *testing.T) {
b := NewBuildView([]string{"aur"}, 2)
var lines []string
for i := 0; i < 300; i++ {
lines = append(lines, fmt.Sprintf("[%d/762] Compiling object file-%03d.cpp.o", i, i))
}
b.Apply(plan.Event{Event: "build", LogLines: lines})
if strings.Contains(plain(b.View(110, true, 20)), "Compiling object file-000") {
t.Error("the whole log is rendered inline")
}
b.OpenOutput()
view := plain(b.View(110, true, 30))
if !strings.Contains(view, "Compiling object") {
t.Fatalf("the full view is empty:\n%s", view)
}
// the modal carries its own scrolling and search
if !strings.Contains(view, "esc") {
t.Errorf("the full view has no way out:\n%s", view)
}
b.OpenOutput() // toggles back
if b.Output != nil {
t.Error("the full view did not close")
}
}
func TestTheFullOutputFollowsTheBuild(t *testing.T) {
b := NewBuildView([]string{"aur"}, 2)
b.Apply(plan.Event{Event: "build", LogLines: []string{"first"}})
b.OpenOutput()
b.Apply(plan.Event{Event: "build", LogLines: []string{"second"}})
if !strings.Contains(plain(b.View(110, true, 30)), "second") {
t.Errorf("the open view does not keep up:\n%s", plain(b.View(110, true, 30)))
}
}
func TestTheLogGrowsWithoutBound(t *testing.T) {
// The inline panel shows a window; the whole stream is kept so it can be
// read back.
b := NewBuildView([]string{"aur"}, 2)
for i := 0; i < 100; i++ {
b.Apply(plan.Event{Event: "build", LogLines: []string{fmt.Sprintf("line %d", i)}})
}
if len(b.Log) != 100 {
t.Errorf("kept %d lines, want 100", len(b.Log))
}
if len(b.tailLog()) != 8 {
t.Errorf("the inline panel shows %d lines, want 8", len(b.tailLog()))
}
}
func TestTheScrollPositionSurvivesALongLogPath(t *testing.T) {
b := NewBuildView([]string{"aur"}, 2)
b.Apply(plan.Event{Event: "phase_start", Phase: "aur",
Log: "/home/faen/.local/state/updater/runs/2026-10-01T00-20-34/logs/aur/noctalia-git.log"})
var lines []string
for i := 0; i < 300; i++ {
lines = append(lines, fmt.Sprintf("line %d", i))
}
b.Apply(plan.Event{Event: "build", LogLines: lines})
b.OpenOutput()
view := plain(b.View(100, true, 24))
if !strings.Contains(view, "/300]") {
t.Errorf("the position was pushed out by the path:\n%s", view)
}
}
func TestTheOutputOpensAtTheEndNotTheStart(t *testing.T) {
// Opening it put you at the top of a log whose interesting end was
// thousands of lines away, and new lines did not carry the view with them.
b := NewBuildView([]string{"aur"}, 2)
var lines []string
for i := 0; i < 500; i++ {
lines = append(lines, fmt.Sprintf("line %d", i))
}
b.Apply(plan.Event{Event: "build", LogLines: lines})
b.OpenOutput()
view := plain(b.View(100, true, 24))
if !strings.Contains(view, "line 499") {
t.Errorf("the newest line is not on screen:\n%s", view)
}
if strings.Contains(view, "line 0\n") {
t.Errorf("the view opened at the start:\n%s", view)
}
}
func TestTheOpenOutputFollowsNewLines(t *testing.T) {
b := NewBuildView([]string{"aur"}, 2)
for i := 0; i < 400; i++ {
b.Apply(plan.Event{Event: "build", LogLines: []string{fmt.Sprintf("old %d", i)}})
}
b.OpenOutput()
b.Apply(plan.Event{Event: "build", LogLines: []string{"the newest line"}})
if !strings.Contains(plain(b.View(100, true, 24)), "the newest line") {
t.Errorf("the view did not follow the end:\n%s", plain(b.View(100, true, 24)))
}
}
func TestScrollingUpStopsFollowingAndGResumesIt(t *testing.T) {
b := NewBuildView([]string{"aur"}, 2)
for i := 0; i < 200; i++ {
b.Apply(plan.Event{Event: "build", LogLines: []string{fmt.Sprintf("line %d", i)}})
}
b.OpenOutput()
_ = b.Output.View(100, 24) // establishes the window
b.Output.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}})
if b.Output.follow {
t.Error("scrolling up kept following")
}
b.Apply(plan.Event{Event: "build", LogLines: []string{"brand new"}})
if strings.Contains(plain(b.View(100, true, 24)), "brand new") {
t.Error("the view was pulled away from where the reader was")
}
b.Output.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}})
if !b.Output.follow {
t.Error("G did not resume following")
}
if !strings.Contains(plain(b.View(100, true, 24)), "brand new") {
t.Error("following did not catch up")
}
}
// -- the footer ------------------------------------------------------------
func TestTheProgressBarFillsAsWorkCompletes(t *testing.T) {
b := NewBuildView([]string{"aur"}, 10)
b.Apply(plan.Event{Event: "start", Phases: []string{"aur"}, Total: 10})
empty := plain(b.View(110, true, 0))
if !strings.Contains(empty, "0/10") {
t.Errorf("the count is missing:\n%s", empty)
}
for i := 0; i < 5; i++ {
b.Apply(plan.Event{Event: "finished", Name: fmt.Sprintf("p%d", i), State: "done"})
}
half := plain(b.View(110, true, 0))
if !strings.Contains(half, "5/10") {
t.Errorf("the count did not move:\n%s", half)
}
if !strings.Contains(half, "━") || !strings.Contains(half, "─") {
t.Errorf("the bar is not partly filled:\n%s", half)
}
}
func TestTheEstimateIsLinearInProgress(t *testing.T) {
// Half way in the same time again; a quarter of the way in three times as
// long. It uses everything the run has shown rather than a subset, so it
// is available from the first moments instead of waiting for several
// finished packages to average.
cases := []struct {
elapsed float64
worked int
total int
wantFrom float64
wantTo float64
}{
{elapsed: 120, worked: 50, total: 100, wantFrom: 100, wantTo: 140}, // 2min in, half done
{elapsed: 300, worked: 25, total: 100, wantFrom: 850, wantTo: 950}, // 5min in, quarter done
{elapsed: 60, worked: 90, total: 100, wantFrom: 0, wantTo: 15}, // nearly there
}
for _, c := range cases {
b := NewBuildView([]string{"aur"}, c.total)
b.Started = time.Now().Add(-time.Duration(c.elapsed) * time.Second)
b.Weight = c.total
b.Feed = nil
for i := 0; i < c.worked; i++ {
b.Feed = append(b.Feed, feedEntry{name: "p", state: "done", weight: 1})
}
got, ok := b.eta()
if !ok {
t.Errorf("no estimate at %.0fs and %d/%d", c.elapsed, c.worked, c.total)
continue
}
if got < c.wantFrom || got > c.wantTo {
t.Errorf("at %.0fs and %d/%d: estimate %.0fs, want %.0f-%.0f",
c.elapsed, c.worked, c.total, got, c.wantFrom, c.wantTo)
}
}
}
func TestNoEstimateBeforeTheRunHasShownAnything(t *testing.T) {
// The arithmetic divides by progress, so near the start it is both
// unstable and enormous.
b := NewBuildView([]string{"aur"}, 100)
b.Started = time.Now()
b.Weight = 100
if left, ok := b.eta(); ok {
t.Errorf("an estimate was offered with nothing done: %.0fs", left)
}
// one package of a hundred is two per cent, which says nothing about the
// other ninety-eight
b.Feed = append(b.Feed, feedEntry{name: "p", state: "done", weight: 1})
if left, ok := b.eta(); ok {
t.Errorf("an estimate was offered at 1%%: %.0fs", left)
}
// five per cent is where it starts meaning something
for i := 0; i < 4; i++ {
b.Feed = append(b.Feed, feedEntry{name: "p", state: "done", weight: 1})
}
if _, ok := b.eta(); !ok {
t.Error("no estimate at five per cent")
}
}
func TestDownloadsAreBlankRatherThanADashWhenUnknown(t *testing.T) {
// paru never reports what it fetches, so a permanent "—" is a line that
// will never say anything.
b := NewBuildView([]string{"aur"}, 5)
view := plain(b.View(110, true, 0))
for _, line := range strings.Split(view, "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "✗") {
if strings.Contains(line, "—") {
t.Errorf("a dash is shown where downloads would be:\n%s", line)
}
}
}
}
// -- warnings can be read in full -----------------------------------------
func TestAWarningOpensInFull(t *testing.T) {
// The dashboard gives each warning one line so the list stays readable,
// which means anything longer is cut off. This is where the rest lives.
b := NewBuildView([]string{"aur"}, 2)
long := "helix-git: failed (exit 1) — error: packages failed to build: " +
strings.Repeat("detail ", 30)
b.AddMessage(long, "the first detail", "the second detail")
modal := b.OpenMessage()
if modal == nil {
t.Fatal("the warning did not open")
}
view := plain(modal.View(100, 30))
// the title is the headline; the body carries the whole sentence
if !strings.Contains(view, "helix-git: failed (exit 1)") {
t.Errorf("the headline is missing:\n%s", view)
}
if !strings.Contains(view, "detail detail") {
t.Errorf("the body was cut short:\n%s", view)
}
for _, want := range []string{"the first detail", "the second detail"} {
if !strings.Contains(view, want) {
t.Errorf("the detail %q is missing:\n%s", want, view)
}
}
}
func TestOpeningWithNoWarningsDoesNothing(t *testing.T) {
b := NewBuildView([]string{"aur"}, 2)
if b.OpenMessage() != nil {
t.Error("a modal appeared with nothing to show")
}
}
func TestLongProseIsWrappedNotCut(t *testing.T) {
// The line renderers truncate, which is right for a log and wrong for a
// paragraph of explanation.
lines := wrapText(strings.Repeat("word ", 60), 40)
if len(lines) < 5 {
t.Fatalf("wrapped into %d lines", len(lines))
}
for _, line := range lines {
if len(line) > 40 {
t.Errorf("a line is %d wide: %q", len(line), line)
}
}
if joined := strings.Join(lines, " "); strings.Count(joined, "word") != 60 {
t.Error("wrapping lost words")
}
}
func TestMessageTitlesAreHeadlines(t *testing.T) {
cases := map[string]string{
"helix-git: failed (exit 1) — error: packages failed to build: helix-git-1": "helix-git: failed (exit 1)",
"6 package(s) left the AUR and were left alone: grrs-git, librewolf-bin": "6 package(s) left the AUR and were left alone",
"short": "short",
}
for text, want := range cases {
if got := shortTitle(text); got != want {
t.Errorf("shortTitle(%q) = %q, want %q", text, got, want)
}
}
}
// -- the log viewer colours what matters ----------------------------------
func TestTheLogViewerColoursErrorsAndWarnings(t *testing.T) {
cases := map[string]string{
"error: could not compile `foo`": "err",
"==> ERROR: A failure occurred in build().": "err",
"error: failed to download sources": "err",
"==> WARNING: Using existing $srcdir/ tree": "warn",
"warning: unused variable": "warn",
" foo.tar.xz ... Skipped": "warn",
"==> Starting build()...": "title",
" Compiling serde v1.0.2": "muted",
}
for line, want := range cases {
got := logLine(line)
var style lipgloss.Style
switch want {
case "err":
style = Err
case "warn":
style = Warn
case "title":
style = Title
default:
style = Muted
}
if !strings.Contains(got, strings.SplitN(style.Render("x"), "x", 2)[0]) {
t.Errorf("%q was not styled as %s: %q", line, want, got)
}
}
}
func TestTheLogViewerLeavesTheTextAlone(t *testing.T) {
for _, line := range []string{"error: x", "==> y", "", "plain"} {
if got := ansi.Strip(logLine(line)); got != line {
t.Errorf("%q became %q", line, got)
}
}
}
// -- the log can be copied ------------------------------------------------
func TestTheLogCanBeCopiedToTheClipboard(t *testing.T) {
// OSC 52 rather than a helper program: it travels over SSH and needs
// nothing installed.
seq := osc52("hello")
if !strings.HasPrefix(seq, "\x1b]52;c;") || !strings.HasSuffix(seq, "\a") {
t.Fatalf("not an OSC 52 sequence: %q", seq)
}
if !strings.Contains(seq, "aGVsbG8=") { // base64 of "hello"
t.Errorf("the payload is not base64: %q", seq)
}
}
func TestACopyOfSomethingHugeIsTruncatedAndSaysSo(t *testing.T) {
// A terminal will only accept so much.
m := newText("x")
m.logMode = true
var lines []string
for i := 0; i < 20000; i++ {
lines = append(lines, strings.Repeat("noise ", 20))
}
m.Update(textBodyMsg{title: "x", lines: lines})
var sink strings.Builder
restore := clipboard
clipboard = &sink
defer func() { clipboard = restore }()
cmd := m.copy()
if cmd == nil {
t.Fatal("no copy command")
}
if msg, ok := cmd().(copiedMsg); !ok || !strings.Contains(msg.what, "truncated") {
t.Errorf("a huge copy did not say it was cut: %+v", cmd())
}
if sink.Len() == 0 {
t.Error("nothing was written to the clipboard")
}
}
// -- the progress line -----------------------------------------------------
func TestTheProgressBarSpansTheLine(t *testing.T) {
b := NewBuildView([]string{"aur"}, 20)
b.Apply(plan.Event{Event: "start", Phases: []string{"aur"}, Total: 20})
for i := 0; i < 10; i++ {
b.Apply(plan.Event{Event: "finished", Name: fmt.Sprintf("p%d", i), State: "done"})
}
var bar string
for _, line := range strings.Split(b.View(100, true, 0), "\n") {
if strings.Contains(line, "━") {
bar = line
}
}
if bar == "" {
t.Fatal("no progress line")
}
// a bar in a table column could not do this
if w := ansi.StringWidth(bar); w < 90 {
t.Errorf("the bar is only %d cells wide on a 100-cell screen", w)
}
if !strings.Contains(bar, "10/20") {
t.Errorf("the bar line does not say where the run is:\n%s", bar)
}
}
func TestTheCountAppearsOnce(t *testing.T) {
// It used to be in the header, the progress column and the footer, which
// is what made the screen feel cluttered.
b := NewBuildView([]string{"aur"}, 20)
b.Apply(plan.Event{Event: "start", Phases: []string{"aur"}, Total: 20})
for i := 0; i < 7; i++ {
b.Apply(plan.Event{Event: "finished", Name: fmt.Sprintf("p%d", i), State: "done"})
}
view := plain(b.View(100, true, 0))
if n := strings.Count(view, "7/20"); n != 1 {
t.Errorf("the count appears %d times:\n%s", n, view)
}
}
func TestTheProgressLineShowsNoEstimateBeforeItCanKnowOne(t *testing.T) {
b := NewBuildView([]string{"aur"}, 20)
b.Apply(plan.Event{Event: "start", Phases: []string{"aur"}, Total: 20})
if strings.Contains(plain(b.View(100, true, 0)), "left") {
t.Error("an estimate is shown before anything has finished")
}
}
func TestABarWithNoSuccessesDoesNotReadAsProgress(t *testing.T) {
// Every failure still fills the bar; the colour has to carry that or it
// reads as work completed.
b := NewBuildView([]string{"aur"}, 4)
b.Apply(plan.Event{Event: "start", Phases: []string{"aur"}, Total: 4})
b.Apply(plan.Event{Event: "failed", Name: "a"})
b.Apply(plan.Event{Event: "failed", Name: "b"})
var bar string
for _, line := range strings.Split(b.View(100, true, 0), "\n") {
if strings.Contains(line, "━") {
bar = line
}
}
prefix := strings.SplitN(Err.Render("x"), "x", 2)[0]
if !strings.Contains(bar, prefix) {
t.Errorf("a run that has only failed is drawn as progress:\n%q", bar)
}
}
func TestOpeningAWarningDoesNotTurnItIntoTheLog(t *testing.T) {
// Both used one field, and the refresh that keeps the log current ran
// whatever was open -- so a warning opened while a build was running was
// replaced by the build's output as soon as the next line arrived.
b := NewBuildView([]string{"aur"}, 2)
b.Apply(plan.Event{Event: "build", LogLines: []string{"Compiling gix-config v0.58.0"}})
b.AddMessage("6 package(s) left the AUR and were left alone",
"grrs-git", "librewolf-bin")
modal := b.OpenMessage()
if modal == nil {
t.Fatal("the warning did not open")
}
b.Output, b.OutputKind = modal, overlayMessage
// more output arrives while it is open
b.Apply(plan.Event{Event: "build", LogLines: []string{"Compiling helix-term v25.7.1"}})
view := plain(b.View(100, true, 24))
if strings.Contains(view, "Compiling helix-term") {
t.Errorf("the warning was replaced by the build's output:\n%s", view)
}
for _, want := range []string{"6 package(s) left the AUR", "grrs-git"} {
if !strings.Contains(view, want) {
t.Errorf("%q is missing from the warning:\n%s", want, view)
}
}
}
func TestTheLogViewStillFollowsWhileAMessageWouldNot(t *testing.T) {
b := NewBuildView([]string{"aur"}, 2)
b.Apply(plan.Event{Event: "build", LogLines: []string{"first"}})
b.OpenOutput()
if b.OutputKind != overlayLog {
t.Fatal("the log view is not marked as the log")
}
b.Apply(plan.Event{Event: "build", LogLines: []string{"second"}})
if !strings.Contains(plain(b.View(100, true, 20)), "second") {
t.Error("the log stopped following")
}
b.OpenOutput() // close
b.OpenMessage()
if b.Output != nil {
t.Error("a message opened with no messages present")
}
}
// -- ? opens help, and the bar stays put --------------------------------
func TestTheRunHelpOpensOnTheRunTab(t *testing.T) {
// The dashboard has its own keys, and the list's are not what somebody
// mid-transaction wants to read.
help := newRunHelp()
view := plain(help.View(100, 30))
for _, want := range []string{"stop the run", "dismiss it for this session"} {
if !strings.Contains(view, want) {
t.Errorf("%q is missing from the run keymap:\n%s", want, view)
}
}
// the list's keys have no meaning here
for _, unwanted := range []string{"DECIDE", "skip for this run", "acknowledge an advisory"} {
if strings.Contains(view, unwanted) {
t.Errorf("%q is shown in the run keymap:\n%s", unwanted, view)
}
}
// and the output tab is reachable
next, _ := help.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}})
if got := plain(next.View(100, 30)); !strings.Contains(got, "copy the whole log") {
t.Errorf("the output tab is missing:\n%s", got)
}
}
func TestTheListKeymapHasNoRunKeys(t *testing.T) {
help := newHelp()
for _, unwanted := range []string{"stop the run", "dismiss it for this session", "copy the whole log"} {
for tab := range listHelpTabs {
help.tab = tab
if strings.Contains(plain(help.View(100, 30)), unwanted) {
t.Errorf("%q appears on the %s tab", unwanted, listHelpTabs[tab])
}
}
}
}
func TestTheHelpKeyIsLastOnTheBar(t *testing.T) {
b := busyBuild()
line := lastHintLine(plain(b.View(200, false, 0)))
if i := strings.Index(line, "help"); i < 0 || i < len(line)-20 {
t.Errorf("the help key is not at the end:\n%s", line)
}
}
// -- typing into the log search -------------------------------------------
func TestTypingAWordContainingODoesNotCloseTheLog(t *testing.T) {
// "error" has an o in it, and o closed the view -- so the search ended
// halfway through the word and the whole log disappeared.
m := Model{build: NewBuildView([]string{"aur"}, 2), width: 100, height: 30,
cfg: testConfig(t)}
m.build.Apply(plan.Event{Event: "build", LogLines: []string{"error: boom"}})
m.build.OpenOutput()
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}})
m = next.(Model)
for _, r := range "error" {
if m.build.Output == nil {
t.Fatalf("the log closed while typing %q", string(r))
}
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
m = next.(Model)
}
if m.build.Output == nil {
t.Fatal("the log closed during the search")
}
if got := m.build.Output.query; got != "error" {
t.Errorf("the query is %q, want %q", got, "error")
}
}
func TestOStillClosesTheLogWhenNotSearching(t *testing.T) {
m := Model{build: NewBuildView([]string{"aur"}, 2), width: 100, height: 30,
cfg: testConfig(t)}
m.build.Apply(plan.Event{Event: "build", LogLines: []string{"x"}})
m.build.OpenOutput()
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'o'}})
if next.(Model).build.Output != nil {
t.Error("o did not close the log")
}
}
func TestAModalCanOpenOverTheDashboard(t *testing.T) {
m := Model{build: NewBuildView([]string{"aur"}, 2), width: 100, height: 30,
cfg: testConfig(t)}
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}})
m = next.(Model)
if m.modal == nil {
t.Fatal("? did not open the keymap over the dashboard")
}
view := plain(m.View())
if !strings.Contains(view, "KEYMAP") {
t.Errorf("the keymap is not on screen:\n%s", view)
}
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyEscape})
if next.(Model).modal != nil {
t.Error("esc did not close the keymap")
}
}
func TestTheSearchFooterHasNoLiteralMarkup(t *testing.T) {
b := NewBuildView([]string{"aur"}, 2)
b.Apply(plan.Event{Event: "build", LogLines: []string{"error one", "error two"}})
b.OpenOutput()
for _, r := range "/error" {
b.Output.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
}
view := b.Output.View(100, 24)
for _, leaked := range []string{"{underline}", "{/}", "{bold}", "{-}"} {
if strings.Contains(view, leaked) {
t.Errorf("style markup leaked into the footer: %q\n%s", leaked, view)
}
}
if !strings.Contains(plain(view), "2 match(es)") {
t.Errorf("the match count is missing:\n%s", plain(view))
}
}
// -- the stream keeps being read, whatever is open -------------------------
func TestOpeningAModalDoesNotStallTheRun(t *testing.T) {
// The events used to be handed to the modal first. It ignored them and
// returned no command, so the next read was never issued, the backend's
// stdout pipe filled, and the backend blocked on write -- the build
// stopped and the clock froze at whatever it read last. Pressing ? during
// a compile was enough.
events := make(chan plan.Event, 4)
m := Model{build: NewBuildView([]string{"aur"}, 3),
stream: &plan.Stream{Events: events},
width: 100, height: 30, cfg: testConfig(t)}
m.build.Apply(plan.Event{Event: "start", Phases: []string{"aur"}, Total: 3})
m.modal = newRunHelp()
next, cmd := m.Update(streamEventMsg{event: plan.Event{
Event: "build", Packages: []plan.BuildState{
{Name: "helix-git", Phase: "cargo", Workers: "rustc ×3"}}}})
m = next.(Model)
if cmd == nil {
t.Fatal("the reader was not re-armed, so nothing more will ever arrive")
}
if len(m.build.Running) != 1 {
t.Errorf("the event was dropped: %+v", m.build.Running)
}
if m.modal == nil {
t.Error("the modal was closed by a stream event")
}
}
func TestAProtocolErrorDoesNotStallTheRunEither(t *testing.T) {
m := Model{build: NewBuildView([]string{"aur"}, 3),
stream: &plan.Stream{Events: make(chan plan.Event, 1)},
width: 100, height: 30, cfg: testConfig(t)}
m.modal = newRunHelp()
_, cmd := m.Update(streamErrorMsg{err: errFake{}})
if cmd == nil {
t.Fatal("a protocol error stopped the reader")
}
}
type errFake struct{}
func (errFake) Error() string { return "unparseable line" }
// -- the frame never exceeds the terminal ---------------------------------
func TestAdversarialNewlinesCannotOverflow(t *testing.T) {
// The height budget counts slice elements, and every element is built from
// a backend string that nothing guarantees holds no newline. Before the
// backstop, a 100-newline message produced 617 lines for a 24-line
// terminal and pushed the run controls off the bottom.
cases := map[string]func(*BuildView){
"message": func(b *BuildView) { b.AddMessage("first\nsecond\nthird\nfourth") },
"newlines": func(b *BuildView) { b.AddMessage(strings.Repeat("\n", 100)) },
"detail": func(b *BuildView) { b.AddMessage("x", "a\nb\nc") },
"log": func(b *BuildView) {
b.Apply(plan.Event{Event: "build", LogLines: []string{"a\nb\nc\nd"}})
},
"feed": func(b *BuildView) {
b.Apply(plan.Event{Event: "finished", Name: "pkg\n\n\n\n", State: "done"})
},
"recovery": func(b *BuildView) {
b.Apply(plan.Event{Event: "recovery", RecoveryLabel: "l\n\n\n\n\n",
RecoveryNote: strings.Repeat("\n", 10)})
},
}
for name, setup := range cases {
for _, height := range []int{10, 12, 20, 24, 40} {
b := NewBuildView([]string{"aur"}, 3)
b.Apply(plan.Event{Event: "start", Phases: []string{"aur"}, Total: 3})
setup(b)
if n := len(strings.Split(b.View(100, false, height), "\n")); n > height {
t.Errorf("%s at height %d produced %d lines", name, height, n)
}
}
}
}
func TestTheStatusCannotPushTheFramePastTheTerminal(t *testing.T) {
// The status appears mid-run, exactly while the reader is trying to abort.
m := loaded(t, sample(), 100, 30)
m.setStatus("a transaction is in progress")
if n := len(strings.Split(m.View(), "\n")); n > 30 {
t.Errorf("status pushed the frame to %d lines", n)
}
}
func TestTheFeedOnlyViewIsBounded(t *testing.T) {
// This rendered every entry ever seen: thousands of finished packages is a
// megabyte a frame, five times a second.
b := NewBuildView([]string{"aur"}, 5000)
for i := 0; i < 5000; i++ {
b.Apply(plan.Event{Event: "finished", Name: fmt.Sprintf("p%d", i), State: "done"})
}
b.Dashboard = false
view := b.View(100, true, 24)
if n := len(strings.Split(view, "\n")); n > 24 {
t.Errorf("the feed-only view produced %d lines", n)
}
if !strings.Contains(view, "p4999") {
t.Error("the newest entry is not shown")
}
}
func TestALaterPhaseDoesNotReadAsFinished(t *testing.T) {
// The repo phase finished 40 packages, then the AUR phase started with 11
// of its own. Taking the phase total made the bar read 40/40 -- full --
// with an entire phase still to run and nothing left in "waiting".
b := NewBuildView([]string{"repo", "aur"}, 51)
b.Apply(plan.Event{Event: "start", Phases: []string{"repo", "aur"}, Total: 51})
b.Apply(plan.Event{Event: "phase_start", Phase: "repo", Total: 40})
for i := 0; i < 40; i++ {
b.Apply(plan.Event{Event: "finished", Name: fmt.Sprintf("r%d", i), State: "done"})
}
b.Apply(plan.Event{Event: "phase_start", Phase: "aur", Total: 11})
view := plain(b.View(110, true, 30))
if strings.Contains(view, "40/40") {
t.Errorf("the bar reads complete with a phase still to run:\n%s", view)
}
if !strings.Contains(view, "40/51") {
t.Errorf("the run is not shown against its own total:\n%s", view)
}
if !strings.Contains(view, "11 waiting") {
t.Errorf("the packages still to come are not counted:\n%s", view)
}
}
func TestSearchingTheLogShowsTheMatch(t *testing.T) {
// The view followed the stream, so a committed search jumped to the first
// hit and was then dragged back to the end on the very next render: it
// reported "3 matches" while showing the wrong part of the log.
b := NewBuildView([]string{"aur"}, 2)
var lines []string
for i := 0; i < 400; i++ {
lines = append(lines, fmt.Sprintf("line %d", i))
}
b.Apply(plan.Event{Event: "build", LogLines: lines})
b.Apply(plan.Event{Event: "build", LogLines: []string{"error: the first one"}})
b.OpenOutput()
if !b.Output.follow {
t.Fatal("the log should open following the end")
}
for _, r := range "/error" {
b.Output.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
}
b.Output.Update(tea.KeyMsg{Type: tea.KeyEnter})
if b.Output.follow {
t.Error("a committed search is still following the end")
}
view := plain(b.Output.View(100, 24))
if !strings.Contains(view, "error: the first one") {
t.Errorf("the match is not on screen:\n%s", view)
}
}
// -- the finished list -----------------------------------------------------
func TestTheFinishedListCanBeReadInFull(t *testing.T) {
// The dashboard shows the tail of the list and "… N earlier" is not a
// thing a reader can look at.
b := NewBuildView([]string{"aur"}, 200)
b.Apply(plan.Event{Event: "start", Phases: []string{"aur"}, Total: 200})
for i := 0; i < 200; i++ {
b.Apply(plan.Event{Event: "finished", Name: fmt.Sprintf("pkg-%03d", i),
Version: "1.0-1", State: "done", Elapsed: 1})
}
if strings.Contains(plain(b.View(110, true, 30)), "pkg-000") {
t.Fatal("the dashboard shows the whole list already")
}
b.OpenFinished()
view := plain(b.Output.View(100, 24))
if !strings.Contains(view, "200 done") {
t.Errorf("the title does not count them:\n%s", view)
}
if !strings.Contains(view, "pkg-199") {
t.Errorf("the newest is not shown:\n%s", view)
}
// scroll back to the start
for i := 0; i < 400; i++ {
b.Output.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}})
}
if got := plain(b.Output.View(100, 24)); !strings.Contains(got, "pkg-000") {
t.Errorf("the oldest cannot be reached:\n%s", got)
}
}
func TestAFailedPackageSaysWhyInTheList(t *testing.T) {
b := NewBuildView([]string{"aur"}, 3)
b.Apply(plan.Event{Event: "start", Phases: []string{"aur"}, Total: 3})
b.Apply(plan.Event{Event: "failed", Name: "broken-git",
FailureReason: "error: could not compile `x`", Elapsed: 12})
b.OpenFinished()
if got := plain(b.Output.View(100, 20)); !strings.Contains(got, "could not compile") {
t.Errorf("the reason is missing:\n%s", got)
}
}
func TestTheFinishedListFollowsWhileItGrows(t *testing.T) {
b := NewBuildView([]string{"aur"}, 3)
b.Apply(plan.Event{Event: "finished", Name: "first", State: "done"})
b.OpenFinished()
b.Apply(plan.Event{Event: "finished", Name: "second", State: "done"})
if got := plain(b.Output.View(100, 20)); !strings.Contains(got, "second") {
t.Errorf("the list did not follow:\n%s", got)
}
}
func TestTheStatusIsPrintedOnce(t *testing.T) {
// It was rendered by renderFooter and appended again by View, and the
// duplicate pushed the filter prompt off the bottom while typing.
m := loaded(t, sample(), 100, 30)
m.setStatus("a thing happened")
if n := strings.Count(plain(m.View()), "a thing happened"); n != 1 {
t.Errorf("the status appears %d times", n)
}
}
// -- skipping the package in progress --------------------------------------
func TestASkippedPackageIsResolvedNotFailed(t *testing.T) {
// Nothing went wrong and nothing is lost, so it must not be counted with
// the failures -- and it must still move the run along, because the
// package is not going to be built.
b := NewBuildView([]string{"aur"}, 12)
b.Apply(plan.Event{Event: "start", Phases: []string{"aur"}, Total: 12})
b.Apply(plan.Event{Event: "phase_start", Phase: "aur", Total: 4})
b.Apply(plan.Event{Event: "build", Packages: []plan.BuildState{
{Name: "mangowm-git", Phase: "cargo", Elapsed: 90}}})
if !b.CanSkip() {
t.Fatal("a package is building in the AUR phase and cannot be skipped")
}
b.Apply(plan.Event{Event: "skipped", Name: "mangowm-git"})
done, failed := b.counts()
if failed != 0 {
t.Errorf("a skip was counted as a failure: %d", failed)
}
if done != 1 {
t.Errorf("a skip did not move the run along: %d done", done)
}
if len(b.Running) != 0 {
t.Errorf("it is still shown as building: %+v", b.Running)
}
if got := plain(b.View(100, true, 24)); !strings.Contains(got, "- mangowm-git") {
t.Errorf("the row has no mark of its own:\n%s", got)
}
}
func TestOnlyTheAurPhaseHasAPackageToSkip(t *testing.T) {
// The repository and flatpak phases are one transaction. A package cannot
// be lifted out of one without unpicking what the database has already
// been told, so the key must not pretend otherwise.
for _, phase := range []string{"repo", "flatpak"} {
b := NewBuildView([]string{"repo", "aur", "flatpak"}, 12)
b.Apply(plan.Event{Event: "phase_start", Phase: phase, Total: 4})
b.Apply(plan.Event{Event: "build", Packages: []plan.BuildState{
{Name: "linux", Phase: "install", Elapsed: 3}}})
if b.CanSkip() {
t.Errorf("%s claims a package can be skipped on its own", phase)
}
}
}
func TestSkippingTellsTheBackend(t *testing.T) {
asked := 0
b := NewBuildView([]string{"aur"}, 12)
b.Apply(plan.Event{Event: "phase_start", Phase: "aur", Total: 4})
b.Apply(plan.Event{Event: "build", Packages: []plan.BuildState{
{Name: "mangowm-git", Phase: "cargo"}}})
m := Model{build: b, stream: &plan.Stream{Skip: func() { asked++ }},
width: 100, height: 30, cfg: testConfig(t)}
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}})
if asked != 1 {
t.Errorf("the backend was asked %d times", asked)
}
if got := next.(Model).status; !strings.Contains(got, "mangowm-git") {
t.Errorf("the status does not say what is being skipped: %q", got)
}
}
func TestSkippingInATransactionPhaseSaysWhyNot(t *testing.T) {
b := NewBuildView([]string{"repo"}, 12)
b.Apply(plan.Event{Event: "phase_start", Phase: "repo", Total: 4})
b.Apply(plan.Event{Event: "build", Packages: []plan.BuildState{
{Name: "linux", Phase: "install"}}})
m := Model{build: b, stream: &plan.Stream{Skip: func() {
t.Error("the backend was asked to skip inside a transaction")
}}, width: 100, height: 30, cfg: testConfig(t)}
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}})
if got := next.(Model).status; !strings.Contains(got, "cannot be skipped") {
t.Errorf("no explanation was given: %q", got)
}
}
func TestTheBarBarelyMovesWhileFastPackagesFinish(t *testing.T) {
// Forty downloads at a second each, then one real build. Counting them
// alike put the bar at 40/41 -- nearly full -- before any compiling had
// started, which is what made it useless as a measure of the run.
b := NewBuildView([]string{"repo", "aur"}, 45)
weights := map[string]int{}
for i := 0; i < 40; i++ {
weights[fmt.Sprintf("r%d", i)] = 1
}
weights["helix-git"] = 60
b.Apply(plan.Event{Event: "start", Phases: []string{"repo", "aur"}, Total: 45,
Weight: 40 + 60 + 4, Weights: weights})
for i := 0; i < 40; i++ {
b.Apply(plan.Event{Event: "finished", Name: fmt.Sprintf("r%d", i),
State: "done", Elapsed: 1})
}
b.Apply(plan.Event{Event: "build", Packages: []plan.BuildState{
{Name: "helix-git", Phase: "cargo", Elapsed: 30}}})
view := plain(b.View(110, true, 30))
if strings.Contains(view, "40/40") {
t.Errorf("the bar is full before the build has finished:\n%s", view)
}
if !strings.Contains(view, "40/104") {
t.Errorf("the bar is not measuring work:\n%s", view)
}
// and the footer still counts packages, not work
if !strings.Contains(view, "✓ 40 done") {
t.Errorf("the footer stopped counting packages:\n%s", view)
}
}