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

264 lines
11 KiB
Python

"""CLI tests for local-only session commands — status / clear / auth logout.
These commands never make network calls, so they need no VCR cassette.
Commands covered:
* ``notebooklm status`` — text mode, JSON mode, ``--paths`` mode (no context).
* ``notebooklm clear`` — local state mutation (removes notebook context).
* ``notebooklm auth logout`` — clears storage_state.json + browser_profile + context.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from click.testing import CliRunner
from notebooklm.notebooklm_cli import cli
@pytest.fixture
def runner() -> CliRunner:
"""Click test runner."""
return CliRunner()
@pytest.fixture
def isolated_home(tmp_path: Path, monkeypatch) -> Path:
"""Redirect NOTEBOOKLM_HOME into ``tmp_path`` so tests touch no real state.
Also clears any NOTEBOOKLM_PROFILE / NOTEBOOKLM_AUTH_JSON inherited from
the test runner's environment — those would otherwise short-circuit the
profile machinery and surface state we did not set up here.
"""
monkeypatch.setenv("NOTEBOOKLM_HOME", str(tmp_path))
monkeypatch.delenv("NOTEBOOKLM_PROFILE", raising=False)
monkeypatch.delenv("NOTEBOOKLM_AUTH_JSON", raising=False)
return tmp_path
def _seed_storage_state(home: Path) -> Path:
"""Write a minimal storage_state.json so ``auth logout`` has something to remove."""
profile_dir = home / "profiles" / "default"
profile_dir.mkdir(parents=True, exist_ok=True)
storage_path = profile_dir / "storage_state.json"
storage_path.write_text(
json.dumps({"cookies": [{"name": "SID", "value": "fake", "domain": ".google.com"}]})
)
return storage_path
def _seed_context(home: Path, notebook_id: str = "test_nb_id", **extra) -> Path:
"""Write context.json under the default profile so ``status``/``clear`` find it."""
profile_dir = home / "profiles" / "default"
profile_dir.mkdir(parents=True, exist_ok=True)
context_path = profile_dir / "context.json"
payload = {"notebook_id": notebook_id, **extra}
context_path.write_text(json.dumps(payload))
return context_path
# ---------------------------------------------------------------------------
# status
# ---------------------------------------------------------------------------
class TestStatusCommand:
"""``notebooklm status`` — local-only inspection of context.json."""
def test_status_no_context(self, runner: CliRunner, isolated_home: Path) -> None:
"""With no context.json, status prints the 'no notebook selected' hint."""
result = runner.invoke(cli, ["status"])
assert result.exit_code == 0, result.output
assert "No notebook selected" in result.output
def test_status_no_context_json(self, runner: CliRunner, isolated_home: Path) -> None:
"""``--json`` returns a structured envelope with ``has_context: false``."""
result = runner.invoke(cli, ["status", "--json"])
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data == {"has_context": False, "notebook": None, "conversation_id": None}
def test_status_with_context(self, runner: CliRunner, isolated_home: Path) -> None:
"""With context.json, status renders the notebook ID/title in a table."""
_seed_context(isolated_home, notebook_id="abc123", title="Demo NB", is_owner=True)
result = runner.invoke(cli, ["status"])
assert result.exit_code == 0, result.output
assert "abc123" in result.output
assert "Demo NB" in result.output
def test_status_with_context_json(self, runner: CliRunner, isolated_home: Path) -> None:
"""``--json`` envelope echoes notebook id/title/is_owner + conversation_id."""
_seed_context(
isolated_home,
notebook_id="abc123",
title="Demo NB",
is_owner=True,
conversation_id="conv-99",
)
result = runner.invoke(cli, ["status", "--json"])
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data["has_context"] is True
assert data["notebook"] == {"id": "abc123", "title": "Demo NB", "is_owner": True}
assert data["conversation_id"] == "conv-99"
def test_status_paths_json(self, runner: CliRunner, isolated_home: Path) -> None:
"""``status --paths --json`` returns the path-info envelope."""
result = runner.invoke(cli, ["status", "--paths", "--json"])
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert "paths" in data
# Both home_dir and storage_path keys must be present and live inside
# our sandbox — guards against the env var being silently ignored.
paths = data["paths"]
assert str(isolated_home) in paths["home_dir"]
assert str(isolated_home) in paths["storage_path"]
# ---------------------------------------------------------------------------
# clear
# ---------------------------------------------------------------------------
class TestClearCommand:
"""``notebooklm clear`` — local state mutation."""
def test_clear_with_context(self, runner: CliRunner, isolated_home: Path) -> None:
"""``clear`` wipes the notebook id from context.json."""
ctx_path = _seed_context(isolated_home, notebook_id="abc123", title="Demo", is_owner=True)
result = runner.invoke(cli, ["clear"])
assert result.exit_code == 0, result.output
assert "Context cleared" in result.output
# context.json should be removed (no surviving non-context fields).
assert not ctx_path.exists()
def test_clear_no_context_is_noop(self, runner: CliRunner, isolated_home: Path) -> None:
"""``clear`` with no existing context still succeeds (idempotent)."""
result = runner.invoke(cli, ["clear"])
assert result.exit_code == 0, result.output
assert "Context cleared" in result.output
def test_clear_json(self, runner: CliRunner, isolated_home: Path) -> None:
"""``clear --json`` reports an actual clear (cleared=True)."""
_seed_context(isolated_home, notebook_id="abc123", title="Demo", is_owner=True)
result = runner.invoke(cli, ["clear", "--json"])
assert result.exit_code == 0, result.output
assert json.loads(result.output) == {"status": "cleared", "cleared": True}
def test_clear_json_noop(self, runner: CliRunner, isolated_home: Path) -> None:
"""``clear --json`` with nothing to clear reports cleared=False (no-op)."""
result = runner.invoke(cli, ["clear", "--json"])
assert result.exit_code == 0, result.output
assert json.loads(result.output) == {"status": "already_clear", "cleared": False}
# ---------------------------------------------------------------------------
# auth logout
# ---------------------------------------------------------------------------
class TestAuthLogoutCommand:
"""``notebooklm auth logout`` — local-only auth file removal."""
def test_auth_logout_clears_storage(self, runner: CliRunner, isolated_home: Path) -> None:
"""``auth logout`` deletes the storage_state.json for the active profile."""
storage = _seed_storage_state(isolated_home)
assert storage.exists() # pre-condition
result = runner.invoke(cli, ["auth", "logout"])
assert result.exit_code == 0, result.output
assert "Logged out" in result.output
assert not storage.exists()
def test_auth_logout_no_session(self, runner: CliRunner, isolated_home: Path) -> None:
"""With no storage/profile/context, logout reports 'Already logged out'."""
result = runner.invoke(cli, ["auth", "logout"])
assert result.exit_code == 0, result.output
assert "Already logged out" in result.output
def test_auth_logout_json(self, runner: CliRunner, isolated_home: Path) -> None:
"""``auth logout --json`` emits a structured logged-out document."""
_seed_storage_state(isolated_home)
result = runner.invoke(cli, ["auth", "logout", "--json"])
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["status"] == "logged_out"
assert payload["removed"] is True
def test_auth_logout_json_no_session(self, runner: CliRunner, isolated_home: Path) -> None:
"""``auth logout --json`` with nothing to remove reports already-logged-out."""
result = runner.invoke(cli, ["auth", "logout", "--json"])
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["status"] == "already_logged_out"
assert payload["removed"] is False
def test_render_logout_outcome_json_failure_is_single_document(self) -> None:
"""A per-step failure under ``--json`` emits exactly ONE error envelope
(exit 1) — never both the error and the success payload. Exercises the
render helper directly (no monkeypatch) since it owns the exit policy."""
import contextlib
import io
from notebooklm.cli._session_render import _render_logout_outcome
from notebooklm.cli.services.session_context import LogoutFailure, LogoutOutcome
outcome = LogoutOutcome(
removed_any=False,
env_auth_remains=False,
failure=LogoutFailure(
kind="storage",
path=Path("/tmp/x/storage_state.json"),
error_message="permission denied",
partial_storage_removed=False,
),
)
buf = io.StringIO()
with contextlib.redirect_stdout(buf), pytest.raises(SystemExit) as exc:
_render_logout_outcome(outcome, json_output=True)
assert exc.value.code == 1
payload = json.loads(buf.getvalue()) # raises if stdout isn't a single document
assert payload["error"] is True
assert payload["code"] == "logout_storage_failed"
def test_auth_logout_also_clears_context(self, runner: CliRunner, isolated_home: Path) -> None:
"""``auth logout`` removes context.json so post-logout commands start fresh.
Regression guard for the account-switch flow: leaving a stale notebook
id in context.json caused mismatched 'not found' / permission errors
after the user logged into a different Google account (see
``_ACCOUNT_MISMATCH_HINT`` in ``rpc/decoder.py``).
"""
_seed_storage_state(isolated_home)
ctx_path = _seed_context(isolated_home, notebook_id="abc123")
result = runner.invoke(cli, ["auth", "logout"])
assert result.exit_code == 0, result.output
assert not ctx_path.exists()
def test_auth_logout_warns_on_env_auth(
self, runner: CliRunner, isolated_home: Path, monkeypatch
) -> None:
"""``NOTEBOOKLM_AUTH_JSON`` users get a heads-up that env auth persists."""
_seed_storage_state(isolated_home)
# The fixture clears this env var; set it back specifically for this test.
monkeypatch.setenv("NOTEBOOKLM_AUTH_JSON", '{"cookies":{}}')
result = runner.invoke(cli, ["auth", "logout"])
assert result.exit_code == 0, result.output
assert "NOTEBOOKLM_AUTH_JSON is set" in result.output