chore: sync local assets for ddd doc steward

This commit is contained in:
tukuaiai
2025-12-21 03:56:58 +08:00
parent 727b2900ca
commit ef7d8f4ad8
136 changed files with 48796 additions and 1 deletions
@@ -0,0 +1 @@
# Test package for Skill Seeker
@@ -0,0 +1,30 @@
"""
Pytest configuration for tests.
Configures anyio to only use asyncio backend (not trio).
Checks that the skill_seekers package is installed before running tests.
"""
import sys
import pytest
def pytest_configure(config):
"""Check if package is installed before running tests."""
try:
import skill_seekers
except ModuleNotFoundError:
print("\n" + "=" * 70)
print("ERROR: skill_seekers package not installed")
print("=" * 70)
print("\nPlease install the package in editable mode first:")
print(" pip install -e .")
print("\nOr activate your virtual environment if you already installed it.")
print("=" * 70 + "\n")
sys.exit(1)
@pytest.fixture(scope="session")
def anyio_backend():
"""Override anyio backend to only use asyncio (not trio)."""
return "asyncio"
@@ -0,0 +1,142 @@
{
"conflicts": [
{
"type": "missing_in_docs",
"severity": "medium",
"api_name": "Node2D",
"docs_info": null,
"code_info": {
"name": "Node2D",
"type": "class",
"source": "scene/node2d.py",
"line": 10,
"base_classes": [
"Node"
],
"docstring": "Base class for 2D nodes"
},
"difference": "API exists in code (scene/node2d.py) but not found in documentation",
"suggestion": "Add documentation for this API"
},
{
"type": "missing_in_docs",
"severity": "medium",
"api_name": "Node2D.move_local_x",
"docs_info": null,
"code_info": {
"name": "Node2D.move_local_x",
"type": "method",
"parameters": [
{
"name": "self",
"type_hint": null,
"default": null
},
{
"name": "delta",
"type_hint": "float",
"default": null
},
{
"name": "snap",
"type_hint": "bool",
"default": "False"
}
],
"return_type": "None",
"source": "scene/node2d.py",
"line": 45,
"docstring": "Move node along local X axis",
"is_async": false
},
"difference": "API exists in code (scene/node2d.py) but not found in documentation",
"suggestion": "Add documentation for this API"
},
{
"type": "missing_in_docs",
"severity": "medium",
"api_name": "Node2D.tween_position",
"docs_info": null,
"code_info": {
"name": "Node2D.tween_position",
"type": "method",
"parameters": [
{
"name": "self",
"type_hint": null,
"default": null
},
{
"name": "target",
"type_hint": "tuple",
"default": null
}
],
"return_type": "None",
"source": "scene/node2d.py",
"line": 52,
"docstring": "Animate to target position",
"is_async": true
},
"difference": "API exists in code (scene/node2d.py) but not found in documentation",
"suggestion": "Add documentation for this API"
},
{
"type": "missing_in_code",
"severity": "high",
"api_name": "move_local_x",
"docs_info": {
"name": "move_local_x",
"parameters": [
{
"name": "delta",
"type": "float",
"default": null
}
],
"return_type": "def",
"source": "https://example.com/api/node2d",
"raw_signature": "def move_local_x(delta: float)"
},
"code_info": null,
"difference": "API documented (https://example.com/api/node2d) but not found in code",
"suggestion": "Update documentation to remove this API, or add it to codebase"
},
{
"type": "missing_in_code",
"severity": "high",
"api_name": "rotate",
"docs_info": {
"name": "rotate",
"parameters": [
{
"name": "angle",
"type": "float",
"default": null
}
],
"return_type": "def",
"source": "https://example.com/api/node2d",
"raw_signature": "def rotate(angle: float)"
},
"code_info": null,
"difference": "API documented (https://example.com/api/node2d) but not found in code",
"suggestion": "Update documentation to remove this API, or add it to codebase"
}
],
"summary": {
"total": 5,
"by_type": {
"missing_in_docs": 3,
"missing_in_code": 2,
"signature_mismatch": 0,
"description_mismatch": 0
},
"by_severity": {
"low": 0,
"medium": 3,
"high": 2
},
"apis_affected": 5
}
}
@@ -0,0 +1,567 @@
# MCP Integration Test Results
Test documentation for Skill Seeker MCP server with Claude Code.
---
## Test Overview
**Goal:** Verify MCP server works correctly with actual Claude Code instance
**Date:** [To be filled when tested]
**Tester:** [To be filled]
**Environment:**
- OS: [macOS / Linux / Windows WSL]
- Python Version: [e.g., 3.11.5]
- Claude Code Version: [e.g., 1.0.0]
- MCP Package Version: [e.g., 0.9.0]
---
## Setup Checklist
- [ ] Python 3.7+ installed
- [ ] Claude Code installed and running
- [ ] Repository cloned
- [ ] MCP dependencies installed (`pip3 install -r mcp/requirements.txt`)
- [ ] CLI dependencies installed (`pip3 install requests beautifulsoup4`)
- [ ] MCP server configured in `~/.config/claude-code/mcp.json`
- [ ] Claude Code restarted after configuration
---
## Test Cases
### Test 1: List Configs
**Command:**
```
List all available configs
```
**Expected Result:**
- Shows 7 preset configurations
- Lists: godot, react, vue, django, fastapi, kubernetes, steam-economy-complete
- Each with description
**Actual Result:**
```
[To be filled]
```
**Status:** [ ] Pass / [ ] Fail
**Notes:**
```
[Any observations]
```
---
### Test 2: Validate Config
**Command:**
```
Validate configs/react.json
```
**Expected Result:**
- Shows "Config is valid"
- Displays config details (base_url, max_pages, rate_limit, categories)
- No errors or warnings
**Actual Result:**
```
[To be filled]
```
**Status:** [ ] Pass / [ ] Fail
**Notes:**
```
[Any observations]
```
---
### Test 3: Generate Config
**Command:**
```
Generate config for Tailwind CSS at https://tailwindcss.com/docs
```
**Expected Result:**
- Creates `configs/tailwind.json`
- File contains valid JSON
- Has required fields: name, base_url, description
- Has default values for optional fields
**Actual Result:**
```
[To be filled]
```
**Config File Created:** [ ] Yes / [ ] No
**Config Validation:**
```bash
# Verify file exists
ls configs/tailwind.json
# Verify valid JSON
python3 -m json.tool configs/tailwind.json
# Check contents
cat configs/tailwind.json
```
**Status:** [ ] Pass / [ ] Fail
**Notes:**
```
[Any observations]
```
---
### Test 4: Estimate Pages
**Command:**
```
Estimate pages for configs/react.json with max discovery 100
```
**Expected Result:**
- Shows progress during estimation
- Completes in ~30-60 seconds
- Shows discovered pages count
- Shows estimated total
- Recommends max_pages value
- No errors or timeouts
**Actual Result:**
```
[To be filled]
```
**Performance:**
- Time taken: [X seconds]
- Pages discovered: [X]
- Estimated total: [X]
**Status:** [ ] Pass / [ ] Fail
**Notes:**
```
[Any observations]
```
---
### Test 5: Scrape Docs (Small Test)
**Command:**
```
Scrape docs using configs/kubernetes.json with max 10 pages
```
**Expected Result:**
- Creates `output/kubernetes_data/` directory
- Creates `output/kubernetes/` skill directory
- Generates `output/kubernetes/SKILL.md`
- Creates reference files in `output/kubernetes/references/`
- Completes in ~1-2 minutes (for 10 pages)
- No errors during scraping
**Actual Result:**
```
[To be filled]
```
**Files Created:**
```bash
# Check directories
ls output/kubernetes_data/
ls output/kubernetes/
ls output/kubernetes/references/
# Check SKILL.md
wc -l output/kubernetes/SKILL.md
# Count reference files
ls output/kubernetes/references/ | wc -l
```
**Performance:**
- Time taken: [X minutes]
- Pages scraped: [X]
- Reference files created: [X]
**Status:** [ ] Pass / [ ] Fail
**Notes:**
```
[Any observations]
```
---
### Test 6: Package Skill
**Command:**
```
Package skill at output/kubernetes/
```
**Expected Result:**
- Creates `output/kubernetes.zip`
- File is valid ZIP archive
- Contains SKILL.md and references/
- Size is reasonable (< 10 MB for 10 pages)
- Completes in < 5 seconds
**Actual Result:**
```
[To be filled]
```
**File Verification:**
```bash
# Check file exists
ls -lh output/kubernetes.zip
# Check ZIP contents
unzip -l output/kubernetes.zip
# Verify ZIP is valid
unzip -t output/kubernetes.zip
```
**Performance:**
- Time taken: [X seconds]
- ZIP file size: [X MB]
**Status:** [ ] Pass / [ ] Fail
**Notes:**
```
[Any observations]
```
---
## Additional Tests
### Test 7: Error Handling - Invalid Config
**Command:**
```
Validate configs/nonexistent.json
```
**Expected Result:**
- Shows clear error message
- Does not crash
- Suggests checking file path
**Actual Result:**
```
[To be filled]
```
**Status:** [ ] Pass / [ ] Fail
---
### Test 8: Error Handling - Invalid URL
**Command:**
```
Generate config for Test at not-a-valid-url
```
**Expected Result:**
- Shows error about invalid URL
- Does not create config file
- Does not crash
**Actual Result:**
```
[To be filled]
```
**Status:** [ ] Pass / [ ] Fail
---
### Test 9: Concurrent Tool Calls
**Commands (rapid succession):**
```
1. List all available configs
2. Validate configs/react.json
3. Validate configs/vue.json
```
**Expected Result:**
- All commands execute successfully
- No race conditions
- Responses are correct for each command
**Actual Result:**
```
[To be filled]
```
**Status:** [ ] Pass / [ ] Fail
---
### Test 10: Large Scrape Operation
**Command:**
```
Scrape docs using configs/react.json with max 100 pages
```
**Expected Result:**
- Handles long-running operation (10-15 minutes)
- Shows progress or remains responsive
- Completes successfully
- Creates comprehensive skill
- No memory leaks
**Actual Result:**
```
[To be filled]
```
**Performance:**
- Time taken: [X minutes]
- Pages scraped: [X]
- Memory usage: [X MB]
- Peak memory: [X MB]
**Status:** [ ] Pass / [ ] Fail
---
## Performance Metrics
| Operation | Expected Time | Actual Time | Status |
|-----------|--------------|-------------|--------|
| List configs | < 1s | [X]s | [ ] |
| Validate config | < 2s | [X]s | [ ] |
| Generate config | < 3s | [X]s | [ ] |
| Estimate pages (100) | 30-60s | [X]s | [ ] |
| Scrape 10 pages | 1-2 min | [X]min | [ ] |
| Scrape 100 pages | 10-15 min | [X]min | [ ] |
| Package skill | < 5s | [X]s | [ ] |
---
## Issues Found
### Issue 1: [Title]
**Severity:** [ ] Critical / [ ] High / [ ] Medium / [ ] Low
**Description:**
```
[Detailed description of the issue]
```
**Steps to Reproduce:**
1. [Step 1]
2. [Step 2]
3. [Step 3]
**Expected Behavior:**
```
[What should happen]
```
**Actual Behavior:**
```
[What actually happened]
```
**Error Messages:**
```
[Any error messages or logs]
```
**Workaround:**
```
[Temporary solution, if any]
```
**Fix Required:** [ ] Yes / [ ] No
---
### Issue 2: [Title]
[Same format as Issue 1]
---
## Configuration Used
```json
{
"mcpServers": {
"skill-seeker": {
"command": "python3",
"args": [
"/path/to/Skill_Seekers/mcp/server.py"
],
"cwd": "/path/to/Skill_Seekers"
}
}
}
```
---
## Summary
**Total Tests:** 10
**Tests Passed:** [X]
**Tests Failed:** [X]
**Tests Skipped:** [X]
**Overall Status:** [ ] Pass / [ ] Fail / [ ] Partial
**Recommendation:**
```
[Ready for production / Needs fixes / Requires more testing]
```
---
## Observations
### What Worked Well
- [Observation 1]
- [Observation 2]
- [Observation 3]
### What Needs Improvement
- [Observation 1]
- [Observation 2]
- [Observation 3]
### Suggestions
- [Suggestion 1]
- [Suggestion 2]
- [Suggestion 3]
---
## Next Steps
- [ ] Address critical issues
- [ ] Re-test failed cases
- [ ] Document workarounds
- [ ] Update MCP server if needed
- [ ] Update documentation based on findings
- [ ] Create GitHub issues for bugs found
---
## Appendix: Test Commands Reference
```bash
# Quick test sequence
echo "Test 1: List configs"
# User says: "List all available configs"
echo "Test 2: Validate"
# User says: "Validate configs/react.json"
echo "Test 3: Generate"
# User says: "Generate config for Tailwind CSS at https://tailwindcss.com/docs"
echo "Test 4: Estimate"
# User says: "Estimate pages for configs/tailwind.json"
echo "Test 5: Scrape"
# User says: "Scrape docs using configs/tailwind.json with max 10 pages"
echo "Test 6: Package"
# User says: "Package skill at output/tailwind/"
# Verify results
ls configs/tailwind.json
ls output/tailwind/SKILL.md
ls output/tailwind.zip
```
---
## Test Environment Setup Script
```bash
#!/bin/bash
# Test environment setup
echo "Setting up MCP integration test environment..."
# 1. Check prerequisites
echo "Checking Python version..."
python3 --version
echo "Checking Claude Code..."
# (Manual check required)
# 2. Install dependencies
echo "Installing dependencies..."
pip3 install -r mcp/requirements.txt
pip3 install requests beautifulsoup4
# 3. Verify installation
echo "Verifying MCP server..."
timeout 2 python3 mcp/server.py || echo "Server can start"
# 4. Create test output directory
echo "Creating test directories..."
mkdir -p test_output
echo "Setup complete! Ready for testing."
echo "Next: Configure Claude Code MCP settings and restart"
```
---
## Cleanup Script
```bash
#!/bin/bash
# Cleanup after tests
echo "Cleaning up test artifacts..."
# Remove test configs
rm -f configs/tailwind.json
rm -f configs/test*.json
# Remove test output
rm -rf output/tailwind*
rm -rf output/kubernetes*
rm -rf test_output
echo "Cleanup complete!"
```
---
**Testing Status:** [ ] Not Started / [ ] In Progress / [ ] Completed
**Sign-off:**
- Tester: [Name]
- Date: [YYYY-MM-DD]
- Approved: [ ] Yes / [ ] No
@@ -0,0 +1,328 @@
#!/usr/bin/env python3
"""
Tests for async scraping functionality
Tests the async/await implementation for parallel web scraping
"""
import sys
import os
import unittest
import asyncio
import tempfile
from pathlib import Path
from unittest.mock import Mock, patch, AsyncMock, MagicMock
from collections import deque
from skill_seekers.cli.doc_scraper import DocToSkillConverter
class TestAsyncConfiguration(unittest.TestCase):
"""Test async mode configuration and initialization"""
def setUp(self):
"""Save original working directory"""
self.original_cwd = os.getcwd()
def tearDown(self):
"""Restore original working directory"""
os.chdir(self.original_cwd)
def test_async_mode_default_false(self):
"""Test async mode is disabled by default"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'max_pages': 10
}
with tempfile.TemporaryDirectory() as tmpdir:
try:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=True)
self.assertFalse(converter.async_mode)
finally:
os.chdir(self.original_cwd)
def test_async_mode_enabled_from_config(self):
"""Test async mode can be enabled via config"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'max_pages': 10,
'async_mode': True
}
with tempfile.TemporaryDirectory() as tmpdir:
try:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=True)
self.assertTrue(converter.async_mode)
finally:
os.chdir(self.original_cwd)
def test_async_mode_with_workers(self):
"""Test async mode works with multiple workers"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'workers': 4,
'async_mode': True
}
with tempfile.TemporaryDirectory() as tmpdir:
try:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=True)
self.assertTrue(converter.async_mode)
self.assertEqual(converter.workers, 4)
finally:
os.chdir(self.original_cwd)
class TestAsyncScrapeMethods(unittest.TestCase):
"""Test async scraping methods exist and have correct signatures"""
def setUp(self):
"""Set up test fixtures"""
self.original_cwd = os.getcwd()
def tearDown(self):
"""Clean up"""
os.chdir(self.original_cwd)
def test_scrape_page_async_exists(self):
"""Test scrape_page_async method exists"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'}
}
with tempfile.TemporaryDirectory() as tmpdir:
try:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=True)
self.assertTrue(hasattr(converter, 'scrape_page_async'))
self.assertTrue(asyncio.iscoroutinefunction(converter.scrape_page_async))
finally:
os.chdir(self.original_cwd)
def test_scrape_all_async_exists(self):
"""Test scrape_all_async method exists"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'}
}
with tempfile.TemporaryDirectory() as tmpdir:
try:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=True)
self.assertTrue(hasattr(converter, 'scrape_all_async'))
self.assertTrue(asyncio.iscoroutinefunction(converter.scrape_all_async))
finally:
os.chdir(self.original_cwd)
class TestAsyncRouting(unittest.TestCase):
"""Test that scrape_all() correctly routes to async version"""
def setUp(self):
"""Set up test fixtures"""
self.original_cwd = os.getcwd()
def tearDown(self):
"""Clean up"""
os.chdir(self.original_cwd)
def test_scrape_all_routes_to_async_when_enabled(self):
"""Test scrape_all calls async version when async_mode=True"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'async_mode': True,
'max_pages': 1
}
with tempfile.TemporaryDirectory() as tmpdir:
try:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=True)
# Mock scrape_all_async to verify it gets called
with patch.object(converter, 'scrape_all_async', new_callable=AsyncMock) as mock_async:
converter.scrape_all()
# Verify async version was called
mock_async.assert_called_once()
finally:
os.chdir(self.original_cwd)
def test_scrape_all_uses_sync_when_async_disabled(self):
"""Test scrape_all uses sync version when async_mode=False"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'async_mode': False,
'max_pages': 1
}
with tempfile.TemporaryDirectory() as tmpdir:
try:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=True)
# Mock scrape_all_async to verify it does NOT get called
with patch.object(converter, 'scrape_all_async', new_callable=AsyncMock) as mock_async:
with patch.object(converter, '_try_llms_txt', return_value=False):
converter.scrape_all()
# Verify async version was NOT called
mock_async.assert_not_called()
finally:
os.chdir(self.original_cwd)
class TestAsyncDryRun(unittest.TestCase):
"""Test async scraping in dry-run mode"""
def setUp(self):
"""Set up test fixtures"""
self.original_cwd = os.getcwd()
def tearDown(self):
"""Clean up"""
os.chdir(self.original_cwd)
def test_async_dry_run_completes(self):
"""Test async dry run completes without errors"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'async_mode': True,
'max_pages': 5
}
with tempfile.TemporaryDirectory() as tmpdir:
try:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=True)
# Mock _try_llms_txt to skip llms.txt detection
with patch.object(converter, '_try_llms_txt', return_value=False):
# Should complete without errors
converter.scrape_all()
# Verify dry run mode was used
self.assertTrue(converter.dry_run)
finally:
os.chdir(self.original_cwd)
class TestAsyncErrorHandling(unittest.TestCase):
"""Test error handling in async scraping"""
def setUp(self):
"""Set up test fixtures"""
self.original_cwd = os.getcwd()
def tearDown(self):
"""Clean up"""
os.chdir(self.original_cwd)
def test_async_handles_http_errors(self):
"""Test async scraping handles HTTP errors gracefully"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'async_mode': True,
'workers': 2,
'max_pages': 1
}
with tempfile.TemporaryDirectory() as tmpdir:
try:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=False)
# Mock httpx to simulate errors
import httpx
async def run_test():
semaphore = asyncio.Semaphore(2)
async with httpx.AsyncClient() as client:
# Mock client.get to raise exception
with patch.object(client, 'get', side_effect=httpx.HTTPError("Test error")):
# Should not raise exception, just log error
await converter.scrape_page_async('https://example.com/test', semaphore, client)
# Run async test
asyncio.run(run_test())
# If we got here without exception, test passed
finally:
os.chdir(self.original_cwd)
class TestAsyncPerformance(unittest.TestCase):
"""Test async performance characteristics"""
def test_async_uses_semaphore_for_concurrency_control(self):
"""Test async mode uses semaphore instead of threading lock"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'async_mode': True,
'workers': 4
}
original_cwd = os.getcwd()
with tempfile.TemporaryDirectory() as tmpdir:
try:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=True)
# Async mode should NOT create threading lock
# (async uses asyncio.Semaphore instead)
self.assertTrue(converter.async_mode)
finally:
os.chdir(original_cwd)
class TestAsyncLlmsTxtIntegration(unittest.TestCase):
"""Test async mode with llms.txt detection"""
def test_async_respects_llms_txt(self):
"""Test async mode respects llms.txt and skips HTML scraping"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'async_mode': True
}
original_cwd = os.getcwd()
with tempfile.TemporaryDirectory() as tmpdir:
try:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=False)
# Mock _try_llms_txt to return True (llms.txt found)
with patch.object(converter, '_try_llms_txt', return_value=True):
with patch.object(converter, 'save_summary'):
converter.scrape_all()
# If llms.txt succeeded, async scraping should be skipped
# Verify by checking that pages were not scraped
self.assertEqual(len(converter.visited_urls), 0)
finally:
os.chdir(original_cwd)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""
Test suite for modern CLI command patterns
Tests that all CLI scripts use correct unified CLI commands in usage messages and print statements
"""
import sys
import os
import unittest
import subprocess
from pathlib import Path
# Add parent directory to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
class TestModernCLICommands(unittest.TestCase):
"""Test that all CLI scripts use modern unified CLI commands"""
def test_doc_scraper_uses_modern_commands(self):
"""Test doc_scraper.py uses skill-seekers commands"""
script_path = Path(__file__).parent.parent / 'src' / 'skill_seekers' / 'cli' / 'doc_scraper.py'
with open(script_path, 'r') as f:
content = f.read()
# Should use modern commands
self.assertIn('skill-seekers scrape', content)
# Should NOT use old python3 cli/ pattern
self.assertNotIn('python3 cli/doc_scraper.py', content)
def test_enhance_skill_local_uses_modern_commands(self):
"""Test enhance_skill_local.py uses skill-seekers commands"""
script_path = Path(__file__).parent.parent / 'src' / 'skill_seekers' / 'cli' / 'enhance_skill_local.py'
with open(script_path, 'r') as f:
content = f.read()
# Should use modern commands
self.assertIn('skill-seekers', content)
# Should NOT use old python3 cli/ pattern
self.assertNotIn('python3 cli/enhance_skill_local.py', content)
def test_estimate_pages_uses_modern_commands(self):
"""Test estimate_pages.py uses skill-seekers commands"""
script_path = Path(__file__).parent.parent / 'src' / 'skill_seekers' / 'cli' / 'estimate_pages.py'
with open(script_path, 'r') as f:
content = f.read()
# Should use modern commands
self.assertIn('skill-seekers estimate', content)
# Should NOT use old python3 cli/ pattern
self.assertNotIn('python3 cli/estimate_pages.py', content)
def test_package_skill_uses_modern_commands(self):
"""Test package_skill.py uses skill-seekers commands"""
script_path = Path(__file__).parent.parent / 'src' / 'skill_seekers' / 'cli' / 'package_skill.py'
with open(script_path, 'r') as f:
content = f.read()
# Should use modern commands
self.assertIn('skill-seekers package', content)
# Should NOT use old python3 cli/ pattern
self.assertNotIn('python3 cli/package_skill.py', content)
def test_github_scraper_uses_modern_commands(self):
"""Test github_scraper.py uses skill-seekers commands"""
script_path = Path(__file__).parent.parent / 'src' / 'skill_seekers' / 'cli' / 'github_scraper.py'
with open(script_path, 'r') as f:
content = f.read()
# Should use modern commands
self.assertIn('skill-seekers', content)
# Should NOT use old python3 cli/ pattern
self.assertNotIn('python3 cli/github_scraper.py', content)
class TestUnifiedCLIEntryPoints(unittest.TestCase):
"""Test that unified CLI entry points work correctly"""
def test_main_cli_help_output(self):
"""Test skill-seekers --help works"""
try:
result = subprocess.run(
['skill-seekers', '--help'],
capture_output=True,
text=True,
timeout=5
)
# Should return successfully
self.assertIn(result.returncode, [0, 2],
f"skill-seekers --help failed with code {result.returncode}")
# Should show subcommands
output = result.stdout + result.stderr
self.assertIn('scrape', output)
self.assertIn('github', output)
self.assertIn('package', output)
except FileNotFoundError:
# If skill-seekers is not installed, skip this test
self.skipTest("skill-seekers command not found - install package first")
def test_main_cli_version_output(self):
"""Test skill-seekers --version works"""
try:
result = subprocess.run(
['skill-seekers', '--version'],
capture_output=True,
text=True,
timeout=5
)
# Should return successfully
self.assertEqual(result.returncode, 0,
f"skill-seekers --version failed: {result.stderr}")
# Should show version
output = result.stdout + result.stderr
self.assertIn('2.1.1', output)
except FileNotFoundError:
# If skill-seekers is not installed, skip this test
self.skipTest("skill-seekers command not found - install package first")
class TestNoHardcodedPaths(unittest.TestCase):
"""Test that no scripts have hardcoded absolute paths"""
def test_no_hardcoded_paths_in_cli_scripts(self):
"""Test that CLI scripts don't have hardcoded paths"""
cli_dir = Path(__file__).parent.parent / 'src' / 'skill_seekers' / 'cli'
hardcoded_paths = [
'/mnt/skills/examples/skill-creator/scripts/',
'/home/',
'/Users/',
]
for script_path in cli_dir.glob('*.py'):
with open(script_path, 'r') as f:
content = f.read()
for hardcoded_path in hardcoded_paths:
self.assertNotIn(hardcoded_path, content,
f"{script_path.name} contains hardcoded path: {hardcoded_path}")
class TestPackageStructure(unittest.TestCase):
"""Test that package structure is correct"""
def test_src_layout_exists(self):
"""Test that src/ layout directory exists"""
src_dir = Path(__file__).parent.parent / 'src' / 'skill_seekers'
self.assertTrue(src_dir.exists(), "src/skill_seekers/ directory should exist")
def test_cli_package_exists(self):
"""Test that CLI package exists in src/"""
cli_dir = Path(__file__).parent.parent / 'src' / 'skill_seekers' / 'cli'
self.assertTrue(cli_dir.exists(), "src/skill_seekers/cli/ directory should exist")
init_file = cli_dir / '__init__.py'
self.assertTrue(init_file.exists(), "src/skill_seekers/cli/__init__.py should exist")
def test_mcp_package_exists(self):
"""Test that MCP package exists in src/"""
mcp_dir = Path(__file__).parent.parent / 'src' / 'skill_seekers' / 'mcp'
self.assertTrue(mcp_dir.exists(), "src/skill_seekers/mcp/ directory should exist")
init_file = mcp_dir / '__init__.py'
self.assertTrue(init_file.exists(), "src/skill_seekers/mcp/__init__.py should exist")
def test_main_cli_file_exists(self):
"""Test that main.py unified CLI exists"""
main_file = Path(__file__).parent.parent / 'src' / 'skill_seekers' / 'cli' / 'main.py'
self.assertTrue(main_file.exists(), "src/skill_seekers/cli/main.py should exist")
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,336 @@
#!/usr/bin/env python3
"""
Test suite for configuration validation
Tests the validate_config() function with various valid and invalid configs
"""
import sys
import os
import unittest
# Add parent directory to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from skill_seekers.cli.doc_scraper import validate_config
class TestConfigValidation(unittest.TestCase):
"""Test configuration validation"""
def test_valid_minimal_config(self):
"""Test valid minimal configuration"""
config = {
'name': 'test-skill',
'base_url': 'https://example.com/'
}
errors, _ = validate_config(config)
# Should have warnings about missing selectors, but no critical errors
self.assertIsInstance(errors, list)
def test_valid_complete_config(self):
"""Test valid complete configuration"""
config = {
'name': 'godot',
'base_url': 'https://docs.godotengine.org/en/stable/',
'description': 'Godot Engine documentation',
'selectors': {
'main_content': 'div[role="main"]',
'title': 'title',
'code_blocks': 'pre code'
},
'url_patterns': {
'include': ['/guide/', '/api/'],
'exclude': ['/blog/']
},
'categories': {
'getting_started': ['intro', 'tutorial'],
'api': ['api', 'reference']
},
'rate_limit': 0.5,
'max_pages': 500
}
errors, _ = validate_config(config)
self.assertEqual(len(errors), 0, f"Valid config should have no errors, got: {errors}")
def test_missing_name(self):
"""Test missing required field 'name'"""
config = {
'base_url': 'https://example.com/'
}
errors, _ = validate_config(config)
self.assertTrue(any('name' in error.lower() for error in errors))
def test_missing_base_url(self):
"""Test missing required field 'base_url'"""
config = {
'name': 'test'
}
errors, _ = validate_config(config)
self.assertTrue(any('base_url' in error.lower() for error in errors))
def test_invalid_name_special_chars(self):
"""Test invalid name with special characters"""
config = {
'name': 'test@skill!',
'base_url': 'https://example.com/'
}
errors, _ = validate_config(config)
self.assertTrue(any('invalid name' in error.lower() for error in errors))
def test_valid_name_formats(self):
"""Test various valid name formats"""
valid_names = ['test', 'test-skill', 'test_skill', 'TestSkill123', 'my-awesome-skill_v2']
for name in valid_names:
config = {
'name': name,
'base_url': 'https://example.com/'
}
errors, _ = validate_config(config)
name_errors = [e for e in errors if 'invalid name' in e.lower()]
self.assertEqual(len(name_errors), 0, f"Name '{name}' should be valid")
def test_invalid_base_url_no_protocol(self):
"""Test invalid base_url without protocol"""
config = {
'name': 'test',
'base_url': 'example.com'
}
errors, _ = validate_config(config)
self.assertTrue(any('base_url' in error.lower() for error in errors))
def test_valid_url_protocols(self):
"""Test valid URL protocols"""
for protocol in ['http://', 'https://']:
config = {
'name': 'test',
'base_url': f'{protocol}example.com/'
}
errors, _ = validate_config(config)
url_errors = [e for e in errors if 'base_url' in e.lower() and 'invalid' in e.lower()]
self.assertEqual(len(url_errors), 0, f"Protocol '{protocol}' should be valid")
def test_invalid_selectors_not_dict(self):
"""Test invalid selectors (not a dictionary)"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': 'invalid'
}
errors, _ = validate_config(config)
self.assertTrue(any('selectors' in error.lower() and 'dictionary' in error.lower() for error in errors))
def test_missing_recommended_selectors(self):
"""Test warning for missing recommended selectors"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {
'main_content': 'article'
# Missing 'title' and 'code_blocks'
}
}
_, warnings = validate_config(config)
self.assertTrue(any('title' in warning.lower() for warning in warnings))
self.assertTrue(any('code_blocks' in warning.lower() for warning in warnings))
def test_invalid_url_patterns_not_dict(self):
"""Test invalid url_patterns (not a dictionary)"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'url_patterns': []
}
errors, _ = validate_config(config)
self.assertTrue(any('url_patterns' in error.lower() and 'dictionary' in error.lower() for error in errors))
def test_invalid_url_patterns_include_not_list(self):
"""Test invalid url_patterns.include (not a list)"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'url_patterns': {
'include': 'not-a-list'
}
}
errors, _ = validate_config(config)
self.assertTrue(any('include' in error.lower() and 'list' in error.lower() for error in errors))
def test_invalid_categories_not_dict(self):
"""Test invalid categories (not a dictionary)"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'categories': []
}
errors, _ = validate_config(config)
self.assertTrue(any('categories' in error.lower() and 'dictionary' in error.lower() for error in errors))
def test_invalid_category_keywords_not_list(self):
"""Test invalid category keywords (not a list)"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'categories': {
'getting_started': 'not-a-list'
}
}
errors, _ = validate_config(config)
self.assertTrue(any('getting_started' in error.lower() and 'list' in error.lower() for error in errors))
def test_invalid_rate_limit_negative(self):
"""Test invalid rate_limit (negative)"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'rate_limit': -1
}
errors, _ = validate_config(config)
self.assertTrue(any('rate_limit' in error.lower() for error in errors))
def test_invalid_rate_limit_too_high(self):
"""Test invalid rate_limit (too high)"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'rate_limit': 20
}
_, warnings = validate_config(config)
self.assertTrue(any('rate_limit' in warning.lower() for warning in warnings))
def test_invalid_rate_limit_not_number(self):
"""Test invalid rate_limit (not a number)"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'rate_limit': 'fast'
}
errors, _ = validate_config(config)
self.assertTrue(any('rate_limit' in error.lower() for error in errors))
def test_valid_rate_limit_range(self):
"""Test valid rate_limit range"""
for rate in [0, 0.1, 0.5, 1, 5, 10]:
config = {
'name': 'test',
'base_url': 'https://example.com/',
'rate_limit': rate
}
errors, _ = validate_config(config)
rate_errors = [e for e in errors if 'rate_limit' in e.lower()]
self.assertEqual(len(rate_errors), 0, f"Rate limit {rate} should be valid")
def test_invalid_max_pages_zero(self):
"""Test invalid max_pages (zero)"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'max_pages': 0
}
errors, _ = validate_config(config)
self.assertTrue(any('max_pages' in error.lower() for error in errors))
def test_invalid_max_pages_too_high(self):
"""Test invalid max_pages (too high)"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'max_pages': 20000
}
_, warnings = validate_config(config)
self.assertTrue(any('max_pages' in warning.lower() for warning in warnings))
def test_invalid_max_pages_not_int(self):
"""Test invalid max_pages (not an integer)"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'max_pages': 'many'
}
errors, _ = validate_config(config)
self.assertTrue(any('max_pages' in error.lower() for error in errors))
def test_valid_max_pages_range(self):
"""Test valid max_pages range"""
for max_p in [1, 10, 100, 500, 5000, 10000]:
config = {
'name': 'test',
'base_url': 'https://example.com/',
'max_pages': max_p
}
errors, _ = validate_config(config)
max_errors = [e for e in errors if 'max_pages' in e.lower()]
self.assertEqual(len(max_errors), 0, f"Max pages {max_p} should be valid")
def test_invalid_start_urls_not_list(self):
"""Test invalid start_urls (not a list)"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'start_urls': 'https://example.com/page1'
}
errors, _ = validate_config(config)
self.assertTrue(any('start_urls' in error.lower() and 'list' in error.lower() for error in errors))
def test_invalid_start_urls_bad_protocol(self):
"""Test invalid start_urls (bad protocol)"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'start_urls': ['ftp://example.com/page1']
}
errors, _ = validate_config(config)
self.assertTrue(any('start_url' in error.lower() for error in errors))
def test_valid_start_urls(self):
"""Test valid start_urls"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'start_urls': [
'https://example.com/page1',
'http://example.com/page2',
'https://example.com/api/docs'
]
}
errors, _ = validate_config(config)
url_errors = [e for e in errors if 'start_url' in e.lower()]
self.assertEqual(len(url_errors), 0, "Valid start_urls should pass validation")
def test_config_with_llms_txt_url(self):
"""Test config validation with explicit llms_txt_url"""
config = {
'name': 'test',
'llms_txt_url': 'https://example.com/llms-full.txt',
'base_url': 'https://example.com/docs'
}
# Should be valid
self.assertEqual(config.get('llms_txt_url'), 'https://example.com/llms-full.txt')
def test_config_with_skip_llms_txt(self):
"""Test config validation accepts skip_llms_txt"""
config = {
'name': 'test',
'base_url': 'https://example.com/docs',
'skip_llms_txt': True
}
errors, warnings = validate_config(config)
self.assertEqual(errors, [])
self.assertTrue(config.get('skip_llms_txt'))
def test_config_with_skip_llms_txt_false(self):
"""Test config validation accepts skip_llms_txt as False"""
config = {
'name': 'test',
'base_url': 'https://example.com/docs',
'skip_llms_txt': False
}
errors, warnings = validate_config(config)
self.assertEqual(errors, [])
self.assertFalse(config.get('skip_llms_txt'))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Test suite for cli/constants.py module."""
import unittest
import sys
from pathlib import Path
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from skill_seekers.cli.constants import (
DEFAULT_RATE_LIMIT,
DEFAULT_MAX_PAGES,
DEFAULT_CHECKPOINT_INTERVAL,
CONTENT_PREVIEW_LENGTH,
MAX_PAGES_WARNING_THRESHOLD,
MIN_CATEGORIZATION_SCORE,
URL_MATCH_POINTS,
TITLE_MATCH_POINTS,
CONTENT_MATCH_POINTS,
API_CONTENT_LIMIT,
API_PREVIEW_LIMIT,
LOCAL_CONTENT_LIMIT,
LOCAL_PREVIEW_LIMIT,
DEFAULT_MAX_DISCOVERY,
DISCOVERY_THRESHOLD,
MAX_REFERENCE_FILES,
MAX_CODE_BLOCKS_PER_PAGE,
)
class TestConstants(unittest.TestCase):
"""Test that all constants are defined and have sensible values."""
def test_scraping_constants_exist(self):
"""Test that scraping constants are defined."""
self.assertIsNotNone(DEFAULT_RATE_LIMIT)
self.assertIsNotNone(DEFAULT_MAX_PAGES)
self.assertIsNotNone(DEFAULT_CHECKPOINT_INTERVAL)
def test_scraping_constants_types(self):
"""Test that scraping constants have correct types."""
self.assertIsInstance(DEFAULT_RATE_LIMIT, (int, float))
self.assertIsInstance(DEFAULT_MAX_PAGES, int)
self.assertIsInstance(DEFAULT_CHECKPOINT_INTERVAL, int)
def test_scraping_constants_ranges(self):
"""Test that scraping constants have sensible values."""
self.assertGreater(DEFAULT_RATE_LIMIT, 0)
self.assertGreater(DEFAULT_MAX_PAGES, 0)
self.assertGreater(DEFAULT_CHECKPOINT_INTERVAL, 0)
self.assertEqual(DEFAULT_RATE_LIMIT, 0.5)
self.assertEqual(DEFAULT_MAX_PAGES, 500)
self.assertEqual(DEFAULT_CHECKPOINT_INTERVAL, 1000)
def test_content_analysis_constants(self):
"""Test content analysis constants."""
self.assertEqual(CONTENT_PREVIEW_LENGTH, 500)
self.assertEqual(MAX_PAGES_WARNING_THRESHOLD, 10000)
self.assertGreater(MAX_PAGES_WARNING_THRESHOLD, DEFAULT_MAX_PAGES)
def test_categorization_constants(self):
"""Test categorization scoring constants."""
self.assertEqual(MIN_CATEGORIZATION_SCORE, 2)
self.assertEqual(URL_MATCH_POINTS, 3)
self.assertEqual(TITLE_MATCH_POINTS, 2)
self.assertEqual(CONTENT_MATCH_POINTS, 1)
# Verify scoring hierarchy
self.assertGreater(URL_MATCH_POINTS, TITLE_MATCH_POINTS)
self.assertGreater(TITLE_MATCH_POINTS, CONTENT_MATCH_POINTS)
def test_enhancement_constants_exist(self):
"""Test that enhancement constants are defined."""
self.assertIsNotNone(API_CONTENT_LIMIT)
self.assertIsNotNone(API_PREVIEW_LIMIT)
self.assertIsNotNone(LOCAL_CONTENT_LIMIT)
self.assertIsNotNone(LOCAL_PREVIEW_LIMIT)
def test_enhancement_constants_values(self):
"""Test enhancement constants have expected values."""
self.assertEqual(API_CONTENT_LIMIT, 100000)
self.assertEqual(API_PREVIEW_LIMIT, 40000)
self.assertEqual(LOCAL_CONTENT_LIMIT, 50000)
self.assertEqual(LOCAL_PREVIEW_LIMIT, 20000)
def test_enhancement_limits_hierarchy(self):
"""Test that API limits are higher than local limits."""
self.assertGreater(API_CONTENT_LIMIT, LOCAL_CONTENT_LIMIT)
self.assertGreater(API_PREVIEW_LIMIT, LOCAL_PREVIEW_LIMIT)
self.assertGreater(API_CONTENT_LIMIT, API_PREVIEW_LIMIT)
self.assertGreater(LOCAL_CONTENT_LIMIT, LOCAL_PREVIEW_LIMIT)
def test_estimation_constants(self):
"""Test page estimation constants."""
self.assertEqual(DEFAULT_MAX_DISCOVERY, 1000)
self.assertEqual(DISCOVERY_THRESHOLD, 10000)
self.assertGreater(DISCOVERY_THRESHOLD, DEFAULT_MAX_DISCOVERY)
def test_file_limit_constants(self):
"""Test file limit constants."""
self.assertEqual(MAX_REFERENCE_FILES, 100)
self.assertEqual(MAX_CODE_BLOCKS_PER_PAGE, 5)
self.assertGreater(MAX_REFERENCE_FILES, 0)
self.assertGreater(MAX_CODE_BLOCKS_PER_PAGE, 0)
class TestConstantsUsage(unittest.TestCase):
"""Test that constants are properly used in other modules."""
def test_doc_scraper_imports_constants(self):
"""Test that doc_scraper imports and uses constants."""
from skill_seekers.cli import doc_scraper
# Check that doc_scraper can access the constants
self.assertTrue(hasattr(doc_scraper, 'DEFAULT_RATE_LIMIT'))
self.assertTrue(hasattr(doc_scraper, 'DEFAULT_MAX_PAGES'))
def test_estimate_pages_imports_constants(self):
"""Test that estimate_pages imports and uses constants."""
from skill_seekers.cli import estimate_pages
# Verify function signature uses constants
import inspect
sig = inspect.signature(estimate_pages.estimate_pages)
self.assertIn('max_discovery', sig.parameters)
def test_enhance_skill_imports_constants(self):
"""Test that enhance_skill imports constants."""
try:
from skill_seekers.cli import enhance_skill
# Check module loads without errors
self.assertIsNotNone(enhance_skill)
except (ImportError, SystemExit) as e:
# anthropic package may not be installed or module exits on import
# This is acceptable - we're just checking the constants import works
pass
def test_enhance_skill_local_imports_constants(self):
"""Test that enhance_skill_local imports constants."""
from skill_seekers.cli import enhance_skill_local
self.assertIsNotNone(enhance_skill_local)
class TestConstantsExports(unittest.TestCase):
"""Test that constants module exports are correct."""
def test_all_exports_exist(self):
"""Test that all items in __all__ exist."""
from skill_seekers.cli import constants
self.assertTrue(hasattr(constants, '__all__'))
for name in constants.__all__:
self.assertTrue(
hasattr(constants, name),
f"Constant '{name}' in __all__ but not defined"
)
def test_all_exports_count(self):
"""Test that __all__ has expected number of exports."""
from skill_seekers.cli import constants
# We defined 18 constants (added DEFAULT_ASYNC_MODE)
self.assertEqual(len(constants.__all__), 18)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""
Tests for cli/estimate_pages.py functionality
"""
import unittest
import tempfile
import json
from pathlib import Path
import sys
from skill_seekers.cli.estimate_pages import estimate_pages
class TestEstimatePages(unittest.TestCase):
"""Test estimate_pages function"""
def test_estimate_pages_with_minimal_config(self):
"""Test estimation with minimal configuration"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'rate_limit': 0.1
}
# This will make real HTTP request to example.com
# We use low max_discovery to keep test fast
result = estimate_pages(config, max_discovery=2, timeout=5)
# Check result structure
self.assertIsInstance(result, dict)
self.assertIn('discovered', result)
self.assertIn('estimated_total', result)
# Actual key is elapsed_seconds, not time_elapsed
self.assertIn('elapsed_seconds', result)
def test_estimate_pages_returns_discovered_count(self):
"""Test that result contains discovered page count"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'rate_limit': 0.1
}
result = estimate_pages(config, max_discovery=1, timeout=5)
self.assertGreaterEqual(result['discovered'], 0)
self.assertIsInstance(result['discovered'], int)
def test_estimate_pages_respects_max_discovery(self):
"""Test that estimation respects max_discovery limit"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'rate_limit': 0.1
}
result = estimate_pages(config, max_discovery=3, timeout=5)
# Should not discover more than max_discovery
self.assertLessEqual(result['discovered'], 3)
def test_estimate_pages_with_start_urls(self):
"""Test estimation with custom start_urls"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'start_urls': ['https://example.com/'],
'rate_limit': 0.1
}
result = estimate_pages(config, max_discovery=2, timeout=5)
self.assertIsInstance(result, dict)
self.assertIn('discovered', result)
class TestEstimatePagesCLI(unittest.TestCase):
"""Test estimate_pages command-line interface (via entry point)"""
def test_cli_help_output(self):
"""Test that skill-seekers estimate --help works"""
import subprocess
try:
result = subprocess.run(
['skill-seekers', 'estimate', '--help'],
capture_output=True,
text=True,
timeout=5
)
# Should return successfully (0 or 2 for argparse)
self.assertIn(result.returncode, [0, 2])
output = result.stdout + result.stderr
self.assertTrue('usage:' in output.lower() or 'estimate' in output.lower())
except FileNotFoundError:
self.skipTest("skill-seekers command not installed")
def test_cli_executes_with_help_flag(self):
"""Test that skill-seekers-estimate entry point works"""
import subprocess
try:
result = subprocess.run(
['skill-seekers-estimate', '--help'],
capture_output=True,
text=True,
timeout=5
)
# Should return successfully
self.assertIn(result.returncode, [0, 2])
except FileNotFoundError:
self.skipTest("skill-seekers-estimate command not installed")
def test_cli_requires_config_argument(self):
"""Test that CLI requires config file argument"""
import subprocess
try:
# Run without config argument
result = subprocess.run(
['skill-seekers', 'estimate'],
capture_output=True,
text=True,
timeout=5
)
# Should fail (non-zero exit code) or show usage
self.assertTrue(
result.returncode != 0 or 'usage' in result.stderr.lower() or 'usage' in result.stdout.lower()
)
except FileNotFoundError:
self.skipTest("skill-seekers command not installed")
class TestEstimatePagesWithRealConfig(unittest.TestCase):
"""Test estimation with real config files (if available)"""
def test_estimate_with_real_config_file(self):
"""Test estimation using a real config file (if exists)"""
config_path = Path('configs/react.json')
if not config_path.exists():
self.skipTest("configs/react.json not found")
with open(config_path, 'r') as f:
config = json.load(f)
# Use very low max_discovery to keep test fast
result = estimate_pages(config, max_discovery=3, timeout=5)
self.assertIsInstance(result, dict)
self.assertIn('discovered', result)
self.assertGreater(result['discovered'], 0)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,375 @@
"""Tests for configurable directory exclusions in GitHub scraper.
Tests Issue #203: Make EXCLUDED_DIRS configurable
"""
import unittest
from unittest.mock import patch, Mock
from skill_seekers.cli.github_scraper import GitHubScraper, EXCLUDED_DIRS
class TestExcludedDirsDefaults(unittest.TestCase):
"""Test default EXCLUDED_DIRS behavior (backward compatibility)."""
@patch('skill_seekers.cli.github_scraper.Github')
def test_defaults_when_no_config(self, mock_github):
"""Test that default exclusions are used when no config provided."""
config = {
'repo': 'owner/repo'
}
scraper = GitHubScraper(config)
# Should use default EXCLUDED_DIRS
self.assertEqual(scraper.excluded_dirs, EXCLUDED_DIRS)
@patch('skill_seekers.cli.github_scraper.Github')
def test_defaults_exclude_common_dirs(self, mock_github):
"""Test that default exclusions work correctly."""
config = {
'repo': 'owner/repo'
}
scraper = GitHubScraper(config)
# Test common directories are excluded
self.assertTrue(scraper.should_exclude_dir('venv'))
self.assertTrue(scraper.should_exclude_dir('node_modules'))
self.assertTrue(scraper.should_exclude_dir('__pycache__'))
self.assertTrue(scraper.should_exclude_dir('.git'))
self.assertTrue(scraper.should_exclude_dir('build'))
# Test normal directories are not excluded
self.assertFalse(scraper.should_exclude_dir('src'))
self.assertFalse(scraper.should_exclude_dir('tests'))
self.assertFalse(scraper.should_exclude_dir('docs'))
@patch('skill_seekers.cli.github_scraper.Github')
def test_dot_directories_always_excluded(self, mock_github):
"""Test that directories starting with '.' are always excluded."""
config = {
'repo': 'owner/repo'
}
scraper = GitHubScraper(config)
# Dot directories should be excluded (even if not in EXCLUDED_DIRS)
self.assertTrue(scraper.should_exclude_dir('.hidden'))
self.assertTrue(scraper.should_exclude_dir('.cache'))
self.assertTrue(scraper.should_exclude_dir('.vscode'))
class TestExcludedDirsAdditional(unittest.TestCase):
"""Test exclude_dirs_additional (extend mode)."""
@patch('skill_seekers.cli.github_scraper.Github')
def test_extend_with_additional_dirs(self, mock_github):
"""Test adding custom exclusions to defaults."""
config = {
'repo': 'owner/repo',
'exclude_dirs_additional': ['proprietary', 'vendor', 'third_party']
}
scraper = GitHubScraper(config)
# Should include both defaults and additional
self.assertIn('venv', scraper.excluded_dirs) # Default
self.assertIn('node_modules', scraper.excluded_dirs) # Default
self.assertIn('proprietary', scraper.excluded_dirs) # Additional
self.assertIn('vendor', scraper.excluded_dirs) # Additional
self.assertIn('third_party', scraper.excluded_dirs) # Additional
# Verify total count
self.assertEqual(
len(scraper.excluded_dirs),
len(EXCLUDED_DIRS) + 3
)
@patch('skill_seekers.cli.github_scraper.Github')
def test_extend_excludes_additional_dirs(self, mock_github):
"""Test that additional directories are actually excluded."""
config = {
'repo': 'owner/repo',
'exclude_dirs_additional': ['legacy', 'deprecated']
}
scraper = GitHubScraper(config)
# Additional dirs should be excluded
self.assertTrue(scraper.should_exclude_dir('legacy'))
self.assertTrue(scraper.should_exclude_dir('deprecated'))
# Default dirs still excluded
self.assertTrue(scraper.should_exclude_dir('venv'))
self.assertTrue(scraper.should_exclude_dir('node_modules'))
# Normal dirs not excluded
self.assertFalse(scraper.should_exclude_dir('src'))
@patch('skill_seekers.cli.github_scraper.Github')
def test_extend_with_empty_list(self, mock_github):
"""Test that empty additional list works correctly."""
config = {
'repo': 'owner/repo',
'exclude_dirs_additional': []
}
scraper = GitHubScraper(config)
# Should just have defaults
self.assertEqual(scraper.excluded_dirs, EXCLUDED_DIRS)
class TestExcludedDirsReplace(unittest.TestCase):
"""Test exclude_dirs (replace mode)."""
@patch('skill_seekers.cli.github_scraper.Github')
def test_replace_with_custom_list(self, mock_github):
"""Test replacing default exclusions entirely."""
config = {
'repo': 'owner/repo',
'exclude_dirs': ['node_modules', 'custom_vendor']
}
scraper = GitHubScraper(config)
# Should ONLY have specified dirs
self.assertEqual(scraper.excluded_dirs, {'node_modules', 'custom_vendor'})
self.assertEqual(len(scraper.excluded_dirs), 2)
@patch('skill_seekers.cli.github_scraper.Github')
def test_replace_excludes_only_specified_dirs(self, mock_github):
"""Test that only specified directories are excluded in replace mode."""
config = {
'repo': 'owner/repo',
'exclude_dirs': ['node_modules', '.git']
}
scraper = GitHubScraper(config)
# Specified dirs should be excluded
self.assertTrue(scraper.should_exclude_dir('node_modules'))
# Note: .git would be excluded anyway due to dot prefix
self.assertTrue(scraper.should_exclude_dir('.git'))
# Default dirs NOT in our list should NOT be excluded
self.assertFalse(scraper.should_exclude_dir('venv'))
self.assertFalse(scraper.should_exclude_dir('__pycache__'))
self.assertFalse(scraper.should_exclude_dir('build'))
# Normal dirs still not excluded
self.assertFalse(scraper.should_exclude_dir('src'))
@patch('skill_seekers.cli.github_scraper.Github')
def test_replace_with_empty_list(self, mock_github):
"""Test that empty replace list allows all directories (except dot-prefixed)."""
config = {
'repo': 'owner/repo',
'exclude_dirs': []
}
scraper = GitHubScraper(config)
# No explicit exclusions
self.assertEqual(scraper.excluded_dirs, set())
# Nothing explicitly excluded
self.assertFalse(scraper.should_exclude_dir('venv'))
self.assertFalse(scraper.should_exclude_dir('node_modules'))
self.assertFalse(scraper.should_exclude_dir('build'))
# But dot dirs still excluded (different logic)
self.assertTrue(scraper.should_exclude_dir('.git'))
self.assertTrue(scraper.should_exclude_dir('.hidden'))
class TestExcludedDirsPrecedence(unittest.TestCase):
"""Test precedence when both options provided."""
@patch('skill_seekers.cli.github_scraper.Github')
def test_replace_takes_precedence_over_additional(self, mock_github):
"""Test that exclude_dirs takes precedence over exclude_dirs_additional."""
config = {
'repo': 'owner/repo',
'exclude_dirs': ['only', 'these'], # Replace mode
'exclude_dirs_additional': ['ignored'] # Should be ignored
}
scraper = GitHubScraper(config)
# Should use replace mode (exclude_dirs), ignore additional
self.assertEqual(scraper.excluded_dirs, {'only', 'these'})
self.assertNotIn('ignored', scraper.excluded_dirs)
self.assertNotIn('venv', scraper.excluded_dirs) # Defaults also ignored
class TestExcludedDirsEdgeCases(unittest.TestCase):
"""Test edge cases and error handling."""
@patch('skill_seekers.cli.github_scraper.Github')
def test_duplicate_exclusions_in_additional(self, mock_github):
"""Test that duplicates in additional list are handled (set deduplication)."""
config = {
'repo': 'owner/repo',
'exclude_dirs_additional': ['venv', 'custom', 'venv'] # venv is duplicate (default + listed)
}
scraper = GitHubScraper(config)
# Should deduplicate automatically (using set)
self.assertIn('venv', scraper.excluded_dirs)
self.assertIn('custom', scraper.excluded_dirs)
# Count should account for deduplication
self.assertEqual(
len(scraper.excluded_dirs),
len(EXCLUDED_DIRS) + 1 # Only 'custom' is truly additional
)
@patch('skill_seekers.cli.github_scraper.Github')
def test_case_sensitive_exclusions(self, mock_github):
"""Test that exclusions are case-sensitive."""
config = {
'repo': 'owner/repo',
'exclude_dirs': ['Venv', 'NODE_MODULES']
}
scraper = GitHubScraper(config)
# Case-sensitive matching
self.assertTrue(scraper.should_exclude_dir('Venv'))
self.assertTrue(scraper.should_exclude_dir('NODE_MODULES'))
self.assertFalse(scraper.should_exclude_dir('venv')) # Different case
self.assertFalse(scraper.should_exclude_dir('node_modules')) # Different case
class TestExcludedDirsWithLocalRepo(unittest.TestCase):
"""Test exclude_dirs integration with local_repo_path."""
@patch('skill_seekers.cli.github_scraper.Github')
def test_exclude_dirs_with_local_repo_path(self, mock_github):
"""Test that exclude_dirs works when local_repo_path is provided."""
config = {
'repo': 'owner/repo',
'local_repo_path': '/tmp/test/repo',
'exclude_dirs_additional': ['proprietary', 'internal']
}
scraper = GitHubScraper(config)
# Should have both defaults and additional
self.assertIn('venv', scraper.excluded_dirs)
self.assertIn('proprietary', scraper.excluded_dirs)
self.assertIn('internal', scraper.excluded_dirs)
# Test exclusion works
self.assertTrue(scraper.should_exclude_dir('proprietary'))
self.assertTrue(scraper.should_exclude_dir('internal'))
self.assertTrue(scraper.should_exclude_dir('venv'))
@patch('skill_seekers.cli.github_scraper.Github')
def test_replace_mode_with_local_repo_path(self, mock_github):
"""Test that replace mode works with local_repo_path."""
config = {
'repo': 'owner/repo',
'local_repo_path': '/tmp/test/repo',
'exclude_dirs': ['only_this']
}
scraper = GitHubScraper(config)
# Should ONLY have specified dir
self.assertEqual(scraper.excluded_dirs, {'only_this'})
self.assertTrue(scraper.should_exclude_dir('only_this'))
self.assertFalse(scraper.should_exclude_dir('venv'))
class TestExcludedDirsLogging(unittest.TestCase):
"""Test logging output for exclude_dirs configuration."""
@patch('skill_seekers.cli.github_scraper.Github')
@patch('skill_seekers.cli.github_scraper.logger')
def test_extend_mode_logs_info(self, mock_logger, mock_github):
"""Test that extend mode logs INFO level message."""
config = {
'repo': 'owner/repo',
'exclude_dirs_additional': ['custom1', 'custom2']
}
scraper = GitHubScraper(config)
# Should have logged INFO message
# Check that info was called with a message about adding custom exclusions
info_calls = [str(call) for call in mock_logger.info.call_args_list]
self.assertTrue(any('Added 2 custom directory exclusions' in call for call in info_calls))
@patch('skill_seekers.cli.github_scraper.Github')
@patch('skill_seekers.cli.github_scraper.logger')
def test_replace_mode_logs_warning(self, mock_logger, mock_github):
"""Test that replace mode logs WARNING level message."""
config = {
'repo': 'owner/repo',
'exclude_dirs': ['only', 'these']
}
scraper = GitHubScraper(config)
# Should have logged WARNING message
warning_calls = [str(call) for call in mock_logger.warning.call_args_list]
self.assertTrue(any('Using custom directory exclusions' in call and 'defaults overridden' in call for call in warning_calls))
@patch('skill_seekers.cli.github_scraper.Github')
@patch('skill_seekers.cli.github_scraper.logger')
def test_no_config_no_logging(self, mock_logger, mock_github):
"""Test that default mode doesn't log exclude_dirs messages."""
config = {
'repo': 'owner/repo'
}
scraper = GitHubScraper(config)
# Should NOT have logged any exclude_dirs messages
info_calls = [str(call) for call in mock_logger.info.call_args_list]
warning_calls = [str(call) for call in mock_logger.warning.call_args_list]
# Filter for exclude_dirs related messages
exclude_info = [c for c in info_calls if 'directory exclusion' in c]
exclude_warnings = [c for c in warning_calls if 'directory exclusion' in c]
self.assertEqual(len(exclude_info), 0)
self.assertEqual(len(exclude_warnings), 0)
class TestExcludedDirsTypeHandling(unittest.TestCase):
"""Test type handling for exclude_dirs configuration."""
@patch('skill_seekers.cli.github_scraper.Github')
def test_exclude_dirs_with_tuple(self, mock_github):
"""Test that tuples are converted to sets correctly."""
config = {
'repo': 'owner/repo',
'exclude_dirs': ('node_modules', 'build') # Tuple instead of list
}
scraper = GitHubScraper(config)
# Should work with tuples (set() accepts tuples)
self.assertEqual(scraper.excluded_dirs, {'node_modules', 'build'})
@patch('skill_seekers.cli.github_scraper.Github')
def test_exclude_dirs_additional_with_set(self, mock_github):
"""Test that sets work correctly for exclude_dirs_additional."""
config = {
'repo': 'owner/repo',
'exclude_dirs_additional': {'custom1', 'custom2'} # Set instead of list
}
scraper = GitHubScraper(config)
# Should work with sets
self.assertIn('custom1', scraper.excluded_dirs)
self.assertIn('custom2', scraper.excluded_dirs)
self.assertIn('venv', scraper.excluded_dirs) # Defaults still there
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,731 @@
#!/usr/bin/env python3
"""
Tests for GitHub Scraper (cli/github_scraper.py)
Tests cover:
- GitHubScraper initialization and configuration (C1.1)
- README extraction (C1.2)
- Language detection (C1.4)
- GitHub Issues extraction (C1.7)
- CHANGELOG extraction (C1.8)
- GitHub Releases extraction (C1.9)
- GitHubToSkillConverter and skill building (C1.10)
- Authentication handling
- Error handling and edge cases
"""
import unittest
import sys
import json
import tempfile
import shutil
import os
from pathlib import Path
from unittest.mock import Mock, patch, MagicMock
from datetime import datetime
try:
from github import Github, GithubException
PYGITHUB_AVAILABLE = True
except ImportError:
PYGITHUB_AVAILABLE = False
class TestGitHubScraperInitialization(unittest.TestCase):
"""Test GitHubScraper initialization and configuration (C1.1)"""
def setUp(self):
if not PYGITHUB_AVAILABLE:
self.skipTest("PyGithub not installed")
from skill_seekers.cli.github_scraper import GitHubScraper
self.GitHubScraper = GitHubScraper
# Create temporary directory for test output
self.temp_dir = tempfile.mkdtemp()
self.output_dir = Path(self.temp_dir)
def tearDown(self):
# Clean up temporary directory
if hasattr(self, 'temp_dir'):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_init_with_repo_name(self):
"""Test initialization with repository name"""
config = {
'repo': 'facebook/react',
'name': 'react',
'github_token': None
}
scraper = self.GitHubScraper(config)
self.assertEqual(scraper.repo_name, 'facebook/react')
self.assertEqual(scraper.name, 'react')
self.assertIsNotNone(scraper.github)
def test_init_with_token_from_config(self):
"""Test initialization with token from config"""
config = {
'repo': 'facebook/react',
'name': 'react',
'github_token': 'test_token_123'
}
with patch('skill_seekers.cli.github_scraper.Github') as mock_github:
scraper = self.GitHubScraper(config)
mock_github.assert_called_once_with('test_token_123')
def test_init_with_token_from_env(self):
"""Test initialization with token from environment variable"""
config = {
'repo': 'facebook/react',
'name': 'react',
'github_token': None
}
with patch.dict(os.environ, {'GITHUB_TOKEN': 'env_token_456'}):
with patch('skill_seekers.cli.github_scraper.Github') as mock_github:
scraper = self.GitHubScraper(config)
mock_github.assert_called_once_with('env_token_456')
def test_init_without_token(self):
"""Test initialization without authentication"""
config = {
'repo': 'facebook/react',
'name': 'react',
'github_token': None
}
with patch('skill_seekers.cli.github_scraper.Github') as mock_github:
with patch.dict(os.environ, {}, clear=True):
scraper = self.GitHubScraper(config)
# Should create unauthenticated client
self.assertIsNotNone(scraper.github)
def test_token_priority_env_over_config(self):
"""Test that GITHUB_TOKEN env var takes priority over config"""
config = {
'repo': 'facebook/react',
'name': 'react',
'github_token': 'config_token'
}
with patch.dict(os.environ, {'GITHUB_TOKEN': 'env_token'}):
scraper = self.GitHubScraper(config)
token = scraper._get_token()
self.assertEqual(token, 'env_token')
class TestREADMEExtraction(unittest.TestCase):
"""Test README extraction (C1.2)"""
def setUp(self):
if not PYGITHUB_AVAILABLE:
self.skipTest("PyGithub not installed")
from skill_seekers.cli.github_scraper import GitHubScraper
self.GitHubScraper = GitHubScraper
def test_extract_readme_success(self):
"""Test successful README extraction"""
config = {
'repo': 'facebook/react',
'name': 'react',
'github_token': None
}
mock_content = Mock()
mock_content.decoded_content = b'# React\n\nA JavaScript library'
with patch('skill_seekers.cli.github_scraper.Github'):
scraper = self.GitHubScraper(config)
scraper.repo = Mock()
scraper.repo.get_contents.return_value = mock_content
scraper._extract_readme()
self.assertIn('readme', scraper.extracted_data)
self.assertEqual(scraper.extracted_data['readme'], '# React\n\nA JavaScript library')
def test_extract_readme_tries_multiple_locations(self):
"""Test that README extraction tries multiple file locations"""
config = {
'repo': 'facebook/react',
'name': 'react',
'github_token': None
}
with patch('skill_seekers.cli.github_scraper.Github'):
scraper = self.GitHubScraper(config)
scraper.repo = Mock()
# Make first attempts fail, succeed on third
def side_effect(path):
if path in ['README.md', 'README.rst']:
raise GithubException(404, 'Not found')
mock_content = Mock()
mock_content.decoded_content = b'# README'
return mock_content
scraper.repo.get_contents.side_effect = side_effect
scraper._extract_readme()
# Should have tried multiple paths
self.assertGreaterEqual(scraper.repo.get_contents.call_count, 1)
def test_extract_readme_not_found(self):
"""Test README extraction when no README exists"""
config = {
'repo': 'test/norepo',
'name': 'norepo',
'github_token': None
}
with patch('skill_seekers.cli.github_scraper.Github'):
scraper = self.GitHubScraper(config)
scraper.repo = Mock()
scraper.repo.get_contents.side_effect = GithubException(404, 'Not found')
scraper._extract_readme()
# Should not crash, just log warning (readme initialized as empty string)
self.assertEqual(scraper.extracted_data['readme'], '')
class TestLanguageDetection(unittest.TestCase):
"""Test language detection (C1.4)"""
def setUp(self):
if not PYGITHUB_AVAILABLE:
self.skipTest("PyGithub not installed")
from skill_seekers.cli.github_scraper import GitHubScraper
self.GitHubScraper = GitHubScraper
def test_extract_languages_success(self):
"""Test successful language detection"""
config = {
'repo': 'facebook/react',
'name': 'react',
'github_token': None
}
with patch('skill_seekers.cli.github_scraper.Github'):
scraper = self.GitHubScraper(config)
scraper.repo = Mock()
scraper.repo.get_languages.return_value = {
'JavaScript': 8000,
'TypeScript': 2000
}
scraper._extract_languages()
self.assertIn('languages', scraper.extracted_data)
self.assertIn('JavaScript', scraper.extracted_data['languages'])
self.assertIn('TypeScript', scraper.extracted_data['languages'])
# Check percentages
js_data = scraper.extracted_data['languages']['JavaScript']
self.assertEqual(js_data['bytes'], 8000)
self.assertEqual(js_data['percentage'], 80.0)
ts_data = scraper.extracted_data['languages']['TypeScript']
self.assertEqual(ts_data['bytes'], 2000)
self.assertEqual(ts_data['percentage'], 20.0)
def test_extract_languages_empty(self):
"""Test language detection with no languages"""
config = {
'repo': 'test/norepo',
'name': 'norepo',
'github_token': None
}
with patch('skill_seekers.cli.github_scraper.Github'):
scraper = self.GitHubScraper(config)
scraper.repo = Mock()
scraper.repo.get_languages.return_value = {}
scraper._extract_languages()
self.assertIn('languages', scraper.extracted_data)
self.assertEqual(scraper.extracted_data['languages'], {})
class TestIssuesExtraction(unittest.TestCase):
"""Test GitHub Issues extraction (C1.7)"""
def setUp(self):
if not PYGITHUB_AVAILABLE:
self.skipTest("PyGithub not installed")
from skill_seekers.cli.github_scraper import GitHubScraper
self.GitHubScraper = GitHubScraper
def test_extract_issues_success(self):
"""Test successful issues extraction"""
config = {
'repo': 'facebook/react',
'name': 'react',
'github_token': None,
'max_issues': 10
}
# Create mock issues
mock_label1 = Mock()
mock_label1.name = 'bug'
mock_label2 = Mock()
mock_label2.name = 'high-priority'
mock_milestone = Mock()
mock_milestone.title = 'v18.0'
mock_issue1 = Mock()
mock_issue1.number = 123
mock_issue1.title = 'Bug in useState'
mock_issue1.state = 'open'
mock_issue1.labels = [mock_label1, mock_label2]
mock_issue1.milestone = mock_milestone
mock_issue1.created_at = datetime(2023, 1, 1)
mock_issue1.updated_at = datetime(2023, 1, 2)
mock_issue1.closed_at = None
mock_issue1.html_url = 'https://github.com/facebook/react/issues/123'
mock_issue1.body = 'Issue description'
mock_issue1.pull_request = None
mock_label3 = Mock()
mock_label3.name = 'enhancement'
mock_issue2 = Mock()
mock_issue2.number = 124
mock_issue2.title = 'Feature request'
mock_issue2.state = 'closed'
mock_issue2.labels = [mock_label3]
mock_issue2.milestone = None
mock_issue2.created_at = datetime(2023, 1, 3)
mock_issue2.updated_at = datetime(2023, 1, 4)
mock_issue2.closed_at = datetime(2023, 1, 5)
mock_issue2.html_url = 'https://github.com/facebook/react/issues/124'
mock_issue2.body = 'Feature description'
mock_issue2.pull_request = None
with patch('skill_seekers.cli.github_scraper.Github'):
scraper = self.GitHubScraper(config)
scraper.repo = Mock()
scraper.repo.get_issues.return_value = [mock_issue1, mock_issue2]
scraper._extract_issues()
self.assertIn('issues', scraper.extracted_data)
issues = scraper.extracted_data['issues']
self.assertEqual(len(issues), 2)
# Check first issue
self.assertEqual(issues[0]['number'], 123)
self.assertEqual(issues[0]['title'], 'Bug in useState')
self.assertEqual(issues[0]['state'], 'open')
self.assertEqual(issues[0]['labels'], ['bug', 'high-priority'])
self.assertEqual(issues[0]['milestone'], 'v18.0')
# Check second issue
self.assertEqual(issues[1]['number'], 124)
self.assertEqual(issues[1]['state'], 'closed')
self.assertIsNone(issues[1]['milestone'])
def test_extract_issues_filters_pull_requests(self):
"""Test that pull requests are filtered out from issues"""
config = {
'repo': 'facebook/react',
'name': 'react',
'github_token': None,
'max_issues': 10
}
# Create mock issue (need all required attributes)
mock_issue = Mock()
mock_issue.number = 123
mock_issue.title = 'Real issue'
mock_issue.state = 'open'
mock_issue.labels = []
mock_issue.milestone = None
mock_issue.created_at = datetime(2023, 1, 1)
mock_issue.updated_at = datetime(2023, 1, 2)
mock_issue.closed_at = None
mock_issue.html_url = 'https://github.com/test/repo/issues/123'
mock_issue.body = 'Issue body'
mock_issue.pull_request = None
mock_pr = Mock()
mock_pr.number = 124
mock_pr.title = 'Pull request'
mock_pr.pull_request = Mock() # Has pull_request attribute
with patch('skill_seekers.cli.github_scraper.Github'):
scraper = self.GitHubScraper(config)
scraper.repo = Mock()
scraper.repo.get_issues.return_value = [mock_issue, mock_pr]
scraper._extract_issues()
issues = scraper.extracted_data['issues']
# Should only have the real issue, not the PR
self.assertEqual(len(issues), 1)
self.assertEqual(issues[0]['number'], 123)
def test_extract_issues_respects_max_limit(self):
"""Test that max_issues limit is respected"""
config = {
'repo': 'facebook/react',
'name': 'react',
'github_token': None,
'max_issues': 2
}
# Create 5 mock issues
mock_issues = []
for i in range(5):
mock_issue = Mock()
mock_issue.number = i
mock_issue.title = f'Issue {i}'
mock_issue.state = 'open'
mock_issue.labels = []
mock_issue.milestone = None
mock_issue.created_at = datetime(2023, 1, 1)
mock_issue.updated_at = datetime(2023, 1, 2)
mock_issue.closed_at = None
mock_issue.html_url = f'https://github.com/test/repo/issues/{i}'
mock_issue.body = None
mock_issue.pull_request = None
mock_issues.append(mock_issue)
with patch('skill_seekers.cli.github_scraper.Github'):
scraper = self.GitHubScraper(config)
scraper.repo = Mock()
scraper.repo.get_issues.return_value = mock_issues
scraper._extract_issues()
issues = scraper.extracted_data['issues']
# Should only extract first 2 issues
self.assertEqual(len(issues), 2)
class TestChangelogExtraction(unittest.TestCase):
"""Test CHANGELOG extraction (C1.8)"""
def setUp(self):
if not PYGITHUB_AVAILABLE:
self.skipTest("PyGithub not installed")
from skill_seekers.cli.github_scraper import GitHubScraper
self.GitHubScraper = GitHubScraper
def test_extract_changelog_success(self):
"""Test successful CHANGELOG extraction"""
config = {
'repo': 'facebook/react',
'name': 'react',
'github_token': None
}
mock_content = Mock()
mock_content.decoded_content = b'# Changelog\n\n## v1.0.0\n- Initial release'
with patch('skill_seekers.cli.github_scraper.Github'):
scraper = self.GitHubScraper(config)
scraper.repo = Mock()
scraper.repo.get_contents.return_value = mock_content
scraper._extract_changelog()
self.assertIn('changelog', scraper.extracted_data)
self.assertIn('Initial release', scraper.extracted_data['changelog'])
def test_extract_changelog_tries_multiple_locations(self):
"""Test that CHANGELOG extraction tries multiple file locations"""
config = {
'repo': 'facebook/react',
'name': 'react',
'github_token': None
}
with patch('skill_seekers.cli.github_scraper.Github'):
scraper = self.GitHubScraper(config)
scraper.repo = Mock()
# Make first attempts fail
call_count = {'count': 0}
def side_effect(path):
call_count['count'] += 1
if path in ['CHANGELOG.md', 'CHANGES.md']:
raise GithubException(404, 'Not found')
mock_content = Mock()
mock_content.decoded_content = b'# History'
return mock_content
scraper.repo.get_contents.side_effect = side_effect
scraper._extract_changelog()
# Should have tried multiple paths
self.assertGreaterEqual(call_count['count'], 1)
def test_extract_changelog_not_found(self):
"""Test CHANGELOG extraction when no changelog exists"""
config = {
'repo': 'test/norepo',
'name': 'norepo',
'github_token': None
}
with patch('skill_seekers.cli.github_scraper.Github'):
scraper = self.GitHubScraper(config)
scraper.repo = Mock()
scraper.repo.get_contents.side_effect = GithubException(404, 'Not found')
scraper._extract_changelog()
# Should not crash, just log warning (changelog initialized as empty string)
self.assertEqual(scraper.extracted_data['changelog'], '')
class TestReleasesExtraction(unittest.TestCase):
"""Test GitHub Releases extraction (C1.9)"""
def setUp(self):
if not PYGITHUB_AVAILABLE:
self.skipTest("PyGithub not installed")
from skill_seekers.cli.github_scraper import GitHubScraper
self.GitHubScraper = GitHubScraper
def test_extract_releases_success(self):
"""Test successful releases extraction"""
config = {
'repo': 'facebook/react',
'name': 'react',
'github_token': None
}
# Create mock releases
mock_release1 = Mock()
mock_release1.tag_name = 'v18.0.0'
mock_release1.title = 'React 18.0.0'
mock_release1.body = 'New features:\n- Concurrent rendering'
mock_release1.draft = False
mock_release1.prerelease = False
mock_release1.created_at = datetime(2023, 3, 1)
mock_release1.published_at = datetime(2023, 3, 1)
mock_release1.html_url = 'https://github.com/facebook/react/releases/tag/v18.0.0'
mock_release1.tarball_url = 'https://github.com/facebook/react/archive/v18.0.0.tar.gz'
mock_release1.zipball_url = 'https://github.com/facebook/react/archive/v18.0.0.zip'
mock_release2 = Mock()
mock_release2.tag_name = 'v18.0.0-rc.0'
mock_release2.title = 'React 18.0.0 RC'
mock_release2.body = 'Release candidate'
mock_release2.draft = False
mock_release2.prerelease = True
mock_release2.created_at = datetime(2023, 2, 1)
mock_release2.published_at = datetime(2023, 2, 1)
mock_release2.html_url = 'https://github.com/facebook/react/releases/tag/v18.0.0-rc.0'
mock_release2.tarball_url = 'https://github.com/facebook/react/archive/v18.0.0-rc.0.tar.gz'
mock_release2.zipball_url = 'https://github.com/facebook/react/archive/v18.0.0-rc.0.zip'
with patch('skill_seekers.cli.github_scraper.Github'):
scraper = self.GitHubScraper(config)
scraper.repo = Mock()
scraper.repo.get_releases.return_value = [mock_release1, mock_release2]
scraper._extract_releases()
self.assertIn('releases', scraper.extracted_data)
releases = scraper.extracted_data['releases']
self.assertEqual(len(releases), 2)
# Check first release
self.assertEqual(releases[0]['tag_name'], 'v18.0.0')
self.assertEqual(releases[0]['name'], 'React 18.0.0')
self.assertFalse(releases[0]['draft'])
self.assertFalse(releases[0]['prerelease'])
self.assertIn('Concurrent rendering', releases[0]['body'])
# Check second release (prerelease)
self.assertEqual(releases[1]['tag_name'], 'v18.0.0-rc.0')
self.assertTrue(releases[1]['prerelease'])
def test_extract_releases_empty(self):
"""Test releases extraction with no releases"""
config = {
'repo': 'test/norepo',
'name': 'norepo',
'github_token': None
}
with patch('skill_seekers.cli.github_scraper.Github'):
scraper = self.GitHubScraper(config)
scraper.repo = Mock()
scraper.repo.get_releases.return_value = []
scraper._extract_releases()
self.assertIn('releases', scraper.extracted_data)
self.assertEqual(scraper.extracted_data['releases'], [])
class TestGitHubToSkillConverter(unittest.TestCase):
"""Test GitHubToSkillConverter and skill building (C1.10)"""
def setUp(self):
if not PYGITHUB_AVAILABLE:
self.skipTest("PyGithub not installed")
from skill_seekers.cli.github_scraper import GitHubToSkillConverter
self.GitHubToSkillConverter = GitHubToSkillConverter
# Create temporary directory for test output
self.temp_dir = tempfile.mkdtemp()
self.output_dir = Path(self.temp_dir)
# Create mock data file
self.data_file = self.output_dir / "test_github_data.json"
self.mock_data = {
'repo_info': {
'name': 'react',
'full_name': 'facebook/react',
'description': 'A JavaScript library',
'stars': 200000,
'language': 'JavaScript'
},
'readme': '# React\n\nA JavaScript library for building user interfaces.',
'languages': {
'JavaScript': {'bytes': 8000, 'percentage': 80.0},
'TypeScript': {'bytes': 2000, 'percentage': 20.0}
},
'issues': [
{
'number': 123,
'title': 'Bug in useState',
'state': 'open',
'labels': ['bug'],
'milestone': 'v18.0',
'created_at': '2023-01-01T10:00:00',
'updated_at': '2023-01-02T10:00:00',
'closed_at': None,
'url': 'https://github.com/facebook/react/issues/123',
'body': 'Issue description'
}
],
'changelog': '# Changelog\n\n## v18.0.0\n- New features',
'releases': [
{
'tag_name': 'v18.0.0',
'name': 'React 18.0.0',
'body': 'Release notes',
'published_at': '2023-03-01T10:00:00',
'prerelease': False,
'draft': False,
'url': 'https://github.com/facebook/react/releases/tag/v18.0.0'
}
]
}
with open(self.data_file, 'w') as f:
json.dump(self.mock_data, f)
def tearDown(self):
# Clean up temporary directory
if hasattr(self, 'temp_dir'):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_init_loads_data(self):
"""Test that converter loads data file on initialization"""
config = {
'repo': 'facebook/react',
'name': 'test',
'description': 'Test skill'
}
# Override data file path
with patch('skill_seekers.cli.github_scraper.GitHubToSkillConverter.__init__') as mock_init:
mock_init.return_value = None
converter = self.GitHubToSkillConverter(config)
converter.data_file = str(self.data_file)
converter.data = converter._load_data()
self.assertIn('repo_info', converter.data)
self.assertEqual(converter.data['repo_info']['name'], 'react')
def test_build_skill_creates_directory_structure(self):
"""Test that build_skill creates proper directory structure"""
# Create data file in expected location
data_file_path = self.output_dir / 'test_github_data.json'
with open(data_file_path, 'w') as f:
json.dump(self.mock_data, f)
config = {
'repo': 'facebook/react',
'name': 'test',
'description': 'Test skill'
}
# Patch the paths to use our temp directory
with patch('skill_seekers.cli.github_scraper.GitHubToSkillConverter._load_data') as mock_load:
mock_load.return_value = self.mock_data
converter = self.GitHubToSkillConverter(config)
converter.skill_dir = str(self.output_dir / 'test_skill')
converter.data = self.mock_data
converter.build_skill()
skill_dir = Path(converter.skill_dir)
self.assertTrue(skill_dir.exists())
self.assertTrue((skill_dir / 'SKILL.md').exists())
self.assertTrue((skill_dir / 'references').exists())
class TestErrorHandling(unittest.TestCase):
"""Test error handling and edge cases"""
def setUp(self):
if not PYGITHUB_AVAILABLE:
self.skipTest("PyGithub not installed")
from skill_seekers.cli.github_scraper import GitHubScraper
self.GitHubScraper = GitHubScraper
def test_invalid_repo_name(self):
"""Test handling of invalid repository name"""
config = {
'repo': 'invalid_repo_format',
'name': 'test',
'github_token': None
}
with patch('skill_seekers.cli.github_scraper.Github'):
scraper = self.GitHubScraper(config)
scraper.repo = None
scraper.github.get_repo = Mock(side_effect=GithubException(404, 'Not found'))
# Should raise ValueError with helpful message
with self.assertRaises(ValueError) as context:
scraper._fetch_repository()
self.assertIn('Repository not found', str(context.exception))
def test_rate_limit_error(self):
"""Test handling of rate limit errors"""
config = {
'repo': 'facebook/react',
'name': 'react',
'github_token': None,
'max_issues': 10
}
with patch('skill_seekers.cli.github_scraper.Github'):
scraper = self.GitHubScraper(config)
scraper.repo = Mock()
scraper.repo.get_issues.side_effect = GithubException(403, 'Rate limit exceeded')
# Should handle gracefully and log warning
scraper._extract_issues()
# Should not crash, just log warning
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,647 @@
#!/usr/bin/env python3
"""
Integration tests for doc_scraper
Tests complete workflows and dry-run mode
"""
import sys
import os
import unittest
import json
import tempfile
import shutil
from pathlib import Path
# Add parent directory to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from skill_seekers.cli.doc_scraper import DocToSkillConverter, load_config, validate_config
class TestDryRunMode(unittest.TestCase):
"""Test dry-run mode functionality"""
def setUp(self):
"""Set up test configuration"""
self.config = {
'name': 'test-dry-run',
'base_url': 'https://example.com/',
'selectors': {
'main_content': 'article',
'title': 'h1',
'code_blocks': 'pre code'
},
'url_patterns': {
'include': [],
'exclude': []
},
'rate_limit': 0.1,
'max_pages': 10
}
def test_dry_run_no_directories_created(self):
"""Test that dry-run mode doesn't create directories"""
converter = DocToSkillConverter(self.config, dry_run=True)
# Check directories were NOT created
data_dir = Path(f"output/{self.config['name']}_data")
skill_dir = Path(f"output/{self.config['name']}")
self.assertFalse(data_dir.exists(), "Dry-run should not create data directory")
self.assertFalse(skill_dir.exists(), "Dry-run should not create skill directory")
def test_dry_run_flag_set(self):
"""Test that dry_run flag is properly set"""
converter = DocToSkillConverter(self.config, dry_run=True)
self.assertTrue(converter.dry_run)
converter_normal = DocToSkillConverter(self.config, dry_run=False)
self.assertFalse(converter_normal.dry_run)
# Clean up
shutil.rmtree(f"output/{self.config['name']}_data", ignore_errors=True)
shutil.rmtree(f"output/{self.config['name']}", ignore_errors=True)
def test_normal_mode_creates_directories(self):
"""Test that normal mode creates directories"""
converter = DocToSkillConverter(self.config, dry_run=False)
# Check directories WERE created
data_dir = Path(f"output/{self.config['name']}_data")
skill_dir = Path(f"output/{self.config['name']}")
self.assertTrue(data_dir.exists(), "Normal mode should create data directory")
self.assertTrue(skill_dir.exists(), "Normal mode should create skill directory")
# Clean up
shutil.rmtree(data_dir, ignore_errors=True)
shutil.rmtree(skill_dir, ignore_errors=True)
class TestConfigLoading(unittest.TestCase):
"""Test configuration loading and validation"""
def setUp(self):
"""Set up temporary directory for test configs"""
self.temp_dir = tempfile.mkdtemp()
def tearDown(self):
"""Clean up temporary directory"""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_load_valid_config(self):
"""Test loading a valid configuration file"""
config_data = {
'name': 'test-config',
'base_url': 'https://example.com/',
'selectors': {
'main_content': 'article',
'title': 'h1',
'code_blocks': 'pre code'
},
'rate_limit': 0.5,
'max_pages': 100
}
config_path = Path(self.temp_dir) / 'test.json'
with open(config_path, 'w') as f:
json.dump(config_data, f)
loaded_config = load_config(str(config_path))
self.assertEqual(loaded_config['name'], 'test-config')
self.assertEqual(loaded_config['base_url'], 'https://example.com/')
def test_load_invalid_json(self):
"""Test loading an invalid JSON file"""
config_path = Path(self.temp_dir) / 'invalid.json'
with open(config_path, 'w') as f:
f.write('{ invalid json }')
with self.assertRaises(SystemExit):
load_config(str(config_path))
def test_load_nonexistent_file(self):
"""Test loading a nonexistent file"""
config_path = Path(self.temp_dir) / 'nonexistent.json'
with self.assertRaises(SystemExit):
load_config(str(config_path))
def test_load_config_with_validation_errors(self):
"""Test loading a config with validation errors"""
config_data = {
'name': 'invalid@name', # Invalid name
'base_url': 'example.com' # Missing protocol
}
config_path = Path(self.temp_dir) / 'invalid_config.json'
with open(config_path, 'w') as f:
json.dump(config_data, f)
with self.assertRaises(SystemExit):
load_config(str(config_path))
class TestRealConfigFiles(unittest.TestCase):
"""Test that real config files in the repository are valid"""
def test_godot_config(self):
"""Test Godot config is valid"""
config_path = 'configs/godot.json'
if os.path.exists(config_path):
config = load_config(config_path)
errors, _ = validate_config(config)
self.assertEqual(len(errors), 0, f"Godot config should be valid, got errors: {errors}")
def test_react_config(self):
"""Test React config is valid"""
config_path = 'configs/react.json'
if os.path.exists(config_path):
config = load_config(config_path)
errors, _ = validate_config(config)
self.assertEqual(len(errors), 0, f"React config should be valid, got errors: {errors}")
def test_vue_config(self):
"""Test Vue config is valid"""
config_path = 'configs/vue.json'
if os.path.exists(config_path):
config = load_config(config_path)
errors, _ = validate_config(config)
self.assertEqual(len(errors), 0, f"Vue config should be valid, got errors: {errors}")
def test_django_config(self):
"""Test Django config is valid"""
config_path = 'configs/django.json'
if os.path.exists(config_path):
config = load_config(config_path)
errors, _ = validate_config(config)
self.assertEqual(len(errors), 0, f"Django config should be valid, got errors: {errors}")
def test_fastapi_config(self):
"""Test FastAPI config is valid"""
config_path = 'configs/fastapi.json'
if os.path.exists(config_path):
config = load_config(config_path)
errors, _ = validate_config(config)
self.assertEqual(len(errors), 0, f"FastAPI config should be valid, got errors: {errors}")
def test_steam_economy_config(self):
"""Test Steam Economy config is valid"""
config_path = 'configs/steam-economy-complete.json'
if os.path.exists(config_path):
config = load_config(config_path)
errors, _ = validate_config(config)
self.assertEqual(len(errors), 0, f"Steam Economy config should be valid, got errors: {errors}")
class TestURLProcessing(unittest.TestCase):
"""Test URL processing and validation"""
def test_url_normalization(self):
"""Test URL normalization in converter"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article', 'title': 'h1', 'code_blocks': 'pre'},
'url_patterns': {'include': [], 'exclude': []},
'rate_limit': 0.1,
'max_pages': 10
}
converter = DocToSkillConverter(config, dry_run=True)
# Base URL should be stored correctly
self.assertEqual(converter.base_url, 'https://example.com/')
def test_start_urls_fallback(self):
"""Test that start_urls defaults to base_url"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article', 'title': 'h1', 'code_blocks': 'pre'},
'rate_limit': 0.1,
'max_pages': 10
}
converter = DocToSkillConverter(config, dry_run=True)
# Should have base_url in pending_urls
self.assertEqual(len(converter.pending_urls), 1)
self.assertEqual(converter.pending_urls[0], 'https://example.com/')
def test_multiple_start_urls(self):
"""Test multiple start URLs"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'start_urls': [
'https://example.com/guide/',
'https://example.com/api/',
'https://example.com/tutorial/'
],
'selectors': {'main_content': 'article', 'title': 'h1', 'code_blocks': 'pre'},
'rate_limit': 0.1,
'max_pages': 10
}
converter = DocToSkillConverter(config, dry_run=True)
# Should have all start URLs in pending_urls
self.assertEqual(len(converter.pending_urls), 3)
class TestLlmsTxtIntegration(unittest.TestCase):
"""Test llms.txt integration into scraping workflow"""
def test_scraper_has_llms_txt_attributes(self):
"""Test that scraper has llms.txt detection attributes"""
config = {
'name': 'test-llms',
'base_url': 'https://hono.dev/docs',
'selectors': {
'main_content': 'article',
'title': 'h1',
'code_blocks': 'pre code'
},
'max_pages': 50
}
scraper = DocToSkillConverter(config, dry_run=True)
# Should have llms.txt attributes
self.assertFalse(scraper.llms_txt_detected)
self.assertIsNone(scraper.llms_txt_variant)
def test_scraper_has_try_llms_txt_method(self):
"""Test that scraper has _try_llms_txt method"""
config = {
'name': 'test-llms',
'base_url': 'https://hono.dev/docs',
'selectors': {
'main_content': 'article',
'title': 'h1',
'code_blocks': 'pre code'
},
'max_pages': 50
}
scraper = DocToSkillConverter(config, dry_run=True)
# Should have _try_llms_txt method
self.assertTrue(hasattr(scraper, '_try_llms_txt'))
self.assertTrue(callable(getattr(scraper, '_try_llms_txt')))
class TestContentExtraction(unittest.TestCase):
"""Test content extraction functionality"""
def setUp(self):
"""Set up test converter"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {
'main_content': 'article',
'title': 'h1',
'code_blocks': 'pre code'
},
'rate_limit': 0.1,
'max_pages': 10
}
self.converter = DocToSkillConverter(config, dry_run=True)
def test_extract_empty_content(self):
"""Test extracting from empty HTML"""
from bs4 import BeautifulSoup
html = '<html><body></body></html>'
soup = BeautifulSoup(html, 'html.parser')
page = self.converter.extract_content(soup, 'https://example.com/test')
self.assertEqual(page['url'], 'https://example.com/test')
self.assertEqual(page['title'], '')
self.assertEqual(page['content'], '')
self.assertEqual(len(page['code_samples']), 0)
def test_extract_basic_content(self):
"""Test extracting basic content"""
from bs4 import BeautifulSoup
html = '''
<html>
<head><title>Test Page</title></head>
<body>
<article>
<h1>Page Title</h1>
<p>This is some content.</p>
<p>This is more content with sufficient length to be included.</p>
<pre><code class="language-python">print("hello")</code></pre>
</article>
</body>
</html>
'''
soup = BeautifulSoup(html, 'html.parser')
page = self.converter.extract_content(soup, 'https://example.com/test')
self.assertEqual(page['url'], 'https://example.com/test')
self.assertIn('Page Title', page['title'])
self.assertIn('content', page['content'].lower())
self.assertGreater(len(page['code_samples']), 0)
self.assertEqual(page['code_samples'][0]['language'], 'python')
class TestFullLlmsTxtWorkflow(unittest.TestCase):
"""Test complete llms.txt workflow with mocked HTTP requests"""
def setUp(self):
"""Set up test configuration and temporary directory"""
self.temp_dir = tempfile.mkdtemp()
self.config = {
'name': 'test-e2e-llms',
'base_url': 'https://hono.dev/docs',
'llms_txt_url': 'https://hono.dev/llms-full.txt',
'selectors': {
'main_content': 'article',
'title': 'h1',
'code_blocks': 'pre code'
},
'max_pages': 50
}
# Sample llms.txt content for testing
self.sample_llms_content = """# Getting Started
Welcome to the framework documentation. This is the introduction section.
## Installation
To install the framework, run the following command:
```bash
npm install hono
```
## Quick Start
Create a simple application:
```javascript
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => {
return c.text('Hello World!')
})
export default app
```
# API Reference
This section covers the API documentation for the framework.
## Context
The context object provides request and response handling:
```typescript
interface Context {
req: Request
res: Response
text: (text: string) => Response
}
```
# Middleware
Middleware functions run before route handlers.
## Built-in Middleware
The framework provides several built-in middleware functions:
```javascript
import { logger, cors } from 'hono/middleware'
app.use('*', logger())
app.use('*', cors())
```
"""
def tearDown(self):
"""Clean up temporary directory and test output"""
shutil.rmtree(self.temp_dir, ignore_errors=True)
# Clean up test output directories
shutil.rmtree(f"output/{self.config['name']}_data", ignore_errors=True)
shutil.rmtree(f"output/{self.config['name']}", ignore_errors=True)
def test_full_llms_txt_workflow(self):
"""Test complete workflow: config -> scrape (llms.txt) -> build -> verify"""
from unittest.mock import patch, MagicMock
import requests
# Mock the requests.get call for downloading llms.txt
with patch('cli.llms_txt_downloader.requests.get') as mock_get:
# Configure mock response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.text = self.sample_llms_content
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
# Create scraper and scrape
scraper = DocToSkillConverter(self.config, dry_run=False)
scraper.scrape_all()
# Verify llms.txt was detected
self.assertTrue(scraper.llms_txt_detected,
"llms.txt should be detected")
self.assertEqual(scraper.llms_txt_variant, 'explicit',
"Should use explicit variant from config")
# Verify pages were parsed
self.assertGreater(len(scraper.pages), 0,
"Should have parsed pages from llms.txt")
# Verify page structure
self.assertTrue(all('title' in page for page in scraper.pages),
"All pages should have titles")
self.assertTrue(all('content' in page for page in scraper.pages),
"All pages should have content")
self.assertTrue(any(len(page.get('code_samples', [])) > 0
for page in scraper.pages),
"At least one page should have code samples")
# Verify code samples have language detection
pages_with_code = [p for p in scraper.pages
if len(p.get('code_samples', [])) > 0]
if pages_with_code:
sample = pages_with_code[0]['code_samples'][0]
self.assertIn('language', sample,
"Code samples should have language field")
self.assertIn('code', sample,
"Code samples should have code field")
# Build skill
scraper.build_skill()
# Verify SKILL.md exists
skill_md_path = Path(f"output/{self.config['name']}/SKILL.md")
self.assertTrue(skill_md_path.exists(),
"SKILL.md should be created")
# Verify SKILL.md content
skill_content = skill_md_path.read_text()
self.assertIn(self.config['name'], skill_content,
"SKILL.md should contain skill name")
self.assertGreater(len(skill_content), 100,
"SKILL.md should have substantial content")
# Verify references directory exists
refs_dir = Path(f"output/{self.config['name']}/references")
self.assertTrue(refs_dir.exists(),
"references directory should exist")
# Verify at least index.md was created
index_md = refs_dir / 'index.md'
self.assertTrue(index_md.exists(),
"references/index.md should exist")
# Verify reference files have content
ref_files = list(refs_dir.glob('*.md'))
self.assertGreater(len(ref_files), 0,
"Should have at least one reference file")
# Verify data directory was created and has summary
data_dir = Path(f"output/{self.config['name']}_data")
self.assertTrue(data_dir.exists(),
"Data directory should exist")
summary_path = data_dir / 'summary.json'
self.assertTrue(summary_path.exists(),
"summary.json should exist")
# Verify summary content
with open(summary_path) as f:
summary = json.load(f)
self.assertEqual(summary['name'], self.config['name'])
self.assertGreater(summary['total_pages'], 0)
self.assertIn('llms_txt_detected', summary)
self.assertTrue(summary['llms_txt_detected'])
def test_multi_variant_download(self):
"""Test downloading all 3 llms.txt variants"""
from unittest.mock import patch, Mock
config = {
'name': 'test-multi-variant',
'base_url': 'https://hono.dev/docs',
'selectors': {
'main_content': 'article',
'title': 'h1',
'code_blocks': 'pre code'
},
'max_pages': 50
}
# Mock all 3 variants
sample_full = "# Full\n" + "x" * 1000
sample_standard = "# Standard\n" + "x" * 200
sample_small = "# Small\n" + "x" * 500
with patch('cli.llms_txt_detector.requests.head') as mock_head, \
patch('cli.llms_txt_downloader.requests.get') as mock_get:
# Mock detection (all exist)
mock_head_response = Mock()
mock_head_response.status_code = 200
mock_head.return_value = mock_head_response
# Mock downloads
def mock_download(url, **kwargs):
response = Mock()
response.status_code = 200
if 'llms-full.txt' in url:
response.text = sample_full
elif 'llms-small.txt' in url:
response.text = sample_small
else: # llms.txt
response.text = sample_standard
response.raise_for_status = Mock()
return response
mock_get.side_effect = mock_download
# Run scraper
from skill_seekers.cli.doc_scraper import DocToSkillConverter as DocumentationScraper
scraper = DocumentationScraper(config, dry_run=False)
result = scraper._try_llms_txt()
# Verify all 3 files created
refs_dir = Path(f"output/{config['name']}/references")
self.assertTrue(refs_dir.exists(), "references directory should exist")
self.assertTrue((refs_dir / 'llms-full.md').exists(), "llms-full.md should exist")
self.assertTrue((refs_dir / 'llms.md').exists(), "llms.md should exist")
self.assertTrue((refs_dir / 'llms-small.md').exists(), "llms-small.md should exist")
# Verify content not truncated
full_content = (refs_dir / 'llms-full.md').read_text()
self.assertEqual(len(full_content), len(sample_full))
# Clean up
shutil.rmtree(f"output/{config['name']}_data", ignore_errors=True)
shutil.rmtree(f"output/{config['name']}", ignore_errors=True)
def test_no_content_truncation():
"""Test that content is NOT truncated in reference files"""
from unittest.mock import Mock
import tempfile
config = {
'name': 'test-no-truncate',
'base_url': 'https://example.com/docs',
'selectors': {
'main_content': 'article',
'title': 'h1',
'code_blocks': 'pre code'
},
'max_pages': 50
}
# Create scraper with long content
from skill_seekers.cli.doc_scraper import DocToSkillConverter
scraper = DocToSkillConverter(config, dry_run=False)
# Create page with content > 2500 chars
long_content = "x" * 5000
long_code = "y" * 1000
pages = [{
'title': 'Long Page',
'url': 'https://example.com/long',
'content': long_content,
'code_samples': [
{'code': long_code, 'language': 'python'}
],
'headings': []
}]
# Create reference file
scraper.create_reference_file('test', pages)
# Verify no truncation
ref_file = Path(f"output/{config['name']}/references/test.md")
with open(ref_file, 'r') as f:
content = f.read()
assert long_content in content # Full content included
assert long_code in content # Full code included
assert '[Content truncated]' not in content
assert '...' not in content or content.count('...') == 0
# Clean up
shutil.rmtree(f"output/{config['name']}_data", ignore_errors=True)
shutil.rmtree(f"output/{config['name']}", ignore_errors=True)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,77 @@
import pytest
from unittest.mock import patch, Mock
from skill_seekers.cli.llms_txt_detector import LlmsTxtDetector
def test_detect_llms_txt_variants():
"""Test detection of llms.txt file variants"""
detector = LlmsTxtDetector("https://hono.dev/docs")
with patch('skill_seekers.cli.llms_txt_detector.requests.head') as mock_head:
mock_response = Mock()
mock_response.status_code = 200
mock_head.return_value = mock_response
variants = detector.detect()
assert variants is not None
assert variants['url'] == 'https://hono.dev/llms-full.txt'
assert variants['variant'] == 'full'
mock_head.assert_called()
def test_detect_no_llms_txt():
"""Test detection when no llms.txt file exists"""
detector = LlmsTxtDetector("https://example.com/docs")
with patch('skill_seekers.cli.llms_txt_detector.requests.head') as mock_head:
mock_response = Mock()
mock_response.status_code = 404
mock_head.return_value = mock_response
variants = detector.detect()
assert variants is None
assert mock_head.call_count == 3 # Should try all three variants
def test_url_parsing_with_complex_paths():
"""Test URL parsing handles non-standard paths correctly"""
detector = LlmsTxtDetector("https://example.com/docs/v2/guide")
with patch('skill_seekers.cli.llms_txt_detector.requests.head') as mock_head:
mock_response = Mock()
mock_response.status_code = 200
mock_head.return_value = mock_response
variants = detector.detect()
assert variants is not None
assert variants['url'] == 'https://example.com/llms-full.txt'
mock_head.assert_called_with(
'https://example.com/llms-full.txt',
timeout=5,
allow_redirects=True
)
def test_detect_all_variants():
"""Test detecting all llms.txt variants"""
detector = LlmsTxtDetector("https://hono.dev/docs")
with patch('skill_seekers.cli.llms_txt_detector.requests.head') as mock_head:
# Mock responses for different variants
def mock_response(url, **kwargs):
response = Mock()
# All 3 variants exist for Hono
if 'llms-full.txt' in url or 'llms.txt' in url or 'llms-small.txt' in url:
response.status_code = 200
else:
response.status_code = 404
return response
mock_head.side_effect = mock_response
variants = detector.detect_all()
assert len(variants) == 3
assert any(v['variant'] == 'full' for v in variants)
assert any(v['variant'] == 'standard' for v in variants)
assert any(v['variant'] == 'small' for v in variants)
assert all('url' in v for v in variants)
@@ -0,0 +1,170 @@
import pytest
from unittest.mock import patch, Mock
import requests
from skill_seekers.cli.llms_txt_downloader import LlmsTxtDownloader
def test_successful_download():
"""Test successful download with valid markdown content"""
downloader = LlmsTxtDownloader("https://example.com/llms.txt")
mock_response = Mock()
mock_response.text = "# Header\n\nSome content with markdown patterns.\n\n## Subheader\n\n- List item\n- Another item\n\n```python\ncode_block()\n```\n" + "x" * 200
mock_response.raise_for_status = Mock()
with patch('requests.get', return_value=mock_response) as mock_get:
content = downloader.download()
assert content is not None
assert len(content) > 100
assert isinstance(content, str)
assert "# Header" in content
mock_get.assert_called_once()
def test_timeout_with_retry():
"""Test timeout scenario with retry logic"""
downloader = LlmsTxtDownloader("https://example.com/llms.txt", max_retries=2)
with patch('requests.get', side_effect=requests.Timeout("Connection timeout")) as mock_get:
with patch('time.sleep') as mock_sleep: # Mock sleep to speed up test
content = downloader.download()
assert content is None
assert mock_get.call_count == 2 # Should retry once (2 total attempts)
assert mock_sleep.call_count == 1 # Should sleep once between retries
def test_empty_content_rejection():
"""Test rejection of content shorter than 100 chars"""
downloader = LlmsTxtDownloader("https://example.com/llms.txt")
mock_response = Mock()
mock_response.text = "# Short"
mock_response.raise_for_status = Mock()
with patch('requests.get', return_value=mock_response):
content = downloader.download()
assert content is None
def test_non_markdown_rejection():
"""Test rejection of content that doesn't look like markdown"""
downloader = LlmsTxtDownloader("https://example.com/llms.txt")
mock_response = Mock()
mock_response.text = "Plain text without any markdown patterns at all. " * 10
mock_response.raise_for_status = Mock()
with patch('requests.get', return_value=mock_response):
content = downloader.download()
assert content is None
def test_http_error_handling():
"""Test handling of HTTP errors (404, 500, etc.)"""
downloader = LlmsTxtDownloader("https://example.com/llms.txt", max_retries=2)
mock_response = Mock()
mock_response.raise_for_status.side_effect = requests.HTTPError("404 Not Found")
with patch('requests.get', return_value=mock_response) as mock_get:
with patch('time.sleep'):
content = downloader.download()
assert content is None
assert mock_get.call_count == 2 # Should retry once
def test_exponential_backoff():
"""Test that exponential backoff delays are correct"""
downloader = LlmsTxtDownloader("https://example.com/llms.txt", max_retries=3)
with patch('requests.get', side_effect=requests.Timeout("Connection timeout")):
with patch('time.sleep') as mock_sleep:
content = downloader.download()
assert content is None
# Should sleep with delays: 1s, 2s (2^0, 2^1)
assert mock_sleep.call_count == 2
mock_sleep.assert_any_call(1) # First retry delay
mock_sleep.assert_any_call(2) # Second retry delay
def test_markdown_validation():
"""Test markdown pattern detection"""
downloader = LlmsTxtDownloader("https://example.com/llms.txt")
# Test various markdown patterns
assert downloader._is_markdown("# Header")
assert downloader._is_markdown("## Subheader")
assert downloader._is_markdown("```code```")
assert downloader._is_markdown("- list item")
assert downloader._is_markdown("* bullet point")
assert downloader._is_markdown("`inline code`")
# Test non-markdown content
assert not downloader._is_markdown("Plain text without any markdown patterns")
def test_custom_timeout():
"""Test custom timeout parameter"""
downloader = LlmsTxtDownloader("https://example.com/llms.txt", timeout=10)
mock_response = Mock()
mock_response.text = "# Header\n\nContent " * 50
mock_response.raise_for_status = Mock()
with patch('requests.get', return_value=mock_response) as mock_get:
content = downloader.download()
assert content is not None
# Verify timeout was passed to requests.get
call_kwargs = mock_get.call_args[1]
assert call_kwargs['timeout'] == 10
def test_custom_max_retries():
"""Test custom max_retries parameter"""
downloader = LlmsTxtDownloader("https://example.com/llms.txt", max_retries=5)
with patch('requests.get', side_effect=requests.Timeout("Connection timeout")) as mock_get:
with patch('time.sleep'):
content = downloader.download()
assert content is None
assert mock_get.call_count == 5 # Should attempt 5 times
def test_user_agent_header():
"""Test that custom user agent is set"""
downloader = LlmsTxtDownloader("https://example.com/llms.txt")
mock_response = Mock()
mock_response.text = "# Header\n\nContent " * 50
mock_response.raise_for_status = Mock()
with patch('requests.get', return_value=mock_response) as mock_get:
content = downloader.download()
assert content is not None
# Verify custom user agent was passed
call_kwargs = mock_get.call_args[1]
assert call_kwargs['headers']['User-Agent'] == 'Skill-Seekers-llms.txt-Reader/1.0'
def test_get_proper_filename():
"""Test filename conversion from .txt to .md"""
downloader = LlmsTxtDownloader("https://hono.dev/llms-full.txt")
filename = downloader.get_proper_filename()
assert filename == "llms-full.md"
assert not filename.endswith('.txt')
def test_get_proper_filename_standard():
"""Test standard variant naming"""
downloader = LlmsTxtDownloader("https://hono.dev/llms.txt")
filename = downloader.get_proper_filename()
assert filename == "llms.md"
def test_get_proper_filename_small():
"""Test small variant naming"""
downloader = LlmsTxtDownloader("https://hono.dev/llms-small.txt")
filename = downloader.get_proper_filename()
assert filename == "llms-small.md"
@@ -0,0 +1,34 @@
import pytest
from skill_seekers.cli.llms_txt_parser import LlmsTxtParser
def test_parse_markdown_sections():
"""Test parsing markdown into page sections"""
sample_content = """# Getting Started
Welcome to the docs.
## Installation
Run: npm install
## Usage
Import the library:
```javascript
import { app } from 'framework'
```
# API Reference
Main API documentation here.
"""
parser = LlmsTxtParser(sample_content)
pages = parser.parse()
assert len(pages) >= 2
assert pages[0]['title'] == 'Getting Started'
assert pages[1]['title'] == 'API Reference'
assert len(pages[0]['code_samples']) == 1
assert pages[0]['code_samples'][0]['language'] == 'javascript'
@@ -0,0 +1,618 @@
#!/usr/bin/env python3
"""
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 shutil
import asyncio
from pathlib import Path
from unittest.mock import Mock, patch, AsyncMock, MagicMock
# CRITICAL: Import MCP package BEFORE adding project to path
# to avoid shadowing the installed mcp package with our local mcp/ directory
# WORKAROUND for shadowing issue: Temporarily change to /tmp to import external mcp
# This avoids our local mcp/ directory being in the import path
_original_dir = os.getcwd()
try:
os.chdir('/tmp') # Change away from project directory
from mcp.server import Server
from mcp.types import Tool, TextContent
MCP_AVAILABLE = True
except ImportError:
MCP_AVAILABLE = False
print("Warning: MCP package not available, skipping MCP tests")
finally:
os.chdir(_original_dir) # Restore original directory
# NOW add parent directory to path for importing our local modules
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Import our local MCP server module
if MCP_AVAILABLE:
# Import from installed package (new src/ layout)
try:
from skill_seekers.mcp import server as skill_seeker_server
except ImportError as e:
print(f"Warning: Could not import skill_seeker server: {e}")
skill_seeker_server = None
@unittest.skipUnless(MCP_AVAILABLE, "MCP package not installed")
class TestMCPServerInitialization(unittest.TestCase):
"""Test MCP server initialization"""
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")
@unittest.skipUnless(MCP_AVAILABLE, "MCP package not installed")
class TestListTools(unittest.IsolatedAsyncioTestCase):
"""Test list_tools functionality"""
async def test_list_tools_returns_tools(self):
"""Test that list_tools returns all expected tools"""
tools = await skill_seeker_server.list_tools()
self.assertIsInstance(tools, list)
self.assertGreater(len(tools), 0)
# Check all expected tools are present
tool_names = [tool.name for tool in tools]
expected_tools = [
"generate_config",
"estimate_pages",
"scrape_docs",
"package_skill",
"list_configs",
"validate_config"
]
for expected in expected_tools:
self.assertIn(expected, tool_names, f"Missing tool: {expected}")
async def test_tool_schemas(self):
"""Test that all tools have valid schemas"""
tools = await skill_seeker_server.list_tools()
for tool in tools:
self.assertIsInstance(tool.name, str)
self.assertIsInstance(tool.description, str)
self.assertIn("inputSchema", tool.__dict__)
# Verify schema has required structure
schema = tool.inputSchema
self.assertEqual(schema["type"], "object")
self.assertIn("properties", schema)
@unittest.skipUnless(MCP_AVAILABLE, "MCP package not installed")
class TestGenerateConfigTool(unittest.IsolatedAsyncioTestCase):
"""Test generate_config tool"""
async def asyncSetUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.original_cwd = os.getcwd()
os.chdir(self.temp_dir)
async def asyncTearDown(self):
"""Clean up test environment"""
os.chdir(self.original_cwd)
shutil.rmtree(self.temp_dir, ignore_errors=True)
async def test_generate_config_basic(self):
"""Test basic config generation"""
args = {
"name": "test-framework",
"url": "https://test-framework.dev/",
"description": "Test framework skill"
}
result = await skill_seeker_server.generate_config_tool(args)
self.assertIsInstance(result, list)
self.assertGreater(len(result), 0)
self.assertIsInstance(result[0], TextContent)
self.assertIn("", result[0].text)
# Verify config file was created
config_path = Path("configs/test-framework.json")
self.assertTrue(config_path.exists())
# Verify config content
with open(config_path) as f:
config = json.load(f)
self.assertEqual(config["name"], "test-framework")
self.assertEqual(config["base_url"], "https://test-framework.dev/")
self.assertEqual(config["description"], "Test framework skill")
async def test_generate_config_with_options(self):
"""Test config generation with custom options"""
args = {
"name": "custom-framework",
"url": "https://custom.dev/",
"description": "Custom skill",
"max_pages": 200,
"rate_limit": 1.0
}
result = await skill_seeker_server.generate_config_tool(args)
# Verify config has custom options
config_path = Path("configs/custom-framework.json")
with open(config_path) as f:
config = json.load(f)
self.assertEqual(config["max_pages"], 200)
self.assertEqual(config["rate_limit"], 1.0)
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"
}
result = await skill_seeker_server.generate_config_tool(args)
config_path = Path("configs/default-test.json")
with open(config_path) as f:
config = json.load(f)
self.assertEqual(config["max_pages"], 100) # Default
self.assertEqual(config["rate_limit"], 0.5) # Default
@unittest.skipUnless(MCP_AVAILABLE, "MCP package not installed")
class TestEstimatePagesTool(unittest.IsolatedAsyncioTestCase):
"""Test estimate_pages tool"""
async def asyncSetUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.original_cwd = os.getcwd()
os.chdir(self.temp_dir)
# Create a test config
os.makedirs("configs", exist_ok=True)
self.config_path = Path("configs/test.json")
config_data = {
"name": "test",
"base_url": "https://example.com/",
"selectors": {
"main_content": "article",
"title": "h1",
"code_blocks": "pre"
},
"rate_limit": 0.5,
"max_pages": 50
}
with open(self.config_path, 'w') as f:
json.dump(config_data, f)
async def asyncTearDown(self):
"""Clean up test environment"""
os.chdir(self.original_cwd)
shutil.rmtree(self.temp_dir, ignore_errors=True)
@patch('skill_seekers.mcp.server.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)
}
result = await skill_seeker_server.estimate_pages_tool(args)
self.assertIsInstance(result, list)
self.assertIsInstance(result[0], TextContent)
self.assertIn("50 pages", result[0].text)
# Should also have progress message
self.assertIn("Estimating page count", result[0].text)
@patch('skill_seekers.mcp.server.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
}
result = await skill_seeker_server.estimate_pages_tool(args)
# Verify subprocess was called with correct args
mock_streaming.assert_called_once()
call_args = mock_streaming.call_args[0][0]
self.assertIn("--max-discovery", call_args)
self.assertIn("500", call_args)
@patch('skill_seekers.mcp.server.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"
}
result = await skill_seeker_server.estimate_pages_tool(args)
self.assertIn("Error", result[0].text)
@unittest.skipUnless(MCP_AVAILABLE, "MCP package not installed")
class TestScrapeDocsTool(unittest.IsolatedAsyncioTestCase):
"""Test scrape_docs tool"""
async def asyncSetUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.original_cwd = os.getcwd()
os.chdir(self.temp_dir)
# Create test config
os.makedirs("configs", exist_ok=True)
self.config_path = Path("configs/test.json")
config_data = {
"name": "test",
"base_url": "https://example.com/",
"selectors": {
"main_content": "article",
"title": "h1",
"code_blocks": "pre"
}
}
with open(self.config_path, 'w') as f:
json.dump(config_data, f)
async def asyncTearDown(self):
"""Clean up test environment"""
os.chdir(self.original_cwd)
shutil.rmtree(self.temp_dir, ignore_errors=True)
@patch('skill_seekers.mcp.server.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)
}
result = await skill_seeker_server.scrape_docs_tool(args)
self.assertIsInstance(result, list)
self.assertIn("success", result[0].text.lower())
@patch('skill_seekers.mcp.server.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
}
result = await skill_seeker_server.scrape_docs_tool(args)
# Verify --skip-scrape was passed
call_args = mock_streaming.call_args[0][0]
self.assertIn("--skip-scrape", call_args)
@patch('skill_seekers.mcp.server.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
}
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.server.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
}
result = await skill_seeker_server.scrape_docs_tool(args)
call_args = mock_streaming.call_args[0][0]
self.assertIn("--enhance-local", call_args)
@unittest.skipUnless(MCP_AVAILABLE, "MCP package not installed")
class TestPackageSkillTool(unittest.IsolatedAsyncioTestCase):
"""Test package_skill tool"""
async def asyncSetUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.original_cwd = os.getcwd()
os.chdir(self.temp_dir)
# Create a mock skill directory
self.skill_dir = Path("output/test-skill")
self.skill_dir.mkdir(parents=True)
(self.skill_dir / "SKILL.md").write_text("# Test Skill")
(self.skill_dir / "references").mkdir()
(self.skill_dir / "references/index.md").write_text("# Index")
async def asyncTearDown(self):
"""Clean up test environment"""
os.chdir(self.original_cwd)
shutil.rmtree(self.temp_dir, ignore_errors=True)
@patch('subprocess.run')
async def test_package_skill_success(self, mock_run):
"""Test successful skill packaging"""
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "Package created: test-skill.zip"
mock_run.return_value = mock_result
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')
async def test_package_skill_error(self, mock_run):
"""Test error handling in skill packaging"""
mock_result = MagicMock()
mock_result.returncode = 1
mock_result.stderr = "Directory not found"
mock_run.return_value = mock_result
args = {
"skill_dir": "nonexistent-dir"
}
result = await skill_seeker_server.package_skill_tool(args)
self.assertIn("Error", result[0].text)
@unittest.skipUnless(MCP_AVAILABLE, "MCP package not installed")
class TestListConfigsTool(unittest.IsolatedAsyncioTestCase):
"""Test list_configs tool"""
async def asyncSetUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.original_cwd = os.getcwd()
os.chdir(self.temp_dir)
# Create test configs
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/"
}
]
for config in configs:
path = Path(f"configs/{config['name']}.json")
with open(path, 'w') as f:
json.dump(config, f)
async def asyncTearDown(self):
"""Clean up test environment"""
os.chdir(self.original_cwd)
shutil.rmtree(self.temp_dir, ignore_errors=True)
async def test_list_configs_success(self):
"""Test listing all configs"""
result = await skill_seeker_server.list_configs_tool({})
self.assertIsInstance(result, list)
self.assertIsInstance(result[0], TextContent)
self.assertIn("test1", result[0].text)
self.assertIn("test2", result[0].text)
self.assertIn("https://test1.dev/", result[0].text)
self.assertIn("https://test2.dev/", result[0].text)
async def test_list_configs_empty(self):
"""Test listing configs when directory is empty"""
# Remove all configs
for config_file in Path("configs").glob("*.json"):
config_file.unlink()
result = await skill_seeker_server.list_configs_tool({})
self.assertIn("No config files found", result[0].text)
async def test_list_configs_no_directory(self):
"""Test listing configs when directory doesn't exist"""
# Remove configs directory
shutil.rmtree("configs")
result = await skill_seeker_server.list_configs_tool({})
self.assertIn("No configs directory", result[0].text)
@unittest.skipUnless(MCP_AVAILABLE, "MCP package not installed")
class TestValidateConfigTool(unittest.IsolatedAsyncioTestCase):
"""Test validate_config tool"""
async def asyncSetUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.original_cwd = os.getcwd()
os.chdir(self.temp_dir)
os.makedirs("configs", exist_ok=True)
async def asyncTearDown(self):
"""Clean up test environment"""
os.chdir(self.original_cwd)
shutil.rmtree(self.temp_dir, ignore_errors=True)
async def test_validate_valid_config(self):
"""Test validating a valid config"""
# Create valid config
config_path = Path("configs/valid.json")
valid_config = {
"name": "valid-test",
"base_url": "https://example.com/",
"selectors": {
"main_content": "article",
"title": "h1",
"code_blocks": "pre"
},
"rate_limit": 0.5,
"max_pages": 100
}
with open(config_path, 'w') as f:
json.dump(valid_config, f)
args = {
"config_path": str(config_path)
}
result = await skill_seeker_server.validate_config_tool(args)
self.assertIsInstance(result, list)
self.assertIn("", result[0].text)
self.assertIn("valid", result[0].text.lower())
async def test_validate_invalid_config(self):
"""Test validating an invalid config"""
# Create invalid config (missing required fields)
config_path = Path("configs/invalid.json")
invalid_config = {
"description": "Missing name field",
"sources": [
{"type": "invalid_type", "url": "https://example.com"} # Invalid source type
]
}
with open(config_path, 'w') as f:
json.dump(invalid_config, f)
args = {
"config_path": str(config_path)
}
result = await skill_seeker_server.validate_config_tool(args)
# Should show error for invalid source type
self.assertIn("", result[0].text)
async def test_validate_nonexistent_config(self):
"""Test validating a nonexistent config"""
args = {
"config_path": "configs/nonexistent.json"
}
result = await skill_seeker_server.validate_config_tool(args)
self.assertIn("Error", result[0].text)
@unittest.skipUnless(MCP_AVAILABLE, "MCP package not installed")
class TestCallToolRouter(unittest.IsolatedAsyncioTestCase):
"""Test call_tool routing"""
async def test_call_tool_unknown(self):
"""Test calling an unknown tool"""
result = await skill_seeker_server.call_tool("unknown_tool", {})
self.assertIsInstance(result, list)
self.assertIn("Unknown tool", result[0].text)
async def test_call_tool_exception_handling(self):
"""Test that exceptions are caught and returned as errors"""
# Call with invalid arguments that should cause an exception
result = await skill_seeker_server.call_tool("generate_config", {})
self.assertIsInstance(result, list)
self.assertIn("Error", result[0].text)
@unittest.skipUnless(MCP_AVAILABLE, "MCP package not installed")
class TestMCPServerIntegration(unittest.IsolatedAsyncioTestCase):
"""Integration tests for MCP server"""
async def test_full_workflow_simulation(self):
"""Test complete workflow: generate config -> validate -> estimate"""
temp_dir = tempfile.mkdtemp()
original_cwd = os.getcwd()
os.chdir(temp_dir)
try:
# Step 1: Generate config using skill_seeker_server
generate_args = {
"name": "workflow-test",
"url": "https://workflow-test.dev/",
"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"
}
result2 = await skill_seeker_server.validate_config_tool(validate_args)
self.assertIn("", result2[0].text)
# Step 3: List configs
result3 = await skill_seeker_server.list_configs_tool({})
self.assertIn("workflow-test", result3[0].text)
finally:
os.chdir(original_cwd)
shutil.rmtree(temp_dir, ignore_errors=True)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""
Tests for cli/package_skill.py functionality
"""
import unittest
import tempfile
import zipfile
from pathlib import Path
import sys
from skill_seekers.cli.package_skill import package_skill
class TestPackageSkill(unittest.TestCase):
"""Test package_skill function"""
def create_test_skill_directory(self, tmpdir):
"""Helper to create a test skill directory structure"""
skill_dir = Path(tmpdir) / "test-skill"
skill_dir.mkdir()
# Create SKILL.md
(skill_dir / "SKILL.md").write_text("---\nname: test-skill\n---\n# Test Skill")
# Create references directory
refs_dir = skill_dir / "references"
refs_dir.mkdir()
(refs_dir / "index.md").write_text("# Index")
(refs_dir / "getting_started.md").write_text("# Getting Started")
# Create scripts directory (empty)
(skill_dir / "scripts").mkdir()
# Create assets directory (empty)
(skill_dir / "assets").mkdir()
return skill_dir
def test_package_valid_skill_directory(self):
"""Test packaging a valid skill directory"""
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = self.create_test_skill_directory(tmpdir)
success, zip_path = package_skill(skill_dir, open_folder_after=False, skip_quality_check=True)
self.assertTrue(success)
self.assertIsNotNone(zip_path)
self.assertTrue(zip_path.exists())
self.assertEqual(zip_path.suffix, '.zip')
self.assertTrue(zipfile.is_zipfile(zip_path))
def test_package_creates_correct_zip_structure(self):
"""Test that packaged zip contains correct files"""
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = self.create_test_skill_directory(tmpdir)
success, zip_path = package_skill(skill_dir, open_folder_after=False, skip_quality_check=True)
self.assertTrue(success)
# Check zip contents
with zipfile.ZipFile(zip_path, 'r') as zf:
names = zf.namelist()
# Should contain SKILL.md
self.assertTrue(any('SKILL.md' in name for name in names))
# Should contain references
self.assertTrue(any('references/index.md' in name for name in names))
self.assertTrue(any('references/getting_started.md' in name for name in names))
def test_package_excludes_backup_files(self):
"""Test that .backup files are excluded from zip"""
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = self.create_test_skill_directory(tmpdir)
# Add a backup file
(skill_dir / "SKILL.md.backup").write_text("# Backup")
success, zip_path = package_skill(skill_dir, open_folder_after=False, skip_quality_check=True)
self.assertTrue(success)
# Check that backup is NOT in zip
with zipfile.ZipFile(zip_path, 'r') as zf:
names = zf.namelist()
self.assertFalse(any('.backup' in name for name in names))
def test_package_nonexistent_directory(self):
"""Test packaging a nonexistent directory"""
success, zip_path = package_skill("/nonexistent/path", open_folder_after=False, skip_quality_check=True)
self.assertFalse(success)
self.assertIsNone(zip_path)
def test_package_directory_without_skill_md(self):
"""Test packaging directory without SKILL.md"""
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / "invalid-skill"
skill_dir.mkdir()
success, zip_path = package_skill(skill_dir, open_folder_after=False, skip_quality_check=True)
self.assertFalse(success)
self.assertIsNone(zip_path)
def test_package_creates_zip_in_correct_location(self):
"""Test that zip is created in output/ directory"""
with tempfile.TemporaryDirectory() as tmpdir:
# Create skill in output-like structure
output_dir = Path(tmpdir) / "output"
output_dir.mkdir()
skill_dir = output_dir / "test-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("# Test")
(skill_dir / "references").mkdir()
(skill_dir / "scripts").mkdir()
(skill_dir / "assets").mkdir()
success, zip_path = package_skill(skill_dir, open_folder_after=False, skip_quality_check=True)
self.assertTrue(success)
# Zip should be in output directory, not inside skill directory
self.assertEqual(zip_path.parent, output_dir)
self.assertEqual(zip_path.name, "test-skill.zip")
def test_package_zip_name_matches_skill_name(self):
"""Test that zip filename matches skill directory name"""
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / "my-awesome-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("# Test")
(skill_dir / "references").mkdir()
(skill_dir / "scripts").mkdir()
(skill_dir / "assets").mkdir()
success, zip_path = package_skill(skill_dir, open_folder_after=False, skip_quality_check=True)
self.assertTrue(success)
self.assertEqual(zip_path.name, "my-awesome-skill.zip")
class TestPackageSkillCLI(unittest.TestCase):
"""Test package_skill.py command-line interface"""
def test_cli_help_output(self):
"""Test that skill-seekers package --help works"""
import subprocess
try:
result = subprocess.run(
['skill-seekers', 'package', '--help'],
capture_output=True,
text=True,
timeout=5
)
# argparse may return 0 or 2 for --help
self.assertIn(result.returncode, [0, 2])
output = result.stdout + result.stderr
self.assertTrue('usage:' in output.lower() or 'package' in output.lower())
except FileNotFoundError:
self.skipTest("skill-seekers command not installed")
def test_cli_executes_without_errors(self):
"""Test that skill-seekers-package entry point works"""
import subprocess
try:
result = subprocess.run(
['skill-seekers-package', '--help'],
capture_output=True,
text=True,
timeout=5
)
# argparse may return 0 or 2 for --help
self.assertIn(result.returncode, [0, 2])
except FileNotFoundError:
self.skipTest("skill-seekers-package command not installed")
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,223 @@
"""Test suite for Python package structure.
Tests that the package structure is correct and imports work properly.
This ensures modern Python packaging (src/ layout, pyproject.toml) is successful.
"""
import pytest
import sys
from pathlib import Path
class TestCliPackage:
"""Test skill_seekers.cli package structure and imports."""
def test_cli_package_exists(self):
"""Test that skill_seekers.cli package can be imported."""
import skill_seekers.cli
assert skill_seekers.cli is not None
def test_cli_has_version(self):
"""Test that skill_seekers.cli package has __version__."""
import skill_seekers.cli
assert hasattr(skill_seekers.cli, '__version__')
assert skill_seekers.cli.__version__ == '2.0.0'
def test_cli_has_all(self):
"""Test that skill_seekers.cli package has __all__ export list."""
import skill_seekers.cli
assert hasattr(skill_seekers.cli, '__all__')
assert isinstance(skill_seekers.cli.__all__, list)
assert len(skill_seekers.cli.__all__) > 0
def test_llms_txt_detector_import(self):
"""Test that LlmsTxtDetector can be imported from skill_seekers.cli."""
from skill_seekers.cli import LlmsTxtDetector
assert LlmsTxtDetector is not None
def test_llms_txt_downloader_import(self):
"""Test that LlmsTxtDownloader can be imported from skill_seekers.cli."""
from skill_seekers.cli import LlmsTxtDownloader
assert LlmsTxtDownloader is not None
def test_llms_txt_parser_import(self):
"""Test that LlmsTxtParser can be imported from skill_seekers.cli."""
from skill_seekers.cli import LlmsTxtParser
assert LlmsTxtParser is not None
def test_open_folder_import(self):
"""Test that open_folder can be imported from skill_seekers.cli (if utils exists)."""
try:
from skill_seekers.cli import open_folder
# If import succeeds, function should not be None
assert open_folder is not None
except ImportError:
# If utils.py doesn't exist, that's okay for now
pytest.skip("utils.py not found, skipping open_folder test")
def test_cli_exports_match_all(self):
"""Test that exported items in __all__ can actually be imported."""
import skill_seekers.cli as cli
for item_name in cli.__all__:
if item_name == 'open_folder' and cli.open_folder is None:
# open_folder might be None if utils doesn't exist
continue
assert hasattr(cli, item_name), f"{item_name} not found in cli package"
class TestMcpPackage:
"""Test skill_seekers.mcp package structure and imports."""
def test_mcp_package_exists(self):
"""Test that skill_seekers.mcp package can be imported."""
import skill_seekers.mcp
assert skill_seekers.mcp is not None
def test_mcp_has_version(self):
"""Test that skill_seekers.mcp package has __version__."""
import skill_seekers.mcp
assert hasattr(skill_seekers.mcp, '__version__')
assert skill_seekers.mcp.__version__ == '2.0.0'
def test_mcp_has_all(self):
"""Test that skill_seekers.mcp package has __all__ export list."""
import skill_seekers.mcp
assert hasattr(skill_seekers.mcp, '__all__')
assert isinstance(skill_seekers.mcp.__all__, list)
def test_mcp_tools_package_exists(self):
"""Test that skill_seekers.mcp.tools subpackage can be imported."""
import skill_seekers.mcp.tools
assert skill_seekers.mcp.tools is not None
def test_mcp_tools_has_version(self):
"""Test that skill_seekers.mcp.tools has __version__."""
import skill_seekers.mcp.tools
assert hasattr(skill_seekers.mcp.tools, '__version__')
assert skill_seekers.mcp.tools.__version__ == '2.0.0'
class TestPackageStructure:
"""Test overall package structure integrity (src/ layout)."""
def test_cli_init_file_exists(self):
"""Test that src/skill_seekers/cli/__init__.py exists."""
init_file = Path(__file__).parent.parent / 'src' / 'skill_seekers' / 'cli' / '__init__.py'
assert init_file.exists(), "src/skill_seekers/cli/__init__.py not found"
def test_mcp_init_file_exists(self):
"""Test that src/skill_seekers/mcp/__init__.py exists."""
init_file = Path(__file__).parent.parent / 'src' / 'skill_seekers' / 'mcp' / '__init__.py'
assert init_file.exists(), "src/skill_seekers/mcp/__init__.py not found"
def test_mcp_tools_init_file_exists(self):
"""Test that src/skill_seekers/mcp/tools/__init__.py exists."""
init_file = Path(__file__).parent.parent / 'src' / 'skill_seekers' / 'mcp' / 'tools' / '__init__.py'
assert init_file.exists(), "src/skill_seekers/mcp/tools/__init__.py not found"
def test_cli_init_has_docstring(self):
"""Test that skill_seekers.cli/__init__.py has a module docstring."""
import skill_seekers.cli
assert skill_seekers.cli.__doc__ is not None
assert len(skill_seekers.cli.__doc__) > 50 # Should have substantial documentation
def test_mcp_init_has_docstring(self):
"""Test that skill_seekers.mcp/__init__.py has a module docstring."""
import skill_seekers.mcp
assert skill_seekers.mcp.__doc__ is not None
assert len(skill_seekers.mcp.__doc__) > 50 # Should have substantial documentation
class TestImportPatterns:
"""Test that various import patterns work correctly."""
def test_direct_module_import(self):
"""Test importing modules directly."""
from skill_seekers.cli import llms_txt_detector
from skill_seekers.cli import llms_txt_downloader
from skill_seekers.cli import llms_txt_parser
assert llms_txt_detector is not None
assert llms_txt_downloader is not None
assert llms_txt_parser is not None
def test_class_import_from_package(self):
"""Test importing classes from package."""
from skill_seekers.cli import LlmsTxtDetector, LlmsTxtDownloader, LlmsTxtParser
assert LlmsTxtDetector.__name__ == 'LlmsTxtDetector'
assert LlmsTxtDownloader.__name__ == 'LlmsTxtDownloader'
assert LlmsTxtParser.__name__ == 'LlmsTxtParser'
def test_package_level_import(self):
"""Test importing entire packages."""
import skill_seekers
import skill_seekers.cli
import skill_seekers.mcp
import skill_seekers.mcp.tools
assert 'skill_seekers' in sys.modules
assert 'skill_seekers.cli' in sys.modules
assert 'skill_seekers.mcp' in sys.modules
assert 'skill_seekers.mcp.tools' in sys.modules
class TestBackwardsCompatibility:
"""Test that existing code patterns still work."""
def test_direct_file_import_still_works(self):
"""Test that direct file imports still work (backwards compatible)."""
# This ensures we didn't break existing code
from skill_seekers.cli.llms_txt_detector import LlmsTxtDetector
from skill_seekers.cli.llms_txt_downloader import LlmsTxtDownloader
from skill_seekers.cli.llms_txt_parser import LlmsTxtParser
assert LlmsTxtDetector is not None
assert LlmsTxtDownloader is not None
assert LlmsTxtParser is not None
def test_module_path_import_still_works(self):
"""Test that full module path imports still work."""
import skill_seekers.cli.llms_txt_detector
import skill_seekers.cli.llms_txt_downloader
import skill_seekers.cli.llms_txt_parser
assert skill_seekers.cli.llms_txt_detector is not None
assert skill_seekers.cli.llms_txt_downloader is not None
assert skill_seekers.cli.llms_txt_parser is not None
class TestRootPackage:
"""Test root skill_seekers package."""
def test_root_package_exists(self):
"""Test that skill_seekers root package can be imported."""
import skill_seekers
assert skill_seekers is not None
def test_root_has_version(self):
"""Test that skill_seekers root package has __version__."""
import skill_seekers
assert hasattr(skill_seekers, '__version__')
assert skill_seekers.__version__ == '2.0.0'
def test_root_has_metadata(self):
"""Test that skill_seekers root package has metadata."""
import skill_seekers
assert hasattr(skill_seekers, '__author__')
assert hasattr(skill_seekers, '__license__')
assert skill_seekers.__license__ == 'MIT'
class TestCLIEntryPoints:
"""Test that CLI entry points are properly configured."""
def test_main_cli_module_exists(self):
"""Test that main.py module exists and can be imported."""
from skill_seekers.cli import main
assert main is not None
assert hasattr(main, 'main')
assert callable(main.main)
def test_main_cli_has_parser(self):
"""Test that main.py has parser creation function."""
from skill_seekers.cli.main import create_parser
parser = create_parser()
assert parser is not None
# Test that main subcommands are configured
assert parser.prog == 'skill-seekers'
@@ -0,0 +1,352 @@
#!/usr/bin/env python3
"""
Tests for parallel scraping, unlimited mode, and rate limiting features (PR #144)
"""
import sys
import os
import unittest
import tempfile
import json
import time
from pathlib import Path
from unittest.mock import Mock, patch, MagicMock
from collections import deque
from skill_seekers.cli.doc_scraper import DocToSkillConverter
class TestParallelScrapingConfiguration(unittest.TestCase):
"""Test parallel scraping configuration and initialization"""
def setUp(self):
"""Save original working directory"""
self.original_cwd = os.getcwd()
def tearDown(self):
"""Restore original working directory"""
os.chdir(self.original_cwd)
def test_single_worker_default(self):
"""Test default is single-worker mode"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'max_pages': 10
}
with tempfile.TemporaryDirectory() as tmpdir:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=True)
self.assertEqual(converter.workers, 1)
self.assertFalse(hasattr(converter, 'lock'))
def test_multiple_workers_creates_lock(self):
"""Test multiple workers creates thread lock"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'max_pages': 10,
'workers': 4
}
with tempfile.TemporaryDirectory() as tmpdir:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=True)
self.assertEqual(converter.workers, 4)
self.assertTrue(hasattr(converter, 'lock'))
def test_workers_from_config(self):
"""Test workers parameter is read from config"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'workers': 8
}
with tempfile.TemporaryDirectory() as tmpdir:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=True)
self.assertEqual(converter.workers, 8)
class TestUnlimitedMode(unittest.TestCase):
"""Test unlimited scraping mode"""
def setUp(self):
"""Save original working directory"""
self.original_cwd = os.getcwd()
def tearDown(self):
"""Restore original working directory"""
os.chdir(self.original_cwd)
def test_unlimited_with_none(self):
"""Test max_pages: None enables unlimited mode"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'max_pages': None
}
with tempfile.TemporaryDirectory() as tmpdir:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=True)
self.assertIsNone(converter.config.get('max_pages'))
def test_unlimited_with_minus_one(self):
"""Test max_pages: -1 enables unlimited mode"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'max_pages': -1
}
with tempfile.TemporaryDirectory() as tmpdir:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=True)
self.assertEqual(converter.config.get('max_pages'), -1)
def test_limited_mode_default(self):
"""Test default max_pages is limited"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'}
}
with tempfile.TemporaryDirectory() as tmpdir:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=True)
max_pages = converter.config.get('max_pages', 500)
self.assertIsNotNone(max_pages)
self.assertGreater(max_pages, 0)
class TestRateLimiting(unittest.TestCase):
"""Test rate limiting configuration"""
def setUp(self):
"""Save original working directory"""
self.original_cwd = os.getcwd()
def tearDown(self):
"""Restore original working directory"""
os.chdir(self.original_cwd)
def test_rate_limit_from_config(self):
"""Test rate_limit is read from config"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'rate_limit': 0.1
}
with tempfile.TemporaryDirectory() as tmpdir:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=True)
self.assertEqual(converter.config.get('rate_limit'), 0.1)
def test_rate_limit_default(self):
"""Test default rate_limit is 0.5"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'}
}
with tempfile.TemporaryDirectory() as tmpdir:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=True)
self.assertEqual(converter.config.get('rate_limit', 0.5), 0.5)
def test_zero_rate_limit_disables(self):
"""Test rate_limit: 0 disables rate limiting"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'rate_limit': 0
}
with tempfile.TemporaryDirectory() as tmpdir:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=True)
self.assertEqual(converter.config.get('rate_limit'), 0)
class TestThreadSafety(unittest.TestCase):
"""Test thread-safety fixes"""
def setUp(self):
"""Save original working directory"""
self.original_cwd = os.getcwd()
def tearDown(self):
"""Restore original working directory"""
os.chdir(self.original_cwd)
def test_lock_protects_visited_urls(self):
"""Test visited_urls operations are protected by lock"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'workers': 4
}
with tempfile.TemporaryDirectory() as tmpdir:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=True)
# Verify lock exists
self.assertTrue(hasattr(converter, 'lock'))
# Verify it's a threading.Lock
import threading
self.assertIsInstance(converter.lock, type(threading.Lock()))
def test_single_worker_no_lock(self):
"""Test single worker doesn't create unnecessary lock"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'workers': 1
}
with tempfile.TemporaryDirectory() as tmpdir:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=True)
self.assertFalse(hasattr(converter, 'lock'))
class TestScrapingModes(unittest.TestCase):
"""Test different scraping mode combinations"""
def setUp(self):
"""Save original working directory"""
self.original_cwd = os.getcwd()
def tearDown(self):
"""Restore original working directory"""
os.chdir(self.original_cwd)
def test_single_threaded_limited(self):
"""Test traditional single-threaded limited mode"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'max_pages': 10,
'workers': 1
}
with tempfile.TemporaryDirectory() as tmpdir:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=True)
self.assertEqual(converter.workers, 1)
self.assertEqual(converter.config.get('max_pages'), 10)
def test_parallel_limited(self):
"""Test parallel scraping with page limit"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'max_pages': 100,
'workers': 4
}
with tempfile.TemporaryDirectory() as tmpdir:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=True)
self.assertEqual(converter.workers, 4)
self.assertEqual(converter.config.get('max_pages'), 100)
self.assertTrue(hasattr(converter, 'lock'))
def test_parallel_unlimited(self):
"""Test parallel scraping with unlimited pages"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'max_pages': None,
'workers': 8
}
with tempfile.TemporaryDirectory() as tmpdir:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=True)
self.assertEqual(converter.workers, 8)
self.assertIsNone(converter.config.get('max_pages'))
self.assertTrue(hasattr(converter, 'lock'))
def test_fast_scraping_mode(self):
"""Test fast scraping with low rate limit and workers"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'rate_limit': 0.1,
'workers': 8,
'max_pages': 1000
}
with tempfile.TemporaryDirectory() as tmpdir:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=True)
self.assertEqual(converter.workers, 8)
self.assertEqual(converter.config.get('rate_limit'), 0.1)
class TestDryRunWithNewFeatures(unittest.TestCase):
"""Test dry-run mode works with new features"""
def setUp(self):
"""Save original working directory"""
self.original_cwd = os.getcwd()
def tearDown(self):
"""Restore original working directory"""
os.chdir(self.original_cwd)
def test_dry_run_with_parallel(self):
"""Test dry-run with parallel workers"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'workers': 4
}
with tempfile.TemporaryDirectory() as tmpdir:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=True)
self.assertTrue(converter.dry_run)
self.assertEqual(converter.workers, 4)
def test_dry_run_with_unlimited(self):
"""Test dry-run with unlimited mode"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'max_pages': None
}
with tempfile.TemporaryDirectory() as tmpdir:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=True)
self.assertTrue(converter.dry_run)
self.assertIsNone(converter.config.get('max_pages'))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,524 @@
#!/usr/bin/env python3
"""
Tests for PDF Advanced Features (Priority 2 & 3)
Tests cover:
- OCR support for scanned PDFs
- Password-protected PDFs
- Table extraction
- Parallel processing
- Caching
"""
import unittest
import sys
import tempfile
import shutil
import io
from pathlib import Path
from unittest.mock import Mock, patch, MagicMock
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent / "cli"))
try:
import fitz # PyMuPDF
PYMUPDF_AVAILABLE = True
except ImportError:
PYMUPDF_AVAILABLE = False
try:
from PIL import Image
import pytesseract
TESSERACT_AVAILABLE = True
except ImportError:
TESSERACT_AVAILABLE = False
class TestOCRSupport(unittest.TestCase):
"""Test OCR support for scanned PDFs (Priority 2)"""
def setUp(self):
if not PYMUPDF_AVAILABLE:
self.skipTest("PyMuPDF not installed")
from pdf_extractor_poc import PDFExtractor
self.PDFExtractor = PDFExtractor
self.temp_dir = tempfile.mkdtemp()
def tearDown(self):
if hasattr(self, 'temp_dir'):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_ocr_initialization(self):
"""Test OCR flag initialization"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
extractor.use_ocr = True
self.assertTrue(extractor.use_ocr)
def test_extract_text_with_ocr_disabled(self):
"""Test that OCR can be disabled"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
extractor.use_ocr = False
extractor.verbose = False
# Create mock page with normal text
mock_page = Mock()
mock_page.get_text.return_value = "This is regular text"
text = extractor.extract_text_with_ocr(mock_page)
self.assertEqual(text, "This is regular text")
mock_page.get_text.assert_called_once_with("text")
def test_extract_text_with_ocr_sufficient_text(self):
"""Test OCR not triggered when sufficient text exists"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
extractor.use_ocr = True
extractor.verbose = False
# Create mock page with enough text
mock_page = Mock()
mock_page.get_text.return_value = "This is a long paragraph with more than 50 characters"
text = extractor.extract_text_with_ocr(mock_page)
self.assertEqual(len(text), 53) # Length after .strip()
# OCR should not be triggered
mock_page.get_pixmap.assert_not_called()
@patch('pdf_extractor_poc.TESSERACT_AVAILABLE', False)
def test_ocr_unavailable_warning(self):
"""Test warning when OCR requested but pytesseract not available"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
extractor.use_ocr = True
extractor.verbose = True
mock_page = Mock()
mock_page.get_text.return_value = "Short" # Less than 50 chars
# Capture output
with patch('sys.stdout', new=io.StringIO()) as fake_out:
text = extractor.extract_text_with_ocr(mock_page)
output = fake_out.getvalue()
self.assertIn("OCR requested but pytesseract not installed", output)
self.assertEqual(text, "Short")
@unittest.skipUnless(TESSERACT_AVAILABLE, "pytesseract not installed")
def test_ocr_extraction_triggered(self):
"""Test OCR extraction when text is minimal"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
extractor.use_ocr = True
extractor.verbose = False
# Create mock page with minimal text
mock_page = Mock()
mock_page.get_text.return_value = "X" # Less than 50 chars
# Mock pixmap and PIL Image
mock_pix = Mock()
mock_pix.width = 100
mock_pix.height = 100
mock_pix.samples = b'\x00' * (100 * 100 * 3)
mock_page.get_pixmap.return_value = mock_pix
with patch('pytesseract.image_to_string', return_value="OCR extracted text here"):
text = extractor.extract_text_with_ocr(mock_page)
# Should use OCR text since it's longer
self.assertEqual(text, "OCR extracted text here")
mock_page.get_pixmap.assert_called_once()
class TestPasswordProtection(unittest.TestCase):
"""Test password-protected PDF support (Priority 2)"""
def setUp(self):
if not PYMUPDF_AVAILABLE:
self.skipTest("PyMuPDF not installed")
from pdf_extractor_poc import PDFExtractor
self.PDFExtractor = PDFExtractor
self.temp_dir = tempfile.mkdtemp()
def tearDown(self):
if hasattr(self, 'temp_dir'):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_password_initialization(self):
"""Test password parameter initialization"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
extractor.password = "test_password"
self.assertEqual(extractor.password, "test_password")
def test_encrypted_pdf_detection(self):
"""Test detection of encrypted PDF"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
extractor.pdf_path = "test.pdf"
extractor.password = "mypassword"
extractor.verbose = False
# Mock encrypted document (use MagicMock for __len__)
mock_doc = MagicMock()
mock_doc.is_encrypted = True
mock_doc.authenticate.return_value = True
mock_doc.metadata = {}
mock_doc.__len__.return_value = 10
with patch('fitz.open', return_value=mock_doc):
# This would be called in extract_all()
doc = fitz.open(extractor.pdf_path)
self.assertTrue(doc.is_encrypted)
result = doc.authenticate(extractor.password)
self.assertTrue(result)
def test_wrong_password_handling(self):
"""Test handling of wrong password"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
extractor.pdf_path = "test.pdf"
extractor.password = "wrong_password"
mock_doc = Mock()
mock_doc.is_encrypted = True
mock_doc.authenticate.return_value = False
with patch('fitz.open', return_value=mock_doc):
doc = fitz.open(extractor.pdf_path)
result = doc.authenticate(extractor.password)
self.assertFalse(result)
def test_missing_password_for_encrypted_pdf(self):
"""Test error when password is missing for encrypted PDF"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
extractor.pdf_path = "test.pdf"
extractor.password = None
mock_doc = Mock()
mock_doc.is_encrypted = True
with patch('fitz.open', return_value=mock_doc):
doc = fitz.open(extractor.pdf_path)
self.assertTrue(doc.is_encrypted)
self.assertIsNone(extractor.password)
class TestTableExtraction(unittest.TestCase):
"""Test table extraction (Priority 2)"""
def setUp(self):
if not PYMUPDF_AVAILABLE:
self.skipTest("PyMuPDF not installed")
from pdf_extractor_poc import PDFExtractor
self.PDFExtractor = PDFExtractor
self.temp_dir = tempfile.mkdtemp()
def tearDown(self):
if hasattr(self, 'temp_dir'):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_table_extraction_initialization(self):
"""Test table extraction flag initialization"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
extractor.extract_tables = True
self.assertTrue(extractor.extract_tables)
def test_table_extraction_disabled(self):
"""Test no tables extracted when disabled"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
extractor.extract_tables = False
extractor.verbose = False
mock_page = Mock()
tables = extractor.extract_tables_from_page(mock_page)
self.assertEqual(tables, [])
# find_tables should not be called
mock_page.find_tables.assert_not_called()
def test_table_extraction_basic(self):
"""Test basic table extraction"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
extractor.extract_tables = True
extractor.verbose = False
# Create mock table
mock_table = Mock()
mock_table.extract.return_value = [
["Header 1", "Header 2", "Header 3"],
["Data 1", "Data 2", "Data 3"]
]
mock_table.bbox = (0, 0, 100, 100)
# Create mock tables result
mock_tables = Mock()
mock_tables.tables = [mock_table]
mock_page = Mock()
mock_page.find_tables.return_value = mock_tables
tables = extractor.extract_tables_from_page(mock_page)
self.assertEqual(len(tables), 1)
self.assertEqual(tables[0]['row_count'], 2)
self.assertEqual(tables[0]['col_count'], 3)
self.assertEqual(tables[0]['table_index'], 0)
def test_multiple_tables_extraction(self):
"""Test extraction of multiple tables from one page"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
extractor.extract_tables = True
extractor.verbose = False
# Create two mock tables
mock_table1 = Mock()
mock_table1.extract.return_value = [["A", "B"], ["1", "2"]]
mock_table1.bbox = (0, 0, 50, 50)
mock_table2 = Mock()
mock_table2.extract.return_value = [["X", "Y", "Z"], ["10", "20", "30"]]
mock_table2.bbox = (0, 60, 50, 110)
mock_tables = Mock()
mock_tables.tables = [mock_table1, mock_table2]
mock_page = Mock()
mock_page.find_tables.return_value = mock_tables
tables = extractor.extract_tables_from_page(mock_page)
self.assertEqual(len(tables), 2)
self.assertEqual(tables[0]['table_index'], 0)
self.assertEqual(tables[1]['table_index'], 1)
def test_table_extraction_error_handling(self):
"""Test error handling during table extraction"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
extractor.extract_tables = True
extractor.verbose = False
mock_page = Mock()
mock_page.find_tables.side_effect = Exception("Table extraction failed")
# Should not raise, should return empty list
tables = extractor.extract_tables_from_page(mock_page)
self.assertEqual(tables, [])
class TestCaching(unittest.TestCase):
"""Test caching of expensive operations (Priority 3)"""
def setUp(self):
if not PYMUPDF_AVAILABLE:
self.skipTest("PyMuPDF not installed")
from pdf_extractor_poc import PDFExtractor
self.PDFExtractor = PDFExtractor
self.temp_dir = tempfile.mkdtemp()
def tearDown(self):
if hasattr(self, 'temp_dir'):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_cache_initialization(self):
"""Test cache is initialized"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
extractor._cache = {}
extractor.use_cache = True
self.assertIsInstance(extractor._cache, dict)
self.assertTrue(extractor.use_cache)
def test_cache_set_and_get(self):
"""Test setting and getting cached values"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
extractor._cache = {}
extractor.use_cache = True
# Set cache
test_data = {"page": 1, "text": "cached content"}
extractor.set_cached("page_1", test_data)
# Get cache
cached = extractor.get_cached("page_1")
self.assertEqual(cached, test_data)
def test_cache_miss(self):
"""Test cache miss returns None"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
extractor._cache = {}
extractor.use_cache = True
cached = extractor.get_cached("nonexistent_key")
self.assertIsNone(cached)
def test_cache_disabled(self):
"""Test caching can be disabled"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
extractor._cache = {}
extractor.use_cache = False
# Try to set cache
extractor.set_cached("page_1", {"data": "test"})
# Cache should be empty
self.assertEqual(len(extractor._cache), 0)
# Try to get cache
cached = extractor.get_cached("page_1")
self.assertIsNone(cached)
def test_cache_overwrite(self):
"""Test cache can be overwritten"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
extractor._cache = {}
extractor.use_cache = True
# Set initial value
extractor.set_cached("page_1", {"version": 1})
# Overwrite
extractor.set_cached("page_1", {"version": 2})
# Get cached value
cached = extractor.get_cached("page_1")
self.assertEqual(cached["version"], 2)
class TestParallelProcessing(unittest.TestCase):
"""Test parallel page processing (Priority 3)"""
def setUp(self):
if not PYMUPDF_AVAILABLE:
self.skipTest("PyMuPDF not installed")
from pdf_extractor_poc import PDFExtractor
self.PDFExtractor = PDFExtractor
self.temp_dir = tempfile.mkdtemp()
def tearDown(self):
if hasattr(self, 'temp_dir'):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_parallel_initialization(self):
"""Test parallel processing flag initialization"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
extractor.parallel = True
extractor.max_workers = 4
self.assertTrue(extractor.parallel)
self.assertEqual(extractor.max_workers, 4)
def test_parallel_disabled_by_default(self):
"""Test parallel processing is disabled by default"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
extractor.parallel = False
self.assertFalse(extractor.parallel)
def test_worker_count_auto_detect(self):
"""Test worker count auto-detection"""
import os
cpu_count = os.cpu_count()
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
extractor.max_workers = cpu_count
self.assertIsNotNone(extractor.max_workers)
self.assertGreater(extractor.max_workers, 0)
def test_custom_worker_count(self):
"""Test custom worker count"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
extractor.max_workers = 8
self.assertEqual(extractor.max_workers, 8)
class TestIntegration(unittest.TestCase):
"""Integration tests for advanced features"""
def setUp(self):
if not PYMUPDF_AVAILABLE:
self.skipTest("PyMuPDF not installed")
from pdf_extractor_poc import PDFExtractor
self.PDFExtractor = PDFExtractor
self.temp_dir = tempfile.mkdtemp()
def tearDown(self):
if hasattr(self, 'temp_dir'):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_full_initialization_with_all_features(self):
"""Test initialization with all advanced features enabled"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
# Set all advanced features
extractor.use_ocr = True
extractor.password = "test_password"
extractor.extract_tables = True
extractor.parallel = True
extractor.max_workers = 4
extractor.use_cache = True
extractor._cache = {}
# Verify all features are set
self.assertTrue(extractor.use_ocr)
self.assertEqual(extractor.password, "test_password")
self.assertTrue(extractor.extract_tables)
self.assertTrue(extractor.parallel)
self.assertEqual(extractor.max_workers, 4)
self.assertTrue(extractor.use_cache)
def test_feature_combinations(self):
"""Test various feature combinations"""
combinations = [
{"use_ocr": True, "extract_tables": True},
{"password": "test", "parallel": True},
{"use_cache": True, "extract_tables": True, "parallel": True},
{"use_ocr": True, "password": "test", "extract_tables": True, "parallel": True}
]
for combo in combinations:
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
for key, value in combo.items():
setattr(extractor, key, value)
# Verify all attributes are set correctly
for key, value in combo.items():
self.assertEqual(getattr(extractor, key), value)
def test_page_data_includes_tables(self):
"""Test that page data includes table count"""
# This tests that the page_data structure includes tables
expected_keys = [
'page_number', 'text', 'markdown', 'headings',
'code_samples', 'images_count', 'extracted_images',
'tables', 'char_count', 'code_blocks_count', 'tables_count'
]
# Just verify the structure is correct
# Actual extraction is tested in other test classes
page_data = {
'page_number': 1,
'text': 'test',
'markdown': 'test',
'headings': [],
'code_samples': [],
'images_count': 0,
'extracted_images': [],
'tables': [],
'char_count': 4,
'code_blocks_count': 0,
'tables_count': 0
}
for key in expected_keys:
self.assertIn(key, page_data)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,404 @@
#!/usr/bin/env python3
"""
Tests for PDF Extractor (cli/pdf_extractor_poc.py)
Tests cover:
- Language detection with confidence scoring
- Code block detection (font, indent, pattern)
- Syntax validation
- Quality scoring
- Chapter detection
- Page chunking
- Code block merging
"""
import unittest
import sys
from pathlib import Path
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent / "cli"))
try:
import fitz # PyMuPDF
PYMUPDF_AVAILABLE = True
except ImportError:
PYMUPDF_AVAILABLE = False
class TestLanguageDetection(unittest.TestCase):
"""Test language detection with confidence scoring"""
def setUp(self):
if not PYMUPDF_AVAILABLE:
self.skipTest("PyMuPDF not installed")
from pdf_extractor_poc import PDFExtractor
self.PDFExtractor = PDFExtractor
def test_detect_python_with_confidence(self):
"""Test Python detection returns language and confidence"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
code = "def hello():\n print('world')\n return True"
language, confidence = extractor.detect_language_from_code(code)
self.assertEqual(language, "python")
self.assertGreater(confidence, 0.4) # Should have reasonable confidence
self.assertLessEqual(confidence, 1.0)
def test_detect_javascript_with_confidence(self):
"""Test JavaScript detection"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
code = "const handleClick = () => {\n console.log('clicked');\n};"
language, confidence = extractor.detect_language_from_code(code)
self.assertEqual(language, "javascript")
self.assertGreater(confidence, 0.5)
def test_detect_cpp_with_confidence(self):
"""Test C++ detection"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
code = "#include <iostream>\nint main() {\n std::cout << \"Hello\";\n}"
language, confidence = extractor.detect_language_from_code(code)
self.assertEqual(language, "cpp")
self.assertGreater(confidence, 0.5)
def test_detect_unknown_low_confidence(self):
"""Test unknown language returns low confidence"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
code = "this is not code at all just plain text"
language, confidence = extractor.detect_language_from_code(code)
self.assertEqual(language, "unknown")
self.assertLess(confidence, 0.3) # Should be low confidence
def test_confidence_range(self):
"""Test confidence is always between 0 and 1"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
test_codes = [
"def foo(): pass",
"const x = 10;",
"#include <stdio.h>",
"random text here",
""
]
for code in test_codes:
_, confidence = extractor.detect_language_from_code(code)
self.assertGreaterEqual(confidence, 0.0)
self.assertLessEqual(confidence, 1.0)
class TestSyntaxValidation(unittest.TestCase):
"""Test syntax validation for different languages"""
def setUp(self):
if not PYMUPDF_AVAILABLE:
self.skipTest("PyMuPDF not installed")
from pdf_extractor_poc import PDFExtractor
self.PDFExtractor = PDFExtractor
def test_validate_python_valid(self):
"""Test valid Python syntax"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
code = "def hello():\n print('world')\n return True"
is_valid, issues = extractor.validate_code_syntax(code, "python")
self.assertTrue(is_valid)
self.assertEqual(len(issues), 0)
def test_validate_python_invalid_indentation(self):
"""Test invalid Python indentation"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
code = "def hello():\n print('world')\n\tprint('mixed')" # Mixed tabs and spaces
is_valid, issues = extractor.validate_code_syntax(code, "python")
self.assertFalse(is_valid)
self.assertGreater(len(issues), 0)
def test_validate_python_unbalanced_brackets(self):
"""Test unbalanced brackets"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
code = "x = [[[1, 2, 3" # Severely unbalanced brackets
is_valid, issues = extractor.validate_code_syntax(code, "python")
self.assertFalse(is_valid)
self.assertGreater(len(issues), 0)
def test_validate_javascript_valid(self):
"""Test valid JavaScript syntax"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
code = "const x = () => { return 42; };"
is_valid, issues = extractor.validate_code_syntax(code, "javascript")
self.assertTrue(is_valid)
self.assertEqual(len(issues), 0)
def test_validate_natural_language_fails(self):
"""Test natural language fails validation"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
code = "This is just a regular sentence with the and for and with and that and have and from words."
is_valid, issues = extractor.validate_code_syntax(code, "python")
self.assertFalse(is_valid)
self.assertIn('May be natural language', ' '.join(issues))
class TestQualityScoring(unittest.TestCase):
"""Test code quality scoring (0-10 scale)"""
def setUp(self):
if not PYMUPDF_AVAILABLE:
self.skipTest("PyMuPDF not installed")
from pdf_extractor_poc import PDFExtractor
self.PDFExtractor = PDFExtractor
def test_quality_score_range(self):
"""Test quality score is between 0 and 10"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
code = "def hello():\n print('world')"
quality = extractor.score_code_quality(code, "python", 0.8)
self.assertGreaterEqual(quality, 0.0)
self.assertLessEqual(quality, 10.0)
def test_high_quality_code(self):
"""Test high-quality code gets good score"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
code = """def calculate_sum(numbers):
'''Calculate sum of numbers'''
total = 0
for num in numbers:
total += num
return total"""
quality = extractor.score_code_quality(code, "python", 0.9)
self.assertGreater(quality, 6.0) # Should be good quality
def test_low_quality_code(self):
"""Test low-quality code gets low score"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
code = "x" # Too short, no structure
quality = extractor.score_code_quality(code, "unknown", 0.1)
self.assertLess(quality, 6.0) # Should be low quality
def test_quality_factors(self):
"""Test that quality considers multiple factors"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
# Good: proper structure, indentation, confidence
good_code = "def foo():\n return bar()"
good_quality = extractor.score_code_quality(good_code, "python", 0.9)
# Bad: no structure, low confidence
bad_code = "some text"
bad_quality = extractor.score_code_quality(bad_code, "unknown", 0.1)
self.assertGreater(good_quality, bad_quality)
class TestChapterDetection(unittest.TestCase):
"""Test chapter/section detection"""
def setUp(self):
if not PYMUPDF_AVAILABLE:
self.skipTest("PyMuPDF not installed")
from pdf_extractor_poc import PDFExtractor
self.PDFExtractor = PDFExtractor
def test_detect_chapter_with_number(self):
"""Test chapter detection with number"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
page_data = {
'text': 'Chapter 1: Introduction to Python\nThis is the first chapter.',
'headings': []
}
is_chapter, title = extractor.detect_chapter_start(page_data)
self.assertTrue(is_chapter)
self.assertIsNotNone(title)
def test_detect_chapter_uppercase(self):
"""Test chapter detection with uppercase"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
page_data = {
'text': 'Chapter 1\nThis is the introduction', # Pattern requires Chapter + digit
'headings': []
}
is_chapter, title = extractor.detect_chapter_start(page_data)
self.assertTrue(is_chapter)
def test_detect_section_heading(self):
"""Test section heading detection"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
page_data = {
'text': '2. Getting Started\nThis is a section.',
'headings': []
}
is_chapter, title = extractor.detect_chapter_start(page_data)
self.assertTrue(is_chapter)
def test_not_chapter(self):
"""Test normal text is not detected as chapter"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
page_data = {
'text': 'This is just normal paragraph text without any chapter markers.',
'headings': []
}
is_chapter, title = extractor.detect_chapter_start(page_data)
self.assertFalse(is_chapter)
class TestCodeBlockMerging(unittest.TestCase):
"""Test code block merging across pages"""
def setUp(self):
if not PYMUPDF_AVAILABLE:
self.skipTest("PyMuPDF not installed")
from pdf_extractor_poc import PDFExtractor
self.PDFExtractor = PDFExtractor
def test_merge_continued_blocks(self):
"""Test merging code blocks split across pages"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
extractor.verbose = False # Initialize verbose attribute
pages = [
{
'page_number': 1,
'code_samples': [
{'code': 'def hello():', 'language': 'python', 'detection_method': 'pattern'}
],
'code_blocks_count': 1
},
{
'page_number': 2,
'code_samples': [
{'code': ' print("world")', 'language': 'python', 'detection_method': 'pattern'}
],
'code_blocks_count': 1
}
]
merged = extractor.merge_continued_code_blocks(pages)
# Should have merged the two blocks
self.assertIn('def hello():', merged[0]['code_samples'][0]['code'])
self.assertIn('print("world")', merged[0]['code_samples'][0]['code'])
def test_no_merge_different_languages(self):
"""Test blocks with different languages are not merged"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
pages = [
{
'page_number': 1,
'code_samples': [
{'code': 'def foo():', 'language': 'python', 'detection_method': 'pattern'}
],
'code_blocks_count': 1
},
{
'page_number': 2,
'code_samples': [
{'code': 'const x = 10;', 'language': 'javascript', 'detection_method': 'pattern'}
],
'code_blocks_count': 1
}
]
merged = extractor.merge_continued_code_blocks(pages)
# Should NOT merge different languages
self.assertEqual(len(merged[0]['code_samples']), 1)
self.assertEqual(len(merged[1]['code_samples']), 1)
class TestCodeDetectionMethods(unittest.TestCase):
"""Test different code detection methods"""
def setUp(self):
if not PYMUPDF_AVAILABLE:
self.skipTest("PyMuPDF not installed")
from pdf_extractor_poc import PDFExtractor
self.PDFExtractor = PDFExtractor
def test_pattern_based_detection(self):
"""Test pattern-based code detection"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
# Should detect function definitions
text = "Here is an example:\ndef calculate(x, y):\n return x + y"
# Pattern-based detection should find this
# (implementation details depend on pdf_extractor_poc.py)
self.assertIn("def ", text)
self.assertIn("return", text)
def test_indent_based_detection(self):
"""Test indent-based code detection"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
# Code with consistent indentation
indented_text = """ def foo():
return bar()"""
# Should detect as code due to indentation
self.assertTrue(indented_text.startswith(" " * 4))
class TestQualityFiltering(unittest.TestCase):
"""Test quality-based filtering"""
def setUp(self):
if not PYMUPDF_AVAILABLE:
self.skipTest("PyMuPDF not installed")
from pdf_extractor_poc import PDFExtractor
self.PDFExtractor = PDFExtractor
def test_filter_by_min_quality(self):
"""Test filtering code blocks by minimum quality"""
extractor = self.PDFExtractor.__new__(self.PDFExtractor)
extractor.min_quality = 5.0
# High quality block
high_quality = {
'code': 'def calculate():\n return 42',
'language': 'python',
'quality': 8.0
}
# Low quality block
low_quality = {
'code': 'x',
'language': 'unknown',
'quality': 2.0
}
# Only high quality should pass
self.assertGreaterEqual(high_quality['quality'], extractor.min_quality)
self.assertLess(low_quality['quality'], extractor.min_quality)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,602 @@
#!/usr/bin/env python3
"""
Tests for PDF Scraper (cli/pdf_scraper.py)
Tests cover:
- Config-based PDF extraction
- Direct PDF path conversion
- JSON-based workflow
- Skill structure generation
- Categorization
- Error handling
"""
import unittest
import sys
import json
import tempfile
import shutil
from pathlib import Path
from unittest.mock import Mock, patch, MagicMock
try:
import fitz # PyMuPDF
PYMUPDF_AVAILABLE = True
except ImportError:
PYMUPDF_AVAILABLE = False
class TestPDFToSkillConverter(unittest.TestCase):
"""Test PDFToSkillConverter initialization and basic functionality"""
def setUp(self):
if not PYMUPDF_AVAILABLE:
self.skipTest("PyMuPDF not installed")
from skill_seekers.cli.pdf_scraper import PDFToSkillConverter
self.PDFToSkillConverter = PDFToSkillConverter
# Create temporary directory for test output
self.temp_dir = tempfile.mkdtemp()
self.output_dir = Path(self.temp_dir)
def tearDown(self):
# Clean up temporary directory
if hasattr(self, 'temp_dir'):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_init_with_name_and_pdf_path(self):
"""Test initialization with name and PDF path"""
config = {
"name": "test_skill",
"pdf_path": "test.pdf"
}
converter = self.PDFToSkillConverter(config)
self.assertEqual(converter.name, "test_skill")
self.assertEqual(converter.pdf_path, "test.pdf")
def test_init_with_config(self):
"""Test initialization with config file"""
# Create test config
config = {
"name": "config_skill",
"description": "Test skill",
"pdf_path": "docs/test.pdf",
"extract_options": {
"chunk_size": 10,
"min_quality": 5.0
}
}
converter = self.PDFToSkillConverter(config)
self.assertEqual(converter.name, "config_skill")
self.assertEqual(converter.config.get("description"), "Test skill")
def test_init_requires_name_or_config(self):
"""Test that initialization requires config dict with 'name' field"""
with self.assertRaises((ValueError, TypeError, KeyError)):
self.PDFToSkillConverter({})
class TestCategorization(unittest.TestCase):
"""Test content categorization functionality"""
def setUp(self):
if not PYMUPDF_AVAILABLE:
self.skipTest("PyMuPDF not installed")
from skill_seekers.cli.pdf_scraper import PDFToSkillConverter
self.PDFToSkillConverter = PDFToSkillConverter
self.temp_dir = tempfile.mkdtemp()
def tearDown(self):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_categorize_by_keywords(self):
"""Test categorization using keyword matching"""
config = {
"name": "test",
"pdf_path": "test.pdf",
"categories": {
"getting_started": ["introduction", "getting started"],
"api": ["api", "reference", "function"]
}
}
converter = self.PDFToSkillConverter(config)
# Mock extracted data with different content
converter.extracted_data = {
"pages": [
{
"page_number": 1,
"text": "Introduction to the API",
"chapter": "Chapter 1: Getting Started"
},
{
"page_number": 2,
"text": "API reference for functions",
"chapter": None
}
]
}
categories = converter.categorize_content()
# Should have both categories
self.assertIn("getting_started", categories)
self.assertIn("api", categories)
def test_categorize_by_chapters(self):
"""Test categorization using chapter information"""
config = {
"name": "test",
"pdf_path": "test.pdf"
}
converter = self.PDFToSkillConverter(config)
# Mock data with chapters
converter.extracted_data = {
"pages": [
{
"page_number": 1,
"text": "Content here",
"chapter": "Chapter 1: Introduction"
},
{
"page_number": 2,
"text": "More content",
"chapter": "Chapter 1: Introduction"
},
{
"page_number": 3,
"text": "New chapter",
"chapter": "Chapter 2: Advanced Topics"
}
]
}
categories = converter.categorize_content()
# Should create categories based on chapters
self.assertIsInstance(categories, dict)
self.assertGreater(len(categories), 0)
def test_categorize_handles_no_chapters(self):
"""Test categorization when no chapters are detected"""
config = {
"name": "test",
"pdf_path": "test.pdf"
}
converter = self.PDFToSkillConverter(config)
# Mock data without chapters
converter.extracted_data = {
"pages": [
{
"page_number": 1,
"text": "Some content",
"chapter": None
}
]
}
categories = converter.categorize_content()
# Should still create categories (fallback to "other")
self.assertIsInstance(categories, dict)
class TestSkillBuilding(unittest.TestCase):
"""Test skill structure generation"""
def setUp(self):
if not PYMUPDF_AVAILABLE:
self.skipTest("PyMuPDF not installed")
from skill_seekers.cli.pdf_scraper import PDFToSkillConverter
self.PDFToSkillConverter = PDFToSkillConverter
self.temp_dir = tempfile.mkdtemp()
def tearDown(self):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_build_skill_creates_structure(self):
"""Test that build_skill creates required directory structure"""
config = {
"name": "test_skill",
"pdf_path": "test.pdf"
}
converter = self.PDFToSkillConverter(config)
# Override skill_dir to use temp directory
converter.skill_dir = str(Path(self.temp_dir) / "test_skill")
# Mock extracted data
converter.extracted_data = {
"pages": [
{
"page_number": 1,
"text": "Test content",
"code_blocks": [],
"images": []
}
],
"total_pages": 1
}
# Mock categorization
converter.categories = {
"getting_started": [converter.extracted_data["pages"][0]]
}
converter.build_skill()
# Check directory structure
skill_dir = Path(self.temp_dir) / "test_skill"
self.assertTrue(skill_dir.exists())
self.assertTrue((skill_dir / "references").exists())
self.assertTrue((skill_dir / "scripts").exists())
self.assertTrue((skill_dir / "assets").exists())
def test_build_skill_creates_skill_md(self):
"""Test that SKILL.md is created"""
config = {
"name": "test_skill",
"pdf_path": "test.pdf",
"description": "Test description"
}
converter = self.PDFToSkillConverter(config)
# Override skill_dir to use temp directory
converter.skill_dir = str(Path(self.temp_dir) / "test_skill")
converter.extracted_data = {
"pages": [{"page_number": 1, "text": "Test", "code_blocks": [], "images": []}],
"total_pages": 1
}
converter.categories = {"test": [converter.extracted_data["pages"][0]]}
converter.build_skill()
skill_md = Path(self.temp_dir) / "test_skill" / "SKILL.md"
self.assertTrue(skill_md.exists())
# Check content
content = skill_md.read_text()
self.assertIn("test_skill", content)
self.assertIn("Test description", content)
def test_build_skill_creates_reference_files(self):
"""Test that reference files are created for categories"""
config = {
"name": "test_skill",
"pdf_path": "test.pdf"
}
converter = self.PDFToSkillConverter(config)
# Override skill_dir to use temp directory
converter.skill_dir = str(Path(self.temp_dir) / "test_skill")
converter.extracted_data = {
"pages": [
{"page_number": 1, "text": "Getting started", "code_blocks": [], "images": []},
{"page_number": 2, "text": "API reference", "code_blocks": [], "images": []}
],
"total_pages": 2
}
converter.categories = {
"getting_started": [converter.extracted_data["pages"][0]],
"api": [converter.extracted_data["pages"][1]]
}
converter.build_skill()
# Check reference files exist
refs_dir = Path(self.temp_dir) / "test_skill" / "references"
self.assertTrue((refs_dir / "getting_started.md").exists())
self.assertTrue((refs_dir / "api.md").exists())
self.assertTrue((refs_dir / "index.md").exists())
class TestCodeBlockHandling(unittest.TestCase):
"""Test code block extraction and inclusion in references"""
def setUp(self):
if not PYMUPDF_AVAILABLE:
self.skipTest("PyMuPDF not installed")
from skill_seekers.cli.pdf_scraper import PDFToSkillConverter
self.PDFToSkillConverter = PDFToSkillConverter
self.temp_dir = tempfile.mkdtemp()
def tearDown(self):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_code_blocks_included_in_references(self):
"""Test that code blocks are included in reference files"""
config = {
"name": "test_skill",
"pdf_path": "test.pdf"
}
converter = self.PDFToSkillConverter(config)
# Override skill_dir to use temp directory
converter.skill_dir = str(Path(self.temp_dir) / "test_skill")
# Mock data with code blocks
converter.extracted_data = {
"pages": [
{
"page_number": 1,
"text": "Example code",
"code_blocks": [
{
"code": "def hello():\n print('world')",
"language": "python",
"quality": 8.0
}
],
"images": []
}
],
"total_pages": 1
}
converter.categories = {
"examples": [converter.extracted_data["pages"][0]]
}
converter.build_skill()
# Check code block in reference file
ref_file = Path(self.temp_dir) / "test_skill" / "references" / "examples.md"
content = ref_file.read_text()
self.assertIn("```python", content)
self.assertIn("def hello()", content)
self.assertIn("print('world')", content)
def test_high_quality_code_preferred(self):
"""Test that high-quality code blocks are prioritized"""
config = {
"name": "test_skill",
"pdf_path": "test.pdf"
}
converter = self.PDFToSkillConverter(config)
# Override skill_dir to use temp directory
converter.skill_dir = str(Path(self.temp_dir) / "test_skill")
# Mock data with varying quality
converter.extracted_data = {
"pages": [
{
"page_number": 1,
"text": "Code examples",
"code_blocks": [
{"code": "x = 1", "language": "python", "quality": 2.0},
{"code": "def process():\n return result", "language": "python", "quality": 9.0}
],
"images": []
}
],
"total_pages": 1
}
converter.categories = {"examples": [converter.extracted_data["pages"][0]]}
converter.build_skill()
ref_file = Path(self.temp_dir) / "test_skill" / "references" / "examples.md"
content = ref_file.read_text()
# High quality code should be included
self.assertIn("def process()", content)
class TestImageHandling(unittest.TestCase):
"""Test image extraction and handling"""
def setUp(self):
if not PYMUPDF_AVAILABLE:
self.skipTest("PyMuPDF not installed")
from skill_seekers.cli.pdf_scraper import PDFToSkillConverter
self.PDFToSkillConverter = PDFToSkillConverter
self.temp_dir = tempfile.mkdtemp()
def tearDown(self):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_images_saved_to_assets(self):
"""Test that images are saved to assets directory"""
config = {
"name": "test_skill",
"pdf_path": "test.pdf"
}
converter = self.PDFToSkillConverter(config)
# Override skill_dir to use temp directory
converter.skill_dir = str(Path(self.temp_dir) / "test_skill")
# Mock image data (1x1 white PNG)
mock_image_bytes = b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82'
converter.extracted_data = {
"pages": [
{
"page_number": 1,
"text": "See diagram",
"code_blocks": [],
"images": [
{
"page": 1,
"index": 0,
"width": 100,
"height": 100,
"data": mock_image_bytes
}
]
}
],
"total_pages": 1
}
converter.categories = {"diagrams": [converter.extracted_data["pages"][0]]}
converter.build_skill()
# Check assets directory has image
assets_dir = Path(self.temp_dir) / "test_skill" / "assets"
image_files = list(assets_dir.glob("*.png"))
self.assertGreater(len(image_files), 0)
def test_image_references_in_markdown(self):
"""Test that images are referenced in markdown files"""
config = {
"name": "test_skill",
"pdf_path": "test.pdf"
}
converter = self.PDFToSkillConverter(config)
# Override skill_dir to use temp directory
converter.skill_dir = str(Path(self.temp_dir) / "test_skill")
mock_image_bytes = b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82'
converter.extracted_data = {
"pages": [
{
"page_number": 1,
"text": "Architecture diagram",
"code_blocks": [],
"images": [
{
"page": 1,
"index": 0,
"width": 200,
"height": 150,
"data": mock_image_bytes
}
]
}
],
"total_pages": 1
}
converter.categories = {"architecture": [converter.extracted_data["pages"][0]]}
converter.build_skill()
# Check markdown has image reference
ref_file = Path(self.temp_dir) / "test_skill" / "references" / "architecture.md"
content = ref_file.read_text()
self.assertIn("![", content) # Markdown image syntax
self.assertIn("../assets/", content) # Relative path to assets
class TestErrorHandling(unittest.TestCase):
"""Test error handling for invalid inputs"""
def setUp(self):
if not PYMUPDF_AVAILABLE:
self.skipTest("PyMuPDF not installed")
from skill_seekers.cli.pdf_scraper import PDFToSkillConverter
self.PDFToSkillConverter = PDFToSkillConverter
self.temp_dir = tempfile.mkdtemp()
def tearDown(self):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_missing_pdf_file(self):
"""Test error when PDF file doesn't exist"""
config = {
"name": "test",
"pdf_path": "nonexistent.pdf"
}
converter = self.PDFToSkillConverter(config)
with self.assertRaises((FileNotFoundError, RuntimeError)):
converter.extract_pdf()
def test_invalid_config_file(self):
"""Test error when config dict is invalid"""
invalid_config = "invalid string not a dict"
with self.assertRaises((ValueError, TypeError, AttributeError)):
self.PDFToSkillConverter(invalid_config)
def test_missing_required_config_fields(self):
"""Test error when config is missing required fields"""
config = {"description": "Missing name and pdf_path"}
with self.assertRaises((ValueError, KeyError)):
converter = self.PDFToSkillConverter(config)
converter.extract_pdf()
class TestJSONWorkflow(unittest.TestCase):
"""Test building skills from extracted JSON"""
def setUp(self):
if not PYMUPDF_AVAILABLE:
self.skipTest("PyMuPDF not installed")
from skill_seekers.cli.pdf_scraper import PDFToSkillConverter
self.PDFToSkillConverter = PDFToSkillConverter
self.temp_dir = tempfile.mkdtemp()
def tearDown(self):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_load_from_json(self):
"""Test loading extracted data from JSON file"""
# Create mock extracted JSON
extracted_data = {
"pages": [
{
"page_number": 1,
"text": "Test content",
"code_blocks": [],
"images": []
}
],
"total_pages": 1,
"metadata": {
"title": "Test PDF"
}
}
json_path = Path(self.temp_dir) / "extracted.json"
json_path.write_text(json.dumps(extracted_data, indent=2))
config = {
"name": "test_skill",
"pdf_path": "test.pdf"
}
converter = self.PDFToSkillConverter(config)
converter.load_extracted_data(str(json_path))
self.assertEqual(converter.extracted_data["total_pages"], 1)
self.assertEqual(len(converter.extracted_data["pages"]), 1)
def test_build_from_json_without_extraction(self):
"""Test that from_json workflow skips PDF extraction"""
extracted_data = {
"pages": [{"page_number": 1, "text": "Content", "code_blocks": [], "images": []}],
"total_pages": 1
}
json_path = Path(self.temp_dir) / "extracted.json"
json_path.write_text(json.dumps(extracted_data))
config = {
"name": "test_skill",
"pdf_path": "test.pdf"
}
converter = self.PDFToSkillConverter(config)
converter.load_extracted_data(str(json_path))
# Should have data loaded without calling extract_pdf()
self.assertIsNotNone(converter.extracted_data)
self.assertEqual(converter.extracted_data["total_pages"], 1)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""
Test script to investigate PR #144 concerns
"""
import sys
import json
import tempfile
from pathlib import Path
from collections import deque
# Add cli to path
sys.path.insert(0, str(Path(__file__).parent / 'cli'))
print("="*60)
print("PR #144 CONCERN INVESTIGATION")
print("="*60)
## CONCERN 1: Thread Safety
print("\n1. THREAD SAFETY ANALYSIS")
print("-" * 40)
print("✓ Lock created when workers > 1:")
print(" - Line 54-56: Creates self.lock with threading.Lock()")
print(" - Only created when self.workers > 1")
print("\n✓ Protected operations in scrape_page():")
print(" - print() - Line 295 (with lock)")
print(" - save_page() - Line 296 (with lock)")
print(" - pages.append() - Line 297 (with lock)")
print(" - visited_urls check - Line 301 (with lock)")
print(" - pending_urls.append() - Line 302 (with lock)")
print("\n✓ Protected operations in scrape_all():")
print(" - visited_urls.add() - Line 414 (BEFORE lock!)")
print(" - save_checkpoint() - Line 431 (with lock)")
print(" - print() - Line 435 (with lock)")
print("\n❌ RACE CONDITION FOUND:")
print(" - Line 414: visited_urls.add(url) is OUTSIDE lock")
print(" - Line 301: Link check 'if link not in visited_urls' is INSIDE lock")
print(" - Two threads could add same URL to visited_urls simultaneously")
print(" - Result: Same URL could be scraped twice")
## CONCERN 2: Checkpoint Behavior
print("\n2. CHECKPOINT WITH WORKERS")
print("-" * 40)
print("✓ Checkpoint save is protected:")
print(" - Line 430-431: Uses lock before save_checkpoint()")
print(" - save_checkpoint() itself does file I/O (line 103-104)")
print("\n⚠️ POTENTIAL ISSUE:")
print(" - pages_scraped counter incremented WITHOUT lock (line 427, 442)")
print(" - Could miss checkpoints or checkpoint at wrong interval")
print(" - Multiple threads incrementing same counter = race condition")
## CONCERN 3: Error Handling
print("\n3. ERROR HANDLING IN PARALLEL MODE")
print("-" * 40)
print("✓ Exceptions are caught in scrape_page():")
print(" - Line 319-324: try/except wraps entire method")
print(" - Errors are printed (with lock if workers > 1)")
print("\n✓ ThreadPoolExecutor exception handling:")
print(" - Exceptions stored in Future objects")
print(" - as_completed() will raise exception when accessed")
print("\n❌ SILENT FAILURE POSSIBLE:")
print(" - Line 425-442: Futures are iterated but exceptions not checked")
print(" - future.result() is never called - exceptions never raised")
print(" - Failed pages silently disappear")
## CONCERN 4: Rate Limiting Semantics
print("\n4. RATE LIMITING WITH WORKERS")
print("-" * 40)
print("✓ Rate limit applied per-worker:")
print(" - Line 315-317: time.sleep() after each scrape_page()")
print(" - Each worker sleeps independently")
print("\n✓ Semantics:")
print(" - 4 workers, 0.5s rate limit = 8 requests/second total")
print(" - 1 worker, 0.5s rate limit = 2 requests/second total")
print(" - This is per-worker, not global rate limiting")
print("\n⚠️ CONSIDERATION:")
print(" - Documentation should clarify this is per-worker")
print(" - Users might expect global rate limit")
print(" - 10 workers with 0.1s = 100 req/s (very aggressive)")
## CONCERN 5: Resource Limits
print("\n5. RESOURCE LIMITS")
print("-" * 40)
print("✓ Worker limit enforced:")
print(" - Capped at 10 workers (mentioned in PR)")
print(" - ThreadPoolExecutor bounds threads")
print("\n❌ NO MEMORY LIMITS:")
print(" - self.pages list grows unbounded")
print(" - visited_urls set grows unbounded")
print(" - 10,000 pages * avg 50KB each = 500MB minimum")
print(" - Unlimited mode could cause OOM")
print("\n❌ NO PENDING URL LIMIT:")
print(" - pending_urls deque grows unbounded")
print(" - Could have thousands of URLs queued")
## CONCERN 6: Streaming Subprocess
print("\n6. STREAMING SUBPROCESS")
print("-" * 40)
print("✓ Good implementation:")
print(" - Uses select() for non-blocking I/O")
print(" - Timeout mechanism works (line 60-63)")
print(" - Kills process on timeout")
print("\n⚠️ Windows fallback:")
print(" - Line 83-85: Falls back to sleep() on Windows")
print(" - Won't stream output on Windows (will appear frozen)")
print(" - But will still work, just poor UX")
print("\n✓ Process cleanup:")
print(" - Line 88: communicate() gets remaining output")
print(" - process.returncode properly captured")
print("\n" + "="*60)
print("SUMMARY OF FINDINGS")
print("="*60)
print("\n🚨 CRITICAL ISSUES FOUND:")
print("1. Race condition on visited_urls.add() (line 414)")
print("2. pages_scraped counter not thread-safe")
print("3. Silent exception swallowing in parallel mode")
print("\n⚠️ MODERATE CONCERNS:")
print("4. No memory limits for unlimited mode")
print("5. Per-worker rate limiting may confuse users")
print("6. Windows streaming falls back to polling")
print("\n✅ WORKS CORRECTLY:")
print("7. Lock protects most shared state")
print("8. Checkpoint saves are protected")
print("9. save_page() file I/O protected")
print("10. Timeout mechanism solid")
print("\n" + "="*60)
@@ -0,0 +1,297 @@
#!/usr/bin/env python3
"""
Tests for cli/quality_checker.py functionality
"""
import unittest
import tempfile
from pathlib import Path
import os
from skill_seekers.cli.quality_checker import SkillQualityChecker, QualityReport
class TestQualityChecker(unittest.TestCase):
"""Test quality checker functionality"""
def create_test_skill(self, tmpdir, skill_md_content, create_references=True):
"""Helper to create a test skill directory"""
skill_dir = Path(tmpdir) / "test-skill"
skill_dir.mkdir()
# Create SKILL.md
skill_md = skill_dir / "SKILL.md"
skill_md.write_text(skill_md_content, encoding='utf-8')
# Create references directory
if create_references:
refs_dir = skill_dir / "references"
refs_dir.mkdir()
(refs_dir / "index.md").write_text("# Index\n\nTest reference.", encoding='utf-8')
(refs_dir / "getting_started.md").write_text("# Getting Started\n\nHow to start.", encoding='utf-8')
return skill_dir
def test_checker_detects_missing_skill_md(self):
"""Test that checker detects missing SKILL.md"""
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / "test-skill"
skill_dir.mkdir()
checker = SkillQualityChecker(skill_dir)
report = checker.check_all()
# Should have error about missing SKILL.md
self.assertTrue(report.has_errors)
self.assertTrue(any('SKILL.md' in issue.message for issue in report.errors))
def test_checker_detects_missing_references(self):
"""Test that checker warns about missing references"""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = """---
name: test
---
# Test Skill
This is a test.
"""
skill_dir = self.create_test_skill(tmpdir, skill_md, create_references=False)
checker = SkillQualityChecker(skill_dir)
report = checker.check_all()
# Should have warning about missing references
self.assertTrue(report.has_warnings)
self.assertTrue(any('references' in issue.message.lower() for issue in report.warnings))
def test_checker_detects_invalid_frontmatter(self):
"""Test that checker detects invalid YAML frontmatter"""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = """# Test Skill
No frontmatter here!
"""
skill_dir = self.create_test_skill(tmpdir, skill_md)
checker = SkillQualityChecker(skill_dir)
report = checker.check_all()
# Should have error about missing frontmatter
self.assertTrue(report.has_errors)
self.assertTrue(any('frontmatter' in issue.message.lower() for issue in report.errors))
def test_checker_detects_missing_name_field(self):
"""Test that checker detects missing name field in frontmatter"""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = """---
description: test
---
# Test Skill
"""
skill_dir = self.create_test_skill(tmpdir, skill_md)
checker = SkillQualityChecker(skill_dir)
report = checker.check_all()
# Should have error about missing name field
self.assertTrue(report.has_errors)
self.assertTrue(any('name' in issue.message.lower() for issue in report.errors))
def test_checker_detects_code_without_language(self):
"""Test that checker warns about code blocks without language tags"""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = """---
name: test
---
# Test Skill
Here's some code:
```
print("hello")
```
"""
skill_dir = self.create_test_skill(tmpdir, skill_md)
checker = SkillQualityChecker(skill_dir)
report = checker.check_all()
# Should have warning about code without language
self.assertTrue(report.has_warnings)
self.assertTrue(any('language' in issue.message.lower() for issue in report.warnings))
def test_checker_approves_good_skill(self):
"""Test that checker gives high score to well-formed skill"""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = """---
name: test
description: A test skill
---
# Test Skill
## When to Use This Skill
Use this when you need to test.
## Quick Reference
Here are some examples:
```python
def hello():
print("hello")
```
```javascript
console.log("hello");
```
## Example: Basic Usage
This shows how to use it.
## Reference Files
See the references directory for more:
- [Getting Started](references/getting_started.md)
- [Index](references/index.md)
"""
skill_dir = self.create_test_skill(tmpdir, skill_md)
checker = SkillQualityChecker(skill_dir)
report = checker.check_all()
# Should have no errors
self.assertFalse(report.has_errors)
# Quality score should be high
self.assertGreaterEqual(report.quality_score, 80.0)
def test_checker_detects_broken_links(self):
"""Test that checker detects broken internal links"""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = """---
name: test
---
# Test Skill
See [this file](nonexistent.md) for more info.
"""
skill_dir = self.create_test_skill(tmpdir, skill_md)
checker = SkillQualityChecker(skill_dir)
report = checker.check_all()
# Should have warning about broken link
self.assertTrue(report.has_warnings)
self.assertTrue(any('broken link' in issue.message.lower() for issue in report.warnings))
def test_quality_score_calculation(self):
"""Test that quality score is calculated correctly"""
with tempfile.TemporaryDirectory() as tmpdir:
report = QualityReport("test", Path(tmpdir))
# Perfect score to start
self.assertEqual(report.quality_score, 100.0)
# Add an error (should deduct 15 points)
report.add_error('test', 'Test error')
self.assertEqual(report.quality_score, 85.0)
# Add a warning (should deduct 5 points)
report.add_warning('test', 'Test warning')
self.assertEqual(report.quality_score, 80.0)
# Add more errors
report.add_error('test', 'Another error')
report.add_error('test', 'Yet another error')
self.assertEqual(report.quality_score, 50.0)
def test_quality_grade_calculation(self):
"""Test that quality grades are assigned correctly"""
with tempfile.TemporaryDirectory() as tmpdir:
report = QualityReport("test", Path(tmpdir))
# Grade A (90-100)
self.assertEqual(report.quality_grade, 'A')
# Grade B (80-89)
report.add_error('test', 'Error 1')
self.assertEqual(report.quality_grade, 'B')
# Grade C (70-79)
report.add_warning('test', 'Warning 1')
report.add_warning('test', 'Warning 2')
self.assertEqual(report.quality_grade, 'C')
# Grade D (60-69)
report.add_warning('test', 'Warning 3')
report.add_warning('test', 'Warning 4')
self.assertEqual(report.quality_grade, 'D')
# Grade F (below 60)
report.add_error('test', 'Error 2')
report.add_error('test', 'Error 3')
self.assertEqual(report.quality_grade, 'F')
def test_is_excellent_property(self):
"""Test is_excellent property"""
with tempfile.TemporaryDirectory() as tmpdir:
report = QualityReport("test", Path(tmpdir))
# Should be excellent with no issues
self.assertTrue(report.is_excellent)
# Adding an error should make it not excellent
report.add_error('test', 'Test error')
self.assertFalse(report.is_excellent)
# Clean report
report2 = QualityReport("test", Path(tmpdir))
# Adding a warning should also make it not excellent
report2.add_warning('test', 'Test warning')
self.assertFalse(report2.is_excellent)
class TestQualityCheckerCLI(unittest.TestCase):
"""Test quality checker CLI"""
def test_cli_help_output(self):
"""Test that CLI help works"""
import subprocess
try:
result = subprocess.run(
['python3', '-m', 'skill_seekers.cli.quality_checker', '--help'],
capture_output=True,
text=True,
timeout=5
)
# Should include usage info
output = result.stdout + result.stderr
self.assertTrue('usage:' in output.lower() or 'quality' in output.lower())
except FileNotFoundError:
self.skipTest("Module not installed")
def test_cli_with_nonexistent_directory(self):
"""Test CLI behavior with nonexistent directory"""
import subprocess
result = subprocess.run(
['python3', '-m', 'skill_seekers.cli.quality_checker', '/nonexistent/path'],
capture_output=True,
text=True
)
# Should fail
self.assertNotEqual(result.returncode, 0)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,527 @@
#!/usr/bin/env python3
"""
Test suite for doc_scraper core features
Tests URL validation, language detection, pattern extraction, and categorization
"""
import sys
import os
import unittest
from unittest.mock import Mock, MagicMock
from bs4 import BeautifulSoup
# Add parent directory to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from skill_seekers.cli.doc_scraper import DocToSkillConverter
class TestURLValidation(unittest.TestCase):
"""Test URL validation logic"""
def setUp(self):
"""Set up test converter"""
self.config = {
'name': 'test',
'base_url': 'https://docs.example.com/',
'url_patterns': {
'include': ['/guide/', '/api/'],
'exclude': ['/blog/', '/about/']
},
'selectors': {
'main_content': 'article',
'title': 'h1',
'code_blocks': 'pre code'
},
'rate_limit': 0.1,
'max_pages': 10
}
self.converter = DocToSkillConverter(self.config, dry_run=True)
def test_valid_url_with_include_pattern(self):
"""Test URL matching include pattern"""
url = 'https://docs.example.com/guide/getting-started'
self.assertTrue(self.converter.is_valid_url(url))
def test_valid_url_with_api_pattern(self):
"""Test URL matching API pattern"""
url = 'https://docs.example.com/api/reference'
self.assertTrue(self.converter.is_valid_url(url))
def test_invalid_url_with_exclude_pattern(self):
"""Test URL matching exclude pattern"""
url = 'https://docs.example.com/blog/announcement'
self.assertFalse(self.converter.is_valid_url(url))
def test_invalid_url_different_domain(self):
"""Test URL from different domain"""
url = 'https://other-site.com/guide/tutorial'
self.assertFalse(self.converter.is_valid_url(url))
def test_invalid_url_no_include_match(self):
"""Test URL not matching any include pattern"""
url = 'https://docs.example.com/download/installer'
self.assertFalse(self.converter.is_valid_url(url))
def test_url_validation_no_patterns(self):
"""Test URL validation with no include/exclude patterns"""
config = {
'name': 'test',
'base_url': 'https://docs.example.com/',
'url_patterns': {
'include': [],
'exclude': []
},
'selectors': {'main_content': 'article', 'title': 'h1', 'code_blocks': 'pre'},
'rate_limit': 0.1,
'max_pages': 10
}
converter = DocToSkillConverter(config, dry_run=True)
# Should accept any URL under base_url
self.assertTrue(converter.is_valid_url('https://docs.example.com/anything'))
self.assertFalse(converter.is_valid_url('https://other.com/anything'))
class TestLanguageDetection(unittest.TestCase):
"""Test language detection from code blocks"""
def setUp(self):
"""Set up test converter"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article', 'title': 'h1', 'code_blocks': 'pre'},
'rate_limit': 0.1,
'max_pages': 10
}
self.converter = DocToSkillConverter(config, dry_run=True)
def test_detect_language_from_class(self):
"""Test language detection from CSS class"""
html = '<code class="language-python">print("hello")</code>'
elem = BeautifulSoup(html, 'html.parser').find('code')
lang = self.converter.detect_language(elem, 'print("hello")')
self.assertEqual(lang, 'python')
def test_detect_language_from_lang_class(self):
"""Test language detection from lang- prefix"""
html = '<code class="lang-javascript">console.log("hello")</code>'
elem = BeautifulSoup(html, 'html.parser').find('code')
lang = self.converter.detect_language(elem, 'console.log("hello")')
self.assertEqual(lang, 'javascript')
def test_detect_language_from_parent(self):
"""Test language detection from parent pre element"""
html = '<pre class="language-cpp"><code>int main() {}</code></pre>'
elem = BeautifulSoup(html, 'html.parser').find('code')
lang = self.converter.detect_language(elem, 'int main() {}')
self.assertEqual(lang, 'cpp')
def test_detect_python_from_heuristics(self):
"""Test Python detection from code content"""
html = '<code>import os\nfrom pathlib import Path</code>'
elem = BeautifulSoup(html, 'html.parser').find('code')
code = elem.get_text()
lang = self.converter.detect_language(elem, code)
self.assertEqual(lang, 'python')
def test_detect_python_from_def(self):
"""Test Python detection from def keyword"""
html = '<code>def my_function():\n pass</code>'
elem = BeautifulSoup(html, 'html.parser').find('code')
code = elem.get_text()
lang = self.converter.detect_language(elem, code)
self.assertEqual(lang, 'python')
def test_detect_javascript_from_const(self):
"""Test JavaScript detection from const keyword"""
html = '<code>const myVar = 10;</code>'
elem = BeautifulSoup(html, 'html.parser').find('code')
code = elem.get_text()
lang = self.converter.detect_language(elem, code)
self.assertEqual(lang, 'javascript')
def test_detect_javascript_from_arrow(self):
"""Test JavaScript detection from arrow function"""
html = '<code>const add = (a, b) => a + b;</code>'
elem = BeautifulSoup(html, 'html.parser').find('code')
code = elem.get_text()
lang = self.converter.detect_language(elem, code)
self.assertEqual(lang, 'javascript')
def test_detect_gdscript(self):
"""Test GDScript detection"""
html = '<code>func _ready():\n var x = 5</code>'
elem = BeautifulSoup(html, 'html.parser').find('code')
code = elem.get_text()
lang = self.converter.detect_language(elem, code)
self.assertEqual(lang, 'gdscript')
def test_detect_cpp(self):
"""Test C++ detection"""
html = '<code>#include <iostream>\nint main() { return 0; }</code>'
elem = BeautifulSoup(html, 'html.parser').find('code')
code = elem.get_text()
lang = self.converter.detect_language(elem, code)
self.assertEqual(lang, 'cpp')
def test_detect_unknown(self):
"""Test unknown language detection"""
html = '<code>some random text without clear indicators</code>'
elem = BeautifulSoup(html, 'html.parser').find('code')
code = elem.get_text()
lang = self.converter.detect_language(elem, code)
self.assertEqual(lang, 'unknown')
def test_detect_brush_pattern_in_pre(self):
"""Test brush: pattern in pre element"""
html = '<pre class="brush: python"><code>x</code></pre>'
elem = BeautifulSoup(html, 'html.parser').find('code')
lang = self.converter.detect_language(elem, 'x')
self.assertEqual(lang, 'python', 'Should detect python from brush: python pattern')
def test_detect_bare_class_in_pre(self):
"""Test bare class name in pre element"""
html = '<pre class="python"><code>x</code></pre>'
elem = BeautifulSoup(html, 'html.parser').find('code')
lang = self.converter.detect_language(elem, 'x')
self.assertEqual(lang, 'python', 'Should detect python from bare class name')
def test_detect_bare_class_in_code(self):
"""Test bare class name in code element"""
html = '<code class="python">x</code>'
elem = BeautifulSoup(html, 'html.parser').find('code')
lang = self.converter.detect_language(elem, 'x')
self.assertEqual(lang, 'python', 'Should detect python from bare class name')
def test_detect_csharp_from_using_system(self):
"""Test C# detection from 'using System' keyword"""
html = '<code>using System;\nnamespace MyApp { }</code>'
elem = BeautifulSoup(html, 'html.parser').find('code')
code = elem.get_text()
lang = self.converter.detect_language(elem, code)
self.assertEqual(lang, 'csharp', 'Should detect C# from using System')
def test_detect_csharp_from_namespace(self):
"""Test C# detection from 'namespace' keyword"""
html = '<code>namespace MyNamespace\n{\n public class Test { }\n}</code>'
elem = BeautifulSoup(html, 'html.parser').find('code')
code = elem.get_text()
lang = self.converter.detect_language(elem, code)
self.assertEqual(lang, 'csharp', 'Should detect C# from namespace')
def test_detect_csharp_from_property_syntax(self):
"""Test C# detection from property syntax"""
html = '<code>public string Name { get; set; }</code>'
elem = BeautifulSoup(html, 'html.parser').find('code')
code = elem.get_text()
lang = self.converter.detect_language(elem, code)
self.assertEqual(lang, 'csharp', 'Should detect C# from { get; set; } syntax')
def test_detect_csharp_from_public_class(self):
"""Test C# detection from 'public class' keyword"""
html = '<code>public class MyClass\n{\n private int value;\n}</code>'
elem = BeautifulSoup(html, 'html.parser').find('code')
code = elem.get_text()
lang = self.converter.detect_language(elem, code)
self.assertEqual(lang, 'csharp', 'Should detect C# from public class')
def test_detect_csharp_from_private_class(self):
"""Test C# detection from 'private class' keyword"""
html = '<code>private class Helper { }</code>'
elem = BeautifulSoup(html, 'html.parser').find('code')
code = elem.get_text()
lang = self.converter.detect_language(elem, code)
self.assertEqual(lang, 'csharp', 'Should detect C# from private class')
def test_detect_csharp_from_public_static_void(self):
"""Test C# detection from 'public static void' keyword"""
html = '<code>public static void Main(string[] args)\n{\n Console.WriteLine("Test");\n}</code>'
elem = BeautifulSoup(html, 'html.parser').find('code')
code = elem.get_text()
lang = self.converter.detect_language(elem, code)
self.assertEqual(lang, 'csharp', 'Should detect C# from public static void')
def test_detect_csharp_from_class_attribute(self):
"""Test C# detection from CSS class attribute"""
html = '<code class="language-csharp">var x = 5;</code>'
elem = BeautifulSoup(html, 'html.parser').find('code')
code = elem.get_text()
lang = self.converter.detect_language(elem, code)
self.assertEqual(lang, 'csharp', 'Should detect C# from language-csharp class')
class TestPatternExtraction(unittest.TestCase):
"""Test pattern extraction from documentation"""
def setUp(self):
"""Set up test converter"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article', 'title': 'h1', 'code_blocks': 'pre'},
'rate_limit': 0.1,
'max_pages': 10
}
self.converter = DocToSkillConverter(config, dry_run=True)
def test_extract_pattern_with_example_marker(self):
"""Test pattern extraction with 'Example:' marker"""
html = '''
<article>
<p>Example: Here's how to use it</p>
<pre><code>print("hello")</code></pre>
</article>
'''
soup = BeautifulSoup(html, 'html.parser')
main = soup.find('article')
patterns = self.converter.extract_patterns(main, [])
self.assertGreater(len(patterns), 0)
self.assertIn('example', patterns[0]['description'].lower())
def test_extract_pattern_with_usage_marker(self):
"""Test pattern extraction with 'Usage:' marker"""
html = '''
<article>
<p>Usage: Call this function like so</p>
<pre><code>my_function(arg)</code></pre>
</article>
'''
soup = BeautifulSoup(html, 'html.parser')
main = soup.find('article')
patterns = self.converter.extract_patterns(main, [])
self.assertGreater(len(patterns), 0)
self.assertIn('usage', patterns[0]['description'].lower())
def test_extract_pattern_limit(self):
"""Test pattern extraction limits to 5 patterns"""
html = '<article>'
for i in range(10):
html += f'<p>Example {i}: Test</p><pre><code>code_{i}</code></pre>'
html += '</article>'
soup = BeautifulSoup(html, 'html.parser')
main = soup.find('article')
patterns = self.converter.extract_patterns(main, [])
self.assertLessEqual(len(patterns), 5, "Should limit to 5 patterns max")
class TestCategorization(unittest.TestCase):
"""Test smart categorization logic"""
def setUp(self):
"""Set up test converter"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'categories': {
'getting_started': ['intro', 'tutorial', 'getting-started'],
'api': ['api', 'reference', 'class'],
'guides': ['guide', 'how-to']
},
'selectors': {'main_content': 'article', 'title': 'h1', 'code_blocks': 'pre'},
'rate_limit': 0.1,
'max_pages': 10
}
self.converter = DocToSkillConverter(config, dry_run=True)
def test_categorize_by_url(self):
"""Test categorization based on URL"""
pages = [{
'url': 'https://example.com/api/reference',
'title': 'Some Title',
'content': 'Some content'
}]
categories = self.converter.smart_categorize(pages)
# Should categorize to 'api' based on URL containing 'api'
self.assertIn('api', categories)
self.assertEqual(len(categories['api']), 1)
def test_categorize_by_title(self):
"""Test categorization based on title"""
pages = [{
'url': 'https://example.com/docs/page',
'title': 'API Reference Documentation',
'content': 'Some content'
}]
categories = self.converter.smart_categorize(pages)
self.assertIn('api', categories)
self.assertEqual(len(categories['api']), 1)
def test_categorize_by_content(self):
"""Test categorization based on content (lower priority)"""
pages = [{
'url': 'https://example.com/docs/page',
'title': 'Some Page',
'content': 'This is a tutorial for beginners. An intro to the system.'
}]
categories = self.converter.smart_categorize(pages)
# Should categorize based on 'tutorial' and 'intro' in content
self.assertIn('getting_started', categories)
def test_categorize_to_other(self):
"""Test pages that don't match any category go to 'other'"""
pages = [{
'url': 'https://example.com/random/page',
'title': 'Random Page',
'content': 'Random content with no keywords'
}]
categories = self.converter.smart_categorize(pages)
self.assertIn('other', categories)
self.assertEqual(len(categories['other']), 1)
def test_empty_categories_removed(self):
"""Test empty categories are removed"""
pages = [{
'url': 'https://example.com/api/reference',
'title': 'API Reference',
'content': 'API documentation'
}]
categories = self.converter.smart_categorize(pages)
# Only 'api' should exist, not empty 'guides' or 'getting_started'
# (categories with no pages are removed)
self.assertIn('api', categories)
self.assertNotIn('guides', categories)
class TestLinkExtraction(unittest.TestCase):
"""Test link extraction and anchor fragment handling"""
def setUp(self):
"""Set up test converter"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article', 'title': 'h1', 'code_blocks': 'pre code'},
'url_patterns': {
'include': [],
'exclude': []
},
'rate_limit': 0.1,
'max_pages': 10
}
self.converter = DocToSkillConverter(config, dry_run=True)
def test_extract_links_strips_anchor_fragments(self):
"""Test that anchor fragments (#anchor) are stripped from extracted links"""
html = '''
<article>
<h1>Test Page</h1>
<p>Content with links</p>
<a href="https://example.com/docs/page.html#section1">Link 1</a>
<a href="https://example.com/docs/page.html#section2">Link 2</a>
<a href="https://example.com/docs/other.html">Link 3</a>
</article>
'''
soup = BeautifulSoup(html, 'html.parser')
page = self.converter.extract_content(soup, 'https://example.com/')
# Should have 2 unique URLs (page.html and other.html), not 3
# The two links with different anchors should be deduplicated
self.assertEqual(len(page['links']), 2)
self.assertIn('https://example.com/docs/page.html', page['links'])
self.assertIn('https://example.com/docs/other.html', page['links'])
def test_extract_links_no_anchor_duplicates(self):
"""Test that multiple anchor links to same page don't create duplicates"""
html = '''
<article>
<h1>Test Page</h1>
<a href="https://example.com/docs/api.html#cb1-1">Anchor 1</a>
<a href="https://example.com/docs/api.html#cb1-2">Anchor 2</a>
<a href="https://example.com/docs/api.html#cb1-3">Anchor 3</a>
<a href="https://example.com/docs/api.html#cb1-4">Anchor 4</a>
<a href="https://example.com/docs/api.html#cb1-5">Anchor 5</a>
</article>
'''
soup = BeautifulSoup(html, 'html.parser')
page = self.converter.extract_content(soup, 'https://example.com/')
# All 5 links point to the same page, should result in only 1 URL
self.assertEqual(len(page['links']), 1)
self.assertEqual(page['links'][0], 'https://example.com/docs/api.html')
def test_extract_links_preserves_query_params(self):
"""Test that query parameters are preserved when stripping anchors"""
html = '''
<article>
<h1>Test Page</h1>
<a href="https://example.com/search?q=test#result1">Search Result</a>
</article>
'''
soup = BeautifulSoup(html, 'html.parser')
page = self.converter.extract_content(soup, 'https://example.com/')
# Query params should be preserved, only anchor stripped
self.assertEqual(len(page['links']), 1)
self.assertEqual(page['links'][0], 'https://example.com/search?q=test')
def test_extract_links_relative_urls_with_anchors(self):
"""Test that relative URLs with anchors are handled correctly"""
html = '''
<article>
<h1>Test Page</h1>
<a href="/docs/guide.html#intro">Relative Link 1</a>
<a href="/docs/guide.html#advanced">Relative Link 2</a>
<a href="/docs/tutorial.html#start">Relative Link 3</a>
</article>
'''
soup = BeautifulSoup(html, 'html.parser')
page = self.converter.extract_content(soup, 'https://example.com/')
# Should have 2 unique URLs (guide.html and tutorial.html)
self.assertEqual(len(page['links']), 2)
self.assertIn('https://example.com/docs/guide.html', page['links'])
self.assertIn('https://example.com/docs/tutorial.html', page['links'])
class TestTextCleaning(unittest.TestCase):
"""Test text cleaning utility"""
def setUp(self):
"""Set up test converter"""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article', 'title': 'h1', 'code_blocks': 'pre'},
'rate_limit': 0.1,
'max_pages': 10
}
self.converter = DocToSkillConverter(config, dry_run=True)
def test_clean_multiple_spaces(self):
"""Test cleaning multiple spaces"""
text = "Hello world test"
cleaned = self.converter.clean_text(text)
self.assertEqual(cleaned, "Hello world test")
def test_clean_newlines(self):
"""Test cleaning newlines"""
text = "Hello\n\nworld\ntest"
cleaned = self.converter.clean_text(text)
self.assertEqual(cleaned, "Hello world test")
def test_clean_tabs(self):
"""Test cleaning tabs"""
text = "Hello\t\tworld\ttest"
cleaned = self.converter.clean_text(text)
self.assertEqual(cleaned, "Hello world test")
def test_clean_strip_whitespace(self):
"""Test stripping leading/trailing whitespace"""
text = " Hello world "
cleaned = self.converter.clean_text(text)
self.assertEqual(cleaned, "Hello world")
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""
Test setup scripts for correctness and path validation.
Tests that bash scripts reference correct paths and are syntactically valid.
"""
import subprocess
import re
from pathlib import Path
import pytest
class TestSetupMCPScript:
"""Test setup_mcp.sh for path correctness and syntax"""
@pytest.fixture
def script_path(self):
"""Get path to setup_mcp.sh"""
return Path("setup_mcp.sh")
@pytest.fixture
def script_content(self, script_path):
"""Read setup_mcp.sh content"""
with open(script_path, 'r') as f:
return f.read()
def test_setup_mcp_exists(self, script_path):
"""Test that setup_mcp.sh exists"""
assert script_path.exists(), "setup_mcp.sh should exist"
assert script_path.is_file(), "setup_mcp.sh should be a file"
def test_bash_syntax_valid(self, script_path):
"""Test that setup_mcp.sh has valid bash syntax"""
result = subprocess.run(
["bash", "-n", str(script_path)],
capture_output=True,
text=True
)
assert result.returncode == 0, f"Bash syntax error: {result.stderr}"
def test_references_correct_mcp_directory(self, script_content):
"""Test that script references src/skill_seekers/mcp/ (v2.0.0 layout)"""
# Should NOT reference old mcp/ or skill_seeker_mcp/ directories
old_mcp_refs = re.findall(r'(?:^|[^a-z_])(?<!/)mcp/(?!\.json)', script_content, re.MULTILINE)
old_skill_seeker_refs = re.findall(r'skill_seeker_mcp/', script_content)
# Allow /mcp/ (as in src/skill_seekers/mcp/) but not standalone mcp/
assert len(old_mcp_refs) == 0, f"Found {len(old_mcp_refs)} references to old 'mcp/' directory: {old_mcp_refs}"
assert len(old_skill_seeker_refs) == 0, f"Found {len(old_skill_seeker_refs)} references to old 'skill_seeker_mcp/': {old_skill_seeker_refs}"
# SHOULD reference src/skill_seekers/mcp/
new_refs = re.findall(r'src/skill_seekers/mcp/', script_content)
assert len(new_refs) >= 6, f"Expected at least 6 references to 'src/skill_seekers/mcp/', found {len(new_refs)}"
def test_requirements_txt_path(self, script_content):
"""Test that script uses pip install -e . (v2.0.0 modern packaging)"""
# v2.0.0 uses '-e .' (editable install) instead of requirements files
# The actual command is "$PIP_INSTALL_CMD -e ."
assert " -e ." in script_content or " -e." in script_content, \
"Should use '-e .' for editable install (modern packaging)"
# Should NOT reference old requirements.txt paths
import re
old_skill_seeker_refs = re.findall(r'skill_seeker_mcp/requirements\.txt', script_content)
old_mcp_refs = re.findall(r'(?<!skill_seeker_)mcp/requirements\.txt', script_content)
assert len(old_skill_seeker_refs) == 0, \
f"Should NOT reference 'skill_seeker_mcp/requirements.txt' (found {len(old_skill_seeker_refs)})"
assert len(old_mcp_refs) == 0, \
f"Should NOT reference old 'mcp/requirements.txt' (found {len(old_mcp_refs)})"
def test_server_py_path(self, script_content):
"""Test that server.py path is correct (v2.0.0 layout)"""
import re
assert "src/skill_seekers/mcp/server.py" in script_content, \
"Should reference src/skill_seekers/mcp/server.py"
# Should NOT reference old paths
old_skill_seeker_refs = re.findall(r'skill_seeker_mcp/server\.py', script_content)
old_mcp_refs = re.findall(r'(?<!/)(?<!skill_seekers/)mcp/server\.py', script_content)
assert len(old_skill_seeker_refs) == 0, \
f"Should NOT reference old 'skill_seeker_mcp/server.py' (found {len(old_skill_seeker_refs)})"
assert len(old_mcp_refs) == 0, \
f"Should NOT reference old 'mcp/server.py' (found {len(old_mcp_refs)})"
def test_referenced_files_exist(self):
"""Test that all files referenced in setup_mcp.sh actually exist"""
# Check critical paths (new src/ layout)
assert Path("src/skill_seekers/mcp/server.py").exists(), \
"src/skill_seekers/mcp/server.py should exist"
assert Path("requirements.txt").exists(), \
"requirements.txt should exist (root level)"
def test_config_directory_exists(self):
"""Test that referenced config directory exists"""
assert Path("configs/").exists(), "configs/ directory should exist"
assert Path("configs/").is_dir(), "configs/ should be a directory"
def test_script_is_executable(self, script_path):
"""Test that setup_mcp.sh is executable"""
import os
assert os.access(script_path, os.X_OK), "setup_mcp.sh should be executable"
def test_json_config_path_format(self, script_content):
"""Test that JSON config examples use correct format (v2.0.0 layout)"""
# Check for the config path format in the script
assert '"$REPO_PATH/src/skill_seekers/mcp/server.py"' in script_content, \
"Config should show correct server.py path with $REPO_PATH variable (v2.0.0 layout)"
def test_no_hardcoded_paths(self, script_content):
"""Test that script doesn't contain hardcoded absolute paths"""
# Check for suspicious absolute paths (but allow $REPO_PATH and ~/.config)
hardcoded_paths = re.findall(r'(?<![$~])/mnt/[^\s"\']+', script_content)
assert len(hardcoded_paths) == 0, f"Found hardcoded absolute paths: {hardcoded_paths}"
def test_pytest_command_references(self, script_content):
"""Test that pytest commands reference correct test files"""
# Check for test file references
if "pytest" in script_content:
assert "tests/test_mcp_server.py" in script_content, \
"Should reference correct test file path"
class TestBashScriptGeneral:
"""General tests for all bash scripts in repository"""
@pytest.fixture
def all_bash_scripts(self):
"""Find all bash scripts in repository root"""
root = Path(".")
return list(root.glob("*.sh"))
def test_all_scripts_have_shebang(self, all_bash_scripts):
"""Test that all bash scripts have proper shebang"""
for script in all_bash_scripts:
with open(script, 'r') as f:
first_line = f.readline()
assert first_line.startswith("#!"), f"{script} should have shebang"
assert "bash" in first_line.lower(), f"{script} should use bash"
def test_all_scripts_syntax_valid(self, all_bash_scripts):
"""Test that all bash scripts have valid syntax"""
for script in all_bash_scripts:
result = subprocess.run(
["bash", "-n", str(script)],
capture_output=True,
text=True
)
assert result.returncode == 0, \
f"{script} has syntax error: {result.stderr}"
def test_all_scripts_use_set_e(self, all_bash_scripts):
"""Test that scripts use 'set -e' for error handling"""
for script in all_bash_scripts:
with open(script, 'r') as f:
content = f.read()
# Check for set -e or set -o errexit
has_error_handling = (
re.search(r'set\s+-[a-z]*e', content) or
re.search(r'set\s+-o\s+errexit', content)
)
assert has_error_handling, \
f"{script} should use 'set -e' for error handling"
def test_no_deprecated_backticks(self, all_bash_scripts):
"""Test that scripts use $() instead of deprecated backticks"""
for script in all_bash_scripts:
with open(script, 'r') as f:
content = f.read()
# Allow backticks in comments
lines = [line for line in content.split('\n') if not line.strip().startswith('#')]
code_content = '\n'.join(lines)
backticks = re.findall(r'`[^`]+`', code_content)
assert len(backticks) == 0, \
f"{script} uses deprecated backticks: {backticks}. Use $() instead"
class TestMCPServerPaths:
"""Test that MCP server references are consistent across codebase"""
def test_github_workflows_reference_correct_paths(self):
"""Test that GitHub workflows reference correct MCP paths"""
workflow_file = Path(".github/workflows/tests.yml")
if workflow_file.exists():
with open(workflow_file, 'r') as f:
content = f.read()
# Should NOT reference old mcp/ directory
assert "mcp/requirements.txt" not in content or "skill_seeker_mcp/requirements.txt" in content, \
"GitHub workflow should use correct MCP paths"
def test_readme_references_correct_paths(self):
"""Test that README references correct MCP paths"""
readme = Path("README.md")
if readme.exists():
with open(readme, 'r') as f:
content = f.read()
# Check for old mcp/ directory paths (but allow mcp.json and "mcp" package name)
# Use negative lookbehind to exclude skill_seeker_mcp/
old_mcp_refs = re.findall(r'(?<!skill_seeker_)mcp/(server\.py|requirements\.txt)', content)
if len(old_mcp_refs) > 0:
pytest.fail(f"README references old mcp/ directory: {old_mcp_refs}")
def test_documentation_references_correct_paths(self):
"""Test that documentation files reference correct MCP paths"""
doc_files = list(Path("docs/").glob("*.md")) if Path("docs/").exists() else []
for doc_file in doc_files:
with open(doc_file, 'r') as f:
content = f.read()
# Check for old mcp/ directory paths (but allow mcp.json and "mcp" package name)
old_mcp_refs = re.findall(r'(?<!skill_seeker_)mcp/(server\.py|requirements\.txt)', content)
if len(old_mcp_refs) > 0:
pytest.fail(f"{doc_file} references old mcp/ directory: {old_mcp_refs}")
def test_mcp_directory_structure():
"""Test that MCP directory structure is correct (new src/ layout)"""
mcp_dir = Path("src/skill_seekers/mcp")
assert mcp_dir.exists(), "src/skill_seekers/mcp/ directory should exist"
assert mcp_dir.is_dir(), "src/skill_seekers/mcp should be a directory"
assert (mcp_dir / "server.py").exists(), "src/skill_seekers/mcp/server.py should exist"
assert (mcp_dir / "__init__.py").exists(), "src/skill_seekers/mcp/__init__.py should exist"
# Old directories should NOT exist
old_mcp = Path("mcp")
old_skill_seeker_mcp = Path("skill_seeker_mcp")
if old_mcp.exists():
# If it exists, it should not contain server.py (might be leftover empty dir)
assert not (old_mcp / "server.py").exists(), \
"Old mcp/server.py should not exist - migrated to src/skill_seekers/mcp/"
if old_skill_seeker_mcp.exists():
assert not (old_skill_seeker_mcp / "server.py").exists(), \
"Old skill_seeker_mcp/server.py should not exist - migrated to src/skill_seekers/mcp/"
if __name__ == '__main__':
print("=" * 60)
print("Testing Setup Scripts")
print("=" * 60)
pytest.main([__file__, "-v"])
@@ -0,0 +1,318 @@
"""Tests for skip_llms_txt configuration option.
This config option allows users to explicitly skip llms.txt detection and fetching,
which is useful when:
- A site's llms.txt is incomplete or incorrect
- You need specific pages not in llms.txt
- You want to force HTML scraping
"""
import os
import tempfile
import unittest
import logging
from unittest.mock import patch, Mock, MagicMock
from skill_seekers.cli.doc_scraper import DocToSkillConverter
class TestSkipLlmsTxtConfig(unittest.TestCase):
"""Test skip_llms_txt configuration option."""
def test_default_skip_llms_txt_is_false(self):
"""Test that skip_llms_txt defaults to False when not specified."""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'}
}
converter = DocToSkillConverter(config, dry_run=True)
self.assertFalse(converter.skip_llms_txt)
def test_skip_llms_txt_can_be_set_true(self):
"""Test that skip_llms_txt can be explicitly set to True."""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'skip_llms_txt': True
}
converter = DocToSkillConverter(config, dry_run=True)
self.assertTrue(converter.skip_llms_txt)
def test_skip_llms_txt_can_be_set_false(self):
"""Test that skip_llms_txt can be explicitly set to False."""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'skip_llms_txt': False
}
converter = DocToSkillConverter(config, dry_run=True)
self.assertFalse(converter.skip_llms_txt)
class TestSkipLlmsTxtSyncBehavior(unittest.TestCase):
"""Test skip_llms_txt behavior in sync scraping mode."""
def test_llms_txt_tried_when_not_skipped(self):
"""Test that _try_llms_txt is called when skip_llms_txt is False."""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'skip_llms_txt': False
}
original_cwd = os.getcwd()
with tempfile.TemporaryDirectory() as tmpdir:
try:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=False)
with patch.object(converter, '_try_llms_txt', return_value=False) as mock_try:
with patch.object(converter, 'scrape_page'):
with patch.object(converter, 'save_summary'):
converter.scrape_all()
mock_try.assert_called_once()
finally:
os.chdir(original_cwd)
def test_llms_txt_skipped_when_skip_true(self):
"""Test that _try_llms_txt is NOT called when skip_llms_txt is True."""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'skip_llms_txt': True
}
original_cwd = os.getcwd()
with tempfile.TemporaryDirectory() as tmpdir:
try:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=False)
with patch.object(converter, '_try_llms_txt') as mock_try:
with patch.object(converter, 'scrape_page'):
with patch.object(converter, 'save_summary'):
converter.scrape_all()
mock_try.assert_not_called()
finally:
os.chdir(original_cwd)
def test_llms_txt_skipped_in_dry_run_mode(self):
"""Test that _try_llms_txt is NOT called in dry-run mode regardless of skip setting."""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'skip_llms_txt': False # Even when False
}
original_cwd = os.getcwd()
with tempfile.TemporaryDirectory() as tmpdir:
try:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=True)
with patch.object(converter, '_try_llms_txt') as mock_try:
with patch.object(converter, 'save_summary'):
converter.scrape_all()
mock_try.assert_not_called()
finally:
os.chdir(original_cwd)
class TestSkipLlmsTxtAsyncBehavior(unittest.TestCase):
"""Test skip_llms_txt behavior in async scraping mode."""
def test_async_llms_txt_tried_when_not_skipped(self):
"""Test that _try_llms_txt is called in async mode when skip_llms_txt is False."""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'async_mode': True,
'skip_llms_txt': False
}
original_cwd = os.getcwd()
with tempfile.TemporaryDirectory() as tmpdir:
try:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=False)
with patch.object(converter, '_try_llms_txt', return_value=False) as mock_try:
with patch.object(converter, 'scrape_page_async', return_value=None):
with patch.object(converter, 'save_summary'):
converter.scrape_all()
mock_try.assert_called_once()
finally:
os.chdir(original_cwd)
def test_async_llms_txt_skipped_when_skip_true(self):
"""Test that _try_llms_txt is NOT called in async mode when skip_llms_txt is True."""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'async_mode': True,
'skip_llms_txt': True
}
original_cwd = os.getcwd()
with tempfile.TemporaryDirectory() as tmpdir:
try:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=False)
with patch.object(converter, '_try_llms_txt') as mock_try:
with patch.object(converter, 'scrape_page_async', return_value=None):
with patch.object(converter, 'save_summary'):
converter.scrape_all()
mock_try.assert_not_called()
finally:
os.chdir(original_cwd)
class TestSkipLlmsTxtWithRealConfig(unittest.TestCase):
"""Test skip_llms_txt with real-world config patterns."""
def test_telegram_bots_config_pattern(self):
"""Test the telegram-bots config pattern which uses skip_llms_txt."""
config = {
'name': 'telegram-bots',
'description': 'Telegram bot documentation',
'base_url': 'https://core.telegram.org/bots',
'skip_llms_txt': True, # Telegram doesn't have useful llms.txt
'start_urls': [
'https://core.telegram.org/bots',
'https://core.telegram.org/bots/api'
],
'selectors': {
'main_content': '#dev_page_content, main, article',
'title': 'h1, title',
'code_blocks': 'pre code, pre'
}
}
converter = DocToSkillConverter(config, dry_run=True)
self.assertTrue(converter.skip_llms_txt)
self.assertEqual(converter.name, 'telegram-bots')
def test_skip_llms_txt_with_multiple_start_urls(self):
"""Test skip_llms_txt works correctly with multiple start URLs."""
config = {
'name': 'test-multi',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'skip_llms_txt': True,
'start_urls': [
'https://example.com/docs/',
'https://example.com/api/',
'https://example.com/guide/'
]
}
converter = DocToSkillConverter(config, dry_run=True)
self.assertTrue(converter.skip_llms_txt)
# start_urls are stored in pending_urls deque
self.assertEqual(len(converter.pending_urls), 3)
class TestSkipLlmsTxtEdgeCases(unittest.TestCase):
"""Test edge cases for skip_llms_txt."""
def test_skip_llms_txt_with_int_zero_logs_warning(self):
"""Test that integer 0 logs warning and defaults to False."""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'skip_llms_txt': 0 # Invalid type
}
with self.assertLogs('skill_seekers.cli.doc_scraper', level='WARNING') as cm:
converter = DocToSkillConverter(config, dry_run=True)
self.assertFalse(converter.skip_llms_txt)
self.assertTrue(any('Invalid value' in log and '0' in log for log in cm.output))
def test_skip_llms_txt_with_int_one_logs_warning(self):
"""Test that integer 1 logs warning and defaults to False."""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'skip_llms_txt': 1 # Invalid type
}
with self.assertLogs('skill_seekers.cli.doc_scraper', level='WARNING') as cm:
converter = DocToSkillConverter(config, dry_run=True)
self.assertFalse(converter.skip_llms_txt)
self.assertTrue(any('Invalid value' in log and '1' in log for log in cm.output))
def test_skip_llms_txt_with_string_logs_warning(self):
"""Test that string values log warning and default to False."""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'skip_llms_txt': "true" # Invalid type
}
with self.assertLogs('skill_seekers.cli.doc_scraper', level='WARNING') as cm:
converter = DocToSkillConverter(config, dry_run=True)
self.assertFalse(converter.skip_llms_txt)
self.assertTrue(any('Invalid value' in log and 'true' in log for log in cm.output))
def test_skip_llms_txt_with_none_logs_warning(self):
"""Test that None logs warning and defaults to False."""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'skip_llms_txt': None # Invalid type
}
with self.assertLogs('skill_seekers.cli.doc_scraper', level='WARNING') as cm:
converter = DocToSkillConverter(config, dry_run=True)
self.assertFalse(converter.skip_llms_txt)
self.assertTrue(any('Invalid value' in log and 'None' in log for log in cm.output))
def test_scraping_proceeds_when_llms_txt_skipped(self):
"""Test that HTML scraping proceeds normally when llms.txt is skipped."""
config = {
'name': 'test',
'base_url': 'https://example.com/',
'selectors': {'main_content': 'article'},
'skip_llms_txt': True
}
original_cwd = os.getcwd()
with tempfile.TemporaryDirectory() as tmpdir:
try:
os.chdir(tmpdir)
converter = DocToSkillConverter(config, dry_run=False)
# Track if scrape_page was called
scrape_called = []
def mock_scrape(url):
scrape_called.append(url)
return None
with patch.object(converter, 'scrape_page', side_effect=mock_scrape):
with patch.object(converter, 'save_summary'):
converter.scrape_all()
# Should have attempted to scrape the base URL
self.assertTrue(len(scrape_called) > 0)
finally:
os.chdir(original_cwd)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,332 @@
"""
Tests for terminal detection functionality in enhance_skill_local.py
This module tests the detect_terminal_app() function and terminal launching logic
to ensure correct terminal selection across different environments.
"""
import unittest
import os
import sys
from unittest.mock import patch, MagicMock
from pathlib import Path
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from skill_seekers.cli.enhance_skill_local import detect_terminal_app, LocalSkillEnhancer
class TestDetectTerminalApp(unittest.TestCase):
"""Test the detect_terminal_app() function."""
original_skill_seeker: str | None = None
original_term_program: str | None = None
def setUp(self):
"""Save original environment variables."""
self.original_skill_seeker = os.environ.get('SKILL_SEEKER_TERMINAL')
self.original_term_program = os.environ.get('TERM_PROGRAM')
def tearDown(self):
"""Restore original environment variables."""
# Remove test env vars
if 'SKILL_SEEKER_TERMINAL' in os.environ:
del os.environ['SKILL_SEEKER_TERMINAL']
if 'TERM_PROGRAM' in os.environ:
del os.environ['TERM_PROGRAM']
# Restore originals if they existed
if self.original_skill_seeker is not None:
os.environ['SKILL_SEEKER_TERMINAL'] = self.original_skill_seeker
if self.original_term_program is not None:
os.environ['TERM_PROGRAM'] = self.original_term_program
# HIGH PRIORITY TESTS
def test_detect_terminal_with_skill_seeker_env(self):
"""Test that SKILL_SEEKER_TERMINAL env var takes highest priority."""
os.environ['SKILL_SEEKER_TERMINAL'] = 'Ghostty'
terminal_app, detection_method = detect_terminal_app()
self.assertEqual(terminal_app, 'Ghostty')
self.assertEqual(detection_method, 'SKILL_SEEKER_TERMINAL')
def test_detect_terminal_with_term_program_known(self):
"""Test detection from TERM_PROGRAM with known terminal (iTerm)."""
# Ensure SKILL_SEEKER_TERMINAL is not set
if 'SKILL_SEEKER_TERMINAL' in os.environ:
del os.environ['SKILL_SEEKER_TERMINAL']
os.environ['TERM_PROGRAM'] = 'iTerm.app'
terminal_app, detection_method = detect_terminal_app()
self.assertEqual(terminal_app, 'iTerm')
self.assertEqual(detection_method, 'TERM_PROGRAM')
def test_detect_terminal_with_term_program_ghostty(self):
"""Test detection from TERM_PROGRAM with Ghostty terminal."""
if 'SKILL_SEEKER_TERMINAL' in os.environ:
del os.environ['SKILL_SEEKER_TERMINAL']
os.environ['TERM_PROGRAM'] = 'ghostty'
terminal_app, detection_method = detect_terminal_app()
self.assertEqual(terminal_app, 'Ghostty')
self.assertEqual(detection_method, 'TERM_PROGRAM')
def test_detect_terminal_with_term_program_apple_terminal(self):
"""Test detection from TERM_PROGRAM with Apple Terminal."""
if 'SKILL_SEEKER_TERMINAL' in os.environ:
del os.environ['SKILL_SEEKER_TERMINAL']
os.environ['TERM_PROGRAM'] = 'Apple_Terminal'
terminal_app, detection_method = detect_terminal_app()
self.assertEqual(terminal_app, 'Terminal')
self.assertEqual(detection_method, 'TERM_PROGRAM')
def test_detect_terminal_with_term_program_wezterm(self):
"""Test detection from TERM_PROGRAM with WezTerm."""
if 'SKILL_SEEKER_TERMINAL' in os.environ:
del os.environ['SKILL_SEEKER_TERMINAL']
os.environ['TERM_PROGRAM'] = 'WezTerm'
terminal_app, detection_method = detect_terminal_app()
self.assertEqual(terminal_app, 'WezTerm')
self.assertEqual(detection_method, 'TERM_PROGRAM')
def test_detect_terminal_with_term_program_unknown(self):
"""Test fallback behavior when TERM_PROGRAM is unknown (e.g., IDE terminals)."""
if 'SKILL_SEEKER_TERMINAL' in os.environ:
del os.environ['SKILL_SEEKER_TERMINAL']
os.environ['TERM_PROGRAM'] = 'zed'
terminal_app, detection_method = detect_terminal_app()
self.assertEqual(terminal_app, 'Terminal')
self.assertEqual(detection_method, 'unknown TERM_PROGRAM (zed)')
def test_detect_terminal_default_fallback(self):
"""Test default fallback when no environment variables are set."""
# Remove both env vars
if 'SKILL_SEEKER_TERMINAL' in os.environ:
del os.environ['SKILL_SEEKER_TERMINAL']
if 'TERM_PROGRAM' in os.environ:
del os.environ['TERM_PROGRAM']
terminal_app, detection_method = detect_terminal_app()
self.assertEqual(terminal_app, 'Terminal')
self.assertEqual(detection_method, 'default')
def test_detect_terminal_priority_order(self):
"""Test that SKILL_SEEKER_TERMINAL takes priority over TERM_PROGRAM."""
os.environ['SKILL_SEEKER_TERMINAL'] = 'Ghostty'
os.environ['TERM_PROGRAM'] = 'iTerm.app'
terminal_app, detection_method = detect_terminal_app()
# SKILL_SEEKER_TERMINAL should win
self.assertEqual(terminal_app, 'Ghostty')
self.assertEqual(detection_method, 'SKILL_SEEKER_TERMINAL')
@patch('subprocess.Popen')
def test_subprocess_popen_called_with_correct_args(self, mock_popen):
"""Test that subprocess.Popen is called with correct arguments on macOS."""
# Only test on macOS
if sys.platform != 'darwin':
self.skipTest("This test only runs on macOS")
# Setup
os.environ['SKILL_SEEKER_TERMINAL'] = 'Ghostty'
# Create a test skill directory with minimal setup
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / 'test_skill'
skill_dir.mkdir()
# Create references directory (required by LocalSkillEnhancer)
(skill_dir / 'references').mkdir()
(skill_dir / 'references' / 'test.md').write_text('# Test')
# Create SKILL.md (required)
(skill_dir / 'SKILL.md').write_text('---\nname: test\n---\n# Test')
# Mock Popen to prevent actual terminal launch
mock_popen.return_value = MagicMock()
# Run enhancer in interactive mode (not headless)
enhancer = LocalSkillEnhancer(skill_dir)
result = enhancer.run(headless=False)
# Verify Popen was called
self.assertTrue(mock_popen.called)
# Verify call arguments
call_args = mock_popen.call_args[0][0]
self.assertEqual(call_args[0], 'open')
self.assertEqual(call_args[1], '-a')
self.assertEqual(call_args[2], 'Ghostty')
# call_args[3] should be the script file path
self.assertTrue(call_args[3].endswith('.sh'))
# MEDIUM PRIORITY TESTS
def test_detect_terminal_whitespace_handling(self):
"""Test that whitespace is stripped from environment variables."""
os.environ['SKILL_SEEKER_TERMINAL'] = ' Ghostty '
terminal_app, detection_method = detect_terminal_app()
self.assertEqual(terminal_app, 'Ghostty')
self.assertEqual(detection_method, 'SKILL_SEEKER_TERMINAL')
def test_detect_terminal_empty_string_env_vars(self):
"""Test that empty string env vars fall through to next priority."""
os.environ['SKILL_SEEKER_TERMINAL'] = ''
os.environ['TERM_PROGRAM'] = 'iTerm.app'
terminal_app, detection_method = detect_terminal_app()
# Should skip empty SKILL_SEEKER_TERMINAL and use TERM_PROGRAM
self.assertEqual(terminal_app, 'iTerm')
self.assertEqual(detection_method, 'TERM_PROGRAM')
def test_detect_terminal_empty_string_both_vars(self):
"""Test that empty strings on both vars falls back to default."""
os.environ['SKILL_SEEKER_TERMINAL'] = ''
os.environ['TERM_PROGRAM'] = ''
terminal_app, detection_method = detect_terminal_app()
# Should fall back to default
self.assertEqual(terminal_app, 'Terminal')
# Empty TERM_PROGRAM should be treated as not set
self.assertEqual(detection_method, 'default')
@patch('subprocess.Popen')
def test_terminal_launch_error_handling(self, mock_popen):
"""Test error handling when terminal launch fails."""
# Only test on macOS
if sys.platform != 'darwin':
self.skipTest("This test only runs on macOS")
# Setup Popen to raise exception
mock_popen.side_effect = Exception("Terminal not found")
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / 'test_skill'
skill_dir.mkdir()
(skill_dir / 'references').mkdir()
(skill_dir / 'references' / 'test.md').write_text('# Test')
(skill_dir / 'SKILL.md').write_text('---\nname: test\n---\n# Test')
enhancer = LocalSkillEnhancer(skill_dir)
# Capture stdout to check error message
from io import StringIO
captured_output = StringIO()
old_stdout = sys.stdout
sys.stdout = captured_output
# Run in interactive mode (not headless) to test terminal launch
result = enhancer.run(headless=False)
# Restore stdout
sys.stdout = old_stdout
# Should return False on error
self.assertFalse(result)
# Should print error message
output = captured_output.getvalue()
self.assertIn('Error launching', output)
def test_output_message_unknown_terminal(self):
"""Test that unknown terminal prints warning message."""
if sys.platform != 'darwin':
self.skipTest("This test only runs on macOS")
os.environ['TERM_PROGRAM'] = 'vscode'
if 'SKILL_SEEKER_TERMINAL' in os.environ:
del os.environ['SKILL_SEEKER_TERMINAL']
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / 'test_skill'
skill_dir.mkdir()
(skill_dir / 'references').mkdir()
(skill_dir / 'references' / 'test.md').write_text('# Test')
(skill_dir / 'SKILL.md').write_text('---\nname: test\n---\n# Test')
enhancer = LocalSkillEnhancer(skill_dir)
# Capture stdout
from io import StringIO
captured_output = StringIO()
old_stdout = sys.stdout
sys.stdout = captured_output
# Mock Popen to prevent actual launch
with patch('subprocess.Popen') as mock_popen:
mock_popen.return_value = MagicMock()
# Run in interactive mode (not headless) to test terminal detection
enhancer.run(headless=False)
# Restore stdout
sys.stdout = old_stdout
output = captured_output.getvalue()
# Should contain warning about unknown terminal
self.assertIn('⚠️', output)
self.assertIn('unknown TERM_PROGRAM', output)
self.assertIn('vscode', output)
self.assertIn('Using Terminal.app as fallback', output)
class TestTerminalMapCompleteness(unittest.TestCase):
"""Test that TERMINAL_MAP covers all documented terminals."""
def test_terminal_map_has_all_documented_terminals(self):
"""Verify TERMINAL_MAP contains all terminals mentioned in documentation."""
from skill_seekers.cli.enhance_skill_local import detect_terminal_app
# Get the TERMINAL_MAP from the function's scope
# We need to test this indirectly by checking each known terminal
known_terminals = [
('Apple_Terminal', 'Terminal'),
('iTerm.app', 'iTerm'),
('ghostty', 'Ghostty'),
('WezTerm', 'WezTerm'),
]
for term_program_value, expected_app_name in known_terminals:
# Set TERM_PROGRAM and verify detection
os.environ['TERM_PROGRAM'] = term_program_value
if 'SKILL_SEEKER_TERMINAL' in os.environ:
del os.environ['SKILL_SEEKER_TERMINAL']
terminal_app, detection_method = detect_terminal_app()
self.assertEqual(
terminal_app,
expected_app_name,
f"TERM_PROGRAM='{term_program_value}' should map to '{expected_app_name}'"
)
self.assertEqual(detection_method, 'TERM_PROGRAM')
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,603 @@
#!/usr/bin/env python3
"""
Tests for Unified Multi-Source Scraper
Covers:
- Config validation (unified vs legacy)
- Conflict detection
- Rule-based merging
- Skill building
"""
import os
import sys
import json
import pytest
import tempfile
from pathlib import Path
from skill_seekers.cli.config_validator import ConfigValidator, validate_config
from skill_seekers.cli.conflict_detector import ConflictDetector, Conflict
from skill_seekers.cli.merge_sources import RuleBasedMerger
from skill_seekers.cli.unified_skill_builder import UnifiedSkillBuilder
# ===========================
# Config Validation Tests
# ===========================
def test_detect_unified_format():
"""Test unified format detection"""
import tempfile
import json
unified_config = {
"name": "test",
"description": "Test skill",
"sources": [
{"type": "documentation", "base_url": "https://example.com"}
]
}
legacy_config = {
"name": "test",
"description": "Test skill",
"base_url": "https://example.com"
}
# Test unified detection
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
json.dump(unified_config, f)
config_path = f.name
try:
validator = ConfigValidator(config_path)
assert validator.is_unified == True
finally:
os.unlink(config_path)
# Test legacy detection
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
json.dump(legacy_config, f)
config_path = f.name
try:
validator = ConfigValidator(config_path)
assert validator.is_unified == False
finally:
os.unlink(config_path)
def test_validate_unified_sources():
"""Test source type validation"""
config = {
"name": "test",
"description": "Test",
"sources": [
{"type": "documentation", "base_url": "https://example.com"},
{"type": "github", "repo": "user/repo"},
{"type": "pdf", "path": "/path/to.pdf"}
]
}
validator = ConfigValidator(config)
validator.validate()
assert len(validator.config['sources']) == 3
def test_validate_invalid_source_type():
"""Test invalid source type raises error"""
config = {
"name": "test",
"description": "Test",
"sources": [
{"type": "invalid_type", "url": "https://example.com"}
]
}
validator = ConfigValidator(config)
with pytest.raises(ValueError, match="Invalid type"):
validator.validate()
def test_needs_api_merge():
"""Test API merge detection"""
# Config with both docs and GitHub code
config_needs_merge = {
"name": "test",
"description": "Test",
"sources": [
{"type": "documentation", "base_url": "https://example.com", "extract_api": True},
{"type": "github", "repo": "user/repo", "include_code": True}
]
}
validator = ConfigValidator(config_needs_merge)
assert validator.needs_api_merge() == True
# Config with only docs
config_no_merge = {
"name": "test",
"description": "Test",
"sources": [
{"type": "documentation", "base_url": "https://example.com"}
]
}
validator = ConfigValidator(config_no_merge)
assert validator.needs_api_merge() == False
def test_backward_compatibility():
"""Test legacy config conversion"""
legacy_config = {
"name": "test",
"description": "Test skill",
"base_url": "https://example.com",
"selectors": {"main_content": "article"},
"max_pages": 100
}
validator = ConfigValidator(legacy_config)
unified = validator.convert_legacy_to_unified()
assert 'sources' in unified
assert len(unified['sources']) == 1
assert unified['sources'][0]['type'] == 'documentation'
assert unified['sources'][0]['base_url'] == 'https://example.com'
# ===========================
# Conflict Detection Tests
# ===========================
def test_detect_missing_in_docs():
"""Test detection of APIs missing in documentation"""
docs_data = {
'pages': [
{
'url': 'https://example.com/api',
'apis': [
{
'name': 'documented_func',
'parameters': [{'name': 'x', 'type': 'int'}],
'return_type': 'str'
}
]
}
]
}
github_data = {
'code_analysis': {
'analyzed_files': [
{
'functions': [
{
'name': 'undocumented_func',
'parameters': [{'name': 'y', 'type_hint': 'float'}],
'return_type': 'bool'
}
]
}
]
}
}
detector = ConflictDetector(docs_data, github_data)
conflicts = detector._find_missing_in_docs()
assert len(conflicts) > 0
assert any(c.type == 'missing_in_docs' for c in conflicts)
assert any(c.api_name == 'undocumented_func' for c in conflicts)
def test_detect_missing_in_code():
"""Test detection of APIs missing in code"""
docs_data = {
'pages': [
{
'url': 'https://example.com/api',
'apis': [
{
'name': 'obsolete_func',
'parameters': [{'name': 'x', 'type': 'int'}],
'return_type': 'str'
}
]
}
]
}
github_data = {
'code_analysis': {
'analyzed_files': []
}
}
detector = ConflictDetector(docs_data, github_data)
conflicts = detector._find_missing_in_code()
assert len(conflicts) > 0
assert any(c.type == 'missing_in_code' for c in conflicts)
assert any(c.api_name == 'obsolete_func' for c in conflicts)
def test_detect_signature_mismatch():
"""Test detection of signature mismatches"""
docs_data = {
'pages': [
{
'url': 'https://example.com/api',
'apis': [
{
'name': 'func',
'parameters': [{'name': 'x', 'type': 'int'}],
'return_type': 'str'
}
]
}
]
}
github_data = {
'code_analysis': {
'analyzed_files': [
{
'functions': [
{
'name': 'func',
'parameters': [
{'name': 'x', 'type_hint': 'int'},
{'name': 'y', 'type_hint': 'bool', 'default': 'False'}
],
'return_type': 'str'
}
]
}
]
}
}
detector = ConflictDetector(docs_data, github_data)
conflicts = detector._find_signature_mismatches()
assert len(conflicts) > 0
assert any(c.type == 'signature_mismatch' for c in conflicts)
assert any(c.api_name == 'func' for c in conflicts)
def test_conflict_severity():
"""Test conflict severity assignment"""
# High severity: missing_in_code
conflict_high = Conflict(
type='missing_in_code',
severity='high',
api_name='test',
docs_info={'name': 'test'},
code_info=None,
difference='API documented but not in code'
)
assert conflict_high.severity == 'high'
# Medium severity: missing_in_docs
conflict_medium = Conflict(
type='missing_in_docs',
severity='medium',
api_name='test',
docs_info=None,
code_info={'name': 'test'},
difference='API in code but not documented'
)
assert conflict_medium.severity == 'medium'
# ===========================
# Merge Tests
# ===========================
def test_rule_based_merge_docs_only():
"""Test rule-based merge for docs-only APIs"""
docs_data = {
'pages': [
{
'url': 'https://example.com/api',
'apis': [
{
'name': 'docs_only_api',
'parameters': [{'name': 'x', 'type': 'int'}],
'return_type': 'str'
}
]
}
]
}
github_data = {'code_analysis': {'analyzed_files': []}}
detector = ConflictDetector(docs_data, github_data)
conflicts = detector.detect_all_conflicts()
merger = RuleBasedMerger(docs_data, github_data, conflicts)
merged = merger.merge_all()
assert 'apis' in merged
assert 'docs_only_api' in merged['apis']
assert merged['apis']['docs_only_api']['status'] == 'docs_only'
def test_rule_based_merge_code_only():
"""Test rule-based merge for code-only APIs"""
docs_data = {'pages': []}
github_data = {
'code_analysis': {
'analyzed_files': [
{
'functions': [
{
'name': 'code_only_api',
'parameters': [{'name': 'y', 'type_hint': 'float'}],
'return_type': 'bool'
}
]
}
]
}
}
detector = ConflictDetector(docs_data, github_data)
conflicts = detector.detect_all_conflicts()
merger = RuleBasedMerger(docs_data, github_data, conflicts)
merged = merger.merge_all()
assert 'apis' in merged
assert 'code_only_api' in merged['apis']
assert merged['apis']['code_only_api']['status'] == 'code_only'
def test_rule_based_merge_matched():
"""Test rule-based merge for matched APIs"""
docs_data = {
'pages': [
{
'url': 'https://example.com/api',
'apis': [
{
'name': 'matched_api',
'parameters': [{'name': 'x', 'type': 'int'}],
'return_type': 'str'
}
]
}
]
}
github_data = {
'code_analysis': {
'analyzed_files': [
{
'functions': [
{
'name': 'matched_api',
'parameters': [{'name': 'x', 'type_hint': 'int'}],
'return_type': 'str'
}
]
}
]
}
}
detector = ConflictDetector(docs_data, github_data)
conflicts = detector.detect_all_conflicts()
merger = RuleBasedMerger(docs_data, github_data, conflicts)
merged = merger.merge_all()
assert 'apis' in merged
assert 'matched_api' in merged['apis']
assert merged['apis']['matched_api']['status'] == 'matched'
def test_merge_summary():
"""Test merge summary statistics"""
docs_data = {
'pages': [
{
'url': 'https://example.com/api',
'apis': [
{'name': 'api1', 'parameters': [], 'return_type': 'str'},
{'name': 'api2', 'parameters': [], 'return_type': 'int'}
]
}
]
}
github_data = {
'code_analysis': {
'analyzed_files': [
{
'functions': [
{'name': 'api3', 'parameters': [], 'return_type': 'bool'}
]
}
]
}
}
detector = ConflictDetector(docs_data, github_data)
conflicts = detector.detect_all_conflicts()
merger = RuleBasedMerger(docs_data, github_data, conflicts)
merged = merger.merge_all()
assert 'summary' in merged
assert merged['summary']['total_apis'] == 3
assert merged['summary']['docs_only'] == 2
assert merged['summary']['code_only'] == 1
# ===========================
# Skill Builder Tests
# ===========================
def test_skill_builder_basic():
"""Test basic skill building"""
config = {
'name': 'test_skill',
'description': 'Test skill description',
'sources': [
{'type': 'documentation', 'base_url': 'https://example.com'}
]
}
scraped_data = {
'documentation': {
'pages': [],
'data_file': '/tmp/test.json'
}
}
with tempfile.TemporaryDirectory() as tmpdir:
# Override output directory
builder = UnifiedSkillBuilder(config, scraped_data)
builder.skill_dir = tmpdir
builder._generate_skill_md()
# Check SKILL.md was created
skill_md = Path(tmpdir) / 'SKILL.md'
assert skill_md.exists()
content = skill_md.read_text()
assert 'test_skill' in content.lower()
assert 'Test skill description' in content
def test_skill_builder_with_conflicts():
"""Test skill building with conflicts"""
config = {
'name': 'test_skill',
'description': 'Test',
'sources': [
{'type': 'documentation', 'base_url': 'https://example.com'},
{'type': 'github', 'repo': 'user/repo'}
]
}
scraped_data = {}
conflicts = [
Conflict(
type='missing_in_code',
severity='high',
api_name='test_api',
docs_info={'name': 'test_api'},
code_info=None,
difference='Test difference'
)
]
with tempfile.TemporaryDirectory() as tmpdir:
builder = UnifiedSkillBuilder(config, scraped_data, conflicts=conflicts)
builder.skill_dir = tmpdir
builder._generate_skill_md()
skill_md = Path(tmpdir) / 'SKILL.md'
content = skill_md.read_text()
assert '1 conflicts detected' in content
assert 'missing_in_code' in content
def test_skill_builder_merged_apis():
"""Test skill building with merged APIs"""
config = {
'name': 'test',
'description': 'Test',
'sources': []
}
scraped_data = {}
merged_data = {
'apis': {
'test_api': {
'name': 'test_api',
'status': 'matched',
'merged_signature': 'test_api(x: int) -> str',
'merged_description': 'Test API',
'source': 'both'
}
}
}
with tempfile.TemporaryDirectory() as tmpdir:
builder = UnifiedSkillBuilder(config, scraped_data, merged_data=merged_data)
builder.skill_dir = tmpdir
content = builder._format_merged_apis()
assert '✅ Verified APIs' in content
assert 'test_api' in content
# ===========================
# Integration Tests
# ===========================
def test_full_workflow_unified_config():
"""Test complete workflow with unified config"""
# Create test config
config = {
"name": "test_unified",
"description": "Test unified workflow",
"merge_mode": "rule-based",
"sources": [
{
"type": "documentation",
"base_url": "https://example.com",
"extract_api": True
},
{
"type": "github",
"repo": "user/repo",
"include_code": True,
"code_analysis_depth": "surface"
}
]
}
# Validate config
validator = ConfigValidator(config)
validator.validate()
assert validator.is_unified == True
assert validator.needs_api_merge() == True
def test_config_file_validation():
"""Test validation from config file"""
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
config = {
"name": "test",
"description": "Test",
"sources": [
{"type": "documentation", "base_url": "https://example.com"}
]
}
json.dump(config, f)
config_path = f.name
try:
validator = validate_config(config_path)
assert validator.is_unified == True
finally:
os.unlink(config_path)
# Run tests
if __name__ == '__main__':
pytest.main([__file__, '-v'])
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""
Test MCP Integration with Unified Scraping
Tests that the MCP server correctly handles unified configs.
"""
import sys
import os
import json
import tempfile
import asyncio
import pytest
from pathlib import Path
# WORKAROUND for shadowing issue: Temporarily change to /tmp to import external mcp
# This avoids any local mcp/ directory being in the import path
_original_dir = os.getcwd()
MCP_AVAILABLE = False
try:
os.chdir('/tmp') # Change away from project directory
from mcp.types import TextContent
MCP_AVAILABLE = True
except ImportError:
pass
finally:
os.chdir(_original_dir) # Restore original directory
# Configure pytest to only use asyncio backend (not trio)
pytestmark = pytest.mark.anyio
if MCP_AVAILABLE:
from skill_seekers.mcp.server import validate_config_tool, scrape_docs_tool
else:
validate_config_tool = None
scrape_docs_tool = None
@pytest.mark.skipif(not MCP_AVAILABLE, reason="MCP package not installed")
async def test_mcp_validate_unified_config():
"""Test that MCP can validate unified configs"""
print("\n✓ Testing MCP validate_config_tool with unified config...")
# Use existing unified config
config_path = "configs/react_unified.json"
if not Path(config_path).exists():
print(f" ⚠️ Skipping: {config_path} not found")
return
args = {"config_path": config_path}
result = await validate_config_tool(args)
# Check result
text = result[0].text
assert "" in text, f"Expected success, got: {text}"
assert "Unified" in text, f"Expected unified format detected, got: {text}"
assert "Sources:" in text, f"Expected sources count, got: {text}"
print(" ✅ MCP correctly validates unified config")
@pytest.mark.skipif(not MCP_AVAILABLE, reason="MCP package not installed")
async def test_mcp_validate_legacy_config():
"""Test that MCP can validate legacy configs"""
print("\n✓ Testing MCP validate_config_tool with legacy config...")
# Use existing legacy config
config_path = "configs/react.json"
if not Path(config_path).exists():
print(f" ⚠️ Skipping: {config_path} not found")
return
args = {"config_path": config_path}
result = await validate_config_tool(args)
# Check result
text = result[0].text
assert "" in text, f"Expected success, got: {text}"
assert "Legacy" in text, f"Expected legacy format detected, got: {text}"
print(" ✅ MCP correctly validates legacy config")
@pytest.mark.skipif(not MCP_AVAILABLE, reason="MCP package not installed")
async def test_mcp_scrape_docs_detection():
"""Test that MCP scrape_docs correctly detects format"""
print("\n✓ Testing MCP scrape_docs format detection...")
# Create temporary unified config
unified_config = {
"name": "test_mcp_unified",
"description": "Test unified via MCP",
"merge_mode": "rule-based",
"sources": [
{
"type": "documentation",
"base_url": "https://example.com",
"extract_api": True,
"max_pages": 5
}
]
}
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
json.dump(unified_config, f)
unified_config_path = f.name
# Create temporary legacy config
legacy_config = {
"name": "test_mcp_legacy",
"description": "Test legacy via MCP",
"base_url": "https://example.com",
"max_pages": 5
}
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
json.dump(legacy_config, f)
legacy_config_path = f.name
try:
# Test unified detection
with open(unified_config_path, 'r') as f:
config = json.load(f)
is_unified = 'sources' in config and isinstance(config['sources'], list)
assert is_unified, "Should detect unified format"
print(" ✅ Unified format detected correctly")
# Test legacy detection
with open(legacy_config_path, 'r') as f:
config = json.load(f)
is_unified = 'sources' in config and isinstance(config['sources'], list)
assert not is_unified, "Should detect legacy format"
print(" ✅ Legacy format detected correctly")
finally:
# Cleanup
Path(unified_config_path).unlink(missing_ok=True)
Path(legacy_config_path).unlink(missing_ok=True)
@pytest.mark.skipif(not MCP_AVAILABLE, reason="MCP package not installed")
async def test_mcp_merge_mode_override():
"""Test that MCP can override merge mode"""
print("\n✓ Testing MCP merge_mode override...")
# Create unified config
config = {
"name": "test_merge_override",
"description": "Test merge mode override",
"merge_mode": "rule-based",
"sources": [
{"type": "documentation", "base_url": "https://example.com"}
]
}
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
json.dump(config, f)
config_path = f.name
try:
# Test that we can override merge_mode in args
args = {
"config_path": config_path,
"merge_mode": "claude-enhanced" # Override
}
# Check that args has merge_mode
assert args.get("merge_mode") == "claude-enhanced"
print(" ✅ Merge mode override supported")
finally:
Path(config_path).unlink(missing_ok=True)
# Run all tests
async def run_all_tests():
print("=" * 60)
print("MCP Unified Scraping Integration Tests")
print("=" * 60)
try:
await test_mcp_validate_unified_config()
await test_mcp_validate_legacy_config()
await test_mcp_scrape_docs_detection()
await test_mcp_merge_mode_override()
print("\n" + "=" * 60)
print("✅ All MCP integration tests passed!")
print("=" * 60)
except AssertionError as e:
print(f"\n❌ Test failed: {e}")
sys.exit(1)
except Exception as e:
print(f"\n❌ Unexpected error: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == '__main__':
asyncio.run(run_all_tests())
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""
Tests for cli/upload_skill.py functionality
"""
import unittest
import tempfile
import zipfile
import os
from pathlib import Path
import sys
from skill_seekers.cli.upload_skill import upload_skill_api
class TestUploadSkillAPI(unittest.TestCase):
"""Test upload_skill_api function"""
def setUp(self):
"""Store original API key state"""
self.original_api_key = os.environ.get('ANTHROPIC_API_KEY')
def tearDown(self):
"""Restore original API key state"""
if self.original_api_key:
os.environ['ANTHROPIC_API_KEY'] = self.original_api_key
elif 'ANTHROPIC_API_KEY' in os.environ:
del os.environ['ANTHROPIC_API_KEY']
def create_test_zip(self, tmpdir):
"""Helper to create a test .zip file"""
zip_path = Path(tmpdir) / "test-skill.zip"
with zipfile.ZipFile(zip_path, 'w') as zf:
zf.writestr("SKILL.md", "---\nname: test\n---\n# Test Skill")
zf.writestr("references/index.md", "# Index")
return zip_path
def test_upload_without_api_key(self):
"""Test that upload fails gracefully without API key"""
# Remove API key
if 'ANTHROPIC_API_KEY' in os.environ:
del os.environ['ANTHROPIC_API_KEY']
with tempfile.TemporaryDirectory() as tmpdir:
zip_path = self.create_test_zip(tmpdir)
success, message = upload_skill_api(zip_path)
self.assertFalse(success)
# Check for api_key (with underscore) in message
self.assertTrue('api_key' in message.lower() or 'api key' in message.lower())
def test_upload_with_nonexistent_file(self):
"""Test upload with nonexistent file"""
os.environ['ANTHROPIC_API_KEY'] = 'sk-ant-test-key'
success, message = upload_skill_api("/nonexistent/file.zip")
self.assertFalse(success)
self.assertIn('not found', message.lower())
def test_upload_with_invalid_zip(self):
"""Test upload with invalid zip file (not a zip)"""
os.environ['ANTHROPIC_API_KEY'] = 'sk-ant-test-key'
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmpfile:
tmpfile.write(b"Not a valid zip file")
tmpfile.flush()
try:
success, message = upload_skill_api(tmpfile.name)
# Should either fail validation or detect invalid zip
self.assertFalse(success)
finally:
os.unlink(tmpfile.name)
def test_upload_accepts_path_object(self):
"""Test that upload_skill_api accepts Path objects"""
os.environ['ANTHROPIC_API_KEY'] = 'sk-ant-test-key'
with tempfile.TemporaryDirectory() as tmpdir:
zip_path = self.create_test_zip(tmpdir)
# This should not raise TypeError
try:
success, message = upload_skill_api(Path(zip_path))
except TypeError:
self.fail("upload_skill_api should accept Path objects")
class TestUploadSkillCLI(unittest.TestCase):
"""Test upload_skill.py command-line interface"""
def test_cli_help_output(self):
"""Test that skill-seekers upload --help works"""
import subprocess
try:
result = subprocess.run(
['skill-seekers', 'upload', '--help'],
capture_output=True,
text=True,
timeout=5
)
# argparse may return 0 or 2 for --help
self.assertIn(result.returncode, [0, 2])
output = result.stdout + result.stderr
self.assertTrue('usage:' in output.lower() or 'upload' in output.lower())
except FileNotFoundError:
self.skipTest("skill-seekers command not installed")
def test_cli_executes_without_errors(self):
"""Test that skill-seekers-upload entry point works"""
import subprocess
try:
result = subprocess.run(
['skill-seekers-upload', '--help'],
capture_output=True,
text=True,
timeout=5
)
# argparse may return 0 or 2 for --help
self.assertIn(result.returncode, [0, 2])
except FileNotFoundError:
self.skipTest("skill-seekers-upload command not installed")
def test_cli_requires_zip_argument(self):
"""Test that CLI requires zip file argument"""
import subprocess
result = subprocess.run(
['python3', 'cli/upload_skill.py'],
capture_output=True,
text=True
)
# Should fail or show usage
self.assertTrue(
result.returncode != 0 or 'usage' in result.stderr.lower() or 'usage' in result.stdout.lower()
)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""
Tests for cli/utils.py utility functions
"""
import unittest
import tempfile
import os
import zipfile
from pathlib import Path
import sys
from skill_seekers.cli.utils import (
has_api_key,
get_api_key,
get_upload_url,
format_file_size,
validate_skill_directory,
validate_zip_file,
print_upload_instructions
)
class TestAPIKeyFunctions(unittest.TestCase):
"""Test API key utility functions"""
def setUp(self):
"""Store original API key state"""
self.original_api_key = os.environ.get('ANTHROPIC_API_KEY')
def tearDown(self):
"""Restore original API key state"""
if self.original_api_key:
os.environ['ANTHROPIC_API_KEY'] = self.original_api_key
elif 'ANTHROPIC_API_KEY' in os.environ:
del os.environ['ANTHROPIC_API_KEY']
def test_has_api_key_when_set(self):
"""Test has_api_key returns True when key is set"""
os.environ['ANTHROPIC_API_KEY'] = 'sk-ant-test-key'
self.assertTrue(has_api_key())
def test_has_api_key_when_not_set(self):
"""Test has_api_key returns False when key is not set"""
if 'ANTHROPIC_API_KEY' in os.environ:
del os.environ['ANTHROPIC_API_KEY']
self.assertFalse(has_api_key())
def test_has_api_key_when_empty_string(self):
"""Test has_api_key returns False when key is empty string"""
os.environ['ANTHROPIC_API_KEY'] = ''
self.assertFalse(has_api_key())
def test_has_api_key_when_whitespace_only(self):
"""Test has_api_key returns False when key is whitespace"""
os.environ['ANTHROPIC_API_KEY'] = ' '
self.assertFalse(has_api_key())
def test_get_api_key_returns_key(self):
"""Test get_api_key returns the actual key"""
os.environ['ANTHROPIC_API_KEY'] = 'sk-ant-test-key'
self.assertEqual(get_api_key(), 'sk-ant-test-key')
def test_get_api_key_returns_none_when_not_set(self):
"""Test get_api_key returns None when not set"""
if 'ANTHROPIC_API_KEY' in os.environ:
del os.environ['ANTHROPIC_API_KEY']
self.assertIsNone(get_api_key())
def test_get_api_key_strips_whitespace(self):
"""Test get_api_key strips whitespace from key"""
os.environ['ANTHROPIC_API_KEY'] = ' sk-ant-test-key '
self.assertEqual(get_api_key(), 'sk-ant-test-key')
class TestGetUploadURL(unittest.TestCase):
"""Test get_upload_url function"""
def test_get_upload_url_returns_correct_url(self):
"""Test get_upload_url returns the correct Claude skills URL"""
url = get_upload_url()
self.assertEqual(url, "https://claude.ai/skills")
def test_get_upload_url_returns_string(self):
"""Test get_upload_url returns a string"""
url = get_upload_url()
self.assertIsInstance(url, str)
class TestFormatFileSize(unittest.TestCase):
"""Test format_file_size function"""
def test_format_bytes_below_1kb(self):
"""Test formatting bytes below 1 KB"""
self.assertEqual(format_file_size(500), "500 bytes")
self.assertEqual(format_file_size(1023), "1023 bytes")
def test_format_kilobytes(self):
"""Test formatting KB sizes"""
self.assertEqual(format_file_size(1024), "1.0 KB")
self.assertEqual(format_file_size(1536), "1.5 KB")
self.assertEqual(format_file_size(10240), "10.0 KB")
def test_format_megabytes(self):
"""Test formatting MB sizes"""
self.assertEqual(format_file_size(1048576), "1.0 MB")
self.assertEqual(format_file_size(1572864), "1.5 MB")
self.assertEqual(format_file_size(10485760), "10.0 MB")
def test_format_zero_bytes(self):
"""Test formatting zero bytes"""
self.assertEqual(format_file_size(0), "0 bytes")
def test_format_large_files(self):
"""Test formatting large file sizes"""
# 100 MB
self.assertEqual(format_file_size(104857600), "100.0 MB")
# 1 GB (still shows as MB)
self.assertEqual(format_file_size(1073741824), "1024.0 MB")
class TestValidateSkillDirectory(unittest.TestCase):
"""Test validate_skill_directory function"""
def test_valid_skill_directory(self):
"""Test validation of valid skill directory"""
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / "test-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("# Test Skill")
is_valid, error = validate_skill_directory(skill_dir)
self.assertTrue(is_valid)
self.assertIsNone(error)
def test_nonexistent_directory(self):
"""Test validation of nonexistent directory"""
is_valid, error = validate_skill_directory("/nonexistent/path")
self.assertFalse(is_valid)
self.assertIn("not found", error.lower())
def test_file_instead_of_directory(self):
"""Test validation when path is a file"""
with tempfile.NamedTemporaryFile() as tmpfile:
is_valid, error = validate_skill_directory(tmpfile.name)
self.assertFalse(is_valid)
self.assertIn("not a directory", error.lower())
def test_directory_without_skill_md(self):
"""Test validation of directory without SKILL.md"""
with tempfile.TemporaryDirectory() as tmpdir:
is_valid, error = validate_skill_directory(tmpdir)
self.assertFalse(is_valid)
self.assertIn("SKILL.md not found", error)
class TestValidateZipFile(unittest.TestCase):
"""Test validate_zip_file function"""
def test_valid_zip_file(self):
"""Test validation of valid .zip file"""
with tempfile.TemporaryDirectory() as tmpdir:
zip_path = Path(tmpdir) / "test-skill.zip"
# Create a real zip file
with zipfile.ZipFile(zip_path, 'w') as zf:
zf.writestr("SKILL.md", "# Test")
is_valid, error = validate_zip_file(zip_path)
self.assertTrue(is_valid)
self.assertIsNone(error)
def test_nonexistent_file(self):
"""Test validation of nonexistent file"""
is_valid, error = validate_zip_file("/nonexistent/file.zip")
self.assertFalse(is_valid)
self.assertIn("not found", error.lower())
def test_directory_instead_of_file(self):
"""Test validation when path is a directory"""
with tempfile.TemporaryDirectory() as tmpdir:
is_valid, error = validate_zip_file(tmpdir)
self.assertFalse(is_valid)
self.assertIn("not a file", error.lower())
def test_wrong_extension(self):
"""Test validation of file with wrong extension"""
with tempfile.NamedTemporaryFile(suffix='.txt') as tmpfile:
is_valid, error = validate_zip_file(tmpfile.name)
self.assertFalse(is_valid)
self.assertIn("not a .zip file", error.lower())
class TestPrintUploadInstructions(unittest.TestCase):
"""Test print_upload_instructions function"""
def test_print_upload_instructions_runs(self):
"""Test that print_upload_instructions executes without error"""
with tempfile.TemporaryDirectory() as tmpdir:
zip_path = Path(tmpdir) / "test.zip"
zip_path.write_text("")
# Should not raise exception
try:
print_upload_instructions(zip_path)
except Exception as e:
self.fail(f"print_upload_instructions raised {e}")
def test_print_upload_instructions_accepts_string_path(self):
"""Test print_upload_instructions accepts string path"""
with tempfile.TemporaryDirectory() as tmpdir:
zip_path = str(Path(tmpdir) / "test.zip")
Path(zip_path).write_text("")
try:
print_upload_instructions(zip_path)
except Exception as e:
self.fail(f"print_upload_instructions raised {e}")
if __name__ == '__main__':
unittest.main()