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>, commands: Rc>>, } 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 { 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, command: &str, args: Json) -> Vec { adapter.handle(json!({"seq": 7, "type": "request", "command": command, "arguments": args})) } fn ok(adapter: &mut Adapter, 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 { 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::>(), [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); }