Skip to main content

babble_bridge/
lib.rs

1//! BabbleSim + Zephyr nRF RPC simulation bridge.
2//!
3//! This crate provides three things:
4//!
5//! - **Test harness** ([`spawn_zephyr_rpc_server_with_socat`]) — spawn a full
6//!   BabbleSim simulation from Rust integration tests.
7//! - **xtask CLI** ([`xtask::cli_main`]) — docker, zephyr-setup, and run-bsim
8//!   commands that downstream crates can re-export.
9//! - **Programmatic setup API** ([`xtask::fetch_prebuilt_binaries`],
10//!   [`xtask::zephyr_setup`]) — call from a downstream `build.rs` or any
11//!   Rust code without shelling out.
12//!
13//! # Test harness usage
14//!
15//! ```no_run
16//! use std::collections::HashSet;
17//! use std::os::unix::net::UnixStream;
18//! use std::path::Path;
19//! use std::time::Duration;
20//! use babble_bridge::LogOutput;
21//!
22//! let tests_dir = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/sockets"));
23//! let (mut processes, socket_path) =
24//!     babble_bridge::spawn_zephyr_rpc_server_with_socat(tests_dir, "my_test", LogOutput::Off);
25//!
26//! // socat is spawned but may not be listening yet — retry until connectable.
27//! let start = std::time::Instant::now();
28//! let _socket = loop {
29//!     match UnixStream::connect(&socket_path) {
30//!         Ok(s) => break s,
31//!         Err(_) if start.elapsed() < Duration::from_secs(5) => {
32//!             std::thread::sleep(Duration::from_millis(50));
33//!         }
34//!         Err(e) => panic!("socket never became connectable: {e}"),
35//!     }
36//! };
37//!
38//! // … write/read via _socket …
39//!
40//! processes.search_stdout_for_strings(HashSet::from([
41//!     "<inf> nrf_ps_server: Initializing RPC server",
42//! ]));
43//! ```
44
45pub 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// ── Public types ─────────────────────────────────────────────────────────────
57
58/// Controls where the simulation process output (stdout/stderr) is forwarded
59/// when [`spawn_zephyr_rpc_server_with_socat`] is called.
60///
61/// # Variants
62///
63/// - `Off` — no forwarding; processes write to `/dev/null` or an internal
64///   buffer used only for [`TestProcesses::search_stdout_for_strings`].
65/// - `Stream` — forward all output to the caller's terminal in real time,
66///   labelled per process (e.g. `[rpc-server] …`).  Output goes to
67///   `/dev/stderr` directly so it bypasses `cargo test` capture.
68/// - `WriteToDir(path)` — write each process's output to a log file under
69///   `path` (`rpc-server.log`, `cgm.log`, `phy.log`).  The directory is
70///   created if it does not exist, and each log file is **truncated** at the
71///   start of every spawn so that stale output from a previous run is cleared.
72/// - `Both(path)` — stream to the terminal AND write to files simultaneously.
73#[derive(Clone, Debug)]
74pub enum LogOutput {
75    /// No forwarding (default, silent).
76    Off,
77    /// Stream all process output to the terminal with `[label]` prefixes.
78    Stream,
79    /// Write each process's output to `<path>/{rpc-server,cgm,phy}.log`.
80    /// Log files are truncated on every spawn.
81    WriteToDir(PathBuf),
82    /// Stream to terminal AND write to files under `path`.
83    Both(PathBuf),
84}
85
86/// Owns all child processes spawned for a single simulation run and
87/// accumulates their stdout output for later inspection.
88///
89/// All child processes are killed when this value is dropped.
90pub struct TestProcesses {
91    children: Vec<Child>,
92    /// Combined stdout lines from every process whose stdout was captured.
93    stdout_lines: Arc<Mutex<Vec<String>>>,
94}
95
96impl TestProcesses {
97    /// Block until every string in `expected` appears as a substring of any
98    /// accumulated stdout line, or panic after 30 seconds listing missing strings.
99    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    /// Like [`search_stdout_for_strings`] but with a caller-supplied timeout.
104    /// Useful in tests to avoid 30-second waits.
105    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    /// Helper method to dump the current stdout from attached nrf-rpc-server.
148    /// Useful when debugging, but will result in search stdout methods no longer
149    /// functioning (as this will consume stdout).
150    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    /// Kill all managed child processes immediately. Called automatically on drop.
173    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
189// ── Internal helpers ─────────────────────────────────────────────────────────
190
191/// Spawn a background thread that drains `stream` line by line and writes
192/// each line to the **real** stderr (fd 2 via `/dev/stderr`) as
193/// `[<label>] <line>`.
194///
195/// We open `/dev/stderr` directly instead of using `eprintln!` so the output
196/// reaches the terminal even when `cargo test` has redirected
197/// `std::io::stderr()` to its per-test capture buffer (which suppresses
198/// passing-test output unless `--nocapture` is passed).
199fn 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
218/// Spawn a background thread that drains `stream` line by line and appends
219/// each line to the file at `path`.  The file must already exist (caller
220/// creates/truncates it before spawning child processes).
221fn 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
240// ── Public function ───────────────────────────────────────────────────────────
241
242/// Spawns the full BabbleSim simulation stack for a single test:
243///
244/// 1. `bs_2G4_phy_v1`  — the radio PHY simulator
245/// 2. `zephyr_rpc_server_app` — Zephyr nRF RPC server with `-uart0_pty`
246/// 3. `cgm_peripheral_sample` — CGM BLE peripheral
247///
248/// The function waits up to 10 seconds for `zephyr_rpc_server_app` to print
249/// its PTY path on stdout (`"UART_0 connected to pseudotty: /dev/pts/N"`),
250/// then launches `socat` to bridge that PTY to a UNIX socket at
251/// Kills any leftover BabbleSim processes from a previous run with the given
252/// `sim_id`. Debugger stops and abnormal exits leave orphaned child processes
253/// that hold the sim_id and block the next launch.
254pub(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    // Give processes time to fully exit.
265    std::thread::sleep(Duration::from_millis(300));
266
267    // BabbleSim stores per-sim IPC files under /tmp/bs_<username>/<sim_id>/.
268    // These lock/pipe files must be removed before a new run or the PHY will
269    // hang waiting for coordination on stale file descriptors.
270    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    // Also clean up any POSIX shared memory objects keyed by sim_id.
283    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
293/// `tests_dir/<test_name>.sock`.
294///
295/// # Panics
296///
297/// Panics if any process fails to spawn, if PTY discovery times out, or if
298/// `socat` is not found on `PATH`.
299pub 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 a log directory was requested, create it and truncate each log file
311    // so output from the previous run is cleared before any process spawns.
312    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 orphaned processes FIRST so socat releases its fd on the socket
336    // file before we unlink it.  Without this ordering, remove_file succeeds
337    // on the directory entry but socat keeps an open fd on the inode, and the
338    // new socat fails to bind if the socket is still in use.
339    kill_stale_sim_processes(sim_id);
340    let _ = std::fs::remove_file(&socket_path);
341
342    // ── 1. PHY ──────────────────────────────────────────────────────────────
343    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", // 2 radio devices: zephyr_rpc_server_app (d=0) + cgm_peripheral_sample (d=1)
348            "-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    // ── 2. Zephyr RPC server (stdout always piped for PTY discovery + log capture) ──
366    //
367    // stdout must stay piped regardless of log mode so the PTY path can
368    // be extracted.  When verbose, the reader thread additionally forwards
369    // every line to stderr with a "[rpc-server]" prefix.  When writing to a
370    // dir, the reader thread also writes every line to rpc-server.log.
371    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    // -force-color tells the Zephyr native-sim tracing layer to emit ANSI
375    // escape codes even when stdout/stderr are pipes rather than a real TTY.
376    // Without it, isatty() returns 0 on a pipe and colors are stripped.
377    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    // Drain Zephyr stderr (kernel/driver logs).
400    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    // Drain Zephyr stdout in a background thread:
406    // - send the PTY path once via `pty_tx` when the "pseudotty" line appears
407    // - append every line to the shared `stdout_lines` buffer
408    // - when verbose, also forward each line to stderr with a "[rpc-server]" prefix
409    // - when writing to dir, also write every line to rpc-server.log
410    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        // Same /dev/stderr trick as pipe_labeled — bypasses cargo test capture.
416        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            // PTY discovery: nsi_print_trace writes to stdout
436            // format: "<uart_name> connected to pseudotty: <slave_path>"
437            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    // ── 3. CGM peripheral ────────────────────────────────────────────────────
456    // When verbose or writing to a log dir, pipe stdout/stderr so we can
457    // forward them.  Otherwise redirect to a local fallback log file (old
458    // behaviour) so the process doesn't block writing to a closed pipe.
459    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    // ── 4. Wait for PTY path ─────────────────────────────────────────────────
503    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    // ── 5. socat bridge: PTY → UNIX socket ───────────────────────────────────
513    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// ── Unit tests ────────────────────────────────────────────────────────────────
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549
550    // Helper: build a TestProcesses with a pre-filled stdout buffer and no
551    // real child processes.
552    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    // ── PTY path parsing ──────────────────────────────────────────────────────
563
564    #[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    // ── search_stdout_with_timeout ────────────────────────────────────────────
583
584    #[test]
585    fn search_finds_exact_line_match() {
586        let mut tp = make_tp(vec!["<inf> nrf_ps_server: Initializing RPC server"]);
587        // Must not panic.
588        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        // Empty set → nothing to wait for → should return immediately.
614        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    // ── kill_all is a no-op on an empty children list ─────────────────────────
638
639    #[test]
640    fn kill_all_on_empty_children_does_not_panic() {
641        let mut tp = make_tp(vec![]);
642        tp.kill_all(); // should be a silent no-op
643    }
644
645    // ── LogOutput variant helpers ─────────────────────────────────────────────
646
647    #[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    // ── pipe_to_file ──────────────────────────────────────────────────────────
707
708    #[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        // Pre-create so pipe_to_file's append open succeeds.
714        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        // Give the background thread time to finish.
720        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        // This is exactly what spawn_zephyr_rpc_server_with_socat does to clear logs.
735        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}