use anyhow::Result;
use std::collections::HashMap;
fn dry_run_config() -> xchecker::orchestrator::OrchestratorConfig {
xchecker::orchestrator::OrchestratorConfig {
dry_run: true,
config: HashMap::new(),
full_config: None,
selectors: None,
strict_validation: false,
redactor: Default::default(),
hooks: None,
}
}
fn unique_spec_id(test_name: &str) -> String {
format!("error-receipt-{}-{}", test_name, std::process::id())
}
#[tokio::test]
async fn test_error_receipt_invalid_phase_transition() -> Result<()> {
let _temp = xchecker::paths::with_isolated_home();
let spec_id = unique_spec_id("invalid-transition");
let config = dry_run_config();
let mut handle =
xchecker::orchestrator::OrchestratorHandle::with_config_and_force(&spec_id, config, false)?;
let result = handle.run_phase(xchecker::types::PhaseId::Design).await;
assert!(
result.is_err(),
"Running Design without Requirements should fail"
);
let spec_dir = xchecker::paths::spec_root(&spec_id);
let receipts_dir = spec_dir.join("receipts");
if receipts_dir.exists() {
let entries: Vec<_> = std::fs::read_dir(&receipts_dir)?
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_str()
.map(|n| n.starts_with("design-"))
.unwrap_or(false)
})
.collect();
if !entries.is_empty() {
let receipt_path = entries[0].path();
let receipt_content = std::fs::read_to_string(&receipt_path)?;
let receipt: serde_json::Value = serde_json::from_str(&receipt_content)?;
assert!(
!receipt["error_kind"].is_null(),
"error_kind should be set on error receipt"
);
assert_eq!(
receipt["error_kind"].as_str(),
Some("cli_args"),
"error_kind should be cli_args for invalid transition"
);
assert!(
!receipt["error_reason"].is_null(),
"error_reason should be set on error receipt"
);
let error_reason = receipt["error_reason"].as_str().unwrap();
assert!(!error_reason.is_empty(), "error_reason should not be empty");
assert!(
error_reason.contains("Design") || error_reason.contains("transition"),
"error_reason should describe the transition error"
);
if !receipt["pipeline"].is_null() {
assert_eq!(
receipt["pipeline"]["execution_strategy"].as_str(),
Some("controlled"),
"execution_strategy should be 'controlled' even on error"
);
}
assert!(
!receipt["emitted_at"].is_null(),
"emitted_at should be set on error receipt"
);
assert_eq!(
receipt["phase"].as_str(),
Some("design"),
"phase should be set correctly"
);
assert_eq!(
receipt["spec_id"].as_str(),
Some(spec_id.as_str()),
"spec_id should be set correctly"
);
assert!(
receipt["exit_code"].as_i64().unwrap() != 0,
"exit_code should be non-zero for error"
);
}
}
Ok(())
}
#[test]
fn test_error_receipt_packet_overflow() -> Result<()> {
let _temp = xchecker::paths::with_isolated_home();
let spec_id = unique_spec_id("packet-overflow");
let error = xchecker::error::XCheckerError::PacketOverflow {
used_bytes: 100000,
limit_bytes: 65536,
used_lines: 1500,
limit_lines: 1200,
};
let spec_dir = xchecker::paths::spec_root(&spec_id);
let receipt_manager = xchecker::receipt::ReceiptManager::new(&spec_dir);
let packet = xchecker::types::PacketEvidence {
files: vec![],
max_bytes: 65536,
max_lines: 1200,
};
let pipeline = Some(xchecker::types::PipelineInfo {
execution_strategy: Some("controlled".to_string()),
});
let error_receipt = receipt_manager.create_error_receipt(
&spec_id,
xchecker::types::PhaseId::Requirements,
&error,
"0.1.0",
"0.8.1",
"haiku",
None,
HashMap::new(),
packet,
Some("Packet overflow stderr".to_string()),
None,
vec![],
None,
"native",
None,
None,
pipeline,
);
assert_eq!(
error_receipt.error_kind,
Some(xchecker::types::ErrorKind::PacketOverflow),
"error_kind should be PacketOverflow"
);
assert!(
error_receipt.error_reason.is_some(),
"error_reason should be set"
);
let error_reason = error_receipt.error_reason.as_ref().unwrap();
assert!(!error_reason.is_empty(), "error_reason should not be empty");
assert!(
error_reason.contains("100000") || error_reason.contains("overflow"),
"error_reason should mention the overflow details"
);
assert!(
error_receipt.pipeline.is_some(),
"pipeline should be set on error receipt"
);
assert_eq!(
error_receipt.pipeline.as_ref().unwrap().execution_strategy,
Some("controlled".to_string()),
"execution_strategy should be 'controlled' even on error"
);
assert!(
error_receipt.emitted_at > chrono::Utc::now() - chrono::Duration::seconds(5),
"emitted_at should be recent"
);
assert_eq!(error_receipt.phase, "requirements");
assert_eq!(error_receipt.spec_id, spec_id);
assert_eq!(
error_receipt.exit_code, 7,
"exit_code should be 7 for PacketOverflow"
);
assert_eq!(
error_receipt.outputs.len(),
0,
"outputs should be empty on error"
);
assert!(
error_receipt.stderr_tail.is_some(),
"stderr_tail should be preserved"
);
Ok(())
}
#[test]
fn test_error_receipt_secret_detected() -> Result<()> {
let _temp = xchecker::paths::with_isolated_home();
let spec_id = unique_spec_id("secret-detected");
let error = xchecker::error::XCheckerError::SecretDetected {
pattern: "github_pat".to_string(),
location: "test.txt:42:10".to_string(),
};
let spec_dir = xchecker::paths::spec_root(&spec_id);
let receipt_manager = xchecker::receipt::ReceiptManager::new(&spec_dir);
let packet = xchecker::types::PacketEvidence {
files: vec![],
max_bytes: 65536,
max_lines: 1200,
};
let pipeline = Some(xchecker::types::PipelineInfo {
execution_strategy: Some("controlled".to_string()),
});
let error_receipt = receipt_manager.create_error_receipt(
&spec_id,
xchecker::types::PhaseId::Design,
&error,
"0.1.0",
"0.8.1",
"haiku",
None,
HashMap::new(),
packet,
None,
None,
vec!["Secret pattern detected".to_string()],
None,
"native",
None,
None,
pipeline,
);
assert_eq!(
error_receipt.error_kind,
Some(xchecker::types::ErrorKind::SecretDetected),
"error_kind should be SecretDetected"
);
assert!(
error_receipt.error_reason.is_some(),
"error_reason should be set"
);
let error_reason = error_receipt.error_reason.as_ref().unwrap();
assert!(!error_reason.is_empty(), "error_reason should not be empty");
assert!(
error_reason.contains("Secret") || error_reason.contains("pattern"),
"error_reason should describe the error type"
);
assert_eq!(
error_receipt.exit_code, 8,
"exit_code should be 8 for SecretDetected"
);
assert_eq!(
error_receipt
.pipeline
.as_ref()
.unwrap()
.execution_strategy
.as_deref(),
Some("controlled")
);
assert_eq!(
error_receipt.warnings.len(),
1,
"warnings should be preserved"
);
assert!(error_receipt.emitted_at > chrono::Utc::now() - chrono::Duration::seconds(5));
assert_eq!(error_receipt.phase, "design");
Ok(())
}
#[test]
fn test_error_receipt_lock_held() -> Result<()> {
let _temp = xchecker::paths::with_isolated_home();
let spec_id = unique_spec_id("lock-held");
let error = xchecker::error::XCheckerError::ConcurrentExecution {
id: spec_id.clone(),
};
let spec_dir = xchecker::paths::spec_root(&spec_id);
let receipt_manager = xchecker::receipt::ReceiptManager::new(&spec_dir);
let packet = xchecker::types::PacketEvidence {
files: vec![],
max_bytes: 65536,
max_lines: 1200,
};
let pipeline = Some(xchecker::types::PipelineInfo {
execution_strategy: Some("controlled".to_string()),
});
let error_receipt = receipt_manager.create_error_receipt(
&spec_id,
xchecker::types::PhaseId::Tasks,
&error,
"0.1.0",
"0.8.1",
"haiku",
None,
HashMap::new(),
packet,
None,
None,
vec![],
None,
"native",
None,
None,
pipeline,
);
assert_eq!(
error_receipt.error_kind,
Some(xchecker::types::ErrorKind::LockHeld),
"error_kind should be LockHeld"
);
assert!(error_receipt.error_reason.is_some());
let error_reason = error_receipt.error_reason.as_ref().unwrap();
assert!(
error_reason.contains(&spec_id) || error_reason.contains("concurrent"),
"error_reason should mention concurrent execution"
);
assert_eq!(
error_receipt.exit_code, 9,
"exit_code should be 9 for LockHeld"
);
assert_eq!(
error_receipt
.pipeline
.as_ref()
.unwrap()
.execution_strategy
.as_deref(),
Some("controlled")
);
assert_eq!(error_receipt.phase, "tasks");
assert!(error_receipt.emitted_at > chrono::Utc::now() - chrono::Duration::seconds(5));
Ok(())
}
#[test]
fn test_error_receipt_claude_failure() -> Result<()> {
let _temp = xchecker::paths::with_isolated_home();
let spec_id = unique_spec_id("claude-failure");
let error =
xchecker::error::XCheckerError::Claude(xchecker::error::ClaudeError::ExecutionFailed {
stderr: "Claude CLI execution failed: command not found".to_string(),
});
let spec_dir = xchecker::paths::spec_root(&spec_id);
let receipt_manager = xchecker::receipt::ReceiptManager::new(&spec_dir);
let packet = xchecker::types::PacketEvidence {
files: vec![],
max_bytes: 65536,
max_lines: 1200,
};
let pipeline = Some(xchecker::types::PipelineInfo {
execution_strategy: Some("controlled".to_string()),
});
let error_receipt = receipt_manager.create_error_receipt(
&spec_id,
xchecker::types::PhaseId::Review,
&error,
"0.1.0",
"0.8.1",
"haiku",
None,
HashMap::new(),
packet,
Some("Error: claude: command not found\n".to_string()),
Some("Redacted stderr output".to_string()),
vec!["Claude CLI not available".to_string()],
None,
"native",
None,
None,
pipeline,
);
assert_eq!(
error_receipt.error_kind,
Some(xchecker::types::ErrorKind::ClaudeFailure),
"error_kind should be ClaudeFailure"
);
assert!(error_receipt.error_reason.is_some());
let error_reason = error_receipt.error_reason.as_ref().unwrap();
assert!(
error_reason.contains("Claude") || error_reason.contains("failed"),
"error_reason should describe Claude failure"
);
assert_eq!(
error_receipt.exit_code, 70,
"exit_code should be 70 for ClaudeFailure"
);
assert!(error_receipt.pipeline.is_some());
assert_eq!(
error_receipt
.pipeline
.as_ref()
.unwrap()
.execution_strategy
.as_deref(),
Some("controlled"),
"execution_strategy should remain 'controlled' even on LLM failure"
);
assert!(error_receipt.stderr_tail.is_some());
assert!(error_receipt.stderr_redacted.is_some());
assert!(!error_receipt.warnings.is_empty());
assert_eq!(error_receipt.phase, "review");
assert_eq!(error_receipt.spec_id, spec_id);
assert!(error_receipt.emitted_at > chrono::Utc::now() - chrono::Duration::seconds(5));
assert_eq!(error_receipt.xchecker_version, "0.1.0");
assert_eq!(error_receipt.claude_cli_version, "0.8.1");
assert_eq!(error_receipt.model_full_name, "haiku");
Ok(())
}
#[test]
fn test_error_receipt_unknown_error() -> Result<()> {
let _temp = xchecker::paths::with_isolated_home();
let spec_id = unique_spec_id("unknown-error");
let error =
xchecker::error::XCheckerError::Io(std::io::Error::other("Something unexpected happened"));
let spec_dir = xchecker::paths::spec_root(&spec_id);
let receipt_manager = xchecker::receipt::ReceiptManager::new(&spec_dir);
let packet = xchecker::types::PacketEvidence {
files: vec![],
max_bytes: 65536,
max_lines: 1200,
};
let pipeline = Some(xchecker::types::PipelineInfo {
execution_strategy: Some("controlled".to_string()),
});
let error_receipt = receipt_manager.create_error_receipt(
&spec_id,
xchecker::types::PhaseId::Fixup,
&error,
"0.1.0",
"0.8.1",
"haiku",
None,
HashMap::new(),
packet,
None,
None,
vec![],
None,
"native",
None,
None,
pipeline,
);
assert_eq!(
error_receipt.error_kind,
Some(xchecker::types::ErrorKind::Unknown),
"error_kind should be Unknown for unclassified errors"
);
assert!(error_receipt.error_reason.is_some());
let error_reason = error_receipt.error_reason.as_ref().unwrap();
assert!(!error_reason.is_empty());
assert_eq!(
error_receipt.exit_code, 1,
"exit_code should be 1 for unknown errors"
);
assert!(error_receipt.pipeline.is_some());
assert_eq!(
error_receipt
.pipeline
.as_ref()
.unwrap()
.execution_strategy
.as_deref(),
Some("controlled")
);
assert_eq!(error_receipt.phase, "fixup");
assert_eq!(error_receipt.spec_id, spec_id);
assert!(error_receipt.emitted_at > chrono::Utc::now() - chrono::Duration::seconds(5));
Ok(())
}
#[test]
fn test_error_receipt_write_and_read() -> Result<()> {
let _temp = xchecker::paths::with_isolated_home();
let spec_id = unique_spec_id("write-read");
let error = xchecker::error::XCheckerError::PacketOverflow {
used_bytes: 100000,
limit_bytes: 65536,
used_lines: 1500,
limit_lines: 1200,
};
let spec_dir = xchecker::paths::spec_root(&spec_id);
let receipt_manager = xchecker::receipt::ReceiptManager::new(&spec_dir);
let packet = xchecker::types::PacketEvidence {
files: vec![],
max_bytes: 65536,
max_lines: 1200,
};
let pipeline = Some(xchecker::types::PipelineInfo {
execution_strategy: Some("controlled".to_string()),
});
let error_receipt = receipt_manager.create_error_receipt(
&spec_id,
xchecker::types::PhaseId::Requirements,
&error,
"0.1.0",
"0.8.1",
"haiku",
Some("sonnet".to_string()),
HashMap::new(),
packet,
Some("stderr output".to_string()),
None,
vec!["warning1".to_string()],
None,
"native",
None,
None,
pipeline.clone(),
);
let receipt_path = receipt_manager.write_receipt(&error_receipt)?;
assert!(receipt_path.exists());
let read_receipt =
receipt_manager.read_latest_receipt(xchecker::types::PhaseId::Requirements)?;
assert!(read_receipt.is_some());
let read_receipt = read_receipt.unwrap();
assert_eq!(read_receipt.error_kind, error_receipt.error_kind);
assert_eq!(read_receipt.error_reason, error_receipt.error_reason);
assert_eq!(read_receipt.exit_code, error_receipt.exit_code);
assert_eq!(read_receipt.stderr_tail, error_receipt.stderr_tail);
assert_eq!(read_receipt.warnings, error_receipt.warnings);
assert!(read_receipt.pipeline.is_some());
assert_eq!(
read_receipt
.pipeline
.as_ref()
.unwrap()
.execution_strategy
.as_deref(),
Some("controlled")
);
assert_eq!(read_receipt.spec_id, spec_id);
assert_eq!(read_receipt.phase, "requirements");
assert_eq!(read_receipt.model_alias, Some("sonnet".to_string()));
Ok(())
}
#[test]
fn test_error_receipt_preserves_packet_evidence() -> Result<()> {
let _temp = xchecker::paths::with_isolated_home();
let spec_id = unique_spec_id("packet-evidence");
let error =
xchecker::error::XCheckerError::Claude(xchecker::error::ClaudeError::ExecutionFailed {
stderr: "LLM timeout".to_string(),
});
let spec_dir = xchecker::paths::spec_root(&spec_id);
let receipt_manager = xchecker::receipt::ReceiptManager::new(&spec_dir);
let packet = xchecker::types::PacketEvidence {
files: vec![
xchecker::types::FileEvidence {
path: "spec.md".to_string(),
range: Some("L1-L100".to_string()),
blake3_pre_redaction: "abc123".to_string(),
priority: xchecker::types::Priority::High,
},
xchecker::types::FileEvidence {
path: "requirements.yaml".to_string(),
range: None,
blake3_pre_redaction: "def456".to_string(),
priority: xchecker::types::Priority::Upstream,
},
],
max_bytes: 65536,
max_lines: 1200,
};
let pipeline = Some(xchecker::types::PipelineInfo {
execution_strategy: Some("controlled".to_string()),
});
let error_receipt = receipt_manager.create_error_receipt(
&spec_id,
xchecker::types::PhaseId::Design,
&error,
"0.1.0",
"0.8.1",
"haiku",
None,
HashMap::new(),
packet.clone(),
None,
None,
vec![],
None,
"native",
None,
None,
pipeline,
);
assert_eq!(error_receipt.packet.max_bytes, 65536);
assert_eq!(error_receipt.packet.max_lines, 1200);
assert_eq!(error_receipt.packet.files.len(), 2);
assert_eq!(error_receipt.packet.files[0].path, "spec.md");
assert_eq!(
error_receipt.packet.files[0].range,
Some("L1-L100".to_string())
);
assert_eq!(error_receipt.packet.files[0].blake3_pre_redaction, "abc123");
assert_eq!(error_receipt.packet.files[1].path, "requirements.yaml");
assert_eq!(error_receipt.packet.files[1].range, None);
Ok(())
}
#[test]
fn test_error_receipt_json_structure() -> Result<()> {
let _temp = xchecker::paths::with_isolated_home();
let spec_id = unique_spec_id("json-structure");
let error = xchecker::error::XCheckerError::SecretDetected {
pattern: "api_key".to_string(),
location: "config.yaml:10".to_string(),
};
let spec_dir = xchecker::paths::spec_root(&spec_id);
let receipt_manager = xchecker::receipt::ReceiptManager::new(&spec_dir);
let packet = xchecker::types::PacketEvidence {
files: vec![],
max_bytes: 65536,
max_lines: 1200,
};
let pipeline = Some(xchecker::types::PipelineInfo {
execution_strategy: Some("controlled".to_string()),
});
let error_receipt = receipt_manager.create_error_receipt(
&spec_id,
xchecker::types::PhaseId::Requirements,
&error,
"0.1.0",
"0.8.1",
"haiku",
None,
HashMap::new(),
packet,
None,
None,
vec![],
None,
"native",
None,
None,
pipeline,
);
let json = serde_json::to_string_pretty(&error_receipt)?;
let parsed: serde_json::Value = serde_json::from_str(&json)?;
assert!(
parsed["error_kind"].is_string(),
"error_kind should be string"
);
assert_eq!(parsed["error_kind"].as_str(), Some("secret_detected"));
assert!(
parsed["error_reason"].is_string(),
"error_reason should be string"
);
assert!(
!parsed["error_reason"].as_str().unwrap().is_empty(),
"error_reason should not be empty"
);
assert!(
parsed["exit_code"].is_number(),
"exit_code should be number"
);
assert_eq!(parsed["exit_code"].as_i64(), Some(8));
assert!(parsed["pipeline"].is_object(), "pipeline should be object");
assert_eq!(
parsed["pipeline"]["execution_strategy"].as_str(),
Some("controlled")
);
assert!(
parsed["emitted_at"].is_string(),
"emitted_at should be string"
);
assert_eq!(parsed["phase"].as_str(), Some("requirements"));
assert_eq!(parsed["spec_id"].as_str(), Some(spec_id.as_str()));
assert!(parsed["outputs"].is_array(), "outputs should be array");
assert_eq!(parsed["outputs"].as_array().unwrap().len(), 0);
Ok(())
}