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

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())