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
183 lines
7.3 KiB
TypeScript
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);
|
|
});
|
|
});
|