From: Jérôme Benoit Date: Wed, 15 Apr 2026 22:48:39 +0000 (+0200) Subject: refactor(web): migrate types to ui-common + UIClient internals to WebSocketClient X-Git-Tag: v4.5~79 X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=2f44afef5abc491c3bfe600efe9b6a87be0522d2;p=e-mobility-charging-stations-simulator.git refactor(web): migrate types to ui-common + UIClient internals to WebSocketClient --- diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9cded6a..4394207a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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) diff --git a/ui/web/package.json b/ui/web/package.json index 589df153..d0300ef3 100644 --- a/ui/web/package.json +++ b/ui/web/package.json @@ -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" diff --git a/ui/web/src/composables/UIClient.ts b/ui/web/src/composables/UIClient.ts index fabef626..15623b5a 100644 --- a/ui/web/src/composables/UIClient.ts +++ b/ui/web/src/composables/UIClient.ts @@ -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) => void -} - export class UIClient { private static instance: null | UIClient = null + private client: WebSocketClient private readonly refreshListeners: Set<() => void> - private responseHandlers: Map - private ws?: WebSocket + private uiServerConfiguration: UIServerConfigurationSection + private readonly wsEventTarget: EventTarget - private constructor (private uiServerConfiguration: UIServerConfigurationSection) { - this.openWS() - this.responseHandlers = new Map() + 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 { @@ -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): 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[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 { - return new Promise((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 ( diff --git a/ui/web/src/composables/Utils.ts b/ui/web/src/composables/Utils.ts index 11d6f7de..a8431172 100644 --- a/ui/web/src/composables/Utils.ts +++ b/ui/web/src/composables/Utils.ts @@ -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 => { diff --git a/ui/web/src/types/ChargingStationType.ts b/ui/web/src/types/ChargingStationType.ts index 5c00b160..066fc2c3 100644 --- a/ui/web/src/types/ChargingStationType.ts +++ b/ui/web/src/types/ChargingStationType.ts @@ -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 outgoingCommands?: Record } + +type JsonObject = { [key in string]?: (JsonObject | JsonPrimitive)[] | JsonObject | JsonPrimitive } + +type JsonPrimitive = boolean | null | number | string diff --git a/ui/web/src/types/ConfigurationType.ts b/ui/web/src/types/ConfigurationType.ts index b76fc240..6fe0af4a 100644 --- a/ui/web/src/types/ConfigurationType.ts +++ b/ui/web/src/types/ConfigurationType.ts @@ -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 index e0968de6..00000000 --- a/ui/web/src/types/JsonType.ts +++ /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 diff --git a/ui/web/src/types/UIProtocol.ts b/ui/web/src/types/UIProtocol.ts index 0db84acf..c97fbfcc 100644 --- a/ui/web/src/types/UIProtocol.ts +++ b/ui/web/src/types/UIProtocol.ts @@ -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 - -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 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 index bb2a5d87..00000000 --- a/ui/web/src/types/UUID.ts +++ /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}` diff --git a/ui/web/src/types/index.ts b/ui/web/src/types/index.ts index a7861c6b..d6c0b95c 100644 --- a/ui/web/src/types/index.ts +++ b/ui/web/src/types/index.ts @@ -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' diff --git a/ui/web/src/views/ChargingStationsView.vue b/ui/web/src/views/ChargingStationsView.vue index aeb9df66..6ccf7308 100644 --- a/ui/web/src/views/ChargingStationsView.vue +++ b/ui/web/src/views/ChargingStationsView.vue @@ -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(() => { diff --git a/ui/web/tests/unit/UIClient.test.ts b/ui/web/tests/unit/UIClient.test.ts index 10caca44..f46f1c43 100644 --- a/ui/web/tests/unit/UIClient.test.ts +++ b/ui/web/tests/unit/UIClient.test.ts @@ -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) - 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') }) diff --git a/ui/web/tests/unit/constants.ts b/ui/web/tests/unit/constants.ts index bb77111d..17beb67f 100644 --- a/ui/web/tests/unit/constants.ts +++ b/ui/web/tests/unit/constants.ts @@ -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, }, diff --git a/ui/web/tests/unit/helpers.ts b/ui/web/tests/unit/helpers.ts index ed58cfee..440af817 100644 --- a/ui/web/tests/unit/helpers.ts +++ b/ui/web/tests/unit/helpers.ts @@ -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 @@ -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()