// @vitest-environment jsdom import { docToMarkdown, docToPlainText, editorSchema } from '@dorfteich/shared'; import { Editor, getSchema } from '@tiptap/core'; import { Collaboration } from '@tiptap/extension-collaboration'; import { yXmlFragmentToProseMirrorRootNode } from 'y-prosemirror'; import { describe, expect, it } from 'vitest'; import * as Y from 'yjs'; import { documentExtensions } from './document-extensions'; /** * `editorSchema` (packages/shared, #24) is the one document schema the api * decodes Yjs states against (`apps/api/src/pages/yjs-content.ts`, #23). The * TipTap editor (#25) cannot reuse that `Schema` instance directly — TipTap * always builds its own from extensions — so these tests guard the bridge in * `spec-utils.ts`/`nodes/*`/`marks.ts` against silent drift. */ describe('web editor schema matches editorSchema (issue #25)', () => { const builtSchema = getSchema(documentExtensions); const nodeNames = Object.keys(editorSchema.spec.nodes.toObject()); const markNames = Object.keys(editorSchema.spec.marks.toObject()); it('defines exactly the same node types', () => { expect(Object.keys(builtSchema.nodes).sort()).toEqual([...nodeNames].sort()); }); it('defines exactly the same mark types', () => { expect(Object.keys(builtSchema.marks).sort()).toEqual([...markNames].sort()); }); it.each(nodeNames)('node "%s" has the same content expression, group, and attrs', (name) => { const canonical = editorSchema.spec.nodes.get(name)!; const built = builtSchema.nodes[name]!.spec; expect(built.content ?? undefined).toBe(canonical.content ?? undefined); expect(built.group ?? undefined).toBe(canonical.group ?? undefined); expect(Object.keys(built.attrs ?? {}).sort()).toEqual( Object.keys(canonical.attrs ?? {}).sort(), ); }); it.each(markNames)('mark "%s" has the same attrs', (name) => { const canonical = editorSchema.spec.marks.get(name)!; const built = builtSchema.marks[name]!.spec; expect(Object.keys(built.attrs ?? {}).sort()).toEqual( Object.keys(canonical.attrs ?? {}).sort(), ); }); }); describe('Yjs round-trip between the web editor and the api decoder (issues #23/#25)', () => { /** Drives a real headless TipTap `Editor` (the exact client code path, * `@tiptap/extension-collaboration` + `@tiptap/y-tiptap`) and decodes the * resulting Yjs state the same way the api does (`y-prosemirror` against * `editorSchema`) — proving the two independently-built bindings agree on * the wire format for the node/mark set this schema actually uses. */ it('preserves headings, lists, task items, marks, and tables', () => { const ydoc = new Y.Doc(); const editor = new Editor({ element: document.createElement('div'), extensions: [ ...documentExtensions, Collaboration.configure({ document: ydoc, field: 'default' }), ], }); editor.commands.setContent({ type: 'doc', content: [ { type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Title' }] }, { type: 'paragraph', content: [ { type: 'text', text: 'bold', marks: [{ type: 'bold' }] }, { type: 'text', text: ' and a ' }, { type: 'text', text: 'link', marks: [{ type: 'link', attrs: { href: 'https://example.org' } }], }, ], }, { type: 'bullet_list', content: [ { type: 'list_item', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'item one' }] }], }, ], }, { type: 'task_list', content: [ { type: 'task_item', attrs: { checked: true }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'done' }] }], }, ], }, { type: 'table', content: [ { type: 'table_row', content: [ { type: 'table_cell', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'cell' }] }], }, ], }, ], }, ], }); const state = Y.encodeStateAsUpdate(ydoc); editor.destroy(); const decodeDoc = new Y.Doc(); Y.applyUpdate(decodeDoc, state); const decoded = yXmlFragmentToProseMirrorRootNode( decodeDoc.getXmlFragment('default'), editorSchema, ); decodeDoc.destroy(); expect(decoded.textContent).toContain('Title'); expect(decoded.textContent).toContain('item one'); expect(decoded.textContent).toContain('done'); expect(decoded.textContent).toContain('cell'); const markdown = docToMarkdown(decoded); expect(markdown).toContain('## Title'); expect(markdown).toContain('**bold**'); expect(docToPlainText(decoded)).toContain('bold and a link'); }); });