From 54a367d4239211207dba8be12944e5292378ba56 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 06:49:38 +0000 Subject: [PATCH] test: add milkdown markdown roundtrip integration coverage Co-authored-by: rfc1437 <774975+rfc1437@users.noreply.github.com> --- .../MilkdownEditor/MilkdownEditor.tsx | 14 ++-- src/renderer/utils/markdownEscape.ts | 34 +++++++++ .../components/MilkdownEditor.test.ts | 11 +++ .../MilkdownMarkdownRoundTrip.test.ts | 74 +++++++++++++++++++ 4 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 tests/renderer/components/MilkdownMarkdownRoundTrip.test.ts diff --git a/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx b/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx index 53d3981..92cd62f 100644 --- a/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx +++ b/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx @@ -22,7 +22,7 @@ import { imageResolverPlugin } from '../../plugins/imageResolverPlugin'; import '../../macros'; import './MilkdownEditor.css'; import { InsertModal } from '../InsertModal'; -import { unescapeMacroSyntax } from '../../utils/markdownEscape'; +import { normalizeMilkdownMarkdown } from '../../utils/markdownEscape'; // Remark plugin to force tight lists (no blank lines between list items) const remarkTightListsPlugin: Plugin<[Record], Root> = () => { @@ -68,11 +68,11 @@ export const shouldPropagateMilkdownChange = ({ externalContent, hasUserInteracted, }: MilkdownChangePropagationInput): boolean => { - const unescaped = unescapeMacroSyntax(markdown); - const prevUnescaped = unescapeMacroSyntax(prevMarkdown); - const externalUnescaped = unescapeMacroSyntax(externalContent); + const normalized = normalizeMilkdownMarkdown(markdown); + const prevNormalized = normalizeMilkdownMarkdown(prevMarkdown); + const externalNormalized = normalizeMilkdownMarkdown(externalContent); - if (unescaped === prevUnescaped) { + if (normalized === prevNormalized) { return false; } @@ -80,7 +80,7 @@ export const shouldPropagateMilkdownChange = ({ return false; } - return unescaped !== externalUnescaped; + return normalized !== externalNormalized; }; interface EditorToolbarProps { @@ -329,7 +329,7 @@ const MilkdownProviderInner: React.FC = ({ if (shouldEmit) { isInternalChange.current = true; - onChangeRef.current(unescapeMacroSyntax(markdown)); + onChangeRef.current(normalizeMilkdownMarkdown(markdown)); } }); }) diff --git a/src/renderer/utils/markdownEscape.ts b/src/renderer/utils/markdownEscape.ts index 253785f..5489546 100644 --- a/src/renderer/utils/markdownEscape.ts +++ b/src/renderer/utils/markdownEscape.ts @@ -37,3 +37,37 @@ export function unescapeMacroSyntax(markdown: string): string { return result; } + +const unorderedListItemPattern = /^\s{0,3}[-+*]\s/; +const orderedListItemPattern = /^\s{0,3}\d+\.\s/; + +function getListLineType(line: string): 'ordered' | 'unordered' | null { + if (unorderedListItemPattern.test(line)) return 'unordered'; + if (orderedListItemPattern.test(line)) return 'ordered'; + return null; +} + +export function normalizeMilkdownMarkdown(markdown: string): string { + const unescaped = unescapeMacroSyntax(markdown); + const lines = unescaped.split('\n'); + const normalized: string[] = []; + + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + + const previousListType = i > 0 ? getListLineType(lines[i - 1]) : null; + const nextListType = i < lines.length - 1 ? getListLineType(lines[i + 1]) : null; + if (line === '' && previousListType !== null && previousListType === nextListType) { + continue; + } + + if (line === '>') { + normalized.push('> '); + continue; + } + + normalized.push(line); + } + + return normalized.join('\n'); +} diff --git a/tests/renderer/components/MilkdownEditor.test.ts b/tests/renderer/components/MilkdownEditor.test.ts index fd3eb82..eb2ce85 100644 --- a/tests/renderer/components/MilkdownEditor.test.ts +++ b/tests/renderer/components/MilkdownEditor.test.ts @@ -45,4 +45,15 @@ describe('shouldPropagateMilkdownChange', () => { expect(result).toBe(false); }); + + it('does not propagate list-spacing-only normalization differences', () => { + const result = shouldPropagateMilkdownChange({ + markdown: '- one\n\n- two', + prevMarkdown: '', + externalContent: '- one\n- two', + hasUserInteracted: true, + }); + + expect(result).toBe(false); + }); }); diff --git a/tests/renderer/components/MilkdownMarkdownRoundTrip.test.ts b/tests/renderer/components/MilkdownMarkdownRoundTrip.test.ts new file mode 100644 index 0000000..cb2c9b4 --- /dev/null +++ b/tests/renderer/components/MilkdownMarkdownRoundTrip.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import matter from 'gray-matter'; +import { Editor, defaultValueCtx, parserCtx, remarkPluginsCtx, remarkStringifyOptionsCtx, rootCtx, serializerCtx } from '@milkdown/kit/core'; +import { commonmark } from '@milkdown/kit/preset/commonmark'; +import { gfm } from '@milkdown/kit/preset/gfm'; +import type { Plugin } from 'unified'; +import type { Root, List, ListItem } from 'mdast'; +import { visit } from 'unist-util-visit'; +import { normalizeMilkdownMarkdown } from '../../../src/renderer/utils/markdownEscape'; + +const wxrRefDir = '/home/runner/work/bDS/bDS/tests/assets/wxr-ref'; + +const remarkTightListsPlugin: Plugin<[Record], Root> = () => { + return (tree: Root) => { + visit(tree, 'list', (node) => { + const listNode = node as List; + listNode.spread = false; + for (const child of listNode.children) { + if (child.type === 'listItem') { + (child as ListItem).spread = false; + } + } + }); + }; +}; + +describe('Milkdown markdown round trip', () => { + let root: HTMLDivElement; + + beforeEach(() => { + root = document.createElement('div'); + document.body.appendChild(root); + }); + + afterEach(() => { + root.remove(); + }); + + it('preserves reference markdown bodies across load -> internal -> markdown cycle', async () => { + const files = fs.readdirSync(wxrRefDir).filter((file) => file.endsWith('.md')); + + for (const file of files) { + const raw = fs.readFileSync(path.join(wxrRefDir, file), 'utf-8'); + const { content } = matter(raw); + + const editor = await Editor.make() + .config((ctx) => { + ctx.set(rootCtx, root); + ctx.set(defaultValueCtx, content); + ctx.set(remarkStringifyOptionsCtx, { + bullet: '-', + listItemIndent: 'one', + }); + ctx.set(remarkPluginsCtx, [{ plugin: remarkTightListsPlugin, options: {} }]); + }) + .use(commonmark) + .use(gfm) + .create(); + + const serialized = editor.action((ctx) => { + const parser = ctx.get(parserCtx); + const serializer = ctx.get(serializerCtx); + const doc = parser(content); + return normalizeMilkdownMarkdown(serializer(doc)); + }); + + await editor.destroy(); + + expect(serialized, `round trip mismatch for ${file}`).toBe(content); + } + }, 30000); +});