runa/crates/runa-dap/tests/adapter.rs

894 lines
26 KiB
Rust
Raw Normal View History

use std::cell::RefCell;
use std::rc::Rc;
use runa::Server;
use runa_dap::{Adapter, Connection};
use runa_protocol::{Command, PROTOCOL_VERSION, Reply, Request, StepMode, Value};
use serde_json::{Value as Json, json};
const SOURCE: &str = r#"fn helper(value) {
value + 1
}
pub fn handle(ctx, state, sender, message) {
let next = helper(state);
next + message
}
"#;
#[derive(Clone)]
struct Image {
server: Rc<RefCell<Server>>,
commands: Rc<RefCell<Vec<Command>>>,
}
impl Image {
fn new() -> Self {
Self {
server: Rc::new(RefCell::new(Server::new())),
commands: Rc::default(),
}
}
fn actor(&mut self, path: &str, source: &str) -> String {
self.request(Command::WriteSource {
path: path.to_owned(),
expected_source_id: None,
source: source.to_owned(),
})
.unwrap();
let Reply::ActorSpawned { actor_ref } = self
.request(Command::Spawn {
behavior_name: path.to_owned(),
mailbox_capacity: 32,
state: Value::Integer(10),
})
.unwrap()
else {
panic!("expected spawned actor")
};
actor_ref
}
fn inspect(&mut self, actor: &str) -> runa_protocol::ActorSnapshotDto {
let Reply::Actor(actor) = self
.request(Command::InspectActor {
actor_ref: actor.to_owned(),
})
.unwrap()
else {
panic!()
};
actor
}
}
impl Connection for Image {
fn request(&mut self, command: Command) -> Result<Reply, String> {
self.commands.borrow_mut().push(command.clone());
self.server
.borrow_mut()
.dispatch(Request {
version: PROTOCOL_VERSION,
request_id: "dap-test".to_owned(),
command,
})
.result
.map_err(|error| error.message)
}
}
fn request(adapter: &mut Adapter<Image>, command: &str, args: Json) -> Vec<Json> {
adapter.handle(json!({"seq": 7, "type": "request", "command": command, "arguments": args}))
}
fn ok(adapter: &mut Adapter<Image>, command: &str, args: Json) -> Json {
let messages = request(adapter, command, args);
assert_eq!(messages[0]["success"], true, "{messages:?}");
messages[0]["body"].clone()
}
fn attach(image: &Image, actor: &str) -> Adapter<Image> {
let mut adapter = Adapter::new(image.clone());
ok(&mut adapter, "initialize", json!({}));
let messages = request(
&mut adapter,
"attach",
json!({
"actorRef": actor, "behaviorSourceUri": format!("runa-actor://session/{actor}/behavior.rn"),
"workingSourceUri": "runa-image://localhost/source/tests/handler.rn"
}),
);
assert_eq!(messages[0]["success"], true, "{messages:?}");
assert!(
messages
.iter()
.any(|message| message["event"] == "initialized")
);
ok(&mut adapter, "configurationDone", json!({}));
adapter
}
fn fixture() -> (Image, String) {
let mut image = Image::new();
let actor = image.actor("tests/handler", SOURCE);
(image, actor)
}
#[test]
fn attach_pause_source_and_resume_never_tick_or_clear_breakpoints() {
let (mut image, actor) = fixture();
let mut adapter = attach(&image, &actor);
assert_eq!(
image.inspect(&actor).status,
runa_protocol::ActorStatusDto::Paused
);
let stopped = adapter.poll();
assert!(
stopped
.iter()
.any(|message| message["event"] == "stopped" && message["body"]["reason"] == "pause")
);
assert!(adapter.poll().is_empty());
let sources = ok(&mut adapter, "loadedSources", json!({}));
assert_eq!(sources["sources"][0]["name"], "behavior.rn");
assert_eq!(sources["sources"][0]["sourceReference"], 1);
assert_eq!(
sources["sources"][0]["path"],
format!("runa-actor://session/{actor}/behavior.rn")
);
assert_eq!(
ok(&mut adapter, "source", json!({"sourceReference": 1}))["content"],
SOURCE
);
assert_eq!(
ok(&mut adapter, "threads", json!({}))["threads"][0]["id"],
1
);
let breakpoints = ok(
&mut adapter,
"setBreakpoints",
json!({"source": {"sourceReference": 1}, "breakpoints": [{"line": 5}]}),
);
assert_eq!(breakpoints["breakpoints"][0]["verified"], true);
ok(&mut adapter, "continue", json!({"threadId": 1}));
assert!(
!image
.commands
.borrow()
.iter()
.any(|command| matches!(command, Command::Tick { .. }))
);
assert_ne!(
image.inspect(&actor).status,
runa_protocol::ActorStatusDto::Paused
);
assert!(
request(&mut adapter, "variables", json!({"variablesReference": 1}))[0]["message"]
.as_str()
.unwrap()
.contains("Stale")
);
ok(&mut adapter, "disconnect", json!({}));
let Reply::ExecutionInspected(inspection) = image
.request(Command::InspectExecution { actor_ref: actor })
.unwrap()
else {
panic!()
};
assert_eq!(inspection.debugger.breakpoints.len(), 1);
assert!(adapter.is_closed());
}
#[test]
fn breakpoints_are_actor_specific_and_all_mutable_sources_are_guarded() {
let (mut image, actor) = fixture();
let second = image.actor("tests/other", SOURCE);
let mut adapter = attach(&image, &actor);
let source = json!({"path": format!("runa-actor://session/{actor}/behavior.rn")});
let set = |source: Json, dirty: bool| json!({"source": source, "sourceModified": dirty, "breakpoints": [{"line": 5}]});
assert_eq!(
ok(&mut adapter, "setBreakpoints", set(source.clone(), false))["breakpoints"][0]["verified"],
true
);
let Reply::ExecutionInspected(other) = image
.request(Command::InspectExecution {
actor_ref: second.clone(),
})
.unwrap()
else {
panic!()
};
assert!(other.debugger.breakpoints.is_empty());
assert_eq!(
ok(&mut adapter, "setBreakpoints", set(source.clone(), true))["breakpoints"][0]["verified"],
false
);
let Reply::ActorBehavior { source_id, .. } = image
.request(Command::ActorBehavior {
actor_ref: actor.clone(),
})
.unwrap()
else {
panic!()
};
image
.request(Command::WriteActorBehavior {
actor_ref: actor.clone(),
expected_source_id: source_id,
source: SOURCE.replace("value + 1", "value + 2"),
})
.unwrap();
assert_eq!(
ok(&mut adapter, "setBreakpoints", set(source, false))["breakpoints"][0]["verified"],
false
);
assert_eq!(
ok(
&mut adapter,
"setBreakpoints",
set(json!({"sourceReference": 1}), true)
)["breakpoints"][0]["verified"],
true
);
let working = json!({"path": "runa-image://localhost/source/tests/handler.rn"});
assert_eq!(
ok(&mut adapter, "setBreakpoints", set(working.clone(), false))["breakpoints"][0]["verified"],
true
);
assert_eq!(
ok(&mut adapter, "setBreakpoints", set(working.clone(), true))["breakpoints"][0]["verified"],
false
);
let Reply::Source(file) = image
.request(Command::ReadSource {
path: "tests/handler".to_owned(),
})
.unwrap()
else {
panic!()
};
image
.request(Command::WriteSource {
path: file.path,
expected_source_id: Some(file.source_id),
source: SOURCE.replace("value + 1", "value + 3"),
})
.unwrap();
assert_eq!(
ok(&mut adapter, "setBreakpoints", set(working, false))["breakpoints"][0]["verified"],
false
);
let before = image
.commands
.borrow()
.iter()
.filter(|command| matches!(command, Command::SetBreakpoints { .. }))
.count();
assert_eq!(
ok(
&mut adapter,
"setBreakpoints",
set(
json!({"path": format!("runa-actor://session/{second}/behavior.rn")}),
false
)
)["breakpoints"][0]["verified"],
false
);
let after = image
.commands
.borrow()
.iter()
.filter(|command| matches!(command, Command::SetBreakpoints { .. }))
.count();
assert_eq!(before, after);
}
#[test]
fn loaded_and_mutable_breakpoint_groups_merge_and_external_changes_emit_events() {
let (mut image, actor) = fixture();
let mut adapter = attach(&image, &actor);
ok(
&mut adapter,
"setBreakpoints",
json!({"source": {"sourceReference": 1}, "breakpoints": [{"line": 5}]}),
);
ok(
&mut adapter,
"setBreakpoints",
json!({"source": {"path": "runa-image://localhost/source/tests/handler.rn"}, "breakpoints": [{"line": 6}]}),
);
let Reply::ExecutionInspected(inspection) = image
.request(Command::InspectExecution {
actor_ref: actor.clone(),
})
.unwrap()
else {
panic!()
};
assert_eq!(
inspection
.debugger
.breakpoints
.iter()
.map(|bp| bp.line)
.collect::<Vec<_>>(),
[4, 5]
);
image
.request(Command::SetBreakpoints {
actor_ref: actor,
implementation_id: inspection.implementation_id,
lines: vec![],
})
.unwrap();
let events = adapter.poll();
assert_eq!(
events
.iter()
.filter(|event| event["event"] == "breakpoint"
&& event["body"]["breakpoint"]["verified"] == false)
.count(),
2
);
}
#[test]
fn variable_expansion_and_evaluation_share_snapshot_handles() {
let (mut image, actor) = fixture();
image
.request(Command::Send {
actor_ref: actor.clone(),
message: Value::Record(std::collections::BTreeMap::from([(
"a/b~c".to_owned(),
Value::List(vec![Value::Integer(3)]),
)])),
})
.unwrap();
let mut adapter = attach(&image, &actor);
adapter.poll();
let root =
ok(&mut adapter, "evaluate", json!({"expression": "actor"}))["variablesReference"].clone();
let evaluated_mailbox = ok(
&mut adapter,
"evaluate",
json!({"expression": "actor/mailbox"}),
)["variablesReference"]
.clone();
let root_variables = ok(
&mut adapter,
"variables",
json!({"variablesReference": root}),
);
let mailbox = root_variables["variables"]
.as_array()
.unwrap()
.iter()
.find(|variable| variable["name"] == "mailbox")
.unwrap();
assert_eq!(mailbox["variablesReference"], evaluated_mailbox);
assert_eq!(
ok(
&mut adapter,
"variables",
json!({"variablesReference": root})
),
root_variables
);
let queued = ok(
&mut adapter,
"variables",
json!({"variablesReference": evaluated_mailbox}),
)["variables"]
.as_array()
.unwrap()
.iter()
.find(|variable| variable["name"] == "queued")
.unwrap()
.clone();
assert_eq!(
ok(
&mut adapter,
"evaluate",
json!({"expression": "actor/mailbox/queued"})
)["variablesReference"],
queued["variablesReference"]
);
let message = ok(
&mut adapter,
"variables",
json!({"variablesReference": queued["variablesReference"]}),
)["variables"][0]
.clone();
let field = ok(
&mut adapter,
"variables",
json!({"variablesReference": message["variablesReference"]}),
)["variables"][0]
.clone();
assert_eq!(field["evaluateName"], "actor/mailbox/queued/0/a~1b~0c");
assert_eq!(
ok(
&mut adapter,
"evaluate",
json!({"expression": field["evaluateName"]})
)["variablesReference"],
field["variablesReference"]
);
ok(
&mut adapter,
"setBreakpoints",
json!({"source": {"sourceReference": 1}, "breakpoints": [{"line": 5}]}),
);
assert!(
!adapter
.poll()
.iter()
.any(|message| message["event"] == "stopped")
);
assert_eq!(
ok(
&mut adapter,
"evaluate",
json!({"expression": "actor/mailbox"})
)["variablesReference"],
evaluated_mailbox
);
image
.request(Command::EditActor {
actor_ref: actor,
state: Some(Value::Integer(99)),
messages: None,
})
.unwrap();
adapter.poll();
let replacement = ok(
&mut adapter,
"evaluate",
json!({"expression": "actor/mailbox"}),
)["variablesReference"]
.clone();
assert_ne!(replacement, evaluated_mailbox);
let stale = request(
&mut adapter,
"variables",
json!({"variablesReference": evaluated_mailbox}),
);
assert!(stale[0]["message"].as_str().unwrap().contains("Stale"));
}
#[test]
fn snapshot_paths_readonly_pagination_escaping_and_stale_references() {
let (mut image, actor) = fixture();
let mut adapter = attach(&image, &actor);
adapter.poll();
let state = ok(
&mut adapter,
"evaluate",
json!({"expression": "actor/committed_state", "context": "watch"}),
);
assert_eq!(state["result"], "10");
assert_eq!(state["evaluateName"], "actor/committed_state");
assert_eq!(state["presentationHint"]["attributes"], json!(["readOnly"]));
let root = ok(&mut adapter, "evaluate", json!({"expression": "actor"}))["variablesReference"]
.as_u64()
.unwrap();
let variables = ok(
&mut adapter,
"variables",
json!({"variablesReference": root, "start": 1, "count": 1}),
);
assert_eq!(variables["variables"].as_array().unwrap().len(), 1);
assert_eq!(
ok(
&mut adapter,
"variables",
json!({"variablesReference": root, "filter": "indexed"})
)["variables"],
json!([])
);
assert_eq!(
ok(
&mut adapter,
"evaluate",
json!({"expression": "actor/mailbox/queued"})
)["result"],
"List(0)"
);
let mut fields = std::collections::BTreeMap::new();
fields.insert("a/b~c".to_owned(), Value::Integer(42));
image
.request(Command::EditActor {
actor_ref: actor.clone(),
state: Some(Value::Record(fields)),
messages: None,
})
.unwrap();
assert!(
adapter
.poll()
.iter()
.any(|message| message["event"] == "stopped")
);
assert_eq!(
request(
&mut adapter,
"variables",
json!({"variablesReference": root})
)[0]["success"],
false
);
assert_eq!(
ok(
&mut adapter,
"evaluate",
json!({"expression": "actor/committed_state/a~1b~0c"})
)["result"],
"42"
);
for expression in [
"state + 1",
"actor/__proto__",
"actor/committed_state/a~2b",
"actor/mailbox/queued/01",
"actor/mailbox/queued/0",
] {
assert_eq!(
request(&mut adapter, "evaluate", json!({"expression": expression}))[0]["success"],
false,
"{expression}"
);
}
ok(&mut adapter, "continue", json!({"threadId": 1}));
assert_eq!(
request(&mut adapter, "evaluate", json!({"expression": "actor"}))[0]["success"],
false
);
}
#[test]
fn source_steps_use_bounded_modes_and_real_frames_without_locals() {
let (mut image, actor) = fixture();
image
.request(Command::Send {
actor_ref: actor.clone(),
message: Value::Integer(2),
})
.unwrap();
image
.request(Command::Tick {
ticks: 1,
instruction_budget: 1,
})
.unwrap();
let mut adapter = attach(&image, &actor);
adapter.poll();
let old_root =
ok(&mut adapter, "evaluate", json!({"expression": "actor"}))["variablesReference"].clone();
for (command, mode) in [
("stepIn", StepMode::Into),
("next", StepMode::Over),
("stepOut", StepMode::Out),
] {
let messages = request(&mut adapter, command, json!({"threadId": 1}));
assert_eq!(messages[0]["success"], true, "{messages:?}");
assert!(
messages
.iter()
.any(|message| message["event"] == "continued")
);
assert!(image.commands.borrow().iter().any(|command| matches!(command, Command::StepSource {instruction_budget: 1000, mode: actual, ..} if *actual == mode)));
let events = adapter.poll();
assert!(
events.iter().any(|event| event["event"] == "stopped"),
"{events:?}"
);
let trace = ok(&mut adapter, "stackTrace", json!({"threadId": 1}));
if command == "stepIn" {
assert!(trace["totalFrames"].as_u64().unwrap() > 0);
}
for frame in trace["stackFrames"].as_array().unwrap() {
let scopes = ok(&mut adapter, "scopes", json!({"frameId": frame["id"]}));
assert_eq!(scopes["scopes"].as_array().unwrap().len(), 2);
assert!(
scopes["scopes"][0]["name"]
.as_str()
.unwrap()
.contains("not frame locals")
);
let metadata = ok(
&mut adapter,
"variables",
json!({"variablesReference": scopes["scopes"][1]["variablesReference"]}),
);
assert!(
metadata["variables"]
.as_array()
.unwrap()
.iter()
.any(|variable| variable["name"] == "instruction_pointer")
);
assert_eq!(frame["source"]["name"], "behavior.rn");
}
}
assert_eq!(
request(
&mut adapter,
"variables",
json!({"variablesReference": old_root})
)[0]["success"],
false
);
}
#[test]
fn bounded_step_reports_budget_without_claiming_to_reach_a_source_line() {
let mut image = Image::new();
let actor = image.actor(
"tests/loop",
"pub fn handle(ctx, state, sender, message) { loop { let value = state + 1; } }",
);
image
.request(Command::Send {
actor_ref: actor.clone(),
message: Value::Null,
})
.unwrap();
image
.request(Command::Tick {
ticks: 1,
instruction_budget: 1,
})
.unwrap();
let mut adapter = attach(&image, &actor);
adapter.poll();
let response = request(&mut adapter, "stepOut", json!({"threadId": 1}));
assert_eq!(response[0]["success"], true, "{response:?}");
assert!(response.iter().any(|message| {
message["event"] == "output"
&& message["body"]["output"]
.as_str()
.unwrap()
.contains("budget")
}));
let stopped = adapter.poll();
assert!(
stopped
.iter()
.any(|message| message["event"] == "stopped" && message["body"]["reason"] == "pause")
);
assert_eq!(image.inspect(&actor).state, Value::Integer(10));
}
#[test]
fn failed_attach_is_nonmutating_and_controls_cannot_retry_invocation() {
let mut image = Image::new();
let actor = image.actor(
"tests/failure",
"pub fn handle(ctx, state, sender, message) { panic(\"bad invocation\") }",
);
image
.request(Command::Send {
actor_ref: actor.clone(),
message: Value::Integer(1),
})
.unwrap();
image
.request(Command::Tick {
ticks: 1,
instruction_budget: 1000,
})
.unwrap();
let before = image.inspect(&actor);
assert!(matches!(
before.status,
runa_protocol::ActorStatusDto::Failed(_)
));
image.commands.borrow_mut().clear();
let mut adapter = attach(&image, &actor);
assert_eq!(image.inspect(&actor), before);
assert!(
!image
.commands
.borrow()
.iter()
.any(|command| matches!(command, Command::System { .. }))
);
let events = adapter.poll();
assert!(
events.iter().any(
|message| message["event"] == "stopped" && message["body"]["reason"] == "exception"
)
);
let failure = ok(&mut adapter, "exceptionInfo", json!({"threadId": 1}));
assert_eq!(failure["breakMode"], "always");
assert!(
failure["description"]
.as_str()
.unwrap()
.contains("bad invocation")
);
assert_eq!(
ok(&mut adapter, "stackTrace", json!({"threadId": 1}))["stackFrames"],
json!([])
);
assert_eq!(
ok(
&mut adapter,
"evaluate",
json!({"expression": "actor/mailbox/reserved"})
)["result"],
"List(1)"
);
for command in ["pause", "continue", "next", "stepIn", "stepOut"] {
let response = request(&mut adapter, command, json!({"threadId": 1}));
assert_eq!(response[0]["success"], false);
assert!(
response[0]["message"]
.as_str()
.unwrap()
.contains("repair/restart")
);
}
assert_eq!(image.inspect(&actor), before);
}
#[test]
fn failed_actor_is_guarded_even_before_first_poll() {
let mut image = Image::new();
let actor = image.actor(
"tests/failure",
"pub fn handle(ctx, state, sender, message) { panic(\"failed\") }",
);
image
.request(Command::Send {
actor_ref: actor.clone(),
message: Value::Null,
})
.unwrap();
image
.request(Command::Tick {
ticks: 1,
instruction_budget: 1000,
})
.unwrap();
let mut adapter = attach(&image, &actor);
assert_eq!(
request(&mut adapter, "continue", json!({"threadId": 1}))[0]["success"],
false
);
}
#[test]
fn incarnation_change_ends_session_before_mutating_actor() {
let (mut image, actor) = fixture();
let mut adapter = attach(&image, &actor);
image
.request(Command::RestartActor {
actor_ref: actor.clone(),
})
.unwrap();
image.commands.borrow_mut().clear();
let response = request(&mut adapter, "continue", json!({"threadId": 1}));
assert_eq!(response[0]["success"], false);
assert!(
response
.iter()
.any(|message| message["event"] == "terminated")
);
assert!(
!image
.commands
.borrow()
.iter()
.any(|command| matches!(command, Command::System { .. }))
);
}
#[test]
fn external_stop_and_implementation_change_terminate_on_poll() {
for stop in [false, true] {
let (mut image, actor) = fixture();
let mut adapter = attach(&image, &actor);
if stop {
image
.request(Command::StopActor { actor_ref: actor })
.unwrap();
image
.request(Command::Tick {
ticks: 1,
instruction_budget: 1,
})
.unwrap();
} else {
image
.request(Command::PublishBehavior {
name: "tests/replacement".to_owned(),
expected_revision: None,
source: "pub fn handle(ctx, state, sender, message) { state }".to_owned(),
})
.unwrap();
image
.request(Command::ResumeActor {
actor_ref: actor.clone(),
})
.unwrap();
image
.request(Command::InstallBehavior {
actor_ref: actor,
behavior_name: "tests/replacement".to_owned(),
})
.unwrap();
}
let events = adapter.poll();
assert!(
events
.iter()
.any(|message| message["event"] == "terminated"),
"stop={stop}, events={events:?}"
);
}
}
#[test]
fn invalid_requests_and_unsupported_commands_never_silently_succeed() {
let (image, actor) = fixture();
let mut adapter = attach(&image, &actor);
for (command, args) in [
("launch", json!({})),
("terminate", json!({})),
("setVariable", json!({})),
("setExceptionBreakpoints", json!({"filters": ["all"]})),
("continue", json!({"threadId": 2})),
("source", json!({"sourceReference": 9})),
("next", json!({"threadId": 1, "granularity": "instruction"})),
("stackTrace", json!({"threadId": 1, "startFrame": -1})),
(
"evaluate",
json!({"expression": "actor", "context": "hover"}),
),
("disconnect", json!({"terminateDebuggee": true})),
] {
assert_eq!(
request(&mut adapter, command, args)[0]["success"],
false,
"{command}"
);
}
assert_eq!(
ok(
&mut adapter,
"setExceptionBreakpoints",
json!({"filters": []})
)["breakpoints"],
json!([])
);
let invalid = adapter.handle(json!({"type": "request", "command": "threads", "seq": -1}));
assert!(
invalid
.iter()
.any(|message| message["event"] == "terminated")
);
}
#[test]
fn zero_based_locations_and_unsupported_breakpoint_features() {
let (image, actor) = fixture();
let mut adapter = Adapter::new(image);
ok(
&mut adapter,
"initialize",
json!({"linesStartAt1": false, "columnsStartAt1": false}),
);
ok(&mut adapter, "attach", json!({"actorRef": actor}));
let response = ok(
&mut adapter,
"setBreakpoints",
json!({"source": {"sourceReference": 1}, "breakpoints": [{"line": 4}, {"line": 5, "condition": "true"}, {"line": 9999}]}),
);
assert_eq!(response["breakpoints"][0]["line"], 4);
assert_eq!(response["breakpoints"][0]["verified"], true);
assert_eq!(response["breakpoints"][1]["verified"], false);
assert_eq!(response["breakpoints"][2]["verified"], false);
}