//! Drives the script-language debuggers (debugpy, js-debug for Node / TypeScript / npm //! scripts / Deno) through a whole session with the launch arguments the UI builds //! (`src/features/debugger/launch.ts`): breakpoint verified and hit, stack, scopes, //! variables, evaluate, step into / over / out, continue, run to exit, then a restart //! that is terminated while paused. //! //! Ignored by default (needs the adapters and runtimes). Run with //! `OPENIDEA_DAP_FIXTURES= cargo test -p ide-dap --test script_debuggers -- --ignored --nocapture` //! where `` holds `py/app.py`, `node/app.js`, `ts/app.ts`, `tsmap/app.ts` compiled //! with `tsc --sourceMap --outDir out`, `tsx/app.ts` (with `npm i tsx` there), `npm/` //! (script `start` = `node app.js`) and `deno/app.ts`, all with the same layout (see `LOOP_LINE`). `OPENIDEA_DAP_ONLY=` //! runs one case. Cases whose adapter or runtime is missing are skipped. use ide_core::event::DebugEvent; use ide_core::{DebugSessionId, IdeEvent, SharedSink}; use ide_dap::*; use ide_language::LanguageRegistry; use parking_lot::Mutex; use serde_json::{Value, json}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, Instant}; /// 0-based line of `total += parse(s)` in every fixture. const LOOP_LINE: u32 = 8; /// First line of `parse`'s body, and the one after it. const PARSE_BODY: u32 = 2; /// `console.log` / `print` of the result: `total 12`. const OUTPUT: &str = "total 12"; struct Case { name: &'static str, /// Breakpoints are set here (the TypeScript source for source-mapped cases). source: PathBuf, /// The runtime that must be installed, besides the adapter. runtime: &'static str, config: LaunchConfig, } fn config(adapter: &str, program: Option, cwd: &Path, extra: Value) -> LaunchConfig { LaunchConfig { name: "fixture".into(), adapter: adapter.into(), request: "launch".into(), program: program.map(|p| p.display().to_string()), args: vec![], cwd: Some(cwd.into()), env: BTreeMap::new(), extra_json: Some(extra.to_string()), } } /// `jsDebugLaunch` in launch.ts, plus `more`. fn js_extra(more: Value) -> Value { let mut extra = json!({ "type": "pwa-node", "console": "internalConsole", "outputCapture": "std", "skipFiles": ["/**", "**/node_modules/**"], "autoAttachChildProcesses": true, }); if let (Some(e), Value::Object(more)) = (extra.as_object_mut(), more) { e.extend(more); } extra } fn cases(dir: &Path) -> Vec { let d = |s: &str| dir.join(s); vec![ Case { name: "python", source: d("py/app.py"), runtime: "python3", config: config( "debugpy", Some(d("py/app.py")), &d("py"), json!({ "python": "python3", "console": "internalConsole", "justMyCode": true, "subProcess": true, "redirectOutput": true, }), ), }, Case { name: "node", source: d("node/app.js"), runtime: "node", config: config("js-debug", Some(d("node/app.js")), &d("node"), js_extra(json!({}))), }, Case { // Node 22.18+ strips types natively. name: "typescript", source: d("ts/app.ts"), runtime: "node", config: config("js-debug", Some(d("ts/app.ts")), &d("ts"), js_extra(json!({}))), }, Case { name: "typescript-flag", source: d("ts/app.ts"), runtime: "node", config: config( "js-debug", None, &d("ts"), js_extra(json!({"runtimeExecutable": "node", "runtimeArgs": ["--experimental-strip-types", "app.ts"]})), ), }, Case { // `tsx/node_modules/.bin/tsx` (`npm i tsx` there): a runner on Node, source-mapped. name: "tsx", source: d("tsx/app.ts"), runtime: "node", config: config( "js-debug", None, &d("tsx"), js_extra(json!({ "runtimeExecutable": d("tsx/node_modules/.bin/tsx"), "runtimeArgs": ["app.ts"], })), ), }, Case { name: "sourcemap", source: d("tsmap/app.ts"), runtime: "node", config: config("js-debug", Some(d("tsmap/out/app.js")), &d("tsmap"), js_extra(json!({}))), }, Case { name: "npm", source: d("npm/app.js"), runtime: "npm", config: config( "js-debug", None, &d("npm"), js_extra(json!({"runtimeExecutable": "npm", "runtimeArgs": ["run", "start"]})), ), }, Case { name: "deno", source: d("deno/app.ts"), runtime: "deno", config: config( "js-debug", None, &d("deno"), js_extra(json!({ "runtimeExecutable": "deno", "runtimeArgs": ["run", "--inspect-wait=127.0.0.1:${freePort}", "app.ts"], "attachSimplePort": "${freePort}", })), ), }, ] } struct Run { manager: Arc, events: Arc>>, owner: SessionOwner, } impl Run { fn new() -> Self { let events: Arc>> = Arc::default(); let sink_events = events.clone(); let sink: SharedSink = Arc::new(move |e: IdeEvent| { if let IdeEvent::Debug { session, event } = e { sink_events.lock().push((session, event)); } }); let languages = Arc::new(LanguageRegistry::from_configs(Vec::new())); Self { manager: DebugManager::new(languages), events, owner: SessionOwner::new("test", sink) } } fn seen(&self) -> usize { self.events.lock().len() } fn log(&self) -> String { let log: Vec = self.events.lock().iter().map(|(s, e)| format!("{}: {e:?}", s.0)).collect(); log.join("\n") } async fn wait( &self, seen: usize, what: &str, f: impl Fn(DebugSessionId, &DebugEvent) -> Option, ) -> Result { let deadline = Instant::now() + Duration::from_secs(30); while Instant::now() < deadline { if let Some(hit) = self.events.lock().iter().skip(seen).find_map(|(s, e)| f(*s, e)) { return Ok(hit); } tokio::time::sleep(Duration::from_millis(30)).await; } Err(format!("timed out waiting for {what}; events:\n{}", self.log())) } async fn stopped(&self, seen: usize, what: &str) -> Result<(DebugSessionId, i64), String> { self.wait(seen, what, |s, e| match e { DebugEvent::Stopped { thread_id, .. } => Some((s, *thread_id)), _ => None, }) .await } async fn step(&self, s: DebugSessionId, thread: i64, kind: StepKind) -> Result { let seen = self.seen(); self.manager.step(s, thread, kind).await.map_err(|e| e.message)?; self.stopped(seen, &format!("{kind:?}")).await?; self.top(s, thread).await } async fn top(&self, s: DebugSessionId, thread: i64) -> Result { let frames = self.manager.stack_trace(s, thread).await.map_err(|e| e.message)?; frames.into_iter().next().ok_or_else(|| "empty stack".into()) } /// Every session of the run has ended (js-debug runs the program in a child session). async fn all_terminated(&self) -> Result<(), String> { let deadline = Instant::now() + Duration::from_secs(30); while Instant::now() < deadline { if self.manager.sessions(None).is_empty() { return Ok(()); } tokio::time::sleep(Duration::from_millis(50)).await; } Err(format!("sessions still live: {:?}", self.manager.sessions(None))) } } fn bp(line: u32) -> Breakpoint { Breakpoint { line, condition: None, hit_condition: None, log_message: None, enabled: true } } fn expect_line(f: &StackFrame, line: u32, what: &str) -> Result<(), String> { if f.line == line { Ok(()) } else { Err(format!("{what}: at {}:{} ({:?}), expected line {}", f.name, f.line + 1, f.path, line + 1)) } } async fn scenario(case: &Case) -> Result, String> { let mut report = Vec::new(); let run = Run::new(); let m = &run.manager; m.set_breakpoints(case.source.clone(), vec![bp(LOOP_LINE)]).await.map_err(|e| e.message)?; let id = m.start(case.config.clone(), run.owner.clone()).await.map_err(|e| format!("start: {}", e.message))?; let (s, thread) = run.stopped(0, "the loop breakpoint").await?; // js-debug verifies in a `breakpoint` event right after the stop. let deadline = Instant::now() + Duration::from_secs(5); while !m.breakpoint_states(&case.source).first().is_some_and(|st| st.verified) { if Instant::now() > deadline { return Err(format!("breakpoint not verified: {:?}", m.breakpoint_states(&case.source))); } tokio::time::sleep(Duration::from_millis(30)).await; } let top = run.top(s, thread).await?; expect_line(&top, LOOP_LINE, "breakpoint")?; if top.path.as_deref().map(|p| p.canonicalize().ok()) != Some(case.source.canonicalize().ok()) { return Err(format!("frame source {:?}, expected {}", top.path, case.source.display())); } report.push(format!("hit {}:{} (verified)", top.name, top.line + 1)); let scopes = m.scopes(s, top.id).await.map_err(|e| e.message)?; // js-debug puts the loop variable in a Block scope before Local. let mut vars = Vec::new(); for scope in scopes.iter().filter(|sc| !sc.expensive) { vars.extend(m.variables(s, scope.variables_reference).await.map_err(|e| e.message)?); } let total = vars.iter().find(|v| v.name == "total").ok_or_else(|| format!("no `total` in {vars:?}"))?; let items = vars.iter().find(|v| v.name == "items").ok_or_else(|| format!("no `items` in {vars:?}"))?; let children = m.variables(s, items.variables_reference).await.map_err(|e| e.message)?; report.push(format!( "scopes {:?}; total = {}; items has {} children", scopes.iter().map(|sc| sc.name.as_str()).collect::>(), total.value, children.len() )); let v = m.evaluate(s, Some(top.id), "total + 100".into()).await.map_err(|e| e.message)?; if v.value != "100" { return Err(format!("evaluate: {}", v.value)); } let f = run.step(s, thread, StepKind::Into).await?; if !f.name.ends_with("parse") { return Err(format!("step into: in {} at {}", f.name, f.line + 1)); } expect_line(&f, PARSE_BODY, "step into")?; let f = run.step(s, thread, StepKind::Over).await?; expect_line(&f, PARSE_BODY + 1, "step over")?; let f = run.step(s, thread, StepKind::Out).await?; if !f.name.ends_with("run") { return Err(format!("step out: in {} at {}", f.name, f.line + 1)); } report.push(format!("step into / over / out: back in {}:{}", f.name, f.line + 1)); // Continue: the loop breakpoint again (second item). let seen = run.seen(); m.resume(s, thread).await.map_err(|e| e.message)?; let (s2, t2) = run.stopped(seen, "the second hit").await?; expect_line(&run.top(s2, t2).await?, LOOP_LINE, "continue")?; // Without breakpoints, the program runs to its end. m.set_breakpoints(case.source.clone(), vec![]).await.map_err(|e| e.message)?; let seen = run.seen(); m.resume(s2, t2).await.map_err(|e| e.message)?; run.wait(seen, "terminated", |s, e| (s == id && matches!(e, DebugEvent::Terminated)).then_some(())).await?; // debugpy may deliver one `print` in several chunks. let stdout: String = run .events .lock() .iter() .filter_map(|(_, e)| match e { DebugEvent::Output { category, output } if category == "stdout" => Some(output.as_str()), _ => None, }) .collect(); if !stdout.contains(OUTPUT) { return Err(format!("stdout {stdout:?} lacks {OUTPUT:?}")); } run.all_terminated().await?; report.push("continue to exit: output and terminated".into()); // Restart (the UI starts the same configuration again), then Stop while paused. m.set_breakpoints(case.source.clone(), vec![bp(LOOP_LINE)]).await.map_err(|e| e.message)?; let seen = run.seen(); let again = m.start(case.config.clone(), run.owner.clone()).await.map_err(|e| format!("restart: {}", e.message))?; let (s, thread) = run.stopped(seen, "the breakpoint after restart").await?; expect_line(&run.top(s, thread).await?, LOOP_LINE, "restart")?; let seen = run.seen(); m.stop(again).await.map_err(|e| e.message)?; run.wait(seen, "terminated after stop", |s, e| (s == again && matches!(e, DebugEvent::Terminated)).then_some(())) .await?; run.all_terminated().await?; report.push("restart + stop while paused: terminated".into()); Ok(report) } fn on_path(cmd: &str) -> bool { adapter::resolve_binary(cmd, Path::new("/")).is_some() } fn installed(case: &Case) -> bool { let adapter = match case.config.adapter.as_str() { "debugpy" => { std::process::Command::new("python3").args(["-c", "import debugpy"]).status().is_ok_and(|s| s.success()) || adapter::tools_root() .is_some_and(|r| r.join(adapter::DEBUGPY_TOOL).join("lib/debugpy/__init__.py").is_file()) } _ => on_path("js-debug-adapter"), }; adapter && on_path(case.runtime) && case.source.is_file() } #[tokio::test(flavor = "multi_thread")] #[ignore = "needs real debug adapters and OPENIDEA_DAP_FIXTURES"] async fn script_debuggers() { let Some(dir) = std::env::var_os("OPENIDEA_DAP_FIXTURES").map(PathBuf::from) else { eprintln!("OPENIDEA_DAP_FIXTURES not set"); return; }; let dir = dir.canonicalize().unwrap(); let _ = tracing_subscriber::fmt().with_env_filter(tracing_subscriber::EnvFilter::from_default_env()).try_init(); if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) { adapter::set_tools_root(home.join("Library/Application Support/ai.l0ng.openidea/tools")); } let only = std::env::var("OPENIDEA_DAP_ONLY").ok(); let mut failures = Vec::new(); for case in cases(&dir) { if only.as_deref().is_some_and(|o| o != case.name) { continue; } if !installed(&case) { println!("== {}: SKIPPED (adapter, runtime or fixture missing)", case.name); continue; } match tokio::time::timeout(Duration::from_secs(180), scenario(&case)).await { Ok(Ok(lines)) => { println!("== {}: OK", case.name); for l in lines { println!(" {l}"); } } Ok(Err(e)) => { println!("== {}: FAILED {e}", case.name); failures.push(case.name); } Err(_) => { println!("== {}: TIMED OUT", case.name); failures.push(case.name); } } } assert!(failures.is_empty(), "failed: {failures:?}"); }