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

274 lines
11 KiB
Python

"""Unit tests for the CDP-attach arm of browser capture (``run_cdp_capture``).
An alternative credential source for layer-3 re-auth: instead of launching the
dedicated persistent profile, attach to an operator-pointed already-running
Chrome over the Chrome DevTools Protocol. The motivation is freshness — the
operator's daily Chrome is continuously Google-refreshed where our dedicated
profile can go stale in the long-idle case.
Covers:
* authenticated landing (lands on the NotebookLM host) → capture / filter /
atomically persist ``storage_state.json`` (the SAME path the other arms use);
* redirected off-host → raise :class:`HeadlessLoginRequiredError` loudly (the
attached browser's session cannot reach NotebookLM); NEVER persists;
* lifecycle: teardown only DISCONNECTS (``browser.close()``) and never closes
the operator's context;
* the same cookie-domain allowlist applies, so the on-disk state is equivalent
regardless of the credential source.
The Playwright client is faked via ``patch("playwright.sync_api.sync_playwright")``
so no real browser / network is required and ``playwright`` stays lazily
imported.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from notebooklm._auth.browser_capture import BrowserCapturePlan, run_cdp_capture
from notebooklm.exceptions import HeadlessLoginRequiredError
class _RaisingCaptureIO:
"""``BrowserCaptureIO`` whose ``fail`` raises (mirrors the headless sink)."""
def __init__(self) -> None:
self.emitted: list[Any] = []
def emit(self, *args: Any, **kwargs: Any) -> None:
self.emitted.append(args)
def fail(self, code: int) -> Any:
raise HeadlessLoginRequiredError(f"io.fail({code})")
def run_async(self, coro: Any) -> Any: # pragma: no cover - not reached
raise AssertionError("run_async not used in the CDP arm")
class _FakeSyncPlaywright:
def __init__(self, playwright: Any) -> None:
self._playwright = playwright
def __enter__(self) -> Any:
return self._playwright
def __exit__(self, *exc: Any) -> bool:
return False
def _fake_cdp_browser(
url: str,
*,
cookies: list[dict] | None = None,
has_context: bool = True,
) -> tuple[Any, Any, Any, Any]:
"""Build a fake playwright whose CDP-attached browser lands on ``url``.
Returns ``(playwright, browser, context, page)`` where ``page`` is the
TEMPORARY page the capture creates via ``context.new_page()`` — so tests can
assert the temporary-page lifecycle and the disconnect-only teardown. The
operator's own pre-existing pages are deliberately NOT modeled as the
captured page: the capture must never navigate them.
"""
page = MagicMock()
page.url = url
page.goto.return_value = None
page.content.return_value = "<html></html>"
context = MagicMock()
context.new_page.return_value = page
context.storage_state.return_value = {
"cookies": cookies if cookies is not None else [],
"origins": [],
}
browser = MagicMock()
browser.contexts = [context] if has_context else []
playwright = MagicMock()
playwright.chromium.connect_over_cdp.return_value = browser
return playwright, browser, context, page
def _run_cdp(plan: BrowserCapturePlan, io: Any, playwright: Any, cdp_url: str) -> Any:
with patch(
"playwright.sync_api.sync_playwright",
side_effect=lambda: _FakeSyncPlaywright(playwright),
):
return run_cdp_capture(plan, io, cdp_url=cdp_url)
def _plan(tmp_path: Path) -> BrowserCapturePlan:
return BrowserCapturePlan(
browser="chromium",
browser_profile=tmp_path, # ignored on the CDP arm
storage_path=tmp_path / "storage_state.json",
)
# ---------------------------------------------------------------------------
# Authenticated landing → capture + persist (same allowlist as other arms)
# ---------------------------------------------------------------------------
@pytest.mark.requires_playwright
def test_cdp_authenticated_landing_persists_and_filters(tmp_path: Path) -> None:
cookies = [
{"name": "SID", "value": "v", "domain": ".google.com", "path": "/"},
# A sibling-product cookie the domain filter must DROP.
{"name": "X", "value": "y", "domain": "mail.google.com", "path": "/"},
]
playwright, browser, _context, page = _fake_cdp_browser(
"https://notebooklm.google.com/", cookies=cookies
)
io = _RaisingCaptureIO()
result = _run_cdp(_plan(tmp_path), io, playwright, "http://127.0.0.1:9222")
# Attached to the operator-pointed endpoint.
playwright.chromium.connect_over_cdp.assert_called_once_with("http://127.0.0.1:9222")
# We navigated our temporary page to the NotebookLM base URL, using an early
# lifecycle state -- the streaming SPA never fires "load", so the default
# wait_until would waste 30s then TimeoutError before classification (#1697).
page.goto.assert_called_once()
assert page.goto.call_args.kwargs.get("wait_until") in {"commit", "domcontentloaded"}
# Persisted, with the same domain allowlist (mail.google.com dropped).
storage = tmp_path / "storage_state.json"
assert storage.exists()
persisted = json.loads(storage.read_text(encoding="utf-8"))
names = {c["name"] for c in persisted["cookies"]}
assert "SID" in names
assert "X" not in names
assert result is not None
# Teardown DISCONNECTS the client (never kills the operator's Chrome).
browser.close.assert_called_once()
@pytest.mark.requires_playwright
def test_cdp_uses_temporary_page_in_existing_context(tmp_path: Path) -> None:
"""Reuse the operator's EXISTING context but navigate/close our OWN page."""
playwright, browser, context, page = _fake_cdp_browser("https://notebooklm.google.com/")
io = _RaisingCaptureIO()
_run_cdp(_plan(tmp_path), io, playwright, "http://127.0.0.1:9222")
# Reused the existing context (never created a fresh, logged-out one).
browser.new_context.assert_not_called()
# Created a TEMPORARY page we own, and closed ONLY it (never the operator's).
context.new_page.assert_called_once()
page.close.assert_called_once()
context.close.assert_not_called()
# ---------------------------------------------------------------------------
# Redirected off-host → loud failure, nothing persisted
# ---------------------------------------------------------------------------
@pytest.mark.requires_playwright
def test_cdp_off_host_landing_raises_loudly_and_persists_nothing(tmp_path: Path) -> None:
playwright, browser, _context, page = _fake_cdp_browser(
"https://accounts.google.com/signin/v2/identifier"
)
io = _RaisingCaptureIO()
with pytest.raises(HeadlessLoginRequiredError, match="cannot reach NotebookLM"):
_run_cdp(_plan(tmp_path), io, playwright, "http://127.0.0.1:9222")
# Same security boundary as the headless arm: nothing persisted on a dead
# session; the temporary page is closed and the client disconnected.
assert not (tmp_path / "storage_state.json").exists()
page.close.assert_called_once()
browser.close.assert_called_once()
# ---------------------------------------------------------------------------
# No context to harvest → fail loudly (never fabricate a logged-out context)
# ---------------------------------------------------------------------------
@pytest.mark.requires_playwright
def test_cdp_no_context_raises_and_persists_nothing(tmp_path: Path) -> None:
"""An attached browser with no context cannot supply a session → raise."""
playwright, browser, _context, _page = _fake_cdp_browser(
"https://notebooklm.google.com/", has_context=False
)
io = _RaisingCaptureIO()
with pytest.raises(HeadlessLoginRequiredError, match="no browser"):
_run_cdp(_plan(tmp_path), io, playwright, "http://127.0.0.1:9222")
assert not (tmp_path / "storage_state.json").exists()
# We still disconnected, and never fabricated a context.
browser.new_context.assert_not_called()
browser.close.assert_called_once()
# ---------------------------------------------------------------------------
# Cookie-value redaction: malformed live-browser cookies must not leak values
# ---------------------------------------------------------------------------
@pytest.mark.requires_playwright
def test_cdp_malformed_cookie_value_never_logged(tmp_path: Path, caplog) -> None:
"""Malformed cookies from the live browser must not leak their value to logs.
The CDP arm feeds ``context.storage_state()`` from the operator's running
Chrome through the shared domain filter, whose malformed-row warnings must
log only a value-free shape — never the cookie ``value`` (a live
credential).
"""
import logging
sentinel = "SUPER_SECRET_COOKIE_VALUE_4f2a"
cookies = [
# Malformed: non-str domain — triggers the "non-str domain" warning,
# which must NOT echo the value.
{"name": "bad", "value": sentinel, "domain": 12345, "path": "/"},
# A valid allowed cookie so the capture still persists something.
{"name": "SID", "value": "ok", "domain": ".google.com", "path": "/"},
]
playwright, _browser, _context, _page = _fake_cdp_browser(
"https://notebooklm.google.com/", cookies=cookies
)
io = _RaisingCaptureIO()
with caplog.at_level(logging.WARNING):
_run_cdp(_plan(tmp_path), io, playwright, "http://127.0.0.1:9222")
# The malformed row WAS flagged...
assert any("non-str domain" in r.message for r in caplog.records)
# ...but its value never appears in any log record.
assert sentinel not in caplog.text
def test_safe_cookie_shape_is_value_free() -> None:
"""``_safe_cookie_shape`` summarizes structure with NO values."""
from notebooklm._auth.browser_capture import _safe_cookie_shape
shape = _safe_cookie_shape({"name": "SID", "value": "SECRET", "domain": 5})
assert "SECRET" not in shape
# Keys and per-field types are present.
assert "name" in shape and "value" in shape and "domain" in shape
assert "int" in shape # domain's type
def test_safe_cookie_shape_tolerates_non_str_keys() -> None:
"""A malformed cookie with a non-str key must not raise KeyError.
This helper exists to *describe* malformed rows, so it must never itself
choke on one (regression for a ``cookie[str(k)]`` re-subscript bug).
"""
from notebooklm._auth.browser_capture import _safe_cookie_shape
shape = _safe_cookie_shape({3: "x", "value": "SECRET"})
assert "SECRET" not in shape
assert "3" in shape
if __name__ == "__main__": # pragma: no cover
raise SystemExit(pytest.main([__file__, "-v"]))