main reporter_ui_test.go
Eric Bower  ·  2026-08-17
  1package main
  2
  3import (
  4	"bytes"
  5	"context"
  6	"strings"
  7	"testing"
  8	"time"
  9)
 10
 11func TestUI_ForecastStateInitialization(t *testing.T) {
 12	stats := &RepoStats{
 13		Repo:              "my-repo",
 14		AvgWallDurationMs: 14200,
 15		Tasks: map[string]*TaskStats{
 16			"fmt": {
 17				AvgDurationMs: 850,
 18				LastStatus:    "success",
 19				LastExitCode:  0,
 20			},
 21			"lint": {
 22				AvgDurationMs: 4200,
 23				LastStatus:    "failed",
 24				LastExitCode:  1,
 25				FailureStreak: 1,
 26			},
 27			"test": {
 28				AvgDurationMs: 13500,
 29				LastStatus:    "success",
 30				LastExitCode:  0,
 31			},
 32		},
 33	}
 34
 35	ui := newPipelineUIState("my-repo", "main", "8f2a1b9", "job123", stats)
 36
 37	if len(ui.Tasks) != 3 {
 38		t.Fatalf("expected 3 tasks, got %d", len(ui.Tasks))
 39	}
 40	if ui.Tasks["fmt"].Status != "expected" {
 41		t.Errorf("expected fmt status to be expected, got %s", ui.Tasks["fmt"].Status)
 42	}
 43	if ui.Tasks["lint"].PrevStatus != "failed" {
 44		t.Errorf("expected lint prev status failed, got %s", ui.Tasks["lint"].PrevStatus)
 45	}
 46	if ui.Tasks["lint"].PredictedDur != 4200*time.Millisecond {
 47		t.Errorf("expected predicted duration 4.2s, got %v", ui.Tasks["lint"].PredictedDur)
 48	}
 49}
 50
 51func TestUI_RenderProgressBar(t *testing.T) {
 52	tests := []struct {
 53		elapsed   time.Duration
 54		predicted time.Duration
 55		width     int
 56		wantBar   string
 57		wantPct   int
 58	}{
 59		{0, 10 * time.Second, 10, "[░░░░░░░░░░]", 0},
 60		{5 * time.Second, 10 * time.Second, 10, "[█████░░░░░]", 50},
 61		{10 * time.Second, 10 * time.Second, 10, "[██████████]", 100},
 62		{15 * time.Second, 10 * time.Second, 10, "[██████████]", 100}, // clamped to 100%
 63		{1 * time.Second, 0, 10, "[██████████]", 100},                 // zero predicted fallback
 64	}
 65
 66	for _, tt := range tests {
 67		bar, pct := renderProgressBar(tt.elapsed, tt.predicted, tt.width)
 68		if bar != tt.wantBar || pct != tt.wantPct {
 69			t.Errorf("renderProgressBar(%v, %v, %d) = (%s, %d), want (%s, %d)",
 70				tt.elapsed, tt.predicted, tt.width, bar, pct, tt.wantBar, tt.wantPct)
 71		}
 72	}
 73}
 74
 75func TestUI_EarlyFailurePeekBox(t *testing.T) {
 76	lines := []string{
 77		"cmd/server/main.go:58:2: errcheck: unchecked error",
 78		"api/v1/routes.go:19:1: revive: exported function missing doc",
 79	}
 80
 81	box := renderErrorPreviewBox(lines, 60)
 82	if !strings.Contains(box, "┌─ [error preview]") {
 83		t.Errorf("expected box header, got:\n%s", box)
 84	}
 85	if !strings.Contains(box, "│ cmd/server/main.go:58:2: errcheck: unchecked error") {
 86		t.Errorf("expected line 1 in box, got:\n%s", box)
 87	}
 88	if !strings.Contains(box, "└") {
 89		t.Errorf("expected box footer, got:\n%s", box)
 90	}
 91}
 92
 93func TestUI_LiveActivityPeekExtraction(t *testing.T) {
 94	rawHistory := "step starting\n\n   \x1b[32mline 1\x1b[0m\nline 2\nline 3\nline 4\nline 5\nline 6\n\n"
 95	peek := extractLastOutputLine(rawHistory)
 96	if peek != "line 6" {
 97		t.Errorf("expected clean peek line 'line 6', got '%s'", peek)
 98	}
 99
100	lines := extractLastOutputLines(rawHistory, 5)
101	if len(lines) != 5 {
102		t.Fatalf("expected 5 lines, got %d", len(lines))
103	}
104	if lines[0] != "line 2" || lines[4] != "line 6" {
105		t.Errorf("unexpected extracted lines: %v", lines)
106	}
107
108	emptyHistory := "   \n\n\x1b[0m\n"
109	peekEmpty := extractLastOutputLine(emptyHistory)
110	if peekEmpty != "" {
111		t.Errorf("expected empty peek, got '%s'", peekEmpty)
112	}
113	linesEmpty := extractLastOutputLines(emptyHistory, 5)
114	if linesEmpty != nil {
115		t.Errorf("expected nil lines, got %v", linesEmpty)
116	}
117}
118
119func TestUI_RenderStateTransitions(t *testing.T) {
120	stats := &RepoStats{
121		Repo:              "my-repo",
122		AvgWallDurationMs: 14000,
123		Tasks: map[string]*TaskStats{
124			"fmt":  {AvgDurationMs: 850, LastStatus: "success", LastExitCode: 0},
125			"lint": {AvgDurationMs: 4200, LastStatus: "failed", LastExitCode: 1, FailureStreak: 1},
126			"test": {AvgDurationMs: 13500, LastStatus: "success", LastExitCode: 0},
127		},
128	}
129
130	ui := newPipelineUIState("my-repo", "main", "8f2a1b9", "job123", stats)
131
132	// State 1: Forecast
133	render1 := ui.Render(0)
134	if !strings.Contains(render1, "◌ lint") || !strings.Contains(render1, "failed last run") {
135		t.Errorf("expected State 1 forecast output, got:\n%s", render1)
136	}
137
138	// State 2: Running fmt and lint
139	ui.Tasks["fmt"].Status = "running"
140	ui.Tasks["fmt"].Duration = 400 * time.Millisecond
141	ui.Tasks["lint"].Status = "running"
142	ui.Tasks["lint"].Duration = 5400 * time.Millisecond
143	ui.Tasks["lint"].RunningOutput = []string{
144		"fetching dependencies...",
145		"building ast...",
146		"running checkers...",
147		"analyzing packages...",
148		"running golangci-lint on 28 packages...",
149	}
150
151	render2 := ui.Render(0)
152	if !strings.Contains(render2, "fmt") || !strings.Contains(render2, "lint") {
153		t.Errorf("expected State 2 running tasks, got:\n%s", render2)
154	}
155	if !strings.Contains(render2, "│  fetching dependencies...") {
156		t.Errorf("expected vertical bar prefix in running history, got:\n%s", render2)
157	}
158	if !strings.Contains(render2, "└─ running golangci-lint on 28 packages...") {
159		t.Errorf("expected last line with corner branch under lint, got:\n%s", render2)
160	}
161
162	// State 3: Failure with error preview
163	ui.Tasks["fmt"].Status = "success"
164	ui.Tasks["fmt"].Duration = 800 * time.Millisecond
165	ui.Tasks["lint"].Status = "failed"
166	ui.Tasks["lint"].ExitCode = "1"
167	ui.Tasks["lint"].Duration = 4200 * time.Millisecond
168	ui.Tasks["lint"].Transition = "still_failing"
169	ui.Tasks["lint"].ErrorPreview = []string{
170		"cmd/server/main.go:58:2: errcheck: unchecked error",
171		"api/v1/routes.go:19:1: revive: exported function missing doc",
172	}
173
174	render3 := ui.Render(0)
175	if !strings.Contains(render3, "lint") || !strings.Contains(render3, "still failing") {
176		t.Errorf("expected State 3 failure indicator, got:\n%s", render3)
177	}
178	if !strings.Contains(render3, "cmd/server/main.go:58:2") {
179		t.Errorf("expected inline error preview box, got:\n%s", render3)
180	}
181
182	// State 5: Completion summary (success vs failure)
183	summary := ui.RenderSummary("/tmp/pici-artifacts/my-repo/job123/index.html", 1)
184	if !strings.Contains(summary, "1 task failed") {
185		t.Errorf("expected failure summary, got:\n%s", summary)
186	}
187	if !strings.Contains(summary, "Report: /tmp/pici-artifacts/my-repo/job123/index.html") {
188		t.Errorf("expected report path in summary, got:\n%s", summary)
189	}
190	if !strings.Contains(summary, "cmd/server/main.go:58:2: errcheck: unchecked error") {
191		t.Errorf("expected error preview box in summary, got:\n%s", summary)
192	}
193}
194
195func TestUI_RenderSummary_FailureWithErrorPreview(t *testing.T) {
196	ui := newPipelineUIState("my-repo", "main", "8f2a1b9", "job123", nil)
197	ui.TaskOrder = []string{"fmt", "lint", "test"}
198	ui.Tasks["fmt"] = &TaskUIState{Name: "fmt", Status: "success", Duration: 500 * time.Millisecond}
199	ui.Tasks["lint"] = &TaskUIState{
200		Name:     "lint",
201		Status:   "failed",
202		ExitCode: "1",
203		Duration: 1200 * time.Millisecond,
204		ErrorPreview: []string{
205			"line 1: syntax error",
206			"line 2: undefined symbol foo",
207		},
208	}
209	ui.Tasks["test"] = &TaskUIState{
210		Name:     "test",
211		Status:   "failed",
212		ExitCode: "2",
213		Duration: 3400 * time.Millisecond,
214		ErrorPreview: []string{
215			"--- FAIL: TestSomething",
216			"    main_test.go:42: assertion failed",
217		},
218	}
219
220	summary := ui.RenderSummary("/artifacts/index.html", 1)
221	if !strings.Contains(summary, "2 tasks failed") {
222		t.Errorf("expected 2 tasks failed, got:\n%s", summary)
223	}
224	if !strings.Contains(summary, "syntax error") {
225		t.Errorf("expected lint error preview in summary, got:\n%s", summary)
226	}
227	if !strings.Contains(summary, "FAIL: TestSomething") {
228		t.Errorf("expected test error preview in summary, got:\n%s", summary)
229	}
230}
231
232func TestUI_RenderSummary_SuccessWithFixed(t *testing.T) {
233	stats := &RepoStats{
234		Repo: "my-repo",
235		Tasks: map[string]*TaskStats{
236			"fmt":  {AvgDurationMs: 900, LastStatus: "success", LastExitCode: 0},
237			"lint": {AvgDurationMs: 4200, LastStatus: "failed", LastExitCode: 1, FailureStreak: 1},
238			"test": {AvgDurationMs: 13500, LastStatus: "success", LastExitCode: 0},
239		},
240	}
241
242	ui := newPipelineUIState("my-repo", "main", "8f2a1b9", "job123", stats)
243	ui.Tasks["fmt"].Status = "success"
244	ui.Tasks["fmt"].Duration = 800 * time.Millisecond
245	ui.Tasks["lint"].Status = "success"
246	ui.Tasks["lint"].Duration = 4100 * time.Millisecond
247	ui.Tasks["lint"].Transition = "fixed"
248	ui.Tasks["test"].Status = "success"
249	ui.Tasks["test"].Duration = 14100 * time.Millisecond
250
251	summary := ui.RenderSummary("/tmp/report.html", 0)
252	if !strings.Contains(summary, "All 3 tasks succeeded") {
253		t.Errorf("expected success summary, got:\n%s", summary)
254	}
255	if !strings.Contains(summary, "🎉 fixed!") {
256		t.Errorf("expected fixed badge in summary, got:\n%s", summary)
257	}
258	if !strings.Contains(summary, "Report: /tmp/report.html") {
259		t.Errorf("expected report path, got:\n%s", summary)
260	}
261}
262
263func TestUI_NonTTYStreaming(t *testing.T) {
264	var buf bytes.Buffer
265	logger := newLogger("ci", "error")
266
267	stats := &RepoStats{Repo: "test"}
268	ui := newPipelineUIState("test", "main", "abc", "job1", stats)
269
270	streamer := &plainStreamReporter{
271		out: &buf,
272		ui:  ui,
273		log: logger,
274	}
275
276	streamer.OnTaskStart("fmt")
277	streamer.OnTaskOutput("fmt", "formatting...")
278	streamer.OnTaskComplete("fmt", 0, 800*time.Millisecond, nil)
279
280	out := buf.String()
281	if !strings.Contains(out, "starting fmt") {
282		t.Errorf("expected non-TTY start event, got: %s", out)
283	}
284	if !strings.Contains(out, "fmt succeeded (0.8s)") {
285		t.Errorf("expected non-TTY complete event, got: %s", out)
286	}
287}
288
289func TestInteractiveController_Cancellation(t *testing.T) {
290	ctx, cancel := context.WithCancel(context.Background())
291	var out bytes.Buffer
292
293	ctrl := newInteractiveController(ctx, &out, "testrepo", "main", "head", "job1", nil, false)
294	go func() {
295		time.Sleep(50 * time.Millisecond)
296		cancel()
297	}()
298
299	err := ctrl.WaitUntilDone(func() ([]SessionInfo, bool) {
300		return []SessionInfo{
301			{Name: "local.testrepo.job1.step.test", Short: "test"},
302		}, false
303	})
304
305	if err == nil {
306		t.Fatal("expected error on cancellation, got nil")
307	}
308	if !strings.Contains(out.String(), "cancelling job and terminating active sessions") && !ctrl.ui.Cancelled {
309		t.Errorf("expected cancellation message, got: %s", out.String())
310	}
311}
312
313func TestInteractiveController_FullRunSimulation(t *testing.T) {
314	var out bytes.Buffer
315	ctx := context.Background()
316
317	stats := &RepoStats{
318		Repo: "my-repo",
319		Tasks: map[string]*TaskStats{
320			"test": {AvgDurationMs: 1000, LastStatus: "success"},
321		},
322	}
323
324	ctrl := newInteractiveController(ctx, &out, "my-repo", "main", "abcd123", "job99", stats, true)
325
326	step := 0
327	err := ctrl.WaitUntilDone(func() ([]SessionInfo, bool) {
328		step++
329		if step == 1 {
330			// Step running
331			return []SessionInfo{
332				{Name: "local.my-repo.job99.step.test", Short: "test", Created: "1000", Ended: ""},
333			}, false
334		}
335		// Step completed
336		return []SessionInfo{
337			{Name: "local.my-repo.job99.step.test", Short: "test", Created: "1000", Ended: "1002", ExitCode: "0"},
338			{Name: "local.my-repo.job99.runner", Short: "runner", Created: "1000", Ended: "1002", ExitCode: "0"},
339		}, true
340	})
341
342	if err != nil {
343		t.Fatalf("expected nil error, got %v", err)
344	}
345	if !ctrl.ui.IsComplete {
346		t.Errorf("expected UI to be marked complete")
347	}
348	if ctrl.ui.Tasks["test"].Status != "success" {
349		t.Errorf("expected test status success, got %s", ctrl.ui.Tasks["test"].Status)
350	}
351}
352
353func TestController_RedrawNoDrift(t *testing.T) {
354	var buf bytes.Buffer
355	ctrl := &interactiveController{
356		out:   &buf,
357		isTTY: true,
358	}
359
360	// Frame 1: 3 lines
361	ctrl.redraw("line1\nline2\nline3\n")
362	if ctrl.rendered != 2 {
363		t.Errorf("expected rendered=2, got %d", ctrl.rendered)
364	}
365
366	// Frame 2: same 3 lines (should move up 2 lines, not drift)
367	buf.Reset()
368	ctrl.redraw("line1\nline2\nline3\n")
369	out := buf.String()
370	if !strings.HasPrefix(out, "\r\033[2A") {
371		t.Errorf("expected move up 2 lines '\\r\\033[2A', got %q", out)
372	}
373
374	// Frame 3: expanded to 5 lines
375	buf.Reset()
376	ctrl.redraw("line1\nline2\nline3\nline4\nline5\n")
377	out = buf.String()
378	if !strings.HasPrefix(out, "\r\033[2A") {
379		t.Errorf("expected move up 2 lines before expanding, got %q", out)
380	}
381	if ctrl.rendered != 4 {
382		t.Errorf("expected rendered=4, got %d", ctrl.rendered)
383	}
384
385	// Frame 4: shrink back to 3 lines
386	buf.Reset()
387	ctrl.redraw("line1\nline2\nline3\n")
388	out = buf.String()
389	if !strings.HasPrefix(out, "\r\033[4A") {
390		t.Errorf("expected move up 4 lines before shrinking, got %q", out)
391	}
392	if ctrl.rendered != 2 {
393		t.Errorf("expected rendered=2, got %d", ctrl.rendered)
394	}
395}