//! CLI tests (tech-spec §9): run the real `heph` binary against a real `hephd` //! over a unix socket, and assert output + side effects (export files). use std::path::PathBuf; use std::process::Command; use std::thread; use std::time::Duration; use tokio::net::UnixListener; use heph_core::{FixedClock, LocalStore}; use hephd::Daemon; const NOW: i64 = 1_704_067_200_000; /// Spawn a daemon on its own thread+runtime; return (socket, tempdir). fn spawn_daemon() -> (PathBuf, tempfile::TempDir) { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("heph.db"); let socket = dir.path().join("d.sock"); let store = LocalStore::open(&db, Box::new(FixedClock(NOW))).unwrap(); let socket_for_thread = socket.clone(); thread::spawn(move || { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); rt.block_on(async move { let listener = UnixListener::bind(&socket_for_thread).unwrap(); let _ = Daemon::new(store).serve(listener).await; }); }); for _ in 0..200 { if socket.exists() { break; } thread::sleep(Duration::from_millis(5)); } (socket, dir) } /// Run the `heph` binary against `socket`; return (stdout, success). fn heph(socket: &std::path::Path, args: &[&str]) -> (String, bool) { let out = Command::new(env!("CARGO_BIN_EXE_heph")) .arg("--socket") .arg(socket) .args(args) .output() .expect("run heph"); ( String::from_utf8_lossy(&out.stdout).into_owned(), out.status.success(), ) } #[test] fn task_then_next_shows_the_task() { let (socket, _dir) = spawn_daemon(); let (out, ok) = heph(&socket, &["task", "Buy milk", "--attention", "red"]); assert!(ok, "task create failed: {out}"); assert!(out.contains("Created task"), "{out}"); let (out, ok) = heph(&socket, &["next"]); assert!(ok); assert!(out.contains("[red]"), "{out}"); assert!(out.contains("Buy milk"), "{out}"); } #[test] fn next_on_empty_store_is_friendly() { let (socket, _dir) = spawn_daemon(); let (out, ok) = heph(&socket, &["next"]); assert!(ok); assert!(out.contains("Nothing actionable"), "{out}"); } /// The first whitespace token of a "Created task …" line. fn created_id(out: &str) -> String { out.split_whitespace() .nth(2) .expect("id in output") .to_string() } #[test] fn task_with_iso_do_date_shows_formatted_date_in_next() { let (socket, _dir) = spawn_daemon(); // A do-date in the past (relative to the daemon's fixed 2024-01-01 clock) // so it is actionable and appears in `next`. let (out, ok) = heph( &socket, &[ "task", "Renew passport", "-a", "orange", "--do-date", "2023-12-25", ], ); assert!(ok, "{out}"); let (out, ok) = heph(&socket, &["next"]); assert!(ok); assert!(out.contains("Renew passport"), "{out}"); // Within 2023 vs the 2024 clock → full YYYY-MM-DD form. assert!( out.contains("do:2023-12-25"), "expected formatted do-date: {out}" ); } #[test] fn edit_reschedules_and_clears_fields() { let (socket, _dir) = spawn_daemon(); let (out, _) = heph( &socket, &[ "task", "Taxes", "--do-date", "2023-01-01", "--recur", "yearly", ], ); let id = created_id(&out); // Reschedule do-date and clear recurrence. let (out, ok) = heph( &socket, &["edit", &id, "--do-date", "2023-06-01", "--recur", "none"], ); assert!(ok, "{out}"); let (out, ok) = heph(&socket, &["show", &id]); assert!(ok, "{out}"); assert!( out.contains("\"recurrence\": null"), "recurrence cleared: {out}" ); assert!(out.contains("\"do_date\""), "{out}"); } #[test] fn list_and_done_and_health() { let (socket, _dir) = spawn_daemon(); let (out, _) = heph(&socket, &["task", "Mow lawn", "-a", "red"]); let id = created_id(&out); let (out, ok) = heph(&socket, &["list"]); assert!(ok, "{out}"); assert!(out.contains("Mow lawn"), "{out}"); let (out, ok) = heph(&socket, &["health"]); assert!(ok, "{out}"); assert!(out.contains("active"), "{out}"); let (out, ok) = heph(&socket, &["done", &id]); assert!(ok, "{out}"); assert!(out.contains("done"), "{out}"); // Done tasks drop out of `next`. let (out, ok) = heph(&socket, &["next"]); assert!(ok); assert!( !out.contains("Mow lawn"), "completed task should be gone: {out}" ); } #[test] fn recur_preset_makes_a_recurring_task() { let (socket, _dir) = spawn_daemon(); let (out, _) = heph(&socket, &["task", "Standup", "--recur", "weekdays"]); let id = created_id(&out); let (out, ok) = heph(&socket, &["show", &id]); assert!(ok, "{out}"); assert!(out.contains("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"), "{out}"); } #[test] fn project_add_then_file_a_task_under_it() { let (socket, _dir) = spawn_daemon(); let (out, ok) = heph(&socket, &["project", "add", "Maintenance"]); assert!(ok, "{out}"); // A task can be filed under the project by name. let (out, ok) = heph( &socket, &["task", "Replace filter", "--project", "Maintenance"], ); assert!(ok, "{out}"); // An unknown project name is a clear error, not a silent miss. let (out, ok) = heph(&socket, &["task", "Whatever", "--project", "Nonexistent"]); assert!(!ok, "expected failure for unknown project: {out}"); } #[test] fn project_list_shows_projects_only() { let (socket, _dir) = spawn_daemon(); heph(&socket, &["project", "add", "Maintenance"]); heph(&socket, &["project", "add", "Coding"]); // A task (and its context doc) must not show up in the project list. heph(&socket, &["task", "Some task"]); let (out, ok) = heph(&socket, &["project", "list"]); assert!(ok, "{out}"); assert!(out.contains("Maintenance"), "{out}"); assert!(out.contains("Coding"), "{out}"); assert!(!out.contains("Some task"), "tasks are not projects: {out}"); } #[test] fn log_append_then_tail() { let (socket, _dir) = spawn_daemon(); let (out, _) = heph(&socket, &["task", "Investigate bug"]); let id = created_id(&out); let (_, ok) = heph( &socket, &["log", &id, "looked", "at", "the", "stack", "trace"], ); assert!(ok); let (out, ok) = heph(&socket, &["log", &id]); assert!(ok, "{out}"); assert!(out.contains("looked at the stack trace"), "{out}"); } #[test] fn export_writes_markdown_files() { let (socket, dir) = spawn_daemon(); heph(&socket, &["doc", "Roof log", "--body", "# Roof"]); let export_dir = dir.path().join("export"); let (out, ok) = heph(&socket, &["export", export_dir.to_str().unwrap()]); assert!(ok, "export failed: {out}"); assert!(out.contains("Exported 1 nodes"), "{out}"); // The doc landed as a .md file under doc/. let docs: Vec<_> = std::fs::read_dir(export_dir.join("doc")) .unwrap() .filter_map(|e| e.ok()) .collect(); assert_eq!(docs.len(), 1); let text = std::fs::read_to_string(docs[0].path()).unwrap(); assert!(text.contains("title: \"Roof log\""), "{text}"); }