567 lines
21 KiB
Python
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'"
|