diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index f16a7d657..5e1b949dd 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -60,5 +60,7 @@ jobs: - name: Run Tests env: NEXTEST_PROFILE: "ci" + # Enable tracing to debug Windows ARM timeout issues with debug mode tests + ARK_TEST_TRACE: "all" run: | cargo nextest run diff --git a/AGENTS.md b/AGENTS.md index f985a8729..76f0e30f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,55 @@ just test just test -p ark ``` +### Kernel and DAP Test Infrastructure + +Integration tests for the kernel and DAP server live in `crates/ark/tests/` and use the test utilities from `crates/ark_test/`. + +**Key components:** + +- **`DummyArkFrontend`**: A mock Jupyter frontend that communicates with the kernel over ZMQ sockets. Use `DummyArkFrontend::lock()` to acquire it (only one per process). + +- **`DapClient`**: A DAP client for testing the debugger. Obtained via `frontend.dap_client()` after starting the kernel. + +- **`MessageAccumulator`**: Collects IOPub messages and coalesces stream fragments, making tests immune to batching variations. + +**Common patterns:** + +```rust +// Lock the frontend and send an execute request +let frontend = DummyArkFrontend::lock(); +frontend.send_execute_request("1 + 1", ExecuteRequestOptions::default()); +frontend.recv_iopub_busy(); +frontend.recv_iopub_execute_input(); +frontend.recv_iopub_execute_result(); +frontend.recv_iopub_idle(); +frontend.recv_shell_execute_reply(); + +// For complex async message flows, use recv_iopub_until with MessageAccumulator +frontend.recv_iopub_until(|acc| { + acc.has_comm_method("start_debug") && + acc.streams_contain("debug at") && + acc.saw_idle() +}); + +// Use in_order() to verify message ordering +frontend.recv_iopub_until(|acc| { + acc.in_order(&[is_execute_result(), is_idle()]) +}); +``` + +**Debugging tests:** + +Log messages (from the `log` crate) are not shown in test output. Use `eprintln!` for printf-style debugging. + +Enable message tracing to see timestamped DAP and IOPub message flows: + +```bash +ARK_TEST_TRACE=1 just test test_name # All messages +ARK_TEST_TRACE=dap just test test_name # DAP events only +ARK_TEST_TRACE=iopub just test test_name # IOPub messages only +``` + ### Required R Packages for Testing The following R packages are required for tests: diff --git a/Cargo.lock b/Cargo.lock index 9321f5720..286f06277 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -352,6 +352,7 @@ dependencies = [ "air_r_syntax", "amalthea", "anyhow", + "ark_test", "assert_matches", "async-trait", "base64 0.21.0", @@ -411,6 +412,24 @@ dependencies = [ "yaml-rust", ] +[[package]] +name = "ark_test" +version = "0.1.0" +dependencies = [ + "amalthea", + "anyhow", + "ark", + "dap", + "harp", + "log", + "serde", + "serde_json", + "stdext", + "tempfile", + "tree-sitter", + "uuid", +] + [[package]] name = "arrayref" version = "0.3.9" diff --git a/crates/ark/Cargo.toml b/crates/ark/Cargo.toml index 6160b15bf..9dae863e6 100644 --- a/crates/ark/Cargo.toml +++ b/crates/ark/Cargo.toml @@ -70,6 +70,7 @@ rustc-hash = "2.1.1" tracing-error = "0.2.0" [dev-dependencies] +ark_test = { path = "../ark_test" } insta = { version = "1.39.0" } stdext = { path = "../stdext", features = ["testing"] } tempfile = "3.13.0" diff --git a/crates/ark/src/console.rs b/crates/ark/src/console.rs index dd800fce4..e9f128dee 100644 --- a/crates/ark/src/console.rs +++ b/crates/ark/src/console.rs @@ -1569,9 +1569,6 @@ impl Console { // For continue-like commands, we do not preserve focus, // i.e. we let the cursor jump to the stopped position. self.debug_preserve_focus = false; - - // Let the DAP client know that execution is now continuing - self.debug_send_dap(DapBackendEvent::Continued); } // Forward the command to R's base REPL. diff --git a/crates/ark/src/dap/dap.rs b/crates/ark/src/dap/dap.rs index e6c7220ff..8b304ef70 100644 --- a/crates/ark/src/dap/dap.rs +++ b/crates/ark/src/dap/dap.rs @@ -220,6 +220,11 @@ impl Dap { self.stack = Some(stack); log::trace!("DAP: Sending `start_debug` events"); + eprintln!( + "DAP trace: start_debug: comm_tx={}, backend_events_tx={}", + self.comm_tx.is_some(), + self.backend_events_tx.is_some() + ); if let Some(comm_tx) = &self.comm_tx { // Ask frontend to connect to the DAP @@ -228,9 +233,17 @@ impl Dap { .log_err(); if let Some(dap_tx) = &self.backend_events_tx { - dap_tx - .send(DapBackendEvent::Stopped(DapStoppedEvent { preserve_focus })) - .log_err(); + eprintln!("DAP trace: start_debug: sending Stopped event"); + match dap_tx.send(DapBackendEvent::Stopped(DapStoppedEvent { preserve_focus })) { + Ok(()) => eprintln!("DAP trace: start_debug: Stopped event sent"), + Err(err) => { + eprintln!("DAP trace: start_debug: failed to send Stopped event: {err:?}") + }, + } + } else { + eprintln!( + "DAP trace: start_debug: backend_events_tx is None, cannot send Stopped event" + ); } } } @@ -247,9 +260,11 @@ impl Dap { self.fallback_sources.clear(); self.clear_variables_reference_maps(); self.reset_variables_reference_count(); + + let was_debugging = self.is_debugging; self.is_debugging = false; - if self.is_connected { + if was_debugging && self.is_connected { log::trace!("DAP: Sending `stop_debug` events"); if let Some(comm_tx) = &self.comm_tx { @@ -507,3 +522,158 @@ impl ServerHandler for Dap { return Ok(()); } } + +#[cfg(test)] +mod tests { + use crossbeam::channel::unbounded; + + use super::*; + + fn create_test_dap() -> (Dap, crossbeam::channel::Receiver) { + let (backend_events_tx, backend_events_rx) = unbounded(); + let (r_request_tx, _r_request_rx) = unbounded(); + + let dap = Dap { + is_debugging: false, + is_connected: true, + backend_events_tx: Some(backend_events_tx), + stack: None, + breakpoints: HashMap::new(), + fallback_sources: HashMap::new(), + frame_id_to_variables_reference: HashMap::new(), + variables_reference_to_r_object: HashMap::new(), + current_variables_reference: 1, + current_breakpoint_id: 1, + comm_tx: None, + r_request_tx, + shared_self: None, + }; + + (dap, backend_events_rx) + } + + #[test] + fn test_did_change_document_removes_breakpoints() { + let (mut dap, rx) = create_test_dap(); + + let uri = Url::parse("file:///test.R").unwrap(); + let hash = blake3::hash(b"test content"); + + dap.breakpoints.insert( + uri.clone(), + (hash, vec![ + Breakpoint::new(1, 10, BreakpointState::Verified), + Breakpoint::new(2, 20, BreakpointState::Verified), + ]), + ); + + dap.did_change_document(&uri); + + assert!(dap.breakpoints.get(&uri).is_none()); + + let event1 = rx.try_recv().unwrap(); + let event2 = rx.try_recv().unwrap(); + + assert!(matches!(event1, DapBackendEvent::BreakpointState { + id: 1, + verified: false, + .. + })); + assert!(matches!(event2, DapBackendEvent::BreakpointState { + id: 2, + verified: false, + .. + })); + + assert!(rx.try_recv().is_err()); + } + + #[test] + fn test_did_change_document_no_breakpoints_is_noop() { + let (mut dap, rx) = create_test_dap(); + + let uri = Url::parse("file:///test.R").unwrap(); + + dap.did_change_document(&uri); + + assert!(rx.try_recv().is_err()); + } + + #[test] + fn test_did_change_document_only_affects_target_uri() { + let (mut dap, rx) = create_test_dap(); + + let uri1 = Url::parse("file:///test1.R").unwrap(); + let uri2 = Url::parse("file:///test2.R").unwrap(); + let hash1 = blake3::hash(b"content 1"); + let hash2 = blake3::hash(b"content 2"); + + dap.breakpoints.insert( + uri1.clone(), + (hash1, vec![Breakpoint::new( + 1, + 10, + BreakpointState::Verified, + )]), + ); + dap.breakpoints.insert( + uri2.clone(), + (hash2, vec![Breakpoint::new( + 2, + 20, + BreakpointState::Verified, + )]), + ); + + dap.did_change_document(&uri1); + + assert!(dap.breakpoints.get(&uri1).is_none()); + assert!(dap.breakpoints.get(&uri2).is_some()); + + let event = rx.try_recv().unwrap(); + assert!(matches!(event, DapBackendEvent::BreakpointState { + id: 1, + .. + })); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn test_did_change_document_without_backend_tx_is_noop() { + let (r_request_tx, _r_request_rx) = unbounded(); + + let mut dap = Dap { + is_debugging: false, + is_connected: false, + backend_events_tx: None, + stack: None, + breakpoints: HashMap::new(), + fallback_sources: HashMap::new(), + frame_id_to_variables_reference: HashMap::new(), + variables_reference_to_r_object: HashMap::new(), + current_variables_reference: 1, + current_breakpoint_id: 1, + comm_tx: None, + r_request_tx, + shared_self: None, + }; + + let uri = Url::parse("file:///test.R").unwrap(); + let hash = blake3::hash(b"test content"); + + dap.breakpoints.insert( + uri.clone(), + (hash, vec![Breakpoint::new( + 1, + 10, + BreakpointState::Verified, + )]), + ); + + // Should not panic even without `backend_events_tx` + dap.did_change_document(&uri); + + // Breakpoints should still be removed + assert!(dap.breakpoints.get(&uri).is_none()); + } +} diff --git a/crates/ark/src/dap/dap_server.rs b/crates/ark/src/dap/dap_server.rs index 8cf3c374e..517757af7 100644 --- a/crates/ark/src/dap/dap_server.rs +++ b/crates/ark/src/dap/dap_server.rs @@ -31,7 +31,6 @@ use dap::server::ServerOutput; use dap::types::*; use stdext::result::ResultExt; use stdext::spawn; -use url::Url; use super::dap::Breakpoint; use super::dap::BreakpointState; @@ -46,9 +45,19 @@ use crate::r_task; use crate::request::debug_request_command; use crate::request::DebugRequest; use crate::request::RRequest; +use crate::url::ExtUrl; const THREAD_ID: i64 = -1; +// TODO: Handle comm close to shut down the DAP server thread. +// +// The DAP comm is allowed to persist across TCP sessions. This supports session +// switching on the frontend. Ideally the frontend would be allowed to close the +// DAP comm in addition to the DAP TCP connection, which would shut down the DAP +// server. To achive this, the DAP server, once disconnected should wait for both +// the connection becoming ready and a channel event signalling comm close. If +// the latter fires, shut the server down. + pub fn start_dap( state: Arc>, server_start: ServerStartMessage, @@ -86,6 +95,14 @@ pub fn start_dap( Ok((stream, addr)) => { log::info!("DAP: Connected to client {addr:?}"); + // Disable Nagle's algorithm to ensure events are sent immediately. + // Without this, small writes may be buffered at the TCP level, + // causing events to not reach the client until more data is sent. + // This was observed to cause hangs on Windows ARM. + if let Err(err) = stream.set_nodelay(true) { + log::warn!("DAP: Failed to set TCP_NODELAY: {err:?}"); + } + let mut state = state.lock().unwrap(); state.is_connected = true; @@ -97,8 +114,19 @@ pub fn start_dap( }, }; + // Clone the stream so that reading and writing use separate OS handles. + // On Windows ARM, concurrent read/write on the same stream handle from + // different threads can cause writes to not be delivered to the peer. + let write_stream = match stream.try_clone() { + Ok(s) => s, + Err(err) => { + log::error!("DAP: Failed to clone stream: {err:?}"); + continue; + }, + }; + let reader = BufReader::new(&stream); - let writer = BufWriter::new(&stream); + let writer = BufWriter::new(write_stream); let mut server = DapServer::new( reader, writer, @@ -116,13 +144,16 @@ pub fn start_dap( // to the stack variable `stream` through `server`) let _ = crossbeam::thread::scope(|scope| { spawn!(scope, "ark-dap-events", { + eprintln!("DAP trace: listen_dap_events thread starting"); move |_| listen_dap_events(output_clone, backend_events_rx, done_rx) }); // Connect the backend to the events thread { + eprintln!("DAP trace: setting backend_events_tx"); let mut state = state.lock().unwrap(); state.backend_events_tx = Some(backend_events_tx); + eprintln!("DAP trace: backend_events_tx set"); } loop { @@ -136,7 +167,9 @@ pub fn start_dap( } // Terminate the events thread + eprintln!("DAP trace: sending done signal to events thread"); let _ = done_tx.send(true); + eprintln!("DAP trace: done signal sent"); }); } } @@ -151,15 +184,18 @@ fn listen_dap_events( loop { select!( recv(backend_events_rx) -> event => { + eprintln!("DAP trace: listen_dap_events: received from channel"); let event = match event { Ok(event) => event, Err(err) => { // Channel closed, sender dropped + eprintln!("DAP trace: listen_dap_events: channel closed: {err:?}"); log::info!("DAP: Event channel closed: {err:?}"); return; }, }; + eprintln!("DAP trace: listen_dap_events: got backend event: {event:?}"); log::trace!("DAP: Got event from backend: {:?}", event); let event = match event { @@ -200,15 +236,24 @@ fn listen_dap_events( }, }; + eprintln!("DAP trace: listen_dap_events: acquiring output lock"); let mut output = output.lock().unwrap(); - if let Err(err) = output.send_event(event) { - log::warn!("DAP: Failed to send event, closing: {err:?}"); - return; + eprintln!("DAP trace: listen_dap_events: sending DAP event"); + match output.send_event(event) { + Ok(()) => eprintln!("DAP trace: listen_dap_events: send_event OK"), + Err(err) => { + eprintln!("DAP trace: listen_dap_events: send_event failed: {err:?}"); + log::warn!("DAP: Failed to send event, closing: {err:?}"); + return; + }, } }, // Break the loop and terminate the thread - recv(done_rx) -> _ => { return; }, + recv(done_rx) -> _ => { + eprintln!("DAP trace: listen_dap_events: received done signal, exiting"); + return; + }, ) } } @@ -350,7 +395,8 @@ impl DapServer { // We currently only support "path" URIs as Positron never sends URIs. // In principle the DAP frontend can negotiate whether it sends URIs or // file paths via the `pathFormat` field of the `Initialize` request. - let uri = match Url::from_file_path(path) { + // `ExtUrl::from_file_path` canonicalizes the path to resolve symlinks. + let uri = match ExtUrl::from_file_path(path) { Ok(uri) => uri, Err(()) => { log::warn!("Can't set breakpoints for non-file path: '{path}'"); diff --git a/crates/ark/src/fixtures/dummy_frontend.rs b/crates/ark/src/fixtures/dummy_frontend.rs deleted file mode 100644 index 4ceba3411..000000000 --- a/crates/ark/src/fixtures/dummy_frontend.rs +++ /dev/null @@ -1,298 +0,0 @@ -use std::ops::Deref; -use std::ops::DerefMut; -use std::sync::Arc; -use std::sync::Mutex; -use std::sync::MutexGuard; -use std::sync::OnceLock; - -use amalthea::fixtures::dummy_frontend::DummyConnection; -use amalthea::fixtures::dummy_frontend::DummyFrontend; - -use crate::console::SessionMode; -use crate::repos::DefaultRepos; - -// There can be only one frontend per process. Needs to be in a mutex because -// the frontend wraps zmq sockets which are unsafe to send across threads. -// -// This is using `OnceLock` because it provides a way of checking whether the -// value has been initialized already. Also we'll need to parameterize -// initialization in the future. -static FRONTEND: OnceLock>> = OnceLock::new(); - -/// Wrapper around `DummyFrontend` that checks sockets are empty on drop -pub struct DummyArkFrontend { - guard: MutexGuard<'static, DummyFrontend>, -} - -struct DummyArkFrontendOptions { - interactive: bool, - site_r_profile: bool, - user_r_profile: bool, - r_environ: bool, - session_mode: SessionMode, - default_repos: DefaultRepos, - startup_file: Option, -} - -/// Wrapper around `DummyArkFrontend` that uses `SessionMode::Notebook` -/// -/// Only one of `DummyArkFrontend` or `DummyArkFrontendNotebook` can be used in -/// a given process. Just don't import both and you should be fine as Rust will -/// let you know about a missing symbol if you happen to copy paste `lock()` -/// calls of different kernel types between files. -pub struct DummyArkFrontendNotebook { - inner: DummyArkFrontend, -} - -/// Wrapper around `DummyArkFrontend` that allows an `.Rprofile` to run -pub struct DummyArkFrontendRprofile { - inner: DummyArkFrontend, -} - -/// Wrapper around `DummyArkFrontend` that allows setting default repos -/// for the frontend -pub struct DummyArkFrontendDefaultRepos { - inner: DummyArkFrontend, -} - -impl DummyArkFrontend { - pub fn lock() -> Self { - Self { - guard: Self::get_frontend().lock().unwrap(), - } - } - - /// Wait for R cleanup to start (indicating shutdown has been initiated). - /// Panics if cleanup doesn't start within the timeout. - #[cfg(unix)] - #[track_caller] - pub fn wait_for_cleanup() { - use std::time::Duration; - - use crate::sys::console::CLEANUP_SIGNAL; - - let (lock, cvar) = &CLEANUP_SIGNAL; - let result = cvar - .wait_timeout_while(lock.lock().unwrap(), Duration::from_secs(3), |started| { - !*started - }) - .unwrap(); - - if !*result.0 { - panic!("Cleanup did not start within timeout"); - } - } - - fn get_frontend() -> &'static Arc> { - // These are the hard-coded defaults. Call `init()` explicitly to - // override. - let options = DummyArkFrontendOptions::default(); - FRONTEND.get_or_init(|| Arc::new(Mutex::new(DummyArkFrontend::init(options)))) - } - - fn init(options: DummyArkFrontendOptions) -> DummyFrontend { - if FRONTEND.get().is_some() { - panic!("Can't spawn Ark more than once"); - } - - // We don't want cli to try and restore the cursor, it breaks our tests - // by adding unecessary ANSI escapes. We don't need this in Positron because - // cli also checks `isatty(stdout())`, which is false in Positron because - // we redirect stdout. - // https://github.com/r-lib/cli/blob/1220ed092c03e167ff0062e9839c81d7258a4600/R/onload.R#L33-L40 - unsafe { std::env::set_var("R_CLI_HIDE_CURSOR", "false") }; - - let connection = DummyConnection::new(); - let (connection_file, registration_file) = connection.get_connection_files(); - - let mut r_args = vec![]; - - // We aren't animals! - r_args.push(String::from("--no-save")); - r_args.push(String::from("--no-restore")); - - if options.interactive { - r_args.push(String::from("--interactive")); - } - if !options.site_r_profile { - r_args.push(String::from("--no-site-file")); - } - if !options.user_r_profile { - r_args.push(String::from("--no-init-file")); - } - if !options.r_environ { - r_args.push(String::from("--no-environ")); - } - - // Start the kernel and REPL in a background thread, does not return and is never joined. - // Must run `start_kernel()` in a background thread because it blocks until it receives - // a `HandshakeReply`, which we send from `from_connection()` below. - stdext::spawn!("dummy_kernel", move || { - crate::start::start_kernel( - connection_file, - Some(registration_file), - r_args, - options.startup_file, - options.session_mode, - false, - options.default_repos, - ); - }); - - DummyFrontend::from_connection(connection) - } -} - -// Check that we haven't left crumbs behind -impl Drop for DummyArkFrontend { - fn drop(&mut self) { - self.assert_no_incoming() - } -} - -// Allow method calls to be forwarded to inner type -impl Deref for DummyArkFrontend { - type Target = DummyFrontend; - - fn deref(&self) -> &Self::Target { - Deref::deref(&self.guard) - } -} - -impl DerefMut for DummyArkFrontend { - fn deref_mut(&mut self) -> &mut Self::Target { - DerefMut::deref_mut(&mut self.guard) - } -} - -impl DummyArkFrontendNotebook { - /// Lock a notebook frontend. - /// - /// NOTE: Only one `DummyArkFrontend` variant should call `lock()` within - /// a given process. - pub fn lock() -> Self { - Self::init(); - - Self { - inner: DummyArkFrontend::lock(), - } - } - - /// Initialize with Notebook session mode - fn init() { - let mut options = DummyArkFrontendOptions::default(); - options.session_mode = SessionMode::Notebook; - FRONTEND.get_or_init(|| Arc::new(Mutex::new(DummyArkFrontend::init(options)))); - } -} - -// Allow method calls to be forwarded to inner type -impl Deref for DummyArkFrontendNotebook { - type Target = DummyFrontend; - - fn deref(&self) -> &Self::Target { - Deref::deref(&self.inner) - } -} - -impl DerefMut for DummyArkFrontendNotebook { - fn deref_mut(&mut self) -> &mut Self::Target { - DerefMut::deref_mut(&mut self.inner) - } -} - -impl DummyArkFrontendDefaultRepos { - /// Lock a frontend with a default repos setting. - /// - /// NOTE: `startup_file` is required because you typically want - /// to force `options(repos =)` to a fixed value for testing, regardless - /// of what the caller's default `repos` are set as (i.e. rig typically - /// sets it to a non-`@CRAN@` value). - /// - /// NOTE: Only one `DummyArkFrontend` variant should call `lock()` within - /// a given process. - pub fn lock(default_repos: DefaultRepos, startup_file: String) -> Self { - Self::init(default_repos, startup_file); - - Self { - inner: DummyArkFrontend::lock(), - } - } - - /// Initialize with given default repos - fn init(default_repos: DefaultRepos, startup_file: String) { - let mut options = DummyArkFrontendOptions::default(); - options.default_repos = default_repos; - options.startup_file = Some(startup_file); - - FRONTEND.get_or_init(|| Arc::new(Mutex::new(DummyArkFrontend::init(options)))); - } -} - -// Allow method calls to be forwarded to inner type -impl Deref for DummyArkFrontendDefaultRepos { - type Target = DummyFrontend; - - fn deref(&self) -> &Self::Target { - Deref::deref(&self.inner) - } -} -impl DummyArkFrontendRprofile { - /// Lock a frontend that supports `.Rprofile`s. - /// - /// NOTE: This variant can only be called exactly once per process, - /// because you can only load an `.Rprofile` one time. Additionally, - /// only one `DummyArkFrontend` variant should call `lock()` within - /// a given process. Practically, this ends up meaning you can only - /// have 1 test block per integration test that uses a - /// `DummyArkFrontendRprofile`. - pub fn lock() -> Self { - Self::init(); - - Self { - inner: DummyArkFrontend::lock(), - } - } - - /// Initialize with user level `.Rprofile` enabled - fn init() { - let mut options = DummyArkFrontendOptions::default(); - options.user_r_profile = true; - let status = FRONTEND.set(Arc::new(Mutex::new(DummyArkFrontend::init(options)))); - - if status.is_err() { - panic!("You can only call `DummyArkFrontendRprofile::lock()` once per process."); - } - - FRONTEND.get().unwrap(); - } -} - -// Allow method calls to be forwarded to inner type -impl Deref for DummyArkFrontendRprofile { - type Target = DummyFrontend; - - fn deref(&self) -> &Self::Target { - Deref::deref(&self.inner) - } -} - -impl DerefMut for DummyArkFrontendRprofile { - fn deref_mut(&mut self) -> &mut Self::Target { - DerefMut::deref_mut(&mut self.inner) - } -} - -impl Default for DummyArkFrontendOptions { - fn default() -> Self { - Self { - interactive: true, - site_r_profile: false, - user_r_profile: false, - r_environ: false, - session_mode: SessionMode::Console, - default_repos: DefaultRepos::Auto, - startup_file: None, - } - } -} diff --git a/crates/ark/src/fixtures/mod.rs b/crates/ark/src/fixtures/mod.rs index 4cbec761b..61a7f3827 100644 --- a/crates/ark/src/fixtures/mod.rs +++ b/crates/ark/src/fixtures/mod.rs @@ -1,5 +1,15 @@ -pub mod dummy_frontend; -pub mod utils; +// +// fixtures/mod.rs +// +// Copyright (C) 2023-2026 Posit Software, PBC. All rights reserved. +// +// + +//! Test utilities for ark's internal unit tests. +//! +//! For integration test utilities (DummyArkFrontend, DapClient, etc.), +//! use the `ark_test` crate instead. + +mod utils; -pub use dummy_frontend::*; pub use utils::*; diff --git a/crates/ark/src/fixtures/utils.rs b/crates/ark/src/fixtures/utils.rs index 33ae84fda..eac3e37e6 100644 --- a/crates/ark/src/fixtures/utils.rs +++ b/crates/ark/src/fixtures/utils.rs @@ -9,10 +9,6 @@ use std::sync::Mutex; use std::sync::MutexGuard; use std::sync::Once; -use amalthea::comm::comm_channel::CommMsg; -use amalthea::socket; -use serde::de::DeserializeOwned; -use serde::Serialize; use tree_sitter::Point; use crate::modules; @@ -28,7 +24,7 @@ pub fn r_test_lock() -> MutexGuard<'static, ()> { static INIT: Once = Once::new(); -pub(crate) fn r_test_init() { +pub fn r_test_init() { harp::fixtures::r_test_init(); INIT.call_once(|| { // Initialize the positron module so tests can use them. @@ -71,40 +67,6 @@ pub fn point_and_offset_from_cursor(x: &str, cursor: u8) -> (String, Point, usiz panic!("`x` must include a `@` character!"); } -pub fn socket_rpc_request<'de, RequestType, ReplyType>( - socket: &socket::comm::CommSocket, - req: RequestType, -) -> ReplyType -where - RequestType: Serialize, - ReplyType: DeserializeOwned, -{ - // Randomly generate a unique ID for this request. - let id = uuid::Uuid::new_v4().to_string(); - - // Serialize the message for the wire - let json = serde_json::to_value(req).unwrap(); - println!("--> {:?}", json); - - // Convert the request to a CommMsg and send it. - let msg = CommMsg::Rpc(id, json); - socket.incoming_tx.send(msg).unwrap(); - let msg = socket - .outgoing_rx - .recv_timeout(std::time::Duration::from_secs(1)) - .unwrap(); - - // Extract the reply from the CommMsg. - match msg { - CommMsg::Rpc(_id, value) => { - println!("<-- {:?}", value); - let reply = serde_json::from_value(value).unwrap(); - reply - }, - _ => panic!("Unexpected Comm Message"), - } -} - pub fn package_is_installed(package: &str) -> bool { harp::parse_eval0( format!(".ps.is_installed('{package}')").as_str(), diff --git a/crates/ark/src/lsp/completions/tests/utils.rs b/crates/ark/src/lsp/completions/tests/utils.rs index 16697a65d..e9ffa26b4 100644 --- a/crates/ark/src/lsp/completions/tests/utils.rs +++ b/crates/ark/src/lsp/completions/tests/utils.rs @@ -8,11 +8,11 @@ use tower_lsp::lsp_types; use tower_lsp::lsp_types::CompletionItem; use tower_lsp::lsp_types::CompletionTextEdit; -use crate::fixtures::utils::point_from_cursor; +use crate::fixtures::point_from_cursor; use crate::lsp::completions::provide_completions; use crate::lsp::completions::sources::utils::has_priority_prefix; -use crate::lsp::document_context::DocumentContext; use crate::lsp::document::Document; +use crate::lsp::document_context::DocumentContext; use crate::lsp::state::WorldState; pub(crate) fn get_completions_at_cursor(cursor_text: &str) -> anyhow::Result> { diff --git a/crates/ark/src/url.rs b/crates/ark/src/url.rs index 8cc1c4144..68fd7c22b 100644 --- a/crates/ark/src/url.rs +++ b/crates/ark/src/url.rs @@ -9,10 +9,13 @@ use url::Url; /// Extended URL utilities for ark. /// -/// On Windows, file URIs can have different representations of the same file. -/// Positron sends `file:///c%3A/...` (URL-encoded colon, lowercase drive) in -/// execute requests and LSP notifications. These variants can be problematic -/// when URI paths are used as HashMap keys. +/// File URIs can have different representations of the same file: +/// - On Windows, Positron sends `file:///c%3A/...` (URL-encoded colon, +/// lowercase drive) in execute requests and LSP notifications. +/// - On macOS, `/var/folders` is a symlink to `/private/var/folders`, and R's +/// `normalizePath()` resolves symlinks. +/// +/// These variants can be problematic when URI paths are used as HashMap keys. /// /// This module provides normalized URI construction and parsing to ensure /// consistent identity across subsystems (DAP breakpoints, LSP documents, @@ -30,7 +33,21 @@ impl ExtUrl { } /// Convert a file path to a normalized file URI. + /// + /// Canonicalizes the path to resolve symlinks (e.g., `/var/folders` -> + /// `/private/var/folders` on macOS) so the URI matches what R's + /// `normalizePath()` produces. Falls back to the original path if + /// canonicalization fails. pub fn from_file_path(path: impl AsRef) -> Result { + let path = path.as_ref(); + + // Canonicalize to resolve symlinks. This is necessary because R's + // `normalizePath()` resolves symlinks, and the URI must match. + let path = std::fs::canonicalize(path).unwrap_or_else(|err| { + log::trace!("Failed to canonicalize path {path:?}: {err:?}"); + path.to_path_buf() + }); + let url = Url::from_file_path(path)?; Ok(Self::normalize(url)) } diff --git a/crates/ark/tests/connections.rs b/crates/ark/tests/connections.rs index e459863e6..42a94b3e5 100644 --- a/crates/ark/tests/connections.rs +++ b/crates/ark/tests/connections.rs @@ -12,9 +12,9 @@ use amalthea::comm::event::CommManagerEvent; use amalthea::socket; use ark::connections::r_connection::Metadata; use ark::connections::r_connection::RConnection; -use ark::fixtures::socket_rpc_request; use ark::modules::ARK_ENVS; use ark::r_task::r_task; +use ark_test::socket_rpc_request; use crossbeam::channel::bounded; use harp::exec::RFunction; use harp::object::RObject; diff --git a/crates/ark/tests/dap.rs b/crates/ark/tests/dap.rs new file mode 100644 index 000000000..9d6170cfc --- /dev/null +++ b/crates/ark/tests/dap.rs @@ -0,0 +1,261 @@ +// +// dap.rs +// +// Copyright (C) 2026 Posit Software, PBC. All rights reserved. +// +// + +use amalthea::fixtures::dummy_frontend::ExecuteRequestOptions; +use ark_test::assert_file_frame; +use ark_test::assert_vdoc_frame; +use ark_test::is_execute_result; +use ark_test::is_idle; +use ark_test::is_start_debug; +use ark_test::is_stop_debug; +use ark_test::stream_contains; +use ark_test::DummyArkFrontend; +use dap::types::Thread; + +#[test] +fn test_dap_initialize_and_disconnect() { + let frontend = DummyArkFrontend::lock(); + + // `start_dap()` connects and initializes, `Drop` disconnects + let mut dap = frontend.start_dap(); + + // First thing sent by frontend after connection + assert!(matches!( + dap.threads().as_slice(), + [Thread { id: -1, name }] if name == "R console" + )); +} + +#[test] +fn test_dap_stopped_at_browser() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + frontend.debug_send_browser(); + + dap.recv_stopped(); + + let stack = dap.stack_trace(); + assert_eq!(stack.len(), 1); + + // line: 1, column: 10 corrsponds to `browser()` + assert_vdoc_frame(&stack[0], "", 1, 10); + + // Execute an expression that doesn't advance the debugger + // FIXME: `preserve_focus_hint` should be false + // https://github.com/posit-dev/positron/issues/11604 + frontend.debug_send_expr("1"); + dap.recv_continued(); + dap.recv_stopped(); + + frontend.debug_send_quit(); + dap.recv_continued(); +} + +#[test] +fn test_dap_nested_stack_frames() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + let file = frontend.send_source( + " +a <- function() { b() } +b <- function() { c() } +c <- function() { browser() } +a() +", + ); + dap.recv_stopped(); + + // Check stack at browser() in c - should have 3 frames: c, b, a + let stack = dap.stack_trace(); + assert!( + stack.len() >= 3, + "Expected at least 3 frames, got {}", + stack.len() + ); + + // Verify frame names (innermost to outermost) + assert_file_frame(&stack[0], &file.filename, 4, 28); + assert_eq!(stack[0].name, "c()"); + + assert_file_frame(&stack[1], &file.filename, 3, 22); + assert_eq!(stack[1].name, "b()"); + + assert_file_frame(&stack[2], &file.filename, 2, 22); + assert_eq!(stack[2].name, "a()"); + + frontend.debug_send_quit(); + dap.recv_continued(); +} + +#[test] +fn test_dap_recursive_function() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Recursive function that hits browser() at the base case + let _file = frontend.send_source( + " +factorial <- function(n) { + if (n <= 1) { + browser() + return(1) + } + n * factorial(n - 1) +} +factorial(3) +", + ); + dap.recv_stopped(); + + // Should be at browser() when n=1, with multiple factorial() frames + let stack = dap.stack_trace(); + + // Count how many factorial() frames we have + let factorial_frames: Vec<_> = stack.iter().filter(|f| f.name == "factorial()").collect(); + assert!( + factorial_frames.len() >= 3, + "Should have at least 3 factorial() frames for factorial(3), got {}", + factorial_frames.len() + ); + + frontend.debug_send_quit(); + dap.recv_continued(); +} + +#[test] +fn test_dap_error_during_debug() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Code that will error after browser() + let _file = frontend.send_source( + " +{ + browser() + stop('intentional error') +} +", + ); + dap.recv_stopped(); + + // We're at browser(), stack should have 1 frame + let stack = dap.stack_trace(); + assert!(stack.len() >= 1, "Should have at least 1 frame"); + + // Step to the error - this should trigger an error and exit debug mode + frontend.debug_send_step_command("n"); + dap.recv_continued(); + + // After error in sourced code, R exits the debug session. + // We received Continued but no subsequent Stopped event - the debug session ended. + // The DapClient::drop() will verify no unexpected messages remain. +} + +#[test] +fn test_dap_error_in_eval() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Enter debug mode via browser() in virtual doc context + frontend.debug_send_browser(); + dap.recv_stopped(); + + let stack = dap.stack_trace(); + assert_eq!(stack.len(), 1, "Should have 1 frame"); + + // Evaluate an expression that causes an error. + // Unlike stepping to an error (which exits debug), evaluating an error + // from the console should keep us in debug mode. + frontend.debug_send_error_expr("stop('eval error')"); + dap.recv_continued(); + dap.recv_stopped(); + + // We should still be in debug mode with the same stack + let stack = dap.stack_trace(); + assert_eq!(stack.len(), 1, "Should still have 1 frame after eval error"); + + // Clean exit + frontend.debug_send_quit(); + dap.recv_continued(); +} + +#[test] +fn test_dap_nested_browser() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Enter debug mode via browser() in virtual doc context + frontend.debug_send_browser(); + dap.recv_stopped(); + + let stack = dap.stack_trace(); + assert_eq!(stack.len(), 1, "Should have 1 frame at Browse[1]>"); + + // Enter nested debug by calling a function with debugonce + frontend.send_execute_request( + "debugonce(identity); identity(1)", + ExecuteRequestOptions::default(), + ); + frontend.recv_iopub_busy(); + frontend.recv_iopub_execute_input(); + + // Entering nested debug via debugonce produces: + // - stop_debug (leaving Browse[1]>) + // - start_debug (twice due to auto-stepping behavior) + // - Stream with "debugging in:" + // - Idle + frontend.recv_iopub_async(vec![ + is_stop_debug(), + is_start_debug(), + is_start_debug(), + stream_contains("debugging in:"), + is_idle(), + ]); + frontend.recv_shell_execute_reply(); + + // DAP: Continued, then two Stopped events (due to auto-stepping) + dap.recv_continued(); + dap.recv_stopped(); + dap.recv_stopped(); + + // Stack now shows 2 frames: identity() and the original browser frame + let stack = dap.stack_trace(); + assert_eq!(stack.len(), 2, "Should have 2 frames at Browse[2]>"); + assert_eq!(stack[0].name, "identity()"); + + // Step with `n` to return to parent browser (Browse[1]>) + frontend.send_execute_request("n", ExecuteRequestOptions::default()); + frontend.recv_iopub_busy(); + frontend.recv_iopub_execute_input(); + + // Stepping back to parent browser produces: + // - stop_debug (leaving identity) + // - ExecuteResult with "exiting from:" message + // - start_debug (back at parent browser) + // - Idle + frontend.recv_iopub_async(vec![ + is_stop_debug(), + is_execute_result(), + is_start_debug(), + is_idle(), + ]); + frontend.recv_shell_execute_reply(); + + // DAP: Continued (left identity) then Stopped (back at parent browser) + dap.recv_continued(); + dap.recv_stopped(); + + // Back to 1 frame at Browse[1]> + let stack = dap.stack_trace(); + assert_eq!(stack.len(), 1, "Should have 1 frame back at Browse[1]>"); + + // Now quit entirely + frontend.debug_send_quit(); + dap.recv_continued(); +} diff --git a/crates/ark/tests/dap_breakpoints.rs b/crates/ark/tests/dap_breakpoints.rs new file mode 100644 index 000000000..128e617bc --- /dev/null +++ b/crates/ark/tests/dap_breakpoints.rs @@ -0,0 +1,209 @@ +// +// dap_breakpoints.rs +// +// Copyright (C) 2026 Posit Software, PBC. All rights reserved. +// +// + +use ark_test::DummyArkFrontend; +use ark_test::SourceFile; + +#[test] +fn test_dap_set_breakpoints_unverified() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + let file = SourceFile::new( + "1 +2 +3 +", + ); + + // Set breakpoints before sourcing - they should be unverified + let breakpoints = dap.set_breakpoints(&file.path, &[2]); + assert_eq!(breakpoints.len(), 1); + assert!(!breakpoints[0].verified); + assert_eq!(breakpoints[0].line, Some(2)); +} + +#[test] +fn test_dap_clear_breakpoints() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + let file = SourceFile::new( + "x <- 1 +y <- 2 +z <- 3 +", + ); + + // Set a breakpoint + let breakpoints = dap.set_breakpoints(&file.path, &[2]); + assert_eq!(breakpoints.len(), 1); + + // Clear all breakpoints by sending empty list + let breakpoints = dap.set_breakpoints(&file.path, &[]); + assert!(breakpoints.is_empty()); +} + +#[test] +fn test_dap_breakpoint_preserves_state_on_resubmit() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + let file = SourceFile::new( + "a <- 1 +b <- 2 +c <- 3 +", + ); + + // Set initial breakpoints + let breakpoints = dap.set_breakpoints(&file.path, &[2, 3]); + assert_eq!(breakpoints.len(), 2); + let id1 = breakpoints[0].id; + let id2 = breakpoints[1].id; + + // Re-submit the same breakpoints - IDs should be preserved + let breakpoints = dap.set_breakpoints(&file.path, &[2, 3]); + assert_eq!(breakpoints.len(), 2); + assert_eq!(breakpoints[0].id, id1); + assert_eq!(breakpoints[1].id, id2); +} + +#[test] +fn test_dap_breakpoint_disabled_preserved_and_restored() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Create file with a function we can set breakpoints on + let file = SourceFile::new( + " +bar <- function() { + a <- 1 + b <- 2 + c <- 3 +} +", + ); + + // Set breakpoint BEFORE sourcing (on line 4: b <- 2) + let breakpoints = dap.set_breakpoints(&file.path, &[4]); + assert_eq!(breakpoints.len(), 1); + let original_id = breakpoints[0].id; + + // Source the file - breakpoint becomes verified during evaluation + frontend.source_file(&file); + + let bp = dap.recv_breakpoint_verified(); + assert_eq!(bp.id, original_id); + + // Now "disable" the breakpoint by omitting it from the request + let breakpoints = dap.set_breakpoints(&file.path, &[]); + assert!(breakpoints.is_empty()); + + // Re-enable by submitting the same line again. + // The breakpoint should have the same ID and be immediately verified + // (restored from disabled state without needing re-sourcing). + let breakpoints = dap.set_breakpoints(&file.path, &[4]); + assert_eq!(breakpoints.len(), 1); + assert_eq!(breakpoints[0].id, original_id); + assert!(breakpoints[0].verified); +} + +/// Test that document hash changes cause breakpoint state to be discarded. +/// +/// This is part of the document change invalidation coverage: when a file's +/// content changes (detected via hash), breakpoint IDs are regenerated and +/// state is reset. This complements the LSP `did_change_document()` unit tests +/// in `dap.rs`. +#[test] +fn test_dap_breakpoint_doc_hash_change_discards_state() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Create initial file + let mut file = SourceFile::new( + "x <- 1 +y <- 2 +z <- 3 +", + ); + + // Set breakpoints and record IDs + let breakpoints = dap.set_breakpoints(&file.path, &[2, 3]); + assert_eq!(breakpoints.len(), 2); + let id1 = breakpoints[0].id; + let id2 = breakpoints[1].id; + + // Modify the file content (different hash) + file.rewrite("a <- 10\nb <- 20\nc <- 30\n"); + + // Re-submit breakpoints at the same lines + let breakpoints = dap.set_breakpoints(&file.path, &[2, 3]); + assert_eq!(breakpoints.len(), 2); + + // IDs should be new (state was discarded due to hash change) + assert_ne!(breakpoints[0].id, id1); + assert_ne!(breakpoints[1].id, id2); + + // Breakpoints should be unverified since they're new + assert!(!breakpoints[0].verified); + assert!(!breakpoints[1].verified); +} + +#[test] +fn test_dap_breakpoints_isolated_per_file() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Create two separate files + let file_a = SourceFile::new( + " +foo <- function() { + 1 +} +", + ); + + let file_b = SourceFile::new( + " +bar <- function() { + 2 +} +", + ); + + // Set breakpoints in both files + let breakpoints_a = dap.set_breakpoints(&file_a.path, &[3]); + assert_eq!(breakpoints_a.len(), 1); + let id_a = breakpoints_a[0].id; + + let breakpoints_b = dap.set_breakpoints(&file_b.path, &[3]); + assert_eq!(breakpoints_b.len(), 1); + let id_b = breakpoints_b[0].id; + + // Source only file A + frontend.source_file(&file_a); + + // Only file A's breakpoint should be verified + let bp = dap.recv_breakpoint_verified(); + assert_eq!(bp.id, id_a); + + // Re-query file B's breakpoints - should still be unverified + let breakpoints_b = dap.set_breakpoints(&file_b.path, &[3]); + assert_eq!(breakpoints_b.len(), 1); + assert_eq!(breakpoints_b[0].id, id_b); + assert!(!breakpoints_b[0].verified); + + // Clear file A's breakpoints + let breakpoints_a = dap.set_breakpoints(&file_a.path, &[]); + assert!(breakpoints_a.is_empty()); + + // File B's breakpoints should still exist + let breakpoints_b = dap.set_breakpoints(&file_b.path, &[3]); + assert_eq!(breakpoints_b.len(), 1); + assert_eq!(breakpoints_b[0].id, id_b); +} diff --git a/crates/ark/tests/dap_breakpoints_integrations.rs b/crates/ark/tests/dap_breakpoints_integrations.rs new file mode 100644 index 000000000..23d18674a --- /dev/null +++ b/crates/ark/tests/dap_breakpoints_integrations.rs @@ -0,0 +1,314 @@ +// +// dap_breakpoints_integrations.rs +// +// Copyright (C) 2026 Posit Software, PBC. All rights reserved. +// +// + +use amalthea::fixtures::dummy_frontend::ExecuteRequestOptions; +use ark_test::DummyArkFrontend; +use ark_test::SourceFile; + +/// Test that `source(file, echo=TRUE)` correctly handles breakpoints. +/// +/// The source() hook explicitly supports echo=TRUE (used by Positron), so this +/// tests that breakpoints work correctly with this option. +#[test] +fn test_dap_source_with_echo() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + let file = SourceFile::new( + " +foo <- function() { + x <- 1 + x +} +", + ); + + // Set breakpoint BEFORE sourcing (on line 3: x <- 1) + let breakpoints = dap.set_breakpoints(&file.path, &[3]); + assert_eq!(breakpoints.len(), 1); + assert!(!breakpoints[0].verified); + let bp_id = breakpoints[0].id; + + // Source the file with echo=TRUE + // The message flow is the same as normal source() - echo=TRUE just affects + // what R prints during sourcing, but we don't need to capture that here. + frontend.send_execute_request( + &format!("source('{}', echo=TRUE)", file.path), + ExecuteRequestOptions::default(), + ); + frontend.recv_iopub_busy(); + frontend.recv_iopub_execute_input(); + frontend.recv_iopub_execute_result(); + frontend.recv_iopub_idle(); + frontend.recv_shell_execute_reply(); + + // Breakpoint becomes verified when the function definition is evaluated + let bp = dap.recv_breakpoint_verified(); + assert_eq!(bp.id, bp_id); + assert_eq!(bp.line, Some(3)); + + // Call foo() to hit the breakpoint + frontend.send_execute_request("foo()", ExecuteRequestOptions::default()); + frontend.recv_iopub_busy(); + frontend.recv_iopub_execute_input(); + + // Direct function call - use recv_iopub_breakpoint_hit_direct which handles + // the debug message flow + frontend.recv_iopub_breakpoint_hit_direct(); + + dap.recv_auto_step_through(); + dap.recv_stopped(); + + // Verify we're stopped at the right place + let stack = dap.stack_trace(); + assert!(!stack.is_empty()); + assert_eq!(stack[0].name, "foo()"); + assert_eq!(stack[0].line, 3); + + // Quit the debugger + frontend.debug_send_quit(); + dap.recv_continued(); + frontend.recv_shell_execute_reply(); +} + +/// Test that breakpoints inside R6 class methods work correctly. +/// +/// R6 is a popular OOP system for R. This test verifies that breakpoints +/// can be set and hit inside R6 method definitions, which was mentioned +/// as an improvement over RStudio's debugging capabilities. +/// +/// This test is skipped if the R6 package is not installed. +#[test] +fn test_dap_breakpoint_r6_method() { + let frontend = DummyArkFrontend::lock(); + + // Check if R6 is installed + if !frontend.is_installed("R6") { + println!("Skipping test_dap_breakpoint_r6_method: R6 package not installed"); + return; + } + + let mut dap = frontend.start_dap(); + + // Create file with an R6 class that has a method with a breakpoint. + // + // Line numbers (1-indexed): + // Line 1: (empty) + // Line 2: Counter <- R6::R6Class("Counter", + // Line 3: public = list( + // Line 4: count = 0, + // Line 5: increment = function() { + // Line 6: self$count <- self$count + 1 # BP here + // Line 7: self$count + // Line 8: } + // Line 9: ) + // Line 10: ) + // Line 11: c <- Counter$new() + // Line 12: c$increment() + let file = SourceFile::new( + r#" +Counter <- R6::R6Class("Counter", + public = list( + count = 0, + increment = function() { + self$count <- self$count + 1 + self$count + } + ) +) +c <- Counter$new() +c$increment() +"#, + ); + + // Set breakpoint on line 6 (self$count <- self$count + 1) BEFORE sourcing + let breakpoints = dap.set_breakpoints(&file.path, &[6]); + assert_eq!(breakpoints.len(), 1); + assert!(!breakpoints[0].verified); + let bp_id = breakpoints[0].id; + + // Source the file - the R6 class is defined, an instance created, and method called + frontend.source_file_and_hit_breakpoint(&file); + + // Breakpoint is verified when the method is hit + let bp = dap.recv_breakpoint_verified(); + assert_eq!(bp.id, bp_id); + assert_eq!(bp.line, Some(6)); + + // Auto-step through wrapper and stop at user expression + dap.recv_auto_step_through(); + dap.recv_stopped(); + + // Verify we're stopped at the breakpoint inside the R6 method + let stack = dap.stack_trace(); + assert_eq!(stack[0].line, 6); + // The method name includes the class context + assert!( + stack[0].name.contains("increment"), + "Expected stack frame name to contain 'increment', got: {}", + stack[0].name + ); + + // Quit the debugger + frontend.debug_send_quit(); + dap.recv_continued(); + frontend.recv_shell_execute_reply(); +} + +/// Test that the source hook falls back to regular source() when disabled. +/// +/// When `ark.source_hook` option is FALSE, the custom source() hook should +/// fall back to R's original source() function, meaning breakpoints won't +/// be injected and verified during sourcing. +#[test] +fn test_dap_source_hook_fallback_when_disabled() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Create file with a function containing a breakpoint location + let file = SourceFile::new( + " +foo <- function() { + x <- 1 + x + 1 +} +", + ); + + // Set breakpoint BEFORE sourcing + let breakpoints = dap.set_breakpoints(&file.path, &[3]); + assert_eq!(breakpoints.len(), 1); + assert!(!breakpoints[0].verified); + + // Disable the source hook + frontend.execute_request_invisibly("options(ark.source_hook = FALSE)"); + + // Source the file - with hook disabled, breakpoint should NOT be verified + frontend.send_execute_request( + &format!("source('{}')", file.path), + ExecuteRequestOptions::default(), + ); + frontend.recv_iopub_busy(); + frontend.recv_iopub_execute_input(); + frontend.recv_iopub_idle(); + frontend.recv_shell_execute_reply(); + + // No breakpoint event should have been sent + dap.assert_no_events(); + + // Re-enable the source hook for cleanup + frontend.execute_request_invisibly("options(ark.source_hook = TRUE)"); + + // Now source again - breakpoint should be verified this time + frontend.source_file(&file); + + // Breakpoint becomes verified when the function definition is evaluated + let bp = dap.recv_breakpoint_verified(); + assert_eq!(bp.line, Some(3)); +} + +/// Test that the source hook falls back when unsupported arguments are passed. +/// +/// The source hook only handles the `file`, `echo`, and `local` arguments. +/// When other arguments are passed (like `chdir`, `print.eval`, etc.), +/// it should fall back to R's original source() function. +#[test] +fn test_dap_source_hook_fallback_with_extra_args() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Create file with a function containing a breakpoint location + let file = SourceFile::new( + " +bar <- function() { + y <- 2 + y + 2 +} +", + ); + + // Set breakpoint BEFORE sourcing + let breakpoints = dap.set_breakpoints(&file.path, &[3]); + assert_eq!(breakpoints.len(), 1); + assert!(!breakpoints[0].verified); + + // Source with an extra argument (chdir) - this should trigger fallback + frontend.send_execute_request( + &format!("source('{}', chdir = TRUE)", file.path), + ExecuteRequestOptions::default(), + ); + frontend.recv_iopub_busy(); + frontend.recv_iopub_execute_input(); + frontend.recv_iopub_idle(); + frontend.recv_shell_execute_reply(); + + // No breakpoint event should have been sent due to fallback + dap.assert_no_events(); + + // Verify the function was still defined (fallback worked) + frontend.execute_request_invisibly("stopifnot(exists('bar'))"); +} + +/// Test that breakpoints work correctly when the same file is sourced multiple times. +/// +/// Re-sourcing a file should re-verify breakpoints as the code is re-parsed. +#[test] +fn test_dap_breakpoint_multiple_source_same_file() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Create file with a simple function + let file = SourceFile::new( + " +greet <- function(name) { + msg <- paste('Hello,', name) + msg +} +greet('World') +", + ); + + // Set breakpoint on line 3 BEFORE first source + let breakpoints = dap.set_breakpoints(&file.path, &[3]); + assert_eq!(breakpoints.len(), 1); + assert!(!breakpoints[0].verified); + let bp_id = breakpoints[0].id; + + // First source - breakpoint gets verified and hit + frontend.source_file_and_hit_breakpoint(&file); + + let bp = dap.recv_breakpoint_verified(); + assert_eq!(bp.id, bp_id); + + dap.recv_auto_step_through(); + dap.recv_stopped(); + + let stack = dap.stack_trace(); + assert_eq!(stack[0].line, 3); + assert_eq!(stack[0].name, "greet()"); + + // Quit the debugger to complete first source + frontend.debug_send_quit(); + dap.recv_continued(); + frontend.recv_shell_execute_reply(); + + // Second source of the same file - breakpoint should hit again + frontend.source_file_and_hit_breakpoint(&file); + + // No new verification event needed - breakpoint is already verified + dap.recv_auto_step_through(); + dap.recv_stopped(); + + let stack = dap.stack_trace(); + assert_eq!(stack[0].line, 3); + assert_eq!(stack[0].name, "greet()"); + + // Quit and finish + frontend.debug_send_quit(); + dap.recv_continued(); + frontend.recv_shell_execute_reply(); +} diff --git a/crates/ark/tests/dap_breakpoints_line_adjustment.rs b/crates/ark/tests/dap_breakpoints_line_adjustment.rs new file mode 100644 index 000000000..c72fd4cc8 --- /dev/null +++ b/crates/ark/tests/dap_breakpoints_line_adjustment.rs @@ -0,0 +1,135 @@ +// +// dap_breakpoints_line_adjustment.rs +// +// Copyright (C) 2026 Posit Software, PBC. All rights reserved. +// +// + +use ark_test::DummyArkFrontend; +use ark_test::SourceFile; + +#[test] +fn test_dap_breakpoint_line_adjustment_multiline_expr() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Breakpoints inside multiline expressions should be adjusted up to expression start + let file = SourceFile::new( + " +foo <- function() { + list( + 1, + 2 + ) +} +", + ); + + // Set breakpoints on lines 4 and 5 (inside the list() call) + // Line 3 is `list(`, lines 4-5 are inside, line 6 is `)` + let breakpoints = dap.set_breakpoints(&file.path, &[4, 5]); + assert_eq!(breakpoints.len(), 2); + let id1 = breakpoints[0].id; + let id2 = breakpoints[1].id; + + // Source the file to verify breakpoints + frontend.source_file(&file); + + // Both breakpoints should be verified and adjusted to line 3 (start of `list(`) + let bp1 = dap.recv_breakpoint_verified(); + let bp2 = dap.recv_breakpoint_verified(); + + // Check that both are our breakpoints (order may vary) + let ids: Vec<_> = vec![bp1.id, bp2.id]; + assert!(ids.contains(&id1)); + assert!(ids.contains(&id2)); + + // Both should be adjusted to line 3 (the start of the multiline expression) + assert_eq!(bp1.line, Some(3)); + assert_eq!(bp2.line, Some(3)); +} + +#[test] +fn test_dap_breakpoint_line_adjustment_blank_line() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Breakpoints on whitespace/comments should be adjusted down to next statement + let file = SourceFile::new( + " +foo <- function() { + # comment + + 1 +} +", + ); + + // Set breakpoints on line 3 (comment) and line 4 (blank line) + // They should both be adjusted down to line 5 (the `1` statement) + let breakpoints = dap.set_breakpoints(&file.path, &[3, 4]); + assert_eq!(breakpoints.len(), 2); + let id1 = breakpoints[0].id; + let id2 = breakpoints[1].id; + + // Source the file to verify breakpoints + frontend.source_file(&file); + + // Both breakpoints should be verified and adjusted to line 5 (the next statement) + let bp1 = dap.recv_breakpoint_verified(); + let bp2 = dap.recv_breakpoint_verified(); + + // Check that both are our breakpoints (order may vary) + let ids: Vec<_> = vec![bp1.id, bp2.id]; + assert!(ids.contains(&id1)); + assert!(ids.contains(&id2)); + + // Both should be adjusted to line 5 (the `1` statement) + assert_eq!(bp1.line, Some(5)); + assert_eq!(bp2.line, Some(5)); +} + +#[test] +fn test_dap_breakpoints_anchor_to_same_line() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Multiple breakpoints that anchor to the same expression should all be verified together + let file = SourceFile::new( + " +foo <- function() { + # comment + list( + 1 + ) +} +", + ); + + // Set breakpoints on lines 3 (comment), 4 (list(), and 5 (inside list) + // All should anchor to line 4 (the `list(` statement) + let breakpoints = dap.set_breakpoints(&file.path, &[3, 4, 5]); + assert_eq!(breakpoints.len(), 3); + let id1 = breakpoints[0].id; + let id2 = breakpoints[1].id; + let id3 = breakpoints[2].id; + + // Source the file to verify breakpoints + frontend.source_file(&file); + + // All three breakpoints should be verified + let bp1 = dap.recv_breakpoint_verified(); + let bp2 = dap.recv_breakpoint_verified(); + let bp3 = dap.recv_breakpoint_verified(); + + // Check that all are our breakpoints (order may vary) + let ids: Vec<_> = vec![bp1.id, bp2.id, bp3.id]; + assert!(ids.contains(&id1)); + assert!(ids.contains(&id2)); + assert!(ids.contains(&id3)); + + // All should be adjusted to line 4 (the `list(` expression start) + assert_eq!(bp1.line, Some(4)); + assert_eq!(bp2.line, Some(4)); + assert_eq!(bp3.line, Some(4)); +} diff --git a/crates/ark/tests/dap_breakpoints_reconnect.rs b/crates/ark/tests/dap_breakpoints_reconnect.rs new file mode 100644 index 000000000..267ba0475 --- /dev/null +++ b/crates/ark/tests/dap_breakpoints_reconnect.rs @@ -0,0 +1,152 @@ +// +// dap_breakpoints_reconnect.rs +// +// Copyright (C) 2026 Posit Software, PBC. All rights reserved. +// +// + +use std::thread; +use std::time::Duration; + +use ark_test::DapClient; +use ark_test::DummyArkFrontend; +use ark_test::SourceFile; + +/// Basic DAP reconnection test. +/// +/// This test, along with the other reconnection tests, helps cover the +/// "multiple sessions with different breakpoint states" scenario from the +/// test coverage plan. Disconnection/reconnection simulates switching between +/// sessions or restarting the debugger. +#[test] +fn test_dap_reconnect_basic() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Get the port before disconnecting + let port = dap.port(); + + // Basic sanity check - threads request works + let threads = dap.threads(); + assert_eq!(threads.len(), 1); + + // Disconnect the client and drop to close TCP connection + dap.disconnect(); + drop(dap); + + // Give the server time to process disconnect and loop back to accept() + thread::sleep(Duration::from_millis(100)); + + // Reconnect to the same DAP server + let mut dap = DapClient::connect("127.0.0.1", port).unwrap(); + dap.initialize(); + dap.attach(); + + // Basic sanity check - threads request works after reconnection + let threads = dap.threads(); + assert_eq!(threads.len(), 1); +} + +/// Test that breakpoint state is preserved across DAP reconnection. +/// +/// Part of multi-session test coverage: verifies that breakpoints set in one +/// "session" persist when the debugger reconnects, as long as the file hasn't +/// changed. +#[test] +fn test_dap_breakpoint_state_preserved_on_reconnect() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + let file = SourceFile::new( + " +foo <- function() { + x <- 1 + y <- 2 +} +", + ); + + // Set breakpoint and source to verify it + let breakpoints = dap.set_breakpoints(&file.path, &[3]); + assert_eq!(breakpoints.len(), 1); + let bp_id = breakpoints[0].id; + + frontend.source_file(&file); + let bp = dap.recv_breakpoint_verified(); + assert_eq!(bp.id, bp_id); + + // Get the port before disconnecting + let port = dap.port(); + + // Disconnect the client and drop to close TCP connection + dap.disconnect(); + drop(dap); + + // Give the server time to process disconnect and loop back to accept() + thread::sleep(Duration::from_millis(100)); + + // Reconnect to the same DAP server (with retry since server needs time to accept) + let mut dap = DapClient::connect("127.0.0.1", port).unwrap(); + dap.initialize(); + dap.attach(); + + // Re-query breakpoints - state should be preserved (same ID, still verified) + let breakpoints = dap.set_breakpoints(&file.path, &[3]); + assert_eq!(breakpoints.len(), 1); + assert_eq!(breakpoints[0].id, bp_id); + assert!(breakpoints[0].verified); +} + +/// Test that breakpoint state is reset when file changes during disconnection. +/// +/// This test covers both multi-session and document change invalidation scenarios: +/// - Simulates a "background session" modifying a file while disconnected +/// - Verifies that breakpoints are reset to unverified when the file hash changes +/// - This is the integration-level test for document change invalidation +/// (complements the unit tests in `dap.rs` for `did_change_document()`) +#[test] +fn test_dap_breakpoint_state_reset_on_reconnect_after_file_change() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + let mut file = SourceFile::new( + "foo <- function() { + x <- 1 + y <- 2 +} +", + ); + + // Set breakpoint and record ID (unverified since we're using temp file) + let breakpoints = dap.set_breakpoints(&file.path, &[2]); + assert_eq!(breakpoints.len(), 1); + let original_id = breakpoints[0].id; + + // Get the port before disconnecting + let port = dap.port(); + + // Disconnect the client and drop to close TCP connection + dap.disconnect(); + drop(dap); + + // Give the server time to process disconnect and loop back to accept() + thread::sleep(Duration::from_millis(100)); + + // Modify the file content while disconnected (simulates background session scenario) + file.rewrite("bar <- function() {\n a <- 10\n b <- 20\n}\n"); + + // Reconnect to the same DAP server (with retry since server needs time to accept) + let mut dap = DapClient::connect("127.0.0.1", port).unwrap(); + dap.initialize(); + dap.attach(); + + // Re-query breakpoints - state should be reset due to hash change + let breakpoints = dap.set_breakpoints(&file.path, &[2]); + assert_eq!(breakpoints.len(), 1); + + // ID should be different (state was discarded due to hash change) + assert_ne!(breakpoints[0].id, original_id); + + // Breakpoint should be unverified + assert!(!breakpoints[0].verified); +} diff --git a/crates/ark/tests/dap_breakpoints_stepping.rs b/crates/ark/tests/dap_breakpoints_stepping.rs new file mode 100644 index 000000000..2bec185f2 --- /dev/null +++ b/crates/ark/tests/dap_breakpoints_stepping.rs @@ -0,0 +1,756 @@ +// +// dap_breakpoints_stepping.rs +// +// Copyright (C) 2026 Posit Software, PBC. All rights reserved. +// +// + +use amalthea::fixtures::dummy_frontend::ExecuteRequestOptions; +use ark_test::is_execute_result; +use ark_test::is_idle; +use ark_test::is_start_debug; +use ark_test::stream_contains; +use ark_test::DummyArkFrontend; +use ark_test::SourceFile; + +/// Test the full breakpoint flow: source, verify, hit, and hit again. +/// +/// This tests end-to-end breakpoint functionality including auto-stepping. +/// When R stops inside `.ark_breakpoint()`, ark auto-steps to the actual +/// user expression, producing this message sequence: +/// - start_debug (entering .ark_breakpoint) +/// - start_debug (at actual user expression after auto-step) +/// - "Called from:" stream +/// - idle +#[test] +fn test_dap_breakpoint_source_and_hit() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Create file with a function and a call to it + let file = SourceFile::new( + " +foo <- function() { + x <- 1 + x + 1 +} +foo() +", + ); + + // Set breakpoint BEFORE sourcing (on line 3: x <- 1) + let breakpoints = dap.set_breakpoints(&file.path, &[3]); + assert_eq!(breakpoints.len(), 1); + assert!(!breakpoints[0].verified); + let bp_id = breakpoints[0].id; + + // Source the file and hit the breakpoint + frontend.source_file_and_hit_breakpoint(&file); + + // Breakpoint becomes verified when the function definition is evaluated + let bp = dap.recv_breakpoint_verified(); + assert_eq!(bp.id, bp_id); + assert_eq!(bp.line, Some(3)); + + dap.recv_auto_step_through(); + dap.recv_stopped(); + + // Verify we're stopped at the right place + let stack = dap.stack_trace(); + assert!(!stack.is_empty()); + assert_eq!(stack[0].name, "foo()"); + + // Quit the debugger to clean up + frontend.debug_send_quit(); + dap.recv_continued(); + + // Receive the shell reply for the original source() request + frontend.recv_shell_execute_reply(); + + // Call foo() again to verify breakpoint is still enabled + frontend.send_execute_request("foo()", ExecuteRequestOptions::default()); + frontend.recv_iopub_busy(); + frontend.recv_iopub_execute_input(); + + // Direct function call has a slightly different flow than source(): + // No "debug at" stream message since we're not stepping through source + frontend.recv_iopub_breakpoint_hit_direct(); + + dap.recv_auto_step_through(); + dap.recv_stopped(); + + // Quit and finish + frontend.debug_send_quit(); + dap.recv_continued(); + frontend.recv_shell_execute_reply(); +} + +/// Regression test: stepping through a top-level `{}` block with breakpoints +/// must not cause subsequent `{}` blocks (without breakpoints) to enter the debugger. +/// +/// This prevents a bug where `RDEBUG` could be accidentally set on the global +/// environment, causing unrelated code to drop into the debugger. +#[test] +fn test_dap_toplevel_braces_no_global_debug() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Create file with a braced block containing a breakpoint + let file = SourceFile::new( + " +{ + x <- 1 + y <- 2 +} +", + ); + + // Set breakpoint on line 3 (x <- 1) + let breakpoints = dap.set_breakpoints(&file.path, &[3]); + assert_eq!(breakpoints.len(), 1); + assert!(!breakpoints[0].verified); + + // Source the file and hit the breakpoint + frontend.source_file_and_hit_breakpoint(&file); + + // Breakpoint becomes verified when the block is evaluated + dap.recv_breakpoint_verified(); + + dap.recv_auto_step_through(); + dap.recv_stopped(); + + // Quit the debugger to exit cleanly + frontend.debug_send_quit(); + dap.recv_continued(); + + // Receive the shell reply for the original source() request + frontend.recv_shell_execute_reply(); + + // Now execute another `{}` block without any breakpoints. + // This should complete normally without entering the debugger. + frontend.execute_request_invisibly( + "{ + a <- 10 + b <- 20 +}", + ); + + // If we reached here without hanging or panicking, the test passes. + // The execute_request_invisibly helper asserts the normal message flow + // (busy -> execute_input -> idle -> execute_reply) which would fail + // if R entered the debugger unexpectedly. +} + +/// Test that breakpoints inside function bodies are verified when the +/// function definition is evaluated, not when the function is called. +/// +/// This tests the timing of verification events: when we source a file +/// containing a function with breakpoints inside it, those breakpoints +/// become verified as soon as R evaluates the function definition (the +/// `foo <- function() {...}` expression), before the function is called. +#[test] +fn test_dap_inner_breakpoint_verified_on_step() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Create a file with a function containing a nested {} block. + // The browser() stops us inside the function, allowing us to verify + // that the breakpoint inside the nested block was already verified + // when the function was defined (not when we step over it). + // + // Line numbers (1-indexed): + // Line 1: (empty) + // Line 2: foo <- function() { + // Line 3: browser() + // Line 4: { + // Line 5: 1 <- BP here + // Line 6: } + // Line 7: } + // Line 8: foo() + let file = SourceFile::new( + " +foo <- function() { + browser() + { + 1 + } +} +foo() +", + ); + + // Set breakpoint on line 5 (the `1` expression inside nested {}) BEFORE sourcing + let breakpoints = dap.set_breakpoints(&file.path, &[5]); + assert_eq!(breakpoints.len(), 1); + assert!(!breakpoints[0].verified); + let bp_id = breakpoints[0].id; + + // Source the file - the function definition is evaluated and breakpoints are injected. + // Then foo() is called which hits browser(). + frontend.send_execute_request( + &format!("source('{}')", file.path), + ExecuteRequestOptions::default(), + ); + frontend.recv_iopub_busy(); + frontend.recv_iopub_execute_input(); + + // The breakpoint gets verified when the function definition is evaluated. + // This happens BEFORE we hit browser() inside the function call. + let bp = dap.recv_breakpoint_verified(); + assert_eq!(bp.id, bp_id); + assert_eq!(bp.line, Some(5)); + + // Then we hit browser() and stop + frontend.recv_iopub_async(vec![ + is_start_debug(), + stream_contains("Called from:"), + is_idle(), + ]); + frontend.recv_shell_execute_reply(); + dap.recv_stopped(); + + // Verify we're stopped at browser() in foo + let stack = dap.stack_trace(); + assert_eq!(stack[0].name, "foo()"); + + // Step with `n` to step over the inner {} block + frontend.debug_send_step_command("n"); + dap.recv_continued(); + dap.recv_stopped(); + + // Verify we're still in foo after stepping over the inner block + let stack = dap.stack_trace(); + assert_eq!(stack[0].name, "foo()"); + + // Quit the debugger + frontend.debug_send_quit(); + dap.recv_continued(); +} + +/// Test stepping from one breakpoint onto an adjacent breakpoint. +/// +/// When stopped at BP1 and stepping with `n` to a line with BP2, the auto-stepping +/// mechanism handles the injected breakpoint code transparently. The DAP event +/// sequence is more complex than a regular step because R steps through: +/// 1. `.ark_auto_step(...)` wrapper (detected via "debug at" message) +/// 2. `.ark_breakpoint(...)` function (detected via function class) +/// 3. Finally the actual user expression at BP2 +/// +/// Expected DAP events when stepping onto an adjacent breakpoint: +/// - Continued (from stop_debug after user's `n`) +/// - Stopped (at .ark_auto_step) +/// - Continued (auto-step over .ark_auto_step) +/// - Continued (from stop_debug) +/// - Stopped (in .ark_breakpoint) +/// - Continued (auto-step out of .ark_breakpoint) +/// - Continued (from stop_debug) +/// - Stopped (at BP2 user expression) +#[test] +fn test_dap_step_to_adjacent_breakpoint() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Create file with a function containing two adjacent breakpoints. + // Line numbers (1-indexed): + // Line 1: (empty) + // Line 2: foo <- function() { + // Line 3: x <- 1 # BP1 + // Line 4: y <- 2 # BP2 + // Line 5: x + y + // Line 6: } + // Line 7: foo() + let file = SourceFile::new( + " +foo <- function() { + x <- 1 + y <- 2 + x + y +} +foo() +", + ); + + // Set breakpoints on lines 3 and 4 BEFORE sourcing + let breakpoints = dap.set_breakpoints(&file.path, &[3, 4]); + assert_eq!(breakpoints.len(), 2); + assert!(!breakpoints[0].verified); + assert!(!breakpoints[1].verified); + let bp1_id = breakpoints[0].id; + let bp2_id = breakpoints[1].id; + + // Source the file - breakpoints get verified when function definition is evaluated + frontend.send_execute_request( + &format!("source('{}')", file.path), + ExecuteRequestOptions::default(), + ); + frontend.recv_iopub_busy(); + frontend.recv_iopub_execute_input(); + + // Both breakpoints become verified when the function definition is evaluated + let bp = dap.recv_breakpoint_verified(); + assert_eq!(bp.id, bp1_id); + let bp = dap.recv_breakpoint_verified(); + assert_eq!(bp.id, bp2_id); + + // Hit BP1: auto-stepping flow + frontend.recv_iopub_breakpoint_hit(); + + // DAP events for hitting BP1: auto-step through .ark_breakpoint wrapper, + // then stop at user expression. + dap.recv_auto_step_through(); + dap.recv_stopped(); + + // Verify we're stopped at BP1 (line 3: x <- 1) + let stack = dap.stack_trace(); + assert_eq!(stack[0].name, "foo()"); + assert_eq!(stack[0].line, 3); + + // Step with `n` to BP2 - this is the key part of the test. + // When stepping onto an injected breakpoint, we go through: + // 1. .ark_auto_step wrapper + // 2. .ark_breakpoint function + // 3. Actual user expression + // Use stream-skipping variants because late-arriving debug output + // from the previous breakpoint can interleave here. + frontend.send_execute_request("n", ExecuteRequestOptions::default()); + frontend.recv_iopub_busy_skip_streams(); + frontend.recv_iopub_execute_input_skip_streams(); + + // IOPub messages: stepping onto an adjacent breakpoint produces multiple + // start_debug/stop_debug cycles due to auto-stepping through the injected code. + // We expect 4 start_debug, 4 stop_debug, and idle (ordering not guaranteed). + frontend.recv_iopub_until(|acc| { + acc.has_comm_method_count("start_debug", 4) && + acc.has_comm_method_count("stop_debug", 4) && + acc.saw_idle() + }); + + frontend.recv_shell_execute_reply(); + + // DAP events when stepping onto an adjacent breakpoint. + // When stepping with `n` from BP1 onto BP2's injected code, R steps through + // the .ark_auto_step and .ark_breakpoint wrappers with auto-stepping. + // + // Enable ARK_TEST_TRACE=all to see the actual message sequence: + // ARK_TEST_TRACE=all cargo nextest run test_dap_step_to_adjacent_breakpoint --success-output=immediate + // + // The sequence is: Continued (step starts), then auto-step through 3 wrappers + // (.ark_auto_step, .ark_breakpoint, nested), then stop at BP2 user expression. + dap.recv_continued(); + dap.recv_auto_step_through(); // .ark_auto_step wrapper + dap.recv_auto_step_through(); // .ark_breakpoint wrapper + dap.recv_auto_step_through(); // Nested wrapper + dap.recv_stopped(); // At BP2 user expression (y <- 2) + + // Verify we're stopped at BP2 (line 4: y <- 2) + let stack = dap.stack_trace(); + assert_eq!(stack[0].name, "foo()"); + assert_eq!(stack[0].line, 4); + + // Quit the debugger. This triggers the cleanup in r_read_console which + // sends a Continued event via stop_debug(). + frontend.debug_send_quit(); + dap.recv_continued(); + frontend.recv_shell_execute_reply(); +} + +/// Test that stepping through `.ark_auto_step()` wrapper is transparent. +/// +/// When a breakpoint is injected, it's wrapped in `.ark_auto_step({ .ark_breakpoint(...) })`. +/// Stepping over this wrapper should land on the next user expression, not inside the wrapper. +/// This test verifies the auto-stepping mechanism works correctly by checking that we +/// don't see intermediate stops inside the injected code. +#[test] +fn test_dap_auto_step_transparent() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Create a file with a function containing multiple expressions. + // We'll set a breakpoint on the first expression and verify that + // after hitting the breakpoint, we're at the user expression (not inside wrappers). + // + // Line numbers (1-indexed): + // Line 1: (empty) + // Line 2: foo <- function() { + // Line 3: a <- 1 # BP here + // Line 4: b <- 2 + // Line 5: a + b + // Line 6: } + // Line 7: foo() + let file = SourceFile::new( + " +foo <- function() { + a <- 1 + b <- 2 + a + b +} +foo() +", + ); + + // Set breakpoint on line 3 (a <- 1) + let breakpoints = dap.set_breakpoints(&file.path, &[3]); + assert_eq!(breakpoints.len(), 1); + let bp_id = breakpoints[0].id; + + // Source the file and hit the breakpoint + frontend.source_file_and_hit_breakpoint(&file); + + let bp = dap.recv_breakpoint_verified(); + assert_eq!(bp.id, bp_id); + + // The auto-step mechanism transparently steps through the wrapper functions + // (.ark_auto_step and .ark_breakpoint) and stops at the user expression + dap.recv_auto_step_through(); + dap.recv_stopped(); + + // Verify we're at the user expression (line 3), not inside any wrapper + let stack = dap.stack_trace(); + assert_eq!(stack[0].name, "foo()"); + assert_eq!(stack[0].line, 3); + + // Quit the debugger + frontend.debug_send_quit(); + dap.recv_continued(); + frontend.recv_shell_execute_reply(); +} + +/// Test that breakpoints inside `lapply()` callbacks hit on each iteration. +/// +/// This is a key use case where users want to debug code that runs multiple times +/// in a loop construct. The breakpoint should be verified once when the callback +/// function is defined, and then hit on each iteration of lapply. +#[test] +fn test_dap_breakpoint_lapply_iteration() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Create file with lapply calling a function with a breakpoint. + // Line numbers (1-indexed): + // Line 1: (empty) + // Line 2: lapply(1:3, function(x) { + // Line 3: y <- x + 1 # BP here + // Line 4: y + // Line 5: }) + let file = SourceFile::new( + " +lapply(1:3, function(x) { + y <- x + 1 + y +}) +", + ); + + // Set breakpoint on line 3 (y <- x + 1) BEFORE sourcing + let breakpoints = dap.set_breakpoints(&file.path, &[3]); + assert_eq!(breakpoints.len(), 1); + assert!(!breakpoints[0].verified); + let bp_id = breakpoints[0].id; + + // Source the file - breakpoint gets verified when the anonymous function is evaluated + frontend.send_execute_request( + &format!("source('{}')", file.path), + ExecuteRequestOptions::default(), + ); + frontend.recv_iopub_busy(); + frontend.recv_iopub_execute_input(); + + // Breakpoint becomes verified when the function definition is evaluated + let bp = dap.recv_breakpoint_verified(); + assert_eq!(bp.id, bp_id); + assert_eq!(bp.line, Some(3)); + + // First iteration: hit breakpoint with x=1 + frontend.recv_iopub_breakpoint_hit(); + dap.recv_auto_step_through(); + dap.recv_stopped(); + + // Verify we're stopped at the breakpoint + let stack = dap.stack_trace(); + assert_eq!(stack[0].line, 3); + + // Continue to second iteration: x=2. + // Send `c` via Shell to continue execution. R will hit the breakpoint again + // on the next iteration of lapply. + // Use stream-skipping variants because late-arriving debug output + // from previous breakpoint hits can interleave here. + frontend.send_execute_request("c", ExecuteRequestOptions::default()); + frontend.recv_iopub_busy_skip_streams(); + frontend.recv_iopub_execute_input_skip_streams(); + + // When continuing from inside lapply, the breakpoint is hit again. + // The flow includes stop_debug (exiting current debug) and start_debug (new hit). + // Note: idle timing relative to stop_debug is not guaranteed. + frontend.recv_iopub_until(|acc| { + acc.has_comm_method_count("start_debug", 2) && + acc.has_comm_method_count("stop_debug", 2) && + acc.saw_idle() + }); + frontend.recv_shell_execute_reply(); + + // DAP events: Continued from stop_debug, then auto-step through, then stopped + dap.recv_continued(); + dap.recv_auto_step_through(); + dap.recv_stopped(); + + let stack = dap.stack_trace(); + assert_eq!(stack[0].line, 3); + + // Continue to third iteration: x=3 + frontend.send_execute_request("c", ExecuteRequestOptions::default()); + frontend.recv_iopub_busy_skip_streams(); + frontend.recv_iopub_execute_input_skip_streams(); + frontend.recv_iopub_until(|acc| { + acc.has_comm_method_count("start_debug", 2) && + acc.has_comm_method_count("stop_debug", 2) && + acc.saw_idle() + }); + frontend.recv_shell_execute_reply(); + + dap.recv_continued(); + dap.recv_auto_step_through(); + dap.recv_stopped(); + + let stack = dap.stack_trace(); + assert_eq!(stack[0].line, 3); + + // Continue past the last iteration - execution completes normally + frontend.send_execute_request("c", ExecuteRequestOptions::default()); + frontend.recv_iopub_busy_skip_streams(); + frontend.recv_iopub_execute_input_skip_streams(); + + // R exits the debugger and completes lapply (returns list result). + // stop_debug is async, but execute_result must come before idle. + frontend.recv_iopub_until(|acc| { + acc.has_comm_method("stop_debug") && acc.in_order(&[is_execute_result(), is_idle()]) + }); + frontend.recv_shell_execute_reply(); + + dap.recv_continued(); + + // Receive the shell reply for the original source() request + frontend.recv_shell_execute_reply(); +} + +/// Test that breakpoints inside a function defined within `local()` are hit +/// when the function is called. +/// +/// This tests breakpoints in nested scopes, which is important for package +/// development where functions are often defined inside local() blocks. +#[test] +fn test_dap_breakpoint_nested_local_function() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Create file with a function defined inside local() that we call directly. + // No browser() needed - we just source and let the breakpoint be hit. + // + // Line numbers (1-indexed): + // Line 1: (empty) + // Line 2: local({ + // Line 3: inner_fn <- function() { + // Line 4: z <- 42 # BP here + // Line 5: z + // Line 6: } + // Line 7: inner_fn() + // Line 8: }) + let file = SourceFile::new( + " +local({ + inner_fn <- function() { + z <- 42 + z + } + inner_fn() +}) +", + ); + + // Set breakpoint on line 4 (z <- 42) BEFORE sourcing + let breakpoints = dap.set_breakpoints(&file.path, &[4]); + assert_eq!(breakpoints.len(), 1); + assert!(!breakpoints[0].verified); + let bp_id = breakpoints[0].id; + + // Source the file - the function is defined and called, hitting the breakpoint + frontend.source_file_and_hit_breakpoint(&file); + + // Breakpoint is verified when hit + let bp = dap.recv_breakpoint_verified(); + assert_eq!(bp.id, bp_id); + assert_eq!(bp.line, Some(4)); + + // Auto-step through wrapper and stop at user expression + dap.recv_auto_step_through(); + dap.recv_stopped(); + + // Verify we're stopped at the breakpoint (line 4: z <- 42) + let stack = dap.stack_trace(); + assert_eq!(stack[0].line, 4); + assert_eq!(stack[0].name, "inner_fn()"); + + // Quit the debugger + frontend.debug_send_quit(); + dap.recv_continued(); + frontend.recv_shell_execute_reply(); +} + +/// Test that breakpoints inside `for` loops hit on each iteration. +/// +/// Similar to the lapply test, but for traditional for loops. +/// The breakpoint should hit on each iteration of the loop. +#[test] +fn test_dap_breakpoint_for_loop_iteration() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Create file with a for loop containing a breakpoint. + // Line numbers (1-indexed): + // Line 1: (empty) + // Line 2: { + // Line 3: for (i in 1:3) { + // Line 4: x <- i * 2 # BP here + // Line 5: } + // Line 6: } + let file = SourceFile::new( + " +{ + for (i in 1:3) { + x <- i * 2 + } +} +", + ); + + // Set breakpoint on line 4 (x <- i * 2) BEFORE sourcing + let breakpoints = dap.set_breakpoints(&file.path, &[4]); + assert_eq!(breakpoints.len(), 1); + assert!(!breakpoints[0].verified); + let bp_id = breakpoints[0].id; + + // Source the file - breakpoint gets verified when the braced block is evaluated + frontend.source_file_and_hit_breakpoint(&file); + + // Breakpoint becomes verified + let bp = dap.recv_breakpoint_verified(); + assert_eq!(bp.id, bp_id); + assert_eq!(bp.line, Some(4)); + + // First iteration: i=1 + dap.recv_auto_step_through(); + dap.recv_stopped(); + + let stack = dap.stack_trace(); + assert_eq!(stack[0].line, 4); + + // Continue to second iteration: i=2 + frontend.send_execute_request("c", ExecuteRequestOptions::default()); + frontend.recv_iopub_busy(); + frontend.recv_iopub_execute_input(); + // Note: idle timing relative to stop_debug is not guaranteed. + frontend.recv_iopub_until(|acc| { + acc.has_comm_method_count("start_debug", 2) && + acc.has_comm_method_count("stop_debug", 2) && + acc.saw_idle() + }); + frontend.recv_shell_execute_reply(); + + dap.recv_continued(); + dap.recv_auto_step_through(); + dap.recv_stopped(); + + let stack = dap.stack_trace(); + assert_eq!(stack[0].line, 4); + + // Continue to third iteration: i=3 + frontend.send_execute_request("c", ExecuteRequestOptions::default()); + frontend.recv_iopub_busy(); + frontend.recv_iopub_execute_input(); + frontend.recv_iopub_until(|acc| { + acc.has_comm_method_count("start_debug", 2) && + acc.has_comm_method_count("stop_debug", 2) && + acc.saw_idle() + }); + frontend.recv_shell_execute_reply(); + + dap.recv_continued(); + dap.recv_auto_step_through(); + dap.recv_stopped(); + + let stack = dap.stack_trace(); + assert_eq!(stack[0].line, 4); + + // Continue past the last iteration - execution completes. + // Use stream-skipping variants because late-arriving debug output + // from previous iterations can interleave here. + frontend.send_execute_request("c", ExecuteRequestOptions::default()); + frontend.recv_iopub_busy_skip_streams(); + frontend.recv_iopub_execute_input_skip_streams(); + // stop_debug is async, but execute_result must come before idle. + frontend.recv_iopub_until(|acc| { + acc.has_comm_method("stop_debug") && acc.in_order(&[is_execute_result(), is_idle()]) + }); + frontend.recv_shell_execute_reply(); + + dap.recv_continued(); + + // Receive the shell reply for the original source() request + frontend.recv_shell_execute_reply(); +} + +/// Test that breakpoints inside tryCatch error handlers work correctly. +/// +/// This verifies that breakpoints can be hit inside error handling code, +/// which is important for debugging error recovery logic. +#[test] +fn test_dap_breakpoint_trycatch_handler() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Create file with a tryCatch that has a breakpoint in the error handler. + // Line numbers (1-indexed): + // Line 1: (empty) + // Line 2: result <- tryCatch({ + // Line 3: stop("test error") + // Line 4: }, error = function(e) { + // Line 5: msg <- conditionMessage(e) # BP here + // Line 6: paste("Caught:", msg) + // Line 7: }) + let file = SourceFile::new( + r#" +result <- tryCatch({ + stop("test error") +}, error = function(e) { + msg <- conditionMessage(e) + paste("Caught:", msg) +}) +"#, + ); + + // Set breakpoint on line 5 (msg <- conditionMessage(e)) BEFORE sourcing + let breakpoints = dap.set_breakpoints(&file.path, &[5]); + assert_eq!(breakpoints.len(), 1); + assert!(!breakpoints[0].verified); + let bp_id = breakpoints[0].id; + + // Source the file - the error is thrown and caught, triggering the handler + frontend.source_file_and_hit_breakpoint(&file); + + // Breakpoint is verified when hit in the error handler + let bp = dap.recv_breakpoint_verified(); + assert_eq!(bp.id, bp_id); + assert_eq!(bp.line, Some(5)); + + // Auto-step through wrapper and stop at user expression + dap.recv_auto_step_through(); + dap.recv_stopped(); + + // Verify we're stopped at the breakpoint inside the error handler + let stack = dap.stack_trace(); + assert_eq!(stack[0].line, 5); + + // Quit the debugger + frontend.debug_send_quit(); + dap.recv_continued(); + frontend.recv_shell_execute_reply(); +} diff --git a/crates/ark/tests/dap_breakpoints_verification.rs b/crates/ark/tests/dap_breakpoints_verification.rs new file mode 100644 index 000000000..22937aeb9 --- /dev/null +++ b/crates/ark/tests/dap_breakpoints_verification.rs @@ -0,0 +1,443 @@ +// +// dap_breakpoints_verification.rs +// +// Copyright (C) 2026 Posit Software, PBC. All rights reserved. +// +// + +use amalthea::fixtures::dummy_frontend::ExecuteRequestOptions; +use ark_test::is_idle; +use ark_test::is_start_debug; +use ark_test::is_stop_debug; +use ark_test::stream_contains; +use ark_test::DummyArkFrontend; +use ark_test::SourceFile; + +#[test] +fn test_dap_breakpoint_verified_on_source() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Create file with a function that we can set breakpoints on. + // The browser() at the end triggers debug mode entry. + let file = SourceFile::new( + " +foo <- function() { + x <- 1 + y <- 2 + x + y +} +", + ); + + // Set breakpoint BEFORE sourcing (on line 4: y <- 2) + let breakpoints = dap.set_breakpoints(&file.path, &[4]); + assert_eq!(breakpoints.len(), 1); + assert!(!breakpoints[0].verified); + let bp_id = breakpoints[0].id; + + // Now source the file - breakpoint verified when verify code runs during evaluation + frontend.source_file(&file); + + // Breakpoint becomes verified when the function definition is evaluated + let bp = dap.recv_breakpoint_verified(); + assert_eq!(bp.id, bp_id); + assert_eq!(bp.line, Some(4)); +} + +#[test] +fn test_dap_breakpoint_verified_on_execute() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Create file with a function definition. + // Using execute_file simulates running code from an editor with location info. + let file = SourceFile::new( + " +bar <- function() { + a <- 1 + b <- 2 + a + b +} +", + ); + + // Set breakpoint BEFORE executing (on line 4: b <- 2) + let breakpoints = dap.set_breakpoints(&file.path, &[4]); + assert_eq!(breakpoints.len(), 1); + assert!(!breakpoints[0].verified); + let bp_id = breakpoints[0].id; + + // Execute the file with location info - breakpoint is verified during execution + frontend.execute_file(&file); + + // Breakpoint becomes verified when the function definition is executed + let bp = dap.recv_breakpoint_verified(); + assert_eq!(bp.id, bp_id); + assert_eq!(bp.line, Some(4)); +} + +#[test] +fn test_dap_breakpoint_invalid_closing_brace() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Breakpoints on closing `}` should be marked invalid with a reason message + let file = SourceFile::new( + " +foo <- function() { + 1 +} +", + ); + + // Set breakpoint on line 4 (the closing brace `}`) + let breakpoints = dap.set_breakpoints(&file.path, &[4]); + assert_eq!(breakpoints.len(), 1); + let id = breakpoints[0].id; + + // Source the file + frontend.source_file(&file); + + // The breakpoint should be marked invalid (unverified with a message) + let bp = dap.recv_breakpoint_invalid(); + assert_eq!(bp.id, id); + assert!(!bp.verified); + assert_eq!( + bp.message, + Some(String::from("Can't break on closing `}` brace")) + ); +} + +#[test] +fn test_dap_breakpoint_invalid_empty_braces() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Breakpoints inside empty braces should be marked invalid + let file = SourceFile::new( + " +foo <- function() { + # comment only, no actual code +} +", + ); + + // Set breakpoint on line 3 (inside empty braces, only a comment) + let breakpoints = dap.set_breakpoints(&file.path, &[3]); + assert_eq!(breakpoints.len(), 1); + let id = breakpoints[0].id; + + // Source the file + frontend.source_file(&file); + + // The breakpoint should be marked invalid (unverified with a message) + let bp = dap.recv_breakpoint_invalid(); + assert_eq!(bp.id, id); + assert!(!bp.verified); + assert_eq!( + bp.message, + Some(String::from("Can't break inside empty braces")) + ); +} + +#[test] +fn test_dap_breakpoint_trailing_expression_verified() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Breakpoint on last expression in braces should be verified (bubble-up mechanism) + let file = SourceFile::new( + " +foo <- function() { + 1 + 2 +} +", + ); + + // Set breakpoint on line 4 (the `2`, which is the last/trailing expression) + let breakpoints = dap.set_breakpoints(&file.path, &[4]); + assert_eq!(breakpoints.len(), 1); + let id = breakpoints[0].id; + + // Source the file + frontend.source_file(&file); + + // The breakpoint should become verified even though it's the trailing expression + let bp = dap.recv_breakpoint_verified(); + assert_eq!(bp.id, id); + assert_eq!(bp.line, Some(4)); +} + +#[test] +fn test_dap_breakpoint_remove_resource_readd_unverified() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Set → source → remove → source again → re-add: breakpoint should be unverified + let file = SourceFile::new( + " +foo <- function() { + 1 +} +", + ); + + // Set breakpoint and source (verified) + let breakpoints = dap.set_breakpoints(&file.path, &[3]); + assert_eq!(breakpoints.len(), 1); + let original_id = breakpoints[0].id; + + frontend.source_file(&file); + let bp = dap.recv_breakpoint_verified(); + assert_eq!(bp.id, original_id); + + // Remove the breakpoint + let breakpoints = dap.set_breakpoints(&file.path, &[]); + assert!(breakpoints.is_empty()); + + // Source again (no breakpoints to inject this time) + frontend.source_file(&file); + + // Re-add the breakpoint at the same line + // It should be unverified because we removed it before the second source, + // so the disabled state was cleared when we sourced without it + let breakpoints = dap.set_breakpoints(&file.path, &[3]); + assert_eq!(breakpoints.len(), 1); + + // The ID should be different (new breakpoint, not restored) + assert_ne!(breakpoints[0].id, original_id); + + // The breakpoint should be unverified (needs re-sourcing) + assert!(!breakpoints[0].verified); + + // Source again to verify + frontend.source_file(&file); + let bp = dap.recv_breakpoint_verified(); + assert_eq!(bp.id, breakpoints[0].id); +} + +#[test] +fn test_dap_breakpoint_partial_verification_on_error() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // When sourcing fails mid-file, breakpoints before the error should be verified, + // breakpoints after should remain unverified + let file = SourceFile::new( + " +foo <- function() { + 1 +} +stop('error') +bar <- function() { + 2 +} +", + ); + + // Set breakpoints in both functions + // Line 3: inside foo (before error) + // Line 7: inside bar (after error) + let breakpoints = dap.set_breakpoints(&file.path, &[3, 7]); + assert_eq!(breakpoints.len(), 2); + let id_before_error = breakpoints[0].id; + let id_after_error = breakpoints[1].id; + + // Source the file - it will error on line 5 + frontend.send_execute_request( + &format!("source('{}')", file.path), + amalthea::fixtures::dummy_frontend::ExecuteRequestOptions::default(), + ); + frontend.recv_iopub_busy(); + frontend.recv_iopub_execute_input(); + + // The breakpoint before the error should be verified + let bp = dap.recv_breakpoint_verified(); + assert_eq!(bp.id, id_before_error); + + // Receive the error output and completion + frontend.recv_iopub_execute_error(); + frontend.recv_iopub_idle(); + frontend.recv_shell_execute_reply_exception(); + + // Re-query breakpoints to check state + let breakpoints = dap.set_breakpoints(&file.path, &[3, 7]); + assert_eq!(breakpoints.len(), 2); + + // First breakpoint should still be verified (preserved state) + assert_eq!(breakpoints[0].id, id_before_error); + assert!(breakpoints[0].verified); + + // Second breakpoint should remain unverified (code after error wasn't reached) + assert_eq!(breakpoints[1].id, id_after_error); + assert!(!breakpoints[1].verified); +} + +#[test] +fn test_dap_breakpoint_added_after_parse_not_verified() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Create file with a function and a call to it + let file = SourceFile::new( + " +foo <- function() { + x <- 1 + y <- 2 + x + y +} +foo() +", + ); + + // Set BP1 BEFORE sourcing (on line 3: x <- 1) + let breakpoints = dap.set_breakpoints(&file.path, &[3]); + assert_eq!(breakpoints.len(), 1); + let bp1_id = breakpoints[0].id; + + // Source the file - BP1 becomes verified during parsing + frontend.send_execute_request( + &format!("source('{}')", file.path), + ExecuteRequestOptions::default(), + ); + frontend.recv_iopub_busy(); + frontend.recv_iopub_execute_input(); + + // BP1 becomes verified when the function definition is evaluated + let bp = dap.recv_breakpoint_verified(); + assert_eq!(bp.id, bp1_id); + + // Receive the breakpoint hit messages (auto-stepping flow). + // This must come before set_breakpoints because R may have already + // hit the breakpoint and queued a Stopped event. + frontend.recv_iopub_breakpoint_hit(); + + dap.recv_auto_step_through(); + dap.recv_stopped(); + + // We're now stopped at BP1 (line 3: x <- 1) + let stack = dap.stack_trace(); + assert_eq!(stack[0].name, "foo()"); + + // Now add BP2 (on line 4: y <- 2) while stopped. + // BP2 was NOT injected into the code during parsing, so it should be unverified. + let breakpoints = dap.set_breakpoints(&file.path, &[3, 4]); + assert_eq!(breakpoints.len(), 2); + assert_eq!(breakpoints[0].id, bp1_id); + assert!(breakpoints[0].verified); // BP1 is verified + let bp2_id = breakpoints[1].id; + assert!(!breakpoints[1].verified); // BP2 is unverified (not injected) + + // Re-submit the same breakpoints - BP2 should STILL be unverified + // because it was never injected into the code + let breakpoints = dap.set_breakpoints(&file.path, &[3, 4]); + assert_eq!(breakpoints.len(), 2); + assert_eq!(breakpoints[0].id, bp1_id); + assert!(breakpoints[0].verified); // BP1 is verified + assert_eq!(breakpoints[1].id, bp2_id); + assert!(!breakpoints[1].verified); // BP2 is STILL unverified + + // Quit the debugger + frontend.debug_send_quit(); + dap.recv_continued(); + frontend.recv_shell_execute_reply(); +} + +/// Test that a disabled breakpoint does NOT get re-verified when stepping to its location. +/// +/// When a breakpoint is disabled (removed from the active set), stepping to that line +/// via `debug()` should NOT trigger a Breakpoint event to re-verify it. +/// +/// The verification here is implicit: we step to the disabled breakpoint line and +/// only expect the normal stepping DAP events (Continued/Stopped). If a Breakpoint +/// event were incorrectly sent, the test framework's cleanup would detect unexpected +/// messages. +#[test] +fn test_dap_breakpoint_disabled_inert_on_debug_stop() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Create file with a function we can set breakpoints on + let file = SourceFile::new( + " +foo <- function() { + x <- 1 + y <- 2 + x + y +} +", + ); + + // Set breakpoint BEFORE sourcing (on line 4: y <- 2) + let breakpoints = dap.set_breakpoints(&file.path, &[4]); + assert_eq!(breakpoints.len(), 1); + let bp_id = breakpoints[0].id; + + // Source the file - breakpoint becomes verified when the code is evaluated + frontend.source_file(&file); + let bp = dap.recv_breakpoint_verified(); + assert_eq!(bp.id, bp_id); + + // Disable the breakpoint by clearing all breakpoints for this file. + // Internally, verified breakpoints become "Disabled" and are preserved. + let breakpoints = dap.set_breakpoints(&file.path, &[]); + assert!(breakpoints.is_empty()); + + // Now enter debug mode via debug(foo); foo() + // This will stop at the first line of foo (line 3: x <- 1) + // Note: Shell reply is delayed until debug mode exits. + frontend.send_execute_request("debug(foo); foo()", ExecuteRequestOptions::default()); + frontend.recv_iopub_busy(); + frontend.recv_iopub_execute_input(); + + // debug(foo); foo() produces: + // - start_debug (entering foo at first line) + // - Stream with "debugging in:" + // - Idle + frontend.recv_iopub_async(vec![ + is_start_debug(), + stream_contains("debugging in:"), + is_idle(), + ]); + + // DAP: Stopped at first line of foo + dap.recv_stopped(); + + // Verify we're at line 3 (x <- 1) + let stack = dap.stack_trace(); + assert!(!stack.is_empty()); + assert_eq!(stack[0].name, "foo()"); + + // Step to the next line (line 4: y <- 2) - where the disabled breakpoint was. + // If the disabled breakpoint were incorrectly re-verified, we'd receive an + // unexpected Breakpoint event here. + frontend.send_execute_request("n", ExecuteRequestOptions::default()); + frontend.recv_iopub_busy(); + frontend.recv_iopub_execute_input(); + + // Stepping produces: stop_debug, start_debug, Stream with "debug at", Idle + frontend.recv_iopub_async(vec![ + is_stop_debug(), + is_start_debug(), + stream_contains("debug at"), + is_idle(), + ]); + frontend.recv_shell_execute_reply(); + + // DAP: Only Continued then Stopped - no Breakpoint event + dap.recv_continued(); + dap.recv_stopped(); + + // Verify we're now at line 4 (y <- 2) + let stack = dap.stack_trace(); + assert!(!stack.is_empty()); + + // Quit the debugger + frontend.debug_send_quit(); + dap.recv_continued(); + + // Shell reply for the original debug(foo); foo() command + frontend.recv_shell_execute_reply(); +} diff --git a/crates/ark/tests/dap_step.rs b/crates/ark/tests/dap_step.rs new file mode 100644 index 000000000..8bdad868a --- /dev/null +++ b/crates/ark/tests/dap_step.rs @@ -0,0 +1,198 @@ +// +// dap_step.rs +// +// Copyright (C) 2026 Posit Software, PBC. All rights reserved. +// +// + +use ark_test::assert_file_frame; +use ark_test::DummyArkFrontend; + +#[test] +fn test_dap_source_and_step() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Use a braced block so `n` can step within the sourced expression. + let file = frontend.send_source( + " +1 +2 +{ + browser() + 3 + 4 +} +", + ); + dap.recv_stopped(); + + // Check stack at browser() - line 4, end_column 10 for `browser()` + let stack = dap.stack_trace(); + assert!(stack.len() >= 1, "Expected at least 1 frame"); + assert_file_frame(&stack[0], &file.filename, 5, 12); + + frontend.debug_send_step_command("n"); + dap.recv_continued(); + dap.recv_stopped(); + + // After stepping, we should be at line 5 (the `3` expression after browser()) + let stack = dap.stack_trace(); + assert!(stack.len() >= 1, "Expected at least 1 frame after step"); + assert_file_frame(&stack[0], &file.filename, 6, 4); + + // Exit with Q via Jupyter + frontend.debug_send_quit(); + dap.recv_continued(); +} + +#[test] +fn test_dap_step_into_function() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + let file = frontend.send_source( + " +foo <- function() { + browser() + bar() + 2 +} +bar <- function() { + 1 +} +foo() +", + ); + dap.recv_stopped(); + + // Check initial stack at browser() in foo + let stack = dap.stack_trace(); + assert!(stack.len() >= 1, "Expected at least 1 frame"); + assert_eq!(stack[0].name, "foo()"); + assert_file_frame(&stack[0], &file.filename, 3, 12); + + // Step with `n` to the bar() call + frontend.debug_send_step_command("n"); + dap.recv_continued(); + dap.recv_stopped(); + + let stack = dap.stack_trace(); + assert_eq!(stack[0].name, "foo()"); + assert_file_frame(&stack[0], &file.filename, 4, 8); + + // Step with `s` into bar() + frontend.debug_send_step_command("s"); + dap.recv_continued(); + dap.recv_stopped(); + + // Verify stack has 2 frames: bar on top, foo below + let stack = dap.stack_trace(); + assert!(stack.len() >= 2, "Expected at least 2 frames after step in"); + assert_eq!(stack[0].name, "bar()"); + assert_eq!(stack[1].name, "foo()"); + + // Step out with `f` (finish) + frontend.debug_send_step_command("f"); + dap.recv_continued(); + dap.recv_stopped(); + + // Verify we're back in foo + let stack = dap.stack_trace(); + assert_eq!(stack[0].name, "foo()"); + + frontend.debug_send_quit(); + dap.recv_continued(); +} + +#[test] +fn test_dap_continue() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + let file = frontend.send_source( + " +{ + browser() + 1 + browser() + 2 +} +", + ); + dap.recv_stopped(); + + // Check we're at first browser() + let stack = dap.stack_trace(); + assert_file_frame(&stack[0], &file.filename, 3, 12); + + // Continue with `c` to next browser() + frontend.debug_send_continue_to_breakpoint(); + dap.recv_continued(); + dap.recv_stopped(); + + // Verify we stopped at second browser() + let stack = dap.stack_trace(); + assert_file_frame(&stack[0], &file.filename, 5, 12); + + // Continue again - should exit debug session + frontend.debug_send_quit(); + dap.recv_continued(); +} + +#[test] +fn test_dap_step_out() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // outer() has browser() so we stay in debug mode after stepping out of inner() + let file = frontend.send_source( + " +outer <- function() { + browser() + inner() + 2 +} +inner <- function() { + x <- 1 + x +} +outer() +", + ); + dap.recv_stopped(); + + // Check initial stack at browser() in outer + let stack = dap.stack_trace(); + assert!(stack.len() >= 1, "Expected at least 1 frame"); + assert_eq!(stack[0].name, "outer()"); + assert_file_frame(&stack[0], &file.filename, 3, 12); + + // Step with `n` to inner() call + frontend.debug_send_step_command("n"); + dap.recv_continued(); + dap.recv_stopped(); + + // Step into inner() with `s` + frontend.debug_send_step_command("s"); + dap.recv_continued(); + dap.recv_stopped(); + + // Verify we're in inner() + let stack = dap.stack_trace(); + assert!(stack.len() >= 2, "Expected at least 2 frames after step in"); + assert_eq!(stack[0].name, "inner()"); + assert_eq!(stack[1].name, "outer()"); + + // Step out with `f` (finish) + frontend.debug_send_step_command("f"); + dap.recv_continued(); + dap.recv_stopped(); + + // Verify we're back in outer, at the line after inner() call + let stack = dap.stack_trace(); + assert_eq!(stack[0].name, "outer()"); + + frontend.debug_send_quit(); + dap.recv_continued(); +} diff --git a/crates/ark/tests/dap_variables.rs b/crates/ark/tests/dap_variables.rs new file mode 100644 index 000000000..58f0f148a --- /dev/null +++ b/crates/ark/tests/dap_variables.rs @@ -0,0 +1,297 @@ +// +// dap_variables.rs +// +// Copyright (C) 2026 Posit Software, PBC. All rights reserved. +// +// + +use ark_test::DummyArkFrontend; + +#[test] +fn test_dap_variables() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + let _file = frontend.send_source( + " +local({ + x <- 42 + y <- 'hello' + browser() +}) +", + ); + dap.recv_stopped(); + + // Get the stack trace to get frame_id + let stack = dap.stack_trace(); + assert!(stack.len() >= 1, "Expected at least 1 frame"); + let frame_id = stack[0].id; + + // Get scopes for the frame + let scopes = dap.scopes(frame_id); + assert!(!scopes.is_empty(), "Expected at least 1 scope"); + + // Get the variables reference from the first (Locals) scope + let variables_reference = scopes[0].variables_reference; + assert!( + variables_reference > 0, + "Expected positive variables_reference" + ); + + // Get variables + let variables = dap.variables(variables_reference); + + // Find x and y in the variables + let x_var = variables.iter().find(|v| v.name == "x"); + let y_var = variables.iter().find(|v| v.name == "y"); + + assert!(x_var.is_some(), "Expected variable 'x' in scope"); + assert!(y_var.is_some(), "Expected variable 'y' in scope"); + + let x_var = x_var.unwrap(); + let y_var = y_var.unwrap(); + + assert_eq!(x_var.value, "42"); + assert_eq!(y_var.value, "\"hello\""); + + frontend.debug_send_quit(); + dap.recv_continued(); +} + +#[test] +fn test_dap_variables_nested() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + let _file = frontend.send_source( + " +local({ + my_list <- list(a = 1, b = 2, c = list(nested = 'deep')) + my_df <- data.frame(x = 1:3, y = c('a', 'b', 'c')) + browser() +}) +", + ); + dap.recv_stopped(); + + let stack = dap.stack_trace(); + let frame_id = stack[0].id; + let scopes = dap.scopes(frame_id); + let variables = dap.variables(scopes[0].variables_reference); + + // Find my_list - it should have a variables_reference > 0 for expansion + let list_var = variables.iter().find(|v| v.name == "my_list").unwrap(); + assert!( + list_var.variables_reference > 0, + "List should be expandable (variables_reference > 0)" + ); + + // Expand the list to see its children + let list_children = dap.variables(list_var.variables_reference); + assert!( + list_children.len() >= 3, + "List should have at least 3 children" + ); + + let a_child = list_children.iter().find(|v| v.name == "a").unwrap(); + assert_eq!(a_child.value, "1"); + + let c_child = list_children.iter().find(|v| v.name == "c").unwrap(); + assert!( + c_child.variables_reference > 0, + "Nested list 'c' should be expandable" + ); + + // Expand nested list + let nested_children = dap.variables(c_child.variables_reference); + let nested_var = nested_children.iter().find(|v| v.name == "nested").unwrap(); + assert_eq!(nested_var.value, "\"deep\""); + + // Find my_df - data frames are classed objects, currently shown as class name + // but not expandable (this is current behavior) + let df_var = variables.iter().find(|v| v.name == "my_df").unwrap(); + assert!( + df_var.value.contains("data.frame"), + "Data frame should show class name" + ); + + frontend.debug_send_quit(); + dap.recv_continued(); +} + +#[test] +fn test_dap_variables_types() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + let _file = frontend.send_source( + " +local({ + v_int <- 42L + v_dbl <- 3.14 + v_chr <- 'hello' + v_lgl <- TRUE + v_null <- NULL + v_na <- NA + v_vec <- c(1, 2, 3) + v_factor <- factor(c('a', 'b', 'a')) + v_fn <- function(x) x + 1 + v_env <- new.env() + browser() +}) +", + ); + dap.recv_stopped(); + + let stack = dap.stack_trace(); + let frame_id = stack[0].id; + let scopes = dap.scopes(frame_id); + let variables = dap.variables(scopes[0].variables_reference); + + // Integer + let v = variables.iter().find(|v| v.name == "v_int").unwrap(); + assert_eq!(v.value, "42L"); + + // Double + let v = variables.iter().find(|v| v.name == "v_dbl").unwrap(); + assert_eq!(v.value, "3.14"); + + // Character + let v = variables.iter().find(|v| v.name == "v_chr").unwrap(); + assert_eq!(v.value, "\"hello\""); + + // Logical + let v = variables.iter().find(|v| v.name == "v_lgl").unwrap(); + assert_eq!(v.value, "TRUE"); + + // NULL + let v = variables.iter().find(|v| v.name == "v_null").unwrap(); + assert_eq!(v.value, "NULL"); + + // NA + let v = variables.iter().find(|v| v.name == "v_na").unwrap(); + assert_eq!(v.value, "NA"); + + // Vector - shows formatted value (not expandable in current implementation) + let v = variables.iter().find(|v| v.name == "v_vec").unwrap(); + assert!( + v.value.contains("1") && v.value.contains("2") && v.value.contains("3"), + "Vector should show formatted values" + ); + + // Factor - classed object, shows class name + let v = variables.iter().find(|v| v.name == "v_factor").unwrap(); + assert!(v.value.contains("factor"), "Factor should show class name"); + + // Function + let v = variables.iter().find(|v| v.name == "v_fn").unwrap(); + assert!( + v.value.contains("function"), + "Function should show function signature" + ); + + // Environment - should be expandable (it's a VECSXP internally when captured) + let v = variables.iter().find(|v| v.name == "v_env").unwrap(); + assert!( + v.value.contains("environment"), + "Environment should show type" + ); + + frontend.debug_send_quit(); + dap.recv_continued(); +} + +#[test] +fn test_dap_variables_multiple_frames() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + let _file = frontend.send_source( + " +outer_var <- 'outer_value' +outer <- function() { + outer_local <- 'from_outer' + inner() +} +inner <- function() { + inner_local <- 'from_inner' + browser() +} +outer() +", + ); + dap.recv_stopped(); + + let stack = dap.stack_trace(); + assert!(stack.len() >= 2, "Expected at least 2 frames"); + + // Check inner frame (top of stack) + let inner_frame_id = stack[0].id; + let inner_scopes = dap.scopes(inner_frame_id); + let inner_vars = dap.variables(inner_scopes[0].variables_reference); + + let inner_local = inner_vars.iter().find(|v| v.name == "inner_local"); + assert!( + inner_local.is_some(), + "inner_local should be in inner frame" + ); + assert_eq!(inner_local.unwrap().value, "\"from_inner\""); + + // inner frame should NOT have outer_local + let outer_in_inner = inner_vars.iter().find(|v| v.name == "outer_local"); + assert!( + outer_in_inner.is_none(), + "outer_local should NOT be in inner frame" + ); + + // Check outer frame + let outer_frame_id = stack[1].id; + let outer_scopes = dap.scopes(outer_frame_id); + let outer_vars = dap.variables(outer_scopes[0].variables_reference); + + let outer_local = outer_vars.iter().find(|v| v.name == "outer_local"); + assert!( + outer_local.is_some(), + "outer_local should be in outer frame" + ); + assert_eq!(outer_local.unwrap().value, "\"from_outer\""); + + frontend.debug_send_quit(); + dap.recv_continued(); +} + +#[test] +fn test_dap_variables_empty_scope() { + let frontend = DummyArkFrontend::lock(); + let mut dap = frontend.start_dap(); + + // Function with no local variables + let _file = frontend.send_source( + " +empty_fn <- function() { + browser() +} +empty_fn() +", + ); + dap.recv_stopped(); + + let stack = dap.stack_trace(); + let frame_id = stack[0].id; + let scopes = dap.scopes(frame_id); + + // The scope might have variables_reference = 0 for empty scope, + // or return an empty list + if scopes[0].variables_reference > 0 { + let variables = dap.variables(scopes[0].variables_reference); + assert!( + variables.is_empty(), + "Empty function should have no local variables" + ); + } + // If variables_reference is 0, that's also valid for empty scope + + frontend.debug_send_quit(); + dap.recv_continued(); +} diff --git a/crates/ark/tests/data_explorer.rs b/crates/ark/tests/data_explorer.rs index 2a49ed2d9..c5454727a 100644 --- a/crates/ark/tests/data_explorer.rs +++ b/crates/ark/tests/data_explorer.rs @@ -66,11 +66,11 @@ use ark::data_explorer::format::format_column; use ark::data_explorer::format::format_string; use ark::data_explorer::r_data_explorer::DataObjectEnvInfo; use ark::data_explorer::r_data_explorer::RDataExplorer; -use ark::fixtures::r_test_lock; -use ark::fixtures::socket_rpc_request; use ark::lsp::events::EVENTS; use ark::r_task::r_task; use ark::thread::RThreadSafe; +use ark_test::r_test_lock; +use ark_test::socket_rpc_request; use crossbeam::channel::bounded; use harp::environment::R_ENVS; use harp::object::RObject; @@ -2238,7 +2238,11 @@ fn test_search_schema_data_type_filters() { // Test filter for all numeric-like types: Integer, Floating and Date let req = RequestBuilder::search_schema_data_types( - vec![ColumnDisplayType::Integer, ColumnDisplayType::Floating, ColumnDisplayType::Date], + vec![ + ColumnDisplayType::Integer, + ColumnDisplayType::Floating, + ColumnDisplayType::Date, + ], SearchSchemaSortOrder::Original, ); TestAssertions::assert_search_matches(socket, req, vec![1, 2, 4]); // age, score, date_joined @@ -3004,11 +3008,9 @@ fn test_empty_data_frame_state() { let _lock = r_test_lock(); // Test state request with 0-row data frame - let socket = open_data_explorer_from_expression( - "data.frame(x = numeric(0), y = character(0))", - None, - ) - .unwrap(); + let socket = + open_data_explorer_from_expression("data.frame(x = numeric(0), y = character(0))", None) + .unwrap(); assert_match!(socket_rpc(&socket, DataExplorerBackendRequest::GetState), DataExplorerBackendReply::GetStateReply(state) => { @@ -3032,8 +3034,10 @@ fn test_empty_data_frame_column_profiles() { .unwrap(); // Test histogram profile for empty numeric column - let histogram_req = ProfileBuilder::small_histogram(0, ColumnHistogramParamsMethod::Fixed, 10, None); - let req = RequestBuilder::get_column_profiles("empty_histogram".to_string(), vec![histogram_req]); + let histogram_req = + ProfileBuilder::small_histogram(0, ColumnHistogramParamsMethod::Fixed, 10, None); + let req = + RequestBuilder::get_column_profiles("empty_histogram".to_string(), vec![histogram_req]); expect_column_profile_results(&socket, req, |profiles| { let histogram = profiles[0].small_histogram.clone().unwrap(); @@ -3043,7 +3047,8 @@ fn test_empty_data_frame_column_profiles() { // Test frequency table for empty string column let freq_table_req = ProfileBuilder::small_frequency_table(1, 5); - let req = RequestBuilder::get_column_profiles("empty_freq_table".to_string(), vec![freq_table_req]); + let req = + RequestBuilder::get_column_profiles("empty_freq_table".to_string(), vec![freq_table_req]); expect_column_profile_results(&socket, req, |profiles| { let freq_table = profiles[0].small_frequency_table.clone().unwrap(); @@ -3070,8 +3075,10 @@ fn test_single_row_data_frame_column_profiles() { .unwrap(); // Test histogram profile for single value numeric column - let histogram_req = ProfileBuilder::small_histogram(0, ColumnHistogramParamsMethod::Fixed, 10, None); - let req = RequestBuilder::get_column_profiles("single_histogram".to_string(), vec![histogram_req]); + let histogram_req = + ProfileBuilder::small_histogram(0, ColumnHistogramParamsMethod::Fixed, 10, None); + let req = + RequestBuilder::get_column_profiles("single_histogram".to_string(), vec![histogram_req]); expect_column_profile_results(&socket, req, |profiles| { let histogram = profiles[0].small_histogram.clone().unwrap(); @@ -3081,7 +3088,8 @@ fn test_single_row_data_frame_column_profiles() { // Test frequency table for single value string column let freq_table_req = ProfileBuilder::small_frequency_table(1, 5); - let req = RequestBuilder::get_column_profiles("single_freq_table".to_string(), vec![freq_table_req]); + let req = + RequestBuilder::get_column_profiles("single_freq_table".to_string(), vec![freq_table_req]); expect_column_profile_results(&socket, req, |profiles| { let freq_table = profiles[0].small_frequency_table.clone().unwrap(); @@ -3099,7 +3107,10 @@ fn test_single_row_data_frame_column_profiles() { for method in histogram_methods { let histogram_req = ProfileBuilder::small_histogram(3, method.clone(), 10, None); // single_int column - let req = RequestBuilder::get_column_profiles(format!("single_histogram_{:?}", method), vec![histogram_req]); + let req = + RequestBuilder::get_column_profiles(format!("single_histogram_{:?}", method), vec![ + histogram_req, + ]); expect_column_profile_results(&socket, req, |profiles| { let histogram = profiles[0].small_histogram.clone().unwrap(); diff --git a/crates/ark/tests/kernel-debugger.rs b/crates/ark/tests/kernel-debugger.rs index 878420c8c..642c3518b 100644 --- a/crates/ark/tests/kernel-debugger.rs +++ b/crates/ark/tests/kernel-debugger.rs @@ -1,5 +1,5 @@ use amalthea::fixtures::dummy_frontend::ExecuteRequestOptions; -use ark::fixtures::DummyArkFrontend; +use ark_test::DummyArkFrontend; #[test] fn test_execute_request_browser() { diff --git a/crates/ark/tests/kernel-execute-error.rs b/crates/ark/tests/kernel-execute-error.rs index e828cfb10..e7877b840 100644 --- a/crates/ark/tests/kernel-execute-error.rs +++ b/crates/ark/tests/kernel-execute-error.rs @@ -1,5 +1,5 @@ use amalthea::fixtures::dummy_frontend::ExecuteRequestOptions; -use ark::fixtures::DummyArkFrontend; +use ark_test::DummyArkFrontend; #[test] fn test_execute_request_error() { diff --git a/crates/ark/tests/kernel-execute.rs b/crates/ark/tests/kernel-execute.rs index 4198b90d7..3e50eb929 100644 --- a/crates/ark/tests/kernel-execute.rs +++ b/crates/ark/tests/kernel-execute.rs @@ -1,5 +1,5 @@ use amalthea::fixtures::dummy_frontend::ExecuteRequestOptions; -use ark::fixtures::DummyArkFrontend; +use ark_test::DummyArkFrontend; #[test] fn test_execute_request() { diff --git a/crates/ark/tests/kernel-hooks-source.rs b/crates/ark/tests/kernel-hooks-source.rs index 49dbce194..19d9d1e72 100644 --- a/crates/ark/tests/kernel-hooks-source.rs +++ b/crates/ark/tests/kernel-hooks-source.rs @@ -1,6 +1,6 @@ use std::io::Write; -use ark::fixtures::DummyArkFrontend; +use ark_test::DummyArkFrontend; #[test] fn test_source_local() { diff --git a/crates/ark/tests/kernel-notebook.rs b/crates/ark/tests/kernel-notebook.rs index e232f25ea..05d4e7110 100644 --- a/crates/ark/tests/kernel-notebook.rs +++ b/crates/ark/tests/kernel-notebook.rs @@ -1,5 +1,5 @@ use amalthea::fixtures::dummy_frontend::ExecuteRequestOptions; -use ark::fixtures::DummyArkFrontendNotebook; +use ark_test::DummyArkFrontendNotebook; #[test] fn test_notebook_execute_request() { diff --git a/crates/ark/tests/kernel-r-profile.rs b/crates/ark/tests/kernel-r-profile.rs index 92f2c65e2..4134a7784 100644 --- a/crates/ark/tests/kernel-r-profile.rs +++ b/crates/ark/tests/kernel-r-profile.rs @@ -1,7 +1,7 @@ use std::io::Write; use amalthea::fixtures::dummy_frontend::ExecuteRequestOptions; -use ark::fixtures::DummyArkFrontendRprofile; +use ark_test::DummyArkFrontendRprofile; // You must run these tests with `cargo nextest` because they initialise // incompatible process singletons diff --git a/crates/ark/tests/kernel-shutdown.rs b/crates/ark/tests/kernel-shutdown.rs index b3b8f3569..5c90af629 100644 --- a/crates/ark/tests/kernel-shutdown.rs +++ b/crates/ark/tests/kernel-shutdown.rs @@ -1,7 +1,7 @@ #[cfg(unix)] use amalthea::fixtures::dummy_frontend::ExecuteRequestOptions; use amalthea::wire::jupyter_message::Status; -use ark::fixtures::DummyArkFrontend; +use ark_test::DummyArkFrontend; /// Install a SIGINT handler for shutdown tests. This overrides the test runner /// handler so it doesn't cancel our test. diff --git a/crates/ark/tests/kernel-srcref.rs b/crates/ark/tests/kernel-srcref.rs index b8f279630..393937016 100644 --- a/crates/ark/tests/kernel-srcref.rs +++ b/crates/ark/tests/kernel-srcref.rs @@ -1,7 +1,7 @@ use amalthea::wire::execute_request::JupyterPositronLocation; use amalthea::wire::execute_request::JupyterPositronPosition; use amalthea::wire::execute_request::JupyterPositronRange; -use ark::fixtures::DummyArkFrontend; +use ark_test::DummyArkFrontend; #[test] fn test_execute_request_srcref() { diff --git a/crates/ark/tests/kernel-stdin.rs b/crates/ark/tests/kernel-stdin.rs index 2d3f17200..b0dcba0f3 100644 --- a/crates/ark/tests/kernel-stdin.rs +++ b/crates/ark/tests/kernel-stdin.rs @@ -1,5 +1,5 @@ use amalthea::fixtures::dummy_frontend::ExecuteRequestOptions; -use ark::fixtures::DummyArkFrontend; +use ark_test::DummyArkFrontend; #[test] fn test_stdin_basic_prompt() { diff --git a/crates/ark/tests/kernel.rs b/crates/ark/tests/kernel.rs index fae6b0e33..561edb417 100644 --- a/crates/ark/tests/kernel.rs +++ b/crates/ark/tests/kernel.rs @@ -1,7 +1,7 @@ use amalthea::fixtures::dummy_frontend::ExecuteRequestOptions; use amalthea::wire::jupyter_message::Message; use amalthea::wire::kernel_info_request::KernelInfoRequest; -use ark::fixtures::DummyArkFrontend; +use ark_test::DummyArkFrontend; use stdext::assert_match; #[test] diff --git a/crates/ark/tests/plots.rs b/crates/ark/tests/plots.rs index 35cf95eee..8ed5efe4a 100644 --- a/crates/ark/tests/plots.rs +++ b/crates/ark/tests/plots.rs @@ -1,5 +1,5 @@ use amalthea::fixtures::dummy_frontend::ExecuteRequestOptions; -use ark::fixtures::DummyArkFrontend; +use ark_test::DummyArkFrontend; #[test] fn test_basic_plot() { diff --git a/crates/ark/tests/repos-auto.rs b/crates/ark/tests/repos-auto.rs index b9e29ca1d..877808eea 100644 --- a/crates/ark/tests/repos-auto.rs +++ b/crates/ark/tests/repos-auto.rs @@ -8,7 +8,7 @@ use std::io::Write; use amalthea::fixtures::dummy_frontend::ExecuteRequestOptions; -use ark::fixtures::DummyArkFrontendDefaultRepos; +use ark_test::DummyArkFrontendDefaultRepos; /// Using the automatic repos setting, the default CRAN repo should be set to the global RStudio /// CRAN mirror. diff --git a/crates/ark/tests/repos-conf-file.rs b/crates/ark/tests/repos-conf-file.rs index e31d68f74..e1c8cca64 100644 --- a/crates/ark/tests/repos-conf-file.rs +++ b/crates/ark/tests/repos-conf-file.rs @@ -7,7 +7,7 @@ use std::io::Write; use amalthea::fixtures::dummy_frontend::ExecuteRequestOptions; -use ark::fixtures::DummyArkFrontendDefaultRepos; +use ark_test::DummyArkFrontendDefaultRepos; /// Using a configuration file, set the default CRAN repo to a custom value, /// and add an extra internal repo. diff --git a/crates/ark/tests/rstudioapi.rs b/crates/ark/tests/rstudioapi.rs index fc330ce4d..7f793192e 100644 --- a/crates/ark/tests/rstudioapi.rs +++ b/crates/ark/tests/rstudioapi.rs @@ -1,5 +1,5 @@ use amalthea::fixtures::dummy_frontend::ExecuteRequestOptions; -use ark::fixtures::DummyArkFrontend; +use ark_test::DummyArkFrontend; #[test] fn test_get_version() { diff --git a/crates/ark/tests/stack.rs b/crates/ark/tests/stack.rs index 3766ae088..b409efafc 100644 --- a/crates/ark/tests/stack.rs +++ b/crates/ark/tests/stack.rs @@ -1,5 +1,5 @@ use amalthea::fixtures::dummy_frontend::ExecuteRequestOptions; -use ark::fixtures::DummyArkFrontend; +use ark_test::DummyArkFrontend; // These tests assert that we've correctly turned off the `R_StackLimit` check during integration // tests that use the `DummyArkFrontend`. It is turned off using `stdext::IS_TESTING` in the diff --git a/crates/ark/tests/variables.rs b/crates/ark/tests/variables.rs index b1a7fceb3..bde07f3ea 100644 --- a/crates/ark/tests/variables.rs +++ b/crates/ark/tests/variables.rs @@ -15,7 +15,7 @@ use amalthea::comm::variables_comm::VariablesBackendRequest; use amalthea::comm::variables_comm::VariablesFrontendEvent; use amalthea::socket::comm::CommInitiator; use amalthea::socket::comm::CommSocket; -use ark::fixtures::r_test_lock; +use ark_test::r_test_lock; use ark::lsp::events::EVENTS; use ark::r_task::r_task; use ark::thread::RThreadSafe; diff --git a/crates/ark_test/Cargo.toml b/crates/ark_test/Cargo.toml new file mode 100644 index 000000000..f6c6618d3 --- /dev/null +++ b/crates/ark_test/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "ark_test" +version = "0.1.0" +description = """ +Test utilities for Ark. +""" + +authors.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +amalthea = { path = "../amalthea" } +anyhow = "1.0.80" +ark = { path = "../ark" } +dap = { git = "https://github.com/sztomi/dap-rs", branch = "main", features = ["client"] } +harp = { path = "../harp" } +log = "0.4.17" +serde = { version = "1.0.183", features = ["derive"] } +serde_json = "1.0.94" +stdext = { path = "../stdext" } +tempfile = "3" +tree-sitter = "0.24.7" +uuid = "1.3.0" \ No newline at end of file diff --git a/crates/ark_test/src/accumulator.rs b/crates/ark_test/src/accumulator.rs new file mode 100644 index 000000000..ec7bfce5c --- /dev/null +++ b/crates/ark_test/src/accumulator.rs @@ -0,0 +1,398 @@ +// +// accumulator.rs +// +// Copyright (C) 2026 Posit Software, PBC. All rights reserved. +// +// + +//! Message accumulator for resilient IOPub message matching in tests. +//! +//! Stream messages from R can be batched (one message containing multiple outputs) or +//! split (multiple messages), depending on timing. This module provides a +//! `MessageAccumulator` that automatically coalesces stream fragments with the same +//! parent header, making tests immune to batching variations. + +use std::collections::HashMap; +use std::collections::HashSet; +use std::time::Duration; +use std::time::Instant; + +/// Time to wait for trailing stream messages after condition is met. +/// Stream messages can arrive slightly out of order due to batching nondeterminism. +const SETTLE_TIMEOUT_MS: i64 = 50; + +use amalthea::socket::socket::Socket; +use amalthea::wire::jupyter_message::Message; +use amalthea::wire::status::ExecutionState; +use amalthea::wire::stream::Stream; + +use crate::tracing::trace_iopub_message; +use crate::tracing::IoPubTrace; + +/// Accumulates IOPub messages and coalesces stream fragments. +/// +/// Stream messages with the same parent header are automatically combined, +/// eliminating sensitivity to whether R batched or split the output. +pub struct MessageAccumulator { + /// All received messages + pub messages: Vec, + /// Coalesced stdout streams keyed by parent message ID + pub stdout_streams: HashMap, + /// Coalesced stderr streams keyed by parent message ID + pub stderr_streams: HashMap, + /// Indices of messages that have been explicitly checked/consumed + consumed: HashSet, + /// Whether we've seen an idle status + saw_idle: bool, + /// Whether receive_until completed successfully (enables Drop check) + verified: bool, +} + +impl MessageAccumulator { + pub fn new() -> Self { + Self { + messages: Vec::new(), + consumed: HashSet::new(), + stdout_streams: HashMap::new(), + stderr_streams: HashMap::new(), + saw_idle: false, + verified: false, + } + } + + /// Receive messages until the condition is satisfied or timeout. + /// + /// The condition function is called after each message is accumulated, + /// allowing it to check for complex conditions across multiple messages. + /// + /// Returns `Ok(())` if the condition was satisfied, or `Err` with a + /// diagnostic message if the timeout was reached. + pub fn receive_until( + &mut self, + socket: &Socket, + mut condition: F, + timeout: Duration, + ) -> Result<(), String> + where + F: FnMut(&mut Self) -> bool, + { + let start = Instant::now(); + let poll_timeout_ms = 100; + + loop { + if condition(self) { + // Condition met. Allow a short settling period for any trailing + // stream messages that may arrive due to batching nondeterminism. + self.settle(socket, SETTLE_TIMEOUT_MS); + self.verified = true; + return Ok(()); + } + + if start.elapsed() >= timeout { + return Err(format!( + "Timeout after {timeout:?} waiting for condition.\n\ + Accumulated {} messages.\n\ + Coalesced stdout streams: {:?}\n\ + Coalesced stderr streams: {:?}\n\ + Saw idle: {}\n\ + Raw messages: {:#?}", + self.messages.len(), + self.stdout_streams, + self.stderr_streams, + self.saw_idle, + self.messages + )); + } + + // Poll for incoming message + match socket.poll_incoming(poll_timeout_ms) { + Ok(false) => continue, + Ok(true) => {}, + Err(e) => { + return Err(format!( + "Error polling socket: {:?}\n\ + Accumulated so far: {:#?}", + e, self.messages + )); + }, + } + + let msg = match Message::read_from_socket(socket) { + Ok(msg) => msg, + Err(e) => { + return Err(format!( + "Error reading message: {:?}\n\ + Accumulated so far: {:#?}", + e, self.messages + )); + }, + }; + + self.accumulate(msg); + } + } + + /// Wait briefly for any trailing messages (primarily streams) that may + /// arrive after the condition is met due to batching nondeterminism. + fn settle(&mut self, socket: &Socket, timeout_ms: i64) { + loop { + match socket.poll_incoming(timeout_ms) { + Ok(true) => { + if let Ok(msg) = Message::read_from_socket(socket) { + self.accumulate(msg); + } + }, + Ok(false) | Err(_) => break, + } + } + } + + fn accumulate(&mut self, msg: Message) { + self.trace_message(&msg); + + match &msg { + Message::Stream(stream) => { + // Key by `parent_header.msg_id` if present, otherwise use the + // stream message's own `header.msg_id` to avoid collapsing + // unrelated orphan streams into the same bucket. + let key = stream + .parent_header + .as_ref() + .map(|h| &h.msg_id) + .unwrap_or(&stream.header.msg_id) + .clone(); + + let text = &stream.content.text; + let streams = match stream.content.name { + Stream::Stdout => &mut self.stdout_streams, + Stream::Stderr => &mut self.stderr_streams, + }; + + streams.entry(key).or_default().push_str(text); + }, + + Message::Status(status) => { + if status.content.execution_state == ExecutionState::Idle { + self.saw_idle = true; + } + }, + + _ => {}, + } + + self.messages.push(msg); + } + + /// Trace a message for debugging (enable with `ARK_TEST_TRACE=1`) + fn trace_message(&self, msg: &Message) { + let trace = match msg { + Message::Status(status) => match status.content.execution_state { + ExecutionState::Busy => IoPubTrace::Busy, + ExecutionState::Idle => IoPubTrace::Idle, + ExecutionState::Starting => IoPubTrace::Status { + state: "starting".to_string(), + }, + }, + Message::ExecuteInput(input) => IoPubTrace::ExecuteInput { + code: input.content.code.clone(), + }, + Message::ExecuteResult(_) => IoPubTrace::ExecuteResult, + Message::ExecuteError(err) => IoPubTrace::ExecuteError { + message: err.content.exception.evalue.clone(), + }, + Message::Stream(stream) => { + let name = match stream.content.name { + Stream::Stdout => "stdout", + Stream::Stderr => "stderr", + }; + IoPubTrace::Stream { + name: name.to_string(), + text: stream.content.text.clone(), + } + }, + Message::CommOpen(comm) => IoPubTrace::CommOpen { + target: comm.content.target_name.clone(), + }, + Message::CommMsg(comm) => { + let method = comm + .content + .data + .get("method") + .and_then(|m| m.as_str()) + .unwrap_or("?") + .to_string(); + IoPubTrace::CommMsg { method } + }, + Message::CommClose(_) => IoPubTrace::CommClose, + _ => IoPubTrace::Other { + msg_type: format!("{:?}", std::mem::discriminant(msg)), + }, + }; + trace_iopub_message(&trace); + } + + /// Check if any coalesced stdout stream contains the given text. + /// + /// This checks across all parent headers, so it works regardless of + /// whether the text came from multiple batched outputs or a single one. + pub fn stdout_contains(&self, text: &str) -> bool { + self.stdout_streams.values().any(|s| s.contains(text)) + } + + /// Check if any coalesced stderr stream contains the given text. + pub fn stderr_contains(&self, text: &str) -> bool { + self.stderr_streams.values().any(|s| s.contains(text)) + } + + /// Check if any stream (stdout or stderr) contains the given text. + pub fn streams_contain(&self, text: &str) -> bool { + self.stdout_contains(text) || self.stderr_contains(text) + } + + /// Find all messages matching a predicate (without marking as consumed). + pub fn find<'a, F>(&'a self, predicate: F) -> impl Iterator + where + F: Fn(&Message) -> bool + 'a, + { + self.messages.iter().filter(move |m| predicate(m)) + } + + /// Check if any message matches a predicate (without marking as consumed). + pub fn any(&self, predicate: F) -> bool + where + F: Fn(&Message) -> bool, + { + self.messages.iter().any(predicate) + } + + /// Mark messages matching a predicate as consumed and return matching count. + pub fn consume(&mut self, predicate: F) -> usize + where + F: Fn(&Message) -> bool, + { + let mut count = 0; + for (i, msg) in self.messages.iter().enumerate() { + if predicate(msg) { + self.consumed.insert(i); + count += 1; + } + } + count + } + + /// Check if we've seen a comm message with the given method. + /// Marks matching messages as consumed. + pub fn has_comm_method(&mut self, method: &str) -> bool { + self.consume(|m| match m { + Message::CommMsg(comm) => { + comm.content.data.get("method").and_then(|v| v.as_str()) == Some(method) + }, + _ => false, + }) > 0 + } + + /// Check if we've seen at least N comm messages with the given method. + /// Marks matching messages as consumed. + pub fn has_comm_method_count(&mut self, method: &str, count: usize) -> bool { + self.consume(|m| match m { + Message::CommMsg(comm) => { + comm.content.data.get("method").and_then(|v| v.as_str()) == Some(method) + }, + _ => false, + }) >= count + } + + /// Check that predicates match messages in the given order. + /// + /// Each predicate must match a message at a strictly higher index than the + /// previous predicate's match. This verifies synchronous message ordering + /// (e.g., `ExecuteResult` before `Idle`). + /// + /// Marks matching messages as consumed. + /// + /// # Example + /// + /// ```ignore + /// acc.in_order(&[ + /// is_execute_result(), + /// is_idle(), + /// ]) + /// ``` + pub fn in_order(&mut self, predicates: &[Box bool>]) -> bool { + let mut last_idx: Option = None; + + for predicate in predicates { + // Find the first matching message that comes after last_idx + let found = self + .messages + .iter() + .enumerate() + .filter(|(i, _)| last_idx.map_or(true, |last| *i > last)) + .find(|(_, msg)| predicate(msg)); + + match found { + Some((idx, _)) => { + self.consumed.insert(idx); + last_idx = Some(idx); + }, + None => return false, + } + } + + true + } + + /// Check if we've seen an idle status message. + /// Marks the idle status message as consumed. + pub fn saw_idle(&mut self) -> bool { + self.consume(|m| { + matches!( + m, + Message::Status(s) if s.content.execution_state == ExecutionState::Idle + ) + }); + self.saw_idle + } +} + +impl Default for MessageAccumulator { + fn default() -> Self { + Self::new() + } +} + +impl Drop for MessageAccumulator { + fn drop(&mut self) { + if !self.verified { + return; + } + if std::thread::panicking() { + return; + } + + let unconsumed: Vec<_> = self + .messages + .iter() + .enumerate() + .filter(|(i, msg)| { + // Stream messages are exempt due to batching nondeterminism + !matches!(msg, Message::Stream(_)) && !self.consumed.contains(i) + }) + .collect(); + + if !unconsumed.is_empty() { + let descriptions: Vec<_> = unconsumed + .iter() + .map(|(i, msg)| format!(" [{i}] {msg:?}")) + .collect(); + + panic!( + "MessageAccumulator dropped with {} unconsumed non-Stream message(s):\n{}\n\n\ + This usually means the test condition didn't account for all messages.\n\ + Either add checks for these messages or verify they're expected.", + unconsumed.len(), + descriptions.join("\n") + ); + } + } +} diff --git a/crates/ark_test/src/comm.rs b/crates/ark_test/src/comm.rs new file mode 100644 index 000000000..b127e5114 --- /dev/null +++ b/crates/ark_test/src/comm.rs @@ -0,0 +1,35 @@ +// +// comm.rs +// +// Copyright (C) 2023-2026 Posit Software, PBC. All rights reserved. +// +// + +use amalthea::comm::comm_channel::CommMsg; +use amalthea::socket; +use serde::de::DeserializeOwned; +use serde::Serialize; + +pub fn socket_rpc_request<'de, RequestType, ReplyType>( + socket: &socket::comm::CommSocket, + req: RequestType, +) -> ReplyType +where + RequestType: Serialize, + ReplyType: DeserializeOwned, +{ + let id = uuid::Uuid::new_v4().to_string(); + let json = serde_json::to_value(req).unwrap(); + + let msg = CommMsg::Rpc(id, json); + socket.incoming_tx.send(msg).unwrap(); + let msg = socket + .outgoing_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .unwrap(); + + match msg { + CommMsg::Rpc(_id, value) => serde_json::from_value(value).unwrap(), + _ => panic!("Unexpected Comm Message"), + } +} diff --git a/crates/ark_test/src/dap_assert.rs b/crates/ark_test/src/dap_assert.rs new file mode 100644 index 000000000..b6d315a31 --- /dev/null +++ b/crates/ark_test/src/dap_assert.rs @@ -0,0 +1,66 @@ +// +// dap_assert.rs +// +// Copyright (C) 2026 Posit Software, PBC. All rights reserved. +// +// + +use dap::types::Source; +use dap::types::StackFrame; + +/// Assert a stack frame matches expected defaults for a virtual document frame. +#[track_caller] +pub fn assert_vdoc_frame(frame: &StackFrame, name: &str, line: i64, end_column: i64) { + let StackFrame { + id: 0, + name: frame_name, + source: + Some(Source { + name: Some(source_name), + path: Some(path), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: frame_line, + column: 1, + end_line: Some(frame_end_line), + end_column: Some(frame_end_column), + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + } = frame + else { + panic!("Frame doesn't match expected structure: {frame:#?}"); + }; + + assert_eq!(frame_name, name); + assert_eq!(*frame_line, line); + assert_eq!(*frame_end_line, line); + assert_eq!(*frame_end_column, end_column); + assert_eq!(source_name, &format!("{name}.R")); + assert!(path.starts_with("ark:"), "Expected ark: URI, got {path}"); + assert!( + path.ends_with(&format!("{name}.R")), + "Expected path ending with {name}.R, got {path}" + ); +} + +/// Assert a stack frame matches expected values for a file-based frame. +#[track_caller] +pub fn assert_file_frame(frame: &StackFrame, path: &str, line: i64, end_column: i64) { + let source = frame.source.as_ref().expect("Expected source"); + let frame_path = source.path.as_ref().expect("Expected path"); + + assert!( + frame_path.ends_with(path), + "Expected path ending with {path}, got {frame_path}" + ); + assert_eq!(frame.line, line, "line mismatch"); + assert_eq!(frame.end_line, Some(line), "end_line mismatch"); + assert_eq!(frame.end_column, Some(end_column), "end_column mismatch"); +} diff --git a/crates/ark_test/src/dap_client.rs b/crates/ark_test/src/dap_client.rs new file mode 100644 index 000000000..b0e0cd605 --- /dev/null +++ b/crates/ark_test/src/dap_client.rs @@ -0,0 +1,704 @@ +// +// dap_client.rs +// +// Copyright (C) 2026 Posit Software, PBC. All rights reserved. +// +// + +use std::io::BufRead; +use std::io::BufReader; +use std::io::BufWriter; +use std::io::Read; +use std::io::Write; +use std::net::TcpStream; +use std::time::Duration; + +use anyhow::anyhow; +use dap::base_message::BaseMessage; +use dap::base_message::Sendable; +use dap::events::BreakpointEventBody; +use dap::events::Event; +use dap::events::StoppedEventBody; +use dap::requests::AttachRequestArguments; +use dap::requests::Command; +use dap::requests::ContinueArguments; +use dap::requests::DisconnectArguments; +use dap::requests::InitializeArguments; +use dap::requests::NextArguments; +use dap::requests::Request; +use dap::requests::ScopesArguments; +use dap::requests::SetBreakpointsArguments; +use dap::requests::StackTraceArguments; +use dap::requests::StepInArguments; +use dap::requests::VariablesArguments; +use dap::responses::Response; +use dap::responses::ResponseBody; +use dap::types::Breakpoint; +use dap::types::Capabilities; +use dap::types::Scope; +use dap::types::Source; +use dap::types::SourceBreakpoint; +use dap::types::StackFrame; +use dap::types::StoppedEventReason; +use dap::types::Thread; +use dap::types::Variable; + +use crate::tracing::trace_dap_event; +use crate::tracing::trace_dap_request; +use crate::tracing::trace_dap_response; + +/// Default timeout for receiving DAP messages +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5); + +/// A minimal DAP client for testing purposes. +/// +/// Automatically disconnects from the server when dropped. +pub struct DapClient { + reader: BufReader, + writer: BufWriter, + seq: i64, + port: u16, + connected: bool, +} + +impl DapClient { + /// Connect to a DAP server at the given address and port. + pub fn connect(addr: &str, port: u16) -> anyhow::Result { + let stream = TcpStream::connect(format!("{addr}:{port}"))?; + + stream.set_read_timeout(Some(DEFAULT_TIMEOUT))?; + stream.set_write_timeout(Some(DEFAULT_TIMEOUT))?; + + // Disable Nagle's algorithm to ensure messages are sent immediately. + // This matches the server-side setting and prevents buffering delays. + stream.set_nodelay(true)?; + + // Clone stream for reader - explicitly set timeout on the clone too + // in case it's not inherited on all platforms + let read_stream = stream.try_clone()?; + read_stream.set_read_timeout(Some(DEFAULT_TIMEOUT))?; + + let reader = BufReader::new(read_stream); + let writer = BufWriter::new(stream); + + Ok(Self { + reader, + writer, + seq: 0, + port, + connected: false, + }) + } + + /// Initialize the DAP session. + /// + /// Sends Initialize request, asserts success, and consumes the Initialized event. + /// Returns the server's capabilities. + #[track_caller] + pub fn initialize(&mut self) -> Capabilities { + let seq = self + .send(Command::Initialize(InitializeArguments { + adapter_id: String::from("ark-test"), + client_id: Some(String::from("test-client")), + client_name: Some(String::from("Test Client")), + // 1-based offsets as in Positron + lines_start_at1: Some(true), + columns_start_at1: Some(true), + ..Default::default() + })) + .unwrap(); + + let response = self.recv_response(seq); + assert!(response.success, "Initialize request failed"); + + let caps = match response.body { + Some(ResponseBody::Initialize(caps)) => caps, + other => panic!("Expected Initialize response body, got {:?}", other), + }; + + let event = self.recv_event(); + assert!( + matches!(event, Event::Initialized), + "Expected Initialized event, got {:?}", + event + ); + + self.connected = true; + caps + } + + /// Attach to the debuggee. + /// + /// Sends Attach request and consumes the Thread (started) event. + #[track_caller] + pub fn attach(&mut self) { + let seq = self + .send(Command::Attach(AttachRequestArguments { + ..Default::default() + })) + .unwrap(); + + let response = self.recv_response(seq); + assert!(response.success, "Attach request failed"); + assert!( + matches!(response.body, Some(ResponseBody::Attach)), + "Expected Attach response body, got {:?}", + response.body + ); + + let event = self.recv_event(); + let Event::Thread(thread) = event else { + panic!("Expected Thread event, got {:?}", event); + }; + assert_eq!(thread.thread_id, -1, "Expected thread_id -1"); + } + + /// Send continue execution (exit browser/debugger) to server. + #[track_caller] + pub fn continue_execution(&mut self) { + let seq = self + .send(Command::Continue(ContinueArguments { + thread_id: -1, + single_thread: None, + })) + .unwrap(); + + let response = self.recv_response(seq); + assert!(response.success, "Continue request failed"); + assert!( + matches!(response.body, Some(ResponseBody::Continue(_))), + "Expected Continue response body, got {:?}", + response.body + ); + } + + /// Send next (step over) command to server. + #[track_caller] + pub fn step_next(&mut self) { + let seq = self + .send(Command::Next(NextArguments { + thread_id: -1, + single_thread: None, + granularity: None, + })) + .unwrap(); + + let response = self.recv_response(seq); + assert!(response.success, "Next request failed"); + assert!( + matches!(response.body, Some(ResponseBody::Next)), + "Expected Next response body, got {:?}", + response.body + ); + } + + /// Send step in command to server. + #[track_caller] + pub fn step_in(&mut self) { + let seq = self + .send(Command::StepIn(StepInArguments { + thread_id: -1, + single_thread: None, + target_id: None, + granularity: None, + })) + .unwrap(); + + let response = self.recv_response(seq); + assert!(response.success, "StepIn request failed"); + assert!( + matches!(response.body, Some(ResponseBody::StepIn)), + "Expected StepIn response body, got {:?}", + response.body + ); + } + + /// Set breakpoints for a source file. + /// + /// Takes a file path and a list of line numbers (1-based). + /// Returns the breakpoints as reported by the server. + #[track_caller] + pub fn set_breakpoints(&mut self, path: &str, lines: &[i64]) -> Vec { + let breakpoints: Vec = lines + .iter() + .map(|&line| SourceBreakpoint { + line, + column: None, + condition: None, + hit_condition: None, + log_message: None, + }) + .collect(); + + #[allow(deprecated)] + let seq = self + .send(Command::SetBreakpoints(SetBreakpointsArguments { + source: Source { + path: Some(path.to_string()), + name: None, + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }, + breakpoints: Some(breakpoints), + lines: None, + source_modified: None, + })) + .unwrap(); + + let response = self.recv_response(seq); + assert!(response.success, "SetBreakpoints request failed"); + + match response.body { + Some(ResponseBody::SetBreakpoints(sb)) => sb.breakpoints, + other => panic!("Expected SetBreakpoints response body, got {:?}", other), + } + } + + /// Request the current stack trace. + #[track_caller] + pub fn stack_trace(&mut self) -> Vec { + let seq = self + .send(Command::StackTrace(StackTraceArguments { + thread_id: -1, + start_frame: None, + levels: None, + format: None, + })) + .unwrap(); + + let response = self.recv_response(seq); + assert!(response.success, "StackTrace request failed"); + + match response.body { + Some(ResponseBody::StackTrace(st)) => st.stack_frames, + other => panic!("Expected StackTrace response body, got {:?}", other), + } + } + + /// Request scopes for a stack frame. + #[track_caller] + pub fn scopes(&mut self, frame_id: i64) -> Vec { + let seq = self + .send(Command::Scopes(ScopesArguments { frame_id })) + .unwrap(); + + let response = self.recv_response(seq); + assert!(response.success, "Scopes request failed"); + + match response.body { + Some(ResponseBody::Scopes(s)) => s.scopes, + other => panic!("Expected Scopes response body, got {:?}", other), + } + } + + /// Request variables for a given variables reference. + #[track_caller] + pub fn variables(&mut self, variables_reference: i64) -> Vec { + let seq = self + .send(Command::Variables(VariablesArguments { + variables_reference, + filter: None, + start: None, + count: None, + format: None, + })) + .unwrap(); + + let response = self.recv_response(seq); + assert!(response.success, "Variables request failed"); + + match response.body { + Some(ResponseBody::Variables(v)) => v.variables, + other => panic!("Expected Variables response body, got {:?}", other), + } + } + + /// Request the list of threads. + #[track_caller] + pub fn threads(&mut self) -> Vec { + let seq = self.send(Command::Threads).unwrap(); + + let response = self.recv_response(seq); + assert!(response.success, "Threads request failed"); + + match response.body { + Some(ResponseBody::Threads(t)) => t.threads, + other => panic!("Expected Threads response body, got {:?}", other), + } + } + + /// Returns the port this client is connected to. + /// + /// Useful for reconnecting to the same DAP server after disconnecting. + pub fn port(&self) -> u16 { + self.port + } + + /// Disconnect from the DAP server. + /// + /// This method drains any pending events before expecting the disconnect response. + pub fn disconnect(&mut self) { + if !self.connected { + return; + } + + let seq = match self.send(Command::Disconnect(DisconnectArguments { + restart: Some(false), + terminate_debuggee: None, + suspend_debuggee: None, + })) { + Ok(seq) => seq, + Err(err) => { + panic!("Failed to send Disconnect request: {err:?}"); + }, + }; + + // Drain any pending events before expecting the response + loop { + let msg = match self.recv() { + Ok(msg) => msg, + Err(err) => { + panic!("Failed to receive DAP message during disconnect: {err:?}"); + }, + }; + + match msg { + Sendable::Response(response) => { + assert_eq!( + response.request_seq, seq, + "Response request_seq mismatch during disconnect" + ); + assert!(response.success, "Disconnect request failed"); + assert!( + matches!(response.body, Some(ResponseBody::Disconnect)), + "Expected Disconnect response body, got {:?}", + response.body + ); + break; + }, + Sendable::Event(_event) => { + // Events (like Continued) may arrive before the Disconnect + // response due to async processing. Drain them silently. + continue; + }, + Sendable::ReverseRequest(req) => { + panic!("Unexpected ReverseRequest during disconnect: {:?}", req); + }, + } + } + + self.connected = false; + } + + /// Send a DAP request. Returns the sequence number of the sent request. + pub fn send(&mut self, command: Command) -> anyhow::Result { + self.seq += 1; + let request = Request { + seq: self.seq, + command, + }; + + let json = serde_json::to_string(&request)?; + write!( + self.writer, + "Content-Length: {}\r\n\r\n{}", + json.len(), + json + )?; + self.writer.flush()?; + + trace_dap_request(&format!("{:?}", request.command)); + + Ok(self.seq) + } + + /// Receive the next DAP message (response or event). + /// + /// Blocks until a message is received or the timeout expires. + /// Returns a `Sendable` which can be matched to get `Response` or `Event`. + pub fn recv(&mut self) -> anyhow::Result { + // Read headers until we find Content-Length + let mut content_length: Option = None; + + eprintln!("DAP client: recv() called, waiting for header"); + loop { + let mut line = String::new(); + eprintln!("DAP client: calling read_line()"); + let bytes_read = match self.reader.read_line(&mut line) { + Ok(n) => n, + Err(err) => { + eprintln!( + "DAP client: read_line() error: {:?} (kind: {:?})", + err, + err.kind() + ); + return Err(err.into()); + }, + }; + eprintln!( + "DAP client: read_line() returned {bytes_read} bytes: {:?}", + line.trim() + ); + + if bytes_read == 0 { + return Err(anyhow!("Connection closed")); + } + + // Check for empty line (just \r\n or \n) which marks end of headers + let trimmed = line.trim(); + if trimmed.is_empty() { + if content_length.is_some() { + // We have Content-Length and hit the empty separator line + eprintln!("DAP client: got empty line, breaking to read content"); + break; + } + // Skip empty lines before headers (shouldn't happen but be safe) + eprintln!("DAP client: skipping empty line (no content-length yet)"); + continue; + } + + // Parse Content-Length header + if let Some(value) = trimmed.strip_prefix("Content-Length:") { + content_length = Some(value.trim().parse()?); + eprintln!("DAP client: parsed Content-Length: {:?}", content_length); + } + // Ignore other headers (like Content-Type) + } + + let content_length = + content_length.ok_or_else(|| anyhow!("Missing Content-Length header"))?; + + // Read the JSON content + eprintln!("DAP client: reading {content_length} bytes of content"); + let mut content = vec![0u8; content_length]; + if let Err(err) = self.reader.read_exact(&mut content) { + eprintln!( + "DAP client: read_exact() error: {:?} (kind: {:?})", + err, + err.kind() + ); + return Err(err.into()); + } + eprintln!("DAP client: read content successfully"); + + let content = std::str::from_utf8(&content)?; + let message: BaseMessage = serde_json::from_str(content)?; + + Ok(message.message) + } + + /// Receive and assert the next message is a response to the given request. + #[track_caller] + pub fn recv_response(&mut self, request_seq: i64) -> Response { + let msg = self.recv().expect("Failed to receive DAP message"); + match msg { + Sendable::Response(response) => { + assert_eq!( + response.request_seq, request_seq, + "Response request_seq mismatch" + ); + trace_dap_response("response", response.success); + response + }, + Sendable::Event(event) => { + panic!("Expected Response, got Event: {:?}", event); + }, + Sendable::ReverseRequest(req) => { + panic!("Expected Response, got ReverseRequest: {:?}", req); + }, + } + } + + /// Receive and assert the next message is an event. + #[track_caller] + pub fn recv_event(&mut self) -> Event { + let msg = self.recv().expect("Failed to receive DAP message"); + match msg { + Sendable::Event(event) => { + trace_dap_event(&event); + event + }, + Sendable::Response(response) => { + panic!("Expected Event, got Response: {:?}", response); + }, + Sendable::ReverseRequest(req) => { + panic!("Expected Event, got ReverseRequest: {:?}", req); + }, + } + } + + /// Assert that no DAP events arrive within 100ms. + #[track_caller] + pub fn assert_no_events(&mut self) { + // Save original timeout and set a short one for checking + let original_timeout = { + let stream = self.reader.get_ref(); + let timeout_val = stream.read_timeout().ok().flatten(); + let _ = stream.set_read_timeout(Some(Duration::from_millis(100))); + timeout_val + }; + + let mut unexpected = Vec::new(); + loop { + match self.recv() { + Ok(Sendable::Event(event)) => { + trace_dap_event(&event); + unexpected.push(event); + }, + Ok(Sendable::Response(_)) | Ok(Sendable::ReverseRequest(_)) | Err(_) => { + break; + }, + } + } + + // Restore original timeout + { + let stream = self.reader.get_ref(); + let _ = stream.set_read_timeout(original_timeout); + } + + assert!( + unexpected.is_empty(), + "Expected no DAP events, but received: {unexpected:?}" + ); + } + + /// Receive and assert the next message is a Continued event. + #[track_caller] + pub fn recv_continued(&mut self) { + let event = self.recv_event(); + assert!( + matches!(event, Event::Continued(_)), + "Expected Continued event, got {:?}", + event + ); + } + + /// Receive the DAP event sequence for auto-stepping through injected code. + /// + /// When R steps through injected breakpoint wrappers (`.ark_auto_step`, + /// `.ark_breakpoint`), it produces this sequence: + /// - Stopped (entering the wrapper) + /// - Continued (auto-step triggers next step) + /// - Continued (from stop_debug) + #[track_caller] + pub fn recv_auto_step_through(&mut self) { + self.recv_stopped(); + self.recv_continued(); + self.recv_continued(); + } + + /// Receive and assert the next message is a Stopped event with default fields. + #[track_caller] + pub fn recv_stopped(&mut self) { + self.recv_stopped_impl(false); + } + + /// Receive and assert the next message is a Stopped event with preserve_focus_hint set to true. + /// + /// This is expected when evaluating an expression in the debug console that + /// doesn't change the debug position (e.g., inspecting a variable). + #[track_caller] + pub fn recv_stopped_preserve_focus(&mut self) { + self.recv_stopped_impl(true); + } + + #[track_caller] + fn recv_stopped_impl(&mut self, preserve_focus: bool) { + let event = self.recv_event(); + assert!( + matches!( + &event, + Event::Stopped(StoppedEventBody { + reason: StoppedEventReason::Step, + description: None, + thread_id: Some(-1), + preserve_focus_hint: Some(pf), + text: None, + all_threads_stopped: Some(true), + hit_breakpoint_ids: None, + }) if *pf == preserve_focus + ), + "Expected Stopped event with preserve_focus_hint={}, got {:?}", + preserve_focus, + event + ); + } + + /// Receive and assert the next message is a Stopped event with reason "breakpoint". + /// + /// Returns the breakpoint IDs that were hit. + #[track_caller] + pub fn recv_stopped_breakpoint(&mut self) -> Vec { + let event = self.recv_event(); + let Event::Stopped(body) = &event else { + panic!("Expected Stopped event, got {:?}", event); + }; + assert!( + matches!(body.reason, StoppedEventReason::Breakpoint), + "Expected Stopped reason 'breakpoint', got {:?}", + body.reason + ); + assert_eq!(body.thread_id, Some(-1)); + assert_eq!(body.all_threads_stopped, Some(true)); + body.hit_breakpoint_ids.clone().unwrap_or_default() + } + + /// Receive and assert the next message is a Breakpoint event with verified=true. + /// + /// Returns the breakpoint from the event. + #[track_caller] + pub fn recv_breakpoint_verified(&mut self) -> Breakpoint { + let event = self.recv_event(); + let Event::Breakpoint(BreakpointEventBody { breakpoint, .. }) = event else { + panic!("Expected Breakpoint event, got {:?}", event); + }; + assert!( + breakpoint.verified, + "Expected verified breakpoint, got {:?}", + breakpoint + ); + breakpoint + } + + /// Receive a Breakpoint event and return the breakpoint. + /// + /// Does not assert on verified status. + #[track_caller] + pub fn recv_breakpoint_event(&mut self) -> Breakpoint { + let event = self.recv_event(); + let Event::Breakpoint(BreakpointEventBody { breakpoint, .. }) = event else { + panic!("Expected Breakpoint event, got {:?}", event); + }; + breakpoint + } + + /// Receive a Breakpoint event for an invalid breakpoint. + /// + /// Asserts that verified=false and message is present. + #[track_caller] + pub fn recv_breakpoint_invalid(&mut self) -> Breakpoint { + let bp = self.recv_breakpoint_event(); + assert!(!bp.verified, "Expected unverified breakpoint, got {:?}", bp); + assert!( + bp.message.is_some(), + "Expected message for invalid breakpoint, got {:?}", + bp + ); + bp + } +} + +impl Drop for DapClient { + fn drop(&mut self) { + // Don't try to disconnect if we're already panicking, as this could + // obscure the original error + if !std::thread::panicking() { + self.disconnect(); + } + } +} diff --git a/crates/ark_test/src/dummy_frontend.rs b/crates/ark_test/src/dummy_frontend.rs new file mode 100644 index 000000000..9cbcb1269 --- /dev/null +++ b/crates/ark_test/src/dummy_frontend.rs @@ -0,0 +1,1044 @@ +use std::io::Seek; +use std::io::Write; +use std::ops::Deref; +use std::ops::DerefMut; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::MutexGuard; +use std::sync::OnceLock; +use std::time::Duration; + +use amalthea::fixtures::dummy_frontend::DummyConnection; +use amalthea::fixtures::dummy_frontend::DummyFrontend; +use amalthea::fixtures::dummy_frontend::ExecuteRequestOptions; +use amalthea::wire::comm_open::CommOpen; +use amalthea::wire::execute_request::ExecuteRequestPositron; +use amalthea::wire::execute_request::JupyterPositronLocation; +use amalthea::wire::execute_request::JupyterPositronPosition; +use amalthea::wire::execute_request::JupyterPositronRange; +use amalthea::wire::jupyter_message::Message; +use ark::console::SessionMode; +use ark::repos::DefaultRepos; +use ark::url::ExtUrl; +use tempfile::NamedTempFile; + +use crate::execute_result_contains; +use crate::is_idle; +use crate::is_start_debug; +use crate::is_stop_debug; +use crate::is_stream; +use crate::stream_contains; +use crate::tracing::trace_iopub_msg; +use crate::tracing::trace_separator; +use crate::tracing::trace_shell_reply; +use crate::tracing::trace_shell_request; +use crate::DapClient; +use crate::MessageAccumulator; + +// There can be only one frontend per process. Needs to be in a mutex because +// the frontend wraps zmq sockets which are unsafe to send across threads. +// +// This is using `OnceLock` because it provides a way of checking whether the +// value has been initialized already. Also we'll need to parameterize +// initialization in the future. +static FRONTEND: OnceLock>> = OnceLock::new(); + +/// Wrapper around `DummyFrontend` that checks sockets are empty on drop +pub struct DummyArkFrontend { + guard: MutexGuard<'static, DummyFrontend>, +} + +struct DummyArkFrontendOptions { + interactive: bool, + site_r_profile: bool, + user_r_profile: bool, + r_environ: bool, + session_mode: SessionMode, + default_repos: DefaultRepos, + startup_file: Option, +} + +/// Wrapper around `DummyArkFrontend` that uses `SessionMode::Notebook` +/// +/// Only one of `DummyArkFrontend` or `DummyArkFrontendNotebook` can be used in +/// a given process. Just don't import both and you should be fine as Rust will +/// let you know about a missing symbol if you happen to copy paste `lock()` +/// calls of different kernel types between files. +pub struct DummyArkFrontendNotebook { + inner: DummyArkFrontend, +} + +/// Wrapper around `DummyArkFrontend` that allows an `.Rprofile` to run +pub struct DummyArkFrontendRprofile { + inner: DummyArkFrontend, +} + +/// Wrapper around `DummyArkFrontend` that allows setting default repos +/// for the frontend +pub struct DummyArkFrontendDefaultRepos { + inner: DummyArkFrontend, +} + +impl DummyArkFrontend { + pub fn lock() -> Self { + Self { + guard: Self::get_frontend().lock().unwrap(), + } + } + + /// Wait for R cleanup to start (indicating shutdown has been initiated). + /// Panics if cleanup doesn't start within the timeout. + #[cfg(unix)] + #[track_caller] + pub fn wait_for_cleanup() { + use std::time::Duration; + + use ark::sys::console::CLEANUP_SIGNAL; + + let (lock, cvar) = &CLEANUP_SIGNAL; + let result = cvar + .wait_timeout_while(lock.lock().unwrap(), Duration::from_secs(3), |started| { + !*started + }) + .unwrap(); + + if !*result.0 { + panic!("Cleanup did not start within timeout"); + } + } + + /// Start DAP server via comm protocol and return a connected client. + /// + /// This sends a `comm_open` message to start the DAP server, waits for + /// the `server_started` response with the port, and connects a `DapClient`. + #[track_caller] + pub fn start_dap(&self) -> DapClient { + let comm_id = uuid::Uuid::new_v4().to_string(); + + // Send comm_open to start the DAP server + self.send_shell(CommOpen { + comm_id: comm_id.clone(), + target_name: String::from("ark_dap"), + data: serde_json::json!({ "ip_address": "127.0.0.1" }), + }); + + // Message order: Busy, then CommMsg and Idle in either order. + // The CommMsg travels through an async path (comm_socket -> comm manager -> iopub) + // while Idle is sent directly to iopub_tx, so they may arrive out of order. + // See FIXME notes at https://github.com/posit-dev/ark/issues/689 + self.recv_iopub_busy(); + + let mut port: Option = None; + let mut got_idle = false; + + while port.is_none() || !got_idle { + let msg = self.recv_iopub(); + match msg { + Message::CommMsg(data) => { + assert_eq!(data.content.comm_id, comm_id); + let method = data.content.data["method"] + .as_str() + .expect("Expected method field"); + assert_eq!(method, "server_started"); + port = Some( + data.content.data["params"]["port"] + .as_u64() + .expect("Expected port field") as u16, + ); + }, + Message::Status(status) => { + use amalthea::wire::status::ExecutionState; + if status.content.execution_state == ExecutionState::Idle { + got_idle = true; + } + }, + other => panic!("Expected CommMsg or Status(Idle), got {:?}", other), + } + } + + let port = port.unwrap(); + + let mut client = DapClient::connect("127.0.0.1", port).unwrap(); + client.initialize(); + client.attach(); + client + } + + /// Receive from IOPub, skipping any Stream messages, and assert Busy status. + /// + /// Use this when late-arriving Stream messages from previous operations + /// can interleave with the expected Busy message. + #[track_caller] + pub fn recv_iopub_busy_skip_streams(&self) { + loop { + let msg = self.recv_iopub(); + trace_iopub_msg(&msg); + match msg { + Message::Stream(_) => continue, + Message::Status(data) => { + assert_eq!( + data.content.execution_state, + amalthea::wire::status::ExecutionState::Busy, + "Expected Busy status" + ); + return; + }, + other => panic!("Expected Busy status, got {:?}", other), + } + } + } + + /// Receive from IOPub, skipping any Stream messages, and assert ExecuteInput. + /// + /// Use this when late-arriving Stream messages from previous operations + /// can interleave with the expected ExecuteInput message. + #[track_caller] + pub fn recv_iopub_execute_input_skip_streams(&self) { + loop { + let msg = self.recv_iopub(); + trace_iopub_msg(&msg); + match msg { + Message::Stream(_) => continue, + Message::ExecuteInput(_) => return, + other => panic!("Expected ExecuteInput, got {:?}", other), + } + } + } + + /// Receive exactly `n` iopub messages, returning a wrapper for inspection. + /// + /// Use this when multiple messages may arrive in non-deterministic order + /// (e.g., from different threads sending to iopub concurrently). + /// + /// Use `pop()` to extract expected messages and `assert_all_consumed()` to + /// verify no unexpected messages remain. + #[track_caller] + pub fn recv_iopub_n(&self, n: usize) -> UnorderedMessages { + let mut messages = Vec::with_capacity(n); + for _ in 0..n { + let msg = self.recv_iopub(); + trace_iopub_msg(&msg); + messages.push(msg); + } + UnorderedMessages { messages } + } + + /// Receive iopub messages until all predicates are matched. + /// + /// Messages may arrive in any order and through different async paths + /// (CommManager for comm messages, Shell for status, R console for streams). + /// Receive IOPub messages until all predicates have matched. + /// + /// Each predicate must match exactly one message. Messages that don't match + /// any predicate are silently ignored. Uses `recv_iopub_until` internally, + /// so stream coalescing is available. + /// + /// Panics if timeout is reached before all predicates match. + #[track_caller] + pub fn recv_iopub_async(&self, predicates: Vec bool>>) { + if predicates.is_empty() { + return; + } + + self.recv_iopub_until(|acc| { + // Track which predicates have been matched + let mut pred_matched = vec![false; predicates.len()]; + + // For each message, try to match it to an unmatched predicate + for msg in &acc.messages { + for (i, pred) in predicates.iter().enumerate() { + if !pred_matched[i] && pred(msg) { + pred_matched[i] = true; + break; + } + } + } + + let all_matched = pred_matched.iter().all(|&m| m); + + if all_matched { + // Mark matched messages as consumed so Drop check passes + for pred in &predicates { + acc.consume(|msg| pred(msg)); + } + } + + all_matched + }); + } + + /// Receive IOPub messages until a condition is satisfied. + /// + /// This is a convenient wrapper around `MessageAccumulator` that handles + /// stream coalescing automatically. Stream messages with the same parent + /// header are combined before checking the condition, making tests immune + /// to whether R batched or split console output. + /// + /// After the condition is satisfied, any remaining messages are drained + /// with a short timeout to prevent interference with subsequent operations. + /// + /// # Example + /// + /// ```ignore + /// frontend.recv_iopub_until(|acc| { + /// acc.streams_contain("Called from:") && + /// acc.has_comm_method("start_debug") && + /// acc.saw_idle() + /// }); + /// ``` + #[track_caller] + pub fn recv_iopub_until(&self, condition: F) + where + F: FnMut(&mut MessageAccumulator) -> bool, + { + let mut acc = MessageAccumulator::new(); + let result = acc.receive_until(&self.iopub_socket, condition, Duration::from_secs(10)); + + if let Err(msg) = result { + panic!("Timeout waiting for IOPub condition.\n{msg}"); + } + } + + /// Send an execute request with tracing + #[track_caller] + pub fn send_execute_request_traced(&self, code: &str, options: ExecuteRequestOptions) { + trace_shell_request("execute_request", Some(code)); + self.send_execute_request(code, options); + } + + /// Receive shell execute reply with tracing + #[track_caller] + pub fn recv_shell_execute_reply_traced(&self) -> u32 { + let result = self.recv_shell_execute_reply(); + trace_shell_reply("execute_reply", "ok"); + result + } + + /// Source a file that was created with `SourceFile::new()`. + #[track_caller] + pub fn source_file(&self, file: &SourceFile) { + trace_separator(&format!("source({})", file.filename)); + self.send_execute_request( + &format!("source('{}')", file.path), + ExecuteRequestOptions::default(), + ); + self.recv_iopub_busy(); + self.recv_iopub_execute_input(); + self.recv_iopub_execute_result(); + self.recv_iopub_idle(); + self.recv_shell_execute_reply(); + } + + /// Execute code from a file with location information. + /// + /// This simulates running code from an editor where the frontend sends + /// the file URI and position. Breakpoints in the code will be verified + /// during execution. + #[track_caller] + pub fn execute_file(&self, file: &SourceFile) { + let code = std::fs::read_to_string(&file.path).unwrap(); + self.send_execute_request(&code, ExecuteRequestOptions { + positron: Some(ExecuteRequestPositron { + code_location: Some(file.location()), + }), + ..Default::default() + }); + self.recv_iopub_busy(); + self.recv_iopub_execute_input(); + self.recv_iopub_idle(); + self.recv_shell_execute_reply(); + } + + /// Source a file and stop at a breakpoint (set via DAP, not browser() in code). + /// + /// Source a file and wait until execution stops at an injected breakpoint. + /// + /// Use this when you've set breakpoints via `dap.set_breakpoints()` before sourcing. + /// The caller must still receive the DAP events (see below) and should call + /// `recv_shell_execute_reply()` after quitting the debugger. + /// + /// Due to the auto-stepping mechanism, hitting an injected breakpoint produces + /// IOPub messages that may arrive in varying order and batching: + /// - Stream output with "Called from:" and "debug at" (may be batched or separate) + /// - start_debug / stop_debug comm messages + /// - idle status + /// + /// **DAP events (caller must receive):** + /// 1. Stopped (entering .ark_breakpoint) + /// 2. Continued (auto-step triggered) + /// 3. Continued (from stop_debug) + /// 4. Stopped (at actual user expression) + #[track_caller] + pub fn source_file_and_hit_breakpoint(&self, file: &SourceFile) { + trace_separator(&format!("source_and_hit_bp({})", file.filename)); + self.send_execute_request( + &format!("source('{}')", file.path), + ExecuteRequestOptions::default(), + ); + self.recv_iopub_busy(); + self.recv_iopub_execute_input(); + + // Auto-stepping message flow when hitting an injected breakpoint. + // Message count varies due to stream batching and timing. We collect + // messages until we have evidence of all required events. + self.recv_iopub_breakpoint_hit(); + } + + /// Receive IOPub messages for a breakpoint hit, handling variable batching. + /// + /// Uses `MessageAccumulator` to coalesce stream fragments, making the test + /// immune to whether R batched or split the output across messages. + #[track_caller] + pub fn recv_iopub_breakpoint_hit(&self) { + self.recv_iopub_until(|acc| { + acc.streams_contain("Called from:") && + acc.streams_contain("debug at") && + acc.has_comm_method_count("start_debug", 2) && + acc.has_comm_method("stop_debug") && + acc.saw_idle() + }); + } + + /// Receive IOPub messages for a breakpoint hit from a direct function call. + /// + /// This is similar to `recv_iopub_breakpoint_hit` but for direct function calls + /// (e.g., `foo()`) rather than `source()`. When the function has source references + /// (from a file created with `SourceFile::new()`), R will print `"debug at"`. + #[track_caller] + pub fn recv_iopub_breakpoint_hit_direct(&self) { + self.recv_iopub_until(|acc| { + acc.streams_contain("Called from:") && + acc.streams_contain("debug at") && + acc.has_comm_method_count("start_debug", 2) && + acc.has_comm_method("stop_debug") && + acc.saw_idle() + }); + } + + /// Source a file that was created with `SourceFile::new()`. + /// + /// The code must contain `browser()` or a breakpoint to enter debug mode. + /// The caller must still receive the DAP `Stopped` event. + #[track_caller] + pub fn source_debug_file(&self, file: &SourceFile) { + trace_separator(&format!("source_debug({})", file.filename)); + self.send_execute_request( + &format!("source('{}')", file.path), + ExecuteRequestOptions::default(), + ); + self.recv_iopub_busy(); + self.recv_iopub_execute_input(); + + self.recv_iopub_async(vec![ + is_start_debug(), + stream_contains("Called from:"), + is_idle(), + ]); + + self.recv_shell_execute_reply(); + } + + /// Source a file containing the given code and receive all expected messages. + /// + /// Returns a `SourcedFile` containing the temp file (which must be kept alive) + /// and the filename for use in assertions. + /// + /// The caller must still receive the DAP `Stopped` event. + #[track_caller] + pub fn send_source(&self, code: &str) -> SourceFile { + let line_count = code.lines().count() as u32; + let mut file = NamedTempFile::new().unwrap(); + write!(file, "{code}").unwrap(); + + // Use forward slashes for R compatibility on Windows (backslashes would be + // interpreted as escape sequences in R strings) + let path = file.path().to_string_lossy().replace('\\', "/"); + let url = ExtUrl::from_file_path(file.path()).unwrap(); + let uri = url.to_string(); + let filename = file + .path() + .file_name() + .unwrap() + .to_str() + .unwrap() + .to_string(); + + self.send_execute_request( + &format!("source('{path}')"), + ExecuteRequestOptions::default(), + ); + self.recv_iopub_busy(); + self.recv_iopub_execute_input(); + + self.recv_iopub_async(vec![ + is_start_debug(), + stream_contains("Called from:"), + is_idle(), + ]); + + self.recv_shell_execute_reply(); + + SourceFile { + file, + path, + filename, + uri, + line_count, + } + } + + /// Execute `browser()` and receive all expected messages. + #[track_caller] + pub fn debug_send_browser(&self) -> u32 { + self.send_execute_request("browser()", ExecuteRequestOptions::default()); + self.recv_iopub_busy(); + self.recv_iopub_execute_input(); + + // Receive 3 messages in non-deterministic order: start_debug, execute_result, idle. + // Message ordering is non-deterministic because they originate from different + // threads (comm manager vs shell handler) that both send to the iopub socket. + self.recv_iopub_async(vec![ + is_start_debug(), + execute_result_contains("Called from: top level"), + is_idle(), + ]); + + self.recv_shell_execute_reply() + } + + /// Execute `Q` to quit the browser and receive all expected messages. + #[track_caller] + pub fn debug_send_quit(&self) -> u32 { + self.send_execute_request("Q", ExecuteRequestOptions::default()); + // Use stream-skipping variants because late-arriving debug output + // from previous operations can interleave here. + self.recv_iopub_busy_skip_streams(); + self.recv_iopub_execute_input_skip_streams(); + + self.recv_iopub_async(vec![is_stop_debug(), is_idle()]); + + self.recv_shell_execute_reply() + } + + /// Execute `n` (next/step over) and receive all expected messages. + #[track_caller] + pub fn debug_send_next(&self) -> u32 { + self.debug_send_step("n") + } + + /// Execute `s` (step in) and receive all expected messages. + #[track_caller] + pub fn debug_send_step_in(&self) -> u32 { + self.debug_send_step("s") + } + + /// Execute `f` (finish/step out) and receive all expected messages. + #[track_caller] + pub fn debug_send_finish(&self) -> u32 { + self.debug_send_step("f") + } + + /// Execute `c` (continue) and receive all expected messages. + #[track_caller] + pub fn debug_send_continue(&self) -> u32 { + self.debug_send_step("c") + } + + /// Execute `c` (continue) to next browser() breakpoint in a sourced file. + /// + /// When continuing from one browser() to another, R outputs "Called from:" + /// instead of "debug at", so this needs a different message pattern. + #[track_caller] + pub fn debug_send_continue_to_breakpoint(&self) -> u32 { + self.send_execute_request("c", ExecuteRequestOptions::default()); + self.recv_iopub_busy(); + self.recv_iopub_execute_input(); + + self.recv_iopub_async(vec![ + is_stop_debug(), + is_start_debug(), + stream_contains("Called from:"), + is_idle(), + ]); + + self.recv_shell_execute_reply() + } + + /// Execute an expression while in debug mode and receive all expected messages. + /// + /// This is for evaluating expressions that don't advance the debugger (e.g., `1`, `x`). + /// The caller must still receive the DAP `Stopped` event with `preserve_focus_hint=true`. + #[track_caller] + pub fn debug_send_expr(&self, expr: &str) -> u32 { + self.send_execute_request(expr, ExecuteRequestOptions::default()); + self.recv_iopub_busy(); + self.recv_iopub_execute_input(); + + self.recv_iopub_async(vec![ + is_stop_debug(), + is_start_debug(), + crate::is_execute_result(), + is_idle(), + ]); + + self.recv_shell_execute_reply() + } + + /// Execute an expression that causes an error while in debug mode. + /// + /// Unlike stepping to an error (which exits debug), evaluating an error + /// from the console should keep us in debug mode. + /// The caller must still receive the DAP `Stopped` event with `preserve_focus_hint=true`. + /// + /// Note: In debug mode, errors are streamed on stderr (not as `ExecuteError`) + /// and a regular execution reply is sent. That's a limitation of the R kernel. + #[track_caller] + pub fn debug_send_error_expr(&self, expr: &str) -> u32 { + self.send_execute_request(expr, ExecuteRequestOptions::default()); + self.recv_iopub_busy(); + self.recv_iopub_execute_input(); + + self.recv_iopub_async(vec![ + is_stop_debug(), + is_start_debug(), + is_stream(), + is_idle(), + ]); + + self.recv_shell_execute_reply() + } + + /// Helper for debug step commands that continue execution. + #[track_caller] + fn debug_send_step(&self, cmd: &str) -> u32 { + self.send_execute_request(cmd, ExecuteRequestOptions::default()); + self.recv_iopub_busy(); + self.recv_iopub_execute_input(); + + self.recv_iopub_async(vec![is_start_debug(), is_idle()]); + + self.recv_shell_execute_reply() + } + + /// Execute a step command in a sourced file context. + /// + /// In sourced files with srcrefs, stepping produces additional messages compared + /// to virtual document context: a `stop_debug` comm (debug session ends briefly), + /// and a `Stream` with "debug at" output from R. + /// + /// This helper only consumes IOPub and shell messages. The caller must still + /// consume DAP events separately. + #[track_caller] + pub fn debug_send_step_command(&self, cmd: &str) -> u32 { + trace_separator(&format!("debug_step({})", cmd)); + self.send_execute_request(cmd, ExecuteRequestOptions::default()); + self.recv_iopub_busy(); + self.recv_iopub_execute_input(); + + self.recv_iopub_async(vec![ + is_stop_debug(), + is_start_debug(), + stream_contains("debug at"), + is_idle(), + ]); + + self.recv_shell_execute_reply() + } +} + +/// Result of sourcing a file via `send_source()`. +/// +/// The temp file is kept alive as long as this struct exists. +pub struct SourceFile { + file: NamedTempFile, + pub path: String, + pub filename: String, + uri: String, + line_count: u32, +} + +impl SourceFile { + /// Create a temp file with the given code without sourcing it. + /// + /// Use this when you need to set breakpoints before sourcing. + /// After setting breakpoints, call `frontend.source_file()` to run the file. + pub fn new(code: &str) -> Self { + // Count lines for the location range + let line_count = code.lines().count() as u32; + let mut file = NamedTempFile::new().unwrap(); + write!(file, "{code}").unwrap(); + + // Use forward slashes for R compatibility on Windows (backslashes would be + // interpreted as escape sequences in R strings) + let path = file.path().to_string_lossy().replace('\\', "/"); + let url = ExtUrl::from_file_path(file.path()).unwrap(); + let uri = url.to_string(); + + // Extract file name + let filename = file + .path() + .file_name() + .unwrap() + .to_str() + .unwrap() + .to_string(); + + Self { + file, + path, + filename, + uri, + line_count, + } + } + + /// Get a `JupyterPositronLocation` pointing to this file. + pub fn location(&self) -> JupyterPositronLocation { + JupyterPositronLocation { + uri: self.uri.clone(), + range: JupyterPositronRange { + start: JupyterPositronPosition { + line: 0, + character: 0, + }, + end: JupyterPositronPosition { + line: self.line_count, + character: 0, + }, + }, + } + } + + /// Rewrite the file with new content. + /// + /// Use this for tests that need to modify the file after creation + /// (e.g., testing hash change detection). + pub fn rewrite(&mut self, code: &str) { + self.file.rewind().unwrap(); + self.file.as_file_mut().set_len(0).unwrap(); + + write!(self.file, "{code}").unwrap(); + + self.file.flush().unwrap(); + self.line_count = code.lines().count() as u32; + } +} + +/// Wrapper for messages that may arrive in non-deterministic order. +/// +/// Use `pop()` to extract expected messages and `assert_all_consumed()` to +/// verify no unexpected messages remain. +#[derive(Debug)] +pub struct UnorderedMessages { + pub messages: Vec, +} + +impl UnorderedMessages { + /// Remove and return the first message matching the predicate. + /// + /// Panics if no message matches. + #[track_caller] + pub fn pop(&mut self, mut predicate: F) -> Message + where + F: FnMut(&Message) -> bool, + { + let pos = self + .messages + .iter() + .position(|m| predicate(m)) + .expect("No message matched the predicate"); + self.messages.remove(pos) + } + + /// Assert that all messages have been consumed. + /// + /// Panics with details of remaining messages if any exist. + #[track_caller] + pub fn assert_all_consumed(self) { + if !self.messages.is_empty() { + panic!("Unexpected messages remaining: {:#?}", self.messages); + } + } +} + +impl DummyArkFrontend { + fn get_frontend() -> &'static Arc> { + // These are the hard-coded defaults. Call `init()` explicitly to + // override. + let options = DummyArkFrontendOptions::default(); + FRONTEND.get_or_init(|| Arc::new(Mutex::new(DummyArkFrontend::init(options)))) + } + + fn init(options: DummyArkFrontendOptions) -> DummyFrontend { + if FRONTEND.get().is_some() { + panic!("Can't spawn Ark more than once"); + } + + // We don't want cli to try and restore the cursor, it breaks our tests + // by adding unecessary ANSI escapes. We don't need this in Positron because + // cli also checks `isatty(stdout())`, which is false in Positron because + // we redirect stdout. + // https://github.com/r-lib/cli/blob/1220ed092c03e167ff0062e9839c81d7258a4600/R/onload.R#L33-L40 + unsafe { std::env::set_var("R_CLI_HIDE_CURSOR", "false") }; + + let connection = DummyConnection::new(); + let (connection_file, registration_file) = connection.get_connection_files(); + + let mut r_args = vec![]; + + // We aren't animals! + r_args.push(String::from("--no-save")); + r_args.push(String::from("--no-restore")); + + if options.interactive { + r_args.push(String::from("--interactive")); + } + if !options.site_r_profile { + r_args.push(String::from("--no-site-file")); + } + if !options.user_r_profile { + r_args.push(String::from("--no-init-file")); + } + if !options.r_environ { + r_args.push(String::from("--no-environ")); + } + + // Start the kernel and REPL in a background thread, does not return and is never joined. + // Must run `start_kernel()` in a background thread because it blocks until it receives + // a `HandshakeReply`, which we send from `from_connection()` below. + stdext::spawn!("dummy_kernel", move || { + ark::start::start_kernel( + connection_file, + Some(registration_file), + r_args, + options.startup_file, + options.session_mode, + false, + options.default_repos, + ); + }); + + DummyFrontend::from_connection(connection) + } +} + +// Check that we haven't left crumbs behind. +// +// Certain messages are allowed to remain because they can arrive asynchronously: +// - Stream messages: can interleave with other operations due to batching. +// - CommMsg with method "execute": `DapClient::drop()` calls `disconnect()` which +// sends a Disconnect request. If ark is still debugging, `handle_disconnect()` +// sends an `execute Q` comm message to quit the browser. Since `DapClient` is +// dropped before `DummyArkFrontend` (reverse declaration order), this message +// can arrive here after the test has otherwise completed cleanly. +impl Drop for DummyArkFrontend { + fn drop(&mut self) { + if std::thread::panicking() { + return; + } + + // Drain any pending IOPub messages + let mut unexpected_messages: Vec = Vec::new(); + while self.iopub_socket.has_incoming_data().unwrap() { + let msg = Message::read_from_socket(&self.iopub_socket).unwrap(); + + let exempt = match &msg { + Message::Stream(_) => true, + Message::CommMsg(comm) => { + comm.content.data.get("method").and_then(|v| v.as_str()) == Some("execute") && + comm.content + .data + .get("params") + .and_then(|p| p.get("command")) + .and_then(|c| c.as_str()) == + Some("Q") + }, + _ => false, + }; + + if !exempt { + unexpected_messages.push(msg); + } + } + + // Fail if any unexpected IOPub messages were left behind + if !unexpected_messages.is_empty() { + panic!( + "IOPub socket has {} unexpected message(s) on exit:\n{:#?}", + unexpected_messages.len(), + unexpected_messages + ); + } + + // Check other sockets strictly (no leniency for non-IOPub) + let mut shell_messages: Vec = Vec::new(); + let mut stdin_messages: Vec = Vec::new(); + + while self.shell_socket.has_incoming_data().unwrap() { + if let Ok(msg) = Message::read_from_socket(&self.shell_socket) { + shell_messages.push(msg); + } + } + while self.stdin_socket.has_incoming_data().unwrap() { + if let Ok(msg) = Message::read_from_socket(&self.stdin_socket) { + stdin_messages.push(msg); + } + } + + if !shell_messages.is_empty() || !stdin_messages.is_empty() { + panic!( + "Non-IOPub sockets have unexpected messages on exit:\n\ + Shell: {:#?}\n\ + StdIn: {:#?}", + shell_messages, stdin_messages + ); + } + } +} + +// Allow method calls to be forwarded to inner type +impl Deref for DummyArkFrontend { + type Target = DummyFrontend; + + fn deref(&self) -> &Self::Target { + Deref::deref(&self.guard) + } +} + +impl DerefMut for DummyArkFrontend { + fn deref_mut(&mut self) -> &mut Self::Target { + DerefMut::deref_mut(&mut self.guard) + } +} + +impl DummyArkFrontendNotebook { + /// Lock a notebook frontend. + /// + /// NOTE: Only one `DummyArkFrontend` variant should call `lock()` within + /// a given process. + pub fn lock() -> Self { + Self::init(); + + Self { + inner: DummyArkFrontend::lock(), + } + } + + /// Initialize with Notebook session mode + fn init() { + let mut options = DummyArkFrontendOptions::default(); + options.session_mode = SessionMode::Notebook; + FRONTEND.get_or_init(|| Arc::new(Mutex::new(DummyArkFrontend::init(options)))); + } +} + +// Allow method calls to be forwarded to inner type +impl Deref for DummyArkFrontendNotebook { + type Target = DummyFrontend; + + fn deref(&self) -> &Self::Target { + Deref::deref(&self.inner) + } +} + +impl DerefMut for DummyArkFrontendNotebook { + fn deref_mut(&mut self) -> &mut Self::Target { + DerefMut::deref_mut(&mut self.inner) + } +} + +impl DummyArkFrontendDefaultRepos { + /// Lock a frontend with a default repos setting. + /// + /// NOTE: `startup_file` is required because you typically want + /// to force `options(repos =)` to a fixed value for testing, regardless + /// of what the caller's default `repos` are set as (i.e. rig typically + /// sets it to a non-`@CRAN@` value). + /// + /// NOTE: Only one `DummyArkFrontend` variant should call `lock()` within + /// a given process. + pub fn lock(default_repos: DefaultRepos, startup_file: String) -> Self { + Self::init(default_repos, startup_file); + + Self { + inner: DummyArkFrontend::lock(), + } + } + + /// Initialize with given default repos + fn init(default_repos: DefaultRepos, startup_file: String) { + let mut options = DummyArkFrontendOptions::default(); + options.default_repos = default_repos; + options.startup_file = Some(startup_file); + + FRONTEND.get_or_init(|| Arc::new(Mutex::new(DummyArkFrontend::init(options)))); + } +} + +// Allow method calls to be forwarded to inner type +impl Deref for DummyArkFrontendDefaultRepos { + type Target = DummyFrontend; + + fn deref(&self) -> &Self::Target { + Deref::deref(&self.inner) + } +} +impl DummyArkFrontendRprofile { + /// Lock a frontend that supports `.Rprofile`s. + /// + /// NOTE: This variant can only be called exactly once per process, + /// because you can only load an `.Rprofile` one time. Additionally, + /// only one `DummyArkFrontend` variant should call `lock()` within + /// a given process. Practically, this ends up meaning you can only + /// have 1 test block per integration test that uses a + /// `DummyArkFrontendRprofile`. + pub fn lock() -> Self { + Self::init(); + + Self { + inner: DummyArkFrontend::lock(), + } + } + + /// Initialize with user level `.Rprofile` enabled + fn init() { + let mut options = DummyArkFrontendOptions::default(); + options.user_r_profile = true; + let status = FRONTEND.set(Arc::new(Mutex::new(DummyArkFrontend::init(options)))); + + if status.is_err() { + panic!("You can only call `DummyArkFrontendRprofile::lock()` once per process."); + } + + FRONTEND.get().unwrap(); + } +} + +// Allow method calls to be forwarded to inner type +impl Deref for DummyArkFrontendRprofile { + type Target = DummyFrontend; + + fn deref(&self) -> &Self::Target { + Deref::deref(&self.inner) + } +} + +impl DerefMut for DummyArkFrontendRprofile { + fn deref_mut(&mut self) -> &mut Self::Target { + DerefMut::deref_mut(&mut self.inner) + } +} + +impl Default for DummyArkFrontendOptions { + fn default() -> Self { + Self { + interactive: true, + site_r_profile: false, + user_r_profile: false, + r_environ: false, + session_mode: SessionMode::Console, + default_repos: DefaultRepos::Auto, + startup_file: None, + } + } +} diff --git a/crates/ark_test/src/iopub.rs b/crates/ark_test/src/iopub.rs new file mode 100644 index 000000000..74f0dac8c --- /dev/null +++ b/crates/ark_test/src/iopub.rs @@ -0,0 +1,105 @@ +// +// iopub.rs +// +// Copyright (C) 2026 Posit Software, PBC. All rights reserved. +// +// + +//! Predicates for matching individual IOPub messages. +//! +//! These `is_*()` functions return boxed predicates that match **individual messages**. +//! They work with: +//! - `recv_iopub_async()` - receives messages until all predicates match +//! - `MessageAccumulator::in_order()` - checks message ordering +//! +//! For accumulator-level checks (coalesced streams, message counts, etc.), +//! use the matchers from [`crate::matcher`] with `recv_iopub_matching()`. + +use amalthea::wire::jupyter_message::Message; +use amalthea::wire::status::ExecutionState; + +/// A predicate for matching IOPub messages. +/// +/// Uses `Fn` (not `FnMut`) so it works in all contexts: +/// - `recv_iopub_async()` which needs `FnMut` (all `Fn` are `FnMut`) +/// - `MessageAccumulator::in_order()` which needs `Fn` +pub type Predicate = Box bool>; + +/// Matches a `start_debug` comm message. +pub fn is_start_debug() -> Predicate { + Box::new(|msg| { + matches!( + msg, + Message::CommMsg(comm) if comm.content.data.get("method").and_then(|v| v.as_str()) == Some("start_debug") + ) + }) +} + +/// Matches a `stop_debug` comm message. +pub fn is_stop_debug() -> Predicate { + Box::new(|msg| { + matches!( + msg, + Message::CommMsg(comm) if comm.content.data.get("method").and_then(|v| v.as_str()) == Some("stop_debug") + ) + }) +} + +/// Matches an `Idle` status message. +pub fn is_idle() -> Predicate { + Box::new(|msg| { + matches!( + msg, + Message::Status(s) if s.content.execution_state == ExecutionState::Idle + ) + }) +} + +/// Matches a Stream message containing the given text. +pub fn stream_contains(text: &'static str) -> Predicate { + Box::new(move |msg| { + let Message::Stream(stream) = msg else { + return false; + }; + stream.content.text.contains(text) + }) +} + +/// Matches a Stream message containing all of the given texts in order. +pub fn stream_contains_all(texts: &'static [&'static str]) -> Predicate { + Box::new(move |msg| { + let Message::Stream(stream) = msg else { + return false; + }; + let content = &stream.content.text; + let mut pos = 0; + for text in texts { + match content[pos..].find(text) { + Some(found) => pos += found + text.len(), + None => return false, + } + } + true + }) +} + +/// Matches an ExecuteResult message. +pub fn is_execute_result() -> Predicate { + Box::new(|msg| matches!(msg, Message::ExecuteResult(_))) +} + +/// Matches an ExecuteResult message containing the given text. +pub fn execute_result_contains(text: &'static str) -> Predicate { + Box::new(move |msg| { + let Message::ExecuteResult(result) = msg else { + return false; + }; + let content = result.content.data["text/plain"].as_str().unwrap_or(""); + content.contains(text) + }) +} + +/// Matches any Stream message. +pub fn is_stream() -> Predicate { + Box::new(|msg| matches!(msg, Message::Stream(_))) +} diff --git a/crates/ark_test/src/lib.rs b/crates/ark_test/src/lib.rs new file mode 100644 index 000000000..759374326 --- /dev/null +++ b/crates/ark_test/src/lib.rs @@ -0,0 +1,28 @@ +// +// lib.rs +// +// Copyright (C) 2026 Posit Software, PBC. All rights reserved. +// +// + +pub mod accumulator; +pub mod comm; +pub mod dap_assert; +pub mod dap_client; +pub mod dummy_frontend; +pub mod iopub; +pub mod tracing; + +// Re-export utilities from ark::fixtures for convenience +pub use accumulator::*; +pub use ark::fixtures::package_is_installed; +pub use ark::fixtures::point_and_offset_from_cursor; +pub use ark::fixtures::point_from_cursor; +pub use ark::fixtures::r_test_init; +pub use ark::fixtures::r_test_lock; +pub use comm::*; +pub use dap_assert::*; +pub use dap_client::*; +pub use dummy_frontend::*; +pub use iopub::*; +pub use tracing::*; diff --git a/crates/ark_test/src/tracing.rs b/crates/ark_test/src/tracing.rs new file mode 100644 index 000000000..16cdbff65 --- /dev/null +++ b/crates/ark_test/src/tracing.rs @@ -0,0 +1,435 @@ +// +// tracing.rs +// +// Copyright (C) 2026 Posit Software, PBC. All rights reserved. +// +// Tracing infrastructure for observing DAP and kernel messages during tests. +// Enable tracing by setting the ARK_TEST_TRACE environment variable: +// +// ARK_TEST_TRACE=1 just test test_name +// +// Or for more selective tracing: +// ARK_TEST_TRACE=dap # Only DAP events +// ARK_TEST_TRACE=iopub # Only IOPub messages +// ARK_TEST_TRACE=all # Both (same as ARK_TEST_TRACE=1) +// + +use std::sync::atomic::AtomicBool; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; +use std::sync::OnceLock; +use std::time::Instant; + +use amalthea::wire::jupyter_message::Message; +use amalthea::wire::status::ExecutionState; +use amalthea::wire::stream::Stream; +use dap::events::Event; + +/// Global start time for relative timestamps +static START_TIME: OnceLock = OnceLock::new(); + +/// Sequence counter for ordering messages across channels +static SEQUENCE: AtomicU64 = AtomicU64::new(0); + +/// Whether DAP tracing is enabled +static DAP_ENABLED: AtomicBool = AtomicBool::new(false); + +/// Whether IOPub tracing is enabled +static IOPUB_ENABLED: AtomicBool = AtomicBool::new(false); + +/// Initialize tracing based on environment variable. +/// Called automatically on first trace call. +fn init_tracing() { + START_TIME.get_or_init(|| { + let trace_var = std::env::var("ARK_TEST_TRACE").unwrap_or_default(); + let trace_var = trace_var.to_lowercase(); + + let (dap, iopub) = match trace_var.as_str() { + "1" | "all" | "true" => (true, true), + "dap" => (true, false), + "iopub" | "kernel" => (false, true), + _ => (false, false), + }; + + DAP_ENABLED.store(dap, Ordering::Relaxed); + IOPUB_ENABLED.store(iopub, Ordering::Relaxed); + + if dap || iopub { + eprintln!( + "Tracing: DAP={}, IOPub={}", + if dap { "on" } else { "off" }, + if iopub { "on" } else { "off" } + ); + } + + Instant::now() + }); +} + +/// Check if DAP tracing is enabled +pub fn is_dap_tracing_enabled() -> bool { + init_tracing(); + DAP_ENABLED.load(Ordering::Relaxed) +} + +/// Check if IOPub tracing is enabled +pub fn is_iopub_tracing_enabled() -> bool { + init_tracing(); + IOPUB_ENABLED.load(Ordering::Relaxed) +} + +/// Get relative timestamp in milliseconds +fn timestamp_ms() -> u64 { + init_tracing(); + START_TIME.get().unwrap().elapsed().as_millis() as u64 +} + +/// Get next sequence number +fn next_seq() -> u64 { + SEQUENCE.fetch_add(1, Ordering::Relaxed) +} + +/// Format a DAP event for display +fn format_dap_event(event: &Event) -> String { + match event { + Event::Stopped(body) => { + let reason = format!("{:?}", body.reason); + let focus = body.preserve_focus_hint.unwrap_or(false); + format!("Stopped(reason={}, preserve_focus={})", reason, focus) + }, + Event::Continued(body) => { + let all = body.all_threads_continued.unwrap_or(false); + format!("Continued(all_threads={})", all) + }, + Event::Breakpoint(body) => { + let bp = &body.breakpoint; + let verified = bp.verified; + let line = bp.line.map(|l| l.to_string()).unwrap_or_else(|| "?".into()); + let id = bp.id.map(|i| i.to_string()).unwrap_or_else(|| "?".into()); + format!( + "Breakpoint(id={}, line={}, verified={})", + id, line, verified + ) + }, + Event::Terminated(_) => "Terminated".to_string(), + Event::Exited(body) => format!("Exited(code={})", body.exit_code), + Event::Thread(body) => { + format!("Thread(id={}, reason={:?})", body.thread_id, body.reason) + }, + Event::Output(body) => { + let cat = body + .category + .as_ref() + .map(|c| format!("{:?}", c)) + .unwrap_or_else(|| "?".into()); + let output = &body.output; + let truncated = if output.len() > 50 { + format!("{}...", &output[..47]) + } else { + output.clone() + }; + format!("Output(cat={}, {:?})", cat, truncated) + }, + Event::Initialized => "Initialized".to_string(), + _ => format!("{:?}", event), + } +} + +/// Trace a DAP event being received +pub fn trace_dap_event(event: &Event) { + if !is_dap_tracing_enabled() { + return; + } + + let seq = next_seq(); + let ts = timestamp_ms(); + let formatted = format_dap_event(event); + + eprintln!("│ {:>6}ms │ #{:<4} │ DAP │ ← {}", ts, seq, formatted); +} + +/// Trace a DAP request being sent +pub fn trace_dap_request(command: &str) { + if !is_dap_tracing_enabled() { + return; + } + + let seq = next_seq(); + let ts = timestamp_ms(); + + eprintln!( + "│ {:>6}ms │ #{:<4} │ DAP │ → Request({})", + ts, seq, command + ); +} + +/// Trace a DAP response being received +pub fn trace_dap_response(command: &str, success: bool) { + if !is_dap_tracing_enabled() { + return; + } + + let seq = next_seq(); + let ts = timestamp_ms(); + let status = if success { "ok" } else { "err" }; + + eprintln!( + "│ {:>6}ms │ #{:<4} │ DAP │ ← Response({}, {})", + ts, seq, command, status + ); +} + +/// IOPub message types for tracing +#[derive(Debug, Clone)] +pub enum IoPubTrace { + Busy, + Idle, + ExecuteInput { code: String }, + ExecuteResult, + ExecuteError { message: String }, + Stream { name: String, text: String }, + CommOpen { target: String }, + CommMsg { method: String }, + CommClose, + Status { state: String }, + Other { msg_type: String }, +} + +impl IoPubTrace { + /// Create from message type and optional details + pub fn from_msg_type(msg_type: &str) -> Self { + match msg_type { + "status" => IoPubTrace::Status { + state: "?".to_string(), + }, + "execute_input" => IoPubTrace::ExecuteInput { + code: "...".to_string(), + }, + "execute_result" => IoPubTrace::ExecuteResult, + "execute_error" => IoPubTrace::ExecuteError { + message: "...".to_string(), + }, + "stream" => IoPubTrace::Stream { + name: "?".to_string(), + text: "...".to_string(), + }, + "comm_open" => IoPubTrace::CommOpen { + target: "?".to_string(), + }, + "comm_msg" => IoPubTrace::CommMsg { + method: "?".to_string(), + }, + "comm_close" => IoPubTrace::CommClose, + _ => IoPubTrace::Other { + msg_type: msg_type.to_string(), + }, + } + } +} + +/// Format an IOPub trace for display +fn format_iopub_trace(trace: &IoPubTrace) -> String { + match trace { + IoPubTrace::Busy => "status(busy)".to_string(), + IoPubTrace::Idle => "status(idle)".to_string(), + IoPubTrace::ExecuteInput { code } => { + let truncated = if code.len() > 30 { + format!("{}...", &code[..27]) + } else { + code.clone() + }; + format!("execute_input({:?})", truncated) + }, + IoPubTrace::ExecuteResult => "execute_result".to_string(), + IoPubTrace::ExecuteError { message } => { + let truncated = if message.len() > 30 { + format!("{}...", &message[..27]) + } else { + message.clone() + }; + format!("execute_error({:?})", truncated) + }, + IoPubTrace::Stream { name, text } => { + let truncated = if text.len() > 30 { + format!("{}...", &text[..27]) + } else { + text.clone() + }; + // Replace newlines for display + let truncated = truncated.replace('\n', "\\n"); + format!("stream({}, {:?})", name, truncated) + }, + IoPubTrace::CommOpen { target } => format!("comm_open({})", target), + IoPubTrace::CommMsg { method } => format!("comm_msg({})", method), + IoPubTrace::CommClose => "comm_close".to_string(), + IoPubTrace::Status { state } => format!("status({})", state), + IoPubTrace::Other { msg_type } => format!("{}(?)", msg_type), + } +} + +/// Trace an IOPub message being received +pub fn trace_iopub_message(trace: &IoPubTrace) { + if !is_iopub_tracing_enabled() { + return; + } + + let seq = next_seq(); + let ts = timestamp_ms(); + let formatted = format_iopub_trace(trace); + + eprintln!("│ {:>6}ms │ #{:<4} │ IOPub │ ← {}", ts, seq, formatted); +} + +/// Trace an IOPub `Message` directly. +/// +/// Converts the message to an `IoPubTrace` and traces it. +pub fn trace_iopub_msg(msg: &Message) { + if !is_iopub_tracing_enabled() { + return; + } + + let trace = match msg { + Message::Status(status) => match status.content.execution_state { + ExecutionState::Busy => IoPubTrace::Busy, + ExecutionState::Idle => IoPubTrace::Idle, + ExecutionState::Starting => IoPubTrace::Status { + state: "starting".to_string(), + }, + }, + Message::ExecuteInput(input) => IoPubTrace::ExecuteInput { + code: input.content.code.clone(), + }, + Message::ExecuteResult(_) => IoPubTrace::ExecuteResult, + Message::ExecuteError(err) => IoPubTrace::ExecuteError { + message: err.content.exception.evalue.clone(), + }, + Message::Stream(stream) => { + let name = match stream.content.name { + Stream::Stdout => "stdout", + Stream::Stderr => "stderr", + }; + IoPubTrace::Stream { + name: name.to_string(), + text: stream.content.text.clone(), + } + }, + Message::CommOpen(comm) => IoPubTrace::CommOpen { + target: comm.content.target_name.clone(), + }, + Message::CommMsg(comm) => { + let method = comm + .content + .data + .get("method") + .and_then(|m| m.as_str()) + .unwrap_or("?") + .to_string(); + IoPubTrace::CommMsg { method } + }, + Message::CommClose(_) => IoPubTrace::CommClose, + _ => IoPubTrace::Other { + msg_type: format!("{:?}", std::mem::discriminant(msg)), + }, + }; + + trace_iopub_message(&trace); +} + +/// Trace a shell request being sent +pub fn trace_shell_request(msg_type: &str, code: Option<&str>) { + if !is_iopub_tracing_enabled() { + return; + } + + let seq = next_seq(); + let ts = timestamp_ms(); + + let detail = if let Some(c) = code { + let truncated = if c.len() > 30 { + format!("{}...", &c[..27]) + } else { + c.to_string() + }; + format!("({:?})", truncated) + } else { + String::new() + }; + + eprintln!( + "│ {:>6}ms │ #{:<4} │ Shell │ → {}{}", + ts, seq, msg_type, detail + ); +} + +/// Trace a shell reply being received +pub fn trace_shell_reply(msg_type: &str, status: &str) { + if !is_iopub_tracing_enabled() { + return; + } + + let seq = next_seq(); + let ts = timestamp_ms(); + + eprintln!( + "│ {:>6}ms │ #{:<4} │ Shell │ ← {}({})", + ts, seq, msg_type, status + ); +} + +/// Print a separator line in the trace output +pub fn trace_separator(label: &str) { + if !is_dap_tracing_enabled() && !is_iopub_tracing_enabled() { + return; + } + + let ts = timestamp_ms(); + eprintln!("├──{:>4}ms──┼───────┼────────┼─ {} ─", ts, label); +} + +/// Print a note in the trace output +pub fn trace_note(note: &str) { + if !is_dap_tracing_enabled() && !is_iopub_tracing_enabled() { + return; + } + + let ts = timestamp_ms(); + eprintln!("│ {:>6}ms │ │ NOTE │ {}", ts, note); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_dap_stopped() { + use dap::events::StoppedEventBody; + use dap::types::StoppedEventReason; + + let event = Event::Stopped(StoppedEventBody { + reason: StoppedEventReason::Step, + description: None, + thread_id: Some(-1), + preserve_focus_hint: Some(false), + text: None, + all_threads_stopped: Some(true), + hit_breakpoint_ids: None, + }); + + let formatted = format_dap_event(&event); + assert!(formatted.contains("Stopped")); + assert!(formatted.contains("Step")); + } + + #[test] + fn test_format_iopub_stream() { + let trace = IoPubTrace::Stream { + name: "stdout".to_string(), + text: "Hello, world!\nLine 2".to_string(), + }; + + let formatted = format_iopub_trace(&trace); + assert!(formatted.contains("stream")); + assert!(formatted.contains("stdout")); + assert!(formatted.contains("\\n")); // Newline escaped + } +}