mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 16:08:05 +00:00
407 lines
16 KiB
Rust
407 lines
16 KiB
Rust
//! 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=<dir> cargo test -p ide-dap --test script_debuggers -- --ignored --nocapture`
|
|
//! where `<dir>` 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=<case>`
|
|
//! 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<PathBuf>, 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_internals>/**", "**/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<Case> {
|
|
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<DebugManager>,
|
|
events: Arc<Mutex<Vec<(DebugSessionId, DebugEvent)>>>,
|
|
owner: SessionOwner,
|
|
}
|
|
|
|
impl Run {
|
|
fn new() -> Self {
|
|
let events: Arc<Mutex<Vec<(DebugSessionId, DebugEvent)>>> = 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<String> = self.events.lock().iter().map(|(s, e)| format!("{}: {e:?}", s.0)).collect();
|
|
log.join("\n")
|
|
}
|
|
|
|
async fn wait<T>(
|
|
&self,
|
|
seen: usize,
|
|
what: &str,
|
|
f: impl Fn(DebugSessionId, &DebugEvent) -> Option<T>,
|
|
) -> Result<T, String> {
|
|
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<StackFrame, String> {
|
|
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<StackFrame, String> {
|
|
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<Vec<String>, 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::<Vec<_>>(),
|
|
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:?}");
|
|
}
|