//! Vitest framework implementation using `vitest list --json` for discovery.

use std::collections::{HashMap, HashSet};
use std::path::PathBuf;

use async_trait::async_trait;

use super::{
    FrameworkError, FrameworkResult, TestFramework, TestInstance, TestRecord,
    discovery_error_detail,
};
use crate::config::VitestFrameworkConfig;
use crate::provider::Command;
use crate::report::junit::TestsuiteXml;

/// Test framework for JavaScript/TypeScript vitest projects.
///
/// Uses `vitest list --json` for test discovery and
/// JSON output with `--reporter=json` for results,
/// which are converted to JUnit XML via `xml_from_report`.
///
/// Test IDs use `{file} > {name}` format, e.g.
/// `tests/math.test.ts > math > add > adds two`.
pub struct VitestFramework {
    config: VitestFrameworkConfig,
    /// The program to invoke (first token of `command`).
    program: String,
    /// Additional arguments parsed from `command` (tokens after the program).
    prefix_args: Vec<String>,
}

impl VitestFramework {
    /// Creates a new vitest framework, validating the command at construction time.
    pub fn new(config: VitestFrameworkConfig) -> FrameworkResult<Self> {
        let mut parts = shell_words::split(&config.command).map_err(|e| {
            FrameworkError::DiscoveryFailed(format!(
                "Failed to parse command '{}': {}",
                config.command, e
            ))
        })?;

        if parts.is_empty() {
            return Err(FrameworkError::DiscoveryFailed(format!(
                "Command '{}' produced no tokens after parsing",
                config.command
            )));
        }

        let program = parts.remove(0);
        let prefix_args = parts;

        Ok(Self {
            config,
            program,
            prefix_args,
        })
    }

    /// Parse vitest JSON discovery output into test records.
    ///
    /// The `cwd` is used to make absolute file paths relative.
    /// Test ID format: `{relative_file} > {name}`.
    fn parse_discovery_json(
        &self,
        json_str: &str,
        cwd: &std::path::Path,
        group: &str,
    ) -> FrameworkResult<Vec<TestRecord>> {
        let items: Vec<VitestListItem> = serde_json::from_str(json_str).map_err(|e| {
            FrameworkError::ParseError(format!("Failed to parse vitest list JSON: {}", e))
        })?;

        let mut tests = Vec::with_capacity(items.len());
        for item in items {
            let file_path = PathBuf::from(&item.file);
            let relative = file_path
                .strip_prefix(cwd)
                .unwrap_or(&file_path)
                .to_string_lossy();

            let test_id = format!("{} > {}", relative, item.name);
            tests.push(TestRecord::new(&test_id, group));
        }

        Ok(tests)
    }
}

/// Convert ` > ` separators to spaces to match vitest's internal name format.
///
/// `vitest list --json` returns names with ` > ` between describe/test titles,
/// but `--testNamePattern` matches against names joined with spaces.
fn to_vitest_match_name(name: &str) -> String {
    name.replace(" > ", " ")
}

/// Check that no two test records produce the same space-separated name.
/// Vitest `--testNamePattern` matches by the space-separated full name, so
/// duplicates would cause over-selection.
fn check_unique_test_names(tests: &[TestRecord]) -> FrameworkResult<()> {
    let mut seen: HashMap<String, usize> = HashMap::new();
    for t in tests {
        let name =
            t.id.split_once(" > ")
                .map(|(_, n)| to_vitest_match_name(n))
                .unwrap_or_else(|| t.id.clone());
        *seen.entry(name).or_insert(0) += 1;
    }

    let duplicates: Vec<&str> = seen
        .iter()
        .filter(|(_, count)| **count > 1)
        .map(|(name, _)| name.as_str())
        .collect();

    if !duplicates.is_empty() {
        let mut sorted = duplicates;
        sorted.sort_unstable();
        return Err(FrameworkError::DiscoveryFailed(format!(
            "Duplicate test names found (space-separated): {}\n\
             Vitest --testNamePattern matches by the space-joined describe chain + \
             test name, so it cannot distinguish tests that produce the same pattern. \
             To fix this, rename the duplicate test()/it() calls, or wrap them in \
             uniquely named describe() blocks so the full name differs.",
            sorted.join(", ")
        )));
    }

    Ok(())
}

/// A single test entry from vitest's JSON list output.
#[derive(Debug, serde::Deserialize)]
struct VitestListItem {
    name: String,
    file: String,
}

