236 lines
8.1 KiB
Python
236 lines
8.1 KiB
Python
"""Regression coverage for issue #4793 DOM recycle hardening."""
|
|
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
REPO_ROOT = Path(__file__).parent.parent.resolve()
|
|
UI_JS_PATH = REPO_ROOT / "static" / "ui.js"
|
|
NODE = shutil.which("node")
|
|
|
|
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
|
|
|
|
|
def _run_node(source: str) -> dict:
|
|
with tempfile.NamedTemporaryFile(
|
|
"w", suffix=".cjs", encoding="utf-8", dir=REPO_ROOT, delete=False
|
|
) as script:
|
|
script.write(source)
|
|
script_path = Path(script.name)
|
|
try:
|
|
result = subprocess.run(
|
|
[NODE, str(script_path)],
|
|
cwd=str(REPO_ROOT),
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
finally:
|
|
script_path.unlink(missing_ok=True)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(result.stderr)
|
|
return json.loads(result.stdout.strip())
|
|
|
|
|
|
def _js_prefix() -> str:
|
|
return f"""
|
|
const fs = require('fs');
|
|
const src = fs.readFileSync({json.dumps(str(UI_JS_PATH))}, 'utf8');
|
|
function extractBetween(startMarker, endMarker, startFrom = 0) {{
|
|
const start = src.indexOf(startMarker, startFrom);
|
|
if (start < 0) throw new Error(startMarker + ' not found');
|
|
const end = src.indexOf(endMarker, start);
|
|
if (end < 0) throw new Error(endMarker + ' not found');
|
|
return src.slice(start, end);
|
|
}}
|
|
function extractBody(startMarker, endMarker, startFrom = 0) {{
|
|
const start = src.indexOf(startMarker, startFrom);
|
|
if (start < 0) throw new Error(startMarker + ' not found');
|
|
const end = src.indexOf(endMarker, start);
|
|
if (end < 0) throw new Error(endMarker + ' not found');
|
|
return src.slice(start + startMarker.length, end);
|
|
}}
|
|
"""
|
|
|
|
|
|
def test_recycled_assistant_turn_reuse_clears_live_anchor_attrs():
|
|
script = _js_prefix() + """
|
|
const recycleChunk = extractBetween("if(!currentAssistantTurn){", "const seg=document.createElement('div');");
|
|
const resetListChunk = extractBetween("const _recycleResetAttrs=[", "let _scrollbarDragActive=false;")
|
|
.replace("const _recycleResetAttrs=", "var _recycleResetAttrs=");
|
|
const removedAttrs = [];
|
|
const role = {
|
|
replacement: null,
|
|
set outerHTML(value) { this.replacement = value; },
|
|
get outerHTML() { return this.replacement; },
|
|
};
|
|
const recycled = {
|
|
dataset: { recycleKey: 'old-key', role: 'old-role', sessionId: 'old-session' },
|
|
classList: { contains(name) { return name === 'assistant-turn'; } },
|
|
removeAttribute(name) { removedAttrs.push(name); },
|
|
querySelector(selector) { return selector === '.msg-role.assistant' ? role : null; },
|
|
};
|
|
eval(resetListChunk);
|
|
const _recycleStash = new Map([[7, recycled]]);
|
|
const _msgNodeRecycleEnabled = true;
|
|
let currentAssistantTurn = null;
|
|
const rawIdx = 7;
|
|
const blocks = { innerHTML: 'keep me' };
|
|
const _assistantTurnBlocks = () => blocks;
|
|
const inner = { appendChild() {} };
|
|
const tsTitle = 'Turn title';
|
|
const m = { _turnTps: 42 };
|
|
const S = { session: { session_id: 'sess-1' } };
|
|
const _assistantRoleHtml = (title, tps) => `role:${title}:${tps}`;
|
|
const isTpsDisplayEnabled = () => true;
|
|
const _formatTurnTps = value => `tps:${value}`;
|
|
const latestRenderedAssistantRawIdx = rawIdx;
|
|
function _setLatestAssistantTurnLandmark() {}
|
|
eval(recycleChunk);
|
|
console.log(JSON.stringify({
|
|
removedAttrs,
|
|
resetAttrs: _recycleResetAttrs,
|
|
blocksInnerHTML: blocks.innerHTML,
|
|
roleHtml: role.replacement,
|
|
currentAssistantTurnIsRecycled: currentAssistantTurn === recycled,
|
|
dataset: {
|
|
role: currentAssistantTurn.dataset.role,
|
|
sessionId: currentAssistantTurn.dataset.sessionId,
|
|
recycleKey: String(currentAssistantTurn.dataset.recycleKey),
|
|
},
|
|
}));
|
|
"""
|
|
result = _run_node(script)
|
|
|
|
expected_attrs = {
|
|
"data-transparent-turn-collapsed",
|
|
"data-transparent-turn-toggle-bound",
|
|
"data-anchor-scene-live-owner",
|
|
"data-anchor-stream-id",
|
|
"data-latest-assistant-response",
|
|
"role",
|
|
"aria-label",
|
|
"data-live-assistant-turn",
|
|
}
|
|
assert set(result["resetAttrs"]) == expected_attrs
|
|
assert len(result["resetAttrs"]) == len(expected_attrs)
|
|
assert set(result["removedAttrs"]) == expected_attrs
|
|
assert len(result["removedAttrs"]) == len(expected_attrs)
|
|
assert result["currentAssistantTurnIsRecycled"] is True
|
|
assert result["dataset"] == {
|
|
"role": "assistant",
|
|
"sessionId": "sess-1",
|
|
"recycleKey": "7",
|
|
}
|
|
|
|
|
|
def test_recycled_assistant_turn_reuse_keeps_block_clear_and_role_refresh():
|
|
script = _js_prefix() + """
|
|
const recycleChunk = extractBetween("if(!currentAssistantTurn){", "const seg=document.createElement('div');");
|
|
const resetListChunk = extractBetween("const _recycleResetAttrs=[", "let _scrollbarDragActive=false;")
|
|
.replace("const _recycleResetAttrs=", "var _recycleResetAttrs=");
|
|
const removedAttrs = [];
|
|
const role = {
|
|
replacement: null,
|
|
set outerHTML(value) { this.replacement = value; },
|
|
get outerHTML() { return this.replacement; },
|
|
};
|
|
const recycled = {
|
|
dataset: { recycleKey: 'old-key' },
|
|
classList: { contains(name) { return name === 'assistant-turn'; } },
|
|
removeAttribute(name) { removedAttrs.push(name); },
|
|
querySelector(selector) { return selector === '.msg-role.assistant' ? role : null; },
|
|
};
|
|
eval(resetListChunk);
|
|
const _recycleStash = new Map([[7, recycled]]);
|
|
const _msgNodeRecycleEnabled = true;
|
|
let currentAssistantTurn = null;
|
|
const rawIdx = 7;
|
|
const blocks = { innerHTML: 'keep me' };
|
|
const _assistantTurnBlocks = () => blocks;
|
|
const inner = { appendChild() {} };
|
|
const tsTitle = 'Turn title';
|
|
const m = { _turnTps: 42 };
|
|
const S = { session: { session_id: 'sess-1' } };
|
|
const _assistantRoleHtml = (title, tps) => `role:${title}:${tps}`;
|
|
const isTpsDisplayEnabled = () => true;
|
|
const _formatTurnTps = value => `tps:${value}`;
|
|
const latestRenderedAssistantRawIdx = rawIdx;
|
|
function _setLatestAssistantTurnLandmark() {}
|
|
eval(recycleChunk);
|
|
console.log(JSON.stringify({
|
|
blocksInnerHTML: blocks.innerHTML,
|
|
roleHtml: role.replacement,
|
|
currentAssistantTurnIsRecycled: currentAssistantTurn === recycled,
|
|
dataset: {
|
|
role: currentAssistantTurn.dataset.role,
|
|
sessionId: currentAssistantTurn.dataset.sessionId,
|
|
recycleKey: String(currentAssistantTurn.dataset.recycleKey),
|
|
},
|
|
}));
|
|
"""
|
|
result = _run_node(script)
|
|
|
|
assert result["blocksInnerHTML"] == ""
|
|
assert result["roleHtml"] == "role:Turn title:tps:42"
|
|
assert result["currentAssistantTurnIsRecycled"] is True
|
|
assert result["dataset"] == {
|
|
"role": "assistant",
|
|
"sessionId": "sess-1",
|
|
"recycleKey": "7",
|
|
}
|
|
|
|
|
|
def test_messages_scrollbar_drag_ignores_bubbled_child_pointerdown():
|
|
script = _js_prefix() + """
|
|
const pointerdownBody = extractBody("el.addEventListener('pointerdown',(e)=>{", "},{passive:true});");
|
|
let _scrollbarDragActive = false;
|
|
const el = { clientWidth: 120 };
|
|
eval(`function pointerdownHandler(e){${pointerdownBody}}`);
|
|
pointerdownHandler({ target: { clientWidth: 120 }, offsetX: 999 });
|
|
const bubbledArmed = _scrollbarDragActive;
|
|
_scrollbarDragActive = false;
|
|
pointerdownHandler({ target: el, offsetX: 999 });
|
|
console.log(JSON.stringify({
|
|
bubbledArmed,
|
|
directArmed: _scrollbarDragActive,
|
|
}));
|
|
"""
|
|
result = _run_node(script)
|
|
|
|
assert result["bubbledArmed"] is False
|
|
assert result["directArmed"] is True
|
|
|
|
|
|
def test_messages_scrollbar_drag_still_arms_for_direct_messages_press_and_rerenders_on_pointerup():
|
|
script = _js_prefix() + """
|
|
const pointerdownBody = extractBody("el.addEventListener('pointerdown',(e)=>{", "},{passive:true});");
|
|
const pointerupBody = extractBody("window.addEventListener('pointerup',()=>{", "},{passive:true});");
|
|
const rerenders = [];
|
|
let _scrollbarDragActive = false;
|
|
const el = { clientWidth: 120 };
|
|
function _scheduleMessageVirtualizedRender(force) {
|
|
rerenders.push(force);
|
|
}
|
|
eval(`function pointerdownHandler(e){${pointerdownBody}}`);
|
|
eval(`function pointerupHandler(){${pointerupBody}}`);
|
|
pointerdownHandler({ target: el, offsetX: 999 });
|
|
const armed = _scrollbarDragActive;
|
|
pointerupHandler();
|
|
console.log(JSON.stringify({
|
|
armed,
|
|
afterPointerup: _scrollbarDragActive,
|
|
rerenders,
|
|
}));
|
|
"""
|
|
result = _run_node(script)
|
|
|
|
assert result["armed"] is True
|
|
assert result["afterPointerup"] is False
|
|
assert result["rerenders"] == [True]
|