"""Tests for #4754: approval card dismissal persists across tab switches and restarts. Uses the node-driver (static source extraction) pattern — no browser required. """ from pathlib import Path ROOT = Path(__file__).resolve().parent.parent MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8") INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8") STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8") def _compact(text: str) -> str: return "".join(text.split()) # --------------------------------------------------------------------------- # localStorage helper presence # --------------------------------------------------------------------------- def test_dismissed_approvals_key_defined(): assert "_DISMISSED_APPROVALS_KEY" in MESSAGES_JS assert "hermes_dismissed_approvals" in MESSAGES_JS def test_get_dismissed_approvals_defined(): assert "function _getDismissedApprovals(" in MESSAGES_JS def test_is_approval_dismissed_defined(): assert "function _isApprovalDismissed(" in MESSAGES_JS def test_mark_approval_dismissed_defined(): assert "function _markApprovalDismissed(" in MESSAGES_JS def test_unmark_approval_dismissed_defined(): assert "function _unmarkApprovalDismissed(" in MESSAGES_JS # --------------------------------------------------------------------------- # 100-entry cap: _markApprovalDismissed must call .slice(-100) # --------------------------------------------------------------------------- def test_dismissed_set_capped_at_100(): compact = _compact(MESSAGES_JS) assert ".slice(-100)" in compact, "_markApprovalDismissed must cap the set at 100" # --------------------------------------------------------------------------- # Guard in showApprovalCard # --------------------------------------------------------------------------- def test_dismiss_approval_card_defined(): assert "function dismissApprovalCard(" in MESSAGES_JS def test_dismiss_approval_card_hides_card(): compact = _compact(MESSAGES_JS) func_start = compact.find("functiondismissApprovalCard(") assert func_start != -1 body_end = compact.find("}", func_start) body = compact[func_start:body_end + 1] assert "hideApprovalCard(true)" in body def test_dismiss_approval_card_captures_sid_before_hide(): # The session ID must be captured before hideApprovalCard(true) nulls out # _approvalSessionId so the clear call receives the correct ID. compact = _compact(MESSAGES_JS) func_start = compact.find("functiondismissApprovalCard(") assert func_start != -1 body_end = compact.find("}", func_start) body = compact[func_start:body_end + 1] sid_pos = body.find("constsid=_approvalSessionId") hide_pos = body.find("hideApprovalCard(true)") assert sid_pos != -1, "sid must be captured via const sid=_approvalSessionId" assert sid_pos < hide_pos, "sid must be captured before hideApprovalCard is called" # --------------------------------------------------------------------------- # respondApproval prunes dismissed set # --------------------------------------------------------------------------- def test_respond_approval_unmarks_dismissed(): compact = _compact(MESSAGES_JS) func_start = compact.find("asyncfunctionrespondApproval(") assert func_start != -1 # Find the closing brace of the function (scan for matching }) assert "_unmarkApprovalDismissed(sid,approvalId)" in compact[func_start:], \ "_unmarkApprovalDismissed(sid,approvalId) must be called inside respondApproval" def test_respond_approval_unmarks_before_clear(): # _unmarkApprovalDismissed must come before _approvalCurrentId is set to null # so approvalId still holds the right value compact = _compact(MESSAGES_JS) func_start = compact.find("asyncfunctionrespondApproval(") assert func_start != -1 unmark_idx = compact.find("_unmarkApprovalDismissed(sid,approvalId)", func_start) clear_idx = compact.find("_approvalCurrentId=null;", func_start) assert unmark_idx != -1 assert clear_idx != -1 assert unmark_idx < clear_idx, \ "_unmarkApprovalDismissed must be called before _approvalCurrentId is cleared" # --------------------------------------------------------------------------- # No-pending poll branch prunes dismissed set # --------------------------------------------------------------------------- def test_no_pending_branch_unmarks_dismissed(): compact = _compact(MESSAGES_JS) # Anchor on the poll-specific else-if branch that checks mismatched session branch_marker = "elseif(!_approvalPollingSessionMissingOrMismatched(sid)){" branch_start = compact.find(branch_marker) assert branch_start != -1, "no-pending poll else-if branch must exist" nearby = compact[branch_start:branch_start + 400] # Must use session-scoped lookup, not the global _approvalCurrentId assert "_approvalPendingBySession.get(sid)" in nearby, \ "no-pending poll branch must read session-scoped pending via _approvalPendingBySession.get(sid)" assert "_unmarkApprovalDismissed(sid,_resolvedId)" in nearby, \ "no-pending poll branch must unmark the session-scoped resolved ID" # Must NOT use the global _approvalCurrentId to unmark (that caused the cross-session bug) assert "_unmarkApprovalDismissed(sid,_approvalCurrentId)" not in nearby, \ "no-pending poll branch must not unmark via the global _approvalCurrentId" def test_no_pending_branch_reads_pending_before_clear(): # The session-scoped entry must be read BEFORE _clearApprovalPendingForSession erases it. compact = _compact(MESSAGES_JS) branch_marker = "elseif(!_approvalPollingSessionMissingOrMismatched(sid)){" branch_start = compact.find(branch_marker) assert branch_start != -1 nearby = compact[branch_start:branch_start + 400] get_idx = nearby.find("_approvalPendingBySession.get(sid)") clear_idx = nearby.find("_clearApprovalPendingForSession(sid)") assert get_idx != -1, "_approvalPendingBySession.get(sid) must appear in branch" assert clear_idx != -1, "_clearApprovalPendingForSession(sid) must appear in branch" assert get_idx < clear_idx, \ "_approvalPendingBySession.get(sid) must come before _clearApprovalPendingForSession" def test_cross_session_dismiss_not_cleared_by_other_session_poll(): """Polling session B with no pending must not un-dismiss session A's dismissed approval. Simulates the node-driver scenario: - session A has approval X in _approvalPendingBySession (it was dismissed) - _approvalCurrentId still holds X from before a session switch - polling session B returns no-pending - the branch must NOT unmark X because _approvalPendingBySession.get(sid_B) is empty """ compact = _compact(MESSAGES_JS) branch_marker = "elseif(!_approvalPollingSessionMissingOrMismatched(sid)){" branch_start = compact.find(branch_marker) assert branch_start != -1 nearby = compact[branch_start:branch_start + 400] # The resolved ID must come from the session map, not the global assert "_approvalPendingBySession.get(sid)" in nearby, \ "resolved ID source must be session-scoped, not the global _approvalCurrentId" # The guard must be conditional on the resolved ID being truthy so that when # the session being polled has no pending entry, _unmarkApprovalDismissed is skipped. assert "if(_resolvedId)" in nearby, \ "unmark must be gated on _resolvedId so it fires only for the resolved session" # --------------------------------------------------------------------------- # index.html: dismiss button present # --------------------------------------------------------------------------- def test_dismiss_button_in_html(): assert 'class="approval-dismiss"' in INDEX_HTML def test_dismiss_button_onclick(): assert 'onclick="dismissApprovalCard()"' in INDEX_HTML def test_dismiss_button_aria_label(): assert 'aria-label="Dismiss approval"' in INDEX_HTML def test_dismiss_button_near_collapse_button(): # Dismiss button must appear after collapse button in document order collapse_idx = INDEX_HTML.find('id="approvalCollapse"') dismiss_idx = INDEX_HTML.find('class="approval-dismiss"') assert collapse_idx != -1 assert dismiss_idx != -1 assert dismiss_idx > collapse_idx, "dismiss button must follow collapse button" # --------------------------------------------------------------------------- # CSS: .approval-dismiss styled # --------------------------------------------------------------------------- def test_approval_dismiss_css_defined(): assert ".approval-dismiss" in STYLE_CSS # --------------------------------------------------------------------------- # Functional: session-namespaced dismissal (the cross-session collision fix) # --------------------------------------------------------------------------- import json import shutil import subprocess NODE = shutil.which("node") def _extract_fn(src: str, name: str) -> str: start = src.index(f"function {name}(") brace = src.index("{", start) depth = 0 for i in range(brace, len(src)): if src[i] == "{": depth += 1 elif src[i] == "}": depth -= 1 if depth == 0: return src[start:i + 1] raise AssertionError(f"{name} body not closed") def test_same_approval_id_in_two_sessions_does_not_collide(): """The bug Codex found: dismissing approval_id 'X' in session A must NOT hide a still-pending approval_id 'X' in session B (gateway/run sources can reuse externally-supplied IDs across sessions). Drives the real helper functions in node with a mocked localStorage.""" if NODE is None: import pytest pytest.skip("node not available") helpers = "\n".join( _extract_fn(MESSAGES_JS, n) for n in ("_promptNotifyKey", "_approvalDismissKey", "_getDismissedApprovals", "_isApprovalDismissed", "_markApprovalDismissed", "_unmarkApprovalDismissed") ) script = ( "const _store = {};\n" "const localStorage = {\n" " getItem: k => (k in _store ? _store[k] : null),\n" " setItem: (k, v) => { _store[k] = String(v); },\n" "};\n" "const _DISMISSED_APPROVALS_KEY = 'hermes_dismissed_approvals';\n" + helpers + "\n" "// Dismiss approval 'X' in session A.\n" "_markApprovalDismissed('sessionA', 'X');\n" "const out = {\n" " a_dismissed: _isApprovalDismissed('sessionA', 'X'),\n" " b_not_dismissed: _isApprovalDismissed('sessionB', 'X'),\n" " diff_id_same_session_not_dismissed: _isApprovalDismissed('sessionA', 'Y'),\n" "};\n" "// Responding in session B (unmark) must not clear session A's dismissal.\n" "_unmarkApprovalDismissed('sessionB', 'X');\n" "out.a_still_dismissed_after_b_unmark = _isApprovalDismissed('sessionA', 'X');\n" "process.stdout.write(JSON.stringify(out));\n" ) result = subprocess.run([NODE, "-e", script], check=True, capture_output=True, text=True, timeout=15) out = json.loads(result.stdout) assert out["a_dismissed"] is True, "session A's own dismissal must register" assert out["b_not_dismissed"] is False, ( "CROSS-SESSION COLLISION: dismissing approval_id 'X' in session A must NOT " "hide the same approval_id 'X' in session B" ) assert out["diff_id_same_session_not_dismissed"] is False assert out["a_still_dismissed_after_b_unmark"] is True, ( "un-dismissing 'X' in session B must not clear session A's dismissal of 'X'" )