test(docmost-client): add unit tests for pure lib modules

Add 230 Vitest unit tests covering the dependency-light, pure modules of
packages/docmost-client/src/lib, imported directly from source:

- node-ops: tree addressing, immutability/clone guarantees, table ops,
  throw-vs-noop contracts (87)
- transforms: commentsToFootnotes reading-order renumbering, insertMarkerAfter
  mark-preserving split, setCalloutRange regex statefulness (43)
- json-edit: applyTextEdits literal $&/$1, error distinction, immutability (17)
- page-lock: async per-page mutex ordering and error isolation (6)
- filters: filterPage/filterComment truthiness traps, filterSearchResult (19)
- markdown-converter: per-node golden matrix + edge cases (41)
- markdown-document envelope: round-trip, CRLF, malformed-JSON throws (17)

No source files changed. The pre-existing test/markdown-document.test.ts is
left intact; new envelope coverage lives in markdown-document-envelope.test.ts.
Full suite: 16 files / 279 tests green.
This commit is contained in:
vvzvlad
2026-06-16 22:10:06 +03:00
parent c6edd73324
commit cc13c94f53
7 changed files with 2867 additions and 0 deletions

234
test/filters.test.ts Normal file
View File

@@ -0,0 +1,234 @@
import { describe, expect, it } from 'vitest';
import {
filterComment,
filterGroup,
filterPage,
filterSearchResult,
filterSpace,
filterWorkspace,
} from '../packages/docmost-client/src/lib/filters.js';
describe('filterPage', () => {
const basePage = {
id: 'pg1',
slugId: 'slug-1',
title: 'Title',
parentPageId: 'parent-1',
spaceId: 'space-1',
isLocked: false,
createdAt: '2024-01-01',
updatedAt: '2024-01-02',
deletedAt: null,
// Extra fields that must be dropped by the filter.
secret: 'should-not-leak',
};
it('omits the content key when no content arg is given', () => {
const result = filterPage(basePage);
expect(result).not.toHaveProperty('content');
expect(result).not.toHaveProperty('secret');
expect(result.id).toBe('pg1');
});
it('includes an empty-string content (truthiness trap: empty string is kept)', () => {
const result = filterPage(basePage, '');
expect(result).toHaveProperty('content');
expect(result.content).toBe('');
});
it('includes a non-empty content string', () => {
const result = filterPage(basePage, '# Markdown');
expect(result.content).toBe('# Markdown');
});
it('omits content when it is not a string', () => {
// A non-string (e.g. an object passed by mistake) must be dropped.
const result = filterPage(basePage, { junk: true } as unknown as string);
expect(result).not.toHaveProperty('content');
});
it('omits subpages when undefined', () => {
const result = filterPage(basePage);
expect(result).not.toHaveProperty('subpages');
});
it('omits subpages when empty array', () => {
const result = filterPage(basePage, undefined, []);
expect(result).not.toHaveProperty('subpages');
});
it('maps non-empty subpages to { id, title } only', () => {
const result = filterPage(basePage, undefined, [
{ id: 's1', title: 'Sub 1', extra: 'drop-me' },
{ id: 's2', title: 'Sub 2' },
]);
expect(result.subpages).toEqual([
{ id: 's1', title: 'Sub 1' },
{ id: 's2', title: 'Sub 2' },
]);
});
});
describe('filterComment', () => {
const baseComment = {
id: 'c1',
pageId: 'pg1',
content: 'original content',
creatorId: 'u1',
createdAt: '2024-01-01',
};
it('overrides comment.content with markdownContent when provided', () => {
const result = filterComment(baseComment, 'markdown version');
expect(result.content).toBe('markdown version');
});
it('falls back to comment.content when markdownContent is undefined', () => {
const result = filterComment(baseComment, undefined);
expect(result.content).toBe('original content');
});
it('keeps an empty-string markdownContent (uses ?? not ||)', () => {
// `??` only falls back on null/undefined, so "" must be preserved.
const result = filterComment(baseComment, '');
expect(result.content).toBe('');
});
it('applies defaults for selection/type/parentCommentId/editedAt/resolvedAt/resolvedById', () => {
const result = filterComment(baseComment);
expect(result.selection).toBeNull();
expect(result.type).toBe('page');
expect(result.parentCommentId).toBeNull();
expect(result.editedAt).toBeNull();
expect(result.resolvedAt).toBeNull();
expect(result.resolvedById).toBeNull();
});
it('passes through provided optional values', () => {
const result = filterComment({
...baseComment,
selection: 'some text',
type: 'inline',
parentCommentId: 'c0',
editedAt: '2024-02-01',
resolvedAt: '2024-03-01',
resolvedById: 'u9',
});
expect(result.selection).toBe('some text');
expect(result.type).toBe('inline');
expect(result.parentCommentId).toBe('c0');
expect(result.editedAt).toBe('2024-02-01');
expect(result.resolvedAt).toBe('2024-03-01');
expect(result.resolvedById).toBe('u9');
});
it('returns null for creatorName when creator is absent', () => {
const result = filterComment(baseComment);
expect(result.creatorName).toBeNull();
});
it('reads the nested creator?.name when present', () => {
const result = filterComment({
...baseComment,
creator: { name: 'Alice' },
});
expect(result.creatorName).toBe('Alice');
});
});
describe('filterSearchResult', () => {
const baseResult = {
id: 'r1',
title: 'Result',
parentPageId: 'p0',
createdAt: '2024-01-01',
updatedAt: '2024-01-02',
rank: 0.9,
highlight: '<em>Result</em>',
};
it('reads nested space?.id and space?.name when space is present', () => {
const result = filterSearchResult({
...baseResult,
space: { id: 'sp1', name: 'Space One' },
});
expect(result.spaceId).toBe('sp1');
expect(result.spaceName).toBe('Space One');
});
it('returns undefined for space fields when space is absent (no throw)', () => {
const result = filterSearchResult(baseResult);
expect(result.spaceId).toBeUndefined();
expect(result.spaceName).toBeUndefined();
});
});
describe('flat pluckers (no branching)', () => {
it('filterWorkspace plucks the expected shape', () => {
const result = filterWorkspace({
id: 'w1',
name: 'WS',
description: 'desc',
defaultSpaceId: 'sp1',
createdAt: '2024-01-01',
updatedAt: '2024-01-02',
deletedAt: null,
extra: 'drop',
});
expect(result).toEqual({
id: 'w1',
name: 'WS',
description: 'desc',
defaultSpaceId: 'sp1',
createdAt: '2024-01-01',
updatedAt: '2024-01-02',
deletedAt: null,
});
});
it('filterSpace plucks the expected shape', () => {
const result = filterSpace({
id: 'sp1',
name: 'Space',
description: 'desc',
slug: 'space',
visibility: 'open',
createdAt: '2024-01-01',
updatedAt: '2024-01-02',
deletedAt: null,
extra: 'drop',
});
expect(result).toEqual({
id: 'sp1',
name: 'Space',
description: 'desc',
slug: 'space',
visibility: 'open',
createdAt: '2024-01-01',
updatedAt: '2024-01-02',
deletedAt: null,
});
});
it('filterGroup plucks the expected shape', () => {
const result = filterGroup({
id: 'g1',
name: 'Group',
description: 'desc',
workspaceId: 'w1',
createdAt: '2024-01-01',
updatedAt: '2024-01-02',
deletedAt: null,
extra: 'drop',
});
expect(result).toEqual({
id: 'g1',
name: 'Group',
description: 'desc',
workspaceId: 'w1',
createdAt: '2024-01-01',
updatedAt: '2024-01-02',
deletedAt: null,
});
});
});

288
test/json-edit.test.ts Normal file
View File

@@ -0,0 +1,288 @@
import { describe, expect, it } from 'vitest';
import { applyTextEdits } from '../packages/docmost-client/src/lib/json-edit.js';
// Helper: build a ProseMirror text node.
function text(value: string, extra: Record<string, unknown> = {}) {
return { type: 'text', text: value, ...extra };
}
// Helper: a simple doc with a single paragraph containing one text node.
function singleParagraph(value: string) {
return {
type: 'doc',
content: [{ type: 'paragraph', content: [text(value)] }],
};
}
describe('applyTextEdits', () => {
describe('single match', () => {
it('replaces an exact single match and reports one replacement', () => {
const doc = singleParagraph('hello world');
const { doc: result, results } = applyTextEdits(doc, [
{ find: 'world', replace: 'there' },
]);
expect(result.content[0].content[0].text).toBe('hello there');
expect(results).toEqual([{ find: 'world', replacements: 1 }]);
});
it('preserves node id/marks around the edited text', () => {
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
attrs: { id: 'p1' },
content: [
text('old value', { marks: [{ type: 'bold' }], custom: 42 }),
],
},
],
};
const { doc: result } = applyTextEdits(doc, [
{ find: 'old', replace: 'new' },
]);
const node = result.content[0].content[0];
expect(node.text).toBe('new value');
expect(node.marks).toEqual([{ type: 'bold' }]);
expect(node.custom).toBe(42);
expect(result.content[0].attrs).toEqual({ id: 'p1' });
});
});
describe('replaceAll', () => {
it('replaces every occurrence inside a single node when replaceAll is set', () => {
const doc = singleParagraph('a a a');
const { doc: result, results } = applyTextEdits(doc, [
{ find: 'a', replace: 'b', replaceAll: true },
]);
expect(result.content[0].content[0].text).toBe('b b b');
expect(results).toEqual([{ find: 'a', replacements: 3 }]);
});
it('replaces occurrences across multiple separate text nodes', () => {
const doc = {
type: 'doc',
content: [
{ type: 'paragraph', content: [text('foo and foo')] },
{ type: 'paragraph', content: [text('also foo')] },
],
};
const { doc: result, results } = applyTextEdits(doc, [
{ find: 'foo', replace: 'bar', replaceAll: true },
]);
expect(result.content[0].content[0].text).toBe('bar and bar');
expect(result.content[1].content[0].text).toBe('also bar');
// 2 + 1 occurrences counted.
expect(results).toEqual([{ find: 'foo', replacements: 3 }]);
});
});
describe('multi-match without replaceAll', () => {
it('throws reporting the match count', () => {
const doc = singleParagraph('x x x');
expect(() => applyTextEdits(doc, [{ find: 'x', replace: 'y' }])).toThrow(
/matches 3 times/,
);
});
it('counts matches across nodes when deciding to throw', () => {
const doc = {
type: 'doc',
content: [
{ type: 'paragraph', content: [text('dup')] },
{ type: 'paragraph', content: [text('dup')] },
],
};
expect(() => applyTextEdits(doc, [{ find: 'dup', replace: 'z' }])).toThrow(
/matches 2 times/,
);
});
});
describe('zero match', () => {
it('throws "text not found" when text is absent entirely', () => {
const doc = singleParagraph('hello world');
expect(() =>
applyTextEdits(doc, [{ find: 'absent', replace: 'x' }]),
).toThrow(/text not found in the document/);
});
});
describe('text split across formatting runs', () => {
it('throws a distinct "spans multiple formatting runs" error', () => {
// "hello world" exists in the concatenated text but no single text node
// contains it: it is split across a bold run and a plain run.
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
text('hello ', { marks: [{ type: 'bold' }] }),
text('world'),
],
},
],
};
expect(() =>
applyTextEdits(doc, [{ find: 'hello world', replace: 'x' }]),
).toThrow(/spans multiple formatting runs/);
});
it('does not raise the spans error when text is genuinely missing', () => {
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [text('hello ', { marks: [{ type: 'bold' }] }), text('world')],
},
],
};
// "nope" is in neither the runs nor the concatenated text.
expect(() =>
applyTextEdits(doc, [{ find: 'nope', replace: 'x' }]),
).toThrow(/text not found in the document/);
});
});
describe('empty find', () => {
it('throws on an empty find string', () => {
const doc = singleParagraph('hello');
expect(() => applyTextEdits(doc, [{ find: '', replace: 'x' }])).toThrow(
/edit\.find must be a non-empty string/,
);
});
});
describe('literal replacement (regex foot-gun)', () => {
it('inserts $& literally without regex expansion', () => {
const doc = singleParagraph('price NUM here');
const { doc: result } = applyTextEdits(doc, [
{ find: 'NUM', replace: '$&100' },
]);
// If String.replace were used, "$&" would expand to the matched "NUM".
expect(result.content[0].content[0].text).toBe('price $&100 here');
});
it('inserts $1 literally without regex expansion', () => {
const doc = singleParagraph('token TKN end');
const { doc: result } = applyTextEdits(doc, [
{ find: 'TKN', replace: 'a$1b$$c' },
]);
expect(result.content[0].content[0].text).toBe('token a$1b$$c end');
});
it('inserts $& literally for replaceAll as well', () => {
const doc = singleParagraph('K K');
const { doc: result } = applyTextEdits(doc, [
{ find: 'K', replace: '[$&]', replaceAll: true },
]);
expect(result.content[0].content[0].text).toBe('[$&] [$&]');
});
});
describe('pruning emptied nodes', () => {
it('removes a text node that becomes empty after the edit', () => {
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [text('REMOVE'), text(' keep')],
},
],
};
const { doc: result } = applyTextEdits(doc, [
{ find: 'REMOVE', replace: '' },
]);
// The emptied node is pruned, leaving only the surviving node.
expect(result.content[0].content).toHaveLength(1);
expect(result.content[0].content[0].text).toBe(' keep');
});
});
describe('immutability', () => {
it('does not mutate the input document', () => {
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
attrs: { id: 'p1' },
content: [text('hello world', { marks: [{ type: 'bold' }] })],
},
],
};
const snapshot = structuredClone(doc);
applyTextEdits(doc, [{ find: 'world', replace: 'there' }]);
expect(doc).toEqual(snapshot);
});
});
describe('deeply nested recursion', () => {
it('finds and replaces text several levels deep', () => {
const doc = {
type: 'doc',
content: [
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [text('deep target value')],
},
],
},
],
},
],
},
],
},
],
};
const { doc: result, results } = applyTextEdits(doc, [
{ find: 'target', replace: 'goal' },
]);
const leaf =
result.content[0].content[0].content[0].content[0].content[0]
.content[0];
expect(leaf.text).toBe('deep goal value');
expect(results).toEqual([{ find: 'target', replacements: 1 }]);
});
});
describe('multiple edits in sequence', () => {
it('applies each edit and returns a result per edit in order', () => {
const doc = singleParagraph('alpha beta gamma');
const { doc: result, results } = applyTextEdits(doc, [
{ find: 'alpha', replace: 'A' },
{ find: 'gamma', replace: 'G' },
]);
expect(result.content[0].content[0].text).toBe('A beta G');
expect(results).toEqual([
{ find: 'alpha', replacements: 1 },
{ find: 'gamma', replacements: 1 },
]);
});
});
});

