mirror of
https://github.com/vectorize-io/hindsight.git
synced 2026-09-14 19:31:49 +08:00
a72f9de15a
* 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>
246 lines
8.8 KiB
Rust
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:?}"
|
|
);
|
|
}
|