09e9f3545f
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
561 lines
23 KiB
Python
561 lines
23 KiB
Python
"""Tests for centralized CLI error handling."""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import notebooklm.cli._encoding as encoding_module
|
|
from notebooklm.cli.error_handler import (
|
|
_output_error,
|
|
emit_cancelled_and_exit,
|
|
exit_with_code,
|
|
handle_errors,
|
|
)
|
|
from notebooklm.exceptions import (
|
|
ArtifactNotFoundError,
|
|
ArtifactPendingTimeoutError,
|
|
AuthError,
|
|
ConfigurationError,
|
|
LabelNotFoundError,
|
|
MindMapNotFoundError,
|
|
NetworkError,
|
|
NotebookLimitError,
|
|
NotebookNotFoundError,
|
|
NoteNotFoundError,
|
|
NotFoundError,
|
|
RateLimitError,
|
|
RPCError,
|
|
SourceNotFoundError,
|
|
ValidationError,
|
|
)
|
|
from notebooklm.types import GenerationStatus
|
|
|
|
|
|
class TestHandleErrorsExitCodes:
|
|
"""Test that exceptions produce correct exit codes."""
|
|
|
|
def test_validation_error_exits_with_code_1(self):
|
|
"""ValidationError should exit with code 1 (user error)."""
|
|
with pytest.raises(SystemExit) as exc_info, handle_errors():
|
|
raise ValidationError("Invalid input")
|
|
assert exc_info.value.code == 1
|
|
|
|
def test_auth_error_exits_with_code_1(self):
|
|
"""AuthError should exit with code 1 (user error)."""
|
|
with pytest.raises(SystemExit) as exc_info, handle_errors():
|
|
raise AuthError("Token expired")
|
|
assert exc_info.value.code == 1
|
|
|
|
def test_config_error_exits_with_code_1(self):
|
|
"""ConfigurationError should exit with code 1 (user error)."""
|
|
with pytest.raises(SystemExit) as exc_info, handle_errors():
|
|
raise ConfigurationError("Missing config")
|
|
assert exc_info.value.code == 1
|
|
|
|
def test_network_error_exits_with_code_1(self):
|
|
"""NetworkError should exit with code 1 (user error)."""
|
|
with pytest.raises(SystemExit) as exc_info, handle_errors():
|
|
raise NetworkError("Connection failed")
|
|
assert exc_info.value.code == 1
|
|
|
|
def test_rate_limit_error_exits_with_code_1(self):
|
|
"""RateLimitError should exit with code 1 (user error)."""
|
|
with pytest.raises(SystemExit) as exc_info, handle_errors():
|
|
raise RateLimitError("Too many requests")
|
|
assert exc_info.value.code == 1
|
|
|
|
def test_unexpected_error_exits_with_code_2(self):
|
|
"""Unexpected exceptions should exit with code 2 (system error)."""
|
|
with pytest.raises(SystemExit) as exc_info, handle_errors():
|
|
raise RuntimeError("Unexpected bug")
|
|
assert exc_info.value.code == 2
|
|
|
|
def test_exit_with_code_is_canonical_raw_exit_path(self):
|
|
"""Callers that already emitted output can still exit through error_handler."""
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
exit_with_code(75)
|
|
|
|
assert exc_info.value.code == 75
|
|
|
|
|
|
class TestHandleErrorsJsonOutput:
|
|
"""Test JSON error output format."""
|
|
|
|
def test_validation_error_json_format(self, capsys):
|
|
"""ValidationError should produce correct JSON structure."""
|
|
with pytest.raises(SystemExit), handle_errors(json_output=True):
|
|
raise ValidationError("Invalid input")
|
|
|
|
output = capsys.readouterr().out
|
|
data = json.loads(output)
|
|
assert data["error"] is True
|
|
assert data["code"] == "VALIDATION_ERROR"
|
|
assert "Invalid input" in data["message"]
|
|
|
|
def test_rate_limit_error_json_includes_retry_after(self, capsys):
|
|
"""RateLimitError with retry_after should include it in JSON output."""
|
|
with pytest.raises(SystemExit), handle_errors(json_output=True):
|
|
raise RateLimitError("Too many requests", retry_after=30)
|
|
|
|
output = capsys.readouterr().out
|
|
data = json.loads(output)
|
|
assert data["error"] is True
|
|
assert data["code"] == "RATE_LIMITED"
|
|
assert data["retry_after"] == 30
|
|
assert "30s" in data["message"]
|
|
|
|
def test_rate_limit_error_json_without_retry_after(self, capsys):
|
|
"""RateLimitError without retry_after should not include extra field."""
|
|
with pytest.raises(SystemExit), handle_errors(json_output=True):
|
|
raise RateLimitError("Too many requests")
|
|
|
|
output = capsys.readouterr().out
|
|
data = json.loads(output)
|
|
assert data["error"] is True
|
|
assert data["code"] == "RATE_LIMITED"
|
|
assert "retry_after" not in data
|
|
|
|
def test_rpc_error_verbose_includes_method_id(self, capsys):
|
|
"""RPCError with verbose=True should include method_id in JSON."""
|
|
with pytest.raises(SystemExit), handle_errors(json_output=True, verbose=True):
|
|
raise RPCError("RPC failed", method_id="abc123")
|
|
|
|
output = capsys.readouterr().out
|
|
data = json.loads(output)
|
|
assert data["error"] is True
|
|
assert data["code"] == "NOTEBOOKLM_ERROR"
|
|
assert data["method_id"] == "abc123"
|
|
|
|
def test_rpc_error_non_verbose_excludes_method_id(self, capsys):
|
|
"""RPCError without verbose should not include method_id."""
|
|
with pytest.raises(SystemExit), handle_errors(json_output=True, verbose=False):
|
|
raise RPCError("RPC failed", method_id="abc123")
|
|
|
|
output = capsys.readouterr().out
|
|
data = json.loads(output)
|
|
assert data["error"] is True
|
|
assert "method_id" not in data
|
|
|
|
def test_notebook_limit_error_json_includes_quota_context(self, capsys):
|
|
"""NotebookLimitError should produce a specific JSON error code."""
|
|
with pytest.raises(SystemExit), handle_errors(json_output=True):
|
|
raise NotebookLimitError(499, limit=500)
|
|
|
|
output = capsys.readouterr().out
|
|
data = json.loads(output)
|
|
assert data["error"] is True
|
|
assert data["code"] == "NOTEBOOK_LIMIT"
|
|
assert data["current_count"] == 499
|
|
assert data["limit"] == 500
|
|
assert "known_limits" not in data
|
|
assert "method_id" not in data
|
|
assert "rpc_code" not in data
|
|
|
|
def test_artifact_timeout_json_includes_poll_context(self, capsys):
|
|
"""ArtifactTimeoutError should be a user error with structured context."""
|
|
with pytest.raises(SystemExit) as exc_info, handle_errors(json_output=True):
|
|
raise ArtifactPendingTimeoutError(
|
|
"nb_123",
|
|
"task_123",
|
|
600.0,
|
|
last_status="pending",
|
|
status_history=("pending",),
|
|
status_transitions=(
|
|
GenerationStatus(
|
|
"task_123",
|
|
"pending",
|
|
metadata={"raw_status": "completed", "media_ready": False},
|
|
),
|
|
),
|
|
)
|
|
|
|
assert exc_info.value.code == 1
|
|
output = capsys.readouterr().out
|
|
data = json.loads(output)
|
|
assert data["error"] is True
|
|
assert data["code"] == "ARTIFACT_TIMEOUT"
|
|
assert data["notebook_id"] == "nb_123"
|
|
assert data["task_id"] == "task_123"
|
|
assert data["timeout_seconds"] == 600.0
|
|
assert data["last_status"] == "pending"
|
|
assert data["status_history"] == ["pending"]
|
|
assert data["status_transitions"] == [
|
|
{
|
|
"task_id": "task_123",
|
|
"status": "pending",
|
|
"url": None,
|
|
"error": None,
|
|
"error_code": None,
|
|
"metadata": {"raw_status": "completed", "media_ready": False},
|
|
}
|
|
]
|
|
assert data["stalled_phase"] == "pending"
|
|
|
|
def test_unexpected_error_json_format(self, capsys):
|
|
"""Unexpected errors should produce UNEXPECTED_ERROR code."""
|
|
with pytest.raises(SystemExit), handle_errors(json_output=True):
|
|
raise RuntimeError("Something broke")
|
|
|
|
output = capsys.readouterr().out
|
|
data = json.loads(output)
|
|
assert data["error"] is True
|
|
assert data["code"] == "UNEXPECTED_ERROR"
|
|
assert "Something broke" in data["message"]
|
|
|
|
def test_error_handler_json_output_preserves_unicode(self, capsys):
|
|
"""CJK / emoji in error messages should be emitted as real UTF-8."""
|
|
with pytest.raises(SystemExit), handle_errors(json_output=True):
|
|
raise ValidationError("笔记本未找到 🔍")
|
|
|
|
output = capsys.readouterr().out
|
|
data = json.loads(output)
|
|
assert "笔记本未找到 🔍" in data["message"]
|
|
# Raw output must contain real CJK/emoji, not escaped sequences.
|
|
assert "笔记本未找到" in output
|
|
assert "🔍" in output
|
|
assert "\\u" not in output
|
|
|
|
def test_output_error_serializes_path_in_extra(self, capsys):
|
|
"""_output_error must not crash on non-primitive extras like pathlib.Path."""
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
_output_error(
|
|
"Bad path",
|
|
"PATH_ERROR",
|
|
json_output=True,
|
|
exit_code=1,
|
|
extra={"path": Path("tmp_test_path")},
|
|
)
|
|
|
|
assert exc_info.value.code == 1
|
|
output = capsys.readouterr().out
|
|
data = json.loads(output)
|
|
assert data["error"] is True
|
|
assert data["code"] == "PATH_ERROR"
|
|
assert data["message"] == "Bad path"
|
|
assert data["path"] == str(Path("tmp_test_path"))
|
|
|
|
|
|
class TestHandleErrorsNotFound:
|
|
"""The ``*NotFoundError`` family emits the typed ``NOT_FOUND`` envelope.
|
|
|
|
Regression guard for issue #1364: before the dedicated ``except
|
|
NotFoundError`` branch, any ``*NotFoundError`` reaching the centralized
|
|
handler fell through to the generic ``NOTEBOOKLM_ERROR`` catch-all. The
|
|
handler now emits ``code="NOT_FOUND"`` with exit ``1`` (matching the
|
|
per-command ``source``/``artifact``/``note get`` convention) and surfaces
|
|
the missing resource id in the JSON ``extra`` block.
|
|
"""
|
|
|
|
# (exception, native id key, id value) for each concrete subclass. All
|
|
# five derive ``(NotFoundError, RPCError, <Domain>Error)`` so the umbrella
|
|
# ``except NotFoundError`` catches every one.
|
|
_CASES = [
|
|
(NotebookNotFoundError("nb_123"), "notebook_id", "nb_123"),
|
|
(SourceNotFoundError("src_456"), "source_id", "src_456"),
|
|
(ArtifactNotFoundError("art_789", "audio"), "artifact_id", "art_789"),
|
|
(NoteNotFoundError("note_111"), "note_id", "note_111"),
|
|
(MindMapNotFoundError("mm_222"), "mind_map_id", "mm_222"),
|
|
(LabelNotFoundError("label_333"), "label_id", "label_333"),
|
|
]
|
|
|
|
@pytest.mark.parametrize(
|
|
("exc", "id_key", "id_value"),
|
|
_CASES,
|
|
ids=lambda v: type(v).__name__ if isinstance(v, Exception) else str(v),
|
|
)
|
|
def test_not_found_json_envelope(self, capsys, exc, id_key, id_value):
|
|
"""Each ``*NotFoundError`` produces NOT_FOUND + exit 1 + the resource id."""
|
|
with pytest.raises(SystemExit) as exc_info, handle_errors(json_output=True):
|
|
raise exc
|
|
|
|
assert exc_info.value.code == 1
|
|
data = json.loads(capsys.readouterr().out)
|
|
assert data["error"] is True
|
|
assert data["code"] == "NOT_FOUND"
|
|
# Native attribute key plus the generic ``id`` alias are both present so
|
|
# automation can read the id without knowing the exact subtype.
|
|
assert data[id_key] == id_value
|
|
assert data["id"] == id_value
|
|
|
|
@pytest.mark.parametrize(
|
|
("exc", "id_key", "id_value"),
|
|
_CASES,
|
|
ids=lambda v: type(v).__name__ if isinstance(v, Exception) else str(v),
|
|
)
|
|
def test_not_found_exit_code_is_1(self, exc, id_key, id_value):
|
|
"""Not-found is a user error (exit 1), never the system-error exit 2."""
|
|
with pytest.raises(SystemExit) as exc_info, handle_errors():
|
|
raise exc
|
|
assert exc_info.value.code == 1
|
|
|
|
def test_not_found_text_mode(self, capsys):
|
|
"""Text mode prints the exception message (no traceback, exit 1)."""
|
|
with pytest.raises(SystemExit) as exc_info, handle_errors(json_output=False):
|
|
raise SourceNotFoundError("src_456")
|
|
|
|
assert exc_info.value.code == 1
|
|
output = capsys.readouterr().err
|
|
assert "Source not found: src_456" in output
|
|
|
|
def test_not_found_candidates_in_json_and_text_hint(self, capsys):
|
|
"""Near-miss candidates (issue #1787) surface in JSON and as a text hint."""
|
|
err = NotebookNotFoundError("Scientific")
|
|
err.candidates = [{"id": "37fe5c1d", "title": "Scientific PDF Parsing"}]
|
|
with pytest.raises(SystemExit), handle_errors(json_output=True):
|
|
raise err
|
|
data = json.loads(capsys.readouterr().out)
|
|
assert data["code"] == "NOT_FOUND"
|
|
assert data["candidates"] == [{"id": "37fe5c1d", "title": "Scientific PDF Parsing"}]
|
|
|
|
err2 = NotebookNotFoundError("Scientific")
|
|
err2.candidates = [{"id": "37fe5c1d", "title": "Scientific PDF Parsing"}]
|
|
with pytest.raises(SystemExit), handle_errors(json_output=False):
|
|
raise err2
|
|
text = capsys.readouterr().err
|
|
assert "Did you mean:" in text
|
|
assert "Scientific PDF Parsing" in text
|
|
|
|
def test_not_found_without_candidates_omits_candidates_key(self, capsys):
|
|
with pytest.raises(SystemExit), handle_errors(json_output=True):
|
|
raise NotebookNotFoundError("Scientific")
|
|
data = json.loads(capsys.readouterr().out)
|
|
assert "candidates" not in data
|
|
|
|
def test_not_found_verbose_includes_method_id(self, capsys):
|
|
"""With ``verbose``, the RPC ``method_id`` is surfaced in the envelope."""
|
|
with pytest.raises(SystemExit), handle_errors(json_output=True, verbose=True):
|
|
raise SourceNotFoundError("src_456", method_id="abc123")
|
|
|
|
data = json.loads(capsys.readouterr().out)
|
|
assert data["code"] == "NOT_FOUND"
|
|
assert data["method_id"] == "abc123"
|
|
|
|
def test_not_found_non_verbose_excludes_method_id(self, capsys):
|
|
"""Without ``verbose``, ``method_id`` stays out of the envelope."""
|
|
with pytest.raises(SystemExit), handle_errors(json_output=True, verbose=False):
|
|
raise SourceNotFoundError("src_456", method_id="abc123")
|
|
|
|
data = json.loads(capsys.readouterr().out)
|
|
assert data["code"] == "NOT_FOUND"
|
|
assert "method_id" not in data
|
|
|
|
def test_not_found_branch_precedes_generic_rpc_error(self, capsys):
|
|
"""A bare ``NotFoundError`` umbrella instance still maps to NOT_FOUND.
|
|
|
|
Confirms the dedicated branch sits before the generic
|
|
``except NotebookLMError`` (the catch-all that would otherwise emit
|
|
``NOTEBOOKLM_ERROR``). A future ``*NotFoundError`` subclass with no
|
|
recognized id attribute drops the (empty) ``extra`` cleanly.
|
|
"""
|
|
with pytest.raises(SystemExit), handle_errors(json_output=True):
|
|
raise NotFoundError("nothing here")
|
|
|
|
data = json.loads(capsys.readouterr().out)
|
|
assert data["code"] == "NOT_FOUND"
|
|
assert "id" not in data
|
|
|
|
def test_generic_rpc_error_still_emits_notebooklm_error(self, capsys):
|
|
"""A non-not-found ``RPCError`` is unaffected — still NOTEBOOKLM_ERROR.
|
|
|
|
Guards against the new branch widening too far and swallowing ordinary
|
|
RPC failures.
|
|
"""
|
|
with pytest.raises(SystemExit), handle_errors(json_output=True):
|
|
raise RPCError("RPC failed")
|
|
|
|
data = json.loads(capsys.readouterr().out)
|
|
assert data["code"] == "NOTEBOOKLM_ERROR"
|
|
|
|
|
|
class TestHandleErrorsTextOutput:
|
|
"""Test text error output with hints."""
|
|
|
|
def test_auth_error_shows_hint(self, capsys):
|
|
"""AuthError should show re-authentication hint in text mode."""
|
|
with pytest.raises(SystemExit), handle_errors(json_output=False):
|
|
raise AuthError("Token expired")
|
|
|
|
output = capsys.readouterr().err
|
|
assert "Authentication error" in output
|
|
assert "notebooklm login" in output
|
|
|
|
def test_network_error_shows_hint(self, capsys):
|
|
"""NetworkError should show connection hint in text mode."""
|
|
with pytest.raises(SystemExit), handle_errors(json_output=False):
|
|
raise NetworkError("Connection refused")
|
|
|
|
output = capsys.readouterr().err
|
|
assert "Network error" in output
|
|
assert "internet connection" in output
|
|
|
|
def test_notebook_limit_error_text_includes_quota_context(self, capsys):
|
|
"""NotebookLimitError should show notebook count in text mode."""
|
|
with pytest.raises(SystemExit), handle_errors(json_output=False):
|
|
raise NotebookLimitError(499, limit=500)
|
|
|
|
output = capsys.readouterr().err
|
|
assert "notebook limit" in output.lower()
|
|
assert "499/500" in output
|
|
|
|
def test_artifact_timeout_text_includes_poll_context(self, capsys):
|
|
"""ArtifactTimeoutError should remain a user error in text mode."""
|
|
with pytest.raises(SystemExit) as exc_info, handle_errors(json_output=False):
|
|
raise ArtifactPendingTimeoutError(
|
|
"nb_123",
|
|
"task_123",
|
|
600.0,
|
|
last_status="pending",
|
|
status_history=("pending",),
|
|
)
|
|
|
|
assert exc_info.value.code == 1
|
|
output = capsys.readouterr().err
|
|
assert "Artifact timeout" in output
|
|
assert "task_123" in output
|
|
assert "last status: pending" in output
|
|
|
|
def test_unexpected_error_shows_bug_report_hint(self, capsys):
|
|
"""Unexpected errors should show bug report hint."""
|
|
with pytest.raises(SystemExit), handle_errors(json_output=False):
|
|
raise RuntimeError("Oops")
|
|
|
|
output = capsys.readouterr().err
|
|
assert "Unexpected error" in output
|
|
assert "bug" in output.lower()
|
|
assert "github" in output.lower()
|
|
|
|
def test_hint_not_shown_in_json_mode(self, capsys):
|
|
"""Hints should not appear in JSON output."""
|
|
with pytest.raises(SystemExit), handle_errors(json_output=True):
|
|
raise AuthError("Token expired")
|
|
|
|
output = capsys.readouterr().out
|
|
data = json.loads(output)
|
|
# Hint text should not be in the JSON structure
|
|
assert "login" not in json.dumps(data).lower()
|
|
|
|
def test_text_output_falls_back_when_stream_cannot_encode(self, monkeypatch):
|
|
"""Error reporting should not mask the original error with UnicodeEncodeError."""
|
|
|
|
class DummyStderr:
|
|
encoding = "cp950"
|
|
|
|
calls = []
|
|
|
|
def flaky_echo(message=None, **kwargs):
|
|
err = kwargs.get("err", False)
|
|
if not calls:
|
|
calls.append((message, err))
|
|
raise UnicodeEncodeError(
|
|
"cp950",
|
|
str(message),
|
|
0,
|
|
1,
|
|
"illegal multibyte sequence",
|
|
)
|
|
calls.append((message, err))
|
|
|
|
monkeypatch.setattr(encoding_module.click, "echo", flaky_echo)
|
|
monkeypatch.setattr(encoding_module.sys, "stderr", DummyStderr())
|
|
|
|
with pytest.raises(SystemExit), handle_errors(json_output=False):
|
|
raise RuntimeError("bad 🌐")
|
|
|
|
assert calls[0] == ("Unexpected error: bad 🌐", True)
|
|
assert calls[1] == ("Unexpected error: bad ?", True)
|
|
|
|
|
|
class TestHandleErrorsKeyboardInterrupt:
|
|
"""Test keyboard interrupt handling."""
|
|
|
|
def test_keyboard_interrupt_exits_with_code_130(self):
|
|
"""KeyboardInterrupt should exit with code 130."""
|
|
with pytest.raises(SystemExit) as exc_info, handle_errors():
|
|
raise KeyboardInterrupt()
|
|
assert exc_info.value.code == 130
|
|
|
|
def test_keyboard_interrupt_json_format(self, capsys):
|
|
"""KeyboardInterrupt should produce CANCELLED code in JSON mode."""
|
|
with pytest.raises(SystemExit), handle_errors(json_output=True):
|
|
raise KeyboardInterrupt()
|
|
|
|
output = capsys.readouterr().out
|
|
data = json.loads(output)
|
|
assert data["error"] is True
|
|
assert data["code"] == "CANCELLED"
|
|
|
|
|
|
class TestEmitCancelledAndExit:
|
|
"""Tests for the SIGINT-with-resume-hint helper.
|
|
|
|
``emit_cancelled_and_exit`` is the canonical exit point for Ctrl-C during
|
|
a long-running ``--wait`` poll. The helper enforces the required user-
|
|
visible phrasing: ``Cancelled. Resume with: <resume_hint>`` and exit 130.
|
|
"""
|
|
|
|
def test_emit_cancelled_with_hint_writes_to_stderr_and_exits_130(self, capsys):
|
|
"""Text mode: prints the canonical resume line on stderr, exits 130."""
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
emit_cancelled_and_exit("notebooklm artifact poll task_abc")
|
|
|
|
assert exc_info.value.code == 130
|
|
captured = capsys.readouterr()
|
|
# Specification: SIGINT under --wait emits exactly this resume line.
|
|
# Used as a literal so a future cosmetic tweak can't silently drift
|
|
# the user-facing string.
|
|
assert "Cancelled. Resume with: notebooklm artifact poll task_abc" in captured.err
|
|
assert captured.out == "", "resume hint must NOT leak onto stdout in text mode"
|
|
|
|
def test_emit_cancelled_without_hint_falls_back_to_plain_cancelled(self, capsys):
|
|
"""No hint: emits the bare ``Cancelled.`` line (matches generic handler)."""
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
emit_cancelled_and_exit(None)
|
|
|
|
assert exc_info.value.code == 130
|
|
captured = capsys.readouterr()
|
|
assert "Cancelled." in captured.err
|
|
assert "Resume with" not in captured.err
|
|
|
|
def test_emit_cancelled_json_envelope_includes_resume_hint(self, capsys):
|
|
"""JSON mode: structured envelope with code=CANCELLED + resume_hint, exit 130."""
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
emit_cancelled_and_exit(
|
|
"notebooklm artifact poll task_xyz",
|
|
json_output=True,
|
|
)
|
|
|
|
assert exc_info.value.code == 130
|
|
captured = capsys.readouterr()
|
|
data = json.loads(captured.out)
|
|
assert data["error"] is True
|
|
assert data["code"] == "CANCELLED"
|
|
assert data["resume_hint"] == "notebooklm artifact poll task_xyz"
|
|
assert "message" in data
|
|
|
|
def test_emit_cancelled_json_omits_resume_hint_when_none(self, capsys):
|
|
"""JSON mode without a hint: envelope still parses but no resume_hint key."""
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
emit_cancelled_and_exit(None, json_output=True)
|
|
|
|
assert exc_info.value.code == 130
|
|
captured = capsys.readouterr()
|
|
data = json.loads(captured.out)
|
|
assert data["error"] is True
|
|
assert data["code"] == "CANCELLED"
|
|
assert "resume_hint" not in data
|
|
|
|
def test_emit_cancelled_json_extra_merged_into_envelope(self, capsys):
|
|
"""JSON mode: ``extra`` dict is merged so callers can attach a task_id."""
|
|
with pytest.raises(SystemExit):
|
|
emit_cancelled_and_exit(
|
|
"notebooklm artifact poll task_abc",
|
|
json_output=True,
|
|
extra={"task_id": "task_abc"},
|
|
)
|
|
|
|
captured = capsys.readouterr()
|
|
data = json.loads(captured.out)
|
|
assert data["task_id"] == "task_abc"
|
|
assert data["resume_hint"] == "notebooklm artifact poll task_abc"
|