From 575be3bd182192e0162d6dd67e73a843700c7f54 Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Tue, 14 Oct 2025 11:50:58 +1100 Subject: [PATCH] Remove mocks for yjs from the tests, and simplify em --- package-lock.json | 11 + package.json | 1 + packages/sync/src/test/connect-indexdb.js | 76 ++--- .../sync/src/test/create-webrtc-connection.ts | 204 +++++------- packages/sync/src/test/index.ts | 104 +++--- packages/sync/src/test/provider.ts | 165 ++-------- packages/sync/src/test/undo-manager.ts | 303 ++++++++---------- packages/sync/src/test/utils.ts | 83 ++--- 8 files changed, 364 insertions(+), 583 deletions(-) diff --git a/package-lock.json b/package-lock.json index fc72add1f51df5..4bf585c4523ec7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -105,6 +105,7 @@ "eslint-plugin-storybook": "0.9.0", "eslint-plugin-testing-library": "6.0.2", "execa": "4.0.2", + "fake-indexeddb": "6.2.3", "fast-glob": "3.2.7", "filenamify": "4.2.0", "glob": "7.1.2", @@ -24966,6 +24967,16 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/fake-indexeddb": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.3.tgz", + "integrity": "sha512-idzJXFtDIHNShFZ9ssS8IdsRgAP0t9zwWvSdCKsWK2dgh2xcXA6/2Oteaxar5GJqmwzZXCrKRO6F5IEiR4yJzw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-average-color": { "version": "9.1.1", "resolved": "https://registry.npmjs.org/fast-average-color/-/fast-average-color-9.1.1.tgz", diff --git a/package.json b/package.json index 09312bcd7502ac..6d2cd3847d17fb 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "eslint-plugin-storybook": "0.9.0", "eslint-plugin-testing-library": "6.0.2", "execa": "4.0.2", + "fake-indexeddb": "6.2.3", "fast-glob": "3.2.7", "filenamify": "4.2.0", "glob": "7.1.2", diff --git a/packages/sync/src/test/connect-indexdb.js b/packages/sync/src/test/connect-indexdb.js index bcb99dc8665bd8..db8c7fc96068b9 100644 --- a/packages/sync/src/test/connect-indexdb.js +++ b/packages/sync/src/test/connect-indexdb.js @@ -1,31 +1,13 @@ /** * External dependencies */ -import { describe, expect, it, jest, beforeEach } from '@jest/globals'; - -const mockIndexeddbPersistence = { - destroy: jest.fn(), -}; - -jest.mock( 'y-indexeddb', () => { - return { - IndexeddbPersistence: jest - .fn() - .mockImplementation( () => mockIndexeddbPersistence ), - }; -} ); - -const mockYDoc = { - clientID: 12345, - meta: new Map(), - getMap: jest.fn(), - transact: jest.fn( ( fn ) => fn() ), - destroy: jest.fn(), -}; - -jest.mock( 'yjs', () => ( { - Doc: jest.fn().mockImplementation( () => mockYDoc ), -} ) ); +import { describe, expect, it, beforeEach, afterEach } from '@jest/globals'; +import * as Y from 'yjs'; +// Polyfill structuredClone for jsdom environment (required by fake-indexeddb). +// Jest uses jsdom which doesn't include the structuredClone API yet. +// See: https://github.com/dumbmatter/fakeIndexedDB#jsdom-often-used-with-jest +import 'core-js/stable/structured-clone'; +import 'fake-indexeddb/auto'; /** * Internal dependencies @@ -33,28 +15,50 @@ jest.mock( 'yjs', () => ( { import { connectIndexDb } from '../connect-indexdb'; describe( 'connectIndexDb', () => { + let doc; + let provider; + beforeEach( () => { - jest.clearAllMocks(); + doc = new Y.Doc(); + } ); + + afterEach( () => { + provider?.destroy(); + doc?.destroy(); } ); it( 'creates an IndexeddbPersistence provider correctly', async () => { - const { IndexeddbPersistence } = jest.requireMock( 'y-indexeddb' ); - const objectId = '123'; - const objectType = 'post'; - const doc = mockYDoc; + const result = await connectIndexDb( '123', 'post', doc ); + provider = result; + + expect( result ).toBeDefined(); + expect( typeof result.destroy ).toBe( 'function' ); + } ); + + it( 'destroy method cleans up the provider', async () => { + const result = await connectIndexDb( '789', 'post', doc ); + provider = result; + + expect( result ).toBeDefined(); + expect( typeof result.destroy ).toBe( 'function' ); + expect( () => result.destroy() ).not.toThrow(); + } ); - const result = await connectIndexDb( objectId, objectType, doc ); + it( 'handles different object types and IDs correctly', async () => { + const result = await connectIndexDb( '456', 'page', doc ); + provider = result; expect( result ).toBeDefined(); expect( typeof result.destroy ).toBe( 'function' ); - expect( IndexeddbPersistence ).toHaveBeenCalledWith( 'post-123', doc ); } ); - it( 'destroy method calls provider.destroy', async () => { - const result = await connectIndexDb( '789', 'post', mockYDoc ); + it( 'persists data to IndexedDB', async () => { + const result = await connectIndexDb( '123', 'post', doc ); + provider = result; - result.destroy(); + const ymap = doc.getMap( 'test' ); + ymap.set( 'key', 'value' ); - expect( mockIndexeddbPersistence.destroy ).toHaveBeenCalled(); + expect( ymap.get( 'key' ) ).toBe( 'value' ); } ); } ); diff --git a/packages/sync/src/test/create-webrtc-connection.ts b/packages/sync/src/test/create-webrtc-connection.ts index 83b442e174064e..5878f99a68b08e 100644 --- a/packages/sync/src/test/create-webrtc-connection.ts +++ b/packages/sync/src/test/create-webrtc-connection.ts @@ -1,161 +1,107 @@ /** * External dependencies */ -import { describe, expect, it, jest, beforeEach } from '@jest/globals'; -import type * as Y from 'yjs'; - -const mockWebrtcProvider = { - destroy: jest.fn(), -}; - -jest.mock( '../webrtc-http-stream-signaling', () => { - return { - WebrtcProviderWithHttpSignaling: jest - .fn() - .mockImplementation( () => mockWebrtcProvider ), - }; -} ); - -const mockYDoc = { - clientID: 12345, - meta: new Map(), - getMap: jest.fn(), - transact: jest.fn( ( fn: () => void ) => fn() ), - destroy: jest.fn(), -}; - -jest.mock( 'yjs', () => ( { - Doc: jest.fn().mockImplementation( () => mockYDoc ), -} ) ); +import { + describe, + expect, + it, + jest, + beforeEach, + afterEach, +} from '@jest/globals'; +import * as Y from 'yjs'; /** * Internal dependencies */ -import { - createWebRTCConnection, - type WebRTCConnectionConfig, -} from '../create-webrtc-connection'; +import { createWebRTCConnection } from '../create-webrtc-connection'; +import { WebrtcProviderWithHttpSignaling } from '../webrtc-http-stream-signaling'; + +// Mock the WebRTC provider to avoid network connections in tests +jest.mock( '../webrtc-http-stream-signaling', () => ( { + WebrtcProviderWithHttpSignaling: jest.fn(), +} ) ); describe( 'createWebRTCConnection', () => { + let doc: Y.Doc; + const mockProvider = WebrtcProviderWithHttpSignaling as jest.Mock< any >; + beforeEach( () => { + doc = new Y.Doc(); jest.clearAllMocks(); } ); - describe( 'configuration', () => { - it( 'creates a connection function with signaling servers', () => { - const config: WebRTCConnectionConfig = { - signaling: [ 'ws://localhost:4444' ], - }; - - const connectDoc = createWebRTCConnection( config ); - - expect( typeof connectDoc ).toBe( 'function' ); - } ); - - it( 'accepts password in configuration', () => { - const config: WebRTCConnectionConfig = { - signaling: [ 'ws://localhost:4444' ], - password: 'test-password', - }; - - const connectDoc = createWebRTCConnection( config ); + afterEach( () => { + doc?.destroy(); + } ); - expect( typeof connectDoc ).toBe( 'function' ); + it( 'creates a connection function', () => { + const connectDoc = createWebRTCConnection( { + signaling: [ 'ws://localhost:4444' ], } ); - it( 'accepts multiple signaling servers', () => { - const config: WebRTCConnectionConfig = { - signaling: [ - 'ws://localhost:4444', - 'ws://localhost:5555', - 'wss://example.com/signaling', - ], - }; - - const connectDoc = createWebRTCConnection( config ); + expect( typeof connectDoc ).toBe( 'function' ); + } ); - expect( typeof connectDoc ).toBe( 'function' ); + it( 'creates WebrtcProvider with room name in format "objectType-objectId"', async () => { + const connectDoc = createWebRTCConnection( { + signaling: [ 'ws://localhost:4444' ], } ); - } ); - describe( 'connection function', () => { - it( 'creates WebrtcProvider with correct room name', async () => { - const { WebrtcProviderWithHttpSignaling } = jest.requireMock( - '../webrtc-http-stream-signaling' - ) as { - WebrtcProviderWithHttpSignaling: jest.Mock; - }; + await connectDoc( '789', 'post', doc ); - const config: WebRTCConnectionConfig = { + expect( mockProvider ).toHaveBeenCalledWith( + 'post-789', + doc, + expect.objectContaining( { signaling: [ 'ws://localhost:4444' ], - }; - - const connectDoc = createWebRTCConnection( config ); - await connectDoc( '123', 'post', mockYDoc as unknown as Y.Doc ); - - expect( WebrtcProviderWithHttpSignaling ).toHaveBeenCalledWith( - 'post-123', - mockYDoc, - expect.objectContaining( { - signaling: [ 'ws://localhost:4444' ], - } ) - ); - } ); + } ) + ); + } ); - it( 'passes password to WebrtcProvider', async () => { - const { WebrtcProviderWithHttpSignaling } = jest.requireMock( - '../webrtc-http-stream-signaling' - ) as { - WebrtcProviderWithHttpSignaling: jest.Mock; - }; + it( 'passes signaling servers to WebrtcProvider', async () => { + const signaling = [ + 'ws://localhost:4444', + 'ws://localhost:5555', + 'wss://example.com/signaling', + ]; + const connectDoc = createWebRTCConnection( { signaling } ); + + await connectDoc( '100', 'page', doc ); + + expect( mockProvider ).toHaveBeenCalledWith( + 'page-100', + doc, + expect.objectContaining( { signaling } ) + ); + } ); - const config: WebRTCConnectionConfig = { - signaling: [ 'ws://localhost:4444' ], - password: 'secret-password', - }; - - const connectDoc = createWebRTCConnection( config ); - await connectDoc( '456', 'page', mockYDoc as unknown as Y.Doc ); - - expect( WebrtcProviderWithHttpSignaling ).toHaveBeenCalledWith( - 'page-456', - mockYDoc, - expect.objectContaining( { - signaling: [ 'ws://localhost:4444' ], - password: 'secret-password', - } ) - ); + it( 'passes password to WebrtcProvider when provided', async () => { + const connectDoc = createWebRTCConnection( { + signaling: [ 'ws://localhost:4444' ], + password: 'test-password', } ); - it( 'returns promise with destroy method', async () => { - const config: WebRTCConnectionConfig = { - signaling: [ 'ws://localhost:4444' ], - }; + await connectDoc( '456', 'post', doc ); - const connectDoc = createWebRTCConnection( config ); - const result = await connectDoc( - '789', - 'post', - mockYDoc as unknown as Y.Doc - ); + expect( mockProvider ).toHaveBeenCalledWith( + 'post-456', + doc, + expect.objectContaining( { + password: 'test-password', + } ) + ); + } ); - expect( result ).toBeDefined(); - expect( typeof result.destroy ).toBe( 'function' ); + it( 'returns promise with no-op destroy method', async () => { + const connectDoc = createWebRTCConnection( { + signaling: [ 'ws://localhost:4444' ], } ); - it( 'destroy method is a no-op', async () => { - const config: WebRTCConnectionConfig = { - signaling: [ 'ws://localhost:4444' ], - }; - - const connectDoc = createWebRTCConnection( config ); - const result = await connectDoc( - '100', - 'post', - mockYDoc as unknown as Y.Doc - ); + const result = await connectDoc( '789', 'post', doc ); - expect( () => result.destroy() ).not.toThrow(); - } ); + expect( result ).toBeDefined(); + expect( typeof result.destroy ).toBe( 'function' ); + expect( () => result.destroy() ).not.toThrow(); } ); } ); diff --git a/packages/sync/src/test/index.ts b/packages/sync/src/test/index.ts index dd264e438e6782..c6e59c2374d74b 100644 --- a/packages/sync/src/test/index.ts +++ b/packages/sync/src/test/index.ts @@ -1,94 +1,76 @@ /** * External dependencies */ -import { describe, expect, it, jest, beforeEach } from '@jest/globals'; +import { + describe, + expect, + it, + jest, + beforeEach, + afterEach, +} from '@jest/globals'; jest.mock( '../connect-indexdb', () => ( { connectIndexDb: jest.fn(), } ) ); jest.mock( '../create-webrtc-connection', () => ( { - createWebRTCConnection: jest.fn(), + createWebRTCConnection: jest.fn( () => jest.fn() ), } ) ); /** * Internal dependencies */ import { getWebRTCSyncProvider, SyncProvider } from '../index'; +import { createWebRTCConnection } from '../create-webrtc-connection'; + +describe( 'getWebRTCSyncProvider', () => { + const mockCreateWebRTC = createWebRTCConnection as jest.Mock; -describe( 'index', () => { beforeEach( () => { jest.clearAllMocks(); } ); - describe( 'getWebRTCSyncProvider', () => { - it( 'creates a SyncProvider instance', () => { - const provider = getWebRTCSyncProvider(); - - expect( provider ).toBeInstanceOf( SyncProvider ); - } ); - - it( 'calls createWebRTCConnection with window settings', () => { - const { createWebRTCConnection } = jest.requireMock( - '../create-webrtc-connection' - ) as { createWebRTCConnection: jest.Mock }; - - getWebRTCSyncProvider(); - - expect( createWebRTCConnection ).toHaveBeenCalledWith( - expect.objectContaining( { - password: undefined, - signaling: expect.arrayContaining( [ undefined ] ), - } ) - ); - } ); + afterEach( () => { + delete ( globalThis.window as any ) + .__experimentalCollaborativeEditingSecret; + delete ( globalThis.window as any ).wp; + } ); - it( 'uses __experimentalCollaborativeEditingSecret and wp.ajax.settings.url when available', () => { - const { createWebRTCConnection } = jest.requireMock( - '../create-webrtc-connection' - ) as { createWebRTCConnection: jest.Mock }; + it( 'creates a SyncProvider instance', () => { + const provider = getWebRTCSyncProvider(); - globalThis.window.__experimentalCollaborativeEditingSecret = - 'test-secret'; - globalThis.window.wp = { - ajax: { settings: { url: 'https://example.com' } }, - }; + expect( provider ).toBeInstanceOf( SyncProvider ); + } ); - getWebRTCSyncProvider(); + it( 'passes window password and signaling URL to createWebRTCConnection', () => { + globalThis.window.__experimentalCollaborativeEditingSecret = + 'test-secret'; + globalThis.window.wp = { + ajax: { settings: { url: 'https://example.com' } }, + }; - expect( createWebRTCConnection ).toHaveBeenCalledWith( - expect.objectContaining( { - password: 'test-secret', - signaling: expect.arrayContaining( [ - 'https://example.com', - ] ), - } ) - ); + getWebRTCSyncProvider(); - delete ( globalThis.window as any ) - .__experimentalCollaborativeEditingSecret; - delete ( globalThis.window as any ).wp; + expect( mockCreateWebRTC ).toHaveBeenCalledWith( { + password: 'test-secret', + signaling: [ 'https://example.com' ], } ); + } ); - it( 'creates new provider instance on each call', () => { - const provider1 = getWebRTCSyncProvider(); - const provider2 = getWebRTCSyncProvider(); + it( 'handles missing window properties gracefully', () => { + getWebRTCSyncProvider(); - expect( provider1 ).not.toBe( provider2 ); + expect( mockCreateWebRTC ).toHaveBeenCalledWith( { + password: undefined, + signaling: [ undefined ], } ); + } ); - it( 'handles missing window.wp gracefully', () => { - const { createWebRTCConnection } = jest.requireMock( - '../create-webrtc-connection' - ) as { createWebRTCConnection: jest.Mock }; - - expect( () => getWebRTCSyncProvider() ).not.toThrow(); + it( 'creates new provider instance on each call', () => { + const provider1 = getWebRTCSyncProvider(); + const provider2 = getWebRTCSyncProvider(); - expect( createWebRTCConnection ).toHaveBeenCalledWith( - expect.objectContaining( { - signaling: expect.arrayContaining( [ undefined ] ), - } ) - ); - } ); + expect( provider1 ).not.toBe( provider2 ); } ); } ); diff --git a/packages/sync/src/test/provider.ts b/packages/sync/src/test/provider.ts index 52f7f8ea807ca6..cdf4b0257acdf8 100644 --- a/packages/sync/src/test/provider.ts +++ b/packages/sync/src/test/provider.ts @@ -9,7 +9,8 @@ import { beforeEach, afterEach, } from '@jest/globals'; -import type * as Y from 'yjs'; +import * as Y from 'yjs'; +import { Awareness } from 'y-protocols/awareness'; /** * Internal dependencies @@ -17,7 +18,6 @@ import type * as Y from 'yjs'; import { SyncProvider } from '../provider'; import { CRDT_STATE_VERSION_KEY, - CRDT_RECORD_MAP_KEY, CRDT_STATE_MAP_KEY, CRDT_STATE_PERSISTED_AT_KEY, CRDT_STATE_PERSISTED_BY_KEY, @@ -26,60 +26,12 @@ import { } from '../config'; import type { SyncConfig, RecordHandlers, ObjectData } from '../types'; -const mockYMapData = new Map(); -const mockYMap = { - get: jest.fn( ( key: string ) => mockYMapData.get( key ) ), - set: jest.fn( ( key: string, value: any ) => - mockYMapData.set( key, value ) - ), - observe: jest.fn(), - unobserve: jest.fn(), - observeDeep: jest.fn(), - unobserveDeep: jest.fn(), -}; - -const mockYDoc = { - clientID: 12345, - meta: new Map(), - getMap: jest.fn( () => mockYMap ), - transact: jest.fn( ( fn: () => void ) => fn() ), - destroy: jest.fn(), -}; - -jest.mock( 'yjs', () => ( { - Doc: jest.fn().mockImplementation( () => mockYDoc ), - UndoManager: jest.fn().mockImplementation( () => ( { - undo: jest.fn(), - redo: jest.fn(), - } ) ), - applyUpdate: jest.fn(), - encodeStateAsUpdate: jest.fn( () => new Uint8Array() ), -} ) ); - -jest.mock( 'y-protocols/awareness', () => ( { - Awareness: jest.fn().mockImplementation( () => ( { - destroy: jest.fn(), - setLocalState: jest.fn(), - getStates: jest.fn( () => new Map() ), - } ) ), -} ) ); - class TestSyncProvider extends SyncProvider { public get testEntityStates() { return this.entityStates; } } -jest.mock( '../y-utilities/y-multidoc-undomanager', () => ( { - YMultiDocUndoManager: jest.fn().mockImplementation( () => ( { - addToScope: jest.fn(), - undo: jest.fn(), - redo: jest.fn(), - canUndo: jest.fn( () => false ), - canRedo: jest.fn( () => false ), - } ) ), -} ) ); - describe( 'SyncProvider', () => { let syncProvider: TestSyncProvider; let mockSyncConfig: SyncConfig; @@ -89,18 +41,6 @@ describe( 'SyncProvider', () => { beforeEach( () => { jest.clearAllMocks(); - mockYDoc.meta = new Map(); - mockYMapData.clear(); - mockYMap.get.mockClear(); - mockYMap.set.mockClear(); - mockYMap.observe.mockClear(); - mockYMap.unobserve.mockClear(); - mockYMap.observeDeep.mockClear(); - mockYMap.unobserveDeep.mockClear(); - mockYDoc.getMap.mockClear().mockReturnValue( mockYMap ); - mockYDoc.transact.mockClear(); - mockYDoc.destroy.mockClear(); - mockSyncConfig = { objectType: 'post', getObjectId: jest.fn( ( record: ObjectData ) => @@ -148,42 +88,23 @@ describe( 'SyncProvider', () => { } ); afterEach( () => { - if ( syncProvider ) { - syncProvider.testEntityStates?.forEach( ( state ) => { - state?.discard(); - } ); - } - } ); - - describe( 'constructor', () => { - it( 'creates a new SyncProvider instance', () => { - expect( syncProvider ).toBeInstanceOf( SyncProvider ); - } ); - - it( 'initializes with empty connection creators', () => { - const provider = new TestSyncProvider(); - expect( provider ).toBeInstanceOf( SyncProvider ); + syncProvider?.testEntityStates?.forEach( ( state ) => { + state?.discard(); } ); + } ); - it( 'accepts connection creators', () => { - const mockConnectionCreator = jest.fn( () => - Promise.resolve( { destroy: jest.fn() } ) - ); - const provider = new TestSyncProvider( [ mockConnectionCreator ] ); - expect( provider ).toBeInstanceOf( SyncProvider ); - } ); + it( 'creates a new SyncProvider instance', () => { + expect( syncProvider ).toBeInstanceOf( SyncProvider ); + } ); - it( 'creates an undo manager', () => { - const provider = new TestSyncProvider(); - const undoManager = provider.getUndoManager(); + it( 'creates undo manager with expected methods', () => { + const undoManager = syncProvider.getUndoManager(); - expect( undoManager ).toBeDefined(); - expect( undoManager ).not.toBeNull(); - expect( typeof undoManager?.undo ).toBe( 'function' ); - expect( typeof undoManager?.redo ).toBe( 'function' ); - expect( typeof undoManager?.hasUndo ).toBe( 'function' ); - expect( typeof undoManager?.hasRedo ).toBe( 'function' ); - } ); + expect( undoManager ).toBeDefined(); + expect( typeof undoManager?.undo ).toBe( 'function' ); + expect( typeof undoManager?.redo ).toBe( 'function' ); + expect( typeof undoManager?.hasUndo ).toBe( 'function' ); + expect( typeof undoManager?.hasRedo ).toBe( 'function' ); } ); describe( 'bootstrap', () => { @@ -202,7 +123,9 @@ describe( 'SyncProvider', () => { expect( entityState?.syncConfig ).toBe( mockSyncConfig ); expect( entityState?.handlers ).toBe( mockHandlers ); expect( entityState?.ydoc ).toBeDefined(); + expect( entityState?.ydoc ).toBeInstanceOf( Y.Doc ); expect( entityState?.awareness ).toBeDefined(); + expect( entityState?.awareness ).toBeInstanceOf( Awareness ); } ); it( 'does not create awareness when not supported', async () => { @@ -293,7 +216,7 @@ describe( 'SyncProvider', () => { it( 'maintains separate CRDT docs for different entities', async () => { const record1 = { id: 1, title: 'Post 1' }; - const record2 = { id: 2, title: 'Post 2' }; + const record2 = { id: 2, title: 'Post 2' }; await syncProvider.bootstrap( mockSyncConfig, record1, { ...mockHandlers, @@ -308,36 +231,7 @@ describe( 'SyncProvider', () => { const state1 = syncProvider.testEntityStates.get( 'post_1' ); const state2 = syncProvider.testEntityStates.get( 'post_2' ); - expect( state1?.ydoc ).toBeDefined(); - expect( state2?.ydoc ).toBeDefined(); - } ); - - it( 'provides access to CRDT document maps', async () => { - await syncProvider.bootstrap( - mockSyncConfig, - mockRawRecord, - mockHandlers - ); - - const entityState = syncProvider.testEntityStates.get( 'post_1' ); - expect( entityState ).toBeDefined(); - - const recordMap = entityState?.ydoc.getMap( CRDT_RECORD_MAP_KEY ); - expect( recordMap ).toBeDefined(); - expect( mockYDoc.getMap ).toHaveBeenCalledWith( - CRDT_RECORD_MAP_KEY - ); - } ); - - it( 'sets up observers for document updates', async () => { - await syncProvider.bootstrap( - mockSyncConfig, - mockRawRecord, - mockHandlers - ); - - expect( mockYMap.observeDeep ).toHaveBeenCalled(); - expect( mockYMap.observe ).toHaveBeenCalled(); + expect( state1?.ydoc ).not.toBe( state2?.ydoc ); } ); } ); @@ -443,22 +337,13 @@ describe( 'SyncProvider', () => { } ); } ); - describe( 'getUndoManager', () => { - it( 'returns the undo manager instance', () => { - const undoManager = syncProvider.getUndoManager(); - expect( undoManager ).toBeDefined(); - } ); - } ); - - describe( 'createEntityMeta', () => { - it( 'returns empty meta by default', async () => { - const meta = await syncProvider.createEntityMeta( - mockSyncConfig, - mockRawRecord - ); + it( 'returns empty meta by default', async () => { + const meta = await syncProvider.createEntityMeta( + mockSyncConfig, + mockRawRecord + ); - expect( meta ).toEqual( {} ); - } ); + expect( meta ).toEqual( {} ); } ); describe( 'state synchronization', () => { diff --git a/packages/sync/src/test/undo-manager.ts b/packages/sync/src/test/undo-manager.ts index 104191275ead60..0b5be48eb928c2 100644 --- a/packages/sync/src/test/undo-manager.ts +++ b/packages/sync/src/test/undo-manager.ts @@ -1,243 +1,220 @@ /** * External dependencies */ -import { describe, expect, it, jest, beforeEach } from '@jest/globals'; - -const mockYDoc = { - clientID: 12345, - meta: new Map(), - getMap: jest.fn(), - transact: jest.fn( ( fn: () => void ) => fn() ), - destroy: jest.fn(), -}; - -jest.mock( 'yjs', () => ( { - Doc: jest.fn().mockImplementation( () => mockYDoc ), -} ) ); +import { describe, expect, it, beforeEach, afterEach } from '@jest/globals'; +import * as Y from 'yjs'; /** * Internal dependencies */ import { UndoManager } from '../undo-manager'; - -const mockYMultiDocUndoManager = { - addToScope: jest.fn(), - undo: jest.fn(), - redo: jest.fn(), - canUndo: jest.fn(), - canRedo: jest.fn(), -}; - -jest.mock( '../y-utilities/y-multidoc-undomanager', () => { - return { - YMultiDocUndoManager: jest - .fn() - .mockImplementation( () => mockYMultiDocUndoManager ), - }; -} ); +import { YMultiDocUndoManager } from '../y-utilities/y-multidoc-undomanager'; describe( 'UndoManager', () => { + let undoManager: UndoManager; + let ydoc: Y.Doc; + let ymap: Y.Map< any >; + beforeEach( () => { - jest.clearAllMocks(); + // Reset the singleton instance + ( UndoManager as any ).instance = null; + ydoc = new Y.Doc(); + ymap = ydoc.getMap( 'test' ); } ); - describe( 'create', () => { - it( 'creates a singleton instance and initializes with correct properties', () => { - const { YMultiDocUndoManager } = jest.requireMock( - '../y-utilities/y-multidoc-undomanager' - ) as { - YMultiDocUndoManager: jest.Mock; - }; - - const instance1 = UndoManager.create(); - const instance2 = UndoManager.create(); - - expect( instance1 ).toBe( instance2 ); - expect( instance1 ).toBeInstanceOf( UndoManager ); - - expect( YMultiDocUndoManager ).toHaveBeenCalledTimes( 1 ); - - const callArgs = YMultiDocUndoManager.mock.calls[ 0 ]; - const options = callArgs[ 1 ] as { - captureTimeout: number; - trackedOrigins: Set< string >; - }; - - expect( callArgs[ 0 ] ).toEqual( [] ); - expect( options.captureTimeout ).toBe( 200 ); - expect( options.trackedOrigins ).toBeInstanceOf( Set ); - expect( options.trackedOrigins.size ).toBe( 1 ); - expect( options.trackedOrigins.has( 'gutenberg' ) ).toBe( true ); - } ); + afterEach( () => { + ydoc?.destroy(); } ); - describe( 'addRecord', () => { - it( 'is a no-op as Yjs automatically tracks changes', () => { - const undoManager = UndoManager.create(); + it( 'creates a singleton instance', () => { + const instance1 = UndoManager.create(); + const instance2 = UndoManager.create(); - expect( () => { - undoManager.addRecord(); - } ).not.toThrow(); + expect( instance1 ).toBe( instance2 ); + expect( instance1 ).toBeInstanceOf( UndoManager ); + } ); - expect( () => { - undoManager.addRecord( undefined, false ); - } ).not.toThrow(); + it( 'initializes with YMultiDocUndoManager tracking gutenberg origin', () => { + const instance = UndoManager.create(); + const internalManager = ( instance as any ).undoManager; - expect( () => { - undoManager.addRecord( undefined, true ); - } ).not.toThrow(); - } ); + expect( internalManager ).toBeInstanceOf( YMultiDocUndoManager ); + expect( internalManager.trackedOrigins.has( 'gutenberg' ) ).toBe( + true + ); + } ); + + it( 'addRecord is a no-op', () => { + undoManager = UndoManager.create(); + + expect( () => undoManager.addRecord() ).not.toThrow(); + expect( () => undoManager.addRecord( undefined, false ) ).not.toThrow(); + expect( () => undoManager.addRecord( undefined, true ) ).not.toThrow(); } ); describe( 'addToScope', () => { it( 'adds a Yjs map to the undo manager scope', () => { - const undoManager = UndoManager.create(); - const mockYMap = { test: 'map' }; + undoManager = UndoManager.create(); - undoManager.addToScope( mockYMap as any ); + undoManager.addToScope( ymap ); - expect( mockYMultiDocUndoManager.addToScope ).toHaveBeenCalledWith( - mockYMap - ); + // Verify the map is in scope by checking the internal manager + const internalManager = ( undoManager as any ).undoManager; + expect( internalManager.docs.has( ydoc ) ).toBe( true ); } ); it( 'can add multiple maps to scope', () => { - const undoManager = UndoManager.create(); - const mockYMap1 = { test: 'map1' }; - const mockYMap2 = { test: 'map2' }; - - undoManager.addToScope( mockYMap1 as any ); - undoManager.addToScope( mockYMap2 as any ); - - expect( mockYMultiDocUndoManager.addToScope ).toHaveBeenCalledTimes( - 2 - ); - expect( mockYMultiDocUndoManager.addToScope ).toHaveBeenCalledWith( - mockYMap1 - ); - expect( mockYMultiDocUndoManager.addToScope ).toHaveBeenCalledWith( - mockYMap2 - ); + undoManager = UndoManager.create(); + const ydoc2 = new Y.Doc(); + const ymap2 = ydoc2.getMap( 'test2' ); + + undoManager.addToScope( ymap ); + undoManager.addToScope( ymap2 ); + + const internalManager = ( undoManager as any ).undoManager; + expect( internalManager.docs.has( ydoc ) ).toBe( true ); + expect( internalManager.docs.has( ydoc2 ) ).toBe( true ); + + ydoc2.destroy(); } ); } ); - describe( 'undo', () => { - it( 'returns undefined when there is nothing to undo', () => { - const undoManager = UndoManager.create(); - mockYMultiDocUndoManager.canUndo.mockReturnValue( false ); + it( 'undo returns undefined when nothing to undo', () => { + undoManager = UndoManager.create(); + undoManager.addToScope( ymap ); - const result = undoManager.undo(); + expect( undoManager.undo() ).toBeUndefined(); + } ); - expect( result ).toBeUndefined(); - expect( mockYMultiDocUndoManager.undo ).not.toHaveBeenCalled(); - } ); + it( 'undo reverts changes and returns empty array', () => { + undoManager = UndoManager.create(); + undoManager.addToScope( ymap ); - it( 'performs undo and returns empty array when undo is available', () => { - const undoManager = UndoManager.create(); - mockYMultiDocUndoManager.canUndo.mockReturnValue( true ); + ydoc.transact( () => { + ymap.set( 'key1', 'value1' ); + }, 'gutenberg' ); - const result = undoManager.undo(); + expect( ymap.get( 'key1' ) ).toBe( 'value1' ); + expect( undoManager.undo() ).toEqual( [] ); + expect( ymap.get( 'key1' ) ).toBeUndefined(); + } ); - expect( mockYMultiDocUndoManager.undo ).toHaveBeenCalled(); - expect( result ).toEqual( [] ); - } ); + it( 'redo returns undefined when nothing to redo', () => { + undoManager = UndoManager.create(); + undoManager.addToScope( ymap ); + + expect( undoManager.redo() ).toBeUndefined(); } ); - describe( 'redo', () => { - it( 'returns undefined when there is nothing to redo', () => { - const undoManager = UndoManager.create(); - mockYMultiDocUndoManager.canRedo.mockReturnValue( false ); + it( 'redo reapplies changes and returns empty array', () => { + undoManager = UndoManager.create(); + undoManager.addToScope( ymap ); - const result = undoManager.redo(); + ydoc.transact( () => { + ymap.set( 'key1', 'value1' ); + }, 'gutenberg' ); - expect( result ).toBeUndefined(); - expect( mockYMultiDocUndoManager.redo ).not.toHaveBeenCalled(); - } ); + undoManager.undo(); + expect( ymap.get( 'key1' ) ).toBeUndefined(); - it( 'performs redo and returns empty array when redo is available', () => { - const undoManager = UndoManager.create(); - mockYMultiDocUndoManager.canRedo.mockReturnValue( true ); + expect( undoManager.redo() ).toEqual( [] ); + expect( ymap.get( 'key1' ) ).toBe( 'value1' ); + } ); - const result = undoManager.redo(); + it( 'hasUndo returns false when no undo available', () => { + undoManager = UndoManager.create(); + undoManager.addToScope( ymap ); - expect( mockYMultiDocUndoManager.redo ).toHaveBeenCalled(); - expect( result ).toEqual( [] ); - } ); + expect( undoManager.hasUndo() ).toBe( false ); } ); - describe( 'hasUndo', () => { - it( 'returns false when no undo is available', () => { - const undoManager = UndoManager.create(); - mockYMultiDocUndoManager.canUndo.mockReturnValue( false ); + it( 'hasUndo returns true after making changes', () => { + undoManager = UndoManager.create(); + undoManager.addToScope( ymap ); - expect( undoManager.hasUndo() ).toBe( false ); - } ); + ydoc.transact( () => { + ymap.set( 'key1', 'value1' ); + }, 'gutenberg' ); - it( 'returns true when undo is available', () => { - const undoManager = UndoManager.create(); - mockYMultiDocUndoManager.canUndo.mockReturnValue( true ); + expect( undoManager.hasUndo() ).toBe( true ); + } ); - expect( undoManager.hasUndo() ).toBe( true ); - } ); + it( 'hasRedo returns false when no redo available', () => { + undoManager = UndoManager.create(); + undoManager.addToScope( ymap ); + + expect( undoManager.hasRedo() ).toBe( false ); } ); - describe( 'hasRedo', () => { - it( 'returns false when no redo is available', () => { - const undoManager = UndoManager.create(); - mockYMultiDocUndoManager.canRedo.mockReturnValue( false ); + it( 'hasRedo returns true after undoing changes', () => { + undoManager = UndoManager.create(); + undoManager.addToScope( ymap ); - expect( undoManager.hasRedo() ).toBe( false ); - } ); + ydoc.transact( () => { + ymap.set( 'key1', 'value1' ); + }, 'gutenberg' ); - it( 'returns true when redo is available', () => { - const undoManager = UndoManager.create(); - mockYMultiDocUndoManager.canRedo.mockReturnValue( true ); + undoManager.undo(); - expect( undoManager.hasRedo() ).toBe( true ); - } ); + expect( undoManager.hasRedo() ).toBe( true ); } ); describe( 'integration workflow', () => { it( 'follows typical undo/redo workflow', () => { - const undoManager = UndoManager.create(); - - mockYMultiDocUndoManager.canUndo.mockReturnValue( false ); - mockYMultiDocUndoManager.canRedo.mockReturnValue( false ); + undoManager = UndoManager.create(); + undoManager.addToScope( ymap ); + // Initially no undo/redo available expect( undoManager.hasUndo() ).toBe( false ); expect( undoManager.hasRedo() ).toBe( false ); - mockYMultiDocUndoManager.canUndo.mockReturnValue( true ); + // Make a change + ydoc.transact( () => { + ymap.set( 'key1', 'value1' ); + }, 'gutenberg' ); + + // Now undo is available expect( undoManager.hasUndo() ).toBe( true ); + expect( undoManager.hasRedo() ).toBe( false ); + // Undo the change undoManager.undo(); - mockYMultiDocUndoManager.canUndo.mockReturnValue( false ); - mockYMultiDocUndoManager.canRedo.mockReturnValue( true ); - expect( undoManager.hasUndo() ).toBe( false ); expect( undoManager.hasRedo() ).toBe( true ); + // Redo the change undoManager.redo(); - mockYMultiDocUndoManager.canUndo.mockReturnValue( true ); - mockYMultiDocUndoManager.canRedo.mockReturnValue( false ); - expect( undoManager.hasUndo() ).toBe( true ); expect( undoManager.hasRedo() ).toBe( false ); } ); it( 'handles multiple scopes', () => { - const undoManager = UndoManager.create(); - const mockYMap1 = { test: 'map1' }; - const mockYMap2 = { test: 'map2' }; + undoManager = UndoManager.create(); + const ydoc2 = new Y.Doc(); + const ymap2 = ydoc2.getMap( 'test2' ); - undoManager.addToScope( mockYMap1 as any ); - undoManager.addToScope( mockYMap2 as any ); + undoManager.addToScope( ymap ); + undoManager.addToScope( ymap2 ); - mockYMultiDocUndoManager.canUndo.mockReturnValue( true ); + // Make changes in both docs + ydoc.transact( () => { + ymap.set( 'doc1', 'value1' ); + }, 'gutenberg' ); + + ydoc2.transact( () => { + ymap2.set( 'doc2', 'value2' ); + }, 'gutenberg' ); + + expect( undoManager.hasUndo() ).toBe( true ); + + // Undo should work across both docs + undoManager.undo(); + expect( ymap2.get( 'doc2' ) ).toBeUndefined(); undoManager.undo(); + expect( ymap.get( 'doc1' ) ).toBeUndefined(); - expect( mockYMultiDocUndoManager.undo ).toHaveBeenCalled(); + ydoc2.destroy(); } ); } ); } ); diff --git a/packages/sync/src/test/utils.ts b/packages/sync/src/test/utils.ts index fb8aa37d0914de..67dfac18207a69 100644 --- a/packages/sync/src/test/utils.ts +++ b/packages/sync/src/test/utils.ts @@ -1,27 +1,8 @@ /** * External dependencies */ -import { describe, expect, it, jest, beforeEach } from '@jest/globals'; - -const mockYMapData = new Map(); -const mockYMap = { - get: jest.fn( ( key: string ) => mockYMapData.get( key ) ), - set: jest.fn( ( key: string, value: any ) => - mockYMapData.set( key, value ) - ), -}; - -const mockYDoc = { - clientID: 12345, - meta: new Map(), - getMap: jest.fn( () => mockYMap ), - transact: jest.fn( ( fn: () => void ) => fn() ), - destroy: jest.fn(), -}; - -jest.mock( 'yjs', () => ( { - Doc: jest.fn().mockImplementation( () => mockYDoc ), -} ) ); +import { describe, expect, it, afterEach } from '@jest/globals'; +import * as Y from 'yjs'; /** * Internal dependencies @@ -35,43 +16,37 @@ import { CRDT_STATE_VERSION_KEY, } from '../config'; -describe( 'utils', () => { - beforeEach( () => { - mockYDoc.meta = new Map(); - mockYMapData.clear(); - mockYMap.get.mockClear(); - mockYMap.set.mockClear(); - mockYDoc.getMap.mockClear(); - } ); +describe( 'createYjsDoc', () => { + let ydoc: Y.Doc; - describe( 'createYjsDoc', () => { - it( 'initializes state map with default values', () => { - const ydoc = createYjsDoc( { objectType: 'post' } ); - const stateMap = ydoc.getMap( CRDT_STATE_MAP_KEY ); - - expect( ydoc ).toBeDefined(); - expect( stateMap.get( CRDT_STATE_PERSISTED_AT_KEY ) ).toBe( 0 ); - expect( stateMap.get( CRDT_STATE_RESTORED_AT_KEY ) ).toBe( 0 ); - expect( stateMap.get( CRDT_STATE_VERSION_KEY ) ).toBe( - CRDT_DOC_VERSION - ); - expect( mockYDoc.meta?.size ).toBe( 0 ); - } ); + afterEach( () => { + ydoc?.destroy(); + } ); - it( 'sets document meta from provided metadata', () => { - const documentMeta = { - objectType: 'post', - objectId: 123, - author: 'test-user', - }; + it( 'initializes state map with default values', () => { + ydoc = createYjsDoc( { objectType: 'post' } ); + const stateMap = ydoc.getMap( CRDT_STATE_MAP_KEY ); + + expect( ydoc ).toBeInstanceOf( Y.Doc ); + expect( stateMap.get( CRDT_STATE_PERSISTED_AT_KEY ) ).toBe( 0 ); + expect( stateMap.get( CRDT_STATE_RESTORED_AT_KEY ) ).toBe( 0 ); + expect( stateMap.get( CRDT_STATE_VERSION_KEY ) ).toBe( + CRDT_DOC_VERSION + ); + expect( ydoc.meta?.get( 'objectType' ) ).toBe( 'post' ); + } ); - mockYDoc.meta = new Map( Object.entries( documentMeta ) ); + it( 'sets document meta from provided metadata', () => { + const documentMeta = { + objectType: 'post', + objectId: 123, + author: 'test-user', + }; - createYjsDoc( documentMeta ); + ydoc = createYjsDoc( documentMeta ); - expect( mockYDoc.meta?.get( 'objectType' ) ).toBe( 'post' ); - expect( mockYDoc.meta?.get( 'objectId' ) ).toBe( 123 ); - expect( mockYDoc.meta?.get( 'author' ) ).toBe( 'test-user' ); - } ); + expect( ydoc.meta?.get( 'objectType' ) ).toBe( 'post' ); + expect( ydoc.meta?.get( 'objectId' ) ).toBe( 123 ); + expect( ydoc.meta?.get( 'author' ) ).toBe( 'test-user' ); } ); } );