diff --git a/docs/testing/README.md b/docs/testing/README.md new file mode 100644 index 00000000..be71ea1b --- /dev/null +++ b/docs/testing/README.md @@ -0,0 +1,26 @@ +# Testing Docs + +Strongclaw keeps contributor-facing testing documentation in this directory. +This is the canonical source of truth for how tests are organized, authored, +and run inside the repository. + +Testing docs do not ship in `src/clawops/assets/...` unless a packaged runtime +flow actually needs them. + +## Start Here + +- Read [authoring.md](authoring.md) when you are writing new tests, moving + existing coverage, or touching the pytest framework. +- Read [operations.md](operations.md) when you are running tests locally, + debugging CI, or checking governance commands. +- Read [../../tests/fixtures/README.md](../../tests/fixtures/README.md) for + fixture-package-specific guidance only. + +## Canonical Rules + +- Put tests under `tests/suites/{unit,integration,contracts,e2e,...}` and place + them in a subsystem-specific subdirectory when one exists. +- Treat `TestContext` as the default path for patching, environment mutation, + and temporary working-directory changes. +- Keep pytest framework rules and contributor docs together under + `docs/testing/` instead of scattering them across packaged asset trees. diff --git a/docs/testing/authoring.md b/docs/testing/authoring.md new file mode 100644 index 00000000..41cde00b --- /dev/null +++ b/docs/testing/authoring.md @@ -0,0 +1,93 @@ +# Authoring Tests + +## Lane Model + +Strongclaw uses four primary default pytest lanes: + +- `unit`: isolated behavior and small-surface regression checks +- `integration`: cross-module or service-shaped behavior +- `contracts`: repository policies, docs parity, and CI/test-governance rules +- `e2e`: black-box CLI and workflow-shaped orchestration coverage + +The repository also maintains an explicit `framework` lane for pytest-framework +self-checks. Framework tests live under +`tests/suites/contracts/testing/framework/` and are excluded from default runs. +Use that lane only for pytest bootstrap and plugin-registration behavior. + +Monkeypatch governance is a default contract, not a framework-only self-check. +The direct-monkeypatch contract lives under `tests/suites/contracts/testing/` +so ordinary pytest runs fail when new unmanaged `monkeypatch` usage appears in +suite code. + +Capability markers are additive and remain module-local: + +- `hypermemory` +- `qdrant` +- `network_local` + +Structural markers are assigned from the suite path layout in +`tests/conftest.py`. + +## Placement Rules + +Add a new test in the suite that matches its behavior: + +- `tests/suites/unit/...` for isolated behavior +- `tests/suites/integration/...` for cross-module or service-backed behavior +- `tests/suites/contracts/...` for repository and governance rules +- `tests/suites/contracts/testing/framework/...` for explicit pytest-framework + self-checks +- `tests/suites/e2e/...` for black-box CLI and workflow-shaped coverage + +Prefer a dedicated subsystem directory inside each lane when one already +exists. + +## Runtime Boundaries + +Use `tests/plugins/infrastructure/` for structural test runtime behavior such +as `TestContext`, environment management, patch management, profile +registration, and framework-owned pytest hooks. + +Use `tests/fixtures/` for domain-facing pytest fixture plugins only. + +Use `tests/utils/helpers/` for builders, subsystem runtimes, AST tooling, and +other reusable support code that is not itself a fixture or pytest framework +surface. + +Tests consume fixtures by name through pytest injection and should not import +from `tests.fixtures`. + +## Preferred Authoring Path + +Environment mutation, working-directory changes, and patching should flow +through the infrastructure runtime: + +- use `test_context.patch.patch(...)` or `patch_object(...)` +- use `test_context.env.set(...)`, `remove(...)`, `update(...)`, or + `prepend_path(...)` +- use `test_context.chdir(...)` for temporary working-directory changes + +Do not add new ordinary-suite tests that depend on raw `monkeypatch` unless the +test is an explicit governed exception. + +## Service Resolution + +Service-backed tests resolve mode with this precedence: + +1. CLI: `--mock ` +2. Environment: `_TEST_MODE` +3. Marker kwargs: `@pytest.mark.(mode="real")` +4. Default mode + +Current service support: + +- `qdrant` + +## Governance + +Framework policy lives under `tests/suites/contracts/testing/`. +Add a contract test when a rule must stay true even if the implementation +changes. + +Pytest-framework registration and bootstrap topology lives under +`tests/suites/contracts/testing/framework/`. diff --git a/docs/testing/operations.md b/docs/testing/operations.md new file mode 100644 index 00000000..2ede7430 --- /dev/null +++ b/docs/testing/operations.md @@ -0,0 +1,42 @@ +# Testing Operations + +## Canonical Commands + +- Full suite: `uv run pytest -q` +- Unit lane: `uv run pytest -q -m unit` +- Integration lane: `uv run pytest -q -m integration` +- Contract lane: `uv run pytest -q -m contract` +- Framework lane only: + `uv run pytest -q -m framework tests/suites/contracts/testing/framework` +- E2E lane: `uv run pytest -q -m e2e` +- Hypermemory lane: `uv run pytest -q -m hypermemory` +- Qdrant lane: `uv run pytest -q -m "hypermemory and qdrant"` + +## Service Mode Commands + +- Force Qdrant mock mode: + `uv run pytest -q -m "hypermemory and qdrant" --mock qdrant` +- Force Qdrant real mode: + `QDRANT_TEST_MODE=real uv run pytest -q -m "hypermemory and qdrant"` +- Provide an existing real endpoint: + `TEST_QDRANT_URL=http://127.0.0.1:6333 uv run pytest -q -m "hypermemory and qdrant"` +- Override the managed Qdrant image: + `TEST_QDRANT_IMAGE=ghcr.io/example/qdrant:test uv run pytest -q -m "hypermemory and qdrant"` + +## Governance Checks + +- Testing contracts: `uv run pytest -q tests/suites/contracts/testing` +- Fixture analysis: + `uv run python -m tests.utils.scripts.analyze_fixtures --json` +- Safe timeout wrapper: + `uv run python -m tests.utils.scripts.pytest_safe --timeout 600 -q -m integration` + +## Common Triage + +- If the monkeypatch governance contract fails, migrate the test to + `TestContext` instead of expanding the allowlist by default. +- If docs or links move, rerun the repository docs contracts so relative-link + and layout drift is caught early. +- If a test needs reusable setup by fixture injection, add or extend a fixture. + If it needs reusable non-fixture logic, move that code into + `tests/utils/helpers/`. diff --git a/platform/docs/TESTING_FRAMEWORK.md b/platform/docs/TESTING_FRAMEWORK.md deleted file mode 100644 index 3b4a53d0..00000000 --- a/platform/docs/TESTING_FRAMEWORK.md +++ /dev/null @@ -1,111 +0,0 @@ -# Testing Framework - -## Lane Model - -Strongclaw uses four primary default pytest lanes: -- `unit`: isolated behavior and small-surface regression checks -- `integration`: cross-module or service-shaped behavior -- `contracts`: repository policies, docs parity, and CI/test-governance rules -- `e2e`: black-box CLI and workflow-shaped orchestration coverage - -The repository also maintains an explicit `framework` lane for pytest-framework self-checks. -Framework tests live under `tests/suites/contracts/testing/framework/` and are excluded from -default runs via the project pytest configuration. Run them explicitly when changing pytest -bootstrap, plugin registration, or framework governance behavior. - -Capability markers are additive and remain module-local: -- `hypermemory` -- `qdrant` -- `network_local` - -Structural markers are assigned from the suite path layout in `tests/conftest.py`. - -## Fixture and Helper Split - -Use `tests/plugins/infrastructure/` for structural test runtime behavior such as `TestContext`, -environment management, patch management, profile registration, and framework-owned pytest hooks. -Use `tests/fixtures/` for domain-facing pytest fixture plugins only. -Use `tests/utils/helpers/` for builders, subsystem runtime helpers, AST tooling, and other -non-structural support code. - -Keep root `tests/conftest.py` lean: -- structural marker assignment -- shared plugin registration -- path fixtures that are meaningful across suites - -Root `tests/conftest.py` registers the infrastructure runtime and the shared fixture package via -`pytest_plugins`. -`tests/plugins/infrastructure/__init__.py` owns framework CLI options, universal `TestContext`, -managed env injection, patch teardown, and named runtime profiles. -`tests/fixtures/__init__.py` aggregates domain packages, and domain package `__init__.py` files -aggregate their leaf fixture modules. -Tests consume fixtures by name through pytest injection and should not import from `tests.fixtures`. -Tests that need reusable builders, fakes, or types should import them from `tests.utils.helpers`. -Environment mutation and patching should flow through the infrastructure runtime, for example -`prepend_path`, `TestContext.env`, and `TestContext.patch`, instead of direct `monkeypatch` -usage in ordinary suite code. - -## DualMode Service Resolution - -Service-backed tests resolve mode with this precedence: -1. CLI: `--mock ` -2. Environment: `_TEST_MODE` -3. Marker kwargs: `@pytest.mark.(mode="real")` -4. Default mode - -Current service support: -- `qdrant` - -Examples: -- `uv run pytest -q -m unit` -- `uv run pytest -q -m "hypermemory and qdrant" --mock qdrant` -- `uv run pytest -q -m e2e` -- `uv run pytest -q -m framework tests/suites/contracts/testing/framework` -- `QDRANT_TEST_MODE=real uv run pytest -q -m "hypermemory and qdrant"` - -## Adopted Patterns - -The repository intentionally adopts a small subset of the AIOA testing architecture: -- worker-aware per-test identity -- deterministic tracked cleanup through `TestContext` -- universal `TestContext` creation for every test -- infrastructure-owned environment and patch isolation -- named infrastructure profiles for repeated runtime setup -- narrow runtime helpers for network and Qdrant integration coverage -- contract tests and static analysis for framework governance - -## Rejected Patterns - -The repository intentionally does not adopt these patterns at current scale: -- plugin dependency graphs -- a monolithic framework control plane in root `conftest.py` -- registry-heavy data factories -- pytest-embedded documentation generation -- parallel-safety abstractions before xdist is an actual requirement - -## Growth Triggers - -Revisit the design if any of these become true: -- more than one helper module grows past the line-count guardrail -- three or more service runtimes need ordered lifecycle orchestration -- xdist becomes part of the default CI matrix -- fixture ownership becomes ambiguous across multiple domains - -## Governance Contracts - -Framework policy lives under `tests/suites/contracts/testing/`. -Add a contract test when a rule must stay true even if the implementation changes. - -Pytest-framework registration and bootstrap topology lives under -`tests/suites/contracts/testing/framework/`. Use that lane for assertions about recursive plugin -registration, explicit framework-only behavior, and other tests that should not run in the default -suite. - -Current governance covers: -- root bootstrap shape -- workflow pytest invocation policy -- test-context cleanup invariants -- environment and patch isolation -- runtime helper behavior -- fixture-analysis health checks -- testing documentation presence diff --git a/platform/docs/TESTING_OPERATIONS.md b/platform/docs/TESTING_OPERATIONS.md deleted file mode 100644 index 47a5d1fd..00000000 --- a/platform/docs/TESTING_OPERATIONS.md +++ /dev/null @@ -1,46 +0,0 @@ -# Testing Operations - -## Canonical Commands - -- Full suite: `uv run pytest -q` -- Unit lane: `uv run pytest -q -m unit` -- Integration lane: `uv run pytest -q -m integration` -- Contract lane: `uv run pytest -q -m contract` -- Framework lane only: `uv run pytest -q -m framework tests/suites/contracts/testing/framework` -- E2E lane: `uv run pytest -q -m e2e` -- Hypermemory lane: `uv run pytest -q -m hypermemory` -- Qdrant lane: `uv run pytest -q -m "hypermemory and qdrant"` - -## DualMode Commands - -- Force Qdrant mock mode: `uv run pytest -q -m "hypermemory and qdrant" --mock qdrant` -- Force Qdrant real mode: `QDRANT_TEST_MODE=real uv run pytest -q -m "hypermemory and qdrant"` -- Provide an existing real endpoint: `TEST_QDRANT_URL=http://127.0.0.1:6333 uv run pytest -q -m "hypermemory and qdrant"` -- Override the managed Qdrant image: `TEST_QDRANT_IMAGE=ghcr.io/example/qdrant:test uv run pytest -q -m "hypermemory and qdrant"` - The default managed image is the repo-pinned official Qdrant GHCR mirror. - -## Governance Checks - -- Framework contracts: `uv run pytest -q tests/suites/contracts/testing` -- Fixture analysis: `uv run python -m tests.utils.scripts.analyze_fixtures --json` -- Safe timeout wrapper: `uv run python -m tests.utils.scripts.pytest_safe --timeout 600 -q -m integration` - -## Adding Coverage - -Add a new test in the suite that matches its behavior: -- `tests/suites/unit/...` for isolated behavior -- `tests/suites/integration/...` for cross-module or service-backed behavior -- `tests/suites/contracts/...` for repository and governance rules -- `tests/suites/contracts/testing/framework/...` for explicit pytest-framework self-checks -- `tests/suites/e2e/...` for black-box CLI and workflow-shaped coverage - -Add a new fixture when pytest injection is the public entrypoint. -Add a new helper when the logic should be reusable outside fixture setup. -Import reusable support code from `tests.utils.helpers`, not from `tests.fixtures`. -Use `prepend_path` or `test_context.env.prepend_path(...)` for PATH mutation. -Use `test_context.patch.patch(...)` or `patch_object(...)` for managed patch teardown. - -Capability markers stay module-local. -Structural markers come from the suite path layout and should not be added manually. -Use `prepend_path`, `TestContext.env`, and `TestContext.patch` for environment and patch -management instead of direct `monkeypatch` calls in ordinary suite code. diff --git a/src/clawops/assets/platform/docs/TESTING_FRAMEWORK.md b/src/clawops/assets/platform/docs/TESTING_FRAMEWORK.md deleted file mode 100644 index 3b4a53d0..00000000 --- a/src/clawops/assets/platform/docs/TESTING_FRAMEWORK.md +++ /dev/null @@ -1,111 +0,0 @@ -# Testing Framework - -## Lane Model - -Strongclaw uses four primary default pytest lanes: -- `unit`: isolated behavior and small-surface regression checks -- `integration`: cross-module or service-shaped behavior -- `contracts`: repository policies, docs parity, and CI/test-governance rules -- `e2e`: black-box CLI and workflow-shaped orchestration coverage - -The repository also maintains an explicit `framework` lane for pytest-framework self-checks. -Framework tests live under `tests/suites/contracts/testing/framework/` and are excluded from -default runs via the project pytest configuration. Run them explicitly when changing pytest -bootstrap, plugin registration, or framework governance behavior. - -Capability markers are additive and remain module-local: -- `hypermemory` -- `qdrant` -- `network_local` - -Structural markers are assigned from the suite path layout in `tests/conftest.py`. - -## Fixture and Helper Split - -Use `tests/plugins/infrastructure/` for structural test runtime behavior such as `TestContext`, -environment management, patch management, profile registration, and framework-owned pytest hooks. -Use `tests/fixtures/` for domain-facing pytest fixture plugins only. -Use `tests/utils/helpers/` for builders, subsystem runtime helpers, AST tooling, and other -non-structural support code. - -Keep root `tests/conftest.py` lean: -- structural marker assignment -- shared plugin registration -- path fixtures that are meaningful across suites - -Root `tests/conftest.py` registers the infrastructure runtime and the shared fixture package via -`pytest_plugins`. -`tests/plugins/infrastructure/__init__.py` owns framework CLI options, universal `TestContext`, -managed env injection, patch teardown, and named runtime profiles. -`tests/fixtures/__init__.py` aggregates domain packages, and domain package `__init__.py` files -aggregate their leaf fixture modules. -Tests consume fixtures by name through pytest injection and should not import from `tests.fixtures`. -Tests that need reusable builders, fakes, or types should import them from `tests.utils.helpers`. -Environment mutation and patching should flow through the infrastructure runtime, for example -`prepend_path`, `TestContext.env`, and `TestContext.patch`, instead of direct `monkeypatch` -usage in ordinary suite code. - -## DualMode Service Resolution - -Service-backed tests resolve mode with this precedence: -1. CLI: `--mock ` -2. Environment: `_TEST_MODE` -3. Marker kwargs: `@pytest.mark.(mode="real")` -4. Default mode - -Current service support: -- `qdrant` - -Examples: -- `uv run pytest -q -m unit` -- `uv run pytest -q -m "hypermemory and qdrant" --mock qdrant` -- `uv run pytest -q -m e2e` -- `uv run pytest -q -m framework tests/suites/contracts/testing/framework` -- `QDRANT_TEST_MODE=real uv run pytest -q -m "hypermemory and qdrant"` - -## Adopted Patterns - -The repository intentionally adopts a small subset of the AIOA testing architecture: -- worker-aware per-test identity -- deterministic tracked cleanup through `TestContext` -- universal `TestContext` creation for every test -- infrastructure-owned environment and patch isolation -- named infrastructure profiles for repeated runtime setup -- narrow runtime helpers for network and Qdrant integration coverage -- contract tests and static analysis for framework governance - -## Rejected Patterns - -The repository intentionally does not adopt these patterns at current scale: -- plugin dependency graphs -- a monolithic framework control plane in root `conftest.py` -- registry-heavy data factories -- pytest-embedded documentation generation -- parallel-safety abstractions before xdist is an actual requirement - -## Growth Triggers - -Revisit the design if any of these become true: -- more than one helper module grows past the line-count guardrail -- three or more service runtimes need ordered lifecycle orchestration -- xdist becomes part of the default CI matrix -- fixture ownership becomes ambiguous across multiple domains - -## Governance Contracts - -Framework policy lives under `tests/suites/contracts/testing/`. -Add a contract test when a rule must stay true even if the implementation changes. - -Pytest-framework registration and bootstrap topology lives under -`tests/suites/contracts/testing/framework/`. Use that lane for assertions about recursive plugin -registration, explicit framework-only behavior, and other tests that should not run in the default -suite. - -Current governance covers: -- root bootstrap shape -- workflow pytest invocation policy -- test-context cleanup invariants -- environment and patch isolation -- runtime helper behavior -- fixture-analysis health checks -- testing documentation presence diff --git a/src/clawops/assets/platform/docs/TESTING_OPERATIONS.md b/src/clawops/assets/platform/docs/TESTING_OPERATIONS.md deleted file mode 100644 index 47a5d1fd..00000000 --- a/src/clawops/assets/platform/docs/TESTING_OPERATIONS.md +++ /dev/null @@ -1,46 +0,0 @@ -# Testing Operations - -## Canonical Commands - -- Full suite: `uv run pytest -q` -- Unit lane: `uv run pytest -q -m unit` -- Integration lane: `uv run pytest -q -m integration` -- Contract lane: `uv run pytest -q -m contract` -- Framework lane only: `uv run pytest -q -m framework tests/suites/contracts/testing/framework` -- E2E lane: `uv run pytest -q -m e2e` -- Hypermemory lane: `uv run pytest -q -m hypermemory` -- Qdrant lane: `uv run pytest -q -m "hypermemory and qdrant"` - -## DualMode Commands - -- Force Qdrant mock mode: `uv run pytest -q -m "hypermemory and qdrant" --mock qdrant` -- Force Qdrant real mode: `QDRANT_TEST_MODE=real uv run pytest -q -m "hypermemory and qdrant"` -- Provide an existing real endpoint: `TEST_QDRANT_URL=http://127.0.0.1:6333 uv run pytest -q -m "hypermemory and qdrant"` -- Override the managed Qdrant image: `TEST_QDRANT_IMAGE=ghcr.io/example/qdrant:test uv run pytest -q -m "hypermemory and qdrant"` - The default managed image is the repo-pinned official Qdrant GHCR mirror. - -## Governance Checks - -- Framework contracts: `uv run pytest -q tests/suites/contracts/testing` -- Fixture analysis: `uv run python -m tests.utils.scripts.analyze_fixtures --json` -- Safe timeout wrapper: `uv run python -m tests.utils.scripts.pytest_safe --timeout 600 -q -m integration` - -## Adding Coverage - -Add a new test in the suite that matches its behavior: -- `tests/suites/unit/...` for isolated behavior -- `tests/suites/integration/...` for cross-module or service-backed behavior -- `tests/suites/contracts/...` for repository and governance rules -- `tests/suites/contracts/testing/framework/...` for explicit pytest-framework self-checks -- `tests/suites/e2e/...` for black-box CLI and workflow-shaped coverage - -Add a new fixture when pytest injection is the public entrypoint. -Add a new helper when the logic should be reusable outside fixture setup. -Import reusable support code from `tests.utils.helpers`, not from `tests.fixtures`. -Use `prepend_path` or `test_context.env.prepend_path(...)` for PATH mutation. -Use `test_context.patch.patch(...)` or `patch_object(...)` for managed patch teardown. - -Capability markers stay module-local. -Structural markers come from the suite path layout and should not be added manually. -Use `prepend_path`, `TestContext.env`, and `TestContext.patch` for environment and patch -management instead of direct `monkeypatch` calls in ordinary suite code. diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index bfe8d19c..7eced5e0 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -1,45 +1,27 @@ # Test Fixture Layout +Contributor-facing testing rules live under +[`docs/testing/`](../../docs/testing/README.md). +This file is only for fixture-package-specific guidance. + `tests/plugins/infrastructure/` contains the structural runtime for every test. -It owns the universal `TestContext`, framework env injection, patch teardown, profile handling, -and infrastructure-owned pytest hooks. +It owns the universal `TestContext`, framework env injection, tracked cwd +changes, patch teardown, profile handling, and infrastructure-owned pytest +hooks. `tests/fixtures/` contains domain-facing pytest activation surfaces. -The package is loaded through root `tests/conftest.py`, then aggregated by package-level -`pytest_plugins` registries: +The package is loaded through root `tests/conftest.py`, then aggregated by +package-level `pytest_plugins` registries: + - `tests.plugins.infrastructure` - `tests.fixtures` - `tests.fixtures.core` - `tests.fixtures.platform` - `tests.fixtures.hypermemory` -Leaf fixture modules should stay thin and expose fixtures that are meaningful to multiple tests or suites. +Leaf fixture modules should stay thin and expose fixtures that are meaningful +to multiple tests or suites. Tests should use fixture injection instead of importing from `tests.fixtures`. -`tests/utils/helpers/` contains reusable builders, subsystem runtimes, and support code. -Move implementation detail there when it is not itself structural infrastructure or a pytest fixture. -Import helper functions, fakes, and types from `tests.utils.helpers.*`. - -Add a new fixture module when: -- A set of related fixtures belongs to a clear subsystem boundary. -- The fixture is reused across multiple tests or suites. -- The module can stay focused without becoming a general grab bag. - -Add a new helper module when: -- The code constructs data, manages lifecycle, or performs AST/runtime analysis. -- The logic can be reused outside pytest fixture setup. -- The module can be tested directly as a normal Python API. - -Add a contract test when: -- A repository policy must not silently drift. -- Fixture or helper placement matters for maintainability. -- CI and local test invocation rules need enforcement. - -Three-layer pattern: -- Infrastructure modules own universal test runtime behavior. -- Fixture modules expose domain-facing pytest fixtures only. -- Helper modules hold typed implementation logic and subsystem runtime helpers. - -Example: -- `tests/fixtures/hypermemory/workspace.py` exports `hypermemory_workspace_factory`. -- `tests/utils/helpers/hypermemory.py` implements the workspace builders and fake backends. +Use [../../docs/testing/authoring.md](../../docs/testing/authoring.md) for +lane placement, governance rules, and `TestContext` guidance. diff --git a/tests/plugins/infrastructure/context.py b/tests/plugins/infrastructure/context.py index e4d7d9f9..ab8a20ef 100644 --- a/tests/plugins/infrastructure/context.py +++ b/tests/plugins/infrastructure/context.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import os import time from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, ClassVar, Protocol, cast, runtime_checkable @@ -93,6 +94,7 @@ class TestContext: _cleaned: bool = False _environment: EnvironmentManager | None = field(default=None, init=False, repr=False) _patch_manager: PatchManager | None = field(default=None, init=False, repr=False) + _cwd_snapshot: str | None = field(default=None, init=False, repr=False) def attach_environment(self, manager: EnvironmentManager) -> None: """Bind the framework-managed environment runtime to this context.""" @@ -120,6 +122,13 @@ def apply_profiles(self, *names: str) -> None: """Apply one or more named runtime profiles to the current test.""" self.env.apply_profiles(*names) + def chdir(self, path: str | os.PathLike[str]) -> None: + """Change the current working directory and restore it during cleanup.""" + if self._cwd_snapshot is None: + self._cwd_snapshot = os.getcwd() + self.register_cleanup("cwd", self._restore_cwd) + os.chdir(path) + def register_resource( self, name: str, @@ -192,6 +201,12 @@ def _run_cleanup_stack( errors.append((name, exc)) return errors + def _restore_cwd(self) -> None: + if self._cwd_snapshot is None: + return + os.chdir(self._cwd_snapshot) + self._cwd_snapshot = None + CONTEXT_KEY = pytest.StashKey[TestContext]() diff --git a/tests/suites/contracts/repo/test_docs_parity.py b/tests/suites/contracts/repo/test_docs_parity.py index 16c601e4..a7dfb8e0 100644 --- a/tests/suites/contracts/repo/test_docs_parity.py +++ b/tests/suites/contracts/repo/test_docs_parity.py @@ -18,12 +18,17 @@ def _official_markdown_files(repo_root: pathlib.Path) -> list[pathlib.Path]: repo_root / "SETUP_GUIDE.md", repo_root / "USAGE_GUIDE.md", ] + docs_markdown = ( + [path for path in (repo_root / "docs").rglob("*.md") if path.is_file()] + if (repo_root / "docs").exists() + else [] + ) platform_markdown = [ path for path in (repo_root / "platform").rglob("*.md") if "platform/plugins/" not in path.as_posix() ] - return root_markdown + platform_markdown + return root_markdown + docs_markdown + platform_markdown def test_markdown_relative_links_resolve() -> None: diff --git a/tests/suites/contracts/repo/test_packaged_runtime_assets.py b/tests/suites/contracts/repo/test_packaged_runtime_assets.py index c1251135..c49d8b78 100644 --- a/tests/suites/contracts/repo/test_packaged_runtime_assets.py +++ b/tests/suites/contracts/repo/test_packaged_runtime_assets.py @@ -9,7 +9,7 @@ def _tracked_relative_files(root: pathlib.Path) -> set[pathlib.Path]: - """Return git-tracked files under one repo-relative root.""" + """Return existing git-tracked files under one repo-relative root.""" repo_relative_root = root.resolve().relative_to(REPO_ROOT) result = subprocess.run( [ @@ -29,7 +29,9 @@ def _tracked_relative_files(root: pathlib.Path) -> set[pathlib.Path]: if not raw_path: continue tracked_path = pathlib.Path(raw_path.decode("utf-8")) - files.add(tracked_path.relative_to(repo_relative_root)) + relative_path = tracked_path.relative_to(repo_relative_root) + if (root / relative_path).is_file(): + files.add(relative_path) return files diff --git a/tests/suites/contracts/testing/framework/test_monkeypatch_allowlist.py b/tests/suites/contracts/testing/test_monkeypatch_governance.py similarity index 93% rename from tests/suites/contracts/testing/framework/test_monkeypatch_allowlist.py rename to tests/suites/contracts/testing/test_monkeypatch_governance.py index 270c3015..ab8af052 100644 --- a/tests/suites/contracts/testing/framework/test_monkeypatch_allowlist.py +++ b/tests/suites/contracts/testing/test_monkeypatch_governance.py @@ -1,4 +1,4 @@ -"""Framework governance for the remaining direct monkeypatch exceptions.""" +"""Governance for the remaining direct monkeypatch exceptions.""" from __future__ import annotations diff --git a/tests/suites/contracts/testing/test_test_context_contract.py b/tests/suites/contracts/testing/test_test_context_contract.py index 880fe08d..7618c8d5 100644 --- a/tests/suites/contracts/testing/test_test_context_contract.py +++ b/tests/suites/contracts/testing/test_test_context_contract.py @@ -42,6 +42,7 @@ def test_test_context_fixture_is_accessed_from_stash( def test_test_context_always_exposes_env_and_patch_runtime(test_context: TestContext) -> None: assert test_context.env is not None assert test_context.patch is not None + assert callable(test_context.chdir) def test_fixture_registry_no_longer_loads_legacy_test_context_plugin() -> None: diff --git a/tests/suites/contracts/testing/test_testing_docs_contract.py b/tests/suites/contracts/testing/test_testing_docs_contract.py index 59d87ed4..4596f2f9 100644 --- a/tests/suites/contracts/testing/test_testing_docs_contract.py +++ b/tests/suites/contracts/testing/test_testing_docs_contract.py @@ -4,33 +4,44 @@ from tests.utils.helpers.repo import REPO_ROOT +DOCS_ROOT = REPO_ROOT / "docs" / "testing" -def test_testing_framework_doc_exists() -> None: - assert (REPO_ROOT / "platform" / "docs" / "TESTING_FRAMEWORK.md").is_file() + +def test_testing_docs_entrypoint_exists() -> None: + assert (DOCS_ROOT / "README.md").is_file() + + +def test_testing_authoring_doc_exists() -> None: + assert (DOCS_ROOT / "authoring.md").is_file() def test_testing_operations_doc_exists() -> None: - assert (REPO_ROOT / "platform" / "docs" / "TESTING_OPERATIONS.md").is_file() + assert (DOCS_ROOT / "operations.md").is_file() def test_fixture_readme_exists() -> None: assert (REPO_ROOT / "tests" / "fixtures" / "README.md").is_file() -def test_docs_mention_lane_model() -> None: - framework_doc = (REPO_ROOT / "platform" / "docs" / "TESTING_FRAMEWORK.md").read_text( - encoding="utf-8" - ) +def test_docs_entrypoint_links_to_authoring_and_operations() -> None: + readme = (DOCS_ROOT / "README.md").read_text(encoding="utf-8") + + assert "authoring.md" in readme + assert "operations.md" in readme + assert "tests/fixtures/README.md" in readme + + +def test_authoring_doc_mentions_lane_model() -> None: + authoring_doc = (DOCS_ROOT / "authoring.md").read_text(encoding="utf-8") for lane in ("unit", "integration", "contracts", "framework"): - assert lane in framework_doc + assert lane in authoring_doc def test_docs_mention_infrastructure_runtime_namespace() -> None: - framework_doc = (REPO_ROOT / "platform" / "docs" / "TESTING_FRAMEWORK.md").read_text( - encoding="utf-8" - ) + authoring_doc = (DOCS_ROOT / "authoring.md").read_text(encoding="utf-8") fixture_readme = (REPO_ROOT / "tests" / "fixtures" / "README.md").read_text(encoding="utf-8") - assert "tests/plugins/infrastructure" in framework_doc + assert "tests/plugins/infrastructure" in authoring_doc + assert "docs/testing/authoring.md" in fixture_readme assert "tests/plugins/infrastructure" in fixture_readme diff --git a/tests/suites/integration/clawops/test_devflow_recovery.py b/tests/suites/integration/clawops/test_devflow_recovery.py index 243d7961..cbff454b 100644 --- a/tests/suites/integration/clawops/test_devflow_recovery.py +++ b/tests/suites/integration/clawops/test_devflow_recovery.py @@ -11,6 +11,7 @@ from clawops.devflow import main from clawops.devflow_roles import WorkspaceMode from clawops.devflow_workspaces import DevflowWorkspacePlanner, PlannedWorkspace +from tests.plugins.infrastructure.context import TestContext from tests.utils.helpers.cli import PathPrepender from tests.utils.helpers.devflow import ( init_git_repo, @@ -70,7 +71,7 @@ def test_devflow_workspace_failure_marks_run_failed_and_audit_still_works( tmp_path: pathlib.Path, prepend_path: PathPrepender, capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch, + test_context: TestContext, ) -> None: repo_root = tmp_path / "repo" repo_root.mkdir() @@ -100,7 +101,7 @@ def _flaky_prepare( source_root=source_root, ) - monkeypatch.setattr(DevflowWorkspacePlanner, "prepare", _flaky_prepare) + test_context.patch.patch_object(DevflowWorkspacePlanner, "prepare", new=_flaky_prepare) exit_code = main(["run", "--project-root", str(repo_root), "--goal", "workspace recovery"]) payload = json.loads(capsys.readouterr().out) diff --git a/tests/suites/unit/ci/test_memory_plugin_verification.py b/tests/suites/unit/ci/test_memory_plugin_verification.py index 696c0244..322eae3a 100644 --- a/tests/suites/unit/ci/test_memory_plugin_verification.py +++ b/tests/suites/unit/ci/test_memory_plugin_verification.py @@ -5,8 +5,6 @@ from pathlib import Path from typing import Any -import pytest - from tests.plugins.infrastructure.context import TestContext from tests.scripts import memory_plugin_verification as memory_plugin_script from tests.utils.helpers import ci_workflows @@ -14,7 +12,7 @@ def test_run_vendored_host_checks_installs_cli_and_clears_aws_env( - monkeypatch: pytest.MonkeyPatch, + test_context: TestContext, tmp_path: Path, ) -> None: """Vendored host checks should prefix PATH and clear ambient AWS variables.""" @@ -35,10 +33,10 @@ def fake_run_checked( seen_calls.append((command, cwd, env)) return None - monkeypatch.setattr(memory_plugin_helpers, "run_checked", fake_run_checked) - monkeypatch.setenv("PATH", "/usr/bin") - monkeypatch.setenv("AWS_PROFILE", "default") - monkeypatch.setenv("AWS_REGION", "us-east-1") + test_context.patch.patch_object(memory_plugin_helpers, "run_checked", new=fake_run_checked) + test_context.env.set("PATH", "/usr/bin") + test_context.env.set("AWS_PROFILE", "default") + test_context.env.set("AWS_REGION", "us-east-1") ci_workflows.run_vendored_host_checks(repo_root) @@ -55,7 +53,7 @@ def fake_run_checked( assert npm_ci_env["PATH"].startswith(str(Path(install_command[3]) / "node_modules" / ".bin")) -def test_wait_for_qdrant_retries_until_ready(monkeypatch: pytest.MonkeyPatch) -> None: +def test_wait_for_qdrant_retries_until_ready(test_context: TestContext) -> None: """Qdrant readiness should retry transient probe failures.""" attempts: list[str] = [] sleeps: list[float] = [] @@ -77,8 +75,12 @@ def fake_urlopen(url: str, timeout: int) -> _Response: raise OSError("not ready") return _Response() - monkeypatch.setattr(memory_plugin_helpers.urllib.request, "urlopen", fake_urlopen) - monkeypatch.setattr(memory_plugin_helpers.time, "sleep", sleeps.append) + test_context.patch.patch_object( + memory_plugin_helpers.urllib.request, + "urlopen", + new=fake_urlopen, + ) + test_context.patch.patch_object(memory_plugin_helpers.time, "sleep", new=sleeps.append) ci_workflows.wait_for_qdrant("http://127.0.0.1:6333/healthz", attempts=4, sleep_seconds=1.5) diff --git a/tests/suites/unit/ci/test_release_workflow.py b/tests/suites/unit/ci/test_release_workflow.py index 23be4d68..21e27cbf 100644 --- a/tests/suites/unit/ci/test_release_workflow.py +++ b/tests/suites/unit/ci/test_release_workflow.py @@ -5,8 +5,6 @@ from pathlib import Path from typing import Any -import pytest - from tests.plugins.infrastructure.context import TestContext from tests.utils.helpers import ci_workflows from tests.utils.helpers._ci_workflows import release as release_helpers @@ -28,7 +26,7 @@ def test_clean_artifact_directories_removes_paths(tmp_path: Path) -> None: def test_verify_release_artifacts_runs_twine_and_smoke_tests( - monkeypatch: pytest.MonkeyPatch, + test_context: TestContext, tmp_path: Path, ) -> None: """Release verification should check artifacts and install both wheel and sdist.""" @@ -52,7 +50,7 @@ def fake_run_checked( seen_commands.append(command) return None - monkeypatch.setattr(release_helpers, "run_checked", fake_run_checked) + test_context.patch.patch_object(release_helpers, "run_checked", new=fake_run_checked) ci_workflows.verify_release_artifacts(dist_dir) @@ -66,7 +64,7 @@ def fake_run_checked( def test_publish_github_release_creates_when_missing( - monkeypatch: pytest.MonkeyPatch, + test_context: TestContext, tmp_path: Path, ) -> None: """Release publishing should create the release when it does not exist yet.""" @@ -91,7 +89,7 @@ def fake_run_checked( raise ci_workflows.CiWorkflowError("missing release") return None - monkeypatch.setattr(release_helpers, "run_checked", fake_run_checked) + test_context.patch.patch_object(release_helpers, "run_checked", new=fake_run_checked) ci_workflows.publish_github_release("v1.0.0", dist_dir, sbom_path) diff --git a/tests/suites/unit/clawops/assets/test_runtime_assets.py b/tests/suites/unit/clawops/assets/test_runtime_assets.py index acb91b48..84dd1682 100644 --- a/tests/suites/unit/clawops/assets/test_runtime_assets.py +++ b/tests/suites/unit/clawops/assets/test_runtime_assets.py @@ -4,17 +4,16 @@ import pathlib -import pytest - from clawops.runtime_assets import PACKAGED_ASSET_ROOT, resolve_asset_path, resolve_runtime_layout +from tests.plugins.infrastructure.context import TestContext from tests.utils.helpers.repo import REPO_ROOT def test_runtime_layout_uses_packaged_assets_outside_source_checkout( tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, + test_context: TestContext, ) -> None: - monkeypatch.chdir(tmp_path) + test_context.chdir(tmp_path) layout = resolve_runtime_layout(home_dir=tmp_path / "home") diff --git a/tests/suites/unit/clawops/cli/test_root_defaults.py b/tests/suites/unit/clawops/cli/test_root_defaults.py index 4adae3fc..49c2ae2e 100644 --- a/tests/suites/unit/clawops/cli/test_root_defaults.py +++ b/tests/suites/unit/clawops/cli/test_root_defaults.py @@ -13,6 +13,7 @@ import clawops.openclaw_config as openclaw_config import clawops.strongclaw_baseline as strongclaw_baseline from clawops.root_detection import resolve_project_root, resolve_strongclaw_repo_root +from tests.plugins.infrastructure.context import TestContext def _init_strongclaw_repo(repo_root: pathlib.Path) -> pathlib.Path: @@ -51,8 +52,8 @@ def test_resolve_project_root_prefers_the_nearest_git_ancestor(tmp_path: pathlib def test_render_openclaw_config_main_infers_repo_root_from_cwd( tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], + test_context: TestContext, ) -> None: repo_root = _init_strongclaw_repo(tmp_path / "repo") nested = repo_root / "platform" / "docs" @@ -82,12 +83,16 @@ def _materialize_runtime_memory_configs( del home_dir, user_timezone return repo_root / "managed-memory.yaml", repo_root / "managed-memory.sqlite.yaml" - monkeypatch.chdir(nested) - monkeypatch.setattr(openclaw_config, "render_openclaw_profile", _render_openclaw_profile) - monkeypatch.setattr( + test_context.chdir(nested) + test_context.patch.patch_object( + openclaw_config, + "render_openclaw_profile", + new=_render_openclaw_profile, + ) + test_context.patch.patch_object( openclaw_config, "materialize_runtime_memory_configs", - _materialize_runtime_memory_configs, + new=_materialize_runtime_memory_configs, ) exit_code = openclaw_config.main(["--profile", "hypermemory", "--output", str(output_path)]) @@ -100,8 +105,8 @@ def _materialize_runtime_memory_configs( def test_strongclaw_baseline_infers_repo_root_and_runs_dir_from_cwd( tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], + test_context: TestContext, ) -> None: repo_root = _init_strongclaw_repo(tmp_path / "repo") nested = repo_root / "src" / "clawops" @@ -117,8 +122,8 @@ def _verify_baseline( recorded_runs_dir = runs_dir return {"ok": True, "runsDir": str(runs_dir)} - monkeypatch.chdir(nested) - monkeypatch.setattr(strongclaw_baseline, "verify_baseline", _verify_baseline) + test_context.chdir(nested) + test_context.patch.patch_object(strongclaw_baseline, "verify_baseline", new=_verify_baseline) exit_code = strongclaw_baseline.main(["verify"]) @@ -130,7 +135,7 @@ def _verify_baseline( def test_acp_runner_infers_project_root_without_explicit_flag( tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, + test_context: TestContext, ) -> None: project_root = tmp_path / "project" (project_root / ".git").mkdir(parents=True) @@ -140,7 +145,7 @@ def test_acp_runner_infers_project_root_without_explicit_flag( workspace.mkdir() state_dir = tmp_path / "state" - monkeypatch.chdir(nested) + test_context.chdir(nested) args = acp_runner.parse_args( [ diff --git a/tests/suites/unit/clawops/cli/test_root_flag_aliases.py b/tests/suites/unit/clawops/cli/test_root_flag_aliases.py index 75b948d8..d5c8bf84 100644 --- a/tests/suites/unit/clawops/cli/test_root_flag_aliases.py +++ b/tests/suites/unit/clawops/cli/test_root_flag_aliases.py @@ -17,6 +17,7 @@ resolve_source_root_argument, warn_ignored_repo_root_argument, ) +from tests.plugins.infrastructure.context import TestContext def _init_source_checkout(root: pathlib.Path) -> pathlib.Path: @@ -64,11 +65,11 @@ def test_project_root_legacy_repo_root_alias_warns( def test_source_root_requires_source_root_guidance_when_not_discoverable( tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, + test_context: TestContext, ) -> None: parser = argparse.ArgumentParser() add_source_root_argument(parser) - monkeypatch.chdir(tmp_path) + test_context.chdir(tmp_path) with pytest.raises(FileNotFoundError, match="pass --source-root explicitly\\."): resolve_source_root_argument(parser.parse_args([]), command_name="clawops baseline") diff --git a/tests/suites/integration/clawops/test_setup_runtime_boundaries.py b/tests/suites/unit/clawops/cli/test_setup_runtime_boundaries.py similarity index 71% rename from tests/suites/integration/clawops/test_setup_runtime_boundaries.py rename to tests/suites/unit/clawops/cli/test_setup_runtime_boundaries.py index ff2fd96b..96b12a6f 100644 --- a/tests/suites/integration/clawops/test_setup_runtime_boundaries.py +++ b/tests/suites/unit/clawops/cli/test_setup_runtime_boundaries.py @@ -1,4 +1,4 @@ -"""Integration coverage for setup/doctor runtime-boundary behavior.""" +"""Unit coverage for setup/doctor runtime-boundary behavior.""" from __future__ import annotations @@ -8,11 +8,12 @@ from clawops import cli as root_cli from clawops import setup_cli +from tests.plugins.infrastructure.context import TestContext def test_root_cli_setup_render_only_path_skips_model_auth( - monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, + test_context: TestContext, ) -> None: """The public CLI should not require model auth on a render-only setup path.""" calls: list[str] = [] @@ -74,13 +75,17 @@ def _render_service_files(repo_root: pathlib.Path) -> dict[str, object]: calls.append("services-render") return {"ok": True} - monkeypatch.setattr(setup_cli, "bootstrap_state_ready", _bootstrap_state_ready) - monkeypatch.setattr(setup_cli, "install_profile_assets", _install_profile_assets) - monkeypatch.setattr(setup_cli, "configure_varlock_env", _configure_varlock_env) - monkeypatch.setattr(setup_cli, "_render_openclaw_config", _render_openclaw_config) - monkeypatch.setattr(setup_cli, "_doctor_host_payload", _doctor_host_payload) - monkeypatch.setattr(setup_cli, "ensure_model_auth", _ensure_model_auth) - monkeypatch.setattr(setup_cli, "render_service_files", _render_service_files) + test_context.patch.patch_object(setup_cli, "bootstrap_state_ready", new=_bootstrap_state_ready) + test_context.patch.patch_object( + setup_cli, "install_profile_assets", new=_install_profile_assets + ) + test_context.patch.patch_object(setup_cli, "configure_varlock_env", new=_configure_varlock_env) + test_context.patch.patch_object( + setup_cli, "_render_openclaw_config", new=_render_openclaw_config + ) + test_context.patch.patch_object(setup_cli, "_doctor_host_payload", new=_doctor_host_payload) + test_context.patch.patch_object(setup_cli, "ensure_model_auth", new=_ensure_model_auth) + test_context.patch.patch_object(setup_cli, "render_service_files", new=_render_service_files) exit_code = root_cli.main(["setup", "--asset-root", str(tmp_path), "--no-activate-services"]) @@ -95,9 +100,9 @@ def _render_service_files(repo_root: pathlib.Path) -> dict[str, object]: def test_root_cli_doctor_bounded_path_skips_openclaw_runtime_audits( - monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str], + test_context: TestContext, ) -> None: """The public CLI should keep bounded doctor local when both skip flags are set.""" @@ -153,13 +158,15 @@ def _verify_channels(**kwargs: object) -> _OkReport: del kwargs return _OkReport() - monkeypatch.setattr(setup_cli, "configure_varlock_env", _configure_varlock_env) - monkeypatch.setattr(setup_cli, "_doctor_host_payload", _doctor_host_payload) - monkeypatch.setattr(setup_cli, "_require_model_check_ok", _require_model_check_ok) - monkeypatch.setattr(setup_cli, "run_openclaw_command", _run_openclaw_command) - monkeypatch.setattr(setup_cli, "verify_sidecars", _verify_sidecars) - monkeypatch.setattr(setup_cli, "verify_observability", _verify_observability) - monkeypatch.setattr(setup_cli, "verify_channels", _verify_channels) + test_context.patch.patch_object(setup_cli, "configure_varlock_env", new=_configure_varlock_env) + test_context.patch.patch_object(setup_cli, "_doctor_host_payload", new=_doctor_host_payload) + test_context.patch.patch_object( + setup_cli, "_require_model_check_ok", new=_require_model_check_ok + ) + test_context.patch.patch_object(setup_cli, "run_openclaw_command", new=_run_openclaw_command) + test_context.patch.patch_object(setup_cli, "verify_sidecars", new=_verify_sidecars) + test_context.patch.patch_object(setup_cli, "verify_observability", new=_verify_observability) + test_context.patch.patch_object(setup_cli, "verify_channels", new=_verify_channels) exit_code = root_cli.main( ["doctor", "--asset-root", str(tmp_path), "--skip-runtime", "--no-model-probe"] diff --git a/tests/suites/unit/clawops/context/test_codebase_graph.py b/tests/suites/unit/clawops/context/test_codebase_graph.py index 27d82180..e03e65fd 100644 --- a/tests/suites/unit/clawops/context/test_codebase_graph.py +++ b/tests/suites/unit/clawops/context/test_codebase_graph.py @@ -13,6 +13,7 @@ normalize_neo4j_driver_url, service_from_config, ) +from tests.plugins.infrastructure.context import TestContext def test_large_scale_requires_healthy_neo4j_even_when_fallback_is_allowed( @@ -146,7 +147,7 @@ def test_medium_scale_symbol_graph_expands_related_files( def test_neo4j_neighbors_use_literal_validated_depth( - monkeypatch: pytest.MonkeyPatch, + test_context: TestContext, ) -> None: backend = Neo4jGraphBackend(GraphConfig()) recorded_query = "" @@ -162,7 +163,7 @@ def _fake_run_query( recorded_parameters = parameters return [{"id": "neighbor-1"}] - monkeypatch.setattr(backend, "_run_query", _fake_run_query) + test_context.patch.patch_object(backend, "_run_query", new=_fake_run_query) neighbors = backend.neighbors( node_id="symbol:provider.rotate_token", diff --git a/tests/suites/unit/clawops/context/test_codebase_hybrid.py b/tests/suites/unit/clawops/context/test_codebase_hybrid.py index 24fc1620..45168991 100644 --- a/tests/suites/unit/clawops/context/test_codebase_hybrid.py +++ b/tests/suites/unit/clawops/context/test_codebase_hybrid.py @@ -6,13 +6,13 @@ import uuid from collections.abc import Sequence -import pytest import requests from clawops.common import write_yaml from clawops.context.codebase.service import CodebaseContextService, service_from_config from clawops.hypermemory.contracts import SparseVectorPayload, VectorPoint from clawops.hypermemory.models import DenseSearchCandidate, RerankResponse, SparseSearchCandidate +from tests.plugins.infrastructure.context import TestContext class _FakeEmbeddingProvider: @@ -258,7 +258,7 @@ def test_medium_scale_worker_syncs_chunk_vectors_when_hybrid_enabled( def test_medium_scale_worker_skips_sparse_rebuild_for_unchanged_warm_consolidation( tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, + test_context: TestContext, ) -> None: repo, service = _build_service(tmp_path) (repo / "auth.py").write_text( @@ -283,9 +283,9 @@ def _unexpected_sparse_rebuild(*args: object, **kwargs: object) -> object: del args, kwargs raise AssertionError("warm consolidation should reuse the persisted sparse state") - monkeypatch.setattr( + test_context.patch.patch( "clawops.context.codebase.service.build_sparse_encoder_from_documents", - _unexpected_sparse_rebuild, + new=_unexpected_sparse_rebuild, ) service.consolidate_runtime_artifacts() diff --git a/tests/suites/unit/clawops/test_strongclaw_baseline.py b/tests/suites/unit/clawops/test_strongclaw_baseline.py index f592d7ea..31a6bfab 100644 --- a/tests/suites/unit/clawops/test_strongclaw_baseline.py +++ b/tests/suites/unit/clawops/test_strongclaw_baseline.py @@ -9,6 +9,7 @@ from clawops import strongclaw_baseline from clawops.strongclaw_runtime import CommandError +from tests.plugins.infrastructure.context import TestContext class _FakeCommandResult: @@ -50,8 +51,8 @@ def _noop_harness_smoke(_repo: pathlib.Path, _runs_dir: pathlib.Path) -> None: def test_verify_baseline_uses_uv_dependency_group_for_repo_tests( - monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, + test_context: TestContext, ) -> None: repo_root = _init_source_checkout(tmp_path / "repo") config_path = tmp_path / "openclaw.json" @@ -99,29 +100,31 @@ def _run_harness_smoke(repo: pathlib.Path, runs_dir: pathlib.Path) -> None: assert repo == repo_root assert runs_dir == tmp_path / "runs" - monkeypatch.setattr(strongclaw_baseline, "require_openclaw", _require_openclaw) - monkeypatch.setattr( + test_context.patch.patch_object(strongclaw_baseline, "require_openclaw", new=_require_openclaw) + test_context.patch.patch_object( strongclaw_baseline, "resolve_openclaw_config_path", - _resolve_openclaw_config_path, + new=_resolve_openclaw_config_path, ) - monkeypatch.setattr( + test_context.patch.patch_object( strongclaw_baseline, "run_openclaw_command", - _run_openclaw_command, + new=_run_openclaw_command, ) - monkeypatch.setattr( + test_context.patch.patch_object( strongclaw_baseline, "ensure_model_auth", - _ensure_model_auth, + new=_ensure_model_auth, ) - monkeypatch.setattr( + test_context.patch.patch_object( strongclaw_baseline, "rendered_openclaw_uses_hypermemory", - _rendered_openclaw_uses_hypermemory, + new=_rendered_openclaw_uses_hypermemory, + ) + test_context.patch.patch_object(strongclaw_baseline, "run_command", new=_run_command) + test_context.patch.patch_object( + strongclaw_baseline, "run_harness_smoke", new=_run_harness_smoke ) - monkeypatch.setattr(strongclaw_baseline, "run_command", _run_command) - monkeypatch.setattr(strongclaw_baseline, "run_harness_smoke", _run_harness_smoke) payload = strongclaw_baseline.verify_baseline(repo_root, runs_dir=tmp_path / "runs") @@ -134,8 +137,8 @@ def _run_harness_smoke(repo: pathlib.Path, runs_dir: pathlib.Path) -> None: def test_verify_baseline_surfaces_repo_test_failure_detail( - monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, + test_context: TestContext, ) -> None: repo_root = _init_source_checkout(tmp_path / "repo") config_path = tmp_path / "openclaw.json" @@ -178,29 +181,31 @@ def _run_command( return _FakeCommandResult(ok=False, stderr="repo tests failed") return _FakeCommandResult(ok=True) - monkeypatch.setattr(strongclaw_baseline, "require_openclaw", _require_openclaw) - monkeypatch.setattr( + test_context.patch.patch_object(strongclaw_baseline, "require_openclaw", new=_require_openclaw) + test_context.patch.patch_object( strongclaw_baseline, "resolve_openclaw_config_path", - _resolve_openclaw_config_path, + new=_resolve_openclaw_config_path, ) - monkeypatch.setattr( + test_context.patch.patch_object( strongclaw_baseline, "run_openclaw_command", - _run_openclaw_command, + new=_run_openclaw_command, ) - monkeypatch.setattr( + test_context.patch.patch_object( strongclaw_baseline, "ensure_model_auth", - _ensure_model_auth, + new=_ensure_model_auth, ) - monkeypatch.setattr( + test_context.patch.patch_object( strongclaw_baseline, "rendered_openclaw_uses_hypermemory", - _rendered_openclaw_uses_hypermemory, + new=_rendered_openclaw_uses_hypermemory, + ) + test_context.patch.patch_object(strongclaw_baseline, "run_command", new=_run_command) + test_context.patch.patch_object( + strongclaw_baseline, "run_harness_smoke", new=_noop_harness_smoke ) - monkeypatch.setattr(strongclaw_baseline, "run_command", _run_command) - monkeypatch.setattr(strongclaw_baseline, "run_harness_smoke", _noop_harness_smoke) with pytest.raises(CommandError, match="repo tests failed"): strongclaw_baseline.verify_baseline(repo_root, runs_dir=tmp_path / "runs") diff --git a/tests/suites/unit/clawops/test_strongclaw_runtime.py b/tests/suites/unit/clawops/test_strongclaw_runtime.py index 30b0449c..99913756 100644 --- a/tests/suites/unit/clawops/test_strongclaw_runtime.py +++ b/tests/suites/unit/clawops/test_strongclaw_runtime.py @@ -8,6 +8,7 @@ import clawops.strongclaw_runtime as runtime from clawops.app_paths import strongclaw_varlock_dir from clawops.strongclaw_runtime import CommandError, ExecResult, write_env_assignments +from tests.plugins.infrastructure.context import TestContext def test_write_env_assignments_uses_owner_only_permissions(tmp_path: pathlib.Path) -> None: @@ -21,7 +22,7 @@ def test_write_env_assignments_uses_owner_only_permissions(tmp_path: pathlib.Pat def test_docker_backend_diagnostics_capture_runtime_context( - monkeypatch: pytest.MonkeyPatch, + test_context: TestContext, ) -> None: def _fake_run_command(argv: list[str], timeout_seconds: int = 15) -> ExecResult: if argv == ["docker", "context", "show"]: @@ -36,11 +37,15 @@ def _fake_run_command(argv: list[str], timeout_seconds: int = 15) -> ExecResult: ) raise AssertionError(argv) - monkeypatch.setattr(runtime, "docker_cli_installed", lambda: True) - monkeypatch.setattr(runtime, "docker_compose_available", lambda: True) - monkeypatch.setattr(runtime, "detect_docker_runtime_provider", lambda: "OrbStack") - monkeypatch.setattr(runtime, "run_command", _fake_run_command) - monkeypatch.setenv("DOCKER_HOST", "unix:///Users/test/.orbstack/run/docker.sock") + test_context.patch.patch_object(runtime, "docker_cli_installed", new=lambda: True) + test_context.patch.patch_object(runtime, "docker_compose_available", new=lambda: True) + test_context.patch.patch_object( + runtime, + "detect_docker_runtime_provider", + new=lambda: "OrbStack", + ) + test_context.patch.patch_object(runtime, "run_command", new=_fake_run_command) + test_context.env.set("DOCKER_HOST", "unix:///Users/test/.orbstack/run/docker.sock") diagnostics = runtime.docker_backend_diagnostics() @@ -52,7 +57,7 @@ def _fake_run_command(argv: list[str], timeout_seconds: int = 15) -> ExecResult: def test_ensure_docker_backend_ready_surfaces_runtime_details( - monkeypatch: pytest.MonkeyPatch, + test_context: TestContext, ) -> None: def _fake_run_command(argv: list[str], timeout_seconds: int = 15) -> ExecResult: if argv == ["docker", "context", "show"]: @@ -67,12 +72,16 @@ def _fake_run_command(argv: list[str], timeout_seconds: int = 15) -> ExecResult: ) raise AssertionError(argv) - monkeypatch.setattr(runtime, "docker_cli_installed", lambda: True) - monkeypatch.setattr(runtime, "docker_compose_available", lambda: True) - monkeypatch.setattr(runtime, "detect_docker_runtime_provider", lambda: "OrbStack") - monkeypatch.setattr(runtime, "run_command", _fake_run_command) - monkeypatch.setattr(runtime, "load_docker_refresh_state", lambda: None) - monkeypatch.setenv("DOCKER_HOST", "unix:///Users/test/.orbstack/run/docker.sock") + test_context.patch.patch_object(runtime, "docker_cli_installed", new=lambda: True) + test_context.patch.patch_object(runtime, "docker_compose_available", new=lambda: True) + test_context.patch.patch_object( + runtime, + "detect_docker_runtime_provider", + new=lambda: "OrbStack", + ) + test_context.patch.patch_object(runtime, "run_command", new=_fake_run_command) + test_context.patch.patch_object(runtime, "load_docker_refresh_state", new=lambda: None) + test_context.env.set("DOCKER_HOST", "unix:///Users/test/.orbstack/run/docker.sock") with pytest.raises(CommandError) as exc_info: runtime.ensure_docker_backend_ready() @@ -85,7 +94,7 @@ def _fake_run_command(argv: list[str], timeout_seconds: int = 15) -> ExecResult: def test_varlock_env_dir_defaults_to_managed_config_root( tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, + test_context: TestContext, ) -> None: repo_root = tmp_path / "assets" asset_dir = repo_root / "platform" / "configs" / "varlock" @@ -95,7 +104,7 @@ def test_varlock_env_dir_defaults_to_managed_config_root( (asset_dir / ".env.ci.example").write_text("APP_ENV=ci\n", encoding="utf-8") (asset_dir / ".env.prod.example").write_text("APP_ENV=prod\n", encoding="utf-8") managed_root = tmp_path / "config-root" - monkeypatch.setenv("STRONGCLAW_CONFIG_DIR", str(managed_root)) + test_context.env.set("STRONGCLAW_CONFIG_DIR", str(managed_root)) legacy_dir = repo_root / "platform" / "configs" / "varlock" expected = runtime.varlock_env_dir(repo_root) diff --git a/tests/suites/unit/clawops/test_strongclaw_varlock_env.py b/tests/suites/unit/clawops/test_strongclaw_varlock_env.py index 0b9ca632..1d9ee98e 100644 --- a/tests/suites/unit/clawops/test_strongclaw_varlock_env.py +++ b/tests/suites/unit/clawops/test_strongclaw_varlock_env.py @@ -4,8 +4,6 @@ import pathlib -import pytest - from clawops.strongclaw_runtime import ( load_env_assignments, varlock_env_template_file, @@ -44,10 +42,9 @@ def _varlock_validation_success( def test_ensure_required_defaults_generates_neo4j_credentials( tmp_path: pathlib.Path, test_context: TestContext, - monkeypatch: pytest.MonkeyPatch, ) -> None: local_env_file = tmp_path / ".env.local" - monkeypatch.setenv("VARLOCK_LOCAL_ENV_FILE", str(local_env_file)) + test_context.env.set("VARLOCK_LOCAL_ENV_FILE", str(local_env_file)) secrets = iter( ( "gateway-secret-value", diff --git a/tests/suites/unit/testing/test_qdrant_runtime.py b/tests/suites/unit/testing/test_qdrant_runtime.py index 6094aa97..93ddd3ee 100644 --- a/tests/suites/unit/testing/test_qdrant_runtime.py +++ b/tests/suites/unit/testing/test_qdrant_runtime.py @@ -6,6 +6,7 @@ import pytest +from tests.plugins.infrastructure.context import TestContext from tests.utils.helpers.qdrant_runtime import ( DEFAULT_QDRANT_IMAGE, QDRANT_IMAGE_ENV, @@ -32,7 +33,7 @@ def test_default_qdrant_image_uses_pinned_ghcr_mirror() -> None: def test_require_live_url_uses_repo_pinned_image_by_default( - monkeypatch: pytest.MonkeyPatch, + test_context: TestContext, ) -> None: """Real-mode runtimes should default to the repo-pinned Qdrant image.""" commands: list[list[str]] = [] @@ -48,10 +49,13 @@ def _fake_run( commands.append(command) return subprocess.CompletedProcess(command, 0, stdout="container-id", stderr="") - monkeypatch.setattr("tests.utils.helpers.qdrant_runtime.shutil.which", _docker_path) - monkeypatch.setattr("tests.utils.helpers.qdrant_runtime._reserve_local_port", lambda: 46333) - monkeypatch.setattr("tests.utils.helpers.qdrant_runtime._wait_for_qdrant", _noop_wait) - monkeypatch.setattr("tests.utils.helpers.qdrant_runtime.subprocess.run", _fake_run) + test_context.patch.patch("tests.utils.helpers.qdrant_runtime.shutil.which", new=_docker_path) + test_context.patch.patch( + "tests.utils.helpers.qdrant_runtime._reserve_local_port", + new=lambda: 46333, + ) + test_context.patch.patch("tests.utils.helpers.qdrant_runtime._wait_for_qdrant", new=_noop_wait) + test_context.patch.patch("tests.utils.helpers.qdrant_runtime.subprocess.run", new=_fake_run) runtime = QdrantRuntime(context=None, mode="real") live_url = runtime.require_live_url() @@ -63,7 +67,7 @@ def _fake_run( def test_require_live_url_honors_configured_image_override( - monkeypatch: pytest.MonkeyPatch, + test_context: TestContext, ) -> None: """Real-mode runtimes should honor an explicit image override.""" commands: list[list[str]] = [] @@ -79,11 +83,14 @@ def _fake_run( commands.append(command) return subprocess.CompletedProcess(command, 0, stdout="container-id", stderr="") - monkeypatch.setenv(QDRANT_IMAGE_ENV, "ghcr.io/example/qdrant:test") - monkeypatch.setattr("tests.utils.helpers.qdrant_runtime.shutil.which", _docker_path) - monkeypatch.setattr("tests.utils.helpers.qdrant_runtime._reserve_local_port", lambda: 46333) - monkeypatch.setattr("tests.utils.helpers.qdrant_runtime._wait_for_qdrant", _noop_wait) - monkeypatch.setattr("tests.utils.helpers.qdrant_runtime.subprocess.run", _fake_run) + test_context.env.set(QDRANT_IMAGE_ENV, "ghcr.io/example/qdrant:test") + test_context.patch.patch("tests.utils.helpers.qdrant_runtime.shutil.which", new=_docker_path) + test_context.patch.patch( + "tests.utils.helpers.qdrant_runtime._reserve_local_port", + new=lambda: 46333, + ) + test_context.patch.patch("tests.utils.helpers.qdrant_runtime._wait_for_qdrant", new=_noop_wait) + test_context.patch.patch("tests.utils.helpers.qdrant_runtime.subprocess.run", new=_fake_run) runtime = QdrantRuntime(context=None, mode="real") runtime.require_live_url() @@ -93,7 +100,7 @@ def _fake_run( def test_require_live_url_skips_when_registry_access_fails( - monkeypatch: pytest.MonkeyPatch, + test_context: TestContext, ) -> None: """Real-mode runtimes should skip when the registry is temporarily unavailable.""" @@ -116,9 +123,12 @@ def _fake_run( ), ) - monkeypatch.setattr("tests.utils.helpers.qdrant_runtime.shutil.which", _docker_path) - monkeypatch.setattr("tests.utils.helpers.qdrant_runtime._reserve_local_port", lambda: 46333) - monkeypatch.setattr("tests.utils.helpers.qdrant_runtime.subprocess.run", _fake_run) + test_context.patch.patch("tests.utils.helpers.qdrant_runtime.shutil.which", new=_docker_path) + test_context.patch.patch( + "tests.utils.helpers.qdrant_runtime._reserve_local_port", + new=lambda: 46333, + ) + test_context.patch.patch("tests.utils.helpers.qdrant_runtime.subprocess.run", new=_fake_run) runtime = QdrantRuntime(context=None, mode="real") @@ -127,7 +137,7 @@ def _fake_run( def test_require_live_url_fails_on_non_registry_docker_errors( - monkeypatch: pytest.MonkeyPatch, + test_context: TestContext, ) -> None: """Real-mode runtimes should still fail for ordinary docker runtime errors.""" @@ -146,9 +156,12 @@ def _fake_run( stderr="docker: Error response from daemon: driver failed programming external connectivity", ) - monkeypatch.setattr("tests.utils.helpers.qdrant_runtime.shutil.which", _docker_path) - monkeypatch.setattr("tests.utils.helpers.qdrant_runtime._reserve_local_port", lambda: 46333) - monkeypatch.setattr("tests.utils.helpers.qdrant_runtime.subprocess.run", _fake_run) + test_context.patch.patch("tests.utils.helpers.qdrant_runtime.shutil.which", new=_docker_path) + test_context.patch.patch( + "tests.utils.helpers.qdrant_runtime._reserve_local_port", + new=lambda: 46333, + ) + test_context.patch.patch("tests.utils.helpers.qdrant_runtime.subprocess.run", new=_fake_run) runtime = QdrantRuntime(context=None, mode="real") diff --git a/tests/suites/unit/testing/test_test_context.py b/tests/suites/unit/testing/test_test_context.py index 4c76de6f..aa5ecb66 100644 --- a/tests/suites/unit/testing/test_test_context.py +++ b/tests/suites/unit/testing/test_test_context.py @@ -3,6 +3,8 @@ from __future__ import annotations import logging +import os +import pathlib import pytest @@ -185,3 +187,21 @@ def test_notes_dict_available_for_diagnostics() -> None: ctx = TestContext() ctx.notes["scope"] = "diagnostic" assert ctx.notes["scope"] == "diagnostic" + + +def test_chdir_restores_original_working_directory(tmp_path: pathlib.Path) -> None: + ctx = TestContext() + original = pathlib.Path.cwd() + target = tmp_path / "workspace" + target.mkdir() + + try: + ctx.chdir(target) + + assert pathlib.Path.cwd() == target + + ctx.cleanup_all() + + assert pathlib.Path.cwd() == original + finally: + os.chdir(original) diff --git a/tests/utils/scripts/analyze_fixtures.py b/tests/utils/scripts/analyze_fixtures.py index 50a7c2dd..0a9e1de1 100644 --- a/tests/utils/scripts/analyze_fixtures.py +++ b/tests/utils/scripts/analyze_fixtures.py @@ -38,7 +38,7 @@ def _test_source_files(repo_root: Path) -> list[Path]: def _is_direct_monkeypatch_call(node: ast.Call) -> bool: if not isinstance(node.func, ast.Attribute): return False - if node.func.attr not in {"delenv", "setattr", "setenv"}: + if node.func.attr not in {"chdir", "delenv", "setattr", "setenv"}: return False return isinstance(node.func.value, ast.Name) and node.func.value.id == "monkeypatch"