//! Integration tests for thin client mode. mod support; use std::fs; use std::io::{BufRead, BufReader, Read, Write}; use std::os::unix::net::UnixStream; use std::path::PathBuf; 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::Deserialize; use serde_json::Value; use support::{ cleanup_test_base, client_handshake, encode_varint_u32, frame_message, read_server_message, register_runtime_dir, register_spawned_herdr_pid, unregister_spawned_herdr_pid, wait_for_message_variant, wait_for_socket, wait_until, CURRENT_PROTOCOL, }; 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-client-test-{}-{nanos}", std::process::id() )) } struct SpawnedHerdr { _master: Option>, child: Box, } impl SpawnedHerdr { fn close_master(&mut self) { drop(self._master.take()); } } impl Drop for SpawnedHerdr { fn drop(&mut self) { let pid = self.child.process_id(); let _ = self.child.kill(); self.close_master(); 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 test_lock() -> MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } fn spawn_client_process( config_home: &PathBuf, runtime_dir: &PathBuf, api_socket_path: &PathBuf, ) -> SpawnedHerdr { register_runtime_dir(runtime_dir); 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("client"); cmd.env("HERDR_DISABLE_SOUND", "1"); 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: Some(pair.master), child, } } fn spawn_server( config_home: &PathBuf, runtime_dir: &PathBuf, api_socket_path: &PathBuf, _client_socket_path: &PathBuf, ) -> 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: Some(pair.master), child, } } fn ping_socket(socket_path: &PathBuf) -> 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 send_json_request(socket_path: &PathBuf, request: &str) -> Value { let mut stream = UnixStream::connect(socket_path).expect("should connect to API socket"); writeln!(stream, "{}", request).unwrap(); let mut reader = BufReader::new(stream); let mut response = String::new(); reader.read_line(&mut response).unwrap(); serde_json::from_str(&response).expect("response should be valid JSON") } fn first_pane_id_in_workspace(socket_path: &PathBuf, workspace_id: &str) -> String { let deadline = Instant::now() + Duration::from_secs(5); while Instant::now() < deadline { let request = format!( r#"{{"id":"pane_list","method":"pane.list","params":{{"workspace_id":"{workspace_id}"}}}}"# ); let panes = send_json_request(socket_path, &request); if let Some(pane_id) = panes["result"]["panes"] .as_array() .and_then(|panes| panes.first()) .and_then(|pane| pane["pane_id"].as_str()) { return pane_id.to_string(); } thread::sleep(Duration::from_millis(25)); } panic!("pane.list did not return a pane for workspace {workspace_id} before timeout"); } fn app_dir_name() -> &'static str { if cfg!(debug_assertions) { "herdr-dev" } else { "herdr" } } #[allow(dead_code)] #[derive(Debug, Deserialize)] struct FrameWire { cells: Vec, width: u16, height: u16, cursor: Option, hyperlinks: Vec, graphics: Vec, } #[allow(dead_code)] #[derive(Debug, Deserialize)] struct CellWire { symbol: String, fg: u32, bg: u32, modifier: u16, skip: bool, hyperlink: Option, } #[derive(Debug, Deserialize)] struct CursorWire { x: u16, y: u16, visible: bool, shape: u8, } fn decode_frame_payload(payload: &[u8]) -> std::io::Result { bincode::serde::decode_from_slice(payload, bincode::config::standard()) .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err.to_string())) .and_then(|(frame, consumed): (FrameWire, usize)| { if consumed != payload.len() { return Err(std::io::Error::new( std::io::ErrorKind::InvalidData, format!( "frame payload had trailing bytes: consumed={}, len={}", consumed, payload.len() ), )); } Ok(frame) }) } fn read_next_frame_payload(stream: &mut UnixStream, timeout: Duration) -> Result, String> { stream .set_read_timeout(Some(Duration::from_millis(200))) .map_err(|e| e.to_string())?; let deadline = Instant::now() + timeout; while Instant::now() < deadline { match read_server_message(stream) { Ok((1, payload)) => return Ok(payload), Ok(_) => continue, Err(_) => continue, } } Err("timed out waiting for Frame message".into()) } fn frame_contains_text(frame: &FrameWire, needle: &str) -> bool { if frame.cells.is_empty() { return false; } let width = frame.width.max(1) as usize; let mut text = String::new(); for row in frame.cells.chunks(width) { for cell in row { let _ = (cell.fg, cell.bg, cell.modifier, cell.skip); text.push_str(&cell.symbol); } text.push('\n'); } let _ = (frame.height, frame.graphics.len()); if let Some(cursor) = frame.cursor.as_ref() { let _ = (cursor.x, cursor.y, cursor.visible, cursor.shape); } text.contains(needle) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[test] fn client_connects_and_receives_frame() { // Client connects to server and handshake completes. // Client receives Frame messages. // Server sends rendered frames to connected clients. 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 spawned = 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)); // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed"); assert_eq!( version, CURRENT_PROTOCOL, "server should report current protocol version" ); assert!( error.is_none(), "handshake should not have error: {:?}", error ); read_next_frame_payload(&mut stream, Duration::from_secs(10)) .expect("should receive a frame from server"); cleanup_spawned_herdr(spawned, base); } #[test] fn client_sees_headless_startup_config_diagnostic() { 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 app_dir = if cfg!(debug_assertions) { "herdr-dev" } else { "herdr" }; fs::create_dir_all(config_home.join(app_dir)).unwrap(); fs::write( config_home.join(app_dir).join("config.toml"), "[keys\nprefix = \"ctrl+a\"\n", ) .unwrap(); fs::create_dir_all(&runtime_dir).unwrap(); register_runtime_dir(&runtime_dir); 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); 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); let spawned = SpawnedHerdr { _master: Some(pair.master), child, }; wait_for_socket(&api_socket, Duration::from_secs(10)); wait_for_socket(&client_socket, Duration::from_secs(10)); let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); stream .set_read_timeout(Some(Duration::from_secs(5))) .unwrap(); let deadline = Instant::now() + Duration::from_secs(5); let mut found_diagnostic = false; while Instant::now() < deadline { match read_server_message(&mut stream) { Ok((1, payload)) => { let frame = decode_frame_payload(&payload).expect("decode frame"); if frame_contains_text(&frame, "config parse error") { found_diagnostic = true; break; } } Ok(_) => {} Err(_) => break, } } assert!( found_diagnostic, "attached client should see startup config parse diagnostic" ); cleanup_spawned_herdr(spawned, base); } #[test] fn server_unreachable_shows_clear_error() { // when server is unreachable, the client exits quickly // with an actionable connection-failed message. 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"); 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 output = std::process::Command::new(env!("CARGO_BIN_EXE_herdr")) .arg("client") .env("HERDR_DISABLE_SOUND", "1") .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_remove("HERDR_ENV") .output() .expect("client command should run"); assert!( !output.status.success(), "client should fail when no server is running" ); let stderr = String::from_utf8_lossy(&output.stderr); assert!( stderr.contains("failed to connect to server"), "stderr should mention connection failure: {stderr}" ); assert!( stderr.contains("Is herdr server running?"), "stderr should include actionable guidance: {stderr}" ); assert!( stderr.contains("Socket path:"), "stderr should include attempted socket path: {stderr}" ); cleanup_test_base(&base); } #[test] fn server_crash_after_attach_causes_lost_connection_error() { // attach a real thin client connection, kill server unexpectedly, // assert clean non-zero client exit plus lost-connection signal. 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 mut spawned = 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)); // Attach a real thin client (client subcommand) through PTY so handshake and // terminal setup paths are exercised. let mut thin_client = spawn_client_process(&config_home, &runtime_dir, &api_socket); // Prove attached before kill by waiting for recognizable rendered app content. let mut thin_reader = thin_client ._master .as_ref() .expect("thin client master") .try_clone_reader() .expect("clone client PTY reader"); let (attached_before_kill, attach_output) = { let deadline = Instant::now() + Duration::from_secs(8); let mut buf = [0u8; 4096]; let mut seen = false; let mut output = String::new(); while Instant::now() < deadline { match thin_reader.read(&mut buf) { Ok(n) if n > 0 => { let out = String::from_utf8_lossy(&buf[..n]); output.push_str(&out); if out.contains("\u{2500}") || out.contains("workspace") || out.contains("pane") || out.contains("terminal") { seen = true; break; } if output.to_lowercase().contains("herdr:") { break; } } Ok(_) => thread::sleep(Duration::from_millis(30)), Err(_) => thread::sleep(Duration::from_millis(30)), } } (seen, output) }; assert!( attached_before_kill, "thin client must complete attach and receive frame before server crash; output: {attach_output:?}" ); // Kill server unexpectedly. if let Some(pid) = spawned.child.process_id() { unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL); } } spawned.close_master(); // Client should exit non-zero after connection loss. let mut crash_output = String::new(); let exited = { let deadline = Instant::now() + Duration::from_secs(12); let mut exited = false; while Instant::now() < deadline { if thin_client.child.try_wait().ok().flatten().is_some() { exited = true; break; } // Keep draining client output so the process can progress to exit. let mut buf = [0u8; 1024]; if let Ok(n) = thin_reader.read(&mut buf) { if n > 0 { crash_output.push_str(&String::from_utf8_lossy(&buf[..n])); } } thread::sleep(Duration::from_millis(20)); } exited }; assert!(exited, "thin client should exit after server SIGKILL"); let status = thin_client.child.wait().expect("wait thin client status"); assert!( !status.success(), "thin client should exit non-zero after lost server connection" ); // Drain trailing output and require the explicit user-visible lost-connection message. let deadline = Instant::now() + Duration::from_secs(5); let mut buf = [0u8; 2048]; while Instant::now() < deadline { match thin_reader.read(&mut buf) { Ok(n) if n > 0 => crash_output.push_str(&String::from_utf8_lossy(&buf[..n])), Ok(_) => break, Err(_) => break, } thread::sleep(Duration::from_millis(30)); } let crash_output_lc = crash_output.to_lowercase(); assert!( crash_output_lc.contains("lost connection to server"), "thin client must emit explicit lost-connection message after server crash; output: {crash_output:?}" ); // Ensure server is gone. let _ = spawned.child.wait(); cleanup_test_base(&base); } #[test] fn client_receives_frame_after_pane_output() { // End-to-end test: server renders, client receives Frame. // This test verifies the full flow: // 1. Start server // 2. Connect client, handshake // 3. Send input to pane (echo command) // 4. Wait for a new frame from the server // 5. Verify the frame contains the pane output 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 spawned = 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)); // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); read_next_frame_payload(&mut stream, Duration::from_secs(10)) .expect("should receive initial frame"); // Send input to trigger a state change and re-render. let input_data = b"echo test-output\n".to_vec(); let input_payload = { let mut buf = encode_varint_u32(1); // Input variant buf.extend_from_slice(&encode_varint_u32(input_data.len() as u32)); buf.extend_from_slice(&input_data); buf }; let framed = frame_message(&input_payload); stream.write_all(&framed).expect("send input"); stream.flush().expect("flush"); // Read subsequent frames — the server should have re-rendered after // the input was processed. let received_frame = wait_for_message_variant(&mut stream, Duration::from_secs(2), 1) .expect("wait for post-output frame"); assert!(received_frame, "should receive a Frame after pane output"); cleanup_spawned_herdr(spawned, base); } #[test] fn pane_spawn_cwd_fallback_in_server() { // Pane spawn failure cwd fallback in server context. // This test verifies that the server can start even with invalid // session data pointing to non-existent directories. 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 data_dir = config_home.join(app_dir_name()); let missing_cwd = base.join("missing-cwd-for-test"); let missing_cwd = missing_cwd.to_str().expect("test cwd should be UTF-8"); fs::create_dir_all(&data_dir).unwrap(); let session = serde_json::json!({ "version": 2, "workspaces": [{ "custom_name": "missing-cwd", "layout": { "Pane": 0 }, "panes": { "0": { "cwd": missing_cwd } }, "zoomed": false, "focused": 0, "root_pane": 0 }], "active": 0, "selected": 0 }); fs::write( data_dir.join("session.json"), serde_json::to_vec_pretty(&session).unwrap(), ) .unwrap(); let spawned = 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 workspaces = send_json_request( &api_socket, r#"{"id":"workspace_list","method":"workspace.list","params":{}}"#, ); let restored_workspace = workspaces["result"]["workspaces"] .as_array() .unwrap() .iter() .find(|workspace| workspace["label"] == "missing-cwd") .expect("server should restore workspace with missing pane cwd"); let workspace_id = restored_workspace["workspace_id"] .as_str() .expect("restored workspace should have public id"); let pane_id = first_pane_id_in_workspace(&api_socket, workspace_id); let pane = send_json_request( &api_socket, &format!(r#"{{"id":"pane_get","method":"pane.get","params":{{"pane_id":"{pane_id}"}}}}"#), ); assert_eq!(pane["result"]["pane"]["workspace_id"], workspace_id); let cwd = pane["result"]["pane"]["cwd"] .as_str() .expect("restored pane should report fallback cwd"); assert_ne!(cwd, missing_cwd); assert!( std::path::Path::new(cwd).exists(), "fallback cwd should exist: {cwd}" ); cleanup_spawned_herdr(spawned, base); } #[test] fn graceful_shutdown_sends_server_shutdown_to_client() { // Issue 2 fix: SIGINT triggers initiate_shutdown → ServerShutdown // broadcast to all clients before the server exits. 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 mut spawned = 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)); // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); // Drain initial frame(s). stream .set_read_timeout(Some(Duration::from_secs(2))) .unwrap(); while read_server_message(&mut stream).is_ok() {} // Send SIGINT to the server process to trigger graceful shutdown. if let Some(pid) = spawned.child.process_id() { unsafe { libc::kill(pid as libc::pid_t, libc::SIGINT); } } // The client should receive a ServerShutdown message (variant 4) // before the connection is closed, not just an abrupt EOF. stream .set_read_timeout(Some(Duration::from_secs(5))) .unwrap(); let result = read_server_message(&mut stream); match result { Ok((variant, _payload)) => { assert_eq!( variant, 4, "expected ServerShutdown (variant 4), got variant {variant}" ); } Err(e) => { panic!("expected ServerShutdown message before connection close, got error: {e}"); } } // Wait for the server to exit. spawned.close_master(); let _ = spawned.child.wait(); drop(spawned); cleanup_test_base(&base); } #[test] fn client_receives_notify_on_agent_state_change() { // Notification events (sound/toast) are forwarded as // ServerMessage::Notify to connected clients when an agent state change // is triggered via the API (pane.report_agent). 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"); // Enable toast and sound in config so the server produces notifications. fs::create_dir_all(config_home.join("herdr")).unwrap(); fs::write( config_home.join("herdr/config.toml"), "onboarding = false\n[ui.toast]\nenabled = true\n[ui.sound]\nenabled = true\n", ) .unwrap(); fs::create_dir_all(&runtime_dir).unwrap(); register_runtime_dir(&runtime_dir); // Spawn the server directly (not using spawn_server helper because it // overwrites the config file with a minimal one). 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); 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); let spawned = SpawnedHerdr { _master: Some(pair.master), child, }; wait_for_socket(&api_socket, Duration::from_secs(10)); wait_for_socket(&client_socket, Duration::from_secs(10)); // Connect as a client and perform handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect"); let (version, error) = client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); // Drain initial frame(s). stream .set_read_timeout(Some(Duration::from_secs(2))) .unwrap(); while read_server_message(&mut stream).is_ok() {} // Create a workspace via the API. let mut ws_stream = UnixStream::connect(&api_socket).expect("connect to API"); let request = r#"{"id":"1","method":"workspace.create","params":{}}"#; writeln!(ws_stream, "{}", request).unwrap(); let mut reader = BufReader::new(ws_stream); let mut ws_response = String::new(); reader.read_line(&mut ws_response).unwrap(); // Extract the workspace ID and pane ID from the response. let ws_id = ws_response .split('"') .find(|s| s.starts_with("w_")) .unwrap_or("w_1") .to_string(); // Get pane list to find a pane ID. let mut pane_stream = UnixStream::connect(&api_socket).expect("connect to API"); let pane_request = format!(r#"{{"id":"2","method":"pane.list","params":{{"workspace_id":"{ws_id}"}}}}"#); writeln!(pane_stream, "{}", pane_request).unwrap(); let mut pane_reader = BufReader::new(pane_stream); let mut pane_response = String::new(); pane_reader.read_line(&mut pane_response).unwrap(); // Extract first pane ID (format: p__). let pane_id = pane_response .split('"') .find(|s| s.starts_with("p_")) .unwrap_or("p_1_1") .to_string(); // Report agent as Blocked via the API — this should trigger a // ServerMessage::Notify with kind=Sound (Request sound). let mut report_stream = UnixStream::connect(&api_socket).expect("connect to API"); let report_request = format!( r#"{{"id":"3","method":"pane.report_agent","params":{{"pane_id":"{pane_id}","agent":"pi","state":"blocked","source":"test"}}}}"# ); writeln!(report_stream, "{}", report_request).unwrap(); let mut report_reader = BufReader::new(report_stream); let mut report_response = String::new(); report_reader.read_line(&mut report_response).unwrap(); // Read messages from the client stream and look for Notify (variant 5). // Notify = ServerMessage variant index 5. stream .set_read_timeout(Some(Duration::from_secs(5))) .unwrap(); let mut found_notify = false; let deadline = Instant::now() + Duration::from_secs(5); while Instant::now() < deadline { match read_server_message(&mut stream) { Ok((variant, _payload)) => { if variant == 5 { // ServerMessage::Notify — found it! found_notify = true; break; } // Continue reading — Frame messages (variant 1) will come first. } Err(_) => { break; } } } assert!( found_notify, "client should receive a ServerMessage::Notify after pane.report_agent" ); // Now report Idle from Working — this should trigger a Done sound // if the pane is in a background workspace. // First, create a second workspace to make the first one "background". let mut ws2_stream = UnixStream::connect(&api_socket).expect("connect to API"); let ws2_request = r#"{"id":"4","method":"workspace.create","params":{}}"#; writeln!(ws2_stream, "{}", ws2_request).unwrap(); let mut ws2_reader = BufReader::new(ws2_stream); let mut ws2_response = String::new(); ws2_reader.read_line(&mut ws2_response).unwrap(); // Focus the new workspace (making the first one background). let ws2_id = ws2_response .split('"') .find(|s| s.starts_with("w_")) .unwrap_or("w_2") .to_string(); let mut focus_stream = UnixStream::connect(&api_socket).expect("connect to API"); let focus_request = format!( r#"{{"id":"5","method":"workspace.focus","params":{{"workspace_id":"{ws2_id}"}}}}"# ); writeln!(focus_stream, "{}", focus_request).unwrap(); let mut focus_reader = BufReader::new(focus_stream); let mut focus_response = String::new(); focus_reader.read_line(&mut focus_response).unwrap(); assert!( wait_until(Duration::from_secs(2), Duration::from_millis(25), || { ping_socket(&api_socket).contains("pong") }), "server should stay responsive after workspace focus" ); // Report agent as Working first, then Idle — this transition in a // background workspace should trigger a Done sound notification. let mut work_stream = UnixStream::connect(&api_socket).expect("connect to API"); let work_request = format!( r#"{{"id":"6","method":"pane.report_agent","params":{{"pane_id":"{pane_id}","agent":"pi","state":"working","source":"test"}}}}"# ); writeln!(work_stream, "{}", work_request).unwrap(); let mut work_reader = BufReader::new(work_stream); let mut work_response = String::new(); work_reader.read_line(&mut work_response).unwrap(); assert!( wait_until(Duration::from_secs(2), Duration::from_millis(25), || { ping_socket(&api_socket).contains("pong") }), "server should stay responsive after working state report" ); let mut idle_stream = UnixStream::connect(&api_socket).expect("connect to API"); let idle_request = format!( r#"{{"id":"7","method":"pane.report_agent","params":{{"pane_id":"{pane_id}","agent":"pi","state":"idle","source":"test"}}}}"# ); writeln!(idle_stream, "{}", idle_request).unwrap(); let mut idle_reader = BufReader::new(idle_stream); let mut idle_response = String::new(); idle_reader.read_line(&mut idle_response).unwrap(); // Read messages and look for Done sound notify. stream .set_read_timeout(Some(Duration::from_secs(5))) .unwrap(); let mut found_done_notify = false; let deadline = Instant::now() + Duration::from_secs(5); while Instant::now() < deadline { match read_server_message(&mut stream) { Ok((variant, _payload)) => { if variant == 5 { // Found a Notify message — that's good enough. // The test already verified the Blocked→Notify path above. found_done_notify = true; break; } // Continue reading — Frame messages will come first. } Err(e) => { eprintln!("read error while looking for Done Notify: {e}"); break; } } } assert!( found_done_notify, "client should receive a Sound Notify with 'agent done' when background pane transitions Working→Idle" ); cleanup_spawned_herdr(spawned, base); }