1
//! Shared E2E result reporting + fixture loading.
2
//!
3
//! The verdict tally and the cargo-test-style output are lifted from the DLL's
4
//! `shell2/run.rs` (the `AZ_E2E` printer) so that every front-end — the
5
//! `AZ_E2E=<path>` binary path, `azul-doc e2e <dir>`, and the in-crate fixture
6
//! test — reports *identically*. Keeping one copy here is the whole point: the
7
//! gate's notion of "green" must not depend on which entry point ran it.
8
//!
9
//! Verdict semantics (unchanged from `run.rs`): a test carrying
10
//! `"expect": "fail"` inverts its raw result —
11
//!
12
//! | raw    | `expect` | verdict | fails the gate? |
13
//! |--------|----------|---------|-----------------|
14
//! | pass   | none     | PASS    | no              |
15
//! | fail   | none     | FAIL    | **yes**         |
16
//! | fail   | `"fail"` | XFAIL   | no              |
17
//! | pass   | `"fail"` | XPASS   | **yes**         |
18
//!
19
//! XPASS is red on purpose: the guarded bug is fixed, so the marker must go.
20

            
21
use alloc::{format, string::String, vec::Vec};
22

            
23
use super::{E2eTest, E2eTestResult};
24

            
25
/// Tally of per-test verdicts for one run.
26
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
27
pub struct E2eVerdict {
28
    /// Clean pass (no `expect` marker).
29
    pub passed: usize,
30
    /// Genuine failure.
31
    pub failed: usize,
32
    /// Expected failure (`expect: fail` and it failed).
33
    pub xfail: usize,
34
    /// Unexpected pass (`expect: fail` but it passed) — a gate failure.
35
    pub xpass: usize,
36
}
37

            
38
impl E2eVerdict {
39
    /// `true` when the run should be considered red (`FAIL` or `XPASS` present).
40
    #[must_use]
41
2
    pub const fn gate_failed(&self) -> bool {
42
2
        self.failed + self.xpass > 0
43
2
    }
44

            
45
    /// Process exit code: 1 when red, 0 when green.
46
    #[must_use]
47
    pub const fn exit_code(&self) -> i32 {
48
        if self.gate_failed() {
49
            1
50
        } else {
51
            0
52
        }
53
    }
54
}
55

            
56
/// Whether `test` is marked as a known failure.
57
57
fn expects_fail(test: &E2eTest) -> bool {
58
57
    test.expect.as_deref() == Some("fail")
59
57
}
60

            
61
/// Render one cargo-test-style `test <name> ... <verdict>` line.
62
57
fn verdict_line(name: &str, verdict: &str, colour: &str, duration_ms: u64, suffix: &str) -> String {
63
57
    format!("test {name} ... \x1b[{colour}m{verdict}\x1b[0m ({duration_ms} ms){suffix}")
64
57
}
65

            
66
/// Tally `results` (paired with the `tests` they came from) and render the full
67
/// cargo-test-style report: one line per test, a `failures:` block detailing the
68
/// red ones, and the trailing `test result:` summary.
69
///
70
/// Returns the rendered report plus the tally, so callers choose the sink
71
/// (stderr for the binary/gate, stdout for a CLI) without this module needing
72
/// `std::io`.
73
#[must_use]
74
1
pub fn render_report(tests: &[E2eTest], results: &[E2eTestResult]) -> (String, E2eVerdict) {
75
1
    let mut out = String::new();
76
1
    let mut v = E2eVerdict::default();
77
    // (result, verdict) pairs that make the gate red.
78
1
    let mut gate_failures: Vec<(&E2eTestResult, &'static str)> = Vec::new();
79

            
80
1
    out.push('\n');
81

            
82
58
    for result in results {
83
        // Pair by name; a fixture whose name is absent is treated as unmarked.
84
57
        let marked = tests
85
57
            .iter()
86
1653
            .find(|t| t.name == result.name)
87
57
            .is_some_and(expects_fail);
88
57
        let raw_pass = result.status == "pass";
89

            
90
57
        let line = match (raw_pass, marked) {
91
            (true, false) => {
92
57
                v.passed += 1;
93
57
                verdict_line(&result.name, "PASS", "32", result.duration_ms, "")
94
            }
95
            (false, false) => {
96
                v.failed += 1;
97
                gate_failures.push((result, "FAIL"));
98
                verdict_line(&result.name, "FAIL", "31", result.duration_ms, "")
99
            }
100
            (false, true) => {
101
                v.xfail += 1;
102
                verdict_line(
103
                    &result.name,
104
                    "XFAIL",
105
                    "33",
106
                    result.duration_ms,
107
                    " (expected failure)",
108
                )
109
            }
110
            (true, true) => {
111
                v.xpass += 1;
112
                gate_failures.push((result, "XPASS"));
113
                verdict_line(
114
                    &result.name,
115
                    "XPASS",
116
                    "31",
117
                    result.duration_ms,
118
                    " (unexpectedly passed — remove the \"expect\":\"fail\" marker)",
119
                )
120
            }
121
        };
122
57
        out.push_str(&line);
123
57
        out.push('\n');
124
    }
125

            
126
1
    out.push('\n');
127

            
128
1
    if !gate_failures.is_empty() {
129
        out.push_str("failures:\n\n");
130
        for (f, verdict) in &gate_failures {
131
            out.push_str(&format!("---- {} ({verdict}) ----\n", f.name));
132
            if *verdict == "XPASS" {
133
                out.push_str(
134
                    "  test passed but is marked \"expect\":\"fail\" — the bug it guards is \
135
                     fixed; remove the marker\n",
136
                );
137
            }
138
            for step in &f.steps {
139
                if step.status == "fail" {
140
                    out.push_str(&format!(
141
                        "  step {}: {} → FAILED: {}\n",
142
                        step.step_index,
143
                        step.op,
144
                        step.error.as_deref().unwrap_or("unknown error")
145
                    ));
146
                }
147
            }
148
            out.push('\n');
149
        }
150
        out.push_str("failures:\n");
151
        for (f, verdict) in &gate_failures {
152
            out.push_str(&format!("    {} ({verdict})\n", f.name));
153
        }
154
        out.push('\n');
155
1
    }
156

            
157
1
    let word = if v.gate_failed() {
158
        "\x1b[31mFAILED\x1b[0m"
159
    } else {
160
1
        "\x1b[32mok\x1b[0m"
161
    };
162
1
    out.push_str(&format!(
163
1
        "test result: {word}. {} passed; {} failed; {} xfailed; {} xpassed; 0 ignored; 0 \
164
1
         measured; 0 filtered out\n",
165
1
        v.passed, v.failed, v.xfail, v.xpass
166
1
    ));
167

            
168
1
    (out, v)
169
1
}
170

            
171
/// Load every e2e test referenced by `path`.
172
///
173
/// A DIRECTORY loads each `*.json` inside it in sorted (deterministic) order; a
174
/// FILE loads just that one. Mirrors `load_e2e_tests` in the DLL's `run.rs`, but
175
/// returns a `Result` instead of calling `process::exit`, so it is usable from a
176
/// library and from a CLI that wants to report the error itself.
177
///
178
/// # Errors
179
///
180
/// Returns a human-readable message if `path` cannot be stat'ed or read, or if
181
/// any fixture is not valid `E2eTest` JSON.
182
#[cfg(feature = "std")]
183
2
pub fn load_e2e_tests(path: &std::path::Path) -> Result<Vec<E2eTest>, String> {
184
    use alloc::vec;
185

            
186
2
    let meta = std::fs::metadata(path)
187
2
        .map_err(|e| format!("cannot stat E2E path '{}': {e}", path.display()))?;
188

            
189
2
    let files: Vec<std::path::PathBuf> = if meta.is_dir() {
190
2
        let mut files: Vec<std::path::PathBuf> = std::fs::read_dir(path)
191
2
            .map_err(|e| format!("cannot read E2E directory '{}': {e}", path.display()))?
192
57
            .filter_map(|e| e.ok().map(|e| e.path()))
193
57
            .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("json"))
194
2
            .collect();
195
        // Deterministic run order.
196
2
        files.sort();
197
2
        files
198
    } else {
199
        vec![path.to_path_buf()]
200
    };
201

            
202
2
    let mut tests = Vec::with_capacity(files.len());
203
59
    for file in files {
204
57
        let src = std::fs::read_to_string(&file)
205
57
            .map_err(|e| format!("cannot read '{}': {e}", file.display()))?;
206
57
        let test: E2eTest = serde_json::from_str(&src)
207
57
            .map_err(|e| format!("invalid E2E JSON in '{}': {e}", file.display()))?;
208
57
        tests.push(test);
209
    }
210

            
211
2
    Ok(tests)
212
2
}