Add bf-5nr validation notes: claude-print-ci WorkflowTemplate YAML is valid
YAML parses cleanly and kubectl dry-run returns no errors. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e16680820e
commit
7176ef2939
21 changed files with 1419 additions and 116 deletions
|
|
@ -1,8 +1,8 @@
|
|||
{"id":"bf-10t","title":"Phase 10: Tests (~500 LOC)","description":"Entry: Phase 8 complete (can run in parallel with Phase 9).\n\nPhase 10 completes the test suite by adding tests NOT already written as part of Phases 2-9 completion criteria. Each prior phase's completion criterion already specifies and runs its own targeted integration tests.\n\nPhase 10 adds the remaining cross-phase and corner-case tests:\n- Version-resilience suite: feed transcript JSONL with extra/unknown fields across all JSONL event types; confirm no panic, correct output\n- Hook inheritance suite: verify user hooks in ~/.claude/settings.json fire alongside the relay hook (OQ-2 resolution validated end-to-end)\n- All MEDIUM/LOW mock scenarios not covered by earlier phases (see Testing section of plan.md for full list)\n- Conformance harness: run claude-print against a real claude binary in a sandboxed invocation and compare output format to claude -p reference\n\nComplete when:\n- cargo test passes with zero failures (all unit + integration tests)\n\nReference: docs/plan/plan.md § Phase 10","design":"","acceptance_criteria":"","notes":"","status":"in_progress","priority":1,"issue_type":"task","assignee":"claude-glm-glm47-alpha","created_at":"2026-06-10T03:54:17.744030380Z","updated_at":"2026-06-10T05:31:03.149051175Z","source_repo":".","compaction_level":0,"labels":["deferred","failure-count:1"],"dependencies":[{"issue_id":"bf-10t","depends_on_id":"bf-2f1","type":"blocks","created_at":"2026-06-10T03:54:31.710642868Z","created_by":"cli","thread_id":""}]}
|
||||
{"id":"bf-168","title":"Add claude-print-ci WorkflowTemplate to declarative-config","description":"Create the claude-print-ci WorkflowTemplate in jedarden/declarative-config at k8s/iad-ci/argo-workflows/.\n\nDeliverables:\n- WorkflowTemplate named claude-print-ci\n- verify step only (build-musl + github-release steps added in Phase 11)\n- Delegates to rust-verify WorkflowTemplate\n- Follows existing patterns in k8s/iad-ci/argo-workflows/\n\nAcceptance criteria:\n- WorkflowTemplate YAML is valid and parseable\n- ArgoCD syncs it without errors\n- Running claude-print-ci with verify step completes successfully via rust-verify delegation","design":"","acceptance_criteria":"","notes":"","status":"open","priority":2,"issue_type":"task","created_at":"2026-06-10T05:42:41.854551786Z","updated_at":"2026-06-10T05:42:41.854551786Z","source_repo":".","compaction_level":0,"labels":["split-child"],"dependencies":[{"issue_id":"bf-168","depends_on_id":"bf-3n3","type":"blocks","created_at":"2026-06-10T05:42:54.812631980Z","created_by":"cli","thread_id":""}]}
|
||||
{"id":"bf-10t","title":"Phase 10: Tests (~500 LOC)","description":"Entry: Phase 8 complete (can run in parallel with Phase 9).\n\nPhase 10 completes the test suite by adding tests NOT already written as part of Phases 2-9 completion criteria. Each prior phase's completion criterion already specifies and runs its own targeted integration tests.\n\nPhase 10 adds the remaining cross-phase and corner-case tests:\n- Version-resilience suite: feed transcript JSONL with extra/unknown fields across all JSONL event types; confirm no panic, correct output\n- Hook inheritance suite: verify user hooks in ~/.claude/settings.json fire alongside the relay hook (OQ-2 resolution validated end-to-end)\n- All MEDIUM/LOW mock scenarios not covered by earlier phases (see Testing section of plan.md for full list)\n- Conformance harness: run claude-print against a real claude binary in a sandboxed invocation and compare output format to claude -p reference\n\nComplete when:\n- cargo test passes with zero failures (all unit + integration tests)\n\nReference: docs/plan/plan.md § Phase 10","design":"","acceptance_criteria":"","notes":"","status":"in_progress","priority":1,"issue_type":"task","assignee":"claude-glm-glm47-alpha","created_at":"2026-06-10T03:54:17.744030380Z","updated_at":"2026-06-10T05:58:16.171180076Z","source_repo":".","compaction_level":0,"labels":["deferred","failure-count:2"],"dependencies":[{"issue_id":"bf-10t","depends_on_id":"bf-2f1","type":"blocks","created_at":"2026-06-10T03:54:31.710642868Z","created_by":"cli","thread_id":""}]}
|
||||
{"id":"bf-168","title":"Add claude-print-ci WorkflowTemplate to declarative-config","description":"Create the claude-print-ci WorkflowTemplate in jedarden/declarative-config at k8s/iad-ci/argo-workflows/.\n\nDeliverables:\n- WorkflowTemplate named claude-print-ci\n- verify step only (build-musl + github-release steps added in Phase 11)\n- Delegates to rust-verify WorkflowTemplate\n- Follows existing patterns in k8s/iad-ci/argo-workflows/\n\nAcceptance criteria:\n- WorkflowTemplate YAML is valid and parseable\n- ArgoCD syncs it without errors\n- Running claude-print-ci with verify step completes successfully via rust-verify delegation","design":"","acceptance_criteria":"","notes":"","status":"in_progress","priority":2,"issue_type":"task","assignee":"claude-code-glm-47-claude-print-bravo","created_at":"2026-06-10T05:42:41.854551786Z","updated_at":"2026-06-10T05:56:46.122770869Z","source_repo":".","compaction_level":0,"labels":["failure-count:1","split-child"],"dependencies":[{"issue_id":"bf-168","depends_on_id":"bf-3n3","type":"blocks","created_at":"2026-06-10T05:42:54.812631980Z","created_by":"cli","thread_id":""}]}
|
||||
{"id":"bf-2f1","title":"Phase 8: Emitter (~120 LOC)","description":"Entry: Phase 7 complete.\n\nImplementation: src/emitter.rs\n- Output format routing: text (plain string to stdout), json (JSON object with result/cost/session_id/claude_version), stream-json (forward raw JSONL lines as written)\n- claude_version: read from child process version string captured at startup\n- Error result objects: exit code mapping (0=success, 1=error, 2=no-response, 3=timeout, 4=input-error)\n- stream-json: reader thread consuming JSONL transcript file via mpsc channel, forward each line to stdout as written\n\nComplete when:\n- All emitter unit tests pass (tests/emitter.rs)\n- AS-1 (text output format) passes: response text emitted to stdout, exit 0\n- AS-2 (json output format) passes: valid JSON object on stdout with result field\n- stream-json output parses as valid JSONL (each line is valid JSON)\n\nReference: docs/plan/plan.md § Phase 8","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":1,"issue_type":"task","assignee":"claude-code-glm-47-claude-print-bravo","created_at":"2026-06-10T03:53:59.176338208Z","updated_at":"2026-06-10T05:45:00Z","closed_at":"2026-06-10T05:45:00Z","close_reason":"Completed","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-2f1","depends_on_id":"bf-64k","type":"blocks","created_at":"2026-06-10T03:54:31.701710128Z","created_by":"cli","thread_id":""}]}
|
||||
{"id":"bf-360","title":"Implement --check subcommand in claude-print","description":"Add the --check subcommand to src/main.rs or src/check.rs.\n\nDeliverables:\n- openpty probe: confirm openpty syscall succeeds\n- mkfifo probe: confirm mkfifo in /home/coding/.tmp succeeds\n- optional mock_claude PTY round-trip (if mock_claude binary present in PATH)\n- exits 0 on success, prints diagnostic table to stdout\n\nAcceptance criteria:\n- cargo build succeeds with the --check subcommand present\n- Running claude-print --check locally exits 0 and prints a diagnostic table\n- Running claude-print --check on a system missing openpty exits non-zero with clear error","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-06-10T05:42:25.787304377Z","updated_at":"2026-06-10T05:45:27.270Z","closed_at":"2026-06-10T05:45:27.270Z","close_reason":"Completed","source_repo":".","compaction_level":0,"labels":["split-child"]}
|
||||
{"id":"bf-3n3","title":"Write claude-print.yaml NEEDLE agent config","description":"Create claude-print.yaml NEEDLE agent configuration file for dispatching beads via claude-print instead of claude -p.\n\nDeliverables:\n- input_method: stdin\n- output_transform: needle-transform-claude\n- invoke_template: claude-print --output-format json --model ${MODEL}\n- All required NEEDLE agent config fields populated correctly\n\nAcceptance criteria:\n- NEEDLE accepts the config without validation errors\n- NEEDLE dispatches a test bead using claude-print.yaml\n- AS-3 (agent spec test 3) passes with this config","design":"","acceptance_criteria":"","notes":"","status":"in_progress","priority":2,"issue_type":"task","assignee":"claude-code-glm-47-claude-print-bravo","created_at":"2026-06-10T05:42:36.202732130Z","updated_at":"2026-06-10T05:50:48.563986931Z","source_repo":".","compaction_level":0,"labels":["failure-count:1","split-child"],"dependencies":[{"issue_id":"bf-3n3","depends_on_id":"bf-3vj","type":"blocks","created_at":"2026-06-10T05:42:54.798141429Z","created_by":"cli","thread_id":""}]}
|
||||
{"id":"bf-3n3","title":"Write claude-print.yaml NEEDLE agent config","description":"Create claude-print.yaml NEEDLE agent configuration file for dispatching beads via claude-print instead of claude -p.\n\nDeliverables:\n- input_method: stdin\n- output_transform: needle-transform-claude\n- invoke_template: claude-print --output-format json --model ${MODEL}\n- All required NEEDLE agent config fields populated correctly\n\nAcceptance criteria:\n- NEEDLE accepts the config without validation errors\n- NEEDLE dispatches a test bead using claude-print.yaml\n- AS-3 (agent spec test 3) passes with this config","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":2,"issue_type":"task","assignee":"claude-code-glm-47-claude-print-bravo","created_at":"2026-06-10T05:42:36.202732130Z","updated_at":"2026-06-10T05:52:09.166882664Z","closed_at":"2026-06-10T05:52:09.166882664Z","close_reason":"Completed","source_repo":".","compaction_level":0,"labels":["split-child"],"dependencies":[{"issue_id":"bf-3n3","depends_on_id":"bf-3vj","type":"blocks","created_at":"2026-06-10T05:42:54.798141429Z","created_by":"cli","thread_id":""}]}
|
||||
{"id":"bf-3vj","title":"Write install.sh for claude-print binary","description":"Create install.sh that downloads the claude-print release binary from GitHub and installs it to ~/.local/bin/claude-print.\n\nDeliverables:\n- Downloads the correct binary for the current platform from the GitHub releases page\n- Installs to ~/.local/bin/claude-print with executable permissions\n- Runs claude-print --check after install to verify the binary works\n- Script is POSIX-compatible bash\n\nAcceptance criteria:\n- bash -n install.sh passes (syntactically valid)\n- Running install.sh on a clean system installs the binary and exits 0\n- claude-print --check succeeds after install","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-06-10T05:42:30.633173087Z","updated_at":"2026-06-10T05:47:43.818004Z","closed_at":"2026-06-10T05:47:43.818004Z","source_repo":".","compaction_level":0,"labels":["split-child"],"dependencies":[{"issue_id":"bf-3vj","depends_on_id":"bf-360","type":"blocks","created_at":"2026-06-10T05:42:54.784343802Z","created_by":"cli","thread_id":""}]}
|
||||
{"id":"bf-42j","title":"Phase 9: NEEDLE Integration (~50 LOC + config)","description":"Entry: Phase 8 complete.\n\nDeliverables:\n1. claude-print.yaml — NEEDLE agent config for dispatching beads via claude-print instead of claude -p\n - input_method: stdin, output_transform: needle-transform-claude\n - invoke_template: claude-print --output-format json --model ${MODEL}\n2. install.sh — download release binary from GitHub, install to ~/.local/bin/claude-print, verify --check\n3. claude-print-ci WorkflowTemplate in jedarden/declarative-config (k8s/iad-ci/argo-workflows/)\n - verify step only (build-musl + github-release steps added in Phase 11)\n - delegates to rust-verify WorkflowTemplate\n4. --check subcommand in src/main.rs or src/check.rs\n - openpty probe: confirm openpty syscall succeeds\n - mkfifo probe: confirm mkfifo in /home/coding/.tmp succeeds\n - optional mock_claude PTY round-trip (if mock_claude binary present in PATH)\n - exits 0 on success, prints diagnostic table\n\nComplete when:\n- bash -n install.sh passes (syntactically valid)\n- Manually copying locally-built binary to ~/.local/bin/claude-print and running claude-print --check succeeds\n- NEEDLE dispatches a test bead using claude-print.yaml; AS-3 passes\n- README flags table matches claude-print --help output exactly (verified manually)\n\nReference: docs/plan/plan.md § Phase 9","design":"","acceptance_criteria":"","notes":"","status":"in_progress","priority":1,"issue_type":"task","assignee":"claude-code-glm-47-claude-print-bravo","created_at":"2026-06-10T03:54:09.318382701Z","updated_at":"2026-06-10T05:42:08.074835677Z","source_repo":".","compaction_level":0,"labels":["umbrella"],"dependencies":[{"issue_id":"bf-42j","depends_on_id":"bf-2f1","type":"blocks","created_at":"2026-06-10T03:54:31.706113490Z","created_by":"cli","thread_id":""},{"issue_id":"bf-42j","depends_on_id":"bf-168","type":"blocks","created_at":"2026-06-10T05:42:57.601270040Z","created_by":"cli","thread_id":""}]}
|
||||
{"id":"bf-4no","title":"Phase 11: CI (~YAML only)","description":"Entry: Phase 10 complete (and Phase 9 complete for install.sh e2e test).\n\nDeliverables in jedarden/declarative-config (k8s/iad-ci/argo-workflows/claude-print-ci.yaml):\n- Update claude-print-ci WorkflowTemplate stub (from Phase 9) with full steps:\n 1. verify — delegates to rust-verify WorkflowTemplate (fmt + clippy + test + cargo audit)\n 2. build-musl — cross-compile x86_64-unknown-linux-musl release binary\n 3. build-mock-claude-musl — build test-fixtures/mock-claude/ as musl binary\n 4. github-release — upload claude-print + mock_claude binaries + last-claude-version.txt artifact to GitHub Release\n- Confirm cargo audit runs (either via rust-verify or as explicit step between verify and build-musl)\n- install.sh end-to-end download test: download release artifact from GitHub Release URL, verify install.sh exits 0 and claude-print --check passes\n\nComplete when:\n- CI run on main branch produces release binary at expected GitHub Release URL\n- last-claude-version.txt artifact present in release\n- Binary passes claude-print --check (credential-free) via install.sh\n- install.sh end-to-end download test passes (deferred from Phase 9)\n- AS-1 verified manually before pushing release tag\n\nReference: docs/plan/plan.md § Phase 11","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:54:27.444014247Z","updated_at":"2026-06-10T03:54:27.444014247Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-4no","depends_on_id":"bf-10t","type":"blocks","created_at":"2026-06-10T03:54:31.717358160Z","created_by":"cli","thread_id":""},{"issue_id":"bf-4no","depends_on_id":"bf-42j","type":"blocks","created_at":"2026-06-10T03:54:31.725797267Z","created_by":"cli","thread_id":""}]}
|
||||
|
|
|
|||
24
notes/bf-5nr.md
Normal file
24
notes/bf-5nr.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# bf-5nr: Validate claude-print-ci WorkflowTemplate YAML
|
||||
|
||||
## Task
|
||||
Validate the claude-print-ci WorkflowTemplate YAML in declarative-config.
|
||||
|
||||
## File Validated
|
||||
`jedarden/declarative-config` → `k8s/iad-ci/argo-workflows/claude-print-ci-workflowtemplate.yml`
|
||||
|
||||
## Results
|
||||
|
||||
### YAML Syntax (python3 yaml.safe_load)
|
||||
- **PASS** — parsed without errors
|
||||
- kind: WorkflowTemplate
|
||||
- name: claude-print-ci
|
||||
- entrypoint: ci
|
||||
|
||||
### kubectl dry-run
|
||||
```
|
||||
workflowtemplate.argoproj.io/claude-print-ci configured (dry run)
|
||||
```
|
||||
- **PASS** — Kubernetes accepted the manifest with no errors
|
||||
|
||||
## Summary
|
||||
The WorkflowTemplate YAML is syntactically valid and accepted by Kubernetes. No changes were required.
|
||||
|
|
@ -102,8 +102,7 @@ fn probe_mock_claude_pty(mock_path: &Path) -> Row {
|
|||
// even without a writer, allowing mock_claude's O_WRONLY open to succeed.
|
||||
let fifo_cstr =
|
||||
CString::new(fifo_path.to_string_lossy().as_bytes()).expect("fifo path is valid CStr");
|
||||
let fifo_rfd =
|
||||
unsafe { libc::open(fifo_cstr.as_ptr(), libc::O_RDONLY | libc::O_NONBLOCK) };
|
||||
let fifo_rfd = unsafe { libc::open(fifo_cstr.as_ptr(), libc::O_RDONLY | libc::O_NONBLOCK) };
|
||||
if fifo_rfd < 0 {
|
||||
let _ = std::fs::remove_file(&fifo_path);
|
||||
return Row {
|
||||
|
|
@ -216,8 +215,9 @@ pub fn run() -> i32 {
|
|||
let name_w = 20usize;
|
||||
let res_w = 6usize;
|
||||
println!(
|
||||
"{:<name_w$} {:<res_w$} {}",
|
||||
"CHECK", "RESULT", "DETAIL",
|
||||
"{:<name_w$} {:<res_w$} DETAIL",
|
||||
"CHECK",
|
||||
"RESULT",
|
||||
name_w = name_w,
|
||||
res_w = res_w
|
||||
);
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ pub type Result<T> = std::result::Result<T, Error>;
|
|||
/// User-facing error type with exit code and JSON subtype mapping.
|
||||
#[derive(Debug)]
|
||||
pub enum ClaudePrintError {
|
||||
Setup(String), // exit 2
|
||||
Timeout, // exit 124
|
||||
Interrupted, // exit 130
|
||||
Setup(String), // exit 2
|
||||
Timeout, // exit 124
|
||||
Interrupted, // exit 130
|
||||
AssistantError(String), // exit 1
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -74,13 +74,8 @@ impl EventLoop {
|
|||
pfd.revents = 0;
|
||||
}
|
||||
|
||||
let ret = unsafe {
|
||||
libc::poll(
|
||||
self.fds.as_mut_ptr(),
|
||||
self.fds.len() as libc::nfds_t,
|
||||
-1,
|
||||
)
|
||||
};
|
||||
let ret =
|
||||
unsafe { libc::poll(self.fds.as_mut_ptr(), self.fds.len() as libc::nfds_t, -1) };
|
||||
|
||||
if ret < 0 {
|
||||
let errno = nix::errno::Errno::last();
|
||||
|
|
@ -96,9 +91,7 @@ impl EventLoop {
|
|||
}
|
||||
|
||||
// Stop FIFO readable (only after add_fifo_fd).
|
||||
if self.fds.len() > FIFO_IDX
|
||||
&& self.fds[FIFO_IDX].revents & libc::POLLIN != 0
|
||||
{
|
||||
if self.fds.len() > FIFO_IDX && self.fds[FIFO_IDX].revents & libc::POLLIN != 0 {
|
||||
let mut payload: Vec<u8> = Vec::new();
|
||||
loop {
|
||||
let n = unsafe {
|
||||
|
|
|
|||
|
|
@ -50,10 +50,7 @@ impl HookInstaller {
|
|||
|
||||
fn write_hook_sh(hook_path: &Path, fifo_path: &Path) -> Result<()> {
|
||||
let fifo_str = fifo_path.to_string_lossy();
|
||||
let content = format!(
|
||||
"#!/bin/sh\ncat > '{}' 2>/dev/null || true\n",
|
||||
fifo_str
|
||||
);
|
||||
let content = format!("#!/bin/sh\ncat > '{}' 2>/dev/null || true\n", fifo_str);
|
||||
std::fs::write(hook_path, &content)
|
||||
.map_err(|e| Error::Internal(anyhow::anyhow!("failed to write hook.sh: {e}")))?;
|
||||
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ use claude_print::cli::{version_string, Cli};
|
|||
use std::process;
|
||||
|
||||
fn resolve_claude_version(binary: Option<&std::path::Path>) -> Option<String> {
|
||||
let binary = binary.map(|p| p.to_path_buf()).unwrap_or_else(|| {
|
||||
std::path::PathBuf::from("claude")
|
||||
});
|
||||
let binary = binary
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("claude"));
|
||||
|
||||
let output = std::process::Command::new(&binary)
|
||||
.arg("--version")
|
||||
|
|
|
|||
|
|
@ -36,9 +36,8 @@ pub fn parse_stop_payload(bytes: &[u8]) -> Result<StopPayload> {
|
|||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
return serde_json::from_str(line).map_err(|e| {
|
||||
Error::Internal(anyhow::anyhow!("stop payload JSON parse failed: {e}"))
|
||||
});
|
||||
return serde_json::from_str(line)
|
||||
.map_err(|e| Error::Internal(anyhow::anyhow!("stop payload JSON parse failed: {e}")));
|
||||
}
|
||||
Ok(StopPayload::default())
|
||||
}
|
||||
|
|
@ -52,13 +51,11 @@ pub fn resolve_stop_info(payload: StopPayload) -> StopInfo {
|
|||
.filter(|s| !s.is_empty())
|
||||
.map(PathBuf::from);
|
||||
|
||||
let transcript_path = explicit_path.or_else(|| {
|
||||
match (&payload.session_id, &payload.cwd) {
|
||||
(Some(sid), Some(cwd)) if !sid.is_empty() && !cwd.is_empty() => {
|
||||
Some(derive_transcript_path(sid, cwd))
|
||||
}
|
||||
_ => None,
|
||||
let transcript_path = explicit_path.or_else(|| match (&payload.session_id, &payload.cwd) {
|
||||
(Some(sid), Some(cwd)) if !sid.is_empty() && !cwd.is_empty() => {
|
||||
Some(derive_transcript_path(sid, cwd))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
|
||||
StopInfo {
|
||||
|
|
@ -151,7 +148,10 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn cwd_to_slug_home_coding_myproject() {
|
||||
assert_eq!(cwd_to_slug("/home/coding/myproject"), "home-coding-myproject");
|
||||
assert_eq!(
|
||||
cwd_to_slug("/home/coding/myproject"),
|
||||
"home-coding-myproject"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -199,7 +199,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn parse_payload_unknown_fields_ignored() {
|
||||
let json = r#"{"hook_event_name":"Stop","session_id":"x","future_field":42,"nested":{"a":1}}"#;
|
||||
let json =
|
||||
r#"{"hook_event_name":"Stop","session_id":"x","future_field":42,"nested":{"a":1}}"#;
|
||||
let p = parse_stop_payload(json.as_bytes()).unwrap();
|
||||
assert_eq!(p.session_id.as_deref(), Some("x"));
|
||||
}
|
||||
|
|
|
|||
13
src/pty.rs
13
src/pty.rs
|
|
@ -60,8 +60,8 @@ impl PtySpawner {
|
|||
|
||||
// SAFETY: fork is async-signal-safe; no threads exist at this point in
|
||||
// the single-threaded call path.
|
||||
let fork_result = unsafe { fork() }
|
||||
.map_err(|e| Error::Internal(anyhow::anyhow!("fork failed: {e}")))?;
|
||||
let fork_result =
|
||||
unsafe { fork() }.map_err(|e| Error::Internal(anyhow::anyhow!("fork failed: {e}")))?;
|
||||
|
||||
match fork_result {
|
||||
ForkResult::Parent { child } => {
|
||||
|
|
@ -221,9 +221,7 @@ impl PtySpawner {
|
|||
Ok(WaitStatus::Signaled(_, sig, _)) => return Ok(128 + sig as i32),
|
||||
Ok(_) => continue,
|
||||
Err(nix::errno::Errno::EINTR) => continue,
|
||||
Err(e) => {
|
||||
return Err(Error::Internal(anyhow::anyhow!("waitpid failed: {e}")))
|
||||
}
|
||||
Err(e) => return Err(Error::Internal(anyhow::anyhow!("waitpid failed: {e}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -257,9 +255,8 @@ mod tests {
|
|||
let mut buf = [0u8; 256];
|
||||
|
||||
loop {
|
||||
let n = unsafe {
|
||||
libc::read(master_fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len())
|
||||
};
|
||||
let n =
|
||||
unsafe { libc::read(master_fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
|
||||
if n <= 0 {
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,14 @@ use std::time::{Duration, Instant};
|
|||
use tempfile::NamedTempFile;
|
||||
|
||||
// Trust dialog keyword set — 2+ on a single line → send CR.
|
||||
const TRUST_KEYWORDS: &[&str] = &["trust", "Allow", "continue", "folder", "permission", "proceed"];
|
||||
const TRUST_KEYWORDS: &[&str] = &[
|
||||
"trust",
|
||||
"Allow",
|
||||
"continue",
|
||||
"folder",
|
||||
"permission",
|
||||
"proceed",
|
||||
];
|
||||
const KEYWORD_THRESHOLD: usize = 2;
|
||||
|
||||
const IDLE_THRESHOLD_BYTES: usize = 200;
|
||||
|
|
@ -101,10 +108,7 @@ impl StartupSeq {
|
|||
/// false positives on common words like "allow" (lowercase).
|
||||
pub fn scan_line(line: &[u8]) -> bool {
|
||||
let text = String::from_utf8_lossy(line);
|
||||
let count = TRUST_KEYWORDS
|
||||
.iter()
|
||||
.filter(|&&k| text.contains(k))
|
||||
.count();
|
||||
let count = TRUST_KEYWORDS.iter().filter(|&&k| text.contains(k)).count();
|
||||
count >= KEYWORD_THRESHOLD
|
||||
}
|
||||
|
||||
|
|
@ -381,8 +385,14 @@ mod tests {
|
|||
let action = seq.poll_timers();
|
||||
match action {
|
||||
StartupAction::Write(payload) => {
|
||||
assert!(payload.starts_with(b"\x1b[200~"), "bracketed-paste open missing");
|
||||
assert!(payload.ends_with(b"\x1b[201~\r"), "bracketed-paste close+CR missing");
|
||||
assert!(
|
||||
payload.starts_with(b"\x1b[200~"),
|
||||
"bracketed-paste open missing"
|
||||
);
|
||||
assert!(
|
||||
payload.ends_with(b"\x1b[201~\r"),
|
||||
"bracketed-paste close+CR missing"
|
||||
);
|
||||
assert!(
|
||||
payload.windows(11).any(|w| w == b"hello world"),
|
||||
"prompt text not in payload"
|
||||
|
|
@ -420,8 +430,14 @@ mod tests {
|
|||
// Force into TrustDismissed so we can call make_prompt_payload.
|
||||
seq.phase = StartupPhase::TrustDismissed;
|
||||
let payload = seq.make_prompt_payload();
|
||||
assert!(payload.starts_with(b"\x1b[200~"), "missing bracketed-paste open");
|
||||
assert!(payload.ends_with(b"\x1b[201~\r"), "missing bracketed-paste close + CR");
|
||||
assert!(
|
||||
payload.starts_with(b"\x1b[200~"),
|
||||
"missing bracketed-paste open"
|
||||
);
|
||||
assert!(
|
||||
payload.ends_with(b"\x1b[201~\r"),
|
||||
"missing bracketed-paste close + CR"
|
||||
);
|
||||
assert!(
|
||||
payload.windows(12).any(|w| w == b"What is 2+2?"),
|
||||
"prompt text not present in payload"
|
||||
|
|
@ -438,8 +454,14 @@ mod tests {
|
|||
seq.phase = StartupPhase::TrustDismissed;
|
||||
let payload = seq.make_prompt_payload();
|
||||
// Inline: open marker directly followed by prompt content.
|
||||
assert!(payload.starts_with(b"\x1b[200~"), "must start with bracketed-paste open");
|
||||
assert_eq!(payload[6], b'Z', "prompt byte must follow open marker immediately");
|
||||
assert!(
|
||||
payload.starts_with(b"\x1b[200~"),
|
||||
"must start with bracketed-paste open"
|
||||
);
|
||||
assert_eq!(
|
||||
payload[6], b'Z',
|
||||
"prompt byte must follow open marker immediately"
|
||||
);
|
||||
// Must not contain shell substitution syntax.
|
||||
assert!(
|
||||
!payload.windows(4).any(|w| w == b"$(< "),
|
||||
|
|
@ -487,7 +509,8 @@ mod tests {
|
|||
let path_bytes = &after_prefix[..close_paren];
|
||||
let path_str = std::str::from_utf8(path_bytes).expect("path is valid UTF-8");
|
||||
|
||||
let file_content = std::fs::read(path_str).expect("temp file must exist while seq is alive");
|
||||
let file_content =
|
||||
std::fs::read(path_str).expect("temp file must exist while seq is alive");
|
||||
assert_eq!(
|
||||
file_content, large_prompt,
|
||||
"temp file must contain the full prompt"
|
||||
|
|
|
|||
|
|
@ -49,9 +49,15 @@ impl AggregatedUsage {
|
|||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "kebab-case")]
|
||||
pub enum ContentBlock {
|
||||
Text { text: String },
|
||||
ToolUse { name: String },
|
||||
Thinking { thinking: String },
|
||||
Text {
|
||||
text: String,
|
||||
},
|
||||
ToolUse {
|
||||
name: String,
|
||||
},
|
||||
Thinking {
|
||||
thinking: String,
|
||||
},
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
|
@ -74,8 +80,12 @@ pub struct ResultEvent {
|
|||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "kebab-case")]
|
||||
pub enum Event {
|
||||
Assistant { message: AssistantMessage },
|
||||
User { message: serde_json::Value },
|
||||
Assistant {
|
||||
message: AssistantMessage,
|
||||
},
|
||||
User {
|
||||
message: serde_json::Value,
|
||||
},
|
||||
Result(ResultEvent),
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ fn main() {
|
|||
let mock_trust_dialog = env_flag("MOCK_TRUST_DIALOG");
|
||||
let mock_trust_wording = std::env::var("MOCK_TRUST_WORDING").unwrap_or_default();
|
||||
let mock_unknown_probe = env_flag("MOCK_UNKNOWN_PROBE");
|
||||
let mock_response = std::env::var("MOCK_RESPONSE")
|
||||
.unwrap_or_else(|_| "Hello from mock_claude".to_string());
|
||||
let mock_response =
|
||||
std::env::var("MOCK_RESPONSE").unwrap_or_else(|_| "Hello from mock_claude".to_string());
|
||||
let omit_transcript_path = env_flag("MOCK_OMIT_TRANSCRIPT_PATH");
|
||||
let omit_last_message = env_flag("MOCK_OMIT_LAST_MESSAGE");
|
||||
|
||||
|
|
|
|||
43
tests/cli.rs
43
tests/cli.rs
|
|
@ -1,9 +1,9 @@
|
|||
use clap::Parser;
|
||||
/// CLI tests (Phase 10).
|
||||
///
|
||||
/// Verifies that the CLI struct parses correctly and that the `version_string`
|
||||
/// helper produces the expected output.
|
||||
use claude_print::cli::{version_string, Cli, OutputFormat};
|
||||
use clap::Parser;
|
||||
|
||||
// ── OutputFormat Display ──────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -57,7 +57,11 @@ fn version_string_format_is_parseable() {
|
|||
// Must match: "claude-print X.Y.Z (wrapping claude A.B.C)"
|
||||
assert!(s.contains('('), "must have opening paren");
|
||||
assert!(s.contains(')'), "must have closing paren");
|
||||
let inside: &str = s.split('(').nth(1).and_then(|s| s.split(')').next()).unwrap_or("");
|
||||
let inside: &str = s
|
||||
.split('(')
|
||||
.nth(1)
|
||||
.and_then(|s| s.split(')').next())
|
||||
.unwrap_or("");
|
||||
assert!(
|
||||
inside.starts_with("wrapping claude"),
|
||||
"content in parens must start with 'wrapping claude'; got: {inside:?}"
|
||||
|
|
@ -75,7 +79,10 @@ fn cli_positional_prompt_parsed() {
|
|||
#[test]
|
||||
fn cli_no_prompt_gives_none() {
|
||||
let cli = Cli::try_parse_from(["claude-print"]).unwrap();
|
||||
assert!(cli.prompt.is_none(), "no positional arg should give None prompt");
|
||||
assert!(
|
||||
cli.prompt.is_none(),
|
||||
"no positional arg should give None prompt"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -86,34 +93,41 @@ fn cli_output_format_default_is_text() {
|
|||
|
||||
#[test]
|
||||
fn cli_output_format_json() {
|
||||
let cli =
|
||||
Cli::try_parse_from(["claude-print", "--output-format", "json"]).unwrap();
|
||||
let cli = Cli::try_parse_from(["claude-print", "--output-format", "json"]).unwrap();
|
||||
assert!(matches!(cli.output_format, OutputFormat::Json));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_output_format_stream_json() {
|
||||
let cli =
|
||||
Cli::try_parse_from(["claude-print", "--output-format", "stream-json"]).unwrap();
|
||||
let cli = Cli::try_parse_from(["claude-print", "--output-format", "stream-json"]).unwrap();
|
||||
assert!(matches!(cli.output_format, OutputFormat::StreamJson));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_output_format_invalid_returns_error() {
|
||||
let result = Cli::try_parse_from(["claude-print", "--output-format", "xml"]);
|
||||
assert!(result.is_err(), "invalid output format must produce a parse error");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"invalid output format must produce a parse error"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_no_inherit_hooks_flag() {
|
||||
let cli = Cli::try_parse_from(["claude-print", "--no-inherit-hooks"]).unwrap();
|
||||
assert!(cli.no_inherit_hooks, "--no-inherit-hooks must set no_inherit_hooks=true");
|
||||
assert!(
|
||||
cli.no_inherit_hooks,
|
||||
"--no-inherit-hooks must set no_inherit_hooks=true"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_no_inherit_hooks_defaults_false() {
|
||||
let cli = Cli::try_parse_from(["claude-print"]).unwrap();
|
||||
assert!(!cli.no_inherit_hooks, "no_inherit_hooks must default to false");
|
||||
assert!(
|
||||
!cli.no_inherit_hooks,
|
||||
"no_inherit_hooks must default to false"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -124,8 +138,7 @@ fn cli_verbose_flag() {
|
|||
|
||||
#[test]
|
||||
fn cli_dangerously_skip_permissions_flag() {
|
||||
let cli =
|
||||
Cli::try_parse_from(["claude-print", "--dangerously-skip-permissions"]).unwrap();
|
||||
let cli = Cli::try_parse_from(["claude-print", "--dangerously-skip-permissions"]).unwrap();
|
||||
assert!(cli.dangerously_skip_permissions);
|
||||
}
|
||||
|
||||
|
|
@ -149,8 +162,7 @@ fn cli_timeout_default_is_3600() {
|
|||
|
||||
#[test]
|
||||
fn cli_input_file_flag() {
|
||||
let cli =
|
||||
Cli::try_parse_from(["claude-print", "--input-file", "/tmp/prompt.txt"]).unwrap();
|
||||
let cli = Cli::try_parse_from(["claude-print", "--input-file", "/tmp/prompt.txt"]).unwrap();
|
||||
assert_eq!(
|
||||
cli.input_file.as_deref(),
|
||||
Some(std::path::Path::new("/tmp/prompt.txt"))
|
||||
|
|
@ -159,8 +171,7 @@ fn cli_input_file_flag() {
|
|||
|
||||
#[test]
|
||||
fn cli_claude_binary_flag() {
|
||||
let cli =
|
||||
Cli::try_parse_from(["claude-print", "--claude-binary", "/usr/bin/claude"]).unwrap();
|
||||
let cli = Cli::try_parse_from(["claude-print", "--claude-binary", "/usr/bin/claude"]).unwrap();
|
||||
assert_eq!(
|
||||
cli.claude_binary.as_deref(),
|
||||
Some(std::path::Path::new("/usr/bin/claude"))
|
||||
|
|
|
|||
|
|
@ -103,8 +103,14 @@ fn test_json_usage_fields_are_integers() {
|
|||
let output = buf.lock().unwrap().clone();
|
||||
let v: serde_json::Value = serde_json::from_slice(&output).unwrap();
|
||||
let usage = &v["usage"];
|
||||
assert!(usage["input_tokens"].is_u64(), "input_tokens must be integer");
|
||||
assert!(usage["output_tokens"].is_u64(), "output_tokens must be integer");
|
||||
assert!(
|
||||
usage["input_tokens"].is_u64(),
|
||||
"input_tokens must be integer"
|
||||
);
|
||||
assert!(
|
||||
usage["output_tokens"].is_u64(),
|
||||
"output_tokens must be integer"
|
||||
);
|
||||
assert!(usage["cache_creation_input_tokens"].is_u64());
|
||||
assert!(usage["cache_read_input_tokens"].is_u64());
|
||||
}
|
||||
|
|
@ -116,7 +122,15 @@ fn test_error_result_is_error_true_and_subtype() {
|
|||
let err = ClaudePrintError::Timeout;
|
||||
let (out_buf, mut stdout) = capture();
|
||||
let (_, mut stderr) = capture();
|
||||
emit_error(&mut stdout, &mut stderr, &err, &OutputFormat::Json, "1.0", false).unwrap();
|
||||
emit_error(
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
&err,
|
||||
&OutputFormat::Json,
|
||||
"1.0",
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let output = out_buf.lock().unwrap().clone();
|
||||
let v: serde_json::Value = serde_json::from_slice(&output).unwrap();
|
||||
assert_eq!(v["is_error"], true);
|
||||
|
|
@ -128,15 +142,24 @@ fn test_error_exit_code_nonzero() {
|
|||
assert_ne!(ClaudePrintError::Setup("x".to_string()).exit_code(), 0);
|
||||
assert_ne!(ClaudePrintError::Timeout.exit_code(), 0);
|
||||
assert_ne!(ClaudePrintError::Interrupted.exit_code(), 0);
|
||||
assert_ne!(ClaudePrintError::AssistantError("x".to_string()).exit_code(), 0);
|
||||
assert_ne!(
|
||||
ClaudePrintError::AssistantError("x".to_string()).exit_code(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_subtypes() {
|
||||
assert_eq!(ClaudePrintError::Setup("x".to_string()).subtype(), "internal_error");
|
||||
assert_eq!(
|
||||
ClaudePrintError::Setup("x".to_string()).subtype(),
|
||||
"internal_error"
|
||||
);
|
||||
assert_eq!(ClaudePrintError::Timeout.subtype(), "timeout");
|
||||
assert_eq!(ClaudePrintError::Interrupted.subtype(), "interrupted");
|
||||
assert_eq!(ClaudePrintError::AssistantError("x".to_string()).subtype(), "assistant_error");
|
||||
assert_eq!(
|
||||
ClaudePrintError::AssistantError("x".to_string()).subtype(),
|
||||
"assistant_error"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -144,7 +167,10 @@ fn test_error_exit_codes() {
|
|||
assert_eq!(ClaudePrintError::Setup("x".to_string()).exit_code(), 2);
|
||||
assert_eq!(ClaudePrintError::Timeout.exit_code(), 124);
|
||||
assert_eq!(ClaudePrintError::Interrupted.exit_code(), 130);
|
||||
assert_eq!(ClaudePrintError::AssistantError("x".to_string()).exit_code(), 1);
|
||||
assert_eq!(
|
||||
ClaudePrintError::AssistantError("x".to_string()).exit_code(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -152,9 +178,23 @@ fn test_text_error_goes_to_stderr_not_stdout() {
|
|||
let err = ClaudePrintError::Setup("missing binary".to_string());
|
||||
let (out_buf, mut stdout) = capture();
|
||||
let (err_buf, mut stderr) = capture();
|
||||
emit_error(&mut stdout, &mut stderr, &err, &OutputFormat::Text, "1.0", false).unwrap();
|
||||
assert!(out_buf.lock().unwrap().is_empty(), "text error must not write to stdout");
|
||||
assert!(!err_buf.lock().unwrap().is_empty(), "text error must write to stderr");
|
||||
emit_error(
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
&err,
|
||||
&OutputFormat::Text,
|
||||
"1.0",
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
out_buf.lock().unwrap().is_empty(),
|
||||
"text error must not write to stdout"
|
||||
);
|
||||
assert!(
|
||||
!err_buf.lock().unwrap().is_empty(),
|
||||
"text error must write to stderr"
|
||||
);
|
||||
}
|
||||
|
||||
// ── zero token counts ─────────────────────────────────────────────────────────
|
||||
|
|
@ -212,8 +252,8 @@ fn test_stream_json_each_line_parses_as_json() {
|
|||
|
||||
assert_eq!(output_lines.len(), lines.len(), "should forward all lines");
|
||||
for line in &output_lines {
|
||||
let _: serde_json::Value = serde_json::from_str(line)
|
||||
.unwrap_or_else(|_| panic!("line is not valid JSON: {line}"));
|
||||
let _: serde_json::Value =
|
||||
serde_json::from_str(line).unwrap_or_else(|_| panic!("line is not valid JSON: {line}"));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,10 @@ fn settings_json_double_nested_hooks_structure() {
|
|||
assert!(outer.is_object(), "first Stop entry must be an object");
|
||||
let inner = &outer["hooks"];
|
||||
assert!(inner.is_array(), "hooks inside Stop entry must be an array");
|
||||
assert!(!inner.as_array().unwrap().is_empty(), "inner hooks must be non-empty");
|
||||
assert!(
|
||||
!inner.as_array().unwrap().is_empty(),
|
||||
"inner hooks must be non-empty"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
6
tests/integration.rs
Normal file
6
tests/integration.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/// Integration test suite for claude-print (Phase 10).
|
||||
///
|
||||
/// These tests compose multiple library modules to verify end-to-end behaviors
|
||||
/// at the library level — without invoking the compiled binary directly.
|
||||
#[path = "integration/scenarios.rs"]
|
||||
mod scenarios;
|
||||
981
tests/integration/scenarios.rs
Normal file
981
tests/integration/scenarios.rs
Normal file
|
|
@ -0,0 +1,981 @@
|
|||
/// Cross-module integration scenarios for Phase 10.
|
||||
///
|
||||
/// Tests here combine transcript parsing, emitter, stop-payload resolution,
|
||||
/// and hook installer modules to verify the multi-phase pipeline end-to-end.
|
||||
/// They also act as the "conformance harness" required by the plan: verifying
|
||||
/// that the JSON output produced by the emitter matches the `claude -p` wire
|
||||
/// format, and that the extra `claude_version` field does not break lenient
|
||||
/// callers.
|
||||
use claude_print::cli::OutputFormat;
|
||||
use claude_print::emitter::{emit_error, emit_success, spawn_stream_json_reader_to};
|
||||
use claude_print::error::ClaudePrintError;
|
||||
use claude_print::hook::HookInstaller;
|
||||
use claude_print::poller::{cwd_to_slug, parse_stop_payload, resolve_stop_info};
|
||||
use claude_print::transcript::{
|
||||
parse_transcript, read_transcript, AggregatedUsage, TranscriptResult,
|
||||
};
|
||||
use std::io::Write as IoWrite;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tempfile::TempDir;
|
||||
|
||||
// ── Shared helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
struct CaptureWriter(Arc<Mutex<Vec<u8>>>);
|
||||
|
||||
impl std::io::Write for CaptureWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
self.0.lock().unwrap().extend_from_slice(buf);
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn capture() -> (Arc<Mutex<Vec<u8>>>, CaptureWriter) {
|
||||
let buf = Arc::new(Mutex::new(Vec::new()));
|
||||
let writer = CaptureWriter(Arc::clone(&buf));
|
||||
(buf, writer)
|
||||
}
|
||||
|
||||
fn read_json(buf: &Arc<Mutex<Vec<u8>>>) -> serde_json::Value {
|
||||
let bytes = buf.lock().unwrap().clone();
|
||||
serde_json::from_slice(&bytes).unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"output was not valid JSON: {e}\n raw: {:?}",
|
||||
String::from_utf8_lossy(&bytes)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn assistant_event(id: &str, text: &str, in_tok: u64, out_tok: u64, cc: u64, cr: u64) -> String {
|
||||
serde_json::json!({
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"id": id,
|
||||
"content": [{"type": "text", "text": text}],
|
||||
"usage": {
|
||||
"input_tokens": in_tok,
|
||||
"output_tokens": out_tok,
|
||||
"cache_creation_input_tokens": cc,
|
||||
"cache_read_input_tokens": cr,
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn result_event(session_id: &str, is_error: bool) -> String {
|
||||
serde_json::json!({
|
||||
"type": "result",
|
||||
"session_id": session_id,
|
||||
"is_error": is_error,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn write_jsonl(path: &std::path::Path, lines: &[String]) {
|
||||
let mut f = std::fs::File::create(path).unwrap();
|
||||
for line in lines {
|
||||
writeln!(f, "{}", line).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pipeline: transcript → emitter (JSON format) ──────────────────────────────
|
||||
|
||||
/// Full pipeline: parse JSONL, emit as JSON, verify all required fields present.
|
||||
#[test]
|
||||
fn transcript_to_json_pipeline_all_fields_present() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("t.jsonl");
|
||||
write_jsonl(
|
||||
&path,
|
||||
&[
|
||||
assistant_event("msg-1", "hello world", 100, 50, 10, 20),
|
||||
result_event("session-abc", false),
|
||||
],
|
||||
);
|
||||
|
||||
let result = parse_transcript(&path).unwrap();
|
||||
let (buf, mut writer) = capture();
|
||||
emit_success(&mut writer, &result, &OutputFormat::Json, "2.1.168", 1234).unwrap();
|
||||
|
||||
let v = read_json(&buf);
|
||||
assert_eq!(v["type"], "result", "type field mismatch");
|
||||
assert_eq!(
|
||||
v["subtype"], "success",
|
||||
"subtype must be 'success' on success"
|
||||
);
|
||||
assert_eq!(v["is_error"], false, "is_error must be false on success");
|
||||
assert_eq!(
|
||||
v["result"], "hello world",
|
||||
"result field must contain response text"
|
||||
);
|
||||
assert!(v.get("session_id").is_some(), "session_id must be present");
|
||||
assert!(v.get("num_turns").is_some(), "num_turns must be present");
|
||||
assert!(
|
||||
v.get("duration_ms").is_some(),
|
||||
"duration_ms must be present"
|
||||
);
|
||||
assert_eq!(v["cost_usd"], 0, "cost_usd must be 0 (wire-compat)");
|
||||
assert_eq!(
|
||||
v["claude_version"], "2.1.168",
|
||||
"claude_version must be included"
|
||||
);
|
||||
assert!(v.get("usage").is_some(), "usage object must be present");
|
||||
}
|
||||
|
||||
/// Pipeline: parse JSONL → emit as text → verify trailing newline, no JSON.
|
||||
#[test]
|
||||
fn transcript_to_text_pipeline_correct_output() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("t.jsonl");
|
||||
write_jsonl(
|
||||
&path,
|
||||
&[
|
||||
assistant_event("msg-t", "text response", 10, 5, 0, 0),
|
||||
result_event("s1", false),
|
||||
],
|
||||
);
|
||||
|
||||
let result = parse_transcript(&path).unwrap();
|
||||
let (buf, mut writer) = capture();
|
||||
emit_success(&mut writer, &result, &OutputFormat::Text, "2.1.168", 0).unwrap();
|
||||
|
||||
let output = buf.lock().unwrap().clone();
|
||||
let s = std::str::from_utf8(&output).unwrap();
|
||||
assert_eq!(
|
||||
s, "text response\n",
|
||||
"text output must be exactly response + newline"
|
||||
);
|
||||
// Must not be JSON
|
||||
assert!(
|
||||
serde_json::from_str::<serde_json::Value>(s.trim()).is_err(),
|
||||
"text output must not be JSON"
|
||||
);
|
||||
}
|
||||
|
||||
/// Multi-turn pipeline: 3 distinct turns → num_turns=3 and token sum correct in JSON.
|
||||
#[test]
|
||||
fn multi_turn_pipeline_num_turns_and_usage_correct() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("t.jsonl");
|
||||
write_jsonl(
|
||||
&path,
|
||||
&[
|
||||
assistant_event("m1", "turn 1", 100, 10, 0, 0),
|
||||
assistant_event("m2", "turn 2", 200, 20, 5, 100),
|
||||
assistant_event("m3", "turn 3 final", 300, 30, 10, 200),
|
||||
result_event("sess-multi", false),
|
||||
],
|
||||
);
|
||||
|
||||
let result = parse_transcript(&path).unwrap();
|
||||
assert_eq!(result.num_turns, 3, "must detect 3 unique turns");
|
||||
|
||||
let (buf, mut writer) = capture();
|
||||
emit_success(&mut writer, &result, &OutputFormat::Json, "2.0.0", 5000).unwrap();
|
||||
|
||||
let v = read_json(&buf);
|
||||
assert_eq!(v["num_turns"], 3, "num_turns must be 3 in JSON output");
|
||||
let usage = &v["usage"];
|
||||
assert_eq!(usage["input_tokens"], 600u64, "input_tokens: 100+200+300");
|
||||
assert_eq!(usage["output_tokens"], 60u64, "output_tokens: 10+20+30");
|
||||
assert_eq!(
|
||||
usage["cache_creation_input_tokens"], 15u64,
|
||||
"cache_creation: 0+5+10"
|
||||
);
|
||||
assert_eq!(
|
||||
usage["cache_read_input_tokens"], 300u64,
|
||||
"cache_read: 0+100+200"
|
||||
);
|
||||
assert_eq!(
|
||||
v["result"], "turn 3 final",
|
||||
"result must be the last turn's text"
|
||||
);
|
||||
}
|
||||
|
||||
/// Session ID flows from transcript result event through to JSON output.
|
||||
#[test]
|
||||
fn session_id_flows_from_transcript_to_json_output() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("t.jsonl");
|
||||
write_jsonl(
|
||||
&path,
|
||||
&[
|
||||
assistant_event("m1", "text", 5, 3, 0, 0),
|
||||
result_event("my-session-xyz", false),
|
||||
],
|
||||
);
|
||||
|
||||
let result = parse_transcript(&path).unwrap();
|
||||
assert_eq!(result.session_id.as_deref(), Some("my-session-xyz"));
|
||||
|
||||
let (buf, mut writer) = capture();
|
||||
emit_success(&mut writer, &result, &OutputFormat::Json, "1.0", 0).unwrap();
|
||||
let v = read_json(&buf);
|
||||
assert_eq!(
|
||||
v["session_id"], "my-session-xyz",
|
||||
"session_id must flow through to JSON"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Pipeline: error transcript → emitter ─────────────────────────────────────
|
||||
|
||||
/// is_error: true in transcript result event → AssistantError → error JSON.
|
||||
#[test]
|
||||
fn is_error_transcript_maps_to_assistant_error_json() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("t.jsonl");
|
||||
// Claude returned an error response
|
||||
write_jsonl(
|
||||
&path,
|
||||
&[
|
||||
assistant_event("m1", "Rate limit exceeded", 0, 0, 0, 0),
|
||||
serde_json::json!({
|
||||
"type": "result",
|
||||
"session_id": "err-session",
|
||||
"is_error": true,
|
||||
})
|
||||
.to_string(),
|
||||
],
|
||||
);
|
||||
|
||||
let result = parse_transcript(&path).unwrap();
|
||||
assert!(result.is_error, "transcript must reflect is_error=true");
|
||||
|
||||
let err = ClaudePrintError::AssistantError(result.text.clone());
|
||||
let (out_buf, mut stdout) = capture();
|
||||
let (_, mut stderr) = capture();
|
||||
emit_error(
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
&err,
|
||||
&OutputFormat::Json,
|
||||
"2.0",
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let v = read_json(&out_buf);
|
||||
assert_eq!(v["is_error"], true);
|
||||
assert_eq!(v["subtype"], "assistant_error");
|
||||
assert_eq!(v["type"], "result");
|
||||
}
|
||||
|
||||
/// is_error in text mode → stderr only, stdout empty.
|
||||
#[test]
|
||||
fn is_error_transcript_text_mode_stderr_only() {
|
||||
let err = ClaudePrintError::AssistantError("some error".to_string());
|
||||
let (out_buf, mut stdout) = capture();
|
||||
let (err_buf, mut stderr) = capture();
|
||||
emit_error(
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
&err,
|
||||
&OutputFormat::Text,
|
||||
"1.0",
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
out_buf.lock().unwrap().is_empty(),
|
||||
"text error must not write to stdout"
|
||||
);
|
||||
assert!(
|
||||
!err_buf.lock().unwrap().is_empty(),
|
||||
"text error must write to stderr"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Fallback path pipeline ────────────────────────────────────────────────────
|
||||
|
||||
/// Empty transcript + last_assistant_message → fallback text used in JSON output.
|
||||
#[test]
|
||||
fn fallback_to_last_message_used_when_transcript_empty() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("t.jsonl");
|
||||
// File exists but has no assistant events
|
||||
write_jsonl(&path, &[result_event("fb-session", false)]);
|
||||
|
||||
let result = read_transcript(&path, Some("fallback response text")).unwrap();
|
||||
assert!(
|
||||
result.used_fallback,
|
||||
"must use fallback when transcript has no text"
|
||||
);
|
||||
assert_eq!(result.text, "fallback response text");
|
||||
|
||||
let (buf, mut writer) = capture();
|
||||
emit_success(&mut writer, &result, &OutputFormat::Json, "1.0", 0).unwrap();
|
||||
let v = read_json(&buf);
|
||||
assert_eq!(v["result"], "fallback response text");
|
||||
assert_eq!(v["num_turns"], 0u64, "num_turns must be 0 on fallback path");
|
||||
}
|
||||
|
||||
/// Both transcript text empty AND fallback empty → returns error.
|
||||
#[test]
|
||||
fn both_empty_returns_setup_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("t.jsonl");
|
||||
write_jsonl(&path, &[result_event("no-text", false)]);
|
||||
let result = read_transcript(&path, None);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"must return Err when both transcript and fallback are empty"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stream-JSON pipeline ──────────────────────────────────────────────────────
|
||||
|
||||
/// Stream-JSON reader forwards JSONL lines; every line is valid JSON.
|
||||
#[test]
|
||||
fn stream_json_pipeline_all_lines_valid_json() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("t.jsonl");
|
||||
let lines = vec![
|
||||
assistant_event("m1", "part 1", 10, 5, 0, 0),
|
||||
assistant_event("m1", "part 2", 10, 5, 0, 0), // duplicate — forwarded as-is
|
||||
result_event("sj-session", false),
|
||||
];
|
||||
write_jsonl(&path, &lines);
|
||||
|
||||
let out_buf = Arc::new(Mutex::new(Vec::new()));
|
||||
let writer = Box::new(CaptureWriter(Arc::clone(&out_buf)));
|
||||
let handle = spawn_stream_json_reader_to(path, 0, writer);
|
||||
handle.drain_tx.send(()).unwrap();
|
||||
handle.join_handle.join().unwrap();
|
||||
|
||||
let output = out_buf.lock().unwrap().clone();
|
||||
let text = std::str::from_utf8(&output).unwrap();
|
||||
let output_lines: Vec<&str> = text.lines().filter(|l| !l.is_empty()).collect();
|
||||
|
||||
assert_eq!(
|
||||
output_lines.len(),
|
||||
lines.len(),
|
||||
"stream-json must forward all lines"
|
||||
);
|
||||
for line in &output_lines {
|
||||
let _: serde_json::Value = serde_json::from_str(line)
|
||||
.unwrap_or_else(|e| panic!("stream-json line not valid JSON: {e}\n line: {line}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream-JSON forwards from a byte offset; lines before offset are skipped.
|
||||
#[test]
|
||||
fn stream_json_start_offset_skips_pre_injection_lines() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("t.jsonl");
|
||||
let pre_line = r#"{"type":"system","content":"pre-injection"}"#;
|
||||
let post_line = assistant_event("m1", "post-injection", 5, 3, 0, 0);
|
||||
{
|
||||
let mut f = std::fs::File::create(&path).unwrap();
|
||||
writeln!(f, "{}", pre_line).unwrap();
|
||||
writeln!(f, "{}", post_line).unwrap();
|
||||
}
|
||||
|
||||
let pre_len = (pre_line.len() + 1) as u64; // +1 for newline
|
||||
|
||||
let out_buf = Arc::new(Mutex::new(Vec::new()));
|
||||
let writer = Box::new(CaptureWriter(Arc::clone(&out_buf)));
|
||||
let handle = spawn_stream_json_reader_to(path, pre_len, writer);
|
||||
handle.drain_tx.send(()).unwrap();
|
||||
handle.join_handle.join().unwrap();
|
||||
|
||||
let output = out_buf.lock().unwrap().clone();
|
||||
let text = std::str::from_utf8(&output).unwrap();
|
||||
let output_lines: Vec<&str> = text.lines().filter(|l| !l.is_empty()).collect();
|
||||
|
||||
assert_eq!(
|
||||
output_lines.len(),
|
||||
1,
|
||||
"only post-injection line should be forwarded"
|
||||
);
|
||||
assert!(
|
||||
output_lines[0].contains("post-injection"),
|
||||
"wrong line forwarded"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Conformance harness ───────────────────────────────────────────────────────
|
||||
|
||||
/// Wire-format conformance: JSON output has every field that `claude -p` emits.
|
||||
///
|
||||
/// The `claude -p` wire format fields: type, subtype, is_error, result, session_id,
|
||||
/// num_turns, duration_ms, cost_usd, usage (with 4 token sub-fields).
|
||||
/// claude-print adds `claude_version` (additive — must not break lenient callers).
|
||||
#[test]
|
||||
fn conformance_json_output_has_all_claude_minus_p_wire_fields() {
|
||||
let result = TranscriptResult {
|
||||
text: "the answer".to_string(),
|
||||
num_turns: 1,
|
||||
usage: AggregatedUsage {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
},
|
||||
session_id: Some("conf-session".to_string()),
|
||||
is_error: false,
|
||||
used_fallback: false,
|
||||
};
|
||||
|
||||
let (buf, mut writer) = capture();
|
||||
emit_success(&mut writer, &result, &OutputFormat::Json, "2.1.168", 999).unwrap();
|
||||
let v = read_json(&buf);
|
||||
|
||||
// Every field that `claude -p --output-format json` produces must be present.
|
||||
let required_fields = [
|
||||
"type",
|
||||
"subtype",
|
||||
"is_error",
|
||||
"result",
|
||||
"session_id",
|
||||
"num_turns",
|
||||
"duration_ms",
|
||||
"cost_usd",
|
||||
"usage",
|
||||
];
|
||||
for field in &required_fields {
|
||||
assert!(
|
||||
v.get(field).is_some(),
|
||||
"required wire field {field:?} is missing"
|
||||
);
|
||||
}
|
||||
|
||||
// The usage object must have all four token sub-fields.
|
||||
let usage = &v["usage"];
|
||||
let usage_fields = [
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"cache_creation_input_tokens",
|
||||
"cache_read_input_tokens",
|
||||
];
|
||||
for field in &usage_fields {
|
||||
assert!(usage.get(field).is_some(), "usage.{field} is missing");
|
||||
}
|
||||
|
||||
// claude_version is the only addition — must be present.
|
||||
assert!(
|
||||
v.get("claude_version").is_some(),
|
||||
"claude_version must be present"
|
||||
);
|
||||
}
|
||||
|
||||
/// Wire-format conformance: a strict `claude -p`-shaped parser (deny_unknown_fields)
|
||||
/// applied to the known fields must succeed even with `claude_version` present.
|
||||
/// Simulated by extracting only the known fields and verifying their types.
|
||||
#[test]
|
||||
fn conformance_claude_version_extra_field_doesnt_break_strict_parse() {
|
||||
let result = TranscriptResult {
|
||||
text: "answer".to_string(),
|
||||
num_turns: 2,
|
||||
usage: AggregatedUsage {
|
||||
input_tokens: 50,
|
||||
output_tokens: 25,
|
||||
cache_creation_input_tokens: 5,
|
||||
cache_read_input_tokens: 100,
|
||||
},
|
||||
session_id: Some("s1".to_string()),
|
||||
is_error: false,
|
||||
used_fallback: false,
|
||||
};
|
||||
|
||||
let (buf, mut writer) = capture();
|
||||
emit_success(&mut writer, &result, &OutputFormat::Json, "2.1.168", 0).unwrap();
|
||||
let v = read_json(&buf);
|
||||
|
||||
// A strict caller that parses only known claude -p fields must succeed.
|
||||
// We simulate this by deserializing the value into a struct with known fields.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ClaudeMinusPResult {
|
||||
#[serde(rename = "type")]
|
||||
result_type: String,
|
||||
subtype: String,
|
||||
is_error: bool,
|
||||
result: String,
|
||||
session_id: Option<String>,
|
||||
num_turns: u64,
|
||||
duration_ms: u64,
|
||||
cost_usd: u64,
|
||||
usage: UsageFields,
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct UsageFields {
|
||||
input_tokens: u64,
|
||||
output_tokens: u64,
|
||||
cache_creation_input_tokens: u64,
|
||||
cache_read_input_tokens: u64,
|
||||
}
|
||||
// This should NOT use deny_unknown_fields — just like a real caller that accesses
|
||||
// fields by name and ignores extras.
|
||||
let parsed: ClaudeMinusPResult = serde_json::from_value(v.clone())
|
||||
.expect("strict parse of known fields must succeed despite extra claude_version field");
|
||||
|
||||
assert_eq!(parsed.result_type, "result");
|
||||
assert_eq!(parsed.subtype, "success");
|
||||
assert!(!parsed.is_error);
|
||||
assert_eq!(parsed.result, "answer");
|
||||
assert_eq!(parsed.num_turns, 2);
|
||||
assert_eq!(parsed.usage.input_tokens, 50);
|
||||
assert_eq!(parsed.usage.output_tokens, 25);
|
||||
}
|
||||
|
||||
/// Wire-format conformance: all four usage fields are unsigned integers, not strings or null.
|
||||
#[test]
|
||||
fn conformance_all_usage_fields_are_unsigned_integers() {
|
||||
let result = TranscriptResult {
|
||||
text: "x".to_string(),
|
||||
num_turns: 1,
|
||||
usage: AggregatedUsage {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 99999,
|
||||
},
|
||||
session_id: None,
|
||||
is_error: false,
|
||||
used_fallback: false,
|
||||
};
|
||||
|
||||
let (buf, mut writer) = capture();
|
||||
emit_success(&mut writer, &result, &OutputFormat::Json, "1.0", 0).unwrap();
|
||||
let v = read_json(&buf);
|
||||
let usage = &v["usage"];
|
||||
|
||||
for field in &[
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"cache_creation_input_tokens",
|
||||
"cache_read_input_tokens",
|
||||
] {
|
||||
assert!(
|
||||
usage[field].is_u64(),
|
||||
"usage.{field} must be an unsigned integer, got: {:?}",
|
||||
usage[field]
|
||||
);
|
||||
assert!(!usage[field].is_null(), "usage.{field} must not be null");
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire-format conformance: error result has required wire format fields.
|
||||
#[test]
|
||||
fn conformance_error_result_wire_format() {
|
||||
let errors = [
|
||||
ClaudePrintError::Setup("setup failed".to_string()),
|
||||
ClaudePrintError::Timeout,
|
||||
ClaudePrintError::Interrupted,
|
||||
ClaudePrintError::AssistantError("assistant err".to_string()),
|
||||
];
|
||||
|
||||
for err in &errors {
|
||||
let (out_buf, mut stdout) = capture();
|
||||
let (_, mut stderr) = capture();
|
||||
emit_error(
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
err,
|
||||
&OutputFormat::Json,
|
||||
"2.0",
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let v = read_json(&out_buf);
|
||||
assert_eq!(v["type"], "result", "error type must be 'result'");
|
||||
assert_eq!(v["is_error"], true, "error is_error must be true");
|
||||
assert!(v.get("subtype").is_some(), "error must have subtype");
|
||||
assert!(
|
||||
v.get("error_message").is_some(),
|
||||
"error must have error_message"
|
||||
);
|
||||
assert!(
|
||||
v.get("claude_version").is_some(),
|
||||
"error must have claude_version"
|
||||
);
|
||||
assert_ne!(
|
||||
v["subtype"].as_str(),
|
||||
Some("success"),
|
||||
"error subtype must not be 'success'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire-format conformance: subtype field is exactly "success" for successful result.
|
||||
#[test]
|
||||
fn conformance_subtype_is_success_for_success_result() {
|
||||
let result = TranscriptResult {
|
||||
text: "ok".to_string(),
|
||||
num_turns: 1,
|
||||
usage: AggregatedUsage::default(),
|
||||
session_id: None,
|
||||
is_error: false,
|
||||
used_fallback: false,
|
||||
};
|
||||
let (buf, mut writer) = capture();
|
||||
emit_success(&mut writer, &result, &OutputFormat::Json, "1.0", 0).unwrap();
|
||||
let v = read_json(&buf);
|
||||
assert_eq!(v["subtype"], "success");
|
||||
assert_eq!(v["type"], "result");
|
||||
}
|
||||
|
||||
/// Wire-format conformance: error subtype strings match the spec.
|
||||
#[test]
|
||||
fn conformance_error_subtype_strings_match_spec() {
|
||||
let cases = [
|
||||
(ClaudePrintError::Setup("x".to_string()), "internal_error"),
|
||||
(ClaudePrintError::Timeout, "timeout"),
|
||||
(ClaudePrintError::Interrupted, "interrupted"),
|
||||
(
|
||||
ClaudePrintError::AssistantError("y".to_string()),
|
||||
"assistant_error",
|
||||
),
|
||||
];
|
||||
for (err, expected_subtype) in &cases {
|
||||
let (out_buf, mut stdout) = capture();
|
||||
let (_, mut stderr) = capture();
|
||||
emit_error(
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
err,
|
||||
&OutputFormat::Json,
|
||||
"1.0",
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let v = read_json(&out_buf);
|
||||
assert_eq!(
|
||||
v["subtype"].as_str(),
|
||||
Some(*expected_subtype),
|
||||
"subtype mismatch for {expected_subtype}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Version resilience through the pipeline ───────────────────────────────────
|
||||
|
||||
/// Unknown JSONL event types are skipped; text from known events still extracted.
|
||||
/// This is the "version resilience" path through the full parse → emit pipeline.
|
||||
#[test]
|
||||
fn version_resilience_unknown_events_in_pipeline_no_panic() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("t.jsonl");
|
||||
// Mix of unknown events and known assistant events
|
||||
let lines = vec![
|
||||
r#"{"type":"session-start","model":"claude-4","session_id":"vs1"}"#.to_string(),
|
||||
assistant_event("m1", "real response", 50, 25, 0, 0),
|
||||
r#"{"type":"thinking-summary","content":"thinking text here"}"#.to_string(),
|
||||
r#"{"type":"tool-result","tool_id":"t1","output":"done"}"#.to_string(),
|
||||
result_event("vs1", false),
|
||||
];
|
||||
write_jsonl(&path, &lines);
|
||||
|
||||
let result = parse_transcript(&path).unwrap();
|
||||
assert_eq!(
|
||||
result.text, "real response",
|
||||
"text must be extracted despite unknown events"
|
||||
);
|
||||
assert_eq!(result.num_turns, 1);
|
||||
|
||||
// Pipeline must complete without panic
|
||||
let (buf, mut writer) = capture();
|
||||
emit_success(&mut writer, &result, &OutputFormat::Json, "3.0.0", 0).unwrap();
|
||||
let v = read_json(&buf);
|
||||
assert_eq!(v["result"], "real response");
|
||||
assert_eq!(v["claude_version"], "3.0.0");
|
||||
}
|
||||
|
||||
/// Extra fields in the Stop payload do not prevent path derivation or pipeline completion.
|
||||
#[test]
|
||||
fn version_resilience_extra_fields_in_stop_payload_through_pipeline() {
|
||||
let mut json = String::from(
|
||||
r#"{"hook_event_name":"Stop","session_id":"vs-session","cwd":"/home/user/project""#,
|
||||
);
|
||||
for i in 0..20 {
|
||||
json.push_str(&format!(r#","new_claude_field_{i}":"value_{i}""#));
|
||||
}
|
||||
json.push('}');
|
||||
|
||||
let payload = parse_stop_payload(json.as_bytes()).unwrap();
|
||||
assert_eq!(payload.session_id.as_deref(), Some("vs-session"));
|
||||
assert_eq!(payload.cwd.as_deref(), Some("/home/user/project"));
|
||||
|
||||
let info = resolve_stop_info(payload);
|
||||
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-project")
|
||||
.join("vs-session.jsonl");
|
||||
assert_eq!(
|
||||
info.transcript_path,
|
||||
Some(expected),
|
||||
"path derivation must survive extra fields"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stop payload + transcript path integration ────────────────────────────────
|
||||
|
||||
/// Explicit transcript_path in Stop payload is used as-is (no derivation).
|
||||
#[test]
|
||||
fn stop_payload_explicit_path_used_directly() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let transcript = dir.path().join("mysession.jsonl");
|
||||
write_jsonl(
|
||||
&transcript,
|
||||
&[
|
||||
assistant_event("m1", "explicit path response", 10, 5, 0, 0),
|
||||
result_event("mysession", false),
|
||||
],
|
||||
);
|
||||
|
||||
let payload_json = format!(
|
||||
r#"{{"hook_event_name":"Stop","session_id":"mysession","transcript_path":"{}","cwd":"/tmp"}}"#,
|
||||
transcript.display()
|
||||
);
|
||||
let payload = parse_stop_payload(payload_json.as_bytes()).unwrap();
|
||||
let info = resolve_stop_info(payload);
|
||||
|
||||
assert_eq!(
|
||||
info.transcript_path,
|
||||
Some(transcript.clone()),
|
||||
"explicit path must be used directly"
|
||||
);
|
||||
|
||||
// Parse the transcript at the resolved path
|
||||
let result = parse_transcript(&transcript).unwrap();
|
||||
assert_eq!(result.text, "explicit path response");
|
||||
assert_eq!(result.session_id.as_deref(), Some("mysession"));
|
||||
}
|
||||
|
||||
/// Missing transcript_path → derive from session_id + cwd → transcript read → emit.
|
||||
#[test]
|
||||
fn stop_payload_path_derivation_and_transcript_emit() {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
|
||||
let dir = TempDir::new().unwrap();
|
||||
let session_id = "derive-test-session";
|
||||
let cwd = "/home/user/myproject";
|
||||
let slug = cwd_to_slug(cwd);
|
||||
let transcript_dir = dir.path().join(".claude").join("projects").join(&slug);
|
||||
std::fs::create_dir_all(&transcript_dir).unwrap();
|
||||
let transcript = transcript_dir.join(format!("{session_id}.jsonl"));
|
||||
write_jsonl(
|
||||
&transcript,
|
||||
&[
|
||||
assistant_event("m1", "derived path response", 20, 10, 0, 0),
|
||||
result_event(session_id, false),
|
||||
],
|
||||
);
|
||||
|
||||
// Verify the slug algorithm
|
||||
assert_eq!(slug, "home-user-myproject");
|
||||
|
||||
let payload_json =
|
||||
format!(r#"{{"hook_event_name":"Stop","session_id":"{session_id}","cwd":"{cwd}"}}"#);
|
||||
let payload = parse_stop_payload(payload_json.as_bytes()).unwrap();
|
||||
let info = resolve_stop_info(payload);
|
||||
|
||||
// Verify derived path uses $HOME — this would differ from our test dir,
|
||||
// but we can still verify the slug computation is correct.
|
||||
let expected_suffix = format!(".claude/projects/{slug}/{session_id}.jsonl");
|
||||
if let Some(p) = &info.transcript_path {
|
||||
assert!(
|
||||
p.to_string_lossy().ends_with(&expected_suffix),
|
||||
"derived path has correct suffix"
|
||||
);
|
||||
}
|
||||
|
||||
// Parse transcript at the actual path (not derived, since we used a test dir)
|
||||
let result = parse_transcript(&transcript).unwrap();
|
||||
assert_eq!(result.text, "derived path response");
|
||||
assert_eq!(result.session_id.as_deref(), Some(session_id));
|
||||
|
||||
// Pipeline completion
|
||||
let (buf, mut writer) = capture();
|
||||
emit_success(&mut writer, &result, &OutputFormat::Json, "2.0", 0).unwrap();
|
||||
let v = read_json(&buf);
|
||||
assert_eq!(v["result"], "derived path response");
|
||||
}
|
||||
|
||||
// ── CWD slug algorithm ────────────────────────────────────────────────────────
|
||||
|
||||
/// Slug algorithm for representative cwd values (plan: "unit test for 3-4 cwd values").
|
||||
#[test]
|
||||
fn cwd_slug_algorithm_representative_cases() {
|
||||
// Documented in plan §8 Transcript Reader
|
||||
assert_eq!(
|
||||
cwd_to_slug("/home/coding/myproject"),
|
||||
"home-coding-myproject"
|
||||
);
|
||||
assert_eq!(cwd_to_slug("/root/foo/bar"), "root-foo-bar");
|
||||
assert_eq!(cwd_to_slug("/tmp/x"), "tmp-x");
|
||||
assert_eq!(cwd_to_slug("/tmp"), "tmp");
|
||||
// Ambiguous case: /home/user/a-b and /home/user-a/b both → home-user-a-b
|
||||
assert_eq!(
|
||||
cwd_to_slug("/home/user/a-b"),
|
||||
"home-user-a-b",
|
||||
"hyphenated dir name"
|
||||
);
|
||||
assert_eq!(
|
||||
cwd_to_slug("/home/user-a/b"),
|
||||
"home-user-a-b",
|
||||
"hyphen in parent dir"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Invariant: temp dir lifecycle (INV-1) ─────────────────────────────────────
|
||||
|
||||
/// INV-1: temp dir is removed on HookInstaller drop (no leftover in TMPDIR).
|
||||
#[test]
|
||||
fn invariant_temp_dir_drop_removes_all_artifacts() {
|
||||
let (dir_path, hook_path, fifo_path, settings_path) = {
|
||||
let installer = HookInstaller::new().unwrap();
|
||||
let d = installer.dir_path().to_path_buf();
|
||||
let h = installer.hook_path.clone();
|
||||
let f = installer.fifo_path.clone();
|
||||
let s = installer.settings_path.clone();
|
||||
(d, h, f, s)
|
||||
};
|
||||
// After drop, none of the paths should exist.
|
||||
assert!(
|
||||
!dir_path.exists(),
|
||||
"temp dir must not exist after drop (INV-1)"
|
||||
);
|
||||
assert!(!hook_path.exists(), "hook.sh must not exist after drop");
|
||||
assert!(!fifo_path.exists(), "stop.fifo must not exist after drop");
|
||||
assert!(
|
||||
!settings_path.exists(),
|
||||
"settings.json must not exist after drop"
|
||||
);
|
||||
}
|
||||
|
||||
/// INV-5: Hook artifacts are NOT placed inside ~/.claude/ or the user's home dir.
|
||||
#[test]
|
||||
fn invariant_hook_artifacts_not_in_home_claude_dir() {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
|
||||
let claude_dir = std::path::PathBuf::from(&home).join(".claude");
|
||||
|
||||
let installer = HookInstaller::new().unwrap();
|
||||
let dir = installer.dir_path();
|
||||
|
||||
assert!(
|
||||
!dir.starts_with(&claude_dir),
|
||||
"temp dir must not be inside ~/.claude/: {dir:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Corner cases ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Stop payload with only session_id and cwd (no transcript_path, no last_message):
|
||||
/// path is derived; fallback message is None.
|
||||
#[test]
|
||||
fn stop_payload_minimal_fields_derives_path() {
|
||||
let json = r#"{"hook_event_name":"Stop","session_id":"min-sid","cwd":"/tmp/min"}"#;
|
||||
let payload = parse_stop_payload(json.as_bytes()).unwrap();
|
||||
assert!(payload.transcript_path.is_none());
|
||||
assert!(payload.last_assistant_message.is_none());
|
||||
|
||||
let info = resolve_stop_info(payload);
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
|
||||
let expected = std::path::PathBuf::from(&home)
|
||||
.join(".claude")
|
||||
.join("projects")
|
||||
.join("tmp-min")
|
||||
.join("min-sid.jsonl");
|
||||
assert_eq!(info.transcript_path, Some(expected));
|
||||
assert!(info.last_assistant_message.is_none());
|
||||
}
|
||||
|
||||
/// stream-json error before inject: text mode stderr-only behavior also applies.
|
||||
#[test]
|
||||
fn stream_json_error_before_inject_no_stdout() {
|
||||
let err = ClaudePrintError::Setup("binary not found".to_string());
|
||||
let (out_buf, mut stdout) = capture();
|
||||
let (err_buf, mut stderr) = capture();
|
||||
// stream_json_after_inject = false → behaves like text mode (stderr only)
|
||||
emit_error(
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
&err,
|
||||
&OutputFormat::StreamJson,
|
||||
"1.0",
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
out_buf.lock().unwrap().is_empty(),
|
||||
"stream-json before-inject error must not write stdout"
|
||||
);
|
||||
assert!(
|
||||
!err_buf.lock().unwrap().is_empty(),
|
||||
"stream-json before-inject error must write stderr"
|
||||
);
|
||||
}
|
||||
|
||||
/// stream-json error after inject: error JSON written to stdout.
|
||||
#[test]
|
||||
fn stream_json_error_after_inject_writes_json_to_stdout() {
|
||||
let err = ClaudePrintError::Timeout;
|
||||
let (out_buf, mut stdout) = capture();
|
||||
let (_, mut stderr) = capture();
|
||||
// stream_json_after_inject = true → JSON to stdout
|
||||
emit_error(
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
&err,
|
||||
&OutputFormat::StreamJson,
|
||||
"2.0",
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let v = read_json(&out_buf);
|
||||
assert_eq!(v["is_error"], true);
|
||||
assert_eq!(v["subtype"], "timeout");
|
||||
assert_eq!(v["claude_version"], "2.0");
|
||||
}
|
||||
|
||||
/// cost_usd is always 0 in JSON output (wire-compat field, always emitted).
|
||||
#[test]
|
||||
fn cost_usd_always_zero_in_json_output() {
|
||||
let result = TranscriptResult {
|
||||
text: "x".to_string(),
|
||||
num_turns: 1,
|
||||
usage: AggregatedUsage {
|
||||
input_tokens: 1_000_000,
|
||||
output_tokens: 500_000,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
},
|
||||
session_id: None,
|
||||
is_error: false,
|
||||
used_fallback: false,
|
||||
};
|
||||
let (buf, mut writer) = capture();
|
||||
emit_success(&mut writer, &result, &OutputFormat::Json, "1.0", 0).unwrap();
|
||||
let v = read_json(&buf);
|
||||
assert_eq!(
|
||||
v["cost_usd"], 0,
|
||||
"cost_usd must always be 0 (plan §9 known limitation)"
|
||||
);
|
||||
}
|
||||
|
||||
/// duration_ms in JSON output matches the value passed to emit_success.
|
||||
#[test]
|
||||
fn duration_ms_reflects_passed_value() {
|
||||
let result = TranscriptResult {
|
||||
text: "x".to_string(),
|
||||
num_turns: 1,
|
||||
usage: AggregatedUsage::default(),
|
||||
session_id: None,
|
||||
is_error: false,
|
||||
used_fallback: false,
|
||||
};
|
||||
let (buf, mut writer) = capture();
|
||||
emit_success(&mut writer, &result, &OutputFormat::Json, "1.0", 42_000).unwrap();
|
||||
let v = read_json(&buf);
|
||||
assert_eq!(
|
||||
v["duration_ms"], 42_000u64,
|
||||
"duration_ms must reflect the passed value"
|
||||
);
|
||||
}
|
||||
130
tests/startup.rs
130
tests/startup.rs
|
|
@ -1,4 +1,5 @@
|
|||
use claude_print::startup::{StartupAction, StartupPhase, StartupSeq};
|
||||
use std::time::Duration;
|
||||
|
||||
// ── Trust dialog keyword detection ───────────────────────────────────────────
|
||||
|
||||
|
|
@ -28,6 +29,33 @@ fn test_trust_dialog_keyword_threshold_two_triggers() {
|
|||
);
|
||||
}
|
||||
|
||||
/// Alternative wording: `continue` + `folder` → CR sent (keyword union logic).
|
||||
///
|
||||
/// This is the Phase 10 "MEDIUM" scenario: trust dialog uses different wording
|
||||
/// than "trust Allow" — the keyword union covers "continue", "folder", "proceed",
|
||||
/// "permission" as alternatives. Verifies that any two keywords from the union
|
||||
/// trigger the dismiss even when the primary keywords are absent.
|
||||
#[test]
|
||||
fn test_trust_dialog_alternate_wording_continue_folder() {
|
||||
let mut seq = StartupSeq::new(b"prompt".to_vec());
|
||||
// Line contains ONLY "continue" + "folder" from the keyword set — no "trust"/"Allow".
|
||||
let action = seq.feed(b"Do you want to continue in this folder?\n");
|
||||
match action {
|
||||
StartupAction::Write(bytes) => assert_eq!(
|
||||
bytes, b"\r",
|
||||
"'continue' + 'folder' must send CR (alternate trust wording)"
|
||||
),
|
||||
other => {
|
||||
panic!("expected Write(b\"\\r\") for 'continue'+'folder' keywords, got: {other:?}")
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
*seq.phase(),
|
||||
StartupPhase::TrustDismissed,
|
||||
"phase must be TrustDismissed after 'continue'+'folder' trigger"
|
||||
);
|
||||
}
|
||||
|
||||
/// A single keyword never triggers (< 2 threshold).
|
||||
#[test]
|
||||
fn test_trust_dialog_single_keyword_no_trigger() {
|
||||
|
|
@ -84,7 +112,10 @@ fn test_trust_dialog_keywords_across_chunk_boundary() {
|
|||
let mut seq = StartupSeq::new(b"prompt".to_vec());
|
||||
// First chunk: partial line ending mid-word.
|
||||
let a1 = seq.feed(b"Do you trust and ");
|
||||
assert!(matches!(a1, StartupAction::None), "partial line must not trigger yet");
|
||||
assert!(
|
||||
matches!(a1, StartupAction::None),
|
||||
"partial line must not trigger yet"
|
||||
);
|
||||
// Second chunk: completes the line.
|
||||
let a2 = seq.feed(b"Allow access to the folder?\n");
|
||||
match a2 {
|
||||
|
|
@ -154,3 +185,100 @@ fn test_trust_dialog_prompt_payload_uses_bracketed_paste() {
|
|||
"phase must be TrustDismissed before timer fires"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Idle fallback (≥ 200 bytes + 0.8 s silence) ──────────────────────────────
|
||||
|
||||
/// 200 bytes received, then 0.8 s idle → CR sent via idle fallback (no keywords needed).
|
||||
///
|
||||
/// This verifies the plan's "arbitrary unknown welcome text" path: claude emits
|
||||
/// ≥ 200 bytes of startup noise with no keywords, then goes quiet — claude-print
|
||||
/// must still dismiss the trust phase via the idle fallback.
|
||||
#[test]
|
||||
fn test_idle_fallback_fires_after_200_bytes_and_silence() {
|
||||
// Use a very short idle timeout so the test doesn't sleep 0.8 s.
|
||||
let gap_ms: u64 = 30;
|
||||
let mut seq = StartupSeq::with_idle_gap(b"prompt".to_vec(), gap_ms);
|
||||
|
||||
// Feed exactly 200 bytes of non-keyword output to clear the byte threshold.
|
||||
let noise = vec![b'x'; 200];
|
||||
let action = seq.feed(&noise);
|
||||
// No keywords → no CR yet.
|
||||
assert!(
|
||||
matches!(action, StartupAction::None),
|
||||
"200 bytes of noise must not immediately trigger trust dismiss (no keywords)"
|
||||
);
|
||||
assert_eq!(
|
||||
*seq.phase(),
|
||||
StartupPhase::Waiting,
|
||||
"still Waiting after byte dump"
|
||||
);
|
||||
|
||||
// Now wait for the IDLE_TIMEOUT_MS (0.8 s normally, gap_ms here for speed).
|
||||
// We use with_idle_gap which sets the post-dismiss idle, but the WAITING idle
|
||||
// threshold is hardcoded at 800 ms. For test speed we sleep a short time and
|
||||
// instead test the feed-based path; the actual 800 ms timer is covered in unit tests.
|
||||
// Here we directly call poll_timers after sleeping past the idle window.
|
||||
std::thread::sleep(Duration::from_millis(900)); // past 800 ms
|
||||
let action = seq.poll_timers();
|
||||
match action {
|
||||
StartupAction::Write(bytes) => assert_eq!(bytes, b"\r", "idle fallback must send CR"),
|
||||
StartupAction::HardTimeout => panic!("hard timeout should not fire — ≥ 200 bytes received"),
|
||||
StartupAction::None => panic!("idle fallback must fire after 0.8 s with ≥ 200 bytes"),
|
||||
}
|
||||
assert_eq!(
|
||||
*seq.phase(),
|
||||
StartupPhase::TrustDismissed,
|
||||
"phase must advance to TrustDismissed via idle fallback"
|
||||
);
|
||||
}
|
||||
|
||||
/// Fewer than 200 bytes received → idle fallback must NOT fire even after 0.8 s.
|
||||
/// This verifies the 200-byte minimum is enforced before the idle fallback.
|
||||
#[test]
|
||||
fn test_idle_fallback_does_not_fire_below_200_bytes() {
|
||||
let mut seq = StartupSeq::with_idle_gap(b"prompt".to_vec(), 20);
|
||||
|
||||
// Feed 199 bytes — one below the threshold.
|
||||
let noise = vec![b'y'; 199];
|
||||
seq.feed(&noise);
|
||||
assert_eq!(*seq.phase(), StartupPhase::Waiting);
|
||||
|
||||
// Wait past the idle window.
|
||||
std::thread::sleep(Duration::from_millis(900));
|
||||
|
||||
let action = seq.poll_timers();
|
||||
// Must not fire the idle fallback (< 200 bytes).
|
||||
assert!(
|
||||
!matches!(action, StartupAction::Write(_)),
|
||||
"idle fallback must not fire with only 199 bytes received; got: {action:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
*seq.phase(),
|
||||
StartupPhase::Waiting,
|
||||
"phase must remain Waiting when byte threshold not met"
|
||||
);
|
||||
}
|
||||
|
||||
/// Hard timeout fires when WAITING persists for ≥ 45 s with fewer than 200 bytes.
|
||||
///
|
||||
/// This test is slow by design — it verifies the binary-not-found / partial-output-hang
|
||||
/// detection described in EC-8. Use `#[ignore]` to skip in fast test runs.
|
||||
///
|
||||
/// To run: `cargo test test_hard_timeout -- --ignored`
|
||||
#[test]
|
||||
#[ignore = "slow: sleeps 45 s to verify the hard timeout"]
|
||||
fn test_hard_timeout_fires_after_45s_with_few_bytes() {
|
||||
let mut seq = StartupSeq::with_idle_gap(b"prompt".to_vec(), 2000);
|
||||
|
||||
// Feed < 200 bytes so the idle fallback never fires.
|
||||
seq.feed(b"tiny output\n");
|
||||
|
||||
// Wait past the 45 s hard timeout.
|
||||
std::thread::sleep(Duration::from_secs(46));
|
||||
|
||||
let action = seq.poll_timers();
|
||||
assert!(
|
||||
matches!(action, StartupAction::HardTimeout),
|
||||
"hard timeout must fire after 45 s with < 200 bytes; got: {action:?}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,10 @@ fn probe_dedup_da1_answered_only_once() {
|
|||
fn unknown_probe_ignored_no_response_no_panic() {
|
||||
let mut e = emu();
|
||||
let resp = e.feed(b"\x1b[99t");
|
||||
assert_eq!(resp, b"", "unknown escape sequence must produce no response");
|
||||
assert_eq!(
|
||||
resp, b"",
|
||||
"unknown escape sequence must produce no response"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -64,5 +67,8 @@ fn split_chunk_probe_answered_on_second_read() {
|
|||
let first = e.feed(b"\x1b[");
|
||||
let second = e.feed(b"c");
|
||||
assert_eq!(first, b"", "partial probe should produce no response yet");
|
||||
assert_eq!(second, b"\x1b[?6c", "probe completed on second read should be answered");
|
||||
assert_eq!(
|
||||
second, b"\x1b[?6c",
|
||||
"probe completed on second read should be answered"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,14 @@ fn write_jsonl(path: &Path, lines: &[String]) {
|
|||
}
|
||||
}
|
||||
|
||||
fn assistant_event(id: &str, text: &str, in_tok: u64, out_tok: u64, cache_create: u64, cache_read: u64) -> String {
|
||||
fn assistant_event(
|
||||
id: &str,
|
||||
text: &str,
|
||||
in_tok: u64,
|
||||
out_tok: u64,
|
||||
cache_create: u64,
|
||||
cache_read: u64,
|
||||
) -> String {
|
||||
serde_json::json!({
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
|
|
@ -394,7 +401,10 @@ fn test_streaming_dedup_40_retries() {
|
|||
});
|
||||
|
||||
let r = read_transcript(&path, None).unwrap();
|
||||
assert_eq!(r.num_turns, 1, "5 streaming chunks of same message.id = 1 turn");
|
||||
assert_eq!(
|
||||
r.num_turns, 1,
|
||||
"5 streaming chunks of same message.id = 1 turn"
|
||||
);
|
||||
assert_eq!(r.text, "chunk0chunk1chunk2chunk3chunk4");
|
||||
assert_eq!(r.usage.input_tokens, 10);
|
||||
}
|
||||
|
|
@ -412,7 +422,10 @@ fn test_transcript_race() {
|
|||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
std::fs::write(
|
||||
&path_clone,
|
||||
format!("{}\n", assistant_event("msg-race-2", "race result", 10, 5, 0, 0)),
|
||||
format!(
|
||||
"{}\n",
|
||||
assistant_event("msg-race-2", "race result", 10, 5, 0, 0)
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,19 +9,65 @@ use std::io::Write as IoWrite;
|
|||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
|
||||
// ── Claude version format tracking ───────────────────────────────────────────
|
||||
|
||||
/// CI artifact: record the current claude binary version for regression
|
||||
/// tracking. If the version changes between CI runs, the operator is alerted
|
||||
/// via a diff in the `last-claude-version.txt` artifact.
|
||||
///
|
||||
/// This test is skipped (pass) when `claude` is not on PATH — non-blocking
|
||||
/// for developer machines that have the binary at a non-standard location.
|
||||
#[test]
|
||||
fn test_claude_version_recorded() {
|
||||
let output = match std::process::Command::new("claude")
|
||||
.arg("--version")
|
||||
.output()
|
||||
{
|
||||
Ok(o) => o,
|
||||
Err(_) => {
|
||||
// claude not on PATH — skip test rather than fail.
|
||||
return;
|
||||
}
|
||||
};
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let combined = format!("{stdout}{stderr}");
|
||||
let first_line = combined.lines().next().unwrap_or("").trim();
|
||||
// The version string must contain "Claude Code" (observed format: "2.1.168 (Claude Code)")
|
||||
assert!(
|
||||
first_line.contains("Claude Code") || first_line.contains("claude"),
|
||||
"unexpected claude --version format: {first_line:?}"
|
||||
);
|
||||
// Write to CI artifact for diff-based regression tracking. Failure is
|
||||
// non-fatal (e.g., read-only filesystem) — the assertion above is the real gate.
|
||||
let artifact_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target");
|
||||
let _ = std::fs::create_dir_all(&artifact_dir);
|
||||
let _ = std::fs::write(
|
||||
artifact_dir.join("last-claude-version.txt"),
|
||||
first_line.as_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stop payload with 50 unknown extra fields ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn stop_payload_50_unknown_fields_parsed_without_error() {
|
||||
let mut json =
|
||||
String::from(r#"{"hook_event_name":"Stop","session_id":"sid1","cwd":"/tmp/x""#);
|
||||
let mut json = String::from(r#"{"hook_event_name":"Stop","session_id":"sid1","cwd":"/tmp/x""#);
|
||||
for i in 0..50 {
|
||||
json.push_str(&format!(r#","future_field_{i}":"value_{i}""#));
|
||||
}
|
||||
json.push('}');
|
||||
let p = parse_stop_payload(json.as_bytes()).expect("must parse with 50 unknown fields");
|
||||
assert_eq!(p.session_id.as_deref(), Some("sid1"), "session_id must survive unknown fields");
|
||||
assert_eq!(p.cwd.as_deref(), Some("/tmp/x"), "cwd must survive unknown fields");
|
||||
assert_eq!(
|
||||
p.session_id.as_deref(),
|
||||
Some("sid1"),
|
||||
"session_id must survive unknown fields"
|
||||
);
|
||||
assert_eq!(
|
||||
p.cwd.as_deref(),
|
||||
Some("/tmp/x"),
|
||||
"cwd must survive unknown fields"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Usage object with 20 new numeric fields ───────────────────────────────────
|
||||
|
|
@ -59,7 +105,10 @@ fn usage_20_new_numeric_fields_ignored_known_fields_correct() {
|
|||
let r = parse_transcript(&path).unwrap();
|
||||
assert_eq!(r.usage.input_tokens, 100, "input_tokens wrong");
|
||||
assert_eq!(r.usage.output_tokens, 50, "output_tokens wrong");
|
||||
assert_eq!(r.usage.cache_creation_input_tokens, 10, "cache_create wrong");
|
||||
assert_eq!(
|
||||
r.usage.cache_creation_input_tokens, 10,
|
||||
"cache_create wrong"
|
||||
);
|
||||
assert_eq!(r.usage.cache_read_input_tokens, 20, "cache_read wrong");
|
||||
assert_eq!(r.num_turns, 1);
|
||||
}
|
||||
|
|
@ -93,7 +142,10 @@ fn content_block_new_type_with_required_field_treated_as_unknown() {
|
|||
drop(file);
|
||||
|
||||
let r = parse_transcript(&path).unwrap();
|
||||
assert_eq!(r.text, "extracted text", "text after unknown block must be extracted");
|
||||
assert_eq!(
|
||||
r.text, "extracted text",
|
||||
"text after unknown block must be extracted"
|
||||
);
|
||||
assert_eq!(r.num_turns, 1);
|
||||
}
|
||||
|
||||
|
|
@ -117,7 +169,10 @@ fn jsonl_events_in_new_order_parse_succeeds() {
|
|||
drop(file);
|
||||
|
||||
let r = parse_transcript(&path).unwrap();
|
||||
assert_eq!(r.text, "world", "text must be extracted despite new event order");
|
||||
assert_eq!(
|
||||
r.text, "world",
|
||||
"text must be extracted despite new event order"
|
||||
);
|
||||
assert_eq!(r.num_turns, 1);
|
||||
assert_eq!(r.session_id.as_deref(), Some("ord-session"));
|
||||
}
|
||||
|
|
@ -184,16 +239,28 @@ fn startup_10_non_dialog_lines_do_not_trigger() {
|
|||
|
||||
#[test]
|
||||
fn token_regression_fixture_v2_1_168() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/fixtures/transcript_v2.1.168.jsonl");
|
||||
let path =
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/transcript_v2.1.168.jsonl");
|
||||
let r = parse_transcript(&path).expect("parse fixture failed");
|
||||
// Turn 1: msg-001 — in=6178, out=295, cache_create=825, cache_read=26442
|
||||
// Turn 2: msg-002 × 3 streaming chunks — in=100, out=50, cache_create=0, cache_read=5000
|
||||
assert_eq!(r.num_turns, 2, "fixture has 2 unique assistant turns");
|
||||
assert_eq!(r.usage.input_tokens, 6278, "input_tokens mismatch (6178 + 100)");
|
||||
assert_eq!(r.usage.output_tokens, 345, "output_tokens mismatch (295 + 50)");
|
||||
assert_eq!(r.usage.cache_creation_input_tokens, 825, "cache_creation mismatch (825 + 0)");
|
||||
assert_eq!(r.usage.cache_read_input_tokens, 31442, "cache_read mismatch (26442 + 5000)");
|
||||
assert_eq!(
|
||||
r.usage.input_tokens, 6278,
|
||||
"input_tokens mismatch (6178 + 100)"
|
||||
);
|
||||
assert_eq!(
|
||||
r.usage.output_tokens, 345,
|
||||
"output_tokens mismatch (295 + 50)"
|
||||
);
|
||||
assert_eq!(
|
||||
r.usage.cache_creation_input_tokens, 825,
|
||||
"cache_creation mismatch (825 + 0)"
|
||||
);
|
||||
assert_eq!(
|
||||
r.usage.cache_read_input_tokens, 31442,
|
||||
"cache_read mismatch (26442 + 5000)"
|
||||
);
|
||||
// Last turn's text is the concatenation of the 3 streaming chunks
|
||||
assert_eq!(r.text, "chunk1 chunk2 chunk3", "last turn text mismatch");
|
||||
}
|
||||
|
|
@ -202,10 +269,13 @@ fn token_regression_fixture_v2_1_168() {
|
|||
|
||||
#[test]
|
||||
fn fixture_unknown_usage_fields_ignored() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/fixtures/transcript_v2.1.168.jsonl");
|
||||
let path =
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/transcript_v2.1.168.jsonl");
|
||||
// The fixture contains `server_tool_use`, `service_tier`, `cache_creation`,
|
||||
// `inference_geo`, `speed` in the usage object — all should be silently ignored.
|
||||
let r = parse_transcript(&path).expect("parse fixture must succeed");
|
||||
assert!(r.num_turns > 0, "must parse at least one turn despite unknown usage fields");
|
||||
assert!(
|
||||
r.num_turns > 0,
|
||||
"must parse at least one turn despite unknown usage fields"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue