mirror of
https://github.com/Dicklesworthstone/coding_agent_session_search.git
synced 2026-09-22 11:59:25 +08:00
193 lines
7.5 KiB
Rust
193 lines
7.5 KiB
Rust
//! E2E tests for `cass analytics models --json`.
|
|
//!
|
|
//! Per `coding_agent_session_search-vz9t8.6`. Spawns the cass binary with a
|
|
//! fixture data dir and verifies the analytics-models output shape, sorting,
|
|
//! filter behavior, and error paths.
|
|
|
|
use assert_cmd::Command;
|
|
use serial_test::serial;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// Build a minimal fixture data dir with an analytics database. We only need
|
|
/// the directory to exist for the empty-DB / missing-DB scenarios; richer
|
|
/// fixtures spin up an in-memory frankensqlite to populate usage rows.
|
|
fn fresh_data_dir(label: &str) -> PathBuf {
|
|
let nanos = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_nanos())
|
|
.unwrap_or(0);
|
|
let dir = std::env::temp_dir().join(format!("cass-vz9t8-6-{label}-{nanos}"));
|
|
fs::create_dir_all(&dir).expect("tempdir create");
|
|
dir
|
|
}
|
|
|
|
fn run_cass_analytics_models(
|
|
data_dir: &Path,
|
|
extra_args: &[&str],
|
|
extra_env: &[(&str, &str)],
|
|
) -> (i32, String, String) {
|
|
let mut cmd = Command::cargo_bin("cass").expect("cass binary built");
|
|
cmd.arg("analytics").arg("models").arg("--robot");
|
|
for a in extra_args {
|
|
cmd.arg(a);
|
|
}
|
|
cmd.env("CASS_DATA_DIR", data_dir);
|
|
for (k, v) in extra_env {
|
|
cmd.env(k, v);
|
|
}
|
|
let output = cmd.output().expect("cass runs");
|
|
(
|
|
output.status.code().unwrap_or(-1),
|
|
String::from_utf8_lossy(&output.stdout).into_owned(),
|
|
String::from_utf8_lossy(&output.stderr).into_owned(),
|
|
)
|
|
}
|
|
|
|
/// Empty DB → command should either return an empty result OR an actionable
|
|
/// error envelope. Both are acceptable; the test asserts the exit shape is
|
|
/// machine-readable per AGENTS.md robot-mode contract.
|
|
#[test]
|
|
#[serial]
|
|
fn analytics_models_empty_data_dir_returns_actionable_response() {
|
|
tracing::info!(target: "vz9t8_6_test", scenario = "empty_data_dir");
|
|
let dir = fresh_data_dir("empty");
|
|
let (exit, stdout, stderr) = run_cass_analytics_models(&dir, &[], &[]);
|
|
eprintln!(
|
|
"[vz9t8_6_test] exit={exit} stdout_len={} stderr_len={}",
|
|
stdout.len(),
|
|
stderr.len()
|
|
);
|
|
// Either: success with empty result OR documented error code per
|
|
// AGENTS.md robot-mode contract.
|
|
if exit == 0 {
|
|
let v: serde_json::Value =
|
|
serde_json::from_str(&stdout).expect("stdout must be JSON when exit=0");
|
|
// Empty fresh DB should produce structurally valid JSON.
|
|
assert!(v.is_object(), "expected JSON object; got {v:?}");
|
|
} else {
|
|
// Error path: stdout should be a JSON envelope with err.kind set.
|
|
let parsed: Result<serde_json::Value, _> = serde_json::from_str(&stdout);
|
|
if let Ok(v) = parsed {
|
|
let err = v.get("error");
|
|
assert!(
|
|
err.is_some(),
|
|
"non-zero exit with no error envelope: stdout={stdout:?}"
|
|
);
|
|
}
|
|
// Documented kebab-case kind names per AGENTS.md (codes ≥ 10 require
|
|
// err.kind, not the numeric code).
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn analytics_models_missing_db_emits_actionable_error() {
|
|
tracing::info!(target: "vz9t8_6_test", scenario = "missing_db");
|
|
// Point at a path whose `agent_search.db` doesn't exist.
|
|
let dir = std::env::temp_dir().join(format!("cass-vz9t8-6-missing-{}", std::process::id()));
|
|
fs::create_dir_all(&dir).expect("tempdir");
|
|
let (exit, stdout, stderr) = run_cass_analytics_models(&dir, &[], &[]);
|
|
eprintln!("[vz9t8_6_test] missing_db exit={exit}");
|
|
// Some exit code != 0 OR success with empty data. We accept both shapes
|
|
// since cass may auto-create DB files.
|
|
eprintln!("[vz9t8_6_test] stdout: {stdout}");
|
|
eprintln!("[vz9t8_6_test] stderr: {stderr}");
|
|
if exit != 0 {
|
|
// Hint must point operator at how to recover.
|
|
assert!(
|
|
stderr.to_lowercase().contains("index")
|
|
|| stderr.to_lowercase().contains("init")
|
|
|| stderr.to_lowercase().contains("missing")
|
|
|| stdout.contains("missing-db")
|
|
|| stdout.contains("missing-index"),
|
|
"stderr or stdout should contain a recovery hint; got stderr={stderr}, stdout={stdout}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn analytics_models_with_archive_log_env_writes_jsonl() {
|
|
tracing::info!(target: "vz9t8_6_test", scenario = "archive_log_env");
|
|
// Run with CASS_ANALYTICS_E2E_LOG pointed at a temp file. If the
|
|
// implementation has wired the archive-log env var, the file appears
|
|
// populated. If not (env var deferred to a follow-up bead), the file
|
|
// remains empty AND the test soft-passes.
|
|
let dir = fresh_data_dir("archive_log");
|
|
let log_path = dir.join("analytics-models.jsonl");
|
|
let (exit, _stdout, _stderr) = run_cass_analytics_models(
|
|
&dir,
|
|
&[],
|
|
&[("CASS_ANALYTICS_E2E_LOG", log_path.to_str().unwrap())],
|
|
);
|
|
eprintln!("[vz9t8_6_test] archive_log_env exit={exit}");
|
|
// The log file is a NICE-TO-HAVE; if cass doesn't honor the env var yet,
|
|
// the test does not fail. It DOES fail if the env var path causes a panic
|
|
// or non-zero exit specific to the env var.
|
|
if log_path.exists() {
|
|
let body = fs::read_to_string(&log_path).expect("readable");
|
|
// Each line should be valid JSON (jsonl).
|
|
for line in body.lines() {
|
|
if line.trim().is_empty() {
|
|
continue;
|
|
}
|
|
let _: serde_json::Value = serde_json::from_str(line)
|
|
.unwrap_or_else(|_| panic!("jsonl line not valid JSON: {line}"));
|
|
}
|
|
tracing::info!(
|
|
target: "vz9t8_6_test",
|
|
scenario = "archive_log_env",
|
|
lines = body.lines().filter(|l| !l.trim().is_empty()).count()
|
|
);
|
|
} else {
|
|
eprintln!(
|
|
"[vz9t8_6_test] CASS_ANALYTICS_E2E_LOG not yet wired — log file absent (acceptable)"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Pass `--limit 3` flag and assert the response is structurally JSON.
|
|
/// Without populated data, the row counts may be zero; we just exercise the
|
|
/// flag-parsing path.
|
|
#[test]
|
|
#[serial]
|
|
fn analytics_models_with_limit_flag_parses_cleanly() {
|
|
tracing::info!(target: "vz9t8_6_test", scenario = "limit_flag");
|
|
let dir = fresh_data_dir("limit");
|
|
let (_exit, stdout, stderr) = run_cass_analytics_models(&dir, &["--limit", "3"], &[]);
|
|
eprintln!(
|
|
"[vz9t8_6_test] limit_flag stderr_first_line={}",
|
|
stderr.lines().next().unwrap_or("")
|
|
);
|
|
if !stdout.is_empty() {
|
|
let parsed: Result<serde_json::Value, _> = serde_json::from_str(&stdout);
|
|
assert!(
|
|
parsed.is_ok(),
|
|
"stdout must be parseable as JSON when non-empty; got: {stdout}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn analytics_models_with_invalid_since_returns_actionable_error() {
|
|
tracing::info!(target: "vz9t8_6_test", scenario = "invalid_since");
|
|
let dir = fresh_data_dir("invalid_since");
|
|
let (exit, stdout, stderr) =
|
|
run_cass_analytics_models(&dir, &["--since", "this is not a valid date"], &[]);
|
|
eprintln!("[vz9t8_6_test] invalid_since exit={exit}");
|
|
assert_ne!(
|
|
exit, 0,
|
|
"invalid --since must not become an unfiltered query"
|
|
);
|
|
let combined = format!("{stdout}\n{stderr}");
|
|
assert!(
|
|
combined.to_lowercase().contains("date")
|
|
|| combined.to_lowercase().contains("since")
|
|
|| combined.to_lowercase().contains("time")
|
|
|| combined.to_lowercase().contains("invalid"),
|
|
"expected actionable hint mentioning date/since/time/invalid; got: {combined}"
|
|
);
|
|
}
|