test(sync): implement test-strategy Phase 1-2 (pure unit/golden/property), +102 tests

Work through test-strategy-report.md, high-ROI no-refactor subset (no regen).

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

Deferred (Phase 3-4, refactor-gated): pull/collab/client-REST/git-merge integration.
This commit is contained in:
vvzvlad
2026-06-17 01:01:26 +03:00
parent f68168e3c1
commit d9d8538846
19 changed files with 1224 additions and 2 deletions

View File

@@ -125,6 +125,17 @@ describe('getCollabToken', () => {
expect(captured.message).toContain('Failed to get collab token: 403');
});
it('returns undefined when the response carries no token on either path', async () => {
// Neither data.data.token nor data.token is present. The source does
// `data.data?.token || data.token`, so both are undefined and it returns
// undefined rather than throwing (documented behaviour / latent bug, §7).
mock.onPost(`${BASE_URL}/auth/collab-token`).reply(200, { data: {} });
const token = await getCollabToken(BASE_URL, API_TOKEN);
expect(token).toBeUndefined();
});
it('rethrows a non-axios error unwrapped (status untouched)', async () => {
const boom = new Error('plain failure');
// A handler that throws a non-axios error from inside the request lifecycle.
@@ -217,6 +228,17 @@ describe('performLogin', () => {
).rejects.toThrow('No Set-Cookie header found in login response');
});
it('throws "No authToken cookie" for an EMPTY set-cookie array (distinct from missing header)', async () => {
// An empty array is TRUTHY, so it passes the `!cookies` missing-header guard
// and reaches the `.find(...)` — which returns undefined, so the distinct
// "No authToken cookie found" error fires (not the missing-header one).
mock.onPost(`${BASE_URL}/auth/login`).reply(200, {}, { 'set-cookie': [] });
await expect(
performLogin(BASE_URL, 'user@example.com', 'pw'),
).rejects.toThrow('No authToken cookie found in login response');
});
describe('error logging (DEBUG gating)', () => {
let errorSpy: ReturnType<typeof vi.spyOn>;

View File

@@ -0,0 +1,205 @@
import { describe, expect, it } from 'vitest';
import fc from 'fast-check';
// Barrel import (R-Infra alias resolves this to packages/docmost-client/src so
// coverage measures the real source, not stale dist).
import { canonicalizeContent, docsCanonicallyEqual } from 'docmost-client';
// ---------------------------------------------------------------------------
// Gaps NOT covered by canonicalize.test.ts (test-strategy report §2 diff):
// - the *.align family (drawio/excalidraw/video/youtube/embed): a "center"
// default is dropped, a non-default value is kept;
// - comment.resolved: TRUE is PRESERVED (only resolved:false is normalized);
// - link.target / link.rel NON-default values are kept;
// - property: canonicalizeContent is a fixpoint, docsCanonicallyEqual is
// reflexive and symmetric.
// The base file already covers id-stripping, null-drop, link/comment/orderedList
// default-drop, key-order insensitivity, and a real-diff negative — not re-added.
// ---------------------------------------------------------------------------
describe('canonicalizeContent — *.align default family', () => {
// Every diagram/media node whose schema `align` defaults to "center".
const alignTypes = ['drawio', 'excalidraw', 'video', 'youtube', 'embed'];
for (const type of alignTypes) {
it(`${type}: align "center" (the schema default) is dropped`, () => {
const out = canonicalizeContent({
type,
attrs: { id: 'n-1', src: '/x', align: 'center' },
});
// align==default removed; the meaningful src survives.
expect(out.attrs).toEqual({ src: '/x' });
});
it(`${type}: a NON-default align (e.g. "right") is kept`, () => {
const out = canonicalizeContent({
type,
attrs: { id: 'n-1', src: '/x', align: 'right' },
});
expect(out.attrs).toEqual({ src: '/x', align: 'right' });
});
}
it('image align is NOT in KNOWN_DEFAULTS: a non-null align survives, null is dropped', () => {
// image.align defaults to null, so it is handled by the null-drop rule and
// a real value ("left") must be kept (no spurious default match).
const kept = canonicalizeContent({
type: 'image',
attrs: { id: 'i-1', src: '/a.png', align: 'left' },
});
expect(kept.attrs).toEqual({ src: '/a.png', align: 'left' });
// An image with align:"center" must KEEP it (center is NOT a default for
// image, only for the diagram/media family) — guards against over-matching.
const center = canonicalizeContent({
type: 'image',
attrs: { id: 'i-2', src: '/b.png', align: 'center' },
});
expect(center.attrs).toEqual({ src: '/b.png', align: 'center' });
});
});
describe('canonicalizeContent — comment.resolved:true preserved (SPEC §11 L66)', () => {
it('keeps resolved:true (a legitimate change, not a default to normalize away)', () => {
const out = canonicalizeContent({
type: 'text',
text: 'anchored',
marks: [{ type: 'comment', attrs: { commentId: 'cmt-1', resolved: true } }],
});
// resolved:true is NON-default; it must survive alongside the commentId so a
// resolve-vs-unresolved divergence is not falsely reported as equal.
expect(out.marks).toEqual([
{ type: 'comment', attrs: { commentId: 'cmt-1', resolved: true } },
]);
});
it('a resolved:true comment is NOT canonically equal to an unresolved one', () => {
const resolved = {
type: 'text',
text: 'x',
marks: [{ type: 'comment', attrs: { commentId: 'c', resolved: true } }],
};
const open = {
type: 'text',
text: 'x',
marks: [{ type: 'comment', attrs: { commentId: 'c' } }],
};
expect(docsCanonicallyEqual(resolved, open)).toBe(false);
});
});
describe('canonicalizeContent — link non-default target/rel kept', () => {
it('keeps a NON-default link.target (e.g. "_self")', () => {
const out = canonicalizeContent({
type: 'text',
text: 'l',
marks: [{ type: 'link', attrs: { href: 'https://e.com', target: '_self' } }],
});
// _self != the "_blank" default, so target must survive.
expect(out.marks).toEqual([
{ type: 'link', attrs: { href: 'https://e.com', target: '_self' } },
]);
});
it('keeps a NON-default link.rel', () => {
const out = canonicalizeContent({
type: 'text',
text: 'l',
marks: [{ type: 'link', attrs: { href: 'https://e.com', rel: 'nofollow' } }],
});
expect(out.marks).toEqual([
{ type: 'link', attrs: { href: 'https://e.com', rel: 'nofollow' } },
]);
});
});
// ---------------------------------------------------------------------------
// Property-based oracle checks (SPEC §11). The generated trees mix node/mark
// types, ids, null attrs, known-default attrs and meaningful attrs, so the
// invariants are exercised across the whole canonicalization surface.
// ---------------------------------------------------------------------------
// An attribute value: a meaningful value, a null/undefined, a block id, or a
// known schema default — so pruning, id-drop, null-drop and default-drop all
// fire during shrinking.
const attrValueArb = fc.oneof(
fc.string({ minLength: 1, maxLength: 6 }),
fc.integer({ min: 0, max: 9 }),
fc.boolean(),
fc.constant(null),
);
// A recursive ProseMirror-ish node arbitrary (bounded depth) with type, attrs
// (incl. an id and possibly a known default), optional marks and content.
const nodeArb: fc.Arbitrary<any> = fc.letrec((tie) => ({
node: fc.record(
{
type: fc.constantFrom(
'paragraph',
'heading',
'orderedList',
'drawio',
'video',
'text',
),
text: fc.option(fc.string({ minLength: 0, maxLength: 5 }), { nil: undefined }),
attrs: fc.option(
fc.dictionary(
fc.constantFrom('id', 'level', 'start', 'align', 'src', 'indent', 'keep'),
attrValueArb,
{ maxKeys: 4 },
),
{ nil: undefined },
),
marks: fc.option(
fc.array(
fc.record({
type: fc.constantFrom('bold', 'link', 'comment'),
attrs: fc.option(
fc.dictionary(
fc.constantFrom('href', 'target', 'rel', 'commentId', 'resolved'),
fc.oneof(attrValueArb, fc.constant('_blank')),
{ maxKeys: 3 },
),
{ nil: undefined },
),
}),
{ maxLength: 2 },
),
{ nil: undefined },
),
content: fc.option(fc.array(tie('node'), { maxLength: 2 }), { nil: undefined }),
},
{ requiredKeys: ['type'] },
),
})).node;
describe('canonicalizeContent — property invariants (SPEC §11 oracle)', () => {
it('is a fixpoint: f(f(x)) === f(x)', () => {
fc.assert(
fc.property(nodeArb, (node) => {
const once = canonicalizeContent(node);
const twice = canonicalizeContent(once);
// The canonical form must already be stable under a second pass.
expect(twice).toEqual(once);
}),
{ numRuns: 300 },
);
});
it('docsCanonicallyEqual is reflexive: equal(x, x) is always true', () => {
fc.assert(
fc.property(nodeArb, (node) => {
expect(docsCanonicallyEqual(node, node)).toBe(true);
}),
{ numRuns: 300 },
);
});
it('docsCanonicallyEqual is symmetric: equal(a, b) === equal(b, a)', () => {
fc.assert(
fc.property(nodeArb, nodeArb, (a, b) => {
expect(docsCanonicallyEqual(a, b)).toBe(docsCanonicallyEqual(b, a));
}),
{ numRuns: 300 },
);
});
});

View File

@@ -264,6 +264,24 @@ describe('diffDocs', () => {
expect(result.summary).toEqual({ inserted: 0, deleted: 0, blocksChanged: 0 });
});
it('still computes integrity (images/tables/callouts/footnotes) in the coarse-fallback branch', () => {
// Regression guard: integrity is computed BEFORE the try/catch, so a
// pathological pair that forces the fallback must NOT zero the integrity
// counts. The unknown node forces the precise path to throw (fellBack).
const oldDoc = doc(image(), callout(), table(), para('a [1]'), { type: '___nope' });
const newDoc = doc(image(), image(), table(), para('b [2] [3]'));
const result = diffDocs(oldDoc, newDoc);
// The fallback was taken...
expect(result.markdown).toContain('coarse block-level diff shown.');
// ...yet every integrity tuple is the real count, not [0,0].
expect(result.integrity.images).toEqual([1, 2]);
expect(result.integrity.callouts).toEqual([1, 0]);
expect(result.integrity.tables).toEqual([1, 1]);
// Footnote markers are counted from both docs even under the fallback.
expect(result.integrity.footnoteMarkers).toEqual([[1], [2, 3]]);
});
it('reports both a deletion and an insertion in the fallback path', () => {
const oldDoc = doc(para('old paragraph'), { type: '___nope' });
const newDoc = doc(para('new paragraph'));

View File

@@ -37,4 +37,42 @@ describe('firstDivergence', () => {
expect(d!.a).toBe('here');
expect(d!.b).toBeUndefined();
});
// --- branches NOT covered above (report §2) ---
it('reports a type mismatch (string vs number) at the current path', () => {
const d = firstDivergence({ k: '1' }, { k: 1 });
expect(d).toEqual({ path: '$.k', a: '1', b: 1 });
});
it('reports null-vs-object as a divergence (typeof object both, but one is null)', () => {
// typeof null === 'object', so this exercises the explicit `a===null` guard.
const d = firstDivergence({ k: null }, { k: { nested: true } });
expect(d).toEqual({ path: '$.k', a: null, b: { nested: true } });
});
it('reports array-vs-object as a divergence (aIsArr !== bIsArr)', () => {
const d = firstDivergence({ k: [1, 2] }, { k: { 0: 1, 1: 2 } });
expect(d).not.toBeNull();
expect(d!.path).toBe('$.k');
expect(Array.isArray(d!.a)).toBe(true);
expect(Array.isArray(d!.b)).toBe(false);
});
it('returns null for two equal primitives (the a === b fast path)', () => {
expect(firstDivergence(7, 7)).toBeNull();
expect(firstDivergence('same', 'same')).toBeNull();
expect(firstDivergence(null, null)).toBeNull();
expect(firstDivergence(true, true)).toBeNull();
});
it('reports a key present only in b (a-side undefined)', () => {
// The key union scans both objects, so a key only in `b` is detected with
// the a-side value undefined.
const d = firstDivergence({ shared: 1 }, { shared: 1, extra: 'b-only' });
expect(d).not.toBeNull();
expect(d!.path).toBe('$.extra');
expect(d!.a).toBeUndefined();
expect(d!.b).toBe('b-only');
});
});

View File

@@ -0,0 +1,120 @@
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();
});
});

