Files
docmost-sync/test/docmost-schema-closures.test.ts
vvzvlad d9d8538846 test(sync): implement test-strategy Phase 1-2 (pure unit/golden/property), +102 tests
Work through test-strategy-report.md, high-ROI no-refactor subset (no regen).

- R-Infra: vitest resolve.alias docmost-client -> packages/docmost-client/src
  (fixes the dist-vs-src coverage artifact: canonicalize 0% -> real)
- R-Cfg-1: export parseArgs + tests
- canonicalize: align family / comment.resolved kept / link non-default +
  fixpoint & docsCanonicallyEqual reflexive/symmetric properties (0 -> 100%)
- markdown-converter golden matrix: columns/embed/audio/pdf, drawio data-align
  rule, inline-mark matrix, textAlign, escaping idempotence, table sanitization
  (61 -> 79%)
- schema parse-closures via generateJSON (TextStyle/comment/mention/Highlight/Column)
- node-ops (immutability, table edge cases, makeFreshId property), transforms
  (setCalloutRange/insertMarkerAfter/commentsToFootnotes + renumber property)
- stabilize normalize-on-write fixpoint (0 -> 100%); diff coarse-fallback;
  client-utils; firstDivergence; corpus fixtures details/columns/mention
- 593 -> 695 green; build clean; corpus STABLE

Deferred (Phase 3-4, refactor-gated): pull/collab/client-REST/git-merge integration.
2026-06-17 01:01:26 +03:00

121 lines
5.5 KiB
TypeScript

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 <span> 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 <span> as a textStyle mark with the color', () => {
const inline = firstInline('<p><span style="color: #ff0000">red</span></p>');
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('<p><span data-comment-id="cid-9">x</span></p>');
// 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(
'<p><span data-comment-id="cid-9" style="color: #ff0000">x</span></p>',
);
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(
'<p><span data-type="mention" data-id="m1" data-label="Alice">@Alice</span></p>',
);
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(
'<p><mark style="background-color: #00ff00">h</mark></p>',
);
const hl = (inline.marks ?? []).find((m: any) => m.type === 'highlight');
expect(hl).toBeDefined();
expect(hl.attrs.color).toBe('#00ff00');
});
it('a bare <mark> with no background-color imports with color null', () => {
const inline = firstInline('<p><mark>h</mark></p>');
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('<p><mark data-color="#abcdef">h</mark></p>');
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('<p><mark data-color="red; background: url(x)">h</mark></p>');
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(
'<div data-type="columns"><div data-type="column" data-width="42.5"><p>c</p></div></div>',
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(
'<div data-type="columns"><div data-type="column"><p>c</p></div></div>',
docmostExtensions,
);
const column = doc.content[0].content[0];
expect(column.attrs.width).toBeNull();
});
});