Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 96 additions & 10 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
run: cargo clippy -- -D warnings

test:

name: test local
runs-on: ubuntu-latest

steps:
Expand Down Expand Up @@ -76,17 +76,103 @@ jobs:
cargo test --bins --verbose
echo "✅ All tests passed!"

# Connect-mode tests require a running Jupyter server and are not run in CI by default.
# To enable, set RUN_CONNECT_TESTS=true in the workflow environment and ensure
# jupyter_server + jupyter-server-documents are installed in the test venv.
# Note: must use --test-threads=1 to avoid races on the shared Jupyter server.
#
# - name: Run connect-mode tests
# if: env.RUN_CONNECT_TESTS == 'true'
# run: cargo test --test integration_connect_mode -- --test-threads=1
test-connect:
name: test connect (${{ matrix.config }})
runs-on: ubuntu-latest
timeout-minutes: 20

strategy:
fail-fast: false
matrix:
include:
- config: jupyter-server no collab
packages: "jupyter_server"
- config: jupyter-collaboration
packages: "jupyter-collaboration"
- config: jupyter-server-documents
packages: "jupyter-server-documents"

steps:
- uses: actions/checkout@v4

- name: Set up Rust cache
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'

- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true

- name: Cache Python test venv
uses: actions/cache@v4
with:
path: tests/.test-venv
key: ${{ runner.os }}-test-venv-connect-${{ matrix.config }}

- name: Create venv and install packages
env:
PACKAGES: ${{ matrix.packages }}
run: |
test -d tests/.test-venv || uv venv tests/.test-venv
uv pip install --python tests/.test-venv ipykernel $PACKAGES

- name: Log installed packages
run: uv pip list --python tests/.test-venv

- name: Build
run: cargo build --verbose

- name: Start Jupyter server
run: |
VENV="$(pwd)/tests/.test-venv"
SERVER_ROOT=$(mktemp -d)
PORT=$(python3 -c "import socket; s=socket.socket(); s.bind(('',0)); p=s.getsockname()[1]; s.close(); print(p)")
PATH="$VENV/bin:$PATH" VIRTUAL_ENV="$VENV" \
jupyter server \
--no-browser \
--ServerApp.token=nbtest123 \
--ServerApp.root_dir="$SERVER_ROOT" \
--port="$PORT" \
--ServerApp.open_browser=False \
> "$SERVER_ROOT/jupyter.log" 2>&1 &
echo "NB_TEST_SERVER_URL=http://127.0.0.1:$PORT" >> "$GITHUB_ENV"
echo "NB_TEST_SERVER_TOKEN=nbtest123" >> "$GITHUB_ENV"
echo "NB_TEST_SERVER_ROOT=$SERVER_ROOT" >> "$GITHUB_ENV"
SERVER_READY=false
for i in $(seq 1 150); do
curl -sf "http://127.0.0.1:$PORT/api?token=nbtest123" > /dev/null 2>&1 && SERVER_READY=true && break
sleep 0.2
done
if [ "$SERVER_READY" != "true" ]; then
echo "::error::Jupyter server failed to start on port $PORT"; exit 1
fi
echo "Jupyter server ready on port $PORT"

- name: Run connect-mode tests
continue-on-error: ${{ matrix.config != 'jupyter-server-documents' }}
run: cargo test --test integration_connect_mode -- --test-threads=1

- name: Show Jupyter server logs
if: failure()
run: cat "$NB_TEST_SERVER_ROOT/jupyter.log" || true

test-windows:
name: Test (Windows)
name: test local (windows)
runs-on: windows-latest
permissions:
contents: write
Expand Down
56 changes: 31 additions & 25 deletions tests/integration_connect_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,6 @@ struct SharedServerInfo {
venv_root: PathBuf,
}

// SAFETY: All fields are Send + Sync (Strings and PathBufs).
unsafe impl Send for SharedServerInfo {}
unsafe impl Sync for SharedServerInfo {}

