relux-runtime 0.6.0

Internal: runtime for Relux. No semver guarantees.
use std::path::Path;

use quick_junit::NonSuccessKind;
use quick_junit::Property;
use quick_junit::Report;
use quick_junit::TestCase;
use quick_junit::TestCaseStatus;
use quick_junit::TestSuite;

use crate::report::result::Failure;
use crate::report::result::Outcome;
use crate::report::result::TestResult;
use crate::report::result::events_json_link;
use crate::report::result::log_link;
use relux_core::diagnostics::IrSpan;
use relux_core::table::SourceTable;

pub fn generate_junit(
    run_dir: &Path,
    suite_name: &str,
    results: &[TestResult],
    source_table: &SourceTable,
) {
    let xml = render_junit(suite_name, results, run_dir, source_table);
    std::fs::write(run_dir.join("junit.xml"), xml).expect("failed to write junit.xml");
}

fn render_junit(
    suite_name: &str,
    results: &[TestResult],
    run_dir: &Path,
    source_table: &SourceTable,
) -> String {
    let mut report = Report::new(suite_name);
    let mut suite = TestSuite::new(suite_name);

    for result in results {
        let classname = Path::new(&result.test_path)
            .with_extension("")
            .display()
            .to_string();
        let mut case = TestCase::new(&result.test_name, TestCaseStatus::success());
        case.set_classname(&classname);
        case.set_time(result.duration);

        match &result.outcome {
            Outcome::Pass => {} // status already success
            Outcome::Fail(failure) => {
                let mut status = TestCaseStatus::non_success(NonSuccessKind::Failure);
                status.set_message(failure.summary());
                status.set_type(failure.failure_type());
                status.set_description(format_failure_detail(failure, source_table));
                case.status = status;
            }
            Outcome::Cancelled(c) => {
                let mut status = TestCaseStatus::non_success(NonSuccessKind::Error);
                status.set_message(format!("cancelled: {}", c.reason_tag()));
                status.set_type("cancelled");
                case.status = status;
            }
            Outcome::Skipped(reason) => {
                let mut status = TestCaseStatus::skipped();
                status.set_message(reason.as_str());
                case.status = status;
            }
            Outcome::Invalid(reason) => {
                let mut status = TestCaseStatus::non_success(NonSuccessKind::Error);
                status.set_message(reason.as_str());
                status.set_type("Invalid");
                case.status = status;
            }
        }

        if let Some(link) = log_link(run_dir, result) {
            let json_link = events_json_link(run_dir, result);
            let system_out = match &json_link {
                Some(json) => format!("[[ATTACHMENT|{link}]]\n[[ATTACHMENT|{json}]]"),
                None => format!("[[ATTACHMENT|{link}]]"),
            };
            case.set_system_out(system_out);
            case.add_property(Property::new("log", &link));
            if let Some(json) = json_link {
                case.add_property(Property::new("events_json", &json));
            }
        }

        suite.add_test_case(case);
    }

    report.add_test_suite(suite);
    report.to_string().expect("JUnit XML serialization failed")
}

fn format_failure_detail(failure: &Failure, source_table: &SourceTable) -> String {
    match failure {
        Failure::MatchTimeout {
            pattern,
            span,
            shell,
            ..
        } => {
            let loc = source_location(span, source_table);
            format!("shell: {shell}\npattern: {pattern}\n{loc}")
        }
        Failure::FailPatternMatched {
            pattern,
            matched_line,
            span,
            shell,
            ..
        } => {
            let loc = source_location(span, source_table);
            format!("shell: {shell}\npattern: {pattern}\nmatched: {matched_line}\n{loc}")
        }
        Failure::ShellExited {
            shell,
            exit_code,
            span,
            ..
        } => {
            let loc = source_location(span, source_table);
            let code_str = match exit_code {
                Some(code) => code.to_string(),
                None => "unknown".to_string(),
            };
            format!("shell: {shell}\nexit_code: {code_str}\n{loc}")
        }
        Failure::Runtime {
            message,
            span,
            shell,
            ..
        } => {
            let shell_line = match shell {
                Some(s) => format!("shell: {s}\n"),
                None => String::new(),
            };
            let loc_line = match span {
                Some(s) => format!("\n{}", source_location(s, source_table)),
                None => String::new(),
            };
            format!("{shell_line}message: {message}{loc_line}")
        }
    }
}

fn source_location(span: &IrSpan, source_table: &SourceTable) -> String {
    if let Some(sf) = source_table.get(span.file()) {
        let line = line_number(&sf.source, span.span().start());
        format!("file: {}\nline: {line}", sf.path.display())
    } else {
        format!("file: {}\nline: ?", span.file().path().display())
    }
}

