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    #[serde(skip_serializing_if = "Option::is_none")]
28    pub suites: Option<Vec<Suite>>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub error: Option<TestError>,
31    /// #854: the optional coverage block, present only for a `--coverage` run
32    /// that produced attributable lines. Last field, so every existing
33    /// document's byte layout is unchanged when it is absent.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub coverage: Option<Coverage>,
36}
37
38/// The `coverage` block: whole-run totals plus one entry per measured `.bynk`
39/// file, keyed by project-relative path. Attributed to `.bynk` source lines, not
40/// emitted `.ts`/`.js` (issue #854); generated glue with no source origin is
41/// out-of-scope, never counted.
42#[derive(Debug, PartialEq, Serialize)]
43pub struct Coverage {
44    /// Covered executable lines across every measured file.
45    pub covered: u32,
46    /// Total executable lines across every measured file.
47    pub lines: u32,
48    /// Whole-run percentage (0–100), rounded.
49    pub percent: u32,
50    pub files: Vec<FileCoverage>,
51}
52
53/// One measured `.bynk` file's line coverage.
54#[derive(Debug, PartialEq, Serialize)]
55pub struct FileCoverage {
56    pub path: String,
57    pub covered: u32,
58    pub lines: u32,
59    pub percent: u32,
60    /// Uncovered executable lines, 1-based and ascending.
61    pub uncovered: Vec<u32>,
62}
63
64impl From<&crate::coverage::CoverageReport> for Coverage {
65    fn from(r: &crate::coverage::CoverageReport) -> Self {
66        Coverage {
67            covered: r.total_covered(),
68            lines: r.total_lines(),
69            percent: r.total_percent(),
70            files: r
71                .files
72                .iter()
73                .map(|f| FileCoverage {
74                    path: f.path.clone(),
75                    covered: f.covered,
76                    lines: f.total,
77                    percent: crate::coverage::percent(f.covered, f.total),
78                    uncovered: f.uncovered.clone(),
79                })
80                .collect(),
81        }
82    }
83}
84
85#[derive(Debug, PartialEq, Serialize)]
86pub struct Suite {
87    pub name: String,
88    pub kind: String,
89    pub cases: Vec<Case>,
90}
91
92#[derive(Debug, PartialEq, Serialize)]
93pub struct Case {
94    pub name: String,
95    /// `"pass"` / `"fail"` from a run, or `"discovered"` in a `--no-run`
96    /// discovery document (the case was listed, not executed).
97    pub outcome: String,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub message: Option<String>,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub location: Option<Location>,
102}
103
104#[derive(Debug, PartialEq, Serialize)]
105pub struct Location {
106    pub path: String,
107    pub line: u32,
108    pub col: u32,
109}
110
111#[derive(Debug, PartialEq, Serialize)]
112pub struct TestError {
113    pub kind: String,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub message: Option<String>,
116    /// `bynkc` diagnostic lines (`path:line:col: severity[category]: message`),
117    /// for `kind == "compile"`. Empty (and omitted) otherwise.
118    #[serde(skip_serializing_if = "Vec::is_empty")]
119    pub diagnostics: Vec<String>,
120    /// Captured stderr from a crashed run, for `kind == "runtime"`.
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub stderr: Option<String>,
123}
124
125impl TestRun {
126    /// A normal run with no suites (no tests, or `--no-run`).
127    pub fn empty() -> Self {
128        TestRun {
129            passed: 0,
130            failed: 0,
131            suites: Some(Vec::new()),
132            error: None,
133            coverage: None,
134        }
135    }
136
137    /// Attach a coverage block (`--coverage`, issue #854). A report with no
138    /// attributed line is dropped rather than serialising an empty block, so a
139    /// coverage run over a project with nothing measurable reads like a normal
140    /// run.
141    pub fn with_coverage(mut self, report: &crate::coverage::CoverageReport) -> Self {
142        if !report.is_empty() {
143            self.coverage = Some(Coverage::from(report));
144        }
145        self
146    }
147
148    /// v0.67: a **discovery** document (`--no-run --format json`) — the suites and
149    /// cases the compile retained, listed without running. `passed`/`failed` are
150    /// 0 and each case carries `outcome: "discovered"` (see [`Case`]), so the
151    /// "every case has an outcome" invariant holds and no consumer mistakes a
152    /// listed case for a result. Reconciles against a later run document: same
153    /// suite `name`/`kind`, same case `name`s.
154    pub fn discovered(suites: Vec<Suite>) -> Self {
155        TestRun {
156            passed: 0,
157            failed: 0,
158            suites: Some(suites),
159            error: None,
160            coverage: None,
161        }
162    }
163
164    /// The document for a run that could not start or complete outside the
165    /// compile step (the runner couldn't be launched, `tsc` rejected the
166    /// emitted TS, or the runner died). No suites — use [`ParsedRun::into_document`]
167    /// when a partial suite prefix was observed.
168    pub fn runtime_error(message: impl Into<String>, stderr: Option<String>) -> Self {
169        TestRun {
170            passed: 0,
171            failed: 0,
172            suites: None,
173            error: Some(TestError {
174                kind: "runtime".to_string(),
175                message: Some(message.into()),
176                diagnostics: Vec::new(),
177                stderr: stderr.filter(|s| !s.trim().is_empty()),
178            }),
179            coverage: None,
180        }
181    }
182
183    /// The document for a project that never compiled.
184    pub fn compile_error(diagnostics: Vec<String>) -> Self {
185        TestRun {
186            passed: 0,
187            failed: 0,
188            suites: None,
189            error: Some(TestError {
190                kind: "compile".to_string(),
191                message: None,
192                diagnostics,
193                stderr: None,
194            }),
195            coverage: None,
196        }
197    }
198
199    /// Render to a pretty JSON string (trailing newline). Serde emits struct
200    /// fields in declaration order regardless of `preserve_order`.
201    pub fn render(&self) -> String {
202        let mut s = serde_json::to_string_pretty(self).expect("TestRun serialises");
203        s.push('\n');
204        s
205    }
206}
207
208/// The outcome of parsing the runner's NDJSON stream: the suites observed, the
209/// running tallies, and whether a `run-end` event was seen (a missing `run-end`
210/// means the runner died mid-stream — a crashed/incomplete run).
211#[derive(Debug, Default, PartialEq)]
212pub struct ParsedRun {
213    pub passed: u32,
214    pub failed: u32,
215    pub suites: Vec<Suite>,
216    pub complete: bool,
217}
218
219impl ParsedRun {
220    /// Fold this parsed stream into the final document. `node_ok` is whether the
221    /// runner process exited zero; `stderr` is its captured stderr (used only
222    /// for a crashed run). A stream with no `run-end` — or a non-zero exit with
223    /// no completion — becomes a `runtime` error carrying the observed prefix.
224    pub fn into_document(self, stderr: &str) -> TestRun {
225        if self.complete {
226            TestRun {
227                passed: self.passed,
228                failed: self.failed,
229                suites: Some(self.suites),
230                error: None,
231                coverage: None,
232            }
233        } else {
234            let trimmed = stderr.trim();
235            TestRun {
236                passed: self.passed,
237                failed: self.failed,
238                suites: Some(self.suites),
239                error: Some(TestError {
240                    kind: "runtime".to_string(),
241                    message: Some("the test runner exited before completing".to_string()),
242                    diagnostics: Vec::new(),
243                    stderr: (!trimmed.is_empty()).then(|| trimmed.to_string()),
244                }),
245                coverage: None,
246            }
247        }
248    }
249}
250
251/// Parse the runner's NDJSON stdout into a [`ParsedRun`]. Unparseable or
252/// unrecognised lines are skipped (the stream is an internal protocol; a
253/// stray line should never abort the whole report).
254pub fn parse_ndjson(stdout: &str) -> ParsedRun {
255    let mut run = ParsedRun::default();
256    for line in stdout.lines() {
257        let line = line.trim();
258        if line.is_empty() {
259            continue;
260        }
261        let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
262            continue;
263        };
264        match value.get("type").and_then(|t| t.as_str()) {
265            Some("suite-begin") => {
266                run.suites.push(Suite {
267                    name: str_field(&value, "name"),
268                    kind: str_field(&value, "kind"),
269                    cases: Vec::new(),
270                });
271            }
272            Some("case") => {
273                let outcome = str_field(&value, "outcome");
274                if outcome == "pass" {
275                    run.passed += 1;
276                } else {
277                    run.failed += 1;
278                }
279                let message = value
280                    .get("message")
281                    .and_then(|m| m.as_str())
282                    .map(str::to_string);
283                let location = value
284                    .get("location")
285                    .and_then(|l| l.as_str())
286                    .and_then(parse_location);
287                let case = Case {
288                    name: str_field(&value, "name"),
289                    outcome,
290                    message,
291                    location,
292                };
293                if let Some(suite) = run.suites.last_mut() {
294                    suite.cases.push(case);
295                }
296            }
297            Some("run-end") => {
298                run.complete = true;
299            }
300            _ => {}
301        }
302    }
303    run
304}
305
306fn str_field(value: &serde_json::Value, key: &str) -> String {
307    value
308        .get(key)
309        .and_then(|v| v.as_str())
310        .unwrap_or_default()
311        .to_string()
312}
313
314/// Split a `path:line:col` location string into structured fields. Returns
315/// `None` for anything that isn't that shape (e.g. the `"unknown"` fallback a
316/// non-assertion throw carries), so such a failure keeps its message but offers
317/// no click-through. Splits from the right, so a path containing `:` is safe.
318fn parse_location(s: &str) -> Option<Location> {
319    let (rest, col) = s.rsplit_once(':')?;
320    let (path, line) = rest.rsplit_once(':')?;
321    let line: u32 = line.parse().ok()?;
322    let col: u32 = col.parse().ok()?;
323    if path.is_empty() {
324        return None;
325    }
326    Some(Location {
327        path: path.to_string(),
328        line,
329        col,
330    })
331}