refactor: comprehensive code quality, security, and release readiness improvements
Some checks failed
Lint & Test / test (push) Failing after 48s
Some checks failed
Lint & Test / test (push) Failing after 48s
Security: tighten CORS defaults, add webhook rate limiting, fix XSS in automations, guard WebSocket JSON.parse, validate ADB address input, seal debug exception leak, URL-encode WS tokens, CSS.escape in selectors. Code quality: add Pydantic models for brightness/power endpoints, fix thread safety and name uniqueness in DeviceStore, immutable update pattern, split 6 oversized files into 16 focused modules, enable TypeScript strictNullChecks (741→102 errors), type state variables, add dom-utils helper, migrate 3 modules from inline onclick to event delegation, ProcessorDependencies dataclass. Performance: async store saves, health endpoint log level, command palette debounce, optimized entity-events comparison, fix service worker precache list. Testing: expand from 45 to 293 passing tests — add store tests (141), route tests (25), core logic tests (42), E2E flow tests (33), organize into tests/api/, tests/storage/, tests/core/, tests/e2e/. DevOps: CI test pipeline, pre-commit config, Dockerfile multi-stage build with non-root user and health check, docker-compose improvements, version bump to 0.2.0. Docs: rewrite CLAUDE.md (202→56 lines), server/CLAUDE.md (212→76), create contexts/server-operations.md, fix .js→.ts references, fix env var prefix in README, rewrite INSTALLATION.md, add CONTRIBUTING.md and .env.example.
This commit is contained in:
0
server/tests/core/__init__.py
Normal file
0
server/tests/core/__init__.py
Normal file
290
server/tests/core/test_automation_engine.py
Normal file
290
server/tests/core/test_automation_engine.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""Tests for AutomationEngine — condition evaluation in isolation."""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from wled_controller.core.automations.automation_engine import AutomationEngine
|
||||
from wled_controller.storage.automation import (
|
||||
AlwaysCondition,
|
||||
ApplicationCondition,
|
||||
Automation,
|
||||
DisplayStateCondition,
|
||||
MQTTCondition,
|
||||
StartupCondition,
|
||||
SystemIdleCondition,
|
||||
TimeOfDayCondition,
|
||||
WebhookCondition,
|
||||
)
|
||||
from wled_controller.storage.automation_store import AutomationStore
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_store(tmp_path) -> AutomationStore:
|
||||
return AutomationStore(str(tmp_path / "auto.json"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_manager():
|
||||
m = MagicMock()
|
||||
m.fire_event = MagicMock()
|
||||
return m
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine(mock_store, mock_manager) -> AutomationEngine:
|
||||
"""Build an AutomationEngine with the PlatformDetector mocked out.
|
||||
|
||||
PlatformDetector starts a Windows display-power listener thread that
|
||||
causes access violations in the test environment, so we replace it
|
||||
with a simple MagicMock.
|
||||
"""
|
||||
with patch(
|
||||
"wled_controller.core.automations.automation_engine.PlatformDetector"
|
||||
):
|
||||
eng = AutomationEngine(
|
||||
automation_store=mock_store,
|
||||
processor_manager=mock_manager,
|
||||
poll_interval=0.1,
|
||||
)
|
||||
return eng
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Condition evaluation (unit-level)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConditionEvaluation:
|
||||
"""Test _evaluate_condition for each condition type individually."""
|
||||
|
||||
def _make_automation(self, conditions):
|
||||
now = datetime.now(timezone.utc)
|
||||
return Automation(
|
||||
id="test_auto",
|
||||
name="Test",
|
||||
enabled=True,
|
||||
condition_logic="or",
|
||||
conditions=conditions,
|
||||
scene_preset_id=None,
|
||||
deactivation_mode="none",
|
||||
deactivation_scene_preset_id=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
def _eval(self, engine, condition, **kwargs):
|
||||
"""Invoke the private _evaluate_condition method."""
|
||||
defaults = dict(
|
||||
running_procs=set(),
|
||||
topmost_proc=None,
|
||||
topmost_fullscreen=False,
|
||||
fullscreen_procs=set(),
|
||||
idle_seconds=None,
|
||||
display_state=None,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return engine._evaluate_condition(
|
||||
condition,
|
||||
defaults["running_procs"],
|
||||
defaults["topmost_proc"],
|
||||
defaults["topmost_fullscreen"],
|
||||
defaults["fullscreen_procs"],
|
||||
defaults["idle_seconds"],
|
||||
defaults["display_state"],
|
||||
)
|
||||
|
||||
def test_always_true(self, engine):
|
||||
assert self._eval(engine, AlwaysCondition()) is True
|
||||
|
||||
def test_startup_true(self, engine):
|
||||
assert self._eval(engine, StartupCondition()) is True
|
||||
|
||||
def test_application_running_match(self, engine):
|
||||
cond = ApplicationCondition(apps=["chrome.exe"], match_type="running")
|
||||
result = self._eval(
|
||||
engine, cond,
|
||||
running_procs={"chrome.exe", "explorer.exe"},
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_application_running_no_match(self, engine):
|
||||
cond = ApplicationCondition(apps=["chrome.exe"], match_type="running")
|
||||
result = self._eval(
|
||||
engine, cond,
|
||||
running_procs={"explorer.exe"},
|
||||
)
|
||||
assert result is False
|
||||
|
||||
def test_application_topmost_match(self, engine):
|
||||
cond = ApplicationCondition(apps=["game.exe"], match_type="topmost")
|
||||
result = self._eval(
|
||||
engine, cond,
|
||||
topmost_proc="game.exe",
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_application_topmost_no_match(self, engine):
|
||||
cond = ApplicationCondition(apps=["game.exe"], match_type="topmost")
|
||||
result = self._eval(
|
||||
engine, cond,
|
||||
topmost_proc="chrome.exe",
|
||||
)
|
||||
assert result is False
|
||||
|
||||
def test_time_of_day_within_range(self, engine):
|
||||
cond = TimeOfDayCondition(start_time="00:00", end_time="23:59")
|
||||
result = self._eval(engine, cond)
|
||||
assert result is True
|
||||
|
||||
def test_system_idle_when_idle(self, engine):
|
||||
cond = SystemIdleCondition(idle_minutes=5, when_idle=True)
|
||||
result = self._eval(engine, cond, idle_seconds=600.0) # 10 minutes idle
|
||||
assert result is True
|
||||
|
||||
def test_system_idle_not_idle(self, engine):
|
||||
cond = SystemIdleCondition(idle_minutes=5, when_idle=True)
|
||||
result = self._eval(engine, cond, idle_seconds=60.0) # 1 minute idle
|
||||
assert result is False
|
||||
|
||||
def test_system_idle_when_not_idle(self, engine):
|
||||
"""when_idle=False means active when user is NOT idle."""
|
||||
cond = SystemIdleCondition(idle_minutes=5, when_idle=False)
|
||||
result = self._eval(engine, cond, idle_seconds=60.0) # 1 min idle (not yet 5)
|
||||
assert result is True
|
||||
|
||||
def test_display_state_match(self, engine):
|
||||
cond = DisplayStateCondition(state="on")
|
||||
result = self._eval(engine, cond, display_state="on")
|
||||
assert result is True
|
||||
|
||||
def test_display_state_no_match(self, engine):
|
||||
cond = DisplayStateCondition(state="off")
|
||||
result = self._eval(engine, cond, display_state="on")
|
||||
assert result is False
|
||||
|
||||
def test_webhook_active(self, engine):
|
||||
cond = WebhookCondition(token="tok123")
|
||||
engine._webhook_states["tok123"] = True
|
||||
result = self._eval(engine, cond)
|
||||
assert result is True
|
||||
|
||||
def test_webhook_inactive(self, engine):
|
||||
cond = WebhookCondition(token="tok123")
|
||||
# Not in _webhook_states → False
|
||||
result = self._eval(engine, cond)
|
||||
assert result is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Condition logic (AND / OR)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConditionLogic:
|
||||
def _make_automation(self, conditions, logic="or"):
|
||||
now = datetime.now(timezone.utc)
|
||||
return Automation(
|
||||
id="logic_auto",
|
||||
name="Logic",
|
||||
enabled=True,
|
||||
condition_logic=logic,
|
||||
conditions=conditions,
|
||||
scene_preset_id=None,
|
||||
deactivation_mode="none",
|
||||
deactivation_scene_preset_id=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
def test_or_any_true(self, engine):
|
||||
auto = self._make_automation(
|
||||
[
|
||||
ApplicationCondition(apps=["missing.exe"], match_type="running"),
|
||||
AlwaysCondition(),
|
||||
],
|
||||
logic="or",
|
||||
)
|
||||
result = engine._evaluate_conditions(
|
||||
auto,
|
||||
running_procs=set(),
|
||||
topmost_proc=None,
|
||||
topmost_fullscreen=False,
|
||||
fullscreen_procs=set(),
|
||||
idle_seconds=None,
|
||||
display_state=None,
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_and_all_must_be_true(self, engine):
|
||||
auto = self._make_automation(
|
||||
[
|
||||
AlwaysCondition(),
|
||||
ApplicationCondition(apps=["missing.exe"], match_type="running"),
|
||||
],
|
||||
logic="and",
|
||||
)
|
||||
result = engine._evaluate_conditions(
|
||||
auto,
|
||||
running_procs=set(),
|
||||
topmost_proc=None,
|
||||
topmost_fullscreen=False,
|
||||
fullscreen_procs=set(),
|
||||
idle_seconds=None,
|
||||
display_state=None,
|
||||
)
|
||||
assert result is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Webhook state management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWebhookState:
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_webhook_state_activate(self, engine):
|
||||
await engine.set_webhook_state("tok_1", True)
|
||||
assert engine._webhook_states["tok_1"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_webhook_state_deactivate(self, engine):
|
||||
engine._webhook_states["tok_1"] = True
|
||||
await engine.set_webhook_state("tok_1", False)
|
||||
assert engine._webhook_states["tok_1"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Start / Stop lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEngineLifecycle:
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_creates_task(self, engine):
|
||||
await engine.start()
|
||||
assert engine._task is not None
|
||||
await engine.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_cancels_task(self, engine):
|
||||
await engine.start()
|
||||
await engine.stop()
|
||||
assert engine._task is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_double_start_is_safe(self, engine):
|
||||
await engine.start()
|
||||
await engine.start() # no-op
|
||||
await engine.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_without_start_is_safe(self, engine):
|
||||
await engine.stop() # no-op
|
||||
185
server/tests/core/test_sync_clock_runtime.py
Normal file
185
server/tests/core/test_sync_clock_runtime.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""Tests for SyncClockRuntime — thread-safe timing, pause/resume/reset."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from wled_controller.core.processing.sync_clock_runtime import SyncClockRuntime
|
||||
|
||||
|
||||
class TestSyncClockRuntimeInit:
|
||||
def test_default_speed(self):
|
||||
rt = SyncClockRuntime()
|
||||
assert rt.speed == 1.0
|
||||
|
||||
def test_custom_speed(self):
|
||||
rt = SyncClockRuntime(speed=2.5)
|
||||
assert rt.speed == 2.5
|
||||
|
||||
def test_starts_running(self):
|
||||
rt = SyncClockRuntime()
|
||||
assert rt.is_running is True
|
||||
|
||||
def test_initial_time_near_zero(self):
|
||||
rt = SyncClockRuntime()
|
||||
t = rt.get_time()
|
||||
assert 0.0 <= t < 0.1 # should be very small right after creation
|
||||
|
||||
|
||||
class TestSyncClockRuntimeSpeed:
|
||||
def test_set_speed(self):
|
||||
rt = SyncClockRuntime()
|
||||
rt.speed = 3.0
|
||||
assert rt.speed == 3.0
|
||||
|
||||
def test_speed_zero(self):
|
||||
rt = SyncClockRuntime()
|
||||
rt.speed = 0.0
|
||||
assert rt.speed == 0.0
|
||||
|
||||
def test_speed_negative(self):
|
||||
"""Negative speed is allowed at the runtime level (clamping is store-level)."""
|
||||
rt = SyncClockRuntime()
|
||||
rt.speed = -1.0
|
||||
assert rt.speed == -1.0
|
||||
|
||||
|
||||
class TestSyncClockRuntimeGetTime:
|
||||
def test_time_advances(self):
|
||||
rt = SyncClockRuntime()
|
||||
t1 = rt.get_time()
|
||||
time.sleep(0.05)
|
||||
t2 = rt.get_time()
|
||||
assert t2 > t1
|
||||
|
||||
def test_time_is_real_seconds(self):
|
||||
"""get_time returns real elapsed seconds, NOT speed-scaled."""
|
||||
rt = SyncClockRuntime(speed=5.0)
|
||||
time.sleep(0.1)
|
||||
t = rt.get_time()
|
||||
# Should be roughly 0.1s (real time), not 0.5s (speed-scaled)
|
||||
assert 0.05 < t < 0.5
|
||||
|
||||
|
||||
class TestSyncClockRuntimePauseResume:
|
||||
def test_pause_freezes_time(self):
|
||||
rt = SyncClockRuntime()
|
||||
time.sleep(0.05)
|
||||
rt.pause()
|
||||
t1 = rt.get_time()
|
||||
time.sleep(0.05)
|
||||
t2 = rt.get_time()
|
||||
assert t1 == t2 # time should not advance while paused
|
||||
|
||||
def test_pause_sets_not_running(self):
|
||||
rt = SyncClockRuntime()
|
||||
rt.pause()
|
||||
assert rt.is_running is False
|
||||
|
||||
def test_resume_unfreezes_time(self):
|
||||
rt = SyncClockRuntime()
|
||||
rt.pause()
|
||||
time.sleep(0.02)
|
||||
rt.resume()
|
||||
assert rt.is_running is True
|
||||
t1 = rt.get_time()
|
||||
time.sleep(0.05)
|
||||
t2 = rt.get_time()
|
||||
assert t2 > t1
|
||||
|
||||
def test_resume_preserves_offset(self):
|
||||
"""After pause+resume, time continues from where it was paused."""
|
||||
rt = SyncClockRuntime()
|
||||
time.sleep(0.05)
|
||||
rt.pause()
|
||||
paused_time = rt.get_time()
|
||||
time.sleep(0.1)
|
||||
rt.resume()
|
||||
resumed_time = rt.get_time()
|
||||
# Resumed time should be close to paused time (not reset, not including pause gap)
|
||||
assert abs(resumed_time - paused_time) < 0.05
|
||||
|
||||
def test_double_pause_is_safe(self):
|
||||
rt = SyncClockRuntime()
|
||||
time.sleep(0.02)
|
||||
rt.pause()
|
||||
t1 = rt.get_time()
|
||||
rt.pause() # second pause should be a no-op
|
||||
t2 = rt.get_time()
|
||||
assert t1 == t2
|
||||
|
||||
def test_double_resume_is_safe(self):
|
||||
rt = SyncClockRuntime()
|
||||
rt.resume() # already running, should be a no-op
|
||||
assert rt.is_running is True
|
||||
|
||||
|
||||
class TestSyncClockRuntimeReset:
|
||||
def test_reset_sets_time_to_zero(self):
|
||||
rt = SyncClockRuntime()
|
||||
time.sleep(0.05)
|
||||
rt.reset()
|
||||
t = rt.get_time()
|
||||
assert t < 0.02 # should be very close to zero
|
||||
|
||||
def test_reset_while_paused(self):
|
||||
rt = SyncClockRuntime()
|
||||
time.sleep(0.05)
|
||||
rt.pause()
|
||||
rt.reset()
|
||||
# After reset, offset is 0 but clock is still paused — is_running unchanged
|
||||
# The reset resets offset and epoch but doesn't change running state
|
||||
t = rt.get_time()
|
||||
# Time might be ~0 if paused, or very small if running
|
||||
assert t < 0.05
|
||||
|
||||
def test_reset_preserves_speed(self):
|
||||
rt = SyncClockRuntime(speed=3.0)
|
||||
time.sleep(0.05)
|
||||
rt.reset()
|
||||
assert rt.speed == 3.0
|
||||
|
||||
|
||||
class TestSyncClockRuntimeThreadSafety:
|
||||
def test_concurrent_get_time(self):
|
||||
rt = SyncClockRuntime()
|
||||
results = []
|
||||
errors = []
|
||||
|
||||
def _read():
|
||||
try:
|
||||
for _ in range(100):
|
||||
t = rt.get_time()
|
||||
results.append(t)
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=_read) for _ in range(8)]
|
||||
for th in threads:
|
||||
th.start()
|
||||
for th in threads:
|
||||
th.join()
|
||||
|
||||
assert len(errors) == 0
|
||||
assert len(results) == 800
|
||||
|
||||
def test_concurrent_pause_resume(self):
|
||||
rt = SyncClockRuntime()
|
||||
errors = []
|
||||
|
||||
def _toggle():
|
||||
try:
|
||||
for _ in range(50):
|
||||
rt.pause()
|
||||
rt.resume()
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=_toggle) for _ in range(4)]
|
||||
for th in threads:
|
||||
th.start()
|
||||
for th in threads:
|
||||
th.join()
|
||||
|
||||
assert len(errors) == 0
|
||||
Reference in New Issue
Block a user