Skip to main content

bynk/
test.rs

1//! `bynk test` — discover and run a project's test declarations.
2//!
3//! Delegates to the `bynkc` the driver resolves (`BYNK_BYNKC` → PATH →
4//! sibling-of-`bynk`) and shells `bynkc test`, forwarding every flag verbatim
5//! (v0.138, #487). Unlike `check`/`fmt`, `test` delegates *always*, not only
6//! under an override: it orchestrates external `tsc`/`node`, so it is a
7//! subprocess regardless, and routing it through the driver's resolution is the
8//! whole point — an editor or developer inherits that resolution instead of
9//! locating `bynkc` themselves (the fix direction for #486). The trade-off (a
10//! `bynkc` binary must be present, and the driver↔compiler skew surface stays)
11//! is accepted; the driver at least resolves it more richly than any editor.
12//! #1675: and it acts on that skew before delegating — see
13//! [`crate::compiler::skew_gate`].
14
15use std::ffi::OsString;
16use std::path::{Path, PathBuf};
17use std::process::{Command, ExitCode};
18
19use bynk_driver::test_filter::TestFilter;
20use bynk_driver::test_runner::TestArgs;
21
22use crate::compiler::{Compiler, SkewAsker, skew_gate};
23
24/// Run `bynk test` by shelling the resolved `bynkc`. When no `bynkc` could be
25/// located, point the developer at `bynk doctor` rather than emitting a raw
26/// spawn error.
27///
28/// Wave 5 §5.4 (findings #40/#72): `args` is the one contract `bynkc test`
29/// and `bynk test` both flatten (`bynk_driver::test_runner::TestArgs`) —
30/// this function's whole body is re-spelling its fields back out as argv
31/// tokens for the shell-out, so a field added there needs no matching change
32/// here beyond this list.
33pub fn run(compiler: &Compiler, args: TestArgs, allow_skew: bool) -> ExitCode {
34    let Some(bynkc) = compiler.path.as_deref() else {
35        eprintln!(
36            "bynk test: no `bynkc` compiler found (looked at $BYNK_BYNKC, PATH, and next to `bynk`)."
37        );
38        eprintln!("  Run `bynk doctor --only test` for the exact remedy.");
39        return ExitCode::FAILURE;
40    };
41    // #1675: `test` always runs a second compiler, so act on its skew from the
42    // driver: a minor skew warns, a major one refuses unless allowed.
43    let asker = SkewAsker {
44        command: Some("test"),
45        has_flag: true,
46    };
47    if !skew_gate(compiler, asker, allow_skew) {
48        return ExitCode::FAILURE;
49    }
50
51    // #2207 (decision C): at a workspace root, test every member in order,
52    // each under a header. Every member runs, and the run fails if any does.
53    if let Some(workspace) = bynk_driver::workspace_at(&args.input) {
54        let workspace = match workspace {
55            Ok(w) => w,
56            Err(e) => {
57                eprintln!(
58                    "bynk test: {}: {e}",
59                    bynk_driver::display_path(Path::new(""), &args.input.join("bynk.toml"))
60                );
61                return ExitCode::FAILURE;
62            }
63        };
64        if let Some(refusal) = workspace_refusal(&args) {
65            eprintln!("bynk test: {refusal}");
66            return ExitCode::FAILURE;
67        }
68        let members: Vec<PathBuf> = workspace
69            .members
70            .iter()
71            .map(|member| bynk_driver::member_dir(&args.input, member))
72            .collect();
73        // #2273: a `--suite`/`--case` value may live in one member only, so
74        // each member runs as part of the selection, and the values are judged
75        // here against every member's suites.
76        let filter = TestFilter::new(&args.suite, &args.case);
77        if !filter.is_empty()
78            && let Some(available) = discover_all(bynkc, &members)
79        {
80            let unmatched = filter.unmatched_in(&available);
81            if !unmatched.is_empty() {
82                unmatched.report("bynk");
83                return ExitCode::FAILURE;
84            }
85        }
86        let mut failed = false;
87        for member in members {
88            println!("{}", bynk_driver::member_header(&member));
89            // Flushed before the child writes to the same stdout.
90            let _ = std::io::Write::flush(&mut std::io::stdout());
91            let member_args = TestArgs {
92                input: member,
93                partial_selection: !filter.is_empty(),
94                ..args.clone()
95            };
96            failed |= run_one(bynkc, member_args) != ExitCode::SUCCESS;
97        }
98        return if failed {
99            ExitCode::FAILURE
100        } else {
101            ExitCode::SUCCESS
102        };
103    }
104    run_one(bynkc, args)
105}
106
107/// Why `args` can't run across a workspace's members, if they can't: one JSON
108/// document, one output directory and one deployment each belong to a single
109/// project.
110fn workspace_refusal(args: &TestArgs) -> Option<&'static str> {
111    if args.format == bynk_driver::test_runner::TestFormat::Json {
112        Some("`--format json` reports one project — run it inside a member of this workspace")
113    } else if args.format == bynk_driver::test_runner::TestFormat::Ndjson {
114        Some("`--format ndjson` streams one project — run it inside a member of this workspace")
115    } else if args.output.is_some() {
116        Some("`--output` names one project's output — run it inside a member of this workspace")
117    } else if args.against.is_some() {
118        Some("`--against` runs one project's suites — run it inside a member of this workspace")
119    } else {
120        None
121    }
122}
123
124/// Every (suite, case) the workspace's members declare, by discovery
125/// (`bynkc test --no-run --format json`), to judge a `--suite`/`--case` value
126/// against. `None` when any member does not compile or answers something else:
127/// the runs report that, and a value is not judged against a partial picture.
128fn discover_all(bynkc: &Path, members: &[PathBuf]) -> Option<Vec<(String, String)>> {
129    let mut available = Vec::new();
130    for member in members {
131        let out = Command::new(bynkc)
132            .arg("test")
133            .arg(member)
134            .args(["--no-run", "--format", "json"])
135            .output()
136            .ok()?;
137        let doc: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
138        if doc.get("error").is_some() {
139            return None;
140        }
141        for suite in doc.get("suites")?.as_array()? {
142            let name = suite.get("name")?.as_str()?;
143            for case in suite.get("cases")?.as_array()? {
144                available.push((name.to_string(), case.get("name")?.as_str()?.to_string()));
145            }
146        }
147    }
148    Some(available)
149}
150
151/// Shell `bynkc test` for one project.
152fn run_one(bynkc: &Path, args: TestArgs) -> ExitCode {
153    let mut argv: Vec<OsString> = vec!["test".into(), args.input.into_os_string()];
154    if let Some(output) = args.output {
155        argv.push("--output".into());
156        argv.push(output.into_os_string());
157    }
158    if args.no_run {
159        argv.push("--no-run".into());
160    }
161    argv.push("--format".into());
162    argv.push(args.format.as_bynkc_arg().into());
163    if args.inspect {
164        argv.push("--inspect".into());
165    }
166    if let Some(seed) = args.seed {
167        argv.push("--seed".into());
168        argv.push(seed.into());
169    }
170    if args.partial_selection {
171        argv.push("--partial-selection".into());
172    }
173    for suite in args.suite {
174        argv.push("--suite".into());
175        argv.push(suite.into());
176    }
177    for case in args.case {
178        argv.push("--case".into());
179        argv.push(case.into());
180    }
181    if args.coverage {
182        argv.push("--coverage".into());
183    }
184    // #2138: a run against a named target, or against a local `bynk dev`,
185    // which `bynkc` starts through this binary.
186    let starts_dev = args.against.as_deref() == Some(bynk_driver::remote::DEV);
187    if let Some(target) = args.against {
188        argv.push("--against".into());
189        argv.push(target.into());
190        // `--settle` has a default, so it is always set; it means something,
191        // and `bynkc` accepts it, only with `--against`.
192        if let Some(settle) = args.settle {
193            argv.push("--settle".into());
194            argv.push(settle.into());
195        }
196    }
197    if args.skip_unsupported {
198        argv.push("--skip-unsupported".into());
199    }
200    if starts_dev {
201        let me = match std::env::current_exe() {
202            Ok(me) => me,
203            Err(e) => {
204                eprintln!("bynk test: could not find this `bynk` binary to start `bynk dev`: {e}");
205                return ExitCode::FAILURE;
206            }
207        };
208        return crate::shell::delegate_with_env(
209            bynkc,
210            argv,
211            &[(bynk_driver::remote::DEV_COMMAND_ENV, me.as_os_str())],
212        );
213    }
214
215    crate::shell::delegate(bynkc, argv)
216}