From: Jérôme Benoit Date: Wed, 15 Apr 2026 20:46:47 +0000 (+0200) Subject: feat(common): add browser WebSocket adapter X-Git-Tag: v4.5~82 X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=fe53a6d218647d4ac51cbc648f3484e68eb32ea0;p=e-mobility-charging-stations-simulator.git feat(common): add browser WebSocket adapter --- diff --git a/ui/common/src/client/WebSocketClient.ts b/ui/common/src/client/WebSocketClient.ts index 44e18201..18b5dcd3 100644 --- a/ui/common/src/client/WebSocketClient.ts +++ b/ui/common/src/client/WebSocketClient.ts @@ -39,7 +39,8 @@ export class WebSocketClient { public constructor ( factory: WebSocketFactory, config: ClientConfig, - timeoutMs = UI_WEBSOCKET_REQUEST_TIMEOUT_MS + timeoutMs = UI_WEBSOCKET_REQUEST_TIMEOUT_MS, + private readonly onNotification?: (notification: unknown[]) => void ) { this.factory = factory this.config = config @@ -162,7 +163,12 @@ export class WebSocketClient { } catch { return } - if (!Array.isArray(message) || message.length !== 2) return + if (!Array.isArray(message)) return + if (message.length === 1) { + this.onNotification?.(message) + return + } + if (message.length !== 2) return const [uuid, responsePayload] = message as [unknown, unknown] if (!validateUUID(uuid)) return const handler = this.responseHandlers.get(uuid) diff --git a/ui/common/src/client/browser-adapter.ts b/ui/common/src/client/browser-adapter.ts new file mode 100644 index 00000000..fad46271 --- /dev/null +++ b/ui/common/src/client/browser-adapter.ts @@ -0,0 +1,87 @@ +import type { WebSocketLike } from './types.js' + +interface BrowserWebSocket { + close(code?: number, reason?: string): void + set onclose(handler: ((event: { code: number; reason: string }) => void) | null) + set onerror(handler: ((event: unknown) => void) | null) + set onmessage(handler: ((event: { data: unknown }) => void) | null) + set onopen(handler: (() => void) | null) + readonly readyState: number + send(data: string): void +} + +export const createBrowserWsAdapter = (ws: BrowserWebSocket): WebSocketLike => { + let onmessageCallback: WebSocketLike['onmessage'] = null + let onerrorCallback: WebSocketLike['onerror'] = null + let oncloseCallback: WebSocketLike['onclose'] = null + let onopenCallback: WebSocketLike['onopen'] = null + + ws.onmessage = event => { + if (onmessageCallback != null) { + const data = event.data as string + onmessageCallback({ data }) + } + } + + ws.onerror = event => { + if (onerrorCallback != null) { + const error = new Error('WebSocket error') + const message = 'WebSocket error' + onerrorCallback({ error, message }) + } + } + + ws.onclose = event => { + if (oncloseCallback != null) { + oncloseCallback({ code: event.code, reason: event.reason }) + } + } + + ws.onopen = () => { + if (onopenCallback != null) { + onopenCallback() + } + } + + return { + close (code?: number, reason?: string): void { + ws.close(code, reason) + }, + get onclose () { + return oncloseCallback + }, + + set onclose (callback) { + oncloseCallback = callback + }, + get onerror () { + return onerrorCallback + }, + + set onerror (callback) { + onerrorCallback = callback + }, + get onmessage () { + return onmessageCallback + }, + + set onmessage (callback) { + onmessageCallback = callback + }, + get onopen () { + return onopenCallback + }, + + set onopen (callback) { + onopenCallback = callback + }, + + get readyState (): number { + return ws.readyState + }, + + send (data: string): void { + ws.send(data) + }, + } +} diff --git a/ui/common/tests/WebSocketClient.test.ts b/ui/common/tests/WebSocketClient.test.ts index 3d41944a..1e28e8c7 100644 --- a/ui/common/tests/WebSocketClient.test.ts +++ b/ui/common/tests/WebSocketClient.test.ts @@ -525,4 +525,64 @@ await describe('WebSocketClient', async () => { return true }) }) + + await it('should fire onNotification for 1-element server notification', async () => { + const notifications: unknown[][] = [] + const mockWs = createMockWs() + const client = new WebSocketClient( + () => mockWs, + { host: 'localhost', port: 8080, protocol: 'ui', version: '0.0.1' }, + undefined, + notification => { notifications.push(notification) } + ) + const connectPromise = client.connect() + mockWs.triggerOpen() + await connectPromise + + mockWs.triggerMessage('["refresh"]') + + assert.strictEqual(notifications.length, 1) + assert.deepStrictEqual(notifications[0], ['refresh']) + client.disconnect() + }) + + await it('should NOT fire onNotification for 2-element response', async () => { + const notifications: unknown[][] = [] + const mockWs = createMockWs() + const factory: WebSocketFactory = () => mockWs + const client = new WebSocketClient( + factory, + { host: 'localhost', port: 8080, protocol: 'ui', version: '0.0.1' }, + undefined, + notification => { notifications.push(notification) } + ) + const connectPromise = client.connect() + mockWs.triggerOpen() + await connectPromise + + const requestPromise = client.sendRequest(ProcedureName.SIMULATOR_STATE, {}) + const uuid = JSON.parse(mockWs.sentMessages[0])[0] as string + mockWs.triggerMessage(JSON.stringify([uuid, { status: ResponseStatus.SUCCESS }])) + await requestPromise + + assert.strictEqual(notifications.length, 0) + client.disconnect() + }) + + await it('should NOT fire onNotification when callback is undefined', async () => { + const mockWs = createMockWs() + const client = new WebSocketClient( + () => mockWs, + { host: 'localhost', port: 8080, protocol: 'ui', version: '0.0.1' } + ) + const connectPromise = client.connect() + mockWs.triggerOpen() + await connectPromise + + // Should not throw when no callback registered + assert.doesNotThrow(() => { + mockWs.triggerMessage('["refresh"]') + }) + client.disconnect() + }) }) diff --git a/ui/common/tests/browser-adapter.test.ts b/ui/common/tests/browser-adapter.test.ts new file mode 100644 index 00000000..869afc85 --- /dev/null +++ b/ui/common/tests/browser-adapter.test.ts @@ -0,0 +1,115 @@ +/** @file Unit tests for the browser WebSocket adapter (browser WebSocket → WebSocketLike) */ + +import assert from 'node:assert' +import { describe, it } from 'node:test' + +import { createBrowserWsAdapter } from '../src/client/browser-adapter.js' +import { WebSocketReadyState } from '../src/client/types.js' + +interface MockBrowserWs { + close: (code?: number, reason?: string) => void + onclose: ((event: { code: number; reason: string }) => void) | null + onerror: ((event: unknown) => void) | null + onmessage: ((event: { data: unknown }) => void) | null + onopen: (() => void) | null + readyState: number + send: (data: string) => void +} + +const createMockBrowserWs = (): MockBrowserWs => ({ + close: () => undefined, + onclose: null, + onerror: null, + onmessage: null, + onopen: null, + readyState: WebSocketReadyState.OPEN, + send: () => undefined, +}) + +await describe('browser WebSocket adapter', async () => { + // Test: MessageEvent data extraction + await it('should extract data from MessageEvent in onmessage', () => { + const mockWs = createMockBrowserWs() + const adapter = createBrowserWsAdapter(mockWs as never) + let receivedData: string | undefined + adapter.onmessage = event => { + receivedData = event.data + } + mockWs.onmessage?.({ data: '["test"]' }) + assert.strictEqual(receivedData, '["test"]') + }) + + // Test: onerror produces WebSocketLike error shape + await it('should produce error shape from browser Event in onerror', () => { + const mockWs = createMockBrowserWs() + const adapter = createBrowserWsAdapter(mockWs as never) + let receivedError: unknown + let receivedMessage: string | undefined + adapter.onerror = event => { + receivedError = event.error + receivedMessage = event.message + } + mockWs.onerror?.({}) // plain Event (no .error or .message) + assert.ok(receivedError instanceof Error) + assert.strictEqual(receivedMessage, 'WebSocket error') + }) + + // Test: onclose extracts code and reason + await it('should extract code and reason from CloseEvent in onclose', () => { + const mockWs = createMockBrowserWs() + const adapter = createBrowserWsAdapter(mockWs as never) + let receivedCode: number | undefined + let receivedReason: string | undefined + adapter.onclose = event => { + receivedCode = event.code + receivedReason = event.reason + } + mockWs.onclose?.({ code: 1000, reason: 'normal closure' }) + assert.strictEqual(receivedCode, 1000) + assert.strictEqual(receivedReason, 'normal closure') + }) + + // Test: onopen forwarded + await it('should forward onopen callback', () => { + const mockWs = createMockBrowserWs() + const adapter = createBrowserWsAdapter(mockWs as never) + let opened = false + adapter.onopen = () => { + opened = true + } + mockWs.onopen?.() + assert.strictEqual(opened, true) + }) + + // Test: send delegation + await it('should delegate send to browser WebSocket', () => { + const mockWs = createMockBrowserWs() + let sentData: string | undefined + mockWs.send = (data: string) => { + sentData = data + } + const adapter = createBrowserWsAdapter(mockWs as never) + adapter.send('{"test":1}') + assert.strictEqual(sentData, '{"test":1}') + }) + + // Test: readyState delegation + await it('should delegate readyState to browser WebSocket', () => { + const mockWs = createMockBrowserWs() + mockWs.readyState = WebSocketReadyState.CONNECTING + const adapter = createBrowserWsAdapter(mockWs as never) + assert.strictEqual(adapter.readyState, WebSocketReadyState.CONNECTING) + }) + + // Test: close delegation + await it('should delegate close to browser WebSocket', () => { + const mockWs = createMockBrowserWs() + let closedCode: number | undefined + mockWs.close = (code?: number) => { + closedCode = code + } + const adapter = createBrowserWsAdapter(mockWs as never) + adapter.close(1000) + assert.strictEqual(closedCode, 1000) + }) +})