diff --git a/CHANGELOG.md b/CHANGELOG.md index 1190f72a..58ef9b7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `libcnb`: - Made `Target` (the type of `DetectContext::target` and `BuildContext::target`) public. ([#815](https://github.com/heroku/libcnb.rs/pull/815)) +- `libcnb-test` + - Added the macro `assert_contains_match!` for testing if a value contains a regular expression match. + - Added the macro `assert_not_contains_match!` for testing if a value does not contain a regular expression match. ### Changed diff --git a/libcnb-test/Cargo.toml b/libcnb-test/Cargo.toml index 8985d4d5..787115be 100644 --- a/libcnb-test/Cargo.toml +++ b/libcnb-test/Cargo.toml @@ -20,6 +20,7 @@ fs_extra = "1.3.0" libcnb-common.workspace = true libcnb-data.workspace = true libcnb-package.workspace = true +regex = "1.10.4" tempfile = "3.10.1" thiserror = "1.0.59" diff --git a/libcnb-test/src/lib.rs b/libcnb-test/src/lib.rs index 15002761..8f78fff4 100644 --- a/libcnb-test/src/lib.rs +++ b/libcnb-test/src/lib.rs @@ -27,3 +27,5 @@ use indoc as _; use libcnb as _; #[cfg(test)] use ureq as _; +// This dependency is used by the `assert_not_contains` and `assert_not_contains_match` macros +use regex as _; diff --git a/libcnb-test/src/macros.rs b/libcnb-test/src/macros.rs index 830a1056..317e89d6 100644 --- a/libcnb-test/src/macros.rs +++ b/libcnb-test/src/macros.rs @@ -141,6 +141,116 @@ value (escaped): `{:?}`: {}"#, }}; } +/// Asserts that `left` contains the `right` pattern (regular expression). +/// +/// Commonly used when asserting `pack` output in integration tests. Expands to a regular +/// expression match test and logs `left` (in unescaped and escaped form) as well as `right` +/// on failure. +/// +/// Multi-line mode is automatically enabled on regular expressions. If this is not what you +/// want it can be disabled by adding `(?-m)` to the start of your pattern. +/// +/// # Example +/// +/// ``` +/// use libcnb_test::assert_contains_match; +/// +/// let output = "Hello World!\nHello Integration Test!"; +/// assert_contains_match!(output, "Test!$"); +/// ``` +#[macro_export] +macro_rules! assert_contains_match { + ($left:expr, $right:expr $(,)?) => {{ + let regex = regex::Regex::new(&format!("(?m){}", $right)).expect("should be a valid regex"); + if !regex.is_match(&$left) { + ::std::panic!( + r#"assertion failed: `(left matches right pattern)` +left (unescaped): +{} + +left (escaped): `{:?}` +right: `{:?}`"#, + $left, + $left, + regex + ) + } + }}; + + ($left:expr, $right:expr, $($arg:tt)+) => {{ + let regex = regex::Regex::new(&format!("(?m){}", $right)).expect("should be a valid regex"); + if !regex.is_match(&$left) { + ::std::panic!( + r#"assertion failed: `(left matches right pattern)` +left (unescaped): +{} + +left (escaped): `{:?}` +right: `{:?}`: {}"#, + $left, + $left, + regex, + ::core::format_args!($($arg)+) + ) + } + }}; +} + +/// Asserts that `left` does not contain the `right` pattern (regular expression). +/// +/// Commonly used when asserting `pack` output in integration tests. Expands to a regular +/// expression match test and logs `left` (in unescaped and escaped form) as well as `right` +/// on failure. +/// +/// Multi-line mode is automatically enabled on regular expressions. If this is not what you +/// want it can be disabled by adding `(?-m)` to the start of your pattern. +/// +/// # Example +/// +/// ``` +/// use libcnb_test::assert_not_contains_match; +/// +/// let output = "Hello World!\nHello Integration Test!"; +/// assert_not_contains_match!(output, "^Test!"); +/// ``` +#[macro_export] +macro_rules! assert_not_contains_match { + ($left:expr, $right:expr $(,)?) => {{ + let regex = regex::Regex::new(&format!("(?m){}", $right)).expect("should be a valid regex"); + if regex.is_match(&$left) { + ::std::panic!( + r#"assertion failed: `(left does not match right pattern)` +left (unescaped): +{} + +left (escaped): `{:?}` +right: `{:?}`"#, + $left, + $left, + regex + ) + } + }}; + + ($left:expr, $right:expr, $($arg:tt)+) => {{ + let regex = regex::Regex::new(&format!("(?m){}", $right)).expect("should be a valid regex"); + if regex.is_match(&$left) { + ::std::panic!( + r#"assertion failed: `(left does not match right pattern)` +left (unescaped): +{} + +left (escaped): `{:?}` +right: `{:?}`: {}"#, + $left, + $left, + regex, + ::core::format_args!($($arg)+) + ) + } + }}; +} + #[cfg(test)] mod tests { #[test] @@ -332,4 +442,198 @@ value (escaped): `\"Hello World!\\nFoo\\nBar\\nBaz\"`: Greeting must be empty!") fn empty_multiline_failure_with_args() { assert_empty!("Hello World!\nFoo\nBar\nBaz", "Greeting must be empty!"); } + + #[test] + fn contains_match_simple() { + assert_contains_match!("Hello World!", "(?i)hello world!"); + } + + #[test] + fn contains_match_simple_with_args() { + assert_contains_match!("Hello World!", "(?i)hello world!", "World must be greeted"); + } + + #[test] + #[should_panic(expected = "assertion failed: `(left matches right pattern)` +left (unescaped): +foo + +left (escaped): `\"foo\"` +right: `Regex(\"(?m)bar\")`")] + fn contains_match_simple_failure() { + assert_contains_match!("foo", "bar"); + } + + #[test] + #[should_panic(expected = "assertion failed: `(left matches right pattern)` +left (unescaped): +Hello World! + +left (escaped): `\"Hello World!\"` +right: `Regex(\"(?m)(?-i)world\")`: World must be case-sensitively greeted!")] + fn contains_match_simple_failure_with_args() { + assert_contains_match!( + "Hello World!", + "(?-i)world", + "World must be case-sensitively greeted!" + ); + } + + #[test] + fn contains_match_multiline() { + assert_contains_match!("Hello World!\nFoo\nBar\nBaz", "^Bar$"); + } + + #[test] + #[should_panic(expected = "assertion failed: `(left matches right pattern)` +left (unescaped): +Hello World! +Foo +Bar +Baz + +left (escaped): `\"Hello World!\\nFoo\\nBar\\nBaz\"` +right: `Regex(\"(?m)Eggs\")`")] + fn contains_match_multiline_failure() { + assert_contains_match!("Hello World!\nFoo\nBar\nBaz", "Eggs"); + } + + #[test] + #[should_panic(expected = "assertion failed: `(left matches right pattern)` +left (unescaped): +Hello World! +Foo +Bar +Baz + +left (escaped): `\"Hello World!\\nFoo\\nBar\\nBaz\"` +right: `Regex(\"(?m)Eggs\")`: We need eggs!")] + fn contains_match_multiline_failure_with_args() { + assert_contains_match!("Hello World!\nFoo\nBar\nBaz", "Eggs", "We need eggs!"); + } + + #[test] + #[should_panic(expected = "should be a valid regex: Syntax( +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +regex parse error: + (?m)(unclosed group + ^ +error: unclosed group +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +)")] + fn contains_match_with_invalid_regex() { + assert_contains_match!("Hello World!", "(unclosed group"); + } + + #[test] + #[should_panic(expected = "should be a valid regex: Syntax( +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +regex parse error: + (?m)(unclosed group + ^ +error: unclosed group +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +)")] + fn contains_match_with_invalid_regex_and_args() { + assert_contains_match!("Hello World!", "(unclosed group", "This should fail."); + } + + #[test] + fn not_contains_match_simple() { + assert_not_contains_match!("Hello World!", "^World"); + } + + #[test] + fn not_contains_match_simple_with_args() { + assert_not_contains_match!("Hello World!", "^World", "World must not be at the start!"); + } + + #[test] + #[should_panic(expected = "assertion failed: `(left does not match right pattern)` +left (unescaped): +foobar + +left (escaped): `\"foobar\"` +right: `Regex(\"(?m)bar\")`")] + fn not_contains_match_simple_failure() { + assert_not_contains_match!("foobar", "bar"); + } + + #[test] + #[should_panic(expected = "assertion failed: `(left does not match right pattern)` +left (unescaped): +Hello Germany! + +left (escaped): `\"Hello Germany!\"` +right: `Regex(\"(?m)Germany!$\")`: Germany must not be greeted!")] + fn not_contains_match_simple_failure_with_args() { + assert_not_contains_match!( + "Hello Germany!", + "Germany!$", + "Germany must not be greeted!" + ); + } + + #[test] + fn not_contains_match_multiline() { + assert_not_contains_match!("Hello World!\nFoo\nBar\nBaz", "^Germany$"); + } + + #[test] + #[should_panic(expected = "assertion failed: `(left does not match right pattern)` +left (unescaped): +Hello World! +Foo +Bar +Baz + +left (escaped): `\"Hello World!\\nFoo\\nBar\\nBaz\"` +right: `Regex(\"(?m)^Bar$\")`")] + fn not_contains_match_multiline_failure() { + assert_not_contains_match!("Hello World!\nFoo\nBar\nBaz", "^Bar$"); + } + + #[test] + #[should_panic(expected = "assertion failed: `(left does not match right pattern)` +left (unescaped): +Hello Eggs! +Foo +Bar +Baz + +left (escaped): `\"Hello Eggs!\\nFoo\\nBar\\nBaz\"` +right: `Regex(\"(?m)Eggs!$\")`: We must not have eggs!")] + fn not_contains_match_multiline_failure_with_args() { + assert_not_contains_match!( + "Hello Eggs!\nFoo\nBar\nBaz", + "Eggs!$", + "We must not have eggs!" + ); + } + + #[test] + #[should_panic(expected = "should be a valid regex: Syntax( +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +regex parse error: + (?m)(unclosed group + ^ +error: unclosed group +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +)")] + fn not_contains_match_with_invalid_regex() { + assert_not_contains_match!("Hello World!", "(unclosed group"); + } + + #[test] + #[should_panic(expected = "should be a valid regex: Syntax( +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +regex parse error: + (?m)(unclosed group + ^ +error: unclosed group +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +)")] + fn not_contains_match_with_invalid_regex_and_args() { + assert_not_contains_match!("Hello World!", "(unclosed group", "This will fail"); + } }