Files
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

412 lines
17 KiB
Python

"""CLI contract baseline: pins the public command tree, options, and help.
This file characterizes public CLI behavior so later refactors (and intentional
surface changes) can be compared against the committed JSON baseline. The
deterministic introspection that produces the contract, ``build_cli_contract``,
lives in ``tests/_baselines/cli_contract.py`` (a non-test module so the baseline
registry can import it without a cross-test import); the ``cli_contract`` baseline
is registered in ``tests/_baselines/registry.py`` (ADR-0022).
Regenerate the baseline after an intended change with the registry-driven flow::
python scripts/regen_baselines.py
The ``__main__`` printer below still emits the contract to stdout for ad-hoc
inspection.
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import AsyncMock, patch
import click
import pytest
from click.testing import CliRunner
import notebooklm.cli.helpers as helpers_module
import notebooklm.client as client_module
from notebooklm.notebooklm_cli import cli
from tests._baselines.cli_contract import _command_for, build_cli_contract
def _find_repo_root() -> Path:
current = Path(__file__).resolve()
for parent in (current, *current.parents):
if (parent / "pyproject.toml").exists():
return parent
raise RuntimeError("Could not locate repository root")
BASELINE_PATH = _find_repo_root() / "tests/fixtures/cli_contract_baseline.json"
HELP_SNIPPETS = {
"": ("NotebookLM CLI", "notebooklm login", "completion"),
"completion": ("Print the shell completion script", "bash", "zsh", "fish"),
"download": ("Download generated content", "cinematic-video", "flashcards"),
"download audio": ("Download audio", "--latest", "--no-clobber"),
"source add": ("--follow-symlinks", "--mime-type", "--json"),
"share public": ("--enable", "--disable", "--json"),
"research wait": ("--import-all", "--cited-only", "--timeout"),
"label generate": ("--scope", "all", "unlabeled", "--yes"),
}
class _Stub:
def __init__(self, stub_id: str, title: str = "") -> None:
self.id = stub_id
self.title = title
class _CompletionCtx:
def __init__(self, notebook_id: str) -> None:
self.params = {"notebook_id": notebook_id}
self.parent = None
def _ctx_with_notebook(notebook_id: str = "nb_contract") -> _CompletionCtx:
return _CompletionCtx(notebook_id)
def test_cli_contract_matches_baseline() -> None:
"""Public command tree, options, defaults, help, and aliases match the baseline."""
expected = json.loads(BASELINE_PATH.read_text(encoding="utf-8"))
assert build_cli_contract() == expected
@pytest.mark.parametrize("path,snippets", HELP_SNIPPETS.items(), ids=lambda value: value or "root")
def test_representative_help_snippets_remain_visible(path: str, snippets: tuple[str, ...]) -> None:
argv = [*path.split(), "--help"] if path else ["--help"]
result = CliRunner().invoke(cli, argv)
assert result.exit_code == 0, result.output
for snippet in snippets:
assert snippet in result.output
def test_completion_callbacks_return_value_help_shape_and_50_row_caps() -> None:
from notebooklm.cli import completion, options
fake_client = AsyncMock()
fake_client.__aenter__.return_value = fake_client
fake_client.__aexit__.return_value = None
fake_client.sources.list = AsyncMock(
return_value=[_Stub(f"src_{index:03d}", f"Source {index}") for index in range(60)]
)
fake_client.artifacts.list = AsyncMock(
return_value=[_Stub(f"art_{index:03d}", f"Artifact {index}") for index in range(60)]
)
with (
patch.object(completion, "resolve_notebook", return_value="nb_contract"),
patch.object(helpers_module, "get_auth_tokens", return_value=object()),
patch.object(client_module, "NotebookLMClient", return_value=fake_client),
):
source_items = options._complete_sources(_ctx_with_notebook(), None, "src_")
artifact_items = options._complete_artifacts(_ctx_with_notebook(), None, "art_")
assert len(source_items) == 50
assert (source_items[0].value, source_items[0].help) == ("src_000", "Source 0")
assert source_items[-1].value == "src_049"
assert len(artifact_items) == 50
assert (artifact_items[0].value, artifact_items[0].help) == ("art_000", "Artifact 0")
assert artifact_items[-1].value == "art_049"
def test_completion_callbacks_are_silent_on_failures(capsys: pytest.CaptureFixture[str]) -> None:
from notebooklm.cli import completion, options
with patch.object(helpers_module, "get_auth_tokens", side_effect=RuntimeError("no auth")):
assert options._complete_notebooks(_ctx_with_notebook(), None, "nb_") == []
fake_client = AsyncMock()
fake_client.__aenter__.return_value = fake_client
fake_client.__aexit__.return_value = None
fake_client.sources.list = AsyncMock(side_effect=RuntimeError("offline"))
fake_client.artifacts.list = AsyncMock(side_effect=RuntimeError("offline"))
with (
patch.object(completion, "resolve_notebook", return_value="nb_contract"),
patch.object(helpers_module, "get_auth_tokens", return_value=object()),
patch.object(client_module, "NotebookLMClient", return_value=fake_client),
):
assert options._complete_sources(_ctx_with_notebook(), None, "src_") == []
assert options._complete_artifacts(_ctx_with_notebook(), None, "art_") == []
captured = capsys.readouterr()
assert captured.out == ""
assert captured.err == ""
@pytest.mark.parametrize(
("case_id", "setup", "expected_exit", "expected_code", "legacy_error"),
[
("auth_required", "missing_storage", 1, "AUTH_REQUIRED", False),
("returned_download_error", "empty_artifacts", 1, None, True),
("typed_user_error", "rate_limited", 1, "RATE_LIMITED", False),
("unexpected_error", "runtime_error", 2, "UNEXPECTED_ERROR", False),
],
)
def test_json_stdout_routing_and_exit_codes_for_download_runtime(
case_id: str,
setup: str,
expected_exit: int,
expected_code: str | None,
legacy_error: bool,
) -> None:
from notebooklm.auth import AuthTokens
from notebooklm.exceptions import RateLimitError
from .conftest import create_mock_client, inject_client
auth = AuthTokens(
cookies={
"SID": "test",
"HSID": "test",
"SSID": "test",
"APISID": "test",
"SAPISID": "test",
},
csrf_token="csrf",
session_id="session",
)
mock_client = create_mock_client()
if setup == "empty_artifacts":
mock_client.artifacts.list = AsyncMock(return_value=[])
elif setup == "rate_limited":
mock_client.artifacts.list = AsyncMock(side_effect=RateLimitError("quota", retry_after=7))
elif setup == "runtime_error":
mock_client.artifacts.list = AsyncMock(side_effect=RuntimeError("boom"))
with patch.object(helpers_module, "get_auth_tokens") as mock_get_auth_tokens:
if setup == "missing_storage":
mock_get_auth_tokens.side_effect = FileNotFoundError("Storage file not found")
else:
mock_get_auth_tokens.return_value = auth
result = CliRunner().invoke(
cli,
["download", "audio", "--json", "-n", "nb_123"],
catch_exceptions=False,
obj=inject_client(mock_client),
)
assert result.exit_code == expected_exit, case_id
assert result.stderr == ""
payload = json.loads(result.stdout)
if legacy_error:
assert isinstance(payload["error"], str)
assert "No completed audio artifacts" in payload["error"]
assert "code" not in payload
else:
assert payload["error"] is True
assert payload["code"] == expected_code
# ---------------------------------------------------------------------------
# Uniform ``--json`` error-envelope + exit-code contract (issue #1214 part b).
#
# Today the typed-envelope matrix is pinned for the ``download`` command only
# (see ``test_json_stdout_routing_and_exit_codes_for_download_runtime``). The
# class of regression this guards: a *new* ``--json``-bearing command that
# forgets to route failures through ``handle_errors`` and instead leaks a raw
# traceback, a bare string, or a non-JSON line to stdout. Such a command
# silently breaks every automation parsing the typed envelope.
#
# The canonical envelope (``cli/error_handler.py::_output_error``) is::
#
# {"error": true, "code": "<UPPER_SNAKE>", "message": "<str>", ...}
#
# with exit code 1 for user/application errors (auth, rate-limit, validation),
# 2 for unexpected bugs, 130 for Ctrl-C, and 0 for success. This test drives a
# uniform *missing-auth* failure (the same trigger the download test uses for
# its ``AUTH_REQUIRED`` case) across EVERY ``--json`` command discovered by
# command-tree introspection and asserts the typed envelope + exit code 1 +
# clean stderr.
# ---------------------------------------------------------------------------
# Commands whose ``--json`` output is intentionally NOT the error envelope:
# diagnostic / local-state commands that report status and succeed (exit 0)
# even without usable auth, emitting their own structured payload. Each is an
# explicit, documented exemption so the exemption is intentional rather than an
# accidental gap. Mirrors the prompt's documented exemptions
# (``agent show`` / ``skill install`` / ``profile``); the concrete commands
# that actually carry ``--json`` today and bypass the error envelope are:
JSON_CONTRACT_EXEMPTIONS: dict[str, str] = {
"auth logout": "Local auth-state mutation: clears on-disk creds, no auth required.",
"auth refresh": "Local keepalive: rotates/rewrites the on-disk cookie jar, not an auth-gated RPC.",
"clear": "Local context reset: clears the active-notebook file, no auth required.",
"skill package": "Local artifact build: zips the packaged skill to disk, no auth required.",
"skill status": "Local install introspection: reads on-disk skill targets, no auth required.",
"auth check": "Diagnostic command: emits a status report payload and exits 0.",
"doctor": "Diagnostic/repair command: emits a checks report payload, not the error envelope.",
"language get": "Settings read: emits the resolved language, no auth required.",
"language list": "Settings read: emits the supported-language table, no auth required.",
"language set": "Settings write helper: emits the applied language payload.",
"profile list": "Local profile listing: reads on-disk profiles, no auth required.",
"status": "Context status: emits the active-notebook context payload, no auth required.",
}
# Dummy required-argument values so a command body is reached past Click's
# argument parsing. Keyed by Click parameter name.
_JSON_CONTRACT_DUMMY_ARGS = {
"artifact_id": "art_1",
"question": "hello",
"title": "Untitled",
"description": "desc",
"code": "en",
"email": "person@example.com",
"content": "body",
# label group positional ARGUMENTS only (Click options like --scope/--emoji/
# --yes do NOT get entries here — this table is for arguments).
"label_ref": "lbl_1",
"label_refs": "lbl_1",
"name": "My Label",
"new_name": "Renamed",
"emoji_value": "📄",
"source_ids": "src_1",
}
def _split_stderr_runner() -> CliRunner:
"""A ``CliRunner`` that captures stderr separately when Click supports it.
Click 8.2 removed the ``mix_stderr`` constructor parameter (stderr is split
by default). Inspect the signature so the kwarg is passed only when present,
rather than relying on a ``TypeError`` to detect the unsupported case.
"""
import inspect
if "mix_stderr" in inspect.signature(CliRunner.__init__).parameters:
return CliRunner(mix_stderr=False)
return CliRunner()
def _has_json_flag(cmd: click.Command) -> bool:
for param in cmd.params:
if isinstance(param, click.Option) and (
param.name in ("json_output", "json") or "--json" in param.opts
):
return True
return False
def _json_command_paths() -> list[str]:
"""Every visible leaf command that exposes a ``--json`` flag."""
paths: list[str] = []
def walk(cmd: click.Command, path: list[str]) -> None:
if isinstance(cmd, click.Group):
ctx = click.Context(cmd)
for name in cmd.list_commands(ctx):
sub = cmd.get_command(ctx, name)
if sub is None or getattr(sub, "hidden", False):
continue
walk(sub, [*path, name])
elif _has_json_flag(cmd):
paths.append(" ".join(path))
walk(cli, [])
return sorted(paths)
def _choice_value(param: click.Parameter) -> str:
if isinstance(param.type, click.Choice):
return param.type.choices[0]
return "x"
def _build_json_invocation(path: str) -> list[str]:
cmd = _command_for(path)
argv = [*path.split(), "--json"]
for param in cmd.params:
if isinstance(param, click.Argument) and param.required:
count = param.nargs if param.nargs and param.nargs > 0 else 1
for _ in range(count):
argv.append(_JSON_CONTRACT_DUMMY_ARGS.get(param.name, _choice_value(param)))
elif isinstance(param, click.Option) and param.required:
argv.append(param.opts[0])
# "1" works for INT/STRING options and is enough to clear Click's
# parser so the command body (and its auth bootstrap) is reached.
# If a --json command ever gains a required option with a
# restrictive type (UUID, existing-Path, ...) that rejects "1",
# Click would exit 2 before auth; add such names to a dummy-option
# map here, mirroring _JSON_CONTRACT_DUMMY_ARGS for arguments.
argv.append(_choice_value(param) if isinstance(param.type, click.Choice) else "1")
if any(isinstance(param, click.Option) and param.name == "notebook_id" for param in cmd.params):
argv.extend(["-n", "nb_contract"])
return argv
def _enforced_json_command_paths() -> list[str]:
return [path for path in _json_command_paths() if path not in JSON_CONTRACT_EXEMPTIONS]
def test_json_contract_exemptions_are_real_commands() -> None:
"""Every exemption must name a real ``--json`` command (no stale entries)."""
json_paths = set(_json_command_paths())
stale = sorted(name for name in JSON_CONTRACT_EXEMPTIONS if name not in json_paths)
assert not stale, (
"Stale --json contract exemptions (no such --json command — remove from "
f"JSON_CONTRACT_EXEMPTIONS): {stale}"
)
def test_json_contract_covers_a_representative_command_set() -> None:
"""Guard against the introspection silently matching nothing."""
enforced = _enforced_json_command_paths()
# If this ever drops sharply, the command-tree walk likely broke or a whole
# group started bypassing the envelope; both deserve a hard failure.
assert len(enforced) >= 30, enforced
@pytest.mark.parametrize("command_path", _enforced_json_command_paths())
def test_json_error_envelope_and_exit_code_are_uniform(command_path: str) -> None:
"""Every non-exempt ``--json`` command emits the typed envelope on failure.
Drives a uniform missing-auth failure and asserts the canonical
``{"error": true, "code": <str>, "message": <str>}`` envelope on stdout,
exit code 1, and empty stderr — the same shape pinned for ``download`` in
``test_json_stdout_routing_and_exit_codes_for_download_runtime``.
"""
argv = _build_json_invocation(command_path)
runner = _split_stderr_runner()
with patch.object(
helpers_module,
"get_auth_tokens",
side_effect=FileNotFoundError("Storage file not found"),
):
result = runner.invoke(cli, argv, catch_exceptions=False)
assert result.exit_code == 1, (
f"{command_path!r} should exit 1 on missing auth, got "
f"{result.exit_code}. stdout={result.stdout!r}"
)
try:
stderr = result.stderr
except ValueError: # pragma: no cover - runner without split capture
stderr = ""
assert stderr == "", f"{command_path!r} wrote to stderr in --json mode: {stderr!r}"
try:
payload = json.loads(result.stdout)
except json.JSONDecodeError as exc: # pragma: no cover - failure path
raise AssertionError(
f"{command_path!r} did not emit JSON in --json mode: {result.stdout!r}"
) from exc
assert payload.get("error") is True, f"{command_path!r} envelope missing error=true: {payload}"
assert isinstance(payload.get("code"), str) and payload["code"], (
f"{command_path!r} envelope missing string code: {payload}"
)
assert isinstance(payload.get("message"), str), (
f"{command_path!r} envelope missing string message: {payload}"
)
if __name__ == "__main__":
print(json.dumps(build_cli_contract(), indent=2, sort_keys=True))