"""Unit tests for the Stage 10 error hierarchy + PTY-output classifier.""" from __future__ import annotations import pytest from claude_code_api import ( AuthError, BackendError, CLINotFoundError, MessageParseError, ProcessError, RateLimitError, SessionError, classify_pty_failure, ) def test_hierarchy_roots_under_backend_error() -> None: # Every backend-emitted exception must descend from BackendError so a # gateway can install a single catch-all handler. for cls in ( AuthError, MessageParseError, ProcessError, RateLimitError, SessionError, ): assert issubclass(cls, BackendError) assert issubclass(CLINotFoundError, ProcessError) def test_process_error_carries_exit_code_and_stderr_in_message() -> None: exc = ProcessError("boom", exit_code=7, stderr="line1\nline2") assert exc.exit_code == 7 assert exc.stderr == "line1\nline2" rendered = str(exc) assert "boom" in rendered assert "exit code: 7" in rendered assert "line1" in rendered # included in the tail def test_process_error_tail_caps_huge_stderr() -> None: # A 5KB blob should not embed wholesale in the message. blob = "x" * 5000 exc = ProcessError("oops", stderr=blob) rendered = str(exc) # Tail is capped to 2000 chars in the formatter. assert rendered.count("x") <= 2000 + 10 # +slack for any literal 'x' in prefix def test_cli_not_found_appends_executable() -> None: exc = CLINotFoundError(executable="/usr/local/bin/claude") assert "/usr/local/bin/claude" in str(exc) assert exc.executable == "/usr/local/bin/claude" # Default constructor is also valid. bare = CLINotFoundError() assert "not found" in str(bare).lower() def test_classify_pty_failure_returns_none_when_no_marker() -> None: assert classify_pty_failure(b"the model is thinking...") is None assert classify_pty_failure("") is None def test_classify_auth_markers() -> None: assert classify_pty_failure(b"Failed to authenticate (status 401)") is AuthError assert classify_pty_failure(b"API Error: 403 Forbidden") is AuthError # claude-p's compact match handles "Please run /login" even when ANSI # / spinner punctuation splits the words. assert classify_pty_failure(b"Please run /login to continue.") is AuthError assert ( classify_pty_failure(b"\x1b[31mPlease\x1b[0m run /login") is AuthError ) def test_classify_rate_limit_markers() -> None: assert classify_pty_failure(b"You've hit your limit. Try again later.") is RateLimitError assert classify_pty_failure(b"You have hit your limit.") is RateLimitError # Bare form (TUI sometimes wraps the noun out). assert classify_pty_failure(b"hit your limit") is RateLimitError def test_classify_strips_ansi_before_matching() -> None: # Common SGR sequences should not block the marker. coloured = b"\x1b[1;31mYou've hit your limit\x1b[0m" assert classify_pty_failure(coloured) is RateLimitError def test_classify_accepts_str_or_bytes() -> None: assert classify_pty_failure("Failed to authenticate") is AuthError assert classify_pty_failure(b"Failed to authenticate") is AuthError def test_auth_and_rate_limit_default_messages() -> None: # Default messages are descriptive enough to surface to a gateway. assert "auth" in str(AuthError()).lower() assert "rate" in str(RateLimitError()).lower() or "limit" in str(RateLimitError()).lower() def test_session_error_is_plain_backend_error() -> None: # No special fields — just a typed marker. exc = SessionError("never appeared") assert isinstance(exc, BackendError) assert "never appeared" in str(exc) def test_message_parse_error_carries_data() -> None: payload = {"oops": True} exc = MessageParseError("bad shape", data=payload) assert exc.data is payload def test_session_error_is_not_a_timeout_error() -> None: # We deliberately broke the TimeoutError lineage: gateways that used to # catch TimeoutError must migrate to SessionError. Pin that. assert not issubclass(SessionError, TimeoutError) def test_raise_chain_smoke() -> None: with pytest.raises(AuthError): raise AuthError() with pytest.raises(BackendError): raise RateLimitError()