Files
vectorize-io__hindsight/hindsight-cli/tests/integration_test.rs
2anoubis a72f9de15a fix(cli): restore default SIGPIPE handling to prevent abort on broken pipe (#3925)
* fix(cli): restore default SIGPIPE handling to prevent abort on broken pipe

Release builds set panic = "abort", so when stdout is piped to a
consumer that closes early (e.g. `hindsight ... | head`), Rust's
println! panics on EPIPE and the process dies with SIGABRT instead of
terminating cleanly. Restore the default SIGPIPE disposition so the CLI
exits normally on a broken pipe, matching standard Unix CLI behavior.

* fix(cli): gate SIGPIPE reset to Unix and add regression test

libc::SIGPIPE does not exist on Windows (libc only defines SIGINT, SIGILL,
SIGFPE, SIGSEGV, SIGTERM, SIGABRT there), so gate the signal() call with
#[cfg(unix)] to keep the Windows build compiling.

Also add a deterministic regression test that spawns the binary with stdout
on a pipe whose read end is already closed, asserting the process exits with
SIGPIPE (signal 13) rather than aborting.

---------

Co-authored-by: Mike <mike@email.nonexist>
2026-09-07 10:50:32 +02:00

246 lines
8.8 KiB
Rust

use std::process::Command;
#[test]
fn test_cli_help() {
let output = Command::new("cargo")
.args(["run", "--", "--help"])
.output()
.expect("Failed to execute command");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Hindsight CLI"));
}
#[test]
fn test_help_emits_no_ansi_when_piped() {
// `Command::output()` captures stdout through a pipe, which is exactly the
// case this guards: coding agents and shell redirection read piped output,
// and the logo/gradients would otherwise land there as escape codes. The
// ui.rs unit tests inject the enabled flag, so only an end-to-end run of
// the real binary proves the helpers are actually wired to the predicate.
let output = Command::new("cargo")
.args(["run", "--", "--help"])
.output()
.expect("Failed to execute command");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains('\u{1b}'),
"piped --help must not emit ANSI escapes, got: {:?}",
stdout.chars().take(200).collect::<String>()
);
}
#[test]
fn test_cli_version() {
let output = Command::new("cargo")
.args(["run", "--", "--version"])
.output()
.expect("Failed to execute command");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("hindsight"));
}
#[test]
fn test_ui_command_without_config() {
// Test that the ui command handles missing config gracefully
// Create a temp home directory with no config
let temp_dir = std::env::temp_dir().join(format!("hindsight-test-ui-{}", std::process::id()));
std::fs::create_dir_all(&temp_dir).expect("Failed to create temp dir");
let output = Command::new("cargo")
.args(["run", "--", "ui"])
.env_remove("HINDSIGHT_API_URL")
.env_remove("HINDSIGHT_API_KEY")
.env("HOME", &temp_dir)
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
// Either it fails with a config error or it succeeds if there's a default config
// Just verify it doesn't crash unexpectedly
assert!(
!output.status.success()
|| stdout.contains("Launching Hindsight Control Plane UI")
|| stderr.contains("Configuration error")
|| stderr.contains("HINDSIGHT_API_URL"),
"Unexpected output - stdout: {}, stderr: {}",
stdout,
stderr
);
// Cleanup
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_ui_command_with_config() {
// This test is skipped by default since it requires a running control plane
// and would block for a long time. The other tests cover the basic functionality.
// To run this test manually:
// 1. Build the control plane: cd hindsight-control-plane && npm run build
// 2. Run: cargo test test_ui_command_with_config -- --ignored
// Just verify that the ui command accepts the configuration
let temp_dir =
std::env::temp_dir().join(format!("hindsight-test-ui-valid-{}", std::process::id()));
std::fs::create_dir_all(&temp_dir).expect("Failed to create temp dir");
// Write a minimal config
let config_dir = temp_dir.join(".config").join("hindsight");
std::fs::create_dir_all(&config_dir).expect("Failed to create config dir");
let config_file = config_dir.join("config");
std::fs::write(
&config_file,
"api_url=http://localhost:8888\napi_key=test-key\n",
)
.expect("Failed to write config");
let output = Command::new("cargo")
.args(["run", "--", "ui", "--help"])
.env("HOME", &temp_dir)
.output()
.expect("Failed to execute command");
// The --help should work regardless
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Hindsight CLI") || output.status.success());
// Cleanup
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_memory_item_timestamp_serializes_as_plain_string() {
// Regression: progenitor used to emit `MemoryItemTimestamp` as a struct
// with two `#[serde(flatten)]` Option subtypes from the `anyOf:[datetime,
// string]` schema. Serializing that errored with
// "can only flatten structs and maps (got a string)"
// — so `hindsight memory retain --timestamp` never worked. The build.rs
// spec-massage step now collapses string-only anyOf unions into a plain
// `{type: string}`, so the field is just `Option<String>`. Assert that.
let item = hindsight_client::types::MemoryItem {
content: hindsight_client::types::Content::Variant0("Bob went hiking yesterday".to_string()),
context: None,
metadata: None,
timestamp: Some("2026-05-31T10:00:00Z".to_string()),
document_id: Some("doc-1".to_string()),
entities: None,
resolve_entities: true,
tags: None,
observation_scopes: None,
strategy: None,
update_mode: None,
};
let json = serde_json::to_string(&item).expect("MemoryItem must serialize");
assert!(
json.contains(r#""timestamp":"2026-05-31T10:00:00Z""#),
"expected timestamp at top-level as a plain string, got: {}",
json
);
}
#[test]
fn test_memory_retain_exposes_timestamp_flag() {
// Regression: `hindsight memory retain` historically had no way to set the
// memory's event date even though the SDKs do. The flag must appear in
// --help so users (and docs) can discover it.
let output = Command::new("cargo")
.args(["run", "--", "memory", "retain", "--help"])
.output()
.expect("Failed to execute command");
assert!(output.status.success(), "retain --help failed");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("--timestamp") && stdout.contains("-t"),
"expected --timestamp/-t flag in retain --help, got: {}",
stdout
);
}
#[test]
fn test_configure_command() {
// Test that configure command creates/updates config
let temp_dir = std::env::temp_dir().join(format!("hindsight-test-{}", std::process::id()));
std::fs::create_dir_all(&temp_dir).expect("Failed to create temp dir");
let output = Command::new("cargo")
.args([
"run",
"--",
"configure",
"--api-url",
"http://localhost:9999",
"--api-key",
"test-key-123",
])
.env("HOME", &temp_dir)
.output()
.expect("Failed to execute command");
assert!(
output.status.success(),
"Configure command failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Configuration saved") || stdout.contains("success"));
// Cleanup
std::fs::remove_dir_all(&temp_dir).ok();
}
/// Regression test for the SIGPIPE crash.
///
/// Release builds set `panic = "abort"`, and Rust ignores SIGPIPE at startup,
/// so a write to a closed stdout pipe surfaces as an `EPIPE` panic that becomes
/// a silent SIGABRT. The fix restores the default SIGPIPE disposition so the
/// CLI instead terminates with SIGPIPE (signal 13), like other Unix tools.
///
/// This test is deterministic: it hands the child a stdout pipe whose read end
/// is already closed, so the very first write triggers SIGPIPE regardless of
/// how much output the child produces (no reliance on overflowing the 64 KiB
/// pipe buffer, which is what makes a plain `--help | head` flaky).
#[cfg(unix)]
#[test]
fn test_sigpipe_terminates_with_signal_not_abort() {
use std::os::unix::io::FromRawFd;
use std::os::unix::process::ExitStatusExt;
// Create a pipe and immediately close its read end. There is no reader, so
// the child's first write to stdout must hit SIGPIPE (if its default
// disposition was restored) rather than an EPIPE panic/abort.
let mut fds = [0i32; 2];
let rc = unsafe { libc::pipe(fds.as_mut_ptr()) };
assert_eq!(rc, 0, "pipe() failed");
unsafe {
libc::close(fds[0]);
}
let stdout = unsafe { std::process::Stdio::from_raw_fd(fds[1]) };
let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_hindsight"))
.arg("--help")
.stdout(stdout)
.stderr(std::process::Stdio::null())
.spawn()
.expect("failed to spawn hindsight");
let status = child.wait().expect("failed to wait on hindsight");
// signal 13 == SIGPIPE. A panic/abort would instead report signal 6 (ABRT)
// or a non-zero exit code, neither of which is the desired clean
// termination.
assert_eq!(
status.signal(),
Some(13),
"expected SIGPIPE termination, got status {status:?}"
);
}