View File

@@ -0,0 +1,507 @@
import { describe, expect, it } from 'vitest';
// Import DIRECTLY from src (NOT the docmost-client barrel, which pulls in
// collaboration.ts and mutates global DOM at import time).
import { convertProseMirrorToMarkdown } from '../packages/docmost-client/src/lib/markdown-converter.js';
// Wrap a single node in a minimal ProseMirror doc. The top-level converter
// joins doc children with "\n\n" and then .trim()s the whole output, so a
// single-node doc yields exactly that node's rendered (and trimmed) string.
const doc = (...nodes: any[]) => ({ type: 'doc', content: nodes });
// Convenience: a text node, optionally with marks.
const text = (t: string, marks?: any[]) =>
marks ? { type: 'text', text: t, marks } : { type: 'text', text: t };
// Convenience: a paragraph wrapping inline children.
const para = (...inline: any[]) => ({ type: 'paragraph', content: inline });
describe('convertProseMirrorToMarkdown', () => {
// ---------------------------------------------------------------------------
describe('headings', () => {
it('emits the right number of "#" for levels 1-6', () => {
for (let level = 1; level <= 6; level++) {
const out = convertProseMirrorToMarkdown(
doc({ type: 'heading', attrs: { level }, content: [text('H')] }),
);
expect(out).toBe('#'.repeat(level) + ' H');
}
});
it('defaults to level 1 when level is missing', () => {
const out = convertProseMirrorToMarkdown(
doc({ type: 'heading', content: [text('NoLevel')] }),
);
expect(out).toBe('# NoLevel');
});
});
// ---------------------------------------------------------------------------
describe('text marks', () => {
it('bold', () => {
expect(
convertProseMirrorToMarkdown(doc(para(text('x', [{ type: 'bold' }])))),
).toBe('**x**');
});
it('italic', () => {
expect(
convertProseMirrorToMarkdown(doc(para(text('x', [{ type: 'italic' }])))),
).toBe('*x*');
});
it('strike', () => {
expect(
convertProseMirrorToMarkdown(doc(para(text('x', [{ type: 'strike' }])))),
).toBe('~~x~~');
});
it('inline code (sole mark) uses backtick span', () => {
expect(
convertProseMirrorToMarkdown(doc(para(text('x', [{ type: 'code' }])))),
).toBe('`x`');
});
it('code + another mark switches to nested HTML (no backtick form)', () => {
// marks array order drives nesting: bold first wraps, then code wraps that.
const out = convertProseMirrorToMarkdown(
doc(para(text('x', [{ type: 'bold' }, { type: 'code' }]))),
);
expect(out).toBe('<code><strong>x</strong></code>');
});
it('code + strike combo emits <code> wrapping <s>', () => {
const out = convertProseMirrorToMarkdown(
doc(para(text('x', [{ type: 'strike' }, { type: 'code' }]))),
);
expect(out).toBe('<code><s>x</s></code>');
});
});
// ---------------------------------------------------------------------------
describe('links', () => {
it('href only', () => {
const out = convertProseMirrorToMarkdown(
doc(para(text('site', [{ type: 'link', attrs: { href: 'https://e.com' } }]))),
);
expect(out).toBe('[site](https://e.com)');
});
it('href + title with an embedded double quote is escaped', () => {
const out = convertProseMirrorToMarkdown(
doc(
para(
text('site', [
{ type: 'link', attrs: { href: 'https://e.com', title: 'a "b" c' } },
]),
),
),
);
// The markdown link-title form escapes the inner " as \".
expect(out).toBe('[site](https://e.com "a \\"b\\" c")');
});
});
// ---------------------------------------------------------------------------
describe('image', () => {
it('percent-encodes spaces and parentheses in src', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'image',
attrs: { alt: 'cap', src: '/files/my pic (1).png' },
}),
);
// space -> %20, ( -> %28, ) -> %29
expect(out).toBe('![cap](/files/my%20pic%20%281%29.png)');
});
it('empty alt and missing src render harmlessly', () => {
const out = convertProseMirrorToMarkdown(doc({ type: 'image', attrs: {} }));
expect(out).toBe('![]()');
});
});
// ---------------------------------------------------------------------------
describe('codeBlock', () => {
it('with language', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'codeBlock',
attrs: { language: 'ts' },
content: [text('const a = 1;')],
}),
);
expect(out).toBe('```ts\nconst a = 1;\n```');
});
it('without language emits empty info string', () => {
const out = convertProseMirrorToMarkdown(
doc({ type: 'codeBlock', content: [text('plain')] }),
);
expect(out).toBe('```\nplain\n```');
});
it('strips ALL trailing newlines for idempotency', () => {
const out = convertProseMirrorToMarkdown(
doc({ type: 'codeBlock', content: [text('a\n\n\n')] }),
);
// Every trailing "\n" is removed, then exactly one is re-added by the fence.
expect(out).toBe('```\na\n```');
});
});
// ---------------------------------------------------------------------------
describe('lists', () => {
it('bullet list', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'bulletList',
content: [
{ type: 'listItem', content: [para(text('one'))] },
{ type: 'listItem', content: [para(text('two'))] },
],
}),
);
expect(out).toBe('- one\n- two');
});
it('ordered list numbers items sequentially', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'orderedList',
content: [
{ type: 'listItem', content: [para(text('a'))] },
{ type: 'listItem', content: [para(text('b'))] },
{ type: 'listItem', content: [para(text('c'))] },
],
}),
);
expect(out).toBe('1. a\n2. b\n3. c');
});
it('nested bullet list indents the child by the 2-col marker width', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
para(text('parent')),
{
type: 'bulletList',
content: [{ type: 'listItem', content: [para(text('child'))] }],
},
],
},
],
}),
);
// First line carries the marker; the nested list is indented 2 columns.
expect(out).toBe('- parent\n - child');
});
it('nested ordered list indents by the wider 3-col marker width', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
para(text('parent')),
{
type: 'orderedList',
content: [{ type: 'listItem', content: [para(text('child'))] }],
},
],
},
],
}),
);
// "1. " is 3 columns wide, so the continuation indent is 3 spaces.
expect(out).toBe('1. parent\n 1. child');
});
});
// ---------------------------------------------------------------------------
describe('task list', () => {
it('unchecked and checked items', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'taskList',
content: [
{ type: 'taskItem', attrs: { checked: false }, content: [para(text('todo'))] },
{ type: 'taskItem', attrs: { checked: true }, content: [para(text('done'))] },
],
}),
);
expect(out).toBe('- [ ] todo\n- [x] done');
});
it('empty task item keeps its marker', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'taskList',
content: [{ type: 'taskItem', attrs: { checked: false }, content: [] }],
}),
);
expect(out).toBe('- [ ]');
});
});
// ---------------------------------------------------------------------------
describe('blockquote', () => {
it('single paragraph quote prefixes the line', () => {
const out = convertProseMirrorToMarkdown(
doc({ type: 'blockquote', content: [para(text('quoted'))] }),
);
expect(out).toBe('> quoted');
});
it('multi-paragraph quote separates blocks with a bare ">" line', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'blockquote',
content: [para(text('first')), para(text('second'))],
}),
);
expect(out).toBe('> first\n>\n> second');
});
});
// ---------------------------------------------------------------------------
describe('breaks and rules', () => {
it('horizontal rule', () => {
expect(
convertProseMirrorToMarkdown(doc({ type: 'horizontalRule' })),
).toBe('---');
});
it('hard break emits two trailing spaces then newline', () => {
const out = convertProseMirrorToMarkdown(
doc(para(text('a'), { type: 'hardBreak' }, text('b'))),
);
expect(out).toBe('a \nb');
});
});
// ---------------------------------------------------------------------------
describe('tables', () => {
it('GFM table emits alignment markers derived from header cells', () => {
const headerRow = {
type: 'tableRow',
content: [
{ type: 'tableHeader', attrs: { align: 'left' }, content: [para(text('L'))] },
{ type: 'tableHeader', attrs: { align: 'center' }, content: [para(text('C'))] },
{ type: 'tableHeader', attrs: { align: 'right' }, content: [para(text('R'))] },
{ type: 'tableHeader', content: [para(text('N'))] },
],
};
const bodyRow = {
type: 'tableRow',
content: [
{ type: 'tableCell', content: [para(text('1'))] },
{ type: 'tableCell', content: [para(text('2'))] },
{ type: 'tableCell', content: [para(text('3'))] },
{ type: 'tableCell', content: [para(text('4'))] },
],
};
const out = convertProseMirrorToMarkdown(
doc({ type: 'table', content: [headerRow, bodyRow] }),
);
expect(out).toBe(
[
'| L | C | R | N |',
'| :-- | :-: | --: | --- |',
'| 1 | 2 | 3 | 4 |',
].join('\n'),
);
});
it('spanned table (colspan/rowspan) emits raw <table> HTML', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableHeader',
attrs: { colspan: 2 },
content: [para(text('wide'))],
},
],
},
{
type: 'tableRow',
content: [
{ type: 'tableCell', content: [para(text('a'))] },
{ type: 'tableCell', content: [para(text('b'))] },
],
},
],
}),
);
expect(out).toBe(
'<table><tbody>' +
'<tr><th colspan="2"><p>wide</p></th></tr>' +
'<tr><td><p>a</p></td><td><p>b</p></td></tr>' +
'</tbody></table>',
);
});
});
// ---------------------------------------------------------------------------
describe('callout and details', () => {
it('callout uses lowercased type fence', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'callout',
attrs: { type: 'WARNING' },
content: [para(text('beware'))],
}),
);
expect(out).toBe(':::warning\nbeware\n:::');
});
it('callout defaults to info', () => {
const out = convertProseMirrorToMarkdown(
doc({ type: 'callout', content: [para(text('hi'))] }),
);
expect(out).toBe(':::info\nhi\n:::');
});
it('details emits summary + content wrapped in <details>', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'details',
content: [
{ type: 'detailsSummary', content: [text('Title')] },
{ type: 'detailsContent', content: [para(text('Body'))] },
],
}),
);
// details joins its children with "\n"; summary opens, content closes.
expect(out).toBe('<details>\n<summary>Title</summary>\n\nBody\n</details>');
});
});
// ---------------------------------------------------------------------------
describe('math', () => {
it('inline math carries LaTeX in a text attr WITHOUT escaping < or >', () => {
const out = convertProseMirrorToMarkdown(
doc(para({ type: 'mathInline', attrs: { text: 'a < b' } })),
);
// < and > must NOT be HTML-escaped (idempotency); only & and " would be.
expect(out).toBe(
'<span data-type="mathInline" data-katex="true" text="a < b"></span>',
);
expect(out).not.toContain('&lt;');
});
it('block math carries LaTeX in a text attr WITHOUT escaping < or >', () => {
const out = convertProseMirrorToMarkdown(
doc({ type: 'mathBlock', attrs: { text: 'x > y & z' } }),
);
// & IS escaped (entity-significant), but < and > are NOT.
expect(out).toBe(
'<div data-type="mathBlock" data-katex="true" text="x > y &amp; z"></div>',
);
expect(out).not.toContain('&lt;');
expect(out).not.toContain('&gt;');
});
});
// ---------------------------------------------------------------------------
describe('inline atoms and media', () => {
it('mention emits schema span with data-* attrs and visible label', () => {
const out = convertProseMirrorToMarkdown(
doc(
para({
type: 'mention',
attrs: { id: 'u1', label: 'Alice', entityType: 'user' },
}),
),
);
expect(out).toBe(
'<span data-type="mention" data-id="u1" data-label="Alice" data-entity-type="user">@Alice</span>',
);
});
it('attachment emits div with schema data-attachment-* attrs', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'attachment',
attrs: { url: '/files/x.zip', name: 'x.zip', mime: 'application/zip', size: 99 },
}),
);
expect(out).toBe(
'<div data-type="attachment" data-attachment-url="/files/x.zip" ' +
'data-attachment-name="x.zip" data-attachment-mime="application/zip" ' +
'data-attachment-size="99"></div>',
);
});
it('video emits a <div>-wrapped <video> with schema attrs', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'video',
attrs: { src: '/v.mp4', alt: 'clip', width: 640 },
}),
);
expect(out).toBe(
'<div><video src="/v.mp4" aria-label="clip" width="640"></video></div>',
);
});
it('youtube emits a div[data-type="youtube"] with data-src', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'youtube',
attrs: { src: 'https://youtu.be/abc', width: 560, height: 315 },
}),
);
expect(out).toBe(
'<div data-type="youtube" data-src="https://youtu.be/abc" ' +
'data-width="560" data-height="315"></div>',
);
});
});
// ---------------------------------------------------------------------------
describe('edge cases', () => {
it('null content returns ""', () => {
expect(convertProseMirrorToMarkdown(null)).toBe('');
});
it('empty object returns ""', () => {
expect(convertProseMirrorToMarkdown({})).toBe('');
});
it('doc with no content returns ""', () => {
expect(convertProseMirrorToMarkdown({ type: 'doc' })).toBe('');
});
it('unknown node type falls back to children-only (no throw, text preserved)', () => {
const out = convertProseMirrorToMarkdown(
doc({ type: 'totallyUnknownType', content: [text('kept')] }),
);
expect(out).toBe('kept');
});
it('deeply nested structure does not stack-overflow', () => {
// Build a deeply nested bullet list (each level holds one nested list).
let node: any = { type: 'listItem', content: [para(text('leaf'))] };
for (let i = 0; i < 200; i++) {
node = {
type: 'listItem',
content: [para(text('lvl')), { type: 'bulletList', content: [node] }],
};
}
const root = doc({ type: 'bulletList', content: [node] });
expect(() => convertProseMirrorToMarkdown(root)).not.toThrow();
const out = convertProseMirrorToMarkdown(root);
expect(out).toContain('leaf');
expect(out.startsWith('- lvl')).toBe(true);
});
});
});

