mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-17 00:28:04 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cf673f3b6 | ||
|
|
d305be6089 | ||
|
|
53af53b4a1 |
@@ -79,4 +79,5 @@ jobs:
|
|||||||
test/test_sports_scroll.py \
|
test/test_sports_scroll.py \
|
||||||
test/test_version_consistency.py \
|
test/test_version_consistency.py \
|
||||||
test/test_plugin_compatibility_gate.py \
|
test/test_plugin_compatibility_gate.py \
|
||||||
test/test_install_preserves_existing.py
|
test/test_install_preserves_existing.py \
|
||||||
|
test/test_core_owned_config_keys.py
|
||||||
|
|||||||
@@ -24,9 +24,20 @@ echo "========================================"
|
|||||||
# Auto-detect latest version if needed
|
# Auto-detect latest version if needed
|
||||||
if [ "$PIXLET_VERSION" = "latest" ]; then
|
if [ "$PIXLET_VERSION" = "latest" ]; then
|
||||||
echo "Detecting latest version..."
|
echo "Detecting latest version..."
|
||||||
PIXLET_VERSION=$(curl -s "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')
|
# GitHub returns this JSON on a single line, so `grep '"tag_name"'`
|
||||||
if [ -z "$PIXLET_VERSION" ]; then
|
# matches the whole document and a greedy `sed 's/.*"([^"]+)".*/\1/'`
|
||||||
echo "Failed to detect latest version, using fallback"
|
# captures the LAST quoted token in it rather than the tag. That resolved
|
||||||
|
# to "mentions_count", which built a download URL for a release that does
|
||||||
|
# not exist. Match the field itself and take the value after it.
|
||||||
|
PIXLET_VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \
|
||||||
|
| grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' \
|
||||||
|
| head -n1 \
|
||||||
|
| sed -E 's/.*:[[:space:]]*"([^"]*)".*/\1/')
|
||||||
|
|
||||||
|
# A wrong-but-non-empty value is what made the old bug silent, so check
|
||||||
|
# the shape rather than just that something came back.
|
||||||
|
if ! printf '%s' "$PIXLET_VERSION" | grep -qE '^v?[0-9]+\.[0-9]+'; then
|
||||||
|
echo "Could not detect the latest version (got: '${PIXLET_VERSION:-<empty>}'), using fallback"
|
||||||
PIXLET_VERSION="v0.50.2"
|
PIXLET_VERSION="v0.50.2"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -67,8 +78,19 @@ download_binary() {
|
|||||||
temp_dir=$(mktemp -d -p "$PROJECT_ROOT" -t pixlet_download.XXXXXXXXXX)
|
temp_dir=$(mktemp -d -p "$PROJECT_ROOT" -t pixlet_download.XXXXXXXXXX)
|
||||||
local temp_file="$temp_dir/$archive_name"
|
local temp_file="$temp_dir/$archive_name"
|
||||||
|
|
||||||
if ! curl -L -o "$temp_file" "$url" 2>/dev/null; then
|
# -f so an HTTP error is a failure. Without it curl writes the 404 body
|
||||||
echo "✗ Failed to download $arch"
|
# to the file and exits 0, and the first sign of trouble is tar saying
|
||||||
|
# "not in gzip format" about what is actually a page of HTML.
|
||||||
|
if ! curl -fL -o "$temp_file" "$url" 2>/dev/null; then
|
||||||
|
echo "✗ Failed to download $arch from $url"
|
||||||
|
rm -rf "$temp_dir"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Belt and braces: a mirror or proxy can return 200 with an error page.
|
||||||
|
if ! gzip -t "$temp_file" 2>/dev/null; then
|
||||||
|
echo "✗ Downloaded file is not a gzip archive: $url"
|
||||||
|
echo " (first bytes: $(head -c 60 "$temp_file" | tr -d '\0' | tr '\n' ' '))"
|
||||||
rm -rf "$temp_dir"
|
rm -rf "$temp_dir"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -395,6 +395,37 @@ class PluginManager:
|
|||||||
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
|
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
#: Config keys the **core** reads out of a plugin's own config block. The
|
||||||
|
#: plugin never declares them, so a schema with
|
||||||
|
#: ``"additionalProperties": false`` — 37 of the 42 published ones — reports
|
||||||
|
#: them as violations and the plugin gets flagged degraded in the web UI for
|
||||||
|
#: using a documented core feature.
|
||||||
|
#:
|
||||||
|
#: Listed explicitly rather than matched on a ``vegas_`` prefix, because
|
||||||
|
#: ``vegas_mode`` is the opposite case: plugins *do* declare that one, and a
|
||||||
|
#: prefix rule would silently stop validating it.
|
||||||
|
#:
|
||||||
|
#: Read by: ``vegas_mode/plugin_adapter.py`` (``vegas_width_pct``,
|
||||||
|
#: ``vegas_overflow``) and ``base_plugin.py`` (``vegas_max_width_screens``).
|
||||||
|
CORE_OWNED_CONFIG_KEYS = frozenset({
|
||||||
|
'vegas_width_pct',
|
||||||
|
'vegas_overflow',
|
||||||
|
'vegas_max_width_screens',
|
||||||
|
})
|
||||||
|
|
||||||
|
def _strip_core_owned_keys(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""A shallow copy of ``config`` without the core's own tuning keys.
|
||||||
|
|
||||||
|
Only the top level is touched, and only when such a key is present, so
|
||||||
|
the common case allocates nothing extra.
|
||||||
|
"""
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
return config
|
||||||
|
if not self.CORE_OWNED_CONFIG_KEYS.intersection(config):
|
||||||
|
return config
|
||||||
|
return {k: v for k, v in config.items()
|
||||||
|
if k not in self.CORE_OWNED_CONFIG_KEYS}
|
||||||
|
|
||||||
def _validate_config_schema_soft(self, plugin_id: str, config: Dict[str, Any]) -> None:
|
def _validate_config_schema_soft(self, plugin_id: str, config: Dict[str, Any]) -> None:
|
||||||
"""Validate a plugin's config against its JSON schema — warn/degrade only.
|
"""Validate a plugin's config against its JSON schema — warn/degrade only.
|
||||||
|
|
||||||
@@ -419,7 +450,7 @@ class PluginManager:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
is_valid, errors = self.schema_manager.validate_config_against_schema(
|
is_valid, errors = self.schema_manager.validate_config_against_schema(
|
||||||
config, schema, plugin_id
|
self._strip_core_owned_keys(config), schema, plugin_id
|
||||||
)
|
)
|
||||||
except Exception as e: # pragma: no cover - defensive
|
except Exception as e: # pragma: no cover - defensive
|
||||||
# Validation machinery itself failed — do not penalise the plugin.
|
# Validation machinery itself failed — do not penalise the plugin.
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""The core's own tuning keys must not make a plugin look broken.
|
||||||
|
|
||||||
|
`vegas_width_pct`, `vegas_overflow` and `vegas_max_width_screens` are read by
|
||||||
|
the *core* out of each plugin's config block — `vegas_mode/plugin_adapter.py`
|
||||||
|
and `base_plugin.py`. No plugin declares them, and 37 of the 42 published
|
||||||
|
config schemas set `"additionalProperties": false`, so schema validation
|
||||||
|
reported them as violations.
|
||||||
|
|
||||||
|
That is not just log noise: `_validate_config_schema_soft` sets `degraded` in
|
||||||
|
the health tracker, which the web UI surfaces. Measured on a real device, **9
|
||||||
|
of 27 installed plugins** were flagged degraded purely for using a documented
|
||||||
|
core feature — including `baseball-scoreboard` and `f1-scoreboard`.
|
||||||
|
|
||||||
|
The fix strips those keys before validating. It deliberately does *not* match
|
||||||
|
on a `vegas_` prefix: `vegas_mode` is plugin-owned and declared in schemas, and
|
||||||
|
a prefix rule would silently stop validating it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.plugin_system.plugin_manager import PluginManager
|
||||||
|
|
||||||
|
|
||||||
|
STRICT_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": False,
|
||||||
|
"properties": {
|
||||||
|
"enabled": {"type": "boolean"},
|
||||||
|
"vegas_mode": {"type": "string"}, # plugin-owned, must stay validated
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def manager():
|
||||||
|
mgr = PluginManager.__new__(PluginManager) # skip the heavy constructor
|
||||||
|
mgr.logger = MagicMock()
|
||||||
|
mgr.schema_manager = MagicMock()
|
||||||
|
mgr._set_degraded_safe = MagicMock()
|
||||||
|
return mgr
|
||||||
|
|
||||||
|
|
||||||
|
class TestStripCoreOwnedKeys:
|
||||||
|
def test_removes_every_core_owned_key(self, manager):
|
||||||
|
cfg = {"enabled": True, "vegas_width_pct": 50,
|
||||||
|
"vegas_overflow": "wrap", "vegas_max_width_screens": 2}
|
||||||
|
assert manager._strip_core_owned_keys(cfg) == {"enabled": True}
|
||||||
|
|
||||||
|
def test_leaves_plugin_owned_vegas_mode_alone(self, manager):
|
||||||
|
"""A prefix rule would have eaten this one."""
|
||||||
|
cfg = {"enabled": True, "vegas_mode": "scroll"}
|
||||||
|
assert manager._strip_core_owned_keys(cfg) == cfg
|
||||||
|
|
||||||
|
def test_returns_the_same_object_when_nothing_to_strip(self, manager):
|
||||||
|
cfg = {"enabled": True}
|
||||||
|
assert manager._strip_core_owned_keys(cfg) is cfg
|
||||||
|
|
||||||
|
def test_does_not_mutate_the_caller_config(self, manager):
|
||||||
|
cfg = {"enabled": True, "vegas_width_pct": 50}
|
||||||
|
manager._strip_core_owned_keys(cfg)
|
||||||
|
assert "vegas_width_pct" in cfg, "the live plugin config was mutated"
|
||||||
|
|
||||||
|
def test_tolerates_a_non_dict(self, manager):
|
||||||
|
assert manager._strip_core_owned_keys(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSoftValidation:
|
||||||
|
def _validate_with(self, manager, config, valid=True, errors=()):
|
||||||
|
manager.schema_manager.load_schema.return_value = STRICT_SCHEMA
|
||||||
|
manager.schema_manager.validate_config_against_schema.return_value = (
|
||||||
|
valid, list(errors))
|
||||||
|
manager._validate_config_schema_soft("baseball-scoreboard", config)
|
||||||
|
return manager.schema_manager.validate_config_against_schema.call_args
|
||||||
|
|
||||||
|
def test_core_keys_never_reach_the_validator(self, manager):
|
||||||
|
"""The regression: these keys reaching a strict schema is what flagged
|
||||||
|
9 of 27 plugins degraded."""
|
||||||
|
args = self._validate_with(
|
||||||
|
manager, {"enabled": True, "vegas_width_pct": 50})
|
||||||
|
validated = args[0][0]
|
||||||
|
assert "vegas_width_pct" not in validated
|
||||||
|
assert validated == {"enabled": True}
|
||||||
|
|
||||||
|
def test_plugin_owned_keys_still_reach_the_validator(self, manager):
|
||||||
|
args = self._validate_with(
|
||||||
|
manager, {"enabled": True, "vegas_mode": "scroll"})
|
||||||
|
assert args[0][0]["vegas_mode"] == "scroll"
|
||||||
|
|
||||||
|
def test_a_genuine_violation_is_still_reported(self, manager):
|
||||||
|
"""Stripping core keys must not turn the check into a no-op."""
|
||||||
|
self._validate_with(
|
||||||
|
manager, {"enabled": True, "typo_key": 1},
|
||||||
|
valid=False, errors=["Field root: 'typo_key' was unexpected"])
|
||||||
|
manager._set_degraded_safe.assert_called()
|
||||||
|
reason = manager._set_degraded_safe.call_args[0][1]
|
||||||
|
assert reason and "typo_key" in reason
|
||||||
Reference in New Issue
Block a user