"""Regression tests for Windows compatibility fixes. These tests verify that fixes for Windows-specific issues remain in place. They test the fix exists, not the bug itself (which requires specific Windows environments). Related issues: - #75: CLI hangs indefinitely on Windows (asyncio ProactorEventLoop) - #79: Fix Windows CLI hanging due to asyncio ProactorEventLoop - #80: Fix Unicode encoding errors on non-English Windows systems - #89: notebooklm login fails on Windows with Python 3.12 (Playwright subprocess) """ import asyncio import os import sys from unittest.mock import patch import pytest from notebooklm.cli.services.playwright_login import ( windows_playwright_event_loop as _windows_playwright_event_loop, ) @pytest.mark.requires_playwright class TestPlaywrightSmokeTest: """Smoke tests that actually invoke Playwright to catch real integration issues. These tests verify that Playwright can be initialized with our event loop configuration. They caught issue #89 (Windows Python 3.12 login failure). """ @pytest.mark.skipif(sys.platform != "win32", reason="Windows-only smoke test") def test_playwright_initializes_with_context_manager(self): """Verify sync_playwright() works on Windows with our event loop fix. This is a regression test for #89. Without _windows_playwright_event_loop(), sync_playwright() raises NotImplementedError on Windows because WindowsSelectorEventLoopPolicy doesn't support subprocess spawning. """ from playwright.sync_api import sync_playwright # This would fail without the context manager fix with _windows_playwright_event_loop(), sync_playwright() as p: # Just verify Playwright initializes - don't launch a browser assert p.chromium is not None assert p.firefox is not None assert p.webkit is not None def test_playwright_initializes_on_non_windows(self): """Verify Playwright works normally on non-Windows platforms. The context manager should be a no-op, and Playwright should work. """ if sys.platform == "win32": pytest.skip("Non-Windows test") from playwright.sync_api import sync_playwright # Context manager is no-op on non-Windows, Playwright should still work with _windows_playwright_event_loop(), sync_playwright() as p: assert p.chromium is not None class TestPlaywrightEventLoopFix: """Regression tests for Playwright event loop fix (#89). Playwright's sync API requires ProactorEventLoop on Windows to spawn browser subprocesses. However, we use WindowsSelectorEventLoopPolicy globally to fix CLI hanging (#79). The _windows_playwright_event_loop() context manager temporarily restores the default policy for Playwright. """ def test_context_manager_exists(self): """Verify the context manager exists and is importable.""" assert callable(_windows_playwright_event_loop) def test_context_manager_is_noop_on_non_windows(self): """Verify context manager is a no-op on non-Windows platforms.""" # Mock ``sys.platform`` to non-Windows; the service reads the same # process-wide ``sys`` module object. with patch.object(sys, "platform", "linux"): original_policy = asyncio.get_event_loop_policy() with _windows_playwright_event_loop(): # Policy should remain unchanged on non-Windows current_policy = asyncio.get_event_loop_policy() assert current_policy is original_policy @pytest.mark.skipif(sys.platform != "win32", reason="Windows-only test") def test_context_manager_restores_policy_on_windows(self): """Verify context manager switches to default policy and restores on exit.""" # Ensure we start with SelectorEventLoopPolicy asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) with _windows_playwright_event_loop(): inside_policy = asyncio.get_event_loop_policy() # Inside the context, should be default (ProactorEventLoop) policy assert not isinstance(inside_policy, asyncio.WindowsSelectorEventLoopPolicy), ( "Context manager should switch to default policy for Playwright" ) # After exit, should restore original policy restored_policy = asyncio.get_event_loop_policy() assert isinstance(restored_policy, asyncio.WindowsSelectorEventLoopPolicy), ( "Context manager should restore WindowsSelectorEventLoopPolicy after Playwright" ) @pytest.mark.skipif(sys.platform != "win32", reason="Windows-only test") def test_context_manager_restores_on_exception(self): """Verify policy is restored even if an exception occurs.""" # Ensure we start with SelectorEventLoopPolicy asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) with pytest.raises(ValueError), _windows_playwright_event_loop(): raise ValueError("Test exception") # Policy should be restored despite exception restored_policy = asyncio.get_event_loop_policy() assert isinstance(restored_policy, asyncio.WindowsSelectorEventLoopPolicy), ( "Context manager should restore policy even on exception" ) class TestWindowsEventLoopPolicy: """Regression tests for Windows asyncio event loop policy fix (#75, #79). The default ProactorEventLoop on Windows can hang indefinitely at the IOCP layer (GetQueuedCompletionStatus) in certain environments like Sandboxie. The fix sets WindowsSelectorEventLoopPolicy at CLI startup. """ @pytest.mark.skipif(sys.platform != "win32", reason="Windows-only test") def test_selector_event_loop_policy_is_set(self): """Verify Windows uses SelectorEventLoop after CLI initialization. This prevents hanging on IOCP operations (see issue #75). The policy should be set in notebooklm_cli.main() before any async code runs. """ import asyncio # Import the CLI main to trigger the policy setup # Note: In actual usage, main() sets the policy before Click runs from notebooklm.notebooklm_cli import main # noqa: F401 policy = asyncio.get_event_loop_policy() assert isinstance(policy, asyncio.WindowsSelectorEventLoopPolicy), ( "Windows must use WindowsSelectorEventLoopPolicy to avoid IOCP hanging. " "See issue #75: https://github.com/teng-lin/notebooklm-py/issues/75" ) class TestWindowsUTF8Mode: """Regression tests for Windows UTF-8 encoding fix (#75, #80). Non-English Windows systems (cp950, cp932, cp936, etc.) can fail with UnicodeEncodeError when outputting Unicode characters like checkmarks. The fix sets PYTHONUTF8=1 and reconfigures live stdout/stderr at CLI startup. """ def test_windows_runtime_is_noop_on_non_windows(self, monkeypatch): """Non-Windows platforms should not mutate streams or event loop policy.""" from notebooklm import notebooklm_cli class DummyStream: def __init__(self): self.reconfigure_calls = [] def reconfigure(self, **kwargs): self.reconfigure_calls.append(kwargs) stdout = DummyStream() stderr = DummyStream() monkeypatch.setattr(notebooklm_cli.sys, "platform", "linux") monkeypatch.setattr(notebooklm_cli.sys, "stdout", stdout) monkeypatch.setattr(notebooklm_cli.sys, "stderr", stderr) monkeypatch.setattr( notebooklm_cli.asyncio, "set_event_loop_policy", lambda policy: pytest.fail("event loop policy should not be set on non-Windows"), ) monkeypatch.delenv("PYTHONUTF8", raising=False) notebooklm_cli._configure_windows_runtime() assert "PYTHONUTF8" not in os.environ assert stdout.reconfigure_calls == [] assert stderr.reconfigure_calls == [] def test_reconfigure_output_stream_ignores_unsupported_stream(self): """Streams without reconfigure support should be ignored.""" from notebooklm import notebooklm_cli notebooklm_cli._reconfigure_output_stream(object()) def test_reconfigure_output_stream_ignores_missing_stream(self): """Detached Windows processes may not have standard streams.""" from notebooklm import notebooklm_cli notebooklm_cli._reconfigure_output_stream(None) def test_reconfigure_output_stream_ignores_reconfigure_errors(self): """A failed best-effort reconfigure should not abort CLI startup.""" from notebooklm import notebooklm_cli class BrokenStream: def reconfigure(self, **kwargs): raise OSError("stream is closed") notebooklm_cli._reconfigure_output_stream(BrokenStream()) def test_windows_runtime_reconfigures_active_output_streams(self, monkeypatch): """Simulate Windows startup and verify existing streams are made UTF-8-safe.""" from notebooklm import notebooklm_cli class DummyStream: encoding = "cp950" def __init__(self): self.reconfigure_calls = [] def reconfigure(self, **kwargs): self.reconfigure_calls.append(kwargs) class DummyPolicy: pass stdout = DummyStream() stderr = DummyStream() policies = [] monkeypatch.setitem( notebooklm_cli.asyncio.__dict__, "WindowsSelectorEventLoopPolicy", DummyPolicy, ) monkeypatch.setattr(notebooklm_cli.sys, "platform", "win32") monkeypatch.setattr(notebooklm_cli.sys, "stdout", stdout) monkeypatch.setattr(notebooklm_cli.sys, "stderr", stderr) monkeypatch.setattr( notebooklm_cli.asyncio, "set_event_loop_policy", lambda policy: policies.append(policy), ) monkeypatch.delenv("PYTHONUTF8", raising=False) notebooklm_cli._configure_windows_runtime() assert os.environ["PYTHONUTF8"] == "1" assert stdout.reconfigure_calls == [{"encoding": "utf-8", "errors": "replace"}] assert stderr.reconfigure_calls == [{"encoding": "utf-8", "errors": "replace"}] assert isinstance(policies[0], DummyPolicy) @pytest.mark.skipif(sys.platform != "win32", reason="Windows-only test") def test_utf8_mode_enabled(self): """Verify UTF-8 mode is enabled on Windows. This prevents UnicodeEncodeError on non-English Windows (see issue #75). The environment variable should be set in notebooklm_cli.main(). """ # Import the CLI main to trigger the UTF-8 setup from notebooklm.notebooklm_cli import main # noqa: F401 # Check if UTF-8 mode is active (either via flag or env var) utf8_enabled = ( getattr(sys.flags, "utf8_mode", 0) == 1 or os.environ.get("PYTHONUTF8") == "1" ) assert utf8_enabled, ( "UTF-8 mode must be enabled on Windows to prevent encoding errors. " "See issue #75: https://github.com/teng-lin/notebooklm-py/issues/75" ) class TestEncodingResilience: """Tests for encoding resilience across platforms.""" @pytest.mark.parametrize( "test_char,description", [ ("✓", "checkmark"), ("✗", "cross mark"), ("📝", "memo emoji"), ("→", "arrow"), ("•", "bullet"), ], ) def test_common_cli_characters_encodable(self, test_char: str, description: str): """Verify common CLI output characters can be encoded. These characters are used in Rich tables and status output. They should either encode successfully or have a fallback. """ # Test that characters can be encoded to UTF-8 (always works) try: encoded = test_char.encode("utf-8") assert len(encoded) > 0 except UnicodeEncodeError: pytest.fail(f"Failed to encode {description} ({test_char!r}) to UTF-8") def test_output_with_replace_errors(self): """Verify output survives encoding with errors='replace'. This simulates the defensive encoding strategy for legacy codepages. """ test_string = "Status: ✓ Complete • 3 items → next" # Simulate legacy codepage that can't handle these characters try: # This would fail on cp950 without errors='replace' encoded = test_string.encode("ascii", errors="replace") decoded = encoded.decode("ascii") # Should have ? replacements but not crash assert len(decoded) > 0 assert "Status" in decoded except Exception as e: pytest.fail(f"Encoding with errors='replace' should not fail: {e}")