feat: M3.7.7 signature extraction CLI + integration tests (unit tests pass, integration tests pending mem-cli fix)
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
//! 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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user