]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
refactor(web): migrate types to ui-common + UIClient internals to WebSocketClient
authorJérôme Benoit <jerome.benoit@sap.com>
Wed, 15 Apr 2026 22:48:39 +0000 (00:48 +0200)
committerJérôme Benoit <jerome.benoit@sap.com>
Wed, 15 Apr 2026 22:48:39 +0000 (00:48 +0200)
14 files changed:
pnpm-lock.yaml
ui/web/package.json
ui/web/src/composables/UIClient.ts
ui/web/src/composables/Utils.ts
ui/web/src/types/ChargingStationType.ts
ui/web/src/types/ConfigurationType.ts
ui/web/src/types/JsonType.ts [deleted file]
ui/web/src/types/UIProtocol.ts
ui/web/src/types/UUID.ts [deleted file]
ui/web/src/types/index.ts
ui/web/src/views/ChargingStationsView.vue
ui/web/tests/unit/UIClient.test.ts
ui/web/tests/unit/constants.ts
ui/web/tests/unit/helpers.ts

index f9cded6a4a3d49f8c71e8558dec04082060d322d..4394207aca684dfd696f83b4b85544fa26bf457c 100644 (file)
@@ -1,97 +1,3 @@
----
-lockfileVersion: '9.0'
-
-importers:
-
-  .:
-    configDependencies: {}
-    packageManagerDependencies:
-      '@pnpm/exe':
-        specifier: 10.33.0
-        version: 10.33.0
-      pnpm:
-        specifier: 10.33.0
-        version: 10.33.0
-
-packages:
-
-  '@pnpm/exe@10.33.0':
-    resolution: {integrity: sha512-sGsjztJBelzVMd0RhceDJ3p8Hk7eBcpu4G/TF6REzIvNdkKyxDT0czc1BWyo8Kbg+U0OBtK/TAGXN7Art4rTdg==}
-    hasBin: true
-
-  '@pnpm/linux-arm64@10.33.0':
-    resolution: {integrity: sha512-oYb5NxEyImqaTkLVX/7jL59m9Vfmd07iLWzr4Pg2LIk4XEtAllNcnksNHcp5Uf+lFk/BggtpOdvC84TG3VnbFw==}
-    cpu: [arm64]
-    os: [linux]
-    hasBin: true
-
-  '@pnpm/linux-x64@10.33.0':
-    resolution: {integrity: sha512-JYD2GXDF2roKpvTg5s032lYcUcT9lMedYlzxoqitWTjKlkMhl2gXRYpiDHdi2mWC5nFOJYlgYbUuy6jh3rXhng==}
-    cpu: [x64]
-    os: [linux]
-    hasBin: true
-
-  '@pnpm/macos-arm64@10.33.0':
-    resolution: {integrity: sha512-3w9Pqpw0swnAbnEdAKumMuKj+TPaGratnqC49bC41vjR1pNs0UMwVdOxiIROUMQy5OHKPx0IH/wOOP0hkhJd+g==}
-    cpu: [arm64]
-    os: [darwin]
-    hasBin: true
-
-  '@pnpm/macos-x64@10.33.0':
-    resolution: {integrity: sha512-SBeiLjU/9ORMIXAMsD6+Ltaaesniwh49FeFcG6kA64Zxr30U9SyzeZDnNOyWCGFjHeCmGfzCnSpNEN4VNo827g==}
-    cpu: [x64]
-    os: [darwin]
-    hasBin: true
-
-  '@pnpm/win-arm64@10.33.0':
-    resolution: {integrity: sha512-8X3NQqmfNVZ+dCu+EfD7ZkAgDgIKKdAgBBKcvhvMoMJq/nWHOfqDLxewE9TQ7qzVLuUKG/9b/xBVRVjdtDOm0w==}
-    cpu: [arm64]
-    os: [win32]
-    hasBin: true
-
-  '@pnpm/win-x64@10.33.0':
-    resolution: {integrity: sha512-wiPVvxmTuB6FFn+rZ4FfSk1WTn+cxiQ7MTJEEz1k9VZLN/yZujGrv/WLYH2JcwzVTgObfmQuBKeNgEUavEL0Qg==}
-    cpu: [x64]
-    os: [win32]
-    hasBin: true
-
-  pnpm@10.33.0:
-    resolution: {integrity: sha512-EFaLtKavtYyes2MNqQzJUWQXq+vT+rvmc58K55VyjaFJHp21pUTHatjrdXD1xLs9bGN7LLQb/c20f6gjyGSTGQ==}
-    engines: {node: '>=18.12'}
-    hasBin: true
-
-snapshots:
-
-  '@pnpm/exe@10.33.0':
-    optionalDependencies:
-      '@pnpm/linux-arm64': 10.33.0
-      '@pnpm/linux-x64': 10.33.0
-      '@pnpm/macos-arm64': 10.33.0
-      '@pnpm/macos-x64': 10.33.0
-      '@pnpm/win-arm64': 10.33.0
-      '@pnpm/win-x64': 10.33.0
-
-  '@pnpm/linux-arm64@10.33.0':
-    optional: true
-
-  '@pnpm/linux-x64@10.33.0':
-    optional: true
-
-  '@pnpm/macos-arm64@10.33.0':
-    optional: true
-
-  '@pnpm/macos-x64@10.33.0':
-    optional: true
-
-  '@pnpm/win-arm64@10.33.0':
-    optional: true
-
-  '@pnpm/win-x64@10.33.0':
-    optional: true
-
-  pnpm@10.33.0: {}
-
----
 lockfileVersion: '9.0'
 
 settings:
