Files
teng-lin--notebooklm-py/tests/unit/test_windows_compatibility.py
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

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}")