claude-print/tests/startup.rs
jedarden edd9470038 Add startup.rs: trust-keyword scanner with test_trust_dialog_* integration tests (Phase 5)
Implements StartupSeq with scan_line() that detects 2+ trust keywords
("trust", "Allow", "continue", "folder", "permission", "proceed") on a
PTY output line and returns CR to dismiss the dialog.  Includes idle
fallback (0.8 s after 200+ bytes) and hard timeout (45 s / <200 bytes →
HardTimeout).  Phase 2 injects the prompt via bracketed paste after a
2 s post-dismiss idle.

11 test_trust_dialog_* integration tests cover keyword match, threshold,
case sensitivity, chunk-boundary assembly, one-shot dismiss, and CR- vs
LF-terminated lines.  12 unit tests in startup::tests cover scan_line
and feed() in isolation.

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

156 lines
6 KiB
Rust

use claude_print::startup::{StartupAction, StartupPhase, StartupSeq};
// ── Trust dialog keyword detection ───────────────────────────────────────────
/// Two trust keywords on one line triggers a CR keypress.
#[test]
fn test_trust_dialog_keyword_match_returns_cr() {
let mut seq = StartupSeq::new(b"What is 2+2?".to_vec());
let action = seq.feed(b"Do you trust and Allow this folder?\n");
match action {
StartupAction::Write(bytes) => assert_eq!(
bytes, b"\r",
"trust dialog dismiss must be a single CR byte"
),
_ => panic!("expected Write(b\"\\r\") on trust keyword match"),
}
}
/// Exactly two keywords on the same line triggers (boundary check).
#[test]
fn test_trust_dialog_keyword_threshold_two_triggers() {
let mut seq = StartupSeq::new(b"prompt".to_vec());
// "trust" + "proceed" = exactly 2 → must trigger.
let action = seq.feed(b"trust this and proceed\n");
assert!(
matches!(action, StartupAction::Write(_)),
"exactly 2 keywords must trigger dismiss"
);
}
/// A single keyword never triggers (< 2 threshold).
#[test]
fn test_trust_dialog_single_keyword_no_trigger() {
let mut seq = StartupSeq::new(b"prompt".to_vec());
let action = seq.feed(b"Please proceed with the next step\n");
assert!(
matches!(action, StartupAction::None),
"single keyword must not trigger dismiss"
);
}
/// No keywords → no trigger.
#[test]
fn test_trust_dialog_no_keywords_no_trigger() {
let mut seq = StartupSeq::new(b"prompt".to_vec());
let action = seq.feed(b"Initializing Claude Code v2.1.168...\n");
assert!(
matches!(action, StartupAction::None),
"no keywords must not trigger dismiss"
);
}
/// Phase transitions to TrustDismissed after keyword match.
#[test]
fn test_trust_dialog_phase_becomes_trust_dismissed() {
let mut seq = StartupSeq::new(b"prompt".to_vec());
assert_eq!(*seq.phase(), StartupPhase::Waiting);
seq.feed(b"trust Allow folder\n");
assert_eq!(
*seq.phase(),
StartupPhase::TrustDismissed,
"phase must advance to TrustDismissed after keyword match"
);
}
/// Once TrustDismissed, further trust-keyword lines are ignored.
#[test]
fn test_trust_dialog_dismiss_is_one_shot() {
let mut seq = StartupSeq::new(b"prompt".to_vec());
// First trigger.
seq.feed(b"trust Allow\n");
assert_eq!(*seq.phase(), StartupPhase::TrustDismissed);
// Second trust-dialog line must not produce another CR.
let action = seq.feed(b"trust Allow folder permission proceed continue\n");
assert!(
matches!(action, StartupAction::None),
"second keyword match after dismiss must be ignored"
);
}
/// Keywords split across two feed() calls are assembled by the line buffer.
#[test]
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");
// Second chunk: completes the line.
let a2 = seq.feed(b"Allow access to the folder?\n");
match a2 {
StartupAction::Write(bytes) => assert_eq!(bytes, b"\r"),
_ => panic!("expected CR once line is complete"),
}
}
/// Keywords on the second of two lines (first line benign) triggers on the correct line.
#[test]
fn test_trust_dialog_keyword_on_second_line() {
let mut seq = StartupSeq::new(b"prompt".to_vec());
// First line: no keywords.
let a1 = seq.feed(b"Loading configuration...\n");
assert!(matches!(a1, StartupAction::None));
// Second line: two keywords.
let a2 = seq.feed(b"Grant permission to proceed?\n");
match a2 {
StartupAction::Write(bytes) => assert_eq!(bytes, b"\r"),
_ => panic!("expected CR on second line with keywords"),
}
}
/// CR-terminated lines (\\r instead of \\n) also trigger the scanner.
#[test]
fn test_trust_dialog_cr_terminated_line() {
let mut seq = StartupSeq::new(b"prompt".to_vec());
let action = seq.feed(b"trust Allow folder\r");
match action {
StartupAction::Write(bytes) => assert_eq!(bytes, b"\r"),
_ => panic!("expected CR on CR-terminated trust line"),
}
}
/// Keyword matching is case-sensitive: "allow" (lowercase) does not match "Allow".
#[test]
fn test_trust_dialog_case_sensitive_keywords() {
let mut seq = StartupSeq::new(b"prompt".to_vec());
// "allow" (lowercase) is not in the keyword list; only "trust" matches → 1 keyword.
let action = seq.feed(b"allow me to trust this\n");
assert!(
matches!(action, StartupAction::None),
"lowercase 'allow' must not count as the 'Allow' keyword"
);
}
// ── Prompt injection payload ─────────────────────────────────────────────────
/// After dismissal the injected payload uses bracketed paste markers.
#[test]
fn test_trust_dialog_prompt_payload_uses_bracketed_paste() {
let prompt = b"What is 2+2?";
let mut seq = StartupSeq::new(prompt.to_vec());
// Trigger dismiss.
seq.feed(b"trust Allow\n");
assert_eq!(*seq.phase(), StartupPhase::TrustDismissed);
// Force the post-dismiss timer to fire by back-dating the dismiss timestamp.
// We do this by calling poll_timers after manually advancing time via a
// SpeedHack: set trust_dismiss_at to 3 s ago by creating a fresh seq at
// TrustDismissed with the dismiss timestamp in the past.
// Since Instant is not constructible from a fixed value, we test payload
// format via the unit-level make_prompt_payload path already covered in
// startup.rs unit tests. Here we verify the state machine sequence only.
assert_eq!(
*seq.phase(),
StartupPhase::TrustDismissed,
"phase must be TrustDismissed before timer fires"
);
}