Skip to main content

bynk_driver/
test_json.rs

1//! v0.59: the `bynkc test --format json` result model, plus the parser that
2//! folds the runner's NDJSON event stream into it.
3//!
4//! The generated `tests/main.ts` runner emits one JSON event per line when
5//! `BYNK_TEST_FORMAT=ndjson` (an **internal** protocol — proposal v0.59,
6//! Decision 2); `run_test` captures that stream and renders the single pinned
7//! **document** below. The document is built from `#[derive(Serialize)]` structs
8//! in **declaration order** — field order *is* the contract (the discipline
9//! `bynk/src/report.rs` calls out); we never use `serde_json::json!`, so the
10//! `preserve_order` feature some workspace crates enable can't reorder it.
11//!
12//! There are three terminal states, distinguished by the consumer on the
13//! presence/`kind` of `error`:
14//! - **normal** — `suites` present, no `error` (may have `failed > 0`);
15//! - **compile** — the project never compiled: no `suites`, `error.kind ==
16//!   "compile"` carrying the `bynkc` diagnostic lines;
17//! - **runtime** — the runner started then died before `run-end`: the observed
18//!   `suites` prefix *and* `error.kind == "runtime"` with the captured stderr.
19
20use serde::Serialize;
21
22/// The pinned `bynkc test --format json` document.
23#[derive(Debug, PartialEq, Serialize)]
24pub struct TestRun {
25    pub passed: u32,
26    pub failed: u32,
27    /// #2138: the cases a remote run skipped (`--skip-unsupported`). Absent
28    /// when zero, so every other document's layout is unchanged.
29    #[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    /// #854: the optional coverage block, present only for a `--coverage` run
36    /// that produced attributable lines. Last field, so every existing
37    /// document's byte layout is unchanged when it is absent.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub coverage: Option<Coverage>,
40    /// #2138: where the `system` suites ran — `"remote:<target>"` under
41    /// `--against`. Absent for an in-process run. Last field, so every
42    /// existing document's layout is unchanged.
43    #[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/// The `coverage` block: whole-run totals plus one entry per measured `.bynk`
52/// file, keyed by project-relative path. Attributed to `.bynk` source lines, not
53/// emitted `.ts`/`.js` (issue #854); generated glue with no source origin is
54/// out-of-scope, never counted.
55#[derive(Debug, PartialEq, Serialize)]
56pub struct Coverage {
57    /// Covered executable lines across every measured file.
58    pub covered: u32,
59    /// Total executable lines across every measured file.
60    pub lines: u32,
61    /// Whole-run percentage (0–100), rounded.
62    pub percent: u32,
63    pub files: Vec<FileCoverage>,
64}
65
66/// One measured `.bynk` file's line coverage.
67#[derive(Debug, PartialEq, Serialize)]
68pub struct FileCoverage {
69    pub path: String,
70    pub covered: u32,
71    pub lines: u32,
72    pub percent: u32,
73    /// Uncovered executable lines, 1-based and ascending.
74    pub uncovered: Vec<u32>,
75    /// #2104: covered executable lines, 1-based and ascending. With
76    /// `uncovered`, every executable line, so an editor can mark both. Named
77    /// `covered_lines` because `covered` is already the count. Additive and
78    /// last, so a consumer that predates it is unaffected.
79    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    /// `"pass"` / `"fail"` from a run, `"skipped"` for a case a remote run
115    /// skipped (#2138), or `"discovered"` in a `--no-run`
116    /// discovery document (the case was listed, not executed).
117    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    /// #2138 (track §3.6): how often `--settle` retried the case's `expect`s
123    /// in a remote run. Absent when it wasn't retried.
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub retries: Option<u32>,
126}
127
128#[derive(Debug, PartialEq, Serialize)]
129pub struct Location {
130    pub path: String,
131    pub line: u32,
132    pub col: u32,
133}
134
135#[derive(Debug, PartialEq, Serialize)]
136pub struct TestError {
137    pub kind: String,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub message: Option<String>,
140    /// `bynkc` diagnostic lines (`path:line:col: severity[category]: message`),
141    /// for `kind == "compile"`. Empty (and omitted) otherwise.
142    #[serde(skip_serializing_if = "Vec::is_empty")]
143    pub diagnostics: Vec<String>,
144    /// Captured stderr from a crashed run, for `kind == "runtime"`.
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub stderr: Option<String>,
147}
148
149impl TestRun {
150    /// A normal run with no suites (no tests, or `--no-run`).
151    pub fn empty() -> Self {
152        TestRun {
153            passed: 0,
154            failed: 0,
155            skipped: 0,
156            suites: Some(Vec::new()),
157            error: None,
158            coverage: None,
159            transport: None,
160        }
161    }
162
163    /// Attach a coverage block (`--coverage`, issue #854). A report with no
164    /// attributed line is dropped rather than serialising an empty block, so a
165    /// coverage run over a project with nothing measurable reads like a normal
166    /// run.
167    pub fn with_coverage(mut self, report: &crate::coverage::CoverageReport) -> Self {
168        if !report.is_empty() {
169            self.coverage = Some(Coverage::from(report));
170        }
171        self
172    }
173
174    /// v0.67: a **discovery** document (`--no-run --format json`) — the suites and
175    /// cases the compile retained, listed without running. `passed`/`failed` are
176    /// 0 and each case carries `outcome: "discovered"` (see [`Case`]), so the
177    /// "every case has an outcome" invariant holds and no consumer mistakes a
178    /// listed case for a result. Reconciles against a later run document: same
179    /// suite `name`/`kind`, same case `name`s.
180    pub fn discovered(suites: Vec<Suite>) -> Self {
181        TestRun {
182            passed: 0,
183            failed: 0,
184            skipped: 0,
185            suites: Some(suites),
186            error: None,
187            coverage: None,
188            transport: None,
189        }
190    }
191
192    /// The document for a run that could not start or complete outside the
193    /// compile step (the runner couldn't be launched, `tsc` rejected the
194    /// emitted TS, or the runner died). No suites — use [`ParsedRun::into_document`]
195    /// when a partial suite prefix was observed.
196    pub fn runtime_error(message: impl Into<String>, stderr: Option<String>) -> Self {
197        TestRun {
198            passed: 0,
199            failed: 0,
200            skipped: 0,
201            suites: None,
202            error: Some(TestError {
203                kind: "runtime".to_string(),
204                message: Some(message.into()),
205                diagnostics: Vec::new(),
206                stderr: stderr.filter(|s| !s.trim().is_empty()),
207            }),
208            coverage: None,
209            transport: None,
210        }
211    }
212
213    /// The document for a project that never compiled.
214    pub fn compile_error(diagnostics: Vec<String>) -> Self {
215        TestRun {
216            passed: 0,
217            failed: 0,
218            skipped: 0,
219            suites: None,
220            error: Some(TestError {
221                kind: "compile".to_string(),
222                message: None,
223                diagnostics,
224                stderr: None,
225            }),
226            coverage: None,
227            transport: None,
228        }
229    }
230
231    /// #2138: fold a remote run into the document — its `transport`, and each
232    /// skipped case as an `outcome: "skipped"` case whose `message` is why,
233    /// in its suite (a new `integration` suite when none of that name ran).
234    pub fn with_remote(mut self, transport: String, skipped: &[(String, String, String)]) -> Self {
235        self.transport = Some(transport);
236        if skipped.is_empty() {
237            return self;
238        }
239        self.skipped = u32::try_from(skipped.len()).unwrap_or(u32::MAX);
240        let suites = self.suites.get_or_insert_with(Vec::new);
241        for (suite, case, reason) in skipped {
242            let case = Case {
243                name: case.clone(),
244                outcome: "skipped".to_string(),
245                message: Some(reason.clone()),
246                location: None,
247                retries: None,
248            };
249            match suites.iter_mut().find(|s| &s.name == suite) {
250                Some(s) => s.cases.push(case),
251                None => suites.push(Suite {
252                    name: suite.clone(),
253                    kind: "integration".to_string(),
254                    cases: vec![case],
255                }),
256            }
257        }
258        self
259    }
260
261    /// Render to a pretty JSON string (trailing newline). Serde emits struct
262    /// fields in declaration order regardless of `preserve_order`.
263    pub fn render(&self) -> String {
264        let mut s = serde_json::to_string_pretty(self).expect("TestRun serialises");
265        s.push('\n');
266        s
267    }
268}
269
270/// The outcome of parsing the runner's NDJSON stream: the suites observed, the
271/// running tallies, and whether a `run-end` event was seen (a missing `run-end`
272/// means the runner died mid-stream — a crashed/incomplete run).
273#[derive(Debug, Default, PartialEq)]
274pub struct ParsedRun {
275    pub passed: u32,
276    pub failed: u32,
277    pub suites: Vec<Suite>,
278    pub complete: bool,
279}
280
281impl ParsedRun {
282    /// Fold this parsed stream into the final document. `node_ok` is whether the
283    /// runner process exited zero; `stderr` is its captured stderr (used only
284    /// for a crashed run). A stream with no `run-end` — or a non-zero exit with
285    /// no completion — becomes a `runtime` error carrying the observed prefix.
286    pub fn into_document(self, stderr: &str) -> TestRun {
287        if self.complete {
288            TestRun {
289                passed: self.passed,
290                failed: self.failed,
291                skipped: 0,
292                suites: Some(self.suites),
293                error: None,
294                coverage: None,
295                transport: None,
296            }
297        } else {
298            let trimmed = stderr.trim();
299            TestRun {
300                passed: self.passed,
301                failed: self.failed,
302                skipped: 0,
303                suites: Some(self.suites),
304                error: Some(TestError {
305                    kind: "runtime".to_string(),
306                    message: Some("the test runner exited before completing".to_string()),
307                    diagnostics: Vec::new(),
308                    stderr: (!trimmed.is_empty()).then(|| trimmed.to_string()),
309                }),
310                coverage: None,
311                transport: None,
312            }
313        }
314    }
315}
316
317/// Parse the runner's NDJSON stdout into a [`ParsedRun`]. Unparseable or
318/// unrecognised lines are skipped (the stream is an internal protocol; a
319/// stray line should never abort the whole report).
320pub fn parse_ndjson(stdout: &str) -> ParsedRun {
321    let mut run = ParsedRun::default();
322    for line in stdout.lines() {
323        let line = line.trim();
324        if line.is_empty() {
325            continue;
326        }
327        let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
328            continue;
329        };
330        match value.get("type").and_then(|t| t.as_str()) {
331            Some("suite-begin") => {
332                run.suites.push(Suite {
333                    name: str_field(&value, "name"),
334                    kind: str_field(&value, "kind"),
335                    cases: Vec::new(),
336                });
337            }
338            Some("case") => {
339                let outcome = str_field(&value, "outcome");
340                if outcome == "pass" {
341                    run.passed += 1;
342                } else {
343                    run.failed += 1;
344                }
345                let message = value
346                    .get("message")
347                    .and_then(|m| m.as_str())
348                    .map(str::to_string);
349                let location = value
350                    .get("location")
351                    .and_then(|l| l.as_str())
352                    .and_then(parse_location);
353                let retries = value
354                    .get("retries")
355                    .and_then(serde_json::Value::as_u64)
356                    .filter(|n| *n > 0)
357                    .map(|n| u32::try_from(n).unwrap_or(u32::MAX));
358                let case = Case {
359                    name: str_field(&value, "name"),
360                    outcome,
361                    message,
362                    location,
363                    retries,
364                };
365                if let Some(suite) = run.suites.last_mut() {
366                    suite.cases.push(case);
367                }
368            }
369            Some("run-end") => {
370                run.complete = true;
371            }
372            _ => {}
373        }
374    }
375    run
376}
377
378fn str_field(value: &serde_json::Value, key: &str) -> String {
379    value
380        .get(key)
381        .and_then(|v| v.as_str())
382        .unwrap_or_default()
383        .to_string()
384}
385
386/// Split a `path:line:col` location string into structured fields. Returns
387/// `None` for anything that isn't that shape (e.g. the `"unknown"` fallback a
388/// non-assertion throw carries), so such a failure keeps its message but offers
389/// no click-through. Splits from the right, so a path containing `:` is safe.
390fn parse_location(s: &str) -> Option<Location> {
391    let (rest, col) = s.rsplit_once(':')?;
392    let (path, line) = rest.rsplit_once(':')?;
393    let line: u32 = line.parse().ok()?;
394    let col: u32 = col.parse().ok()?;
395    if path.is_empty() {
396        return None;
397    }
398    Some(Location {
399        path: path.to_string(),
400        line,
401        col,
402    })
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    /// #2104: a file's coverage carries both line lists, `covered_lines` last,
410    /// so the block's existing field order (the contract) is unchanged.
411    #[test]
412    fn file_coverage_carries_covered_and_uncovered_lines() {
413        let report = crate::coverage::CoverageReport {
414            files: vec![crate::coverage::FileCoverage {
415                path: "src/demo/m.bynk".into(),
416                covered: 2,
417                total: 5,
418                uncovered: vec![7, 8, 9],
419                covered_lines: vec![3, 4],
420            }],
421        };
422        assert_eq!(
423            serde_json::to_string(&Coverage::from(&report)).unwrap(),
424            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]}]}"#
425        );
426    }
427}