Files
hermes-webui/tests/test_mobile_enter_newline.py
T

567 lines
21 KiB
Python

"""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'"