TradingAgents/tests/unit/test_documentation_structur...

811 lines
29 KiB
Python

"""
Test suite for documentation structure validation.
This module tests:
1. Documentation directory structure exists
2. Required documentation files are present
3. Documentation files have valid markdown structure
4. Internal links resolve correctly
5. No sensitive information (API keys, secrets) in docs
6. Documentation follows consistent formatting
7. Code examples in docs are valid
Tests are written TDD-style and will fail until documentation is created.
"""
import os
import re
from pathlib import Path
from typing import List, Set, Tuple
import pytest
pytestmark = pytest.mark.unit
# ============================================================================
# Fixtures and Constants
# ============================================================================
@pytest.fixture
def project_root() -> Path:
"""Get the project root directory."""
# Navigate up from tests/ to project root
return Path(__file__).parent.parent
@pytest.fixture
def docs_root(project_root: Path) -> Path:
"""Get the documentation root directory."""
return project_root / "docs"
# Expected documentation structure
REQUIRED_DOCS_STRUCTURE = {
# Root documentation files
"docs/README.md": "Main documentation index",
"docs/QUICKSTART.md": "Quick start guide",
# Architecture documentation
"docs/architecture/multi-agent-system.md": "Multi-agent system architecture",
"docs/architecture/data-flow.md": "Data flow documentation",
"docs/architecture/llm-integration.md": "LLM integration architecture",
# API documentation
"docs/api/trading-graph.md": "Trading graph API reference",
"docs/api/agents.md": "Agents API reference",
"docs/api/dataflows.md": "Data flows API reference",
# User guides
"docs/guides/adding-new-analyst.md": "Guide for adding new analyst agents",
"docs/guides/adding-llm-provider.md": "Guide for adding new LLM providers",
"docs/guides/configuration.md": "Configuration guide",
# Testing documentation
"docs/testing/README.md": "Testing documentation index",
"docs/testing/running-tests.md": "Guide for running tests",
"docs/testing/writing-tests.md": "Guide for writing tests",
# Development documentation
"docs/development/setup.md": "Development environment setup",
"docs/development/contributing.md": "Contribution guidelines",
}
# Patterns for detecting sensitive information
SENSITIVE_PATTERNS = [
(r"sk-[a-zA-Z0-9]{32,}", "OpenAI API key"),
(r"sk-or-v1-[a-zA-Z0-9]{32,}", "OpenRouter API key"),
(r"sk-ant-[a-zA-Z0-9]{32,}", "Anthropic API key"),
(r"ghp_[a-zA-Z0-9]{36,}", "GitHub Personal Access Token"),
(r"gho_[a-zA-Z0-9]{36,}", "GitHub OAuth Token"),
(r"[a-zA-Z0-9]{40}", "Generic 40-char secret (potential GitHub token)"),
(r"(?i)password\s*[=:]\s*['\"][^'\"]+['\"]", "Hardcoded password"),
(r"(?i)secret\s*[=:]\s*['\"][^'\"]+['\"]", "Hardcoded secret"),
(r"(?i)api[_-]?key\s*[=:]\s*['\"][^'\"]+['\"]", "Hardcoded API key"),
]
# Required markdown headers for each document type
REQUIRED_HEADERS = {
"README.md": ["# ", "## "], # Must have at least h1 and h2
".md": ["# "], # All other markdown files must have at least h1
}
# ============================================================================
# Structure Tests
# ============================================================================
class TestDocumentationStructure:
"""Test that documentation directory structure exists and is complete."""
def test_docs_root_exists(self, docs_root: Path):
"""Test that docs/ directory exists."""
assert docs_root.exists(), (
f"Documentation root directory not found at {docs_root}. "
"Create docs/ directory to start."
)
assert docs_root.is_dir(), f"{docs_root} exists but is not a directory"
def test_all_required_files_exist(self, docs_root: Path):
"""Test that all required documentation files exist."""
missing_files = []
for doc_path, description in REQUIRED_DOCS_STRUCTURE.items():
full_path = docs_root.parent / doc_path
if not full_path.exists():
missing_files.append(f"{doc_path} - {description}")
assert not missing_files, (
f"Missing {len(missing_files)} required documentation files:\n" +
"\n".join(f" - {f}" for f in missing_files)
)
def test_all_required_directories_exist(self, docs_root: Path):
"""Test that all required documentation subdirectories exist."""
required_dirs = [
"architecture",
"api",
"guides",
"testing",
"development",
]
missing_dirs = []
for dir_name in required_dirs:
dir_path = docs_root / dir_name
if not dir_path.exists():
missing_dirs.append(dir_name)
elif not dir_path.is_dir():
missing_dirs.append(f"{dir_name} (exists but not a directory)")
assert not missing_dirs, (
f"Missing required documentation directories:\n" +
"\n".join(f" - docs/{d}" for d in missing_dirs)
)
def test_no_empty_files(self, docs_root: Path):
"""Test that no documentation files are empty."""
empty_files = []
for doc_path in REQUIRED_DOCS_STRUCTURE.keys():
full_path = docs_root.parent / doc_path
if full_path.exists() and full_path.stat().st_size == 0:
empty_files.append(doc_path)
assert not empty_files, (
f"Found {len(empty_files)} empty documentation files:\n" +
"\n".join(f" - {f}" for f in empty_files)
)
# ============================================================================
# Content Validation Tests
# ============================================================================
class TestMarkdownStructure:
"""Test that documentation files have valid markdown structure."""
def test_all_files_have_required_headers(self, docs_root: Path):
"""Test that all markdown files have required header levels."""
files_missing_headers = []
for doc_path in REQUIRED_DOCS_STRUCTURE.keys():
full_path = docs_root.parent / doc_path
if not full_path.exists():
continue # Skip missing files (covered by structure tests)
content = full_path.read_text(encoding="utf-8")
# Determine required headers based on filename
filename = full_path.name
required = REQUIRED_HEADERS.get(filename, REQUIRED_HEADERS[".md"])
missing_headers = []
for header_prefix in required:
if not any(line.startswith(header_prefix) for line in content.splitlines()):
missing_headers.append(header_prefix.strip())
if missing_headers:
files_missing_headers.append(
f"{doc_path}: missing {', '.join(missing_headers)}"
)
assert not files_missing_headers, (
f"Files with missing required headers:\n" +
"\n".join(f" - {f}" for f in files_missing_headers)
)
def test_markdown_has_valid_code_blocks(self, docs_root: Path):
"""Test that markdown code blocks are properly closed."""
files_with_unclosed_blocks = []
for doc_path in REQUIRED_DOCS_STRUCTURE.keys():
full_path = docs_root.parent / doc_path
if not full_path.exists():
continue
content = full_path.read_text(encoding="utf-8")
# Count code block delimiters (```)
code_block_count = content.count("```")
# Code blocks must come in pairs
if code_block_count % 2 != 0:
files_with_unclosed_blocks.append(
f"{doc_path} (found {code_block_count} ``` markers)"
)
assert not files_with_unclosed_blocks, (
f"Files with unclosed code blocks:\n" +
"\n".join(f" - {f}" for f in files_with_unclosed_blocks)
)
def test_readme_has_table_of_contents(self, docs_root: Path):
"""Test that main README has a table of contents."""
readme_path = docs_root / "README.md"
if not readme_path.exists():
pytest.skip("README.md does not exist yet")
content = readme_path.read_text(encoding="utf-8").lower()
# Look for common TOC indicators
has_toc = any(
indicator in content
for indicator in [
"table of contents",
"## contents",
"## overview",
"[architecture]",
"[api reference]",
"[guides]",
]
)
assert has_toc, (
"docs/README.md should include a table of contents or overview section "
"linking to major documentation sections"
)
def test_quickstart_has_installation_steps(self, docs_root: Path):
"""Test that QUICKSTART has installation/setup steps."""
quickstart_path = docs_root / "QUICKSTART.md"
if not quickstart_path.exists():
pytest.skip("QUICKSTART.md does not exist yet")
content = quickstart_path.read_text(encoding="utf-8").lower()
# Look for installation-related content
has_installation = any(
keyword in content
for keyword in [
"install",
"pip install",
"setup",
"requirements",
"getting started",
]
)
assert has_installation, (
"docs/QUICKSTART.md should include installation or setup instructions"
)
# ============================================================================
# Cross-Reference Tests
# ============================================================================
class TestDocumentationLinks:
"""Test that internal documentation links are valid."""
def _extract_markdown_links(self, content: str) -> List[Tuple[str, str]]:
"""Extract all markdown links from content.
Returns:
List of (link_text, link_url) tuples
"""
# Match [text](url) pattern
link_pattern = r'\[([^\]]+)\]\(([^)]+)\)'
return re.findall(link_pattern, content)
def _is_external_link(self, url: str) -> bool:
"""Check if a URL is external (http/https)."""
return url.startswith(('http://', 'https://', 'mailto:'))
def _resolve_relative_link(
self, base_path: Path, link_url: str
) -> Path:
"""Resolve a relative link from a base document path.
Args:
base_path: Path to the document containing the link
link_url: The relative URL from the link
Returns:
Resolved absolute path
"""
# Remove anchor fragments
link_url = link_url.split('#')[0]
if not link_url: # Just an anchor link
return base_path
# Resolve relative to the directory containing the base file
base_dir = base_path.parent
return (base_dir / link_url).resolve()
def test_internal_links_resolve(self, docs_root: Path):
"""Test that all internal documentation links resolve to existing files."""
broken_links = []
for doc_path in REQUIRED_DOCS_STRUCTURE.keys():
full_path = docs_root.parent / doc_path
if not full_path.exists():
continue
content = full_path.read_text(encoding="utf-8")
links = self._extract_markdown_links(content)
for link_text, link_url in links:
# Skip external links
if self._is_external_link(link_url):
continue
# Resolve relative link
target_path = self._resolve_relative_link(full_path, link_url)
# Check if target exists
if not target_path.exists():
broken_links.append(
f"{doc_path}: [{link_text}]({link_url}) -> {target_path}"
)
assert not broken_links, (
f"Found {len(broken_links)} broken internal links:\n" +
"\n".join(f" - {link}" for link in broken_links)
)
def test_readme_links_to_main_sections(self, docs_root: Path):
"""Test that main README links to all major documentation sections."""
readme_path = docs_root / "README.md"
if not readme_path.exists():
pytest.skip("README.md does not exist yet")
content = readme_path.read_text(encoding="utf-8")
links = self._extract_markdown_links(content)
link_urls = [url for _, url in links]
# Required sections that should be linked
required_links = [
("architecture", "Architecture documentation"),
("api", "API documentation"),
("guides", "User guides"),
("testing", "Testing documentation"),
]
missing_links = []
for section, description in required_links:
# Check if any link points to this section
has_link = any(section in url.lower() for url in link_urls)
if not has_link:
missing_links.append(f"{section}/ - {description}")
assert not missing_links, (
f"README.md missing links to major sections:\n" +
"\n".join(f" - {link}" for link in missing_links)
)
# ============================================================================
# Security Tests
# ============================================================================
class TestDocumentationSecurity:
"""Test that documentation contains no sensitive information."""
def test_no_api_keys_in_docs(self, docs_root: Path):
"""Test that documentation files contain no API keys or secrets."""
files_with_secrets = []
for doc_path in REQUIRED_DOCS_STRUCTURE.keys():
full_path = docs_root.parent / doc_path
if not full_path.exists():
continue
content = full_path.read_text(encoding="utf-8")
# Check against all sensitive patterns
for pattern, secret_type in SENSITIVE_PATTERNS:
matches = re.finditer(pattern, content)
for match in matches:
# Skip if it's clearly an example/placeholder
matched_text = match.group(0)
if self._is_placeholder(matched_text):
continue
files_with_secrets.append(
f"{doc_path}: Found {secret_type}: {matched_text[:20]}..."
)
assert not files_with_secrets, (
f"Found potential secrets in documentation:\n" +
"\n".join(f" - {s}" for s in files_with_secrets) +
"\n\nUse placeholders like 'your-api-key-here' or 'sk-xxx' instead."
)
def _is_placeholder(self, text: str) -> bool:
"""Check if text is likely a placeholder rather than real secret.
Args:
text: The potentially sensitive text
Returns:
True if text appears to be a placeholder
"""
placeholder_indicators = [
"xxx",
"your-",
"example",
"placeholder",
"replace",
"insert",
"paste",
"...",
]
text_lower = text.lower()
return any(indicator in text_lower for indicator in placeholder_indicators)
def test_env_examples_use_placeholders(self, docs_root: Path):
"""Test that .env examples in docs use placeholders, not real values."""
files_with_real_values = []
# Pattern to match environment variable assignments
env_var_pattern = r'^([A-Z_]+)=(.+)$'
for doc_path in REQUIRED_DOCS_STRUCTURE.keys():
full_path = docs_root.parent / doc_path
if not full_path.exists():
continue
content = full_path.read_text(encoding="utf-8")
# Find code blocks that might contain .env examples
code_blocks = re.findall(r'```(?:bash|shell|env)?\n(.*?)```', content, re.DOTALL)
for block in code_blocks:
for line in block.splitlines():
match = re.match(env_var_pattern, line.strip())
if match:
var_name, var_value = match.groups()
# Check if value looks like a real key
if (
var_name.endswith(('_KEY', '_TOKEN', '_SECRET'))
and not self._is_placeholder(var_value)
and len(var_value) > 20 # Real keys are typically longer
):
files_with_real_values.append(
f"{doc_path}: {var_name}={var_value[:20]}..."
)
assert not files_with_real_values, (
f"Found environment variables with potentially real values:\n" +
"\n".join(f" - {v}" for v in files_with_real_values) +
"\n\nUse placeholders in documentation."
)
# ============================================================================
# Code Example Tests
# ============================================================================
class TestCodeExamples:
"""Test that code examples in documentation are valid."""
def _extract_code_blocks(self, content: str, language: str = None) -> List[str]:
"""Extract code blocks from markdown content.
Args:
content: Markdown content
language: Optional language filter (e.g., 'python')
Returns:
List of code block contents
"""
if language:
pattern = rf'```{language}\n(.*?)```'
else:
pattern = r'```(?:\w+)?\n(.*?)```'
return re.findall(pattern, content, re.DOTALL)
def test_python_code_examples_have_valid_syntax(self, docs_root: Path):
"""Test that Python code examples have valid syntax."""
files_with_syntax_errors = []
for doc_path in REQUIRED_DOCS_STRUCTURE.keys():
full_path = docs_root.parent / doc_path
if not full_path.exists():
continue
content = full_path.read_text(encoding="utf-8")
python_blocks = self._extract_code_blocks(content, "python")
for i, code_block in enumerate(python_blocks):
try:
# Try to compile the code (doesn't execute it)
compile(code_block, f"{doc_path}:block{i}", "exec")
except SyntaxError as e:
files_with_syntax_errors.append(
f"{doc_path} (block {i}): {e.msg} at line {e.lineno}"
)
assert not files_with_syntax_errors, (
f"Found Python code blocks with syntax errors:\n" +
"\n".join(f" - {err}" for err in files_with_syntax_errors)
)
def test_code_examples_use_project_imports(self, docs_root: Path):
"""Test that code examples use correct import paths."""
files_with_wrong_imports = []
# Expected import prefix for this project
expected_prefix = "tradingagents"
for doc_path in REQUIRED_DOCS_STRUCTURE.keys():
full_path = docs_root.parent / doc_path
if not full_path.exists():
continue
content = full_path.read_text(encoding="utf-8")
python_blocks = self._extract_code_blocks(content, "python")
for i, code_block in enumerate(python_blocks):
# Look for import statements
import_lines = [
line for line in code_block.splitlines()
if line.strip().startswith(('import ', 'from '))
]
for line in import_lines:
# Check if it's importing from this project
if 'tradingagents' in line.lower() and expected_prefix not in line:
files_with_wrong_imports.append(
f"{doc_path} (block {i}): {line.strip()}"
)
assert not files_with_wrong_imports, (
f"Found code examples with incorrect import paths:\n" +
"\n".join(f" - {imp}" for imp in files_with_wrong_imports) +
f"\n\nAll imports should use '{expected_prefix}' prefix."
)
# ============================================================================
# Content Quality Tests
# ============================================================================
class TestDocumentationQuality:
"""Test documentation quality and completeness."""
def test_architecture_docs_describe_key_components(self, docs_root: Path):
"""Test that architecture docs describe key system components."""
arch_path = docs_root / "architecture" / "multi-agent-system.md"
if not arch_path.exists():
pytest.skip("Architecture documentation does not exist yet")
content = arch_path.read_text(encoding="utf-8").lower()
# Key components that should be documented
required_components = [
"agent",
"graph",
"state",
"workflow",
]
missing_components = []
for component in required_components:
if component not in content:
missing_components.append(component)
assert not missing_components, (
f"Architecture documentation missing key components:\n" +
"\n".join(f" - {c}" for c in missing_components)
)
def test_api_docs_include_code_examples(self, docs_root: Path):
"""Test that API documentation includes code examples."""
api_files = [
"docs/api/trading-graph.md",
"docs/api/agents.md",
"docs/api/dataflows.md",
]
files_without_examples = []
for doc_path in api_files:
full_path = docs_root.parent / doc_path
if not full_path.exists():
continue
content = full_path.read_text(encoding="utf-8")
# Check for code blocks
has_code = "```" in content
if not has_code:
files_without_examples.append(doc_path)
assert not files_without_examples, (
f"API documentation files missing code examples:\n" +
"\n".join(f" - {f}" for f in files_without_examples)
)
def test_guides_have_step_by_step_instructions(self, docs_root: Path):
"""Test that guides include step-by-step instructions."""
guide_files = [
"docs/guides/adding-new-analyst.md",
"docs/guides/adding-llm-provider.md",
"docs/guides/configuration.md",
]
files_without_steps = []
for doc_path in guide_files:
full_path = docs_root.parent / doc_path
if not full_path.exists():
continue
content = full_path.read_text(encoding="utf-8").lower()
# Look for step indicators
has_steps = any(
indicator in content
for indicator in [
"step 1",
"1.",
"first,",
"## setup",
"## installation",
]
)
if not has_steps:
files_without_steps.append(doc_path)
assert not files_without_steps, (
f"Guide files missing step-by-step instructions:\n" +
"\n".join(f" - {f}" for f in files_without_steps)
)
def test_contributing_guide_exists_and_complete(self, docs_root: Path):
"""Test that contributing guide exists and covers key topics."""
contrib_path = docs_root / "development" / "contributing.md"
if not contrib_path.exists():
pytest.skip("Contributing guide does not exist yet")
content = contrib_path.read_text(encoding="utf-8").lower()
# Key topics for contributing guide
required_topics = [
("pull request", "Pull request guidelines"),
("test", "Testing requirements"),
("code", "Code standards"),
]
missing_topics = []
for keyword, topic in required_topics:
if keyword not in content:
missing_topics.append(topic)
assert not missing_topics, (
f"Contributing guide missing key topics:\n" +
"\n".join(f" - {t}" for t in missing_topics)
)
# ============================================================================
# Integration Tests
# ============================================================================
class TestDocumentationIntegration:
"""Test documentation integrates properly with project."""
def test_docs_referenced_in_main_readme(self, project_root: Path):
"""Test that main project README references the documentation."""
main_readme = project_root / "README.md"
if not main_readme.exists():
pytest.skip("Main README.md does not exist")
content = main_readme.read_text(encoding="utf-8").lower()
# Should reference docs directory
has_docs_reference = any(
ref in content
for ref in [
"docs/",
"documentation",
"[docs]",
"see docs",
]
)
assert has_docs_reference, (
"Main README.md should reference the docs/ directory or documentation"
)
def test_all_public_apis_documented(self, project_root: Path, docs_root: Path):
"""Test that all public APIs have corresponding documentation."""
# This is a basic check - could be enhanced with AST parsing
api_doc_path = docs_root / "api"
if not api_doc_path.exists():
pytest.skip("API documentation directory does not exist yet")
# Check that major modules have API docs
major_modules = [
("graph/trading_graph.py", "trading-graph.md"),
("agents/", "agents.md"),
("dataflows/", "dataflows.md"),
]
missing_docs = []
for module_path, expected_doc in major_modules:
module_full_path = project_root / "tradingagents" / module_path
doc_full_path = api_doc_path / expected_doc
if module_full_path.exists() and not doc_full_path.exists():
missing_docs.append(f"{expected_doc} for {module_path}")
assert not missing_docs, (
f"Missing API documentation for modules:\n" +
"\n".join(f" - {d}" for d in missing_docs)
)
# ============================================================================
# Performance Tests
# ============================================================================
class TestDocumentationSize:
"""Test that documentation files are reasonable in size."""
def test_no_excessively_large_files(self, docs_root: Path):
"""Test that no documentation files are excessively large."""
max_size_kb = 500 # 500 KB max per file
large_files = []
for doc_path in REQUIRED_DOCS_STRUCTURE.keys():
full_path = docs_root.parent / doc_path
if not full_path.exists():
continue
size_kb = full_path.stat().st_size / 1024
if size_kb > max_size_kb:
large_files.append(f"{doc_path}: {size_kb:.1f} KB")
assert not large_files, (
f"Found excessively large documentation files (>{max_size_kb} KB):\n" +
"\n".join(f" - {f}" for f in large_files) +
f"\n\nConsider splitting large files into smaller documents."
)
def test_reasonable_line_length(self, docs_root: Path):
"""Test that documentation lines are reasonable length."""
max_line_length = 120
files_with_long_lines = []
for doc_path in REQUIRED_DOCS_STRUCTURE.keys():
full_path = docs_root.parent / doc_path
if not full_path.exists():
continue
content = full_path.read_text(encoding="utf-8")
long_lines = []
for i, line in enumerate(content.splitlines(), 1):
# Skip code blocks and URLs
if line.strip().startswith(('```', 'http://', 'https://')):
continue
if len(line) > max_line_length:
long_lines.append(i)
if long_lines:
files_with_long_lines.append(
f"{doc_path}: lines {long_lines[:3]}{'...' if len(long_lines) > 3 else ''}"
)
assert not files_with_long_lines, (
f"Found files with lines exceeding {max_line_length} characters:\n" +
"\n".join(f" - {f}" for f in files_with_long_lines) +
f"\n\nConsider breaking long lines for better readability."
)