mod test_helpers;
use std::fs;
use std::net::TcpListener;
use std::path::PathBuf;
use std::process::Command;
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use tempfile::TempDir;
use reqwest;
struct SharedServerInfo {
server_url: String,
token: String,
server_root: PathBuf,
binary_path: PathBuf,
venv_path_env: String,
venv_root: PathBuf,
}
unsafe impl Send for SharedServerInfo {}
unsafe impl Sync for SharedServerInfo {}
static SHARED_SERVER: OnceLock<Option<SharedServerInfo>> = OnceLock::new();
fn shared_server() -> Option<&'static SharedServerInfo> {
SHARED_SERVER.get_or_init(start_shared_server).as_ref()
}
fn start_shared_server() -> Option<SharedServerInfo> {
let venv_root = test_helpers::setup_execution_venv()?;
let venv_path_env = test_helpers::setup_venv_environment()?;
let venv_bin = if cfg!(windows) {
venv_root.join("Scripts")
} else {
venv_root.join("bin")
};
let install_ok = Command::new("uv")
.args([
"pip",
"install",
"--python",
venv_root.to_str().unwrap(),
"jupyter_server",
"jupyter-server-documents",
])
.status()
.map(|s| s.success())
.unwrap_or(false);
if !install_ok {
eprintln!("⚠️ Could not install jupyter_server into test venv");
return None;
}
let jupyter_bin = venv_bin.join("jupyter");
if !jupyter_bin.exists() {
eprintln!(
"⚠️ jupyter binary not found at {} — skipping connect-mode tests",
jupyter_bin.display()
);
return None;
}
let port = {
let listener = TcpListener::bind("127.0.0.1:0").ok()?;
listener.local_addr().ok()?.port()
};
let server_root_tmp: &'static TempDir = Box::leak(Box::new(
TempDir::new().expect("Failed to create server root tmpdir"),
));
let server_root = server_root_tmp.path().to_path_buf();
let token = "nbtest123".to_string();
let child = Command::new(&jupyter_bin)
.args([
"server",
"--no-browser",
&format!("--ServerApp.token={}", token),
&format!("--ServerApp.root_dir={}", server_root.display()),
&format!("--port={}", port),
"--ServerApp.open_browser=False",
])
.env("PATH", &venv_path_env)
.env("VIRTUAL_ENV", &venv_root)
.env_remove("PYTHONHOME")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.ok()?;
let _guard: &'static mut ServerKillGuard = Box::leak(Box::new(ServerKillGuard { child }));
let server_url = format!("http://127.0.0.1:{}", port);
if !wait_for_server(&server_url, &token, Duration::from_secs(15)) {
eprintln!("⚠️ Jupyter Server did not become ready in time — skipping connect-mode tests");
return None;
}
let binary_path = env!("CARGO_BIN_EXE_nb").into();
Some(SharedServerInfo {
server_url,
token,
server_root,
binary_path,
venv_path_env,
venv_root,
})
}
struct ServerKillGuard {
child: std::process::Child,
}
impl Drop for ServerKillGuard {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
struct TestCtx {
info: &'static SharedServerInfo,
}
impl TestCtx {
fn new() -> Option<Self> {
shared_server().map(|info| TestCtx { info })
}
fn copy_fixture(&self, fixture_name: &str, dest_name: &str) -> PathBuf {
let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join(fixture_name);
let dest_path = self.info.server_root.join(dest_name);
fs::copy(&fixture_path, &dest_path)
.unwrap_or_else(|_| panic!("Failed to copy fixture {}", fixture_name));
dest_path
}
fn run(&self, args: &[&str]) -> CommandResult {
self.run_from_dir(args, &self.info.server_root)
}
fn run_from_dir(&self, args: &[&str], cwd: &std::path::Path) -> CommandResult {
let output = Command::new(&self.info.binary_path)
.args(args)
.args([
"--server",
&self.info.server_url,
"--token",
&self.info.token,
])
.current_dir(cwd)
.env("PATH", &self.info.venv_path_env)
.env("VIRTUAL_ENV", &self.info.venv_root)
.env_remove("PYTHONHOME")
.output()
.expect("Failed to execute nb command");
CommandResult {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
success: output.status.success(),
}
}
}
struct CommandResult {
stdout: String,
stderr: String,
success: bool,
}
impl CommandResult {
fn assert_success(self) -> Self {
if !self.success {
panic!(
"Command failed:\nStderr: {}\nStdout: {}",
self.stderr, self.stdout
);
}
self
}
fn assert_failure(self) -> Self {
if self.success {
panic!(
"Expected command to fail but it succeeded:\nStdout: {}\nStderr: {}",
self.stdout, self.stderr
);
}
self
}
}
fn wait_for_server(server_url: &str, token: &str, timeout: Duration) -> bool {
let url = format!("{}/api?token={}", server_url, token);
let deadline = Instant::now() + timeout;
let mut interval_ms = 200u64;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Failed to build tokio runtime for server health check");
while Instant::now() < deadline {
let ok = rt.block_on(async {
match reqwest::get(&url).await {
Ok(resp) => resp.status().is_success(),
Err(_) => false,
}
});
if ok {
return true;
}
std::thread::sleep(Duration::from_millis(interval_ms));
interval_ms = (interval_ms * 2).min(2_000);
}
false
}
#[test]
fn test_execute_without_restart_preserves_state() {
let Some(ctx) = TestCtx::new() else {
eprintln!("⚠️ Skipping connect-mode test: jupyter server not available");
return;
};
ctx.copy_fixture("for_connect_restart.ipynb", "test_preserve.ipynb");
let result = ctx
.run(&["execute", "test_preserve.ipynb"])
.assert_success();
assert!(
result.stdout.contains("persistent_var = 999"),
"Full notebook execution should print 'persistent_var = 999'\nStdout: {}",
result.stdout
);
let result = ctx
.run(&["execute", "test_preserve.ipynb", "--cell-index", "1"])
.assert_success();
assert!(
result.stdout.contains("persistent_var = 999"),
"Cell-use re-execution without restart should still print 'persistent_var = 999'\nStdout: {}",
result.stdout
);
}
#[test]
fn test_restart_kernel_clears_state() {
let Some(ctx) = TestCtx::new() else {
eprintln!("⚠️ Skipping connect-mode test: jupyter server not available");
return;
};
ctx.copy_fixture("for_connect_restart.ipynb", "test_restart.ipynb");
let result = ctx.run(&["execute", "test_restart.ipynb"]).assert_success();
assert!(
result.stdout.contains("persistent_var = 999"),
"Full notebook execution should print 'persistent_var = 999'\nStdout: {}",
result.stdout
);
let result = ctx
.run(&["execute", "test_restart.ipynb", "--cell-index", "1"])
.assert_success();
assert!(
result.stdout.contains("persistent_var = 999"),
"Without restart, cell-use should still find persistent_var\nStdout: {}",
result.stdout
);
let result = ctx
.run(&[
"execute",
"test_restart.ipynb",
"--cell-index",
"1",
"--restart-kernel",
"--allow-errors",
])
.assert_failure();
let combined = format!("{}\n{}", result.stdout, result.stderr);
assert!(
combined.contains("NameError"),
"After restart, cell-use should produce a NameError because persistent_var is undefined\nStdout: {}\nStderr: {}",
result.stdout,
result.stderr
);
}
#[test]
fn test_restart_kernel_then_full_notebook_works() {
let Some(ctx) = TestCtx::new() else {
eprintln!("⚠️ Skipping connect-mode test: jupyter server not available");
return;
};
ctx.copy_fixture("for_connect_restart.ipynb", "test_restart_full.ipynb");
ctx.run(&["execute", "test_restart_full.ipynb"])
.assert_success();
let result = ctx
.run(&["execute", "test_restart_full.ipynb", "--restart-kernel"])
.assert_success();
assert!(
result.stdout.contains("persistent_var = 999"),
"Full notebook execution after restart should print 'persistent_var = 999'\nStdout: {}",
result.stdout
);
}
#[test]
fn test_execute_from_different_cwd() {
let Some(ctx) = TestCtx::new() else {
eprintln!("⚠️ Skipping connect-mode test: jupyter server not available");
return;
};
ctx.copy_fixture("for_connect_restart.ipynb", "test_cwd.ipynb");
let other_dir = TempDir::new().expect("Failed to create temp dir");
let result = ctx
.run_from_dir(&["execute", "test_cwd.ipynb"], other_dir.path())
.assert_success();
assert!(
result.stdout.contains("persistent_var = 999"),
"Execution from different CWD should read notebook from server and succeed\nStdout: {}",
result.stdout
);
}
#[test]
fn test_clear_outputs_in_connect_mode() {
let Some(ctx) = TestCtx::new() else {
eprintln!("⚠️ Skipping connect-mode test: jupyter server not available");
return;
};
ctx.copy_fixture("with_outputs.ipynb", "test_clear_all.ipynb");
ctx.run(&["output", "clear", "test_clear_all.ipynb"])
.assert_success();
let result = ctx
.run(&["read", "test_clear_all.ipynb", "--json"])
.assert_success();
let parsed: serde_json::Value =
serde_json::from_str(&result.stdout).expect("Failed to parse JSON output");
for cell in parsed["cells"].as_array().unwrap() {
if cell["cell_type"] == "code" {
let outputs = cell["outputs"].as_array().unwrap();
assert!(
outputs.is_empty(),
"Expected empty outputs after clear, got: {:?}",
outputs
);
assert!(
cell["execution_count"].is_null(),
"Expected null execution_count after clear"
);
}
}
}
#[test]
fn test_clear_outputs_specific_cell_in_connect_mode() {
let Some(ctx) = TestCtx::new() else {
eprintln!("⚠️ Skipping connect-mode test: jupyter server not available");
return;
};
ctx.copy_fixture("with_outputs.ipynb", "test_clear_one.ipynb");
ctx.run(&[
"output",
"clear",
"test_clear_one.ipynb",
"--cell-index",
"0",
])
.assert_success();
let result = ctx
.run(&["read", "test_clear_one.ipynb", "--json"])
.assert_success();
let parsed: serde_json::Value =
serde_json::from_str(&result.stdout).expect("Failed to parse JSON output");
let cells = parsed["cells"].as_array().unwrap();
assert!(
cells[0]["outputs"].as_array().unwrap().is_empty(),
"Cell 0 outputs should be cleared"
);
assert!(
cells[0]["execution_count"].is_null(),
"Cell 0 execution_count should be null"
);
assert!(
!cells[1]["outputs"].as_array().unwrap().is_empty(),
"Cell 1 outputs should still be present"
);
}
#[test]
fn test_clear_outputs_invalid_cell_id_in_connect_mode() {
let Some(ctx) = TestCtx::new() else {
eprintln!("⚠️ Skipping connect-mode test: jupyter server not available");
return;
};
ctx.copy_fixture("with_outputs.ipynb", "test_clear_bad_id.ipynb");
let result = ctx
.run(&[
"output",
"clear",
"test_clear_bad_id.ipynb",
"--cell",
"nonexistent-id",
])
.assert_failure();
assert!(
result.stderr.contains("not found"),
"Expected 'not found' error message, got stderr: {}",
result.stderr
);
}