Files
wehub-resource-sync a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:34 +08:00

689 lines
26 KiB
Rust

use crate::test_support::*;
// ============================================================================
// Binary Integration Tests
// These tests run the actual jcode binary and require real credentials.
// Run with: cargo test --test e2e binary_integration -- --ignored
// ============================================================================
// ----------------------------------------------------------------------------
// Reload/handoff robustness coverage map (for future contributors)
//
// Unit-level (no credentials, run by default):
// - server::reload_state::tests + server::socket_tests: marker/handoff state
// machine (Ready/Waiting/Failed/Idle verdicts, dead-pid crash detection,
// stale/foreign/completed marker cleanup, Failed-marker preservation,
// corrupt-marker tolerance, bounded handoff-event wait).
// - server::reload::reload_tests: graceful shutdown signaling, timeout, and
// partial-checkpoint behavior; recovery-intent persistence for peers.
// - server::reload_recovery::tests: recovery-store path-traversal safety,
// persist/peek roundtrip, non-consuming directive peek, delivery
// idempotency + continuation mismatch.
// - server::util::reload_target_tests: no-downgrade exec-target guard.
//
// E2E (real spawned process, run with --ignored; need a release binary):
// - binary_integration_reload_handoff: server identity changes, marker clears.
// - binary_integration_selfdev_reload_reconnects_quickly: repeated reloads.
// - binary_integration_selfdev_client_reload_resumes_session.
// - binary_integration_selfdev_full_reload_resumes_session_quickly.
//
// Known E2E gaps worth adding when a release binary is available:
// - Concurrent/rapid `client.reload()` calls collapsing into one handoff
// without stranding the client or leaving a stuck marker.
// - A pre-existing *foreign* stale reload marker (different pid) in the
// runtime dir at boot being cleared rather than blocking startup.
// - Crash-during-boot of the replacement server (e.g. point the reload
// candidate at a binary that exits non-zero) resolving the waiting client
// to a Failed verdict instead of an indefinite hang.
// ----------------------------------------------------------------------------
/// Test that the jcode binary can run independent with Claude provider
#[tokio::test]
#[ignore] // Requires Claude credentials
async fn binary_integration_independent_claude() -> Result<()> {
use std::process::Command;
let _env = setup_test_env()?;
let output = Command::new("cargo")
.args([
"run",
"--release",
"--bin",
"jcode",
"--",
"run",
"Say 'test-ok' and nothing else",
])
.output()?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success() || stdout.contains("test") || stderr.contains("Claude"),
"Binary should run successfully. stdout: {}, stderr: {}",
stdout,
stderr
);
Ok(())
}
/// Test that the jcode binary can run with OpenAI provider
#[tokio::test]
#[ignore] // Requires OpenAI/Codex credentials
async fn binary_integration_openai_provider() -> Result<()> {
use std::process::Command;
let _env = setup_test_env()?;
let output = Command::new("cargo")
.args([
"run",
"--release",
"--bin",
"jcode",
"--",
"--provider",
"openai",
"run",
"Say 'openai-ok' and nothing else",
])
.output()?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
// Check either success or identifiable OpenAI response
let has_response = stdout.to_lowercase().contains("openai")
|| stdout.to_lowercase().contains("ok")
|| stderr.contains("OpenAI");
assert!(
output.status.success() || has_response,
"OpenAI provider should work. stdout: {}, stderr: {}",
stdout,
stderr
);
Ok(())
}
/// Test that jcode version command works
#[tokio::test]
async fn binary_version_command() -> Result<()> {
use std::process::Command;
let _env = setup_test_env()?;
let output = Command::new(env!("CARGO_BIN_EXE_jcode"))
.arg("--version")
.output()?;
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success(), "Version command should succeed");
assert!(
stdout.contains("jcode") || stdout.contains("20"),
"Version should contain 'jcode' or date. Got: {}",
stdout
);
Ok(())
}
/// Test full server reload handoff against a real spawned server process.
///
/// Requires a built release binary at target/release/jcode because the reload
/// flow execs into the repo's reload candidate.
#[tokio::test]
#[ignore]
async fn binary_integration_reload_handoff() -> Result<()> {
let _env = setup_test_env()?;
let release_binary =
jcode::build::release_binary_path(std::path::Path::new(env!("CARGO_MANIFEST_DIR")));
if !release_binary.exists() {
anyhow::bail!(
"release binary missing at {} (run `cargo build --release` first)",
release_binary.display()
);
}
let temp_root = tempfile::Builder::new()
.prefix("jcode-reload-e2e-")
.tempdir()?;
let runtime_dir = temp_root.path().join("runtime");
let home_dir = temp_root.path().join("home");
let install_dir = temp_root.path().join("install");
let stderr_path = temp_root.path().join("server-stderr.log");
std::fs::create_dir_all(&runtime_dir)?;
std::fs::create_dir_all(&home_dir)?;
std::fs::create_dir_all(&install_dir)?;
let socket_path = runtime_dir.join("jcode.sock");
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
let stderr_file = std::fs::File::create(&stderr_path)?;
let mut child = Command::new(env!("CARGO_BIN_EXE_jcode"))
.arg("--no-update")
.arg("--socket")
.arg(&socket_path)
.arg("serve")
// This test must exercise the real exec-based reload handoff, not the
// in-process test shortcut used by other e2e cases.
.env_remove("JCODE_TEST_SESSION")
.env("JCODE_HOME", &home_dir)
.env("JCODE_RUNTIME_DIR", &runtime_dir)
.env("JCODE_INSTALL_DIR", &install_dir)
.env("JCODE_DEBUG_CONTROL", "1")
.env("JCODE_TEMP_SERVER", "1")
.env("JCODE_SERVER_OWNER_PID", std::process::id().to_string())
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::from(stderr_file))
.spawn()?;
let test_result = async {
wait_for_server_ready(&socket_path, &debug_socket_path).await?;
let server_info_before =
debug_run_command(debug_socket_path.clone(), "server:info", None).await?;
let server_info_before_json: serde_json::Value = serde_json::from_str(&server_info_before)?;
let server_id_before = server_info_before_json
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing server id before reload"))?
.to_string();
let mut client = wait_for_server_client(&socket_path).await?;
client.reload().await?;
let disconnect_deadline = Instant::now() + Duration::from_secs(10);
let mut saw_disconnect = false;
while Instant::now() < disconnect_deadline {
match tokio::time::timeout(Duration::from_secs(1), client.read_event()).await {
Ok(Ok(_)) => continue,
Ok(Err(_)) | Err(_) => {
saw_disconnect = true;
break;
}
}
}
assert!(
saw_disconnect,
"old client connection never disconnected during reload"
);
let marker_deadline = Instant::now() + Duration::from_secs(20);
while jcode::server::reload_marker_active(Duration::from_secs(30)) {
if Instant::now() >= marker_deadline {
anyhow::bail!("reload marker remained active too long after restart");
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
wait_for_server_ready(&socket_path, &debug_socket_path).await?;
let _client = wait_for_server_client(&socket_path).await?;
let server_info_after =
debug_run_command(debug_socket_path.clone(), "server:info", None).await?;
let server_info_after_json: serde_json::Value = serde_json::from_str(&server_info_after)?;
let server_id_after = server_info_after_json
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing server id after reload"))?;
assert_ne!(
server_id_after, server_id_before,
"server identity should change after exec-based reload"
);
assert!(
server_info_after_json
.get("uptime_secs")
.and_then(|v| v.as_u64())
.is_some(),
"replacement server should answer debug state queries after reload"
);
Ok::<_, anyhow::Error>(())
}
.await;
kill_child(&mut child);
if let Err(ref error) = test_result {
if let Ok(stderr) = std::fs::read_to_string(&stderr_path) {
eprintln!("spawned server stderr:\n{}", stderr);
}
eprintln!("reload e2e test error: {error:#}");
}
test_result
}
/// Test repeated self-dev reload handoff against a real TUI client running in a PTY.
///
/// Requires a built release binary at target/release/jcode because the
/// self-dev server reload path execs into the repo's reload candidate.
#[cfg(unix)]
#[tokio::test]
#[ignore]
async fn binary_integration_selfdev_reload_reconnects_quickly() -> Result<()> {
let _env = setup_test_env()?;
let release_binary =
jcode::build::release_binary_path(std::path::Path::new(env!("CARGO_MANIFEST_DIR")));
if !release_binary.exists() {
anyhow::bail!(
"release binary missing at {} (run `cargo build --release` first)",
release_binary.display()
);
}
let temp_root = tempfile::Builder::new()
.prefix("jcode-selfdev-reload-e2e-")
.tempdir()?;
let runtime_dir = temp_root.path().join("runtime");
let home_dir = temp_root.path().join("home");
let install_dir = temp_root.path().join("install");
std::fs::create_dir_all(&runtime_dir)?;
std::fs::create_dir_all(&home_dir)?;
std::fs::create_dir_all(&install_dir)?;
let _home_guard = EnvVarGuard::set("JCODE_HOME", &home_dir);
let _runtime_guard = EnvVarGuard::set("JCODE_RUNTIME_DIR", &runtime_dir);
let _install_guard = EnvVarGuard::set("JCODE_INSTALL_DIR", &install_dir);
let socket_path = runtime_dir.join("jcode.sock");
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
let mut command = Command::new(&release_binary);
command
.arg("--no-update")
.arg("--provider")
.arg("antigravity")
.arg("self-dev")
.current_dir(env!("CARGO_MANIFEST_DIR"))
.env_remove("JCODE_TEST_SESSION")
.env("JCODE_HOME", &home_dir)
.env("JCODE_RUNTIME_DIR", &runtime_dir)
.env("JCODE_INSTALL_DIR", &install_dir);
let mut child = spawn_pty_child(command)?;
let test_result = async {
wait_for_server_ready(&socket_path, &debug_socket_path).await?;
let session_id = wait_for_default_connected_client_session(&debug_socket_path).await?;
let state_before =
debug_run_command(debug_socket_path.clone(), "client:state", None).await?;
let _: serde_json::Value = serde_json::from_str(&state_before)?;
let server_info_before =
debug_run_command(debug_socket_path.clone(), "server:info", None).await?;
let server_info_before_json: serde_json::Value = serde_json::from_str(&server_info_before)?;
let mut server_id_before = server_info_before_json
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing initial server id"))?
.to_string();
for cycle in 1..=3 {
child.send_command("/server-reload")?;
let server_id_after = wait_for_selfdev_reload_cycle(
&debug_socket_path,
&session_id,
&server_id_before,
Duration::from_secs(20),
)
.await?;
assert_ne!(
server_id_after, server_id_before,
"self-dev reload cycle {} should replace the server process",
cycle
);
server_id_before = server_id_after;
}
Ok::<_, anyhow::Error>(())
}
.await;
let _ = tokio::time::timeout(
Duration::from_secs(2),
debug_run_command(debug_socket_path.clone(), "client:quit", None),
)
.await;
kill_child(&mut child.child);
if let Err(ref error) = test_result {
eprintln!("self-dev reload e2e test error: {error:#}");
eprintln!("self-dev client PTY output:\n{}", child.output_text());
if let Some(log_excerpt) = latest_log_excerpt(&home_dir) {
eprintln!("self-dev reload logs (tail):\n{}", log_excerpt);
}
}
test_result
}
/// Test self-dev client binary reload against a real TUI client running in a PTY.
///
/// Starts from the test binary, then forces `/client-reload` to re-exec into
/// the built release candidate while keeping the shared server online.
#[cfg(unix)]
#[tokio::test]
#[ignore]
async fn binary_integration_selfdev_client_reload_resumes_session() -> Result<()> {
let _env = setup_test_env()?;
let release_binary =
jcode::build::release_binary_path(std::path::Path::new(env!("CARGO_MANIFEST_DIR")));
if !release_binary.exists() {
anyhow::bail!(
"release binary missing at {} (run `cargo build --release` first)",
release_binary.display()
);
}
let temp_root = tempfile::Builder::new()
.prefix("jcode-selfdev-client-reload-e2e-")
.tempdir()?;
let runtime_dir = temp_root.path().join("runtime");
let home_dir = temp_root.path().join("home");
let install_dir = temp_root.path().join("install");
std::fs::create_dir_all(&runtime_dir)?;
std::fs::create_dir_all(&home_dir)?;
std::fs::create_dir_all(&install_dir)?;
let _home_guard = EnvVarGuard::set("JCODE_HOME", &home_dir);
let _runtime_guard = EnvVarGuard::set("JCODE_RUNTIME_DIR", &runtime_dir);
let _install_guard = EnvVarGuard::set("JCODE_INSTALL_DIR", &install_dir);
let socket_path = runtime_dir.join("jcode.sock");
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
let starter_binary = temp_root.path().join("jcode-selfdev-client-starter");
std::fs::copy(env!("CARGO_BIN_EXE_jcode"), &starter_binary)?;
let starter_mtime = std::fs::metadata(&release_binary)?
.modified()?
.checked_sub(Duration::from_secs(60))
.unwrap_or(std::time::UNIX_EPOCH + Duration::from_secs(1));
set_file_mtime(&starter_binary, starter_mtime)?;
let mut command = Command::new(&starter_binary);
command
.arg("--no-update")
.arg("--provider")
.arg("antigravity")
.arg("self-dev")
.current_dir(env!("CARGO_MANIFEST_DIR"))
.env_remove("JCODE_TEST_SESSION")
.env("JCODE_HOME", &home_dir)
.env("JCODE_RUNTIME_DIR", &runtime_dir)
.env("JCODE_INSTALL_DIR", &install_dir);
let mut child = spawn_pty_child(command)?;
let test_result = async {
wait_for_server_ready(&socket_path, &debug_socket_path).await?;
let session_id = wait_for_default_connected_client_session(&debug_socket_path).await?;
let state_before =
debug_run_command(debug_socket_path.clone(), "client:state", Some(&session_id)).await?;
let state_before_json: serde_json::Value = serde_json::from_str(&state_before)?;
let version_before = state_before_json
.get("version")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing client version before reload"))?
.to_string();
let clients_before =
debug_run_command(debug_socket_path.clone(), "clients:map", None).await?;
let clients_before_json: serde_json::Value = serde_json::from_str(&clients_before)?;
let client_id_before = clients_before_json
.get("clients")
.and_then(|v| v.as_array())
.and_then(|clients| {
clients.iter().find_map(|client| {
let session = client.get("session_id").and_then(|v| v.as_str())?;
if session != session_id {
return None;
}
client
.get("client_id")
.and_then(|v| v.as_str())
.map(str::to_string)
})
})
.ok_or_else(|| anyhow::anyhow!("missing client id before reload"))?;
let server_info_before =
debug_run_command(debug_socket_path.clone(), "server:info", None).await?;
let server_info_before_json: serde_json::Value = serde_json::from_str(&server_info_before)?;
let server_id_before = server_info_before_json
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing server id before client reload"))?
.to_string();
child.send_command("/client-reload")?;
let client_id_after = wait_for_selfdev_client_reload_cycle(
&debug_socket_path,
&session_id,
&client_id_before,
&server_id_before,
Duration::from_secs(20),
)
.await?;
let state_after =
debug_run_command(debug_socket_path.clone(), "client:state", Some(&session_id)).await?;
let state_after_json: serde_json::Value = serde_json::from_str(&state_after)?;
let version_after = state_after_json
.get("version")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing client version after reload"))?;
assert_ne!(
client_id_after, client_id_before,
"client reload should reconnect with a different client id"
);
assert_ne!(
version_after, version_before,
"client reload should switch binaries"
);
let server_info_after =
debug_run_command(debug_socket_path.clone(), "server:info", None).await?;
let server_info_after_json: serde_json::Value = serde_json::from_str(&server_info_after)?;
let server_id_after = server_info_after_json
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing server id after client reload"))?;
assert_eq!(
server_id_after, server_id_before,
"client reload should not replace the server process"
);
Ok::<_, anyhow::Error>(())
}
.await;
let _ = tokio::time::timeout(
Duration::from_secs(2),
debug_run_command(debug_socket_path.clone(), "client:quit", None),
)
.await;
kill_child(&mut child.child);
if let Err(ref error) = test_result {
eprintln!("self-dev client reload e2e test error: {error:#}");
eprintln!("self-dev client PTY output:\n{}", child.output_text());
if let Some(log_excerpt) = latest_log_excerpt(&home_dir) {
eprintln!("self-dev client reload logs (tail):\n{}", log_excerpt);
}
}
test_result
}
/// Test full self-dev `/reload` against a real TUI client running in a PTY.
///
/// Starts from an older starter binary so the client reloads into the built
/// release candidate while the shared server also restarts.
#[cfg(unix)]
#[tokio::test]
#[ignore]
async fn binary_integration_selfdev_full_reload_resumes_session_quickly() -> Result<()> {
let _env = setup_test_env()?;
let release_binary =
jcode::build::release_binary_path(std::path::Path::new(env!("CARGO_MANIFEST_DIR")));
if !release_binary.exists() {
anyhow::bail!(
"release binary missing at {} (run `cargo build --release` first)",
release_binary.display()
);
}
let temp_root = tempfile::Builder::new()
.prefix("jcode-selfdev-full-reload-e2e-")
.tempdir()?;
let runtime_dir = temp_root.path().join("runtime");
let home_dir = temp_root.path().join("home");
let install_dir = temp_root.path().join("install");
std::fs::create_dir_all(&runtime_dir)?;
std::fs::create_dir_all(&home_dir)?;
std::fs::create_dir_all(&install_dir)?;
let _home_guard = EnvVarGuard::set("JCODE_HOME", &home_dir);
let _runtime_guard = EnvVarGuard::set("JCODE_RUNTIME_DIR", &runtime_dir);
let _install_guard = EnvVarGuard::set("JCODE_INSTALL_DIR", &install_dir);
let socket_path = runtime_dir.join("jcode.sock");
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
let starter_binary = temp_root.path().join("jcode-selfdev-full-reload-starter");
std::fs::copy(env!("CARGO_BIN_EXE_jcode"), &starter_binary)?;
let starter_mtime = std::fs::metadata(&release_binary)?
.modified()?
.checked_sub(Duration::from_secs(60))
.unwrap_or(std::time::UNIX_EPOCH + Duration::from_secs(1));
set_file_mtime(&starter_binary, starter_mtime)?;
let mut command = Command::new(&starter_binary);
command
.arg("--no-update")
.arg("--provider")
.arg("antigravity")
.arg("self-dev")
.current_dir(env!("CARGO_MANIFEST_DIR"))
.env_remove("JCODE_TEST_SESSION")
.env("JCODE_HOME", &home_dir)
.env("JCODE_RUNTIME_DIR", &runtime_dir)
.env("JCODE_INSTALL_DIR", &install_dir);
let mut child = spawn_pty_child(command)?;
let test_result = async {
wait_for_server_ready(&socket_path, &debug_socket_path).await?;
let session_id = wait_for_default_connected_client_session(&debug_socket_path).await?;
let state_before =
debug_run_command(debug_socket_path.clone(), "client:state", Some(&session_id)).await?;
let state_before_json: serde_json::Value = serde_json::from_str(&state_before)?;
let version_before = state_before_json
.get("version")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing client version before full reload"))?
.to_string();
let clients_before =
debug_run_command(debug_socket_path.clone(), "clients:map", None).await?;
let clients_before_json: serde_json::Value = serde_json::from_str(&clients_before)?;
let client_id_before = clients_before_json
.get("clients")
.and_then(|v| v.as_array())
.and_then(|clients| {
clients.iter().find_map(|client| {
let session = client.get("session_id").and_then(|v| v.as_str())?;
if session != session_id {
return None;
}
client
.get("client_id")
.and_then(|v| v.as_str())
.map(str::to_string)
})
})
.ok_or_else(|| anyhow::anyhow!("missing client id before full reload"))?;
let server_info_before =
debug_run_command(debug_socket_path.clone(), "server:info", None).await?;
let server_info_before_json: serde_json::Value = serde_json::from_str(&server_info_before)?;
let server_id_before = server_info_before_json
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing server id before full reload"))?
.to_string();
child.send_command("/reload")?;
let server_id_after = wait_for_selfdev_reload_cycle(
&debug_socket_path,
&session_id,
&server_id_before,
Duration::from_secs(20),
)
.await?;
let client_id_after = wait_for_selfdev_client_reload_cycle(
&debug_socket_path,
&session_id,
&client_id_before,
&server_id_after,
Duration::from_secs(20),
)
.await?;
let state_after =
debug_run_command(debug_socket_path.clone(), "client:state", Some(&session_id)).await?;
let state_after_json: serde_json::Value = serde_json::from_str(&state_after)?;
let version_after = state_after_json
.get("version")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing client version after full reload"))?;
assert_ne!(
server_id_after, server_id_before,
"full reload should replace the server process"
);
assert_ne!(
client_id_after, client_id_before,
"full reload should reconnect with a different client id"
);
assert_ne!(
version_after, version_before,
"full reload should switch binaries"
);
Ok::<_, anyhow::Error>(())
}
.await;
let _ = tokio::time::timeout(
Duration::from_secs(2),
debug_run_command(debug_socket_path.clone(), "client:quit", None),
)
.await;
kill_child(&mut child.child);
if let Err(ref error) = test_result {
eprintln!("self-dev full reload e2e test error: {error:#}");
eprintln!("self-dev client PTY output:\n{}", child.output_text());
if let Some(log_excerpt) = latest_log_excerpt(&home_dir) {
eprintln!("self-dev full reload logs (tail):\n{}", log_excerpt);
}
}
test_result
}