diff --git a/.gitignore b/.gitignore index 94adf28..895fe89 100644 --- a/.gitignore +++ b/.gitignore @@ -39,18 +39,6 @@ pip-delete-this-directory.txt .pydevproject .settings/ -# Testing -.tox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -.hypothesis/ -.pytest_cache/ -htmlcov/ - # FastAPI .env.local .env.development.local diff --git a/Makefile b/Makefile index 5e599a0..d9bdd27 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install dev-install format lint type-check test test-cov clean pre-commit setup-dev +.PHONY: help install dev-install format lint type-check security check-all clean pre-commit setup-dev dev help: @echo "Available commands:" @@ -13,10 +13,6 @@ help: @echo " security - Run security checks with bandit" @echo " check-all - Run all code quality checks" @echo "" - @echo "Testing:" - @echo " test - Run tests with pytest" - @echo " test-cov - Run tests with coverage reporting" - @echo "" @echo "Development:" @echo " pre-commit - Run pre-commit hooks on all files" @echo " clean - Clean up cache files and artifacts" @@ -59,17 +55,6 @@ security: check-all: format lint type-check security @echo "โœ… All code quality checks passed!" -test: - @echo "๐Ÿงช Running tests..." - uv run pytest -v - @echo "โœ… Tests complete!" - -test-cov: - @echo "๐Ÿงช Running tests with coverage..." - uv run pytest -v --cov=strix --cov-report=term-missing --cov-report=html - @echo "โœ… Tests with coverage complete!" - @echo "๐Ÿ“Š Coverage report generated in htmlcov/" - pre-commit: @echo "๐Ÿ”ง Running pre-commit hooks..." uv run pre-commit run --all-files @@ -78,13 +63,10 @@ pre-commit: clean: @echo "๐Ÿงน Cleaning up cache files..." find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true - find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true find . -type d -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true find . -type d -name ".ruff_cache" -exec rm -rf {} + 2>/dev/null || true - find . -type d -name "htmlcov" -exec rm -rf {} + 2>/dev/null || true find . -name "*.pyc" -delete 2>/dev/null || true - find . -name ".coverage" -delete 2>/dev/null || true @echo "โœ… Cleanup complete!" -dev: format lint type-check test +dev: format lint type-check @echo "โœ… Development cycle complete!" diff --git a/pyproject.toml b/pyproject.toml index c618315..0332c1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,10 +71,6 @@ dev = [ "pyright>=1.1.401", "pylint>=3.3.7", "bandit>=1.8.3", - "pytest>=8.4.0", - "pytest-asyncio>=1.0.0", - "pytest-cov>=6.1.1", - "pytest-mock>=3.14.1", "pre-commit>=4.2.0", "black>=25.1.0", "isort>=6.0.1", @@ -131,7 +127,6 @@ module = [ "textual.*", "pyte.*", "libtmux.*", - "pytest.*", "cvss.*", "opentelemetry.*", "scrubadub.*", @@ -140,23 +135,6 @@ module = [ ] ignore_missing_imports = true -# Relax strict rules for test files (pytest decorators are not fully typed) -[[tool.mypy.overrides]] -module = ["tests.*"] -disallow_untyped_decorators = false -disallow_untyped_defs = false -# Test fixtures often build raw dicts that match SDK TypedDict unions -# structurally; type:ignore-per-line would be very noisy. -disable_error_code = [ - "typeddict-item", # TypedDict key access on union variants - "typeddict-unknown-key", - "type-arg", # generic params on dict/MagicMock in test helpers - "no-untyped-call", # SDK has untyped __init__ in some places - "no-any-return", # test helpers often return Any from typed call_args - "arg-type", # fakes pass mocks where SDK wants strict types - "attr-defined", # fake objects monkey-patch attrs -] - # ============================================================================ # Ruff Configuration (Fast Python Linter & Formatter) # ============================================================================ @@ -167,7 +145,6 @@ line-length = 100 extend-exclude = [ ".git", ".mypy_cache", - ".pytest_cache", ".ruff_cache", "__pycache__", "build", @@ -203,7 +180,6 @@ select = [ "PIE", # flake8-pie "T20", # flake8-print "PYI", # flake8-pyi - "PT", # flake8-pytest-style "Q", # flake8-quotes "RSE", # flake8-raise "RET", # flake8-return @@ -246,17 +222,6 @@ ignore = [ "strix/tools/notes/tools.py" = ["PLC0415", "TC002"] "strix/tools/finish/tool.py" = ["PLC0415", "TC002"] "strix/tools/reporting/tool.py" = ["PLC0415", "TC002"] -"tests/**/*.py" = [ - "S105", # Possible hardcoded password (string literal) - "S106", # Possible hardcoded password (function call) - "S108", # Possible insecure usage of temporary file/directory - "ARG001", # Unused function argument - "ARG002", # Unused method argument (test helpers / fakes mirror SDK signatures) - "PLR2004", # Magic value used in comparison - "PT018", # Multi-part assertions are a pytest style preference - "TC002", # Type-only third-party import (tests import for runtime instantiation) - "TC003", # Type-only stdlib import -] "strix/tools/**/*.py" = [ "ARG001", # Unused function argument (tools may have unused args for interface consistency) ] @@ -409,53 +374,11 @@ ensure_newline_before_comments = true known_first_party = ["strix"] known_third_party = ["fastapi", "pydantic", "litellm"] -# ============================================================================ -# Pytest Configuration -# ============================================================================ - -[tool.pytest.ini_options] -minversion = "6.0" -addopts = [ - "--strict-markers", - "--strict-config", - "--cov=strix", - "--cov-report=term-missing", - "--cov-report=html", - "--cov-report=xml", -] -testpaths = ["tests"] -python_files = ["test_*.py", "*_test.py"] -python_functions = ["test_*"] -python_classes = ["Test*"] -asyncio_mode = "auto" - -[tool.coverage.run] -source = ["strix"] -omit = [ - "*/tests/*", - "*/migrations/*", - "*/__pycache__/*" -] - -[tool.coverage.report] -exclude_lines = [ - "pragma: no cover", - "def __repr__", - "if self.debug:", - "if settings.DEBUG", - "raise AssertionError", - "raise NotImplementedError", - "if 0:", - "if __name__ == .__main__.:", - "class .*\\bProtocol\\):", - "@(abc\\.)?abstractmethod", -] - # ============================================================================ # Bandit Configuration (Security Linting) # ============================================================================ [tool.bandit] -exclude_dirs = ["tests", "docs", "build", "dist"] +exclude_dirs = ["docs", "build", "dist"] skips = ["B101", "B601", "B404", "B603", "B607"] # Skip assert, shell injection, subprocess import and partial path checks severity = "medium" diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index fda3372..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Strix Test Suite diff --git a/tests/agents/__init__.py b/tests/agents/__init__.py deleted file mode 100644 index 0be0f17..0000000 --- a/tests/agents/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for strix.agents module.""" diff --git a/tests/agents/test_factory.py b/tests/agents/test_factory.py deleted file mode 100644 index 1902980..0000000 --- a/tests/agents/test_factory.py +++ /dev/null @@ -1,200 +0,0 @@ -"""Phase 5 tests for the SDK agent factory + prompt renderer. - -These two modules are the keystone wiring between Phases 2-4 and an -actual ``Runner.run`` invocation. The tests verify: - -- The prompt renderer reuses the existing Jinja template (parity with - legacy LLM._load_system_prompt) and degrades gracefully when the - template isn't available. -- ``build_strix_agent(is_root=True)`` carries ``finish_scan`` and - stops on it; child agents carry ``agent_finish`` and stop on it. -- ``make_child_factory`` snapshots scan-level config into a closure - so each spawned child inherits the right scan_mode / is_whitebox / - prompt context without create_agent having to re-derive it. -""" - -from __future__ import annotations - -from typing import Any -from unittest.mock import patch - -from agents import Agent -from agents.tool import FunctionTool - -from strix.agents.factory import build_strix_agent, make_child_factory -from strix.agents.prompt import _resolve_skills, render_system_prompt - - -# --- prompt renderer ---------------------------------------------------- - - -def test_resolve_skills_deduplicates_and_orders() -> None: - out = _resolve_skills( - requested=["recon", "xss", "recon"], - scan_mode="deep", - is_whitebox=False, - ) - assert out == ["recon", "xss", "scan_modes/deep"] - - -def test_resolve_skills_adds_whitebox_pair() -> None: - out = _resolve_skills(requested=None, scan_mode="fast", is_whitebox=True) - # The whitebox pair sits at the tail; scan_modes goes in the middle - # because callers can append more skills after it via the requested arg. - assert out == [ - "scan_modes/fast", - "coordination/source_aware_whitebox", - "custom/source_aware_sast", - ] - - -def test_render_system_prompt_returns_string() -> None: - """Smoke: the StrixAgent template is on disk and renders to non-empty.""" - out = render_system_prompt(skills=[], scan_mode="deep") - assert isinstance(out, str) - # The first line of the template starts with 'You are Strix'. - assert out.startswith("You are Strix") - - -def test_render_system_prompt_swallows_template_errors() -> None: - """If the template path can't be resolved, return an empty string - (not raise) โ€” agent construction must never blow up on prompt load.""" - with patch( - "strix.agents.prompt.get_strix_resource_path", - side_effect=RuntimeError("missing"), - ): - out = render_system_prompt(skills=[]) - assert out == "" - - -# --- factory: shape + tools -------------------------------------------- - - -def test_root_agent_carries_finish_scan_and_stops_there() -> None: - agent = build_strix_agent(name="strix", is_root=True) - assert isinstance(agent, Agent) - tool_names = {t.name for t in agent.tools if isinstance(t, FunctionTool)} - assert "finish_scan" in tool_names - assert "agent_finish" not in tool_names - behavior = agent.tool_use_behavior - # StopAtTools is a TypedDict at runtime โ†’ behavior is a dict. - assert isinstance(behavior, dict) - assert behavior["stop_at_tool_names"] == ["finish_scan"] - - -def test_child_agent_carries_agent_finish_and_stops_there() -> None: - agent = build_strix_agent(name="recon-bot", is_root=False) - tool_names = {t.name for t in agent.tools if isinstance(t, FunctionTool)} - assert "agent_finish" in tool_names - assert "finish_scan" not in tool_names - behavior = agent.tool_use_behavior - assert isinstance(behavior, dict) - assert behavior["stop_at_tool_names"] == ["agent_finish"] - - -def test_root_and_child_share_base_tool_set() -> None: - """The base tool set (think/todo/notes/file_edit/web_search/etc) is - identical between root and child โ€” only the terminator differs.""" - root = build_strix_agent(is_root=True) - child = build_strix_agent(is_root=False) - root_names = {t.name for t in root.tools if isinstance(t, FunctionTool)} - child_names = {t.name for t in child.tools if isinstance(t, FunctionTool)} - # Drop the terminators and compare. - assert root_names - {"finish_scan"} == child_names - {"agent_finish"} - - -def test_agent_includes_graph_and_sandbox_tools() -> None: - """The graph + sandbox tool families are required for parity with - legacy. Spot-check the ones most likely to be forgotten in a refactor.""" - agent = build_strix_agent(is_root=True) - names = {t.name for t in agent.tools if isinstance(t, FunctionTool)} - expected = { - "think", - "create_todo", - "create_note", - "web_search", - "str_replace_editor", - "create_vulnerability_report", - "browser_action", - "terminal_execute", - "python_action", - "view_agent_graph", - "agent_status", - "send_message_to_agent", - "wait_for_message", - "create_agent", - } - missing = expected - names - assert not missing, f"missing tools: {missing}" - - -def test_agent_does_not_include_caido_tools() -> None: - """Caido tools come from CaidoCapability.tools(); the agent doesn't - declare them directly to avoid double-registration when the SDK - runtime merges capability tools.""" - agent = build_strix_agent(is_root=True) - names = {t.name for t in agent.tools if isinstance(t, FunctionTool)} - caido = { - "list_requests", - "view_request", - "send_request", - "repeat_request", - "scope_rules", - "list_sitemap", - "view_sitemap_entry", - } - overlap = names & caido - assert overlap == set(), f"unexpected Caido tools in agent.tools: {overlap}" - - -def test_agent_uses_run_config_model() -> None: - """``model=None`` so the RunConfig drives the model alias through - MultiProvider rather than an SDK default like gpt-4.1.""" - agent = build_strix_agent(is_root=True) - assert agent.model is None - - -def test_agent_instructions_contain_rendered_prompt() -> None: - """The factory must wire the rendered prompt into ``instructions``.""" - agent = build_strix_agent(is_root=True, scan_mode="deep") - assert isinstance(agent.instructions, str) - assert agent.instructions.startswith("You are Strix") - - -# --- child factory ------------------------------------------------------ - - -def test_make_child_factory_returns_callable_that_builds_child() -> None: - factory = make_child_factory(scan_mode="deep", is_whitebox=False) - assert callable(factory) - child = factory(name="sub-1", skills=["recon"]) - assert isinstance(child, Agent) - assert child.name == "sub-1" - behavior = child.tool_use_behavior - assert isinstance(behavior, dict) - assert behavior["stop_at_tool_names"] == ["agent_finish"] - - -def test_make_child_factory_passes_scan_level_config() -> None: - """Verify scan_mode + is_whitebox flow into the rendered prompt - via the closure rather than the create_agent call site.""" - captured: dict[str, Any] = {} - - def fake_render(**kwargs: Any) -> str: - captured.update(kwargs) - return "stub-prompt" - - factory = make_child_factory( - scan_mode="fast", - is_whitebox=True, - interactive=True, - system_prompt_context={"scope_source": "test"}, - ) - with patch("strix.agents.factory.render_system_prompt", side_effect=fake_render): - factory(name="child", skills=["xss"]) - - assert captured["scan_mode"] == "fast" - assert captured["is_whitebox"] is True - assert captured["interactive"] is True - assert captured["system_prompt_context"] == {"scope_source": "test"} - assert captured["skills"] == ["xss"] diff --git a/tests/config/__init__.py b/tests/config/__init__.py deleted file mode 100644 index 2edfe31..0000000 --- a/tests/config/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for strix.config module.""" diff --git a/tests/config/test_config_override.py b/tests/config/test_config_override.py deleted file mode 100644 index 51f6069..0000000 --- a/tests/config/test_config_override.py +++ /dev/null @@ -1,31 +0,0 @@ -import json -import os - -from strix.config.config import Config, apply_saved_config - - -def test_apply_config_override_clears_default_only_vars(monkeypatch, tmp_path) -> None: - from strix.interface.main import apply_config_override - - default_cfg = tmp_path / "cli-config.json" - default_cfg.write_text( - json.dumps({"env": {"LLM_API_BASE": "https://default.api", "STRIX_LLM": "default-model"}}), - encoding="utf-8", - ) - custom_cfg = tmp_path / "custom.json" - custom_cfg.write_text(json.dumps({"env": {"STRIX_LLM": "custom-model"}}), encoding="utf-8") - - monkeypatch.setattr(Config, "_config_file_override", None) - monkeypatch.setattr(Config, "_applied_from_default", {}) - monkeypatch.setattr(Config, "config_dir", classmethod(lambda cls: tmp_path)) - for var_name in Config._llm_env_vars(): - monkeypatch.delenv(var_name, raising=False) - - apply_saved_config() - - assert os.environ.get("LLM_API_BASE") == "https://default.api" - - apply_config_override(str(custom_cfg)) - - assert os.environ.get("STRIX_LLM") == "custom-model" - assert "LLM_API_BASE" not in os.environ diff --git a/tests/config/test_config_telemetry.py b/tests/config/test_config_telemetry.py deleted file mode 100644 index 89af42f..0000000 --- a/tests/config/test_config_telemetry.py +++ /dev/null @@ -1,55 +0,0 @@ -import json - -from strix.config.config import Config - - -def test_traceloop_vars_are_tracked() -> None: - tracked = Config.tracked_vars() - - assert "STRIX_OTEL_TELEMETRY" in tracked - assert "STRIX_POSTHOG_TELEMETRY" in tracked - assert "TRACELOOP_BASE_URL" in tracked - assert "TRACELOOP_API_KEY" in tracked - assert "TRACELOOP_HEADERS" in tracked - - -def test_apply_saved_uses_saved_traceloop_vars(monkeypatch, tmp_path) -> None: - config_path = tmp_path / "cli-config.json" - config_path.write_text( - json.dumps( - { - "env": { - "TRACELOOP_BASE_URL": "https://otel.example.com", - "TRACELOOP_API_KEY": "api-key", - "TRACELOOP_HEADERS": "x-test=value", - } - } - ), - encoding="utf-8", - ) - - monkeypatch.setattr(Config, "_config_file_override", config_path) - monkeypatch.delenv("TRACELOOP_BASE_URL", raising=False) - monkeypatch.delenv("TRACELOOP_API_KEY", raising=False) - monkeypatch.delenv("TRACELOOP_HEADERS", raising=False) - - applied = Config.apply_saved() - - assert applied["TRACELOOP_BASE_URL"] == "https://otel.example.com" - assert applied["TRACELOOP_API_KEY"] == "api-key" - assert applied["TRACELOOP_HEADERS"] == "x-test=value" - - -def test_apply_saved_respects_existing_env_traceloop_vars(monkeypatch, tmp_path) -> None: - config_path = tmp_path / "cli-config.json" - config_path.write_text( - json.dumps({"env": {"TRACELOOP_BASE_URL": "https://otel.example.com"}}), - encoding="utf-8", - ) - - monkeypatch.setattr(Config, "_config_file_override", config_path) - monkeypatch.setenv("TRACELOOP_BASE_URL", "https://env.example.com") - - applied = Config.apply_saved(force=False) - - assert "TRACELOOP_BASE_URL" not in applied diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index e698403..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1 +0,0 @@ -"""Pytest configuration and shared fixtures for Strix tests.""" diff --git a/tests/interface/__init__.py b/tests/interface/__init__.py deleted file mode 100644 index 9261c15..0000000 --- a/tests/interface/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for strix.interface module.""" diff --git a/tests/interface/test_diff_scope.py b/tests/interface/test_diff_scope.py deleted file mode 100644 index 9fe7dd6..0000000 --- a/tests/interface/test_diff_scope.py +++ /dev/null @@ -1,153 +0,0 @@ -import importlib.util -from pathlib import Path - -import pytest - - -def _load_utils_module(): - module_path = Path(__file__).resolve().parents[2] / "strix" / "interface" / "utils.py" - spec = importlib.util.spec_from_file_location("strix_interface_utils_test", module_path) - if spec is None or spec.loader is None: - raise RuntimeError("Failed to load strix.interface.utils for tests") - - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -utils = _load_utils_module() - - -def test_parse_name_status_uses_rename_destination_path() -> None: - raw = ( - b"R100\x00old/path.py\x00new/path.py\x00" - b"R75\x00legacy/module.py\x00modern/module.py\x00" - b"M\x00src/app.py\x00" - b"A\x00src/new_file.py\x00" - b"D\x00src/deleted.py\x00" - ) - - entries = utils._parse_name_status_z(raw) - classified = utils._classify_diff_entries(entries) - - assert "new/path.py" in classified["analyzable_files"] - assert "old/path.py" not in classified["analyzable_files"] - assert "modern/module.py" in classified["analyzable_files"] - assert classified["renamed_files"][0]["old_path"] == "old/path.py" - assert classified["renamed_files"][0]["new_path"] == "new/path.py" - assert "src/deleted.py" in classified["deleted_files"] - assert "src/deleted.py" not in classified["analyzable_files"] - - -def test_build_diff_scope_instruction_includes_added_modified_and_deleted_guidance() -> None: - scope = utils.RepoDiffScope( - source_path="/tmp/repo", - workspace_subdir="repo", - base_ref="refs/remotes/origin/main", - merge_base="abc123", - added_files=["src/added.py"], - modified_files=["src/changed.py"], - renamed_files=[{"old_path": "src/old.py", "new_path": "src/new.py", "similarity": 90}], - deleted_files=["src/deleted.py"], - analyzable_files=["src/added.py", "src/changed.py", "src/new.py"], - ) - - instruction = utils.build_diff_scope_instruction([scope]) - - assert "For Added files, review the entire file content." in instruction - assert "For Modified files, focus primarily on the changed areas." in instruction - assert "Note: These files were deleted" in instruction - assert "src/deleted.py" in instruction - assert "src/old.py -> src/new.py" in instruction - - -def test_resolve_base_ref_prefers_github_base_ref(monkeypatch) -> None: - calls: list[str] = [] - - def fake_ref_exists(_repo_path: Path, ref: str) -> bool: - calls.append(ref) - return ref == "refs/remotes/origin/release-2026" - - monkeypatch.setattr(utils, "_git_ref_exists", fake_ref_exists) - monkeypatch.setattr(utils, "_extract_github_base_sha", lambda _env: None) - monkeypatch.setattr(utils, "_resolve_origin_head_ref", lambda _repo_path: None) - - base_ref = utils._resolve_base_ref( - Path("/tmp/repo"), - diff_base=None, - env={"GITHUB_BASE_REF": "release-2026"}, - ) - - assert base_ref == "refs/remotes/origin/release-2026" - assert calls[0] == "refs/remotes/origin/release-2026" - - -def test_resolve_base_ref_falls_back_to_remote_main(monkeypatch) -> None: - calls: list[str] = [] - - def fake_ref_exists(_repo_path: Path, ref: str) -> bool: - calls.append(ref) - return ref == "refs/remotes/origin/main" - - monkeypatch.setattr(utils, "_git_ref_exists", fake_ref_exists) - monkeypatch.setattr(utils, "_extract_github_base_sha", lambda _env: None) - monkeypatch.setattr(utils, "_resolve_origin_head_ref", lambda _repo_path: None) - - base_ref = utils._resolve_base_ref(Path("/tmp/repo"), diff_base=None, env={}) - - assert base_ref == "refs/remotes/origin/main" - assert "refs/remotes/origin/main" in calls - assert "origin/main" not in calls - - -def test_resolve_diff_scope_context_auto_degrades_when_repo_scope_resolution_fails( - monkeypatch, -) -> None: - source = {"source_path": "/tmp/repo", "workspace_subdir": "repo"} - - monkeypatch.setattr(utils, "_should_activate_auto_scope", lambda *_args, **_kwargs: True) - monkeypatch.setattr(utils, "_is_git_repo", lambda _repo_path: True) - monkeypatch.setattr( - utils, - "_resolve_repo_diff_scope", - lambda *_args, **_kwargs: (_ for _ in ()).throw(ValueError("shallow history")), - ) - - result = utils.resolve_diff_scope_context( - local_sources=[source], - scope_mode="auto", - diff_base=None, - non_interactive=True, - env={}, - ) - - assert result.active is False - assert result.mode == "auto" - assert result.metadata["active"] is False - assert result.metadata["mode"] == "auto" - assert "skipped_diff_scope_sources" in result.metadata - assert result.metadata["skipped_diff_scope_sources"] == [ - "/tmp/repo (diff-scope skipped: shallow history)" - ] - - -def test_resolve_diff_scope_context_diff_mode_still_raises_on_repo_scope_resolution_failure( - monkeypatch, -) -> None: - source = {"source_path": "/tmp/repo", "workspace_subdir": "repo"} - - monkeypatch.setattr(utils, "_is_git_repo", lambda _repo_path: True) - monkeypatch.setattr( - utils, - "_resolve_repo_diff_scope", - lambda *_args, **_kwargs: (_ for _ in ()).throw(ValueError("shallow history")), - ) - - with pytest.raises(ValueError, match="shallow history"): - utils.resolve_diff_scope_context( - local_sources=[source], - scope_mode="diff", - diff_base=None, - non_interactive=True, - env={}, - ) diff --git a/tests/llm/__init__.py b/tests/llm/__init__.py deleted file mode 100644 index 86f5ed8..0000000 --- a/tests/llm/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for strix.llm module.""" diff --git a/tests/llm/test_anthropic_cache_wrapper.py b/tests/llm/test_anthropic_cache_wrapper.py deleted file mode 100644 index d2759a1..0000000 --- a/tests/llm/test_anthropic_cache_wrapper.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Smoke tests for AnthropicCachingLitellmModel.""" - -from __future__ import annotations - -from strix.llm.anthropic_cache_wrapper import AnthropicCachingLitellmModel - - -def _make(model: str) -> AnthropicCachingLitellmModel: - # ``LitellmModel.__init__`` only validates that model is a string; we - # don't need a real API key for in-memory ``_patch`` testing. - return AnthropicCachingLitellmModel(model=model, api_key="test-key") - - -def test_is_anthropic_detects_anthropic_prefix() -> None: - m = _make("anthropic/claude-3-5-sonnet") - assert m._is_anthropic() is True - - -def test_is_anthropic_detects_claude_substring() -> None: - m = _make("openrouter/anthropic-claude-haiku") - assert m._is_anthropic() is True - - -def test_is_anthropic_false_for_openai() -> None: - m = _make("openai/gpt-4o") - assert m._is_anthropic() is False - - -def test_is_anthropic_false_for_gemini() -> None: - m = _make("gemini/gemini-1.5-pro") - assert m._is_anthropic() is False - - -def test_patch_anthropic_adds_cache_control_to_system() -> None: - m = _make("anthropic/claude-3-5-sonnet") - items: list = [ - {"role": "system", "content": "You are a helpful agent."}, - {"role": "user", "content": "hi"}, - ] - out = m._patch(items) - assert out[0]["role"] == "system" - content = out[0]["content"] - assert isinstance(content, list) - assert content[0]["type"] == "text" - assert content[0]["text"] == "You are a helpful agent." - assert content[0]["cache_control"] == {"type": "ephemeral"} - # Second item passes through unchanged. - assert out[1] == {"role": "user", "content": "hi"} - - -def test_patch_non_anthropic_passes_through() -> None: - m = _make("openai/gpt-4o") - items: list = [ - {"role": "system", "content": "You are a helpful agent."}, - {"role": "user", "content": "hi"}, - ] - assert m._patch(items) is items # exact same list reference, no copy - - -def test_patch_skips_non_string_system_content() -> None: - """If system content is already structured (e.g., previously patched), - don't re-wrap โ€” pass through unchanged.""" - m = _make("anthropic/claude-3-5-sonnet") - items: list = [ - {"role": "system", "content": [{"type": "text", "text": "x"}]}, - {"role": "user", "content": "hi"}, - ] - out = m._patch(items) - assert out[0]["content"] == [{"type": "text", "text": "x"}] - - -def test_patch_handles_empty_list() -> None: - m = _make("anthropic/claude-3-5-sonnet") - assert m._patch([]) == [] diff --git a/tests/llm/test_multi_provider_setup.py b/tests/llm/test_multi_provider_setup.py deleted file mode 100644 index 32d8e3a..0000000 --- a/tests/llm/test_multi_provider_setup.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Smoke tests for the multi-provider setup.""" - -from __future__ import annotations - -import pytest -from agents.exceptions import UserError - -from strix.llm.anthropic_cache_wrapper import AnthropicCachingLitellmModel -from strix.llm.multi_provider_setup import ( - _AnthropicCachingProvider, - build_multi_provider, -) - - -def test_anthropic_provider_wraps_in_caching_model() -> None: - provider = _AnthropicCachingProvider() - model = provider.get_model("claude-sonnet-4-6") - assert isinstance(model, AnthropicCachingLitellmModel) - # The provider re-prefixes with ``anthropic/`` so litellm routes correctly. - assert model.model == "anthropic/claude-sonnet-4-6" - assert model._is_anthropic() is True - - -def test_anthropic_provider_preserves_existing_anthropic_prefix() -> None: - """If the alias already carries ``anthropic/``, don't double-prefix.""" - provider = _AnthropicCachingProvider() - model = provider.get_model("anthropic/claude-3-5-sonnet-20241022") - assert model.model == "anthropic/claude-3-5-sonnet-20241022" - - -def test_anthropic_provider_empty_name_raises() -> None: - provider = _AnthropicCachingProvider() - with pytest.raises(UserError, match="non-empty"): - provider.get_model(None) - - -def test_build_multi_provider_routes_anthropic_through_caching_wrapper() -> None: - """The configured MultiProvider should hit our caching wrapper for the - ``anthropic/`` prefix.""" - mp = build_multi_provider() - model = mp.get_model("anthropic/claude-sonnet-4-6") - assert isinstance(model, AnthropicCachingLitellmModel) diff --git a/tests/orchestration/__init__.py b/tests/orchestration/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/orchestration/test_bus.py b/tests/orchestration/test_bus.py deleted file mode 100644 index 6ccc621..0000000 --- a/tests/orchestration/test_bus.py +++ /dev/null @@ -1,195 +0,0 @@ -"""Phase 0 smoke tests for AgentMessageBus.""" - -from __future__ import annotations - -import asyncio - -import pytest - -from strix.orchestration.bus import AgentMessageBus - - -@pytest.fixture -def bus() -> AgentMessageBus: - return AgentMessageBus() - - -@pytest.mark.asyncio -async def test_register_records_agent(bus: AgentMessageBus) -> None: - await bus.register("a1", "alpha", parent_id=None) - assert bus.statuses["a1"] == "running" - assert bus.parent_of["a1"] is None - assert bus.names["a1"] == "alpha" - assert bus.inboxes["a1"] == [] - assert bus.stats_live["a1"]["calls"] == 0 - - -@pytest.mark.asyncio -async def test_send_and_drain_fifo(bus: AgentMessageBus) -> None: - await bus.register("a1", "alpha", parent_id=None) - await bus.send("a1", {"from": "b", "content": "first"}) - await bus.send("a1", {"from": "c", "content": "second"}) - drained = await bus.drain("a1") - assert [m["content"] for m in drained] == ["first", "second"] - assert await bus.drain("a1") == [] - - -@pytest.mark.asyncio -async def test_send_to_unknown_agent_is_dropped(bus: AgentMessageBus) -> None: - await bus.send("ghost", {"from": "user", "content": "x"}) - assert "ghost" not in bus.inboxes - - -@pytest.mark.asyncio -async def test_finalize_clears_inbox_parent_name(bus: AgentMessageBus) -> None: - """C13 (AUDIT_R3): finalize cleans up routing state to avoid orphan messages.""" - await bus.register("a1", "alpha", parent_id=None) - await bus.register("a2", "beta", parent_id="a1") - await bus.send("a1", {"from": "a2", "content": "hi"}) - await bus.finalize("a1", "completed") - # Inbox / parent / name removed so siblings can't accidentally re-fill. - assert "a1" not in bus.inboxes - assert "a1" not in bus.parent_of - assert "a1" not in bus.names - # Status remains for diagnostics. - assert bus.statuses["a1"] == "completed" - # Messages sent to a finalized agent are dropped silently. - await bus.send("a1", {"from": "a2", "content": "ignored"}) - assert "a1" not in bus.inboxes - - -@pytest.mark.asyncio -async def test_record_usage_aggregates(bus: AgentMessageBus) -> None: - await bus.register("a1", "alpha", parent_id=None) - - class _Details: - cached_tokens = 10 - - class _Usage: - input_tokens = 100 - output_tokens = 50 - input_tokens_details = _Details() - - await bus.record_usage("a1", _Usage()) - await bus.record_usage("a1", _Usage()) - stats = bus.stats_live["a1"] - assert stats["in"] == 200 - assert stats["out"] == 100 - assert stats["cached"] == 20 - assert stats["calls"] == 2 - - -@pytest.mark.asyncio -async def test_record_usage_handles_none(bus: AgentMessageBus) -> None: - await bus.register("a1", "alpha", parent_id=None) - await bus.record_usage("a1", None) - assert bus.stats_live["a1"]["calls"] == 0 - - -@pytest.mark.asyncio -async def test_total_stats_snapshot(bus: AgentMessageBus) -> None: - """C12 (AUDIT_R2): total_stats acquires the lock for a consistent snapshot.""" - await bus.register("a1", "alpha", parent_id=None) - await bus.register("a2", "beta", parent_id="a1") - - class _Details: - cached_tokens = 5 - - class _Usage: - input_tokens = 10 - output_tokens = 20 - input_tokens_details = _Details() - - await bus.record_usage("a1", _Usage()) - await bus.record_usage("a2", _Usage()) - await bus.finalize("a2", "completed") - - totals = await bus.total_stats() - assert totals["in"] == 20 - assert totals["out"] == 40 - assert totals["cached"] == 10 - assert totals["calls"] == 2 - - -@pytest.mark.asyncio -async def test_concurrent_send_drain_no_lost_messages() -> None: - bus = AgentMessageBus() - await bus.register("a1", "alpha", parent_id=None) - - async def producer(start: int, count: int) -> None: - for i in range(count): - await bus.send("a1", {"from": "p", "content": str(start + i)}) - - # 50 producers x 20 messages = 1000 messages; drain in 1 reader. - producers = [asyncio.create_task(producer(i * 20, 20)) for i in range(50)] - await asyncio.gather(*producers) - drained = await bus.drain("a1") - assert len(drained) == 1000 - - -@pytest.mark.asyncio -async def test_cancel_descendants_cancels_whole_tree() -> None: - """C9 (AUDIT_R2): cancel_descendants cancels every transitive child.""" - bus = AgentMessageBus() - await bus.register("root", "root", parent_id=None) - await bus.register("child1", "c1", parent_id="root") - await bus.register("grandchild1", "g1", parent_id="child1") - await bus.register("child2", "c2", parent_id="root") - - pending = asyncio.get_event_loop().create_future() - - async def fake_run() -> None: - await pending # block until cancelled - - for aid in ("root", "child1", "grandchild1", "child2"): - bus.tasks[aid] = asyncio.create_task(fake_run()) - - await bus.cancel_descendants("root") - - for aid in ("root", "child1", "grandchild1", "child2"): - assert bus.tasks[aid].cancelled() or bus.tasks[aid].done() - - -@pytest.mark.asyncio -async def test_cancel_descendants_triggers_leaves_before_root() -> None: - """C9: explicit ordering check โ€” leaves' .cancel() called before root's.""" - bus = AgentMessageBus() - await bus.register("root", "root", parent_id=None) - await bus.register("child1", "c1", parent_id="root") - await bus.register("grandchild1", "g1", parent_id="child1") - - cancel_call_order: list[str] = [] - pending = asyncio.get_event_loop().create_future() - - class _RecordingTask: - """Wrap a real Task; record the moment .cancel() is invoked.""" - - def __init__(self, name: str, task: asyncio.Task) -> None: - self._name = name - self._task = task - - def done(self) -> bool: - return self._task.done() - - def cancelled(self) -> bool: - return self._task.cancelled() - - def cancel(self, *args: object, **kwargs: object) -> bool: - cancel_call_order.append(self._name) - return self._task.cancel() - - def __await__(self): - return self._task.__await__() - - async def fake_run() -> None: - await pending - - for aid in ("root", "child1", "grandchild1"): - real = asyncio.create_task(fake_run()) - bus.tasks[aid] = _RecordingTask(aid, real) # type: ignore[assignment] - - await bus.cancel_descendants("root") - - # grandchild and child must have .cancel() called before root. - assert cancel_call_order.index("grandchild1") < cancel_call_order.index("root") - assert cancel_call_order.index("child1") < cancel_call_order.index("root") diff --git a/tests/orchestration/test_filter.py b/tests/orchestration/test_filter.py deleted file mode 100644 index c38dc0d..0000000 --- a/tests/orchestration/test_filter.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Phase 0 smoke tests for inject_messages_filter.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any - -import pytest -from agents.run_config import CallModelData, ModelInputData - -from strix.orchestration.bus import AgentMessageBus -from strix.orchestration.filter import inject_messages_filter - - -@dataclass -class _FakeAgent: - name: str = "agent" - - -def _call_data( - context: Any, - items: list[Any], - instructions: str | None = "system", -) -> CallModelData[Any]: - return CallModelData( - model_data=ModelInputData(input=items, instructions=instructions), - agent=_FakeAgent(), - context=context, - ) - - -@pytest.mark.asyncio -async def test_empty_inbox_passes_through() -> None: - bus = AgentMessageBus() - await bus.register("a1", "alpha", parent_id=None) - data = _call_data({"bus": bus, "agent_id": "a1"}, [{"role": "user", "content": "x"}]) - - out = await inject_messages_filter(data) - - assert out.input == [{"role": "user", "content": "x"}] - assert out.instructions == "system" - - -@pytest.mark.asyncio -async def test_pending_messages_appended_in_order() -> None: - bus = AgentMessageBus() - await bus.register("a1", "alpha", parent_id=None) - await bus.send("a1", {"from": "b", "content": "hello", "type": "info", "priority": "normal"}) - await bus.send("a1", {"from": "c", "content": "second", "type": "info", "priority": "high"}) - data = _call_data({"bus": bus, "agent_id": "a1"}, [{"role": "user", "content": "task"}]) - - out = await inject_messages_filter(data) - - assert len(out.input) == 3 - assert out.input[0] == {"role": "user", "content": "task"} - assert "Message from agent b" in out.input[1]["content"] - assert "hello" in out.input[1]["content"] - assert "second" in out.input[2]["content"] - assert "priority=high" in out.input[2]["content"] - - -@pytest.mark.asyncio -async def test_user_sender_skips_envelope() -> None: - bus = AgentMessageBus() - await bus.register("a1", "alpha", parent_id=None) - await bus.send("a1", {"from": "user", "content": "follow-up question"}) - data = _call_data({"bus": bus, "agent_id": "a1"}, []) - - out = await inject_messages_filter(data) - - assert out.input == [{"role": "user", "content": "follow-up question"}] - - -@pytest.mark.asyncio -async def test_no_bus_in_context_passes_through() -> None: - data = _call_data({"agent_id": "a1"}, [{"role": "user", "content": "x"}]) - out = await inject_messages_filter(data) - assert out.input == [{"role": "user", "content": "x"}] - - -@pytest.mark.asyncio -async def test_filter_exception_returns_unmodified() -> None: - """C14 (AUDIT_R3): filter exception is caught; original data returned.""" - - class _BrokenBus: - async def drain(self, _: str) -> list[dict[str, Any]]: - raise RuntimeError("simulated bug") - - data = _call_data( - {"bus": _BrokenBus(), "agent_id": "a1"}, - [{"role": "user", "content": "still works"}], - ) - - out = await inject_messages_filter(data) - assert out.input == [{"role": "user", "content": "still works"}] diff --git a/tests/orchestration/test_hooks.py b/tests/orchestration/test_hooks.py deleted file mode 100644 index 004becb..0000000 --- a/tests/orchestration/test_hooks.py +++ /dev/null @@ -1,174 +0,0 @@ -"""Phase 0 smoke tests for StrixOrchestrationHooks.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -import pytest - -from strix.orchestration.bus import AgentMessageBus -from strix.orchestration.hooks import StrixOrchestrationHooks - - -@dataclass -class _Ctx: - """Minimal stand-in for RunContextWrapper / AgentHookContext. - - Only ``.context`` is exercised by the hooks under test; SDK's real wrappers - expose much more, but the hooks treat ``.context`` as the dict we put in. - """ - - context: dict[str, Any] = field(default_factory=dict) - - -@dataclass -class _Tool: - name: str - - -class _FakeTracer: - def __init__(self) -> None: - self.starts: list[tuple[str, str]] = [] - self.ends: list[tuple[str, str, Any]] = [] - - def log_tool_start(self, agent_id: str, tool_name: str) -> None: - self.starts.append((agent_id, tool_name)) - - def log_tool_end(self, agent_id: str, tool_name: str, result: Any) -> None: - self.ends.append((agent_id, tool_name, result)) - - -@pytest.mark.asyncio -async def test_on_llm_start_injects_85_percent_warning() -> None: - hooks = StrixOrchestrationHooks() - items: list[Any] = [] - ctx = _Ctx(context={"max_turns": 100, "turn_count": 85}) - await hooks.on_llm_start(ctx, agent=None, system_prompt="x", input_items=items) - assert len(items) == 1 - assert "85%" in items[0]["content"] - - -@pytest.mark.asyncio -async def test_on_llm_start_injects_n_minus_3_warning() -> None: - hooks = StrixOrchestrationHooks() - items: list[Any] = [] - ctx = _Ctx(context={"max_turns": 100, "turn_count": 97}) - await hooks.on_llm_start(ctx, agent=None, system_prompt="x", input_items=items) - assert len(items) == 1 - assert "3 iterations left" in items[0]["content"] - - -@pytest.mark.asyncio -async def test_on_llm_start_no_warning_at_other_turns() -> None: - hooks = StrixOrchestrationHooks() - items: list[Any] = [] - ctx = _Ctx(context={"max_turns": 100, "turn_count": 50}) - await hooks.on_llm_start(ctx, agent=None, system_prompt="x", input_items=items) - assert items == [] - - -@pytest.mark.asyncio -async def test_on_llm_end_records_usage_and_increments_turn() -> None: - hooks = StrixOrchestrationHooks() - bus = AgentMessageBus() - await bus.register("a1", "alpha", parent_id=None) - ctx = _Ctx(context={"bus": bus, "agent_id": "a1", "turn_count": 0}) - - class _Details: - cached_tokens = 5 - - class _Usage: - input_tokens = 10 - output_tokens = 20 - input_tokens_details = _Details() - - class _Resp: - usage = _Usage() - - await hooks.on_llm_end(ctx, agent=None, response=_Resp()) - assert ctx.context["turn_count"] == 1 - assert bus.stats_live["a1"]["in"] == 10 - - -@pytest.mark.asyncio -async def test_on_agent_end_detects_crash() -> None: - """on_agent_end without agent_finish_called posts crash message to parent.""" - hooks = StrixOrchestrationHooks() - bus = AgentMessageBus() - await bus.register("root", "root", parent_id=None) - await bus.register("child", "specialist", parent_id="root") - ctx = _Ctx(context={"bus": bus, "agent_id": "child"}) - - await hooks.on_agent_end(ctx, agent=None, output=None) # crashed (output=None) - - drained = await bus.drain("root") - assert len(drained) == 1 - assert "[Agent crash]" in drained[0]["content"] - assert "child" in drained[0]["content"] - assert drained[0]["type"] == "crash" - assert bus.statuses["child"] == "crashed" - - -@pytest.mark.asyncio -async def test_on_agent_end_no_crash_when_finish_called() -> None: - hooks = StrixOrchestrationHooks() - bus = AgentMessageBus() - await bus.register("root", "root", parent_id=None) - await bus.register("child", "specialist", parent_id="root") - ctx = _Ctx( - context={ - "bus": bus, - "agent_id": "child", - "agent_finish_called": True, - } - ) - - await hooks.on_agent_end(ctx, agent=None, output="done") - - assert await bus.drain("root") == [] - assert bus.statuses["child"] == "completed" - - -@pytest.mark.asyncio -async def test_on_tool_end_marks_finish_called() -> None: - """When agent_finish or finish_scan returns, mark context flag for crash detection.""" - hooks = StrixOrchestrationHooks() - ctx = _Ctx(context={"agent_id": "a1"}) - await hooks.on_tool_end(ctx, agent=None, tool=_Tool("agent_finish"), result="ok") - assert ctx.context["agent_finish_called"] is True - - -@pytest.mark.asyncio -async def test_on_tool_end_other_tool_does_not_set_flag() -> None: - hooks = StrixOrchestrationHooks() - ctx = _Ctx(context={"agent_id": "a1"}) - await hooks.on_tool_end(ctx, agent=None, tool=_Tool("terminal_execute"), result="x") - assert ctx.context.get("agent_finish_called") is None - - -@pytest.mark.asyncio -async def test_on_tool_start_logs_to_tracer() -> None: - hooks = StrixOrchestrationHooks() - tracer = _FakeTracer() - ctx = _Ctx(context={"tracer": tracer, "agent_id": "a1"}) - await hooks.on_tool_start(ctx, agent=None, tool=_Tool("browser_action")) - assert tracer.starts == [("a1", "browser_action")] - - -@pytest.mark.asyncio -async def test_hook_exception_does_not_propagate() -> None: - """C15 (AUDIT_R3): a bug in the hook body must never tear down the run.""" - hooks = StrixOrchestrationHooks() - - class _BrokenBus: - async def record_usage(self, *_: Any, **__: Any) -> None: - raise RuntimeError("simulated") - - ctx = _Ctx(context={"bus": _BrokenBus(), "agent_id": "a1"}) - - class _Resp: - usage = None - - # Should not raise. - await hooks.on_llm_end(ctx, agent=None, response=_Resp()) diff --git a/tests/runtime/__init__.py b/tests/runtime/__init__.py deleted file mode 100644 index 684b21b..0000000 --- a/tests/runtime/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for strix.runtime module.""" diff --git a/tests/runtime/test_strix_docker_client.py b/tests/runtime/test_strix_docker_client.py deleted file mode 100644 index d65e1d0..0000000 --- a/tests/runtime/test_strix_docker_client.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Phase 0 smoke tests for StrixDockerSandboxClient. - -These tests do NOT require Docker. They mock the Docker SDK client (passed -to the constructor) and verify our subclass injects the right kwargs into -``containers.create``. Live container tests are part of Phase 0 manual -smoke (TESTING_STRATEGY.md ยง9). -""" - -from __future__ import annotations - -import asyncio -from typing import Any -from unittest.mock import MagicMock - -import docker.errors # type: ignore[import-untyped,unused-ignore] -import pytest - -from strix.runtime.strix_docker_client import StrixDockerSandboxClient - - -@pytest.fixture -def fake_docker() -> MagicMock: - """Stand-in for the Docker SDK client passed to the subclass.""" - fake = MagicMock() - fake.images.get.return_value = MagicMock() # image_exists -> True - fake.images.pull = MagicMock() - fake.containers.create.return_value = MagicMock(name="container") - return fake - - -def _create_kwargs(fake: MagicMock) -> dict[str, Any]: - result: dict[str, Any] = fake.containers.create.call_args.kwargs - return result - - -def test_subclass_injects_net_admin_and_net_raw(fake_docker: MagicMock) -> None: - client = StrixDockerSandboxClient(docker_client=fake_docker) - asyncio.run( - client._create_container("strix-image:latest", manifest=None, exposed_ports=()), - ) - kwargs = _create_kwargs(fake_docker) - assert "NET_ADMIN" in kwargs["cap_add"] - assert "NET_RAW" in kwargs["cap_add"] - - -def test_subclass_injects_host_gateway(fake_docker: MagicMock) -> None: - client = StrixDockerSandboxClient(docker_client=fake_docker) - asyncio.run( - client._create_container("strix-image:latest", manifest=None, exposed_ports=()), - ) - kwargs = _create_kwargs(fake_docker) - assert kwargs["extra_hosts"]["host.docker.internal"] == "host-gateway" - - -def test_subclass_preserves_image_and_command(fake_docker: MagicMock) -> None: - client = StrixDockerSandboxClient(docker_client=fake_docker) - asyncio.run( - client._create_container("custom:tag", manifest=None, exposed_ports=()), - ) - kwargs = _create_kwargs(fake_docker) - assert kwargs["image"] == "custom:tag" - assert kwargs["entrypoint"] == ["tail"] - assert kwargs["command"] == ["-f", "/dev/null"] - assert kwargs["detach"] is True - - -def test_subclass_emits_ports_dict_for_exposed_ports(fake_docker: MagicMock) -> None: - client = StrixDockerSandboxClient(docker_client=fake_docker) - asyncio.run( - client._create_container( - "strix-image:latest", - manifest=None, - exposed_ports=(48081, 48080), - ), - ) - kwargs = _create_kwargs(fake_docker) - assert "48081/tcp" in kwargs["ports"] - assert "48080/tcp" in kwargs["ports"] - - -def test_caps_appended_not_duplicated(fake_docker: MagicMock) -> None: - """Idempotent injection: calling twice doesn't add duplicate caps.""" - client = StrixDockerSandboxClient(docker_client=fake_docker) - asyncio.run( - client._create_container("img:latest", manifest=None, exposed_ports=()), - ) - kwargs = _create_kwargs(fake_docker) - assert kwargs["cap_add"].count("NET_ADMIN") == 1 - assert kwargs["cap_add"].count("NET_RAW") == 1 - - -def test_pulls_image_when_missing(fake_docker: MagicMock) -> None: - """If image_exists returns False on first check, pull is invoked.""" - # First call raises ImageNotFound, second succeeds. - fake_docker.images.get.side_effect = [ - docker.errors.ImageNotFound("not found"), - MagicMock(), - ] - client = StrixDockerSandboxClient(docker_client=fake_docker) - asyncio.run( - client._create_container("registry.io/strix:tag", manifest=None, exposed_ports=()), - ) - fake_docker.images.pull.assert_called_once() diff --git a/tests/sandbox/__init__.py b/tests/sandbox/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/sandbox/test_caido_capability.py b/tests/sandbox/test_caido_capability.py deleted file mode 100644 index fb5227e..0000000 --- a/tests/sandbox/test_caido_capability.py +++ /dev/null @@ -1,156 +0,0 @@ -"""Phase 4 tests for CaidoCapability. - -The capability has three observable behaviors that need parity with the -PLAYBOOK contract: - -1. ``process_manifest`` injects http_proxy / https_proxy / ALL_PROXY env - vars into the manifest's ``Environment.value`` dict. -2. ``tools()`` returns the seven Caido SDK function tools we wrapped in - Phase 2.5 โ€” same instances, in the documented order. -3. ``bind`` schedules an aggregated healthcheck task; the orchestration - hook later awaits it on first agent start. - -The healthcheck task itself is exercised by the healthcheck unit tests; -here we only verify the wiring (task created, name set, points at the -right ports). -""" - -from __future__ import annotations - -import asyncio -from unittest.mock import MagicMock, patch - -import pytest -from agents.sandbox.entries import LocalDir -from agents.sandbox.manifest import Environment, Manifest - -from strix.sandbox.caido_capability import CaidoCapability - - -def test_capability_type_and_default_state() -> None: - cap = CaidoCapability() - assert cap.type == "caido" - assert cap._healthcheck_task is None - assert cap._tool_server_host_port is None - assert cap._caido_host_port is None - - -def test_process_manifest_injects_proxy_env_vars(tmp_path: object) -> None: - """Existing env vars must be preserved; proxy keys are added.""" - cap = CaidoCapability() - manifest = Manifest( - environment=Environment( - value={"PYTHONUNBUFFERED": "1", "TOOL_SERVER_TOKEN": "abc"}, - ), - ) - out = cap.process_manifest(manifest) - env = out.environment.value - # Pre-existing entries preserved. - assert env["PYTHONUNBUFFERED"] == "1" - assert env["TOOL_SERVER_TOKEN"] == "abc" - # Proxy entries injected, all pointing at the in-container Caido port. - assert env["http_proxy"] == "http://127.0.0.1:48080" - assert env["https_proxy"] == "http://127.0.0.1:48080" - assert env["ALL_PROXY"] == "http://127.0.0.1:48080" - - -def test_process_manifest_handles_missing_environment() -> None: - """A manifest without env entries should still get the proxy block.""" - cap = CaidoCapability() - # ``LocalDir`` requires a real path on disk; use a temp one to satisfy - # the validator without actually mounting anything. - manifest = Manifest(entries={"src": LocalDir(src="/tmp")}) - out = cap.process_manifest(manifest) - env = out.environment.value - assert env["http_proxy"] == "http://127.0.0.1:48080" - - -def test_tools_returns_seven_caido_tools_in_order() -> None: - cap = CaidoCapability() - names = [t.name for t in cap.tools()] - assert names == [ - "list_requests", - "view_request", - "send_request", - "repeat_request", - "scope_rules", - "list_sitemap", - "view_sitemap_entry", - ] - - -def test_tools_returns_a_fresh_list_per_call() -> None: - """SDK convention โ€” caller may mutate the returned list.""" - cap = CaidoCapability() - a = cap.tools() - b = cap.tools() - assert a == b - assert a is not b - - -@pytest.mark.asyncio -async def test_instructions_mentions_caido_and_tools() -> None: - cap = CaidoCapability() - out = await cap.instructions(Manifest()) - assert out is not None - assert "" in out - # Every tool name appears verbatim so the model knows what's available. - for name in ( - "list_requests", - "view_request", - "send_request", - "repeat_request", - "scope_rules", - "list_sitemap", - "view_sitemap_entry", - ): - assert name in out - - -def test_configure_host_ports_stores_both() -> None: - cap = CaidoCapability() - cap.configure_host_ports(tool_server_host_port=12345, caido_host_port=12346) - assert cap._tool_server_host_port == 12345 - assert cap._caido_host_port == 12346 - - -@pytest.mark.asyncio -async def test_bind_without_configured_ports_skips_healthcheck() -> None: - """If the session manager forgets to configure ports, bind shouldn't - schedule a probe against ``None`` โ€” it should warn and no-op. - """ - cap = CaidoCapability() - fake_session = MagicMock() - cap.bind(fake_session) - assert cap._healthcheck_task is None - assert cap.session is fake_session - - -@pytest.mark.asyncio -async def test_bind_schedules_healthcheck_task_when_ports_configured() -> None: - """The hook chain (StrixOrchestrationHooks.on_agent_start) awaits this - task โ€” it must exist as an asyncio.Task with a useful name. - """ - cap = CaidoCapability() - cap.configure_host_ports(tool_server_host_port=54321, caido_host_port=54322) - fake_session = MagicMock() - - # Patch the actual probes so we don't try to connect for real. - async def _fake_probe(*args: object, **kwargs: object) -> None: - return None - - with ( - patch( - "strix.sandbox.caido_capability.wait_for_http_ready", - side_effect=_fake_probe, - ), - patch( - "strix.sandbox.caido_capability.wait_for_tcp_ready", - side_effect=_fake_probe, - ), - ): - cap.bind(fake_session) - assert cap._healthcheck_task is not None - assert isinstance(cap._healthcheck_task, asyncio.Task) - assert "caido-healthcheck-54321" in cap._healthcheck_task.get_name() - await cap._healthcheck_task # must complete without error diff --git a/tests/sandbox/test_healthcheck.py b/tests/sandbox/test_healthcheck.py deleted file mode 100644 index 6ee1734..0000000 --- a/tests/sandbox/test_healthcheck.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Phase 4 tests for the sandbox port readiness probes. - -The two helpers (``wait_for_http_ready`` and ``wait_for_tcp_ready``) -gate session bring-up, so a regression here would mean every fresh -scan hits a connection-refused on its first tool call. Tests cover: - -- Happy path returns when the probe succeeds. -- Polling continues across transient failures. -- Timeout raises ``SandboxNotReadyError`` with a useful last-error. -- Real ``asyncio.open_connection`` against a local listener verifies - the TCP probe end-to-end (no mocking โ€” the helper is small enough - that a real socket is the cheaper test). -""" - -from __future__ import annotations - -import asyncio -import contextlib -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch - -import httpx -import pytest - -from strix.sandbox.healthcheck import ( - SandboxNotReadyError, - wait_for_http_ready, - wait_for_tcp_ready, -) - - -# --- HTTP probe ---------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_wait_for_http_ready_returns_immediately_on_2xx() -> None: - response = MagicMock(spec=httpx.Response) - response.status_code = 200 - client = AsyncMock() - client.get = AsyncMock(return_value=response) - client.__aenter__ = AsyncMock(return_value=client) - client.__aexit__ = AsyncMock(return_value=None) - - with patch("strix.sandbox.healthcheck.httpx.AsyncClient", return_value=client): - await wait_for_http_ready("http://localhost:9999/health", timeout=1) - - assert client.get.await_count == 1 - - -@pytest.mark.asyncio -async def test_wait_for_http_ready_polls_through_connect_errors() -> None: - """Two connect errors followed by a 200 โ€” the helper should keep going.""" - response_ok = MagicMock(spec=httpx.Response) - response_ok.status_code = 200 - - side_effects: list[Any] = [ - httpx.ConnectError("conn refused"), - httpx.ConnectError("conn refused"), - response_ok, - ] - - client = AsyncMock() - client.get = AsyncMock(side_effect=side_effects) - client.__aenter__ = AsyncMock(return_value=client) - client.__aexit__ = AsyncMock(return_value=None) - - with patch("strix.sandbox.healthcheck.httpx.AsyncClient", return_value=client): - await wait_for_http_ready( - "http://localhost:9999/health", - timeout=5, - poll_interval=0.01, - ) - assert client.get.await_count == 3 - - -@pytest.mark.asyncio -async def test_wait_for_http_ready_raises_after_timeout() -> None: - client = AsyncMock() - client.get = AsyncMock(side_effect=httpx.ConnectError("nope")) - client.__aenter__ = AsyncMock(return_value=client) - client.__aexit__ = AsyncMock(return_value=None) - - with ( - patch( - "strix.sandbox.healthcheck.httpx.AsyncClient", - return_value=client, - ), - pytest.raises(SandboxNotReadyError) as exc_info, - ): - await wait_for_http_ready( - "http://localhost:9999/health", - timeout=0.3, - poll_interval=0.05, - ) - - err = str(exc_info.value) - assert "http://localhost:9999/health" in err - assert "ConnectError" in err - - -@pytest.mark.asyncio -async def test_wait_for_http_ready_treats_5xx_as_not_ready() -> None: - response_500 = MagicMock(spec=httpx.Response) - response_500.status_code = 500 - response_ok = MagicMock(spec=httpx.Response) - response_ok.status_code = 200 - - client = AsyncMock() - client.get = AsyncMock(side_effect=[response_500, response_ok]) - client.__aenter__ = AsyncMock(return_value=client) - client.__aexit__ = AsyncMock(return_value=None) - - with patch("strix.sandbox.healthcheck.httpx.AsyncClient", return_value=client): - await wait_for_http_ready( - "http://localhost:9999/health", - timeout=2, - poll_interval=0.01, - ) - assert client.get.await_count == 2 - - -# --- TCP probe ----------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_wait_for_tcp_ready_against_real_listener() -> None: - """Spin up a local TCP echo server and verify the probe connects.""" - - async def _server_handler( - reader: asyncio.StreamReader, - writer: asyncio.StreamWriter, - ) -> None: - # Drain any bytes the test sends, then close. - await reader.read(0) - writer.close() - with contextlib.suppress(OSError): - await writer.wait_closed() - - server = await asyncio.start_server(_server_handler, "127.0.0.1", 0) - port = server.sockets[0].getsockname()[1] - try: - await wait_for_tcp_ready("127.0.0.1", port, timeout=2, poll_interval=0.05) - finally: - server.close() - await server.wait_closed() - - -@pytest.mark.asyncio -async def test_wait_for_tcp_ready_raises_when_port_closed() -> None: - async def _no_handler( - _reader: asyncio.StreamReader, - _writer: asyncio.StreamWriter, - ) -> None: - return - - # Bind and immediately close to claim a definitely-unused port number. - server = await asyncio.start_server(_no_handler, "127.0.0.1", 0) - closed_port = server.sockets[0].getsockname()[1] - server.close() - await server.wait_closed() - - with pytest.raises(SandboxNotReadyError) as exc_info: - await wait_for_tcp_ready( - "127.0.0.1", - closed_port, - timeout=0.3, - poll_interval=0.05, - ) - - err = str(exc_info.value) - assert f"127.0.0.1:{closed_port}" in err diff --git a/tests/sandbox/test_session_manager.py b/tests/sandbox/test_session_manager.py deleted file mode 100644 index bacdbbe..0000000 --- a/tests/sandbox/test_session_manager.py +++ /dev/null @@ -1,206 +0,0 @@ -"""Phase 4 tests for the per-scan sandbox session manager. - -We don't spin up real Docker here โ€” the ``StrixDockerSandboxClient`` is -patched and we assert on the manifest / options / bundle shape. Goals: - -- Cache hit: a second ``create_or_reuse(scan_id, ...)`` returns the same - bundle without calling client.create twice. -- Manifest carries the right env vars (TOOL_SERVER_TOKEN, container ports, - STRIX_SANDBOX_EXECUTION_TIMEOUT, PYTHONUNBUFFERED). -- The Docker client options request both container ports be exposed. -- Capability is configured with the resolved host ports *before* bind, - so its healthcheck task probes the right ones. -- Bundle is cached and surfaces in ``cached_scan_ids``. -- ``cleanup`` cancels the healthcheck task and calls ``client.delete``; - errors during delete are swallowed. -""" - -from __future__ import annotations - -from collections.abc import Iterator -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from strix.sandbox import session_manager -from strix.sandbox.caido_capability import CaidoCapability - - -@pytest.fixture(autouse=True) -def _isolate_cache() -> Iterator[None]: - session_manager._reset_cache_for_tests() - yield - session_manager._reset_cache_for_tests() - - -def _noop_bind(_self: Any, _session: Any) -> None: - """Stand-in for CaidoCapability.bind that skips the healthcheck task.""" - - -def _make_endpoint(port: int) -> Any: - ep = MagicMock() - ep.port = port - ep.host = "127.0.0.1" - ep.tls = False - return ep - - -def _make_client_and_session( - *, - tool_port: int = 12001, - caido_port: int = 12002, -) -> tuple[Any, Any]: - """Build a fake DockerSandboxClient and session pair.""" - session = MagicMock() - session._resolve_exposed_port = AsyncMock( - side_effect=lambda port: _make_endpoint( - tool_port if port == 48081 else caido_port, - ), - ) - client = MagicMock() - client.create = AsyncMock(return_value=session) - client.delete = AsyncMock() - return client, session - - -@pytest.mark.asyncio -async def test_create_or_reuse_creates_new_session(tmp_path: Any) -> None: - client, session = _make_client_and_session() - # Patch the capability's bind to a no-op so we don't spin up the - # healthcheck task in unit tests. - with ( - patch( - "strix.sandbox.session_manager.StrixDockerSandboxClient", - return_value=client, - ), - patch.object(CaidoCapability, "bind", _noop_bind), - ): - bundle = await session_manager.create_or_reuse( - "scan-1", - image="strix-sandbox:test", - sources_path=tmp_path, - ) - - # Bundle shape. - assert bundle["client"] is client - assert bundle["session"] is session - assert bundle["tool_server_host_port"] == 12001 - assert bundle["caido_host_port"] == 12002 - assert isinstance(bundle["bearer"], str) and len(bundle["bearer"]) >= 32 - assert isinstance(bundle["capability"], CaidoCapability) - # Capability got the resolved host ports BEFORE bind would have run. - assert bundle["capability"]._tool_server_host_port == 12001 - assert bundle["capability"]._caido_host_port == 12002 - - # client.create called exactly once with manifest + exposed ports. - assert client.create.await_count == 1 - options = client.create.await_args.kwargs["options"] - assert options.image == "strix-sandbox:test" - assert set(options.exposed_ports) == {48080, 48081} - - manifest = client.create.await_args.kwargs["manifest"] - env = manifest.environment.value - assert env["TOOL_SERVER_TOKEN"] == bundle["bearer"] - assert env["TOOL_SERVER_PORT"] == "48081" - assert env["CAIDO_PORT"] == "48080" - assert env["STRIX_SANDBOX_EXECUTION_TIMEOUT"] == "120" - assert env["PYTHONUNBUFFERED"] == "1" - assert env["HOST_GATEWAY"] == "host.docker.internal" - - -@pytest.mark.asyncio -async def test_create_or_reuse_returns_cached_bundle(tmp_path: Any) -> None: - client, _ = _make_client_and_session() - with ( - patch( - "strix.sandbox.session_manager.StrixDockerSandboxClient", - return_value=client, - ), - patch.object(CaidoCapability, "bind", _noop_bind), - ): - first = await session_manager.create_or_reuse( - "scan-X", - image="i", - sources_path=tmp_path, - ) - second = await session_manager.create_or_reuse( - "scan-X", - image="i", - sources_path=tmp_path, - ) - - assert first is second - assert client.create.await_count == 1 - assert "scan-X" in session_manager.cached_scan_ids() - - -@pytest.mark.asyncio -async def test_create_or_reuse_passes_custom_execution_timeout(tmp_path: Any) -> None: - client, _ = _make_client_and_session() - with ( - patch( - "strix.sandbox.session_manager.StrixDockerSandboxClient", - return_value=client, - ), - patch.object(CaidoCapability, "bind", _noop_bind), - ): - await session_manager.create_or_reuse( - "scan-2", - image="i", - sources_path=tmp_path, - execution_timeout=300, - ) - - manifest = client.create.await_args.kwargs["manifest"] - assert manifest.environment.value["STRIX_SANDBOX_EXECUTION_TIMEOUT"] == "300" - - -@pytest.mark.asyncio -async def test_cleanup_calls_delete_and_drops_cache(tmp_path: Any) -> None: - client, session = _make_client_and_session() - with ( - patch( - "strix.sandbox.session_manager.StrixDockerSandboxClient", - return_value=client, - ), - patch.object(CaidoCapability, "bind", _noop_bind), - ): - await session_manager.create_or_reuse( - "scan-3", - image="i", - sources_path=tmp_path, - ) - assert "scan-3" in session_manager.cached_scan_ids() - await session_manager.cleanup("scan-3") - - client.delete.assert_awaited_once_with(session) - assert "scan-3" not in session_manager.cached_scan_ids() - - -@pytest.mark.asyncio -async def test_cleanup_swallows_delete_errors(tmp_path: Any) -> None: - """A flaky Docker daemon shouldn't prevent cache eviction.""" - client, _ = _make_client_and_session() - client.delete = AsyncMock(side_effect=RuntimeError("docker daemon went away")) - with ( - patch( - "strix.sandbox.session_manager.StrixDockerSandboxClient", - return_value=client, - ), - patch.object(CaidoCapability, "bind", _noop_bind), - ): - await session_manager.create_or_reuse( - "scan-4", - image="i", - sources_path=tmp_path, - ) - await session_manager.cleanup("scan-4") # must not raise - - assert "scan-4" not in session_manager.cached_scan_ids() - - -@pytest.mark.asyncio -async def test_cleanup_unknown_scan_is_noop() -> None: - """No cached entry โ†’ cleanup is a quiet no-op.""" - await session_manager.cleanup("never-existed") # must not raise diff --git a/tests/skills/__init__.py b/tests/skills/__init__.py deleted file mode 100644 index f0b70da..0000000 --- a/tests/skills/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Tests for skill-related runtime behavior. diff --git a/tests/static/__init__.py b/tests/static/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/telemetry/__init__.py b/tests/telemetry/__init__.py deleted file mode 100644 index 8f6aa4f..0000000 --- a/tests/telemetry/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for strix.telemetry module.""" diff --git a/tests/telemetry/test_flags.py b/tests/telemetry/test_flags.py deleted file mode 100644 index a7f8e43..0000000 --- a/tests/telemetry/test_flags.py +++ /dev/null @@ -1,28 +0,0 @@ -from strix.telemetry.flags import is_otel_enabled, is_posthog_enabled - - -def test_flags_fallback_to_strix_telemetry(monkeypatch) -> None: - monkeypatch.delenv("STRIX_OTEL_TELEMETRY", raising=False) - monkeypatch.delenv("STRIX_POSTHOG_TELEMETRY", raising=False) - monkeypatch.setenv("STRIX_TELEMETRY", "0") - - assert is_otel_enabled() is False - assert is_posthog_enabled() is False - - -def test_otel_flag_overrides_global_telemetry(monkeypatch) -> None: - monkeypatch.setenv("STRIX_TELEMETRY", "0") - monkeypatch.setenv("STRIX_OTEL_TELEMETRY", "1") - monkeypatch.delenv("STRIX_POSTHOG_TELEMETRY", raising=False) - - assert is_otel_enabled() is True - assert is_posthog_enabled() is False - - -def test_posthog_flag_overrides_global_telemetry(monkeypatch) -> None: - monkeypatch.setenv("STRIX_TELEMETRY", "0") - monkeypatch.setenv("STRIX_POSTHOG_TELEMETRY", "1") - monkeypatch.delenv("STRIX_OTEL_TELEMETRY", raising=False) - - assert is_otel_enabled() is False - assert is_posthog_enabled() is True diff --git a/tests/telemetry/test_strix_processor.py b/tests/telemetry/test_strix_processor.py deleted file mode 100644 index e4a657e..0000000 --- a/tests/telemetry/test_strix_processor.py +++ /dev/null @@ -1,201 +0,0 @@ -"""Phase 1 smoke tests for StrixTracingProcessor.""" - -from __future__ import annotations - -import json -import threading -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -import pytest - -from strix.telemetry.strix_processor import StrixTracingProcessor - - -@dataclass -class _FakeSpanData: - """Minimal stand-in for an SDK SpanData class.""" - - name: str = "FunctionSpanData" # used by class .__name__ - payload: dict[str, Any] = field(default_factory=dict) - - def export(self) -> dict[str, Any]: - return dict(self.payload) - - -# Concrete SpanData subclasses so the processor's ``_span_kind`` heuristic -# (drop ``SpanData`` suffix, lowercase) produces stable event_types. -class FunctionSpanData(_FakeSpanData): - pass - - -class GenerationSpanData(_FakeSpanData): - pass - - -class AgentSpanData(_FakeSpanData): - pass - - -@dataclass -class _FakeSpan: - span_id: str - trace_id: str - span_data: _FakeSpanData - - -@dataclass -class _FakeTrace: - trace_id: str - name: str = "test-workflow" - metadata: dict[str, Any] = field(default_factory=dict) - - def export(self) -> dict[str, Any]: - return {"name": self.name, "metadata": self.metadata} - - -def _read_events(path: Path) -> list[dict[str, Any]]: - return [json.loads(line) for line in path.read_text().splitlines() if line.strip()] - - -@pytest.fixture -def run_dir(tmp_path: Path) -> Path: - return tmp_path / "strix_runs" / "test-run" - - -def test_constructor_creates_run_dir(run_dir: Path) -> None: - StrixTracingProcessor(run_dir=run_dir) - assert run_dir.exists() - - -def test_on_trace_start_writes_run_started(run_dir: Path) -> None: - p = StrixTracingProcessor(run_dir=run_dir) - p.on_trace_start(_FakeTrace(trace_id="t-1", metadata={"scan_id": "abc"})) - - events = _read_events(p.events_path) - assert events == [ - { - "event_type": "run.started", - "trace_id": "t-1", - "metadata": {"name": "test-workflow", "metadata": {"scan_id": "abc"}}, - } - ] - - -def test_on_trace_end_writes_run_completed(run_dir: Path) -> None: - p = StrixTracingProcessor(run_dir=run_dir) - p.on_trace_end(_FakeTrace(trace_id="t-1")) - - events = _read_events(p.events_path) - assert events == [{"event_type": "run.completed", "trace_id": "t-1"}] - - -def test_span_start_and_end_emit_typed_events(run_dir: Path) -> None: - """``GenerationSpanData`` โ†’ ``generation.started`` / ``generation.completed``.""" - p = StrixTracingProcessor(run_dir=run_dir) - span = _FakeSpan( - span_id="s-1", - trace_id="t-1", - span_data=GenerationSpanData(payload={"model": "gpt-foo"}), - ) - - p.on_span_start(span) - p.on_span_end(span) - - events = _read_events(p.events_path) - assert [e["event_type"] for e in events] == [ - "generation.started", - "generation.completed", - ] - assert events[0]["span_id"] == "s-1" - assert events[0]["data"] == {"model": "gpt-foo"} - - -def test_concurrent_writes_yield_valid_jsonl(run_dir: Path) -> None: - """C7 (AUDIT_R2): per-path lock prevents JSONL corruption under contention.""" - p = StrixTracingProcessor(run_dir=run_dir) - - def writer(idx: int) -> None: - for i in range(50): - p._emit({"event_type": "synthetic", "writer": idx, "i": i}) - - threads = [threading.Thread(target=writer, args=(i,)) for i in range(10)] - for t in threads: - t.start() - for t in threads: - t.join() - - # 10 threads x 50 events = 500 lines; all valid JSON. - lines = p.events_path.read_text().splitlines() - assert len(lines) == 500 - for line in lines: - json.loads(line) # raises on corrupt line - - -def test_emit_swallows_oserror_and_logs( - run_dir: Path, - caplog: pytest.LogCaptureFixture, -) -> None: - """C16 (AUDIT_R3): a write failure must NOT propagate.""" - p = StrixTracingProcessor(run_dir=run_dir) - # Make events_path point to a directory so open(..., "a") raises. - p.events_path = run_dir - - with caplog.at_level("ERROR", logger="strix.telemetry.strix_processor"): - p._emit({"event_type": "boom"}) - - assert any("Failed to append" in rec.message for rec in caplog.records) - - -def test_span_export_failure_does_not_propagate(run_dir: Path) -> None: - """If span_data.export raises, we still emit an event with data=None.""" - p = StrixTracingProcessor(run_dir=run_dir) - - class _BoomSpanData: - def export(self) -> dict[str, Any]: - raise RuntimeError("nope") - - # Reuse the lowercase rule: class name has no "SpanData" suffix โ†’ "boomspandata" - # would not be ideal; use a properly-named subclass. - class FunctionSpanDataBroken(_BoomSpanData): - pass - - span = _FakeSpan(span_id="s-1", trace_id="t-1", span_data=FunctionSpanDataBroken()) - p.on_span_end(span) - - events = _read_events(p.events_path) - assert len(events) == 1 - assert events[0]["data"] is None - - -def test_pii_scrubbed_via_sanitizer(run_dir: Path) -> None: - """Sanitizer is invoked on every emit before write.""" - seen: list[Any] = [] - - class _StubSanitizer: - def sanitize(self, data: Any, key_hint: str | None = None) -> Any: - seen.append(data) - # Replace any "secret" string with [REDACTED]. - if isinstance(data, dict): - clean = {k: "[REDACTED]" if k == "api_key" else v for k, v in data.items()} - if "metadata" in clean and isinstance(clean["metadata"], dict): - md = dict(clean["metadata"]) - md.pop("api_key", None) - clean["metadata"] = md - return clean - return data - - p = StrixTracingProcessor(run_dir=run_dir, sanitizer=_StubSanitizer()) - p._emit({"event_type": "test", "api_key": "sk-very-secret"}) - - events = _read_events(p.events_path) - assert events[0]["api_key"] == "[REDACTED]" - assert seen and seen[0]["api_key"] == "sk-very-secret" - - -def test_force_flush_and_shutdown_are_noops(run_dir: Path) -> None: - p = StrixTracingProcessor(run_dir=run_dir) - # Should not raise. - p.force_flush() - p.shutdown() diff --git a/tests/telemetry/test_tracer.py b/tests/telemetry/test_tracer.py deleted file mode 100644 index 96dff63..0000000 --- a/tests/telemetry/test_tracer.py +++ /dev/null @@ -1,428 +0,0 @@ -import json -import sys -import types -from pathlib import Path -from typing import Any, ClassVar - -import pytest -from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExportResult - -from strix.telemetry import tracer as tracer_module -from strix.telemetry import utils as telemetry_utils -from strix.telemetry.tracer import Tracer, set_global_tracer - - -def _load_events(events_path: Path) -> list[dict[str, Any]]: - lines = events_path.read_text(encoding="utf-8").splitlines() - return [json.loads(line) for line in lines if line] - - -@pytest.fixture(autouse=True) -def _reset_tracer_globals(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(tracer_module, "_global_tracer", None) - monkeypatch.setattr(tracer_module, "_OTEL_BOOTSTRAPPED", False) - monkeypatch.setattr(tracer_module, "_OTEL_REMOTE_ENABLED", False) - telemetry_utils.reset_events_write_locks() - monkeypatch.delenv("STRIX_TELEMETRY", raising=False) - monkeypatch.delenv("STRIX_OTEL_TELEMETRY", raising=False) - monkeypatch.delenv("STRIX_POSTHOG_TELEMETRY", raising=False) - monkeypatch.delenv("TRACELOOP_BASE_URL", raising=False) - monkeypatch.delenv("TRACELOOP_API_KEY", raising=False) - monkeypatch.delenv("TRACELOOP_HEADERS", raising=False) - - -def test_tracer_local_mode_writes_jsonl_with_correlation( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - monkeypatch.chdir(tmp_path) - - tracer = Tracer("local-observability") - set_global_tracer(tracer) - tracer.set_scan_config({"targets": ["https://example.com"], "user_instructions": "focus auth"}) - tracer.log_chat_message("starting scan", "user", "agent-1") - tracer.log_chat_message("scanning login form", "assistant", "agent-1") - - events_path = tmp_path / "strix_runs" / "local-observability" / "events.jsonl" - assert events_path.exists() - - events = _load_events(events_path) - assert any(event["event_type"] == "chat.message" for event in events) - assert any(event["event_type"] == "run.configured" for event in events) - - for event in events: - assert event["run_id"] == "local-observability" - assert event["trace_id"] - assert event["span_id"] - - -def test_tracer_redacts_sensitive_payloads(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - monkeypatch.chdir(tmp_path) - - tracer = Tracer("redaction-run") - set_global_tracer(tracer) - tracer.log_chat_message( - "request failed with token sk-secret-token-value", - "assistant", - "agent-1", - metadata={ - "api_key": "sk-secret-token-value", - "authorization": "Bearer super-secret-token", - }, - ) - - events_path = tmp_path / "strix_runs" / "redaction-run" / "events.jsonl" - events = _load_events(events_path) - serialized = json.dumps(events) - - assert "sk-secret-token-value" not in serialized - assert "super-secret-token" not in serialized - assert "[REDACTED]" in serialized - - -def test_tracer_remote_mode_configures_traceloop_export( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - monkeypatch.chdir(tmp_path) - - class FakeTraceloop: - init_calls: ClassVar[list[dict[str, Any]]] = [] - - @staticmethod - def init(**kwargs: Any) -> None: - FakeTraceloop.init_calls.append(kwargs) - - @staticmethod - def set_association_properties(properties: dict[str, Any]) -> None: # noqa: ARG004 - return None - - monkeypatch.setattr(tracer_module, "Traceloop", FakeTraceloop) - monkeypatch.setenv("TRACELOOP_BASE_URL", "https://otel.example.com") - monkeypatch.setenv("TRACELOOP_API_KEY", "test-api-key") - monkeypatch.setenv("TRACELOOP_HEADERS", '{"x-custom":"header"}') - - tracer = Tracer("remote-observability") - set_global_tracer(tracer) - tracer.log_chat_message("hello", "user", "agent-1") - - assert tracer._remote_export_enabled is True - assert FakeTraceloop.init_calls - init_kwargs = FakeTraceloop.init_calls[-1] - assert init_kwargs["api_endpoint"] == "https://otel.example.com" - assert init_kwargs["api_key"] == "test-api-key" - assert init_kwargs["headers"] == {"x-custom": "header"} - assert isinstance(init_kwargs["processor"], SimpleSpanProcessor) - assert "strix.run_id" not in init_kwargs["resource_attributes"] - assert "strix.run_name" not in init_kwargs["resource_attributes"] - - events_path = tmp_path / "strix_runs" / "remote-observability" / "events.jsonl" - events = _load_events(events_path) - run_started = next(event for event in events if event["event_type"] == "run.started") - assert run_started["payload"]["remote_export_enabled"] is True - - -def test_tracer_local_mode_avoids_traceloop_remote_endpoint( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - monkeypatch.chdir(tmp_path) - - class FakeTraceloop: - init_calls: ClassVar[list[dict[str, Any]]] = [] - - @staticmethod - def init(**kwargs: Any) -> None: - FakeTraceloop.init_calls.append(kwargs) - - @staticmethod - def set_association_properties(properties: dict[str, Any]) -> None: # noqa: ARG004 - return None - - monkeypatch.setattr(tracer_module, "Traceloop", FakeTraceloop) - - tracer = Tracer("local-traceloop") - set_global_tracer(tracer) - tracer.log_chat_message("hello", "user", "agent-1") - - assert FakeTraceloop.init_calls - init_kwargs = FakeTraceloop.init_calls[-1] - assert "api_endpoint" not in init_kwargs - assert "api_key" not in init_kwargs - assert "headers" not in init_kwargs - assert isinstance(init_kwargs["processor"], SimpleSpanProcessor) - assert tracer._remote_export_enabled is False - - -def test_otlp_fallback_includes_auth_and_custom_headers( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - monkeypatch.chdir(tmp_path) - monkeypatch.setattr(tracer_module, "Traceloop", None) - monkeypatch.setenv("TRACELOOP_BASE_URL", "https://otel.example.com") - monkeypatch.setenv("TRACELOOP_API_KEY", "test-api-key") - monkeypatch.setenv("TRACELOOP_HEADERS", '{"x-custom":"header"}') - - captured: dict[str, Any] = {} - - class FakeOTLPSpanExporter: - def __init__(self, endpoint: str, headers: dict[str, str] | None = None, **kwargs: Any): - captured["endpoint"] = endpoint - captured["headers"] = headers or {} - captured["kwargs"] = kwargs - - def export(self, spans: Any) -> SpanExportResult: - return SpanExportResult.SUCCESS - - def shutdown(self) -> None: - return None - - def force_flush(self, timeout_millis: int = 30_000) -> bool: - return True - - fake_module = types.ModuleType("opentelemetry.exporter.otlp.proto.http.trace_exporter") - fake_module.OTLPSpanExporter = FakeOTLPSpanExporter - monkeypatch.setitem( - sys.modules, - "opentelemetry.exporter.otlp.proto.http.trace_exporter", - fake_module, - ) - - tracer = Tracer("otlp-fallback") - set_global_tracer(tracer) - - assert tracer._remote_export_enabled is True - assert captured["endpoint"] == "https://otel.example.com/v1/traces" - assert captured["headers"]["Authorization"] == "Bearer test-api-key" - assert captured["headers"]["x-custom"] == "header" - - -def test_traceloop_init_failure_does_not_mark_bootstrapped_on_provider_failure( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - monkeypatch.chdir(tmp_path) - - class FakeTraceloop: - @staticmethod - def init(**kwargs: Any) -> None: # noqa: ARG004 - raise RuntimeError("traceloop init failed") - - @staticmethod - def set_association_properties(properties: dict[str, Any]) -> None: # noqa: ARG004 - return None - - monkeypatch.setattr(tracer_module, "Traceloop", FakeTraceloop) - - def _raise_provider_error(provider: Any) -> None: - raise RuntimeError("provider setup failed") - - monkeypatch.setattr(tracer_module.trace, "set_tracer_provider", _raise_provider_error) - - tracer = Tracer("bootstrap-failure") - set_global_tracer(tracer) - - assert tracer_module._OTEL_BOOTSTRAPPED is False - assert tracer._remote_export_enabled is False - - -def test_run_completed_event_emitted_once(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - monkeypatch.chdir(tmp_path) - - tracer = Tracer("single-complete") - set_global_tracer(tracer) - tracer.save_run_data(mark_complete=True) - tracer.save_run_data(mark_complete=True) - - events_path = tmp_path / "strix_runs" / "single-complete" / "events.jsonl" - events = _load_events(events_path) - run_completed = [event for event in events if event["event_type"] == "run.completed"] - assert len(run_completed) == 1 - - -def test_events_with_agent_id_include_agent_name( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - monkeypatch.chdir(tmp_path) - - tracer = Tracer("agent-name-enrichment") - set_global_tracer(tracer) - # _enrich_actor pulls names from the tracer's agents dict; populate it - # the same way the orchestration path will once wired (placeholder - # until live wiring lands). - tracer.agents["agent-1"] = {"name": "Root Agent"} - tracer.log_chat_message("hello", "assistant", "agent-1") - - events_path = tmp_path / "strix_runs" / "agent-name-enrichment" / "events.jsonl" - events = _load_events(events_path) - chat_event = next(event for event in events if event["event_type"] == "chat.message") - - assert chat_event["actor"]["agent_id"] == "agent-1" - assert chat_event["actor"]["agent_name"] == "Root Agent" - - -def test_get_total_llm_stats_aggregates_recordings( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - monkeypatch.chdir(tmp_path) - tracer = Tracer("cost-rollup") - set_global_tracer(tracer) - - tracer.record_llm_usage( - input_tokens=1_000, - output_tokens=250, - cached_tokens=100, - cost=0.12345, - requests=2, - ) - tracer.record_llm_usage( - input_tokens=2_000, - output_tokens=500, - cached_tokens=400, - cost=0.54321, - requests=3, - ) - - stats = tracer.get_total_llm_stats() - - assert stats["total"] == { - "input_tokens": 3_000, - "output_tokens": 750, - "cached_tokens": 500, - "cost": 0.6667, - "requests": 5, - } - assert stats["total_tokens"] == 3_750 - - -def test_run_metadata_is_only_on_run_lifecycle_events( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - monkeypatch.chdir(tmp_path) - - tracer = Tracer("metadata-scope") - set_global_tracer(tracer) - tracer.log_chat_message("hello", "assistant", "agent-1") - tracer.save_run_data(mark_complete=True) - - events_path = tmp_path / "strix_runs" / "metadata-scope" / "events.jsonl" - events = _load_events(events_path) - - run_started = next(event for event in events if event["event_type"] == "run.started") - run_completed = next(event for event in events if event["event_type"] == "run.completed") - chat_event = next(event for event in events if event["event_type"] == "chat.message") - - assert "run_metadata" in run_started - assert "run_metadata" in run_completed - assert "run_metadata" not in chat_event - - -def test_set_run_name_resets_cached_paths(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - monkeypatch.chdir(tmp_path) - - tracer = Tracer() - set_global_tracer(tracer) - old_events_path = tracer.events_file_path - - tracer.set_run_name("renamed-run") - tracer.log_chat_message("hello", "assistant", "agent-1") - - new_events_path = tracer.events_file_path - assert new_events_path != old_events_path - assert new_events_path == tmp_path / "strix_runs" / "renamed-run" / "events.jsonl" - - events = _load_events(new_events_path) - assert any(event["event_type"] == "run.started" for event in events) - assert any(event["event_type"] == "chat.message" for event in events) - - -def test_set_run_name_resets_run_completed_flag( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - monkeypatch.chdir(tmp_path) - - tracer = Tracer() - set_global_tracer(tracer) - - tracer.save_run_data(mark_complete=True) - tracer.set_run_name("renamed-complete") - tracer.save_run_data(mark_complete=True) - - events_path = tmp_path / "strix_runs" / "renamed-complete" / "events.jsonl" - events = _load_events(events_path) - run_completed = [event for event in events if event["event_type"] == "run.completed"] - - assert any(event["event_type"] == "run.started" for event in events) - assert len(run_completed) == 1 - - -def test_set_run_name_updates_traceloop_association_properties( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - monkeypatch.chdir(tmp_path) - - class FakeTraceloop: - associations: ClassVar[list[dict[str, Any]]] = [] - - @staticmethod - def init(**kwargs: Any) -> None: # noqa: ARG004 - return None - - @staticmethod - def set_association_properties(properties: dict[str, Any]) -> None: - FakeTraceloop.associations.append(properties) - - monkeypatch.setattr(tracer_module, "Traceloop", FakeTraceloop) - - tracer = Tracer() - set_global_tracer(tracer) - tracer.set_run_name("renamed-run") - - assert FakeTraceloop.associations - assert FakeTraceloop.associations[-1]["run_id"] == "renamed-run" - assert FakeTraceloop.associations[-1]["run_name"] == "renamed-run" - - -def test_events_write_locks_are_scoped_by_events_file( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - monkeypatch.chdir(tmp_path) - monkeypatch.setenv("STRIX_TELEMETRY", "0") - - tracer_one = Tracer("lock-run-a") - tracer_two = Tracer("lock-run-b") - - lock_a_from_one = tracer_one._get_events_write_lock(tracer_one.events_file_path) - lock_a_from_two = tracer_two._get_events_write_lock(tracer_one.events_file_path) - lock_b = tracer_two._get_events_write_lock(tracer_two.events_file_path) - - assert lock_a_from_one is lock_a_from_two - assert lock_a_from_one is not lock_b - - -def test_tracer_skips_jsonl_when_telemetry_disabled( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - monkeypatch.chdir(tmp_path) - monkeypatch.setenv("STRIX_TELEMETRY", "0") - - tracer = Tracer("telemetry-disabled") - set_global_tracer(tracer) - tracer.log_chat_message("hello", "assistant", "agent-1") - tracer.save_run_data(mark_complete=True) - - events_path = tmp_path / "strix_runs" / "telemetry-disabled" / "events.jsonl" - assert not events_path.exists() - - -def test_tracer_otel_flag_overrides_global_telemetry( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - monkeypatch.chdir(tmp_path) - monkeypatch.setenv("STRIX_TELEMETRY", "0") - monkeypatch.setenv("STRIX_OTEL_TELEMETRY", "1") - - tracer = Tracer("otel-enabled") - set_global_tracer(tracer) - tracer.log_chat_message("hello", "assistant", "agent-1") - tracer.save_run_data(mark_complete=True) - - events_path = tmp_path / "strix_runs" / "otel-enabled" / "events.jsonl" - assert events_path.exists() diff --git a/tests/telemetry/test_utils.py b/tests/telemetry/test_utils.py deleted file mode 100644 index 3e039ac..0000000 --- a/tests/telemetry/test_utils.py +++ /dev/null @@ -1,39 +0,0 @@ -from strix.telemetry.utils import prune_otel_span_attributes - - -def test_prune_otel_span_attributes_drops_high_volume_prompt_content() -> None: - attributes = { - "gen_ai.operation.name": "openai.chat", - "gen_ai.request.model": "gpt-5.2", - "gen_ai.prompt.0.role": "system", - "gen_ai.prompt.0.content": "a" * 20_000, - "gen_ai.completion.0.content": "b" * 10_000, - "llm.input_messages.0.content": "c" * 5_000, - "llm.output_messages.0.content": "d" * 5_000, - "llm.input": "x" * 3_000, - "llm.output": "y" * 3_000, - } - - pruned = prune_otel_span_attributes(attributes) - - assert "gen_ai.prompt.0.content" not in pruned - assert "gen_ai.completion.0.content" not in pruned - assert "llm.input_messages.0.content" not in pruned - assert "llm.output_messages.0.content" not in pruned - assert "llm.input" not in pruned - assert "llm.output" not in pruned - assert pruned["gen_ai.operation.name"] == "openai.chat" - assert pruned["gen_ai.prompt.0.role"] == "system" - assert pruned["strix.filtered_attributes_count"] == 6 - - -def test_prune_otel_span_attributes_keeps_metadata_when_nothing_is_dropped() -> None: - attributes = { - "gen_ai.operation.name": "openai.chat", - "gen_ai.request.model": "gpt-5.2", - "gen_ai.prompt.0.role": "user", - } - - pruned = prune_otel_span_attributes(attributes) - - assert pruned == attributes diff --git a/tests/test_entry.py b/tests/test_entry.py deleted file mode 100644 index 999862a..0000000 --- a/tests/test_entry.py +++ /dev/null @@ -1,319 +0,0 @@ -"""Phase 5 tests for the top-level SDK scan entry point. - -We never spin up a real Docker container or hit a real LLM here. The -tests patch ``session_manager.create_or_reuse``, ``Runner.run``, and -the agent factory so we can verify the wiring shape: - -- The bus is registered with a root agent before Runner.run. -- The context dict carries every field downstream code (tools, hooks, - filter) reads. -- The session manager's bundle flows through to the context (host - ports, bearer, sandbox session/client). -- ``cleanup_on_exit=True`` always cleans up, even when Runner.run - raises. -- ``cleanup_on_exit=False`` preserves the cached session. -- Cancellation propagates: if Runner.run raises, descendants are - cancelled before re-raising. -""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from strix.entry import _build_root_task, _build_scope_context, run_strix_scan -from strix.orchestration.bus import AgentMessageBus - - -# --- helpers ------------------------------------------------------------ - - -def _bundle_for_test() -> dict[str, Any]: - return { - "client": MagicMock(name="docker_client"), - "session": MagicMock(name="sandbox_session"), - "capability": MagicMock(), - "tool_server_host_port": 12001, - "caido_host_port": 12002, - "bearer": "test-bearer-token-1234567890", - } - - -def _scan_config(**overrides: Any) -> dict[str, Any]: - base: dict[str, Any] = { - "targets": [ - { - "type": "web_application", - "details": {"target_url": "https://example.com"}, - }, - ], - "user_instructions": "find xss", - "scan_mode": "deep", - "is_whitebox": False, - } - base.update(overrides) - return base - - -# --- task / scope builders --------------------------------------------- - - -def test_build_root_task_groups_targets_and_appends_instructions() -> None: - config = _scan_config( - targets=[ - { - "type": "repository", - "details": { - "target_repo": "https://github.com/x/y", - "cloned_repo_path": "/tmp/y", - "workspace_subdir": "y", - }, - }, - { - "type": "ip_address", - "details": {"target_ip": "10.0.0.1"}, - }, - ], - user_instructions="report only critical issues", - ) - task = _build_root_task(config) - assert "Repositories:" in task - assert "https://github.com/x/y (available at: /workspace/y)" in task - assert "IP Addresses:" in task - assert "10.0.0.1" in task - assert "Special instructions: report only critical issues" in task - - -def test_build_root_task_renders_diff_scope_block() -> None: - config = _scan_config( - diff_scope={ - "active": True, - "repos": [ - { - "workspace_subdir": "service-x", - "analyzable_files_count": 7, - "deleted_files_count": 2, - }, - ], - }, - ) - task = _build_root_task(config) - assert "Scope Constraints:" in task - assert "service-x: 7 changed file(s)" in task - assert "service-x: 2 deleted file(s)" in task - - -def test_build_scope_context_marks_authorization_source() -> None: - config = _scan_config( - targets=[ - { - "type": "web_application", - "details": {"target_url": "https://target.test"}, - }, - ], - ) - ctx = _build_scope_context(config) - assert ctx["scope_source"] == "system_scan_config" - assert ctx["authorization_source"] == "strix_platform_verified_targets" - assert ctx["user_instructions_do_not_expand_scope"] is True - assert ctx["authorized_targets"] == [ - {"type": "web_application", "value": "https://target.test", "workspace_path": ""}, - ] - - -# --- run_strix_scan wiring --------------------------------------------- - - -@pytest.mark.asyncio -async def test_run_strix_scan_wires_context_and_calls_runner(tmp_path: Path) -> None: - """End-to-end (mocked) โ€” assert every downstream consumer of context - sees the bundle's bearer + host ports.""" - bundle = _bundle_for_test() - captured_context: dict[str, Any] = {} - - async def fake_runner_run(*args: Any, **kwargs: Any) -> Any: - captured_context.update(kwargs.get("context", {})) - return MagicMock(name="run_result") - - with ( - patch( - "strix.entry.session_manager.create_or_reuse", - new=AsyncMock(return_value=bundle), - ) as create_mock, - patch( - "strix.entry.session_manager.cleanup", - new=AsyncMock(), - ) as cleanup_mock, - patch("strix.entry.Runner.run", side_effect=fake_runner_run) as runner_mock, - # Stub the factory to avoid rendering the 158k-char prompt for - # every test (it's covered by sdk_prompt tests). - patch( - "strix.entry.build_strix_agent", - return_value=MagicMock(name="root_agent"), - ) as factory_mock, - ): - await run_strix_scan( - scan_config=_scan_config(), - scan_id="scan-test", - image="strix-sandbox:test", - sources_path=tmp_path, - ) - - # Session manager calls. - create_mock.assert_awaited_once() - create_args = create_mock.await_args - assert create_args is not None - assert create_args.args == ("scan-test",) - assert create_args.kwargs["image"] == "strix-sandbox:test" - assert create_args.kwargs["sources_path"] == tmp_path - cleanup_mock.assert_awaited_once_with("scan-test") - - # Factory called with is_root=True. - factory_mock.assert_called_once() - assert factory_mock.call_args.kwargs["is_root"] is True - - # Runner.run called once with the root agent. - assert runner_mock.call_count == 1 - - # Context shape passed into Runner.run. - assert captured_context["sandbox_session"] is bundle["session"] - assert captured_context["sandbox_client"] is bundle["client"] - assert captured_context["sandbox_token"] == bundle["bearer"] - assert captured_context["tool_server_host_port"] == bundle["tool_server_host_port"] - assert captured_context["caido_host_port"] == bundle["caido_host_port"] - # Bus is registered and root agent_id is populated. - bus = captured_context["bus"] - assert isinstance(bus, AgentMessageBus) - assert captured_context["agent_id"] in bus.statuses - assert bus.parent_of[captured_context["agent_id"]] is None - # Child factory wired through so create_agent works. - assert callable(captured_context["agent_factory"]) - - -@pytest.mark.asyncio -async def test_run_strix_scan_cleans_up_on_runner_failure(tmp_path: Path) -> None: - """If Runner.run raises, cleanup must still fire (the finally branch).""" - bundle = _bundle_for_test() - - with ( - patch( - "strix.entry.session_manager.create_or_reuse", - new=AsyncMock(return_value=bundle), - ), - patch( - "strix.entry.session_manager.cleanup", - new=AsyncMock(), - ) as cleanup_mock, - patch( - "strix.entry.Runner.run", - side_effect=RuntimeError("simulated LLM blow-up"), - ), - patch("strix.entry.build_strix_agent", return_value=MagicMock()), - pytest.raises(RuntimeError, match="simulated LLM"), - ): - await run_strix_scan( - scan_config=_scan_config(), - scan_id="scan-fail", - image="i", - sources_path=tmp_path, - ) - - cleanup_mock.assert_awaited_once_with("scan-fail") - - -@pytest.mark.asyncio -async def test_run_strix_scan_skips_cleanup_when_disabled(tmp_path: Path) -> None: - bundle = _bundle_for_test() - - async def fake_runner_run(*args: Any, **kwargs: Any) -> Any: - return MagicMock() - - with ( - patch( - "strix.entry.session_manager.create_or_reuse", - new=AsyncMock(return_value=bundle), - ), - patch( - "strix.entry.session_manager.cleanup", - new=AsyncMock(), - ) as cleanup_mock, - patch("strix.entry.Runner.run", side_effect=fake_runner_run), - patch("strix.entry.build_strix_agent", return_value=MagicMock()), - ): - await run_strix_scan( - scan_config=_scan_config(), - scan_id="scan-keep", - image="i", - sources_path=tmp_path, - cleanup_on_exit=False, - ) - - cleanup_mock.assert_not_called() - - -@pytest.mark.asyncio -async def test_run_strix_scan_auto_generates_scan_id(tmp_path: Path) -> None: - """A caller without a stable id should still get a valid scan_id - flowing into create_or_reuse.""" - bundle = _bundle_for_test() - captured_scan_id: list[str] = [] - - async def fake_create(scan_id: str, **_kwargs: Any) -> Any: - captured_scan_id.append(scan_id) - return bundle - - with ( - patch( - "strix.entry.session_manager.create_or_reuse", - new=AsyncMock(side_effect=fake_create), - ), - patch("strix.entry.session_manager.cleanup", new=AsyncMock()), - patch("strix.entry.Runner.run", new=AsyncMock(return_value=MagicMock())), - patch("strix.entry.build_strix_agent", return_value=MagicMock()), - ): - await run_strix_scan( - scan_config=_scan_config(), - image="i", - sources_path=tmp_path, - ) - - assert len(captured_scan_id) == 1 - assert captured_scan_id[0].startswith("scan-") - assert len(captured_scan_id[0]) > len("scan-") - - -@pytest.mark.asyncio -async def test_run_strix_scan_passes_scan_level_config_into_factory( - tmp_path: Path, -) -> None: - """scan_mode / is_whitebox flow from scan_config into both the - root factory call and the child factory closure.""" - bundle = _bundle_for_test() - factory_calls: list[dict[str, Any]] = [] - - def fake_factory(**kwargs: Any) -> Any: - factory_calls.append(kwargs) - return MagicMock() - - with ( - patch( - "strix.entry.session_manager.create_or_reuse", - new=AsyncMock(return_value=bundle), - ), - patch("strix.entry.session_manager.cleanup", new=AsyncMock()), - patch("strix.entry.Runner.run", new=AsyncMock(return_value=MagicMock())), - patch("strix.entry.build_strix_agent", side_effect=fake_factory), - ): - await run_strix_scan( - scan_config=_scan_config(scan_mode="fast", is_whitebox=True), - scan_id="s", - image="i", - sources_path=tmp_path, - ) - - assert factory_calls[0]["scan_mode"] == "fast" - assert factory_calls[0]["is_whitebox"] is True - assert factory_calls[0]["is_root"] is True diff --git a/tests/test_run_config_factory.py b/tests/test_run_config_factory.py deleted file mode 100644 index 159ea22..0000000 --- a/tests/test_run_config_factory.py +++ /dev/null @@ -1,173 +0,0 @@ -"""Smoke tests for make_run_config / make_agent_context.""" - -from __future__ import annotations - -from agents import RunConfig -from agents.model_settings import ModelSettings -from agents.retry import ModelRetryBackoffSettings - -from strix.orchestration.bus import AgentMessageBus -from strix.run_config_factory import make_agent_context, make_run_config - - -def test_make_run_config_returns_run_config() -> None: - cfg = make_run_config(sandbox_session=None) - assert isinstance(cfg, RunConfig) - - -def test_default_parallel_tool_calls_is_false() -> None: - """Default is sequential โ€” the tool server serializes one task per agent.""" - cfg = make_run_config(sandbox_session=None) - assert cfg.model_settings is not None - assert cfg.model_settings.parallel_tool_calls is False - - -def test_default_tool_choice_is_required() -> None: - cfg = make_run_config(sandbox_session=None) - assert cfg.model_settings is not None - assert cfg.model_settings.tool_choice == "required" - - -def test_call_model_input_filter_is_wired() -> None: - cfg = make_run_config(sandbox_session=None) - assert cfg.call_model_input_filter is not None - # Wired to inject_messages_filter (validated by name to keep import light). - assert cfg.call_model_input_filter.__name__ == "inject_messages_filter" - - -def test_retry_settings_have_max_retries_5() -> None: - cfg = make_run_config(sandbox_session=None) - assert cfg.model_settings is not None - retry = cfg.model_settings.retry - assert retry is not None - assert retry.max_retries == 5 - - -def test_retry_backoff_uses_strix_defaults() -> None: - """min(90, 2*2^n) with initial 2s, max 90s, x2.""" - cfg = make_run_config(sandbox_session=None) - assert cfg.model_settings is not None - retry = cfg.model_settings.retry - assert retry is not None - backoff = retry.backoff - assert isinstance(backoff, ModelRetryBackoffSettings) - assert backoff.initial_delay == 2.0 - assert backoff.max_delay == 90.0 - assert backoff.multiplier == 2.0 - - -def test_retry_policy_is_set() -> None: - """Retry policy is wired (auth/validation 4xx excluded by construction).""" - cfg = make_run_config(sandbox_session=None) - assert cfg.model_settings is not None - retry = cfg.model_settings.retry - assert retry is not None - assert retry.policy is not None - - -def test_trace_include_sensitive_data_is_false() -> None: - cfg = make_run_config(sandbox_session=None) - assert cfg.trace_include_sensitive_data is False - - -def test_model_settings_override_merges() -> None: - """Per-call override path.""" - override = ModelSettings(tool_choice="auto", parallel_tool_calls=True) - cfg = make_run_config(sandbox_session=None, model_settings_override=override) - assert cfg.model_settings is not None - assert cfg.model_settings.tool_choice == "auto" - assert cfg.model_settings.parallel_tool_calls is True - # Retry settings (not in override) preserved from base. - assert cfg.model_settings.retry is not None - assert cfg.model_settings.retry.max_retries == 5 - - -def test_reasoning_effort_propagates() -> None: - cfg = make_run_config(sandbox_session=None, reasoning_effort="high") - assert cfg.model_settings is not None - assert cfg.model_settings.reasoning is not None - assert cfg.model_settings.reasoning.effort == "high" - - -def test_max_turns_default_is_300() -> None: - """Default max_turns=300 in make_agent_context. - - ``max_turns`` itself is passed to ``Runner.run``; the context copy is - consumed by the budget-warning hook. - """ - bus = AgentMessageBus() - ctx = make_agent_context( - bus=bus, - sandbox_session=None, - sandbox_token=None, - tool_server_host_port=None, - caido_host_port=None, - agent_id="root", - parent_id=None, - tracer=None, - ) - assert ctx["max_turns"] == 300 - - -def test_make_agent_context_full_shape() -> None: - """The context dict carries every field tools/hooks reach for.""" - bus = AgentMessageBus() - ctx = make_agent_context( - bus=bus, - sandbox_session=None, - sandbox_token="bearer-xyz", - tool_server_host_port=48081, - caido_host_port=48080, - agent_id="agent-1", - parent_id=None, - tracer="not-a-real-tracer", - is_whitebox=True, - diff_scope={"changed_files": ["src/app.py"]}, - run_id="strix_runs/abc_def", - ) - - assert ctx["bus"] is bus - assert ctx["agent_id"] == "agent-1" - assert ctx["parent_id"] is None - assert ctx["agent_finish_called"] is False - assert ctx["turn_count"] == 0 - assert ctx["is_whitebox"] is True - assert ctx["diff_scope"] == {"changed_files": ["src/app.py"]} - assert ctx["run_id"] == "strix_runs/abc_def" - assert ctx["sandbox_token"] == "bearer-xyz" - assert ctx["tool_server_host_port"] == 48081 - assert ctx["caido_host_port"] == 48080 - - -def test_make_agent_context_is_whitebox_defaults_false() -> None: - bus = AgentMessageBus() - ctx = make_agent_context( - bus=bus, - sandbox_session=None, - sandbox_token=None, - tool_server_host_port=None, - caido_host_port=None, - agent_id="r", - parent_id=None, - tracer=None, - ) - assert ctx["is_whitebox"] is False - assert ctx["diff_scope"] is None - - -def test_sandbox_config_omitted_when_no_session() -> None: - cfg = make_run_config(sandbox_session=None) - assert cfg.sandbox is None - - -def test_model_default_is_strix_claude() -> None: - cfg = make_run_config(sandbox_session=None) - assert cfg.model == "anthropic/claude-sonnet-4-6" - - -def test_multi_provider_is_built() -> None: - """Verify the factory wires our custom MultiProvider, not the SDK default.""" - cfg = make_run_config(sandbox_session=None) - # MultiProvider is opaque, but our build_multi_provider returns - # an instance with our prefix routes installed. - assert cfg.model_provider is not None diff --git a/tests/tools/__init__.py b/tests/tools/__init__.py deleted file mode 100644 index 182b8f1..0000000 --- a/tests/tools/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for strix.tools module.""" diff --git a/tests/tools/conftest.py b/tests/tools/conftest.py deleted file mode 100644 index 186c576..0000000 --- a/tests/tools/conftest.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Fixtures for strix.tools tests.""" - -from collections.abc import Callable -from typing import Any - -import pytest - - -@pytest.fixture -def sample_function_with_types() -> Callable[..., None]: - """Create a sample function with type annotations for testing argument conversion.""" - - def func( - name: str, - count: int, - enabled: bool, - ratio: float, - items: list[Any], - config: dict[str, Any], - optional: str | None = None, - ) -> None: - pass - - return func - - -@pytest.fixture -def sample_function_no_annotations() -> Callable[..., None]: - """Create a sample function without type annotations.""" - - def func(arg1, arg2, arg3): # type: ignore[no-untyped-def] - pass - - return func diff --git a/tests/tools/test_decorator.py b/tests/tools/test_decorator.py deleted file mode 100644 index dc73e34..0000000 --- a/tests/tools/test_decorator.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Phase 0 smoke tests for the strix_tool decorator factory. - -The SDK's ``FunctionTool`` only honors ``timeout_seconds`` for ``async`` -handlers (verified at ``agents.tool._validate_function_tool_timeout_config``): -sync function bodies cannot be cleanly cancelled, so the SDK refuses to -attach a timeout to them. Every Strix tool is therefore an ``async def``; -sync libraries (libtmux, IPython) get wrapped in ``asyncio.to_thread`` -inside the async tool body. -""" - -from __future__ import annotations - -import pytest -from agents.tool import FunctionTool - -from strix.tools._decorator import strix_tool - - -def test_strix_tool_returns_function_tool() -> None: - @strix_tool() - async def my_tool(x: int) -> str: - """Do a thing.""" - return str(x) - - assert isinstance(my_tool, FunctionTool) - - -def test_strix_tool_default_timeout_is_120s() -> None: - @strix_tool() - async def my_tool(x: int) -> str: - """Do a thing.""" - return str(x) - - assert my_tool.timeout_seconds == 120.0 - - -def test_strix_tool_default_timeout_behavior_is_error_as_result() -> None: - @strix_tool() - async def my_tool(x: int) -> str: - """Do a thing.""" - return str(x) - - assert my_tool.timeout_behavior == "error_as_result" - - -def test_strix_tool_timeout_override() -> None: - @strix_tool(timeout=300) - async def my_tool(x: int) -> str: - """Do a thing.""" - return str(x) - - assert my_tool.timeout_seconds == 300.0 - - -def test_strix_tool_critical_tool_can_raise() -> None: - """C20 (AUDIT_R3): critical tools opt into raise_exception.""" - - @strix_tool(timeout=30, timeout_behavior="raise_exception") - async def critical_tool(x: int) -> str: - """Do a critical thing.""" - return str(x) - - assert critical_tool.timeout_behavior == "raise_exception" - - -def test_strix_tool_sync_handlers_rejected_by_sdk() -> None: - """SDK explicitly rejects timeout on sync handlers; documenting the constraint.""" - with pytest.raises(ValueError, match="async @function_tool"): - - @strix_tool() - def my_sync_tool(x: int) -> str: - """Sync tools can't have a timeout in the SDK.""" - return str(x) diff --git a/tests/tools/test_graph_tools.py b/tests/tools/test_graph_tools.py deleted file mode 100644 index bf99e67..0000000 --- a/tests/tools/test_graph_tools.py +++ /dev/null @@ -1,514 +0,0 @@ -"""Phase 3 tests for the multi-agent graph SDK tools. - -Six tools: view_agent_graph, agent_status, send_message_to_agent, -wait_for_message, create_agent, agent_finish. - -Strategy: - -- Build a real ``AgentMessageBus`` in each test and put it under - ``ctx.context['bus']`` so the tools exercise the same code path - production runs do. -- ``create_agent`` is the only tool that touches ``Runner.run`` and - ``asyncio.create_task``. Its test injects a stub agent factory and - patches the SDK's ``Runner.run`` to a sentinel coroutine โ€” we verify - the spawn shape (bus.register called, task handle stored, identity - block in initial input) without spinning up a real LLM. -- ``wait_for_message`` is exercised on both branches: a message arrives - mid-poll, and the timeout path. -""" - -from __future__ import annotations - -import asyncio -import json -from dataclasses import dataclass, field -from typing import Any -from unittest.mock import AsyncMock, patch - -import pytest -from agents.tool import FunctionTool - -from strix.orchestration.bus import AgentMessageBus -from strix.tools.agents_graph.tools import ( - agent_finish, - agent_status, - create_agent, - send_message_to_agent, - view_agent_graph, - wait_for_message, -) - - -@dataclass -class _Ctx: - context: dict[str, Any] = field(default_factory=dict) - - -async def _invoke(tool: FunctionTool, ctx: _Ctx, **kwargs: Any) -> dict[str, Any]: - from agents.tool_context import ToolContext - - tool_ctx = ToolContext( - context=ctx.context, - usage=None, - tool_name=tool.name, - tool_call_id="test-call-id", - tool_arguments=json.dumps(kwargs), - ) - result = await tool.on_invoke_tool(tool_ctx, json.dumps(kwargs)) - assert isinstance(result, str) - decoded = json.loads(result) - assert isinstance(decoded, dict) - return decoded - - -async def _make_bus_with_agents() -> AgentMessageBus: - """Bus prepopulated with a root + two registered children.""" - bus = AgentMessageBus() - await bus.register("root-1", "root", parent_id=None) - await bus.register("child-A", "scanner", parent_id="root-1") - await bus.register("child-B", "exploiter", parent_id="root-1") - return bus - - -def _ctx_for(bus: AgentMessageBus, agent_id: str = "root-1") -> _Ctx: - return _Ctx(context={"bus": bus, "agent_id": agent_id, "parent_id": None}) - - -# --- registration --------------------------------------------------------- - - -def test_all_graph_tools_are_function_tools() -> None: - for tool in ( - view_agent_graph, - agent_status, - send_message_to_agent, - wait_for_message, - create_agent, - agent_finish, - ): - assert isinstance(tool, FunctionTool) - - -# --- view_agent_graph ----------------------------------------------------- - - -@pytest.mark.asyncio -async def test_view_agent_graph_renders_tree() -> None: - bus = await _make_bus_with_agents() - out = await _invoke(view_agent_graph, _ctx_for(bus)) - assert out["success"] is True - assert "root (root-1)" in out["graph_structure"] - assert "scanner (child-A)" in out["graph_structure"] - assert "exploiter (child-B)" in out["graph_structure"] - # The "you" marker should be on the calling agent's line. - you_lines = [line for line in out["graph_structure"].splitlines() if "โ† you" in line] - assert len(you_lines) == 1 - assert "root-1" in you_lines[0] - assert out["summary"]["total"] == 3 - assert out["summary"]["running"] == 3 - - -@pytest.mark.asyncio -async def test_view_agent_graph_handles_missing_bus() -> None: - out = await _invoke(view_agent_graph, _Ctx(context={})) - assert out["success"] is False - assert "Bus" in out["error"] - - -# --- agent_status --------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_agent_status_returns_state() -> None: - bus = await _make_bus_with_agents() - out = await _invoke(agent_status, _ctx_for(bus), agent_id="child-A") - assert out["success"] is True - assert out["agent_id"] == "child-A" - assert out["name"] == "scanner" - assert out["status"] == "running" - assert out["parent_id"] == "root-1" - assert out["pending_messages"] == 0 - - -@pytest.mark.asyncio -async def test_agent_status_unknown_id() -> None: - bus = await _make_bus_with_agents() - out = await _invoke(agent_status, _ctx_for(bus), agent_id="nope") - assert out["success"] is False - assert "Unknown" in out["error"] - - -# --- send_message_to_agent ----------------------------------------------- - - -@pytest.mark.asyncio -async def test_send_message_queues_into_target_inbox() -> None: - bus = await _make_bus_with_agents() - out = await _invoke( - send_message_to_agent, - _ctx_for(bus, agent_id="child-A"), - target_agent_id="child-B", - message="hello sibling", - priority="high", - ) - assert out["success"] is True - assert out["delivery_status"] == "queued" - # Drain via the same API the filter uses; confirm the message landed. - msgs = await bus.drain("child-B") - assert len(msgs) == 1 - assert msgs[0]["from"] == "child-A" - assert msgs[0]["content"] == "hello sibling" - assert msgs[0]["priority"] == "high" - - -@pytest.mark.asyncio -async def test_send_message_unknown_target() -> None: - bus = await _make_bus_with_agents() - out = await _invoke( - send_message_to_agent, - _ctx_for(bus), - target_agent_id="ghost", - message="hi", - ) - assert out["success"] is False - assert "not found" in out["error"] - - -@pytest.mark.asyncio -async def test_send_message_to_finalized_agent_is_rejected() -> None: - """A finalized target should not silently swallow messages.""" - bus = await _make_bus_with_agents() - await bus.finalize("child-A", "completed") - out = await _invoke( - send_message_to_agent, - _ctx_for(bus), - target_agent_id="child-A", - message="too late", - ) - assert out["success"] is False - # finalize() also clears parent_of/names, so the user-visible state is - # "completed" โ€” confirm the wrapper treats finalized agents as - # undeliverable rather than queuing into a dropped inbox. - assert "completed" in out["error"] or "not found" in out["error"] - - -# --- wait_for_message ---------------------------------------------------- - - -@pytest.mark.asyncio -async def test_wait_for_message_returns_when_message_arrives() -> None: - bus = await _make_bus_with_agents() - - async def deliver_after_short_pause() -> None: - await asyncio.sleep(0.1) - await bus.send("child-A", {"from": "child-B", "content": "ping", "type": "info"}) - - sender = asyncio.create_task(deliver_after_short_pause()) - out = await _invoke( - wait_for_message, - _ctx_for(bus, agent_id="child-A"), - timeout_seconds=3, - ) - await sender - assert out["success"] is True - assert out["status"] == "message_arrived" - assert out["pending_messages"] >= 1 - # Status must be returned to "running" after the wait completes. - assert bus.statuses["child-A"] == "running" - - -@pytest.mark.asyncio -async def test_wait_for_message_times_out() -> None: - bus = await _make_bus_with_agents() - out = await _invoke( - wait_for_message, - _ctx_for(bus, agent_id="child-A"), - timeout_seconds=1, - ) - assert out["success"] is True - assert out["status"] == "timeout" - assert out["timeout_seconds"] == 1 - assert bus.statuses["child-A"] == "running" - - -# --- create_agent -------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_create_agent_requires_factory_in_context() -> None: - bus = await _make_bus_with_agents() - out = await _invoke( - create_agent, - _ctx_for(bus), - name="recon-bot", - task="enumerate hosts", - ) - assert out["success"] is False - assert "agent_factory" in out["error"] - - -@pytest.mark.asyncio -async def test_create_agent_spawns_and_registers_child() -> None: - """Verify the spawn shape without running a real LLM.""" - bus = await _make_bus_with_agents() - - factory_calls: list[dict[str, Any]] = [] - - def fake_factory(*, name: str, skills: list[str]) -> Any: - factory_calls.append({"name": name, "skills": list(skills)}) - # The Runner.run patch below ignores this object; any sentinel - # works. - return object() - - runner_calls: list[dict[str, Any]] = [] - - async def fake_runner_run(*args: Any, **kwargs: Any) -> Any: - # Capture the input + max_turns so the test can assert the - # identity block + delegation envelope are present. - runner_calls.append({"args": args, "kwargs": kwargs}) - await asyncio.sleep(0) # cooperate so create_task can return - return None - - ctx = _Ctx( - context={ - "bus": bus, - "agent_id": "root-1", - "parent_id": None, - "agent_factory": fake_factory, - "sandbox_session": None, - "sandbox_client": None, - "sandbox_token": "token", - "tool_server_host_port": 12345, - "caido_host_port": None, - "tracer": None, - "model": "anthropic/claude-sonnet-4-6", - "model_settings": None, - "max_turns": 300, - "is_whitebox": False, - } - ) - - with patch( - "strix.tools.agents_graph.tools.Runner.run", - side_effect=fake_runner_run, - ): - out = await _invoke( - create_agent, - ctx, - name="recon-bot", - task="enumerate hosts", - inherit_context=False, - skills=["recon"], - ) - # The spawned task must be allowed to run so Runner.run side-effect - # records the call. - new_id = out["agent_id"] - await asyncio.gather(*(t for t in bus.tasks.values()), return_exceptions=True) - - assert out["success"] is True - assert factory_calls == [{"name": "recon-bot", "skills": ["recon"]}] - assert len(runner_calls) == 1 - - # Bus state: child registered, task stored. - assert new_id in bus.statuses - assert bus.parent_of[new_id] == "root-1" - assert bus.names[new_id] == "recon-bot" - assert new_id in bus.tasks - - # Initial input shape: identity preamble + task message at the end. - initial_input = runner_calls[0]["kwargs"]["input"] - assert any( - isinstance(item, dict) and "You are agent recon-bot" in item.get("content", "") - for item in initial_input - ) - assert initial_input[-1]["content"] == "enumerate hosts" - - -@pytest.mark.asyncio -async def test_create_agent_inherits_parent_history() -> None: - bus = await _make_bus_with_agents() - - def fake_factory(*, name: str, skills: list[str]) -> Any: - return object() - - runner_calls: list[dict[str, Any]] = [] - - async def fake_runner_run(*args: Any, **kwargs: Any) -> Any: - runner_calls.append(kwargs) - return None - - parent_history = [ - {"role": "user", "content": "scope: example.com"}, - {"role": "assistant", "content": "I'll start with subdomain enum."}, - ] - ctx = _Ctx( - context={ - "bus": bus, - "agent_id": "root-1", - "parent_id": None, - "agent_factory": fake_factory, - "parent_input_items": parent_history, - "sandbox_session": None, - "sandbox_client": None, - "sandbox_token": "token", - "tool_server_host_port": 12345, - "caido_host_port": None, - "tracer": None, - "model": "anthropic/claude-sonnet-4-6", - "model_settings": None, - "max_turns": 300, - } - ) - - with patch( - "strix.tools.agents_graph.tools.Runner.run", - side_effect=fake_runner_run, - ): - await _invoke( - create_agent, - ctx, - name="child", - task="do thing", - inherit_context=True, - ) - await asyncio.gather(*(t for t in bus.tasks.values()), return_exceptions=True) - - initial_input = runner_calls[0]["input"] - contents = [item.get("content", "") for item in initial_input] - assert any("Inherited context from parent" in c for c in contents) - assert any("End of inherited context" in c for c in contents) - # Parent's exact items should be in between. - assert any(c == "scope: example.com" for c in contents) - - -# --- agent_finish -------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_agent_finish_rejects_root() -> None: - bus = await _make_bus_with_agents() - out = await _invoke( - agent_finish, - _ctx_for(bus, agent_id="root-1"), - result_summary="done", - ) - assert out["success"] is False - assert "agent_finish is for subagents" in out["error"] - - -@pytest.mark.asyncio -async def test_agent_finish_posts_report_to_parent_inbox() -> None: - bus = await _make_bus_with_agents() - ctx = _Ctx( - context={ - "bus": bus, - "agent_id": "child-A", - "parent_id": "root-1", - "agent_finish_called": False, - } - ) - out = await _invoke( - agent_finish, - ctx, - result_summary="found 3 issues", - findings=["xss in /search", "open redirect", "stored xss"], - final_recommendations=["sanitize search input"], - success=True, - ) - assert out["success"] is True - assert out["agent_completed"] is True - assert out["parent_notified"] is True - - # Side effects: agent_finish_called flipped (so on_agent_end records - # "completed", not "crashed"), and the parent's inbox got the report. - assert ctx.context["agent_finish_called"] is True - parent_msgs = await bus.drain("root-1") - assert len(parent_msgs) == 1 - msg = parent_msgs[0] - assert msg["type"] == "completion" - assert msg["from"] == "child-A" - payload = json.loads(msg["content"]) - assert payload["kind"] == "agent_completion_report" - assert payload["summary"] == "found 3 issues" - assert "xss in /search" in payload["findings"] - assert "sanitize search input" in payload["recommendations"] - - -@pytest.mark.asyncio -async def test_agent_finish_skips_parent_when_report_to_parent_false() -> None: - bus = await _make_bus_with_agents() - ctx = _Ctx( - context={ - "bus": bus, - "agent_id": "child-A", - "parent_id": "root-1", - "agent_finish_called": False, - } - ) - out = await _invoke( - agent_finish, - ctx, - result_summary="silent done", - report_to_parent=False, - ) - assert out["success"] is True - assert out["parent_notified"] is False - assert ctx.context["agent_finish_called"] is True - parent_msgs = await bus.drain("root-1") - assert parent_msgs == [] - - -# --- bus integration sanity --------------------------------------------- - - -@pytest.mark.asyncio -async def test_create_agent_spawn_is_cancelable_via_bus() -> None: - """Verify bus.cancel_descendants reaches a child task we just spawned.""" - bus = await _make_bus_with_agents() - - def fake_factory(*, name: str, skills: list[str]) -> Any: - return object() - - # Long-lived child that yields control so we can cancel it before it - # finishes naturally. - async def slow_runner_run(*args: Any, **kwargs: Any) -> Any: - await asyncio.sleep(60) - return None - - ctx = _Ctx( - context={ - "bus": bus, - "agent_id": "root-1", - "parent_id": None, - "agent_factory": fake_factory, - "sandbox_session": None, - "sandbox_client": None, - "sandbox_token": "t", - "tool_server_host_port": 12345, - "caido_host_port": None, - "tracer": None, - "model": "anthropic/claude-sonnet-4-6", - "model_settings": None, - "max_turns": 300, - } - ) - - runner_mock = AsyncMock(side_effect=slow_runner_run) - with patch( - "strix.tools.agents_graph.tools.Runner.run", - new=runner_mock, - ): - out = await _invoke( - create_agent, - ctx, - name="long-running", - task="do thing", - inherit_context=False, - ) - child_id = out["agent_id"] - # Let the task actually start. - await asyncio.sleep(0.05) - await bus.cancel_descendants("root-1") - - # The cancel should have propagated; the task is done (cancelled). - assert bus.tasks[child_id].done() diff --git a/tests/tools/test_local_tools.py b/tests/tools/test_local_tools.py deleted file mode 100644 index bab8e7d..0000000 --- a/tests/tools/test_local_tools.py +++ /dev/null @@ -1,250 +0,0 @@ -"""Phase 2.3 smoke tests for the simplest SDK-wrapped local tools. - -Validates the wrapping pattern (legacy implementation in, JSON string out) -on three tool families: think (trivial), todo (in-memory + agent_state -adapter), notes (in-memory + JSONL persistence). - -If this slice works end-to-end the same pattern carries the rest of the -local tools (reporting, web_search, file_edit, finish_scan, load_skill). -""" - -from __future__ import annotations - -import json -from collections.abc import Iterator -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any -from unittest.mock import patch - -import pytest -from agents.tool import FunctionTool - -from strix.tools.notes import tools as _notes_impl -from strix.tools.notes.tools import ( - create_note, - delete_note, - get_note, - list_notes, - update_note, -) -from strix.tools.thinking.tool import think -from strix.tools.todo.tools import ( - create_todo, - delete_todo, - list_todos, - mark_todo_done, - mark_todo_pending, - update_todo, -) - - -@dataclass -class _Ctx: - """Stand-in for ``RunContextWrapper``.""" - - context: dict[str, Any] = field(default_factory=dict) - - -def _ctx_for(agent_id: str = "test-agent") -> _Ctx: - return _Ctx(context={"agent_id": agent_id}) - - -async def _invoke(tool: FunctionTool, ctx: _Ctx, **kwargs: Any) -> dict[str, Any]: - """Invoke a function tool the way the SDK would and JSON-decode the result.""" - from agents.tool_context import ToolContext - - tool_ctx = ToolContext( - context=ctx.context, - usage=None, - tool_name=tool.name, - tool_call_id="test-call-id", - tool_arguments=json.dumps(kwargs), - ) - result = await tool.on_invoke_tool(tool_ctx, json.dumps(kwargs)) - assert isinstance(result, str) - decoded = json.loads(result) - assert isinstance(decoded, dict) - return decoded - - -# --- think ---------------------------------------------------------------- - - -def test_think_is_a_function_tool() -> None: - assert isinstance(think, FunctionTool) - assert think.name == "think" - - -@pytest.mark.asyncio -async def test_think_records_thought() -> None: - ctx = _ctx_for() - thought = "planning my next move" - out = await _invoke(think, ctx, thought=thought) - assert out["success"] is True - assert f"{len(thought)} characters" in out["message"] - - -@pytest.mark.asyncio -async def test_think_rejects_empty() -> None: - ctx = _ctx_for() - out = await _invoke(think, ctx, thought=" ") - assert out["success"] is False - - -# --- todo ----------------------------------------------------------------- - - -@pytest.fixture(autouse=True) -def _isolate_todo_storage() -> None: - """Each test starts with an empty todo store so tests don't bleed.""" - from strix.tools.todo import tools as todo_module - - todo_module._todos_storage.clear() - - -def test_todo_tools_are_function_tools() -> None: - for tool in ( - create_todo, - list_todos, - update_todo, - mark_todo_done, - mark_todo_pending, - delete_todo, - ): - assert isinstance(tool, FunctionTool) - - -@pytest.mark.asyncio -async def test_todo_lifecycle() -> None: - ctx = _ctx_for("agent-A") - - # Create - created = await _invoke(create_todo, ctx, title="audit endpoint", priority="high") - assert created["success"] is True - assert created["count"] == 1 - todo_id = created["created"][0]["todo_id"] - - # List - listed = await _invoke(list_todos, ctx) - assert listed["success"] is True - assert any(t["todo_id"] == todo_id for t in listed["todos"]) - - # Update - updated = await _invoke(update_todo, ctx, todo_id=todo_id, status="in_progress") - assert updated["success"] is True - - # Mark done - done = await _invoke(mark_todo_done, ctx, todo_id=todo_id) - assert done["success"] is True - - # Reset to pending - pending = await _invoke(mark_todo_pending, ctx, todo_id=todo_id) - assert pending["success"] is True - - # Delete - deleted = await _invoke(delete_todo, ctx, todo_id=todo_id) - assert deleted["success"] is True - - -@pytest.mark.asyncio -async def test_todos_are_per_agent_isolated() -> None: - """Two agents should have independent todo stores.""" - ctx_a = _ctx_for("agent-A") - ctx_b = _ctx_for("agent-B") - - await _invoke(create_todo, ctx_a, title="A's task") - await _invoke(create_todo, ctx_b, title="B's task") - - list_a = await _invoke(list_todos, ctx_a) - list_b = await _invoke(list_todos, ctx_b) - - titles_a = [t["title"] for t in list_a["todos"]] - titles_b = [t["title"] for t in list_b["todos"]] - assert titles_a == ["A's task"] - assert titles_b == ["B's task"] - - -@pytest.mark.asyncio -async def test_create_todo_bulk_via_json_string() -> None: - ctx = _ctx_for() - out = await _invoke( - create_todo, - ctx, - todos=json.dumps( - [ - {"title": "t1", "priority": "high"}, - {"title": "t2", "priority": "low"}, - ], - ), - ) - assert out["success"] is True - assert out["count"] == 2 - - -# --- notes ---------------------------------------------------------------- - - -@pytest.fixture -def notes_run_dir(tmp_path: Path) -> Iterator[Path]: - """Point the legacy notes module at a fresh run dir per test.""" - run_dir = tmp_path / "strix_runs" / "test" - run_dir.mkdir(parents=True) - _notes_impl._notes_storage.clear() - _notes_impl._loaded_notes_run_dir = None - - with patch.object(_notes_impl, "_get_run_dir", return_value=run_dir): - yield run_dir - - -def test_notes_tools_are_function_tools() -> None: - for tool in (create_note, list_notes, get_note, update_note, delete_note): - assert isinstance(tool, FunctionTool) - - -@pytest.mark.asyncio -async def test_note_lifecycle(notes_run_dir: Path) -> None: - ctx = _ctx_for() - - created = await _invoke( - create_note, - ctx, - title="SQLi at /login", - content="Form param `email` reflects into the WHERE clause.", - category="findings", - tags=["sqli", "auth"], - ) - assert created["success"] is True - note_id = created["note_id"] - - listed = await _invoke(list_notes, ctx, category="findings") - assert listed["success"] is True - assert listed["total_count"] == 1 - - fetched = await _invoke(get_note, ctx, note_id=note_id) - assert fetched["success"] is True - assert "WHERE clause" in fetched["note"]["content"] - - updated = await _invoke( - update_note, - ctx, - note_id=note_id, - content="Confirmed boolean-blind SQLi.", - ) - assert updated["success"] is True - - deleted = await _invoke(delete_note, ctx, note_id=note_id) - assert deleted["success"] is True - - -@pytest.mark.asyncio -async def test_notes_jsonl_appended(notes_run_dir: Path) -> None: - """Verify side effect: notes.jsonl receives one event per op.""" - ctx = _ctx_for() - await _invoke(create_note, ctx, title="t", content="c", category="general") - - jsonl = notes_run_dir / "notes" / "notes.jsonl" - assert jsonl.exists() - events = [json.loads(line) for line in jsonl.read_text().splitlines() if line] - assert events[0]["op"] == "create" - assert events[0]["note"]["title"] == "t" diff --git a/tests/tools/test_notes_jsonl_concurrency.py b/tests/tools/test_notes_jsonl_concurrency.py deleted file mode 100644 index 71ee9f0..0000000 --- a/tests/tools/test_notes_jsonl_concurrency.py +++ /dev/null @@ -1,79 +0,0 @@ -"""C6 regression test โ€” concurrent notes JSONL writes must produce valid JSONL. - -This test would fail before the C6 fix (AUDIT_R2 ยง1.1, applied in Phase 2.2): -the legacy ``_append_note_event`` opened the file and called ``f.write`` -without holding ``_notes_lock``, so two threads writing simultaneously -could interleave bytes mid-line and corrupt the JSONL. -""" - -from __future__ import annotations - -import json -import threading -from collections.abc import Iterator -from pathlib import Path -from typing import Any -from unittest.mock import patch - -import pytest - -from strix.tools.notes.tools import _append_note_event - - -@pytest.fixture -def notes_path(tmp_path: Path) -> Iterator[Path]: - """Point ``_get_notes_jsonl_path`` at a tmp file for the test.""" - target = tmp_path / "notes" / "notes.jsonl" - target.parent.mkdir(parents=True, exist_ok=True) - - with patch( - "strix.tools.notes.tools._get_notes_jsonl_path", - return_value=target, - ): - yield target - - -def test_concurrent_note_writes_yield_valid_jsonl(notes_path: Path) -> None: - """C6 fix: 50 threads x 20 events = 1000 lines, all valid JSON. - - Without the lock, byte-level interleaving on the file produces - fragments like ``{"timesta{"timestamp"...`` that fail json.loads. - """ - - def writer(thread_idx: int) -> None: - for i in range(20): - note: dict[str, Any] = { - "title": f"thread-{thread_idx}-note-{i}", - "content": "x" * 200, # non-trivial body to widen the race - "category": "general", - } - _append_note_event( - op="create", - note_id=f"t{thread_idx}-i{i}", - note=note, - ) - - threads = [threading.Thread(target=writer, args=(t,)) for t in range(50)] - for t in threads: - t.start() - for t in threads: - t.join() - - lines = notes_path.read_text(encoding="utf-8").splitlines() - assert len(lines) == 1000, f"expected 1000 lines, got {len(lines)}" - for line in lines: - # raises if the line is malformed JSON - event = json.loads(line) - assert event["op"] == "create" - assert "note_id" in event - - -def test_single_writer_still_works(notes_path: Path) -> None: - """Sanity: serial writes still produce a valid JSONL log.""" - _append_note_event("create", "n1", {"title": "first"}) - _append_note_event("update", "n1", {"title": "first updated"}) - _append_note_event("delete", "n1") - - events = [json.loads(line) for line in notes_path.read_text().splitlines()] - assert [e["op"] for e in events] == ["create", "update", "delete"] - assert all(e["note_id"] == "n1" for e in events) diff --git a/tests/tools/test_notes_wiki.py b/tests/tools/test_notes_wiki.py deleted file mode 100644 index 2db4ab7..0000000 --- a/tests/tools/test_notes_wiki.py +++ /dev/null @@ -1,187 +0,0 @@ -from pathlib import Path - -import pytest - -from strix.telemetry.tracer import Tracer, get_global_tracer, set_global_tracer -from strix.tools.notes import tools as notes_actions - - -def _reset_notes_state() -> None: - notes_actions._notes_storage.clear() - notes_actions._loaded_notes_run_dir = None - - -def test_wiki_notes_are_persisted_and_removed( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.chdir(tmp_path) - _reset_notes_state() - - previous_tracer = get_global_tracer() - tracer = Tracer("wiki-test-run") - set_global_tracer(tracer) - - try: - created = notes_actions._create_note_impl( - title="Repo Map", - content="## Architecture\n- monolith", - category="wiki", - tags=["source-map"], - ) - assert created["success"] is True - note_id = created["note_id"] - assert isinstance(note_id, str) - - note = notes_actions._notes_storage[note_id] - wiki_filename = note.get("wiki_filename") - assert isinstance(wiki_filename, str) - - wiki_path = tmp_path / "strix_runs" / "wiki-test-run" / "wiki" / wiki_filename - assert wiki_path.exists() - assert "## Architecture" in wiki_path.read_text(encoding="utf-8") - - updated = notes_actions._update_note_impl( - note_id=note_id, - content="## Architecture\n- service-oriented", - ) - assert updated["success"] is True - assert "service-oriented" in wiki_path.read_text(encoding="utf-8") - - deleted = notes_actions._delete_note_impl(note_id=note_id) - assert deleted["success"] is True - assert wiki_path.exists() is False - finally: - _reset_notes_state() - set_global_tracer(previous_tracer) - - -def test_notes_jsonl_replay_survives_memory_reset( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.chdir(tmp_path) - _reset_notes_state() - - previous_tracer = get_global_tracer() - tracer = Tracer("notes-replay-run") - set_global_tracer(tracer) - - try: - created = notes_actions._create_note_impl( - title="Auth findings", - content="initial finding", - category="findings", - tags=["auth"], - ) - assert created["success"] is True - note_id = created["note_id"] - assert isinstance(note_id, str) - - notes_path = tmp_path / "strix_runs" / "notes-replay-run" / "notes" / "notes.jsonl" - assert notes_path.exists() is True - - _reset_notes_state() - listed = notes_actions._list_notes_impl(category="findings") - assert listed["success"] is True - assert listed["total_count"] == 1 - assert listed["notes"][0]["note_id"] == note_id - assert "content" not in listed["notes"][0] - assert "content_preview" in listed["notes"][0] - - updated = notes_actions._update_note_impl(note_id=note_id, content="updated finding") - assert updated["success"] is True - - _reset_notes_state() - listed_after_update = notes_actions._list_notes_impl(search="updated finding") - assert listed_after_update["success"] is True - assert listed_after_update["total_count"] == 1 - assert listed_after_update["notes"][0]["note_id"] == note_id - assert listed_after_update["notes"][0]["content_preview"] == "updated finding" - - listed_with_content = notes_actions._list_notes_impl( - category="findings", - include_content=True, - ) - assert listed_with_content["success"] is True - assert listed_with_content["total_count"] == 1 - assert listed_with_content["notes"][0]["content"] == "updated finding" - - deleted = notes_actions._delete_note_impl(note_id=note_id) - assert deleted["success"] is True - - _reset_notes_state() - listed_after_delete = notes_actions._list_notes_impl(category="findings") - assert listed_after_delete["success"] is True - assert listed_after_delete["total_count"] == 0 - finally: - _reset_notes_state() - set_global_tracer(previous_tracer) - - -def test_get_note_returns_full_note(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.chdir(tmp_path) - _reset_notes_state() - - previous_tracer = get_global_tracer() - tracer = Tracer("get-note-run") - set_global_tracer(tracer) - - try: - created = notes_actions._create_note_impl( - title="Repo wiki", - content="entrypoints and sinks", - category="wiki", - tags=["repo:appsmith"], - ) - assert created["success"] is True - note_id = created["note_id"] - assert isinstance(note_id, str) - - result = notes_actions._get_note_impl(note_id=note_id) - assert result["success"] is True - assert result["note"]["note_id"] == note_id - assert result["note"]["content"] == "entrypoints and sinks" - finally: - _reset_notes_state() - set_global_tracer(previous_tracer) - - -def test_list_and_get_note_handle_wiki_repersist_oserror_gracefully( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.chdir(tmp_path) - _reset_notes_state() - - previous_tracer = get_global_tracer() - tracer = Tracer("wiki-repersist-oserror-run") - set_global_tracer(tracer) - - try: - created = notes_actions._create_note_impl( - title="Repo wiki", - content="initial wiki content", - category="wiki", - tags=["repo:demo"], - ) - assert created["success"] is True - note_id = created["note_id"] - assert isinstance(note_id, str) - - _reset_notes_state() - - def _raise_oserror(*_args: object, **_kwargs: object) -> None: - raise OSError("disk full") - - monkeypatch.setattr(notes_actions, "_persist_wiki_note", _raise_oserror) - - listed = notes_actions._list_notes_impl(category="wiki") - assert listed["success"] is True - assert listed["total_count"] == 1 - assert listed["notes"][0]["note_id"] == note_id - - fetched = notes_actions._get_note_impl(note_id=note_id) - assert fetched["success"] is True - assert fetched["note"]["note_id"] == note_id - assert fetched["note"]["content"] == "initial wiki content" - finally: - _reset_notes_state() - set_global_tracer(previous_tracer) diff --git a/tests/tools/test_remaining_local_tools.py b/tests/tools/test_remaining_local_tools.py deleted file mode 100644 index f4958bd..0000000 --- a/tests/tools/test_remaining_local_tools.py +++ /dev/null @@ -1,305 +0,0 @@ -"""Smoke tests for the remaining local SDK tool wrappers. - -Covers: web_search, file_edit (str_replace_editor + list_files + -search_files), reporting (create_vulnerability_report), finish_scan. -""" - -from __future__ import annotations - -import json -from dataclasses import dataclass, field -from typing import Any -from unittest.mock import patch - -import pytest -from agents.tool import FunctionTool - -from strix.tools.file_edit.tools import ( - list_files, - search_files, - str_replace_editor, -) -from strix.tools.finish.tool import finish_scan -from strix.tools.reporting.tool import create_vulnerability_report -from strix.tools.web_search.tool import web_search - - -@dataclass -class _Ctx: - context: dict[str, Any] = field(default_factory=dict) - - -def _ctx_for(agent_id: str = "test-agent") -> _Ctx: - return _Ctx( - context={ - "agent_id": agent_id, - "tool_server_host_port": 12345, - "sandbox_token": "test-token", - }, - ) - - -async def _invoke(tool: FunctionTool, ctx: _Ctx, **kwargs: Any) -> dict[str, Any]: - from agents.tool_context import ToolContext - - tool_ctx = ToolContext( - context=ctx.context, - usage=None, - tool_name=tool.name, - tool_call_id="test-call-id", - tool_arguments=json.dumps(kwargs), - ) - result = await tool.on_invoke_tool(tool_ctx, json.dumps(kwargs)) - assert isinstance(result, str) - decoded = json.loads(result) - assert isinstance(decoded, dict) - return decoded - - -def test_all_remaining_tools_are_function_tools() -> None: - for tool in ( - web_search, - str_replace_editor, - list_files, - search_files, - create_vulnerability_report, - finish_scan, - ): - assert isinstance(tool, FunctionTool) - - -# --- web_search ----------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_web_search_no_api_key_returns_structured_error( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The legacy function returns a structured error when the env var is - missing โ€” verify the wrapper passes that through verbatim.""" - monkeypatch.delenv("PERPLEXITY_API_KEY", raising=False) - - out = await _invoke(web_search, _ctx_for(), query="anything") - assert out["success"] is False - assert "PERPLEXITY_API_KEY" in out["message"] - - -@pytest.mark.asyncio -async def test_web_search_delegates_to_perplexity(monkeypatch: pytest.MonkeyPatch) -> None: - """The wrapper invokes the Perplexity HTTP path on a thread.""" - monkeypatch.setenv("PERPLEXITY_API_KEY", "fake-key") - - fake_result = { - "success": True, - "query": "xss techniques", - "content": "Reflected XSS payload examples...", - "message": "Web search completed successfully", - } - with patch( - "strix.tools.web_search.tool._do_search", - return_value=fake_result, - ) as do_search: - out = await _invoke(web_search, _ctx_for(), query="xss techniques") - - assert out == fake_result - do_search.assert_called_once_with("xss techniques") - - -# --- file_edit (sandbox-bound) ------------------------------------------- - - -@pytest.mark.asyncio -async def test_str_replace_editor_routes_to_sandbox() -> None: - """file_edit tools must POST to the in-sandbox tool server, not run locally.""" - fake_response = {"result": {"content": "file viewed"}} - with patch( - "strix.tools.file_edit.tools.post_to_sandbox", - return_value=fake_response, - ) as dispatch: - out = await _invoke( - str_replace_editor, - _ctx_for(), - command="view", - path="src/foo.py", - ) - - assert out == fake_response - assert dispatch.call_count == 1 - # post_to_sandbox is called positionally as (ctx, tool_name, kwargs). - args, _ = dispatch.call_args - assert args[1] == "str_replace_editor" - assert args[2]["command"] == "view" - assert args[2]["path"] == "src/foo.py" - # All optional file-edit params are forwarded as None (parity with legacy schema). - assert args[2]["file_text"] is None - assert args[2]["old_str"] is None - - -@pytest.mark.asyncio -async def test_list_files_routes_to_sandbox() -> None: - fake_response = {"result": {"files": ["a.py"], "directories": []}} - with patch( - "strix.tools.file_edit.tools.post_to_sandbox", - return_value=fake_response, - ) as dispatch: - out = await _invoke(list_files, _ctx_for(), path="src", recursive=True) - - assert out == fake_response - args, _ = dispatch.call_args - assert args[1] == "list_files" - assert args[2] == {"path": "src", "recursive": True} - - -@pytest.mark.asyncio -async def test_search_files_routes_to_sandbox() -> None: - fake_response = {"result": {"output": "src/foo.py:1:match"}} - with patch( - "strix.tools.file_edit.tools.post_to_sandbox", - return_value=fake_response, - ) as dispatch: - out = await _invoke( - search_files, - _ctx_for(), - path="src", - regex="TODO", - file_pattern="*.py", - ) - - assert out == fake_response - args, _ = dispatch.call_args - assert args[1] == "search_files" - assert args[2] == {"path": "src", "regex": "TODO", "file_pattern": "*.py"} - - -# --- reporting ----------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_create_vulnerability_report_validates_required_fields() -> None: - """Empty required fields should be rejected by the validator.""" - out = await _invoke( - create_vulnerability_report, - _ctx_for(), - title="", # empty -> validation error - description="d", - impact="i", - target="t", - technical_analysis="ta", - poc_description="pd", - poc_script_code="curl ...", - remediation_steps="rs", - cvss_breakdown={"attack_vector": "N"}, - ) - assert out["success"] is False - assert "errors" in out - assert any("Title" in e for e in out["errors"]) - - -@pytest.mark.asyncio -async def test_create_vulnerability_report_delegates_to_impl() -> None: - """Verify the wrapper threads its kwargs through to the implementation.""" - fake_result = { - "success": True, - "message": "Vulnerability report 'X' created successfully", - "report_id": "abc123", - "severity": "high", - "cvss_score": 7.5, - } - with patch( - "strix.tools.reporting.tool._do_create", - return_value=fake_result, - ) as do_create: - out = await _invoke( - create_vulnerability_report, - _ctx_for(), - title="t", - description="d", - impact="i", - target="tg", - technical_analysis="ta", - poc_description="pd", - poc_script_code="pc", - remediation_steps="rs", - cvss_breakdown={"attack_vector": "N"}, - cve="CVE-2024-12345", - ) - - assert out == fake_result - kwargs = do_create.call_args.kwargs - assert kwargs["title"] == "t" - assert kwargs["cve"] == "CVE-2024-12345" - # Optional params we didn't pass should still be forwarded as None. - assert kwargs["endpoint"] is None - assert kwargs["method"] is None - - -# --- finish_scan --------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_finish_scan_validates_empty_fields() -> None: - """Legacy validation: every section must be non-empty.""" - out = await _invoke( - finish_scan, - _ctx_for(), - executive_summary="", - methodology="m", - technical_analysis="ta", - recommendations="r", - ) - assert out["success"] is False - assert any("Executive summary" in e for e in out["errors"]) - - -@pytest.mark.asyncio -async def test_finish_scan_rejects_subagent() -> None: - """A subagent (parent_id is set) must not be able to finish the scan.""" - ctx = _Ctx( - context={ - "agent_id": "child-1", - "parent_id": "root-1", - "tool_server_host_port": 12345, - "sandbox_token": "test-token", - }, - ) - out = await _invoke( - finish_scan, - ctx, - executive_summary="es", - methodology="m", - technical_analysis="ta", - recommendations="r", - ) - assert out["success"] is False - assert out["error"] == "finish_scan_wrong_agent" - - -@pytest.mark.asyncio -async def test_finish_scan_persists_via_tracer() -> None: - """When a global tracer exists, finish_scan should write the four sections.""" - from unittest.mock import MagicMock - - fake_tracer = MagicMock() - fake_tracer.vulnerability_reports = [{}, {}, {}] - - with patch( - "strix.telemetry.tracer.get_global_tracer", - return_value=fake_tracer, - ): - out = await _invoke( - finish_scan, - _ctx_for("root-agent"), - executive_summary="es", - methodology="m", - technical_analysis="ta", - recommendations="r", - ) - - assert out["success"] is True - assert out["vulnerabilities_found"] == 3 - fake_tracer.update_scan_final_fields.assert_called_once_with( - executive_summary="es", - methodology="m", - technical_analysis="ta", - recommendations="r", - ) diff --git a/tests/tools/test_sandbox_dispatch.py b/tests/tools/test_sandbox_dispatch.py deleted file mode 100644 index f9d175a..0000000 --- a/tests/tools/test_sandbox_dispatch.py +++ /dev/null @@ -1,206 +0,0 @@ -"""Phase 2.1 smoke tests for the sandbox dispatch helper.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -import httpx -import pytest - -from strix.tools._sandbox_dispatch import post_to_sandbox - - -@dataclass -class _Ctx: - """Stand-in for ``RunContextWrapper``. Only ``.context`` is touched.""" - - context: dict[str, Any] = field(default_factory=dict) - - -def _ok_ctx(**overrides: Any) -> _Ctx: - base: dict[str, Any] = { - "tool_server_host_port": 48081, - "sandbox_token": "test-bearer", - "agent_id": "agent-1", - } - base.update(overrides) - return _Ctx(context=base) - - -@pytest.mark.asyncio -async def test_missing_context_returns_error() -> None: - """If ``ctx.context`` isn't a dict, return error โ€” never raise.""" - ctx = _Ctx(context="not a dict") - result = await post_to_sandbox(ctx, "browser_action", {"action": "launch"}) - assert "error" in result - assert "context" in result["error"].lower() - - -@pytest.mark.asyncio -async def test_missing_port_or_token_returns_error() -> None: - ctx = _Ctx(context={"sandbox_token": "tok"}) # no port - result = await post_to_sandbox(ctx, "x", {}) - assert "error" in result - assert "tool server port" in result["error"].lower() - - -@pytest.mark.asyncio -async def test_successful_response_returned_as_dict( - monkeypatch: pytest.MonkeyPatch, -) -> None: - captured: dict[str, Any] = {} - - async def fake_post( - self: httpx.AsyncClient, - url: str, - *, - json: dict[str, Any] | None = None, - headers: dict[str, str] | None = None, - ) -> httpx.Response: - captured["url"] = url - captured["json"] = json - captured["headers"] = headers - return httpx.Response( - status_code=200, - json={"result": "ok"}, - request=httpx.Request("POST", url), - ) - - monkeypatch.setattr(httpx.AsyncClient, "post", fake_post) - - result = await post_to_sandbox(_ok_ctx(), "terminal_execute", {"command": "ls"}) - assert result == {"result": "ok"} - assert captured["url"] == "http://127.0.0.1:48081/execute" - assert captured["json"] == { - "agent_id": "agent-1", - "tool_name": "terminal_execute", - "kwargs": {"command": "ls"}, - } - assert captured["headers"]["Authorization"] == "Bearer test-bearer" - - -@pytest.mark.asyncio -async def test_401_returns_auth_error(monkeypatch: pytest.MonkeyPatch) -> None: - async def fake_post( - self: httpx.AsyncClient, - url: str, - **_: Any, - ) -> httpx.Response: - return httpx.Response( - status_code=401, - text="forbidden", - request=httpx.Request("POST", url), - ) - - monkeypatch.setattr(httpx.AsyncClient, "post", fake_post) - result = await post_to_sandbox(_ok_ctx(), "x", {}) - assert "error" in result - assert "authorization" in result["error"].lower() - - -@pytest.mark.asyncio -async def test_5xx_returns_http_error(monkeypatch: pytest.MonkeyPatch) -> None: - async def fake_post( - self: httpx.AsyncClient, - url: str, - **_: Any, - ) -> httpx.Response: - return httpx.Response( - status_code=503, - text="server fell over", - request=httpx.Request("POST", url), - ) - - monkeypatch.setattr(httpx.AsyncClient, "post", fake_post) - result = await post_to_sandbox(_ok_ctx(), "browser_action", {}) - assert "error" in result - assert "503" in result["error"] - - -@pytest.mark.asyncio -async def test_timeout_returns_timeout_error(monkeypatch: pytest.MonkeyPatch) -> None: - async def fake_post(*_args: Any, **_kwargs: Any) -> httpx.Response: - raise httpx.ReadTimeout("read timeout") - - monkeypatch.setattr(httpx.AsyncClient, "post", fake_post) - result = await post_to_sandbox(_ok_ctx(), "python_action", {}) - assert "error" in result - assert "timed out" in result["error"].lower() - - -@pytest.mark.asyncio -async def test_connection_error_returns_error(monkeypatch: pytest.MonkeyPatch) -> None: - async def fake_post(*_args: Any, **_kwargs: Any) -> httpx.Response: - raise httpx.ConnectError("refused") - - monkeypatch.setattr(httpx.AsyncClient, "post", fake_post) - result = await post_to_sandbox(_ok_ctx(), "x", {}) - assert "error" in result - assert "connection" in result["error"].lower() - - -@pytest.mark.asyncio -async def test_response_too_large_capped(monkeypatch: pytest.MonkeyPatch) -> None: - """C18 (AUDIT_R3): response > 50MB returns error, doesn't OOM.""" - - async def fake_post( - self: httpx.AsyncClient, - url: str, - **_: Any, - ) -> httpx.Response: - # Construct a response with a >50MB body. Use a string trick โ€” - # httpx.Response stores .content directly; we make it look huge. - big = b"x" * (51 * 1024 * 1024) - return httpx.Response( - status_code=200, - content=big, - request=httpx.Request("POST", url), - ) - - monkeypatch.setattr(httpx.AsyncClient, "post", fake_post) - result = await post_to_sandbox(_ok_ctx(), "browser_action", {}) - assert "error" in result - assert "too large" in result["error"].lower() - - -@pytest.mark.asyncio -async def test_non_json_response_returns_error( - monkeypatch: pytest.MonkeyPatch, -) -> None: - async def fake_post( - self: httpx.AsyncClient, - url: str, - **_: Any, - ) -> httpx.Response: - return httpx.Response( - status_code=200, - text="hello not json", - request=httpx.Request("POST", url), - ) - - monkeypatch.setattr(httpx.AsyncClient, "post", fake_post) - result = await post_to_sandbox(_ok_ctx(), "x", {}) - assert "error" in result - assert "non-json" in result["error"].lower() - - -@pytest.mark.asyncio -async def test_non_object_json_returns_error( - monkeypatch: pytest.MonkeyPatch, -) -> None: - async def fake_post( - self: httpx.AsyncClient, - url: str, - **_: Any, - ) -> httpx.Response: - return httpx.Response( - status_code=200, - json=["a", "list", "not", "an", "object"], - request=httpx.Request("POST", url), - ) - - monkeypatch.setattr(httpx.AsyncClient, "post", fake_post) - result = await post_to_sandbox(_ok_ctx(), "x", {}) - assert "error" in result - assert "non-object" in result["error"].lower() diff --git a/tests/tools/test_sandbox_tools.py b/tests/tools/test_sandbox_tools.py deleted file mode 100644 index f6bd054..0000000 --- a/tests/tools/test_sandbox_tools.py +++ /dev/null @@ -1,348 +0,0 @@ -"""Smoke tests for the sandbox-bound SDK tool wrappers. - -Covers: browser_action, terminal_execute, python_action, file_edit -helpers, and the seven Caido proxy tools. - -These wrappers are pure pass-throughs to ``post_to_sandbox`` โ€” there's -no per-tool logic to assert, so the tests focus on: - -- ``FunctionTool`` registration succeeds (which proves the SDK could - derive a JSON schema from the type hints โ€” a non-trivial check given - Literal types, ``dict[str, str]``, and strict-mode opt-outs). -- The dispatch payload to ``post_to_sandbox`` carries the right - ``kwargs`` shape so the in-container tool server can dispatch to the - matching action. -- The ``send_request`` / ``repeat_request`` tools opt out of strict - schema mode (their ``headers`` / ``modifications`` dicts are - free-form and would otherwise fail registration). -""" - -from __future__ import annotations - -import json -from dataclasses import dataclass, field -from typing import Any -from unittest.mock import patch - -import pytest -from agents.tool import FunctionTool - -from strix.tools.browser.tool import browser_action -from strix.tools.proxy.tools import ( - list_requests, - list_sitemap, - repeat_request, - scope_rules, - send_request, - view_request, - view_sitemap_entry, -) -from strix.tools.python.tool import python_action -from strix.tools.terminal.tool import terminal_execute - - -_ALL_SANDBOX_TOOLS = ( - browser_action, - terminal_execute, - python_action, - list_requests, - view_request, - send_request, - repeat_request, - scope_rules, - list_sitemap, - view_sitemap_entry, -) - - -@dataclass -class _Ctx: - context: dict[str, Any] = field(default_factory=dict) - - -def _ctx_for(agent_id: str = "test-agent") -> _Ctx: - return _Ctx( - context={ - "agent_id": agent_id, - "tool_server_host_port": 12345, - "sandbox_token": "test-token", - }, - ) - - -async def _invoke(tool: FunctionTool, ctx: _Ctx, **kwargs: Any) -> dict[str, Any]: - from agents.tool_context import ToolContext - - tool_ctx = ToolContext( - context=ctx.context, - usage=None, - tool_name=tool.name, - tool_call_id="test-call-id", - tool_arguments=json.dumps(kwargs), - ) - result = await tool.on_invoke_tool(tool_ctx, json.dumps(kwargs)) - assert isinstance(result, str) - decoded = json.loads(result) - assert isinstance(decoded, dict) - return decoded - - -def test_all_sandbox_tools_register() -> None: - for tool in _ALL_SANDBOX_TOOLS: - assert isinstance(tool, FunctionTool) - - -def test_send_and_repeat_request_opt_out_of_strict_mode() -> None: - """The two free-form-dict tools must turn off strict JSON schema.""" - assert send_request.strict_json_schema is False - assert repeat_request.strict_json_schema is False - # All other tools should keep strict mode on (the SDK default). - for tool in ( - browser_action, - terminal_execute, - python_action, - list_requests, - view_request, - scope_rules, - list_sitemap, - view_sitemap_entry, - ): - assert tool.strict_json_schema is True, tool.name - - -# --- browser --------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_browser_action_dispatches_full_payload() -> None: - """All optional browser_action params must forward as None when unset - (the in-container handler distinguishes ``None`` from missing).""" - fake = {"result": {"screenshot": "data:image/png;base64,..."}} - with patch( - "strix.tools.browser.tool.post_to_sandbox", - return_value=fake, - ) as dispatch: - out = await _invoke( - browser_action, - _ctx_for(), - action="goto", - url="https://example.com", - ) - - assert out == fake - args, _ = dispatch.call_args - assert args[1] == "browser_action" - payload = args[2] - assert payload["action"] == "goto" - assert payload["url"] == "https://example.com" - # All optional params are present in the payload (as None / defaults) - # so the legacy in-container handler sees the full kwarg surface. - for key in ( - "coordinate", - "text", - "tab_id", - "js_code", - "duration", - "key", - "file_path", - ): - assert key in payload, key - assert payload[key] is None - assert payload["clear"] is False - - -# --- terminal -------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_terminal_execute_dispatches() -> None: - fake = {"result": {"content": "hello\n", "exit_code": 0}} - with patch( - "strix.tools.terminal.tool.post_to_sandbox", - return_value=fake, - ) as dispatch: - out = await _invoke( - terminal_execute, - _ctx_for(), - command="echo hello", - terminal_id="term-1", - ) - - assert out == fake - args, _ = dispatch.call_args - assert args[1] == "terminal_execute" - assert args[2]["command"] == "echo hello" - assert args[2]["terminal_id"] == "term-1" - assert args[2]["is_input"] is False - assert args[2]["no_enter"] is False - assert args[2]["timeout"] is None - - -# --- python --------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_python_action_dispatches() -> None: - fake = {"result": {"stdout": "42\n", "is_running": False}} - with patch( - "strix.tools.python.tool.post_to_sandbox", - return_value=fake, - ) as dispatch: - out = await _invoke( - python_action, - _ctx_for(), - action="execute", - code="print(6*7)", - session_id="sess-1", - ) - - assert out == fake - args, _ = dispatch.call_args - assert args[1] == "python_action" - assert args[2] == { - "action": "execute", - "code": "print(6*7)", - "timeout": 30, - "session_id": "sess-1", - } - - -# --- proxy / Caido -------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_list_requests_forwards_full_query() -> None: - fake: dict[str, Any] = {"result": {"requests": []}} - with patch( - "strix.tools.proxy.tools.post_to_sandbox", - return_value=fake, - ) as dispatch: - await _invoke( - list_requests, - _ctx_for(), - httpql_filter="resp.code:eq:500", - page_size=20, - sort_by="response_time", - sort_order="asc", - ) - - args, _ = dispatch.call_args - assert args[1] == "list_requests" - assert args[2]["httpql_filter"] == "resp.code:eq:500" - assert args[2]["page_size"] == 20 - assert args[2]["sort_by"] == "response_time" - assert args[2]["sort_order"] == "asc" - # Defaults preserved. - assert args[2]["start_page"] == 1 - assert args[2]["end_page"] == 1 - - -@pytest.mark.asyncio -async def test_view_request_dispatches() -> None: - fake = {"result": {"raw": "GET / HTTP/1.1..."}} - with patch( - "strix.tools.proxy.tools.post_to_sandbox", - return_value=fake, - ) as dispatch: - await _invoke( - view_request, - _ctx_for(), - request_id="req-9", - part="response", - search_pattern="Set-Cookie", - ) - - args, _ = dispatch.call_args - assert args[1] == "view_request" - assert args[2]["request_id"] == "req-9" - assert args[2]["part"] == "response" - assert args[2]["search_pattern"] == "Set-Cookie" - - -@pytest.mark.asyncio -async def test_send_request_normalizes_missing_headers() -> None: - """Legacy schema treats omitted ``headers`` as ``{}``; the wrapper must too.""" - fake = {"result": {"status": 200}} - with patch( - "strix.tools.proxy.tools.post_to_sandbox", - return_value=fake, - ) as dispatch: - await _invoke( - send_request, - _ctx_for(), - method="POST", - url="https://api.example.com/login", - body='{"u":"x"}', - ) - - args, _ = dispatch.call_args - assert args[1] == "send_request" - assert args[2]["headers"] == {} # not None - assert args[2]["method"] == "POST" - assert args[2]["body"] == '{"u":"x"}' - - -@pytest.mark.asyncio -async def test_repeat_request_normalizes_missing_modifications() -> None: - fake = {"result": {"status": 200}} - with patch( - "strix.tools.proxy.tools.post_to_sandbox", - return_value=fake, - ) as dispatch: - await _invoke(repeat_request, _ctx_for(), request_id="req-1") - - args, _ = dispatch.call_args - assert args[1] == "repeat_request" - assert args[2]["modifications"] == {} - - -@pytest.mark.asyncio -async def test_scope_rules_dispatches() -> None: - fake = {"result": {"scope_id": "s-1"}} - with patch( - "strix.tools.proxy.tools.post_to_sandbox", - return_value=fake, - ) as dispatch: - await _invoke( - scope_rules, - _ctx_for(), - action="create", - scope_name="prod", - allowlist=["*.example.com"], - ) - - args, _ = dispatch.call_args - assert args[1] == "scope_rules" - assert args[2]["action"] == "create" - assert args[2]["scope_name"] == "prod" - assert args[2]["allowlist"] == ["*.example.com"] - assert args[2]["denylist"] is None - - -@pytest.mark.asyncio -async def test_list_sitemap_defaults() -> None: - fake: dict[str, Any] = {"result": {"entries": []}} - with patch( - "strix.tools.proxy.tools.post_to_sandbox", - return_value=fake, - ) as dispatch: - await _invoke(list_sitemap, _ctx_for()) - - args, _ = dispatch.call_args - assert args[1] == "list_sitemap" - assert args[2]["depth"] == "DIRECT" - assert args[2]["page"] == 1 - - -@pytest.mark.asyncio -async def test_view_sitemap_entry_dispatches() -> None: - fake = {"result": {"entry_id": "e-1"}} - with patch( - "strix.tools.proxy.tools.post_to_sandbox", - return_value=fake, - ) as dispatch: - await _invoke(view_sitemap_entry, _ctx_for(), entry_id="e-1") - - args, _ = dispatch.call_args - assert args[1] == "view_sitemap_entry" - assert args[2] == {"entry_id": "e-1"} diff --git a/tests/tools/test_tool_registration_modes.py b/tests/tools/test_tool_registration_modes.py deleted file mode 100644 index ac2346a..0000000 --- a/tests/tools/test_tool_registration_modes.py +++ /dev/null @@ -1,62 +0,0 @@ -import importlib -import sys -from types import ModuleType -from typing import Any - -from strix.config import Config -from strix.tools.registry import clear_registry - - -def _empty_config_load(_cls: type[Config]) -> dict[str, dict[str, str]]: - return {"env": {}} - - -def _reload_tools_module() -> ModuleType: - clear_registry() - - for name in list(sys.modules): - if name == "strix.tools" or name.startswith("strix.tools."): - sys.modules.pop(name, None) - - return importlib.import_module("strix.tools") - - -def test_non_sandbox_skips_browser_and_web_search_when_disabled( - monkeypatch: Any, -) -> None: - """Browser registration is gated on STRIX_DISABLE_BROWSER and - web_search on PERPLEXITY_API_KEY; both should stay out of the - in-container ``register_tool`` registry when their gates are off. - Agents_graph is no longer in this registry โ€” those tools are SDK - function tools (host-side only), not in-container tools. - """ - monkeypatch.setenv("STRIX_SANDBOX_MODE", "false") - monkeypatch.setenv("STRIX_DISABLE_BROWSER", "true") - monkeypatch.delenv("PERPLEXITY_API_KEY", raising=False) - monkeypatch.setattr(Config, "load", classmethod(_empty_config_load)) - - tools = _reload_tools_module() - names = set(tools.get_tool_names()) - - assert "browser_action" not in names - assert "web_search" not in names - - -def test_sandbox_registers_sandbox_tools_but_not_non_sandbox_tools( - monkeypatch: Any, -) -> None: - monkeypatch.setenv("STRIX_SANDBOX_MODE", "true") - monkeypatch.setenv("STRIX_DISABLE_BROWSER", "true") - monkeypatch.delenv("PERPLEXITY_API_KEY", raising=False) - monkeypatch.setattr(Config, "load", classmethod(_empty_config_load)) - - tools = _reload_tools_module() - names = set(tools.get_tool_names()) - - assert "terminal_execute" in names - assert "python_action" in names - assert "list_requests" in names - assert "create_agent" not in names - assert "finish_scan" not in names - assert "browser_action" not in names - assert "web_search" not in names diff --git a/uv.lock b/uv.lock index 59e2078..94b963c 100644 --- a/uv.lock +++ b/uv.lock @@ -731,90 +731,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, ] -[[package]] -name = "coverage" -version = "7.13.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, - { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, - { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, - { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, - { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, - { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, - { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, - { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, - { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, - { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, - { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, - { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, - { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, - { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, - { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, - { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, - { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, - { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, - { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, - { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, - { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, - { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, - { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, - { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, - { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, - { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, - { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, - { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, - { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, - { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, - { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, - { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, - { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, - { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, - { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, - { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, - { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, - { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, - { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, - { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, - { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, - { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, - { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, - { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, - { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, - { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, - { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, - { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, - { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, - { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, - { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, - { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, - { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, - { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, - { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, - { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, - { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, - { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, - { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, - { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, - { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, -] - [[package]] name = "croniter" version = "6.2.2" @@ -1816,15 +1732,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" }, ] -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - [[package]] name = "ipython" version = "9.11.0" @@ -3917,15 +3824,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" }, ] -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - [[package]] name = "polars" version = "1.39.3" @@ -4468,61 +4366,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/d0/bb522283b90853afbf506cd5b71c650cf708829914efd0003d615cf426cd/pyte-0.8.2-py3-none-any.whl", hash = "sha256:85db42a35798a5aafa96ac4d8da78b090b2c933248819157fc0e6f78876a0135", size = 31627, upload-time = "2023-11-12T09:33:41.096Z" }, ] -[[package]] -name = "pytest" -version = "9.0.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, -] - -[[package]] -name = "pytest-asyncio" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, -] - -[[package]] -name = "pytest-cov" -version = "7.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "coverage" }, - { name = "pluggy" }, - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, -] - -[[package]] -name = "pytest-mock" -version = "3.15.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, -] - [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -5471,10 +5314,6 @@ dev = [ { name = "pyinstaller", marker = "python_full_version < '3.15'" }, { name = "pylint" }, { name = "pyright" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "pytest-cov" }, - { name = "pytest-mock" }, { name = "ruff" }, ] @@ -5515,10 +5354,6 @@ dev = [ { name = "pyinstaller", marker = "python_full_version >= '3.12' and python_full_version < '3.15'", specifier = ">=6.17.0" }, { name = "pylint", specifier = ">=3.3.7" }, { name = "pyright", specifier = ">=1.1.401" }, - { name = "pytest", specifier = ">=8.4.0" }, - { name = "pytest-asyncio", specifier = ">=1.0.0" }, - { name = "pytest-cov", specifier = ">=6.1.1" }, - { name = "pytest-mock", specifier = ">=3.14.1" }, { name = "ruff", specifier = ">=0.11.13" }, ]