mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-24 18:50:15 +00:00
403 lines
16 KiB
Python
403 lines
16 KiB
Python
"""Regression tests for sidebar lineage collapse helpers."""
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).parent.parent.resolve()
|
|
SESSIONS_JS_PATH = REPO_ROOT / "static" / "sessions.js"
|
|
NODE = shutil.which("node")
|
|
|
|
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
|
|
|
|
|
def _run_node(source: str) -> str:
|
|
# Pass source via stdin rather than `-e <source>` argv — the latter is
|
|
# capped at MAX_ARG_STRLEN (131072 bytes on Linux) and tests that embed
|
|
# the entire sessions.js file can exceed that. stdin has no such limit.
|
|
result = subprocess.run(
|
|
[NODE],
|
|
input=source,
|
|
cwd=str(REPO_ROOT),
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(result.stderr)
|
|
return result.stdout.strip()
|
|
|
|
|
|
def test_sidebar_lineage_collapse_keeps_latest_tip_and_counts_segments():
|
|
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
|
|
source = f"""
|
|
const src = {js!r};
|
|
function extractFunc(name) {{
|
|
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
|
|
const start = src.search(re);
|
|
if (start < 0) throw new Error(name + ' not found');
|
|
let i = src.indexOf('{{', start);
|
|
let depth = 1; i++;
|
|
while (depth > 0 && i < src.length) {{
|
|
if (src[i] === '{{') depth++;
|
|
else if (src[i] === '}}') depth--;
|
|
i++;
|
|
}}
|
|
return src.slice(start, i);
|
|
}}
|
|
eval(extractFunc('_sessionTimestampMs'));
|
|
eval(extractFunc('_isChildSession'));
|
|
eval(extractFunc('_sessionLineageKey'));
|
|
eval(extractFunc('_collapseSessionLineageForSidebar'));
|
|
const sessions = [
|
|
{{session_id:'root', title:'Hermes WebUI', message_count:10, updated_at:10, last_message_at:10, _lineage_root_id:'root', _lineage_tip_id:'root'}},
|
|
{{session_id:'tip', title:'Hermes WebUI', message_count:20, updated_at:20, last_message_at:20, _lineage_root_id:'root', _lineage_tip_id:'tip'}},
|
|
{{session_id:'solo', title:'Other', message_count:5, updated_at:15, last_message_at:15}},
|
|
];
|
|
const collapsed = _collapseSessionLineageForSidebar(sessions);
|
|
console.log(JSON.stringify(collapsed));
|
|
"""
|
|
collapsed = json.loads(_run_node(source))
|
|
by_sid = {row["session_id"]: row for row in collapsed}
|
|
assert set(by_sid) == {"tip", "solo"}
|
|
assert by_sid["tip"]["_lineage_collapsed_count"] == 2
|
|
assert [seg["session_id"] for seg in by_sid["tip"]["_lineage_segments"]] == ["tip", "root"]
|
|
|
|
|
|
def test_sidebar_active_state_can_fall_back_to_url_session_during_boot():
|
|
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
|
|
source = f"""
|
|
const src = {js!r};
|
|
function extractFunc(name) {{
|
|
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
|
|
const start = src.search(re);
|
|
if (start < 0) throw new Error(name + ' not found');
|
|
let i = src.indexOf('{{', start);
|
|
let depth = 1; i++;
|
|
while (depth > 0 && i < src.length) {{
|
|
if (src[i] === '{{') depth++;
|
|
else if (src[i] === '}}') depth--;
|
|
i++;
|
|
}}
|
|
return src.slice(start, i);
|
|
}}
|
|
global.S = {{ session: null }};
|
|
global.window = {{ location: {{ pathname: '/session/url-active', search: '', hash: '' }} }};
|
|
eval(extractFunc('_sessionIdFromLocation'));
|
|
eval(extractFunc('_activeSessionIdForSidebar'));
|
|
console.log(_activeSessionIdForSidebar());
|
|
"""
|
|
assert _run_node(source) == "url-active"
|
|
|
|
|
|
def test_collapsed_lineage_contains_active_hidden_segment():
|
|
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
|
|
source = f"""
|
|
const src = {js!r};
|
|
function extractFunc(name) {{
|
|
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
|
|
const start = src.search(re);
|
|
if (start < 0) throw new Error(name + ' not found');
|
|
let i = src.indexOf('{{', start);
|
|
let depth = 1; i++;
|
|
while (depth > 0 && i < src.length) {{
|
|
if (src[i] === '{{') depth++;
|
|
else if (src[i] === '}}') depth--;
|
|
i++;
|
|
}}
|
|
return src.slice(start, i);
|
|
}}
|
|
eval(extractFunc('_sessionTimestampMs'));
|
|
eval(extractFunc('_isChildSession'));
|
|
eval(extractFunc('_sessionLineageKey'));
|
|
eval(extractFunc('_collapseSessionLineageForSidebar'));
|
|
eval(extractFunc('_sessionLineageContainsSession'));
|
|
const sessions = [
|
|
{{session_id:'root', title:'Hermes WebUI', message_count:10, updated_at:10, last_message_at:10, _lineage_root_id:'root', _lineage_tip_id:'tip'}},
|
|
{{session_id:'tip', title:'Hermes WebUI', message_count:20, updated_at:20, last_message_at:20, _lineage_root_id:'root', _lineage_tip_id:'tip'}},
|
|
];
|
|
const collapsed = _collapseSessionLineageForSidebar(sessions);
|
|
console.log(JSON.stringify({{sid: collapsed[0].session_id, containsRoot: _sessionLineageContainsSession(collapsed[0], 'root')}}));
|
|
"""
|
|
result = _run_node(source)
|
|
assert '"sid":"tip"' in result
|
|
assert '"containsRoot":true' in result
|
|
|
|
|
|
def test_stale_optimistic_compression_tips_collapse_even_when_parents_are_visible():
|
|
"""Active compression can leave old streaming tips in browser memory.
|
|
|
|
The server/index already expose only the latest tip, but client-side
|
|
optimistic rows from previous tips may still include parent_session_id links.
|
|
Those rows carry explicit lineage metadata and must collapse as one sidebar
|
|
conversation instead of rendering 7/8/9/10 segment duplicates.
|
|
"""
|
|
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
|
|
source = f"""
|
|
const src = {js!r};
|
|
function extractFunc(name) {{
|
|
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
|
|
const start = src.search(re);
|
|
if (start < 0) throw new Error(name + ' not found');
|
|
let i = src.indexOf('{{', start);
|
|
let depth = 1; i++;
|
|
while (depth > 0 && i < src.length) {{
|
|
if (src[i] === '{{') depth++;
|
|
else if (src[i] === '}}') depth--;
|
|
i++;
|
|
}}
|
|
return src.slice(start, i);
|
|
}}
|
|
eval(extractFunc('_sessionTimestampMs'));
|
|
eval(extractFunc('_isChildSession'));
|
|
eval(extractFunc('_sessionLineageKey'));
|
|
eval(extractFunc('_collapseSessionLineageForSidebar'));
|
|
const sessions = [
|
|
{{session_id:'seg7', title:'Graphify', parent_session_id:'seg6', message_count:1141, updated_at:70, last_message_at:70, _lineage_root_id:'root', _compression_segment_count:7}},
|
|
{{session_id:'seg8', title:'Graphify', parent_session_id:'seg7', message_count:1254, updated_at:80, last_message_at:80, _lineage_root_id:'root', _compression_segment_count:8, pending_user_message:'old'}},
|
|
{{session_id:'seg9', title:'Graphify', parent_session_id:'seg8', message_count:1404, updated_at:90, last_message_at:90, _lineage_root_id:'root', _compression_segment_count:9, active_stream_id:'old-stream'}},
|
|
{{session_id:'seg10', title:'Graphify', parent_session_id:'seg9', message_count:1490, updated_at:100, last_message_at:100, _lineage_root_id:'root', _compression_segment_count:10, active_stream_id:'current-stream'}},
|
|
];
|
|
const collapsed = _collapseSessionLineageForSidebar(sessions);
|
|
console.log(JSON.stringify(collapsed));
|
|
"""
|
|
collapsed = json.loads(_run_node(source))
|
|
assert [row["session_id"] for row in collapsed] == ["seg10"]
|
|
assert collapsed[0]["_lineage_collapsed_count"] == 4
|
|
assert collapsed[0]["_compression_segment_count"] == 10
|
|
assert [seg["session_id"] for seg in collapsed[0]["_lineage_segments"]] == ["seg10", "seg9", "seg8", "seg7"]
|
|
|
|
|
|
def test_sidebar_lineage_collapse_prefers_highest_compression_segment_over_touched_parent():
|
|
"""A touched parent segment must not hide the newer compressed tip.
|
|
|
|
Opening or polling an older segment can refresh its updated_at without adding
|
|
messages. The collapsed sidebar row must still pick the highest compression
|
|
segment, otherwise the visible chat jumps back to a parent that lacks the
|
|
completed assistant answer.
|
|
"""
|
|
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
|
|
source = f"""
|
|
const src = {js!r};
|
|
function extractFunc(name) {{
|
|
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
|
|
const start = src.search(re);
|
|
if (start < 0) throw new Error(name + ' not found');
|
|
let i = src.indexOf('{{', start);
|
|
let depth = 1; i++;
|
|
while (depth > 0 && i < src.length) {{
|
|
if (src[i] === '{{') depth++;
|
|
else if (src[i] === '}}') depth--;
|
|
i++;
|
|
}}
|
|
return src.slice(start, i);
|
|
}}
|
|
eval(extractFunc('_sessionTimestampMs'));
|
|
eval(extractFunc('_isChildSession'));
|
|
eval(extractFunc('_sessionLineageKey'));
|
|
eval(extractFunc('_collapseSessionLineageForSidebar'));
|
|
const sessions = [
|
|
{{session_id:'seg13', title:'Schaue dir die Release (fork)', message_count:2490, updated_at:200, last_message_at:200, _lineage_root_id:'root', _compression_segment_count:13}},
|
|
{{session_id:'seg14', title:'Schaue dir die Release (fork)', message_count:2532, updated_at:150, last_message_at:150, _lineage_root_id:'root', _compression_segment_count:14}},
|
|
];
|
|
const collapsed = _collapseSessionLineageForSidebar(sessions);
|
|
console.log(JSON.stringify(collapsed));
|
|
"""
|
|
collapsed = json.loads(_run_node(source))
|
|
assert [row["session_id"] for row in collapsed] == ["seg14"]
|
|
assert collapsed[0]["_lineage_collapsed_count"] == 2
|
|
assert [seg["session_id"] for seg in collapsed[0]["_lineage_segments"]] == ["seg14", "seg13"]
|
|
|
|
|
|
|
|
def test_sidebar_attaches_child_sessions_to_collapsed_hidden_parent_lineage():
|
|
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
|
|
source = f"""
|
|
const src = {js!r};
|
|
function extractFunc(name) {{
|
|
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
|
|
const start = src.search(re);
|
|
if (start < 0) throw new Error(name + ' not found');
|
|
let i = src.indexOf('{{', start);
|
|
let depth = 1; i++;
|
|
while (depth > 0 && i < src.length) {{
|
|
if (src[i] === '{{') depth++;
|
|
else if (src[i] === '}}') depth--;
|
|
i++;
|
|
}}
|
|
return src.slice(start, i);
|
|
}}
|
|
eval(extractFunc('_sessionTimestampMs'));
|
|
eval(extractFunc('_isChildSession'));
|
|
eval(extractFunc('_sessionLineageKey'));
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_collapseSessionLineageForSidebar'));
|
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
|
const raw = [
|
|
{{session_id:'root', title:'Root', updated_at:10, last_message_at:10, _lineage_root_id:'root', _lineage_tip_id:'tip'}},
|
|
{{session_id:'tip', title:'Tip', updated_at:20, last_message_at:20, _lineage_root_id:'root', _lineage_tip_id:'tip'}},
|
|
{{session_id:'child', title:'Subtask', parent_session_id:'tip', relationship_type:'child_session', _parent_lineage_root_id:'root', updated_at:30, last_message_at:30}},
|
|
];
|
|
const collapsed = _collapseSessionLineageForSidebar(raw);
|
|
const attached = _attachChildSessionsToSidebarRows(collapsed, raw);
|
|
console.log(JSON.stringify(attached));
|
|
"""
|
|
rows = json.loads(_run_node(source))
|
|
assert [row["session_id"] for row in rows] == ["tip"]
|
|
assert rows[0]["_child_session_count"] == 1
|
|
assert rows[0]["_child_sessions"][0]["session_id"] == "child"
|
|
|
|
|
|
def test_cross_surface_webui_child_session_remains_top_level_when_parent_is_messaging():
|
|
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
|
|
source = f"""
|
|
const src = {js!r};
|
|
function extractFunc(name) {{
|
|
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
|
|
const start = src.search(re);
|
|
if (start < 0) throw new Error(name + ' not found');
|
|
let i = src.indexOf('{{', start);
|
|
let depth = 1; i++;
|
|
while (depth > 0 && i < src.length) {{
|
|
if (src[i] === '{{') depth++;
|
|
else if (src[i] === '}}') depth--;
|
|
i++;
|
|
}}
|
|
return src.slice(start, i);
|
|
}}
|
|
eval(extractFunc('_isChildSession'));
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
|
const collapsed = [{{session_id:'telegram_parent', title:'Telegram parent', source_label:'Telegram'}}];
|
|
const raw = [
|
|
collapsed[0],
|
|
{{
|
|
session_id:'webui_tip',
|
|
title:'Current WebUI continuation',
|
|
parent_session_id:'telegram_parent',
|
|
relationship_type:'child_session',
|
|
parent_source:'telegram',
|
|
source_label:'Telegram',
|
|
session_source:'messaging',
|
|
raw_source:'telegram',
|
|
_cross_surface_child_session:true,
|
|
}},
|
|
];
|
|
const rows = _attachChildSessionsToSidebarRows(collapsed, raw);
|
|
console.log(JSON.stringify(rows));
|
|
"""
|
|
rows = json.loads(_run_node(source))
|
|
assert [row["session_id"] for row in rows] == ["telegram_parent", "webui_tip"]
|
|
assert rows[1].get("_orphan_child_session") is True
|
|
assert "_child_sessions" not in rows[0]
|
|
|
|
|
|
def test_session_segment_count_prefers_visible_collapsed_backend_and_materialized_counts():
|
|
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
|
|
source = f"""
|
|
const src = {js!r};
|
|
function extractFunc(name) {{
|
|
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
|
|
const start = src.search(re);
|
|
if (start < 0) throw new Error(name + ' not found');
|
|
let i = src.indexOf('{{', start);
|
|
let depth = 1; i++;
|
|
while (depth > 0 && i < src.length) {{
|
|
if (src[i] === '{{') depth++;
|
|
else if (src[i] === '}}') depth--;
|
|
i++;
|
|
}}
|
|
return src.slice(start, i);
|
|
}}
|
|
eval(extractFunc('_sessionSegmentCount'));
|
|
const cases = [
|
|
_sessionSegmentCount({{_lineage_collapsed_count:3, _compression_segment_count:2, _lineage_segments:[{{session_id:'a'}}, {{session_id:'b'}}]}}),
|
|
_sessionSegmentCount({{_compression_segment_count:25}}),
|
|
_sessionSegmentCount({{_lineage_segments:[{{session_id:'tip'}}, {{session_id:'root'}}, {{session_id:'older'}}]}}),
|
|
_sessionSegmentCount({{_lineage_collapsed_count:1, _compression_segment_count:1}}),
|
|
_sessionSegmentCount(null),
|
|
];
|
|
console.log(JSON.stringify(cases));
|
|
"""
|
|
assert json.loads(_run_node(source)) == [3, 25, 3, 0, 0]
|
|
|
|
|
|
def test_sidebar_lineage_segment_badge_is_localized():
|
|
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
|
|
css = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
|
assert "session-lineage-count" in js
|
|
assert "const segmentCount=_sessionSegmentCount(s);" in js
|
|
assert "t('session_meta_segments', segmentCount)" in js
|
|
assert "titleRow.appendChild(segmentCountEl);" in js
|
|
assert ".session-lineage-count{" in css
|
|
|
|
|
|
def test_lineage_segment_expansion_static_contract():
|
|
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
|
|
css = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
|
assert "const _expandedLineageKeys = new Set();" in js
|
|
assert "session-lineage-count,.session-lineage-segments,.session-lineage-segment" in js
|
|
assert "segmentCountEl.setAttribute('aria-expanded'" in js
|
|
assert "_expandedLineageKeys.has(lineageKey)" in js
|
|
assert "_expandedLineageKeys.add(lineageKey)" in js
|
|
assert "_expandedLineageKeys.delete(lineageKey)" in js
|
|
assert "className='session-lineage-segments'" in js
|
|
assert "className='session-lineage-segment'" in js
|
|
assert "const segTitle=seg.title||t('session_lineage_segment_untitled');" in js
|
|
assert "row.title=t('session_lineage_segment_open');" in js
|
|
assert "await loadSession(seg.session_id);" in js
|
|
assert ".session-lineage-count.expandable{" in css
|
|
assert ".session-lineage-count.expandable:hover" in css
|
|
assert ".session-lineage-segments{" in css
|
|
assert ".session-lineage-segment{" in css
|
|
|
|
|
|
def test_active_hidden_lineage_segment_auto_expands_parent():
|
|
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
|
|
source = f"""
|
|
const src = {js!r};
|
|
function extractFunc(name) {{
|
|
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
|
|
const start = src.search(re);
|
|
if (start < 0) throw new Error(name + ' not found');
|
|
let i = src.indexOf('{{', start);
|
|
let depth = 1; i++;
|
|
while (depth > 0 && i < src.length) {{
|
|
if (src[i] === '{{') depth++;
|
|
else if (src[i] === '}}') depth--;
|
|
i++;
|
|
}}
|
|
return src.slice(start, i);
|
|
}}
|
|
const _expandedChildSessionKeys = new Set();
|
|
const _expandedLineageKeys = new Set();
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_syncSidebarExpansionForActiveSession'));
|
|
const rows = [{{
|
|
session_id:'seg10',
|
|
_lineage_key:'root',
|
|
_lineage_segments:[
|
|
{{session_id:'seg10', updated_at:100}},
|
|
{{session_id:'seg9', updated_at:90}},
|
|
{{session_id:'seg8', updated_at:80}},
|
|
],
|
|
}}];
|
|
_syncSidebarExpansionForActiveSession(rows, 'seg8');
|
|
console.log(JSON.stringify({{lineage:[..._expandedLineageKeys], child:[..._expandedChildSessionKeys]}}));
|
|
"""
|
|
assert json.loads(_run_node(source)) == {"lineage": ["root"], "child": []}
|
|
|
|
|
|
def test_lineage_segment_locale_keys_are_defined_for_sidebar_locales():
|
|
i18n = (REPO_ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
|
|
required = [
|
|
"session_meta_segments:",
|
|
"session_lineage_segment_untitled:",
|
|
"session_lineage_segment_open:",
|
|
]
|
|
locale_count = i18n.count("session_meta_messages:")
|
|
for key in required:
|
|
assert i18n.count(key) >= locale_count, f"{key} missing from one or more locale blocks"
|