claude-print/tests/stop_poller.rs
jedarden 59e170ed03 Implement Phase 6: Stop Poller (bf-64s)
Add src/poller.rs with FIFO O_NONBLOCK open (read-end + keeper write-end),
Stop hook JSON payload parsing, transcript path derivation via cwd slug,
and StopInfo resolution. Wire poller into EventLoop via add_fifo_fd() which
was already present in event_loop.rs from Phase 3.

Update mock-claude to emit proper JSON Stop payloads (with and without
transcript_path via MOCK_OMIT_TRANSCRIPT_PATH=1) and update the pty_integration
assertion to match.

Tests test_stop_hook_fires and test_missing_transcript_path_derived both pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 00:05:14 -04:00

142 lines
5.2 KiB
Rust

use claude_print::event_loop::{EventLoop, ExitReason};
use claude_print::hook::HookInstaller;
use claude_print::poller::{open_fifo_nonblock, parse_stop_payload, resolve_stop_info};
use std::io::Write;
use std::os::unix::io::AsRawFd;
/// Verify that when a Stop JSON payload is written to the FIFO, the event loop
/// returns it via FifoPayload and parse_stop_payload extracts the fields.
#[test]
fn test_stop_hook_fires() {
let installer = HookInstaller::new().expect("HookInstaller::new");
// Open FIFO: keeper write-end + read-end (O_NONBLOCK, no ENXIO).
let (fifo_read, _fifo_keeper) =
open_fifo_nonblock(&installer.fifo_path).expect("open_fifo_nonblock");
// Dummy "master" pipe — won't produce PTY data, so POLLIN won't fire on it.
let (dummy_r, _dummy_w) = nix::unistd::pipe().expect("pipe");
// Self-pipe for interrupt signalling — won't be written in this test.
let (self_pipe_r, _self_pipe_w) = nix::unistd::pipe().expect("pipe");
let mut el = EventLoop::new(dummy_r.as_raw_fd(), self_pipe_r.as_raw_fd());
el.add_fifo_fd(fifo_read.as_raw_fd());
// Simulate the Stop hook writing a JSON payload to the FIFO.
let fifo_path = installer.fifo_path.clone();
let payload_json = concat!(
r#"{"hook_event_name":"Stop","session_id":"test-session-123","#,
r#""transcript_path":"/tmp/test-transcript/test-session-123.jsonl","#,
r#""cwd":"/tmp/test-cwd","last_assistant_message":"hello world"}"#,
);
let payload_bytes = payload_json.as_bytes().to_vec();
let writer = std::thread::spawn(move || {
// Blocking open; succeeds immediately because read-end (keeper) is open.
let mut f = std::fs::OpenOptions::new()
.write(true)
.open(&fifo_path)
.expect("open FIFO for writing");
f.write_all(&payload_bytes).expect("write payload");
f.write_all(b"\n").expect("write newline");
});
let reason = el.run(|_| {}).expect("event loop");
writer.join().expect("writer thread");
let raw = match reason {
ExitReason::FifoPayload(bytes) => bytes,
other => panic!("expected FifoPayload, got {other:?}"),
};
let stop = parse_stop_payload(&raw).expect("parse_stop_payload");
assert_eq!(
stop.session_id.as_deref(),
Some("test-session-123"),
"session_id mismatch"
);
assert_eq!(
stop.transcript_path.as_deref(),
Some("/tmp/test-transcript/test-session-123.jsonl"),
"transcript_path mismatch"
);
assert_eq!(
stop.last_assistant_message.as_deref(),
Some("hello world"),
"last_assistant_message mismatch"
);
let info = resolve_stop_info(stop);
assert_eq!(
info.transcript_path,
Some(std::path::PathBuf::from(
"/tmp/test-transcript/test-session-123.jsonl"
)),
"StopInfo transcript_path should use the explicit payload path"
);
}
/// When `transcript_path` is absent from the Stop payload, the transcript path
/// is derived from `session_id` + `cwd` using the documented slug algorithm.
#[test]
fn test_missing_transcript_path_derived() {
let installer = HookInstaller::new().expect("HookInstaller::new");
let (fifo_read, _fifo_keeper) =
open_fifo_nonblock(&installer.fifo_path).expect("open_fifo_nonblock");
let (dummy_r, _dummy_w) = nix::unistd::pipe().expect("pipe");
let (self_pipe_r, _self_pipe_w) = nix::unistd::pipe().expect("pipe");
let mut el = EventLoop::new(dummy_r.as_raw_fd(), self_pipe_r.as_raw_fd());
el.add_fifo_fd(fifo_read.as_raw_fd());
// Payload deliberately omits `transcript_path`.
let fifo_path = installer.fifo_path.clone();
let writer = std::thread::spawn(move || {
let payload = concat!(
r#"{"hook_event_name":"Stop","session_id":"abc123","#,
r#""cwd":"/home/user/myproject","last_assistant_message":"derived test"}"#,
);
let mut f = std::fs::OpenOptions::new()
.write(true)
.open(&fifo_path)
.expect("open FIFO for writing");
f.write_all(payload.as_bytes()).expect("write payload");
f.write_all(b"\n").expect("write newline");
});
let reason = el.run(|_| {}).expect("event loop");
writer.join().expect("writer thread");
let raw = match reason {
ExitReason::FifoPayload(bytes) => bytes,
other => panic!("expected FifoPayload, got {other:?}"),
};
let stop = parse_stop_payload(&raw).expect("parse_stop_payload");
// Confirm transcript_path is absent from the raw payload.
assert!(
stop.transcript_path.is_none(),
"transcript_path should be absent from payload"
);
assert_eq!(stop.session_id.as_deref(), Some("abc123"));
let info = resolve_stop_info(stop);
// Derived slug: /home/user/myproject → home-user-myproject
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
let expected = std::path::PathBuf::from(&home)
.join(".claude")
.join("projects")
.join("home-user-myproject")
.join("abc123.jsonl");
assert_eq!(
info.transcript_path,
Some(expected.clone()),
"derived transcript_path should be {expected:?}"
);
assert_eq!(info.session_id.as_deref(), Some("abc123"));
}