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.
121 lines
5.5 KiB
TypeScript
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();
|
|
});
|
|
});
|