"""Regression for #4295: keep mid-stream reader anchor while streaming. The fix needs a scroll restore that keeps the semantic message anchor authoritative even when DOM height grows while the user is manually unpinned. """ import subprocess from pathlib import Path REPO = Path(__file__).resolve().parents[1] UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8") def _function_body(src: str, name: str) -> str: marker = f"function {name}" start = src.find(marker) assert start >= 0, f"{name} not found" brace = src.find("{", start) assert brace >= 0, f"{name} body not found" depth = 0 for i, ch in enumerate(src[brace:], brace): if ch == "{": depth += 1 elif ch == "}": depth -= 1 if depth == 0: return src[start : i + 1] raise AssertionError(f"{name} body did not terminate") def _compact(text: str) -> str: return "".join(text.split()) def test_restore_message_scroll_snapshot_remounts_virtual_anchor_and_keeps_unpinned(): """Drive the real restore helpers with a tiny DOM stub. This covers the #4295 failure mode where a mid-stream render rebuild has to remount a virtualized anchor before restoring the semantic reader position. The geometry is intentionally near-bottom after restore; old distance-based state inference would incorrectly re-pin the reader. """ script = f""" const assert = require('assert'); let _programmaticScroll = false; let _messageVirtualWindowKey = 'old-window'; let _lastScrollTop = 0; let _messageUserUnpinned = false; let _scrollPinned = true; let _nearBottomCount = 2; let _messageViewportAnchorRemounting = false; let targetMounted = false; const renderCalls = []; const targetRow = {{ getBoundingClientRect() {{ return {{ top: 140, bottom: 180 }}; }} }}; const container = {{ scrollTop: 300, scrollHeight: 905, clientHeight: 400, getBoundingClientRect() {{ return {{ top: 100, bottom: 500 }}; }}, querySelector(selector) {{ if (selector === '[data-msg-idx="20"]' && targetMounted) return targetRow; return null; }} }}; function $(id) {{ return id === 'messages' ? container : null; }} function requestAnimationFrame(fn) {{ fn(); }} function setTimeout(fn) {{ fn(); }} function _getVisibleMessagesWithIdx() {{ return [{{ rawIdx: 5 }}, {{ rawIdx: 20 }}, {{ rawIdx: 42 }}]; }} function _messageVisibleIndexForRawIdx(rawIdx, visWithIdx) {{ return visWithIdx.findIndex((entry) => entry && entry.rawIdx === rawIdx); }} function _messageVirtualScrollTopForVisibleIdx(visWithIdx, visIdx, el) {{ assert.strictEqual(visIdx, 1); assert.strictEqual(el, container); return 480; }} function renderMessages(options) {{ renderCalls.push(options); assert.strictEqual(_messageViewportAnchorRemounting, true); targetMounted = true; }} {_function_body(UI_JS, "_restoreMessageViewportAnchor")} {_function_body(UI_JS, "_remountMessageViewportAnchor")} {_function_body(UI_JS, "_restorePinnedMessageScrollSnapshot")} {_function_body(UI_JS, "_restoreMessageScrollSnapshot")} _restoreMessageScrollSnapshot({{ anchor: {{ rawIdx: 20, topOffset: 15 }}, top: 222, bottom: 600, pinned: false, userUnpinned: true, }}); assert.strictEqual(renderCalls.length, 1); assert.deepStrictEqual(renderCalls[0], {{ preserveScroll: true }}); assert.strictEqual(_messageVirtualWindowKey, ''); assert.strictEqual(container.scrollTop, 505); assert.strictEqual(_lastScrollTop, 505); assert.strictEqual(_messageUserUnpinned, true); assert.strictEqual(_scrollPinned, false); assert.strictEqual(_nearBottomCount, 0); assert.strictEqual(_programmaticScroll, false); """ subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) def test_live_anchor_rebuild_guard_holds_height_and_marks_reader_unpinned(): """Live anchor rebuilds must not let the scroll container collapse mid-read.""" script = f""" const assert = require('assert'); let _messageUserUnpinned = true; let _scrollPinned = false; let _nearBottomCount = 2; const messages = {{ scrollTop: 2000, scrollHeight: 5000, clientHeight: 600, }}; const inner = {{ style: {{ minHeight: '' }}, dataset: {{}} }}; function $(id) {{ if (id === 'messages') return messages; if (id === 'msgInner') return inner; return null; }} {_function_body(UI_JS, "_prepareLiveAnchorScrollRebuildGuard")} const snapshot = {{ top: 2000, bottom: 0, scrollHeight: 5000, pinned: true, userUnpinned: false, }}; const guard = _prepareLiveAnchorScrollRebuildGuard(snapshot); assert.strictEqual(guard.readerAwayFromBottom, true); assert.strictEqual(snapshot.pinned, false); assert.strictEqual(snapshot.userUnpinned, true); assert.strictEqual(snapshot.bottom, 2400); assert.strictEqual(_messageUserUnpinned, true); assert.strictEqual(_scrollPinned, false); assert.strictEqual(_nearBottomCount, 0); assert.strictEqual(inner.style.minHeight, '5000px'); assert.strictEqual(inner.dataset.liveAnchorScrollGuardPreviousMinHeight, ''); messages.scrollHeight = 5200; const nestedSnapshot = {{ top: 2000, bottom: 0, scrollHeight: 5200, pinned: true, userUnpinned: false, }}; const nestedGuard = _prepareLiveAnchorScrollRebuildGuard(nestedSnapshot); assert.strictEqual(nestedGuard.readerAwayFromBottom, true); assert.strictEqual(inner.style.minHeight, '5200px'); assert.strictEqual(inner.dataset.liveAnchorScrollGuardPreviousMinHeight, ''); guard.release(); assert.strictEqual(inner.style.minHeight, ''); nestedGuard.release(); assert.strictEqual(inner.style.minHeight, ''); assert.strictEqual(Object.prototype.hasOwnProperty.call(inner.dataset, 'liveAnchorScrollGuardPreviousMinHeight'), false); """ subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) def test_live_anchor_rebuild_guard_keeps_pinned_follower_pinned_on_large_growth(): """A PINNED follower must NOT be reclassified as unpinned when a large live render transiently pushes bottomDistance>250. Regression guard: the predicate must require an explicit non-follow signal (_messageUserUnpinned / _scrollPinned === false), not a raw scrollTop>0, or pinned live streams stop auto-following.""" script = f""" const assert = require('assert'); let _messageUserUnpinned = false; let _scrollPinned = true; let _nearBottomCount = 2; const messages = {{ scrollTop: 4400, scrollHeight: 5500, clientHeight: 600, }}; const inner = {{ style: {{ minHeight: '' }}, dataset: {{}} }}; function $(id) {{ if (id === 'messages') return messages; if (id === 'msgInner') return inner; return null; }} {_function_body(UI_JS, "_prepareLiveAnchorScrollRebuildGuard")} const snapshot = {{ top: 4400, bottom: 0, scrollHeight: 5500, pinned: true, userUnpinned: false, }}; const guard = _prepareLiveAnchorScrollRebuildGuard(snapshot); // bottomDistance = 5500 - 4400 - 600 = 500 (>250), but the reader is pinned and // has not manually unpinned -> must stay pinned, follow-scroll must NOT be skipped. assert.strictEqual(guard.readerAwayFromBottom, false); assert.strictEqual(guard.release, null); assert.strictEqual(_messageUserUnpinned, false); assert.strictEqual(_scrollPinned, true); assert.strictEqual(snapshot.pinned, true); assert.strictEqual(snapshot.userUnpinned, false); assert.strictEqual(inner.style.minHeight, ''); """ subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) def test_restore_message_scroll_snapshot_keeps_intermediate_distance_state(): script = f""" const assert = require('assert'); let _programmaticScroll = false; let _messageVirtualWindowKey = 'old-window'; let _lastScrollTop = 0; let _messageUserUnpinned = true; let _scrollPinned = false; let _nearBottomCount = 0; let _messageViewportAnchorRemounting = false; const container = {{ scrollTop: 10, scrollHeight: 800, clientHeight: 400, getBoundingClientRect() {{ return {{ top: 100, bottom: 500 }}; }}, querySelector() {{ return null; }} }}; function $(id) {{ return id === 'messages' ? container : null; }} function requestAnimationFrame(fn) {{ fn(); }} function setTimeout(fn) {{ fn(); }} function _getVisibleMessagesWithIdx() {{ return []; }} function _messageVisibleIndexForRawIdx() {{ return -1; }} function _messageVirtualScrollTopForVisibleIdx() {{ throw new Error('not expected'); }} function renderMessages() {{ throw new Error('not expected'); }} {_function_body(UI_JS, "_restoreMessageViewportAnchor")} {_function_body(UI_JS, "_remountMessageViewportAnchor")} {_function_body(UI_JS, "_restorePinnedMessageScrollSnapshot")} {_function_body(UI_JS, "_restoreMessageScrollSnapshot")} _restoreMessageScrollSnapshot({{ anchor: null, top: 200, bottom: 200, pinned: false, userUnpinned: false, }}); assert.strictEqual(container.scrollTop, 200); assert.strictEqual(_lastScrollTop, 200); assert.strictEqual(_messageUserUnpinned, true); assert.strictEqual(_scrollPinned, false); assert.strictEqual(_nearBottomCount, 0); assert.strictEqual(_programmaticScroll, false); """ subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) def test_capture_snapshot_treats_recent_scroll_away_as_manual_reader(): """Manual live-Worklog browsing must not reuse a stale pinned snapshot. A reader can be far from the live tail while scrolling down through a long Compact Worklog. If a live activity rebuild captures that state while the sticky globals still say pinned, restoring as a pinned follower preserves a huge bottom gap and can keep yanking the viewport back toward the turn start. Recent message-pane scroll intent makes the snapshot explicitly unpinned. """ script = f""" const assert = require('assert'); let _messageUserUnpinned = false; let _scrollPinned = true; let _lastMessageScrollIntentMs = 1000; const MESSAGE_WHEEL_INTENT_SUPPRESS_MS = 1200; const container = {{ scrollTop: 900, scrollHeight: 5000, clientHeight: 600, getBoundingClientRect() {{ return {{ top: 0, bottom: 600 }}; }}, querySelectorAll() {{ return []; }}, }}; function $(id) {{ return id === 'messages' ? container : null; }} const performance = {{ now() {{ return 1500; }} }}; function _captureMessageViewportAnchor() {{ return null; }} function _recentMessageTouchScrollIntent() {{ return false; }} function _recentMessageKeyScrollIntent() {{ return false; }} function _shouldFollowMessagesOnDomReplace() {{ return true; }} {_function_body(UI_JS, "_recentMessageScrollIntent")} {_function_body(UI_JS, "_captureMessageScrollSnapshot")} const snapshot = _captureMessageScrollSnapshot(); assert.strictEqual(snapshot.bottom, 3500); assert.strictEqual(snapshot.pinned, false); assert.strictEqual(snapshot.userUnpinned, true); """ subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) def test_capture_snapshot_keeps_true_pinned_follower_pinned_despite_large_gap(): """Large bottom distance alone is not enough to unpin a follower. During fast streaming, content can grow under a followed viewport before the follow write lands. Without a recent user scroll intent or explicit unpin state, the snapshot must stay pinned so the existing tail-relative restore continues to protect pinned live followers. Raw touch/key recency is not enough here because those helpers also track near-tail artifact-suppression windows; only the guarded away-from-bottom intent stamp can classify a snapshot as manual-reader state. """ script = f""" const assert = require('assert'); let _messageUserUnpinned = false; let _scrollPinned = true; let _lastMessageScrollIntentMs = -Infinity; const MESSAGE_WHEEL_INTENT_SUPPRESS_MS = 1200; const container = {{ scrollTop: 900, scrollHeight: 5000, clientHeight: 600, getBoundingClientRect() {{ return {{ top: 0, bottom: 600 }}; }}, querySelectorAll() {{ return []; }}, }}; function $(id) {{ return id === 'messages' ? container : null; }} const performance = {{ now() {{ return 1500; }} }}; function _captureMessageViewportAnchor() {{ return null; }} function _recentMessageTouchScrollIntent() {{ return true; }} function _recentMessageKeyScrollIntent() {{ return true; }} function _shouldFollowMessagesOnDomReplace() {{ return true; }} {_function_body(UI_JS, "_recentMessageScrollIntent")} {_function_body(UI_JS, "_captureMessageScrollSnapshot")} const snapshot = _captureMessageScrollSnapshot(); assert.strictEqual(snapshot.bottom, 3500); assert.strictEqual(snapshot.pinned, true); assert.strictEqual(snapshot.userUnpinned, false); """ subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) def test_wheel_scroll_intent_only_records_when_reader_is_away_from_bottom(): """Downward wheel intent should protect manual reading, not bottom following.""" script = f""" const assert = require('assert'); let _lastNonMessageScrollIntentMs = -Infinity; let _lastMessageWheelIntentMs = -Infinity; let _lastMessageScrollIntentMs = -Infinity; let _messageUserUnpinned = false; let _nearBottomCount = 2; let _scrollPinned = true; let _messageTouchScrollActive = false; let _lastMessageTouchScrollIntentMs = -Infinity; let _touchStartY = null; const child = {{}}; const el = {{ scrollTop: 900, scrollHeight: 5000, clientHeight: 600, contains(target) {{ return target === child; }}, }}; function _cancelBottomSettle() {{}} function _markMessageTouchScrollIntent(active) {{ _messageTouchScrollActive = !!active; _lastMessageTouchScrollIntentMs = performance.now(); }} const document = {{ getElementById(id) {{ return id === 'messages' ? el : null; }} }}; const performance = {{ now() {{ return 1234; }} }}; {_function_body(UI_JS, "_recordNonMessageScrollIntent")} _recordNonMessageScrollIntent({{ target: child, type: 'wheel', deltaY: 24 }}); assert.strictEqual(_lastMessageScrollIntentMs, 1234); assert.strictEqual(_messageUserUnpinned, false); _lastMessageScrollIntentMs = -Infinity; el.scrollTop = 4400; // bottomDistance = 0 _recordNonMessageScrollIntent({{ target: child, type: 'wheel', deltaY: 24 }}); assert.strictEqual(_lastMessageScrollIntentMs, -Infinity); assert.strictEqual(_scrollPinned, true); """ subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) def test_manual_scroll_snapshot_intent_excludes_raw_touch_and_key_recency(): """Only bottom-guarded message scroll intent can drive snapshot unpinning.""" compact = _compact(_function_body(UI_JS, "_recentMessageScrollIntent")) assert "_lastMessageScrollIntentMs" in compact assert "_scrollbarDragActive" in compact assert "_recentMessageTouchScrollIntent" not in compact assert "_recentMessageKeyScrollIntent" not in compact assert "_lastMessageKeyScrollIntentMs=now;" in _compact(UI_JS) assert "if(bottomDistance>120)_lastMessageScrollIntentMs=now;" in _compact(UI_JS) def test_touch_scroll_intent_only_records_when_reader_is_away_from_bottom(): """Touch recency is broad, but snapshot intent is bottom-distance guarded.""" script = f""" const assert = require('assert'); let _lastNonMessageScrollIntentMs = -Infinity; let _lastMessageWheelIntentMs = -Infinity; let _lastMessageScrollIntentMs = -Infinity; let _messageUserUnpinned = false; let _nearBottomCount = 2; let _scrollPinned = true; let _messageTouchScrollActive = false; let _lastMessageTouchScrollIntentMs = -Infinity; let _touchStartY = null; const child = {{}}; const el = {{ scrollTop: 900, scrollHeight: 5000, clientHeight: 600, contains(target) {{ return target === child; }}, }}; function _cancelBottomSettle() {{}} function _markMessageTouchScrollIntent(active) {{ _messageTouchScrollActive = !!active; _lastMessageTouchScrollIntentMs = performance.now(); }} const document = {{ getElementById(id) {{ return id === 'messages' ? el : null; }} }}; const performance = {{ now() {{ return 1234; }} }}; {_function_body(UI_JS, "_recordNonMessageScrollIntent")} _recordNonMessageScrollIntent({{ target: child, type: 'touchmove' }}); assert.strictEqual(_lastMessageScrollIntentMs, 1234); assert.strictEqual(_lastMessageTouchScrollIntentMs, 1234); assert.strictEqual(_messageUserUnpinned, false); _lastMessageScrollIntentMs = -Infinity; el.scrollTop = 4400; // bottomDistance = 0 _recordNonMessageScrollIntent({{ target: child, type: 'touchmove' }}); assert.strictEqual(_lastMessageScrollIntentMs, -Infinity); assert.strictEqual(_lastMessageTouchScrollIntentMs, 1234); assert.strictEqual(_scrollPinned, true); """ subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) def test_restore_message_scroll_snapshot_prefers_semantic_anchor_with_virtual_fallback(): body = _function_body(UI_JS, "_restoreMessageScrollSnapshot") helper = _function_body(UI_JS, "_remountMessageViewportAnchor") compact = _compact(body) helper_compact = _compact(helper) assert "snapshot.anchor&&typeof_restoreMessageViewportAnchor==='function'" in compact assert "if(!restoredViaAnchor&&typeof_remountMessageViewportAnchor==='function'&&_remountMessageViewportAnchor(snapshot.anchor))" in compact assert "typeof_getVisibleMessagesWithIdx!=='function'" in helper_compact assert "_getVisibleMessagesWithIdx()" in helper_compact assert "_messageVisibleIndexForRawIdx(targetIdx,visWithIdx)" in helper_compact assert "_messageVirtualScrollTopForVisibleIdx(visWithIdx,visIdx,container)" in helper_compact assert "_messageVirtualWindowKey=''" in helper_compact assert "renderMessages({preserveScroll:true});" in helper_compact assert "_messageViewportAnchorRemounting=true" in helper_compact assert "setTimeout(()=>{_programmaticScroll=false;},0);" in helper_compact def test_restore_message_scroll_snapshot_keeps_user_unpinned_state_authoritative_mid_stream(): body = _function_body(UI_JS, "_restoreMessageScrollSnapshot") compact = _compact(body) assert "constbottomDistance=el.scrollHeight-el.scrollTop-el.clientHeight;" in compact assert "if(snapshot.userUnpinned===true){_messageUserUnpinned=true;_scrollPinned=false;_nearBottomCount=0;}elseif(snapshot.pinned===true){_messageUserUnpinned=false;_scrollPinned=true;_nearBottomCount=2;}else{" in compact assert "_messageUserUnpinned=false;_scrollPinned=false;_nearBottomCount=0;" not in compact def test_same_frame_restore_uses_the_shared_anchor_remount_fallback(): body = _function_body(UI_JS, "_restoreMessageScrollSnapshotSameFrame") compact = _compact(body) assert "if(!restoredViaAnchor&&typeof_remountMessageViewportAnchor==='function'&&_remountMessageViewportAnchor(snapshot.anchor))" in compact assert "_messageVirtualScrollTopForVisibleIdx" not in body