1pub mod xtask;
46
47use std::collections::HashSet;
48use std::env;
49use std::io::{BufRead, BufReader};
50use std::os::unix::process::CommandExt;
51use std::path::{Path, PathBuf};
52use std::process::{Child, Command, Stdio};
53use std::sync::{Arc, Mutex};
54use std::time::{Duration, Instant};
55
56#[derive(Clone, Debug)]
74pub enum LogOutput {
75 Off,
77 Stream,
79 WriteToDir(PathBuf),
82 Both(PathBuf),
84}
85
86pub struct TestProcesses {
91 children: Vec<Child>,
92 stdout_lines: Arc<Mutex<Vec<String>>>,
94}
95
96impl TestProcesses {
97 pub fn search_stdout_for_strings(&mut self, expected: HashSet<&str>) {
100 self.search_stdout_with_timeout(expected, Duration::from_secs(30));
101 }
102
103 pub fn search_stdout_with_timeout(&mut self, expected: HashSet<&str>, timeout: Duration) {
106 let start = Instant::now();
107
108 loop {
109 let missing: HashSet<&str> = {
110 let lines = self.stdout_lines.lock().unwrap();
111 expected
112 .iter()
113 .copied()
114 .filter(|needle| !lines.iter().any(|line| line.contains(needle)))
115 .collect()
116 };
117
118 if missing.is_empty() {
119 return;
120 }
121
122 if start.elapsed() >= timeout {
123 let lines = self.stdout_lines.lock().unwrap();
124 panic!(
125 "search_stdout_for_strings timed out after {:?}.\n\
126 Missing strings:\n{}\n\
127 Captured stdout ({} lines):\n{}",
128 timeout,
129 missing
130 .iter()
131 .map(|s| format!(" - {:?}", s))
132 .collect::<Vec<_>>()
133 .join("\n"),
134 lines.len(),
135 lines
136 .iter()
137 .map(|l| format!(" {l}"))
138 .collect::<Vec<_>>()
139 .join("\n"),
140 );
141 }
142
143 std::thread::sleep(Duration::from_millis(50));
144 }
145 }
146
147 pub fn debug_dump_stdout(&mut self, timeout: Duration) {
151 let start = Instant::now();
152
153 loop {
154 if start.elapsed() >= timeout {
155 return;
156 }
157
158 let lines = self.stdout_lines.lock().unwrap();
159 println!(
160 "Captured stdout:\n{}",
161 lines
162 .iter()
163 .map(|l| format!(" {l}"))
164 .collect::<Vec<_>>()
165 .join("\n"),
166 );
167
168 std::thread::sleep(Duration::from_millis(50));
169 }
170 }
171
172 pub fn kill_all(&mut self) {
174 for child in &mut self.children {
175 let _ = child.kill();
176 }
177 for child in &mut self.children {
178 let _ = child.wait();
179 }
180 }
181}
182
183impl Drop for TestProcesses {
184 fn drop(&mut self) {
185 self.kill_all();
186 }
187}
188
189fn pipe_labeled<R>(stream: R, label: &'static str)
200where
201 R: std::io::Read + Send + 'static,
202{
203 std::thread::spawn(move || {
204 use std::io::Write;
205 let mut out = std::fs::OpenOptions::new()
206 .write(true)
207 .open("/dev/stderr")
208 .expect("open /dev/stderr");
209 let reader = BufReader::new(stream);
210 for line in reader.lines() {
211 if let Ok(line) = line {
212 let _ = writeln!(out, "[{label}] {line}");
213 }
214 }
215 });
216}
217
218fn pipe_to_file<R>(stream: R, path: PathBuf)
222where
223 R: std::io::Read + Send + 'static,
224{
225 std::thread::spawn(move || {
226 use std::io::Write;
227 let mut file = std::fs::OpenOptions::new()
228 .append(true)
229 .open(&path)
230 .unwrap_or_else(|e| panic!("pipe_to_file: could not open {}: {e}", path.display()));
231 let reader = BufReader::new(stream);
232 for line in reader.lines() {
233 if let Ok(line) = line {
234 let _ = writeln!(file, "{line}");
235 }
236 }
237 });
238}
239
240pub(crate) fn kill_stale_sim_processes(sim_id: &str) {
255 let patterns = [
256 format!("bs_2G4_phy_v1.*-s={sim_id}"),
257 format!("zephyr_rpc_server_app.*-s={sim_id}"),
258 format!("cgm_peripheral_sample.*-s={sim_id}"),
259 format!("socat.*{sim_id}.sock"),
260 ];
261 for pat in &patterns {
262 let _ = Command::new("pkill").args(["-9", "-f", pat]).status();
263 }
264 std::thread::sleep(Duration::from_millis(300));
266
267 if let Ok(entries) = std::fs::read_dir("/tmp") {
271 for entry in entries.flatten() {
272 let name = entry.file_name();
273 if name.to_string_lossy().starts_with("bs_") {
274 let sim_dir = entry.path().join(sim_id);
275 if sim_dir.is_dir() {
276 let _ = std::fs::remove_dir_all(&sim_dir);
277 }
278 }
279 }
280 }
281
282 if let Ok(entries) = std::fs::read_dir("/dev/shm") {
284 for entry in entries.flatten() {
285 let name = entry.file_name();
286 if name.to_string_lossy().contains(sim_id) {
287 let _ = std::fs::remove_file(entry.path());
288 }
289 }
290 }
291}
292
293pub fn spawn_zephyr_rpc_server_with_socat(
300 tests_dir: &Path,
301 test_name: &str,
302 log: LogOutput,
303) -> (TestProcesses, PathBuf) {
304 let verbose = matches!(log, LogOutput::Stream | LogOutput::Both(_));
305 let log_dir: Option<PathBuf> = match &log {
306 LogOutput::WriteToDir(p) | LogOutput::Both(p) => Some(p.clone()),
307 _ => None,
308 };
309
310 if let Some(ref dir) = log_dir {
313 std::fs::create_dir_all(dir)
314 .unwrap_or_else(|e| panic!("could not create log dir {}: {e}", dir.display()));
315 for name in &["phy.log", "rpc-server.log", "cgm.log"] {
316 std::fs::File::create(dir.join(name))
317 .unwrap_or_else(|e| panic!("could not create log file {name}: {e}"));
318 }
319 }
320
321 let bsim_bin = Path::new("external/tools/bsim/bin");
322 let bsim_out = "external/tools/bsim";
323 let bsim_comp = "external/tools/bsim/components";
324 let ld_path = match env::var("LD_LIBRARY_PATH") {
325 Ok(existing) => format!("external/tools/bsim/lib:{existing}"),
326 Err(_) => "external/tools/bsim/lib".to_string(),
327 };
328
329 let sim_id = test_name;
330
331 std::fs::create_dir_all(tests_dir)
332 .unwrap_or_else(|e| panic!("could not create tests dir {}: {e}", tests_dir.display()));
333 let socket_path = tests_dir.join(format!("{test_name}.sock"));
334
335 kill_stale_sim_processes(sim_id);
340 let _ = std::fs::remove_file(&socket_path);
341
342 let needs_phy_pipe = verbose || log_dir.is_some();
344 let mut phy = Command::new("./bs_2G4_phy_v1")
345 .args([
346 &format!("-s={sim_id}"),
347 "-D=2", "-sim_length=86400e6",
349 ])
350 .current_dir(bsim_bin)
351 .stdin(Stdio::null())
352 .stdout(Stdio::null())
353 .stderr(if needs_phy_pipe { Stdio::piped() } else { Stdio::null() })
354 .env("BSIM_OUT_PATH", bsim_out)
355 .env("BSIM_COMPONENTS_PATH", bsim_comp)
356 .env("LD_LIBRARY_PATH", &ld_path)
357 .process_group(0)
358 .spawn()
359 .unwrap_or_else(|e| panic!("failed to spawn bs_2G4_phy_v1: {e}"));
360 if let Some(stderr) = phy.stderr.take() {
361 if verbose { pipe_labeled(stderr, "babblesim-phy"); }
362 else if let Some(ref dir) = log_dir { pipe_to_file(stderr, dir.join("phy.log")); }
363 }
364
365 let stdout_lines: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
372 let (pty_tx, pty_rx) = std::sync::mpsc::channel::<PathBuf>();
373
374 let zephyr_color_arg: &[&str] = if verbose { &["-force-color"] } else { &[] };
378
379 let needs_zephyr_stderr = verbose || log_dir.is_some();
380 let mut zephyr_proc = Command::new("./zephyr_rpc_server_app")
381 .args([
382 &format!("-s={sim_id}"),
383 "-d=0",
384 "-uart0_pty",
385 "-uart_pty_pollT=1000",
386 ])
387 .args(zephyr_color_arg)
388 .current_dir(bsim_bin)
389 .stdin(Stdio::null())
390 .stdout(Stdio::piped())
391 .stderr(if needs_zephyr_stderr { Stdio::piped() } else { Stdio::null() })
392 .env("BSIM_OUT_PATH", bsim_out)
393 .env("BSIM_COMPONENTS_PATH", bsim_comp)
394 .env("LD_LIBRARY_PATH", &ld_path)
395 .process_group(0)
396 .spawn()
397 .unwrap_or_else(|e| panic!("failed to spawn zephyr_rpc_server_app: {e}"));
398
399 if let Some(stderr) = zephyr_proc.stderr.take() {
401 if verbose { pipe_labeled(stderr, "rpc-server"); }
402 else if let Some(ref dir) = log_dir { pipe_to_file(stderr, dir.join("rpc-server.log")); }
403 }
404
405 let zephyr_stdout = zephyr_proc.stdout.take().expect("stdout was piped");
411 let stdout_lines_clone = Arc::clone(&stdout_lines);
412 let rpc_log_path = log_dir.as_ref().map(|d| d.join("rpc-server.log"));
413 std::thread::spawn(move || {
414 use std::io::Write;
415 let mut real_stderr = verbose.then(|| {
417 std::fs::OpenOptions::new()
418 .write(true)
419 .open("/dev/stderr")
420 .expect("open /dev/stderr")
421 });
422 let mut log_file = rpc_log_path.as_ref().map(|p| {
423 std::fs::OpenOptions::new()
424 .append(true)
425 .open(p)
426 .unwrap_or_else(|e| panic!("could not open rpc-server.log: {e}"))
427 });
428 let reader = BufReader::new(zephyr_stdout);
429 let mut pty_sent = false;
430 for line in reader.lines() {
431 let line = match line {
432 Ok(l) => l,
433 Err(_) => break,
434 };
435 if !pty_sent {
438 if let Some(idx) = line.find("connected to pseudotty: ") {
439 let pty_path_str = line[idx + "connected to pseudotty: ".len()..].trim();
440 let pty_path = PathBuf::from(pty_path_str);
441 let _ = pty_tx.send(pty_path);
442 pty_sent = true;
443 }
444 }
445 if let Some(ref mut out) = real_stderr {
446 let _ = writeln!(out, "[rpc-server] {line}");
447 }
448 if let Some(ref mut f) = log_file {
449 let _ = writeln!(f, "{line}");
450 }
451 stdout_lines_clone.lock().unwrap().push(line);
452 }
453 });
454
455 let mut cgm = if verbose || log_dir.is_some() {
460 Command::new("./cgm_peripheral_sample")
461 .args([&format!("-s={sim_id}"), "-d=1"])
462 .current_dir(bsim_bin)
463 .stdin(Stdio::null())
464 .stdout(Stdio::piped())
465 .stderr(Stdio::piped())
466 .env("BSIM_OUT_PATH", bsim_out)
467 .env("BSIM_COMPONENTS_PATH", bsim_comp)
468 .env("LD_LIBRARY_PATH", &ld_path)
469 .process_group(0)
470 .spawn()
471 .unwrap_or_else(|e| panic!("failed to spawn cgm_peripheral_sample: {e}"))
472 } else {
473 let cgm_log_path = bsim_bin.join("cgm_peripheral_sample.log");
474 let cgm_log_file = std::fs::File::create(&cgm_log_path)
475 .unwrap_or_else(|e| panic!("could not create cgm log file: {e}"));
476 let cgm_log_clone = cgm_log_file
477 .try_clone()
478 .expect("could not clone cgm log file handle");
479 Command::new("./cgm_peripheral_sample")
480 .args([&format!("-s={sim_id}"), "-d=1"])
481 .current_dir(bsim_bin)
482 .stdin(Stdio::null())
483 .stdout(cgm_log_file)
484 .stderr(cgm_log_clone)
485 .env("BSIM_OUT_PATH", bsim_out)
486 .env("BSIM_COMPONENTS_PATH", bsim_comp)
487 .env("LD_LIBRARY_PATH", &ld_path)
488 .process_group(0)
489 .spawn()
490 .unwrap_or_else(|e| panic!("failed to spawn cgm_peripheral_sample: {e}"))
491 };
492 if let (Some(stdout), Some(stderr)) = (cgm.stdout.take(), cgm.stderr.take()) {
493 if verbose {
494 pipe_labeled(stdout, "cgm");
495 pipe_labeled(stderr, "cgm");
496 } else if let Some(ref dir) = log_dir {
497 pipe_to_file(stdout, dir.join("cgm.log"));
498 pipe_to_file(stderr, dir.join("cgm.log"));
499 }
500 }
501
502 let pty_path = pty_rx
504 .recv_timeout(Duration::from_secs(30))
505 .unwrap_or_else(|_| {
506 panic!(
507 "timed out waiting for zephyr_rpc_server_app to announce UART PTY path \
508 (expected a stdout line containing \"connected to pseudotty: \")"
509 )
510 });
511
512 let socket_path_str = socket_path
514 .to_str()
515 .expect("socket path must be valid UTF-8");
516 let pty_path_str = pty_path
517 .to_str()
518 .expect("PTY path must be valid UTF-8");
519
520 let socat = Command::new("socat")
521 .arg(format!("UNIX-LISTEN:{socket_path_str},fork"))
522 .arg(format!("{pty_path_str},raw,echo=0"))
523 .stdin(Stdio::null())
524 .stdout(Stdio::null())
525 .stderr(Stdio::null())
526 .process_group(0)
527 .spawn()
528 .unwrap_or_else(|e| {
529 panic!(
530 "failed to spawn socat (is it installed?): {e}\n\
531 socat bridges the Zephyr UART PTY ({pty_path_str}) to the test UNIX socket \
532 ({socket_path_str})"
533 )
534 });
535
536 let processes = TestProcesses {
537 children: vec![phy, zephyr_proc, cgm, socat],
538 stdout_lines,
539 };
540
541 (processes, socket_path)
542}
543
544#[cfg(test)]
547mod tests {
548 use super::*;
549
550 fn make_tp(lines: Vec<&str>) -> TestProcesses {
553 let buf = Arc::new(Mutex::new(
554 lines.into_iter().map(str::to_owned).collect(),
555 ));
556 TestProcesses {
557 children: vec![],
558 stdout_lines: buf,
559 }
560 }
561
562 #[test]
565 fn parses_pty_path_from_typical_stdout_line() {
566 let line = "UART_0 connected to pseudotty: /dev/pts/5";
567 let needle = "connected to pseudotty: ";
568 let idx = line.find(needle).expect("needle present");
569 let path = line[idx + needle.len()..].trim();
570 assert_eq!(path, "/dev/pts/5");
571 }
572
573 #[test]
574 fn parses_pty_path_ignores_leading_whitespace() {
575 let line = " UARTE_1 connected to pseudotty: /dev/pts/12 ";
576 let needle = "connected to pseudotty:";
577 let idx = line.find(needle).expect("needle present");
578 let path = line[idx + needle.len()..].trim();
579 assert_eq!(path, "/dev/pts/12");
580 }
581
582 #[test]
585 fn search_finds_exact_line_match() {
586 let mut tp = make_tp(vec!["<inf> nrf_ps_server: Initializing RPC server"]);
587 tp.search_stdout_with_timeout(
589 HashSet::from(["Initializing RPC server"]),
590 Duration::from_millis(500),
591 );
592 }
593
594 #[test]
595 fn search_finds_multiple_strings_across_different_lines() {
596 let mut tp = make_tp(vec![
597 "<inf> nrf_ps_server: Initializing RPC server",
598 "<dbg> NRF_RPC: Done initializing nRF RPC module",
599 "some other log line",
600 ]);
601 tp.search_stdout_with_timeout(
602 HashSet::from([
603 "Initializing RPC server",
604 "Done initializing nRF RPC module",
605 ]),
606 Duration::from_millis(500),
607 );
608 }
609
610 #[test]
611 fn search_succeeds_on_empty_expected_set() {
612 let mut tp = make_tp(vec![]);
613 tp.search_stdout_with_timeout(HashSet::new(), Duration::from_millis(100));
615 }
616
617 #[test]
618 #[should_panic(expected = "timed out")]
619 fn search_panics_when_string_is_absent() {
620 let mut tp = make_tp(vec!["something irrelevant"]);
621 tp.search_stdout_with_timeout(
622 HashSet::from(["this string is not present"]),
623 Duration::from_millis(200),
624 );
625 }
626
627 #[test]
628 #[should_panic(expected = "timed out")]
629 fn search_panics_when_only_some_strings_are_found() {
630 let mut tp = make_tp(vec!["line A present"]);
631 tp.search_stdout_with_timeout(
632 HashSet::from(["line A present", "line B missing"]),
633 Duration::from_millis(200),
634 );
635 }
636
637 #[test]
640 fn kill_all_on_empty_children_does_not_panic() {
641 let mut tp = make_tp(vec![]);
642 tp.kill_all(); }
644
645 #[test]
648 fn log_output_off_is_not_verbose() {
649 let verbose = matches!(LogOutput::Off, LogOutput::Stream | LogOutput::Both(_));
650 assert!(!verbose);
651 }
652
653 #[test]
654 fn log_output_write_to_dir_is_not_verbose() {
655 let verbose = matches!(
656 LogOutput::WriteToDir(PathBuf::from("/tmp")),
657 LogOutput::Stream | LogOutput::Both(_)
658 );
659 assert!(!verbose);
660 }
661
662 #[test]
663 fn log_output_stream_is_verbose() {
664 let verbose = matches!(LogOutput::Stream, LogOutput::Stream | LogOutput::Both(_));
665 assert!(verbose);
666 }
667
668 #[test]
669 fn log_output_both_is_verbose() {
670 let verbose = matches!(
671 LogOutput::Both(PathBuf::from("/tmp")),
672 LogOutput::Stream | LogOutput::Both(_)
673 );
674 assert!(verbose);
675 }
676
677 #[test]
678 fn log_output_off_has_no_log_dir() {
679 let log_dir: Option<PathBuf> = match &LogOutput::Off {
680 LogOutput::WriteToDir(p) | LogOutput::Both(p) => Some(p.clone()),
681 _ => None,
682 };
683 assert!(log_dir.is_none());
684 }
685
686 #[test]
687 fn log_output_write_to_dir_extracts_path() {
688 let expected = PathBuf::from("/tmp/sim-logs");
689 let log_dir: Option<PathBuf> = match &LogOutput::WriteToDir(expected.clone()) {
690 LogOutput::WriteToDir(p) | LogOutput::Both(p) => Some(p.clone()),
691 _ => None,
692 };
693 assert_eq!(log_dir, Some(expected));
694 }
695
696 #[test]
697 fn log_output_both_extracts_path() {
698 let expected = PathBuf::from("/tmp/sim-logs");
699 let log_dir: Option<PathBuf> = match &LogOutput::Both(expected.clone()) {
700 LogOutput::WriteToDir(p) | LogOutput::Both(p) => Some(p.clone()),
701 _ => None,
702 };
703 assert_eq!(log_dir, Some(expected));
704 }
705
706 #[test]
709 fn pipe_to_file_writes_lines_to_file() {
710 use std::io::Cursor;
711 let dir = tempfile::tempdir().expect("tempdir");
712 let path = dir.path().join("out.log");
713 std::fs::File::create(&path).unwrap();
715
716 let content = b"line one\nline two\nline three\n";
717 pipe_to_file(Cursor::new(content), path.clone());
718
719 std::thread::sleep(Duration::from_millis(200));
721
722 let written = std::fs::read_to_string(&path).unwrap();
723 assert!(written.contains("line one"), "missing 'line one' in {written:?}");
724 assert!(written.contains("line two"), "missing 'line two' in {written:?}");
725 assert!(written.contains("line three"), "missing 'line three' in {written:?}");
726 }
727
728 #[test]
729 fn file_create_truncates_existing_content() {
730 let dir = tempfile::tempdir().expect("tempdir");
731 let path = dir.path().join("stale.log");
732 std::fs::write(&path, "old sentinel content\n").unwrap();
733
734 std::fs::File::create(&path).unwrap();
736
737 let after = std::fs::read_to_string(&path).unwrap();
738 assert!(after.is_empty(), "file should be empty after File::create, got {after:?}");
739 }
740}