main reporter_ui_test.go
Eric Bower  ·  2026-09-15
  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_ForecastStateSortByStartTime(t *testing.T) {
 52	// Tasks defined out of chronological order:
 53	// "test" started first at t=100
 54	// "lint" started second at t=200
 55	// "fmt" started third at t=300
 56	stats := &RepoStats{
 57		Repo: "my-repo",
 58		Tasks: map[string]*TaskStats{
 59			"fmt": {
 60				AvgDurationMs: 850,
 61				LastStartedAt: 300,
 62			},
 63			"lint": {
 64				AvgDurationMs: 4200,
 65				LastStartedAt: 200,
 66			},
 67			"test": {
 68				AvgDurationMs: 13500,
 69				LastStartedAt: 100,
 70			},
 71		},
 72	}
 73
 74	ui := newPipelineUIState("my-repo", "main", "8f2a1b9", "job123", stats)
 75
 76	expectedOrder := []string{"test", "lint", "fmt"}
 77	if len(ui.TaskOrder) != len(expectedOrder) {
 78		t.Fatalf("expected %d tasks in order, got %d", len(expectedOrder), len(ui.TaskOrder))
 79	}
 80	for i, expected := range expectedOrder {
 81		if ui.TaskOrder[i] != expected {
 82			t.Errorf("expected task at position %d to be %q, got %q (order: %v)", i, expected, ui.TaskOrder[i], ui.TaskOrder)
 83		}
 84	}
 85}
 86
 87func TestUI_RenderProgressBar(t *testing.T) {
 88	tests := []struct {
 89		elapsed   time.Duration
 90		predicted time.Duration
 91		width     int
 92		wantBar   string
 93		wantPct   int
 94	}{
 95		{0, 10 * time.Second, 10, "[░░░░░░░░░░]", 0},
 96		{5 * time.Second, 10 * time.Second, 10, "[█████░░░░░]", 50},
 97		{10 * time.Second, 10 * time.Second, 10, "[██████████]", 100},
 98		{15 * time.Second, 10 * time.Second, 10, "[██████████]", 100}, // clamped to 100%
 99		{1 * time.Second, 0, 10, "[██████████]", 100},                 // zero predicted fallback
100	}
101
102	for _, tt := range tests {
103		bar, pct := renderProgressBar(tt.elapsed, tt.predicted, tt.width)
104		if bar != tt.wantBar || pct != tt.wantPct {
105			t.Errorf("renderProgressBar(%v, %v, %d) = (%s, %d), want (%s, %d)",
106				tt.elapsed, tt.predicted, tt.width, bar, pct, tt.wantBar, tt.wantPct)
107		}
108	}
109}
110
111func TestUI_EarlyFailurePeekBox(t *testing.T) {
112	lines := []string{
113		"cmd/server/main.go:58:2: errcheck: unchecked error",
114		"api/v1/routes.go:19:1: revive: exported function missing doc",
115	}
116
117	box := renderErrorPreviewBox(lines, 60)
118	if !strings.Contains(box, "┌─ [error preview]") {
119		t.Errorf("expected box header, got:\n%s", box)
120	}
121	if !strings.Contains(box, "│ cmd/server/main.go:58:2: errcheck: unchecked error") {
122		t.Errorf("expected line 1 in box, got:\n%s", box)
123	}
124	if !strings.Contains(box, "└") {
125		t.Errorf("expected box footer, got:\n%s", box)
126	}
127}
128
129func TestUI_LiveActivityPeekExtraction(t *testing.T) {
130	rawHistory := "step starting\n\n   \x1b[32mline 1\x1b[0m\nline 2\nline 3\nline 4\nline 5\nline 6\n\n"
131	peek := extractLastOutputLine(rawHistory)
132	if peek != "line 6" {
133		t.Errorf("expected clean peek line 'line 6', got '%s'", peek)
134	}
135
136	lines := extractLastOutputLines(rawHistory, 5)
137	if len(lines) != 5 {
138		t.Fatalf("expected 5 lines, got %d", len(lines))
139	}
140	if lines[0] != "line 2" || lines[4] != "line 6" {
141		t.Errorf("unexpected extracted lines: %v", lines)
142	}
143
144	emptyHistory := "   \n\n\x1b[0m\n"
145	peekEmpty := extractLastOutputLine(emptyHistory)
146	if peekEmpty != "" {
147		t.Errorf("expected empty peek, got '%s'", peekEmpty)
148	}
149	linesEmpty := extractLastOutputLines(emptyHistory, 5)
150	if linesEmpty != nil {
151		t.Errorf("expected nil lines, got %v", linesEmpty)
152	}
153}
154
155func TestUI_RenderStateTransitions(t *testing.T) {
156	stats := &RepoStats{
157		Repo:              "my-repo",
158		AvgWallDurationMs: 14000,
159		Tasks: map[string]*TaskStats{
160			"fmt":  {AvgDurationMs: 850, LastStatus: "success", LastExitCode: 0},
161			"lint": {AvgDurationMs: 4200, LastStatus: "failed", LastExitCode: 1, FailureStreak: 1},
162			"test": {AvgDurationMs: 13500, LastStatus: "success", LastExitCode: 0},
163		},
164	}
165
166	ui := newPipelineUIState("my-repo", "main", "8f2a1b9", "job123", stats)
167
168	// State 1: Forecast
169	render1 := ui.Render(0)
170	if !strings.Contains(render1, "◌ lint") || !strings.Contains(render1, "failed last run") {
171		t.Errorf("expected State 1 forecast output, got:\n%s", render1)
172	}
173
174	// State 2: Running fmt and lint
175	ui.Tasks["fmt"].Status = "running"
176	ui.Tasks["fmt"].Duration = 400 * time.Millisecond
177	ui.Tasks["lint"].Status = "running"
178	ui.Tasks["lint"].Duration = 5400 * time.Millisecond
179	ui.Tasks["lint"].RunningOutput = []string{
180		"fetching dependencies...",
181		"building ast...",
182		"running checkers...",
183		"analyzing packages...",
184		"running golangci-lint on 28 packages...",
185	}
186
187	render2 := ui.Render(0)
188	if !strings.Contains(render2, "fmt") || !strings.Contains(render2, "lint") {
189		t.Errorf("expected State 2 running tasks, got:\n%s", render2)
190	}
191	if !strings.Contains(render2, "│  fetching dependencies...") {
192		t.Errorf("expected vertical bar prefix in running history, got:\n%s", render2)
193	}
194	if !strings.Contains(render2, "└─ running golangci-lint on 28 packages...") {
195		t.Errorf("expected last line with corner branch under lint, got:\n%s", render2)
196	}
197
198	// State 3: Failure with error preview
199	ui.Tasks["fmt"].Status = "success"
200	ui.Tasks["fmt"].Duration = 800 * time.Millisecond
201	ui.Tasks["lint"].Status = "failed"
202	ui.Tasks["lint"].ExitCode = "1"
203	ui.Tasks["lint"].Duration = 4200 * time.Millisecond
204	ui.Tasks["lint"].Transition = "still_failing"
205	ui.Tasks["lint"].ErrorPreview = []string{
206		"cmd/server/main.go:58:2: errcheck: unchecked error",
207		"api/v1/routes.go:19:1: revive: exported function missing doc",
208	}
209
210	render3 := ui.Render(0)
211	if !strings.Contains(render3, "lint") || !strings.Contains(render3, "still failing") {
212		t.Errorf("expected State 3 failure indicator, got:\n%s", render3)
213	}
214	if !strings.Contains(render3, "cmd/server/main.go:58:2") {
215		t.Errorf("expected inline error preview box, got:\n%s", render3)
216	}
217
218	// State 5: Completion summary (success vs failure)
219	summary := ui.RenderSummary("/tmp/pici-artifacts/my-repo/job123/index.html", 1)
220	if !strings.Contains(summary, "1 task failed") {
221		t.Errorf("expected failure summary, got:\n%s", summary)
222	}
223	if !strings.Contains(summary, "Report: /tmp/pici-artifacts/my-repo/job123/index.html") {
224		t.Errorf("expected report path in summary, got:\n%s", summary)
225	}
226	if !strings.Contains(summary, "cmd/server/main.go:58:2: errcheck: unchecked error") {
227		t.Errorf("expected error preview box in summary, got:\n%s", summary)
228	}
229}
230
231func TestUI_RenderSummary_FailureWithErrorPreview(t *testing.T) {
232	ui := newPipelineUIState("my-repo", "main", "8f2a1b9", "job123", nil)
233	ui.TaskOrder = []string{"fmt", "lint", "test"}
234	ui.Tasks["fmt"] = &TaskUIState{Name: "fmt", Status: "success", Duration: 500 * time.Millisecond}
235	ui.Tasks["lint"] = &TaskUIState{
236		Name:     "lint",
237		Status:   "failed",
238		ExitCode: "1",
239		Duration: 1200 * time.Millisecond,
240		ErrorPreview: []string{
241			"line 1: syntax error",
242			"line 2: undefined symbol foo",
243		},
244	}
245	ui.Tasks["test"] = &TaskUIState{
246		Name:     "test",
247		Status:   "failed",
248		ExitCode: "2",
249		Duration: 3400 * time.Millisecond,
250		ErrorPreview: []string{
251			"--- FAIL: TestSomething",
252			"    main_test.go:42: assertion failed",
253		},
254	}
255
256	summary := ui.RenderSummary("/artifacts/index.html", 1)
257	if !strings.Contains(summary, "2 tasks failed") {
258		t.Errorf("expected 2 tasks failed, got:\n%s", summary)
259	}
260	if !strings.Contains(summary, "syntax error") {
261		t.Errorf("expected lint error preview in summary, got:\n%s", summary)
262	}
263	if !strings.Contains(summary, "FAIL: TestSomething") {
264		t.Errorf("expected test error preview in summary, got:\n%s", summary)
265	}
266}
267
268func TestUI_RenderSummary_SuccessWithFixed(t *testing.T) {
269	stats := &RepoStats{
270		Repo: "my-repo",
271		Tasks: map[string]*TaskStats{
272			"fmt":  {AvgDurationMs: 900, LastStatus: "success", LastExitCode: 0},
273			"lint": {AvgDurationMs: 4200, LastStatus: "failed", LastExitCode: 1, FailureStreak: 1},
274			"test": {AvgDurationMs: 13500, LastStatus: "success", LastExitCode: 0},
275		},
276	}
277
278	ui := newPipelineUIState("my-repo", "main", "8f2a1b9", "job123", stats)
279	ui.Tasks["fmt"].Status = "success"
280	ui.Tasks["fmt"].Duration = 800 * time.Millisecond
281	ui.Tasks["lint"].Status = "success"
282	ui.Tasks["lint"].Duration = 4100 * time.Millisecond
283	ui.Tasks["lint"].Transition = "fixed"
284	ui.Tasks["test"].Status = "success"
285	ui.Tasks["test"].Duration = 14100 * time.Millisecond
286
287	summary := ui.RenderSummary("/tmp/report.html", 0)
288	if !strings.Contains(summary, "All 3 tasks succeeded") {
289		t.Errorf("expected success summary, got:\n%s", summary)
290	}
291	if !strings.Contains(summary, "🎉 fixed!") {
292		t.Errorf("expected fixed badge in summary, got:\n%s", summary)
293	}
294	if !strings.Contains(summary, "Report: /tmp/report.html") {
295		t.Errorf("expected report path, got:\n%s", summary)
296	}
297}
298
299func TestUI_NonTTYStreaming(t *testing.T) {
300	var buf bytes.Buffer
301	logger := newLogger("ci", "error")
302
303	stats := &RepoStats{Repo: "test"}
304	ui := newPipelineUIState("test", "main", "abc", "job1", stats)
305
306	streamer := &plainStreamReporter{
307		out: &buf,
308		ui:  ui,
309		log: logger,
310	}
311
312	streamer.OnTaskStart("fmt")
313	streamer.OnTaskOutput("fmt", "formatting...")
314	streamer.OnTaskComplete("fmt", 0, 800*time.Millisecond, nil)
315
316	out := buf.String()
317	if !strings.Contains(out, "starting fmt") {
318		t.Errorf("expected non-TTY start event, got: %s", out)
319	}
320	if !strings.Contains(out, "fmt succeeded (0.8s)") {
321		t.Errorf("expected non-TTY complete event, got: %s", out)
322	}
323}
324
325func TestInteractiveController_Cancellation(t *testing.T) {
326	ctx, cancel := context.WithCancel(context.Background())
327	var out bytes.Buffer
328
329	ctrl := newInteractiveController(ctx, &out, "testrepo", "main", "head", "job1", nil, false)
330	go func() {
331		time.Sleep(50 * time.Millisecond)
332		cancel()
333	}()
334
335	err := ctrl.WaitUntilDone(func() ([]SessionInfo, bool) {
336		return []SessionInfo{
337			{Name: "local.testrepo.job1.step.test", Short: "test"},
338		}, false
339	})
340
341	if err == nil {
342		t.Fatal("expected error on cancellation, got nil")
343	}
344	if !strings.Contains(out.String(), "cancelling job and terminating active sessions") && !ctrl.ui.Cancelled {
345		t.Errorf("expected cancellation message, got: %s", out.String())
346	}
347}
348
349func TestInteractiveController_FullRunSimulation(t *testing.T) {
350	var out bytes.Buffer
351	ctx := context.Background()
352
353	stats := &RepoStats{
354		Repo: "my-repo",
355		Tasks: map[string]*TaskStats{
356			"test": {AvgDurationMs: 1000, LastStatus: "success"},
357		},
358	}
359
360	ctrl := newInteractiveController(ctx, &out, "my-repo", "main", "abcd123", "job99", stats, true)
361
362	step := 0
363	err := ctrl.WaitUntilDone(func() ([]SessionInfo, bool) {
364		step++
365		if step == 1 {
366			// Step running
367			return []SessionInfo{
368				{Name: "local.my-repo.job99.step.test", Short: "test", Created: "1000", Ended: ""},
369			}, false
370		}
371		// Step completed
372		return []SessionInfo{
373			{Name: "local.my-repo.job99.step.test", Short: "test", Created: "1000", Ended: "1002", ExitCode: "0"},
374			{Name: "local.my-repo.job99.runner", Short: "runner", Created: "1000", Ended: "1002", ExitCode: "0"},
375		}, true
376	})
377
378	if err != nil {
379		t.Fatalf("expected nil error, got %v", err)
380	}
381	if !ctrl.ui.IsComplete {
382		t.Errorf("expected UI to be marked complete")
383	}
384	if ctrl.ui.Tasks["test"].Status != "success" {
385		t.Errorf("expected test status success, got %s", ctrl.ui.Tasks["test"].Status)
386	}
387}
388
389func TestController_RedrawNoDrift(t *testing.T) {
390	var buf bytes.Buffer
391	ctrl := &interactiveController{
392		out:   &buf,
393		isTTY: true,
394	}
395
396	// Frame 1: 3 lines
397	ctrl.redraw("line1\nline2\nline3\n")
398	if ctrl.rendered != 2 {
399		t.Errorf("expected rendered=2, got %d", ctrl.rendered)
400	}
401
402	// Frame 2: same 3 lines (should move up 2 lines, not drift)
403	buf.Reset()
404	ctrl.redraw("line1\nline2\nline3\n")
405	out := buf.String()
406	if !strings.HasPrefix(out, "\r\033[2A") {
407		t.Errorf("expected move up 2 lines '\\r\\033[2A', got %q", out)
408	}
409
410	// Frame 3: expanded to 5 lines
411	buf.Reset()
412	ctrl.redraw("line1\nline2\nline3\nline4\nline5\n")
413	out = buf.String()
414	if !strings.HasPrefix(out, "\r\033[2A") {
415		t.Errorf("expected move up 2 lines before expanding, got %q", out)
416	}
417	if ctrl.rendered != 4 {
418		t.Errorf("expected rendered=4, got %d", ctrl.rendered)
419	}
420
421	// Frame 4: shrink back to 3 lines
422	buf.Reset()
423	ctrl.redraw("line1\nline2\nline3\n")
424	out = buf.String()
425	if !strings.HasPrefix(out, "\r\033[4A") {
426		t.Errorf("expected move up 4 lines before shrinking, got %q", out)
427	}
428	if ctrl.rendered != 2 {
429		t.Errorf("expected rendered=2, got %d", ctrl.rendered)
430	}
431}
432
433func TestUI_PruneUnexecutedTasksOnSuccess(t *testing.T) {
434	stats := &RepoStats{
435		Repo: "pgit",
436		Tasks: map[string]*TaskStats{
437			"-d":   {AvgDurationMs: 0},
438			"fmt":  {AvgDurationMs: 1000},
439			"lint": {AvgDurationMs: 500},
440			"test": {AvgDurationMs: 3000},
441		},
442	}
443
444	ui := newPipelineUIState("pgit", "main", "abc1234", "job1", stats)
445	if len(ui.TaskOrder) != 4 {
446		t.Fatalf("expected 4 tasks initialized from stats, got %d", len(ui.TaskOrder))
447	}
448
449	// fmt, lint, test succeeded in this run; -d was never executed
450	ui.Tasks["fmt"].Status = "success"
451	ui.Tasks["fmt"].Duration = 1 * time.Second
452	ui.Tasks["lint"].Status = "success"
453	ui.Tasks["lint"].Duration = 500 * time.Millisecond
454	ui.Tasks["test"].Status = "success"
455	ui.Tasks["test"].Duration = 2 * time.Second
456
457	ui.PruneUnexecuted()
458
459	if _, exists := ui.Tasks["-d"]; exists {
460		t.Errorf("expected '-d' to be removed from ui.Tasks")
461	}
462	if len(ui.TaskOrder) != 3 {
463		t.Errorf("expected 3 tasks in ui.TaskOrder, got %d", len(ui.TaskOrder))
464	}
465
466	summary := ui.RenderSummary("/tmp/report.html", 0)
467	if !strings.Contains(summary, "All 3 tasks succeeded") {
468		t.Errorf("expected summary to state All 3 tasks succeeded, got:\n%s", summary)
469	}
470	if strings.Contains(summary, "-d") {
471		t.Errorf("expected summary NOT to contain '-d', got:\n%s", summary)
472	}
473}
474
475func TestUI_RenderSummary_IgnoresExpectedTasksOnSuccess(t *testing.T) {
476	stats := &RepoStats{
477		Repo: "pgit",
478		Tasks: map[string]*TaskStats{
479			"-d":   {AvgDurationMs: 0},
480			"test": {AvgDurationMs: 3000},
481		},
482	}
483
484	ui := newPipelineUIState("pgit", "main", "abc1234", "job2", stats)
485	ui.Tasks["test"].Status = "success"
486	ui.Tasks["test"].Duration = 1 * time.Second
487	// -d remains with Status == "expected"
488
489	summary := ui.RenderSummary("/tmp/report.html", 0)
490	if !strings.Contains(summary, "All 1 tasks succeeded") {
491		t.Errorf("expected 'All 1 tasks succeeded', got:\n%s", summary)
492	}
493	if strings.Contains(summary, "-d") {
494		t.Errorf("expected summary NOT to contain '-d', got:\n%s", summary)
495	}
496}