Skip to main content

ignition_core/actions/
testing.rs

1//! The testing action (QUICK-p0g) — `ign testing run`, the
2//! standalone verb over the gateway's already-deployed testing
3//! bundle (`POST /system/webdev/{project}/testing/run`), which until
4//! now only `ign adopt --testing` exercised as a deploy smoke check.
5//!
6//! Purpose: an agent or a CI job runs the gateway's Jython suite and
7//! reads a machine verdict — without `ign adopt`, without the
8//! Designer, without the gateway webpage.
9//!
10//! Two contract rules this module exists to enforce:
11//!
12//! 1. **The verdict rides the BODY, never the status line.** The
13//!    route answers 200 on green and **207** on failures/errors —
14//!    both are success transport shapes. `failed + errors > 0` is
15//!    the only oracle (the route module's own Pitfall-2 rule).
16//! 2. **An absent bundle is a target-state refusal, not a bug.** The
17//!    presence probe runs BEFORE any run POST and reads the RAW
18//!    status ([`crate::client::webdev::testing_probe`]), because
19//!    `classify` collapses 405/500/501 into `Internal` (exit 1) —
20//!    which would let "the testing bundle was never deployed" read
21//!    as a CLI defect.
22//!
23//! **The exit-code shape.** A red suite renders the SUCCESS envelope
24//! (every result field intact) and exits 6 — the `ign lint --strict`
25//! precedent, the repo's sanctioned success-path EXIT exception.
26//! `ErrorEnvelope` is LOCKED to `{ok, profile, error{…}}` with no
27//! `data` field, so a red run CANNOT carry its results through the
28//! error path; and this is how every real test runner behaves (full
29//! report on stdout, verdict in the exit code). The stable slug
30//! rides the payload as `data.slug = "tests_failed"` — it is NOT a
31//! `CoreError` variant and therefore must never appear in the
32//! exit-code tables (`readme_exit_table_agreement` cross-checks
33//! those against the enum literals, bidirectionally).
34
35use std::time::Duration;
36
37use serde::Serialize;
38
39use crate::client::ReqwestGatewayApi;
40use crate::client::webdev::{TestingProbe, testing_probe};
41use crate::error::{CoreError, TESTING_ROUTE_PREFIX};
42
43/// The route folder every refusal names (`testing/run`) — BUILT from
44/// [`TESTING_ROUTE_PREFIX`] so the raise site and the content address
45/// the [`CoreError::RoutesNotDeployed`] hint keys off cannot drift
46/// apart (a `testing/`-less route here would silently send the caller
47/// to the generic `ign webdev deploy` hint, which does not install
48/// the testing bundle).
49fn testing_run_route() -> String {
50    format!("{TESTING_ROUTE_PREFIX}run")
51}
52
53/// The first-touch warmup pause. A freshly deployed route can answer
54/// 500 on its FIRST request while WebDev lazily compiles the module;
55/// the second hit answers (live-pinned on both a reset rig and a
56/// fresh gateway — `actions::adopt`'s empty-suite trap). ONE bounded
57/// retry, then the error is real.
58const LAZY_COMPILE_WARMUP: Duration = Duration::from_secs(3);
59
60/// The stable red-run slug. Rides the SUCCESS payload as `data.slug`
61/// (never the error envelope — see the module docs).
62pub const TESTS_FAILED_SLUG: &str = "tests_failed";
63
64/// The report `ign testing run` renders into `data.report`.
65///
66/// The gateway route CAN emit junit/text itself, but only its json
67/// path sets the 200/207 status and returns machine counts — its
68/// junit answer is raw XML and its text answer is raw `<pre>` text,
69/// both at HTTP 200 with no `passed/failed/errors/total` anywhere.
70/// A straight passthrough would therefore make a RED suite exit 0 in
71/// junit and text mode, the exact opposite of what a CI flag is for
72/// (and XML on stdout would be a new exception to the envelope
73/// purity contract that `contract_stdout_purity` pins).
74///
75/// So the CLI always POSTs `format: "json"` and renders the report
76/// locally, as pure functions: ONE suite execution, one round trip,
77/// and the verdict + exit code are IDENTICAL in every format.
78#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
79pub enum TestingFormat {
80    /// Structured results only — `data.report` stays empty.
81    #[default]
82    Json,
83    /// JUnit XML in `data.report`, for a CI test reporter.
84    Junit,
85    /// Console-style text in `data.report`, for a human reading logs.
86    Text,
87}
88
89impl TestingFormat {
90    /// The flag value / wire name.
91    pub fn as_str(self) -> &'static str {
92        match self {
93            Self::Json => "json",
94            Self::Junit => "junit",
95            Self::Text => "text",
96        }
97    }
98}
99
100/// `ign testing run` inputs. `discover` / `module` / `package` are
101/// mutually exclusive — enforced HERE as well as in clap (defence in
102/// depth: the TUI and the MCP bridge call the action directly).
103#[derive(Debug, Clone, Default)]
104pub struct TestingRunOptions {
105    /// List the gateway's discovered test modules instead of running.
106    pub discover: bool,
107    /// Run ONE dotted module (`proj.foo_test`).
108    pub module: Option<String>,
109    /// Run every module under a package prefix (`proj.`).
110    pub package: Option<String>,
111    /// The report rendered into `data.report` (see [`TestingFormat`]).
112    pub format: TestingFormat,
113}
114
115/// `ign testing run` result — ALL keys always present (the family
116/// convention: agents never key-hunt). Discover mode leaves the
117/// verdict/count fields at their zero/None defaults.
118#[derive(Debug, Serialize)]
119pub struct TestingRunResult {
120    /// `"discover"` or `"run"`.
121    pub mode: String,
122    /// The project whose testing bundle answered.
123    pub project: String,
124    /// Discover: every discovered module. Run: the modules executed.
125    pub modules: Vec<String>,
126    /// Discover: the module count. Run: the module count executed.
127    pub count: u32,
128    /// `"passed"` | `"failed"` — `null` in discover mode (a
129    /// discovery is not a verdict).
130    pub verdict: Option<String>,
131    /// [`TESTS_FAILED_SLUG`] on a red run, `null` otherwise — the
132    /// stable machine slug agents branch on.
133    pub slug: Option<String>,
134    /// Tests that passed.
135    pub passed: u32,
136    /// Tests that failed an assertion.
137    pub failed: u32,
138    /// Tests skipped.
139    pub skipped: u32,
140    /// Tests that raised (not an assertion failure).
141    pub errors: u32,
142    /// `passed + failed + skipped + errors`, as the route reports it.
143    pub total: u32,
144    /// The route-measured wall time in milliseconds.
145    #[serde(rename = "durationMs")]
146    pub duration_ms: u64,
147    /// The rendered report for `--format junit|text` (empty string in
148    /// json and discover modes — the key still rides).
149    pub report: String,
150    /// The route's answer VERBATIM — `null` in discover mode. Never
151    /// interpreted, so a route that grows a field does not need a CLI
152    /// release to surface it.
153    pub results: serde_json::Value,
154}
155
156impl TestingRunResult {
157    /// The red-run exit passthrough, mirroring
158    /// [`crate::actions::lint::LintResult::strict_exit_code`]:
159    /// `Some(6)` (target state — the LOCKED taxonomy's
160    /// gateway-state class) when the suite failed, `None` otherwise.
161    /// The envelope renders FIRST; the binary decides the exit after.
162    pub fn failure_exit_code(&self) -> Option<u8> {
163        match self.verdict.as_deref() {
164            Some("failed") => Some(6),
165            _ => None,
166        }
167    }
168
169    /// The discover-mode shape: modules + count, no verdict.
170    fn discovered(project: &str, discovered: &serde_json::Value) -> Self {
171        let modules = string_list(discovered.get("discovered_modules"));
172        let count = discovered
173            .get("count")
174            .and_then(serde_json::Value::as_u64)
175            .map_or(modules.len() as u32, |count| count as u32);
176        Self {
177            mode: "discover".to_string(),
178            project: project.to_string(),
179            modules,
180            count,
181            verdict: None,
182            slug: None,
183            passed: 0,
184            failed: 0,
185            skipped: 0,
186            errors: 0,
187            total: 0,
188            duration_ms: 0,
189            report: String::new(),
190            results: serde_json::Value::Null,
191        }
192    }
193}
194
195/// `ign testing run` — prove the bundle answers, then discover or run.
196///
197/// Sequence (the `script_run` probe-then-act precedent —
198/// correctness over latency): the usage gate FIRST (zero HTTP), then
199/// the raw-status presence probe (one bounded lazy-compile retry,
200/// whose 200 body IS the discover answer), then the run POST.
201pub async fn testing_run(
202    api: &ReqwestGatewayApi,
203    project: &str,
204    opts: &TestingRunOptions,
205) -> Result<TestingRunResult, CoreError> {
206    // Usage errors LEAD (the 03-03 put convention): the selectors are
207    // three ways to answer the same question and combining them has
208    // no meaning. Refuses BEFORE any network I/O.
209    check_selector_exclusivity(opts)?;
210
211    let route = testing_run_route();
212    let endpoint = || Some(format!("/system/webdev/{project}/{route}"));
213    let discovered = match probe_with_warmup(api, project).await? {
214        TestingProbe::Present { discovered } => discovered,
215        TestingProbe::Absent => {
216            return Err(CoreError::RoutesNotDeployed {
217                project: project.to_string(),
218                route: route.clone(),
219                endpoint: endpoint(),
220            });
221        }
222        TestingProbe::Unlicensed => {
223            return Err(CoreError::WebdevUnlicensed {
224                endpoint: endpoint(),
225            });
226        }
227        TestingProbe::AuthGated { status } => {
228            return Err(CoreError::Auth {
229                status,
230                endpoint: endpoint(),
231            });
232        }
233        TestingProbe::LazyCompile { body } => {
234            // The SECOND 500 is real — the route is deployed and
235            // blowing up, which is a bug, not a target state.
236            return Err(CoreError::Internal(format!(
237                "the testing route answered HTTP 500 twice (the first-touch \
238                 lazy-compile warmup did not clear it): {body}"
239            )));
240        }
241    };
242
243    if opts.discover {
244        return Ok(TestingRunResult::discovered(project, &discovered));
245    }
246
247    // The run leg rides the CLASSIFIED path — 200 and 207 both pass
248    // through classify untouched, and the probe already proved the
249    // route present, so anything else here is a genuine failure.
250    let results = run_with_warmup(api, project, &run_body(opts)).await?;
251    Ok(map_run(project, opts.format, results))
252}
253
254/// The three-way selector gate: `--discover`, `--module` and
255/// `--package` are mutually exclusive. `invalid_input` (exit 2)
256/// naming BOTH offending flags, before any gateway I/O — the
257/// `actions::script::read_script_input` refusal shape.
258fn check_selector_exclusivity(opts: &TestingRunOptions) -> Result<(), CoreError> {
259    let refuse = |first: &str, second: &str| {
260        Err(CoreError::InvalidInput {
261            reason: format!(
262                "{first} and {second} are mutually exclusive — they are different \
263                 ways to answer the same question: pass exactly one (or none, to \
264                 run every discovered module)"
265            ),
266        })
267    };
268    match (opts.discover, opts.module.is_some(), opts.package.is_some()) {
269        (true, true, _) => refuse("--discover", "--module"),
270        (true, _, true) => refuse("--discover", "--package"),
271        (_, true, true) => refuse("--module", "--package"),
272        _ => Ok(()),
273    }
274}
275
276/// The probe with the ONE bounded first-touch warmup retry.
277async fn probe_with_warmup(
278    api: &ReqwestGatewayApi,
279    project: &str,
280) -> Result<TestingProbe, CoreError> {
281    match testing_probe(api, project).await? {
282        TestingProbe::LazyCompile { .. } => {
283            tokio::time::sleep(LAZY_COMPILE_WARMUP).await;
284            testing_probe(api, project).await
285        }
286        probe => Ok(probe),
287    }
288}
289
290/// The run POST with the SAME single warmup retry as the probe leg —
291/// the classified path turns a 500 into `Internal`, and a first-touch
292/// blowup on the run leg is the same lazily-compiled-module story
293/// (the probe warms `testing.runner`; the run leg can be the first
294/// touch of a test module's own imports).
295async fn run_with_warmup(
296    api: &ReqwestGatewayApi,
297    project: &str,
298    body: &serde_json::Value,
299) -> Result<serde_json::Value, CoreError> {
300    match crate::client::webdev::testing_run(api, project, body).await {
301        Ok(results) => Ok(results),
302        // ONLY the classified 5xx (`Internal`) is the lazy-compile
303        // story; an auth, connection, or usage error must surface
304        // immediately rather than sleep and re-execute the suite.
305        Err(CoreError::Internal(_)) => {
306            tokio::time::sleep(LAZY_COMPILE_WARMUP).await;
307            crate::client::webdev::testing_run(api, project, body).await
308        }
309        Err(err) => Err(err),
310    }
311}
312
313/// The POST body: ONLY the keys the caller set, plus the always-json
314/// format (the report is rendered client-side — see
315/// [`TestingFormat`]).
316fn run_body(opts: &TestingRunOptions) -> serde_json::Value {
317    let mut body = serde_json::Map::new();
318    if let Some(module) = &opts.module {
319        body.insert("module".to_string(), serde_json::json!(module));
320    }
321    if let Some(package) = &opts.package {
322        body.insert("package".to_string(), serde_json::json!(package));
323    }
324    body.insert("format".to_string(), serde_json::json!("json"));
325    serde_json::Value::Object(body)
326}
327
328/// Map the route's run answer onto the result. The verdict comes
329/// from `failed + errors`, NEVER from the HTTP status (200 and 207
330/// are both success transport shapes).
331fn map_run(project: &str, format: TestingFormat, results: serde_json::Value) -> TestingRunResult {
332    let count_of = |key: &str| {
333        results
334            .get(key)
335            .and_then(serde_json::Value::as_u64)
336            .unwrap_or(0) as u32
337    };
338    let failed = count_of("failed");
339    let errors = count_of("errors");
340    let red = failed + errors > 0;
341    let modules = results
342        .get("modules")
343        .and_then(serde_json::Value::as_array)
344        .map(|items| {
345            items
346                .iter()
347                .filter_map(|item| {
348                    item.get("module")
349                        .and_then(serde_json::Value::as_str)
350                        .map(str::to_string)
351                })
352                .collect::<Vec<_>>()
353        })
354        .unwrap_or_default();
355    let report = match format {
356        TestingFormat::Json => String::new(),
357        TestingFormat::Junit => to_junit_xml(&results),
358        TestingFormat::Text => to_console(&results),
359    };
360    TestingRunResult {
361        mode: "run".to_string(),
362        project: project.to_string(),
363        count: modules.len() as u32,
364        modules,
365        verdict: Some(if red { "failed" } else { "passed" }.to_string()),
366        slug: red.then(|| TESTS_FAILED_SLUG.to_string()),
367        passed: count_of("passed"),
368        failed,
369        skipped: count_of("skipped"),
370        errors,
371        total: count_of("total"),
372        duration_ms: u64_of(&results, "duration_ms"),
373        report,
374        results,
375    }
376}
377
378/// `["a", "b"]` under `field` → the owned string list (non-strings
379/// and a missing/ill-typed field degrade to an empty list rather
380/// than failing a read).
381fn string_list(field: Option<&serde_json::Value>) -> Vec<String> {
382    field
383        .and_then(serde_json::Value::as_array)
384        .map(|items| {
385            items
386                .iter()
387                .filter_map(|item| item.as_str().map(str::to_string))
388                .collect()
389        })
390        .unwrap_or_default()
391}
392
393/// The per-module views a report iterates (`results.modules`, or the
394/// whole body when the route answered a single-module shape — the
395/// gateway reporter's own `if not modules: modules = [results]`).
396fn report_modules(results: &serde_json::Value) -> Vec<&serde_json::Value> {
397    match results.get("modules").and_then(serde_json::Value::as_array) {
398        Some(modules) if !modules.is_empty() => modules.iter().collect(),
399        _ => vec![results],
400    }
401}
402
403/// `key` as u64, 0 when absent/ill-typed.
404fn u64_of(value: &serde_json::Value, key: &str) -> u64 {
405    value
406        .get(key)
407        .and_then(serde_json::Value::as_u64)
408        .unwrap_or(0)
409}
410
411/// `key` as &str, `fallback` when absent/ill-typed.
412fn str_of<'a>(value: &'a serde_json::Value, key: &str, fallback: &'a str) -> &'a str {
413    value
414        .get(key)
415        .and_then(serde_json::Value::as_str)
416        .unwrap_or(fallback)
417}
418
419/// JUnit XML from the structured results — a PURE function, unit
420/// tested without a gateway. Mirrors the attribute set and element
421/// shapes of the bundle's `testing/reporter/code.py::to_junit_xml`
422/// so a CI reporter sees the same document either way.
423pub fn to_junit_xml(results: &serde_json::Value) -> String {
424    let mut parts = vec![r#"<?xml version="1.0" encoding="UTF-8"?>"#.to_string()];
425    parts.push(format!(
426        r#"<testsuites tests="{}" failures="{}" errors="{}" skipped="{}" time="{:.3}">"#,
427        u64_of(results, "total"),
428        u64_of(results, "failed"),
429        u64_of(results, "errors"),
430        u64_of(results, "skipped"),
431        u64_of(results, "duration_ms") as f64 / 1000.0,
432    ));
433    for module in report_modules(results) {
434        let module_name = str_of(module, "module", "unknown");
435        let cases = module
436            .get("results")
437            .and_then(serde_json::Value::as_array)
438            .map(Vec::as_slice)
439            .unwrap_or(&[]);
440        parts.push(format!(
441            r#"  <testsuite name="{}" tests="{}" failures="{}" errors="{}" skipped="{}" time="{:.3}">"#,
442            escape_xml(module_name),
443            cases.len(),
444            u64_of(module, "failed"),
445            u64_of(module, "errors"),
446            u64_of(module, "skipped"),
447            u64_of(module, "duration_ms") as f64 / 1000.0,
448        ));
449        for case in cases {
450            parts.push(format!(
451                r#"    <testcase name="{}" classname="{}" time="{:.3}">"#,
452                escape_xml(str_of(case, "name", "unknown")),
453                escape_xml(module_name),
454                u64_of(case, "duration_ms") as f64 / 1000.0,
455            ));
456            let message = escape_xml(str_of(case, "message", ""));
457            let traceback = escape_xml(str_of(case, "traceback", ""));
458            match str_of(case, "status", "error") {
459                "failed" => parts.push(format!(
460                    r#"      <failure message="{message}">{traceback}</failure>"#
461                )),
462                "error" => parts.push(format!(
463                    r#"      <error message="{message}">{traceback}</error>"#
464                )),
465                "skipped" => parts.push(format!(r#"      <skipped message="{message}" />"#)),
466                _ => {}
467            }
468            parts.push("    </testcase>".to_string());
469        }
470        parts.push("  </testsuite>".to_string());
471    }
472    parts.push("</testsuites>".to_string());
473    parts.join("\n")
474}
475
476/// Console-style text from the structured results — a PURE function,
477/// mirroring the bundle's `testing/reporter/code.py::to_console`.
478pub fn to_console(results: &serde_json::Value) -> String {
479    let rule = "=".repeat(60);
480    let mut lines = vec![rule.clone(), "TEST RESULTS".to_string(), rule.clone()];
481    for module in report_modules(results) {
482        lines.push(String::new());
483        lines.push(format!("--- {} ---", str_of(module, "module", "unknown")));
484        let cases = module
485            .get("results")
486            .and_then(serde_json::Value::as_array)
487            .map(Vec::as_slice)
488            .unwrap_or(&[]);
489        for case in cases {
490            let status = str_of(case, "status", "error").to_uppercase();
491            let marker = match status.as_str() {
492                "PASSED" => "  PASS",
493                "FAILED" => "  FAIL",
494                "SKIPPED" => "  SKIP",
495                _ => " ERROR",
496            };
497            let mut line = format!("{marker}  {}", str_of(case, "name", "unknown"));
498            let duration = u64_of(case, "duration_ms");
499            if duration > 0 {
500                line.push_str(&format!("  ({duration}ms)"));
501            }
502            lines.push(line);
503            let message = str_of(case, "message", "");
504            if !message.is_empty() && matches!(status.as_str(), "FAILED" | "ERROR") {
505                lines.push(format!("         {message}"));
506            }
507        }
508    }
509    lines.push(String::new());
510    lines.push(rule.clone());
511    lines.push(format!(
512        "Total: {}  Passed: {}  Failed: {}  Skipped: {}  Errors: {}  ({}ms)",
513        u64_of(results, "total"),
514        u64_of(results, "passed"),
515        u64_of(results, "failed"),
516        u64_of(results, "skipped"),
517        u64_of(results, "errors"),
518        u64_of(results, "duration_ms"),
519    ));
520    lines.push(rule);
521    lines.join("\n")
522}
523
524/// XML escaping with the control-character STRIP the gateway
525/// reporter also does — XML 1.0 forbids most characters below 0x20
526/// (only `\t \n \r` are legal), so an unstripped traceback would
527/// make the document malformed, not merely unescaped.
528fn escape_xml(text: &str) -> String {
529    let mut out = String::with_capacity(text.len());
530    for ch in text
531        .chars()
532        .filter(|ch| matches!(ch, '\t' | '\n' | '\r') || *ch >= '\u{20}')
533    {
534        match ch {
535            '&' => out.push_str("&amp;"),
536            '<' => out.push_str("&lt;"),
537            '>' => out.push_str("&gt;"),
538            '"' => out.push_str("&quot;"),
539            other => out.push(other),
540        }
541    }
542    out
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548
549    /// The sample the renderers are pinned against — the route's own
550    /// `run_all` shape.
551    fn sample() -> serde_json::Value {
552        serde_json::json!({
553            "passed": 1,
554            "failed": 1,
555            "skipped": 1,
556            "errors": 0,
557            "total": 3,
558            "duration_ms": 1500,
559            "modules": [{
560                "module": "proj.foo_test",
561                "passed": 1,
562                "failed": 1,
563                "skipped": 1,
564                "errors": 0,
565                "duration_ms": 1500,
566                "results": [
567                    {"name": "test_ok", "status": "passed", "duration_ms": 5},
568                    {"name": "test_bad", "status": "failed", "duration_ms": 7,
569                     "message": "1 != 2 <&>", "traceback": "Traceback ..."},
570                    {"name": "test_skip", "status": "skipped", "duration_ms": 0,
571                     "message": "not today"}
572                ]
573            }]
574        })
575    }
576
577    /// The selector gate refuses every pair, naming BOTH flags.
578    #[test]
579    fn selectors_are_mutually_exclusive() {
580        let cases = [
581            (
582                TestingRunOptions {
583                    discover: true,
584                    module: Some("a.b".into()),
585                    ..Default::default()
586                },
587                ["--discover", "--module"],
588            ),
589            (
590                TestingRunOptions {
591                    discover: true,
592                    package: Some("a.".into()),
593                    ..Default::default()
594                },
595                ["--discover", "--package"],
596            ),
597            (
598                TestingRunOptions {
599                    module: Some("a.b".into()),
600                    package: Some("a.".into()),
601                    ..Default::default()
602                },
603                ["--module", "--package"],
604            ),
605        ];
606        for (opts, flags) in cases {
607            let err = check_selector_exclusivity(&opts).expect_err("the pair refuses");
608            assert_eq!(err.code(), "invalid_input");
609            assert_eq!(err.exit_code(), 2, "usage class");
610            let message = err.to_string();
611            for flag in flags {
612                assert!(message.contains(flag), "message names {flag}: {message}");
613            }
614        }
615        // Zero or one selector is fine (none = run everything).
616        assert!(check_selector_exclusivity(&TestingRunOptions::default()).is_ok());
617        assert!(
618            check_selector_exclusivity(&TestingRunOptions {
619                module: Some("a.b".into()),
620                ..Default::default()
621            })
622            .is_ok()
623        );
624    }
625
626    /// The POST body carries ONLY the keys the caller set, plus the
627    /// always-json format — `--format` NEVER changes the wire.
628    #[test]
629    fn run_body_carries_only_what_was_set() {
630        assert_eq!(
631            run_body(&TestingRunOptions::default()),
632            serde_json::json!({"format": "json"})
633        );
634        assert_eq!(
635            run_body(&TestingRunOptions {
636                module: Some("a.b.c_test".into()),
637                format: TestingFormat::Junit,
638                ..Default::default()
639            }),
640            serde_json::json!({"module": "a.b.c_test", "format": "json"}),
641            "the format flag never leaves the CLI — the report is rendered locally"
642        );
643        assert_eq!(
644            run_body(&TestingRunOptions {
645                package: Some("proj.".into()),
646                ..Default::default()
647            }),
648            serde_json::json!({"package": "proj.", "format": "json"})
649        );
650    }
651
652    /// The verdict comes from `failed + errors`, and `failure_exit_code`
653    /// is the 6 the binary passes through.
654    #[test]
655    fn verdict_reads_the_body_not_the_status() {
656        let green = map_run(
657            "P",
658            TestingFormat::Json,
659            serde_json::json!({"passed": 3, "failed": 0, "errors": 0, "total": 3}),
660        );
661        assert_eq!(green.verdict.as_deref(), Some("passed"));
662        assert_eq!(green.slug, None);
663        assert_eq!(green.failure_exit_code(), None);
664        assert!(green.report.is_empty(), "json mode renders no report");
665
666        // errors alone (zero failures) is still RED.
667        let red = map_run(
668            "P",
669            TestingFormat::Json,
670            serde_json::json!({"passed": 3, "failed": 0, "errors": 1, "total": 4}),
671        );
672        assert_eq!(red.verdict.as_deref(), Some("failed"));
673        assert_eq!(red.slug.as_deref(), Some(TESTS_FAILED_SLUG));
674        assert_eq!(red.failure_exit_code(), Some(6));
675    }
676
677    /// Every format produces the SAME verdict — the point of
678    /// rendering locally rather than passing `--format` to the route.
679    #[test]
680    fn the_verdict_is_identical_in_every_format() {
681        for format in [
682            TestingFormat::Json,
683            TestingFormat::Junit,
684            TestingFormat::Text,
685        ] {
686            let result = map_run("P", format, sample());
687            assert_eq!(
688                result.verdict.as_deref(),
689                Some("failed"),
690                "{} keeps the red verdict",
691                format.as_str()
692            );
693            assert_eq!(result.failure_exit_code(), Some(6));
694            assert_eq!(result.report.is_empty(), format == TestingFormat::Json);
695        }
696    }
697
698    /// The JUnit renderer mirrors the gateway reporter's document.
699    #[test]
700    fn junit_xml_mirrors_the_gateway_reporter() {
701        let xml = to_junit_xml(&sample());
702        assert!(xml.starts_with(r#"<?xml version="1.0" encoding="UTF-8"?>"#));
703        assert!(
704            xml.contains(
705                r#"<testsuites tests="3" failures="1" errors="0" skipped="1" time="1.500">"#
706            ),
707            "root attributes carry the counts: {xml}"
708        );
709        assert!(xml.contains(r#"<testsuite name="proj.foo_test" tests="3""#));
710        assert!(
711            xml.contains(r#"<testcase name="test_ok" classname="proj.foo_test" time="0.005">"#)
712        );
713        assert!(
714            xml.contains(r#"<failure message="1 != 2 &lt;&amp;&gt;">Traceback ...</failure>"#),
715            "the failure message is XML-escaped: {xml}"
716        );
717        assert!(xml.contains(r#"<skipped message="not today" />"#));
718        assert!(xml.ends_with("</testsuites>"));
719    }
720
721    /// The console renderer mirrors the gateway reporter's layout.
722    #[test]
723    fn console_text_mirrors_the_gateway_reporter() {
724        let text = to_console(&sample());
725        assert!(text.starts_with(&"=".repeat(60)));
726        assert!(text.contains("--- proj.foo_test ---"));
727        assert!(text.contains("  PASS  test_ok  (5ms)"));
728        assert!(text.contains("  FAIL  test_bad  (7ms)"));
729        assert!(
730            text.contains("         1 != 2 <&>"),
731            "a failure prints its message: {text}"
732        );
733        assert!(text.contains("  SKIP  test_skip"));
734        assert!(text.contains("Total: 3  Passed: 1  Failed: 1  Skipped: 1  Errors: 0  (1500ms)"));
735    }
736
737    /// Control characters are STRIPPED, not merely escaped (XML 1.0).
738    #[test]
739    fn escape_xml_strips_illegal_control_characters() {
740        assert_eq!(escape_xml("a\u{0}b\u{7}c"), "abc");
741        assert_eq!(escape_xml("keep\tthis\nand\rthat"), "keep\tthis\nand\rthat");
742        assert_eq!(
743            escape_xml(r#"<a href="x">&</a>"#),
744            "&lt;a href=&quot;x&quot;&gt;&amp;&lt;/a&gt;"
745        );
746    }
747}