"""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_rebuild_path_marks_dom_wipe_scroll_as_programmatic(): # During innerHTML='' the scroller can transiently collapse to clientHeight # and clamp scrollTop to 0. That browser event must be suppressed as # programmatic; otherwise the scroll listener treats it as user upward # intent and disables live auto-follow. fix_idx = UI_JS.find("window._fixMobileScrollJank()") assert fix_idx != -1, "renderMessages() guard call not found" wipe_idx = UI_JS.find("innerHTML=''", fix_idx) assert wipe_idx != -1, "innerHTML='' not found after _fixMobileScrollJank()" window = UI_JS[fix_idx:wipe_idx] assert "_programmaticScroll=true" in window, ( "renderMessages() must mark the DOM wipe/rebuild scroll event as " "programmatic before innerHTML='' can clamp scrollTop." ) assert "_programmaticScrollSetAt=performance.now()" in window assert UI_JS.find("_deferClearProgrammaticScroll(160)", wipe_idx) != -1, ( "renderMessages() must clear the programmatic-scroll suppression after " "the rebuild/post-render paint window." ) 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." )