mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
* fix(store): don't read a ZIP-installed plugin's remote from the LEDMatrix repo update_plugin looked up remote.origin.url with `git -C <plugin> config --local` for plugins that are not git checkouts. Under plugin-repos/ git walks up to the enclosing LEDMatrix repository, so the lookup returned LEDMatrix's own URL and a plugin missing from the registry was "reinstalled" from the LEDMatrix repo. Only ask git when the plugin directory has its own .git, the test _get_local_git_info already uses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(schema): report each missing required field once, by name validate_config_against_schema ran its own required-fields loop after Draft7Validator.iter_errors, which already yields one `required` error per missing field, so every missing top-level field was listed twice. The validator's copy also printed the schema's whole `required` list ("Missing required property '['api_key', 'city']'") instead of the field. Drop the loop and take the field name from the error itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(store): stop mangling repository URLs that contain ".git" install_from_url and fetch_registry_from_url cleaned URLs with `rstrip('/').replace('.git', '')`, which removes ".git" anywhere: https://github.com/user/my.github.io became .../myhub.io, so installing or browsing that repository asked GitHub for one that does not exist. Add src/plugin_system/repo_urls.py with one anchored normalize_repo_url(), same_repo() for comparisons, github_owner_repo() and github_api_headers(), and use them for the five copies of the owner/repo parsing and GitHub headers in the store and for saved repositories. GitHub URLs are now recognised by urlparse().hostname everywhere: _get_latest_commit_info used a substring test, and _install_from_monorepo_api parsed any host. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(store): install a repository whose only branch is not main/master _install_via_git returned None both when every clone failed and when the last-resort clone of the repository's default branch succeeded. _install_plugin_impl papered over it with `and not plugin_path.exists()`; install_from_url did not, so a repository whose only branch is e.g. `develop` was cloned, then treated as a failure, then "downloaded" from main/master archives that do not exist. After a default-branch clone, return the branch the clone checked out (read from .git/HEAD), so None means failure and nothing else, and give both callers the same `branch_used is None` fallback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(plugins): judge the memory limit on each call's own growth monitor_call stores `metrics.memory_mb = max(previous, growth)`, and _check_limits compared that high-water mark with max_memory_mb. It never decreases, so once one update() grew the process past the limit every later call raised ResourceLimitExceeded and the circuit breaker kept reopening. Pass the call's own RSS growth to _check_limits; keep the high-water mark for reporting and document what it measures. Remove ResourceMetrics.update_average_execution_time: nothing called it, and it overwrote the running total with the average. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(plugins): reload_plugin re-reads the manifest from the discovered directory reload_plugin read `plugins_dir / plugin_id / "manifest.json"`, ignoring the discovery map and the plugin_dirs rules. For a plugin whose directory name differs from its manifest id the path did not exist, the re-read was skipped without a word, and the reload kept the stale manifest. Resolve the directory with find_plugin_directory, as load_plugin does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(plugins): drop the always-null last_display from plugin state info PluginStateManager reported `last_display` from `_last_display`, which nothing ever wrote, so it was null for every plugin. Recording it in PluginExecutor.execute_display would not help: get_state_info's only reader is the web process, whose PluginManager never calls display(). Remove the field, its dict and get_last_display() (no caller in core, the web UI or the plugin monorepo). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(store): share the rollback and requirements helpers, drop dead code - install_plugin and _reinstall_with_rollback set aside, discard and restore the old copy through _set_aside/_discard_backup/_restore_backup instead of two copies of the same blocks. - The loader and the store run the same pre-pip checks through contained_plugin_dir() and requirements_to_install() in plugin_loader. They still invoke pip differently (sys.executable -m pip vs. the sudo wrapper). `except (BrokenPipeError, OSError)` + `isinstance(e, OSError)` becomes `except OSError` checking errno.EPIPE. - load_module never returns None, so load_plugin's check is gone and the docstring says what it raises. - Remove the always-true JSONSCHEMA_AVAILABLE, the inline re-imports of re and permission_utils, the fake status_result object nobody reads, hasattr(git_error, 'cmd'), a redundant "merge conflict" test and `import traceback` (exc_info=True does it). - Correct comments: install_from_url names the directory for the caller's id when given (not always the manifest id), _get_local_git_info saves one git subprocess (not four), _enrich calls two helpers, search_plugins documents all its arguments, _find_plugin_path states its behaviour instead of a TODO, and history narration is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(plugins): tidy base_plugin, correct plugin_manager/state comments - base_plugin: drop the unused `import logging`; get_display_duration runs the instance value and the config value through one _positive_seconds() helper instead of two copies of the coercion; the 'static'/'none'/fallback branches of get_vegas_display_mode, which all returned FIXED_SEGMENT, are one; fix the mis-indented validate_config example; say that get_supported_vegas_modes/get_vegas_segment_width are not consulted by core (kept, plugins override them). - schema_manager: import expand_style_elements normally rather than swallowing an ImportError of a core module. - plugin_manager: the plugins directory is the configured one (plugin-repos/ by default), not plugins/; get_config() returns the live dict, not a copy, so the interval cache comments say what it saves. - state_manager: config_version and the file version are not used to detect corruption; say what they are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(plugins): stop writing data/plugin_operations.json PluginOperationQueue wrote its finished-operation history to data/plugin_operations.json after every operation, and read it back only into its own in-memory list, which only get_operation_history() exposes -- and nothing calls that. The operation-history endpoint reads OperationHistory (data/operation_history.json). No code in src/, web_interface/, scripts/ or test/ reads the file. Drop the history_file/lazy_load parameters and the load/save code; the bounded in-memory history stays. web_interface/app.py and the integration test stop passing the removed arguments. An existing data/plugin_operations.json is left in place (data/* is gitignored). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): plugin-system Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
197 lines
6.5 KiB
Python
197 lines
6.5 KiB
Python
"""
|
|
Integration tests for plugin operations (install, update, uninstall).
|
|
"""
|
|
|
|
import unittest
|
|
import tempfile
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
from src.plugin_system.operation_queue import PluginOperationQueue
|
|
from src.plugin_system.operation_types import OperationType
|
|
from src.plugin_system.state_manager import PluginStateManager
|
|
from src.plugin_system.operation_history import OperationHistory
|
|
|
|
|
|
class TestPluginOperationsIntegration(unittest.TestCase):
|
|
"""Integration tests for plugin operations."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
self.temp_dir = Path(tempfile.mkdtemp())
|
|
|
|
# Initialize components
|
|
self.operation_queue = PluginOperationQueue(max_history=100)
|
|
|
|
self.state_manager = PluginStateManager(
|
|
state_file=str(self.temp_dir / "state.json"),
|
|
auto_save=True
|
|
)
|
|
|
|
self.operation_history = OperationHistory(
|
|
history_file=str(self.temp_dir / "history.json"),
|
|
max_records=100
|
|
)
|
|
|
|
def tearDown(self):
|
|
"""Clean up test fixtures."""
|
|
self.operation_queue.shutdown()
|
|
shutil.rmtree(self.temp_dir)
|
|
|
|
def test_install_operation_flow(self):
|
|
"""Test complete install operation flow."""
|
|
plugin_id = "test-plugin"
|
|
|
|
# Enqueue install operation
|
|
operation_id = self.operation_queue.enqueue_operation(
|
|
OperationType.INSTALL,
|
|
plugin_id,
|
|
{"version": "1.0.0"}
|
|
)
|
|
|
|
self.assertIsNotNone(operation_id)
|
|
|
|
# Get operation status
|
|
operation = self.operation_queue.get_operation_status(operation_id)
|
|
self.assertEqual(operation.operation_type, OperationType.INSTALL)
|
|
self.assertEqual(operation.plugin_id, plugin_id)
|
|
|
|
# Record in history
|
|
history_id = self.operation_history.record_operation(
|
|
operation_type="install",
|
|
plugin_id=plugin_id,
|
|
status="in_progress",
|
|
operation_id=operation_id
|
|
)
|
|
self.assertIsNotNone(history_id)
|
|
|
|
# Update state manager
|
|
self.state_manager.set_plugin_installed(plugin_id, "1.0.0")
|
|
|
|
# Verify state
|
|
state = self.state_manager.get_plugin_state(plugin_id)
|
|
self.assertIsNotNone(state)
|
|
self.assertEqual(state.version, "1.0.0")
|
|
|
|
def test_update_operation_flow(self):
|
|
"""Test complete update operation flow."""
|
|
plugin_id = "test-plugin"
|
|
|
|
# First, mark as installed
|
|
self.state_manager.set_plugin_installed(plugin_id, "1.0.0")
|
|
|
|
# Enqueue update operation
|
|
operation_id = self.operation_queue.enqueue_operation(
|
|
OperationType.UPDATE,
|
|
plugin_id,
|
|
{"from_version": "1.0.0", "to_version": "2.0.0"}
|
|
)
|
|
|
|
self.assertIsNotNone(operation_id)
|
|
|
|
# Record in history
|
|
self.operation_history.record_operation(
|
|
operation_type="update",
|
|
plugin_id=plugin_id,
|
|
status="in_progress",
|
|
operation_id=operation_id
|
|
)
|
|
|
|
# Update state
|
|
self.state_manager.update_plugin_state(plugin_id, {"version": "2.0.0"})
|
|
|
|
# Verify state
|
|
state = self.state_manager.get_plugin_state(plugin_id)
|
|
self.assertEqual(state.version, "2.0.0")
|
|
|
|
def test_uninstall_operation_flow(self):
|
|
"""Test complete uninstall operation flow."""
|
|
plugin_id = "test-plugin"
|
|
|
|
# First, mark as installed
|
|
self.state_manager.set_plugin_installed(plugin_id, "1.0.0")
|
|
|
|
# Enqueue uninstall operation
|
|
operation_id = self.operation_queue.enqueue_operation(
|
|
OperationType.UNINSTALL,
|
|
plugin_id
|
|
)
|
|
|
|
self.assertIsNotNone(operation_id)
|
|
|
|
# Record in history
|
|
self.operation_history.record_operation(
|
|
operation_type="uninstall",
|
|
plugin_id=plugin_id,
|
|
status="in_progress",
|
|
operation_id=operation_id
|
|
)
|
|
|
|
# Update state - remove plugin state
|
|
self.state_manager.remove_plugin_state(plugin_id)
|
|
|
|
# Verify state
|
|
state = self.state_manager.get_plugin_state(plugin_id)
|
|
self.assertIsNone(state)
|
|
|
|
def test_operation_history_tracking(self):
|
|
"""Test that operations are tracked in history."""
|
|
plugin_id = "test-plugin"
|
|
|
|
# Perform multiple operations
|
|
operations = [
|
|
("install", "1.0.0"),
|
|
("update", "2.0.0"),
|
|
("uninstall", None)
|
|
]
|
|
|
|
for op_type, version in operations:
|
|
history_id = self.operation_history.record_operation(
|
|
operation_type=op_type,
|
|
plugin_id=plugin_id,
|
|
status="completed"
|
|
)
|
|
self.assertIsNotNone(history_id)
|
|
|
|
# Get history
|
|
history = self.operation_history.get_history(limit=10, plugin_id=plugin_id)
|
|
|
|
# Verify all operations recorded
|
|
self.assertEqual(len(history), 3)
|
|
self.assertEqual(history[0].operation_type, "uninstall")
|
|
self.assertEqual(history[1].operation_type, "update")
|
|
self.assertEqual(history[2].operation_type, "install")
|
|
|
|
def test_concurrent_operation_prevention(self):
|
|
"""Test that concurrent operations on same plugin are prevented."""
|
|
plugin_id = "test-plugin"
|
|
|
|
# Enqueue first operation
|
|
op1_id = self.operation_queue.enqueue_operation(
|
|
OperationType.INSTALL,
|
|
plugin_id
|
|
)
|
|
|
|
# Get the operation to check its status
|
|
op1 = self.operation_queue.get_operation_status(op1_id)
|
|
self.assertIsNotNone(op1)
|
|
|
|
# Try to enqueue second operation
|
|
# Note: If the first operation completes quickly, it may not raise an error
|
|
# The prevention only works for truly concurrent (pending/running) operations
|
|
try:
|
|
op2_id = self.operation_queue.enqueue_operation(
|
|
OperationType.UPDATE,
|
|
plugin_id
|
|
)
|
|
# If no exception, the first operation may have completed already
|
|
# This is acceptable - the mechanism prevents truly concurrent operations
|
|
except ValueError as e:
|
|
# Expected behavior when first operation is still pending/running
|
|
self.assertIn("already has an active operation", str(e))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|
|
|