Files
odysseus/tests/test_esc_menu_stack_js.py
Collin Osborne 471ee494f0 fix: make transient dropdown/popup menus close on Escape
The global Escape arbiter in ui.js only sees `.modal` elements, so the many
ad-hoc dropdowns and context popups that are built on the fly and appended to
<body> ignored Escape entirely: document-library card/chat menus, chat
context/stats/overflow popups, cookbook serve & running menus, calendar event
menus, and compare pane menus.

Add a small DOM-free dismissal registry (static/js/escMenuStack.js). Menus
register a dismiss callback while open, and the arbiter closes the
most-recently-opened one first, so a menu opened over a modal closes before the
modal. bindMenuDismiss() wires the ubiquitous "append-to-body, close on outside
click" idiom to both the outside-click listener and the Escape stack in one
call, and dismissOrRemove() lets the pre-existing bulk removers (scroll/swipe/
modal-dismiss cleanup, reopen sweeps) tear a menu down through its real teardown
instead of orphaning its stack entry.

Covers ~14 menus across documentLibrary, chatRenderer, cookbookServe,
cookbookRunning, calendar, and compare/panes. Every teardown path — item click,
outside click, swipe, toggle, rebuild, bulk cleanup — routes through the
registry so no entry is ever stranded.

tests/test_esc_menu_stack_js.py pins the registry's LIFO and
exactly-one-per-press guarantees (node-driven; skips when node is absent).
2026-06-01 14:23:22 -04:00

117 lines
4.8 KiB
Python

"""Pin the DOM-free Escape-dismissal registry in static/js/escMenuStack.js.
Driven through `node --input-type=module` so we exercise the real JS without a
full Vitest/Jest setup (same spirit as test_reply_recipients_js.py). Skips when
`node` is not installed rather than failing.
The module source is inlined into the eval'd module body (rather than imported
by path) so the test runs identically on Windows and POSIX — the repo has no
`"type": "module"` in package.json, so a path import of a `.js` file is treated
as CommonJS by node and rejects the ES `export`s. escMenuStack.js has no
imports of its own, so inlining is exact.
Background: ad-hoc dropdowns/popups (document-library card menus, chat context
popups, cookbook serve menus, calendar event menus, compare pane menus) live
outside the .modal system, so the global Escape arbiter in ui.js couldn't see
them. They register a dismiss callback here while open; the arbiter calls
dismissTopMenu() to close the most-recently-opened one. These tests lock in the
LIFO contract and the "exactly one menu per Escape, never get stuck" guarantees
the arbiter relies on.
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parent.parent
_HELPER = _REPO / "static" / "js" / "escMenuStack.js"
_HAS_NODE = shutil.which("node") is not None
_SRC = _HELPER.read_text(encoding="utf-8") if _HELPER.exists() else ""
def _run(body: str) -> str:
"""Run `body` as a module with the registry's functions already in scope."""
js = _SRC + "\n" + body
proc = subprocess.run(
["node", "--input-type=module"],
input=js, capture_output=True, text=True, encoding="utf-8",
cwd=str(_REPO), timeout=30,
)
assert proc.returncode == 0, proc.stderr
return proc.stdout.strip()
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_empty_stack_dismiss_is_noop():
# Nothing open: returns false so the arbiter can fall through to modals.
body = "console.log(JSON.stringify([dismissTopMenu(), _openMenuCount()]));"
assert json.loads(_run(body)) == [False, 0]
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_dismiss_is_lifo_and_closes_exactly_one():
body = """
const order = [];
registerMenuDismiss(() => order.push('A'));
registerMenuDismiss(() => order.push('B'));
const r1 = dismissTopMenu(); // closes B (most recent)
const r2 = dismissTopMenu(); // closes A
const r3 = dismissTopMenu(); // nothing left
console.log(JSON.stringify({ order, r1, r2, r3, left: _openMenuCount() }));
"""
out = json.loads(_run(body))
assert out["order"] == ["B", "A"] # LIFO
assert [out["r1"], out["r2"], out["r3"]] == [True, True, False]
assert out["left"] == 0
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_unregister_removes_entry_without_firing():
body = """
let fired = false;
const unreg = registerMenuDismiss(() => { fired = true; });
unreg(); // menu closed itself via outside-click
const r = dismissTopMenu(); // Escape should now find nothing
console.log(JSON.stringify({ fired, r, left: _openMenuCount() }));
"""
# Unregistering must not invoke the callback and must leave the stack empty.
assert json.loads(_run(body)) == {"fired": False, "r": False, "left": 0}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_unregister_targets_correct_entry_when_interleaved():
body = """
const order = [];
const unregA = registerMenuDismiss(() => order.push('A'));
registerMenuDismiss(() => order.push('B'));
unregA(); // remove the older entry, keep B
dismissTopMenu(); // should fire B, not A
console.log(JSON.stringify({ order, left: _openMenuCount() }));
"""
out = json.loads(_run(body))
assert out["order"] == ["B"]
assert out["left"] == 0
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_throwing_dismiss_still_pops_and_reports_handled():
body = """
registerMenuDismiss(() => { throw new Error('boom'); });
const r = dismissTopMenu(); // must swallow the error...
console.log(JSON.stringify({ r, left: _openMenuCount() }));
"""
# A misbehaving menu must not wedge the stack or crash the arbiter.
assert json.loads(_run(body)) == {"r": True, "left": 0}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_non_function_registration_is_ignored():
body = """
const unreg = registerMenuDismiss(null);
console.log(JSON.stringify({ left: _openMenuCount(), unregType: typeof unreg }));
"""
# Bad input must not enter the stack, and must still return a callable.
assert json.loads(_run(body)) == {"left": 0, "unregType": "function"}