View File

@@ -0,0 +1,199 @@
import { describe, expect, it } from 'vitest';
// Import DIRECTLY from src (NOT the docmost-client barrel, which pulls in
// collaboration.ts and mutates global DOM at import time).
import {
serializeDocmostMarkdown,
parseDocmostMarkdown,
serializeDocmostMarkdownBody,
type DocmostMdMeta,
} from '../packages/docmost-client/src/lib/markdown-document.js';
const meta: DocmostMdMeta = {
version: 1,
pageId: 'p1',
slugId: 's1',
title: 'Hello',
spaceId: 'sp1',
parentPageId: null,
};
describe('serializeDocmostMarkdown / parseDocmostMarkdown', () => {
// ---------------------------------------------------------------------------
describe('round-trip', () => {
it('round-trips meta, body, and comments', () => {
const body = '# Title\n\nSome **body** text.';
const comments = [{ id: 'c1', text: 'a note' }];
const full = serializeDocmostMarkdown(meta, body, comments);
const parsed = parseDocmostMarkdown(full);
expect(parsed.meta).toEqual(meta);
expect(parsed.body).toBe(body);
expect(parsed.comments).toEqual(comments);
});
it('emits a comments block with [] even when there are no comments', () => {
const full = serializeDocmostMarkdown(meta, 'body', []);
expect(full).toContain('<!-- docmost:comments\n[]\n-->');
const parsed = parseDocmostMarkdown(full);
expect(parsed.comments).toEqual([]);
expect(parsed.body).toBe('body');
});
it('non-array comments arg is normalized to [] in the serialized output', () => {
const full = serializeDocmostMarkdown(meta, 'body', null as any);
expect(full).toContain('<!-- docmost:comments\n[]\n-->');
});
it('trims surrounding whitespace from the body on serialize', () => {
const full = serializeDocmostMarkdown(meta, '\n\n body \n\n', []);
const parsed = parseDocmostMarkdown(full);
expect(parsed.body).toBe('body');
});
});
// ---------------------------------------------------------------------------
describe('missing blocks (tolerant parsing)', () => {
it('missing meta block yields meta:null', () => {
const input = 'Just a body.\n\n<!-- docmost:comments\n[]\n-->\n';
const parsed = parseDocmostMarkdown(input);
expect(parsed.meta).toBeNull();
expect(parsed.body).toBe('Just a body.');
expect(parsed.comments).toEqual([]);
});
it('missing comments block yields comments:null and treats all as body', () => {
const input =
'<!-- docmost:meta\n' + JSON.stringify(meta) + '\n-->\n\nbody only';
const parsed = parseDocmostMarkdown(input);
expect(parsed.meta).toEqual(meta);
expect(parsed.comments).toBeNull();
expect(parsed.body).toBe('body only');
});
it('plain markdown with neither block: meta and comments null, whole input is body', () => {
const input = '# Plain\n\nNo envelope here.';
const parsed = parseDocmostMarkdown(input);
expect(parsed.meta).toBeNull();
expect(parsed.comments).toBeNull();
expect(parsed.body).toBe(input);
});
});
// ---------------------------------------------------------------------------
describe('CRLF normalization', () => {
it('parses a CRLF-encoded document the same as LF', () => {
const lf = serializeDocmostMarkdown(meta, 'line one\nline two', [
{ id: 'c1' },
]);
const crlf = lf.replace(/\n/g, '\r\n');
const parsed = parseDocmostMarkdown(crlf);
expect(parsed.meta).toEqual(meta);
expect(parsed.body).toBe('line one\nline two');
expect(parsed.comments).toEqual([{ id: 'c1' }]);
});
});
// ---------------------------------------------------------------------------
describe('only the final document-ending comments block is captured', () => {
it('an earlier literal docmost:comments opener inside the body stays in the body', () => {
// The body documents the format and contains a literal opener that does
// NOT end the document. Only the trailing block is treated as metadata.
const bodyWithLiteral =
'Here is how the format looks:\n\n<!-- docmost:comments\n[{"fake":true}]\n-->\n\nand more prose after it.';
const full = serializeDocmostMarkdown(meta, bodyWithLiteral, [
{ id: 'real' },
]);
const parsed = parseDocmostMarkdown(full);
// The real (final) block parses into the comments...
expect(parsed.comments).toEqual([{ id: 'real' }]);
// ...and the earlier literal opener is preserved verbatim in the body.
expect(parsed.body).toContain(
'<!-- docmost:comments\n[{"fake":true}]\n-->',
);
expect(parsed.body).toContain('and more prose after it.');
});
it('a literal opener whose closer does NOT end the doc is left entirely in the body', () => {
// No real trailing block: the opener is not document-ending, so comments
// stays null and nothing is stripped.
const input =
'<!-- docmost:meta\n' +
JSON.stringify(meta) +
'\n-->\n\nbody start\n\n<!-- docmost:comments\n[]\n-->\n\ntrailing text not ending the doc';
const parsed = parseDocmostMarkdown(input);
expect(parsed.comments).toBeNull();
expect(parsed.body).toContain('<!-- docmost:comments');
expect(parsed.body).toContain('trailing text not ending the doc');
});
});
// ---------------------------------------------------------------------------
describe('malformed JSON throws a clear error', () => {
it('throws on malformed meta JSON', () => {
const input = '<!-- docmost:meta\n{not valid json}\n-->\n\nbody';
expect(() => parseDocmostMarkdown(input)).toThrow(/docmost:meta JSON/);
});
it('throws on malformed comments JSON', () => {
const input = 'body\n\n<!-- docmost:comments\n[not, valid]\n-->\n';
expect(() => parseDocmostMarkdown(input)).toThrow(/docmost:comments JSON/);
});
});
});
describe('serializeDocmostMarkdownBody', () => {
it('emits NO comments block', () => {
const out = serializeDocmostMarkdownBody(meta, 'just the body');
expect(out).not.toContain('docmost:comments');
expect(out).toContain('<!-- docmost:meta');
});
it('serialize -> parse preserves meta and the trimmed body, comments null (SPEC §3)', () => {
const fullMeta: DocmostMdMeta = {
version: 1,
pageId: 'page-123',
slugId: 'slug-abc',
title: 'My Page',
spaceId: 'space-1',
parentPageId: 'parent-9',
};
const body = 'Hello\n\nWorld';
const out = serializeDocmostMarkdownBody(fullMeta, body);
const parsed = parseDocmostMarkdown(out);
expect(parsed.meta).toEqual(fullMeta);
expect(parsed.body).toBe(body);
expect(parsed.comments).toBeNull();
});
it('preserves a null parentPageId for a root page', () => {
const out = serializeDocmostMarkdownBody(meta, 'body text');
const parsed = parseDocmostMarkdown(out);
expect(parsed.meta).toEqual(meta);
expect(parsed.comments).toBeNull();
});
it('produces a parseable file for an empty or missing body', () => {
const minimal: DocmostMdMeta = { version: 1, pageId: 'p-empty' };
const emptyFile = serializeDocmostMarkdownBody(minimal, '');
const parsedEmpty = parseDocmostMarkdown(emptyFile);
expect(parsedEmpty.meta).toEqual(minimal);
expect(parsedEmpty.body).toBe('');
expect(parsedEmpty.comments).toBeNull();
// Missing body (undefined) — serializer coalesces to "".
const missingFile = serializeDocmostMarkdownBody(
minimal,
undefined as unknown as string,
);
const parsedMissing = parseDocmostMarkdown(missingFile);
expect(parsedMissing.meta).toEqual(minimal);
expect(parsedMissing.body).toBe('');
expect(parsedMissing.comments).toBeNull();
});
it('trims the body', () => {
const out = serializeDocmostMarkdownBody(meta, '\n\n hi \n');
const parsed = parseDocmostMarkdown(out);
expect(parsed.body).toBe('hi');
});
});

