From 4fb658ce70d18d728e58b02dcb8e22451aedf163 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Wed, 12 Aug 2026 00:09:54 +0200 Subject: [PATCH] fix(simulator): seed numberOfPhases default into stationInfo (#2078) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * fix(simulator): seed numberOfPhases default into stationInfo The derived numberOfPhases default (AC: 3, DC: 0) lived only in the getNumberOfPhases getter and was never written to stationInfo. Raw consumers of stationInfo — the UI data payload (buildChargingStationDataPayload) and the persisted configuration file — therefore received an undefined numberOfPhases, so the Web UI showed an empty placeholder instead of the effective phase count. Seed stationInfo.numberOfPhases via the existing getNumberOfPhases getter in getStationInfo, post-merge and source-agnostic, so both freshly template-derived and already-persisted (legacy) configurations are fixed. Idempotent: an explicit AC template value is preserved (?? 3), DC pins 0. Backend consumers keep reading the getter and are invariant. * test(simulator): align numberOfPhases test names with should-prefix convention * test(simulator): cover DC phase pinning and persisted-config backfill Add two discriminant cases to the numberOfPhases seeding suite: - DC pins numberOfPhases to 0 even when the template sets a value - a persisted configuration predating the field is backfilled on reload (the file-sourced path that motivated seeding in the orchestrator) * docs(simulator): harmonize numberOfPhases seeding review nits - reuse the canonical flushMicrotasks test helper instead of a local node:timers/promises import - expand the test @file description to cover DC pinning and legacy backfill - align the seed comment terminology on "backfill" * test(simulator): dedupe test baseName literal into a constant * docs(simulator): tighten seeding comments (drop getter paraphrase) * docs(simulator): fix backfill comment accuracy and align seed wording * test(simulator): inline baseName to match sibling station-test convention * test(simulator): consolidate real-station-from-template scaffolding into a shared helper Six charging-station tests each hand-rolled the same temp-dir template scaffolding to build a real ChargingStation (the mock factory bypasses initialize()/getStationInfo()). Extract writeStationTemplate/copyStationTemplate/ createStationFromTemplate/cleanupStationTemplates into StationHelpers.realStation, migrate all six tests, and document the helper in TEST_STYLE_GUIDE. * test(simulator): share temp-file and singleton-reset test helpers Extract the mkdtemp/write/cleanup boilerplate into tests/helpers/TempFiles.ts (createTempDir/writeTempFile/cleanupTempDirs) and a resetSingleton() helper into TestLifecycleHelpers, then migrate the file-I/O tests (IdTagsCache, EvProfiles, JsonFileStorage, Configuration-HotReload, FileUtils, UIMCPServer integration) and the singleton-reset tests (Bootstrap, SharedLRUCache, IdTagsCache) onto them. * docs(test): document TempFiles helpers and resetSingleton in the style guide --- src/charging-station/ChargingStation.ts | 4 + tests/TEST_STYLE_GUIDE.md | 54 ++++++-- tests/charging-station/Bootstrap.test.ts | 8 +- ...ion-ConfigurationTemplateIsolation.test.ts | 39 ++---- ...argingStation-ConversionEfficiency.test.ts | 31 ++--- .../ChargingStation-NumberOfPhases.test.ts | 123 ++++++++++++++++++ .../ChargingStation-Reconnect.test.ts | 30 ++--- .../ChargingStation-ResetCancellation.test.ts | 34 ++--- .../ChargingStation-ResetIdentity.test.ts | 77 +++-------- tests/charging-station/IdTagsCache.test.ts | 41 +++--- tests/charging-station/SharedLRUCache.test.ts | 15 +-- .../helpers/StationHelpers.realStation.ts | 96 ++++++++++++++ .../helpers/StationHelpers.ts | 1 + .../meter-values/EvProfiles.test.ts | 74 ++++------- .../ui-server/UIMCPServer-Integration.test.ts | 17 +-- tests/helpers/TempFiles.ts | 45 +++++++ tests/helpers/TestLifecycleHelpers.ts | 11 ++ .../storage/JsonFileStorage.test.ts | 8 +- tests/utils/Configuration-HotReload.test.ts | 13 +- tests/utils/FileUtils.test.ts | 8 +- 20 files changed, 446 insertions(+), 283 deletions(-) create mode 100644 tests/charging-station/ChargingStation-NumberOfPhases.test.ts create mode 100644 tests/charging-station/helpers/StationHelpers.realStation.ts create mode 100644 tests/helpers/TempFiles.ts diff --git a/src/charging-station/ChargingStation.ts b/src/charging-station/ChargingStation.ts index f225b29d..4290c7da 100644 --- a/src/charging-station/ChargingStation.ts +++ b/src/charging-station/ChargingStation.ts @@ -1722,6 +1722,10 @@ export class ChargingStation extends EventEmitter { mergeDeepRight(Constants.DEFAULT_STATION_INFO as ChargingStationInfo, stationInfo), options ) + // getNumberOfPhases owns this derived default, but raw consumers (UI data payload, + // persisted configuration) read stationInfo directly. Seed it post-merge so + // those paths — and persisted configs predating the field — get the effective value. + stationInfo.numberOfPhases = this.getNumberOfPhases(stationInfo) stationInfo.chargingStationId = getChargingStationId(this.index, stationInfo) stationInfo.hashId = getHashId(this.index, stationTemplate, stationInfo.chargingStationId) return stationInfo diff --git a/tests/TEST_STYLE_GUIDE.md b/tests/TEST_STYLE_GUIDE.md index 50cb7c26..6d36002f 100644 --- a/tests/TEST_STYLE_GUIDE.md +++ b/tests/TEST_STYLE_GUIDE.md @@ -320,23 +320,55 @@ const { station, mocks } = createMockChargingStation({ assert.strictEqual(mocks.webSocket.sentMessages.length, 1) ``` +### Real station from a template + +`createMockChargingStation()` is a stub that bypasses `initialize()`/`getStationInfo()`. +Tests exercising the real construction pipeline (persistence, reset, reconnect, template +parsing) MUST build a real station from a template file via +`helpers/StationHelpers.realStation.ts` — never re-implement the temp-dir scaffolding: + +| Helper | Purpose | +| ----------------------------- | ----------------------------------------------------------- | +| `writeStationTemplate(obj)` | Write an inline template object into an isolated temp dir | +| `copyStationTemplate(ovr?)` | Copy a bundled asset template, optionally merging overrides | +| `createStationFromTemplate()` | Construct a real `ChargingStation` from a template file | +| `cleanupStationTemplates()` | Remove temp template dirs (call in `afterEach`) | + +```typescript +afterEach(() => { + standardCleanup() + cleanupStationTemplates() +}) + +const station = createStationFromTemplate(copyStationTemplate()) +``` + --- ## 10. Utility Reference ### Lifecycle Helpers (`helpers/TestLifecycleHelpers.ts`) -| Utility | Purpose | -| --------------------------------- | ---------------------------------------- | -| `standardCleanup()` | **MANDATORY** afterEach cleanup | -| `flushMicrotasks()` | Drain async side-effects from `emit()` | -| `withMockTimers()` | Execute test with timer mocking | -| `createTimerScope()` | Manual timer control | -| `sleep(ms)` | Real-time delay (avoid in tests) | -| `createLoggerMocks()` | Create logger spies (error, warn) | -| `createConsoleMocks()` | Create console spies (error, warn, info) | -| `setupConnectorWithTransaction()` | Setup connector in transaction state | -| `clearConnectorTransaction()` | Clear connector transaction state | +| Utility | Purpose | +| --------------------------------- | ------------------------------------------ | +| `standardCleanup()` | **MANDATORY** afterEach cleanup | +| `flushMicrotasks()` | Drain async side-effects from `emit()` | +| `withMockTimers()` | Execute test with timer mocking | +| `createTimerScope()` | Manual timer control | +| `sleep(ms)` | Real-time delay (avoid in tests) | +| `createLoggerMocks()` | Create logger spies (error, warn) | +| `createConsoleMocks()` | Create console spies (error, warn, info) | +| `setupConnectorWithTransaction()` | Setup connector in transaction state | +| `clearConnectorTransaction()` | Clear connector transaction state | +| `resetSingleton(cls)` | Reset a `getInstance()` singleton instance | + +### Temp Files (`helpers/TempFiles.ts`) + +| Utility | Purpose | +| ------------------- | --------------------------------------------------- | +| `createTempDir()` | Create a tracked temp dir under the OS temp root | +| `writeTempFile()` | Write a file into a dir (typically `createTempDir`) | +| `cleanupTempDirs()` | Remove tracked temp dirs (call in `afterEach`) | ### Mock Classes (`mocks/`) diff --git a/tests/charging-station/Bootstrap.test.ts b/tests/charging-station/Bootstrap.test.ts index 222a5615..a8d808f4 100644 --- a/tests/charging-station/Bootstrap.test.ts +++ b/tests/charging-station/Bootstrap.test.ts @@ -16,7 +16,7 @@ import { setTimeout as sleep } from 'node:timers/promises' import { Bootstrap, STATE_FILE_VERSION } from '../../src/charging-station/index.js' import { logger } from '../../src/utils/index.js' -import { standardCleanup } from '../helpers/TestLifecycleHelpers.js' +import { resetSingleton, standardCleanup } from '../helpers/TestLifecycleHelpers.js' interface Barrier { promise: Promise @@ -56,10 +56,6 @@ const createBarrier = (): Barrier => { return { promise, resolve: resolveFn } } -const resetBootstrapSingleton = (): void => { - ;(Bootstrap as unknown as { instance: Bootstrap | null }).instance = null -} - const buildLifecycleTestInstance = (stateFilePath: string): BootstrapInternal => { const instance = Object.create(Bootstrap.prototype) as BootstrapInternal EventEmitter.call(instance as unknown as EventEmitter) @@ -130,7 +126,7 @@ await describe('Bootstrap lifecycle state machine', async () => { afterEach(() => { rmSync(testDir, { force: true, recursive: true }) - resetBootstrapSingleton() + resetSingleton(Bootstrap) mock.restoreAll() standardCleanup() }) diff --git a/tests/charging-station/ChargingStation-ConfigurationTemplateIsolation.test.ts b/tests/charging-station/ChargingStation-ConfigurationTemplateIsolation.test.ts index 224e43e8..fda25cad 100644 --- a/tests/charging-station/ChargingStation-ConfigurationTemplateIsolation.test.ts +++ b/tests/charging-station/ChargingStation-ConfigurationTemplateIsolation.test.ts @@ -5,20 +5,22 @@ * local and does not mutate the shared template held in the SharedLRUCache. */ import assert from 'node:assert/strict' -import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { afterEach, describe, it } from 'node:test' +import type { ChargingStation } from '../../src/charging-station/ChargingStation.js' import type { ChargingStationTemplate } from '../../src/types/index.js' -import { ChargingStation } from '../../src/charging-station/ChargingStation.js' import { getConfigurationKey, setConfigurationKeyValue, } from '../../src/charging-station/ConfigurationKeyUtils.js' import { SharedLRUCache } from '../../src/charging-station/SharedLRUCache.js' import { standardCleanup } from '../helpers/TestLifecycleHelpers.js' +import { + cleanupStationTemplates, + copyStationTemplate, + createStationFromTemplate, +} from './helpers/StationHelpers.realStation.js' // templateFileHash is the SharedLRUCache key; reached via a typed boundary cast (no `as any`). const templateHashOf = (station: ChargingStation): string => @@ -31,38 +33,15 @@ const configValue = (template: ChargingStationTemplate, key: string): string | u const CONFIG_KEY = 'MeterValueSampleInterval' const TEMPLATE_VALUE = '30' -const tmpRoots: string[] = [] - -// Fresh template in its own temp station-templates dir. A station caches its parsed template -// in the SharedLRUCache under a content-derived key; templateHashOf() fetches that exact entry -// so the test can assert the station's Configuration is an independent copy of it. -const makeTemplate = (): string => { - const root = mkdtempSync(join(tmpdir(), 'cs-config-isolation-')) - tmpRoots.push(root) - mkdirSync(join(root, 'station-templates'), { recursive: true }) - const file = join(root, 'station-templates', 'virtual-simple.station-template.json') - copyFileSync( - join(process.cwd(), 'src/assets/station-templates/virtual-simple.station-template.json'), - file - ) - return file -} - await describe('ChargingStation OCPP Configuration isolation', async () => { afterEach(() => { standardCleanup() - for (const root of tmpRoots.splice(0)) { - rmSync(root, { force: true, recursive: true }) - } + cleanupStationTemplates() }) await it('should not mutate the shared cached template when a non-persistent station changes a configuration key', () => { - const templateFile = makeTemplate() - const station = new ChargingStation(1, templateFile, { - autoStart: false, - persistentConfiguration: false, - supervisionUrls: 'ws://localhost:9999/', - }) + const templateFile = copyStationTemplate() + const station = createStationFromTemplate(templateFile, { persistentConfiguration: false }) // The exact cached template the station parsed and read its Configuration from. const cachedTemplate = SharedLRUCache.getInstance().getChargingStationTemplate( diff --git a/tests/charging-station/ChargingStation-ConversionEfficiency.test.ts b/tests/charging-station/ChargingStation-ConversionEfficiency.test.ts index 46a28820..a7044c6d 100644 --- a/tests/charging-station/ChargingStation-ConversionEfficiency.test.ts +++ b/tests/charging-station/ChargingStation-ConversionEfficiency.test.ts @@ -8,18 +8,19 @@ * voltageOut` on DC) are left unchanged and are not reduced by the factor. */ import assert from 'node:assert/strict' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { afterEach, describe, it } from 'node:test' -import { ChargingStation } from '../../src/charging-station/ChargingStation.js' +import type { ChargingStation } from '../../src/charging-station/ChargingStation.js' + import { standardCleanup } from '../helpers/TestLifecycleHelpers.js' +import { + cleanupStationTemplates, + createStationFromTemplate, + writeStationTemplate, +} from './helpers/StationHelpers.realStation.js' const POWER_W = 50000 -const tmpRoots: string[] = [] - interface TemplateOverrides { connectorMaximumPower?: number conversionEfficiency?: number @@ -32,14 +33,10 @@ interface TemplateOverrides { // station power and the station bound is the binding one; an explicit // connectorMaximumPower override makes the connector hardware bound binding // instead, to exercise the second power-derived term of the min(). -const makeTemplate = (overrides: TemplateOverrides = {}): string => { - const root = mkdtempSync(join(tmpdir(), 'cs-conversion-efficiency-')) - tmpRoots.push(root) - mkdirSync(join(root, 'station-templates'), { recursive: true }) - const file = join(root, 'station-templates', 'dc.station-template.json') +const buildTemplate = (overrides: TemplateOverrides = {}): Record => { const connectorMaximumPower = overrides.connectorMaximumPower != null ? { maximumPower: overrides.connectorMaximumPower } : {} - const template: Record = { + return { $schemaVersion: 1, baseName: 'TEST-CONVERSION-EFFICIENCY', chargePointModel: 'Simulator simple', @@ -59,25 +56,19 @@ const makeTemplate = (overrides: TemplateOverrides = {}): string => { ? { conversionEfficiency: overrides.conversionEfficiency } : {}), } - writeFileSync(file, JSON.stringify(template), 'utf8') - return file } const newStation = (overrides: TemplateOverrides = {}): ChargingStation => - new ChargingStation(1, makeTemplate(overrides), { - autoStart: false, + createStationFromTemplate(writeStationTemplate(buildTemplate(overrides)), { baseName: 'TEST-CONVERSION-EFFICIENCY', fixedName: true, persistentConfiguration: false, - supervisionUrls: 'ws://localhost:9999/', }) await describe('ChargingStation AC/DC conversion efficiency', async () => { afterEach(() => { standardCleanup() - for (const root of tmpRoots.splice(0)) { - rmSync(root, { force: true, recursive: true }) - } + cleanupStationTemplates() }) await it('reduces the DC connector available power by the efficiency factor', () => { diff --git a/tests/charging-station/ChargingStation-NumberOfPhases.test.ts b/tests/charging-station/ChargingStation-NumberOfPhases.test.ts new file mode 100644 index 00000000..920089c7 --- /dev/null +++ b/tests/charging-station/ChargingStation-NumberOfPhases.test.ts @@ -0,0 +1,123 @@ +/** + * @file Tests for `numberOfPhases` default seeding into `stationInfo`. + * @description The derived `numberOfPhases` default lives only in the + * `getNumberOfPhases` getter (AC: template value ?? 3, DC: 0) and must be + * seeded into `stationInfo` by `getStationInfo` so raw consumers — the UI data + * payload (`buildAddedMessage`) and the persisted configuration — receive the + * effective value instead of `undefined`. An explicit AC template value is + * preserved (idempotent, no clobber), DC pins 0 even against an explicit + * template value, and a persisted configuration predating the field is + * backfilled on reload. + */ +import assert from 'node:assert/strict' +import { readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { afterEach, describe, it } from 'node:test' + +import type { ChargingStation } from '../../src/charging-station/ChargingStation.js' + +import { buildAddedMessage } from '../../src/utils/MessageChannelUtils.js' +import { flushMicrotasks, standardCleanup } from '../helpers/TestLifecycleHelpers.js' +import { + cleanupStationTemplates, + createStationFromTemplate, + writeStationTemplate, +} from './helpers/StationHelpers.realStation.js' + +const POWER_W = 22000 + +interface TemplateOverrides { + currentOutType?: string + numberOfPhases?: number +} + +const buildTemplate = (overrides: TemplateOverrides = {}): Record => ({ + $schemaVersion: 1, + baseName: 'TEST-NUMBER-OF-PHASES', + chargePointModel: 'Simulator simple', + chargePointVendor: 'Simulator', + Connectors: { + 0: {}, + 1: { bootStatus: 'Available' }, + }, + currentOutType: overrides.currentOutType ?? 'AC', + numberOfConnectors: 1, + power: POWER_W, + powerUnit: 'W', + randomConnectors: false, + ...(overrides.numberOfPhases != null ? { numberOfPhases: overrides.numberOfPhases } : {}), +}) + +const makeStation = (templateFile: string, persistentConfiguration = false): ChargingStation => + createStationFromTemplate(templateFile, { + baseName: 'TEST-NUMBER-OF-PHASES', + fixedName: true, + persistentConfiguration, + }) + +const newStation = (overrides: TemplateOverrides = {}): ChargingStation => + makeStation(writeStationTemplate(buildTemplate(overrides))) + +await describe('ChargingStation numberOfPhases seeding', async () => { + afterEach(() => { + standardCleanup() + cleanupStationTemplates() + }) + + await it('should seed numberOfPhases to 3 for an AC template omitting the field', () => { + const station = newStation({ currentOutType: 'AC' }) + assert.strictEqual(station.stationInfo?.numberOfPhases, 3) + }) + + await it('should seed numberOfPhases to 0 for a DC template', () => { + const station = newStation({ currentOutType: 'DC' }) + assert.strictEqual(station.stationInfo?.numberOfPhases, 0) + }) + + await it('should preserve an explicit AC template numberOfPhases value', () => { + const station = newStation({ currentOutType: 'AC', numberOfPhases: 1 }) + assert.strictEqual(station.stationInfo?.numberOfPhases, 1) + }) + + await it('should transmit the seeded numberOfPhases in the UI data payload', () => { + const station = newStation({ currentOutType: 'AC' }) + const payload = buildAddedMessage(station).data + assert.strictEqual(payload.stationInfo.numberOfPhases, 3) + }) + + await it('should match getNumberOfPhases so backend consumers are invariant', () => { + const acStation = newStation({ currentOutType: 'AC' }) + assert.strictEqual(acStation.stationInfo?.numberOfPhases, acStation.getNumberOfPhases()) + const dcStation = newStation({ currentOutType: 'DC' }) + assert.strictEqual(dcStation.stationInfo?.numberOfPhases, dcStation.getNumberOfPhases()) + }) + + await it('should pin numberOfPhases to 0 for DC even when the template sets it', () => { + const station = newStation({ currentOutType: 'DC', numberOfPhases: 3 }) + assert.strictEqual(station.stationInfo?.numberOfPhases, 0) + }) + + await it('should backfill numberOfPhases into a persisted config that predates the field', async () => { + const templateFile = writeStationTemplate(buildTemplate({ currentOutType: 'AC' })) + // The persisted config write runs under an async lock; flush before reading it back. + makeStation(templateFile, true) + await flushMicrotasks() + // Simulate a legacy configuration written before numberOfPhases was seeded. + const configurationDir = join(dirname(dirname(templateFile)), 'configurations') + const configurationFile = join( + configurationDir, + readdirSync(configurationDir).find(entry => entry.endsWith('.json')) ?? '' + ) + const configuration = JSON.parse(readFileSync(configurationFile, 'utf8')) as { + stationInfo: { numberOfPhases?: number } + } + assert.strictEqual(configuration.stationInfo.numberOfPhases, 3) + delete configuration.stationInfo.numberOfPhases + writeFileSync(configurationFile, JSON.stringify(configuration), 'utf8') + // A fresh station starts with an empty configurationFileHash, so getConfigurationFromFile + // bypasses the shared cache and reads the file from disk; the file-sourced stationInfo + // must backfill the field. + const reloaded = makeStation(templateFile, true) + assert.strictEqual(reloaded.stationInfo?.numberOfPhases, 3) + }) +}) diff --git a/tests/charging-station/ChargingStation-Reconnect.test.ts b/tests/charging-station/ChargingStation-Reconnect.test.ts index 84464f48..21e855a6 100644 --- a/tests/charging-station/ChargingStation-Reconnect.test.ts +++ b/tests/charging-station/ChargingStation-Reconnect.test.ts @@ -5,15 +5,18 @@ * disconnected after a requested close. */ import assert from 'node:assert/strict' -import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { afterEach, describe, it } from 'node:test' import { WebSocket } from 'ws' -import { ChargingStation } from '../../src/charging-station/ChargingStation.js' +import type { ChargingStation } from '../../src/charging-station/ChargingStation.js' + import { WebSocketCloseEventStatusCode } from '../../src/types/index.js' import { standardCleanup } from '../helpers/TestLifecycleHelpers.js' +import { + cleanupStationTemplates, + copyStationTemplate, + createStationFromTemplate, +} from './helpers/StationHelpers.realStation.js' // onClose and reconnect are private; the tests drive onClose directly with a // spied reconnect to observe the reconnect decision without opening a socket. @@ -24,22 +27,9 @@ interface StationInternals { wsConnection: unknown } -const tmpRoots: string[] = [] - // Build a started station whose reconnect() is replaced by a counter. const makeStation = (): { reconnectCount: () => number; station: ChargingStation } => { - const root = mkdtempSync(join(tmpdir(), 'cs-reconnect-')) - tmpRoots.push(root) - mkdirSync(join(root, 'station-templates'), { recursive: true }) - const templateFile = join(root, 'station-templates', 'virtual-simple.station-template.json') - copyFileSync( - join(process.cwd(), 'src/assets/station-templates/virtual-simple.station-template.json'), - templateFile - ) - const station = new ChargingStation(1, templateFile, { - autoStart: false, - supervisionUrls: 'ws://localhost:9999/', - }) + const station = createStationFromTemplate(copyStationTemplate()) let reconnects = 0 const internals = station as unknown as StationInternals internals.reconnect = () => { @@ -53,9 +43,7 @@ const makeStation = (): { reconnectCount: () => number; station: ChargingStation await describe('ChargingStation reconnect decision on WebSocket close', async () => { afterEach(() => { standardCleanup() - for (const root of tmpRoots.splice(0)) { - rmSync(root, { force: true, recursive: true }) - } + cleanupStationTemplates() }) await it('should reconnect after a server-initiated normal close while started', () => { diff --git a/tests/charging-station/ChargingStation-ResetCancellation.test.ts b/tests/charging-station/ChargingStation-ResetCancellation.test.ts index 8dd47bf2..a5411400 100644 --- a/tests/charging-station/ChargingStation-ResetCancellation.test.ts +++ b/tests/charging-station/ChargingStation-ResetCancellation.test.ts @@ -6,38 +6,24 @@ * CSMS (the "zombie" reconnect that triggers issue #2017). */ import assert from 'node:assert/strict' -import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { afterEach, describe, it } from 'node:test' +import type { ChargingStation } from '../../src/charging-station/ChargingStation.js' import type { ChargingStationOptions } from '../../src/types/index.js' -import { ChargingStation } from '../../src/charging-station/ChargingStation.js' import { flushMicrotasks, standardCleanup, withMockTimers, } from '../helpers/TestLifecycleHelpers.js' +import { + cleanupStationTemplates, + copyStationTemplate, + createStationFromTemplate, +} from './helpers/StationHelpers.realStation.js' const RESET_TIME_MS = 60000 -const tmpRoots: string[] = [] - -// Fresh template under its own temp station-templates dir so each test is -// isolated, mirroring the ChargingStation-ResetIdentity harness. -const makeTemplate = (): string => { - const root = mkdtempSync(join(tmpdir(), 'cs-reset-cancel-')) - tmpRoots.push(root) - mkdirSync(join(root, 'station-templates'), { recursive: true }) - const file = join(root, 'station-templates', 'virtual-simple.station-template.json') - copyFileSync( - join(process.cwd(), 'src/assets/station-templates/virtual-simple.station-template.json'), - file - ) - return file -} - interface ResetInternals { initialize: (options?: ChargingStationOptions) => void start: () => void @@ -45,12 +31,10 @@ interface ResetInternals { } const newStation = (resetTimeMs = RESET_TIME_MS): ChargingStation => { - const station = new ChargingStation(1, makeTemplate(), { - autoStart: false, + const station = createStationFromTemplate(copyStationTemplate(), { baseName: 'TEST-RESET-CANCEL', fixedName: true, persistentConfiguration: false, - supervisionUrls: 'ws://localhost:9999/', }) if (station.stationInfo != null) { station.stationInfo.resetTime = resetTimeMs @@ -61,9 +45,7 @@ const newStation = (resetTimeMs = RESET_TIME_MS): ChargingStation => { await describe('ChargingStation cancellable reset', async () => { afterEach(() => { standardCleanup() - for (const root of tmpRoots.splice(0)) { - rmSync(root, { force: true, recursive: true }) - } + cleanupStationTemplates() }) await it('should not re-initialize or reconnect when deleted during the reset sleep window', async t => { diff --git a/tests/charging-station/ChargingStation-ResetIdentity.test.ts b/tests/charging-station/ChargingStation-ResetIdentity.test.ts index fea520f9..9d0ff0b8 100644 --- a/tests/charging-station/ChargingStation-ResetIdentity.test.ts +++ b/tests/charging-station/ChargingStation-ResetIdentity.test.ts @@ -5,26 +5,21 @@ * non-persistent one only keeps it when the creation options are re-applied. */ import assert from 'node:assert/strict' -import { - copyFileSync, - existsSync, - mkdirSync, - mkdtempSync, - readdirSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs' -import { tmpdir } from 'node:os' +import { existsSync, readdirSync, readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { afterEach, describe, it } from 'node:test' import { setTimeout as sleep } from 'node:timers/promises' +import type { ChargingStation } from '../../src/charging-station/ChargingStation.js' import type { ChargingStationOptions } from '../../src/types/index.js' -import { ChargingStation } from '../../src/charging-station/ChargingStation.js' import { SharedLRUCache } from '../../src/charging-station/SharedLRUCache.js' import { standardCleanup } from '../helpers/TestLifecycleHelpers.js' +import { + cleanupStationTemplates, + copyStationTemplate, + createStationFromTemplate, +} from './helpers/StationHelpers.realStation.js' // The identity logic lives in initialize(); the identity tests call it directly // to avoid reset()'s stop/sleep/start (which would dial a socket). A separate @@ -39,30 +34,6 @@ const internalsOf = (station: ChargingStation): StationInternals => const identityOf = (station: ChargingStation): string | undefined => station.stationInfo?.chargingStationId -const tmpRoots: string[] = [] - -// Fresh template under its own temp station-templates dir, so each test's -// persisted config lands in an isolated sibling configurations dir. Optional -// overrides are merged into the template's top-level fields (e.g. to enable the -// OCPP-config supervision URL mechanism). -const makeTemplate = (overrides?: Record): string => { - const root = mkdtempSync(join(tmpdir(), 'cs-reset-identity-')) - tmpRoots.push(root) - mkdirSync(join(root, 'station-templates'), { recursive: true }) - const file = join(root, 'station-templates', 'virtual-simple.station-template.json') - const source = join( - process.cwd(), - 'src/assets/station-templates/virtual-simple.station-template.json' - ) - if (overrides == null) { - copyFileSync(source, file) - } else { - const template = JSON.parse(readFileSync(source, 'utf8')) as Record - writeFileSync(file, JSON.stringify({ ...template, ...overrides }, null, 2)) - } - return file -} - // Config writes are asynchronous, so wait until the identity has been persisted. // The configurations dir sits beside the station-templates dir (same derivation // the station uses to place its config file). @@ -90,9 +61,7 @@ const waitForPersistedId = async ( await describe('ChargingStation keeps its identity across a reset', async () => { afterEach(() => { standardCleanup() - for (const root of tmpRoots.splice(0)) { - rmSync(root, { force: true, recursive: true }) - } + cleanupStationTemplates() }) await it('should keep identity only when the creation options are re-applied (non-persistent)', () => { @@ -103,7 +72,7 @@ await describe('ChargingStation keeps its identity across a reset', async () => persistentConfiguration: false, supervisionUrls: 'ws://localhost:9999/', } - const station = new ChargingStation(1, makeTemplate(), options) + const station = createStationFromTemplate(copyStationTemplate(), options) assert.strictEqual(identityOf(station), 'TEST-RESET-ID') // With no options and no saved config to fall back to, the station reverts @@ -117,13 +86,11 @@ await describe('ChargingStation keeps its identity across a reset', async () => }) await it('should keep identity without re-applying the creation options (persistent)', async () => { - const templateFile = makeTemplate() - const station = new ChargingStation(1, templateFile, { - autoStart: false, + const templateFile = copyStationTemplate() + const station = createStationFromTemplate(templateFile, { baseName: 'TEST-PERSIST-ID', fixedName: true, persistentConfiguration: true, - supervisionUrls: 'ws://localhost:9999/', }) assert.strictEqual(identityOf(station), 'TEST-PERSIST-ID') assert.ok( @@ -143,12 +110,10 @@ await describe('ChargingStation keeps its identity across a reset', async () => // nor dials a socket, and the reset delay is zeroed. for (const persistentConfiguration of [false, true]) { await it(`should re-apply the creation options to initialize() only when non-persistent (persistent=${persistentConfiguration.toString()})`, async t => { - const station = new ChargingStation(1, makeTemplate(), { - autoStart: false, + const station = createStationFromTemplate(copyStationTemplate(), { baseName: 'TEST-RESET-WIRING', fixedName: true, persistentConfiguration, - supervisionUrls: 'ws://localhost:9999/', }) if (station.stationInfo != null) { station.stationInfo.resetTime = 0 @@ -172,10 +137,8 @@ await describe('ChargingStation keeps its identity across a reset', async () => } await it('should keep the setSupervisionUrl URL across a reset via the retained options', () => { - const station = new ChargingStation(1, makeTemplate(), { - autoStart: false, + const station = createStationFromTemplate(copyStationTemplate(), { persistentConfiguration: false, - supervisionUrls: 'ws://localhost:9999/', }) station.setSupervisionUrl('ws://localhost:8888/') @@ -193,13 +156,12 @@ await describe('ChargingStation keeps its identity across a reset', async () => // supervisionUrlOcppConfiguration routes the URL through an OCPP config key // rather than stationInfo.supervisionUrls, and must be a template field to // survive the reset rebuild. - const station = new ChargingStation( - 1, - makeTemplate({ + const station = createStationFromTemplate( + copyStationTemplate({ supervisionUrlOcppConfiguration: true, supervisionUrlOcppKey: 'ConnectionUrl', }), - { autoStart: false, persistentConfiguration: false, supervisionUrls: 'ws://localhost:9999/' } + { persistentConfiguration: false } ) station.setSupervisionUrl('ws://localhost:7777/') if (station.stationInfo != null) { @@ -221,13 +183,12 @@ await describe('ChargingStation keeps its identity across a reset', async () => }) await it('should re-seed an OCPP-config supervision URL from the retained options on template reload (non-persistent)', () => { - const station = new ChargingStation( - 1, - makeTemplate({ + const station = createStationFromTemplate( + copyStationTemplate({ supervisionUrlOcppConfiguration: true, supervisionUrlOcppKey: 'ConnectionUrl', }), - { autoStart: false, persistentConfiguration: false, supervisionUrls: 'ws://localhost:9999/' } + { persistentConfiguration: false } ) station.setSupervisionUrl('ws://localhost:7777/') diff --git a/tests/charging-station/IdTagsCache.test.ts b/tests/charging-station/IdTagsCache.test.ts index 6893dd4c..285cdf1d 100644 --- a/tests/charging-station/IdTagsCache.test.ts +++ b/tests/charging-station/IdTagsCache.test.ts @@ -10,9 +10,6 @@ */ import assert from 'node:assert/strict' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { afterEach, describe, it } from 'node:test' import type { ChargingStation } from '../../src/charging-station/index.js' @@ -20,7 +17,8 @@ import type { ChargingStation } from '../../src/charging-station/index.js' import { getIdTagsFile } from '../../src/charging-station/index.js' import { IdTagsCache } from '../../src/charging-station/index.js' import { IdTagDistribution } from '../../src/types/index.js' -import { standardCleanup } from '../helpers/TestLifecycleHelpers.js' +import { cleanupTempDirs, createTempDir, writeTempFile } from '../helpers/TempFiles.js' +import { resetSingleton, standardCleanup } from '../helpers/TestLifecycleHelpers.js' import { createMockChargingStation } from './helpers/StationHelpers.js' const TEST_ID_TAGS = ['TAG-001', 'TAG-002', 'TAG-003'] @@ -42,13 +40,6 @@ function populateCache (cache: IdTagsCache, file: string, idTags: string[]): voi internal.idTagsCaches.set(file, { idTags, idTagsFileWatcher: undefined }) } -/** - * Resets the IdTagsCache singleton so subsequent getInstance() creates a fresh cache. - */ -function resetIdTagsCache (): void { - ;(IdTagsCache as unknown as { instance: null }).instance = null -} - /** * Resolves the idTags file path for a mock station, throwing if unresolvable. * @param station - The station whose stationInfo is used @@ -69,7 +60,8 @@ function resolveIdTagsFile (station: ChargingStation): string { await describe('IdTagsCache', async () => { afterEach(() => { standardCleanup() - resetIdTagsCache() + resetSingleton(IdTagsCache) + cleanupTempDirs() }) await describe('getInstance', async () => { @@ -82,7 +74,7 @@ await describe('IdTagsCache', async () => { await it('should create new instance after reset', () => { const instance1 = IdTagsCache.getInstance() - resetIdTagsCache() + resetSingleton(IdTagsCache) const instance2 = IdTagsCache.getInstance() assert.notStrictEqual(instance1, instance2) @@ -101,19 +93,16 @@ await describe('IdTagsCache', async () => { }) await it('should load id tags from file when cache is empty', () => { - const tmpDir = mkdtempSync(join(tmpdir(), 'idtags-test-')) - const idTagsFile = join(tmpDir, 'idtags.json') - writeFileSync(idTagsFile, JSON.stringify(TEST_ID_TAGS)) - - try { - const cache = IdTagsCache.getInstance() - const result = cache.getIdTags(idTagsFile) - - assert.deepStrictEqual(result, TEST_ID_TAGS) - cache.deleteIdTags(idTagsFile) - } finally { - rmSync(tmpDir, { force: true, recursive: true }) - } + const idTagsFile = writeTempFile( + createTempDir('idtags-test-'), + 'idtags.json', + JSON.stringify(TEST_ID_TAGS) + ) + const cache = IdTagsCache.getInstance() + const result = cache.getIdTags(idTagsFile) + + assert.deepStrictEqual(result, TEST_ID_TAGS) + cache.deleteIdTags(idTagsFile) }) await it('should return empty array for empty file path', () => { diff --git a/tests/charging-station/SharedLRUCache.test.ts b/tests/charging-station/SharedLRUCache.test.ts index caae6409..f0dbd869 100644 --- a/tests/charging-station/SharedLRUCache.test.ts +++ b/tests/charging-station/SharedLRUCache.test.ts @@ -21,7 +21,7 @@ import { Bootstrap } from '../../src/charging-station/index.js' import { SharedLRUCache } from '../../src/charging-station/index.js' import { StandardParametersKey } from '../../src/types/index.js' import { Constants } from '../../src/utils/index.js' -import { standardCleanup } from '../helpers/TestLifecycleHelpers.js' +import { resetSingleton, standardCleanup } from '../helpers/TestLifecycleHelpers.js' interface BootstrapStatic { instance: Bootstrap | null @@ -69,13 +69,6 @@ function installMockBootstrap (): void { } as unknown as Bootstrap } -/** - * Resets the SharedLRUCache singleton so subsequent getInstance() creates a fresh cache. - */ -function resetSharedLRUCache (): void { - ;(SharedLRUCache as unknown as { instance: null }).instance = null -} - await describe('SharedLRUCache', async () => { beforeEach(() => { installMockBootstrap() @@ -83,8 +76,8 @@ await describe('SharedLRUCache', async () => { afterEach(() => { standardCleanup() - resetSharedLRUCache() - ;(Bootstrap as unknown as BootstrapStatic).instance = null + resetSingleton(SharedLRUCache) + resetSingleton(Bootstrap) }) await describe('getInstance', async () => { @@ -97,7 +90,7 @@ await describe('SharedLRUCache', async () => { await it('should create new instance after reset', () => { const instance1 = SharedLRUCache.getInstance() - resetSharedLRUCache() + resetSingleton(SharedLRUCache) const instance2 = SharedLRUCache.getInstance() assert.notStrictEqual(instance1, instance2) diff --git a/tests/charging-station/helpers/StationHelpers.realStation.ts b/tests/charging-station/helpers/StationHelpers.realStation.ts new file mode 100644 index 00000000..c5865585 --- /dev/null +++ b/tests/charging-station/helpers/StationHelpers.realStation.ts @@ -0,0 +1,96 @@ +/** + * @file Helpers to build a real ChargingStation from a template file. + * @description Tests that exercise the real construction pipeline + * (`initialize()`/`getStationInfo()`, persistence, reset, reconnect) cannot use + * the `createMockChargingStation` stub, which bypasses it. These helpers write a + * template into an isolated temp `station-templates` dir and construct a real + * `ChargingStation` from it, tracking temp roots for a single `afterEach` + * cleanup. Two template sources are supported: an inline object + * (`writeStationTemplate`) and a bundled asset (`copyStationTemplate`). + */ +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import type { ChargingStationOptions } from '../../../src/types/index.js' + +import { ChargingStation } from '../../../src/charging-station/ChargingStation.js' + +const TEST_SUPERVISION_URL = 'ws://localhost:9999/' +const ASSET_TEMPLATES_DIR = join(process.cwd(), 'src', 'assets', 'station-templates') +const DEFAULT_ASSET_TEMPLATE = 'virtual-simple.station-template.json' + +const templateRoots: string[] = [] + +const freshTemplateDir = (): string => { + const root = mkdtempSync(join(tmpdir(), 'cs-test-')) + templateRoots.push(root) + mkdirSync(join(root, 'station-templates'), { recursive: true }) + return root +} + +/** + * Writes an inline template object into a fresh isolated `station-templates` dir. + * @param template - Template object to serialize. + * @param fileName - Template file name (cosmetic; the config path derives from the dir). + * @returns Absolute path to the written template file. + */ +export const writeStationTemplate = ( + template: Record, + fileName = 'test.station-template.json' +): string => { + const file = join(freshTemplateDir(), 'station-templates', fileName) + writeFileSync(file, JSON.stringify(template), 'utf8') + return file +} + +/** + * Copies a bundled asset template into a fresh isolated `station-templates` dir, + * optionally merging top-level overrides. + * @param overrides - Top-level fields merged into the asset template. + * @param assetFileName - Asset template file name under `src/assets/station-templates`. + * @returns Absolute path to the template file. + */ +export const copyStationTemplate = ( + overrides?: Record, + assetFileName = DEFAULT_ASSET_TEMPLATE +): string => { + const file = join(freshTemplateDir(), 'station-templates', assetFileName) + const source = join(ASSET_TEMPLATES_DIR, assetFileName) + if (overrides == null) { + copyFileSync(source, file) + } else { + const template = JSON.parse(readFileSync(source, 'utf8')) as Record + writeFileSync(file, JSON.stringify({ ...template, ...overrides }), 'utf8') + } + return file +} + +/** + * Constructs a real ChargingStation from a template file with test defaults + * (`autoStart` off, local supervision URL); caller options take precedence. + * @param templateFile - Path returned by `writeStationTemplate`/`copyStationTemplate`. + * @param options - Charging station options merged over the defaults. + * @param index - Station index. + * @returns The constructed ChargingStation. + */ +export const createStationFromTemplate = ( + templateFile: string, + options: ChargingStationOptions = {}, + index = 1 +): ChargingStation => + new ChargingStation(index, templateFile, { + autoStart: false, + supervisionUrls: TEST_SUPERVISION_URL, + ...options, + }) + +/** + * Removes every temp template dir created by `writeStationTemplate`/`copyStationTemplate`. + * Call in `afterEach`, alongside `standardCleanup()`. + */ +export const cleanupStationTemplates = (): void => { + for (const root of templateRoots.splice(0)) { + rmSync(root, { force: true, recursive: true }) + } +} diff --git a/tests/charging-station/helpers/StationHelpers.ts b/tests/charging-station/helpers/StationHelpers.ts index 57f1cb9b..372b99b5 100644 --- a/tests/charging-station/helpers/StationHelpers.ts +++ b/tests/charging-station/helpers/StationHelpers.ts @@ -14,5 +14,6 @@ export * from './StationHelpers.cleanup.js' export * from './StationHelpers.connector.js' export * from './StationHelpers.factory.js' +export * from './StationHelpers.realStation.js' export * from './StationHelpers.template.js' export * from './StationHelpers.types.js' diff --git a/tests/charging-station/meter-values/EvProfiles.test.ts b/tests/charging-station/meter-values/EvProfiles.test.ts index cf9a1b4e..2945517a 100644 --- a/tests/charging-station/meter-values/EvProfiles.test.ts +++ b/tests/charging-station/meter-values/EvProfiles.test.ts @@ -10,9 +10,6 @@ */ import assert from 'node:assert/strict' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { afterEach, describe, it } from 'node:test' import type { EvProfile } from '../../../src/charging-station/meter-values/types.js' @@ -22,6 +19,7 @@ import { loadEvProfilesFile, selectEvProfile, } from '../../../src/charging-station/meter-values/EvProfiles.js' +import { cleanupTempDirs, createTempDir, writeTempFile } from '../../helpers/TempFiles.js' import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js' const midProfile: EvProfile = { @@ -41,6 +39,7 @@ const midProfile: EvProfile = { await describe('EvProfiles', async () => { afterEach(() => { standardCleanup() + cleanupTempDirs() }) await describe('interpolateChargingCurve', async () => { await it('should return endpoint value at the lower boundary', () => { @@ -105,22 +104,15 @@ await describe('EvProfiles', async () => { }) await it('should return undefined on invalid JSON (fail-soft)', () => { - const dir = mkdtempSync(join(tmpdir(), 'ev-profiles-')) - const path = join(dir, 'bad.json') - writeFileSync(path, '{not json}') - try { - const result = loadEvProfilesFile(path, 'test') - assert.strictEqual(result, undefined) - } finally { - rmSync(dir, { force: true, recursive: true }) - } + const path = writeTempFile(createTempDir('ev-profiles-'), 'bad.json', '{not json}') + const result = loadEvProfilesFile(path, 'test') + assert.strictEqual(result, undefined) }) await it('should return undefined on schema violation', () => { - const dir = mkdtempSync(join(tmpdir(), 'ev-profiles-')) - const path = join(dir, 'bad-schema.json') - writeFileSync( - path, + const path = writeTempFile( + createTempDir('ev-profiles-'), + 'bad-schema.json', JSON.stringify({ profiles: [ { @@ -131,19 +123,14 @@ await describe('EvProfiles', async () => { ], }) ) - try { - const result = loadEvProfilesFile(path, 'test') - assert.strictEqual(result, undefined) - } finally { - rmSync(dir, { force: true, recursive: true }) - } + const result = loadEvProfilesFile(path, 'test') + assert.strictEqual(result, undefined) }) await it('should load a valid file and sort curve by socPercent', () => { - const dir = mkdtempSync(join(tmpdir(), 'ev-profiles-')) - const path = join(dir, 'ok.json') - writeFileSync( - path, + const path = writeTempFile( + createTempDir('ev-profiles-'), + 'ok.json', JSON.stringify({ profiles: [ { @@ -163,23 +150,18 @@ await describe('EvProfiles', async () => { ], }) ) - try { - const result = loadEvProfilesFile(path, 'test') - assert.ok(result != null) - const curve = result.profiles[0].chargingCurve - assert.strictEqual(curve[0].socPercent, 0) - assert.strictEqual(curve[1].socPercent, 50) - assert.strictEqual(curve[2].socPercent, 100) - } finally { - rmSync(dir, { force: true, recursive: true }) - } + const result = loadEvProfilesFile(path, 'test') + assert.ok(result != null) + const curve = result.profiles[0].chargingCurve + assert.strictEqual(curve[0].socPercent, 0) + assert.strictEqual(curve[1].socPercent, 50) + assert.strictEqual(curve[2].socPercent, 100) }) await it('should swap inverted initial SoC bounds', () => { - const dir = mkdtempSync(join(tmpdir(), 'ev-profiles-')) - const path = join(dir, 'inverted.json') - writeFileSync( - path, + const path = writeTempFile( + createTempDir('ev-profiles-'), + 'inverted.json', JSON.stringify({ profiles: [ { @@ -194,14 +176,10 @@ await describe('EvProfiles', async () => { ], }) ) - try { - const result = loadEvProfilesFile(path, 'test') - assert.ok(result != null) - assert.strictEqual(result.profiles[0].initialSocPercentMin, 20) - assert.strictEqual(result.profiles[0].initialSocPercentMax, 80) - } finally { - rmSync(dir, { force: true, recursive: true }) - } + const result = loadEvProfilesFile(path, 'test') + assert.ok(result != null) + assert.strictEqual(result.profiles[0].initialSocPercentMin, 20) + assert.strictEqual(result.profiles[0].initialSocPercentMax, 80) }) }) }) diff --git a/tests/charging-station/ui-server/UIMCPServer-Integration.test.ts b/tests/charging-station/ui-server/UIMCPServer-Integration.test.ts index 7741526d..1e821b29 100644 --- a/tests/charging-station/ui-server/UIMCPServer-Integration.test.ts +++ b/tests/charging-station/ui-server/UIMCPServer-Integration.test.ts @@ -6,9 +6,7 @@ import type { AddressInfo } from 'node:net' import assert from 'node:assert/strict' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { request as httpRequest, type Server } from 'node:http' -import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, it } from 'node:test' @@ -16,6 +14,7 @@ import { UIMCPServer } from '../../../src/charging-station/ui-server/UIMCPServer import { HttpMethod } from '../../../src/charging-station/ui-server/UIServerUtils.js' import { ApplicationProtocol, ConfigurationSection } from '../../../src/types/index.js' import { Configuration } from '../../../src/utils/index.js' +import { cleanupTempDirs, createTempDir, writeTempFile } from '../../helpers/TempFiles.js' import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js' import { createMockBootstrap, createMockUIServerConfiguration } from './UIServerTestUtils.js' @@ -181,7 +180,7 @@ await describe('UIMCPServer HTTP Integration', async () => { } beforeEach(() => { - logTmpDir = mkdtempSync(join(tmpdir(), 'mcp-log-test-')) + logTmpDir = createTempDir('mcp-log-test-') getConfigSectionCache().set(ConfigurationSection.log, { console: false, enabled: true, @@ -195,15 +194,18 @@ await describe('UIMCPServer HTTP Integration', async () => { afterEach(() => { getConfigSectionCache().delete(ConfigurationSection.log) - rmSync(logTmpDir, { force: true, recursive: true }) + cleanupTempDirs() }) await it('should return log content with default date (current local date)', async () => { // Arrange const now = new Date() const todayDate = `${now.getFullYear().toString()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}` - const logFile = join(logTmpDir, `combined-${todayDate}.log`) - writeFileSync(logFile, 'info: test log line 1\ninfo: test log line 2\n') + writeTempFile( + logTmpDir, + `combined-${todayDate}.log`, + 'info: test log line 1\ninfo: test log line 2\n' + ) // Act const result = await callTool(testPort, 'readCombinedLog', { tail: 10 }) @@ -218,8 +220,7 @@ await describe('UIMCPServer HTTP Integration', async () => { await it('should return log content for explicit date parameter', async () => { // Arrange const testDate = '2020-01-01' - const logFile = join(logTmpDir, `combined-${testDate}.log`) - writeFileSync(logFile, 'info: historical log entry\n') + writeTempFile(logTmpDir, `combined-${testDate}.log`, 'info: historical log entry\n') // Act const result = await callTool(testPort, 'readCombinedLog', { date: testDate, tail: 10 }) diff --git a/tests/helpers/TempFiles.ts b/tests/helpers/TempFiles.ts new file mode 100644 index 00000000..c2059334 --- /dev/null +++ b/tests/helpers/TempFiles.ts @@ -0,0 +1,45 @@ +/** + * @file Temp-file helpers for tests that touch the real filesystem. + * @description Centralizes the `mkdtemp` + write + cleanup boilerplate that file + * I/O tests (id-tags cache, EV profiles, JSON storage, config hot-reload, file + * utils, MCP logs) would otherwise each re-implement. Dirs created via + * `createTempDir` are tracked and removed by `cleanupTempDirs` in `afterEach`. + */ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const tempDirs: string[] = [] + +/** + * Creates a fresh temp dir under the OS temp root and tracks it for cleanup. + * @param prefix - Directory name prefix (kept per-suite for debuggability). + * @returns Absolute path to the created dir. + */ +export const createTempDir = (prefix = 'omp-test-'): string => { + const dir = mkdtempSync(join(tmpdir(), prefix)) + tempDirs.push(dir) + return dir +} + +/** + * Writes a file into an existing dir (typically from `createTempDir`). + * @param dir - Target directory. + * @param fileName - File name to write. + * @param contents - File contents. + * @returns Absolute path to the written file. + */ +export const writeTempFile = (dir: string, fileName: string, contents: string): string => { + const file = join(dir, fileName) + writeFileSync(file, contents, 'utf8') + return file +} + +/** + * Removes every temp dir created by `createTempDir`. Call in `afterEach`. + */ +export const cleanupTempDirs = (): void => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { force: true, recursive: true }) + } +} diff --git a/tests/helpers/TestLifecycleHelpers.ts b/tests/helpers/TestLifecycleHelpers.ts index b988613e..81d8150c 100644 --- a/tests/helpers/TestLifecycleHelpers.ts +++ b/tests/helpers/TestLifecycleHelpers.ts @@ -350,6 +350,17 @@ export function standardCleanup (): void { OCPP20VariableManager.getInstance().resetRuntimeOverrides() } +/** + * Clears a `getInstance()` singleton's cached instance so the next `getInstance()` + * builds a fresh one. Reaches the private static `instance` field via a typed cast + * (test-only reflection; there is no runtime shape to validate). + * @param holder - The singleton class (e.g. `Bootstrap`, `IdTagsCache`). + */ +export const resetSingleton = (holder: unknown): void => { + const singleton = holder as { instance: unknown } + singleton.instance = null +} + /** * Flush all pending microtasks by yielding to the event loop. * setImmediate fires after all microtasks in the current event loop iteration are drained. diff --git a/tests/performance/storage/JsonFileStorage.test.ts b/tests/performance/storage/JsonFileStorage.test.ts index 89fd2790..a37f3145 100644 --- a/tests/performance/storage/JsonFileStorage.test.ts +++ b/tests/performance/storage/JsonFileStorage.test.ts @@ -3,14 +3,14 @@ * @description Unit tests for the JSON file performance storage backend. */ import assert from 'node:assert/strict' -import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' +import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs' import { join } from 'node:path' import { afterEach, beforeEach, describe, it } from 'node:test' import { pathToFileURL } from 'node:url' import { JsonFileStorage } from '../../../src/performance/storage/JsonFileStorage.js' import { logger } from '../../../src/utils/index.js' +import { cleanupTempDirs, createTempDir } from '../../helpers/TempFiles.js' import { createLoggerMocks, standardCleanup } from '../../helpers/TestLifecycleHelpers.js' import { buildTestStatistics } from './StorageTestHelpers.js' @@ -24,7 +24,7 @@ await describe('JsonFileStorage', async () => { let storage: JsonFileStorage beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), 'json-file-storage-test-')) + tmpDir = createTempDir('json-file-storage-test-') dbPath = join(tmpDir, 'perf.json') storage = new JsonFileStorage(buildStorageUri(dbPath), LOG_PREFIX) storage.open() @@ -33,7 +33,7 @@ await describe('JsonFileStorage', async () => { afterEach(() => { storage.close() standardCleanup() - rmSync(tmpDir, { force: true, recursive: true }) + cleanupTempDirs() }) await it('should write performance statistics atomically and leave no temp artifact behind', async () => { diff --git a/tests/utils/Configuration-HotReload.test.ts b/tests/utils/Configuration-HotReload.test.ts index 44e9bade..e8073b45 100644 --- a/tests/utils/Configuration-HotReload.test.ts +++ b/tests/utils/Configuration-HotReload.test.ts @@ -3,8 +3,7 @@ * @description Validates snapshot rollback, callback gating, lock release, and event coalescing */ import assert from 'node:assert/strict' -import { type FSWatcher, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' +import { type FSWatcher, writeFileSync } from 'node:fs' import { join } from 'node:path' import { afterEach, describe, it } from 'node:test' @@ -14,6 +13,7 @@ import { BaseError } from '../../src/exception/index.js' import { ConfigurationSection } from '../../src/types/index.js' import { ConfigurationValidationError } from '../../src/utils/index.js' import { Configuration, logger } from '../../src/utils/index.js' +import { cleanupTempDirs, createTempDir } from '../helpers/TempFiles.js' import { standardCleanup } from '../helpers/TestLifecycleHelpers.js' import { buildInvalidJsonString, @@ -35,7 +35,7 @@ interface ConfigurationInternals { const getInternals = (): ConfigurationInternals => Configuration as unknown as ConfigurationInternals -const createTempConfigDir = (): string => mkdtempSync(join(tmpdir(), 'cfg-hot-reload-')) +const createTempConfigDir = (): string => createTempDir('cfg-hot-reload-') const writeConfigFile = (dir: string, contents: unknown): string => { const file = join(dir, 'config.json') @@ -46,6 +46,7 @@ const writeConfigFile = (dir: string, contents: unknown): string => { await describe('Configuration hot-reload', async () => { afterEach(() => { standardCleanup() + cleanupTempDirs() }) await it('should replace caches and invoke callback on a valid reload', async t => { @@ -90,7 +91,6 @@ await describe('Configuration hot-reload', async () => { internals.configurationSectionCache = originalCache internals.configurationFileReloading = originalReloading internals.configurationChangeCallback = originalCallback - rmSync(tempDir, { force: true, recursive: true }) } }) @@ -149,7 +149,6 @@ await describe('Configuration hot-reload', async () => { internals.configurationSectionCache = originalCache internals.configurationFileReloading = originalReloading internals.configurationChangeCallback = originalCallback - rmSync(tempDir, { force: true, recursive: true }) } }) @@ -195,7 +194,6 @@ await describe('Configuration hot-reload', async () => { internals.configurationSectionCache = originalCache internals.configurationFileReloading = originalReloading internals.configurationChangeCallback = originalCallback - rmSync(tempDir, { force: true, recursive: true }) } }) @@ -236,7 +234,6 @@ await describe('Configuration hot-reload', async () => { internals.configurationFileReloading = originalReloading internals.configurationChangeCallback = originalCallback internals.configurationFileWatcher = originalWatcher - rmSync(tempDir, { force: true, recursive: true }) } }) @@ -291,7 +288,6 @@ await describe('Configuration hot-reload', async () => { internals.configurationFileReloading = originalReloading internals.configurationFileReloadPending = originalPending internals.configurationChangeCallback = originalCallback - rmSync(tempDir, { force: true, recursive: true }) } }) @@ -328,7 +324,6 @@ await describe('Configuration hot-reload', async () => { internals.configurationSectionCache = originalCache internals.configurationFileReloading = originalReloading internals.configurationChangeCallback = originalCallback - rmSync(tempDir, { force: true, recursive: true }) } }) }) diff --git a/tests/utils/FileUtils.test.ts b/tests/utils/FileUtils.test.ts index bc6c6b22..5fce3205 100644 --- a/tests/utils/FileUtils.test.ts +++ b/tests/utils/FileUtils.test.ts @@ -6,21 +6,19 @@ import assert from 'node:assert/strict' import { existsSync, mkdirSync, - mkdtempSync, readdirSync, readFileSync, - rmSync, statSync, type WatchListener, writeFileSync, } from 'node:fs' -import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, it } from 'node:test' import { FileType } from '../../src/types/index.js' import { atomicWriteFile, atomicWriteFileSync, watchJsonFile } from '../../src/utils/index.js' import { logger } from '../../src/utils/index.js' +import { cleanupTempDirs, createTempDir } from '../helpers/TempFiles.js' import { createLoggerMocks, standardCleanup } from '../helpers/TestLifecycleHelpers.js' const LOG_PREFIX = 'FileUtils-test |' @@ -36,12 +34,12 @@ await describe('FileUtils', async () => { let tmpDir: string beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), 'fileutils-test-')) + tmpDir = createTempDir('fileutils-test-') }) afterEach(() => { standardCleanup() - rmSync(tmpDir, { force: true, recursive: true }) + cleanupTempDirs() }) await describe('watchJsonFile', async () => { -- 2.53.0