diff --git a/static/messages.js b/static/messages.js index eb0ba502e..c3758371c 100644 --- a/static/messages.js +++ b/static/messages.js @@ -4261,6 +4261,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const parserFor = (data)=>{ return _smdParserKey(data, el); }; + const writeFadeText=(writeParent, writeData, writeText)=>{ + if(!writeParent||_streamFadeSkipNode(writeParent)){ + _smdAppendPlainText(writeParent, writeData, writeText, baseAddText); + return; + } + _streamFadeAppendText(writeParent, writeText); + }; renderer.add_text=(data,text)=>{ const parent=data&&data.nodes&&data.nodes[data.index]; if(!parent||_streamFadeSkipNode(parent)){baseAddText(data,text);return;} @@ -4273,7 +4280,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const value=String(text||''); const hasMediaPrefixTail=!!_smdMediaPrefixTail(value); if(/MEDIA:/.test(value)||hasMediaTail||hasMediaPrefixTail){ - _smdMediaAwareAddText(baseAddText, parent, data, text, _SMD_MEDIA_TAIL, parser); + _smdMediaAwareAddText(baseAddText, parent, data, text, _SMD_MEDIA_TAIL, parser, writeFadeText); return; } const frag=document.createDocumentFragment(); @@ -4338,8 +4345,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ // Without this, streamed prose shows MEDIA:C:\... as literal text until the // turn settles and the full re-render swaps it for the real . // SAFETY & CROSS-CHUNK SPLITS (Greptile #1 + #2): - // 1. Prose slices go back to baseAddText (which calls createTextNode), - // NOT through DOMParser — mixed prose with HTML entities / + // 1. Prose slices go back to the owning text writer (text nodes or + // fade spans), NOT through DOMParser — mixed prose with HTML entities / // malicious stays as literal text. // 2. Each MEDIA token's HTML (from _inlineMediaHtmlForRef) is handed // to DOMParser one at a time — only trusted markup is parsed. @@ -4357,57 +4364,88 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } return ''; } - function _smdMediaTailSet(tailMap, parser, chunk, parent, baseAddText, data){ - if(chunk) tailMap.set(parser, {chunk, parent, baseAddText, data}); + function _smdAppendPlainText(parent, data, text, baseAddText){ + const value=String(text||''); + if(parent&&parent.appendChild&&typeof document!=='undefined'&&document.createTextNode){ + parent.appendChild(document.createTextNode(value)); + return; + } + if(baseAddText) baseAddText(data,value); + } + function _smdMediaWriteText(parent, data, baseAddText, writeText, text){ + if(writeText){ + writeText(parent, data, String(text||'')); + return; + } + if(baseAddText) baseAddText(data,String(text||'')); + } + function _smdMediaTailSet(tailMap, parser, chunk, parent, baseAddText, data, writeText){ + if(!tailMap||!parser) return; + if(chunk) tailMap.set(parser, {chunk, parent, baseAddText, data, writeText}); else tailMap.delete(parser); } function _smdMediaTailEntryChunk(entry){ return entry && typeof entry==='object' && Object.prototype.hasOwnProperty.call(entry,'chunk') ? entry.chunk : entry; } + function _smdMediaTailSameOwner(entry, parent, baseAddText, writeText){ + return !!entry && entry.parent===parent && entry.baseAddText===baseAddText && entry.writeText===writeText; + } function _smdMediaRefHasReliableBoundary(rawRef){ const raw=String(rawRef||''); if(/[?#]$/.test(raw)) return false; const ref=raw.split(/[?#]/,1)[0]; return /\.(?:png|jpe?g|gif|webp|bmp|ico|svg|avif|mp4|webm|mov|m4v|mkv|avi|ogv|mp3|wav|ogg|m4a|aac|wma|opus|flac|oga|pdf|html?|csv|diff|patch|excalidraw)$/i.test(ref); } + function _smdMediaTailFlushEntry(entry){ + const chunk=_smdMediaTailEntryChunk(entry); + if(!chunk) return; + const m=/^MEDIA:([^\s\)\]]+)$/.exec(String(chunk)); + const emitted=!!(m && entry && entry.parent && _smdAppendMediaNode(entry.parent, m[1])); + if(!emitted && entry) _smdMediaWriteText(entry.parent, entry.data, entry.baseAddText, entry.writeText, chunk); + } function _smdMediaTailFlush(parser){ if(!_SMD_MEDIA_TAIL||!parser||!_SMD_MEDIA_TAIL.get) return; const entry=_SMD_MEDIA_TAIL.get(parser); - const chunk=_smdMediaTailEntryChunk(entry); - if(!chunk) return; + if(!entry) return; _SMD_MEDIA_TAIL.delete(parser); - const m=/^MEDIA:([^\s\)\]]+)$/.exec(String(chunk)); - const emitted=!!(m && entry && entry.parent && _smdAppendMediaNode(entry.parent, m[1])); - if(!emitted && entry && entry.baseAddText) entry.baseAddText(entry.data, chunk); + _smdMediaTailFlushEntry(entry); } - function _smdMediaAwareAddText(baseAddText, parent, data, text, tailMap, parser){ + function _smdMediaAwareAddText(baseAddText, parent, data, text, tailMap, parser, writeText){ const value=String(text||''); const tails=tailMap||(typeof _SMD_MEDIA_TAIL!=='undefined'&&_SMD_MEDIA_TAIL)||null; + const writeCurrent=(chunk)=>_smdMediaWriteText(parent, data, baseAddText, writeText, chunk); if(!value){ - if(baseAddText) baseAddText(data,''); + writeCurrent(''); return; } // Pull any pending tail from a previous (split) chunk, then clear it; // this call will either complete it, re-buffer it, or flush it as text. - const leadEntry = tails && parser && tails.get ? tails.get(parser) : null; - const lead = _smdMediaTailEntryChunk(leadEntry); - if(lead && tails && parser && tails.delete) tails.delete(parser); + let leadEntry = tails && parser && tails.get ? tails.get(parser) : null; + let lead = _smdMediaTailEntryChunk(leadEntry); + if(lead && !_smdMediaTailSameOwner(leadEntry, parent, baseAddText, writeText)){ + if(tails && parser && tails.delete) tails.delete(parser); + _smdMediaTailFlushEntry(leadEntry); + leadEntry=null; + lead=''; + }else if(lead && tails && parser && tails.delete){ + tails.delete(parser); + } const combined = lead ? lead + value : value; // Fast path: no MEDIA tokens in the (possibly combined) string. if(!/MEDIA:/.test(combined)){ const prefixTail=_smdMediaPrefixTail(combined); if(prefixTail && tails && parser && prefixTail.length < _MEDIA_TAIL_MAX){ const stable=combined.slice(0, combined.length-prefixTail.length); - if(stable && baseAddText) baseAddText(data, stable); - _smdMediaTailSet(tails, parser, prefixTail, parent, baseAddText, data); + if(stable) writeCurrent(stable); + _smdMediaTailSet(tails, parser, prefixTail, parent, baseAddText, data, writeText); return; } - if(baseAddText) baseAddText(data, combined); + writeCurrent(combined); return; } // Walk the combined string, slicing into prose + MEDIA token runs. - // Prose runs go through baseAddText (text nodes). MEDIA tokens go - // through the single-token DOMParser helper only after a delimiter or + // Prose runs go through the owning text writer. MEDIA tokens go through + // the single-token DOMParser helper only after a delimiter or // reliable filename suffix proves the ref is complete. const re=/MEDIA:([^\s\)\]]+)/g; let last=0, m; @@ -4416,19 +4454,19 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const matchEnd = m.index + m[0].length; if(m.index>last){ const slice = combined.slice(last, m.index); - if(baseAddText) baseAddText(data, slice); + writeCurrent(slice); } if(matchEnd===combined.length && !_smdMediaRefHasReliableBoundary(m[1])){ const candidate = combined.slice(m.index); if(candidate.length < _MEDIA_TAIL_MAX){ unmatchedTail = candidate; - } else if(baseAddText){ - baseAddText(data, candidate); + } else { + writeCurrent(candidate); } last = combined.length; break; } - if(!_smdAppendMediaNode(parent, m[1]) && baseAddText) baseAddText(data, m[0]); + if(!_smdAppendMediaNode(parent, m[1])) writeCurrent(m[0]); last = matchEnd; } // Tail buffer — hold trailing bytes that look like an unterminated @@ -4440,14 +4478,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const tailValue = tailMatch ? tailMatch[0] : prefixTail; if(tailValue && rest.length < _MEDIA_TAIL_MAX){ const tailStart = tailMatch ? tailMatch.index : rest.length-prefixTail.length; - if(tailStart>0 && baseAddText) baseAddText(data, rest.slice(0, tailStart)); + if(tailStart>0) writeCurrent(rest.slice(0, tailStart)); unmatchedTail = tailValue; - } else if(baseAddText){ - baseAddText(data, rest); + } else { + writeCurrent(rest); } } if(tails && parser){ - _smdMediaTailSet(tails, parser, unmatchedTail, parent, baseAddText, data); + _smdMediaTailSet(tails, parser, unmatchedTail, parent, baseAddText, data, writeText); } } // Single-token DOM splice. Only ever fed the output of @@ -4516,12 +4554,15 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const renderer=window.smd.default_renderer(el); const baseSetAttr=renderer.set_attr; const baseAddText=renderer.add_text; + const writePlainText=(writeParent, writeData, writeText)=>{ + _smdAppendPlainText(writeParent, writeData, writeText, baseAddText); + }; const parserFor = (data)=>{ return _smdParserKey(data, el); }; renderer.add_text=(data,text)=>{ const parent=data&&data.nodes&&data.nodes[data.index]; - _smdMediaAwareAddText(baseAddText, parent, data, text, _SMD_MEDIA_TAIL, parserFor(data)); + _smdMediaAwareAddText(baseAddText, parent, data, text, _SMD_MEDIA_TAIL, parserFor(data), writePlainText); }; renderer.set_attr=(data,attr,value)=>{ const isHref=window.smd&&attr===window.smd.HREF; diff --git a/tests/test_smd_media_in_stream.py b/tests/test_smd_media_in_stream.py index 43fd06e81..a58f8b675 100644 --- a/tests/test_smd_media_in_stream.py +++ b/tests/test_smd_media_in_stream.py @@ -32,6 +32,7 @@ from __future__ import annotations import json import pathlib +import re import shutil import subprocess import unittest @@ -43,8 +44,11 @@ NODE = shutil.which("node") def _extract_js_function(src: str, name: str) -> str: - start = src.index(f"function {name}") - brace = src.index("{", start) + match = re.search(rf"function\s+{re.escape(name)}\s*\(", src) + if not match: + raise ValueError(f"Could not find JS function {name}") + start = match.start() + brace = src.index("{", match.end()) depth = 1 pos = brace + 1 while pos < len(src) and depth: @@ -60,9 +64,13 @@ def _run_real_smd_media_cases() -> dict: helpers = "\n".join( [ _extract_js_function(MESSAGES_JS, "_smdMediaPrefixTail"), + _extract_js_function(MESSAGES_JS, "_smdAppendPlainText"), + _extract_js_function(MESSAGES_JS, "_smdMediaWriteText"), _extract_js_function(MESSAGES_JS, "_smdMediaTailSet"), _extract_js_function(MESSAGES_JS, "_smdMediaTailEntryChunk"), + _extract_js_function(MESSAGES_JS, "_smdMediaTailSameOwner"), _extract_js_function(MESSAGES_JS, "_smdMediaRefHasReliableBoundary"), + _extract_js_function(MESSAGES_JS, "_smdMediaTailFlushEntry"), _extract_js_function(MESSAGES_JS, "_smdMediaTailFlush"), _extract_js_function(MESSAGES_JS, "_smdMediaAwareAddText"), _extract_js_function(MESSAGES_JS, "_smdAppendMediaNode"), @@ -73,6 +81,7 @@ def _run_real_smd_media_cases() -> dict: _extract_js_function(MESSAGES_JS, "_streamFadeSkipNode"), _extract_js_function(MESSAGES_JS, "_streamFadeReduceMotionEnabled"), _extract_js_function(MESSAGES_JS, "_streamFadeBindCleanup"), + _extract_js_function(MESSAGES_JS, "_streamFadeAppendText"), _extract_js_function(MESSAGES_JS, "_streamFadeRenderer"), _extract_js_function(MESSAGES_JS, "_safeSmdRenderer"), _extract_js_function(MESSAGES_JS, "_smdRendererWithoutUnderscoreEmphasis"), @@ -113,6 +122,7 @@ def _run_real_smd_media_cases() -> dict: " }\n" " get childNodes(){ return this.children; }\n" " get firstChild(){ return this.children[0] || null; }\n" + " get parentElement(){ return this.parentNode; }\n" " get className(){ return this.attributes.class || ''; }\n" " set className(value){ this.attributes.class=String(value); }\n" " appendChild(child){\n" @@ -131,6 +141,12 @@ def _run_real_smd_media_cases() -> dict: " visit(this); return out;\n" " }\n" " get textContent(){ return this.nodeType===3 ? this.data : this.children.map(c=>c.textContent).join(''); }\n" + " set textContent(value){\n" + " if(this.nodeType===3){ this.data=String(value); return; }\n" + " this.children=[];\n" + " const text=String(value);\n" + " if(text) this.appendChild(document.createTextNode(text));\n" + " }\n" " get outerHTML(){\n" " if(this.nodeType===3) return esc(this.data);\n" " if(this.nodeType===11) return this.children.map(c=>c.outerHTML).join('');\n" @@ -154,6 +170,12 @@ def _run_real_smd_media_cases() -> dict: " return { body: { firstChild: host } };\n" "} };\n" f"{helpers}\n" + "function collectTagTexts(root, tag){\n" + " const out=[]; const wanted=String(tag).toLowerCase();\n" + " const visit=node=>{ for(const child of node.children){ if(String(child.tagName||'').toLowerCase()===wanted) out.push(child.textContent); visit(child); } };\n" + " visit(root); return out;\n" + "}\n" + "function collectClassTexts(root, cls){ return root.querySelectorAll('.'+cls).map(node=>node.textContent); }\n" "function renderChunks(chunks, mode){\n" " postProcessCalls = 0; playbackCalls = 0;\n" " const root=document.createElement('div');\n" @@ -165,7 +187,7 @@ def _run_real_smd_media_cases() -> dict: " smd.parser_end(parser);\n" " _smdMediaTailFlush(parser);\n" " _smdMediaTailClear(parser);\n" - " return { html: root.outerHTML, text: root.textContent, postProcessCalls, playbackCalls };\n" + " return { html: root.outerHTML, text: root.textContent, liTexts: collectTagTexts(root, 'li'), fadeWords: collectClassTexts(root, 'stream-fade-word'), postProcessCalls, playbackCalls };\n" "}\n" "function renderModes(chunks){ return { safe: renderChunks(chunks, 'safe'), fade: renderChunks(chunks, 'fade') }; }\n" "const marker='MEDIA:';\n" @@ -174,7 +196,9 @@ def _run_real_smd_media_cases() -> dict: "const refSplit=renderModes(['MEDIA:C:/tmp/li', 've.png ']);\n" "const finalExtensionless=renderModes(['MEDIA:https://fal.media/generated']);\n" "const pdf=renderModes(['MEDIA:C:/tmp/report.pdf ']);\n" - "console.log(JSON.stringify({prefixSplits, refSplit, finalExtensionless, pdf}));\n" + "const falsePrefix=renderModes(['M', 'aybe plain prose ']);\n" + "const crossParent=renderModes(['- ME', '\\n- ow']);\n" + "console.log(JSON.stringify({prefixSplits, refSplit, finalExtensionless, pdf, falsePrefix, crossParent}));\n" ) completed = subprocess.run( [NODE, "--input-type=module", "-e", script], @@ -272,16 +296,14 @@ class TestSmdMediaInStream(unittest.TestCase): def test_media_interceptor_falls_back_to_base_when_no_token(self): # Fast path: when the chunk + buffered tail carries no MEDIA token, - # the wrapper should delegate to the underlying add_text unchanged - # so the fade renderer's word-by-word animation is preserved for - # plain prose. + # the wrapper should delegate to the injected text writer so the + # owning renderer's semantics (plain text for safe mode, word fade for + # fade mode) survive for plain prose. idx = MESSAGES_JS.index("function _smdMediaAwareAddText") block = MESSAGES_JS[idx:idx + 6500] - self.assertTrue( - "baseAddText(data,value)" in block or "baseAddText(data," in block, - "Interceptor must delegate to baseAddText for plain text so the " - "underlying renderer's semantics survive", - ) + self.assertIn("const writeCurrent=", block) + self.assertIn("writeCurrent(combined)", block) + self.assertIn("function _smdMediaWriteText", MESSAGES_JS) def test_no_recursive_infinite_loop_via_baseAddText(self): # Regression guard: the fade renderer's add_text is itself a wrapper. @@ -418,6 +440,29 @@ class TestSmdMediaInStream(unittest.TestCase): "concurrent streams don't cross-pollinate", ) + def test_tail_entries_preserve_original_text_owner(self): + # A buffered MEDIA-looking suffix belongs to the parent/writer that + # produced it. If the next smd add_text callback is for a different + # parent, the scanner must flush through the original owner instead + # of concatenating across DOM nodes. + idx = MESSAGES_JS.index("function _smdMediaTailSet") + block = MESSAGES_JS[idx:idx + 3500] + self.assertIn("writeText", block) + self.assertIn("function _smdMediaTailSameOwner", MESSAGES_JS) + self.assertIn("entry.parent===parent", MESSAGES_JS) + self.assertIn("entry.writeText===writeText", MESSAGES_JS) + self.assertIn("_smdMediaTailFlushEntry(leadEntry)", MESSAGES_JS) + + def test_stream_fade_media_scanner_preserves_plain_prose_fade(self): + # False MEDIA-prefix tails (for example "M" + "aybe") still enter + # the scanner. Those plain prose writes must use the non-recursive + # fade appender, not the raw default_renderer text writer. + idx = MESSAGES_JS.index("function _streamFadeRenderer") + block = MESSAGES_JS[idx:idx + 7000] + self.assertIn("const writeFadeText=", block) + self.assertIn("_streamFadeAppendText(writeParent, writeText)", block) + self.assertIn("_smdMediaAwareAddText(baseAddText, parent, data, text, _SMD_MEDIA_TAIL, parser, writeFadeText)", block) + def test_smd_parser_identity_is_bound_to_real_parser(self): # smd's renderer.data does not expose a parser by default. Bind the # created parser onto both renderer.data and the owning element so every @@ -514,6 +559,20 @@ class TestSmdMediaRealParserBehaviour(unittest.TestCase): self.assertGreaterEqual(result["postProcessCalls"], 1) self.assertGreaterEqual(result["playbackCalls"], 1) + def test_real_smd_parser_false_prefix_plain_prose_keeps_fade(self): + result = self.cases["falsePrefix"]["fade"] + self.assertEqual(result["text"], "Maybe plain prose ") + self.assertEqual(result["fadeWords"], ["Maybe", "plain", "prose"]) + self.assertIn('class="stream-fade-word is-new"', result["html"]) + + def test_real_smd_parser_refuses_cross_parent_tail_concat(self): + for mode, result in self.cases["crossParent"].items(): + with self.subTest(mode=mode): + self.assertEqual(result["liTexts"], ["ME", "ow"]) + if mode == "fade": + self.assertTrue(result["fadeWords"]) + self.assertEqual("".join(result["fadeWords"]), "MEow") + if __name__ == "__main__": import unittest