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_serializing_if = "Option::is_none")]
161 pub origin: Option<String>,
162 #[serde(skip)]
166 pub duration_ms: Option<f64>,
167}
168
169#[derive(Debug, PartialEq, Serialize)]
170pub struct Location {
171 pub path: String,
172 pub line: u32,
173 pub col: u32,
174}
175
176#[derive(Debug, PartialEq, Serialize)]
180pub struct Violation {
181 pub kind: String,
182 pub agent: String,
183 pub name: String,
184 pub location: Option<Location>,
185}
186
187#[derive(Debug, PartialEq, Serialize)]
191pub struct Refusal {
192 pub index: u32,
193 pub call: String,
194 pub kind: String,
195 pub agent: String,
196 pub name: String,
197 pub location: Option<Location>,
198}
199
200#[derive(Debug, PartialEq, Serialize)]
201pub struct TestError {
202 pub kind: String,
203 #[serde(skip_serializing_if = "Option::is_none")]
204 pub message: Option<String>,
205 #[serde(skip_serializing_if = "Vec::is_empty")]
208 pub diagnostics: Vec<String>,
209 #[serde(skip_serializing_if = "Option::is_none")]
211 pub stderr: Option<String>,
212}
213
214impl TestRun {
215 pub fn empty() -> Self {
217 TestRun {
218 passed: 0,
219 failed: 0,
220 skipped: 0,
221 suites: Some(Vec::new()),
222 error: None,
223 coverage: None,
224 transport: None,
225 }
226 }
227
228 pub fn with_coverage(mut self, report: &crate::coverage::CoverageReport) -> Self {
233 if !report.is_empty() {
234 self.coverage = Some(Coverage::from(report));
235 }
236 self
237 }
238
239 pub fn discovered(suites: Vec<Suite>) -> Self {
246 TestRun {
247 passed: 0,
248 failed: 0,
249 skipped: 0,
250 suites: Some(suites),
251 error: None,
252 coverage: None,
253 transport: None,
254 }
255 }
256
257 pub fn runtime_error(message: impl Into<String>, stderr: Option<String>) -> Self {
262 TestRun {
263 passed: 0,
264 failed: 0,
265 skipped: 0,
266 suites: None,
267 error: Some(TestError {
268 kind: "runtime".to_string(),
269 message: Some(message.into()),
270 diagnostics: Vec::new(),
271 stderr: stderr.filter(|s| !s.trim().is_empty()),
272 }),
273 coverage: None,
274 transport: None,
275 }
276 }
277
278 pub fn compile_error(diagnostics: Vec<String>) -> Self {
280 TestRun {
281 passed: 0,
282 failed: 0,
283 skipped: 0,
284 suites: None,
285 error: Some(TestError {
286 kind: "compile".to_string(),
287 message: None,
288 diagnostics,
289 stderr: None,
290 }),
291 coverage: None,
292 transport: None,
293 }
294 }
295
296 pub fn with_tiers(mut self, discovered: &[bynk_emit::project::DiscoveredSuite]) -> Self {
300 for suite in self.suites.iter_mut().flatten() {
301 let Some(known) = discovered.iter().find(|d| d.name == suite.name) else {
302 continue;
303 };
304 for case in &mut suite.cases {
305 let known_case = known.cases.iter().find(|c| c.name == case.name);
306 if let Some(tier) = known_case.and_then(|c| c.tier) {
307 case.tier = Some(tier.to_string());
308 }
309 if known_case.is_some_and(|c| c.doc) {
310 case.origin = Some("doc".to_string());
311 }
312 }
313 }
314 self
315 }
316
317 pub fn with_remote(mut self, transport: String, skipped: &[(String, String, String)]) -> Self {
321 self.transport = Some(transport);
322 if skipped.is_empty() {
323 return self;
324 }
325 self.skipped = u32::try_from(skipped.len()).unwrap_or(u32::MAX);
326 let suites = self.suites.get_or_insert_with(Vec::new);
327 for (suite, case, reason) in skipped {
328 let case = Case {
329 name: case.clone(),
330 outcome: "skipped".to_string(),
331 message: Some(reason.clone()),
332 location: None,
333 retries: None,
334 tier: Some("system".to_string()),
336 kind: None,
337 seed: None,
338 counterexample: None,
339 violation: None,
340 refusals: None,
341 origin: None,
342 duration_ms: None,
343 };
344 match suites.iter_mut().find(|s| &s.name == suite) {
345 Some(s) => s.cases.push(case),
346 None => suites.push(Suite {
347 name: suite.clone(),
348 kind: "integration".to_string(),
349 cases: vec![case],
350 }),
351 }
352 }
353 self
354 }
355
356 pub fn render(&self) -> String {
359 let mut s = serde_json::to_string_pretty(self).expect("TestRun serialises");
360 s.push('\n');
361 s
362 }
363}
364
365#[derive(Debug, Default, Clone, PartialEq, Eq)]
369pub struct Unmatched {
370 pub suites: Vec<String>,
371 pub cases: Vec<String>,
372}
373
374impl Unmatched {
375 pub fn is_empty(&self) -> bool {
376 self.suites.is_empty() && self.cases.is_empty()
377 }
378
379 pub fn messages(&self) -> Vec<String> {
381 let suites = self
382 .suites
383 .iter()
384 .map(|s| format!("no suite named --suite {s:?}"));
385 let cases = self
386 .cases
387 .iter()
388 .map(|c| format!("no case matched --case {c:?}"));
389 suites.chain(cases).collect()
390 }
391
392 pub fn report(&self, program: &str) {
395 for message in self.messages() {
396 eprintln!("{program} test: {message}");
397 }
398 }
399}
400
401#[derive(Debug, Default, PartialEq)]
405pub struct ParsedRun {
406 pub passed: u32,
407 pub failed: u32,
408 pub suites: Vec<Suite>,
409 pub complete: bool,
410 pub unmatched: Unmatched,
412}
413
414impl ParsedRun {
415 pub fn into_document(self, stderr: &str) -> TestRun {
420 if self.complete {
421 TestRun {
422 passed: self.passed,
423 failed: self.failed,
424 skipped: 0,
425 suites: Some(self.suites),
426 error: None,
427 coverage: None,
428 transport: None,
429 }
430 } else {
431 let trimmed = stderr.trim();
432 TestRun {
433 passed: self.passed,
434 failed: self.failed,
435 skipped: 0,
436 suites: Some(self.suites),
437 error: Some(TestError {
438 kind: "runtime".to_string(),
439 message: Some("the test runner exited before completing".to_string()),
440 diagnostics: Vec::new(),
441 stderr: (!trimmed.is_empty()).then(|| trimmed.to_string()),
442 }),
443 coverage: None,
444 transport: None,
445 }
446 }
447 }
448}
449
450pub fn parse_ndjson(stdout: &str) -> ParsedRun {
454 let mut run = ParsedRun::default();
455 for line in stdout.lines() {
456 let line = line.trim();
457 if line.is_empty() {
458 continue;
459 }
460 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
461 continue;
462 };
463 match value.get("type").and_then(|t| t.as_str()) {
464 Some("suite-begin") => {
465 run.suites.push(Suite {
466 name: str_field(&value, "name"),
467 kind: str_field(&value, "kind"),
468 cases: Vec::new(),
469 });
470 }
471 Some("case") => {
472 let case = case_from_event(&value);
473 if case.outcome == "pass" {
474 run.passed += 1;
475 } else {
476 run.failed += 1;
477 }
478 if let Some(suite) = run.suites.last_mut() {
479 suite.cases.push(case);
480 }
481 }
482 Some("run-end") => {
483 run.complete = true;
484 run.unmatched = unmatched_from_event(&value);
485 }
486 _ => {}
487 }
488 }
489 run
490}
491
492pub(crate) fn case_from_event(value: &serde_json::Value) -> Case {
495 let message = value
496 .get("message")
497 .and_then(|m| m.as_str())
498 .map(str::to_string);
499 let location = value
500 .get("location")
501 .and_then(|l| l.as_str())
502 .and_then(parse_location);
503 let retries = value
504 .get("retries")
505 .and_then(serde_json::Value::as_u64)
506 .filter(|n| *n > 0)
507 .map(|n| u32::try_from(n).unwrap_or(u32::MAX));
508 let violation = value.get("violation").map(violation_from_json);
509 let refusals = refusals_from_json(value.get("refusals"));
510 Case {
511 name: str_field(value, "name"),
512 outcome: str_field(value, "outcome"),
513 message,
514 location,
515 retries,
516 tier: None,
517 kind: str_opt(value, "kind"),
518 seed: str_opt(value, "seed"),
519 counterexample: str_opt(value, "counterexample"),
520 violation,
521 refusals,
522 origin: None,
523 duration_ms: value.get("durationMs").and_then(serde_json::Value::as_f64),
524 }
525}
526
527fn location_from_json(value: &serde_json::Value) -> Option<Location> {
530 value.get("location").and_then(|l| match l {
531 serde_json::Value::String(s) => parse_location(s),
532 serde_json::Value::Object(_) => Some(Location {
533 path: str_field(l, "path"),
534 line: l
535 .get("line")
536 .and_then(serde_json::Value::as_u64)
537 .unwrap_or(0) as u32,
538 col: l
539 .get("col")
540 .and_then(serde_json::Value::as_u64)
541 .unwrap_or(0) as u32,
542 }),
543 _ => None,
544 })
545}
546
547pub(crate) fn violation_from_json(value: &serde_json::Value) -> Violation {
549 Violation {
550 kind: str_field(value, "kind"),
551 agent: str_field(value, "agent"),
552 name: str_field(value, "name"),
553 location: location_from_json(value),
554 }
555}
556
557pub(crate) fn refusals_from_json(value: Option<&serde_json::Value>) -> Option<Vec<Refusal>> {
560 let refusals: Vec<Refusal> = value?
561 .as_array()?
562 .iter()
563 .filter_map(|r| {
564 Some(Refusal {
565 index: u32::try_from(r.get("index")?.as_u64()?).ok()?,
566 call: r.get("call")?.as_str()?.to_string(),
567 kind: r.get("kind")?.as_str()?.to_string(),
568 agent: r.get("agent")?.as_str()?.to_string(),
569 name: r.get("name")?.as_str()?.to_string(),
570 location: location_from_json(r),
571 })
572 })
573 .collect();
574 (!refusals.is_empty()).then_some(refusals)
575}
576
577pub(crate) fn unmatched_from_event(value: &serde_json::Value) -> Unmatched {
579 let Some(unmatched) = value.get("unmatched") else {
580 return Unmatched::default();
581 };
582 let strings = |key: &str| -> Vec<String> {
583 unmatched
584 .get(key)
585 .and_then(|v| v.as_array())
586 .map(|a| {
587 a.iter()
588 .filter_map(|v| v.as_str())
589 .map(str::to_string)
590 .collect()
591 })
592 .unwrap_or_default()
593 };
594 Unmatched {
595 suites: strings("suites"),
596 cases: strings("cases"),
597 }
598}
599
600fn str_opt(value: &serde_json::Value, key: &str) -> Option<String> {
601 value.get(key).and_then(|v| v.as_str()).map(str::to_string)
602}
603
604fn str_field(value: &serde_json::Value, key: &str) -> String {
605 value
606 .get(key)
607 .and_then(|v| v.as_str())
608 .unwrap_or_default()
609 .to_string()
610}
611
612fn parse_location(s: &str) -> Option<Location> {
617 let (rest, col) = s.rsplit_once(':')?;
618 let (path, line) = rest.rsplit_once(':')?;
619 let line: u32 = line.parse().ok()?;
620 let col: u32 = col.parse().ok()?;
621 if path.is_empty() {
622 return None;
623 }
624 Some(Location {
625 path: path.to_string(),
626 line,
627 col,
628 })
629}
630
631#[cfg(test)]
632mod tests {
633 use super::*;
634
635 fn known(
636 suite: &str,
637 cases: &[(&str, Option<&'static str>)],
638 ) -> bynk_emit::project::DiscoveredSuite {
639 bynk_emit::project::DiscoveredSuite {
640 name: suite.to_string(),
641 kind: "unit",
642 cases: cases
643 .iter()
644 .map(|(name, tier)| bynk_emit::project::DiscoveredCase {
645 name: (*name).to_string(),
646 location: None,
647 tier: *tier,
648 generative: None,
649 doc: false,
650 })
651 .collect(),
652 skipped: Vec::new(),
653 envs: Vec::new(),
654 }
655 }
656
657 #[test]
660 fn a_cases_duration_is_read_but_never_written_to_the_document() {
661 let ndjson = concat!(
662 r#"{"type":"suite-begin","name":"s","kind":"unit","tests":1}"#,
663 "\n",
664 r#"{"type":"case","suite":"s","name":"c","outcome":"pass","durationMs":1.5}"#,
665 "\n",
666 r#"{"type":"suite-end","name":"s"}"#,
667 "\n",
668 r#"{"type":"run-end","passed":1,"failed":0}"#,
669 "\n",
670 );
671 let parsed = parse_ndjson(ndjson);
672 assert_eq!(parsed.suites[0].cases[0].duration_ms, Some(1.5));
673 let rendered = parsed.into_document("").render();
674 assert!(!rendered.contains("durationMs"), "{rendered}");
675 }
676
677 #[test]
680 fn run_end_reports_the_values_a_filter_selected_nothing_for() {
681 let ended = parse_ndjson(concat!(
682 r#"{"type":"run-begin","suites":0}"#,
683 "\n",
684 r#"{"type":"run-end","passed":0,"failed":0,"unmatched":{"suites":["nope"],"cases":["typo","other"]}}"#,
685 "\n",
686 ));
687 assert!(ended.complete);
688 assert_eq!(ended.unmatched.suites, vec!["nope".to_string()]);
689 assert_eq!(
690 ended.unmatched.cases,
691 vec!["typo".to_string(), "other".to_string()]
692 );
693 assert_eq!(
694 ended.unmatched.messages(),
695 vec![
696 "no suite named --suite \"nope\"".to_string(),
697 "no case matched --case \"typo\"".to_string(),
698 "no case matched --case \"other\"".to_string(),
699 ]
700 );
701
702 let plain = parse_ndjson(r#"{"type":"run-end","passed":1,"failed":0}"#);
703 assert!(plain.unmatched.is_empty());
704 }
705
706 #[test]
711 fn a_run_takes_each_cases_tier_from_the_case_table() {
712 let ndjson = concat!(
713 r#"{"type":"suite-begin","name":"shop","kind":"unit"}"#,
714 "\n",
715 r#"{"type":"case","name":"wired","outcome":"pass"}"#,
716 "\n",
717 r#"{"type":"case","name":"plain","outcome":"fail","message":"no"}"#,
718 "\n",
719 r#"{"type":"case","name":"a property","outcome":"pass"}"#,
720 "\n",
721 r#"{"type":"case","name":"unlisted","outcome":"pass"}"#,
722 "\n",
723 r#"{"type":"suite-begin","name":"other","kind":"unit"}"#,
724 "\n",
725 r#"{"type":"case","name":"wired","outcome":"pass"}"#,
726 "\n",
727 r#"{"type":"run-end"}"#,
728 "\n",
729 );
730 let table = [
731 known(
732 "shop",
733 &[
734 ("wired", Some("system")),
735 ("plain", Some("unit")),
736 ("a property", None),
737 ],
738 ),
739 known("other", &[("wired", Some("integration"))]),
740 ];
741 let doc = parse_ndjson(ndjson).into_document("").with_tiers(&table);
742 let suites = doc.suites.unwrap();
743 let tiers = |i: usize| -> Vec<Option<String>> {
744 suites[i].cases.iter().map(|c| c.tier.clone()).collect()
745 };
746 assert_eq!(
747 tiers(0),
748 [Some("system".into()), Some("unit".into()), None, None]
749 );
750 assert_eq!(
751 tiers(1),
752 [Some("integration".into())],
753 "the same name in another suite"
754 );
755 }
756
757 #[test]
760 fn file_coverage_carries_covered_and_uncovered_lines() {
761 let report = crate::coverage::CoverageReport {
762 files: vec![crate::coverage::FileCoverage {
763 path: "src/demo/m.bynk".into(),
764 covered: 2,
765 total: 5,
766 uncovered: vec![7, 8, 9],
767 covered_lines: vec![3, 4],
768 }],
769 };
770 assert_eq!(
771 serde_json::to_string(&Coverage::from(&report)).unwrap(),
772 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]}]}"#
773 );
774 }
775
776 #[test]
777 fn refusals_read_in_either_shape_and_drop_what_is_malformed() {
778 let refusal = |location: &str| {
779 format!(
780 r#"{{"index":1,"call":"Spend","kind":"transition","agent":"Wallet","name":"never_decreases","location":{location}}}"#
781 )
782 };
783 let as_event = refusals_from_json(
784 serde_json::from_str(&format!(
785 r#"[{}]"#,
786 refusal(r#""src/demo/wallet.bynk:18:14""#)
787 ))
788 .ok()
789 .as_ref(),
790 )
791 .unwrap();
792 let as_document = refusals_from_json(
793 serde_json::from_str(&format!(
794 r#"[{}]"#,
795 refusal(r#"{"path":"src/demo/wallet.bynk","line":18,"col":14}"#)
796 ))
797 .ok()
798 .as_ref(),
799 )
800 .unwrap();
801 assert_eq!(as_event, as_document);
802 assert_eq!(as_event[0].index, 1);
803 assert_eq!(as_event[0].kind, "transition");
804 assert_eq!(as_event[0].location.as_ref().unwrap().line, 18);
805
806 let malformed: serde_json::Value =
808 serde_json::from_str(r#"[{"index":0,"call":"Spend","kind":"invariant"}]"#).unwrap();
809 assert_eq!(refusals_from_json(Some(&malformed)), None);
810 assert_eq!(refusals_from_json(Some(&serde_json::json!([]))), None);
811 assert_eq!(refusals_from_json(Some(&serde_json::json!("x"))), None);
812 assert_eq!(refusals_from_json(None), None);
813 }
814
815 #[test]
816 fn a_violation_reads_its_location_in_either_shape() {
817 let as_event = serde_json::json!({
820 "kind": "transition", "agent": "Meter", "name": "same",
821 "location": "src/demo/m.bynk:28:14"
822 });
823 let as_document = serde_json::json!({
824 "kind": "transition", "agent": "Meter", "name": "same",
825 "location": { "path": "src/demo/m.bynk", "line": 28, "col": 14 }
826 });
827 let expected = Some(Location {
828 path: "src/demo/m.bynk".to_string(),
829 line: 28,
830 col: 14,
831 });
832 assert_eq!(violation_from_json(&as_event).location, expected);
833 assert_eq!(violation_from_json(&as_document).location, expected);
834 }
835
836 #[test]
837 fn a_doc_example_case_is_marked_and_no_other_is() {
838 let table = vec![bynk_emit::project::DiscoveredSuite {
839 name: "demo.m".to_string(),
840 kind: "unit",
841 cases: vec![
842 bynk_emit::project::DiscoveredCase {
843 name: "doc example m.bynk:4".to_string(),
844 location: None,
845 tier: Some("unit"),
846 generative: None,
847 doc: true,
848 },
849 bynk_emit::project::DiscoveredCase {
850 name: "real".to_string(),
851 location: None,
852 tier: Some("unit"),
853 generative: None,
854 doc: false,
855 },
856 ],
857 skipped: Vec::new(),
858 envs: Vec::new(),
859 }];
860 let ndjson = concat!(
861 r#"{"type":"suite-begin","name":"demo.m","kind":"unit","tests":2}"#,
862 "\n",
863 r#"{"type":"case","suite":"demo.m","name":"doc example m.bynk:4","outcome":"pass"}"#,
864 "\n",
865 r#"{"type":"case","suite":"demo.m","name":"real","outcome":"pass"}"#,
866 "\n",
867 r#"{"type":"suite-end","name":"demo.m"}"#,
868 "\n",
869 r#"{"type":"run-end","passed":2,"failed":0}"#,
870 "\n",
871 );
872 let doc = parse_ndjson(ndjson).into_document("").with_tiers(&table);
873 let rendered = doc.render();
874 assert_eq!(rendered.matches("\"origin\"").count(), 1, "{rendered}");
876 assert!(rendered.contains("\"origin\": \"doc\""), "{rendered}");
877 let suites = doc.suites.as_ref().unwrap();
878 assert_eq!(suites[0].cases[0].origin.as_deref(), Some("doc"));
879 assert_eq!(suites[0].cases[1].origin, None);
880 }
881}