Files
hermes-webui/tests/test_smooth_text_fade.py
T

560 lines
21 KiB
Python

import re
import subprocess
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
CONFIG_PY = (REPO / "api" / "config.py").read_text(encoding="utf-8")
INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
PANELS_JS = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
I18N_JS = (REPO / "static" / "i18n.js").read_text(encoding="utf-8")
FADE_SETTING = "fade_text_effect"
FADE_CHECKBOX_ID = "settingsFadeTextEffect"
FADE_RUNTIME_FLAG = "window._fadeTextEffect"
FADE_LABEL_KEY = "settings_label_fade_text_effect"
FADE_DESC_KEY = "settings_desc_fade_text_effect"
def function_block(src: str, name: str) -> str:
marker = re.search(rf"(^|\n)\s*(?:async\s+)?function\s+{re.escape(name)}\(", src)
assert marker is not None, f"{name}() not found"
start = marker.start()
brace = src.find("{", marker.end())
assert brace != -1, f"{name}() opening brace not found"
depth = 0
in_string = None
escape = False
for i in range(brace, len(src)):
ch = src[i]
if in_string:
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == in_string:
in_string = None
continue
if ch in "'`\"":
in_string = ch
continue
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return src[start : i + 1]
raise AssertionError(f"{name}() closing brace not found")
def assert_contains_all(src: str, snippets: list[str]) -> None:
for snippet in snippets:
assert snippet in src
def slice_between(src: str, start_anchor: str, end_anchor: str) -> str:
start = src.find(start_anchor)
assert start != -1, f"start anchor not found: {start_anchor!r}"
end = src.find(end_anchor, start + len(start_anchor))
assert end != -1, f"end anchor not found after {start_anchor!r}: {end_anchor!r}"
return src[start:end]
def fade_helper_script(performance_stub: str = "{_t:0,now(){return this._t;}}") -> str:
helpers = "\n".join(
function_block(MESSAGES_JS, name)
for name in [
"_streamFadeWordCountOf",
"_streamFadePauseAfter",
"_resetStreamFadeState",
"_streamFadeNextText",
]
)
return f"""
let _streamFadeVisibleText='';
let _streamFadeLastTickMs=0;
let _streamFadeWordCarry=0;
let _streamFadeStartedAt=0;
let _streamFadeLastTargetWords=0;
let _streamFadeLastArrivalMs=0;
let _streamFadeArrivalWps=0;
let _streamFadeLatestAnimationEndAt=0;
let _streamFadeVisibleWords=0;
let _streamFadeHoldUntilMs=0;
let _streamFadeCurrentMs=620;
let _streamFadeDomText='';
const _STREAM_FADE_MS=620;
const _STREAM_FADE_MAX_MS=900;
const _STREAM_FADE_DONE_MAX_MS=1000;
const _STREAM_FADE_DONE_DRAIN_MAX_MS=1400;
const performance={performance_stub};
{helpers}
"""
def run_node(script: str) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
["node", "-e", script],
cwd=REPO,
text=True,
capture_output=True,
check=False,
)
assert result.returncode == 0, result.stderr
return result
def test_fade_text_effect_setting_is_wired_through_backend_and_startup():
bool_keys = CONFIG_PY[CONFIG_PY.index("_SETTINGS_BOOL_KEYS") : CONFIG_PY.index("# Language codes")]
assert f'"{FADE_SETTING}": False' in CONFIG_PY
assert f'"{FADE_SETTING}"' in bool_keys
assert f"{FADE_RUNTIME_FLAG}=!!s.{FADE_SETTING}" in BOOT_JS
assert f"{FADE_RUNTIME_FLAG}=false" in BOOT_JS
def test_preferences_ui_exposes_and_saves_fade_text_effect():
assert f'id="{FADE_CHECKBOX_ID}"' in INDEX_HTML
assert f'data-i18n="{FADE_LABEL_KEY}"' in INDEX_HTML
assert f'data-i18n="{FADE_DESC_KEY}"' in INDEX_HTML
assert FADE_LABEL_KEY in I18N_JS
assert FADE_DESC_KEY in I18N_JS
payload_block = function_block(PANELS_JS, "_preferencesPayloadFromUi")
assert_contains_all(payload_block, [f"$('{FADE_CHECKBOX_ID}')", f"payload.{FADE_SETTING}="])
load_block = function_block(PANELS_JS, "loadSettingsPanel")
fade_load = load_block[load_block.index(f"$('{FADE_CHECKBOX_ID}')") :]
assert_contains_all(
fade_load[:700],
[f"settings.{FADE_SETTING}", FADE_RUNTIME_FLAG, "addEventListener('change',_schedulePreferencesAutosave"],
)
autosave_block = function_block(PANELS_JS, "_autosavePreferencesSettings")
assert_contains_all(autosave_block, [FADE_SETTING, f"{FADE_RUNTIME_FLAG}=!!payload.{FADE_SETTING}"])
save_block = function_block(PANELS_JS, "saveSettings")
assert_contains_all(save_block, [FADE_CHECKBOX_ID, f"body.{FADE_SETTING}", "fadeTextEffect"])
apply_block = function_block(PANELS_JS, "_applySavedSettingsUi")
assert_contains_all(apply_block, ["fadeTextEffect", f"{FADE_RUNTIME_FLAG}=!!fadeTextEffect"])
def test_stream_fade_uses_incremental_renderer_without_changing_default_path():
# _scheduleRender is deeply nested inside attachLiveStream; the simple
# brace-counting function_block parser can't handle template literals
# with ${...} that contain braces. Use the full file for assertions
# instead — the checked strings are unique enough.
assert re.search(r"function\s+_scheduleRender\(", MESSAGES_JS)
render_block = function_block(MESSAGES_JS, "_renderStreamingFadeMarkdown")
renderer_block = function_block(MESSAGES_JS, "_streamFadeRenderer")
cleanup_block = function_block(MESSAGES_JS, "_streamFadeBindCleanup")
assert_contains_all(
MESSAGES_JS,
[
"_renderStreamingFadeMarkdown(displayText)",
"_smdWrite(displayText)",
"?33:66",
],
)
assert_contains_all(
render_block,
[
"_streamFadeNextText(displayText)",
"if(!next.changed) return next.caughtUp",
"if(!_shouldUseTransparentStreamFade())",
"_smdNewParser(assistantBody,true)",
"_smdWrite(next.text,true)",
"_sanitizeSmdLinks(assistantBody)",
"assistantBody.appendChild(document.createTextNode(delta))",
"_streamFadeDomText=String(next.text||'')",
"stream-fade-active",
],
)
assert render_block.index("_smdWrite(next.text,true)") < render_block.index(
"assistantBody.appendChild(document.createTextNode(delta))"
)
assert "_streamFadeAppendText(assistantBody,delta)" not in render_block
assert "_streamFadeBindCleanup(assistantBody)" not in render_block
append_block = function_block(MESSAGES_JS, "_streamFadeAppendText")
assert_contains_all(
append_block,
[
"document.createDocumentFragment()",
"span.className='stream-fade-word is-new'",
"el.appendChild(frag)",
"_streamFadeLatestAnimationEndAt",
],
)
assert_contains_all(
renderer_block,
[
"span.className='stream-fade-word is-new'",
"_streamFadeReduceMotionEnabled()",
"const appendStartedAt=performance.now()",
"--stream-fade-ms",
"renderer.set_attr",
"data-blocked-scheme",
"_streamFadeLatestAnimationEndAt",
],
)
assert_contains_all(
cleanup_block,
["animationend", "span.replaceWith(document.createTextNode"],
)
assert "_wrapStreamingFadeWords" not in MESSAGES_JS
assert "animationDelay" not in renderer_block
assert "_STREAM_FADE_STAGGER_MS" not in MESSAGES_JS
assert "_streamFadeAppendOffset" not in MESSAGES_JS
def test_stream_fade_appends_new_spans_without_replacing_existing_nodes():
script = (
function_block(MESSAGES_JS, "_streamFadeAppendText")
+ r"""
const _STREAM_FADE_MS=620;
let _streamFadeLatestAnimationEndAt=0;
let _streamFadeCurrentMs=620;
const performance={_t:0,now(){return this._t;}};
function _streamFadeReduceMotionEnabled(){ return false; }
class FakeNode{
constructor(type,text=''){
this.type=type;
this.children=[];
this.className='';
this.textContent=text;
this.style={values:{},setProperty:(name,value)=>{this.style.values[name]=value;}};
}
appendChild(child){
if(child&&child.type==='fragment'){
child.children.forEach(n=>this.children.push(n));
}else{
this.children.push(child);
}
return child;
}
}
global.document={
createDocumentFragment(){ return new FakeNode('fragment'); },
createTextNode(text){ return new FakeNode('text',String(text)); },
createElement(tag){ const node=new FakeNode(tag); node.tagName=String(tag).toUpperCase(); return node; },
};
const body=new FakeNode('div');
_streamFadeAppendText(body,'alpha beta ');
const firstSpan=body.children.find(node=>node.className==='stream-fade-word is-new');
if(!firstSpan) throw new Error('missing first fade span');
_streamFadeAppendText(body,'gamma');
if(body.children.find(node=>node.className==='stream-fade-word is-new')!==firstSpan){
throw new Error('first span was replaced');
}
const spans=body.children.filter(node=>node.className==='stream-fade-word is-new');
if(spans.length!==3) throw new Error(`expected three animated spans, got ${spans.length}`);
if(spans.map(node=>node.textContent).join('|')!=='alpha|beta|gamma'){
throw new Error(`wrong span text: ${spans.map(node=>node.textContent).join('|')}`);
}
"""
)
run_node(script)
def test_transparent_anchor_prose_uses_fade_renderer_when_enabled():
anchor_block = function_block(MESSAGES_JS, "_anchorProseIncrementalNode")
predicate_block = function_block(MESSAGES_JS, "_shouldUseLiveProseFade")
assert_contains_all(
anchor_block,
[
"const fade=typeof _shouldUseLiveProseFade==='function'&&_shouldUseLiveProseFade()",
"if(st && st.fade!==fade) st=null",
"if(body.classList) body.classList.toggle('stream-fade-active',fade)",
"const baseRenderer=fade?_streamFadeRenderer(body):_safeSmdRenderer(body)",
"st={node,parser:window.smd.parser(renderer),writtenText:'',fade}",
"const body=st.node&&st.node.querySelector&&st.node.querySelector('.msg-body')",
],
)
assert_contains_all(
predicate_block,
[
"!_streamFadeReduceMotionEnabled()",
"_shouldUseStreamFade()",
"_shouldUseTransparentStreamFade()",
],
)
assert "function _shouldUseTransparentStreamFade()" in MESSAGES_JS
assert "typeof isTransparentStream==='function'&&isTransparentStream()" in MESSAGES_JS
def test_reduced_motion_disables_live_prose_fade_predicate():
script = (
"\n".join(
function_block(MESSAGES_JS, name)
for name in [
"_shouldUseStreamFade",
"_shouldUseTransparentStreamFade",
"_streamFadeReduceMotionEnabled",
"_shouldUseLiveProseFade",
]
)
+ r"""
let _streamFadeReduceMotionMql=null;
let _streamFadeReduceMotion=false;
let _streamFadeReduceMotionOnChange=null;
let transparent=true;
let reduceMotion=true;
global.window={
_fadeTextEffect:true,
matchMedia(){
return {
get matches(){ return reduceMotion; },
addEventListener(){},
removeEventListener(){},
};
},
};
function isTransparentStream(){ return transparent; }
if(_shouldUseLiveProseFade()) throw new Error('reduced motion allowed live prose fade');
_streamFadeReduceMotionMql=null;
reduceMotion=false;
window._fadeTextEffect=false;
if(!_shouldUseLiveProseFade()) throw new Error('transparent stream fade should work when motion is allowed');
_streamFadeReduceMotionMql=null;
transparent=false;
window._fadeTextEffect=true;
if(!_shouldUseLiveProseFade()) throw new Error('regular fade preference should work when motion is allowed');
"""
)
run_node(script)
def test_transparent_stream_hidden_body_appends_plain_text_only():
script = (
function_block(MESSAGES_JS, "_renderStreamingFadeMarkdown")
+ r"""
let _streamFadeDomText='';
let _smdParser=null;
let _smdReconnect=false;
let parserEnded=false;
function _streamFadeNextText(){ return {changed:true,caughtUp:false,text:'alpha beta'}; }
function _shouldUseTransparentStreamFade(){ return true; }
function _smdEndParser(){ parserEnded=true; }
const assistantBody={
textContent:'',
innerHTML:'',
children:[],
classList:{added:[],add(name){ this.added.push(name); }},
appendChild(node){
this.children.push(node);
this.textContent += String(node.textContent || '');
return node;
},
};
global.document={
createTextNode(text){ return {type:'text',textContent:String(text)}; },
};
const caughtUp=_renderStreamingFadeMarkdown('alpha beta');
if(caughtUp) throw new Error('expected fade playout to remain catching up');
if(assistantBody.textContent!=='alpha beta') throw new Error(`wrong hidden text: ${assistantBody.textContent}`);
if(_streamFadeDomText!=='alpha beta') throw new Error(`wrong dom text: ${_streamFadeDomText}`);
if(assistantBody.children.some(node=>node.className==='stream-fade-word is-new')){
throw new Error('hidden body received fade span');
}
if(!assistantBody.classList.added.includes('stream-fade-active')) throw new Error('missing stream fade active marker');
"""
)
run_node(script)
def test_transparent_anchor_prose_receives_revealed_fade_text():
render_section = slice_between(
MESSAGES_JS,
"const displayText = segmentStart===0",
"scrollIfPinned();",
)
assert_contains_all(
render_section,
[
"let anchorProcessText=displayText",
"if(assistantBody){",
"const caughtUp=_renderStreamingFadeMarkdown(displayText)",
"if(_shouldUseLiveProseFade())",
"anchorProcessText=_streamFadeDomText||''",
"if(anchorProcessText) _upsertAnchorProcessProse(anchorProcessText)",
],
)
assert render_section.index("let anchorProcessText=displayText") < render_section.index("if(assistantBody){")
assert render_section.index("anchorProcessText=_streamFadeDomText||''") < render_section.index(
"_upsertAnchorProcessProse(anchorProcessText)"
)
assert render_section.index("if(assistantBody){") < render_section.rindex(
"if(anchorProcessText) _upsertAnchorProcessProse(anchorProcessText)"
)
def test_stream_fade_done_drain_has_hard_cap_for_large_buffered_responses():
drain_block = function_block(MESSAGES_JS, "_drainStreamFadeBeforeDone")
assert "const _STREAM_FADE_DONE_DRAIN_MAX_MS=1400" in MESSAGES_JS
assert_contains_all(
drain_block,
[
"const drainStartedAt=performance.now();",
"const target=_streamFadeCurrentDisplayText();",
"const caughtUp=_renderStreamingFadeMarkdown(target);",
"const anchorProcessText=_streamFadeDomText||target;",
"if(anchorProcessText) _upsertAnchorProcessProse(anchorProcessText);",
"performance.now()-drainStartedAt>=_STREAM_FADE_DONE_DRAIN_MAX_MS",
"if(_smdParser) _smdEndParser();",
"onDone();",
],
)
assert drain_block.index("_renderStreamingFadeMarkdown(target)") < drain_block.index(
"_upsertAnchorProcessProse(anchorProcessText)"
)
def test_live_streaming_assistant_content_opts_out_of_global_theme_transitions():
"""Per-token markdown rewrites must not inherit global div color/background fades.
The global theme transition is useful for dark/light switches, but live
assistant DOM updates happen for every streamed token. If those live nodes
inherit color/background transitions, light themes visibly flash/fade on
each word.
"""
live_transition_guard = slice_between(
STYLE_CSS,
"Live assistant content is updated token-by-token",
":root{--app-titlebar-safe-top",
)
assert_contains_all(
live_transition_guard,
[
"#liveAssistantTurn *",
"#thinkingRow *",
'.assistant-segment[data-live-assistant="1"] *',
'.agent-activity-thinking[data-thinking-active="1"] *',
'.agent-activity-thinking[data-live-thinking="1"] *',
'.live-worklog[data-live-worklog-shell="1"] *',
"transition-property:none!important",
"transition-duration:0s!important",
"transition-delay:0s!important",
],
)
def test_stream_fade_css_is_opacity_only_and_hides_live_cursor():
fade_css = STYLE_CSS[STYLE_CSS.index("OpenWebUI-style streaming word fade") :]
assert "filter:" not in STYLE_CSS[STYLE_CSS.index("OpenWebUI-style streaming word fade") :].split(
"[data-live-assistant", 1
)[0]
assert "translateY" not in STYLE_CSS[STYLE_CSS.index("OpenWebUI-style streaming word fade") :].split(
"[data-live-assistant", 1
)[0]
assert_contains_all(
fade_css,
[
"@keyframes stream-fade-word-in",
".stream-fade-word.is-new",
"var(--stream-fade-ms,620ms) cubic-bezier(.16,.84,.32,1)",
"35%{opacity:.18;}",
"70%{opacity:.72;}",
"prefers-reduced-motion: reduce",
".msg-body.stream-fade-active > :last-child::after",
"display:none",
"content:none",
],
)
assert ".stream-fade-active .stream-fade-word{display:inline;}" in fade_css
def test_stream_fade_reduced_motion_listener_is_cleaned_up_on_terminal_paths():
assert "_streamFadeReduceMotionOnChange" in MESSAGES_JS
assert "function _streamFadeCleanupReduceMotionListener()" in MESSAGES_JS
assert "removeEventListener('change',_streamFadeReduceMotionOnChange)" in MESSAGES_JS
assert "removeListener(_streamFadeReduceMotionOnChange)" in MESSAGES_JS
assert MESSAGES_JS.count("_streamFadeCleanupReduceMotionListener();") >= 4
def test_stream_fade_duration_scales_up_with_playback_speed():
script = (
fade_helper_script()
+ r"""
const words=Array.from({length:260},(_,i)=>'w'+i).join(' ');
performance._t += 33;
let out=_streamFadeNextText('slow start');
if(!out.changed) throw new Error('expected initial reveal');
if(_streamFadeCurrentMs !== 620) throw new Error(`expected base fade 620ms, got ${_streamFadeCurrentMs}`);
for(let frame=0;frame<20&&_streamFadeCurrentMs<900;frame++){
performance._t += 120;
out=_streamFadeNextText(words);
}
if(_streamFadeCurrentMs !== 900) throw new Error(`expected max fade 900ms, got ${_streamFadeCurrentMs}`);
"""
)
run_node(script)
def test_stream_fade_playout_handles_fast_models_without_paragraph_pops():
script = (
fade_helper_script()
+ r"""
const words=Array.from({length:240},(_,i)=>'w'+i);
let shown=0;
let targetCount=0;
for(let frame=0;frame<240;frame++){
performance._t += 16;
// Simulate sustained fast generation: ~40 words/sec arriving.
targetCount = Math.min(words.length, Math.floor(performance._t/1000*40));
const out=_streamFadeNextText(words.slice(0,targetCount).join(' '));
shown=(out.text.match(/\S+/g)||[]).length;
}
const backlog=targetCount-shown;
if(shown < 145) throw new Error(`too slow: shown=${shown} target=${targetCount} backlog=${backlog} arrivalWps=${_streamFadeArrivalWps}`);
if(backlog > 15) throw new Error(`did not catch up: shown=${shown} target=${targetCount} backlog=${backlog} arrivalWps=${_streamFadeArrivalWps}`);
const huge=Array.from({length:500},(_,i)=>'b'+i).join(' ');
let previous=0;
for(let frame=0;frame<40;frame++){
performance._t += 16;
const out=_streamFadeNextText(huge);
const shown=(out.text.match(/\S+/g)||[]).length;
const revealed=shown-previous;
previous=shown;
if(revealed>3) throw new Error(`revealed too much in one frame: ${revealed}`);
}
if(previous<50) throw new Error(`too slow under large backlog: ${previous}`);
"""
)
run_node(script)
def test_stream_fade_respects_sentence_and_paragraph_boundaries():
script = (
fade_helper_script()
+ r"""
const target='alpha beta gamma\n\nsecond paragraph starts here\n\nthird paragraph starts here';
performance._t += 200;
let out=_streamFadeNextText(target);
const breaks=(out.text.match(/\n\s*\n/g)||[]).length;
if(breaks>1) throw new Error(`revealed multiple paragraph breaks: ${JSON.stringify(out.text)}`);
_resetStreamFadeState();
const pausedTarget='alpha beta.\n\nsecond paragraph starts here';
out={text:''};
for(let frame=0;frame<8&&!out.text.includes('.');frame++){
performance._t += 33;
out=_streamFadeNextText(pausedTarget);
}
if(!out.text.includes('.')) throw new Error(`expected first sentence: ${JSON.stringify(out.text)}`);
const held=_streamFadeNextText(pausedTarget);
if(held.changed) throw new Error('expected sentence pause to hold next reveal');
performance._t += 50;
for(let frame=0;frame<8&&!out.text.includes('\n\n');frame++){
performance._t += 33;
out=_streamFadeNextText(pausedTarget);
}
if(!out.text.includes('\n\n')) throw new Error(`expected paragraph break: ${JSON.stringify(out.text)}`);
const afterBreak=_streamFadeNextText(pausedTarget);
if(afterBreak.changed) throw new Error('expected paragraph pause to hold next reveal');
"""
)
run_node(script)