1use std::io::{BufRead, BufReader, Write};
29use std::process::{Child, Command, ExitStatus, Stdio};
30
31use serde::Serialize;
32
33use crate::test_json::{
34 Case, TestError, TestRun, Unmatched, case_from_event, unmatched_from_event,
35};
36
37#[derive(Serialize)]
38struct RunBegin {
39 r#type: &'static str,
40 suites: usize,
41}
42
43#[derive(Serialize)]
44struct SuiteBegin<'a> {
45 r#type: &'static str,
46 name: &'a str,
47 kind: &'a str,
48 tests: usize,
49}
50
51#[derive(Serialize)]
52struct CaseEvent<'a> {
53 r#type: &'static str,
54 suite: &'a str,
55 #[serde(flatten)]
56 case: &'a Case,
57 #[serde(rename = "durationMs", skip_serializing_if = "Option::is_none")]
60 duration_ms: Option<f64>,
61}
62
63#[derive(Serialize)]
64struct SuiteEnd<'a> {
65 r#type: &'static str,
66 name: &'a str,
67}
68
69#[derive(Serialize)]
70struct RunEnd<'a> {
71 r#type: &'static str,
72 passed: u32,
73 failed: u32,
74 #[serde(skip_serializing_if = "is_zero")]
75 skipped: u32,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 transport: Option<&'a str>,
78}
79
80#[derive(Serialize)]
81struct ErrorEvent<'a> {
82 r#type: &'static str,
83 #[serde(flatten)]
84 error: &'a TestError,
85}
86
87fn is_zero(n: &u32) -> bool {
88 *n == 0
89}
90
91fn line<T: Serialize>(event: &T) -> String {
92 let mut text = serde_json::to_string(event).unwrap_or_default();
94 text.push('\n');
95 text
96}
97
98fn run_begin(suites: usize) -> String {
99 line(&RunBegin {
100 r#type: "run-begin",
101 suites,
102 })
103}
104
105fn suite_begin(name: &str, kind: &str, tests: usize) -> String {
106 line(&SuiteBegin {
107 r#type: "suite-begin",
108 name,
109 kind,
110 tests,
111 })
112}
113
114fn case_event(suite: &str, case: &Case) -> String {
115 line(&CaseEvent {
116 r#type: "case",
117 suite,
118 case,
119 duration_ms: case.duration_ms,
120 })
121}
122
123fn suite_end(name: &str) -> String {
124 line(&SuiteEnd {
125 r#type: "suite-end",
126 name,
127 })
128}
129
130fn run_end(passed: u32, failed: u32, skipped: u32, transport: Option<&str>) -> String {
131 line(&RunEnd {
132 r#type: "run-end",
133 passed,
134 failed,
135 skipped,
136 transport,
137 })
138}
139
140fn error_event(error: &TestError) -> String {
141 line(&ErrorEvent {
142 r#type: "error",
143 error,
144 })
145}
146
147pub fn events_for(run: &TestRun) -> String {
151 let mut out = String::new();
152 let Some(suites) = &run.suites else {
153 if let Some(error) = &run.error {
154 out.push_str(&error_event(error));
155 }
156 return out;
157 };
158 let ran = |suite: &&crate::test_json::Suite| {
163 suite.cases.is_empty() || suite.cases.iter().any(|c| c.outcome != "skipped")
164 };
165 out.push_str(&run_begin(suites.iter().filter(ran).count()));
166 for suite in suites.iter().filter(ran) {
167 let cases: Vec<&Case> = suite
168 .cases
169 .iter()
170 .filter(|c| c.outcome != "skipped")
171 .collect();
172 out.push_str(&suite_begin(&suite.name, &suite.kind, cases.len()));
173 for case in cases {
174 out.push_str(&case_event(&suite.name, case));
175 }
176 out.push_str(&suite_end(&suite.name));
177 }
178 for suite in suites {
179 for case in suite.cases.iter().filter(|c| c.outcome == "skipped") {
180 out.push_str(&case_event(&suite.name, case));
181 }
182 }
183 match &run.error {
184 Some(error) => out.push_str(&error_event(error)),
185 None => out.push_str(&run_end(
186 run.passed,
187 run.failed,
188 run.skipped,
189 run.transport.as_deref(),
190 )),
191 }
192 out
193}
194
195pub fn print_run(stream: bool, run: &TestRun) {
197 if stream {
198 print!("{}", events_for(run));
199 } else {
200 print!("{}", run.render());
201 }
202}
203
204pub type Skipped = (String, String, String);
206
207pub struct StreamContext<'a> {
209 pub discovered: &'a [bynk_emit::project::DiscoveredSuite],
211 pub skipped: Vec<Skipped>,
213 pub transport: Option<String>,
215}
216
217pub struct Stream<'a> {
219 context: StreamContext<'a>,
220 ended: bool,
221 unmatched: Unmatched,
222}
223
224impl<'a> Stream<'a> {
225 pub fn new(context: StreamContext<'a>) -> Self {
226 Self {
227 context,
228 ended: false,
229 unmatched: Unmatched::default(),
230 }
231 }
232
233 pub fn unmatched(&self) -> &Unmatched {
236 &self.unmatched
237 }
238
239 fn origin_of(&self, suite: &str, case: &str) -> Option<String> {
241 let known = self.context.discovered.iter().find(|d| d.name == suite)?;
242 known
243 .cases
244 .iter()
245 .find(|c| c.name == case)?
246 .doc
247 .then(|| "doc".to_string())
248 }
249
250 fn tier_of(&self, suite: &str, case: &str) -> Option<&'static str> {
251 self.context
252 .discovered
253 .iter()
254 .find(|d| d.name == suite)?
255 .cases
256 .iter()
257 .find(|c| c.name == case)?
258 .tier
259 }
260
261 pub fn push(&mut self, raw: &str) -> String {
264 let raw = raw.trim();
265 let Ok(value) = serde_json::from_str::<serde_json::Value>(raw) else {
266 return String::new();
267 };
268 let text = |key: &str| value.get(key).and_then(|v| v.as_str()).unwrap_or_default();
269 let count = |key: &str| {
270 value
271 .get(key)
272 .and_then(serde_json::Value::as_u64)
273 .map_or(0, |n| usize::try_from(n).unwrap_or(usize::MAX))
274 };
275 match text("type") {
276 "run-begin" => run_begin(count("suites")),
277 "suite-begin" => suite_begin(text("name"), text("kind"), count("tests")),
278 "case" => {
279 let mut case = case_from_event(&value);
280 case.tier = self.tier_of(text("suite"), &case.name).map(str::to_string);
281 case.origin = self.origin_of(text("suite"), &case.name);
282 case_event(text("suite"), &case)
283 }
284 "suite-end" => suite_end(text("name")),
285 "run-end" => {
286 self.ended = true;
287 self.unmatched = unmatched_from_event(&value);
288 let mut out = String::new();
289 for (suite, name, reason) in &self.context.skipped {
290 out.push_str(&case_event(
291 suite,
292 &Case {
293 name: name.clone(),
294 outcome: "skipped".to_string(),
295 message: Some(reason.clone()),
296 location: None,
297 retries: None,
298 tier: self.tier_of(suite, name).map(str::to_string),
299 kind: None,
300 seed: None,
301 counterexample: None,
302 violation: None,
303 refusals: None,
304 origin: self.origin_of(suite, name),
305 duration_ms: None,
306 },
307 ));
308 }
309 let passed = u32::try_from(count("passed")).unwrap_or(u32::MAX);
310 let failed = u32::try_from(count("failed")).unwrap_or(u32::MAX);
311 let skipped = u32::try_from(self.context.skipped.len()).unwrap_or(u32::MAX);
312 out.push_str(&run_end(
313 passed,
314 failed,
315 skipped,
316 self.context.transport.as_deref(),
317 ));
318 out
319 }
320 _ => String::new(),
321 }
322 }
323
324 pub fn finish(&self, stderr: &str) -> String {
327 if self.ended {
328 return String::new();
329 }
330 let trimmed = stderr.trim();
331 error_event(&TestError {
332 kind: "runtime".to_string(),
333 message: Some("the test runner exited before completing".to_string()),
334 diagnostics: Vec::new(),
335 stderr: (!trimmed.is_empty()).then(|| trimmed.to_string()),
336 })
337 }
338}
339
340pub fn forward(
345 mut cmd: Command,
346 stream: &mut Stream<'_>,
347 out: &mut impl Write,
348 aliases: &[(String, String)],
349) -> std::io::Result<ExitStatus> {
350 let mut child = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
351 let stderr_pipe = child.stderr.take();
352 let collector = std::thread::spawn(move || {
353 let mut text = String::new();
354 if let Some(pipe) = stderr_pipe {
355 for line in BufReader::new(pipe).lines().map_while(Result::ok) {
356 text.push_str(&line);
357 text.push('\n');
358 }
359 }
360 text
361 });
362 let mut gone = false;
363 if let Some(pipe) = child.stdout.take() {
364 let mut reader = BufReader::new(pipe);
366 let mut buffer = Vec::new();
367 loop {
368 buffer.clear();
369 match reader.read_until(b'\n', &mut buffer) {
370 Ok(0) | Err(_) => break,
371 Ok(_) => {}
372 }
373 let line = String::from_utf8_lossy(&buffer);
374 let text = stream.push(&crate::doc_examples::apply_aliases(&line, aliases));
375 if !text.is_empty() && write_and_flush(out, &text).is_err() {
376 gone = true;
377 break;
378 }
379 }
380 }
381 if gone {
382 kill(&mut child);
383 }
384 let status = child.wait()?;
385 let stderr = collector.join().unwrap_or_default();
386 if !gone {
387 let closing = stream.finish(&stderr);
388 if !closing.is_empty() {
389 let _ = write_and_flush(out, &closing);
390 }
391 }
392 Ok(status)
393}
394
395fn write_and_flush(out: &mut impl Write, text: &str) -> std::io::Result<()> {
396 out.write_all(text.as_bytes())?;
397 out.flush()
398}
399
400fn kill(child: &mut Child) {
401 let _ = child.kill();
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407 use crate::test_json::{Location, Suite};
408
409 fn context<'a>() -> StreamContext<'a> {
410 StreamContext {
411 discovered: &[],
412 skipped: Vec::new(),
413 transport: None,
414 }
415 }
416
417 fn case(name: &str, outcome: &str) -> Case {
418 Case {
419 name: name.to_string(),
420 outcome: outcome.to_string(),
421 message: None,
422 location: None,
423 retries: None,
424 tier: None,
425 kind: None,
426 seed: None,
427 counterexample: None,
428 violation: None,
429 refusals: None,
430 origin: None,
431 duration_ms: None,
432 }
433 }
434
435 #[test]
436 fn a_case_event_is_the_documents_case_behind_type_and_suite() {
437 let mut failed = case("never seventy three", "fail");
438 failed.message = Some("property failed".to_string());
439 failed.location = Some(Location {
440 path: "tests/m.test.bynk".to_string(),
441 line: 3,
442 col: 4,
443 });
444 failed.tier = Some("unit".to_string());
445 failed.kind = Some("property".to_string());
446 failed.seed = Some("0x5f3a".to_string());
447 failed.counterexample = Some("p = 73".to_string());
448 assert_eq!(
449 case_event("demo.m", &failed),
450 concat!(
451 r#"{"type":"case","suite":"demo.m","name":"never seventy three","outcome":"fail","#,
452 r#""message":"property failed","location":{"path":"tests/m.test.bynk","line":3,"col":4},"#,
453 r#""tier":"unit","kind":"property","seed":"0x5f3a","counterexample":"p = 73"}"#,
454 "\n"
455 )
456 );
457 }
458
459 #[test]
460 fn a_case_event_names_the_refused_steps_before_its_duration() {
461 let mut failed = case("every call is accepted", "fail");
462 failed.refusals = Some(vec![crate::test_json::Refusal {
463 index: 0,
464 call: "Spend".to_string(),
465 kind: "invariant".to_string(),
466 agent: "Wallet".to_string(),
467 name: "nonneg".to_string(),
468 location: Some(Location {
469 path: "src/demo/wallet.bynk".to_string(),
470 line: 9,
471 col: 13,
472 }),
473 }]);
474 failed.duration_ms = Some(3.0);
475 assert_eq!(
476 case_event("demo.wallet", &failed),
477 concat!(
478 r#"{"type":"case","suite":"demo.wallet","name":"every call is accepted","outcome":"fail","#,
479 r#""refusals":[{"index":0,"call":"Spend","kind":"invariant","agent":"Wallet","name":"nonneg","#,
480 r#""location":{"path":"src/demo/wallet.bynk","line":9,"col":13}}],"durationMs":3.0}"#,
481 "\n"
482 )
483 );
484 }
485
486 #[test]
487 fn a_case_event_ends_with_its_duration_when_the_runner_measured_one() {
488 let mut ran = case("one", "pass");
489 ran.tier = Some("unit".to_string());
490 ran.duration_ms = Some(12.5);
491 assert_eq!(
492 case_event("demo.m", &ran),
493 "{\"type\":\"case\",\"suite\":\"demo.m\",\"name\":\"one\",\"outcome\":\"pass\",\"tier\":\"unit\",\"durationMs\":12.5}\n"
494 );
495 assert!(!case_event("demo.m", &case("two", "skipped")).contains("durationMs"));
497 }
498
499 #[test]
500 fn the_runners_events_become_the_streams_with_tiers() {
501 let discovered = [bynk_emit::project::DiscoveredSuite {
502 name: "demo.m".to_string(),
503 kind: "unit",
504 cases: vec![bynk_emit::project::DiscoveredCase {
505 name: "one".to_string(),
506 location: None,
507 tier: Some("unit"),
508 generative: None,
509 doc: false,
510 }],
511 skipped: Vec::new(),
512 envs: Vec::new(),
513 }];
514 let mut stream = Stream::new(StreamContext {
515 discovered: &discovered,
516 ..context()
517 });
518 let out: String = [
519 r#"{"type":"run-begin","suites":1}"#,
520 r#"{"type":"suite-begin","name":"demo.m","kind":"unit","tests":1}"#,
521 r#"{"type":"case","suite":"demo.m","name":"one","outcome":"pass","durationMs":0.25}"#,
522 r#"{"type":"suite-end","name":"demo.m"}"#,
523 "not an event",
524 r#"{"type":"something-new","x":1}"#,
525 r#"{"type":"run-end","passed":1,"failed":0,"unmatched":{"cases":["x"]}}"#,
526 ]
527 .iter()
528 .map(|l| stream.push(l))
529 .collect();
530 assert_eq!(
531 out,
532 concat!(
533 "{\"type\":\"run-begin\",\"suites\":1}\n",
534 "{\"type\":\"suite-begin\",\"name\":\"demo.m\",\"kind\":\"unit\",\"tests\":1}\n",
535 "{\"type\":\"case\",\"suite\":\"demo.m\",\"name\":\"one\",\"outcome\":\"pass\",\"tier\":\"unit\",\"durationMs\":0.25}\n",
536 "{\"type\":\"suite-end\",\"name\":\"demo.m\"}\n",
537 "{\"type\":\"run-end\",\"passed\":1,\"failed\":0}\n",
538 ),
539 "an unrecognised line is dropped, and `unmatched` is not part of the stream"
540 );
541 assert_eq!(stream.finish(""), "", "nothing closes a stream that ended");
542 }
543
544 #[test]
545 fn skipped_cases_and_the_transport_close_a_remote_run() {
546 let mut stream = Stream::new(StreamContext {
547 skipped: vec![(
548 "demo.s".to_string(),
549 "advances".to_string(),
550 "advance Clock".to_string(),
551 )],
552 transport: Some("remote:staging".to_string()),
553 ..context()
554 });
555 assert_eq!(
556 stream.push(r#"{"type":"run-end","passed":2,"failed":0}"#),
557 concat!(
558 "{\"type\":\"case\",\"suite\":\"demo.s\",\"name\":\"advances\",\"outcome\":\"skipped\",\"message\":\"advance Clock\"}\n",
559 "{\"type\":\"run-end\",\"passed\":2,\"failed\":0,\"skipped\":1,\"transport\":\"remote:staging\"}\n",
560 )
561 );
562 }
563
564 #[test]
565 fn a_runner_that_died_is_an_error_event() {
566 let mut stream = Stream::new(context());
567 stream.push(r#"{"type":"run-begin","suites":1}"#);
568 assert_eq!(
569 stream.finish("boom\n"),
570 "{\"type\":\"error\",\"kind\":\"runtime\",\"message\":\"the test runner exited before completing\",\"stderr\":\"boom\"}\n"
571 );
572 }
573
574 #[test]
575 fn a_document_held_in_full_becomes_its_events() {
576 let compile = TestRun::compile_error(vec!["a.bynk:1:1: error[x]: no".to_string()]);
577 assert_eq!(
578 events_for(&compile),
579 "{\"type\":\"error\",\"kind\":\"compile\",\"diagnostics\":[\"a.bynk:1:1: error[x]: no\"]}\n"
580 );
581
582 let mut run = TestRun::empty();
583 run.suites = Some(vec![
584 Suite {
585 name: "demo.m".to_string(),
586 kind: "unit".to_string(),
587 cases: vec![case("one", "pass"), case("two", "skipped")],
588 },
589 Suite {
590 name: "demo.s".to_string(),
591 kind: "integration".to_string(),
592 cases: vec![case("advances", "skipped")],
593 },
594 Suite {
595 name: "demo.empty".to_string(),
596 kind: "unit".to_string(),
597 cases: Vec::new(),
598 },
599 ]);
600 run.passed = 1;
601 run.skipped = 2;
602 assert_eq!(
603 events_for(&run),
604 concat!(
605 "{\"type\":\"run-begin\",\"suites\":2}\n",
606 "{\"type\":\"suite-begin\",\"name\":\"demo.m\",\"kind\":\"unit\",\"tests\":1}\n",
607 "{\"type\":\"case\",\"suite\":\"demo.m\",\"name\":\"one\",\"outcome\":\"pass\"}\n",
608 "{\"type\":\"suite-end\",\"name\":\"demo.m\"}\n",
609 "{\"type\":\"suite-begin\",\"name\":\"demo.empty\",\"kind\":\"unit\",\"tests\":0}\n",
610 "{\"type\":\"suite-end\",\"name\":\"demo.empty\"}\n",
611 "{\"type\":\"case\",\"suite\":\"demo.m\",\"name\":\"two\",\"outcome\":\"skipped\"}\n",
612 "{\"type\":\"case\",\"suite\":\"demo.s\",\"name\":\"advances\",\"outcome\":\"skipped\"}\n",
613 "{\"type\":\"run-end\",\"passed\":1,\"failed\":0,\"skipped\":2}\n",
614 ),
615 "`tests` counts what the suite reports; a suite of only skipped cases has no block"
616 );
617 }
618
619 struct Timed(Vec<(std::time::Instant, String)>);
621
622 impl Write for Timed {
623 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
624 self.0.push((
625 std::time::Instant::now(),
626 String::from_utf8_lossy(buf).into_owned(),
627 ));
628 Ok(buf.len())
629 }
630 fn flush(&mut self) -> std::io::Result<()> {
631 Ok(())
632 }
633 }
634
635 #[cfg(unix)]
639 #[test]
640 fn events_are_forwarded_as_they_are_written() {
641 let mut cmd = Command::new("sh");
642 cmd.arg("-c").arg(
643 r#"printf '{"type":"run-begin","suites":1}\n{"type":"suite-be'; sleep 0.4;
644 printf 'gin","name":"s","kind":"unit","tests":0}\n'; sleep 0.4; echo oops >&2"#,
645 );
646 let mut stream = Stream::new(context());
647 let mut sink = Timed(Vec::new());
648 let started = std::time::Instant::now();
649 let status = forward(cmd, &mut stream, &mut sink, &[]).unwrap();
650 let finished = started.elapsed();
651 assert!(status.success());
652 let first = sink.0.first().expect("an event");
653 assert_eq!(first.1, "{\"type\":\"run-begin\",\"suites\":1}\n");
654 assert!(
655 first.0.duration_since(started) < finished - std::time::Duration::from_millis(500),
656 "the first event arrived well before the runner exited"
657 );
658 let all: String = sink.0.iter().map(|(_, t)| t.as_str()).collect();
659 assert_eq!(
660 all,
661 concat!(
662 "{\"type\":\"run-begin\",\"suites\":1}\n",
663 "{\"type\":\"suite-begin\",\"name\":\"s\",\"kind\":\"unit\",\"tests\":0}\n",
664 "{\"type\":\"error\",\"kind\":\"runtime\",\"message\":\"the test runner exited before completing\",\"stderr\":\"oops\"}\n",
665 ),
666 "a split event is one event, and the missing run-end is an error"
667 );
668 }
669}