diff --git a/static/messages.js b/static/messages.js index 8142a714..02bfde2d 100644 --- a/static/messages.js +++ b/static/messages.js @@ -2601,6 +2601,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ let _streamFadeHoldUntilMs=0; let _streamFadeCurrentMs=620; let _streamFadeDomText=''; + let _streamFadeSilentPrefixChars=0; let _streamFadeReduceMotionMql=null; let _streamFadeReduceMotion=false; let _streamFadeReduceMotionOnChange=null; @@ -3877,11 +3878,26 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const value=String(text||''); const fade=typeof _shouldUseLiveProseFade==='function'&&_shouldUseLiveProseFade(); let st; + let _rewindPrevRendered=''; try{ st=_anchorProseSmdCache.get(key); // Self-heal desyncs (edit/sanitize made the text no longer a pure append): // rebuild the parser+node from scratch, mirroring the _smdWrite guard. - if(st && st.writtenText && !value.startsWith(st.writtenText)) st=null; + // Fade-flash guard: when the text REWINDS (tool-call XML stripped from the + // live prose), the rebuilt node would re-create every word as a new + // is-new span and replay the fade on ALL visible words at once. Mute the + // fade renderer for the common prefix so only the post-rewind tail fades. + if(st && st.writtenText && !value.startsWith(st.writtenText)){ + // Snapshot the OLD rendered text BEFORE clearing the node. The silent + // prefix is later recomputed in RENDERED-text space (old node text vs + // new node text) — source-space byte counts are wrong here because + // markdown delimiters, link destinations and MEDIA tokens never reach + // the fade add_text hook, so a source-space budget over-mutes the + // first genuinely new word after a rewind (#6783 review). + const oldBody=st.node&&st.node.querySelector&&st.node.querySelector('.msg-body'); + _rewindPrevRendered=oldBody?(oldBody.textContent||''):''; + st=null; + } if(st && st.fade!==fade) st=null; if(st && st.finalized && st.writtenText!==value){ const body=st.node&&st.node.querySelector&&st.node.querySelector('.msg-body'); @@ -3917,6 +3933,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ window.smd.parser_write(st.parser,delta); st.writtenText=value; } + // Rewind rebuild: mute the rendered common prefix (old node text vs new + // node text) so already-visible words do not replay their fade; only the + // post-rewind tail animates. Rendered-space compare, not source-space + // (#6783 review — markdown/MEDIA bytes never reach add_text). + if(_rewindPrevRendered && typeof _streamFadeMuteRenderedPrefix==='function'){ + _streamFadeMuteRenderedPrefix(body,_rewindPrevRendered); + _rewindPrevRendered=''; + } if(finalize){ _finalizeAnchorProseIncrementalNode(st); } @@ -4335,10 +4359,24 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ function _smdWrite(displayText, fade=false){ if(!_smdParser||!window.smd) return; displayText=String(displayText||''); - // Self-heal desyncs: if displayText no longer starts with what we've already - // written (e.g. due to stream sanitization/tag stripping), incremental slicing + let _rewindPrevRendered=''; + // Self-heal desyncs: if displayText no longer starts with what we have + // already written (e.g. due to stream sanitization/tag stripping), incremental slicing // can skip characters. Rebuild parser from the full current displayText. if(_smdWrittenText && !displayText.startsWith(_smdWrittenText)){ + // Fade-flash fix: when the visible text REWINDS (tool-call XML stripping + // makes displayText a strict prefix of what was already written), the + // rebuild below would clear the body and re-create every word as a new + // `is-new` span — replaying the fade animation on ALL visible text at + // once (a full-message blink on every tool call). Instead, snapshot the + // OLD RENDERED text and mute the rebuild prefix spans AFTER the + // parser_write, in RENDERED-text space: source-space byte counts include + // markdown delimiters / link destinations / MEDIA token bytes that never + // reach the fade add_text hook, so a source-space budget over-mutes the + // first genuinely new word after a rewind (#6783 review). + if(assistantBody && typeof assistantBody.textContent==='string'){ + _rewindPrevRendered=assistantBody.textContent; + } _smdParser=null; _smdWrittenLen=0; _smdWrittenText=''; @@ -4351,6 +4389,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ try{window.smd.parser_write(_smdParser,delta);}catch(_){} _smdWrittenLen=displayText.length; _smdWrittenText=displayText; + // Rebuild after a rewind: strip is-new from spans covered by the + // RENDERED common prefix (old node text vs new node text), so already- + // visible words stay plain while only the post-rewind tail fades. + if(_rewindPrevRendered && typeof _streamFadeMuteRenderedPrefix==='function'){ + _streamFadeMuteRenderedPrefix(assistantBody,_rewindPrevRendered); + } // URL scheme safety is handled by the renderer's set_attr hook // (_safeSmdRenderer or _streamFadeRenderer), applied inline as smd // creates each DOM node — no post-hoc full-DOM scan needed. @@ -4427,6 +4471,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ _streamFadeHoldUntilMs=0; _streamFadeCurrentMs=_STREAM_FADE_MS; _streamFadeDomText=''; + _streamFadeSilentPrefixChars=0; } function _cancelAnimationFramePendingStreamRender(){ if(_pendingRafHandle===null) return; @@ -4511,6 +4556,11 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const reduceMotion=_streamFadeReduceMotionEnabled(); const appendStartedAt=performance.now(); let last=0, match, changed=false; + // Silent-prefix window: after a rebuild caused by a REWIND (tool-call + // XML stripping), words that were already visible before the rewind + // point must NOT replay their fade animation. They are appended as + // plain text; only the tail beyond _streamFadeSilentPrefixChars fades. + let silentLeft=_streamFadeSilentPrefixChars||0; while((match=wordRe.exec(value))){ if(match.index>last) frag.appendChild(document.createTextNode(value.slice(last,match.index))); if(reduceMotion){ @@ -4520,6 +4570,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ changed=true; continue; } + if(silentLeft>0){ + frag.appendChild(document.createTextNode(match[1])); + if(match[2]) frag.appendChild(document.createTextNode(match[2])); + silentLeft-=match[0].length; + last=match.index+match[0].length; + changed=true; + continue; + } const span=document.createElement('span'); span.className='stream-fade-word is-new'; const fadeMs=_streamFadeCurrentMs||_STREAM_FADE_MS; @@ -4531,6 +4589,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ last=match.index+match[0].length; changed=true; } + if(silentLeft>0) _streamFadeSilentPrefixChars=silentLeft; + else _streamFadeSilentPrefixChars=0; if(!changed){baseAddText(data,text);return;} if(lastlast) frag.appendChild(document.createTextNode(value.slice(last,match.index))); if(reduceMotion){ frag.appendChild(document.createTextNode(match[1])); + }else if(silentLeft>0){ + frag.appendChild(document.createTextNode(match[1])); + silentLeft-=match[0].length; }else{ const span=document.createElement('span'); span.className='stream-fade-word is-new'; @@ -4838,6 +4905,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ last=match.index+match[0].length; changed=true; } + if(silentLeft>0) _streamFadeSilentPrefixChars=silentLeft; + else _streamFadeSilentPrefixChars=0; if(!changed){ frag.appendChild(document.createTextNode(value)); }else if(last{ + if(!node||consumed>_common) return; + const isText=node.nodeType===3||node.type==='text'; + if(isText){ + const len=(node.textContent||'').length; + const start=consumed; + consumed+=len; + // Text node inside a fade span that starts before the common-prefix + // boundary → mute the span (drop is-new, keeping the word visible + // without replaying its animation). + if(start<_common){ + const parent=node.parentNode; + if(parent&&/\bstream-fade-word\b/.test(parent.className||'')&&/\bis-new\b/.test(parent.className||'')){ + if(parent.classList&&typeof parent.classList.remove==='function'){ + parent.classList.remove('is-new'); + }else{ + parent.className=String(parent.className||'').replace(/\bis-new\b/g,'').replace(/\s{2,}/g,' ').trim(); + } + } + } + return; + } + const kids=node.childNodes||node.children; + if(kids){ for(let i=0;i=0) return 90; const trimmed=String(text||'').trimEnd(); @@ -4861,8 +4973,26 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ return {text:'', caughtUp:true, changed:hadVisible}; } if(!_streamFadeVisibleText||!targetText.startsWith(_streamFadeVisibleText)){ - // Markdown/tool stripping can rewrite the visible prefix. Reset safely rather than - // trying to animate across incompatible strings or stale word birth timestamps. + // Markdown/tool stripping can rewrite the visible prefix. Shrink the + // playout cursor to the common prefix instead of resetting to zero — + // a full reset would replay the fade animation on every already-visible + // word whenever the display text briefly rewinds (e.g. tool-call XML + // being stripped mid-stream). Only when nothing overlaps does the playout + // need a true from-scratch start. + let _commonLen=0; + const _maxCommon=Math.min(_streamFadeVisibleText.length,targetText.length); + while(_commonLen<_maxCommon&&_streamFadeVisibleText.charCodeAt(_commonLen)===targetText.charCodeAt(_commonLen)) _commonLen+=1; + if(_commonLen>0){ + _streamFadeVisibleText=targetText.slice(0,_commonLen); + _streamFadeVisibleWords=_streamFadeWordCountOf(_streamFadeVisibleText); + _streamFadeWordCarry=0; + _streamFadeLastTickMs=0; + _streamFadeStartedAt=0; + // changed:true forces the DOM to sync to the shrunken prefix this + // frame (dropping the rewind tail). The rebuild mutes the common + // prefix so no fade animation is replayed. + return {text:_streamFadeVisibleText,caughtUp:_streamFadeVisibleText===targetText,changed:true}; + } _resetStreamFadeState(); } if(!_streamFadeLastTickMs){ diff --git a/tests/test_smd_media_in_stream.py b/tests/test_smd_media_in_stream.py index a58f8b67..d123ef12 100644 --- a/tests/test_smd_media_in_stream.py +++ b/tests/test_smd_media_in_stream.py @@ -100,6 +100,7 @@ def _run_real_smd_media_cases() -> dict: "const _STREAM_FADE_MS = 620;\n" "let _streamFadeCurrentMs = _STREAM_FADE_MS;\n" "let _streamFadeLatestAnimationEndAt = 0;\n" + "let _streamFadeSilentPrefixChars = 0;\n" "let _streamFadeReduceMotionMql = null;\n" "let _streamFadeReduceMotion = false;\n" "let _streamFadeReduceMotionOnChange = null;\n" diff --git a/tests/test_smooth_text_fade.py b/tests/test_smooth_text_fade.py index 6950936b..25a01231 100644 --- a/tests/test_smooth_text_fade.py +++ b/tests/test_smooth_text_fade.py @@ -1,5 +1,6 @@ import re import subprocess +import tempfile from pathlib import Path REPO = Path(__file__).resolve().parents[1] @@ -86,6 +87,7 @@ let _streamFadeVisibleWords=0; let _streamFadeHoldUntilMs=0; let _streamFadeCurrentMs=620; let _streamFadeDomText=''; +let _streamFadeSilentPrefixChars=0; const _STREAM_FADE_MS=620; const _STREAM_FADE_MAX_MS=900; const _STREAM_FADE_DONE_MAX_MS=1000; @@ -96,6 +98,25 @@ const performance={performance_stub}; def run_node(script: str) -> subprocess.CompletedProcess[str]: + # Windows cmdline length limit (~32K): long extracted function blocks + # (e.g. _smdWrite with its comment block) overflow `node -e`. Write long + # scripts to a temp file and run `node ` instead. + if len(script) > 24000: + with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False, encoding="utf-8") as f: + f.write(script) + tmp_path = f.name + try: + result = subprocess.run( + ["node", tmp_path], + cwd=REPO, + text=True, + capture_output=True, + check=False, + ) + finally: + Path(tmp_path).unlink(missing_ok=True) + assert result.returncode == 0, result.stderr + return result result = subprocess.run( ["node", "-e", script], cwd=REPO, @@ -218,6 +239,7 @@ def test_stream_fade_appends_new_spans_without_replacing_existing_nodes(): const _STREAM_FADE_MS=620; let _streamFadeLatestAnimationEndAt=0; let _streamFadeCurrentMs=620; +let _streamFadeSilentPrefixChars=0; const performance={_t:0,now(){return this._t;}}; function _streamFadeReduceMotionEnabled(){ return false; } class FakeNode{ @@ -557,3 +579,257 @@ if(afterBreak.changed) throw new Error('expected paragraph pause to hold next re """ ) run_node(script) + + +def test_stream_fade_rewind_keeps_common_prefix_visible(): + """A display-text REWIND (tool-call XML stripped mid-stream) must not reset + the playout to zero — that would replay the fade on every already-visible + word and produce a full-message blink per tool call (#fade-flash).""" + script = ( + fade_helper_script() + + r""" +// Phase 1: play out a sentence normally. +let out=_streamFadeNextText('alpha beta gamma delta'); +for(let frame=0;frame<60&&!out.caughtUp;frame++){ + performance._t += 33; + out=_streamFadeNextText('alpha beta gamma delta'); +} +if(!out.caughtUp) throw new Error(`never caught up: ${JSON.stringify(out.text)}`); +const fullText=out.text; +// Phase 2: the stream text REWINDS (e.g. stripped): the +// target becomes a strict prefix of what was already shown. +performance._t += 33; +out=_streamFadeNextText('alpha beta'); +// The playout must stay at the common prefix, NOT reset to ''. +if(!out.text.startsWith('alpha beta')) throw new Error(`rewind dropped prefix: ${JSON.stringify(out.text)}`); +if(out.text.length>fullText.length) throw new Error(`rewind grew text: ${JSON.stringify(out.text)}`); +if(!out.caughtUp) throw new Error(`rewind to prefix should be caught up, got changed=${out.changed}`); +""" + ) + run_node(script) + + +def test_stream_fade_rewind_remount_mutes_common_prefix(): + """When the DOM must be rebuilt after a rewind (smd self-heal), words inside + the common prefix must be written as plain text so their fade animation is + not replayed; only the post-rewind tail may animate.""" + script = ( + function_block(MESSAGES_JS, "_streamFadeAppendText") + + r""" +const _STREAM_FADE_MS=620; +let _streamFadeLatestAnimationEndAt=0; +let _streamFadeCurrentMs=620; +let _streamFadeSilentPrefixChars=0; +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'); +// Simulate a rebuild: the rewind-triggered self-heal mutes the common prefix +// ("alpha beta" = 10 chars) so those words must NOT become animated spans. +_streamFadeSilentPrefixChars=10; +_streamFadeAppendText(body,'alpha beta gamma'); +const spans=body.children.filter(node=>node.className==='stream-fade-word is-new'); +if(spans.length!==1) throw new Error(`expected exactly 1 animated span, got ${spans.length}`); +if(spans[0].textContent!=='gamma') throw new Error(`wrong animated word: ${spans[0].textContent}`); +const plain=body.children.filter(node=>node.type==='text').map(n=>n.textContent).join(''); +if(!plain.includes('alpha beta')) throw new Error(`common prefix not written as plain text: ${plain}`); +if(_streamFadeSilentPrefixChars!==0) throw new Error(`silent prefix not consumed: ${_streamFadeSilentPrefixChars}`); +""" + ) + run_node(script) + + +def test_stream_fade_smd_write_self_heal_sets_silent_prefix_on_rewind(): + """_smdWrite's self-heal rebuild (triggered when the display text REWINDS, + e.g. tool-call XML stripped mid-stream) must mute the rebuild's rendered + common prefix. Without this the cleared + rebuilt body would replay the + fade on every already-visible word — the full-message blink. The prefix is + computed in RENDERED-text space (old node text vs new node text), never in + source space (#6783 review).""" + write_block = function_block(MESSAGES_JS, "_smdWrite") + script = ( + write_block + + r""" +let _smdParser=null; +let _smdWrittenLen=0; +let _smdWrittenText=''; +let _streamFadeSilentPrefixChars=0; +let rebuildCalls=0; +let muteCalls=[]; +const writes=[]; +global.window={ + smd:{ + parser(renderer){ return { renderer }; }, + parser_write(parser,delta){ writes.push(String(delta)); }, + }, +}; +function _streamFadeMuteRenderedPrefix(el, prev){ muteCalls.push(prev); } +const assistantBody={ innerHTML:'', textContent:'' }; +function _smdNewParser(el, fade){ _smdParser={fresh:fade?true:false}; rebuildCalls+=1; } +function _scheduleStreamingKatex(){} +// Phase 0: parser already attached (as after _smdNewParser on stream start). +_smdParser={}; +// Phase 1: normal incremental write of the full text. +_smdWrite('alpha beta gamma', true); +if(_smdWrittenText!=='alpha beta gamma') throw new Error(`phase1 writtenText wrong: ${_smdWrittenText}`); +if(rebuildCalls!==0) throw new Error('phase1 must not rebuild'); +// Phase 2: the display text REWINDS to a strict prefix (tool-call XML tail +// stripped). Simulate the rendered body state (what smd already painted) +// before the rebuild; the self-heal must snapshot it and hand it to +// _streamFadeMuteRenderedPrefix (rendered-space mute). +assistantBody.textContent='alpha beta gamma'; +_smdWrite('alpha beta', true); +if(rebuildCalls!==1) throw new Error(`expected 1 rebuild, got ${rebuildCalls}`); +if(muteCalls.length!==1) throw new Error(`expected 1 mute call, got ${muteCalls.length}`); +if(muteCalls[0]!=='alpha beta gamma') throw new Error(`mute prev wrong: ${JSON.stringify(muteCalls[0])}`); +if(_smdWrittenText!=='alpha beta') throw new Error(`phase2 writtenText wrong: ${_smdWrittenText}`); +// Phase 3: after the tool call completes, new text continues past the rewind +// point; the written delta must start exactly at the rewind point (no replay +// of the prefix). No rewind now → no mute call. +writes.length=0; +muteCalls.length=0; +_smdWrite('alpha beta new tail', true); +if(writes.length!==1||writes[0]!==' new tail') throw new Error(`delta wrong: ${JSON.stringify(writes)}`); +if(muteCalls.length!==0) throw new Error(`phase3 must not mute, got ${muteCalls.length}`); +""" + ) + run_node(script) + + +def _fade_fake_node(tag='div', text=''): + """Fake DOM node with dynamic textContent (sums descendant text), parentNode + backlinks and classList — enough for _streamFadeMuteRenderedPrefix.""" + return """ +class FakeNode{ + constructor(tag='div',text=''){ + this.tagName=String(tag).toUpperCase(); + this.nodeType=(tag==='#text')?3:1; + this.type=(tag==='#text')?'text':undefined; + this.children=[]; + this.parentNode=null; + this.className=''; + this._text=text; + if(text!=='' && tag!=='#text'){ this.appendChild(new FakeNode('#text',text)); } + } + appendChild(child){ child.parentNode=this; this.children.push(child); return child; } + get childNodes(){ return this.children; } + get textContent(){ + if(this.nodeType===3) return this._text; + let s=''; + for(const c of this.children) s+=c.textContent; + return s; + } + set textContent(v){ this._text=String(v); } + get classList(){ + const self=this; + return { + contains(c){ return (' '+self.className+' ').indexOf(' '+c+' ')>-1; }, + remove(c){ self.className=(' '+self.className+' ').replace(' '+c+' ',' ').trim(); }, + }; + } +} +""" + + +def test_stream_fade_mute_rendered_prefix_plain_words(): + """Rendered-space mute: only spans fully inside the rendered common prefix + lose is-new; the first genuinely new word keeps its fade span. Old text + 'alpha beta' vs new 'alpha beta gamma' → alpha+beta plain, gamma animated.""" + script = ( + function_block(MESSAGES_JS, "_streamFadeMuteRenderedPrefix") + + _fade_fake_node() + + r""" +const body=new FakeNode('div'); +const a=new FakeNode('span','alpha'); a.className='stream-fade-word is-new'; +const sp1=new FakeNode('#text',' '); +const b=new FakeNode('span','beta'); b.className='stream-fade-word is-new'; +const sp2=new FakeNode('#text',' '); +const g=new FakeNode('span','gamma'); g.className='stream-fade-word is-new'; +body.appendChild(a); body.appendChild(sp1); body.appendChild(b); +body.appendChild(sp2); body.appendChild(g); +// Rebuild scenario: old rendered text was 'alpha beta' (rewind point), new +// rendered text is 'alpha beta gamma'. Common prefix = 'alpha beta ' (11). +_streamFadeMuteRenderedPrefix(body,'alpha beta'); +if(a.classList.contains('is-new')) throw new Error('alpha must be muted'); +if(b.classList.contains('is-new')) throw new Error('beta must be muted'); +if(!g.classList.contains('is-new')) throw new Error('gamma must stay animated'); +""" + ) + run_node(script) + + +def test_stream_fade_mute_rendered_prefix_markdown_and_media_bytes(): + """Regression for the #6783 blocker: source-space budgets over-mute because + markdown delimiters (`**alpha**`) and MEDIA tokens add bytes that never + reach the fade add_text hook. The mute must operate on RENDERED text only: + old rendered 'alpha beta' (from `**alpha** beta`) rewritten to `**alpha** + gamma` renders 'alpha gamma' → alpha muted, gamma animated.""" + script = ( + function_block(MESSAGES_JS, "_streamFadeMuteRenderedPrefix") + + _fade_fake_node() + + r""" +const body=new FakeNode('div'); +const a=new FakeNode('span','alpha'); a.className='stream-fade-word is-new'; +const sp=new FakeNode('#text',' '); +const g=new FakeNode('span','gamma'); g.className='stream-fade-word is-new'; +body.appendChild(a); body.appendChild(sp); body.appendChild(g); +// Rendered common prefix 'alpha ' = 6 chars. A SOURCE-space compare of +// `**alpha** beta` vs `**alpha** gamma` would count 10 (4 delimiters + +// 'alpha' + space) and mute gamma too — exactly the bug this test locks in. +_streamFadeMuteRenderedPrefix(body,'alpha beta'); +if(a.classList.contains('is-new')) throw new Error('alpha must be muted'); +if(!g.classList.contains('is-new')) throw new Error('gamma must stay animated (source-space budget bug)'); +""" + ) + run_node(script) + + +def test_stream_fade_mute_rendered_prefix_scoped_per_root(): + """The mute is scoped to the passed root: muting one parser's node must not + touch another parser's node (main vs anchor prose). Two concurrent roots + with identical text content stay independent.""" + script = ( + function_block(MESSAGES_JS, "_streamFadeMuteRenderedPrefix") + + _fade_fake_node() + + r""" +function buildBody(){ + const body=new FakeNode('div'); + const w1=new FakeNode('span','alpha'); w1.className='stream-fade-word is-new'; + const sp=new FakeNode('#text',' '); + const w2=new FakeNode('span','beta'); w2.className='stream-fade-word is-new'; + body.appendChild(w1); body.appendChild(sp); body.appendChild(w2); + return body; +} +const main=buildBody(); +const anchor=buildBody(); +_streamFadeMuteRenderedPrefix(main,'alpha'); // common prefix 'alpha' (5) +// main: alpha muted, beta animated. +if(main.children[0].classList.contains('is-new')) throw new Error('main alpha must be muted'); +if(!main.children[2].classList.contains('is-new')) throw new Error('main beta must stay animated'); +// anchor: untouched. +if(!anchor.children[0].classList.contains('is-new')) throw new Error('anchor must be untouched'); +if(!anchor.children[2].classList.contains('is-new')) throw new Error('anchor must be untouched'); +""" + ) + run_node(script)