Files
hermes-webui/tests/test_live_prose_fade_cursor_source_space.py
T
Anthony Ruiz 69e4fa3b98 fix(stream): adopt parser-owned fade row instead of recloning it
After rewind/cache-miss the keyed visible row stayed distinct from the
persistent parser candidate, so every later growth frame deep-cloned the
parser DOM into the old row. That cut off the previous tail word's
~620ms fade and broke word-node identity.

Promote the parser-owned candidate into the existing row's DOM position
once, mute the already-rendered prefix on the real parser body, and
return the adopted node so later keyed renders hit existing === node.

Regression: two post-transition growth frames keep the first new tail
span as the same .is-new node until an explicit animationend.
2026-08-27 23:58:14 +00:00

982 lines
39 KiB
Python

"""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