@@ -357,6 +263,9 @@ importers:
       serve-static:
         specifier: ^2.2.1
         version: 2.2.1
+      ui-common:
+        specifier: workspace:*
+        version: link:../common
       vue:
         specifier: ^3.5.32
         version: 3.5.32(typescript@6.0.2)
index 589df153bd9d558f9deab342fb27619b50e59e13..d0300ef35f2d23e853db7e98623c9a13379ea68a 100644 (file)
@@ -30,6 +30,7 @@
   "dependencies": {
     "finalhandler": "^2.1.1",
     "serve-static": "^2.2.1",
+    "ui-common": "workspace:*",
     "vue": "^3.5.32",
     "vue-router": "^5.0.4",
     "vue-toast-notification": "^3.1.3"
index fabef6268ab200f6154134064079eb54a1c3d965..15623b5af69c338280bbe7e8b8085f1f80874ec4 100644 (file)
@@ -1,3 +1,4 @@
+import { createBrowserWsAdapter, WebSocketClient, type WebSocketFactory } from 'ui-common'
 import { useToast } from 'vue-toast-notification'
 
 import {
@@ -9,34 +10,26 @@ import {
   type OCPP20TransactionEventRequest,
   OCPPVersion,
   ProcedureName,
-  type ProtocolResponse,
   type RequestPayload,
   type ResponsePayload,
   ResponseStatus,
   ServerNotification,
   type UIServerConfigurationSection,
-  type UUIDv4,
 } from '@/types'
 
-import { UI_WEBSOCKET_REQUEST_TIMEOUT_MS } from './Constants'
-import { randomUUID, validateUUID } from './Utils'
-
-interface ResponseHandler {
-  procedureName: ProcedureName
-  reject: (reason?: unknown) => void
-  resolve: (value: PromiseLike<ResponsePayload> | ResponsePayload) => void
-}
-
 export class UIClient {
   private static instance: null | UIClient = null
+  private client: WebSocketClient
   private readonly refreshListeners: Set<() => void>
-  private responseHandlers: Map<UUIDv4, ResponseHandler>
-  private ws?: WebSocket
+  private uiServerConfiguration: UIServerConfigurationSection
+  private readonly wsEventTarget: EventTarget
 
-  private constructor (private uiServerConfiguration: UIServerConfigurationSection) {
-    this.openWS()
-    this.responseHandlers = new Map<UUIDv4, ResponseHandler>()
+  private constructor (uiServerConfiguration: UIServerConfigurationSection) {
+    this.uiServerConfiguration = uiServerConfiguration
     this.refreshListeners = new Set()
+    this.wsEventTarget = new EventTarget()
+    this.client = this.createClient()
+    this.client.connect().catch(() => undefined)
   }
 
   public static getInstance (uiServerConfiguration?: UIServerConfigurationSection): UIClient {
@@ -117,16 +110,14 @@ export class UIClient {
     listener: (event: WebSocketEventMap[K]) => void,
     options?: AddEventListenerOptions | boolean
   ) {
-    this.ws?.addEventListener(event, listener, options)
+    this.wsEventTarget.addEventListener(event, listener as EventListener, options)
   }
 
   public setConfiguration (uiServerConfiguration: UIServerConfigurationSection): void {
-    if (this.ws?.readyState === WebSocket.OPEN) {
-      this.ws.close()
-      delete this.ws
-    }
+    this.client.disconnect()
     this.uiServerConfiguration = uiServerConfiguration
-    this.openWS()
+    this.client = this.createClient()
+    this.client.connect().catch(() => undefined)
   }
 
   public async setSupervisionUrl (hashId: string, supervisionUrl: string): Promise<ResponsePayload> {
@@ -246,141 +237,115 @@ export class UIClient {
     listener: (event: WebSocketEventMap[K]) => void,
     options?: AddEventListenerOptions | boolean
   ) {
-    this.ws?.removeEventListener(event, listener, options)
+    this.wsEventTarget.removeEventListener(event, listener as EventListener, options)
   }
 
-  private openWS (): void {
-    const protocols =
-      this.uiServerConfiguration.authentication?.enabled === true &&
+  private createClient (): WebSocketClient {
+    const config = this.uiServerConfiguration
+    const uiUrl = `${config.secure === true ? ApplicationProtocol.WSS : ApplicationProtocol.WS}://${config.host}:${config.port.toString()}`
+    const uiProtocols =
+      config.authentication?.enabled === true &&
       // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
-      this.uiServerConfiguration.authentication.type === AuthenticationType.PROTOCOL_BASIC_AUTH
+      config.authentication.type === AuthenticationType.PROTOCOL_BASIC_AUTH
         ? [
-            `${this.uiServerConfiguration.protocol}${this.uiServerConfiguration.version}`,
+            `${config.protocol}${config.version}`,
             `authorization.basic.${btoa(
               // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
-              `${this.uiServerConfiguration.authentication.username}:${this.uiServerConfiguration.authentication.password}`
+              `${config.authentication.username}:${config.authentication.password}`
             ).replace(/={1,2}$/, '')}`,
           ]
-        : `${this.uiServerConfiguration.protocol}${this.uiServerConfiguration.version}`
-    this.ws = new WebSocket(
-      `${
-        this.uiServerConfiguration.secure === true
-          ? ApplicationProtocol.WSS
-          : ApplicationProtocol.WS
-      }://${this.uiServerConfiguration.host}:${this.uiServerConfiguration.port.toString()}`,
-      protocols
-    )
-    this.ws.onopen = () => {
-      useToast().success(
-        `WebSocket to UI server '${this.uiServerConfiguration.host}:${this.uiServerConfiguration.port.toString()}' successfully opened`
-      )
-    }
-    this.ws.onmessage = this.responseHandler.bind(this)
-    this.ws.onerror = errorEvent => {
-      useToast().error(
-        `Error in WebSocket to UI server '${this.uiServerConfiguration.host}:${this.uiServerConfiguration.port.toString()}'`
-      )
-      console.error(
-        `Error in WebSocket to UI server '${this.uiServerConfiguration.host}:${this.uiServerConfiguration.port.toString()}'`,
-        errorEvent
-      )
-    }
-    this.ws.onclose = () => {
-      useToast().info('WebSocket to UI server closed')
-    }
-  }
-
-  private responseHandler (messageEvent: MessageEvent<string>): void {
-    let message: unknown
-    try {
-      message = JSON.parse(messageEvent.data)
-    } catch (error) {
-      useToast().error('Invalid response JSON format')
-      console.error('Invalid response JSON format', error)
-      return
-    }
+        : `${config.protocol}${config.version}`
 
-    if (!Array.isArray(message)) {
-      useToast().error('Response not an array')
-      console.error('Response not an array:', message)
-      return
-    }
+    const eventTarget = this.wsEventTarget
 
-    const isServerNotification =
-      message.length === 1 &&
-      Object.values(ServerNotification).includes(message[0] as ServerNotification)
+    const factory: WebSocketFactory = (_url, _protocols) => {
+      const adapter = createBrowserWsAdapter(
+        new WebSocket(uiUrl, uiProtocols) as unknown as Parameters<typeof createBrowserWsAdapter>[0]
+      )
 
-    if (isServerNotification) {
-      if (message[0] === ServerNotification.REFRESH) {
-        for (const listener of this.refreshListeners) {
-          listener()
-        }
+      return {
+        close (code?: number, reason?: string): void {
+          adapter.close(code, reason)
+        },
+        get onclose () {
+          return adapter.onclose
+        },
+        set onclose (handler) {
+          adapter.onclose = event => {
+            handler?.(event)
+            useToast().info('WebSocket to UI server closed')
+            eventTarget.dispatchEvent(new Event('close'))
+          }
+        },
+        get onerror () {
+          return adapter.onerror
+        },
+        set onerror (handler) {
+          adapter.onerror = event => {
+            handler?.(event)
+            useToast().error(
+              `Error in WebSocket to UI server '${config.host}:${config.port.toString()}'`
+            )
+            console.error(
+              `Error in WebSocket to UI server '${config.host}:${config.port.toString()}'`,
+              event
+            )
+            eventTarget.dispatchEvent(new Event('error'))
+          }
+        },
+        get onmessage () {
+          return adapter.onmessage
+        },
+        set onmessage (handler) {
+          adapter.onmessage = handler
+        },
+        get onopen () {
+          return adapter.onopen
+        },
+        set onopen (handler) {
+          adapter.onopen = () => {
+            handler?.()
+            useToast().success(
+              `WebSocket to UI server '${config.host}:${config.port.toString()}' successfully opened`
+            )
+            eventTarget.dispatchEvent(new Event('open'))
+          }
+        },
+        get readyState () {
+          return adapter.readyState
+        },
+        send (data: string): void {
+          adapter.send(data)
+        },
       }
-      return
     }
 
-    const isProtocolResponse = message.length === 2 && validateUUID(message[0] as string)
-
-    if (isProtocolResponse) {
-      const [uuid, responsePayload] = message as ProtocolResponse
-      const responseHandler = this.responseHandlers.get(uuid)
-      if (responseHandler != null) {
-        const { procedureName, reject, resolve } = responseHandler
-        switch (responsePayload.status) {
-          case ResponseStatus.FAILURE:
-            reject(responsePayload)
-            break
-          case ResponseStatus.SUCCESS:
-            resolve(responsePayload)
-            break
-          default:
-            reject(
-              new Error(
-                // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
-                `Response status for procedure '${procedureName}' not supported: '${responsePayload.status}'`
-              )
-            )
+    return new WebSocketClient(
+      factory,
+      {
+        authentication: config.authentication,
+        host: config.host,
+        port: config.port,
+        protocol: config.protocol,
+        secure: config.secure,
+        version: config.version,
+      },
+      undefined,
+      (notification: unknown[]) => {
+        if (notification[0] === ServerNotification.REFRESH) {
+          for (const listener of this.refreshListeners) {
+            listener()
+          }
         }
-        this.responseHandlers.delete(uuid)
-      } else {
-        throw new Error(`Not a response to a request: ${JSON.stringify(message, undefined, 2)}`)
       }
-      return
-    }
-
-    useToast().error('Unknown message format')
-    console.error('Unknown message format:', message)
+    )
   }
 
   private async sendRequest (
     procedureName: ProcedureName,
     payload: RequestPayload
   ): Promise<ResponsePayload> {
-    return new Promise<ResponsePayload>((resolve, reject) => {
-      if (this.ws?.readyState === WebSocket.OPEN) {
-        const uuid = randomUUID()
-        const msg = JSON.stringify([uuid, procedureName, payload])
-        const sendTimeout = setTimeout(() => {
-          this.responseHandlers.delete(uuid)
-          reject(new Error(`Send request '${procedureName}' message: connection timeout`))
-        }, UI_WEBSOCKET_REQUEST_TIMEOUT_MS)
-        try {
-          this.ws.send(msg)
-          this.responseHandlers.set(uuid, { procedureName, reject, resolve })
-        } catch (error) {
-          this.responseHandlers.delete(uuid)
-          reject(
-            new Error(
-              `Send request '${procedureName}' message: error ${(error as Error).toString()}`
-            )
-          )
-        } finally {
-          clearTimeout(sendTimeout)
-        }
-      } else {
-        reject(new Error(`Send request '${procedureName}' message: connection closed`))
-      }
-    })
+    return this.client.sendRequest(procedureName, payload)
   }
 
   private async transactionEvent (
index 11d6f7de6c91650810eddde968d06736d740e639..a843117220797f91dddfbad30c921105273e3212 100644 (file)
@@ -122,7 +122,7 @@ export const resetToggleButtonState = (id: string, shared = false): void => {
 }
 
 export const randomUUID = (): UUIDv4 => {
-  return crypto.randomUUID()
+  return crypto.randomUUID() as UUIDv4
 }
 
 export const validateUUID = (uuid: unknown): uuid is UUIDv4 => {
index 5c00b160657417d11b9d90853b2e2f0b4eedbee8..066fc2c3f3bc4c9dff546177ca28248d262e0dae 100644 (file)
@@ -1,5 +1,3 @@
-import type { JsonObject } from './JsonType'
-
 export enum AmpereUnits {
   AMPERE = 'A',
   CENTI_AMPERE = 'cA',
@@ -323,7 +321,7 @@ export const IncomingRequestCommand = {
 export type IncomingRequestCommand = OCPP16IncomingRequestCommand
 
 export interface OCPP16BootNotificationResponse extends JsonObject {
-  currentTime: Date
+  currentTime: string
   interval: number
   status: OCPP16RegistrationStatus
 }
@@ -351,17 +349,17 @@ export interface Status extends JsonObject {
   acceptedStartTransactionRequests?: number
   acceptedStopTransactionRequests?: number
   authorizeRequests?: number
-  lastRunDate?: Date
+  lastRunDate?: string
   rejectedAuthorizeRequests?: number
   rejectedStartTransactionRequests?: number
   rejectedStopTransactionRequests?: number
   skippedConsecutiveTransactions?: number
   skippedTransactions?: number
   start?: boolean
-  startDate?: Date
+  startDate?: string
   startTransactionRequests?: number
-  stopDate?: Date
-  stoppedDate?: Date
+  stopDate?: string
+  stoppedDate?: string
   stopTransactionRequests?: number
 }
 
@@ -369,3 +367,7 @@ interface CommandsSupport extends JsonObject {
   incomingCommands: Record<IncomingRequestCommand, boolean>
   outgoingCommands?: Record<RequestCommand, boolean>
 }
+
+type JsonObject = { [key in string]?: (JsonObject | JsonPrimitive)[] | JsonObject | JsonPrimitive }
+
+type JsonPrimitive = boolean | null | number | string
index b76fc240a48e836a2fe47e9665286ea9f4129e9f..6fe0af4aeefb80a87be0c80ab8f18a3d269a580e 100644 (file)
@@ -1,4 +1,6 @@
-import type { AuthenticationType, Protocol, ProtocolVersion } from './UIProtocol'
+import type { AuthenticationType, ProtocolVersion } from 'ui-common'
+
+import type { Protocol } from './UIProtocol'
 
 export interface ConfigurationData {
   theme?: string
diff --git a/ui/web/src/types/JsonType.ts b/ui/web/src/types/JsonType.ts
deleted file mode 100644 (file)
index e0968de..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-export type JsonObject = {
-  [key in string]?: (JsonObject | JsonPrimitive)[] | JsonObject | JsonPrimitive
-}
-export type JsonType = JsonObject | JsonPrimitive | JsonType[]
-type JsonPrimitive = boolean | Date | null | number | string
index 0db84acf2ec75cf6e896f5d7542ebb6842652e61..c97fbfccce9ddee27eb6b152971aa6129ab2c193 100644 (file)
@@ -1,91 +1,19 @@
-import type { JsonObject } from './JsonType'
-import type { UUIDv4 } from './UUID'
-
 export enum ApplicationProtocol {
   WS = 'ws',
   WSS = 'wss',
 }
 
-export enum AuthenticationType {
-  PROTOCOL_BASIC_AUTH = 'protocol-basic-auth',
-}
-
-export enum ProcedureName {
-  ADD_CHARGING_STATIONS = 'addChargingStations',
-  AUTHORIZE = 'authorize',
-  CLOSE_CONNECTION = 'closeConnection',
-  DELETE_CHARGING_STATIONS = 'deleteChargingStations',
-  GET_15118_EV_CERTIFICATE = 'get15118EVCertificate',
-  GET_CERTIFICATE_STATUS = 'getCertificateStatus',
-  LIST_CHARGING_STATIONS = 'listChargingStations',
-  LIST_TEMPLATES = 'listTemplates',
-  LOCK_CONNECTOR = 'lockConnector',
-  LOG_STATUS_NOTIFICATION = 'logStatusNotification',
-  NOTIFY_CUSTOMER_INFORMATION = 'notifyCustomerInformation',
-  NOTIFY_REPORT = 'notifyReport',
-  OPEN_CONNECTION = 'openConnection',
-  SECURITY_EVENT_NOTIFICATION = 'securityEventNotification',
-  SET_SUPERVISION_URL = 'setSupervisionUrl',
-  SIGN_CERTIFICATE = 'signCertificate',
-  SIMULATOR_STATE = 'simulatorState',
-  START_AUTOMATIC_TRANSACTION_GENERATOR = 'startAutomaticTransactionGenerator',
-  START_CHARGING_STATION = 'startChargingStation',
-  START_SIMULATOR = 'startSimulator',
-  START_TRANSACTION = 'startTransaction',
-  STOP_AUTOMATIC_TRANSACTION_GENERATOR = 'stopAutomaticTransactionGenerator',
-  STOP_CHARGING_STATION = 'stopChargingStation',
-  STOP_SIMULATOR = 'stopSimulator',
-  STOP_TRANSACTION = 'stopTransaction',
-  TRANSACTION_EVENT = 'transactionEvent',
-  UNLOCK_CONNECTOR = 'unlockConnector',
-}
-
 export enum Protocol {
   UI = 'ui',
 }
 
-export enum ProtocolVersion {
-  '0.0.1' = '0.0.1',
-}
-
-export enum ResponseStatus {
-  FAILURE = 'failure',
-  SUCCESS = 'success',
-}
-
-export enum ServerNotification {
-  REFRESH = 'refresh',
-}
-
-export type ProtocolNotification = [ServerNotification]
-
-export type ProtocolRequest = [UUIDv4, ProcedureName, RequestPayload]
-
-export type ProtocolRequestHandler = (
-  payload: RequestPayload
-) => Promise<ResponsePayload> | ResponsePayload
-
-export type ProtocolResponse = [UUIDv4, ResponsePayload]
-
-export interface RequestPayload extends JsonObject {
-  connectorIds?: number[]
-  hashIds?: string[]
-}
-
-export interface ResponsePayload extends JsonObject {
-  hashIdsFailed?: string[]
-  hashIdsSucceeded?: string[]
-  responsesFailed?: JsonObject[]
-  status: ResponseStatus
-}
-
-export interface SimulatorState extends JsonObject {
+export interface SimulatorState {
   started: boolean
   templateStatistics: Record<string, TemplateStatistics>
   version: string
 }
 
-interface TemplateStatistics extends JsonObject {
+interface TemplateStatistics {
   added: number
   configured: number
   indexes: number[]
diff --git a/ui/web/src/types/UUID.ts b/ui/web/src/types/UUID.ts
deleted file mode 100644 (file)
index bb2a5d8..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-/**
- * UUIDv4 type representing a standard UUID format
- * Pattern: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
- * where x is any hexadecimal digit and y is one of 8, 9, A, or B
- */
-export type UUIDv4 = `${string}-${string}-${string}-${string}-${string}`
index a7861c6b2fabb15cd1ccc3d667e9de79b794c97e..d6c0b95c1c96f82c3b62ca71895301ee174ede13 100644 (file)
@@ -19,18 +19,22 @@ export {
   OCPPVersion,
 } from './ChargingStationType'
 export type { ConfigurationData, UIServerConfigurationSection } from './ConfigurationType'
+export { ApplicationProtocol, Protocol, type SimulatorState } from './UIProtocol'
 export {
-  ApplicationProtocol,
   AuthenticationType,
+  type BroadcastChannelResponsePayload,
+  type JsonObject,
+  type JsonPrimitive,
+  type JsonType,
   ProcedureName,
-  Protocol,
   type ProtocolNotification,
+  type ProtocolRequest,
+  type ProtocolRequestHandler,
   type ProtocolResponse,
   ProtocolVersion,
   type RequestPayload,
   type ResponsePayload,
   ResponseStatus,
   ServerNotification,
-  type SimulatorState,
-} from './UIProtocol'
-export type { UUIDv4 } from './UUID'
+  type UUIDv4,
+} from 'ui-common'
index aeb9df66aa13e8d5c37350ae99523d2d0a741289..6ccf73085df4620169dc0fc3d2f66f8800989b9f 100644 (file)
@@ -193,7 +193,7 @@ const getSimulatorState = (): void => {
     $uiClient
       .simulatorState()
       .then((response: ResponsePayload) => {
-        simulatorState.value = response.state as SimulatorState
+        simulatorState.value = response.state as unknown as SimulatorState
         return undefined
       })
       .finally(() => {
index 10caca441e65141c685ff701b8de8734df6e8ebc..f46f1c4361ebcc484da3ce22ec661029d70ede3f 100644 (file)
@@ -1,3 +1,4 @@
+/* eslint-disable @typescript-eslint/no-non-null-assertion */
 /**
  * @file Tests for UIClient composable
  * @description Unit tests for WebSocket client singleton, connection lifecycle,
@@ -21,6 +22,7 @@ import { MockWebSocket } from './helpers'
 
 // Reset singleton between tests
 beforeEach(() => {
+  MockWebSocket.lastInstance = null
   // @ts-expect-error — accessing private static property for testing
   UIClient.instance = null
   vi.stubGlobal('WebSocket', MockWebSocket)
@@ -28,6 +30,7 @@ beforeEach(() => {
 
 afterEach(() => {
   vi.unstubAllGlobals()
+  MockWebSocket.lastInstance = null
   // @ts-expect-error — accessing private static property for testing
   UIClient.instance = null
 })
@@ -72,23 +75,20 @@ describe('UIClient', () => {
 
   describe('WebSocket connection', () => {
     it('should connect with ws:// URL format', () => {
-      const client = UIClient.getInstance(createUIServerConfig())
-      // @ts-expect-error — accessing private property for testing
-      const ws = client.ws as MockWebSocket
+      UIClient.getInstance(createUIServerConfig())
+      const ws = MockWebSocket.lastInstance!
       expect(ws.url).toBe('ws://localhost:8080')
     })
 
     it('should connect with wss:// when secure is true', () => {
-      const client = UIClient.getInstance(createUIServerConfig({ secure: true }))
-      // @ts-expect-error — accessing private property for testing
-      const ws = client.ws as MockWebSocket
+      UIClient.getInstance(createUIServerConfig({ secure: true }))
+      const ws = MockWebSocket.lastInstance!
       expect(ws.url).toBe('wss://localhost:8080')
     })
 
     it('should use protocol version as subprotocol without auth', () => {
-      const client = UIClient.getInstance(createUIServerConfig())
-      // @ts-expect-error — accessing private property for testing
-      const ws = client.ws as MockWebSocket
+      UIClient.getInstance(createUIServerConfig())
+      const ws = MockWebSocket.lastInstance!
       expect(ws.protocols).toBe('ui0.0.1')
     })
 
@@ -101,9 +101,8 @@ describe('UIClient', () => {
           username: 'user',
         },
       })
-      const client = UIClient.getInstance(config)
-      // @ts-expect-error — accessing private property for testing
-      const ws = client.ws as MockWebSocket
+      UIClient.getInstance(config)
+      const ws = MockWebSocket.lastInstance!
       expect(ws.protocols).toBeInstanceOf(Array)
       const protocols = ws.protocols as string[]
       expect(protocols[0]).toBe('ui0.0.1')
@@ -111,30 +110,28 @@ describe('UIClient', () => {
     })
 
     it('should show success toast on WebSocket open', () => {
-      const client = UIClient.getInstance(createUIServerConfig())
-      // @ts-expect-error — accessing private property for testing
-      const ws = client.ws as MockWebSocket
+      UIClient.getInstance(createUIServerConfig())
+      const ws = MockWebSocket.lastInstance!
       ws.simulateOpen()
       expect(toastMock.success).toHaveBeenCalledWith(expect.stringContaining('successfully opened'))
     })
 
     it('should log error on WebSocket error', () => {
       const consoleSpy = vi.spyOn(console, 'error')
-      const client = UIClient.getInstance(createUIServerConfig())
-      // @ts-expect-error — accessing private property for testing
-      const ws = client.ws as MockWebSocket
+      UIClient.getInstance(createUIServerConfig())
+      const ws = MockWebSocket.lastInstance!
       ws.simulateError()
 
       expect(consoleSpy).toHaveBeenCalledWith(
         expect.stringContaining('Error in WebSocket'),
-        expect.any(Event)
+        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+        expect.objectContaining({ error: expect.any(Error), message: expect.any(String) })
       )
     })
 
     it('should handle WebSocket close event', () => {
-      const client = UIClient.getInstance(createUIServerConfig())
-      // @ts-expect-error — accessing private property for testing
-      const ws = client.ws as MockWebSocket
+      UIClient.getInstance(createUIServerConfig())
+      const ws = MockWebSocket.lastInstance!
       ws.simulateClose()
     })
   })
@@ -142,12 +139,11 @@ describe('UIClient', () => {
   describe('request/response handling', () => {
     it('should resolve promise on SUCCESS response', async () => {
       const client = UIClient.getInstance(createUIServerConfig())
-      // @ts-expect-error — accessing private property for testing
-      const ws = client.ws as MockWebSocket
+      const ws = MockWebSocket.lastInstance!
       ws.simulateOpen()
 
       const promise = client.listChargingStations()
-      const [uuid] = JSON.parse(ws.sentMessages[0]) as [string]
+      const uuid = (JSON.parse(ws.sentMessages[0]) as string[])[0]
       ws.simulateMessage([uuid, { status: ResponseStatus.SUCCESS }])
 
       const result = await promise
@@ -156,105 +152,83 @@ describe('UIClient', () => {
 
     it('should reject promise on FAILURE response', async () => {
       const client = UIClient.getInstance(createUIServerConfig())
-      // @ts-expect-error — accessing private property for testing
-      const ws = client.ws as MockWebSocket
+      const ws = MockWebSocket.lastInstance!
       ws.simulateOpen()
 
       const promise = client.listChargingStations()
-      const [uuid] = JSON.parse(ws.sentMessages[0]) as [string]
+      const uuid = (JSON.parse(ws.sentMessages[0]) as string[])[0]
       ws.simulateMessage([uuid, { status: ResponseStatus.FAILURE }])
 
-      await expect(promise).rejects.toEqual(
-        expect.objectContaining({ status: ResponseStatus.FAILURE })
-      )
+      await expect(promise).rejects.toThrow(/failure status/)
     })
 
     it('should reject with Error on unknown response status', async () => {
       const client = UIClient.getInstance(createUIServerConfig())
-      // @ts-expect-error — accessing private property for testing
-      const ws = client.ws as MockWebSocket
+      const ws = MockWebSocket.lastInstance!
       ws.simulateOpen()
 
       const promise = client.listChargingStations()
-      const [uuid] = JSON.parse(ws.sentMessages[0]) as [string]
+      const uuid = (JSON.parse(ws.sentMessages[0]) as string[])[0]
       ws.simulateMessage([uuid, { status: 'unknown' }])
 
-      await expect(promise).rejects.toThrow(/not supported/)
+      await expect(promise).rejects.toThrow(/failure status/)
     })
 
     it('should reject when WebSocket is not open', async () => {
       const client = UIClient.getInstance(createUIServerConfig())
-      // @ts-expect-error — accessing private property for testing
-      const ws = client.ws as MockWebSocket
-      ws.readyState = WebSocket.CLOSED
 
-      await expect(client.listChargingStations()).rejects.toThrow('connection closed')
+      await expect(client.listChargingStations()).rejects.toThrow('WebSocket is not open')
     })
 
     it('should reject when ws.send throws', async () => {
       const client = UIClient.getInstance(createUIServerConfig())
-      // @ts-expect-error — accessing private property for testing
-      const ws = client.ws as MockWebSocket
+      const ws = MockWebSocket.lastInstance!
       ws.simulateOpen()
       ws.send.mockImplementation(() => {
         throw new Error('send failed')
       })
 
-      await expect(client.startSimulator()).rejects.toThrow('error Error: send failed')
+      await expect(client.startSimulator()).rejects.toThrow('send failed')
     })
 
     it('should handle invalid JSON response gracefully', () => {
       const consoleSpy = vi.spyOn(console, 'error')
-      const client = UIClient.getInstance(createUIServerConfig())
-      // @ts-expect-error — accessing private property for testing
-      const ws = client.ws as MockWebSocket
+      UIClient.getInstance(createUIServerConfig())
+      const ws = MockWebSocket.lastInstance!
 
       ws.onmessage?.({ data: 'not json' } as MessageEvent<string>)
 
-      expect(toastMock.error).toHaveBeenCalledWith('Invalid response JSON format')
-      expect(consoleSpy).toHaveBeenCalledWith(
-        'Invalid response JSON format',
-        expect.any(SyntaxError)
-      )
+      expect(toastMock.error).not.toHaveBeenCalled()
+      expect(consoleSpy).not.toHaveBeenCalled()
     })
 
     it('should handle non-array response gracefully', () => {
       const consoleSpy = vi.spyOn(console, 'error')
-      const client = UIClient.getInstance(createUIServerConfig())
-      // @ts-expect-error — accessing private property for testing
-      const ws = client.ws as MockWebSocket
+      UIClient.getInstance(createUIServerConfig())
+      const ws = MockWebSocket.lastInstance!
 
       ws.simulateMessage({ notAnArray: true })
 
-      expect(toastMock.error).toHaveBeenCalledWith('Response not an array')
-      expect(consoleSpy).toHaveBeenCalledWith(
-        'Response not an array:',
-        expect.objectContaining({ notAnArray: true })
-      )
+      expect(toastMock.error).not.toHaveBeenCalled()
+      expect(consoleSpy).not.toHaveBeenCalled()
     })
 
-    it('should throw on response with unknown UUID', () => {
-      const client = UIClient.getInstance(createUIServerConfig())
-      // @ts-expect-error — accessing private property for testing
-      const ws = client.ws as MockWebSocket
+    it('should silently ignore response with unknown UUID', () => {
+      UIClient.getInstance(createUIServerConfig())
+      const ws = MockWebSocket.lastInstance!
 
       const fakeUUID = crypto.randomUUID()
-      expect(() => {
-        ws.simulateMessage([fakeUUID, { status: ResponseStatus.SUCCESS }])
-      }).toThrow('Not a response to a request')
+      ws.simulateMessage([fakeUUID, { status: ResponseStatus.SUCCESS }])
     })
 
-    it('should show error toast on response with invalid UUID', () => {
+    it('should silently ignore response with invalid UUID', () => {
       const consoleSpy = vi.spyOn(console, 'error')
-      const client = UIClient.getInstance(createUIServerConfig())
-      // @ts-expect-error — accessing private property for testing
-      const ws = client.ws as MockWebSocket
+      UIClient.getInstance(createUIServerConfig())
+      const ws = MockWebSocket.lastInstance!
       ws.simulateMessage(['not-a-valid-uuid', { status: ResponseStatus.SUCCESS }])
-      expect(toastMock.error).toHaveBeenCalledWith('Unknown message format')
-      expect(consoleSpy).toHaveBeenCalledWith(
-        'Unknown message format:',
-        expect.arrayContaining(['not-a-valid-uuid'])
-      )
+
+      expect(toastMock.error).not.toHaveBeenCalled()
+      expect(consoleSpy).not.toHaveBeenCalled()
     })
   })
 
@@ -263,8 +237,7 @@ describe('UIClient', () => {
       const client = UIClient.getInstance(createUIServerConfig())
       const listener = vi.fn()
       client.onRefresh(listener)
-      // @ts-expect-error — accessing private property for testing
-      const ws = client.ws as MockWebSocket
+      const ws = MockWebSocket.lastInstance!
       ws.simulateMessage([ServerNotification.REFRESH])
       expect(listener).toHaveBeenCalledOnce()
     })
@@ -274,8 +247,7 @@ describe('UIClient', () => {
       const listener = vi.fn()
       const unsubscribe = client.onRefresh(listener)
       unsubscribe()
-      // @ts-expect-error — accessing private property for testing
-      const ws = client.ws as MockWebSocket
+      const ws = MockWebSocket.lastInstance!
       ws.simulateMessage([ServerNotification.REFRESH])
       expect(listener).not.toHaveBeenCalled()
     })
@@ -435,39 +407,38 @@ describe('UIClient', () => {
   describe('event listener management', () => {
     it('should register WebSocket event listener', () => {
       const client = UIClient.getInstance(createUIServerConfig())
-      // @ts-expect-error — accessing private property for testing
-      const ws = client.ws as MockWebSocket
+      const ws = MockWebSocket.lastInstance!
       const listener = vi.fn()
 
-      client.registerWSEventListener('message', listener)
+      client.registerWSEventListener('open', listener)
+      ws.simulateOpen()
 
-      expect(ws.addEventListener).toHaveBeenCalledWith('message', listener, undefined)
+      expect(listener).toHaveBeenCalledOnce()
     })
 
     it('should unregister WebSocket event listener', () => {
       const client = UIClient.getInstance(createUIServerConfig())
-      // @ts-expect-error — accessing private property for testing
-      const ws = client.ws as MockWebSocket
+      const ws = MockWebSocket.lastInstance!
       const listener = vi.fn()
 
-      client.unregisterWSEventListener('message', listener)
+      client.registerWSEventListener('open', listener)
+      client.unregisterWSEventListener('open', listener)
+      ws.simulateOpen()
 
-      expect(ws.removeEventListener).toHaveBeenCalledWith('message', listener, undefined)
+      expect(listener).not.toHaveBeenCalled()
     })
   })
 
   describe('setConfiguration', () => {
     it('should close existing WebSocket and open new connection', () => {
       const client = UIClient.getInstance(createUIServerConfig())
-      // @ts-expect-error — accessing private property for testing
-      const oldWs = client.ws as MockWebSocket
+      const oldWs = MockWebSocket.lastInstance!
       oldWs.simulateOpen()
 
       client.setConfiguration(createUIServerConfig({ port: 9090 }))
 
       expect(oldWs.close).toHaveBeenCalled()
-      // @ts-expect-error — accessing private property for testing
-      const newWs = client.ws as MockWebSocket
+      const newWs = MockWebSocket.lastInstance!
       expect(newWs).not.toBe(oldWs)
       expect(newWs.url).toBe('ws://localhost:9090')
     })
index bb77111df46235435b612a193f04e983970f3a55..17beb67fbddf205c41812cbb7fac35adda7c0ab6 100644 (file)
@@ -36,7 +36,7 @@ export function createChargingStationData (
 ): ChargingStationData {
   return {
     bootNotificationResponse: {
-      currentTime: new Date('2024-01-01T00:00:00Z'),
+      currentTime: '2024-01-01T00:00:00.000Z',
       interval: 60,
       status: OCPP16RegistrationStatus.ACCEPTED,
     },
index ed58cfee34d26b34c3a68dfe05044b9387fc763a..440af8170cb5ec2c23426b8c3f44e52b0a7b4244 100644 (file)
@@ -77,6 +77,7 @@ export class MockWebSocket {
   static readonly CLOSED = 3
   static readonly CLOSING = 2
   static readonly CONNECTING = 0
+  static lastInstance: MockWebSocket | null = null
   static readonly OPEN = 1
 
   addEventListener: ReturnType<typeof vi.fn>
@@ -98,6 +99,7 @@ export class MockWebSocket {
     public readonly url = '',
     public readonly protocols: string | string[] = []
   ) {
+    MockWebSocket.lastInstance = this
     this.addEventListener = vi.fn()
     this.close = vi.fn()
     this.removeEventListener = vi.fn()