Skip to content

Commit 47fbb07

Browse files
committed
Add comprehensive tests for collaboration cursor fixes
Add test coverage for Issue #201 fixes: - Provider lifecycle: verify provider NOT disconnected on re-render with same sessionId - Provider cleanup: verify provider IS disconnected when sessionId changes - Extension creation: verify extensions created only once despite multiple synced events - Awareness API: verify high-level setAwarenessField() is used for presence - Awareness state: verify presence state updates on awarenessChange events - Disconnect handling: verify awareness set to disconnected status on disconnect event
1 parent c74e408 commit 47fbb07

File tree

1 file changed

+261
-1
lines changed

1 file changed

+261
-1
lines changed

__tests__/components/ui/coaching-sessions/editor-cache-context.test.tsx

Lines changed: 261 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ describe('EditorCacheProvider', () => {
169169
// THE CRITICAL TEST: Logout cleanup
170170
it('should destroy TipTap provider when user logs out', async () => {
171171
let cacheRef: any = null
172-
172+
173173
// Start with logged in user
174174
render(
175175
<EditorCacheProvider sessionId="test-session">
@@ -198,4 +198,264 @@ describe('EditorCacheProvider', () => {
198198
// After reset, the cache should be in loading state (not ready)
199199
expect(screen.getByTestId('is-ready')).toHaveTextContent('no')
200200
})
201+
202+
describe('Provider Lifecycle (Issue #201 Fixes)', () => {
203+
it('should NOT disconnect provider when re-rendering with same sessionId', async () => {
204+
const { TiptapCollabProvider } = await import('@hocuspocus/provider')
205+
206+
const { rerender } = render(
207+
<EditorCacheProvider sessionId="test-session">
208+
<TestConsumer />
209+
</EditorCacheProvider>
210+
)
211+
212+
await waitFor(() => {
213+
expect(screen.getByTestId('is-ready')).toHaveTextContent('yes')
214+
})
215+
216+
// Get the mock instance and clear previous calls
217+
const mockProvider = vi.mocked(TiptapCollabProvider).mock.results[0]?.value
218+
vi.clearAllMocks()
219+
220+
// Re-render the same component (simulates user clicking in editor)
221+
rerender(
222+
<EditorCacheProvider sessionId="test-session">
223+
<TestConsumer />
224+
</EditorCacheProvider>
225+
)
226+
227+
// Wait a bit to ensure no cleanup happens
228+
await new Promise(resolve => setTimeout(resolve, 50))
229+
230+
// CRITICAL: Provider should NOT be disconnected
231+
expect(mockProvider?.disconnect).not.toHaveBeenCalled()
232+
233+
// CRITICAL: Provider should NOT be recreated
234+
expect(TiptapCollabProvider).not.toHaveBeenCalled()
235+
})
236+
237+
it('should disconnect old provider when sessionId changes', async () => {
238+
const { TiptapCollabProvider } = await import('@hocuspocus/provider')
239+
240+
const { rerender } = render(
241+
<EditorCacheProvider sessionId="session-1">
242+
<TestConsumer />
243+
</EditorCacheProvider>
244+
)
245+
246+
await waitFor(() => {
247+
expect(screen.getByTestId('is-ready')).toHaveTextContent('yes')
248+
})
249+
250+
const oldProvider = vi.mocked(TiptapCollabProvider).mock.results[0]?.value
251+
252+
// Change session ID
253+
rerender(
254+
<EditorCacheProvider sessionId="session-2">
255+
<TestConsumer />
256+
</EditorCacheProvider>
257+
)
258+
259+
await waitFor(() => {
260+
// Old provider should be disconnected
261+
expect(oldProvider?.disconnect).toHaveBeenCalled()
262+
})
263+
264+
// New provider should be created
265+
expect(TiptapCollabProvider).toHaveBeenCalledTimes(2)
266+
})
267+
})
268+
269+
describe('Extension Creation', () => {
270+
it('should create extensions only once even if synced event fires multiple times', async () => {
271+
const { Extensions } = await import('@/components/ui/coaching-sessions/coaching-notes/extensions')
272+
273+
// Create a mock provider that we can control
274+
const { TiptapCollabProvider } = await import('@hocuspocus/provider')
275+
let syncedCallback: (() => void) | undefined
276+
277+
vi.mocked(TiptapCollabProvider).mockImplementationOnce(function() {
278+
const provider = {
279+
on: vi.fn((event, callback) => {
280+
if (event === 'synced') {
281+
syncedCallback = callback
282+
}
283+
return provider
284+
}),
285+
off: vi.fn(),
286+
setAwarenessField: vi.fn(),
287+
destroy: vi.fn(),
288+
disconnect: vi.fn(),
289+
connect: vi.fn()
290+
}
291+
return provider as any
292+
})
293+
294+
render(
295+
<EditorCacheProvider sessionId="test-session">
296+
<TestConsumer />
297+
</EditorCacheProvider>
298+
)
299+
300+
// Wait for provider initialization
301+
await waitFor(() => {
302+
expect(syncedCallback).toBeDefined()
303+
})
304+
305+
// Trigger synced event multiple times
306+
act(() => {
307+
syncedCallback!()
308+
syncedCallback!()
309+
syncedCallback!()
310+
})
311+
312+
// Extensions should only be created once
313+
expect(Extensions).toHaveBeenCalledTimes(1)
314+
})
315+
})
316+
317+
describe('Awareness State Management', () => {
318+
it('should use setAwarenessField for setting user presence', async () => {
319+
const { TiptapCollabProvider } = await import('@hocuspocus/provider')
320+
321+
render(
322+
<EditorCacheProvider sessionId="test-session">
323+
<TestConsumer />
324+
</EditorCacheProvider>
325+
)
326+
327+
await waitFor(() => {
328+
expect(screen.getByTestId('is-ready')).toHaveTextContent('yes')
329+
})
330+
331+
const mockProvider = vi.mocked(TiptapCollabProvider).mock.results[0]?.value
332+
333+
// Should have called setAwarenessField with presence data
334+
expect(mockProvider?.setAwarenessField).toHaveBeenCalledWith(
335+
'presence',
336+
expect.objectContaining({
337+
userId: 'user-1',
338+
name: 'Test User',
339+
status: 'connected'
340+
})
341+
)
342+
})
343+
344+
it('should update presence state when awarenessChange event fires', async () => {
345+
const { TiptapCollabProvider } = await import('@hocuspocus/provider')
346+
let awarenessCallback: ((data: any) => void) | undefined
347+
348+
vi.mocked(TiptapCollabProvider).mockImplementationOnce(function() {
349+
const provider = {
350+
on: vi.fn((event, callback) => {
351+
if (event === 'awarenessChange') {
352+
awarenessCallback = callback
353+
}
354+
return provider
355+
}),
356+
off: vi.fn(),
357+
setAwarenessField: vi.fn(),
358+
destroy: vi.fn(),
359+
disconnect: vi.fn(),
360+
connect: vi.fn()
361+
}
362+
return provider as any
363+
})
364+
365+
let cacheRef: any = null
366+
367+
render(
368+
<EditorCacheProvider sessionId="test-session">
369+
<TestConsumer onCacheReady={(cache) => { cacheRef = cache }} />
370+
</EditorCacheProvider>
371+
)
372+
373+
await waitFor(() => {
374+
expect(awarenessCallback).toBeDefined()
375+
})
376+
377+
// Simulate awareness change with user data
378+
act(() => {
379+
awarenessCallback!({
380+
states: [
381+
{
382+
clientId: 1,
383+
presence: {
384+
userId: 'user-1',
385+
name: 'Test User',
386+
relationshipRole: 'Coach',
387+
color: '#ff0000',
388+
status: 'connected'
389+
}
390+
},
391+
{
392+
clientId: 2,
393+
presence: {
394+
userId: 'user-2',
395+
name: 'Other User',
396+
relationshipRole: 'Coachee',
397+
color: '#00ff00',
398+
status: 'connected'
399+
}
400+
}
401+
]
402+
})
403+
})
404+
405+
// Presence state should be updated
406+
expect(cacheRef?.presenceState.users.size).toBe(2)
407+
expect(cacheRef?.presenceState.users.get('user-1')).toMatchObject({
408+
userId: 'user-1',
409+
name: 'Test User'
410+
})
411+
})
412+
413+
it('should set disconnected status on disconnect event', async () => {
414+
const { TiptapCollabProvider } = await import('@hocuspocus/provider')
415+
let disconnectCallback: (() => void) | undefined
416+
417+
vi.mocked(TiptapCollabProvider).mockImplementationOnce(function() {
418+
const provider = {
419+
on: vi.fn((event, callback) => {
420+
if (event === 'disconnect') {
421+
disconnectCallback = callback
422+
}
423+
return provider
424+
}),
425+
off: vi.fn(),
426+
setAwarenessField: vi.fn(),
427+
destroy: vi.fn(),
428+
disconnect: vi.fn(),
429+
connect: vi.fn()
430+
}
431+
return provider as any
432+
})
433+
434+
render(
435+
<EditorCacheProvider sessionId="test-session">
436+
<TestConsumer />
437+
</EditorCacheProvider>
438+
)
439+
440+
await waitFor(() => {
441+
expect(disconnectCallback).toBeDefined()
442+
})
443+
444+
const mockProvider = vi.mocked(TiptapCollabProvider).mock.results[0]?.value
445+
vi.clearAllMocks()
446+
447+
// Trigger disconnect event
448+
act(() => {
449+
disconnectCallback!()
450+
})
451+
452+
// Should update awareness with disconnected status
453+
expect(mockProvider?.setAwarenessField).toHaveBeenCalledWith(
454+
'presence',
455+
expect.objectContaining({
456+
status: 'disconnected'
457+
})
458+
)
459+
})
460+
})
201461
})

0 commit comments

Comments
 (0)