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>
142 lines
5.2 KiB
Rust
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"));
|
|
}
|