diff --git a/src/notebooklm/cli/session.py b/src/notebooklm/cli/session.py index 355dfa9f..87a2455a 100644 --- a/src/notebooklm/cli/session.py +++ b/src/notebooklm/cli/session.py @@ -11,18 +11,23 @@ import json import logging import os +import shutil import subprocess import sys import time from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any import click import httpx from rich.table import Table +if TYPE_CHECKING: + from playwright.sync_api import BrowserContext, Page + from rich.console import Console + from ..auth import ( ALLOWED_COOKIE_DOMAINS, GOOGLE_REGIONAL_CCTLDS, @@ -59,6 +64,17 @@ # Retryable Playwright connection errors RETRYABLE_CONNECTION_ERRORS = ("ERR_CONNECTION_CLOSED", "ERR_CONNECTION_RESET") LOGIN_MAX_RETRIES = 3 +# Playwright TargetClosedError substring — matches the default message from +# Playwright's TargetClosedError class (introduced in v1.41). If a future +# version changes this message, the error will propagate unhandled (safe fallback). +TARGET_CLOSED_ERROR = "Target page, context or browser has been closed" +BROWSER_CLOSED_HELP = ( + "[red]The browser window was closed during login.[/red]\n" + "This can happen when switching Google accounts in a persistent browser session.\n\n" + "Try:\n" + " 1. Run: notebooklm login --fresh\n" + " 2. Or run: notebooklm auth logout && notebooklm login" +) CONNECTION_ERROR_HELP = ( "[red]Failed to connect to NotebookLM after multiple retries.[/red]\n" "This may be caused by:\n" @@ -198,9 +214,7 @@ def _login_with_browser_cookies(storage_path: Path, browser_name: str) -> None: storage_path.chmod(0o600) except OSError as e: logger.error("Failed to save authentication to %s: %s", storage_path, e) - console.print( - f"[red]Failed to save authentication to {storage_path}.[/red]\n" f"Details: {e}" - ) + console.print(f"[red]Failed to save authentication to {storage_path}.[/red]\nDetails: {e}") raise SystemExit(1) from None console.print(f"\n[green]Authentication saved to:[/green] {storage_path}") @@ -340,6 +354,30 @@ def _ensure_chromium_installed() -> None: ) +def _recover_page(context: "BrowserContext", console: "Console") -> "Page": + """Get a fresh page from a persistent browser context. + + Used when the current page reference is stale (TargetClosedError). + A new page in a persistent context inherits all cookies and storage. + + Returns a new Page, or raises SystemExit if the context/browser is dead. + Raises the original PlaywrightError for non-TargetClosed failures. + """ + from playwright.sync_api import Error as PlaywrightError + + try: + return context.new_page() + except PlaywrightError as exc: + error_str = str(exc) + if TARGET_CLOSED_ERROR in error_str: + logger.error("Browser context is dead, cannot recover page: %s", error_str) + console.print(BROWSER_CLOSED_HELP) + raise SystemExit(1) from exc + # Not a TargetClosedError — don't mask the real problem + logger.error("Failed to create new page for recovery: %s", error_str) + raise + + def register_session_commands(cli): """Register session commands on the main CLI group.""" @@ -368,7 +406,13 @@ def register_session_commands(cli): "Requires: pip install 'notebooklm[cookies]'" ), ) - def login(storage, browser, browser_cookies): + @click.option( + "--fresh", + is_flag=True, + default=False, + help="Start with a clean browser session (deletes cached browser profile). Use to switch Google accounts.", + ) + def login(storage, browser, browser_cookies, fresh): """Log in to NotebookLM via browser. Opens a browser window for Google login. After logging in, @@ -393,12 +437,31 @@ def login(storage, browser, browser_cookies): # rookiepy fast-path: skip Playwright entirely if browser_cookies is not None: + if fresh: + console.print( + "[yellow]Warning: --fresh has no effect with --browser-cookies " + "(no browser profile is used).[/yellow]" + ) resolved_storage = Path(storage) if storage else get_storage_path() _login_with_browser_cookies(resolved_storage, browser_cookies) return storage_path = Path(storage) if storage else get_storage_path() browser_profile = get_browser_profile_dir() + + if fresh and browser_profile.exists(): + try: + shutil.rmtree(browser_profile) + console.print("[yellow]Cleared cached browser session (--fresh)[/yellow]") + except OSError as exc: + logger.error("Failed to clear browser profile %s: %s", browser_profile, exc) + console.print( + f"[red]Cannot clear browser profile: {exc}[/red]\n" + "Close any open browser windows and try again.\n" + f"If the problem persists, manually delete: {browser_profile}" + ) + raise SystemExit(1) from exc + if sys.platform == "win32": # On Windows < Python 3.13, mode= is ignored by mkdir(). On # Python 3.13+, mode= applies Windows ACLs that can be overly @@ -454,7 +517,7 @@ def login(storage, browser, browser_cookies): try: context = p.chromium.launch_persistent_context(**launch_kwargs) - page = context.pages[0] if context.pages else context.new_page() + page = context.pages[0] if context.pages else _recover_page(context, console) # Retry navigation on transient connection errors with backoff for attempt in range(1, LOGIN_MAX_RETRIES + 1): @@ -466,22 +529,45 @@ def login(storage, browser, browser_cookies): is_retryable = any( code in error_str for code in RETRYABLE_CONNECTION_ERRORS ) + is_target_closed = TARGET_CLOSED_ERROR in error_str # Check if we should retry - if is_retryable and attempt < LOGIN_MAX_RETRIES: - # Retryable error with attempts remaining: retry + if (is_retryable or is_target_closed) and attempt < LOGIN_MAX_RETRIES: + # For TargetClosedError, get a fresh page reference + if is_target_closed: + page = _recover_page(context, console) + backoff_seconds = attempt # Linear backoff: 1s, 2s logger.debug( - f"Retryable connection error on attempt {attempt}/{LOGIN_MAX_RETRIES}: {error_str}" + "Retryable error on attempt %d/%d: %s", + attempt, + LOGIN_MAX_RETRIES, + error_str, ) - console.print( - f"[yellow]Connection interrupted " - f"(attempt {attempt}/{LOGIN_MAX_RETRIES}). " - f"Retrying in {backoff_seconds}s...[/yellow]" + if is_target_closed: + console.print( + f"[yellow]Browser page closed " + f"(attempt {attempt}/{LOGIN_MAX_RETRIES}). " + f"Retrying with fresh page...[/yellow]" + ) + else: + console.print( + f"[yellow]Connection interrupted " + f"(attempt {attempt}/{LOGIN_MAX_RETRIES}). " + f"Retrying in {backoff_seconds}s...[/yellow]" + ) + time.sleep(backoff_seconds) + elif is_target_closed: + # Exhausted retries on browser-closed errors + logger.error( + "Browser closed during login after %d attempts. Last error: %s", + LOGIN_MAX_RETRIES, + error_str, ) - time.sleep(backoff_seconds) + console.print(BROWSER_CLOSED_HELP) + raise SystemExit(1) from exc elif is_retryable: - # Exhausted retries on a retryable error + # Exhausted retries on network errors logger.error( f"Failed to connect to NotebookLM after {LOGIN_MAX_RETRIES} attempts. " f"Last error: {error_str}" @@ -508,7 +594,20 @@ def login(storage, browser, browser_cookies): try: page.goto(url, wait_until="commit") except PlaywrightError as exc: - if "Navigation interrupted" not in str(exc): + error_str = str(exc) + if TARGET_CLOSED_ERROR in error_str: + # Page was destroyed (e.g. user switched accounts) -- get fresh page + page = _recover_page(context, console) + try: + page.goto(url, wait_until="commit") + except PlaywrightError as inner_exc: + if TARGET_CLOSED_ERROR in str(inner_exc): + # Recovered page also dead -- context/browser is gone + console.print(BROWSER_CLOSED_HELP) + raise SystemExit(1) from inner_exc + elif "Navigation interrupted" not in str(inner_exc): + raise + elif "Navigation interrupted" not in error_str: raise current_url = page.url @@ -745,6 +844,71 @@ def auth_group(): """Authentication management commands.""" pass + @auth_group.command("logout") + def auth_logout(): + """Log out by clearing saved authentication. + + Removes both the saved cookie file (storage_state.json) and the + cached browser profile. After logout, run 'notebooklm login' to + authenticate with a different Google account. + + \b + Examples: + notebooklm auth logout # Clear auth for active profile + notebooklm -p work auth logout # Clear auth for 'work' profile + """ + # Warn if env-based auth will remain active after logout + if os.environ.get("NOTEBOOKLM_AUTH_JSON"): + console.print( + "[yellow]Note: NOTEBOOKLM_AUTH_JSON is set — env-based auth will " + "remain active after logout. Unset it to fully log out.[/yellow]" + ) + + storage_path = get_storage_path() + browser_profile = get_browser_profile_dir() + + removed_any = False + + # Remove storage_state.json + if storage_path.exists(): + try: + storage_path.unlink() + removed_any = True + except OSError as exc: + logger.error("Failed to remove auth file %s: %s", storage_path, exc) + console.print( + f"[red]Cannot remove auth file: {exc}[/red]\n" + "Close any running notebooklm commands and try again.\n" + f"If the problem persists, manually delete: {storage_path}" + ) + raise SystemExit(1) from exc + + # Remove browser profile directory + if browser_profile.exists(): + try: + shutil.rmtree(browser_profile) + removed_any = True + except OSError as exc: + logger.error("Failed to remove browser profile %s: %s", browser_profile, exc) + partial = ( + "[yellow]Note: Auth file was removed, but browser profile " + "could not be deleted.[/yellow]\n" + if removed_any + else "" + ) + console.print( + f"{partial}" + f"[red]Cannot remove browser profile: {exc}[/red]\n" + "Close any open browser windows and try again.\n" + f"If the problem persists, manually delete: {browser_profile}" + ) + raise SystemExit(1) from exc + + if removed_any: + console.print("[green]Logged out.[/green] Run 'notebooklm login' to sign in again.") + else: + console.print("[yellow]No active session found.[/yellow] Already logged out.") + @auth_group.command("check") @click.option( "--test", "test_fetch", is_flag=True, help="Test token fetch (makes network request)" diff --git a/tests/unit/cli/test_session.py b/tests/unit/cli/test_session.py index 63c7f642..3ba183f3 100644 --- a/tests/unit/cli/test_session.py +++ b/tests/unit/cli/test_session.py @@ -392,6 +392,311 @@ def goto_side_effect(url, **kwargs): # Verify exactly 3 retry attempts assert mock_page.goto.call_count == 3 + def test_login_fresh_deletes_browser_profile(self, runner, tmp_path): + """Test --fresh deletes existing browser_profile directory before login.""" + browser_dir = tmp_path / "profile" + browser_dir.mkdir() + (browser_dir / "Default" / "Cookies").parent.mkdir(parents=True) + (browser_dir / "Default" / "Cookies").write_text("fake cookies") + + storage_file = tmp_path / "storage.json" + + with ( + patch("notebooklm.cli.session._ensure_chromium_installed"), + patch("playwright.sync_api.sync_playwright") as mock_pw, + patch("notebooklm.cli.session.get_storage_path", return_value=storage_file), + patch( + "notebooklm.cli.session.get_browser_profile_dir", + return_value=browser_dir, + ), + patch("notebooklm.cli.session._sync_server_language_to_config"), + patch("builtins.input", return_value=""), + ): + mock_context = MagicMock() + mock_page = MagicMock() + mock_page.url = "https://notebooklm.google.com/" + mock_context.pages = [mock_page] + mock_context.storage_state.side_effect = lambda path: Path(path).write_text("{}") + mock_launch = ( + mock_pw.return_value.__enter__.return_value.chromium.launch_persistent_context + ) + mock_launch.return_value = mock_context + + result = runner.invoke(cli, ["login", "--fresh"]) + + assert result.exit_code == 0 + # The old cached cookies file was removed by shutil.rmtree; + # mkdir recreates an empty directory, then Playwright populates it + assert not (browser_dir / "Default" / "Cookies").exists() + assert "Cleared cached browser session" in result.output + + def test_login_fresh_works_when_no_profile_exists(self, runner, tmp_path): + """Test --fresh works when browser_profile doesn't exist yet (first login).""" + browser_dir = tmp_path / "profile" + # Do NOT create browser_dir - simulates first login + storage_file = tmp_path / "storage.json" + + with ( + patch("notebooklm.cli.session._ensure_chromium_installed"), + patch("playwright.sync_api.sync_playwright") as mock_pw, + patch("notebooklm.cli.session.get_storage_path", return_value=storage_file), + patch( + "notebooklm.cli.session.get_browser_profile_dir", + return_value=browser_dir, + ), + patch("notebooklm.cli.session._sync_server_language_to_config"), + patch("builtins.input", return_value=""), + ): + mock_context = MagicMock() + mock_page = MagicMock() + mock_page.url = "https://notebooklm.google.com/" + mock_context.pages = [mock_page] + mock_context.storage_state.side_effect = lambda path: Path(path).write_text("{}") + mock_launch = ( + mock_pw.return_value.__enter__.return_value.chromium.launch_persistent_context + ) + mock_launch.return_value = mock_context + + result = runner.invoke(cli, ["login", "--fresh"]) + + assert result.exit_code == 0 + assert "Authentication saved" in result.output + + def test_login_fresh_ignored_with_browser_cookies(self, runner, tmp_path): + """Test --fresh warns and is ignored when combined with --browser-cookies.""" + # Pass explicit "auto" value for cross-platform Click compatibility. + with ( + patch("notebooklm.cli.session._login_with_browser_cookies"), + patch("notebooklm.cli.session.get_storage_path", return_value=tmp_path / "s.json"), + ): + result = runner.invoke(cli, ["login", "--fresh", "--browser-cookies", "auto"]) + assert "--fresh has no effect" in result.output + + def test_login_help_shows_fresh_option(self, runner): + """Test login --help shows --fresh flag.""" + result = runner.invoke(cli, ["login", "--help"]) + assert "--fresh" in result.output + + def test_login_fresh_oserror_on_rmtree(self, runner, tmp_path): + """Test --fresh handles OSError on rmtree gracefully.""" + browser_dir = tmp_path / "profile" + browser_dir.mkdir() + + with ( + patch("notebooklm.cli.session.get_storage_path", return_value=tmp_path / "s.json"), + patch( + "notebooklm.cli.session.get_browser_profile_dir", + return_value=browser_dir, + ), + patch("notebooklm.cli.session.shutil.rmtree", side_effect=OSError("locked")), + ): + result = runner.invoke(cli, ["login", "--fresh"]) + + assert result.exit_code == 1 + assert "Cannot clear browser profile" in result.output + + def test_login_recovers_from_target_closed_on_initial_navigation(self, runner, tmp_path): + """Test login retries with fresh page when initial goto gets TargetClosedError (#246).""" + storage_file = tmp_path / "storage.json" + browser_dir = tmp_path / "profile" + + with ( + patch("notebooklm.cli.session._ensure_chromium_installed"), + patch("playwright.sync_api.sync_playwright") as mock_pw, + patch("notebooklm.cli.session.get_storage_path", return_value=storage_file), + patch( + "notebooklm.cli.session.get_browser_profile_dir", + return_value=browser_dir, + ), + patch("notebooklm.cli.session._sync_server_language_to_config"), + patch("builtins.input", return_value=""), + ): + from playwright.sync_api import Error as PlaywrightError + + mock_context = MagicMock() + mock_page_stale = MagicMock() + mock_page_fresh = MagicMock() + mock_page_fresh.url = "https://notebooklm.google.com/" + mock_page_fresh.goto.side_effect = None + + # Stale page raises TargetClosedError on every call + mock_page_stale.goto.side_effect = PlaywrightError( + "Page.goto: Target page, context or browser has been closed" + ) + mock_context.pages = [mock_page_stale] + # new_page() returns a working fresh page + mock_context.new_page.return_value = mock_page_fresh + mock_context.storage_state.side_effect = lambda path: Path(path).write_text("{}") + + mock_launch = ( + mock_pw.return_value.__enter__.return_value.chromium.launch_persistent_context + ) + mock_launch.return_value = mock_context + + with patch("notebooklm.cli.session.time.sleep"): + result = runner.invoke(cli, ["login"]) + + assert result.exit_code == 0 + assert "Authentication saved" in result.output + # Verify new_page was called to recover from the stale page + mock_context.new_page.assert_called() + + def test_login_recovers_from_target_closed_in_cookie_forcing(self, runner, tmp_path): + """Test login recovers when cookie-forcing goto hits TargetClosedError (#246). + + This is the PRIMARY crash site: after user switches accounts in the browser, + the old page reference is dead. The cookie-forcing section must get a fresh + page and continue. + """ + storage_file = tmp_path / "storage.json" + browser_dir = tmp_path / "profile" + + with ( + patch("notebooklm.cli.session._ensure_chromium_installed"), + patch("playwright.sync_api.sync_playwright") as mock_pw, + patch("notebooklm.cli.session.get_storage_path", return_value=storage_file), + patch( + "notebooklm.cli.session.get_browser_profile_dir", + return_value=browser_dir, + ), + patch("notebooklm.cli.session._sync_server_language_to_config"), + patch("builtins.input", return_value=""), + ): + from playwright.sync_api import Error as PlaywrightError + + mock_context = MagicMock() + mock_page_stale = MagicMock() + mock_page_fresh = MagicMock() + mock_page_fresh.url = "https://notebooklm.google.com/" + mock_page_fresh.goto.side_effect = None + + # Initial navigation succeeds (auto-login via cached session) + goto_call_count = 0 + + def stale_goto_side_effect(url, **kwargs): + nonlocal goto_call_count + goto_call_count += 1 + # Call 1: initial goto to NOTEBOOKLM_URL -- succeeds + if goto_call_count == 1: + return + # Call 2+: cookie-forcing -- page is stale, user switched accounts + raise PlaywrightError("Page.goto: Target page, context or browser has been closed") + + mock_page_stale.goto.side_effect = stale_goto_side_effect + mock_page_stale.url = "https://notebooklm.google.com/" + mock_context.pages = [mock_page_stale] + mock_context.new_page.return_value = mock_page_fresh + mock_context.storage_state.side_effect = lambda path: Path(path).write_text("{}") + + mock_launch = ( + mock_pw.return_value.__enter__.return_value.chromium.launch_persistent_context + ) + mock_launch.return_value = mock_context + + result = runner.invoke(cli, ["login"]) + + assert result.exit_code == 0 + assert "Authentication saved" in result.output + # Verify new_page was called to get a fresh page after the stale one died + mock_context.new_page.assert_called() + + def test_login_shows_browser_closed_message_after_exhausting_retries(self, runner, tmp_path): + """Test login shows browser-specific error (not network error) when TargetClosedError exhausts retries.""" + storage_file = tmp_path / "storage.json" + browser_dir = tmp_path / "profile" + + with ( + patch("notebooklm.cli.session._ensure_chromium_installed"), + patch("playwright.sync_api.sync_playwright") as mock_pw, + patch("notebooklm.cli.session.get_storage_path", return_value=storage_file), + patch( + "notebooklm.cli.session.get_browser_profile_dir", + return_value=browser_dir, + ), + patch("notebooklm.cli.session._sync_server_language_to_config"), + patch("builtins.input", return_value=""), + ): + from playwright.sync_api import Error as PlaywrightError + + mock_context = MagicMock() + mock_page = MagicMock() + # Every page (original + recovered) raises TargetClosedError + mock_page.goto.side_effect = PlaywrightError( + "Page.goto: Target page, context or browser has been closed" + ) + mock_context.pages = [mock_page] + mock_context.new_page.return_value = mock_page # new pages also fail + mock_context.storage_state.side_effect = lambda path: Path(path).write_text("{}") + + mock_launch = ( + mock_pw.return_value.__enter__.return_value.chromium.launch_persistent_context + ) + mock_launch.return_value = mock_context + + with patch("notebooklm.cli.session.time.sleep"): + result = runner.invoke(cli, ["login"]) + + assert result.exit_code == 1 + # Should show browser-closed message, NOT network error message + assert "browser" in result.output.lower() and "closed" in result.output.lower() + assert "Network connectivity" not in result.output + + def test_login_cookie_forcing_double_failure_shows_browser_closed(self, runner, tmp_path): + """Test cookie-forcing shows BROWSER_CLOSED_HELP when recovered page also raises TargetClosedError (#246). + + This is the final safety net: if the recovered page is also dead during + cookie-forcing, the user should see BROWSER_CLOSED_HELP, not a traceback. + """ + storage_file = tmp_path / "storage.json" + browser_dir = tmp_path / "profile" + + with ( + patch("notebooklm.cli.session._ensure_chromium_installed"), + patch("playwright.sync_api.sync_playwright") as mock_pw, + patch("notebooklm.cli.session.get_storage_path", return_value=storage_file), + patch( + "notebooklm.cli.session.get_browser_profile_dir", + return_value=browser_dir, + ), + patch("notebooklm.cli.session._sync_server_language_to_config"), + patch("builtins.input", return_value=""), + ): + from playwright.sync_api import Error as PlaywrightError + + mock_context = MagicMock() + mock_page_stale = MagicMock() + mock_page_recovered = MagicMock() + + # Initial navigation succeeds + goto_call_count = 0 + + def stale_goto_side_effect(url, **kwargs): + nonlocal goto_call_count + goto_call_count += 1 + if goto_call_count == 1: + return # initial navigation OK + raise PlaywrightError("Page.goto: Target page, context or browser has been closed") + + mock_page_stale.goto.side_effect = stale_goto_side_effect + mock_page_stale.url = "https://notebooklm.google.com/" + # Recovered page also raises TargetClosedError on goto + mock_page_recovered.goto.side_effect = PlaywrightError( + "Page.goto: Target page, context or browser has been closed" + ) + mock_context.pages = [mock_page_stale] + mock_context.new_page.return_value = mock_page_recovered + mock_context.storage_state.side_effect = lambda path: Path(path).write_text("{}") + + mock_launch = ( + mock_pw.return_value.__enter__.return_value.chromium.launch_persistent_context + ) + mock_launch.return_value = mock_context + + result = runner.invoke(cli, ["login"]) + + assert result.exit_code == 1 + assert "browser" in result.output.lower() and "closed" in result.output.lower() + # ============================================================================= # USE COMMAND TESTS @@ -1376,3 +1681,118 @@ def test_unknown_browser_shows_error(self, runner, tmp_path): ): result = runner.invoke(cli, ["login", "--browser-cookies", "netscape"]) assert result.exit_code != 0 + + +# ============================================================================= +# AUTH LOGOUT COMMAND TESTS +# ============================================================================= + + +class TestAuthLogoutCommand: + def test_auth_logout_deletes_storage_and_browser_profile(self, runner, tmp_path): + """Test auth logout deletes both storage_state.json and browser_profile/.""" + storage_file = tmp_path / "storage.json" + storage_file.write_text('{"cookies": []}') + browser_dir = tmp_path / "browser_profile" + browser_dir.mkdir() + (browser_dir / "Default").mkdir() + (browser_dir / "Default" / "Cookies").write_text("data") + + with ( + patch("notebooklm.cli.session.get_storage_path", return_value=storage_file), + patch( + "notebooklm.cli.session.get_browser_profile_dir", + return_value=browser_dir, + ), + ): + result = runner.invoke(cli, ["auth", "logout"]) + + assert result.exit_code == 0 + assert "Logged out" in result.output + assert not storage_file.exists() + assert not browser_dir.exists() + + def test_auth_logout_when_already_logged_out(self, runner, tmp_path): + """Test auth logout is a no-op with friendly message when not logged in.""" + storage_file = tmp_path / "storage.json" + browser_dir = tmp_path / "browser_profile" + # Neither exists + + with ( + patch("notebooklm.cli.session.get_storage_path", return_value=storage_file), + patch( + "notebooklm.cli.session.get_browser_profile_dir", + return_value=browser_dir, + ), + ): + result = runner.invoke(cli, ["auth", "logout"]) + + assert result.exit_code == 0 + assert "already" in result.output.lower() or "No active session" in result.output + + def test_auth_logout_partial_state_only_storage(self, runner, tmp_path): + """Test auth logout handles case where only storage_state.json exists.""" + storage_file = tmp_path / "storage.json" + storage_file.write_text('{"cookies": []}') + browser_dir = tmp_path / "browser_profile" + # browser_dir does not exist + + with ( + patch("notebooklm.cli.session.get_storage_path", return_value=storage_file), + patch( + "notebooklm.cli.session.get_browser_profile_dir", + return_value=browser_dir, + ), + ): + result = runner.invoke(cli, ["auth", "logout"]) + + assert result.exit_code == 0 + assert "Logged out" in result.output + assert not storage_file.exists() + + def test_auth_logout_handles_permission_error_on_rmtree(self, runner, tmp_path): + """Test auth logout handles locked browser profile gracefully.""" + storage_file = tmp_path / "storage.json" + storage_file.write_text('{"cookies": []}') + browser_dir = tmp_path / "browser_profile" + browser_dir.mkdir() + + with ( + patch("notebooklm.cli.session.get_storage_path", return_value=storage_file), + patch( + "notebooklm.cli.session.get_browser_profile_dir", + return_value=browser_dir, + ), + patch( + "notebooklm.cli.session.shutil.rmtree", + side_effect=OSError("sharing violation"), + ), + ): + result = runner.invoke(cli, ["auth", "logout"]) + + assert result.exit_code == 1 + assert "in use" in result.output.lower() or "Cannot" in result.output + + def test_auth_logout_handles_permission_error_on_unlink(self, runner, tmp_path): + """Test auth logout handles locked storage_state.json gracefully on Windows.""" + storage_file = tmp_path / "storage.json" + storage_file.write_text('{"cookies": []}') + browser_dir = tmp_path / "browser_profile" + # No browser dir + + with ( + patch("notebooklm.cli.session.get_storage_path", return_value=storage_file), + patch( + "notebooklm.cli.session.get_browser_profile_dir", + return_value=browser_dir, + ), + patch.object( + type(storage_file), + "unlink", + side_effect=OSError("file in use"), + ), + ): + result = runner.invoke(cli, ["auth", "logout"]) + + assert result.exit_code == 1 + assert "Cannot" in result.output or "in use" in result.output.lower()