chore: remove mock ws

This commit is contained in:
Nathan
2025-08-29 09:33:28 +08:00
parent e4f2ebf813
commit c54a004969
6 changed files with 2 additions and 702 deletions

View File

@@ -15,10 +15,6 @@ export default defineConfig({
APPFLOWY_WS_BASE_URL: process.env.APPFLOWY_WS_BASE_URL || 'ws://localhost/ws/v2',
GOTRUE_ADMIN_EMAIL: process.env.GOTRUE_ADMIN_EMAIL || 'admin@example.com',
GOTRUE_ADMIN_PASSWORD: process.env.GOTRUE_ADMIN_PASSWORD || 'password',
// WebSocket mocking configuration
MOCK_WEBSOCKET: process.env.MOCK_WEBSOCKET === 'true' || false,
WS_AUTO_RESPOND: process.env.WS_AUTO_RESPOND === 'true' || false,
WS_RESPONSE_DELAY: process.env.WS_RESPONSE_DELAY || '100',
},
e2e: {
chromeWebSecurity: false,
@@ -38,10 +34,6 @@ export default defineConfig({
config.env.APPFLOWY_WS_BASE_URL = process.env.APPFLOWY_WS_BASE_URL || config.env.APPFLOWY_WS_BASE_URL;
config.env.GOTRUE_ADMIN_EMAIL = process.env.GOTRUE_ADMIN_EMAIL || config.env.GOTRUE_ADMIN_EMAIL;
config.env.GOTRUE_ADMIN_PASSWORD = process.env.GOTRUE_ADMIN_PASSWORD || config.env.GOTRUE_ADMIN_PASSWORD;
// Pass WebSocket mock configuration
config.env.MOCK_WEBSOCKET = process.env.MOCK_WEBSOCKET === 'true' || config.env.MOCK_WEBSOCKET;
config.env.WS_AUTO_RESPOND = process.env.WS_AUTO_RESPOND === 'true' || config.env.WS_AUTO_RESPOND;
config.env.WS_RESPONSE_DELAY = process.env.WS_RESPONSE_DELAY || config.env.WS_RESPONSE_DELAY;
// Add task for logging to Node.js console
on('task', {

View File

@@ -6,10 +6,6 @@ import './auth-utils';
import './page-utils';
// Import console logger v2 (improved version)
import './console-logger';
// Import WebSocket mock utilities
import './websocket-mock';
// Import WebSocket collab mock for document sync
import './websocket-collab-mock';
// ***********************************************
// This example commands.ts shows you how to

View File

@@ -18,33 +18,11 @@ import 'cypress-file-upload';
import 'cypress-plugin-api';
import 'cypress-real-events';
import './commands';
import { CollabWebSocketMock } from './websocket-collab-mock';
// Install WebSocket mock before window loads if enabled
if (Cypress.env('MOCK_WEBSOCKET') === true || Cypress.env('MOCK_WEBSOCKET') === 'true') {
const delay = parseInt(Cypress.env('WS_RESPONSE_DELAY') || '50');
Cypress.on('window:before:load', (win) => {
// Install mock on every window load
if (!(win as any).__collabMockInstance) {
(win as any).__collabMockInstance = new CollabWebSocketMock(win as any, delay);
// eslint-disable-next-line no-console
console.log('[E2E] Collab WebSocket mock installed on window:', win.location.href);
}
});
}
// Global hooks for console logging
beforeEach(() => {
// Start capturing console logs for each test
cy.startConsoleCapture();
// Log if WebSocket mocking is enabled
if (Cypress.env('MOCK_WEBSOCKET') === true || Cypress.env('MOCK_WEBSOCKET') === 'true') {
cy.task('log', 'Collab WebSocket mocking enabled for this test');
cy.log('Collab WebSocket mocking enabled');
}
});
afterEach(() => {
@@ -54,11 +32,6 @@ afterEach(() => {
// Stop capturing to clean up
cy.stopConsoleCapture();
// Restore WebSocket if it was mocked
if (Cypress.env('MOCK_WEBSOCKET') === true || Cypress.env('MOCK_WEBSOCKET') === 'true') {
cy.restoreCollabWebSocket();
}
});
// Globally ignore transient app bootstrap errors during tests

View File

@@ -204,20 +204,9 @@ export function createPage(pageName: string) {
// Check if there are any WebSocket connection indicators
const wsConnected = win.localStorage.getItem('ws_connected');
cy.task('log', `WebSocket connection status: ${wsConnected || 'unknown'}`);
// Log any global WebSocket state if available
cy.task('log', `Window has WebSocket: ${!!win.WebSocket}`);
});
if (Cypress.env('MOCK_WEBSOCKET')) {
cy.task('log', 'Waiting for document to sync (MOCK_WEBSOCKET mode)');
cy.waitForDocumentSync();
cy.task('log', 'Document synced');
} else {
cy.task('log', 'WebSocket mode: Real-time sync expected');
// Check if WebSocket is connected
cy.window().then((win) => {
// Log any global WebSocket state if available
cy.task('log', `Window has WebSocket: ${!!win.WebSocket}`);
});
}
// Wait for document to load properly - be more generous with WebSocket sync
const isCi = Cypress.env('CI') || Cypress.env('GITHUB_ACTIONS');

View File

@@ -1,381 +0,0 @@
/// <reference types="cypress" />
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<string>();
rootChildrenArray.push([paragraphId]);
childrenMap.set(objectId, rootChildrenArray);
// Add empty children array for the paragraph
const paragraphChildrenArray = new Y.Array<string>();
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<string> = new Set();
private syncedDocs: Set<string> = 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<string, Set<EventListener>>(),
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<void>;
restoreCollabWebSocket(): Chainable<void>;
waitForDocumentSync(objectId?: string, timeout?: number): Chainable<void>;
}
}
}
// 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 };

View File

@@ -1,269 +0,0 @@
/// <reference types="cypress" />
interface MockWebSocketConfig {
enabled: boolean;
url?: string | RegExp;
responseDelay?: number;
autoRespond?: boolean;
mockResponses?: Map<string, any>;
}
interface MockWebSocketInstance {
url: string;
readyState: number;
binaryType: BinaryType;
onopen: ((event: Event) => void) | null;
onclose: ((event: CloseEvent) => void) | null;
onerror: ((event: Event) => void) | null;
onmessage: ((event: MessageEvent) => void) | null;
send: (data: any) => void;
close: (code?: number, reason?: string) => void;
addEventListener: (type: string, listener: EventListener) => void;
removeEventListener: (type: string, listener: EventListener) => void;
dispatchEvent: (event: Event) => boolean;
}
class MockWebSocket implements MockWebSocketInstance {
url: string;
readyState: number = 0; // CONNECTING
binaryType: BinaryType = 'arraybuffer';
onopen: ((event: Event) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
private listeners: Map<string, Set<EventListener>> = new Map();
private config: MockWebSocketConfig;
private sentMessages: any[] = [];
constructor(url: string, protocols?: string | string[], config?: MockWebSocketConfig) {
this.url = url;
this.config = config || { enabled: true };
// Store the mock instance for Cypress access
if ((window as any).Cypress) {
(window as any).__mockWebSocket = this;
(window as any).__mockWebSocketMessages = this.sentMessages;
}
// Simulate connection opening
setTimeout(() => {
this.readyState = 1; // OPEN
const openEvent = new Event('open');
this.onopen?.(openEvent);
this.dispatchEvent(openEvent);
// Log connection opened
console.log('[MockWebSocket] Connection opened:', url);
}, this.config.responseDelay || 100);
}
send(data: any): void {
if (this.readyState !== 1) {
throw new Error('WebSocket is not open');
}
this.sentMessages.push(data);
console.log('[MockWebSocket] Message sent:', data);
// Auto-respond with echo if configured
if (this.config.autoRespond) {
setTimeout(() => {
this.receiveMessage(data);
}, this.config.responseDelay || 50);
}
// Check for mock responses
if (this.config.mockResponses) {
const messageStr = typeof data === 'string' ? data : JSON.stringify(data);
for (const [pattern, response] of this.config.mockResponses) {
if (messageStr.includes(pattern)) {
setTimeout(() => {
this.receiveMessage(response);
}, this.config.responseDelay || 50);
break;
}
}
}
}
receiveMessage(data: any): void {
const messageEvent = new MessageEvent('message', { data });
this.onmessage?.(messageEvent);
this.dispatchEvent(messageEvent);
console.log('[MockWebSocket] Message received:', data);
}
close(code: number = 1000, reason?: string): void {
if (this.readyState === 2 || this.readyState === 3) {
return; // Already closing or closed
}
this.readyState = 2; // CLOSING
setTimeout(() => {
this.readyState = 3; // CLOSED
const closeEvent = new CloseEvent('close', {
code,
reason,
wasClean: true,
});
this.onclose?.(closeEvent);
this.dispatchEvent(closeEvent);
console.log('[MockWebSocket] Connection closed:', code, reason);
}, 10);
}
addEventListener(type: string, listener: EventListener): void {
if (!this.listeners.has(type)) {
this.listeners.set(type, new Set());
}
this.listeners.get(type)!.add(listener);
}
removeEventListener(type: string, listener: EventListener): void {
this.listeners.get(type)?.delete(listener);
}
dispatchEvent(event: Event): boolean {
const listeners = this.listeners.get(event.type);
if (listeners) {
listeners.forEach(listener => listener(event));
}
return true;
}
}
// WebSocket mock management functions
export function setupWebSocketMock(config?: Partial<MockWebSocketConfig>): void {
const defaultConfig: MockWebSocketConfig = {
enabled: true,
autoRespond: false,
responseDelay: 100,
mockResponses: new Map(),
...config,
};
if (!defaultConfig.enabled) {
console.log('[MockWebSocket] Mocking disabled');
return;
}
// Store original WebSocket
const OriginalWebSocket = window.WebSocket;
(window as any).__OriginalWebSocket = OriginalWebSocket;
// Replace WebSocket with mock
(window as any).WebSocket = function(url: string, protocols?: string | string[]) {
// Check if URL matches the pattern to mock
if (defaultConfig.url) {
const shouldMock = typeof defaultConfig.url === 'string'
? url.includes(defaultConfig.url)
: defaultConfig.url.test(url);
if (!shouldMock) {
console.log('[MockWebSocket] URL not matched, using real WebSocket:', url);
return new OriginalWebSocket(url, protocols);
}
}
console.log('[MockWebSocket] Creating mock WebSocket for:', url);
return new MockWebSocket(url, protocols, defaultConfig);
};
// Copy static properties
Object.setPrototypeOf(window.WebSocket, OriginalWebSocket);
Object.keys(OriginalWebSocket).forEach(key => {
(window.WebSocket as any)[key] = (OriginalWebSocket as any)[key];
});
console.log('[MockWebSocket] WebSocket mocking enabled');
}
export function restoreWebSocket(): void {
if ((window as any).__OriginalWebSocket) {
window.WebSocket = (window as any).__OriginalWebSocket;
delete (window as any).__OriginalWebSocket;
delete (window as any).__mockWebSocket;
delete (window as any).__mockWebSocketMessages;
console.log('[MockWebSocket] WebSocket restored to original');
}
}
export function getMockWebSocketInstance(): MockWebSocketInstance | null {
return (window as any).__mockWebSocket || null;
}
export function getMockWebSocketMessages(): any[] {
return (window as any).__mockWebSocketMessages || [];
}
// Cypress commands
declare global {
namespace Cypress {
interface Chainable {
mockWebSocket(config?: Partial<MockWebSocketConfig>): Chainable<void>;
restoreWebSocket(): Chainable<void>;
getWebSocketMessages(): Chainable<any[]>;
sendWebSocketMessage(data: any): Chainable<void>;
waitForWebSocketMessage(predicate?: (msg: any) => boolean, timeout?: number): Chainable<any>;
}
}
}
// Add Cypress commands
if ((window as any).Cypress) {
Cypress.Commands.add('mockWebSocket', (config?: Partial<MockWebSocketConfig>) => {
cy.window().then((win) => {
(win as any).setupWebSocketMock = setupWebSocketMock;
(win as any).setupWebSocketMock(config);
});
});
Cypress.Commands.add('restoreWebSocket', () => {
cy.window().then((win) => {
(win as any).restoreWebSocket = restoreWebSocket;
(win as any).restoreWebSocket();
});
});
Cypress.Commands.add('getWebSocketMessages', () => {
cy.window().then((win) => {
return (win as any).__mockWebSocketMessages || [];
});
});
Cypress.Commands.add('sendWebSocketMessage', (data: any) => {
cy.window().then((win) => {
const mockWs = (win as any).__mockWebSocket;
if (mockWs) {
mockWs.receiveMessage(data);
} else {
throw new Error('No mock WebSocket instance found');
}
});
});
Cypress.Commands.add('waitForWebSocketMessage', (predicate?: (msg: any) => boolean, timeout = 5000) => {
return cy.window().then((win) => {
return new Cypress.Promise((resolve) => {
const startTime = Date.now();
const checkMessages = () => {
const messages = (win as any).__mockWebSocketMessages || [];
const foundMessage = predicate
? messages.find(predicate)
: messages[messages.length - 1];
if (foundMessage) {
resolve(foundMessage);
} else if (Date.now() - startTime > timeout) {
throw new Error('Timeout waiting for WebSocket message');
} else {
setTimeout(checkMessages, 100);
}
};
checkMessages();
});
});
});
}