1use 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
43fn testing_run_route() -> String {
50 format!("{TESTING_ROUTE_PREFIX}run")
51}
52
53const LAZY_COMPILE_WARMUP: Duration = Duration::from_secs(3);
59
60pub const TESTS_FAILED_SLUG: &str = "tests_failed";
63
64#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
79pub enum TestingFormat {
80 #[default]
82 Json,
83 Junit,
85 Text,
87}
88
89impl TestingFormat {
90 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#[derive(Debug, Clone, Default)]
104pub struct TestingRunOptions {
105 pub discover: bool,
107 pub module: Option<String>,
109 pub package: Option<String>,
111 pub format: TestingFormat,
113}
114
115#[derive(Debug, Serialize)]
119pub struct TestingRunResult {
120 pub mode: String,
122 pub project: String,
124 pub modules: Vec<String>,
126 pub count: u32,
128 pub verdict: Option<String>,
131 pub slug: Option<String>,
134 pub passed: u32,
136 pub failed: u32,
138 pub skipped: u32,
140 pub errors: u32,
142 pub total: u32,
144 #[serde(rename = "durationMs")]
146 pub duration_ms: u64,
147 pub report: String,
150 pub results: serde_json::Value,
154}
155
156impl TestingRunResult {
157 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 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
195pub async fn testing_run(
202 api: &ReqwestGatewayApi,
203 project: &str,
204 opts: &TestingRunOptions,
205) -> Result<TestingRunResult, CoreError> {
206 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 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 let results = run_with_warmup(api, project, &run_body(opts)).await?;
251 Ok(map_run(project, opts.format, results))
252}
253
254fn 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
276async 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
290async 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 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
313fn 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
328fn 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
378fn 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
393fn 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
403fn 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
411fn 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
419pub 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
476pub 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
524fn 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("&"),
536 '<' => out.push_str("<"),
537 '>' => out.push_str(">"),
538 '"' => out.push_str("""),
539 other => out.push(other),
540 }
541 }
542 out
543}
544
545#[cfg(test)]
546mod tests {
547 use super::*;
548
549 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 #[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 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 #[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 #[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 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 #[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 #[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 <&>">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 #[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 #[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 "<a href="x">&</a>"
745 );
746 }
747}