#![cfg(feature = "serde")]
#![cfg_attr(
feature = "unstable-error-generic-member-access",
feature(error_generic_member_access)
)]
#![allow(
clippy::all,
reason = "integration test fixtures intentionally trip style lints"
)]
mod common;
#[cfg(feature = "tracing")]
use oopsie::erased::TracingLevel;
use oopsie::erased::{ErasedBacktrace, ErasedError, ErasedMetadata, ErasedSpan, ErasedSpanTrace};
use oopsie::oopsie;
use oopsie_core::{redact, snap_name};
#[oopsie(traced)]
#[oopsie("Something went wrong: {message}")]
#[oopsie(help = "Try restarting the service")]
pub struct ErrorWithHelp {
message: String,
}
#[oopsie(traced)]
#[oopsie("Code-only error: {message}")]
pub struct ErrorWithCodeOnly {
message: String,
}
#[oopsie(traced)]
#[oopsie("Exit-coded error: {message}")]
#[oopsie(exit_code = 78)]
pub struct ErrorWithExitCode {
message: String,
}
#[cfg(feature = "tracing")]
#[test]
#[test_with::env(OOPSIE_BACKTRACE_SNAPSHOT_TESTS)]
fn test_erased_error_text() {
let error = ErasedError::from_error(common::make_error());
redact!(backtrace, {
insta::assert_snapshot!(snap_name!("erased_error_text"), error.to_text());
});
}
#[cfg(feature = "tracing")]
#[test]
#[test_with::env(OOPSIE_BACKTRACE_SNAPSHOT_TESTS)]
fn test_erased_error_json() {
let error = ErasedError::from_error(common::make_error());
redact!(backtrace, {
insta::assert_json_snapshot!(snap_name!("erased_error_json"), error);
});
}
#[test]
fn test_help_extraction() {
let error = ErrorWithHelpOopsie {
message: "connection refused",
}
.build();
let erased = ErasedError::from_error(error);
assert_eq!(
erased.diagnostics().help(),
Some("Try restarting the service")
);
assert!(erased.diagnostics().code().is_some());
}
#[test]
fn test_code_only_extraction() {
let error = ErrorWithCodeOnlyOopsie { message: "timeout" }.build();
let erased = ErasedError::from_error(error);
assert!(erased.diagnostics().code().is_some());
assert_eq!(erased.diagnostics().help(), None);
}
#[test]
fn test_exit_code_survives_erasure_and_round_trip() {
use oopsie::Diagnostic as _;
let error = ErrorWithExitCodeOopsie { message: "boom" }.build();
let erased = ErasedError::from_error(error);
let expected = std::num::NonZeroU8::new(78).unwrap();
assert_eq!(erased.diagnostics().exit_code(), Some(expected));
assert_eq!(erased.oopsie_exit_code(), Some(expected));
let json = serde_json::to_string(&erased).unwrap();
let restored: ErasedError = serde_json::from_str(&json).unwrap();
assert_eq!(restored.diagnostics().exit_code(), Some(expected));
}
#[test]
fn test_format_short_includes_help() {
let error = ErrorWithHelpOopsie {
message: "connection refused",
}
.build();
let erased = ErasedError::from_error(error);
let short = erased.format_short();
redact!(backtrace, {
insta::assert_snapshot!(snap_name!("format_short_with_help"), short);
});
}
#[cfg(feature = "tracing")]
#[test]
fn test_extract_backtrace_returns_some_when_provided() {
let error = common::make_error();
let bt = oopsie::Diagnostic::oopsie_backtrace(&error);
assert!(
bt.is_some(),
"extract_backtrace should return Some for oopsie errors"
);
}
#[test]
fn test_extract_error_code_returns_some_for_oopsie_errors() {
let error = ErrorWithHelpOopsie { message: "test" }.build();
let erased = ErasedError::from_error(error);
assert!(
erased.diagnostics().code().is_some(),
"oopsie errors with help should have an error code"
);
}
#[test]
fn test_diagnostics_is_none_for_default() {
let diag = oopsie::erased::Diagnostics::default();
assert!(
diag.is_none(),
"default Diagnostics should have no code or help"
);
}
#[test]
fn test_diagnostics_is_not_none_when_code_present() {
let error = ErrorWithCodeOnlyOopsie {
message: "has code",
}
.build();
let erased = ErasedError::from_error(error);
assert!(
!erased.diagnostics().is_none(),
"Diagnostics with a code should not be is_none()"
);
}
#[test]
fn test_write_text_contains_message_and_help() {
let error = ErrorWithHelpOopsie {
message: "text output test",
}
.build();
let erased = ErasedError::from_error(error);
let mut buf = Vec::new();
erased.write_text(&mut buf).unwrap();
let text = String::from_utf8(buf).unwrap();
assert!(
text.contains("text output test"),
"write_text should include the error message"
);
assert!(
text.contains("Try restarting the service"),
"write_text should include help text"
);
}
#[test]
fn test_write_json_output_is_valid_json_with_expected_fields() {
let error = ErrorWithHelpOopsie {
message: "json output test",
}
.build();
let erased = ErasedError::from_error(error);
let mut buf = Vec::new();
erased.write_json(&mut buf).unwrap();
let json: serde_json::Value =
serde_json::from_slice(&buf).expect("write_json should produce valid JSON");
assert_eq!(
json["message"], "Something went wrong: json output test",
"JSON message field should match Display"
);
assert_eq!(
json["diagnostics"]["help"], "Try restarting the service",
"JSON diagnostics.help should match oopsie help attribute"
);
}
#[test]
fn test_erased_frame_display_name_without_location() {
let json = serde_json::json!({
"frames": [
{ "name": "my::func", "filename": null, "line": null, "column": null }
]
});
let bt: ErasedBacktrace = serde_json::from_value(json).unwrap();
assert_eq!(bt.to_string(), " 1: my::func\n");
}
#[cfg(feature = "tracing")]
#[test]
fn test_erased_error_clone() {
let original = ErasedError::from_error(common::make_error());
let cloned = original.clone();
assert_eq!(original.message(), cloned.message());
assert_eq!(original.source_chain(), cloned.source_chain());
assert_eq!(
original.diagnostics().code(),
cloned.diagnostics().code(),
"cloned diagnostics code must match"
);
assert_eq!(
original.diagnostics().help(),
cloned.diagnostics().help(),
"cloned diagnostics help must match"
);
}
#[test]
fn test_erased_backtrace_clone() {
let json = serde_json::json!({
"frames": [
{ "name": "frame::one", "filename": "src/lib.rs", "line": 10, "column": 2 }
]
});
let original: ErasedBacktrace = serde_json::from_value(json).unwrap();
let cloned = original.clone();
assert_eq!(original.to_string(), cloned.to_string());
assert_eq!(original.frames().len(), cloned.frames().len());
}
#[test]
fn test_erased_spantrace_and_span_and_metadata_clone() {
let json = serde_json::json!({
"spans": [{
"metadata": {
"name": "span", "target": "tgt", "level": "INFO",
"file": "src/lib.rs", "line": 7
},
"fields": "k=v"
}]
});
let spantrace: ErasedSpanTrace = serde_json::from_value(json).unwrap();
let cloned_spantrace = spantrace.clone();
assert_eq!(spantrace, cloned_spantrace);
let span: ErasedSpan = serde_json::from_value(serde_json::json!({
"metadata": { "name": "s", "target": "t", "level": "WARN" },
"fields": ""
}))
.unwrap();
let cloned_span = span.clone();
assert_eq!(span, cloned_span);
let metadata = cloned_span.metadata().clone();
assert_eq!(span.metadata(), &metadata);
}
#[test]
fn test_erased_spantrace_partial_eq() {
let json = serde_json::json!({
"spans": [{
"metadata": { "name": "n", "target": "t", "level": "INFO" },
"fields": "a=1"
}]
});
let left: ErasedSpanTrace = serde_json::from_value(json.clone()).unwrap();
let right: ErasedSpanTrace = serde_json::from_value(json).unwrap();
assert_eq!(left, right, "identical spantraces must be equal");
let other: ErasedSpanTrace = serde_json::from_value(serde_json::json!({
"spans": [{
"metadata": { "name": "different", "target": "t", "level": "INFO" },
"fields": "a=1"
}]
}))
.unwrap();
assert_ne!(
left, other,
"spantraces differing in span name must be unequal"
);
}
#[test]
fn test_erased_span_and_metadata_partial_eq() {
let make = |fields: &str| -> ErasedSpan {
serde_json::from_value(serde_json::json!({
"metadata": { "name": "n", "target": "t", "level": "DEBUG" },
"fields": fields
}))
.unwrap()
};
let a = make("x=1");
let b = make("x=1");
let c = make("x=2");
assert_eq!(a, b, "identical spans must be equal");
assert_ne!(a, c, "spans differing in fields must be unequal");
let meta_a: ErasedMetadata = serde_json::from_value(serde_json::json!({
"name": "m", "target": "t", "level": "ERROR"
}))
.unwrap();
let meta_b: ErasedMetadata = serde_json::from_value(serde_json::json!({
"name": "m", "target": "t", "level": "ERROR"
}))
.unwrap();
let meta_c: ErasedMetadata = serde_json::from_value(serde_json::json!({
"name": "m", "target": "t", "level": "WARN"
}))
.unwrap();
assert_eq!(meta_a, meta_b, "identical metadata must be equal");
assert_ne!(
meta_a, meta_c,
"metadata differing in level must be unequal"
);
}
#[test]
fn test_erased_spantrace_display_empty_spans() {
let spantrace: ErasedSpanTrace =
serde_json::from_value(serde_json::json!({ "spans": [] })).unwrap();
assert_eq!(spantrace.to_string(), "");
}
#[cfg(feature = "tracing")]
#[test]
fn test_tracing_level_bidirectional_conversion() {
let cases = [
(TracingLevel::TRACE, tracing::Level::TRACE),
(TracingLevel::DEBUG, tracing::Level::DEBUG),
(TracingLevel::INFO, tracing::Level::INFO),
(TracingLevel::WARN, tracing::Level::WARN),
(TracingLevel::ERROR, tracing::Level::ERROR),
];
for (erased, tracing_level) in cases {
let forward: tracing::Level = erased.into();
assert_eq!(forward, tracing_level, "TracingLevel -> tracing::Level");
let back = TracingLevel::from(&tracing_level);
assert_eq!(back, erased, "&tracing::Level -> TracingLevel");
let roundtrip = TracingLevel::from(&tracing::Level::from(erased));
assert_eq!(roundtrip, erased, "TracingLevel roundtrip must be identity");
}
}
#[cfg(feature = "fancy")]
#[test]
fn test_round_trip_through_json_renders_in_report() {
let error = ErrorWithHelpOopsie {
message: "connection refused",
}
.build();
let erased = ErasedError::from_error(error);
let code = erased
.diagnostics()
.code()
.expect("oopsie errors carry a code")
.to_owned();
let json = serde_json::to_string(&erased).expect("ErasedError serializes");
let roundtripped: ErasedError = serde_json::from_str(&json).expect("ErasedError deserializes");
let report = oopsie::Report::new(roundtripped);
let rendered = report.to_string();
assert!(
rendered.contains(&format!("[{code}]")),
"Report must render the transported error code, got:\n{rendered}"
);
assert!(
rendered.contains("Something went wrong: connection refused"),
"Report must render the transported message, got:\n{rendered}"
);
assert!(
rendered.contains("Try restarting the service"),
"Report must render the transported help text, got:\n{rendered}"
);
}
#[test]
fn test_unknown_span_level_does_not_reject_payload() {
let erased: ErasedError = serde_json::from_str(
r#"{"message":"m","spantrace":{"spans":[{"metadata":{"name":"s","target":"t","level":"FATAL"},"fields":""}]},"backtrace":null}"#,
)
.expect("one unknown span level must not drop the whole error");
assert!(erased.spantrace().is_some());
}
#[cfg(feature = "tracing")]
#[test]
fn test_round_trip_source_chain_survives_report_and_reerasure() {
let erased = ErasedError::from_error(common::make_error());
assert!(
!erased.source_chain().is_empty(),
"fixture must have a cause"
);
let json = serde_json::to_string(&erased).unwrap();
let roundtripped: ErasedError = serde_json::from_str(&json).unwrap();
assert_eq!(serde_json::to_string(&roundtripped).unwrap(), json);
let reerased = ErasedError::from_error_ref(&roundtripped);
assert_eq!(reerased.source_chain(), roundtripped.source_chain());
#[cfg(feature = "fancy")]
{
let rendered = oopsie::Report::new(roundtripped).to_string();
assert!(
rendered.contains(&format!("╰─▶ {}", erased.source_chain()[0])),
"Report must render the transported cause, got:\n{rendered}"
);
}
}
#[test]
fn test_display_stays_single_line_after_round_trip() {
let error = ErrorWithHelpOopsie { message: "boom" }.build();
let erased = ErasedError::from_error(error);
let displayed = erased.to_string();
assert_eq!(displayed, "Something went wrong: boom");
}
#[test]
fn from_error_ref_terminates_on_cyclic_source() {
use oopsie::Diagnostic;
use std::error::Error;
use std::fmt;
#[derive(Debug)]
struct Cyclic;
impl fmt::Display for Cyclic {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("cyclic")
}
}
impl Error for Cyclic {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(self)
}
}
impl Diagnostic for Cyclic {}
let erased = ErasedError::from_error_ref(&Cyclic);
assert!(
erased.source_chain().len() <= 256,
"source chain must be bounded on a cyclic source(), got {}",
erased.source_chain().len()
);
}