1use std::collections::{BTreeMap, BTreeSet};
19use std::path::{Path, PathBuf};
20use std::process::{Child, Command, ExitCode, Stdio};
21use std::time::{Duration, Instant};
22
23use bynk_emit::project::{BuildTarget, RemoteTests};
24
25use crate::remote::{DEV, DEV_ENV_ENV, DEV_SECRETS_ENV, DEV_URLS_ENV, is_loopback_http};
26use crate::test_json::TestRun;
27
28pub const TEST_SECRET: &str = "__bynk_test_secret";
31
32const READY_TIMEOUT: Duration = Duration::from_secs(180);
34
35const STOP_GRACE: Duration = Duration::from_secs(30);
38
39#[derive(Debug, Clone)]
41pub struct DevRunArgs {
42 pub input: PathBuf,
43 pub output: PathBuf,
44 pub json: bool,
45 pub stream: bool,
48 pub seed: Option<String>,
49 pub filter: crate::test_filter::TestFilter,
51 pub skip_unsupported: bool,
52 pub settle: Option<String>,
54 pub run_id: String,
56 pub bynk: PathBuf,
58}
59
60pub fn run(program: &str, args: &DevRunArgs) -> ExitCode {
63 let fail = |message: String| {
64 if args.json {
65 crate::test_stream::print_run(args.stream, &TestRun::runtime_error(message, None));
66 } else {
67 eprintln!("{program} test: {message}");
68 }
69 ExitCode::FAILURE
70 };
71 let plan = match plan(args) {
72 Ok(plan) => plan,
73 Err(Planned::Failed(code)) => return code,
74 Err(Planned::Message(m)) => return fail(m),
75 };
76 let unmatched = args.filter.unmatched_in(&plan.cases);
80 if !unmatched.is_empty() {
81 return fail(unmatched.messages().join("; "));
82 }
83 if plan.groups.is_empty() {
84 if args.json {
85 crate::test_stream::print_run(
86 args.stream,
87 &TestRun::empty().with_remote(format!("remote:{DEV}"), &[]),
88 );
89 } else {
90 eprintln!("{program} test: no `system` suites to run against `bynk dev`");
91 }
92 return ExitCode::SUCCESS;
93 }
94 let mut docs: Vec<serde_json::Value> = Vec::new();
95 let mut all_ok = true;
96 for (i, group) in plan.groups.iter().enumerate() {
97 let vars = dev_vars(group, &plan.secrets);
98 if !args.json {
99 eprintln!(
100 "{program} test: starting `bynk dev`{}",
101 if group.is_empty() {
102 String::new()
103 } else {
104 format!(
105 " with env {}",
106 group
107 .iter()
108 .map(|(k, v)| format!("{k}={v:?}"))
109 .collect::<Vec<_>>()
110 .join(", ")
111 )
112 }
113 );
114 }
115 let ready = args.output.join(format!(".bynk-dev-ready-{i}.json"));
116 let log = args.output.join(format!(".bynk-dev-{i}.log"));
117 let mut dev = match Dev::start(&args.bynk, &args.input, &ready, &log, &vars) {
118 Ok(dev) => dev,
119 Err(e) => return fail(format!("could not start `bynk dev`: {e}")),
120 };
121 let urls = match dev.wait_ready(&ready) {
122 Ok(urls) => urls,
123 Err(e) => {
124 drop(dev);
125 return fail(format!("{e} (its output is in {})", log.display()));
126 }
127 };
128 let outcome = run_group(args, group, &urls, &vars);
129 dev.stop();
130 match outcome {
131 Ok((ok, doc)) => {
132 all_ok &= ok;
133 docs.extend(doc);
134 }
135 Err(e) => return fail(e),
136 }
137 }
138 if args.json {
139 crate::test_stream::print_run(args.stream, &merge(&docs));
140 }
141 if all_ok {
142 ExitCode::SUCCESS
143 } else {
144 ExitCode::FAILURE
145 }
146}
147
148struct Plan {
150 groups: Vec<BTreeMap<String, String>>,
151 secrets: BTreeSet<String>,
152 cases: Vec<(String, String)>,
154}
155
156enum Planned {
157 Failed(ExitCode),
159 Message(String),
160}
161
162fn plan(args: &DevRunArgs) -> Result<Plan, Planned> {
166 let options = crate::try_project_options(&args.input)
167 .map_err(|e| Planned::Message(e.to_string()))?
168 .contracts(true)
169 .remote_tests(Some(RemoteTests {
170 skip_unsupported: args.skip_unsupported,
171 dev: true,
172 ..RemoteTests::default()
173 }))
174 .target(BuildTarget::Workers);
175 let out = match bynk_emit::project::compile_project(&options) {
176 Ok(out) => out,
177 Err(failure) => {
178 if args.json {
179 crate::test_stream::print_run(
180 args.stream,
181 &TestRun::compile_error(crate::project_failure_short_lines(&failure)),
182 );
183 } else {
184 crate::print_project_failure(&failure);
185 }
186 return Err(Planned::Failed(ExitCode::FAILURE));
187 }
188 };
189 let mut groups: Vec<BTreeMap<String, String>> = Vec::new();
190 for env in out.discovered.iter().flat_map(|s| s.envs.iter()) {
191 if !groups.contains(env) {
192 groups.push(env.clone());
193 }
194 }
195 let cases = out
199 .discovered
200 .iter()
201 .flat_map(|s| {
202 s.cases
203 .iter()
204 .filter(|_| !s.envs.is_empty())
205 .map(|c| (s.name.clone(), c.name.clone()))
206 .chain(s.skipped.iter().map(|k| (s.name.clone(), k.case.clone())))
207 })
208 .collect();
209 let mut secrets = BTreeSet::new();
210 for (path, doc) in &out.artefacts.docs {
211 if path.file_name().is_some_and(|n| n == "bynk-secrets.json") {
212 let manifest: serde_json::Value = serde_json::from_str(&doc.text()).unwrap_or_default();
213 for key in ["declared", "read"] {
214 for name in manifest[key].as_array().into_iter().flatten() {
215 if let Some(name) = name.as_str() {
216 secrets.insert(name.to_string());
217 }
218 }
219 }
220 }
221 }
222 Ok(Plan {
223 groups,
224 secrets,
225 cases,
226 })
227}
228
229pub fn dev_vars(
232 group: &BTreeMap<String, String>,
233 secrets: &BTreeSet<String>,
234) -> BTreeMap<String, String> {
235 let mut vars = group.clone();
236 for s in secrets {
237 vars.entry(s.clone())
238 .or_insert_with(|| TEST_SECRET.to_string());
239 }
240 vars
241}
242
243fn run_group(
248 args: &DevRunArgs,
249 group: &BTreeMap<String, String>,
250 urls: &BTreeMap<String, String>,
251 vars: &BTreeMap<String, String>,
252) -> Result<(bool, Option<serde_json::Value>), String> {
253 let me = std::env::current_exe().map_err(|e| format!("could not find this binary: {e}"))?;
254 let mut cmd = Command::new(me);
255 cmd.arg("test")
256 .arg(&args.input)
257 .arg("--output")
258 .arg(&args.output);
259 cmd.arg("--format")
260 .arg(if args.json { "json" } else { "rich" });
261 cmd.args(["--against", DEV]);
262 if let Some(seed) = &args.seed {
263 cmd.args(["--seed", seed]);
264 }
265 cmd.args(args.filter.args());
266 if !args.filter.is_empty() {
267 cmd.arg("--partial-selection");
268 }
269 if args.skip_unsupported {
270 cmd.arg("--skip-unsupported");
271 }
272 if let Some(settle) = &args.settle {
273 cmd.args(["--settle", settle]);
274 }
275 cmd.env_remove(crate::remote::DEV_COMMAND_ENV);
276 cmd.env(crate::remote::DEV_RUN_ID_ENV, &args.run_id);
279 cmd.env(
280 DEV_URLS_ENV,
281 serde_json::to_string(urls).expect("a string map serialises"),
282 );
283 cmd.env(
284 DEV_ENV_ENV,
285 serde_json::to_string(group).expect("a string map serialises"),
286 );
287 cmd.env(
290 DEV_SECRETS_ENV,
291 serde_json::to_string(vars).expect("a string map serialises"),
292 );
293 if args.json {
294 let out = cmd
295 .stderr(Stdio::inherit())
296 .output()
297 .map_err(|e| format!("could not run the suites: {e}"))?;
298 let doc = serde_json::from_slice(&out.stdout)
299 .map_err(|e| format!("the run printed no JSON document: {e}"))?;
300 Ok((out.status.success(), Some(doc)))
301 } else {
302 let status = cmd
303 .status()
304 .map_err(|e| format!("could not run the suites: {e}"))?;
305 Ok((status.success(), None))
306 }
307}
308
309fn merge(docs: &[serde_json::Value]) -> TestRun {
312 use crate::test_json::{Case, Location, Suite, TestError};
313 let count = |d: &serde_json::Value, k: &str| d[k].as_u64().unwrap_or(0) as u32;
314 let mut run = TestRun::empty();
315 run.transport = Some(format!("remote:{DEV}"));
316 let suites = run.suites.get_or_insert_with(Vec::new);
317 for doc in docs {
318 run.passed += count(doc, "passed");
319 run.failed += count(doc, "failed");
320 run.skipped += count(doc, "skipped");
321 for s in doc["suites"].as_array().into_iter().flatten() {
322 let name = s["name"].as_str().unwrap_or_default().to_string();
323 let cases = s["cases"].as_array().into_iter().flatten().map(|c| Case {
324 name: c["name"].as_str().unwrap_or_default().to_string(),
325 outcome: c["outcome"].as_str().unwrap_or_default().to_string(),
326 message: c["message"].as_str().map(str::to_string),
327 location: c["location"].as_object().map(|l| Location {
328 path: l["path"].as_str().unwrap_or_default().to_string(),
329 line: l["line"].as_u64().unwrap_or(0) as u32,
330 col: l["col"].as_u64().unwrap_or(0) as u32,
331 }),
332 retries: c["retries"].as_u64().map(|n| n as u32),
333 tier: c["tier"].as_str().map(str::to_string),
334 kind: c["kind"].as_str().map(str::to_string),
335 seed: c["seed"].as_str().map(str::to_string),
336 counterexample: c["counterexample"].as_str().map(str::to_string),
337 violation: c
338 .get("violation")
339 .map(crate::test_json::violation_from_json),
340 refusals: crate::test_json::refusals_from_json(c.get("refusals")),
341 duration_ms: None,
343 });
344 match suites.iter_mut().find(|x| x.name == name) {
345 Some(existing) => {
346 for case in cases {
349 let repeat = case.outcome == "skipped"
350 && existing
351 .cases
352 .iter()
353 .any(|c| c.outcome == "skipped" && c.name == case.name);
354 if repeat {
355 run.skipped = run.skipped.saturating_sub(1);
356 } else {
357 existing.cases.push(case);
358 }
359 }
360 }
361 None => suites.push(Suite {
362 name,
363 kind: s["kind"].as_str().unwrap_or("integration").to_string(),
364 cases: cases.collect(),
365 }),
366 }
367 }
368 if run.error.is_none()
369 && let Some(e) = doc["error"].as_object()
370 {
371 run.error = Some(TestError {
372 kind: e["kind"].as_str().unwrap_or("runtime").to_string(),
373 message: e
374 .get("message")
375 .and_then(|m| m.as_str())
376 .map(str::to_string),
377 diagnostics: e
378 .get("diagnostics")
379 .and_then(|d| d.as_array())
380 .into_iter()
381 .flatten()
382 .filter_map(|d| d.as_str().map(str::to_string))
383 .collect(),
384 stderr: e.get("stderr").and_then(|m| m.as_str()).map(str::to_string),
385 });
386 }
387 }
388 run
389}
390
391pub fn remove_stale(ready: &Path) -> std::io::Result<()> {
394 match std::fs::remove_file(ready) {
395 Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
396 _ => Ok(()),
397 }
398}
399
400fn free_ports() -> std::io::Result<(u16, u16)> {
403 let a = std::net::TcpListener::bind("127.0.0.1:0")?;
404 let b = std::net::TcpListener::bind("127.0.0.1:0")?;
405 Ok((a.local_addr()?.port(), b.local_addr()?.port()))
406}
407
408struct Dev {
410 child: Child,
411 stopped: bool,
412}
413
414impl Dev {
415 fn start(
416 bynk: &Path,
417 input: &Path,
418 ready: &Path,
419 log: &Path,
420 vars: &BTreeMap<String, String>,
421 ) -> std::io::Result<Dev> {
422 if let Some(dir) = ready.parent() {
423 std::fs::create_dir_all(dir)?;
424 }
425 crate::dev_run::remove_stale(ready)?;
428 let (port, inspector) = free_ports()?;
429 let log_file = std::fs::File::create(log)?;
430 let mut cmd = Command::new(bynk);
431 cmd.arg("dev")
432 .arg(input)
433 .arg("--base-port")
434 .arg(port.to_string())
435 .arg("--inspect-port")
436 .arg(inspector.to_string())
437 .arg("--ready-json")
438 .arg(ready)
439 .arg("--");
440 for (k, v) in vars {
442 cmd.arg("--var").arg(format!("{k}:{v}"));
443 }
444 #[cfg(unix)]
445 std::os::unix::process::CommandExt::process_group(&mut cmd, 0);
446 let child = cmd
447 .stdin(Stdio::null())
448 .stdout(log_file.try_clone()?)
449 .stderr(log_file)
450 .spawn()?;
451 Ok(Dev {
452 child,
453 stopped: false,
454 })
455 }
456
457 fn wait_ready(&mut self, ready: &Path) -> Result<BTreeMap<String, String>, String> {
460 let deadline = Instant::now() + READY_TIMEOUT;
461 loop {
462 if let Ok(text) = std::fs::read_to_string(ready)
463 && let Ok(urls) = serde_json::from_str::<BTreeMap<String, String>>(&text)
464 {
465 if let Some(bad) = urls.values().find(|u| !is_loopback_http(u)) {
466 return Err(format!("`bynk dev` reported `{bad}`, which isn't loopback"));
467 }
468 return Ok(urls);
469 }
470 if let Ok(Some(status)) = self.child.try_wait() {
471 return Err(format!("`bynk dev` exited before it was ready ({status})"));
472 }
473 if Instant::now() >= deadline {
474 return Err(format!(
475 "`bynk dev` wasn't ready within {}s",
476 READY_TIMEOUT.as_secs()
477 ));
478 }
479 std::thread::sleep(Duration::from_millis(250));
480 }
481 }
482
483 fn stop(&mut self) {
489 if self.stopped {
490 return;
491 }
492 self.stopped = true;
493 #[cfg(unix)]
494 {
495 let pid = self.child.id().to_string();
496 if !matches!(self.child.try_wait(), Ok(Some(_))) {
497 let _ = Command::new("kill").args(["-s", "TERM", &pid]).status();
498 let deadline = Instant::now() + STOP_GRACE;
499 while Instant::now() < deadline {
500 if matches!(self.child.try_wait(), Ok(Some(_))) {
501 break;
502 }
503 std::thread::sleep(Duration::from_millis(100));
504 }
505 }
506 let _ = Command::new("kill")
509 .args(["-s", "KILL", "--", &format!("-{pid}")])
510 .stderr(Stdio::null())
511 .status();
512 }
513 let _ = self.child.kill();
514 let _ = self.child.wait();
515 }
516}
517
518impl Drop for Dev {
519 fn drop(&mut self) {
520 self.stop();
521 }
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527
528 #[test]
531 fn merging_the_groups_keeps_each_cases_tier() {
532 let doc = serde_json::json!({
533 "passed": 1, "failed": 0,
534 "suites": [{"name": "shop", "kind": "integration", "cases": [
535 {"name": "wired", "outcome": "pass", "tier": "system"},
536 {"name": "plain", "outcome": "pass"}
537 ]}]
538 });
539 let merged = merge(&[doc]);
540 let cases = &merged.suites.as_ref().unwrap()[0].cases;
541 assert_eq!(cases[0].tier.as_deref(), Some("system"));
542 assert_eq!(cases[1].tier, None);
543 }
544
545 #[test]
546 fn a_group_s_env_wins_and_other_secrets_get_the_test_value() {
547 let group = BTreeMap::from([
548 ("PAYMENTS".to_string(), "fake".to_string()),
549 ("AUTH_SECRET".to_string(), "s3".to_string()),
550 ]);
551 let secrets = BTreeSet::from(["AUTH_SECRET".to_string(), "HOOK_SECRET".to_string()]);
552 let vars = dev_vars(&group, &secrets);
553 assert_eq!(vars["PAYMENTS"], "fake");
554 assert_eq!(vars["AUTH_SECRET"], "s3");
555 assert_eq!(vars["HOOK_SECRET"], TEST_SECRET);
556 }
557
558 #[test]
559 fn a_stale_ready_file_is_removed_before_a_session_starts() {
560 let dir = std::env::temp_dir().join(format!("bynk-dev-stale-{}", std::process::id()));
561 std::fs::create_dir_all(&dir).unwrap();
562 let ready = dir.join(".bynk-dev-ready-0.json");
563 std::fs::write(&ready, r#"{"svc":"http://localhost:1"}"#).unwrap();
565 let started = Dev::start(
568 &dir.join("no-such-bynk"),
569 &dir,
570 &ready,
571 &dir.join("dev.log"),
572 &BTreeMap::new(),
573 );
574 assert!(started.is_err());
575 assert!(!ready.exists(), "the stale file is gone");
576 remove_stale(&ready).expect("removing an absent file is fine");
577 let _ = std::fs::remove_dir_all(&dir);
578 }
579
580 #[test]
581 fn group_documents_merge_into_one() {
582 let a = serde_json::json!({"passed": 1, "failed": 0, "suites": [{"name": "s", "kind": "integration", "cases": [{"name": "a", "outcome": "pass"}]}], "transport": "remote:dev"});
583 let b = serde_json::json!({"passed": 0, "failed": 1, "suites": [{"name": "s", "kind": "integration", "cases": [{"name": "b", "outcome": "fail", "message": "m"}]}], "transport": "remote:dev"});
584 let run = merge(&[a, b]);
585 assert_eq!((run.passed, run.failed), (1, 1));
586 let suites = run.suites.expect("suites");
587 assert_eq!(suites.len(), 1);
588 assert_eq!(suites[0].cases.len(), 2);
589 assert_eq!(run.transport.as_deref(), Some("remote:dev"));
590 }
591}