Full document, spreadsheet, slideshow, and diagram tooling
1/**
2 * Regression tests for #719: version-history preview shows raw ProseMirror
3 * schema XML instead of rendered document.
4 *
5 * Before the fix: `previewDiv.textContent = fragment.toString()` dumped
6 * <paragraph indent="0"><heading level="1">Title</heading>…
7 * into the preview pane. Unreadable.
8 *
9 * After: `renderYjsFragmentAsHtml(fragment)` walks the Yjs XmlFragment and
10 * emits readable HTML (h1/p/ul/li/blockquote/table etc.).
11 *
12 * These tests use duck-typed mock nodes (nodeName + getAttribute + toArray)
13 * rather than instantiating real Yjs objects — keeps the tests hermetic and
14 * documents the exact interface the renderer depends on.
15 */
16import { describe, it, expect } from 'vitest';
17import { renderYjsFragmentAsHtml } from '../src/version-panel.js';
18
19function mockText(text: string) {
20 return Object.assign(
21 { toString: () => text },
22 { constructor: { name: 'YXmlText' } },
23 );
24}
25
26function mockElement(
27 nodeName: string,
28 attrs: Record<string, string>,
29 children: unknown[],
30) {
31 return {
32 nodeName,
33 getAttribute: (k: string) => attrs[k],
34 toArray: () => children,
35 };
36}
37
38function mockFragment(children: unknown[]) {
39 return { toArray: () => children };
40}
41
42describe('#719 — renderYjsFragmentAsHtml', () => {
43 it('renders a heading as <hN> with its level', () => {
44 const frag = mockFragment([
45 mockElement('heading', { level: '1' }, [mockText('Title')]),
46 mockElement('heading', { level: '2' }, [mockText('Sub')]),
47 mockElement('heading', { level: '3' }, [mockText('Deep')]),
48 ]);
49 const html = renderYjsFragmentAsHtml(frag);
50 expect(html).toBe('<h1>Title</h1><h2>Sub</h2><h3>Deep</h3>');
51 });
52
53 it('clamps heading level to 1..6', () => {
54 const frag = mockFragment([
55 mockElement('heading', { level: '0' }, [mockText('A')]),
56 mockElement('heading', { level: '99' }, [mockText('B')]),
57 ]);
58 expect(renderYjsFragmentAsHtml(frag)).toBe('<h1>A</h1><h6>B</h6>');
59 });
60
61 it('renders paragraphs', () => {
62 const frag = mockFragment([
63 mockElement('paragraph', {}, [mockText('hello')]),
64 mockElement('paragraph', {}, [mockText('world')]),
65 ]);
66 expect(renderYjsFragmentAsHtml(frag)).toBe('<p>hello</p><p>world</p>');
67 });
68
69 it('renders bullet lists with list items', () => {
70 const frag = mockFragment([
71 mockElement('bulletList', {}, [
72 mockElement('listItem', {}, [
73 mockElement('paragraph', {}, [mockText('alpha')]),
74 ]),
75 mockElement('listItem', {}, [
76 mockElement('paragraph', {}, [mockText('beta')]),
77 ]),
78 ]),
79 ]);
80 expect(renderYjsFragmentAsHtml(frag)).toBe(
81 '<ul><li><p>alpha</p></li><li><p>beta</p></li></ul>',
82 );
83 });
84
85 it('renders tables with tr/th/td', () => {
86 const frag = mockFragment([
87 mockElement('table', {}, [
88 mockElement('tableRow', {}, [
89 mockElement('tableHeader', {}, [
90 mockElement('paragraph', {}, [mockText('Name')]),
91 ]),
92 ]),
93 mockElement('tableRow', {}, [
94 mockElement('tableCell', {}, [
95 mockElement('paragraph', {}, [mockText('Alice')]),
96 ]),
97 ]),
98 ]),
99 ]);
100 const html = renderYjsFragmentAsHtml(frag);
101 expect(html).toContain('<table>');
102 expect(html).toContain('<tr><th><p>Name</p></th></tr>');
103 expect(html).toContain('<tr><td><p>Alice</p></td></tr>');
104 });
105
106 it('renders codeBlock as <pre><code>', () => {
107 const frag = mockFragment([
108 mockElement('codeBlock', {}, [mockText('const x = 1')]),
109 ]);
110 expect(renderYjsFragmentAsHtml(frag)).toBe('<pre><code>const x = 1</code></pre>');
111 });
112
113 it('renders images with src + alt', () => {
114 const frag = mockFragment([
115 mockElement('image', { src: 'photo.jpg', alt: 'A photo' }, []),
116 ]);
117 expect(renderYjsFragmentAsHtml(frag)).toBe('<img src="photo.jpg" alt="A photo">');
118 });
119
120 it('escapes HTML-special characters in text nodes', () => {
121 const frag = mockFragment([
122 mockElement('paragraph', {}, [mockText('<script>alert(1)</script> & "x"')]),
123 ]);
124 expect(renderYjsFragmentAsHtml(frag)).toBe(
125 '<p><script>alert(1)</script> & "x"</p>',
126 );
127 });
128
129 it('falls back to <div> for unknown node types (content still shows)', () => {
130 const frag = mockFragment([
131 mockElement('mysteryBlock', {}, [mockText('still visible')]),
132 ]);
133 expect(renderYjsFragmentAsHtml(frag)).toBe('<div>still visible</div>');
134 });
135
136 it('never emits ProseMirror schema tags (e.g. <paragraph>, <heading>) in output', () => {
137 const frag = mockFragment([
138 mockElement('heading', { level: '1' }, [mockText('Outline Test')]),
139 mockElement('paragraph', {}, [mockText('body')]),
140 mockElement('bulletList', {}, [
141 mockElement('listItem', {}, [
142 mockElement('paragraph', {}, [mockText('x')]),
143 ]),
144 ]),
145 ]);
146 const html = renderYjsFragmentAsHtml(frag);
147 // The regression we're guarding against:
148 expect(html).not.toMatch(/<paragraph\b/);
149 expect(html).not.toMatch(/<heading\b/);
150 expect(html).not.toMatch(/<bulletList\b/);
151 expect(html).not.toMatch(/<listItem\b/);
152 });
153
154 it('renders an empty fragment as the empty string', () => {
155 expect(renderYjsFragmentAsHtml(mockFragment([]))).toBe('');
156 });
157});