/// Vitest JSON report structure (top level).
#[derive(Debug, serde::Deserialize)]
struct VitestJsonReport {
    #[serde(rename = "testResults")]
    test_results: Vec<VitestJsonTestResult>,
}

/// A single test file's results from vitest JSON reporter.
#[derive(Debug, serde::Deserialize)]
struct VitestJsonTestResult {
    /// Absolute path to the test file.
    name: String,
    #[serde(rename = "assertionResults")]
    assertion_results: Vec<VitestJsonAssertionResult>,
}

/// A single test assertion result from vitest JSON reporter.
#[derive(Debug, serde::Deserialize)]
struct VitestJsonAssertionResult {
    #[serde(rename = "ancestorTitles")]
    ancestor_titles: Vec<String>,
    title: String,
    status: String,
    duration: Option<f64>,
    #[serde(default, rename = "failureMessages")]
    failure_messages: Vec<String>,
}

#[async_trait]
impl TestFramework for VitestFramework {
    async fn discover(
        &self,
        _paths: &[PathBuf],
        filters: &str,
        group: &str,
    ) -> FrameworkResult<Vec<TestRecord>> {
        let mut cmd = tokio::process::Command::new(&self.program);
        for arg in &self.prefix_args {
            cmd.arg(arg);
        }
        cmd.arg("list").arg("--json");

        if !filters.is_empty() {
            let args = shell_words::split(filters).map_err(|e| {
                FrameworkError::DiscoveryFailed(format!(
                    "Invalid filter string '{}': {}",
                    filters, e
                ))
            })?;
            for arg in args {
                cmd.arg(arg);
            }
        }

        // Build a display string for the command before running it
        let mut cmd_parts: Vec<&str> = Vec::new();
        cmd_parts.push(&self.program);
        for arg in &self.prefix_args {
            cmd_parts.push(arg);
        }
        cmd_parts.push("list");
        cmd_parts.push("--json");
        let filter_display: String;
        if !filters.is_empty() {
            filter_display = filters.to_string();
            cmd_parts.push(&filter_display);
        }
        let cmd_display = cmd_parts.join(" ");

        let output = cmd
            .output()
            .await
            .map_err(|e| FrameworkError::DiscoveryFailed(e.to_string()))?;

        let stdout = String::from_utf8_lossy(&output.stdout);
        let stderr = String::from_utf8_lossy(&output.stderr);

        if !output.status.success() {
            let detail = discovery_error_detail(&stderr, &stdout);
            return Err(FrameworkError::DiscoveryFailed(format!(
                "vitest list failed ({}):\n  command: {}\n  {}",
                output.status, cmd_display, detail
            )));
        }

        let cwd = std::env::current_dir().map_err(|e| {
            FrameworkError::DiscoveryFailed(format!("Failed to get current directory: {}", e))
        })?;

        let tests = self.parse_discovery_json(&stdout, &cwd, group)?;

        check_unique_test_names(&tests)?;

        if tests.is_empty() {
            tracing::warn!(
                "No tests discovered. Output: {}",
                discovery_error_detail(&stderr, &stdout)
            );
        }

        Ok(tests)
    }

    fn produce_test_execution_command(
        &self,
        tests: &[TestInstance],
        result_path: &str,
        fail_fast: bool,
    ) -> Command {
        let mut cmd = Command::new(&self.program);
        for arg in &self.prefix_args {
            cmd = cmd.arg(arg);
        }

        cmd = cmd.arg("run");

        if fail_fast {
            cmd = cmd.arg("--bail");
        }

        // Build --testNamePattern from the name part of each test ID.
        // Test IDs are `{file} > {name}`; we extract the name, convert
        // ` > ` to spaces (vitest's internal matching format), and
        // escape regex metacharacters.
        let mut escaped_names: Vec<String> = Vec::new();
        for t in tests {
            let name = t.id().split_once(" > ").map(|(_, n)| n).unwrap_or(t.id());
            escaped_names.push(regex::escape(&to_vitest_match_name(name)));
        }

        escaped_names.sort_unstable();
        escaped_names.dedup();
        if !escaped_names.is_empty() {
            let pattern = format!("^({})$", escaped_names.join("|"));
            cmd = cmd.arg("--testNamePattern").arg(pattern);
        }

        cmd = cmd
            .arg("--reporter=verbose")
            .arg("--reporter=json")
            .arg(format!("--outputFile={}", result_path));

        if let Some(run_args) = &self.config.run_args {
            match shell_words::split(run_args) {
                Ok(args) => {
                    for arg in args {
                        cmd = cmd.arg(arg);
                    }
                }
                Err(e) => {
                    tracing::warn!("Failed to parse run_args '{}': {}", run_args, e);
                }
            }
        }

        cmd
    }