15
test/fixtures/corpus/08-details.json vendored Normal file
View File

@@ -0,0 +1,15 @@
{
"type": "doc",
"content": [
{
"type": "details",
"attrs": { "open": false },
"content": [
{ "type": "detailsSummary", "content": [{ "type": "text", "text": "Click to expand" }] },
{ "type": "detailsContent", "content": [
{ "type": "paragraph", "content": [{ "type": "text", "text": "Hidden body paragraph." }] }
]}
]
}
]
}

17
test/fixtures/corpus/09-columns.json vendored Normal file
View File

@@ -0,0 +1,17 @@
{
"type": "doc",
"content": [
{
"type": "columns",
"attrs": { "layout": "two", "widthMode": "normal" },
"content": [
{ "type": "column", "attrs": { "width": 50 }, "content": [
{ "type": "paragraph", "content": [{ "type": "text", "text": "Left column." }] }
]},
{ "type": "column", "attrs": { "width": 50 }, "content": [
{ "type": "paragraph", "content": [{ "type": "text", "text": "Right column." }] }
]}
]
}
]
}

View File

@@ -0,0 +1,13 @@
{
"type": "doc",
"content": [
{
"type": "heading",
"attrs": { "level": 2 },
"content": [
{ "type": "text", "text": "Notes for " },
{ "type": "mention", "attrs": { "id": "m-2", "label": "Bob", "entityType": "user", "entityId": "u-2", "slugId": "s-2", "creatorId": "c-2" } }
]
}
]
}

