Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
147 changes: 145 additions & 2 deletions src/scenario.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,6 +29,10 @@ pub struct ScenarioStep {
pub expected_error: Option<String>,
/// When set, the step is expected to panic with a message containing this substring.
pub expected_panic: Option<String>,
/// 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<String>,
}

#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
Expand Down Expand Up @@ -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<String, String> = HashMap::new();

for (i, step) in scenario.steps.iter().enumerate() {
let step_label = step.name.as_deref().unwrap_or(&step.function);
Expand All @@ -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
Expand All @@ -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!(
" {}",
Expand Down Expand Up @@ -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<String, String>,
) -> Result<String> {
let re = Regex::new(r"\{\{(\w+)\}\}").unwrap();

// Collect any missing variable names first for a clear error message.
let missing: Vec<String> = 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::<Vec<_>>()
.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: &regex::Captures| {
variables[&caps[1]].clone()
});

Ok(result.into_owned())
}

fn assert_expected_events(
expected_events: &[ScenarioEventAssertion],
actual_events: &[ContractEvent],
Expand Down Expand Up @@ -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<String, String> = 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<String, String> = 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#"
Expand Down
65 changes: 65 additions & 0 deletions tests/command_feature_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading