* fix: preserve reader scroll intent during live renders * fix: enforce programmatic scroll guard freshness * test: update programmatic scroll guard assertion * test: update scroll safety valve assertion * fix: reconcile scroll intent helper after base refresh * fix: timestamp fallback history scroll writes * test: cover stale older-message fallback scroll guard * test: execute the real slow-render history fallback * test: provide the history fallback DOM helper * test: retain async declaration in history fallback harness * test: remove trailing blank line from scroll intent regression * test(#6414): update scroll-intent harness for window._autoScrollFollow + _messageScrollInputGeneration (master drift) * Release exp-v0.52.172: preserve reader scroll intent during live renders (#6453) --------- Co-authored-by: Frank Song <franksong2702@gmail.com> Co-authored-by: Frank Song <xuefu.song@qq.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
216 lines
7.8 KiB
Python
216 lines
7.8 KiB
Python
"""Regression for #6414: real upward wheel intent wins during a render scroll guard."""
|
|
|
|
import json
|
|
import re
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
|
|
|
|
|
SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8")
|
|
|
|
|
|
def _function_body(name: str, source: str = UI_JS) -> str:
|
|
marker = f"async function {name}"
|
|
start = source.find(marker)
|
|
if start < 0:
|
|
marker = f"function {name}"
|
|
start = source.index(marker)
|
|
brace = source.index("{", start)
|
|
depth = 0
|
|
for index in range(brace, len(source)):
|
|
if source[index] == "{":
|
|
depth += 1
|
|
elif source[index] == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return source[start : index + 1]
|
|
raise AssertionError(f"{name} did not terminate")
|
|
|
|
|
|
def _run_small_upward_wheel_case(programmatic_scroll_age_ms: int) -> dict[str, bool | int]:
|
|
script = f"""
|
|
let now = 1000;
|
|
const performance = {{ now: () => now }};
|
|
const messages = {{
|
|
scrollHeight: 5000,
|
|
scrollTop: 4400,
|
|
clientHeight: 500,
|
|
contains: (target) => target === messages,
|
|
}};
|
|
const document = {{ getElementById: (id) => id === 'messages' ? messages : null }};
|
|
let _programmaticScroll = true;
|
|
let _programmaticScrollSetAt = now - {programmatic_scroll_age_ms};
|
|
let _messageUserUnpinned = false;
|
|
let _scrollPinned = true;
|
|
let _nearBottomCount = 2;
|
|
let _lastNonMessageScrollIntentMs = -Infinity;
|
|
let _lastMessageScrollIntentMs = -Infinity;
|
|
let _lastMessageWheelIntentMs = -Infinity;
|
|
let _touchStartY = null;
|
|
let _messageTouchScrollActive = false;
|
|
let _messageScrollInputGeneration = 0;
|
|
let writes = 0;
|
|
let cancels = 0;
|
|
const _autoScrollFollow = true;
|
|
const window = {{ _autoScrollFollow: true }};
|
|
const PROGRAMMATIC_SCROLL_VALID_MS = 150;
|
|
function _cancelBottomSettle() {{ cancels += 1; }}
|
|
function _markMessageTouchScrollIntent() {{}}
|
|
function _recentNonMessageScrollIntent() {{ return false; }}
|
|
function _recentMessageScrollIntent() {{ return false; }}
|
|
function _recentMessageTouchScrollIntent() {{ return false; }}
|
|
function _recentMessageWheelIntent() {{ return now - _lastMessageWheelIntentMs < 1200; }}
|
|
function _recentMessageKeyScrollIntent() {{ return false; }}
|
|
function _messageBottomDistance() {{ return messages.scrollHeight - messages.scrollTop - messages.clientHeight; }}
|
|
function _setMessageScrollToBottom() {{ writes += 1; }}
|
|
function _settleMessageScrollToBottom() {{ writes += 1; }}
|
|
{_function_body('_freshProgrammaticScrollActive')}
|
|
{_function_body('_recordNonMessageScrollIntent')}
|
|
{_function_body('scrollIfPinned')}
|
|
|
|
_recordNonMessageScrollIntent({{
|
|
type: 'wheel',
|
|
target: messages,
|
|
deltaY: -5,
|
|
}});
|
|
scrollIfPinned();
|
|
console.log(JSON.stringify({{
|
|
cancels,
|
|
writes,
|
|
programmaticScroll: _programmaticScroll,
|
|
messageUserUnpinned: _messageUserUnpinned,
|
|
scrollPinned: _scrollPinned,
|
|
}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def test_small_upward_wheel_unpins_during_fresh_programmatic_scroll_guard():
|
|
"""A capture-phase wheel event must beat the scroll listener's early return.
|
|
|
|
Small trackpad deltas are intentionally below the ordinary sticky-unpin
|
|
threshold. They still represent a reader trying to leave the live tail when
|
|
a render has armed `_programmaticScroll`; otherwise the listener returns and
|
|
the next stream tick pulls the reader back down.
|
|
"""
|
|
|
|
result = _run_small_upward_wheel_case(programmatic_scroll_age_ms=40)
|
|
assert result["messageUserUnpinned"] is True
|
|
assert result["scrollPinned"] is False
|
|
assert result["cancels"] == 1
|
|
assert result["writes"] == 0
|
|
|
|
|
|
def test_small_upward_wheel_does_not_unpin_after_programmatic_guard_stales():
|
|
"""A stale latch must fall back to the ordinary low-delta wheel threshold."""
|
|
|
|
result = _run_small_upward_wheel_case(programmatic_scroll_age_ms=200)
|
|
assert result["messageUserUnpinned"] is False
|
|
assert result["scrollPinned"] is True
|
|
assert result["programmaticScroll"] is False
|
|
assert result["cancels"] == 0
|
|
|
|
|
|
def test_older_message_fallback_executes_real_slow_render_writer_contract():
|
|
"""Exercise the production fallback, rather than retyping its ownership edge."""
|
|
script = f"""
|
|
let now = 1000;
|
|
const performance = {{ now: () => now }};
|
|
let storedTop = 100;
|
|
let writeObservation = null;
|
|
const container = {{
|
|
scrollHeight: 1000,
|
|
clientHeight: 300,
|
|
get scrollTop() {{ return storedTop; }},
|
|
set scrollTop(value) {{
|
|
storedTop = value;
|
|
writeObservation = {{ value, at: now, fresh: _freshProgrammaticScrollActive() }};
|
|
}},
|
|
}};
|
|
const document = {{ getElementById: (id) => id === 'messages' ? container : null }};
|
|
function $(id) {{ return document.getElementById(id); }}
|
|
const window = {{}};
|
|
const S = {{
|
|
session: {{ session_id: 'slow-render-session' }},
|
|
messages: [{{ role: 'assistant', content: 'tail' }}],
|
|
}};
|
|
let _loadingOlder = false;
|
|
let _messagesTruncated = true;
|
|
let _oldestIdx = 1;
|
|
let _messagesGeneration = 0;
|
|
let _loadingSessionId = null;
|
|
let _messageRenderWindowSize = 50;
|
|
let _scrollPinned = true;
|
|
let _programmaticScroll = true;
|
|
let _programmaticScrollSetAt = now;
|
|
const _INITIAL_MSG_LIMIT = 50;
|
|
const _msgLimitMax = 100;
|
|
const MESSAGE_RENDER_WINDOW_DEFAULT = 50;
|
|
const PROGRAMMATIC_SCROLL_VALID_MS = 150;
|
|
async function api() {{
|
|
return {{
|
|
session: {{
|
|
messages: [
|
|
{{ role: 'user', content: 'older' }},
|
|
{{ role: 'assistant', content: 'tail' }},
|
|
],
|
|
_messages_truncated: true,
|
|
_messages_offset: 0,
|
|
tool_calls: [],
|
|
}},
|
|
}};
|
|
}}
|
|
function _sameTranscriptMessage(a, b) {{ return a && b && a.role === b.role && a.content === b.content; }}
|
|
function _syncToolCallsForLoadedMessages() {{}}
|
|
function _messageIsRenderable() {{ return true; }}
|
|
function msgContent(message) {{ return message && message.content; }}
|
|
function _currentMessageRenderWindowSize() {{ return _messageRenderWindowSize; }}
|
|
function renderMessages() {{ now += 200; container.scrollHeight = 1300; }}
|
|
function _captureMessageViewportAnchor() {{ return null; }}
|
|
function _restoreMessageViewportAnchor() {{ return false; }}
|
|
function _messageVirtualPrependedHeightDelta() {{ return null; }}
|
|
function requestAnimationFrame() {{ return 0; }}
|
|
{_function_body('_freshProgrammaticScrollActive')}
|
|
{_function_body('_loadOlderMessages', SESSIONS_JS)}
|
|
|
|
await _loadOlderMessages();
|
|
if (!writeObservation) throw new Error('real fallback did not write scrollTop');
|
|
if (!writeObservation.fresh) throw new Error('real fallback write inherited a stale latch');
|
|
if (writeObservation.at !== 1200) throw new Error('fallback timestamp was not taken after slow render');
|
|
now = writeObservation.at + 151;
|
|
if (_freshProgrammaticScrollActive()) throw new Error('fallback latch did not expire from its own write');
|
|
console.log(JSON.stringify({{ top: storedTop, observation: writeObservation }}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "--input-type=module", "-e", script],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
observed = json.loads(result.stdout)
|
|
assert observed["top"] == 400
|
|
assert observed["observation"] == {"value": 400, "at": 1200, "fresh": True}
|
|
|
|
|
|
def test_all_programmatic_scroll_writers_stamp_the_shared_freshness_clock():
|
|
"""Keep UI and session writers from silently drifting apart."""
|
|
for source_name, source in (("ui.js", UI_JS), ("sessions.js", SESSIONS_JS)):
|
|
writers = list(re.finditer(r"_programmaticScroll\s*=\s*true\s*;", source))
|
|
assert writers, f"{source_name} has no programmatic-scroll writers"
|
|
for writer in writers:
|
|
tail = source[writer.end() : writer.end() + 160]
|
|
assert re.match(
|
|
r"\s*_programmaticScrollSetAt\s*=\s*performance\.now\(\)\s*;",
|
|
tail,
|
|
), f"{source_name} writer lacks a paired freshness timestamp"
|