Files
teng-lin--notebooklm-py/tests/unit/cli/test_session_characterization.py
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

586 lines
24 KiB
Python

"""Characterization tests for ``notebooklm`` session/auth CLI commands.
These tests pin observable CLI behavior across the session command surface
BEFORE the P3.T3 service extraction. They are end-to-end at the ``CliRunner``
level so they capture exit codes, stdout/stderr structure, and JSON envelope
shape that lower-level service-unit tests do not cover holistically.
They MUST pass identically on ``main`` HEAD before the extraction commit
lands, and they MUST continue to pass byte-for-byte after the extraction.
Any divergence is a behavior regression — not an opportunity to "fix" the
old behavior.
Coverage matrix (commands x output modes from the P3.T3 spec):
| Command | text | json |
|--------------------------|------|------|
| login --browser chrome | yes | - |
| login --no-browser path | yes | - |
| status | yes | yes |
| use | yes | yes |
| clear | yes | - |
| auth check | yes | yes |
| auth refresh | yes | - |
| auth inspect | yes | yes |
Each test pins one (command, mode) cell of the matrix to a specific
expected-text fragment or JSON envelope. The fragments are intentionally
narrow (one or two structural lines) so the snapshots survive cosmetic
changes (e.g. Rich color codes) while still catching shape regressions.
P1.T3 regression coverage (must survive the extraction unchanged):
- ``use --json`` exit code matches text mode on error
- ``auth check`` OSError handling (storage unreadable)
"""
from __future__ import annotations
import json
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from click.testing import CliRunner
import notebooklm.auth as auth_module
import notebooklm.cli.context as context_module
import notebooklm.cli.helpers as helpers_module
import notebooklm.cli.resolve as resolve_module
import notebooklm.cli.services.auth_source as auth_source_module
import notebooklm.cli.services.playwright_login as playwright_login_module
import notebooklm.cli.services.session_context as session_context_module
import notebooklm.cli.session_cmd as session_cmd
import notebooklm.paths as paths_module
from notebooklm.notebooklm_cli import cli
from notebooklm.types import Notebook
from tests._fixtures import patch_session_login_dual
from .conftest import create_mock_client, inject_client
pytestmark = pytest.mark.characterization
# ----------------------------------------------------------------------------
# Fixtures
# ----------------------------------------------------------------------------
@pytest.fixture
def char_runner() -> CliRunner:
return CliRunner()
@pytest.fixture
def char_context_file(tmp_path, monkeypatch):
"""Provide a temporary context file path with full ``get_context_path`` patching.
Patches every binding of ``get_context_path`` the CLI surface uses,
including the P3.T3 service-layer consumer
``cli.services.session_context.get_context_path`` so ``read_status``
reads from the test tmp file rather than the real
``~/.notebooklm/context.json``.
The ``session_context`` binding is patched in object form
(``monkeypatch.setattr(_session_context, ...)``) because that module is
the real consumer ``read_status`` / ``verify_and_set_notebook`` look the
symbol up in; the old ``notebooklm.cli.session_cmd.get_context_path``
string-patch was a dead pure-surface re-export (#1367) — nothing in
``session_cmd``'s body resolves ``get_context_path`` anymore, so that
patch never bit. Removing it and repointing onto the live consumer keeps
the ``status`` snapshots load-bearing (verified disable->red).
"""
import notebooklm.cli.services.session_context as _session_context
context_file = tmp_path / "context.json"
def _return_context_file(*_args, **_kwargs):
return context_file
monkeypatch.setattr(_session_context, "get_context_path", _return_context_file)
with (
patch.object(helpers_module, "get_context_path", return_value=context_file),
patch.object(context_module, "get_context_path", return_value=context_file),
patch.object(resolve_module, "get_context_path", return_value=context_file),
):
yield context_file
@pytest.fixture
def char_storage_file(tmp_path, monkeypatch):
"""Provide a storage_state.json path and patch get_storage_path to return it.
Patches every storage-path resolution seam used by the auth commands:
the canonical ``notebooklm.paths.get_storage_path`` source, the
session_cmd legacy binding, and the P3.T3 service-layer bindings.
"""
storage_file = tmp_path / "storage_state.json"
with (
patch.object(paths_module, "get_storage_path", return_value=storage_file),
patch.object(session_cmd, "get_storage_path", return_value=storage_file),
patch.object(
auth_source_module,
"get_storage_path",
return_value=storage_file,
),
patch.object(
session_context_module,
"get_storage_path",
return_value=storage_file,
create=True,
),
patch.object(
playwright_login_module,
"get_storage_path",
return_value=storage_file,
),
):
yield storage_file
# ----------------------------------------------------------------------------
# login
# ----------------------------------------------------------------------------
class TestLoginCharacterization:
"""Golden snapshots for ``notebooklm login`` paths."""
def test_login_rejects_when_auth_json_env_set(self, char_runner, monkeypatch):
"""``login`` refuses to run when NOTEBOOKLM_AUTH_JSON is set."""
monkeypatch.setenv("NOTEBOOKLM_AUTH_JSON", '{"cookies": []}')
result = char_runner.invoke(cli, ["login"])
assert result.exit_code != 0
assert "NOTEBOOKLM_AUTH_JSON" in result.output
assert "Cannot run 'login'" in result.output
def test_login_browser_chrome_invokes_playwright(self, char_runner, tmp_path):
"""``login --browser chrome`` reaches the Playwright entry point."""
with (
patch.object(session_cmd, "run_login") as mock_run_login,
patch_session_login_dual("_sync_server_language_to_config") as mock_sync,
patch.object(
session_cmd,
"prepare_paths_or_exit",
return_value=(
tmp_path / "storage_state.json",
tmp_path / "browser_profile",
),
),
):
result = char_runner.invoke(cli, ["login", "--browser", "chrome"])
assert result.exit_code == 0
mock_run_login.assert_called_once()
plan = mock_run_login.call_args.args[0]
assert plan.browser == "chrome"
assert plan.storage_path == tmp_path / "storage_state.json"
assert plan.browser_profile == tmp_path / "browser_profile"
mock_sync.assert_called_once_with(
storage_path=tmp_path / "storage_state.json",
profile=None,
)
# The "Authentication saved to:" footer is the characterization signal
assert "Authentication saved to:" in result.output
def test_login_no_browser_via_browser_cookies(self, char_runner):
"""``login --browser-cookies`` skips Playwright (no-browser path)."""
with (
patch_session_login_dual("_login_browser_cookies_single") as mock_single,
patch_session_login_dual("_warn_missing_optional_domains"),
):
result = char_runner.invoke(cli, ["login", "--browser-cookies", "chrome"])
assert result.exit_code == 0
mock_single.assert_called_once()
# When --browser-cookies is used, no Playwright "Opening ..." banner
assert "Opening" not in result.output
def test_login_account_without_browser_cookies_errors(self, char_runner):
"""``login --account email`` without ``--browser-cookies`` is a usage error."""
result = char_runner.invoke(cli, ["login", "--account", "user@example.com"])
assert result.exit_code != 0
assert "--browser-cookies" in result.output
# ----------------------------------------------------------------------------
# use
# ----------------------------------------------------------------------------
class TestUseCharacterization:
"""Golden snapshots for ``notebooklm use``."""
def test_use_text_success(self, char_runner, char_context_file, mock_auth):
"""``use <id>`` (text) prints the resolved notebook in a table."""
mock_client = create_mock_client()
mock_client.notebooks.get = AsyncMock(
return_value=Notebook(
id="nb_char_001",
title="Characterization Notebook",
created_at=datetime(2024, 1, 15),
is_owner=True,
)
)
with (
patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch,
patch.object(
session_cmd,
"resolve_notebook_id",
new_callable=AsyncMock,
) as mock_resolve,
):
mock_fetch.return_value = ("csrf", "session")
mock_resolve.return_value = "nb_char_001"
result = char_runner.invoke(cli, ["use", "nb_char_001"], obj=inject_client(mock_client))
assert result.exit_code == 0
assert "nb_char_001" in result.output
assert "Characterization Notebook" in result.output
def test_use_json_success_envelope(self, char_runner, char_context_file, mock_auth):
"""``use --json`` emits the documented envelope shape."""
mock_client = create_mock_client()
mock_client.notebooks.get = AsyncMock(
return_value=Notebook(
id="nb_json_001",
title="JSON Char Notebook",
created_at=datetime(2024, 2, 1),
is_owner=False,
)
)
with (
patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch,
patch.object(
session_cmd,
"resolve_notebook_id",
new_callable=AsyncMock,
) as mock_resolve,
):
mock_fetch.return_value = ("csrf", "session")
mock_resolve.return_value = "nb_json_001"
result = char_runner.invoke(
cli, ["use", "nb_json_001", "--json"], obj=inject_client(mock_client)
)
assert result.exit_code == 0
data = json.loads(result.output)
assert data == {
"active_notebook_id": "nb_json_001",
"success": True,
"verified": True,
"notebook": {
"id": "nb_json_001",
"title": "JSON Char Notebook",
"is_owner": False,
"created_at": "2024-02-01T00:00:00",
"modified_at": None,
},
}
def test_use_force_text_marks_not_verified(self, char_runner, char_context_file):
"""``use --force`` persists without verification and prints '(not verified ...)'."""
result = char_runner.invoke(cli, ["use", "nb_unverif", "--force"])
assert result.exit_code == 0
assert "nb_unverif" in result.output
assert "not verified" in result.output.lower()
def test_use_force_json_envelope(self, char_runner, char_context_file):
"""``use --force --json`` envelope reports ``verified: false``."""
result = char_runner.invoke(cli, ["use", "nb_unverif", "--force", "--json"])
assert result.exit_code == 0
data = json.loads(result.output)
assert data == {
"active_notebook_id": "nb_unverif",
"success": True,
"verified": False,
}
# ----------------------------------------------------------------------------
# status
# ----------------------------------------------------------------------------
class TestStatusCharacterization:
"""Golden snapshots for ``notebooklm status``."""
def test_status_no_context_text(self, char_runner, char_context_file):
if char_context_file.exists():
char_context_file.unlink()
result = char_runner.invoke(cli, ["status"])
assert result.exit_code == 0
assert "No notebook selected" in result.output
def test_status_no_context_json(self, char_runner, char_context_file):
if char_context_file.exists():
char_context_file.unlink()
result = char_runner.invoke(cli, ["status", "--json"])
assert result.exit_code == 0
data = json.loads(result.output)
assert data == {
"has_context": False,
"notebook": None,
"conversation_id": None,
}
def test_status_with_context_text(self, char_runner, char_context_file):
char_context_file.write_text(
json.dumps(
{
"notebook_id": "nb_status_1",
"title": "Status Notebook",
"is_owner": True,
"created_at": "2024-03-04",
}
)
)
result = char_runner.invoke(cli, ["status"])
assert result.exit_code == 0
assert "nb_status_1" in result.output
assert "Status Notebook" in result.output
assert "Current Context" in result.output
def test_status_with_context_json(self, char_runner, char_context_file):
char_context_file.write_text(
json.dumps(
{
"notebook_id": "nb_status_2",
"title": "Status Notebook 2",
"is_owner": False,
"created_at": "2024-04-05",
"conversation_id": "conv_xyz",
}
)
)
result = char_runner.invoke(cli, ["status", "--json"])
assert result.exit_code == 0
data = json.loads(result.output)
assert data == {
"has_context": True,
"notebook": {
"id": "nb_status_2",
"title": "Status Notebook 2",
"is_owner": False,
},
"conversation_id": "conv_xyz",
}
def test_status_paths_json(self, char_runner, char_context_file):
with patch.object(session_context_module, "get_path_info") as mock_path_info:
mock_path_info.return_value = {
"home_dir": "/tmp/.notebooklm",
"home_source": "default",
"storage_path": "/tmp/.notebooklm/storage_state.json",
"context_path": "/tmp/.notebooklm/context.json",
"browser_profile_dir": "/tmp/.notebooklm/browser_profile",
}
result = char_runner.invoke(cli, ["status", "--paths", "--json"])
assert result.exit_code == 0
data = json.loads(result.output)
assert "paths" in data
assert data["paths"]["home_dir"] == "/tmp/.notebooklm"
# ----------------------------------------------------------------------------
# clear
# ----------------------------------------------------------------------------
class TestClearCharacterization:
"""Golden snapshots for ``notebooklm clear``."""
def test_clear_text_when_context_present(self, char_runner, char_context_file):
char_context_file.write_text(json.dumps({"notebook_id": "nb_clear_1"}))
result = char_runner.invoke(cli, ["clear"])
assert result.exit_code == 0
assert "Context cleared" in result.output
def test_clear_text_when_no_context(self, char_runner, char_context_file):
if char_context_file.exists():
char_context_file.unlink()
result = char_runner.invoke(cli, ["clear"])
assert result.exit_code == 0
# The current behavior prints "Context cleared" unconditionally.
assert "Context cleared" in result.output
# ----------------------------------------------------------------------------
# auth check (P1.T3 regression coverage included)
# ----------------------------------------------------------------------------
class TestAuthCheckCharacterization:
"""Golden snapshots for ``notebooklm auth check``."""
def test_auth_check_text_storage_missing(self, char_runner, char_storage_file):
# storage file does not exist
result = char_runner.invoke(cli, ["auth", "check"])
assert result.exit_code == 1 # failed check ⇒ non-zero, matches --json (issue #1569)
assert "Authentication Check" in result.output
assert "Storage file not found" in result.output
def test_auth_check_json_storage_missing(self, char_runner, char_storage_file):
result = char_runner.invoke(cli, ["auth", "check", "--json"])
assert result.exit_code == 1
data = json.loads(result.output)
assert data["status"] == "error"
assert data["checks"]["storage_exists"] is False
def test_auth_check_json_valid_storage(self, char_runner, char_storage_file):
# Minimum cookie set that ``extract_cookies_from_storage`` accepts:
# both ``SID`` and ``__Secure-1PSIDTS`` are required by
# ``MINIMUM_REQUIRED_COOKIES``; HSID is the conventional companion.
char_storage_file.write_text(
json.dumps(
{
"cookies": [
{"name": "SID", "value": "abc", "domain": ".google.com"},
{
"name": "__Secure-1PSIDTS",
"value": "tts",
"domain": ".google.com",
},
{"name": "HSID", "value": "xyz", "domain": ".google.com"},
]
}
)
)
result = char_runner.invoke(cli, ["auth", "check", "--json"])
assert result.exit_code == 0
data = json.loads(result.output)
assert data["status"] == "ok"
assert data["checks"]["storage_exists"] is True
assert data["checks"]["json_valid"] is True
assert data["checks"]["sid_cookie"] is True
def test_auth_check_json_invalid_json(self, char_runner, char_storage_file):
char_storage_file.write_text("not valid json {")
result = char_runner.invoke(cli, ["auth", "check", "--json"])
assert result.exit_code == 1
data = json.loads(result.output)
assert data["status"] == "error"
assert data["checks"]["storage_exists"] is True
assert data["checks"]["json_valid"] is False
def test_auth_check_oserror_text_p1t3(self, char_runner, char_storage_file):
"""P1.T3 regression: OSError on read does not raise; reports error gracefully.
The read failure surfaces as a failed ``json_valid`` check, so text
mode exits non-zero (cleanly, no traceback) like --json (issue #1569).
"""
# File exists but read raises OSError (permission denied simulation).
char_storage_file.write_text("{}", encoding="utf-8")
with patch("pathlib.Path.read_text", side_effect=OSError("Permission denied")):
result = char_runner.invoke(cli, ["auth", "check"])
assert result.exit_code == 1
assert result.exception is None or isinstance(result.exception, SystemExit)
assert "Storage unreadable" in result.output or "Permission denied" in result.output
def test_auth_check_oserror_json_p1t3(self, char_runner, char_storage_file):
"""P1.T3 regression: ``auth check --json`` exits 1 on OSError."""
char_storage_file.write_text("{}")
with patch("pathlib.Path.read_text", side_effect=OSError("Permission denied")):
result = char_runner.invoke(cli, ["auth", "check", "--json"])
assert result.exit_code == 1
data = json.loads(result.output)
assert data["status"] == "error"
assert data["checks"]["json_valid"] is False
# ----------------------------------------------------------------------------
# auth refresh
# ----------------------------------------------------------------------------
class TestAuthRefreshCharacterization:
"""Golden snapshots for ``notebooklm auth refresh``."""
def test_auth_refresh_rejects_when_auth_json_env_set(self, char_runner, monkeypatch):
monkeypatch.setenv("NOTEBOOKLM_AUTH_JSON", '{"cookies":[]}')
result = char_runner.invoke(cli, ["auth", "refresh"])
assert result.exit_code != 0
assert "NOTEBOOKLM_AUTH_JSON" in result.output
def test_auth_refresh_default_path_success(self, char_runner, char_storage_file):
"""Default path (no --browser-cookies) calls ``fetch_tokens_with_domains``."""
char_storage_file.write_text(json.dumps({"cookies": [{"name": "SID", "value": "x"}]}))
with patch.object(
session_cmd,
"fetch_tokens_with_domains",
new_callable=AsyncMock,
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = char_runner.invoke(cli, ["auth", "refresh"])
assert result.exit_code == 0
mock_fetch.assert_awaited()
assert "ok" in result.output
assert "refreshed" in result.output
def test_auth_refresh_quiet_suppresses_success_output(self, char_runner, char_storage_file):
with patch.object(
session_cmd,
"fetch_tokens_with_domains",
new_callable=AsyncMock,
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
result = char_runner.invoke(cli, ["auth", "refresh", "--quiet"])
assert result.exit_code == 0
assert result.output.strip() == ""
def test_auth_refresh_browser_cookies_path(self, char_runner, char_storage_file):
with patch_session_login_dual("_refresh_from_browser_cookies") as mock_refresh:
result = char_runner.invoke(cli, ["auth", "refresh", "--browser-cookies", "chrome"])
assert result.exit_code == 0
mock_refresh.assert_called_once()
def test_auth_refresh_include_domains_without_browser_cookies_errors(self, char_runner):
"""``--include-domains`` without ``--browser-cookies`` is a usage error."""
result = char_runner.invoke(cli, ["auth", "refresh", "--include-domains", "youtube"])
assert result.exit_code != 0
assert "--include-domains" in result.output
# ----------------------------------------------------------------------------
# auth inspect
# ----------------------------------------------------------------------------
class TestAuthInspectCharacterization:
"""Golden snapshots for ``notebooklm auth inspect``."""
def test_auth_inspect_text(self, char_runner):
"""``auth inspect`` (text) lists accounts in a table."""
fake_account_1 = MagicMock(email="a@example.com", is_default=True, browser_profile=None)
fake_account_2 = MagicMock(email="b@example.com", is_default=False, browser_profile=None)
with patch_session_login_dual("_enumerate_browser_accounts") as mock_enum:
mock_enum.return_value = ("chrome", [fake_account_1, fake_account_2])
result = char_runner.invoke(cli, ["auth", "inspect", "--browser", "chrome"])
assert result.exit_code == 0
assert "a@example.com" in result.output
assert "b@example.com" in result.output
assert "Browser:" in result.output
def test_auth_inspect_json(self, char_runner):
fake_account = MagicMock(
email="primary@example.com", is_default=True, browser_profile="Default"
)
with patch_session_login_dual("_enumerate_browser_accounts") as mock_enum:
mock_enum.return_value = ("chrome", [fake_account])
result = char_runner.invoke(cli, ["auth", "inspect", "--browser", "chrome", "--json"])
assert result.exit_code == 0
data = json.loads(result.output)
assert data == {
"browser": "chrome",
"accounts": [
{
"email": "primary@example.com",
"is_default": True,
"browser_profile": "Default",
}
],
}