diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-01 15:48:22 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-01 15:48:22 +0300 |
| commit | 207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch) | |
| tree | d7b037ecdf629224a048c841d11659efdc23507d /tests | |
| parent | 8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff) | |
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell
* feat: add client-owned shell mouse controls
* feat: complete client-owned shell chrome
* feat: add client-owned shell settings and notifications
* feat: run client shell custom commands
* feat: render popup terminals in client shell
* feat: add client-owned scrollback and copy mode
* fix: preserve client worktree trust defaults
* feat: render startup diagnostics in client shell
* feat: render onboarding in client shell
* feat: render product announcements in client shell
* feat: render release notes in client shell
* feat: render mobile switcher in client shell
* feat: render kitty graphics in client shell
* fix: preserve client shell lifecycle coherence
* refactor: route client shell commands through endpoint connections
* refactor: make the client-rendered shell the default
* refactor: remove monolithic launch mode
* refactor: complete client-rendered shell cutover
* perf: retain client pane surface updates
* perf: scale retained pane surface updates
* perf: remove remote pane input latency
* test: make client shell checks platform-independent
* fix: prevent client timer starvation
* test: fix client shell platform gates
* test: accept retained surfaces in cross-area checks
* test: make client mode assertions platform-neutral
* fix: badge only outdated integrations
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/api_ping.rs | 2 | ||||
| -rw-r--r-- | tests/auto_detect.rs | 118 | ||||
| -rw-r--r-- | tests/cli.rs | 2 | ||||
| -rw-r--r-- | tests/client_mode.rs | 527 | ||||
| -rw-r--r-- | tests/cross_area.rs | 306 | ||||
| -rw-r--r-- | tests/detach_reattach.rs | 210 | ||||
| -rw-r--r-- | tests/live_handoff.rs | 57 | ||||
| -rw-r--r-- | tests/multi_client.rs | 1162 | ||||
| -rw-r--r-- | tests/server_headless.rs | 205 | ||||
| -rw-r--r-- | tests/support/mod.rs | 152 |
10 files changed, 683 insertions, 2058 deletions
diff --git a/tests/api_ping.rs b/tests/api_ping.rs index 0a52e6a7..b8823ac4 100644 --- a/tests/api_ping.rs +++ b/tests/api_ping.rs @@ -1,4 +1,4 @@ -mod support; +pub mod support; use std::fs; use std::io::{Read, Write}; diff --git a/tests/auto_detect.rs b/tests/auto_detect.rs index 94ab3f07..49473dbb 100644 --- a/tests/auto_detect.rs +++ b/tests/auto_detect.rs @@ -2,7 +2,7 @@ #![cfg(not(target_os = "macos"))] -mod support; +pub mod support; use std::fs; use std::io::{BufRead, BufReader, Write}; @@ -150,7 +150,7 @@ fn spawn_herdr_auto( .unwrap(); let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); - // No subcommand, no --no-session → auto-detect launch + // No subcommand → 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); @@ -168,48 +168,6 @@ fn spawn_herdr_auto( } } -/// 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"); @@ -423,50 +381,6 @@ fn auto_detect_socket_path_consistency() { 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() { @@ -701,34 +615,6 @@ fn auto_detect_writes_client_and_server_logs_to_separate_files() { } #[test] -fn no_session_writes_startup_logs_to_monolith_file() { - 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 spawned = spawn_herdr_no_session(&config_home, &runtime_dir, &api_socket); - wait_for_socket(&api_socket, Duration::from_secs(10)); - - let app_dir_name = if cfg!(debug_assertions) { - "herdr-dev" - } else { - "herdr" - }; - let log_dir = config_home.join(app_dir_name); - let monolith_log = log_dir.join("herdr.log"); - - wait_for_log_contains( - &monolith_log, - "event=\"app.startup\" subsystem=\"app\"", - Duration::from_secs(10), - ); - - cleanup_spawned_herdr(spawned, base); -} - -#[test] fn auto_detect_respects_nested_guard_before_auto_attach() { let _lock = test_lock(); let base = unique_test_dir(); diff --git a/tests/cli.rs b/tests/cli.rs index c7eb2834..ff238996 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,6 +1,6 @@ #![cfg(not(target_os = "macos"))] -mod support; +pub mod support; #[path = "cli/mod.rs"] mod cases; diff --git a/tests/client_mode.rs b/tests/client_mode.rs index 5112182d..c6c76c34 100644 --- a/tests/client_mode.rs +++ b/tests/client_mode.rs @@ -2,7 +2,7 @@ #![cfg(unix)] -mod support; +pub mod support; use std::fs; use std::io::{BufRead, BufReader, Read, Write}; @@ -13,12 +13,13 @@ 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, + cleanup_test_base, client_shell_handshake, read_server_message, register_runtime_dir, + register_spawned_herdr_pid, unregister_spawned_herdr_pid, wait_for_client_shell_bootstrap, + wait_for_message_variant, wait_for_message_variants, wait_for_socket, wait_until, + CURRENT_PROTOCOL, SERVER_MESSAGE_PANE_SURFACE, SERVER_MESSAGE_PANE_SURFACE_PATCH, + SERVER_MESSAGE_SEMANTIC_NOTIFICATION, SERVER_MESSAGE_SERVER_SHUTDOWN, }; fn unique_test_dir() -> PathBuf { @@ -86,12 +87,30 @@ fn spawn_client_process( spawn_client_process_with_args(config_home, runtime_dir, api_socket_path, &["client"]) } +fn spawn_client_shell_process( + config_home: &PathBuf, + runtime_dir: &PathBuf, + api_socket_path: &PathBuf, +) -> SpawnedHerdr { + spawn_client_process_with_args(config_home, runtime_dir, api_socket_path, &["client"]) +} + fn spawn_client_process_with_args( config_home: &PathBuf, runtime_dir: &PathBuf, api_socket_path: &PathBuf, args: &[&str], ) -> SpawnedHerdr { + spawn_client_process_with_args_and_env(config_home, runtime_dir, api_socket_path, args, &[]) +} + +fn spawn_client_process_with_args_and_env( + config_home: &PathBuf, + runtime_dir: &PathBuf, + api_socket_path: &PathBuf, + args: &[&str], + extra_env: &[(&str, &str)], +) -> SpawnedHerdr { register_runtime_dir(runtime_dir); let pair = native_pty_system() .openpty(PtySize { @@ -111,47 +130,10 @@ fn spawn_client_process_with_args( 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, + for (key, value) in extra_env { + cmd.env(key, value); } -} -fn spawn_no_session_process(config_home: &PathBuf, runtime_dir: &PathBuf) -> SpawnedHerdr { - 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[ui]\nwindow_title = \"monolithic\"\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("SHELL", "/bin/sh"); - cmd.env_remove("HERDR_ENV"); - cmd.env_remove("HERDR_SOCKET_PATH"); - cmd.env_remove("HERDR_CLIENT_SOCKET_PATH"); - cmd.env_remove("HERDR_SESSION"); - cmd.env_remove("HERDR_WORKSPACE_ID"); - cmd.env_remove("HERDR_TAB_ID"); - cmd.env_remove("HERDR_PANE_ID"); let child = pair.slave.spawn_command(cmd).unwrap(); register_spawned_herdr_pid(child.process_id()); drop(pair.slave); @@ -266,100 +248,12 @@ fn app_dir_name() -> &'static str { } } -#[allow(dead_code)] -#[derive(Debug, Deserialize)] -struct FrameWire { - cells: Vec<CellWire>, - width: u16, - height: u16, - cursor: Option<CursorWire>, - hyperlinks: Vec<String>, - graphics: Vec<u8>, -} - -#[allow(dead_code)] -#[derive(Debug, Deserialize)] -struct CellWire { - symbol: String, - fg: u32, - bg: u32, - modifier: u16, - skip: bool, - hyperlink: Option<u32>, -} - -#[derive(Debug, Deserialize)] -struct CursorWire { - x: u16, - y: u16, - visible: bool, - shape: u8, -} - -fn decode_frame_payload(payload: &[u8]) -> std::io::Result<FrameWire> { - 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<Vec<u8>, 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_text(frame: &FrameWire) -> String { - if frame.cells.is_empty() { - return String::new(); - } - - 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 -} - // --------------------------------------------------------------------------- // 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. +fn client_connects_and_receives_pane_surface() { let _lock = test_lock(); let base = unique_test_dir(); let config_home = base.join("config"); @@ -371,22 +265,13 @@ fn client_connects_and_receives_frame() { 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"); + let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 54, 23) + .expect("handshake should succeed"); + assert_eq!(version, CURRENT_PROTOCOL); + assert!(error.is_none(), "{error:?}"); + wait_for_client_shell_bootstrap(&mut stream, Duration::from_secs(10)) + .expect("should receive the shell snapshot and pane surface"); cleanup_spawned_herdr(spawned, base); } @@ -560,41 +445,26 @@ fn client_sees_headless_startup_config_diagnostic() { 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; - let mut last_frame_text = String::new(); - while Instant::now() < deadline { - match read_server_message(&mut stream) { - Ok((1, payload)) => { - let frame = decode_frame_payload(&payload).expect("decode frame"); - last_frame_text = frame_text(&frame); - if last_frame_text.contains("config.toml") - && last_frame_text.contains("herdr config check") - { - found_diagnostic = true; - break; - } - } - Ok(_) => {} - Err(_) => break, - } - } - + let client = spawn_client_shell_process(&config_home, &runtime_dir, &api_socket); + let output = spawn_pty_drain( + client + ._master + .as_ref() + .expect("client shell master") + .try_clone_reader() + .expect("clone client shell reader"), + ); assert!( - found_diagnostic, - "attached client should see startup config parse diagnostic; last frame:\n{last_frame_text}" + wait_until(Duration::from_secs(8), Duration::from_millis(20), || { + let output = read_output(&output); + output.contains("config.toml") && output.contains("herdr config check") + }), + "client shell should render startup config diagnostic; output: {:?}", + read_output(&output) ); - cleanup_spawned_herdr(spawned, base); + drop(spawned); + cleanup_spawned_herdr(client, base); } #[test] @@ -881,6 +751,120 @@ fn attach_thin_client_with_config( (spawned_server, thin_client, output) } +#[test] +fn client_shell_detaches_restores_and_freshly_reattaches_to_current_state() { + 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 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 created = send_json_request( + &api_socket, + &serde_json::json!({ + "id": "client-shell-lifecycle-workspace", + "method": "workspace.create", + "params": {"cwd": base, "focus": true, "label": "shell-lifecycle"}, + }) + .to_string(), + ); + assert_eq!(created["result"]["type"], "workspace_created", "{created}"); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .expect("root pane id") + .to_string(); + send_pane_shell_command(&api_socket, &pane_id, "printf 'SHELL_LIFECYCLE_INITIAL\\n'"); + + let mut client_a = spawn_client_shell_process(&config_home, &runtime_dir, &api_socket); + let output_a = spawn_pty_drain( + client_a + ._master + .as_ref() + .expect("first client shell PTY") + .try_clone_reader() + .expect("clone first client shell reader"), + ); + assert!( + wait_until(Duration::from_secs(8), Duration::from_millis(20), || { + let output = read_output(&output_a); + output.contains("shell-lifecycle") && output.contains("SHELL_LIFECYCLE_INITIAL") + }), + "client shell should compose one coherent snapshot and pane surface; output: {:?}", + read_output(&output_a) + ); + + let detach_watermark = output_len(&output_a); + client_a + ._master + .as_ref() + .expect("first client shell PTY") + .take_writer() + .expect("first client shell writer") + .write_all(b"\x02q") + .expect("detach first client shell"); + let detach_output = drain_until_client_exits(&mut client_a, &output_a, detach_watermark); + assert!( + output_has_mouse_teardown(&detach_output), + "client shell should restore the host terminal after detach; output: {detach_output:?}" + ); + assert!( + ping_socket(&api_socket).contains("pong"), + "server should remain alive after client shell detach" + ); + drop(client_a); + + send_pane_shell_command( + &api_socket, + &pane_id, + "printf 'SHELL_LIFECYCLE_DETACHED\\n'", + ); + let mut client_b = spawn_client_shell_process(&config_home, &runtime_dir, &api_socket); + let output_b = spawn_pty_drain( + client_b + ._master + .as_ref() + .expect("reattached client shell PTY") + .try_clone_reader() + .expect("clone reattached client shell reader"), + ); + assert!( + wait_until(Duration::from_secs(8), Duration::from_millis(20), || { + let output = read_output(&output_b); + output.contains("shell-lifecycle") && output.contains("SHELL_LIFECYCLE_DETACHED") + }), + "fresh client shell should receive current state and detached-period output; output: {:?}", + read_output(&output_b) + ); + + let disconnect_watermark = output_len(&output_b); + if let Some(pid) = server.child.process_id() { + unsafe { + libc::kill(pid as libc::pid_t, libc::SIGKILL); + } + } + server.close_master(); + let disconnect_output = + drain_until_client_exits(&mut client_b, &output_b, disconnect_watermark); + assert!( + output_has_mouse_teardown(&disconnect_output), + "client shell should restore the host terminal after endpoint loss; output: {disconnect_output:?}" + ); + assert!( + disconnect_output + .to_lowercase() + .contains("lost connection to server"), + "client shell should explain endpoint loss; output: {disconnect_output:?}" + ); + + drop(server); + cleanup_spawned_herdr(client_b, base); +} + fn captured_window_titles(output: &SharedOutput) -> Vec<String> { read_output(output) .split("\x1b]0;") @@ -943,26 +927,6 @@ fn send_pane_shell_command(socket_path: &PathBuf, pane_id: &str, command: &str) } #[test] -fn configured_window_title_is_emitted_in_no_session_mode() { - let _lock = test_lock(); - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let no_session = spawn_no_session_process(&config_home, &runtime_dir); - let reader = no_session - ._master - .as_ref() - .expect("no-session PTY master") - .try_clone_reader() - .expect("clone no-session PTY reader"); - let output = spawn_pty_drain(reader); - - wait_for_window_title(&output, "monolithic"); - - cleanup_spawned_herdr(no_session, base); -} - -#[test] fn configured_window_title_tracks_all_tokens_and_focused_osc_only() { let _lock = test_lock(); let base = unique_test_dir(); @@ -1029,7 +993,7 @@ fn configured_window_title_tracks_all_tokens_and_focused_osc_only() { "hostname token was empty: {renamed}" ); - send_pane_shell_command(&api_socket, &pane_id, r"printf '\033]0;⠋ building\007'"); + send_pane_shell_command(&api_socket, &pane_id, r"printf '\033]0;building\007'"); wait_for_window_title(&output, "|W=space-a|T=tab-a|P=pane-a|O=building"); let second_tab = send_json_request( @@ -1308,14 +1272,7 @@ fn client_exits_cleanly_when_terminal_hangs_up() { } #[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 +fn client_receives_pane_surface_after_pane_output() { let _lock = test_lock(); let base = unique_test_dir(); let config_home = base.join("config"); @@ -1327,33 +1284,55 @@ fn client_receives_frame_after_pane_output() { 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"); + let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 54, 23) + .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"); + assert!(error.is_none(), "{error:?}"); + wait_for_client_shell_bootstrap(&mut stream, Duration::from_secs(10)) + .expect("initial client shell bootstrap"); - // 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"); + let created = send_json_request( + &api_socket, + &serde_json::json!({ + "id": "create-output-workspace", + "method": "workspace.create", + "params": {"label": "output", "focus": true} + }) + .to_string(), + ); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .expect("root pane id"); + assert!(wait_for_message_variant( + &mut stream, + Duration::from_secs(5), + SERVER_MESSAGE_PANE_SURFACE, + ) + .expect("wait for created workspace surface")); + + let sent = send_json_request( + &api_socket, + &serde_json::json!({ + "id": "send-output", + "method": "pane.send_text", + "params": {"pane_id": pane_id, "text": "printf 'test-output\\n'\\n"} + }) + .to_string(), + ); + assert!(sent.get("error").is_none(), "{sent}"); + assert!( + wait_for_message_variants( + &mut stream, + Duration::from_secs(5), + &[ + SERVER_MESSAGE_PANE_SURFACE, + SERVER_MESSAGE_PANE_SURFACE_PATCH, + ], + ) + .expect("wait for post-output pane surface"), + "should receive a pane surface update after pane output" + ); cleanup_spawned_herdr(spawned, base); } @@ -1424,7 +1403,25 @@ fn pane_spawn_cwd_fallback_in_server() { "fallback cwd should exist: {cwd}" ); - cleanup_spawned_herdr(spawned, base); + let client_shell = spawn_client_shell_process(&config_home, &runtime_dir, &api_socket); + let output = spawn_pty_drain( + client_shell + ._master + .as_ref() + .expect("restored client shell PTY") + .try_clone_reader() + .expect("clone restored client shell reader"), + ); + assert!( + wait_until(Duration::from_secs(8), Duration::from_millis(20), || { + read_output(&output).contains("missing-cwd") + }), + "client shell should render the restored session; output: {:?}", + read_output(&output) + ); + + drop(spawned); + cleanup_spawned_herdr(client_shell, base); } #[test] @@ -1442,18 +1439,13 @@ fn graceful_shutdown_sends_server_shutdown_to_client() { 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"); + let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 54, 23) + .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() {} + assert!(error.is_none(), "{error:?}"); + wait_for_client_shell_bootstrap(&mut stream, Duration::from_secs(5)) + .expect("client shell bootstrap"); // Send SIGINT to the server process to trigger graceful shutdown. if let Some(pid) = spawned.child.process_id() { @@ -1462,7 +1454,7 @@ fn graceful_shutdown_sends_server_shutdown_to_client() { } } - // The client should receive a ServerShutdown message (variant 4) + // The client should receive a ServerShutdown message // before the connection is closed, not just an abrupt EOF. stream .set_read_timeout(Some(Duration::from_secs(5))) @@ -1471,8 +1463,8 @@ fn graceful_shutdown_sends_server_shutdown_to_client() { match result { Ok((variant, _payload)) => { assert_eq!( - variant, 4, - "expected ServerShutdown (variant 4), got variant {variant}" + variant, SERVER_MESSAGE_SERVER_SHUTDOWN, + "expected ServerShutdown, got variant {variant}" ); } Err(e) => { @@ -1501,9 +1493,9 @@ fn client_receives_notify_on_agent_state_change() { 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::create_dir_all(config_home.join(app_dir_name())).unwrap(); fs::write( - config_home.join("herdr/config.toml"), + config_home.join(app_dir_name()).join("config.toml"), "onboarding = false\n[ui.toast]\nenabled = true\n[ui.sound]\nenabled = true\n", ) .unwrap(); @@ -1541,18 +1533,13 @@ fn client_receives_notify_on_agent_state_change() { 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"); + let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 54, 23) + .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() {} + assert!(error.is_none(), "{error:?}"); + wait_for_client_shell_bootstrap(&mut stream, Duration::from_secs(5)) + .expect("client shell bootstrap"); // Create a workspace via the API. let mut ws_stream = UnixStream::connect(&api_socket).expect("connect to API"); @@ -1596,8 +1583,7 @@ fn client_receives_notify_on_agent_state_change() { 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. + // Read messages from the client stream and look for the semantic notification. stream .set_read_timeout(Some(Duration::from_secs(5))) .unwrap(); @@ -1606,12 +1592,11 @@ fn client_receives_notify_on_agent_state_change() { while Instant::now() < deadline { match read_server_message(&mut stream) { Ok((variant, _payload)) => { - if variant == 5 { - // ServerMessage::Notify — found it! + if variant == SERVER_MESSAGE_SEMANTIC_NOTIFICATION { found_notify = true; break; } - // Continue reading — Frame messages (variant 1) will come first. + // Snapshot and pane-surface messages may arrive first. } Err(_) => { break; @@ -1621,7 +1606,7 @@ fn client_receives_notify_on_agent_state_change() { assert!( found_notify, - "client should receive a ServerMessage::Notify after pane.report_agent" + "client should receive a semantic notification after pane.report_agent" ); // Now report Idle from Working — this should trigger a Done sound @@ -1683,7 +1668,7 @@ fn client_receives_notify_on_agent_state_change() { let mut idle_response = String::new(); idle_reader.read_line(&mut idle_response).unwrap(); - // Read messages and look for Done sound notify. + // Read messages and look for the done semantic notification. stream .set_read_timeout(Some(Duration::from_secs(5))) .unwrap(); @@ -1692,16 +1677,14 @@ fn client_receives_notify_on_agent_state_change() { 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. + if variant == SERVER_MESSAGE_SEMANTIC_NOTIFICATION { found_done_notify = true; break; } - // Continue reading — Frame messages will come first. + // Snapshot and pane-surface messages may arrive first. } Err(e) => { - eprintln!("read error while looking for Done Notify: {e}"); + eprintln!("read error while looking for done notification: {e}"); break; } } @@ -1709,7 +1692,7 @@ fn client_receives_notify_on_agent_state_change() { assert!( found_done_notify, - "client should receive a Sound Notify with 'agent done' when background pane transitions Working→Idle" + "client should receive a semantic notification when a background pane transitions Working→Idle" ); cleanup_spawned_herdr(spawned, base); diff --git a/tests/cross_area.rs b/tests/cross_area.rs index 6f1ab82b..9b856cb8 100644 --- a/tests/cross_area.rs +++ b/tests/cross_area.rs @@ -1,6 +1,6 @@ //! Cross-area integration tests for end-to-end persistence flows. -mod support; +pub mod support; use std::fs; use std::io::{self, BufRead, BufReader, Read, Write}; @@ -11,11 +11,11 @@ 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::{json, Value}; use support::{ - cleanup_test_base, register_runtime_dir, register_spawned_herdr_pid, - unregister_spawned_herdr_pid, CURRENT_PROTOCOL, + cleanup_test_base, client_shell_handshake, register_runtime_dir, register_spawned_herdr_pid, + unregister_spawned_herdr_pid, CURRENT_PROTOCOL, SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT, + SERVER_MESSAGE_PANE_SURFACE, SERVER_MESSAGE_PANE_SURFACE_PATCH, }; fn unique_test_dir() -> PathBuf { @@ -196,7 +196,7 @@ fn workspace_create(socket_path: &Path, label: &str) -> Value { socket_path, "workspace_create", "workspace.create", - json!({ "label": label }), + json!({ "label": label, "focus": true }), ) } @@ -365,16 +365,6 @@ fn encode_varint_u32(v: u32) -> Vec<u8> { } } -fn encode_varint_u16(v: u16) -> Vec<u8> { - if v < 251 { - vec![v as u8] - } else { - let mut buf = vec![251u8]; - buf.extend_from_slice(&v.to_le_bytes()); - buf - } -} - fn frame_message(payload: &[u8]) -> Vec<u8> { let mut framed = (payload.len() as u32).to_le_bytes().to_vec(); framed.extend_from_slice(payload); @@ -414,76 +404,6 @@ fn decode_varint_u32(payload: &[u8], offset: usize) -> Result<(u32, usize), Stri } } -fn client_handshake(stream: &mut UnixStream, version: u32, cols: u16, rows: u16) { - stream - .set_read_timeout(Some(Duration::from_secs(5))) - .expect("set read timeout"); - - // ClientMessage::Hello = variant 0 - let mut payload = encode_varint_u32(0); - payload.extend_from_slice(&encode_varint_u32(version)); - payload.extend_from_slice(&encode_varint_u16(cols)); - payload.extend_from_slice(&encode_varint_u16(rows)); - payload.extend_from_slice(&encode_varint_u32(8)); // cell_width_px - payload.extend_from_slice(&encode_varint_u32(16)); // cell_height_px - payload.extend_from_slice(&encode_varint_u32(0)); // RenderEncoding::SemanticFrame - payload.extend_from_slice(&encode_varint_u32(0)); // ClientKeybindings::Server - payload.extend_from_slice(&encode_varint_u32(0)); // ClientLaunchMode::App - - stream - .write_all(&frame_message(&payload)) - .expect("write hello"); - stream.flush().expect("flush hello"); - - let mut len_buf = [0u8; 4]; - stream - .read_exact(&mut len_buf) - .expect("read welcome length"); - let len = u32::from_le_bytes(len_buf) as usize; - assert!(len > 0 && len <= 2 * 1024 * 1024, "unexpected welcome size"); - - let mut welcome_payload = vec![0u8; len]; - stream - .read_exact(&mut welcome_payload) - .expect("read welcome payload"); - - let mut offset = 0; - let (variant, consumed) = decode_varint_u32(&welcome_payload, offset).expect("decode variant"); - offset += consumed; - assert_eq!(variant, 0, "expected ServerMessage::Welcome variant"); - - let (_server_version, consumed) = - decode_varint_u32(&welcome_payload, offset).expect("decode version"); - offset += consumed; - - let (_encoding, consumed) = - decode_varint_u32(&welcome_payload, offset).expect("decode render encoding"); - offset += consumed; - - let option_tag = *welcome_payload - .get(offset) - .expect("welcome payload should contain Option tag"); - if option_tag == 1 { - let (str_len, consumed) = - decode_varint_u32(&welcome_payload, offset + 1).expect("decode error length"); - let start = offset + 1 + consumed; - let end = start + str_len as usize; - let err = String::from_utf8(welcome_payload[start..end].to_vec()).expect("utf8 error"); - panic!("handshake rejected: {err}"); - } -} - -fn send_client_input(stream: &mut UnixStream, data: &[u8]) { - // ClientMessage::Input = variant 1 - let mut payload = encode_varint_u32(1); - payload.extend_from_slice(&encode_varint_u32(data.len() as u32)); - payload.extend_from_slice(data); - stream - .write_all(&frame_message(&payload)) - .expect("write input"); - stream.flush().expect("flush input"); -} - fn send_client_detach(stream: &mut UnixStream) { // ClientMessage::Detach = variant 4 let payload = encode_varint_u32(4); @@ -500,112 +420,8 @@ fn is_timeout(err: &io::Error) -> bool { ) } -#[allow(dead_code)] -#[derive(Debug, Deserialize)] -struct FrameWire { - cells: Vec<CellWire>, - width: u16, - height: u16, - cursor: Option<CursorWire>, - hyperlinks: Vec<String>, - graphics: Vec<u8>, -} - -#[allow(dead_code)] -#[derive(Debug, Deserialize)] -struct CellWire { - symbol: String, - fg: u32, - bg: u32, - modifier: u16, - skip: bool, - hyperlink: Option<u32>, -} - -#[derive(Debug, Deserialize)] -struct CursorWire { - x: u16, - y: u16, - visible: bool, - shape: u8, -} - -fn decode_frame_payload(payload: &[u8]) -> io::Result<FrameWire> { - bincode::serde::decode_from_slice(payload, bincode::config::standard()) - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string())) - .and_then(|(frame, consumed): (FrameWire, usize)| { - if consumed != payload.len() { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!( - "frame payload had trailing bytes: consumed={}, len={}", - consumed, - payload.len() - ), - )); - } - Ok(frame) - }) -} - -fn frame_contains_colored_symbol(frame: &FrameWire, symbol: &str, rgb: (u8, u8, u8)) -> bool { - let (r, g, b) = rgb; - let fg = 0x02_00_00_00 | (u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b); - frame - .cells - .iter() - .any(|cell| cell.symbol == symbol && cell.fg == fg) -} - -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) -} - fn read_server_variant(stream: &mut UnixStream, timeout: Duration) -> io::Result<u32> { stream.set_read_timeout(Some(timeout))?; - - let mut len_buf = [0u8; 4]; - stream.read_exact(&mut len_buf)?; - let len = u32::from_le_bytes(len_buf) as usize; - if len == 0 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "zero-length payload", - )); - } - - let mut payload = vec![0u8; len]; - stream.read_exact(&mut payload)?; - - let (variant, _consumed) = decode_varint_u32(&payload, 0) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - Ok(variant) -} - -fn read_server_message_payload( - stream: &mut UnixStream, - timeout: Duration, -) -> io::Result<(u32, Vec<u8>)> { - stream.set_read_timeout(Some(timeout))?; - let mut len_buf = [0u8; 4]; stream.read_exact(&mut len_buf)?; let len = u32::from_le_bytes(len_buf) as usize; @@ -615,49 +431,20 @@ fn read_server_message_payload( "zero-length payload", )); } - let mut payload = vec![0u8; len]; stream.read_exact(&mut payload)?; - - let (variant, consumed) = decode_varint_u32(&payload, 0) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - Ok((variant, payload[consumed..].to_vec())) -} - -fn wait_for_frame_matching( - stream: &mut UnixStream, - timeout: Duration, - predicate: impl Fn(&FrameWire) -> bool, -) -> io::Result<bool> { - let deadline = Instant::now() + timeout; - while Instant::now() < deadline { - let slice = deadline - .saturating_duration_since(Instant::now()) - .min(Duration::from_millis(80)); - match read_server_message_payload(stream, slice) { - Ok((1, payload)) => { - let frame = decode_frame_payload(&payload)?; - if predicate(&frame) { - return Ok(true); - } - } - Ok((_variant, _payload)) => {} - Err(err) if is_timeout(&err) => {} - Err(err) => return Err(err), - } - } - - Ok(false) + decode_varint_u32(&payload, 0) + .map(|(variant, _)| variant) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) } fn wait_for_frame(stream: &mut UnixStream, timeout: Duration) -> bool { let deadline = Instant::now() + timeout; while Instant::now() < deadline { - let slice = deadline - .saturating_duration_since(Instant::now()) - .min(Duration::from_millis(80)); + let slice = deadline.saturating_duration_since(Instant::now()); match read_server_variant(stream, slice) { - Ok(1) => return true, // ServerMessage::Frame + Ok(SERVER_MESSAGE_PANE_SURFACE | SERVER_MESSAGE_PANE_SURFACE_PATCH) => return true, + Ok(SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT) => {} Ok(_) => {} Err(err) if is_timeout(&err) => {} Err(_) => return false, @@ -669,7 +456,7 @@ fn wait_for_frame(stream: &mut UnixStream, timeout: Duration) -> bool { fn drain_server_messages(stream: &mut UnixStream, max_drain: Duration) { let deadline = Instant::now() + max_drain; while Instant::now() < deadline { - match read_server_variant(stream, Duration::from_millis(40)) { + match read_server_variant(stream, deadline.saturating_duration_since(Instant::now())) { Ok(_) => {} Err(err) if is_timeout(&err) => break, Err(_) => break, @@ -696,7 +483,7 @@ fn cross_area_detach_and_reattach_preserves_state() { // Local attach (client A). let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect"); - client_handshake(&mut client_a, CURRENT_PROTOCOL, 100, 30); + client_shell_handshake(&mut client_a, CURRENT_PROTOCOL, 100, 30).expect("shell handshake"); assert!(wait_for_frame(&mut client_a, Duration::from_secs(2))); // Use herdr: create a workspace and write output into its pane. @@ -733,7 +520,7 @@ fn cross_area_detach_and_reattach_preserves_state() { // Reattach from another terminal/session (client B). let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect"); - client_handshake(&mut client_b, CURRENT_PROTOCOL, 80, 24); + client_shell_handshake(&mut client_b, CURRENT_PROTOCOL, 80, 24).expect("shell handshake"); assert!( wait_for_frame(&mut client_b, Duration::from_secs(5)), "reattached client should receive frame" @@ -789,7 +576,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() { wait_for_socket(&client_socket, Duration::from_secs(10)); let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect"); - client_handshake(&mut client_a, CURRENT_PROTOCOL, 100, 30); + client_shell_handshake(&mut client_a, CURRENT_PROTOCOL, 100, 30).expect("shell handshake"); assert!(wait_for_frame(&mut client_a, Duration::from_secs(2))); let created = workspace_create(&api_socket, "agent-persist"); @@ -840,18 +627,10 @@ fn cross_area_agent_process_survives_detach_and_reattach() { "agent status should remain working while detached" ); - // Reattach and ensure client-side state reflects the persisted working status. + // Reattach and verify the persisted projection through the API. let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect"); - client_handshake(&mut client_b, CURRENT_PROTOCOL, 80, 24); - let saw_working_on_client = - wait_for_frame_matching(&mut client_b, Duration::from_secs(5), |frame| { - frame_contains_colored_symbol(frame, "●", (249, 226, 175)) - }) - .expect("frame decoding should succeed"); - assert!( - saw_working_on_client, - "reattached client frame should expose persisted agent working status" - ); + client_shell_handshake(&mut client_b, CURRENT_PROTOCOL, 80, 24).expect("shell handshake"); + assert!(wait_for_frame(&mut client_b, Duration::from_secs(5))); // Transition to blocked and verify API + client surfaces both observe it. // The fake process remains visibly working, so blocked is the deterministic @@ -862,15 +641,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() { "pane agent status should transition to blocked" ); - let saw_blocked_on_client = - wait_for_frame_matching(&mut client_b, Duration::from_secs(5), |frame| { - frame_contains_colored_symbol(frame, "●", (243, 139, 168)) - }) - .expect("frame decoding should succeed"); - assert!( - saw_blocked_on_client, - "reattached client frame should show blocked status after transition" - ); + // The API status above is the stable cross-area contract for this transition. cleanup_spawned_herdr(server, base); } @@ -889,7 +660,7 @@ fn cross_area_client_and_api_workspace_views_are_consistent() { wait_for_socket(&client_socket, Duration::from_secs(10)); let mut client = UnixStream::connect(&client_socket).expect("client should connect"); - client_handshake(&mut client, CURRENT_PROTOCOL, 100, 30); + client_shell_handshake(&mut client, CURRENT_PROTOCOL, 100, 30).expect("shell handshake"); assert!(wait_for_frame(&mut client, Duration::from_secs(2))); drain_server_messages(&mut client, Duration::from_millis(300)); @@ -902,17 +673,8 @@ fn cross_area_client_and_api_workspace_views_are_consistent() { .expect("workspace.create should return workspace_id") .to_string(); - // The attached client must receive a frame that includes the new workspace - // label, proving client-side state reflects the API surface. - let saw_workspace_on_client = - wait_for_frame_matching(&mut client, Duration::from_secs(3), |frame| { - frame_contains_text(frame, "api-visible-workspace") - }) - .expect("frame decoding should succeed"); - assert!( - saw_workspace_on_client, - "client-side frame should include the newly created workspace label" - ); + // ClientShell state is asserted through the authoritative API projection below; + // do not decode client-composed UI to duplicate that assertion. let deadline = Instant::now() + Duration::from_secs(5); let mut count_reached = false; @@ -952,9 +714,9 @@ fn cross_area_two_clients_shared_view_and_single_detach_stability() { wait_for_socket(&client_socket, Duration::from_secs(10)); let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect"); - client_handshake(&mut client_a, CURRENT_PROTOCOL, 110, 30); + client_shell_handshake(&mut client_a, CURRENT_PROTOCOL, 110, 30).expect("shell handshake"); let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect"); - client_handshake(&mut client_b, CURRENT_PROTOCOL, 100, 30); + client_shell_handshake(&mut client_b, CURRENT_PROTOCOL, 100, 30).expect("shell handshake"); assert!(wait_for_frame(&mut client_a, Duration::from_secs(2))); assert!(wait_for_frame(&mut client_b, Duration::from_secs(2))); @@ -968,7 +730,7 @@ fn cross_area_two_clients_shared_view_and_single_detach_stability() { .to_string(); // Input from client A should update shared state visible to client B. - send_client_input(&mut client_a, b"echo SHARED_VIEW\n"); + pane_send_text(&api_socket, &pane_id, "echo SHARED_VIEW\n"); assert!( wait_for_frame(&mut client_b, Duration::from_secs(2)), "client B should receive update from client A" @@ -982,19 +744,24 @@ fn cross_area_two_clients_shared_view_and_single_detach_stability() { // Detach client A; client B should keep working. send_client_detach(&mut client_a); - drop(client_a); - - send_client_input(&mut client_b, b"echo AFTER_A_DETACH\n"); assert!( - wait_for_frame(&mut client_b, Duration::from_secs(2)), - "remaining client should still receive frames after other client detaches" + support::wait_for_disconnect(&mut client_a, Duration::from_secs(2)) + .expect("wait for client A detach"), + "client A should disconnect before the remaining-client assertion" ); + drop(client_a); + + pane_send_text(&api_socket, &pane_id, "echo AFTER_A_DETACH\n"); assert!(pane_read_recent_contains( &api_socket, &pane_id, "AFTER_A_DETACH", Duration::from_secs(5) )); + assert!( + wait_for_frame(&mut client_b, Duration::from_secs(2)), + "remaining client should still receive frames after other client detaches" + ); let ping = ping_socket(&api_socket); assert!( @@ -1123,7 +890,8 @@ fn cross_area_server_kill_then_restart_and_reconnect() { let mut reconnect_client = UnixStream::connect(&client_socket).expect("new client should connect after restart"); - client_handshake(&mut reconnect_client, CURRENT_PROTOCOL, 80, 24); + client_shell_handshake(&mut reconnect_client, CURRENT_PROTOCOL, 80, 24) + .expect("shell handshake"); assert!( wait_for_frame(&mut reconnect_client, Duration::from_secs(5)), "new client should receive frame after restart" diff --git a/tests/detach_reattach.rs b/tests/detach_reattach.rs index 4461a17b..f570e979 100644 --- a/tests/detach_reattach.rs +++ b/tests/detach_reattach.rs @@ -1,7 +1,7 @@ //! Integration tests for detach/reattach flow. //! -mod support; +pub mod support; use std::fs; use std::io::{BufRead, BufReader, Write}; @@ -14,9 +14,9 @@ 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, client_handshake, drain_messages, read_server_message, register_runtime_dir, - register_spawned_herdr_pid, send_detach, send_input, unregister_spawned_herdr_pid, - wait_for_disconnect, wait_for_message_variant, wait_for_socket, wait_until, CURRENT_PROTOCOL, + cleanup_test_base, client_shell_handshake, drain_messages, register_runtime_dir, + register_spawned_herdr_pid, send_detach, unregister_spawned_herdr_pid, wait_for_disconnect, + wait_for_socket, wait_until, CURRENT_PROTOCOL, }; const CUSTOM_HEADLESS_SIZE_CONFIG: &str = r#"onboarding = false @@ -280,72 +280,6 @@ fn first_pane_id(response: &Value) -> String { // --------------------------------------------------------------------------- #[test] -fn navigate_q_detaches_client_and_server_persists() { - // In persistence mode, navigate-mode q detaches the client and the server persists. - // Flow: - // 1. Start server - // 2. Connect client, handshake - // 3. Send prefix key (Ctrl+B) then 'q' - // 4. Verify server is still alive via API ping - // 5. Verify the client connection is closed - 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); - - // Drain initial frames. - drain_messages(&mut stream); - - // Send prefix key (Ctrl+B = 0x02) then 'q' (quit/detach in persistence mode). - send_input(&mut stream, &[0x02]).expect("send prefix"); - - // Drain any frames generated by entering navigate mode. - drain_messages(&mut stream); - - send_input(&mut stream, b"q").expect("send detach key"); - - assert!( - wait_until(Duration::from_secs(2), Duration::from_millis(25), || { - ping_socket(&api_socket).contains("pong") - }), - "server should still respond to ping after client detach" - ); - - // Verify server is still alive and responsive. - let response = ping_socket(&api_socket); - assert!( - response.contains("pong"), - "server should still respond to ping after client detach: {response}" - ); - - // The client should receive a ServerShutdown with reason "detached" - // shortly after the quit/detach key. There may be some frames in - // between from the mode change, so we read multiple messages. - let got_shutdown = wait_for_message_variant(&mut stream, Duration::from_secs(2), 2) - .expect("wait for shutdown message") - || wait_for_disconnect(&mut stream, Duration::from_secs(1)).expect("wait for disconnect"); - assert!( - got_shutdown, - "client should receive ServerShutdown after quit/detach key" - ); - - cleanup_spawned_herdr(spawned, base); -} - -#[test] fn explicit_detach_message_causes_clean_disconnect() { // Client sends ClientMessage::Detach // directly (not via keybind), server handles it gracefully. @@ -363,8 +297,8 @@ fn explicit_detach_message_causes_clean_disconnect() { // Connect and 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"); + let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24) + .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); @@ -422,7 +356,7 @@ fn reattach_after_detach_shows_current_state() { // --- Client A --- let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect"); - let (version, error) = client_handshake(&mut stream_a, CURRENT_PROTOCOL, 80, 24) + let (version, error) = client_shell_handshake(&mut stream_a, CURRENT_PROTOCOL, 80, 24) .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); @@ -461,7 +395,7 @@ fn reattach_after_detach_shows_current_state() { // --- Client B (reattach) --- let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect"); - let (version, error) = client_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24) + let (version, error) = client_shell_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24) .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!( @@ -470,31 +404,10 @@ fn reattach_after_detach_shows_current_state() { error ); - // Client B should receive a frame with the current state, + // Client B receives the client-owned shell projection. // including the workspace created while client A was attached. - stream_b.set_nonblocking(false).unwrap(); - stream_b - .set_read_timeout(Some(Duration::from_secs(5))) - .unwrap(); - - let mut received_frame = false; - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline { - match read_server_message(&mut stream_b) { - Ok((variant, _payload)) => { - if variant == 1 { - // ServerMessage::Frame - received_frame = true; - break; - } - } - Err(_) => break, - } - } - assert!( - received_frame, - "reattached client should receive a Frame with current state" - ); + support::wait_for_client_shell_bootstrap(&mut stream_b, Duration::from_secs(5)) + .expect("client shell bootstrap"); // Verify the workspace still exists via API. let mut list_stream = UnixStream::connect(&api_socket).expect("connect to API"); @@ -541,19 +454,29 @@ fn processes_survive_during_and_after_detach() { // Connect and 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"); + let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24) + .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); // Drain initial frames. drain_messages(&mut stream); - // Send input to the pane — the fresh server should have at least one - // pane with a shell running. - send_input(&mut stream, b"echo SURVIVED_DETACH\n").expect("send echo command"); + // Drive the pane through the JSON API; the shell transport carries only + // client-composed UI state, not legacy raw shell input. + let created = workspace_create(&api_socket, "process-survival"); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .expect("root pane id") + .to_string(); + pane_send_text(&api_socket, &pane_id, "echo SURVIVED_DETACH\n"); + assert!(wait_until( + Duration::from_secs(5), + Duration::from_millis(50), + || pane_read_recent_text(&api_socket, &pane_id).contains("SURVIVED_DETACH") + )); - // Drain any frames generated by the input. + // Drain any shell projection updates. drain_messages(&mut stream); // Detach the client via explicit Detach message. @@ -580,32 +503,14 @@ fn processes_survive_during_and_after_detach() { // Reattach — verify we can connect and receive a frame. let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach"); - let (version, error) = client_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24) + let (version, error) = client_shell_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24) .expect("reattach handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); // Verify the reattached client receives a frame. - stream_b - .set_read_timeout(Some(Duration::from_secs(5))) - .unwrap(); - let mut received_frame = false; - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline { - match read_server_message(&mut stream_b) { - Ok((variant, _)) => { - if variant == 1 { - received_frame = true; - break; - } - } - Err(_) => break, - } - } - assert!( - received_frame, - "reattached client should receive a Frame showing current state" - ); + support::wait_for_client_shell_bootstrap(&mut stream_b, Duration::from_secs(5)) + .expect("client shell bootstrap"); cleanup_spawned_herdr(spawned, base); } @@ -629,8 +534,8 @@ fn server_persists_after_client_connection_drop() { // Connect and 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"); + let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24) + .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); @@ -656,7 +561,7 @@ fn server_persists_after_client_connection_drop() { // Reattach — verify we can connect and handshake again. let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach"); - let (version, error) = client_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24) + let (version, error) = client_shell_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24) .expect("reattach handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "reattach should succeed: {:?}", error); @@ -723,11 +628,12 @@ fn pane_created_after_detach_uses_configured_headless_size() { wait_for_socket(&client_socket, Duration::from_secs(10)); let mut stream = UnixStream::connect(&client_socket).expect("client should connect"); - let (version, error) = - client_handshake(&mut stream, CURRENT_PROTOCOL, 160, 50).expect("handshake should succeed"); + let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 160, 50) + .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{error:?}"); - drain_messages(&mut stream); + support::wait_for_client_shell_bootstrap(&mut stream, Duration::from_secs(5)) + .expect("client shell bootstrap"); let first = workspace_create(&api_socket, "attached-size"); let first_pane_id = first["result"]["root_pane"]["pane_id"] @@ -787,8 +693,8 @@ fn detached_output_preserves_last_attached_pty_size() { wait_for_socket(&client_socket, Duration::from_secs(10)); let mut stream = UnixStream::connect(&client_socket).expect("client should connect"); - let (version, error) = - client_handshake(&mut stream, CURRENT_PROTOCOL, 120, 40).expect("handshake should succeed"); + let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 120, 40) + .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); drain_messages(&mut stream); @@ -856,7 +762,7 @@ fn output_accumulated_while_detached_visible_on_reattach() { // Connect and handshake client A. let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect"); - let (version, error) = client_handshake(&mut stream_a, CURRENT_PROTOCOL, 80, 24) + let (version, error) = client_shell_handshake(&mut stream_a, CURRENT_PROTOCOL, 80, 24) .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); @@ -914,44 +820,14 @@ fn output_accumulated_while_detached_visible_on_reattach() { // --- Client B (reattach) --- let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect"); - let (version, error) = client_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24) + let (version, error) = client_shell_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24) .expect("reattach handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); // Client B should receive a frame with the current state. - stream_b - .set_read_timeout(Some(Duration::from_secs(5))) - .unwrap(); - let mut received_frame = false; - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline { - match read_server_message(&mut stream_b) { - Ok((variant, _)) => { - if variant == 1 { - received_frame = true; - break; - } - } - Err(_) => break, - } - } - assert!( - received_frame, - "reattached client should receive a Frame showing current state" - ); - - // Verify the pane content via API includes the output sent while detached. - let read_response = pane_read_recent(&api_socket, &pane_id); - - // The pane output should contain the text sent while detached. - assert!( - read_response["result"]["read"]["text"] - .as_str() - .unwrap_or_default() - .contains("DURING_DETACH"), - "pane should contain output produced while detached: {read_response}" - ); + support::wait_for_client_shell_bootstrap(&mut stream_b, Duration::from_secs(5)) + .expect("client shell bootstrap"); cleanup_spawned_herdr(spawned, base); } diff --git a/tests/live_handoff.rs b/tests/live_handoff.rs index 832079e8..d58fb301 100644 --- a/tests/live_handoff.rs +++ b/tests/live_handoff.rs @@ -1,4 +1,4 @@ -mod support; +pub mod support; use std::fs; use std::io::{BufRead, BufReader, Read, Write}; @@ -12,8 +12,10 @@ use std::time::{Duration, Instant}; use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; use support::{ - cleanup_test_base, client_handshake, register_runtime_dir, register_spawned_herdr_pid, - send_input, unregister_spawned_herdr_pid, wait_for_disconnect, wait_for_socket, + cleanup_test_base, client_shell_handshake, register_runtime_dir, register_spawned_herdr_pid, + send_client_shell_shift_enter, unregister_spawned_herdr_pid, wait_for_client_shell_bootstrap, + wait_for_message_variant, wait_for_socket, SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT, + SERVER_MESSAGE_SERVER_SHUTDOWN, }; struct SpawnedHerdr { @@ -868,9 +870,19 @@ fn live_handoff_preserves_pane_process_io() { .as_u64() .unwrap() as u32; let mut client_stream = UnixStream::connect(&client_socket).unwrap(); - let (server_protocol, error) = client_handshake(&mut client_stream, protocol, 80, 24).unwrap(); + let (server_protocol, error) = + client_shell_handshake(&mut client_stream, protocol, 54, 23).unwrap(); assert_eq!(server_protocol, protocol); - assert!(error.is_none(), "client handshake failed: {error:?}"); + assert!(error.is_none(), "client shell handshake failed: {error:?}"); + assert!( + wait_for_message_variant( + &mut client_stream, + Duration::from_secs(5), + SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT, + ) + .unwrap(), + "client shell should receive a complete snapshot before handoff" + ); assert_ok(request( &api_socket, @@ -888,8 +900,13 @@ fn live_handoff_preserves_pane_process_io() { )); drop(spawned); assert!( - wait_for_disconnect(&mut client_stream, Duration::from_secs(5)).unwrap(), - "connected clients should disconnect during live handoff" + wait_for_message_variant( + &mut client_stream, + Duration::from_secs(5), + SERVER_MESSAGE_SERVER_SHUTDOWN, + ) + .unwrap(), + "connected client shell should receive live-handoff shutdown" ); thread::sleep(Duration::from_millis(300)); wait_for_api(&api_socket, Duration::from_secs(10)); @@ -935,6 +952,14 @@ fn live_handoff_preserves_pane_process_io() { ); wait_for_output(&api_socket, &second_pane_id, "second:after-handoff-sec"); + let mut reattached_shell = UnixStream::connect(&client_socket).unwrap(); + let (server_protocol, error) = + client_shell_handshake(&mut reattached_shell, protocol, 54, 23).unwrap(); + assert_eq!(server_protocol, protocol); + assert!(error.is_none(), "reattached client shell failed: {error:?}"); + wait_for_client_shell_bootstrap(&mut reattached_shell, Duration::from_secs(5)) + .expect("fresh client shell should receive restored snapshot before pane content"); + let _ = request( &api_socket, serde_json::json!({"id":"test:stop","method":"server.stop","params":{}}), @@ -1020,10 +1045,13 @@ pathlib.Path({received:?}).write_text(data.hex()) wait_for_socket(&client_socket, Duration::from_secs(5)); let mut client_stream = UnixStream::connect(&client_socket).unwrap(); - let (server_protocol, error) = client_handshake(&mut client_stream, protocol, 80, 24).unwrap(); + let (server_protocol, error) = + client_shell_handshake(&mut client_stream, protocol, 54, 23).unwrap(); assert_eq!(server_protocol, protocol); - assert!(error.is_none(), "client handshake failed: {error:?}"); - send_input(&mut client_stream, b"\x1b[13;2u").unwrap(); + assert!(error.is_none(), "client shell handshake failed: {error:?}"); + wait_for_client_shell_bootstrap(&mut client_stream, Duration::from_secs(5)) + .expect("client shell should receive restored state before sending input"); + send_client_shell_shift_enter(&mut client_stream, &pane_id).unwrap(); wait_for_file_contains(&received_marker, "1b5b31333b3275", Duration::from_secs(5)); @@ -1111,10 +1139,13 @@ pathlib.Path({received:?}).write_text(data.hex()) wait_for_socket(&client_socket, Duration::from_secs(5)); let mut client_stream = UnixStream::connect(&client_socket).unwrap(); - let (server_protocol, error) = client_handshake(&mut client_stream, protocol, 80, 24).unwrap(); + let (server_protocol, error) = + client_shell_handshake(&mut client_stream, protocol, 54, 23).unwrap(); assert_eq!(server_protocol, protocol); - assert!(error.is_none(), "client handshake failed: {error:?}"); - send_input(&mut client_stream, b"\x1b[13;2u").unwrap(); + assert!(error.is_none(), "client shell handshake failed: {error:?}"); + wait_for_client_shell_bootstrap(&mut client_stream, Duration::from_secs(5)) + .expect("client shell should receive restored state before sending input"); + send_client_shell_shift_enter(&mut client_stream, &pane_id).unwrap(); wait_for_file_contains( &received_marker, diff --git a/tests/multi_client.rs b/tests/multi_client.rs index 20deedaf..83fb0b00 100644 --- a/tests/multi_client.rs +++ b/tests/multi_client.rs @@ -1,10 +1,9 @@ -//! Integration tests for multi-client server behavior. +//! Gate B integration tests for the ClientShell protocol. -mod support; +pub mod support; -use std::collections::VecDeque; use std::fs; -use std::io::{self, BufRead, BufReader, Read, Write}; +use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; use std::sync::{Mutex, MutexGuard, OnceLock}; @@ -12,20 +11,21 @@ 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, register_runtime_dir, register_spawned_herdr_pid, - unregister_spawned_herdr_pid, CURRENT_PROTOCOL, + cleanup_test_base, client_shell_handshake, drain_messages, register_runtime_dir, + register_spawned_herdr_pid, send_detach, unregister_spawned_herdr_pid, + wait_for_client_shell_bootstrap, wait_for_message_variant, wait_for_message_variants, + CURRENT_PROTOCOL, SERVER_MESSAGE_PANE_SURFACE, SERVER_MESSAGE_PANE_SURFACE_PATCH, }; fn unique_test_dir() -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); + .unwrap() + .as_nanos(); PathBuf::from(format!( - "/tmp/herdr-multi-client-test-{}-{nanos}", + "/tmp/herdr-multi-client-{}-{nanos}", std::process::id() )) } @@ -39,45 +39,26 @@ 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 { + let done = unsafe { libc::waitpid(pid as libc::pid_t, &mut status, libc::WNOHANG) }; + if done == pid as libc::pid_t || done == -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_child_exit(child: &mut Box<dyn Child + Send + Sync>) { - let _ = child.kill(); - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline { - if child.try_wait().ok().flatten().is_some() { - return; - } - thread::sleep(Duration::from_millis(25)); - } -} - 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()) + .unwrap_or_else(|p| p.into_inner()) } fn wait_for_socket(path: &Path, timeout: Duration) { @@ -94,24 +75,19 @@ fn wait_for_socket(path: &Path, timeout: Duration) { fn wait_for_file(path: &Path, timeout: Duration) { let deadline = Instant::now() + timeout; while Instant::now() < deadline { - if path.exists() && UnixStream::connect(path).is_ok() { + if path.exists() { return; } thread::sleep(Duration::from_millis(25)); } - panic!("socket did not accept connections at {}", path.display()); + panic!("socket did not appear at {}", path.display()); } -fn spawn_server(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(); - +fn spawn_server(config: &Path, runtime: &Path, api: &Path) -> SpawnedHerdr { + fs::create_dir_all(config.join("herdr")).unwrap(); + fs::create_dir_all(runtime).unwrap(); + register_runtime_dir(runtime); + fs::write(config.join("herdr/config.toml"), "onboarding = false\n").unwrap(); let pair = native_pty_system() .openpty(PtySize { rows: 24, @@ -120,32 +96,25 @@ fn spawn_server(config_home: &Path, runtime_dir: &Path, api_socket_path: &Path) 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("XDG_CONFIG_HOME", config); + cmd.env("XDG_RUNTIME_DIR", runtime); + cmd.env("HERDR_SOCKET_PATH", api); 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, } } -fn spawn_client_process( - config_home: &Path, - runtime_dir: &Path, - api_socket_path: &Path, -) -> SpawnedHerdr { - register_runtime_dir(runtime_dir); +fn spawn_client(config: &Path, runtime: &Path, api: &Path) -> SpawnedHerdr { + register_runtime_dir(runtime); let pair = native_pty_system() .openpty(PtySize { rows: 24, @@ -154,177 +123,86 @@ fn spawn_client_process( 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("XDG_CONFIG_HOME", config); + cmd.env("XDG_RUNTIME_DIR", runtime); + cmd.env("HERDR_SOCKET_PATH", api); 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, } } -fn server_log_path(config_home: &Path) -> PathBuf { - let app_dir = if cfg!(debug_assertions) { - "herdr-dev" - } else { - "herdr" - }; - config_home.join(app_dir).join("herdr-server.log") -} - -fn count_log_occurrences(path: &Path, needle: &str) -> usize { - fs::read_to_string(path) - .ok() - .map(|text| text.lines().filter(|line| line.contains(needle)).count()) - .unwrap_or(0) -} - -fn log_tail(path: &Path, lines: usize) -> String { - let Ok(text) = fs::read_to_string(path) else { - return format!("could not read {}", path.display()); - }; - let mut tail = VecDeque::with_capacity(lines); - for line in text.lines() { - if tail.len() == lines { - tail.pop_front(); - } - tail.push_back(line.to_string()); - } - tail.into_iter().collect::<Vec<_>>().join("\n") -} - -fn wait_for_log_occurrence_count( - path: &Path, - needle: &str, - min_count: usize, - timeout: Duration, -) -> bool { - let deadline = Instant::now() + timeout; - while Instant::now() < deadline { - if count_log_occurrences(path, needle) >= min_count { - return true; - } - thread::sleep(Duration::from_millis(40)); - } - false -} - -fn ping_socket(socket_path: &Path) -> String { - let mut stream = UnixStream::connect(socket_path).expect("should connect to API socket"); - writeln!( - stream, - "{{\"id\":\"ping\",\"method\":\"ping\",\"params\":{{}}}}" - ) - .unwrap(); - - let mut reader = BufReader::new(stream); - let mut response = String::new(); - reader.read_line(&mut response).unwrap(); - response.trim().to_string() +fn cleanup(server: SpawnedHerdr, base: PathBuf) { + drop(server); + cleanup_test_base(&base); } -fn send_json_request(socket_path: &Path, request: &str) -> Value { - let mut stream = UnixStream::connect(socket_path).expect("should connect to API socket"); +fn api_request(socket: &Path, request: &str) -> Value { + let mut stream = UnixStream::connect(socket).unwrap(); 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") + BufReader::new(stream).read_line(&mut response).unwrap(); + serde_json::from_str(&response).unwrap() } -fn create_workspace_and_root_pane(socket_path: &Path, label: &str) -> (String, String) { - let response = send_json_request( - socket_path, - &format!( - "{{\"id\":\"ws_create\",\"method\":\"workspace.create\",\"params\":{{\"label\":\"{label}\"}}}}" - ), +fn create_pane(socket: &Path, label: &str) -> String { + let result = api_request( + socket, + &format!(r#"{{"id":"create","method":"workspace.create","params":{{"label":"{label}"}}}}"#), ); - - if response.get("error").is_some() { - panic!("workspace.create failed: {response}"); - } - - let workspace_id = response - .pointer("/result/workspace/workspace_id") - .and_then(Value::as_str) - .expect("workspace.create should return workspace id") - .to_string(); - - let pane_id = response + assert!( + result.get("error").is_none(), + "workspace.create failed: {result}" + ); + result .pointer("/result/root_pane/pane_id") - .and_then(Value::as_str) - .expect("workspace.create should return root pane id") - .to_string(); - - (workspace_id, pane_id) + .unwrap() + .as_str() + .unwrap() + .into() } -fn report_idle_agent(socket_path: &Path, pane_id: &str) { - let response = send_json_request( - socket_path, +fn pane_input(socket: &Path, pane: &str, text: &str) { + let escaped = text.replace('"', "\\\""); + let result = api_request( + socket, &format!( - r#"{{"id":"report_agent","method":"pane.report_agent","params":{{"pane_id":"{pane_id}","agent":"pi","state":"idle","source":"multi-client-test"}}}}"# + r#"{{"id":"input","method":"pane.send_input","params":{{"pane_id":"{pane}","text":"{escaped}","keys":["Enter"]}}}}"# ), ); assert!( - response.get("error").is_none(), - "pane.report_agent should succeed: {response}" + result.get("error").is_none(), + "pane.send_input failed: {result}" ); } -fn pane_send_input(socket_path: &Path, pane_id: &str, text: &str) { - let request = format!( - "{{\"id\":\"send_input\",\"method\":\"pane.send_input\",\"params\":{{\"pane_id\":\"{pane_id}\",\"text\":\"{}\",\"keys\":[\"Enter\"]}}}}", - text.replace('"', "\\\"") - ); - let response = send_json_request(socket_path, &request); - if response.get("error").is_some() { - panic!("pane.send_input failed: {response}"); - } -} - -fn pane_read_recent(socket_path: &Path, pane_id: &str, lines: usize) -> String { - let response = send_json_request( - socket_path, +fn pane_text(socket: &Path, pane: &str) -> String { + let result = api_request( + socket, &format!( - "{{\"id\":\"pane_read\",\"method\":\"pane.read\",\"params\":{{\"pane_id\":\"{pane_id}\",\"source\":\"recent\",\"lines\":{lines}}}}}" + r#"{{"id":"read","method":"pane.read","params":{{"pane_id":"{pane}","source":"recent","lines":200}}}}"# ), ); - - if response.get("error").is_some() { - panic!("pane.read failed: {response}"); - } - - response + result .pointer("/result/read/text") .and_then(Value::as_str) .unwrap_or_default() - .to_string() + .into() } -fn pane_read_recent_contains( - socket_path: &Path, - pane_id: &str, - needle: &str, - timeout: Duration, -) -> bool { +fn pane_contains(socket: &Path, pane: &str, needle: &str, timeout: Duration) -> bool { let deadline = Instant::now() + timeout; while Instant::now() < deadline { - if pane_read_recent(socket_path, pane_id, 200).contains(needle) { + if pane_text(socket, pane).contains(needle) { return true; } thread::sleep(Duration::from_millis(50)); @@ -332,844 +210,168 @@ fn pane_read_recent_contains( false } -fn parse_size_after_marker(text: &str, marker: &str) -> Option<(u16, u16)> { - let mut seen_marker = false; - for line in text.lines() { - if !seen_marker { - if line.contains(marker) { - seen_marker = true; - } - continue; - } - - let mut parts = line.split_whitespace(); - let Some(rows_raw) = parts.next() else { - continue; - }; - let Some(cols_raw) = parts.next() else { - continue; - }; - - let Ok(rows) = rows_raw.parse::<u16>() else { - continue; - }; - let Ok(cols) = cols_raw.parse::<u16>() else { - continue; - }; - - return Some((rows, cols)); - } - - None +fn shell(socket: &Path, cols: u16, rows: u16) -> UnixStream { + let mut stream = UnixStream::connect(socket).unwrap(); + let (version, error) = + client_shell_handshake(&mut stream, CURRENT_PROTOCOL, cols, rows).unwrap(); + assert_eq!(version, CURRENT_PROTOCOL); + assert!(error.is_none(), "ClientShell handshake failed: {error:?}"); + wait_for_client_shell_bootstrap(&mut stream, Duration::from_secs(5)).unwrap(); + stream } -fn try_read_pane_tty_size( - socket_path: &Path, - pane_id: &str, - timeout: Duration, -) -> Option<(u16, u16)> { +fn tty_size(socket: &Path, pane: &str, timeout: Duration) -> (u16, u16) { let marker = format!( - "SIZE_MARKER_{}_{}", - std::process::id(), + "SIZE_{}", SystemTime::now() .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) + .unwrap() + .as_nanos() ); - - pane_send_input(socket_path, pane_id, &format!("echo {marker}; stty size")); - + pane_input(socket, pane, &format!("echo {marker}; stty size")); let deadline = Instant::now() + timeout; while Instant::now() < deadline { - let text = pane_read_recent(socket_path, pane_id, 200); - if let Some(size) = parse_size_after_marker(&text, &marker) { - return Some(size); - } - thread::sleep(Duration::from_millis(50)); - } - - None -} - -fn read_pane_tty_size(socket_path: &Path, pane_id: &str, timeout: Duration) -> (u16, u16) { - if let Some(size) = try_read_pane_tty_size(socket_path, pane_id, timeout) { - return size; - } - - let snapshot = pane_read_recent(socket_path, pane_id, 200); - panic!( - "did not observe tty size after marker. pane output:\n{}", - snapshot - ); -} - -// --------------------------------------------------------------------------- -// Minimal bincode v2 varint helpers for protocol tests -// --------------------------------------------------------------------------- - -fn encode_varint_u32(v: u32) -> Vec<u8> { - if v < 251 { - vec![v as u8] - } else if v < 65536 { - let mut buf = vec![251u8]; - buf.extend_from_slice(&(v as u16).to_le_bytes()); - buf - } else { - let mut buf = vec![252u8]; - buf.extend_from_slice(&v.to_le_bytes()); - buf - } -} - -fn encode_varint_u16(v: u16) -> Vec<u8> { - if v < 251 { - vec![v as u8] - } else { - let mut buf = vec![251u8]; - buf.extend_from_slice(&v.to_le_bytes()); - buf - } -} - -fn encode_varint_enum(variant_idx: u32, fields: &[&[u8]]) -> Vec<u8> { - let mut buf = encode_varint_u32(variant_idx); - for field in fields { - buf.extend_from_slice(field); - } - buf -} - -fn frame_message(payload: &[u8]) -> Vec<u8> { - let len = payload.len() as u32; - let mut framed = len.to_le_bytes().to_vec(); - framed.extend_from_slice(payload); - framed -} - -fn decode_varint_u32(payload: &[u8], offset: usize) -> Result<(u32, usize), String> { - if offset >= payload.len() { - return Err("payload too short for varint".into()); - } - let first_byte = payload[offset]; - match first_byte { - 0..=250 => Ok((first_byte as u32, 1)), - 251 => { - if offset + 3 > payload.len() { - return Err("payload too short for u16 varint".into()); + let mut found = false; + for line in pane_text(socket, pane).lines() { + if line.contains(&marker) { + found = true; + continue; } - let v = u16::from_le_bytes( - payload[offset + 1..offset + 3] - .try_into() - .map_err(|e: std::array::TryFromSliceError| e.to_string())?, - ); - Ok((v as u32, 3)) - } - 252 => { - if offset + 5 > payload.len() { - return Err("payload too short for u32 varint".into()); - } - let v = u32::from_le_bytes( - payload[offset + 1..offset + 5] - .try_into() - .map_err(|e: std::array::TryFromSliceError| e.to_string())?, - ); - Ok((v, 5)) - } - _ => Err(format!("unsupported varint tag: {first_byte}")), - } -} - -fn is_timeout(err: &io::Error) -> bool { - matches!( - err.kind(), - io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock - ) -} - -fn read_server_variant(stream: &mut UnixStream, timeout: Duration) -> io::Result<u32> { - stream.set_read_timeout(Some(timeout))?; - - let mut len_buf = [0u8; 4]; - stream.read_exact(&mut len_buf)?; - let len = u32::from_le_bytes(len_buf) as usize; - if len == 0 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "zero-length payload", - )); - } - - let mut payload = vec![0u8; len]; - stream.read_exact(&mut payload)?; - - let (variant, _consumed) = decode_varint_u32(&payload, 0) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - Ok(variant) -} - -fn client_handshake( - stream: &mut UnixStream, - version: u32, - cols: u16, - rows: u16, -) -> Result<(), String> { - stream - .set_read_timeout(Some(Duration::from_secs(5))) - .map_err(|e| e.to_string())?; - - // ClientMessage::Hello = variant 0 - let hello_payload = encode_varint_enum( - 0, - &[ - &encode_varint_u32(version), - &encode_varint_u16(cols), - &encode_varint_u16(rows), - &encode_varint_u32(8), // cell_width_px - &encode_varint_u32(16), // cell_height_px - &encode_varint_u32(0), // RenderEncoding::SemanticFrame - &encode_varint_u32(0), // ClientKeybindings::Server - &encode_varint_u32(0), // ClientLaunchMode::App - ], - ); - stream - .write_all(&frame_message(&hello_payload)) - .map_err(|e| e.to_string())?; - stream.flush().map_err(|e| e.to_string())?; - - // Read ServerMessage::Welcome = variant 0 - let mut len_buf = [0u8; 4]; - stream.read_exact(&mut len_buf).map_err(|e| e.to_string())?; - let len = u32::from_le_bytes(len_buf) as usize; - let mut payload = vec![0u8; len]; - stream.read_exact(&mut payload).map_err(|e| e.to_string())?; - - let mut offset = 0; - let (variant, consumed) = decode_varint_u32(&payload, offset)?; - offset += consumed; - if variant != 0 { - return Err(format!("expected Welcome variant 0, got {variant}")); - } - - let (_server_version, consumed) = decode_varint_u32(&payload, offset)?; - offset += consumed; - - let (_encoding, consumed) = decode_varint_u32(&payload, offset)?; - offset += consumed; - - if offset >= payload.len() { - return Err("payload too short for Welcome.error option tag".into()); - } - let option_tag = payload[offset]; - offset += 1; - - if option_tag == 1 { - let (str_len, consumed) = decode_varint_u32(&payload, offset)?; - offset += consumed; - let str_len = str_len as usize; - if offset + str_len > payload.len() { - return Err("payload too short for welcome error string".into()); - } - let err = String::from_utf8(payload[offset..offset + str_len].to_vec()) - .map_err(|e| e.to_string())?; - return Err(format!("handshake rejected: {err}")); - } - - Ok(()) -} - -fn connect_raw_client(client_socket: &Path, cols: u16, rows: u16) -> UnixStream { - let mut stream = UnixStream::connect(client_socket).expect("should connect to client socket"); - client_handshake(&mut stream, CURRENT_PROTOCOL, cols, rows).expect("handshake should succeed"); - stream -} - -fn send_client_input(stream: &mut UnixStream, data: &[u8]) { - // ClientMessage::Input = variant 1 - let payload = { - let mut buf = encode_varint_u32(1); - buf.extend_from_slice(&encode_varint_u32(data.len() as u32)); - buf.extend_from_slice(data); - buf - }; - stream.write_all(&frame_message(&payload)).unwrap(); - stream.flush().unwrap(); -} - -fn send_client_detach(stream: &mut UnixStream) { - // ClientMessage::Detach = variant 4 - let payload = encode_varint_u32(4); - stream.write_all(&frame_message(&payload)).unwrap(); - stream.flush().unwrap(); -} - -#[allow(dead_code)] -#[derive(Debug, Deserialize)] -struct FrameWire { - cells: Vec<CellWire>, - width: u16, - height: u16, - cursor: Option<CursorWire>, - hyperlinks: Vec<String>, - graphics: Vec<u8>, -} - -#[allow(dead_code)] -#[derive(Debug, Deserialize)] -struct CellWire { - symbol: String, - fg: u32, - bg: u32, - modifier: u16, - skip: bool, - hyperlink: Option<u32>, -} - -#[derive(Debug, Deserialize)] -struct CursorWire { - x: u16, - y: u16, - visible: bool, - shape: u8, -} - -fn decode_frame_payload(payload: &[u8]) -> io::Result<FrameWire> { - bincode::serde::decode_from_slice(payload, bincode::config::standard()) - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string())) - .and_then(|(frame, consumed): (FrameWire, usize)| { - if consumed != payload.len() { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!( - "frame payload had trailing bytes: consumed={}, len={}", - consumed, - payload.len() - ), - )); - } - Ok(frame) - }) -} - -fn read_server_message_payload( - stream: &mut UnixStream, - timeout: Duration, -) -> io::Result<(u32, Vec<u8>)> { - stream.set_read_timeout(Some(timeout))?; - - let mut len_buf = [0u8; 4]; - stream.read_exact(&mut len_buf)?; - let len = u32::from_le_bytes(len_buf) as usize; - if len == 0 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "zero-length payload", - )); - } - - let mut payload = vec![0u8; len]; - stream.read_exact(&mut payload)?; - - let (variant, consumed) = decode_varint_u32(&payload, 0) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - - Ok((variant, payload[consumed..].to_vec())) -} - -fn drain_server_messages(stream: &mut UnixStream, max_drain: Duration) { - let deadline = Instant::now() + max_drain; - while Instant::now() < deadline { - match read_server_variant(stream, Duration::from_millis(50)) { - Ok(_) => {} - Err(err) if is_timeout(&err) => break, - Err(_) => break, - } - } -} - -fn wait_for_frame(stream: &mut UnixStream, timeout: Duration) -> bool { - let deadline = Instant::now() + timeout; - while Instant::now() < deadline { - let remaining = deadline.saturating_duration_since(Instant::now()); - let slice = remaining.min(Duration::from_millis(75)); - match read_server_variant(stream, slice) { - Ok(1) => return true, // ServerMessage::Frame - Ok(_) => {} - Err(err) if is_timeout(&err) => {} - Err(_) => return false, - } - } - false -} - -fn wait_for_frame_matching_with_snapshots( - stream: &mut UnixStream, - timeout: Duration, - predicate: impl Fn(&FrameWire) -> bool, -) -> io::Result<(bool, Vec<String>)> { - let deadline = Instant::now() + timeout; - let mut snapshots = VecDeque::with_capacity(5); - while Instant::now() < deadline { - let slice = deadline - .saturating_duration_since(Instant::now()) - .min(Duration::from_millis(80)); - match read_server_message_payload(stream, slice) { - Ok((1, frame_payload)) => { - let frame = decode_frame_payload(&frame_payload)?; - if snapshots.len() == 5 { - snapshots.pop_front(); - } - snapshots.push_back(frame_text(&frame)); - if predicate(&frame) { - return Ok((true, snapshots.into_iter().collect())); + if found { + let mut words = line.split_whitespace(); + if let (Some(r), Some(c)) = (words.next(), words.next()) { + if let (Ok(r), Ok(c)) = (r.parse(), c.parse()) { + return (r, c); + } } } - Ok((_variant, _payload)) => {} - Err(err) if is_timeout(&err) => {} - Err(err) => return Err(err), - } - } - - Ok((false, snapshots.into_iter().collect())) -} - -fn frame_text(frame: &FrameWire) -> String { - if frame.cells.is_empty() { - return String::new(); - } - - let row_width = frame.width.max(1) as usize; - let mut full_text = String::new(); - - for row in frame.cells.chunks(row_width) { - for cell in row { - let _ = (cell.fg, cell.bg, cell.modifier, cell.skip); - full_text.push_str(&cell.symbol); } - full_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); + thread::sleep(Duration::from_millis(50)); } - - full_text -} - -fn frame_contains_text(frame: &FrameWire, needle: &str) -> bool { - frame_text(frame).contains(needle) -} - -fn agent_panel_starts_with(frame: &FrameWire, agent_label: &str) -> bool { - frame_text(frame) - .lines() - .skip_while(|line| !line.contains("agents")) - .skip(1) - .find(|line| line.contains("agent-")) - .is_some_and(|line| line.contains(agent_label)) + panic!("pane did not report tty size: {}", pane_text(socket, pane)); } #[test] -fn multi_client_allows_multiple_simultaneous_connections() { +fn effective_size_uses_smallest_foreground_client_and_recovers_on_disconnect() { 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); - wait_for_socket(&api_socket, Duration::from_secs(10)); - wait_for_file(&client_socket, Duration::from_secs(10)); - - let mut client_a = connect_raw_client(&client_socket, 120, 40); - let mut client_b = connect_raw_client(&client_socket, 100, 30); - - assert!( - wait_for_frame(&mut client_a, Duration::from_secs(2)), - "client A should receive frames" - ); + let config = base.join("config"); + let runtime = base.join("runtime"); + let api = runtime.join("herdr.sock"); + let clients = runtime.join("herdr-client.sock"); + let server = spawn_server(&config, &runtime, &api); + wait_for_socket(&api, Duration::from_secs(10)); + wait_for_file(&clients, Duration::from_secs(10)); + let pane = create_pane(&api, "effective-size"); + let mut large = shell(&clients, 120, 40); + let mut small = shell(&clients, 80, 24); + let reduced = tty_size(&api, &pane, Duration::from_secs(5)); assert!( - wait_for_frame(&mut client_b, Duration::from_secs(2)), - "client B should receive frames" + reduced.0 <= 24 && reduced.1 <= 80, + "small ClientShell should determine effective size: {reduced:?}" ); - - let ping = ping_socket(&api_socket); - assert!( - ping.contains("pong"), - "server should remain responsive: {ping}" - ); - - cleanup_spawned_herdr(server, base); -} - -#[test] -fn multi_client_effective_size_shrinks_when_smaller_client_joins() { - 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); - wait_for_socket(&api_socket, Duration::from_secs(10)); - wait_for_file(&client_socket, Duration::from_secs(10)); - - let (_workspace_id, pane_id) = create_workspace_and_root_pane(&api_socket, "size-shrink"); - - let mut large = connect_raw_client(&client_socket, 120, 40); - assert!(wait_for_frame(&mut large, Duration::from_secs(2))); - let large_only_size = read_pane_tty_size(&api_socket, &pane_id, Duration::from_secs(5)); - - let mut small = connect_raw_client(&client_socket, 80, 24); - assert!(wait_for_frame(&mut small, Duration::from_secs(2))); - - let deadline = Instant::now() + Duration::from_secs(8); - let mut last_seen_size = None; - let mut size_with_small_client = None; - while Instant::now() < deadline { - if let Some(size) = - try_read_pane_tty_size(&api_socket, &pane_id, Duration::from_millis(400)) - { - last_seen_size = Some(size); - if size.0 < large_only_size.0 && size.1 < large_only_size.1 { - size_with_small_client = Some(size); - break; - } - } - thread::sleep(Duration::from_millis(60)); - } - - assert!( - size_with_small_client.is_some(), - "effective pane size should shrink when smaller client joins: before={large_only_size:?}, last_seen={last_seen_size:?}" - ); - - cleanup_spawned_herdr(server, base); -} - -#[test] -fn non_foreground_client_render_preserves_agent_panel_scroll() { - 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); - wait_for_socket(&api_socket, Duration::from_secs(10)); - wait_for_file(&client_socket, Duration::from_secs(10)); - - for index in 1..=23 { - let (_, pane_id) = - create_workspace_and_root_pane(&api_socket, &format!("agent-{index:02}")); - report_idle_agent(&api_socket, &pane_id); - } - - let mut setup_client = connect_raw_client(&client_socket, 106, 40); - assert!(wait_for_frame(&mut setup_client, Duration::from_secs(2))); - drain_server_messages(&mut setup_client, Duration::from_millis(250)); - - let wheel_down = b"\x1b[<65;10;30M"; - send_client_input(&mut setup_client, &wheel_down.repeat(20)); - let (reached_bottom, setup_frames) = wait_for_frame_matching_with_snapshots( - &mut setup_client, + send_detach(&mut small).unwrap(); + drop(small); + assert!(wait_for_message_variant( + &mut large, Duration::from_secs(3), - |frame| agent_panel_starts_with(frame, "agent-16"), + SERVER_MESSAGE_PANE_SURFACE ) - .expect("setup frame decoding should succeed"); - assert!( - reached_bottom, - "40-row client should scroll the agent panel to its final page; frames:\n{}", - setup_frames.join("\n--- frame ---\n") - ); - send_client_detach(&mut setup_client); - drop(setup_client); - - let mut tall_background = connect_raw_client(&client_socket, 106, 64); - assert!(wait_for_frame(&mut tall_background, Duration::from_secs(2))); - let mut probe = connect_raw_client(&client_socket, 106, 40); - let (started_at_tall_limit, initial_frames) = - wait_for_frame_matching_with_snapshots(&mut probe, Duration::from_secs(3), |frame| { - agent_panel_starts_with(frame, "agent-10") - }) - .expect("initial probe frame decoding should succeed"); + .unwrap()); + let restored = tty_size(&api, &pane, Duration::from_secs(5)); assert!( - started_at_tall_limit, - "tall client should normalize the shared scroll before the probe attaches; frames:\n{}", - initial_frames.join("\n--- frame ---\n") + restored.0 > reduced.0 && restored.1 > reduced.1, + "size should recover: {reduced:?} -> {restored:?}" ); - drain_server_messages(&mut probe, Duration::from_millis(250)); - - send_client_input(&mut probe, wheel_down); - let (scrolled, probe_frames) = - wait_for_frame_matching_with_snapshots(&mut probe, Duration::from_secs(3), |frame| { - agent_panel_starts_with(frame, "agent-11") - }) - .expect("probe frame decoding should succeed"); - assert!( - scrolled, - "background client projection must not undo the foreground wheel event; frames:\n{}", - probe_frames.join("\n--- frame ---\n") - ); - - cleanup_spawned_herdr(server, base); + cleanup(server, base); } #[test] -fn multi_client_broadcasts_frame_updates_to_all_clients() { +fn api_pane_output_is_fanned_out_as_pane_surface_updates() { 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); - wait_for_socket(&api_socket, Duration::from_secs(10)); - wait_for_file(&client_socket, Duration::from_secs(10)); - - let mut client_a = connect_raw_client(&client_socket, 100, 30); - let mut client_b = connect_raw_client(&client_socket, 100, 30); - - // Ensure we have an active pane that can reflect input changes. - let (_workspace_id, pane_id) = - create_workspace_and_root_pane(&api_socket, "broadcast-client-a-to-b"); - - // Drain initial frames so we measure the frame caused by new input. - drain_server_messages(&mut client_a, Duration::from_millis(300)); - drain_server_messages(&mut client_b, Duration::from_millis(300)); - + let config = base.join("config"); + let runtime = base.join("runtime"); + let api = runtime.join("herdr.sock"); + let clients = runtime.join("herdr-client.sock"); + let server = spawn_server(&config, &runtime, &api); + wait_for_socket(&api, Duration::from_secs(10)); + wait_for_file(&clients, Duration::from_secs(10)); + let pane = create_pane(&api, "fanout"); + let mut a = shell(&clients, 100, 30); + let mut b = shell(&clients, 100, 30); + drain_messages(&mut a); + drain_messages(&mut b); let marker = format!( - "MB{}", + "FANOUT_{}", SystemTime::now() .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis()) - .unwrap_or(0) + .unwrap() + .as_millis() ); - - send_client_input(&mut client_a, format!("echo {marker}\n").as_bytes()); - if !pane_read_recent_contains(&api_socket, &pane_id, &marker, Duration::from_secs(5)) { - panic!( - "pane output should include client A marker so broadcast reflects a real state change. pane output:\n{}\nserver log tail:\n{}", - pane_read_recent(&api_socket, &pane_id, 200), - log_tail(&server_log_path(&config_home), 80) - ); - } - let (received, client_b_frames) = - wait_for_frame_matching_with_snapshots(&mut client_b, Duration::from_secs(10), |frame| { - frame_contains_text(frame, &marker) - }) - .expect("frame decoding should succeed"); - - assert!( - received, - "client B should receive a broadcast frame containing client A marker. pane output:\n{}\nclient B frame snapshots:\n{}\nserver log tail:\n{}", - pane_read_recent(&api_socket, &pane_id, 200), - client_b_frames.join("\n--- frame ---\n"), - log_tail(&server_log_path(&config_home), 80) - ); - - cleanup_spawned_herdr(server, base); -} - -#[test] -fn multi_client_disconnect_recalculates_to_next_smallest() { - 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); - wait_for_socket(&api_socket, Duration::from_secs(10)); - wait_for_file(&client_socket, Duration::from_secs(10)); - - let (_workspace_id, pane_id) = - create_workspace_and_root_pane(&api_socket, "size-next-smallest"); - - let mut c120 = connect_raw_client(&client_socket, 120, 40); - let mut c100 = connect_raw_client(&client_socket, 100, 30); - let mut c80 = connect_raw_client(&client_socket, 80, 24); - - assert!(wait_for_frame(&mut c120, Duration::from_secs(2))); - assert!(wait_for_frame(&mut c100, Duration::from_secs(2))); - assert!(wait_for_frame(&mut c80, Duration::from_secs(2))); - - let size_with_three = read_pane_tty_size(&api_socket, &pane_id, Duration::from_secs(5)); - - drain_server_messages(&mut c100, Duration::from_millis(250)); - - // Smallest client disconnects; effective size should increase to the next-smallest. - send_client_detach(&mut c80); - drop(c80); - - assert!( - wait_for_frame(&mut c100, Duration::from_secs(2)), - "next-smallest client should receive resized-up frame" - ); - - let deadline = Instant::now() + Duration::from_secs(8); - let mut size_after_smallest_disconnect = None; - while Instant::now() < deadline { - let maybe_size = try_read_pane_tty_size(&api_socket, &pane_id, Duration::from_millis(400)); - if let Some(size) = maybe_size { - if size.0 > size_with_three.0 && size.1 > size_with_three.1 { - size_after_smallest_disconnect = Some(size); - break; - } - } - thread::sleep(Duration::from_millis(60)); - } - - assert!( - size_after_smallest_disconnect.is_some(), - "effective pane size should increase after smallest disconnects: before={:?}, last_seen={:?}", - size_with_three, - try_read_pane_tty_size(&api_socket, &pane_id, Duration::from_millis(300)) - ); - - cleanup_spawned_herdr(server, base); -} - -#[test] -fn multi_client_smallest_leaving_resizes_up_for_remaining_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 server = spawn_server(&config_home, &runtime_dir, &api_socket); - wait_for_socket(&api_socket, Duration::from_secs(10)); - wait_for_file(&client_socket, Duration::from_secs(10)); - - let (_workspace_id, pane_id) = create_workspace_and_root_pane(&api_socket, "size-resize-up"); - - let mut large = connect_raw_client(&client_socket, 120, 40); - let mut small = connect_raw_client(&client_socket, 80, 24); - - assert!(wait_for_frame(&mut large, Duration::from_secs(2))); - assert!(wait_for_frame(&mut small, Duration::from_secs(2))); - - let size_with_small_client = read_pane_tty_size(&api_socket, &pane_id, Duration::from_secs(5)); - - drain_server_messages(&mut large, Duration::from_millis(250)); - - send_client_detach(&mut small); - drop(small); - - // Remaining client should receive a new (larger) frame. - assert!( - wait_for_frame(&mut large, Duration::from_secs(2)), - "remaining client should receive resized-up frame" - ); - - let size_after_small_leaves = read_pane_tty_size(&api_socket, &pane_id, Duration::from_secs(5)); - - assert!( - size_after_small_leaves.0 > size_with_small_client.0 - && size_after_small_leaves.1 > size_with_small_client.1, - "remaining clients should get larger effective pane size after smallest leaves: before={:?}, after={:?}", - size_with_small_client, - size_after_small_leaves - ); - - cleanup_spawned_herdr(server, base); + pane_input(&api, &pane, &format!("printf '{marker}\\n'")); + assert!(pane_contains(&api, &pane, &marker, Duration::from_secs(5))); + let surface_updates = [ + SERVER_MESSAGE_PANE_SURFACE, + SERVER_MESSAGE_PANE_SURFACE_PATCH, + ]; + assert!(wait_for_message_variants(&mut a, Duration::from_secs(5), &surface_updates).unwrap()); + assert!(wait_for_message_variants(&mut b, Duration::from_secs(5), &surface_updates).unwrap()); + cleanup(server, base); } #[test] -fn multi_client_client_crash_sigkill_does_not_affect_server() { +fn crashed_client_shell_does_not_affect_survivor() { 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); - wait_for_socket(&api_socket, Duration::from_secs(10)); - wait_for_file(&client_socket, Duration::from_secs(10)); - - let mut survivor = connect_raw_client(&client_socket, 100, 30); - assert!(wait_for_frame(&mut survivor, Duration::from_secs(2))); - - let log_path = server_log_path(&config_home); - let connected_before = count_log_occurrences(&log_path, "client connected"); - - let crashing_client = spawn_client_process(&config_home, &runtime_dir, &api_socket); - - let attached_before_kill = wait_for_log_occurrence_count( - &log_path, - "client connected", - connected_before + 1, - Duration::from_secs(8), - ); - assert!( - attached_before_kill, - "thin client must complete handshake/attachment before SIGKILL" - ); - - if let Some(pid) = crashing_client.child.process_id() { - unsafe { - libc::kill(pid as libc::pid_t, libc::SIGKILL); - } - } - let mut crashing_client = crashing_client; - wait_for_child_exit(&mut crashing_client.child); - - let ping = ping_socket(&api_socket); - assert!( - ping.contains("pong"), - "server should stay healthy after SIGKILLed client: {ping}" - ); - - drain_server_messages(&mut survivor, Duration::from_millis(250)); - send_client_input(&mut survivor, b"echo survivor-still-works\n"); - assert!( - wait_for_frame(&mut survivor, Duration::from_secs(2)), - "remaining client should continue receiving frames" - ); - - cleanup_spawned_herdr(server, base); + let config = base.join("config"); + let runtime = base.join("runtime"); + let api = runtime.join("herdr.sock"); + let clients = runtime.join("herdr-client.sock"); + let server = spawn_server(&config, &runtime, &api); + wait_for_socket(&api, Duration::from_secs(10)); + wait_for_file(&clients, Duration::from_secs(10)); + let mut survivor = shell(&clients, 100, 30); + let crashed = spawn_client(&config, &runtime, &api); + // Give the supported client process time to complete its ClientShell hello; + // the point of this test is a connected peer dying, not a failed launch. + thread::sleep(Duration::from_secs(1)); + let pid = crashed.child.process_id().unwrap(); + unsafe { + libc::kill(pid as libc::pid_t, libc::SIGKILL); + } + drop(crashed); + let response = api_request(&api, r#"{"id":"ping","method":"ping","params":{}}"#); + assert!(response.to_string().contains("pong")); + pane_input(&api, &create_pane(&api, "survivor"), "printf 'survivor\\n'"); + assert!(wait_for_message_variant( + &mut survivor, + Duration::from_secs(5), + SERVER_MESSAGE_PANE_SURFACE + ) + .unwrap()); + cleanup(server, base); } #[test] -fn multi_client_rapid_connect_disconnect_stress_10_cycles() { +fn rapid_client_shell_connect_disconnect_remains_healthy() { 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); - wait_for_socket(&api_socket, Duration::from_secs(10)); - wait_for_file(&client_socket, Duration::from_secs(10)); - - for i in 0..10u16 { - let mut client = connect_raw_client(&client_socket, 80 + i, 24 + (i % 4)); - let _ = wait_for_frame(&mut client, Duration::from_millis(500)); - send_client_detach(&mut client); + let config = base.join("config"); + let runtime = base.join("runtime"); + let api = runtime.join("herdr.sock"); + let clients = runtime.join("herdr-client.sock"); + let server = spawn_server(&config, &runtime, &api); + wait_for_socket(&api, Duration::from_secs(10)); + wait_for_file(&clients, Duration::from_secs(10)); + for i in 0..10 { + let mut client = shell(&clients, 80 + i, 24); + send_detach(&mut client).unwrap(); drop(client); - thread::sleep(Duration::from_millis(40)); } - - let ping = ping_socket(&api_socket); - assert!( - ping.contains("pong"), - "server should remain healthy after rapid connect/disconnect: {ping}" - ); - - let mut final_client = connect_raw_client(&client_socket, 100, 30); - assert!( - wait_for_frame(&mut final_client, Duration::from_secs(2)), - "new client should still connect and receive frames after stress" - ); - - cleanup_spawned_herdr(server, base); + let final_client = shell(&clients, 100, 30); + drop(final_client); + let response = api_request(&api, r#"{"id":"ping","method":"ping","params":{}}"#); + assert!(response.to_string().contains("pong")); + cleanup(server, base); } diff --git a/tests/server_headless.rs b/tests/server_headless.rs index 5abb5ace..f6eb0771 100644 --- a/tests/server_headless.rs +++ b/tests/server_headless.rs @@ -1,6 +1,6 @@ //! Integration tests for headless server mode. -mod support; +pub mod support; use std::fs; use std::io::{BufRead, BufReader, Read, Write}; @@ -13,7 +13,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; use support::{ - cleanup_test_base, register_runtime_dir, register_spawned_herdr_pid, + cleanup_test_base, client_handshake, register_runtime_dir, register_spawned_herdr_pid, unregister_spawned_herdr_pid, CURRENT_PROTOCOL, }; @@ -151,207 +151,6 @@ fn ping_socket(socket_path: &Path) -> String { response.trim().to_string() } -/// Sends a Hello message over the client socket and reads the Welcome response. -/// Uses bincode v2 wire format: [u32LE length][bincode payload] -/// bincode v2 standard config uses VarintEncoding: -/// - Integers < 251 are encoded as a single byte -/// - Enum variant index is encoded as u32 varint -/// - Option discriminant is always a single byte (0=None, 1=Some) -/// - String: length (varint) + UTF-8 bytes -fn client_handshake( - stream: &mut UnixStream, - version: u32, - cols: u16, - rows: u16, -) -> Result<(u32, Option<String>), String> { - stream - .set_read_timeout(Some(Duration::from_secs(5))) - .map_err(|e| e.to_string())?; - - // Encode Hello message using bincode v2 varint format. - // ClientMessage::Hello is variant 0. - let hello_payload = encode_varint_enum( - 0, - &[ - &encode_varint_u32(version), - &encode_varint_u16(cols), - &encode_varint_u16(rows), - &encode_varint_u32(8), // cell_width_px - &encode_varint_u32(16), // cell_height_px - &encode_varint_u32(0), // RenderEncoding::SemanticFrame - &encode_varint_u32(0), // ClientKeybindings::Server - &encode_varint_u32(0), // ClientLaunchMode::App - ], - ); - let framed = frame_message(&hello_payload); - stream.write_all(&framed).map_err(|e| e.to_string())?; - stream.flush().map_err(|e| e.to_string())?; - - // Read the framed response. - let mut len_buf = [0u8; 4]; - stream.read_exact(&mut len_buf).map_err(|e| e.to_string())?; - let len = u32::from_le_bytes(len_buf) as usize; - - if len > 2 * 1024 * 1024 { - return Err(format!("oversized response: {len}")); - } - - let mut payload = vec![0u8; len]; - stream.read_exact(&mut payload).map_err(|e| e.to_string())?; - - // Decode Welcome: ServerMessage variant 0 = Welcome { version: u32, error: Option<String> } - decode_welcome(&payload) -} - -/// Encode a varint u32 value according to bincode v2 VarintEncoding. -fn encode_varint_u32(v: u32) -> Vec<u8> { - if v < 251 { - vec![v as u8] - } else if v < 65536 { - let mut buf = vec![251u8]; - buf.extend_from_slice(&(v as u16).to_le_bytes()); - buf - } else { - let mut buf = vec![252u8]; - buf.extend_from_slice(&v.to_le_bytes()); - buf - } -} - -/// Encode a varint u16 value. -fn encode_varint_u16(v: u16) -> Vec<u8> { - if v < 251 { - vec![v as u8] - } else { - let mut buf = vec![251u8]; - buf.extend_from_slice(&v.to_le_bytes()); - buf - } -} - -/// Encode an enum variant with its fields. -fn encode_varint_enum(variant_idx: u32, fields: &[&[u8]]) -> Vec<u8> { - let mut buf = encode_varint_u32(variant_idx); - for field in fields { - buf.extend_from_slice(field); - } - buf -} - -/// Frame a message with u32LE length prefix. -fn frame_message(payload: &[u8]) -> Vec<u8> { - let len = payload.len() as u32; - let mut framed = len.to_le_bytes().to_vec(); - framed.extend_from_slice(payload); - framed -} - -/// Decode a varint u32 from a byte slice at the given offset. -/// Returns (value, bytes_consumed). -fn decode_varint_u32(payload: &[u8], offset: usize) -> Result<(u32, usize), String> { - if offset >= payload.len() { - return Err("payload too short for varint".into()); - } - let first_byte = payload[offset]; - match first_byte { - 0..=250 => Ok((first_byte as u32, 1)), - 251 => { - if offset + 3 > payload.len() { - return Err("payload too short for u16 varint".into()); - } - let v = u16::from_le_bytes( - payload[offset + 1..offset + 3] - .try_into() - .map_err(|e: std::array::TryFromSliceError| e.to_string())?, - ); - Ok((v as u32, 3)) - } - 252 => { - if offset + 5 > payload.len() { - return Err("payload too short for u32 varint".into()); - } - let v = u32::from_le_bytes( - payload[offset + 1..offset + 5] - .try_into() - .map_err(|e: std::array::TryFromSliceError| e.to_string())?, - ); - Ok((v, 5)) - } - _ => Err(format!("unsupported varint tag: {first_byte}")), - } -} - -/// Decode a varint u16 from a byte slice at the given offset. -#[allow(dead_code)] -fn decode_varint_u16(payload: &[u8], offset: usize) -> Result<(u16, usize), String> { - if offset >= payload.len() { - return Err("payload too short for varint".into()); - } - let first_byte = payload[offset]; - match first_byte { - 0..=250 => Ok((first_byte as u16, 1)), - 251 => { - if offset + 3 > payload.len() { - return Err("payload too short for u16 varint".into()); - } - let v = u16::from_le_bytes( - payload[offset + 1..offset + 3] - .try_into() - .map_err(|e: std::array::TryFromSliceError| e.to_string())?, - ); - Ok((v, 3)) - } - _ => Err(format!("unsupported varint tag for u16: {first_byte}")), - } -} - -/// Decode a ServerMessage::Welcome from bincode v2 payload. -fn decode_welcome(payload: &[u8]) -> Result<(u32, Option<String>), String> { - let mut offset = 0; - - // Variant index (should be 0 for Welcome) - let (variant, consumed) = decode_varint_u32(payload, offset)?; - offset += consumed; - if variant != 0 { - return Err(format!( - "expected Welcome (variant 0), got variant {variant}" - )); - } - - // version: u32 - let (version, consumed) = decode_varint_u32(payload, offset)?; - offset += consumed; - - // encoding: RenderEncoding - let (_encoding, consumed) = decode_varint_u32(payload, offset)?; - offset += consumed; - - // error: Option<String> — discriminant is always 1 byte - if offset >= payload.len() { - return Err("payload too short for Option tag".into()); - } - let option_tag = payload[offset]; - offset += 1; - - let error = if option_tag == 1 { - // Some(String) — length as varint + UTF-8 bytes - let (str_len, consumed) = decode_varint_u32(payload, offset)?; - offset += consumed; - let str_len = str_len as usize; - - if offset + str_len > payload.len() { - return Err("payload too short for string content".into()); - } - let s = String::from_utf8(payload[offset..offset + str_len].to_vec()) - .map_err(|e| e.to_string())?; - Some(s) - } else { - None - }; - - Ok((version, error)) -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/tests/support/mod.rs b/tests/support/mod.rs index f295dbbe..1da9985b 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - use std::collections::HashSet; use std::fs; use std::io::{Read, Write}; @@ -16,6 +14,13 @@ static CLEANUP_GUARD: OnceLock<CleanupGuard> = OnceLock::new(); const WATCHDOG_SCAN_INTERVAL: Duration = Duration::from_secs(1); const RUNTIME_OWNER_MARKER: &str = ".herdr-test-owner-pid"; pub const CURRENT_PROTOCOL: u32 = 21; +pub const SERVER_MESSAGE_SERVER_SHUTDOWN: u32 = 3; +pub const SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT: u32 = 12; +pub const SERVER_MESSAGE_PANE_SURFACE: u32 = 13; +pub const SERVER_MESSAGE_SEMANTIC_NOTIFICATION: u32 = 14; +pub const SERVER_MESSAGE_PANE_SURFACE_PATCH: u32 = 19; +const CLIENT_MESSAGE_CLIENT_SHELL_HELLO: u32 = 11; +const CLIENT_MESSAGE_CLIENT_SHELL_PANE_INPUT: u32 = 13; pub fn register_spawned_herdr_pid(pid: Option<u32>) { let Some(pid) = pid else { @@ -108,7 +113,7 @@ pub fn wait_for_file(path: &Path, timeout: Duration) { panic!("file did not appear at {}", path.display()); } -pub fn encode_varint_u32(v: u32) -> Vec<u8> { +fn encode_varint_u32(v: u32) -> Vec<u8> { if v < 251 { vec![v as u8] } else if v < 65536 { @@ -122,7 +127,7 @@ pub fn encode_varint_u32(v: u32) -> Vec<u8> { } } -pub fn encode_varint_u16(v: u16) -> Vec<u8> { +fn encode_varint_u16(v: u16) -> Vec<u8> { if v < 251 { vec![v as u8] } else { @@ -132,14 +137,14 @@ pub fn encode_varint_u16(v: u16) -> Vec<u8> { } } -pub fn frame_message(payload: &[u8]) -> Vec<u8> { +fn frame_message(payload: &[u8]) -> Vec<u8> { let len = payload.len() as u32; let mut framed = len.to_le_bytes().to_vec(); framed.extend_from_slice(payload); framed } -pub fn decode_varint_u32(payload: &[u8], offset: usize) -> Result<(u32, usize), String> { +fn decode_varint_u32(payload: &[u8], offset: usize) -> Result<(u32, usize), String> { if offset >= payload.len() { return Err("payload too short for varint".into()); } @@ -220,16 +225,35 @@ fn decode_welcome(payload: &[u8]) -> Result<(u32, Option<String>), String> { Ok((version, error)) } -pub fn client_handshake( +fn finish_handshake( stream: &mut UnixStream, - version: u32, - cols: u16, - rows: u16, + hello_payload: &[u8], ) -> Result<(u32, Option<String>), String> { stream .set_read_timeout(Some(Duration::from_secs(5))) .map_err(|e| e.to_string())?; + stream + .write_all(&frame_message(hello_payload)) + .map_err(|e| e.to_string())?; + stream.flush().map_err(|e| e.to_string())?; + + let mut len_buf = [0u8; 4]; + stream.read_exact(&mut len_buf).map_err(|e| e.to_string())?; + let len = u32::from_le_bytes(len_buf) as usize; + if len > 2 * 1024 * 1024 { + return Err(format!("oversized response: {len}")); + } + let mut payload = vec![0u8; len]; + stream.read_exact(&mut payload).map_err(|e| e.to_string())?; + decode_welcome(&payload) +} +pub fn client_handshake( + stream: &mut UnixStream, + version: u32, + cols: u16, + rows: u16, +) -> Result<(u32, Option<String>), String> { let hello_payload = encode_varint_enum( 0, &[ @@ -238,25 +262,33 @@ pub fn client_handshake( &encode_varint_u16(rows), &encode_varint_u32(8), // cell_width_px &encode_varint_u32(16), // cell_height_px - &encode_varint_u32(0), // RenderEncoding::SemanticFrame - &encode_varint_u32(0), // ClientKeybindings::Server - &encode_varint_u32(0), // ClientLaunchMode::App + &[0], // pixel_mouse = false ], ); - let framed = frame_message(&hello_payload); - stream.write_all(&framed).map_err(|e| e.to_string())?; - stream.flush().map_err(|e| e.to_string())?; - - let mut len_buf = [0u8; 4]; - stream.read_exact(&mut len_buf).map_err(|e| e.to_string())?; - let len = u32::from_le_bytes(len_buf) as usize; - if len > 2 * 1024 * 1024 { - return Err(format!("oversized response: {len}")); - } + finish_handshake(stream, &hello_payload) +} - let mut payload = vec![0u8; len]; - stream.read_exact(&mut payload).map_err(|e| e.to_string())?; - decode_welcome(&payload) +pub fn client_shell_handshake( + stream: &mut UnixStream, + version: u32, + surface_cols: u16, + surface_rows: u16, +) -> Result<(u32, Option<String>), String> { + let hello_payload = encode_varint_enum( + CLIENT_MESSAGE_CLIENT_SHELL_HELLO, + &[ + &encode_varint_u32(version), + &encode_varint_u32(8), + &encode_varint_u32(16), + &encode_varint_u16(surface_cols), + &encode_varint_u16(surface_rows), + &[0], // pixel mouse disabled + &[0], // direct graphics disabled + &[0], // client-owned keybindings + &[0], // mouse capture disabled + ], + ); + finish_handshake(stream, &hello_payload) } pub fn read_server_message(stream: &mut UnixStream) -> Result<(u32, Vec<u8>), String> { @@ -281,16 +313,27 @@ pub fn read_server_message(stream: &mut UnixStream) -> Result<(u32, Vec<u8>), St Ok((variant, payload[consumed..].to_vec())) } -pub fn send_input(stream: &mut UnixStream, data: &[u8]) -> Result<(), String> { - let mut buf = encode_varint_u32(1); - buf.extend_from_slice(&encode_varint_u32(data.len() as u32)); - buf.extend_from_slice(data); - let framed = frame_message(&buf); +pub fn send_client_shell_shift_enter(stream: &mut UnixStream, pane_id: &str) -> Result<(), String> { + let mut payload = encode_varint_u32(CLIENT_MESSAGE_CLIENT_SHELL_PANE_INPUT); + payload.extend_from_slice(&encode_varint_u32(pane_id.len() as u32)); + payload.extend_from_slice(pane_id.as_bytes()); + payload.extend_from_slice(&encode_varint_u32(1)); // one pane input event + payload.extend_from_slice(&encode_varint_u32(0)); // Key + payload.extend_from_slice(&encode_varint_u32(1)); // Enter + payload.push(1); // Shift + payload.extend_from_slice(&encode_varint_u32(0)); // Press + payload.extend_from_slice(&encode_varint_u16(1)); + payload.push(0); // no shifted codepoint + payload.push(0); // no generated text + payload.push(0); // does not track release + payload.push(0); // no physical key id + stream - .write_all(&framed) - .map_err(|e| format!("write input: {e}"))?; - stream.flush().map_err(|e| format!("flush input: {e}"))?; - Ok(()) + .write_all(&frame_message(&payload)) + .map_err(|e| format!("write client shell key: {e}"))?; + stream + .flush() + .map_err(|e| format!("flush client shell key: {e}")) } pub fn send_detach(stream: &mut UnixStream) -> Result<(), String> { @@ -330,13 +373,21 @@ pub fn wait_for_message_variant( timeout: Duration, variant: u32, ) -> Result<bool, String> { + wait_for_message_variants(stream, timeout, &[variant]) +} + +pub fn wait_for_message_variants( + stream: &mut UnixStream, + timeout: Duration, + variants: &[u32], +) -> Result<bool, 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((got, _)) if got == variant => return Ok(true), + Ok((got, _)) if variants.contains(&got) => return Ok(true), Ok(_) => continue, Err(_) => continue, } @@ -344,6 +395,35 @@ pub fn wait_for_message_variant( Ok(false) } +pub fn wait_for_client_shell_bootstrap( + 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; + let mut saw_snapshot = false; + while Instant::now() < deadline { + match read_server_message(stream) { + Ok((SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT, _)) => saw_snapshot = true, + Ok((SERVER_MESSAGE_PANE_SURFACE, _)) if saw_snapshot => return Ok(()), + Ok((SERVER_MESSAGE_PANE_SURFACE, _)) => { + return Err("client shell pane surface arrived before its snapshot".into()); + } + Ok(_) | Err(_) => {} + } + } + Err(format!( + "timed out waiting for client shell {}", + if saw_snapshot { + "pane surface" + } else { + "snapshot" + } + )) +} + pub fn wait_for_disconnect(stream: &mut UnixStream, timeout: Duration) -> Result<bool, String> { stream.set_nonblocking(true).map_err(|e| e.to_string())?; let deadline = Instant::now() + timeout; |
