diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-28 04:11:56 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-28 04:12:53 +0300 |
| commit | bf9cc201244946496b4d950046bf7edcc4892994 (patch) | |
| tree | 920c866e67783cf31d2feff9642457204999c8eb /tests | |
| parent | 789738cada5afaa20bbd77a0d479fb689c455bfe (diff) | |
fix: preserve live handoff terminal state
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/live_handoff.rs | 405 |
1 files changed, 404 insertions, 1 deletions
diff --git a/tests/live_handoff.rs b/tests/live_handoff.rs index d9a8b34f..f55b5875 100644 --- a/tests/live_handoff.rs +++ b/tests/live_handoff.rs @@ -13,7 +13,7 @@ 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, - unregister_spawned_herdr_pid, wait_for_disconnect, wait_for_socket, + send_input, unregister_spawned_herdr_pid, wait_for_disconnect, wait_for_socket, }; struct SpawnedHerdr { @@ -537,6 +537,409 @@ fn live_handoff_preserves_pane_process_io() { } #[test] +fn live_handoff_preserves_keyboard_protocol_for_client_input() { + 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 script = base.join("read-raw.py"); + let ready_marker = base.join("keyboard-ready"); + let received_marker = base.join("keyboard-received"); + + fs::create_dir_all(&base).unwrap(); + fs::write( + &script, + format!( + r#"import os +import pathlib +import select +import sys +import tty + +sys.stdout.buffer.write(b"\x1b[>5u") +sys.stdout.flush() +pathlib.Path({ready:?}).write_text("ready") +tty.setraw(sys.stdin.fileno()) +ready_fds, _, _ = select.select([sys.stdin.fileno()], [], [], 5) +data = os.read(sys.stdin.fileno(), 32) if ready_fds else b"" +pathlib.Path({received:?}).write_text(data.hex()) +"#, + ready = ready_marker.display().to_string(), + received = received_marker.display().to_string() + ), + ) + .unwrap(); + + let spawned = spawn_server(&config_home, &runtime_dir, &api_socket); + wait_for_socket(&api_socket, Duration::from_secs(10)); + register_runtime_dir(&runtime_dir); + + let created = request( + &api_socket, + serde_json::json!({ + "id": "test:workspace:create", + "method": "workspace.create", + "params": {"cwd": "/tmp", "focus": true} + }), + ); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + assert_ok(request( + &api_socket, + serde_json::json!({ + "id": "test:pane:run", + "method": "pane.send_input", + "params": {"pane_id": pane_id, "text": format!("python3 {}", script.display()), "keys": ["Enter"]} + }), + )); + support::wait_for_file(&ready_marker, Duration::from_secs(5)); + + let protocol = request( + &api_socket, + serde_json::json!({"id":"test:protocol","method":"ping","params":{}}), + )["result"]["protocol"] + .as_u64() + .unwrap() as u32; + assert_ok(request( + &api_socket, + serde_json::json!({"id":"test:handoff","method":"server.live_handoff","params":{}}), + )); + drop(spawned); + wait_for_api(&api_socket, Duration::from_secs(10)); + 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(); + assert_eq!(server_protocol, protocol); + assert!(error.is_none(), "client handshake failed: {error:?}"); + send_input(&mut client_stream, b"\x1b[13;2u").unwrap(); + + wait_for_file_contains(&received_marker, "1b5b31333b3275", Duration::from_secs(5)); + + let _ = request( + &api_socket, + serde_json::json!({"id":"test:stop","method":"server.stop","params":{}}), + ); + cleanup_test_base(&base); +} + +#[test] +fn live_handoff_preserves_modify_other_keys_for_client_input() { + 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 script = base.join("read-raw.py"); + let ready_marker = base.join("modify-ready"); + let received_marker = base.join("modify-received"); + + fs::create_dir_all(&base).unwrap(); + fs::write( + &script, + format!( + r#"import os +import pathlib +import select +import sys +import tty + +sys.stdout.buffer.write(b"\x1b[>4;2m") +sys.stdout.flush() +pathlib.Path({ready:?}).write_text("ready") +tty.setraw(sys.stdin.fileno()) +ready_fds, _, _ = select.select([sys.stdin.fileno()], [], [], 5) +data = os.read(sys.stdin.fileno(), 32) if ready_fds else b"" +pathlib.Path({received:?}).write_text(data.hex()) +"#, + ready = ready_marker.display().to_string(), + received = received_marker.display().to_string() + ), + ) + .unwrap(); + + let spawned = spawn_server(&config_home, &runtime_dir, &api_socket); + wait_for_socket(&api_socket, Duration::from_secs(10)); + register_runtime_dir(&runtime_dir); + + let created = request( + &api_socket, + serde_json::json!({ + "id": "test:workspace:create", + "method": "workspace.create", + "params": {"cwd": "/tmp", "focus": true} + }), + ); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + assert_ok(request( + &api_socket, + serde_json::json!({ + "id": "test:pane:run", + "method": "pane.send_input", + "params": {"pane_id": pane_id, "text": format!("python3 {}", script.display()), "keys": ["Enter"]} + }), + )); + support::wait_for_file(&ready_marker, Duration::from_secs(5)); + + let protocol = request( + &api_socket, + serde_json::json!({"id":"test:protocol","method":"ping","params":{}}), + )["result"]["protocol"] + .as_u64() + .unwrap() as u32; + assert_ok(request( + &api_socket, + serde_json::json!({"id":"test:handoff","method":"server.live_handoff","params":{}}), + )); + drop(spawned); + wait_for_api(&api_socket, Duration::from_secs(10)); + 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(); + assert_eq!(server_protocol, protocol); + assert!(error.is_none(), "client handshake failed: {error:?}"); + send_input(&mut client_stream, b"\x1b[13;2u").unwrap(); + + wait_for_file_contains( + &received_marker, + "1b5b32373b323b31337e", + Duration::from_secs(5), + ); + + let _ = request( + &api_socket, + serde_json::json!({"id":"test:stop","method":"server.stop","params":{}}), + ); + cleanup_test_base(&base); +} + +#[test] +fn live_handoff_accepts_old_pane_id_from_child_env() { + 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 pane_id_marker = base.join("old-pane-id"); + + let spawned = spawn_server(&config_home, &runtime_dir, &api_socket); + wait_for_socket(&api_socket, Duration::from_secs(10)); + register_runtime_dir(&runtime_dir); + + let created = request( + &api_socket, + serde_json::json!({ + "id": "test:workspace:create", + "method": "workspace.create", + "params": {"cwd": "/tmp", "focus": true} + }), + ); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + assert_ok(request( + &api_socket, + serde_json::json!({ + "id": "test:pane:print-id", + "method": "pane.send_input", + "params": {"pane_id": pane_id, "text": format!("printf '%s' \"$HERDR_PANE_ID\" > {}", pane_id_marker.display()), "keys": ["Enter"]} + }), + )); + support::wait_for_file(&pane_id_marker, Duration::from_secs(5)); + let old_pane_id = fs::read_to_string(&pane_id_marker).unwrap(); + assert!( + old_pane_id.starts_with("p_"), + "unexpected pane id from env: {old_pane_id:?}" + ); + + assert_ok(request( + &api_socket, + serde_json::json!({"id":"test:handoff","method":"server.live_handoff","params":{}}), + )); + drop(spawned); + wait_for_api(&api_socket, Duration::from_secs(10)); + + assert_ok(request( + &api_socket, + serde_json::json!({ + "id": "test:old-pane-report", + "method": "pane.report_agent", + "params": { + "pane_id": old_pane_id, + "source": "handoff-test", + "agent": "pi", + "state": "working" + } + }), + )); + let agents = request( + &api_socket, + serde_json::json!({"id":"test:agent-list","method":"agent.list","params":{}}), + ); + let found = agents["result"]["agents"] + .as_array() + .unwrap() + .iter() + .any(|agent| { + agent["agent"].as_str() == Some("pi") + && agent["agent_status"].as_str() == Some("working") + }); + assert!( + found, + "old pane id report did not update restored pane: {agents}" + ); + + let _ = request( + &api_socket, + serde_json::json!({"id":"test:stop","method":"server.stop","params":{}}), + ); + cleanup_test_base(&base); +} + +#[test] +fn live_handoff_keeps_agent_started_pane_after_agent_exits() { + let _lock = test_lock(); + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let api_socket = runtime_dir.join("herdr.sock"); + let started_marker = base.join("agent-started"); + let exited_marker = base.join("agent-exited"); + let shell_marker = base.join("shell-after-agent"); + + let spawned = spawn_server(&config_home, &runtime_dir, &api_socket); + wait_for_socket(&api_socket, Duration::from_secs(10)); + register_runtime_dir(&runtime_dir); + + let command = format!( + "echo started > {}; sleep 1; echo exited > {}", + started_marker.display(), + exited_marker.display() + ); + let started = request( + &api_socket, + serde_json::json!({ + "id": "test:agent-start", + "method": "agent.start", + "params": { + "name": "handoff-agent", + "cwd": "/tmp", + "focus": true, + "argv": ["/bin/sh", "-c", command] + } + }), + ); + assert_ok(started.clone()); + let pane_id = started["result"]["agent"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + support::wait_for_file(&started_marker, Duration::from_secs(5)); + + assert_ok(request( + &api_socket, + serde_json::json!({"id":"test:handoff","method":"server.live_handoff","params":{}}), + )); + drop(spawned); + wait_for_api(&api_socket, Duration::from_secs(10)); + support::wait_for_file(&exited_marker, Duration::from_secs(5)); + thread::sleep(Duration::from_millis(300)); + + assert_ok(request( + &api_socket, + serde_json::json!({ + "id": "test:pane:shell-after-agent", + "method": "pane.send_input", + "params": {"pane_id": pane_id, "text": format!("echo alive > {}", shell_marker.display()), "keys": ["Enter"]} + }), + )); + support::wait_for_file(&shell_marker, Duration::from_secs(5)); + + let _ = request( + &api_socket, + serde_json::json!({"id":"test:stop","method":"server.stop","params":{}}), + ); + cleanup_test_base(&base); +} + +#[test] +fn live_handoff_keeps_shell_pane_after_foreground_process_exits() { + let _lock = test_lock(); + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let api_socket = runtime_dir.join("herdr.sock"); + let started_marker = base.join("foreground-started"); + let exited_marker = base.join("foreground-exited"); + let shell_marker = base.join("shell-after-foreground"); + + let spawned = spawn_server(&config_home, &runtime_dir, &api_socket); + wait_for_socket(&api_socket, Duration::from_secs(10)); + register_runtime_dir(&runtime_dir); + + let created = request( + &api_socket, + serde_json::json!({ + "id": "test:workspace:create", + "method": "workspace.create", + "params": {"cwd": "/tmp", "focus": true} + }), + ); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + let command = format!( + "sh -c 'echo started > {}; sleep 1; echo exited > {}'", + started_marker.display(), + exited_marker.display() + ); + assert_ok(request( + &api_socket, + serde_json::json!({ + "id": "test:pane:run-foreground", + "method": "pane.send_input", + "params": {"pane_id": pane_id, "text": command, "keys": ["Enter"]} + }), + )); + support::wait_for_file(&started_marker, Duration::from_secs(5)); + + assert_ok(request( + &api_socket, + serde_json::json!({"id":"test:handoff","method":"server.live_handoff","params":{}}), + )); + drop(spawned); + wait_for_api(&api_socket, Duration::from_secs(10)); + support::wait_for_file(&exited_marker, Duration::from_secs(5)); + + assert_ok(request( + &api_socket, + serde_json::json!({ + "id": "test:pane:shell-after-foreground", + "method": "pane.send_input", + "params": {"pane_id": pane_id, "text": format!("echo alive > {}", shell_marker.display()), "keys": ["Enter"]} + }), + )); + support::wait_for_file(&shell_marker, Duration::from_secs(5)); + + let _ = request( + &api_socket, + serde_json::json!({"id":"test:stop","method":"server.stop","params":{}}), + ); + cleanup_test_base(&base); +} + +#[test] fn live_handoff_preserves_python_http_server() { let _lock = test_lock(); let base = unique_test_dir(); |
