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
1@@ -32,6 +32,7 @@ type TaskUIState struct {
2 Duration time.Duration
3 ExitCode string
4 LastOutput string // Last non-empty line for running peek
5+ RunningOutput []string // Captured recent output lines while running (up to 5)
6 ErrorPreview []string // Captured error lines on failure
7 PredictedDur time.Duration
8 PrevStatus string
9@@ -134,15 +135,30 @@ func truncateLine(s string, maxWidth int) string {
10 return string(runes[:maxWidth])
11 }
12
13-func extractLastOutputLine(raw string) string {
14+func extractLastOutputLines(raw string, maxLines int) []string {
15 clean := stripANSI(raw)
16 lines := strings.Split(clean, "\n")
17- for i := len(lines) - 1; i >= 0; i-- {
18- line := strings.TrimSpace(lines[i])
19+ var nonEmpties []string
20+ for _, l := range lines {
21+ line := strings.TrimSpace(l)
22 if line != "" && !strings.HasPrefix(line, "step starting") && !strings.HasPrefix(line, "step completed") {
23- return line
24+ nonEmpties = append(nonEmpties, line)
25 }
26 }
27+ if len(nonEmpties) == 0 {
28+ return nil
29+ }
30+ if maxLines > 0 && len(nonEmpties) > maxLines {
31+ nonEmpties = nonEmpties[len(nonEmpties)-maxLines:]
32+ }
33+ return nonEmpties
34+}
35+
36+func extractLastOutputLine(raw string) string {
37+ lines := extractLastOutputLines(raw, 1)
38+ if len(lines) > 0 {
39+ return lines[0]
40+ }
41 return ""
42 }
43
44@@ -339,7 +355,15 @@ func (ui *PipelineUIState) Render(frame int) string {
45 }
46
47 fmt.Fprintf(&sb, " %c %-8s %s %s%s\n", spinnerRune, task.Name, formatShortDuration(taskDur), predStr, barPart)
48- if task.LastOutput != "" {
49+ if len(task.RunningOutput) > 0 {
50+ for i, line := range task.RunningOutput {
51+ if i == len(task.RunningOutput)-1 {
52+ fmt.Fprintf(&sb, " └─ %s\n", line)
53+ } else {
54+ fmt.Fprintf(&sb, " │ %s\n", line)
55+ }
56+ }
57+ } else if task.LastOutput != "" {
58 fmt.Fprintf(&sb, " └─ %s\n", task.LastOutput)
59 }
60
61@@ -440,7 +464,11 @@ func (ui *PipelineUIState) RenderSummary(reportPath string, exitCode int) string
62 if task.ExitCode == "" {
63 exitStr = "exit 1"
64 }
65- fmt.Fprintf(&sb, " ✖ %-9s %6s %s (see error preview above)\n", task.Name, formatShortDuration(task.Duration), exitStr)
66+ fmt.Fprintf(&sb, " ✖ %-9s %6s %s\n", task.Name, formatShortDuration(task.Duration), exitStr)
67+ if len(task.ErrorPreview) > 0 {
68+ sb.WriteString(renderErrorPreviewBox(task.ErrorPreview, 65))
69+ sb.WriteString("\n")
70+ }
71 default:
72 fmt.Fprintf(&sb, " ✖ %-9s cancelled\n", task.Name)
73 }
74@@ -754,7 +782,10 @@ func waitAndReportUI(cfg *Cfg, log *slog.Logger, repoName, jobID, eventType, bra
75 ctrl.ui.mu.Lock()
76 if task, ok := ctrl.ui.Tasks[s.Short]; ok {
77 if s.Ended == "" {
78- task.LastOutput = extractLastOutputLine(hist)
79+ task.RunningOutput = extractLastOutputLines(hist, 5)
80+ if len(task.RunningOutput) > 0 {
81+ task.LastOutput = task.RunningOutput[len(task.RunningOutput)-1]
82+ }
83 } else if s.ExitCode != "0" && len(task.ErrorPreview) == 0 {
84 task.ErrorPreview = extractErrorPreview(hist, 5)
85 }
86@@ -797,6 +828,21 @@ func waitAndReportUI(cfg *Cfg, log *slog.Logger, repoName, jobID, eventType, bra
87 }
88 }
89
90+ for _, s := range finalSessions {
91+ if s.Short == "runner" || s.Short == "" {
92+ continue
93+ }
94+ if s.ExitCode != "0" && s.ExitCode != "" {
95+ ctrl.ui.mu.Lock()
96+ if task, ok := ctrl.ui.Tasks[s.Short]; ok && len(task.ErrorPreview) == 0 {
97+ if hist, err := fetchHistoryPlain(s.Name); err == nil && hist != "" {
98+ task.ErrorPreview = extractErrorPreview(hist, 5)
99+ }
100+ }
101+ ctrl.ui.mu.Unlock()
102+ }
103+ }
104+
105 exitCode, overallStatus := resolveJobExitCode(finalSessions)
106
107 // Update historical stats.json
+66,
-5
1@@ -91,10 +91,18 @@ func TestUI_EarlyFailurePeekBox(t *testing.T) {
2 }
3
4 func TestUI_LiveActivityPeekExtraction(t *testing.T) {
5- rawHistory := "step starting\n\n \x1b[32mrunning golangci-lint on 28 packages...\x1b[0m\n\n"
6+ rawHistory := "step starting\n\n \x1b[32mline 1\x1b[0m\nline 2\nline 3\nline 4\nline 5\nline 6\n\n"
7 peek := extractLastOutputLine(rawHistory)
8- if peek != "running golangci-lint on 28 packages..." {
9- t.Errorf("expected clean peek line, got '%s'", peek)
10+ if peek != "line 6" {
11+ t.Errorf("expected clean peek line 'line 6', got '%s'", peek)
12+ }
13+
14+ lines := extractLastOutputLines(rawHistory, 5)
15+ if len(lines) != 5 {
16+ t.Fatalf("expected 5 lines, got %d", len(lines))
17+ }
18+ if lines[0] != "line 2" || lines[4] != "line 6" {
19+ t.Errorf("unexpected extracted lines: %v", lines)
20 }
21
22 emptyHistory := " \n\n\x1b[0m\n"
23@@ -102,6 +110,10 @@ func TestUI_LiveActivityPeekExtraction(t *testing.T) {
24 if peekEmpty != "" {
25 t.Errorf("expected empty peek, got '%s'", peekEmpty)
26 }
27+ linesEmpty := extractLastOutputLines(emptyHistory, 5)
28+ if linesEmpty != nil {
29+ t.Errorf("expected nil lines, got %v", linesEmpty)
30+ }
31 }
32
33 func TestUI_RenderStateTransitions(t *testing.T) {
34@@ -128,14 +140,23 @@ func TestUI_RenderStateTransitions(t *testing.T) {
35 ui.Tasks["fmt"].Duration = 400 * time.Millisecond
36 ui.Tasks["lint"].Status = "running"
37 ui.Tasks["lint"].Duration = 5400 * time.Millisecond
38- ui.Tasks["lint"].LastOutput = "running golangci-lint on 28 packages..."
39+ ui.Tasks["lint"].RunningOutput = []string{
40+ "fetching dependencies...",
41+ "building ast...",
42+ "running checkers...",
43+ "analyzing packages...",
44+ "running golangci-lint on 28 packages...",
45+ }
46
47 render2 := ui.Render(0)
48 if !strings.Contains(render2, "fmt") || !strings.Contains(render2, "lint") {
49 t.Errorf("expected State 2 running tasks, got:\n%s", render2)
50 }
51+ if !strings.Contains(render2, "│ fetching dependencies...") {
52+ t.Errorf("expected vertical bar prefix in running history, got:\n%s", render2)
53+ }
54 if !strings.Contains(render2, "└─ running golangci-lint on 28 packages...") {
55- t.Errorf("expected live activity peek under lint, got:\n%s", render2)
56+ t.Errorf("expected last line with corner branch under lint, got:\n%s", render2)
57 }
58
59 // State 3: Failure with error preview
60@@ -166,6 +187,46 @@ func TestUI_RenderStateTransitions(t *testing.T) {
61 if !strings.Contains(summary, "Report: /tmp/pici-artifacts/my-repo/job123/index.html") {
62 t.Errorf("expected report path in summary, got:\n%s", summary)
63 }
64+ if !strings.Contains(summary, "cmd/server/main.go:58:2: errcheck: unchecked error") {
65+ t.Errorf("expected error preview box in summary, got:\n%s", summary)
66+ }
67+}
68+
69+func TestUI_RenderSummary_FailureWithErrorPreview(t *testing.T) {
70+ ui := newPipelineUIState("my-repo", "main", "8f2a1b9", "job123", nil)
71+ ui.TaskOrder = []string{"fmt", "lint", "test"}
72+ ui.Tasks["fmt"] = &TaskUIState{Name: "fmt", Status: "success", Duration: 500 * time.Millisecond}
73+ ui.Tasks["lint"] = &TaskUIState{
74+ Name: "lint",
75+ Status: "failed",
76+ ExitCode: "1",
77+ Duration: 1200 * time.Millisecond,
78+ ErrorPreview: []string{
79+ "line 1: syntax error",
80+ "line 2: undefined symbol foo",
81+ },
82+ }
83+ ui.Tasks["test"] = &TaskUIState{
84+ Name: "test",
85+ Status: "failed",
86+ ExitCode: "2",
87+ Duration: 3400 * time.Millisecond,
88+ ErrorPreview: []string{
89+ "--- FAIL: TestSomething",
90+ " main_test.go:42: assertion failed",
91+ },
92+ }
93+
94+ summary := ui.RenderSummary("/artifacts/index.html", 1)
95+ if !strings.Contains(summary, "2 tasks failed") {
96+ t.Errorf("expected 2 tasks failed, got:\n%s", summary)
97+ }
98+ if !strings.Contains(summary, "syntax error") {
99+ t.Errorf("expected lint error preview in summary, got:\n%s", summary)
100+ }
101+ if !strings.Contains(summary, "FAIL: TestSomething") {
102+ t.Errorf("expected test error preview in summary, got:\n%s", summary)
103+ }
104 }
105
106 func TestUI_RenderSummary_SuccessWithFixed(t *testing.T) {
M
run.sh
+1,
-1
1@@ -8,7 +8,7 @@ while true; do
2 echo "waiting for pici event ..."
3 event=$(ssh pipe sub pici 2>/dev/null)
4 if [ -n "$event" ]; then
5- echo "$event" | pici runner --wait
6+ echo "$event" | pici runner
7 else
8 echo "could not connect to pipe, waiting 5s..."
9 sleep 5