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
} 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)
--- /dev/null
+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)
+ },
+ }
+}
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()
+ })
})
--- /dev/null
+/** @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)
+ })
+})