Skip to main content

bynk_driver/
test_stream.rs

1//! `bynk test --format ndjson` (#2267, Slice D stage 1): the supported result
2//! stream.
3//!
4//! The runner already writes one JSON event per line (`BYNK_TEST_FORMAT=ndjson`),
5//! an internal protocol between the generated entry and the driver. `--format
6//! json` collects all of it and prints one document at the end; `--format ndjson`
7//! forwards it as it arrives, so a consumer sees each suite when that suite
8//! finishes. The events the driver prints are its own, not the runner's lines
9//! passed through, so the contract does not depend on the runner's wording:
10//!
11//! - `run-begin {suites}`
12//! - `suite-begin {name, kind, tests}`
13//! - `case {suite, <the document's case fields>}`: the same object the JSON
14//!   document holds for the case, in the same field order, behind `type` and
15//!   `suite`, so it carries `tier` and the generative fields too
16//! - `suite-end {name}`
17//! - `run-end {passed, failed, skipped?, transport?}`
18//! - `error {kind, message?, diagnostics?, stderr?}`: the document's `error`
19//!   object, for a compile failure, a run the driver could not start or finish,
20//!   and a runner that died before `run-end`
21//!
22//! The stream ends with exactly one of `run-end` or `error`. A case a remote run
23//! skipped (`--skip-unsupported`) is a `case` event with outcome `skipped`
24//! between the last `suite-end` and `run-end`. A line that is not a recognised
25//! runner event is dropped. Field order is the contract; a consumer ignores
26//! event types and fields it does not know.
27
28use 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    /// After the document's case fields, so a later optional `Case` field still
58    /// lands before it.
59    #[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    // The structs hold only strings and numbers, which always serialise.
93    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
147/// The stream for a document held in full: a run that could not start (a
148/// compile failure, a launch error) is one `error` event, and a normal one is
149/// its suites' events then `run-end`. Used where nothing is read live.
150pub 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    // The live stream never sees a skipped case: the runner did not run it. So a
159    // suite's `tests` counts the cases between its `suite-begin` and `suite-end`,
160    // and a suite made only of skipped cases (which `with_remote` adds for them)
161    // has no such block, only its skipped cases after the last `suite-end`.
162    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
195/// Print a document in `--format json`, or its events in `--format ndjson`.
196pub fn print_run(stream: bool, run: &TestRun) {
197    if stream {
198        print!("{}", events_for(run));
199    } else {
200        print!("{}", run.render());
201    }
202}
203
204/// A remote run's skipped case, as it goes into the stream: suite, case, reason.
205pub type Skipped = (String, String, String);
206
207/// What the stream knows about the run besides the runner's own events.
208pub struct StreamContext<'a> {
209    /// The compile's case table, for each case's tier.
210    pub discovered: &'a [bynk_emit::project::DiscoveredSuite],
211    /// The cases a remote run skipped, which the runner never saw.
212    pub skipped: Vec<Skipped>,
213    /// `remote:<target>` under `--against`.
214    pub transport: Option<String>,
215}
216
217/// Turns the runner's event lines into the stream's, one at a time.
218pub 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    /// The `--suite`/`--case` values the runner reported as selecting nothing:
234    /// not part of the stream, but the driver turns them into an error (#2273).
235    pub fn unmatched(&self) -> &Unmatched {
236        &self.unmatched
237    }
238
239    /// `"doc"` for a doc-comment example's case (#2461), from the case table.
240    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    /// The stream's text for one line of the runner's output: nothing for a line
262    /// that is not a recognised event.
263    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    /// What closes the stream when the runner's output has ended: nothing after
325    /// a `run-end`, else an `error` event, the runner having died mid-run.
326    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
340/// Run `cmd`, forwarding each event to `out` as the runner writes it, and return
341/// its exit status. The runner's stderr is collected on a thread, so a full pipe
342/// cannot stall it. If `out` stops accepting output (a consumer that went away),
343/// the runner is killed rather than left running.
344pub 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        // Lossy, so a stray byte in a line cannot end the stream early.
365        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        // A case without one (a skipped case, a replayed document) has no field.
496        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    /// A sink that records when each write arrived.
620    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    /// An event reaches the consumer while the runner is still running, not when
636    /// it exits; an event split across two writes is still one event; a runner
637    /// that never says `run-end` ends the stream with an `error`.
638    #[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}