Files
hermes-webui/tests/test_messages_stale_stream_source_scope.py
T
nesquena-hermes da5bf69aee fix(sidebar): hoist _sessionAttentionState to fix ReferenceError crash (#3696) + scope-undef prevention gate (#3698)
* fix(sidebar): hoist _sessionAttentionState to top-level scope (#3696)

_sessionAttentionState was declared inside renderSessionListFromCache() and
relied on function hoisting, but the top-level function _sidebarRowHasVisible
Messages (reached via renderSessionListFromCache -> _partitionSidebarSessionRows)
called it bare. Hoisting is scoped to the enclosing function, so every sidebar
cache-render threw 'ReferenceError: _sessionAttentionState is not defined' and
the session list went blank. Regressed in #3672 (v0.51.269) when _sidebarRow
HasVisibleMessages was extracted to top level.

Fix: move _sessionAttentionState to top-level scope (it is pure — only uses its
arg plus the i18n global t), so both the visibility predicate and the nested
per-row renderer can reach it.

Prevention (the durable half): add scripts/scope_undef_gate.py — models the
classic-<script> shared global scope (union of all static files' top-level
symbols) and runs ESLint no-undef per file, flagging a function defined nested
but called from a sibling scope. Wired into CI (.github/workflows/tests.yml lint
job) alongside the existing no-const-assign runtime gate, plus an in-suite test
(test_static_js_scope_undef.py) and a focused structural regression test
(test_issue3696_session_attention_scope.py). RED/GREEN-validated against the
broken tree.

* fix(streaming): thread source param into stale-stream bailout; tighten scope gate

Opus review of #3698 found the new scope_undef_gate's 'source' allowlist entry
was masking a real same-class bug: _bailOutOfTerminalEventsFromStaleStream
(declared inside attachLiveStream, params activeSid/streamId/uploaded/options)
called _closeSource(source) against a 'source' not in its lexical scope. All 5
call sites are inside _wireSSE(source), but JS scope is lexical not dynamic, so
the helper would throw ReferenceError: source is not defined on the stale-stream
terminal-event path (user back in an active session whose old stream finalizes
late).

Fix: thread source as an explicit parameter (declaration + all 5 call sites),
the same make-the-dependency-explicit fix as #3696 — and REMOVE the 'source'
allowlist entry so the gate stays gated against that name (it now passes because
the bug is fixed, not because it's allowlisted). Added the documented
false-negative classes from Opus's review to the gate docstring (name-collision
shadowing, destructuring-regex gap, exposure escape hatches, name-keyed
allowlist) and a focused regression test.

This is the prevention gate catching a real latent bug on its first outing.

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-06-05 20:49:45 -07:00

57 lines
2.8 KiB
Python

"""Regression test for the messages.js stale-stream `source` scope bug.
Surfaced by the scope_undef_gate (scripts/scope_undef_gate.py) during the #3696
review: `_bailOutOfTerminalEventsFromStaleStream` is declared at brace depth 2
inside `attachLiveStream` (whose params are activeSid/streamId/uploaded/options —
no `source`), yet its body called `_closeSource(source)` referencing a `source`
that is NOT in its lexical scope. All call sites live inside `_wireSSE(source)`,
but JS scoping is lexical not dynamic, so when the helper runs it would throw
`ReferenceError: source is not defined` on the stale-stream terminal-event path
(`_ownsActiveStreamOrBackground()` false → user back in an active session whose
old stream finalizes late). Same class as #3696.
Fix: thread `source` as an explicit parameter
(`_bailOutOfTerminalEventsFromStaleStream(source)`) and pass it at every call
site, instead of relying on (broken) scope resolution. This test locks that the
declaration takes the param and no bare-call site remains.
"""
import re
from pathlib import Path
MESSAGES_JS = (Path(__file__).resolve().parents[1] / "static" / "messages.js").read_text(encoding="utf-8")
def test_bailout_helper_takes_source_param():
"""The helper must declare a `source` parameter (not rely on an out-of-scope
closure variable)."""
m = re.search(r"function\s+_bailOutOfTerminalEventsFromStaleStream\s*\(([^)]*)\)", MESSAGES_JS)
assert m, "_bailOutOfTerminalEventsFromStaleStream declaration not found"
params = [p.strip() for p in m.group(1).split(",") if p.strip()]
assert "source" in params, (
"_bailOutOfTerminalEventsFromStaleStream must take `source` as a parameter — "
"it calls _closeSource(source) but is declared inside attachLiveStream (no "
"`source` in scope), so a bare reference throws ReferenceError on the "
f"stale-stream path. Current params: {params}"
)
def test_no_bare_bailout_call_sites_remain():
"""Every call site must pass `source` — a bare `()` call would leave the helper's
`source` undefined again."""
bare = re.findall(r"_bailOutOfTerminalEventsFromStaleStream\(\s*\)", MESSAGES_JS)
assert not bare, (
f"Found {len(bare)} bare _bailOutOfTerminalEventsFromStaleStream() call site(s) "
"with no argument — each must pass `source` so the helper can close the right "
"stream. A bare call reintroduces the ReferenceError."
)
def test_bailout_call_sites_pass_source():
"""Positive check: the call sites pass `source`."""
calls = re.findall(r"_bailOutOfTerminalEventsFromStaleStream\(\s*source\s*\)", MESSAGES_JS)
assert len(calls) >= 5, (
f"expected >=5 call sites passing `source`, found {len(calls)} — if the SSE "
"terminal-event wiring changed, update this count, but every call must still "
"pass source."
)