claude-print/tests/version_compat.rs
jedarden 50b213285a Add Phase 9: NEEDLE integration — install.sh, claude-print.yaml, --check subcommand
- claude-print.yaml: NEEDLE agent config with stdin input_method, needle-transform-claude
  output_transform, and invoke_template for subscription-billed claude-print runs
- install.sh: download release binary from GitHub, backup existing, install mock_claude,
  install NEEDLE config if present, run --check to verify, print --version
- src/check.rs: --check doctor subcommand with openpty probe, mkfifo probe, and optional
  mock_claude PTY round-trip (skipped if mock_claude not in PATH)
- src/main.rs + src/lib.rs: wire up check::run() for --check flag
- README.md: add Install, Usage, Flags table (matches --help exactly), Exit codes,
  and NEEDLE integration sections
- test-fixtures/mock-claude: extend with all MOCK_* env var controls needed for
  integration tests (MOCK_SILENT, MOCK_EXIT_BEFORE_STOP, MOCK_TRUST_DIALOG, etc.)
- tests/cli.rs, tests/hooks.rs, tests/version_compat.rs: Phase 10 unit test stubs

claude-print --check passes: openpty PASS, mkfifo PASS, mock_claude PTY PASS
bash -n install.sh: syntax OK

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

211 lines
8.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// Version-resilience test suite (Phase 10).
///
/// Verifies that `claude-print` survives Claude Code schema changes without
/// rebuilding. All tests are credential-free and run in CI on every push.
use claude_print::poller::parse_stop_payload;
use claude_print::startup::StartupSeq;
use claude_print::transcript::parse_transcript;
use std::io::Write as IoWrite;
use std::path::Path;
use tempfile::TempDir;
// ── 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""#);
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");
}
// ── Usage object with 20 new numeric fields ───────────────────────────────────
#[test]
fn usage_20_new_numeric_fields_ignored_known_fields_correct() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("t.jsonl");
let mut usage = serde_json::json!({
"input_tokens": 100,
"output_tokens": 50,
"cache_creation_input_tokens": 10,
"cache_read_input_tokens": 20,
});
let obj = usage.as_object_mut().unwrap();
for i in 0..20u64 {
obj.insert(
format!("future_metric_{i}"),
serde_json::Value::Number(serde_json::Number::from(i)),
);
}
let event = serde_json::json!({
"type": "assistant",
"message": {
"id": "msg-new-usage",
"content": [{"type": "text", "text": "ok"}],
"usage": usage,
}
});
let mut file = std::fs::File::create(&path).unwrap();
writeln!(file, "{}", event).unwrap();
drop(file);
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_read_input_tokens, 20, "cache_read wrong");
assert_eq!(r.num_turns, 1);
}
// ── Content block with new type and required fields → Unknown via #[serde(other)]
#[test]
fn content_block_new_type_with_required_field_treated_as_unknown() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("t.jsonl");
let event = serde_json::json!({
"type": "assistant",
"message": {
"id": "msg-future-block",
"content": [
{
"type": "future_rich_media",
"required_in_new_version": "some-value",
"extra_metadata": {"version": 3}
},
{"type": "text", "text": "extracted text"}
],
"usage": {
"input_tokens": 5, "output_tokens": 3,
"cache_creation_input_tokens": 0, "cache_read_input_tokens": 0
}
}
});
let mut file = std::fs::File::create(&path).unwrap();
writeln!(file, "{}", event).unwrap();
drop(file);
let r = parse_transcript(&path).unwrap();
assert_eq!(r.text, "extracted text", "text after unknown block must be extracted");
assert_eq!(r.num_turns, 1);
}
// ── JSONL with events in a new order ─────────────────────────────────────────
#[test]
fn jsonl_events_in_new_order_parse_succeeds() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("t.jsonl");
// A hypothetical "summary" event appears before user/assistant in a future version.
let lines = [
r#"{"type":"summary","content":"A new summary event type","model":"claude-5"}"#,
r#"{"type":"user","message":{"content":[{"type":"text","text":"hello"}]}}"#,
r#"{"type":"assistant","message":{"id":"msg-ord","content":[{"type":"text","text":"world"}],"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}"#,
r#"{"type":"result","is_error":false,"session_id":"ord-session"}"#,
];
let mut file = std::fs::File::create(&path).unwrap();
for line in &lines {
writeln!(file, "{}", line).unwrap();
}
drop(file);
let r = parse_transcript(&path).unwrap();
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"));
}
// ── Startup heuristic stability: 20 trust dialog phrasings must all trigger ───
#[test]
fn startup_20_trust_dialog_phrasings_all_trigger() {
let phrasings: &[&str] = &[
"Do you trust and Allow access to this folder?",
"Grant permission to proceed with this folder",
"Please trust and continue to allow",
"Allow and continue access to this folder",
"Do you want to proceed and trust this folder?",
"Permission required: trust to continue",
"Trust this folder and proceed with Allow",
"continue and allow this folder permission",
"Grant trust, proceed, and allow folder access",
"Please trust, allow, and continue in this folder",
"Permission to proceed: trust and allow folder",
"Trust dialog: allow and continue with folder",
"You must trust and continue to allow folder access",
"Do you Allow and trust this folder to proceed?",
"Before continuing, trust and allow this folder",
"Allow permission to proceed and trust folder",
"This action requires trust and proceed to continue",
"To allow folder access, trust and proceed",
"Grant access: trust, allow, and proceed with folder",
"Confirm permission: trust to allow and continue",
];
for &phrasing in phrasings {
assert!(
StartupSeq::scan_line(phrasing.as_bytes()),
"expected trust dialog trigger for: {phrasing:?}"
);
}
}
// ── Startup heuristic stability: 10 non-dialog lines must not trigger ─────────
#[test]
fn startup_10_non_dialog_lines_do_not_trigger() {
let non_dialogs: &[&str] = &[
"Initializing Claude Code v2.1.168...",
"Loading configuration...",
"Reading context from files",
"Connecting to API endpoint",
"claude-print started",
"Processing your request",
"",
" ",
"\x1b[31mError\x1b[0m: Something went wrong",
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
];
for &line in non_dialogs {
assert!(
!StartupSeq::scan_line(line.as_bytes()),
"expected NO trigger for non-dialog line: {line:?}"
);
}
}
// ── Token count regression: fixture transcript_v2.1.168.jsonl ─────────────────
#[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 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)");
// Last turn's text is the concatenation of the 3 streaming chunks
assert_eq!(r.text, "chunk1 chunk2 chunk3", "last turn text mismatch");
}
// ── Fixture also contains unknown usage fields → ignored ──────────────────────
#[test]
fn fixture_unknown_usage_fields_ignored() {
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");
}