Commit b4c6cdb
Eric Bower
·
2026-08-17 10:20:53 -0400 EDT
parent 2b62b2d
chore(ui): each task get 5 lines of history
3 files changed,
+120,
-13
+53,
-7
| ... | ... | @@ -32,6 +32,7 @@ type TaskUIState struct { | |
| 32 | 32 | Duration time.Duration | |
| 33 | 33 | ExitCode string | |
| 34 | 34 | LastOutput string // Last non-empty line for running peek | |
| 35 | + | RunningOutput []string // Captured recent output lines while running (up to 5) | |
| 35 | 36 | ErrorPreview []string // Captured error lines on failure | |
| 36 | 37 | PredictedDur time.Duration | |
| 37 | 38 | PrevStatus string |
| ... | ... | @@ -134,15 +135,30 @@ func truncateLine(s string, maxWidth int) string { | |
| 134 | 135 | return string(runes[:maxWidth]) | |
| 135 | 136 | } | |
| 136 | 137 | ||
| 137 | - | func extractLastOutputLine(raw string) string { | |
| 138 | + | func extractLastOutputLines(raw string, maxLines int) []string { | |
| 138 | 139 | clean := stripANSI(raw) | |
| 139 | 140 | lines := strings.Split(clean, "\n") | |
| 140 | - | for i := len(lines) - 1; i >= 0; i-- { | |
| 141 | - | line := strings.TrimSpace(lines[i]) | |
| 141 | + | var nonEmpties []string | |
| 142 | + | for _, l := range lines { | |
| 143 | + | line := strings.TrimSpace(l) | |
| 142 | 144 | if line != "" && !strings.HasPrefix(line, "step starting") && !strings.HasPrefix(line, "step completed") { | |
| 143 | - | return line | |
| 145 | + | nonEmpties = append(nonEmpties, line) | |
| 144 | 146 | } | |
| 145 | 147 | } | |
| 148 | + | if len(nonEmpties) == 0 { | |
| 149 | + | return nil | |
| 150 | + | } | |
| 151 | + | if maxLines > 0 && len(nonEmpties) > maxLines { | |
| 152 | + | nonEmpties = nonEmpties[len(nonEmpties)-maxLines:] | |
| 153 | + | } | |
| 154 | + | return nonEmpties | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func extractLastOutputLine(raw string) string { | |
| 158 | + | lines := extractLastOutputLines(raw, 1) | |
| 159 | + | if len(lines) > 0 { | |
| 160 | + | return lines[0] | |
| 161 | + | } | |
| 146 | 162 | return "" | |
| 147 | 163 | } | |
| 148 | 164 |
| ... | ... | @@ -339,7 +355,15 @@ func (ui *PipelineUIState) Render(frame int) string { | |
| 339 | 355 | } | |
| 340 | 356 | ||
| 341 | 357 | fmt.Fprintf(&sb, " %c %-8s %s %s%s\n", spinnerRune, task.Name, formatShortDuration(taskDur), predStr, barPart) | |
| 342 | - | if task.LastOutput != "" { | |
| 358 | + | if len(task.RunningOutput) > 0 { | |
| 359 | + | for i, line := range task.RunningOutput { | |
| 360 | + | if i == len(task.RunningOutput)-1 { | |
| 361 | + | fmt.Fprintf(&sb, " └─ %s\n", line) | |
| 362 | + | } else { | |
| 363 | + | fmt.Fprintf(&sb, " │ %s\n", line) | |
| 364 | + | } | |
| 365 | + | } | |
| 366 | + | } else if task.LastOutput != "" { | |
| 343 | 367 | fmt.Fprintf(&sb, " └─ %s\n", task.LastOutput) | |
| 344 | 368 | } | |
| 345 | 369 |
| ... | ... | @@ -440,7 +464,11 @@ func (ui *PipelineUIState) RenderSummary(reportPath string, exitCode int) string | |
| 440 | 464 | if task.ExitCode == "" { | |
| 441 | 465 | exitStr = "exit 1" | |
| 442 | 466 | } | |
| 443 | - | fmt.Fprintf(&sb, " ✖ %-9s %6s %s (see error preview above)\n", task.Name, formatShortDuration(task.Duration), exitStr) | |
| 467 | + | fmt.Fprintf(&sb, " ✖ %-9s %6s %s\n", task.Name, formatShortDuration(task.Duration), exitStr) | |
| 468 | + | if len(task.ErrorPreview) > 0 { | |
| 469 | + | sb.WriteString(renderErrorPreviewBox(task.ErrorPreview, 65)) | |
| 470 | + | sb.WriteString("\n") | |
| 471 | + | } | |
| 444 | 472 | default: | |
| 445 | 473 | fmt.Fprintf(&sb, " ✖ %-9s cancelled\n", task.Name) | |
| 446 | 474 | } |
| ... | ... | @@ -754,7 +782,10 @@ func waitAndReportUI(cfg *Cfg, log *slog.Logger, repoName, jobID, eventType, bra | |
| 754 | 782 | ctrl.ui.mu.Lock() | |
| 755 | 783 | if task, ok := ctrl.ui.Tasks[s.Short]; ok { | |
| 756 | 784 | if s.Ended == "" { | |
| 757 | - | task.LastOutput = extractLastOutputLine(hist) | |
| 785 | + | task.RunningOutput = extractLastOutputLines(hist, 5) | |
| 786 | + | if len(task.RunningOutput) > 0 { | |
| 787 | + | task.LastOutput = task.RunningOutput[len(task.RunningOutput)-1] | |
| 788 | + | } | |
| 758 | 789 | } else if s.ExitCode != "0" && len(task.ErrorPreview) == 0 { | |
| 759 | 790 | task.ErrorPreview = extractErrorPreview(hist, 5) | |
| 760 | 791 | } |
| ... | ... | @@ -797,6 +828,21 @@ func waitAndReportUI(cfg *Cfg, log *slog.Logger, repoName, jobID, eventType, bra | |
| 797 | 828 | } | |
| 798 | 829 | } | |
| 799 | 830 | ||
| 831 | + | for _, s := range finalSessions { | |
| 832 | + | if s.Short == "runner" || s.Short == "" { | |
| 833 | + | continue | |
| 834 | + | } | |
| 835 | + | if s.ExitCode != "0" && s.ExitCode != "" { | |
| 836 | + | ctrl.ui.mu.Lock() | |
| 837 | + | if task, ok := ctrl.ui.Tasks[s.Short]; ok && len(task.ErrorPreview) == 0 { | |
| 838 | + | if hist, err := fetchHistoryPlain(s.Name); err == nil && hist != "" { | |
| 839 | + | task.ErrorPreview = extractErrorPreview(hist, 5) | |
| 840 | + | } | |
| 841 | + | } | |
| 842 | + | ctrl.ui.mu.Unlock() | |
| 843 | + | } | |
| 844 | + | } | |
| 845 | + | ||
| 800 | 846 | exitCode, overallStatus := resolveJobExitCode(finalSessions) | |
| 801 | 847 | ||
| 802 | 848 | // Update historical stats.json |
+66,
-5
| ... | ... | @@ -91,10 +91,18 @@ func TestUI_EarlyFailurePeekBox(t *testing.T) { | |
| 91 | 91 | } | |
| 92 | 92 | ||
| 93 | 93 | func TestUI_LiveActivityPeekExtraction(t *testing.T) { | |
| 94 | - | rawHistory := "step starting\n\n \x1b[32mrunning golangci-lint on 28 packages...\x1b[0m\n\n" | |
| 94 | + | rawHistory := "step starting\n\n \x1b[32mline 1\x1b[0m\nline 2\nline 3\nline 4\nline 5\nline 6\n\n" | |
| 95 | 95 | peek := extractLastOutputLine(rawHistory) | |
| 96 | - | if peek != "running golangci-lint on 28 packages..." { | |
| 97 | - | t.Errorf("expected clean peek line, got '%s'", peek) | |
| 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) | |
| 98 | 106 | } | |
| 99 | 107 | ||
| 100 | 108 | emptyHistory := " \n\n\x1b[0m\n" |
| ... | ... | @@ -102,6 +110,10 @@ func TestUI_LiveActivityPeekExtraction(t *testing.T) { | |
| 102 | 110 | if peekEmpty != "" { | |
| 103 | 111 | t.Errorf("expected empty peek, got '%s'", peekEmpty) | |
| 104 | 112 | } | |
| 113 | + | linesEmpty := extractLastOutputLines(emptyHistory, 5) | |
| 114 | + | if linesEmpty != nil { | |
| 115 | + | t.Errorf("expected nil lines, got %v", linesEmpty) | |
| 116 | + | } | |
| 105 | 117 | } | |
| 106 | 118 | ||
| 107 | 119 | func TestUI_RenderStateTransitions(t *testing.T) { |
| ... | ... | @@ -128,14 +140,23 @@ func TestUI_RenderStateTransitions(t *testing.T) { | |
| 128 | 140 | ui.Tasks["fmt"].Duration = 400 * time.Millisecond | |
| 129 | 141 | ui.Tasks["lint"].Status = "running" | |
| 130 | 142 | ui.Tasks["lint"].Duration = 5400 * time.Millisecond | |
| 131 | - | ui.Tasks["lint"].LastOutput = "running golangci-lint on 28 packages..." | |
| 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 | + | } | |
| 132 | 150 | ||
| 133 | 151 | render2 := ui.Render(0) | |
| 134 | 152 | if !strings.Contains(render2, "fmt") || !strings.Contains(render2, "lint") { | |
| 135 | 153 | t.Errorf("expected State 2 running tasks, got:\n%s", render2) | |
| 136 | 154 | } | |
| 155 | + | if !strings.Contains(render2, "│ fetching dependencies...") { | |
| 156 | + | t.Errorf("expected vertical bar prefix in running history, got:\n%s", render2) | |
| 157 | + | } | |
| 137 | 158 | if !strings.Contains(render2, "└─ running golangci-lint on 28 packages...") { | |
| 138 | - | t.Errorf("expected live activity peek under lint, got:\n%s", render2) | |
| 159 | + | t.Errorf("expected last line with corner branch under lint, got:\n%s", render2) | |
| 139 | 160 | } | |
| 140 | 161 | ||
| 141 | 162 | // State 3: Failure with error preview |
| ... | ... | @@ -166,6 +187,46 @@ func TestUI_RenderStateTransitions(t *testing.T) { | |
| 166 | 187 | if !strings.Contains(summary, "Report: /tmp/pici-artifacts/my-repo/job123/index.html") { | |
| 167 | 188 | t.Errorf("expected report path in summary, got:\n%s", summary) | |
| 168 | 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 | + | ||
| 195 | + | func 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 | + | } | |
| 169 | 230 | } | |
| 170 | 231 | ||
| 171 | 232 | func TestUI_RenderSummary_SuccessWithFixed(t *testing.T) { |
M
run.sh
+1,
-1