import { loro, Shape } from "@loro-extended/change" import { describe, expect, it } from "vitest" import { Bridge, BridgeAdapter } from "../adapter/bridge-adapter.js" import { Repo } from "../repo.js" // Test schemas const DocSchema = Shape.doc({ title: Shape.text(), count: Shape.counter(), }) const PresenceSchema = Shape.plain.struct({ status: Shape.plain.string(), cursor: Shape.plain.struct({ x: Shape.plain.number(), y: Shape.plain.number(), }), }) const MouseSchema = Shape.plain.struct({ x: Shape.plain.number(), y: Shape.plain.number(), }) // Schema with Shape.any() for untyped document const AnyDocSchema = Shape.doc({ doc: Shape.any(), }) describe("Handle", () => { describe("get() with typed document", () => { it("should create a handle with typed document access", () => { const repo = new Repo({ identity: { name: "test", type: "user" }, adapters: [], }) const handle = repo.get("doc-1", DocSchema) // doc should be a TypedDoc expect(handle.doc).toBeDefined() expect(loro(handle.doc)).toBeDefined() expect(loro(handle.doc).doc).toBeDefined() // Can use typed mutations handle.change(draft => { draft.title.insert(0, "Hello") draft.count.increment(5) }) // Can read typed values const json = handle.doc.toJSON() expect(json.title).toBe("Hello") expect(json.count).toBe(5) }) it("should provide docId and peerId", () => { const repo = new Repo({ identity: { name: "test", type: "user", peerId: "123" as `${number}` }, adapters: [], }) const handle = repo.get("doc-1", DocSchema) expect(handle.docId).toBe("doc-1") expect(handle.peerId).toBe("123") }) }) describe("get() with Shape.any() in doc schema", () => { it("should create a handle with untyped document access via Shape.any()", () => { const repo = new Repo({ identity: { name: "test", type: "user" }, adapters: [], }) const handle = repo.get("doc-1", AnyDocSchema) // doc should still be a TypedDoc (with unknown types for the 'doc' field) expect(handle.doc).toBeDefined() expect(loro(handle.doc)).toBeDefined() expect(loro(handle.doc).doc).toBeDefined() // Can use raw LoroDoc access for the 'doc' container loro(handle.doc).doc.getMap("doc").set("key", "value") // Can read via loroDoc const docMap = loro(handle.doc).doc.getMap("doc") expect(docMap.get("key")).toBe("value") }) }) describe("get() with ephemeral stores", () => { it("should create typed ephemeral stores from declarations", () => { const repo = new Repo({ identity: { name: "test", type: "user", peerId: "123" as `${number}` }, adapters: [], }) const handle = repo.get("doc-1", DocSchema, { presence: PresenceSchema, mouse: MouseSchema, }) // Ephemeral stores should be accessible as properties expect(handle.presence).toBeDefined() expect(handle.mouse).toBeDefined() // Can set values using setSelf handle.presence.setSelf({ status: "online", cursor: { x: 100, y: 200 }, }) handle.mouse.setSelf({ x: 50, y: 75 }) // Can read values using self expect(handle.presence.self).toEqual({ status: "online", cursor: { x: 100, y: 200 }, }) expect(handle.mouse.self).toEqual({ x: 50, y: 75 }) }) it("should support set/get with arbitrary keys", () => { const repo = new Repo({ identity: { name: "test", type: "user", peerId: "123" as `${number}` }, adapters: [], }) const handle = repo.get("doc-1", DocSchema, { presence: PresenceSchema, }) // Can set with arbitrary keys (not just peerId) handle.presence.set("custom-key", { status: "away", cursor: { x: 0, y: 0 }, }) // Can get with arbitrary keys expect(handle.presence.get("custom-key")).toEqual({ status: "away", cursor: { x: 0, y: 0 }, }) }) it("should provide peers map excluding self", () => { const repo = new Repo({ identity: { name: "test", type: "user", peerId: "123" as `${number}` }, adapters: [], }) const handle = repo.get("doc-1", DocSchema, { presence: PresenceSchema, }) // Set my presence handle.presence.setSelf({ status: "online", cursor: { x: 100, y: 200 }, }) // Set another peer's presence (simulating remote) handle.presence.set("456", { status: "away", cursor: { x: 50, y: 50 }, }) // peers should exclude self const peers = handle.presence.peers expect(peers.has("123")).toBe(false) expect(peers.has("456")).toBe(true) expect(peers.get("456")).toEqual({ status: "away", cursor: { x: 50, y: 50 }, }) }) it("should provide getAll() with all entries", () => { const repo = new Repo({ identity: { name: "test", type: "user", peerId: "123" as `${number}` }, adapters: [], }) const handle = repo.get("doc-1", DocSchema, { presence: PresenceSchema, }) handle.presence.setSelf({ status: "online", cursor: { x: 100, y: 200 }, }) handle.presence.set("456", { status: "away", cursor: { x: 50, y: 50 }, }) const all = handle.presence.getAll() expect(all.size).toBe(2) expect(all.has("123")).toBe(true) expect(all.has("456")).toBe(true) }) it("should handle Uint8Array values in ephemeral stores (cursor data)", () => { // Schema with Uint8Array for cursor data (like loro-prosemirror) const CursorPresenceSchema = Shape.plain.struct({ anchor: Shape.plain.bytes().nullable(), focus: Shape.plain.bytes().nullable(), user: Shape.plain .struct({ name: Shape.plain.string(), color: Shape.plain.string(), }) .nullable(), }) const repo = new Repo({ identity: { name: "test", type: "user", peerId: "123" as `${number}` }, adapters: [], }) const handle = repo.get("doc-1", DocSchema, { cursors: CursorPresenceSchema, }) // Set Uint8Array values directly (no base64 encoding needed!) const anchorData = new Uint8Array([1, 2, 3, 4, 5]) const focusData = new Uint8Array([6, 7, 8, 9, 10]) handle.cursors.setSelf({ anchor: anchorData, focus: focusData, user: { name: "Alice", color: "#ff0000" }, }) // Verify Uint8Array values are preserved expect(handle.cursors.self?.anchor).toEqual(anchorData) expect(handle.cursors.self?.focus).toEqual(focusData) expect(handle.cursors.self?.user).toEqual({ name: "Alice", color: "#ff0000", }) // Simulate peer presence with Uint8Array const peerAnchor = new Uint8Array([11, 12, 13]) handle.cursors.set("456", { anchor: peerAnchor, focus: null, user: { name: "Bob", color: "#00ff00" }, }) // Verify peer's Uint8Array is accessible const peerCursors = handle.cursors.get("456") expect(peerCursors?.anchor).toEqual(peerAnchor) expect(peerCursors?.focus).toBeNull() expect(peerCursors?.user).toEqual({ name: "Bob", color: "#00ff00" }) }) it("should handle Shape.any() in ephemeral stores", () => { // Schema with any value for flexible metadata const FlexiblePresenceSchema = Shape.plain.struct({ name: Shape.plain.string(), metadata: Shape.plain.any(), }) const repo = new Repo({ identity: { name: "test", type: "user", peerId: "123" as `${number}` }, adapters: [], }) const handle = repo.get("doc-1", DocSchema, { presence: FlexiblePresenceSchema, }) // Set various types of metadata handle.presence.setSelf({ name: "Alice", metadata: { custom: "data", nested: { value: 123 } }, }) expect(handle.presence.self?.name).toBe("Alice") expect(handle.presence.self?.metadata).toEqual({ custom: "data", nested: { value: 123 }, }) // Update with different metadata type handle.presence.setSelf({ name: "Alice", metadata: [1, 2, 3], }) expect(handle.presence.self?.metadata).toEqual([1, 2, 3]) }) }) describe("addEphemeral() for external stores", () => { it("should allow registering external ephemeral stores", async () => { const { EphemeralStore } = await import("loro-crdt") const repo = new Repo({ identity: { name: "test", type: "user", peerId: "123" as `${number}` }, adapters: [], }) const handle = repo.get("doc-1", DocSchema) // Create an external store (like loro-prosemirror would) const externalStore = new EphemeralStore() externalStore.set("cursor", { position: 42 }) // Register it with the handle handle.addEphemeral("cursors", externalStore) // Should be retrievable via getEphemeral const retrieved = handle.getEphemeral("cursors") expect(retrieved).toBe(externalStore) expect(retrieved?.get("cursor")).toEqual({ position: 42 }) }) it("should throw if store name already exists", async () => { const { EphemeralStore } = await import("loro-crdt") const repo = new Repo({ identity: { name: "test", type: "user", peerId: "123" as `${number}` }, adapters: [], }) const handle = repo.get("doc-1", DocSchema, { presence: PresenceSchema, }) const externalStore = new EphemeralStore() // Should throw because 'presence' already exists expect(() => { handle.addEphemeral("presence", externalStore) }).toThrow('Ephemeral store "presence" already exists') }) }) describe("sync infrastructure", () => { it("should provide readyStates", () => { const repo = new Repo({ identity: { name: "test", type: "user" }, adapters: [], }) const handle = repo.get("doc-1", DocSchema) // With no adapters, readyStates includes the local "aware" state expect(handle.readyStates.length).toBeGreaterThanOrEqual(0) // The readyStates array is available expect(Array.isArray(handle.readyStates)).toBe(true) }) it("should support onReadyStateChange subscription", () => { const repo = new Repo({ identity: { name: "test", type: "user" }, adapters: [], }) const handle = repo.get("doc-1", DocSchema) const changes: unknown[] = [] const unsubscribe = handle.onReadyStateChange(states => { changes.push(states) }) expect(typeof unsubscribe).toBe("function") unsubscribe() }) }) describe("ephemeral store sync between peers", () => { it("should sync ephemeral stores between connected peers", async () => { // Create two repos connected via bridge const bridge = new Bridge() const repo1 = new Repo({ identity: { name: "peer1", type: "user", peerId: "1" as `${number}` }, adapters: [new BridgeAdapter({ adapterType: "network-1", bridge })], }) const repo2 = new Repo({ identity: { name: "peer2", type: "user", peerId: "2" as `${number}` }, adapters: [new BridgeAdapter({ adapterType: "network-2", bridge })], }) try { const handle1 = repo1.get("doc-1", DocSchema, { presence: PresenceSchema, }) const _handle2 = repo2.get("doc-1", DocSchema, { presence: PresenceSchema, }) // Wait for connection to establish await new Promise(resolve => setTimeout(resolve, 100)) // Set presence on peer1 handle1.presence.setSelf({ status: "online", cursor: { x: 100, y: 200 }, }) // Wait for sync await new Promise(resolve => setTimeout(resolve, 100)) // Note: The new namespaced stores use a different sync mechanism // This test verifies the basic structure works, but full sync // requires the synchronizer to handle namespaced stores in messages } finally { // Clean up to prevent test from hanging due to heartbeat interval repo1.synchronizer.stopHeartbeat() repo2.synchronizer.stopHeartbeat() } }) }) })