View File

@@ -284,5 +284,46 @@ describe('applyTextEdits', () => {
{ find: 'gamma', replacements: 1 },
]);
});
it('a LATE edit failure leaves the INPUT document unmodified (no partial mutation)', () => {
// The first edit is valid but a later one is absent and throws. Because the
// function mutates only a deep copy and throws before returning, the
// caller's input must be byte-identical afterwards (no partial apply leaks).
const doc = singleParagraph('alpha beta');
const snapshot = structuredClone(doc);
expect(() =>
applyTextEdits(doc, [
{ find: 'alpha', replace: 'A' }, // would succeed
{ find: 'MISSING', replace: 'X' }, // throws "text not found"
]),
).toThrow(/text not found/);
// Input doc is untouched: the successful first edit was applied only to the
// internal copy, which was discarded when the second edit threw.
expect(doc).toEqual(snapshot);
});
});
describe('find === replace and self-containing replaceAll', () => {
it('a find === replace edit is a no-op on the text but still reports a replacement', () => {
const doc = singleParagraph('hello world');
const { doc: result, results } = applyTextEdits(doc, [
{ find: 'world', replace: 'world' },
]);
expect(result.content[0].content[0].text).toBe('hello world');
// The replacement still counts (it spliced the same value back in).
expect(results).toEqual([{ find: 'world', replacements: 1 }]);
});
it('replaceAll where the replacement CONTAINS the find ("a" -> "aa") does not re-scan', () => {
// split().join() replaces all original occurrences in one pass; it must NOT
// re-match the inserted "aa" (which would loop). "a a a" -> "aa aa aa".
const doc = singleParagraph('a a a');
const { doc: result, results } = applyTextEdits(doc, [
{ find: 'a', replace: 'aa', replaceAll: true },
]);
expect(result.content[0].content[0].text).toBe('aa aa aa');
// Exactly the 3 original occurrences are counted, not 6.
expect(results).toEqual([{ find: 'a', replacements: 3 }]);
});
});
});

View File

