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
605 lines
22 KiB
Python
605 lines
22 KiB
Python
"""Unit tests for the layer-3 headless re-auth decision layer.
|
||
|
||
Covers :mod:`notebooklm._auth.headless_reauth`:
|
||
|
||
* the opt-in × profile-present × failure-class decision matrix,
|
||
* the three typed honest outcomes (UNAVAILABLE / FAILED / SUCCESS) and that
|
||
SUCCESS is never reported on a dead/redirected session,
|
||
* the env-var opt-in gate (``NOTEBOOKLM_HEADLESS_REAUTH=1``),
|
||
* the default-unchanged behavior (no opt-in + no profile → UNAVAILABLE, the
|
||
browser is never launched).
|
||
|
||
The browser drive is faked end-to-end via ``run_browser_capture`` so no real
|
||
Playwright / network is needed; ``playwright`` stays lazily imported.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
from notebooklm._auth import headless_reauth as hr
|
||
from notebooklm._auth.headless_reauth import (
|
||
HeadlessReauthResult,
|
||
HeadlessReauthStatus,
|
||
attempt_headless_reauth,
|
||
headless_reauth_env_enabled,
|
||
)
|
||
from notebooklm.exceptions import HeadlessLoginRequiredError
|
||
|
||
|
||
def _make_profile(tmp_path: Path) -> Path:
|
||
"""Create (idempotently) a non-empty browser-profile dir on disk."""
|
||
profile = tmp_path / "browser_profile"
|
||
profile.mkdir(exist_ok=True)
|
||
(profile / "Default").mkdir(exist_ok=True) # a populated profile dir
|
||
return profile
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# env opt-in gate
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_env_enabled_only_for_exact_one() -> None:
|
||
assert headless_reauth_env_enabled({"NOTEBOOKLM_HEADLESS_REAUTH": "1"}) is True
|
||
assert headless_reauth_env_enabled({"NOTEBOOKLM_HEADLESS_REAUTH": "0"}) is False
|
||
assert headless_reauth_env_enabled({"NOTEBOOKLM_HEADLESS_REAUTH": "true"}) is False
|
||
assert headless_reauth_env_enabled({}) is False
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Decision matrix: opt-in OFF → UNAVAILABLE, never launches a browser
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_optin_off_is_unavailable_and_never_launches(tmp_path: Path, monkeypatch) -> None:
|
||
"""No opt-in + no env → UNAVAILABLE; the capture core is never reached.
|
||
|
||
This pins the locked design decision: L3 NEVER fires by default.
|
||
"""
|
||
profile = _make_profile(tmp_path)
|
||
|
||
def _boom(*_a, **_k): # pragma: no cover - must not be called
|
||
raise AssertionError("run_browser_capture must not be called when opt-in is off")
|
||
|
||
monkeypatch.setattr(hr, "run_browser_capture", _boom)
|
||
|
||
result = attempt_headless_reauth(
|
||
storage_path=tmp_path / "storage_state.json",
|
||
allow_headless=False,
|
||
browser_profile=profile,
|
||
env={},
|
||
)
|
||
assert result.status is HeadlessReauthStatus.UNAVAILABLE
|
||
assert result.succeeded is False
|
||
assert "not enabled" in result.reason
|
||
|
||
|
||
def test_optin_off_even_with_profile_is_unavailable(tmp_path: Path, monkeypatch) -> None:
|
||
monkeypatch.setattr(hr, "run_browser_capture", lambda *a, **k: None)
|
||
result = attempt_headless_reauth(
|
||
storage_path=tmp_path / "storage_state.json",
|
||
allow_headless=False,
|
||
browser_profile=_make_profile(tmp_path),
|
||
env={"NOTEBOOKLM_HEADLESS_REAUTH": "0"},
|
||
)
|
||
assert result.status is HeadlessReauthStatus.UNAVAILABLE
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Decision matrix: opt-in ON but no reusable profile → UNAVAILABLE
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_optin_on_no_profile_dir_is_unavailable(tmp_path: Path, monkeypatch) -> None:
|
||
monkeypatch.setattr(hr, "run_browser_capture", lambda *a, **k: None)
|
||
result = attempt_headless_reauth(
|
||
storage_path=tmp_path / "storage_state.json",
|
||
allow_headless=True,
|
||
browser_profile=tmp_path / "does_not_exist",
|
||
env={},
|
||
)
|
||
assert result.status is HeadlessReauthStatus.UNAVAILABLE
|
||
assert "no reusable browser profile" in result.reason
|
||
|
||
|
||
def test_optin_on_empty_profile_dir_is_unavailable(tmp_path: Path, monkeypatch) -> None:
|
||
"""A freshly-mkdir'd but empty profile holds no Google session → decline."""
|
||
monkeypatch.setattr(hr, "run_browser_capture", lambda *a, **k: None)
|
||
empty = tmp_path / "browser_profile"
|
||
empty.mkdir()
|
||
result = attempt_headless_reauth(
|
||
storage_path=tmp_path / "storage_state.json",
|
||
allow_headless=True,
|
||
browser_profile=empty,
|
||
env={},
|
||
)
|
||
assert result.status is HeadlessReauthStatus.UNAVAILABLE
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Decision matrix: opt-in ON + profile present → drives the browser
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_success_when_capture_succeeds(tmp_path: Path, monkeypatch) -> None:
|
||
"""Capture returns normally → SUCCESS, storage_path carried out."""
|
||
storage = tmp_path / "storage_state.json"
|
||
captured: dict[str, object] = {}
|
||
|
||
def _fake_capture(plan, io, *, headless, interactive):
|
||
captured["headless"] = headless
|
||
captured["interactive"] = interactive
|
||
captured["profile"] = plan.browser_profile
|
||
return None
|
||
|
||
profile = _make_profile(tmp_path)
|
||
monkeypatch.setattr(hr, "run_browser_capture", _fake_capture)
|
||
# Ensure the playwright-import probe passes by faking it present.
|
||
monkeypatch.setitem(__import__("sys").modules, "playwright", _DummyModule())
|
||
monkeypatch.setitem(__import__("sys").modules, "playwright.sync_api", _DummyModule())
|
||
|
||
result = attempt_headless_reauth(
|
||
storage_path=storage,
|
||
allow_headless=True,
|
||
browser_profile=profile,
|
||
env={},
|
||
)
|
||
assert result.status is HeadlessReauthStatus.SUCCESS
|
||
assert result.succeeded is True
|
||
assert result.storage_path == storage
|
||
# The headless arm must be driven non-interactively, headless.
|
||
assert captured == {
|
||
"headless": True,
|
||
"interactive": False,
|
||
"profile": profile,
|
||
}
|
||
|
||
|
||
def test_failed_when_profile_session_also_dead(tmp_path: Path, monkeypatch) -> None:
|
||
"""Headless landed on the Google login page → FAILED, NEVER success."""
|
||
|
||
def _redirected(plan, io, *, headless, interactive):
|
||
raise HeadlessLoginRequiredError("redirected to login")
|
||
|
||
monkeypatch.setattr(hr, "run_browser_capture", _redirected)
|
||
monkeypatch.setitem(__import__("sys").modules, "playwright", _DummyModule())
|
||
monkeypatch.setitem(__import__("sys").modules, "playwright.sync_api", _DummyModule())
|
||
|
||
result = attempt_headless_reauth(
|
||
storage_path=tmp_path / "storage_state.json",
|
||
allow_headless=True,
|
||
browser_profile=_make_profile(tmp_path),
|
||
env={},
|
||
)
|
||
assert result.status is HeadlessReauthStatus.FAILED
|
||
assert result.succeeded is False
|
||
assert result.storage_path is None
|
||
assert "expired" in result.reason
|
||
|
||
|
||
def test_failed_on_unexpected_capture_error(tmp_path: Path, monkeypatch) -> None:
|
||
"""An unexpected capture exception → FAILED (best-effort recovery)."""
|
||
|
||
def _boom(plan, io, *, headless, interactive):
|
||
raise RuntimeError("launch blew up")
|
||
|
||
monkeypatch.setattr(hr, "run_browser_capture", _boom)
|
||
monkeypatch.setitem(__import__("sys").modules, "playwright", _DummyModule())
|
||
monkeypatch.setitem(__import__("sys").modules, "playwright.sync_api", _DummyModule())
|
||
|
||
result = attempt_headless_reauth(
|
||
storage_path=tmp_path / "storage_state.json",
|
||
allow_headless=True,
|
||
browser_profile=_make_profile(tmp_path),
|
||
env={},
|
||
)
|
||
assert result.status is HeadlessReauthStatus.FAILED
|
||
# Error TYPE only — never a cookie value.
|
||
assert "RuntimeError" in result.reason
|
||
|
||
|
||
def test_env_optin_drives_browser_without_explicit_flag(tmp_path: Path, monkeypatch) -> None:
|
||
"""``NOTEBOOKLM_HEADLESS_REAUTH=1`` enables L3 even with allow_headless=False."""
|
||
monkeypatch.setattr(hr, "run_browser_capture", lambda *a, **k: None)
|
||
monkeypatch.setitem(__import__("sys").modules, "playwright", _DummyModule())
|
||
monkeypatch.setitem(__import__("sys").modules, "playwright.sync_api", _DummyModule())
|
||
|
||
result = attempt_headless_reauth(
|
||
storage_path=tmp_path / "storage_state.json",
|
||
allow_headless=False,
|
||
browser_profile=_make_profile(tmp_path),
|
||
env={"NOTEBOOKLM_HEADLESS_REAUTH": "1"},
|
||
)
|
||
assert result.status is HeadlessReauthStatus.SUCCESS
|
||
|
||
|
||
def test_unavailable_when_playwright_missing(tmp_path: Path, monkeypatch) -> None:
|
||
"""Opt-in + profile, but the ``browser`` extra is absent → UNAVAILABLE.
|
||
|
||
Distinct from FAILED: there is nothing to drive, not a dead session.
|
||
"""
|
||
import builtins
|
||
|
||
real_import = builtins.__import__
|
||
|
||
def _no_playwright(name, *args, **kwargs):
|
||
if name == "playwright.sync_api" or name == "playwright":
|
||
raise ImportError("No module named 'playwright'")
|
||
return real_import(name, *args, **kwargs)
|
||
|
||
def _must_not_run(*_a, **_k): # pragma: no cover
|
||
raise AssertionError("capture must not run when playwright is missing")
|
||
|
||
monkeypatch.setattr(hr, "run_browser_capture", _must_not_run)
|
||
monkeypatch.setattr(builtins, "__import__", _no_playwright)
|
||
|
||
result = attempt_headless_reauth(
|
||
storage_path=tmp_path / "storage_state.json",
|
||
allow_headless=True,
|
||
browser_profile=_make_profile(tmp_path),
|
||
env={},
|
||
)
|
||
assert result.status is HeadlessReauthStatus.UNAVAILABLE
|
||
assert "playwright" in result.reason
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# HeadlessReauthResult convenience
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_result_succeeded_property() -> None:
|
||
assert HeadlessReauthResult(HeadlessReauthStatus.SUCCESS, "ok").succeeded is True
|
||
assert HeadlessReauthResult(HeadlessReauthStatus.FAILED, "no").succeeded is False
|
||
assert HeadlessReauthResult(HeadlessReauthStatus.UNAVAILABLE, "no").succeeded is False
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Explicit-path coalescing: concurrent attempts → ONE browser per profile
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_concurrent_explicit_attempts_coalesce_to_one_browser(tmp_path: Path, monkeypatch) -> None:
|
||
"""N concurrent ``attempt_headless_reauth`` calls drive ONE browser.
|
||
|
||
The explicit ``refresh_auth(allow_headless=True)`` entry bypasses the
|
||
mid-RPC coordinator's single-flight, so the per-storage-path drive lock +
|
||
freshness skip in ``attempt_headless_reauth`` is what prevents redundant
|
||
browsers. The leader writes the storage file (advancing its mtime); waiting
|
||
followers observe the fresh file and coalesce.
|
||
"""
|
||
import threading
|
||
import time
|
||
|
||
storage = tmp_path / "storage_state.json"
|
||
profile = _make_profile(tmp_path)
|
||
drives = {"count": 0}
|
||
barrier = threading.Barrier(6)
|
||
|
||
def _slow_capture(plan, io, *, headless, interactive):
|
||
drives["count"] += 1
|
||
time.sleep(0.05) # hold the lock so followers pile up behind it
|
||
# Simulate the real capture writing fresh storage (advances mtime).
|
||
plan.storage_path.write_text('{"cookies": [], "origins": []}', encoding="utf-8")
|
||
|
||
monkeypatch.setattr(hr, "run_browser_capture", _slow_capture)
|
||
monkeypatch.setitem(__import__("sys").modules, "playwright", _DummyModule())
|
||
monkeypatch.setitem(__import__("sys").modules, "playwright.sync_api", _DummyModule())
|
||
|
||
results: list[HeadlessReauthResult] = []
|
||
results_lock = threading.Lock()
|
||
|
||
def _worker() -> None:
|
||
barrier.wait()
|
||
res = attempt_headless_reauth(
|
||
storage_path=storage, allow_headless=True, browser_profile=profile, env={}
|
||
)
|
||
with results_lock:
|
||
results.append(res)
|
||
|
||
threads = [threading.Thread(target=_worker) for _ in range(6)]
|
||
for t in threads:
|
||
t.start()
|
||
for t in threads:
|
||
t.join()
|
||
|
||
# Exactly one real browser drive; all six callers report SUCCESS (the
|
||
# leader drove, the followers coalesced onto the fresh storage).
|
||
assert drives["count"] == 1
|
||
assert len(results) == 6
|
||
assert all(r.status is HeadlessReauthStatus.SUCCESS for r in results)
|
||
|
||
|
||
class _DummyModule:
|
||
"""Stand-in for the ``playwright`` / ``playwright.sync_api`` modules.
|
||
|
||
Only used to satisfy the function-local ``import playwright.sync_api``
|
||
availability probe in :func:`attempt_headless_reauth` without installing
|
||
the real extra; the actual capture is faked via ``run_browser_capture``.
|
||
"""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Readiness probe (doctor diagnostics): credential-free, launches nothing
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_readiness_ready_when_profile_present_and_playwright(tmp_path: Path, monkeypatch) -> None:
|
||
"""Profile present + playwright importable → available, ready detail."""
|
||
profile = _make_profile(tmp_path)
|
||
monkeypatch.setattr(hr, "_playwright_installed", lambda: True)
|
||
|
||
readiness = hr.headless_reauth_readiness(browser_profile=profile)
|
||
|
||
assert readiness.profile_present is True
|
||
assert readiness.playwright_installed is True
|
||
assert readiness.available is True
|
||
assert "ready" in readiness.detail
|
||
assert "NOTEBOOKLM_HEADLESS_REAUTH" in readiness.detail
|
||
|
||
|
||
def test_readiness_unavailable_without_profile(tmp_path: Path, monkeypatch) -> None:
|
||
monkeypatch.setattr(hr, "_playwright_installed", lambda: True)
|
||
|
||
readiness = hr.headless_reauth_readiness(browser_profile=tmp_path / "nope")
|
||
|
||
assert readiness.profile_present is False
|
||
assert readiness.available is False
|
||
assert "no reusable browser profile" in readiness.detail
|
||
|
||
|
||
def test_readiness_unavailable_without_playwright(tmp_path: Path, monkeypatch) -> None:
|
||
profile = _make_profile(tmp_path)
|
||
monkeypatch.setattr(hr, "_playwright_installed", lambda: False)
|
||
|
||
readiness = hr.headless_reauth_readiness(browser_profile=profile)
|
||
|
||
assert readiness.profile_present is True
|
||
assert readiness.playwright_installed is False
|
||
assert readiness.available is False
|
||
assert "playwright not installed" in readiness.detail
|
||
|
||
|
||
def test_readiness_reports_both_missing(tmp_path: Path, monkeypatch) -> None:
|
||
monkeypatch.setattr(hr, "_playwright_installed", lambda: False)
|
||
|
||
readiness = hr.headless_reauth_readiness(browser_profile=tmp_path / "nope")
|
||
|
||
assert readiness.available is False
|
||
assert "no reusable browser profile" in readiness.detail
|
||
assert "playwright not installed" in readiness.detail
|
||
|
||
|
||
def test_readiness_never_drives_a_browser(tmp_path: Path, monkeypatch) -> None:
|
||
"""The readiness probe must never launch the capture core."""
|
||
|
||
def _boom(*_a, **_k): # pragma: no cover - must not be called
|
||
raise AssertionError("headless_reauth_readiness must not drive a browser")
|
||
|
||
monkeypatch.setattr(hr, "run_browser_capture", _boom)
|
||
monkeypatch.setattr(hr, "_playwright_installed", lambda: True)
|
||
|
||
readiness = hr.headless_reauth_readiness(browser_profile=_make_profile(tmp_path))
|
||
assert readiness.available is True
|
||
|
||
|
||
def test_playwright_installed_true_with_extra() -> None:
|
||
"""The browser extra IS installed in the test env, so the probe is True."""
|
||
assert hr._playwright_installed() is True
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# CDP attach arm (alternative credential source): resolution + routing
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_resolve_cdp_url_explicit_arg_wins() -> None:
|
||
assert (
|
||
hr.resolve_cdp_url(
|
||
"http://127.0.0.1:9222", {"NOTEBOOKLM_HEADLESS_REAUTH_CDP_URL": "http://x"}
|
||
)
|
||
== "http://127.0.0.1:9222"
|
||
)
|
||
|
||
|
||
def test_resolve_cdp_url_falls_back_to_env() -> None:
|
||
assert (
|
||
hr.resolve_cdp_url(None, {"NOTEBOOKLM_HEADLESS_REAUTH_CDP_URL": "http://127.0.0.1:9222"})
|
||
== "http://127.0.0.1:9222"
|
||
)
|
||
|
||
|
||
def test_resolve_cdp_url_blank_is_unset() -> None:
|
||
assert hr.resolve_cdp_url(None, {"NOTEBOOKLM_HEADLESS_REAUTH_CDP_URL": " "}) is None
|
||
assert hr.resolve_cdp_url("", {}) is None
|
||
assert hr.resolve_cdp_url(None, {}) is None
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"url",
|
||
[
|
||
"http://127.0.0.1:9222",
|
||
"http://localhost:9222",
|
||
"http://[::1]:9222",
|
||
"http://127.5.6.7:9222", # anywhere in 127.0.0.0/8
|
||
"ws://127.0.0.1:9222/devtools/browser/abc",
|
||
"127.0.0.1:9222", # bare host:port (no scheme)
|
||
],
|
||
)
|
||
def test_resolve_cdp_url_allows_loopback(url: str) -> None:
|
||
"""Loopback endpoints are the only sanctioned LOCAL-ONLY targets."""
|
||
assert hr.resolve_cdp_url(url, {}) == url
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"url",
|
||
[
|
||
"http://remote-host:9222",
|
||
"http://10.0.0.5:9222", # private LAN, NOT loopback
|
||
"http://192.168.1.10:9222",
|
||
"http://0.0.0.0:9222", # wildcard bind is not loopback
|
||
"http://example.com:9222",
|
||
"not a url",
|
||
],
|
||
)
|
||
def test_resolve_cdp_url_rejects_non_loopback(url: str, caplog) -> None:
|
||
"""Non-loopback (remote / LAN / wildcard / junk) endpoints are rejected.
|
||
|
||
This is the LOCAL-UNATTENDED-ONLY boundary: a remote CDP endpoint must
|
||
never reach ``connect_over_cdp``. The rejection must NOT log the endpoint.
|
||
"""
|
||
import logging
|
||
|
||
with caplog.at_level(logging.WARNING, logger="notebooklm.auth"):
|
||
assert hr.resolve_cdp_url(url, {}) is None
|
||
# The endpoint value must not appear in any log record.
|
||
assert url not in caplog.text
|
||
|
||
|
||
def test_cdp_path_skips_profile_gate_and_drives_cdp(tmp_path: Path, monkeypatch) -> None:
|
||
"""A resolved CDP URL routes to run_cdp_capture WITHOUT requiring a profile.
|
||
|
||
The dedicated profile is intentionally NOT created here: the CDP arm must
|
||
not decline for a missing profile (the live browser is the source).
|
||
"""
|
||
storage = tmp_path / "storage_state.json"
|
||
calls: dict[str, Any] = {}
|
||
|
||
def _fake_cdp(plan, io, *, cdp_url):
|
||
calls["cdp_url"] = cdp_url
|
||
storage.write_text("{}", encoding="utf-8")
|
||
return None
|
||
|
||
def _no_launch(*_a, **_k): # pragma: no cover - must not be called
|
||
raise AssertionError("the CDP arm must not launch the dedicated profile")
|
||
|
||
monkeypatch.setattr(hr, "run_cdp_capture", _fake_cdp)
|
||
monkeypatch.setattr(hr, "run_browser_capture", _no_launch)
|
||
monkeypatch.setattr(hr, "_playwright_installed", lambda: True)
|
||
|
||
result = attempt_headless_reauth(
|
||
storage_path=storage,
|
||
allow_headless=True,
|
||
cdp_url="http://127.0.0.1:9222",
|
||
env={},
|
||
)
|
||
|
||
assert result.status is HeadlessReauthStatus.SUCCESS
|
||
assert calls["cdp_url"] == "http://127.0.0.1:9222"
|
||
|
||
|
||
def test_cdp_url_from_env_routes_to_cdp(tmp_path: Path, monkeypatch) -> None:
|
||
storage = tmp_path / "storage_state.json"
|
||
used: dict[str, Any] = {}
|
||
|
||
def _fake_cdp(plan, io, *, cdp_url):
|
||
used["cdp_url"] = cdp_url
|
||
storage.write_text("{}", encoding="utf-8")
|
||
|
||
monkeypatch.setattr(hr, "run_cdp_capture", _fake_cdp)
|
||
monkeypatch.setattr(hr, "_playwright_installed", lambda: True)
|
||
|
||
result = attempt_headless_reauth(
|
||
storage_path=storage,
|
||
allow_headless=True,
|
||
env={"NOTEBOOKLM_HEADLESS_REAUTH_CDP_URL": "http://127.0.0.1:9333"},
|
||
)
|
||
|
||
assert result.status is HeadlessReauthStatus.SUCCESS
|
||
assert used["cdp_url"] == "http://127.0.0.1:9333"
|
||
|
||
|
||
def test_cdp_off_host_maps_to_failed(tmp_path: Path, monkeypatch) -> None:
|
||
"""A HeadlessLoginRequiredError from the CDP arm → honest FAILED."""
|
||
storage = tmp_path / "storage_state.json"
|
||
|
||
def _fake_cdp(plan, io, *, cdp_url):
|
||
raise HeadlessLoginRequiredError("attached browser cannot reach NotebookLM")
|
||
|
||
monkeypatch.setattr(hr, "run_cdp_capture", _fake_cdp)
|
||
monkeypatch.setattr(hr, "_playwright_installed", lambda: True)
|
||
|
||
result = attempt_headless_reauth(
|
||
storage_path=storage,
|
||
allow_headless=True,
|
||
cdp_url="http://127.0.0.1:9222",
|
||
env={},
|
||
)
|
||
|
||
assert result.status is HeadlessReauthStatus.FAILED
|
||
assert "attached browser" in result.reason
|
||
assert not storage.exists()
|
||
|
||
|
||
def test_cdp_opt_in_still_required(tmp_path: Path, monkeypatch) -> None:
|
||
"""Even with a CDP URL, opt-in is required — never fires by default."""
|
||
|
||
def _boom(*_a, **_k): # pragma: no cover - must not be called
|
||
raise AssertionError("CDP arm must not run without opt-in")
|
||
|
||
monkeypatch.setattr(hr, "run_cdp_capture", _boom)
|
||
monkeypatch.setattr(hr, "_playwright_installed", lambda: True)
|
||
|
||
result = attempt_headless_reauth(
|
||
storage_path=tmp_path / "storage_state.json",
|
||
allow_headless=False,
|
||
cdp_url="http://127.0.0.1:9222",
|
||
env={},
|
||
)
|
||
|
||
assert result.status is HeadlessReauthStatus.UNAVAILABLE
|
||
assert "not enabled" in result.reason
|
||
|
||
|
||
def test_remote_cdp_url_does_not_route_to_cdp(tmp_path: Path, monkeypatch) -> None:
|
||
"""A remote CDP endpoint must NOT drive the CDP arm (local-only boundary).
|
||
|
||
With a remote URL and no reusable profile, the attempt declines as
|
||
UNAVAILABLE (the remote URL was rejected by ``resolve_cdp_url``, then the
|
||
profile arm found no profile) — and ``run_cdp_capture`` is never called.
|
||
"""
|
||
|
||
def _boom(*_a, **_k): # pragma: no cover - must not be called
|
||
raise AssertionError("a remote CDP endpoint must never reach run_cdp_capture")
|
||
|
||
monkeypatch.setattr(hr, "run_cdp_capture", _boom)
|
||
monkeypatch.setattr(hr, "run_browser_capture", lambda *a, **k: None)
|
||
monkeypatch.setattr(hr, "_playwright_installed", lambda: True)
|
||
|
||
result = attempt_headless_reauth(
|
||
storage_path=tmp_path / "storage_state.json",
|
||
allow_headless=True,
|
||
browser_profile=tmp_path / "no_profile",
|
||
env={"NOTEBOOKLM_HEADLESS_REAUTH_CDP_URL": "http://remote-host:9222"},
|
||
)
|
||
|
||
# Fell through to the profile arm, which declined (no profile) → UNAVAILABLE.
|
||
assert result.status is HeadlessReauthStatus.UNAVAILABLE
|
||
assert "no reusable browser profile" in result.reason
|
||
|
||
|
||
def test_cdp_playwright_missing_is_unavailable(tmp_path: Path, monkeypatch) -> None:
|
||
def _boom(*_a, **_k): # pragma: no cover - must not be called
|
||
raise AssertionError("CDP arm must not run without playwright")
|
||
|
||
monkeypatch.setattr(hr, "run_cdp_capture", _boom)
|
||
monkeypatch.setattr(hr, "_playwright_installed", lambda: False)
|
||
|
||
result = attempt_headless_reauth(
|
||
storage_path=tmp_path / "storage_state.json",
|
||
allow_headless=True,
|
||
cdp_url="http://127.0.0.1:9222",
|
||
env={},
|
||
)
|
||
|
||
assert result.status is HeadlessReauthStatus.UNAVAILABLE
|
||
assert "playwright is not installed" in result.reason
|
||
|
||
|
||
if __name__ == "__main__": # pragma: no cover
|
||
raise SystemExit(pytest.main([__file__, "-v"]))
|