From c935cec7b6e8e416458c3d91dd59f0830bd16487 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sun, 11 Jan 2026 00:28:12 -0700 Subject: [PATCH] Add MS Office-themed test dashboard with interactive reporting - Self-contained HTML dashboard with MS Office 365 design - pytest plugin captures inputs, outputs, and errors per test - Unified orchestrator runs pytest + torture tests together - Test files persisted in reports/test_files/ with relative links - GitHub Actions workflow with PR comments and job summaries - Makefile with convenient commands (test, view-dashboard, etc.) - Works offline with embedded JSON data (no CORS issues) --- .github/workflows/test-dashboard.yml | 124 ++++ ADVANCED_TOOLS_PLAN.md | 190 +++++ Makefile | 127 ++++ QUICKSTART_DASHBOARD.md | 114 +++ reports/README.md | 209 ++++++ reports/pytest_results.json | 18 + reports/test_dashboard.html | 963 ++++++++++++++++++++++++++ reports/test_files/test_data.xlsx | Bin 0 -> 5068 bytes reports/test_files/test_document.docx | Bin 0 -> 37045 bytes reports/test_results.json | 154 ++++ run_dashboard_tests.py | 507 ++++++++++++++ test_mcp_tools.py | 97 +++ tests/conftest.py | 4 +- tests/pytest_dashboard_plugin.py | 194 ++++++ view_dashboard.sh | 22 + 15 files changed, 2721 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/test-dashboard.yml create mode 100644 ADVANCED_TOOLS_PLAN.md create mode 100644 Makefile create mode 100644 QUICKSTART_DASHBOARD.md create mode 100644 reports/README.md create mode 100644 reports/pytest_results.json create mode 100644 reports/test_dashboard.html create mode 100644 reports/test_files/test_data.xlsx create mode 100644 reports/test_files/test_document.docx create mode 100644 reports/test_results.json create mode 100755 run_dashboard_tests.py create mode 100644 test_mcp_tools.py create mode 100644 tests/pytest_dashboard_plugin.py create mode 100755 view_dashboard.sh diff --git a/.github/workflows/test-dashboard.yml b/.github/workflows/test-dashboard.yml new file mode 100644 index 0000000..6ac200d --- /dev/null +++ b/.github/workflows/test-dashboard.yml @@ -0,0 +1,124 @@ +name: Test Dashboard + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main ] + workflow_dispatch: # Allow manual trigger + +jobs: + test-and-dashboard: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + + - name: Install UV + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + + - name: Install dependencies + run: | + uv sync --dev + + - name: Run tests with dashboard generation + run: | + python run_dashboard_tests.py + continue-on-error: true # Generate dashboard even if tests fail + + - name: Extract test summary + id: test_summary + run: | + TOTAL=$(jq '.summary.total' reports/test_results.json) + PASSED=$(jq '.summary.passed' reports/test_results.json) + FAILED=$(jq '.summary.failed' reports/test_results.json) + SKIPPED=$(jq '.summary.skipped' reports/test_results.json) + PASS_RATE=$(jq '.summary.pass_rate' reports/test_results.json) + + echo "total=$TOTAL" >> $GITHUB_OUTPUT + echo "passed=$PASSED" >> $GITHUB_OUTPUT + echo "failed=$FAILED" >> $GITHUB_OUTPUT + echo "skipped=$SKIPPED" >> $GITHUB_OUTPUT + echo "pass_rate=$PASS_RATE" >> $GITHUB_OUTPUT + + - name: Upload test dashboard + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-dashboard + path: reports/ + retention-days: 30 + + - name: Comment PR with results + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const total = ${{ steps.test_summary.outputs.total }}; + const passed = ${{ steps.test_summary.outputs.passed }}; + const failed = ${{ steps.test_summary.outputs.failed }}; + const skipped = ${{ steps.test_summary.outputs.skipped }}; + const passRate = ${{ steps.test_summary.outputs.pass_rate }}; + + const statusEmoji = failed > 0 ? 'โŒ' : 'โœ…'; + const passRateEmoji = passRate >= 90 ? '๐ŸŽ‰' : passRate >= 70 ? '๐Ÿ‘' : 'โš ๏ธ'; + + const comment = `## ${statusEmoji} Test Results + + | Metric | Value | + |--------|-------| + | Total Tests | ${total} | + | โœ… Passed | ${passed} | + | โŒ Failed | ${failed} | + | โญ๏ธ Skipped | ${skipped} | + | ${passRateEmoji} Pass Rate | ${passRate.toFixed(1)}% | + + ### ๐Ÿ“Š Interactive Dashboard + + [Download test dashboard artifact](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) + + The dashboard includes: + - Detailed test results with inputs/outputs + - Error tracebacks for failed tests + - Category breakdown (Word, Excel, PowerPoint, etc.) + - Interactive filtering and search + + **To view**: Download the artifact, extract, and open \`test_dashboard.html\` in your browser. + `; + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: comment + }); + + - name: Create job summary + if: always() + run: | + echo "# ๐Ÿ“Š Test Dashboard Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "## Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- **Total**: ${{ steps.test_summary.outputs.total }} tests" >> $GITHUB_STEP_SUMMARY + echo "- **โœ… Passed**: ${{ steps.test_summary.outputs.passed }}" >> $GITHUB_STEP_SUMMARY + echo "- **โŒ Failed**: ${{ steps.test_summary.outputs.failed }}" >> $GITHUB_STEP_SUMMARY + echo "- **โญ๏ธ Skipped**: ${{ steps.test_summary.outputs.skipped }}" >> $GITHUB_STEP_SUMMARY + echo "- **๐Ÿ“ˆ Pass Rate**: ${{ steps.test_summary.outputs.pass_rate }}%" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "## ๐ŸŒ Dashboard" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Download the \`test-dashboard\` artifact to view the interactive HTML dashboard." >> $GITHUB_STEP_SUMMARY + + - name: Fail job if tests failed + if: steps.test_summary.outputs.failed > 0 + run: exit 1 diff --git a/ADVANCED_TOOLS_PLAN.md b/ADVANCED_TOOLS_PLAN.md new file mode 100644 index 0000000..43af168 --- /dev/null +++ b/ADVANCED_TOOLS_PLAN.md @@ -0,0 +1,190 @@ +# Advanced MCP Office Tools Enhancement Plan + +## Current Status +- โœ… Basic text extraction +- โœ… Image extraction +- โœ… Metadata extraction +- โœ… Format detection +- โœ… Document health analysis +- โœ… Word-to-Markdown conversion + +## Missing Advanced Features by Library + +### ๐Ÿ“Š Excel Tools (openpyxl + pandas + xlsxwriter) + +#### Data Analysis & Manipulation +- `analyze_excel_data` - Statistical analysis, data types, missing values +- `create_pivot_table` - Generate pivot tables with aggregations +- `excel_data_validation` - Set dropdown lists, number ranges, date constraints +- `excel_conditional_formatting` - Apply color scales, data bars, icon sets +- `excel_formula_analysis` - Extract, validate, and analyze formulas +- `excel_chart_creation` - Create charts (bar, line, pie, scatter, etc.) +- `excel_worksheet_operations` - Add/delete/rename sheets, copy data +- `excel_merge_spreadsheets` - Combine multiple Excel files intelligently + +#### Advanced Excel Features +- `excel_named_ranges` - Create and manage named ranges +- `excel_data_filtering` - Apply AutoFilter and advanced filters +- `excel_cell_styling` - Font, borders, alignment, number formats +- `excel_protection` - Password protect sheets/workbooks +- `excel_hyperlinks` - Add/extract hyperlinks from cells +- `excel_comments_notes` - Add/extract cell comments and notes + +### ๐Ÿ“ Word Tools (python-docx + mammoth) + +#### Document Structure & Layout +- `word_extract_tables` - Extract tables with styling and structure +- `word_extract_headers_footers` - Get headers/footers from all sections +- `word_extract_toc` - Extract table of contents with page numbers +- `word_document_structure` - Analyze heading hierarchy and outline +- `word_page_layout_analysis` - Margins, orientation, columns, page breaks +- `word_section_analysis` - Different sections with different formatting + +#### Content Management +- `word_find_replace_advanced` - Pattern-based find/replace with formatting +- `word_extract_comments` - Get all comments with author and timestamps +- `word_extract_tracked_changes` - Get revision history and changes +- `word_extract_hyperlinks` - Extract all hyperlinks with context +- `word_extract_footnotes_endnotes` - Get footnotes and endnotes +- `word_style_analysis` - Analyze and extract custom styles + +#### Document Generation +- `word_create_document` - Create new Word documents from templates +- `word_merge_documents` - Combine multiple Word documents +- `word_insert_content` - Add text, tables, images at specific locations +- `word_apply_formatting` - Apply consistent formatting across content + +### ๐ŸŽฏ PowerPoint Tools (python-pptx) + +#### Presentation Analysis +- `ppt_extract_slide_content` - Get text, images, shapes from each slide +- `ppt_extract_speaker_notes` - Get presenter notes for all slides +- `ppt_slide_layout_analysis` - Analyze slide layouts and master slides +- `ppt_extract_animations` - Get animation sequences and timing +- `ppt_presentation_structure` - Outline view with slide hierarchy + +#### Content Management +- `ppt_slide_operations` - Add/delete/reorder slides +- `ppt_master_slide_analysis` - Extract master slide templates +- `ppt_shape_analysis` - Analyze text boxes, shapes, SmartArt +- `ppt_media_extraction` - Extract embedded videos and audio +- `ppt_hyperlink_analysis` - Extract slide transitions and hyperlinks + +#### Presentation Generation +- `ppt_create_presentation` - Create new presentations from data +- `ppt_slide_generation` - Generate slides from templates and content +- `ppt_chart_integration` - Add charts and graphs to slides + +### ๐Ÿ”„ Cross-Format Tools + +#### Document Conversion +- `convert_excel_to_word_table` - Convert spreadsheet data to Word tables +- `convert_word_table_to_excel` - Extract Word tables to Excel format +- `extract_presentation_data_to_excel` - Convert slide content to spreadsheet +- `create_report_from_data` - Generate Word reports from Excel data + +#### Advanced Analysis +- `cross_document_comparison` - Compare content across different formats +- `document_summarization` - AI-powered document summaries +- `extract_key_metrics` - Find numbers, dates, important data across docs +- `document_relationship_analysis` - Find references between documents + +### ๐ŸŽจ Advanced Image & Media Tools + +#### Image Processing (Pillow integration) +- `advanced_image_extraction` - Extract with OCR, face detection, object recognition +- `image_format_conversion` - Convert between formats with optimization +- `image_metadata_analysis` - EXIF data, creation dates, camera info +- `image_quality_analysis` - Resolution, compression, clarity metrics + +#### Media Analysis +- `extract_embedded_objects` - Get all embedded files (PDFs, other Office docs) +- `analyze_document_media` - Comprehensive media inventory +- `optimize_document_media` - Reduce file sizes by optimizing images + +### ๐Ÿ“ˆ Data Science Integration + +#### Analytics Tools (pandas + numpy integration) +- `statistical_analysis` - Mean, median, correlations, distributions +- `time_series_analysis` - Trend analysis on date-based data +- `data_cleaning_suggestions` - Identify data quality issues +- `export_for_analysis` - Export to JSON, CSV, Parquet for data science + +#### Visualization Preparation +- `prepare_chart_data` - Format data for visualization libraries +- `generate_chart_configs` - Create chart.js, plotly, matplotlib configs +- `data_validation_rules` - Suggest data validation based on content analysis + +### ๐Ÿ” Security & Compliance Tools + +#### Document Security +- `analyze_document_security` - Check for sensitive information +- `redact_sensitive_content` - Remove/mask PII, financial data +- `document_audit_trail` - Track document creation, modification history +- `compliance_checking` - Check against various compliance standards + +#### Access Control +- `extract_permissions` - Get document protection and sharing settings +- `password_analysis` - Check password protection strength +- `digital_signature_verification` - Verify document signatures + +### ๐Ÿ”ง Automation & Workflow Tools + +#### Batch Operations +- `batch_document_processing` - Process multiple documents with same operations +- `template_application` - Apply templates to multiple documents +- `bulk_format_conversion` - Convert multiple files between formats +- `automated_report_generation` - Generate reports from data templates + +#### Integration Tools +- `export_to_cms` - Export content to various CMS formats +- `api_integration_prep` - Prepare data for API consumption +- `database_export` - Export structured data to database formats +- `email_template_generation` - Create email templates from documents + +## Implementation Priority + +### Phase 1: High-Impact Excel Tools ๐Ÿ”ฅ +1. `analyze_excel_data` - Immediate value for data analysis +2. `create_pivot_table` - High-demand business feature +3. `excel_chart_creation` - Visual data representation +4. `excel_conditional_formatting` - Professional spreadsheet styling + +### Phase 2: Advanced Word Processing ๐Ÿ“„ +1. `word_extract_tables` - Critical for data extraction +2. `word_document_structure` - Essential for navigation +3. `word_find_replace_advanced` - Powerful content management +4. `word_create_document` - Document generation capability + +### Phase 3: PowerPoint & Cross-Format ๐ŸŽฏ +1. `ppt_extract_slide_content` - Complete presentation analysis +2. `convert_excel_to_word_table` - Cross-format workflows +3. `ppt_create_presentation` - Automated presentation generation + +### Phase 4: Advanced Analytics & Security ๐Ÿš€ +1. `statistical_analysis` - Data science integration +2. `analyze_document_security` - Compliance and security +3. `batch_document_processing` - Automation workflows + +## Technical Implementation Notes + +### Library Extensions Needed +- **openpyxl**: Chart creation, conditional formatting, data validation +- **python-docx**: Advanced styling, document manipulation +- **python-pptx**: Slide generation, animation analysis +- **pandas**: Statistical functions, data analysis tools +- **Pillow**: Advanced image processing features + +### New Dependencies to Consider +- **matplotlib/plotly**: Chart generation +- **numpy**: Statistical calculations +- **python-dateutil**: Advanced date parsing +- **regex**: Advanced pattern matching +- **cryptography**: Document security analysis + +### Architecture Considerations +- Maintain mixin pattern for clean organization +- Add result caching for expensive operations +- Implement progress tracking for batch operations +- Add streaming support for large data processing +- Maintain backward compatibility with existing tools \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..99cbabd --- /dev/null +++ b/Makefile @@ -0,0 +1,127 @@ +# Makefile for MCP Office Tools +# Provides convenient commands for testing, development, and dashboard generation + +.PHONY: help test test-dashboard test-pytest test-torture view-dashboard clean install format lint type-check + +# Default target - show help +help: + @echo "MCP Office Tools - Available Commands" + @echo "======================================" + @echo "" + @echo "Testing & Dashboard:" + @echo " make test - Run all tests with dashboard generation" + @echo " make test-dashboard - Alias for 'make test'" + @echo " make test-pytest - Run only pytest tests" + @echo " make test-torture - Run only torture tests" + @echo " make view-dashboard - Open test dashboard in browser" + @echo "" + @echo "Development:" + @echo " make install - Install project with dev dependencies" + @echo " make format - Format code with black" + @echo " make lint - Lint code with ruff" + @echo " make type-check - Run type checking with mypy" + @echo " make clean - Clean temporary files and caches" + @echo "" + @echo "Examples:" + @echo " make test # Run everything and open dashboard" + @echo " make test-pytest # Quick pytest-only run" + @echo " make view-dashboard # View existing results" + +# Run all tests and generate unified dashboard +test: test-dashboard + +test-dashboard: + @echo "๐Ÿงช Running comprehensive test suite with dashboard generation..." + @python run_dashboard_tests.py + +# Run only pytest tests +test-pytest: + @echo "๐Ÿงช Running pytest test suite..." + @uv run pytest --dashboard-output=reports/test_results.json -v + +# Run only torture tests +test-torture: + @echo "๐Ÿ”ฅ Running torture tests..." + @uv run python torture_test.py + +# View test dashboard in browser +view-dashboard: + @echo "๐Ÿ“Š Opening test dashboard..." + @./view_dashboard.sh + +# Install project with dev dependencies +install: + @echo "๐Ÿ“ฆ Installing MCP Office Tools with dev dependencies..." + @uv sync --dev + @echo "โœ… Installation complete!" + +# Format code with black +format: + @echo "๐ŸŽจ Formatting code with black..." + @uv run black src/ tests/ examples/ + @echo "โœ… Formatting complete!" + +# Lint code with ruff +lint: + @echo "๐Ÿ” Linting code with ruff..." + @uv run ruff check src/ tests/ examples/ + @echo "โœ… Linting complete!" + +# Type checking with mypy +type-check: + @echo "๐Ÿ”Ž Running type checks with mypy..." + @uv run mypy src/ + @echo "โœ… Type checking complete!" + +# Clean temporary files and caches +clean: + @echo "๐Ÿงน Cleaning temporary files and caches..." + @find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + @find . -type d -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true + @find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true + @find . -type d -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true + @find . -type d -name ".ruff_cache" -exec rm -rf {} + 2>/dev/null || true + @find . -type f -name "*.pyc" -delete 2>/dev/null || true + @rm -rf dist/ build/ 2>/dev/null || true + @echo "โœ… Cleanup complete!" + +# Run full quality checks (format, lint, type-check, test) +check: format lint type-check test + @echo "โœ… All quality checks passed!" + +# Quick development test cycle (no dashboard) +quick-test: + @echo "โšก Quick test run (no dashboard)..." + @uv run pytest -v --tb=short + +# Coverage report +coverage: + @echo "๐Ÿ“Š Generating coverage report..." + @uv run pytest --cov=mcp_office_tools --cov-report=html --cov-report=term + @echo "โœ… Coverage report generated at htmlcov/index.html" + +# Run server in development mode +dev: + @echo "๐Ÿš€ Starting MCP Office Tools server..." + @uv run mcp-office-tools + +# Build distribution packages +build: + @echo "๐Ÿ“ฆ Building distribution packages..." + @uv build + @echo "โœ… Build complete! Packages in dist/" + +# Show project info +info: + @echo "MCP Office Tools - Project Information" + @echo "=======================================" + @echo "" + @echo "Project: mcp-office-tools" + @echo "Version: $(shell grep '^version' pyproject.toml | cut -d'"' -f2)" + @echo "Python: $(shell python --version)" + @echo "UV: $(shell uv --version 2>/dev/null || echo 'not installed')" + @echo "" + @echo "Directory: $(shell pwd)" + @echo "Tests: $(shell find tests -name 'test_*.py' | wc -l) test files" + @echo "Source files: $(shell find src -name '*.py' | wc -l) Python files" + @echo "" diff --git a/QUICKSTART_DASHBOARD.md b/QUICKSTART_DASHBOARD.md new file mode 100644 index 0000000..5da6509 --- /dev/null +++ b/QUICKSTART_DASHBOARD.md @@ -0,0 +1,114 @@ +# Test Dashboard - Quick Start + +## TL;DR - 3 Commands to Get Started + +```bash +# 1. Run all tests and generate dashboard +python run_dashboard_tests.py + +# 2. View dashboard (alternative) +make test + +# 3. Open existing dashboard +./view_dashboard.sh +``` + +## What You Get + +A beautiful, interactive HTML test dashboard that looks like Microsoft Office 365: + +- **Summary Cards** - Pass/fail stats at a glance +- **Interactive Filters** - Search and filter by category/status +- **Detailed Views** - Expand any test to see inputs, outputs, errors +- **MS Office Theme** - Professional, familiar design + +## File Locations + +``` +reports/ +โ”œโ”€โ”€ test_dashboard.html โ† Open this in browser +โ””โ”€โ”€ test_results.json โ† Test data (auto-generated) +``` + +## Common Tasks + +### Run Tests +```bash +make test # Run everything +make test-pytest # Pytest only +python torture_test.py # Torture tests only +``` + +### View Results +```bash +./view_dashboard.sh # Auto-open in browser +make view-dashboard # Same thing +open reports/test_dashboard.html # Manual +``` + +### Customize +```bash +# Edit colors +vim reports/test_dashboard.html # Edit CSS variables + +# Change categorization +vim tests/pytest_dashboard_plugin.py # Edit _categorize_test() +``` + +## Color Reference + +- Word: Blue `#2B579A` +- Excel: Green `#217346` +- PowerPoint: Orange `#D24726` +- Pass: Green `#107C10` +- Fail: Red `#D83B01` + +## Example Output + +``` +$ python run_dashboard_tests.py + +====================================================================== +๐Ÿงช Running pytest test suite... +====================================================================== +... pytest output ... + +====================================================================== +๐Ÿ”ฅ Running torture tests... +====================================================================== +... torture test output ... + +====================================================================== +๐Ÿ“Š TEST DASHBOARD SUMMARY +====================================================================== + +โœ… Passed: 12 +โŒ Failed: 2 +โญ๏ธ Skipped: 1 + +๐Ÿ“ˆ Pass Rate: 80.0% +โฑ๏ธ Duration: 45.12s + +๐Ÿ“„ Results saved to: reports/test_results.json +๐ŸŒ Dashboard: reports/test_dashboard.html +====================================================================== + +๐ŸŒ Opening dashboard in browser... +``` + +## Troubleshooting + +**Dashboard shows no results?** +โ†’ Run tests first: `python run_dashboard_tests.py` + +**Can't open in browser?** +โ†’ Manually open: `file:///path/to/reports/test_dashboard.html` + +**Tests not categorized correctly?** +โ†’ Edit `tests/pytest_dashboard_plugin.py`, function `_categorize_test()` + +## More Info + +- Full docs: `TEST_DASHBOARD.md` +- Implementation details: `DASHBOARD_SUMMARY.md` +- Dashboard features: `reports/README.md` diff --git a/reports/README.md b/reports/README.md new file mode 100644 index 0000000..a9a3903 --- /dev/null +++ b/reports/README.md @@ -0,0 +1,209 @@ +# MCP Office Tools - Test Dashboard + +Beautiful, interactive HTML dashboard for viewing test results with Microsoft Office-inspired design. + +## Features + +- **MS Office Theme**: Modern Microsoft Office 365-inspired design with Fluent Design elements +- **Category-based Organization**: Separate results by Word, Excel, PowerPoint, Universal, and Server categories +- **Interactive Filtering**: Search and filter tests by name, category, or status +- **Detailed Test Views**: Expand any test to see inputs, outputs, errors, and tracebacks +- **Real-time Statistics**: Pass/fail rates, duration metrics, and category breakdowns +- **Self-contained**: Works offline with no external dependencies + +## Quick Start + +### Run All Tests with Dashboard + +```bash +# Run both pytest and torture tests, generate dashboard, and open in browser +python run_dashboard_tests.py +``` + +### Run Only Pytest Tests + +```bash +# Run pytest with dashboard plugin +pytest -p tests.pytest_dashboard_plugin --dashboard-output=reports/test_results.json + +# Open dashboard +open reports/test_dashboard.html # macOS +xdg-open reports/test_dashboard.html # Linux +start reports/test_dashboard.html # Windows +``` + +### View Existing Results + +Simply open `reports/test_dashboard.html` in your browser. The dashboard will automatically load `test_results.json` from the same directory. + +## Dashboard Components + +### Summary Cards + +Four main summary cards show: +- **Total Tests**: Number of test cases executed +- **Passed**: Successful tests with pass rate and progress bar +- **Failed**: Tests with errors +- **Duration**: Total execution time + +### Filter Controls + +- **Search Box**: Filter tests by name, module, or category +- **Category Filters**: Filter by Word, Excel, PowerPoint, Universal, or Server +- **Status Filters**: Show only passed, failed, or skipped tests + +### Test Results + +Each test displays: +- **Status Icon**: Visual indicator (โœ“ pass, โœ— fail, โŠ˜ skip) +- **Test Name**: Descriptive test name +- **Category Badge**: Color-coded category (Word=blue, Excel=green, PowerPoint=orange) +- **Duration**: Execution time in milliseconds +- **Expandable Details**: Click to view inputs, outputs, errors, and full traceback + +## File Structure + +``` +reports/ +โ”œโ”€โ”€ test_dashboard.html # Main dashboard (open this in browser) +โ”œโ”€โ”€ test_results.json # Generated test data (auto-loaded by dashboard) +โ”œโ”€โ”€ pytest_results.json # Intermediate pytest results +โ””โ”€โ”€ README.md # This file +``` + +## Design Philosophy + +### Microsoft Office Color Palette + +- **Word Blue**: `#2B579A` - Used for Word-related tests +- **Excel Green**: `#217346` - Used for Excel-related tests +- **PowerPoint Orange**: `#D24726` - Used for PowerPoint-related tests +- **Primary Blue**: `#0078D4` - Accent color (Fluent Design) + +### Fluent Design Principles + +- **Subtle Shadows**: Cards have soft shadows for depth +- **Rounded Corners**: 8px border radius for modern look +- **Hover Effects**: Interactive elements respond to mouse hover +- **Typography**: Segoe UI font family (Office standard) +- **Clean Layout**: Generous whitespace and clear hierarchy + +## Integration with CI/CD + +### GitHub Actions Example + +```yaml +- name: Run Tests with Dashboard + run: | + python run_dashboard_tests.py + +- name: Upload Test Dashboard + uses: actions/upload-artifact@v3 + with: + name: test-dashboard + path: reports/ +``` + +### GitLab CI Example + +```yaml +test_dashboard: + script: + - python run_dashboard_tests.py + artifacts: + paths: + - reports/ + expire_in: 1 week +``` + +## Customization + +### Change Dashboard Output Location + +```bash +# Custom output path for pytest +pytest -p tests.pytest_dashboard_plugin --dashboard-output=custom/path/results.json +``` + +### Modify Colors + +Edit the CSS variables in `test_dashboard.html`: + +```css +:root { + --word-blue: #2B579A; + --excel-green: #217346; + --powerpoint-orange: #D24726; + /* ... more colors ... */ +} +``` + +## Troubleshooting + +### Dashboard shows "No Test Results Found" + +- Ensure `test_results.json` exists in the `reports/` directory +- Run tests first: `python run_dashboard_tests.py` +- Check browser console for JSON loading errors + +### Tests not categorized correctly + +- Categories are determined by test path/name +- Ensure test files follow naming convention (e.g., `test_word_*.py`) +- Edit `_categorize_test()` in `pytest_dashboard_plugin.py` to customize + +### Dashboard doesn't open automatically + +- May require manual browser opening +- Use the file path printed in terminal +- Check that `webbrowser` module is available + +## Advanced Usage + +### Extend the Plugin + +The pytest plugin can be customized by editing `tests/pytest_dashboard_plugin.py`: + +```python +def _extract_inputs(self, item): + """Customize how test inputs are extracted""" + # Your custom logic here + pass + +def _categorize_test(self, item): + """Customize test categorization""" + # Your custom logic here + pass +``` + +### Add Custom Test Data + +The JSON format supports additional fields: + +```json +{ + "metadata": { /* your custom metadata */ }, + "summary": { /* summary stats */ }, + "categories": { /* category breakdown */ }, + "tests": [ + { + "name": "test_name", + "custom_field": "your_value", + /* ... standard fields ... */ + } + ] +} +``` + +## Contributing + +When adding new test categories or features: + +1. Update `_categorize_test()` in the pytest plugin +2. Add corresponding color scheme in HTML dashboard CSS +3. Add filter button in dashboard controls +4. Update this README with new features + +## License + +Part of the MCP Office Tools project. See main project LICENSE file. diff --git a/reports/pytest_results.json b/reports/pytest_results.json new file mode 100644 index 0000000..4d17593 --- /dev/null +++ b/reports/pytest_results.json @@ -0,0 +1,18 @@ +{ + "metadata": { + "start_time": "2026-01-11T00:23:10.209539", + "pytest_version": "9.0.2", + "end_time": "2026-01-11T00:23:10.999816", + "duration": 0.7902717590332031, + "exit_status": 0 + }, + "summary": { + "total": 0, + "passed": 0, + "failed": 0, + "skipped": 0, + "pass_rate": 0 + }, + "categories": {}, + "tests": [] +} \ No newline at end of file diff --git a/reports/test_dashboard.html b/reports/test_dashboard.html new file mode 100644 index 0000000..8ec838e --- /dev/null +++ b/reports/test_dashboard.html @@ -0,0 +1,963 @@ + + + + + + MCP Office Tools - Test Dashboard + + + + +
+
+

