From 258497b9823041540f5c4d0dedad74f4089ed201 Mon Sep 17 00:00:00 2001 From: Steve Date: Sat, 11 Apr 2026 17:23:36 +0100 Subject: [PATCH 1/2] test: increase targeted unit coverage --- src/decoder/validator.rs | 165 +++++++++++++++++++++++++++++++++++++-- src/fix/mod.rs | 23 ++++++ src/fix/obfuscator.rs | 24 ++++++ 3 files changed, 207 insertions(+), 5 deletions(-) diff --git a/src/decoder/validator.rs b/src/decoder/validator.rs index 30585ae..822bfb5 100644 --- a/src/decoder/validator.rs +++ b/src/decoder/validator.rs @@ -479,7 +479,7 @@ mod tests { use super::*; use crate::decoder::schema::{ ComponentContainer, ComponentDef, Field, FieldContainer, FieldRef, FixDictionary, GroupDef, - Message, MessageContainer, ValuesWrapper, + Message, MessageContainer, Value, ValuesWrapper, }; const SOH: &str = "\u{0001}"; @@ -494,6 +494,22 @@ mod tests { } } + fn enum_field(name: &str, number: u32, field_type: &str, values: &[(&str, &str)]) -> Field { + Field { + name: name.to_string(), + number, + field_type: field_type.to_string(), + values: values + .iter() + .map(|(enumeration, description)| Value { + enumeration: (*enumeration).to_string(), + description: (*description).to_string(), + }) + .collect(), + values_wrapper: ValuesWrapper::default(), + } + } + fn test_lookup() -> FixTagLookup { let dict = FixDictionary { typ: "FIX".to_string(), @@ -506,8 +522,12 @@ mod tests { field("BodyLength", 9, "LENGTH"), field("MsgType", 35, "STRING"), field("CheckSum", 10, "STRING"), + enum_field("Side", 54, "CHAR", &[("1", "Buy"), ("2", "Sell")]), + field("TransactTime", 60, "UTCTIMESTAMP"), field("NoItems", 100, "NUMINGROUP"), field("ItemValue", 101, "STRING"), + field("ItemCode", 102, "STRING"), + field("ItemExtra", 103, "STRING"), ], }, messages: MessageContainer { @@ -522,10 +542,20 @@ mod tests { groups: vec![GroupDef { name: "NoItems".to_string(), required: Some("Y".to_string()), - fields: vec![FieldRef { - name: "ItemValue".to_string(), - required: Some("N".to_string()), - }], + fields: vec![ + FieldRef { + name: "ItemValue".to_string(), + required: Some("N".to_string()), + }, + FieldRef { + name: "ItemCode".to_string(), + required: Some("N".to_string()), + }, + FieldRef { + name: "ItemExtra".to_string(), + required: Some("N".to_string()), + }, + ], groups: Vec::new(), components: Vec::new(), }], @@ -652,4 +682,129 @@ mod tests { "tag error map should include tag 35 when missing" ); } + + #[test] + fn detects_unknown_msg_type() { + let dict = test_lookup(); + let msg = build_message(&[(35, "Q"), (100, "0")], None); + let report = validate_fix_message(&msg, &dict); + assert!( + report + .errors + .iter() + .any(|e| e.contains("Unknown MsgType: Q")), + "expected unknown MsgType error, got {:?}", + report.errors + ); + assert!( + report + .tag_errors + .get(&35) + .is_some_and(|errs| errs.iter().any(|e| e.contains("Unknown MsgType: Q"))) + ); + } + + #[test] + fn detects_invalid_enum_and_type() { + let dict = test_lookup(); + let msg = build_message(&[(35, "Z"), (54, "LONG"), (100, "0")], None); + let report = validate_fix_message(&msg, &dict); + assert!( + report + .errors + .iter() + .any(|e| e.contains("Invalid enum value 'LONG'")), + "expected enum validation error, got {:?}", + report.errors + ); + assert!( + report + .errors + .iter() + .any(|e| e.contains("Invalid type: expected CHAR")), + "expected type validation error, got {:?}", + report.errors + ); + } + + #[test] + fn detects_invalid_numingroup_and_tag_outside_group() { + let dict = test_lookup(); + let msg = build_message(&[(35, "Z"), (100, "oops"), (101, "ALPHA")], None); + let report = validate_fix_message(&msg, &dict); + assert!( + report + .errors + .iter() + .any(|e| e.contains("Invalid NumInGroup value 'oops'")), + "expected invalid NumInGroup error, got {:?}", + report.errors + ); + assert!( + report + .errors + .iter() + .any(|e| e.contains("appears outside of repeating group 100")), + "expected out-of-group error, got {:?}", + report.errors + ); + } + + #[test] + fn detects_out_of_order_tags_within_group() { + let dict = test_lookup(); + let msg = build_message( + &[ + (35, "Z"), + (100, "1"), + (101, "ALPHA"), + (103, "EXTRA"), + (102, "CODE"), + ], + None, + ); + let report = validate_fix_message(&msg, &dict); + assert!( + report + .errors + .iter() + .any(|e| e.contains("Tag 102 (ItemCode) out of order within repeating group 100")), + "expected group ordering error, got {:?}", + report.errors + ); + } + + #[test] + fn missing_checksum_is_reported() { + let dict = test_lookup(); + let msg = format!("8=FIX.4.4{SOH}9=010{SOH}35=Z{SOH}100=0{SOH}"); + let report = validate_fix_message(&msg, &dict); + assert!( + report + .errors + .iter() + .any(|e| e.contains("Missing required checksum tag 10")), + "expected missing checksum error, got {:?}", + report.errors + ); + } + + #[test] + fn helper_type_validators_cover_temporal_formats() { + assert!(is_valid_type("20240101-00:00:00", "UTCTIMESTAMP")); + assert!(!is_valid_type("2024-01-01 00:00:00", "UTCTIMESTAMP")); + assert!(is_valid_type("20240101", "UTCDATEONLY")); + assert!(is_valid_type("12:34:56.789", "UTCTIMEONLY")); + assert!(!is_valid_type("25:00:00", "UTCTIMEONLY")); + assert!(is_valid_type("202401", "MONTHYEAR")); + assert!(is_valid_type("202401w3", "MONTHYEAR")); + assert!(!is_valid_type("2024-13", "MONTHYEAR")); + } + + #[test] + fn helper_checksum_and_body_length_fall_back_when_fields_are_missing() { + let msg = format!("8=FIX.4.4{SOH}9=005{SOH}35=Z{SOH}"); + assert_eq!(calculate_checksum(&msg), -1); + assert_eq!(compute_actual_body_length(&msg), None); + } } diff --git a/src/fix/mod.rs b/src/fix/mod.rs index 8b8a8f0..787836f 100644 --- a/src/fix/mod.rs +++ b/src/fix/mod.rs @@ -21,3 +21,26 @@ pub fn supported_fix_versions() -> &'static str { pub fn create_obfuscator(enabled: bool) -> Obfuscator { Obfuscator::from_sensitive_tags(&SENSITIVE_TAG_NAMES, enabled) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn choose_embedded_xml_defaults_to_fix44() { + let xml = choose_embedded_xml("unknown"); + assert!(xml.contains(" Date: Sat, 11 Apr 2026 17:50:01 +0100 Subject: [PATCH 2/2] test: raise Rust coverage above 90 percent --- pcap2fix/src/main.rs | 104 +++++++++++++++++++ src/bin/generate_sensitive_tags.rs | 154 +++++++++++++++++++++++++++++ src/main.rs | 98 ++++++++++++++++++ tests/cli.rs | 137 +++++++++++++++++++++++++ 4 files changed, 493 insertions(+) diff --git a/pcap2fix/src/main.rs b/pcap2fix/src/main.rs index 599c2e8..f2c36ba 100644 --- a/pcap2fix/src/main.rs +++ b/pcap2fix/src/main.rs @@ -772,4 +772,108 @@ mod tests { assert!(out.is_empty()); assert_eq!(buf, b"8=FI"); } + + #[test] + fn parse_delimiter_rejects_invalid_values() { + assert!(parse_delimiter("long").is_err()); + assert!(parse_delimiter("\\xGG").is_err()); + } + + #[test] + fn find_message_end_rejects_invalid_body_length_and_checksum_fields() { + assert!(matches!( + find_message_end(b"8=FIX.4.4|35=0|10=000|", b'|'), + MessageEnd::Invalid + )); + assert!(matches!( + find_message_end(b"8=FIX.4.4|9=abc|35=0|10=000|", b'|'), + MessageEnd::Invalid + )); + assert!(matches!( + find_message_end(b"8=FIX.4.4|9=4|35=0|10=xyz|", b'|'), + MessageEnd::Invalid + )); + assert!(matches!( + find_message_end(b"8=FIX.4.4|9=4|35=0|10=000!", b'|'), + MessageEnd::Invalid + )); + } + + #[test] + fn retain_partial_begin_string_clears_non_matching_tail() { + let mut buf = b"trailing-noise".to_vec(); + retain_partial_begin_string(&mut buf); + assert!(buf.is_empty()); + } + + #[test] + fn append_segment_trims_overlap_and_store_future_segment_prefers_longest() { + let mut flow = FlowState { + next_seq: Some(14), + buffer: b"8=FIX.4.4|9=".to_vec(), + pending: BTreeMap::new(), + last_seen: Instant::now(), + }; + + append_segment(&mut flow, 12, b"9=5|35=0|"); + assert_eq!(flow.buffer, b"8=FIX.4.4|9=5|35=0|"); + + store_future_segment(&mut flow, 30, b"short"); + store_future_segment(&mut flow, 30, b"longer-segment"); + assert_eq!( + flow.pending.get(&30).map(Vec::as_slice), + Some(&b"longer-segment"[..]) + ); + } + + #[test] + fn reassembly_overflow_clears_flow_state() { + let mut flow = FlowState::default(); + let mut out = Vec::new(); + let err = reassemble_and_emit(&mut flow, 1, b"0123456789", b'|', 4, &mut out).unwrap_err(); + + assert!(err.to_string().contains("flow exceeded max buffer")); + assert!(flow.buffer.is_empty()); + assert!(flow.pending.is_empty()); + assert!(flow.next_seq.is_none()); + } + + #[test] + fn evict_idle_drops_stale_flows() { + let mut flows = HashMap::new(); + flows.insert( + FlowKey { + src: "10.0.0.1".parse().unwrap(), + dst: "10.0.0.2".parse().unwrap(), + sport: 5000, + dport: 5001, + }, + FlowState { + last_seen: Instant::now() - Duration::from_secs(120), + ..FlowState::default() + }, + ); + flows.insert( + FlowKey { + src: "10.0.0.3".parse().unwrap(), + dst: "10.0.0.4".parse().unwrap(), + sport: 5002, + dport: 5003, + }, + FlowState::default(), + ); + + evict_idle(&mut flows, Duration::from_secs(60)); + + assert_eq!(flows.len(), 1); + assert!(flows.keys().any(|flow| flow.sport == 5002)); + } + + #[test] + fn open_reader_errors_for_missing_file() { + let err = open_reader("/definitely/missing/file.pcap") + .err() + .expect("missing file should error"); + assert!(err.to_string().contains("open pcap")); + } } diff --git a/src/bin/generate_sensitive_tags.rs b/src/bin/generate_sensitive_tags.rs index be82d8e..ab2fbfb 100644 --- a/src/bin/generate_sensitive_tags.rs +++ b/src/bin/generate_sensitive_tags.rs @@ -170,3 +170,157 @@ struct FixField { #[serde(rename = "@name")] name: String, } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Mutex, OnceLock}; + use tempfile::tempdir; + + fn xml_with_fields(fields: &[(u32, &str)]) -> String { + let body = fields + .iter() + .map(|(number, name)| format!(r#""#)) + .collect::>() + .join(""); + format!(r#"{body}"#) + } + + fn cwd_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + fn with_current_dir(dir: &Path, f: impl FnOnce() -> T) -> T { + let _guard = cwd_lock().lock().expect("cwd lock"); + let original = env::current_dir().expect("current dir"); + env::set_current_dir(dir).expect("set current dir"); + let result = f(); + env::set_current_dir(original).expect("restore current dir"); + result + } + + #[test] + fn find_repo_root_walks_up_from_nested_directories() { + let temp = tempdir().expect("tempdir"); + let repo_root = temp.path(); + fs::write( + repo_root.join("Cargo.toml"), + "[package]\nname='demo'\nversion='0.1.0'\n", + ) + .expect("cargo file"); + fs::create_dir(repo_root.join("resources")).expect("resources dir"); + let nested = repo_root.join("a/b/c"); + fs::create_dir_all(&nested).expect("nested dirs"); + + let found = with_current_dir(&nested, || find_repo_root().expect("repo root")); + + assert_eq!( + found.canonicalize().expect("canonical found path"), + repo_root.canonicalize().expect("canonical repo root") + ); + } + + #[test] + fn collect_xml_paths_returns_sorted_xml_files_only() { + let temp = tempdir().expect("tempdir"); + let resources = temp.path(); + fs::write(resources.join("b.xml"), "").expect("write b.xml"); + fs::write(resources.join("a.xml"), "").expect("write a.xml"); + fs::write(resources.join("notes.txt"), "ignore").expect("write txt"); + + let paths = collect_xml_paths(resources).expect("collect xml paths"); + let names: Vec<_> = paths + .iter() + .map(|path| path.file_name().and_then(|name| name.to_str()).unwrap()) + .collect(); + + assert_eq!(names, vec!["a.xml", "b.xml"]); + } + + #[test] + fn load_fields_parses_and_deduplicates_by_tag_number() { + let temp = tempdir().expect("tempdir"); + let first = temp.path().join("first.xml"); + let second = temp.path().join("second.xml"); + fs::write( + &first, + xml_with_fields(&[(1, "Account"), (2, "Password"), (50, "SenderSubID")]), + ) + .expect("write first xml"); + fs::write( + &second, + xml_with_fields(&[(2, "ShouldNotReplace"), (554, "Password")]), + ) + .expect("write second xml"); + + let fields = load_fields(&[first, second]).expect("load fields"); + + assert_eq!(fields.get(&1).map(String::as_str), Some("Account")); + assert_eq!(fields.get(&2).map(String::as_str), Some("Password")); + assert_eq!(fields.get(&554).map(String::as_str), Some("Password")); + } + + #[test] + fn filter_sensitive_selects_expected_field_names() { + let fields = BTreeMap::from([ + (1, "Account".to_string()), + (49, "SenderCompID".to_string()), + (50, "SenderSubID".to_string()), + (553, "Username".to_string()), + (9999, "Unrelated".to_string()), + ]); + + let filtered = filter_sensitive(&fields); + + assert_eq!(filtered.len(), 4); + assert!(filtered.contains_key(&1)); + assert!(filtered.contains_key(&49)); + assert!(filtered.contains_key(&50)); + assert!(filtered.contains_key(&553)); + assert!(!filtered.contains_key(&9999)); + } + + #[test] + fn write_output_creates_parent_directory_and_serialises_tags() { + let temp = tempdir().expect("tempdir"); + let out = temp.path().join("src/fix/sensitive.rs"); + let tags = BTreeMap::from([(1, "Account".to_string()), (554, "Password".to_string())]); + + write_output(&out, &tags).expect("write output"); + + let rendered = fs::read_to_string(out).expect("read output"); + assert!(rendered.contains("SENSITIVE_TAG_NAMES")); + assert!(rendered.contains("map.insert(1, \"Account\");")); + assert!(rendered.contains("map.insert(554, \"Password\");")); + } + + #[test] + fn run_generates_sensitive_file_from_repo_resources() { + let temp = tempdir().expect("tempdir"); + let repo_root = temp.path(); + fs::write( + repo_root.join("Cargo.toml"), + "[package]\nname='demo'\nversion='0.1.0'\n", + ) + .expect("cargo file"); + fs::create_dir_all(repo_root.join("resources")).expect("resources dir"); + fs::create_dir_all(repo_root.join("src/fix")).expect("src/fix dir"); + fs::write( + repo_root.join("resources/fix44.xml"), + xml_with_fields(&[(1, "Account"), (35, "MsgType"), (554, "Password")]), + ) + .expect("write fix xml"); + let nested = repo_root.join("tests/nested"); + fs::create_dir_all(&nested).expect("nested dir"); + + let result = with_current_dir(&nested, run); + + result.expect("run should succeed"); + let generated = fs::read_to_string(repo_root.join("src/fix/sensitive.rs")) + .expect("generated sensitive.rs"); + assert!(generated.contains("map.insert(1, \"Account\");")); + assert!(generated.contains("map.insert(554, \"Password\");")); + assert!(!generated.contains("MsgType")); + } +} diff --git a/src/main.rs b/src/main.rs index f96de69..5e85b12 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1551,6 +1551,8 @@ fn handle_components(opts: &CliOptions, schema: &SchemaTree) -> Result<()> { mod tests { use super::*; use std::collections::HashMap; + use std::io::Write; + use tempfile::NamedTempFile; fn dummy_opts(version: &str) -> CliOptions { CliOptions { @@ -1991,4 +1993,100 @@ mod tests { assert_eq!(dictionary_marker(Some("fix44"), "FIX50"), " "); assert_eq!(dictionary_marker(None, "FIX44"), " "); } + + #[test] + fn parse_paging_rejects_empty_and_unknown_values() { + assert!(parse_paging(Some(&"".to_string())).is_err()); + assert!(parse_paging(Some(&"sometimes".to_string())).is_err()); + } + + #[test] + fn parse_output_style_value_rejects_invalid_component() { + let err = parse_output_style_value("header,wat").unwrap_err(); + assert!(err.to_string().contains("invalid --style component")); + } + + #[test] + fn find_message_supports_name_and_msg_type_queries() { + let schema = load_schema_for_key("FIX44", &HashMap::new()).expect("load FIX44 schema"); + + let by_name = find_message(&schema, "Heartbeat").expect("lookup by name"); + let by_type = find_message(&schema, "0").expect("lookup by msg type"); + + assert_eq!(by_name.msg_type, "0"); + assert_eq!(by_type.name, "Heartbeat"); + assert!(find_message(&schema, "NoSuchMessage").is_none()); + } + + #[test] + fn session_component_helpers_cover_fix50_family() { + assert!(!requires_session_components("FIX44")); + assert!(requires_session_components("FIX50")); + assert!(requires_session_components("FIX50SP1")); + assert!(requires_session_components("FIX50SP2")); + assert_eq!(key_to_xml_id("fix44"), Some("44")); + assert_eq!(key_to_xml_id("FIXT11"), Some("T11")); + assert_eq!(key_to_xml_id("FIX99"), None); + } + + #[test] + fn ensure_session_components_backfills_missing_fix50_header_and_trailer() { + let mut dict = + FixDictionary::from_xml(fix::choose_embedded_xml("50")).expect("parse FIX50"); + dict.header = Default::default(); + dict.trailer = Default::default(); + + ensure_session_components("FIX50", &mut dict); + + assert!(component_def_has_entries(&dict.header)); + assert!(component_def_has_entries(&dict.trailer)); + } + + #[test] + fn component_def_has_entries_detects_fields_groups_and_components() { + let mut block = decoder::schema::ComponentDef::default(); + assert!(!component_def_has_entries(&block)); + + block.fields.push(decoder::schema::FieldRef { + name: "BeginString".into(), + required: Some("Y".into()), + }); + assert!(component_def_has_entries(&block)); + + let mut block = decoder::schema::ComponentDef::default(); + block.groups.push(decoder::schema::GroupDef { + name: "NoPartyIDs".into(), + required: Some("N".into()), + fields: Vec::new(), + groups: Vec::new(), + components: Vec::new(), + }); + assert!(component_def_has_entries(&block)); + + let mut block = decoder::schema::ComponentDef::default(); + block.components.push(decoder::schema::ComponentRef { + name: "Header".into(), + _required: Some("Y".into()), + }); + assert!(component_def_has_entries(&block)); + } + + #[test] + fn load_custom_dictionaries_keeps_last_duplicate_entry() { + let mut first = NamedTempFile::new().expect("temp xml"); + let mut second = NamedTempFile::new().expect("temp xml"); + write!(first, "{}", fix::choose_embedded_xml("44")).expect("write first xml"); + write!(second, "{}", fix::choose_embedded_xml("44")).expect("write second xml"); + + let paths = vec![ + first.path().display().to_string(), + second.path().display().to_string(), + ]; + let dicts = load_custom_dictionaries(&paths).expect("load custom dictionaries"); + + let fix44 = dicts.get("FIX44").expect("FIX44 custom dictionary"); + assert_eq!(fix44.path, second.path().display().to_string()); + assert_eq!(fix44.dict.major, "4"); + assert_eq!(fix44.dict.minor, "4"); + } } diff --git a/tests/cli.rs b/tests/cli.rs index ffb1c0b..2e59b9f 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -200,3 +200,140 @@ fn explicit_header_style_renders_source_banner_for_files() { .success() .stdout(contains(expected)); } + +#[test] +fn info_flag_lists_available_dictionaries_and_highlights_selection() { + cargo_bin_cmd!("fixdecoder") + .args(["--fix=44", "--info"]) + .assert() + .success() + .stdout( + contains("Available FIX Dictionaries") + .and(contains("Loaded dictionaries")) + .and(contains("FIX44")), + ); +} + +#[test] +fn message_listing_works_in_plain_and_column_modes() { + cargo_bin_cmd!("fixdecoder") + .args(["--fix=44", "--message"]) + .assert() + .success() + .stdout(contains("Heartbeat").and(contains("ExecutionReport"))); + + cargo_bin_cmd!("fixdecoder") + .args(["--fix=44", "--message", "--column"]) + .assert() + .success() + .stdout(contains("Heartbeat").and(contains("Logon"))); +} + +#[test] +fn message_detail_accepts_msg_type_lookup() { + cargo_bin_cmd!("fixdecoder") + .args([ + "--fix=44", + "--message", + "0", + "--verbose", + "--header", + "--trailer", + "--column", + ]) + .assert() + .success() + .stdout( + contains("Message: ") + .and(contains("Heartbeat")) + .and(contains("Header")) + .and(contains("Trailer")) + .and(contains("HEARTBEAT")), + ); +} + +#[test] +fn missing_message_is_reported() { + cargo_bin_cmd!("fixdecoder") + .args(["--fix=44", "--message", "NoSuchMessage"]) + .assert() + .success() + .stdout(contains("Message not found: NoSuchMessage")); +} + +#[test] +fn tag_listing_works_in_plain_and_column_modes() { + cargo_bin_cmd!("fixdecoder") + .args(["--fix=44", "--tag"]) + .assert() + .success() + .stdout(contains("BeginString").and(contains("CheckSum"))); + + cargo_bin_cmd!("fixdecoder") + .args(["--fix=44", "--tag", "--column"]) + .assert() + .success() + .stdout(contains("BeginString").and(contains("ClOrdID"))); +} + +#[test] +fn tag_detail_verbose_columns_show_enum_values() { + cargo_bin_cmd!("fixdecoder") + .args(["--fix=44", "--tag", "54", "--verbose", "--column"]) + .assert() + .success() + .stdout(contains("Side").and(contains("BUY")).and(contains("SELL"))); +} + +#[test] +fn invalid_and_missing_tags_are_reported() { + cargo_bin_cmd!("fixdecoder") + .args(["--fix=44", "--tag", "nope"]) + .assert() + .failure() + .stderr(contains("Invalid tag: nope")); + + cargo_bin_cmd!("fixdecoder") + .args(["--fix=44", "--tag", "999999"]) + .assert() + .success() + .stdout(contains("Tag not found: 999999")); +} + +#[test] +fn component_listing_works_in_plain_and_column_modes() { + cargo_bin_cmd!("fixdecoder") + .args(["--fix=44", "--component"]) + .assert() + .success() + .stdout(contains("Header").and(contains("CommissionData"))); + + cargo_bin_cmd!("fixdecoder") + .args(["--fix=44", "--component", "--column"]) + .assert() + .success() + .stdout(contains("Header").and(contains("Parties"))); +} + +#[test] +fn component_detail_verbose_columns_show_fields() { + cargo_bin_cmd!("fixdecoder") + .args(["--fix=44", "--component", "Header", "--verbose", "--column"]) + .assert() + .success() + .stdout( + contains("Component: ") + .and(contains("Header")) + .and(contains("BeginString")) + .and(contains("NEW_ORDER_SINGLE")), + ); +} + +#[test] +fn missing_component_is_reported() { + cargo_bin_cmd!("fixdecoder") + .args(["--fix=44", "--component", "NoSuchComponent"]) + .assert() + .success() + .stdout(contains("Component not found: NoSuchComponent")); +}