use std::time::Duration;
use serde::Serialize;
use crate::client::ReqwestGatewayApi;
use crate::client::webdev::{TestingProbe, testing_probe};
use crate::error::{CoreError, TESTING_ROUTE_PREFIX};
fn testing_run_route() -> String {
format!("{TESTING_ROUTE_PREFIX}run")
}
const LAZY_COMPILE_WARMUP: Duration = Duration::from_secs(3);
pub const TESTS_FAILED_SLUG: &str = "tests_failed";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum TestingFormat {
#[default]
Json,
Junit,
Text,
}
impl TestingFormat {
pub fn as_str(self) -> &'static str {
match self {
Self::Json => "json",
Self::Junit => "junit",
Self::Text => "text",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct TestingRunOptions {
pub discover: bool,
pub module: Option<String>,
pub package: Option<String>,
pub format: TestingFormat,
}
#[derive(Debug, Serialize)]
pub struct TestingRunResult {
pub mode: String,
pub project: String,
pub modules: Vec<String>,
pub count: u32,
pub verdict: Option<String>,
pub slug: Option<String>,
pub passed: u32,
pub failed: u32,
pub skipped: u32,
pub errors: u32,
pub total: u32,
#[serde(rename = "durationMs")]
pub duration_ms: u64,
pub report: String,
pub results: serde_json::Value,
}
impl TestingRunResult {
pub fn failure_exit_code(&self) -> Option<u8> {
match self.verdict.as_deref() {
Some("failed") => Some(6),
_ => None,
}
}
fn discovered(project: &str, discovered: &serde_json::Value) -> Self {
let modules = string_list(discovered.get("discovered_modules"));
let count = discovered
.get("count")
.and_then(serde_json::Value::as_u64)
.map_or(modules.len() as u32, |count| count as u32);
Self {
mode: "discover".to_string(),
project: project.to_string(),
modules,
count,
verdict: None,
slug: None,
passed: 0,
failed: 0,
skipped: 0,
errors: 0,
total: 0,
duration_ms: 0,
report: String::new(),
results: serde_json::Value::Null,
}
}
}
pub async fn testing_run(
api: &ReqwestGatewayApi,
project: &str,
opts: &TestingRunOptions,
) -> Result<TestingRunResult, CoreError> {
check_selector_exclusivity(opts)?;
let route = testing_run_route();
let endpoint = || Some(format!("/system/webdev/{project}/{route}"));
let discovered = match probe_with_warmup(api, project).await? {
TestingProbe::Present { discovered } => discovered,
TestingProbe::Absent => {
return Err(CoreError::RoutesNotDeployed {
project: project.to_string(),
route: route.clone(),
endpoint: endpoint(),
});
}
TestingProbe::Unlicensed => {
return Err(CoreError::WebdevUnlicensed {
endpoint: endpoint(),
});
}
TestingProbe::AuthGated { status } => {
return Err(CoreError::Auth {
status,
endpoint: endpoint(),
});
}
TestingProbe::LazyCompile { body } => {
return Err(CoreError::Internal(format!(
"the testing route answered HTTP 500 twice (the first-touch \
lazy-compile warmup did not clear it): {body}"
)));
}
};
if opts.discover {
return Ok(TestingRunResult::discovered(project, &discovered));
}
let results = run_with_warmup(api, project, &run_body(opts)).await?;
Ok(map_run(project, opts.format, results))
}
fn check_selector_exclusivity(opts: &TestingRunOptions) -> Result<(), CoreError> {
let refuse = |first: &str, second: &str| {
Err(CoreError::InvalidInput {
reason: format!(
"{first} and {second} are mutually exclusive — they are different \
ways to answer the same question: pass exactly one (or none, to \
run every discovered module)"
),
})
};
match (opts.discover, opts.module.is_some(), opts.package.is_some()) {
(true, true, _) => refuse("--discover", "--module"),
(true, _, true) => refuse("--discover", "--package"),
(_, true, true) => refuse("--module", "--package"),
_ => Ok(()),
}
}
async fn probe_with_warmup(
api: &ReqwestGatewayApi,
project: &str,
) -> Result<TestingProbe, CoreError> {
match testing_probe(api, project).await? {
TestingProbe::LazyCompile { .. } => {
tokio::time::sleep(LAZY_COMPILE_WARMUP).await;
testing_probe(api, project).await
}
probe => Ok(probe),
}
}
async fn run_with_warmup(
api: &ReqwestGatewayApi,
project: &str,
body: &serde_json::Value,
) -> Result<serde_json::Value, CoreError> {
match crate::client::webdev::testing_run(api, project, body).await {
Ok(results) => Ok(results),
Err(CoreError::Internal(_)) => {
tokio::time::sleep(LAZY_COMPILE_WARMUP).await;
crate::client::webdev::testing_run(api, project, body).await
}
Err(err) => Err(err),
}
}
fn run_body(opts: &TestingRunOptions) -> serde_json::Value {
let mut body = serde_json::Map::new();
if let Some(module) = &opts.module {
body.insert("module".to_string(), serde_json::json!(module));
}
if let Some(package) = &opts.package {
body.insert("package".to_string(), serde_json::json!(package));
}
body.insert("format".to_string(), serde_json::json!("json"));
serde_json::Value::Object(body)
}
fn map_run(project: &str, format: TestingFormat, results: serde_json::Value) -> TestingRunResult {
let count_of = |key: &str| {
results
.get(key)
.and_then(serde_json::Value::as_u64)
.unwrap_or(0) as u32
};
let failed = count_of("failed");
let errors = count_of("errors");
let red = failed + errors > 0;
let modules = results
.get("modules")
.and_then(serde_json::Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| {
item.get("module")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
let report = match format {
TestingFormat::Json => String::new(),
TestingFormat::Junit => to_junit_xml(&results),
TestingFormat::Text => to_console(&results),
};
TestingRunResult {
mode: "run".to_string(),
project: project.to_string(),
count: modules.len() as u32,
modules,
verdict: Some(if red { "failed" } else { "passed" }.to_string()),
slug: red.then(|| TESTS_FAILED_SLUG.to_string()),
passed: count_of("passed"),
failed,
skipped: count_of("skipped"),
errors,
total: count_of("total"),
duration_ms: u64_of(&results, "duration_ms"),
report,
results,
}
}
fn string_list(field: Option<&serde_json::Value>) -> Vec<String> {
field
.and_then(serde_json::Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
fn report_modules(results: &serde_json::Value) -> Vec<&serde_json::Value> {
match results.get("modules").and_then(serde_json::Value::as_array) {
Some(modules) if !modules.is_empty() => modules.iter().collect(),
_ => vec![results],
}
}
fn u64_of(value: &serde_json::Value, key: &str) -> u64 {
value
.get(key)
.and_then(serde_json::Value::as_u64)
.unwrap_or(0)
}
fn str_of<'a>(value: &'a serde_json::Value, key: &str, fallback: &'a str) -> &'a str {
value
.get(key)
.and_then(serde_json::Value::as_str)
.unwrap_or(fallback)
}
pub fn to_junit_xml(results: &serde_json::Value) -> String {
let mut parts = vec![r#"<?xml version="1.0" encoding="UTF-8"?>"#.to_string()];
parts.push(format!(
r#"<testsuites tests="{}" failures="{}" errors="{}" skipped="{}" time="{:.3}">"#,
u64_of(results, "total"),
u64_of(results, "failed"),
u64_of(results, "errors"),
u64_of(results, "skipped"),
u64_of(results, "duration_ms") as f64 / 1000.0,
));
for module in report_modules(results) {
let module_name = str_of(module, "module", "unknown");
let cases = module
.get("results")
.and_then(serde_json::Value::as_array)
.map(Vec::as_slice)
.unwrap_or(&[]);
parts.push(format!(
r#" <testsuite name="{}" tests="{}" failures="{}" errors="{}" skipped="{}" time="{:.3}">"#,
escape_xml(module_name),
cases.len(),
u64_of(module, "failed"),
u64_of(module, "errors"),
u64_of(module, "skipped"),
u64_of(module, "duration_ms") as f64 / 1000.0,
));
for case in cases {
parts.push(format!(
r#" <testcase name="{}" classname="{}" time="{:.3}">"#,
escape_xml(str_of(case, "name", "unknown")),
escape_xml(module_name),
u64_of(case, "duration_ms") as f64 / 1000.0,
));
let message = escape_xml(str_of(case, "message", ""));
let traceback = escape_xml(str_of(case, "traceback", ""));
match str_of(case, "status", "error") {
"failed" => parts.push(format!(
r#" <failure message="{message}">{traceback}</failure>"#
)),
"error" => parts.push(format!(
r#" <error message="{message}">{traceback}</error>"#
)),
"skipped" => parts.push(format!(r#" <skipped message="{message}" />"#)),
_ => {}
}
parts.push(" </testcase>".to_string());
}
parts.push(" </testsuite>".to_string());
}
parts.push("</testsuites>".to_string());
parts.join("\n")
}
pub fn to_console(results: &serde_json::Value) -> String {
let rule = "=".repeat(60);
let mut lines = vec![rule.clone(), "TEST RESULTS".to_string(), rule.clone()];
for module in report_modules(results) {
lines.push(String::new());
lines.push(format!("--- {} ---", str_of(module, "module", "unknown")));
let cases = module
.get("results")
.and_then(serde_json::Value::as_array)
.map(Vec::as_slice)
.unwrap_or(&[]);
for case in cases {
let status = str_of(case, "status", "error").to_uppercase();
let marker = match status.as_str() {
"PASSED" => " PASS",
"FAILED" => " FAIL",
"SKIPPED" => " SKIP",
_ => " ERROR",
};
let mut line = format!("{marker} {}", str_of(case, "name", "unknown"));
let duration = u64_of(case, "duration_ms");
if duration > 0 {
line.push_str(&format!(" ({duration}ms)"));
}
lines.push(line);
let message = str_of(case, "message", "");
if !message.is_empty() && matches!(status.as_str(), "FAILED" | "ERROR") {
lines.push(format!(" {message}"));
}
}
}
lines.push(String::new());
lines.push(rule.clone());
lines.push(format!(
"Total: {} Passed: {} Failed: {} Skipped: {} Errors: {} ({}ms)",
u64_of(results, "total"),
u64_of(results, "passed"),
u64_of(results, "failed"),
u64_of(results, "skipped"),
u64_of(results, "errors"),
u64_of(results, "duration_ms"),
));
lines.push(rule);
lines.join("\n")
}
fn escape_xml(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for ch in text
.chars()
.filter(|ch| matches!(ch, '\t' | '\n' | '\r') || *ch >= '\u{20}')
{
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
other => out.push(other),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> serde_json::Value {
serde_json::json!({
"passed": 1,
"failed": 1,
"skipped": 1,
"errors": 0,
"total": 3,
"duration_ms": 1500,
"modules": [{
"module": "proj.foo_test",
"passed": 1,
"failed": 1,
"skipped": 1,
"errors": 0,
"duration_ms": 1500,
"results": [
{"name": "test_ok", "status": "passed", "duration_ms": 5},
{"name": "test_bad", "status": "failed", "duration_ms": 7,
"message": "1 != 2 <&>", "traceback": "Traceback ..."},
{"name": "test_skip", "status": "skipped", "duration_ms": 0,
"message": "not today"}
]
}]
})
}
#[test]
fn selectors_are_mutually_exclusive() {
let cases = [
(
TestingRunOptions {
discover: true,
module: Some("a.b".into()),
..Default::default()
},
["--discover", "--module"],
),
(
TestingRunOptions {
discover: true,
package: Some("a.".into()),
..Default::default()
},
["--discover", "--package"],
),
(
TestingRunOptions {
module: Some("a.b".into()),
package: Some("a.".into()),
..Default::default()
},
["--module", "--package"],
),
];
for (opts, flags) in cases {
let err = check_selector_exclusivity(&opts).expect_err("the pair refuses");
assert_eq!(err.code(), "invalid_input");
assert_eq!(err.exit_code(), 2, "usage class");
let message = err.to_string();
for flag in flags {
assert!(message.contains(flag), "message names {flag}: {message}");
}
}
assert!(check_selector_exclusivity(&TestingRunOptions::default()).is_ok());
assert!(
check_selector_exclusivity(&TestingRunOptions {
module: Some("a.b".into()),
..Default::default()
})
.is_ok()
);
}
#[test]
fn run_body_carries_only_what_was_set() {
assert_eq!(
run_body(&TestingRunOptions::default()),
serde_json::json!({"format": "json"})
);
assert_eq!(
run_body(&TestingRunOptions {
module: Some("a.b.c_test".into()),
format: TestingFormat::Junit,
..Default::default()
}),
serde_json::json!({"module": "a.b.c_test", "format": "json"}),
"the format flag never leaves the CLI — the report is rendered locally"
);
assert_eq!(
run_body(&TestingRunOptions {
package: Some("proj.".into()),
..Default::default()
}),
serde_json::json!({"package": "proj.", "format": "json"})
);
}
#[test]
fn verdict_reads_the_body_not_the_status() {
let green = map_run(
"P",
TestingFormat::Json,
serde_json::json!({"passed": 3, "failed": 0, "errors": 0, "total": 3}),
);
assert_eq!(green.verdict.as_deref(), Some("passed"));
assert_eq!(green.slug, None);
assert_eq!(green.failure_exit_code(), None);
assert!(green.report.is_empty(), "json mode renders no report");
let red = map_run(
"P",
TestingFormat::Json,
serde_json::json!({"passed": 3, "failed": 0, "errors": 1, "total": 4}),
);
assert_eq!(red.verdict.as_deref(), Some("failed"));
assert_eq!(red.slug.as_deref(), Some(TESTS_FAILED_SLUG));
assert_eq!(red.failure_exit_code(), Some(6));
}
#[test]
fn the_verdict_is_identical_in_every_format() {
for format in [
TestingFormat::Json,
TestingFormat::Junit,
TestingFormat::Text,
] {
let result = map_run("P", format, sample());
assert_eq!(
result.verdict.as_deref(),
Some("failed"),
"{} keeps the red verdict",
format.as_str()
);
assert_eq!(result.failure_exit_code(), Some(6));
assert_eq!(result.report.is_empty(), format == TestingFormat::Json);
}
}
#[test]
fn junit_xml_mirrors_the_gateway_reporter() {
let xml = to_junit_xml(&sample());
assert!(xml.starts_with(r#"<?xml version="1.0" encoding="UTF-8"?>"#));
assert!(
xml.contains(
r#"<testsuites tests="3" failures="1" errors="0" skipped="1" time="1.500">"#
),
"root attributes carry the counts: {xml}"
);
assert!(xml.contains(r#"<testsuite name="proj.foo_test" tests="3""#));
assert!(
xml.contains(r#"<testcase name="test_ok" classname="proj.foo_test" time="0.005">"#)
);
assert!(
xml.contains(r#"<failure message="1 != 2 <&>">Traceback ...</failure>"#),
"the failure message is XML-escaped: {xml}"
);
assert!(xml.contains(r#"<skipped message="not today" />"#));
assert!(xml.ends_with("</testsuites>"));
}
#[test]
fn console_text_mirrors_the_gateway_reporter() {
let text = to_console(&sample());
assert!(text.starts_with(&"=".repeat(60)));
assert!(text.contains("--- proj.foo_test ---"));
assert!(text.contains(" PASS test_ok (5ms)"));
assert!(text.contains(" FAIL test_bad (7ms)"));
assert!(
text.contains(" 1 != 2 <&>"),
"a failure prints its message: {text}"
);
assert!(text.contains(" SKIP test_skip"));
assert!(text.contains("Total: 3 Passed: 1 Failed: 1 Skipped: 1 Errors: 0 (1500ms)"));
}
#[test]
fn escape_xml_strips_illegal_control_characters() {
assert_eq!(escape_xml("a\u{0}b\u{7}c"), "abc");
assert_eq!(escape_xml("keep\tthis\nand\rthat"), "keep\tthis\nand\rthat");
assert_eq!(
escape_xml(r#"<a href="x">&</a>"#),
"<a href="x">&</a>"
);
}
}