@wordpress/core-data
Version:
Access to and manipulation of core WordPress entities.
932 lines (757 loc) • 23.6 kB
text/typescript
import { act, renderHook, waitFor } from '@testing-library/react';
import {
useActiveCollaborators,
useResolvedSelection,
useGetDebugData,
useIsDisconnected,
useOnCollaboratorJoin,
useOnCollaboratorLeave,
useOnPostSave,
} from '../use-post-editor-awareness-state';
import { getSyncManager } from '../../sync';
import { SelectionType } from '../../utils/crdt-user-selections';
import type {
PostEditorAwarenessState,
YDocDebugData,
} from '../../awareness/types';
import type { SelectionCursor } from '../../types';
// Mock the sync module
jest.mock( '../../sync', () => ( {
getSyncManager: jest.fn(),
} ) );
const mockPostContentBlocks = [
{ clientId: 'block-1', name: 'core/paragraph', innerBlocks: [] },
];
jest.mock( '../../awareness/block-lookup', () => ( {
usePostContentBlocks: jest.fn( () => mockPostContentBlocks ),
} ) );
const mockAvatarUrls = {
'24': 'https://example.com/avatar-24.png',
'48': 'https://example.com/avatar-48.png',
'96': 'https://example.com/avatar-96.png',
};
const createMockActiveUser = (
overrides: Partial< PostEditorAwarenessState > = {}
): PostEditorAwarenessState => ( {
clientId: 12345,
isMe: false,
isConnected: true,
collaboratorInfo: {
id: 1,
name: 'Test User',
slug: 'test-user',
avatar_urls: mockAvatarUrls,
browserType: 'Chrome',
enteredAt: 1704067200000,
},
editorState: {
selection: {
type: SelectionType.None,
},
},
...overrides,
} );
const createMockDebugData = (): YDocDebugData => ( {
doc: { testKey: 'testValue' },
clients: {},
collaboratorMap: {},
} );
describe( 'use-post-editor-awareness-state hooks', () => {
let mockAwareness: {
setUp: jest.Mock;
getCurrentState: jest.Mock;
onStateChange: jest.Mock;
convertSelectionStateToAbsolute: jest.Mock;
getDebugData: jest.Mock;
doc: {
getMap: jest.Mock;
};
};
let mockSyncManager: {
getAwareness: jest.Mock;
};
let stateChangeCallback:
| ( ( newState: PostEditorAwarenessState[] ) => void )
| null;
let stateMapObserver:
| ( ( event: { keysChanged: Set< string > } ) => void )
| null;
let mockStateMapData: Record< string, unknown >;
let mockRecordMapData: Record< string, unknown >;
beforeEach( () => {
stateChangeCallback = null;
stateMapObserver = null;
mockStateMapData = {};
mockRecordMapData = {};
const mockStateMap = {
get: jest.fn( ( key: string ) => mockStateMapData[ key ] ),
observe: jest.fn( ( observer: typeof stateMapObserver ) => {
stateMapObserver = observer;
} ),
unobserve: jest.fn(),
};
const mockRecordMap = {
get: jest.fn( ( key: string ) => mockRecordMapData[ key ] ),
};
mockAwareness = {
setUp: jest.fn(),
getCurrentState: jest.fn().mockReturnValue( [] ),
onStateChange: jest.fn( ( callback ) => {
stateChangeCallback = callback;
return jest.fn(); // unsubscribe function
} ),
convertSelectionStateToAbsolute: jest.fn().mockReturnValue( null ),
getDebugData: jest.fn().mockReturnValue( createMockDebugData() ),
doc: {
getMap: jest.fn( ( name: string ) => {
if ( name === 'state' ) {
return mockStateMap;
}
if ( name === 'document' ) {
return mockRecordMap;
}
return null;
} ),
},
};
mockSyncManager = {
getAwareness: jest.fn().mockReturnValue( mockAwareness ),
};
( getSyncManager as jest.Mock ).mockReturnValue( mockSyncManager );
} );
afterEach( () => {
jest.clearAllMocks();
} );
describe( 'useActiveUsers', () => {
test( 'should return empty array when postId is null', () => {
const { result } = renderHook( () =>
useActiveCollaborators( null, 'post' )
);
expect( result.current ).toEqual( [] );
expect( mockSyncManager.getAwareness ).not.toHaveBeenCalled();
} );
test( 'should return empty array when postType is null', () => {
const { result } = renderHook( () =>
useActiveCollaborators( 123, null )
);
expect( result.current ).toEqual( [] );
expect( mockSyncManager.getAwareness ).not.toHaveBeenCalled();
} );
test( 'should return empty array when getSyncManager returns undefined', () => {
( getSyncManager as jest.Mock ).mockReturnValue( undefined );
const { result } = renderHook( () =>
useActiveCollaborators( 123, 'post' )
);
expect( result.current ).toEqual( [] );
} );
test( 'should return empty array when awareness is not available', () => {
mockSyncManager.getAwareness.mockReturnValue( undefined );
const { result } = renderHook( () =>
useActiveCollaborators( 123, 'post' )
);
expect( result.current ).toEqual( [] );
} );
test( 'should call getAwareness with correct parameters', () => {
renderHook( () => useActiveCollaborators( 123, 'post' ) );
expect( mockSyncManager.getAwareness ).toHaveBeenCalledWith(
'postType/post',
'123'
);
} );
test( 'should call awareness.setUp', () => {
renderHook( () => useActiveCollaborators( 123, 'post' ) );
expect( mockAwareness.setUp ).toHaveBeenCalled();
} );
test( 'should return initial state from getCurrentState', () => {
const mockUsers = [ createMockActiveUser() ];
mockAwareness.getCurrentState.mockReturnValue( mockUsers );
const { result } = renderHook( () =>
useActiveCollaborators( 123, 'post' )
);
expect( result.current ).toEqual( mockUsers );
} );
test( 'should subscribe to state changes', () => {
renderHook( () => useActiveCollaborators( 123, 'post' ) );
expect( mockAwareness.onStateChange ).toHaveBeenCalled();
} );
test( 'should update state when awareness emits changes', async () => {
const initialUsers: PostEditorAwarenessState[] = [];
const updatedUsers = [ createMockActiveUser() ];
mockAwareness.getCurrentState.mockReturnValue( initialUsers );
const { result } = renderHook( () =>
useActiveCollaborators( 123, 'post' )
);
expect( result.current ).toEqual( initialUsers );
// Simulate awareness state change
act( () => {
stateChangeCallback?.( updatedUsers );
} );
await waitFor( () => {
expect( result.current ).toEqual( updatedUsers );
} );
} );
test( 'should unsubscribe when postId changes', () => {
const unsubscribe = jest.fn();
mockAwareness.onStateChange.mockReturnValue( unsubscribe );
const { rerender } = renderHook(
( { postId } ) => useActiveCollaborators( postId, 'post' ),
{ initialProps: { postId: 123 as number | null } }
);
expect( unsubscribe ).not.toHaveBeenCalled();
rerender( { postId: 456 } );
expect( unsubscribe ).toHaveBeenCalled();
} );
test( 'should unsubscribe when postType changes', () => {
const unsubscribe = jest.fn();
mockAwareness.onStateChange.mockReturnValue( unsubscribe );
const { rerender } = renderHook(
( { postType } ) => useActiveCollaborators( 123, postType ),
{ initialProps: { postType: 'post' as string | null } }
);
expect( unsubscribe ).not.toHaveBeenCalled();
rerender( { postType: 'page' } );
expect( unsubscribe ).toHaveBeenCalled();
} );
test( 'should reset state when postId becomes null', () => {
const mockUsers = [ createMockActiveUser() ];
mockAwareness.getCurrentState.mockReturnValue( mockUsers );
const { result, rerender } = renderHook(
( { postId } ) => useActiveCollaborators( postId, 'post' ),
{ initialProps: { postId: 123 as number | null } }
);
expect( result.current ).toEqual( mockUsers );
rerender( { postId: null } );
expect( result.current ).toEqual( [] );
} );
} );
describe( 'useResolvedSelection', () => {
test( 'should return function that returns default when postId is null', () => {
const { result } = renderHook( () =>
useResolvedSelection( null, 'post' )
);
const mockSelection: SelectionCursor = {
type: SelectionType.Cursor,
cursorPosition: {
relativePosition: {} as any,
absoluteOffset: 5,
},
};
expect( result.current( mockSelection ) ).toEqual( {
richTextOffset: null,
localClientId: null,
attributeKey: null,
} );
} );
test( 'should call awareness.convertSelectionStateToAbsolute with selection and blocks', () => {
const mockSelection: SelectionCursor = {
type: SelectionType.Cursor,
cursorPosition: {
relativePosition: {} as any,
absoluteOffset: 5,
},
};
mockAwareness.convertSelectionStateToAbsolute.mockReturnValue( {
richTextOffset: 10,
localClientId: 'block-1',
attributeKey: 'content',
} );
const { result } = renderHook( () =>
useResolvedSelection( 123, 'post' )
);
const position = result.current( mockSelection );
expect(
mockAwareness.convertSelectionStateToAbsolute
).toHaveBeenCalledWith( mockSelection, mockPostContentBlocks );
expect( position ).toEqual( {
richTextOffset: 10,
localClientId: 'block-1',
attributeKey: 'content',
} );
} );
} );
describe( 'useGetDebugData', () => {
test( 'should return default debug data when postId is null', () => {
const { result } = renderHook( () =>
useGetDebugData( null, 'post' )
);
expect( result.current ).toEqual( {
doc: {},
clients: {},
collaboratorMap: {},
} );
} );
test( 'should call awareness.getDebugData and return result', () => {
const mockDebugData = createMockDebugData();
mockAwareness.getDebugData.mockReturnValue( mockDebugData );
const { result } = renderHook( () =>
useGetDebugData( 123, 'post' )
);
expect( result.current ).toEqual( mockDebugData );
} );
} );
describe( 'useIsDisconnected', () => {
test( 'should return false when postId is null', () => {
const { result } = renderHook( () =>
useIsDisconnected( null, 'post' )
);
expect( result.current ).toBe( false );
} );
test( 'should return false when current user is connected', () => {
const connectedUser = createMockActiveUser( {
isMe: true,
isConnected: true,
} );
mockAwareness.getCurrentState.mockReturnValue( [ connectedUser ] );
const { result } = renderHook( () =>
useIsDisconnected( 123, 'post' )
);
expect( result.current ).toBe( false );
} );
test( 'should return true when current user is disconnected', () => {
const disconnectedUser = createMockActiveUser( {
isMe: true,
isConnected: false,
} );
mockAwareness.getCurrentState.mockReturnValue( [
disconnectedUser,
] );
const { result } = renderHook( () =>
useIsDisconnected( 123, 'post' )
);
expect( result.current ).toBe( true );
} );
test( 'should return false when no user is marked as me', () => {
const otherUser = createMockActiveUser( {
isMe: false,
isConnected: false,
} );
mockAwareness.getCurrentState.mockReturnValue( [ otherUser ] );
const { result } = renderHook( () =>
useIsDisconnected( 123, 'post' )
);
expect( result.current ).toBe( false );
} );
test( 'should update when state changes to disconnected', async () => {
const connectedUser = createMockActiveUser( {
isMe: true,
isConnected: true,
} );
const disconnectedUser = createMockActiveUser( {
isMe: true,
isConnected: false,
} );
mockAwareness.getCurrentState.mockReturnValue( [ connectedUser ] );
const { result } = renderHook( () =>
useIsDisconnected( 123, 'post' )
);
expect( result.current ).toBe( false );
// Simulate disconnection
act( () => {
stateChangeCallback?.( [ disconnectedUser ] );
} );
await waitFor( () => {
expect( result.current ).toBe( true );
} );
} );
} );
describe( 'hook cleanup', () => {
test( 'should unsubscribe on unmount', () => {
const unsubscribe = jest.fn();
mockAwareness.onStateChange.mockReturnValue( unsubscribe );
const { unmount } = renderHook( () =>
useActiveCollaborators( 123, 'post' )
);
expect( unsubscribe ).not.toHaveBeenCalled();
unmount();
expect( unsubscribe ).toHaveBeenCalled();
} );
} );
describe( 'multiple users scenario', () => {
test( 'should handle multiple active users', () => {
const user1 = createMockActiveUser( {
clientId: 1,
isMe: true,
collaboratorInfo: {
id: 1,
name: 'User One',
slug: 'user-one',
avatar_urls: mockAvatarUrls,
browserType: 'Chrome',
enteredAt: 1704067200000,
},
} );
const user2 = createMockActiveUser( {
clientId: 2,
isMe: false,
collaboratorInfo: {
id: 2,
name: 'User Two',
slug: 'user-two',
avatar_urls: mockAvatarUrls,
browserType: 'Firefox',
enteredAt: 1704067300000,
},
} );
mockAwareness.getCurrentState.mockReturnValue( [ user1, user2 ] );
const { result } = renderHook( () =>
useActiveCollaborators( 123, 'post' )
);
expect( result.current ).toHaveLength( 2 );
expect( result.current[ 0 ].collaboratorInfo.name ).toBe(
'User One'
);
expect( result.current[ 1 ].collaboratorInfo.name ).toBe(
'User Two'
);
} );
test( 'should identify correct user as disconnected among multiple', () => {
const meConnected = createMockActiveUser( {
clientId: 1,
isMe: true,
isConnected: true,
} );
const otherDisconnected = createMockActiveUser( {
clientId: 2,
isMe: false,
isConnected: false,
} );
mockAwareness.getCurrentState.mockReturnValue( [
meConnected,
otherDisconnected,
] );
const { result } = renderHook( () =>
useIsDisconnected( 123, 'post' )
);
// Should be false because *I* am connected (other user's status doesn't matter)
expect( result.current ).toBe( false );
} );
} );
describe( 'useOnCollaboratorJoin', () => {
const me = createMockActiveUser( {
clientId: 1,
isMe: true,
collaboratorInfo: {
id: 1,
name: 'Me',
slug: 'me',
avatar_urls: mockAvatarUrls,
browserType: 'Chrome',
enteredAt: 1704067200000,
},
} );
const alice = createMockActiveUser( {
clientId: 2,
isMe: false,
collaboratorInfo: {
id: 100,
name: 'Alice',
slug: 'alice',
avatar_urls: mockAvatarUrls,
browserType: 'Chrome',
enteredAt: 1704067300000,
},
} );
test( 'should not fire on initial mount', () => {
const callback = jest.fn();
mockAwareness.getCurrentState.mockReturnValue( [ me, alice ] );
renderHook( () => useOnCollaboratorJoin( 123, 'post', callback ) );
expect( callback ).not.toHaveBeenCalled();
} );
test( 'should not fire when collaborators load after initially empty state', async () => {
const callback = jest.fn();
mockAwareness.getCurrentState.mockReturnValue( [] );
renderHook( () => useOnCollaboratorJoin( 123, 'post', callback ) );
// Simulate store hydration: collaborators appear
act( () => {
stateChangeCallback?.( [ me, alice ] );
} );
await waitFor( () => {
expect( callback ).not.toHaveBeenCalled();
} );
} );
test( 'should fire callback when a new collaborator joins', async () => {
const callback = jest.fn();
mockAwareness.getCurrentState.mockReturnValue( [ me ] );
renderHook( () => useOnCollaboratorJoin( 123, 'post', callback ) );
// Alice joins
act( () => {
stateChangeCallback?.( [ me, alice ] );
} );
await waitFor( () => {
expect( callback ).toHaveBeenCalledWith( alice, me );
} );
} );
test( 'should not fire callback for the current user', async () => {
const callback = jest.fn();
mockAwareness.getCurrentState.mockReturnValue( [] );
renderHook( () => useOnCollaboratorJoin( 123, 'post', callback ) );
// First: hydrate with alice so prevCollaborators is non-empty
act( () => {
stateChangeCallback?.( [ alice ] );
} );
// Now "me" appears
act( () => {
stateChangeCallback?.( [ alice, me ] );
} );
await waitFor( () => {
expect( callback ).not.toHaveBeenCalled();
} );
} );
test( 'should not fire when postId is null', () => {
const callback = jest.fn();
renderHook( () => useOnCollaboratorJoin( null, 'post', callback ) );
expect( callback ).not.toHaveBeenCalled();
expect( mockSyncManager.getAwareness ).not.toHaveBeenCalled();
} );
} );
describe( 'useOnCollaboratorLeave', () => {
const me = createMockActiveUser( {
clientId: 1,
isMe: true,
} );
const alice = createMockActiveUser( {
clientId: 2,
isMe: false,
isConnected: true,
collaboratorInfo: {
id: 100,
name: 'Alice',
slug: 'alice',
avatar_urls: mockAvatarUrls,
browserType: 'Chrome',
enteredAt: 1704067300000,
},
} );
test( 'should fire callback when a connected collaborator disconnects', async () => {
const callback = jest.fn();
mockAwareness.getCurrentState.mockReturnValue( [ me, alice ] );
renderHook( () => useOnCollaboratorLeave( 123, 'post', callback ) );
// Alice disconnects
act( () => {
stateChangeCallback?.( [
me,
{ ...alice, isConnected: false },
] );
} );
await waitFor( () => {
expect( callback ).toHaveBeenCalledWith( alice );
} );
} );
test( 'should fire callback when a connected collaborator disappears from the list', async () => {
const callback = jest.fn();
mockAwareness.getCurrentState.mockReturnValue( [ me, alice ] );
renderHook( () => useOnCollaboratorLeave( 123, 'post', callback ) );
// Alice disappears entirely
act( () => {
stateChangeCallback?.( [ me ] );
} );
await waitFor( () => {
expect( callback ).toHaveBeenCalledWith( alice );
} );
} );
test( 'should not fire callback when an already-disconnected collaborator is removed', async () => {
const callback = jest.fn();
const disconnectedAlice = { ...alice, isConnected: false };
mockAwareness.getCurrentState.mockReturnValue( [
me,
disconnectedAlice,
] );
renderHook( () => useOnCollaboratorLeave( 123, 'post', callback ) );
// Disconnected Alice is removed from list (cleanup after delay)
act( () => {
stateChangeCallback?.( [ me ] );
} );
await waitFor( () => {
expect( callback ).not.toHaveBeenCalled();
} );
} );
test( 'should not fire callback for the current user disconnecting', async () => {
const callback = jest.fn();
mockAwareness.getCurrentState.mockReturnValue( [ me, alice ] );
renderHook( () => useOnCollaboratorLeave( 123, 'post', callback ) );
// "Me" disconnects
act( () => {
stateChangeCallback?.( [
{ ...me, isConnected: false },
alice,
] );
} );
await waitFor( () => {
expect( callback ).not.toHaveBeenCalled();
} );
} );
test( 'should not fire on initial mount', () => {
const callback = jest.fn();
mockAwareness.getCurrentState.mockReturnValue( [ me, alice ] );
renderHook( () => useOnCollaboratorLeave( 123, 'post', callback ) );
expect( callback ).not.toHaveBeenCalled();
} );
} );
describe( 'useOnPostSave', () => {
const me = createMockActiveUser( {
clientId: 1,
isMe: true,
} );
const alice = createMockActiveUser( {
clientId: 2,
isMe: false,
collaboratorInfo: {
id: 100,
name: 'Alice',
slug: 'alice',
avatar_urls: mockAvatarUrls,
browserType: 'Chrome',
enteredAt: 1704067300000,
},
} );
test( 'should fire callback when a remote collaborator saves', async () => {
const callback = jest.fn();
mockAwareness.getCurrentState.mockReturnValue( [ me, alice ] );
renderHook( () => useOnPostSave( 123, 'post', callback ) );
// Simulate a save event via the Y.Doc state map
const savedAt = Date.now() + 1000;
mockStateMapData = {
savedAt,
savedBy: alice.clientId,
};
mockRecordMapData = { status: 'draft' };
act( () => {
stateMapObserver?.( {
keysChanged: new Set( [ 'savedAt' ] ),
} );
} );
await waitFor( () => {
expect( callback ).toHaveBeenCalledWith(
{
savedAt,
savedByClientId: alice.clientId,
postStatus: 'draft',
},
alice,
null
);
} );
} );
test( 'should pass previous save event on subsequent saves', async () => {
const callback = jest.fn();
mockAwareness.getCurrentState.mockReturnValue( [ me, alice ] );
renderHook( () => useOnPostSave( 123, 'post', callback ) );
// First save
const firstSavedAt = Date.now() + 1000;
mockStateMapData = {
savedAt: firstSavedAt,
savedBy: alice.clientId,
};
mockRecordMapData = { status: 'draft' };
act( () => {
stateMapObserver?.( {
keysChanged: new Set( [ 'savedAt' ] ),
} );
} );
await waitFor( () => {
expect( callback ).toHaveBeenCalledTimes( 1 );
} );
// Second save
const secondSavedAt = Date.now() + 2000;
mockStateMapData = {
savedAt: secondSavedAt,
savedBy: alice.clientId,
};
mockRecordMapData = { status: 'publish' };
act( () => {
stateMapObserver?.( {
keysChanged: new Set( [ 'savedAt' ] ),
} );
} );
await waitFor( () => {
expect( callback ).toHaveBeenCalledTimes( 2 );
} );
expect( callback ).toHaveBeenLastCalledWith(
{
savedAt: secondSavedAt,
savedByClientId: alice.clientId,
postStatus: 'publish',
},
alice,
{
savedAt: firstSavedAt,
savedByClientId: alice.clientId,
postStatus: 'draft',
}
);
} );
test( 'should not fire callback when the current user saves', async () => {
const callback = jest.fn();
mockAwareness.getCurrentState.mockReturnValue( [ me, alice ] );
renderHook( () => useOnPostSave( 123, 'post', callback ) );
// Simulate a save event by "me"
const savedAt = Date.now() + 1000;
mockStateMapData = {
savedAt,
savedBy: me.clientId,
};
mockRecordMapData = { status: 'draft' };
act( () => {
stateMapObserver?.( {
keysChanged: new Set( [ 'savedAt' ] ),
} );
} );
await waitFor( () => {
expect( callback ).not.toHaveBeenCalled();
} );
} );
test( 'should not fire callback when saver is not in the collaborator list', async () => {
const callback = jest.fn();
mockAwareness.getCurrentState.mockReturnValue( [ me ] );
renderHook( () => useOnPostSave( 123, 'post', callback ) );
// Simulate a save from an unknown client
const savedAt = Date.now() + 1000;
mockStateMapData = {
savedAt,
savedBy: 99999,
};
mockRecordMapData = { status: 'draft' };
act( () => {
stateMapObserver?.( {
keysChanged: new Set( [ 'savedAt' ] ),
} );
} );
await waitFor( () => {
expect( callback ).not.toHaveBeenCalled();
} );
} );
test( 'should not fire duplicate callbacks for the same savedAt timestamp', async () => {
const callback = jest.fn();
mockAwareness.getCurrentState.mockReturnValue( [ me, alice ] );
renderHook( () => useOnPostSave( 123, 'post', callback ) );
const savedAt = Date.now() + 1000;
mockStateMapData = {
savedAt,
savedBy: alice.clientId,
};
mockRecordMapData = { status: 'draft' };
// First save event
act( () => {
stateMapObserver?.( {
keysChanged: new Set( [ 'savedAt' ] ),
} );
} );
await waitFor( () => {
expect( callback ).toHaveBeenCalledTimes( 1 );
} );
// Same savedAt again (e.g. component re-render)
act( () => {
stateMapObserver?.( {
keysChanged: new Set( [ 'savedAt' ] ),
} );
} );
// Should still be 1 call
expect( callback ).toHaveBeenCalledTimes( 1 );
} );
test( 'should not fire when postId is null', () => {
const callback = jest.fn();
renderHook( () => useOnPostSave( null, 'post', callback ) );
expect( callback ).not.toHaveBeenCalled();
} );
} );
} );