]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(common): add browser WebSocket adapter
authorJérôme Benoit <jerome.benoit@sap.com>
Wed, 15 Apr 2026 20:46:47 +0000 (22:46 +0200)
committerJérôme Benoit <jerome.benoit@sap.com>
Wed, 15 Apr 2026 20:46:47 +0000 (22:46 +0200)
ui/common/src/client/WebSocketClient.ts
ui/common/src/client/browser-adapter.ts [new file with mode: 0644]
ui/common/tests/WebSocketClient.test.ts
ui/common/tests/browser-adapter.test.ts [new file with mode: 0644]

index 44e18201c35fd6191703115e181c7384d6a538ea..18b5dcd344ad4b680fe9038932ddde8eb0360e46 100644 (file)
@@ -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 (file)
index 0000000..fad4627
--- /dev/null
@@ -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)
+    },
+  }
+}
index 3d41944a570d3f0ea5205468ace10cf215969410..1e28e8c7e6a0046e318006b8958df7bc91803777 100644 (file)
@@ -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 (file)
index 0000000..869afc8
--- /dev/null
@@ -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)
+  })
+})