diff --git a/README.md b/README.md index f0e9d0f7..9d2464ff 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,63 @@ The batch args file should contain a JSON array of test cases: See [docs/batch-execution.md](https://github.com/Timi16/soroban-debugger/blob/main/docs/batch-execution.md) for detailed documentation. +### Scenario Command + +Run a multi-step test scenario defined in a TOML file: + +```bash +soroban-debug scenario --scenario my_scenario.toml --contract my_contract.wasm +``` + +Each step can specify a function to call, its arguments, and assertions on the return value, +contract storage, emitted events, and CPU/memory budget. + +#### Capturing Step Outputs into Variables + +A step can save its return value into a named variable using the `capture` field. Later steps +can reference that variable using the `{{var_name}}` syntax in their `args` or `expected_return` +fields. This lets multi-step scenarios remain free of hard-coded intermediate values. + +```toml +[[steps]] +name = "Mint tokens" +function = "mint" +args = '["Alice", 1000]' +# Store the return value (e.g. the new total supply) in a variable. +capture = "supply" + +[[steps]] +name = "Check total supply" +function = "total_supply" +# Assert the next call returns whatever was captured above. +expected_return = "{{supply}}" + +[[steps]] +name = "Transfer using captured supply" +function = "transfer" +# Interpolate the captured value into the args JSON. +args = '["Alice", "Bob", {{supply}}]' +``` + +If a step references a variable that has not been captured yet, the scenario fails immediately +with a descriptive error listing the undefined variable name and the variables that are +currently available. + +#### Scenario Step Fields + +| Field | Type | Description | +|-------|------|-------------| +| `name` | string | Optional human-readable label for the step | +| `function` | string | Contract function to call | +| `args` | string (JSON) | Function arguments as a JSON array. Supports `{{var}}` interpolation. | +| `capture` | string | Variable name to store the return value in for use by later steps | +| `expected_return` | string | Assert the return value equals this. Supports `{{var}}` interpolation. | +| `expected_error` | string | Assert the step fails with an error message containing this substring | +| `expected_panic` | string | Assert the step panics with a message containing this substring | +| `expected_events` | array | Assert the step emits exactly these contract events | +| `expected_storage` | table | Assert specific storage keys have these values after the step | +| `budget_limits` | table | Assert CPU/memory usage stays within `max_cpu_instructions`/`max_memory_bytes` | + ### Storage Filtering Filter large storage outputs by key pattern using `--storage-filter`: diff --git a/src/scenario.rs b/src/scenario.rs index f97cfbd4..0722b7d7 100644 --- a/src/scenario.rs +++ b/src/scenario.rs @@ -6,6 +6,7 @@ use crate::logging; use crate::runtime::executor::ContractExecutor; use crate::ui::formatter::Formatter; use crate::{DebuggerError, Result}; +use regex::Regex; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; @@ -28,6 +29,10 @@ pub struct ScenarioStep { pub expected_error: Option, /// When set, the step is expected to panic with a message containing this substring. pub expected_panic: Option, + /// When set, the return value of this step is stored in a variable with this name. + /// Later steps can reference the value using `{{var_name}}` in their `args` or + /// `expected_return` fields. + pub capture: Option, } #[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] @@ -89,6 +94,7 @@ pub fn run_scenario(args: ScenarioArgs, _verbosity: Verbosity) -> Result<()> { let mut engine = DebuggerEngine::new(executor, vec![]); let mut all_passed = true; + let mut variables: HashMap = HashMap::new(); for (i, step) in scenario.steps.iter().enumerate() { let step_label = step.name.as_deref().unwrap_or(&step.function); @@ -97,7 +103,20 @@ pub fn run_scenario(args: ScenarioArgs, _verbosity: Verbosity) -> Result<()> { Formatter::info(format!("Step {}: {}", i + 1, step_label)) ); - let parsed_args = if let Some(args_json) = &step.args { + // Resolve variable references in args and expected_return before executing. + let resolved_args = if let Some(args_json) = &step.args { + Some(interpolate_variables(args_json, &variables)?) + } else { + None + }; + + let resolved_expected_return = if let Some(expected) = &step.expected_return { + Some(interpolate_variables(expected, &variables)?) + } else { + None + }; + + let parsed_args = if let Some(args_json) = &resolved_args { Some(crate::cli::commands::parse_args(args_json)?) } else { None @@ -124,7 +143,21 @@ pub fn run_scenario(args: ScenarioArgs, _verbosity: Verbosity) -> Result<()> { step_passed = false; } else { println!(" Result: {}", res); - if let Some(expected) = &step.expected_return { + + // Capture the return value into a named variable if requested. + if let Some(var_name) = &step.capture { + variables.insert(var_name.clone(), res.trim().to_string()); + println!( + " {}", + Formatter::info(format!( + "Captured return value as '{}' = '{}'", + var_name, + res.trim() + )) + ); + } + + if let Some(expected) = &resolved_expected_return { if res.trim() == expected.trim() { println!( " {}", @@ -288,6 +321,53 @@ pub fn run_scenario(args: ScenarioArgs, _verbosity: Verbosity) -> Result<()> { } } +/// Replaces `{{var_name}}` placeholders in `template` with values from `variables`. +/// Returns an error with a descriptive message if any referenced variable is not defined. +fn interpolate_variables( + template: &str, + variables: &HashMap, +) -> Result { + let re = Regex::new(r"\{\{(\w+)\}\}").unwrap(); + + // Collect any missing variable names first for a clear error message. + let missing: Vec = re + .captures_iter(template) + .filter_map(|caps| { + let var_name = caps[1].to_string(); + if variables.contains_key(&var_name) { + None + } else { + Some(var_name) + } + }) + .collect(); + + if !missing.is_empty() { + let available: Vec<&String> = variables.keys().collect(); + let available_str = if available.is_empty() { + "(none)".to_string() + } else { + available + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + }; + return Err(DebuggerError::ExecutionError(format!( + "Undefined variable(s) referenced in scenario step: [{}]. Available variables: [{}]", + missing.join(", "), + available_str + )) + .into()); + } + + let result = re.replace_all(template, |caps: ®ex::Captures| { + variables[&caps[1]].clone() + }); + + Ok(result.into_owned()) +} + fn assert_expected_events( expected_events: &[ScenarioEventAssertion], actual_events: &[ContractEvent], @@ -365,6 +445,69 @@ fn assert_budget_limits( mod tests { use super::*; + #[test] + fn test_interpolate_variables_replaces_known_placeholders() { + let mut vars = HashMap::new(); + vars.insert("count".to_string(), "I64(1)".to_string()); + vars.insert("name".to_string(), "Alice".to_string()); + + let result = interpolate_variables("[{{count}}, \"{{name}}\"]", &vars).unwrap(); + assert_eq!(result, "[I64(1), \"Alice\"]"); + } + + #[test] + fn test_interpolate_variables_no_placeholders_is_identity() { + let vars: HashMap = HashMap::new(); + let result = interpolate_variables("[1, 2, 3]", &vars).unwrap(); + assert_eq!(result, "[1, 2, 3]"); + } + + #[test] + fn test_interpolate_variables_errors_on_undefined_variable() { + let mut vars = HashMap::new(); + vars.insert("defined".to_string(), "42".to_string()); + + let err = interpolate_variables("{{defined}} and {{missing}}", &vars).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("missing"), "error should name the missing variable: {}", msg); + assert!( + msg.contains("defined"), + "error should list available variables: {}", + msg + ); + } + + #[test] + fn test_interpolate_variables_errors_on_undefined_with_no_available_vars() { + let vars: HashMap = HashMap::new(); + let err = interpolate_variables("{{unknown}}", &vars).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("unknown"), "error should name the missing variable: {}", msg); + assert!(msg.contains("(none)"), "error should say no vars are available: {}", msg); + } + + #[test] + fn test_capture_field_deserialization() { + let toml_str = r#" + [[steps]] + function = "increment" + args = "[]" + capture = "my_result" + + [[steps]] + function = "get" + expected_return = "{{my_result}}" + "#; + + let scenario: Scenario = toml::from_str(toml_str).unwrap(); + assert_eq!(scenario.steps[0].capture.as_deref(), Some("my_result")); + assert!(scenario.steps[1].capture.is_none()); + assert_eq!( + scenario.steps[1].expected_return.as_deref(), + Some("{{my_result}}") + ); + } + #[test] fn test_scenario_deserialization() { let toml_str = r#" diff --git a/tests/command_feature_tests.rs b/tests/command_feature_tests.rs index 1dffc7ff..4ae87cd4 100644 --- a/tests/command_feature_tests.rs +++ b/tests/command_feature_tests.rs @@ -378,6 +378,71 @@ expected_error = "unauthorized" .stdout(predicate::str::contains("Step succeeded with")); } +#[test] +fn scenario_captures_step_output_and_uses_in_expected_return() { + let wasm = fixture_wasm("counter"); + let scenario = NamedTempFile::new().unwrap(); + fs::write( + scenario.path(), + r#" +[[steps]] +name = "Increment" +function = "increment" +args = "[]" +capture = "count" + +[[steps]] +name = "Verify Get matches captured value" +function = "get" +expected_return = "{{count}}" +"#, + ) + .unwrap(); + + base_cmd() + .args([ + "scenario", + "--scenario", + scenario.path().to_str().unwrap(), + "--contract", + wasm.to_str().unwrap(), + ]) + .assert() + .success() + .stdout(predicate::str::contains("Captured return value as 'count'")) + .stdout(predicate::str::contains( + "All scenario steps passed successfully!", + )); +} + +#[test] +fn scenario_fails_on_undefined_variable_in_args() { + let wasm = fixture_wasm("counter"); + let scenario = NamedTempFile::new().unwrap(); + fs::write( + scenario.path(), + r#" +[[steps]] +name = "Reference undefined variable" +function = "increment" +args = "[{{undefined_var}}]" +"#, + ) + .unwrap(); + + base_cmd() + .args([ + "scenario", + "--scenario", + scenario.path().to_str().unwrap(), + "--contract", + wasm.to_str().unwrap(), + ]) + .assert() + .failure() + .stderr(predicate::str::contains("undefined_var")); +} + #[test] fn repl_accepts_commands_and_exits() { let wasm = fixture_wasm("counter");