1 import { EditorController } from './EditorController'
2 import type { Logger } from '@proton/utils/logs'
3 import { DocumentRole } from '@proton/docs-shared'
4 import type { ConnectionCloseReason } from '@proton/docs-proto'
5 import { EventType, EventTypeEnum } from '@proton/docs-proto'
6 import metrics from '@proton/metrics'
7 import type { DocumentStateValues } from '../State/DocumentState'
8 import { DocumentState } from '../State/DocumentState'
9 import type { ClientRequiresEditorMethods, DecryptedMessage } from '@proton/docs-shared'
10 import type { SerializedEditorState } from 'lexical'
11 import type { DecryptedCommit } from '../Models/DecryptedCommit'
13 jest.mock('@proton/metrics', () => ({
14 docs_readonly_mode_documents_total: {
19 describe('EditorController', () => {
20 let controller: EditorController
22 let exportAndDownload: any
23 let sharedState: DocumentState
24 let editorInvoker: jest.Mocked<ClientRequiresEditorMethods>
32 } as unknown as Logger
38 sharedState = new DocumentState({
39 ...DocumentState.defaults,
40 userRole: new DocumentRole('Editor'),
41 } as DocumentStateValues)
44 receiveMessage: jest.fn(),
45 showEditor: jest.fn(),
46 performOpeningCeremony: jest.fn(),
47 performClosingCeremony: jest.fn(),
48 changeLockedState: jest.fn(),
49 broadcastPresenceState: jest.fn(),
50 toggleDebugTreeView: jest.fn(),
51 printAsPDF: jest.fn(),
52 getCurrentEditorState: jest.fn(),
53 showCommentsPanel: jest.fn(),
54 exportData: jest.fn(),
55 getDocumentState: jest.fn(),
56 getClientId: jest.fn(),
57 replaceEditorState: jest.fn(),
58 } as unknown as jest.Mocked<ClientRequiresEditorMethods>
60 controller = new EditorController(logger, exportAndDownload, sharedState)
63 describe('receiveEditor', () => {
64 it('should initialize editor invoker and set editor ready state', () => {
65 controller.receiveEditor(editorInvoker)
66 expect(sharedState.getProperty('editorReady')).toBe(true)
67 expect(logger.info).toHaveBeenCalledWith('Editor is ready to receive invocations')
71 describe('showEditorForTheFirstTime', () => {
73 controller.receiveEditor(editorInvoker)
76 it('should throw error if editor invoker is not initialized', () => {
77 const controller = new EditorController(logger, exportAndDownload, sharedState)
78 expect(() => controller.showEditorForTheFirstTime()).toThrow('Editor invoker not initialized')
81 it('should show editor when realtime is disabled', () => {
82 sharedState.setProperty('realtimeEnabled', false)
83 sharedState.setProperty('realtimeReadyToBroadcast', false)
84 sharedState.setProperty('realtimeConnectionTimedOut', false)
86 controller.showEditorForTheFirstTime()
88 expect(editorInvoker.showEditor).toHaveBeenCalled()
89 expect(editorInvoker.performOpeningCeremony).toHaveBeenCalled()
92 it('should show editor when realtime is ready to broadcast', () => {
93 sharedState.setProperty('realtimeEnabled', true)
94 sharedState.setProperty('realtimeReadyToBroadcast', true)
95 sharedState.setProperty('realtimeConnectionTimedOut', false)
97 controller.showEditorForTheFirstTime()
99 expect(editorInvoker.showEditor).toHaveBeenCalled()
100 expect(editorInvoker.performOpeningCeremony).toHaveBeenCalled()
103 it('should show editor when realtime connection has timed out', () => {
104 sharedState.setProperty('realtimeEnabled', true)
105 sharedState.setProperty('realtimeReadyToBroadcast', false)
106 sharedState.setProperty('realtimeConnectionTimedOut', true)
108 controller.showEditorForTheFirstTime()
110 expect(editorInvoker.showEditor).toHaveBeenCalled()
111 expect(editorInvoker.performOpeningCeremony).toHaveBeenCalled()
114 it('should not show editor when realtime is enabled but not ready and not timed out', () => {
115 sharedState.setProperty('realtimeEnabled', true)
116 sharedState.setProperty('realtimeReadyToBroadcast', false)
117 sharedState.setProperty('realtimeConnectionTimedOut', false)
119 controller.showEditorForTheFirstTime()
121 expect(editorInvoker.showEditor).not.toHaveBeenCalled()
122 expect(editorInvoker.performOpeningCeremony).not.toHaveBeenCalled()
125 it('should emit EditorIsReadyToBeShown event when editor is shown', () => {
126 sharedState.setProperty('realtimeEnabled', false)
127 sharedState.setProperty('realtimeReadyToBroadcast', false)
128 sharedState.setProperty('realtimeConnectionTimedOut', false)
129 sharedState.emitEvent = jest.fn()
131 controller.showEditorForTheFirstTime()
133 expect(sharedState.emitEvent).toHaveBeenCalledWith({
134 name: 'EditorIsReadyToBeShown',
139 it('should reload editing locked state when editor is shown', () => {
140 sharedState.setProperty('realtimeEnabled', true)
141 sharedState.setProperty('realtimeReadyToBroadcast', true)
143 controller.reloadEditingLockedState = jest.fn()
145 controller.showEditorForTheFirstTime()
147 expect(controller.reloadEditingLockedState).toHaveBeenCalled()
151 describe('reloadEditingLockedState', () => {
153 controller.receiveEditor(editorInvoker)
156 it('should do nothing if editor invoker is not initialized', () => {
157 const controller = new EditorController(logger, exportAndDownload, sharedState)
158 controller.reloadEditingLockedState()
159 expect(editorInvoker.changeLockedState).not.toHaveBeenCalled()
162 it('should lock editor when participant limit is reached and user is not admin', () => {
163 sharedState.setProperty('realtimeIsParticipantLimitReached', true)
164 sharedState.setProperty('userRole', new DocumentRole('Editor'))
166 controller.reloadEditingLockedState()
168 expect(editorInvoker.changeLockedState).toHaveBeenCalledWith(true)
169 expect(metrics.docs_readonly_mode_documents_total.increment).toHaveBeenCalledWith({
170 reason: 'user_limit_reached',
174 it('should lock editor when user lacks edit permissions', () => {
175 sharedState.setProperty('userRole', new DocumentRole('Viewer'))
177 controller.reloadEditingLockedState()
179 expect(editorInvoker.changeLockedState).toHaveBeenCalledWith(true)
180 expect(metrics.docs_readonly_mode_documents_total.increment).toHaveBeenCalledWith({
181 reason: 'no_editing_permissions',
185 it('should lock editor when experiencing errored sync', () => {
186 sharedState.setProperty('userRole', new DocumentRole('Editor'))
187 sharedState.setProperty('realtimeIsExperiencingErroredSync', true)
189 controller.reloadEditingLockedState()
191 expect(editorInvoker.changeLockedState).toHaveBeenCalledWith(true)
192 expect(metrics.docs_readonly_mode_documents_total.increment).toHaveBeenCalledWith({
193 reason: 'errored_sync',
197 it('should lock editor when size constraint is reached', () => {
198 sharedState.setProperty('userRole', new DocumentRole('Editor'))
199 sharedState.setProperty('realtimeIsLockedDueToSizeContraint', true)
201 controller.reloadEditingLockedState()
203 expect(editorInvoker.changeLockedState).toHaveBeenCalledWith(true)
204 expect(metrics.docs_readonly_mode_documents_total.increment).toHaveBeenCalledWith({
205 reason: 'size_limit',
209 it('should lock editor when not connected', () => {
210 sharedState.setProperty('userRole', new DocumentRole('Editor'))
211 sharedState.setProperty('realtimeStatus', 'disconnected')
213 controller.reloadEditingLockedState()
215 expect(editorInvoker.changeLockedState).toHaveBeenCalledWith(true)
216 expect(metrics.docs_readonly_mode_documents_total.increment).toHaveBeenCalledWith({
217 reason: 'not_connected',
221 it('should unlock editor when all conditions are met', () => {
222 sharedState.setProperty('userRole', new DocumentRole('Editor'))
223 sharedState.setProperty('realtimeStatus', 'connected')
224 sharedState.setProperty('documentTrashState', 'not_trashed')
225 sharedState.setProperty('editorHasRenderingIssue', false)
226 sharedState.setProperty('realtimeIsLockedDueToSizeContraint', false)
227 sharedState.setProperty('realtimeIsExperiencingErroredSync', false)
229 controller.reloadEditingLockedState()
231 expect(editorInvoker.changeLockedState).toHaveBeenCalledWith(false)
234 it('should lock editor when document is in trash', () => {
235 sharedState.setProperty('realtimeStatus', 'connected')
236 controller.receiveEditor(editorInvoker)
237 sharedState.setProperty('userRole', new DocumentRole('Editor'))
238 sharedState.setProperty('documentTrashState', 'trashed')
240 controller.reloadEditingLockedState()
242 expect(logger.info).toHaveBeenCalledWith('Locking editor due to trash state')
243 expect(editorInvoker.changeLockedState).toHaveBeenCalledWith(true)
244 expect(metrics.docs_readonly_mode_documents_total.increment).toHaveBeenCalled()
247 it('should not lock editor when document is not in trash', () => {
248 controller.receiveEditor(editorInvoker)
249 sharedState.setProperty('userRole', new DocumentRole('Editor'))
250 sharedState.setProperty('documentTrashState', 'not_trashed')
251 sharedState.setProperty('realtimeStatus', 'connected')
253 controller.reloadEditingLockedState()
255 expect(logger.info).toHaveBeenCalledWith('Unlocking editor')
256 expect(editorInvoker.changeLockedState).toHaveBeenCalledWith(false)
260 describe('event handling', () => {
262 controller.receiveEditor(editorInvoker)
265 it('should handle realtime document updates', () => {
266 const payload = { content: new Uint8Array([1, 2, 3]) } as DecryptedMessage
267 sharedState.emitEvent({
268 name: 'RealtimeReceivedDocumentUpdate',
272 expect(editorInvoker.receiveMessage).toHaveBeenCalledWith({
273 type: { wrapper: 'du' },
274 content: payload.content,
278 it('should handle client presence state', () => {
279 const payload = new Uint8Array([1, 2, 3])
280 sharedState.emitEvent({
281 name: 'RealtimeReceivedOtherClientPresenceState',
285 expect(editorInvoker.receiveMessage).toHaveBeenCalledWith({
288 eventType: EventType.create(EventTypeEnum.ClientIsBroadcastingItsPresenceState).value,
294 it('should handle connection closed', () => {
295 sharedState.emitEvent({
296 name: 'RealtimeConnectionClosed',
297 payload: {} as ConnectionCloseReason,
300 expect(editorInvoker.performClosingCeremony).toHaveBeenCalled()
304 describe('export and document operations', () => {
306 controller.receiveEditor(editorInvoker)
309 it('should export and download document', async () => {
310 const format = 'docx'
311 const documentName = 'test.docx'
312 const data = new Uint8Array([1, 2, 3])
314 editorInvoker.exportData.mockResolvedValue(data)
315 sharedState.setProperty('documentName', documentName)
317 await controller.exportAndDownload(format)
319 expect(editorInvoker.exportData).toHaveBeenCalledWith(format)
320 expect(exportAndDownload.execute).toHaveBeenCalledWith(data, documentName, format)
323 it('should throw when exporting without editor invoker', async () => {
324 const controller = new EditorController(logger, exportAndDownload, sharedState)
325 await expect(controller.exportAndDownload('docx')).rejects.toThrow()
328 it('should restore revision by replacing', async () => {
329 const lexicalState = { root: {} } as SerializedEditorState
330 await controller.restoreRevisionByReplacing(lexicalState)
331 expect(editorInvoker.replaceEditorState).toHaveBeenCalledWith(lexicalState)
334 it('should get document client id', async () => {
336 editorInvoker.getClientId.mockResolvedValue(clientId)
338 const result = await controller.getDocumentClientId()
339 expect(result).toBe(clientId)
343 describe('subscription handlers', () => {
345 controller.receiveEditor(editorInvoker)
348 it('should show editor when realtimeReadyToBroadcast becomes true', () => {
349 sharedState.setProperty('realtimeReadyToBroadcast', true)
350 expect(editorInvoker.showEditor).toHaveBeenCalled()
351 expect(editorInvoker.performOpeningCeremony).toHaveBeenCalled()
354 it('should send base commit to editor when baseCommit changes', () => {
355 const mockContent = new Uint8Array([1, 2, 3])
356 sharedState.setProperty('baseCommit', {
357 squashedRepresentation: () => mockContent,
358 } as unknown as DecryptedCommit)
360 expect(editorInvoker.receiveMessage).toHaveBeenCalledWith({
361 type: { wrapper: 'du' },
362 content: mockContent,
363 origin: 'InitialLoad',
367 it('should broadcast presence state when requested', () => {
368 sharedState.emitEvent({
369 name: 'RealtimeRequestingClientToBroadcastItsState',
372 expect(editorInvoker.broadcastPresenceState).toHaveBeenCalled()
376 describe('sendBaseCommitToEditor', () => {
377 it('should do nothing if baseCommit is not set', () => {
378 sharedState.setProperty('baseCommit', undefined)
379 controller.sendBaseCommitToEditor()
380 expect(editorInvoker.receiveMessage).not.toHaveBeenCalled()
384 describe('additional editor state methods', () => {
386 controller.receiveEditor(editorInvoker)
389 it('should handle broadcastPresenceState', () => {
390 controller.broadcastPresenceState()
391 expect(editorInvoker.broadcastPresenceState).toHaveBeenCalled()
394 it('should throw when calling broadcastPresenceState without editor', () => {
395 const controller = new EditorController(logger, exportAndDownload, sharedState)
396 expect(() => controller.broadcastPresenceState()).toThrow('Editor invoker not initialized')
399 it('should handle getEditorJSON', async () => {
400 const mockState = { root: {} } as SerializedEditorState
401 editorInvoker.getCurrentEditorState.mockResolvedValue(mockState)
403 const result = await controller.getEditorJSON()
404 expect(result).toEqual(mockState)
405 expect(editorInvoker.getCurrentEditorState).toHaveBeenCalled()
408 it('should handle getDocumentState', async () => {
409 const mockState = new Uint8Array([1, 2, 3])
410 editorInvoker.getDocumentState.mockResolvedValue(mockState)
412 const result = await controller.getDocumentState()
413 expect(result).toEqual(mockState)
417 describe('reloadEditingLockedState additional cases', () => {
419 controller.receiveEditor(editorInvoker)
422 it('should lock editor when editor has rendering issue', () => {
423 sharedState.setProperty('userRole', new DocumentRole('Editor'))
424 sharedState.setProperty('editorHasRenderingIssue', true)
426 controller.reloadEditingLockedState()
428 expect(editorInvoker.changeLockedState).toHaveBeenCalledWith(true)
431 it('should lock editor when document is trashed', () => {
432 sharedState.setProperty('userRole', new DocumentRole('Editor'))
433 sharedState.setProperty('documentTrashState', 'trashed')
435 controller.reloadEditingLockedState()
437 expect(editorInvoker.changeLockedState).toHaveBeenCalledWith(true)
441 describe('metrics', () => {
443 controller.receiveEditor(editorInvoker)
446 it('should increment metrics with unknown reason when no specific condition is met', () => {
447 sharedState.setProperty('userRole', new DocumentRole('Editor'))
448 sharedState.setProperty('realtimeStatus', 'connected')
450 controller.incrementMetricsReadonlyState()
452 expect(metrics.docs_readonly_mode_documents_total.increment).toHaveBeenCalledWith({
457 it('should increment metrics with not_connected reason', () => {
458 sharedState.setProperty('userRole', new DocumentRole('Editor'))
459 sharedState.setProperty('realtimeStatus', 'disconnected')
461 controller.incrementMetricsReadonlyState()
463 expect(metrics.docs_readonly_mode_documents_total.increment).toHaveBeenCalledWith({
464 reason: 'not_connected',
469 describe('showCommentsPanel', () => {
470 it('should do nothing if editor is not initialized', () => {
471 const controller = new EditorController(logger, exportAndDownload, sharedState)
472 controller.showCommentsPanel()
473 expect(editorInvoker.showCommentsPanel).not.toHaveBeenCalled()
476 it('should show comments panel when editor is initialized', () => {
477 controller.receiveEditor(editorInvoker)
478 controller.showCommentsPanel()
479 expect(editorInvoker.showCommentsPanel).toHaveBeenCalled()
483 describe('toggleDebugTreeView', () => {
484 it('should throw error when editor is not initialized', async () => {
485 await expect(controller.toggleDebugTreeView()).rejects.toThrow('Editor invoker not initialized')
488 it('should call editor toggleDebugTreeView when initialized', async () => {
489 controller.receiveEditor(editorInvoker)
490 await controller.toggleDebugTreeView()
491 expect(editorInvoker.toggleDebugTreeView).toHaveBeenCalled()
495 describe('printAsPDF', () => {
496 it('should throw error when editor is not initialized', async () => {
497 await expect(controller.printAsPDF()).rejects.toThrow('Editor invoker not initialized')
500 it('should call editor printAsPDF when initialized', async () => {
501 controller.receiveEditor(editorInvoker)
502 await controller.printAsPDF()
503 expect(editorInvoker.printAsPDF).toHaveBeenCalled()
507 describe('changeLockedState', () => {
508 it('should throw error when editor is not initialized', () => {
509 expect(() => controller.changeLockedState(true)).toThrow('Editor invoker not initialized')
512 it('should call editor changeLockedState when initialized', () => {
513 controller.receiveEditor(editorInvoker)
514 controller.changeLockedState(true)
515 expect(editorInvoker.changeLockedState).toHaveBeenCalledWith(true)
519 describe('performClosingCeremony', () => {
520 it('should throw error when editor is not initialized', () => {
521 expect(() => controller.performClosingCeremony()).toThrow('Editor invoker not initialized')
524 it('should call editor performClosingCeremony when initialized', () => {
525 controller.receiveEditor(editorInvoker)
526 controller.performClosingCeremony()
527 expect(editorInvoker.performClosingCeremony).toHaveBeenCalled()
531 describe('event handling edge cases', () => {
532 it('should handle RealtimeReceivedDocumentUpdate when editor is not initialized', () => {
533 const payload = { content: new Uint8Array([1, 2, 3]) } as DecryptedMessage
534 sharedState.emitEvent({
535 name: 'RealtimeReceivedDocumentUpdate',
538 expect(editorInvoker.receiveMessage).not.toHaveBeenCalled()
541 it('should handle RealtimeConnectionClosed when editor is not initialized', () => {
542 sharedState.emitEvent({
543 name: 'RealtimeConnectionClosed',
544 payload: {} as ConnectionCloseReason,
546 expect(editorInvoker.performClosingCeremony).not.toHaveBeenCalled()
549 it('should handle RealtimeReceivedOtherClientPresenceState when editor is not initialized', () => {
550 const payload = new Uint8Array([1, 2, 3])
551 sharedState.emitEvent({
552 name: 'RealtimeReceivedOtherClientPresenceState',
555 expect(editorInvoker.receiveMessage).not.toHaveBeenCalled()
559 describe('metrics edge cases', () => {
560 it('should increment metrics with size_limit reason', () => {
561 sharedState.setProperty('userRole', new DocumentRole('Editor'))
562 sharedState.setProperty('realtimeIsLockedDueToSizeContraint', true)
564 controller.incrementMetricsReadonlyState()
566 expect(metrics.docs_readonly_mode_documents_total.increment).toHaveBeenCalledWith({
567 reason: 'size_limit',
571 it('should increment metrics with errored_sync reason', () => {
572 sharedState.setProperty('userRole', new DocumentRole('Editor'))
573 sharedState.setProperty('realtimeIsExperiencingErroredSync', true)
575 controller.incrementMetricsReadonlyState()
577 expect(metrics.docs_readonly_mode_documents_total.increment).toHaveBeenCalledWith({
578 reason: 'errored_sync',
582 it('should increment metrics with user_limit_reached reason', () => {
583 sharedState.setProperty('realtimeIsParticipantLimitReached', true)
585 controller.incrementMetricsReadonlyState()
587 expect(metrics.docs_readonly_mode_documents_total.increment).toHaveBeenCalledWith({
588 reason: 'user_limit_reached',
593 describe('document state operations', () => {
594 it('should throw when getting document state without editor', async () => {
595 await expect(controller.getDocumentState()).rejects.toThrow(
596 'Attempting to get document state before editor invoker is initialized',
600 it('should return undefined for client id when editor is not initialized', async () => {
601 const result = await controller.getDocumentClientId()
602 expect(result).toBeUndefined()
605 it('should throw when restoring revision without editor', async () => {
606 const lexicalState = { root: {} } as SerializedEditorState
607 await expect(controller.restoreRevisionByReplacing(lexicalState)).rejects.toThrow(
608 'Attempting to restore revision by replacing before editor invoker is initialized',
613 describe('editor ready state', () => {
614 it('should emit EditorIsReadyToBeShown event when showing editor', () => {
615 const emitEventSpy = jest.spyOn(sharedState, 'emitEvent')
616 controller.receiveEditor(editorInvoker)
617 sharedState.setProperty('realtimeEnabled', true)
618 sharedState.setProperty('realtimeReadyToBroadcast', true)
620 controller.showEditorForTheFirstTime()
622 expect(emitEventSpy).toHaveBeenCalledWith({
623 name: 'EditorIsReadyToBeShown',
628 it('should log info when showing editor', () => {
629 controller.receiveEditor(editorInvoker)
630 sharedState.setProperty('realtimeEnabled', true)
631 sharedState.setProperty('realtimeReadyToBroadcast', true)
633 controller.showEditorForTheFirstTime()
635 expect(logger.info).toHaveBeenCalledWith('Showing editor for the first time')
639 describe('getEditorJSON', () => {
640 it('should throw error when editor is not initialized', async () => {
641 await expect(controller.getEditorJSON()).rejects.toThrow('Editor invoker not initialized')
644 it('should return editor state when initialized', async () => {
645 const mockState: SerializedEditorState = {
656 controller.receiveEditor(editorInvoker)
657 editorInvoker.getCurrentEditorState.mockResolvedValue(mockState)
659 const result = await controller.getEditorJSON()
661 expect(result).toEqual(mockState)
662 expect(editorInvoker.getCurrentEditorState).toHaveBeenCalled()
666 describe('exportData', () => {
667 it('should throw error when editor is not initialized', async () => {
668 await expect(controller.exportData('docx')).rejects.toThrow(
669 'Attepting to export document before editor invoker or decrypted node is initialized',
673 it('should return exported data when initialized', async () => {
674 const mockExportedData = new Uint8Array([1, 2, 3, 4])
675 controller.receiveEditor(editorInvoker)
676 editorInvoker.exportData.mockResolvedValue(mockExportedData)
678 const result = await controller.exportData('docx')
680 expect(result).toEqual(mockExportedData)
681 expect(editorInvoker.exportData).toHaveBeenCalledWith('docx')
685 describe('RealtimeRequestingClientToBroadcastItsState event', () => {
686 it('should not broadcast presence state when editor is not initialized', () => {
687 sharedState.emitEvent({
688 name: 'RealtimeRequestingClientToBroadcastItsState',
692 expect(editorInvoker.broadcastPresenceState).not.toHaveBeenCalled()
695 it('should broadcast presence state when editor is initialized', () => {
696 controller.receiveEditor(editorInvoker)
698 sharedState.emitEvent({
699 name: 'RealtimeRequestingClientToBroadcastItsState',
703 expect(editorInvoker.broadcastPresenceState).toHaveBeenCalled()