/// import * as Y from 'yjs'; import { messages } from '../../src/proto/messages'; // Keys expected by the app Yjs utils const YjsEditorKey = { data_section: 'data_section', document: 'document', meta: 'meta', blocks: 'blocks', page_id: 'page_id', children_map: 'children_map', text_map: 'text_map', }; /** * Enhanced WebSocket mock that simulates AppFlowy's collab protocol * This mock ensures that the Document component receives the necessary * Y.Doc structure to render properly during tests. */ // Message types from AppFlowy protocol interface CollabMessage { objectId: string; collabType: number; syncRequest?: { stateVector?: Uint8Array; lastMessageId?: { timestamp: number; counter: number }; }; update?: { flags: number; payload: Uint8Array; messageId?: { timestamp: number; counter: number }; }; awarenessUpdate?: { payload: Uint8Array; }; } interface ProtobufMessage { collabMessage?: CollabMessage; } // Helper to create initial document structure function createInitialDocumentUpdate(objectId: string): Uint8Array { // Create a Y.Doc with the expected structure const doc = new Y.Doc({ guid: objectId }); // Create the data_section map const dataSection = doc.getMap(YjsEditorKey.data_section); // Create the document map with initial structure const document = new Y.Map(); // Set page_id as a plain string (app expects string, not Y.Text) document.set(YjsEditorKey.page_id, objectId); // Add blocks map for document content with a root page block const blocks = new Y.Map(); const rootBlock = new Y.Map(); rootBlock.set('id', objectId); rootBlock.set('ty', 0); // BlockType.Page rootBlock.set('children', objectId); rootBlock.set('data', ''); rootBlock.set('parent', objectId); rootBlock.set('external_id', objectId); blocks.set(objectId, rootBlock); // Create a paragraph block for the editor to render const paragraphId = `${objectId}-p1`; const paragraphBlock = new Y.Map(); paragraphBlock.set('id', paragraphId); paragraphBlock.set('ty', 1); // BlockType.Paragraph paragraphBlock.set('children', paragraphId); paragraphBlock.set('data', '{}'); paragraphBlock.set('parent', objectId); paragraphBlock.set('external_id', paragraphId); blocks.set(paragraphId, paragraphBlock); document.set(YjsEditorKey.blocks, blocks); // Add meta information const meta = new Y.Map(); const childrenMap = new Y.Map(); // Add the paragraph as a child of the root page const rootChildrenArray = new Y.Array(); rootChildrenArray.push([paragraphId]); childrenMap.set(objectId, rootChildrenArray); // Add empty children array for the paragraph const paragraphChildrenArray = new Y.Array(); childrenMap.set(paragraphId, paragraphChildrenArray); meta.set(YjsEditorKey.children_map, childrenMap); // Add text for the paragraph const textMap = new Y.Map(); const paragraphText = new Y.Text(); textMap.set(paragraphId, paragraphText); meta.set(YjsEditorKey.text_map, textMap); document.set(YjsEditorKey.meta, meta); // Set the document in data_section dataSection.set(YjsEditorKey.document, document); // Log the structure for debugging console.log('[CollabWebSocketMock] Created initial document structure:', { objectId, hasDataSection: doc.getMap(YjsEditorKey.data_section) !== undefined, hasDocument: dataSection.get(YjsEditorKey.document) !== undefined, blocksCount: blocks.size, hasRootBlock: blocks.has(objectId), }); // Encode the state as an update return Y.encodeStateAsUpdate(doc); } // WebSocket mock class with collab protocol support class CollabWebSocketMock { private url: string; private ws: WebSocket | null = null; private originalWebSocket: typeof WebSocket; private pendingDocs: Set = new Set(); private syncedDocs: Set = new Set(); private messageQueue: any[] = []; private responseDelay: number; private targetWindow: Window & typeof globalThis; constructor(targetWindow: Window & typeof globalThis, responseDelay: number = 50) { this.targetWindow = targetWindow; this.url = ''; this.originalWebSocket = targetWindow.WebSocket; this.responseDelay = responseDelay; this.setupMock(); } private setupMock() { const self = this; // Replace WebSocket constructor on AUT window (this.targetWindow as any).WebSocket = function (url: string, protocols?: string | string[]) { // Check if this is the AppFlowy WebSocket URL if (!url.includes('/ws/v2/')) { // Use real WebSocket for non-collab URLs return new self.originalWebSocket(url, protocols); } // Intercept collab socket self.url = url; // Create mock WebSocket instance const mockWs: any = { url: url, readyState: 0, // CONNECTING binaryType: 'arraybuffer', onopen: null, onclose: null, onerror: null, onmessage: null, listeners: new Map>(), send: (data: any) => { if (mockWs.readyState !== 1) { throw new Error('WebSocket is not open'); } // Heartbeat support: echo back plain text heartbeats if (typeof data === 'string' && data === 'echo') { const echoEvent = new MessageEvent('message', { data: 'echo' }); setTimeout(() => { mockWs.onmessage?.(echoEvent); mockWs.dispatchEvent(echoEvent); }, 10); return; } self.handleMessage(mockWs, data); }, close: (code?: number, reason?: string) => { if (mockWs.readyState === 2 || mockWs.readyState === 3) return; mockWs.readyState = 2; // CLOSING setTimeout(() => { mockWs.readyState = 3; // CLOSED const closeEvent = new CloseEvent('close', { code: code || 1000, reason: reason || '', wasClean: true, }); mockWs.onclose?.(closeEvent); mockWs.dispatchEvent(closeEvent); }, 10); }, addEventListener: (type: string, listener: EventListener) => { if (!mockWs.listeners.has(type)) { mockWs.listeners.set(type, new Set()); } mockWs.listeners.get(type)!.add(listener); }, removeEventListener: (type: string, listener: EventListener) => { mockWs.listeners.get(type)?.delete(listener); }, dispatchEvent: (event: Event) => { const listeners = mockWs.listeners.get(event.type); if (listeners) { listeners.forEach((listener: EventListener) => listener(event)); } return true; } }; // Ensure instanceof checks pass try { (mockWs as any).constructor = (self.targetWindow as any).WebSocket; Object.setPrototypeOf(mockWs, (self.targetWindow as any).WebSocket.prototype); } catch (_) { // ignore } self.ws = mockWs; // Store mock on AUT window for debugging (self.targetWindow as any).__mockCollabWebSocket = mockWs; // Simulate connection opening setTimeout(() => { mockWs.readyState = 1; // OPEN const openEvent = new Event('open'); mockWs.onopen?.(openEvent); mockWs.dispatchEvent(openEvent); console.log('[CollabWebSocketMock] WebSocket connection opened for URL:', url); // Log what page IDs we're dealing with const urlMatch = url.match(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/g); if (urlMatch) { console.log('[CollabWebSocketMock] Found object IDs in URL:', urlMatch); } // Process any queued messages self.processMessageQueue(mockWs); }, 50); return mockWs; } as any; // Copy WebSocket static properties Object.setPrototypeOf(this.targetWindow.WebSocket, this.originalWebSocket); // Align prototype so `instanceof WebSocket` works try { (this.targetWindow as any).WebSocket.prototype = this.originalWebSocket.prototype; } catch (_) { // no-op } // Copy static constants (read-only properties) try { Object.defineProperty(this.targetWindow.WebSocket, 'CONNECTING', { value: 0, writable: false }); Object.defineProperty(this.targetWindow.WebSocket, 'OPEN', { value: 1, writable: false }); Object.defineProperty(this.targetWindow.WebSocket, 'CLOSING', { value: 2, writable: false }); Object.defineProperty(this.targetWindow.WebSocket, 'CLOSED', { value: 3, writable: false }); } catch (_) { // no-op if already defined } } private handleMessage(ws: any, data: ArrayBuffer | Uint8Array | string) { try { // Ignore heartbeat or text messages if (typeof data === 'string') { return; } const buffer = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength); const decoded = messages.Message.decode(buffer); const collabMsg = decoded.collabMessage; if (collabMsg?.syncRequest && collabMsg.objectId) { const objectId = collabMsg.objectId; const collabType = collabMsg.collabType ?? 0; setTimeout(() => { this.sendSyncResponse(ws, objectId, collabType); }, this.responseDelay); } } catch (error) { // If decoding fails, ignore silently to avoid breaking app // Useful when app sends non-collab binary messages } } private sendSyncResponse(ws: any, objectId: string, collabType: number) { if (this.syncedDocs.has(objectId)) return; const update = createInitialDocumentUpdate(objectId); const msg: messages.IMessage = { collabMessage: { objectId, collabType, update: { flags: 0, payload: update, messageId: { timestamp: Date.now(), counter: 1 }, }, }, }; const encoded = messages.Message.encode(msg).finish(); const dataBuf = encoded.buffer.slice(encoded.byteOffset, encoded.byteOffset + encoded.byteLength); const messageEvent = new MessageEvent('message', { data: dataBuf }); ws.onmessage?.(messageEvent); ws.dispatchEvent(messageEvent); this.syncedDocs.add(objectId); } private processMessageQueue(ws: any) { while (this.messageQueue.length > 0) { const msg = this.messageQueue.shift(); this.handleMessage(ws, msg); } } public restore() { this.targetWindow.WebSocket = this.originalWebSocket; delete (this.targetWindow as any).__mockCollabWebSocket; this.pendingDocs.clear(); this.syncedDocs.clear(); } public addPendingDocument(objectId: string) { this.pendingDocs.add(objectId); } } // Cypress commands for collab WebSocket mocking declare global { namespace Cypress { interface Chainable { mockCollabWebSocket(responseDelay?: number): Chainable; restoreCollabWebSocket(): Chainable; waitForDocumentSync(objectId?: string, timeout?: number): Chainable; } } } // Store mock instance globally let collabMockInstance: CollabWebSocketMock | null = null; // Add Cypress commands if ((window as any).Cypress) { Cypress.Commands.add('mockCollabWebSocket', (responseDelay = 50) => { cy.window().then((win) => { if (!collabMockInstance) { collabMockInstance = new CollabWebSocketMock(win, responseDelay); (win as any).__collabMockInstance = collabMockInstance; } }); }); Cypress.Commands.add('restoreCollabWebSocket', () => { cy.window().then((win) => { if (collabMockInstance) { collabMockInstance.restore(); collabMockInstance = null; delete (win as any).__collabMockInstance; } }); }); Cypress.Commands.add('waitForDocumentSync', (objectId?: string, timeout = 10000) => { // When mocking, just wait for the modal to be visible // The Document component might not render immediately even with mocked data cy.get('[role="dialog"]', { timeout: 5000 }).should('be.visible'); cy.wait(1000); // Give time for the document to render }); } export { CollabWebSocketMock };