Files
hermes-webui/tests/test_issue3696_session_attention_scope.py
T
nesquena-hermesandnesquena-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

84 lines
3.9 KiB
Python

"""Regression test for #3696 — `_sessionAttentionState is not defined`.
Bug: `_sessionAttentionState` was declared INSIDE `renderSessionListFromCache()`
and relied on "function hoisting", but the separate top-level function
`_sidebarRowHasVisibleMessages` (reached via renderSessionListFromCache ->
_partitionSidebarSessionRows) called it BARE. Function hoisting is scoped to the
enclosing function, so the call threw `ReferenceError: _sessionAttentionState is
not defined` on every sidebar cache-render — the session list went blank
(v0.51.269, regressed by #3672 when _sidebarRowHasVisibleMessages was extracted
to top level).
Fix: hoist `_sessionAttentionState` to top-level (module/global) scope so both the
top-level visibility predicate and the nested per-row renderer can reach it.
This is a structural test (no node/eslint needed, runs in every shard). The
behavioral scope-analysis guard lives in tests/test_static_js_scope_undef.py +
scripts/scope_undef_gate.py, which catch the whole class. This test pins the
specific #3696 invariant cheaply.
"""
import re
from pathlib import Path
SESSIONS_JS = (Path(__file__).resolve().parents[1] / "static" / "sessions.js").read_text(encoding="utf-8")
def _brace_body(src: str, open_brace_idx: int) -> tuple[int, int]:
"""Return (start, end) char offsets of the body delimited by the brace at
open_brace_idx (exclusive of the braces)."""
depth = 1
i = open_brace_idx + 1
while i < len(src) and depth:
if src[i] == "{":
depth += 1
elif src[i] == "}":
depth -= 1
i += 1
return open_brace_idx + 1, i - 1
def _function_span(src: str, name: str) -> tuple[int, int]:
m = re.search(r"function\s+" + re.escape(name) + r"\s*\(", src)
assert m, f"function {name} not found"
brace = src.find("{", m.end())
return _brace_body(src, brace)
def test_session_attention_state_is_top_level():
"""`_sessionAttentionState` must be declared at top-level scope (column 0),
not nested inside another function — otherwise the top-level callers throw
ReferenceError (#3696)."""
decls = re.findall(r"^(\s*)function\s+_sessionAttentionState\s*\(", SESSIONS_JS, re.M)
assert decls, "_sessionAttentionState declaration not found"
assert len(decls) == 1, f"expected exactly one declaration, found {len(decls)}"
assert decls[0] == "", (
"#3696: _sessionAttentionState must be a TOP-LEVEL function (no leading "
f"indentation), but it is indented {len(decls[0])} spaces (nested). A nested "
"declaration only hoists within its enclosing function, so top-level callers "
"like _sidebarRowHasVisibleMessages throw 'ReferenceError: _sessionAttentionState "
"is not defined'."
)
def test_session_attention_state_not_nested_in_render_from_cache():
"""Belt-and-suspenders: the definition must NOT live inside the body of
`renderSessionListFromCache` (where it was when #3696 shipped)."""
start, end = _function_span(SESSIONS_JS, "renderSessionListFromCache")
body = SESSIONS_JS[start:end]
assert "function _sessionAttentionState(" not in body, (
"#3696: _sessionAttentionState is defined inside renderSessionListFromCache() — "
"it must be hoisted to top-level so _sidebarRowHasVisibleMessages can call it."
)
def test_sidebar_visibility_predicate_calls_attention_state():
"""Guard the regression's trigger: _sidebarRowHasVisibleMessages (top-level)
references _sessionAttentionState. If this call is ever removed the bug can't
recur, but while it exists the function MUST be top-level (asserted above)."""
start, end = _function_span(SESSIONS_JS, "_sidebarRowHasVisibleMessages")
body = SESSIONS_JS[start:end]
assert "_sessionAttentionState(" in body, (
"_sidebarRowHasVisibleMessages no longer calls _sessionAttentionState — if this "
"is intentional, update this test; the #3696 invariant assumes this call exists."
)