Files
gitmost/packages/git-sync/test/canonicalize.test.ts
claude code agent 227 c44d8ba05c feat(git-sync): vendor pure converter + engine into @docmost/git-sync (Phase A.1)
First step of docs/git-sync-plan.md. New workspace package @docmost/git-sync
vendoring the PURE parts from docmost-sync (HEAD b03eb35):
- lib: markdown-converter, markdown-document, canonicalize, docmost-schema,
  node-ops, diff, and an extracted markdown-to-prosemirror (only the pure
  marked->HTML->generateJSON path from upstream collaboration.ts; no websocket).
- engine (pure, no IO): reconcile, layout, sanitize, stabilize, loop-guard.
Ported the upstream pure-module + round-trip corpus tests (vitest): 314 pass,
3 expected upstream known-limitation fails. tsc clean. No server wiring yet.

docmost-schema inlines getStyleProperty (as packages/mcp does — @tiptap/core
3.20.4 doesn't export it). IO engine (pull/push/git/settings) deferred to later
Phase A/B steps; the editor-ext idempotency gate (plan §13.1) is the next step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:17:23 +03:00

303 lines
9.3 KiB
TypeScript

import { describe, expect, it } from 'vitest';
// Import via the package barrel to also assert the symbols are re-exported.
import { canonicalizeContent, docsCanonicallyEqual } from 'docmost-client';
describe('canonicalizeContent', () => {
it('strips node-level attrs.id, recursively', () => {
const input = {
type: 'doc',
content: [
{
type: 'heading',
attrs: { id: 'h-1', level: 2 },
content: [{ type: 'text', text: 'Title' }],
},
],
};
const out = canonicalizeContent(input);
expect(out.content[0].attrs).toEqual({ level: 2 });
// No `id` survives anywhere in the canonical tree.
expect(JSON.stringify(out)).not.toContain('"id"');
});
it('drops null/undefined attrs but keeps every non-null attr', () => {
const out = canonicalizeContent({
type: 'paragraph',
attrs: {
id: 'p-1',
indent: null,
textAlign: undefined,
level: 0,
keep: 'yes',
},
content: [],
});
// null/undefined gone; non-null values (incl. 0 and false) kept.
expect(out.attrs).toEqual({ keep: 'yes', level: 0 });
});
it('removes an attrs object that becomes empty after pruning', () => {
const out = canonicalizeContent({
type: 'paragraph',
attrs: { id: 'p-1', indent: null, textAlign: null },
content: [{ type: 'text', text: 'x' }],
});
// attrs had only an id + null defaults -> the whole attrs key is dropped.
expect('attrs' in out).toBe(false);
expect(out).toEqual({
type: 'paragraph',
content: [{ type: 'text', text: 'x' }],
});
});
it('treats {attrs:{}} as equivalent to no attrs', () => {
const withEmpty = canonicalizeContent({ type: 'paragraph', attrs: {} });
const without = canonicalizeContent({ type: 'paragraph' });
expect(withEmpty).toEqual(without);
});
it('keeps comment marks + commentId but normalizes resolved:false default (SPEC §3 anchor)', () => {
const out = canonicalizeContent({
type: 'text',
text: 'anchored',
marks: [
{ type: 'comment', attrs: { commentId: 'cmt-1', resolved: false } },
],
});
// The comment mark is preserved; commentId (a meaningful anchor) survives,
// but the `resolved: false` schema default is normalized away.
expect(out.marks).toEqual([
{ type: 'comment', attrs: { commentId: 'cmt-1' } },
]);
});
it('drops known non-null schema defaults (link target/rel, comment resolved)', () => {
const out = canonicalizeContent({
type: 'text',
text: 'a link',
marks: [
{
type: 'link',
attrs: {
href: 'https://example.com/page',
target: '_blank',
rel: 'noopener noreferrer nofollow',
},
},
],
});
// href (non-default) kept; target/rel (schema defaults) dropped.
expect(out.marks).toEqual([
{ type: 'link', attrs: { href: 'https://example.com/page' } },
]);
});
it('keeps a NON-default value that happens to share an attr name (orderedList start:5)', () => {
const out = canonicalizeContent({
type: 'orderedList',
attrs: { id: 'ol-1', start: 5 },
content: [],
});
// start:5 is NOT the default (1), so it must survive.
expect(out.attrs).toEqual({ start: 5 });
});
it('keeps meaningful node/mark attrs (level, language, href, src, width)', () => {
const out = canonicalizeContent({
type: 'doc',
content: [
{
type: 'codeBlock',
attrs: { id: 'c-1', language: 'js' },
content: [{ type: 'text', text: 'x' }],
},
{
type: 'image',
attrs: { id: 'i-1', src: '/a.png', width: 100, height: null },
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'link',
marks: [{ type: 'link', attrs: { href: 'https://e.com' } }],
},
],
},
],
});
expect(out.content[0].attrs).toEqual({ language: 'js' });
expect(out.content[1].attrs).toEqual({ src: '/a.png', width: 100 });
expect(out.content[2].content[0].marks[0].attrs).toEqual({
href: 'https://e.com',
});
});
it('preserves text, type and content order exactly', () => {
const input = {
type: 'paragraph',
content: [
{ type: 'text', text: 'one' },
{ type: 'text', text: 'two', marks: [{ type: 'bold' }] },
{ type: 'text', text: 'three' },
],
};
const out = canonicalizeContent(input);
expect(out.content.map((n: any) => n.text)).toEqual([
'one',
'two',
'three',
]);
expect(out.content[1].marks).toEqual([{ type: 'bold' }]);
});
it('drops an empty marks array (marks:[] === no marks)', () => {
const out = canonicalizeContent({ type: 'text', text: 'x', marks: [] });
expect('marks' in out).toBe(false);
});
it('does not mutate its input (frozen tree passes through unchanged)', () => {
const input = Object.freeze({
type: 'doc',
content: Object.freeze([
Object.freeze({
type: 'paragraph',
attrs: Object.freeze({ id: 'p-1', indent: null }),
content: Object.freeze([Object.freeze({ type: 'text', text: 'x' })]),
}),
]),
});
const before = JSON.stringify(input);
const out = canonicalizeContent(input);
// Input is structurally identical after the call.
expect(JSON.stringify(input)).toBe(before);
// A fresh tree is returned.
expect(out).not.toBe(input);
expect('attrs' in out.content[0]).toBe(false);
});
});
describe('docsCanonicallyEqual', () => {
it('is true when docs differ only by block ids', () => {
const a = {
type: 'doc',
content: [
{ type: 'heading', attrs: { id: 'h-1', level: 1 }, content: [] },
],
};
const b = {
type: 'doc',
content: [
{ type: 'heading', attrs: { id: 'h-DIFFERENT', level: 1 }, content: [] },
],
};
expect(docsCanonicallyEqual(a, b)).toBe(true);
});
it('is true when one side omits an attr the other sets to default null', () => {
const a = {
type: 'paragraph',
attrs: { id: 'p-1' },
content: [{ type: 'text', text: 'x' }],
};
const b = {
type: 'paragraph',
attrs: { id: 'p-2', indent: null, textAlign: null },
content: [{ type: 'text', text: 'x' }],
};
expect(docsCanonicallyEqual(a, b)).toBe(true);
});
it('is key-order-insensitive for attrs', () => {
const a = { type: 'image', attrs: { src: '/a.png', width: 10 } };
const b = { type: 'image', attrs: { width: 10, src: '/a.png' } };
expect(docsCanonicallyEqual(a, b)).toBe(true);
});
it('is false for a real text difference', () => {
const a = { type: 'text', text: 'hello' };
const b = { type: 'text', text: 'world' };
expect(docsCanonicallyEqual(a, b)).toBe(false);
});
it('is false for a real attr difference (different level)', () => {
const a = { type: 'heading', attrs: { id: 'x', level: 1 } };
const b = { type: 'heading', attrs: { id: 'y', level: 2 } };
expect(docsCanonicallyEqual(a, b)).toBe(false);
});
it('is false when a meaningful mark attr differs (commentId)', () => {
const a = {
type: 'text',
text: 'x',
marks: [{ type: 'comment', attrs: { commentId: 'cmt-1' } }],
};
const b = {
type: 'text',
text: 'x',
marks: [{ type: 'comment', attrs: { commentId: 'cmt-2' } }],
};
expect(docsCanonicallyEqual(a, b)).toBe(false);
});
it('is true when a link has only href vs one with the schema-default target/rel', () => {
const a = {
type: 'text',
text: 'link',
marks: [{ type: 'link', attrs: { href: 'https://example.com' } }],
};
const b = {
type: 'text',
text: 'link',
marks: [
{
type: 'link',
attrs: {
href: 'https://example.com',
target: '_blank',
rel: 'noopener noreferrer nofollow',
},
},
],
};
expect(docsCanonicallyEqual(a, b)).toBe(true);
});
it('is true when an orderedList omits start vs one with the default start:1', () => {
const a = { type: 'orderedList', content: [] };
const b = { type: 'orderedList', attrs: { start: 1 }, content: [] };
expect(docsCanonicallyEqual(a, b)).toBe(true);
});
it('is false when an orderedList has a non-default start (5 vs absent)', () => {
const a = { type: 'orderedList', content: [] };
const b = { type: 'orderedList', attrs: { start: 5 }, content: [] };
expect(docsCanonicallyEqual(a, b)).toBe(false);
});
it('is true when a comment mark omits resolved vs one with the default false', () => {
const a = {
type: 'text',
text: 'x',
marks: [{ type: 'comment', attrs: { commentId: 'cmt-1' } }],
};
const b = {
type: 'text',
text: 'x',
marks: [{ type: 'comment', attrs: { commentId: 'cmt-1', resolved: false } }],
};
expect(docsCanonicallyEqual(a, b)).toBe(true);
});
it('is false when a comment mark is dropped entirely', () => {
const a = {
type: 'text',
text: 'x',
marks: [{ type: 'comment', attrs: { commentId: 'cmt-1' } }],
};
const b = { type: 'text', text: 'x' };
expect(docsCanonicallyEqual(a, b)).toBe(false);
});
});