/// One shared Jupyter Server for the whole test suite.
/// Initialized on first access; lives until the test process exits.
static SHARED_SERVER: OnceLock<Option<SharedServerInfo>> = OnceLock::new();
Expand All @@ -44,6 +40,31 @@ fn shared_server() -> Option<&'static SharedServerInfo> {
}

fn start_shared_server() -> Option<SharedServerInfo> {
// If NB_TEST_SERVER_URL/TOKEN are set, use the externally-managed server.
if let (Ok(server_url), Ok(token)) = (
std::env::var("NB_TEST_SERVER_URL"),
std::env::var("NB_TEST_SERVER_TOKEN"),
) {
let venv_root = test_helpers::setup_execution_venv()?;
let venv_path_env = test_helpers::setup_venv_environment()?;
let binary_path = env!("CARGO_BIN_EXE_nb").into();
let server_root: PathBuf = std::env::var("NB_TEST_SERVER_ROOT")
.map(PathBuf::from)
.unwrap_or_else(|_| std::env::temp_dir());
if !wait_for_server(&server_url, &token, Duration::from_secs(15)) {
eprintln!("⚠️ NB_TEST_SERVER_URL set but server not responding");
return None;
}
return Some(SharedServerInfo {
server_url,
token,
server_root,
binary_path,
venv_path_env,
venv_root,
});
}

// Reuse the existing execution venv (ipykernel already installed there).
let venv_root = test_helpers::setup_execution_venv()?;
let venv_path_env = test_helpers::setup_venv_environment()?;
Expand All @@ -54,28 +75,8 @@ fn start_shared_server() -> Option<SharedServerInfo> {
venv_root.join("bin")
};

// Ensure jupyter_server and jupyter-server-documents are installed (idempotent).
// jupyter-server-documents provides the FileID / Y.js API that nb's remote
// executor relies on for real-time output observation.
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;
}

