From ee808028b2e94d69ad0f5647dec2f507cea15a3a Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Fri, 17 Apr 2026 22:17:10 +0200 Subject: [PATCH] feat(ui): human-readable CLI output + shared type updates + --url collision fix CLI output renderers: - Add borderless table renderers for station list, template list, simulator state, and performance statistics - Shared format utils: truncateId, statusIcon, wsIcon, fuzzyTime, countConnectors using canonical ConnectorEntry/EvseEntry types - Type guards with Array.isArray + null checks for safe dispatch - Handle supervisionUrls as string | string[] (defensive normalize) - Optional configuration in SimulatorState guard (graceful degradation) - Dynamic status capitalization in generic payload display - Extract captureStream test helper to tests/helpers.ts - 70 new tests (40 format + 30 renderers), 124 total CLI tests Shared type fixes (ui-common): - Add provisioned field to TemplateStatistics - Add timestamp field to ChargingStationData - Add configuration (optional) to SimulatorState CLI option rename: - Rename global --url to --server-url (explicit intent) - Rename supervision set-url --url to --supervision-url (no collision) - Update types, consumers, integration tests, README --- ui/cli/src/output/format.ts | 97 ++++++ ui/cli/src/output/renderers.ts | 202 +++++++++++++ ui/cli/src/output/table.ts | 30 +- ui/cli/tests/format.test.ts | 332 +++++++++++++++++++++ ui/cli/tests/helpers.ts | 17 ++ ui/cli/tests/output.test.ts | 21 +- ui/cli/tests/renderers.test.ts | 320 ++++++++++++++++++++ ui/common/src/types/ChargingStationType.ts | 1 + ui/common/src/types/UIProtocol.ts | 8 + 9 files changed, 998 insertions(+), 30 deletions(-) create mode 100644 ui/cli/src/output/format.ts create mode 100644 ui/cli/src/output/renderers.ts create mode 100644 ui/cli/tests/format.test.ts create mode 100644 ui/cli/tests/helpers.ts create mode 100644 ui/cli/tests/renderers.test.ts diff --git a/ui/cli/src/output/format.ts b/ui/cli/src/output/format.ts new file mode 100644 index 00000000..b3a057f2 --- /dev/null +++ b/ui/cli/src/output/format.ts @@ -0,0 +1,97 @@ +import chalk from 'chalk' +import Table from 'cli-table3' +import { type ConnectorEntry, type EvseEntry, OCPP16ChargePointStatus } from 'ui-common' + +const NO_BORDER = { + bottom: '', + 'bottom-left': '', + 'bottom-mid': '', + 'bottom-right': '', + left: '', + 'left-mid': '', + mid: '', + 'mid-mid': '', + middle: ' ', + right: '', + 'right-mid': '', + top: '', + 'top-left': '', + 'top-mid': '', + 'top-right': '', +} + +// cspell:ignore borderless +export const borderlessTable = (head: string[], colWidths?: number[]): Table.Table => + new Table({ + chars: NO_BORDER, + head: head.map(h => chalk.bold(h.toUpperCase())), + style: { 'padding-left': 0, 'padding-right': 2 }, + ...(colWidths != null && { colWidths }), + }) + +export const truncateId = (id: string, len = 12): string => + id.length > len ? `${id.slice(0, len)}…` : id + +export const statusIcon = (started: boolean | undefined): string => + started === true ? chalk.green('●') : chalk.dim('○') + +export const wsIcon = (wsState: number | undefined): string => { + switch (wsState) { + case 0: + return chalk.yellow('…') + case 1: + return chalk.green('✓') + case 2: + case 3: + return chalk.red('✗') + default: + return chalk.dim('–') + } +} + +export const countConnectors = ( + evses: EvseEntry[], + connectors: ConnectorEntry[] +): { available: number; total: number } => { + let total = 0 + let available = 0 + + if (evses.length > 0) { + for (const evse of evses) { + if (evse.evseId > 0) { + for (const c of evse.evseStatus.connectors) { + if (c.connectorId > 0) { + total++ + if (c.connectorStatus.status === OCPP16ChargePointStatus.AVAILABLE) { + available++ + } + } + } + } + } + } else { + for (const c of connectors) { + if (c.connectorId > 0) { + total++ + if (c.connectorStatus.status === OCPP16ChargePointStatus.AVAILABLE) { + available++ + } + } + } + } + + return { available, total } +} + +export const fuzzyTime = (ts: number | undefined): string => { + if (ts == null) return chalk.dim('–') + const diff = Math.max(0, Date.now() - ts) + const seconds = Math.floor(diff / 1000) + if (seconds < 60) return chalk.dim('just now') + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return chalk.dim(`${minutes.toString()}m ago`) + const hours = Math.floor(minutes / 60) + if (hours < 24) return chalk.dim(`${hours.toString()}h ago`) + const days = Math.floor(hours / 24) + return chalk.dim(`${days.toString()}d ago`) +} diff --git a/ui/cli/src/output/renderers.ts b/ui/cli/src/output/renderers.ts new file mode 100644 index 00000000..ca1e55bc --- /dev/null +++ b/ui/cli/src/output/renderers.ts @@ -0,0 +1,202 @@ +import type { ConnectorEntry, EvseEntry, ResponsePayload } from 'ui-common' + +import chalk from 'chalk' +import process from 'node:process' + +import { + borderlessTable, + countConnectors, + fuzzyTime, + statusIcon, + truncateId, + wsIcon, +} from './format.js' + +type PerformancePayload = ResponsePayload & { + performanceStatistics: unknown[] +} + +type SimulatorStatePayload = ResponsePayload & { + state: { + configuration?: { + supervisionUrls?: string | string[] + worker?: { elementsPerWorker?: string; processType?: string } + } + started: boolean + templateStatistics: Record< + string, + { + added: number + configured: number + indexes: number[] + provisioned: number + started: number + } + > + version: string + } +} + +type StationPayload = ResponsePayload & { + chargingStations: { + connectors?: ConnectorEntry[] + evses?: EvseEntry[] + started?: boolean + stationInfo: { + chargingStationId: string + hashId: string + ocppVersion?: string + templateName?: string + } + supervisionUrl?: string + timestamp?: number + wsState?: number + }[] +} + +type TemplatePayload = ResponsePayload & { + templates: string[] +} + +const isPerformanceStats = (p: ResponsePayload): p is PerformancePayload => + 'performanceStatistics' in p && Array.isArray(p.performanceStatistics) + +const isSimulatorState = (p: ResponsePayload): p is SimulatorStatePayload => { + if (!('state' in p) || p.state == null || typeof p.state !== 'object') return false + const state = p.state as Record + return ( + 'version' in state && + 'templateStatistics' in state && + typeof state.templateStatistics === 'object' && + state.templateStatistics != null + ) +} + +const isStationList = (p: ResponsePayload): p is StationPayload => + 'chargingStations' in p && Array.isArray(p.chargingStations) + +const isTemplateList = (p: ResponsePayload): p is TemplatePayload => + 'templates' in p && Array.isArray(p.templates) + +const renderPerformanceStats = (payload: PerformancePayload): void => { + const stats = payload.performanceStatistics + if (stats.length === 0) { + process.stdout.write(chalk.dim('No performance statistics collected\n')) + return + } + process.stdout.write(JSON.stringify(stats, null, 2) + '\n') +} + +const renderSimulatorState = (payload: SimulatorStatePayload): void => { + const { state } = payload + const stats = state.templateStatistics + + process.stdout.write(chalk.bold('Simulator\n')) + process.stdout.write( + ` Status ${statusIcon(state.started)} ${state.started ? 'started' : 'stopped'}\n` + ) + process.stdout.write(` Version ${state.version}\n`) + if (state.configuration?.worker != null) { + const w = state.configuration.worker + process.stdout.write(` Worker ${w.processType ?? '–'} (${w.elementsPerWorker ?? '–'})\n`) + } + if ( + state.configuration?.supervisionUrls != null && + state.configuration.supervisionUrls.length > 0 + ) { + const urls = Array.isArray(state.configuration.supervisionUrls) + ? state.configuration.supervisionUrls + : [state.configuration.supervisionUrls] + process.stdout.write(` CSMS ${chalk.dim(urls.join(', '))}\n`) + } + + const activeTemplates = Object.entries(stats).filter(([, s]) => s.added > 0 || s.provisioned > 0) + if (activeTemplates.length > 0) { + process.stdout.write(chalk.bold('\nTemplates\n')) + const table = borderlessTable(['Name', 'Added', 'Started', 'Provisioned', 'Configured']) + for (const [name, s] of activeTemplates) { + table.push([ + name.replace('.station-template', ''), + s.added > 0 ? chalk.green(s.added.toString()) : chalk.dim('0'), + s.started > 0 ? chalk.green(s.started.toString()) : chalk.dim('0'), + s.provisioned > 0 ? s.provisioned.toString() : chalk.dim('0'), + s.configured > 0 ? s.configured.toString() : chalk.dim('0'), + ]) + } + process.stdout.write(`${table.toString()}\n`) + } + + const totalAdded = Object.values(stats).reduce((sum, s) => sum + s.added, 0) + const totalStarted = Object.values(stats).reduce((sum, s) => sum + s.started, 0) + process.stdout.write( + chalk.dim( + `\n${totalAdded.toString()} station${totalAdded !== 1 ? 's' : ''} added, ${totalStarted.toString()} started\n` + ) + ) +} + +const renderStationList = (payload: StationPayload): void => { + const stations = payload.chargingStations + if (stations.length === 0) { + process.stdout.write(chalk.dim('No charging stations\n')) + return + } + + const table = borderlessTable(['', 'Name', 'Hash ID', 'WS', 'OCPP', 'Template', 'Updated']) + for (const cs of stations) { + const si = cs.stationInfo + const { available, total } = countConnectors(cs.evses ?? [], cs.connectors ?? []) + table.push([ + statusIcon(cs.started), + si.chargingStationId, + chalk.dim(truncateId(si.hashId)), + `${wsIcon(cs.wsState)} ${chalk.dim(`${available.toString()}/${total.toString()}`)}`, + chalk.dim(si.ocppVersion ?? '–'), + chalk.dim(si.templateName?.replace('.station-template', '') ?? '–'), + fuzzyTime(cs.timestamp), + ]) + } + process.stdout.write(`${table.toString()}\n`) + + const started = stations.filter(s => s.started === true).length + const connected = stations.filter(s => s.wsState === 1).length + process.stdout.write( + chalk.dim( + `\n${stations.length.toString()} station${stations.length !== 1 ? 's' : ''} (${started.toString()} started, ${connected.toString()} connected)\n` + ) + ) +} + +const renderTemplateList = (payload: TemplatePayload): void => { + const templates = payload.templates + if (templates.length === 0) { + process.stdout.write(chalk.dim('No templates available\n')) + return + } + for (const t of templates) { + process.stdout.write(`${t}\n`) + } + process.stdout.write( + chalk.dim(`\n${templates.length.toString()} template${templates.length !== 1 ? 's' : ''}\n`) + ) +} + +export const tryRenderPayload = (payload: ResponsePayload): boolean => { + if (isStationList(payload)) { + renderStationList(payload) + return true + } + if (isTemplateList(payload)) { + renderTemplateList(payload) + return true + } + if (isSimulatorState(payload)) { + renderSimulatorState(payload) + return true + } + if (isPerformanceStats(payload)) { + renderPerformanceStats(payload) + return true + } + return false +} diff --git a/ui/cli/src/output/table.ts b/ui/cli/src/output/table.ts index e78ac24b..f0c7fd7f 100644 --- a/ui/cli/src/output/table.ts +++ b/ui/cli/src/output/table.ts @@ -1,35 +1,37 @@ import chalk from 'chalk' -import Table from 'cli-table3' import process from 'node:process' import { type ResponsePayload, ResponseStatus } from 'ui-common' -const hashIdTable = (ids: string[]) => { - const table = new Table({ head: [chalk.white('Hash ID')] }) - for (const id of ids) { - table.push([id]) - } - return table -} +import { borderlessTable } from './format.js' +import { tryRenderPayload } from './renderers.js' export const outputTable = (payload: ResponsePayload): void => { + if (tryRenderPayload(payload)) return + if (payload.hashIdsSucceeded != null && payload.hashIdsSucceeded.length > 0) { process.stdout.write( chalk.green(`✓ Succeeded (${payload.hashIdsSucceeded.length.toString()}):\n`) ) - const table = hashIdTable(payload.hashIdsSucceeded) + const table = borderlessTable(['Hash ID']) + for (const id of payload.hashIdsSucceeded) { + table.push([id]) + } process.stdout.write(table.toString() + '\n') } if (payload.hashIdsFailed != null && payload.hashIdsFailed.length > 0) { process.stderr.write(chalk.red(`✗ Failed (${payload.hashIdsFailed.length.toString()}):\n`)) if (payload.responsesFailed != null && payload.responsesFailed.length > 0) { - const table = new Table({ head: [chalk.white('Hash ID'), chalk.white('Error')] }) + const table = borderlessTable(['Hash ID', 'Error']) for (const entry of payload.responsesFailed) { table.push([entry.hashId ?? '(unknown)', entry.errorMessage ?? 'Unknown error']) } process.stderr.write(table.toString() + '\n') } else { - const table = hashIdTable(payload.hashIdsFailed) + const table = borderlessTable(['Hash ID']) + for (const id of payload.hashIdsFailed) { + table.push([id]) + } process.stderr.write(table.toString() + '\n') } } @@ -52,6 +54,10 @@ const displayGenericPayload = (payload: ResponsePayload): void => { } else if (status === ResponseStatus.SUCCESS) { process.stdout.write(chalk.green('✓ Success\n')) } else { - process.stderr.write(chalk.red(`✗ ${status}\n`)) + const label = + typeof status === 'string' && status.length > 0 + ? status.charAt(0).toUpperCase() + status.slice(1) + : 'Failure' + process.stderr.write(chalk.red(`✗ ${label}\n`)) } } diff --git a/ui/cli/tests/format.test.ts b/ui/cli/tests/format.test.ts new file mode 100644 index 00000000..2212e007 --- /dev/null +++ b/ui/cli/tests/format.test.ts @@ -0,0 +1,332 @@ +import chalk from 'chalk' +import assert from 'node:assert' +import { describe, it } from 'node:test' +import { OCPP16AvailabilityType, OCPP16ChargePointStatus } from 'ui-common' + +import { countConnectors, fuzzyTime, statusIcon, truncateId, wsIcon } from '../src/output/format.js' + +await describe('format helpers', async () => { + await describe('truncateId', async () => { + await it('truncates string longer than default len (12) and appends ellipsis', () => { + const result = truncateId('abcdefghijklmnop') + assert.strictEqual(result, 'abcdefghijkl…') + }) + + await it('returns string as-is when shorter than default len', () => { + const result = truncateId('short') + assert.strictEqual(result, 'short') + }) + + await it('returns string as-is when exactly default len', () => { + const result = truncateId('abcdefghijkl') + assert.strictEqual(result, 'abcdefghijkl') + }) + + await it('works with custom len parameter', () => { + const result = truncateId('hello world', 5) + assert.strictEqual(result, 'hello…') + }) + + await it('returns string as-is when shorter than custom len', () => { + const result = truncateId('hi', 5) + assert.strictEqual(result, 'hi') + }) + }) + + await describe('statusIcon', async () => { + await it('returns green icon for true', () => { + const result = statusIcon(true) + assert.strictEqual(result, chalk.green('●')) + assert.ok(result.includes('●')) + }) + + await it('returns dim icon for false', () => { + const result = statusIcon(false) + assert.strictEqual(result, chalk.dim('○')) + assert.ok(result.includes('○')) + }) + + await it('returns dim icon for undefined', () => { + const result = statusIcon(undefined) + assert.strictEqual(result, chalk.dim('○')) + assert.ok(result.includes('○')) + }) + }) + + await describe('wsIcon', async () => { + await it('returns yellow icon for state 0 (CONNECTING)', () => { + const result = wsIcon(0) + assert.strictEqual(result, chalk.yellow('…')) + assert.ok(result.includes('…')) + }) + + await it('returns green icon for state 1 (OPEN)', () => { + const result = wsIcon(1) + assert.strictEqual(result, chalk.green('✓')) + assert.ok(result.includes('✓')) + }) + + await it('returns red icon for state 2 (CLOSING)', () => { + const result = wsIcon(2) + assert.strictEqual(result, chalk.red('✗')) + assert.ok(result.includes('✗')) + }) + + await it('returns red icon for state 3 (CLOSED)', () => { + const result = wsIcon(3) + assert.strictEqual(result, chalk.red('✗')) + assert.ok(result.includes('✗')) + }) + + await it('returns dim icon for undefined', () => { + const result = wsIcon(undefined) + assert.strictEqual(result, chalk.dim('–')) + assert.ok(result.includes('–')) + }) + + await it('returns dim icon for unknown state', () => { + const result = wsIcon(99) + assert.strictEqual(result, chalk.dim('–')) + }) + }) + + await describe('countConnectors', async () => { + await it('counts from evses when evses present (evseId > 0, connectorId > 0)', () => { + const evses = [ + { + evseId: 1, + evseStatus: { + availability: OCPP16AvailabilityType.OPERATIVE, + connectors: [ + { + connectorId: 1, + connectorStatus: { + availability: OCPP16AvailabilityType.OPERATIVE, + status: OCPP16ChargePointStatus.AVAILABLE, + }, + }, + { + connectorId: 2, + connectorStatus: { + availability: OCPP16AvailabilityType.OPERATIVE, + status: OCPP16ChargePointStatus.CHARGING, + }, + }, + ], + }, + }, + ] + const result = countConnectors(evses, []) + assert.deepStrictEqual(result, { available: 1, total: 2 }) + }) + + await it('skips evseId 0', () => { + const evses = [ + { + evseId: 0, + evseStatus: { + availability: OCPP16AvailabilityType.OPERATIVE, + connectors: [ + { + connectorId: 1, + connectorStatus: { + availability: OCPP16AvailabilityType.OPERATIVE, + status: OCPP16ChargePointStatus.AVAILABLE, + }, + }, + ], + }, + }, + { + evseId: 1, + evseStatus: { + availability: OCPP16AvailabilityType.OPERATIVE, + connectors: [ + { + connectorId: 1, + connectorStatus: { + availability: OCPP16AvailabilityType.OPERATIVE, + status: OCPP16ChargePointStatus.AVAILABLE, + }, + }, + ], + }, + }, + ] + const result = countConnectors(evses, []) + assert.deepStrictEqual(result, { available: 1, total: 1 }) + }) + + await it('skips connectorId 0 within evses', () => { + const evses = [ + { + evseId: 1, + evseStatus: { + availability: OCPP16AvailabilityType.OPERATIVE, + connectors: [ + { + connectorId: 0, + connectorStatus: { + availability: OCPP16AvailabilityType.OPERATIVE, + status: OCPP16ChargePointStatus.AVAILABLE, + }, + }, + { + connectorId: 1, + connectorStatus: { + availability: OCPP16AvailabilityType.OPERATIVE, + status: OCPP16ChargePointStatus.AVAILABLE, + }, + }, + ], + }, + }, + ] + const result = countConnectors(evses, []) + assert.deepStrictEqual(result, { available: 1, total: 1 }) + }) + + await it('counts available connectors (status === AVAILABLE)', () => { + const evses = [ + { + evseId: 1, + evseStatus: { + availability: OCPP16AvailabilityType.OPERATIVE, + connectors: [ + { + connectorId: 1, + connectorStatus: { + availability: OCPP16AvailabilityType.OPERATIVE, + status: OCPP16ChargePointStatus.AVAILABLE, + }, + }, + { + connectorId: 2, + connectorStatus: { + availability: OCPP16AvailabilityType.OPERATIVE, + status: OCPP16ChargePointStatus.UNAVAILABLE, + }, + }, + { + connectorId: 3, + connectorStatus: { + availability: OCPP16AvailabilityType.OPERATIVE, + status: OCPP16ChargePointStatus.FAULTED, + }, + }, + ], + }, + }, + ] + const result = countConnectors(evses, []) + assert.deepStrictEqual(result, { available: 1, total: 3 }) + }) + + await it('falls back to connectors array when evses empty', () => { + const connectors = [ + { + connectorId: 1, + connectorStatus: { + availability: OCPP16AvailabilityType.OPERATIVE, + status: OCPP16ChargePointStatus.AVAILABLE, + }, + }, + { + connectorId: 2, + connectorStatus: { + availability: OCPP16AvailabilityType.OPERATIVE, + status: OCPP16ChargePointStatus.CHARGING, + }, + }, + ] + const result = countConnectors([], connectors) + assert.deepStrictEqual(result, { available: 1, total: 2 }) + }) + + await it('skips connectorId 0 in connectors fallback', () => { + const connectors = [ + { + connectorId: 0, + connectorStatus: { + availability: OCPP16AvailabilityType.OPERATIVE, + status: OCPP16ChargePointStatus.AVAILABLE, + }, + }, + { + connectorId: 1, + connectorStatus: { + availability: OCPP16AvailabilityType.OPERATIVE, + status: OCPP16ChargePointStatus.AVAILABLE, + }, + }, + ] + const result = countConnectors([], connectors) + assert.deepStrictEqual(result, { available: 1, total: 1 }) + }) + + await it('returns {available: 0, total: 0} for empty arrays', () => { + const result = countConnectors([], []) + assert.deepStrictEqual(result, { available: 0, total: 0 }) + }) + }) + + await describe('fuzzyTime', async () => { + await it('returns dim dash for undefined', () => { + const result = fuzzyTime(undefined) + assert.strictEqual(result, chalk.dim('–')) + }) + + await it('returns just now for timestamps within last 60 seconds', () => { + const thirtySecondsAgo = Date.now() - 30_000 + const result = fuzzyTime(thirtySecondsAgo) + assert.strictEqual(result, chalk.dim('just now')) + }) + + await it('returns just now for ts = Date.now()', () => { + const result = fuzzyTime(Date.now()) + assert.strictEqual(result, chalk.dim('just now')) + }) + + await it('clamps future timestamps to just now', () => { + const oneMinuteInFuture = Date.now() + 60_000 + const result = fuzzyTime(oneMinuteInFuture) + assert.strictEqual(result, chalk.dim('just now')) + }) + + await it('returns minutes format for timestamps 1-59 minutes ago', () => { + const fiveMinutesAgo = Date.now() - 5 * 60_000 + const result = fuzzyTime(fiveMinutesAgo) + assert.strictEqual(result, chalk.dim('5m ago')) + }) + + await it('returns minutes format for exactly 1 minute ago', () => { + const sixtySecondsAgo = Date.now() - 60_000 + const result = fuzzyTime(sixtySecondsAgo) + assert.strictEqual(result, chalk.dim('1m ago')) + }) + + await it('returns hours format for timestamps 1-23 hours ago', () => { + const threeHoursAgo = Date.now() - 3 * 60 * 60_000 + const result = fuzzyTime(threeHoursAgo) + assert.strictEqual(result, chalk.dim('3h ago')) + }) + + await it('returns hours format for exactly 1 hour ago', () => { + const sixtyMinutesAgo = Date.now() - 60 * 60_000 + const result = fuzzyTime(sixtyMinutesAgo) + assert.strictEqual(result, chalk.dim('1h ago')) + }) + + await it('returns days format for timestamps 24+ hours ago', () => { + const twoDaysAgo = Date.now() - 2 * 24 * 60 * 60_000 + const result = fuzzyTime(twoDaysAgo) + assert.strictEqual(result, chalk.dim('2d ago')) + }) + + await it('returns days format for exactly 24 hours ago', () => { + const twentyFourHoursAgo = Date.now() - 24 * 60 * 60_000 + const result = fuzzyTime(twentyFourHoursAgo) + assert.strictEqual(result, chalk.dim('1d ago')) + }) + }) +}) diff --git a/ui/cli/tests/helpers.ts b/ui/cli/tests/helpers.ts new file mode 100644 index 00000000..74721a1b --- /dev/null +++ b/ui/cli/tests/helpers.ts @@ -0,0 +1,17 @@ +import process from 'node:process' + +export const captureStream = (stream: 'stderr' | 'stdout', fn: () => void): string => { + const target = stream === 'stdout' ? process.stdout : process.stderr + const chunks: string[] = [] + const original = target.write.bind(target) + target.write = ((chunk: string): boolean => { + chunks.push(chunk) + return true + }) as typeof target.write + try { + fn() + } finally { + target.write = original + } + return chunks.join('') +} diff --git a/ui/cli/tests/output.test.ts b/ui/cli/tests/output.test.ts index 0b7cf253..a9d64735 100644 --- a/ui/cli/tests/output.test.ts +++ b/ui/cli/tests/output.test.ts @@ -8,22 +8,7 @@ import { createFormatter } from '../src/output/formatter.js' import { printError } from '../src/output/human.js' import { outputJson, outputJsonError } from '../src/output/json.js' import { outputTable } from '../src/output/table.js' - -const captureStream = (stream: 'stderr' | 'stdout', fn: () => void): string => { - const target = stream === 'stdout' ? process.stdout : process.stderr - const chunks: string[] = [] - const original = target.write.bind(target) - target.write = ((chunk: string): boolean => { - chunks.push(chunk) - return true - }) as typeof target.write - try { - fn() - } finally { - target.write = original - } - return chunks.join('') -} +import { captureStream } from './helpers.js' await describe('output formatters', async () => { await it('should create JSON formatter when jsonMode is true', () => { @@ -172,7 +157,7 @@ await describe('output formatters', async () => { }) assert.ok(output.includes('Station not found')) assert.ok(output.includes('cs-001')) - assert.ok(output.includes('Error')) + assert.ok(output.includes('ERROR')) }) await it('should display responsesFailed with missing errorMessage as Unknown error', () => { @@ -230,6 +215,6 @@ await describe('output formatters', async () => { const output = captureStream('stderr', () => { outputTable({ status: ResponseStatus.FAILURE }) }) - assert.ok(output.includes('failure')) + assert.ok(output.includes('Failure')) }) }) diff --git a/ui/cli/tests/renderers.test.ts b/ui/cli/tests/renderers.test.ts new file mode 100644 index 00000000..6acd0bfd --- /dev/null +++ b/ui/cli/tests/renderers.test.ts @@ -0,0 +1,320 @@ +import assert from 'node:assert' +import { describe, it } from 'node:test' +import { OCPP16AvailabilityType, OCPP16ChargePointStatus, ResponseStatus } from 'ui-common' + +import { tryRenderPayload } from '../src/output/renderers.js' +import { captureStream } from './helpers.js' + +const stationListPayload = { + chargingStations: [ + { + connectors: [ + { + connectorId: 1, + connectorStatus: { + availability: OCPP16AvailabilityType.OPERATIVE, + status: OCPP16ChargePointStatus.AVAILABLE, + }, + }, + ], + evses: [], + started: true, + stationInfo: { + chargingStationId: 'CS-001', + hashId: 'abcdef123456789012345678', + ocppVersion: '2.0.1', + templateName: 'test.station-template', + }, + wsState: 1, + }, + ], + status: ResponseStatus.SUCCESS, +} + +const simulatorStatePayload = { + state: { + configuration: { + supervisionUrls: ['ws://localhost:8180'], + worker: { elementsPerWorker: 'all', processType: 'workerSet' }, + }, + started: true, + templateStatistics: { + 'test.station-template': { + added: 2, + configured: 1, + indexes: [0, 1], + provisioned: 0, + started: 2, + }, + }, + version: '4.0.0', + }, + status: ResponseStatus.SUCCESS, +} + +await describe('renderers', async () => { + await describe('tryRenderPayload dispatch', async () => { + await it('returns false for payload without known keys', () => { + const result = tryRenderPayload({ status: ResponseStatus.SUCCESS }) + assert.strictEqual(result, false) + }) + + await it('returns true for station list payload', () => { + let result = false + captureStream('stdout', () => { + result = tryRenderPayload(stationListPayload) + }) + assert.strictEqual(result, true) + }) + + await it('returns true for template list payload', () => { + let result = false + captureStream('stdout', () => { + result = tryRenderPayload({ + status: ResponseStatus.SUCCESS, + templates: ['template-a', 'template-b'], + }) + }) + assert.strictEqual(result, true) + }) + + await it('returns true for simulator state payload', () => { + let result = false + captureStream('stdout', () => { + result = tryRenderPayload(simulatorStatePayload) + }) + assert.strictEqual(result, true) + }) + + await it('returns true for performance stats payload', () => { + let result = false + captureStream('stdout', () => { + result = tryRenderPayload({ performanceStatistics: [], status: ResponseStatus.SUCCESS }) + }) + assert.strictEqual(result, true) + }) + }) + + await describe('type guard specifics', async () => { + await it('isStationList rejects non-array chargingStations', () => { + const result = tryRenderPayload({ + chargingStations: 'not-array', + status: ResponseStatus.SUCCESS, + }) + assert.strictEqual(result, false) + }) + + await it('isTemplateList rejects non-array templates', () => { + const result = tryRenderPayload({ status: ResponseStatus.SUCCESS, templates: 'not-array' }) + assert.strictEqual(result, false) + }) + + await it('isPerformanceStats rejects non-array performanceStatistics', () => { + const result = tryRenderPayload({ + performanceStatistics: 'not-array', + status: ResponseStatus.SUCCESS, + }) + assert.strictEqual(result, false) + }) + + await it('isSimulatorState rejects state without version', () => { + const result = tryRenderPayload({ + state: { + configuration: {}, + started: true, + templateStatistics: {}, + }, + status: ResponseStatus.SUCCESS, + }) + assert.strictEqual(result, false) + }) + + await it('isSimulatorState rejects state without templateStatistics', () => { + const result = tryRenderPayload({ + state: { + configuration: {}, + started: true, + version: '1.0.0', + }, + status: ResponseStatus.SUCCESS, + }) + assert.strictEqual(result, false) + }) + + await it('isSimulatorState accepts state without configuration', () => { + const output = captureStream('stdout', () => { + const result = tryRenderPayload({ + state: { + started: true, + templateStatistics: {}, + version: '1.0.0', + }, + status: ResponseStatus.SUCCESS, + }) + assert.strictEqual(result, true) + }) + assert.ok(output.includes('1.0.0')) + }) + + await it('isSimulatorState rejects null state', () => { + const result = tryRenderPayload({ state: null, status: ResponseStatus.SUCCESS }) + assert.strictEqual(result, false) + }) + + await it('isSimulatorState rejects non-object state (string)', () => { + const result = tryRenderPayload({ state: 'not-an-object', status: ResponseStatus.SUCCESS }) + assert.strictEqual(result, false) + }) + }) + + await describe('renderStationList', async () => { + await it('renders empty message for empty array', () => { + const output = captureStream('stdout', () => { + tryRenderPayload({ chargingStations: [], status: ResponseStatus.SUCCESS }) + }) + assert.ok(output.includes('No charging stations')) + }) + + await it('renders table with station name', () => { + const output = captureStream('stdout', () => { + tryRenderPayload(stationListPayload) + }) + assert.ok(output.includes('CS-001')) + }) + + await it('renders table with truncated hash id', () => { + const output = captureStream('stdout', () => { + tryRenderPayload(stationListPayload) + }) + assert.ok(output.includes('abcdef123456')) + }) + + await it('renders footer with station count', () => { + const output = captureStream('stdout', () => { + tryRenderPayload(stationListPayload) + }) + assert.ok(output.includes('1 station')) + }) + + await it('renders footer with started and connected counts', () => { + const output = captureStream('stdout', () => { + tryRenderPayload(stationListPayload) + }) + assert.ok(output.includes('started')) + assert.ok(output.includes('connected')) + }) + }) + + await describe('renderTemplateList', async () => { + await it('renders one template per line', () => { + const output = captureStream('stdout', () => { + tryRenderPayload({ + status: ResponseStatus.SUCCESS, + templates: ['template-a', 'template-b'], + }) + }) + assert.ok(output.includes('template-a')) + assert.ok(output.includes('template-b')) + }) + + await it('renders footer count', () => { + const output = captureStream('stdout', () => { + tryRenderPayload({ + status: ResponseStatus.SUCCESS, + templates: ['template-a', 'template-b'], + }) + }) + assert.ok(output.includes('2 templates')) + }) + + await it('renders singular footer for single template', () => { + const output = captureStream('stdout', () => { + tryRenderPayload({ status: ResponseStatus.SUCCESS, templates: ['only-one'] }) + }) + assert.ok(output.includes('1 template')) + assert.ok(!output.includes('1 templates')) + }) + + await it('renders empty message for empty array', () => { + const output = captureStream('stdout', () => { + tryRenderPayload({ status: ResponseStatus.SUCCESS, templates: [] }) + }) + assert.ok(output.includes('No templates available')) + }) + }) + + await describe('renderSimulatorState', async () => { + await it('renders version', () => { + const output = captureStream('stdout', () => { + tryRenderPayload(simulatorStatePayload) + }) + assert.ok(output.includes('4.0.0')) + }) + + await it('renders started status', () => { + const output = captureStream('stdout', () => { + tryRenderPayload(simulatorStatePayload) + }) + assert.ok(output.includes('started')) + }) + + await it('renders template statistics table', () => { + const output = captureStream('stdout', () => { + tryRenderPayload(simulatorStatePayload) + }) + assert.ok(output.includes('test')) + }) + + await it('renders footer with station counts', () => { + const output = captureStream('stdout', () => { + tryRenderPayload(simulatorStatePayload) + }) + assert.ok(output.includes('2 stations')) + }) + + await it('renders worker info when present', () => { + const output = captureStream('stdout', () => { + tryRenderPayload(simulatorStatePayload) + }) + assert.ok(output.includes('workerSet')) + }) + + await it('renders supervision URL when present', () => { + const output = captureStream('stdout', () => { + tryRenderPayload(simulatorStatePayload) + }) + assert.ok(output.includes('ws://localhost:8180')) + }) + + await it('renders stopped status when started is false', () => { + const stoppedPayload = { + state: { + ...simulatorStatePayload.state, + started: false, + }, + status: ResponseStatus.SUCCESS, + } + const output = captureStream('stdout', () => { + tryRenderPayload(stoppedPayload) + }) + assert.ok(output.includes('stopped')) + }) + }) + + await describe('renderPerformanceStats', async () => { + await it('renders empty message for empty array', () => { + const output = captureStream('stdout', () => { + tryRenderPayload({ performanceStatistics: [], status: ResponseStatus.SUCCESS }) + }) + assert.ok(output.includes('No performance statistics collected')) + }) + + await it('renders JSON for non-empty stats array', () => { + const stats = [{ id: 'cs-001', measurements: {} }] + const output = captureStream('stdout', () => { + tryRenderPayload({ performanceStatistics: stats, status: ResponseStatus.SUCCESS }) + }) + assert.ok(output.includes('cs-001')) + }) + }) +}) diff --git a/ui/common/src/types/ChargingStationType.ts b/ui/common/src/types/ChargingStationType.ts index a79080b2..32c471ce 100644 --- a/ui/common/src/types/ChargingStationType.ts +++ b/ui/common/src/types/ChargingStationType.ts @@ -159,6 +159,7 @@ export interface ChargingStationData extends JsonObject { started: boolean stationInfo: ChargingStationInfo supervisionUrl: string + timestamp?: number wsState?: 0 | 1 | 2 | 3 } diff --git a/ui/common/src/types/UIProtocol.ts b/ui/common/src/types/UIProtocol.ts index 3e28bd30..d1434242 100644 --- a/ui/common/src/types/UIProtocol.ts +++ b/ui/common/src/types/UIProtocol.ts @@ -97,6 +97,13 @@ export interface ResponsePayload extends JsonObject { } export interface SimulatorState { + configuration?: { + supervisionUrls?: string | string[] + worker?: { + elementsPerWorker?: string + processType?: string + } + } started: boolean templateStatistics: Record version: string @@ -106,5 +113,6 @@ export interface TemplateStatistics { added: number configured: number indexes: number[] + provisioned: number started: number } -- 2.43.0