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 tier_of(&self, suite: &str, case: &str) -> Option<&'static str> {
240 self.context
241 .discovered
242 .iter()
243 .find(|d| d.name == suite)?
244 .cases
245 .iter()
246 .find(|c| c.name == case)?
247 .tier
248 }
249
250 pub fn push(&mut self, raw: &str) -> String {
253 let raw = raw.trim();
254 let Ok(value) = serde_json::from_str::<serde_json::Value>(raw) else {
255 return String::new();
256 };
257 let text = |key: &str| value.get(key).and_then(|v| v.as_str()).unwrap_or_default();
258 let count = |key: &str| {
259 value
260 .get(key)
261 .and_then(serde_json::Value::as_u64)
262 .map_or(0, |n| usize::try_from(n).unwrap_or(usize::MAX))
263 };
264 match text("type") {
265 "run-begin" => run_begin(count("suites")),
266 "suite-begin" => suite_begin(text("name"), text("kind"), count("tests")),
267 "case" => {
268 let mut case = case_from_event(&value);
269 case.tier = self.tier_of(text("suite"), &case.name).map(str::to_string);
270 case_event(text("suite"), &case)
271 }
272 "suite-end" => suite_end(text("name")),
273 "run-end" => {
274 self.ended = true;
275 self.unmatched = unmatched_from_event(&value);
276 let mut out = String::new();
277 for (suite, name, reason) in &self.context.skipped {
278 out.push_str(&case_event(
279 suite,
280 &Case {
281 name: name.clone(),
282 outcome: "skipped".to_string(),
283 message: Some(reason.clone()),
284 location: None,
285 retries: None,
286 tier: self.tier_of(suite, name).map(str::to_string),
287 kind: None,
288 seed: None,
289 counterexample: None,
290 violation: None,
291 refusals: None,
292 duration_ms: None,
293 },
294 ));
295 }
296 let passed = u32::try_from(count("passed")).unwrap_or(u32::MAX);
297 let failed = u32::try_from(count("failed")).unwrap_or(u32::MAX);
298 let skipped = u32::try_from(self.context.skipped.len()).unwrap_or(u32::MAX);
299 out.push_str(&run_end(
300 passed,
301 failed,
302 skipped,
303 self.context.transport.as_deref(),
304 ));
305 out
306 }
307 _ => String::new(),
308 }
309 }
310
311 pub fn finish(&self, stderr: &str) -> String {
314 if self.ended {
315 return String::new();
316 }
317 let trimmed = stderr.trim();
318 error_event(&TestError {
319 kind: "runtime".to_string(),
320 message: Some("the test runner exited before completing".to_string()),
321 diagnostics: Vec::new(),
322 stderr: (!trimmed.is_empty()).then(|| trimmed.to_string()),
323 })
324 }
325}
326
327pub fn forward(
332 mut cmd: Command,
333 stream: &mut Stream<'_>,
334 out: &mut impl Write,
335) -> std::io::Result<ExitStatus> {
336 let mut child = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
337 let stderr_pipe = child.stderr.take();
338 let collector = std::thread::spawn(move || {
339 let mut text = String::new();
340 if let Some(pipe) = stderr_pipe {
341 for line in BufReader::new(pipe).lines().map_while(Result::ok) {
342 text.push_str(&line);
343 text.push('\n');
344 }
345 }
346 text
347 });
348 let mut gone = false;
349 if let Some(pipe) = child.stdout.take() {
350 let mut reader = BufReader::new(pipe);
352 let mut buffer = Vec::new();
353 loop {
354 buffer.clear();
355 match reader.read_until(b'\n', &mut buffer) {
356 Ok(0) | Err(_) => break,
357 Ok(_) => {}
358 }
359 let text = stream.push(&String::from_utf8_lossy(&buffer));
360 if !text.is_empty() && write_and_flush(out, &text).is_err() {
361 gone = true;
362 break;
363 }
364 }
365 }
366 if gone {
367 kill(&mut child);
368 }
369 let status = child.wait()?;
370 let stderr = collector.join().unwrap_or_default();
371 if !gone {
372 let closing = stream.finish(&stderr);
373 if !closing.is_empty() {
374 let _ = write_and_flush(out, &closing);
375 }
376 }
377 Ok(status)
378}
379
380fn write_and_flush(out: &mut impl Write, text: &str) -> std::io::Result<()> {
381 out.write_all(text.as_bytes())?;
382 out.flush()
383}
384
385fn kill(child: &mut Child) {
386 let _ = child.kill();
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392 use crate::test_json::{Location, Suite};
393
394 fn context<'a>() -> StreamContext<'a> {
395 StreamContext {
396 discovered: &[],
397 skipped: Vec::new(),
398 transport: None,
399 }
400 }
401
402 fn case(name: &str, outcome: &str) -> Case {
403 Case {
404 name: name.to_string(),
405 outcome: outcome.to_string(),
406 message: None,
407 location: None,
408 retries: None,
409 tier: None,
410 kind: None,
411 seed: None,
412 counterexample: None,
413 violation: None,
414 refusals: None,
415 duration_ms: None,
416 }
417 }
418
419 #[test]
420 fn a_case_event_is_the_documents_case_behind_type_and_suite() {
421 let mut failed = case("never seventy three", "fail");
422 failed.message = Some("property failed".to_string());
423 failed.location = Some(Location {
424 path: "tests/m.test.bynk".to_string(),
425 line: 3,
426 col: 4,
427 });
428 failed.tier = Some("unit".to_string());
429 failed.kind = Some("property".to_string());
430 failed.seed = Some("0x5f3a".to_string());
431 failed.counterexample = Some("p = 73".to_string());
432 assert_eq!(
433 case_event("demo.m", &failed),
434 concat!(
435 r#"{"type":"case","suite":"demo.m","name":"never seventy three","outcome":"fail","#,
436 r#""message":"property failed","location":{"path":"tests/m.test.bynk","line":3,"col":4},"#,
437 r#""tier":"unit","kind":"property","seed":"0x5f3a","counterexample":"p = 73"}"#,
438 "\n"
439 )
440 );
441 }
442
443 #[test]
444 fn a_case_event_names_the_refused_steps_before_its_duration() {
445 let mut failed = case("every call is accepted", "fail");
446 failed.refusals = Some(vec![crate::test_json::Refusal {
447 index: 0,
448 call: "Spend".to_string(),
449 kind: "invariant".to_string(),
450 agent: "Wallet".to_string(),
451 name: "nonneg".to_string(),
452 location: Some(Location {
453 path: "src/demo/wallet.bynk".to_string(),
454 line: 9,
455 col: 13,
456 }),
457 }]);
458 failed.duration_ms = Some(3.0);
459 assert_eq!(
460 case_event("demo.wallet", &failed),
461 concat!(
462 r#"{"type":"case","suite":"demo.wallet","name":"every call is accepted","outcome":"fail","#,
463 r#""refusals":[{"index":0,"call":"Spend","kind":"invariant","agent":"Wallet","name":"nonneg","#,
464 r#""location":{"path":"src/demo/wallet.bynk","line":9,"col":13}}],"durationMs":3.0}"#,
465 "\n"
466 )
467 );
468 }
469
470 #[test]
471 fn a_case_event_ends_with_its_duration_when_the_runner_measured_one() {
472 let mut ran = case("one", "pass");
473 ran.tier = Some("unit".to_string());
474 ran.duration_ms = Some(12.5);
475 assert_eq!(
476 case_event("demo.m", &ran),
477 "{\"type\":\"case\",\"suite\":\"demo.m\",\"name\":\"one\",\"outcome\":\"pass\",\"tier\":\"unit\",\"durationMs\":12.5}\n"
478 );
479 assert!(!case_event("demo.m", &case("two", "skipped")).contains("durationMs"));
481 }
482
483 #[test]
484 fn the_runners_events_become_the_streams_with_tiers() {
485 let discovered = [bynk_emit::project::DiscoveredSuite {
486 name: "demo.m".to_string(),
487 kind: "unit",
488 cases: vec![bynk_emit::project::DiscoveredCase {
489 name: "one".to_string(),
490 location: None,
491 tier: Some("unit"),
492 generative: None,
493 }],
494 skipped: Vec::new(),
495 envs: Vec::new(),
496 }];
497 let mut stream = Stream::new(StreamContext {
498 discovered: &discovered,
499 ..context()
500 });
501 let out: String = [
502 r#"{"type":"run-begin","suites":1}"#,
503 r#"{"type":"suite-begin","name":"demo.m","kind":"unit","tests":1}"#,
504 r#"{"type":"case","suite":"demo.m","name":"one","outcome":"pass","durationMs":0.25}"#,
505 r#"{"type":"suite-end","name":"demo.m"}"#,
506 "not an event",
507 r#"{"type":"something-new","x":1}"#,
508 r#"{"type":"run-end","passed":1,"failed":0,"unmatched":{"cases":["x"]}}"#,
509 ]
510 .iter()
511 .map(|l| stream.push(l))
512 .collect();
513 assert_eq!(
514 out,
515 concat!(
516 "{\"type\":\"run-begin\",\"suites\":1}\n",
517 "{\"type\":\"suite-begin\",\"name\":\"demo.m\",\"kind\":\"unit\",\"tests\":1}\n",
518 "{\"type\":\"case\",\"suite\":\"demo.m\",\"name\":\"one\",\"outcome\":\"pass\",\"tier\":\"unit\",\"durationMs\":0.25}\n",
519 "{\"type\":\"suite-end\",\"name\":\"demo.m\"}\n",
520 "{\"type\":\"run-end\",\"passed\":1,\"failed\":0}\n",
521 ),
522 "an unrecognised line is dropped, and `unmatched` is not part of the stream"
523 );
524 assert_eq!(stream.finish(""), "", "nothing closes a stream that ended");
525 }
526
527 #[test]
528 fn skipped_cases_and_the_transport_close_a_remote_run() {
529 let mut stream = Stream::new(StreamContext {
530 skipped: vec![(
531 "demo.s".to_string(),
532 "advances".to_string(),
533 "advance Clock".to_string(),
534 )],
535 transport: Some("remote:staging".to_string()),
536 ..context()
537 });
538 assert_eq!(
539 stream.push(r#"{"type":"run-end","passed":2,"failed":0}"#),
540 concat!(
541 "{\"type\":\"case\",\"suite\":\"demo.s\",\"name\":\"advances\",\"outcome\":\"skipped\",\"message\":\"advance Clock\"}\n",
542 "{\"type\":\"run-end\",\"passed\":2,\"failed\":0,\"skipped\":1,\"transport\":\"remote:staging\"}\n",
543 )
544 );
545 }
546
547 #[test]
548 fn a_runner_that_died_is_an_error_event() {
549 let mut stream = Stream::new(context());
550 stream.push(r#"{"type":"run-begin","suites":1}"#);
551 assert_eq!(
552 stream.finish("boom\n"),
553 "{\"type\":\"error\",\"kind\":\"runtime\",\"message\":\"the test runner exited before completing\",\"stderr\":\"boom\"}\n"
554 );
555 }
556
557 #[test]
558 fn a_document_held_in_full_becomes_its_events() {
559 let compile = TestRun::compile_error(vec!["a.bynk:1:1: error[x]: no".to_string()]);
560 assert_eq!(
561 events_for(&compile),
562 "{\"type\":\"error\",\"kind\":\"compile\",\"diagnostics\":[\"a.bynk:1:1: error[x]: no\"]}\n"
563 );
564
565 let mut run = TestRun::empty();
566 run.suites = Some(vec![
567 Suite {
568 name: "demo.m".to_string(),
569 kind: "unit".to_string(),
570 cases: vec![case("one", "pass"), case("two", "skipped")],
571 },
572 Suite {
573 name: "demo.s".to_string(),
574 kind: "integration".to_string(),
575 cases: vec![case("advances", "skipped")],
576 },
577 Suite {
578 name: "demo.empty".to_string(),
579 kind: "unit".to_string(),
580 cases: Vec::new(),
581 },
582 ]);
583 run.passed = 1;
584 run.skipped = 2;
585 assert_eq!(
586 events_for(&run),
587 concat!(
588 "{\"type\":\"run-begin\",\"suites\":2}\n",
589 "{\"type\":\"suite-begin\",\"name\":\"demo.m\",\"kind\":\"unit\",\"tests\":1}\n",
590 "{\"type\":\"case\",\"suite\":\"demo.m\",\"name\":\"one\",\"outcome\":\"pass\"}\n",
591 "{\"type\":\"suite-end\",\"name\":\"demo.m\"}\n",
592 "{\"type\":\"suite-begin\",\"name\":\"demo.empty\",\"kind\":\"unit\",\"tests\":0}\n",
593 "{\"type\":\"suite-end\",\"name\":\"demo.empty\"}\n",
594 "{\"type\":\"case\",\"suite\":\"demo.m\",\"name\":\"two\",\"outcome\":\"skipped\"}\n",
595 "{\"type\":\"case\",\"suite\":\"demo.s\",\"name\":\"advances\",\"outcome\":\"skipped\"}\n",
596 "{\"type\":\"run-end\",\"passed\":1,\"failed\":0,\"skipped\":2}\n",
597 ),
598 "`tests` counts what the suite reports; a suite of only skipped cases has no block"
599 );
600 }
601
602 struct Timed(Vec<(std::time::Instant, String)>);
604
605 impl Write for Timed {
606 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
607 self.0.push((
608 std::time::Instant::now(),
609 String::from_utf8_lossy(buf).into_owned(),
610 ));
611 Ok(buf.len())
612 }
613 fn flush(&mut self) -> std::io::Result<()> {
614 Ok(())
615 }
616 }
617
618 #[cfg(unix)]
622 #[test]
623 fn events_are_forwarded_as_they_are_written() {
624 let mut cmd = Command::new("sh");
625 cmd.arg("-c").arg(
626 r#"printf '{"type":"run-begin","suites":1}\n{"type":"suite-be'; sleep 0.4;
627 printf 'gin","name":"s","kind":"unit","tests":0}\n'; sleep 0.4; echo oops >&2"#,
628 );
629 let mut stream = Stream::new(context());
630 let mut sink = Timed(Vec::new());
631 let started = std::time::Instant::now();
632 let status = forward(cmd, &mut stream, &mut sink).unwrap();
633 let finished = started.elapsed();
634 assert!(status.success());
635 let first = sink.0.first().expect("an event");
636 assert_eq!(first.1, "{\"type\":\"run-begin\",\"suites\":1}\n");
637 assert!(
638 first.0.duration_since(started) < finished - std::time::Duration::from_millis(500),
639 "the first event arrived well before the runner exited"
640 );
641 let all: String = sink.0.iter().map(|(_, t)| t.as_str()).collect();
642 assert_eq!(
643 all,
644 concat!(
645 "{\"type\":\"run-begin\",\"suites\":1}\n",
646 "{\"type\":\"suite-begin\",\"name\":\"s\",\"kind\":\"unit\",\"tests\":0}\n",
647 "{\"type\":\"error\",\"kind\":\"runtime\",\"message\":\"the test runner exited before completing\",\"stderr\":\"oops\"}\n",
648 ),
649 "a split event is one event, and the missing run-end is an error"
650 );
651 }
652}