aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-28 04:11:56 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-28 04:12:53 +0300
commitbf9cc201244946496b4d950046bf7edcc4892994 (patch)
tree920c866e67783cf31d2feff9642457204999c8eb /tests
parent789738cada5afaa20bbd77a0d479fb689c455bfe (diff)
fix: preserve live handoff terminal state
Diffstat (limited to 'tests')
-rw-r--r--tests/live_handoff.rs405
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();