@@ -0,0 +1,227 @@
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';
// markdown-converter.ts is the weakest pure module (report §2). These golden
// tests close the gaps the base markdown-converter.test.ts leaves open:
// columns/column wrapper, embed/audio/pdf (used to emit nothing), drawio/
// excalidraw data-align presence rule, the remaining inline-mark matrix,
// paragraph.textAlign, subpages + unknown-in-container fallback, escaping
// idempotence, table-cell pipe/newline sanitization, and empty/single-column
// tables. Cases already asserted in the base file are NOT repeated.
const doc = (...nodes: any[]) => ({ type: 'doc', content: nodes });
const c = (node: any) => convertProseMirrorToMarkdown(doc(node));
const text = (t: string, marks?: any[]) =>
marks ? { type: 'text', text: t, marks } : { type: 'text', text: t };
const para = (...inline: any[]) => ({ type: 'paragraph', content: inline });
describe('columns / column (raw-HTML layout wrapper)', () => {
it('wraps a multi-column layout as nested data-type divs with the children inside (regression: children unwrapped)', () => {
const out = c({
type: 'columns',
attrs: { layout: 'two' },
content: [
{ type: 'column', attrs: { width: 50 }, content: [para(text('L'))] },
{ type: 'column', content: [para(text('R'))] },
],
});
expect(out).toBe(
'<div data-type="columns" data-layout="two">' +
'<div data-type="column" data-width="50"><p>L</p></div>' +
'<div data-type="column"><p>R</p></div>' +
'</div>',
);
});
it('omits the default widthMode "normal" but emits a non-default one', () => {
const normal = c({
type: 'columns',
attrs: { layout: 'two', widthMode: 'normal' },
content: [{ type: 'column', content: [para(text('x'))] }],
});
expect(normal).not.toContain('data-width-mode');
const wide = c({
type: 'columns',
attrs: { layout: 'two', widthMode: 'full' },
content: [{ type: 'column', content: [para(text('x'))] }],
});
expect(wide).toContain('data-width-mode="full"');
});
});
describe('embed / audio / pdf (previously emitted nothing — invisible regression)', () => {
it('embed emits div[data-type="embed"] with src/provider', () => {
expect(c({ type: 'embed', attrs: { src: 'https://x.com/e', provider: 'iframe' } })).toBe(
'<div data-type="embed" data-src="https://x.com/e" data-provider="iframe"></div>',
);
});
it('audio emits a div-wrapped <audio> with src', () => {
expect(c({ type: 'audio', attrs: { src: '/a.mp3' } })).toBe(
'<div><audio src="/a.mp3"></audio></div>',
);
});
it('pdf emits div[data-type="pdf"] with src and name', () => {
expect(c({ type: 'pdf', attrs: { src: '/d.pdf', name: 'd.pdf' } })).toBe(
'<div data-type="pdf" src="/d.pdf" data-name="d.pdf"></div>',
);
});
});
describe('drawio / excalidraw data-align asymmetry (SPEC §11)', () => {
it('drawio: data-align is ABSENT when align is unset', () => {
const out = c({ type: 'drawio', attrs: { src: '/d.drawio' } });
expect(out).toBe('<div data-type="drawio" data-src="/d.drawio"></div>');
expect(out).not.toContain('data-align');
});
it('drawio: data-align is PRESENT for a non-default align', () => {
expect(c({ type: 'drawio', attrs: { src: '/d.drawio', align: 'right' } })).toBe(
'<div data-type="drawio" data-src="/d.drawio" data-align="right"></div>',
);
});
it('excalidraw: data-align is ABSENT when align is unset', () => {
const out = c({ type: 'excalidraw', attrs: { src: '/e.excalidraw' } });
expect(out).toBe('<div data-type="excalidraw" data-src="/e.excalidraw"></div>');
expect(out).not.toContain('data-align');
});
});
describe('inline-mark matrix (underline/sub/sup/highlight±color/textStyle/comment)', () => {
it('emits the schema HTML for each remaining inline mark in one matrix', () => {
const cases: [any[], string][] = [
[[{ type: 'underline' }], '<u>m</u>'],
[[{ type: 'subscript' }], '<sub>m</sub>'],
[[{ type: 'superscript' }], '<sup>m</sup>'],
[[{ type: 'highlight' }], '<mark>m</mark>'],
[
[{ type: 'highlight', attrs: { color: '#ff0000' } }],
'<mark style="background-color: #ff0000">m</mark>',
],
[
[{ type: 'textStyle', attrs: { color: '#00ff00' } }],
'<span style="color: #00ff00">m</span>',
],
[
[{ type: 'comment', attrs: { commentId: 'cid-1' } }],
'<span data-comment-id="cid-1">m</span>',
],
[
[{ type: 'comment', attrs: { commentId: 'cid-1', resolved: true } }],
'<span data-comment-id="cid-1" data-resolved="true">m</span>',
],
];
for (const [marks, expected] of cases) {
expect(c(para(text('m', marks)))).toBe(expected);
}
});
it('a textStyle mark with no color emits nothing (plain text passes through)', () => {
expect(c(para(text('plain', [{ type: 'textStyle', attrs: {} }])))).toBe('plain');
});
it('a comment mark with no commentId emits nothing (plain text)', () => {
expect(c(para(text('plain', [{ type: 'comment', attrs: {} }])))).toBe('plain');
});
});
describe('paragraph.textAlign -> <div align>', () => {
it('non-default alignment wraps the paragraph in <div align="...">', () => {
expect(c({ type: 'paragraph', attrs: { textAlign: 'center' }, content: [text('x')] })).toBe(
'<div align="center">x</div>',
);
});
it('textAlign "left" (the default) is NOT wrapped', () => {
expect(c({ type: 'paragraph', attrs: { textAlign: 'left' }, content: [text('x')] })).toBe('x');
});
});
describe('subpages token + unknown-in-container fallback', () => {
it('subpages emits the {{SUBPAGES}} placeholder token', () => {
expect(c({ type: 'subpages' })).toBe('{{SUBPAGES}}');
});
it('an unknown block inside a raw-HTML container is wrapped in <div> (never markdown)', () => {
// Inside columns the children are rendered as HTML; an unknown block type
// must NOT fall back to markdown (which would land as literal text on
// re-import). It is wrapped in a <div> so its children survive.
const out = c({
type: 'columns',
attrs: { layout: 'two' },
content: [
{ type: 'column', content: [{ type: 'weirdBlock', content: [para(text('kept'))] }] },
],
});
expect(out).toBe(
'<div data-type="columns" data-layout="two">' +
'<div data-type="column"><div><p>kept</p></div></div>' +
'</div>',
);
});
it('an unknown TOP-LEVEL block falls back to its children only (markdown context)', () => {
expect(c({ type: 'totallyUnknown', content: [text('inner')] })).toBe('inner');
});
});
describe('escaping idempotence (SPEC §11 phantom-diff guard)', () => {
it('escapeAttr escapes ONLY & and " in an attribute context, and is idempotent', () => {
// The mathBlock `text` attr goes through escapeAttr. & -> &amp;, " -> &quot;.
const once = c({ type: 'mathBlock', attrs: { text: 'a & "b"' } });
expect(once).toBe(
'<div data-type="mathBlock" data-katex="true" text="a &amp; &quot;b&quot;"></div>',
);
// < and > are deliberately NOT escaped (would accumulate on round-trips).
const angled = c({ type: 'mathBlock', attrs: { text: 'a < b > c' } });
expect(angled).toContain('text="a < b > c"');
expect(angled).not.toContain('&lt;');
expect(angled).not.toContain('&gt;');
});
it('encodeMdUrl turns a space into %20 in an image src (single inert URL token)', () => {
expect(c({ type: 'image', attrs: { alt: 'c', src: '/my pic.png' } })).toBe(
'![c](/my%20pic.png)',
);
});
});
describe('table-cell sanitization (| and newline must not corrupt the GFM row)', () => {
it('escapes a literal pipe and collapses an inter-block newline in a cell', () => {
// A cell with a pipe in one paragraph and a second block paragraph: the pipe
// is escaped to \| and the block join (a space) keeps the row intact.
const out = c({
type: 'table',
content: [
{ type: 'tableRow', content: [
{ type: 'tableHeader', content: [para(text('H'))] },
]},
{ type: 'tableRow', content: [
{ type: 'tableCell', content: [para(text('a|b')), para(text('c'))] },
]},
],
});
expect(out).toBe('| H |\n| --- |\n| a\\|b c |');
});
});
describe('empty / single-column tables', () => {
it('a table with no rows renders as the empty string', () => {
expect(c({ type: 'table', content: [] })).toBe('');
});
it('a single-column GFM table emits one column with a "---" separator', () => {
const out = c({
type: 'table',
content: [
{ type: 'tableRow', content: [{ type: 'tableHeader', content: [para(text('Only'))] }] },
{ type: 'tableRow', content: [{ type: 'tableCell', content: [para(text('v'))] }] },
],
});
expect(out).toBe('| Only |\n| --- |\n| v |');
});
});

