headroom/tests/conftest.py
Zhenjia ZHOU fda4670ef8
fix(tests): reset whole headroom logger subtree so caplog stays deterministic (#1117)
## Description

Five `caplog`-based test assertions are order-dependent flakes: they
pass in isolation but fail in full-suite runs.

**Root cause** is a global logging-state leak.
`benchmarks.claude_session_mode_benchmark._disable_headroom_benchmark_logging()`
(exercised by `tests/test_claude_session_mode_benchmark.py`) sets
`propagate = False` + `CRITICAL` on the `headroom`, `headroom.proxy`,
`headroom.transforms` (and `headroom.cache`) loggers and never restores
them. pytest's `caplog` attaches its handler to the **root** logger, so
once any `headroom.*` child is left non-propagating, records from that
subtree silently never reach `caplog` for **every test that runs
afterwards** — which is exactly why these only fail in full-suite order.

The repo already ships a `_reset_headroom_logger_propagation` autouse
fixture for this hazard (its docstring documents the equivalent
`_setup_file_logging` leak), but it only reset the **top** `headroom`
logger, not children like `headroom.proxy`. A non-propagating child
still blocks the record before it reaches root. This PR extends the
existing fixture to reset the whole `headroom.*` subtree before each
test.

Scope is intentionally one file (`tests/conftest.py`) — test-harness
only, no production change.

> Design note: I extended the existing defensive fixture rather than
restoring state inside the benchmark, because (a) the fixture already
exists for exactly this and only needed completing, and (b) the same
`propagate=False` hazard also originates from production
`_setup_file_logging`, so a centralized per-test reset is the more
durable fix. Happy to instead make the benchmark restore its own logging
state if maintainers prefer fixing it at the source.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Extend the `_reset_headroom_logger_propagation` autouse fixture in
`tests/conftest.py` to reset `propagate = True` for **every** existing
`headroom.*` logger (previously only the top `headroom` logger), so
`caplog` capture is deterministic regardless of test execution order.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`) — N/A, change is under
`tests/`
- [ ] New tests added — N/A, this fixes existing tests; they are
themselves the proof
- [x] Manual testing performed

### Test Output

```text
# Causal proof: run the polluter first, then the 5 victims, in one process.

# BEFORE (fixture reset scoped to only "headroom"):
$ pytest tests/test_claude_session_mode_benchmark.py \
         tests/test_corrupt_golden_bytes_recovery.py \
         tests/test_forwarded_headers.py \
         'tests/test_transforms/test_kompress_compressor.py::TestKompressBackendSelection::test_unrecognized_backend_warns_and_falls_back_to_auto'
5 failed, 54 passed
  FAILED tests/test_corrupt_golden_bytes_recovery.py::TestCorruptMemoryGoldenBytes::test_corrupt_bytes_logs_error
  FAILED tests/test_corrupt_golden_bytes_recovery.py::TestCorruptMemoryGoldenBytes::test_unicode_decode_error_handled
  FAILED tests/test_corrupt_golden_bytes_recovery.py::TestCorruptCcrGoldenBytes::test_corrupt_bytes_logs_error
  FAILED tests/test_forwarded_headers.py::test_non_allowlisted_peer_ignores_forwarded_and_logs
  FAILED tests/test_transforms/test_kompress_compressor.py::...test_unrecognized_backend_warns_and_falls_back_to_auto

# AFTER (this PR — whole headroom.* subtree reset):
$ pytest <same selection>
59 passed

# Full suite (Rust core rebuilt locally):
$ pytest
6251 passed, 496 skipped

$ ruff check .
All checks passed!
$ ruff format --check tests/conftest.py
1 file already formatted
```

## Real Behavior Proof

- Environment: macOS, Python 3.13.3, branch off latest `main`, Rust
`_core` rebuilt locally (`uv pip install -e .`).
- Exact command / steps: ran the polluter
(`test_claude_session_mode_benchmark`) together with the 5 victim tests
in one process to reproduce the order-dependent failure, then toggled
**only** the fixture change to confirm causality; then ran the full
`pytest` suite and `ruff`.
- Observed result: scoping the reset to only `"headroom"` → 5 failed /
54 passed; extending it to the `headroom.*` subtree → 59 passed. Full
suite: 6251 passed, 496 skipped, 0 failed. Lint clean.
- Not tested: behavior under CI's sharded `test (N)` jobs specifically —
but the fix is order-independent (resets before *every* test), so
sharding cannot reintroduce the leak.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation — N/A
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective — the 5
previously-flaky tests are the proof
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md — N/A (test-harness only)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-06-26 12:05:26 -05:00

273 lines
8.2 KiB
Python

"""Shared pytest fixtures for Headroom tests."""
# CRITICAL: Must be set before ANY imports that could trigger sentence_transformers
# The Rust tokenizers use parallelism that deadlocks with pytest-asyncio
import os
os.environ["TOKENIZERS_PARALLELISM"] = "false"
import json
import tempfile
from datetime import datetime
from pathlib import Path
from unittest.mock import Mock
import pytest
from tests._skip_helpers import external_model_skip_reason
# =============================================================================
# Global test hooks
# =============================================================================
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_call(item):
"""Wrap test execution to skip transient or offline external model failures.
This handles model-loading failures that occur when:
- HuggingFace Hub is slow during model downloads (sentence-transformers)
- Required HuggingFace model files were not restored into the offline CI cache
- External embedding APIs timeout
- Network connectivity issues in CI
"""
outcome = yield
if outcome.excinfo is not None:
exc_type, exc_value, exc_tb = outcome.excinfo
reason = external_model_skip_reason(exc_value)
if reason is not None:
pytest.skip(reason)
@pytest.fixture(autouse=True)
def _reset_headroom_logger_propagation():
"""Keep `headroom.*` log records flowing to pytest's caplog handler.
Two sources disable propagation on the headroom logger tree and never
restore it, which then makes later `caplog`-based assertions flaky in
full-suite runs (caplog attaches to root, so a `propagate=False` anywhere
on the chain silently drops the records):
- ``headroom.proxy.helpers._setup_file_logging`` sets
``getLogger("headroom").propagate = False`` on proxy startup.
- ``benchmarks.claude_session_mode_benchmark._disable_headroom_benchmark_logging``
(exercised by ``test_claude_session_mode_benchmark``) sets
``propagate = False`` + ``CRITICAL`` on ``headroom``, ``headroom.proxy``,
``headroom.transforms``, ``headroom.cache`` (and children).
Resetting only ``"headroom"`` is not enough — a child like
``"headroom.proxy"`` left non-propagating blocks the record before it
reaches root. Reset the whole subtree before every test so capture is
deterministic regardless of run order.
"""
import logging as _logging
for _name in ("headroom", *list(_logging.root.manager.loggerDict)):
if _name == "headroom" or _name.startswith("headroom."):
logger = _logging.getLogger(_name)
logger.disabled = False
logger.propagate = True
yield
# =============================================================================
# Sample messages fixtures
# =============================================================================
# Sample messages fixtures
@pytest.fixture
def sample_messages():
"""Basic conversation messages."""
return [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello, how are you?"},
{"role": "assistant", "content": "I'm doing well, thank you!"},
]
@pytest.fixture
def sample_messages_with_tools():
"""Conversation with tool calls and responses."""
return [
{"role": "system", "content": "You are a helpful assistant with tools."},
{"role": "user", "content": "Search for user 12345"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_123",
"type": "function",
"function": {"name": "search_user", "arguments": '{"user_id": "12345"}'},
}
],
},
{
"role": "tool",
"tool_call_id": "call_123",
"content": '{"id": "12345", "name": "Alice", "email": "alice@example.com"}',
},
{"role": "assistant", "content": "I found user Alice with ID 12345."},
]
@pytest.fixture
def sample_tool_output_large():
"""Large tool output for compression testing (100 items)."""
return json.dumps(
[
{
"id": i,
"name": f"Item {i}",
"score": i * 0.1,
"status": "active" if i % 2 == 0 else "inactive",
}
for i in range(100)
]
)
@pytest.fixture
def sample_tool_output_with_errors():
"""Tool output containing error items."""
items = [{"id": i, "status": "success"} for i in range(20)]
items[5] = {"id": 5, "status": "error", "message": "Connection refused"}
items[15] = {"id": 15, "status": "failed", "exception": "TimeoutError"}
return json.dumps(items)
@pytest.fixture
def sample_system_prompt_with_date():
"""System prompt containing dynamic date."""
return "You are a helpful assistant. Current date: 2025-01-06. Help the user with their tasks."
@pytest.fixture
def sample_anthropic_messages():
"""Anthropic-style messages with content blocks."""
return [
{
"role": "user",
"content": [
{"type": "text", "text": "Analyze this image"},
{
"type": "image",
"source": {"type": "base64", "media_type": "image/png", "data": "..."},
},
],
}
]
# Mock client fixtures
@pytest.fixture
def mock_openai_response():
"""Mock OpenAI API response."""
mock = Mock()
mock.id = "chatcmpl-123"
mock.model = "gpt-4o"
mock.usage = Mock()
mock.usage.prompt_tokens = 100
mock.usage.completion_tokens = 50
mock.usage.total_tokens = 150
mock.choices = [Mock()]
mock.choices[0].message = Mock()
mock.choices[0].message.content = "This is a response."
mock.choices[0].message.role = "assistant"
mock.choices[0].finish_reason = "stop"
return mock
@pytest.fixture
def mock_openai_client(mock_openai_response):
"""Mock OpenAI client."""
client = Mock()
client.chat = Mock()
client.chat.completions = Mock()
client.chat.completions.create = Mock(return_value=mock_openai_response)
return client
# Storage fixtures
@pytest.fixture
def temp_sqlite_db():
"""Temporary SQLite database path."""
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
yield f.name
Path(f.name).unlink(missing_ok=True)
@pytest.fixture
def temp_jsonl_file():
"""Temporary JSONL file path."""
with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=False) as f:
yield f.name
Path(f.name).unlink(missing_ok=True)
# Provider fixtures
@pytest.fixture
def openai_provider():
"""OpenAI provider instance."""
from headroom.providers.openai import OpenAIProvider
return OpenAIProvider()
@pytest.fixture
def openai_tokenizer():
"""OpenAI token counter for gpt-4o."""
from headroom.providers.openai import OpenAITokenCounter
return OpenAITokenCounter("gpt-4o")
# Config fixtures
@pytest.fixture
def default_config():
"""Default HeadroomConfig."""
from headroom.config import HeadroomConfig
return HeadroomConfig()
@pytest.fixture
def smart_crusher_config():
"""SmartCrusher config for testing."""
from headroom.config import SmartCrusherConfig
return SmartCrusherConfig(
enabled=True,
min_items_to_analyze=3,
min_tokens_to_crush=0, # Always crush for tests
max_items_after_crush=10,
)
# Helper for creating RequestMetrics
@pytest.fixture
def sample_request_metrics():
"""Sample RequestMetrics for storage tests."""
from headroom.config import RequestMetrics
return RequestMetrics(
request_id="test-123",
timestamp=datetime(2025, 1, 6, 12, 0, 0),
model="gpt-4o",
stream=False,
mode="audit",
tokens_input_before=1000,
tokens_input_after=800,
tokens_output=200,
block_breakdown={"system": 100, "user": 200, "assistant": 500},
waste_signals={"json_bloat": 50},
stable_prefix_hash="abc123",
cache_alignment_score=85.0,
cached_tokens=100,
transforms_applied=["CacheAligner", "SmartCrusher"],
tool_units_dropped=1,
turns_dropped=0,
messages_hash="def456",
)