bynkc/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}
32
33#[derive(Debug, PartialEq, Serialize)]
34pub struct Suite {
35 pub name: String,
36 pub kind: String,
37 pub cases: Vec<Case>,
38}
39
40#[derive(Debug, PartialEq, Serialize)]
41pub struct Case {
42 pub name: String,
43 /// `"pass"` / `"fail"` from a run, or `"discovered"` in a `--no-run`
44 /// discovery document (the case was listed, not executed).
45 pub outcome: String,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub message: Option<String>,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub location: Option<Location>,
50}
51
52#[derive(Debug, PartialEq, Serialize)]
53pub struct Location {
54 pub path: String,
55 pub line: u32,
56 pub col: u32,
57}
58
59#[derive(Debug, PartialEq, Serialize)]
60pub struct TestError {
61 pub kind: String,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub message: Option<String>,
64 /// `bynkc` diagnostic lines (`path:line:col: severity[category]: message`),
65 /// for `kind == "compile"`. Empty (and omitted) otherwise.
66 #[serde(skip_serializing_if = "Vec::is_empty")]
67 pub diagnostics: Vec<String>,
68 /// Captured stderr from a crashed run, for `kind == "runtime"`.
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub stderr: Option<String>,
71}
72
73impl TestRun {
74 /// A normal run with no suites (no tests, or `--no-run`).
75 pub fn empty() -> Self {
76 TestRun {
77 passed: 0,
78 failed: 0,
79 suites: Some(Vec::new()),
80 error: None,
81 }
82 }
83
84 /// v0.67: a **discovery** document (`--no-run --format json`) — the suites and
85 /// cases the compile retained, listed without running. `passed`/`failed` are
86 /// 0 and each case carries `outcome: "discovered"` (see [`Case`]), so the
87 /// "every case has an outcome" invariant holds and no consumer mistakes a
88 /// listed case for a result. Reconciles against a later run document: same
89 /// suite `name`/`kind`, same case `name`s.
90 pub fn discovered(suites: Vec<Suite>) -> Self {
91 TestRun {
92 passed: 0,
93 failed: 0,
94 suites: Some(suites),
95 error: None,
96 }
97 }
98
99 /// The document for a run that could not start or complete outside the
100 /// compile step (the runner couldn't be launched, `tsc` rejected the
101 /// emitted TS, or the runner died). No suites — use [`ParsedRun::into_document`]
102 /// when a partial suite prefix was observed.
103 pub fn runtime_error(message: impl Into<String>, stderr: Option<String>) -> Self {
104 TestRun {
105 passed: 0,
106 failed: 0,
107 suites: None,
108 error: Some(TestError {
109 kind: "runtime".to_string(),
110 message: Some(message.into()),
111 diagnostics: Vec::new(),
112 stderr: stderr.filter(|s| !s.trim().is_empty()),
113 }),
114 }
115 }
116
117 /// The document for a project that never compiled.
118 pub fn compile_error(diagnostics: Vec<String>) -> Self {
119 TestRun {
120 passed: 0,
121 failed: 0,
122 suites: None,
123 error: Some(TestError {
124 kind: "compile".to_string(),
125 message: None,
126 diagnostics,
127 stderr: None,
128 }),
129 }
130 }
131
132 /// Render to a pretty JSON string (trailing newline). Serde emits struct
133 /// fields in declaration order regardless of `preserve_order`.
134 pub fn render(&self) -> String {
135 let mut s = serde_json::to_string_pretty(self).expect("TestRun serialises");
136 s.push('\n');
137 s
138 }
139}
140
141/// The outcome of parsing the runner's NDJSON stream: the suites observed, the
142/// running tallies, and whether a `run-end` event was seen (a missing `run-end`
143/// means the runner died mid-stream — a crashed/incomplete run).
144#[derive(Debug, Default, PartialEq)]
145pub struct ParsedRun {
146 pub passed: u32,
147 pub failed: u32,
148 pub suites: Vec<Suite>,
149 pub complete: bool,
150}
151
152impl ParsedRun {
153 /// Fold this parsed stream into the final document. `node_ok` is whether the
154 /// runner process exited zero; `stderr` is its captured stderr (used only
155 /// for a crashed run). A stream with no `run-end` — or a non-zero exit with
156 /// no completion — becomes a `runtime` error carrying the observed prefix.
157 pub fn into_document(self, stderr: &str) -> TestRun {
158 if self.complete {
159 TestRun {
160 passed: self.passed,
161 failed: self.failed,
162 suites: Some(self.suites),
163 error: None,
164 }
165 } else {
166 let trimmed = stderr.trim();
167 TestRun {
168 passed: self.passed,
169 failed: self.failed,
170 suites: Some(self.suites),
171 error: Some(TestError {
172 kind: "runtime".to_string(),
173 message: Some("the test runner exited before completing".to_string()),
174 diagnostics: Vec::new(),
175 stderr: (!trimmed.is_empty()).then(|| trimmed.to_string()),
176 }),
177 }
178 }
179 }
180}
181
182/// Parse the runner's NDJSON stdout into a [`ParsedRun`]. Unparseable or
183/// unrecognised lines are skipped (the stream is an internal protocol; a
184/// stray line should never abort the whole report).
185pub fn parse_ndjson(stdout: &str) -> ParsedRun {
186 let mut run = ParsedRun::default();
187 for line in stdout.lines() {
188 let line = line.trim();
189 if line.is_empty() {
190 continue;
191 }
192 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
193 continue;
194 };
195 match value.get("type").and_then(|t| t.as_str()) {
196 Some("suite-begin") => {
197 run.suites.push(Suite {
198 name: str_field(&value, "name"),
199 kind: str_field(&value, "kind"),
200 cases: Vec::new(),
201 });
202 }
203 Some("case") => {
204 let outcome = str_field(&value, "outcome");
205 if outcome == "pass" {
206 run.passed += 1;
207 } else {
208 run.failed += 1;
209 }
210 let message = value
211 .get("message")
212 .and_then(|m| m.as_str())
213 .map(str::to_string);
214 let location = value
215 .get("location")
216 .and_then(|l| l.as_str())
217 .and_then(parse_location);
218 let case = Case {
219 name: str_field(&value, "name"),
220 outcome,
221 message,
222 location,
223 };
224 if let Some(suite) = run.suites.last_mut() {
225 suite.cases.push(case);
226 }
227 }
228 Some("run-end") => {
229 run.complete = true;
230 }
231 _ => {}
232 }
233 }
234 run
235}
236
237fn str_field(value: &serde_json::Value, key: &str) -> String {
238 value
239 .get(key)
240 .and_then(|v| v.as_str())
241 .unwrap_or_default()
242 .to_string()
243}
244
245/// Split a `path:line:col` location string into structured fields. Returns
246/// `None` for anything that isn't that shape (e.g. the `"unknown"` fallback a
247/// non-assertion throw carries), so such a failure keeps its message but offers
248/// no click-through. Splits from the right, so a path containing `:` is safe.
249fn parse_location(s: &str) -> Option<Location> {
250 let (rest, col) = s.rsplit_once(':')?;
251 let (path, line) = rest.rsplit_once(':')?;
252 let line: u32 = line.parse().ok()?;
253 let col: u32 = col.parse().ok()?;
254 if path.is_empty() {
255 return None;
256 }
257 Some(Location {
258 path: path.to_string(),
259 line,
260 col,
261 })
262}