View File

@@ -126,6 +126,25 @@ describe('serializeDocmostMarkdown / parseDocmostMarkdown', () => {
});
});
// ---------------------------------------------------------------------------
describe('end-anchored comments closer tolerates CRLF + trailing whitespace', () => {
it('captures the final comments block when its "-->" closer has CRLF and trailing spaces', () => {
// The closer regex is /\r?\n-->[ \t]*\r?\n?\s*$/. Build a document whose
// trailing comments block uses CRLF line endings AND has trailing spaces
// after the "-->" closer, then assert it is still recognised as the
// document-ending block (and the body is not polluted by it).
const metaLine = JSON.stringify(meta);
const crlfDoc =
`<!-- docmost:meta\r\n${metaLine}\r\n-->\r\n\r\n` +
`the body line\r\n\r\n` +
`<!-- docmost:comments\r\n[{"id":"c-crlf"}]\r\n--> \r\n`;
const parsed = parseDocmostMarkdown(crlfDoc);
expect(parsed.meta).toEqual(meta);
expect(parsed.body).toBe('the body line');
expect(parsed.comments).toEqual([{ id: 'c-crlf' }]);
});
});
// ---------------------------------------------------------------------------
describe('malformed JSON throws a clear error', () => {
it('throws on malformed meta JSON', () => {

View File

@@ -190,6 +190,22 @@ const markedTextRunArb: fc.Arbitrary<any> = fc.oneof(
text: t,
marks: [{ type: 'link', attrs: title ? { href, title } : { href } }],
})),
// Inline COMMENT anchor (SPEC §3): a span[data-comment-id] that must survive
// the round-trip byte-for-byte. The commentId is an alphanumeric token (no
// attribute-breaking chars), and `resolved` rides as data-resolved="true"
// only when true — both forms were verified byte-stable.
fc
.tuple(safeTextArb, fc.stringMatching(/^[A-Za-z0-9]{4,10}$/), fc.boolean())
.map(([t, commentId, resolved]) => ({
type: 'text',
text: t,
marks: [
{
type: 'comment',
attrs: resolved ? { commentId, resolved: true } : { commentId },
},
],
})),
);
// Inline math node carrying LaTeX that includes the `a < b` the task asks for.
@@ -549,6 +565,7 @@ describe('markdown <-> ProseMirror round-trip (property-based)', () => {
'mark:strike',
'mark:code',
'mark:link',
'mark:comment',
]) {
expect(seen, `expected the generator to produce ${t}`).toContain(t);
}

268
test/node-ops-extra.test.ts Normal file
View File

@@ -0,0 +1,268 @@
import { describe, expect, it } from 'vitest';
import fc from 'fast-check';
import {
getNodeByRef,
replaceNodeById,
insertNodeRelative,
insertTableRow,
updateTableCell,
sanitizeForYjs,
findUnstorableAttr,
buildOutline,
} from '../packages/docmost-client/src/lib/node-ops.js';
// Gaps NOT covered by node-ops.test.ts (test-strategy report §2). The base file
// is comprehensive; these add only the missing edges: newNode-arg immutability,
// anchor-is-container routing, malformed opts, ragged/empty/no-colwidth/non-int
// insertTableRow, getNodeByRef non-object/#-1, updateTableCell empty-id refresh,
// outline 100/40 boundary, malformed marks, and the makeFreshId property.
const text = (value: string, marks?: any[]): any => {
const node: any = { type: 'text', text: value };
if (marks) node.marks = marks;
return node;
};
const para = (id: string, value = ''): any => ({
type: 'paragraph',
attrs: { id, indent: 0 },
content: value ? [text(value)] : [],
});
const cell = (
type: 'tableCell' | 'tableHeader',
paraId: string | null,
value = '',
extraAttrs: Record<string, any> = {},
): any => ({
type,
attrs: { colspan: 1, rowspan: 1, ...extraAttrs },
content: paraId == null ? [] : [para(paraId, value)],
});
const row = (cells: any[]): any => ({ type: 'tableRow', content: cells });
const doc = (...content: any[]): any => ({ type: 'doc', content });
// ===========================================================================
describe('replaceNodeById — newNode ARGUMENT immutability', () => {
it('does not mutate the caller-supplied newNode after replacement', () => {
// The doc-argument immutability is covered in the base file; this pins the
// OTHER input — the replacement node must be deep-cloned, so mutating the
// result never reaches the caller's newNode (and vice versa).
const d = doc(para('p0', 'old'), para('p1', 'old2'));
const newNode = { type: 'paragraph', attrs: { id: 'new' }, content: [text('NEW')] };
const snapshot = structuredClone(newNode);
const res = replaceNodeById(d, 'p0', newNode);
// Mutating the inserted copy must not touch the argument...
res.doc.content[0].content.push(text('mutated'));
expect(newNode).toEqual(snapshot);
// ...and mutating the argument afterwards must not touch the inserted copy.
newNode.content.push(text('later'));
expect(res.doc.content[0].content).toEqual([text('NEW'), text('mutated')]);
});
});
// ===========================================================================
describe('insertNodeRelative — container routing and malformed opts', () => {
it('routes a structural row when anchorText resolves to the TABLE block itself', () => {
// anchorText only scans top-level blocks, so it resolves to the whole table;
// the matched container IS the anchor (containerIdx === chain.length-1), so
// a row "after" must be appended inside the table, not spliced beside a row.
const table = { type: 'table', content: [row([cell('tableCell', 'r0', 'hello cell')])] };
const newRow = row([cell('tableCell', 'rNew', 'NEW')]);
const res = insertNodeRelative(doc(table), newRow, {
position: 'after',
anchorText: 'hello cell',
});
expect(res.inserted).toBe(true);
const firstCellId = (r: any) => r.content[0].content[0].attrs.id;
expect(res.doc.content[0].content.map(firstCellId)).toEqual(['r0', 'rNew']);
});
it('prepends a structural row when anchorText resolves to the table and position is "before"', () => {
const table = { type: 'table', content: [row([cell('tableCell', 'r0', 'hello cell')])] };
const newRow = row([cell('tableCell', 'rNew', 'NEW')]);
const res = insertNodeRelative(doc(table), newRow, {
position: 'before',
anchorText: 'hello cell',
});
const firstCellId = (r: any) => r.content[0].content[0].attrs.id;
expect(res.doc.content[0].content.map(firstCellId)).toEqual(['rNew', 'r0']);
});
it('is a no-op (inserted:false) for a malformed opts object', () => {
const d = doc(para('p0'));
const res = insertNodeRelative(d, para('n'), null as any);
expect(res.inserted).toBe(false);
expect(res.doc).toEqual(d);
});
});
// ===========================================================================
describe('insertTableRow — column count and index edge cases', () => {
const ragged = () => ({
type: 'table',
content: [
row([cell('tableHeader', 'h0', 'H0')]), // 1 col
row([cell('tableCell', 'c0', 'A'), cell('tableCell', 'c1', 'B')]), // 2 cols
],
});
it('derives the column count from the WIDEST row (ragged table)', () => {
// The guard counts against the widest row (2), so 3 cells throws...
expect(() => insertTableRow(doc(ragged()), '#0', ['X', 'Y', 'Z'])).toThrow(
/got 3 cell\(s\) but the table has 2 column\(s\)/,
);
// ...and a 2-cell row is padded to the widest width (2), not the header's 1.
const res = insertTableRow(doc(ragged()), '#0', ['X', 'Y']);
expect(res.doc.content[0].content[2].content).toHaveLength(2);
});
it('an EMPTY table falls back to the supplied cell count', () => {
const res = insertTableRow(doc({ type: 'table', content: [] }), '#0', ['A', 'B']);
expect(res.inserted).toBe(true);
expect(res.doc.content[0].content[0].content).toHaveLength(2);
});
it('omits colwidth entirely when the header cell has none (no undefined leak)', () => {
const noColwidth = {
type: 'table',
content: [
row([cell('tableHeader', 'h0', 'H')]),
row([cell('tableCell', 'c0', 'A')]),
],
};
const res = insertTableRow(doc(noColwidth), '#0', ['X']);
const newCellAttrs = res.doc.content[0].content[2].content[0].attrs;
expect('colwidth' in newCellAttrs).toBe(false); // not colwidth:undefined
});
it('APPENDS for a non-integer or negative index (does not throw)', () => {
const t = {
type: 'table',
content: [
row([cell('tableHeader', 'h0', 'H')]),
row([cell('tableCell', 'c0', 'A')]),
],
};
const frac = insertTableRow(doc(t), '#0', ['X'], 1.5);
expect(frac.inserted).toBe(true);
expect(frac.doc.content[0].content).toHaveLength(3); // appended at the end
const neg = insertTableRow(doc(t), '#0', ['X'], -1);
expect(neg.doc.content[0].content).toHaveLength(3);
});
});
// ===========================================================================
describe('getNodeByRef — malformed refs', () => {
it('returns null for a non-object block at a valid #n index', () => {
const d = { type: 'doc', content: [null] };
expect(getNodeByRef(d, '#0')).toBeNull();
});
it('returns null for "#-1" (the index regex does not match a negative)', () => {
const d = doc(para('p0'));
// "#-1" matches neither the "#<digits>" form nor any block id -> null.
expect(getNodeByRef(d, '#-1')).toBeNull();
});
});
// ===========================================================================
describe('updateTableCell — fresh id when the first paragraph has an empty id', () => {
it('mints a fresh id when the existing first paragraph id is the empty string', () => {
const table = {
type: 'table',
content: [
row([cell('tableHeader', 'h0', 'H')]),
row([
{
type: 'tableCell',
attrs: { colspan: 1, rowspan: 1 },
content: [{ type: 'paragraph', attrs: { id: '' }, content: [text('old')] }],
},
]),
],
};
const res = updateTableCell(doc(table), '#0', 1, 0, 'new');
const newId = res.doc.content[0].content[1].content[0].content[0].attrs.id;
// An empty id is treated as missing -> a fresh Docmost-style id is minted.
expect(newId).toMatch(/^[a-z0-9]{12}$/);
expect(newId).not.toBe('');
});
});
// ===========================================================================
describe('buildOutline — exact 100 / 40 char truncation boundaries', () => {
it('does NOT truncate firstText at exactly 100 chars but DOES at 101', () => {
const at100 = buildOutline(doc(para('p', 'x'.repeat(100))));
expect(at100[0].firstText).toBe('x'.repeat(100)); // boundary: not cut
expect(at100[0].firstText.endsWith('…')).toBe(false);
const at101 = buildOutline(doc(para('p', 'x'.repeat(101))));
expect(at101[0].firstText).toBe('x'.repeat(100) + '…'); // first char over the cap
});
it('does NOT truncate a header cell at exactly 40 chars but DOES at 41', () => {
const tableAt40 = {
type: 'table',
content: [row([cell('tableHeader', 'h', 'y'.repeat(40))])],
};
expect(buildOutline(doc(tableAt40))[0].header).toEqual(['y'.repeat(40)]);
const tableAt41 = {
type: 'table',
content: [row([cell('tableHeader', 'h', 'y'.repeat(41))])],
};
expect(buildOutline(doc(tableAt41))[0].header).toEqual(['y'.repeat(40) + '…']);
});
});
// ===========================================================================
describe('sanitizeForYjs / findUnstorableAttr — malformed marks array', () => {
const malformed = () =>
doc({
type: 'paragraph',
attrs: { id: 'p' },
content: [
text('x', [null, { type: 'link', attrs: { href: 'u', gone: undefined } }]),
],
});
it('sanitizeForYjs skips a null mark and strips undefined on the real one', () => {
const res = sanitizeForYjs(malformed());
const marks = res.content[0].content[0].marks;
expect(marks[0]).toBeNull(); // the null mark is left untouched, not crashed on
expect(marks[1].attrs).toEqual({ href: 'u' }); // undefined dropped
});
it('findUnstorableAttr skips a null mark and reports the real undefined attr path', () => {
expect(findUnstorableAttr(malformed())).toBe(
'content[0].content[0].marks[1].attrs.gone (undefined)',
);
});
});
// ===========================================================================
describe('makeFreshId — format and uniqueness (property, via insertTableRow)', () => {
it('every minted cell-paragraph id matches ^[a-z0-9]{12}$ and is globally unique', () => {
fc.assert(
fc.property(fc.integer({ min: 1, max: 5 }), (cols) => {
// Build an empty-id table of `cols` columns; the inserted row mints a
// fresh id per cell. The doc carries one pre-existing id to also assert
// the new ids never collide with it.
const headerCells = Array.from({ length: cols }, (_, i) =>
cell('tableHeader', `pre-${i}`, `H${i}`),
);
const d = doc({ type: 'table', content: [row(headerCells)] });
const res = insertTableRow(d, '#0', Array.from({ length: cols }, () => 'v'), 1);
const ids = res.doc.content[0].content[1].content.map(
(c: any) => c.content[0].attrs.id,
);
for (const id of ids) {
expect(id).toMatch(/^[a-z0-9]{12}$/);
}
// Unique within the new row AND distinct from the pre-existing ids.
expect(new Set(ids).size).toBe(ids.length);
for (const id of ids) {
expect(id.startsWith('pre-')).toBe(false);
}
}),
{ numRuns: 100 },
);
});
});

View File

@@ -135,6 +135,45 @@ describe('withPageLock', () => {
).rejects.toBe(err);
});
it('forms a FRESH chain after the previous chain has fully drained', async () => {
// After op1 fully settles, withPageLock drops the page's map entry (the
// chain has drained). A later op on the SAME page must still run and
// serialize correctly behind a brand-new chain — observed purely via
// behaviour, never by reaching into the private `chains` map.
const pageId = uniquePageId();
const order: string[] = [];
// First op: run to completion and let its tail drain the chain.
await withPageLock(pageId, async () => {
order.push('first');
return 'a';
});
// Give the tail's .then(delete) a chance to run.
await flushMicrotasks();
// A new op + an immediate second op on the freshly-empty page must still
// serialize (second waits for the new chain's head to settle).
const gate = deferred();
const second = withPageLock(pageId, async () => {
order.push('second-start');
await gate.promise;
order.push('second-end');
return 'b';
});
const third = withPageLock(pageId, async () => {
order.push('third');
return 'c';
});
await flushMicrotasks();
// The new chain serializes: third has not started while second is gated.
expect(order).toEqual(['first', 'second-start']);
gate.resolve();
await Promise.all([second, third]);
expect(order).toEqual(['first', 'second-start', 'second-end', 'third']);
});
it('serializes a third op behind the chain even after a rejection mid-chain', async () => {
const pageId = uniquePageId();
const order: string[] = [];

52
test/parse-args.test.ts Normal file
View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { parseArgs } from '../src/roundtrip.js';
// R-Cfg-1: parseArgs is exported so the CLI flag parsing is unit-testable. The
// tricky bits are the --corpus lookahead (its directory arg is optional and must
// NOT swallow a following flag), the `argv[++i]` value consumption for
// --fixture/--page, and the empty-argv default.
describe('parseArgs', () => {
it('returns an empty object for empty argv (no flags)', () => {
expect(parseArgs([])).toEqual({});
});
it('reads the value after --fixture (argv[++i])', () => {
expect(parseArgs(['--fixture', 'path/to/doc.json'])).toEqual({
fixture: 'path/to/doc.json',
});
});
it('reads the value after --page (argv[++i])', () => {
expect(parseArgs(['--page', 'page-123'])).toEqual({ page: 'page-123' });
});
it('--corpus consumes the directory when the next token is not a flag', () => {
expect(parseArgs(['--corpus', 'test/fixtures/corpus'])).toEqual({
corpus: 'test/fixtures/corpus',
});
});
it('--corpus uses the DEFAULT dir when followed by another flag (lookahead)', () => {
// The lookahead must NOT consume "--fixture" as the corpus directory; corpus
// falls back to its default and --fixture is still parsed on the next loop.
const parsed = parseArgs(['--corpus', '--fixture', 'x']);
expect(parsed.corpus).toBe('test/fixtures/corpus');
expect(parsed.fixture).toBe('x');
});
it('--corpus uses the default dir when it is the LAST token (no lookahead value)', () => {
expect(parseArgs(['--corpus'])).toEqual({ corpus: 'test/fixtures/corpus' });
});
it('a trailing --fixture with no value consumes undefined (off-by-one argv[++i])', () => {
// `argv[++i]` reads past the end -> undefined; the loop then terminates.
expect(parseArgs(['--fixture'])).toEqual({ fixture: undefined });
});
it('parses multiple flags together (page then a defaulted corpus)', () => {
const parsed = parseArgs(['--page', 'p-1', '--corpus']);
expect(parsed.page).toBe('p-1');
expect(parsed.corpus).toBe('test/fixtures/corpus');
});
});

90
test/stabilize.test.ts Normal file
View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import { stabilizePageFile, type PageMeta } from '../src/stabilize.js';
// markdownToProseMirror lives in collaboration.ts; importing it mutates the
// global DOM via jsdom at module load time (required for @tiptap/html under Node).
import { markdownToProseMirror } from '../packages/docmost-client/src/lib/collaboration.js';
import { parseDocmostMarkdown } from '../packages/docmost-client/src/lib/markdown-document.js';
// stabilize.ts (SPEC §11 normalize-on-write) was 0% covered (only the gated e2e
// touched it). stabilizePageFile is import-testable: build a small ProseMirror
// content + meta and assert (1) the normalize-on-write pass reaches a fixpoint
// (a SECOND pass over the written body is byte-identical), and (2) the meta is
// serialized verbatim, including a null parentPageId.
const meta: PageMeta = {
version: 1,
pageId: 'pg-1',
slugId: 'sl-1',
title: 'My Title',
spaceId: 'sp-1',
parentPageId: null,
};
describe('stabilizePageFile — normalize-on-write fixpoint (SPEC §11)', () => {
it('reaches a byte-identical fixpoint after one extra export/import/export pass', async () => {
// A diagram is the canonical one-pass asymmetry: drawio's `align` default of
// "center" materializes on import, so a NAIVE export differs on the second
// export. stabilizePageFile runs the convergence pass at write time, so the
// written body must already be at the fixpoint: re-importing its body and
// re-stabilizing yields the exact same bytes.
const content = {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'intro' }] },
{ type: 'drawio', attrs: { src: '/d.drawio' } },
{ type: 'paragraph', content: [{ type: 'text', text: 'outro' }] },
],
};
const file1 = await stabilizePageFile(content, meta);
// Re-import the written body and stabilize again — the second pass must be
// byte-identical to the first (the fixpoint property git relies on).
const body1 = parseDocmostMarkdown(file1).body;
const doc2 = await markdownToProseMirror(body1);
const file2 = await stabilizePageFile(doc2, meta);
expect(file2).toBe(file1);
// The materialized diagram default is present in the stabilized body (proof
// that the convergence pass actually ran, not just that two naive exports
// happened to match).
expect(body1).toContain('data-align="center"');
});
it('already-stable content is unchanged by the pass (idempotent)', async () => {
// Plain prose is already a fixpoint; stabilizing it once and twice agree.
const content = {
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'just plain text' }] }],
};
const file1 = await stabilizePageFile(content, meta);
const body1 = parseDocmostMarkdown(file1).body;
const doc2 = await markdownToProseMirror(body1);
const file2 = await stabilizePageFile(doc2, meta);
expect(file2).toBe(file1);
expect(body1).toBe('just plain text');
});
});
describe('stabilizePageFile — meta serialization', () => {
it('preserves a null parentPageId verbatim in the meta block', async () => {
const file = await stabilizePageFile(
{ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'x' }] }] },
meta,
);
const parsed = parseDocmostMarkdown(file);
// The whole meta round-trips, and parentPageId is exactly null (root page).
expect(parsed.meta).toEqual(meta);
expect(parsed.meta!.parentPageId).toBeNull();
// No trailing docmost:comments block — the sync body serializer omits it.
expect(file).not.toContain('docmost:comments');
});
it('keeps a non-null parentPageId as-is', async () => {
const childMeta: PageMeta = { ...meta, parentPageId: 'parent-99' };
const file = await stabilizePageFile(
{ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'x' }] }] },
childMeta,
);
expect(parseDocmostMarkdown(file).meta).toEqual(childMeta);
});
});

Binary file not shown.