Files
hermes-webui/tests/test_pr1441_ime_safari_guard.py
nesquena-hermes e6e9868625 Opus pre-release follow-up: blur resets _imeComposing flag
Opus advisor caught a recoverable footgun in PR #1441's manual flag: if
focus is lost mid-composition (window blur or older Safari WebKit IME
quirk), compositionend may never fire and _imeComposing stays true
until the next full composition cycle. Result: Enter-to-send is
silently broken until page reload — an unrecoverable stuck state for
something that's supposed to be transient.

Add a blur listener that also resets the flag. Cheap belt-and-suspenders
against the stuck state. Adds 1 regression test pinning the listener.

(other Opus findings logged in /tmp/stage-264-brief.md as follow-up
issues: _LOGIN_LOCALE parity for ja/pt/ko, promote _isImeEnter to the
6 other Safari-affected Enter guards in sessions.js + ui.js)
2026-05-02 02:56:48 +00:00

131 lines
4.9 KiB
Python

"""Regression tests for PR #1441 — IME composition Enter on Safari + broader IME coverage.
Original guard was `e.isComposing` only, which fails on Safari where the committing
keydown for IME composition fires AFTER `compositionend` with `isComposing=false`.
PR #1441 adds two more guards (`keyCode===229` + manual `_imeComposing` flag).
These tests pin the structural shape of the helper so a future cleanup pass that
strips one of the three guards trips a test before shipping.
"""
import pathlib
import re
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
def test_ime_helper_function_exists():
"""The `_isImeEnter` helper must exist and combine all 3 guards."""
# Helper definition — single function, three guards joined by ||
pattern = re.compile(
r"function\s+_isImeEnter\s*\(\s*e\s*\)\s*\{[^}]*"
r"e\.isComposing"
r"[^}]*"
r"e\.keyCode\s*===\s*229"
r"[^}]*"
r"_imeComposing"
r"[^}]*\}",
re.DOTALL,
)
assert pattern.search(BOOT_JS), (
"_isImeEnter helper must combine e.isComposing, keyCode===229, and "
"_imeComposing flag (PR #1441)"
)
def test_compositionstart_sets_manual_flag():
"""A compositionstart listener on #msg must set _imeComposing = true."""
# The listener registers on the resolved `_c` (i.e. $('msg')) element
pattern = re.compile(
r"compositionstart['\"]\s*,\s*(?:\(\s*\)|function\s*\(\s*\))\s*=>?\s*\{?\s*"
r"_imeComposing\s*=\s*true",
re.DOTALL,
)
assert pattern.search(BOOT_JS), (
"compositionstart listener must set _imeComposing = true (PR #1441)"
)
def test_compositionend_resets_flag_on_next_tick():
"""compositionend must reset _imeComposing in a setTimeout(..., 0) — NOT
synchronously — so Safari's trailing committing-Enter keydown is still
swallowed (it fires AFTER compositionend).
"""
pattern = re.compile(
r"compositionend['\"]\s*,\s*(?:\(\s*\)|function\s*\(\s*\))\s*=>?\s*\{?\s*"
r"setTimeout\s*\(\s*(?:\(\s*\)|function\s*\(\s*\))\s*=>?\s*\{?\s*"
r"_imeComposing\s*=\s*false",
re.DOTALL,
)
assert pattern.search(BOOT_JS), (
"compositionend listener must reset _imeComposing in setTimeout(..., 0) "
"to handle Safari's post-compositionend trailing Enter (PR #1441)"
)
def test_blur_resets_imecomposing_flag():
"""The blur listener must also reset _imeComposing so the flag cannot get
stuck at true if compositionend never fires (focus loss / window blur
with some IME implementations). Without this, a single missed
compositionend would brick Enter-to-send until the page is reloaded.
Added in v0.50.264 stage review per Opus advisor recommendation.
"""
pattern = re.compile(
r"['\"]blur['\"]\s*,\s*(?:\(\s*\)|function\s*\(\s*\))\s*=>?\s*\{?\s*"
r"_imeComposing\s*=\s*false",
re.DOTALL,
)
assert pattern.search(BOOT_JS), (
"blur listener must reset _imeComposing = false to recover from "
"missed compositionend (Opus follow-up to PR #1441)"
)
def test_ime_listeners_null_guard_msg_lookup():
"""The IIFE that registers composition listeners must null-guard $('msg') so
boot.js does not throw on pages that don't have a #msg textarea (e.g. login,
onboarding).
"""
# The IIFE pattern: (()=>{const _c=$('msg');if(!_c)return; ...
pattern = re.compile(
r"\(\s*\(\s*\)\s*=>\s*\{\s*const\s+_c\s*=\s*\$\(\s*['\"]msg['\"]\s*\)\s*;\s*"
r"if\s*\(\s*!\s*_c\s*\)\s*return\s*;",
re.DOTALL,
)
assert pattern.search(BOOT_JS), (
"Composition-listener IIFE must null-guard $('msg') so non-chat pages "
"(login, onboarding) don't throw (PR #1441)"
)
def test_chat_send_enter_uses_helper():
"""The send-Enter path must call _isImeEnter(e), not e.isComposing."""
# The original was `if(e.isComposing){return;}` inside `if(e.key==='Enter')`.
# Now it must be `if(_isImeEnter(e)){return;}`.
pattern = re.compile(
r"if\s*\(\s*e\.key\s*===\s*['\"]Enter['\"]\s*\)\s*\{\s*"
r"if\s*\(\s*_isImeEnter\s*\(\s*e\s*\)\s*\)\s*",
re.DOTALL,
)
assert pattern.search(BOOT_JS), (
"Chat composer send-Enter path must use _isImeEnter(e) helper (PR #1441)"
)
def test_dropdown_enter_uses_helper():
"""The autocomplete-dropdown Enter path must also use _isImeEnter(e).
Otherwise IME-confirming Enter inside a slash-command dropdown would
select the highlighted item instead of just committing the IME candidate.
"""
pattern = re.compile(
r"if\s*\(\s*e\.key\s*===\s*['\"]Enter['\"]\s*&&\s*!\s*e\.shiftKey\s*\)\s*\{\s*"
r"if\s*\(\s*_isImeEnter\s*\(\s*e\s*\)\s*\)\s*",
re.DOTALL,
)
assert pattern.search(BOOT_JS), (
"Command-dropdown Enter path must use _isImeEnter(e) helper (PR #1441)"
)