use std::{collections::BTreeMap, time::UNIX_EPOCH};
use serde_json::{json, Value};
use crate::{
automation::{self, PromptRequirement},
error::HenError,
request::{
AssertionOutcome, AssertionStatus, ExecutionRecord, RequestFailure, RequestFailureKind,
},
};
#[derive(Debug, Clone, Copy)]
pub struct BodyReportOptions {
pub include_body: bool,
pub max_body_chars: Option<usize>,
}
impl Default for BodyReportOptions {
fn default() -> Self {
Self {
include_body: true,
max_body_chars: None,
}
}
}
pub fn run_outcome_json(
outcome: &automation::RunOutcome,
body_options: BodyReportOptions,
) -> Value {
json!({
"collection": {
"path": outcome.collection.path.display().to_string(),
"name": outcome.collection.name,
"description": outcome.collection.description,
"requiredInputs": outcome.collection.required_inputs.iter().map(prompt_requirement_json).collect::<Vec<_>>(),
"requests": outcome.collection.requests.iter().map(|request| {
json!({
"index": request.index,
"description": request.description,
"method": request.method,
"url": request.url,
"dependencies": request.dependencies,
})
}).collect::<Vec<_>>()
},
"plan": outcome.plan,
"selectedRequests": outcome.selected_requests,
"primaryTarget": outcome.primary_target,
"executionFailed": outcome.execution_failed,
"interrupted": outcome.interrupted.is_some(),
"interruptSignal": outcome.interrupted.map(|signal| signal.as_str()),
"records": outcome.records.iter().map(|record| run_record_json(record, body_options)).collect::<Vec<_>>(),
"failures": outcome.failures.iter().map(request_failure_json).collect::<Vec<_>>(),
})
}
pub fn verification_result_json(result: &automation::VerificationResult) -> Value {
json!({
"path": result.path.as_ref().map(|path| path.display().to_string()),
"name": result.summary.name,
"description": result.summary.description,
"requiredInputs": result.required_inputs.iter().map(prompt_requirement_json).collect::<Vec<_>>(),
"requests": result.summary.requests.iter().map(|request| {
json!({
"index": request.index,
"description": request.description,
"method": request.method,
"url": request.url,
})
}).collect::<Vec<_>>(),
})
}
pub fn hen_error_json(error: &HenError) -> Value {
json!({
"error": {
"kind": error.kind().label(),
"summary": error.summary(),
"details": error.details(),
"exitCode": error.exit_code(),
}
})
}
pub fn run_outcome_ndjson(
outcome: &automation::RunOutcome,
body_options: BodyReportOptions,
) -> String {
let mut lines = vec![json!({
"type": "run",
"collection": {
"path": outcome.collection.path.display().to_string(),
"name": outcome.collection.name,
"description": outcome.collection.description,
},
"plan": outcome.plan,
"selectedRequests": outcome.selected_requests,
"primaryTarget": outcome.primary_target,
"executionFailed": outcome.execution_failed,
"interrupted": outcome.interrupted.is_some(),
"interruptSignal": outcome.interrupted.map(|signal| signal.as_str()),
"recordCount": outcome.records.len(),
"failureCount": outcome.failures.len(),
})];
lines.extend(
outcome
.records
.iter()
.map(|record| with_type(run_record_json(record, body_options), "record")),
);
lines.extend(
outcome
.failures
.iter()
.map(|failure| with_type(request_failure_json(failure), "failure")),
);
render_ndjson(lines)
}
pub fn verification_result_ndjson(result: &automation::VerificationResult) -> String {
let mut lines = vec![json!({
"type": "verify",
"path": result.path.as_ref().map(|path| path.display().to_string()),
"name": result.summary.name,
"description": result.summary.description,
})];
lines.extend(result.summary.requests.iter().map(|request| {
json!({
"type": "request",
"index": request.index,
"description": request.description,
"method": request.method,
"url": request.url,
})
}));
lines.extend(result.required_inputs.iter().map(|prompt| {
json!({
"type": "requiredInput",
"name": prompt.name,
"default": prompt.default,
})
}));
render_ndjson(lines)
}
pub fn hen_error_ndjson(error: &HenError) -> String {
render_ndjson(vec![with_type(hen_error_json(error), "error")])
}
pub fn run_outcome_junit(outcome: &automation::RunOutcome) -> String {
let suite_name = report_suite_name(
&outcome.collection.name,
&outcome.collection.path.display().to_string(),
);
let mut records_by_index: BTreeMap<usize, &ExecutionRecord> = outcome
.records
.iter()
.map(|record| (record.index, record))
.collect();
let mut failures_by_index: BTreeMap<usize, &RequestFailure> = outcome
.failures
.iter()
.filter_map(|failure| failure.index().map(|index| (index, failure)))
.collect();
let mut cases = Vec::new();
let request_summaries: BTreeMap<usize, &automation::RequestSummary> = outcome
.collection
.requests
.iter()
.map(|request| (request.index, request))
.collect();
for index in &outcome.plan {
if let Some(record) = records_by_index.remove(index) {
cases.extend(record_cases(
&suite_name,
request_summaries.get(index).copied(),
record,
));
continue;
}
if let Some(failure) = failures_by_index.remove(index) {
cases.extend(failure_cases(
&suite_name,
request_summaries.get(index).copied(),
failure,
));
}
}
cases.extend(records_by_index.into_values().flat_map(|record| {
record_cases(
&suite_name,
request_summaries.get(&record.index).copied(),
record,
)
}));
cases.extend(failures_by_index.into_values().flat_map(|failure| {
failure_cases(
&suite_name,
failure
.index()
.and_then(|index| request_summaries.get(&index).copied()),
failure,
)
}));
cases.extend(
outcome
.failures
.iter()
.filter(|failure| failure.index().is_none())
.flat_map(|failure| failure_cases(&suite_name, None, failure)),
);
if let Some(signal) = outcome.interrupted {
cases.push(JUnitCase {
class_name: suite_name.clone(),
name: format!("run interrupted by {}", signal.as_str()),
time_secs: 0.0,
status: JUnitCaseStatus::Error {
kind: "interrupted".to_string(),
message: format!("Execution interrupted by {}", signal.as_str()),
details: format!(
"Execution interrupted by {} before completing all planned requests",
signal.as_str()
),
},
});
}
render_junit_suite(
&suite_name,
total_elapsed(&outcome.records)
.unwrap_or_default()
.as_secs_f64(),
&[
("path", outcome.collection.path.display().to_string()),
(
"selectedRequests",
outcome
.selected_requests
.iter()
.map(|index| index.to_string())
.collect::<Vec<_>>()
.join(","),
),
(
"plan",
outcome
.plan
.iter()
.map(|index| index.to_string())
.collect::<Vec<_>>()
.join(","),
),
("executionFailed", outcome.execution_failed.to_string()),
("interrupted", outcome.interrupted.is_some().to_string()),
(
"interruptSignal",
outcome
.interrupted
.map(|signal| signal.as_str().to_string())
.unwrap_or_default(),
),
],
&cases,
)
}
pub fn verification_result_junit(result: &automation::VerificationResult) -> String {
let fallback = result
.path
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "hen verify".to_string());
let suite_name = report_suite_name(&result.summary.name, &fallback);
render_junit_suite(
&suite_name,
0.0,
&[("path", fallback.clone())],
&[JUnitCase {
class_name: suite_name.clone(),
name: "syntax validation".to_string(),
time_secs: 0.0,
status: JUnitCaseStatus::Passed,
}],
)
}
pub fn hen_error_junit(suite_name: &str, case_name: &str, error: &HenError) -> String {
render_junit_suite(
suite_name,
0.0,
&[],
&[JUnitCase {
class_name: suite_name.to_string(),
name: case_name.to_string(),
time_secs: 0.0,
status: JUnitCaseStatus::Error {
kind: error.kind().label().to_string(),
message: error.summary().to_string(),
details: error.to_string(),
},
}],
)
}
fn request_failure_json(failure: &RequestFailure) -> Value {
json!({
"index": failure.index(),
"request": failure.request(),
"kind": format!("{:?}", failure.kind()),
"message": failure.to_string(),
"assertions": assertion_results_json(failure.assertions()),
})
}
fn prompt_requirement_json(prompt: &PromptRequirement) -> Value {
json!({
"name": prompt.name,
"default": prompt.default,
})
}
fn run_record_json(record: &ExecutionRecord, body_options: BodyReportOptions) -> Value {
let body = describe_text(&record.execution.output, body_options);
json!({
"index": record.index,
"description": record.description,
"method": record.method.as_str(),
"url": record.url,
"status": record.execution.snapshot.status.as_u16(),
"statusText": record.execution.snapshot.status.canonical_reason(),
"startedAtUnixMs": record.started_at.duration_since(UNIX_EPOCH).ok().map(|value| value.as_millis() as u64),
"durationMs": record.duration.as_millis() as u64,
"body": body.value,
"bodyChars": body.char_count as u64,
"bodyCharLimit": body.char_limit.map(|value| value as u64),
"bodyTruncated": body.truncated,
"assertions": assertion_results_json(&record.execution.assertions),
})
}
fn assertion_results_json(assertions: &[AssertionOutcome]) -> Vec<Value> {
assertions
.iter()
.map(|assertion| {
json!({
"assertion": assertion.assertion,
"status": assertion.status.as_str(),
"message": assertion.message,
})
})
.collect()
}
fn with_type(mut value: Value, kind: &str) -> Value {
if let Value::Object(ref mut map) = value {
map.insert("type".to_string(), Value::String(kind.to_string()));
}
value
}
fn render_ndjson(lines: Vec<Value>) -> String {
lines
.into_iter()
.map(|line| serde_json::to_string(&line).expect("ndjson line should serialize"))
.collect::<Vec<_>>()
.join("\n")
}
#[derive(Debug, Clone)]
struct JUnitCase {
class_name: String,
name: String,
time_secs: f64,
status: JUnitCaseStatus,
}
#[derive(Debug, Clone)]
enum JUnitCaseStatus {
Passed,
Failure {
kind: String,
message: String,
details: String,
},
Skipped {
message: String,
},
Error {
kind: String,
message: String,
details: String,
},
}
fn success_case(
suite_name: &str,
request_summary: Option<&automation::RequestSummary>,
record: &ExecutionRecord,
) -> JUnitCase {
JUnitCase {
class_name: suite_name.to_string(),
name: request_case_name(
record.index,
request_summary,
record.method.as_str(),
&record.url,
&record.description,
),
time_secs: record.duration.as_secs_f64(),
status: JUnitCaseStatus::Passed,
}
}
fn record_cases(
suite_name: &str,
request_summary: Option<&automation::RequestSummary>,
record: &ExecutionRecord,
) -> Vec<JUnitCase> {
let request_name = request_case_name(
record.index,
request_summary,
record.method.as_str(),
&record.url,
&record.description,
);
if record.execution.assertions.is_empty() {
return vec![success_case(suite_name, request_summary, record)];
}
let case_time_secs = record.duration.as_secs_f64() / record.execution.assertions.len() as f64;
assertion_cases(
suite_name,
&request_name,
&record.execution.assertions,
case_time_secs,
"assertion",
)
}
fn failure_case(
suite_name: &str,
request_summary: Option<&automation::RequestSummary>,
failure: &RequestFailure,
) -> JUnitCase {
let name = match failure.index() {
Some(index) => request_case_name(
index,
request_summary,
request_summary
.map(|summary| summary.method.as_str())
.unwrap_or("request"),
request_summary
.map(|summary| summary.url.as_str())
.unwrap_or(""),
failure.request(),
),
None => failure.request().to_string(),
};
let status = match failure.kind() {
RequestFailureKind::Execution { message, .. } => JUnitCaseStatus::Failure {
kind: "execution".to_string(),
message: message.clone(),
details: failure.to_string(),
},
RequestFailureKind::Dependency { dependency } => JUnitCaseStatus::Skipped {
message: format!("dependency '{}' failed", dependency),
},
RequestFailureKind::MissingDependency { dependency } => JUnitCaseStatus::Skipped {
message: format!("missing dependency '{}'", dependency),
},
RequestFailureKind::MapAborted { group, cause } => JUnitCaseStatus::Skipped {
message: format!("'{}' aborted after failure in '{}'", group, cause),
},
RequestFailureKind::Join { message } => JUnitCaseStatus::Error {
kind: "join".to_string(),
message: message.clone(),
details: failure.to_string(),
},
};
JUnitCase {
class_name: suite_name.to_string(),
name,
time_secs: 0.0,
status,
}
}
fn failure_cases(
suite_name: &str,
request_summary: Option<&automation::RequestSummary>,
failure: &RequestFailure,
) -> Vec<JUnitCase> {
match failure.kind() {
RequestFailureKind::Execution { assertions, .. } if !assertions.is_empty() => {
let request_name = match failure.index() {
Some(index) => request_case_name(
index,
request_summary,
request_summary
.map(|summary| summary.method.as_str())
.unwrap_or("request"),
request_summary
.map(|summary| summary.url.as_str())
.unwrap_or(""),
failure.request(),
),
None => failure.request().to_string(),
};
assertion_cases(suite_name, &request_name, assertions, 0.0, "assertion")
}
_ => vec![failure_case(suite_name, request_summary, failure)],
}
}
fn assertion_cases(
suite_name: &str,
request_name: &str,
assertions: &[AssertionOutcome],
time_secs: f64,
failure_kind: &str,
) -> Vec<JUnitCase> {
assertions
.iter()
.map(|assertion| JUnitCase {
class_name: suite_name.to_string(),
name: assertion_case_name(request_name, &assertion.assertion),
time_secs,
status: assertion_status_to_junit(&assertion.status, &assertion.message, &assertion.assertion, failure_kind),
})
.collect()
}
fn assertion_status_to_junit(
status: &AssertionStatus,
message: &Option<String>,
assertion: &str,
failure_kind: &str,
) -> JUnitCaseStatus {
match status {
AssertionStatus::Passed => JUnitCaseStatus::Passed,
AssertionStatus::Skipped => JUnitCaseStatus::Skipped {
message: message
.clone()
.unwrap_or_else(|| "assertion skipped".to_string()),
},
AssertionStatus::Failed => {
let details = message.clone().unwrap_or_else(|| assertion.to_string());
JUnitCaseStatus::Failure {
kind: failure_kind.to_string(),
message: details.clone(),
details,
}
}
}
}
fn assertion_case_name(request_name: &str, assertion: &str) -> String {
format!("{} :: {}", request_name, assertion)
}
fn request_case_name(
index: usize,
request_summary: Option<&automation::RequestSummary>,
method: &str,
url: &str,
fallback: &str,
) -> String {
match request_summary {
Some(summary) => format!("#{} {} {}", summary.index, summary.method, summary.url),
None if !url.is_empty() => format!("#{} {} {}", index, method, url),
None => format!("#{} {}", index, fallback),
}
}
fn render_junit_suite(
suite_name: &str,
suite_time_secs: f64,
properties: &[(&str, String)],
cases: &[JUnitCase],
) -> String {
let failure_count = cases
.iter()
.filter(|case| matches!(case.status, JUnitCaseStatus::Failure { .. }))
.count();
let skipped_count = cases
.iter()
.filter(|case| matches!(case.status, JUnitCaseStatus::Skipped { .. }))
.count();
let error_count = cases
.iter()
.filter(|case| matches!(case.status, JUnitCaseStatus::Error { .. }))
.count();
let mut xml = String::new();
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.push_str(&format!(
"<testsuite name=\"{}\" tests=\"{}\" failures=\"{}\" errors=\"{}\" skipped=\"{}\" time=\"{}\">",
escape_xml(suite_name),
cases.len(),
failure_count,
error_count,
skipped_count,
format_junit_time(suite_time_secs),
));
if !properties.is_empty() {
xml.push_str("\n <properties>");
for (name, value) in properties {
xml.push_str(&format!(
"\n <property name=\"{}\" value=\"{}\"/>",
escape_xml(name),
escape_xml(value),
));
}
xml.push_str("\n </properties>");
}
for case in cases {
xml.push_str(&format!(
"\n <testcase classname=\"{}\" name=\"{}\" time=\"{}\">",
escape_xml(&case.class_name),
escape_xml(&case.name),
format_junit_time(case.time_secs),
));
match &case.status {
JUnitCaseStatus::Passed => {}
JUnitCaseStatus::Failure {
kind,
message,
details,
} => {
xml.push_str(&format!(
"\n <failure type=\"{}\" message=\"{}\">{}</failure>",
escape_xml(kind),
escape_xml(message),
escape_xml(details),
));
}
JUnitCaseStatus::Skipped { message } => {
xml.push_str(&format!(
"\n <skipped message=\"{}\"/>",
escape_xml(message),
));
}
JUnitCaseStatus::Error {
kind,
message,
details,
} => {
xml.push_str(&format!(
"\n <error type=\"{}\" message=\"{}\">{}</error>",
escape_xml(kind),
escape_xml(message),
escape_xml(details),
));
}
}
xml.push_str("\n </testcase>");
}
xml.push_str("\n</testsuite>\n");
xml
}
fn report_suite_name(name: &str, fallback: &str) -> String {
let trimmed = name.trim();
if trimmed.is_empty() {
fallback.to_string()
} else {
trimmed.to_string()
}
}
fn format_junit_time(time_secs: f64) -> String {
if time_secs == 0.0 {
"0".to_string()
} else {
format!("{:.3}", time_secs)
}
}
fn escape_xml(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn total_elapsed(records: &[ExecutionRecord]) -> Option<std::time::Duration> {
if records.is_empty() {
return None;
}
let mut earliest: Option<std::time::Duration> = None;
let mut latest: Option<std::time::Duration> = None;
for record in records {
let start = match record.started_at.duration_since(UNIX_EPOCH) {
Ok(duration) => duration,
Err(_) => continue,
};
let finish = match record
.started_at
.checked_add(record.duration)
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
{
Some(duration) => duration,
None => continue,
};
earliest = Some(match earliest {
Some(current) => current.min(start),
None => start,
});
latest = Some(match latest {
Some(current) => current.max(finish),
None => finish,
});
}
match (earliest, latest) {
(Some(start), Some(end)) if end >= start => Some(end - start),
_ => None,
}
}
struct TextDescription {
value: Option<String>,
truncated: bool,
char_count: usize,
char_limit: Option<usize>,
}
fn describe_text(value: &str, options: BodyReportOptions) -> TextDescription {
let char_count = value.chars().count();
let char_limit = options.max_body_chars;
if !options.include_body {
return TextDescription {
value: None,
truncated: false,
char_count,
char_limit,
};
}
match char_limit {
Some(limit) if char_count > limit => TextDescription {
value: Some(value.chars().take(limit).collect()),
truncated: true,
char_count,
char_limit,
},
_ => TextDescription {
value: Some(value.to_string()),
truncated: false,
char_count,
char_limit,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::{
collections::HashMap,
path::PathBuf,
time::{Duration, SystemTime},
};
use http::{Method, StatusCode};
use crate::{
automation::{CollectionSummary, RunOutcome, VerificationResult},
parser::{SyntaxRequestSummary, SyntaxSummary},
request::{
AssertionOutcome, AssertionStatus, ExecutionRecord, RequestExecution,
ResponseSnapshot,
},
};
#[test]
fn verification_result_json_matches_golden_fixture() {
let result = VerificationResult {
path: Some(PathBuf::from("/workspace/collection.hen")),
summary: SyntaxSummary {
name: "Fixture Collection".to_string(),
description: "Collection used for structured output fixtures.".to_string(),
requests: vec![
SyntaxRequestSummary {
index: 0,
description: "Get one".to_string(),
method: "GET".to_string(),
url: "https://example.com/one".to_string(),
},
SyntaxRequestSummary {
index: 1,
description: "Get two".to_string(),
method: "POST".to_string(),
url: "https://example.com/two".to_string(),
},
],
},
required_inputs: vec![
PromptRequirement {
name: "api_token".to_string(),
default: None,
},
PromptRequirement {
name: "region".to_string(),
default: Some("us-east-1".to_string()),
},
],
};
assert_json_fixture(
verification_result_json(&result),
include_str!("../tests/fixtures/golden/verify_hen_syntax.json"),
);
}
#[test]
fn run_outcome_json_matches_golden_fixture() {
let result = RunOutcome {
collection: CollectionSummary {
path: PathBuf::from("/workspace/collection.hen"),
name: "Fixture Collection".to_string(),
description: "Collection used for structured output fixtures.".to_string(),
requests: vec![crate::automation::RequestSummary {
index: 0,
description: "Get one".to_string(),
method: "GET".to_string(),
url: "https://example.com/one".to_string(),
dependencies: vec![],
}],
required_inputs: vec![PromptRequirement {
name: "api_token".to_string(),
default: None,
}],
},
plan: vec![0],
selected_requests: vec![0],
primary_target: Some(0),
records: vec![ExecutionRecord {
index: 0,
description: "Get one".to_string(),
method: Method::GET,
url: "https://example.com/one".to_string(),
execution: RequestExecution {
output: "hello world".to_string(),
export_env: HashMap::new(),
snapshot: ResponseSnapshot {
status: StatusCode::OK,
headers: Default::default(),
body: "hello world".to_string(),
json: None,
},
assertions: vec![
AssertionOutcome {
assertion: "^ & body.ok == true".to_string(),
status: AssertionStatus::Passed,
message: None,
},
AssertionOutcome {
assertion: "[ true == false ] ^ & body.service == 'hen'".to_string(),
status: AssertionStatus::Skipped,
message: Some("guard evaluated to false".to_string()),
},
],
},
started_at: SystemTime::UNIX_EPOCH + Duration::from_millis(1_704_067_200_000),
duration: Duration::from_millis(25),
}],
failures: vec![],
execution_failed: false,
interrupted: None,
};
assert_json_fixture(
run_outcome_json(
&result,
BodyReportOptions {
include_body: true,
max_body_chars: Some(5),
},
),
include_str!("../tests/fixtures/golden/run_hen.json"),
);
}
#[test]
fn run_outcome_ndjson_emits_summary_and_record_lines() {
let result = sample_run_outcome();
let lines = run_outcome_ndjson(
&result,
BodyReportOptions {
include_body: true,
max_body_chars: None,
},
)
.lines()
.map(|line| serde_json::from_str::<Value>(line).expect("ndjson line should parse"))
.collect::<Vec<_>>();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0]["type"], "run");
assert_eq!(lines[0]["interrupted"], false);
assert_eq!(lines[0]["interruptSignal"], Value::Null);
assert_eq!(lines[1]["type"], "record");
assert_eq!(lines[1]["status"], 200);
assert_eq!(lines[1]["assertions"][0]["status"], "passed");
assert_eq!(lines[1]["assertions"][1]["status"], "skipped");
}
#[test]
fn run_outcome_junit_renders_testcase() {
let junit = run_outcome_junit(&sample_run_outcome());
assert!(junit.contains("<testsuite"));
assert!(junit.contains(
"<testcase classname=\"Fixture Collection\" name=\"#0 GET https://example.com/one :: ^ & body.ok == true\""
));
assert!(junit.contains("tests=\"2\""));
assert!(junit.contains("skipped=\"1\""));
assert!(junit.contains("<skipped message=\"guard evaluated to false\"/>"));
}
#[test]
fn run_outcome_junit_includes_interruption_case() {
let mut result = sample_run_outcome();
result.execution_failed = true;
result.interrupted = Some(crate::request::InterruptSignal::Sigterm);
let junit = run_outcome_junit(&result);
assert!(junit.contains("errors=\"1\""));
assert!(junit.contains("run interrupted by SIGTERM"));
assert!(junit.contains("Execution interrupted by SIGTERM"));
}
fn sample_run_outcome() -> RunOutcome {
RunOutcome {
collection: CollectionSummary {
path: PathBuf::from("/workspace/collection.hen"),
name: "Fixture Collection".to_string(),
description: "Collection used for structured output fixtures.".to_string(),
requests: vec![crate::automation::RequestSummary {
index: 0,
description: "Get one".to_string(),
method: "GET".to_string(),
url: "https://example.com/one".to_string(),
dependencies: vec![],
}],
required_inputs: vec![PromptRequirement {
name: "api_token".to_string(),
default: None,
}],
},
plan: vec![0],
selected_requests: vec![0],
primary_target: Some(0),
records: vec![ExecutionRecord {
index: 0,
description: "Get one".to_string(),
method: Method::GET,
url: "https://example.com/one".to_string(),
execution: RequestExecution {
output: "hello world".to_string(),
export_env: HashMap::new(),
snapshot: ResponseSnapshot {
status: StatusCode::OK,
headers: Default::default(),
body: "hello world".to_string(),
json: None,
},
assertions: vec![
AssertionOutcome {
assertion: "^ & body.ok == true".to_string(),
status: AssertionStatus::Passed,
message: None,
},
AssertionOutcome {
assertion: "[ true == false ] ^ & body.service == 'hen'".to_string(),
status: AssertionStatus::Skipped,
message: Some("guard evaluated to false".to_string()),
},
],
},
started_at: SystemTime::UNIX_EPOCH + Duration::from_millis(1_704_067_200_000),
duration: Duration::from_millis(25),
}],
failures: vec![],
execution_failed: false,
interrupted: None,
}
}
fn assert_json_fixture(value: Value, expected: &str) {
let actual = serde_json::to_string_pretty(&value).expect("json should serialize");
assert_eq!(actual.trim(), expected.trim());
}
}