1use serde::Serialize;
21
22#[derive(Debug, PartialEq, Serialize)]
24pub struct TestRun {
25 pub passed: u32,
26 pub failed: u32,
27 #[serde(skip_serializing_if = "is_zero")]
30 pub skipped: u32,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub suites: Option<Vec<Suite>>,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub error: Option<TestError>,
35 #[serde(skip_serializing_if = "Option::is_none")]
39 pub coverage: Option<Coverage>,
40 #[serde(skip_serializing_if = "Option::is_none")]
44 pub transport: Option<String>,
45}
46
47fn is_zero(n: &u32) -> bool {
48 *n == 0
49}
50
51#[derive(Debug, PartialEq, Serialize)]
56pub struct Coverage {
57 pub covered: u32,
59 pub lines: u32,
61 pub percent: u32,
63 pub files: Vec<FileCoverage>,
64}
65
66#[derive(Debug, PartialEq, Serialize)]
68pub struct FileCoverage {
69 pub path: String,
70 pub covered: u32,
71 pub lines: u32,
72 pub percent: u32,
73 pub uncovered: Vec<u32>,
75 pub covered_lines: Vec<u32>,
80}
81
82impl From<&crate::coverage::CoverageReport> for Coverage {
83 fn from(r: &crate::coverage::CoverageReport) -> Self {
84 Coverage {
85 covered: r.total_covered(),
86 lines: r.total_lines(),
87 percent: r.total_percent(),
88 files: r
89 .files
90 .iter()
91 .map(|f| FileCoverage {
92 path: f.path.clone(),
93 covered: f.covered,
94 lines: f.total,
95 percent: crate::coverage::percent(f.covered, f.total),
96 uncovered: f.uncovered.clone(),
97 covered_lines: f.covered_lines.clone(),
98 })
99 .collect(),
100 }
101 }
102}
103
104#[derive(Debug, PartialEq, Serialize)]
105pub struct Suite {
106 pub name: String,
107 pub kind: String,
108 pub cases: Vec<Case>,
109}
110
111#[derive(Debug, PartialEq, Serialize)]
112pub struct Case {
113 pub name: String,
114 pub outcome: String,
118 #[serde(skip_serializing_if = "Option::is_none")]
119 pub message: Option<String>,
120 #[serde(skip_serializing_if = "Option::is_none")]
121 pub location: Option<Location>,
122 #[serde(skip_serializing_if = "Option::is_none")]
125 pub retries: Option<u32>,
126 #[serde(skip_serializing_if = "Option::is_none")]
131 pub tier: Option<String>,
132 #[serde(skip_serializing_if = "Option::is_none")]
137 pub kind: Option<String>,
138 #[serde(skip_serializing_if = "Option::is_none")]
141 pub seed: Option<String>,
142 #[serde(skip_serializing_if = "Option::is_none")]
145 pub counterexample: Option<String>,
146 #[serde(skip_serializing_if = "Option::is_none")]
149 pub violation: Option<Violation>,
150 #[serde(skip_serializing_if = "Option::is_none")]
155 pub refusals: Option<Vec<Refusal>>,
156 #[serde(skip)]
160 pub duration_ms: Option<f64>,
161}
162
163#[derive(Debug, PartialEq, Serialize)]
164pub struct Location {
165 pub path: String,
166 pub line: u32,
167 pub col: u32,
168}
169
170#[derive(Debug, PartialEq, Serialize)]
174pub struct Violation {
175 pub kind: String,
176 pub agent: String,
177 pub name: String,
178 pub location: Option<Location>,
179}
180
181#[derive(Debug, PartialEq, Serialize)]
185pub struct Refusal {
186 pub index: u32,
187 pub call: String,
188 pub kind: String,
189 pub agent: String,
190 pub name: String,
191 pub location: Option<Location>,
192}
193
194#[derive(Debug, PartialEq, Serialize)]
195pub struct TestError {
196 pub kind: String,
197 #[serde(skip_serializing_if = "Option::is_none")]
198 pub message: Option<String>,
199 #[serde(skip_serializing_if = "Vec::is_empty")]
202 pub diagnostics: Vec<String>,
203 #[serde(skip_serializing_if = "Option::is_none")]
205 pub stderr: Option<String>,
206}
207
208impl TestRun {
209 pub fn empty() -> Self {
211 TestRun {
212 passed: 0,
213 failed: 0,
214 skipped: 0,
215 suites: Some(Vec::new()),
216 error: None,
217 coverage: None,
218 transport: None,
219 }
220 }
221
222 pub fn with_coverage(mut self, report: &crate::coverage::CoverageReport) -> Self {
227 if !report.is_empty() {
228 self.coverage = Some(Coverage::from(report));
229 }
230 self
231 }
232
233 pub fn discovered(suites: Vec<Suite>) -> Self {
240 TestRun {
241 passed: 0,
242 failed: 0,
243 skipped: 0,
244 suites: Some(suites),
245 error: None,
246 coverage: None,
247 transport: None,
248 }
249 }
250
251 pub fn runtime_error(message: impl Into<String>, stderr: Option<String>) -> Self {
256 TestRun {
257 passed: 0,
258 failed: 0,
259 skipped: 0,
260 suites: None,
261 error: Some(TestError {
262 kind: "runtime".to_string(),
263 message: Some(message.into()),
264 diagnostics: Vec::new(),
265 stderr: stderr.filter(|s| !s.trim().is_empty()),
266 }),
267 coverage: None,
268 transport: None,
269 }
270 }
271
272 pub fn compile_error(diagnostics: Vec<String>) -> Self {
274 TestRun {
275 passed: 0,
276 failed: 0,
277 skipped: 0,
278 suites: None,
279 error: Some(TestError {
280 kind: "compile".to_string(),
281 message: None,
282 diagnostics,
283 stderr: None,
284 }),
285 coverage: None,
286 transport: None,
287 }
288 }
289
290 pub fn with_tiers(mut self, discovered: &[bynk_emit::project::DiscoveredSuite]) -> Self {
294 for suite in self.suites.iter_mut().flatten() {
295 let Some(known) = discovered.iter().find(|d| d.name == suite.name) else {
296 continue;
297 };
298 for case in &mut suite.cases {
299 if let Some(tier) = known
300 .cases
301 .iter()
302 .find(|c| c.name == case.name)
303 .and_then(|c| c.tier)
304 {
305 case.tier = Some(tier.to_string());
306 }
307 }
308 }
309 self
310 }
311
312 pub fn with_remote(mut self, transport: String, skipped: &[(String, String, String)]) -> Self {
316 self.transport = Some(transport);
317 if skipped.is_empty() {
318 return self;
319 }
320 self.skipped = u32::try_from(skipped.len()).unwrap_or(u32::MAX);
321 let suites = self.suites.get_or_insert_with(Vec::new);
322 for (suite, case, reason) in skipped {
323 let case = Case {
324 name: case.clone(),
325 outcome: "skipped".to_string(),
326 message: Some(reason.clone()),
327 location: None,
328 retries: None,
329 tier: Some("system".to_string()),
331 kind: None,
332 seed: None,
333 counterexample: None,
334 violation: None,
335 refusals: None,
336 duration_ms: None,
337 };
338 match suites.iter_mut().find(|s| &s.name == suite) {
339 Some(s) => s.cases.push(case),
340 None => suites.push(Suite {
341 name: suite.clone(),
342 kind: "integration".to_string(),
343 cases: vec![case],
344 }),
345 }
346 }
347 self
348 }
349
350 pub fn render(&self) -> String {
353 let mut s = serde_json::to_string_pretty(self).expect("TestRun serialises");
354 s.push('\n');
355 s
356 }
357}
358
359#[derive(Debug, Default, Clone, PartialEq, Eq)]
363pub struct Unmatched {
364 pub suites: Vec<String>,
365 pub cases: Vec<String>,
366}
367
368impl Unmatched {
369 pub fn is_empty(&self) -> bool {
370 self.suites.is_empty() && self.cases.is_empty()
371 }
372
373 pub fn messages(&self) -> Vec<String> {
375 let suites = self
376 .suites
377 .iter()
378 .map(|s| format!("no suite named --suite {s:?}"));
379 let cases = self
380 .cases
381 .iter()
382 .map(|c| format!("no case matched --case {c:?}"));
383 suites.chain(cases).collect()
384 }
385
386 pub fn report(&self, program: &str) {
389 for message in self.messages() {
390 eprintln!("{program} test: {message}");
391 }
392 }
393}
394
395#[derive(Debug, Default, PartialEq)]
399pub struct ParsedRun {
400 pub passed: u32,
401 pub failed: u32,
402 pub suites: Vec<Suite>,
403 pub complete: bool,
404 pub unmatched: Unmatched,
406}
407
408impl ParsedRun {
409 pub fn into_document(self, stderr: &str) -> TestRun {
414 if self.complete {
415 TestRun {
416 passed: self.passed,
417 failed: self.failed,
418 skipped: 0,
419 suites: Some(self.suites),
420 error: None,
421 coverage: None,
422 transport: None,
423 }
424 } else {
425 let trimmed = stderr.trim();
426 TestRun {
427 passed: self.passed,
428 failed: self.failed,
429 skipped: 0,
430 suites: Some(self.suites),
431 error: Some(TestError {
432 kind: "runtime".to_string(),
433 message: Some("the test runner exited before completing".to_string()),
434 diagnostics: Vec::new(),
435 stderr: (!trimmed.is_empty()).then(|| trimmed.to_string()),
436 }),
437 coverage: None,
438 transport: None,
439 }
440 }
441 }
442}
443
444pub fn parse_ndjson(stdout: &str) -> ParsedRun {
448 let mut run = ParsedRun::default();
449 for line in stdout.lines() {
450 let line = line.trim();
451 if line.is_empty() {
452 continue;
453 }
454 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
455 continue;
456 };
457 match value.get("type").and_then(|t| t.as_str()) {
458 Some("suite-begin") => {
459 run.suites.push(Suite {
460 name: str_field(&value, "name"),
461 kind: str_field(&value, "kind"),
462 cases: Vec::new(),
463 });
464 }
465 Some("case") => {
466 let case = case_from_event(&value);
467 if case.outcome == "pass" {
468 run.passed += 1;
469 } else {
470 run.failed += 1;
471 }
472 if let Some(suite) = run.suites.last_mut() {
473 suite.cases.push(case);
474 }
475 }
476 Some("run-end") => {
477 run.complete = true;
478 run.unmatched = unmatched_from_event(&value);
479 }
480 _ => {}
481 }
482 }
483 run
484}
485
486pub(crate) fn case_from_event(value: &serde_json::Value) -> Case {
489 let message = value
490 .get("message")
491 .and_then(|m| m.as_str())
492 .map(str::to_string);
493 let location = value
494 .get("location")
495 .and_then(|l| l.as_str())
496 .and_then(parse_location);
497 let retries = value
498 .get("retries")
499 .and_then(serde_json::Value::as_u64)
500 .filter(|n| *n > 0)
501 .map(|n| u32::try_from(n).unwrap_or(u32::MAX));
502 let violation = value.get("violation").map(violation_from_json);
503 let refusals = refusals_from_json(value.get("refusals"));
504 Case {
505 name: str_field(value, "name"),
506 outcome: str_field(value, "outcome"),
507 message,
508 location,
509 retries,
510 tier: None,
511 kind: str_opt(value, "kind"),
512 seed: str_opt(value, "seed"),
513 counterexample: str_opt(value, "counterexample"),
514 violation,
515 refusals,
516 duration_ms: value.get("durationMs").and_then(serde_json::Value::as_f64),
517 }
518}
519
520fn location_from_json(value: &serde_json::Value) -> Option<Location> {
523 value.get("location").and_then(|l| match l {
524 serde_json::Value::String(s) => parse_location(s),
525 serde_json::Value::Object(_) => Some(Location {
526 path: str_field(l, "path"),
527 line: l
528 .get("line")
529 .and_then(serde_json::Value::as_u64)
530 .unwrap_or(0) as u32,
531 col: l
532 .get("col")
533 .and_then(serde_json::Value::as_u64)
534 .unwrap_or(0) as u32,
535 }),
536 _ => None,
537 })
538}
539
540pub(crate) fn violation_from_json(value: &serde_json::Value) -> Violation {
542 Violation {
543 kind: str_field(value, "kind"),
544 agent: str_field(value, "agent"),
545 name: str_field(value, "name"),
546 location: location_from_json(value),
547 }
548}
549
550pub(crate) fn refusals_from_json(value: Option<&serde_json::Value>) -> Option<Vec<Refusal>> {
553 let refusals: Vec<Refusal> = value?
554 .as_array()?
555 .iter()
556 .filter_map(|r| {
557 Some(Refusal {
558 index: u32::try_from(r.get("index")?.as_u64()?).ok()?,
559 call: r.get("call")?.as_str()?.to_string(),
560 kind: r.get("kind")?.as_str()?.to_string(),
561 agent: r.get("agent")?.as_str()?.to_string(),
562 name: r.get("name")?.as_str()?.to_string(),
563 location: location_from_json(r),
564 })
565 })
566 .collect();
567 (!refusals.is_empty()).then_some(refusals)
568}
569
570pub(crate) fn unmatched_from_event(value: &serde_json::Value) -> Unmatched {
572 let Some(unmatched) = value.get("unmatched") else {
573 return Unmatched::default();
574 };
575 let strings = |key: &str| -> Vec<String> {
576 unmatched
577 .get(key)
578 .and_then(|v| v.as_array())
579 .map(|a| {
580 a.iter()
581 .filter_map(|v| v.as_str())
582 .map(str::to_string)
583 .collect()
584 })
585 .unwrap_or_default()
586 };
587 Unmatched {
588 suites: strings("suites"),
589 cases: strings("cases"),
590 }
591}
592
593fn str_opt(value: &serde_json::Value, key: &str) -> Option<String> {
594 value.get(key).and_then(|v| v.as_str()).map(str::to_string)
595}
596
597fn str_field(value: &serde_json::Value, key: &str) -> String {
598 value
599 .get(key)
600 .and_then(|v| v.as_str())
601 .unwrap_or_default()
602 .to_string()
603}
604
605fn parse_location(s: &str) -> Option<Location> {
610 let (rest, col) = s.rsplit_once(':')?;
611 let (path, line) = rest.rsplit_once(':')?;
612 let line: u32 = line.parse().ok()?;
613 let col: u32 = col.parse().ok()?;
614 if path.is_empty() {
615 return None;
616 }
617 Some(Location {
618 path: path.to_string(),
619 line,
620 col,
621 })
622}
623
624#[cfg(test)]
625mod tests {
626 use super::*;
627
628 fn known(
629 suite: &str,
630 cases: &[(&str, Option<&'static str>)],
631 ) -> bynk_emit::project::DiscoveredSuite {
632 bynk_emit::project::DiscoveredSuite {
633 name: suite.to_string(),
634 kind: "unit",
635 cases: cases
636 .iter()
637 .map(|(name, tier)| bynk_emit::project::DiscoveredCase {
638 name: (*name).to_string(),
639 location: None,
640 tier: *tier,
641 generative: None,
642 })
643 .collect(),
644 skipped: Vec::new(),
645 envs: Vec::new(),
646 }
647 }
648
649 #[test]
652 fn a_cases_duration_is_read_but_never_written_to_the_document() {
653 let ndjson = concat!(
654 r#"{"type":"suite-begin","name":"s","kind":"unit","tests":1}"#,
655 "\n",
656 r#"{"type":"case","suite":"s","name":"c","outcome":"pass","durationMs":1.5}"#,
657 "\n",
658 r#"{"type":"suite-end","name":"s"}"#,
659 "\n",
660 r#"{"type":"run-end","passed":1,"failed":0}"#,
661 "\n",
662 );
663 let parsed = parse_ndjson(ndjson);
664 assert_eq!(parsed.suites[0].cases[0].duration_ms, Some(1.5));
665 let rendered = parsed.into_document("").render();
666 assert!(!rendered.contains("durationMs"), "{rendered}");
667 }
668
669 #[test]
672 fn run_end_reports_the_values_a_filter_selected_nothing_for() {
673 let ended = parse_ndjson(concat!(
674 r#"{"type":"run-begin","suites":0}"#,
675 "\n",
676 r#"{"type":"run-end","passed":0,"failed":0,"unmatched":{"suites":["nope"],"cases":["typo","other"]}}"#,
677 "\n",
678 ));
679 assert!(ended.complete);
680 assert_eq!(ended.unmatched.suites, vec!["nope".to_string()]);
681 assert_eq!(
682 ended.unmatched.cases,
683 vec!["typo".to_string(), "other".to_string()]
684 );
685 assert_eq!(
686 ended.unmatched.messages(),
687 vec![
688 "no suite named --suite \"nope\"".to_string(),
689 "no case matched --case \"typo\"".to_string(),
690 "no case matched --case \"other\"".to_string(),
691 ]
692 );
693
694 let plain = parse_ndjson(r#"{"type":"run-end","passed":1,"failed":0}"#);
695 assert!(plain.unmatched.is_empty());
696 }
697
698 #[test]
703 fn a_run_takes_each_cases_tier_from_the_case_table() {
704 let ndjson = concat!(
705 r#"{"type":"suite-begin","name":"shop","kind":"unit"}"#,
706 "\n",
707 r#"{"type":"case","name":"wired","outcome":"pass"}"#,
708 "\n",
709 r#"{"type":"case","name":"plain","outcome":"fail","message":"no"}"#,
710 "\n",
711 r#"{"type":"case","name":"a property","outcome":"pass"}"#,
712 "\n",
713 r#"{"type":"case","name":"unlisted","outcome":"pass"}"#,
714 "\n",
715 r#"{"type":"suite-begin","name":"other","kind":"unit"}"#,
716 "\n",
717 r#"{"type":"case","name":"wired","outcome":"pass"}"#,
718 "\n",
719 r#"{"type":"run-end"}"#,
720 "\n",
721 );
722 let table = [
723 known(
724 "shop",
725 &[
726 ("wired", Some("system")),
727 ("plain", Some("unit")),
728 ("a property", None),
729 ],
730 ),
731 known("other", &[("wired", Some("integration"))]),
732 ];
733 let doc = parse_ndjson(ndjson).into_document("").with_tiers(&table);
734 let suites = doc.suites.unwrap();
735 let tiers = |i: usize| -> Vec<Option<String>> {
736 suites[i].cases.iter().map(|c| c.tier.clone()).collect()
737 };
738 assert_eq!(
739 tiers(0),
740 [Some("system".into()), Some("unit".into()), None, None]
741 );
742 assert_eq!(
743 tiers(1),
744 [Some("integration".into())],
745 "the same name in another suite"
746 );
747 }
748
749 #[test]
752 fn file_coverage_carries_covered_and_uncovered_lines() {
753 let report = crate::coverage::CoverageReport {
754 files: vec![crate::coverage::FileCoverage {
755 path: "src/demo/m.bynk".into(),
756 covered: 2,
757 total: 5,
758 uncovered: vec![7, 8, 9],
759 covered_lines: vec![3, 4],
760 }],
761 };
762 assert_eq!(
763 serde_json::to_string(&Coverage::from(&report)).unwrap(),
764 r#"{"covered":2,"lines":5,"percent":40,"files":[{"path":"src/demo/m.bynk","covered":2,"lines":5,"percent":40,"uncovered":[7,8,9],"covered_lines":[3,4]}]}"#
765 );
766 }
767
768 #[test]
769 fn refusals_read_in_either_shape_and_drop_what_is_malformed() {
770 let refusal = |location: &str| {
771 format!(
772 r#"{{"index":1,"call":"Spend","kind":"transition","agent":"Wallet","name":"never_decreases","location":{location}}}"#
773 )
774 };
775 let as_event = refusals_from_json(
776 serde_json::from_str(&format!(
777 r#"[{}]"#,
778 refusal(r#""src/demo/wallet.bynk:18:14""#)
779 ))
780 .ok()
781 .as_ref(),
782 )
783 .unwrap();
784 let as_document = refusals_from_json(
785 serde_json::from_str(&format!(
786 r#"[{}]"#,
787 refusal(r#"{"path":"src/demo/wallet.bynk","line":18,"col":14}"#)
788 ))
789 .ok()
790 .as_ref(),
791 )
792 .unwrap();
793 assert_eq!(as_event, as_document);
794 assert_eq!(as_event[0].index, 1);
795 assert_eq!(as_event[0].kind, "transition");
796 assert_eq!(as_event[0].location.as_ref().unwrap().line, 18);
797
798 let malformed: serde_json::Value =
800 serde_json::from_str(r#"[{"index":0,"call":"Spend","kind":"invariant"}]"#).unwrap();
801 assert_eq!(refusals_from_json(Some(&malformed)), None);
802 assert_eq!(refusals_from_json(Some(&serde_json::json!([]))), None);
803 assert_eq!(refusals_from_json(Some(&serde_json::json!("x"))), None);
804 assert_eq!(refusals_from_json(None), None);
805 }
806
807 #[test]
808 fn a_violation_reads_its_location_in_either_shape() {
809 let as_event = serde_json::json!({
812 "kind": "transition", "agent": "Meter", "name": "same",
813 "location": "src/demo/m.bynk:28:14"
814 });
815 let as_document = serde_json::json!({
816 "kind": "transition", "agent": "Meter", "name": "same",
817 "location": { "path": "src/demo/m.bynk", "line": 28, "col": 14 }
818 });
819 let expected = Some(Location {
820 path: "src/demo/m.bynk".to_string(),
821 line: 28,
822 col: 14,
823 });
824 assert_eq!(violation_from_json(&as_event).location, expected);
825 assert_eq!(violation_from_json(&as_document).location, expected);
826 }
827}