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:
@@ -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>;
|
||||
|
||||
|
||||
205
test/canonicalize-extra.test.ts
Normal file
205
test/canonicalize-extra.test.ts
Normal 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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
120
test/docmost-schema-closures.test.ts
Normal file
120
test/docmost-schema-closures.test.ts
Normal 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
15
test/fixtures/corpus/08-details.json
vendored
Normal 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
17
test/fixtures/corpus/09-columns.json
vendored
Normal 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." }] }
|
||||
]}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
13
test/fixtures/corpus/10-mention-in-heading.json
vendored
Normal file
13
test/fixtures/corpus/10-mention-in-heading.json
vendored
Normal 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" } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
227
test/markdown-converter-golden.test.ts
Normal file
227
test/markdown-converter-golden.test.ts
Normal 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. & -> &, " -> ".
|
||||
const once = c({ type: 'mathBlock', attrs: { text: 'a & "b"' } });
|
||||
expect(once).toBe(
|
||||
'<div data-type="mathBlock" data-katex="true" text="a & "b""></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('<');
|
||||
expect(angled).not.toContain('>');
|
||||
});
|
||||
|
||||
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(
|
||||
'',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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 |');
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
268
test/node-ops-extra.test.ts
Normal 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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
52
test/parse-args.test.ts
Normal 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
90
test/stabilize.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
BIN
test/transforms-extra.test.ts
Normal file
BIN
test/transforms-extra.test.ts
Normal file
Binary file not shown.
Reference in New Issue
Block a user