----
-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:
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)
"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"
+import { createBrowserWsAdapter, WebSocketClient, type WebSocketFactory } from 'ui-common'
import { useToast } from 'vue-toast-notification'
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 {
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> {
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 (
}
export const randomUUID = (): UUIDv4 => {
- return crypto.randomUUID()
+ return crypto.randomUUID() as UUIDv4
}
export const validateUUID = (uuid: unknown): uuid is UUIDv4 => {
-import type { JsonObject } from './JsonType'
-
export enum AmpereUnits {
AMPERE = 'A',
CENTI_AMPERE = 'cA',
export type IncomingRequestCommand = OCPP16IncomingRequestCommand
export interface OCPP16BootNotificationResponse extends JsonObject {
- currentTime: Date
+ currentTime: string
interval: number
status: OCPP16RegistrationStatus
}
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
}
incomingCommands: Record<IncomingRequestCommand, boolean>
outgoingCommands?: Record<RequestCommand, boolean>
}
+
+type JsonObject = { [key in string]?: (JsonObject | JsonPrimitive)[] | JsonObject | JsonPrimitive }
+
+type JsonPrimitive = boolean | null | number | string
-import type { AuthenticationType, Protocol, ProtocolVersion } from './UIProtocol'
+import type { AuthenticationType, ProtocolVersion } from 'ui-common'
+
+import type { Protocol } from './UIProtocol'
export interface ConfigurationData {
theme?: string
+++ /dev/null
-export type JsonObject = {
- [key in string]?: (JsonObject | JsonPrimitive)[] | JsonObject | JsonPrimitive
-}
-export type JsonType = JsonObject | JsonPrimitive | JsonType[]
-type JsonPrimitive = boolean | Date | null | number | string
-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[]
+++ /dev/null
-/**
- * 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}`
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'
$uiClient
.simulatorState()
.then((response: ResponsePayload) => {
- simulatorState.value = response.state as SimulatorState
+ simulatorState.value = response.state as unknown as SimulatorState
return undefined
})
.finally(() => {
+/* eslint-disable @typescript-eslint/no-non-null-assertion */
/**
* @file Tests for UIClient composable
* @description Unit tests for WebSocket client singleton, connection lifecycle,
// Reset singleton between tests
beforeEach(() => {
+ MockWebSocket.lastInstance = null
// @ts-expect-error — accessing private static property for testing
UIClient.instance = null
vi.stubGlobal('WebSocket', MockWebSocket)
afterEach(() => {
vi.unstubAllGlobals()
+ MockWebSocket.lastInstance = null
// @ts-expect-error — accessing private static property for testing
UIClient.instance = null
})
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')
})
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')
})
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()
})
})
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
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()
})
})
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()
})
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()
})
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')
})
): ChargingStationData {
return {
bootNotificationResponse: {
- currentTime: new Date('2024-01-01T00:00:00Z'),
+ currentTime: '2024-01-01T00:00:00.000Z',
interval: 60,
status: OCPP16RegistrationStatus.ACCEPTED,
},
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>
public readonly url = '',
public readonly protocols: string | string[] = []
) {
+ MockWebSocket.lastInstance = this
this.addEventListener = vi.fn()
this.close = vi.fn()
this.removeEventListener = vi.fn()