From bee01d9921dde74a3cbd181c3ba5e0b5cf9e2435 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Tue, 19 May 2026 18:31:35 +0100 Subject: [PATCH 1/4] Make CollaborationManager a Plugin --- packages/collaboration-manager/jest.config.ts | 5 +- .../src/CollaborationManager.spec.ts | 66 ++--- .../src/CollaborationManager.ts | 139 ++++++---- .../src/client/OTClient.spec.ts | 260 ++++++++++++------ .../test/mocks/codex-tooltip.ts | 13 + .../test/mocks/createManager.ts | 64 +++++ .../collaboration-manager/test/mocks/ws.ts | 20 +- .../src/api/DocumentAPI/DocumentAPI.spec.ts | 3 +- .../core/src/api/DocumentAPI/DocumentAPI.ts | 57 +++- packages/core/src/index.ts | 26 +- packages/ot-server/jest.config.ts | 1 + .../ot-server/test/mocks/codex-tooltip.ts | 13 + packages/sdk/src/api/DocumentAPI.ts | 44 ++- .../EventBus/events/core/CoreEventType.ts | 7 +- 14 files changed, 521 insertions(+), 197 deletions(-) create mode 100644 packages/collaboration-manager/test/mocks/codex-tooltip.ts create mode 100644 packages/collaboration-manager/test/mocks/createManager.ts create mode 100644 packages/ot-server/test/mocks/codex-tooltip.ts diff --git a/packages/collaboration-manager/jest.config.ts b/packages/collaboration-manager/jest.config.ts index ad18fcf1..940d6f69 100644 --- a/packages/collaboration-manager/jest.config.ts +++ b/packages/collaboration-manager/jest.config.ts @@ -8,11 +8,12 @@ export default { tsconfig: '/tsconfig.test.json', }, }, - testMatch: [ '/src/**/*.spec.ts' ], - modulePathIgnorePatterns: [ '/.*/__mocks__', '/.*/mocks' ], + testMatch: ['/src/**/*.spec.ts'], + modulePathIgnorePatterns: ['/.*/__mocks__', '/.*/mocks'], extensionsToTreatAsEsm: ['.ts'], moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1', + '^codex-tooltip$': '/test/mocks/codex-tooltip.ts', }, transform: { ...createDefaultEsmPreset().transform, diff --git a/packages/collaboration-manager/src/CollaborationManager.spec.ts b/packages/collaboration-manager/src/CollaborationManager.spec.ts index 5e524079..31bd04c6 100644 --- a/packages/collaboration-manager/src/CollaborationManager.spec.ts +++ b/packages/collaboration-manager/src/CollaborationManager.spec.ts @@ -4,9 +4,9 @@ import { EditorJSModel } from '@editorjs/model'; import type { CoreConfig } from '@editorjs/sdk'; import { beforeAll, jest } from '@jest/globals'; import { BatchedOperation } from './BatchedOperation.js'; -import { CollaborationManager } from './CollaborationManager.js'; import { Operation, OperationType } from './Operation.js'; import { UndoRedoManager } from './UndoRedoManager.js'; +import { createManager } from '../test/mocks/createManager.js'; const userId = 'user'; const remoteUserId = 'remote-user'; @@ -31,7 +31,7 @@ describe('CollaborationManager', () => { it('should throw an error on unknown operation type', () => { const model = new EditorJSModel(userId, { identifier: documentId }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; // @ts-expect-error - for test purposes expect(() => collaborationManager.applyOperation(new Operation('unknown', new IndexBuilder().build(), 'hello'))).toThrow('Unknown operation type'); @@ -51,7 +51,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 4]) @@ -92,7 +92,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([3, 5]) @@ -125,7 +125,7 @@ describe('CollaborationManager', () => { model.initializeDocument({ blocks: [], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .build(); const operation = new Operation(OperationType.Insert, index, { @@ -173,7 +173,7 @@ describe('CollaborationManager', () => { model.initializeDocument({ blocks: [block], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .build(); const operation = new Operation(OperationType.Delete, index, { @@ -202,7 +202,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 5]) @@ -249,7 +249,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 5]) @@ -279,7 +279,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const op1 = new Operation(OperationType.Insert, new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 0]) @@ -329,7 +329,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 3]) @@ -382,7 +382,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 4]) @@ -424,7 +424,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([ @@ -467,7 +467,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 4]) @@ -510,7 +510,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 4]) @@ -546,7 +546,7 @@ describe('CollaborationManager', () => { model.initializeDocument({ blocks: [], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .build(); const operation = new Operation(OperationType.Insert, index, { @@ -586,7 +586,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 5]) @@ -636,7 +636,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 3]) @@ -689,7 +689,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 3]) @@ -742,7 +742,7 @@ describe('CollaborationManager', () => { model.initializeDocument({ blocks: [block], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .build(); const operation = new Operation(OperationType.Delete, index, { @@ -778,7 +778,7 @@ describe('CollaborationManager', () => { model.initializeDocument({ blocks: [block], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .build(); const operation = new Operation(OperationType.Delete, index, { @@ -817,7 +817,7 @@ describe('CollaborationManager', () => { model.initializeDocument({ blocks: [block], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .build(); const operation = new Operation(OperationType.Delete, index, { @@ -859,7 +859,7 @@ describe('CollaborationManager', () => { model.initializeDocument({ blocks: [block], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .build(); const operation = new Operation(OperationType.Delete, index, { @@ -900,7 +900,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index1 = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 0]) @@ -952,7 +952,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index1 = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 0]) @@ -1005,7 +1005,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; model.insertText('another-user', 0, createDataKey('text'), 'hello', 0); @@ -1045,7 +1045,7 @@ describe('CollaborationManager', () => { }, }], }); - void new CollaborationManager(config as Required, model); + createManager(config as Required, model); model.insertText(userId, 0, createDataKey('text'), 'a', 0); @@ -1076,7 +1076,7 @@ describe('CollaborationManager', () => { }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; // Create local operation const localIndex = new IndexBuilder().addBlockIndex(0) @@ -1135,7 +1135,7 @@ describe('CollaborationManager', () => { }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; model.insertText(userId, 0, createDataKey('text'), 'world', 0); jest.advanceTimersByTime(500); @@ -1176,8 +1176,8 @@ describe('CollaborationManager', () => { }], }); - const collaborationManager = new CollaborationManager(config as Required, model); - const remoteCollaborationManager = new CollaborationManager(remoteConfig as Required, model); + const collaborationManager = createManager(config as Required, model).manager; + const remoteCollaborationManager = createManager(remoteConfig as Required, model).manager; // Char-by-char insert text 'hello' from local user const localText = 'hello'; @@ -1244,8 +1244,8 @@ describe('CollaborationManager', () => { }], }); - const collaborationManager = new CollaborationManager(config as Required, model); - const remoteCollaborationManager = new CollaborationManager(remoteConfig as Required, model); + const collaborationManager = createManager(config as Required, model).manager; + const remoteCollaborationManager = createManager(remoteConfig as Required, model).manager; // Isert line 'hello' from local user const localIndex = new IndexBuilder().addBlockIndex(0) @@ -1305,7 +1305,7 @@ describe('CollaborationManager', () => { }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; // Create local delete operation const localIndex = new IndexBuilder().addBlockIndex(0) diff --git a/packages/collaboration-manager/src/CollaborationManager.ts b/packages/collaboration-manager/src/CollaborationManager.ts index 7dac0f42..a96d3df0 100644 --- a/packages/collaboration-manager/src/CollaborationManager.ts +++ b/packages/collaboration-manager/src/CollaborationManager.ts @@ -1,14 +1,18 @@ import { BlockAddedEvent, type BlockNodeSerialized, BlockRemovedEvent, - type EditorJSModel, - EventType, type ModelEvents, TextAddedEvent, TextFormattedEvent, TextRemovedEvent, TextUnformattedEvent } from '@editorjs/model'; -import type { CoreConfig } from '@editorjs/sdk'; +import { + CoreEventType, + type EditorAPI, + type EditorjsPlugin, + type EditorjsPluginParams, + PluginType +} from '@editorjs/sdk'; import { OTClient } from './client/index.js'; import { BatchedOperation } from './BatchedOperation.js'; import { type ModifyOperationData, Operation, OperationType } from './Operation.js'; @@ -17,13 +21,19 @@ import { UndoRedoManager } from './UndoRedoManager.js'; const DEBOUNCE_TIMEOUT = 500; /** - * CollaborationManager listens to EditorJSModel events and applies operations + * CollaborationManager is a Plugin that listens to document API events and applies operations. + * It also manages undo/redo history and a connection to an OT server. */ -export class CollaborationManager { +export class CollaborationManager implements EditorjsPlugin { + /** + * Plugin type + */ + public static readonly type = PluginType.Plugin; + /** - * EditorJSModel instance to listen to and apply operations + * Editor API instance used to interact with the document */ - #model: EditorJSModel; + #api: EditorAPI; /** * UndoRedoManager instance to manage undo/redo operations @@ -41,11 +51,6 @@ export class CollaborationManager { */ #currentBatch: BatchedOperation | null = null; - /** - * Editor's config - */ - #config: Required; - /** * OT Client */ @@ -57,41 +62,34 @@ export class CollaborationManager { #debounceTimer?: ReturnType; /** - * Creates an instance of CollaborationManager - * @param config - Editor's config - * @param model - EditorJSModel instance to listen to and apply operations + * Editor's config */ - constructor(config: Required, model: EditorJSModel) { - this.#config = config; - this.#model = model; - this.#undoRedoManager = new UndoRedoManager(); - model.addEventListener(EventType.Changed, this.#handleEvent.bind(this)); - } + #config: EditorjsPluginParams['config']; /** - * Connects to OT server + * Creates an instance of CollaborationManager plugin + * @param params - plugin constructor parameters */ - public connect(): void { - if (this.#config.collaborationServer === undefined) { - return; - } + constructor(params: EditorjsPluginParams) { + const { api, config, eventBus } = params; - this.#client = new OTClient( - this.#config.collaborationServer, - this.#config.userId, - (data) => { - if (!data) { - return; - } + this.#api = api; + this.#config = config; + this.#undoRedoManager = new UndoRedoManager(); - this.#model.initializeDocument(data); - }, - (op) => { - this.applyOperation(op); - } - ); + api.document.onUpdate(this.#handleEvent.bind(this)); + + eventBus.addEventListener(`core:${CoreEventType.Undo}`, () => { + this.undo(); + }); - void this.#client.connectDocument(this.#model.serialized); + eventBus.addEventListener(`core:${CoreEventType.Redo}`, () => { + this.redo(); + }); + + eventBus.addEventListener(`core:${CoreEventType.Ready}`, () => { + this.#connect(); + }); } /** @@ -137,7 +135,7 @@ export class CollaborationManager { } /** - * Applies operation to the model + * Applies operation to the document via API * @param operation - operation to apply */ public applyOperation(operation: Operation | BatchedOperation): void { @@ -156,15 +154,27 @@ export class CollaborationManager { switch (operation.type) { case OperationType.Insert: - this.#model.insertData(operation.userId, operation.index, operation.data.payload as string | BlockNodeSerialized[]); + this.#api.document.insertData({ + userId: operation.userId, + index: operation.index, + data: operation.data.payload as string | BlockNodeSerialized[], + }); break; case OperationType.Delete: - this.#model.removeData(operation.userId, operation.index, operation.data.payload as string | BlockNodeSerialized[]); + this.#api.document.removeData({ + userId: operation.userId, + index: operation.index, + data: operation.data.payload as string | BlockNodeSerialized[], + }); break; case OperationType.Modify: - this.#model.modifyData(operation.userId, operation.index, { - value: operation.data.payload, - previous: (operation.data as ModifyOperationData).prevPayload, + this.#api.document.modifyData({ + userId: operation.userId, + index: operation.index, + data: { + value: operation.data.payload, + previous: (operation.data as ModifyOperationData).prevPayload, + }, }); break; default: @@ -173,8 +183,8 @@ export class CollaborationManager { } /** - * Handles EditorJSModel events - * @param e - event to handle + * Handles document update events + * @param e - model event to handle */ #handleEvent(e: ModelEvents): void { let operation: Operation | null = null; @@ -271,6 +281,32 @@ export class CollaborationManager { this.#debounce(); } + /** + * Connects to the OT server if a collaboration server is configured + */ + #connect(): void { + if (this.#config.collaborationServer === undefined) { + return; + } + + this.#client = new OTClient( + this.#config.collaborationServer, + this.#config.userId, + (data) => { + if (!data) { + return; + } + + this.#api.blocks.render(data); + }, + (op) => { + this.applyOperation(op); + } + ); + + void this.#client.connectDocument(this.#api.document.data); + } + /** * Puts current batch to the undo stack and clears the batch */ @@ -292,4 +328,11 @@ export class CollaborationManager { this.#putBatchToUndo(); }, DEBOUNCE_TIMEOUT); } + + /** + * Destroys the plugin instance: clears the debounce timer + */ + public destroy(): void { + clearTimeout(this.#debounceTimer); + } } diff --git a/packages/collaboration-manager/src/client/OTClient.spec.ts b/packages/collaboration-manager/src/client/OTClient.spec.ts index 10c9ea77..2855da34 100644 --- a/packages/collaboration-manager/src/client/OTClient.spec.ts +++ b/packages/collaboration-manager/src/client/OTClient.spec.ts @@ -1,94 +1,98 @@ +/* eslint-disable @typescript-eslint/no-magic-numbers */ import type { DocumentId, EditorDocumentSerialized } from '@editorjs/model'; -import { createDataKey, EditorJSModel, IndexBuilder } from '@editorjs/model'; -import { beforeEach, afterEach, jest } from '@jest/globals'; -import type { CoreConfig } from '@editorjs/sdk'; -import { MessageType } from './MessageType.js'; +import { createDataKey, IndexBuilder } from '@editorjs/model'; +import { beforeEach, afterEach, jest, describe, it, expect } from '@jest/globals'; import { OTClient } from './OTClient.js'; -import { CollaborationManager } from '../CollaborationManager.js'; import { Operation, OperationType } from '../Operation.js'; +import { MessageType } from './MessageType.js'; import { MockWebSocket } from '../../test/mocks/ws.js'; const userId = 'user'; const remoteUserId = 'remote-user'; -const documentId = 'document'; - -const config: CoreConfig = { - userId, - documentId: documentId, +const documentId = 'document' as DocumentId; + +/** + * Minimal stub document used when connecting the OTClient to a document. + */ +const stubDocument: EditorDocumentSerialized = { + identifier: documentId, + blocks: [], + properties: {}, }; describe('OTClient', () => { - describe('connect (mocked WebSocket)', () => { - const collabWsUrl = 'ws://test-collab.invalid/document'; + let OriginalWebSocket: typeof WebSocket; - let OriginalWebSocket: typeof WebSocket; + beforeEach(() => { + OriginalWebSocket = globalThis.WebSocket; // eslint-disable-line no-undef + globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; // eslint-disable-line no-undef + MockWebSocket.lastInstance = null; + }); - let connectDocumentSpy: jest.SpiedFunction; + afterEach(() => { + globalThis.WebSocket = OriginalWebSocket; // eslint-disable-line no-undef + MockWebSocket.lastInstance = null; + }); - let otClientFromConnect: OTClient | undefined; + describe('#onMessage / remote-operation handling', () => { + it('should call onRemoteOperation with the incoming operation when there are no pending local operations', async () => { + const onRemoteOperation = jest.fn(); + const client = new OTClient('ws://test-collab.invalid/document', userId, jest.fn(), onRemoteOperation); - beforeEach(() => { - otClientFromConnect = undefined; - OriginalWebSocket = globalThis.WebSocket; // eslint-disable-line no-undef -- Node provides globalThis at runtime - globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; // eslint-disable-line no-undef + await client.connectDocument(stubDocument); - const originalConnectDocument = OTClient.prototype.connectDocument; + const index = new IndexBuilder() + .addDocumentId(documentId) + .addBlockIndex(0) + .addDataKey(createDataKey('text')) + .addTextRange([0, 5]) + .build(); - connectDocumentSpy = jest.spyOn(OTClient.prototype, 'connectDocument').mockImplementation(async function (this: OTClient, doc: EditorDocumentSerialized) { - otClientFromConnect = this; + const remoteOp = new Operation(OperationType.Insert, index, { payload: 'hello' }, remoteUserId, 1); - return originalConnectDocument.call(this, doc); + MockWebSocket.lastInstance!.receiveFromServer({ + type: MessageType.Operation, + payload: remoteOp.serialize(), }); - }); - afterEach(() => { - connectDocumentSpy.mockRestore(); - globalThis.WebSocket = OriginalWebSocket; // eslint-disable-line no-undef - MockWebSocket.lastInstance = null; + expect(onRemoteOperation).toHaveBeenCalledTimes(1); + expect(onRemoteOperation).toHaveBeenCalledWith(expect.objectContaining({ type: OperationType.Insert })); }); - it('should not apply remote operation when it transforms to Neutral against pending local operation', async () => { - jest.useRealTimers(); - - const dataKeyValueA = createDataKey('valueA'); - - const model = new EditorJSModel(userId, { identifier: documentId }); - - model.initializeDocument({ - blocks: [{ - name: 'paragraph', - data: { - text: { - value: 'a', - $t: 't', - }, - }, - }, { - name: 'paragraph', - data: { - text: { - value: 'b', - $t: 't', - }, - valueA: 0, - }, - }], - properties: {}, + it('should NOT call onRemoteOperation when the remote operation is from the current user', async () => { + const onRemoteOperation = jest.fn(); + const client = new OTClient('ws://test-collab.invalid/document', userId, jest.fn(), onRemoteOperation); + + await client.connectDocument(stubDocument); + + const index = new IndexBuilder() + .addDocumentId(documentId) + .addBlockIndex(0) + .addDataKey(createDataKey('text')) + .addTextRange([0, 5]) + .build(); + + // Same userId as the local user — should be ignored + const ownOp = new Operation(OperationType.Insert, index, { payload: 'hello' }, userId, 1); + + MockWebSocket.lastInstance!.receiveFromServer({ + type: MessageType.Operation, + payload: ownOp.serialize(), }); - const collabConfig = { - ...config, - collaborationServer: collabWsUrl, - } as Required; + expect(onRemoteOperation).not.toHaveBeenCalled(); + }); - const collaborationManager = new CollaborationManager(collabConfig, model); + it('should NOT call onRemoteOperation when remote operation transforms to Neutral against a pending local operation', async () => { + const onRemoteOperation = jest.fn(); + const client = new OTClient('ws://test-collab.invalid/document', userId, jest.fn(), onRemoteOperation); - collaborationManager.connect(); + await client.connectDocument(stubDocument); const index = new IndexBuilder() - .addDocumentId(documentId as DocumentId) + .addDocumentId(documentId) .addBlockIndex(1) - .addDataKey(dataKeyValueA) + .addDataKey(createDataKey('valueA')) .build(); const localOp = new Operation(OperationType.Modify, index, { @@ -96,23 +100,15 @@ describe('OTClient', () => { prevPayload: null, }, userId); - expect(otClientFromConnect).toBeDefined(); - const otClient = otClientFromConnect!; - - /** - * Queue the same operation twice in OTClient: - * - first operation is in-flight and awaits server acknowledgement - * - second operation stays in pending operations and participates in transformation - */ - await otClient.send(localOp); - await otClient.send(Operation.from(localOp)); - - const applySpy = jest.spyOn(collaborationManager, 'applyOperation'); + // First send is in-flight (awaiting server acknowledgement). + await client.send(localOp); + // Second send stays in #pendingOperations (first is still unacknowledged). + await client.send(Operation.from(localOp)); + // Remote Modify on the same index — transforms to Neutral against the pending op. const remoteOp = new Operation(OperationType.Modify, index, { payload: { n: 2 }, prevPayload: null, - // eslint-disable-next-line @typescript-eslint/no-magic-numbers -- test revision }, remoteUserId, 3); MockWebSocket.lastInstance!.receiveFromServer({ @@ -120,11 +116,117 @@ describe('OTClient', () => { payload: remoteOp.serialize(), }); - expect(applySpy).not.toHaveBeenCalled(); + expect(onRemoteOperation).not.toHaveBeenCalled(); + }); + + it('should ignore messages with type other than Operation', async () => { + const onRemoteOperation = jest.fn(); + const client = new OTClient('ws://test-collab.invalid/document', userId, jest.fn(), onRemoteOperation); + + await client.connectDocument(stubDocument); + + MockWebSocket.lastInstance!.receiveFromServer({ + type: MessageType.Handshake, + payload: { + document: documentId, + userId: remoteUserId, + rev: 0, + }, + }); + + expect(onRemoteOperation).not.toHaveBeenCalled(); + }); + }); + + describe('connectDocument', () => { + it('should send a handshake packet to the server', async () => { + const client = new OTClient('ws://test-collab.invalid/document', userId, jest.fn(), jest.fn()); + + const sendSpy = jest.spyOn(MockWebSocket.prototype, 'send'); + + await client.connectDocument(stubDocument); + + const handshakeCall = sendSpy.mock.calls.find(([data]) => + typeof data === 'string' && (JSON.parse(data) as { type: MessageType }).type === MessageType.Handshake + ); + + expect(handshakeCall).toBeDefined(); + + sendSpy.mockRestore(); + }); + + it('should call onHandshake with the document data returned by the server', async () => { + const onHandshake = jest.fn(); + const client = new OTClient('ws://test-collab.invalid/document', userId, onHandshake, jest.fn()); + + await client.connectDocument(stubDocument); + + // Awaiting send() lets the handshake-reply microtask run and #handshake resolve, + // which triggers onHandshake. + const index = new IndexBuilder().addDocumentId(documentId) + .addBlockIndex(0) + .build(); + + await client.send(new Operation(OperationType.Insert, index, { payload: [] }, userId)); + + expect(onHandshake).toHaveBeenCalledTimes(1); + }); + }); + + describe('send', () => { + it('should send the operation to the server as a WebSocket message', async () => { + const client = new OTClient('ws://test-collab.invalid/document', userId, jest.fn(), jest.fn()); + + await client.connectDocument(stubDocument); + + const sendSpy = jest.spyOn(MockWebSocket.lastInstance!, 'send'); + + const index = new IndexBuilder() + .addDocumentId(documentId) + .addBlockIndex(0) + .addDataKey(createDataKey('text')) + .addTextRange([0, 0]) + .build(); + + const op = new Operation(OperationType.Insert, index, { payload: 'x' }, userId); + + await client.send(op); + + const opCall = sendSpy.mock.calls.find(([data]) => + typeof data === 'string' && (JSON.parse(data) as { type: MessageType }).type === MessageType.Operation + ); + + expect(opCall).toBeDefined(); + sendSpy.mockRestore(); + }); + + it('should queue a second operation while the first is awaiting acknowledgement', async () => { + const client = new OTClient('ws://test-collab.invalid/document', userId, jest.fn(), jest.fn()); + + await client.connectDocument(stubDocument); + + const sendSpy = jest.spyOn(MockWebSocket.lastInstance!, 'send'); + + const index = new IndexBuilder() + .addDocumentId(documentId) + .addBlockIndex(0) + .addDataKey(createDataKey('text')) + .addTextRange([0, 0]) + .build(); + + const op1 = new Operation(OperationType.Insert, index, { payload: 'a' }, userId); + const op2 = new Operation(OperationType.Insert, index, { payload: 'b' }, userId); + + await client.send(op1); + await client.send(op2); - applySpy.mockRestore(); + // Only one Operation message should have been sent to the server (op1 is in-flight, op2 queued). + const opCalls = sendSpy.mock.calls.filter(([data]) => + typeof data === 'string' && (JSON.parse(data) as { type: MessageType }).type === MessageType.Operation + ); - jest.useFakeTimers(); + expect(opCalls).toHaveLength(1); + sendSpy.mockRestore(); }); }); }); diff --git a/packages/collaboration-manager/test/mocks/codex-tooltip.ts b/packages/collaboration-manager/test/mocks/codex-tooltip.ts new file mode 100644 index 00000000..c5419340 --- /dev/null +++ b/packages/collaboration-manager/test/mocks/codex-tooltip.ts @@ -0,0 +1,13 @@ +/** + * Minimal mock for codex-tooltip to avoid `window is not defined` in Jest (Node) environment. + */ +export default class Tooltip { + // eslint-disable-next-line @typescript-eslint/no-empty-function + public show(): void {} + // eslint-disable-next-line @typescript-eslint/no-empty-function + public hide(): void {} + // eslint-disable-next-line @typescript-eslint/no-empty-function + public onHover(): void {} + // eslint-disable-next-line @typescript-eslint/no-empty-function + public destroy(): void {} +} diff --git a/packages/collaboration-manager/test/mocks/createManager.ts b/packages/collaboration-manager/test/mocks/createManager.ts new file mode 100644 index 00000000..6f71ff35 --- /dev/null +++ b/packages/collaboration-manager/test/mocks/createManager.ts @@ -0,0 +1,64 @@ +import type { EditorDocumentSerialized, ModelEvents } from '@editorjs/model'; +import { EventType } from '@editorjs/model'; +import type { EditorJSModel } from '@editorjs/model'; +import { EventBus } from '@editorjs/sdk'; +import type { CoreConfigValidated, DocumentAPI, EditorAPI, InsertRemoveDataParams, ModifyDataParams } from '@editorjs/sdk'; +import { CollaborationManager } from '../../src/CollaborationManager.js'; + +/** + * Creates a mock DocumentAPI backed by a real EditorJSModel instance + * @param model - the EditorJS model to back the mock API with + */ +function createMockDocumentAPI(model: EditorJSModel): DocumentAPI { + return { + get data(): EditorDocumentSerialized { + return model.serialized; + }, + onUpdate(callback: (event: ModelEvents) => void): () => void { + model.addEventListener(EventType.Changed, callback); + + return () => model.removeEventListener(EventType.Changed, callback); + }, + insertData({ userId, index, data }: InsertRemoveDataParams): void { + model.insertData(userId, index, data); + }, + removeData({ userId, index, data }: InsertRemoveDataParams): void { + model.removeData(userId, index, data); + }, + modifyData({ userId, index, data }: ModifyDataParams): void { + model.modifyData(userId, index, data); + }, + }; +} + +/** + * Creates a CollaborationManager instance backed by a real model for testing + * @param config - editor configuration + * @param model - the EditorJS model instance + * @returns an object containing the manager and the eventBus used + */ +export function createManager(config: CoreConfigValidated, model: EditorJSModel): { manager: CollaborationManager; + eventBus: EventBus; } { + const eventBus = new EventBus(); + + const api: EditorAPI = { + document: createMockDocumentAPI(model), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + blocks: {} as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + selection: {} as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + text: {} as any, + }; + + const manager = new CollaborationManager({ + config, + api, + eventBus, + }); + + return { + manager, + eventBus, + }; +} diff --git a/packages/collaboration-manager/test/mocks/ws.ts b/packages/collaboration-manager/test/mocks/ws.ts index a4543919..1e6652b4 100644 --- a/packages/collaboration-manager/test/mocks/ws.ts +++ b/packages/collaboration-manager/test/mocks/ws.ts @@ -1,4 +1,4 @@ -import { EditorDocumentSerialized } from '@editorjs/model'; +import type { EditorDocumentSerialized } from '@editorjs/model'; import type { HandshakePayload } from '../../src/client/Message.js'; import { MessageType } from '../../src/client/MessageType.js'; @@ -31,7 +31,6 @@ export class MockWebSocket { /** * Adds event listener - * * @param type - event type * @param listener - listener function */ @@ -52,7 +51,6 @@ export class MockWebSocket { /** * Removes event listener - * * @param type - event type * @param listener - listener function */ @@ -66,12 +64,18 @@ export class MockWebSocket { /** * Sends data to the server - * * @param data - data to send */ public send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void { - const raw = typeof data === 'string' ? data : String(data); - const message = JSON.parse(raw) as { type: string; payload: { document?: string; data?: EditorDocumentSerialized } }; + // eslint-disable-next-line @typescript-eslint/no-base-to-string + const raw = typeof data === 'string' ? data : data.toString(); + const message = JSON.parse(raw) as { + type: MessageType; + payload: { + document?: string; + data?: EditorDocumentSerialized; + }; + }; if (message.type === MessageType.Handshake) { const handshakePayload = message.payload as HandshakePayload; @@ -95,7 +99,6 @@ export class MockWebSocket { /** * Deliver a server → client WebSocket payload (remote operation, etc.). - * * @param payload - payload to receive from the server */ public receiveFromServer(payload: unknown): void { @@ -104,12 +107,11 @@ export class MockWebSocket { /** * Emits event - * * @param type - event type * @param event - event object */ #emit(type: string, event: { data: string }): void { - this.listeners.get(type)?.forEach(fn => { + this.listeners.get(type)?.forEach((fn) => { fn(event); }); } diff --git a/packages/core/src/api/DocumentAPI/DocumentAPI.spec.ts b/packages/core/src/api/DocumentAPI/DocumentAPI.spec.ts index 943dea0f..6182eeb4 100644 --- a/packages/core/src/api/DocumentAPI/DocumentAPI.spec.ts +++ b/packages/core/src/api/DocumentAPI/DocumentAPI.spec.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { beforeEach, describe, expect, jest } from '@jest/globals'; +import type { CoreConfigValidated } from '@editorjs/sdk'; jest.unstable_mockModule('@editorjs/model', () => { const EditorJSModel = jest.fn(() => ({ @@ -21,7 +22,7 @@ describe('DocumentAPI', () => { // @ts-expect-error - mock object, don't need to pass any arguments const model = new EditorJSModel(); - const documentAPI = new DocumentAPI(model); + const documentAPI = new DocumentAPI(model, {} as unknown as CoreConfigValidated); beforeEach(() => { jest.resetAllMocks(); diff --git a/packages/core/src/api/DocumentAPI/DocumentAPI.ts b/packages/core/src/api/DocumentAPI/DocumentAPI.ts index a3151501..6af17b80 100644 --- a/packages/core/src/api/DocumentAPI/DocumentAPI.ts +++ b/packages/core/src/api/DocumentAPI/DocumentAPI.ts @@ -1,8 +1,14 @@ import 'reflect-metadata'; -import { type EditorDocumentSerialized, EditorJSModel, EventType, ModelEvents } from '@editorjs/model'; -import { DocumentAPI as DocumentApiInterface } from '@editorjs/sdk'; -import { injectable } from 'inversify'; +import { type EditorDocumentSerialized, EditorJSModel, EventType, type ModelEvents } from '@editorjs/model'; +import { + CoreConfigValidated, + DocumentAPI as DocumentApiInterface, + type InsertRemoveDataParams, + type ModifyDataParams +} from '@editorjs/sdk'; +import { inject, injectable } from 'inversify'; +import { TOKENS } from '../../tokens'; /** * Document API @@ -15,13 +21,23 @@ export class DocumentAPI implements DocumentApiInterface { */ #model: EditorJSModel; + /** + * Editor's config + */ + #config: CoreConfigValidated; + /** * DocumentAPI constructor * All parameters are injected through the IoC container * @param model - Editor's Document Model instance + * @param config - Editor's config */ - constructor(model: EditorJSModel) { + constructor( + model: EditorJSModel, + @inject(TOKENS.EditorConfig) config: CoreConfigValidated + ) { this.#model = model; + this.#config = config; } /** @@ -42,4 +58,37 @@ export class DocumentAPI implements DocumentApiInterface { this.#model.removeEventListener(EventType.Changed, callback); }; } + + /** + * Inserts data at the specified index + * @param params - insert data method params + * @param [params.userId] - user identifier attributed to the change + * @param params.index - position in the document tree where data should be inserted + * @param params.data - text or blocks to insert + */ + public insertData({ userId = this.#config.userId, index, data }: InsertRemoveDataParams): void { + this.#model.insertData(userId, index, data); + } + + /** + * Removes data at the specified index + * @param params - remove data method params + * @param [params.userId] - user identifier attributed to the change + * @param params.index - Index of the document node to remove + * @param params.data - removed data + */ + public removeData({ userId = this.#config.userId, index, data }: InsertRemoveDataParams): void { + this.#model.removeData(userId, index, data); + } + + /** + * Modifies data at the specified index + * @param params - modify data method params + * @param [params.userId] - user identifier attributed to the change + * @param params.index - Index of the document node to modify + * @param params.data - modification data containing current and previous values + */ + public modifyData({ userId = this.#config.userId, index, data }: ModifyDataParams): void { + this.#model.modifyData(userId, index, data); + } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a28bb6c4..7c16d0c9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,6 +4,7 @@ import { Container } from 'inversify'; import { type BlockToolConstructor, CoreEventType, + CoreEventBase, EventBus, type InlineToolConstructor, PluginType, @@ -28,7 +29,7 @@ import { TOKENS } from './tokens.js'; const DEFAULT_HOLDER_ID = 'editorjs'; /** - * Editor entry poit + * Editor entry point * - initializes Model * - subscribes to model updates * - creates Adapters for Tools @@ -60,11 +61,6 @@ export default class Core { */ #plugins: Container; - /** - * Collaboration manager - */ - #collaborationManager: CollaborationManager; - /** * @param config - Editor configuration */ @@ -97,29 +93,18 @@ export default class Core { this.#toolsManager = this.#iocContainer.get(ToolsManager); - this.#collaborationManager = new CollaborationManager(this.#config, this.#model); - - this.#iocContainer.bind(CollaborationManager).toConstantValue(this.#collaborationManager); - if (config.onModelUpdate !== undefined) { this.#model.addEventListener(EventType.Changed, () => { config.onModelUpdate?.(this.#model); }); } - eventBus.addEventListener(`core:${CoreEventType.Undo}`, () => { - this.#collaborationManager.undo(); - }); - - eventBus.addEventListener(`core:${CoreEventType.Redo}`, () => { - this.#collaborationManager.redo(); - }); - this.use(Paragraph); this.use(BoldInlineTool); this.use(ItalicInlineTool); this.use(LinkInlineTool); this.use(ShortcutsPlugin); + this.use(CollaborationManager); this.use(DOMAdapters); } @@ -183,7 +168,10 @@ export default class Core { this.#iocContainer.get(BlockRenderer); this.#model.initializeDocument({ blocks }); - this.#collaborationManager.connect(); + + const eventBus = this.#iocContainer.get(EventBus); + + eventBus.dispatchEvent(new CoreEventBase(CoreEventType.Ready, undefined)); } catch (error) { console.error('Editor.js initialization failed', error); } diff --git a/packages/ot-server/jest.config.ts b/packages/ot-server/jest.config.ts index 184ce25f..0a49adc9 100644 --- a/packages/ot-server/jest.config.ts +++ b/packages/ot-server/jest.config.ts @@ -9,6 +9,7 @@ export default { moduleNameMapper: { // eslint-disable-next-line @typescript-eslint/naming-convention '^(\\.{1,2}/.*)\\.js$': '$1', + '^codex-tooltip$': '/test/mocks/codex-tooltip.ts', }, transform: { ...createDefaultEsmPreset().transform, diff --git a/packages/ot-server/test/mocks/codex-tooltip.ts b/packages/ot-server/test/mocks/codex-tooltip.ts new file mode 100644 index 00000000..c5419340 --- /dev/null +++ b/packages/ot-server/test/mocks/codex-tooltip.ts @@ -0,0 +1,13 @@ +/** + * Minimal mock for codex-tooltip to avoid `window is not defined` in Jest (Node) environment. + */ +export default class Tooltip { + // eslint-disable-next-line @typescript-eslint/no-empty-function + public show(): void {} + // eslint-disable-next-line @typescript-eslint/no-empty-function + public hide(): void {} + // eslint-disable-next-line @typescript-eslint/no-empty-function + public onHover(): void {} + // eslint-disable-next-line @typescript-eslint/no-empty-function + public destroy(): void {} +} diff --git a/packages/sdk/src/api/DocumentAPI.ts b/packages/sdk/src/api/DocumentAPI.ts index 6c26bbf2..2d783f23 100644 --- a/packages/sdk/src/api/DocumentAPI.ts +++ b/packages/sdk/src/api/DocumentAPI.ts @@ -1,4 +1,28 @@ -import type { EditorDocumentSerialized, ModelEvents } from '@editorjs/model'; +import type { BlockNodeSerialized, EditorDocumentSerialized, Index, ModelEvents, ModifiedEventData } from '@editorjs/model'; + +/** + * Parameters for insertData and removeData methods + */ +export interface InsertRemoveDataParams { + /** User identifier attributed to the change */ + userId: string | number | undefined; + /** Position in the document tree where data should be inserted or removed */ + index: Index; + /** Text or blocks to insert or remove */ + data: string | BlockNodeSerialized[]; +} + +/** + * Parameters for modifyData method + */ +export interface ModifyDataParams { + /** User identifier attributed to the change */ + userId: string | number | undefined; + /** Position in the document tree where data should be modified */ + index: Index; + /** Modification data containing current and previous values */ + data: ModifiedEventData; +} /** * Document API interface @@ -15,4 +39,22 @@ export interface DocumentAPI { * @param callback - callback called on model update */ onUpdate(callback: (event: ModelEvents) => void): () => void; + + /** + * Inserts data at the specified index + * @param params - insert operation parameters + */ + insertData(params: InsertRemoveDataParams): void; + + /** + * Removes data at the specified index + * @param params - remove operation parameters + */ + removeData(params: InsertRemoveDataParams): void; + + /** + * Modifies data at the specified index + * @param params - modify operation parameters + */ + modifyData(params: ModifyDataParams): void; } diff --git a/packages/sdk/src/entities/EventBus/events/core/CoreEventType.ts b/packages/sdk/src/entities/EventBus/events/core/CoreEventType.ts index 8f7995b5..403e3c81 100644 --- a/packages/sdk/src/entities/EventBus/events/core/CoreEventType.ts +++ b/packages/sdk/src/entities/EventBus/events/core/CoreEventType.ts @@ -33,5 +33,10 @@ export enum CoreEventType { /** * Event is fired when redo action should be performed */ - Redo = 'redo' + Redo = 'redo', + + /** + * Event is fired when the Editor is fully initialized (document and tools are ready) + */ + Ready = 'ready' } From 83e4bb043bf8157f8737c5a04fc76f94d8f71acf Mon Sep 17 00:00:00 2001 From: George Berezhnoy Date: Tue, 19 May 2026 18:38:10 +0100 Subject: [PATCH 2/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/core/src/api/DocumentAPI/DocumentAPI.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/api/DocumentAPI/DocumentAPI.ts b/packages/core/src/api/DocumentAPI/DocumentAPI.ts index 6af17b80..777559c8 100644 --- a/packages/core/src/api/DocumentAPI/DocumentAPI.ts +++ b/packages/core/src/api/DocumentAPI/DocumentAPI.ts @@ -8,7 +8,7 @@ import { type ModifyDataParams } from '@editorjs/sdk'; import { inject, injectable } from 'inversify'; -import { TOKENS } from '../../tokens'; +import { TOKENS } from '../../tokens.js'; /** * Document API From 6c1027481c02fc8c8630f0c4ffd8338cab9018a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 May 2026 17:44:19 +0000 Subject: [PATCH 3/4] Fix CollaborationManager destroy cleanup and OT client close handling Agent-Logs-Url: https://github.com/editor-js/document-model/sessions/48043d73-bfdc-4210-8f53-4d2128ebbfc0 Co-authored-by: gohabereg <23050529+gohabereg@users.noreply.github.com> --- .../src/CollaborationManager.spec.ts | 48 ++++++++++++++++- .../src/CollaborationManager.ts | 53 +++++++++++++++---- .../src/client/OTClient.spec.ts | 16 ++++++ .../src/client/OTClient.ts | 9 ++++ .../test/mocks/createManager.ts | 4 +- .../collaboration-manager/test/mocks/ws.ts | 7 +++ 6 files changed, 124 insertions(+), 13 deletions(-) diff --git a/packages/collaboration-manager/src/CollaborationManager.spec.ts b/packages/collaboration-manager/src/CollaborationManager.spec.ts index 31bd04c6..1223d2ff 100644 --- a/packages/collaboration-manager/src/CollaborationManager.spec.ts +++ b/packages/collaboration-manager/src/CollaborationManager.spec.ts @@ -1,12 +1,13 @@ /* eslint-disable @typescript-eslint/no-magic-numbers */ -import { createDataKey, IndexBuilder } from '@editorjs/model'; +import { createDataKey, EventType, IndexBuilder } from '@editorjs/model'; import { EditorJSModel } from '@editorjs/model'; -import type { CoreConfig } from '@editorjs/sdk'; +import { CoreEventType, type CoreConfig } from '@editorjs/sdk'; import { beforeAll, jest } from '@jest/globals'; import { BatchedOperation } from './BatchedOperation.js'; import { Operation, OperationType } from './Operation.js'; import { UndoRedoManager } from './UndoRedoManager.js'; import { createManager } from '../test/mocks/createManager.js'; +import { MockWebSocket } from '../test/mocks/ws.js'; const userId = 'user'; const remoteUserId = 'remote-user'; @@ -1342,4 +1343,47 @@ describe('CollaborationManager', () => { }); }); }); + + describe('destroy', () => { + it('should unsubscribe model and core event listeners', () => { + const model = new EditorJSModel(userId, { identifier: documentId }); + const removeModelListenerSpy = jest.spyOn(model, 'removeEventListener'); + const { manager, eventBus } = createManager(config as Required, model); + const removeEventBusListenerSpy = jest.spyOn(eventBus, 'removeEventListener'); + + manager.destroy(); + + expect(removeModelListenerSpy).toHaveBeenCalledWith(EventType.Changed, expect.any(Function)); + expect(removeEventBusListenerSpy).toHaveBeenCalledWith(`core:${CoreEventType.Undo}`, expect.any(Function)); + expect(removeEventBusListenerSpy).toHaveBeenCalledWith(`core:${CoreEventType.Redo}`, expect.any(Function)); + expect(removeEventBusListenerSpy).toHaveBeenCalledWith(`core:${CoreEventType.Ready}`, expect.any(Function)); + }); + + it('should close ot client connection', async () => { + const originalWebSocket = globalThis.WebSocket; + + try { + globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; + + const model = new EditorJSModel(userId, { identifier: documentId }); + const { manager, eventBus } = createManager({ + ...config, + collaborationServer: 'ws://test-collab.invalid/document', + } as Required, model); + + eventBus.dispatchEvent(new CustomEvent(`core:${CoreEventType.Ready}`)); + await Promise.resolve(); + + const closeSpy = jest.spyOn(MockWebSocket.lastInstance!, 'close'); + + manager.destroy(); + await Promise.resolve(); + + expect(closeSpy).toHaveBeenCalledTimes(1); + closeSpy.mockRestore(); + } finally { + globalThis.WebSocket = originalWebSocket; + } + }); + }); }); diff --git a/packages/collaboration-manager/src/CollaborationManager.ts b/packages/collaboration-manager/src/CollaborationManager.ts index a96d3df0..c6f8f58a 100644 --- a/packages/collaboration-manager/src/CollaborationManager.ts +++ b/packages/collaboration-manager/src/CollaborationManager.ts @@ -61,6 +61,26 @@ export class CollaborationManager implements EditorjsPlugin { */ #debounceTimer?: ReturnType; + /** + * Cleanup callback for document updates listener + */ + #unsubscribeDocumentUpdates?: () => void; + + /** + * Cleanup callback for undo event listener + */ + #unsubscribeUndo?: () => void; + + /** + * Cleanup callback for redo event listener + */ + #unsubscribeRedo?: () => void; + + /** + * Cleanup callback for ready event listener + */ + #unsubscribeReady?: () => void; + /** * Editor's config */ @@ -77,19 +97,26 @@ export class CollaborationManager implements EditorjsPlugin { this.#config = config; this.#undoRedoManager = new UndoRedoManager(); - api.document.onUpdate(this.#handleEvent.bind(this)); - - eventBus.addEventListener(`core:${CoreEventType.Undo}`, () => { + const onUndo = (): void => { this.undo(); - }); - - eventBus.addEventListener(`core:${CoreEventType.Redo}`, () => { + }; + const onRedo = (): void => { this.redo(); - }); - - eventBus.addEventListener(`core:${CoreEventType.Ready}`, () => { + }; + const onReady = (): void => { this.#connect(); - }); + }; + + this.#unsubscribeDocumentUpdates = api.document.onUpdate(this.#handleEvent.bind(this)); + + eventBus.addEventListener(`core:${CoreEventType.Undo}`, onUndo); + this.#unsubscribeUndo = () => eventBus.removeEventListener(`core:${CoreEventType.Undo}`, onUndo); + + eventBus.addEventListener(`core:${CoreEventType.Redo}`, onRedo); + this.#unsubscribeRedo = () => eventBus.removeEventListener(`core:${CoreEventType.Redo}`, onRedo); + + eventBus.addEventListener(`core:${CoreEventType.Ready}`, onReady); + this.#unsubscribeReady = () => eventBus.removeEventListener(`core:${CoreEventType.Ready}`, onReady); } /** @@ -334,5 +361,11 @@ export class CollaborationManager implements EditorjsPlugin { */ public destroy(): void { clearTimeout(this.#debounceTimer); + this.#unsubscribeDocumentUpdates?.(); + this.#unsubscribeUndo?.(); + this.#unsubscribeRedo?.(); + this.#unsubscribeReady?.(); + this.#client?.close(); + this.#client = null; } } diff --git a/packages/collaboration-manager/src/client/OTClient.spec.ts b/packages/collaboration-manager/src/client/OTClient.spec.ts index 2855da34..0bce6234 100644 --- a/packages/collaboration-manager/src/client/OTClient.spec.ts +++ b/packages/collaboration-manager/src/client/OTClient.spec.ts @@ -229,4 +229,20 @@ describe('OTClient', () => { sendSpy.mockRestore(); }); }); + + describe('close', () => { + it('should close websocket connection', async () => { + const client = new OTClient('ws://test-collab.invalid/document', userId, jest.fn(), jest.fn()); + + await client.connectDocument(stubDocument); + + const closeSpy = jest.spyOn(MockWebSocket.lastInstance!, 'close'); + + client.close(); + await Promise.resolve(); + + expect(closeSpy).toHaveBeenCalledTimes(1); + closeSpy.mockRestore(); + }); + }); }); diff --git a/packages/collaboration-manager/src/client/OTClient.ts b/packages/collaboration-manager/src/client/OTClient.ts index 3e5d394d..7f04408b 100644 --- a/packages/collaboration-manager/src/client/OTClient.ts +++ b/packages/collaboration-manager/src/client/OTClient.ts @@ -139,6 +139,15 @@ export class OTClient { await this.#sendNextOperation(); } + /** + * Closes websocket connection + */ + public close(): void { + void this.#ws.then((ws) => { + ws.close(); + }); + } + /** * Sends next operation from the pending ops array */ diff --git a/packages/collaboration-manager/test/mocks/createManager.ts b/packages/collaboration-manager/test/mocks/createManager.ts index 6f71ff35..b018faf1 100644 --- a/packages/collaboration-manager/test/mocks/createManager.ts +++ b/packages/collaboration-manager/test/mocks/createManager.ts @@ -44,7 +44,9 @@ export function createManager(config: CoreConfigValidated, model: EditorJSModel) const api: EditorAPI = { document: createMockDocumentAPI(model), // eslint-disable-next-line @typescript-eslint/no-explicit-any - blocks: {} as any, + blocks: { + render: () => undefined, + } as any, // eslint-disable-next-line @typescript-eslint/no-explicit-any selection: {} as any, // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/collaboration-manager/test/mocks/ws.ts b/packages/collaboration-manager/test/mocks/ws.ts index 1e6652b4..40e2d38c 100644 --- a/packages/collaboration-manager/test/mocks/ws.ts +++ b/packages/collaboration-manager/test/mocks/ws.ts @@ -97,6 +97,13 @@ export class MockWebSocket { } } + /** + * Closes websocket connection + */ + public close(): void { + this.readyState = 3; + } + /** * Deliver a server → client WebSocket payload (remote operation, etc.). * @param payload - payload to receive from the server From 11fbc61ff1359ac694c9c8c08f740f5a4805e497 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Tue, 19 May 2026 23:55:40 +0100 Subject: [PATCH 4/4] Fix CI checks --- .../src/CollaborationManager.spec.ts | 34 +++++++------------ .../src/CollaborationManager.ts | 6 ++-- 2 files changed, 16 insertions(+), 24 deletions(-) diff --git a/packages/collaboration-manager/src/CollaborationManager.spec.ts b/packages/collaboration-manager/src/CollaborationManager.spec.ts index 1223d2ff..61480e72 100644 --- a/packages/collaboration-manager/src/CollaborationManager.spec.ts +++ b/packages/collaboration-manager/src/CollaborationManager.spec.ts @@ -7,7 +7,7 @@ import { BatchedOperation } from './BatchedOperation.js'; import { Operation, OperationType } from './Operation.js'; import { UndoRedoManager } from './UndoRedoManager.js'; import { createManager } from '../test/mocks/createManager.js'; -import { MockWebSocket } from '../test/mocks/ws.js'; +import { OTClient } from './client/index.js'; const userId = 'user'; const remoteUserId = 'remote-user'; @@ -1360,30 +1360,22 @@ describe('CollaborationManager', () => { }); it('should close ot client connection', async () => { - const originalWebSocket = globalThis.WebSocket; - - try { - globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; - - const model = new EditorJSModel(userId, { identifier: documentId }); - const { manager, eventBus } = createManager({ - ...config, - collaborationServer: 'ws://test-collab.invalid/document', - } as Required, model); + const model = new EditorJSModel(userId, { identifier: documentId }); + const { manager, eventBus } = createManager({ + ...config, + collaborationServer: 'ws://test-collab.invalid/document', + } as Required, model); - eventBus.dispatchEvent(new CustomEvent(`core:${CoreEventType.Ready}`)); - await Promise.resolve(); + eventBus.dispatchEvent(new CustomEvent(`core:${CoreEventType.Ready}`)); + await Promise.resolve(); - const closeSpy = jest.spyOn(MockWebSocket.lastInstance!, 'close'); + const closeSpy = jest.spyOn(OTClient.prototype, 'close'); - manager.destroy(); - await Promise.resolve(); + manager.destroy(); + await Promise.resolve(); - expect(closeSpy).toHaveBeenCalledTimes(1); - closeSpy.mockRestore(); - } finally { - globalThis.WebSocket = originalWebSocket; - } + expect(closeSpy).toHaveBeenCalledTimes(1); + closeSpy.mockRestore(); }); }); }); diff --git a/packages/collaboration-manager/src/CollaborationManager.ts b/packages/collaboration-manager/src/CollaborationManager.ts index c6f8f58a..acbfbf48 100644 --- a/packages/collaboration-manager/src/CollaborationManager.ts +++ b/packages/collaboration-manager/src/CollaborationManager.ts @@ -110,13 +110,13 @@ export class CollaborationManager implements EditorjsPlugin { this.#unsubscribeDocumentUpdates = api.document.onUpdate(this.#handleEvent.bind(this)); eventBus.addEventListener(`core:${CoreEventType.Undo}`, onUndo); - this.#unsubscribeUndo = () => eventBus.removeEventListener(`core:${CoreEventType.Undo}`, onUndo); + this.#unsubscribeUndo = () => void eventBus.removeEventListener(`core:${CoreEventType.Undo}`, onUndo); eventBus.addEventListener(`core:${CoreEventType.Redo}`, onRedo); - this.#unsubscribeRedo = () => eventBus.removeEventListener(`core:${CoreEventType.Redo}`, onRedo); + this.#unsubscribeRedo = () => void eventBus.removeEventListener(`core:${CoreEventType.Redo}`, onRedo); eventBus.addEventListener(`core:${CoreEventType.Ready}`, onReady); - this.#unsubscribeReady = () => eventBus.removeEventListener(`core:${CoreEventType.Ready}`, onReady); + this.#unsubscribeReady = () => void eventBus.removeEventListener(`core:${CoreEventType.Ready}`, onReady); } /**