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