888f8fd16e
ruff --select UP007,UP045 --fix converted ~1760 sites across the backend: `Optional[T]` → `T | None`, `Union[X, Y]` → `X | Y`. The remaining module-level alias targets that ruff conservatively skips (BindableFloatInput, ColorList, DeviceConfig) were converted by hand earlier in the pass. black -formatted the result so the wider unions fit cleanly under the 100-char line budget. pyproject.toml now sets [tool.ruff.lint] extend-select = ["UP007", "UP045"] so future legacy imports fire CI on every push. The pre-commit ruff hook was bumped from v0.8.0 -> v0.15.12 to recognise UP045 (split off from UP007 in v0.13).
94 lines
3.6 KiB
Python
94 lines
3.6 KiB
Python
"""Cross-cutting parity guard between server event emitters and the TS allowlist.
|
|
|
|
The frontend ``events-ws.ts`` module enforces a typed allowlist on inbound
|
|
WebSocket message ``type`` values. If a new server-side ``fire_event(...)``
|
|
call introduces a type that isn't in the JS allowlist, the corresponding
|
|
UI silently breaks. This test rederives the set of types the server emits
|
|
from the Python source and asserts every one is present in the TS
|
|
allowlist source, catching the drift at CI time rather than in production.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
_SERVER_SRC = _REPO_ROOT / "src" / "ledgrab"
|
|
_EVENTS_WS = _SERVER_SRC / "static" / "js" / "core" / "events-ws.ts"
|
|
|
|
|
|
# Scan the small set of files that funnel into the global ``events-ws.ts``
|
|
# dispatcher (``fire_event`` / ``_fire_event`` / ``_emit`` / the wrapper
|
|
# ``fire_entity_event``). Everything else is per-WS-handler protocol and
|
|
# is read off its own connection — not relevant here.
|
|
#
|
|
# Patterns we recognise as broadcast emit sites:
|
|
# fire_event({"type": "<x>" ...})
|
|
# _fire_event({"type": "<x>" ...})
|
|
# self._emit("<x>", ...) — discovery_watcher.py
|
|
# fire_entity_event(...) — always "entity_changed"
|
|
_EMIT_PATTERNS = (
|
|
re.compile(r'fire_event\(\s*\{\s*"type"\s*:\s*"([a-z_]+)"'),
|
|
re.compile(r'_fire_event\(\s*\{\s*"type"\s*:\s*"([a-z_]+)"'),
|
|
re.compile(r'self\._emit\(\s*"([a-z_]+)"'),
|
|
)
|
|
_FIRE_ENTITY_EVENT_RE = re.compile(r"fire_entity_event\(")
|
|
|
|
|
|
def _server_event_types() -> set[str]:
|
|
"""Extract event types that flow into the events-ws.ts dispatcher."""
|
|
discovered: set[str] = set()
|
|
for path in _SERVER_SRC.rglob("*.py"):
|
|
try:
|
|
text = path.read_text(encoding="utf-8")
|
|
except OSError:
|
|
continue
|
|
for rx in _EMIT_PATTERNS:
|
|
discovered.update(rx.findall(text))
|
|
if _FIRE_ENTITY_EVENT_RE.search(text):
|
|
# fire_entity_event always dispatches the "entity_changed" type.
|
|
discovered.add("entity_changed")
|
|
return discovered
|
|
|
|
|
|
def _ts_allowlist_types() -> set[str]:
|
|
"""Parse the allowlist constant out of events-ws.ts source text."""
|
|
text = _EVENTS_WS.read_text(encoding="utf-8")
|
|
match = re.search(
|
|
r"_ALLOWED_SERVER_EVENT_TYPES[^=]*=\s*new\s+Set\(\[(.*?)\]\)",
|
|
text,
|
|
re.DOTALL,
|
|
)
|
|
if not match:
|
|
pytest.fail("Could not locate _ALLOWED_SERVER_EVENT_TYPES in events-ws.ts")
|
|
body = match.group(1)
|
|
return set(re.findall(r"'([a-z_]+)'", body))
|
|
|
|
|
|
def test_every_server_event_type_is_in_ts_allowlist() -> None:
|
|
"""Catch the regression where the JS allowlist forgets a new event.
|
|
|
|
Failure mode: a server-side ``fire_event({"type": "foo"})`` ships, the
|
|
UI never sees it because ``events-ws.ts`` filters it out, and the bug
|
|
surfaces only via "feature X stopped working" reports.
|
|
"""
|
|
server_types = _server_event_types()
|
|
allowlist = _ts_allowlist_types()
|
|
missing = sorted(server_types - allowlist)
|
|
assert not missing, (
|
|
"Server emits these event types but the TS allowlist excludes "
|
|
f"them — features that listen will silently break: {missing}. "
|
|
"Either add them to _ALLOWED_SERVER_EVENT_TYPES in "
|
|
"static/js/core/events-ws.ts or move them to _LOCAL_TYPES in this "
|
|
"test if they're a per-WS protocol envelope."
|
|
)
|
|
|
|
|
|
def test_ts_allowlist_is_not_empty() -> None:
|
|
"""Sanity check: the parse helper actually found the constant."""
|
|
allowlist = _ts_allowlist_types()
|
|
assert allowlist, "Parser failed to extract allowlist from events-ws.ts"
|