use std::fs;
use super::bundle::install_with;
use super::fixtures::{Event, Hooks};
use super::tests::{Fixture, TestResult};
#[test]
fn a_third_payload_failure_keeps_its_name_leaves_no_final_path_and_retries() -> TestResult {
let fixture = Fixture::new()?;
let mut hooks = Hooks {
fail_payload: Some(3),
..Hooks::default()
};
let error = install_with(
&fixture.binary,
&fixture.config,
&fixture.bundle,
&mut hooks,
)
.err()
.ok_or("third payload succeeded")?;
assert_eq!(error.code, "bundle_write_failed");
let partial = error.partial.as_ref().ok_or("partial marker missing")?;
assert_eq!(partial.name, "bundle_partial");
assert_eq!(
partial.completed,
[
".agents/plugins/marketplace.json",
".claude-plugin/marketplace.json"
]
);
assert!(!partial.published);
assert!(!fixture.bundle.exists());
assert!(
!hooks
.events
.iter()
.filter_map(|event| match event {
Event::Stage(path) => Some(path),
_ => None,
})
.any(|path| path.exists())
);
super::install(&fixture.binary, &fixture.config, &fixture.bundle)?;
Ok(())
}
#[test]
fn an_initial_payload_failure_has_no_partial_marker() -> TestResult {
let fixture = Fixture::new()?;
let error = install_with(
&fixture.binary,
&fixture.config,
&fixture.bundle,
&mut Hooks {
fail_payload: Some(1),
..Hooks::default()
},
)
.err()
.ok_or("first payload succeeded")?;
assert_eq!(error.code, "bundle_write_failed");
assert!(error.partial.is_none());
assert!(!fixture.bundle.exists());
Ok(())
}
#[test]
fn sync_failure_retains_its_name_and_cleans_the_unpublished_stage() -> TestResult {
let fixture = Fixture::new()?;
let error = install_with(
&fixture.binary,
&fixture.config,
&fixture.bundle,
&mut Hooks {
fail_sync: Some(3),
..Hooks::default()
},
)
.err()
.ok_or("sync failure accepted")?;
assert_eq!(error.code, "bundle_sync_failed");
assert_eq!(
error
.partial
.as_ref()
.ok_or("partial marker missing")?
.completed
.len(),
2
);
assert!(!fixture.bundle.exists());
super::install(&fixture.binary, &fixture.config, &fixture.bundle)?;
Ok(())
}
#[test]
fn all_file_and_stage_syncs_precede_publish_and_the_parent_sync_follows() -> TestResult {
let fixture = Fixture::new()?;
let mut hooks = Hooks::default();
install_with(
&fixture.binary,
&fixture.config,
&fixture.bundle,
&mut hooks,
)?;
let stage = match hooks.events.first().ok_or("stage event missing")? {
Event::Stage(path) => path.clone(),
_ => return Err("first event is not stage creation".into()),
};
assert_ne!(stage, fixture.bundle);
assert_eq!(stage.parent(), fixture.bundle.parent());
let relative = [
".agents/plugins/marketplace.json",
".claude-plugin/marketplace.json",
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
".mcp.json",
"bin/haematite",
"bin/haematite-jq",
"serve.json",
"skills/haematite/SKILL.md",
"bundle.json",
"skills/haematite",
"skills",
"bin",
".codex-plugin",
".claude-plugin",
".agents/plugins",
".agents",
];
let mut expected = vec![Event::Stage(stage.clone())];
expected.extend(
relative
.into_iter()
.map(|name| Event::Sync(stage.join(name))),
);
expected.push(Event::Sync(stage));
expected.push(Event::Publish(fixture.bundle.clone()));
expected.push(Event::Sync(
fixture.bundle.parent().ok_or("parent missing")?.to_owned(),
));
assert_eq!(hooks.events, expected);
assert_eq!(
hooks
.events
.iter()
.filter(|event| matches!(event, Event::Sync(_)))
.count(),
19
);
Ok(())
}
#[test]
fn a_publication_conflict_is_named_and_does_not_replace_the_destination() -> TestResult {
let fixture = Fixture::new()?;
let error = install_with(
&fixture.binary,
&fixture.config,
&fixture.bundle,
&mut Hooks {
obstruct_publish: true,
..Hooks::default()
},
)
.err()
.ok_or("publication conflict accepted")?;
assert_eq!(error.code, "bundle_publish_failed");
assert!(
!error
.partial
.as_ref()
.ok_or("partial marker missing")?
.published
);
assert_eq!(fs::read(fixture.bundle.join("sentinel"))?, b"retained");
assert_eq!(fs::read_dir(&fixture.bundle)?.count(), 1);
Ok(())
}
#[test]
fn failed_parent_sync_reports_the_complete_published_bundle() -> TestResult {
let fixture = Fixture::new()?;
let error = install_with(
&fixture.binary,
&fixture.config,
&fixture.bundle,
&mut Hooks {
fail_sync: Some(19),
..Hooks::default()
},
)
.err()
.ok_or("parent sync failure accepted")?;
assert_eq!(error.code, "bundle_sync_failed");
assert!(!error.to_string().contains("bundle_cleanup_failed"));
let partial = error.partial.as_ref().ok_or("partial marker missing")?;
assert!(partial.published);
assert_eq!(partial.completed.len(), 9);
assert!(fixture.bundle.join("bundle.json").is_file());
assert!(super::install(&fixture.binary, &fixture.config, &fixture.bundle)?.already_installed);
Ok(())
}
#[test]
fn cleanup_failure_is_visible_beside_the_original_failure() -> TestResult {
let fixture = Fixture::new()?;
let error = install_with(
&fixture.binary,
&fixture.config,
&fixture.bundle,
&mut Hooks {
fail_payload: Some(3),
fail_cleanup: true,
..Hooks::default()
},
)
.err()
.ok_or("cleanup failure accepted")?;
assert_eq!(error.code, "bundle_write_failed");
assert!(error.to_string().contains("bundle_cleanup_failed"));
assert_eq!(
error
.partial
.as_ref()
.ok_or("partial marker missing")?
.completed
.len(),
2
);
assert!(!fixture.bundle.exists());
super::install(&fixture.binary, &fixture.config, &fixture.bundle)?;
Ok(())
}
fn retained_stages(fixture: &Fixture) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let mut stages = Vec::new();
for name in [".haem-stage-live-z", ".haem-stage-crashed-a"] {
let path = fixture.bundle.parent().ok_or("parent missing")?.join(name);
fs::create_dir(&path)?;
fs::write(path.join("sentinel"), name)?;
stages.push(path.to_str().ok_or("stage path")?.to_owned());
}
stages.sort_unstable();
Ok(stages)
}
fn assert_stages_retained(stages: &[String]) -> TestResult {
for stage in stages {
let path = std::path::Path::new(stage);
assert_eq!(
fs::read(path.join("sentinel"))?,
path.file_name().ok_or("stage name")?.as_encoded_bytes()
);
}
Ok(())
}
#[test]
fn fresh_and_repeat_reports_name_all_prior_stages_without_deleting_them() -> TestResult {
let fixture = Fixture::new()?;
let stages = retained_stages(&fixture)?;
fs::create_dir(fixture.directory.path().join("unrelated"))?;
for repeated in [false, true] {
let report = super::install(&fixture.binary, &fixture.config, &fixture.bundle)?;
assert_eq!(report.already_installed, repeated);
assert_eq!(report.stale_stages, stages);
assert_eq!(
serde_json::to_value(&report)?["stale_stages"],
serde_json::json!(stages)
);
assert_stages_retained(&stages)?;
}
Ok(())
}
#[test]
fn preflight_and_payload_failures_carry_the_prior_stages() -> TestResult {
for preflight in [false, true] {
let fixture = Fixture::new()?;
let stages = retained_stages(&fixture)?;
if preflight {
fs::remove_file(&fixture.binary)?;
}
let error = install_with(
&fixture.binary,
&fixture.config,
&fixture.bundle,
&mut Hooks {
fail_payload: Some(3),
..Hooks::default()
},
)
.err()
.ok_or("failed install accepted")?;
assert_eq!(
error.code,
if preflight {
"bundle_source_invalid"
} else {
"bundle_write_failed"
}
);
assert_eq!(error.stale_stages, stages);
assert_eq!(error.partial.is_some(), !preflight);
assert!(error.to_string().contains("stale_stages="));
assert!(!fixture.bundle.exists());
assert_stages_retained(&stages)?;
}
Ok(())
}
#[test]
fn a_stage_inventory_read_failure_keeps_its_name() -> TestResult {
let fixture = Fixture::new()?;
let path = fixture
.directory
.path()
.join("missing-parent")
.join("bundle");
let error = super::publication::stale_stages(&path)
.err()
.ok_or("missing parent read accepted")?;
assert_eq!(error.code, "bundle_read_failed");
assert!(error.stale_stages.is_empty());
Ok(())
}
#[cfg(not(target_os = "macos"))]
fn non_utf8_stage(fixture: &Fixture) -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
use std::os::unix::ffi::OsStringExt;
let mut name = b".haem-stage-".to_vec();
name.push(0xff);
let path = fixture
.directory
.path()
.join(std::ffi::OsString::from_vec(name));
fs::create_dir(&path)?;
fs::write(path.join("sentinel"), b"retained")?;
Ok(path)
}
#[cfg(not(target_os = "macos"))]
#[test]
fn a_non_utf8_stage_name_is_reported_lossily_without_deleting_it() -> TestResult {
let fixture = Fixture::new()?;
let path = non_utf8_stage(&fixture)?;
let expected = format!("[lossy] {}", path.to_string_lossy());
assert!(expected.contains('\u{fffd}'));
for repeated in [false, true] {
let report = super::install(&fixture.binary, &fixture.config, &fixture.bundle)?;
assert_eq!(report.already_installed, repeated);
assert_eq!(report.stale_stages, [expected.as_str()]);
assert_eq!(report.files, 9);
assert_eq!(fs::read(path.join("sentinel"))?, b"retained");
assert_eq!(fs::read(fixture.bundle.join("bin/haematite"))?, b"operator");
}
Ok(())
}
#[cfg(not(target_os = "macos"))]
#[test]
fn a_failed_install_retains_the_lossy_stage_label_and_original_refusal() -> TestResult {
let fixture = Fixture::new()?;
let path = non_utf8_stage(&fixture)?;
fs::remove_file(&fixture.binary)?;
let error = super::install(&fixture.binary, &fixture.config, &fixture.bundle)
.err()
.ok_or("missing source accepted")?;
assert_eq!(error.code, "bundle_source_invalid");
assert_eq!(
error.stale_stages,
[format!("[lossy] {}", path.to_string_lossy())]
);
assert_eq!(fs::read(path.join("sentinel"))?, b"retained");
assert!(!fixture.bundle.exists());
Ok(())
}
fn in_memory_non_utf8_stage() -> std::path::PathBuf {
use std::os::unix::ffi::OsStringExt;
let mut name = b".haem-stage-".to_vec();
name.push(0xff);
std::path::PathBuf::from(std::ffi::OsString::from_vec(name))
}
#[test]
fn a_non_utf8_stage_label_is_marked_and_preserved_in_memory_on_every_platform() -> TestResult {
let path = in_memory_non_utf8_stage();
let bytes = path.as_os_str().as_encoded_bytes().to_vec();
let label = super::publication::stage_label(&path);
assert_eq!(label, "[lossy] .haem-stage-\u{fffd}");
let report = super::Report {
directory: "bundle".to_owned(),
files: 9,
already_installed: false,
stale_stages: vec![label.clone()],
};
assert_eq!(
serde_json::to_value(&report)?["stale_stages"],
serde_json::json!([label])
);
assert_eq!(path.as_os_str().as_encoded_bytes(), bytes);
Ok(())
}
#[test]
fn a_non_utf8_stage_label_keeps_the_original_refusal_in_memory_on_every_platform() {
let path = in_memory_non_utf8_stage();
let label = super::publication::stage_label(&path);
assert_eq!(label, "[lossy] .haem-stage-\u{fffd}");
let error = super::Error::new("bundle_source_invalid", "source missing")
.with_stale_stages(vec![label.clone()]);
assert_eq!(error.code, "bundle_source_invalid");
assert_eq!(error.message, "source missing");
assert_eq!(error.stale_stages, [label.as_str()]);
assert_eq!(
error.to_string(),
format!(
"bundle_source_invalid: source missing; stale_stages={:?}",
[label]
)
);
}