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