//! M3.7.7 integration tests: failure signature extraction + normalisation //! //! Verifies that: //! 1. Same failure from different runs produces identical hash //! 2. Different failures produce different hashes //! 3. Normalisation strips volatile data (timestamps, paths, SHAs) //! 4. Cascading failures pick the root error //! 5. Unknown tools still produce signatures //! 6. No model calls are made //! 7. Extraction is fast (< 50ms for 50KB logs) //! 8. Tool is part of the signature identity //! 9. `mem sig explain` names the rule use mem_core::lesson; use std::fs; use std::path::Path; use std::time::Instant; fn load_fixture(name: &str) -> String { let path = format!("fixtures/failures/{}.txt", name); fs::read_to_string(&path).expect(&format!("Failed to load fixture: {}", path)) } #[test] fn a1_same_failure_same_hash() { // For each tool, two runs of the same failure should produce identical sig_sha let tools_and_fixtures = vec![ ("npm", "npm-run-a", "npm-run-b"), ("cargo", "cargo-run-a", "cargo-run-b"), ("kubectl", "kubectl-apply-a", "kubectl-apply-b"), ]; for (tool, fixture_a, fixture_b) in tools_and_fixtures { let log_a = load_fixture(fixture_a); let log_b = load_fixture(fixture_b); let sig_a = lesson::extract(tool, &log_a) .expect(&format!("Failed to extract signature from {}", fixture_a)); let sig_b = lesson::extract(tool, &log_b) .expect(&format!("Failed to extract signature from {}", fixture_b)); assert_eq!( sig_a.sig_sha, sig_b.sig_sha, "Same failure ({}) from different runs should have identical hash. a={}, b={}", tool, sig_a.sig_sha, sig_b.sig_sha ); } } #[test] fn a2_different_failure_different_hash() { // Different failures from the same tool should produce different hashes let tools_and_fixtures = vec![ ("npm", "npm-run-a", "npm-different"), ("cargo", "cargo-run-a", "cargo-different"), ("kubectl", "kubectl-apply-a", "kubectl-different"), ]; for (tool, fixture_same, fixture_diff) in tools_and_fixtures { let log_same = load_fixture(fixture_same); let log_diff = load_fixture(fixture_diff); let sig_same = lesson::extract(tool, &log_same) .expect(&format!("Failed to extract signature from {}", fixture_same)); let sig_diff = lesson::extract(tool, &log_diff) .expect(&format!("Failed to extract signature from {}", fixture_diff)); assert_ne!( sig_same.sig_sha, sig_diff.sig_sha, "Different failures ({}) should have different hashes", tool ); } } #[test] fn a3_normalisation_removes_volatiles() { // The normalised form should not contain timestamps, paths, SHAs, line numbers let log = load_fixture("npm-run-a"); let sig = lesson::extract("npm", &log).expect("Failed to extract signature"); // These patterns should NOT appear in the normalised form let patterns = vec![ (r"2026-08-\d{2}T\d{2}:\d{2}:\d{2}", "ISO timestamp"), (r"/home/runner/work", "workspace path"), (r"\d{7,40}", "git SHA"), (r":\d+:\d+", "line:col"), ]; for (pattern, name) in patterns { let re = regex::Regex::new(pattern).unwrap(); if re.is_match(&sig.normalised) { panic!( "Normalised signature should not contain {} but found in: {}", name, sig.normalised ); } } } #[test] fn a4_cascade_picks_first() { // When multiple error lines exist, pick the root (first), not the consequence let log = r#"error: some root cause error: consequence of root cause error: further consequence Error summary "#; let sig = lesson::extract("cargo", log).expect("Failed to extract signature"); assert!( sig.raw.contains("root cause"), "Should pick first error, not consequence" ); } #[test] fn a5_unknown_tool_fallback() { // An unrecognised tool should still produce a signature let log = load_fixture("npm-run-a"); let sig = lesson::extract("unknown-tool", &log).expect("Failed to extract signature for unknown tool"); // Should have a fallback rule assert_eq!(sig.tool, "unknown-tool"); assert!(!sig.normalised.is_empty(), "Should produce normalised form for unknown tool"); } #[test] fn a6_no_model_calls() { // Extraction should work with no HTTP requests to embedding/LLM services // (This test passes if extraction succeeds without panicking) let fixtures = vec!["npm-run-a", "npm-different", "cargo-run-a", "kubectl-apply-a"]; for fixture in fixtures { let log = load_fixture(fixture); let tool = fixture.split('-').next().unwrap(); let sig = lesson::extract(tool, &log); assert!(sig.is_some(), "Extraction should succeed for {}", fixture); } } #[test] fn a7_latency_under_50ms() { // Extraction on a 50KB log should complete in under 50ms // Create a 50KB synthetic log let base_log = load_fixture("npm-run-a"); let mut large_log = base_log.clone(); while large_log.len() < 50_000 { large_log.push_str(&base_log); } large_log.truncate(50_000); let start = Instant::now(); let _sig = lesson::extract("npm", &large_log).expect("Failed to extract from 50KB log"); let elapsed = start.elapsed(); assert!( elapsed.as_millis() < 50, "Extraction should complete in < 50ms, took {}ms", elapsed.as_millis() ); } #[test] fn a8_tool_in_identity() { // The same normalised text under two different tools should hash differently let log = r#"error: connection refused Error: failed to connect "#; let sig_npm = lesson::extract("npm", log).expect("Failed npm extraction"); let sig_cargo = lesson::extract("cargo", log).expect("Failed cargo extraction"); assert_ne!( sig_npm.sig_sha, sig_cargo.sig_sha, "Same error text under different tools should produce different hashes" ); } #[test] fn a9_explain_output() { // Verify that extract() returns the rule name let fixtures = vec![ ("npm", "npm-run-a"), ("cargo", "cargo-run-a"), ("kubectl", "kubectl-apply-a"), ]; for (tool, fixture) in fixtures { let log = load_fixture(fixture); let sig = lesson::extract(tool, &log).expect(&format!("Failed to extract {}", fixture)); // Rule should be set (not empty or "unknown") assert!(!sig.rule.is_empty(), "Rule should be set for {}", tool); assert_ne!(sig.rule, "unknown", "Rule should be specific for {}", tool); // Tool should be in rule (e.g., "npm_error_line") let rule_lower = sig.rule.to_lowercase(); let tool_lower = tool.to_lowercase(); assert!( rule_lower.contains(&tool_lower) || rule_lower == "fallback", "Rule should mention tool {} or be fallback, got: {}", tool, sig.rule ); } }