mirror of
https://github.com/tradecatlabs/vibe-coding-cn.git
synced 2026-08-18 21:38:05 +00:00
chore: sync local assets for ddd doc steward
This commit is contained in:
@@ -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"
|
||||
+142
@@ -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'])
|
||||
+206
@@ -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()
|
||||
Reference in New Issue
Block a user