This commit is contained in:
Pablo Estevez
2026-01-17 17:29:21 +00:00
parent c89f059712
commit 5ed767ff9a
144 changed files with 14142 additions and 16488 deletions

View File

@@ -36,21 +36,18 @@ Combined with unit tests: 24 total tests (23 passed, 1 skipped)
Run with: pytest tests/test_install_skill.py tests/test_install_skill_e2e.py -v
"""
import asyncio
import json
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from unittest.mock import patch, MagicMock
from unittest.mock import MagicMock, patch
import pytest
# Defensive import for MCP package (may not be installed in all environments)
try:
from mcp.types import TextContent
MCP_AVAILABLE = True
except ImportError:
MCP_AVAILABLE = False
@@ -71,25 +68,15 @@ class TestInstallSkillE2E:
"name": "test-e2e",
"description": "Test skill for E2E testing",
"base_url": "https://example.com/docs/",
"selectors": {
"main_content": "article",
"title": "title",
"code_blocks": "pre"
},
"url_patterns": {
"include": ["/docs/"],
"exclude": ["/search", "/404"]
},
"categories": {
"getting_started": ["intro", "start"],
"api": ["api", "reference"]
},
"selectors": {"main_content": "article", "title": "title", "code_blocks": "pre"},
"url_patterns": {"include": ["/docs/"], "exclude": ["/search", "/404"]},
"categories": {"getting_started": ["intro", "start"], "api": ["api", "reference"]},
"rate_limit": 0.1,
"max_pages": 5 # Keep it small for fast testing
"max_pages": 5, # Keep it small for fast testing
}
config_path = tmp_path / "test-e2e.json"
with open(config_path, 'w') as f:
with open(config_path, "w") as f:
json.dump(config, f, indent=2)
return str(config_path)
@@ -112,34 +99,33 @@ class TestInstallSkillE2E:
"""E2E test: config_path mode, no upload"""
# Mock the subprocess calls for scraping and enhancement
with patch('skill_seekers.mcp.server.scrape_docs_tool') as mock_scrape, \
patch('skill_seekers.mcp.server.run_subprocess_with_streaming') as mock_enhance, \
patch('skill_seekers.mcp.server.package_skill_tool') as mock_package:
with (
patch("skill_seekers.mcp.server.scrape_docs_tool") as mock_scrape,
patch("skill_seekers.mcp.server.run_subprocess_with_streaming") as mock_enhance,
patch("skill_seekers.mcp.server.package_skill_tool") as mock_package,
):
# Mock scrape_docs to return success
mock_scrape.return_value = [TextContent(
type="text",
text=f"✅ Scraping complete\n\nSkill built at: {mock_scrape_output}"
)]
mock_scrape.return_value = [
TextContent(type="text", text=f"✅ Scraping complete\n\nSkill built at: {mock_scrape_output}")
]
# Mock enhancement subprocess (success)
mock_enhance.return_value = ("✅ Enhancement complete", "", 0)
# Mock package_skill to return success
zip_path = str(tmp_path / "output" / "test-e2e.zip")
mock_package.return_value = [TextContent(
type="text",
text=f"✅ Package complete\n\nSaved to: {zip_path}"
)]
mock_package.return_value = [TextContent(type="text", text=f"✅ Package complete\n\nSaved to: {zip_path}")]
# Run the tool
result = await install_skill_tool({
"config_path": test_config_file,
"destination": str(tmp_path / "output"),
"auto_upload": False, # Skip upload
"unlimited": False,
"dry_run": False
})
result = await install_skill_tool(
{
"config_path": test_config_file,
"destination": str(tmp_path / "output"),
"auto_upload": False, # Skip upload
"unlimited": False,
"dry_run": False,
}
)
# Verify output
assert len(result) == 1
@@ -170,19 +156,19 @@ class TestInstallSkillE2E:
async def test_e2e_with_config_name_fetch(self, tmp_path):
"""E2E test: config_name mode with fetch phase"""
with patch('skill_seekers.mcp.server.fetch_config_tool') as mock_fetch, \
patch('skill_seekers.mcp.server.scrape_docs_tool') as mock_scrape, \
patch('skill_seekers.mcp.server.run_subprocess_with_streaming') as mock_enhance, \
patch('skill_seekers.mcp.server.package_skill_tool') as mock_package, \
patch('builtins.open', create=True) as mock_file_open, \
patch('os.environ.get') as mock_env:
with (
patch("skill_seekers.mcp.server.fetch_config_tool") as mock_fetch,
patch("skill_seekers.mcp.server.scrape_docs_tool") as mock_scrape,
patch("skill_seekers.mcp.server.run_subprocess_with_streaming") as mock_enhance,
patch("skill_seekers.mcp.server.package_skill_tool") as mock_package,
patch("builtins.open", create=True) as mock_file_open,
patch("os.environ.get") as mock_env,
):
# Mock fetch_config to return success
config_path = str(tmp_path / "configs" / "react.json")
mock_fetch.return_value = [TextContent(
type="text",
text=f"✅ Config fetched successfully\n\nConfig saved to: {config_path}"
)]
mock_fetch.return_value = [
TextContent(type="text", text=f"✅ Config fetched successfully\n\nConfig saved to: {config_path}")
]
# Mock config file read
mock_config = MagicMock()
@@ -191,32 +177,30 @@ class TestInstallSkillE2E:
# Mock scrape_docs
skill_dir = str(tmp_path / "output" / "react")
mock_scrape.return_value = [TextContent(
type="text",
text=f"✅ Scraping complete\n\nSkill built at: {skill_dir}"
)]
mock_scrape.return_value = [
TextContent(type="text", text=f"✅ Scraping complete\n\nSkill built at: {skill_dir}")
]
# Mock enhancement
mock_enhance.return_value = ("✅ Enhancement complete", "", 0)
# Mock package
zip_path = str(tmp_path / "output" / "react.zip")
mock_package.return_value = [TextContent(
type="text",
text=f"✅ Package complete\n\nSaved to: {zip_path}"
)]
mock_package.return_value = [TextContent(type="text", text=f"✅ Package complete\n\nSaved to: {zip_path}")]
# Mock env (no API key - should skip upload)
mock_env.return_value = ""
# Run the tool
result = await install_skill_tool({
"config_name": "react",
"destination": str(tmp_path / "output"),
"auto_upload": True, # Would upload if key present
"unlimited": False,
"dry_run": False
})
result = await install_skill_tool(
{
"config_name": "react",
"destination": str(tmp_path / "output"),
"auto_upload": True, # Would upload if key present
"unlimited": False,
"dry_run": False,
}
)
# Verify output
output = result[0].text
@@ -238,11 +222,7 @@ class TestInstallSkillE2E:
async def test_e2e_dry_run_mode(self, test_config_file):
"""E2E test: dry-run mode (no actual execution)"""
result = await install_skill_tool({
"config_path": test_config_file,
"auto_upload": False,
"dry_run": True
})
result = await install_skill_tool({"config_path": test_config_file, "auto_upload": False, "dry_run": True})
output = result[0].text
@@ -263,18 +243,11 @@ class TestInstallSkillE2E:
async def test_e2e_error_handling_scrape_failure(self, test_config_file):
"""E2E test: error handling when scrape fails"""
with patch('skill_seekers.mcp.server.scrape_docs_tool') as mock_scrape:
with patch("skill_seekers.mcp.server.scrape_docs_tool") as mock_scrape:
# Mock scrape failure
mock_scrape.return_value = [TextContent(
type="text",
text="❌ Scraping failed: Network timeout"
)]
mock_scrape.return_value = [TextContent(type="text", text="❌ Scraping failed: Network timeout")]
result = await install_skill_tool({
"config_path": test_config_file,
"auto_upload": False,
"dry_run": False
})
result = await install_skill_tool({"config_path": test_config_file, "auto_upload": False, "dry_run": False})
output = result[0].text
@@ -286,23 +259,19 @@ class TestInstallSkillE2E:
async def test_e2e_error_handling_enhancement_failure(self, test_config_file, mock_scrape_output):
"""E2E test: error handling when enhancement fails"""
with patch('skill_seekers.mcp.server.scrape_docs_tool') as mock_scrape, \
patch('skill_seekers.mcp.server.run_subprocess_with_streaming') as mock_enhance:
with (
patch("skill_seekers.mcp.server.scrape_docs_tool") as mock_scrape,
patch("skill_seekers.mcp.server.run_subprocess_with_streaming") as mock_enhance,
):
# Mock successful scrape
mock_scrape.return_value = [TextContent(
type="text",
text=f"✅ Scraping complete\n\nSkill built at: {mock_scrape_output}"
)]
mock_scrape.return_value = [
TextContent(type="text", text=f"✅ Scraping complete\n\nSkill built at: {mock_scrape_output}")
]
# Mock enhancement failure
mock_enhance.return_value = ("", "Enhancement error: Claude not found", 1)
result = await install_skill_tool({
"config_path": test_config_file,
"auto_upload": False,
"dry_run": False
})
result = await install_skill_tool({"config_path": test_config_file, "auto_upload": False, "dry_run": False})
output = result[0].text
@@ -322,22 +291,15 @@ class TestInstallSkillCLI_E2E:
"name": "test-cli-e2e",
"description": "Test skill for CLI E2E testing",
"base_url": "https://example.com/docs/",
"selectors": {
"main_content": "article",
"title": "title",
"code_blocks": "pre"
},
"url_patterns": {
"include": ["/docs/"],
"exclude": []
},
"selectors": {"main_content": "article", "title": "title", "code_blocks": "pre"},
"url_patterns": {"include": ["/docs/"], "exclude": []},
"categories": {},
"rate_limit": 0.1,
"max_pages": 3
"max_pages": 3,
}
config_path = tmp_path / "test-cli-e2e.json"
with open(config_path, 'w') as f:
with open(config_path, "w") as f:
json.dump(config, f, indent=2)
return str(config_path)
@@ -349,11 +311,7 @@ class TestInstallSkillCLI_E2E:
# Import and call the tool directly (more reliable than subprocess)
from skill_seekers.mcp.server import install_skill_tool
result = await install_skill_tool({
"config_path": test_config_file,
"dry_run": True,
"auto_upload": False
})
result = await install_skill_tool({"config_path": test_config_file, "dry_run": True, "auto_upload": False})
# Verify output
output = result[0].text
@@ -366,9 +324,7 @@ class TestInstallSkillCLI_E2E:
# Run CLI without config
result = subprocess.run(
[sys.executable, "-m", "skill_seekers.cli.install_skill"],
capture_output=True,
text=True
[sys.executable, "-m", "skill_seekers.cli.install_skill"], capture_output=True, text=True
)
# Should fail
@@ -381,9 +337,7 @@ class TestInstallSkillCLI_E2E:
"""E2E test: CLI help command"""
result = subprocess.run(
[sys.executable, "-m", "skill_seekers.cli.install_skill", "--help"],
capture_output=True,
text=True
[sys.executable, "-m", "skill_seekers.cli.install_skill", "--help"], capture_output=True, text=True
)
# Should succeed
@@ -397,36 +351,34 @@ class TestInstallSkillCLI_E2E:
assert "--no-upload" in output
@pytest.mark.asyncio
@patch('skill_seekers.mcp.server.scrape_docs_tool')
@patch('skill_seekers.mcp.server.run_subprocess_with_streaming')
@patch('skill_seekers.mcp.server.package_skill_tool')
@patch("skill_seekers.mcp.server.scrape_docs_tool")
@patch("skill_seekers.mcp.server.run_subprocess_with_streaming")
@patch("skill_seekers.mcp.server.package_skill_tool")
async def test_cli_full_workflow_mocked(self, mock_package, mock_enhance, mock_scrape, test_config_file, tmp_path):
"""E2E test: Full CLI workflow with mocked phases (via direct call)"""
# Setup mocks
skill_dir = str(tmp_path / "output" / "test-cli-e2e")
mock_scrape.return_value = [TextContent(
type="text",
text=f"✅ Scraping complete\n\nSkill built at: {skill_dir}"
)]
mock_scrape.return_value = [
TextContent(type="text", text=f"✅ Scraping complete\n\nSkill built at: {skill_dir}")
]
mock_enhance.return_value = ("✅ Enhancement complete", "", 0)
zip_path = str(tmp_path / "output" / "test-cli-e2e.zip")
mock_package.return_value = [TextContent(
type="text",
text=f"✅ Package complete\n\nSaved to: {zip_path}"
)]
mock_package.return_value = [TextContent(type="text", text=f"✅ Package complete\n\nSaved to: {zip_path}")]
# Call the tool directly
from skill_seekers.mcp.server import install_skill_tool
result = await install_skill_tool({
"config_path": test_config_file,
"destination": str(tmp_path / "output"),
"auto_upload": False,
"dry_run": False
})
result = await install_skill_tool(
{
"config_path": test_config_file,
"destination": str(tmp_path / "output"),
"auto_upload": False,
"dry_run": False,
}
)
# Verify success
output = result[0].text
@@ -434,7 +386,9 @@ class TestInstallSkillCLI_E2E:
assert "Enhancement" in output or "MANDATORY" in output
assert "WORKFLOW COMPLETE" in output or "" in output
@pytest.mark.skip(reason="Subprocess-based CLI test has asyncio issues; functionality tested in test_cli_full_workflow_mocked")
@pytest.mark.skip(
reason="Subprocess-based CLI test has asyncio issues; functionality tested in test_cli_full_workflow_mocked"
)
def test_cli_via_unified_command(self, test_config_file):
"""E2E test: Using 'skill-seekers install' unified CLI
@@ -445,17 +399,16 @@ class TestInstallSkillCLI_E2E:
# Test the unified CLI entry point
result = subprocess.run(
["skill-seekers", "install",
"--config", test_config_file,
"--dry-run"],
["skill-seekers", "install", "--config", test_config_file, "--dry-run"],
capture_output=True,
text=True,
timeout=30
timeout=30,
)
# Should work if command is available
assert result.returncode == 0 or "DRY RUN" in result.stdout, \
assert result.returncode == 0 or "DRY RUN" in result.stdout, (
f"Unified CLI failed:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
)
@pytest.mark.skipif(not MCP_AVAILABLE, reason="MCP package not installed")
@@ -475,22 +428,15 @@ class TestInstallSkillE2E_RealFiles:
"name": "test-real-e2e",
"description": "Real E2E test",
"base_url": "https://httpbin.org/html", # Simple HTML endpoint
"selectors": {
"main_content": "body",
"title": "title",
"code_blocks": "code"
},
"url_patterns": {
"include": [],
"exclude": []
},
"selectors": {"main_content": "body", "title": "title", "code_blocks": "code"},
"url_patterns": {"include": [], "exclude": []},
"categories": {},
"rate_limit": 0.5,
"max_pages": 1 # Just one page for speed
"max_pages": 1, # Just one page for speed
}
config_path = tmp_path / "test-real-e2e.json"
with open(config_path, 'w') as f:
with open(config_path, "w") as f:
json.dump(config, f, indent=2)
return str(config_path)
@@ -501,30 +447,30 @@ class TestInstallSkillE2E_RealFiles:
"""E2E test with real scraping but mocked enhancement/upload"""
# Only mock enhancement and upload (let scraping run for real)
with patch('skill_seekers.mcp.server.run_subprocess_with_streaming') as mock_enhance, \
patch('skill_seekers.mcp.server.upload_skill_tool') as mock_upload, \
patch('os.environ.get') as mock_env:
with (
patch("skill_seekers.mcp.server.run_subprocess_with_streaming") as mock_enhance,
patch("skill_seekers.mcp.server.upload_skill_tool") as mock_upload,
patch("os.environ.get") as mock_env,
):
# Mock enhancement (avoid needing Claude Code)
mock_enhance.return_value = ("✅ Enhancement complete", "", 0)
# Mock upload (avoid needing API key)
mock_upload.return_value = [TextContent(
type="text",
text="✅ Upload successful"
)]
mock_upload.return_value = [TextContent(type="text", text="✅ Upload successful")]
# Mock API key present
mock_env.return_value = "sk-ant-test-key"
# Run with real scraping
result = await install_skill_tool({
"config_path": real_test_config,
"destination": str(tmp_path / "output"),
"auto_upload": False, # Skip upload even with key
"unlimited": False,
"dry_run": False
})
result = await install_skill_tool(
{
"config_path": real_test_config,
"destination": str(tmp_path / "output"),
"auto_upload": False, # Skip upload even with key
"unlimited": False,
"dry_run": False,
}
)
output = result[0].text