+
+
W
+
X
+
P
+
+ MCP Office Tools - Test Dashboard +

+
+ Loading... +
+
+
+ + +
+ +
+
+
+
Total Tests
+
+
0
+
Test cases executed
+
+ +
+
+
Passed
+ + โœ“ + +
+
0
+
+ 0% pass rate +
+
+
+
+
+ +
+
+
Failed
+ + โœ— + +
+
0
+
Tests with errors
+
+ +
+
+
Duration
+
+
0s
+
Total execution time
+
+
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+ + +
+ +
+ + + +
+ + + + + + + + diff --git a/reports/test_files/test_data.xlsx b/reports/test_files/test_data.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..43f39cac5acdc609cb60f3052d68c3a0fc1727cc GIT binary patch literal 5068 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 dict: + """Run pytest tests with dashboard plugin.""" + print("\n" + "=" * 70) + print("๐Ÿงช Running pytest test suite...") + print("=" * 70) + + # Ensure plugin is loaded + plugin_path = Path(__file__).parent / "tests" / "pytest_dashboard_plugin.py" + + # Run pytest with plugin + cmd = [ + sys.executable, + "-m", + "pytest", + "-p", + "tests.pytest_dashboard_plugin", + f"--dashboard-output={output_path}", + "-v", + ] + + result = subprocess.run(cmd, cwd=Path(__file__).parent) + + # Load results + if output_path.exists(): + with open(output_path) as f: + return json.load(f) + else: + return { + "metadata": { + "start_time": datetime.now().isoformat(), + "end_time": datetime.now().isoformat(), + "duration": 0, + "exit_status": result.returncode, + }, + "summary": {"total": 0, "passed": 0, "failed": 0, "skipped": 0, "pass_rate": 0}, + "categories": {}, + "tests": [], + } + + +async def run_torture_tests(test_files_dir: Path = None) -> dict: + """Run torture tests and capture results. + + Args: + test_files_dir: Directory to store test files. If provided, files persist + for inclusion in dashboard. If None, uses temp directory. + """ + print("\n" + "=" * 70) + print("๐Ÿ”ฅ Running torture tests...") + print("=" * 70) + + from torture_test import ( + run_torture_tests as run_torture, + create_test_xlsx, + create_test_docx, + EXCEL_TEST_FILES, + ExcelMixin, + WordMixin, + ) + + excel_mixin = ExcelMixin() + word_mixin = WordMixin() + + results = [] + start_time = time.time() + + # Use persistent directory if provided, otherwise temp + if test_files_dir: + test_files_dir.mkdir(parents=True, exist_ok=True) + test_xlsx = create_test_xlsx(str(test_files_dir / "test_data.xlsx")) + test_docx = create_test_docx(str(test_files_dir / "test_document.docx")) + # Use relative paths for the dashboard + test_xlsx_path = "test_files/test_data.xlsx" + test_docx_path = "test_files/test_document.docx" + else: + import tempfile + tmpdir = tempfile.mkdtemp() + test_xlsx = create_test_xlsx(os.path.join(tmpdir, "test_data.xlsx")) + test_docx = create_test_docx(os.path.join(tmpdir, "test_document.docx")) + test_xlsx_path = test_xlsx + test_docx_path = test_docx + + # Test 1: Excel Data Analysis + test_start = time.time() + try: + result = await excel_mixin.analyze_excel_data(test_xlsx) + summary = result.get("summary", {}) + sheets_count = summary.get("sheets_analyzed", 1) + results.append({ + "name": "Excel Data Analysis", + "nodeid": "torture_test.py::test_excel_data_analysis", + "category": "Excel", + "outcome": "passed", + "duration": time.time() - test_start, + "timestamp": datetime.now().isoformat(), + "module": "torture_test", + "class": None, + "function": "test_excel_data_analysis", + "inputs": {"file": test_xlsx_path}, + "outputs": {"sheets_analyzed": sheets_count}, + "error": None, + "traceback": None, + }) + except Exception as e: + results.append({ + "name": "Excel Data Analysis", + "nodeid": "torture_test.py::test_excel_data_analysis", + "category": "Excel", + "outcome": "failed", + "duration": time.time() - test_start, + "timestamp": datetime.now().isoformat(), + "module": "torture_test", + "class": None, + "function": "test_excel_data_analysis", + "inputs": {"file": test_xlsx_path}, + "outputs": None, + "error": str(e), + "traceback": f"{type(e).__name__}: {e}", + }) + + # Test 2: Excel Formula Extraction + test_start = time.time() + try: + result = await excel_mixin.extract_excel_formulas(test_xlsx) + summary = result.get("summary", {}) + formula_count = summary.get("total_formulas", 0) + results.append({ + "name": "Excel Formula Extraction", + "nodeid": "torture_test.py::test_excel_formula_extraction", + "category": "Excel", + "outcome": "passed", + "duration": time.time() - test_start, + "timestamp": datetime.now().isoformat(), + "module": "torture_test", + "class": None, + "function": "test_excel_formula_extraction", + "inputs": {"file": test_xlsx_path}, + "outputs": {"total_formulas": formula_count}, + "error": None, + "traceback": None, + }) + except Exception as e: + results.append({ + "name": "Excel Formula Extraction", + "nodeid": "torture_test.py::test_excel_formula_extraction", + "category": "Excel", + "outcome": "failed", + "duration": time.time() - test_start, + "timestamp": datetime.now().isoformat(), + "module": "torture_test", + "class": None, + "function": "test_excel_formula_extraction", + "inputs": {"file": test_xlsx_path}, + "outputs": None, + "error": str(e), + "traceback": f"{type(e).__name__}: {e}", + }) + + # Test 3: Excel Chart Generation + test_start = time.time() + try: + result = await excel_mixin.create_excel_chart_data( + test_xlsx, + x_column="Category", + y_columns=["Value"], + chart_type="bar" + ) + chart_libs = len(result.get("chart_configuration", {})) + results.append({ + "name": "Excel Chart Data Generation", + "nodeid": "torture_test.py::test_excel_chart_generation", + "category": "Excel", + "outcome": "passed", + "duration": time.time() - test_start, + "timestamp": datetime.now().isoformat(), + "module": "torture_test", + "class": None, + "function": "test_excel_chart_generation", + "inputs": {"file": test_xlsx_path, "x_column": "Category", "y_columns": ["Value"]}, + "outputs": {"chart_libraries": chart_libs}, + "error": None, + "traceback": None, + }) + except Exception as e: + results.append({ + "name": "Excel Chart Data Generation", + "nodeid": "torture_test.py::test_excel_chart_generation", + "category": "Excel", + "outcome": "failed", + "duration": time.time() - test_start, + "timestamp": datetime.now().isoformat(), + "module": "torture_test", + "class": None, + "function": "test_excel_chart_generation", + "inputs": {"file": test_xlsx_path, "x_column": "Category", "y_columns": ["Value"]}, + "outputs": None, + "error": str(e), + "traceback": f"{type(e).__name__}: {e}", + }) + + # Test 4: Word Structure Analysis + test_start = time.time() + try: + result = await word_mixin.analyze_word_structure(test_docx) + heading_count = result["structure"].get("total_headings", 0) + results.append({ + "name": "Word Structure Analysis", + "nodeid": "torture_test.py::test_word_structure_analysis", + "category": "Word", + "outcome": "passed", + "duration": time.time() - test_start, + "timestamp": datetime.now().isoformat(), + "module": "torture_test", + "class": None, + "function": "test_word_structure_analysis", + "inputs": {"file": test_docx_path}, + "outputs": {"total_headings": heading_count}, + "error": None, + "traceback": None, + }) + except Exception as e: + results.append({ + "name": "Word Structure Analysis", + "nodeid": "torture_test.py::test_word_structure_analysis", + "category": "Word", + "outcome": "failed", + "duration": time.time() - test_start, + "timestamp": datetime.now().isoformat(), + "module": "torture_test", + "class": None, + "function": "test_word_structure_analysis", + "inputs": {"file": test_docx_path}, + "outputs": None, + "error": str(e), + "traceback": f"{type(e).__name__}: {e}", + }) + + # Test 5: Word Table Extraction + test_start = time.time() + try: + result = await word_mixin.extract_word_tables(test_docx) + table_count = result.get("total_tables", 0) + results.append({ + "name": "Word Table Extraction", + "nodeid": "torture_test.py::test_word_table_extraction", + "category": "Word", + "outcome": "passed", + "duration": time.time() - test_start, + "timestamp": datetime.now().isoformat(), + "module": "torture_test", + "class": None, + "function": "test_word_table_extraction", + "inputs": {"file": test_docx_path}, + "outputs": {"total_tables": table_count}, + "error": None, + "traceback": None, + }) + except Exception as e: + results.append({ + "name": "Word Table Extraction", + "nodeid": "torture_test.py::test_word_table_extraction", + "category": "Word", + "outcome": "failed", + "duration": time.time() - test_start, + "timestamp": datetime.now().isoformat(), + "module": "torture_test", + "class": None, + "function": "test_word_table_extraction", + "inputs": {"file": test_docx_path}, + "outputs": None, + "error": str(e), + "traceback": f"{type(e).__name__}: {e}", + }) + + # Test 6: Real Excel file (if available) + real_excel = EXCEL_TEST_FILES[0] + if os.path.exists(real_excel): + test_start = time.time() + try: + result = await excel_mixin.analyze_excel_data(real_excel) + sheets = len(result.get("sheets", [])) + results.append({ + "name": "Real Excel File Analysis (FORScan)", + "nodeid": "torture_test.py::test_real_excel_analysis", + "category": "Excel", + "outcome": "passed", + "duration": time.time() - test_start, + "timestamp": datetime.now().isoformat(), + "module": "torture_test", + "class": None, + "function": "test_real_excel_analysis", + "inputs": {"file": real_excel}, + "outputs": {"sheets": sheets}, + "error": None, + "traceback": None, + }) + except Exception as e: + results.append({ + "name": "Real Excel File Analysis (FORScan)", + "nodeid": "torture_test.py::test_real_excel_analysis", + "category": "Excel", + "outcome": "failed", + "duration": time.time() - test_start, + "timestamp": datetime.now().isoformat(), + "module": "torture_test", + "class": None, + "function": "test_real_excel_analysis", + "inputs": {"file": real_excel}, + "outputs": None, + "error": str(e), + "traceback": f"{type(e).__name__}: {e}", + }) + else: + results.append({ + "name": "Real Excel File Analysis (FORScan)", + "nodeid": "torture_test.py::test_real_excel_analysis", + "category": "Excel", + "outcome": "skipped", + "duration": 0, + "timestamp": datetime.now().isoformat(), + "module": "torture_test", + "class": None, + "function": "test_real_excel_analysis", + "inputs": {"file": real_excel}, + "outputs": None, + "error": f"File not found: {real_excel}", + "traceback": None, + }) + + # Calculate summary + total_duration = time.time() - start_time + passed = sum(1 for r in results if r["outcome"] == "passed") + failed = sum(1 for r in results if r["outcome"] == "failed") + skipped = sum(1 for r in results if r["outcome"] == "skipped") + total = len(results) + + return { + "metadata": { + "start_time": datetime.fromtimestamp(start_time).isoformat(), + "end_time": datetime.now().isoformat(), + "duration": total_duration, + "exit_status": 0 if failed == 0 else 1, + "pytest_version": "torture_test", + }, + "summary": { + "total": total, + "passed": passed, + "failed": failed, + "skipped": skipped, + "pass_rate": (passed / total * 100) if total > 0 else 0, + }, + "categories": { + "Excel": { + "total": sum(1 for r in results if r["category"] == "Excel"), + "passed": sum(1 for r in results if r["category"] == "Excel" and r["outcome"] == "passed"), + "failed": sum(1 for r in results if r["category"] == "Excel" and r["outcome"] == "failed"), + "skipped": sum(1 for r in results if r["category"] == "Excel" and r["outcome"] == "skipped"), + }, + "Word": { + "total": sum(1 for r in results if r["category"] == "Word"), + "passed": sum(1 for r in results if r["category"] == "Word" and r["outcome"] == "passed"), + "failed": sum(1 for r in results if r["category"] == "Word" and r["outcome"] == "failed"), + "skipped": sum(1 for r in results if r["category"] == "Word" and r["outcome"] == "skipped"), + }, + }, + "tests": results, + } + + +def merge_results(pytest_results: dict, torture_results: dict) -> dict: + """Merge pytest and torture test results.""" + # Merge tests + all_tests = pytest_results.get("tests", []) + torture_results.get("tests", []) + + # Recalculate summary + total = len(all_tests) + passed = sum(1 for t in all_tests if t["outcome"] == "passed") + failed = sum(1 for t in all_tests if t["outcome"] == "failed") + skipped = sum(1 for t in all_tests if t["outcome"] == "skipped") + + # Merge categories + all_categories = {} + for cat_dict in [pytest_results.get("categories", {}), torture_results.get("categories", {})]: + for cat, stats in cat_dict.items(): + if cat not in all_categories: + all_categories[cat] = {"total": 0, "passed": 0, "failed": 0, "skipped": 0} + for key in ["total", "passed", "failed", "skipped"]: + all_categories[cat][key] += stats.get(key, 0) + + # Combine durations + total_duration = pytest_results.get("metadata", {}).get("duration", 0) + \ + torture_results.get("metadata", {}).get("duration", 0) + + return { + "metadata": { + "start_time": pytest_results.get("metadata", {}).get("start_time", datetime.now().isoformat()), + "end_time": datetime.now().isoformat(), + "duration": total_duration, + "exit_status": 0 if failed == 0 else 1, + "pytest_version": pytest_results.get("metadata", {}).get("pytest_version", "unknown"), + "test_types": ["pytest", "torture_test"], + }, + "summary": { + "total": total, + "passed": passed, + "failed": failed, + "skipped": skipped, + "pass_rate": (passed / total * 100) if total > 0 else 0, + }, + "categories": all_categories, + "tests": all_tests, + } + + +def main(): + """Main execution function.""" + reports_dir = Path(__file__).parent / "reports" + reports_dir.mkdir(exist_ok=True) + + test_files_dir = reports_dir / "test_files" + + pytest_output = reports_dir / "pytest_results.json" + final_output = reports_dir / "test_results.json" + + # Run pytest tests + pytest_results = run_pytest_tests(pytest_output) + + # Run torture tests with persistent test files + torture_results = asyncio.run(run_torture_tests(test_files_dir)) + + # Merge results + merged_results = merge_results(pytest_results, torture_results) + + # Write final results + with open(final_output, "w") as f: + json.dump(merged_results, f, indent=2) + + # Embed JSON data into HTML for offline viewing (file:// URLs) + dashboard_html = reports_dir / "test_dashboard.html" + if dashboard_html.exists(): + html_content = dashboard_html.read_text() + # Remove any existing embedded data + import re + html_content = re.sub( + r'\n?', + '', + html_content, + flags=re.DOTALL + ) + # Embed fresh data before + embed_script = f'\n' + html_content = html_content.replace('', f'{embed_script}') + dashboard_html.write_text(html_content) + + print("\n" + "=" * 70) + print("๐Ÿ“Š TEST DASHBOARD SUMMARY") + print("=" * 70) + print(f"\nโœ… Passed: {merged_results['summary']['passed']}") + print(f"โŒ Failed: {merged_results['summary']['failed']}") + print(f"โญ๏ธ Skipped: {merged_results['summary']['skipped']}") + print(f"\n๐Ÿ“ˆ Pass Rate: {merged_results['summary']['pass_rate']:.1f}%") + print(f"โฑ๏ธ Duration: {merged_results['metadata']['duration']:.2f}s") + print(f"\n๐Ÿ“„ Results saved to: {final_output}") + print(f"๐ŸŒ Dashboard: {reports_dir / 'test_dashboard.html'}") + print("=" * 70) + + # Try to open dashboard in browser + try: + import webbrowser + dashboard_path = reports_dir / "test_dashboard.html" + webbrowser.open(f"file://{dashboard_path.absolute()}") + print("\n๐ŸŒ Opening dashboard in browser...") + except Exception as e: + print(f"\nโš ๏ธ Could not open browser automatically: {e}") + print(f" Open manually: file://{(reports_dir / 'test_dashboard.html').absolute()}") + + # Return exit code + return merged_results["metadata"]["exit_status"] + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test_mcp_tools.py b/test_mcp_tools.py new file mode 100644 index 0000000..185b08b --- /dev/null +++ b/test_mcp_tools.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Simple test script to verify MCP Office Tools functionality.""" + +import asyncio +import tempfile +import os +from pathlib import Path + +# Create simple test documents +def create_test_documents(): + """Create test documents for verification.""" + temp_dir = Path(tempfile.mkdtemp()) + + # Create a simple CSV file + csv_path = temp_dir / "test.csv" + csv_content = """Name,Age,City +John Doe,30,New York +Jane Smith,25,Los Angeles +Bob Johnson,35,Chicago""" + + with open(csv_path, 'w') as f: + f.write(csv_content) + + # Create a simple text file to test validation + txt_path = temp_dir / "test.txt" + with open(txt_path, 'w') as f: + f.write("This is a simple text file, not an Office document.") + + return temp_dir, csv_path, txt_path + +async def test_mcp_server(): + """Test MCP server functionality.""" + print("๐Ÿงช Testing MCP Office Tools Server") + print("=" * 50) + + # Create test documents + temp_dir, csv_path, txt_path = create_test_documents() + print(f"๐Ÿ“ Created test files in: {temp_dir}") + + try: + # Import the server components + from mcp_office_tools.mixins import UniversalMixin + + # Test the Universal Mixin directly + universal = UniversalMixin() + + print("\n๐Ÿ” Testing extract_text with CSV file...") + try: + result = await universal.extract_text(str(csv_path)) + print("โœ… CSV text extraction successful!") + print(f" Text length: {len(result.get('text', ''))}") + print(f" Method used: {result.get('method_used', 'unknown')}") + except Exception as e: + print(f"โŒ CSV text extraction failed: {e}") + + print("\n๐Ÿ” Testing get_supported_formats...") + try: + result = await universal.get_supported_formats() + print("โœ… Supported formats query successful!") + print(f" Total formats: {len(result.get('formats', []))}") + print(f" Excel formats: {len([f for f in result.get('formats', []) if 'Excel' in f.get('description', '')])}") + except Exception as e: + print(f"โŒ Supported formats query failed: {e}") + + print("\n๐Ÿ” Testing validation with unsupported file...") + try: + result = await universal.extract_text(str(txt_path)) + print("โŒ Should have failed with unsupported file!") + except Exception as e: + print(f"โœ… Correctly rejected unsupported file: {type(e).__name__}") + + print("\n๐Ÿ” Testing detect_office_format...") + try: + result = await universal.detect_office_format(str(csv_path)) + print("โœ… Format detection successful!") + print(f" Detected format: {result.get('format', 'unknown')}") + print(f" Is supported: {result.get('is_supported', False)}") + except Exception as e: + print(f"โŒ Format detection failed: {e}") + + except ImportError as e: + print(f"โŒ Failed to import server components: {e}") + return False + except Exception as e: + print(f"โŒ Unexpected error: {e}") + return False + finally: + # Cleanup + import shutil + shutil.rmtree(temp_dir) + print(f"\n๐Ÿงน Cleaned up test files from: {temp_dir}") + + print("\nโœ… Basic MCP Office Tools testing completed!") + return True + +if __name__ == "__main__": + asyncio.run(test_mcp_server()) \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index de9d55c..407f354 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -245,8 +245,8 @@ def mock_validation_context(): return MockValidationContext -# FastMCP-specific test markers -pytest_plugins = ["pytest_asyncio"] +# FastMCP-specific test markers and dashboard plugin +pytest_plugins = ["pytest_asyncio", "tests.pytest_dashboard_plugin"] # Configure pytest markers def pytest_configure(config): diff --git a/tests/pytest_dashboard_plugin.py b/tests/pytest_dashboard_plugin.py new file mode 100644 index 0000000..47b5016 --- /dev/null +++ b/tests/pytest_dashboard_plugin.py @@ -0,0 +1,194 @@ +"""Pytest plugin to capture test results for the dashboard. + +This plugin captures detailed test execution data including inputs, outputs, +timing, and status for display in the HTML test dashboard. +""" + +import json +import time +import traceback +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Any +import pytest + + +class DashboardReporter: + """Reporter that captures test execution data for the dashboard.""" + + def __init__(self, output_path: str): + self.output_path = Path(output_path) + self.test_results: List[Dict[str, Any]] = [] + self.start_time = time.time() + self.session_metadata = { + "start_time": datetime.now().isoformat(), + "pytest_version": pytest.__version__, + } + + def pytest_runtest_protocol(self, item, nextitem): + """Capture test execution at the protocol level.""" + # Store test item for later use + item._dashboard_start = time.time() + return None + + def pytest_runtest_makereport(self, item, call): + """Capture test results and extract information.""" + if call.when == "call": # Only capture the main test call, not setup/teardown + test_data = { + "name": item.name, + "nodeid": item.nodeid, + "category": self._categorize_test(item), + "outcome": None, # Will be set in pytest_runtest_logreport + "duration": call.duration, + "timestamp": datetime.now().isoformat(), + "module": item.module.__name__ if item.module else "unknown", + "class": item.cls.__name__ if item.cls else None, + "function": item.function.__name__ if hasattr(item, "function") else item.name, + "inputs": self._extract_inputs(item), + "outputs": None, + "error": None, + "traceback": None, + } + + # Store for later processing in pytest_runtest_logreport + item._dashboard_data = test_data + + def pytest_runtest_logreport(self, report): + """Process test reports to extract outputs and status.""" + if report.when == "call" and hasattr(report, "item"): + item = report.item if hasattr(report, "item") else None + if item and hasattr(item, "_dashboard_data"): + test_data = item._dashboard_data + + # Set outcome + test_data["outcome"] = report.outcome + + # Extract output + if hasattr(report, "capstdout"): + test_data["outputs"] = { + "stdout": report.capstdout, + "stderr": getattr(report, "capstderr", ""), + } + + # Extract error information + if report.failed: + test_data["error"] = str(report.longrepr) if hasattr(report, "longrepr") else "Unknown error" + if hasattr(report, "longreprtext"): + test_data["traceback"] = report.longreprtext + elif hasattr(report, "longrepr"): + test_data["traceback"] = str(report.longrepr) + + # Extract actual output from test result if available + if hasattr(report, "result"): + test_data["outputs"]["result"] = str(report.result) + + self.test_results.append(test_data) + + def pytest_sessionfinish(self, session, exitstatus): + """Write results to JSON file at end of test session.""" + end_time = time.time() + + # Calculate summary statistics + total_tests = len(self.test_results) + passed = sum(1 for t in self.test_results if t["outcome"] == "passed") + failed = sum(1 for t in self.test_results if t["outcome"] == "failed") + skipped = sum(1 for t in self.test_results if t["outcome"] == "skipped") + + # Group by category + categories = {} + for test in self.test_results: + cat = test["category"] + if cat not in categories: + categories[cat] = {"total": 0, "passed": 0, "failed": 0, "skipped": 0} + categories[cat]["total"] += 1 + if test["outcome"] == "passed": + categories[cat]["passed"] += 1 + elif test["outcome"] == "failed": + categories[cat]["failed"] += 1 + elif test["outcome"] == "skipped": + categories[cat]["skipped"] += 1 + + # Build final output + output_data = { + "metadata": { + **self.session_metadata, + "end_time": datetime.now().isoformat(), + "duration": end_time - self.start_time, + "exit_status": exitstatus, + }, + "summary": { + "total": total_tests, + "passed": passed, + "failed": failed, + "skipped": skipped, + "pass_rate": (passed / total_tests * 100) if total_tests > 0 else 0, + }, + "categories": categories, + "tests": self.test_results, + } + + # Ensure output directory exists + self.output_path.parent.mkdir(parents=True, exist_ok=True) + + # Write JSON + with open(self.output_path, "w") as f: + json.dump(output_data, f, indent=2) + + print(f"\n Dashboard test results written to: {self.output_path}") + + def _categorize_test(self, item) -> str: + """Categorize test based on its name/path.""" + nodeid = item.nodeid.lower() + + if "word" in nodeid: + return "Word" + elif "excel" in nodeid: + return "Excel" + elif "powerpoint" in nodeid or "pptx" in nodeid: + return "PowerPoint" + elif "universal" in nodeid: + return "Universal" + elif "server" in nodeid: + return "Server" + else: + return "Other" + + def _extract_inputs(self, item) -> Dict[str, Any]: + """Extract test inputs from fixtures and parameters.""" + inputs = {} + + # Get fixture values + if hasattr(item, "funcargs"): + for name, value in item.funcargs.items(): + # Skip complex objects, only store simple values + if isinstance(value, (str, int, float, bool, type(None))): + inputs[name] = value + elif isinstance(value, (list, tuple)) and len(value) < 10: + inputs[name] = list(value) + elif isinstance(value, dict) and len(value) < 10: + inputs[name] = value + else: + inputs[name] = f"<{type(value).__name__}>" + + # Get parametrize values if present + if hasattr(item, "callspec"): + inputs["params"] = item.callspec.params + + return inputs + + +def pytest_configure(config): + """Register the dashboard reporter plugin.""" + output_path = config.getoption("--dashboard-output", default="reports/test_results.json") + reporter = DashboardReporter(output_path) + config.pluginmanager.register(reporter, "dashboard_reporter") + + +def pytest_addoption(parser): + """Add command line option for dashboard output path.""" + parser.addoption( + "--dashboard-output", + action="store", + default="reports/test_results.json", + help="Path to output JSON file for dashboard (default: reports/test_results.json)", + ) diff --git a/view_dashboard.sh b/view_dashboard.sh new file mode 100755 index 0000000..8a17422 --- /dev/null +++ b/view_dashboard.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Quick script to open the test dashboard in browser + +DASHBOARD_PATH="/home/rpm/claude/mcp-office-tools/reports/test_dashboard.html" + +echo "๐Ÿ“Š Opening MCP Office Tools Test Dashboard..." +echo "Dashboard: $DASHBOARD_PATH" +echo "" + +# Try different browser commands based on what's available +if command -v xdg-open &> /dev/null; then + xdg-open "$DASHBOARD_PATH" +elif command -v firefox &> /dev/null; then + firefox "$DASHBOARD_PATH" & +elif command -v chromium &> /dev/null; then + chromium "$DASHBOARD_PATH" & +elif command -v google-chrome &> /dev/null; then + google-chrome "$DASHBOARD_PATH" & +else + echo "โš ๏ธ No browser command found. Please open manually:" + echo " file://$DASHBOARD_PATH" +fi