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}