chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
Tests for issue #1968: AsyncLogger must write to stderr by default so that
|
||||
stdout-based transports (e.g. MCP stdio) are not corrupted.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
# Load async_logger directly without triggering the full crawl4ai __init__
|
||||
# (which pulls in many optional deps like aiofiles, OpenSSL, playwright …).
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"crawl4ai.async_logger",
|
||||
Path(__file__).parent.parent / "crawl4ai" / "async_logger.py",
|
||||
)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_mod) # type: ignore[union-attr]
|
||||
|
||||
AsyncLogger = _mod.AsyncLogger
|
||||
LogLevel = _mod.LogLevel
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _capture_consoles():
|
||||
"""Return StringIO objects for stdout and stderr plus a no-color Console
|
||||
for each, suitable for injecting into AsyncLogger in tests."""
|
||||
out = io.StringIO()
|
||||
err = io.StringIO()
|
||||
stdout_console = Console(file=out, no_color=True, highlight=False)
|
||||
stderr_console = Console(file=err, no_color=True, highlight=False)
|
||||
return out, err, stdout_console, stderr_console
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAsyncLoggerDefaultsToStderr:
|
||||
"""The default AsyncLogger must NOT write to stdout."""
|
||||
|
||||
def test_default_console_writes_to_stderr_not_stdout(self, capsys):
|
||||
"""Logging to the default AsyncLogger must not pollute stdout."""
|
||||
logger = AsyncLogger(verbose=True)
|
||||
|
||||
logger.info("hello from logger", tag="TEST")
|
||||
logger.error("error message", tag="TEST")
|
||||
logger.warning("warning message", tag="TEST")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
# stdout must remain pristine (no log lines mixed in)
|
||||
assert captured.out == "", (
|
||||
"AsyncLogger wrote to stdout — this breaks MCP stdio transport!\n"
|
||||
f"stdout content: {captured.out!r}"
|
||||
)
|
||||
# stderr should contain the output
|
||||
assert "hello from logger" in captured.err, (
|
||||
"Expected log output on stderr but found nothing.\n"
|
||||
f"stderr content: {captured.err!r}"
|
||||
)
|
||||
|
||||
def test_default_console_is_stderr(self):
|
||||
"""The internal console.file should be sys.stderr (or equivalent)."""
|
||||
logger = AsyncLogger(verbose=True)
|
||||
# Rich Console with stderr=True writes to sys.stderr
|
||||
assert logger.console.file is sys.stderr, (
|
||||
f"Expected logger.console.file to be sys.stderr, "
|
||||
f"got {logger.console.file!r}"
|
||||
)
|
||||
|
||||
|
||||
class TestAsyncLoggerCustomConsole:
|
||||
"""Callers can inject a custom Console (e.g. stdout for non-MCP use)."""
|
||||
|
||||
def test_custom_console_is_respected(self):
|
||||
"""When a custom Console is passed, it must be used verbatim."""
|
||||
buf = io.StringIO()
|
||||
custom = Console(file=buf, no_color=True, highlight=False)
|
||||
logger = AsyncLogger(verbose=True, console=custom)
|
||||
|
||||
logger.info("custom target", tag="TEST")
|
||||
|
||||
output = buf.getvalue()
|
||||
assert "custom target" in output, (
|
||||
f"Expected log output in custom console, got: {output!r}"
|
||||
)
|
||||
|
||||
def test_stdout_console_can_be_injected(self, capsys):
|
||||
"""Passing Console(file=sys.stdout) restores legacy behaviour."""
|
||||
stdout_console = Console(file=sys.stdout, no_color=True, highlight=False)
|
||||
logger = AsyncLogger(verbose=True, console=stdout_console)
|
||||
|
||||
logger.info("going to stdout", tag="TEST")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "going to stdout" in captured.out
|
||||
|
||||
|
||||
class TestAsyncLoggerVerboseFalse:
|
||||
"""verbose=False must suppress console output regardless of the target stream."""
|
||||
|
||||
def test_no_output_when_verbose_false(self, capsys):
|
||||
logger = AsyncLogger(verbose=False)
|
||||
logger.info("silent message", tag="TEST")
|
||||
logger.error("silent error", tag="TEST")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
assert captured.err == ""
|
||||
|
||||
|
||||
class TestAsyncLoggerFileLogging:
|
||||
"""File logging path should still work after the stderr change."""
|
||||
|
||||
def test_file_logging_still_works(self, tmp_path):
|
||||
log_file = tmp_path / "test.log"
|
||||
logger = AsyncLogger(log_file=str(log_file), verbose=False)
|
||||
logger.info("file log entry", tag="FILE")
|
||||
|
||||
content = log_file.read_text(encoding="utf-8")
|
||||
assert "file log entry" in content
|
||||
|
||||
|
||||
class TestMCPScenario:
|
||||
"""Simulates what MCP stdio transport sees on stdout."""
|
||||
|
||||
def test_stdout_clean_across_all_log_levels(self, capsys):
|
||||
"""Simulate MCP usage: crawl + log, stdout must only have JSON."""
|
||||
logger = AsyncLogger(verbose=True)
|
||||
fake_json_response = '{"jsonrpc": "2.0", "result": "ok", "id": 1}'
|
||||
|
||||
# Simulate interleaved logging (as would happen during a crawl)
|
||||
logger.info("Crawling started", tag="CRAWL")
|
||||
logger.warning("Slow response", tag="CRAWL")
|
||||
logger.success("Done", tag="CRAWL")
|
||||
|
||||
# MCP server would write JSON to stdout
|
||||
print(fake_json_response)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
|
||||
# stdout must contain ONLY the JSON line
|
||||
assert captured.out.strip() == fake_json_response, (
|
||||
"Stdout was polluted by logger output!\n"
|
||||
f"stdout: {captured.out!r}"
|
||||
)
|
||||
# All log output should be on stderr
|
||||
assert "Crawling started" in captured.err
|
||||
Reference in New Issue
Block a user