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
390 lines
15 KiB
Python
390 lines
15 KiB
Python
"""Unit tests for the ``notebooklm doctor`` diagnostics command."""
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
import notebooklm.cli.doctor_cmd as doctor_cmd_module
|
|
from notebooklm import paths
|
|
from notebooklm.notebooklm_cli import cli
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolated_notebooklm_home(tmp_path, monkeypatch):
|
|
"""Keep doctor tests away from the real profile home and cached profile state."""
|
|
monkeypatch.setenv("NOTEBOOKLM_HOME", str(tmp_path))
|
|
monkeypatch.delenv("NOTEBOOKLM_PROFILE", raising=False)
|
|
monkeypatch.delenv("NOTEBOOKLM_AUTH_JSON", raising=False)
|
|
paths.set_active_profile(None)
|
|
paths._reset_config_cache()
|
|
yield tmp_path
|
|
paths.set_active_profile(None)
|
|
paths._reset_config_cache()
|
|
|
|
|
|
def _write_json(path: Path, data: object) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(data), encoding="utf-8")
|
|
|
|
|
|
def _make_profile(home: Path, name: str = "default") -> Path:
|
|
profile_dir = home / "profiles" / name
|
|
profile_dir.mkdir(parents=True)
|
|
if sys.platform != "win32":
|
|
profile_dir.chmod(0o700)
|
|
return profile_dir
|
|
|
|
|
|
def _storage(cookies: list[dict[str, str]]) -> dict[str, list[dict[str, str]]]:
|
|
return {"cookies": cookies}
|
|
|
|
|
|
def _invoke_json(runner, args: list[str], *, exit_code: int = 0) -> dict:
|
|
result = runner.invoke(cli, [*args, "doctor", "--json"])
|
|
assert result.exit_code == exit_code, result.output
|
|
return json.loads(result.output)
|
|
|
|
|
|
def test_doctor_reports_clean_profile_layout(runner, isolated_notebooklm_home):
|
|
home = isolated_notebooklm_home
|
|
profile_dir = _make_profile(home)
|
|
_write_json(
|
|
profile_dir / "storage_state.json",
|
|
_storage([{"name": "SID", "value": "x"}, {"name": "__Secure-1PSIDTS", "value": "y"}]),
|
|
)
|
|
_write_json(home / "config.json", {"default_profile": "default"})
|
|
|
|
data = _invoke_json(runner, [])
|
|
|
|
assert data["profile"] == "default"
|
|
assert data["profile_source"] == "config.json"
|
|
assert data["checks"]["migration"] == {"status": "pass", "detail": "complete"}
|
|
assert data["checks"]["auth"] == {
|
|
"status": "pass",
|
|
"detail": "local auth cookies present (2 cookies)",
|
|
}
|
|
assert data["checks"]["config"] == {
|
|
"status": "pass",
|
|
"detail": "valid (default_profile: default)",
|
|
}
|
|
if sys.platform == "win32":
|
|
assert data["checks"]["profile_dir"]["status"] == "warn"
|
|
assert str(profile_dir) in data["checks"]["profile_dir"]["detail"]
|
|
assert "permissions:" in data["checks"]["profile_dir"]["detail"]
|
|
else:
|
|
assert data["checks"]["profile_dir"] == {"status": "pass", "detail": str(profile_dir)}
|
|
|
|
|
|
def test_doctor_reports_legacy_layout_without_startup_migration(runner, isolated_notebooklm_home):
|
|
home = isolated_notebooklm_home
|
|
_write_json(
|
|
home / "storage_state.json",
|
|
_storage([{"name": "SID", "value": "x"}, {"name": "__Secure-1PSIDTS", "value": "y"}]),
|
|
)
|
|
|
|
data = _invoke_json(runner, ["--storage", str(home / "unused.json")], exit_code=1)
|
|
|
|
assert data["checks"]["migration"] == {
|
|
"status": "fail",
|
|
"detail": "legacy layout detected",
|
|
}
|
|
assert data["checks"]["profile_dir"]["status"] == "fail"
|
|
assert data["checks"]["auth"] == {
|
|
"status": "pass",
|
|
"detail": "local auth cookies present (2 cookies)",
|
|
}
|
|
|
|
|
|
def test_doctor_reports_missing_profile_dir(runner, isolated_notebooklm_home):
|
|
home = isolated_notebooklm_home
|
|
|
|
data = _invoke_json(runner, ["--storage", str(home / "unused.json")], exit_code=1)
|
|
|
|
assert data["checks"]["migration"] == {
|
|
"status": "pass",
|
|
"detail": "clean (no legacy files)",
|
|
}
|
|
assert data["checks"]["profile_dir"] == {
|
|
"status": "fail",
|
|
"detail": f"{home / 'profiles' / 'default'} not found",
|
|
}
|
|
assert data["checks"]["auth"] == {"status": "fail", "detail": "not authenticated"}
|
|
|
|
|
|
def test_doctor_reports_invalid_storage_json(runner, isolated_notebooklm_home):
|
|
profile_dir = _make_profile(isolated_notebooklm_home)
|
|
profile_dir.joinpath("storage_state.json").write_text("{not json", encoding="utf-8")
|
|
|
|
data = _invoke_json(runner, [], exit_code=1)
|
|
|
|
assert data["checks"]["auth"]["status"] == "fail"
|
|
assert data["checks"]["auth"]["detail"].startswith("invalid storage file:")
|
|
|
|
|
|
def test_doctor_reports_invalid_storage_root_shape(runner, isolated_notebooklm_home):
|
|
profile_dir = _make_profile(isolated_notebooklm_home)
|
|
_write_json(profile_dir / "storage_state.json", [])
|
|
|
|
data = _invoke_json(runner, [], exit_code=1)
|
|
|
|
assert data["checks"]["auth"] == {
|
|
"status": "fail",
|
|
"detail": "invalid storage file: storage root is not an object",
|
|
}
|
|
|
|
|
|
def test_doctor_reports_invalid_storage_cookie_shape(runner, isolated_notebooklm_home):
|
|
profile_dir = _make_profile(isolated_notebooklm_home)
|
|
_write_json(profile_dir / "storage_state.json", {"cookies": {"name": "SID"}})
|
|
|
|
data = _invoke_json(runner, [], exit_code=1)
|
|
|
|
assert data["checks"]["auth"] == {
|
|
"status": "fail",
|
|
"detail": "invalid storage file: cookies is not a list",
|
|
}
|
|
|
|
|
|
def test_doctor_reports_cookies_missing_sid(runner, isolated_notebooklm_home):
|
|
profile_dir = _make_profile(isolated_notebooklm_home)
|
|
_write_json(profile_dir / "storage_state.json", _storage([{"name": "HSID", "value": "x"}]))
|
|
|
|
data = _invoke_json(runner, [], exit_code=1)
|
|
|
|
assert data["checks"]["auth"] == {"status": "fail", "detail": "SID cookie missing"}
|
|
|
|
|
|
def test_doctor_warns_when_psidts_missing(runner, isolated_notebooklm_home):
|
|
"""SID but no __Secure-1PSIDTS warns (not fails) and keeps exit 0 (issue #1753)."""
|
|
home = isolated_notebooklm_home
|
|
profile_dir = _make_profile(home)
|
|
_write_json(profile_dir / "storage_state.json", _storage([{"name": "SID", "value": "x"}]))
|
|
_write_json(home / "config.json", {"default_profile": "default"})
|
|
|
|
# A lone SID is a warn, not a failure, so doctor still exits 0.
|
|
data = _invoke_json(runner, [], exit_code=0)
|
|
|
|
assert data["checks"]["auth"]["status"] == "warn"
|
|
assert "__Secure-1PSIDTS missing" in data["checks"]["auth"]["detail"]
|
|
|
|
|
|
def test_doctor_text_mode_does_not_greenlight_auth_warn(runner, isolated_notebooklm_home):
|
|
"""Text mode must not print 'All checks passed.' when auth only warns (#1753).
|
|
|
|
A warn keeps the exit code at 0, but the green all-passed footer would
|
|
greenlight the exact unusable (SID-without-__Secure-1PSIDTS) state doctor is
|
|
meant to surface — so an auth-specific advisory is rendered instead.
|
|
"""
|
|
home = isolated_notebooklm_home
|
|
profile_dir = _make_profile(home)
|
|
_write_json(profile_dir / "storage_state.json", _storage([{"name": "SID", "value": "x"}]))
|
|
_write_json(home / "config.json", {"default_profile": "default"})
|
|
|
|
result = runner.invoke(cli, ["doctor"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "All checks passed" not in result.output
|
|
assert "raised a warning" in result.output
|
|
|
|
|
|
def test_doctor_warns_when_config_default_profile_is_missing(runner, isolated_notebooklm_home):
|
|
home = isolated_notebooklm_home
|
|
_make_profile(home)
|
|
_write_json(home / "profiles" / "default" / "storage_state.json", _storage([]))
|
|
_write_json(home / "config.json", {"default_profile": "missing"})
|
|
|
|
data = _invoke_json(runner, [], exit_code=1)
|
|
|
|
assert data["profile"] == "missing"
|
|
assert data["profile_source"] == "config.json"
|
|
assert data["checks"]["profile_dir"]["status"] == "fail"
|
|
assert data["checks"]["config"] == {
|
|
"status": "warn",
|
|
"detail": "default_profile 'missing' does not exist",
|
|
}
|
|
|
|
|
|
def test_doctor_reports_invalid_config_root_shape(runner, isolated_notebooklm_home):
|
|
home = isolated_notebooklm_home
|
|
_make_profile(home)
|
|
_write_json(home / "config.json", [])
|
|
|
|
data = _invoke_json(runner, [], exit_code=1)
|
|
|
|
assert data["checks"]["config"] == {
|
|
"status": "fail",
|
|
"detail": "invalid: config root is not an object",
|
|
}
|
|
|
|
|
|
def test_doctor_fix_creates_missing_profile_dir(runner, isolated_notebooklm_home):
|
|
home = isolated_notebooklm_home
|
|
|
|
result = runner.invoke(cli, ["doctor", "--fix", "--json"])
|
|
|
|
# --fix repairs the profile dir, but no auth was set up so the auth check
|
|
# is still failing — doctor exits 1 on the lingering failure.
|
|
assert result.exit_code == 1, result.output
|
|
data = json.loads(result.output)
|
|
profile_dir = home / "profiles" / "default"
|
|
assert profile_dir.is_dir()
|
|
if sys.platform != "win32":
|
|
assert profile_dir.stat().st_mode & 0o777 == 0o700
|
|
assert data["checks"]["profile_dir"] == {"status": "pass", "detail": str(profile_dir)}
|
|
assert data["checks"]["auth"]["status"] == "fail"
|
|
assert data["fixes_applied"] == [f"Created profile directory: {profile_dir}"]
|
|
|
|
|
|
def test_doctor_fix_migrates_legacy_layout(runner, isolated_notebooklm_home):
|
|
home = isolated_notebooklm_home
|
|
storage_payload = _storage([{"name": "SID", "value": "x"}])
|
|
context_payload = {"current_notebook": "nb_123"}
|
|
_write_json(home / "storage_state.json", storage_payload)
|
|
_write_json(home / "context.json", context_payload)
|
|
|
|
result = runner.invoke(
|
|
cli,
|
|
["--storage", str(home / "unused.json"), "doctor", "--fix", "--json"],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
data = json.loads(result.output)
|
|
profile_dir = home / "profiles" / "default"
|
|
assert not (home / "storage_state.json").exists()
|
|
assert (profile_dir / "storage_state.json").exists()
|
|
assert (profile_dir / "context.json").exists()
|
|
assert json.loads((profile_dir / "storage_state.json").read_text(encoding="utf-8")) == (
|
|
storage_payload
|
|
)
|
|
assert json.loads((profile_dir / "context.json").read_text(encoding="utf-8")) == (
|
|
context_payload
|
|
)
|
|
assert data["checks"]["migration"] == {
|
|
"status": "pass",
|
|
"detail": "complete (just migrated)",
|
|
}
|
|
assert data["fixes_applied"] == ["Migrated legacy layout to profiles/default/"]
|
|
|
|
|
|
def test_doctor_json_output_shape(runner, isolated_notebooklm_home):
|
|
_make_profile(isolated_notebooklm_home)
|
|
|
|
# No storage_state.json was written, so the auth check fails and doctor
|
|
# exits 1; the JSON shape contract still holds on the failure path.
|
|
data = _invoke_json(runner, [], exit_code=1)
|
|
|
|
assert set(data) == {"profile", "profile_source", "checks"}
|
|
assert set(data["checks"]) == {
|
|
"migration",
|
|
"profile_dir",
|
|
"auth",
|
|
"config",
|
|
"headless_reauth",
|
|
}
|
|
for check in data["checks"].values():
|
|
assert set(check) == {"status", "detail"}
|
|
assert check["status"] in {"pass", "warn", "fail"}
|
|
assert isinstance(check["detail"], str)
|
|
|
|
|
|
def test_doctor_json_wraps_unexpected_filesystem_error(runner, isolated_notebooklm_home):
|
|
with patch.object(doctor_cmd_module, "get_storage_path", side_effect=OSError("denied")):
|
|
result = runner.invoke(cli, ["doctor", "--json"], catch_exceptions=True)
|
|
|
|
assert result.exit_code == 2
|
|
payload = json.loads(result.output)
|
|
assert payload == {
|
|
"error": True,
|
|
"code": "UNEXPECTED_ERROR",
|
|
"message": "Unexpected error: denied",
|
|
}
|
|
assert result.stderr == ""
|
|
|
|
|
|
def test_doctor_headless_reauth_degrades_to_warn_on_profile_resolution_error(
|
|
runner, isolated_notebooklm_home, monkeypatch
|
|
):
|
|
"""A read-only diagnostic must not crash if the profile dir cannot resolve.
|
|
|
|
``get_browser_profile_dir`` can raise ``ValueError`` (malformed profile
|
|
config) / ``OSError`` (permissions); the headless-reauth check degrades to
|
|
a ``warn`` row instead of bubbling up. ``monkeypatch.setattr`` on the public
|
|
helper avoids growing the string-patch ratchet for this file.
|
|
"""
|
|
from notebooklm.cli import doctor_cmd
|
|
|
|
_make_profile(isolated_notebooklm_home)
|
|
_write_json(
|
|
isolated_notebooklm_home / "profiles" / "default" / "storage_state.json",
|
|
_storage([{"name": "SID", "value": "x"}]),
|
|
)
|
|
|
|
def _boom(*_a, **_k):
|
|
raise ValueError("malformed profile name")
|
|
|
|
monkeypatch.setattr(doctor_cmd, "get_browser_profile_dir", _boom)
|
|
|
|
data = _invoke_json(runner, [])
|
|
|
|
assert data["checks"]["headless_reauth"]["status"] == "warn"
|
|
assert "could not resolve the browser profile" in data["checks"]["headless_reauth"]["detail"]
|
|
# The error type is surfaced, never a raw path / value.
|
|
assert "ValueError" in data["checks"]["headless_reauth"]["detail"]
|
|
|
|
|
|
def test_doctor_text_mode_exits_nonzero_on_failure(runner, isolated_notebooklm_home):
|
|
"""Regression for #1160: text-mode doctor must exit 1 when a check fails.
|
|
|
|
Previously the command always exited 0, so a broken install read as green
|
|
in CI / ``set -e`` scripts. With no profile dir and no auth, both the
|
|
``profile_dir`` and ``auth`` checks fail.
|
|
"""
|
|
result = runner.invoke(cli, ["doctor"])
|
|
|
|
assert result.exit_code == 1, result.output
|
|
# The rendered table still shows the failing rows.
|
|
assert "fail" in result.output
|
|
|
|
|
|
def test_doctor_text_mode_exits_zero_when_all_pass(runner, isolated_notebooklm_home):
|
|
"""A fully healthy profile keeps doctor's text mode at exit 0.
|
|
|
|
On Windows the profile_dir permissions check warns rather than passes, but a
|
|
warning is not a failure, so the command still exits 0.
|
|
"""
|
|
home = isolated_notebooklm_home
|
|
profile_dir = _make_profile(home)
|
|
_write_json(
|
|
profile_dir / "storage_state.json",
|
|
_storage([{"name": "SID", "value": "x"}, {"name": "__Secure-1PSIDTS", "value": "y"}]),
|
|
)
|
|
_write_json(home / "config.json", {"default_profile": "default"})
|
|
|
|
result = runner.invoke(cli, ["doctor"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "fail" not in result.output
|
|
|
|
|
|
def test_doctor_warn_only_keeps_exit_zero(runner, isolated_notebooklm_home):
|
|
"""A lingering warning (no failures) must not flip the exit code to 1.
|
|
|
|
Legacy files alongside a migrated ``profiles/`` directory is a ``warn``
|
|
(partial migration), not a ``fail`` — doctor should still exit 0.
|
|
"""
|
|
home = isolated_notebooklm_home
|
|
profile_dir = _make_profile(home)
|
|
_write_json(profile_dir / "storage_state.json", _storage([{"name": "SID", "value": "x"}]))
|
|
# Leftover legacy file alongside the profiles/ dir -> migration warn. The
|
|
# ``--storage`` override suppresses the startup migration that would
|
|
# otherwise sweep this file into the profile before the checks run.
|
|
_write_json(home / "context.json", {"current_notebook": "nb_123"})
|
|
|
|
data = _invoke_json(runner, ["--storage", str(home / "unused.json")], exit_code=0)
|
|
|
|
assert data["checks"]["migration"]["status"] == "warn"
|
|
assert not any(c["status"] == "fail" for c in data["checks"].values())
|