Files
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

465 lines
15 KiB
Python

"""Tests for thread_context module."""
import threading
from concurrent.futures import ThreadPoolExecutor
from contextvars import copy_context
from unittest.mock import patch
import pytest
from local_deep_research.utilities.thread_context import (
clear_search_context,
get_search_context,
preserve_research_context,
search_context,
set_search_context,
)
class TestSetSearchContext:
"""Tests for set_search_context function."""
def setup_method(self):
"""Clear context before each test."""
clear_search_context()
def test_sets_context(self):
"""Should set context."""
context = {"research_id": "123", "user": "test"}
set_search_context(context)
assert get_search_context() == context
def test_copies_context(self):
"""Should copy context to avoid mutations."""
context = {"research_id": "123"}
set_search_context(context)
context["new_key"] = "value"
assert "new_key" not in get_search_context()
def test_overwrites_existing_context(self):
"""Should overwrite existing context."""
set_search_context({"old": "context"})
set_search_context({"new": "context"})
assert get_search_context() == {"new": "context"}
def test_logs_debug_on_overwrite(self):
"""Should log debug message when overwriting existing context."""
set_search_context({"first": "context"})
with patch(
"local_deep_research.utilities.thread_context.logger"
) as mock_logger:
set_search_context({"second": "context"})
mock_logger.debug.assert_called_once()
def test_handles_empty_context(self):
"""Should handle empty context dictionary."""
set_search_context({})
assert get_search_context() == {}
class TestClearSearchContext:
"""Tests for clear_search_context function."""
def setup_method(self):
"""Clear context before each test."""
clear_search_context()
def test_clears_existing_context(self):
"""Should clear context when set."""
set_search_context({"research_id": "123"})
clear_search_context()
assert get_search_context() is None
def test_noop_when_no_context(self):
"""Should not raise when no context is set."""
clear_search_context() # Should not raise
def test_clear_then_set(self):
"""Should allow setting new context after clearing."""
set_search_context({"first": "context"})
clear_search_context()
set_search_context({"second": "context"})
assert get_search_context() == {"second": "context"}
class TestGetSearchContext:
"""Tests for get_search_context function."""
def setup_method(self):
"""Clear context before each test."""
clear_search_context()
def test_returns_none_when_not_set(self):
"""Should return None when no context is set."""
result = get_search_context()
assert result is None
def test_returns_context_when_set(self):
"""Should return context when set."""
context = {"research_id": "456"}
set_search_context(context)
result = get_search_context()
assert result == context
def test_returns_copy_of_context(self):
"""Should return a copy to prevent mutations."""
context = {"research_id": "789"}
set_search_context(context)
result = get_search_context()
result["mutated"] = True
# Original should not be mutated
assert "mutated" not in get_search_context()
def test_multiple_calls_return_same_data(self):
"""Multiple calls should return same data."""
context = {"key": "value"}
set_search_context(context)
result1 = get_search_context()
result2 = get_search_context()
assert result1 == result2
class TestThreadIsolation:
"""Tests for thread isolation of context."""
def setup_method(self):
"""Clear context before each test."""
clear_search_context()
def test_context_isolated_between_threads(self):
"""Context should be isolated between threads."""
main_context = {"thread": "main"}
set_search_context(main_context)
other_thread_context = []
def other_thread():
other_thread_context.append(get_search_context())
thread = threading.Thread(target=other_thread)
thread.start()
thread.join()
# Other thread should not see main thread's context
assert other_thread_context[0] is None
def test_each_thread_has_own_context(self):
"""Each thread should have its own context."""
results = {}
def thread_worker(thread_id):
set_search_context({"thread_id": thread_id})
# Small delay to allow interleaving
import time
time.sleep(0.01)
ctx = get_search_context()
results[thread_id] = ctx["thread_id"] if ctx else None
threads = []
for i in range(5):
t = threading.Thread(target=thread_worker, args=(i,))
threads.append(t)
t.start()
for t in threads:
t.join()
# Each thread should have preserved its own context
for i in range(5):
assert results[i] == i
class TestPreserveResearchContext:
"""Tests for preserve_research_context decorator."""
def setup_method(self):
"""Clear context before each test."""
clear_search_context()
def test_preserves_context_in_thread_pool(self):
"""Should preserve context when function runs in thread pool."""
context = {"research_id": "pool-test"}
set_search_context(context)
captured_context = []
@preserve_research_context
def worker():
captured_context.append(get_search_context())
return "done"
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(worker)
future.result()
assert len(captured_context) == 1
assert captured_context[0] == context
def test_works_without_context(self):
"""Should work when no context is set."""
captured_context = []
@preserve_research_context
def worker():
captured_context.append(get_search_context())
return "done"
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(worker)
future.result()
# Should not fail, context should be None
assert len(captured_context) == 1
assert captured_context[0] is None
def test_preserves_function_metadata(self):
"""Should preserve function name and docstring."""
@preserve_research_context
def my_function():
"""My docstring."""
pass
assert my_function.__name__ == "my_function"
assert my_function.__doc__ == "My docstring."
def test_passes_arguments_correctly(self):
"""Should pass arguments to wrapped function."""
context = {"research_id": "arg-test"}
set_search_context(context)
@preserve_research_context
def worker(a, b, c=None):
return (a, b, c)
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(worker, 1, 2, c=3)
result = future.result()
assert result == (1, 2, 3)
def test_returns_function_result(self):
"""Should return the wrapped function's result."""
context = {"research_id": "return-test"}
set_search_context(context)
@preserve_research_context
def worker():
return {"result": "success"}
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(worker)
result = future.result()
assert result == {"result": "success"}
def test_clears_context_after_execution(self):
"""Should clear context after wrapped function completes to prevent leaks."""
context = {"research_id": "cleanup-test"}
set_search_context(context)
@preserve_research_context
def worker():
# Context should be set during execution
return get_search_context()
context_after = []
def run_and_check():
worker()
context_after.append(get_search_context())
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(run_and_check)
future.result()
# After worker completes, context should be cleared on the worker thread
assert context_after[0] is None
def test_clears_context_on_exception(self):
"""Should clear context even when wrapped function raises."""
context = {"research_id": "exception-test"}
set_search_context(context)
@preserve_research_context
def failing_worker():
raise ValueError("test error")
context_after = []
def run_and_check():
try:
failing_worker()
except ValueError:
pass
context_after.append(get_search_context())
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(run_and_check)
future.result()
# Context should be cleared even after exception
assert context_after[0] is None
def test_preserves_context_across_multiple_calls(self):
"""Should preserve context for multiple calls."""
context = {"research_id": "multi-call"}
set_search_context(context)
results = []
@preserve_research_context
def worker(idx):
ctx = get_search_context()
return (idx, ctx["research_id"] if ctx else None)
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [executor.submit(worker, i) for i in range(3)]
results = [f.result() for f in futures]
for idx, research_id in results:
assert research_id == "multi-call"
class TestEdgeCases:
"""Edge case tests for thread_context module."""
def setup_method(self):
"""Clear context before each test."""
clear_search_context()
def test_context_with_nested_dict(self):
"""Should handle nested dictionaries in context."""
context = {
"research_id": "nested",
"metadata": {"key1": "value1", "key2": {"nested": "value"}},
}
set_search_context(context)
result = get_search_context()
assert result["metadata"]["key2"]["nested"] == "value"
def test_context_with_list_values(self):
"""Should handle lists in context."""
context = {"items": [1, 2, 3], "tags": ["a", "b", "c"]}
set_search_context(context)
result = get_search_context()
assert result["items"] == [1, 2, 3]
def test_context_with_none_values(self):
"""Should handle None values in context."""
context = {"research_id": "test", "optional": None}
set_search_context(context)
result = get_search_context()
assert result["optional"] is None
def test_rapid_set_get_cycles(self):
"""Should handle rapid set/get cycles."""
for i in range(100):
set_search_context({"iteration": i})
result = get_search_context()
assert result["iteration"] == i
class TestSearchContextManager:
"""Tests for search_context context manager."""
def setup_method(self):
"""Clear context before each test."""
clear_search_context()
def test_sets_and_clears_context(self):
"""Context should be set inside and cleared after the block."""
with search_context({"research_id": "cm-test"}):
ctx = get_search_context()
assert ctx == {"research_id": "cm-test"}
assert get_search_context() is None
def test_clears_on_exception(self):
"""Context should be cleared even when an exception occurs."""
with pytest.raises(ValueError):
with search_context({"research_id": "error-test"}):
raise ValueError("boom")
assert get_search_context() is None
def test_copies_context(self):
"""Context manager should copy the input dict."""
original = {"research_id": "copy-test"}
with search_context(original):
original["mutated"] = True
ctx = get_search_context()
assert "mutated" not in ctx
def test_works_in_thread_pool(self):
"""Context manager should work correctly in thread pool workers."""
results = []
def worker(rid):
with search_context({"research_id": rid}):
ctx = get_search_context()
results.append(ctx["research_id"])
# After exiting, context should be cleared
assert get_search_context() is None
with ThreadPoolExecutor(max_workers=3) as executor:
futures = [executor.submit(worker, f"pool-{i}") for i in range(5)]
for f in futures:
f.result()
assert sorted(results) == [f"pool-{i}" for i in range(5)]
def test_nested_context_restores_nothing(self):
"""Exiting inner context manager clears context (no stacking)."""
with search_context({"research_id": "outer"}):
with search_context({"research_id": "inner"}):
assert get_search_context()["research_id"] == "inner"
# Inner exited — context is cleared
assert get_search_context() is None
class TestContextVarsPropagation:
"""Regression: context must propagate through frameworks that copy
contextvars to worker threads (e.g. langchain's ContextThreadPoolExecutor
used by LangGraph for tool execution)."""
def setup_method(self):
clear_search_context()
def test_copy_context_run_propagates_to_worker(self):
"""copy_context().run(fn) — the mechanism used by langchain's
ContextThreadPoolExecutor — must carry our context across threads."""
set_search_context({"research_id": "ctx-propagation"})
observed = {}
def worker():
observed["ctx"] = get_search_context()
# Capture the parent context, then run the worker inside that copy
# on a new thread (matches what ContextThreadPoolExecutor.submit does).
ctx = copy_context()
t = threading.Thread(target=ctx.run, args=(worker,))
t.start()
t.join()
assert observed["ctx"] == {"research_id": "ctx-propagation"}
def test_worker_mutation_does_not_leak_to_caller(self):
"""A copied context is isolated — set_search_context inside the
worker must not affect the caller's context."""
set_search_context({"research_id": "caller"})
def worker():
set_search_context({"research_id": "worker"})
ctx = copy_context()
t = threading.Thread(target=ctx.run, args=(worker,))
t.start()
t.join()
# Caller still sees its own value
assert get_search_context() == {"research_id": "caller"}