aboutsummaryrefslogtreecommitdiff
path: root/tests/auto_detect.rs
diff options
context:
space:
mode:
Diffstat (limited to 'tests/auto_detect.rs')
-rw-r--r--tests/auto_detect.rs706
1 files changed, 706 insertions, 0 deletions
diff --git a/tests/auto_detect.rs b/tests/auto_detect.rs
new file mode 100644
index 00000000..4c92d62c
--- /dev/null
+++ b/tests/auto_detect.rs
@@ -0,0 +1,706 @@
+//! Integration tests for auto-detect launch behavior.
+
+#![cfg(not(target_os = "macos"))]
+
+mod support;
+
+use std::fs;
+use std::io::{BufRead, BufReader, Write};
+use std::os::unix::net::UnixStream;
+use std::path::{Path, PathBuf};
+use std::process::Command;
+use std::sync::{Mutex, MutexGuard, OnceLock};
+use std::thread;
+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
+
+use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
+use serde_json::Value;
+use support::{
+ cleanup_test_base, register_runtime_dir, register_spawned_herdr_pid,
+ unregister_spawned_herdr_pid,
+};
+
+fn unique_test_dir() -> PathBuf {
+ let nanos = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0);
+ PathBuf::from(format!(
+ "/tmp/herdr-autodetect-test-{}-{nanos}",
+ std::process::id()
+ ))
+}
+
+struct SpawnedHerdr {
+ _master: Box<dyn MasterPty + Send>,
+ child: Box<dyn Child + Send + Sync>,
+}
+
+impl Drop for SpawnedHerdr {
+ fn drop(&mut self) {
+ let pid = self.child.process_id();
+ let _ = self.child.kill();
+
+ if let Some(pid) = pid {
+ let deadline = Instant::now() + Duration::from_secs(2);
+ while Instant::now() < deadline {
+ let mut status = 0;
+ let result =
+ unsafe { libc::waitpid(pid as libc::pid_t, &mut status, libc::WNOHANG) };
+ if result == pid as libc::pid_t || result == -1 {
+ break;
+ }
+ thread::sleep(Duration::from_millis(20));
+ }
+
+ unregister_spawned_herdr_pid(Some(pid));
+ }
+ }
+}
+
+fn cleanup_spawned_herdr(spawned: SpawnedHerdr, base: PathBuf) {
+ drop(spawned);
+ cleanup_test_base(&base);
+}
+
+fn wait_for_socket(path: &Path, timeout: Duration) {
+ let deadline = Instant::now() + timeout;
+ while Instant::now() < deadline {
+ if path.exists() && UnixStream::connect(path).is_ok() {
+ return;
+ }
+ thread::sleep(Duration::from_millis(25));
+ }
+ panic!("socket did not appear at {}", path.display());
+}
+
+fn test_lock() -> MutexGuard<'static, ()> {
+ static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
+ LOCK.get_or_init(|| Mutex::new(()))
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+}
+
+fn spawn_server(
+ config_home: &Path,
+ runtime_dir: &Path,
+ api_socket_path: &Path,
+ _client_socket_path: &Path,
+) -> SpawnedHerdr {
+ fs::create_dir_all(config_home.join("herdr")).unwrap();
+ fs::create_dir_all(runtime_dir).unwrap();
+ register_runtime_dir(runtime_dir);
+ fs::write(
+ config_home.join("herdr/config.toml"),
+ "onboarding = false\n",
+ )
+ .unwrap();
+
+ let pair = native_pty_system()
+ .openpty(PtySize {
+ rows: 24,
+ cols: 80,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .unwrap();
+
+ let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
+ cmd.arg("server");
+ cmd.env("XDG_CONFIG_HOME", config_home);
+ cmd.env("XDG_RUNTIME_DIR", runtime_dir);
+ cmd.env("HERDR_SOCKET_PATH", api_socket_path);
+ cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
+ cmd.env("SHELL", "/bin/sh");
+ cmd.env_remove("HERDR_ENV");
+
+ let child = pair.slave.spawn_command(cmd).unwrap();
+ register_spawned_herdr_pid(child.process_id());
+ drop(pair.slave);
+
+ SpawnedHerdr {
+ _master: pair.master,
+ child,
+ }
+}
+
+/// Spawn `herdr` (no subcommand) — the auto-detect launch path.
+fn spawn_herdr_auto(
+ config_home: &Path,
+ runtime_dir: &Path,
+ api_socket_path: &Path,
+ _client_socket_path: &Path,
+) -> SpawnedHerdr {
+ fs::create_dir_all(config_home.join("herdr")).unwrap();
+ fs::create_dir_all(runtime_dir).unwrap();
+ register_runtime_dir(runtime_dir);
+ fs::write(
+ config_home.join("herdr/config.toml"),
+ "onboarding = false\n",
+ )
+ .unwrap();
+
+ let pair = native_pty_system()
+ .openpty(PtySize {
+ rows: 24,
+ cols: 80,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .unwrap();
+
+ let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
+ // No subcommand, no --no-session → auto-detect launch
+ cmd.env("XDG_CONFIG_HOME", config_home);
+ cmd.env("XDG_RUNTIME_DIR", runtime_dir);
+ cmd.env("HERDR_SOCKET_PATH", api_socket_path);
+ cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
+ cmd.env("SHELL", "/bin/sh");
+ cmd.env_remove("HERDR_ENV");
+
+ let child = pair.slave.spawn_command(cmd).unwrap();
+ register_spawned_herdr_pid(child.process_id());
+ drop(pair.slave);
+
+ SpawnedHerdr {
+ _master: pair.master,
+ child,
+ }
+}
+
+/// Spawn `herdr --no-session` — the monolithic escape hatch.
+fn spawn_herdr_no_session(
+ config_home: &Path,
+ runtime_dir: &Path,
+ api_socket_path: &Path,
+) -> SpawnedHerdr {
+ fs::create_dir_all(config_home.join("herdr")).unwrap();
+ fs::create_dir_all(runtime_dir).unwrap();
+ register_runtime_dir(runtime_dir);
+ fs::write(
+ config_home.join("herdr/config.toml"),
+ "onboarding = false\n",
+ )
+ .unwrap();
+
+ let pair = native_pty_system()
+ .openpty(PtySize {
+ rows: 24,
+ cols: 80,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .unwrap();
+
+ let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
+ cmd.arg("--no-session");
+ cmd.env("XDG_CONFIG_HOME", config_home);
+ cmd.env("XDG_RUNTIME_DIR", runtime_dir);
+ cmd.env("HERDR_SOCKET_PATH", api_socket_path);
+ cmd.env("SHELL", "/bin/sh");
+ cmd.env_remove("HERDR_ENV");
+
+ let child = pair.slave.spawn_command(cmd).unwrap();
+ register_spawned_herdr_pid(child.process_id());
+ drop(pair.slave);
+
+ SpawnedHerdr {
+ _master: pair.master,
+ child,
+ }
+}
+
+fn ping_socket(socket_path: &Path) -> String {
+ let mut stream = UnixStream::connect(socket_path).expect("should connect to API socket");
+
+ let request = r#"{"id":"1","method":"ping","params":{}}"#;
+ writeln!(stream, "{}", request).unwrap();
+
+ let mut reader = BufReader::new(stream);
+ let mut response = String::new();
+ reader.read_line(&mut response).unwrap();
+ response.trim().to_string()
+}
+
+fn run_cli(socket_path: &Path, args: &[&str]) -> std::process::Output {
+ let mut command = Command::new(env!("CARGO_BIN_EXE_herdr"));
+ command.args(args);
+ command.env("HERDR_SOCKET_PATH", socket_path);
+ command.output().unwrap()
+}
+
+fn process_exists(pid: u32) -> bool {
+ let result = unsafe { libc::kill(pid as i32, 0) };
+ if result == 0 {
+ true
+ } else {
+ std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
+ }
+}
+
+fn read_json_line(stream: UnixStream) -> Value {
+ let mut reader = BufReader::new(stream);
+ let mut response = String::new();
+ reader.read_line(&mut response).unwrap();
+ serde_json::from_str(&response).unwrap()
+}
+
+fn wait_for_pid_exit(pid: u32, timeout: Duration) -> bool {
+ let deadline = Instant::now() + timeout;
+ while Instant::now() < deadline {
+ if !process_exists(pid) {
+ return true;
+ }
+ thread::sleep(Duration::from_millis(20));
+ }
+ false
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+/// Running `herdr` with no server present starts a server
+/// and attaches as client.
+#[test]
+fn auto_detect_no_server_spawns_server_and_attaches() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ // Ensure no server is running initially.
+ assert!(
+ !api_socket.exists(),
+ "api socket should not exist initially"
+ );
+ assert!(
+ !client_socket.exists(),
+ "client socket should not exist initially"
+ );
+
+ // Run `herdr` (no subcommand) — should auto-detect, spawn server, attach as client.
+ let herdr = spawn_herdr_auto(&config_home, &runtime_dir, &api_socket, &client_socket);
+
+ // Wait for both sockets to appear (server was spawned).
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_socket(&client_socket, Duration::from_secs(10));
+
+ // Verify the API socket responds to ping (server is running).
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "API socket should respond to ping: {response}"
+ );
+
+ // Verify the client socket accepts connections (server is listening on it).
+ let _stream = UnixStream::connect(&client_socket)
+ .expect("should connect to client socket (server is listening)");
+
+ // Verify the client process is running.
+ let client_pid = herdr.child.process_id().expect("client should have PID");
+ assert!(
+ process_exists(client_pid),
+ "client process should be running"
+ );
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+/// Running `herdr` with a server already running attaches
+/// as client directly (no second server).
+#[test]
+fn auto_detect_server_running_attaches_directly() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ // Start a server explicitly.
+ let server = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_socket(&client_socket, Duration::from_secs(10));
+
+ let server_pid = server.child.process_id().expect("server should have PID");
+
+ // Verify server is running.
+ assert!(process_exists(server_pid), "server should be running");
+
+ // Run `herdr` (no subcommand) — should detect the running server and attach.
+ let client = spawn_herdr_auto(&config_home, &runtime_dir, &api_socket, &client_socket);
+
+ // Wait a moment for the client to attach.
+ thread::sleep(Duration::from_millis(500));
+
+ // Verify the client is running.
+ let client_pid = client.child.process_id().expect("client should have PID");
+ assert!(
+ process_exists(client_pid),
+ "client process should be running"
+ );
+
+ // Verify the server is still the same one (no second server spawned).
+ assert!(
+ process_exists(server_pid),
+ "original server should still be running"
+ );
+
+ // Verify API still responds.
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "API should still respond to ping: {response}"
+ );
+
+ cleanup_spawned_herdr(client, PathBuf::from("/nonexistent"));
+ cleanup_spawned_herdr(server, base);
+}
+
+/// Socket path resolution is consistent between server and client.
+/// Both derive the client socket from the `HERDR_SOCKET_PATH` override,
+/// so overriding the API socket keeps both endpoints aligned.
+#[test]
+fn auto_detect_socket_path_consistency() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ // Run `herdr` with custom socket paths.
+ let herdr = spawn_herdr_auto(&config_home, &runtime_dir, &api_socket, &client_socket);
+
+ // Wait for both sockets to appear at the custom paths.
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_socket(&client_socket, Duration::from_secs(10));
+
+ // Verify sockets exist at the specified paths.
+ assert!(
+ api_socket.exists(),
+ "API socket should exist at custom path"
+ );
+ assert!(
+ client_socket.exists(),
+ "client socket should exist at custom path"
+ );
+
+ // Verify API responds (server is using the custom API socket path).
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "API should respond at custom path: {response}"
+ );
+
+ // Verify client socket accepts connections (server is using the custom
+ // client socket path).
+ let _stream = UnixStream::connect(&client_socket)
+ .expect("should connect to client socket at custom path");
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+/// `herdr --no-session` bypasses server/client and runs
+/// monolithically. No server process is spawned. No client socket is created.
+#[test]
+fn no_session_flag_runs_monolithically() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ // Run `herdr --no-session` — monolithic mode, no server/client.
+ let herdr = spawn_herdr_no_session(&config_home, &runtime_dir, &api_socket);
+
+ // Wait for the API socket (monolithic mode creates it).
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+
+ // Verify the API socket exists and responds.
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "monolithic API should respond: {response}"
+ );
+
+ // Verify NO client socket was created — this is the key distinction
+ // between monolithic mode and server/client mode.
+ assert!(
+ !client_socket.exists(),
+ "no client socket should exist in monolithic mode"
+ );
+
+ // Verify the API socket is served by the monolithic process itself,
+ // not by a separate server. We can check this by verifying the client
+ // PID matches what would be serving the socket — in monolithic mode,
+ // there is only one herdr process.
+ let client_pid = herdr.child.process_id().expect("should have PID");
+ assert!(
+ process_exists(client_pid),
+ "monolithic process should be running"
+ );
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+/// CLI subcommands work through the server's JSON API socket.
+#[test]
+fn cli_subcommands_work_through_server() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ // Start a server.
+ let server = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_socket(&client_socket, Duration::from_secs(10));
+
+ // Test `herdr workspace list` through the server's API socket.
+ let output = run_cli(&api_socket, &["workspace", "list"]);
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ assert!(
+ output.status.success(),
+ "workspace list should succeed: stderr={}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ // The response should be valid JSON with a "result" field.
+ assert!(
+ stdout.contains("result"),
+ "workspace list output should contain 'result': {stdout}"
+ );
+
+ // Test `herdr pane list` through the server's API socket.
+ let output = run_cli(&api_socket, &["pane", "list"]);
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ assert!(
+ output.status.success(),
+ "pane list should succeed: stderr={}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ assert!(
+ stdout.contains("result"),
+ "pane list output should contain 'result': {stdout}"
+ );
+
+ cleanup_spawned_herdr(server, base);
+}
+
+/// Verify that the server spawned by auto-detect
+/// persists after the client exits, and a new `herdr` can reattach.
+#[test]
+fn auto_detect_server_persists_and_reattaches() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ // Run `herdr` — auto-detect spawns server + attaches client.
+ let mut client1 = spawn_herdr_auto(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_socket(&client_socket, Duration::from_secs(10));
+
+ // Verify API responds.
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "API should respond before client exit: {response}"
+ );
+
+ // Kill the first client.
+ let client1_pid = client1.child.process_id().expect("client1 should have PID");
+ let _ = client1.child.kill();
+ let _ = wait_for_pid_exit(client1_pid, Duration::from_secs(2));
+ drop(client1);
+
+ // Wait a moment for the server to process the disconnect.
+ thread::sleep(Duration::from_millis(500));
+
+ // Verify server is still running after client exit — the API should
+ // still respond because the server is a separate daemon process.
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "API should still respond after client exit (server persists): {response}"
+ );
+
+ // The client socket should still exist (server is still listening).
+ assert!(
+ client_socket.exists(),
+ "client socket should still exist after client exit"
+ );
+
+ // Run `herdr` again — should detect the running server and reattach.
+ let client2 = spawn_herdr_auto(&config_home, &runtime_dir, &api_socket, &client_socket);
+ thread::sleep(Duration::from_millis(500));
+
+ // Verify the new client is running.
+ let client2_pid = client2.child.process_id().expect("client2 should have PID");
+ assert!(
+ process_exists(client2_pid),
+ "second client should be running"
+ );
+
+ // Verify API still responds (same server).
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "API should still respond after reattach: {response}"
+ );
+
+ cleanup_spawned_herdr(client2, PathBuf::from("/nonexistent"));
+ cleanup_test_base(&base);
+}
+
+/// Verify that the default API and client
+/// sockets live in the app config directory when no env override is set.
+#[test]
+fn auto_detect_default_socket_path_from_config_dir() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+
+ // Don't set HERDR_SOCKET_PATH or HERDR_CLIENT_SOCKET_PATH.
+ // The default paths should come from the app config directory, not XDG_RUNTIME_DIR.
+ let app_dir_name = if cfg!(debug_assertions) {
+ "herdr-dev"
+ } else {
+ "herdr"
+ };
+ let api_socket = config_home.join(app_dir_name).join("herdr.sock");
+ let client_socket = config_home.join(app_dir_name).join("herdr-client.sock");
+
+ // Spawn server with XDG_RUNTIME_DIR set to a different directory to prove it is ignored.
+ fs::create_dir_all(config_home.join(app_dir_name)).unwrap();
+ fs::create_dir_all(&runtime_dir).unwrap();
+ register_runtime_dir(&runtime_dir);
+ fs::write(
+ config_home.join(app_dir_name).join("config.toml"),
+ "onboarding = false\n",
+ )
+ .unwrap();
+
+ let pair = native_pty_system()
+ .openpty(PtySize {
+ rows: 24,
+ cols: 80,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .unwrap();
+
+ let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
+ cmd.arg("server");
+ cmd.env("XDG_CONFIG_HOME", &config_home);
+ cmd.env("XDG_RUNTIME_DIR", &runtime_dir);
+ cmd.env("SHELL", "/bin/sh");
+ cmd.env_remove("HERDR_ENV");
+ // Explicitly remove socket overrides to test default path resolution.
+ cmd.env_remove("HERDR_SOCKET_PATH");
+ cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
+
+ let child = pair.slave.spawn_command(cmd).unwrap();
+ register_spawned_herdr_pid(child.process_id());
+ drop(pair.slave);
+ let server = SpawnedHerdr {
+ _master: pair.master,
+ child,
+ };
+
+ // Wait for sockets to appear at the default config-dir paths.
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_socket(&client_socket, Duration::from_secs(10));
+
+ // Verify both sockets exist.
+ assert!(api_socket.exists(), "API socket should exist in config dir");
+ assert!(
+ client_socket.exists(),
+ "client socket should exist in config dir"
+ );
+
+ // Verify API responds.
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "API should respond at config-dir path: {response}"
+ );
+
+ cleanup_spawned_herdr(server, base);
+}
+
+#[test]
+fn auto_detect_respects_nested_guard_before_auto_attach() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let server = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_socket(&client_socket, Duration::from_secs(10));
+
+ let baseline = read_json_line({
+ let mut stream = UnixStream::connect(&api_socket).unwrap();
+ writeln!(
+ stream,
+ r#"{{"id":"ws_before","method":"workspace.list","params":{{}}}}"#
+ )
+ .unwrap();
+ stream
+ });
+ let baseline_count = baseline["result"]["workspaces"]
+ .as_array()
+ .map(|workspaces| workspaces.len())
+ .unwrap_or(0);
+
+ let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .env("XDG_CONFIG_HOME", &config_home)
+ .env("XDG_RUNTIME_DIR", &runtime_dir)
+ .env("HERDR_SOCKET_PATH", &api_socket)
+ .env_remove("HERDR_CLIENT_SOCKET_PATH")
+ .env("HERDR_ENV", "1")
+ .output()
+ .unwrap();
+
+ assert!(
+ !output.status.success(),
+ "nested launch should fail before auto-attach"
+ );
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(
+ stderr.contains("nested herdr is disabled by default"),
+ "stderr should mention nested-launch guard: {stderr}"
+ );
+
+ let after = read_json_line({
+ let mut stream = UnixStream::connect(&api_socket).unwrap();
+ writeln!(
+ stream,
+ r#"{{"id":"ws_after","method":"workspace.list","params":{{}}}}"#
+ )
+ .unwrap();
+ stream
+ });
+ let after_count = after["result"]["workspaces"]
+ .as_array()
+ .map(|workspaces| workspaces.len())
+ .unwrap_or(0);
+ assert_eq!(
+ after_count, baseline_count,
+ "nested launch should not auto-attach or mutate server state"
+ );
+
+ cleanup_spawned_herdr(server, base);
+}