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); }); });