use lifeloop::host_assets::{
self as ha, AssetStatus, CCD_COMPAT_PROFILE, CCD_RENEWAL_PROFILE, HostAdapter, IntegrationMode,
LIFELOOP_DIRECT_PROFILE, LifecycleProfile, claude_settings_status_with_profile,
codex_hooks_contain_managed_lifecycle_with_profile, codex_hooks_status_with_profile,
merge_claude_settings_text_with_profile, merge_claude_settings_with_profile,
merge_codex_hooks_with_profile, render_applied_assets_with_profile,
render_source_assets_with_profile,
};
use serde_json::{Value, json};
#[test]
fn render_applied_assets_matches_ccd_compat_profile_byte_for_byte() {
for host in HostAdapter::ALL.iter().copied() {
for mode in IntegrationMode::ALL.iter().copied() {
let legacy = ha::render_applied_assets(host, mode);
let explicit = render_applied_assets_with_profile(host, mode, &CCD_COMPAT_PROFILE);
assert_eq!(
legacy, explicit,
"render_applied_assets({host:?}, {mode:?}) must match CCD_COMPAT_PROFILE"
);
}
}
}
#[test]
fn render_source_assets_matches_ccd_compat_profile_byte_for_byte() {
for host in HostAdapter::ALL.iter().copied() {
let legacy = ha::render_source_assets(host);
let explicit = render_source_assets_with_profile(host, &CCD_COMPAT_PROFILE);
assert_eq!(
legacy, explicit,
"render_source_assets({host:?}) must match CCD_COMPAT_PROFILE"
);
}
}
#[test]
fn merge_claude_settings_matches_ccd_compat_profile_byte_for_byte() {
let input = json!({
"theme": "dark",
"hooks": {
"Stop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "echo user" }] }]
}
});
let legacy = ha::merge_claude_settings(input.clone()).expect("legacy merge");
let explicit =
merge_claude_settings_with_profile(input, &CCD_COMPAT_PROFILE).expect("explicit merge");
assert_eq!(legacy, explicit);
}
#[test]
fn merge_codex_hooks_matches_ccd_compat_profile_byte_for_byte() {
let input = json!({
"hooks": {
"Stop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "echo user" }] }]
}
});
let legacy = ha::merge_codex_hooks(input.clone()).expect("legacy merge");
let explicit =
merge_codex_hooks_with_profile(input, &CCD_COMPAT_PROFILE).expect("explicit merge");
assert_eq!(legacy, explicit);
}
#[test]
fn lifecycle_profile_id_is_stable_for_each_built_in() {
assert_eq!(CCD_COMPAT_PROFILE.id, "ccd-compat");
assert_eq!(CCD_RENEWAL_PROFILE.id, "ccd-renewal");
assert_eq!(LIFELOOP_DIRECT_PROFILE.id, "lifeloop-direct");
}
#[test]
fn lifecycle_profile_command_helpers_concat_prefix_and_arg() {
let cmd = CCD_COMPAT_PROFILE.claude_command("on-session-start");
assert!(cmd.starts_with(CCD_COMPAT_PROFILE.claude_command_prefix));
assert!(cmd.ends_with("on-session-start"));
let cmd = LIFELOOP_DIRECT_PROFILE.codex_command("on-agent-end");
assert!(cmd.starts_with(LIFELOOP_DIRECT_PROFILE.codex_command_prefix));
assert!(cmd.ends_with("on-agent-end"));
}
#[test]
fn lifeloop_direct_profile_recognizes_ccd_compat_as_legacy() {
let legacy = LIFELOOP_DIRECT_PROFILE.claude_legacy_substrings;
assert!(
legacy.contains(&CCD_COMPAT_PROFILE.claude_command_prefix),
"lifeloop-direct must scrub CCD-compat entries during in-place migration"
);
assert!(
legacy.iter().any(|s| s.contains("ccd-hook.py")),
"lifeloop-direct must also scrub the pre-v1 Python bridge legacy form"
);
}
#[test]
fn ccd_renewal_profile_recognizes_ccd_compat_as_legacy() {
let legacy = CCD_RENEWAL_PROFILE.claude_legacy_substrings;
assert!(
legacy.contains(&CCD_COMPAT_PROFILE.claude_command_prefix),
"ccd-renewal must scrub CCD-compat entries during in-place migration"
);
assert!(
legacy.iter().any(|s| s.contains("ccd-hook.py")),
"ccd-renewal must also scrub the pre-v1 Python bridge legacy form"
);
}
#[test]
fn lifeloop_direct_claude_command_does_not_contain_ccd_binary_reference() {
let cmd = LIFELOOP_DIRECT_PROFILE.claude_command("on-session-start");
assert!(
!cmd.contains("CCD_BIN") && !cmd.contains("\"ccd\""),
"lifeloop-direct command must not embed CCD binary references: {cmd}"
);
assert!(
cmd.contains("LIFELOOP_BIN") && cmd.contains("lifeloop"),
"lifeloop-direct command must invoke the lifeloop binary: {cmd}"
);
}
#[test]
fn lifeloop_direct_renders_plausible_claude_settings_without_ccd_strings() {
let assets = render_applied_assets_with_profile(
HostAdapter::Claude,
IntegrationMode::NativeHook,
&LIFELOOP_DIRECT_PROFILE,
);
assert_eq!(assets.len(), 1);
let settings = &assets[0];
assert_eq!(settings.relative_path, ha::CLAUDE_TARGET_SETTINGS);
let parsed: Value = serde_json::from_str(&settings.contents).expect("valid json");
let session_start_cmd = parsed["hooks"]["SessionStart"][0]["hooks"][0]["command"]
.as_str()
.expect("session_start command");
assert!(
session_start_cmd.starts_with(LIFELOOP_DIRECT_PROFILE.claude_command_prefix),
"must use lifeloop-direct prefix"
);
assert!(
!session_start_cmd.contains("CCD_BIN"),
"must not embed CCD_BIN: {session_start_cmd}"
);
for (event, _, _) in LIFELOOP_DIRECT_PROFILE.claude_managed_events {
assert!(
parsed["hooks"].get(event).is_some(),
"rendered settings missing managed event {event}"
);
}
}
#[test]
fn lifeloop_direct_renders_plausible_codex_hooks_without_ccd_strings() {
let assets = render_applied_assets_with_profile(
HostAdapter::Codex,
IntegrationMode::NativeHook,
&LIFELOOP_DIRECT_PROFILE,
);
let hooks_asset = assets
.iter()
.find(|a| a.relative_path == ha::CODEX_TARGET_HOOKS)
.expect("hooks.json present");
let parsed: Value = serde_json::from_str(&hooks_asset.contents).expect("valid json");
assert!(
codex_hooks_contain_managed_lifecycle_with_profile(&parsed, &LIFELOOP_DIRECT_PROFILE),
"lifeloop-direct hooks must satisfy its own managed-lifecycle predicate"
);
assert!(
!codex_hooks_contain_managed_lifecycle_with_profile(&parsed, &CCD_COMPAT_PROFILE),
"CCD predicate must reject lifeloop-direct rendered hooks"
);
}
#[test]
fn lifeloop_direct_codex_status_text_is_lifeloop_flavored() {
let assets = render_applied_assets_with_profile(
HostAdapter::Codex,
IntegrationMode::NativeHook,
&LIFELOOP_DIRECT_PROFILE,
);
let hooks_asset = assets
.iter()
.find(|a| a.relative_path == ha::CODEX_TARGET_HOOKS)
.expect("hooks.json present");
let parsed: Value = serde_json::from_str(&hooks_asset.contents).expect("valid json");
let session_start = &parsed["hooks"]["SessionStart"][0]["hooks"][0];
assert_eq!(
session_start["statusMessage"],
Value::String("Loading Lifeloop session context".to_owned()),
"status text must reflect the binary the harness is actually invoking"
);
}
#[test]
fn ccd_renewal_profile_invokes_lifeloop_broker_with_ccd_client() {
let assets = render_applied_assets_with_profile(
HostAdapter::Codex,
IntegrationMode::NativeHook,
&CCD_RENEWAL_PROFILE,
);
let hooks_asset = assets
.iter()
.find(|a| a.relative_path == ha::CODEX_TARGET_HOOKS)
.expect("hooks.json present");
let parsed: Value = serde_json::from_str(&hooks_asset.contents).expect("valid json");
let session_start = parsed["hooks"]["SessionStart"][0]["hooks"][0]["command"]
.as_str()
.expect("session start command");
assert!(
session_start.starts_with(CCD_RENEWAL_PROFILE.codex_command_prefix),
"must use ccd-renewal prefix"
);
assert!(
session_start.contains("lifeloop")
&& session_start.contains("host-hook")
&& session_start.contains("--client-cmd")
&& session_start.contains("CCD_BIN"),
"ccd-renewal profile must broker CCD through Lifeloop: {session_start}"
);
assert!(
!session_start.contains("ccd\" --output hook-protocol host-hook"),
"ccd-renewal must not render the removed CCD host-hook command: {session_start}"
);
}
#[test]
fn ccd_compat_merge_does_not_recognize_lifeloop_direct_managed_entries() {
let assets = render_applied_assets_with_profile(
HostAdapter::Claude,
IntegrationMode::NativeHook,
&LIFELOOP_DIRECT_PROFILE,
);
let lifeloop_direct_settings = &assets[0].contents;
let merged = merge_claude_settings_text_with_profile(
Some(lifeloop_direct_settings),
false,
&CCD_COMPAT_PROFILE,
)
.expect("merge should succeed")
.expect("rendered");
let parsed: Value = serde_json::from_str(&merged.rendered).expect("valid json");
let session_start_entries = parsed["hooks"]["SessionStart"][0]["hooks"]
.as_array()
.expect("array");
let lifeloop_direct_present = session_start_entries.iter().any(|e| {
e.get("command")
.and_then(Value::as_str)
.map(|s| s.starts_with(LIFELOOP_DIRECT_PROFILE.claude_command_prefix))
.unwrap_or(false)
});
let ccd_compat_present = session_start_entries.iter().any(|e| {
e.get("command")
.and_then(Value::as_str)
.map(|s| s.starts_with(CCD_COMPAT_PROFILE.claude_command_prefix))
.unwrap_or(false)
});
assert!(
lifeloop_direct_present,
"CCD merge must preserve lifeloop-direct entry: {session_start_entries:?}"
);
assert!(
ccd_compat_present,
"CCD merge must add its own entry: {session_start_entries:?}"
);
}
#[test]
fn lifeloop_direct_merge_scrubs_ccd_compat_entries_for_clean_migration() {
let ccd_assets = render_applied_assets_with_profile(
HostAdapter::Claude,
IntegrationMode::NativeHook,
&CCD_COMPAT_PROFILE,
);
let ccd_compat_settings = &ccd_assets[0].contents;
let merged = merge_claude_settings_text_with_profile(
Some(ccd_compat_settings),
false,
&LIFELOOP_DIRECT_PROFILE,
)
.expect("merge ok")
.expect("rendered");
let parsed: Value = serde_json::from_str(&merged.rendered).expect("valid json");
let session_start_entries = parsed["hooks"]["SessionStart"][0]["hooks"]
.as_array()
.expect("array");
assert_eq!(
session_start_entries.len(),
1,
"migration must collapse to a single managed entry, found: {session_start_entries:?}"
);
let cmd = session_start_entries[0]["command"]
.as_str()
.expect("command string");
assert!(
cmd.starts_with(LIFELOOP_DIRECT_PROFILE.claude_command_prefix),
"post-migration entry must use lifeloop-direct prefix, got: {cmd}"
);
assert!(
!cmd.contains("CCD_BIN"),
"post-migration entry must not retain CCD binary references, got: {cmd}"
);
}
#[test]
fn ccd_renewal_merge_scrubs_ccd_compat_entries_for_clean_migration() {
let ccd_assets = render_applied_assets_with_profile(
HostAdapter::Claude,
IntegrationMode::NativeHook,
&CCD_COMPAT_PROFILE,
);
let ccd_compat_settings = &ccd_assets[0].contents;
let merged = merge_claude_settings_text_with_profile(
Some(ccd_compat_settings),
false,
&CCD_RENEWAL_PROFILE,
)
.expect("merge ok")
.expect("rendered");
let parsed: Value = serde_json::from_str(&merged.rendered).expect("valid json");
let entries = parsed["hooks"]["SessionStart"][0]["hooks"]
.as_array()
.expect("array");
assert_eq!(
entries.len(),
1,
"migration must collapse to a single managed entry, found: {entries:?}"
);
let cmd = entries[0]["command"].as_str().expect("command string");
assert!(
cmd.starts_with(CCD_RENEWAL_PROFILE.claude_command_prefix),
"post-migration entry must use ccd-renewal prefix, got: {cmd}"
);
assert!(
!cmd.starts_with(CCD_COMPAT_PROFILE.claude_command_prefix),
"post-migration entry must not retain CCD-compat prefix, got: {cmd}"
);
}
#[test]
fn lifeloop_direct_merge_preserves_user_owned_entries_during_migration() {
let input = json!({
"hooks": {
"SessionStart": [{
"matcher": "startup|resume|clear|compact",
"hooks": [
{ "type": "command", "command": "\"${CCD_BIN:-ccd}\" --output hook-protocol host-hook --path \"$CLAUDE_PROJECT_DIR\" --host claude --hook on-session-start" },
{ "type": "command", "command": "echo user-owned" }
]
}]
}
});
let merged =
merge_claude_settings_with_profile(input, &LIFELOOP_DIRECT_PROFILE).expect("merge ok");
let entries = merged["hooks"]["SessionStart"][0]["hooks"]
.as_array()
.expect("array");
assert_eq!(
entries.len(),
2,
"expected user entry + lifeloop-direct entry, got: {entries:?}"
);
let user_present = entries.iter().any(|e| {
e.get("command")
.and_then(Value::as_str)
.map(|s| s == "echo user-owned")
.unwrap_or(false)
});
assert!(
user_present,
"user-owned entry must be preserved through migration"
);
let lifeloop_direct_present = entries.iter().any(|e| {
e.get("command")
.and_then(Value::as_str)
.map(|s| s.starts_with(LIFELOOP_DIRECT_PROFILE.claude_command_prefix))
.unwrap_or(false)
});
assert!(
lifeloop_direct_present,
"lifeloop-direct managed entry must be added"
);
}
#[test]
fn lifeloop_direct_merge_replaces_its_own_stale_entry_idempotently() {
let assets = render_applied_assets_with_profile(
HostAdapter::Claude,
IntegrationMode::NativeHook,
&LIFELOOP_DIRECT_PROFILE,
);
let body = &assets[0].contents;
let once = merge_claude_settings_text_with_profile(Some(body), false, &LIFELOOP_DIRECT_PROFILE)
.expect("ok")
.expect("rendered");
assert_eq!(
once.rendered, *body,
"rendered settings.json must be a fixed point of its own profile's merge"
);
}
#[test]
fn claude_settings_status_with_profile_separates_profiles() {
let assets = render_applied_assets_with_profile(
HostAdapter::Claude,
IntegrationMode::NativeHook,
&LIFELOOP_DIRECT_PROFILE,
);
let body = &assets[0].contents;
assert_eq!(
claude_settings_status_with_profile(Some(body), &LIFELOOP_DIRECT_PROFILE),
AssetStatus::Present,
"lifeloop-direct's own rendered output must be Present under that profile"
);
assert_eq!(
claude_settings_status_with_profile(Some(body), &CCD_COMPAT_PROFILE),
AssetStatus::Drifted,
"lifeloop-direct rendered output must NOT be Present under CCD_COMPAT_PROFILE"
);
}
#[test]
fn codex_hooks_status_with_profile_separates_profiles() {
let assets = render_applied_assets_with_profile(
HostAdapter::Codex,
IntegrationMode::NativeHook,
&LIFELOOP_DIRECT_PROFILE,
);
let hooks = assets
.iter()
.find(|a| a.relative_path == ha::CODEX_TARGET_HOOKS)
.expect("hooks.json present");
assert_eq!(
codex_hooks_status_with_profile(Some(&hooks.contents), &LIFELOOP_DIRECT_PROFILE),
AssetStatus::Present
);
assert_eq!(
codex_hooks_status_with_profile(Some(&hooks.contents), &CCD_COMPAT_PROFILE),
AssetStatus::Drifted
);
}
#[test]
fn custom_profile_renders_with_its_own_command_prefix() {
static CUSTOM_CLAUDE_EVENTS: &[(&str, &str, &str)] = &[
("SessionStart", "on-session-start", "*"),
("Stop", "on-agent-end", "*"),
];
static CUSTOM_CODEX_EVENTS: &[(&str, &str, &str, &str)] = &[(
"SessionStart",
"on-session-start",
"*",
"Custom client status",
)];
let custom = LifecycleProfile {
id: "custom-test",
claude_command_prefix: "custom-broker --hook ",
claude_legacy_substrings: &[],
claude_managed_events: CUSTOM_CLAUDE_EVENTS,
codex_command_prefix: "custom-broker --codex --hook ",
codex_managed_events: CUSTOM_CODEX_EVENTS,
};
let assets = render_applied_assets_with_profile(
HostAdapter::Claude,
IntegrationMode::NativeHook,
&custom,
);
let parsed: Value = serde_json::from_str(&assets[0].contents).expect("valid json");
let cmd = parsed["hooks"]["SessionStart"][0]["hooks"][0]["command"]
.as_str()
.expect("command");
assert_eq!(cmd, "custom-broker --hook on-session-start");
assert_eq!(
parsed["hooks"].as_object().expect("object").len(),
2,
"custom profile must drive the event set, not leak CCD-compat events"
);
}
#[test]
fn invalid_profile_prefixes_fail_closed() {
static CUSTOM_CLAUDE_EVENTS: &[(&str, &str, &str)] =
&[("SessionStart", "on-session-start", "*")];
static CUSTOM_CODEX_EVENTS: &[(&str, &str, &str, &str)] =
&[("SessionStart", "on-session-start", "*", "status")];
let invalid = LifecycleProfile {
id: "invalid-empty-prefix",
claude_command_prefix: "",
claude_legacy_substrings: &[],
claude_managed_events: CUSTOM_CLAUDE_EVENTS,
codex_command_prefix: "",
codex_managed_events: CUSTOM_CODEX_EVENTS,
};
let existing = serde_json::json!({
"hooks": {
"SessionStart": [{
"matcher": "*",
"hooks": [{ "type": "command", "command": "user-owned" }]
}]
}
});
assert!(invalid.validate().is_err());
assert!(merge_claude_settings_with_profile(existing.clone(), &invalid).is_none());
assert!(merge_codex_hooks_with_profile(existing, &invalid).is_none());
assert!(
render_applied_assets_with_profile(
HostAdapter::Claude,
IntegrationMode::NativeHook,
&invalid
)
.is_empty()
);
}