refactor(editor): Move editor-ui and design-system to frontend dir (no-changelog) (#13564)

This commit is contained in:
Alex Grozav
2025-02-28 14:28:30 +02:00
committed by GitHub
parent 684353436d
commit f5743176e5
1635 changed files with 805 additions and 1079 deletions

View File

@@ -0,0 +1,20 @@
/** Mocked EventSource class to help testing */
export class MockEventSource extends EventTarget {
constructor(public url: string) {
super();
}
simulateConnectionOpen() {
this.dispatchEvent(new Event('open'));
}
simulateConnectionClose() {
this.dispatchEvent(new Event('close'));
}
simulateMessageEvent(data: string) {
this.dispatchEvent(new MessageEvent('message', { data }));
}
close = vi.fn();
}

View File

@@ -0,0 +1,32 @@
import { WebSocketState } from '@/push-connection/useWebSocketClient';
/** Mocked WebSocket class to help testing */
export class MockWebSocket extends EventTarget {
readyState: number = WebSocketState.CONNECTING;
constructor(public url: string) {
super();
}
simulateConnectionOpen() {
this.dispatchEvent(new Event('open'));
this.readyState = WebSocketState.OPEN;
}
simulateConnectionClose(code: number) {
this.dispatchEvent(new CloseEvent('close', { code }));
this.readyState = WebSocketState.CLOSED;
}
simulateMessageEvent(data: string) {
this.dispatchEvent(new MessageEvent('message', { data }));
}
dispatchErrorEvent() {
this.dispatchEvent(new Event('error'));
}
send = vi.fn();
close = vi.fn();
}

View File

@@ -0,0 +1,153 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { useEventSourceClient } from '../useEventSourceClient';
import { MockEventSource } from './mockEventSource';
describe('useEventSourceClient', () => {
let mockEventSource: MockEventSource;
beforeEach(() => {
mockEventSource = new MockEventSource('http://test.com');
// @ts-expect-error - mock EventSource
global.EventSource = vi.fn(() => mockEventSource);
vi.useFakeTimers();
});
afterEach(() => {
vi.clearAllTimers();
vi.clearAllMocks();
});
test('should create EventSource connection with provided URL', () => {
const url = 'http://test.com';
const onMessage = vi.fn();
const { connect } = useEventSourceClient({ url, onMessage });
connect();
expect(EventSource).toHaveBeenCalledWith(url, { withCredentials: true });
});
test('should update connection status on successful connection', () => {
const { connect, isConnected } = useEventSourceClient({
url: 'http://test.com',
onMessage: vi.fn(),
});
connect();
mockEventSource.simulateConnectionOpen();
expect(isConnected.value).toBe(true);
});
test('should handle incoming messages', () => {
const onMessage = vi.fn();
const { connect } = useEventSourceClient({ url: 'http://test.com', onMessage });
connect();
mockEventSource.simulateMessageEvent('test data');
expect(onMessage).toHaveBeenCalledWith('test data');
});
test('should handle disconnection', () => {
const { connect, disconnect, isConnected } = useEventSourceClient({
url: 'http://test.com',
onMessage: vi.fn(),
});
connect();
// Simulate successful connection
mockEventSource.simulateConnectionOpen();
expect(isConnected.value).toBe(true);
disconnect();
expect(isConnected.value).toBe(false);
expect(mockEventSource.close).toHaveBeenCalled();
});
test('should handle connection loss', () => {
const { connect, isConnected } = useEventSourceClient({
url: 'http://test.com',
onMessage: vi.fn(),
});
connect();
expect(EventSource).toHaveBeenCalledTimes(1);
// Simulate successful connection
mockEventSource.simulateConnectionOpen();
expect(isConnected.value).toBe(true);
// Simulate connection loss
mockEventSource.simulateConnectionClose();
expect(isConnected.value).toBe(false);
// Advance timer to trigger reconnect
vi.advanceTimersByTime(1_000);
expect(EventSource).toHaveBeenCalledTimes(2);
});
test('sendMessage should be a noop function', () => {
const { connect, sendMessage } = useEventSourceClient({
url: 'http://test.com',
onMessage: vi.fn(),
});
connect();
// Simulate successful connection
mockEventSource.simulateConnectionOpen();
const message = 'test message';
// Should not throw error and should do nothing
expect(() => sendMessage(message)).not.toThrow();
});
test('should attempt reconnection with increasing delays', () => {
const { connect } = useEventSourceClient({
url: 'http://test.com',
onMessage: vi.fn(),
});
connect();
mockEventSource.simulateConnectionOpen();
mockEventSource.simulateConnectionClose();
// First reconnection attempt after 1 second
vi.advanceTimersByTime(1_000);
expect(EventSource).toHaveBeenCalledTimes(2);
mockEventSource.simulateConnectionClose();
// Second reconnection attempt after 2 seconds
vi.advanceTimersByTime(2_000);
expect(EventSource).toHaveBeenCalledTimes(3);
});
test('should reset connection attempts on successful connection', () => {
const { connect } = useEventSourceClient({
url: 'http://test.com',
onMessage: vi.fn(),
});
connect();
// First connection attempt
mockEventSource.simulateConnectionOpen();
mockEventSource.simulateConnectionClose();
// First reconnection attempt
vi.advanceTimersByTime(1_000);
expect(EventSource).toHaveBeenCalledTimes(2);
// Successful connection
mockEventSource.simulateConnectionOpen();
// Connection lost again
mockEventSource.simulateConnectionClose();
// Should start with initial delay again
vi.advanceTimersByTime(1_000);
expect(EventSource).toHaveBeenCalledTimes(3);
});
});

View File

@@ -0,0 +1,88 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useHeartbeat } from '../useHeartbeat';
describe('useHeartbeat', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.clearAllTimers();
});
it('should start heartbeat and call onHeartbeat at specified intervals', () => {
const onHeartbeat = vi.fn();
const interval = 1000;
const heartbeat = useHeartbeat({ interval, onHeartbeat });
heartbeat.startHeartbeat();
// Initially, the callback should not be called
expect(onHeartbeat).not.toHaveBeenCalled();
// Advance timer by interval
vi.advanceTimersByTime(interval);
expect(onHeartbeat).toHaveBeenCalledTimes(1);
// Advance timer by another interval
vi.advanceTimersByTime(interval);
expect(onHeartbeat).toHaveBeenCalledTimes(2);
});
it('should stop heartbeat when stopHeartbeat is called', () => {
const onHeartbeat = vi.fn();
const interval = 1000;
const heartbeat = useHeartbeat({ interval, onHeartbeat });
heartbeat.startHeartbeat();
// Advance timer by interval
vi.advanceTimersByTime(interval);
expect(onHeartbeat).toHaveBeenCalledTimes(1);
// Stop the heartbeat
heartbeat.stopHeartbeat();
// Advance timer by multiple intervals
vi.advanceTimersByTime(interval * 3);
expect(onHeartbeat).toHaveBeenCalledTimes(1); // Should still be 1
});
it('should be safe to call stopHeartbeat multiple times', () => {
const onHeartbeat = vi.fn();
const interval = 1000;
const heartbeat = useHeartbeat({ interval, onHeartbeat });
heartbeat.startHeartbeat();
// Stop multiple times
heartbeat.stopHeartbeat();
heartbeat.stopHeartbeat();
heartbeat.stopHeartbeat();
vi.advanceTimersByTime(interval * 2);
expect(onHeartbeat).not.toHaveBeenCalled();
});
it('should restart heartbeat after stopping', () => {
const onHeartbeat = vi.fn();
const interval = 1000;
const heartbeat = useHeartbeat({ interval, onHeartbeat });
// First start
heartbeat.startHeartbeat();
vi.advanceTimersByTime(interval);
expect(onHeartbeat).toHaveBeenCalledTimes(1);
// Stop
heartbeat.stopHeartbeat();
vi.advanceTimersByTime(interval);
expect(onHeartbeat).toHaveBeenCalledTimes(1);
// Restart
heartbeat.startHeartbeat();
vi.advanceTimersByTime(interval);
expect(onHeartbeat).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,140 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { useWebSocketClient } from '../useWebSocketClient';
import { MockWebSocket } from './mockWebSocketClient';
describe('useWebSocketClient', () => {
let mockWebSocket: MockWebSocket;
beforeEach(() => {
mockWebSocket = new MockWebSocket('ws://test.com');
// @ts-expect-error - mock WebSocket
global.WebSocket = vi.fn(() => mockWebSocket);
vi.useFakeTimers();
});
afterEach(() => {
vi.clearAllTimers();
vi.clearAllMocks();
});
test('should create WebSocket connection with provided URL', () => {
const url = 'ws://test.com';
const onMessage = vi.fn();
const { connect } = useWebSocketClient({ url, onMessage });
connect();
expect(WebSocket).toHaveBeenCalledWith(url);
});
test('should update connection status and start heartbeat on successful connection', () => {
const { connect, isConnected } = useWebSocketClient({
url: 'ws://test.com',
onMessage: vi.fn(),
});
connect();
mockWebSocket.simulateConnectionOpen();
expect(isConnected.value).toBe(true);
// Advance timer to trigger heartbeat
vi.advanceTimersByTime(30_000);
expect(mockWebSocket.send).toHaveBeenCalledWith(JSON.stringify({ type: 'heartbeat' }));
});
test('should handle incoming messages', () => {
const onMessage = vi.fn();
const { connect } = useWebSocketClient({ url: 'ws://test.com', onMessage });
connect();
mockWebSocket.simulateMessageEvent('test data');
expect(onMessage).toHaveBeenCalledWith('test data');
});
test('should handle disconnection', () => {
const { connect, disconnect, isConnected } = useWebSocketClient({
url: 'ws://test.com',
onMessage: vi.fn(),
});
connect();
// Simulate successful connection
mockWebSocket.simulateConnectionOpen();
expect(isConnected.value).toBe(true);
disconnect();
expect(isConnected.value).toBe(false);
expect(mockWebSocket.close).toHaveBeenCalledWith(1000);
});
test('should handle connection loss', () => {
const { connect, isConnected } = useWebSocketClient({
url: 'ws://test.com',
onMessage: vi.fn(),
});
connect();
expect(WebSocket).toHaveBeenCalledTimes(1);
// Simulate successful connection
mockWebSocket.simulateConnectionOpen();
expect(isConnected.value).toBe(true);
// Simulate connection loss
mockWebSocket.simulateConnectionClose(1006);
expect(isConnected.value).toBe(false);
// Advance timer to reconnect
vi.advanceTimersByTime(1_000);
expect(WebSocket).toHaveBeenCalledTimes(2);
});
test('should throw error when trying to send message while disconnected', () => {
const { sendMessage } = useWebSocketClient({ url: 'ws://test.com', onMessage: vi.fn() });
expect(() => sendMessage('test')).toThrow('Not connected to the server');
});
test('should attempt reconnection with increasing delays', () => {
const { connect } = useWebSocketClient({
url: 'http://test.com',
onMessage: vi.fn(),
});
connect();
mockWebSocket.simulateConnectionOpen();
mockWebSocket.simulateConnectionClose(1006);
// First reconnection attempt after 1 second
vi.advanceTimersByTime(1_000);
expect(WebSocket).toHaveBeenCalledTimes(2);
mockWebSocket.simulateConnectionClose(1006);
// Second reconnection attempt after 2 seconds
vi.advanceTimersByTime(2_000);
expect(WebSocket).toHaveBeenCalledTimes(3);
});
test('should send message when connected', () => {
const { connect, sendMessage } = useWebSocketClient({
url: 'ws://test.com',
onMessage: vi.fn(),
});
connect();
// Simulate successful connection
mockWebSocket.simulateConnectionOpen();
const message = 'test message';
sendMessage(message);
expect(mockWebSocket.send).toHaveBeenCalledWith(message);
});
});

View File

@@ -0,0 +1,69 @@
import { useReconnectTimer } from '@/push-connection/useReconnectTimer';
import { ref } from 'vue';
export type UseEventSourceClientOptions = {
url: string;
onMessage: (data: string) => void;
};
/**
* Creates an EventSource connection to the server. Uses reconnection logic
* to reconnect if the connection is lost.
*/
export const useEventSourceClient = (options: UseEventSourceClientOptions) => {
const isConnected = ref(false);
const eventSource = ref<EventSource | null>(null);
const onConnected = () => {
isConnected.value = true;
reconnectTimer.resetConnectionAttempts();
};
const onConnectionLost = () => {
console.warn('[EventSourceClient] Connection lost');
isConnected.value = false;
reconnectTimer.scheduleReconnect();
};
const onMessage = (event: MessageEvent) => {
options.onMessage(event.data);
};
const disconnect = () => {
if (eventSource.value) {
reconnectTimer.stopReconnectTimer();
eventSource.value.close();
eventSource.value = null;
}
isConnected.value = false;
};
const connect = () => {
// Ensure we disconnect any existing connection
disconnect();
eventSource.value = new EventSource(options.url, { withCredentials: true });
eventSource.value.addEventListener('open', onConnected);
eventSource.value.addEventListener('message', onMessage);
eventSource.value.addEventListener('close', onConnectionLost);
};
const reconnectTimer = useReconnectTimer({
onAttempt: connect,
onAttemptScheduled: (delay) => {
console.log(`[EventSourceClient] Attempting to reconnect in ${delay}ms`);
},
});
const sendMessage = (_: string) => {
// Noop, EventSource does not support sending messages
};
return {
isConnected,
connect,
disconnect,
sendMessage,
};
};

View File

@@ -0,0 +1,32 @@
import { ref } from 'vue';
export type UseHeartbeatOptions = {
interval: number;
onHeartbeat: () => void;
};
/**
* Creates a heartbeat timer using the given interval. The timer needs
* to be started and stopped manually.
*/
export const useHeartbeat = (options: UseHeartbeatOptions) => {
const { interval, onHeartbeat } = options;
const heartbeatTimer = ref<ReturnType<typeof setInterval> | null>(null);
const startHeartbeat = () => {
heartbeatTimer.value = setInterval(onHeartbeat, interval);
};
const stopHeartbeat = () => {
if (heartbeatTimer.value) {
clearInterval(heartbeatTimer.value);
heartbeatTimer.value = null;
}
};
return {
startHeartbeat,
stopHeartbeat,
};
};

View File

@@ -0,0 +1,49 @@
import { ref } from 'vue';
type UseReconnectTimerOptions = {
/** Callback that an attempt should be made */
onAttempt: () => void;
/** Callback that a future attempt was scheduled */
onAttemptScheduled: (delay: number) => void;
};
/**
* A timer for exponential backoff reconnect attempts.
*/
export const useReconnectTimer = ({ onAttempt, onAttemptScheduled }: UseReconnectTimerOptions) => {
const initialReconnectDelay = 1000;
const maxReconnectDelay = 15_000;
const reconnectTimer = ref<ReturnType<typeof setTimeout> | null>(null);
const reconnectAttempts = ref(0);
const scheduleReconnect = () => {
const delay = Math.min(initialReconnectDelay * 2 ** reconnectAttempts.value, maxReconnectDelay);
reconnectAttempts.value++;
onAttemptScheduled(delay);
reconnectTimer.value = setTimeout(() => {
onAttempt();
}, delay);
};
/** Stops the reconnect timer. NOTE: This does not reset the reconnect attempts. */
const stopReconnectTimer = () => {
if (reconnectTimer.value) {
clearTimeout(reconnectTimer.value);
reconnectTimer.value = null;
}
};
const resetConnectionAttempts = () => {
reconnectAttempts.value = 0;
};
return {
scheduleReconnect,
stopReconnectTimer,
resetConnectionAttempts,
};
};

View File

@@ -0,0 +1,105 @@
import { useHeartbeat } from '@/push-connection/useHeartbeat';
import { useReconnectTimer } from '@/push-connection/useReconnectTimer';
import { ref } from 'vue';
import { createHeartbeatMessage } from '@n8n/api-types';
export type UseWebSocketClientOptions<T> = {
url: string;
onMessage: (data: T) => void;
};
/** Defined here as not available in tests */
export const WebSocketState = {
CONNECTING: 0,
OPEN: 1,
CLOSING: 2,
CLOSED: 3,
};
/**
* Creates a WebSocket connection to the server. Uses reconnection logic
* to reconnect if the connection is lost.
*/
export const useWebSocketClient = <T>(options: UseWebSocketClientOptions<T>) => {
const isConnected = ref(false);
const socket = ref<WebSocket | null>(null);
/**
* Heartbeat timer to keep the connection alive. This is an additional
* mechanism to the protocol level ping/pong mechanism the server sends.
* This is used the ensure the client notices connection issues.
*/
const { startHeartbeat, stopHeartbeat } = useHeartbeat({
interval: 30_000,
onHeartbeat: () => {
socket.value?.send(JSON.stringify(createHeartbeatMessage()));
},
});
const onConnected = () => {
socket.value?.removeEventListener('open', onConnected);
isConnected.value = true;
startHeartbeat();
reconnectTimer.resetConnectionAttempts();
};
const onConnectionLost = (event: CloseEvent) => {
console.warn(`[WebSocketClient] Connection lost, code=${event.code ?? 'unknown'}`);
disconnect();
reconnectTimer.scheduleReconnect();
};
const onMessage = (event: MessageEvent) => {
options.onMessage(event.data);
};
const onError = (error: unknown) => {
console.warn('[WebSocketClient] Connection error:', error);
};
const disconnect = () => {
if (socket.value) {
stopHeartbeat();
reconnectTimer.stopReconnectTimer();
socket.value.removeEventListener('message', onMessage);
socket.value.removeEventListener('error', onError);
socket.value.removeEventListener('close', onConnectionLost);
socket.value.close(1000);
socket.value = null;
}
isConnected.value = false;
};
const connect = () => {
// Ensure we disconnect any existing connection
disconnect();
socket.value = new WebSocket(options.url);
socket.value.addEventListener('open', onConnected);
socket.value.addEventListener('message', onMessage);
socket.value.addEventListener('error', onError);
socket.value.addEventListener('close', onConnectionLost);
};
const reconnectTimer = useReconnectTimer({
onAttempt: connect,
onAttemptScheduled: (delay) => {
console.log(`[WebSocketClient] Attempting to reconnect in ${delay}ms`);
},
});
const sendMessage = (serializedMessage: string) => {
if (!isConnected.value || !socket.value) {
throw new Error('Not connected to the server');
}
socket.value.send(serializedMessage);
};
return {
isConnected,
connect,
disconnect,
sendMessage,
};
};