908
test/node-ops.test.ts Normal file
View File

@@ -0,0 +1,908 @@
import { describe, expect, it } from 'vitest';
import {
blockPlainText,
buildOutline,
getNodeByRef,
replaceNodeById,
deleteNodeById,
sanitizeForYjs,
findUnstorableAttr,
insertNodeRelative,
readTable,
insertTableRow,
deleteTableRow,
updateTableCell,
} from '../packages/docmost-client/src/lib/node-ops.js';
// ---------------------------------------------------------------------------
// Tiny ProseMirror/TipTap JSON fixture builders. These produce the exact plain
// JSON shape Docmost uses: { type, attrs?, content?, text?, marks? }.
// ---------------------------------------------------------------------------
/** A text leaf node, optionally carrying marks. */
function text(value: string, marks?: any[]): any {
const node: any = { type: 'text', text: value };
if (marks) node.marks = marks;
return node;
}
/** A paragraph block with an id and a single text child (or empty). */
function para(id: string, value = ''): any {
return {
type: 'paragraph',
attrs: { id, indent: 0 },
content: value ? [text(value)] : [],
};
}
/** A heading block. */
function heading(id: string, level: number, value: string): any {
return {
type: 'heading',
attrs: { id, level },
content: [text(value)],
};
}
/** A table cell (or header) wrapping a single paragraph; extra attrs merged in. */
function cell(
type: 'tableCell' | 'tableHeader',
paraId: string | null,
value = '',
extraAttrs: Record<string, any> = {},
): any {
const attrs = { colspan: 1, rowspan: 1, ...extraAttrs };
return {
type,
attrs,
content: paraId == null ? [] : [para(paraId, value)],
};
}
/** A table row. */
function row(cells: any[]): any {
return { type: 'tableRow', content: cells };
}
/** A doc root with the given top-level blocks. */
function doc(...content: any[]): any {
return { type: 'doc', content };
}
// ===========================================================================
// blockPlainText
// ===========================================================================
describe('blockPlainText', () => {
it('returns the text of a plain text node', () => {
expect(blockPlainText(text('hello'))).toBe('hello');
});
it('concatenates text from nested containers', () => {
const node = {
type: 'paragraph',
content: [text('foo'), text('bar'), { type: 'span', content: [text('baz')] }],
};
expect(blockPlainText(node)).toBe('foobarbaz');
});
it('returns "" for nullish or non-object inputs', () => {
expect(blockPlainText(null)).toBe('');
expect(blockPlainText(undefined)).toBe('');
expect(blockPlainText('a string')).toBe('');
expect(blockPlainText(42)).toBe('');
expect(blockPlainText([text('x')])).toBe(''); // arrays are not objects here
});
it('uses BOTH text and nested content of a node, text first', () => {
const node = { type: 'weird', text: 'A', content: [text('B'), text('C')] };
expect(blockPlainText(node)).toBe('ABC');
});
});
// ===========================================================================
// buildOutline
// ===========================================================================
describe('buildOutline', () => {
it('captures heading level, id and firstText', () => {
const outline = buildOutline(doc(heading('h1', 2, 'Title')));
expect(outline).toEqual([
{ index: 0, type: 'heading', id: 'h1', firstText: 'Title', level: 2 },
]);
});
it('reports table rows/cols and header texts (cols from row 0)', () => {
const table = {
type: 'table',
content: [
row([cell('tableHeader', 'a', 'H1'), cell('tableHeader', 'b', 'H2')]),
row([cell('tableCell', 'c', 'x'), cell('tableCell', 'd', 'y')]),
],
};
const [entry] = buildOutline(doc(table));
expect(entry.type).toBe('table');
expect(entry.rows).toBe(2);
expect(entry.cols).toBe(2);
expect(entry.header).toEqual(['H1', 'H2']);
});
it('derives cols from row 0 for a ragged table', () => {
const table = {
type: 'table',
content: [
row([cell('tableHeader', 'a', 'H1')]), // row 0 has 1 col
row([cell('tableCell', 'b', 'x'), cell('tableCell', 'c', 'y')]), // 2 cols
],
};
const [entry] = buildOutline(doc(table));
expect(entry.rows).toBe(2);
expect(entry.cols).toBe(1); // cols reflect ONLY row 0
expect(entry.header).toEqual(['H1']);
});
it('reports item count for any *List block', () => {
const list = {
type: 'bulletList',
attrs: { id: 'l1' },
content: [{ type: 'listItem' }, { type: 'listItem' }, { type: 'listItem' }],
};
const [entry] = buildOutline(doc(list));
expect(entry.type).toBe('bulletList');
expect(entry.items).toBe(3);
});
it('returns [] for an empty or non-object doc', () => {
expect(buildOutline(null)).toEqual([]);
expect(buildOutline({ type: 'doc' })).toEqual([]); // no content array
expect(buildOutline({ type: 'doc', content: [] })).toEqual([]);
expect(buildOutline('nope')).toEqual([]);
});
it('falls back to null id when a block has no attrs.id', () => {
const [entry] = buildOutline(doc({ type: 'paragraph', content: [text('hi')] }));
expect(entry.id).toBeNull();
expect(entry.firstText).toBe('hi');
});
it('truncates firstText to 100 chars with an ellipsis', () => {
const long = 'x'.repeat(150);
const [entry] = buildOutline(doc(para('p', long)));
expect(entry.firstText).toBe('x'.repeat(100) + '…');
expect(entry.firstText.length).toBe(101); // 100 chars + ellipsis
});
it('truncates table header cell text to 40 chars', () => {
const long = 'y'.repeat(60);
const table = {
type: 'table',
content: [row([cell('tableHeader', 'a', long)])],
};
const [entry] = buildOutline(doc(table));
expect(entry.header).toEqual(['y'.repeat(40) + '…']);
});
});
// ===========================================================================
// getNodeByRef
// ===========================================================================
describe('getNodeByRef', () => {
it('resolves a top-level block by #n', () => {
const d = doc(para('p0', 'zero'), para('p1', 'one'));
const hit = getNodeByRef(d, '#1');
expect(hit).not.toBeNull();
expect(hit!.path).toEqual([1]);
expect(hit!.type).toBe('paragraph');
expect(hit!.node.attrs.id).toBe('p1');
});
it('returns null for #n out of range', () => {
const d = doc(para('p0'));
expect(getNodeByRef(d, '#5')).toBeNull();
expect(getNodeByRef(d, '#1')).toBeNull();
});
it('finds a nested node by id with the correct path', () => {
const table = {
type: 'table',
content: [row([cell('tableCell', 'deep', 'found me')])],
};
const d = doc(para('p0'), table);
const hit = getNodeByRef(d, 'deep');
expect(hit).not.toBeNull();
// doc.content[1] -> table.content[0] -> row.content[0] -> cell.content[0]
expect(hit!.path).toEqual([1, 0, 0, 0]);
expect(hit!.type).toBe('paragraph');
});
it('returns null when the id is not found', () => {
const d = doc(para('p0'));
expect(getNodeByRef(d, 'missing')).toBeNull();
});
it('returns the FIRST node for a duplicate id', () => {
const d = doc(para('dup', 'first'), para('dup', 'second'));
const hit = getNodeByRef(d, 'dup');
expect(hit!.path).toEqual([0]);
expect(blockPlainText(hit!.node)).toBe('first');
});
it('returns null for a non-object doc', () => {
expect(getNodeByRef(null, '#0')).toBeNull();
expect(getNodeByRef('x', 'id')).toBeNull();
});
it('returns a CLONE — mutating it does not touch the input doc', () => {
const d = doc(para('p0', 'orig'));
const snapshot = structuredClone(d);
const hit = getNodeByRef(d, 'p0');
hit!.node.attrs.id = 'mutated';
hit!.node.content.push(text('extra'));
expect(d).toEqual(snapshot);
});
});
// ===========================================================================
// replaceNodeById
// ===========================================================================
describe('replaceNodeById', () => {
const newNode = () => ({ type: 'paragraph', attrs: { id: 'new' }, content: [text('NEW')] });
it('reports replaced:0 when nothing matches', () => {
const d = doc(para('p0'));
const res = replaceNodeById(d, 'missing', newNode());
expect(res.replaced).toBe(0);
expect(res.doc).toEqual(d);
});
it('replaces a single match', () => {
const d = doc(para('p0', 'old'), para('p1'));
const res = replaceNodeById(d, 'p0', newNode());
expect(res.replaced).toBe(1);
expect(res.doc.content[0]).toEqual(newNode());
expect(res.doc.content[1].attrs.id).toBe('p1');
});
it('replaces N matches', () => {
const d = doc(para('dup', 'a'), para('keep'), para('dup', 'b'));
const res = replaceNodeById(d, 'dup', newNode());
expect(res.replaced).toBe(2);
expect(res.doc.content[0]).toEqual(newNode());
expect(res.doc.content[1].attrs.id).toBe('keep');
expect(res.doc.content[2]).toEqual(newNode());
});
it('replaces a nested match inside a table cell', () => {
const table = {
type: 'table',
content: [row([cell('tableCell', 'inner', 'x')])],
};
const d = doc(table);
const res = replaceNodeById(d, 'inner', newNode());
expect(res.replaced).toBe(1);
expect(res.doc.content[0].content[0].content[0].content[0]).toEqual(newNode());
});
it('does NOT recurse into the substituted node', () => {
// The replacement itself carries the same id; it must not be re-replaced.
const d = doc(para('target'));
const replacement = { type: 'paragraph', attrs: { id: 'target' }, content: [text('R')] };
const res = replaceNodeById(d, 'target', replacement);
expect(res.replaced).toBe(1); // not 2 — no recursion into the new node
});
it('gives each match a SEPARATE clone', () => {
const d = doc(para('dup'), para('dup'));
const res = replaceNodeById(d, 'dup', newNode());
res.doc.content[0].content.push(text('mutated'));
// The second replacement must be untouched.
expect(res.doc.content[1]).toEqual(newNode());
});
it('does not mutate the input doc', () => {
const d = doc(para('p0', 'old'));
const snapshot = structuredClone(d);
replaceNodeById(d, 'p0', newNode());
expect(d).toEqual(snapshot);
});
});
// ===========================================================================
// deleteNodeById
// ===========================================================================
describe('deleteNodeById', () => {
it('reports deleted:0 when nothing matches', () => {
const d = doc(para('p0'));
const res = deleteNodeById(d, 'missing');
expect(res.deleted).toBe(0);
expect(res.doc).toEqual(d);
});
it('deletes a single match', () => {
const d = doc(para('p0'), para('p1'), para('p2'));
const res = deleteNodeById(d, 'p1');
expect(res.deleted).toBe(1);
expect(res.doc.content.map((c: any) => c.attrs.id)).toEqual(['p0', 'p2']);
});
it('deletes N matches', () => {
const d = doc(para('dup'), para('keep'), para('dup'));
const res = deleteNodeById(d, 'dup');
expect(res.deleted).toBe(2);
expect(res.doc.content.map((c: any) => c.attrs.id)).toEqual(['keep']);
});
it('deletes a nested node and preserves sibling order', () => {
// A callout-style container holding three paragraph children; deleting the
// middle one must leave the outer siblings in order.
const callout = {
type: 'callout',
attrs: { id: 'cal' },
content: [para('a', 'A'), para('b', 'B'), para('c', 'C')],
};
const d = doc(para('outer0'), callout, para('outer1'));
const res = deleteNodeById(d, 'b');
expect(res.deleted).toBe(1);
// Inner siblings keep their order.
const innerIds = res.doc.content[1].content.map((cl: any) => cl.attrs.id);
expect(innerIds).toEqual(['a', 'c']);
// Outer siblings are untouched and in order.
const outerIds = res.doc.content.map((cl: any) => cl.attrs.id);
expect(outerIds).toEqual(['outer0', 'cal', 'outer1']);
});
it('does not mutate the input doc (deep-equal before/after)', () => {
const d = doc(para('p0'), para('p1'));
const snapshot = structuredClone(d);
deleteNodeById(d, 'p0');
expect(d).toEqual(snapshot);
});
});
// ===========================================================================
// sanitizeForYjs
// ===========================================================================
describe('sanitizeForYjs', () => {
it('strips undefined keys from node.attrs', () => {
const d = doc({ type: 'paragraph', attrs: { id: 'p', gone: undefined, kept: 1 } });
const res = sanitizeForYjs(d);
expect('gone' in res.content[0].attrs).toBe(false);
expect(res.content[0].attrs).toEqual({ id: 'p', kept: 1 });
});
it('strips undefined keys from mark.attrs', () => {
const d = doc({
type: 'paragraph',
attrs: { id: 'p' },
content: [text('hi', [{ type: 'link', attrs: { href: 'u', gone: undefined } }])],
});
const res = sanitizeForYjs(d);
expect('gone' in res.content[0].content[0].marks[0].attrs).toBe(false);
expect(res.content[0].content[0].marks[0].attrs).toEqual({ href: 'u' });
});
it('PRESERVES null, false, 0 and "" (only undefined is dropped)', () => {
const d = doc({
type: 'paragraph',
attrs: { a: null, b: false, c: 0, d: '', e: undefined },
});
const res = sanitizeForYjs(d);
expect(res.content[0].attrs).toEqual({ a: null, b: false, c: 0, d: '' });
});
it('recurses into nested content', () => {
const d = doc({
type: 'table',
content: [row([cell('tableCell', null, '', { gone: undefined, colwidth: null })])],
});
const res = sanitizeForYjs(d);
const cellAttrs = res.content[0].content[0].content[0].attrs;
expect('gone' in cellAttrs).toBe(false);
expect(cellAttrs.colwidth).toBeNull();
});
it('does not mutate the input doc', () => {
const d = doc({ type: 'paragraph', attrs: { id: 'p', gone: undefined } });
// structuredClone preserves an explicit `undefined` value key, so snapshot it.
const snapshot = structuredClone(d);
sanitizeForYjs(d);
expect(d).toEqual(snapshot);
expect('gone' in d.content[0].attrs).toBe(true); // still present on the input
});
});
// ===========================================================================
// findUnstorableAttr
// ===========================================================================
describe('findUnstorableAttr', () => {
it('returns null for a fully storable doc', () => {
const d = doc(para('p0', 'clean'));
expect(findUnstorableAttr(d)).toBeNull();
});
it('detects an undefined node attr with its path and kind', () => {
const d = doc(para('a'), para('b'), { type: 'paragraph', attrs: { id: 'c', x: undefined } });
expect(findUnstorableAttr(d)).toBe('content[2].attrs.x (undefined)');
});
it('detects a function attr', () => {
const d = doc({ type: 'paragraph', attrs: { fn: () => 1 } });
expect(findUnstorableAttr(d)).toBe('content[0].attrs.fn (function)');
});
it('detects a symbol attr', () => {
const d = doc({ type: 'paragraph', attrs: { s: Symbol('x') } });
expect(findUnstorableAttr(d)).toBe('content[0].attrs.s (symbol)');
});
it('detects a bigint attr', () => {
const d = doc({ type: 'paragraph', attrs: { big: 10n } });
expect(findUnstorableAttr(d)).toBe('content[0].attrs.big (bigint)');
});
it('detects an unstorable mark attr with the marks[i] path', () => {
const d = doc({
type: 'paragraph',
attrs: { id: 'p' },
content: [text('hi'), text('yo', [{ type: 'link', attrs: { x: undefined } }])],
});
expect(findUnstorableAttr(d)).toBe('content[0].content[1].marks[0].attrs.x (undefined)');
});
it('returns the FIRST hit only', () => {
const d = doc(
{ type: 'paragraph', attrs: { first: undefined } },
{ type: 'paragraph', attrs: { second: undefined } },
);
expect(findUnstorableAttr(d)).toBe('content[0].attrs.first (undefined)');
});
it('returns null for a non-object doc', () => {
expect(findUnstorableAttr(null)).toBeNull();
expect(findUnstorableAttr('x')).toBeNull();
});
});
// ===========================================================================
// insertNodeRelative
// ===========================================================================
describe('insertNodeRelative', () => {
const block = (id: string, value = '') => para(id, value);
it('appends a node to top-level content', () => {
const d = doc(para('p0'));
const res = insertNodeRelative(d, block('new', 'N'), { position: 'append' });
expect(res.inserted).toBe(true);
expect(res.doc.content.map((c: any) => c.attrs.id)).toEqual(['p0', 'new']);
});
it('creates a content array when appending to a doc without one', () => {
const res = insertNodeRelative({ type: 'doc' }, block('new'), { position: 'append' });
expect(res.inserted).toBe(true);
expect(res.doc.content.map((c: any) => c.attrs.id)).toEqual(['new']);
});
it('inserts before a node by id (top level)', () => {
const d = doc(para('p0'), para('p1'));
const res = insertNodeRelative(d, block('new'), { position: 'before', anchorNodeId: 'p1' });
expect(res.inserted).toBe(true);
expect(res.doc.content.map((c: any) => c.attrs.id)).toEqual(['p0', 'new', 'p1']);
});
it('inserts after a node by id (top level)', () => {
const d = doc(para('p0'), para('p1'));
const res = insertNodeRelative(d, block('new'), { position: 'after', anchorNodeId: 'p0' });
expect(res.inserted).toBe(true);
expect(res.doc.content.map((c: any) => c.attrs.id)).toEqual(['p0', 'new', 'p1']);
});
it('inserts before a NESTED anchor by id, into its own parent content', () => {
const table = {
type: 'table',
content: [row([cell('tableCell', 'inner', 'x')])],
};
const d = doc(table);
const res = insertNodeRelative(d, block('new'), { position: 'before', anchorNodeId: 'inner' });
expect(res.inserted).toBe(true);
// The new (non-structural) node is spliced into the cell's content before the paragraph.
const cellContent = res.doc.content[0].content[0].content[0].content;
expect(cellContent.map((c: any) => c.attrs.id)).toEqual(['new', 'inner']);
});
it('inserts by anchorText against top-level blocks (substring match)', () => {
const d = doc(para('p0', 'hello world'), para('p1', 'other'));
const res = insertNodeRelative(d, block('new'), { position: 'after', anchorText: 'world' });
expect(res.inserted).toBe(true);
expect(res.doc.content.map((c: any) => c.attrs.id)).toEqual(['p0', 'new', 'p1']);
});
it('returns inserted:false when the anchor cannot be resolved', () => {
const d = doc(para('p0'));
const byId = insertNodeRelative(d, block('new'), { position: 'after', anchorNodeId: 'nope' });
expect(byId.inserted).toBe(false);
expect(byId.doc).toEqual(d);
const byText = insertNodeRelative(d, block('new'), { position: 'before', anchorText: 'zzz' });
expect(byText.inserted).toBe(false);
expect(byText.doc).toEqual(d);
});
it('routes a structural tableRow to the nearest table container', () => {
const table = {
type: 'table',
content: [
row([cell('tableCell', 'r0c0', 'A')]),
row([cell('tableCell', 'r1c0', 'B')]),
],
};
const d = doc(table);
const newRow = row([cell('tableCell', 'rNew', 'NEW')]);
// Anchor on a cell paragraph inside row 0; "after" should put the row after row 0.
const res = insertNodeRelative(d, newRow, { position: 'after', anchorNodeId: 'r0c0' });
expect(res.inserted).toBe(true);
const rowFirstCellId = (r: any) => r.content[0].content[0].attrs.id;
expect(res.doc.content[0].content.map(rowFirstCellId)).toEqual(['r0c0', 'rNew', 'r1c0']);
});
it('throws when appending a structural node at the top level', () => {
const d = doc(para('p0'));
const newRow = row([cell('tableCell', 'x', 'X')]);
expect(() => insertNodeRelative(d, newRow, { position: 'append' })).toThrow(
/cannot append a tableRow at the top level/,
);
});
it('throws when a structural anchor is not inside the required container', () => {
// Anchor resolves to a top-level paragraph that is not inside any table.
const d = doc(para('p0', 'loose'));
const newRow = row([cell('tableCell', 'x', 'X')]);
expect(() =>
insertNodeRelative(d, newRow, { position: 'after', anchorNodeId: 'p0' }),
).toThrow(/the anchor is not inside a table/);
});
it('honours offset: before vs after place the node on the correct side', () => {
const d = doc(para('a'), para('b'), para('c'));
const before = insertNodeRelative(d, block('N'), { position: 'before', anchorNodeId: 'b' });
expect(before.doc.content.map((c: any) => c.attrs.id)).toEqual(['a', 'N', 'b', 'c']);
const after = insertNodeRelative(d, block('N'), { position: 'after', anchorNodeId: 'b' });
expect(after.doc.content.map((c: any) => c.attrs.id)).toEqual(['a', 'b', 'N', 'c']);
});
it('does not mutate the input doc or the node argument', () => {
const d = doc(para('p0'));
const dSnapshot = structuredClone(d);
const node = block('new', 'N');
const nodeSnapshot = structuredClone(node);
insertNodeRelative(d, node, { position: 'append' });
expect(d).toEqual(dSnapshot);
expect(node).toEqual(nodeSnapshot);
});
});
// ===========================================================================
// readTable
// ===========================================================================
describe('readTable', () => {
const makeTable = () => ({
type: 'table',
content: [
row([cell('tableHeader', 'h0', 'H0'), cell('tableHeader', 'h1', 'H1')]),
row([cell('tableCell', 'c0', 'A'), cell('tableCell', 'c1', 'B')]),
],
});
it('reads a table by #n', () => {
const d = doc(para('p0'), makeTable());
const res = readTable(d, '#1');
expect(res).not.toBeNull();
expect(res!.rows).toBe(2);
expect(res!.cols).toBe(2);
expect(res!.cells).toEqual([['H0', 'H1'], ['A', 'B']]);
expect(res!.cellIds).toEqual([['h0', 'h1'], ['c0', 'c1']]);
expect(res!.path).toEqual([1]);
});
it('climbs from an inner paragraph id up to the table', () => {
const d = doc(makeTable());
const res = readTable(d, 'c1'); // id of a paragraph inside a data cell
expect(res).not.toBeNull();
expect(res!.path).toEqual([0]);
expect(res!.cells).toEqual([['H0', 'H1'], ['A', 'B']]);
});
it('reports per-row widths via cells for a ragged table', () => {
const table = {
type: 'table',
content: [
row([cell('tableHeader', 'h0', 'H0')]),
row([cell('tableCell', 'c0', 'A'), cell('tableCell', 'c1', 'B')]),
],
};
const res = readTable(doc(table), '#0');
expect(res!.cols).toBe(1); // cols comes from row 0
expect(res!.cells).toEqual([['H0'], ['A', 'B']]); // actual per-row widths preserved
expect(res!.cellIds).toEqual([['h0'], ['c0', 'c1']]);
});
it('reports null cellId for an empty cell with no paragraph', () => {
const table = {
type: 'table',
content: [row([cell('tableCell', null), cell('tableCell', 'c1', 'B')])],
};
const res = readTable(doc(table), '#0');
expect(res!.cells).toEqual([['', 'B']]);
expect(res!.cellIds).toEqual([[null, 'c1']]);
});
it('returns null when the ref matches no table', () => {
const d = doc(para('p0'));
expect(readTable(d, '#0')).toBeNull(); // #0 is a paragraph, not a table
expect(readTable(d, 'missing')).toBeNull();
expect(readTable(d, 'p0')).toBeNull(); // id found but no enclosing table
});
});
// ===========================================================================
// insertTableRow
// ===========================================================================
describe('insertTableRow', () => {
const makeTable = () => ({
type: 'table',
content: [
row([
cell('tableHeader', 'h0', 'H0', { colwidth: [120] }),
cell('tableHeader', 'h1', 'H1', { colwidth: [240] }),
]),
row([cell('tableCell', 'c0', 'A'), cell('tableCell', 'c1', 'B')]),
],
});
/** First-paragraph ids of every cell in a row, for ordering assertions. */
const rowCellParaIds = (r: any): (string | undefined)[] =>
r.content.map((c: any) => c.content[0]?.attrs?.id);
/** Cell text of a row. */
const rowTexts = (r: any): string[] =>
r.content.map((c: any) => blockPlainText(c));
it('appends a row when index is omitted', () => {
const d = doc(makeTable());
const res = insertTableRow(d, '#0', ['X', 'Y']);
expect(res.inserted).toBe(true);
const rows = res.doc.content[0].content;
expect(rows.length).toBe(3);
expect(rowTexts(rows[2])).toEqual(['X', 'Y']);
});
it('splices at a middle index', () => {
const d = doc(makeTable());
const res = insertTableRow(d, '#0', ['X', 'Y'], 1);
const rows = res.doc.content[0].content;
expect(rows.length).toBe(3);
expect(rowTexts(rows[1])).toEqual(['X', 'Y']); // new row at index 1
expect(rowTexts(rows[2])).toEqual(['A', 'B']); // old data row pushed down
});
it('splices at the end index', () => {
const d = doc(makeTable());
const res = insertTableRow(d, '#0', ['X', 'Y'], 2); // rows == 2, valid end index
const rows = res.doc.content[0].content;
expect(rows.length).toBe(3);
expect(rowTexts(rows[2])).toEqual(['X', 'Y']);
});
it('APPENDS (does not throw) for an out-of-range index', () => {
const d = doc(makeTable());
const res = insertTableRow(d, '#0', ['X', 'Y'], 99);
const rows = res.doc.content[0].content;
expect(res.inserted).toBe(true);
expect(rows.length).toBe(3);
expect(rowTexts(rows[2])).toEqual(['X', 'Y']); // appended at the end
});
it('throws when given more cells than columns', () => {
const d = doc(makeTable());
expect(() => insertTableRow(d, '#0', ['X', 'Y', 'Z'])).toThrow(
/got 3 cell\(s\) but the table has 2 column\(s\)/,
);
});
it('pads a short row to the column count', () => {
const d = doc(makeTable());
const res = insertTableRow(d, '#0', ['only']);
const rows = res.doc.content[0].content;
expect(rowTexts(rows[2])).toEqual(['only', '']); // padded with empty cell
});
it('copies colwidth from the header row for each column', () => {
const d = doc(makeTable());
const res = insertTableRow(d, '#0', ['X', 'Y']);
const newRow = res.doc.content[0].content[2];
expect(newRow.content[0].attrs.colwidth).toEqual([120]);
expect(newRow.content[1].attrs.colwidth).toEqual([240]);
expect(newRow.content[0].attrs).toMatchObject({ colspan: 1, rowspan: 1 });
});
it('index 0 inherits the header cell TYPE', () => {
const d = doc(makeTable());
const res = insertTableRow(d, '#0', ['X', 'Y'], 0);
const newRow = res.doc.content[0].content[0];
expect(newRow.content.every((c: any) => c.type === 'tableHeader')).toBe(true);
// A non-zero index produces plain data cells instead.
const res2 = insertTableRow(d, '#0', ['X', 'Y'], 1);
const dataRow = res2.doc.content[0].content[1];
expect(dataRow.content.every((c: any) => c.type === 'tableCell')).toBe(true);
});
it('mints unique, well-formed paragraph ids for new cells', () => {
const d = doc(makeTable());
const existing = new Set(['h0', 'h1', 'c0', 'c1']);
const res = insertTableRow(d, '#0', ['X', 'Y']);
const newRow = res.doc.content[0].content[2];
const ids = rowCellParaIds(newRow) as string[];
for (const id of ids) {
expect(typeof id).toBe('string');
expect(id).toMatch(/^[a-z0-9]{12}$/); // Docmost-style 12-char id
expect(existing.has(id)).toBe(false); // unique vs pre-existing ids
}
expect(new Set(ids).size).toBe(ids.length); // unique within the row
});
it('returns inserted:false when the table cannot be located', () => {
const d = doc(para('p0'));
const res = insertTableRow(d, 'missing', ['X']);
expect(res.inserted).toBe(false);
expect(res.doc).toEqual(d);
});
it('does not mutate the input doc', () => {
const d = doc(makeTable());
const snapshot = structuredClone(d);
insertTableRow(d, '#0', ['X', 'Y'], 1);
expect(d).toEqual(snapshot);
});
});
// ===========================================================================
// deleteTableRow
// ===========================================================================
describe('deleteTableRow', () => {
const makeTable = () => ({
type: 'table',
content: [
row([cell('tableHeader', 'h0', 'H')]),
row([cell('tableCell', 'c0', 'A')]),
row([cell('tableCell', 'c1', 'B')]),
],
});
const firstId = (r: any) => r.content[0].content[0].attrs.id;
it('deletes a middle row and preserves siblings', () => {
const d = doc(makeTable());
const res = deleteTableRow(d, '#0', 1);
expect(res.deleted).toBe(true);
expect(res.doc.content[0].content.map(firstId)).toEqual(['h0', 'c1']);
});
it('deletes the first row', () => {
const d = doc(makeTable());
const res = deleteTableRow(d, '#0', 0);
expect(res.doc.content[0].content.map(firstId)).toEqual(['c0', 'c1']);
});
it('deletes the last row', () => {
const d = doc(makeTable());
const res = deleteTableRow(d, '#0', 2);
expect(res.doc.content[0].content.map(firstId)).toEqual(['h0', 'c0']);
});
it('throws on an out-of-range index', () => {
const d = doc(makeTable());
expect(() => deleteTableRow(d, '#0', 99)).toThrow(/out of range/);
expect(() => deleteTableRow(d, '#0', -1)).toThrow(/out of range/);
});
it('throws when asked to delete the only row', () => {
const single = {
type: 'table',
content: [row([cell('tableCell', 'c0', 'A')])],
};
expect(() => deleteTableRow(doc(single), '#0', 0)).toThrow(
/refusing to delete the only row/,
);
});
it('returns deleted:false when the table cannot be located', () => {
const d = doc(para('p0'));
const res = deleteTableRow(d, 'missing', 0);
expect(res.deleted).toBe(false);
expect(res.doc).toEqual(d);
});
it('does not mutate the input doc', () => {
const d = doc(makeTable());
const snapshot = structuredClone(d);
deleteTableRow(d, '#0', 1);
expect(d).toEqual(snapshot);
});
});
// ===========================================================================
// updateTableCell
// ===========================================================================
describe('updateTableCell', () => {
const makeTable = () => ({
type: 'table',
content: [
row([cell('tableHeader', 'h0', 'H0'), cell('tableHeader', 'h1', 'H1')]),
row([
cell('tableCell', 'c0', 'A', { colspan: 2, rowspan: 3, colwidth: [200] }),
cell('tableCell', 'c1', 'B'),
]),
],
});
it('sets the cell text', () => {
const d = doc(makeTable());
const res = updateTableCell(d, '#0', 1, 1, 'NEW');
expect(res.updated).toBe(true);
expect(blockPlainText(res.doc.content[0].content[1].content[1])).toBe('NEW');
});
it('REUSES the existing first-paragraph id', () => {
const d = doc(makeTable());
const res = updateTableCell(d, '#0', 1, 0, 'changed');
const para0 = res.doc.content[0].content[1].content[0].content[0];
expect(para0.attrs.id).toBe('c0'); // critical: id reused, not regenerated
expect(para0.content[0].text).toBe('changed');
});
it('mints a fresh id when the cell had no paragraph', () => {
const table = {
type: 'table',
content: [row([cell('tableCell', null), cell('tableCell', 'c1', 'B')])],
};
const d = doc(table);
const res = updateTableCell(d, '#0', 0, 0, 'now has text');
const newPara = res.doc.content[0].content[0].content[0].content[0];
expect(typeof newPara.attrs.id).toBe('string');
expect(newPara.attrs.id).toMatch(/^[a-z0-9]{12}$/);
expect(newPara.attrs.id).not.toBe('c1'); // unique vs existing ids
expect(newPara.content[0].text).toBe('now has text');
});
it('PRESERVES the cell colspan/rowspan/colwidth (only content replaced)', () => {
const d = doc(makeTable());
const res = updateTableCell(d, '#0', 1, 0, 'x');
const cellNode = res.doc.content[0].content[1].content[0];
expect(cellNode.attrs).toEqual({ colspan: 2, rowspan: 3, colwidth: [200] });
});
it('throws when row or col is out of range', () => {
const d = doc(makeTable());
expect(() => updateTableCell(d, '#0', 5, 0, 'x')).toThrow(/out of range/);
expect(() => updateTableCell(d, '#0', 0, 5, 'x')).toThrow(/out of range/);
expect(() => updateTableCell(d, '#0', -1, 0, 'x')).toThrow(/out of range/);
});
it('an empty string yields an empty paragraph content array', () => {
const d = doc(makeTable());
const res = updateTableCell(d, '#0', 1, 1, '');
const cellPara = res.doc.content[0].content[1].content[1].content[0];
expect(cellPara.type).toBe('paragraph');
expect(cellPara.content).toEqual([]); // empty string -> empty content
expect(cellPara.attrs.id).toBe('c1'); // id still reused
});
it('returns updated:false when the table cannot be located', () => {
const d = doc(para('p0'));
const res = updateTableCell(d, 'missing', 0, 0, 'x');
expect(res.updated).toBe(false);
expect(res.doc).toEqual(d);
});
it('does not mutate the input doc', () => {
const d = doc(makeTable());
const snapshot = structuredClone(d);
updateTableCell(d, '#0', 1, 1, 'NEW');
expect(d).toEqual(snapshot);
});
});

