diff --git a/AGENTS.md b/AGENTS.md index 19def4d36..ca85af065 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,6 +111,7 @@ Verified against `Makefile`. Grouped by use. | `make format-check` | Format dry-run. Part of `make ci`; rarely run alone. | | `make test` | Full unit suite — `tests/` + every `solstone/apps/*/tests/`, one parallel run. Format-check runs first; failures block tests. | | `make test-cov` | Same suite with full-repo terminal coverage; used by `make verify`. | +| `make test-integration` | Opt-in real local build/process and persisted-index contracts. Never part of `make ci`. | | `make test-app APP=` | Run a single app's tests (focus helper). | | `make test-only TEST=` | Run a specific test file or pytest node id (`TEST="-k test_name"` also works). | | `make coverage` | HTML coverage report under `htmlcov/`. Occasional. | @@ -159,10 +160,10 @@ seek green. ## 6. Testing quickstart - **Framework:** pytest. Files `test_*.py`, functions `test_*`. Shared fixtures in `tests/conftest.py`. -- **Fixture journal:** `tests/fixtures/journal/` — a complete mock journal with facets, entities, segments, index state. The autouse `set_test_journal_path` fixture in `tests/conftest.py` sets `SOLSTONE_JOURNAL` to this path for unit tests. Individual tests may override it with `monkeypatch.setenv` when they need an isolated tmp journal (see §8). +- **Fixture journal:** `tests/fixtures/journal/` — immutable mock input with facets, entities, segments, and index state. The autouse `set_test_journal_path` fixture in `tests/conftest.py` points unit tests at it. Tests that write, scan, or rebuild journal/index state must use `journal_copy` or a smaller `tmp_path` journal (see §8). - **Run one test:** `make test-only TEST=tests/test_utils.py::test_foo` or `TEST="-k test_foo"`. **One app:** `make test-app APP=`. - **`make test` runs everything** — `tests/` and every `solstone/apps/*/tests/` in one parallel run. App tests are not a separate step. -- **All tests are fast unit/component tests** — no real browser, no live network, no API keys. There is no integration/e2e test tier; tests that would need those were removed in favor of live verification via `make sandbox`. +- **`make test` / `make ci` are strict unit/component rails** — mock process, thread, clock, network, and repository boundaries; no real browser, live network, API keys, heavyweight builds, or writes to shared fixture state. Real local build/process and persisted-index contracts use `@pytest.mark.integration` and run only through `make test-integration`. Live product verification uses `make sandbox`. - **After editing `solstone/convey/` or `solstone/apps/`:** `journal restart-convey` to reload code in a running stack. - **`make dev` + `make sandbox`** both write runtime artifacts into the fixtures journal; `tests/fixtures/journal/.gitignore` covers those — never commit them. - **Test invariants, not snapshots.** A test asserts what must hold in *every* valid state of the system — not what happens to be true today. Never pin a test to hand-edited prose (CHANGELOG / README / docs), to a value the system is *designed* to change (a version, a date, a growing count), or to a transient state. The tell: if doing the correct next thing — cut a release, rename a label, graduate a shipped changelog entry — turns the test red, the test is wrong, not the system. And test the code that *produces* a fact, never the rendered text about it. (A `[Unreleased]`-pinned changelog test was exactly this anti-pattern — its pass condition required the release process to *not* run; removed 2026-05-30.) diff --git a/Makefile b/Makefile index 81567ba49..1fc0e6da4 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ export TMPDIR := /var/tmp PYTEST_BASETEMP_INIT := BASETEMP=$$(mktemp -d /var/tmp/solstone-pytest-XXXXXX); trap 'rm -rf "$$BASETEMP"' EXIT INT TERM; PYTEST_BASETEMP_FLAG := --basetemp "$$BASETEMP" -.PHONY: install hopper-install uninstall test test-cov test-integration test-performance test-app test-only format format-check install-checks ci clean clean-install coverage watch versions update update-prices preflight pre-commit skills render-packaging check-rust-fmt check-rust-clippy check-rust-test check-rust-ios check-rust-deny audit openapi check-openapi contract check-contract journal-resolution-vectors check-journal-resolution-vectors dev all sandbox sandbox-stop install-models parakeet-helper parakeet-helper-clean wheel-macos wheel-macos-clean verify verify-api verify-schemathesis update-api-baselines eval-schemas service-logs check-layer-hygiene check-api-conventions check-journal-io-access check-journal-io-mechanic check-journal-config-owner check-call-http-only check-tools-http-only check-access-imports-clean check-convey-bind-imports-clean check-schema-bounds check-thin-base-install check-cogitate-prompts smoke-cogitate release release-test FORCE +.PHONY: install hopper-install uninstall test test-cov test-integration test-performance test-app test-only format format-check install-checks ci clean clean-install coverage watch versions update update-prices preflight pre-commit skills render-packaging check-rust-fmt check-rust-clippy check-rust-test check-rust-ios check-rust-deny audit openapi check-openapi contract check-contract journal-resolution-vectors check-journal-resolution-vectors dev all sandbox sandbox-stop install-models parakeet-helper parakeet-helper-clean wheel-macos wheel-macos-clean verify verify-api verify-schemathesis update-api-baselines eval-schemas service-logs check-layer-hygiene check-api-conventions check-journal-io-access check-journal-io-mechanic check-journal-config-owner check-call-http-only check-no-legacy-chat check-tools-http-only check-access-imports-clean check-convey-bind-imports-clean check-schema-bounds check-thin-base-install check-cogitate-prompts smoke-cogitate release release-test FORCE # Default target - install package in editable mode all: install @@ -492,6 +492,9 @@ install-checks: .installed @echo "=== Running call-http-only check ===" @$(MAKE) check-call-http-only @echo "" + @echo "=== Running legacy-chat surface check ===" + @$(MAKE) check-no-legacy-chat + @echo "" @echo "=== Running schema-bounds check ===" @$(MAKE) check-schema-bounds @echo "" @@ -630,6 +633,10 @@ check-provider-start-commands: .installed check-call-http-only: .installed $(VENV_BIN)/python scripts/check_call_http_only.py +# Removed chat surfaces stay out of tracked Python, HTML, and JavaScript. +check-no-legacy-chat: .installed + $(VENV_BIN)/python scripts/check_no_legacy_chat.py + # Generation schema bounds ratchet check-schema-bounds: .installed $(VENV_BIN)/python scripts/check_schema_bounds.py diff --git a/docs/testing.md b/docs/testing.md index 8efdb788f..4ad042acf 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -4,9 +4,15 @@ - **Framework**: pytest; coverage reporting comes from `make test-cov`, `make verify`, or `make coverage`, not `make test` or `make ci` - **Unit Tests**: live under `tests/` (and each app's `tests/` dir) - - Fast, no external API calls, no real browser - - Use `tests/fixtures/journal/` mock data + - Fast, with mocked process/thread/clock/network/repository boundaries + - No external API calls, real browser, heavyweight build, or shared fixture writes + - Read `tests/fixtures/journal/` mock data; use `journal_copy` or `tmp_path` + for any scan, index rebuild, or mutation - Test individual functions and modules +- **Integration Tests**: marked `@pytest.mark.integration` + - Opt-in via `make test-integration`; excluded from `make test` and `make ci` + - Real local processes/builds and persisted-index contracts + - Still use disposable `tmp_path` state and never the owner's journal - **Naming**: Files `test_*.py`, functions `test_*` - **Fixtures**: Shared fixtures in `tests/conftest.py` @@ -19,12 +25,16 @@ os.environ["SOLSTONE_JOURNAL"] = "tests/fixtures/journal" # Now all journal operations work with test data ``` -The `tests/fixtures/journal/` directory contains a complete mock journal structure with sample facets, agents, transcripts, and indexed data for testing. +The `tests/fixtures/journal/` directory contains immutable mock input with sample +facets, agents, transcripts, and indexed data. Tests may read it directly. Any +test that writes, scans, or rebuilds journal/index state must use the +`journal_copy` fixture or a smaller journal under `tmp_path`. ## Running Tests - `make test` runs all unit tests — `tests/` + every `solstone/apps/*/tests/`, in one parallel run - `make test-cov` — the same suite with coverage reporting +- `make test-integration` — opt-in real local build/process and persisted-index contracts - `make test-app APP=` and `make test-only TEST=path` are the focused development loop - `make coverage` to generate a coverage report - `make ci` once on the settled final tree before merge or release (install checks plus the full unit suite) diff --git a/pyproject.toml b/pyproject.toml index 2bae29504..48561c01b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -251,7 +251,7 @@ python_files = ["test_*.py"] python_classes = ["Test*"] python_functions = ["test_*"] markers = [ - "integration: uses real external processes and belongs in operator validation, not unit CI", + "integration: exercises real local processes, builds, or persisted index contracts outside unit CI", "performance: asserts a wall-clock performance floor and is opt-in on an idle host", "xdist_group: marks tests to run in the same xdist worker group", ] diff --git a/scripts/check_no_legacy_chat.py b/scripts/check_no_legacy_chat.py new file mode 100644 index 000000000..f9ca602ab --- /dev/null +++ b/scripts/check_no_legacy_chat.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Reject removed chat imports, names, and DOM literals. + +This is a repository architecture check, not a unit test. It inventories tracked +Python, HTML, and JavaScript files once, parses each Python file once, and reports +every legacy chat surface still present. +""" + +from __future__ import annotations + +import ast +import subprocess +from collections.abc import Iterable +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def _parts(*pieces: str) -> str: + return "".join(pieces) + + +ALLOWED_UNIFIED_PATHS = { + "apps/sol/maint/006_rename_unified_triage_providers.py", + "tests/test_maint_006_rename_unified_triage_providers.py", +} +FORBIDDEN_CHAT_LITERALS = { + "conversationBackdrop", + "conversationMessages", + "chatBarResponsePanel", + "chatBarThinking", + "chatBarResponse", + "chatBarDismiss", + "conversation-backdrop", + "conversation-messages", + "conversation-separator", + "solstone:conversationState", + "solstone:chatBarState", + "panelFocusTrapHandler", + "openPanel", + "closePanel", + "_closeConversationPanel", +} +BANNED_NAMES = { + _parts("_", "display_", "mode"), + _parts("record_", "exchange"), + _parts("build_", "memory_", "context"), + _parts("INJECTION_", "MARKER"), + _parts("inject_", "memory"), + _parts("get_", "recent_", "exchanges"), + _parts("get_", "today_", "exchanges"), + _parts("TRIAGE_", "AGENT_", "NAMES"), + _parts("record_", "triage_", "exchange"), + _parts("compute_", "display_", "mode"), +} +LEGACY_CHAT_MODULE = _parts("think", ".", "conversation") +LEGACY_MEMORY_MODULE = _parts("talent", ".", "conversation_", "memory") +LEGACY_NAME = _parts("uni", "fied") + + +def scan_python_source(source: str, relative_path: str) -> list[str]: + """Return legacy Python findings for one source file.""" + try: + tree = ast.parse(source, filename=relative_path) + except SyntaxError: + return [] + + findings: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name in {LEGACY_CHAT_MODULE, LEGACY_MEMORY_MODULE}: + findings.append(f"{relative_path}: import {alias.name}") + elif isinstance(node, ast.ImportFrom): + if node.module in {LEGACY_CHAT_MODULE, LEGACY_MEMORY_MODULE}: + findings.append(f"{relative_path}: from {node.module} import ...") + elif isinstance(node, ast.Name) and node.id in BANNED_NAMES: + findings.append(f"{relative_path}: name {node.id}") + elif isinstance(node, ast.Attribute) and node.attr in BANNED_NAMES: + findings.append(f"{relative_path}: attribute {node.attr}") + elif ( + isinstance(node, ast.Constant) + and node.value == LEGACY_NAME + and relative_path not in ALLOWED_UNIFIED_PATHS + ): + findings.append(f"{relative_path}: legacy literal") + return findings + + +def scan_text_source(source: str, relative_path: str) -> list[str]: + """Return legacy DOM findings for one HTML or JavaScript file.""" + if "tests/fixtures" in relative_path: + return [] + return [ + f"{relative_path}: {literal}" + for literal in sorted(FORBIDDEN_CHAT_LITERALS) + if literal in source + ] + + +def scan_paths(root: Path, relative_paths: Iterable[str]) -> list[str]: + """Scan an already-discovered path inventory.""" + findings: list[str] = [] + for relative_path in relative_paths: + path = root / relative_path + if not path.exists(): + continue + source = path.read_text(encoding="utf-8") + if path.suffix == ".py": + findings.extend(scan_python_source(source, relative_path)) + elif path.suffix in {".html", ".js"}: + findings.extend(scan_text_source(source, relative_path)) + return findings + + +def tracked_source_paths(root: Path = ROOT) -> list[str]: + """Return the tracked source inventory in one Git subprocess.""" + result = subprocess.run( + ["git", "ls-files", "*.py", "*.html", "*.js"], + cwd=root, + check=True, + capture_output=True, + text=True, + ) + return [line for line in result.stdout.splitlines() if line] + + +def main() -> int: + findings = scan_paths(ROOT, tracked_source_paths()) + if findings: + print("Legacy chat surfaces remain:") + for finding in findings: + print(f" {finding}") + return 1 + print("No legacy chat surfaces found.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/solstone/apps/thinking/tests/conftest.py b/solstone/apps/thinking/tests/conftest.py index 24bb92161..909c42836 100644 --- a/solstone/apps/thinking/tests/conftest.py +++ b/solstone/apps/thinking/tests/conftest.py @@ -10,7 +10,7 @@ import os import pytest -from solstone.think.services import operations, spp +from solstone.think.services import operations, spp, spp_transport @pytest.fixture(scope="module") @@ -62,6 +62,13 @@ def _clear_spp_attestation_state(): spp.delete_attestation_state() +@pytest.fixture(autouse=True) +def _clear_spp_transport_state(): + spp_transport.teardown_confidential_transport() + yield + spp_transport.teardown_confidential_transport() + + @pytest.fixture def settings_env(tmp_path, monkeypatch): """Create a temporary journal with provider config.""" diff --git a/solstone/apps/thinking/tests/test_confidential_routes.py b/solstone/apps/thinking/tests/test_confidential_routes.py index 7c3a3be52..abb27b326 100644 --- a/solstone/apps/thinking/tests/test_confidential_routes.py +++ b/solstone/apps/thinking/tests/test_confidential_routes.py @@ -16,19 +16,7 @@ from solstone.convey import create_app from solstone.think.services import operations, spp, spp_handoff, spp_transport from solstone.think.services.spp_attest.cadence import AttestationSession from tests.helpers.journal_config import seed_journal_config - - -class _InlineThread: - def __init__(self, target=None, args=(), kwargs=None, daemon=None): - self._target = target - self._args = args - self._kwargs = kwargs or {} - - def start(self): - self._target(*self._args, **self._kwargs) - - def join(self, timeout=None): - return None +from tests.helpers.module_mocks import inline_thread_constructor, module_mock class _FakeChannel: @@ -128,7 +116,14 @@ def test_enable_confidential_returns_operation_and_lands_not_verified( ) monkeypatch.setattr(spp_handoff, "run_confidential_handoff", runner) - monkeypatch.setattr(operations.threading, "Thread", _InlineThread) + monkeypatch.setattr( + operations, + "threading", + module_mock( + operations.threading, + Thread=inline_thread_constructor(), + ), + ) response = thinking_client.post("/app/thinking/api/confidential/enable") @@ -160,7 +155,14 @@ def test_enable_confidential_early_access_stays_off( return operations.HandoffResult("early_access", None, False) monkeypatch.setattr(spp_handoff, "run_confidential_handoff", runner) - monkeypatch.setattr(operations.threading, "Thread", _InlineThread) + monkeypatch.setattr( + operations, + "threading", + module_mock( + operations.threading, + Thread=inline_thread_constructor(), + ), + ) response = thinking_client.post("/app/thinking/api/confidential/enable") @@ -319,7 +321,14 @@ def test_confidential_routes_and_provider_payload_are_secret_free( return operations.HandoffResult("enabled", "not yet verified", False) monkeypatch.setattr(spp_handoff, "run_confidential_handoff", runner) - monkeypatch.setattr(operations.threading, "Thread", _InlineThread) + monkeypatch.setattr( + operations, + "threading", + module_mock( + operations.threading, + Thread=inline_thread_constructor(), + ), + ) start = thinking_client.post("/app/thinking/api/confidential/enable") assert start.status_code == 202 diff --git a/solstone/apps/thinking/tests/test_local_bootstrap_routes.py b/solstone/apps/thinking/tests/test_local_bootstrap_routes.py index a0ddadcaa..569c7a15e 100644 --- a/solstone/apps/thinking/tests/test_local_bootstrap_routes.py +++ b/solstone/apps/thinking/tests/test_local_bootstrap_routes.py @@ -30,6 +30,7 @@ from solstone.think.providers.install_state import ( write_install_status, ) from solstone.think.providers.local import LOCAL_MODEL_SPECS +from tests.helpers.module_mocks import module_mock def _client(journal_path): @@ -61,6 +62,15 @@ def _reset_local_state(): local_bootstrap._INSTALL_THREADS.clear() +@pytest.fixture(autouse=True) +def _isolate_local_bootstrap_threading(monkeypatch): + monkeypatch.setattr( + local_bootstrap, + "threading", + module_mock(local_bootstrap.threading), + ) + + class _FakeThread: init_count = 0 start_count = 0 diff --git a/solstone/apps/transcripts/tests/test_segment_routes.py b/solstone/apps/transcripts/tests/test_segment_routes.py index 9ce416790..a6244530f 100644 --- a/solstone/apps/transcripts/tests/test_segment_routes.py +++ b/solstone/apps/transcripts/tests/test_segment_routes.py @@ -39,6 +39,7 @@ from solstone.observe.processing_record import ( ) from solstone.think.data_state import ANALYZING_STALE_SECONDS from solstone.think.importers import health_schema +from tests.helpers.module_mocks import module_mock # 20260304 is the canonical fully-analyzed reference day; see # tests/fixtures/journal/chronicle/20260304/README.md and @@ -52,6 +53,12 @@ _requires_ffmpeg = pytest.mark.skipif( ) +@pytest.fixture(autouse=True) +def _isolate_routes_stdlib_modules(monkeypatch): + monkeypatch.setattr(routes, "subprocess", module_mock(routes.subprocess)) + monkeypatch.setattr(routes, "threading", module_mock(routes.threading)) + + def _apple_health_card_stream() -> str: return health_schema.health_card_stream(health_schema.SOURCE_APPLE_HEALTH) diff --git a/tests/conftest.py b/tests/conftest.py index f48c84187..acc7c3e45 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -350,6 +350,7 @@ def reset_supervisor_state(): "parakeet": mod.ProviderRecoveryState(), } mod._is_remote_mode = False + mod._supervisor_callosum = None # Create fresh task queue mod._task_queue = mod.TaskQueue(on_queue_change=None) except ImportError: @@ -365,6 +366,7 @@ def reset_supervisor_state(): "parakeet": mod.ProviderRecoveryState(), } mod._is_remote_mode = False + mod._supervisor_callosum = None mod._observer_health = {} mod._enabled_observers = set() # Create fresh task queue diff --git a/tests/helpers/module_mocks.py b/tests/helpers/module_mocks.py new file mode 100644 index 000000000..453a26524 --- /dev/null +++ b/tests/helpers/module_mocks.py @@ -0,0 +1,63 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Helpers for replacing product-local stdlib module aliases with mocks.""" + +from __future__ import annotations + +from types import ModuleType +from typing import Any, Callable +from unittest.mock import Mock + + +def module_mock(module: ModuleType, /, **overrides: Any) -> Mock: + """Create a shallow module mock with real defaults and local overrides.""" + mocked = Mock(spec=module) + for name in dir(module): + if not name.startswith("__"): + setattr(mocked, name, getattr(module, name)) + for name, value in overrides.items(): + setattr(mocked, name, value) + return mocked + + +def inline_thread_constructor() -> Mock: + """Return a mocked Thread constructor whose ``start`` runs inline.""" + constructor = Mock(name="Thread") + + def build(*args: Any, **kwargs: Any) -> Mock: + target = kwargs.get("target", args[0] if args else None) + target_args = kwargs.get("args", ()) + target_kwargs = kwargs.get("kwargs", {}) + thread = Mock(name="thread") + thread._target = target + thread._args = target_args + thread._kwargs = target_kwargs + thread.is_alive.return_value = False + thread.start.side_effect = lambda: target(*target_args, **target_kwargs) + return thread + + constructor.side_effect = build + return constructor + + +def capturing_thread_constructor( + started: list[Any], + *, + capture: Callable[[Mock], Any] = lambda thread: thread, +) -> Mock: + """Return a mocked Thread constructor that records starts without running.""" + constructor = Mock(name="Thread") + + def build(*args: Any, **kwargs: Any) -> Mock: + target = kwargs.get("target", args[0] if args else None) + thread = Mock(name="thread") + thread._target = target + thread._args = kwargs.get("args", ()) + thread._kwargs = kwargs.get("kwargs", {}) + thread.is_alive.return_value = False + thread.start.side_effect = lambda: started.append(capture(thread)) + return thread + + constructor.side_effect = build + return constructor diff --git a/tests/helpers/test_module_mocks.py b/tests/helpers/test_module_mocks.py new file mode 100644 index 000000000..c41ba5220 --- /dev/null +++ b/tests/helpers/test_module_mocks.py @@ -0,0 +1,53 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import threading +import time +from unittest.mock import Mock + +from tests.helpers.module_mocks import ( + capturing_thread_constructor, + inline_thread_constructor, + module_mock, +) + + +def test_module_mock_overrides_are_local() -> None: + real_time = time.time + clock = Mock(return_value=123.0) + + mocked_time = module_mock(time, time=clock) + + assert mocked_time.time() == 123.0 + assert mocked_time.monotonic is time.monotonic + assert time.time is real_time + + +def test_inline_thread_constructor_runs_target_without_real_thread() -> None: + target = Mock() + constructor = inline_thread_constructor() + active_before = threading.active_count() + + thread = constructor(target=target, args=("value",), kwargs={"flag": True}) + thread.start() + + target.assert_called_once_with("value", flag=True) + assert constructor.call_count == 1 + assert threading.active_count() == active_before + + +def test_capturing_thread_constructor_records_without_running() -> None: + target = Mock() + started: list[tuple[object, ...]] = [] + constructor = capturing_thread_constructor( + started, + capture=lambda thread: thread._args, + ) + + thread = constructor(target=target, args=("value",)) + thread.start() + + assert started == [("value",)] + target.assert_not_called() diff --git a/tests/integration/test_journal_index_fixture.py b/tests/integration/test_journal_index_fixture.py new file mode 100644 index 000000000..230954251 --- /dev/null +++ b/tests/integration/test_journal_index_fixture.py @@ -0,0 +1,152 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Persisted-index contracts against a disposable copy of the fixture journal.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from solstone.think.indexer.journal import ( + get_journal_index, + index_file, + scan_journal, + search_counts, + search_journal, +) + +pytestmark = pytest.mark.integration + + +def _scan(journal: Path) -> None: + scan_journal(str(journal), full=True) + + +def _entity_count(journal: Path) -> int: + conn, _ = get_journal_index(str(journal)) + try: + return conn.execute( + "SELECT count(*) FROM chunks WHERE agent='entity'" + ).fetchone()[0] + finally: + conn.close() + + +def test_search_journal_stream_filter(journal_copy: Path) -> None: + _scan(journal_copy) + + total, results = search_journal("", stream="default") + assert total > 0 + assert all(result["metadata"]["stream"] == "default" for result in results) + + total, _results = search_journal("", stream="nonexistent") + assert total == 0 + + +def test_search_journal_results_include_stream(journal_copy: Path) -> None: + _scan(journal_copy) + + total, results = search_journal("", stream="default") + + assert total > 0 + assert all(result["metadata"]["stream"] == "default" for result in results) + + +def test_browser_fixture_chunks_include_stream_and_agent( + journal_copy: Path, +) -> None: + rel = "20260703/suze.browser/000141_317/browser_mail-google-com.jsonl" + + index_file(str(journal_copy), rel) + + conn, _ = get_journal_index(str(journal_copy)) + try: + rows = conn.execute( + "SELECT agent, stream FROM chunks WHERE path=? ORDER BY idx", + (rel,), + ).fetchall() + finally: + conn.close() + + assert len(rows) == 6 + assert {agent for agent, _stream in rows} == {"browser"} + assert {stream for _agent, stream in rows} == {"suze.browser"} + + +def test_search_counts_stream_filter(journal_copy: Path) -> None: + _scan(journal_copy) + + assert "streams" in search_counts("") + assert search_counts("", stream="default")["total"] > 0 + assert search_counts("", stream="nonexistent")["total"] == 0 + + +def test_entity_search_chunks_indexed(journal_copy: Path) -> None: + _scan(journal_copy) + + # One chunk per entity-facet relationship in the current fixture journal. + assert _entity_count(journal_copy) == 40 + + +def test_entity_search_chunks_use_entity_search_path(journal_copy: Path) -> None: + _scan(journal_copy) + conn, _ = get_journal_index(str(journal_copy)) + try: + rows = conn.execute( + "SELECT DISTINCT path FROM chunks WHERE agent='entity'" + ).fetchall() + finally: + conn.close() + + assert all(row[0].startswith("entity_search:") for row in rows) + + +def test_entity_search_by_name(journal_copy: Path) -> None: + _scan(journal_copy) + + total, results = search_journal("Alice Johnson", agent="entity") + + assert total >= 1 + assert any(result["metadata"]["agent"] == "entity" for result in results) + + +def test_entity_search_by_type(journal_copy: Path) -> None: + _scan(journal_copy) + + total, _results = search_journal("Person", agent="entity") + + assert total >= 1 + + +def test_entity_search_includes_description(journal_copy: Path) -> None: + _scan(journal_copy) + + total, results = search_journal("college", agent="entity") + + assert total >= 1 + assert any("college" in result["text"].lower() for result in results) + + +def test_entity_search_includes_facet(journal_copy: Path) -> None: + _scan(journal_copy) + + total, results = search_journal( + "Alice Johnson", + agent="entity", + facet="personal", + ) + + assert total >= 1 + assert all(result["metadata"]["facet"] == "personal" for result in results) + + +def test_entity_search_idempotent(journal_copy: Path) -> None: + _scan(journal_copy) + count1 = _entity_count(journal_copy) + + _scan(journal_copy) + count2 = _entity_count(journal_copy) + + assert count1 == count2 == 40 diff --git a/tests/test_solstone_core_wheel_install.py b/tests/integration/test_solstone_core_wheel_install.py similarity index 96% rename from tests/test_solstone_core_wheel_install.py rename to tests/integration/test_solstone_core_wheel_install.py index fe7c7a98a..e4a0a8f61 100644 --- a/tests/test_solstone_core_wheel_install.py +++ b/tests/integration/test_solstone_core_wheel_install.py @@ -11,7 +11,8 @@ from pathlib import Path import pytest -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] +pytestmark = pytest.mark.integration @pytest.mark.skipif( diff --git a/tests/services/test_spl_handoff.py b/tests/services/test_spl_handoff.py index 23d3f4b4f..7fa3ac08a 100644 --- a/tests/services/test_spl_handoff.py +++ b/tests/services/test_spl_handoff.py @@ -24,6 +24,7 @@ from solstone.think.services import ( ) from solstone.think.spl import relay_client from tests.helpers.journal_config import seed_journal_config +from tests.helpers.module_mocks import inline_thread_constructor, module_mock TEST_INSTANCE_ID = "00000000-0000-4000-8000-000000000000" TEST_NONCE = "TESTNONCE" @@ -31,19 +32,6 @@ TEST_BASE_URL = "https://services.test" TEST_SUBSCRIBE_URL = "https://services.test/account/subscription" -class _InlineThread: - def __init__(self, target=None, args=(), kwargs=None, daemon=None): - self._target = target - self._args = args - self._kwargs = kwargs or {} - - def start(self): - self._target(*self._args, **self._kwargs) - - def join(self, timeout=None): - return None - - class FakeResponse: def __init__(self, status: int, body: bytes = b"") -> None: self.status = status @@ -654,7 +642,14 @@ def test_run_spl_handoff_maps_needs_subscription_to_operation( operations.clear_registry() try: - monkeypatch.setattr(operations.threading, "Thread", _InlineThread) + monkeypatch.setattr( + operations, + "threading", + module_mock( + operations.threading, + Thread=inline_thread_constructor(), + ), + ) operations.start_operation( "spl", "spl_enable", diff --git a/tests/spl/test_relay_client_blob_dispatch.py b/tests/spl/test_relay_client_blob_dispatch.py index 1bad38c19..99126c3e8 100644 --- a/tests/spl/test_relay_client_blob_dispatch.py +++ b/tests/spl/test_relay_client_blob_dispatch.py @@ -4,10 +4,12 @@ from __future__ import annotations from typing import Any +from unittest.mock import AsyncMock, Mock import pytest from solstone.think.spl import blob_receiver, relay_client +from tests.helpers.module_mocks import module_mock class FakeWs: @@ -79,11 +81,14 @@ async def test_handle_tunnel_tls_writes_peeked_bytes_first( ) -> None: ws = FakeWs([b"\x16\x03\x01abcdef"]) writer = FakeTcpWriter() - monkeypatch.setattr(relay_client.websockets, "connect", lambda *_a, **_k: ws) + monkeypatch.setattr(relay_client.websockets, "connect", Mock(return_value=ws)) monkeypatch.setattr( - relay_client.asyncio, - "open_connection", - lambda *_a, **_k: _open_connection(writer), + relay_client, + "asyncio", + module_mock( + relay_client.asyncio, + open_connection=AsyncMock(return_value=(FakeTcpReader(), writer)), + ), ) await _client()._handle_tunnel("tls") @@ -98,7 +103,7 @@ async def test_handle_tunnel_blob_branch_does_not_open_loopback( ) -> None: ws = FakeWs([b"SBO1rest"]) called = False - monkeypatch.setattr(relay_client.websockets, "connect", lambda *_a, **_k: ws) + monkeypatch.setattr(relay_client.websockets, "connect", Mock(return_value=ws)) async def fail_open_connection(*_args: Any) -> tuple[FakeTcpReader, FakeTcpWriter]: raise AssertionError("blob branch must not open loopback") @@ -109,15 +114,19 @@ async def test_handle_tunnel_blob_branch_does_not_open_loopback( assert await reader.read_exactly(4) == b"SBO1" called = True - monkeypatch.setattr(relay_client.asyncio, "open_connection", fail_open_connection) - monkeypatch.setattr(blob_receiver, "receive_blob", fake_receive) + open_connection = AsyncMock(side_effect=fail_open_connection) + monkeypatch.setattr( + relay_client, + "asyncio", + module_mock(relay_client.asyncio, open_connection=open_connection), + ) + monkeypatch.setattr( + blob_receiver, + "receive_blob", + AsyncMock(side_effect=fake_receive), + ) await _client()._handle_tunnel("blob") assert called is True - - -async def _open_connection( - writer: FakeTcpWriter, -) -> tuple[FakeTcpReader, FakeTcpWriter]: - return FakeTcpReader(), writer + open_connection.assert_not_awaited() diff --git a/tests/spl/test_relay_client_pair_dispatch.py b/tests/spl/test_relay_client_pair_dispatch.py index 1b80f0edc..54e198423 100644 --- a/tests/spl/test_relay_client_pair_dispatch.py +++ b/tests/spl/test_relay_client_pair_dispatch.py @@ -4,11 +4,13 @@ from __future__ import annotations from typing import Any +from unittest.mock import AsyncMock import pytest from solstone.think.link import browser_pairing from solstone.think.spl import relay_client +from tests.helpers.module_mocks import module_mock from tests.spl.test_relay_client_blob_dispatch import ( FakeTcpReader, FakeTcpWriter, @@ -23,9 +25,12 @@ async def test_pairing_tunnel_tls_writes_peeked_bytes_first( ws = FakeWs([b"\x16\x03\x01pairing"]) writer = FakeTcpWriter() monkeypatch.setattr( - relay_client.asyncio, - "open_connection", - lambda *_a, **_k: _open_connection(writer), + relay_client, + "asyncio", + module_mock( + relay_client.asyncio, + open_connection=AsyncMock(return_value=(FakeTcpReader(), writer)), + ), ) await relay_client._bridge_pairing_tunnel( @@ -56,8 +61,17 @@ async def test_pairing_tunnel_sbp_branch_does_not_open_loopback( assert await reader.read_exactly(5) == b"SBP1\x01" called = True - monkeypatch.setattr(relay_client.asyncio, "open_connection", fail_open_connection) - monkeypatch.setattr(browser_pairing, "register_browser", fake_register) + open_connection = AsyncMock(side_effect=fail_open_connection) + monkeypatch.setattr( + relay_client, + "asyncio", + module_mock(relay_client.asyncio, open_connection=open_connection), + ) + monkeypatch.setattr( + browser_pairing, + "register_browser", + AsyncMock(side_effect=fake_register), + ) await relay_client._bridge_pairing_tunnel( "wss://relay.test", @@ -68,9 +82,4 @@ async def test_pairing_tunnel_sbp_branch_does_not_open_loopback( ) assert called is True - - -async def _open_connection( - writer: FakeTcpWriter, -) -> tuple[FakeTcpReader, FakeTcpWriter]: - return FakeTcpReader(), writer + open_connection.assert_not_awaited() diff --git a/tests/test_app_activities.py b/tests/test_app_activities.py index 7179e98cc..e27622c80 100644 --- a/tests/test_app_activities.py +++ b/tests/test_app_activities.py @@ -22,7 +22,7 @@ def fixture_journal(monkeypatch): @pytest.fixture -def activities_client(fixture_journal): +def activities_client(fixture_journal, monkeypatch): """Create a Flask test client with the activities blueprint.""" from flask import Flask @@ -30,7 +30,7 @@ def activities_client(fixture_journal): app = Flask(__name__) app.register_blueprint(activities_bp) - state.journal_root = "tests/fixtures/journal" + monkeypatch.setattr(state, "journal_root", "tests/fixtures/journal") return app.test_client() diff --git a/tests/test_app_sol.py b/tests/test_app_sol.py index 954ca18ef..7d3405294 100644 --- a/tests/test_app_sol.py +++ b/tests/test_app_sol.py @@ -250,7 +250,7 @@ class TestResolveOutputPath: @pytest.fixture -def agents_client(tmp_path): +def agents_client(tmp_path, monkeypatch): """Create a Flask test client with agents blueprint and tmp journal.""" from flask import Flask @@ -261,7 +261,7 @@ def agents_client(tmp_path): app.register_blueprint(sol_bp) # Point state at our tmp journal - state.journal_root = str(tmp_path) + monkeypatch.setattr(state, "journal_root", str(tmp_path)) # Create test files day_dir = tmp_path / "chronicle" / "20260214" diff --git a/tests/test_callosum.py b/tests/test_callosum.py index 27816eaf1..feb10096c 100644 --- a/tests/test_callosum.py +++ b/tests/test_callosum.py @@ -18,7 +18,9 @@ from unittest.mock import Mock, patch import pytest +from solstone.think import callosum as callosum_module from solstone.think.callosum import CallosumConnection, CallosumServer +from tests.helpers.module_mocks import module_mock @pytest.fixture @@ -275,7 +277,7 @@ def test_client_disconnected_idle_blocks_and_drops_emit(tmp_path, monkeypatch): assert 1 <= wait_calls <= 20 -def test_client_does_not_join_partial_json_across_reconnect(): +def test_client_does_not_join_partial_json_across_reconnect(monkeypatch): client = CallosumConnection() delivered = [] @@ -307,15 +309,22 @@ def test_client_does_not_join_partial_json_across_reconnect(): sock1.recv.side_effect = recv_first sock2.recv.side_effect = recv_second - times = iter([2.0, 4.0, 6.0, 8.0, 10.0]) + socket_constructor = Mock(side_effect=[sock1, sock2]) + clock = Mock(side_effect=[2.0, 4.0, 6.0, 8.0, 10.0]) + monkeypatch.setattr( + callosum_module, + "socket", + module_mock(callosum_module.socket, socket=socket_constructor), + ) + monkeypatch.setattr( + callosum_module, + "time", + module_mock(callosum_module.time, time=clock), + ) try: - with ( - patch("solstone.think.callosum.socket.socket", side_effect=[sock1, sock2]), - patch("solstone.think.callosum.time.time", side_effect=lambda: next(times)), - ): - client.start(callback=delivered.append) - if client.thread is not None: - client.thread.join(timeout=1.0) + client.start(callback=delivered.append) + if client.thread is not None: + client.thread.join(timeout=1.0) assert delivered == [] finally: for handle in (read1, write1, read2, write2): @@ -370,15 +379,19 @@ def test_server_broadcast_validates_event_field(): assert server.broadcast_queue.qsize() == 0 -def test_server_broadcast_adds_timestamp(): +def test_server_broadcast_adds_timestamp(monkeypatch): """Test that server adds timestamp if not present.""" server = CallosumServer() # Valid message without timestamp msg = {"tract": "test", "event": "hello"} - with patch("solstone.think.callosum.time.time", return_value=1234567.890): - result = server.broadcast(msg) + monkeypatch.setattr( + callosum_module, + "now_ms", + Mock(return_value=1234567890), + ) + result = server.broadcast(msg) assert result is True # Message should be queued with timestamp added @@ -503,7 +516,7 @@ def test_client_emit_returns_false_when_queue_full(): assert "Queue full" in mock_logger.warning.call_args[0][0] -def test_client_run_loop_warns_on_utf8_split(caplog): +def test_client_run_loop_warns_on_utf8_split(caplog, monkeypatch): client = CallosumConnection() mock_sock = Mock() read_sock, write_sock = socket.socketpair() @@ -515,12 +528,21 @@ def test_client_run_loop_warns_on_utf8_split(caplog): return "⚠️".encode("utf-8")[:1] mock_sock.recv.side_effect = fake_recv + monkeypatch.setattr( + callosum_module, + "socket", + module_mock( + callosum_module.socket, + socket=Mock(return_value=mock_sock), + ), + ) + monkeypatch.setattr( + callosum_module, + "time", + module_mock(callosum_module.time, time=Mock(return_value=2.0)), + ) try: - with ( - patch("solstone.think.callosum.socket.socket", return_value=mock_sock), - patch("solstone.think.callosum.time.time", return_value=2.0), - caplog.at_level(logging.WARNING, logger="solstone.think.callosum"), - ): + with caplog.at_level(logging.WARNING, logger="solstone.think.callosum"): client._run_loop() finally: read_sock.close() diff --git a/tests/test_chat_runtime.py b/tests/test_chat_runtime.py index 27cccaa1f..d625b1073 100644 --- a/tests/test_chat_runtime.py +++ b/tests/test_chat_runtime.py @@ -16,6 +16,7 @@ from solstone.convey.chat_stream import ( read_chat_events, reduce_chat_state, ) +from tests.helpers.module_mocks import module_mock def _reset_chat_state(chat_module) -> None: @@ -45,6 +46,8 @@ def _ms(year: int, month: int, day: int, hour: int, minute: int, second: int) -> def _install_fake_timers(monkeypatch): + import solstone.convey.chat as chat + timers: list[FakeTimer] = [] class FakeTimer: @@ -69,7 +72,11 @@ def _install_fake_timers(monkeypatch): return self.function(*self.args, **self.kwargs) - monkeypatch.setattr("solstone.convey.chat.threading.Timer", FakeTimer) + monkeypatch.setattr( + chat, + "threading", + module_mock(chat.threading, Timer=FakeTimer), + ) return timers diff --git a/tests/test_cortex.py b/tests/test_cortex.py index 20d3d5afb..f1824d495 100644 --- a/tests/test_cortex.py +++ b/tests/test_cortex.py @@ -16,6 +16,7 @@ from unittest.mock import MagicMock, patch import pytest from solstone.think.models import GPT_5 +from tests.helpers.module_mocks import module_mock class MockPipe: @@ -79,6 +80,15 @@ def mock_journal(tmp_path, monkeypatch): return journal_path +@pytest.fixture(autouse=True) +def _isolate_cortex_stdlib_modules(monkeypatch): + from solstone.think import cortex + + monkeypatch.setattr(cortex, "threading", module_mock(cortex.threading)) + monkeypatch.setattr(cortex, "subprocess", module_mock(cortex.subprocess)) + monkeypatch.setattr(cortex, "time", module_mock(cortex.time)) + + @pytest.fixture def cortex_service(mock_journal): """Create a CortexService instance for testing.""" diff --git a/tests/test_journal_index.py b/tests/test_journal_index.py index a1dd9bc9e..154f9bec7 100644 --- a/tests/test_journal_index.py +++ b/tests/test_journal_index.py @@ -5,9 +5,9 @@ import hashlib import json -import os from datetime import datetime, timedelta from pathlib import Path +from unittest.mock import Mock import pytest @@ -1260,12 +1260,22 @@ def test_voice_tool_flips_relax_and_rerank(monkeypatch): def test_search_journal_returns_counts(monkeypatch): """Test search tool returns counts aggregation.""" - from solstone.think.tools.search import search_journal - - # Use fixtures journal - monkeypatch.setenv("SOLSTONE_JOURNAL", "tests/fixtures/journal") + from solstone.think.tools import search as search_tools + + monkeypatch.setattr(search_tools, "search_journal_impl", Mock(return_value=(0, []))) + monkeypatch.setattr( + search_tools, + "search_counts_impl", + Mock( + return_value={ + "facets": [("work", 2)], + "agents": [("flow", 2)], + "days": [("20240101", 2)], + } + ), + ) - result = search_journal("test") + result = search_tools.search_journal("test") # Should have counts structure assert "counts" in result @@ -1282,11 +1292,16 @@ def test_search_journal_returns_counts(monkeypatch): def test_search_journal_returns_query_echo(monkeypatch): """Test search tool returns query echo.""" - from solstone.think.tools.search import search_journal + from solstone.think.tools import search as search_tools - monkeypatch.setenv("SOLSTONE_JOURNAL", "tests/fixtures/journal") + monkeypatch.setattr(search_tools, "search_journal_impl", Mock(return_value=(0, []))) + monkeypatch.setattr( + search_tools, + "search_counts_impl", + Mock(return_value={"facets": [], "agents": [], "days": []}), + ) - result = search_journal("test query", facet="work", agent="flow") + result = search_tools.search_journal("test query", facet="work", agent="flow") assert "query" in result assert result["query"]["text"] == "test query" @@ -1296,17 +1311,35 @@ def test_search_journal_returns_query_echo(monkeypatch): def test_search_journal_results_include_path(monkeypatch): """Test search tool results include path and idx.""" - from solstone.think.tools.search import search_journal - - monkeypatch.setenv("SOLSTONE_JOURNAL", "tests/fixtures/journal") + from solstone.think.tools import search as search_tools + + monkeypatch.setattr( + search_tools, + "search_journal_impl", + Mock( + return_value=( + 1, + [ + { + "text": "result", + "metadata": {"path": "facets/work/result.md", "idx": 3}, + } + ], + ) + ), + ) + monkeypatch.setattr( + search_tools, + "search_counts_impl", + Mock(return_value={"facets": [], "agents": [], "days": []}), + ) - result = search_journal("") + result = search_tools.search_journal("") - if result.get("results"): - item = result["results"][0] - assert "path" in item - assert "idx" in item - assert item["id"] == f"{item['path']}:{item['idx']}" + item = result["results"][0] + assert item["path"] == "facets/work/result.md" + assert item["idx"] == 3 + assert item["id"] == "facets/work/result.md:3" def test_search_journal_truncates_large_results(monkeypatch): @@ -1315,8 +1348,6 @@ def test_search_journal_truncates_large_results(monkeypatch): from solstone.think.tools.search import _MAX_RESULT_TEXT, search_journal - monkeypatch.setenv("SOLSTONE_JOURNAL", "tests/fixtures/journal") - big_text = "x" * 10_000 fake_results = [ { @@ -1664,94 +1695,22 @@ def test_extract_stream_missing_marker(tmp_path): assert result is None -def test_search_journal_stream_filter(monkeypatch): - """search_journal filters by stream name.""" - from solstone.think.indexer.journal import scan_journal, search_journal - - monkeypatch.setenv("SOLSTONE_JOURNAL", "tests/fixtures/journal") - scan_journal(os.environ["SOLSTONE_JOURNAL"], full=True) - - # Search with matching stream - total, results = search_journal("", stream="default") - assert total > 0 - for r in results: - assert r["metadata"]["stream"] == "default" - - # Search with non-existent stream - total, results = search_journal("", stream="nonexistent") - assert total == 0 - - -def test_search_journal_results_include_stream(monkeypatch): - """search_journal results include stream in metadata.""" - from solstone.think.indexer.journal import scan_journal, search_journal - - monkeypatch.setenv("SOLSTONE_JOURNAL", "tests/fixtures/journal") - scan_journal(os.environ["SOLSTONE_JOURNAL"], full=True) - - # Filter to segment content which has stream markers - total, results = search_journal("", stream="default") - assert total > 0 - - for r in results: - assert "stream" in r["metadata"] - assert r["metadata"]["stream"] == "default" - - -def test_browser_fixture_chunks_include_stream_and_agent(monkeypatch): - """Browser formatter chunks keep segment stream and formatter agent.""" - from solstone.think.indexer.journal import get_journal_index, index_file - - journal = Path("tests/fixtures/journal").resolve() - rel = "20260703/suze.browser/000141_317/browser_mail-google-com.jsonl" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) - - index_file(str(journal), rel) - - conn, _ = get_journal_index(str(journal)) - rows = conn.execute( - "SELECT agent, stream FROM chunks WHERE path=? ORDER BY idx", - (rel,), - ).fetchall() - conn.close() - - assert len(rows) == 6 - assert {agent for agent, _stream in rows} == {"browser"} - assert {stream for _agent, stream in rows} == {"suze.browser"} - - -def test_search_counts_stream_filter(monkeypatch): - """search_counts filters by stream and includes streams aggregation.""" - from solstone.think.indexer.journal import scan_journal, search_counts - - monkeypatch.setenv("SOLSTONE_JOURNAL", "tests/fixtures/journal") - scan_journal(os.environ["SOLSTONE_JOURNAL"], full=True) - - # Unfiltered counts should include streams - counts = search_counts("") - assert "streams" in counts - - # Filter by stream - counts = search_counts("", stream="default") - assert counts["total"] > 0 - - # Non-existent stream returns zero - counts = search_counts("", stream="nonexistent") - assert counts["total"] == 0 - - def test_search_tool_stream_filter(monkeypatch): """Agent search tool accepts and passes stream filter.""" - from solstone.think.indexer.journal import scan_journal - from solstone.think.tools.search import search_journal + from solstone.think.tools import search as search_tools + + search = Mock(return_value=(1, [{"text": "result", "metadata": {}}])) + counts = Mock(return_value={"facets": [], "agents": [], "days": [("20240101", 1)]}) + monkeypatch.setattr(search_tools, "search_journal_impl", search) + monkeypatch.setattr(search_tools, "search_counts_impl", counts) - monkeypatch.setenv("SOLSTONE_JOURNAL", "tests/fixtures/journal") - scan_journal(os.environ["SOLSTONE_JOURNAL"], full=True) + result = search_tools.search_journal("", stream="default") - result = search_journal("", stream="default") assert "results" in result - assert result["total"] > 0 + assert result["total"] == 1 assert result["query"]["filters"]["stream"] == "default" + search.assert_called_once_with("", 10, 0, rerank=True, stream="default") + counts.assert_called_once_with("", stream="default") def test_prune_chunks_by_stream(monkeypatch, tmp_path): @@ -1838,100 +1797,6 @@ def test_prune_chunks_by_stream(monkeypatch, tmp_path): conn.close() -def test_entity_search_chunks_indexed(monkeypatch): - """Entity search chunks are generated from identity + relationship data.""" - from solstone.think.indexer.journal import scan_journal - - monkeypatch.setenv("SOLSTONE_JOURNAL", "tests/fixtures/journal") - scan_journal("tests/fixtures/journal", full=True) - conn, _ = get_journal_index("tests/fixtures/journal") - count = conn.execute("SELECT count(*) FROM chunks WHERE agent='entity'").fetchone()[ - 0 - ] - conn.close() - # One chunk per entity-facet relationship in the current fixture journal. - assert count == 40 - - -def test_entity_search_chunks_use_entity_search_path(monkeypatch): - """Entity search chunks use entity_search: path prefix.""" - from solstone.think.indexer.journal import scan_journal - - monkeypatch.setenv("SOLSTONE_JOURNAL", "tests/fixtures/journal") - scan_journal("tests/fixtures/journal", full=True) - conn, _ = get_journal_index("tests/fixtures/journal") - rows = conn.execute( - "SELECT DISTINCT path FROM chunks WHERE agent='entity'" - ).fetchall() - conn.close() - assert all(r[0].startswith("entity_search:") for r in rows) - - -def test_entity_search_by_name(monkeypatch): - """Entity name is searchable via FTS.""" - from solstone.think.indexer.journal import scan_journal - - monkeypatch.setenv("SOLSTONE_JOURNAL", "tests/fixtures/journal") - scan_journal("tests/fixtures/journal", full=True) - total, results = search_journal("Alice Johnson", agent="entity") - assert total >= 1 - assert any(r["metadata"]["agent"] == "entity" for r in results) - - -def test_entity_search_by_type(monkeypatch): - """Entity type is searchable via FTS.""" - from solstone.think.indexer.journal import scan_journal - - monkeypatch.setenv("SOLSTONE_JOURNAL", "tests/fixtures/journal") - scan_journal("tests/fixtures/journal", full=True) - total, results = search_journal("Person", agent="entity") - assert total >= 1 - - -def test_entity_search_includes_description(monkeypatch): - """Entity search chunks include relationship descriptions.""" - from solstone.think.indexer.journal import scan_journal - - monkeypatch.setenv("SOLSTONE_JOURNAL", "tests/fixtures/journal") - scan_journal("tests/fixtures/journal", full=True) - # Alice has description "Close friend from college" in personal facet - total, results = search_journal("college", agent="entity") - assert total >= 1 - matched = [r for r in results if "college" in r["text"].lower()] - assert len(matched) >= 1 - - -def test_entity_search_includes_facet(monkeypatch): - """Entity search chunks have facet metadata from relationships.""" - from solstone.think.indexer.journal import scan_journal - - monkeypatch.setenv("SOLSTONE_JOURNAL", "tests/fixtures/journal") - scan_journal("tests/fixtures/journal", full=True) - total, results = search_journal("Alice Johnson", agent="entity", facet="personal") - assert total >= 1 - assert all(r["metadata"]["facet"] == "personal" for r in results) - - -def test_entity_search_idempotent(monkeypatch): - """Two full scans produce identical entity chunk count (no duplicates).""" - from solstone.think.indexer.journal import scan_journal - - monkeypatch.setenv("SOLSTONE_JOURNAL", "tests/fixtures/journal") - scan_journal("tests/fixtures/journal", full=True) - conn, _ = get_journal_index("tests/fixtures/journal") - count1 = conn.execute( - "SELECT count(*) FROM chunks WHERE agent='entity'" - ).fetchone()[0] - conn.close() - scan_journal("tests/fixtures/journal", full=True) - conn, _ = get_journal_index("tests/fixtures/journal") - count2 = conn.execute( - "SELECT count(*) FROM chunks WHERE agent='entity'" - ).fetchone()[0] - conn.close() - assert count1 == count2 == 40 - - class TestSegmentChunks: """Tests for segment-level concatenated FTS5 chunks.""" diff --git a/tests/test_no_legacy_chat_imports.py b/tests/test_no_legacy_chat_imports.py index c0e872701..660909df3 100644 --- a/tests/test_no_legacy_chat_imports.py +++ b/tests/test_no_legacy_chat_imports.py @@ -1,144 +1,73 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc +"""Unit tests for the legacy-chat repository scanner.""" + from __future__ import annotations -import ast -import subprocess +import importlib.util from pathlib import Path ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "check_no_legacy_chat.py" + +spec = importlib.util.spec_from_file_location("check_no_legacy_chat", SCRIPT) +assert spec and spec.loader +checker = importlib.util.module_from_spec(spec) +spec.loader.exec_module(checker) + + +def test_python_scan_rejects_legacy_imports_names_and_literal() -> None: + source = "\n".join( + [ + "import think.conversation", + "from talent.conversation_memory import load", + "record_exchange()", + "value = 'unified'", + ] + ) + + findings = checker.scan_python_source(source, "solstone/example.py") + + assert set(findings) == { + "solstone/example.py: import think.conversation", + "solstone/example.py: from talent.conversation_memory import ...", + "solstone/example.py: name record_exchange", + "solstone/example.py: legacy literal", + } + + +def test_python_scan_allows_migration_literal() -> None: + path = "apps/sol/maint/006_rename_unified_triage_providers.py" + assert checker.scan_python_source("value = 'unified'\n", path) == [] -def _git_ls(*patterns: str) -> list[str]: - result = subprocess.run( - ["git", "ls-files", *patterns], - cwd=ROOT, - check=True, - capture_output=True, - text=True, + +def test_python_scan_ignores_clean_source_and_unparseable_templates() -> None: + assert ( + checker.scan_python_source("def clean():\n return 'chat'\n", "clean.py") + == [] + ) + assert checker.scan_python_source("{{ template }}", "template.py") == [] + + +def test_text_scan_rejects_legacy_dom_literals() -> None: + findings = checker.scan_text_source( + "
\nopenPanel();", + "solstone/apps/example/workspace.html", ) - return [line for line in result.stdout.splitlines() if line] - - -ALLOWED_UNIFIED_PATHS = { - ROOT / "apps/sol/maint/006_rename_unified_triage_providers.py", - ROOT / "tests/test_maint_006_rename_unified_triage_providers.py", -} -FORBIDDEN_CHAT_LITERALS = { - "conversationBackdrop", - "conversationMessages", - "chatBarResponsePanel", - "chatBarThinking", - "chatBarResponse", - "chatBarDismiss", - "conversation-backdrop", - "conversation-messages", - "conversation-separator", - "solstone:conversationState", - "solstone:chatBarState", - "panelFocusTrapHandler", - "openPanel", - "closePanel", - "_closeConversationPanel", -} - - -def _parts(*pieces: str) -> str: - return "".join(pieces) - - -BANNED_NAMES = { - _parts("_", "display_", "mode"), - _parts("record_", "exchange"), - _parts("build_", "memory_", "context"), - _parts("INJECTION_", "MARKER"), - _parts("inject_", "memory"), - _parts("get_", "recent_", "exchanges"), - _parts("get_", "today_", "exchanges"), - _parts("TRIAGE_", "AGENT_", "NAMES"), - _parts("record_", "triage_", "exchange"), - _parts("compute_", "display_", "mode"), -} -LEGACY_CHAT_MODULE = _parts("think", ".", "conversation") -LEGACY_MEMORY_MODULE = _parts("talent", ".", "conversation_", "memory") -LEGACY_NAME = _parts("uni", "fied") - - -def _python_files() -> list[Path]: - # `git ls-files` excludes anything gitignored (`/journal/*` on dev boxes can - # be 100+ GB of capture data; `ROOT.rglob` walks all of it on every call). - return [path for line in _git_ls("*.py") if (path := ROOT / line).exists()] - - -def _text_scan_files() -> list[Path]: - blocked_parts = ("tests/fixtures",) - return [ - path - for line in _git_ls("*.html", "*.js") - if not any(part in line for part in blocked_parts) - if (path := ROOT / line).exists() + + assert findings == [ + "solstone/apps/example/workspace.html: conversationMessages", + "solstone/apps/example/workspace.html: openPanel", ] -def _parse(path: Path) -> ast.Module | None: - try: - return ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) - except SyntaxError: - return None - - -def test_no_legacy_chat_imports_or_usages(): - violations: list[str] = [] - - for path in _python_files(): - tree = _parse(path) - if tree is None: - continue - for node in ast.walk(tree): - if isinstance(node, ast.Import): - for alias in node.names: - if alias.name in {LEGACY_CHAT_MODULE, LEGACY_MEMORY_MODULE}: - violations.append(f"{path}: import {alias.name}") - elif isinstance(node, ast.ImportFrom): - if node.module in {LEGACY_CHAT_MODULE, LEGACY_MEMORY_MODULE}: - violations.append(f"{path}: from {node.module} import ...") - elif isinstance(node, ast.Name) and node.id in BANNED_NAMES: - violations.append(f"{path}: name {node.id}") - elif isinstance(node, ast.Attribute) and node.attr in BANNED_NAMES: - violations.append(f"{path}: attribute {node.attr}") - - assert violations == [] - - -def test_no_live_unified_literals_outside_migration_paths(): - violations: list[str] = [] - - for path in _python_files(): - if path in ALLOWED_UNIFIED_PATHS: - continue - if path == Path(__file__).resolve(): - continue - tree = _parse(path) - if tree is None: - continue - for node in ast.walk(tree): - if isinstance(node, ast.Constant) and node.value == LEGACY_NAME: - violations.append(str(path)) - - assert violations == [] - - -def test_no_legacy_chat_dom_literals_in_templates_or_js(): - violations: list[str] = [] - this_file = Path(__file__).resolve() - - for path in _text_scan_files(): - if path.resolve() == this_file: - continue - content = path.read_text(encoding="utf-8") - for literal in FORBIDDEN_CHAT_LITERALS: - if literal in content: - violations.append(f"{path}: {literal}") - - assert violations == [] +def test_text_scan_ignores_fixture_content() -> None: + assert ( + checker.scan_text_source( + "
", + "tests/fixtures/example.html", + ) + == [] + ) diff --git a/tests/test_preflight.py b/tests/test_preflight.py index 38816c69b..4dd8112f6 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -8,9 +8,12 @@ import subprocess import sys from pathlib import Path from types import SimpleNamespace +from unittest.mock import Mock import pytest +from tests.helpers.module_mocks import module_mock + ROOT = Path(__file__).resolve().parent.parent @@ -22,9 +25,19 @@ def preflight(): @pytest.fixture -def probe(): +def probe(monkeypatch): from solstone.think import probe as probe_module + monkeypatch.setattr( + probe_module, + "subprocess", + module_mock(probe_module.subprocess), + ) + monkeypatch.setattr( + probe_module, + "shutil", + module_mock(probe_module.shutil), + ) yield probe_module @@ -75,12 +88,12 @@ def patch_green_environment(probe, monkeypatch, home_root, repo: Path) -> None: monkeypatch.setattr( probe, "run_probe", - fake_probe_dispatcher(probe, repo), + Mock(side_effect=fake_probe_dispatcher(probe, repo)), ) monkeypatch.setattr( probe.shutil, "disk_usage", - lambda _root: SimpleNamespace(total=100, used=80, free=20 * 1024**3), + Mock(return_value=SimpleNamespace(total=100, used=80, free=20 * 1024**3)), ) config_dir = home_root / ".config" config_dir.mkdir() @@ -186,7 +199,7 @@ def test_solstone_core_rust_toolchain_failure_names_rust_toolchain( "current_solstone_core_platform", lambda: ("linux", "x86_64"), ) - monkeypatch.setattr(probe.subprocess, "run", raise_missing) + monkeypatch.setattr(probe.subprocess, "run", Mock(side_effect=raise_missing)) result = preflight.solstone_core_rust_toolchain_check(args(preflight)) @@ -237,7 +250,7 @@ def test_local_bin_sol_reachable_ok(preflight, probe, monkeypatch, home_root): local = home_root / ".local" / "bin" / "sol" local.parent.mkdir(parents=True) local.write_text("#!/bin/sh\n", encoding="utf-8") - monkeypatch.setattr(probe.shutil, "which", lambda name: str(local)) + monkeypatch.setattr(probe.shutil, "which", Mock(return_value=str(local))) result = preflight.local_bin_sol_reachable_check(args(preflight)) @@ -249,7 +262,7 @@ def test_uv_missing_fails(preflight, probe, monkeypatch): raise FileNotFoundError monkeypatch.setattr(probe, "_is_source_checkout", lambda: True) - monkeypatch.setattr(probe.subprocess, "run", raise_missing) + monkeypatch.setattr(probe.subprocess, "run", Mock(side_effect=raise_missing)) result = preflight.uv_installed_check(args(preflight)) @@ -265,11 +278,11 @@ def test_main_returns_one_when_uv_missing( repo = make_repo(tmp_path) monkeypatch.setattr(probe, "ROOT", repo) - monkeypatch.setattr(probe.subprocess, "run", raise_missing) + monkeypatch.setattr(probe.subprocess, "run", Mock(side_effect=raise_missing)) monkeypatch.setattr( probe.shutil, "disk_usage", - lambda _root: SimpleNamespace(total=100, used=80, free=20 * 1024**3), + Mock(return_value=SimpleNamespace(total=100, used=80, free=20 * 1024**3)), ) (home_root / ".config").mkdir() diff --git a/tests/test_providers_check.py b/tests/test_providers_check.py index 66db3940e..1f1823ab0 100644 --- a/tests/test_providers_check.py +++ b/tests/test_providers_check.py @@ -7,10 +7,12 @@ import fcntl import json from datetime import datetime from types import SimpleNamespace -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest +from tests.helpers.module_mocks import module_mock + def _patch_health_journal(monkeypatch, providers_cli, tmp_path): monkeypatch.setattr(providers_cli, "get_journal", lambda: str(tmp_path)) @@ -375,18 +377,27 @@ def test_check_cogitate_local_endpoint_unreachable_uses_endpoint_reason(monkeypa assert "journal install-provider local" not in msg -def test_cortex_start_emits_providers_check(tmp_path): - from solstone.think.cortex import CortexService +def test_cortex_start_emits_providers_check(tmp_path, monkeypatch): + from solstone.think import cortex as cortex_module + + thread_constructor = MagicMock(return_value=MagicMock()) + monkeypatch.setattr( + cortex_module, + "threading", + module_mock(cortex_module.threading, Thread=thread_constructor), + ) + monkeypatch.setattr( + cortex_module, + "time", + module_mock(cortex_module.time, sleep=MagicMock()), + ) - cortex = CortexService(journal_path=str(tmp_path)) + cortex = cortex_module.CortexService(journal_path=str(tmp_path)) cortex.callosum = MagicMock() cortex.callosum.start.return_value = None cortex.shutdown_requested.set() - with patch("solstone.think.cortex.threading.Thread") as mock_thread: - mock_thread.return_value = MagicMock() - with patch("solstone.think.cortex.time.sleep", return_value=None): - cortex.start() + cortex.start() cortex.callosum.emit.assert_any_call( "supervisor", "request", cmd=["journal", "providers", "check"] diff --git a/tests/test_push_devices.py b/tests/test_push_devices.py index bb3d74200..3f0604f34 100644 --- a/tests/test_push_devices.py +++ b/tests/test_push_devices.py @@ -5,8 +5,10 @@ from __future__ import annotations import json from pathlib import Path +from unittest.mock import Mock from solstone.think.push import devices +from tests.helpers.module_mocks import module_mock def _devices_path(tmp_path: Path) -> Path: @@ -38,7 +40,11 @@ def test_load_devices_returns_empty_for_missing_store(monkeypatch, tmp_path): def test_register_one_device_stores_fingerprint_row(monkeypatch, tmp_path): monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - monkeypatch.setattr(devices.time, "time", lambda: 1000) + monkeypatch.setattr( + devices, + "time", + module_mock(devices.time, time=Mock(return_value=1000)), + ) count = _register("fp-1", "a" * 64) @@ -58,8 +64,11 @@ def test_register_one_device_stores_fingerprint_row(monkeypatch, tmp_path): def test_register_same_fingerprint_replaces_row(monkeypatch, tmp_path): monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - times = iter([1000, 2000]) - monkeypatch.setattr(devices.time, "time", lambda: next(times)) + monkeypatch.setattr( + devices, + "time", + module_mock(devices.time, time=Mock(side_effect=[1000, 2000])), + ) first = _register("fp-1", "a" * 64) second = _register("fp-1", "b" * 64, environment="production") diff --git a/tests/test_repack_wheel_record.py b/tests/test_repack_wheel_record.py index d8aee74e2..f2b1137ea 100644 --- a/tests/test_repack_wheel_record.py +++ b/tests/test_repack_wheel_record.py @@ -72,7 +72,10 @@ def test_repack_preserves_original_executable_mode_and_rewrites_record( wheel.extractall(unpacked) binary = unpacked / "solstone_core-1.2.3.data" / "scripts" / "solstone-core" - assert (binary.stat().st_mode & 0o777) == 0o644 + # zipfile extraction applies the caller's umask, so the write bits may be + # 0644 or 0664. The contract only needs to prove that extraction dropped + # the archived executable bits before repack restores the original attrs. + assert (binary.stat().st_mode & 0o111) == 0 signed_content = b"#!/bin/sh\necho signed\n" binary.write_bytes(signed_content) diff --git a/tests/test_runner.py b/tests/test_runner.py index fc3b75bd9..072cd1661 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -22,6 +22,7 @@ from solstone.think.runner import ( run_task, snapshot_descendants, ) +from tests.helpers.module_mocks import module_mock @pytest.fixture @@ -171,15 +172,17 @@ def test_terminate_raises_for_surviving_descendant(monkeypatch): def test_descendant_poll_uses_bounded_deadline(monkeypatch): descendant = DescendantRef(222, 333) sleeps = [] - times = iter([10.0, 10.05]) + monotonic = Mock(side_effect=[10.0, 10.05]) + sleep = Mock(side_effect=lambda amount: sleeps.append(amount)) monkeypatch.setattr( "solstone.think.runner._alive_descendants", lambda descendants: list(descendants), ) - monkeypatch.setattr("solstone.think.runner.time.monotonic", lambda: next(times)) monkeypatch.setattr( - "solstone.think.runner.time.sleep", lambda amount: sleeps.append(amount) + runner, + "time", + module_mock(runner.time, monotonic=monotonic, sleep=sleep), ) survivors = runner._poll_descendants_until_gone([descendant], 10.05) diff --git a/tests/test_supervisor.py b/tests/test_supervisor.py index a77e1d60d..f072f80fa 100644 --- a/tests/test_supervisor.py +++ b/tests/test_supervisor.py @@ -30,6 +30,10 @@ from solstone.think.processing import ( TimeWindowSettings, ) from solstone.think.providers.artifact_proof import ReadinessOutcome +from tests.helpers.module_mocks import ( + capturing_thread_constructor, + module_mock, +) def _mlx_readiness( @@ -660,11 +664,17 @@ def _fresh_task_queue(mod, *, on_queue_change=None): def _capture_thread_starts(monkeypatch, mod): spawned = [] - - def fake_thread_start(self): - spawned.append(self._args) - - monkeypatch.setattr(mod.threading.Thread, "start", fake_thread_start) + monkeypatch.setattr( + mod, + "threading", + module_mock( + mod.threading, + Thread=capturing_thread_constructor( + spawned, + capture=lambda thread: thread._args, + ), + ), + ) return spawned @@ -1277,12 +1287,7 @@ def test_task_queue_same_command_queued(monkeypatch): # Create fresh task queue (no callback to avoid callosum events) mod._task_queue = mod.TaskQueue(on_queue_change=None) - spawned = [] - - def fake_thread_start(self): - spawned.append(self._target.__name__) - - monkeypatch.setattr(mod.threading.Thread, "start", fake_thread_start) + spawned = _capture_thread_starts(monkeypatch, mod) # First request - should run immediately msg1 = { @@ -1322,12 +1327,7 @@ def test_task_queue_dedupe_exact_match(monkeypatch): # Create fresh task queue (no callback to avoid callosum events) mod._task_queue = mod.TaskQueue(on_queue_change=None) - spawned = [] - - def fake_thread_start(self): - spawned.append(self._target.__name__) - - monkeypatch.setattr(mod.threading.Thread, "start", fake_thread_start) + _capture_thread_starts(monkeypatch, mod) # First request - runs msg1 = { @@ -1365,12 +1365,7 @@ def test_task_queue_different_commands_independent(monkeypatch): # Create fresh task queue (no callback to avoid callosum events) mod._task_queue = mod.TaskQueue(on_queue_change=None) - spawned = [] - - def fake_thread_start(self): - spawned.append(self._target.__name__) - - monkeypatch.setattr(mod.threading.Thread, "start", fake_thread_start) + spawned = _capture_thread_starts(monkeypatch, mod) # Indexer request - runs msg1 = { @@ -1406,12 +1401,7 @@ def test_process_queue_spawns_next(monkeypatch): ] } - spawned = [] - - def fake_thread_start(self): - spawned.append(self._args) # Capture args (refs, cmd, cmd_name, callosum) - - monkeypatch.setattr(mod.threading.Thread, "start", fake_thread_start) + spawned = _capture_thread_starts(monkeypatch, mod) # Process queue mod._task_queue._process_next("indexer") @@ -1435,12 +1425,7 @@ def test_process_queue_clears_running_when_empty(monkeypatch): mod._task_queue._running = {"indexer": {"ref": "ref123", "thread": None}} mod._task_queue._queues = {"indexer": []} - spawned = [] - - def fake_thread_start(self): - spawned.append(True) - - monkeypatch.setattr(mod.threading.Thread, "start", fake_thread_start) + spawned = _capture_thread_starts(monkeypatch, mod) # Process queue mod._task_queue._process_next("indexer") @@ -1459,12 +1444,7 @@ def test_task_request_uses_caller_provided_ref(monkeypatch): # Create fresh task queue (no callback to avoid callosum events) mod._task_queue = mod.TaskQueue(on_queue_change=None) - spawned = [] - - def fake_thread_start(self): - spawned.append(self._args) # Capture args (refs, cmd, cmd_name, callosum) - - monkeypatch.setattr(mod.threading.Thread, "start", fake_thread_start) + spawned = _capture_thread_starts(monkeypatch, mod) # Request with caller-provided ref msg = { @@ -1487,12 +1467,7 @@ def test_task_queue_preserves_caller_ref(monkeypatch): # Create fresh task queue (no callback to avoid callosum events) mod._task_queue = mod.TaskQueue(on_queue_change=None) - spawned = [] - - def fake_thread_start(self): - spawned.append(self._args) - - monkeypatch.setattr(mod.threading.Thread, "start", fake_thread_start) + _capture_thread_starts(monkeypatch, mod) # First request runs immediately msg1 = { @@ -1529,12 +1504,7 @@ def test_task_queue_coalesces_refs_on_dedupe(monkeypatch): # Create fresh task queue (no callback to avoid callosum events) mod._task_queue = mod.TaskQueue(on_queue_change=None) - spawned = [] - - def fake_thread_start(self): - spawned.append(self._args) - - monkeypatch.setattr(mod.threading.Thread, "start", fake_thread_start) + _capture_thread_starts(monkeypatch, mod) # First request runs immediately msg1 = { @@ -1588,12 +1558,7 @@ def test_process_queue_spawns_with_multiple_refs(monkeypatch): ] } - spawned = [] - - def fake_thread_start(self): - spawned.append(self._args) - - monkeypatch.setattr(mod.threading.Thread, "start", fake_thread_start) + spawned = _capture_thread_starts(monkeypatch, mod) # Process queue mod._task_queue._process_next("indexer") @@ -1618,12 +1583,7 @@ def test_stale_queue_detected_on_submit(monkeypatch): dead_thread.join() assert not dead_thread.is_alive() - spawned = [] - - def fake_thread_start(self): - spawned.append(self._target.__name__) - - monkeypatch.setattr(mod.threading.Thread, "start", fake_thread_start) + spawned = _capture_thread_starts(monkeypatch, mod) mod._task_queue._running = {"indexer": {"ref": "stale-ref", "thread": dead_thread}} mod._task_queue._queues = { diff --git a/tests/test_supervisor_parakeet.py b/tests/test_supervisor_parakeet.py index db04d48d2..efa929f9d 100644 --- a/tests/test_supervisor_parakeet.py +++ b/tests/test_supervisor_parakeet.py @@ -23,10 +23,20 @@ from solstone.think.providers.parakeet_placement import ( PARAKEET_ATT_CONTEXT_ENV, PARAKEET_ATT_CONTEXT_FRAMES, ) +from tests.helpers.module_mocks import module_mock _LaunchRecord = dict[str, Any] +@pytest.fixture(autouse=True) +def _isolate_supervisor_threading(monkeypatch): + monkeypatch.setattr( + supervisor, + "threading", + module_mock(supervisor.threading), + ) + + class _InlineExecutor: def submit(self, fn, *args, **kwargs): future: concurrent.futures.Future = concurrent.futures.Future() diff --git a/tests/test_supervisor_recovery.py b/tests/test_supervisor_recovery.py index b554fb08c..98f26b2a3 100644 --- a/tests/test_supervisor_recovery.py +++ b/tests/test_supervisor_recovery.py @@ -75,7 +75,7 @@ def _reset_runtime_recovery(monkeypatch) -> dict[str, mod.ProviderRuntimeState]: def test_rising_edge_fires_once_per_local_generation(monkeypatch): states = _reset_runtime_recovery(monkeypatch) callosum = Mock() - mod._supervisor_callosum = callosum + monkeypatch.setattr(mod, "_supervisor_callosum", callosum) states["local"].generation = 7 mod._mark_provider_recovery_down("local") @@ -90,7 +90,7 @@ def test_rising_edge_fires_once_per_local_generation(monkeypatch): def test_startup_ready_does_not_nudge(monkeypatch): states = _reset_runtime_recovery(monkeypatch) callosum = Mock() - mod._supervisor_callosum = callosum + monkeypatch.setattr(mod, "_supervisor_callosum", callosum) states["local"].generation = 1 mod._finish_provider_startup_condition(states["local"], "ready") @@ -102,7 +102,7 @@ def test_startup_ready_does_not_nudge(monkeypatch): def test_parakeet_ready_never_nudges_local_recovery(monkeypatch): states = _reset_runtime_recovery(monkeypatch) callosum = Mock() - mod._supervisor_callosum = callosum + monkeypatch.setattr(mod, "_supervisor_callosum", callosum) states["parakeet"].generation = 4 mod._mark_provider_recovery_down("parakeet") @@ -116,7 +116,7 @@ def test_parakeet_ready_never_nudges_local_recovery(monkeypatch): def test_flap_two_local_generations_nudge_twice(monkeypatch): states = _reset_runtime_recovery(monkeypatch) callosum = Mock() - mod._supervisor_callosum = callosum + monkeypatch.setattr(mod, "_supervisor_callosum", callosum) states["local"].generation = 1 mod._mark_provider_recovery_down("local") @@ -136,7 +136,7 @@ def test_flap_two_local_generations_nudge_twice(monkeypatch): def test_undeliverable_callosum_none(monkeypatch, caplog): states = _reset_runtime_recovery(monkeypatch) - mod._supervisor_callosum = None + monkeypatch.setattr(mod, "_supervisor_callosum", None) states["local"].generation = 3 mod._mark_provider_recovery_down("local") caplog.set_level(logging.WARNING) @@ -151,7 +151,7 @@ def test_undeliverable_emit_raises(monkeypatch, caplog): states = _reset_runtime_recovery(monkeypatch) callosum = Mock() callosum.emit.side_effect = RuntimeError("boom") - mod._supervisor_callosum = callosum + monkeypatch.setattr(mod, "_supervisor_callosum", callosum) states["local"].generation = 5 mod._mark_provider_recovery_down("local") caplog.set_level(logging.WARNING) @@ -164,9 +164,9 @@ def test_undeliverable_emit_raises(monkeypatch, caplog): assert "Cannot nudge catchup drain: boom" in caplog.text -def test_nudge_no_targeting(): +def test_nudge_no_targeting(monkeypatch): callosum = Mock() - mod._supervisor_callosum = callosum + monkeypatch.setattr(mod, "_supervisor_callosum", callosum) mod._nudge_catchup_drain() @@ -176,7 +176,7 @@ def test_nudge_no_targeting(): def test_remote_mode_inert(monkeypatch, mock_callosum): states = _reset_runtime_recovery(monkeypatch) callosum = Mock() - mod._supervisor_callosum = callosum + monkeypatch.setattr(mod, "_supervisor_callosum", callosum) monkeypatch.setattr(mod, "_is_remote_mode", True) states["local"].generation = 9 mod._mark_provider_recovery_down("local") diff --git a/tests/test_supervisor_startup.py b/tests/test_supervisor_startup.py index 716a8af3a..8c059c9e5 100644 --- a/tests/test_supervisor_startup.py +++ b/tests/test_supervisor_startup.py @@ -7,17 +7,33 @@ import json from types import SimpleNamespace from unittest import mock +from tests.helpers.module_mocks import ( + capturing_thread_constructor, + module_mock, +) -def test_task_queue_defers_submit_when_not_ready(monkeypatch): - mod = importlib.import_module("solstone.think.supervisor") - queue = mod.TaskQueue(on_queue_change=None, ready=False) +def _capture_thread_starts(monkeypatch, mod): started = [] + monkeypatch.setattr( + mod, + "threading", + module_mock( + mod.threading, + Thread=capturing_thread_constructor( + started, + capture=lambda thread: thread._args, + ), + ), + ) + return started - def fake_thread_start(self): - started.append(self._args) - monkeypatch.setattr(mod.threading.Thread, "start", fake_thread_start) +def test_task_queue_defers_submit_when_not_ready(monkeypatch): + mod = importlib.import_module("solstone.think.supervisor") + queue = mod.TaskQueue(on_queue_change=None, ready=False) + + started = _capture_thread_starts(monkeypatch, mod) ref = queue.submit( ["journal", "indexer", "--rescan"], ref="pending-ref", day="20260418" @@ -41,12 +57,7 @@ def test_task_queue_set_ready_drains_in_submission_order(monkeypatch): mod = importlib.import_module("solstone.think.supervisor") queue = mod.TaskQueue(on_queue_change=None, ready=False) - started = [] - - def fake_thread_start(self): - started.append(self._args) - - monkeypatch.setattr(mod.threading.Thread, "start", fake_thread_start) + started = _capture_thread_starts(monkeypatch, mod) queue.submit(["journal", "indexer", "--rescan"], ref="ref-1") queue.submit(["sol", "insight", "20260418"], ref="ref-2") @@ -67,12 +78,7 @@ def test_task_queue_set_ready_dedupes_same_cmd_in_pending(monkeypatch): mod = importlib.import_module("solstone.think.supervisor") queue = mod.TaskQueue(on_queue_change=None, ready=False) - started = [] - - def fake_thread_start(self): - started.append(self._args) - - monkeypatch.setattr(mod.threading.Thread, "start", fake_thread_start) + started = _capture_thread_starts(monkeypatch, mod) queue.submit(["journal", "indexer", "--rescan"], ref="ref-1") queue.submit(["journal", "indexer", "--rescan"], ref="ref-2") @@ -96,12 +102,7 @@ def test_task_queue_ready_true_default_dispatches_immediately(monkeypatch): mod = importlib.import_module("solstone.think.supervisor") queue = mod.TaskQueue(on_queue_change=None) - started = [] - - def fake_thread_start(self): - started.append(self._args) - - monkeypatch.setattr(mod.threading.Thread, "start", fake_thread_start) + started = _capture_thread_starts(monkeypatch, mod) ref = queue.submit(["journal", "indexer", "--rescan"], ref="ready-ref") @@ -126,29 +127,33 @@ def test_wait_for_convey_ready_success(caplog): assert "Convey ready after" in caplog.text -def test_wait_for_convey_ready_timeout(caplog): +def test_wait_for_convey_ready_timeout(caplog, monkeypatch): mod = importlib.import_module("solstone.think.supervisor") caplog.set_level("ERROR") convey_mp = SimpleNamespace(process=SimpleNamespace(poll=lambda: None)) ticks = itertools.chain([0.0, 0.0, 0.1, 0.2, 0.3], itertools.repeat(0.35)) + monkeypatch.setattr( + mod, + "time", + module_mock( + mod.time, + sleep=mock.Mock(), + monotonic=mock.Mock(side_effect=lambda: next(ticks)), + ), + ) with mock.patch("solstone.think.supervisor.is_solstone_up", return_value=False): with mock.patch( "solstone.think.supervisor.read_service_port", return_value=5015 ): - with mock.patch("solstone.think.supervisor.time.sleep", return_value=None): - with mock.patch( - "solstone.think.supervisor.time.monotonic", - side_effect=lambda: next(ticks), - ): - assert ( - mod.wait_for_convey_ready( - convey_mp, - timeout=0.3, - interval=0.05, - ) - is False - ) + assert ( + mod.wait_for_convey_ready( + convey_mp, + timeout=0.3, + interval=0.05, + ) + is False + ) assert "Convey not ready after" in caplog.text diff --git a/tests/test_thinking_call_parity.py b/tests/test_thinking_call_parity.py index b95544022..55a000395 100644 --- a/tests/test_thinking_call_parity.py +++ b/tests/test_thinking_call_parity.py @@ -17,6 +17,7 @@ from solstone.apps.thinking import copy as thinking_copy from solstone.think.convey_client import ConveyClient from solstone.think.services import operations, scout, scout_handoff from tests._baseline_harness import make_test_client +from tests.helpers.module_mocks import inline_thread_constructor, module_mock runner = CliRunner() @@ -199,6 +200,14 @@ def test_scout_enable_polls_terminal_success( return operations.HandoffResult("enabled", None, False) monkeypatch.setattr(scout_handoff, "run_scout_handoff", runner_result) + monkeypatch.setattr( + operations, + "threading", + module_mock( + operations.threading, + Thread=inline_thread_constructor(), + ), + ) result = runner.invoke( thinking_call.app, diff --git a/tests/test_transcribe_telemetry.py b/tests/test_transcribe_telemetry.py index df5b490a9..b7d008bad 100644 --- a/tests/test_transcribe_telemetry.py +++ b/tests/test_transcribe_telemetry.py @@ -24,6 +24,7 @@ from solstone.observe.transcribe.overlap import ( from solstone.observe.utils import SAMPLE_RATE from solstone.observe.vad import VadResult from solstone.think.providers.parakeet_server import ParakeetServerNotReady +from tests.helpers.module_mocks import module_mock # A string that exists nowhere but in the (mocked) transcript. If it shows up in a # serialized event, transcript content leaked into telemetry. @@ -399,8 +400,14 @@ def test_stage_timings_accumulate_repeated_stages( # Drive perf_counter so the two blocks have distinct, known durations. # Values are binary-exact so the int() truncation is not off by a millisecond. - ticks = iter([0.0, 0.25, 1.0, 1.5]) - monkeypatch.setattr(transcribe_main.time, "perf_counter", lambda: next(ticks)) + monkeypatch.setattr( + transcribe_main, + "time", + module_mock( + transcribe_main.time, + perf_counter=MagicMock(side_effect=[0.0, 0.25, 1.0, 1.5]), + ), + ) timings = transcribe_main._StageTimings() assert timings.as_dict() == {}