09e9f3545f
CodeQL / Analyze (push) Waiting to run
dependency-audit / pip-audit (push) Waiting to run
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
317 lines
13 KiB
Python
317 lines
13 KiB
Python
"""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}")
|