fn line_number(source: &str, offset: usize) -> usize {
    source[..offset.min(source.len())]
        .bytes()
        .filter(|&b| b == b'\n')
        .count()
        + 1
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::report::result::Failure;
    use crate::report::result::FailureContext;
    use crate::report::result::Outcome;
    use crate::report::result::TestResult;
    use relux_core::diagnostics::IrSpan;
    use relux_core::table::FileId;
    use relux_core::table::SharedTable;
    use relux_core::table::SourceFile;
    use std::path::PathBuf;
    use std::time::Duration;

    fn test_source_table() -> SourceTable {
        let table: SourceTable = SharedTable::new();
        table.insert(
            FileId::new(PathBuf::from("tests/auth/login.relux")),
            SourceFile::new(
                PathBuf::from("tests/auth/login.relux"),
                "line1\nline2\nline3\n".to_string(),
            ),
        );
        table
    }

    fn test_span(offset_start: usize, offset_end: usize) -> IrSpan {
        IrSpan::new(
            FileId::new(PathBuf::from("tests/auth/login.relux")),
            relux_core::Span::new(offset_start, offset_end),
        )
    }

    fn make_result(
        name: &str,
        path: &str,
        outcome: Outcome,
        duration: Duration,
        log_dir: Option<PathBuf>,
    ) -> TestResult {
        TestResult {
            test_name: name.to_string(),
            test_path: path.to_string(),
            outcome,
            duration,
            progress: String::new(),
            log_dir,
            warnings: Vec::new(),
            flaky_retries: 0,
        }
    }

    #[test]
    fn passed_test_with_log() {
        let source_table = test_source_table();
        let run_dir = Path::new("/tmp/runs/run-001");
        let results = vec![make_result(
            "login test",
            "tests/auth/login.relux",
            Outcome::Pass,
            Duration::from_millis(1230),
            Some(PathBuf::from("/tmp/runs/run-001/login-test")),
        )];

        let xml = render_junit("my-project", &results, run_dir, &source_table);

        assert!(xml.contains("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
        assert!(xml.contains("name=\"login test\""));
        assert!(xml.contains("classname=\"tests/auth/login\""));
        assert!(xml.contains("[[ATTACHMENT|login-test/event.html]]"));
        assert!(xml.contains("[[ATTACHMENT|login-test/events.json]]"));
        assert!(xml.contains("<property name=\"log\" value=\"login-test/event.html\""));
        assert!(xml.contains("<property name=\"events_json\" value=\"login-test/events.json\""));
        assert!(!xml.contains("<failure"));
        assert!(!xml.contains("<skipped"));
    }

    #[test]
    fn failed_test_with_diagnostics() {
        let source_table = test_source_table();
        let run_dir = Path::new("/tmp/runs/run-001");
        let failure = Failure::MatchTimeout {
            pattern: "/ready/".to_string(),
            span: test_span(12, 17),
            shell: "default".to_string(),
            effective: Box::new(relux_ir::IrTimeout::tolerance(Duration::from_secs(5))),
            context: FailureContext::pre_vm(),
        };
        let results = vec![make_result(
            "timeout test",
            "tests/auth/timeout.relux",
            Outcome::Fail(failure),
            Duration::from_secs(5),
            Some(PathBuf::from("/tmp/runs/run-001/timeout-test")),
        )];

        let xml = render_junit("my-project", &results, run_dir, &source_table);

        assert!(xml.contains("name=\"timeout test\""));
        assert!(xml.contains("classname=\"tests/auth/timeout\""));
        assert!(xml.contains("type=\"MatchTimeout\""));
        assert!(xml.contains("shell: default"));
        assert!(xml.contains("pattern: /ready/"));
        assert!(xml.contains("file: tests/auth/login.relux"));
        assert!(xml.contains("line: 3"));
        assert!(xml.contains("[[ATTACHMENT|timeout-test/event.html]]"));
        assert!(xml.contains("[[ATTACHMENT|timeout-test/events.json]]"));
    }

    #[test]
    fn skipped_test() {
        let source_table = test_source_table();
        let run_dir = Path::new("/tmp/runs/run-001");
        let results = vec![make_result(
            "setup test",
            "tests/platform/setup.relux",
            Outcome::Skipped("os:linux".to_string()),
            Duration::ZERO,
            None,
        )];

        let xml = render_junit("my-project", &results, run_dir, &source_table);

        assert!(xml.contains("name=\"setup test\""));
        assert!(xml.contains("classname=\"tests/platform/setup\""));
        assert!(xml.contains("<skipped"));
        assert!(xml.contains("os:linux"));
        assert!(!xml.contains("ATTACHMENT"));
        assert!(!xml.contains("<property"));
    }

    #[test]
    fn suite_name_appears_in_output() {
        let source_table = test_source_table();
        let run_dir = Path::new("/tmp/runs/run-001");
        let results = vec![];

        let xml = render_junit("my-cool-project", &results, run_dir, &source_table);

        assert!(xml.contains("name=\"my-cool-project\""));
    }

    #[test]
    fn classname_strips_extension() {
        let source_table = test_source_table();
        let run_dir = Path::new("/tmp/runs/run-001");
        let results = vec![make_result(
            "a test",
            "tests/deep/nested/file.relux",
            Outcome::Pass,
            Duration::from_millis(100),
            None,
        )];

        let xml = render_junit("proj", &results, run_dir, &source_table);

        assert!(xml.contains("classname=\"tests/deep/nested/file\""));
    }

    #[test]
    fn line_number_calculation() {
        assert_eq!(line_number("hello", 0), 1);
        assert_eq!(line_number("hello\nworld", 6), 2);
        assert_eq!(line_number("a\nb\nc\n", 4), 3);
        assert_eq!(line_number("ab", 100), 1);
    }
}