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}