项目文件夹

文件
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

679 行
29 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""Tests for the cookie-domain blast-radius split.
Pins the two-layer contract:
* ``REQUIRED_COOKIE_DOMAINS`` is the default *extraction* set fed to
rookiepy. This is the canonical enforcement point: sibling-product
cookies (YouTube, etc.) never reach ``storage_state.json`` unless
the user opts in via ``--include-domains`` on
``notebooklm login`` / ``notebooklm auth refresh`` /
``notebooklm auth inspect``.
* The runtime gate consults the full ``REQUIRED OPTIONAL`` union so
opted-in cookies survive every downstream filter
(``convert_rookiepy_cookies_to_storage_state``,
``extract_cookies_with_domains``,
``build_httpx_cookies_from_storage``).
These tests are split out from ``test_auth.py`` because they cross the
auth/cli boundary — the contracts only make sense as a set.
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import patch
import pytest
from click.testing import CliRunner
from notebooklm._auth.cookie_policy import ALLOWED_COOKIE_DOMAINS
from notebooklm._auth.cookie_policy import (
build_cookie_domain_allowlist as _neutral_build_cookie_domain_allowlist,
)
from notebooklm._auth.cookie_policy import (
resolve_optional_cookie_domains as _neutral_resolve_optional_cookie_domains,
)
from notebooklm.auth import (
OPTIONAL_COOKIE_DOMAINS,
OPTIONAL_COOKIE_DOMAINS_BY_LABEL,
REQUIRED_COOKIE_DOMAINS,
_is_allowed_cookie_domain,
convert_rookiepy_cookies_to_storage_state,
)
from notebooklm.cli.services.login import (
_build_google_cookie_domains,
_resolve_optional_cookie_domains,
)
from notebooklm.cli.session_cmd import _parse_include_domains
from notebooklm.notebooklm_cli import cli
from tests._fixtures import patch_session_login_dual
class TestNeutralBuilderMatchesCliBuilder:
"""Drift canary: the neutral cookie-domain builder in
``notebooklm._auth.cookie_policy`` (consumed by the Playwright
browser-capture filter) must stay equivalent to the CLI extractor builder
``cli.services.login._build_google_cookie_domains`` (consumed by the
rookiepy / Firefox paths). Both derive from the same shared constants; this
pins that they never silently diverge, so the on-disk ``storage_state.json``
cookie set is identical regardless of which login path wrote it.
"""
@pytest.mark.parametrize(
"include_optional, include_domains",
[
(False, None),
(True, None),
(False, set()),
(False, {"youtube"}),
(False, {"docs"}),
(False, {"myaccount"}),
(False, {"mail"}),
(False, {"youtube", "docs"}),
(False, {"youtube", "docs", "myaccount", "mail"}),
(False, {"all"}),
],
)
def test_builders_produce_identical_domain_sets(self, include_optional, include_domains):
cli_domains = _build_google_cookie_domains(
include_optional=include_optional, include_domains=include_domains
)
neutral_domains = _neutral_build_cookie_domain_allowlist(
include_optional=include_optional, include_domains=include_domains
)
# Order is not significant for the allowlist; compare as sets.
assert set(cli_domains) == set(neutral_domains)
@pytest.mark.parametrize(
"labels",
[set(), {"youtube"}, {"docs", "mail"}, {"all"}],
)
def test_optional_resolvers_match(self, labels):
assert _resolve_optional_cookie_domains(labels) == (
_neutral_resolve_optional_cookie_domains(labels)
)
class TestRequiredVsOptional:
"""REQUIRED is empirically justified; OPTIONAL is opt-in only."""
def test_required_set_includes_runtime_auth_domains(self):
"""Codex caution: host + dotted variants must both stay in REQUIRED."""
for domain in (
".google.com",
"google.com",
".notebooklm.google.com",
"notebooklm.google.com",
".googleusercontent.com",
"accounts.google.com",
".accounts.google.com",
"drive.google.com",
".drive.google.com",
):
assert domain in REQUIRED_COOKIE_DOMAINS, (
f"{domain!r} must remain in REQUIRED_COOKIE_DOMAINS; the "
"runtime gate uses this exact-match set and dropping a "
"variant breaks http.cookiejar normalization round-trips."
)
def test_required_excludes_youtube(self):
"""YouTube is OPTIONAL, not REQUIRED."""
for domain in (".youtube.com", "youtube.com", "accounts.youtube.com"):
assert domain not in REQUIRED_COOKIE_DOMAINS
def test_required_excludes_other_optional_siblings(self):
"""Docs / myaccount / mail are OPTIONAL too."""
for domain in (
"docs.google.com",
"myaccount.google.com",
"mail.google.com",
):
assert domain not in REQUIRED_COOKIE_DOMAINS
def test_optional_label_keys_are_lowercase(self):
"""``--include-domains`` lowercases input; the labels must too."""
for label in OPTIONAL_COOKIE_DOMAINS_BY_LABEL:
assert label == label.lower(), f"label {label!r} is not lowercase"
def test_allowed_is_union(self):
"""``ALLOWED_COOKIE_DOMAINS`` is the back-compat union."""
assert ALLOWED_COOKIE_DOMAINS == REQUIRED_COOKIE_DOMAINS | OPTIONAL_COOKIE_DOMAINS
class TestRuntimeGate:
"""The runtime gate consults the REQUIRED OPTIONAL union.
Blast-radius reduction is enforced at *extraction* time (see
:class:`TestBuildGoogleCookieDomains` and
:class:`TestBlastRadiusExtractor` below): rookiepy only returns cookies
from :data:`REQUIRED_COOKIE_DOMAINS` by default, so YouTube cookies
never reach ``storage_state.json`` unless the user explicitly opts in
via ``--include-domains=youtube``. The runtime gate must stay
permissive over the full union so opted-in cookies survive the
downstream auth/storage filters.
"""
def test_runtime_gate_accepts_youtube_for_opt_in(self):
"""Contract: the runtime gate accepts every OPTIONAL domain.
If it rejected ``.youtube.com`` here, ``--include-domains=youtube``
cookies would be extracted by rookiepy and then immediately
filtered back out by ``convert_rookiepy_cookies_to_storage_state``,
``extract_cookies_with_domains``, and
``build_httpx_cookies_from_storage`` — all of which delegate to
this gate.
"""
assert _is_allowed_cookie_domain(".youtube.com") is True
assert _is_allowed_cookie_domain("youtube.com") is True
assert _is_allowed_cookie_domain("accounts.youtube.com") is True
assert _is_allowed_cookie_domain(".accounts.youtube.com") is True
def test_runtime_gate_accepts_required_set(self):
"""Every REQUIRED domain passes the runtime gate (exact-match tier)."""
for domain in REQUIRED_COOKIE_DOMAINS:
assert _is_allowed_cookie_domain(domain) is True, (
f"{domain!r} must pass the runtime gate (it's in REQUIRED)"
)
def test_runtime_gate_accepts_google_subdomain_optional_siblings(self):
"""Docs / myaccount / Mail pass via the .google.com suffix tier."""
for domain in (
"docs.google.com",
".docs.google.com",
"myaccount.google.com",
".myaccount.google.com",
"mail.google.com",
".mail.google.com",
):
assert _is_allowed_cookie_domain(domain) is True
def test_runtime_gate_still_rejects_lookalikes(self):
"""The permissive gate is still strict against lookalike domains."""
assert _is_allowed_cookie_domain(".not-youtube.com") is False
assert _is_allowed_cookie_domain("notyoutube.com") is False
assert _is_allowed_cookie_domain("evil-google.com") is False
assert _is_allowed_cookie_domain(".google.zz") is False
class TestBuildGoogleCookieDomains:
"""``_build_google_cookie_domains`` is the rookiepy/Firefox feeder."""
def test_default_returns_required_only(self):
"""Default call returns REQUIRED + regional ccTLDs (no OPTIONAL).
This is what changes the rookiepy extraction surface — narrowing
what we ask the browser for at login time.
"""
domains = set(_build_google_cookie_domains())
# Every REQUIRED domain present.
assert REQUIRED_COOKIE_DOMAINS.issubset(domains)
# No OPTIONAL domain present.
assert not (OPTIONAL_COOKIE_DOMAINS & domains)
def test_include_optional_returns_union(self):
"""``include_optional=True`` restores the previous broad set."""
domains = set(_build_google_cookie_domains(include_optional=True))
assert REQUIRED_COOKIE_DOMAINS.issubset(domains)
assert OPTIONAL_COOKIE_DOMAINS.issubset(domains)
def test_include_domains_label_subset(self):
"""``include_domains={'youtube'}`` adds YouTube only — not docs/mail."""
domains = set(_build_google_cookie_domains(include_domains={"youtube"}))
assert REQUIRED_COOKIE_DOMAINS.issubset(domains)
assert OPTIONAL_COOKIE_DOMAINS_BY_LABEL["youtube"].issubset(domains)
# Docs / mail / myaccount are NOT pulled in.
assert OPTIONAL_COOKIE_DOMAINS_BY_LABEL["docs"].isdisjoint(domains)
assert OPTIONAL_COOKIE_DOMAINS_BY_LABEL["mail"].isdisjoint(domains)
assert OPTIONAL_COOKIE_DOMAINS_BY_LABEL["myaccount"].isdisjoint(domains)
def test_include_domains_multi_label(self):
"""``include_domains={'youtube', 'docs'}`` is the union of both."""
domains = set(_build_google_cookie_domains(include_domains={"youtube", "docs"}))
assert OPTIONAL_COOKIE_DOMAINS_BY_LABEL["youtube"].issubset(domains)
assert OPTIONAL_COOKIE_DOMAINS_BY_LABEL["docs"].issubset(domains)
# mail / myaccount NOT pulled in.
assert OPTIONAL_COOKIE_DOMAINS_BY_LABEL["mail"].isdisjoint(domains)
def test_include_domains_all_shortcut(self):
"""``include_domains={'all'}`` is equivalent to every label."""
domains = set(_build_google_cookie_domains(include_domains={"all"}))
for optional_set in OPTIONAL_COOKIE_DOMAINS_BY_LABEL.values():
assert optional_set.issubset(domains)
class TestParseIncludeDomains:
"""``_parse_include_domains`` accepts both flag forms and rejects unknowns."""
def test_empty_returns_empty(self):
assert _parse_include_domains(()) == set()
def test_single_label(self):
assert _parse_include_domains(("youtube",)) == {"youtube"}
def test_multiple_flag_invocations(self):
"""``--include-domains=youtube --include-domains=docs``."""
assert _parse_include_domains(("youtube", "docs")) == {"youtube", "docs"}
def test_comma_separated_single_invocation(self):
"""``--include-domains=youtube,docs`` (the codex-flagged form)."""
assert _parse_include_domains(("youtube,docs",)) == {"youtube", "docs"}
def test_mixed_comma_and_repeated(self):
"""Mix of both forms in the same call."""
labels = _parse_include_domains(("youtube,docs", "mail"))
assert labels == {"youtube", "docs", "mail"}
def test_whitespace_around_commas_tolerated(self):
assert _parse_include_domains(("youtube , docs",)) == {"youtube", "docs"}
def test_lowercased(self):
assert _parse_include_domains(("YouTube,DOCS",)) == {"youtube", "docs"}
def test_empty_fragments_dropped(self):
assert _parse_include_domains(("youtube,,docs,",)) == {"youtube", "docs"}
def test_unknown_label_raises(self):
"""Click surfaces this as a non-zero exit + stderr message."""
import click
with pytest.raises(click.BadParameter) as exc_info:
_parse_include_domains(("zoom",))
assert "zoom" in str(exc_info.value)
# Help text lists the supported labels.
assert "youtube" in str(exc_info.value)
def test_all_label_accepted(self):
assert _parse_include_domains(("all",)) == {"all"}
class TestResolveOptionalCookieDomains:
"""``_resolve_optional_cookie_domains`` flattens labels to a domain set."""
def test_empty_labels(self):
assert _resolve_optional_cookie_domains(set()) == frozenset()
def test_single_label_resolves(self):
result = _resolve_optional_cookie_domains({"youtube"})
assert result == OPTIONAL_COOKIE_DOMAINS_BY_LABEL["youtube"]
def test_all_label_returns_full_optional_set(self):
assert _resolve_optional_cookie_domains({"all"}) == OPTIONAL_COOKIE_DOMAINS
def test_all_takes_precedence_when_combined(self):
"""``--include-domains=all,youtube`` resolves to the full OPTIONAL set."""
assert _resolve_optional_cookie_domains({"all", "youtube"}) == OPTIONAL_COOKIE_DOMAINS
class TestBlastRadiusExtractor:
"""Blast-radius reduction is enforced at extraction time, not runtime.
Contract: rookiepy is asked only for ``REQUIRED_COOKIE_DOMAINS`` by
default. Sibling-product cookies (YouTube) therefore never enter
``storage_state.json`` unless the user explicitly opts in. The
downstream runtime gate is permissive over the full union so
opted-in cookies still flow through.
These tests pin the contract at the *only* choke point that gates
blast radius: the domain list fed to the browser-cookie extractor.
"""
def _raw_cookie(self, domain: str, name: str) -> dict:
return {
"domain": domain,
"name": name,
"value": "v",
"path": "/",
"secure": True,
"expires": None,
"http_only": False,
}
def test_default_extraction_list_excludes_youtube(self):
"""Default login asks rookiepy for REQUIRED only — no YouTube domains.
The actual blast-radius control: rookiepy never returns YouTube
cookies under the default flag set, so they never reach
``storage_state.json``. The downstream runtime gate stays
permissive so opted-in cookies survive the filters.
"""
youtube_variants = frozenset({".youtube.com", "youtube.com"})
domains_default = frozenset(_build_google_cookie_domains())
assert domains_default.isdisjoint(youtube_variants)
def test_opt_in_then_default_resets_extraction_set(self):
"""After ``--include-domains=youtube`` then a default re-login, no YouTube.
Simulates the codex-flagged "user opted in once, then forgot
and re-ran default" case. The extractor must actively exclude on
re-run, not just default to a smaller request set.
"""
youtube_variants = frozenset({".youtube.com", "youtube.com"})
# First run with --include-domains=youtube: YouTube cookies extracted.
domains_optin = frozenset(_build_google_cookie_domains(include_domains={"youtube"}))
# Set-intersection form sidesteps CodeQL's substring-sanitization
# heuristic.
assert youtube_variants & domains_optin
# Second run with default: YouTube is NOT in the extraction list.
domains_default = frozenset(_build_google_cookie_domains())
assert domains_default.isdisjoint(youtube_variants)
def test_youtube_cookies_survive_when_opted_in(self, tmp_path: Path):
"""End-to-end: ``--include-domains=youtube`` persists YouTube cookies.
The Gemini-flagged regression: under the original tightened
runtime gate, ``--include-domains=youtube`` was a silent no-op
because every downstream filter
(:func:`convert_rookiepy_cookies_to_storage_state`,
:func:`extract_cookies_with_domains`,
:func:`build_httpx_cookies_from_storage`) dropped the cookies
again on the way through. This test exercises the full pipeline
and asserts opted-in cookies survive end-to-end.
"""
from notebooklm.auth import (
_is_allowed_auth_domain,
build_httpx_cookies_from_storage,
extract_cookies_with_domains,
)
# Simulate the rookiepy output for a user who passed
# ``--include-domains=youtube``: REQUIRED auth cookies plus a YouTube
# opt-in cookie.
raw = [
self._raw_cookie(".google.com", "SID"),
self._raw_cookie(".google.com", "__Secure-1PSIDTS"),
self._raw_cookie(".youtube.com", "LOGIN_INFO"),
]
# Step 1: rookiepy → storage_state conversion must keep the YouTube
# cookie (the runtime gate is permissive over the union).
storage_state = convert_rookiepy_cookies_to_storage_state(raw)
kept_domains = frozenset(c["domain"] for c in storage_state["cookies"])
assert {".youtube.com"} <= kept_domains, (
"convert_rookiepy_cookies_to_storage_state must keep YouTube "
"cookies once they have been extracted; the runtime gate is "
"permissive over the union."
)
assert any(c["name"] == "LOGIN_INFO" for c in storage_state["cookies"])
# Step 2: storage_state → DomainCookieMap (used by AuthTokens).
cookie_map = extract_cookies_with_domains(storage_state)
assert ("LOGIN_INFO", ".youtube.com", "/") in cookie_map, (
"extract_cookies_with_domains must keep YouTube cookies for the opt-in path."
)
# Step 3: storage_state → httpx jar (used by downloads + refresh).
storage_file = tmp_path / "storage_state.json"
storage_file.write_text(json.dumps(storage_state))
jar = build_httpx_cookies_from_storage(storage_file)
assert jar.get("LOGIN_INFO", domain=".youtube.com") == "v", (
"build_httpx_cookies_from_storage must keep YouTube cookies for the opt-in path."
)
# Step 4: the runtime gate itself accepts every YouTube variant.
for domain in (".youtube.com", "youtube.com", "accounts.youtube.com"):
assert _is_allowed_auth_domain(domain) is True, (
f"_is_allowed_auth_domain({domain!r}) must accept the domain so "
"opted-in cookies survive every downstream filter."
)
class TestTokenVerificationStillWorksAfterMinimumSet:
"""Token-verification regression test (load-bearing for the cookie-domain split).
Asserts that a storage_state.json built from the minimum REQUIRED set
still satisfies the auth-jar construction path — i.e. the same flow
``fetch_tokens_with_domains`` uses. We stop before any network call
because the unit-test suite must run offline; instead we assert that
the storage state passes the upstream validators that ``fetch_tokens``
relies on.
"""
def test_minimum_required_storage_state_validates(self, tmp_path: Path):
"""A storage_state built from REQUIRED-only cookies passes ``extract_cookies_from_storage``.
``extract_cookies_from_storage`` is the validator
``fetch_tokens_with_domains`` calls before making any network
request. If REQUIRED is too narrow, this validation raises
``ValueError`` and token-fetch never even starts.
"""
from notebooklm.auth import extract_cookies_from_storage
# Minimum cookies that a real login must produce. SID is the
# canonical guard (MINIMUM_REQUIRED_COOKIES). The companion auth
# cookies (HSID/SSID/APISID/SAPISID) live on .google.com, fully
# within REQUIRED.
storage_state = {
"cookies": [
{"name": "SID", "value": "v_sid", "domain": ".google.com"},
{"name": "HSID", "value": "v_hsid", "domain": ".google.com"},
{"name": "SSID", "value": "v_ssid", "domain": ".google.com"},
{"name": "APISID", "value": "v_apisid", "domain": ".google.com"},
{"name": "SAPISID", "value": "v_sapisid", "domain": ".google.com"},
# Notebooklm session cookie on the API host.
{
"name": "__Secure-1PSIDTS",
"value": "v_1psidts",
"domain": ".google.com",
},
],
"origins": [],
}
cookies = extract_cookies_from_storage(storage_state)
assert cookies["SID"] == "v_sid"
assert cookies["HSID"] == "v_hsid"
def test_minimum_required_set_round_trips_through_load_httpx_cookies(self, tmp_path: Path):
"""The httpx jar (used by downloads + refresh) is non-empty for REQUIRED-only state."""
from notebooklm._auth.cookies import load_httpx_cookies
storage_state = {
"cookies": [
{"name": "SID", "value": "v_sid", "domain": ".google.com"},
{"name": "HSID", "value": "v_hsid", "domain": ".google.com"},
{"name": "SSID", "value": "v_ssid", "domain": ".google.com"},
{"name": "APISID", "value": "v_apisid", "domain": ".google.com"},
{"name": "SAPISID", "value": "v_sapisid", "domain": ".google.com"},
{
"name": "__Secure-1PSIDTS",
"value": "v_1psidts",
"domain": ".google.com",
},
],
"origins": [],
}
storage_file = tmp_path / "storage.json"
storage_file.write_text(json.dumps(storage_state))
jar = load_httpx_cookies(path=storage_file)
assert jar.get("SID", domain=".google.com") == "v_sid"
class TestLoginCliFlag:
"""``notebooklm login --include-domains`` plumbs through to the extractor."""
def test_login_help_advertises_include_domains(self):
"""``login --help`` mentions the new flag and the supported labels."""
runner = CliRunner()
result = runner.invoke(cli, ["login", "--help"])
assert result.exit_code == 0
assert "--include-domains" in result.output
assert "youtube" in result.output
def test_login_rejects_unknown_include_domain_label(self, monkeypatch):
"""Unknown labels are surfaced as a click error (non-zero exit + stderr)."""
# Block the env-var conflict guard from firing.
monkeypatch.delenv("NOTEBOOKLM_AUTH_JSON", raising=False)
runner = CliRunner()
result = runner.invoke(
cli,
[
"login",
"--browser-cookies",
"chrome",
"--include-domains",
"zoom",
],
)
assert result.exit_code != 0
# Click renders BadParameter to stderr-style output captured in result.output.
assert "zoom" in result.output or "zoom" in (result.stderr or "")
def test_login_include_domains_forwards_to_extractor(self, monkeypatch):
"""``--include-domains=youtube`` is parsed and threaded to login helper."""
monkeypatch.delenv("NOTEBOOKLM_AUTH_JSON", raising=False)
runner = CliRunner()
with patch_session_login_dual("_login_browser_cookies_single") as login_single:
result = runner.invoke(
cli,
[
"login",
"--browser-cookies",
"chrome",
"--include-domains",
"youtube,docs",
],
)
assert result.exit_code == 0, result.output
login_single.assert_called_once()
kwargs = login_single.call_args.kwargs
assert kwargs["include_domains"] == {"youtube", "docs"}
def test_login_default_no_include_domains_emits_migration_note(self, monkeypatch):
"""Default browser-cookies login prints the migration note."""
monkeypatch.delenv("NOTEBOOKLM_AUTH_JSON", raising=False)
runner = CliRunner()
with patch_session_login_dual("_login_browser_cookies_single"):
result = runner.invoke(
cli,
["login", "--browser-cookies", "chrome"],
)
assert result.exit_code == 0, result.output
assert "sibling-product cookies not included" in result.output
def test_login_with_include_domains_suppresses_migration_note(self, monkeypatch):
"""The migration note is suppressed once the user opts in."""
monkeypatch.delenv("NOTEBOOKLM_AUTH_JSON", raising=False)
runner = CliRunner()
with patch_session_login_dual("_login_browser_cookies_single"):
result = runner.invoke(
cli,
[
"login",
"--browser-cookies",
"chrome",
"--include-domains",
"all",
],
)
assert result.exit_code == 0, result.output
assert "sibling-product cookies not included" not in result.output
def test_login_include_domains_on_playwright_path_no_longer_warns(self, monkeypatch, tmp_path):
"""``--include-domains`` now applies on the Playwright path (P1-17).
Prior to P1-17 the Playwright login flow ignored ``--include-domains``
and emitted a "no effect" warning. Now the flag drives the
write-time cookie-domain allowlist filter so sibling-product cookies
only get persisted when the user explicitly opts in. Confirm the
legacy warning is gone — the test guarding the old behavior is
repurposed as a regression guard so we don't reintroduce it.
"""
monkeypatch.delenv("NOTEBOOKLM_AUTH_JSON", raising=False)
monkeypatch.setenv("NOTEBOOKLM_HOME", str(tmp_path))
runner = CliRunner()
# Stub Playwright import to break out before any real browser launch;
# we only care about the pre-launch warning emission path.
with patch.dict("sys.modules", {"playwright": None, "playwright.sync_api": None}):
result = runner.invoke(cli, ["login", "--include-domains", "youtube"])
# CodeRabbit feedback: pin the failure path so the test cannot pass
# spuriously if the warning never fired (e.g. if Playwright became
# available via some other import shim). The Playwright-not-installed
# branch in ``_run_playwright_login`` exits 1 with a clear message.
assert result.exit_code == 1
assert "Playwright not installed" in result.output
assert "--include-domains has no effect without --browser-cookies" not in result.output
class TestAuthRefreshCliFlag:
"""``notebooklm auth refresh --include-domains`` follows the same plumbing."""
def test_refresh_include_domains_requires_browser_cookies(self, monkeypatch, tmp_path):
"""``--include-domains`` without ``--browser-cookies`` is rejected."""
monkeypatch.delenv("NOTEBOOKLM_AUTH_JSON", raising=False)
monkeypatch.setenv("NOTEBOOKLM_HOME", str(tmp_path))
# A storage file has to exist so refresh doesn't bail on FileNotFound
# before reaching the validation.
(tmp_path / "storage_state.json").write_text("{}")
runner = CliRunner()
result = runner.invoke(cli, ["auth", "refresh", "--include-domains", "youtube"])
assert result.exit_code != 0
assert "--browser-cookies" in result.output
def test_refresh_include_domains_forwards_when_browser_cookies_set(self, monkeypatch, tmp_path):
"""``--include-domains`` is forwarded to the browser-refresh helper."""
monkeypatch.delenv("NOTEBOOKLM_AUTH_JSON", raising=False)
monkeypatch.setenv("NOTEBOOKLM_HOME", str(tmp_path))
runner = CliRunner()
with patch_session_login_dual("_refresh_from_browser_cookies") as helper:
result = runner.invoke(
cli,
[
"auth",
"refresh",
"--browser-cookies",
"chrome",
"--include-domains",
"youtube",
],
)
assert result.exit_code == 0, result.output
helper.assert_called_once()
assert helper.call_args.kwargs["include_domains"] == {"youtube"}
class TestAuthInspectCliFlag:
"""``notebooklm auth inspect --include-domains`` thread-through."""
def test_inspect_help_advertises_include_domains(self):
runner = CliRunner()
result = runner.invoke(cli, ["auth", "inspect", "--help"])
assert result.exit_code == 0
assert "--include-domains" in result.output
def test_inspect_include_domains_forwarded_to_enumerate(self):
"""``--include-domains=youtube`` reaches ``_enumerate_browser_accounts``."""
runner = CliRunner()
with patch_session_login_dual("_enumerate_browser_accounts") as enum:
enum.return_value = ([], [])
result = runner.invoke(
cli,
[
"auth",
"inspect",
"--browser",
"chrome",
"--include-domains",
"youtube",
"--json",
],
)
assert result.exit_code == 0, result.output
enum.assert_called_once()
assert enum.call_args.kwargs["include_domains"] == {"youtube"}