diff --git a/cloud-hypervisor/src/logger.rs b/cloud-hypervisor/src/logger.rs index 145af208b..594a07423 100644 --- a/cloud-hypervisor/src/logger.rs +++ b/cloud-hypervisor/src/logger.rs @@ -144,3 +144,207 @@ impl log::Log for Logger { fn flush(&self) {} } + +#[cfg(test)] +mod tests { + use std::io; + use std::sync::Arc; + + use log::Log; + + use super::*; + + /// A `Write` sink that appends to a shared byte buffer so tests can + /// inspect what the logger wrote. + #[derive(Clone, Default)] + struct SharedBuffer(Arc>>); + + impl SharedBuffer { + fn contents(&self) -> String { + String::from_utf8(self.0.lock().unwrap().clone()).unwrap() + } + } + + impl Write for SharedBuffer { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + fn render(tokens: &[Token]) -> String { + tokens + .iter() + .map(|t| match t { + Token::Literal(s) => format!("L({s})"), + Token::BootTime => "B".to_string(), + Token::Thread => "T".to_string(), + Token::Level => "V".to_string(), + Token::Location => "O".to_string(), + Token::Msg => "M".to_string(), + }) + .collect::>() + .join("|") + } + + #[test] + fn parse_plain_literal() { + let tokens = parse_format("hello world").unwrap(); + assert_eq!(render(&tokens), "L(hello world)"); + } + + #[test] + fn parse_empty_string() { + let tokens = parse_format("").unwrap(); + assert!(tokens.is_empty()); + } + + #[test] + fn parse_all_known_tokens() { + let tokens = parse_format("[{boottime}] <{thread}> {level} {location} -- {msg}").unwrap(); + assert_eq!(render(&tokens), "L([)|B|L(] <)|T|L(> )|V|L( )|O|L( -- )|M"); + } + + #[test] + fn parse_default_format_succeeds() { + let tokens = parse_format(DEFAULT_FORMAT).unwrap(); + // Default format has 5 tokens interleaved with literals. + assert!(tokens.iter().any(|t| matches!(t, Token::BootTime))); + assert!(tokens.iter().any(|t| matches!(t, Token::Thread))); + assert!(tokens.iter().any(|t| matches!(t, Token::Level))); + assert!(tokens.iter().any(|t| matches!(t, Token::Location))); + assert!(tokens.iter().any(|t| matches!(t, Token::Msg))); + } + + #[test] + fn parse_escaped_braces() { + let tokens = parse_format("{{not-a-token}}").unwrap(); + assert_eq!(render(&tokens), "L({not-a-token})"); + } + + #[test] + fn parse_escaped_braces_around_token() { + let tokens = parse_format("{{{level}}}").unwrap(); + assert_eq!(render(&tokens), "L({)|V|L(})"); + } + + #[test] + fn parse_unterminated_brace_errors() { + match parse_format("hello {level") { + Err(Error::UnterminatedBrace) => {} + Err(other) => panic!("unexpected error: {other:?}"), + Ok(_) => panic!("expected error"), + } + } + + #[test] + fn parse_unmatched_close_brace_errors() { + match parse_format("hello }") { + Err(Error::UnmatchedBrace) => {} + Err(other) => panic!("unexpected error: {other:?}"), + Ok(_) => panic!("expected error"), + } + } + + #[test] + fn parse_unknown_token_errors() { + match parse_format("{nope}") { + Err(Error::UnknownToken(name)) => assert_eq!(name, "nope"), + Err(other) => panic!("unexpected error: {other:?}"), + Ok(_) => panic!("expected error"), + } + } + + #[test] + fn logger_new_uses_default_format() { + let buf = SharedBuffer::default(); + let logger = Logger::new(Box::new(buf.clone())).unwrap(); + // The default format has all 5 dynamic tokens. + assert_eq!( + logger + .tokens + .iter() + .filter(|t| !matches!(t, Token::Literal(_))) + .count(), + 5 + ); + } + + #[test] + fn logger_enabled_always_true() { + let buf = SharedBuffer::default(); + let logger = Logger::new(Box::new(buf)).unwrap(); + let metadata = log::Metadata::builder() + .level(log::Level::Trace) + .target("anything") + .build(); + assert!(logger.enabled(&metadata)); + } + + #[test] + fn logger_writes_expected_fields() { + let buf = SharedBuffer::default(); + let logger = Logger::new(Box::new(buf.clone())).unwrap(); + + logger.log( + &log::Record::builder() + .args(format_args!("hello {}", "world")) + .level(log::Level::Info) + .target("unit_test_target") + .file(Some("foo.rs")) + .line(Some(42)) + .build(), + ); + + let out = buf.contents(); + assert!(out.starts_with("cloud-hypervisor: "), "got: {out}"); + assert!(out.contains("INFO"), "got: {out}"); + assert!(out.contains("foo.rs:42"), "got: {out}"); + assert!(out.contains("hello world"), "got: {out}"); + assert!(out.ends_with("\r\n"), "got: {out}"); + } + + #[test] + fn logger_uses_target_when_no_file() { + let buf = SharedBuffer::default(); + let logger = Logger::new(Box::new(buf.clone())).unwrap(); + + logger.log( + &log::Record::builder() + .args(format_args!("no location")) + .level(log::Level::Warn) + .target("my_target") + .file(None) + .line(None) + .build(), + ); + + let out = buf.contents(); + assert!(out.contains("my_target"), "got: {out}"); + assert!(!out.contains("foo.rs"), "got: {out}"); + } + + #[test] + fn logger_appends_each_record() { + let buf = SharedBuffer::default(); + let logger = Logger::new(Box::new(buf.clone())).unwrap(); + + for i in 0..3 { + logger.log( + &log::Record::builder() + .args(format_args!("entry-{i}")) + .level(log::Level::Debug) + .target("t") + .build(), + ); + } + + let out = buf.contents(); + assert_eq!(out.matches("entry-").count(), 3, "got: {out}"); + assert_eq!(out.matches("\r\n").count(), 3, "got: {out}"); + } +}