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

435 lines
14 KiB
Python

"""Extended tests for thread_context module.
Focuses on thread-local context propagation correctness,
thread isolation guarantees, and shallow-copy edge cases.
"""
import threading
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,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _cleanup():
"""Remove any leftover context on the current thread."""
clear_search_context()
# ---------------------------------------------------------------------------
# set_search_context
# ---------------------------------------------------------------------------
class TestSetSearchContextExtended:
"""Extended tests for set_search_context."""
def setup_method(self):
_cleanup()
def teardown_method(self):
_cleanup()
def test_sets_context_in_thread_local_storage(self):
"""Setting context stores it for retrieval via get_search_context."""
ctx = {"research_id": "abc", "query": "test query"}
set_search_context(ctx)
stored = get_search_context()
assert stored is not None
assert stored == {"research_id": "abc", "query": "test query"}
def test_copies_context_so_original_mutation_has_no_effect(self):
"""set_search_context copies the dict -- mutating the original after
the call must not change the stored value."""
original = {"key": "original_value"}
set_search_context(original)
# Mutate the original
original["key"] = "mutated"
original["extra"] = "added"
stored = get_search_context()
assert stored["key"] == "original_value"
assert "extra" not in stored
def test_overwrites_existing_context_and_logs_debug(self):
"""When context already exists the old value is replaced and a
debug message is emitted."""
set_search_context({"round": 1})
with patch(
"local_deep_research.utilities.thread_context.logger"
) as mock_logger:
set_search_context({"round": 2})
mock_logger.debug.assert_called_once()
assert (
"overwritten" in mock_logger.debug.call_args[0][0].lower()
or "already set" in mock_logger.debug.call_args[0][0].lower()
)
assert get_search_context() == {"round": 2}
# ---------------------------------------------------------------------------
# clear_search_context
# ---------------------------------------------------------------------------
class TestClearSearchContextExtended:
"""Extended tests for clear_search_context."""
def setup_method(self):
_cleanup()
def teardown_method(self):
_cleanup()
def test_removes_context_from_thread_local_storage(self):
"""After clearing, get_search_context returns None."""
set_search_context({"id": "will-be-cleared"})
assert get_search_context() is not None
clear_search_context()
assert get_search_context() is None
def test_noop_when_no_context_set(self):
"""Calling clear when there is nothing stored must not raise."""
assert get_search_context() is None
clear_search_context() # should not raise
assert get_search_context() is None
# ---------------------------------------------------------------------------
# get_search_context
# ---------------------------------------------------------------------------
class TestGetSearchContextExtended:
"""Extended tests for get_search_context."""
def setup_method(self):
_cleanup()
def teardown_method(self):
_cleanup()
def test_returns_none_when_no_context_set(self):
"""Without prior set_search_context the return value is None."""
assert get_search_context() is None
def test_returns_copy_so_mutation_does_not_affect_stored(self):
"""Modifying the returned dict must not alter the stored context."""
set_search_context({"research_id": "immutable-check"})
returned = get_search_context()
returned["research_id"] = "mutated"
returned["injected"] = True
# The stored context must remain untouched
stored = get_search_context()
assert stored["research_id"] == "immutable-check"
assert "injected" not in stored
def test_returns_correct_context_values(self):
"""The returned dict contains exactly the same key-value pairs that
were originally stored."""
ctx = {
"research_id": "r-42",
"user": "alice",
"max_depth": 3,
"tags": ["science", "ai"],
}
set_search_context(ctx)
result = get_search_context()
assert result == ctx
# ---------------------------------------------------------------------------
# search_context (context manager)
# ---------------------------------------------------------------------------
class TestSearchContextManagerExtended:
"""Extended tests for the search_context context manager."""
def setup_method(self):
_cleanup()
def teardown_method(self):
_cleanup()
def test_sets_context_within_the_block(self):
"""Inside the with-block the context must be available."""
with search_context({"research_id": "inside-check"}):
ctx = get_search_context()
assert ctx is not None
assert ctx["research_id"] == "inside-check"
def test_clears_context_after_normal_exit(self):
"""After the with-block exits normally the context is cleared."""
with search_context({"research_id": "will-clear"}):
pass # normal exit
assert get_search_context() is None
def test_clears_context_when_exception_occurs(self):
"""Context is cleared even when an exception propagates out of the
with-block."""
with pytest.raises(RuntimeError, match="boom"):
with search_context({"research_id": "exception-path"}):
assert get_search_context() is not None
raise RuntimeError("boom")
assert get_search_context() is None
def test_re_raises_exception_from_inside_block(self):
"""The context manager does not swallow exceptions."""
with pytest.raises(TypeError, match="bad type"):
with search_context({"research_id": "re-raise"}):
raise TypeError("bad type")
# ---------------------------------------------------------------------------
# preserve_research_context (decorator)
# ---------------------------------------------------------------------------
class TestPreserveResearchContextExtended:
"""Extended tests for the preserve_research_context decorator."""
def setup_method(self):
_cleanup()
def teardown_method(self):
_cleanup()
def test_no_context_function_runs_normally(self):
"""When no context exists at decoration time the wrapped function
executes without setting or clearing any context."""
# Ensure no context is set
assert get_search_context() is None
@preserve_research_context
def plain_task(x, y):
"""Add two numbers."""
# Context should remain unset inside the function
assert get_search_context() is None
return x + y
result = plain_task(3, 7)
assert result == 10
# Still no context after the call
assert get_search_context() is None
def test_with_context_sets_before_and_clears_after(self):
"""When context is set at decoration time the decorator sets it
before the function body and clears it in the finally block."""
set_search_context({"research_id": "decorator-lifecycle"})
observed_inside = []
@preserve_research_context
def worker():
observed_inside.append(get_search_context())
# Clear the main-thread context so we can verify the decorator
# independently restores it inside the wrapper.
clear_search_context()
worker()
# During execution the context was set
assert observed_inside[0] is not None
assert observed_inside[0]["research_id"] == "decorator-lifecycle"
# After execution the context is cleared
assert get_search_context() is None
def test_preserves_function_name_and_docstring(self):
"""functools.wraps should keep __name__ and __doc__ intact."""
set_search_context({"research_id": "wraps-test"})
@preserve_research_context
def documented_function(a, b):
"""This is my docstring."""
return a * b
assert documented_function.__name__ == "documented_function"
assert documented_function.__doc__ == "This is my docstring."
def test_clears_context_when_decorated_function_raises(self):
"""If the wrapped function raises, context is still cleaned up via
the finally clause."""
set_search_context({"research_id": "error-cleanup"})
@preserve_research_context
def failing():
raise ValueError("intentional failure")
clear_search_context()
with pytest.raises(ValueError, match="intentional failure"):
failing()
assert get_search_context() is None
# ---------------------------------------------------------------------------
# Thread isolation
# ---------------------------------------------------------------------------
class TestThreadIsolationExtended:
"""Thread isolation guarantees for thread-local storage."""
def setup_method(self):
_cleanup()
def teardown_method(self):
_cleanup()
def test_context_set_in_one_thread_not_visible_in_another(self):
"""A context set on the main thread must not be visible in a freshly
spawned thread."""
set_search_context({"owner": "main-thread"})
child_result = [None]
error_holder = [None]
def child():
try:
child_result[0] = get_search_context()
except Exception as exc:
error_holder[0] = exc
t = threading.Thread(target=child)
t.start()
t.join()
assert error_holder[0] is None, (
f"Child thread raised: {error_holder[0]}"
)
assert child_result[0] is None, (
"Child thread should not see main thread context"
)
def test_clearing_in_one_thread_does_not_affect_another(self):
"""Clearing context on the main thread must not affect a context
set independently in a child thread."""
barrier = threading.Barrier(2, timeout=5)
child_context_after_main_clear = [None]
error_holder = [None]
def child():
try:
set_search_context({"owner": "child-thread"})
# Signal that child has set its context
barrier.wait()
# Wait for main thread to clear its context
barrier.wait()
child_context_after_main_clear[0] = get_search_context()
except Exception as exc:
error_holder[0] = exc
set_search_context({"owner": "main-thread"})
t = threading.Thread(target=child)
t.start()
# Wait until child has set its context
barrier.wait()
# Main clears its own context
clear_search_context()
# Signal child to read its context
barrier.wait()
t.join()
assert error_holder[0] is None, (
f"Child thread raised: {error_holder[0]}"
)
assert child_context_after_main_clear[0] is not None
assert child_context_after_main_clear[0]["owner"] == "child-thread"
def test_search_context_manager_isolates_across_threads(self):
"""Using the search_context context manager in two threads
concurrently must not cause cross-thread leakage."""
barrier = threading.Barrier(2, timeout=5)
results = {}
errors = []
def worker(name, ctx_value):
try:
with search_context({"worker": ctx_value}):
# Synchronise so both threads are inside their
# context managers at the same time.
barrier.wait()
observed = get_search_context()
results[name] = observed["worker"] if observed else None
except Exception as exc:
errors.append(exc)
t1 = threading.Thread(target=worker, args=("t1", "value-from-t1"))
t2 = threading.Thread(target=worker, args=("t2", "value-from-t2"))
t1.start()
t2.start()
t1.join()
t2.join()
assert not errors, f"Threads raised errors: {errors}"
assert results["t1"] == "value-from-t1"
assert results["t2"] == "value-from-t2"
# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------
class TestEdgeCasesExtended:
"""Edge-case scenarios for thread_context."""
def setup_method(self):
_cleanup()
def teardown_method(self):
_cleanup()
def test_empty_dict_context_is_valid(self):
"""An empty dict is a legitimate context -- it should be stored and
retrieved without being confused with None."""
set_search_context({})
result = get_search_context()
assert result is not None
assert result == {}
def test_shallow_copy_semantics_for_nested_dicts(self):
"""set_search_context and get_search_context use dict.copy() which is
a shallow copy. Nested mutable objects are shared references.
This test documents the expected (but potentially surprising)
behaviour: mutating a nested dict through a retrieved copy *does*
affect the stored context because dict.copy() is shallow."""
nested = {"inner_key": "original"}
set_search_context({"nested": nested})
retrieved = get_search_context()
# Top-level key mutation does NOT leak back
retrieved["top_new"] = "added"
assert "top_new" not in get_search_context()
# Nested mutation DOES leak back (shallow copy)
retrieved["nested"]["inner_key"] = "mutated"
assert get_search_context()["nested"]["inner_key"] == "mutated"