import { draftPersistence } from "./draft-persistence"; import { afterEach, describe, expect, test, vi } from "vitest"; import type { WebSocketEmitterOptions } from "@webstudio-is/sync-client/websocket"; import { $collaborators, $syncStatus } from "@webstudio-is/sync-client"; import { __testing__ } from "~/shared/awareness"; import { createMultiplayerSyncEmitter, startMultiplayerPresenceSync, } from "./multiplayer-sync"; const { $pointerPosition } = __testing__; const createTransportFactory = () => { let callbacks: WebSocketEmitterOptions | undefined; const sendToRelay = vi.fn(); const close = vi.fn(); return { close, get callbacks() { if (callbacks === undefined) { throw new Error("Transport was not created"); } return callbacks; }, createTransport(options: WebSocketEmitterOptions) { callbacks = options; return { close, emit: vi.fn(), on: vi.fn(() => () => {}), sendToRelay, }; }, sendToRelay, }; }; describe("createMultiplayerSyncEmitter", () => { test("Preview waits for a visible collaborator edit to become durable", async () => { draftPersistence.reset("project"); const transport = createTransportFactory(); const emitter = createMultiplayerSyncEmitter({ projectId: "project", clientId: "local", createTransport: transport.createTransport, url: "localhost:1999", }); emitter.connect("build"); transport.callbacks.onBroadcast({ type: "broadcast", actorId: "remote", originClientId: "remote", clientSeq: 1, relayTs: 10, seq: 4, transaction: { id: "remote-edit", object: "server", payload: [] }, }); const done = vi.fn(); const waiting = draftPersistence .wait("project", { signal: new AbortController().signal }) .then(done); transport.callbacks.onAck(3, 3); await Promise.resolve(); expect(done).not.toHaveBeenCalled(); transport.callbacks.onAck(4, 4); await waiting; expect(done).toHaveBeenCalledOnce(); emitter.close(); draftPersistence.reset(); }); test.each(["settled", "rejected", "dropped", "failed"] as const)( "Preview waits for durable acknowledgment or rejects %s", async (status) => { draftPersistence.reset("project"); const transport = createTransportFactory(); const emitter = createMultiplayerSyncEmitter({ projectId: "project", clientId: "client-1", createTransport: transport.createTransport, url: "localhost:1999", }); emitter.connect("build-1"); emitter.emit({ type: "apply", clientId: "client-1", transaction: { id: "tx-1", object: "server", payload: [] }, }); const done = vi.fn(); const wait = draftPersistence.wait("project", { signal: new AbortController().signal, }); const result = status === "settled" ? wait.then(done) : expect(wait).rejects.toMatchObject({ name: "DraftPersistenceError", reason: "save-failed", }); transport.callbacks.onApplied("tx-1", 1, status); await Promise.resolve(); expect(done).not.toHaveBeenCalled(); transport.callbacks.onAck(1, 2); await result; if (status !== "settled") { await expect( draftPersistence.wait("project", { signal: new AbortController().signal, }) ).rejects.toMatchObject({ name: "DraftPersistenceError", reason: "save-failed", }); } emitter.close(); draftPersistence.reset(); } ); afterEach(() => { $syncStatus.set({ status: "idle" }); $collaborators.set(new Map()); }); test("keeps unsaved state until all local applies are durable", () => { const transport = createTransportFactory(); const emitter = createMultiplayerSyncEmitter({ getAuthToken: async () => "token", clientId: "client-1", createTransport: transport.createTransport, url: "localhost:1999", }); $syncStatus.set({ status: "idle" }); emitter.connect("build-1"); emitter.emit({ type: "apply", clientId: "client-1", transaction: { id: "tx-1", object: "server", payload: [] }, }); emitter.emit({ type: "apply", clientId: "client-1", transaction: { id: "tx-2", object: "server", payload: [] }, }); expect($syncStatus.get()).toEqual({ status: "syncing" }); transport.callbacks.onBroadcast({ type: "broadcast", actorId: "client-1", clientSeq: 1, originClientId: "client-1", relayTs: 10, seq: 1, transaction: { id: "tx-1", object: "server", payload: [] }, }); transport.callbacks.onAck(1, 2); expect($syncStatus.get()).toEqual({ status: "syncing" }); transport.callbacks.onBroadcast({ type: "broadcast", actorId: "client-1", clientSeq: 2, originClientId: "client-1", relayTs: 11, seq: 2, transaction: { id: "tx-2", object: "server", payload: [] }, }); transport.callbacks.onAck(2, 3); expect($syncStatus.get()).toEqual({ status: "idle" }); }); test("connects to the build room and sends local transactions as relay applies", () => { const transport = createTransportFactory(); const emitter = createMultiplayerSyncEmitter({ getAuthToken: async () => "token", clientId: "client-1", createTransport: transport.createTransport, url: "localhost:1999", }); emitter.connect("build-1"); emitter.emit({ type: "apply", clientId: "client-1", transaction: { id: "tx-1", object: "server", payload: [] }, }); expect(transport.callbacks.buildId).toBe("build-1"); expect(transport.sendToRelay).toHaveBeenCalledWith({ type: "apply", transactionId: "tx-1", transaction: { id: "tx-1", object: "server", payload: [] }, clientId: "client-1", actorId: "client-1", clientSeq: 1, }); }); test("forwards sync messages locally before relaying server transactions", () => { const transport = createTransportFactory(); const emitter = createMultiplayerSyncEmitter({ getAuthToken: async () => "token", clientId: "client-1", createTransport: transport.createTransport, url: "localhost:1999", }); const handler = vi.fn(); emitter.on(handler); emitter.emit({ type: "state", clientId: "client-1", state: new Map([["server", new Map()]]), }); expect(handler).toHaveBeenCalledWith({ type: "state", clientId: "client-1", state: new Map([["server", new Map()]]), }); expect(transport.sendToRelay).not.toHaveBeenCalled(); }); test("does not relay client-only sync transactions to the multiplayer service", () => { const transport = createTransportFactory(); const emitter = createMultiplayerSyncEmitter({ getAuthToken: async () => "token", clientId: "client-1", createTransport: transport.createTransport, url: "localhost:1999", }); const handler = vi.fn(); emitter.on(handler); emitter.connect("build-1"); emitter.emit({ type: "apply", clientId: "client-1", transaction: { id: "tx-1", object: "canvasIframeState", payload: "idle" }, }); expect(handler).toHaveBeenCalledWith({ type: "apply", clientId: "client-1", transaction: { id: "tx-1", object: "canvasIframeState", payload: "idle" }, }); expect(transport.sendToRelay).not.toHaveBeenCalled(); }); test("applies relay broadcasts to sync handlers", () => { const transport = createTransportFactory(); const emitter = createMultiplayerSyncEmitter({ clientId: "client-1", createTransport: transport.createTransport, url: "localhost:1999", }); const handler = vi.fn(); emitter.on(handler); emitter.connect("build-1"); transport.callbacks.onBroadcast({ type: "broadcast", actorId: "client-2", clientSeq: 1, originClientId: "client-2", relayTs: 10, seq: 1, transaction: { id: "tx-2", object: "server", payload: [] }, }); expect(handler).toHaveBeenCalledWith({ type: "apply", clientId: "client-2", transaction: { id: "tx-2", object: "server", payload: [] }, }); }); test("surfaces relay applied errors to user messages", () => { const transport = createTransportFactory(); const onUserMessage = vi.fn(); const emitter = createMultiplayerSyncEmitter({ getAuthToken: async () => "token", clientId: "client-1", createTransport: transport.createTransport, onUserMessage, url: "localhost:1999", }); emitter.connect("build-1"); emitter.emit({ type: "apply", clientId: "client-1", transaction: { id: "tx-1", object: "server", payload: [] }, }); transport.callbacks.onApplied("tx-1", 1, "rejected", "No permission"); expect(onUserMessage).toHaveBeenCalledWith("No permission"); expect($syncStatus.get()).toEqual({ status: "idle" }); }); test("updates collaborators from relay presence", () => { const transport = createTransportFactory(); const emitter = createMultiplayerSyncEmitter({ clientId: "client-1", createTransport: transport.createTransport, url: "localhost:1999", }); $collaborators.set(new Map()); emitter.connect("build-1"); transport.callbacks.onPresence("client-2", { pageId: "page-1", pointerPosition: { x: 1, y: 2, xRatio: 0.1, yRatio: 0.2 }, selectedInstanceIds: ["instance-1"], }); expect($collaborators.get().get("client-2")).toMatchObject({ pageId: "page-1", pointerPosition: { x: 1, y: 2, xRatio: 0.1, yRatio: 0.2 }, selectedInstanceIds: ["instance-1"], }); }); }); describe("startMultiplayerPresenceSync", () => { afterEach(() => { $pointerPosition.set(undefined); }); test("sends only the latest awareness update in one microtask", async () => { const emitter = { sendPresence: vi.fn() }; const unsubscribe = startMultiplayerPresenceSync(emitter); await Promise.resolve(); emitter.sendPresence.mockClear(); $pointerPosition.set({ x: 1, y: 2, xRatio: 0.1, yRatio: 0.2, }); $pointerPosition.set({ x: 3, y: 4, xRatio: 0.3, yRatio: 0.4, }); expect(emitter.sendPresence).not.toHaveBeenCalled(); await Promise.resolve(); expect(emitter.sendPresence).toHaveBeenCalledTimes(1); expect(emitter.sendPresence).toHaveBeenCalledWith({ pageId: undefined, pointerPosition: { x: 3, y: 4, xRatio: 0.3, yRatio: 0.4 }, selectedInstanceIds: undefined, }); unsubscribe(); }); test("does not send scheduled presence after unsubscribe", async () => { const emitter = { sendPresence: vi.fn() }; const unsubscribe = startMultiplayerPresenceSync(emitter); await Promise.resolve(); emitter.sendPresence.mockClear(); $pointerPosition.set({ x: 1, y: 2, xRatio: 0.1, yRatio: 0.2, }); unsubscribe(); await Promise.resolve(); expect(emitter.sendPresence).not.toHaveBeenCalled(); }); });