Files
LEDMatrix/scripts/run_plugin_tests.py
Chuck 23f0176c18 feat: add dev preview server and CLI render script (#264)
* fix(web): wire up "Check & Update All" plugins button

window.updateAllPlugins was never assigned, so the button always showed
"Bulk update handler unavailable." Wire it to PluginInstallManager.updateAll(),
add per-plugin progress feedback in the button text, show a summary
notification on completion, and skip redundant plugin list reloads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add dev preview server, CLI render script, and visual test display manager

Adds local development tools for rapid plugin iteration without deploying to RPi:

- VisualTestDisplayManager: renders real pixels via PIL (same fonts/interface as production)
- Dev preview server (Flask): interactive web UI with plugin picker, auto-generated config
  forms, zoom/grid controls, and mock data support for API-dependent plugins
- CLI render script: render any plugin to PNG for AI-assisted visual feedback loops
- Updated test runner and conftest to auto-detect plugin-repos/ directory

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(dev-preview): address code review issues

- Use get_logger() from src.logging_config instead of logging.getLogger()
  in visual_display_manager.py to match project logging conventions
- Eliminate duplicate public/private weather draw methods — public draw_sun/
  draw_cloud/draw_rain/draw_snow now delegate to the private _draw_* variants
  so plugins get consistent pixel output in tests vs production
- Default install_deps=False in dev_server.py and render_plugin.py — dev
  scripts don't need to run pip install; developers are expected to have
  plugin deps installed in their venv already
- Guard plugins_dir fixture against PermissionError during directory iteration
- Fix PluginInstallManager.updateAll() to fall back to window.installedPlugins
  when PluginStateManager.installedPlugins is empty (plugins_manager.js
  populates window.installedPlugins independently of PluginStateManager)
- Remove 5 debug console.log statements from plugins_manager.js button setup
  and initialization code

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(scroll): fix scroll completion to prevent multi-pass wrapping

Change required_total_distance from total_scroll_width + display_width to
total_scroll_width alone. The scrolling image already contains display_width
pixels of blank initial padding, so reaching total_scroll_width means all
content has scrolled off-screen. The extra display_width term was causing
1-2+ unnecessary wrap-arounds, making the same games appear multiple times
and producing a black flicker between passes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(dev-preview): address PR #264 code review findings

- docs/DEV_PREVIEW.md: add bash language tag to fenced code block
- scripts/dev_server.py: add MAX/MIN_WIDTH/HEIGHT constants and validate
  width/height in render endpoint; add structured logger calls to
  discover_plugins (missing dirs, hidden entries, missing manifest,
  JSON/OS errors, duplicate ids); add type annotations to all helpers
- scripts/render_plugin.py: add MIN/MAX_DIMENSION validation after
  parse_args; replace prints with get_logger() calls; narrow broad
  Exception catches to ImportError/OSError/ValueError in plugin load
  block; add type annotations to all helpers and main(); rename unused
  module binding to _module
- scripts/run_plugin_tests.py: wrap plugins_path.iterdir() in
  try/except PermissionError with fallback to plugin-repos/
- scripts/templates/dev_preview.html: replace non-focusable div toggles
  with button role="switch" + aria-checked; add keyboard handlers
  (Enter/Space); sync aria-checked in toggleGrid/toggleAutoRefresh
- src/common/scroll_helper.py: early-guard zero total_scroll_width to
  keep scroll_position at 0 and skip completion/wrap logic
- src/plugin_system/testing/visual_display_manager.py: forward color
  arg in draw_cloud -> _draw_cloud; add color param to _draw_cloud;
  restore _scrolling_state in reset(); narrow broad Exception catches in
  _load_fonts to FileNotFoundError/OSError/ImportError; add explicit
  type annotations to draw_text
- test/plugins/test_visual_rendering.py: use context manager for
  Image.open in test_save_snapshot
- test/plugins/conftest.py: add return type hints to all fixtures

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: add bandit and gitleaks pre-commit hooks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:57:42 -05:00

220 lines
6.3 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Plugin Test Runner
Discovers and runs tests for LEDMatrix plugins.
Supports both unittest and pytest.
"""
import sys
import os
import argparse
import subprocess
from pathlib import Path
from typing import Optional
# Add project root to path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
def discover_plugin_tests(plugins_dir: Path, plugin_id: Optional[str] = None) -> list:
"""
Discover test files in plugin directories.
Args:
plugins_dir: Plugins directory path
plugin_id: Optional specific plugin ID to test
Returns:
List of test file paths
"""
test_files = []
if plugin_id:
# Test specific plugin
plugin_dir = plugins_dir / plugin_id
if plugin_dir.exists():
test_files.extend(_find_tests_in_dir(plugin_dir))
else:
# Test all plugins
for item in plugins_dir.iterdir():
if not item.is_dir():
continue
if item.name.startswith('.') or item.name.startswith('_'):
continue
test_files.extend(_find_tests_in_dir(item))
return test_files
def _find_tests_in_dir(directory: Path) -> list:
"""Find test files in a directory."""
test_files = []
# Look for test files
patterns = ['test_*.py', '*_test.py', 'tests/test_*.py', 'tests/*_test.py']
for pattern in patterns:
if '/' in pattern:
# Subdirectory pattern
subdir, file_pattern = pattern.split('/', 1)
test_dir = directory / subdir
if test_dir.exists():
test_files.extend(test_dir.glob(file_pattern))
else:
# Direct pattern
test_files.extend(directory.glob(pattern))
return sorted(set(test_files))
def run_unittest_tests(test_files: list, verbose: bool = False) -> int:
"""
Run tests using unittest.
Args:
test_files: List of test file paths
verbose: Enable verbose output
Returns:
Exit code (0 for success, non-zero for failure)
"""
import unittest
# Discover tests
loader = unittest.TestLoader()
suite = unittest.TestSuite()
for test_file in test_files:
# Import the test module
module_name = test_file.stem
spec = importlib.util.spec_from_file_location(module_name, test_file)
if spec and spec.loader:
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# Load tests from module
tests = loader.loadTestsFromModule(module)
suite.addTests(tests)
# Run tests
runner = unittest.TextTestRunner(verbosity=2 if verbose else 1)
result = runner.run(suite)
return 0 if result.wasSuccessful() else 1
def run_pytest_tests(test_files: list, verbose: bool = False, coverage: bool = False) -> int:
"""
Run tests using pytest.
Args:
test_files: List of test file paths
verbose: Enable verbose output
coverage: Generate coverage report
Returns:
Exit code (0 for success, non-zero for failure)
"""
import pytest
args = []
if verbose:
args.append('-v')
else:
args.append('-q')
if coverage:
args.extend(['--cov', 'plugins', '--cov-report', 'html', '--cov-report', 'term'])
# Add test files
args.extend([str(f) for f in test_files])
# Run pytest
exit_code = pytest.main(args)
return exit_code
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(description='Run LEDMatrix plugin tests')
parser.add_argument('--plugin', '-p', help='Test specific plugin ID')
parser.add_argument('--plugins-dir', '-d', default=None,
help='Plugins directory (default: auto-detect plugins/ or plugin-repos/)')
parser.add_argument('--runner', '-r', choices=['unittest', 'pytest', 'auto'],
default='auto', help='Test runner to use (default: auto)')
parser.add_argument('--verbose', '-v', action='store_true',
help='Enable verbose output')
parser.add_argument('--coverage', '-c', action='store_true',
help='Generate coverage report (pytest only)')
args = parser.parse_args()
if args.plugins_dir:
plugins_dir = Path(args.plugins_dir)
else:
# Auto-detect: prefer plugins/ if it has content, then plugin-repos/
plugins_path = PROJECT_ROOT / 'plugins'
plugin_repos_path = PROJECT_ROOT / 'plugin-repos'
try:
has_plugins = plugins_path.exists() and any(
p for p in plugins_path.iterdir()
if p.is_dir() and not p.name.startswith('.')
)
except PermissionError:
print(f"Warning: cannot read {plugins_path}, falling back to plugin-repos/")
has_plugins = False
if has_plugins:
plugins_dir = plugins_path
elif plugin_repos_path.exists():
plugins_dir = plugin_repos_path
else:
plugins_dir = plugins_path
if not plugins_dir.exists():
print(f"Error: Plugins directory not found: {plugins_dir}")
return 1
# Discover tests
test_files = discover_plugin_tests(plugins_dir, args.plugin)
if not test_files:
if args.plugin:
print(f"No tests found for plugin: {args.plugin}")
else:
print("No test files found in plugins directory")
return 0
print(f"Found {len(test_files)} test file(s)")
for test_file in test_files:
print(f" - {test_file}")
print()
# Determine runner
runner = args.runner
if runner == 'auto':
# Try pytest first, fall back to unittest
try:
import pytest
runner = 'pytest'
except ImportError:
runner = 'unittest'
# Run tests
if runner == 'pytest':
import importlib.util
return run_pytest_tests(test_files, args.verbose, args.coverage)
else:
import importlib.util
return run_unittest_tests(test_files, args.verbose)
if __name__ == '__main__':
import importlib.util
from typing import Optional
sys.exit(main())