import { describe, expect, it } from "bun:test";
import { createHeadlessEditor } from "@lexical/headless";
import { createYjsBinding, syncLexicalUpdateToYjs, syncYjsChangesToLexical } from "@lexical/yjs";
import { $getRoot, $isElementNode } from "lexical";
import * as Y from "yjs";
import { exportPlan, importPlan } from "../convert";
import { registry } from "../registry";
import { $isCalloutNode, $isTabNode, $isTabsNode } from "./containers";
import type { Binding, Provider } from "@lexical/yjs";
import type { LexicalEditor, TextNode } from "lexical";
const REGISTRY = registry();
const ID = "01K0N4TR8K7JGM4R1J7PW4R8YJ";
const ID2 = "01K0N4V4E7Y6P4MJ5WD8XZF3B2";
const ID3 = "01K0N4W3B7P27CBAEC7A8C8WEA";
function editor(): LexicalEditor {
return createHeadlessEditor({
nodes: REGISTRY.nodes,
onError(err) {
throw err;
},
});
}
function through(source: string): string {
let instance = editor();
importPlan(instance, source, { registry: REGISTRY });
return exportPlan(instance, { registry: REGISTRY });
}
/** Canonical output must be a fixed point. */
function canonical(source: string): string {
let once = through(source);
expect(through(once)).toBe(once);
return once;
}
const CALLOUT = `\n\nBody text.\n\n\n`;
const TABS = `\n`
+ `\n\nRun it.\n\n\n`
+ `\n\n- a\n- b\n\n\n`
+ `\n`;
describe("structural components", () => {
it("round-trips a callout with its attributes", () => {
let out = canonical(CALLOUT);
expect(out).toContain(`id="${ID}"`);
expect(out).toContain('type="warning"');
expect(out).toContain('title="Careful"');
expect(out).toContain("Body text.");
});
it("round-trips tabs with labels and block content", () => {
let out = canonical(TABS);
expect(out).toContain('label="macOS"');
expect(out).toContain('label="Web"');
expect(out).toContain("- a");
});
it("keeps nesting stable several levels deep", () => {
let deep = `\n`
+ `\n\n`
+ `\n\n`
+ `| a | b |\n| - | - |\n| 1 | 2 |\n\n- list\n\n`
+ `\n\n\n\n`;
expect(canonical(deep)).toContain("| a | b |");
});
it("omits optional attributes that are unset", () => {
let out = canonical(`\n\nx\n\n\n`);
expect(out).not.toContain("title=");
});
it("builds element nodes with ordinary children, not decorators", () => {
let instance = editor();
importPlan(instance, TABS, { registry: REGISTRY });
instance.getEditorState().read(() => {
let tabs = $getRoot().getFirstChild();
expect($isTabsNode(tabs)).toBe(true);
if (!$isElementNode(tabs)) return;
let children = tabs.getChildren();
expect(children).toHaveLength(2);
expect(children.every($isTabNode)).toBe(true);
let first = children[0];
expect($isElementNode(first) && first.getChildren()[0]?.getType()).toBe("paragraph");
});
});
});
// -- collaboration ---------------------------------------------------------
const PROVIDER = {
awareness: {
getLocalState: () => null,
getStates: () => new Map(),
off() {},
on() {},
setLocalState() {},
setLocalStateField() {},
},
connect() {},
disconnect() {},
off() {},
on() {},
} as unknown as Provider;
type Peer = { editor: LexicalEditor; doc: Y.Doc; binding: Binding };
function peer(): Peer {
let instance = editor();
let doc = new Y.Doc();
let binding = createYjsBinding({
editor: instance,
id: "plan",
doc,
docMap: new Map([["plan", doc]]),
});
instance.registerUpdateListener(
({ dirtyElements, dirtyLeaves, editorState, normalizedNodes, prevEditorState, tags }) => {
if (tags.has("skip-collab")) return;
syncLexicalUpdateToYjs(
binding,
PROVIDER,
prevEditorState,
editorState,
dirtyElements,
dirtyLeaves,
normalizedNodes,
tags,
);
},
);
binding.root.getSharedType().observeDeep((events, transaction) => {
if (transaction.origin !== binding) syncYjsChangesToLexical(binding, PROVIDER, events, false);
});
return { editor: instance, doc, binding };
}
async function settle(): Promise {
for (let i = 0; i < 5; i++) await new Promise(resolve => setTimeout(resolve, 0));
}
/** Edit the first text node inside the nth tab. */
function writeTab(instance: LexicalEditor, index: number, value: string) {
instance.update(
() => {
let tabs = $getRoot().getFirstChild();
if (!$isElementNode(tabs)) return;
let tab = tabs.getChildren()[index];
if (!$isElementNode(tab)) return;
let paragraph = tab.getFirstChild();
if (!$isElementNode(paragraph)) return;
(paragraph.getFirstChild() as TextNode | null)?.setTextContent(value);
},
{ discrete: true },
);
}
describe("container collaboration", () => {
it("merges concurrent edits in different tabs", async () => {
let a = peer();
let b = peer();
let held: Array<{ from: Y.Doc; update: Uint8Array }> = [];
let paused = false;
let deliver = (from: Y.Doc, update: Uint8Array) => {
let target = from === a.doc ? b.doc : a.doc;
Y.applyUpdate(target, update, "relay");
};
for (let doc of [a.doc, b.doc]) {
doc.on("update", (update: Uint8Array, origin: unknown) => {
if (origin === "relay") return;
if (paused) held.push({ from: doc, update });
else deliver(doc, update);
});
}
importPlan(
a.editor,
`\n`
+ `\n\nfirst\n\n\n`
+ `\n\nsecond\n\n\n`
+ `\n`,
{ registry: REGISTRY },
);
await settle();
// Both peers edit different tabs before either sees the other's change.
paused = true;
writeTab(a.editor, 0, "edited-by-A");
writeTab(b.editor, 1, "edited-by-B");
paused = false;
for (let { from, update } of held) deliver(from, update);
await settle();
let left = exportPlan(a.editor, { registry: REGISTRY });
let right = exportPlan(b.editor, { registry: REGISTRY });
expect(left).toBe(right);
expect(left).toContain("edited-by-A");
expect(left).toContain("edited-by-B");
});
it("keeps callout attributes through a sync", async () => {
let a = peer();
let b = peer();
a.doc.on("update", (update: Uint8Array, origin: unknown) => {
if (origin !== "relay") Y.applyUpdate(b.doc, update, "relay");
});
importPlan(a.editor, CALLOUT, { registry: REGISTRY });
await settle();
b.editor.getEditorState().read(() => {
let callout = $getRoot().getFirstChild();
expect($isCalloutNode(callout)).toBe(true);
if (!$isCalloutNode(callout)) return;
expect(callout.getId()).toBe(ID);
expect(callout.getCalloutType()).toBe("warning");
expect(callout.getTitle()).toBe("Careful");
});
});
});