use super::*;
use crate::{CommandSpec, NativeProcess, ProcessConfig, StderrMode, StdinMode};
use std::time::Duration;
fn quick_exit_config() -> ProcessConfig {
ProcessConfig {
command: CommandSpec::Shell("exit 0".to_string()),
cwd: None,
env: None,
capture: false,
stderr_mode: StderrMode::Stdout,
creationflags: None,
create_process_group: false,
stdin_mode: StdinMode::Inherit,
nice: None,
}
}
#[test]
fn negotiate_reports_lifecycle_supported() {
let caps = ObserverCapabilities::negotiate();
assert!(caps.is_supported(EventCategory::Lifecycle));
assert_eq!(
caps.support(EventCategory::Lifecycle),
CapabilitySupport::Supported
);
let entry = caps.category(EventCategory::Lifecycle);
assert_eq!(entry.backend, "portable-lifecycle");
assert!(!entry.reason.is_empty());
}
#[test]
fn negotiate_reports_syscall_categories_unavailable_with_reason() {
let caps = ObserverCapabilities::negotiate();
for category in [
EventCategory::File,
EventCategory::Network,
EventCategory::Process,
] {
let entry = caps.category(category);
assert_eq!(
entry.support,
CapabilitySupport::Unavailable,
"{} should be unavailable in Phase 1",
category.as_str()
);
assert!(
entry.reason.contains("Phase 3"),
"{} reason must explain the deferral: {:?}",
category.as_str(),
entry.reason
);
assert!(!caps.is_supported(category));
}
}
#[test]
fn syscall_categories_advertise_per_os_backend_name() {
let caps = ObserverCapabilities::negotiate();
let file = caps.category(EventCategory::File);
let network = caps.category(EventCategory::Network);
let process = caps.category(EventCategory::Process);
#[cfg(target_os = "linux")]
{
assert_eq!(file.backend, "seccomp-user-notify");
assert_eq!(network.backend, "ebpf");
assert_eq!(process.backend, "seccomp-user-notify");
}
#[cfg(target_os = "windows")]
{
assert_eq!(file.backend, "etw");
assert_eq!(network.backend, "etw");
assert_eq!(process.backend, "etw");
}
#[cfg(target_os = "macos")]
{
assert_eq!(file.backend, "kqueue");
assert_eq!(network.backend, "endpoint-security");
assert_eq!(process.backend, "endpoint-security");
}
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
{
assert_eq!(file.backend, "none");
assert_eq!(network.backend, "none");
assert_eq!(process.backend, "none");
}
for entry in [file, network, process] {
assert!(
!entry.reason.contains("seccomp/eBPF/ETW"),
"stale multi-backend reason: {:?}",
entry.reason
);
assert!(
entry.reason.contains("Phase 3"),
"reason must keep the Phase 3 anchor: {:?}",
entry.reason
);
}
}
#[test]
fn negotiate_covers_every_category_exactly_once() {
let caps = ObserverCapabilities::negotiate();
assert_eq!(caps.categories().len(), EventCategory::ALL.len());
for category in EventCategory::ALL {
let _ = caps.category(category);
}
}
#[test]
fn observed_child_emits_started_then_exited() {
let (process, subscriber) =
NativeProcess::with_observer(quick_exit_config(), ObserverConfig::lifecycle());
process.start().expect("spawn quick-exit child");
let pid = process.pid().expect("child has a pid");
let code = process
.wait(Some(Duration::from_secs(30)))
.expect("child exits");
process.close().ok();
let events = subscriber.drain();
assert_eq!(
events.len(),
2,
"expected exactly started + exited, got {:?}",
events
);
let started = &events[0];
assert_eq!(started.category, EventCategory::Lifecycle);
assert_eq!(started.kind, ObserverEventKind::Started);
assert_eq!(started.kind.as_str(), "started");
assert_eq!(started.pid, pid);
let exited = &events[1];
assert_eq!(exited.category, EventCategory::Lifecycle);
assert_eq!(exited.kind, ObserverEventKind::Exited { exit_code: code });
assert_eq!(exited.kind.as_str(), "exited");
assert_eq!(exited.pid, pid);
assert!(exited.timestamp_ms >= started.timestamp_ms);
}
#[test]
fn exited_event_is_emitted_exactly_once_across_paths() {
let (process, subscriber) =
NativeProcess::with_observer(quick_exit_config(), ObserverConfig::lifecycle());
process.start().expect("spawn");
let _ = process.wait(Some(Duration::from_secs(30)));
let _ = process.poll();
process.close().ok();
let exited_count = subscriber
.drain()
.into_iter()
.filter(|e| matches!(e.kind, ObserverEventKind::Exited { .. }))
.count();
assert_eq!(exited_count, 1, "exited must fire exactly once");
}
#[test]
fn no_events_when_observation_not_configured() {
let process = NativeProcess::new(quick_exit_config());
process.start().expect("spawn");
let _ = process.wait(Some(Duration::from_secs(30)));
process.close().ok();
}
#[test]
fn config_observes_only_requested_categories() {
let lifecycle = ObserverConfig::lifecycle();
assert!(lifecycle.observes(EventCategory::Lifecycle));
assert!(!lifecycle.observes(EventCategory::File));
let none = ObserverConfig::with_categories([]);
assert!(!none.observes(EventCategory::Lifecycle));
}
#[test]
fn unobserved_category_produces_no_events() {
let (process, subscriber) = NativeProcess::with_observer(
quick_exit_config(),
ObserverConfig::with_categories([EventCategory::File]),
);
process.start().expect("spawn");
let _ = process.wait(Some(Duration::from_secs(30)));
process.close().ok();
assert!(
subscriber.drain().is_empty(),
"non-lifecycle observer must emit nothing in Phase 1"
);
}
#[test]
fn to_table_rows_one_row_per_category_in_stable_order() {
let caps = ObserverCapabilities::negotiate();
let rows = caps.to_table_rows();
assert_eq!(rows.len(), EventCategory::ALL.len());
let expected_cats: Vec<&str> = EventCategory::ALL.iter().map(|c| c.as_str()).collect();
let actual_cats: Vec<&str> = rows.iter().map(|r| r[0].as_str()).collect();
assert_eq!(actual_cats, expected_cats);
}
#[test]
fn to_table_rows_carries_support_backend_and_reason() {
let caps = ObserverCapabilities::negotiate();
let rows = caps.to_table_rows();
let lifecycle = rows
.iter()
.find(|r| r[0] == "lifecycle")
.expect("lifecycle row");
assert_eq!(lifecycle[1], "supported");
assert_eq!(lifecycle[2], "portable-lifecycle");
assert!(!lifecycle[3].is_empty());
}
#[test]
fn render_summary_lists_every_category_and_aligns_columns() {
let summary = ObserverCapabilities::negotiate().render_summary();
assert!(summary.starts_with("observer capabilities:\n"));
for category in EventCategory::ALL {
assert!(
summary.contains(category.as_str()),
"{} should appear in summary:\n{}",
category.as_str(),
summary
);
}
let body: Vec<&str> = summary.lines().skip(1).filter(|l| !l.is_empty()).collect();
assert_eq!(body.len(), EventCategory::ALL.len());
for line in &body {
assert!(
line.starts_with(" "),
"row missing two-space indent: {line:?}"
);
}
}
#[test]
fn category_and_support_string_forms_are_stable() {
assert_eq!(EventCategory::Lifecycle.as_str(), "lifecycle");
assert_eq!(EventCategory::File.as_str(), "file");
assert_eq!(EventCategory::Network.as_str(), "network");
assert_eq!(EventCategory::Process.as_str(), "process");
assert_eq!(CapabilitySupport::Supported.as_str(), "supported");
assert_eq!(CapabilitySupport::Partial.as_str(), "partial");
assert_eq!(CapabilitySupport::Unavailable.as_str(), "unavailable");
}