Merge PR #6746 from happy5318/fix/mobile-enter-newline

This commit is contained in:
nesquena-hermes committed 2026-09-24 08:05:12 +00:00
commit 10faa69155
6 files changed
+877 -13

No files matched your search

+566
View File
@@ -0,0 +1,566 @@
"""Regression: mobile soft-keyboard Enter must insert a newline, not send.
This module covers the three production changes in this PR, each with a test
that provably fails when only that change is reverted:
A. ``_isTouchOnlyDevice()`` supplements media-query touch detection for phones.
Several iOS Safari builds report ``(any-pointer:fine)`` as **true** on a
plain iPhone (Apple Pencil / pointer-emulation heuristics), so the upstream
``matchMedia('(pointer:coarse)') && !_hasFinePointerCoexisting()`` test
evaluates false on exactly the devices it is meant to detect, and plain
Enter sends instead of inserting a newline. Phone UAs bypass that unreliable
signal, while tablets still honor it so attached hardware keyboards retain
desktop Enter-to-send behavior.
B. ``window._sendKey`` is initialised synchronously from ``localStorage``
before the composer handler is registered. Without it, on a slow network the
handler runs while ``window._sendKey`` is still undefined, so a user who
configured ``shift+enter`` has plain Enter send the message.
C. The composer textarea carries ``enterkeyhint="enter"`` so the iOS software
keyboard renders a return key rather than a "Go"/"Send" key.
The behavioural tests execute the **real** helpers and composer handler
extracted verbatim from ``static/boot.js`` — reverting the production code
changes what these tests run.
"""
from __future__ import annotations
import re
import tempfile
from pathlib import Path
import pytest
try:
from playwright.sync_api import sync_playwright
except Exception: # pragma: no cover - playwright optional in some envs
sync_playwright = None
REPO = Path(__file__).resolve().parents[1]
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
IPHONE_UA = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"
)
IPAD_UA = (
"Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"
)
ANDROID_TABLET_UA = (
"Mozilla/5.0 (Linux; Android 14; SM-X910) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
)
TOUCH_MAC_UA = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15"
)
def _require_playwright():
if sync_playwright is None:
pytest.skip("playwright is unavailable; run `playwright install chromium`")
return sync_playwright
# ─── Source extraction ────────────────────────────────────────────────────
def _extract_function(name: str, source: str) -> str:
"""Extract a top-level ``function name(...){...}`` with brace matching."""
start = source.index(f"function {name}(")
brace_start = source.index("{", start)
depth = 0
for i in range(brace_start, len(source)):
c = source[i]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return source[start : i + 1]
raise AssertionError(f"could not extract function {name}")
def _extract_composer_handler() -> str:
"""Extract the real ``$('msg').addEventListener('keydown', …)`` call."""
signature = "$('msg').addEventListener('keydown',e=>"
start = BOOT_JS.index(signature)
brace = BOOT_JS.index("{", start)
depth = 0
for idx in range(brace, len(BOOT_JS)):
char = BOOT_JS[idx]
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
end = BOOT_JS.index(");", idx) + 2
return BOOT_JS[start:end]
raise AssertionError("could not extract composer keydown listener")
def _extract_send_key_init() -> str:
"""Extract the synchronous ``window._sendKey`` bootstrap from boot.js.
Returns an empty string when the line is absent, so reverting change B
removes it from the harness too (which is what makes the test bite).
"""
match = re.search(
r"^try\{\s*window\._sendKey=localStorage\.getItem\([^\n]*$",
BOOT_JS,
re.MULTILINE,
)
return match.group(0) if match else ""
def _available_helpers() -> str:
"""Extract the touch/IME/numpad helpers the composer handler may call.
``_isTouchOnlyDevice`` is this PR's addition and ``_hasFinePointerCoexisting``
is the upstream helper; include whichever exist so the harness stays valid
with the production change reverted.
"""
names = (
"_isImeEnter",
"_isNumpadEnter",
"_hasFinePointerCoexisting",
"_isTouchOnlyDevice",
)
out = []
for name in names:
if f"function {name}(" in BOOT_JS:
out.append(_extract_function(name, BOOT_JS))
return "\n".join(out)
HARNESS_HTML = """
<!doctype html>
<html><head><meta charset="utf-8"></head>
<body>
<textarea id="msg" rows="1"></textarea>
<script>
var _imeComposing = false;
function $(id) { return document.getElementById(id); }
// --- Real helpers extracted from boot.js ---
__HELPERS__
// --- Real synchronous _sendKey bootstrap extracted from boot.js ---
// Empty when that line has been removed from production code.
__SENDKEY_INIT__
window.__sendCount = 0;
window.__defaultPrevented = 0;
window.send = function() { window.__sendCount++; };
var send = window.send;
// --- Real composer handler extracted from boot.js ---
__HANDLER__
// Observe whether the handler suppressed the browser's default newline.
document.getElementById('msg').addEventListener('keydown', function(e){
if (e.defaultPrevented) window.__defaultPrevented++;
});
</script>
</body></html>
"""
def _build_harness_html() -> str:
return (
HARNESS_HTML.replace("__HELPERS__", _available_helpers())
.replace("__SENDKEY_INIT__", _extract_send_key_init())
.replace("__HANDLER__", _extract_composer_handler())
)
def _write_harness() -> Path:
tmp = Path(tempfile.mkstemp(suffix=".html")[1])
tmp.write_text(_build_harness_html(), encoding="utf-8")
return tmp
# ─── Media-query shim ─────────────────────────────────────────────────────
# Reproduces the iOS Safari quirk: a touch-primary phone that ALSO reports
# (any-pointer:fine) as true. Installed before any page script runs.
IOS_SAFARI_MATCHMEDIA_QUIRK = """
(() => {
const real = window.matchMedia.bind(window);
window.matchMedia = (q) => {
if (q === '(pointer:coarse)') return {matches: true, media: q};
if (q === '(any-pointer:fine)') return {matches: true, media: q};
return real(q);
};
})();
"""
# A plain desktop: no coarse pointer, fine pointer present.
DESKTOP_MATCHMEDIA = """
(() => {
const real = window.matchMedia.bind(window);
window.matchMedia = (q) => {
if (q === '(pointer:coarse)') return {matches: false, media: q};
if (q === '(any-pointer:fine)') return {matches: true, media: q};
return real(q);
};
})();
"""
# A tablet / iPadOS-desktop UA with only a software keyboard: a coarse pointer
# is present (touch screen) but no fine pointer (no attached hardware
# keyboard or trackpad). This is the path that the production change leaves
# alone — these devices must keep the mobile "Enter inserts newline" behavior.
TABLET_NO_FINE_POINTER_MATCHMEDIA = """
(() => {
const real = window.matchMedia.bind(window);
window.matchMedia = (q) => {
if (q === '(pointer:coarse)') return {matches: true, media: q};
if (q === '(any-pointer:fine)') return {matches: false, media: q};
return real(q);
};
})();
"""
def _press_enter(page, *, shift=False, ctrl=False, meta=False, numpad=False):
page.evaluate(
"""
({shift, ctrl, meta, code, location}) => {
const el = document.getElementById('msg');
el.focus();
el.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Enter', code, keyCode: 13, which: 13, location,
shiftKey: shift, ctrlKey: ctrl, metaKey: meta,
bubbles: true, cancelable: true,
}));
}
""",
{
"shift": shift,
"ctrl": ctrl,
"meta": meta,
"code": "NumpadEnter" if numpad else "Enter",
"location": 3 if numpad else 0,
},
)
def _send_count(page) -> int:
return page.evaluate("__sendCount")
# ─── Change A: UA-first touch detection ───────────────────────────────────
def test_iphone_reporting_fine_pointer_still_treats_enter_as_newline():
"""Change A. iPhone UA + ``(any-pointer:fine)`` true → Enter must NOT send.
Reverting to ``matchMedia('(pointer:coarse)') && !_hasFinePointerCoexisting()``
makes ``_mobileDefault`` false here, so plain Enter sends and this fails.
"""
playwright = _require_playwright()
harness = _write_harness()
with playwright() as p:
browser = p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"])
ctx = browser.new_context(
user_agent=IPHONE_UA,
viewport={"width": 390, "height": 844},
has_touch=True,
is_mobile=True,
)
ctx.add_init_script(IOS_SAFARI_MATCHMEDIA_QUIRK)
page = ctx.new_page()
page.goto(harness.as_uri())
page.wait_for_load_state("domcontentloaded")
# Sanity: the quirk is actually in place, otherwise the test is vacuous.
assert page.evaluate("matchMedia('(pointer:coarse)').matches") is True
assert page.evaluate("matchMedia('(any-pointer:fine)').matches") is True
_press_enter(page)
sends = _send_count(page)
prevented = page.evaluate("__defaultPrevented")
browser.close()
assert sends == 0, (
"plain Enter sent the message on an iPhone that reports (any-pointer:fine); "
"the soft keyboard's return key must insert a newline"
)
assert prevented == 0, "Enter should fall through so the browser inserts the newline"
def test_android_phone_treats_enter_as_newline():
"""Change A. Android UA → Enter must NOT send, regardless of media queries."""
playwright = _require_playwright()
harness = _write_harness()
android_ua = (
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36"
)
with playwright() as p:
browser = p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"])
ctx = browser.new_context(
user_agent=android_ua,
viewport={"width": 412, "height": 915},
has_touch=True,
is_mobile=True,
)
ctx.add_init_script(IOS_SAFARI_MATCHMEDIA_QUIRK)
page = ctx.new_page()
page.goto(harness.as_uri())
page.wait_for_load_state("domcontentloaded")
_press_enter(page)
sends = _send_count(page)
browser.close()
assert sends == 0, "plain Enter must insert a newline on an Android phone"
def _run_enter_case(
playwright,
harness: Path,
*,
user_agent: str,
max_touch_points: int = 0,
matchmedia_shim: str = IOS_SAFARI_MATCHMEDIA_QUIRK,
expect_coarse: bool = True,
expect_fine: bool = True,
) -> tuple[int, int]:
with playwright() as p:
browser = p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"])
ctx = browser.new_context(
user_agent=user_agent,
viewport={"width": 1024, "height": 768},
has_touch=True,
)
ctx.add_init_script(
f"Object.defineProperty(navigator, 'maxTouchPoints', {{value: {max_touch_points}}});"
)
ctx.add_init_script(matchmedia_shim)
page = ctx.new_page()
page.goto(harness.as_uri())
page.wait_for_load_state("domcontentloaded")
# Sanity: the shim is actually in place, otherwise the test would be vacuous.
assert page.evaluate("matchMedia('(pointer:coarse)').matches") is expect_coarse
assert page.evaluate("matchMedia('(any-pointer:fine)').matches") is expect_fine
_press_enter(page)
sends = _send_count(page)
prevented = page.evaluate("__defaultPrevented")
browser.close()
return sends, prevented
@pytest.mark.parametrize(
("user_agent", "max_touch_points", "device"),
[
(IPAD_UA, 5, "iPad with Magic Keyboard"),
(ANDROID_TABLET_UA, 5, "Android tablet with hardware keyboard"),
(TOUCH_MAC_UA, 5, "touch Macintosh/iPadOS desktop UA"),
],
)
def test_tablet_with_fine_pointer_keeps_enter_to_send(
user_agent: str,
max_touch_points: int,
device: str,
):
"""Tablet/touch-desktop UA + fine pointer must retain desktop Enter=send."""
playwright = _require_playwright()
harness = _write_harness()
sends, prevented = _run_enter_case(
playwright,
harness,
user_agent=user_agent,
max_touch_points=max_touch_points,
)
assert sends == 1, f"plain Enter must send on {device}"
assert prevented == 1, f"plain Enter must be consumed after sending on {device}"
@pytest.mark.parametrize(
("user_agent", "max_touch_points", "device"),
[
(IPAD_UA, 5, "iPad with software keyboard only"),
(ANDROID_TABLET_UA, 5, "Android tablet with software keyboard only"),
(TOUCH_MAC_UA, 5, "touch Macintosh/iPadOS desktop with software keyboard only"),
],
)
def test_tablet_without_fine_pointer_inserts_newline(
user_agent: str,
max_touch_points: int,
device: str,
):
"""Control for the tablet-with-fine-pointer test.
A tablet / iPadOS-desktop UA reporting only a coarse pointer (no fine
pointer — i.e. no attached hardware keyboard) must keep the mobile
"plain Enter inserts a newline" behavior, because the production change
only short-circuits phone UAs and otherwise still falls back to the
media-query test ``(pointer:coarse) && !(any-pointer:fine)``.
Reverting the change to a phone-only match would break this test, and a
tablet shortcut that always returns true would invert
``test_tablet_with_fine_pointer_keeps_enter_to_send``.
"""
playwright = _require_playwright()
harness = _write_harness()
sends, prevented = _run_enter_case(
playwright,
harness,
user_agent=user_agent,
max_touch_points=max_touch_points,
matchmedia_shim=TABLET_NO_FINE_POINTER_MATCHMEDIA,
expect_fine=False,
)
assert sends == 0, (
f"plain Enter must insert a newline on {device} (no fine pointer "
"is exposed, so the media-query path should still classify it as touch-only)"
)
assert prevented == 0, (
f"Enter must fall through so the browser inserts the newline on {device}"
)
def test_mobile_ctrl_enter_still_sends():
"""Change A must not cost mobile users a way to send from the keyboard."""
playwright = _require_playwright()
harness = _write_harness()
with playwright() as p:
browser = p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"])
ctx = browser.new_context(
user_agent=IPHONE_UA,
viewport={"width": 390, "height": 844},
has_touch=True,
is_mobile=True,
)
ctx.add_init_script(IOS_SAFARI_MATCHMEDIA_QUIRK)
page = ctx.new_page()
page.goto(harness.as_uri())
page.wait_for_load_state("domcontentloaded")
_press_enter(page, ctrl=True)
ctrl_sends = _send_count(page)
_press_enter(page, numpad=True)
total_sends = _send_count(page)
browser.close()
assert ctrl_sends == 1, "Ctrl+Enter must still send on mobile"
assert total_sends == 2, "Numpad Enter must still send on mobile"
def test_desktop_enter_still_sends():
"""Change A must not alter desktop behaviour."""
playwright = _require_playwright()
harness = _write_harness()
with playwright() as p:
browser = p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"])
ctx = browser.new_context()
ctx.add_init_script(DESKTOP_MATCHMEDIA)
page = ctx.new_page()
page.goto(harness.as_uri())
page.wait_for_load_state("domcontentloaded")
_press_enter(page)
sends = _send_count(page)
browser.close()
assert sends == 1, "plain Enter must still send on a desktop browser"
# ─── Change B: synchronous _sendKey bootstrap ─────────────────────────────
def test_stored_shift_enter_preference_applies_before_settings_fetch():
"""Change B. A stored ``shift+enter`` must hold on the very first keypress.
Reverting the synchronous ``window._sendKey`` bootstrap leaves the value
undefined while the async ``/api/settings`` fetch is still in flight, so the
handler falls into the default branch and plain Enter sends.
"""
playwright = _require_playwright()
harness = _write_harness()
with playwright() as p:
browser = p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"])
ctx = browser.new_context()
ctx.add_init_script(DESKTOP_MATCHMEDIA)
# Seed the preference the way a returning user's browser would have it,
# before any page script runs.
ctx.add_init_script(
"try{localStorage.setItem('hermes-pref-send_key','shift+enter');}catch(e){}"
)
page = ctx.new_page()
page.goto(harness.as_uri())
page.wait_for_load_state("domcontentloaded")
assert (
page.evaluate("localStorage.getItem('hermes-pref-send_key')")
== "shift+enter"
), "harness failed to seed the preference; test would be vacuous"
_press_enter(page)
plain_sends = _send_count(page)
_press_enter(page, shift=True)
after_shift = _send_count(page)
browser.close()
assert plain_sends == 0, (
"plain Enter sent the message even though the stored preference is "
"shift+enter; window._sendKey was not initialised before the handler ran"
)
assert after_shift == 1, "Shift+Enter must send when the preference is shift+enter"
def test_send_key_bootstrap_precedes_composer_handler():
"""Change B, structural. The bootstrap must sit ABOVE the handler in boot.js.
Ordering is what closes the race; a bootstrap placed after the
``addEventListener`` call would still leave the first keypress undefined.
"""
init = _extract_send_key_init()
assert init, "synchronous window._sendKey bootstrap is missing from boot.js"
handler_idx = BOOT_JS.index("$('msg').addEventListener('keydown',e=>")
assert BOOT_JS.index(init) < handler_idx, (
"window._sendKey bootstrap must appear before the composer keydown "
"listener is registered"
)
# ─── Change C: enterkeyhint on the composer ───────────────────────────────
def test_composer_textarea_declares_enterkeyhint():
"""Change C, structural. iOS renders the return key from ``enterkeyhint``.
The rendered keycap is a native-keyboard behaviour with no DOM-observable
effect in a headless browser, so this asserts the attribute is present and
parsed rather than claiming to verify the keyboard itself.
"""
match = re.search(r"<textarea id=\"msg\"[^>]*>", INDEX_HTML)
assert match, "composer textarea not found in index.html"
assert 'enterkeyhint="enter"' in match.group(0), (
"composer textarea must declare enterkeyhint=\"enter\" so the iOS "
"software keyboard shows a return key instead of Go/Send"
)
def test_enterkeyhint_reaches_the_dom():
"""Change C. The attribute must survive parsing as the DOM property."""
playwright = _require_playwright()
match = re.search(r"<textarea id=\"msg\"[^>]*>", INDEX_HTML)
assert match, "composer textarea not found in index.html"
snippet = (
"<!doctype html><html><head><meta charset='utf-8'></head><body>"
+ match.group(0)
+ "</textarea></body></html>"
)
tmp = Path(tempfile.mkstemp(suffix=".html")[1])
tmp.write_text(snippet, encoding="utf-8")
with playwright() as p:
browser = p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"])
page = browser.new_page()
page.goto(tmp.as_uri())
hint = page.evaluate("document.getElementById('msg').enterKeyHint")
browser.close()
assert hint == "enter", f"enterKeyHint resolved to {hint!r}, expected 'enter'"
-9
View File
@@ -1697,15 +1697,6 @@ def test_mobile_enter_newline_does_not_depend_on_viewport_heuristic():
"the viewport height-delta probe must no longer gate the mobile Enter override"
def test_mobile_enter_newline_respects_hardware_keyboard_on_touch_devices():
"""Touch devices with a co-existing fine pointer (hardware keyboard) keep desktop Enter=send."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
assert "any-pointer:fine" in boot_js, \
"boot.js must use any-pointer:fine to detect a co-existing hardware keyboard/trackpad"
assert "!_hasFinePointerCoexisting()" in boot_js, \
"mobile Enter newline override must skip touch devices that also expose a fine pointer"
def test_mobile_enter_newline_only_overrides_enter_default():
"""Mobile newline override must only apply when _sendKey is the default 'enter'."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
@@ -0,0 +1,287 @@
"""Regression: send-key preference changes take effect without page reload.
This test catches the bug where an inline capture-phase interceptor in
index.html snapshotted ``_sendKey`` into a closure at page-parse time.
When the user later changed the setting (which updates ``window._sendKey``
via panels.js), the stale closure still called ``stopImmediatePropagation()``
and blocked the newly configured shortcut from reaching the boot.js
composer handler.
The fix removes the inline interceptor entirely — boot.js already
initialises ``window._sendKey`` synchronously and its composer handler
reads the live value on every event.
This Playwright test loads a minimal HTML page that includes the real
boot.js composer keydown handler (extracted verbatim), simulates a
preference change at runtime, and verifies the correct behaviour for
each send-key mode — including the transition from one mode to another
without a page reload.
"""
from __future__ import annotations
from pathlib import Path
import pytest
try:
from playwright.sync_api import sync_playwright
except Exception:
sync_playwright = None
REPO = Path(__file__).resolve().parents[1]
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
def _require_playwright():
if sync_playwright is None:
pytest.skip("playwright is unavailable; run `playwright install chromium`")
return sync_playwright
def _extract_composer_handler() -> str:
"""Extract the real ``$('msg').addEventListener('keydown', …)`` handler
from boot.js, including its full body and the closing ``);``."""
signature = "$('msg').addEventListener('keydown',e=>"
start = BOOT_JS.index(signature)
brace = BOOT_JS.index("{", start)
depth = 0
for idx in range(brace, len(BOOT_JS)):
char = BOOT_JS[idx]
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
# Include the closing ``);`` that completes the addEventListener call.
end = BOOT_JS.index(");", idx) + 2
return BOOT_JS[start:end]
raise AssertionError("could not extract composer keydown listener")
def _extract_function(name: str, source: str) -> str:
"""Extract a function by name, matching braces properly."""
pattern = f"function {name}("
start = source.index(pattern)
brace_start = source.index("{", start)
depth = 0
for i in range(brace_start, len(source)):
c = source[i]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return source[start : i + 1]
raise ValueError(f"Could not extract function {name}")
def _extract_helper_functions() -> str:
"""Extract the helper functions the composer handler depends on."""
names = ("_isImeEnter", "_isNumpadEnter", "_isTouchOnlyDevice")
return "\n".join(_extract_function(n, BOOT_JS) for n in names)
# Minimal HTML that loads the real handler logic.
HARNESS_HTML = """
<!doctype html>
<html><head><meta charset="utf-8"></head>
<body>
<textarea id="msg" rows="1"></textarea>
<script>
// _imeComposing is a module-level flag in boot.js that _isImeEnter references.
var _imeComposing = false;
// Minimal $ shim (boot.js uses $('msg') === document.getElementById('msg'))
function $(id) { return document.getElementById(id); }
// --- Real helper functions extracted from boot.js ---
__HELPERS__
// --- Synchronous _sendKey init (from boot.js line ~2362) ---
try { window._sendKey = localStorage.getItem('hermes-pref-send_key') || 'enter'; } catch(_) { window._sendKey = 'enter'; }
// --- Real composer handler extracted from boot.js ---
// send() stub: count invocations instead of doing real work.
window.__sendCount = 0;
window.send = function() { window.__sendCount++; };
var send = window.send; // local alias so extracted handler's send() resolves
__HANDLER__
</script>
</body></html>
"""
def _build_harness_html() -> str:
return HARNESS_HTML.replace("__HELPERS__", _extract_helper_functions()).replace(
"__HANDLER__", _extract_composer_handler()
)
@pytest.fixture
def page():
_require_playwright()
with sync_playwright() as p:
browser = p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"])
context = browser.new_context()
pg = context.new_page()
html = _build_harness_html()
import tempfile
tmp = Path(tempfile.mkstemp(suffix=".html")[1])
tmp.write_text(html, encoding="utf-8")
pg.goto(tmp.as_uri())
pg.wait_for_load_state("domcontentloaded")
yield pg
browser.close()
def _set_send_key(page, mode: str):
"""Simulate panels.js applying a saved settings preference at runtime."""
page.evaluate(
f"""() => {{
localStorage.setItem('hermes-pref-send_key', '{mode}');
window._sendKey = '{mode}';
}}"""
)
def _press_enter(page, *, shift=False, ctrl=False, meta=False, numpad=False):
"""Dispatch a real Enter keydown on #msg with optional modifiers."""
code = "NumpadEnter" if numpad else "Enter"
location = 3 if numpad else 0
page.evaluate(
"""
({shift, ctrl, meta, code, location}) => {
const el = document.getElementById('msg');
el.focus();
el.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Enter',
code: code,
keyCode: 13,
which: 13,
location: location,
shiftKey: shift,
ctrlKey: ctrl,
metaKey: meta,
bubbles: true,
cancelable: true,
}));
}
""",
{"shift": shift, "ctrl": ctrl, "meta": meta, "code": code, "location": location},
)
def _send_count(page) -> int:
return page.evaluate("__sendCount")
# ─── Static guard ─────────────────────────────────────────────────────────
def test_no_inline_interceptor_remains():
"""The stale-snapshot interceptor must be gone from index.html."""
assert "stopImmediatePropagation" not in INDEX_HTML, (
"index.html still contains a capture-phase interceptor with "
"stopImmediatePropagation — this reintroduces the stale _sendKey bug"
)
# ─── Behavioural tests ────────────────────────────────────────────────────
def test_enter_mode_plain_enter_sends(page):
_set_send_key(page, "enter")
_press_enter(page)
assert _send_count(page) == 1
def test_enter_mode_shift_enter_does_not_send(page):
_set_send_key(page, "enter")
_press_enter(page, shift=True)
assert _send_count(page) == 0
def test_shift_enter_mode_plain_enter_does_not_send(page):
_set_send_key(page, "shift+enter")
_press_enter(page)
assert _send_count(page) == 0
def test_shift_enter_mode_shift_enter_sends(page):
_set_send_key(page, "shift+enter")
_press_enter(page, shift=True)
assert _send_count(page) == 1
def test_ctrl_enter_mode_plain_enter_does_not_send(page):
_set_send_key(page, "ctrl+enter")
_press_enter(page)
assert _send_count(page) == 0
def test_ctrl_enter_mode_ctrl_enter_sends(page):
_set_send_key(page, "ctrl+enter")
_press_enter(page, ctrl=True)
assert _send_count(page) == 1
def test_ctrl_enter_mode_cmd_enter_sends(page):
_set_send_key(page, "ctrl+enter")
_press_enter(page, meta=True)
assert _send_count(page) == 1
# ─── Core regression: live preference change ─────────────────────────────
def test_preference_change_enter_to_shift_enter_without_reload(page):
"""The bug: interceptor snapshotted 'enter' at load time. Changing to
'shift+enter' at runtime didn't update the closure, so Shift+Enter was
still blocked. After the fix, the live window._sendKey is read on every
event, so the transition takes effect immediately."""
# Start in 'enter' mode — plain Enter sends.
_set_send_key(page, "enter")
_press_enter(page)
assert _send_count(page) == 1
# Switch to 'shift+enter' without reloading.
_set_send_key(page, "shift+enter")
# Plain Enter should no longer send.
_press_enter(page)
assert _send_count(page) == 1 # unchanged
# Shift+Enter should now send.
_press_enter(page, shift=True)
assert _send_count(page) == 2
def test_preference_change_shift_enter_to_enter_without_reload(page):
"""Reverse transition: 'shift+enter' → 'enter'."""
_set_send_key(page, "shift+enter")
_press_enter(page) # no send
_press_enter(page, shift=True) # sends
assert _send_count(page) == 1
_set_send_key(page, "enter")
_press_enter(page) # now sends
assert _send_count(page) == 2
_press_enter(page, shift=True) # no longer sends
assert _send_count(page) == 2
def test_preference_change_enter_to_ctrl_enter_without_reload(page):
"""Transition: 'enter' → 'ctrl+enter'."""
_set_send_key(page, "enter")
_press_enter(page) # sends
assert _send_count(page) == 1
_set_send_key(page, "ctrl+enter")
_press_enter(page) # no longer sends
assert _send_count(page) == 1
_press_enter(page, ctrl=True) # sends
assert _send_count(page) == 2