1mod diagnostics;
16mod io;
17mod lifecycle;
18mod matching;
19mod values;
20
21use std::collections::HashMap;
22use std::path::Path;
23use std::sync::Arc;
24use std::sync::Mutex;
25use std::time::Duration;
26use std::time::Instant;
27
28use relux_core::diagnostics::IrSpan;
29use relux_core::table::SourceTable;
30use relux_ir::IrTimeout;
31
32use super::ArtifactEntry;
33use super::EnvInfo;
34use super::SourceLocation;
35use super::StructuredLog;
36use super::TestInfo;
37use super::TestOutcome;
38use super::buffer::BufferEvent;
39use super::buffer::BufferEventKind;
40use super::event::Event;
41use super::event::EventKind;
42use super::event::EventSeq;
43use super::event::TimeoutValue;
44use super::span::Span;
45use super::span::SpanId;
46use crate::observe::progress::ProgressEvent;
47use crate::observe::progress::ProgressTx;
48
49#[derive(Clone)]
53pub struct StructuredLogBuilder {
54 pub(super) inner: Arc<Mutex<BuilderInner>>,
55 pub(super) test_start: Instant,
56 pub(super) sources: SourceTable,
57 pub(super) project_root: Arc<Path>,
58 pub(super) progress_tx: ProgressTx,
59}
60
61pub struct SpanGuard {
67 id: Option<SpanId>,
68 log: StructuredLogBuilder,
69}
70
71impl SpanGuard {
72 pub(super) fn new(id: SpanId, log: StructuredLogBuilder) -> Self {
73 Self { id: Some(id), log }
74 }
75
76 pub fn id(&self) -> SpanId {
77 self.id.expect("span guard already closed")
78 }
79
80 pub fn close(mut self) {
81 if let Some(id) = self.id.take() {
82 self.log.close_span_inner(id);
83 }
84 }
85}
86
87impl Drop for SpanGuard {
88 fn drop(&mut self) {
89 if let Some(id) = self.id.take() {
90 self.log.close_span_inner(id);
91 }
92 }
93}
94
95pub(super) struct BuilderInner {
96 pub(super) next_seq: EventSeq,
97 pub(super) next_span_id: SpanId,
98 pub(super) spans: HashMap<SpanId, Span>,
99 pub(super) events: Vec<Event>,
100 pub(super) buffer_events: Vec<BufferEvent>,
101 pub(super) shells: HashMap<String, super::shell::ShellRecord>,
102}
103
104impl StructuredLogBuilder {
105 pub fn new(
106 progress_tx: ProgressTx,
107 test_start: Instant,
108 sources: SourceTable,
109 project_root: Arc<Path>,
110 ) -> Self {
111 Self {
112 inner: Arc::new(Mutex::new(BuilderInner {
113 next_seq: 0,
114 next_span_id: 0,
115 spans: HashMap::new(),
116 events: Vec::new(),
117 buffer_events: Vec::new(),
118 shells: HashMap::new(),
119 })),
120 test_start,
121 sources,
122 project_root,
123 progress_tx,
124 }
125 }
126
127 pub(super) fn now(&self) -> Duration {
128 self.test_start.elapsed()
129 }
130
131 pub(crate) fn resolve_location(&self, span: &IrSpan) -> Option<SourceLocation> {
132 let file_id = span.file();
133 let source_file = self.sources.get(file_id)?;
134 let line = source_file.line_at(span.span().start());
135 let rel_path = source_file
136 .path
137 .strip_prefix(&*self.project_root)
138 .unwrap_or(&source_file.path);
139 Some(SourceLocation {
140 file: rel_path.display().to_string(),
141 line,
142 start: span.span().start(),
143 end: span.span().end(),
144 })
145 }
146
147 pub(super) fn timeout_value(&self, t: &IrTimeout) -> TimeoutValue {
148 match t {
149 IrTimeout::Tolerance {
150 duration,
151 multiplier,
152 span,
153 } => TimeoutValue::Tolerance {
154 duration: humantime::format_duration(*duration).to_string(),
155 multiplier: format_multiplier(*multiplier),
156 total_duration: humantime::format_duration(t.adjusted_duration()).to_string(),
157 source: self.resolve_location(span),
158 },
159 IrTimeout::Assertion { duration, span } => TimeoutValue::Assertion {
160 duration: humantime::format_duration(*duration).to_string(),
161 source: self.resolve_location(span),
162 },
163 }
164 }
165
166 pub(super) fn push_progress(&self, event: ProgressEvent) {
167 let _ = self.progress_tx.send(event);
168 }
169
170 pub fn current_seq(&self) -> EventSeq {
174 let inner = self.inner.lock().unwrap();
175 inner.next_seq.saturating_sub(1)
176 }
177
178 #[cfg(test)]
181 pub(crate) fn buffer_events_for_tests(&self) -> Vec<BufferEvent> {
182 self.inner.lock().unwrap().buffer_events.clone()
183 }
184
185 #[cfg(test)]
186 pub(crate) fn sources_for_tests(&self) -> &relux_core::table::SourceTable {
187 &self.sources
188 }
189
190 #[cfg(test)]
191 pub(crate) fn resolve_location_for_tests(&self, span: &IrSpan) -> Option<SourceLocation> {
192 self.resolve_location(span)
193 }
194
195 pub fn push_event(
196 &self,
197 span: SpanId,
198 shell: Option<&str>,
199 shell_marker: Option<&str>,
200 location: Option<&IrSpan>,
201 kind: EventKind,
202 ) -> EventSeq {
203 let source = location.and_then(|s| self.resolve_location(s));
204 let ts = self.now();
205 let mut inner = self.inner.lock().unwrap();
206 let seq = inner.next_seq;
207 inner.next_seq += 1;
208 inner.events.push(Event {
209 seq,
210 ts,
211 span,
212 shell: shell.map(String::from),
213 shell_marker: shell_marker.map(String::from),
214 source,
215 kind,
216 });
217 seq
218 }
219
220 pub fn push_buffer_event(
221 &self,
222 shell: &str,
223 shell_marker: &str,
224 kind: BufferEventKind,
225 ) -> EventSeq {
226 let ts = self.now();
227 let mut inner = self.inner.lock().unwrap();
228 let seq = inner.next_seq;
229 inner.next_seq += 1;
230 inner.buffer_events.push(BufferEvent {
231 seq,
232 ts,
233 shell: shell.to_string(),
234 shell_marker: shell_marker.to_string(),
235 kind,
236 });
237 seq
238 }
239
240 pub fn build(
241 self,
242 info: TestInfo,
243 env: EnvInfo,
244 outcome: TestOutcome,
245 artifacts: Vec<ArtifactEntry>,
246 ) -> StructuredLog {
247 let inner = match Arc::try_unwrap(self.inner) {
248 Ok(mutex) => mutex.into_inner().unwrap(),
249 Err(arc) => {
250 let mut guard = arc.lock().unwrap();
251 BuilderInner {
252 next_seq: guard.next_seq,
253 next_span_id: guard.next_span_id,
254 spans: std::mem::take(&mut guard.spans),
255 events: std::mem::take(&mut guard.events),
256 buffer_events: std::mem::take(&mut guard.buffer_events),
257 shells: std::mem::take(&mut guard.shells),
258 }
259 }
260 };
261
262 let mut referenced: std::collections::HashSet<String> = std::collections::HashSet::new();
263 for span in inner.spans.values() {
264 if let Some(loc) = &span.location {
265 referenced.insert(loc.file.clone());
266 }
267 }
268 for ev in &inner.events {
269 if let Some(loc) = &ev.source {
270 referenced.insert(loc.file.clone());
271 }
272 }
273
274 let mut sources: HashMap<String, String> = HashMap::new();
275 for (_, source_file) in self.sources.as_vec() {
276 let rel = source_file
277 .path
278 .strip_prefix(&*self.project_root)
279 .unwrap_or(&source_file.path)
280 .display()
281 .to_string();
282 if referenced.contains(&rel) {
283 sources.insert(rel, source_file.source.clone());
284 }
285 }
286
287 StructuredLog {
288 schema_version: super::SCHEMA_VERSION,
289 info,
290 outcome,
291 env,
292 shells: inner.shells,
293 spans: inner.spans,
294 events: inner.events,
295 buffer_events: inner.buffer_events,
296 sources,
297 artifacts,
298 }
299 }
300}
301
302fn format_multiplier(m: f64) -> String {
306 if m.fract() == 0.0 {
307 format!("{m:.1}")
308 } else {
309 format!("{m}")
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use std::path::PathBuf;
316
317 use super::super::span::FnCallKind;
318 use super::super::span::SpanKind;
319 use super::*;
320 use crate::observe::progress;
321
322 fn make_builder() -> (
323 StructuredLogBuilder,
324 tokio::sync::mpsc::UnboundedReceiver<ProgressEvent>,
325 ) {
326 let (tx, rx) = progress::channel();
327 let sources = relux_core::table::SharedTable::new();
328 let builder = StructuredLogBuilder::new(
329 tx,
330 Instant::now(),
331 sources,
332 Arc::from(PathBuf::from("/project").as_path()),
333 );
334 (builder, rx)
335 }
336
337 #[test]
338 fn seq_is_monotonic_across_event_and_buffer_pushes() {
339 let (b, _rx) = make_builder();
340 let test_span = b.open_span(SpanKind::Test { name: "t".into() }, None, None);
341 let id = test_span.id();
342 let s1 = b.push_event(
343 id,
344 Some("sh"),
345 Some("m"),
346 None,
347 EventKind::Send { data: "a".into() },
348 );
349 let s2 = b.push_buffer_event("sh", "m", BufferEventKind::Grew { data: "b".into() });
350 let s3 = b.push_event(
351 id,
352 Some("sh"),
353 Some("m"),
354 None,
355 EventKind::Recv { data: "c".into() },
356 );
357 assert_eq!(s1, 0);
358 assert_eq!(s2, 1);
359 assert_eq!(s3, 2);
360 }
361
362 #[test]
363 fn open_close_span_round_trips() {
364 let (b, _rx) = make_builder();
365 let span = b.open_span(SpanKind::Test { name: "t".into() }, None, None);
366 let id = span.id();
367 span.close();
368 let inner = b.inner.lock().unwrap();
369 let stored = inner.spans.get(&id).unwrap();
370 assert!(stored.end_ts.is_some());
371 assert!(stored.parent.is_none());
372 }
373
374 #[test]
375 fn span_guard_closes_on_drop() {
376 let (b, _rx) = make_builder();
377 let id = {
378 let span = b.open_span(SpanKind::Test { name: "t".into() }, None, None);
379 span.id()
380 };
382 let inner = b.inner.lock().unwrap();
383 assert!(inner.spans.get(&id).unwrap().end_ts.is_some());
384 }
385
386 #[test]
387 fn span_guard_explicit_close_then_drop_is_noop() {
388 let (b, _rx) = make_builder();
389 let span = b.open_span(SpanKind::Test { name: "t".into() }, None, None);
390 let id = span.id();
391 span.close();
392 let end_after_close = b.inner.lock().unwrap().spans.get(&id).unwrap().end_ts;
393 assert!(end_after_close.is_some());
394 let end_later = b.inner.lock().unwrap().spans.get(&id).unwrap().end_ts;
398 assert_eq!(end_after_close, end_later);
399 }
400
401 #[test]
402 fn span_ids_are_unique_and_parent_preserved() {
403 let (b, _rx) = make_builder();
404 let parent = b.open_span(SpanKind::Test { name: "t".into() }, None, None);
405 let parent_id = parent.id();
406 let child = b.open_span(
407 SpanKind::ShellBlock { shell: "sh".into() },
408 Some(parent_id),
409 None,
410 );
411 let child_id = child.id();
412 assert_ne!(parent_id, child_id);
413 let inner = b.inner.lock().unwrap();
414 assert_eq!(inner.spans.get(&child_id).unwrap().parent, Some(parent_id));
415 }
416
417 #[test]
418 fn shell_glossary_records_spawn_and_terminate() {
419 let (b, _rx) = make_builder();
420 b.record_shell_spawn("test-marker-0000", "default", "/bin/bash");
421 b.record_shell_terminate("test-marker-0000");
422 let inner = b.inner.lock().unwrap();
423 let rec = inner.shells.get("test-marker-0000").unwrap();
424 assert_eq!(rec.name, "default");
425 assert_eq!(rec.command, "/bin/bash");
426 assert!(rec.terminate_ts.is_some());
427 }
428
429 #[test]
430 fn clone_shares_storage() {
431 let (b, _rx) = make_builder();
432 let b2 = b.clone();
433 let span = b.open_span(SpanKind::Test { name: "t".into() }, None, None);
434 b2.push_event(
435 span.id(),
436 Some("sh"),
437 Some("m"),
438 None,
439 EventKind::Send { data: "x".into() },
440 );
441 let inner = b.inner.lock().unwrap();
442 assert_eq!(inner.events.len(), 1);
443 }
444
445 #[test]
446 fn build_consumes_builder_and_yields_log() {
447 let (b, _rx) = make_builder();
448 let span = b.open_span(SpanKind::Test { name: "t".into() }, None, None);
449 let id = span.id();
450 b.push_event(
451 id,
452 Some("sh"),
453 Some("m"),
454 None,
455 EventKind::Send { data: "x".into() },
456 );
457 b.push_buffer_event("sh", "m", BufferEventKind::Grew { data: "y".into() });
458 span.close();
459 let log = b.build(
460 TestInfo {
461 name: "t".into(),
462 path: "t.relux".into(),
463 duration_ms: 1,
464 },
465 EnvInfo::default(),
466 TestOutcome::Pass,
467 Vec::new(),
468 );
469 assert_eq!(log.events.len(), 1);
470 assert_eq!(log.buffer_events.len(), 1);
471 assert_eq!(log.spans.len(), 1);
472 assert!(matches!(log.outcome, TestOutcome::Pass));
473 }
474
475 #[test]
476 fn emit_send_pushes_event_and_progress() {
477 let (b, mut rx) = make_builder();
478 let span = b.open_span(SpanKind::Test { name: "t".into() }, None, None);
479 b.emit_send(span.id(), "sh", "m", "hello", None);
480 let inner = b.inner.lock().unwrap();
481 assert!(matches!(
482 &inner.events.last().unwrap().kind,
483 EventKind::Send { data } if data == "hello"
484 ));
485 drop(inner);
486 assert!(matches!(rx.try_recv(), Ok(ProgressEvent::Send)));
487 }
488
489 #[test]
490 fn emit_match_done_record_pushes_event_with_supplied_buffer_seq() {
491 let (b, _rx) = make_builder();
492 let span = b.open_span(SpanKind::Test { name: "t".into() }, None, None);
493 let buffer_seq = b.push_buffer_event(
496 "sh",
497 "m",
498 BufferEventKind::Matched {
499 before: "before".into(),
500 matched: "ok".into(),
501 after: "after".into(),
502 },
503 );
504 b.emit_match_done_record(
505 span.id(),
506 "sh",
507 "m",
508 "ok",
509 Duration::from_millis(5),
510 None,
511 buffer_seq,
512 None,
513 );
514 let inner = b.inner.lock().unwrap();
515 assert_eq!(inner.buffer_events.len(), 1);
516 let last = inner.events.last().unwrap();
517 match &last.kind {
518 EventKind::MatchDone {
519 buffer_seq: ev_seq, ..
520 } => assert_eq!(*ev_seq, buffer_seq),
521 _ => panic!("expected MatchDone"),
522 }
523 }
524
525 #[test]
526 fn resolve_stack_walks_parent_chain_root_to_leaf() {
527 let (b, _rx) = make_builder();
528 let test_span = b.open_span(SpanKind::Test { name: "t".into() }, None, None);
529 let test_id = test_span.id();
530 let block_span = b.open_span(
531 SpanKind::ShellBlock { shell: "sh".into() },
532 Some(test_id),
533 None,
534 );
535 let block_id = block_span.id();
536 let fn_span = b.open_span(
537 SpanKind::FnCall {
538 name: "do_thing".into(),
539 args: vec![("x".into(), "1".into())],
540 result: None,
541 callee_kind: FnCallKind::User,
542 is_pure: false,
543 },
544 Some(block_id),
545 None,
546 );
547 let fn_id = fn_span.id();
548
549 let frames = b.resolve_stack(fn_id);
550 assert_eq!(frames.len(), 3);
551 assert_eq!(frames[0].span, test_id);
552 assert_eq!(frames[0].kind, "test");
553 assert_eq!(frames[0].name.as_deref(), Some("t"));
554 assert_eq!(frames[1].span, block_id);
555 assert_eq!(frames[1].kind, "shell-block");
556 assert_eq!(frames[1].name.as_deref(), Some("sh"));
557 assert_eq!(frames[2].span, fn_id);
558 assert_eq!(frames[2].kind, "fn-call");
559 assert_eq!(frames[2].name.as_deref(), Some("do_thing"));
560 assert_eq!(frames[2].args, vec![("x".into(), "1".into())]);
561 }
562
563 #[test]
564 fn resolve_stack_renders_nested_pure_fn_chain_top_down() {
565 let (b, _rx) = make_builder();
570 let test_span = b.open_span(SpanKind::Test { name: "t".into() }, None, None);
571 let test_id = test_span.id();
572 let block_span = b.open_span(
573 SpanKind::ShellBlock { shell: "s".into() },
574 Some(test_id),
575 None,
576 );
577 let block_id = block_span.id();
578 let outer = b.open_span(
579 SpanKind::FnCall {
580 name: "outer".into(),
581 args: vec![("s".into(), "actual".into())],
582 result: None,
583 callee_kind: FnCallKind::User,
584 is_pure: true,
585 },
586 Some(block_id),
587 None,
588 );
589 let outer_id = outer.id();
590 let inner = b.open_span(
591 SpanKind::FnCall {
592 name: "inner".into(),
593 args: vec![("s".into(), "actual".into())],
594 result: None,
595 callee_kind: FnCallKind::User,
596 is_pure: true,
597 },
598 Some(outer_id),
599 None,
600 );
601 let inner_id = inner.id();
602
603 let frames = b.resolve_stack(inner_id);
605 let rendered: Vec<(&str, Option<&str>)> = frames
606 .iter()
607 .map(|f| (f.kind.as_str(), f.name.as_deref()))
608 .collect();
609 assert_eq!(
610 rendered,
611 vec![
612 ("test", Some("t")),
613 ("shell-block", Some("s")),
614 ("pure-fn-call", Some("outer")),
615 ("pure-fn-call", Some("inner")),
616 ]
617 );
618 assert_eq!(frames[2].span, outer_id);
619 assert_eq!(frames[3].span, inner_id);
620 }
621
622 #[test]
623 fn fn_call_round_trips_callee_kind_and_is_pure() {
624 let (b, _rx) = make_builder();
625 let test_span = b.open_span(SpanKind::Test { name: "t".into() }, None, None);
626 let test_id = test_span.id();
627 let fn_span = b.open_span(
628 SpanKind::FnCall {
629 name: "trim".into(),
630 args: vec![("$0".into(), "hi".into())],
631 result: None,
632 callee_kind: FnCallKind::Bif,
633 is_pure: true,
634 },
635 Some(test_id),
636 None,
637 );
638 let fn_id = fn_span.id();
639 fn_span.close();
640 test_span.close();
641
642 let inner = b.inner.lock().unwrap();
643 let stored = inner.spans.get(&fn_id).unwrap();
644 match &stored.kind {
645 SpanKind::FnCall {
646 callee_kind,
647 is_pure,
648 ..
649 } => {
650 assert_eq!(*callee_kind, FnCallKind::Bif);
651 assert!(*is_pure);
652 }
653 _ => panic!("expected FnCall"),
654 }
655 }
656
657 #[test]
658 fn current_seq_reflects_latest_emission() {
659 let (b, _rx) = make_builder();
660 assert_eq!(b.current_seq(), 0);
661 let span = b.open_span(SpanKind::Test { name: "t".into() }, None, None);
662 b.push_event(
663 span.id(),
664 Some("sh"),
665 Some("m"),
666 None,
667 EventKind::Send { data: "a".into() },
668 );
669 assert_eq!(b.current_seq(), 0);
670 b.push_buffer_event("sh", "m", BufferEventKind::Grew { data: "b".into() });
671 assert_eq!(b.current_seq(), 1);
672 }
673
674 #[test]
675 fn round_trip_serde_json() {
676 let (b, _rx) = make_builder();
677 let span = b.open_span(SpanKind::Test { name: "t".into() }, None, None);
678 let buffer_seq = b.push_buffer_event(
679 "sh",
680 "m",
681 BufferEventKind::Matched {
682 before: "".into(),
683 matched: "ok".into(),
684 after: "".into(),
685 },
686 );
687 b.emit_match_done_record(
688 span.id(),
689 "sh",
690 "m",
691 "ok",
692 Duration::from_millis(1),
693 None,
694 buffer_seq,
695 None,
696 );
697 span.close();
698 let log = b.build(
699 TestInfo {
700 name: "t".into(),
701 path: "t.relux".into(),
702 duration_ms: 1,
703 },
704 EnvInfo::default(),
705 TestOutcome::Pass,
706 Vec::new(),
707 );
708 let json = serde_json::to_string(&log).unwrap();
709 let back: StructuredLog = serde_json::from_str(&json).unwrap();
710 assert_eq!(back.events.len(), log.events.len());
711 assert_eq!(back.buffer_events.len(), log.buffer_events.len());
712 assert_eq!(back.spans.len(), log.spans.len());
713 }
714
715 #[test]
716 fn push_event_records_source_when_irspan_provided() {
717 use relux_core::Span as CoreSpan;
718 use relux_core::diagnostics::IrSpan;
719 use relux_core::table::FileId;
720
721 let (builder, _rx) = make_builder();
722 let path = PathBuf::from("/project/t.relux");
723 let file_id = FileId::new(path.clone());
724 let src = relux_core::table::SourceFile::new(path, "abcdef\n".into());
725 builder.sources_for_tests().insert(file_id.clone(), src);
726
727 let span_guard = builder.open_span(SpanKind::Test { name: "t".into() }, None, None);
728 let ir = IrSpan::new(file_id, CoreSpan::new(1, 4));
729 builder.push_event(
730 span_guard.id(),
731 None,
732 None,
733 Some(&ir),
734 EventKind::Annotate { text: "hi".into() },
735 );
736 drop(span_guard);
737
738 let log = builder.build(
739 TestInfo {
740 name: "t".into(),
741 path: "t".into(),
742 duration_ms: 0,
743 },
744 EnvInfo::default(),
745 TestOutcome::Pass,
746 Vec::new(),
747 );
748 let ev = log
749 .events
750 .iter()
751 .find(|e| matches!(e.kind, EventKind::Annotate { .. }))
752 .unwrap();
753 let src = ev.source.as_ref().expect("source present");
754 assert_eq!(src.start, 1);
755 assert_eq!(src.end, 4);
756 }
757
758 #[test]
759 fn build_populates_sources_for_referenced_files_only() {
760 use relux_core::Span as CoreSpan;
761 use relux_core::diagnostics::IrSpan;
762 use relux_core::table::FileId;
763
764 let (builder, _rx) = make_builder();
765 let path_used = PathBuf::from("/project/used.relux");
766 let path_unused = PathBuf::from("/project/unused.relux");
767 let fid_used = FileId::new(path_used.clone());
768 let fid_unused = FileId::new(path_unused.clone());
769 builder.sources_for_tests().insert(
770 fid_used.clone(),
771 relux_core::table::SourceFile::new(path_used, "u-content\n".into()),
772 );
773 builder.sources_for_tests().insert(
774 fid_unused,
775 relux_core::table::SourceFile::new(path_unused, "x-content\n".into()),
776 );
777
778 let test_span = builder.open_span(SpanKind::Test { name: "t".into() }, None, None);
779 let ir = IrSpan::new(fid_used, CoreSpan::new(0, 1));
780 builder.emit_annotate(test_span.id(), "sh", "m", "a", Some(&ir));
781 drop(test_span);
782
783 let log = builder.build(
784 TestInfo {
785 name: "t".into(),
786 path: "t".into(),
787 duration_ms: 0,
788 },
789 EnvInfo::default(),
790 TestOutcome::Pass,
791 Vec::new(),
792 );
793
794 assert_eq!(log.sources.len(), 1, "only referenced files in sources");
795 assert_eq!(
796 log.sources.get("used.relux"),
797 Some(&"u-content\n".to_string())
798 );
799 assert!(!log.sources.contains_key("unused.relux"));
800 }
801
802 #[test]
803 fn emit_send_records_source_from_irspan() {
804 use relux_core::Span as CoreSpan;
805 use relux_core::diagnostics::IrSpan;
806 use relux_core::table::FileId;
807
808 let (builder, _rx) = make_builder();
809 let path = PathBuf::from("/project/t.relux");
810 let file_id = FileId::new(path.clone());
811 let src = relux_core::table::SourceFile::new(path, "send hello\n".into());
812 builder.sources_for_tests().insert(file_id.clone(), src);
813
814 let test_span = builder.open_span(SpanKind::Test { name: "t".into() }, None, None);
815 let ir = IrSpan::new(file_id, CoreSpan::new(0, 4));
816 builder.emit_send(test_span.id(), "sh", "m", "hello", Some(&ir));
817 drop(test_span);
818
819 let log = builder.build(
820 TestInfo {
821 name: "t".into(),
822 path: "t".into(),
823 duration_ms: 0,
824 },
825 EnvInfo::default(),
826 TestOutcome::Pass,
827 Vec::new(),
828 );
829 let ev = log
830 .events
831 .iter()
832 .find(|e| matches!(e.kind, EventKind::Send { .. }))
833 .unwrap();
834 let s = ev.source.as_ref().expect("source set");
835 assert_eq!(s.start, 0);
836 assert_eq!(s.end, 4);
837 }
838
839 #[test]
840 fn source_location_carries_byte_range() {
841 use relux_core::Span as CoreSpan;
842 use relux_core::diagnostics::IrSpan;
843 use relux_core::table::FileId;
844
845 let (builder, _rx) = make_builder();
846 let path = PathBuf::from("/project/lib/x.relux");
847 let file_id = FileId::new(path.clone());
848 let src = relux_core::table::SourceFile::new(path, "line 1\nline 2\nline 3\n".into());
849 builder.sources_for_tests().insert(file_id.clone(), src);
850
851 let ir = IrSpan::new(file_id, CoreSpan::new(7, 13));
852 let loc = builder.resolve_location_for_tests(&ir).expect("resolve");
853 assert_eq!(loc.file, "lib/x.relux");
854 assert_eq!(loc.line, 2);
855 assert_eq!(loc.start, 7);
856 assert_eq!(loc.end, 13);
857 }
858
859 #[test]
860 fn test_outcome_skip_serde_round_trip() {
861 use super::super::skip::SkipRecord;
862 use super::super::span::MarkerEvalDetail;
863 use super::super::span::MarkerEvalKind;
864
865 let original = TestOutcome::Skip(SkipRecord {
866 span: 42u64,
867 event_seq: 7u64,
868 marker_kind: MarkerEvalKind::Skip,
869 evaluation: MarkerEvalDetail::Unconditional,
870 location: None,
871 });
872 let json = serde_json::to_string(&original).unwrap();
873 assert!(
874 json.contains("\"kind\":\"skip\""),
875 "expected `\"kind\":\"skip\"` in JSON, got: {json}"
876 );
877 let parsed: TestOutcome = serde_json::from_str(&json).unwrap();
878 match parsed {
879 TestOutcome::Skip(rec) => {
880 assert_eq!(rec.span, 42u64);
881 assert_eq!(rec.event_seq, 7u64);
882 }
883 other => panic!("expected TestOutcome::Skip, got {other:?}"),
884 }
885 }
886
887 #[test]
888 fn open_multimatch_span_attaches_to_parent_and_uses_shell() {
889 let (b, _rx) = make_builder();
890 let parent = b.open_span(SpanKind::Test { name: "t".into() }, None, None);
891 let parent_id = parent.id();
892 let mm = b.open_multimatch_span(parent_id, "default", None);
893 let mm_id = mm.id();
894 let inner = b.inner.lock().unwrap();
895 let stored = inner.spans.get(&mm_id).unwrap();
896 assert_eq!(stored.parent, Some(parent_id));
897 match &stored.kind {
898 SpanKind::MultiMatch { shell } => assert_eq!(shell, "default"),
899 _ => panic!("expected MultiMatch span"),
900 }
901 }
902
903 #[test]
904 fn emit_multimatch_start_pushes_event_with_patterns_and_effective() {
905 use crate::observe::structured::event::MultiMatchPattern;
906 use relux_ir::IrTimeout;
907 let (b, _rx) = make_builder();
908 let span = b.open_span(SpanKind::Test { name: "t".into() }, None, None);
909 let effective = IrTimeout::tolerance(Duration::from_secs(5));
910 let patterns = vec![
911 MultiMatchPattern {
912 pattern: "^ok$".into(),
913 is_regex: true,
914 },
915 MultiMatchPattern {
916 pattern: "done".into(),
917 is_regex: false,
918 },
919 ];
920 b.emit_multimatch_start(span.id(), "sh", "m", &patterns, &effective, None);
921 let inner = b.inner.lock().unwrap();
922 match &inner.events.last().unwrap().kind {
923 EventKind::MultiMatchStart { patterns: p, .. } => {
924 assert_eq!(p.len(), 2);
925 assert_eq!(p[0].pattern, "^ok$");
926 assert!(p[0].is_regex);
927 assert!(!p[1].is_regex);
928 }
929 other => panic!("expected MultiMatchStart, got {other:?}"),
930 }
931 }
932
933 #[test]
934 fn emit_multimatch_pattern_done_records_index_elapsed_and_buffer_seq() {
935 let (b, _rx) = make_builder();
936 let span = b.open_span(SpanKind::Test { name: "t".into() }, None, None);
937 let buffer_seq = b.push_buffer_event(
938 "sh",
939 "m",
940 BufferEventKind::Matched {
941 before: "".into(),
942 matched: "ok".into(),
943 after: "".into(),
944 },
945 );
946 b.emit_multimatch_pattern_done(
947 span.id(),
948 "sh",
949 "m",
950 1,
951 Duration::from_millis(7),
952 buffer_seq,
953 None,
954 );
955 let inner = b.inner.lock().unwrap();
956 match &inner.events.last().unwrap().kind {
957 EventKind::MultiMatchPatternDone {
958 index,
959 buffer_seq: ev_seq,
960 ..
961 } => {
962 assert_eq!(*index, 1);
963 assert_eq!(*ev_seq, buffer_seq);
964 }
965 other => panic!("expected MultiMatchPatternDone, got {other:?}"),
966 }
967 }
968
969 #[test]
970 fn emit_multimatch_done_records_advance_to_seq() {
971 let (b, _rx) = make_builder();
972 let span = b.open_span(SpanKind::Test { name: "t".into() }, None, None);
973 let buffer_seq = b.push_buffer_event(
974 "sh",
975 "m",
976 BufferEventKind::Matched {
977 before: "".into(),
978 matched: "longest".into(),
979 after: "".into(),
980 },
981 );
982 b.emit_multimatch_done(span.id(), "sh", "m", buffer_seq, None);
983 let inner = b.inner.lock().unwrap();
984 match &inner.events.last().unwrap().kind {
985 EventKind::MultiMatchDone { advance_to } => assert_eq!(*advance_to, buffer_seq),
986 other => panic!("expected MultiMatchDone, got {other:?}"),
987 }
988 }
989
990 #[test]
991 fn emit_multimatch_timeout_records_unmatched_indices() {
992 let (b, _rx) = make_builder();
993 let span = b.open_span(SpanKind::Test { name: "t".into() }, None, None);
994 b.emit_multimatch_timeout(span.id(), "sh", "m", &[0, 2], None);
995 let inner = b.inner.lock().unwrap();
996 match &inner.events.last().unwrap().kind {
997 EventKind::MultiMatchTimeout { unmatched } => {
998 assert_eq!(unmatched, &vec![0usize, 2]);
999 }
1000 other => panic!("expected MultiMatchTimeout, got {other:?}"),
1001 }
1002 }
1003
1004 #[test]
1005 fn failure_record_translates_multimatch() {
1006 use crate::observe::structured::event::MultiMatchPattern;
1007 use crate::observe::structured::failure::FailureRecord;
1008 use crate::report::result::Failure;
1009 use crate::report::result::FailureContext;
1010 use relux_core::diagnostics::IrSpan;
1011 use relux_ir::IrTimeout;
1012
1013 let (b, _rx) = make_builder();
1014 let f = Failure::MultiMatch {
1015 shell: "default".into(),
1016 patterns: vec![
1017 MultiMatchPattern {
1018 pattern: "^a$".into(),
1019 is_regex: true,
1020 },
1021 MultiMatchPattern {
1022 pattern: "b".into(),
1023 is_regex: false,
1024 },
1025 ],
1026 matched: vec![1],
1027 span: IrSpan::synthetic(),
1028 effective: Box::new(IrTimeout::tolerance(Duration::from_secs(5))),
1029 context: FailureContext::pre_vm(),
1030 };
1031 let rec = b.failure_record(&f);
1032 match rec {
1033 FailureRecord::MultiMatch {
1034 shell,
1035 patterns,
1036 matched,
1037 ..
1038 } => {
1039 assert_eq!(shell, "default");
1040 assert_eq!(patterns.len(), 2);
1041 assert_eq!(matched, vec![1]);
1042 }
1043 other => panic!("expected MultiMatch, got {other:?}"),
1044 }
1045 }
1046}