Commit 1a7066e
Eric Bower
·
2026-08-16 09:45:42 -0400 EDT
parent 50d7d59
feat: reporter ui and plain
7 files changed,
+1704,
-211
M
main.go
+10,
-211
| ... | ... | @@ -16,13 +16,11 @@ import ( | |
| 16 | 16 | "log/slog" | |
| 17 | 17 | "os" | |
| 18 | 18 | "os/exec" | |
| 19 | - | "os/signal" | |
| 20 | 19 | "path/filepath" | |
| 21 | 20 | "runtime" | |
| 22 | 21 | "sort" | |
| 23 | 22 | "strconv" | |
| 24 | 23 | "strings" | |
| 25 | - | "syscall" | |
| 26 | 24 | "time" | |
| 27 | 25 | ) | |
| 28 | 26 |
| ... | ... | @@ -1027,7 +1025,7 @@ See: https://github.com/picosh/pici | |
| 1027 | 1025 | fmt.Fprintln(os.Stdout, "ā job launched") //nolint:errcheck | |
| 1028 | 1026 | ||
| 1029 | 1027 | if cfg.Wait { | |
| 1030 | - | if err := waitAndReport(cfg, log, eventData.Name, jobID, eventData.Type); err != nil { | |
| 1028 | + | if err := waitAndReportPlain(cfg, log, eventData.Name, jobID, eventData.Type); err != nil { | |
| 1031 | 1029 | runErr = err | |
| 1032 | 1030 | return fmt.Errorf("wait: %w", err) | |
| 1033 | 1031 | } |
| ... | ... | @@ -1043,210 +1041,12 @@ See: https://github.com/picosh/pici | |
| 1043 | 1041 | } | |
| 1044 | 1042 | ||
| 1045 | 1043 | // waitAndReport polls the job's sessions until all complete, prints live | |
| 1046 | - | // progress to stdout, then dumps session history and a final summary. | |
| 1044 | + | // interactive progress to stdout, updates stats.json, and outputs a final summary. | |
| 1047 | 1045 | func waitAndReport(cfg *Cfg, log *slog.Logger, name, jobID, eventType string) error { | |
| 1048 | - | domain := getDomain(eventType) | |
| 1049 | - | prefix := domain + "." + name + "." + jobID + "." | |
| 1050 | - | interval := cfg.MonitorInterval | |
| 1051 | - | if interval <= 0 { | |
| 1052 | - | interval = 5 * time.Second | |
| 1053 | - | } | |
| 1054 | - | ticker := time.NewTicker(interval) | |
| 1055 | - | defer ticker.Stop() | |
| 1056 | - | ||
| 1057 | - | // Handle ^C and termination signals gracefully | |
| 1058 | - | sigCh := make(chan os.Signal, 1) | |
| 1059 | - | signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) | |
| 1060 | - | defer signal.Stop(sigCh) | |
| 1061 | - | ||
| 1062 | - | fmt.Fprintln(os.Stdout) //nolint:errcheck | |
| 1063 | - | fmt.Fprint(os.Stdout, "ā³ waiting for completion...\n") //nolint:errcheck | |
| 1064 | - | ||
| 1065 | - | // Track state for each session | |
| 1066 | - | known := make(map[string]*sessionState) | |
| 1067 | - | var jobSessions []SessionInfo | |
| 1068 | - | var sessionOrder []string // track insertion order for deterministic output | |
| 1069 | - | var liveLines []string // last set of status lines printed (for overwrite) | |
| 1070 | - | ||
| 1071 | - | var done <-chan struct{} | |
| 1072 | - | if cfg.Ctx != nil { | |
| 1073 | - | done = cfg.Ctx.Done() | |
| 1074 | - | } | |
| 1075 | - | ||
| 1076 | - | for { | |
| 1077 | - | select { | |
| 1078 | - | case <-done: | |
| 1079 | - | cancelJobSessions(prefix) | |
| 1080 | - | fmt.Fprintln(os.Stdout, "\nā¹ job cancelled") //nolint:errcheck | |
| 1081 | - | return cfg.Ctx.Err() | |
| 1082 | - | case <-sigCh: | |
| 1083 | - | cancelJobSessions(prefix) | |
| 1084 | - | fmt.Fprintln(os.Stdout, "\nā¹ job cancelled") //nolint:errcheck | |
| 1085 | - | return fmt.Errorf("job cancelled by signal") | |
| 1086 | - | case <-ticker.C: | |
| 1087 | - | } | |
| 1088 | - | ||
| 1089 | - | // Fetch current session list | |
| 1090 | - | listOutput, err := exec.Command("zmx", "list").CombinedOutput() | |
| 1091 | - | if err != nil { | |
| 1092 | - | log.Error("zmx list", "err", err) | |
| 1093 | - | continue | |
| 1094 | - | } | |
| 1095 | - | ||
| 1096 | - | sessions := parseZMXList(string(listOutput)) | |
| 1097 | - | jobSessions = nil | |
| 1098 | - | for _, s := range sessions { | |
| 1099 | - | if strings.HasPrefix(s.Name, prefix) { | |
| 1100 | - | s.Short = cleanSessionShort(s.Name, prefix, name, jobID) | |
| 1101 | - | jobSessions = append(jobSessions, s) | |
| 1102 | - | } | |
| 1103 | - | } | |
| 1104 | - | ||
| 1105 | - | if len(jobSessions) == 0 { | |
| 1106 | - | continue // runner session may not have appeared yet | |
| 1107 | - | } | |
| 1108 | - | ||
| 1109 | - | // Update state for each session | |
| 1110 | - | for _, s := range jobSessions { | |
| 1111 | - | state, ok := known[s.Short] | |
| 1112 | - | if !ok { | |
| 1113 | - | state = &sessionState{} | |
| 1114 | - | known[s.Short] = state | |
| 1115 | - | sessionOrder = append(sessionOrder, s.Short) | |
| 1116 | - | } | |
| 1117 | - | ||
| 1118 | - | if s.Ended == "" { | |
| 1119 | - | state.status = "running" | |
| 1120 | - | created, _ := strconv.ParseInt(s.Created, 10, 64) | |
| 1121 | - | state.duration = fmtDurationTs(created, time.Now().Unix()) | |
| 1122 | - | } else { | |
| 1123 | - | if s.ExitCode == "0" { | |
| 1124 | - | state.status = "success" | |
| 1125 | - | } else { | |
| 1126 | - | state.status = "failed" | |
| 1127 | - | state.exitCode = s.ExitCode | |
| 1128 | - | } | |
| 1129 | - | state.duration = fmtDuration(s.Created, s.Ended) | |
| 1130 | - | state.printed = true // lock final state | |
| 1131 | - | } | |
| 1132 | - | } | |
| 1133 | - | ||
| 1134 | - | // Build current status lines | |
| 1135 | - | lines := make([]string, 0, len(sessionOrder)) | |
| 1136 | - | for _, short := range sessionOrder { | |
| 1137 | - | state := known[short] | |
| 1138 | - | icon := map[string]string{"running": "š", "success": "ā ", "failed": "ā"}[state.status] | |
| 1139 | - | detail := "" | |
| 1140 | - | if state.status == "failed" && state.exitCode != "" { | |
| 1141 | - | detail = fmt.Sprintf(", exit %s", state.exitCode) | |
| 1142 | - | } | |
| 1143 | - | if state.duration != "" && state.duration != "ā" { | |
| 1144 | - | detail += fmt.Sprintf(" (%s)", state.duration) | |
| 1145 | - | } | |
| 1146 | - | lines = append(lines, fmt.Sprintf(" %-12s %s %s%s", short, icon, state.status, detail)) | |
| 1147 | - | } | |
| 1148 | - | ||
| 1149 | - | // Overwrite previous lines with cursor-up, or print fresh | |
| 1150 | - | if len(liveLines) > 0 { | |
| 1151 | - | // Move cursor up to overwrite previous lines //nolint:errcheck | |
| 1152 | - | for range len(liveLines) { | |
| 1153 | - | fmt.Fprint(os.Stdout, "\033[A") //nolint:errcheck | |
| 1154 | - | } | |
| 1155 | - | // Clear each line | |
| 1156 | - | for i, line := range lines { //nolint:errcheck | |
| 1157 | - | if i > 0 { | |
| 1158 | - | fmt.Fprint(os.Stdout, "\n") //nolint:errcheck | |
| 1159 | - | } | |
| 1160 | - | fmt.Fprint(os.Stdout, line+"\033[K") //nolint:errcheck | |
| 1161 | - | } | |
| 1162 | - | fmt.Fprint(os.Stdout, "\n") //nolint:errcheck | |
| 1163 | - | } else { //nolint:errcheck | |
| 1164 | - | for _, line := range lines { | |
| 1165 | - | fmt.Fprintln(os.Stdout, line) //nolint:errcheck | |
| 1166 | - | } | |
| 1167 | - | } | |
| 1168 | - | liveLines = lines | |
| 1169 | - | ||
| 1170 | - | // Stage local artifacts on tick so progress and HTML reports update live | |
| 1171 | - | _ = stageLocalArtifacts(cfg, log, name, jobID, eventType) | |
| 1172 | - | ||
| 1173 | - | // Check if all sessions are done and the runner session has finished | |
| 1174 | - | if isJobComplete(jobSessions) { | |
| 1175 | - | break | |
| 1176 | - | } | |
| 1177 | - | } | |
| 1178 | - | ||
| 1179 | - | // Print last 25 lines of history for failed sessions only | |
| 1180 | - | fmt.Fprintln(os.Stdout) //nolint:errcheck | |
| 1181 | - | for _, s := range jobSessions { | |
| 1182 | - | state := known[s.Short] | |
| 1183 | - | if state == nil || state.status != "failed" { | |
| 1184 | - | continue | |
| 1185 | - | } | |
| 1186 | - | ||
| 1187 | - | separator := strings.Repeat("\u2500", 50) | |
| 1188 | - | fmt.Fprintln(os.Stdout, separator) //nolint:errcheck | |
| 1189 | - | fmt.Fprintf(os.Stdout, "Session: %s (exit %s)\n", s.Short, state.exitCode) //nolint:errcheck | |
| 1190 | - | fmt.Fprintln(os.Stdout, separator) //nolint:errcheck | |
| 1191 | - | fmt.Fprintln(os.Stdout) //nolint:errcheck | |
| 1192 | - | ||
| 1193 | - | history, err := fetchHistoryPlain(s.Name) | |
| 1194 | - | if err != nil { | |
| 1195 | - | fmt.Fprintf(os.Stdout, " (history unavailable: %v)\n", err) //nolint:errcheck | |
| 1196 | - | } else { | |
| 1197 | - | lines := strings.Split(history, "\n") | |
| 1198 | - | // Show last 25 lines | |
| 1199 | - | if len(lines) > 25 { | |
| 1200 | - | fmt.Fprintf(os.Stdout, " ... (%d lines omitted)\n", len(lines)-25) //nolint:errcheck | |
| 1201 | - | lines = lines[len(lines)-25:] | |
| 1202 | - | } | |
| 1203 | - | for _, line := range lines { | |
| 1204 | - | if line != "" { | |
| 1205 | - | fmt.Fprintf(os.Stdout, " %s\n", line) //nolint:errcheck | |
| 1206 | - | } | |
| 1207 | - | } | |
| 1208 | - | } | |
| 1209 | - | fmt.Fprintln(os.Stdout) //nolint:errcheck | |
| 1210 | - | } | |
| 1211 | - | ||
| 1212 | - | // Final summary | |
| 1213 | - | exitCode, status := resolveJobExitCode(jobSessions) | |
| 1214 | - | _, _, duration := computeJobTiming(jobSessions) | |
| 1215 | - | icon := map[string]string{"success": "ā ", "failed": "ā"}[status] | |
| 1216 | - | if exitCode == 0 { | |
| 1217 | - | fmt.Fprintf(os.Stdout, "%s job finished: %s (%s)\n", icon, status, duration) //nolint:errcheck | |
| 1218 | - | } else { | |
| 1219 | - | fmt.Fprintf(os.Stdout, "%s job failed: exit %d (%s)\n", icon, exitCode, duration) //nolint:errcheck | |
| 1220 | - | } | |
| 1221 | - | ||
| 1222 | - | topic := fmt.Sprintf("%s.%s.%s", domain, name, jobID) | |
| 1223 | - | if exitCode != 0 && cfg.DebugOnFail { | |
| 1224 | - | cwd, _ := os.Getwd() | |
| 1225 | - | debugCfg := DebugBridgeConfig{ | |
| 1226 | - | Topic: topic, | |
| 1227 | - | KeyLocation: cfg.KeyLocation, | |
| 1228 | - | CertLocation: cfg.CertificateLocation, | |
| 1229 | - | WorkDir: cwd, | |
| 1230 | - | Timeout: cfg.DebugTimeout, | |
| 1231 | - | Logger: log, | |
| 1232 | - | Stdout: os.Stdout, | |
| 1233 | - | } | |
| 1234 | - | if err := startDebugBridge(cfg.Ctx, debugCfg); err != nil { | |
| 1235 | - | log.Error("debug bridge failed", "err", err) | |
| 1236 | - | } | |
| 1237 | - | } | |
| 1238 | - | ||
| 1239 | - | if cfg.SummaryFile != "" { | |
| 1240 | - | if err := writeSummaryMarkdown(cfg.SummaryFile, name, jobID, jobSessions, known, cfg.DebugOnFail && exitCode != 0, topic); err != nil { | |
| 1241 | - | log.Error("write summary markdown", "err", err, "file", cfg.SummaryFile) | |
| 1242 | - | } | |
| 1243 | - | } | |
| 1244 | - | ||
| 1245 | - | if exitCode != 0 { | |
| 1246 | - | return &JobFailedError{ExitCode: exitCode} | |
| 1247 | - | } | |
| 1248 | - | ||
| 1249 | - | return nil | |
| 1046 | + | cwd, _ := os.Getwd() | |
| 1047 | + | branch := detectGitBranch(cwd) | |
| 1048 | + | commit := detectGitCommit(cwd) | |
| 1049 | + | return waitAndReportUI(cfg, log, name, jobID, eventType, branch, commit) | |
| 1250 | 1050 | } | |
| 1251 | 1051 | ||
| 1252 | 1052 | func writeSummaryMarkdown(filePath string, repoName, jobID string, jobSessions []SessionInfo, known map[string]*sessionState, debugActive bool, debugTopic string) error { |
| ... | ... | @@ -1351,7 +1151,6 @@ type sessionState struct { | |
| 1351 | 1151 | status string // "running", "success", "failed" | |
| 1352 | 1152 | exitCode string | |
| 1353 | 1153 | duration string | |
| 1354 | - | printed bool // true once final state (success/failed) is printed | |
| 1355 | 1154 | } | |
| 1356 | 1155 | ||
| 1357 | 1156 | // monitorJobState tracks display state for a single job across ticks. |
| ... | ... | @@ -2630,14 +2429,14 @@ func runLocal(cfg *Cfg, dest string) error { | |
| 2630 | 2429 | } | |
| 2631 | 2430 | }() | |
| 2632 | 2431 | ||
| 2633 | - | fmt.Fprintf(os.Stdout, "š starting local job local.%s.%s\n", eventData.Name, jobID) //nolint:errcheck | |
| 2432 | + | log.Info("starting local job", "job_id", jobID) | |
| 2634 | 2433 | if err := eng.Setup(); err != nil { | |
| 2635 | 2434 | return fmt.Errorf("workspace setup: %w", err) | |
| 2636 | 2435 | } | |
| 2637 | 2436 | if cfg.InPlace { | |
| 2638 | - | fmt.Fprintf(os.Stdout, "š using in-place workspace directory %s\n", eng.Wk.GetDir()) //nolint:errcheck | |
| 2437 | + | log.Debug("using in-place workspace directory", "dir", eng.Wk.GetDir()) | |
| 2639 | 2438 | } else { | |
| 2640 | - | fmt.Fprintf(os.Stdout, "š¦ syncing workspace to temp directory %s\n", eng.Wk.GetDir()) //nolint:errcheck | |
| 2439 | + | log.Debug("syncing workspace to temp directory", "dir", eng.Wk.GetDir()) | |
| 2641 | 2440 | } | |
| 2642 | 2441 | log.Debug("workspace directory", "dir", eng.Wk.GetDir()) | |
| 2643 | 2442 |
| ... | ... | @@ -2674,7 +2473,7 @@ func runLocal(cfg *Cfg, dest string) error { | |
| 2674 | 2473 | return err | |
| 2675 | 2474 | } | |
| 2676 | 2475 | ||
| 2677 | - | fmt.Fprintln(os.Stdout, "š launching sessions...") //nolint:errcheck | |
| 2476 | + | log.Debug("launching sessions") | |
| 2678 | 2477 | if err := eng.Run(manifest); err != nil { | |
| 2679 | 2478 | return fmt.Errorf("run: %w", err) | |
| 2680 | 2479 | } |
+145,
-0
| ... | ... | @@ -0,0 +1,145 @@ | |
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "log/slog" | |
| 6 | + | "os" | |
| 7 | + | "os/exec" | |
| 8 | + | "os/signal" | |
| 9 | + | "path/filepath" | |
| 10 | + | "strconv" | |
| 11 | + | "strings" | |
| 12 | + | "syscall" | |
| 13 | + | "time" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // waitAndReportPlain provides clean, linear append-only output for background services | |
| 17 | + | // like pici-runner.service, bypassing all TTY cursor overwrites and spinner loops. | |
| 18 | + | func waitAndReportPlain(cfg *Cfg, log *slog.Logger, name, jobID, eventType string) error { | |
| 19 | + | domain := getDomain(eventType) | |
| 20 | + | prefix := fmt.Sprintf("%s.%s.%s.", domain, name, jobID) | |
| 21 | + | interval := cfg.MonitorInterval | |
| 22 | + | if interval <= 0 { | |
| 23 | + | interval = 1 * time.Second | |
| 24 | + | } | |
| 25 | + | ticker := time.NewTicker(interval) | |
| 26 | + | defer ticker.Stop() | |
| 27 | + | ||
| 28 | + | sigCh := make(chan os.Signal, 1) | |
| 29 | + | signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) | |
| 30 | + | defer signal.Stop(sigCh) | |
| 31 | + | ||
| 32 | + | startedSessions := make(map[string]bool) | |
| 33 | + | completedSessions := make(map[string]bool) | |
| 34 | + | var finalSessions []SessionInfo | |
| 35 | + | ||
| 36 | + | var done <-chan struct{} | |
| 37 | + | if cfg.Ctx != nil { | |
| 38 | + | done = cfg.Ctx.Done() | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | for { | |
| 42 | + | select { | |
| 43 | + | case <-done: | |
| 44 | + | cancelJobSessions(prefix) | |
| 45 | + | fmt.Fprintf(os.Stdout, "[%s] ā¹ runner cancelled\n", time.Now().Format("15:04:05")) //nolint:errcheck | |
| 46 | + | return cfg.Ctx.Err() | |
| 47 | + | case <-sigCh: | |
| 48 | + | cancelJobSessions(prefix) | |
| 49 | + | fmt.Fprintf(os.Stdout, "\n[%s] ā¹ runner terminated by signal\n", time.Now().Format("15:04:05")) //nolint:errcheck | |
| 50 | + | return fmt.Errorf("job cancelled by signal") | |
| 51 | + | case <-ticker.C: | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | listOutput, err := exec.Command("zmx", "list").CombinedOutput() | |
| 55 | + | if err != nil { | |
| 56 | + | if log != nil { | |
| 57 | + | log.Error("zmx list", "err", err) | |
| 58 | + | } | |
| 59 | + | continue | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | sessions := parseZMXList(string(listOutput)) | |
| 63 | + | var jobSessions []SessionInfo | |
| 64 | + | for _, s := range sessions { | |
| 65 | + | if strings.HasPrefix(s.Name, prefix) { | |
| 66 | + | s.Short = cleanSessionShort(s.Name, prefix, name, jobID) | |
| 67 | + | jobSessions = append(jobSessions, s) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | finalSessions = jobSessions | |
| 72 | + | ||
| 73 | + | for _, s := range jobSessions { | |
| 74 | + | if s.Short == "runner" || s.Short == "" { | |
| 75 | + | continue | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | if !startedSessions[s.Short] { | |
| 79 | + | startedSessions[s.Short] = true | |
| 80 | + | fmt.Fprintf(os.Stdout, "[%s] ā¶ [%s] started\n", time.Now().Format("15:04:05"), s.Short) //nolint:errcheck | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | if s.Ended != "" && !completedSessions[s.Short] { | |
| 84 | + | completedSessions[s.Short] = true | |
| 85 | + | created, _ := strconv.ParseInt(s.Created, 10, 64) | |
| 86 | + | ended, _ := strconv.ParseInt(s.Ended, 10, 64) | |
| 87 | + | dur := time.Duration(ended-created) * time.Second | |
| 88 | + | if s.ExitCode == "0" { | |
| 89 | + | fmt.Fprintf(os.Stdout, "[%s] ā [%s] succeeded (%s)\n", time.Now().Format("15:04:05"), s.Short, formatShortDuration(dur)) //nolint:errcheck | |
| 90 | + | } else { | |
| 91 | + | fmt.Fprintf(os.Stdout, "[%s] ā [%s] failed with exit %s (%s)\n", time.Now().Format("15:04:05"), s.Short, s.ExitCode, formatShortDuration(dur)) //nolint:errcheck | |
| 92 | + | hist, err := fetchHistoryPlain(s.Name) | |
| 93 | + | if err == nil && hist != "" { | |
| 94 | + | errPreview := extractErrorPreview(hist, 5) | |
| 95 | + | for _, line := range errPreview { | |
| 96 | + | fmt.Fprintf(os.Stdout, " ā %s\n", line) //nolint:errcheck | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // Stage local artifacts | |
| 104 | + | _ = stageLocalArtifacts(cfg, log, name, jobID, eventType) | |
| 105 | + | ||
| 106 | + | if isJobComplete(jobSessions) { | |
| 107 | + | break | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | exitCode, status := resolveJobExitCode(finalSessions) | |
| 112 | + | _, _, duration := computeJobTiming(finalSessions) | |
| 113 | + | ||
| 114 | + | reportPath := filepath.Join(cfg.ArtifactDir, name, jobID, "index.html") | |
| 115 | + | if exitCode == 0 { | |
| 116 | + | fmt.Fprintf(os.Stdout, "[%s] ā job finished: %s (%s)\n", time.Now().Format("15:04:05"), status, duration) //nolint:errcheck | |
| 117 | + | } else { | |
| 118 | + | fmt.Fprintf(os.Stdout, "[%s] ā job failed: exit %d (%s)\n", time.Now().Format("15:04:05"), exitCode, duration) //nolint:errcheck | |
| 119 | + | } | |
| 120 | + | fmt.Fprintf(os.Stdout, "Artifacts: %s\n", reportPath) //nolint:errcheck | |
| 121 | + | ||
| 122 | + | if cfg.SummaryFile != "" { | |
| 123 | + | knownStates := make(map[string]*sessionState) | |
| 124 | + | for _, s := range finalSessions { | |
| 125 | + | st := &sessionState{ | |
| 126 | + | status: "failed", | |
| 127 | + | exitCode: s.ExitCode, | |
| 128 | + | } | |
| 129 | + | if s.ExitCode == "0" { | |
| 130 | + | st.status = "success" | |
| 131 | + | } | |
| 132 | + | knownStates[s.Short] = st | |
| 133 | + | } | |
| 134 | + | topic := fmt.Sprintf("%s.%s.%s", domain, name, jobID) | |
| 135 | + | if err := writeSummaryMarkdown(cfg.SummaryFile, name, jobID, finalSessions, knownStates, cfg.DebugOnFail && exitCode != 0, topic); err != nil && log != nil { | |
| 136 | + | log.Error("write summary markdown", "err", err, "file", cfg.SummaryFile) | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | if exitCode != 0 { | |
| 141 | + | return &JobFailedError{ExitCode: exitCode} | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | return nil | |
| 145 | + | } |
+49,
-0
| ... | ... | @@ -0,0 +1,49 @@ | |
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "io" | |
| 7 | + | "log/slog" | |
| 8 | + | "os/exec" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestWaitAndReportPlain_Cancellation(t *testing.T) { | |
| 14 | + | if _, err := exec.LookPath("zmx"); err != nil { | |
| 15 | + | t.Skip("zmx not found, skipping integration test") | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 19 | + | cfg := &Cfg{ | |
| 20 | + | Ctx: ctx, | |
| 21 | + | Cancel: cancel, | |
| 22 | + | MonitorInterval: 50 * time.Millisecond, | |
| 23 | + | Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), | |
| 24 | + | ArtifactDir: t.TempDir(), | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | jobID := fmt.Sprintf("plain-cancel-%d", time.Now().UnixNano()) | |
| 28 | + | prefix := "local.testrepo." + jobID + "." | |
| 29 | + | runnerSession := prefix + "runner" | |
| 30 | + | ||
| 31 | + | t.Cleanup(func() { | |
| 32 | + | _ = exec.Command("zmx", "kill", "-f", runnerSession).Run() | |
| 33 | + | }) | |
| 34 | + | ||
| 35 | + | cmd := exec.Command("zmx", "run", runnerSession, "-d", "sleep", "30") | |
| 36 | + | if err := cmd.Run(); err != nil { | |
| 37 | + | t.Fatalf("failed to start zmx session: %v", err) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | go func() { | |
| 41 | + | time.Sleep(100 * time.Millisecond) | |
| 42 | + | cancel() | |
| 43 | + | }() | |
| 44 | + | ||
| 45 | + | err := waitAndReportPlain(cfg, nil, "testrepo", jobID, "local") | |
| 46 | + | if err == nil { | |
| 47 | + | t.Error("expected error when waitAndReportPlain is cancelled, got nil") | |
| 48 | + | } | |
| 49 | + | } |
+863,
-0
| ... | ... | @@ -0,0 +1,863 @@ | |
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "io" | |
| 7 | + | "log/slog" | |
| 8 | + | "os" | |
| 9 | + | "os/exec" | |
| 10 | + | "os/signal" | |
| 11 | + | "path/filepath" | |
| 12 | + | "regexp" | |
| 13 | + | "sort" | |
| 14 | + | "strconv" | |
| 15 | + | "strings" | |
| 16 | + | "sync" | |
| 17 | + | "syscall" | |
| 18 | + | "time" | |
| 19 | + | ||
| 20 | + | "golang.org/x/sys/unix" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // --------------------------------------------------------- | |
| 24 | + | // UI State Data Model | |
| 25 | + | // --------------------------------------------------------- | |
| 26 | + | ||
| 27 | + | type TaskUIState struct { | |
| 28 | + | Name string | |
| 29 | + | Status string // "expected", "running", "success", "failed", "cancelled" | |
| 30 | + | Created int64 | |
| 31 | + | Ended int64 | |
| 32 | + | Duration time.Duration | |
| 33 | + | ExitCode string | |
| 34 | + | LastOutput string // Last non-empty line for running peek | |
| 35 | + | ErrorPreview []string // Captured error lines on failure | |
| 36 | + | PredictedDur time.Duration | |
| 37 | + | PrevStatus string | |
| 38 | + | PrevExitCode int | |
| 39 | + | FailureStreak int | |
| 40 | + | Transition string | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type PipelineUIState struct { | |
| 44 | + | Repo string | |
| 45 | + | Branch string | |
| 46 | + | Commit string | |
| 47 | + | JobID string | |
| 48 | + | StartedAt time.Time | |
| 49 | + | Elapsed time.Duration | |
| 50 | + | Tasks map[string]*TaskUIState | |
| 51 | + | TaskOrder []string | |
| 52 | + | PredictedTotal time.Duration | |
| 53 | + | IsComplete bool | |
| 54 | + | Cancelled bool | |
| 55 | + | Stats *RepoStats | |
| 56 | + | mu sync.Mutex | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var spinnerFrames = []rune{'ā ', 'ā ', 'ā ¹', 'ā ø', 'ā ¼', 'ā “', 'ā ¦', 'ā §', 'ā ', 'ā '} | |
| 60 | + | ||
| 61 | + | func newPipelineUIState(repo, branch, commit, jobID string, stats *RepoStats) *PipelineUIState { | |
| 62 | + | ui := &PipelineUIState{ | |
| 63 | + | Repo: repo, | |
| 64 | + | Branch: branch, | |
| 65 | + | Commit: commit, | |
| 66 | + | JobID: jobID, | |
| 67 | + | StartedAt: time.Now(), | |
| 68 | + | Tasks: make(map[string]*TaskUIState), | |
| 69 | + | Stats: stats, | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | if stats != nil { | |
| 73 | + | ui.PredictedTotal = time.Duration(stats.AvgWallDurationMs) * time.Millisecond | |
| 74 | + | ||
| 75 | + | // Pre-populate expected tasks in deterministic order | |
| 76 | + | var names []string | |
| 77 | + | for name := range stats.Tasks { | |
| 78 | + | names = append(names, name) | |
| 79 | + | } | |
| 80 | + | sort.Strings(names) | |
| 81 | + | ||
| 82 | + | for _, name := range names { | |
| 83 | + | ts := stats.Tasks[name] | |
| 84 | + | ui.Tasks[name] = &TaskUIState{ | |
| 85 | + | Name: name, | |
| 86 | + | Status: "expected", | |
| 87 | + | PredictedDur: time.Duration(ts.AvgDurationMs) * time.Millisecond, | |
| 88 | + | PrevStatus: ts.LastStatus, | |
| 89 | + | PrevExitCode: ts.LastExitCode, | |
| 90 | + | FailureStreak: ts.FailureStreak, | |
| 91 | + | } | |
| 92 | + | ui.TaskOrder = append(ui.TaskOrder, name) | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | return ui | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // --------------------------------------------------------- | |
| 100 | + | // UI Helpers & Formatters | |
| 101 | + | // --------------------------------------------------------- | |
| 102 | + | ||
| 103 | + | var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\([a-zA-Z]`) | |
| 104 | + | ||
| 105 | + | func stripANSI(s string) string { | |
| 106 | + | return ansiRegex.ReplaceAllString(s, "") | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func getTerminalWidth() int { | |
| 110 | + | ws, err := unix.IoctlGetWinsize(int(os.Stdout.Fd()), unix.TIOCGWINSZ) | |
| 111 | + | if err == nil && ws.Col > 0 { | |
| 112 | + | return int(ws.Col) | |
| 113 | + | } | |
| 114 | + | if cols := os.Getenv("COLUMNS"); cols != "" { | |
| 115 | + | if n, err := strconv.Atoi(cols); err == nil && n > 0 { | |
| 116 | + | return n | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | return 80 | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func truncateLine(s string, maxWidth int) string { | |
| 123 | + | if maxWidth <= 0 { | |
| 124 | + | maxWidth = 80 | |
| 125 | + | } | |
| 126 | + | clean := stripANSI(s) | |
| 127 | + | runes := []rune(clean) | |
| 128 | + | if len(runes) <= maxWidth { | |
| 129 | + | return s | |
| 130 | + | } | |
| 131 | + | if maxWidth > 3 { | |
| 132 | + | return string(runes[:maxWidth-3]) + "..." | |
| 133 | + | } | |
| 134 | + | return string(runes[:maxWidth]) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func extractLastOutputLine(raw string) string { | |
| 138 | + | clean := stripANSI(raw) | |
| 139 | + | lines := strings.Split(clean, "\n") | |
| 140 | + | for i := len(lines) - 1; i >= 0; i-- { | |
| 141 | + | line := strings.TrimSpace(lines[i]) | |
| 142 | + | if line != "" && !strings.HasPrefix(line, "step starting") && !strings.HasPrefix(line, "step completed") { | |
| 143 | + | return line | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | return "" | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func extractErrorPreview(raw string, maxLines int) []string { | |
| 150 | + | clean := stripANSI(raw) | |
| 151 | + | lines := strings.Split(clean, "\n") | |
| 152 | + | var nonEmpties []string | |
| 153 | + | for _, l := range lines { | |
| 154 | + | trimmed := strings.TrimRight(l, "\r\n ") | |
| 155 | + | if trimmed != "" { | |
| 156 | + | nonEmpties = append(nonEmpties, trimmed) | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | if len(nonEmpties) == 0 { | |
| 160 | + | return nil | |
| 161 | + | } | |
| 162 | + | if len(nonEmpties) > maxLines { | |
| 163 | + | nonEmpties = nonEmpties[len(nonEmpties)-maxLines:] | |
| 164 | + | } | |
| 165 | + | return nonEmpties | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func renderProgressBar(elapsed, predicted time.Duration, width int) (string, int) { | |
| 169 | + | if width <= 0 { | |
| 170 | + | width = 10 | |
| 171 | + | } | |
| 172 | + | if predicted <= 0 { | |
| 173 | + | return "[" + strings.Repeat("ā", width) + "]", 100 | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | pct := int((float64(elapsed) / float64(predicted)) * 100) | |
| 177 | + | displayPct := pct | |
| 178 | + | if displayPct > 100 { | |
| 179 | + | displayPct = 100 | |
| 180 | + | } | |
| 181 | + | if displayPct < 0 { | |
| 182 | + | displayPct = 0 | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | filled := (displayPct * width) / 100 | |
| 186 | + | unfilled := width - filled | |
| 187 | + | if unfilled < 0 { | |
| 188 | + | unfilled = 0 | |
| 189 | + | } | |
| 190 | + | bar := "[" + strings.Repeat("ā", filled) + strings.Repeat("ā", unfilled) + "]" | |
| 191 | + | return bar, displayPct | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func renderErrorPreviewBox(lines []string, width int) string { | |
| 195 | + | if len(lines) == 0 { | |
| 196 | + | return "" | |
| 197 | + | } | |
| 198 | + | if width < 30 { | |
| 199 | + | width = 60 | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | var sb strings.Builder | |
| 203 | + | headerTitle := "āā [error preview] " | |
| 204 | + | headerLine := headerTitle + strings.Repeat("ā", max(0, width-len(headerTitle))) | |
| 205 | + | sb.WriteString(" " + headerLine + "\n") | |
| 206 | + | ||
| 207 | + | for _, line := range lines { | |
| 208 | + | if len(line) > width-4 { | |
| 209 | + | line = line[:width-7] + "..." | |
| 210 | + | } | |
| 211 | + | fmt.Fprintf(&sb, " ā %s\n", line) | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | footer := "ā" + strings.Repeat("ā", max(0, width-1)) | |
| 215 | + | sb.WriteString(" " + footer) | |
| 216 | + | return sb.String() | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func formatShortDuration(d time.Duration) string { | |
| 220 | + | secs := d.Seconds() | |
| 221 | + | if secs < 0 { | |
| 222 | + | secs = 0 | |
| 223 | + | } | |
| 224 | + | if secs >= 60 { | |
| 225 | + | m := int(secs) / 60 | |
| 226 | + | s := int(secs) % 60 | |
| 227 | + | return fmt.Sprintf("%dm%ds", m, s) | |
| 228 | + | } | |
| 229 | + | if secs >= 10 { | |
| 230 | + | return fmt.Sprintf("%.1fs", secs) | |
| 231 | + | } | |
| 232 | + | return fmt.Sprintf("%.1fs", secs) | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // --------------------------------------------------------- | |
| 236 | + | // Full Interactive UI State Rendering | |
| 237 | + | // --------------------------------------------------------- | |
| 238 | + | ||
| 239 | + | func (ui *PipelineUIState) Render(frame int) string { | |
| 240 | + | ui.mu.Lock() | |
| 241 | + | defer ui.mu.Unlock() | |
| 242 | + | ||
| 243 | + | var sb strings.Builder | |
| 244 | + | commitShort := ui.Commit | |
| 245 | + | if len(commitShort) > 7 { | |
| 246 | + | commitShort = commitShort[:7] | |
| 247 | + | } | |
| 248 | + | if commitShort == "" { | |
| 249 | + | commitShort = "HEAD" | |
| 250 | + | } | |
| 251 | + | branch := ui.Branch | |
| 252 | + | if branch == "" { | |
| 253 | + | branch = "local" | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | // Header line 1: repo info | |
| 257 | + | fmt.Fprintf(&sb, "š pici: %s (branch: %s ⢠%s)\n", ui.Repo, branch, commitShort) | |
| 258 | + | ||
| 259 | + | // Calculate counts | |
| 260 | + | totalTasks := len(ui.TaskOrder) | |
| 261 | + | completedCount := 0 | |
| 262 | + | failedCount := 0 | |
| 263 | + | for _, name := range ui.TaskOrder { | |
| 264 | + | task := ui.Tasks[name] | |
| 265 | + | if task.Status == "success" || task.Status == "failed" { | |
| 266 | + | completedCount++ | |
| 267 | + | } | |
| 268 | + | if task.Status == "failed" { | |
| 269 | + | failedCount++ | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | elapsed := ui.Elapsed | |
| 274 | + | if elapsed == 0 { | |
| 275 | + | elapsed = time.Since(ui.StartedAt) | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | // Header line 2: timing & status | |
| 279 | + | fmt.Fprintf(&sb, "ā± Running for %s [ ", formatShortDuration(elapsed)) | |
| 280 | + | if failedCount > 0 { | |
| 281 | + | fmt.Fprintf(&sb, "%d/%d completed ⢠%d failed ]\n", completedCount, totalTasks, failedCount) | |
| 282 | + | } else if completedCount == 0 { | |
| 283 | + | estTotal := "~" + formatShortDuration(ui.PredictedTotal) | |
| 284 | + | if ui.PredictedTotal == 0 { | |
| 285 | + | estTotal = "estimating" | |
| 286 | + | } | |
| 287 | + | fmt.Fprintf(&sb, "0/%d tasks ⢠est. %s ]\n", totalTasks, estTotal) | |
| 288 | + | } else { | |
| 289 | + | rem := ui.PredictedTotal - elapsed | |
| 290 | + | if rem < 0 { | |
| 291 | + | rem = 0 | |
| 292 | + | } | |
| 293 | + | fmt.Fprintf(&sb, "%d/%d completed ⢠est. ~%s remaining ]\n", completedCount, totalTasks, formatShortDuration(rem)) | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | sb.WriteString("\n") | |
| 297 | + | ||
| 298 | + | spinnerRune := spinnerFrames[frame%len(spinnerFrames)] | |
| 299 | + | ||
| 300 | + | // Render task list | |
| 301 | + | for _, name := range ui.TaskOrder { | |
| 302 | + | task := ui.Tasks[name] | |
| 303 | + | switch task.Status { | |
| 304 | + | case "expected": | |
| 305 | + | pred := "" | |
| 306 | + | if task.PredictedDur > 0 { | |
| 307 | + | pred = fmt.Sprintf("~%s", formatShortDuration(task.PredictedDur)) | |
| 308 | + | } | |
| 309 | + | notes := "" | |
| 310 | + | if task.PrevStatus == "failed" { | |
| 311 | + | notes = " ⢠ā ļø failed last run" | |
| 312 | + | } | |
| 313 | + | detail := "" | |
| 314 | + | if pred != "" || notes != "" { | |
| 315 | + | detail = fmt.Sprintf("(expected %s%s)", pred, notes) | |
| 316 | + | } | |
| 317 | + | fmt.Fprintf(&sb, " ā %-8s %s\n", task.Name, detail) | |
| 318 | + | ||
| 319 | + | case "running": | |
| 320 | + | taskDur := task.Duration | |
| 321 | + | if taskDur == 0 && task.Created > 0 { | |
| 322 | + | taskDur = time.Duration(time.Now().Unix()-task.Created) * time.Second | |
| 323 | + | } | |
| 324 | + | predStr := "" | |
| 325 | + | var bar string | |
| 326 | + | var pct int | |
| 327 | + | if task.PredictedDur > 0 { | |
| 328 | + | predStr = fmt.Sprintf("/ ~%s", formatShortDuration(task.PredictedDur)) | |
| 329 | + | bar, pct = renderProgressBar(taskDur, task.PredictedDur, 10) | |
| 330 | + | } | |
| 331 | + | extraNote := "" | |
| 332 | + | if task.PrevStatus == "failed" { | |
| 333 | + | extraNote = " (retesting previous failure)" | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | barPart := "" | |
| 337 | + | if bar != "" { | |
| 338 | + | barPart = fmt.Sprintf(" %s %d%%%s", bar, pct, extraNote) | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | fmt.Fprintf(&sb, " %c %-8s %s %s%s\n", spinnerRune, task.Name, formatShortDuration(taskDur), predStr, barPart) | |
| 342 | + | if task.LastOutput != "" { | |
| 343 | + | fmt.Fprintf(&sb, " āā %s\n", task.LastOutput) | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | case "success": | |
| 347 | + | transitionNote := "" | |
| 348 | + | if task.Transition == "fixed" { | |
| 349 | + | transitionNote = " ⢠š fixed!" | |
| 350 | + | } | |
| 351 | + | durStr := formatShortDuration(task.Duration) | |
| 352 | + | if transitionNote != "" { | |
| 353 | + | fmt.Fprintf(&sb, " ā %-8s %s (%s)\n", task.Name, durStr, strings.TrimPrefix(transitionNote, " ⢠")) | |
| 354 | + | } else { | |
| 355 | + | fmt.Fprintf(&sb, " ā %-8s %s\n", task.Name, durStr) | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | case "failed": | |
| 359 | + | notes := "" | |
| 360 | + | switch task.Transition { | |
| 361 | + | case "still_failing": | |
| 362 | + | notes = " ⢠ā ļø still failing" | |
| 363 | + | case "new_failure": | |
| 364 | + | notes = " ⢠ā new failure" | |
| 365 | + | } | |
| 366 | + | exitStr := "exit 1" | |
| 367 | + | if task.ExitCode != "" { | |
| 368 | + | exitStr = "exit " + task.ExitCode | |
| 369 | + | } | |
| 370 | + | fmt.Fprintf(&sb, " ā %-8s %s (%s%s)\n", task.Name, formatShortDuration(task.Duration), exitStr, notes) | |
| 371 | + | if len(task.ErrorPreview) > 0 { | |
| 372 | + | sb.WriteString(renderErrorPreviewBox(task.ErrorPreview, 65)) | |
| 373 | + | sb.WriteString("\n") | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | case "cancelled": | |
| 377 | + | fmt.Fprintf(&sb, " ā %-8s cancelled\n", task.Name) | |
| 378 | + | } | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | return sb.String() | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | // RenderSummary renders the post-run completion summary. | |
| 385 | + | func (ui *PipelineUIState) RenderSummary(reportPath string, exitCode int) string { | |
| 386 | + | ui.mu.Lock() | |
| 387 | + | defer ui.mu.Unlock() | |
| 388 | + | ||
| 389 | + | var sb strings.Builder | |
| 390 | + | totalTasks := len(ui.TaskOrder) | |
| 391 | + | failedCount := 0 | |
| 392 | + | var failedTasks []*TaskUIState | |
| 393 | + | ||
| 394 | + | for _, name := range ui.TaskOrder { | |
| 395 | + | task := ui.Tasks[name] | |
| 396 | + | if task.Status == "failed" { | |
| 397 | + | failedCount++ | |
| 398 | + | failedTasks = append(failedTasks, task) | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | if exitCode != 0 && failedCount == 0 { | |
| 403 | + | failedCount = 1 | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | elapsed := ui.Elapsed | |
| 407 | + | if elapsed == 0 { | |
| 408 | + | elapsed = time.Since(ui.StartedAt) | |
| 409 | + | } | |
| 410 | + | wallStr := formatShortDuration(elapsed) | |
| 411 | + | ||
| 412 | + | if exitCode == 0 && failedCount == 0 { | |
| 413 | + | fmt.Fprintf(&sb, "ā All %d tasks succeeded (total time: %s)\n\n", totalTasks, wallStr) | |
| 414 | + | for _, name := range ui.TaskOrder { | |
| 415 | + | task := ui.Tasks[name] | |
| 416 | + | avgStr := "" | |
| 417 | + | if task.PredictedDur > 0 { | |
| 418 | + | avgStr = fmt.Sprintf("(avg %s", formatShortDuration(task.PredictedDur)) | |
| 419 | + | if task.Transition == "fixed" { | |
| 420 | + | avgStr += " ⢠š fixed!)" | |
| 421 | + | } else { | |
| 422 | + | avgStr += ")" | |
| 423 | + | } | |
| 424 | + | } | |
| 425 | + | fmt.Fprintf(&sb, " ā %-9s %6s %s\n", task.Name, formatShortDuration(task.Duration), avgStr) | |
| 426 | + | } | |
| 427 | + | } else { | |
| 428 | + | taskWord := "task" | |
| 429 | + | if failedCount > 1 { | |
| 430 | + | taskWord = "tasks" | |
| 431 | + | } | |
| 432 | + | fmt.Fprintf(&sb, "ā %d %s failed (total time: %s)\n\n", failedCount, taskWord, wallStr) | |
| 433 | + | for _, name := range ui.TaskOrder { | |
| 434 | + | task := ui.Tasks[name] | |
| 435 | + | switch task.Status { | |
| 436 | + | case "success": | |
| 437 | + | fmt.Fprintf(&sb, " ā %-9s %6s\n", task.Name, formatShortDuration(task.Duration)) | |
| 438 | + | case "failed": | |
| 439 | + | exitStr := "exit " + task.ExitCode | |
| 440 | + | if task.ExitCode == "" { | |
| 441 | + | exitStr = "exit 1" | |
| 442 | + | } | |
| 443 | + | fmt.Fprintf(&sb, " ā %-9s %6s %s (see error preview above)\n", task.Name, formatShortDuration(task.Duration), exitStr) | |
| 444 | + | default: | |
| 445 | + | fmt.Fprintf(&sb, " ā %-9s cancelled\n", task.Name) | |
| 446 | + | } | |
| 447 | + | } | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | sb.WriteString("\nArtifacts:\n") | |
| 451 | + | if reportPath != "" { | |
| 452 | + | fmt.Fprintf(&sb, " š Report: %s\n", reportPath) | |
| 453 | + | } | |
| 454 | + | if failedCount > 0 { | |
| 455 | + | if len(failedTasks) > 0 { | |
| 456 | + | for _, ft := range failedTasks { | |
| 457 | + | fmt.Fprintf(&sb, " š Debug: zmx attach local.%s.%s.step.%s\n", ui.Repo, ui.JobID, ft.Name) | |
| 458 | + | } | |
| 459 | + | } else { | |
| 460 | + | fmt.Fprintf(&sb, " š Debug: zmx attach local.%s.%s.runner\n", ui.Repo, ui.JobID) | |
| 461 | + | } | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | return sb.String() | |
| 465 | + | } | |
| 466 | + | ||
| 467 | + | // --------------------------------------------------------- | |
| 468 | + | // Non-TTY Plain Stream Reporter | |
| 469 | + | // --------------------------------------------------------- | |
| 470 | + | ||
| 471 | + | type plainStreamReporter struct { | |
| 472 | + | out io.Writer | |
| 473 | + | ui *PipelineUIState | |
| 474 | + | log *slog.Logger | |
| 475 | + | } | |
| 476 | + | ||
| 477 | + | func (p *plainStreamReporter) OnTaskStart(name string) { | |
| 478 | + | fmt.Fprintf(p.out, "ā¶ starting %s\n", name) //nolint:errcheck | |
| 479 | + | } | |
| 480 | + | ||
| 481 | + | func (p *plainStreamReporter) OnTaskOutput(name, line string) { | |
| 482 | + | if line != "" { | |
| 483 | + | fmt.Fprintf(p.out, " [%s] %s\n", name, line) //nolint:errcheck | |
| 484 | + | } | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | func (p *plainStreamReporter) OnTaskComplete(name string, exitCode int, dur time.Duration, errorLines []string) { | |
| 488 | + | durStr := formatShortDuration(dur) | |
| 489 | + | if exitCode == 0 { | |
| 490 | + | fmt.Fprintf(p.out, "ā %s succeeded (%s)\n", name, durStr) //nolint:errcheck | |
| 491 | + | } else { | |
| 492 | + | fmt.Fprintf(p.out, "ā %s failed with exit %d (%s)\n", name, exitCode, durStr) //nolint:errcheck | |
| 493 | + | if len(errorLines) > 0 { | |
| 494 | + | for _, line := range errorLines { | |
| 495 | + | fmt.Fprintf(p.out, " ā %s\n", line) //nolint:errcheck | |
| 496 | + | } | |
| 497 | + | } | |
| 498 | + | } | |
| 499 | + | } | |
| 500 | + | ||
| 501 | + | // --------------------------------------------------------- | |
| 502 | + | // Interactive Controller Loop | |
| 503 | + | // --------------------------------------------------------- | |
| 504 | + | ||
| 505 | + | type interactiveController struct { | |
| 506 | + | ctx context.Context | |
| 507 | + | out io.Writer | |
| 508 | + | ui *PipelineUIState | |
| 509 | + | isTTY bool | |
| 510 | + | rendered int // number of lines rendered in previous frame for cursor overwrite | |
| 511 | + | streamer *plainStreamReporter | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | func newInteractiveController(ctx context.Context, out io.Writer, repo, branch, commit, jobID string, stats *RepoStats, isTTY bool) *interactiveController { | |
| 515 | + | if ctx == nil { | |
| 516 | + | ctx = context.Background() | |
| 517 | + | } | |
| 518 | + | ui := newPipelineUIState(repo, branch, commit, jobID, stats) | |
| 519 | + | return &interactiveController{ | |
| 520 | + | ctx: ctx, | |
| 521 | + | out: out, | |
| 522 | + | ui: ui, | |
| 523 | + | isTTY: isTTY, | |
| 524 | + | streamer: &plainStreamReporter{out: out, ui: ui}, | |
| 525 | + | } | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | func (c *interactiveController) WaitUntilDone(fetchSessions func() ([]SessionInfo, bool)) error { | |
| 529 | + | sigCh := make(chan os.Signal, 1) | |
| 530 | + | signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) | |
| 531 | + | defer signal.Stop(sigCh) | |
| 532 | + | ||
| 533 | + | ticker := time.NewTicker(100 * time.Millisecond) | |
| 534 | + | defer ticker.Stop() | |
| 535 | + | ||
| 536 | + | frame := 0 | |
| 537 | + | ||
| 538 | + | // Initial render | |
| 539 | + | c.redraw(c.ui.Render(frame)) | |
| 540 | + | ||
| 541 | + | prevStatus := make(map[string]string) | |
| 542 | + | prevOutput := make(map[string]string) | |
| 543 | + | ||
| 544 | + | for { | |
| 545 | + | select { | |
| 546 | + | case <-c.ctx.Done(): | |
| 547 | + | c.ui.Cancelled = true | |
| 548 | + | c.clear() | |
| 549 | + | fmt.Fprintln(c.out, "\nā¹ cancelling job and terminating active sessions...") //nolint:errcheck | |
| 550 | + | return c.ctx.Err() | |
| 551 | + | ||
| 552 | + | case <-sigCh: | |
| 553 | + | c.ui.Cancelled = true | |
| 554 | + | c.clear() | |
| 555 | + | fmt.Fprintln(c.out, "\n^C") //nolint:errcheck | |
| 556 | + | fmt.Fprintln(c.out, "ā¹ cancelling job and terminating active sessions...") //nolint:errcheck | |
| 557 | + | return fmt.Errorf("job cancelled by signal") | |
| 558 | + | ||
| 559 | + | case <-ticker.C: | |
| 560 | + | frame++ | |
| 561 | + | sessions, isComplete := fetchSessions() | |
| 562 | + | ||
| 563 | + | // Update UI state with current sessions | |
| 564 | + | c.ui.mu.Lock() | |
| 565 | + | for _, s := range sessions { | |
| 566 | + | name := s.Short | |
| 567 | + | if name == "runner" || name == "" { | |
| 568 | + | continue | |
| 569 | + | } | |
| 570 | + | ||
| 571 | + | task, exists := c.ui.Tasks[name] | |
| 572 | + | if !exists { | |
| 573 | + | task = &TaskUIState{ | |
| 574 | + | Name: name, | |
| 575 | + | Status: "running", | |
| 576 | + | } | |
| 577 | + | c.ui.Tasks[name] = task | |
| 578 | + | c.ui.TaskOrder = append(c.ui.TaskOrder, name) | |
| 579 | + | } | |
| 580 | + | ||
| 581 | + | created, _ := strconv.ParseInt(s.Created, 10, 64) | |
| 582 | + | ended, _ := strconv.ParseInt(s.Ended, 10, 64) | |
| 583 | + | task.Created = created | |
| 584 | + | task.Ended = ended | |
| 585 | + | ||
| 586 | + | oldState := prevStatus[name] | |
| 587 | + | if s.Ended == "" { | |
| 588 | + | task.Status = "running" | |
| 589 | + | if created > 0 { | |
| 590 | + | task.Duration = time.Duration(time.Now().Unix()-created) * time.Second | |
| 591 | + | } | |
| 592 | + | if !c.isTTY && (oldState == "" || oldState == "expected") { | |
| 593 | + | c.streamer.OnTaskStart(name) | |
| 594 | + | } | |
| 595 | + | if !c.isTTY && task.LastOutput != "" && task.LastOutput != prevOutput[name] { | |
| 596 | + | c.streamer.OnTaskOutput(name, task.LastOutput) | |
| 597 | + | prevOutput[name] = task.LastOutput | |
| 598 | + | } | |
| 599 | + | } else { | |
| 600 | + | task.ExitCode = s.ExitCode | |
| 601 | + | exitCodeInt, _ := strconv.Atoi(s.ExitCode) | |
| 602 | + | if s.ExitCode == "0" { | |
| 603 | + | task.Status = "success" | |
| 604 | + | } else { | |
| 605 | + | task.Status = "failed" | |
| 606 | + | } | |
| 607 | + | if created > 0 && ended > 0 { | |
| 608 | + | task.Duration = time.Duration(ended-created) * time.Second | |
| 609 | + | } | |
| 610 | + | if task.Transition == "" && c.ui.Stats != nil { | |
| 611 | + | task.Transition = computeOutcomeTransition(c.ui.Stats.Tasks[name], exitCodeInt) | |
| 612 | + | } | |
| 613 | + | if !c.isTTY && oldState != task.Status { | |
| 614 | + | c.streamer.OnTaskComplete(name, exitCodeInt, task.Duration, task.ErrorPreview) | |
| 615 | + | } | |
| 616 | + | } | |
| 617 | + | prevStatus[name] = task.Status | |
| 618 | + | } | |
| 619 | + | c.ui.mu.Unlock() | |
| 620 | + | ||
| 621 | + | c.redraw(c.ui.Render(frame)) | |
| 622 | + | ||
| 623 | + | if isComplete { | |
| 624 | + | c.ui.Elapsed = time.Since(c.ui.StartedAt) | |
| 625 | + | c.ui.IsComplete = true | |
| 626 | + | c.clear() | |
| 627 | + | return nil | |
| 628 | + | } | |
| 629 | + | } | |
| 630 | + | } | |
| 631 | + | } | |
| 632 | + | ||
| 633 | + | func (c *interactiveController) redraw(outStr string) { | |
| 634 | + | if !c.isTTY { | |
| 635 | + | return | |
| 636 | + | } | |
| 637 | + | trimmed := strings.TrimRight(outStr, "\n") | |
| 638 | + | rawLines := strings.Split(trimmed, "\n") | |
| 639 | + | if len(rawLines) == 0 || (len(rawLines) == 1 && rawLines[0] == "") { | |
| 640 | + | return | |
| 641 | + | } | |
| 642 | + | ||
| 643 | + | termWidth := getTerminalWidth() | |
| 644 | + | lines := make([]string, len(rawLines)) | |
| 645 | + | for i, l := range rawLines { | |
| 646 | + | lines[i] = truncateLine(l, termWidth-1) | |
| 647 | + | } | |
| 648 | + | ||
| 649 | + | var b strings.Builder | |
| 650 | + | ||
| 651 | + | // Move cursor up from the bottom of previous render block to the top line | |
| 652 | + | if c.rendered > 0 { | |
| 653 | + | fmt.Fprintf(&b, "\r\033[%dA", c.rendered) | |
| 654 | + | } | |
| 655 | + | ||
| 656 | + | // Print each line, clearing to the end of the line and resetting carriage | |
| 657 | + | for i, line := range lines { | |
| 658 | + | b.WriteString("\r\033[2K") // clear entire line | |
| 659 | + | b.WriteString(line) | |
| 660 | + | if i < len(lines)-1 { | |
| 661 | + | b.WriteString("\n") | |
| 662 | + | } | |
| 663 | + | } | |
| 664 | + | ||
| 665 | + | // If the previous frame had more lines, clear the leftover trailing lines | |
| 666 | + | if c.rendered > len(lines)-1 { | |
| 667 | + | extra := c.rendered - (len(lines) - 1) | |
| 668 | + | for i := 0; i < extra; i++ { | |
| 669 | + | b.WriteString("\n\r\033[2K") | |
| 670 | + | } | |
| 671 | + | fmt.Fprintf(&b, "\r\033[%dA", extra) | |
| 672 | + | } | |
| 673 | + | ||
| 674 | + | c.rendered = len(lines) - 1 | |
| 675 | + | fmt.Fprint(c.out, b.String()) //nolint:errcheck | |
| 676 | + | } | |
| 677 | + | ||
| 678 | + | func (c *interactiveController) clear() { | |
| 679 | + | if !c.isTTY || c.rendered < 0 { | |
| 680 | + | return | |
| 681 | + | } | |
| 682 | + | var b strings.Builder | |
| 683 | + | if c.rendered > 0 { | |
| 684 | + | fmt.Fprintf(&b, "\r\033[%dA", c.rendered) | |
| 685 | + | } | |
| 686 | + | for i := 0; i <= c.rendered; i++ { | |
| 687 | + | b.WriteString("\r\033[2K") | |
| 688 | + | if i < c.rendered { | |
| 689 | + | b.WriteString("\n") | |
| 690 | + | } | |
| 691 | + | } | |
| 692 | + | if c.rendered > 0 { | |
| 693 | + | fmt.Fprintf(&b, "\r\033[%dA", c.rendered) | |
| 694 | + | } | |
| 695 | + | c.rendered = 0 | |
| 696 | + | fmt.Fprint(c.out, b.String()) //nolint:errcheck | |
| 697 | + | } | |
| 698 | + | ||
| 699 | + | // --------------------------------------------------------- | |
| 700 | + | // Interactive waitAndReport Entrypoint | |
| 701 | + | // --------------------------------------------------------- | |
| 702 | + | ||
| 703 | + | func isTerminal(w io.Writer) bool { | |
| 704 | + | if os.Getenv("CI") != "" || os.Getenv("TERM") == "dumb" { | |
| 705 | + | return false | |
| 706 | + | } | |
| 707 | + | if f, ok := w.(*os.File); ok { | |
| 708 | + | stat, err := f.Stat() | |
| 709 | + | if err == nil && (stat.Mode()&os.ModeCharDevice) != 0 { | |
| 710 | + | return true | |
| 711 | + | } | |
| 712 | + | } | |
| 713 | + | return false | |
| 714 | + | } | |
| 715 | + | ||
| 716 | + | // waitAndReportUI executes the rich interactive reporting loop, updating stats.json. | |
| 717 | + | func waitAndReportUI(cfg *Cfg, log *slog.Logger, repoName, jobID, eventType, branch, commit string) error { | |
| 718 | + | domain := getDomain(eventType) | |
| 719 | + | prefix := fmt.Sprintf("%s.%s.%s.", domain, repoName, jobID) | |
| 720 | + | ||
| 721 | + | stats, _ := loadRepoStats(repoName) | |
| 722 | + | isTTY := isTerminal(os.Stdout) | |
| 723 | + | ||
| 724 | + | ctrl := newInteractiveController(cfg.Ctx, os.Stdout, repoName, branch, commit, jobID, stats, isTTY) | |
| 725 | + | ||
| 726 | + | lastStage := time.Now() | |
| 727 | + | ||
| 728 | + | // Session poller & peek fetcher | |
| 729 | + | fetchSessions := func() ([]SessionInfo, bool) { | |
| 730 | + | listOutput, err := exec.Command("zmx", "list").CombinedOutput() | |
| 731 | + | if err != nil { | |
| 732 | + | if log != nil { | |
| 733 | + | log.Error("zmx list", "err", err) | |
| 734 | + | } | |
| 735 | + | return nil, false | |
| 736 | + | } | |
| 737 | + | ||
| 738 | + | sessions := parseZMXList(string(listOutput)) | |
| 739 | + | var jobSessions []SessionInfo | |
| 740 | + | for _, s := range sessions { | |
| 741 | + | if strings.HasPrefix(s.Name, prefix) { | |
| 742 | + | s.Short = cleanSessionShort(s.Name, prefix, repoName, jobID) | |
| 743 | + | jobSessions = append(jobSessions, s) | |
| 744 | + | } | |
| 745 | + | } | |
| 746 | + | ||
| 747 | + | // Update tail history for live activity and error peeks | |
| 748 | + | for _, s := range jobSessions { | |
| 749 | + | if s.Short == "runner" || s.Short == "" { | |
| 750 | + | continue | |
| 751 | + | } | |
| 752 | + | hist, err := fetchHistoryPlain(s.Name) | |
| 753 | + | if err == nil && hist != "" { | |
| 754 | + | ctrl.ui.mu.Lock() | |
| 755 | + | if task, ok := ctrl.ui.Tasks[s.Short]; ok { | |
| 756 | + | if s.Ended == "" { | |
| 757 | + | task.LastOutput = extractLastOutputLine(hist) | |
| 758 | + | } else if s.ExitCode != "0" && len(task.ErrorPreview) == 0 { | |
| 759 | + | task.ErrorPreview = extractErrorPreview(hist, 5) | |
| 760 | + | } | |
| 761 | + | } | |
| 762 | + | ctrl.ui.mu.Unlock() | |
| 763 | + | } | |
| 764 | + | } | |
| 765 | + | ||
| 766 | + | // Stage local artifacts periodically (every 2 seconds) so reports stay updated live without disk trashing | |
| 767 | + | if time.Since(lastStage) >= 2*time.Second { | |
| 768 | + | _ = stageLocalArtifacts(cfg, log, repoName, jobID, eventType) | |
| 769 | + | lastStage = time.Now() | |
| 770 | + | } | |
| 771 | + | ||
| 772 | + | return jobSessions, isJobComplete(jobSessions) | |
| 773 | + | } | |
| 774 | + | ||
| 775 | + | waitErr := ctrl.WaitUntilDone(fetchSessions) | |
| 776 | + | if waitErr != nil { | |
| 777 | + | cancelJobSessions(prefix) | |
| 778 | + | if ctrl.ui.Cancelled { | |
| 779 | + | for _, task := range ctrl.ui.Tasks { | |
| 780 | + | if task.Status == "running" { | |
| 781 | + | task.Status = "cancelled" | |
| 782 | + | } | |
| 783 | + | } | |
| 784 | + | fmt.Fprintln(os.Stdout, "ā¹ job cancelled") //nolint:errcheck | |
| 785 | + | } | |
| 786 | + | return waitErr | |
| 787 | + | } | |
| 788 | + | ||
| 789 | + | // Fetch final session list | |
| 790 | + | var finalSessions []SessionInfo | |
| 791 | + | if listOutput, err := exec.Command("zmx", "list").CombinedOutput(); err == nil { | |
| 792 | + | for _, s := range parseZMXList(string(listOutput)) { | |
| 793 | + | if strings.HasPrefix(s.Name, prefix) { | |
| 794 | + | s.Short = cleanSessionShort(s.Name, prefix, repoName, jobID) | |
| 795 | + | finalSessions = append(finalSessions, s) | |
| 796 | + | } | |
| 797 | + | } | |
| 798 | + | } | |
| 799 | + | ||
| 800 | + | exitCode, overallStatus := resolveJobExitCode(finalSessions) | |
| 801 | + | ||
| 802 | + | // Update historical stats.json | |
| 803 | + | if stats != nil { | |
| 804 | + | for _, s := range finalSessions { | |
| 805 | + | if s.Short == "runner" || s.Short == "" { | |
| 806 | + | continue | |
| 807 | + | } | |
| 808 | + | created, _ := strconv.ParseInt(s.Created, 10, 64) | |
| 809 | + | ended, _ := strconv.ParseInt(s.Ended, 10, 64) | |
| 810 | + | dur := time.Duration(ended-created) * time.Second | |
| 811 | + | code, _ := strconv.Atoi(s.ExitCode) | |
| 812 | + | updateTaskStats(stats, s.Short, dur, code) | |
| 813 | + | } | |
| 814 | + | updateRepoStats(stats, ctrl.ui.Elapsed, overallStatus) | |
| 815 | + | _ = saveRepoStats(stats) | |
| 816 | + | } | |
| 817 | + | ||
| 818 | + | // Output summary | |
| 819 | + | reportPath := filepath.Join(cfg.ArtifactDir, repoName, jobID, "index.html") | |
| 820 | + | fmt.Fprintln(os.Stdout) //nolint:errcheck | |
| 821 | + | fmt.Fprint(os.Stdout, ctrl.ui.RenderSummary(reportPath, exitCode)) //nolint:errcheck | |
| 822 | + | ||
| 823 | + | // Debug bridge if failed | |
| 824 | + | topic := fmt.Sprintf("%s.%s.%s", domain, repoName, jobID) | |
| 825 | + | if exitCode != 0 && cfg.DebugOnFail { | |
| 826 | + | cwd, _ := os.Getwd() | |
| 827 | + | debugCfg := DebugBridgeConfig{ | |
| 828 | + | Topic: topic, | |
| 829 | + | KeyLocation: cfg.KeyLocation, | |
| 830 | + | CertLocation: cfg.CertificateLocation, | |
| 831 | + | WorkDir: cwd, | |
| 832 | + | Timeout: cfg.DebugTimeout, | |
| 833 | + | Logger: log, | |
| 834 | + | Stdout: os.Stdout, | |
| 835 | + | } | |
| 836 | + | if err := startDebugBridge(cfg.Ctx, debugCfg); err != nil && log != nil { | |
| 837 | + | log.Error("debug bridge failed", "err", err) | |
| 838 | + | } | |
| 839 | + | } | |
| 840 | + | ||
| 841 | + | if cfg.SummaryFile != "" { | |
| 842 | + | knownStates := make(map[string]*sessionState) | |
| 843 | + | for _, s := range finalSessions { | |
| 844 | + | st := &sessionState{ | |
| 845 | + | status: "failed", | |
| 846 | + | exitCode: s.ExitCode, | |
| 847 | + | } | |
| 848 | + | if s.ExitCode == "0" { | |
| 849 | + | st.status = "success" | |
| 850 | + | } | |
| 851 | + | knownStates[s.Short] = st | |
| 852 | + | } | |
| 853 | + | if err := writeSummaryMarkdown(cfg.SummaryFile, repoName, jobID, finalSessions, knownStates, cfg.DebugOnFail && exitCode != 0, topic); err != nil && log != nil { | |
| 854 | + | log.Error("write summary markdown", "err", err, "file", cfg.SummaryFile) | |
| 855 | + | } | |
| 856 | + | } | |
| 857 | + | ||
| 858 | + | if exitCode != 0 { | |
| 859 | + | return &JobFailedError{ExitCode: exitCode} | |
| 860 | + | } | |
| 861 | + | ||
| 862 | + | return nil | |
| 863 | + | } |
+334,
-0
| ... | ... | @@ -0,0 +1,334 @@ | |
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func 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 | + | ||
| 51 | + | func 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 | + | ||
| 75 | + | func 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 | + | ||
| 93 | + | func TestUI_LiveActivityPeekExtraction(t *testing.T) { | |
| 94 | + | rawHistory := "step starting\n\n \x1b[32mrunning golangci-lint on 28 packages...\x1b[0m\n\n" | |
| 95 | + | peek := extractLastOutputLine(rawHistory) | |
| 96 | + | if peek != "running golangci-lint on 28 packages..." { | |
| 97 | + | t.Errorf("expected clean peek line, got '%s'", peek) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | emptyHistory := " \n\n\x1b[0m\n" | |
| 101 | + | peekEmpty := extractLastOutputLine(emptyHistory) | |
| 102 | + | if peekEmpty != "" { | |
| 103 | + | t.Errorf("expected empty peek, got '%s'", peekEmpty) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestUI_RenderStateTransitions(t *testing.T) { | |
| 108 | + | stats := &RepoStats{ | |
| 109 | + | Repo: "my-repo", | |
| 110 | + | AvgWallDurationMs: 14000, | |
| 111 | + | Tasks: map[string]*TaskStats{ | |
| 112 | + | "fmt": {AvgDurationMs: 850, LastStatus: "success", LastExitCode: 0}, | |
| 113 | + | "lint": {AvgDurationMs: 4200, LastStatus: "failed", LastExitCode: 1, FailureStreak: 1}, | |
| 114 | + | "test": {AvgDurationMs: 13500, LastStatus: "success", LastExitCode: 0}, | |
| 115 | + | }, | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | ui := newPipelineUIState("my-repo", "main", "8f2a1b9", "job123", stats) | |
| 119 | + | ||
| 120 | + | // State 1: Forecast | |
| 121 | + | render1 := ui.Render(0) | |
| 122 | + | if !strings.Contains(render1, "ā lint") || !strings.Contains(render1, "failed last run") { | |
| 123 | + | t.Errorf("expected State 1 forecast output, got:\n%s", render1) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // State 2: Running fmt and lint | |
| 127 | + | ui.Tasks["fmt"].Status = "running" | |
| 128 | + | ui.Tasks["fmt"].Duration = 400 * time.Millisecond | |
| 129 | + | ui.Tasks["lint"].Status = "running" | |
| 130 | + | ui.Tasks["lint"].Duration = 5400 * time.Millisecond | |
| 131 | + | ui.Tasks["lint"].LastOutput = "running golangci-lint on 28 packages..." | |
| 132 | + | ||
| 133 | + | render2 := ui.Render(0) | |
| 134 | + | if !strings.Contains(render2, "fmt") || !strings.Contains(render2, "lint") { | |
| 135 | + | t.Errorf("expected State 2 running tasks, got:\n%s", render2) | |
| 136 | + | } | |
| 137 | + | if !strings.Contains(render2, "āā running golangci-lint on 28 packages...") { | |
| 138 | + | t.Errorf("expected live activity peek under lint, got:\n%s", render2) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // State 3: Failure with error preview | |
| 142 | + | ui.Tasks["fmt"].Status = "success" | |
| 143 | + | ui.Tasks["fmt"].Duration = 800 * time.Millisecond | |
| 144 | + | ui.Tasks["lint"].Status = "failed" | |
| 145 | + | ui.Tasks["lint"].ExitCode = "1" | |
| 146 | + | ui.Tasks["lint"].Duration = 4200 * time.Millisecond | |
| 147 | + | ui.Tasks["lint"].Transition = "still_failing" | |
| 148 | + | ui.Tasks["lint"].ErrorPreview = []string{ | |
| 149 | + | "cmd/server/main.go:58:2: errcheck: unchecked error", | |
| 150 | + | "api/v1/routes.go:19:1: revive: exported function missing doc", | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | render3 := ui.Render(0) | |
| 154 | + | if !strings.Contains(render3, "lint") || !strings.Contains(render3, "still failing") { | |
| 155 | + | t.Errorf("expected State 3 failure indicator, got:\n%s", render3) | |
| 156 | + | } | |
| 157 | + | if !strings.Contains(render3, "cmd/server/main.go:58:2") { | |
| 158 | + | t.Errorf("expected inline error preview box, got:\n%s", render3) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // State 5: Completion summary (success vs failure) | |
| 162 | + | summary := ui.RenderSummary("/tmp/pici-artifacts/my-repo/job123/index.html", 1) | |
| 163 | + | if !strings.Contains(summary, "1 task failed") { | |
| 164 | + | t.Errorf("expected failure summary, got:\n%s", summary) | |
| 165 | + | } | |
| 166 | + | if !strings.Contains(summary, "Report: /tmp/pici-artifacts/my-repo/job123/index.html") { | |
| 167 | + | t.Errorf("expected report path in summary, got:\n%s", summary) | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func TestUI_RenderSummary_SuccessWithFixed(t *testing.T) { | |
| 172 | + | stats := &RepoStats{ | |
| 173 | + | Repo: "my-repo", | |
| 174 | + | Tasks: map[string]*TaskStats{ | |
| 175 | + | "fmt": {AvgDurationMs: 900, LastStatus: "success", LastExitCode: 0}, | |
| 176 | + | "lint": {AvgDurationMs: 4200, LastStatus: "failed", LastExitCode: 1, FailureStreak: 1}, | |
| 177 | + | "test": {AvgDurationMs: 13500, LastStatus: "success", LastExitCode: 0}, | |
| 178 | + | }, | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | ui := newPipelineUIState("my-repo", "main", "8f2a1b9", "job123", stats) | |
| 182 | + | ui.Tasks["fmt"].Status = "success" | |
| 183 | + | ui.Tasks["fmt"].Duration = 800 * time.Millisecond | |
| 184 | + | ui.Tasks["lint"].Status = "success" | |
| 185 | + | ui.Tasks["lint"].Duration = 4100 * time.Millisecond | |
| 186 | + | ui.Tasks["lint"].Transition = "fixed" | |
| 187 | + | ui.Tasks["test"].Status = "success" | |
| 188 | + | ui.Tasks["test"].Duration = 14100 * time.Millisecond | |
| 189 | + | ||
| 190 | + | summary := ui.RenderSummary("/tmp/report.html", 0) | |
| 191 | + | if !strings.Contains(summary, "All 3 tasks succeeded") { | |
| 192 | + | t.Errorf("expected success summary, got:\n%s", summary) | |
| 193 | + | } | |
| 194 | + | if !strings.Contains(summary, "š fixed!") { | |
| 195 | + | t.Errorf("expected fixed badge in summary, got:\n%s", summary) | |
| 196 | + | } | |
| 197 | + | if !strings.Contains(summary, "Report: /tmp/report.html") { | |
| 198 | + | t.Errorf("expected report path, got:\n%s", summary) | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func TestUI_NonTTYStreaming(t *testing.T) { | |
| 203 | + | var buf bytes.Buffer | |
| 204 | + | logger := newLogger("ci", "error") | |
| 205 | + | ||
| 206 | + | stats := &RepoStats{Repo: "test"} | |
| 207 | + | ui := newPipelineUIState("test", "main", "abc", "job1", stats) | |
| 208 | + | ||
| 209 | + | streamer := &plainStreamReporter{ | |
| 210 | + | out: &buf, | |
| 211 | + | ui: ui, | |
| 212 | + | log: logger, | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | streamer.OnTaskStart("fmt") | |
| 216 | + | streamer.OnTaskOutput("fmt", "formatting...") | |
| 217 | + | streamer.OnTaskComplete("fmt", 0, 800*time.Millisecond, nil) | |
| 218 | + | ||
| 219 | + | out := buf.String() | |
| 220 | + | if !strings.Contains(out, "starting fmt") { | |
| 221 | + | t.Errorf("expected non-TTY start event, got: %s", out) | |
| 222 | + | } | |
| 223 | + | if !strings.Contains(out, "fmt succeeded (0.8s)") { | |
| 224 | + | t.Errorf("expected non-TTY complete event, got: %s", out) | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | func TestInteractiveController_Cancellation(t *testing.T) { | |
| 229 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 230 | + | var out bytes.Buffer | |
| 231 | + | ||
| 232 | + | ctrl := newInteractiveController(ctx, &out, "testrepo", "main", "head", "job1", nil, false) | |
| 233 | + | go func() { | |
| 234 | + | time.Sleep(50 * time.Millisecond) | |
| 235 | + | cancel() | |
| 236 | + | }() | |
| 237 | + | ||
| 238 | + | err := ctrl.WaitUntilDone(func() ([]SessionInfo, bool) { | |
| 239 | + | return []SessionInfo{ | |
| 240 | + | {Name: "local.testrepo.job1.step.test", Short: "test"}, | |
| 241 | + | }, false | |
| 242 | + | }) | |
| 243 | + | ||
| 244 | + | if err == nil { | |
| 245 | + | t.Fatal("expected error on cancellation, got nil") | |
| 246 | + | } | |
| 247 | + | if !strings.Contains(out.String(), "cancelling job and terminating active sessions") && !ctrl.ui.Cancelled { | |
| 248 | + | t.Errorf("expected cancellation message, got: %s", out.String()) | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | func TestInteractiveController_FullRunSimulation(t *testing.T) { | |
| 253 | + | var out bytes.Buffer | |
| 254 | + | ctx := context.Background() | |
| 255 | + | ||
| 256 | + | stats := &RepoStats{ | |
| 257 | + | Repo: "my-repo", | |
| 258 | + | Tasks: map[string]*TaskStats{ | |
| 259 | + | "test": {AvgDurationMs: 1000, LastStatus: "success"}, | |
| 260 | + | }, | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | ctrl := newInteractiveController(ctx, &out, "my-repo", "main", "abcd123", "job99", stats, true) | |
| 264 | + | ||
| 265 | + | step := 0 | |
| 266 | + | err := ctrl.WaitUntilDone(func() ([]SessionInfo, bool) { | |
| 267 | + | step++ | |
| 268 | + | if step == 1 { | |
| 269 | + | // Step running | |
| 270 | + | return []SessionInfo{ | |
| 271 | + | {Name: "local.my-repo.job99.step.test", Short: "test", Created: "1000", Ended: ""}, | |
| 272 | + | }, false | |
| 273 | + | } | |
| 274 | + | // Step completed | |
| 275 | + | return []SessionInfo{ | |
| 276 | + | {Name: "local.my-repo.job99.step.test", Short: "test", Created: "1000", Ended: "1002", ExitCode: "0"}, | |
| 277 | + | {Name: "local.my-repo.job99.runner", Short: "runner", Created: "1000", Ended: "1002", ExitCode: "0"}, | |
| 278 | + | }, true | |
| 279 | + | }) | |
| 280 | + | ||
| 281 | + | if err != nil { | |
| 282 | + | t.Fatalf("expected nil error, got %v", err) | |
| 283 | + | } | |
| 284 | + | if !ctrl.ui.IsComplete { | |
| 285 | + | t.Errorf("expected UI to be marked complete") | |
| 286 | + | } | |
| 287 | + | if ctrl.ui.Tasks["test"].Status != "success" { | |
| 288 | + | t.Errorf("expected test status success, got %s", ctrl.ui.Tasks["test"].Status) | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | func TestController_RedrawNoDrift(t *testing.T) { | |
| 293 | + | var buf bytes.Buffer | |
| 294 | + | ctrl := &interactiveController{ | |
| 295 | + | out: &buf, | |
| 296 | + | isTTY: true, | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | // Frame 1: 3 lines | |
| 300 | + | ctrl.redraw("line1\nline2\nline3\n") | |
| 301 | + | if ctrl.rendered != 2 { | |
| 302 | + | t.Errorf("expected rendered=2, got %d", ctrl.rendered) | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | // Frame 2: same 3 lines (should move up 2 lines, not drift) | |
| 306 | + | buf.Reset() | |
| 307 | + | ctrl.redraw("line1\nline2\nline3\n") | |
| 308 | + | out := buf.String() | |
| 309 | + | if !strings.HasPrefix(out, "\r\033[2A") { | |
| 310 | + | t.Errorf("expected move up 2 lines '\\r\\033[2A', got %q", out) | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | // Frame 3: expanded to 5 lines | |
| 314 | + | buf.Reset() | |
| 315 | + | ctrl.redraw("line1\nline2\nline3\nline4\nline5\n") | |
| 316 | + | out = buf.String() | |
| 317 | + | if !strings.HasPrefix(out, "\r\033[2A") { | |
| 318 | + | t.Errorf("expected move up 2 lines before expanding, got %q", out) | |
| 319 | + | } | |
| 320 | + | if ctrl.rendered != 4 { | |
| 321 | + | t.Errorf("expected rendered=4, got %d", ctrl.rendered) | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | // Frame 4: shrink back to 3 lines | |
| 325 | + | buf.Reset() | |
| 326 | + | ctrl.redraw("line1\nline2\nline3\n") | |
| 327 | + | out = buf.String() | |
| 328 | + | if !strings.HasPrefix(out, "\r\033[4A") { | |
| 329 | + | t.Errorf("expected move up 4 lines before shrinking, got %q", out) | |
| 330 | + | } | |
| 331 | + | if ctrl.rendered != 2 { | |
| 332 | + | t.Errorf("expected rendered=2, got %d", ctrl.rendered) | |
| 333 | + | } | |
| 334 | + | } |
A
stats.go
+146,
-0
| ... | ... | @@ -0,0 +1,146 @@ | |
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "math" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "time" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // RepoStats tracks historical run durations and outcomes across runs. | |
| 12 | + | type RepoStats struct { | |
| 13 | + | Repo string `json:"repo"` | |
| 14 | + | LastRunAt string `json:"last_run_at"` | |
| 15 | + | LastOverallStatus string `json:"last_overall_status"` | |
| 16 | + | RunsCount int `json:"runs_count"` | |
| 17 | + | AvgWallDurationMs int64 `json:"avg_wall_duration_ms"` | |
| 18 | + | Tasks map[string]*TaskStats `json:"tasks"` | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // TaskStats tracks duration averages, exit codes, and failure streaks per task. | |
| 22 | + | type TaskStats struct { | |
| 23 | + | AvgDurationMs int64 `json:"avg_duration_ms"` | |
| 24 | + | LastDurationMs int64 `json:"last_duration_ms"` | |
| 25 | + | LastStatus string `json:"last_status"` | |
| 26 | + | LastExitCode int `json:"last_exit_code"` | |
| 27 | + | FailureStreak int `json:"failure_streak,omitempty"` | |
| 28 | + | SeenCount int `json:"seen_count"` | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // getStatsPath returns the path to stats.json for the specified repository. | |
| 32 | + | func getStatsPath(repo string) string { | |
| 33 | + | cacheDir := os.Getenv("XDG_CACHE_HOME") | |
| 34 | + | if cacheDir == "" { | |
| 35 | + | home, err := os.UserHomeDir() | |
| 36 | + | if err != nil { | |
| 37 | + | cacheDir = "/tmp" | |
| 38 | + | } else { | |
| 39 | + | cacheDir = filepath.Join(home, ".cache") | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | return filepath.Join(cacheDir, "pici", repo, "stats.json") | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // loadRepoStats loads historical stats for a repo, or returns an initialized empty struct. | |
| 46 | + | func loadRepoStats(repo string) (*RepoStats, error) { | |
| 47 | + | path := getStatsPath(repo) | |
| 48 | + | data, err := os.ReadFile(path) | |
| 49 | + | if err != nil { | |
| 50 | + | if os.IsNotExist(err) { | |
| 51 | + | return &RepoStats{ | |
| 52 | + | Repo: repo, | |
| 53 | + | Tasks: make(map[string]*TaskStats), | |
| 54 | + | }, nil | |
| 55 | + | } | |
| 56 | + | return nil, err | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var stats RepoStats | |
| 60 | + | if err := json.Unmarshal(data, &stats); err != nil { | |
| 61 | + | return &RepoStats{ | |
| 62 | + | Repo: repo, | |
| 63 | + | Tasks: make(map[string]*TaskStats), | |
| 64 | + | }, nil | |
| 65 | + | } | |
| 66 | + | if stats.Tasks == nil { | |
| 67 | + | stats.Tasks = make(map[string]*TaskStats) | |
| 68 | + | } | |
| 69 | + | return &stats, nil | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // saveRepoStats writes repo stats to stats.json. | |
| 73 | + | func saveRepoStats(stats *RepoStats) error { | |
| 74 | + | if stats == nil || stats.Repo == "" { | |
| 75 | + | return nil | |
| 76 | + | } | |
| 77 | + | path := getStatsPath(stats.Repo) | |
| 78 | + | if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { | |
| 79 | + | return err | |
| 80 | + | } | |
| 81 | + | data, err := json.MarshalIndent(stats, "", " ") | |
| 82 | + | if err != nil { | |
| 83 | + | return err | |
| 84 | + | } | |
| 85 | + | return os.WriteFile(path, data, 0644) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // updateTaskStats updates single task statistics using EMA duration (alpha = 0.3). | |
| 89 | + | func updateTaskStats(stats *RepoStats, name string, actualDur time.Duration, exitCode int) { | |
| 90 | + | if stats.Tasks == nil { | |
| 91 | + | stats.Tasks = make(map[string]*TaskStats) | |
| 92 | + | } | |
| 93 | + | task, ok := stats.Tasks[name] | |
| 94 | + | if !ok { | |
| 95 | + | task = &TaskStats{} | |
| 96 | + | stats.Tasks[name] = task | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | actualMs := actualDur.Milliseconds() | |
| 100 | + | if task.SeenCount == 0 || task.AvgDurationMs == 0 { | |
| 101 | + | task.AvgDurationMs = actualMs | |
| 102 | + | } else { | |
| 103 | + | // EMA: alpha = 0.3 | |
| 104 | + | task.AvgDurationMs = int64(math.Round(0.3*float64(actualMs) + 0.7*float64(task.AvgDurationMs))) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | task.LastDurationMs = actualMs | |
| 108 | + | task.LastExitCode = exitCode | |
| 109 | + | task.SeenCount++ | |
| 110 | + | ||
| 111 | + | if exitCode == 0 { | |
| 112 | + | task.LastStatus = "success" | |
| 113 | + | task.FailureStreak = 0 | |
| 114 | + | } else { | |
| 115 | + | task.LastStatus = "failed" | |
| 116 | + | task.FailureStreak++ | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // updateRepoStats updates overall repository stats after a run. | |
| 121 | + | func updateRepoStats(stats *RepoStats, wallDur time.Duration, overallStatus string) { | |
| 122 | + | stats.RunsCount++ | |
| 123 | + | stats.LastRunAt = time.Now().UTC().Format(time.RFC3339) | |
| 124 | + | stats.LastOverallStatus = overallStatus | |
| 125 | + | ||
| 126 | + | wallMs := wallDur.Milliseconds() | |
| 127 | + | if stats.RunsCount == 1 || stats.AvgWallDurationMs == 0 { | |
| 128 | + | stats.AvgWallDurationMs = wallMs | |
| 129 | + | } else { | |
| 130 | + | stats.AvgWallDurationMs = int64(math.Round(0.3*float64(wallMs) + 0.7*float64(stats.AvgWallDurationMs))) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // computeOutcomeTransition returns "fixed", "still_failing", "new_failure", or "". | |
| 135 | + | func computeOutcomeTransition(prev *TaskStats, currentExitCode int) string { | |
| 136 | + | if prev != nil && (prev.LastStatus == "failed" || prev.LastExitCode != 0) { | |
| 137 | + | if currentExitCode == 0 { | |
| 138 | + | return "fixed" | |
| 139 | + | } | |
| 140 | + | return "still_failing" | |
| 141 | + | } | |
| 142 | + | if currentExitCode != 0 { | |
| 143 | + | return "new_failure" | |
| 144 | + | } | |
| 145 | + | return "" | |
| 146 | + | } |
+157,
-0
| ... | ... | @@ -0,0 +1,157 @@ | |
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "path/filepath" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestStats_EMACalculation(t *testing.T) { | |
| 10 | + | // Formula: EMA_new = 0.3 * actual + 0.7 * EMA_prev | |
| 11 | + | // If EMA_prev == 0, EMA_new = actual | |
| 12 | + | stats := &RepoStats{ | |
| 13 | + | Repo: "testrepo", | |
| 14 | + | Tasks: map[string]*TaskStats{ | |
| 15 | + | "fmt": { | |
| 16 | + | AvgDurationMs: 1000, | |
| 17 | + | LastDurationMs: 1000, | |
| 18 | + | LastStatus: "success", | |
| 19 | + | LastExitCode: 0, | |
| 20 | + | SeenCount: 1, | |
| 21 | + | }, | |
| 22 | + | }, | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // Update with a new run of 2000ms | |
| 26 | + | updateTaskStats(stats, "fmt", 2000*time.Millisecond, 0) | |
| 27 | + | task := stats.Tasks["fmt"] | |
| 28 | + | if task.SeenCount != 2 { | |
| 29 | + | t.Errorf("expected seen count 2, got %d", task.SeenCount) | |
| 30 | + | } | |
| 31 | + | // 0.3 * 2000 + 0.7 * 1000 = 600 + 700 = 1300 | |
| 32 | + | if task.AvgDurationMs != 1300 { | |
| 33 | + | t.Errorf("expected AvgDurationMs 1300, got %d", task.AvgDurationMs) | |
| 34 | + | } | |
| 35 | + | if task.LastDurationMs != 2000 { | |
| 36 | + | t.Errorf("expected LastDurationMs 2000, got %d", task.LastDurationMs) | |
| 37 | + | } | |
| 38 | + | if task.LastStatus != "success" { | |
| 39 | + | t.Errorf("expected LastStatus success, got %s", task.LastStatus) | |
| 40 | + | } | |
| 41 | + | if task.FailureStreak != 0 { | |
| 42 | + | t.Errorf("expected FailureStreak 0, got %d", task.FailureStreak) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // Update with new task (not seen before) | |
| 46 | + | updateTaskStats(stats, "lint", 4000*time.Millisecond, 1) | |
| 47 | + | lintTask := stats.Tasks["lint"] | |
| 48 | + | if lintTask.AvgDurationMs != 4000 { | |
| 49 | + | t.Errorf("expected initial AvgDurationMs 4000, got %d", lintTask.AvgDurationMs) | |
| 50 | + | } | |
| 51 | + | if lintTask.LastStatus != "failed" { | |
| 52 | + | t.Errorf("expected LastStatus failed, got %s", lintTask.LastStatus) | |
| 53 | + | } | |
| 54 | + | if lintTask.FailureStreak != 1 { | |
| 55 | + | t.Errorf("expected FailureStreak 1, got %d", lintTask.FailureStreak) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // Fail again -> streak increments | |
| 59 | + | updateTaskStats(stats, "lint", 4100*time.Millisecond, 1) | |
| 60 | + | lintTask = stats.Tasks["lint"] | |
| 61 | + | if lintTask.FailureStreak != 2 { | |
| 62 | + | t.Errorf("expected FailureStreak 2, got %d", lintTask.FailureStreak) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // Succeed -> streak resets to 0 | |
| 66 | + | updateTaskStats(stats, "lint", 3900*time.Millisecond, 0) | |
| 67 | + | lintTask = stats.Tasks["lint"] | |
| 68 | + | if lintTask.FailureStreak != 0 { | |
| 69 | + | t.Errorf("expected FailureStreak reset to 0, got %d", lintTask.FailureStreak) | |
| 70 | + | } | |
| 71 | + | if lintTask.LastStatus != "success" { | |
| 72 | + | t.Errorf("expected LastStatus success, got %s", lintTask.LastStatus) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestStats_StorageAndPath(t *testing.T) { | |
| 77 | + | tempCache := t.TempDir() | |
| 78 | + | t.Setenv("XDG_CACHE_HOME", tempCache) | |
| 79 | + | ||
| 80 | + | repo := "my-project" | |
| 81 | + | expectedPath := filepath.Join(tempCache, "pici", repo, "stats.json") | |
| 82 | + | path := getStatsPath(repo) | |
| 83 | + | if path != expectedPath { | |
| 84 | + | t.Errorf("expected stats path %s, got %s", expectedPath, path) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // Test Save & Load | |
| 88 | + | stats := &RepoStats{ | |
| 89 | + | Repo: repo, | |
| 90 | + | LastRunAt: time.Now().UTC().Format(time.RFC3339), | |
| 91 | + | LastOverallStatus: "success", | |
| 92 | + | RunsCount: 5, | |
| 93 | + | AvgWallDurationMs: 12000, | |
| 94 | + | Tasks: map[string]*TaskStats{ | |
| 95 | + | "build": { | |
| 96 | + | AvgDurationMs: 6000, | |
| 97 | + | LastDurationMs: 6100, | |
| 98 | + | LastStatus: "success", | |
| 99 | + | LastExitCode: 0, | |
| 100 | + | SeenCount: 5, | |
| 101 | + | }, | |
| 102 | + | }, | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | if err := saveRepoStats(stats); err != nil { | |
| 106 | + | t.Fatalf("failed to save stats: %v", err) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | loaded, err := loadRepoStats(repo) | |
| 110 | + | if err != nil { | |
| 111 | + | t.Fatalf("failed to load stats: %v", err) | |
| 112 | + | } | |
| 113 | + | if loaded.Repo != repo || loaded.RunsCount != 5 || loaded.AvgWallDurationMs != 12000 { | |
| 114 | + | t.Errorf("loaded stats mismatch: %+v", loaded) | |
| 115 | + | } | |
| 116 | + | if task, ok := loaded.Tasks["build"]; !ok || task.AvgDurationMs != 6000 { | |
| 117 | + | t.Errorf("loaded task stats mismatch: %+v", task) | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func TestStats_OutcomeTransitions(t *testing.T) { | |
| 122 | + | // 1. Fixed transition (failed in prev run, succeeded in current run) | |
| 123 | + | t1 := &TaskStats{ | |
| 124 | + | LastStatus: "failed", | |
| 125 | + | LastExitCode: 1, | |
| 126 | + | } | |
| 127 | + | if trans := computeOutcomeTransition(t1, 0); trans != "fixed" { | |
| 128 | + | t.Errorf("expected 'fixed', got '%s'", trans) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // 2. Still failing transition (failed in prev run, failed in current run) | |
| 132 | + | if trans := computeOutcomeTransition(t1, 1); trans != "still_failing" { | |
| 133 | + | t.Errorf("expected 'still_failing', got '%s'", trans) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // 3. New failure transition (succeeded in prev run, failed in current run) | |
| 137 | + | t2 := &TaskStats{ | |
| 138 | + | LastStatus: "success", | |
| 139 | + | LastExitCode: 0, | |
| 140 | + | } | |
| 141 | + | if trans := computeOutcomeTransition(t2, 2); trans != "new_failure" { | |
| 142 | + | t.Errorf("expected 'new_failure', got '%s'", trans) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // 4. Clean success (succeeded in prev run, succeeded in current run) | |
| 146 | + | if trans := computeOutcomeTransition(t2, 0); trans != "" { | |
| 147 | + | t.Errorf("expected '', got '%s'", trans) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // 5. First time seen (nil prev) | |
| 151 | + | if trans := computeOutcomeTransition(nil, 0); trans != "" { | |
| 152 | + | t.Errorf("expected '', got '%s'", trans) | |
| 153 | + | } | |
| 154 | + | if trans := computeOutcomeTransition(nil, 1); trans != "new_failure" { | |
| 155 | + | t.Errorf("expected 'new_failure', got '%s'", trans) | |
| 156 | + | } | |
| 157 | + | } |