1use std::fs::{
2 self,
3 File,
4};
5use std::io::{
6 self,
7 BufReader,
8 BufWriter,
9 Read,
10 Seek,
11 SeekFrom,
12 Write,
13};
14use std::path::{
15 Path,
16 PathBuf,
17};
18
19use serde::{
20 Deserialize,
21 Serialize,
22};
23
24use super::checkpoint::copy_prefix;
25use super::{
26 MARKER_LINES_CAP,
27 MAX_SECTIONS,
28};
29use crate::domain::{
30 LineKind,
31 LogSection,
32 LogSectionKind,
33 PipelineStatus,
34};
35
36const ERRORS_FILE: &str = "errors.bin";
37const ERRORS_BUFFER_BYTES: usize = 64 * 1024;
38const MAX_TITLE_BYTES: usize = 512;
39const MAX_STEP_MARKERS: usize = 100_000;
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct StepBound {
43 pub node_id: String,
44 pub title: String,
45 pub status: PipelineStatus,
46 pub started_ms: Option<i64>,
47 pub finished_ms: Option<i64>,
48}
49
50#[derive(Serialize, Deserialize)]
51struct Group {
52 header: u32,
53 title: String,
54 end: Option<u32>,
55 first_ms: Option<i64>,
56 last_ms: Option<i64>,
57 open: bool,
58}
59
60pub(crate) struct OutlineOutput {
61 pub sections: Vec<LogSection>,
62 pub error_lines: Vec<u32>,
63 pub warning_lines: Vec<u32>,
64 pub error_total: u32,
65 pub warning_total: u32,
66 pub focus_line: Option<u32>,
67}
68
69#[derive(Default, Serialize, Deserialize)]
70pub(crate) struct Outline {
71 steps: Vec<StepBound>,
72 ts_boundary: Vec<u32>,
73 markers: Vec<(u32, i64)>,
74 groups: Vec<Group>,
75 open_groups: Vec<usize>,
76 ignored_depth: u32,
77 warned: bool,
78 last_ts: Option<i64>,
79 #[serde(skip)]
80 errors_path: PathBuf,
81 #[serde(skip)]
82 errors: Option<BufWriter<File>>,
83 error_total: u32,
84 warning_total: u32,
85 error_lines: Vec<u32>,
86 warning_lines: Vec<u32>,
87}
88
89fn floor_sec(ms: i64) -> i64 {
90 ms.div_euclid(1000)
91}
92
93fn truncate_title(text: &str) -> String {
94 if text.len() <= MAX_TITLE_BYTES {
95 return text.to_string();
96 }
97 let mut end = MAX_TITLE_BYTES;
98 while !text.is_char_boundary(end) {
99 end -= 1;
100 }
101 text[..end].to_string()
102}
103
104fn normalized(mut steps: Vec<StepBound>) -> Vec<StepBound> {
105 steps.sort_by_key(|s| (s.started_ms.is_none(), s.started_ms));
106 steps.truncate(MAX_SECTIONS);
107 steps
108}
109
110impl Outline {
111 pub(crate) fn new(steps: Vec<StepBound>, dir: &Path) -> Self {
112 Self {
113 steps: normalized(steps),
114 errors_path: dir.join(ERRORS_FILE),
115 ..Self::default()
116 }
117 }
118
119 pub(crate) fn accepts(&self, steps: &[StepBound]) -> bool {
120 let steps = normalized(steps.to_vec());
121 let placed = self.ts_boundary.len();
122 steps.len() >= placed
123 && self
124 .steps
125 .iter()
126 .zip(&steps)
127 .take(placed)
128 .all(|(old, new)| old.node_id == new.node_id && old.started_ms == new.started_ms)
129 }
130
131 fn spool_bytes(&self) -> u64 {
132 u64::from(self.error_total) * 4
133 }
134
135 pub(crate) fn spool_fits(&self, dir: &Path) -> bool {
136 self.error_total == 0
137 || fs::metadata(dir.join(ERRORS_FILE)).is_ok_and(|m| m.len() >= self.spool_bytes())
138 }
139
140 pub(crate) fn resume(
141 mut self, steps: Vec<StepBound>, base: &Path, dir: &Path,
142 ) -> io::Result<Self> {
143 self.steps = normalized(steps);
144 self.errors_path = dir.join(ERRORS_FILE);
145 if self.error_total > 0 {
146 let spool = copy_prefix(
147 &base.join(ERRORS_FILE),
148 &self.errors_path,
149 self.spool_bytes(),
150 )?;
151 self.errors = Some(BufWriter::with_capacity(ERRORS_BUFFER_BYTES, spool));
152 }
153 Ok(self)
154 }
155
156 pub(crate) fn discard_spool(&mut self) -> io::Result<()> {
157 self.errors = None;
158 match fs::remove_file(&self.errors_path) {
159 Err(e) if e.kind() != io::ErrorKind::NotFound => Err(e),
160 _ => Ok(()),
161 }
162 }
163
164 pub(crate) fn line(
165 &mut self, index: u32, kind: LineKind, ts: Option<i64>, text: &str, step_start: bool,
166 ) -> io::Result<()> {
167 if ts.is_some() {
168 self.last_ts = ts;
169 }
170 if let Some(effective) = self.last_ts {
171 let sec = floor_sec(effective);
172 self.advance(index, sec);
173 if step_start && self.markers.len() < MAX_STEP_MARKERS {
174 self.markers.push((index, sec));
175 }
176 }
177 match kind {
178 LineKind::Error => {
179 if self.error_lines.len() < MARKER_LINES_CAP {
180 self.error_lines.push(index);
181 }
182 self.error_total = self.error_total.saturating_add(1);
183 if self.errors.is_none() {
184 let file = File::create(&self.errors_path)?;
185 self.errors = Some(BufWriter::with_capacity(ERRORS_BUFFER_BYTES, file));
186 }
187 if let Some(writer) = self.errors.as_mut() {
188 writer.write_all(&index.to_le_bytes())?;
189 }
190 }
191 LineKind::Warning => {
192 if self.warning_lines.len() < MARKER_LINES_CAP {
193 self.warning_lines.push(index);
194 }
195 self.warning_total = self.warning_total.saturating_add(1);
196 }
197 LineKind::Group => self.group_start(index, text),
198 _ => {}
199 }
200 Ok(())
201 }
202
203 fn advance(&mut self, index: u32, sec: i64) {
204 while self.ts_boundary.len() < self.steps.len() {
205 let Some(started) = self.steps[self.ts_boundary.len()].started_ms else {
206 break;
207 };
208 if sec < floor_sec(started) {
209 break;
210 }
211 self.ts_boundary.push(index);
212 }
213 }
214
215 fn group_start(&mut self, index: u32, text: &str) {
216 if self.steps.len() + self.groups.len() < MAX_SECTIONS {
217 self.groups.push(Group {
218 header: index,
219 title: truncate_title(text),
220 end: None,
221 first_ms: self.last_ts,
222 last_ms: None,
223 open: false,
224 });
225 self.open_groups.push(self.groups.len() - 1);
226 } else {
227 if !self.warned {
228 self.warned = true;
229 tracing::warn!("Run log has more than {MAX_SECTIONS} sections; ignoring groups");
230 }
231 self.ignored_depth += 1;
232 }
233 }
234
235 pub(crate) fn group_end(&mut self, line_count: u32, ts: Option<i64>) {
236 if ts.is_some() {
237 self.last_ts = ts;
238 }
239 if self.ignored_depth > 0 {
240 self.ignored_depth -= 1;
241 return;
242 }
243 if let Some(group) = self.open_groups.pop() {
244 let group = &mut self.groups[group];
245 group.end = Some(line_count);
246 group.last_ms = self.last_ts;
247 }
248 }
249
250 pub(crate) fn end_groups(&mut self, line_count: u32) {
251 self.ignored_depth = 0;
252 for group in self.open_groups.drain(..) {
253 let group = &mut self.groups[group];
254 group.end = Some(line_count);
255 group.last_ms = self.last_ts;
256 }
257 }
258
259 pub(crate) fn open_current_group(&mut self, title: &str) {
260 let Some(&group) = self.open_groups.last() else {
261 return;
262 };
263 let group = &mut self.groups[group];
264 group.open = true;
265 if !title.trim().is_empty() {
266 group.title = truncate_title(title);
267 }
268 }
269
270 pub(crate) fn finish(&mut self, line_count: u32) -> io::Result<OutlineOutput> {
271 if let Some(writer) = self.errors.as_mut() {
272 writer.flush()?;
273 }
274
275 let mut drafts = self.step_drafts(line_count);
276 let step_count = drafts.len();
277 let ranges: Vec<(u32, u32)> = drafts.iter().map(|d| (d.start, d.end)).collect();
278 let mut stack: Vec<(usize, Option<usize>)> = Vec::new();
279 for group in &self.groups {
280 let step = containing_step(&ranges, group.header);
281 while let Some(&(top, top_step)) = stack.last() {
282 if drafts[top].end <= group.header || top_step != step {
283 stack.pop();
284 } else {
285 break;
286 }
287 }
288 let parent = stack.last().map(|&(top, _)| top).or(step);
289 let mut end = group.end.unwrap_or(line_count);
290 if let Some(parent) = parent {
291 end = end.min(drafts[parent].end);
292 }
293 let last_ms = if group.end.is_none() {
294 self.last_ts
295 } else {
296 group.last_ms
297 };
298 let duration_ms = match (group.first_ms, last_ms) {
299 (Some(first), Some(last)) => Some((last - first).max(0)),
300 _ => None,
301 };
302 drafts.push(Draft {
303 kind: LogSectionKind::Group,
304 node_id: None,
305 title: group.title.clone(),
306 status: None,
307 duration_ms,
308 header: Some(group.header),
309 start: group.header + 1,
310 end,
311 parent,
312 depth: parent.map_or(0, |p| drafts[p].depth.saturating_add(1)),
313 open: group.open,
314 });
315 stack.push((drafts.len() - 1, step));
316 }
317
318 let mut edges: Vec<u32> = drafts
319 .iter()
320 .flat_map(|d| [d.edge_start(), d.end])
321 .collect();
322 edges.sort_unstable();
323 edges.dedup();
324 let prefix = self.prefix_counts(&edges)?;
325 let prefix_at = |edge: u32| {
326 edges
327 .binary_search(&edge)
328 .ok()
329 .and_then(|i| prefix.get(i).copied())
330 .unwrap_or(0)
331 };
332
333 let focus_line = {
334 let failed = drafts[..step_count]
335 .iter()
336 .find(|d| d.status == Some(PipelineStatus::Failed));
337 let in_failed = failed.and_then(|d| {
338 let (from, to) = (prefix_at(d.edge_start()), prefix_at(d.end));
339 (from < to).then_some(from)
340 });
341 match in_failed {
342 Some(nth) => self.nth_error(nth)?,
343 None => self
344 .error_lines
345 .first()
346 .copied()
347 .or_else(|| failed.and_then(|d| (d.start < d.end).then(|| d.end - 1))),
348 }
349 };
350
351 let only_step = step_count == 1;
352 let mut order: Vec<usize> = (0..drafts.len()).collect();
353 order.sort_by_key(|&i| (drafts[i].edge_start(), drafts[i].depth));
354 let mut new_id = vec![0u32; drafts.len()];
355 for (position, &old) in order.iter().enumerate() {
356 new_id[old] = position as u32;
357 }
358 let sections = order
359 .iter()
360 .map(|&old| {
361 let draft = &drafts[old];
362 let error_count = prefix_at(draft.end) - prefix_at(draft.edge_start());
363 let open = match draft.kind {
364 LogSectionKind::Step => {
365 draft.status == Some(PipelineStatus::Failed) || only_step
366 }
367 LogSectionKind::Group => error_count > 0 || draft.open,
368 };
369 LogSection {
370 id: new_id[old],
371 parent: draft.parent.map(|p| new_id[p]),
372 kind: draft.kind,
373 node_id: draft.node_id.clone(),
374 title: draft.title.clone(),
375 status: draft.status.clone(),
376 duration_ms: draft.duration_ms,
377 header_line: draft.header,
378 start_line: draft.start,
379 end_line: draft.end,
380 error_count,
381 open,
382 }
383 })
384 .collect();
385
386 Ok(OutlineOutput {
387 sections,
388 error_lines: self.error_lines.clone(),
389 warning_lines: self.warning_lines.clone(),
390 error_total: self.error_total,
391 warning_total: self.warning_total,
392 focus_line,
393 })
394 }
395
396 fn step_drafts(&self, line_count: u32) -> Vec<Draft> {
397 let count = self.steps.len();
398 let mut placed: Vec<Option<u32>> = vec![None; count];
399 let mut previous: Option<u32> = None;
400 let mut cursor = 0;
401 for (k, step) in self.steps.iter().enumerate() {
402 let Some(started) = step.started_ms else {
403 continue;
404 };
405 if step.status == PipelineStatus::Skipped {
406 continue;
407 }
408 if k == 0 {
409 placed[0] = Some(0);
410 previous = Some(0);
411 continue;
412 }
413 let start_sec = floor_sec(started);
414 let end_sec = step.finished_ms.map(floor_sec);
415 let mut found = None;
416 while let Some(&(line, sec)) = self.markers.get(cursor) {
417 if previous.is_some_and(|p| line <= p) || sec < start_sec {
418 cursor += 1;
419 continue;
420 }
421 if end_sec.is_some_and(|end| sec > end) {
422 break;
423 }
424 found = Some(line);
425 cursor += 1;
426 break;
427 }
428 let boundary = found.or_else(|| {
429 let fallback = self.ts_boundary.get(k).copied().unwrap_or(line_count);
430 previous.is_none_or(|p| fallback > p).then_some(fallback)
431 });
432 if let Some(boundary) = boundary {
433 placed[k] = Some(boundary);
434 previous = Some(boundary);
435 }
436 }
437 let mut next = line_count;
438 let mut starts = vec![0u32; count];
439 for k in (0..count).rev() {
440 starts[k] = placed[k].unwrap_or(next).min(line_count);
441 next = starts[k];
442 }
443 self.steps
444 .iter()
445 .enumerate()
446 .map(|(i, step)| {
447 let duration_ms = match (step.started_ms, step.finished_ms) {
448 (Some(started), Some(finished)) => Some((finished - started).max(0)),
449 _ => None,
450 };
451 Draft {
452 kind: LogSectionKind::Step,
453 node_id: Some(step.node_id.clone()),
454 title: step.title.clone(),
455 status: Some(step.status.clone()),
456 duration_ms,
457 header: None,
458 start: starts[i],
459 end: starts.get(i + 1).copied().unwrap_or(line_count),
460 parent: None,
461 depth: 0,
462 open: false,
463 }
464 })
465 .collect()
466 }
467
468 fn prefix_counts(&self, edges: &[u32]) -> io::Result<Vec<u32>> {
469 let mut out = vec![0u32; edges.len()];
470 let mut next_edge = 0;
471 let mut count = 0u32;
472 if self.error_total > 0 {
473 let mut reader =
474 BufReader::with_capacity(ERRORS_BUFFER_BYTES, File::open(&self.errors_path)?);
475 let mut record = [0u8; 4];
476 loop {
477 match reader.read_exact(&mut record) {
478 Ok(()) => {}
479 Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break,
480 Err(e) => return Err(e),
481 }
482 let line = u32::from_le_bytes(record);
483 while next_edge < edges.len() && edges[next_edge] <= line {
484 out[next_edge] = count;
485 next_edge += 1;
486 }
487 count += 1;
488 }
489 }
490 for slot in &mut out[next_edge..] {
491 *slot = count;
492 }
493 Ok(out)
494 }
495
496 fn nth_error(&self, nth: u32) -> io::Result<Option<u32>> {
497 if let Some(&line) = self.error_lines.get(nth as usize) {
498 return Ok(Some(line));
499 }
500 let mut file = File::open(&self.errors_path)?;
501 file.seek(SeekFrom::Start(u64::from(nth) * 4))?;
502 let mut record = [0u8; 4];
503 file.read_exact(&mut record)?;
504 Ok(Some(u32::from_le_bytes(record)))
505 }
506}
507
508struct Draft {
509 kind: LogSectionKind,
510 node_id: Option<String>,
511 title: String,
512 status: Option<PipelineStatus>,
513 duration_ms: Option<i64>,
514 header: Option<u32>,
515 start: u32,
516 end: u32,
517 parent: Option<usize>,
518 depth: u8,
519 open: bool,
520}
521
522impl Draft {
523 fn edge_start(&self) -> u32 {
524 self.header.unwrap_or(self.start)
525 }
526}
527
528fn containing_step(ranges: &[(u32, u32)], header: u32) -> Option<usize> {
529 ranges
530 .partition_point(|&(start, _)| start <= header)
531 .checked_sub(1)
532 .filter(|&i| header < ranges[i].1)
533}
534
535#[cfg(test)]
536mod tests {
537 use chrono::{
538 DateTime,
539 SecondsFormat,
540 };
541 use pipedash_plugin_actions::ActionsLogDialect;
542
543 use super::*;
544 use crate::domain::{
545 LineMarker,
546 LogDialect,
547 };
548
549 const BASE: i64 = 1_700_000_000_000;
550
551 fn t(sec: i64, ms: i64) -> Option<i64> {
552 Some(BASE + sec * 1000 + ms)
553 }
554
555 fn step(id: &str, status: PipelineStatus, sec: i64) -> StepBound {
556 StepBound {
557 node_id: id.into(),
558 title: id.into(),
559 status,
560 started_ms: t(sec, 0),
561 finished_ms: t(sec + 1, 0),
562 }
563 }
564
565 struct Feed {
566 outline: Outline,
567 lines: u32,
568 _dir: tempfile::TempDir,
569 }
570
571 impl Feed {
572 fn new(steps: Vec<StepBound>) -> Self {
573 let dir = tempfile::tempdir().expect("tempdir");
574 Self {
575 outline: Outline::new(steps, dir.path()),
576 lines: 0,
577 _dir: dir,
578 }
579 }
580
581 fn line(&mut self, ts: Option<i64>, kind: LineKind, text: &str) {
582 self.outline
583 .line(self.lines, kind, ts, text, false)
584 .expect("line");
585 self.lines += 1;
586 }
587
588 fn raw(&mut self, line: &str) {
589 let parsed = ActionsLogDialect.parse(line.as_bytes());
590 let ts = parsed.timestamp.map(|t| t.timestamp_millis());
591 if parsed.marker == LineMarker::GroupEnd {
592 self.outline.group_end(self.lines, ts);
593 return;
594 }
595 let kind = match parsed.marker {
596 LineMarker::GroupStart => LineKind::Group,
597 LineMarker::Error => LineKind::Error,
598 _ => LineKind::Plain,
599 };
600 let text = String::from_utf8_lossy(parsed.body).into_owned();
601 let step_start = ActionsLogDialect.is_step_start(&parsed);
602 self.outline
603 .line(self.lines, kind, ts, &text, step_start)
604 .expect("line");
605 self.lines += 1;
606 }
607
608 fn plain(&mut self, ts: Option<i64>) {
609 self.line(ts, LineKind::Plain, "x");
610 }
611
612 fn group(&mut self, ts: Option<i64>, title: &str) {
613 self.line(ts, LineKind::Group, title);
614 }
615
616 fn error(&mut self, ts: Option<i64>) {
617 self.line(ts, LineKind::Error, "boom");
618 }
619
620 fn end(&mut self, ts: Option<i64>) {
621 self.outline.group_end(self.lines, ts);
622 }
623
624 fn finish(mut self) -> OutlineOutput {
625 self.outline.finish(self.lines).expect("finish")
626 }
627 }
628
629 fn find<'a>(out: &'a OutlineOutput, title: &str) -> &'a LogSection {
630 out.sections
631 .iter()
632 .find(|s| s.title == title)
633 .expect("section")
634 }
635
636 fn bound(id: &str, status: PipelineStatus, start: (i64, i64), end: (i64, i64)) -> StepBound {
637 StepBound {
638 node_id: id.into(),
639 title: id.into(),
640 status,
641 started_ms: t(start.0, start.1),
642 finished_ms: t(end.0, end.1),
643 }
644 }
645
646 fn stamp(sec: i64, ms: i64) -> String {
647 DateTime::from_timestamp_millis(BASE + sec * 1000 + ms)
648 .expect("timestamp")
649 .to_rfc3339_opts(SecondsFormat::Millis, true)
650 }
651
652 fn raw(feed: &mut Feed, sec: i64, ms: i64, body: &str) {
653 feed.raw(&format!("{} {body}", stamp(sec, ms)));
654 }
655
656 #[test]
657 fn run_logs_outline_actions_markers_place_steps_in_shared_seconds() {
658 let mut feed = Feed::new(vec![
659 bound("Set up job", PipelineStatus::Success, (10, 0), (10, 0)),
660 bound("Bulk output", PipelineStatus::Success, (10, 0), (37, 0)),
661 bound("Fail", PipelineStatus::Failed, (37, 0), (37, 0)),
662 bound("Complete job", PipelineStatus::Success, (37, 0), (37, 0)),
663 ]);
664 raw(&mut feed, 10, 0, "Current runner version");
665 raw(&mut feed, 10, 1, "##[group]Runner Image");
666 raw(&mut feed, 10, 2, "Image: ubuntu");
667 raw(&mut feed, 10, 2, "##[endgroup]");
668 raw(&mut feed, 10, 3, "##[group]Secret source");
669 raw(&mut feed, 10, 4, "Dependabot");
670 raw(&mut feed, 10, 4, "##[endgroup]");
671 raw(&mut feed, 10, 5, "Prepare workflow directory");
672 raw(&mut feed, 10, 10, "##[group]Run echo \"::group::bulk\"");
673 raw(&mut feed, 10, 10, "##[endgroup]");
674 raw(&mut feed, 10, 11, "##[group]bulk");
675 raw(&mut feed, 37, 0, "line 1");
676 raw(&mut feed, 37, 900, "line 2");
677 raw(&mut feed, 37, 910, "##[endgroup]");
678 raw(&mut feed, 37, 920, "##[group]Run echo \"::error::boom\"");
679 raw(&mut feed, 37, 920, "##[endgroup]");
680 raw(&mut feed, 37, 930, "##[error]boom");
681 raw(&mut feed, 37, 931, "##[error]boom again");
682 raw(&mut feed, 37, 940, "Cleaning up orphan processes");
683 let out = feed.finish();
684 let range = |title: &str| (find(&out, title).start_line, find(&out, title).end_line);
685 assert_eq!(range("Set up job"), (0, 6));
686 assert_eq!(range("Bulk output"), (6, 10));
687 assert_eq!(range("Fail"), (10, 14));
688 assert_eq!(range("Complete job"), (14, 14));
689 assert_eq!(find(&out, "Fail").error_count, 2);
690 assert!(find(&out, "Fail").open);
691 assert!(!find(&out, "Bulk output").open);
692 assert_eq!(out.focus_line, Some(11));
693 let bulk = find(&out, "bulk");
694 assert_eq!(bulk.parent, Some(find(&out, "Bulk output").id));
695 assert_eq!((bulk.header_line, bulk.end_line), (Some(7), 10));
696 assert_eq!(
697 find(&out, "Runner Image").parent,
698 Some(find(&out, "Set up job").id)
699 );
700 }
701
702 #[test]
703 fn run_logs_outline_plain_dialect_falls_back_to_timestamps_first_tied_step_wins() {
704 let mut feed = Feed::new(vec![
705 bound("pre", PipelineStatus::Success, (1, 0), (2, 0)),
706 bound("a", PipelineStatus::Success, (5, 0), (5, 0)),
707 bound("b", PipelineStatus::Success, (5, 0), (5, 0)),
708 ]);
709 feed.plain(t(1, 0));
710 feed.plain(t(3, 0));
711 feed.plain(t(5, 100));
712 feed.plain(t(5, 200));
713 let out = feed.finish();
714 let range = |title: &str| (find(&out, title).start_line, find(&out, title).end_line);
715 assert_eq!(range("pre"), (0, 2));
716 assert_eq!(range("a"), (2, 4));
717 assert_eq!(range("b"), (4, 4));
718 }
719
720 #[test]
721 fn run_logs_outline_skipped_step_does_not_steal_the_next_marker() {
722 let mut feed = Feed::new(vec![
723 bound("first", PipelineStatus::Success, (1, 0), (1, 0)),
724 bound("skipped", PipelineStatus::Skipped, (5, 0), (5, 0)),
725 bound("real", PipelineStatus::Success, (5, 0), (6, 0)),
726 ]);
727 raw(&mut feed, 1, 0, "setup");
728 raw(&mut feed, 5, 100, "##[group]Run make");
729 raw(&mut feed, 5, 200, "##[endgroup]");
730 raw(&mut feed, 5, 300, "output");
731 let out = feed.finish();
732 let range = |title: &str| (find(&out, title).start_line, find(&out, title).end_line);
733 assert_eq!(range("first"), (0, 1));
734 assert_eq!(range("skipped"), (1, 1));
735 assert_eq!(range("real"), (1, 3));
736 }
737
738 #[test]
739 fn run_logs_outline_markers_after_a_steps_finish_second_are_not_taken() {
740 let mut feed = Feed::new(vec![
741 bound("s0", PipelineStatus::Success, (1, 0), (1, 0)),
742 bound("a", PipelineStatus::Success, (2, 0), (2, 0)),
743 bound("b", PipelineStatus::Success, (3, 0), (4, 0)),
744 ]);
745 raw(&mut feed, 1, 0, "setup");
746 raw(&mut feed, 2, 100, "a output");
747 raw(&mut feed, 4, 500, "##[group]Run x");
748 raw(&mut feed, 4, 600, "##[endgroup]");
749 raw(&mut feed, 4, 700, "b output");
750 let out = feed.finish();
751 let range = |title: &str| (find(&out, title).start_line, find(&out, title).end_line);
752 assert_eq!(range("s0"), (0, 1));
753 assert_eq!(range("a"), (1, 2));
754 assert_eq!(range("b"), (2, 4));
755 }
756
757 #[test]
758 fn run_logs_outline_unreached_and_unstarted_steps_are_empty_at_the_end() {
759 let mut feed = Feed::new(vec![
760 StepBound {
761 started_ms: None,
762 ..step("none", PipelineStatus::Pending, 0)
763 },
764 step("late", PipelineStatus::Pending, 50),
765 step("a", PipelineStatus::Success, 5),
766 ]);
767 feed.plain(t(5, 0));
768 feed.plain(t(6, 0));
769 let out = feed.finish();
770 assert_eq!(
771 (find(&out, "a").start_line, find(&out, "a").end_line),
772 (0, 2)
773 );
774 assert_eq!(
775 (find(&out, "late").start_line, find(&out, "late").end_line),
776 (2, 2)
777 );
778 assert_eq!(
779 (find(&out, "none").start_line, find(&out, "none").end_line),
780 (2, 2)
781 );
782 }
783
784 #[test]
785 fn run_logs_outline_failed_step_is_open_and_success_is_closed() {
786 let mut feed = Feed::new(vec![
787 step("ok", PipelineStatus::Success, 1),
788 step("bad", PipelineStatus::Failed, 2),
789 ]);
790 feed.plain(t(1, 0));
791 feed.plain(t(2, 0));
792 let out = feed.finish();
793 assert!(!find(&out, "ok").open);
794 assert!(find(&out, "bad").open);
795
796 let mut feed = Feed::new(vec![step("only", PipelineStatus::Success, 1)]);
797 feed.plain(t(1, 0));
798 assert!(feed.finish().sections[0].open);
799 }
800
801 #[test]
802 fn run_logs_outline_focus_is_first_error_of_failed_step() {
803 let mut feed = Feed::new(vec![
804 step("early", PipelineStatus::Success, 1),
805 step("bad", PipelineStatus::Failed, 2),
806 ]);
807 for _ in 0..20_000 {
808 feed.error(t(1, 0));
809 }
810 feed.plain(t(2, 0));
811 feed.error(t(2, 1));
812 feed.error(t(2, 2));
813 let out = feed.finish();
814 assert_eq!(out.focus_line, Some(20_001));
815 assert_eq!(out.error_total, 20_002);
816 assert_eq!(out.error_lines.len(), MARKER_LINES_CAP);
817 assert_eq!(find(&out, "early").error_count, 20_000);
818 assert_eq!(find(&out, "bad").error_count, 2);
819 }
820
821 #[test]
822 fn run_logs_outline_focus_falls_back_to_first_error() {
823 let mut feed = Feed::new(vec![
824 step("bad", PipelineStatus::Failed, 1),
825 step("ok", PipelineStatus::Success, 2),
826 ]);
827 feed.plain(t(1, 0));
828 feed.plain(t(2, 0));
829 feed.error(t(2, 1));
830 assert_eq!(feed.finish().focus_line, Some(2));
831
832 let mut feed = Feed::new(vec![]);
833 feed.plain(None);
834 let out = feed.finish();
835 assert_eq!(out.focus_line, None);
836 assert!(out.sections.is_empty());
837 }
838
839 #[test]
840 fn run_logs_outline_focus_lands_on_the_end_of_a_failed_step_without_error_lines() {
841 let mut feed = Feed::new(vec![
842 step("ok", PipelineStatus::Success, 1),
843 step("bad", PipelineStatus::Failed, 2),
844 step("after", PipelineStatus::Success, 3),
845 ]);
846 feed.plain(t(1, 0));
847 feed.plain(t(2, 0));
848 feed.plain(t(2, 1));
849 feed.plain(t(3, 0));
850 let out = feed.finish();
851 assert_eq!(out.error_total, 0);
852 assert_eq!(out.focus_line, Some(2));
853 }
854
855 #[test]
856 fn run_logs_outline_counts_errors_beyond_the_listed_cap() {
857 let mut feed = Feed::new(vec![
858 step("one", PipelineStatus::Success, 1),
859 step("two", PipelineStatus::Success, 2),
860 ]);
861 for _ in 0..MARKER_LINES_CAP {
862 feed.error(t(1, 0));
863 }
864 feed.plain(t(2, 0));
865 feed.group(t(2, 1), "late group");
866 for _ in 0..5 {
867 feed.error(t(2, 2));
868 }
869 feed.end(t(2, 3));
870 let out = feed.finish();
871 let group = find(&out, "late group");
872 assert_eq!(group.error_count, 5);
873 assert!(group.open);
874 assert_eq!(out.error_total as usize, MARKER_LINES_CAP + 5);
875 assert_eq!(out.error_lines.len(), MARKER_LINES_CAP);
876 }
877
878 #[test]
879 fn run_logs_outline_group_crossing_step_end_is_clamped_and_reparented() {
880 let mut feed = Feed::new(vec![
881 step("a", PipelineStatus::Success, 1),
882 step("b", PipelineStatus::Success, 2),
883 ]);
884 feed.group(t(1, 0), "outer");
885 feed.plain(t(1, 5));
886 feed.group(t(1, 6), "inner");
887 feed.plain(t(2, 0));
888 feed.group(t(2, 1), "third");
889 feed.plain(t(2, 2));
890 let out = feed.finish();
891 let a = find(&out, "a");
892 let b = find(&out, "b");
893 let outer = find(&out, "outer");
894 let inner = find(&out, "inner");
895 let third = find(&out, "third");
896 assert_eq!((a.end_line, b.start_line), (3, 3));
897 assert_eq!(outer.parent, Some(a.id));
898 assert_eq!(outer.end_line, 3);
899 assert_eq!(inner.parent, Some(outer.id));
900 assert_eq!(inner.end_line, 3);
901 assert_eq!(third.parent, Some(b.id));
902 assert_eq!(third.end_line, 6);
903 for (i, section) in out.sections.iter().enumerate() {
904 assert_eq!(section.id as usize, i);
905 assert!(section.parent.is_none_or(|p| p < section.id));
906 }
907 }
908
909 #[test]
910 fn run_logs_outline_group_duration_uses_first_and_last_timestamps() {
911 let mut feed = Feed::new(vec![]);
912 feed.group(t(1, 0), "g");
913 feed.plain(t(2, 0));
914 feed.end(t(3, 500));
915 let out = feed.finish();
916 assert_eq!(find(&out, "g").duration_ms, Some(2500));
917 }
918
919 #[test]
920 fn run_logs_outline_caps_sections_at_max() {
921 let steps = (0..25_000)
922 .map(|i| step(&format!("s{i}"), PipelineStatus::Success, i))
923 .collect();
924 let out = Feed::new(steps).finish();
925 assert_eq!(out.sections.len(), MAX_SECTIONS);
926 }
927
928 #[test]
929 fn run_logs_outline_ignored_group_ends_do_not_close_tracked_groups() {
930 let steps = (0..MAX_SECTIONS - 2)
931 .map(|i| step(&format!("s{i}"), PipelineStatus::Success, i as i64))
932 .collect();
933 let mut feed = Feed::new(steps);
934 feed.group(None, "A");
935 feed.group(None, "B");
936 feed.group(None, "C");
937 feed.group(None, "D");
938 feed.end(None);
939 feed.end(None);
940 feed.end(None);
941 feed.plain(None);
942 feed.end(None);
943 let out = feed.finish();
944 assert_eq!(out.sections.len(), MAX_SECTIONS);
945 let a = find(&out, "A");
946 let b = find(&out, "B");
947 assert_eq!((a.header_line, a.end_line), (Some(0), 5));
948 assert_eq!((b.header_line, b.end_line), (Some(1), 4));
949 assert_eq!(b.parent, Some(a.id));
950 }
951}