170
test/page-lock.test.ts Normal file
View File

@@ -0,0 +1,170 @@
import { describe, expect, it } from 'vitest';
import { withPageLock } from '../packages/docmost-client/src/lib/page-lock.js';
// A manually-resolvable promise so ordering is fully deterministic without
// any timers. `resolve`/`reject` are pulled out of the executor.
function deferred<T = void>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
// A unique pageId per test: the module-level Map is process-global and shared
// across every test in a worker, so reusing ids would couple tests together.
let pageCounter = 0;
function uniquePageId() {
return `page-${process.pid}-${Date.now()}-${pageCounter++}`;
}
// Drain the microtask queue several times. withPageLock chains through
// `Promise.resolve().catch().then(fn)`, so `fn` only starts after a few
// microtask hops — a single `await Promise.resolve()` is not enough. We never
// use timers, so this stays fully deterministic.
async function flushMicrotasks() {
for (let i = 0; i < 10; i++) await Promise.resolve();
}
describe('withPageLock', () => {
it('serializes two ops on the same pageId (second waits for the first to settle)', async () => {
const pageId = uniquePageId();
const order: string[] = [];
const gate1 = deferred();
const op1 = withPageLock(pageId, async () => {
order.push('op1-start');
await gate1.promise;
order.push('op1-end');
return 'one';
});
const op2 = withPageLock(pageId, async () => {
order.push('op2-start');
return 'two';
});
// Let microtasks flush: op1 has started, op2 must not have started yet.
await flushMicrotasks();
expect(order).toEqual(['op1-start']);
// Release op1; op2 may only begin after op1 fully settles.
gate1.resolve();
await Promise.all([op1, op2]);
expect(order).toEqual(['op1-start', 'op1-end', 'op2-start']);
});
it('does not poison the queue when the first op rejects (second still runs)', async () => {
const pageId = uniquePageId();
const order: string[] = [];
const gate1 = deferred();
const op1 = withPageLock(pageId, async () => {
order.push('op1-start');
await gate1.promise;
throw new Error('boom');
});
const op2 = withPageLock(pageId, async () => {
order.push('op2-start');
return 'survived';
});
await flushMicrotasks();
expect(order).toEqual(['op1-start']);
gate1.resolve();
// op1 rejects, but op2 still runs afterwards.
await expect(op1).rejects.toThrow('boom');
await expect(op2).resolves.toBe('survived');
expect(order).toEqual(['op1-start', 'op2-start']);
});
it('runs ops on different pageIds concurrently (no cross-page blocking)', async () => {
const pageIdA = uniquePageId();
const pageIdB = uniquePageId();
const order: string[] = [];
const gateA = deferred();
const opA = withPageLock(pageIdA, async () => {
order.push('A-start');
await gateA.promise;
order.push('A-end');
return 'a';
});
const opB = withPageLock(pageIdB, async () => {
order.push('B-start');
return 'b';
});
// B is on a different page and must start without waiting for A.
await flushMicrotasks();
expect(order).toContain('A-start');
expect(order).toContain('B-start');
gateA.resolve();
await Promise.all([opA, opB]);
// B finished while A was still gated.
expect(order).toEqual(['A-start', 'B-start', 'A-end']);
});
it('returns the real resolved value to the caller', async () => {
const pageId = uniquePageId();
const value = { ok: true, n: 7 };
await expect(withPageLock(pageId, async () => value)).resolves.toBe(value);
});
it('propagates the real rejection to the caller (not swallowed)', async () => {
const pageId = uniquePageId();
const err = new Error('real failure');
await expect(
withPageLock(pageId, async () => {
throw err;
}),
).rejects.toBe(err);
});
it('serializes a third op behind the chain even after a rejection mid-chain', async () => {
const pageId = uniquePageId();
const order: string[] = [];
const gate1 = deferred();
const op1 = withPageLock(pageId, async () => {
order.push('1-start');
await gate1.promise;
throw new Error('mid-fail');
});
const op2 = withPageLock(pageId, async () => {
order.push('2');
return 2;
});
const op3 = withPageLock(pageId, async () => {
order.push('3');
return 3;
});
await flushMicrotasks();
expect(order).toEqual(['1-start']);
gate1.resolve();
await expect(op1).rejects.toThrow('mid-fail');
await expect(op2).resolves.toBe(2);
await expect(op3).resolves.toBe(3);
expect(order).toEqual(['1-start', '2', '3']);
});
});

