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).
117 lines
4.8 KiB
Python
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"}
|