import { LoroDoc } from "loro-crdt" import { describe, expect, it } from "vitest" import type { ChannelMsg } from "./channel.js" import { deserializeChannelMsg, serializeChannelMsg, uint8ArrayFromJSON, uint8ArrayToJSON, versionVectorFromJSON, versionVectorToJSON, } from "./channel-json.js" describe("Channel JSON Serialization", () => { describe("VersionVector serialization", () => { it("should serialize empty VersionVector", () => { const doc = new LoroDoc() const vv = doc.version() const json = versionVectorToJSON(vv) expect(json).toEqual({}) }) it("should serialize VersionVector with single peer", () => { const doc = new LoroDoc() doc.setPeerId("1") doc.getText("text").insert(0, "hello") const vv = doc.version() const json = versionVectorToJSON(vv) expect(json).toEqual({ "1": 5 }) }) it("should serialize VersionVector with multiple peers", () => { const doc1 = new LoroDoc() doc1.setPeerId("1") doc1.getText("text").insert(0, "hello") const doc2 = new LoroDoc() doc2.setPeerId("2") doc2.getText("text").insert(0, "world") // Merge the documents doc1.import(doc2.export({ mode: "snapshot" })) const vv = doc1.version() const json = versionVectorToJSON(vv) expect(json).toEqual({ "1": 5, "2": 5 }) }) it("should deserialize empty VersionVector", () => { const json = {} const vv = versionVectorFromJSON(json) expect(vv.toJSON().size).toBe(0) }) it("should deserialize VersionVector with single peer", () => { const json = { "1": 5 } const vv = versionVectorFromJSON(json) const result = vv.toJSON() expect(result.get("1")).toBe(5) }) it("should deserialize VersionVector with multiple peers", () => { const json = { "1": 5, "2": 10 } const vv = versionVectorFromJSON(json) const result = vv.toJSON() expect(result.get("1")).toBe(5) expect(result.get("2")).toBe(10) }) it("should round-trip VersionVector", () => { const doc = new LoroDoc() doc.setPeerId("42") doc.getText("text").insert(0, "test") const original = doc.version() const json = versionVectorToJSON(original) const restored = versionVectorFromJSON(json) expect(restored.toJSON()).toEqual(original.toJSON()) }) }) describe("Uint8Array serialization", () => { it("should serialize empty Uint8Array", () => { const data = new Uint8Array([]) const json = uint8ArrayToJSON(data) expect(json).toBe("") }) it("should serialize Uint8Array with data", () => { const data = new Uint8Array([72, 101, 108, 108, 111]) // "Hello" const json = uint8ArrayToJSON(data) expect(typeof json).toBe("string") expect(json.length).toBeGreaterThan(0) }) it("should deserialize empty string to empty Uint8Array", () => { const json = "" const data = uint8ArrayFromJSON(json) expect(data).toBeInstanceOf(Uint8Array) expect(data.length).toBe(0) }) it("should deserialize Uint8Array", () => { const original = new Uint8Array([72, 101, 108, 108, 111]) const json = uint8ArrayToJSON(original) const restored = uint8ArrayFromJSON(json) expect(restored).toBeInstanceOf(Uint8Array) expect(restored.length).toBe(original.length) expect(Array.from(restored)).toEqual(Array.from(original)) }) it("should round-trip Uint8Array with binary data", () => { const original = new Uint8Array([0, 1, 127, 128, 255]) const json = uint8ArrayToJSON(original) const restored = uint8ArrayFromJSON(json) expect(Array.from(restored)).toEqual(Array.from(original)) }) it("should handle large Uint8Array (2MB) without stack overflow", () => { // Create a 2MB array - this would cause stack overflow with spread operator const size = 2 * 1024 * 1024 // 2MB const data = new Uint8Array(size) // Fill with test pattern for (let i = 0; i < size; i++) { data[i] = i % 256 } // Should not throw const json = uint8ArrayToJSON(data) expect(typeof json).toBe("string") expect(json.length).toBeGreaterThan(0) // Should round-trip correctly const restored = uint8ArrayFromJSON(json) expect(restored.length).toBe(size) // Verify data integrity (check first, middle, and last portions) for (let i = 0; i < 1000; i++) { expect(restored[i]).toBe(i % 256) } const midStart = Math.floor(size / 2) for (let i = 0; i < 1000; i++) { expect(restored[midStart + i]).toBe((midStart + i) % 256) } for (let i = size - 1000; i < size; i++) { expect(restored[i]).toBe(i % 256) } }) it("should handle arrays at chunk boundary sizes", () => { const sizes = [8191, 8192, 8193, 16384, 16385] for (const size of sizes) { const data = new Uint8Array(size) for (let i = 0; i < size; i++) { data[i] = i % 256 } const json = uint8ArrayToJSON(data) const restored = uint8ArrayFromJSON(json) expect(restored.length).toBe(size) expect(Array.from(restored)).toEqual(Array.from(data)) } }) it("should round-trip Loro document snapshot", () => { const doc = new LoroDoc() doc.getText("text") doc.getText("text").insert(0, "Hello World") const snapshot = doc.export({ mode: "snapshot" }) const json = uint8ArrayToJSON(snapshot) const restored = uint8ArrayFromJSON(json) // Verify we can import the restored snapshot const newDoc = new LoroDoc() newDoc.import(restored) expect(newDoc.toJSON()).toEqual({ text: "Hello World" }) }) }) describe("Channel message serialization", () => { describe("establishment messages", () => { it("should serialize establish-request", () => { const msg: ChannelMsg = { type: "channel/establish-request", identity: { peerId: "1", name: "Test Peer", type: "user" }, } const json = serializeChannelMsg(msg) expect(json).toEqual(msg) }) it("should serialize establish-response", () => { const msg: ChannelMsg = { type: "channel/establish-response", identity: { peerId: "2", name: "Another Peer", type: "user" }, } const json = serializeChannelMsg(msg) expect(json).toEqual(msg) }) it("should round-trip establish-request", () => { const original: ChannelMsg = { type: "channel/establish-request", identity: { peerId: "1", name: "Test Peer", type: "user" }, } const json = serializeChannelMsg(original) const restored = deserializeChannelMsg(json) expect(restored).toEqual(original) }) }) describe("sync messages", () => { it("should serialize sync-request", () => { const doc = new LoroDoc() doc.setPeerId("1") doc.getText("text").insert(0, "test") const msg: ChannelMsg = { type: "channel/sync-request", docId: "doc-1", requesterDocVersion: doc.version(), bidirectional: false, } const json = serializeChannelMsg(msg) expect(json.type).toBe("channel/sync-request") if (json.type === "channel/sync-request") { expect(json.docId).toBe("doc-1") expect(json.requesterDocVersion).toEqual({ "1": 4 }) } }) it("should serialize sync-response with up-to-date transmission", () => { const doc = new LoroDoc() doc.setPeerId("1") doc.getText("text").insert(0, "test") const msg: ChannelMsg = { type: "channel/sync-response", docId: "doc-1", transmission: { type: "up-to-date", version: doc.version(), }, } const json = serializeChannelMsg(msg) expect(json.type).toBe("channel/sync-response") if (json.type === "channel/sync-response") { expect(json.transmission.type).toBe("up-to-date") if (json.transmission.type === "up-to-date") { expect(json.transmission.version).toEqual({ "1": 4 }) } } }) it("should serialize sync-response with snapshot transmission", () => { const doc = new LoroDoc() doc.setPeerId("1") doc.getText("text").insert(0, "hello") const snapshot = doc.export({ mode: "snapshot" }) const msg: ChannelMsg = { type: "channel/sync-response", docId: "doc-1", transmission: { type: "snapshot", data: snapshot, version: doc.version(), }, } const json = serializeChannelMsg(msg) expect(json.type).toBe("channel/sync-response") if (json.type === "channel/sync-response") { expect(json.transmission.type).toBe("snapshot") if (json.transmission.type === "snapshot") { expect(typeof json.transmission.data).toBe("string") expect(json.transmission.version).toEqual({ "1": 5 }) } } }) it("should serialize sync-response with update transmission", () => { const doc = new LoroDoc() doc.setPeerId("1") doc.getText("text").insert(0, "hello") const update = doc.export({ mode: "update" }) const msg: ChannelMsg = { type: "channel/sync-response", docId: "doc-1", transmission: { type: "update", data: update, version: doc.version(), }, } const json = serializeChannelMsg(msg) expect(json.type).toBe("channel/sync-response") if (json.type === "channel/sync-response") { expect(json.transmission.type).toBe("update") if (json.transmission.type === "update") { expect(typeof json.transmission.data).toBe("string") expect(json.transmission.version).toEqual({ "1": 5 }) } } }) it("should serialize sync-response with unavailable transmission", () => { const msg: ChannelMsg = { type: "channel/sync-response", docId: "doc-1", transmission: { type: "unavailable", }, } const json = serializeChannelMsg(msg) if (json.type === "channel/sync-response") { expect(json.transmission.type).toBe("unavailable") } }) it("should round-trip sync-request", () => { const doc = new LoroDoc() doc.setPeerId("1") doc.getText("text").insert(0, "test") const original: ChannelMsg = { type: "channel/sync-request", docId: "doc-1", requesterDocVersion: doc.version(), bidirectional: false, } const json = serializeChannelMsg(original) const restored = deserializeChannelMsg(json) expect(restored.type).toBe("channel/sync-request") if (restored.type === "channel/sync-request") { expect(restored.docId).toBe("doc-1") expect(restored.requesterDocVersion.toJSON()).toEqual( original.requesterDocVersion.toJSON(), ) } }) it("should round-trip sync-response with snapshot", () => { const doc = new LoroDoc() doc.setPeerId("1") doc.getText("text").insert(0, "hello world") const snapshot = doc.export({ mode: "snapshot" }) const original: ChannelMsg = { type: "channel/sync-response", docId: "doc-1", transmission: { type: "snapshot", data: snapshot, version: doc.version(), }, } const json = serializeChannelMsg(original) const restored = deserializeChannelMsg(json) expect(restored.type).toBe("channel/sync-response") if (restored.type === "channel/sync-response") { expect(restored.transmission.type).toBe("snapshot") if (restored.transmission.type === "snapshot") { // Verify the snapshot can be imported const newDoc = new LoroDoc() newDoc.import(restored.transmission.data) expect(newDoc.toJSON()).toEqual({ text: "hello world" }) } } }) }) describe("sync messages with ephemeral", () => { it("should serialize sync-request with ephemeral data", () => { const doc = new LoroDoc() doc.setPeerId("1") doc.getText("text").insert(0, "test") const ephemeralData = new Uint8Array([1, 2, 3, 4, 5]) const msg: ChannelMsg = { type: "channel/sync-request", docId: "doc-1", requesterDocVersion: doc.version(), ephemeral: [ { peerId: "123456789", data: ephemeralData, namespace: "presence", }, ], bidirectional: false, } const json = serializeChannelMsg(msg) expect(json.type).toBe("channel/sync-request") if (json.type === "channel/sync-request") { expect(json.ephemeral).toBeDefined() expect(json.ephemeral?.[0].peerId).toBe("123456789") expect(typeof json.ephemeral?.[0].data).toBe("string") expect(json.ephemeral?.[0].namespace).toBe("presence") } }) it("should serialize sync-response with ephemeral data", () => { const doc = new LoroDoc() doc.setPeerId("1") doc.getText("text").insert(0, "hello") const snapshot = doc.export({ mode: "snapshot" }) const ephemeralData = new Uint8Array([10, 20, 30, 40, 50]) const msg: ChannelMsg = { type: "channel/sync-response", docId: "doc-1", transmission: { type: "snapshot", data: snapshot, version: doc.version(), }, ephemeral: [ { peerId: "123456789", data: ephemeralData, namespace: "presence", }, ], } const json = serializeChannelMsg(msg) expect(json.type).toBe("channel/sync-response") if (json.type === "channel/sync-response") { expect(json.ephemeral).toBeDefined() expect(json.ephemeral).toHaveLength(1) expect(json.ephemeral?.[0].peerId).toBe("123456789") expect(typeof json.ephemeral?.[0].data).toBe("string") expect(json.ephemeral?.[0].namespace).toBe("presence") } }) it("should round-trip sync-request with ephemeral", () => { const doc = new LoroDoc() doc.setPeerId("1") doc.getText("text").insert(0, "test") const ephemeralData = new Uint8Array([1, 2, 3, 4, 5]) const original: ChannelMsg = { type: "channel/sync-request", docId: "doc-1", requesterDocVersion: doc.version(), ephemeral: [ { peerId: "123456789", data: ephemeralData, namespace: "presence", }, ], bidirectional: false, } const json = serializeChannelMsg(original) const restored = deserializeChannelMsg(json) expect(restored.type).toBe("channel/sync-request") if (restored.type === "channel/sync-request") { expect(restored.ephemeral).toBeDefined() if (restored.ephemeral?.[0]) { expect(restored.ephemeral[0].peerId).toBe("123456789") expect(Array.from(restored.ephemeral[0].data)).toEqual( Array.from(ephemeralData), ) expect(restored.ephemeral[0].namespace).toBe("presence") } } }) it("should round-trip sync-response with ephemeral", () => { const doc = new LoroDoc() doc.setPeerId("1") doc.getText("text").insert(0, "hello world") const snapshot = doc.export({ mode: "snapshot" }) const ephemeralData = new Uint8Array([10, 20, 30, 40, 50]) const original: ChannelMsg = { type: "channel/sync-response", docId: "doc-1", transmission: { type: "snapshot", data: snapshot, version: doc.version(), }, ephemeral: [ { peerId: "123456789", data: ephemeralData, namespace: "presence", }, ], } const json = serializeChannelMsg(original) const restored = deserializeChannelMsg(json) expect(restored.type).toBe("channel/sync-response") if (restored.type === "channel/sync-response") { expect(restored.ephemeral).toBeDefined() expect(restored.ephemeral).toHaveLength(1) if (restored.ephemeral?.[0]) { expect(restored.ephemeral[0].peerId).toBe("123456789") expect(Array.from(restored.ephemeral[0].data)).toEqual( Array.from(ephemeralData), ) expect(restored.ephemeral[0].namespace).toBe("presence") } // Also verify the snapshot still works if (restored.transmission.type === "snapshot") { const newDoc = new LoroDoc() newDoc.import(restored.transmission.data) expect(newDoc.toJSON()).toEqual({ text: "hello world" }) } } }) it("should handle sync-request without ephemeral (backward compatibility)", () => { const doc = new LoroDoc() doc.setPeerId("1") doc.getText("text").insert(0, "test") const original: ChannelMsg = { type: "channel/sync-request", docId: "doc-1", requesterDocVersion: doc.version(), // No ephemeral field bidirectional: false, } const json = serializeChannelMsg(original) const restored = deserializeChannelMsg(json) expect(restored.type).toBe("channel/sync-request") if (restored.type === "channel/sync-request") { expect(restored.ephemeral).toBeUndefined() } }) it("should handle sync-response without ephemeral (backward compatibility)", () => { const doc = new LoroDoc() doc.setPeerId("1") doc.getText("text").insert(0, "hello") const original: ChannelMsg = { type: "channel/sync-response", docId: "doc-1", transmission: { type: "up-to-date", version: doc.version(), }, // No ephemeral field } const json = serializeChannelMsg(original) const restored = deserializeChannelMsg(json) expect(restored.type).toBe("channel/sync-response") if (restored.type === "channel/sync-response") { expect(restored.ephemeral).toBeUndefined() } }) }) describe("directory messages", () => { it("should serialize directory-request without docIds", () => { const msg: ChannelMsg = { type: "channel/directory-request", } const json = serializeChannelMsg(msg) expect(json).toEqual(msg) }) it("should serialize directory-request with docIds", () => { const msg: ChannelMsg = { type: "channel/directory-request", docIds: ["doc-1", "doc-2"], } const json = serializeChannelMsg(msg) expect(json).toEqual(msg) }) it("should serialize directory-response", () => { const msg: ChannelMsg = { type: "channel/directory-response", docIds: ["doc-1", "doc-2", "doc-3"], } const json = serializeChannelMsg(msg) expect(json).toEqual(msg) }) it("should round-trip directory messages", () => { const request: ChannelMsg = { type: "channel/directory-request", docIds: ["doc-1"], } const response: ChannelMsg = { type: "channel/directory-response", docIds: ["doc-1", "doc-2"], } expect(deserializeChannelMsg(serializeChannelMsg(request))).toEqual( request, ) expect(deserializeChannelMsg(serializeChannelMsg(response))).toEqual( response, ) }) it("should serialize new-doc", () => { const msg: ChannelMsg = { type: "channel/new-doc", docIds: ["doc-1", "doc-2", "doc-3"], } const json = serializeChannelMsg(msg) expect(json).toEqual(msg) }) it("should round-trip new-doc message", () => { const original: ChannelMsg = { type: "channel/new-doc", docIds: ["doc-1", "doc-2"], } expect(deserializeChannelMsg(serializeChannelMsg(original))).toEqual( original, ) }) }) describe("delete messages", () => { it("should serialize delete-request", () => { const msg: ChannelMsg = { type: "channel/delete-request", docId: "doc-to-delete", } const json = serializeChannelMsg(msg) expect(json).toEqual(msg) }) it("should serialize delete-response with deleted status", () => { const msg: ChannelMsg = { type: "channel/delete-response", docId: "doc-1", status: "deleted", } const json = serializeChannelMsg(msg) expect(json).toEqual(msg) }) it("should serialize delete-response with ignored status", () => { const msg: ChannelMsg = { type: "channel/delete-response", docId: "doc-1", status: "ignored", } const json = serializeChannelMsg(msg) expect(json).toEqual(msg) }) it("should round-trip delete messages", () => { const request: ChannelMsg = { type: "channel/delete-request", docId: "doc-1", } const response: ChannelMsg = { type: "channel/delete-response", docId: "doc-1", status: "deleted", } expect(deserializeChannelMsg(serializeChannelMsg(request))).toEqual( request, ) expect(deserializeChannelMsg(serializeChannelMsg(response))).toEqual( response, ) }) }) describe("batch messages", () => { it("should serialize batch with single message", () => { const msg: ChannelMsg = { type: "channel/batch", messages: [ { type: "channel/directory-request", }, ], } const json = serializeChannelMsg(msg) expect(json.type).toBe("channel/batch") if (json.type === "channel/batch") { expect(json.messages).toHaveLength(1) expect(json.messages[0].type).toBe("channel/directory-request") } }) it("should serialize batch with multiple messages", () => { const doc = new LoroDoc() doc.setPeerId("1") doc.getText("text").insert(0, "test") const msg: ChannelMsg = { type: "channel/batch", messages: [ { type: "channel/sync-request", docId: "doc-1", requesterDocVersion: doc.version(), bidirectional: true, }, { type: "channel/sync-request", docId: "doc-2", requesterDocVersion: doc.version(), bidirectional: true, }, ], } const json = serializeChannelMsg(msg) expect(json.type).toBe("channel/batch") if (json.type === "channel/batch") { expect(json.messages).toHaveLength(2) expect(json.messages[0].type).toBe("channel/sync-request") expect(json.messages[1].type).toBe("channel/sync-request") } }) it("should serialize batch with ephemeral messages", () => { const ephemeralData = new Uint8Array([1, 2, 3, 4, 5]) const msg: ChannelMsg = { type: "channel/batch", messages: [ { type: "channel/ephemeral", docId: "doc-1", hopsRemaining: 1, stores: [ { peerId: "123456789", data: ephemeralData, namespace: "presence", }, ], }, { type: "channel/ephemeral", docId: "doc-2", hopsRemaining: 1, stores: [ { peerId: "123456789", data: ephemeralData, namespace: "cursors", }, ], }, ], } const json = serializeChannelMsg(msg) expect(json.type).toBe("channel/batch") if (json.type === "channel/batch") { expect(json.messages).toHaveLength(2) expect(json.messages[0].type).toBe("channel/ephemeral") expect(json.messages[1].type).toBe("channel/ephemeral") // Verify ephemeral data was serialized if (json.messages[0].type === "channel/ephemeral") { expect(typeof json.messages[0].stores[0].data).toBe("string") } } }) it("should round-trip batch message", () => { const doc = new LoroDoc() doc.setPeerId("1") doc.getText("text").insert(0, "test") const original: ChannelMsg = { type: "channel/batch", messages: [ { type: "channel/sync-request", docId: "doc-1", requesterDocVersion: doc.version(), bidirectional: true, }, { type: "channel/directory-request", }, ], } const json = serializeChannelMsg(original) const restored = deserializeChannelMsg(json) expect(restored.type).toBe("channel/batch") if (restored.type === "channel/batch") { expect(restored.messages).toHaveLength(2) expect(restored.messages[0].type).toBe("channel/sync-request") expect(restored.messages[1].type).toBe("channel/directory-request") // Verify version vector was restored if (restored.messages[0].type === "channel/sync-request") { expect(restored.messages[0].requesterDocVersion.toJSON()).toEqual( doc.version().toJSON(), ) } } }) it("should round-trip batch with ephemeral through full JSON cycle", () => { const ephemeralData = new Uint8Array([10, 20, 30, 40, 50]) const original: ChannelMsg = { type: "channel/batch", messages: [ { type: "channel/ephemeral", docId: "doc-1", hopsRemaining: 1, stores: [ { peerId: "123456789", data: ephemeralData, namespace: "presence", }, ], }, ], } // Full cycle: serialize -> stringify -> parse -> deserialize const json = serializeChannelMsg(original) const stringified = JSON.stringify(json) const parsed = JSON.parse(stringified) const restored = deserializeChannelMsg(parsed) expect(restored.type).toBe("channel/batch") if (restored.type === "channel/batch") { expect(restored.messages).toHaveLength(1) if (restored.messages[0].type === "channel/ephemeral") { expect(Array.from(restored.messages[0].stores[0].data)).toEqual( Array.from(ephemeralData), ) } } }) it("should handle empty batch", () => { const msg: ChannelMsg = { type: "channel/batch", messages: [], } const json = serializeChannelMsg(msg) const restored = deserializeChannelMsg(json) expect(restored.type).toBe("channel/batch") if (restored.type === "channel/batch") { expect(restored.messages).toHaveLength(0) } }) }) }) describe("JSON stringification", () => { it("should survive JSON.stringify and JSON.parse", () => { const doc = new LoroDoc() doc.setPeerId("1") doc.getText("text").insert(0, "test") const original: ChannelMsg = { type: "channel/sync-request", docId: "doc-1", requesterDocVersion: doc.version(), bidirectional: false, } const json = serializeChannelMsg(original) const stringified = JSON.stringify(json) const parsed = JSON.parse(stringified) const restored = deserializeChannelMsg(parsed) expect(restored.type).toBe("channel/sync-request") if (restored.type === "channel/sync-request") { expect(restored.requesterDocVersion.toJSON()).toEqual( original.requesterDocVersion.toJSON(), ) } }) it("should handle complex message through full JSON cycle", () => { const doc = new LoroDoc() doc.setPeerId("1") doc.getText("text").insert(0, "hello world") const snapshot = doc.export({ mode: "snapshot" }) const original: ChannelMsg = { type: "channel/sync-response", docId: "doc-1", transmission: { type: "snapshot", data: snapshot, version: doc.version(), }, } // Full cycle: serialize -> stringify -> parse -> deserialize const json = serializeChannelMsg(original) const stringified = JSON.stringify(json) const parsed = JSON.parse(stringified) const restored = deserializeChannelMsg(parsed) // Verify the restored message works if ( restored.type === "channel/sync-response" && restored.transmission.type === "snapshot" ) { const newDoc = new LoroDoc() newDoc.import(restored.transmission.data) expect(newDoc.toJSON()).toEqual({ text: "hello world" }) } }) }) })