/** * The authoritative document, without a socket in sight. * * These exercise the parts a wire test cannot reach cleanly: what happens to a * document when two people edit the same region concurrently, and what happens * when an update leaves it in a state the dialect will not accept. */ import { describe, expect, it, spyOn } from "bun:test"; import { $createParagraphNode, $createTextNode, $getRoot, $isElementNode, $isParagraphNode, } from "lexical"; import * as Y from "yjs"; import { $importPlan, limits } from "@chopin/dialect"; import { peer, REGISTRY } from "../testing/peer"; import * as room from "./room"; function questionnaire(id: string, question: string, option: string, header: string) { return { id, questions: [{ id: question, header, prompt: `What should ${header} be?`, multiple: false, options: [{ id: option, label: "Choose this" }], }], }; } describe("document", () => { it("starts empty and projects to nothing", async () => { let document = await room.create(); document.editor.getEditorState().read(() => { let first = $getRoot().getFirstChild(); expect($isParagraphNode(first)).toBe(true); expect($getRoot().getChildrenSize()).toBe(1); }); expect(room.project(document)).toBe(""); }); it("seeds from canonical source and projects it back unchanged", async () => { let source = "# Title\n\nA paragraph.\n"; let document = await room.create(source); expect(room.project(document)).toBe(source); }); it("refuses to seed from source the dialect rejects", async () => { await expect(room.create("\n")).rejects.toThrow(); }); it("gives a joining client only what it is missing", async () => { let document = await room.create("# Title\n"); let whole = room.sync(document); let caughtUp = room.sync(document, Y.encodeStateVector(document.doc)); expect(whole.byteLength).toBeGreaterThan(0); expect(caughtUp.byteLength).toBeLessThan(whole.byteLength); }); }); describe("questionnaire insertion", () => { it("inserts a batch directly after validated prose in input order", async () => { let document = await room.create("Related prose.\n\nLater prose.\n"); let digest = room.digests(document)[0]!; let mutation = room.insertQuestionnaires(document, [ { value: questionnaire( "01K0N4TR8K7JGM4R1J7PW4R8YJ", "01K0N4V4E7Y6P4MJ5WD8XZF3B2", "01K0N4W3B7P27CBAEC7A8C8WEA", "First", ), at: { index: 0, digest }, }, { value: questionnaire( "01K0N4X2M5R8T3VQ7YB6ZC4DEF", "01K0N4Y2M5R8T3VQ7YB6ZC4DEF", "01K0N4Z2M5R8T3VQ7YB6ZC4DEF", "Second", ), at: { index: 0, digest }, }, ]); expect(mutation).toBeDefined(); let source = room.project(document); expect(source.indexOf("Related prose.")).toBeLessThan(source.indexOf('header="First"')); expect(source.indexOf('header="First"')).toBeLessThan(source.indexOf('header="Second"')); expect(source.indexOf('header="Second"')).toBeLessThan(source.indexOf("Later prose.")); }); it("refuses every insertion when one destination has changed", async () => { let document = await room.create("Related prose.\n"); let digest = room.digests(document)[0]!; expect(() => room.insertQuestionnaires(document, [ { value: questionnaire( "01K0N4TR8K7JGM4R1J7PW4R8YJ", "01K0N4V4E7Y6P4MJ5WD8XZF3B2", "01K0N4W3B7P27CBAEC7A8C8WEA", "First", ), at: { index: 0, digest }, }, { value: questionnaire( "01K0N4X2M5R8T3VQ7YB6ZC4DEF", "01K0N4Y2M5R8T3VQ7YB6ZC4DEF", "01K0N4Z2M5R8T3VQ7YB6ZC4DEF", "Second", ), at: { index: 0, digest: room.digest("changed") }, }, ]) ).toThrow(/changed.*read/i); expect(room.project(document)).not.toContain(" { /** * The property the whole design rests on: two people editing different * parts of one document end up with the same document, whatever order the * updates arrive in. */ it("converges when two peers edit different tabs", async () => { let source = ` First. Second. `; let server = await room.create(source); let state = room.sync(server); let alice = peer(); let bob = peer(); Y.applyUpdate(alice.doc, state, "remote"); Y.applyUpdate(bob.doc, state, "remote"); await room.settle(); // Each edits a different tab, neither having seen the other's change. let before = { alice: Y.encodeStateVector(alice.doc), bob: Y.encodeStateVector(bob.doc) }; alice.editor.update(() => { let tab = $getRoot().getFirstChild(); let first = $isElementNode(tab) ? tab.getFirstChild() : null; if ($isElementNode(first)) first.getFirstChild()?.remove(); }, { discrete: true }); bob.editor.update(() => { let tabs = $getRoot().getFirstChild(); let second = $isElementNode(tabs) ? tabs.getLastChild() : null; if ($isElementNode(second)) second.getFirstChild()?.remove(); }, { discrete: true }); let fromAlice = Y.encodeStateAsUpdate(alice.doc, before.alice); let fromBob = Y.encodeStateAsUpdate(bob.doc, before.bob); // The server sees them in one order, each peer in the other. await room.apply(server, [fromAlice, fromBob]); Y.applyUpdate(alice.doc, fromBob, "remote"); Y.applyUpdate(bob.doc, fromAlice, "remote"); await room.settle(); let projected = room.project(server); expect(Y.encodeStateAsUpdate(alice.doc).byteLength) .toBe(Y.encodeStateAsUpdate(bob.doc).byteLength); expect(projected).not.toContain("First."); expect(projected).not.toContain("Second."); }); it("survives the same update arriving twice", async () => { let server = await room.create("# Title\n"); let client = peer(); Y.applyUpdate(client.doc, room.sync(server), "remote"); await room.settle(); let before = Y.encodeStateVector(client.doc); client.editor.update(() => { $importPlan("# Title\n\nAdded.\n", { registry: REGISTRY, validate: false }); }, { discrete: true }); let update = Y.encodeStateAsUpdate(client.doc, before); await room.apply(server, [update]); let once = room.project(server); await room.apply(server, [update]); expect(room.project(server)).toBe(once); }); }); describe("recovery", () => { it("destroys a scratch restoration when a journal update throws", async () => { let document = await room.create("# Title\n"); let checkpoint = Y.encodeStateAsUpdate(document.doc); let destroy = spyOn(Y.Doc.prototype, "destroy"); try { await expect( room.restore(document.epoch, checkpoint, "# Title\n", [{ epoch: document.epoch, update: new Uint8Array([255]), }]), ).rejects.toThrow(); expect(destroy).toHaveBeenCalledTimes(1); } finally { destroy.mockRestore(); document.doc.destroy(); } }); it("restores a checkpoint stored with an empty paragraph's former blank line", async () => { let document = await room.create("# Title\n"); document.editor.update(() => $getRoot().append($createParagraphNode()), { discrete: true }); await room.settle(); let checkpoint = Y.encodeStateAsUpdate(document.doc); try { let restored = await room.restore(document.epoch, checkpoint, "# Title\n\n", []); expect(room.project(restored)).toBe("# Title\n"); restored.doc.destroy(); await expect(room.restore(document.epoch, checkpoint, "# Other\n", [])).rejects.toThrow( "stored plan source does not match its Yjs checkpoint", ); } finally { document.doc.destroy(); } }); /** * A rejected batch cannot be undone — Yjs has no such operation — so the * document is rebuilt from the last state that was known to be good. */ it("reports an update that leaves the document invalid", async () => { let document = await room.create("# Title\n"); room.mark(document); let client = peer(); Y.applyUpdate(client.doc, room.sync(document), "remote"); await room.settle(); // A Callout with no id is well-formed MDX and outside the dialect. let before = Y.encodeStateVector(client.doc); client.editor.update(() => { $importPlan('\n\tText.\n\n', { registry: REGISTRY, validate: false, }); }, { discrete: true }); let outcome = await room.apply(document, [Y.encodeStateAsUpdate(client.doc, before)]); expect(outcome.ok).toBe(false); if (outcome.ok) return; expect(outcome.issues.length).toBeGreaterThan(0); }); /** * The stricter URL rules arrived after documents were stored. They judge * what a change brings in, so a document already holding such a link keeps * opening, restoring and taking edits instead of failing every projection. */ it("keeps opening and editing a stored document with a link the newer URL rules refuse", async () => { let source = "Read [the notes](docs\\notes.md).\n"; let document = await room.create(source); let stored = room.project(document); expect(stored).toContain("docs\\"); room.mark(document); let restored = await room.restore( document.epoch, Y.encodeStateAsUpdate(document.doc), stored, [], ); try { let client = peer(); Y.applyUpdate(client.doc, room.sync(restored), "remote"); await room.settle(); let before = Y.encodeStateVector(client.doc); client.editor.update(() => { $importPlan(`${stored}\nAdded.\n`, { registry: REGISTRY, validate: false }); }, { discrete: true }); let outcome = await room.apply(restored, [Y.encodeStateAsUpdate(client.doc, before)]); expect(outcome.ok).toBe(true); expect(room.project(restored)).toBe(`${stored}\nAdded.\n`); // A new link of the same kind is still refused. before = Y.encodeStateVector(client.doc); client.editor.update(() => { $importPlan(`${stored}\nAdded [elsewhere](//evil.com).\n`, { registry: REGISTRY, validate: false, }); }, { discrete: true }); let refused = await room.apply(restored, [Y.encodeStateAsUpdate(client.doc, before)]); expect(refused.ok).toBe(false); if (!refused.ok) expect(refused.issues).toContain("bad-link"); // So is a hidden character, which the stored text did not carry. let hidden = `https://ex${String.fromCharCode(0x200b)}ample.com`; before = Y.encodeStateVector(client.doc); client.editor.update(() => { $importPlan(`${stored}\nAdded [elsewhere](${hidden}).\n`, { registry: REGISTRY, validate: false, }); }, { discrete: true }); let hiding = await room.apply(restored, [Y.encodeStateAsUpdate(client.doc, before)]); expect(hiding.ok).toBe(false); if (!hiding.ok) expect(hiding.issues).toContain("bad-link"); } finally { restored.doc.destroy(); document.doc.destroy(); } }); it("with questions open, refuses a human edit that grows into the expiry reserve but allows one that shrinks", async () => { let filler = "x".repeat(limits.MAX_SOURCE_BYTES - 40); let document = await room.create(`# T\n\n${filler}\n\nTail.\n`); room.mark(document); expect(room.fitsExpiry(room.project(document), 1)).toBe(false); let client = peer(); Y.applyUpdate(client.doc, room.sync(document), "remote"); await room.settle(); let before = Y.encodeStateVector(client.doc); client.editor.update(() => { let paragraph = $createParagraphNode(); paragraph.append($createTextNode("More.")); $getRoot().append(paragraph); }, { discrete: true }); let grown = await room.apply( document, [Y.encodeStateAsUpdate(client.doc, before)], undefined, 1, ); expect(grown.ok).toBe(false); let fresh = await room.rebuild(document); let shrinker = peer(); Y.applyUpdate(shrinker.doc, room.sync(fresh), "remote"); await room.settle(); let start = Y.encodeStateVector(shrinker.doc); shrinker.editor.update(() => { $getRoot().getLastChild()?.remove(); }, { discrete: true }); let shrunk = await room.apply( fresh, [Y.encodeStateAsUpdate(shrinker.doc, start)], undefined, 1, ); expect(shrunk.ok).toBe(true); expect(room.project(fresh)).not.toContain("Tail."); }); // Projection and its re-parse run on the event loop. Brackets used to make // both quadratic, so one large paste held the loop for minutes and the // storage writer lease, renewed on a timer, expired underneath the server. it("accepts and refuses large bracket-heavy pastes without stalling the event loop", async () => { let document = await room.create("# T\n"); room.mark(document); let client = peer(); Y.applyUpdate(client.doc, room.sync(document), "remote"); await room.settle(); let longest = 0; let last = performance.now(); let heartbeat = setInterval(() => { let now = performance.now(); longest = Math.max(longest, now - last); last = now; }, 10); try { let paste = async () => { let before = Y.encodeStateVector(client.doc); client.editor.update(() => { let paragraph = $createParagraphNode(); paragraph.append($createTextNode("[a]".repeat(limits.MAX_SOURCE_BYTES / 6))); $getRoot().append(paragraph); }, { discrete: true }); return room.apply(document, [Y.encodeStateAsUpdate(client.doc, before)], undefined, 1); }; expect((await paste()).ok).toBe(true); let refused = await paste(); expect(refused.ok ? [] : refused.issues).toEqual(["source-too-large"]); } finally { clearInterval(heartbeat); } expect(longest).toBeLessThan(5_000); }); it("rebuilds to the last known-good state under a fresh epoch", async () => { let document = await room.create("# Title\n"); room.mark(document); let original = document.epoch; let client = peer(); Y.applyUpdate(client.doc, room.sync(document), "remote"); await room.settle(); let before = Y.encodeStateVector(client.doc); client.editor.update(() => { $importPlan('\n\tText.\n\n', { registry: REGISTRY, validate: false, }); }, { discrete: true }); await room.apply(document, [Y.encodeStateAsUpdate(client.doc, before)]); let rebuilt = await room.rebuild(document); expect(rebuilt.epoch).not.toBe(original); expect(room.project(rebuilt)).toBe("# Title\n"); }); }); describe("round trip proof", () => { it("accepts source that survives import and export", () => { expect(() => room.validate("# Title\n\nText with **bold**.\n")).not.toThrow(); }); it("rejects source the dialect does not allow", () => { expect(() => room.validate('\n')).toThrow(); }); });