From dead5fa8a8aa7238d1e2d67d8bf7d4fb95c88541 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sun, 12 Jul 2026 04:45:32 +0300 Subject: [PATCH] test(#503): guard hardBreak representability through the MCP canon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #503 reported a hardBreak sent via updatePageJson being «silently cut» — no error, no line break — forcing the agent to split lines into separate paragraphs. A faithful repro shows the bug does NOT reproduce against develop: hardBreak is fully representable in the markdown canon in BOTH directions and survives the real Yjs write path. - pm -> md: the serializer (now in @docmost/prosemirror-markdown after the #293 STEP 5 consolidation) emits the CommonMark hard break ` \n`, not a bare `\n`. - md -> pm: marked tokenizes ` \n` to `
`, which generateJSON parses back to a hardBreak node in ONE paragraph (not split, not dropped). - updatePageJson (PM JSON -> applyDocToFragment = PMNode.fromJSON + updateYFragment, the real collab-session write encoder -> read back) keeps text + hardBreak + text in one paragraph. The issue's diagnosis points at packages/mcp/.../markdown-converter.ts as the serializer, but that file is a 15-line re-export shim since #293 STEP 5 — the diagnosis predates the converter consolidation that already closed both sides. P1 (semantic round-trip) + P2 (byte-fixpoint) hold, and the node already has broad property/corpus/golden coverage in the package. No converter change is warranted (switching the break form to `\`+newline would churn the whole #351 byte-fixpoint corpus against the established ` \n` repo convention for zero functional gain). This adds one integration guard at the MCP canon seam covering all three seams of the reported updatePageJson path. Mutation-verified: neutering the serializer arm or the importer `
` handling reddens seams 1-2; neutering the real applyDocToFragment write path reddens seam 3. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test/unit/docmost-md-roundtrip.test.mjs | 72 ++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) 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", + ); +});