"""Tests for ExecutionContext singleton. This module tests the ExecutionContext class which provides a single source of truth for all configuration in Skill Seekers. """ import argparse import json import os import tempfile import pytest from skill_seekers.cli.execution_context import ( ExecutionContext, get_context, ) class TestExecutionContextBasics: """Basic functionality tests.""" def setup_method(self): """Reset singleton before each test.""" ExecutionContext.reset() def teardown_method(self): """Clean up after each test.""" ExecutionContext.reset() def test_get_returns_defaults_when_not_initialized(self): """Should return default context when not explicitly initialized.""" ctx = ExecutionContext.get() assert ctx is not None assert ctx.enhancement.level == 2 # default assert ctx.output.name is None # default def test_get_context_shortcut(self): """get_context() should be equivalent to ExecutionContext.get().""" args = argparse.Namespace(name="test-skill") ExecutionContext.initialize(args=args) ctx = get_context() assert ctx.output.name == "test-skill" def test_initialize_returns_instance(self): """initialize() should return the context instance.""" args = argparse.Namespace(name="test") ctx = ExecutionContext.initialize(args=args) assert isinstance(ctx, ExecutionContext) assert ctx.output.name == "test" def test_singleton_behavior(self): """Multiple calls should return same instance.""" args = argparse.Namespace(name="first") ctx1 = ExecutionContext.initialize(args=args) ctx2 = ExecutionContext.get() assert ctx1 is ctx2 def test_reset_clears_instance(self): """reset() should clear the initialized instance, get() returns fresh defaults.""" args = argparse.Namespace(name="test-skill") ExecutionContext.initialize(args=args) assert ExecutionContext.get().output.name == "test-skill" ExecutionContext.reset() # After reset, get() returns default context (not the old one) ctx = ExecutionContext.get() assert ctx.output.name is None # default, not "test-skill" class TestExecutionContextFromArgs: """Tests for building context from CLI args.""" def setup_method(self): ExecutionContext.reset() def teardown_method(self): ExecutionContext.reset() def test_basic_args(self): """Should extract basic args correctly.""" args = argparse.Namespace( name="react-docs", output="custom/output", doc_version="18.2", dry_run=True, enhance_level=3, agent="kimi", ) ctx = ExecutionContext.initialize(args=args) assert ctx.output.name == "react-docs" assert ctx.output.output_dir == "custom/output" assert ctx.output.doc_version == "18.2" assert ctx.output.dry_run is True assert ctx.enhancement.level == 3 assert ctx.enhancement.agent == "kimi" def test_scraping_args(self): """Should extract scraping args correctly.""" args = argparse.Namespace( name="test", max_pages=100, rate_limit=1.5, browser=True, workers=4, async_mode=True, resume=True, fresh=False, skip_scrape=True, ) ctx = ExecutionContext.initialize(args=args) assert ctx.scraping.max_pages == 100 assert ctx.scraping.rate_limit == 1.5 assert ctx.scraping.browser is True assert ctx.scraping.workers == 4 assert ctx.scraping.async_mode is True assert ctx.scraping.resume is True assert ctx.scraping.skip_scrape is True def test_analysis_args(self): """Should extract analysis args correctly.""" args = argparse.Namespace( name="test", depth="full", skip_patterns=True, skip_test_examples=True, skip_how_to_guides=True, file_patterns="*.py,*.js", ) ctx = ExecutionContext.initialize(args=args) assert ctx.analysis.depth == "full" assert ctx.analysis.skip_patterns is True assert ctx.analysis.skip_test_examples is True assert ctx.analysis.skip_how_to_guides is True assert ctx.analysis.file_patterns == ["*.py", "*.js"] def test_workflow_args(self): """Should extract workflow args correctly.""" args = argparse.Namespace( name="test", enhance_workflow=["security-focus", "api-docs"], enhance_stage=["stage1:prompt1"], var=["key1=value1", "key2=value2"], ) ctx = ExecutionContext.initialize(args=args) assert ctx.enhancement.workflows == ["security-focus", "api-docs"] assert ctx.enhancement.stages == ["stage1:prompt1"] assert ctx.enhancement.workflow_vars == {"key1": "value1", "key2": "value2"} def test_rag_args(self): """Should extract RAG args correctly.""" args = argparse.Namespace( name="test", chunk_for_rag=True, chunk_tokens=1024, ) ctx = ExecutionContext.initialize(args=args) assert ctx.rag.chunk_for_rag is True assert ctx.rag.chunk_tokens == 1024 def test_api_mode_detection(self): """Should detect API mode from api_key.""" args = argparse.Namespace( name="test", api_key="test-key", ) ctx = ExecutionContext.initialize(args=args) assert ctx.enhancement.mode == "api" def test_local_mode_detection(self): """Should default to local/auto mode without API key.""" # Clean API key env vars to ensure test isolation api_keys = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "MOONSHOT_API_KEY", "GOOGLE_API_KEY"] saved = {k: os.environ.pop(k, None) for k in api_keys} try: args = argparse.Namespace(name="test") ctx = ExecutionContext.initialize(args=args) assert ctx.enhancement.mode in ("local", "auto") finally: for k, v in saved.items(): if v is not None: os.environ[k] = v def test_raw_args_access(self): """Should provide access to raw args for backward compatibility.""" args = argparse.Namespace( name="test", custom_field="custom_value", ) ctx = ExecutionContext.initialize(args=args) assert ctx.get_raw("name") == "test" assert ctx.get_raw("custom_field") == "custom_value" assert ctx.get_raw("nonexistent", "default") == "default" class TestExecutionContextFromConfigFile: """Tests for building context from config files.""" def setup_method(self): ExecutionContext.reset() def teardown_method(self): ExecutionContext.reset() def test_unified_config_format(self): """Should load unified config with sources array.""" config = { "name": "unity-docs", "version": "2022.3", "enhancement": { "enabled": True, "level": 2, "mode": "local", "agent": "kimi", "timeout": "unlimited", }, "workflows": ["unity-game-dev"], "workflow_stages": ["custom:stage"], "workflow_vars": {"var1": "value1"}, "sources": [{"type": "documentation", "base_url": "https://docs.unity3d.com/"}], } with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(config, f) config_path = f.name try: ctx = ExecutionContext.initialize(config_path=config_path) assert ctx.output.name == "unity-docs" assert ctx.output.doc_version == "2022.3" assert ctx.enhancement.enabled is True assert ctx.enhancement.level == 2 assert ctx.enhancement.mode == "local" assert ctx.enhancement.agent == "kimi" assert ctx.enhancement.workflows == ["unity-game-dev"] assert ctx.enhancement.stages == ["custom:stage"] assert ctx.enhancement.workflow_vars == {"var1": "value1"} finally: os.unlink(config_path) def test_simple_web_config_format(self): """Should load simple web config format.""" config = { "name": "react-docs", "version": "18.2", "base_url": "https://react.dev/", "max_pages": 500, "rate_limit": 0.5, "browser": True, } with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(config, f) config_path = f.name try: ctx = ExecutionContext.initialize(config_path=config_path) assert ctx.output.name == "react-docs" assert ctx.output.doc_version == "18.2" assert ctx.scraping.max_pages == 500 assert ctx.scraping.rate_limit == 0.5 assert ctx.scraping.browser is True finally: os.unlink(config_path) def test_timeout_integer(self): """Should handle integer timeout in config.""" config = { "name": "test", "enhancement": {"timeout": 3600}, "sources": [], } with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(config, f) config_path = f.name try: ctx = ExecutionContext.initialize(config_path=config_path) assert ctx.enhancement.timeout == 3600 finally: os.unlink(config_path) class TestExecutionContextPriority: """Tests for configuration priority (CLI > Config > Env > Defaults).""" def setup_method(self): ExecutionContext.reset() self._original_env = {} def teardown_method(self): ExecutionContext.reset() # Restore env vars for key, value in self._original_env.items(): if value is not None: os.environ[key] = value else: os.environ.pop(key, None) def test_cli_overrides_config(self): """CLI args should override config file values.""" config = {"name": "config-name", "sources": []} with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(config, f) config_path = f.name try: args = argparse.Namespace(name="cli-name") ctx = ExecutionContext.initialize(args=args, config_path=config_path) # CLI should win assert ctx.output.name == "cli-name" finally: os.unlink(config_path) def test_config_overrides_defaults(self): """Config file should override default values.""" config = { "name": "config-name", "enhancement": {"level": 3}, "sources": [], } with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(config, f) config_path = f.name try: ctx = ExecutionContext.initialize(config_path=config_path) # Config should override default (level=2) assert ctx.enhancement.level == 3 finally: os.unlink(config_path) def test_env_overrides_defaults(self): """Environment variables should override defaults.""" self._original_env["SKILL_SEEKER_AGENT"] = os.environ.get("SKILL_SEEKER_AGENT") os.environ["SKILL_SEEKER_AGENT"] = "claude" ctx = ExecutionContext.initialize() # Env var should override default (None) assert ctx.enhancement.agent == "claude" class TestExecutionContextSourceInfo: """Tests for source info integration.""" def setup_method(self): ExecutionContext.reset() def teardown_method(self): ExecutionContext.reset() def test_source_info_integration(self): """Should integrate source info from source_detector.""" class MockSourceInfo: type = "web" raw_source = "https://react.dev/" parsed = {"url": "https://react.dev/"} suggested_name = "react" ctx = ExecutionContext.initialize(source_info=MockSourceInfo()) assert ctx.source is not None assert ctx.source.type == "web" assert ctx.source.raw_source == "https://react.dev/" assert ctx.source.suggested_name == "react" class TestExecutionContextOverride: """Tests for the override context manager.""" def setup_method(self): ExecutionContext.reset() def teardown_method(self): ExecutionContext.reset() def test_override_temporarily_changes_values(self): """override() should temporarily change values.""" args = argparse.Namespace(name="original", enhance_level=2) ctx = ExecutionContext.initialize(args=args) assert ctx.enhancement.level == 2 with ctx.override(enhancement__level=3): ctx_from_get = ExecutionContext.get() assert ctx_from_get.enhancement.level == 3 # After exit, original value restored assert ExecutionContext.get().enhancement.level == 2 def test_override_restores_on_exception(self): """override() should restore values even on exception.""" args = argparse.Namespace(name="original", enhance_level=2) ctx = ExecutionContext.initialize(args=args) try: with ctx.override(enhancement__level=3): assert ExecutionContext.get().enhancement.level == 3 raise ValueError("Test error") except ValueError: pass # Should still be restored assert ExecutionContext.get().enhancement.level == 2 class TestExecutionContextValidation: """Tests for Pydantic validation.""" def setup_method(self): ExecutionContext.reset() def teardown_method(self): ExecutionContext.reset() def test_enhancement_level_bounds(self): """Enhancement level should be 0-3.""" args = argparse.Namespace(name="test", enhance_level=5) with pytest.raises(ValueError) as exc_info: ExecutionContext.initialize(args=args) assert "level" in str(exc_info.value) def test_analysis_depth_choices(self): """Analysis depth should reject invalid values.""" import pydantic args = argparse.Namespace(name="test", depth="invalid") with pytest.raises(pydantic.ValidationError): ExecutionContext.initialize(args=args) def test_analysis_depth_valid_choices(self): """Analysis depth should accept surface, deep, full.""" for depth in ("surface", "deep", "full"): ExecutionContext.reset() args = argparse.Namespace(name="test", depth=depth) ctx = ExecutionContext.initialize(args=args) assert ctx.analysis.depth == depth class TestExecutionContextDefaults: """Tests for default values.""" def setup_method(self): ExecutionContext.reset() def teardown_method(self): ExecutionContext.reset() def test_default_values(self): """Should have sensible defaults.""" # Clear API key env vars so mode defaults to "auto" regardless of environment api_keys = ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "MOONSHOT_API_KEY", "GOOGLE_API_KEY") saved = {k: os.environ.pop(k, None) for k in api_keys} try: ctx = ExecutionContext.initialize() # Enhancement defaults assert ctx.enhancement.enabled is True assert ctx.enhancement.level == 2 assert ctx.enhancement.mode == "auto" # Default is auto, resolved at runtime assert ctx.enhancement.timeout == 2700 # 45 minutes finally: for k, v in saved.items(): if v is not None: os.environ[k] = v # Output defaults assert ctx.output.name is None assert ctx.output.dry_run is False # Scraping defaults assert ctx.scraping.browser is False assert ctx.scraping.workers == 1 assert ctx.scraping.languages == ["en"] # Analysis defaults assert ctx.analysis.depth == "surface" assert ctx.analysis.skip_patterns is False # RAG defaults assert ctx.rag.chunk_for_rag is False assert ctx.rag.chunk_tokens == 512