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

@@ -4,15 +4,14 @@ Comprehensive test suite for Skill Seeker MCP Server
Tests all MCP tools and server functionality
"""
import sys
import os
import unittest
import json
import tempfile
import os
import shutil
import asyncio
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock, patch, AsyncMock, MagicMock
from unittest.mock import MagicMock, patch
# CRITICAL: Import MCP package BEFORE adding project to path
# to avoid shadowing the installed mcp package with our local mcp/ directory
@@ -21,9 +20,10 @@ from unittest.mock import Mock, patch, AsyncMock, MagicMock
# This avoids our local mcp/ directory being in the import path
_original_dir = os.getcwd()
try:
os.chdir('/tmp') # Change away from project directory
os.chdir("/tmp") # Change away from project directory
from mcp.server import Server
from mcp.types import Tool, TextContent
from mcp.types import TextContent, Tool
MCP_AVAILABLE = True
except ImportError:
MCP_AVAILABLE = False
@@ -51,11 +51,13 @@ class TestMCPServerInitialization(unittest.TestCase):
def test_server_import(self):
"""Test that server module can be imported"""
from mcp import server as mcp_server_module
self.assertIsNotNone(mcp_server_module)
def test_server_initialization(self):
"""Test server initializes correctly"""
import mcp.server
app = mcp.server.Server("test-skill-seeker")
self.assertEqual(app.name, "test-skill-seeker")
@@ -79,7 +81,7 @@ class TestListTools(unittest.IsolatedAsyncioTestCase):
"scrape_docs",
"package_skill",
"list_configs",
"validate_config"
"validate_config",
]
for expected in expected_tools:
@@ -117,11 +119,7 @@ class TestGenerateConfigTool(unittest.IsolatedAsyncioTestCase):
async def test_generate_config_basic(self):
"""Test basic config generation"""
args = {
"name": "test-framework",
"url": "https://test-framework.dev/",
"description": "Test framework skill"
}
args = {"name": "test-framework", "url": "https://test-framework.dev/", "description": "Test framework skill"}
result = await skill_seeker_server.generate_config_tool(args)
@@ -148,7 +146,7 @@ class TestGenerateConfigTool(unittest.IsolatedAsyncioTestCase):
"url": "https://custom.dev/",
"description": "Custom skill",
"max_pages": 200,
"rate_limit": 1.0
"rate_limit": 1.0,
}
result = await skill_seeker_server.generate_config_tool(args)
@@ -162,11 +160,7 @@ class TestGenerateConfigTool(unittest.IsolatedAsyncioTestCase):
async def test_generate_config_defaults(self):
"""Test that default values are applied correctly"""
args = {
"name": "default-test",
"url": "https://test.dev/",
"description": "Test defaults"
}
args = {"name": "default-test", "url": "https://test.dev/", "description": "Test defaults"}
result = await skill_seeker_server.generate_config_tool(args)
@@ -193,15 +187,11 @@ class TestEstimatePagesTool(unittest.IsolatedAsyncioTestCase):
config_data = {
"name": "test",
"base_url": "https://example.com/",
"selectors": {
"main_content": "article",
"title": "h1",
"code_blocks": "pre"
},
"selectors": {"main_content": "article", "title": "h1", "code_blocks": "pre"},
"rate_limit": 0.5,
"max_pages": 50
"max_pages": 50,
}
with open(self.config_path, 'w') as f:
with open(self.config_path, "w") as f:
json.dump(config_data, f)
async def asyncTearDown(self):
@@ -209,16 +199,14 @@ class TestEstimatePagesTool(unittest.IsolatedAsyncioTestCase):
os.chdir(self.original_cwd)
shutil.rmtree(self.temp_dir, ignore_errors=True)
@patch('skill_seekers.mcp.tools.scraping_tools.run_subprocess_with_streaming')
@patch("skill_seekers.mcp.tools.scraping_tools.run_subprocess_with_streaming")
async def test_estimate_pages_success(self, mock_streaming):
"""Test successful page estimation"""
# Mock successful subprocess run with streaming
# Returns (stdout, stderr, returncode)
mock_streaming.return_value = ("Estimated 50 pages", "", 0)
args = {
"config_path": str(self.config_path)
}
args = {"config_path": str(self.config_path)}
result = await skill_seeker_server.estimate_pages_tool(args)
@@ -228,16 +216,13 @@ class TestEstimatePagesTool(unittest.IsolatedAsyncioTestCase):
# Should also have progress message
self.assertIn("Estimating page count", result[0].text)
@patch('skill_seekers.mcp.tools.scraping_tools.run_subprocess_with_streaming')
@patch("skill_seekers.mcp.tools.scraping_tools.run_subprocess_with_streaming")
async def test_estimate_pages_with_max_discovery(self, mock_streaming):
"""Test page estimation with custom max_discovery"""
# Mock successful subprocess run with streaming
mock_streaming.return_value = ("Estimated 100 pages", "", 0)
args = {
"config_path": str(self.config_path),
"max_discovery": 500
}
args = {"config_path": str(self.config_path), "max_discovery": 500}
result = await skill_seeker_server.estimate_pages_tool(args)
@@ -247,15 +232,13 @@ class TestEstimatePagesTool(unittest.IsolatedAsyncioTestCase):
self.assertIn("--max-discovery", call_args)
self.assertIn("500", call_args)
@patch('skill_seekers.mcp.tools.scraping_tools.run_subprocess_with_streaming')
@patch("skill_seekers.mcp.tools.scraping_tools.run_subprocess_with_streaming")
async def test_estimate_pages_error(self, mock_streaming):
"""Test error handling in page estimation"""
# Mock failed subprocess run with streaming
mock_streaming.return_value = ("", "Config file not found", 1)
args = {
"config_path": "nonexistent.json"
}
args = {"config_path": "nonexistent.json"}
result = await skill_seeker_server.estimate_pages_tool(args)
@@ -278,13 +261,9 @@ class TestScrapeDocsTool(unittest.IsolatedAsyncioTestCase):
config_data = {
"name": "test",
"base_url": "https://example.com/",
"selectors": {
"main_content": "article",
"title": "h1",
"code_blocks": "pre"
}
"selectors": {"main_content": "article", "title": "h1", "code_blocks": "pre"},
}
with open(self.config_path, 'w') as f:
with open(self.config_path, "w") as f:
json.dump(config_data, f)
async def asyncTearDown(self):
@@ -292,31 +271,26 @@ class TestScrapeDocsTool(unittest.IsolatedAsyncioTestCase):
os.chdir(self.original_cwd)
shutil.rmtree(self.temp_dir, ignore_errors=True)
@patch('skill_seekers.mcp.tools.scraping_tools.run_subprocess_with_streaming')
@patch("skill_seekers.mcp.tools.scraping_tools.run_subprocess_with_streaming")
async def test_scrape_docs_basic(self, mock_streaming):
"""Test basic documentation scraping"""
# Mock successful subprocess run with streaming
mock_streaming.return_value = ("Scraping completed successfully", "", 0)
args = {
"config_path": str(self.config_path)
}
args = {"config_path": str(self.config_path)}
result = await skill_seeker_server.scrape_docs_tool(args)
self.assertIsInstance(result, list)
self.assertIn("success", result[0].text.lower())
@patch('skill_seekers.mcp.tools.scraping_tools.run_subprocess_with_streaming')
@patch("skill_seekers.mcp.tools.scraping_tools.run_subprocess_with_streaming")
async def test_scrape_docs_with_skip_scrape(self, mock_streaming):
"""Test scraping with skip_scrape flag"""
# Mock successful subprocess run with streaming
mock_streaming.return_value = ("Using cached data", "", 0)
args = {
"config_path": str(self.config_path),
"skip_scrape": True
}
args = {"config_path": str(self.config_path), "skip_scrape": True}
result = await skill_seeker_server.scrape_docs_tool(args)
@@ -324,32 +298,26 @@ class TestScrapeDocsTool(unittest.IsolatedAsyncioTestCase):
call_args = mock_streaming.call_args[0][0]
self.assertIn("--skip-scrape", call_args)
@patch('skill_seekers.mcp.tools.scraping_tools.run_subprocess_with_streaming')
@patch("skill_seekers.mcp.tools.scraping_tools.run_subprocess_with_streaming")
async def test_scrape_docs_with_dry_run(self, mock_streaming):
"""Test scraping with dry_run flag"""
# Mock successful subprocess run with streaming
mock_streaming.return_value = ("Dry run completed", "", 0)
args = {
"config_path": str(self.config_path),
"dry_run": True
}
args = {"config_path": str(self.config_path), "dry_run": True}
result = await skill_seeker_server.scrape_docs_tool(args)
call_args = mock_streaming.call_args[0][0]
self.assertIn("--dry-run", call_args)
@patch('skill_seekers.mcp.tools.scraping_tools.run_subprocess_with_streaming')
@patch("skill_seekers.mcp.tools.scraping_tools.run_subprocess_with_streaming")
async def test_scrape_docs_with_enhance_local(self, mock_streaming):
"""Test scraping with local enhancement"""
# Mock successful subprocess run with streaming
mock_streaming.return_value = ("Scraping with enhancement", "", 0)
args = {
"config_path": str(self.config_path),
"enhance_local": True
}
args = {"config_path": str(self.config_path), "enhance_local": True}
result = await skill_seeker_server.scrape_docs_tool(args)
@@ -379,7 +347,7 @@ class TestPackageSkillTool(unittest.IsolatedAsyncioTestCase):
os.chdir(self.original_cwd)
shutil.rmtree(self.temp_dir, ignore_errors=True)
@patch('subprocess.run')
@patch("subprocess.run")
async def test_package_skill_success(self, mock_run):
"""Test successful skill packaging"""
mock_result = MagicMock()
@@ -387,16 +355,14 @@ class TestPackageSkillTool(unittest.IsolatedAsyncioTestCase):
mock_result.stdout = "Package created: test-skill.zip"
mock_run.return_value = mock_result
args = {
"skill_dir": str(self.skill_dir)
}
args = {"skill_dir": str(self.skill_dir)}
result = await skill_seeker_server.package_skill_tool(args)
self.assertIsInstance(result, list)
self.assertIn("test-skill", result[0].text)
@patch('subprocess.run')
@patch("subprocess.run")
async def test_package_skill_error(self, mock_run):
"""Test error handling in skill packaging"""
mock_result = MagicMock()
@@ -404,9 +370,7 @@ class TestPackageSkillTool(unittest.IsolatedAsyncioTestCase):
mock_result.stderr = "Directory not found"
mock_run.return_value = mock_result
args = {
"skill_dir": "nonexistent-dir"
}
args = {"skill_dir": "nonexistent-dir"}
result = await skill_seeker_server.package_skill_tool(args)
@@ -427,21 +391,13 @@ class TestListConfigsTool(unittest.IsolatedAsyncioTestCase):
os.makedirs("configs", exist_ok=True)
configs = [
{
"name": "test1",
"description": "Test 1 skill",
"base_url": "https://test1.dev/"
},
{
"name": "test2",
"description": "Test 2 skill",
"base_url": "https://test2.dev/"
}
{"name": "test1", "description": "Test 1 skill", "base_url": "https://test1.dev/"},
{"name": "test2", "description": "Test 2 skill", "base_url": "https://test2.dev/"},
]
for config in configs:
path = Path(f"configs/{config['name']}.json")
with open(path, 'w') as f:
with open(path, "w") as f:
json.dump(config, f)
async def asyncTearDown(self):
@@ -504,20 +460,14 @@ class TestValidateConfigTool(unittest.IsolatedAsyncioTestCase):
valid_config = {
"name": "valid-test",
"base_url": "https://example.com/",
"selectors": {
"main_content": "article",
"title": "h1",
"code_blocks": "pre"
},
"selectors": {"main_content": "article", "title": "h1", "code_blocks": "pre"},
"rate_limit": 0.5,
"max_pages": 100
"max_pages": 100,
}
with open(config_path, 'w') as f:
with open(config_path, "w") as f:
json.dump(valid_config, f)
args = {
"config_path": str(config_path)
}
args = {"config_path": str(config_path)}
result = await skill_seeker_server.validate_config_tool(args)
@@ -533,14 +483,12 @@ class TestValidateConfigTool(unittest.IsolatedAsyncioTestCase):
"description": "Missing name field",
"sources": [
{"type": "invalid_type", "url": "https://example.com"} # Invalid source type
]
],
}
with open(config_path, 'w') as f:
with open(config_path, "w") as f:
json.dump(invalid_config, f)
args = {
"config_path": str(config_path)
}
args = {"config_path": str(config_path)}
result = await skill_seeker_server.validate_config_tool(args)
@@ -549,9 +497,7 @@ class TestValidateConfigTool(unittest.IsolatedAsyncioTestCase):
async def test_validate_nonexistent_config(self):
"""Test validating a nonexistent config"""
args = {
"config_path": "configs/nonexistent.json"
}
args = {"config_path": "configs/nonexistent.json"}
result = await skill_seeker_server.validate_config_tool(args)
@@ -593,15 +539,13 @@ class TestMCPServerIntegration(unittest.IsolatedAsyncioTestCase):
generate_args = {
"name": "workflow-test",
"url": "https://workflow-test.dev/",
"description": "Workflow test skill"
"description": "Workflow test skill",
}
result1 = await skill_seeker_server.generate_config_tool(generate_args)
self.assertIn("", result1[0].text)
# Step 2: Validate config
validate_args = {
"config_path": "configs/workflow-test.json"
}
validate_args = {"config_path": "configs/workflow-test.json"}
result2 = await skill_seeker_server.validate_config_tool(validate_args)
self.assertIn("", result2[0].text)
@@ -620,9 +564,7 @@ class TestSubmitConfigTool(unittest.IsolatedAsyncioTestCase):
async def test_submit_config_requires_token(self):
"""Should error without GitHub token"""
args = {
"config_json": '{"name": "test", "description": "Test", "base_url": "https://example.com"}'
}
args = {"config_json": '{"name": "test", "description": "Test", "base_url": "https://example.com"}'}
result = await skill_seeker_server.submit_config_tool(args)
self.assertIn("GitHub token required", result[0].text)
@@ -630,7 +572,7 @@ class TestSubmitConfigTool(unittest.IsolatedAsyncioTestCase):
"""Should reject config missing required fields"""
args = {
"config_json": '{"name": "test"}', # Missing description, base_url
"github_token": "fake_token"
"github_token": "fake_token",
}
result = await skill_seeker_server.submit_config_tool(args)
self.assertIn("validation failed", result[0].text.lower())
@@ -641,7 +583,7 @@ class TestSubmitConfigTool(unittest.IsolatedAsyncioTestCase):
"""Should reject invalid name characters"""
args = {
"config_json": '{"name": "React@2024!", "description": "Test", "base_url": "https://example.com"}',
"github_token": "fake_token"
"github_token": "fake_token",
}
result = await skill_seeker_server.submit_config_tool(args)
self.assertIn("validation failed", result[0].text.lower())
@@ -650,7 +592,7 @@ class TestSubmitConfigTool(unittest.IsolatedAsyncioTestCase):
"""Should reject invalid URL format"""
args = {
"config_json": '{"name": "test", "description": "Test", "base_url": "not-a-url"}',
"github_token": "fake_token"
"github_token": "fake_token",
}
result = await skill_seeker_server.submit_config_tool(args)
self.assertIn("validation failed", result[0].text.lower())
@@ -661,20 +603,13 @@ class TestSubmitConfigTool(unittest.IsolatedAsyncioTestCase):
"name": "testframework",
"description": "Test framework docs",
"base_url": "https://docs.test.com/",
"selectors": {
"main_content": "article",
"title": "h1",
"code_blocks": "pre code"
},
"max_pages": 100
}
args = {
"config_json": json.dumps(valid_config),
"github_token": "fake_token"
"selectors": {"main_content": "article", "title": "h1", "code_blocks": "pre code"},
"max_pages": 100,
}
args = {"config_json": json.dumps(valid_config), "github_token": "fake_token"}
# Mock GitHub API call
with patch('github.Github') as mock_gh:
with patch("github.Github") as mock_gh:
mock_repo = MagicMock()
mock_issue = MagicMock()
mock_issue.html_url = "https://github.com/test/issue/1"
@@ -693,23 +628,13 @@ class TestSubmitConfigTool(unittest.IsolatedAsyncioTestCase):
"description": "Test unified config",
"merge_mode": "rule-based",
"sources": [
{
"type": "documentation",
"base_url": "https://docs.test.com/",
"max_pages": 100
},
{
"type": "github",
"repo": "testorg/testrepo"
}
]
}
args = {
"config_json": json.dumps(unified_config),
"github_token": "fake_token"
{"type": "documentation", "base_url": "https://docs.test.com/", "max_pages": 100},
{"type": "github", "repo": "testorg/testrepo"},
],
}
args = {"config_json": json.dumps(unified_config), "github_token": "fake_token"}
with patch('github.Github') as mock_gh:
with patch("github.Github") as mock_gh:
mock_repo = MagicMock()
mock_issue = MagicMock()
mock_issue.html_url = "https://github.com/test/issue/2"
@@ -723,21 +648,14 @@ class TestSubmitConfigTool(unittest.IsolatedAsyncioTestCase):
async def test_submit_config_from_file_path(self):
"""Should accept config_path parameter"""
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
json.dump({
"name": "testfile",
"description": "From file",
"base_url": "https://test.com/"
}, f)
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump({"name": "testfile", "description": "From file", "base_url": "https://test.com/"}, f)
temp_path = f.name
try:
args = {
"config_path": temp_path,
"github_token": "fake_token"
}
args = {"config_path": temp_path, "github_token": "fake_token"}
with patch('github.Github') as mock_gh:
with patch("github.Github") as mock_gh:
mock_repo = MagicMock()
mock_issue = MagicMock()
mock_issue.html_url = "https://github.com/test/issue/3"
@@ -754,10 +672,10 @@ class TestSubmitConfigTool(unittest.IsolatedAsyncioTestCase):
"""Should auto-detect category from config name"""
args = {
"config_json": '{"name": "react-test", "description": "React", "base_url": "https://react.dev/"}',
"github_token": "fake_token"
"github_token": "fake_token",
}
with patch('github.Github') as mock_gh:
with patch("github.Github") as mock_gh:
mock_repo = MagicMock()
mock_issue = MagicMock()
mock_issue.html_url = "https://github.com/test/issue/4"
@@ -770,5 +688,5 @@ class TestSubmitConfigTool(unittest.IsolatedAsyncioTestCase):
self.assertTrue("web-frameworks" in result[0].text or "Category" in result[0].text)
if __name__ == '__main__':
if __name__ == "__main__":
unittest.main()