    fn report_format(&self) -> &str {
        "json"
    }

    fn xml_from_report(&self, raw_output: &str) -> super::FrameworkResult<String> {
        use quick_xml::Writer;
        use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
        use std::io::Cursor;

        let report: VitestJsonReport = serde_json::from_str(raw_output).map_err(|e| {
            super::FrameworkError::ParseError(format!("Failed to parse vitest JSON output: {}", e))
        })?;

        let cwd_str = std::env::current_dir()
            .map(|p| p.to_string_lossy().to_string())
            .unwrap_or_default();

        let mut writer = Writer::new_with_indent(Cursor::new(Vec::new()), b' ', 2);

        let _ = writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)));

        // Collect suite data first to compute totals.
        struct SuiteData {
            name: String,
            tests: i32,
            failures: i32,
            time: f64,
            cases: Vec<CaseData>,
        }
        struct CaseData {
            classname: String,
            name: String,
            time: f64,
            failure_message: Option<String>,
        }

        let mut suites = Vec::new();
        let mut total_tests = 0;
        let mut total_failures = 0;
        let mut total_time = 0.0;

        for test_result in &report.test_results {
            let file = test_result
                .name
                .strip_prefix(&cwd_str)
                .unwrap_or(&test_result.name)
                .trim_start_matches('/');

            let file_string = file.to_string();
            let mut suite = SuiteData {
                name: file_string.clone(),
                tests: 0,
                failures: 0,
                time: 0.0,
                cases: Vec::new(),
            };

            for ar in &test_result.assertion_results {
                if ar.status == "pending" || ar.status == "todo" || ar.status == "skipped" {
                    continue;
                }

                let name = ar
                    .ancestor_titles
                    .iter()
                    .chain(std::iter::once(&ar.title))
                    .cloned()
                    .collect::<Vec<_>>()
                    .join(" > ");

                let classname = file_string.clone();

                let duration_secs = ar.duration.unwrap_or(0.0) / 1000.0;
                let failed = ar.status == "failed";

                suite.tests += 1;
                suite.time += duration_secs;
                if failed {
                    suite.failures += 1;
                }

                suite.cases.push(CaseData {
                    classname,
                    name,
                    time: duration_secs,
                    failure_message: if failed {
                        ar.failure_messages.first().cloned()
                    } else {
                        None
                    },
                });
            }

            total_tests += suite.tests;
            total_failures += suite.failures;
            total_time += suite.time;
            suites.push(suite);
        }

        // Write <testsuites>
        let mut ts_elem = BytesStart::new("testsuites");
        ts_elem.push_attribute(("name", "vitest tests"));
        let tests_str = total_tests.to_string();
        let failures_str = total_failures.to_string();
        let time_str = format!("{:.6}", total_time);
        ts_elem.push_attribute(("tests", tests_str.as_str()));
        ts_elem.push_attribute(("failures", failures_str.as_str()));
        ts_elem.push_attribute(("errors", "0"));
        ts_elem.push_attribute(("time", time_str.as_str()));
        let _ = writer.write_event(Event::Start(ts_elem));

        for suite in &suites {
            if suite.cases.is_empty() {
                continue;
            }

            let mut s_elem = BytesStart::new("testsuite");
            s_elem.push_attribute(("name", suite.name.as_str()));
            let suite_tests_str = suite.tests.to_string();
            let suite_failures_str = suite.failures.to_string();
            let suite_time_str = format!("{:.6}", suite.time);
            s_elem.push_attribute(("tests", suite_tests_str.as_str()));
            s_elem.push_attribute(("failures", suite_failures_str.as_str()));
            s_elem.push_attribute(("errors", "0"));
            s_elem.push_attribute(("skipped", "0"));
            s_elem.push_attribute(("time", suite_time_str.as_str()));
            let _ = writer.write_event(Event::Start(s_elem));

            for tc in &suite.cases {
                let mut tc_elem = BytesStart::new("testcase");
                tc_elem.push_attribute(("classname", tc.classname.as_str()));
                tc_elem.push_attribute(("name", tc.name.as_str()));
                tc_elem.push_attribute(("time", format!("{:.6}", tc.time).as_str()));

                if let Some(ref msg) = tc.failure_message {
                    let _ = writer.write_event(Event::Start(tc_elem));

                    let mut fail_elem = BytesStart::new("failure");
                    let first_line = msg.lines().next().unwrap_or("");
                    fail_elem.push_attribute(("message", first_line));
                    let _ = writer.write_event(Event::Start(fail_elem));
                    let _ = writer.write_event(Event::Text(BytesText::new(msg)));
                    let _ = writer.write_event(Event::End(BytesEnd::new("failure")));

                    let _ = writer.write_event(Event::End(BytesEnd::new("testcase")));
                } else {
                    let _ = writer.write_event(Event::Empty(tc_elem));
                }
            }

            let _ = writer.write_event(Event::End(BytesEnd::new("testsuite")));
        }

        let _ = writer.write_event(Event::End(BytesEnd::new("testsuites")));

        String::from_utf8(writer.into_inner().into_inner()).map_err(|e| {
            super::FrameworkError::ParseError(format!("Failed to encode JUnit XML as UTF-8: {}", e))
        })
    }

    fn resolve_test_ids(
        &self,
        testsuites: &mut [TestsuiteXml],
        batch_test_ids: &[String],
    ) -> super::FrameworkResult<()> {
        let batch_set: HashSet<&str> = batch_test_ids.iter().map(|s| s.as_str()).collect();
        for testsuite in testsuites.iter_mut() {
            for testcase in &mut testsuite.testcases {
                let canonical = format!(
                    "{} > {}",
                    testcase.classname.as_deref().unwrap_or(""),
                    &testcase.name
                );
                if !batch_set.contains(canonical.as_str()) {
                    return Err(super::FrameworkError::Other(anyhow::anyhow!(
                        "JUnit testcase '{}' not found in batch test IDs",
                        canonical
                    )));
                }
                testcase.name = canonical;
                testcase.classname = None;
            }
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::VitestFrameworkConfig;

    #[test]
    fn test_command_prefix_with_command() -> Result<(), Box<dyn std::error::Error>> {
        let config = VitestFrameworkConfig {
            command: "npx vitest".to_string(),
            ..Default::default()
        };
        let fw = VitestFramework::new(config)?;
        assert_eq!(fw.program, "npx");
        assert_eq!(fw.prefix_args, vec!["vitest"]);
        Ok(())
    }

    #[test]
    fn test_new_rejects_empty_command() {
        let config = VitestFrameworkConfig {
            command: "".to_string(),
            ..Default::default()
        };
        assert!(VitestFramework::new(config).is_err());
    }

    #[test]
    fn test_execution_command_builds_name_pattern() -> Result<(), Box<dyn std::error::Error>> {
        let config = VitestFrameworkConfig {
            command: "npx vitest".to_string(),
            run_args: Some("--no-coverage".to_string()),
            ..Default::default()
        };
        let fw = VitestFramework::new(config)?;

        let r1 = TestRecord::new(
            "tests/math.test.ts > math > add > adds two positive numbers",
            "grp",
        );
        let r2 = TestRecord::new(
            "tests/math.test.ts > math > subtract > subtracts two numbers",
            "grp",
        );
        let r3 = TestRecord::new("tests/string.test.ts > string utils > capitalize", "grp");
        let tests = vec![
            TestInstance::new(&r1),
            TestInstance::new(&r2),
            TestInstance::new(&r3),
        ];
        let cmd = fw.produce_test_execution_command(&tests, "/tmp/junit.xml", false);

        assert_eq!(cmd.program, "npx");
        assert!(cmd.args.contains(&"vitest".to_string()));
        assert!(cmd.args.contains(&"run".to_string()));
        // No file selectors — test names are unique so --testNamePattern suffices
        assert!(!cmd.args.contains(&"tests/math.test.ts".to_string()));
        assert!(!cmd.args.contains(&"tests/string.test.ts".to_string()));
        assert!(cmd.args.contains(&"--reporter=json".to_string()));
        assert!(
            cmd.args
                .contains(&"--outputFile=/tmp/junit.xml".to_string())
        );
        assert!(!cmd.args.contains(&"--includeTaskLocation".to_string()));
        assert!(cmd.args.contains(&"--no-coverage".to_string()));

        // --testNamePattern with full names (after file prefix) escaped
        assert!(cmd.args.contains(&"--testNamePattern".to_string()));
        let tnp_idx = cmd
            .args
            .iter()
            .position(|a| a == "--testNamePattern")
            .ok_or("--testNamePattern not found in args")?;
        let pattern = &cmd.args[tnp_idx + 1];
        assert!(pattern.starts_with("^("));
        assert!(pattern.ends_with(")$"));
        // ` > ` converted to spaces for vitest matching
        assert!(
            pattern.contains("math add adds two positive numbers"),
            "should have space-separated name. Got: {}",
            pattern
        );
        assert!(
            pattern.contains("math subtract subtracts two numbers"),
            "should have space-separated name. Got: {}",
            pattern
        );
        assert!(
            pattern.contains("string utils capitalize"),
            "should have space-separated name. Got: {}",
            pattern
        );
        // Names are separated by |
        assert!(pattern.contains('|'));

        // --testNamePattern comes before --reporter=json
        let reporter_idx = cmd
            .args
            .iter()
            .position(|a| a == "--reporter=json")
            .ok_or("--reporter=json not found in args")?;
        assert!(tnp_idx < reporter_idx);

        Ok(())
    }

    #[test]
    fn test_execution_command_escapes_regex_in_names() -> Result<(), Box<dyn std::error::Error>> {
        let config = VitestFrameworkConfig {
            command: "npx vitest".to_string(),
            ..Default::default()
        };
        let fw = VitestFramework::new(config)?;

        // Name part contains regex metacharacters that should be escaped
        let r1 = TestRecord::new("tests/a.test.ts > suite (group) > test.name+thing*", "grp");
        let tests = vec![TestInstance::new(&r1)];
        let cmd = fw.produce_test_execution_command(&tests, "/tmp/out.json", false);

        let tnp_idx = cmd
            .args
            .iter()
            .position(|a| a == "--testNamePattern")
            .ok_or("--testNamePattern not found in args")?;
        let pattern = &cmd.args[tnp_idx + 1];
        // Parentheses, dot, plus, star should be escaped
        assert!(
            pattern.contains(r"\("),
            "( should be escaped. Got: {}",
            pattern
        );
        assert!(
            pattern.contains(r"\)"),
            ") should be escaped. Got: {}",
            pattern
        );
        assert!(
            pattern.contains(r"\."),
            ". should be escaped. Got: {}",
            pattern
        );
        assert!(
            pattern.contains(r"\+"),
            "+ should be escaped. Got: {}",
            pattern
        );
        assert!(
            pattern.contains(r"\*"),
            "* should be escaped. Got: {}",
            pattern
        );

        Ok(())
    }

    #[test]
    fn test_discover_rejects_duplicate_names() -> Result<(), Box<dyn std::error::Error>> {
        // Same space-separated name across files
        let tests = vec![
            TestRecord::new("a.test.ts > suite > duplicate name", "grp"),
            TestRecord::new("b.test.ts > suite > duplicate name", "grp"),
            TestRecord::new("a.test.ts > suite > unique name", "grp"),
        ];

        let result = check_unique_test_names(&tests);
        assert!(result.is_err());
        let msg = result.unwrap_err().to_string();
        assert!(
            msg.contains("suite duplicate name"),
            "Error should mention the space-separated duplicate. Got: {}",
            msg
        );

        // No duplicates should pass.
        let unique_tests = vec![
            TestRecord::new("a.test.ts > name one", "grp"),
            TestRecord::new("b.test.ts > name two", "grp"),
        ];
        assert!(check_unique_test_names(&unique_tests).is_ok());

        Ok(())
    }

    #[test]
    fn test_xml_from_report_converts_json() -> Result<(), Box<dyn std::error::Error>> {
        let config = VitestFrameworkConfig::default();
        let fw = VitestFramework::new(config)?;

        let json = r#"{
            "testResults": [{
                "name": "/project/tests/math.test.ts",
                "assertionResults": [
                    {
                        "ancestorTitles": ["math", "add"],
                        "title": "adds two numbers",
                        "status": "passed",
                        "duration": 1.5,
                        "failureMessages": []
                    },
                    {
                        "ancestorTitles": ["math", "add"],
                        "title": "adds negative",
                        "status": "failed",
                        "duration": 2.0,
                        "failureMessages": ["Expected 3 but got 2"]
                    }
                ]
            }]
        }"#;

        let junit = fw.xml_from_report(json)?;

        assert!(
            junit.contains("math.test.ts\""),
            "classname should be file path. Got: {}",
            junit
        );
        assert!(
            junit.contains("math &gt; add &gt; adds two numbers"),
            "name should use > separator. Got: {}",
            junit
        );
        assert!(junit.contains("<failure"), "should have failure element");

        Ok(())
    }

    #[test]
    fn test_xml_from_report_skips_pending() -> Result<(), Box<dyn std::error::Error>> {
        let config = VitestFrameworkConfig::default();
        let fw = VitestFramework::new(config)?;

        let json = r#"{
            "testResults": [{
                "name": "/project/tests/a.test.ts",
                "assertionResults": [
                    {
                        "ancestorTitles": ["suite"],
                        "title": "runs",
                        "status": "passed",
                        "duration": 1.0,
                        "failureMessages": []
                    },
                    {
                        "ancestorTitles": ["suite"],
                        "title": "is pending",
                        "status": "pending",
                        "duration": 0.0,
                        "failureMessages": []
                    }
                ]
            }]
        }"#;

        let junit = fw.xml_from_report(json)?;

        assert!(
            junit.contains("runs"),
            "passed test should be in output. Got: {}",
            junit
        );
        assert!(
            !junit.contains("is pending"),
            "pending test should not be in output. Got: {}",
            junit
        );

        Ok(())
    }

    #[test]
    fn test_xml_from_report_skips_skipped() -> Result<(), Box<dyn std::error::Error>> {
        let config = VitestFrameworkConfig::default();
        let fw = VitestFramework::new(config)?;

        let json = r#"{
            "testResults": [{
                "name": "/project/tests/a.test.ts",
                "assertionResults": [
                    {
                        "ancestorTitles": ["suite"],
                        "title": "runs",
                        "status": "passed",
                        "duration": 1.0,
                        "failureMessages": []
                    },
                    {
                        "ancestorTitles": ["suite"],
                        "title": "is skipped",
                        "status": "skipped",
                        "duration": 0.0,
                        "failureMessages": []
                    }
                ]
            }]
        }"#;

        let junit = fw.xml_from_report(json)?;

        assert!(
            junit.contains("runs"),
            "passed test should be in output. Got: {}",
            junit
        );
        assert!(
            !junit.contains("is skipped"),
            "skipped test should not be in output. Got: {}",
            junit
        );

        Ok(())
    }

    #[test]
    fn test_xml_from_report_rejects_invalid_input() -> Result<(), Box<dyn std::error::Error>> {
        let config = VitestFrameworkConfig::default();
        let fw = VitestFramework::new(config)?;

        let result = fw.xml_from_report("not json");
        assert!(result.is_err());

        Ok(())
    }

    #[test]
    fn test_parse_discovery_json() -> Result<(), Box<dyn std::error::Error>> {
        let config = VitestFrameworkConfig::default();
        let fw = VitestFramework::new(config)?;

        let json = r#"[
            {
                "name": "math > add > adds two positive numbers",
                "file": "/project/tests/math.test.ts"
            },
            {
                "name": "string utils > capitalize",
                "file": "/project/tests/string.test.ts"
            }
        ]"#;

        let cwd = std::path::Path::new("/project");
        let tests = fw.parse_discovery_json(json, cwd, "default")?;

        assert_eq!(tests.len(), 2);

        assert_eq!(
            tests[0].id,
            "tests/math.test.ts > math > add > adds two positive numbers"
        );
        assert_eq!(tests[0].group, "default");

        assert_eq!(
            tests[1].id,
            "tests/string.test.ts > string utils > capitalize"
        );

        Ok(())
    }

    #[test]
    fn test_execution_command_fail_fast() -> Result<(), Box<dyn std::error::Error>> {
        let config = VitestFrameworkConfig {
            command: "npx vitest".to_string(),
            ..Default::default()
        };
        let fw = VitestFramework::new(config)?;
        let record = TestRecord::new("tests/a.test.ts > suite > test_one", "grp");
        let tests = vec![TestInstance::new(&record)];

        let cmd = fw.produce_test_execution_command(&tests, "/tmp/out.json", true);
        assert!(
            cmd.args.contains(&"--bail".to_string()),
            "fail_fast=true should add --bail. Args: {:?}",
            cmd.args
        );

        let cmd_no = fw.produce_test_execution_command(&tests, "/tmp/out.json", false);
        assert!(
            !cmd_no.args.contains(&"--bail".to_string()),
            "fail_fast=false should not add --bail. Args: {:?}",
            cmd_no.args
        );

        Ok(())
    }
}
