mirror of
https://github.com/mims-harvard/ToolUniverse.git
synced 2026-09-19 07:31:47 +08:00
976 lines
34 KiB
Python
976 lines
34 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Automatically find and test all tool configurations using test_new_tools.py
|
|
|
|
This script:
|
|
1. Scans src/tooluniverse/data/ for all JSON config files
|
|
2. Extracts unique tool name prefixes/patterns
|
|
3. Runs test_new_tools.py on each pattern
|
|
4. Aggregates results and generates a comprehensive report
|
|
|
|
Usage:
|
|
python scripts/test_all_tools.py [--verbose] [--fail-fast] [--parallel]
|
|
|
|
Options:
|
|
--verbose Show detailed output for each test
|
|
--fail-fast Stop testing after first failure
|
|
--parallel Run tests in parallel (faster but less readable output)
|
|
--output FILE Save report to file (default: TOOL_TEST_REPORT.md)
|
|
--json-output Save atomic JSON progress (default: TOOL_TEST_RESULTS.json)
|
|
--resume Reuse completed categories when source/config still match
|
|
--skip TOOLS Skip specific tools (comma-separated, e.g., "agentic,finder,tool")
|
|
--skip-pattern Skip tools matching pattern (e.g., "agentic*" or "*discovery*")
|
|
--skip-remote Skip remote tools that require external servers
|
|
--skip-mcp Skip MCP tools that require MCP servers
|
|
"""
|
|
|
|
import argparse
|
|
import concurrent.futures
|
|
import fnmatch
|
|
import hashlib
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from collections import defaultdict
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
|
|
# run_test_for_pattern spawns an independent subprocess per pattern (I/O-bound:
|
|
# mostly waiting on live HTTP calls), so a moderate thread pool speeds up
|
|
# --parallel substantially without hammering any single upstream API too hard.
|
|
DEFAULT_PARALLEL_WORKERS = 10
|
|
|
|
CHECKPOINT_SCHEMA_VERSION = 2
|
|
RESULT_STATES = (
|
|
"passed",
|
|
"failed",
|
|
"schema_error",
|
|
"no_tests",
|
|
"timeout",
|
|
"error",
|
|
)
|
|
FAILURE_STATES = {"failed", "schema_error", "timeout", "error"}
|
|
|
|
|
|
def classify_result(result: Dict[str, Any]) -> str:
|
|
"""Return one unambiguous terminal state for a pattern result."""
|
|
if result.get("timed_out"):
|
|
return "timeout"
|
|
if result.get("error"):
|
|
return "error"
|
|
if result.get("failed", 0) > 0:
|
|
return "failed"
|
|
if result.get("schema_invalid", 0) > 0:
|
|
return "schema_error"
|
|
if result.get("exit_code", 0) != 0:
|
|
return "error"
|
|
if result.get("tests_run", 0) == 0:
|
|
return "no_tests"
|
|
if result.get("passed", 0) < result.get("tests_run", 0):
|
|
return "error"
|
|
return "passed"
|
|
|
|
|
|
def normalize_result(result: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Copy a result and attach its canonical state."""
|
|
normalized = dict(result)
|
|
normalized["state"] = classify_result(normalized)
|
|
return normalized
|
|
|
|
|
|
def result_is_failure(result: Dict[str, Any]) -> bool:
|
|
return normalize_result(result)["state"] in FAILURE_STATES
|
|
|
|
|
|
def _utc_now() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def compute_sweep_fingerprint(
|
|
repo_root: Path,
|
|
expected_patterns: List[str],
|
|
config_patterns: Dict[str, List[Path]],
|
|
) -> str:
|
|
"""Hash the local inputs that determine a tool sweep's results."""
|
|
input_paths = {
|
|
repo_root / "pyproject.toml",
|
|
repo_root / "scripts" / "test_all_tools.py",
|
|
repo_root / "scripts" / "test_new_tools.py",
|
|
}
|
|
input_paths.update((repo_root / "src" / "tooluniverse").rglob("*.py"))
|
|
for pattern in expected_patterns:
|
|
input_paths.update(config_patterns.get(pattern, []))
|
|
|
|
digest = hashlib.sha256()
|
|
digest.update(
|
|
json.dumps(expected_patterns, separators=(",", ":")).encode("utf-8")
|
|
)
|
|
for path in sorted(input_paths, key=lambda item: item.as_posix()):
|
|
try:
|
|
relative_path = path.relative_to(repo_root)
|
|
except ValueError:
|
|
relative_path = path
|
|
digest.update(b"\0path\0")
|
|
digest.update(relative_path.as_posix().encode("utf-8"))
|
|
digest.update(b"\0content\0")
|
|
if path.is_file():
|
|
digest.update(path.read_bytes())
|
|
else:
|
|
digest.update(b"<missing>")
|
|
return digest.hexdigest()
|
|
|
|
|
|
def write_checkpoint(
|
|
output_path: Path,
|
|
results: Dict[str, Dict[str, Any]],
|
|
expected_patterns: List[str],
|
|
sweep_fingerprint: str,
|
|
started_at: str,
|
|
complete: bool,
|
|
) -> None:
|
|
"""Atomically persist machine-readable progress after each category."""
|
|
normalized_results = {
|
|
pattern: normalize_result(result) for pattern, result in sorted(results.items())
|
|
}
|
|
status_counts = {state: 0 for state in RESULT_STATES}
|
|
for result in normalized_results.values():
|
|
status_counts[result["state"]] += 1
|
|
|
|
payload = {
|
|
"schema_version": CHECKPOINT_SCHEMA_VERSION,
|
|
"started_at": started_at,
|
|
"updated_at": _utc_now(),
|
|
"complete": complete,
|
|
"expected_patterns": expected_patterns,
|
|
"sweep_fingerprint": sweep_fingerprint,
|
|
"completed_patterns": len(normalized_results),
|
|
"status_counts": status_counts,
|
|
"results": normalized_results,
|
|
}
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary_path = output_path.with_name(f"{output_path.name}.tmp")
|
|
temporary_path.write_text(
|
|
json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
|
)
|
|
temporary_path.replace(output_path)
|
|
|
|
|
|
def load_checkpoint(
|
|
output_path: Path,
|
|
expected_patterns: List[str],
|
|
sweep_fingerprint: str,
|
|
) -> Dict[str, Dict[str, Any]]:
|
|
"""Load and validate results from a prior machine-readable checkpoint."""
|
|
if not output_path.exists():
|
|
return {}
|
|
payload = json.loads(output_path.read_text(encoding="utf-8"))
|
|
if payload.get("schema_version") != CHECKPOINT_SCHEMA_VERSION:
|
|
raise ValueError(
|
|
f"Unsupported checkpoint schema: {payload.get('schema_version')!r}"
|
|
)
|
|
checkpoint_patterns = payload.get("expected_patterns")
|
|
if checkpoint_patterns != expected_patterns:
|
|
raise ValueError(
|
|
"Checkpoint test scope does not match the current selected patterns"
|
|
)
|
|
if payload.get("sweep_fingerprint") != sweep_fingerprint:
|
|
raise ValueError(
|
|
"Checkpoint source/config fingerprint does not match the current tree"
|
|
)
|
|
results = payload.get("results")
|
|
if not isinstance(results, dict):
|
|
raise ValueError("Checkpoint field 'results' must be an object")
|
|
|
|
validated = {}
|
|
for pattern, result in results.items():
|
|
if not isinstance(pattern, str) or not isinstance(result, dict):
|
|
raise ValueError("Checkpoint results must map pattern names to objects")
|
|
normalized = normalize_result(result)
|
|
if result.get("state") != normalized["state"]:
|
|
raise ValueError(f"Checkpoint state mismatch for pattern {pattern!r}")
|
|
validated[pattern] = normalized
|
|
return validated
|
|
|
|
|
|
def find_all_tool_configs(data_dir: Path) -> List[Path]:
|
|
"""Find all JSON configuration files."""
|
|
json_files = list(data_dir.glob("*.json"))
|
|
json_files.extend(data_dir.glob("**/*.json"))
|
|
|
|
# Remove duplicates and sort
|
|
json_files = sorted(set(json_files))
|
|
|
|
# Filter out non-tool files if needed
|
|
json_files = [f for f in json_files if not f.name.startswith('.')]
|
|
|
|
# Skip broken_apis/ — those configs document non-functional upstream APIs
|
|
# and the tools are not registered at runtime, so testing them produces
|
|
# only false-positive "Tool not found" failures.
|
|
json_files = [f for f in json_files if "broken_apis" not in f.parts]
|
|
|
|
return json_files
|
|
|
|
|
|
def extract_tool_patterns(config_files: List[Path]) -> Dict[str, List[Path]]:
|
|
"""Extract tool name patterns from config files.
|
|
|
|
Groups files by their base name pattern (e.g., 'fda', 'ncbi', 'cbioportal')
|
|
"""
|
|
patterns = defaultdict(list)
|
|
|
|
for config_file in config_files:
|
|
# Get base name without extension
|
|
base_name = config_file.stem
|
|
|
|
# Extract pattern (typically the prefix before _tools).
|
|
if '_tools' in base_name:
|
|
pattern = base_name.replace('_tools', '')
|
|
else:
|
|
# Use the full stem for non-_tools files. Splitting on the first
|
|
# underscore produced over-broad patterns: e.g. tool_page_index.json
|
|
# and tool_discovery_agents.json both yielded "tool", and downstream
|
|
# test_new_tools.py globs *tool* — which matches every *_tools.json
|
|
# (500+ files) and times that "category" out on every run.
|
|
pattern = base_name
|
|
|
|
patterns[pattern].append(config_file)
|
|
|
|
return patterns
|
|
|
|
|
|
def load_config_stats(config_file: Path) -> Dict[str, Any]:
|
|
"""Load basic statistics from a config file."""
|
|
try:
|
|
with open(config_file, 'r') as f:
|
|
content = json.load(f)
|
|
|
|
if isinstance(content, list):
|
|
tools = content
|
|
elif isinstance(content, dict):
|
|
tools = [content]
|
|
else:
|
|
return {"error": "Invalid format"}
|
|
|
|
tool_count = len(tools)
|
|
example_count = sum(len(t.get("test_examples", [])) for t in tools)
|
|
|
|
return {
|
|
"tool_count": tool_count,
|
|
"example_count": example_count,
|
|
"tools": [t.get("name", "UNNAMED") for t in tools]
|
|
}
|
|
except Exception as e:
|
|
return {"error": str(e)}
|
|
|
|
|
|
def run_test_for_pattern(
|
|
pattern: str,
|
|
repo_root: Path,
|
|
verbose: bool = False,
|
|
fail_fast: bool = False
|
|
) -> Dict[str, Any]:
|
|
"""Run test_new_tools.py for a specific pattern."""
|
|
cmd = [
|
|
sys.executable,
|
|
str(repo_root / "scripts" / "test_new_tools.py"),
|
|
pattern
|
|
]
|
|
|
|
if verbose:
|
|
cmd.append("-v")
|
|
if fail_fast:
|
|
cmd.append("--fail-fast")
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
cmd,
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=repo_root,
|
|
timeout=300 # 5 minute timeout per pattern
|
|
)
|
|
|
|
# Parse output to extract statistics
|
|
output = result.stdout
|
|
stats = parse_test_output(output)
|
|
stats["exit_code"] = result.returncode
|
|
stats["raw_output"] = output
|
|
stats["stderr"] = result.stderr
|
|
|
|
return normalize_result(stats)
|
|
|
|
except subprocess.TimeoutExpired:
|
|
return normalize_result(
|
|
{
|
|
"error": "Timeout after 5 minutes",
|
|
"timed_out": True,
|
|
"exit_code": -1,
|
|
}
|
|
)
|
|
except Exception as e:
|
|
return normalize_result({"error": str(e), "exit_code": -1})
|
|
|
|
|
|
def _format_result_status(result: Dict[str, Any]) -> str:
|
|
"""One-line human-readable status for a single pattern's test result."""
|
|
state = normalize_result(result)["state"]
|
|
if state == "timeout":
|
|
return "TIMEOUT: exceeded 5 minutes"
|
|
if state == "error":
|
|
return f"ERROR: {result.get('error', 'incomplete or invalid test output')}"
|
|
if state == "failed":
|
|
return f"FAILED: {result['failed']} test failure(s)"
|
|
if state == "schema_error":
|
|
return f"SCHEMA ERROR: {result['schema_invalid']} invalid result(s)"
|
|
if state == "no_tests":
|
|
return "NO TESTS: category has no executable examples"
|
|
return f"PASSED: {result.get('tests_run', 0)} test(s)"
|
|
|
|
|
|
def run_all_patterns(
|
|
patterns: List[str],
|
|
repo_root: Path,
|
|
verbose: bool = False,
|
|
fail_fast: bool = False,
|
|
parallel: bool = False,
|
|
max_workers: int = DEFAULT_PARALLEL_WORKERS,
|
|
initial_results: Optional[Dict[str, Dict[str, Any]]] = None,
|
|
on_result: Optional[Callable[[str, Dict[str, Dict[str, Any]]], None]] = None,
|
|
) -> Dict[str, Dict[str, Any]]:
|
|
"""Run run_test_for_pattern for every pattern, sequentially or in parallel.
|
|
|
|
Returns a dict of pattern -> result, in the same shape either way.
|
|
Progress is printed as each result becomes available; in parallel mode
|
|
that's completion order, not pattern order, since patterns run
|
|
concurrently.
|
|
|
|
--fail-fast semantics under --parallel: patterns already running when a
|
|
failure is detected are allowed to finish (they're independent
|
|
subprocesses already in flight), but no further not-yet-started patterns
|
|
are submitted. This is the closest parallel analogue to the sequential
|
|
"stop after first failure" behavior.
|
|
"""
|
|
total = len(patterns)
|
|
results = {
|
|
pattern: normalize_result(result)
|
|
for pattern, result in (initial_results or {}).items()
|
|
}
|
|
pending_patterns = [pattern for pattern in patterns if pattern not in results]
|
|
|
|
if not parallel:
|
|
for pattern in pending_patterns:
|
|
i = len(results) + 1
|
|
print(f"[{i}/{total}] Testing {pattern}...", end=" ", flush=True)
|
|
result = normalize_result(
|
|
run_test_for_pattern(
|
|
pattern, repo_root, verbose=verbose, fail_fast=fail_fast
|
|
)
|
|
)
|
|
results[pattern] = result
|
|
if on_result:
|
|
on_result(pattern, results)
|
|
print(_format_result_status(result))
|
|
if fail_fast and result_is_failure(result):
|
|
print("\n⚠️ Stopping due to --fail-fast")
|
|
break
|
|
return results
|
|
|
|
workers = max(1, min(max_workers, len(pending_patterns))) if pending_patterns else 1
|
|
print(
|
|
f"⚡ Running {len(pending_patterns)} pending pattern(s) in parallel "
|
|
f"(workers={workers}, total={total})"
|
|
)
|
|
print()
|
|
|
|
stop_requested = False
|
|
completed = len(results)
|
|
if not pending_patterns:
|
|
return results
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
|
future_to_pattern = {
|
|
executor.submit(
|
|
run_test_for_pattern, pattern, repo_root, verbose, fail_fast
|
|
): pattern
|
|
for pattern in pending_patterns
|
|
}
|
|
for future in concurrent.futures.as_completed(future_to_pattern):
|
|
pattern = future_to_pattern[future]
|
|
if future.cancelled():
|
|
# Cancelled while still queued (fail-fast) -- never ran, so
|
|
# it's simply omitted from results rather than recorded as
|
|
# a failure.
|
|
continue
|
|
completed += 1
|
|
result = normalize_result(future.result())
|
|
results[pattern] = result
|
|
if on_result:
|
|
on_result(pattern, results)
|
|
print(f"[{completed}/{total}] {pattern}: {_format_result_status(result)}", flush=True)
|
|
|
|
if fail_fast and result_is_failure(result) and not stop_requested:
|
|
stop_requested = True
|
|
print("\n⚠️ --fail-fast: not starting any remaining not-yet-started patterns "
|
|
"(already-running patterns will still finish)")
|
|
for f in future_to_pattern:
|
|
f.cancel()
|
|
|
|
return results
|
|
|
|
|
|
# Maps a label found in test output to the stats key it populates.
|
|
# All values are parsed as int except "Duration" which is float.
|
|
_OUTPUT_LABELS: List[Tuple[str, str]] = [
|
|
("Tools Tested:", "tools_tested"),
|
|
("Tests Run:", "tests_run"),
|
|
("Passed:", "passed"),
|
|
("Failed:", "failed"),
|
|
("404 Errors:", "errors_404"),
|
|
("Other Errors:", "errors_other"),
|
|
("Schema Valid:", "schema_valid"),
|
|
("Schema Invalid:", "schema_invalid"),
|
|
]
|
|
|
|
|
|
def parse_test_output(output: str) -> Dict[str, Any]:
|
|
"""Parse test output to extract statistics."""
|
|
stats: Dict[str, Any] = {key: 0 for _, key in _OUTPUT_LABELS}
|
|
stats["duration"] = 0.0
|
|
|
|
for line in output.split('\n'):
|
|
line = line.strip()
|
|
|
|
for label, key in _OUTPUT_LABELS:
|
|
if label in line:
|
|
try:
|
|
# Take the first token after the colon (handles "Passed: 12 (100.0%)")
|
|
stats[key] = int(line.split(':')[1].strip().split()[0])
|
|
except (ValueError, IndexError):
|
|
pass
|
|
break
|
|
else:
|
|
# Duration is a float with a trailing 's'
|
|
if "Duration:" in line:
|
|
try:
|
|
stats["duration"] = float(line.split(':')[1].strip().rstrip('s'))
|
|
except (ValueError, IndexError):
|
|
pass
|
|
|
|
return stats
|
|
|
|
|
|
def generate_report(
|
|
results: Dict[str, Dict[str, Any]],
|
|
config_patterns: Dict[str, List[Path]],
|
|
total_duration: float,
|
|
output_file: str = "TOOL_TEST_REPORT.md",
|
|
skipped_tools: set = None
|
|
) -> str:
|
|
"""Generate a markdown report of all test results."""
|
|
if skipped_tools is None:
|
|
skipped_tools = set()
|
|
|
|
# Calculate totals
|
|
total_tools = sum(r.get("tools_tested", 0) for r in results.values())
|
|
total_tests = sum(r.get("tests_run", 0) for r in results.values())
|
|
total_passed = sum(r.get("passed", 0) for r in results.values())
|
|
total_failed = sum(r.get("failed", 0) for r in results.values())
|
|
total_404 = sum(r.get("errors_404", 0) for r in results.values())
|
|
total_other_errors = sum(r.get("errors_other", 0) for r in results.values())
|
|
total_schema_valid = sum(r.get("schema_valid", 0) for r in results.values())
|
|
total_schema_invalid = sum(r.get("schema_invalid", 0) for r in results.values())
|
|
normalized_results = {
|
|
pattern: normalize_result(result) for pattern, result in results.items()
|
|
}
|
|
state_counts = {state: 0 for state in RESULT_STATES}
|
|
for result in normalized_results.values():
|
|
state_counts[result["state"]] += 1
|
|
|
|
pass_rate = (total_passed / total_tests * 100) if total_tests > 0 else 0
|
|
|
|
lines = []
|
|
lines.append("# ToolUniverse - Comprehensive Tool Test Report")
|
|
lines.append("")
|
|
lines.append(f"**Generated**: {time.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
lines.append(f"**Total Duration**: {total_duration:.2f}s")
|
|
|
|
if skipped_tools:
|
|
skipped_list = ", ".join(sorted(skipped_tools))
|
|
lines.append(f"**Skipped**: {len(skipped_tools)} tool(s) - {skipped_list}")
|
|
|
|
lines.append("")
|
|
lines.append("---")
|
|
lines.append("")
|
|
lines.append("## Executive Summary")
|
|
lines.append("")
|
|
lines.append(f"- **Tool Categories Tested**: {len(results)}")
|
|
lines.append(f"- **Total Tools**: {total_tools}")
|
|
lines.append(f"- **Total Test Examples**: {total_tests}")
|
|
lines.append(f"- **Pass Rate**: {pass_rate:.1f}%")
|
|
lines.append("")
|
|
lines.append("### Overall Statistics")
|
|
lines.append("")
|
|
lines.append("| Metric | Count |")
|
|
lines.append("|--------|-------|")
|
|
lines.append(f"| ✅ Passed | {total_passed} ({pass_rate:.1f}%) |")
|
|
lines.append(f"| ❌ Failed | {total_failed} |")
|
|
lines.append(f"| 🔍 404 Errors | {total_404} |")
|
|
lines.append(f"| ⚠️ Other Errors | {total_other_errors} |")
|
|
lines.append(f"| ✓ Schema Valid | {total_schema_valid} |")
|
|
lines.append(f"| ✗ Schema Invalid | {total_schema_invalid} |")
|
|
for state in RESULT_STATES:
|
|
lines.append(f"| Categories: {state} | {state_counts[state]} |")
|
|
lines.append("")
|
|
lines.append("---")
|
|
lines.append("")
|
|
lines.append("## Results by Tool Category")
|
|
lines.append("")
|
|
|
|
# Sort by category name
|
|
for pattern in sorted(results.keys()):
|
|
result = normalized_results[pattern]
|
|
config_files = config_patterns.get(pattern, [])
|
|
|
|
state = result["state"]
|
|
lines.append(f"### {state.upper()} - {pattern}")
|
|
lines.append("")
|
|
lines.append(f"**Config Files**: {', '.join(f.name for f in config_files)}")
|
|
lines.append(f"**Status**: {state}")
|
|
lines.append("")
|
|
|
|
if result.get("error"):
|
|
lines.append(f"**Error**: {result['error']}")
|
|
lines.append("")
|
|
else:
|
|
tools_tested = result.get("tools_tested", 0)
|
|
tests_run = result.get("tests_run", 0)
|
|
passed = result.get("passed", 0)
|
|
failed = result.get("failed", 0)
|
|
|
|
pattern_pass_rate = (passed / tests_run * 100) if tests_run > 0 else 0
|
|
|
|
lines.append("| Metric | Value |")
|
|
lines.append("|--------|-------|")
|
|
lines.append(f"| Tools | {tools_tested} |")
|
|
lines.append(f"| Tests | {tests_run} |")
|
|
lines.append(f"| Passed | {passed} ({pattern_pass_rate:.1f}%) |")
|
|
lines.append(f"| Failed | {failed} |")
|
|
|
|
if result.get("errors_404", 0) > 0:
|
|
lines.append(f"| 404 Errors | {result['errors_404']} |")
|
|
if result.get("errors_other", 0) > 0:
|
|
lines.append(f"| Other Errors | {result['errors_other']} |")
|
|
|
|
lines.append(f"| Schema Valid | {result.get('schema_valid', 0)} |")
|
|
lines.append(f"| Schema Invalid | {result.get('schema_invalid', 0)} |")
|
|
lines.append(f"| Duration | {result.get('duration', 0):.2f}s |")
|
|
lines.append("")
|
|
|
|
lines.append("---")
|
|
lines.append("")
|
|
lines.append("## Issues Requiring Attention")
|
|
lines.append("")
|
|
|
|
# List failures
|
|
issues_found = False
|
|
|
|
patterns_without_tests = [
|
|
pattern
|
|
for pattern, result in normalized_results.items()
|
|
if result["state"] == "no_tests"
|
|
]
|
|
if patterns_without_tests:
|
|
issues_found = True
|
|
lines.append("### Categories Without Executable Tests")
|
|
lines.append("")
|
|
for pattern in sorted(patterns_without_tests):
|
|
lines.append(f"- **{pattern}**")
|
|
lines.append("")
|
|
|
|
patterns_with_runtime_errors = [
|
|
pattern
|
|
for pattern, result in normalized_results.items()
|
|
if result["state"] in {"timeout", "error"}
|
|
]
|
|
if patterns_with_runtime_errors:
|
|
issues_found = True
|
|
lines.append("### Incomplete Category Runs")
|
|
lines.append("")
|
|
for pattern in sorted(patterns_with_runtime_errors):
|
|
result = normalized_results[pattern]
|
|
detail = result.get("error", result["state"])
|
|
lines.append(f"- **{pattern}** ({result['state']}): {detail}")
|
|
lines.append("")
|
|
|
|
# 404 Errors
|
|
patterns_with_404 = [p for p, r in results.items() if r.get("errors_404", 0) > 0]
|
|
if patterns_with_404:
|
|
issues_found = True
|
|
lines.append("### 🔍 404 Errors Detected")
|
|
lines.append("")
|
|
lines.append("These tools are returning 404 errors (API endpoints may have changed):")
|
|
lines.append("")
|
|
for pattern in sorted(patterns_with_404):
|
|
count = results[pattern]["errors_404"]
|
|
lines.append(f"- **{pattern}**: {count} 404 error(s)")
|
|
lines.append("")
|
|
|
|
# Schema Mismatches
|
|
patterns_with_schema_issues = [
|
|
p for p, r in results.items()
|
|
if r.get("schema_invalid", 0) > 0
|
|
]
|
|
if patterns_with_schema_issues:
|
|
issues_found = True
|
|
lines.append("### ⚠️ Schema Validation Issues")
|
|
lines.append("")
|
|
lines.append("These tools have schema mismatches:")
|
|
lines.append("")
|
|
for pattern in sorted(patterns_with_schema_issues):
|
|
count = results[pattern]["schema_invalid"]
|
|
lines.append(f"- **{pattern}**: {count} schema mismatch(es)")
|
|
lines.append("")
|
|
|
|
# Other Failures
|
|
patterns_with_failures = [
|
|
p for p, r in results.items()
|
|
if r.get("failed", 0) > 0 and not r.get("errors_404", 0)
|
|
]
|
|
if patterns_with_failures:
|
|
issues_found = True
|
|
lines.append("### ❌ Other Failures")
|
|
lines.append("")
|
|
lines.append("These tools have other failures:")
|
|
lines.append("")
|
|
for pattern in sorted(patterns_with_failures):
|
|
count = results[pattern]["failed"]
|
|
lines.append(f"- **{pattern}**: {count} failure(s)")
|
|
lines.append("")
|
|
|
|
if not issues_found:
|
|
lines.append("✨ **No issues found!** All tools are working correctly.")
|
|
lines.append("")
|
|
|
|
lines.append("---")
|
|
lines.append("")
|
|
lines.append("## Next Steps")
|
|
lines.append("")
|
|
|
|
if patterns_with_404:
|
|
lines.append("1. **Fix 404 Errors**: Review API documentation for tools with 404 errors")
|
|
lines.append(" - Check if API endpoints have changed")
|
|
lines.append(" - Update tool configurations accordingly")
|
|
lines.append("")
|
|
|
|
if patterns_with_schema_issues:
|
|
lines.append("2. **Fix Schema Mismatches**: Update return_schema definitions")
|
|
lines.append(" - Review actual API responses")
|
|
lines.append(" - Update JSON schemas to match current API")
|
|
lines.append("")
|
|
|
|
if patterns_with_failures:
|
|
lines.append("3. **Investigate Failures**: Review error messages and fix issues")
|
|
lines.append(" - Check API keys and authentication")
|
|
lines.append(" - Verify network connectivity")
|
|
lines.append(" - Review error messages in detail")
|
|
lines.append("")
|
|
|
|
if not issues_found:
|
|
lines.append("✅ All tools validated successfully! No action needed.")
|
|
lines.append("")
|
|
|
|
lines.append("---")
|
|
lines.append("")
|
|
lines.append(f"**Report Generated**: {time.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
lines.append("**Command**: `python scripts/test_all_tools.py`")
|
|
|
|
report = "\n".join(lines)
|
|
|
|
# Write to file
|
|
repo_root = Path(__file__).parent.parent
|
|
output_path = repo_root / output_file
|
|
with open(output_path, 'w') as f:
|
|
f.write(report)
|
|
|
|
return str(output_path)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Test all ToolUniverse tool configurations"
|
|
)
|
|
parser.add_argument(
|
|
"-v", "--verbose",
|
|
action="store_true",
|
|
help="Show detailed output"
|
|
)
|
|
parser.add_argument(
|
|
"--fail-fast",
|
|
action="store_true",
|
|
help="Stop after first failure"
|
|
)
|
|
parser.add_argument(
|
|
"--parallel",
|
|
action="store_true",
|
|
help=f"Run tests in parallel across patterns (up to {DEFAULT_PARALLEL_WORKERS} workers)"
|
|
)
|
|
parser.add_argument(
|
|
"--output",
|
|
default="TOOL_TEST_REPORT.md",
|
|
help="Output report filename"
|
|
)
|
|
parser.add_argument(
|
|
"--json-output",
|
|
default="TOOL_TEST_RESULTS.json",
|
|
help="Machine-readable checkpoint filename",
|
|
)
|
|
parser.add_argument(
|
|
"--resume",
|
|
action="store_true",
|
|
help="Reuse completed categories when source/config still match --json-output",
|
|
)
|
|
parser.add_argument(
|
|
"--pattern",
|
|
help="Test only specific pattern (e.g., 'fda', 'cbioportal')"
|
|
)
|
|
parser.add_argument(
|
|
"--skip",
|
|
help="Skip specific tools (comma-separated, e.g., 'agentic,finder,tool')"
|
|
)
|
|
parser.add_argument(
|
|
"--skip-pattern",
|
|
help="Skip tools matching wildcard pattern (e.g., 'agentic*' or '*discovery*')"
|
|
)
|
|
parser.add_argument(
|
|
"--skip-remote",
|
|
action="store_true",
|
|
help="Skip remote tools that require external servers"
|
|
)
|
|
parser.add_argument(
|
|
"--skip-mcp",
|
|
action="store_true",
|
|
help="Skip MCP (Model Context Protocol) tools that require MCP servers"
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Setup paths
|
|
repo_root = Path(__file__).parent.parent
|
|
data_dir = repo_root / "src" / "tooluniverse" / "data"
|
|
|
|
if not data_dir.exists():
|
|
print(f"❌ Error: Data directory not found at {data_dir}")
|
|
sys.exit(1)
|
|
|
|
print("=" * 70)
|
|
print("ToolUniverse - Comprehensive Tool Testing")
|
|
print("=" * 70)
|
|
print()
|
|
|
|
# Find all config files
|
|
print("🔍 Scanning for tool configurations...")
|
|
config_files = find_all_tool_configs(data_dir)
|
|
print(f"✅ Found {len(config_files)} configuration files")
|
|
|
|
# Extract patterns
|
|
print("📊 Analyzing tool patterns...")
|
|
config_patterns = extract_tool_patterns(config_files)
|
|
|
|
# Build skip list
|
|
skip_tools = set()
|
|
if args.skip:
|
|
skip_tools.update(tool.strip() for tool in args.skip.split(','))
|
|
print(f"📝 Skipping tools: {', '.join(sorted(skip_tools))}")
|
|
|
|
# Skip remote tools if requested
|
|
if args.skip_remote:
|
|
remote_tools = [
|
|
'boltz', 'depmap', 'expert_feedback', 'immune_compass',
|
|
'pinnacle', 'transcriptformer', 'uspto_downloader',
|
|
# Add external API services that require remote servers
|
|
'blast', # NCBI BLAST API
|
|
'simbad', # SIMBAD astronomical database API
|
|
'uspto', # USPTO Patent API (in addition to uspto_downloader)
|
|
]
|
|
skip_tools.update(remote_tools)
|
|
print(f"🌐 Skipping remote tools (require external servers): {', '.join(sorted(remote_tools))}")
|
|
|
|
# Skip MCP tools if requested
|
|
if args.skip_mcp:
|
|
mcp_tools = ['boltz_mcp_loader', 'mcp_client_example', 'mcpautoloadertool']
|
|
skip_tools.update(mcp_tools)
|
|
print(f"🔌 Skipping MCP tools (require MCP servers): {', '.join(sorted(mcp_tools))}")
|
|
|
|
# Apply skip pattern
|
|
if args.skip_pattern:
|
|
pattern_to_skip = args.skip_pattern.strip()
|
|
tools_to_skip = [
|
|
tool for tool in config_patterns.keys()
|
|
if fnmatch.fnmatch(tool, pattern_to_skip)
|
|
]
|
|
skip_tools.update(tools_to_skip)
|
|
if tools_to_skip:
|
|
print(f"📝 Skipping tools matching '{pattern_to_skip}': {', '.join(sorted(tools_to_skip))}")
|
|
|
|
# Filter by pattern if specified
|
|
if args.pattern:
|
|
filtered = {
|
|
k: v for k, v in config_patterns.items()
|
|
if args.pattern.lower() in k.lower()
|
|
}
|
|
if not filtered:
|
|
print(f"❌ No patterns found matching '{args.pattern}'")
|
|
sys.exit(1)
|
|
config_patterns = filtered
|
|
print(f"✅ Filtered to {len(config_patterns)} pattern(s) matching '{args.pattern}'")
|
|
|
|
# Apply skip list
|
|
if skip_tools:
|
|
before_count = len(config_patterns)
|
|
config_patterns = {
|
|
k: v for k, v in config_patterns.items()
|
|
if k not in skip_tools
|
|
}
|
|
skipped_count = before_count - len(config_patterns)
|
|
if skipped_count > 0:
|
|
print(f"⏭️ Skipped {skipped_count} tool(s)")
|
|
|
|
if not config_patterns:
|
|
print("❌ No tools remaining after filtering")
|
|
sys.exit(1)
|
|
|
|
print(f"✅ Found {len(config_patterns)} unique tool patterns to test")
|
|
print()
|
|
print("🧪 Running tests...")
|
|
print()
|
|
|
|
# Run tests for each pattern
|
|
start_time = time.time()
|
|
started_at = _utc_now()
|
|
expected_patterns = sorted(config_patterns.keys())
|
|
sweep_fingerprint = compute_sweep_fingerprint(
|
|
repo_root, expected_patterns, config_patterns
|
|
)
|
|
checkpoint_path = Path(args.json_output)
|
|
if not checkpoint_path.is_absolute():
|
|
checkpoint_path = repo_root / checkpoint_path
|
|
|
|
resumed_results: Dict[str, Dict[str, Any]] = {}
|
|
if args.resume:
|
|
try:
|
|
checkpoint_results = load_checkpoint(
|
|
checkpoint_path, expected_patterns, sweep_fingerprint
|
|
)
|
|
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
print(f"❌ Cannot resume from {checkpoint_path}: {exc}")
|
|
sys.exit(1)
|
|
resumed_results = {
|
|
pattern: result
|
|
for pattern, result in checkpoint_results.items()
|
|
if pattern in config_patterns
|
|
}
|
|
print(f"Resuming with {len(resumed_results)} completed pattern(s)")
|
|
|
|
write_checkpoint(
|
|
checkpoint_path,
|
|
resumed_results,
|
|
expected_patterns,
|
|
sweep_fingerprint,
|
|
started_at,
|
|
complete=False,
|
|
)
|
|
|
|
def save_progress(
|
|
_pattern: str, current_results: Dict[str, Dict[str, Any]]
|
|
) -> None:
|
|
write_checkpoint(
|
|
checkpoint_path,
|
|
current_results,
|
|
expected_patterns,
|
|
sweep_fingerprint,
|
|
started_at,
|
|
complete=False,
|
|
)
|
|
|
|
results = run_all_patterns(
|
|
expected_patterns,
|
|
repo_root,
|
|
verbose=args.verbose,
|
|
fail_fast=args.fail_fast,
|
|
parallel=args.parallel,
|
|
initial_results=resumed_results,
|
|
on_result=save_progress,
|
|
)
|
|
|
|
total_duration = time.time() - start_time
|
|
checkpoint_complete = set(results) == set(expected_patterns)
|
|
write_checkpoint(
|
|
checkpoint_path,
|
|
results,
|
|
expected_patterns,
|
|
sweep_fingerprint,
|
|
started_at,
|
|
complete=checkpoint_complete,
|
|
)
|
|
|
|
print()
|
|
print("=" * 70)
|
|
print("Generating report...")
|
|
|
|
# Generate report
|
|
report_path = generate_report(
|
|
results,
|
|
config_patterns,
|
|
total_duration,
|
|
args.output,
|
|
skip_tools
|
|
)
|
|
|
|
print(f"✅ Report saved to: {report_path}")
|
|
print(f"✅ JSON checkpoint saved to: {checkpoint_path}")
|
|
print()
|
|
|
|
# Print summary
|
|
total_tests = sum(r.get("tests_run", 0) for r in results.values())
|
|
total_passed = sum(r.get("passed", 0) for r in results.values())
|
|
total_failed = sum(r.get("failed", 0) for r in results.values())
|
|
normalized_results = [normalize_result(result) for result in results.values()]
|
|
state_counts = {state: 0 for state in RESULT_STATES}
|
|
for result in normalized_results:
|
|
state_counts[result["state"]] += 1
|
|
|
|
print("=" * 70)
|
|
print("SUMMARY")
|
|
print("=" * 70)
|
|
print(f"Patterns Tested: {len(results)}")
|
|
print(f"Total Tests: {total_tests}")
|
|
print(f"Passed: {total_passed}")
|
|
print(f"Failed: {total_failed}")
|
|
print(
|
|
"Category states: "
|
|
+ ", ".join(f"{state}={state_counts[state]}" for state in RESULT_STATES)
|
|
)
|
|
print(f"Duration: {total_duration:.2f}s")
|
|
if skip_tools:
|
|
print(f"Skipped: {len(skip_tools)} tool(s)")
|
|
print("=" * 70)
|
|
|
|
# Runtime, assertion, and schema failures are unsuccessful. A no-test
|
|
# category is reported as a coverage gap without turning a health canary
|
|
# into a runtime failure.
|
|
failed_categories = [
|
|
result for result in normalized_results if result["state"] in FAILURE_STATES
|
|
]
|
|
if failed_categories or not checkpoint_complete:
|
|
sys.exit(1)
|
|
if state_counts["no_tests"]:
|
|
print("\nSweep completed with categories that have no executable tests.")
|
|
else:
|
|
print("\n✨ All tests passed!")
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|