// Verify the `jupyter` binary exists in the venv.
// For local dev, run `./tests/setup_test_env.sh` first to install dependencies.
let jupyter_bin = venv_bin.join("jupyter");
if !jupyter_bin.exists() {
eprintln!(
Expand Down Expand Up @@ -267,6 +268,7 @@ fn wait_for_server(server_url: &str, token: &str, timeout: Duration) -> bool {
/// 1. Execute the full notebook → `persistent_var` is set, cell-use prints it.
/// 2. Execute only cell-use (index 1) without restarting → the value is still in scope.
#[test]
#[ignore] // #87 flaky: Y.js sync timing
fn test_execute_without_restart_preserves_state() {
let Some(ctx) = TestCtx::new() else {
eprintln!("⚠️ Skipping connect-mode test: jupyter server not available");
Expand Down Expand Up @@ -306,6 +308,7 @@ fn test_execute_without_restart_preserves_state() {
/// 3. Execute only cell-use (index 1) with `--restart-kernel --allow-errors` →
/// the kernel has been restarted so `persistent_var` is undefined → NameError.
#[test]
#[ignore] // #87
fn test_restart_kernel_clears_state() {
let Some(ctx) = TestCtx::new() else {
eprintln!("⚠️ Skipping connect-mode test: jupyter server not available");
Expand Down Expand Up @@ -362,6 +365,7 @@ fn test_restart_kernel_clears_state() {
/// After the kernel is restarted, running all cells from scratch must work correctly
/// and produce the expected output.
#[test]
#[ignore] // #87
fn test_restart_kernel_then_full_notebook_works() {
let Some(ctx) = TestCtx::new() else {
eprintln!("⚠️ Skipping connect-mode test: jupyter server not available");
Expand Down Expand Up @@ -415,6 +419,7 @@ fn test_execute_from_different_cwd() {

/// Clear all outputs from a notebook in connect mode.
#[test]
#[ignore] // #90
fn test_clear_outputs_in_connect_mode() {
let Some(ctx) = TestCtx::new() else {
eprintln!("⚠️ Skipping connect-mode test: jupyter server not available");
Expand Down Expand Up @@ -454,6 +459,7 @@ fn test_clear_outputs_in_connect_mode() {

/// Clear outputs from a specific cell by index in connect mode.
#[test]
#[ignore] // #90
fn test_clear_outputs_specific_cell_in_connect_mode() {
let Some(ctx) = TestCtx::new() else {
eprintln!("⚠️ Skipping connect-mode test: jupyter server not available");
Expand Down
98 changes: 23 additions & 75 deletions tests/test_helpers.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
/// Helper module for test utilities
use serde_json::Value;
use std::path::PathBuf;
use std::process::Command;
use std::sync::{Mutex, OnceLock};
use std::sync::OnceLock;

// ==================== AI-OPTIMIZED MARKDOWN PARSING ====================

Expand Down Expand Up @@ -71,100 +70,49 @@ pub fn parse_notebook_header(output: &str) -> Option<Sentinel> {
}

#[allow(dead_code)]
static VENV_PATH: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
static VENV_PATH: OnceLock<Option<PathBuf>> = OnceLock::new();

/// Check if uv is installed
#[allow(dead_code)]
pub fn has_uv() -> bool {
Command::new("uv")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}

/// Check if Python 3 is available
#[allow(dead_code)]
pub fn has_python3() -> bool {
Command::new("python3")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}

/// Setup test virtual environment with execution dependencies
/// Returns the path to the venv if successful
/// Return the path to the pre-built test venv (created by setup_test_env.sh or CI).
/// Returns None if the venv doesn't exist.
#[allow(dead_code)]
pub fn setup_execution_venv() -> Option<PathBuf> {
let mutex = VENV_PATH.get_or_init(|| {
let venv_path = initialize_venv();
Mutex::new(venv_path)
});

mutex.lock().unwrap().clone()
VENV_PATH.get_or_init(find_venv).clone()
}

#[allow(dead_code)]
fn initialize_venv() -> Option<PathBuf> {
if !has_uv() || !has_python3() {
eprintln!("⚠️ Skipping execution test setup: uv or python3 not available");
return None;
}

let test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests");
let venv_path = test_dir.join(".test-venv");

// Create venv if it doesn't exist
if !venv_path.exists() {
eprintln!("📦 Creating test venv with uv...");
let status = Command::new("uv")
.args(["venv", venv_path.to_str().unwrap()])
.status();
fn find_venv() -> Option<PathBuf> {
let venv_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join(".test-venv");

if status.map(|s| !s.success()).unwrap_or(true) {
eprintln!("⚠️ Failed to create test venv");
return None;
}
}
let python_bin = if cfg!(windows) {
venv_path.join("Scripts").join("python.exe")
} else {
venv_path.join("bin").join("python")
};

// Install ipykernel for Python kernel
eprintln!("📦 Installing ipykernel...");
let status = Command::new("uv")
.args([
"pip",
"install",
"--python",
venv_path.to_str().unwrap(),
"ipykernel",
])
.status();

if status.map(|s| s.success()).unwrap_or(false) {
eprintln!("✅ Test venv ready at: {}", venv_path.display());
if python_bin.exists() {
Some(venv_path)
} else {
eprintln!("⚠️ Failed to install dependencies in test venv");
eprintln!(
"⚠️ Test venv not found at {}. Run ./tests/setup_test_env.sh first.",
venv_path.display()
);
None
}
}

/// Set environment to use test venv for execution
/// Build a PATH string that prepends the test venv's bin directory.
#[allow(dead_code)]
pub fn setup_venv_environment() -> Option<String> {
let mutex = VENV_PATH.get()?;
let venv_path = mutex.lock().unwrap();
let venv_path = venv_path.as_ref()?;
let venv_path = VENV_PATH.get()?.as_ref()?;

let bin_path = if cfg!(windows) {
venv_path.join("Scripts")
} else {
venv_path.join("bin")
};

// Prepend venv bin to PATH
let current_path = std::env::var("PATH").unwrap_or_default();
let new_path = format!("{}:{}", bin_path.display(), current_path);

Some(new_path)
let sep = if cfg!(windows) { ";" } else { ":" };
Some(format!("{}{}{}", bin_path.display(), sep, current_path))
}
Loading