项目文件夹

文件
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

2203 行
97 KiB
Python

"""Tests for generate CLI commands."""
import asyncio
import importlib
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from click.testing import CliRunner
import notebooklm.auth as auth_module
from notebooklm._app.generate_retry import (
GenerationOutcome,
)
from notebooklm.cli import helpers as helpers_module
from notebooklm.cli.polling_ui import status_with_elapsed
from notebooklm.notebooklm_cli import cli
from notebooklm.rpc.types import ReportFormat
from .conftest import create_mock_client, inject_client, mind_map_result
# ``notebooklm.cli.generate_cmd`` (the module) is shadowed by ``cli.__init__``'s
# re-export of the ``generate`` Click Group (same name). Use ``importlib`` so
# tests target the module's attribute set (``console``, helpers) rather than
# the Click Group sitting at the same dotted path.
generate_module = importlib.import_module("notebooklm.cli.generate_cmd")
polling_ui_module = importlib.import_module("notebooklm.cli.polling_ui")
@pytest.fixture
def runner():
return CliRunner()
@pytest.fixture
def mock_auth():
with patch.object(helpers_module, "load_auth_from_storage") as mock:
mock.return_value = {
"SID": "test",
"HSID": "test",
"SSID": "test",
"APISID": "test",
"SAPISID": "test",
}
yield mock
# =============================================================================
# PER-TYPE SMOKE TESTS (PARAMETRIZED)
# =============================================================================
#
# The bare "patch client -> mock generate_<type> -> invoke -> assert" smoke
# tests for every artifact type collapse into one parametrize over
# ``(cmd, method, task_id, extra_args)`` crossed with text/JSON output mode.
# Each row exercises both the text-mode happy path (exit 0 + task id surfaced)
# and the ``--json`` envelope (parseable ``task_id``), replacing the former
# per-type ``test_generate_<type>`` + ``TestGenerateJsonOutput`` clusters
# (issues #1315 and #1317). Tests that assert option-specific kwargs, distinct
# return structures, or wait/timeout behavior remain standalone below.
# (cmd, method, task_id, extra_args) — extra_args carries the required
# positional description for commands that need one (data-table).
_STANDARD_GENERATE_CASES = [
("audio", "generate_audio", "audio_123", []),
("video", "generate_video", "video_123", []),
("cinematic-video", "generate_cinematic_video", "cin_123", []),
("quiz", "generate_quiz", "quiz_123", []),
("flashcards", "generate_flashcards", "flash_123", []),
("slide-deck", "generate_slide_deck", "slides_123", []),
("infographic", "generate_infographic", "info_123", []),
("data-table", "generate_data_table", "table_123", ["Compare key concepts"]),
("report", "generate_report", "report_123", []),
]
class TestGenerateStandardTypes:
"""Per-type happy-path smoke coverage across text and JSON output modes."""
@pytest.mark.parametrize("output_mode", ["text", "json"])
@pytest.mark.parametrize(
"cmd,method,task_id,extra_args",
_STANDARD_GENERATE_CASES,
ids=[case[0] for case in _STANDARD_GENERATE_CASES],
)
def test_generate_standard_type(
self, runner, mock_auth, output_mode, cmd, method, task_id, extra_args
):
mock_client = create_mock_client()
setattr(
mock_client.artifacts,
method,
AsyncMock(return_value={"task_id": task_id, "status": "processing"}),
)
args = ["generate", cmd, *extra_args, "-n", "nb_123"]
if output_mode == "json":
args.append("--json")
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(cli, args, obj=inject_client(mock_client))
assert result.exit_code == 0, result.output
if output_mode == "json":
data = json.loads(result.output)
assert data["task_id"] == task_id
else:
assert task_id in result.output or "Started" in result.output
# =============================================================================
# GENERATE AUDIO TESTS
# =============================================================================
class TestGenerateAudio:
def test_generate_audio_with_format(self, runner, mock_auth):
mock_client = create_mock_client()
mock_client.artifacts.generate_audio = AsyncMock(
return_value={"artifact_id": "audio_123", "status": "processing"}
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
["generate", "audio", "--format", "debate", "-n", "nb_123"],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
mock_client.artifacts.generate_audio.assert_called()
def test_generate_audio_with_length(self, runner, mock_auth):
mock_client = create_mock_client()
mock_client.artifacts.generate_audio = AsyncMock(
return_value={"artifact_id": "audio_123", "status": "processing"}
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
["generate", "audio", "--length", "long", "-n", "nb_123"],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
def test_generate_audio_with_wait(self, runner, mock_auth):
mock_client = create_mock_client()
mock_client.artifacts.generate_audio = AsyncMock(
return_value={"artifact_id": "audio_123", "status": "processing"}
)
completed_status = MagicMock()
completed_status.is_complete = True
completed_status.is_failed = False
completed_status.is_removed = False
completed_status.url = "https://example.com/audio.mp3"
completed_status.artifact_id = "audio_123"
mock_client.artifacts.wait_for_completion = AsyncMock(return_value=completed_status)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["generate", "audio", "--wait", "-n", "nb_123"], obj=inject_client(mock_client)
)
assert result.exit_code == 0
assert "Audio ready" in result.output
assert "https://example.com/audio.mp3" in result.output
mock_client.artifacts.wait_for_completion.assert_awaited_once()
kwargs = mock_client.artifacts.wait_for_completion.await_args.kwargs
assert kwargs.get("timeout") == 1200.0
def test_generate_audio_with_wait_timeout_interval_forwarded(self, runner, mock_auth):
"""`generate audio --wait --timeout 60 --interval 5` plumbs both into
artifacts.wait_for_completion.
The new `--timeout`/`--interval` flags must reach the polling call so
that scripts can bound the wait and the cadence — not just toggle the
wait on/off as the legacy `--wait` flag did. The CLI surface is
uniform with `artifact wait` / `source wait` after this change.
"""
mock_client = create_mock_client()
mock_client.artifacts.generate_audio = AsyncMock(
return_value={"artifact_id": "audio_xyz", "status": "processing"}
)
completed_status = MagicMock()
completed_status.is_complete = True
completed_status.is_failed = False
completed_status.is_removed = False
completed_status.url = "https://example.com/audio.mp3"
completed_status.task_id = "audio_xyz"
mock_client.artifacts.wait_for_completion = AsyncMock(return_value=completed_status)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
[
"generate",
"audio",
"--wait",
"--timeout",
"60",
"--interval",
"5",
"-n",
"nb_123",
],
obj=inject_client(mock_client),
)
assert result.exit_code == 0, result.output
mock_client.artifacts.wait_for_completion.assert_awaited_once()
kwargs = mock_client.artifacts.wait_for_completion.await_args.kwargs
assert kwargs.get("timeout") == 60.0
assert kwargs.get("initial_interval") == 5.0, (
f"expected --interval=5 to plumb into wait_for_completion, got kwargs={kwargs}"
)
assert "poll_interval" not in kwargs
def test_generate_audio_timeout_interval_without_wait_is_no_op(self, runner, mock_auth):
"""`generate audio --timeout 60 --interval 5` (without --wait) is
accepted but does not call wait_for_completion.
The polling flags only take effect when paired with --wait; supplying
them without --wait must NOT trigger a wait (preserves the default
no-wait behavior promised by the original `--wait/--no-wait` toggle).
"""
mock_client = create_mock_client()
mock_client.artifacts.generate_audio = AsyncMock(
return_value={"artifact_id": "audio_xyz", "status": "processing"}
)
mock_client.artifacts.wait_for_completion = AsyncMock()
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
[
"generate",
"audio",
"--timeout",
"60",
"--interval",
"5",
"-n",
"nb_123",
],
obj=inject_client(mock_client),
)
assert result.exit_code == 0, result.output
mock_client.artifacts.wait_for_completion.assert_not_awaited()
def test_generate_audio_with_wait_invokes_console_status(self, runner, mock_auth):
"""`generate audio --wait` wraps the polling call in `console.status`.
The spinner gives interactive users feedback during the long wait, with
a transient line naming the artifact kind (and a typical-duration hint).
Asserts the wrap by patching `notebooklm.cli.polling_ui.console.status`
and confirming it is invoked exactly once with a message that mentions
the artifact kind. Does not assert the elapsed-timer ticker — that's a
rendering detail that relies on a TTY which `CliRunner` doesn't have.
"""
mock_client = create_mock_client()
mock_client.artifacts.generate_audio = AsyncMock(
return_value={"artifact_id": "audio_xyz", "status": "processing"}
)
completed_status = MagicMock()
completed_status.is_complete = True
completed_status.is_failed = False
completed_status.is_removed = False
completed_status.url = "https://example.com/audio.mp3"
completed_status.task_id = "audio_xyz"
mock_client.artifacts.wait_for_completion = AsyncMock(return_value=completed_status)
with (
patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch,
patch.object(polling_ui_module.console, "status") as mock_status,
):
mock_fetch.return_value = ("csrf", "session")
# ``console.status`` returns a context manager; emulate one so
# the wrapped polling call still runs.
mock_status.return_value.__enter__ = MagicMock(return_value=MagicMock())
mock_status.return_value.__exit__ = MagicMock(return_value=False)
result = runner.invoke(
cli, ["generate", "audio", "--wait", "-n", "nb_123"], obj=inject_client(mock_client)
)
assert result.exit_code == 0, result.output
assert mock_status.called, "expected console.status to wrap the --wait polling call"
status_msg = mock_status.call_args.args[0]
assert "audio" in status_msg.lower(), (
f"expected status message to mention artifact kind 'audio', got: {status_msg!r}"
)
def test_generate_audio_failure(self, runner, mock_auth):
mock_client = create_mock_client()
mock_client.artifacts.generate_audio = AsyncMock(return_value=None)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["generate", "audio", "-n", "nb_123"], obj=inject_client(mock_client)
)
# P1.T6: failed generation now exits non-zero in text mode (was 0
# pre-fix). Message lands on stderr via ``output_error`` →
# ``safe_echo(err=True)``.
assert result.exit_code != 0
assert "Audio generation failed" in result.stderr
# =============================================================================
# GENERATE VIDEO TESTS
# =============================================================================
class TestGenerateVideo:
def test_generate_video_with_style(self, runner, mock_auth):
mock_client = create_mock_client()
mock_client.artifacts.generate_video = AsyncMock(
return_value={"artifact_id": "video_123", "status": "processing"}
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
["generate", "video", "--style", "kawaii", "-n", "nb_123"],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
def test_generate_video_with_custom_style_prompt(self, runner, mock_auth):
mock_client = create_mock_client()
mock_client.artifacts.generate_video = AsyncMock(
return_value={"artifact_id": "video_123", "status": "processing"}
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
[
"generate",
"video",
"--style",
"custom",
"--style-prompt",
" Use hand-drawn diagrams ",
"-n",
"nb_123",
],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
mock_client.artifacts.generate_video.assert_awaited_once()
kwargs = mock_client.artifacts.generate_video.await_args.kwargs
assert kwargs["video_style"].name == "CUSTOM"
assert kwargs["style_prompt"] == "Use hand-drawn diagrams"
def test_generate_video_custom_style_requires_prompt(
self, runner, mock_auth, mock_fetch_tokens
):
result = runner.invoke(
cli,
["generate", "video", "--style", "custom", "-n", "nb_123"],
)
# Per ADR-0015, post-parse validation failures exit 1 via
# ``output_error`` (VALIDATION_ERROR), not 2 via Click's UsageError.
assert result.exit_code == 1
assert "--style custom requires --style-prompt" in result.output
def test_generate_video_custom_style_rejects_blank_prompt(
self, runner, mock_auth, mock_fetch_tokens
):
result = runner.invoke(
cli,
[
"generate",
"video",
"--style",
"custom",
"--style-prompt",
" ",
"-n",
"nb_123",
],
)
assert result.exit_code == 1
assert "--style custom requires --style-prompt" in result.output
def test_generate_video_style_prompt_requires_custom_style(
self, runner, mock_auth, mock_fetch_tokens
):
result = runner.invoke(
cli,
[
"generate",
"video",
"--style",
"anime",
"--style-prompt",
"Use hand-drawn diagrams",
"-n",
"nb_123",
],
)
assert result.exit_code == 1
assert "--style-prompt requires --style custom" in result.output
# =============================================================================
# GENERATE CINEMATIC VIDEO TESTS
# =============================================================================
class TestGenerateCinematicVideo:
def test_generate_cinematic_video_with_description(self, runner, mock_auth):
mock_client = create_mock_client()
mock_client.artifacts.generate_cinematic_video = AsyncMock(
return_value={"artifact_id": "cin_123", "status": "processing"}
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
[
"generate",
"cinematic-video",
"documentary about quantum physics",
"-n",
"nb_123",
],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
def test_generate_cinematic_video_ignores_style(self, runner, mock_auth):
"""Cinematic video accepts --style (inherited from video) but ignores it."""
mock_client = create_mock_client()
mock_client.artifacts.generate_cinematic_video = AsyncMock(
return_value={"artifact_id": "cin_123", "status": "processing"}
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
["generate", "cinematic-video", "--style", "anime", "-n", "nb_123"],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
# Should call generate_cinematic_video (not generate_video) despite --style
mock_client.artifacts.generate_cinematic_video.assert_called_once()
def test_generate_cinematic_video_rejects_style_prompt(
self, runner, mock_auth, mock_fetch_tokens
):
result = runner.invoke(
cli,
[
"generate",
"cinematic-video",
"--style-prompt",
"Use hand-drawn diagrams",
"-n",
"nb_123",
],
)
# Per ADR-0015, post-parse validation exits 1 via ``output_error``.
assert result.exit_code == 1
assert "--style-prompt cannot be used with cinematic video" in result.output
def test_generate_cinematic_video_rejects_non_cinematic_format(
self, runner, mock_auth, mock_fetch_tokens
):
"""`cinematic-video --format explainer` (or any non-cinematic value) is
rejected through ``output_error`` (per ADR-0015) — exit 1, not a silent
format override."""
for bad_format in ("explainer", "brief"):
result = runner.invoke(
cli,
[
"generate",
"cinematic-video",
"--format",
bad_format,
"-n",
"nb_123",
],
)
assert result.exit_code == 1, (
f"--format {bad_format} should exit 1, got {result.exit_code}: {result.output}"
)
assert "--format" in result.output
assert "cinematic" in result.output.lower()
def test_generate_cinematic_video_explicit_cinematic_format_ok(self, runner, mock_auth):
"""`cinematic-video --format cinematic` is the canonical happy path."""
mock_client = create_mock_client()
mock_client.artifacts.generate_cinematic_video = AsyncMock(
return_value={"artifact_id": "cin_123", "status": "processing"}
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
[
"generate",
"cinematic-video",
"--format",
"cinematic",
"-n",
"nb_123",
],
obj=inject_client(mock_client),
)
assert result.exit_code == 0, result.output
mock_client.artifacts.generate_cinematic_video.assert_called_once()
def test_generate_cinematic_video_default_format_ok(self, runner, mock_auth):
"""`cinematic-video` with no --format defaults to cinematic and works."""
mock_client = create_mock_client()
mock_client.artifacts.generate_cinematic_video = AsyncMock(
return_value={"artifact_id": "cin_123", "status": "processing"}
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["generate", "cinematic-video", "-n", "nb_123"], obj=inject_client(mock_client)
)
assert result.exit_code == 0, result.output
mock_client.artifacts.generate_cinematic_video.assert_called_once()
def test_generate_cinematic_video_help_documents_format_constraint(self, runner):
"""`cinematic-video --help` must surface the --format constraint."""
result = runner.invoke(cli, ["generate", "cinematic-video", "--help"])
assert result.exit_code == 0
# The help should make it explicit that --format must be 'cinematic' for
# this subcommand.
assert "--format" in result.output
assert "cinematic" in result.output.lower()
assert "cinematic format defaults to 3600" in result.output
# =============================================================================
# GENERATE QUIZ TESTS
# =============================================================================
class TestGenerateQuiz:
def test_generate_quiz_with_options(self, runner, mock_auth):
mock_client = create_mock_client()
mock_client.artifacts.generate_quiz = AsyncMock(
return_value={"artifact_id": "quiz_123", "status": "processing"}
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
[
"generate",
"quiz",
"--quantity",
"more",
"--difficulty",
"hard",
"-n",
"nb_123",
],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
# =============================================================================
# GENERATE SLIDE DECK TESTS
# =============================================================================
class TestGenerateSlideDeck:
def test_generate_slide_deck_with_options(self, runner, mock_auth):
mock_client = create_mock_client()
mock_client.artifacts.generate_slide_deck = AsyncMock(
return_value={"artifact_id": "slides_123", "status": "processing"}
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
[
"generate",
"slide-deck",
"--format",
"presenter",
"--length",
"short",
"-n",
"nb_123",
],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
# =============================================================================
# GENERATE INFOGRAPHIC TESTS
# =============================================================================
class TestGenerateInfographic:
def test_generate_infographic_with_options(self, runner, mock_auth):
mock_client = create_mock_client()
mock_client.artifacts.generate_infographic = AsyncMock(
return_value={"artifact_id": "info_123", "status": "processing"}
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
[
"generate",
"infographic",
"--orientation",
"portrait",
"--detail",
"detailed",
"--style",
"anime",
"-n",
"nb_123",
],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
mock_client.artifacts.generate_infographic.assert_awaited_once()
kwargs = mock_client.artifacts.generate_infographic.await_args.kwargs
assert kwargs["orientation"].name == "PORTRAIT"
assert kwargs["detail_level"].name == "DETAILED"
assert kwargs["style"].name == "ANIME"
# =============================================================================
# GENERATE MIND MAP TESTS
# =============================================================================
class TestGenerateMindMap:
def test_generate_mind_map_note_backed(self, runner, mock_auth):
"""--kind note-backed routes through client.artifacts.generate_mind_map."""
mock_client = create_mock_client()
mock_client.artifacts.generate_mind_map = AsyncMock(
return_value=mind_map_result(
{"mind_map": {"name": "Root", "children": []}, "note_id": "n1"}
)
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
["generate", "mind-map", "--kind", "note-backed", "-n", "nb_123"],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
mock_client.artifacts.generate_mind_map.assert_awaited_once()
mock_client.mind_maps.generate.assert_not_called()
def test_generate_mind_map_interactive(self, runner, mock_auth):
"""--interactive routes through client.mind_maps.generate(kind=INTERACTIVE)."""
from notebooklm.types import MindMap, MindMapKind
mock_client = create_mock_client()
mock_client.mind_maps.generate = AsyncMock(
return_value=MindMap(
id="art_42",
notebook_id="nb_123",
title="Interactive Mind Map",
kind=MindMapKind.INTERACTIVE,
)
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
["generate", "mind-map", "--kind", "interactive", "-n", "nb_123"],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
# Interactive path dispatches to the unified API, not the note-backed
# artifacts.generate_mind_map.
mock_client.artifacts.generate_mind_map.assert_not_called()
mock_client.mind_maps.generate.assert_awaited_once()
assert mock_client.mind_maps.generate.await_args.kwargs["kind"] == MindMapKind.INTERACTIVE
assert "art_42" in result.output
def test_generate_mind_map_interactive_json(self, runner, mock_auth):
"""--kind interactive --json emits the converged {mind_map, note_id, kind} shape."""
from notebooklm.types import MindMap, MindMapKind
mock_client = create_mock_client()
mock_client.mind_maps.generate = AsyncMock(
return_value=MindMap(
id="art_42",
notebook_id="nb_123",
title="Interactive Mind Map",
kind=MindMapKind.INTERACTIVE,
tree={"name": "Root", "children": []},
)
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
["generate", "mind-map", "--kind", "interactive", "--json", "-n", "nb_123"],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
data = json.loads(result.output)
# Converged shape: id under note_id, tree under mind_map, plus kind.
assert data["note_id"] == "art_42"
assert data["kind"] == "interactive"
assert data["mind_map"] == {"name": "Root", "children": []}
def test_generate_mind_map_interactive_forwards_instructions(self, runner, mock_auth):
"""--kind interactive with --instructions forwards the prompt (no drop/warning).
The interactive CREATE_ARTIFACT payload carries a prompt slot at
``[9][1][2]`` that the server honors for variant 4 (verified live), so the
CLI must thread ``--instructions`` through instead of dropping it.
"""
from notebooklm.types import MindMap, MindMapKind
mock_client = create_mock_client()
mock_client.mind_maps.generate = AsyncMock(
return_value=MindMap(
id="art_42",
notebook_id="nb_123",
title="Interactive Mind Map",
kind=MindMapKind.INTERACTIVE,
)
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
[
"generate",
"mind-map",
"--kind",
"interactive",
"--instructions",
"focus on chapter 3",
"-n",
"nb_123",
],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
assert "--instructions is ignored" not in result.output
# Behaviour: the interactive generator call forwards the prompt.
mock_client.mind_maps.generate.assert_awaited_once()
call_kwargs = mock_client.mind_maps.generate.await_args.kwargs
assert call_kwargs.get("instructions") == "focus on chapter 3"
def test_generate_mind_map_interactive_json_forwards_instructions(self, runner, mock_auth):
"""Under --json the interactive prompt is forwarded and stdout stays pure JSON.
No behavioral warning is emitted anymore (the prompt is applied, not
dropped), and stdout remains a parseable JSON payload.
"""
from notebooklm.types import MindMap, MindMapKind
mock_client = create_mock_client()
mock_client.mind_maps.generate = AsyncMock(
return_value=MindMap(
id="art_42",
notebook_id="nb_123",
title="Interactive Mind Map",
kind=MindMapKind.INTERACTIVE,
)
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
[
"generate",
"mind-map",
"--kind",
"interactive",
"--instructions",
"focus on chapter 3",
"-n",
"nb_123",
"--json",
],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
# No dropped-instructions warning anywhere (the prompt is applied now).
assert "--instructions is ignored" not in result.stderr
assert "--instructions is ignored" not in result.stdout
# stdout stays pure, parseable JSON.
payload = json.loads(result.stdout)
assert payload["kind"] == "interactive"
# Behaviour: instructions ARE forwarded to the interactive generator.
mock_client.mind_maps.generate.assert_awaited_once()
assert (
mock_client.mind_maps.generate.await_args.kwargs.get("instructions")
== "focus on chapter 3"
)
def test_generate_mind_map_default_routes_interactive(self, runner, mock_auth):
"""Omitting --kind now defaults to the interactive studio-artifact path (#1272)."""
from notebooklm.types import MindMap, MindMapKind
mock_client = create_mock_client()
mock_client.mind_maps.generate = AsyncMock(
return_value=MindMap(
id="art_42",
notebook_id="nb_123",
title="Interactive Mind Map",
kind=MindMapKind.INTERACTIVE,
)
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["generate", "mind-map", "-n", "nb_123"], obj=inject_client(mock_client)
)
assert result.exit_code == 0
# The bare default dispatches to the unified interactive API, not the
# note-backed artifacts.generate_mind_map.
mock_client.mind_maps.generate.assert_awaited_once()
assert mock_client.mind_maps.generate.await_args.kwargs["kind"] == MindMapKind.INTERACTIVE
mock_client.artifacts.generate_mind_map.assert_not_called()
assert "art_42" in result.output
# =============================================================================
# GENERATE REPORT TESTS
# =============================================================================
class TestGenerateReport:
def test_generate_report_study_guide(self, runner, mock_auth):
mock_client = create_mock_client()
mock_client.artifacts.generate_report = AsyncMock(
return_value={"artifact_id": "report_123", "status": "processing"}
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
["generate", "report", "--format", "study-guide", "-n", "nb_123"],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
def test_generate_report_custom(self, runner, mock_auth):
mock_client = create_mock_client()
mock_client.artifacts.generate_report = AsyncMock(
return_value={"artifact_id": "report_123", "status": "processing"}
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
["generate", "report", "Create a white paper", "-n", "nb_123"],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
@pytest.mark.parametrize(
"format_name,extra_text,expected_format",
[
("briefing-doc", "Focus on financial metrics", ReportFormat.BRIEFING_DOC),
("study-guide", "Target audience: beginners", ReportFormat.STUDY_GUIDE),
("blog-post", "Keep it conversational", ReportFormat.BLOG_POST),
],
)
def test_generate_report_append(
self, runner, mock_auth, format_name, extra_text, expected_format
):
"""--append passes extra_instructions while keeping built-in format."""
mock_client = create_mock_client()
mock_client.artifacts.generate_report = AsyncMock(
return_value={"artifact_id": "report_123", "status": "processing"}
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
[
"generate",
"report",
"--format",
format_name,
"--append",
extra_text,
"-n",
"nb_123",
],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
call_kwargs = mock_client.artifacts.generate_report.call_args.kwargs
assert call_kwargs["extra_instructions"] == extra_text
assert call_kwargs["report_format"] == expected_format
assert call_kwargs["custom_prompt"] is None
def test_generate_report_append_with_custom_warns(self, runner, mock_auth):
"""--append with --format custom prints a warning and clears extra_instructions."""
mock_client = create_mock_client()
mock_client.artifacts.generate_report = AsyncMock(
return_value={"artifact_id": "report_123", "status": "processing"}
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
[
"generate",
"report",
"--format",
"custom",
"--append",
"extra",
"-n",
"nb_123",
],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
assert "Warning" in result.output
assert "--format custom" in result.output
call_kwargs = mock_client.artifacts.generate_report.call_args.kwargs
assert call_kwargs["extra_instructions"] is None
def test_generate_report_append_with_description_warns(self, runner, mock_auth):
"""--append with a description arg (auto-promoted to custom) warns and clears extra_instructions."""
mock_client = create_mock_client()
mock_client.artifacts.generate_report = AsyncMock(
return_value={"artifact_id": "report_123", "status": "processing"}
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
["generate", "report", "My custom prompt", "--append", "extra", "-n", "nb_123"],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
assert "Warning" in result.output
call_kwargs = mock_client.artifacts.generate_report.call_args.kwargs
assert call_kwargs["extra_instructions"] is None
assert call_kwargs["report_format"] == ReportFormat.CUSTOM
# =============================================================================
# JSON OUTPUT TESTS (MATERIALLY DISTINCT STRUCTURE)
# =============================================================================
#
# The standard-type ``--json`` cases (audio/video/.../data-table) are covered
# by ``TestGenerateStandardTypes`` above. Only mind-map keeps a dedicated JSON
# test here because its return payload (``mind_map`` + ``note_id``) is a
# materially different structure, not "same data, other format".
class TestGenerateJsonOutput:
"""JSON-output tests for commands whose envelope differs from the standard shape."""
def test_generate_mind_map_note_backed_json_output(self, runner, mock_auth):
"""--kind note-backed --json emits the note-backed {mind_map, note_id} shape."""
mock_client = create_mock_client()
mock_client.artifacts.generate_mind_map = AsyncMock(
return_value=mind_map_result(
{"mind_map": {"name": "Root", "children": []}, "note_id": "n1"}
)
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
["generate", "mind-map", "--kind", "note-backed", "--json", "-n", "nb_123"],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
data = json.loads(result.output)
assert "mind_map" in data
assert data["note_id"] == "n1"
# =============================================================================
# COMMAND EXISTENCE TESTS
# =============================================================================
class TestGenerateCommandsExist:
def test_generate_group_exists(self, runner):
result = runner.invoke(cli, ["generate", "--help"])
assert result.exit_code == 0
assert "audio" in result.output
assert "video" in result.output
assert "quiz" in result.output
def test_generate_audio_command_exists(self, runner):
result = runner.invoke(cli, ["generate", "audio", "--help"])
assert result.exit_code == 0
assert "DESCRIPTION" in result.output
assert "--notebook" in result.output or "-n" in result.output
def test_generate_video_command_exists(self, runner):
result = runner.invoke(cli, ["generate", "video", "--help"])
assert result.exit_code == 0
assert "DESCRIPTION" in result.output
def test_generate_cinematic_video_command_exists(self, runner):
result = runner.invoke(cli, ["generate", "cinematic-video", "--help"])
assert result.exit_code == 0
assert "cinematic" in result.output.lower()
def test_generate_quiz_command_exists(self, runner):
result = runner.invoke(cli, ["generate", "quiz", "--help"])
assert result.exit_code == 0
def test_generate_slide_deck_command_exists(self, runner):
result = runner.invoke(cli, ["generate", "slide-deck", "--help"])
assert result.exit_code == 0
# =============================================================================
# LANGUAGE VALIDATION TESTS
# =============================================================================
class TestGenerateLanguageValidation:
def test_invalid_language_code_rejected(self, runner, mock_auth):
"""Test that invalid language codes are rejected with helpful error."""
mock_client = create_mock_client()
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
["generate", "audio", "-n", "nb_123", "--language", "invalid_code"],
obj=inject_client(mock_client),
)
assert result.exit_code != 0
assert "Unknown language code: invalid_code" in result.output
assert "notebooklm language list" in result.output
def test_valid_language_code_accepted(self, runner, mock_auth):
"""Test that valid language codes are accepted."""
mock_client = create_mock_client()
mock_client.artifacts.generate_audio = AsyncMock(
return_value={"artifact_id": "audio_123", "status": "processing"}
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
["generate", "audio", "-n", "nb_123", "--language", "ja"],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
# =============================================================================
# RETRY FUNCTIONALITY TESTS
#
# The pure ``calculate_backoff_delay`` / ``generate_with_retry`` tests moved to
# ``tests/unit/app/test_app_generate_retry.py`` (they call the ``_app`` function
# directly; the function is defined in ``_app/generate_retry.py``). The
# ``--retry`` Click *option* surface stays here.
# =============================================================================
class TestRetryOptionAvailable:
"""Test that --retry option is available on generate commands."""
def test_retry_option_in_audio_help(self, runner):
"""Test --retry option appears in audio command help."""
result = runner.invoke(cli, ["generate", "audio", "--help"])
assert result.exit_code == 0
assert "--retry" in result.output
def test_retry_option_in_video_help(self, runner):
"""Test --retry option appears in video command help."""
result = runner.invoke(cli, ["generate", "video", "--help"])
assert result.exit_code == 0
assert "--retry" in result.output
def test_retry_option_in_cinematic_video_help(self, runner):
"""Test --retry option appears in cinematic-video command help."""
result = runner.invoke(cli, ["generate", "cinematic-video", "--help"])
assert result.exit_code == 0
assert "--retry" in result.output
def test_retry_option_in_slide_deck_help(self, runner):
"""Test --retry option appears in slide-deck command help."""
result = runner.invoke(cli, ["generate", "slide-deck", "--help"])
assert result.exit_code == 0
assert "--retry" in result.output
def test_retry_option_in_quiz_help(self, runner):
"""Test --retry option appears in quiz command help."""
result = runner.invoke(cli, ["generate", "quiz", "--help"])
assert result.exit_code == 0
assert "--retry" in result.output
class TestRateLimitDetection:
"""Test rate limit detection in handle_generation_result."""
def test_rate_limit_message_shown(self, runner, mock_auth):
"""Test that rate limit error shows proper message."""
from notebooklm.types import GenerationStatus
rate_limited = GenerationStatus(
task_id="", status="failed", error="Rate limited", error_code="USER_DISPLAYABLE_ERROR"
)
mock_client = create_mock_client()
mock_client.artifacts.generate_audio = AsyncMock(return_value=rate_limited)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["generate", "audio", "-n", "nb_123"], obj=inject_client(mock_client)
)
assert "rate limited by Google" in result.output
assert "--retry" in result.output
def test_rate_limit_json_output(self, runner, mock_auth):
"""Test that rate limit error produces correct JSON output."""
from notebooklm.types import GenerationStatus
rate_limited = GenerationStatus(
task_id="", status="failed", error="Rate limited", error_code="USER_DISPLAYABLE_ERROR"
)
mock_client = create_mock_client()
mock_client.artifacts.generate_audio = AsyncMock(return_value=rate_limited)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["generate", "audio", "-n", "nb_123", "--json"], obj=inject_client(mock_client)
)
data = json.loads(result.output)
assert data["error"] is True
assert data["code"] == "RATE_LIMITED"
# =============================================================================
# RESOLVE_LANGUAGE DIRECT TESTS
# =============================================================================
class TestResolveLanguageDirect:
"""Direct tests for resolve_language() covering uncovered branches."""
def test_invalid_language_exits_via_output_error(self, capsys):
"""Invalid language code routes through ``output_error`` (per ADR-0015):
exit 1, message on stderr. Replaces the old ``click.BadParameter``
contract — the post-parse JSON envelope contract supersedes it."""
import importlib
generate_module = importlib.import_module("notebooklm.cli.generate_cmd")
with pytest.raises(SystemExit) as exc_info:
generate_module.resolve_language("xx_INVALID")
assert exc_info.value.code == 1
captured = capsys.readouterr()
assert "Unknown language code: xx_INVALID" in captured.err
assert "notebooklm language list" in captured.err
def test_none_language_with_config_returns_config(self):
"""language is None, config_lang is not None -> returns config_lang."""
import importlib
generate_module = importlib.import_module("notebooklm.cli.generate_cmd")
with patch.object(generate_module, "get_language", return_value="fr"):
result = generate_module.resolve_language(None)
assert result == "fr"
def test_none_language_no_config_returns_default(self):
"""language is None and config_lang is None -> returns DEFAULT_LANGUAGE."""
import importlib
generate_module = importlib.import_module("notebooklm.cli.generate_cmd")
with patch.object(generate_module, "get_language", return_value=None):
result = generate_module.resolve_language(None)
assert result == "en"
def test_env_overrides_config(self, monkeypatch):
"""NOTEBOOKLM_HL set, config also set, no flag → env wins over config."""
import importlib
monkeypatch.setenv("NOTEBOOKLM_HL", "ja")
generate_module = importlib.import_module("notebooklm.cli.generate_cmd")
with patch.object(generate_module, "get_language", return_value="zh_Hans"):
result = generate_module.resolve_language(None)
assert result == "ja"
def test_flag_overrides_env(self, monkeypatch):
"""Explicit --language argument wins over NOTEBOOKLM_HL env var."""
import importlib
monkeypatch.setenv("NOTEBOOKLM_HL", "ja")
generate_module = importlib.import_module("notebooklm.cli.generate_cmd")
with patch.object(generate_module, "get_language", return_value="zh_Hans"):
result = generate_module.resolve_language("ko")
assert result == "ko"
def test_env_only_no_config(self, monkeypatch):
"""NOTEBOOKLM_HL set, no config, no flag → env wins over default."""
import importlib
monkeypatch.setenv("NOTEBOOKLM_HL", "ja")
generate_module = importlib.import_module("notebooklm.cli.generate_cmd")
with patch.object(generate_module, "get_language", return_value=None):
result = generate_module.resolve_language(None)
assert result == "ja"
def test_empty_env_falls_through_to_config(self, monkeypatch):
"""Empty NOTEBOOKLM_HL is treated as unset and config wins."""
import importlib
monkeypatch.setenv("NOTEBOOKLM_HL", "")
generate_module = importlib.import_module("notebooklm.cli.generate_cmd")
with patch.object(generate_module, "get_language", return_value="zh_Hans"):
result = generate_module.resolve_language(None)
assert result == "zh_Hans"
def test_invalid_env_exits_via_output_error(self, monkeypatch, capsys):
"""An unsupported NOTEBOOKLM_HL value still gets validated. Per ADR-0015
it routes through ``output_error`` (exit 1, message on stderr) rather
than ``click.BadParameter``. The message must name ``NOTEBOOKLM_HL`` so
the user can tell which input source is at fault — mirroring the
``in config`` disambiguation that the config-file branch already does."""
import importlib
monkeypatch.setenv("NOTEBOOKLM_HL", "xx_INVALID")
generate_module = importlib.import_module("notebooklm.cli.generate_cmd")
with (
patch.object(generate_module, "get_language", return_value=None),
pytest.raises(SystemExit) as exc_info,
):
generate_module.resolve_language(None)
assert exc_info.value.code == 1
captured = capsys.readouterr()
assert "xx_INVALID" in captured.err
assert "NOTEBOOKLM_HL" in captured.err
def test_resolve_language_rejects_invalid_config_value(self, capsys):
"""An unsupported language stored in the config file gets validated.
Per ADR-0015, routes through ``output_error`` (exit 1, message on
stderr) rather than ``click.BadParameter``."""
import importlib
generate_module = importlib.import_module("notebooklm.cli.generate_cmd")
with (
patch.object(generate_module, "get_language", return_value="xx_INVALID"),
pytest.raises(SystemExit) as exc_info,
):
generate_module.resolve_language(None)
assert exc_info.value.code == 1
captured = capsys.readouterr()
assert "xx_INVALID" in captured.err
assert "notebooklm language list" in captured.err
def test_resolve_language_accepts_valid_config_value(self):
"""A supported language stored in the config file is returned as-is."""
import importlib
generate_module = importlib.import_module("notebooklm.cli.generate_cmd")
with patch.object(generate_module, "get_language", return_value="ja"):
result = generate_module.resolve_language(None)
assert result == "ja"
def test_resolve_language_treats_whitespace_env_as_unset(self, monkeypatch):
"""Whitespace-only NOTEBOOKLM_HL falls through to config, not rejected."""
import importlib
monkeypatch.setenv("NOTEBOOKLM_HL", " ")
generate_module = importlib.import_module("notebooklm.cli.generate_cmd")
with patch.object(generate_module, "get_language", return_value="ja"):
result = generate_module.resolve_language(None)
assert result == "ja"
def test_resolve_language_treats_whitespace_env_as_unset_no_config(self, monkeypatch):
"""Whitespace-only NOTEBOOKLM_HL with no config falls through to default."""
import importlib
monkeypatch.setenv("NOTEBOOKLM_HL", " ")
generate_module = importlib.import_module("notebooklm.cli.generate_cmd")
with patch.object(generate_module, "get_language", return_value=None):
result = generate_module.resolve_language(None)
assert result == "en"
# =============================================================================
# _OUTPUT_GENERATION_OUTCOME DIRECT TESTS
# =============================================================================
class TestOutputGenerationOutcomeDirect:
"""Direct tests for command-layer generation outcome rendering."""
def setup_method(self):
self.generate_module = generate_module
def test_json_completed_with_url(self):
outcome = GenerationOutcome(
status="completed",
artifact_type="audio",
task_id="task_123",
url="https://example.com/audio.mp3",
)
with patch.object(self.generate_module, "json_output_response") as mock_json:
self.generate_module._output_generation_outcome(outcome, json_output=True)
mock_json.assert_called_once_with(
{"task_id": "task_123", "status": "completed", "url": "https://example.com/audio.mp3"}
)
def test_json_failed(self):
outcome = GenerationOutcome(
status="failed", artifact_type="audio", error="Something went wrong"
)
with (
patch.object(self.generate_module, "output_error") as mock_err,
pytest.raises(SystemExit),
):
mock_err.side_effect = SystemExit(1)
self.generate_module._output_generation_outcome(outcome, json_output=True)
mock_err.assert_called_once_with("Something went wrong", "GENERATION_FAILED", True, 1)
def test_json_failed_no_error_message(self):
outcome = GenerationOutcome(status="failed", artifact_type="audio")
with (
patch.object(self.generate_module, "output_error") as mock_err,
pytest.raises(SystemExit),
):
mock_err.side_effect = SystemExit(1)
self.generate_module._output_generation_outcome(outcome, json_output=True)
mock_err.assert_called_once_with("Audio generation failed", "GENERATION_FAILED", True, 1)
def test_json_pending_with_task_id(self):
outcome = GenerationOutcome(status="pending", artifact_type="audio", task_id="task_456")
with patch.object(self.generate_module, "json_output_response") as mock_json:
self.generate_module._output_generation_outcome(outcome, json_output=True)
mock_json.assert_called_once_with({"task_id": "task_456", "status": "pending"})
def test_text_completed_with_url(self):
outcome = GenerationOutcome(
status="completed",
artifact_type="audio",
task_id="task_123",
url="https://example.com/audio.mp3",
)
with patch.object(self.generate_module, "console") as mock_console:
self.generate_module._output_generation_outcome(outcome, json_output=False)
mock_console.print.assert_called_once_with(
"[green]Audio ready:[/green] https://example.com/audio.mp3"
)
def test_text_completed_without_url(self):
outcome = GenerationOutcome(status="completed", artifact_type="audio", task_id="task_123")
with patch.object(self.generate_module, "console") as mock_console:
self.generate_module._output_generation_outcome(outcome, json_output=False)
mock_console.print.assert_called_once_with("[green]Audio ready[/green]")
def test_text_failed(self):
outcome = GenerationOutcome(
status="failed", artifact_type="audio", error="Transcription error"
)
with (
patch.object(self.generate_module, "output_error") as mock_err,
pytest.raises(SystemExit),
):
mock_err.side_effect = SystemExit(1)
self.generate_module._output_generation_outcome(outcome, json_output=False)
mock_err.assert_called_once_with("Transcription error", "GENERATION_FAILED", False, 1)
def test_text_failed_no_error_message(self):
outcome = GenerationOutcome(status="failed", artifact_type="audio")
with (
patch.object(self.generate_module, "output_error") as mock_err,
pytest.raises(SystemExit),
):
mock_err.side_effect = SystemExit(1)
self.generate_module._output_generation_outcome(outcome, json_output=False)
mock_err.assert_called_once_with("Audio generation failed", "GENERATION_FAILED", False, 1)
def test_text_pending_with_task_id(self):
outcome = GenerationOutcome(status="pending", artifact_type="audio", task_id="task_789")
with patch.object(self.generate_module, "console") as mock_console:
self.generate_module._output_generation_outcome(outcome, json_output=False)
mock_console.print.assert_called_once_with("[yellow]Started:[/yellow] task_789")
def test_text_pending_without_task_id_shows_raw_status(self):
raw_status = object()
outcome = GenerationOutcome(status="pending", artifact_type="audio", raw_status=raw_status)
with patch.object(self.generate_module, "console") as mock_console:
self.generate_module._output_generation_outcome(outcome, json_output=False)
mock_console.print.assert_called_once()
call_args = mock_console.print.call_args[0][0]
assert "[yellow]Started:[/yellow]" in call_args
# ``TestExtractTaskIdDirect`` moved to ``tests/unit/app/test_app_generate_retry.py``
# (``_extract_task_id`` is defined in ``_app/generate_retry.py``).
# =============================================================================
# _OUTPUT_MIND_MAP_RESULT DIRECT TESTS
# =============================================================================
class TestOutputMindMapResultDirect:
"""Direct tests for _output_mind_map_result() covering uncovered branches."""
def setup_method(self):
import importlib
self.generate_module = importlib.import_module("notebooklm.cli.generate_cmd")
def test_falsy_result_json_calls_error(self):
"""Falsy result with json_output -> json_error_response."""
with patch.object(self.generate_module, "json_error_response") as mock_err:
self.generate_module._output_mind_map_result(None, json_output=True)
mock_err.assert_called_once_with("GENERATION_FAILED", "Mind map generation failed")
def test_falsy_result_no_json_prints_message(self):
"""Falsy result without json_output -> console.print yellow."""
with patch.object(self.generate_module, "console") as mock_console:
self.generate_module._output_mind_map_result(None, json_output=False)
mock_console.print.assert_called_with("[yellow]No result[/yellow]")
def test_truthy_result_json_calls_output(self):
"""Truthy result with json_output -> converged {mind_map, note_id, kind}."""
result_data = {"note_id": "n1", "mind_map": {"name": "Root", "children": []}}
with patch.object(self.generate_module, "json_output_response") as mock_json:
self.generate_module._output_mind_map_result(result_data, json_output=True)
mock_json.assert_called_once_with(
{"mind_map": {"name": "Root", "children": []}, "note_id": "n1", "kind": "note_backed"}
)
def test_truthy_result_dict_text_output(self):
"""Truthy result dict with text output prints note_id and children count."""
result_data = {
"note_id": "n1",
"mind_map": {"name": "Root", "children": [{"label": "Child1"}, {"label": "Child2"}]},
}
with patch.object(self.generate_module, "console") as mock_console:
self.generate_module._output_mind_map_result(result_data, json_output=False)
printed_args = [call[0][0] for call in mock_console.print.call_args_list]
assert any("n1" in arg for arg in printed_args)
assert any("Root" in arg for arg in printed_args)
assert any("2" in arg for arg in printed_args)
def test_truthy_result_non_dict_text_output(self):
"""Non-dict truthy result with text output → console.print(result)."""
result_data = "some-string-result"
with patch.object(self.generate_module, "console") as mock_console:
self.generate_module._output_mind_map_result(result_data, json_output=False)
# Should print the result directly
printed_args = [call[0][0] for call in mock_console.print.call_args_list]
assert any("some-string-result" in str(arg) for arg in printed_args)
# =============================================================================
# GENERATE REVISE-SLIDE CLI TESTS
# =============================================================================
class TestGenerateReviseSlide:
"""Tests for the 'generate revise-slide' CLI command."""
def test_revise_slide_basic(self, runner, mock_auth):
"""revise-slide command invokes client.artifacts.revise_slide."""
mock_client = create_mock_client()
mock_client.artifacts.revise_slide = AsyncMock(
return_value={"artifact_id": "art_rev_1", "status": "processing"}
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
[
"generate",
"revise-slide",
"Make the title bigger",
"--artifact",
"art_1",
"--slide",
"0",
"-n",
"nb_123",
],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
mock_client.artifacts.revise_slide.assert_called_once()
def test_revise_slide_passes_correct_args(self, runner, mock_auth):
"""Verify artifact_id, slide_index, and prompt are forwarded."""
mock_client = create_mock_client()
mock_client.artifacts.revise_slide = AsyncMock(
return_value={"artifact_id": "art_rev_2", "status": "processing"}
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
[
"generate",
"revise-slide",
"Remove taxonomy",
"--artifact",
"art_1",
"--slide",
"3",
"-n",
"nb_123",
],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
call_kwargs = mock_client.artifacts.revise_slide.call_args
assert call_kwargs is not None, "revise_slide was not called"
assert call_kwargs.kwargs.get("artifact_id") == "art_1"
assert call_kwargs.kwargs.get("slide_index") == 3
assert call_kwargs.kwargs.get("prompt") == "Remove taxonomy"
def test_revise_slide_missing_artifact_fails(self, runner, mock_auth):
"""revise-slide requires --artifact option."""
mock_client = create_mock_client()
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
[
"generate",
"revise-slide",
"Make bigger",
"--slide",
"0",
"-n",
"nb_123",
],
obj=inject_client(mock_client),
)
assert result.exit_code != 0
def test_revise_slide_missing_slide_fails(self, runner, mock_auth):
"""revise-slide requires --slide option."""
mock_client = create_mock_client()
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
[
"generate",
"revise-slide",
"Make bigger",
"--artifact",
"art_1",
"-n",
"nb_123",
],
obj=inject_client(mock_client),
)
assert result.exit_code != 0
def test_revise_slide_json_output(self, runner, mock_auth):
"""revise-slide with --json flag produces JSON output."""
mock_client = create_mock_client()
mock_client.artifacts.revise_slide = AsyncMock(
return_value={"artifact_id": "art_rev_3", "status": "processing"}
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
[
"generate",
"revise-slide",
"Bold the title",
"--artifact",
"art_1",
"--slide",
"1",
"-n",
"nb_123",
"--json",
],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
data = json.loads(result.output)
assert "task_id" in data or "artifact_id" in data or "status" in data
# =============================================================================
# GENERATE REPORT WITH DESCRIPTION (LINE 1057)
# =============================================================================
class TestGenerateReportWithNonBriefingFormat:
"""Test generate report when description is provided with non-briefing-doc format.
The else-branch sets custom_prompt = description when report_format !=
'briefing-doc' and description is provided.
"""
def test_report_description_with_study_guide_format(self, runner, mock_auth):
"""Description + non-default format -> custom_prompt = description."""
mock_client = create_mock_client()
mock_client.artifacts.generate_report = AsyncMock(
return_value={"artifact_id": "report_xyz", "status": "processing"}
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
[
"generate",
"report",
"Focus on beginners",
"--format",
"study-guide",
"-n",
"nb_123",
],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
mock_client.artifacts.generate_report.assert_called_once()
call_kwargs = mock_client.artifacts.generate_report.call_args.kwargs
# custom_prompt should be the description argument
assert call_kwargs.get("custom_prompt") == "Focus on beginners"
def test_report_description_with_blog_post_format(self, runner, mock_auth):
"""Description + blog-post format -> custom_prompt set."""
mock_client = create_mock_client()
mock_client.artifacts.generate_report = AsyncMock(
return_value={"artifact_id": "report_abc", "status": "processing"}
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
[
"generate",
"report",
"Write in casual tone",
"--format",
"blog-post",
"-n",
"nb_123",
],
obj=inject_client(mock_client),
)
assert result.exit_code == 0
mock_client.artifacts.generate_report.assert_called_once()
call_kwargs = mock_client.artifacts.generate_report.call_args.kwargs
assert call_kwargs.get("custom_prompt") == "Write in casual tone"
# =============================================================================
# HANDLE_GENERATION_RESULT PATHS (GenerationStatus, dict seam inputs, and wait paths)
# =============================================================================
class TestHandleGenerationResultPaths:
"""Test handle_generation_result branches: GenerationStatus and dict seam inputs."""
def test_generation_result_with_generation_status_object(self, runner, mock_auth):
"""result is a GenerationStatus -> task_id = result.task_id."""
from notebooklm.types import GenerationStatus
status = GenerationStatus(
task_id="task_gen_1", status="pending", error=None, error_code=None
)
mock_client = create_mock_client()
mock_client.artifacts.generate_audio = AsyncMock(return_value=status)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["generate", "audio", "-n", "nb_123"], obj=inject_client(mock_client)
)
assert result.exit_code == 0
assert "task_gen_1" in result.output or "Started" in result.output
def test_generation_result_with_dict_input(self, runner, mock_auth):
"""A dict generation-start result surfaces its task id.
The raw positional-list path is gone — the facade ``generate_*``
methods return typed ``GenerationStatus`` objects (dicts remain a
tolerated seam shape at this boundary).
"""
mock_client = create_mock_client()
mock_client.artifacts.generate_audio = AsyncMock(
return_value={"task_id": "task_dict_1", "status": "processing"}
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["generate", "audio", "-n", "nb_123"], obj=inject_client(mock_client)
)
assert result.exit_code == 0
assert "task_dict_1" in result.output or "Started" in result.output
def test_generation_result_falsy_shows_failed_message(self, runner, mock_auth):
"""Falsy result → stderr error message + non-zero exit (P1.T6).
Pre-fix exited 0 in text mode; post-fix routes through
``output_error`` → ``SystemExit(1)`` and writes the message to
stderr. See ``TestArtifactGenerationExitCodes`` for the contract.
"""
mock_client = create_mock_client()
mock_client.artifacts.generate_audio = AsyncMock(return_value=None)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["generate", "audio", "-n", "nb_123"], obj=inject_client(mock_client)
)
assert result.exit_code != 0
assert "generation failed" in result.stderr.lower()
def test_generation_result_falsy_json_shows_error(self, runner, mock_auth):
"""Falsy result with --json → GENERATION_FAILED envelope + non-zero exit.
Post-P1.T6 the path routes through ``output_error`` (not the older
``json_error_response`` helper) so this test pins the JSON-mode
contract here; ``TestArtifactGenerationExitCodes`` covers the same
path with explicit exit-code assertions and the text-mode parity.
"""
mock_client = create_mock_client()
mock_client.artifacts.generate_audio = AsyncMock(return_value=None)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["generate", "audio", "-n", "nb_123", "--json"], obj=inject_client(mock_client)
)
# ``output_error`` raises ``SystemExit(1)``; Click reports exit_code 1.
assert result.exit_code != 0
data = json.loads(result.output)
assert data["error"] is True
assert data["code"] == "GENERATION_FAILED"
def test_generation_with_wait_and_generation_status(self, runner, mock_auth):
"""wait=True with GenerationStatus triggers wait_for_completion."""
from notebooklm.types import GenerationStatus
initial_status = GenerationStatus(
task_id="task_wait_1", status="pending", error=None, error_code=None
)
completed_status = GenerationStatus(
task_id="task_wait_1",
status="completed",
error=None,
error_code=None,
url="https://example.com/result.mp3",
)
mock_client = create_mock_client()
mock_client.artifacts.generate_audio = AsyncMock(return_value=initial_status)
mock_client.artifacts.wait_for_completion = AsyncMock(return_value=completed_status)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["generate", "audio", "-n", "nb_123", "--wait"], obj=inject_client(mock_client)
)
assert result.exit_code == 0
mock_client.artifacts.wait_for_completion.assert_called_once()
# =============================================================================
# ADDITIONAL TARGETED COVERAGE TESTS
# =============================================================================
# ``TestGenerateWithRetryConsoleOutput`` (the pure ``on_retry``-sink retry test)
# moved to ``tests/unit/app/test_app_generate_retry.py`` as
# ``test_retry_fires_on_retry_sink`` (retargeted at the injected sink, no
# Click coupling).
class TestHandleGenerationResultListPathAndWait:
"""Test generation-result rendering branches and wait console messages."""
def test_wait_with_task_id_shows_generating_message(self, runner, mock_auth):
"""wait=True, task_id present, not json -> console.print generating."""
from notebooklm.types import GenerationStatus
initial_status = GenerationStatus(
task_id="task_console_1", status="pending", error=None, error_code=None
)
completed_status = GenerationStatus(
task_id="task_console_1",
status="completed",
error=None,
error_code=None,
url="https://example.com/audio.mp3",
)
mock_client = create_mock_client()
mock_client.artifacts.generate_audio = AsyncMock(return_value=initial_status)
mock_client.artifacts.wait_for_completion = AsyncMock(return_value=completed_status)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["generate", "audio", "-n", "nb_123", "--wait"], obj=inject_client(mock_client)
)
assert result.exit_code == 0
# The console message "Generating audio... Task: task_console_1" should appear
assert "task_console_1" in result.output or "Generating" in result.output
mock_client.artifacts.wait_for_completion.assert_called_once()
def test_dict_result_extracts_task_id_for_wait(self, runner, mock_auth):
"""Dict result without ``artifact_id`` + wait=True → task_id from ``task_id``.
(Formerly exercised the raw positional-list path; the facade returns
typed statuses, so the list-sniffing branch was removed.)
"""
from notebooklm.types import GenerationStatus
completed_status = GenerationStatus(
task_id="task_dict_wait",
status="completed",
error=None,
error_code=None,
url="https://example.com/audio.mp3",
)
mock_client = create_mock_client()
mock_client.artifacts.generate_audio = AsyncMock(
return_value={"task_id": "task_dict_wait", "status": "processing"}
)
mock_client.artifacts.wait_for_completion = AsyncMock(return_value=completed_status)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["generate", "audio", "-n", "nb_123", "--wait"], obj=inject_client(mock_client)
)
assert result.exit_code == 0
mock_client.artifacts.wait_for_completion.assert_called_once()
def test_dict_result_prefers_artifact_id_for_wait(self, runner, mock_auth):
"""Dict generation-start results preserve artifact_id-first wait semantics."""
from notebooklm.types import GenerationStatus
completed_status = GenerationStatus(
task_id="artifact_wait_id",
status="completed",
error=None,
error_code=None,
url="https://example.com/audio.mp3",
)
mock_client = create_mock_client()
mock_client.artifacts.generate_audio = AsyncMock(
return_value={
"artifact_id": "artifact_wait_id",
"task_id": "task_wait_id",
"status": "processing",
}
)
mock_client.artifacts.wait_for_completion = AsyncMock(return_value=completed_status)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["generate", "audio", "-n", "nb_123", "--wait"], obj=inject_client(mock_client)
)
assert result.exit_code == 0, result.output
mock_client.artifacts.wait_for_completion.assert_awaited_once()
args = mock_client.artifacts.wait_for_completion.await_args.args
assert args[:2] == ("nb_123", "artifact_wait_id")
class TestOutputMindMapNonDictMindMap:
"""Test _output_mind_map_result when mind_map value is not a dict."""
def setup_method(self):
import importlib
self.generate_module = importlib.import_module("notebooklm.cli.generate_cmd")
def test_mind_map_non_dict_value_prints_directly(self):
"""A dict result with non-dict ``mind_map`` still prints header and note id."""
result_data = {
"note_id": "n1",
"mind_map": ["node1", "node2"], # list, not dict → else branch
}
with patch.object(self.generate_module, "console") as mock_console:
self.generate_module._output_mind_map_result(result_data, json_output=False)
printed_calls = [call[0][0] for call in mock_console.print.call_args_list]
# Should print the header and Note ID, then the raw result
assert any("n1" in str(arg) for arg in printed_calls)
class TestStatusWithElapsed:
"""Cover the polling-UI spinner helper.
The pure ``_format_status_message`` tests moved to
``tests/unit/app/test_app_generate_retry.py`` (the formatter is defined in
``_app/generate_retry.py``); the CLI-coupled ``status_with_elapsed``
no-op-under-``--json`` assertion (which patches ``polling_ui.console``)
stays here.
"""
def test_status_with_elapsed_json_output_is_no_op(self):
"""Under --json the helper must NOT call console.status (stdout stays JSON)."""
async def _exercise() -> None:
with patch.object(polling_ui_module.console, "status") as mock_status:
async with status_with_elapsed("audio", json_output=True):
pass
assert not mock_status.called, "console.status must not be invoked under --json"
asyncio.run(_exercise())
# =============================================================================
# SIGINT / RESUME-HINT TESTS
# =============================================================================
class TestGenerateWaitSigintResumeHint:
"""Ctrl-C during ``generate <kind> --wait`` surfaces the resume hint.
The hint follows the canonical phrasing
``Cancelled. Resume with: notebooklm artifact poll <task_id>``
and the process exits 130. This guards against the prior regression
where Ctrl-C during a 30-min cinematic-video wait dumped a Python
KeyboardInterrupt traceback with no actionable next step.
"""
def test_generate_audio_wait_sigint_prints_resume_hint_and_exits_130(self, runner, mock_auth):
"""SIGINT during ``generate audio --wait`` exits 130 with a resume hint
naming the task_id.
Simulates the Ctrl-C by patching ``client.artifacts.wait_for_completion``
to raise ``KeyboardInterrupt`` — the same exception Python delivers
when the user hits Ctrl-C during the polling loop.
"""
from notebooklm.types import GenerationStatus
initial_status = GenerationStatus(
task_id="task_sigint_1", status="pending", error=None, error_code=None
)
mock_client = create_mock_client()
mock_client.artifacts.generate_audio = AsyncMock(return_value=initial_status)
# The polling call is where Ctrl-C lands (asyncio.sleep inside the
# library's wait loop is the actual suspension point). Surfacing
# KeyboardInterrupt from the awaitable is the cleanest way to
# simulate that without spinning up a real polling loop.
mock_client.artifacts.wait_for_completion = AsyncMock(side_effect=KeyboardInterrupt)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["generate", "audio", "-n", "nb_123", "--wait"], obj=inject_client(mock_client)
)
# Exit 130 = 128 + signal 2 (SIGINT). Standard convention.
assert result.exit_code == 130, (
f"expected SIGINT exit 130, got {result.exit_code}; output={result.output!r}"
)
# Specification: SIGINT under --wait must display exactly this resume
# hint. Hard-coded here so any drift in the user-visible string is
# caught at this layer, not by a downstream user.
combined = result.output + (result.stderr if result.stderr_bytes else "")
assert "Cancelled. Resume with: notebooklm artifact poll task_sigint_1" in combined, (
f"expected resume hint with task_id; got: {combined!r}"
)
def test_generate_audio_wait_sigint_json_emits_cancelled_envelope(self, runner, mock_auth):
"""SIGINT under ``--json`` emits a structured CANCELLED envelope on stdout.
Automation parsing stdout-as-JSON gets a parseable cancellation
instead of a half-printed JSON document or a Python traceback. The
envelope carries the resume hint so an agent can re-issue the resume
command without scraping a human-facing string.
"""
from notebooklm.types import GenerationStatus
initial_status = GenerationStatus(
task_id="task_sigint_json", status="pending", error=None, error_code=None
)
mock_client = create_mock_client()
mock_client.artifacts.generate_audio = AsyncMock(return_value=initial_status)
mock_client.artifacts.wait_for_completion = AsyncMock(side_effect=KeyboardInterrupt)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
["generate", "audio", "-n", "nb_123", "--wait", "--json"],
obj=inject_client(mock_client),
)
assert result.exit_code == 130
# Last JSON document on stdout is the cancellation envelope (the
# earlier ``Started`` line is suppressed under --wait + --json since
# the wait succeeds before any status print).
# Find a JSON object containing "code": "CANCELLED".
assert '"code": "CANCELLED"' in result.output, (
f"expected CANCELLED envelope on stdout under --json; got: {result.output!r}"
)
assert "notebooklm artifact poll task_sigint_json" in result.output
def test_status_with_elapsed_propagates_keyboardinterrupt_when_no_resume_hint(self):
"""Without ``resume_hint``, KeyboardInterrupt propagates to the generic handler.
Preserves the existing ``error_handler.handle_errors`` ownership of
non-wait commands — the SIGINT-with-hint path is opt-in via the
``resume_hint`` argument so unrelated callers (e.g. mind-map's static
``console.status`` block) keep getting the generic ``Cancelled.``
treatment.
"""
async def _exercise() -> None:
with patch.object(polling_ui_module.console, "status") as mock_status:
mock_status.return_value.__enter__ = MagicMock(return_value=MagicMock())
mock_status.return_value.__exit__ = MagicMock(return_value=False)
with pytest.raises(KeyboardInterrupt):
async with status_with_elapsed("audio", resume_hint=None):
raise KeyboardInterrupt
asyncio.run(_exercise())
# =============================================================================
# P1.T6 — Exit-code parity across text/JSON modes on artifact generation failure
# =============================================================================
class TestArtifactGenerationExitCodes:
"""Failed artifact generation must exit non-zero in BOTH text and JSON modes.
Pre-fix behavior: text mode printed a Rich error to stdout and returned
normally (exit 0); JSON mode emitted a ``json_error_response`` envelope and
exited 1. The exit-code asymmetry meant shell scripts driving
``notebooklm generate audio ...`` without ``--json`` could not detect
failures via ``$?``.
These tests pin the unified contract: every failure path inside
``handle_generation_result`` (and the command-layer outcome renderer for
terminal failures reached via ``--wait``) routes through ``output_error``,
which exits non-zero and writes the human-readable message to stderr in
text mode or a structured envelope on stdout in JSON mode.
"""
# --- Initial-call failure (result is None / falsy) ---------------------
def test_text_mode_none_result_exits_nonzero(self, runner, mock_auth):
"""``generate audio`` without ``--json`` exits != 0 when the API returns None."""
mock_client = create_mock_client()
mock_client.artifacts.generate_audio = AsyncMock(return_value=None)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["generate", "audio", "-n", "nb_123"], obj=inject_client(mock_client)
)
assert result.exit_code != 0
# Message routed to stderr via safe_echo(err=True) under Click 8.2+ which
# separates stdout/stderr by default.
assert "Audio generation failed" in result.stderr
def test_json_mode_none_result_exits_nonzero(self, runner, mock_auth):
"""``generate audio --json`` exits != 0 when the API returns None."""
mock_client = create_mock_client()
mock_client.artifacts.generate_audio = AsyncMock(return_value=None)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["generate", "audio", "-n", "nb_123", "--json"], obj=inject_client(mock_client)
)
assert result.exit_code != 0
data = json.loads(result.output)
assert data["error"] is True
assert data["code"] == "GENERATION_FAILED"
assert "Audio generation failed" in data["message"]
# --- Rate-limit failure --------------------------------------------------
def test_text_mode_rate_limited_exits_nonzero(self, runner, mock_auth):
"""Rate-limited result (no retries left) exits != 0 in text mode."""
from notebooklm.types import GenerationStatus
rate_limited = GenerationStatus(
task_id="", status="failed", error="Rate limited", error_code="USER_DISPLAYABLE_ERROR"
)
mock_client = create_mock_client()
mock_client.artifacts.generate_audio = AsyncMock(return_value=rate_limited)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["generate", "audio", "-n", "nb_123"], obj=inject_client(mock_client)
)
assert result.exit_code != 0
# The "rate limited" message and the daily-quota hint both land on
# stderr; the second goes through ``output_error``'s ``hint`` arg.
assert "rate limited by Google" in result.stderr
assert "--retry" in result.stderr
def test_json_mode_rate_limited_exits_nonzero(self, runner, mock_auth):
"""Rate-limited result exits != 0 with a RATE_LIMITED JSON envelope."""
from notebooklm.types import GenerationStatus
rate_limited = GenerationStatus(
task_id="", status="failed", error="Rate limited", error_code="USER_DISPLAYABLE_ERROR"
)
mock_client = create_mock_client()
mock_client.artifacts.generate_audio = AsyncMock(return_value=rate_limited)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["generate", "audio", "-n", "nb_123", "--json"], obj=inject_client(mock_client)
)
assert result.exit_code != 0
data = json.loads(result.output)
assert data["error"] is True
assert data["code"] == "RATE_LIMITED"
# --- Wait-then-failed terminal status -----------------------------------
def test_text_mode_wait_then_failed_exits_nonzero(self, runner, mock_auth):
"""``--wait`` that observes a terminal is_failed status exits != 0 in text mode."""
from notebooklm.types import GenerationStatus
initial = GenerationStatus(
task_id="task_fail_1", status="pending", error=None, error_code=None
)
terminal = GenerationStatus(
task_id="task_fail_1",
status="failed",
error="Transcription error",
error_code="INTERNAL_ERROR",
)
mock_client = create_mock_client()
mock_client.artifacts.generate_audio = AsyncMock(return_value=initial)
mock_client.artifacts.wait_for_completion = AsyncMock(return_value=terminal)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli, ["generate", "audio", "-n", "nb_123", "--wait"], obj=inject_client(mock_client)
)
assert result.exit_code != 0
assert "Transcription error" in result.stderr
def test_json_mode_wait_then_failed_exits_nonzero(self, runner, mock_auth):
"""``--wait --json`` that observes a terminal is_failed status exits != 0."""
from notebooklm.types import GenerationStatus
initial = GenerationStatus(
task_id="task_fail_2", status="pending", error=None, error_code=None
)
terminal = GenerationStatus(
task_id="task_fail_2",
status="failed",
error="Transcription error",
error_code="INTERNAL_ERROR",
)
mock_client = create_mock_client()
mock_client.artifacts.generate_audio = AsyncMock(return_value=initial)
mock_client.artifacts.wait_for_completion = AsyncMock(return_value=terminal)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = runner.invoke(
cli,
["generate", "audio", "-n", "nb_123", "--wait", "--json"],
obj=inject_client(mock_client),
)
assert result.exit_code != 0
data = json.loads(result.output)
assert data["error"] is True
assert data["code"] == "GENERATION_FAILED"
assert "Transcription error" in data["message"]