Merge pull request #7473 from nesquena/stage/7082

Release exp-v0.52.278: stop tearing live progress words (#7082, @ruizanthony)
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-09-08 14:53:01 -07:00
commit 7d56234ea4
5 files changed
+1094 -6

No files matched your search

+2
View File
@@ -5,6 +5,8 @@
### Fixed
- **Live progress text in Transparent Stream no longer tears in the middle of a word.** During a streaming turn, a live progress row could split a word across a block boundary and render the tail on its own line (e.g. `Ces deu` / `x fichiers passent.`). The streamed text was always intact — it was purely a client-side render artifact in the live fade/reconcile path. The renderer now adopts the parser-owned DOM once after a parser-owner replacement (keeping the per-token fast path for normal ticks) and handles the source-space cursor at block boundaries, so live prose stays whole with no lost, duplicated, or reordered text. Verified with the crown-jewel stream gate (no flicker, no alternating, no prose reversals across transparent/worklog/hidden modes). Thanks @ruizanthony. (#7082)
- **A slash-style model ID picked under a session provider that differs from the profile default now routes to the right provider instead of 404ing.** When you selected a model whose ID contains a `/` (e.g. a Nous portal row `upstage/solar-pro4:free`) while the session's provider differed from the profile default, `model_with_provider_context()` dropped the provider hint, so the request went to the *default* provider's base URL (e.g. `api.x.ai` under an xAI-OAuth default) and 404'd. The resolver now emits an explicit `@provider:model` hint when the session provider is a known static/portal provider or a `custom:<slug>` that resolves to a unique `custom_providers[]` entry, while still keeping the bare ID for unknown/ambiguous slugs so existing custom/proxy base-URL routing stays in charge. Ambiguous custom slugs fail closed to a persisted error rather than crashing the worker. Thanks @Manny7717. (#7356, closes #7333)
- **Editing and resubmitting a message can no longer truncate most of a long session's history if you click "Send edit" more than once.** `submitEdit` was re-entrant: its only guard was `S.busy`, which the send path doesn't set until after two multi-second awaits (`_ensureAllMessagesLoaded` on a long session, then the truncate round-trip). On a laggy instance that left a 10s+ window in which a second click started another full truncate — and because the first call sets `_oldestIdx = 0`, the second call recomputed `keep_count` from a still-window-relative index, producing a far smaller keep count that could delete most of a 2000-message session. A `_submitEditInFlight` guard is now acquired synchronously before the awaits and released in a `finally` (so an early return or throw can't wedge editing off), while the absolute keep count is still captured up front so a single legitimate submit is unaffected. The Enter-to-submit path clicks the same button, so it's covered too. Thanks @alanjds. (#7277)
+7
View File
@@ -5083,6 +5083,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
};
_walk(rootEl);
}
// Exposed for the transparent-stream fade prose reconciler in ui.js
// (same pattern as __anchorProseIncrementalNode above): the no-cursor
// rebuild branch of _refreshTransparentFadeProseRow snapshots the rendered
// text before clearing and re-applies this mute so only genuinely-new tail
// words animate (#7082 review). The helper is stateless, so unlike
// __anchorProseIncrementalNode it never needs to be cleared per-stream.
if(typeof window!=='undefined') window.__streamFadeMuteRenderedPrefix=_streamFadeMuteRenderedPrefix;
function _streamFadePauseAfter(text, paragraphBreakIndex){
if(paragraphBreakIndex>=0) return 90;
const trimmed=String(text||'').trimEnd();
+97 -6
View File
@@ -13865,6 +13865,10 @@ function _refreshTransparentThinkingLiveRow(existing, node){
return true;
}
const _TRANSPARENT_FADE_BLOCK_TAGS = new Set([
'P','DIV','H1','H2','H3','H4','H5','H6','BLOCKQUOTE','LI','UL','OL','PRE','TABLE',
]);
function _bindTransparentFadeCleanup(body){
if(!body || body._transparentFadeCleanupBound || typeof body.addEventListener !== 'function') return;
body._transparentFadeCleanupBound = true;
@@ -13881,6 +13885,20 @@ function _bindTransparentFadeCleanup(body){
});
}
// Trailing block element of a live prose body, if any. Live rows are produced
// by the incremental streaming-markdown path, which keeps an open block (a <p>)
// as it parses; text appended as a sibling of that block would render on its
// own line. Inline wrappers (fade spans) are not block boxes and are skipped.
function _transparentFadeAppendTarget(body){
if(!body) return body;
const kids = body.childNodes;
if(!kids || !kids.length) return body;
const last = kids[kids.length - 1];
if(!last || last.nodeType !== 1) return body;
const tag = String(last.tagName || '').toUpperCase();
return _TRANSPARENT_FADE_BLOCK_TAGS.has(tag) ? last : body;
}
function _appendTransparentFadeText(body, text){
if(!body) return;
const value = String(text || '');
@@ -13906,13 +13924,58 @@ function _appendTransparentFadeText(body, text){
}
if(!changed) frag.appendChild(document.createTextNode(value));
else if(last < value.length) frag.appendChild(document.createTextNode(value.slice(last)));
body.appendChild(frag);
// Append inside the trailing block element (the streaming markdown parser's
// open <p>) rather than at the root of .msg-body. A block sibling would start
// its own line box, so a continuation of the current sentence — in particular
// the tail of a word — must land inside that block to stay on the same line.
_transparentFadeAppendTarget(body).appendChild(frag);
}
function _refreshTransparentFadeProseRow(existing, node, preservedState){
if(!existing || !node) return node || existing;
if(existing === node) return existing;
let body = existing.querySelector ? existing.querySelector('.msg-body') : null;
const nextText = String((node.dataset && node.dataset.rawText) || (node.textContent || ''));
const currentText = String(existing.getAttribute('data-stream-fade-text') || (body && body.textContent) || '');
// Resume strictly from the source-space cursor. `body.textContent` is NOT a
// valid substitute: rows built by the incremental streaming-markdown path
// render one character behind their source text (the parser holds the last
// character pending), so falling back to rendered text yields a delta that
// starts mid-word and tears the word in half. With no cursor we cannot know
// how much of the source is already rendered, so rebuild the body instead of
// guessing a delta.
const hasCursor = !!(existing.getAttribute && existing.getAttribute('data-stream-fade-text') !== null);
const currentText = hasCursor ? String(existing.getAttribute('data-stream-fade-text') || '') : '';
const candidateBody = node.querySelector ? node.querySelector('.msg-body') : null;
const parserOwned = !!(candidateBody && candidateBody !== body && candidateBody.__smdParser);
const mute = (typeof window !== 'undefined' && typeof window.__streamFadeMuteRenderedPrefix === 'function')
? window.__streamFadeMuteRenderedPrefix
: null;
if(parserOwned){
// Promote the actual parser-owned candidate into the keyed row's DOM
// position once. Cloning its children into the old visible row on every
// later growth frame cuts off the previous tail word's ~620ms fade
// (`.is-new` is stripped from the replacement) and breaks word-node
// identity used for scroll-anchor stability. After this swap, later
// keyed renders hit `_refreshTransparentLiveRow(existing === node)` and
// keep growing the same parser target. Pending and MEDIA tails stay on
// the parser until it flushes them.
const prevRendered = String((body && body.textContent) || '');
if(candidateBody.classList) candidateBody.classList.add('stream-fade-active');
_bindTransparentFadeCleanup(candidateBody);
if(mute && prevRendered) mute(candidateBody, prevRendered);
if(node.removeAttribute) node.removeAttribute('data-stream-fade-text');
const parent = existing.parentNode || existing.parentElement || null;
if(parent && existing.parentNode === parent){
if(typeof parent.replaceChild === 'function'){
parent.replaceChild(node, existing);
}else if(typeof parent.insertBefore === 'function'){
parent.insertBefore(node, existing);
if(typeof existing.remove === 'function') existing.remove();
}
}
_rehydrateTransparentLiveRow(node, existing, preservedState);
return node;
}
const pairs = _transparentLiveRowAttributePairs(node);
const kept = Object.create(null);
for(const pair of pairs){
@@ -13933,14 +13996,42 @@ function _refreshTransparentFadeProseRow(existing, node, preservedState){
existing.appendChild(body);
}
if(body.classList) body.classList.add('stream-fade-active');
if(!nextText.startsWith(currentText)){
body.textContent = '';
if(!hasCursor || !nextText.startsWith(currentText)){
// Rebuild branch. Snapshot the rendered text BEFORE clearing (#7082
// review should-fix): without it every word re-wraps as `.is-new`, the
// whole visible row dips to opacity 0 and fades back (~620ms), and the
// wholesale node replacement invites the one-time scroll-anchor bounce
// the #6257 comment in `_bindTransparentFadeCleanup` warns about.
const prevRendered = String(body.textContent || '');
existing.setAttribute('data-stream-fade-text', '');
_appendTransparentFadeText(body, nextText);
// Non-parser candidates may still carry a parsed-looking body (tests and
// rewind rebuilds). Clone that DOM when present so we do not flatten it
// back to literal source.
let resumeCursor = nextText;
if(candidateBody && candidateBody !== body &&
typeof candidateBody.cloneNode === 'function' &&
candidateBody.childNodes && candidateBody.childNodes.length){
const clone = candidateBody.cloneNode(true);
body.textContent = '';
while(clone.childNodes && clone.childNodes.length) body.appendChild(clone.childNodes[0]);
_bindTransparentFadeCleanup(body);
const renderedNow = String(body.textContent || '');
if(renderedNow && nextText.startsWith(renderedNow)) resumeCursor = renderedNow;
}else{
body.textContent = '';
_appendTransparentFadeText(body, nextText);
}
// messages.js `_streamFadeMuteRenderedPrefix` idiom (exported on window the
// same way `__anchorProseIncrementalNode` is): rendered-space compare of the
// pre-rebuild snapshot against the rebuilt body, stripping `.is-new` from
// spans inside the common prefix so already-visible words do not replay
// their fade — only genuinely-new tail words animate.
if(mute && prevRendered) mute(body, prevRendered);
existing.setAttribute('data-stream-fade-text', resumeCursor);
}else{
_appendTransparentFadeText(body, nextText.slice(currentText.length));
existing.setAttribute('data-stream-fade-text', nextText);
}
existing.setAttribute('data-stream-fade-text', nextText);
_rehydrateTransparentLiveRow(existing, node, preservedState);
return existing;
}
@@ -308,6 +308,13 @@ eval(extractFunc('_transparentLiveRowInteractiveState'));
eval(extractFunc('_rehydrateTransparentLiveRow'));
eval(extractFunc('_refreshTransparentThinkingLiveRow'));
eval(extractFunc('_bindTransparentFadeCleanup'));
// `_appendTransparentFadeText` now delegates its insertion point to
// `_transparentFadeAppendTarget`, so the extraction harness must provide it
// (and the block-tag set it consults) or the extracted function throws.
global._TRANSPARENT_FADE_BLOCK_TAGS = new Set([
'P','DIV','H1','H2','H3','H4','H5','H6','BLOCKQUOTE','LI','UL','OL','PRE','TABLE',
]);
eval(extractFunc('_transparentFadeAppendTarget'));
eval(extractFunc('_appendTransparentFadeText'));
eval(extractFunc('_refreshTransparentFadeProseRow'));
eval(extractFunc('_refreshTransparentLiveRow'));
@@ -0,0 +1,981 @@
"""Browserless regression: live prose fade rows must not split words across blocks.
Context
-------
Transparent-stream progress rows are built incrementally by
``_anchorProseIncrementalNode`` (static/messages.js), which streams the source
text into the vendored streaming-markdown parser. That parser always holds the
most recently written character back in its pending buffer, so the rendered
``.msg-body`` text lags the source text by one character while the row is live.
``_refreshTransparentFadeProseRow`` (static/ui.js) resumes appending from the
``data-stream-fade-text`` cursor, and used to fall back to ``body.textContent``
when that attribute was missing -- which is exactly the case for incrementally
built rows. The fallback mixed two different coordinate spaces (rendered text
vs source text), so the computed delta started one character early, in the
middle of a word, and was appended as a *sibling* of the parser's ``<p>``
element instead of inside it. A ``<p>`` is a block box, so the tail rendered on
its own line and the word was visually torn in half ("Ces deu" / "x fichiers").
These tests drive the real vendored parser to build the live row exactly like
production does, then run the real reconciler functions over it.
"""
import json
import os
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
NODE = shutil.which("node")
# Streamed source text, split exactly like the SSE token deltas that produced
# the original report.
FIRST_DELTA = "Ces"
SECOND_DELTA = " deux fichiers passent."
FULL_TEXT = FIRST_DELTA + SECOND_DELTA
def _run_node_module(script):
assert NODE, "node is required for DOM-executed live prose render tests"
env = os.environ.copy()
env["UI_JS_PATH"] = str(ROOT / "static" / "ui.js")
env["SMD_PATH"] = str(ROOT / "static" / "vendor" / "smd.min.js")
env["MESSAGES_JS_PATH"] = str(ROOT / "static" / "messages.js")
result = subprocess.run(
[NODE, "--input-type=module", "-e", script],
env=env,
text=True,
capture_output=True,
check=False,
)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
# A DOM stub faithful enough for this contract: textContent is *computed* from
# the node tree (the production code reads it back), and block-vs-inline
# structure is preserved so a torn word is observable.
_HARNESS = r"""
import { readFileSync } from 'node:fs';
const src = readFileSync(process.env.UI_JS_PATH, 'utf8');
function extractFuncFrom(source, name){
const marker = new RegExp('function\\s+' + name + '\\s*\\(');
const start = source.search(marker);
if(start < 0) throw new Error(name + ' not found');
let i = source.indexOf('{', start) + 1;
let depth = 1;
while(depth > 0 && i < source.length){
if(source[i] === '{') depth += 1;
else if(source[i] === '}') depth -= 1;
i += 1;
}
return source.slice(start, i);
}
function extractFunc(name){ return extractFuncFrom(src, name); }
const BLOCK_TAGS = new Set(['P','DIV','H1','H2','H3','H4','H5','H6','BLOCKQUOTE','LI','UL','OL','PRE','TABLE']);
class TextNode {
constructor(t){ this.nodeType = 3; this.tagName = '#text'; this._t = String(t); this.parentNode = null; this.childNodes = []; }
get textContent(){ return this._t; }
set textContent(v){ this._t = String(v); }
cloneNode(){ return new TextNode(this._t); }
}
function extractConst(name){
const marker = new RegExp('const\\s+' + name + '\\s*=');
const start = src.search(marker);
if(start < 0) throw new Error(name + ' not found');
let i = src.indexOf('[', start) + 1;
let depth = 1;
while(depth > 0 && i < src.length){
if(src[i] === '[') depth += 1;
else if(src[i] === ']') depth -= 1;
i += 1;
}
const end = src.indexOf(';', i);
return src.slice(start, end + 1);
}
class FakeElement {
constructor(tag='div'){
this.nodeType = 1;
this.tagName = String(tag).toUpperCase();
this.childNodes = [];
this.attributes = Object.create(null);
this.dataset = Object.create(null);
this.style = { setProperty(){}, removeProperty(){} };
this.parentNode = null;
this.type = '';
this.disabled = false;
this._classes = new Set();
this._listeners = Object.create(null);
const self = this;
this.classList = {
add(...n){ n.forEach(x=>self._classes.add(x)); },
remove(...n){ n.forEach(x=>self._classes.delete(x)); },
contains(n){ return self._classes.has(n); },
toggle(n, force){
if(force === true){ self._classes.add(n); return true; }
if(force === false){ self._classes.delete(n); return false; }
if(self._classes.has(n)){ self._classes.delete(n); return false; }
self._classes.add(n); return true;
},
};
}
get children(){ return this.childNodes.filter(n=>n.nodeType === 1); }
get parentElement(){ return this.parentNode; }
get lastChild(){ return this.childNodes[this.childNodes.length - 1] || null; }
get className(){ return Array.from(this._classes).join(' '); }
set className(v){ this._classes = new Set(String(v ?? '').trim().split(/\s+/).filter(Boolean)); }
get textContent(){ return this.childNodes.map(n=>n.textContent).join(''); }
set textContent(v){
this.childNodes = [];
const s = String(v ?? '');
if(s) this.appendChild(new TextNode(s));
}
appendChild(n){
if(n && n._isFragment){ n.childNodes.slice().forEach(c=>this.appendChild(c)); return n; }
if(n.parentNode){ const i = n.parentNode.childNodes.indexOf(n); if(i >= 0) n.parentNode.childNodes.splice(i, 1); }
n.parentNode = this; this.childNodes.push(n); return n;
}
insertBefore(n, ref){
if(n && n._isFragment){ n.childNodes.slice().forEach(c=>this.insertBefore(c, ref)); return n; }
if(n && n.parentNode){ const i = n.parentNode.childNodes.indexOf(n); if(i >= 0) n.parentNode.childNodes.splice(i, 1); }
if(n) n.parentNode = this;
if(!ref){ this.childNodes.push(n); return n; }
const i = this.childNodes.indexOf(ref);
if(i < 0) this.childNodes.push(n);
else this.childNodes.splice(i, 0, n);
return n;
}
replaceChild(n, old){
const i = this.childNodes.indexOf(old);
if(i < 0) return old;
if(n && n.parentNode){ const j = n.parentNode.childNodes.indexOf(n); if(j >= 0) n.parentNode.childNodes.splice(j, 1); }
if(old) old.parentNode = null;
if(n) n.parentNode = this;
this.childNodes[i] = n;
return old;
}
remove(){
if(!this.parentNode) return;
const i = this.parentNode.childNodes.indexOf(this);
if(i >= 0) this.parentNode.childNodes.splice(i, 1);
this.parentNode = null;
}
setAttribute(name, value){ this.attributes[String(name)] = String(value ?? ''); }
getAttribute(name){ const k = String(name); return k in this.attributes ? this.attributes[k] : null; }
removeAttribute(name){ delete this.attributes[String(name)]; }
getAttributeNames(){ return Object.keys(this.attributes); }
addEventListener(type, fn){
const key = String(type || '');
if(!this._listeners[key]) this._listeners[key] = [];
this._listeners[key].push(fn);
}
dispatchEvent(event){
const type = event && event.type ? String(event.type) : '';
const list = this._listeners[type] || [];
for(const fn of list) fn(event);
return true;
}
cloneNode(deep){
const copy = new FakeElement(this.tagName);
copy._classes = new Set(this._classes);
for(const k of Object.keys(this.attributes)) copy.attributes[k] = this.attributes[k];
for(const k of Object.keys(this.dataset)) copy.dataset[k] = this.dataset[k];
if(deep) this.childNodes.forEach(c=>copy.appendChild(c.cloneNode(true)));
return copy;
}
querySelector(sel){
const options = String(sel || '').split(',').map(s => s.trim()).filter(Boolean);
const matches = (el, option)=>{
const classes = [];
const re = /\.([A-Za-z0-9_-]+)/g;
let m;
while((m = re.exec(option))) classes.push(m[1]);
if(!classes.length) return false;
return classes.every(cls => el._classes && el._classes.has(cls));
};
const walk = (node)=>{
for(const c of node.childNodes){
if(c.nodeType !== 1) continue;
if(options.some(opt => matches(c, opt))) return c;
const hit = walk(c);
if(hit) return hit;
}
return null;
};
return walk(this);
}
querySelectorAll(){ return []; }
}
class Fragment extends FakeElement {
constructor(){ super('#fragment'); this._isFragment = true; }
}
globalThis.document = {
createElement:(t)=>new FakeElement(t),
createTextNode:(t)=>new TextNode(t),
createDocumentFragment:()=>new Fragment(),
};
globalThis.window = { matchMedia: ()=>({ matches: false }) };
// Report whether any word is torn across a block boundary inside .msg-body,
// i.e. a block child whose text ends mid-word immediately followed by a
// sibling whose text starts mid-word.
function midWordBlockSplit(body){
const kids = body.childNodes;
for(let i = 0; i < kids.length - 1; i++){
const cur = kids[i];
const next = kids[i + 1];
const isBlock = cur.nodeType === 1 && BLOCK_TAGS.has(cur.tagName);
if(!isBlock) continue;
const before = cur.textContent;
const after = next.textContent;
if(/\S$/.test(before) && /^\S/.test(after)){
return { split: true, before: before.slice(-12), after: after.slice(0, 12) };
}
}
return { split: false, before: '', after: '' };
}
// Non-whitespace text stranded at the root of .msg-body after a block element.
function strandedAfterBlock(body){
let seenBlock = false, stranded = '';
for(const kid of body.childNodes){
if(kid.nodeType === 1 && BLOCK_TAGS.has(kid.tagName)){ seenBlock = true; continue; }
if(seenBlock) stranded += kid.textContent;
}
return stranded.trim();
}
"""
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_live_prose_row_without_cursor_keeps_words_intact():
"""A live row built incrementally must never be torn mid-word on refresh."""
script = (
_HARNESS
+ """
const smd = await import(process.env.SMD_PATH);
(0, eval)(extractConst('_TRANSPARENT_FADE_BLOCK_TAGS'));
(0, eval)(extractFunc('_transparentFadeAppendTarget'));
(0, eval)(extractFunc('_bindTransparentFadeCleanup'));
(0, eval)(extractFunc('_appendTransparentFadeText'));
(0, eval)(extractFunc('_transparentLiveRowAttributePairs'));
(0, eval)(extractFunc('_transparentLiveRowInteractiveState'));
(0, eval)(extractFunc('_rehydrateTransparentLiveRow'));
(0, eval)(extractFunc('_refreshTransparentFadeProseRow'));
const FIRST = %s;
const SECOND = %s;
const FULL = FIRST + SECOND;
// Live row exactly as _anchorProseIncrementalNode builds it: the real parser
// streams the source in, and is deliberately NOT ended (the turn is live).
const existing = new FakeElement('div');
existing.className = 'assistant-segment transparent-event-row';
existing.setAttribute('data-anchor-row-role', 'prose');
existing.setAttribute('data-anchor-row-id', 'live-prose-row');
existing.setAttribute('data-anchor-source-event-type', 'process_prose');
const body = new FakeElement('div');
body.className = 'msg-body stream-fade-active';
existing.appendChild(body);
const parser = smd.parser(smd.default_renderer(body));
smd.parser_write(parser, FIRST);
smd.parser_write(parser, SECOND.slice(0, 4));
const renderedBefore = body.textContent;
const sourceBefore = FIRST + SECOND.slice(0, 4);
// Candidate carrying the full source text for this row.
const candidate = new FakeElement('div');
candidate.className = 'assistant-segment transparent-event-row';
candidate.setAttribute('data-anchor-row-role', 'prose');
candidate.setAttribute('data-anchor-row-id', 'live-prose-row');
candidate.setAttribute('data-anchor-source-event-type', 'process_prose');
candidate.dataset.rawText = FULL;
const candidateBody = new FakeElement('div');
candidateBody.className = 'msg-body stream-fade-active';
candidateBody.textContent = FULL;
candidate.appendChild(candidateBody);
_refreshTransparentFadeProseRow(existing, candidate, null);
const refreshedBody = existing.querySelector('.msg-body');
const split = midWordBlockSplit(refreshedBody);
process.stdout.write(JSON.stringify({
renderedLagsSource: renderedBefore.length < sourceBefore.length,
renderedBefore,
sourceBefore,
finalText: refreshedBody.textContent,
expectedText: FULL,
midWordSplit: split.split,
splitBefore: split.before,
splitAfter: split.after,
strandedAfterBlock: strandedAfterBlock(refreshedBody),
cursor: existing.getAttribute('data-stream-fade-text'),
}));
"""
% (json.dumps(FIRST_DELTA), json.dumps(SECOND_DELTA))
)
data = _run_node_module(script)
# Precondition: the vendored parser really does lag the source text.
assert data["renderedLagsSource"] is True, (
"expected the streaming parser to hold a pending character, got "
f"rendered={data['renderedBefore']!r} source={data['sourceBefore']!r}"
)
# The contract under test: no word may be torn across a block boundary.
assert data["midWordSplit"] is False, (
"live prose row was torn mid-word across a block boundary: "
f"...{data['splitBefore']!r} | {data['splitAfter']!r}..."
)
assert data["strandedAfterBlock"] == "", (
"text was appended outside the block element instead of inside it: "
f"{data['strandedAfterBlock']!r}"
)
# And the visible text stays exactly the source text.
assert data["finalText"] == data["expectedText"]
assert data["cursor"] == FULL_TEXT
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_live_prose_row_with_cursor_still_appends_incrementally():
"""The existing incremental contract (cursor present) must be preserved."""
script = (
_HARNESS
+ """
(0, eval)(extractConst('_TRANSPARENT_FADE_BLOCK_TAGS'));
(0, eval)(extractFunc('_transparentFadeAppendTarget'));
(0, eval)(extractFunc('_bindTransparentFadeCleanup'));
(0, eval)(extractFunc('_appendTransparentFadeText'));
(0, eval)(extractFunc('_transparentLiveRowAttributePairs'));
(0, eval)(extractFunc('_transparentLiveRowInteractiveState'));
(0, eval)(extractFunc('_rehydrateTransparentLiveRow'));
(0, eval)(extractFunc('_refreshTransparentFadeProseRow'));
const existing = new FakeElement('div');
existing.className = 'assistant-segment transparent-event-row';
existing.setAttribute('data-anchor-row-role', 'prose');
existing.setAttribute('data-stream-fade-text', 'old ');
const body = new FakeElement('div');
body.className = 'msg-body stream-fade-active';
const oldSpan = new FakeElement('span');
oldSpan.className = 'stream-fade-word is-new';
oldSpan.textContent = 'old';
body.appendChild(oldSpan);
body.appendChild(new TextNode(' '));
existing.appendChild(body);
const candidate = new FakeElement('div');
candidate.className = 'assistant-segment transparent-event-row';
candidate.setAttribute('data-anchor-row-role', 'prose');
candidate.dataset.rawText = 'old new';
const candidateBody = new FakeElement('div');
candidateBody.className = 'msg-body stream-fade-active';
candidateBody.textContent = 'old new';
candidate.appendChild(candidateBody);
_refreshTransparentFadeProseRow(existing, candidate, null);
const refreshedBody = existing.querySelector('.msg-body');
const spans = refreshedBody.children.filter(c=>c._classes.has('stream-fade-word'));
process.stdout.write(JSON.stringify({
text: refreshedBody.textContent,
cursor: existing.getAttribute('data-stream-fade-text'),
spanTexts: spans.map(s=>s.textContent),
oldSpanPreserved: spans[0] === oldSpan,
}));
"""
)
data = _run_node_module(script)
assert data["text"] == "old new"
assert data["cursor"] == "old new"
assert data["spanTexts"] == ["old", "new"]
# Node identity of already-faded words must survive (scroll-anchor stability).
assert data["oldSpanPreserved"] is True
# Shared eval preamble for the rebuild-branch tests: real ui.js reconciler
# functions plus the real messages.js mute helper, wired to window exactly the
# way production wires it (window.__streamFadeMuteRenderedPrefix).
_REBUILD_PRELUDE = """
const messagesSrc = readFileSync(process.env.MESSAGES_JS_PATH, 'utf8');
// Single combined eval: sloppy-mode indirect eval hoists function declarations
// to the global object but discards top-level `const` bindings after the call,
// so _TRANSPARENT_FADE_BLOCK_TAGS must be evaluated in the SAME eval as the
// functions that close over it (_transparentFadeAppendTarget).
(0, eval)([
extractConst('_TRANSPARENT_FADE_BLOCK_TAGS'),
extractFunc('_transparentFadeAppendTarget'),
extractFunc('_bindTransparentFadeCleanup'),
extractFunc('_appendTransparentFadeText'),
extractFunc('_transparentLiveRowAttributePairs'),
extractFunc('_transparentLiveRowInteractiveState'),
extractFunc('_rehydrateTransparentLiveRow'),
extractFunc('_refreshTransparentFadeProseRow'),
extractFunc('_refreshTransparentLiveRow'),
].join('\\n'));
(0, eval)(extractFuncFrom(messagesSrc, '_streamFadeMuteRenderedPrefix'));
globalThis.window.__streamFadeMuteRenderedPrefix = globalThis._streamFadeMuteRenderedPrefix;
if(typeof window.__streamFadeMuteRenderedPrefix !== 'function'){
throw new Error('messages.js must export _streamFadeMuteRenderedPrefix on window');
}
function makeRow(rawText){
const row = new FakeElement('div');
row.className = 'assistant-segment transparent-event-row';
row.setAttribute('data-anchor-row-role', 'prose');
row.setAttribute('data-anchor-row-id', 'live-prose-row');
row.setAttribute('data-anchor-source-event-type', 'process_prose');
if(rawText !== undefined) row.dataset.rawText = rawText;
const rowBody = new FakeElement('div');
rowBody.className = 'msg-body stream-fade-active';
row.appendChild(rowBody);
return row;
}
function collectFadeSpans(root){
return collectFadeSpanNodes(root).map(c=>({ text: c.textContent, isNew: c._classes.has('is-new') }));
}
function collectFadeSpanNodes(root){
const out = [];
const walk = (node)=>{
for(const c of node.childNodes || []){
if(c.nodeType !== 1) continue;
if(c._classes && c._classes.has('stream-fade-word')) out.push(c);
walk(c);
}
};
walk(root);
return out;
}
function findTag(root, tag){
for(const c of root.childNodes || []){
if(c.nodeType !== 1) continue;
if(c.tagName === tag) return c;
const hit = findTag(c, tag);
if(hit) return hit;
}
return null;
}
"""
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_live_prose_rebuild_mutes_already_rendered_prefix():
"""#7082 review should-fix: the no-cursor rebuild must not re-animate the
already-rendered prefix. Only genuinely-new tail words keep ``.is-new``."""
script = (
_HARNESS
+ _REBUILD_PRELUDE
+ """
const smd = await import(process.env.SMD_PATH);
const FIRST = %s;
const SECOND = %s;
const FULL = FIRST + SECOND;
// Existing row: incrementally built by the real parser, no cursor attribute.
const existing = makeRow(undefined);
delete existing.dataset.rawText;
const body = existing.querySelector('.msg-body');
const parser = smd.parser(smd.default_renderer(body));
smd.parser_write(parser, FIRST);
smd.parser_write(parser, SECOND.slice(0, 4));
const prevRendered = body.textContent;
// Candidate: the incremental fade node's parsed body — a <p> whose words are
// wrapped as fade spans, all `.is-new` (worst case: a rewind rebuild of the
// incremental node re-wrapped every word).
const candidate = makeRow(FULL);
const candidateBody = candidate.querySelector('.msg-body');
const p = new FakeElement('p');
candidateBody.appendChild(p);
const words = FULL.split(' ');
words.forEach((word, i)=>{
const span = new FakeElement('span');
span.className = 'stream-fade-word is-new';
span.textContent = word;
p.appendChild(span);
if(i < words.length - 1) p.appendChild(new TextNode(' '));
});
_refreshTransparentFadeProseRow(existing, candidate, null);
const refreshedBody = existing.querySelector('.msg-body');
process.stdout.write(JSON.stringify({
prevRendered,
finalText: refreshedBody.textContent,
spans: collectFadeSpans(refreshedBody),
cursor: existing.getAttribute('data-stream-fade-text'),
candidateBodyIntact: candidateBody.childNodes.length === 1 && candidateBody.childNodes[0] === p,
}));
"""
% (json.dumps(FIRST_DELTA), json.dumps(SECOND_DELTA))
)
data = _run_node_module(script)
assert data["finalText"] == FULL_TEXT
assert data["cursor"] == FULL_TEXT
spans = data["spans"]
assert [s["text"] for s in spans] == FULL_TEXT.split(" ")
# Words inside the previously-rendered prefix must NOT replay their fade …
prev = data["prevRendered"]
consumed = 0
for span in spans:
starts_in_prefix = consumed < len(prev)
if starts_in_prefix:
assert span["isNew"] is False, (
f"already-rendered word {span['text']!r} would replay its fade "
f"(prefix was {prev!r})"
)
consumed += len(span["text"]) + 1 # word + following space
# … while genuinely-new tail words still animate.
assert spans[-1]["isNew"] is True
muted = [s for s in spans if not s["isNew"]]
assert muted, "expected at least one muted already-rendered word"
# The persistent incremental node (parser-owned DOM) must not be stolen.
assert data["candidateBodyIntact"] is True
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_live_prose_rebuild_preserves_markdown_dom():
"""Greptile P1: the no-cursor rebuild must not flatten a live markdown row
to literal source text — the parsed DOM (links, emphasis) must survive."""
md_source = "Voici **un** [lien](https://example.com) utile pour tout le monde."
script = (
_HARNESS
+ _REBUILD_PRELUDE
+ """
const smd = await import(process.env.SMD_PATH);
const SOURCE = %s;
// Existing row: the parser has only seen a prefix of the source.
const existing = makeRow(undefined);
delete existing.dataset.rawText;
const body = existing.querySelector('.msg-body');
const parser = smd.parser(smd.default_renderer(body));
smd.parser_write(parser, SOURCE.slice(0, 10));
const prevRendered = body.textContent;
// Candidate: the incremental node fed the FULL source through the real
// streaming-markdown parser (still live, deliberately not ended). Production
// binds the parser on the body via _smdBindParserIdentity (messages.js), and
// the cursor fix reads the held-back tail from that binding.
const candidate = makeRow(SOURCE);
const candidateBody = candidate.querySelector('.msg-body');
const candidateParser = smd.parser(smd.default_renderer(candidateBody));
smd.parser_write(candidateParser, SOURCE);
candidateBody.__smdParser = candidateParser;
const candidateRendered = candidateBody.textContent;
const pendingTail = String(candidateParser.pending || '') + String(candidateParser.text || '');
const visible = _refreshTransparentFadeProseRow(existing, candidate, null);
const refreshedBody = visible.querySelector('.msg-body');
const link = findTag(refreshedBody, 'A');
const strong = findTag(refreshedBody, 'STRONG');
process.stdout.write(JSON.stringify({
prevRendered,
candidateRendered,
pendingTail,
finalText: refreshedBody.textContent,
hasLink: !!link,
linkText: link ? link.textContent : null,
linkHref: link ? link.getAttribute('href') : null,
hasStrong: !!strong,
strongText: strong ? strong.textContent : null,
cursor: visible.getAttribute('data-stream-fade-text'),
adoptedParserRow: visible === candidate,
sameParserBody: refreshedBody === candidateBody,
}));
"""
% json.dumps(md_source)
)
data = _run_node_module(script)
# The parsed markdown DOM survived the rebuild …
assert data["hasLink"] is True, "rebuild dropped the parsed <a> element"
assert data["linkText"] == "lien"
assert data["linkHref"] == "https://example.com"
assert data["hasStrong"] is True, "rebuild dropped the parsed <strong> element"
assert data["strongText"] == "un"
# … and no markdown syntax leaked into the visible text.
assert "**" not in data["finalText"]
assert "](" not in data["finalText"]
assert data["finalText"] == data["candidateRendered"]
# Parser-owned rows must not publish a source-space cursor. The next
# refresh has to adopt the parsed DOM again; pending tails stay on the
# parser until it flushes them.
assert data["pendingTail"] == "."
assert data["cursor"] is None
assert data["adoptedParserRow"] is True
assert data["sameParserBody"] is True
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_live_prose_rebuild_leaves_parser_pending_tail_on_parser():
"""Parser pending tails stay on the parser after a cursorless rebuild.
A source-space resume cursor would make the next refresh append the
held-back bytes as raw text and break later Markdown. The visible row
must keep the adopted parsed DOM until the parser itself flushes.
"""
md_source = "Voici **un** [lien](https://example.com) super."
script = (
_HARNESS
+ _REBUILD_PRELUDE
+ """
const smd = await import(process.env.SMD_PATH);
const SOURCE = %s;
const existing = makeRow(undefined);
delete existing.dataset.rawText;
const body = existing.querySelector('.msg-body');
const parser = smd.parser(smd.default_renderer(body));
smd.parser_write(parser, SOURCE.slice(0, 8));
const candidate = makeRow(SOURCE);
const candidateBody = candidate.querySelector('.msg-body');
const candidateParser = smd.parser(smd.default_renderer(candidateBody));
smd.parser_write(candidateParser, SOURCE);
candidateBody.__smdParser = candidateParser;
const pendingTail = String(candidateParser.pending || '') + String(candidateParser.text || '');
const candidateRendered = candidateBody.textContent;
const visible = _refreshTransparentFadeProseRow(existing, candidate, null);
const afterRebuildText = visible.querySelector('.msg-body').textContent;
const afterRebuildCursor = visible.getAttribute('data-stream-fade-text');
const sameVisible = _refreshTransparentFadeProseRow(visible, candidate, null);
const afterResumeBody = sameVisible.querySelector('.msg-body');
const afterResumeStrong = findTag(afterResumeBody, 'STRONG');
const afterResumeLink = findTag(afterResumeBody, 'A');
if(typeof smd.parser_end === 'function') smd.parser_end(candidateParser);
const settledVisible = _refreshTransparentFadeProseRow(sameVisible, candidate, null);
const afterEndBody = settledVisible.querySelector('.msg-body');
process.stdout.write(JSON.stringify({
pendingTail,
candidateRendered,
afterRebuildText,
afterRebuildCursor,
afterResumeText: afterResumeBody.textContent,
afterResumeCursor: sameVisible.getAttribute('data-stream-fade-text'),
hasStrongAfterResume: !!afterResumeStrong,
hasLinkAfterResume: !!afterResumeLink,
literalStarsAfterResume: afterResumeBody.textContent.includes('**'),
afterEndText: afterEndBody.textContent,
literalStarsAfterEnd: afterEndBody.textContent.includes('**'),
adoptedParserRow: visible === candidate,
laterRefreshSameRow: sameVisible === candidate,
settledSameRow: settledVisible === candidate,
sameParserBody: afterResumeBody === candidateBody,
}));
"""
% json.dumps(md_source)
)
data = _run_node_module(script)
assert data["pendingTail"], "expected the streaming parser to hold a pending tail"
assert data["pendingTail"] == "."
assert data["afterRebuildText"] == data["candidateRendered"]
assert not data["afterRebuildText"].endswith(data["pendingTail"])
assert data["afterRebuildCursor"] is None
# Same live parser: later refreshes must keep parsed structure. The
# parser may flush its held-back "." into the candidate; that is still
# parser-owned text, not a source-byte append.
assert data["adoptedParserRow"] is True
assert data["laterRefreshSameRow"] is True
assert data["settledSameRow"] is True
assert data["sameParserBody"] is True
assert data["afterResumeCursor"] is None
assert data["hasStrongAfterResume"] is True
assert data["hasLinkAfterResume"] is True
assert data["literalStarsAfterResume"] is False
assert data["afterResumeText"].startswith("Voici un lien super")
assert "**" not in data["afterResumeText"]
assert "](" not in data["afterResumeText"]
# After the parser flushes, the adopted DOM may grow, but still no
# literal markdown syntax.
assert data["literalStarsAfterEnd"] is False
assert "super" in data["afterEndText"]
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_live_prose_keeps_parser_authority_across_markdown_close():
"""#7082 re-gate: two reconciliations across incomplete-to-complete Markdown
must keep the parser as the only DOM authority.
Cloning the parsed body and then appending later source bytes through
``_appendTransparentFadeText`` leaves literal ``**world**`` in the visible
row while the live parser has already built ``<strong>world</strong>``.
"""
first_source = "Hello **"
full_source = "Hello **world** done "
script = (
_HARNESS
+ _REBUILD_PRELUDE
+ """
const smd = await import(process.env.SMD_PATH);
const FIRST = %s;
const FULL = %s;
function countSubstr(hay, needle){
if(!needle) return 0;
let n = 0, from = 0;
while(true){
const i = hay.indexOf(needle, from);
if(i < 0) return n;
n += 1;
from = i + needle.length;
}
}
function serialize(node){
if(!node) return '';
if(node.nodeType === 3) return node.textContent;
const tag = String(node.tagName || '').toLowerCase();
if(!tag || tag === '#fragment' || tag === 'div' && node._classes && node._classes.has('msg-body')){
return (node.childNodes || []).map(serialize).join('');
}
const inner = (node.childNodes || []).map(serialize).join('');
return '<' + tag + '>' + inner + '</' + tag + '>';
}
const existing = makeRow(undefined);
delete existing.dataset.rawText;
const candidate = makeRow(FIRST);
const candidateBody = candidate.querySelector('.msg-body');
const parser = smd.parser(smd.default_renderer(candidateBody));
smd.parser_write(parser, FIRST);
candidateBody.__smdParser = parser;
const visible = _refreshTransparentFadeProseRow(existing, candidate, null);
smd.parser_write(parser, FULL.slice(FIRST.length));
candidate.dataset.rawText = FULL;
const sameVisible = _refreshTransparentFadeProseRow(visible, candidate, null);
const liveAfterClose = sameVisible.querySelector('.msg-body');
const strongAfterClose = findTag(liveAfterClose, 'STRONG');
const htmlAfterClose = serialize(liveAfterClose);
const textAfterClose = liveAfterClose.textContent;
const candidateHtmlAfterClose = serialize(candidateBody);
if(typeof smd.parser_end === 'function') smd.parser_end(parser);
const settledVisible = _refreshTransparentFadeProseRow(sameVisible, candidate, null);
const liveAfterEnd = settledVisible.querySelector('.msg-body');
const strongAfterEnd = findTag(liveAfterEnd, 'STRONG');
process.stdout.write(JSON.stringify({
htmlAfterClose,
textAfterClose,
candidateHtmlAfterClose,
candidateTextAfterClose: candidateBody.textContent,
hasStrongAfterClose: !!strongAfterClose,
strongTextAfterClose: strongAfterClose ? strongAfterClose.textContent : null,
helloCountAfterClose: countSubstr(textAfterClose, 'Hello'),
worldCountAfterClose: countSubstr(textAfterClose, 'world'),
doneCountAfterClose: countSubstr(textAfterClose, 'done'),
literalStarsAfterClose: textAfterClose.includes('**'),
htmlAfterEnd: serialize(liveAfterEnd),
textAfterEnd: liveAfterEnd.textContent,
hasStrongAfterEnd: !!strongAfterEnd,
strongTextAfterEnd: strongAfterEnd ? strongAfterEnd.textContent : null,
literalStarsAfterEnd: liveAfterEnd.textContent.includes('**'),
helloCountAfterEnd: countSubstr(liveAfterEnd.textContent, 'Hello'),
worldCountAfterEnd: countSubstr(liveAfterEnd.textContent, 'world'),
doneCountAfterEnd: countSubstr(liveAfterEnd.textContent, 'done'),
adoptedParserRow: visible === candidate,
laterRefreshSameRow: sameVisible === candidate,
settledSameRow: settledVisible === candidate,
sameParserBody: liveAfterClose === candidateBody,
}));
"""
% (json.dumps(first_source), json.dumps(full_source))
)
data = _run_node_module(script)
assert data["hasStrongAfterClose"] is True, (
"visible row lost parser emphasis after the second reconciliation: "
f"html={data['htmlAfterClose']!r} text={data['textAfterClose']!r}"
)
assert data["strongTextAfterClose"] == "world"
assert data["literalStarsAfterClose"] is False, (
"literal ** leaked into the live row: "
f"{data['textAfterClose']!r} html={data['htmlAfterClose']!r}"
)
assert data["helloCountAfterClose"] == 1
assert data["worldCountAfterClose"] == 1
assert data["doneCountAfterClose"] == 1
assert "world" in data["textAfterClose"]
assert "done" in data["textAfterClose"]
assert data["hasStrongAfterEnd"] is True
assert data["strongTextAfterEnd"] == "world"
assert data["literalStarsAfterEnd"] is False
assert data["helloCountAfterEnd"] == 1
assert data["worldCountAfterEnd"] == 1
assert data["doneCountAfterEnd"] == 1
assert data["adoptedParserRow"] is True
assert data["laterRefreshSameRow"] is True
assert data["settledSameRow"] is True
assert data["sameParserBody"] is True
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_live_prose_post_rewind_keeps_tail_word_identity_across_two_growths():
"""#7082 must-fix: after the separate-owner rewind transition, two later
growth frames must keep the first genuinely-new tail span as the same
node with ``.is-new`` until an explicit ``animationend``.
"""
prefix_source = "Hello there"
first_growth = "Hello there world"
second_growth = "Hello there world more"
script = (
_HARNESS
+ _REBUILD_PRELUDE
+ """
const smd = await import(process.env.SMD_PATH);
const PREFIX = %s;
const FIRST = %s;
const SECOND = %s;
function wrapWords(parent, text){
const words = String(text || '').split(' ');
words.forEach((word, i)=>{
const span = new FakeElement('span');
span.className = 'stream-fade-word is-new';
span.textContent = word;
parent.appendChild(span);
if(i < words.length - 1) parent.appendChild(new TextNode(' '));
});
}
const host = new FakeElement('div');
const existing = makeRow(undefined);
delete existing.dataset.rawText;
const staleBody = existing.querySelector('.msg-body');
wrapWords(staleBody, PREFIX);
host.appendChild(existing);
const candidate = makeRow(PREFIX);
const candidateBody = candidate.querySelector('.msg-body');
const parser = smd.parser(smd.default_renderer(candidateBody));
smd.parser_write(parser, PREFIX);
candidateBody.__smdParser = parser;
const pendingAtAdopt = String(parser.pending || '') + String(parser.text || '');
// Keep the live parser identity, then rebuild fade spans so the mute + later
// growth can be observed as node identity. Do not parser_write after this:
// the default renderer would replace the word nodes we need to pin.
candidateBody.textContent = '';
const p = new FakeElement('p');
const strong = new FakeElement('strong');
const hello = new FakeElement('span');
hello.className = 'stream-fade-word is-new';
hello.textContent = 'Hello';
const there = new FakeElement('span');
there.className = 'stream-fade-word is-new';
there.textContent = 'there';
strong.appendChild(there);
p.appendChild(hello);
p.appendChild(new TextNode(' '));
p.appendChild(strong);
candidateBody.appendChild(p);
const visible = _refreshTransparentFadeProseRow(existing, candidate, null);
const afterAdoptNew = collectFadeSpanNodes(visible).filter(span => span._classes.has('is-new'));
candidate.dataset.rawText = FIRST;
const world = new FakeElement('span');
world.className = 'stream-fade-word is-new';
world.textContent = 'world';
p.appendChild(new TextNode(' '));
p.appendChild(world);
const afterFirstGrowth = _refreshTransparentLiveRow(visible, candidate, null);
const firstNewWasAnimated = world._classes.has('is-new');
const firstNewAfterFirstGrowth = world;
candidate.dataset.rawText = SECOND;
const more = new FakeElement('span');
more.className = 'stream-fade-word is-new';
more.textContent = 'more';
p.appendChild(new TextNode(' '));
p.appendChild(more);
const afterSecondGrowth = _refreshTransparentLiveRow(afterFirstGrowth, candidate, null);
const afterSecondSpans = collectFadeSpanNodes(afterSecondGrowth);
const afterSecondNew = afterSecondSpans.filter(span => span._classes.has('is-new'));
const secondNewSameIdentity = world === firstNewAfterFirstGrowth && afterSecondSpans.includes(world);
const secondNewIsNew = world._classes.has('is-new');
visible.querySelector('.msg-body').dispatchEvent({ type: 'animationend', target: world });
const afterCleanupNew = collectFadeSpanNodes(afterSecondGrowth).filter(span => span._classes.has('is-new'));
if(typeof smd.parser_end === 'function') smd.parser_end(parser);
const settled = _refreshTransparentLiveRow(afterSecondGrowth, candidate, null);
const settledBody = settled.querySelector('.msg-body');
process.stdout.write(JSON.stringify({
adoptedParserRow: visible === candidate,
firstGrowthSameRow: afterFirstGrowth === candidate,
secondGrowthSameRow: afterSecondGrowth === candidate,
sameParserBody: afterSecondGrowth.querySelector('.msg-body') === candidateBody,
afterAdoptNewTexts: afterAdoptNew.map(span => span.textContent),
firstNewText: firstNewAfterFirstGrowth.textContent,
firstNewWasAnimated,
firstNewStillConnected: !!(world.parentNode),
secondNewSameIdentity,
secondNewIsNew,
afterSecondNewTexts: afterSecondNew.map(span => span.textContent),
afterCleanupNewTexts: afterCleanupNew.map(span => span.textContent),
hasStrong: !!findTag(afterSecondGrowth, 'STRONG'),
strongText: findTag(afterSecondGrowth, 'STRONG') ? findTag(afterSecondGrowth, 'STRONG').textContent : null,
literalStars: afterSecondGrowth.textContent.includes('**'),
pendingAtAdopt,
settledSameRow: settled === candidate,
afterSecondText: afterSecondGrowth.querySelector('.msg-body').textContent,
afterEndText: settledBody.textContent,
cursorAfterAdopt: visible.getAttribute('data-stream-fade-text'),
cursorAfterSecond: afterSecondGrowth.getAttribute('data-stream-fade-text'),
oldRowDetached: existing.parentNode === null,
adoptedRowParented: visible.parentNode === host,
helloCount: (settledBody.textContent.match(/Hello/g) || []).length,
worldCount: (settledBody.textContent.match(/world/g) || []).length,
moreCount: (settledBody.textContent.match(/more/g) || []).length,
}));
"""
% (json.dumps(prefix_source), json.dumps(first_growth), json.dumps(second_growth))
)
data = _run_node_module(script)
assert data["adoptedParserRow"] is True
assert data["firstGrowthSameRow"] is True
assert data["secondGrowthSameRow"] is True
assert data["sameParserBody"] is True
assert data["afterAdoptNewTexts"] == []
assert data["firstNewText"] == "world"
assert data["firstNewWasAnimated"] is True
assert data["firstNewStillConnected"] is True
assert data["secondNewSameIdentity"] is True
assert data["secondNewIsNew"] is True
assert data["afterSecondNewTexts"] == ["world", "more"]
assert data["afterCleanupNewTexts"] == ["more"]
assert data["hasStrong"] is True
assert data["strongText"] == "there"
assert data["literalStars"] is False
assert data["settledSameRow"] is True
assert data["afterSecondText"] == second_growth
assert data["afterEndText"] == second_growth
assert data["cursorAfterAdopt"] is None
assert data["cursorAfterSecond"] is None
assert data["oldRowDetached"] is True
assert data["adoptedRowParented"] is True
assert data["helloCount"] == 1
assert data["worldCount"] == 1
assert data["moreCount"] == 1