diff --git a/packages/mcp/test/unit/docmost-md-roundtrip.test.mjs b/packages/mcp/test/unit/docmost-md-roundtrip.test.mjs index b8b1293c..b4e6baaf 100644 --- a/packages/mcp/test/unit/docmost-md-roundtrip.test.mjs +++ b/packages/mcp/test/unit/docmost-md-roundtrip.test.mjs @@ -5,8 +5,13 @@ import { serializeDocmostMarkdown, parseDocmostMarkdown, } from "../../build/lib/markdown-document.js"; +import * as Y from "yjs"; import { convertProseMirrorToMarkdown } from "../../build/lib/markdown-converter.js"; -import { markdownToProseMirror } from "../../build/lib/collaboration.js"; +import { + markdownToProseMirror, + applyDocToFragment, +} from "../../build/lib/collaboration.js"; +import { TiptapTransformer } from "@hocuspocus/transformer"; /** Recursively find the first descendant node (or self) of the given type. */ function find(node, type) { @@ -224,3 +229,68 @@ test("drawio round-trips through export and import", () => { assert.equal(diagram.attrs.attachmentId, "att-7"); }); }); + +// #503: a hardBreak (Shift+Enter) must be REPRESENTABLE end-to-end through the +// MCP canon, not silently cut. The reported symptom was a hardBreak sent via +// updatePageJson vanishing with no error and no line break. This guards all +// three seams of that path at once: +// 1. pm -> md: the serializer emits the CommonMark hard break ` \n` (two +// trailing spaces), not a bare `\n` (which reimports as a soft break and is +// lost). +// 2. md -> pm: ` \n` reimports to ONE paragraph carrying text+hardBreak+text +// (not two paragraphs, break not dropped) — the "непредставим" arm. +// 3. the literal updatePageJson mechanism: PM JSON -> live Yjs fragment via +// `applyDocToFragment` (PMNode.fromJSON + updateYFragment — the SAME encoder +// the collab-session write path uses, NOT buildYDoc/toYdoc) -> read back +// preserves the hardBreak between the two text runs in ONE paragraph. +test("hardBreak survives the MCP canon in BOTH directions (#503)", async () => { + const doc = { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "line one" }, + { type: "hardBreak" }, + { type: "text", text: "line two" }, + ], + }, + ], + }; + + // 1. pm -> md: exact CommonMark hard break, not a dropped/soft break. + const body = convertProseMirrorToMarkdown(doc); + assert.equal(body, "line one \nline two"); + + // 2. md -> pm: ONE paragraph, text + hardBreak + text preserved (not split). + const rebuilt = await markdownToProseMirror(body); + const paras = findAll(rebuilt, "paragraph"); + assert.equal(paras.length, 1, "the break must NOT split the paragraph in two"); + const inline = paras[0].content.map((n) => n.type); + assert.deepEqual( + inline, + ["text", "hardBreak", "text"], + "the hardBreak must survive the md -> pm import", + ); + + // 2b. byte-fixpoint: a second export is stable (P2). + assert.equal(convertProseMirrorToMarkdown(rebuilt), body); + + // 3. updatePageJson path: PM JSON -> live Yjs fragment (applyDocToFragment = + // the real collab write path's PMNode.fromJSON + updateYFragment) -> read back + // keeps text + hardBreak + text in ONE paragraph. + const ydoc = new Y.Doc(); + applyDocToFragment(ydoc, doc); + const fromYjs = TiptapTransformer.fromYdoc(ydoc, "default"); + const yjsParas = findAll(fromYjs, "paragraph"); + assert.equal( + yjsParas.length, + 1, + "the Yjs write path must NOT split the paragraph in two", + ); + assert.deepEqual( + yjsParas[0].content.map((n) => n.type), + ["text", "hardBreak", "text"], + "the hardBreak must survive the updatePageJson (updateYFragment) write path", + ); +});