Files
hermes-webui/tests/test_issue4856_android_scroll_regression.py
T

99 lines
3.9 KiB
Python

"""Regression tests for #4856: Android scroll-to-top on every interaction."""
from pathlib import Path
REPO = Path(__file__).parent.parent
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
_FUNC_MARKER = "window._fixMobileScrollJank=function _fixMobileScrollJank(){"
_RAF_MARKER = "requestAnimationFrame(()=>{"
def _extract_fix_mobile_scroll_jank(src: str) -> str:
idx = src.find(_FUNC_MARKER)
assert idx != -1, "_fixMobileScrollJank not found in ui.js"
depth = 0
for i, ch in enumerate(src[idx:], idx):
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return src[idx : i + 1]
raise AssertionError("Could not extract _fixMobileScrollJank")
def _extract_raf_body(fn_src: str) -> str:
idx = fn_src.find(_RAF_MARKER)
assert idx != -1, "requestAnimationFrame callback not found in function"
depth = 0
for i, ch in enumerate(fn_src[idx:], idx):
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return fn_src[idx : i + 1]
raise AssertionError("Could not extract rAF body")
def test_fix_sets_none_not_auto():
# Base-fails/head-passes: function must set 'none' to suppress Chromium
# scroll-anchor re-selection; 'auto' is already the CSS default so setting
# it is a no-op and leaves the anchor engine active during the DOM wipe.
fn = _extract_fix_mobile_scroll_jank(UI_JS)
assert "overflowAnchor='none'" in fn, (
"_fixMobileScrollJank() must set overflowAnchor='none' to suppress "
"Chromium scroll-anchor re-selection during the DOM wipe (#4856)."
)
assert "overflowAnchor='auto'" not in fn, (
"_fixMobileScrollJank() must not set overflowAnchor='auto'; that is "
"already the CSS resting value on mobile and is a no-op."
)
def test_raf_cleanup_checks_none():
# The rAF guard must check for 'none' so it only clears the inline style
# it set; checking 'auto' was always false and left the inline style on.
fn = _extract_fix_mobile_scroll_jank(UI_JS)
raf = _extract_raf_body(fn)
assert "overflowAnchor==='none'" in raf, (
"The rAF cleanup in _fixMobileScrollJank() must check for 'none' so it "
"clears the inline style after the synchronous scrollTop write lands."
)
assert "overflowAnchor==='auto'" not in raf, (
"The rAF cleanup must not check for 'auto'; that check was always false."
)
def test_rebuild_path_calls_fix_before_wipe():
# renderMessages() must call _fixMobileScrollJank() before innerHTML='' so
# anchor suppression is active during the full wipe-and-rebuild window.
fix_idx = UI_JS.find("window._fixMobileScrollJank()")
assert fix_idx != -1, (
"renderMessages() must call window._fixMobileScrollJank() before innerHTML=''."
)
wipe_idx = UI_JS.find("innerHTML=''", fix_idx)
assert wipe_idx != -1, (
"innerHTML='' not found after _fixMobileScrollJank() call site."
)
assert fix_idx < wipe_idx, (
"_fixMobileScrollJank() must be called before innerHTML='' in renderMessages()."
)
def test_streaming_tick_calls_fix_before_dom_writes():
# The streaming render tick in messages.js must call _fixMobileScrollJank()
# before _lastRenderMs=performance.now() so anchor suppression covers every
# incremental DOM update during streaming.
guard_idx = MESSAGES_JS.find("window._fixMobileScrollJank")
assert guard_idx != -1, (
"The streaming tick must call window._fixMobileScrollJank() before DOM writes."
)
render_idx = MESSAGES_JS.find("_lastRenderMs=performance.now()")
assert render_idx != -1, "streaming render timestamp not found in messages.js"
assert guard_idx < render_idx, (
"The mobile scroll-jank guard must run before streaming DOM work begins."
)