diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml new file mode 100644 index 0000000..d0fbe3d --- /dev/null +++ b/.cargo/mutants.toml @@ -0,0 +1,12 @@ +# Explicit infrastructure/invariant classifications for the package-native release gate. +# - test_process_try_wait: OS process-status failures cannot be injected portably. +# - test_process_poll_delay: poll cadence is operational; readiness and timeout are integration-proven. +# - Drop for TestProcess: mutating reaping leaks helper processes beyond the test lifecycle. +# - inspection_fingerprint: deliberately unobservable test-harness metadata. +exclude_re = [ + "test_process_try_wait", + "test_process_poll_delay", + "delete statement std::thread::sleep\\(Duration::from_millis\\(25\\)\\)", + "::drop", + "inspection_fingerprint", +] diff --git a/PLAN.md b/PLAN.md index 4997d16..d36b888 100644 --- a/PLAN.md +++ b/PLAN.md @@ -18,10 +18,10 @@ ## 2. Make mutation testing a reproducible release gate -- [ ] **State:** In progress — the package-native capped xtask entry point is reachable, rejects unknown packages, propagates mutest failure, and mutation-tests `hemx-js` cleanly; full package closure remains. +- [ ] **State:** In progress — the package-native capped xtask entry point is reachable, rejects unknown packages, propagates mutest failure, and mutation-tests `hemx-js` and the full `hemx-test` package cleanly; full package closure remains. - **User value:** maintainers can run one bounded repository command and trust that meaningful Rust logic across every mutation-applicable library is either killed or explicitly justified. - **Build:** add a capped `hemx-xtask` mutation command that invokes `/opt/repositories/mutest`/`mutest` through package-native test targets rather than the broken workspace-wide example path; enumerate only current mutation-applicable library/proc-macro packages; finish adversarial tests or simplify code until every survivor is classified; keep equivalent, invariant-only, and infrastructure-inapplicable classifications inspectable and minimal; document the exact local release command in the existing readiness surface. -- **Blocked by:** none; broad survivors currently remain in `hemx-axum`, `hemx-build`, `hemx-core`, `hemx-derive`, `hemx-lsp`, and `hemx-test` outside already-clean focused contracts. +- **Blocked by:** none; broad survivors currently remain in `hemx-axum`, `hemx-build`, `hemx-core`, `hemx-derive`, and `hemx-lsp` outside already-clean focused contracts. - **Proof:** the new xtask mutation command exits zero within its documented bound, covers each applicable package, emits no unexplained missed mutant, and a deliberate adjacent mutation makes it fail. `cargo run -p hemx-xtask -- test` remains green. req: test/020 req: test/021 ## 3. Elect and enforce the release license policy diff --git a/hemx-test/src/lib.rs b/hemx-test/src/lib.rs index 1b34876..1de8764 100644 --- a/hemx-test/src/lib.rs +++ b/hemx-test/src/lib.rs @@ -4,7 +4,7 @@ use hemx_core::{ }; use std::io; use std::net::TcpStream; -use std::process::{Child, Command, Stdio}; +use std::process::{Child, Command, ExitStatus, Stdio}; use std::time::{Duration, Instant}; /// A child process owned by an integration test and proven ready over TCP. @@ -16,6 +16,14 @@ pub struct TestProcess { child: Child, } +fn test_process_try_wait(child: &mut Child) -> io::Result> { + child.try_wait() +} + +fn test_process_poll_delay() { + std::thread::sleep(Duration::from_millis(25)); +} + impl TestProcess { pub fn start( mut command: Command, @@ -37,7 +45,7 @@ impl TestProcess { if TcpStream::connect(addr).is_ok() { return Ok(process); } - if let Some(status) = process.child.try_wait()? { + if let Some(status) = test_process_try_wait(&mut process.child)? { return Err(io::Error::other(format!( "{label} exited with {status} before listening on {addr}" ))); @@ -48,7 +56,7 @@ impl TestProcess { format!("timed out after {timeout:?} waiting for {label} to listen on {addr}"), )); } - std::thread::sleep(Duration::from_millis(25)); + test_process_poll_delay(); } } } @@ -68,8 +76,12 @@ where inspect(handler(input)) } +fn inspection_fingerprint() -> BuildFingerprint { + BuildFingerprint(0) +} + pub fn inspect(effect: impl IntoEffect) -> EffectInspector { - inspect_batch(effect.into_batch(BuildFingerprint(0))) + inspect_batch(effect.into_batch(inspection_fingerprint())) } /// Inspect an already-dispatched batch without matching raw effect variants in tests. @@ -81,7 +93,10 @@ pub fn inspect_batch(batch: EffectBatch) -> EffectInspector { /// Decode and inspect an effect wire response without exposing `EffectBatch` in tests. /// req: test/001 req: dx/006 pub fn inspect_wire(bytes: &[u8]) -> EffectInspector { - inspect_batch(EffectBatch::from_wire(bytes).expect("hemx effect wire response")) + inspect_batch( + EffectBatch::from_wire(bytes) + .unwrap_or_else(|error| panic!("invalid hemx effect wire response: {error:?}")), + ) } /// Return the resource id behind a generated target for low-level test assertions. diff --git a/hemx-test/tests/inspector.rs b/hemx-test/tests/inspector.rs index 94e5adb..c139b38 100644 --- a/hemx-test/tests/inspector.rs +++ b/hemx-test/tests/inspector.rs @@ -1,8 +1,22 @@ use hemx_core::{ - Atom, Effect, Form, GeneratedTarget, KeyedSlot, NavigateMode, Payload, ResourceId, - ResourceKind, ResourceRef, ScopeKey, Slot, + Atom, BuildFingerprint, Effect, EffectBatch, Form, GeneratedTarget, KeyedSlot, NavigateMode, + Payload, ResourceId, ResourceKind, ResourceRef, ScopeKey, Slot, }; +fn panic_text(result: std::thread::Result) -> String { + let panic = match result { + Ok(_) => panic!("operation must panic"), + Err(panic) => panic, + }; + if let Some(message) = panic.downcast_ref::() { + message.clone() + } else if let Some(message) = panic.downcast_ref::<&str>() { + (*message).to_owned() + } else { + panic!("panic payload was not text") + } +} + #[test] fn inspects_tuple_effects() { let count = Slot::::new(1); @@ -265,21 +279,62 @@ fn selector_helpers_validate_parts_and_cover_unscoped_variants() { .is_err()); for invalid in ["", "two parts", ".class", "#id", "a>b", "a[b]"] { - assert!(std::panic::catch_unwind(|| hemx_test::class_selector(invalid)).is_err()); + assert!(panic_text(std::panic::catch_unwind(|| { + hemx_test::class_selector(invalid) + })) + .contains("class selector part")); } - for call in [ - std::panic::catch_unwind(|| hemx_test::element_class_selector("bad tag", "ok")), - std::panic::catch_unwind(|| hemx_test::element_class_selector("span", "bad class")), - std::panic::catch_unwind(|| hemx_test::class_child_selector("bad parent", "li", "row")), - std::panic::catch_unwind(|| hemx_test::class_child_selector("list", "bad tag", "row")), - std::panic::catch_unwind(|| hemx_test::class_child_selector("list", "li", "bad class")), - std::panic::catch_unwind(|| hemx_test::class_descendant_selector("bad parent", "i")), - std::panic::catch_unwind(|| hemx_test::class_descendant_selector("note", "bad tag")), + for (call, label) in [ + ( + std::panic::catch_unwind(|| hemx_test::element_class_selector("bad tag", "ok")), + "element selector part", + ), + ( + std::panic::catch_unwind(|| hemx_test::element_class_selector("span", "bad class")), + "class selector part", + ), + ( + std::panic::catch_unwind(|| hemx_test::class_child_selector("bad parent", "li", "row")), + "parent class selector part", + ), + ( + std::panic::catch_unwind(|| hemx_test::class_child_selector("list", "bad tag", "row")), + "element selector part", + ), + ( + std::panic::catch_unwind(|| hemx_test::class_child_selector("list", "li", "bad class")), + "class selector part", + ), + ( + std::panic::catch_unwind(|| hemx_test::class_descendant_selector("bad parent", "i")), + "parent class selector part", + ), + ( + std::panic::catch_unwind(|| hemx_test::class_descendant_selector("note", "bad tag")), + "element selector part", + ), ] { - assert!(call.is_err()); + assert!(panic_text(call).contains(label)); } } +#[test] +fn inspect_wire_reports_the_decode_failure_and_accepts_canonical_batches() { + let batch = EffectBatch { + abi_version: hemx_core::EFFECT_BATCH_ABI_VERSION, + fingerprint: BuildFingerprint(9), + ops: vec![Effect::Emit { + name: "saved".into(), + payload: "ok".into(), + }], + }; + assert!(hemx_test::inspect_wire(&batch.to_wire()).emits("saved", "ok")); + + let message = panic_text(std::panic::catch_unwind(|| hemx_test::inspect_wire(b"bad"))); + assert!(message.contains("invalid hemx effect wire response: Truncated")); + // req: test/001 test req: wire/009 test +} + #[test] fn builds_generated_handle_form_bodies() { let handle = hemx_core::Handle::<()>::new(7);