diff --git a/crates/nexum-module-macros/src/lib.rs b/crates/nexum-module-macros/src/lib.rs index ddac670..a324de9 100644 --- a/crates/nexum-module-macros/src/lib.rs +++ b/crates/nexum-module-macros/src/lib.rs @@ -432,17 +432,20 @@ mod tests { #[test] fn empty_subscribes_is_rejected() { let err = parse_args(quote! { subscribes() }).err().unwrap(); + // Foreign syn::Error; pins our macro message. assert!(err.to_string().contains("at least one event type"), "{err}"); } #[test] fn unknown_argument_is_rejected() { let err = parse_args(quote! { emits(Foo) }).err().unwrap(); + // Foreign syn::Error; pins our macro message. assert!( err.to_string().contains("subscribes(EventType, ...)"), "{err}" ); let err = parse_args(quote! { subscribes(Foo), extra }).err().unwrap(); + // Foreign syn::Error; pins our macro message. assert!(err.to_string().contains("unexpected tokens"), "{err}"); } diff --git a/crates/nexum-runtime/src/addons.rs b/crates/nexum-runtime/src/addons.rs index d977fc7..54038c8 100644 --- a/crates/nexum-runtime/src/addons.rs +++ b/crates/nexum-runtime/src/addons.rs @@ -90,6 +90,7 @@ impl RuntimeAddOn for PrometheusAddOn { mod tests { use super::*; use crate::engine_config::MetricsSection; + use crate::test_utils::Refusal; /// An enabled exporter with an unparseable bind address fails at install. #[test] @@ -103,6 +104,8 @@ mod tests { Ok(_) => panic!("invalid bind_addr must not install"), Err(err) => err, }; - assert!(err.to_string().contains("bind_addr"), "{err}"); + Refusal::from(err).variant::( + |e| matches!(e, PrometheusError::BindAddr { addr, .. } if addr == "not-a-socket-addr"), + ); } } diff --git a/crates/nexum-runtime/src/builder.rs b/crates/nexum-runtime/src/builder.rs index 5358fb8..58a230f 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -676,9 +676,12 @@ mod tests { use crate::host::state::HostState; use crate::manifest::NamespaceCaps; use crate::preset::{CoreRuntime, Runtime as RuntimePreset}; + use crate::supervisor::prepass::BootRefusal; use crate::test_utils::clock::ManualClock; use crate::test_utils::wasm::workspace_root; - use crate::test_utils::{Prebuilt, TestManifest, example_wasm_or_skip, module_wasm_or_skip}; + use crate::test_utils::{ + Prebuilt, Refusal, TestManifest, example_wasm_or_skip, module_wasm_or_skip, + }; use wasmtime::component::Linker; /// The preset shortcut reaches the supervisor boot, which bails on the @@ -697,7 +700,7 @@ mod tests { Ok(_) => panic!("default config declares no modules; launch must bail"), Err(err) => err, }; - assert!(err.to_string().contains("no modules to run"), "{err}"); + Refusal::from(err).variant::(|e| matches!(e, LaunchRefusal::NothingToRun)); } /// Counts linker hook runs. @@ -779,7 +782,7 @@ mod tests { Ok(_) => panic!("default config declares no modules; launch must bail"), Err(err) => err, }; - assert!(err.to_string().contains("no modules to run"), "{err}"); + Refusal::from(err).variant::(|e| matches!(e, LaunchRefusal::NothingToRun)); assert_eq!(preset_linked.load(Ordering::SeqCst), 1, "preset extension"); assert_eq!( appended_linked.load(Ordering::SeqCst), @@ -839,7 +842,7 @@ mod tests { Ok(_) => panic!("default config declares no modules; launch must bail"), Err(err) => err, }; - assert!(err.to_string().contains("no modules to run"), "{err}"); + Refusal::from(err).variant::(|e| matches!(e, LaunchRefusal::NothingToRun)); seen.get().expect("clock attached before boot").clone() } @@ -979,7 +982,7 @@ mod tests { Ok(_) => panic!("default config declares no modules; launch must bail"), Err(err) => err, }; - assert!(err.to_string().contains("no modules to run"), "{err}"); + Refusal::from(err).variant::(|e| matches!(e, LaunchRefusal::NothingToRun)); assert_eq!( built.load(Ordering::SeqCst), 1, @@ -1059,7 +1062,9 @@ mod tests { Ok(_) => panic!("init-failing module must abort launch"), Err(err) => err, }; - assert!(err.to_string().contains("failed initialisation"), "{err}"); + Refusal::from(err).variant::(|e| { + matches!(e, LaunchRefusal::AllDeadOverride { modules: 1 }) + }); } #[tokio::test] @@ -1091,12 +1096,10 @@ mod tests { Ok(_) => panic!("an unconfigured chain subscription must abort launch"), Err(err) => err, }; - let msg = format!("{err:#}"); - assert!( - msg.contains("module example subscribes to chain 424242") - && msg.contains("[chains.424242]"), - "the launch error is the boot-time chain refusal: {msg}", - ); + Refusal::from(err).variant::(|e| { + matches!(e, BootRefusal::UnconfiguredChain { noun: "module", name, chain_id: 424_242, .. } + if name == "example") + }); } /// Add-ons install before the supervisor boots, exactly once. @@ -1145,7 +1148,7 @@ mod tests { Ok(_) => panic!("no modules configured; launch must bail"), Err(err) => err, }; - assert!(err.to_string().contains("no modules to run"), "{err}"); + Refusal::from(err).variant::(|e| matches!(e, LaunchRefusal::NothingToRun)); assert_eq!( calls.load(Ordering::SeqCst), 1, @@ -1236,7 +1239,7 @@ mod tests { .wait() .await .expect_err("aborted task surfaces an error"); - assert!(err.to_string().contains("terminated abnormally"), "{err}"); + Refusal::from(err).variant::(|e| matches!(e, LaunchRefusal::EventLoopGone)); } /// Dropping the handle without `wait` still drains the event loop. diff --git a/crates/nexum-runtime/src/digest.rs b/crates/nexum-runtime/src/digest.rs index b8940b3..038e473 100644 --- a/crates/nexum-runtime/src/digest.rs +++ b/crates/nexum-runtime/src/digest.rs @@ -193,6 +193,7 @@ mod tests { actual, }; let msg = err.to_string(); + // Operator wording pin. assert!(msg.contains("modules/example.wasm"), "{msg}"); assert!(msg.contains(&declared.to_string()), "{msg}"); assert!(msg.contains(&actual.to_string()), "{msg}"); diff --git a/crates/nexum-runtime/src/engine_config.rs b/crates/nexum-runtime/src/engine_config.rs index 4ab3115..0362f09 100644 --- a/crates/nexum-runtime/src/engine_config.rs +++ b/crates/nexum-runtime/src/engine_config.rs @@ -836,6 +836,7 @@ request_timeout_secs = 0 "#, ) .expect_err("a zero timeout must not parse"); + // Foreign toml::de::Error; pins our serde message threaded through it. assert!( err.to_string() .contains("request_timeout_secs must not be 0"), @@ -854,7 +855,8 @@ rpc_url = "wss://example.test/x" "#, ) .expect_err("bogus chain key must not parse"); - assert!(!err.to_string().is_empty()); + // Foreign toml::de::Error; pins that it names the offending key. + assert!(err.to_string().contains("bogus"), "{err}"); } #[test] @@ -1337,9 +1339,11 @@ key = "value" // environment. Use a guaranteed-unique prefix. let err = substitute_env_vars(r#"x = "${NEXUM_TEST_DEFINITELY_UNSET_VAR_XYZ}""#).unwrap_err(); - let msg = err.to_string(); - assert!(msg.contains("NEXUM_TEST_DEFINITELY_UNSET_VAR_XYZ")); - assert!(msg.contains("not set")); + assert!( + matches!(&err, EnvVarError::Missing { name } + if name == "NEXUM_TEST_DEFINITELY_UNSET_VAR_XYZ"), + "{err}" + ); } #[test] diff --git a/crates/nexum-runtime/src/host/extension.rs b/crates/nexum-runtime/src/host/extension.rs index 14c31c8..aba7616 100644 --- a/crates/nexum-runtime/src/host/extension.rs +++ b/crates/nexum-runtime/src/host/extension.rs @@ -365,6 +365,6 @@ mod tests { ext("acme", Arc::new(Clockwork)), ]) .expect_err("duplicate namespace"); - assert!(err.to_string().contains("acme"), "{err}"); + assert_eq!(err.namespace, "acme"); } } diff --git a/crates/nexum-runtime/src/manifest/load.rs b/crates/nexum-runtime/src/manifest/load.rs index aef5b75..23036fd 100644 --- a/crates/nexum-runtime/src/manifest/load.rs +++ b/crates/nexum-runtime/src/manifest/load.rs @@ -175,6 +175,7 @@ event_signature = "0x00000000000000000000000000000000000000000000000000000000dea chain_id = 1\n{field}\n" ); let err = toml::from_str::(&toml).expect_err("malformed hex"); + // Foreign toml::de::Error; pins our hex message threaded through it. assert!(err.to_string().contains(detail), "{err}"); } } @@ -322,6 +323,7 @@ kind = "acme-status" scope = 7 "#; let err = toml::from_str::(toml).expect_err("non-string filter"); + // Foreign toml::de::Error; pins our filter message threaded through it. assert!(err.to_string().contains("must be a string"), "{err}"); } @@ -588,6 +590,7 @@ max_state_bytes = 52428800 let err = load(&path, &CapabilityRegistry::core()).unwrap_err(); assert!(matches!(err, ParseError::MissingCapabilities), "{err:?}"); let msg = err.to_string(); + // Operator wording pin. assert!(msg.contains("[capabilities]"), "{msg}"); assert!(msg.contains("required = []"), "{msg}"); } diff --git a/crates/nexum-runtime/src/supervisor/load.rs b/crates/nexum-runtime/src/supervisor/load.rs index 9cd60e6..06c9764 100644 --- a/crates/nexum-runtime/src/supervisor/load.rs +++ b/crates/nexum-runtime/src/supervisor/load.rs @@ -38,7 +38,7 @@ use crate::runtime::dispatch_rate::TokenBucket; /// Admission refusals ahead of instantiation; the wording is operator-pinned. #[derive(Debug, ThisError, IntoStaticStr)] #[strum(serialize_all = "snake_case")] -pub(super) enum LoadRefusal { +pub(crate) enum LoadRefusal { #[error("{owner} declares manifest section [{section}]; no wired extension claims it")] SectionUnclaimed { owner: String, section: String }, #[error("extension namespace {namespace} is claimed twice")] diff --git a/crates/nexum-runtime/src/supervisor/mod.rs b/crates/nexum-runtime/src/supervisor/mod.rs index b2f11bf..4eaf26f 100644 --- a/crates/nexum-runtime/src/supervisor/mod.rs +++ b/crates/nexum-runtime/src/supervisor/mod.rs @@ -6,8 +6,8 @@ mod artifact; mod cursors; mod dispatch; mod lifecycle; -mod load; -mod prepass; +pub(crate) mod load; +pub(crate) mod prepass; mod role; mod store; mod subscriptions; diff --git a/crates/nexum-runtime/src/supervisor/prepass.rs b/crates/nexum-runtime/src/supervisor/prepass.rs index c0101b1..b777b80 100644 --- a/crates/nexum-runtime/src/supervisor/prepass.rs +++ b/crates/nexum-runtime/src/supervisor/prepass.rs @@ -17,7 +17,7 @@ use crate::manifest::{self, CapabilityRegistry, LoadedManifest, ParseError, Subs /// Refusals before any compile; the wording is operator-pinned. #[derive(Debug, Error, IntoStaticStr)] #[strum(serialize_all = "snake_case")] -pub(super) enum BootRefusal { +pub(crate) enum BootRefusal { #[error( "name {name} is claimed twice: {held_role} {} and {role} {}; \ [module].name must be unique across [[modules]] and [[adapters]]", diff --git a/crates/nexum-runtime/src/supervisor/tests/boot_refusals.rs b/crates/nexum-runtime/src/supervisor/tests/boot_refusals.rs index 0cf4b16..9ffdaa2 100644 --- a/crates/nexum-runtime/src/supervisor/tests/boot_refusals.rs +++ b/crates/nexum-runtime/src/supervisor/tests/boot_refusals.rs @@ -57,24 +57,25 @@ async fn boot_rejects_provider_whose_manifest_is_an_event_module() { .adapter(TestManifest::new("acme").kind("event-module")) .expect_refusal() .await - .names("acme-adapter"); + .variant::(|e| { + matches!(e, LoadRefusal::WorkerKindAdapter { registered, .. } + if registered == &["acme-adapter"]) + }); } /// The refusal names the registered kinds. #[tokio::test] async fn boot_rejects_an_unregistered_provider_kind() { - let refusal = BootScenario::over(mock_components()) + BootScenario::over(mock_components()) .extensions(acme_extensions()) .adapter(TestManifest::new("bad").kind("gadget")) .expect_refusal() - .await; - assert!(matches!( - refusal.root::(), - Some(LoadRefusal::UnregisteredKind { kind, registered, .. }) - if kind == "gadget" && registered == &["acme-adapter"] - )); - // Operator wording pin. - refusal + .await + .variant::(|e| { + matches!(e, LoadRefusal::UnregisteredKind { kind, registered, .. } + if kind == "gadget" && registered == &["acme-adapter"]) + }) + // Operator wording pin. .names("unregistered provider kind gadget") .names("acme-adapter"); } @@ -94,6 +95,8 @@ async fn boot_admits_a_registered_provider_kind_past_the_kind_gate() { ) .expect_refusal() .await + .variant::(|e| e.kind() == std::io::ErrorKind::NotFound) + // Operator wording pin. .names("read component") .names("missing-acme") .lacks("requires a module.toml"); @@ -103,19 +106,21 @@ async fn boot_admits_a_registered_provider_kind_past_the_kind_gate() { /// the boot before any entry loads. #[tokio::test] async fn boot_refuses_a_provider_kind_without_a_host_service() { - let refusal = BootScenario::over(mock_components()) + BootScenario::over(mock_components()) .extensions(serviceless_acme_extensions()) .expect_refusal() - .await; - assert!(matches!( - refusal.root::(), - Some(LoadRefusal::ServicelessKind { - namespace: "acme", - kind: "acme-adapter" + .await + .variant::(|e| { + matches!( + e, + LoadRefusal::ServicelessKind { + namespace: "acme", + kind: "acme-adapter" + } + ) }) - )); - // Operator wording pin. - refusal.names("extension acme registers provider kind acme-adapter without a host service"); + // Operator wording pin. + .names("extension acme registers provider kind acme-adapter without a host service"); } /// Provider kinds come only from `engine.toml`, so single boot skips the @@ -149,7 +154,7 @@ async fn boot_single_skips_the_provider_kind_service_gate() { .err() .expect("a missing manifest must refuse the boot"); Refusal::from(err) - .names("no module.toml") + .variant::(|e| matches!(e, BootRefusal::ManifestMissing { .. })) .lacks("without a host service"); } @@ -168,7 +173,9 @@ async fn boot_refuses_an_undeclared_extension_subscription_kind() { ) .expect_refusal() .await - .names("unknown event kind acme-status"); + .variant::( + |e| matches!(e, LoadRefusal::UnknownEventKind { kind, .. } if kind == "acme-status"), + ); } /// No wasm needs to exist; the refusal precedes compile and carries the @@ -181,8 +188,11 @@ async fn boot_refuses_a_component_without_module_toml() { .module(Entry::new(ManifestSource::Beside).wasm(orphan)) .expect_refusal() .await - .names("no module.toml") - .names("orphan.wasm") + .variant::(|e| { + matches!(e, BootRefusal::ManifestMissing { component } + if component.ends_with("orphan.wasm")) + }) + // Operator wording pin. .names("required = []") .lacks("compile"); } @@ -195,8 +205,10 @@ async fn boot_refuses_a_nonexistent_explicit_manifest_path() { .module(missing) .expect_refusal() .await - .names("modle.toml") - .names("not found"); + .variant::(|e| { + matches!(e, BootRefusal::ManifestNotFound { manifest, .. } + if manifest.ends_with("modle.toml")) + }); } /// Operator `http_allow` must not stand in for the component's own @@ -212,7 +224,10 @@ async fn boot_refuses_a_capsless_manifest_before_any_other_gate() { .adapter(Entry::new(provider.to_owned()).http_allow(["api.acme.example"])) .expect_refusal() .await - .names("no [capabilities] section") + .variant::(|e| { + matches!(e, BootRefusal::Manifest(ParseError::MissingCapabilities)) + }) + // Operator wording pin. .names("required = []") .lacks("no wired extension claims") .lacks("compile"); @@ -224,7 +239,10 @@ async fn boot_refuses_a_capsless_manifest_before_any_other_gate() { .module(module.to_owned()) .expect_refusal() .await - .names("no [capabilities] section") + .variant::(|e| { + matches!(e, BootRefusal::Manifest(ParseError::MissingCapabilities)) + }) + // Operator wording pin. .names("required = []") .lacks("unknown event kind") .lacks("no wired extension claims") @@ -242,7 +260,7 @@ async fn boot_refuses_a_blank_manifest_name_for_both_roles() { .adapter(TestManifest::new("").kind("acme-adapter").cap("chain")) .expect_refusal() .await - .names("[module].name") + .variant::(|e| matches!(e, BootRefusal::Manifest(ParseError::BlankModuleName))) .lacks("claimed twice") .lacks("read component") .lacks("compile"); @@ -252,7 +270,9 @@ async fn boot_refuses_a_blank_manifest_name_for_both_roles() { .module(TestManifest::new(blank).cap("logging")) .expect_refusal() .await - .names("[module].name") + .variant::(|e| { + matches!(e, BootRefusal::Manifest(ParseError::BlankModuleName)) + }) .lacks("claimed twice") .lacks("read component") .lacks("compile"); @@ -276,8 +296,10 @@ async fn boot_denies_an_undeclared_chain_import_for_balance_tracker() { ) .expect_refusal() .await - .names("capability violation") - .names("nexum:host/chain"); + .variant::(|e| { + matches!(e, CapabilityError::Undeclared(v) + if v.capability == "chain" && v.wit_import.starts_with("nexum:host/chain")) + }); } /// The example component's only gated import is `logging`, so the refusal @@ -293,6 +315,8 @@ async fn boot_denies_an_undeclared_logging_import_for_a_provider() { .adapter(Entry::new(TestManifest::new("acme").kind("acme-adapter").cap("chain")).wasm(wasm)) .expect_refusal() .await - .names("capability violation") - .names("nexum:host/logging"); + .variant::(|e| { + matches!(e, CapabilityError::Undeclared(v) + if v.capability == "logging" && v.wit_import.starts_with("nexum:host/logging")) + }); } diff --git a/crates/nexum-runtime/src/supervisor/tests/chain_gate.rs b/crates/nexum-runtime/src/supervisor/tests/chain_gate.rs index 95d597c..ee2cadd 100644 --- a/crates/nexum-runtime/src/supervisor/tests/chain_gate.rs +++ b/crates/nexum-runtime/src/supervisor/tests/chain_gate.rs @@ -31,6 +31,11 @@ async fn boot_refuses_a_subscription_on_an_unconfigured_chain() { .module(manifest) .expect_refusal() .await + .variant::(|e| { + matches!(e, BootRefusal::UnconfiguredChain { noun: "module", name, chain_id: 424_242, .. } + if name == "example") + }) + // Operator wording pin. .names("module example subscribes to chain 424242") .names("[chains.424242]") .names("configured chains: 1, 100, 11155111") @@ -54,6 +59,11 @@ async fn boot_single_refuses_a_subscription_on_an_unconfigured_chain() { .err() .expect("an unconfigured chain must refuse the boot"), ) + .variant::(|e| { + matches!(e, BootRefusal::UnconfiguredChain { noun: "module", name, chain_id: 424_242, .. } + if name == "gated") + }) + // Operator wording pin. .names("module gated subscribes to chain 424242") .names("configured chains: 1, 100, 11155111") .lacks("compile"); @@ -73,6 +83,11 @@ async fn boot_refuses_an_adapter_subscription_on_an_unconfigured_chain() { ) .expect_refusal() .await + .variant::(|e| { + matches!(e, BootRefusal::UnconfiguredChain { noun: "adapter", name, chain_id: 424_242, .. } + if name == "feed") + }) + // Operator wording pin. .names("load provider") .names("adapter feed subscribes to chain 424242") .names("[chains.424242]") @@ -104,6 +119,8 @@ async fn boot_refuses_an_invalid_chain_log_filter() { .module(manifest) .expect_refusal() .await + .variant::(|e| matches!(e, BootRefusal::Manifest(ParseError::Toml(_)))) + // Operator wording pin. .names("load module") .names("manifest: parse") .names(detail) @@ -158,6 +175,8 @@ async fn boot_admits_a_block_subscription_on_a_configured_chain_past_the_chain_g .module(TestManifest::new("example").cap("logging").block_sub(1)) .expect_refusal() .await + .variant::(|e| e.kind() == std::io::ErrorKind::NotFound) + // Operator wording pin. .names("read component") .lacks("subscribes to chain"); } @@ -178,6 +197,11 @@ async fn an_unconfigured_chain_refuses_boot_before_an_earlier_module_loads() { ) .expect_refusal() .await + .variant::(|e| { + matches!(e, BootRefusal::UnconfiguredChain { noun: "module", name, chain_id: 424_242, .. } + if name == "example") + }) + // Operator wording pin. .names("load module") .names("second.wasm") .names("module example subscribes to chain 424242") @@ -196,6 +220,11 @@ async fn boot_refusal_names_the_missing_engine_toml_on_the_defaulted_path() { ) .expect_refusal() .await + .variant::(|e| { + matches!(e, BootRefusal::UnconfiguredChainDefaulted { noun: "module", name, chain_id: 424_242 } + if name == "example") + }) + // Operator wording pin. .names("no engine.toml was found") .names("[chains.424242]") .lacks("configured chains:"); @@ -215,6 +244,7 @@ fn configured_chains_normalise_named_and_numeric_spellings() { fn unconfigured_chain_message_says_none_when_engine_toml_declares_no_chains() { let chains = ConfiguredChains::from_config(&EngineConfig::default()); let msg = unconfigured_chain(Role::Module, "example", 424_242, &chains).to_string(); + // Operator wording pin. assert!(msg.contains("configured chains: none"), "{msg}"); assert!(!msg.contains("no engine.toml was found"), "{msg}"); } diff --git a/crates/nexum-runtime/src/supervisor/tests/digest.rs b/crates/nexum-runtime/src/supervisor/tests/digest.rs index e96071c..0586637 100644 --- a/crates/nexum-runtime/src/supervisor/tests/digest.rs +++ b/crates/nexum-runtime/src/supervisor/tests/digest.rs @@ -34,6 +34,7 @@ fn read_verified_component_rejects_a_mismatched_digest() { ContentDigest::of_bytes(b"not the pinned bytes"), ); Refusal::from(err) + // Operator wording pin. .names("component digest mismatch") .lacks("compile"); } @@ -49,7 +50,7 @@ fn read_verified_component_requires_a_digest_when_the_flag_is_set() { .err() .expect("an unpinned artifact must refuse under the flag"); Refusal::from(err) - .names("require_component_digest") + .variant::(|e| matches!(e, LoadRefusal::DigestUnpinned { .. })) .lacks("compile"); } @@ -137,7 +138,10 @@ async fn boot_single_refuses_a_mismatched_component_digest() { let (_store, result) = try_boot_single(&wasm, Some(&manifest), false, None).await; Refusal::from(result.err().expect("a stale pin must refuse the boot")) - .names("component digest mismatch") + .variant::(|e| { + e.declared == wrong_digest() + && e.actual == ContentDigest::of_bytes(b"drifted artifact bytes") + }) .lacks("compile"); } @@ -154,7 +158,7 @@ async fn boot_single_requires_a_digest_when_the_engine_flag_is_set() { .err() .expect("an unpinned manifest must refuse under the flag"), ) - .names("require_component_digest") + .variant::(|e| matches!(e, LoadRefusal::DigestUnpinned { .. })) .lacks("compile"); } @@ -192,7 +196,10 @@ async fn boot_refuses_a_provider_with_a_mismatched_digest() { ) .expect_refusal() .await - .names("component digest mismatch") + .variant::(|e| { + e.declared == wrong_digest() + && e.actual == ContentDigest::of_bytes(b"drifted provider bytes") + }) .lacks("compile"); } @@ -207,7 +214,7 @@ async fn boot_requires_a_provider_digest_when_the_engine_flag_is_set() { .adapter(Entry::new(TestManifest::new("acme").kind("acme-adapter").cap("chain")).wasm(wasm)) .expect_refusal() .await - .names("require_component_digest"); + .variant::(|e| matches!(e, LoadRefusal::DigestUnpinned { .. })); } #[tokio::test] @@ -219,6 +226,6 @@ async fn boot_requires_a_module_digest_when_the_engine_flag_is_set() { .module(Entry::new(TestManifest::new("unpinned")).wasm(wasm)) .expect_refusal() .await - .names("require_component_digest") + .variant::(|e| matches!(e, LoadRefusal::DigestUnpinned { .. })) .lacks("compile"); } diff --git a/crates/nexum-runtime/src/supervisor/tests/ledger.rs b/crates/nexum-runtime/src/supervisor/tests/ledger.rs index f9ade48..6c12637 100644 --- a/crates/nexum-runtime/src/supervisor/tests/ledger.rs +++ b/crates/nexum-runtime/src/supervisor/tests/ledger.rs @@ -33,8 +33,11 @@ fn extension_sections_must_be_claimed() { sections.insert("venu".into(), toml::Value::Boolean(true)); let err = enforce_extension_sections("keeper", §ions, &extensions) .expect_err("unclaimed section"); - assert!(err.to_string().contains("[venu]"), "{err}"); - assert!(err.to_string().contains("keeper"), "{err}"); + assert!( + matches!(&err, LoadRefusal::SectionUnclaimed { owner, section } + if owner == "keeper" && section == "venu"), + "{err}" + ); } /// Two extensions colliding on a subscription kind or a manifest section @@ -89,14 +92,20 @@ fn extension_claims_must_be_unique() { ext("b", &["orders"], &["pool"]), ]) .expect_err("duplicate subscription kind"); - assert!(err.to_string().contains("orders"), "{err}"); + assert!( + matches!(&err, LoadRefusal::SubscriptionKindClaimed { kind } if *kind == "orders"), + "{err}" + ); let err = enforce_extension_uniqueness(&[ ext("a", &["orders"], &["venue"]), ext("b", &["fills"], &["venue"]), ]) .expect_err("duplicate manifest section"); - assert!(err.to_string().contains("[venue]"), "{err}"); + assert!( + matches!(&err, LoadRefusal::SectionClaimed { section } if *section == "venue"), + "{err}" + ); } #[test] @@ -116,14 +125,12 @@ fn claim_namespace_rejects_cross_role_duplicate_with_both_paths() { Path::new("adapters/impostor.wasm"), ) .expect_err("cross-role duplicate must be refused"); - let msg = format!("{err:#}"); - assert!( - msg.contains("module") && msg.contains("adapter"), - "the refusal names both roles: {msg}", - ); assert!( - msg.contains("modules/price-alert.wasm") && msg.contains("adapters/impostor.wasm"), - "the refusal names both claimant paths: {msg}", + matches!(&err, BootRefusal::NamespaceClaimed { name, held_role: "module", held, role: "adapter", path } + if name == "price-alert" + && held.as_path() == Path::new("modules/price-alert.wasm") + && path.as_path() == Path::new("adapters/impostor.wasm")), + "the refusal names both claimants: {err}", ); } @@ -155,11 +162,12 @@ async fn boot_rejects_duplicate_names_across_and_within_roles() { .module(Entry::new(TestManifest::new("dup").cap("logging")).wasm(module_wasm)) .expect_refusal() .await - .names("name dup is claimed twice") - .names("adapter") - .names("module") - .names("missing-adapter.wasm") - .names("missing-module.wasm") + .variant::(|e| { + matches!(e, BootRefusal::NamespaceClaimed { name, held_role: "adapter", held, role: "module", path } + if name == "dup" + && held.ends_with("missing-adapter.wasm") + && path.ends_with("missing-module.wasm")) + }) .lacks("compile"); let scenario = BootScenario::new(); @@ -172,8 +180,11 @@ async fn boot_rejects_duplicate_names_across_and_within_roles() { .module(Entry::new(TestManifest::new("dup").cap("logging")).wasm(second)) .expect_refusal() .await - .names("name dup is claimed twice") - .names("missing-a.wasm") - .names("missing-b.wasm") + .variant::(|e| { + matches!(e, BootRefusal::NamespaceClaimed { name, held_role: "module", held, role: "module", path } + if name == "dup" + && held.ends_with("missing-a.wasm") + && path.ends_with("missing-b.wasm")) + }) .lacks("compile"); } diff --git a/crates/nexum-runtime/src/supervisor/tests/lifecycle.rs b/crates/nexum-runtime/src/supervisor/tests/lifecycle.rs index eae6eb1..5c3b956 100644 --- a/crates/nexum-runtime/src/supervisor/tests/lifecycle.rs +++ b/crates/nexum-runtime/src/supervisor/tests/lifecycle.rs @@ -769,5 +769,8 @@ async fn a_hanging_provider_install_refuses_the_boot_by_deadline() { INSTALL_DEADLINE, "the install rode the configured deadline, not another timer", ); - refusal.names("did not install in time"); + refusal + .variant::(|_| true) + // Operator wording pin. + .names("did not install in time"); } diff --git a/crates/nexum-runtime/src/supervisor/tests/mod.rs b/crates/nexum-runtime/src/supervisor/tests/mod.rs index fc30444..ae15567 100644 --- a/crates/nexum-runtime/src/supervisor/tests/mod.rs +++ b/crates/nexum-runtime/src/supervisor/tests/mod.rs @@ -20,7 +20,7 @@ use super::artifact::read_verified_component; use super::cursors::{ chainlog_cursor_key, commit_chain_log_cursor, progress_key, read_chain_log_cursor, }; -use super::dispatch::with_dispatch_deadline; +use super::dispatch::{DeadlineExceeded, with_dispatch_deadline}; use super::prepass::{NamespaceLedger, claim_namespace, unconfigured_chain}; use super::store::resolve_module_limits; use super::subscriptions::build_alloy_filter; @@ -31,7 +31,7 @@ use crate::engine_config::ModuleLimits; use crate::host::extension::{HostService, Installed, ProviderInstance, ProviderKind}; use crate::host::logs::LogSource; use crate::host::provider_pool::ProviderPool; -use crate::manifest::{self, CapabilityRegistry, ResourceSection}; +use crate::manifest::{self, CapabilityRegistry, ParseError, ResourceSection}; use crate::preset::CoreRuntime; use crate::test_utils::{ BootScenario, Entry, ManifestSource, Refusal, TestManifest, example_wasm_or_skip, diff --git a/crates/nexum-runtime/src/test_utils/mod.rs b/crates/nexum-runtime/src/test_utils/mod.rs index 7264fda..247f970 100644 --- a/crates/nexum-runtime/src/test_utils/mod.rs +++ b/crates/nexum-runtime/src/test_utils/mod.rs @@ -102,9 +102,10 @@ mod tests { use alloy_chains::Chain; use futures::StreamExt as _; - use crate::builder::RuntimeBuilder; + use crate::builder::{LaunchRefusal, RuntimeBuilder}; use crate::engine_config::EngineConfig; use crate::host::component::{ChainMethod, ComponentsBuilder, StateHandle, StateStore}; + use crate::test_utils::Refusal; /// A custom component set launches through the public builder on fakes; /// it bails at boot only because the default config declares no modules, @@ -129,7 +130,7 @@ mod tests { Ok(_) => panic!("default config declares no modules; launch must bail"), Err(err) => err, }; - assert!(err.to_string().contains("no modules to run"), "{err}"); + Refusal::from(err).variant::(|e| matches!(e, LaunchRefusal::NothingToRun)); // The fake actually serves and records, independent of the launch. let body = pool diff --git a/crates/nexum-runtime/src/test_utils/scenario.rs b/crates/nexum-runtime/src/test_utils/scenario.rs index 7053a38..2c6131a 100644 --- a/crates/nexum-runtime/src/test_utils/scenario.rs +++ b/crates/nexum-runtime/src/test_utils/scenario.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use alloy_chains::Chain; +use derive_more::From; use tempfile::TempDir; use super::manifest::{ManifestSource, TestManifest}; @@ -310,21 +311,32 @@ impl Booted { } } -#[derive(Debug)] +#[derive(Debug, From)] pub struct Refusal(anyhow::Error); -impl From for Refusal { - fn from(err: anyhow::Error) -> Self { - Self(err) - } -} - impl Refusal { /// The typed root under the context wraps, for `matches!` on a variant. pub fn root(&self) -> Option<&E> { self.0.chain().find_map(|cause| cause.downcast_ref::()) } + /// Assert the chain carries an `E` matching `pred`. + #[track_caller] + pub fn variant(self, pred: impl FnOnce(&E) -> bool) -> Self + where + E: std::error::Error + Send + Sync + 'static, + { + let Some(root) = self.root::() else { + panic!( + "refusal carries no {}: {}", + std::any::type_name::(), + self.chain(), + ) + }; + assert!(pred(root), "refusal variant mismatch: {root:?}"); + self + } + /// Assert the refusal names `needle` somewhere in its context chain. #[track_caller] pub fn names(self, needle: &str) -> Self { @@ -354,7 +366,9 @@ impl Refusal { mod tests { use super::*; use crate::host::extension::HostWallClock; - use crate::manifest::NamespaceCaps; + use crate::manifest::{NamespaceCaps, ParseError}; + use crate::supervisor::load::LoadRefusal; + use crate::supervisor::prepass::BootRefusal; use crate::test_utils::{example_wasm_or_skip, module_wasm_or_skip}; /// Claims the `[acme]` manifest section and nothing else. @@ -527,8 +541,10 @@ mod tests { .module(TestManifest::new("bad").cap("telepathy")) .expect_refusal() .await - .names("unknown capability") - .names("telepathy") + .variant::(|e| { + matches!(e, BootRefusal::Manifest(ParseError::UnknownCapability { name, .. }) + if name == "telepathy") + }) .lacks("compile"); } @@ -538,7 +554,9 @@ mod tests { .adapter(TestManifest::new("feed").kind("acme-feed")) .expect_refusal() .await - .names("unregistered provider kind acme-feed") + .variant::( + |e| matches!(e, LoadRefusal::UnregisteredKind { kind, .. } if kind == "acme-feed"), + ) .lacks("compile"); } @@ -550,8 +568,10 @@ mod tests { .module(Entry::new(ManifestSource::Beside).wasm(orphan)) .expect_refusal() .await - .names("no module.toml") - .names("orphan.wasm") + .variant::(|e| { + matches!(e, BootRefusal::ManifestMissing { component } + if component.ends_with("orphan.wasm")) + }) .lacks("compile"); } @@ -563,8 +583,10 @@ mod tests { .module(missing) .expect_refusal() .await - .names("modle.toml") - .names("not found") + .variant::(|e| { + matches!(e, BootRefusal::ManifestNotFound { manifest, .. } + if manifest.ends_with("modle.toml")) + }) .lacks("compile"); } @@ -574,7 +596,10 @@ mod tests { .module(acme_section_manifest()) .expect_refusal() .await - .names("no wired extension claims it") + .variant::(|e| { + matches!(e, LoadRefusal::SectionUnclaimed { owner, section } + if owner == "acme-user" && section == "acme") + }) .lacks("read component"); BootScenario::new() @@ -582,6 +607,8 @@ mod tests { .module(acme_section_manifest()) .expect_refusal() .await + .variant::(|e| e.kind() == std::io::ErrorKind::NotFound) + // Operator wording pin. .names("read component") .lacks("no wired extension claims it"); } @@ -691,4 +718,27 @@ mod tests { fn lacks_panics_on_a_present_needle() { Refusal(anyhow::anyhow!("boom")).lacks("boom"); } + + fn not_found() -> anyhow::Error { + std::io::Error::new(std::io::ErrorKind::NotFound, "gone").into() + } + + #[test] + fn variant_finds_the_typed_root_under_context_wraps() { + Refusal(not_found().context("outer context")) + .variant::(|e| e.kind() == std::io::ErrorKind::NotFound); + } + + #[test] + #[should_panic(expected = "refusal carries no")] + fn variant_panics_on_an_absent_type() { + Refusal(anyhow::anyhow!("boom")).variant::(|_| true); + } + + #[test] + #[should_panic(expected = "refusal variant mismatch")] + fn variant_panics_on_a_failed_predicate() { + Refusal(not_found()) + .variant::(|e| e.kind() == std::io::ErrorKind::PermissionDenied); + } } diff --git a/crates/nexum-sdk/src/chain/transport.rs b/crates/nexum-sdk/src/chain/transport.rs index ee2ef28..4636432 100644 --- a/crates/nexum-sdk/src/chain/transport.rs +++ b/crates/nexum-sdk/src/chain/transport.rs @@ -191,6 +191,7 @@ mod tests { panic!("expected failure, got {resp:?}"); }; assert_eq!(err.code, -32601); + // Operator wording pin. assert!(err.message.contains("eth_sendRawTransaction")); } @@ -222,6 +223,7 @@ mod tests { let TransportError::Transport(kind) = err else { panic!("expected transport kind, got {err:?}"); }; + // Foreign alloy TransportErrorKind; pins its rendered copy. assert!(kind.to_string().contains("timeout")); }