Files
docmost-sync/test/collaboration-apply.test.ts
vvzvlad 1750058503 refactor(sync): testability seams for pull + collab; integration tests
Behavior-preserving refactors (R-Collab-1, R-Pull-1, R-Pull-2) to unblock testing,
plus the integration tests they enable.

- collaboration: extract applyTransformToYdoc from onSynced; onSynced stays
  synchronous (NO await between Yjs read and write — SPEC §2 atomicity preserved)
- pull: readExisting(deps) injectable IO; split main into pure computePullActions
  (plan + suppression/mass-delete decisions) + thin applyPullActions(deps) (IO);
  ordering and data-loss guards preserved bit-for-bit
- tests (+35): collaboration-apply (atomicity/null-abort/throw-no-partial),
  read-existing, compute/apply-pull-actions (move-write-fail keeps old path),
  git temp-repo 3-way non-FF merge
- transforms-extra property: constrain the generator to mutually-non-substring
  words (the domain where the renumber property holds) -> deterministic; document
  the inherited commentsToFootnotes substring-overlap comment-drop via it.fails
  (off the sync path, SPEC §3; backport-fix lives in docmost-mcp)
- 695 -> 731 green; build clean; corpus STABLE
2026-06-17 01:29:49 +03:00

183 lines
7.3 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { TiptapTransformer } from '@hocuspocus/transformer';
// R-Collab-1 (test-strategy report §5): the SYNCHRONOUS read-transform-write
// body of `mutatePageContent`'s `onSynced` is now the exported pure-ish
// `applyTransformToYdoc(ydoc, transform)`. These tests drive it directly
// against a real `Y.Doc` — NO network, NO Hocuspocus server. They assert the
// SPEC §2 atomicity contract holds (read -> transform -> write with no await),
// plus the abort/throw/empty-doc-fallback behaviour preserved from the inline
// version.
//
// Import directly from the source .js (matches the repo's other collaboration
// tests, e.g. collaboration-mutate.test.ts).
import {
applyTransformToYdoc,
buildYDoc,
} from '../packages/docmost-client/src/lib/collaboration.js';
import { docmostExtensions } from '../packages/docmost-client/src/lib/docmost-schema.js';
// A valid minimal ProseMirror doc with a single paragraph of `text`.
function docWith(text: string): any {
return {
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
};
}
// Seed a Y.Doc's "default" fragment with a ProseMirror doc, exactly the way the
// live collaboration server would have it after the initial sync. We encode via
// the same TiptapTransformer path the SUT reads back through.
function seedYdoc(content: any): Y.Doc {
const seeded = buildYDoc(content);
const ydoc = new Y.Doc();
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(seeded));
return ydoc;
}
// Read the live ProseMirror doc back off a Y.Doc the same way the SUT does.
function readYdoc(ydoc: Y.Doc): any {
return TiptapTransformer.fromYdoc(ydoc, 'default');
}
describe('applyTransformToYdoc — synchronous read/transform/write (R-Collab-1)', () => {
it('writes back the transformed doc when transform mutates it', () => {
const ydoc = seedYdoc(docWith('original'));
let seenLive: any;
const result = applyTransformToYdoc(ydoc, (live) => {
seenLive = live;
return docWith('rewritten');
});
// The transform observed the seeded live doc...
expect(seenLive.content[0].content[0].text).toBe('original');
// ...and the write happened.
expect(result.written).toBe(true);
expect(result.doc).toEqual(docWith('rewritten'));
// The Y.Doc fragment now holds the NEW content (old text fully replaced).
const xml = ydoc.getXmlFragment('default').toString();
expect(xml).toContain('rewritten');
expect(xml).not.toContain('original');
});
it('is fully SYNCHRONOUS — the fragment is mutated before control returns', () => {
// The whole point of the SPEC §2 invariant: no `await` is yielded between
// reading the live doc and writing it back. We assert this structurally by
// observing the write took effect on the SAME synchronous tick — the
// function does not return a Promise, and the fragment already reflects the
// new doc the instant the call returns (no microtask hop needed).
const ydoc = seedYdoc(docWith('before'));
const ret = applyTransformToYdoc(ydoc, () => docWith('after'));
// Not a thenable: the contract is a plain synchronous value, not a Promise.
expect(typeof (ret as any).then).not.toBe('function');
// Already written synchronously.
expect(ydoc.getXmlFragment('default').toString()).toContain('after');
});
it('transform returning null ABORTS with NO write (live doc preserved)', () => {
const ydoc = seedYdoc(docWith('keepme'));
const before = ydoc.getXmlFragment('default').toString();
let seenLive: any;
const result = applyTransformToYdoc(ydoc, (live) => {
seenLive = live;
return null; // abort
});
expect(result.written).toBe(false);
// The returned doc is the live doc the transform saw (no write).
expect(result.doc).toBe(seenLive);
expect(result.doc.content[0].content[0].text).toBe('keepme');
// The fragment is byte-identical to before: nothing was written.
expect(ydoc.getXmlFragment('default').toString()).toBe(before);
});
it('transform THROWING propagates and leaves NO partial write', () => {
const ydoc = seedYdoc(docWith('intact'));
const before = ydoc.getXmlFragment('default').toString();
expect(() =>
applyTransformToYdoc(ydoc, () => {
throw new Error('boom from transform');
}),
).toThrow(/boom from transform/);
// The throw happens before any ydoc.transact, so the live doc is untouched.
expect(ydoc.getXmlFragment('default').toString()).toBe(before);
expect(readYdoc(ydoc).content[0].content[0].text).toBe('intact');
});
it('an empty/invalid live doc falls back to { type:"doc", content:[] }', () => {
// A brand-new Y.Doc has an empty "default" fragment; fromYdoc yields a doc
// with no content array, which the helper must coerce to a valid empty doc
// before handing it to the transform.
const ydoc = new Y.Doc();
let seenLive: any;
applyTransformToYdoc(ydoc, (live) => {
seenLive = live;
return null; // abort — we only care what the transform saw
});
expect(seenLive).toEqual({ type: 'doc', content: [] });
});
it('the empty-doc fallback is still WRITABLE (transform can write into it)', () => {
const ydoc = new Y.Doc();
const result = applyTransformToYdoc(ydoc, (live) => {
// The live doc is the empty fallback; produce real content from it.
expect(live).toEqual({ type: 'doc', content: [] });
return docWith('seeded from empty');
});
expect(result.written).toBe(true);
expect(ydoc.getXmlFragment('default').toString()).toContain(
'seeded from empty',
);
});
it('preserves concurrent live content the transform chooses to keep (atomicity)', () => {
// Model the SPEC §2 concern: the live doc already contains a concurrent
// human edit. A transform that appends without discarding must not lose it,
// and because the read+write is one synchronous unit nothing can interleave.
const live = {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'human edit' }] },
],
};
const ydoc = seedYdoc(live);
const result = applyTransformToYdoc(ydoc, (liveDoc) => {
// Append a machine paragraph while keeping the human's paragraph.
return {
type: 'doc',
content: [
...liveDoc.content,
{ type: 'paragraph', content: [{ type: 'text', text: 'machine edit' }] },
],
};
});
expect(result.written).toBe(true);
const xml = ydoc.getXmlFragment('default').toString();
expect(xml).toContain('human edit');
expect(xml).toContain('machine edit');
});
});
// Sanity: the helper round-trips through the real schema, proving the seed/read
// path is faithful (not a degenerate empty-fragment artifact).
describe('applyTransformToYdoc — schema fidelity', () => {
it('round-trips a paragraph through the docmost schema unchanged', () => {
const ydoc = seedYdoc(docWith('round trip'));
const got = TiptapTransformer.fromYdoc(ydoc, 'default');
// The doc encodes/decodes against the real docmost extension set (the same
// set buildYDoc uses), so the seed/read path is the production one.
expect(got.type).toBe('doc');
expect(got.content[0].content[0].text).toBe('round trip');
expect(Array.isArray(docmostExtensions)).toBe(true);
});
});