From 35707684b9de1b2e2b6be5de5a49e69ad57ac36b Mon Sep 17 00:00:00 2001 From: Eric Yue Date: Tue, 15 Sep 2026 21:28:59 +0800 Subject: test: wait for runtime readiness in integration tests (#4168) Root cause: The handoff test starts an agent immediately after workspace creation, before the new shell is necessarily ready. The reconnect test assumes a fixed sidebar row and searches raw ANSI output for text that can arrive as separate incremental cell updates. Fix: Wait for a shell builtin to write a readiness marker before sending the single raw agent.start request. Exercise the gap with a delayed shell. Locate the recovered workspace in the rendered sidebar and check the reconstructed screen for selection and input, including overlay borders. Do not extend existing timeouts or add retries that hide failures. Validation: All 25 affected group and helper tests pass. Ten repeated rounds of the two runtime tests and the row helper pass, for 30 test executions without retries. Standalone native just ci passes 3582 Rust tests with six default skips, formatting, all-target Clippy, 106 maintenance tests, six architecture tests and 39 integration-asset tests. Windows cross-lint was not run because its SDK is not installed. Co-authored-by: JJ Liebig --- tests/client_mode.rs | 47 +++++++++++++++++++++++++++++++++++++++++------ tests/live_handoff.rs | 25 ++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 7 deletions(-) (limited to 'tests') diff --git a/tests/client_mode.rs b/tests/client_mode.rs index a946af7c..71d3ff8d 100644 --- a/tests/client_mode.rs +++ b/tests/client_mode.rs @@ -716,6 +716,39 @@ fn output_len(output: &SharedOutput) -> usize { output.lock().unwrap_or_else(|p| p.into_inner()).text.len() } +fn sidebar_row_click(screen: &str, label: &str) -> Vec { + let sidebar_width = screen + .lines() + .find_map(|line| line.chars().position(|character| character == '│')) + .expect("visible sidebar boundary"); + let row = screen + .lines() + .position(|line| { + line.chars() + .take(sidebar_width) + .collect::() + .contains(label) + }) + .unwrap_or_else(|| panic!("sidebar row {label:?} is not visible: {screen}")) + + 1; + format!("\x1b[<0;7;{row}M\x1b[<0;7;{row}m").into_bytes() +} + +#[test] +fn sidebar_row_click_tracks_restored_workspace_count() { + for restored in [false, true] { + let screen = format!( + " machines │\n │\n ▾ Local │local-returned in pane output\n{} · local-returned └─────────────────\n", + if restored { " · restored │\n" } else { "" } + ); + let row = if restored { 5 } else { 4 }; + assert_eq!( + sidebar_row_click(&screen, "local-returned"), + format!("\x1b[<0;7;{row}M\x1b[<0;7;{row}m").into_bytes() + ); + } +} + /// Spawns a server + real thin client under a PTY and waits until the client /// has attached and rendered a frame. Returns the pieces plus a shared buffer /// that keeps accumulating PTY output (including teardown) on a background @@ -1074,20 +1107,22 @@ fn federated_client_starts_without_local_and_survives_its_restart() { local_pane, "printf 'LOCAL_RECOVERED_SURFACE\\n'", ); - let watermark = output_len(&output); // Select the fresh workspace below Local's restored workspace. - input.write_all(b"\x1b[<0;7;5M\x1b[<0;7;5m").unwrap(); + // A fast shutdown may leave no saved workspace, so locate the actual row. + input + .write_all(&sidebar_row_click(&screen_text(), "local-returned")) + .unwrap(); assert!( wait_until(Duration::from_secs(10), Duration::from_millis(20), || { - read_output(&output)[watermark..].contains("LOCAL_RECOVERED_SURFACE") + screen_text().contains("LOCAL_RECOVERED_SURFACE") }), "recovered Local must be selectable: {}", - read_output(&output) + screen_text() ); // A coherent frame precedes the final host-effects fence; input stays gated until then. assert!( wait_until(Duration::from_secs(8), Duration::from_millis(100), || { - if read_output(&output)[watermark..].contains("LOCAL_INPUT_RECOVERED") { + if screen_text().contains("LOCAL_INPUT_RECOVERED") { return true; } input @@ -1096,7 +1131,7 @@ fn federated_client_starts_without_local_and_survives_its_restart() { false }), "recovered Local must accept input: {}", - read_output(&output) + screen_text() ); drop(input); drop(client); diff --git a/tests/live_handoff.rs b/tests/live_handoff.rs index e40f8268..ea4b84df 100644 --- a/tests/live_handoff.rs +++ b/tests/live_handoff.rs @@ -1581,9 +1581,13 @@ fn live_handoff_keeps_agent_started_pane_after_agent_exits() { let api_socket = runtime_dir.join("herdr.sock"); let started_marker = base.join("agent-started"); let exited_marker = base.join("agent-exited"); + let ready_marker = base.join("shell-ready"); let shell_marker = base.join("shell-after-agent"); let bin = base.join("bin"); fs::create_dir_all(&bin).unwrap(); + let delayed_shell = bin.join("delayed-shell"); + fs::write(&delayed_shell, "#!/bin/sh\n/bin/sleep 0.4\nexec /bin/sh\n").unwrap(); + fs::set_permissions(&delayed_shell, fs::Permissions::from_mode(0o755)).unwrap(); let fake_pi = bin.join("pi"); fs::write( &fake_pi, @@ -1601,7 +1605,10 @@ fn live_handoff_keeps_agent_started_pane_after_agent_exits() { &config_home, &runtime_dir, &api_socket, - &[("PATH", path.as_str())], + &[ + ("PATH", path.as_str()), + ("SHELL", delayed_shell.to_str().unwrap()), + ], ); wait_for_socket(&api_socket, Duration::from_secs(10)); register_runtime_dir(&runtime_dir); @@ -1619,6 +1626,22 @@ fn live_handoff_keeps_agent_started_pane_after_agent_exits() { .unwrap() .to_string(); + assert_ok(request( + &api_socket, + serde_json::json!({ + "id": "test:shell-ready", + "method": "pane.send_input", + "params": { + "pane_id": pane_id, + "text": format!("printf ready > {}", ready_marker.display()), + "keys": ["Enter"] + } + }), + )); + // Creation acknowledges the PTY, not an idle interactive shell. A real + // shell command must execute before this raw agent.start request. + support::wait_for_file(&ready_marker, Duration::from_secs(5)); + let started = request( &api_socket, serde_json::json!({ -- cgit v1.2.3