561
test/transforms.test.ts Normal file
View File

@@ -0,0 +1,561 @@
import { describe, expect, it } from 'vitest';
import {
walk,
getList,
insertMarkerAfter,
setCalloutRange,
noteItem,
mdToInlineNodes,
commentsToFootnotes,
} from '../packages/docmost-client/src/lib/transforms.js';
// ---------------------------------------------------------------------------
// Small inline fixture builders. A ProseMirror node is a plain JSON object of
// shape { type, attrs?, content?, text?, marks? }.
// ---------------------------------------------------------------------------
/** A plain text run, optionally with marks. */
function text(t: string, marks?: any[]): any {
return marks ? { type: 'text', text: t, marks } : { type: 'text', text: t };
}
/** A paragraph holding the given inline runs. */
function para(...runs: any[]): any {
return { type: 'paragraph', attrs: { id: 'p' }, content: runs };
}
/** A callout holding the given child blocks. */
function callout(...children: any[]): any {
return { type: 'callout', content: children };
}
/** A document with the given top-level blocks. */
function doc(...blocks: any[]): any {
return { type: 'doc', content: blocks };
}
/**
* Recursively strip every `attrs.id` so docs containing freshId()-generated ids
* can be deep-compared structurally. Mutates a clone, returns it.
*/
function stripIds<T>(value: T): T {
const v: any = structuredClone(value);
const recur = (n: any): void => {
if (Array.isArray(n)) {
n.forEach(recur);
return;
}
if (n && typeof n === 'object') {
if (n.attrs && typeof n.attrs === 'object' && 'id' in n.attrs) {
delete n.attrs.id;
}
for (const k of Object.keys(n)) recur(n[k]);
}
};
recur(v);
return v;
}
// ===========================================================================
describe('walk', () => {
it('is a no-op for a nullish or non-object root', () => {
const seen: any[] = [];
walk(null, (n) => seen.push(n));
walk(undefined, (n) => seen.push(n));
walk('string', (n) => seen.push(n));
walk(42, (n) => seen.push(n));
walk([1, 2, 3], (n) => seen.push(n)); // array is not an object root
expect(seen).toEqual([]);
});
it('visits the root itself and all nested nodes (callout/table/list)', () => {
const tree = {
type: 'doc',
content: [
callout(para(text('a'))),
{
type: 'table',
content: [
{ type: 'tableRow', content: [{ type: 'tableCell', content: [para(text('b'))] }] },
],
},
{
type: 'orderedList',
content: [{ type: 'listItem', content: [para(text('c'))] }],
},
],
};
const types: string[] = [];
walk(tree, (n) => types.push(n.type));
// Root first, then DFS into every nested container.
expect(types[0]).toBe('doc');
expect(types).toContain('callout');
expect(types).toContain('table');
expect(types).toContain('tableRow');
expect(types).toContain('tableCell');
expect(types).toContain('orderedList');
expect(types).toContain('listItem');
expect(types).toContain('paragraph');
expect(types).toContain('text');
});
it('ignores a non-array content field', () => {
const node = { type: 'weird', content: { not: 'an array' } };
const seen: any[] = [];
walk(node, (n) => seen.push(n));
// Only the root is visited; the object content is never recursed into.
expect(seen).toEqual([node]);
});
});
// ===========================================================================
describe('getList', () => {
it('returns the FIRST match in depth-first order', () => {
const first = { type: 'orderedList', attrs: { id: 'L1' }, content: [] };
const second = { type: 'orderedList', attrs: { id: 'L2' }, content: [] };
const tree = doc(callout(first), second);
const found = getList(tree, (n) => n.type === 'orderedList');
expect(found).toBe(first); // DFS reaches the callout's child before the sibling
expect(found.attrs.id).toBe('L1');
});
it('returns null when nothing matches', () => {
const tree = doc(para(text('x')));
expect(getList(tree, (n) => n.type === 'orderedList')).toBeNull();
});
it('returns a LIVE reference, not a clone', () => {
const list = { type: 'orderedList', content: [] };
const tree = doc(list);
const found = getList(tree, (n) => n.type === 'orderedList');
expect(found).toBe(list); // same object identity
found.marker = 'mutated';
expect(list.marker).toBe('mutated'); // mutation visible on the original
});
it('matches a node lacking attrs.id', () => {
const noId = { type: 'orderedList', content: [] }; // no attrs at all
const tree = doc(para(text('x')), noId);
const found = getList(tree, (n) => n.type === 'orderedList');
expect(found).toBe(noId);
});
});
// ===========================================================================
describe('insertMarkerAfter', () => {
it('returns inserted:false when the anchor is not found', () => {
const d = doc(para(text('hello world')));
const r = insertMarkerAfter(d, 'absent text', '[1]');
expect(r.inserted).toBe(false);
// Returned doc is a clone of the unchanged input.
expect(r.doc).toEqual(d);
expect(r.doc).not.toBe(d);
});
it('inserts a plain marker run after the anchor in a single text run', () => {
const d = doc(para(text('see here for details')));
const r = insertMarkerAfter(d, 'see here', '[1]');
expect(r.inserted).toBe(true);
expect(r.doc.content[0].content).toEqual([
{ type: 'text', text: 'see here', marks: [] },
{ type: 'text', text: ' [1]' },
{ type: 'text', text: ' for details', marks: [] },
]);
});
it('preserves marks across runs and emits a PLAIN marker, no empty runs', () => {
// Anchor "foo bar" spans a plain run "foo " and a bold run "bar baz".
const d = doc(
para(
text('foo '),
text('bar baz', [{ type: 'bold' }]),
),
);
const r = insertMarkerAfter(d, 'foo bar', '[1]');
expect(r.inserted).toBe(true);
// The bold run "bar baz" is split at the anchor end (after "bar"); the
// leading "foo " run is untouched, the marker is plain, surrounding marks
// are preserved verbatim, and no empty text run is emitted.
expect(r.doc.content[0].content).toEqual([
{ type: 'text', text: 'foo ' },
{ type: 'text', text: 'bar', marks: [{ type: 'bold' }] },
{ type: 'text', text: ' [1]' },
{ type: 'text', text: ' baz', marks: [{ type: 'bold' }] },
]);
});
it('splits exactly at a run boundary without emitting an empty run', () => {
// Anchor ends exactly at the end of the first run "alpha".
const d = doc(para(text('alpha'), text('beta')));
const r = insertMarkerAfter(d, 'alpha', '[1]');
expect(r.inserted).toBe(true);
// "before" == whole first run, "after" is empty -> no empty run pushed.
expect(r.doc.content[0].content).toEqual([
{ type: 'text', text: 'alpha', marks: [] },
{ type: 'text', text: ' [1]' },
{ type: 'text', text: 'beta' },
]);
});
it('beforeBlock scope excludes blocks at/after the boundary', () => {
const d = doc(
para(text('body anchor')), // index 0 (in scope when beforeBlock=1)
para(text('notes anchor')), // index 1 (out of scope)
);
// Anchor only exists in the out-of-scope block -> not inserted.
const r = insertMarkerAfter(d, 'notes anchor', '[1]', { beforeBlock: 1 });
expect(r.inserted).toBe(false);
// The in-scope anchor still inserts when limited.
const r2 = insertMarkerAfter(d, 'body anchor', '[1]', { beforeBlock: 1 });
expect(r2.inserted).toBe(true);
});
it('does not mutate the input document', () => {
const d = doc(para(text('keep me intact please')));
const snapshot = structuredClone(d);
insertMarkerAfter(d, 'keep me', '[1]');
expect(d).toEqual(snapshot);
});
it('returns inserted:false for an empty anchor', () => {
const d = doc(para(text('anything')));
const r = insertMarkerAfter(d, '', '[1]');
expect(r.inserted).toBe(false);
expect(r.doc).toEqual(d);
});
});
// ===========================================================================
describe('setCalloutRange', () => {
it('rewrites a Unicode-ellipsis [1]…[K] range inside a callout', () => {
const d = doc(callout(para(text('Footnotes [1]…[5] follow'))));
const r = setCalloutRange(d, 7);
expect(r.changed).toBe(1);
expect(r.doc.content[0].content[0].content[0].text).toBe('Footnotes [1]…[7] follow');
});
it('rewrites an ASCII-ellipsis [1]...[K] range inside a callout', () => {
const d = doc(callout(para(text('range [1]...[3] here'))));
const r = setCalloutRange(d, 9);
expect(r.changed).toBe(1);
expect(r.doc.content[0].content[0].content[0].text).toBe('range [1]...[9] here');
});
it('leaves a paragraph [1]…[K] (outside any callout) untouched', () => {
const d = doc(para(text('not a callout [1]…[5]')));
const r = setCalloutRange(d, 9);
expect(r.changed).toBe(0);
expect(r.doc.content[0].content[0].text).toBe('not a callout [1]…[5]');
});
it('rewrites across multiple callouts and reports the changed count', () => {
const d = doc(
callout(para(text('a [1]…[2] b'))),
para(text('skip [1]…[2]')),
callout(para(text('c [1]...[4] d'))),
);
const r = setCalloutRange(d, 10);
expect(r.changed).toBe(2);
expect(r.doc.content[0].content[0].content[0].text).toBe('a [1]…[10] b');
expect(r.doc.content[2].content[0].content[0].text).toBe('c [1]...[10] d');
});
it('reports changed:0 when no range matches', () => {
const d = doc(callout(para(text('no range here'))));
const r = setCalloutRange(d, 4);
expect(r.changed).toBe(0);
});
it('does not mutate the input document', () => {
const d = doc(callout(para(text('x [1]…[5] y'))));
const snapshot = structuredClone(d);
setCalloutRange(d, 99);
expect(d).toEqual(snapshot);
});
it('handles TWO matching text nodes in one callout (regex lastIndex reset)', () => {
// Two separate text nodes, each carrying a range, inside one callout.
const d = doc(
callout(
para(text('first [1]…[2]')),
para(text('second [1]…[3]')),
),
);
const r = setCalloutRange(d, 6);
expect(r.changed).toBe(2);
expect(r.doc.content[0].content[0].content[0].text).toBe('first [1]…[6]');
expect(r.doc.content[0].content[1].content[0].text).toBe('second [1]…[6]');
});
});
// ===========================================================================
describe('noteItem', () => {
it('wraps inline nodes in listItem > paragraph', () => {
const inline = [text('hello')];
const item = noteItem(inline);
expect(item.type).toBe('listItem');
expect(item.content).toHaveLength(1);
const p = item.content[0];
expect(p.type).toBe('paragraph');
expect(p.content).toEqual([text('hello')]);
// The paragraph carries a string id from Math.random()-based freshId().
expect(typeof p.attrs.id).toBe('string');
expect(p.attrs.id.length).toBeGreaterThan(0);
});
it('produces empty content for non-array input', () => {
expect(noteItem(undefined as any).content[0].content).toEqual([]);
expect(noteItem(null as any).content[0].content).toEqual([]);
expect(noteItem('nope' as any).content[0].content).toEqual([]);
});
it('clones the input so the result shares no references', () => {
const inline = [text('mutable')];
const item = noteItem(inline);
inline[0].text = 'changed';
expect(item.content[0].content[0].text).toBe('mutable'); // unaffected
expect(item.content[0].content[0]).not.toBe(inline[0]);
});
it('matches the expected structure (ignoring the random id)', () => {
const item = noteItem([text('body', [{ type: 'bold' }])]);
expect(stripIds(item)).toEqual({
type: 'listItem',
content: [
{
type: 'paragraph',
attrs: {},
content: [{ type: 'text', text: 'body', marks: [{ type: 'bold' }] }],
},
],
});
});
});
// ===========================================================================
describe('mdToInlineNodes', () => {
it('returns [] for empty or non-string input', () => {
expect(mdToInlineNodes('')).toEqual([]);
expect(mdToInlineNodes(' ')).toEqual([]);
expect(mdToInlineNodes(undefined as any)).toEqual([]);
expect(mdToInlineNodes(null as any)).toEqual([]);
expect(mdToInlineNodes(123 as any)).toEqual([]);
});
it('strips a case-insensitive "комментарий:" prefix', () => {
expect(mdToInlineNodes('Комментарий: hello')).toEqual([{ type: 'text', text: 'hello' }]);
expect(mdToInlineNodes('комментарий : hi')).toEqual([{ type: 'text', text: 'hi' }]);
});
it('strips a leading "N. " numeric prefix', () => {
expect(mdToInlineNodes('3. some note')).toEqual([{ type: 'text', text: 'some note' }]);
});
it('turns a leading **bold lead** into a bold node + plain remainder, space preserved', () => {
const nodes = mdToInlineNodes('**Lead** rest of text');
expect(nodes).toEqual([
{ type: 'text', text: 'Lead', marks: [{ type: 'bold' }] },
{ type: 'text', text: ' rest of text' }, // separating space preserved
]);
});
it('splits an inline **bold** mid-text', () => {
const nodes = mdToInlineNodes('start **mid** end');
expect(nodes).toEqual([
{ type: 'text', text: 'start ' },
{ type: 'text', text: 'mid', marks: [{ type: 'bold' }] },
{ type: 'text', text: ' end' },
]);
});
it('passes plain text through unchanged when there is no bold', () => {
expect(mdToInlineNodes('just plain text')).toEqual([{ type: 'text', text: 'just plain text' }]);
});
it('handles a bold-only string', () => {
// A bold-only string is treated as a leading bold lead with empty remainder.
expect(mdToInlineNodes('**only**')).toEqual([
{ type: 'text', text: 'only', marks: [{ type: 'bold' }] },
]);
});
});
// ===========================================================================
describe('commentsToFootnotes', () => {
const HEADING = 'Примечания переводчика';
/**
* Build a realistic doc: body paragraphs, then the notes heading, then the
* notes orderedList. `notes` is an array of inline-text strings for existing
* list items.
*/
function buildDoc(opts: {
body: any[];
notes?: string[];
omitHeading?: boolean;
omitList?: boolean;
disclaimer?: any;
}): any {
const blocks: any[] = [];
if (opts.disclaimer) blocks.push(opts.disclaimer);
blocks.push(...opts.body);
if (!opts.omitHeading) {
blocks.push({ type: 'heading', attrs: { id: 'h', level: 2 }, content: [text(HEADING)] });
}
if (!opts.omitList) {
const items = (opts.notes ?? []).map((t, i) => ({
type: 'listItem',
attrs: { id: `li${i}` },
content: [para(text(t))],
}));
blocks.push({ type: 'orderedList', attrs: { id: 'ol' }, content: items });
}
return doc(...blocks);
}
function findNotesList(d: any): any {
return d.content.find((n: any) => n.type === 'orderedList');
}
it('is identity (renumber pass only) when there are zero comments', () => {
const d = buildDoc({ body: [para(text('plain body'))], notes: [] });
const r = commentsToFootnotes(d, []);
expect(r.consumed).toEqual([]);
expect(r.doc.content[0]).toEqual(para(text('plain body')));
});
it('inserts a marker and appends one note for one comment with a selection', () => {
const d = buildDoc({ body: [para(text('the quick brown fox'))], notes: [] });
const r = commentsToFootnotes(d, [
{ id: 'c1', content: 'A note', selection: 'quick brown' },
]);
expect(r.consumed).toEqual(['c1']);
// Body now carries "[1]" right after the selection.
const bodyText = r.doc.content[0].content.map((n: any) => n.text).join('');
expect(bodyText).toBe('the quick brown [1] fox');
// The notes list holds exactly one note built from the comment content.
const list = findNotesList(r.doc);
expect(list.content).toHaveLength(1);
expect(stripIds(list.content[0])).toEqual(
stripIds(noteItem(mdToInlineNodes('A note'))),
);
});
it('numbers many comments by BODY reading order, not comment-array order', () => {
// Body order: "alpha" then "omega". Comments are given out of order.
const d = buildDoc({
body: [para(text('alpha then omega here'))],
notes: [],
});
const r = commentsToFootnotes(d, [
{ id: 'cOmega', content: 'note for omega', selection: 'omega' },
{ id: 'cAlpha', content: 'note for alpha', selection: 'alpha' },
]);
// Both consumed, in comment-array processing order (NOT reading order, NOT sorted).
expect(r.consumed).toEqual(['cOmega', 'cAlpha']);
const bodyText = r.doc.content[0].content.map((n: any) => n.text).join('');
// "alpha" precedes "omega" in reading order => [1] then [2].
expect(bodyText).toBe('alpha [1] then omega [2] here');
const list = findNotesList(r.doc);
// Note list reordered to reading order: alpha-note first, omega-note second.
expect(list.content[0].content[0].content[0].text).toBe('note for alpha');
expect(list.content[1].content[0].content[0].text).toBe('note for omega');
});
it('skips a comment with no selection without consuming it', () => {
const d = buildDoc({ body: [para(text('body text here'))], notes: [] });
const r = commentsToFootnotes(d, [
{ id: 'c1', content: 'no anchor', selection: null },
{ id: 'c2', content: 'anchored', selection: 'body text' },
]);
expect(r.consumed).toEqual(['c2']);
const list = findNotesList(r.doc);
expect(list.content).toHaveLength(1);
});
it('skips a comment whose selection is absent (no orphan note)', () => {
const d = buildDoc({ body: [para(text('present text'))], notes: [] });
const r = commentsToFootnotes(d, [
{ id: 'c1', content: 'orphan', selection: 'this string is not in the body' },
]);
expect(r.consumed).toEqual([]); // nothing anchored
const list = findNotesList(r.doc);
expect(list.content).toHaveLength(0); // no orphan note appended
const bodyText = r.doc.content[0].content.map((n: any) => n.text).join('');
expect(bodyText).toBe('present text'); // body unchanged
});
it('renumbers existing [N] markers mixed with new placeholders by reading order', () => {
// Body already has an existing "[1]" marker after "first"; a new comment
// anchors before it in reading order at "intro".
const d = buildDoc({
body: [para(text('intro and first [1] then more'))],
notes: ['existing note one'],
});
const r = commentsToFootnotes(d, [
{ id: 'cNew', content: 'fresh note', selection: 'intro' },
]);
expect(r.consumed).toEqual(['cNew']);
const bodyText = r.doc.content[0].content.map((n: any) => n.text).join('');
// Reading order: "intro" placeholder -> [1]; existing "[1]" -> [2].
expect(bodyText).toBe('intro [1] and first [2] then more');
const list = findNotesList(r.doc);
// Notes reordered: the new note (for "intro") first, the existing note second.
expect(list.content).toHaveLength(2);
expect(list.content[0].content[0].content[0].text).toBe('fresh note');
expect(list.content[1].content[0].content[0].text).toBe('existing note one');
});
it('throws "document is inconsistent" when a body [N] has no matching note', () => {
// Body references [9] but the notes list has only 3 items.
const d = buildDoc({
body: [para(text('see footnote [9] here'))],
notes: ['n1', 'n2', 'n3'],
});
expect(() => commentsToFootnotes(d, [])).toThrow(/document is inconsistent/);
});
it('throws when the notes heading is missing', () => {
const d = buildDoc({ body: [para(text('x'))], notes: [], omitHeading: true });
expect(() => commentsToFootnotes(d, [])).toThrow(/not found/);
});
it('throws when the notes orderedList is missing', () => {
const d = buildDoc({ body: [para(text('x'))], omitList: true });
expect(() => commentsToFootnotes(d, [])).toThrow(/orderedList not found/);
});
it('does not mutate the input document', () => {
const d = buildDoc({ body: [para(text('the quick brown fox'))], notes: [] });
const snapshot = structuredClone(d);
commentsToFootnotes(d, [{ id: 'c1', content: 'A note', selection: 'quick brown' }]);
expect(d).toEqual(snapshot);
});
it('does not renumber a top-level disclaimer callout but syncs its range', () => {
// A disclaimer callout carries "[1]…[K]"; it must be preserved (not consumed
// as a footnote marker) and its range synced to the final note count.
const disclaimer = callout(para(text('Notes range [1]…[1] applies')));
const d = buildDoc({
body: [para(text('alpha and beta here'))],
notes: [],
disclaimer,
});
const r = commentsToFootnotes(d, [
{ id: 'c1', content: 'note a', selection: 'alpha' },
{ id: 'c2', content: 'note b', selection: 'beta' },
]);
expect(r.consumed.sort()).toEqual(['c1', 'c2']);
// Disclaimer callout is at index 0; its body must NOT have been renumbered
// into [1][2], it remains a "[1]…[n]" range synced to 2 notes.
const calloutText = r.doc.content[0].content[0].content
.map((n: any) => n.text)
.join('');
expect(calloutText).toBe('Notes range [1]…[2] applies');
// Body markers (index 1) are the real footnotes.
const bodyText = r.doc.content[1].content.map((n: any) => n.text).join('');
expect(bodyText).toBe('alpha [1] and beta [2] here');
const list = findNotesList(r.doc);
expect(list.content).toHaveLength(2);
});
});