import { describe, expect, it } from 'vitest'; import { generateJSON } from '@tiptap/html'; // Import the extension list DIRECTLY from src (NOT the barrel, which pulls in // collaboration.ts and mutates global DOM at import time). import { docmostExtensions } from '../packages/docmost-client/src/lib/docmost-schema.js'; // --------------------------------------------------------------------------- // These exercise the schema's parse-CLOSURES (getAttrs / parseHTML), which are // otherwise uncalled by the declarative-spec tests (docmost-schema.test.ts only // covers sanitizeCssColor / clampCalloutType). generateJSON(html, extensions) // runs the real HTML -> ProseMirror import the way markdownToProseMirror does. // // Report §2 gaps (prosemirror-schema): // - TextStyle.getAttrs: a colored imports as a textStyle mark, but a // comment span (data-comment-id) and a mention span (data-type=mention) are // NOT swallowed (their anchors must survive — SPEC §3); // - Highlight: background-color is read into the color attr (its own path, // distinct from textStyle); // - Column.width: data-width="N" is parseFloat'd to a NUMBER (string->number). // --------------------------------------------------------------------------- /** Import an HTML fragment and return the doc's first paragraph's first inline. */ function firstInline(html: string): any { const doc = generateJSON(html, docmostExtensions); return doc.content[0].content[0]; } describe('TextStyle.getAttrs (colored span import)', () => { it('imports a plain colored as a textStyle mark with the color', () => { const inline = firstInline('

red

'); expect(inline.text).toBe('red'); expect(inline.marks).toEqual([ { type: 'textStyle', attrs: { color: '#ff0000' } }, ]); }); it('does NOT swallow a comment span (data-comment-id) — it stays a comment mark', () => { const inline = firstInline('

x

'); // The comment anchor must survive: getAttrs returns false for this span, so // the Comment mark claims it instead of textStyle (no silent drop, SPEC §3). const markTypes = (inline.marks ?? []).map((m: any) => m.type); expect(markTypes).toContain('comment'); expect(markTypes).not.toContain('textStyle'); const comment = inline.marks.find((m: any) => m.type === 'comment'); expect(comment.attrs.commentId).toBe('cid-9'); }); it('a comment span that ALSO carries a color is still a comment (data-comment-id guard wins)', () => { const inline = firstInline( '

x

', ); const markTypes = (inline.marks ?? []).map((m: any) => m.type); expect(markTypes).toContain('comment'); // textStyle.getAttrs short-circuits on data-comment-id, so the color span is // NOT additionally claimed as a textStyle mark. expect(markTypes).not.toContain('textStyle'); }); it('does NOT swallow a mention span (data-type=mention) — it stays a mention node', () => { const inline = firstInline( '

@Alice

', ); expect(inline.type).toBe('mention'); expect(inline.attrs.id).toBe('m1'); expect(inline.attrs.label).toBe('Alice'); }); }); describe('Highlight background-color guard', () => { it('reads background-color into the highlight color attr', () => { const inline = firstInline( '

h

', ); const hl = (inline.marks ?? []).find((m: any) => m.type === 'highlight'); expect(hl).toBeDefined(); expect(hl.attrs.color).toBe('#00ff00'); }); it('a bare with no background-color imports with color null', () => { const inline = firstInline('

h

'); const hl = (inline.marks ?? []).find((m: any) => m.type === 'highlight'); expect(hl).toBeDefined(); expect(hl.attrs.color).toBeNull(); }); it('reads data-color and runs it through the sanitizeCssColor guard', () => { // The data-color attribute path bypasses the browser CSS parser and hits // the guarded parseHTML directly, so a well-formed color passes verbatim... const ok = firstInline('

h

'); const okHl = (ok.marks ?? []).find((m: any) => m.type === 'highlight'); expect(okHl.attrs.color).toBe('#abcdef'); // ...and a CSS-injection breakout payload is rejected, leaving color null. const bad = firstInline('

h

'); const badHl = (bad.marks ?? []).find((m: any) => m.type === 'highlight'); expect(badHl.attrs.color).toBeNull(); }); }); describe('Column.width parseFloat (string -> number)', () => { it('parses data-width="42.5" into the NUMBER 42.5 (not the string)', () => { const doc = generateJSON( '

c

', docmostExtensions, ); const column = doc.content[0].content[0]; expect(column.type).toBe('column'); expect(column.attrs.width).toBe(42.5); expect(typeof column.attrs.width).toBe('number'); }); it('a column without data-width imports with width null', () => { const doc = generateJSON( '

c

', docmostExtensions, ); const column = doc.content[0].content[0]; expect(column.attrs.width).toBeNull(); }); });