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 origin: c["origin"].as_str().map(str::to_string),
342 duration_ms: None,
344 });
345 match suites.iter_mut().find(|x| x.name == name) {
346 Some(existing) => {
347 for case in cases {
350 let repeat = case.outcome == "skipped"
351 && existing
352 .cases
353 .iter()
354 .any(|c| c.outcome == "skipped" && c.name == case.name);
355 if repeat {
356 run.skipped = run.skipped.saturating_sub(1);
357 } else {
358 existing.cases.push(case);
359 }
360 }
361 }
362 None => suites.push(Suite {
363 name,
364 kind: s["kind"].as_str().unwrap_or("integration").to_string(),
365 cases: cases.collect(),
366 }),
367 }
368 }
369 if run.error.is_none()
370 && let Some(e) = doc["error"].as_object()
371 {
372 run.error = Some(TestError {
373 kind: e["kind"].as_str().unwrap_or("runtime").to_string(),
374 message: e
375 .get("message")
376 .and_then(|m| m.as_str())
377 .map(str::to_string),
378 diagnostics: e
379 .get("diagnostics")
380 .and_then(|d| d.as_array())
381 .into_iter()
382 .flatten()
383 .filter_map(|d| d.as_str().map(str::to_string))
384 .collect(),
385 stderr: e.get("stderr").and_then(|m| m.as_str()).map(str::to_string),
386 });
387 }
388 }
389 run
390}
391
392pub fn remove_stale(ready: &Path) -> std::io::Result<()> {
395 match std::fs::remove_file(ready) {
396 Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
397 _ => Ok(()),
398 }
399}
400
401fn free_ports() -> std::io::Result<(u16, u16)> {
404 let a = std::net::TcpListener::bind("127.0.0.1:0")?;
405 let b = std::net::TcpListener::bind("127.0.0.1:0")?;
406 Ok((a.local_addr()?.port(), b.local_addr()?.port()))
407}
408
409struct Dev {
411 child: Child,
412 stopped: bool,
413}
414
415impl Dev {
416 fn start(
417 bynk: &Path,
418 input: &Path,
419 ready: &Path,
420 log: &Path,
421 vars: &BTreeMap<String, String>,
422 ) -> std::io::Result<Dev> {
423 if let Some(dir) = ready.parent() {
424 std::fs::create_dir_all(dir)?;
425 }
426 crate::dev_run::remove_stale(ready)?;
429 let (port, inspector) = free_ports()?;
430 let log_file = std::fs::File::create(log)?;
431 let mut cmd = Command::new(bynk);
432 cmd.arg("dev")
433 .arg(input)
434 .arg("--base-port")
435 .arg(port.to_string())
436 .arg("--inspect-port")
437 .arg(inspector.to_string())
438 .arg("--ready-json")
439 .arg(ready)
440 .arg("--");
441 for (k, v) in vars {
443 cmd.arg("--var").arg(format!("{k}:{v}"));
444 }
445 #[cfg(unix)]
446 std::os::unix::process::CommandExt::process_group(&mut cmd, 0);
447 let child = cmd
448 .stdin(Stdio::null())
449 .stdout(log_file.try_clone()?)
450 .stderr(log_file)
451 .spawn()?;
452 Ok(Dev {
453 child,
454 stopped: false,
455 })
456 }
457
458 fn wait_ready(&mut self, ready: &Path) -> Result<BTreeMap<String, String>, String> {
461 let deadline = Instant::now() + READY_TIMEOUT;
462 loop {
463 if let Ok(text) = std::fs::read_to_string(ready)
464 && let Ok(urls) = serde_json::from_str::<BTreeMap<String, String>>(&text)
465 {
466 if let Some(bad) = urls.values().find(|u| !is_loopback_http(u)) {
467 return Err(format!("`bynk dev` reported `{bad}`, which isn't loopback"));
468 }
469 return Ok(urls);
470 }
471 if let Ok(Some(status)) = self.child.try_wait() {
472 return Err(format!("`bynk dev` exited before it was ready ({status})"));
473 }
474 if Instant::now() >= deadline {
475 return Err(format!(
476 "`bynk dev` wasn't ready within {}s",
477 READY_TIMEOUT.as_secs()
478 ));
479 }
480 std::thread::sleep(Duration::from_millis(250));
481 }
482 }
483
484 fn stop(&mut self) {
490 if self.stopped {
491 return;
492 }
493 self.stopped = true;
494 #[cfg(unix)]
495 {
496 let pid = self.child.id().to_string();
497 if !matches!(self.child.try_wait(), Ok(Some(_))) {
498 let _ = Command::new("kill").args(["-s", "TERM", &pid]).status();
499 let deadline = Instant::now() + STOP_GRACE;
500 while Instant::now() < deadline {
501 if matches!(self.child.try_wait(), Ok(Some(_))) {
502 break;
503 }
504 std::thread::sleep(Duration::from_millis(100));
505 }
506 }
507 let _ = Command::new("kill")
510 .args(["-s", "KILL", "--", &format!("-{pid}")])
511 .stderr(Stdio::null())
512 .status();
513 }
514 let _ = self.child.kill();
515 let _ = self.child.wait();
516 }
517}
518
519impl Drop for Dev {
520 fn drop(&mut self) {
521 self.stop();
522 }
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528
529 #[test]
532 fn merging_the_groups_keeps_each_cases_tier() {
533 let doc = serde_json::json!({
534 "passed": 1, "failed": 0,
535 "suites": [{"name": "shop", "kind": "integration", "cases": [
536 {"name": "wired", "outcome": "pass", "tier": "system"},
537 {"name": "plain", "outcome": "pass"}
538 ]}]
539 });
540 let merged = merge(&[doc]);
541 let cases = &merged.suites.as_ref().unwrap()[0].cases;
542 assert_eq!(cases[0].tier.as_deref(), Some("system"));
543 assert_eq!(cases[1].tier, None);
544 }
545
546 #[test]
547 fn a_group_s_env_wins_and_other_secrets_get_the_test_value() {
548 let group = BTreeMap::from([
549 ("PAYMENTS".to_string(), "fake".to_string()),
550 ("AUTH_SECRET".to_string(), "s3".to_string()),
551 ]);
552 let secrets = BTreeSet::from(["AUTH_SECRET".to_string(), "HOOK_SECRET".to_string()]);
553 let vars = dev_vars(&group, &secrets);
554 assert_eq!(vars["PAYMENTS"], "fake");
555 assert_eq!(vars["AUTH_SECRET"], "s3");
556 assert_eq!(vars["HOOK_SECRET"], TEST_SECRET);
557 }
558
559 #[test]
560 fn a_stale_ready_file_is_removed_before_a_session_starts() {
561 let dir = std::env::temp_dir().join(format!("bynk-dev-stale-{}", std::process::id()));
562 std::fs::create_dir_all(&dir).unwrap();
563 let ready = dir.join(".bynk-dev-ready-0.json");
564 std::fs::write(&ready, r#"{"svc":"http://localhost:1"}"#).unwrap();
566 let started = Dev::start(
569 &dir.join("no-such-bynk"),
570 &dir,
571 &ready,
572 &dir.join("dev.log"),
573 &BTreeMap::new(),
574 );
575 assert!(started.is_err());
576 assert!(!ready.exists(), "the stale file is gone");
577 remove_stale(&ready).expect("removing an absent file is fine");
578 let _ = std::fs::remove_dir_all(&dir);
579 }
580
581 #[test]
582 fn group_documents_merge_into_one() {
583 let a = serde_json::json!({"passed": 1, "failed": 0, "suites": [{"name": "s", "kind": "integration", "cases": [{"name": "a", "outcome": "pass"}]}], "transport": "remote:dev"});
584 let b = serde_json::json!({"passed": 0, "failed": 1, "suites": [{"name": "s", "kind": "integration", "cases": [{"name": "b", "outcome": "fail", "message": "m"}]}], "transport": "remote:dev"});
585 let run = merge(&[a, b]);
586 assert_eq!((run.passed, run.failed), (1, 1));
587 let suites = run.suites.expect("suites");
588 assert_eq!(suites.len(), 1);
589 assert_eq!(suites[0].cases.len(), 2);
590 assert_eq!(run.transport.as_deref(), Some("remote:dev"));
591 }
592}