Skip to main content

debugger/testing/
runner.rs

1//! Test runner implementation
2//!
3//! Executes test scenarios by communicating directly with the daemon
4//! using structured data rather than parsing CLI output.
5
6use std::path::Path;
7use std::process::Stdio;
8
9use colored::Colorize;
10use tokio::process::Command as TokioCommand;
11
12use crate::cli::spawn::ensure_daemon_running;
13use crate::common::{Error, Result};
14use crate::ipc::protocol::{
15    BreakpointLocation, Command, EvaluateContext, EvaluateResult, StackFrameInfo,
16    StopResult, VariableInfo,
17};
18use crate::ipc::DaemonClient;
19
20use super::config::{
21    CommandExpectation, EvaluateExpectation, FrameAssertion, StopExpectation, TestScenario,
22    TestStep, VariableAssertion,
23};
24
25/// Result of a test run
26#[derive(Debug)]
27pub struct TestResult {
28    pub name: String,
29    pub passed: bool,
30    pub steps_run: usize,
31    pub steps_total: usize,
32    pub error: Option<String>,
33}
34
35/// Run a test scenario from a YAML file
36pub async fn run_scenario(path: &Path, verbose: bool) -> Result<TestResult> {
37    // Load and parse the YAML scenario
38    let content = std::fs::read_to_string(path).map_err(|e| {
39        Error::Config(format!(
40            "Failed to read test scenario '{}': {}",
41            path.display(),
42            e
43        ))
44    })?;
45
46    let scenario: TestScenario = serde_yaml::from_str(&content)
47        .map_err(|e| Error::Config(format!("Failed to parse test scenario: {}", e)))?;
48
49    let steps_total = scenario.steps.len();
50
51    println!(
52        "\n{} {}",
53        "Running Test:".blue().bold(),
54        scenario.name.white().bold()
55    );
56
57    if let Some(desc) = &scenario.description {
58        println!("  {}", desc.dimmed());
59    }
60
61    // Run setup steps
62    if let Some(setup_steps) = &scenario.setup {
63        println!("\n{}", "Setup:".cyan());
64        for step in setup_steps {
65            if verbose {
66                println!("  $ {}", step.shell.dimmed());
67            }
68
69            let status = TokioCommand::new("sh")
70                .arg("-c")
71                .arg(&step.shell)
72                .stdin(Stdio::null())
73                .stdout(if verbose {
74                    Stdio::inherit()
75                } else {
76                    Stdio::null()
77                })
78                .stderr(if verbose {
79                    Stdio::inherit()
80                } else {
81                    Stdio::null()
82                })
83                .status()
84                .await
85                .map_err(|e| Error::Config(format!("Setup command failed to execute: {}", e)))?;
86
87            if !status.success() {
88                return Ok(TestResult {
89                    name: scenario.name.clone(),
90                    passed: false,
91                    steps_run: 0,
92                    steps_total,
93                    error: Some(format!(
94                        "Setup command '{}' failed with exit code {:?}",
95                        step.shell,
96                        status.code()
97                    )),
98                });
99            }
100            println!("  {} {}", "✓".green(), step.shell.dimmed());
101        }
102    }
103
104    // Ensure daemon is running
105    ensure_daemon_running().await?;
106    let mut client = DaemonClient::connect().await?;
107
108    // Resolve program path relative to the scenario file
109    let scenario_dir = path.parent().unwrap_or(Path::new("."));
110    let program_path = if scenario.target.program.is_relative() {
111        scenario_dir.join(&scenario.target.program)
112    } else {
113        scenario.target.program.clone()
114    };
115
116    let program_path = program_path.canonicalize().map_err(|e| {
117        Error::Config(format!(
118            "Program not found '{}': {}",
119            scenario.target.program.display(),
120            e
121        ))
122    })?;
123
124    // Start the debug session
125    println!("\n{}", "Starting debug session...".cyan());
126    client
127        .send_command(Command::Start {
128            program: program_path.clone(),
129            args: scenario.target.args.clone().unwrap_or_default(),
130            adapter: scenario.target.adapter.clone(),
131            stop_on_entry: scenario.target.stop_on_entry,
132            initial_breakpoints: Vec::new(),
133        })
134        .await?;
135
136    if verbose {
137        println!(
138            "  Program: {}",
139            program_path.display().to_string().dimmed()
140        );
141        if let Some(adapter) = &scenario.target.adapter {
142            println!("  Adapter: {}", adapter.dimmed());
143        }
144    }
145
146    println!("  {} Session started", "✓".green());
147
148    // Execute test steps
149    println!("\n{}", "Steps:".cyan());
150
151    for (i, step) in scenario.steps.iter().enumerate() {
152        let step_num = i + 1;
153
154        match execute_step(&mut client, step, step_num, verbose).await {
155            Ok(()) => {
156                // Step passed
157            }
158            Err(e) => {
159                println!("  {} Step {}: {}", "✗".red(), step_num, e);
160
161                // Cleanup: stop the debug session
162                let _ = client.send_command(Command::Stop).await;
163
164                return Ok(TestResult {
165                    name: scenario.name.clone(),
166                    passed: false,
167                    steps_run: step_num,
168                    steps_total,
169                    error: Some(e.to_string()),
170                });
171            }
172        }
173    }
174
175    // Cleanup: stop the debug session
176    let _ = client.send_command(Command::Stop).await;
177
178    println!(
179        "\n{} {}\n",
180        "✓".green().bold(),
181        "Test Passed".green().bold()
182    );
183
184    Ok(TestResult {
185        name: scenario.name,
186        passed: true,
187        steps_run: steps_total,
188        steps_total,
189        error: None,
190    })
191}
192
193/// Execute a single test step
194async fn execute_step(
195    client: &mut DaemonClient,
196    step: &TestStep,
197    step_num: usize,
198    verbose: bool,
199) -> Result<()> {
200    match step {
201        TestStep::Command { command, expect } => {
202            execute_command_step(client, command, expect.as_ref(), step_num, verbose).await
203        }
204        TestStep::Await { timeout, expect } => {
205            execute_await_step(client, *timeout, expect.as_ref(), step_num, verbose).await
206        }
207        TestStep::InspectLocals { asserts } => {
208            execute_inspect_locals_step(client, asserts, step_num, verbose).await
209        }
210        TestStep::InspectStack { asserts } => {
211            execute_inspect_stack_step(client, asserts, step_num, verbose).await
212        }
213        TestStep::CheckOutput { contains, equals } => {
214            execute_check_output_step(client, contains.as_ref(), equals.as_ref(), step_num, verbose)
215                .await
216        }
217        TestStep::Evaluate { expression, expect } => {
218            execute_evaluate_step(client, expression, expect.as_ref(), step_num, verbose).await
219        }
220    }
221}
222
223/// Execute a command step
224async fn execute_command_step(
225    client: &mut DaemonClient,
226    command_str: &str,
227    expect: Option<&CommandExpectation>,
228    step_num: usize,
229    _verbose: bool,
230) -> Result<()> {
231    let cmd = parse_command(command_str)?;
232
233    let result = client.send_command(cmd).await;
234
235    // Check expectations
236    if let Some(exp) = expect {
237        if let Some(should_succeed) = exp.success {
238            let did_succeed = result.is_ok();
239            if should_succeed != did_succeed {
240                return Err(Error::TestAssertion(format!(
241                    "Command '{}' expected success={}, got success={}",
242                    command_str, should_succeed, did_succeed
243                )));
244            }
245        }
246    }
247
248    // For commands that are expected to fail, we don't propagate the error
249    if expect.map(|e| e.success == Some(false)).unwrap_or(false) {
250        println!(
251            "  {} Step {}: {} (expected failure)",
252            "✓".green(),
253            step_num,
254            command_str.dimmed()
255        );
256        return Ok(());
257    }
258
259    result?;
260
261    println!(
262        "  {} Step {}: {}",
263        "✓".green(),
264        step_num,
265        command_str.dimmed()
266    );
267
268    Ok(())
269}
270
271/// Execute an await step
272async fn execute_await_step(
273    client: &mut DaemonClient,
274    timeout: Option<u64>,
275    expect: Option<&StopExpectation>,
276    step_num: usize,
277    _verbose: bool,
278) -> Result<()> {
279    let timeout_secs = timeout.unwrap_or(30);
280
281    let result = client
282        .send_command(Command::Await { timeout_secs })
283        .await?;
284
285    let stop_result: StopResult = serde_json::from_value(result)
286        .map_err(|e| Error::TestAssertion(format!("Failed to parse stop result: {}", e)))?;
287
288    // Check expectations
289    if let Some(exp) = expect {
290        if let Some(expected_reason) = &exp.reason {
291            if !stop_result.reason.contains(expected_reason) {
292                return Err(Error::TestAssertion(format!(
293                    "Expected stop reason '{}', got '{}'",
294                    expected_reason, stop_result.reason
295                )));
296            }
297        }
298
299        if let Some(expected_file) = &exp.file {
300            let actual_file = stop_result.source.as_deref().unwrap_or("");
301            if !actual_file.contains(expected_file) {
302                return Err(Error::TestAssertion(format!(
303                    "Expected file '{}', got '{}'",
304                    expected_file, actual_file
305                )));
306            }
307        }
308
309        if let Some(expected_line) = exp.line {
310            let actual_line = stop_result.line.unwrap_or(0);
311            if expected_line != actual_line {
312                return Err(Error::TestAssertion(format!(
313                    "Expected line {}, got {}",
314                    expected_line, actual_line
315                )));
316            }
317        }
318    }
319
320    let location = if let Some(source) = &stop_result.source {
321        if let Some(line) = stop_result.line {
322            format!("{}:{}", source, line)
323        } else {
324            source.clone()
325        }
326    } else {
327        "unknown location".to_string()
328    };
329
330    println!(
331        "  {} Step {}: await ({} at {})",
332        "✓".green(),
333        step_num,
334        stop_result.reason.dimmed(),
335        location.dimmed()
336    );
337
338    Ok(())
339}
340
341/// Execute an inspect locals step
342async fn execute_inspect_locals_step(
343    client: &mut DaemonClient,
344    asserts: &[VariableAssertion],
345    step_num: usize,
346    _verbose: bool,
347) -> Result<()> {
348    let result = client
349        .send_command(Command::Locals { frame_id: None })
350        .await?;
351
352    let vars: Vec<VariableInfo> = serde_json::from_value(result["variables"].clone())
353        .map_err(|e| Error::TestAssertion(format!("Failed to parse variables: {}", e)))?;
354
355    for assertion in asserts {
356        let var = vars.iter().find(|v| v.name == assertion.name);
357
358        match var {
359            Some(v) => {
360                // Check value (exact match)
361                if let Some(expected_value) = &assertion.value {
362                    if &v.value != expected_value {
363                        return Err(Error::TestAssertion(format!(
364                            "Variable '{}': expected value '{}', got '{}'",
365                            assertion.name, expected_value, v.value
366                        )));
367                    }
368                }
369
370                // Check value (partial match)
371                if let Some(expected_substr) = &assertion.value_contains {
372                    if !v.value.contains(expected_substr) {
373                        return Err(Error::TestAssertion(format!(
374                            "Variable '{}': expected value containing '{}', got '{}'",
375                            assertion.name, expected_substr, v.value
376                        )));
377                    }
378                }
379
380                // Check type
381                if let Some(expected_type) = &assertion.type_name {
382                    let actual_type = v.type_name.as_deref().unwrap_or("");
383                    if actual_type != expected_type {
384                        return Err(Error::TestAssertion(format!(
385                            "Variable '{}': expected type '{}', got '{}'",
386                            assertion.name, expected_type, actual_type
387                        )));
388                    }
389                }
390            }
391            None => {
392                let available: Vec<&str> = vars.iter().map(|v| v.name.as_str()).collect();
393                return Err(Error::TestAssertion(format!(
394                    "Variable '{}' not found. Available: {:?}",
395                    assertion.name, available
396                )));
397            }
398        }
399    }
400
401    let checked: Vec<&str> = asserts.iter().map(|a| a.name.as_str()).collect();
402    println!(
403        "  {} Step {}: inspect locals ({:?})",
404        "✓".green(),
405        step_num,
406        checked
407    );
408
409    Ok(())
410}
411
412/// Execute an inspect stack step
413async fn execute_inspect_stack_step(
414    client: &mut DaemonClient,
415    asserts: &[FrameAssertion],
416    step_num: usize,
417    _verbose: bool,
418) -> Result<()> {
419    let result = client
420        .send_command(Command::StackTrace {
421            thread_id: None,
422            limit: 50,
423        })
424        .await?;
425
426    let frames: Vec<StackFrameInfo> = serde_json::from_value(result["frames"].clone())
427        .map_err(|e| Error::TestAssertion(format!("Failed to parse stack frames: {}", e)))?;
428
429    for assertion in asserts {
430        if assertion.index >= frames.len() {
431            return Err(Error::TestAssertion(format!(
432                "Frame {} does not exist (only {} frames)",
433                assertion.index,
434                frames.len()
435            )));
436        }
437
438        let frame = &frames[assertion.index];
439
440        if let Some(expected_func) = &assertion.function {
441            if !frame.name.contains(expected_func) {
442                return Err(Error::TestAssertion(format!(
443                    "Frame {}: expected function '{}', got '{}'",
444                    assertion.index, expected_func, frame.name
445                )));
446            }
447        }
448
449        if let Some(expected_file) = &assertion.file {
450            let actual_file = frame.source.as_deref().unwrap_or("");
451            if !actual_file.contains(expected_file) {
452                return Err(Error::TestAssertion(format!(
453                    "Frame {}: expected file '{}', got '{}'",
454                    assertion.index, expected_file, actual_file
455                )));
456            }
457        }
458
459        if let Some(expected_line) = assertion.line {
460            let actual_line = frame.line.unwrap_or(0);
461            if expected_line != actual_line {
462                return Err(Error::TestAssertion(format!(
463                    "Frame {}: expected line {}, got {}",
464                    assertion.index, expected_line, actual_line
465                )));
466            }
467        }
468    }
469
470    println!(
471        "  {} Step {}: inspect stack ({} frames checked)",
472        "✓".green(),
473        step_num,
474        asserts.len()
475    );
476
477    Ok(())
478}
479
480/// Execute a check output step
481async fn execute_check_output_step(
482    client: &mut DaemonClient,
483    contains: Option<&String>,
484    equals: Option<&String>,
485    step_num: usize,
486    _verbose: bool,
487) -> Result<()> {
488    let result = client
489        .send_command(Command::GetOutput {
490            tail: None,
491            clear: false,
492        })
493        .await?;
494
495    let output = result["output"].as_str().unwrap_or("");
496
497    if let Some(expected_substr) = contains {
498        if !output.contains(expected_substr) {
499            return Err(Error::TestAssertion(format!(
500                "Output does not contain '{}'. Got: '{}'",
501                expected_substr,
502                if output.len() > 200 {
503                    format!("{}...", &output[..200])
504                } else {
505                    output.to_string()
506                }
507            )));
508        }
509    }
510
511    if let Some(expected_exact) = equals {
512        if output.trim() != expected_exact.trim() {
513            return Err(Error::TestAssertion(format!(
514                "Output mismatch. Expected: '{}', got: '{}'",
515                expected_exact, output
516            )));
517        }
518    }
519
520    println!(
521        "  {} Step {}: check output",
522        "✓".green(),
523        step_num
524    );
525
526    Ok(())
527}
528
529/// Execute an evaluate step
530async fn execute_evaluate_step(
531    client: &mut DaemonClient,
532    expression: &str,
533    expect: Option<&EvaluateExpectation>,
534    step_num: usize,
535    _verbose: bool,
536) -> Result<()> {
537    let result = client
538        .send_command(Command::Evaluate {
539            expression: expression.to_string(),
540            frame_id: None,
541            context: EvaluateContext::Watch,
542        })
543        .await?;
544
545    let eval_result: EvaluateResult = serde_json::from_value(result)
546        .map_err(|e| Error::TestAssertion(format!("Failed to parse evaluate result: {}", e)))?;
547
548    if let Some(exp) = expect {
549        if let Some(expected_result) = &exp.result {
550            if &eval_result.result != expected_result {
551                return Err(Error::TestAssertion(format!(
552                    "Evaluate '{}': expected '{}', got '{}'",
553                    expression, expected_result, eval_result.result
554                )));
555            }
556        }
557
558        if let Some(expected_substr) = &exp.result_contains {
559            if !eval_result.result.contains(expected_substr) {
560                return Err(Error::TestAssertion(format!(
561                    "Evaluate '{}': expected result containing '{}', got '{}'",
562                    expression, expected_substr, eval_result.result
563                )));
564            }
565        }
566
567        if let Some(expected_type) = &exp.type_name {
568            let actual_type = eval_result.type_name.as_deref().unwrap_or("");
569            if actual_type != expected_type {
570                return Err(Error::TestAssertion(format!(
571                    "Evaluate '{}': expected type '{}', got '{}'",
572                    expression, expected_type, actual_type
573                )));
574            }
575        }
576    }
577
578    println!(
579        "  {} Step {}: evaluate '{}' = {}",
580        "✓".green(),
581        step_num,
582        expression.dimmed(),
583        eval_result.result.dimmed()
584    );
585
586    Ok(())
587}
588
589/// Parse a command string into a Command enum
590fn parse_command(s: &str) -> Result<Command> {
591    let parts: Vec<&str> = s.split_whitespace().collect();
592    if parts.is_empty() {
593        return Err(Error::Config("Empty command".to_string()));
594    }
595
596    let cmd = parts[0].to_lowercase();
597    let args = &parts[1..];
598
599    match cmd.as_str() {
600        "continue" | "c" => Ok(Command::Continue),
601        "next" | "n" => Ok(Command::Next),
602        "step" | "s" => Ok(Command::StepIn),
603        "finish" | "out" => Ok(Command::StepOut),
604        "pause" => Ok(Command::Pause),
605
606        "break" | "b" => {
607            if args.is_empty() {
608                return Err(Error::Config(
609                    "break command requires a location".to_string(),
610                ));
611            }
612            // Handle "break add <location>" or just "break <location>"
613            let location_str = if args[0] == "add" && args.len() > 1 {
614                args[1..].join(" ")
615            } else {
616                args.join(" ")
617            };
618
619            let location = BreakpointLocation::parse(&location_str)?;
620            Ok(Command::BreakpointAdd {
621                location,
622                condition: None,
623                hit_count: None,
624            })
625        }
626
627        "breakpoint" => {
628            if args.is_empty() {
629                return Err(Error::Config(
630                    "breakpoint command requires a subcommand".to_string(),
631                ));
632            }
633
634            match args[0] {
635                "add" => {
636                    if args.len() < 2 {
637                        return Err(Error::Config(
638                            "breakpoint add requires a location".to_string(),
639                        ));
640                    }
641                    let location = BreakpointLocation::parse(args[1])?;
642                    Ok(Command::BreakpointAdd {
643                        location,
644                        condition: None,
645                        hit_count: None,
646                    })
647                }
648                "remove" => {
649                    if args.len() < 2 {
650                        return Ok(Command::BreakpointRemove { id: None, all: true });
651                    }
652                    if args[1] == "all" || args[1] == "--all" {
653                        return Ok(Command::BreakpointRemove { id: None, all: true });
654                    }
655                    let id: u32 = args[1].parse().map_err(|_| {
656                        Error::Config(format!("Invalid breakpoint ID: {}", args[1]))
657                    })?;
658                    Ok(Command::BreakpointRemove {
659                        id: Some(id),
660                        all: false,
661                    })
662                }
663                "list" => Ok(Command::BreakpointList),
664                "enable" => {
665                    if args.len() < 2 {
666                        return Err(Error::Config(
667                            "breakpoint enable requires an ID".to_string(),
668                        ));
669                    }
670                    let id: u32 = args[1].parse().map_err(|_| {
671                        Error::Config(format!("Invalid breakpoint ID: {}", args[1]))
672                    })?;
673                    Ok(Command::BreakpointEnable { id })
674                }
675                "disable" => {
676                    if args.len() < 2 {
677                        return Err(Error::Config(
678                            "breakpoint disable requires an ID".to_string(),
679                        ));
680                    }
681                    let id: u32 = args[1].parse().map_err(|_| {
682                        Error::Config(format!("Invalid breakpoint ID: {}", args[1]))
683                    })?;
684                    Ok(Command::BreakpointDisable { id })
685                }
686                _ => Err(Error::Config(format!(
687                    "Unknown breakpoint subcommand: {}",
688                    args[0]
689                ))),
690            }
691        }
692
693        "locals" => Ok(Command::Locals { frame_id: None }),
694
695        "backtrace" | "bt" => Ok(Command::StackTrace {
696            thread_id: None,
697            limit: 20,
698        }),
699
700        "threads" => Ok(Command::Threads),
701
702        "thread" => {
703            if args.is_empty() {
704                return Err(Error::Config("thread command requires an ID".to_string()));
705            }
706            let id: i64 = args[0]
707                .parse()
708                .map_err(|_| Error::Config(format!("Invalid thread ID: {}", args[0])))?;
709            Ok(Command::ThreadSelect { id })
710        }
711
712        "frame" => {
713            if args.is_empty() {
714                return Err(Error::Config(
715                    "frame command requires a number".to_string(),
716                ));
717            }
718            let number: usize = args[0]
719                .parse()
720                .map_err(|_| Error::Config(format!("Invalid frame number: {}", args[0])))?;
721            Ok(Command::FrameSelect { number })
722        }
723
724        "up" => Ok(Command::FrameUp),
725        "down" => Ok(Command::FrameDown),
726
727        "print" | "p" | "eval" => {
728            if args.is_empty() {
729                return Err(Error::Config(
730                    "print/eval command requires an expression".to_string(),
731                ));
732            }
733            Ok(Command::Evaluate {
734                expression: args.join(" "),
735                frame_id: None,
736                context: EvaluateContext::Watch,
737            })
738        }
739
740        "stop" => Ok(Command::Stop),
741        "detach" => Ok(Command::Detach),
742        "restart" => Ok(Command::Restart),
743
744        _ => Err(Error::Config(format!("Unknown command: {}", cmd))),
745    }
746}
747
748#[cfg(test)]
749mod tests {
750    use super::*;
751
752    #[test]
753    fn test_parse_simple_commands() {
754        assert!(matches!(parse_command("continue").unwrap(), Command::Continue));
755        assert!(matches!(parse_command("c").unwrap(), Command::Continue));
756        assert!(matches!(parse_command("next").unwrap(), Command::Next));
757        assert!(matches!(parse_command("step").unwrap(), Command::StepIn));
758        assert!(matches!(parse_command("finish").unwrap(), Command::StepOut));
759        assert!(matches!(parse_command("pause").unwrap(), Command::Pause));
760    }
761
762    #[test]
763    fn test_parse_break_commands() {
764        let cmd = parse_command("break main").unwrap();
765        assert!(matches!(cmd, Command::BreakpointAdd { .. }));
766
767        let cmd = parse_command("break add main.rs:42").unwrap();
768        assert!(matches!(cmd, Command::BreakpointAdd { .. }));
769
770        let cmd = parse_command("b foo.c:10").unwrap();
771        assert!(matches!(cmd, Command::BreakpointAdd { .. }));
772    }
773
774    #[test]
775    fn test_parse_breakpoint_subcommands() {
776        assert!(matches!(
777            parse_command("breakpoint add main").unwrap(),
778            Command::BreakpointAdd { .. }
779        ));
780        assert!(matches!(
781            parse_command("breakpoint list").unwrap(),
782            Command::BreakpointList
783        ));
784        assert!(matches!(
785            parse_command("breakpoint remove 1").unwrap(),
786            Command::BreakpointRemove { .. }
787        ));
788    }
789
790    #[test]
791    fn test_parse_print_commands() {
792        let cmd = parse_command("print x + y").unwrap();
793        match cmd {
794            Command::Evaluate { expression, .. } => {
795                assert_eq!(expression, "x + y");
796            }
797            _ => panic!("Expected Evaluate command"),
798        }
799    }
800}