import { EventEmitter } from 'node:events'
import { dirname, extname, join } from 'node:path'
-import process, { exit } from 'node:process'
+import process, { env, exit } from 'node:process'
import { fileURLToPath } from 'node:url'
import { isMainThread } from 'node:worker_threads'
import { availableParallelism, type MessageHandler } from 'poolifier'
isNotEmptyArray,
logger,
logPrefix,
+ once,
} from '../utils/index.js'
import {
DEFAULT_ELEMENTS_PER_WORKER,
type WorkerAbstract,
WorkerFactory,
} from '../worker/index.js'
+import { readStateFile, reconstructTemplateIndexes, writeStateFile } from './BootstrapStateUtils.js'
import { buildTemplateName, waitChargingStationEvents } from './Helpers.js'
import { UIServerFactory } from './ui-server/UIServerFactory.js'
gracefulShutdownError = 4,
}
+enum StopReason {
+ reload = 'reload',
+ shutdown = 'shutdown',
+ user = 'user',
+}
+
export class Bootstrap extends EventEmitter implements IBootstrap {
private static instance: Bootstrap | null = null
public get numberOfChargingStationTemplates (): number {
)
}
+ private readonly assetsDir: string
+ private readonly configurationsDir: string
private started: boolean
private starting: boolean
+ private readonly stateFilePath: string
private stopping: boolean
private storage?: Storage
private readonly templateStatistics: Map<string, TemplateStatistics>
private readonly uiServer: AbstractUIServer
private uiServerStarted: boolean
private readonly version: string = packageJson.version
+ private readonly warnPersistStateWithoutUIServerOnce: () => void
private workerImplementation?: WorkerAbstract<ChargingStationWorkerData, ChargingStationInfo>
private get numberOfAddedChargingStations (): number {
)
}
+ private get persistStateEnabled (): boolean {
+ if (env[Constants.ENV_SIMULATOR_COLD_START] === 'true') {
+ return false
+ }
+ if (!Configuration.getPersistState()) {
+ return false
+ }
+ if (
+ Configuration.getConfigurationSection<UIServerConfiguration>(ConfigurationSection.uiServer)
+ .enabled !== true
+ ) {
+ this.warnPersistStateWithoutUIServerOnce()
+ return false
+ }
+ return true
+ }
+
private constructor () {
super()
for (const signal of ['SIGINT', 'SIGQUIT', 'SIGTERM']) {
this.stopping = false
this.uiServerStarted = false
this.templateStatistics = new Map<string, TemplateStatistics>()
+ this.assetsDir = join(dirname(fileURLToPath(import.meta.url)), 'assets')
+ this.configurationsDir = join(this.assetsDir, 'configurations')
+ this.stateFilePath = join(this.configurationsDir, '.simulator-state.json')
+ this.warnPersistStateWithoutUIServerOnce = once(() => {
+ logger.warn(
+ `${this.logPrefix()} ${moduleName}: persistState is enabled but UI server is disabled. Persistence has no recovery channel and is ignored`
+ )
+ })
this.uiServer = UIServerFactory.getUIServerImplementation(
Configuration.getConfigurationSection<UIServerConfiguration>(ConfigurationSection.uiServer),
this
)
- this.initializeCounters()
+ this.prepareTemplateStatistics()
this.initializeWorkerImplementation(
Configuration.getConfigurationSection<WorkerConfiguration>(ConfigurationSection.worker)
)
const stationInfo = await this.workerImplementation?.addElement({
index,
options,
- templateFile: join(
- dirname(fileURLToPath(import.meta.url)),
- 'assets',
- 'station-templates',
- templateFile
- ),
+ templateFile: join(this.assetsDir, 'station-templates', templateFile),
})
const templateStatistics = this.templateStatistics.get(buildTemplateName(templateFile))
if (stationInfo != null && templateStatistics != null) {
}
}
+ public shouldAutoStart (): boolean {
+ if (!this.persistStateEnabled) {
+ return true
+ }
+ const stateFile = readStateFile(this.stateFilePath, this.logPrefix)
+ if (stateFile == null) {
+ return true
+ }
+ return stateFile.started
+ }
+
public async start (): Promise<void> {
if (!this.started) {
if (!this.starting) {
await this.storage.open()
}
}
- this.uiServer.setChargingStationTemplates(
- Configuration.getStationTemplateUrls()?.map(stationTemplateUrl =>
- buildTemplateName(stationTemplateUrl.file)
- )
- )
if (
!this.uiServerStarted &&
Configuration.getConfigurationSection<UIServerConfiguration>(
ConfigurationSection.uiServer
).enabled === true
) {
+ this.syncUIServerTemplates()
this.uiServer.start()
this.uiServerStarted = true
}
this.workerImplementation?.info
)
this.started = true
+ if (this.persistStateEnabled) {
+ await writeStateFile(this.stateFilePath, true, this.logPrefix)
+ }
} finally {
this.starting = false
}
}
}
- public async stop (): Promise<void> {
+ public startUIServer (): void {
+ if (this.uiServerStarted) {
+ return
+ }
+ if (
+ Configuration.getConfigurationSection<UIServerConfiguration>(ConfigurationSection.uiServer)
+ .enabled !== true
+ ) {
+ return
+ }
+ this.syncUIServerTemplates()
+ this.uiServer.start()
+ this.uiServerStarted = true
+ }
+
+ public async stop (reason: StopReason = StopReason.user): Promise<void> {
if (this.started) {
if (!this.stopping) {
this.stopping = true
await this.storage?.close()
delete this.storage
this.started = false
+ if (this.persistStateEnabled && reason === StopReason.user) {
+ await writeStateFile(this.stateFilePath, false, this.logPrefix)
+ }
} finally {
this.stopping = false
}
}
private gracefulShutdown (): void {
- this.stop()
+ this.stop(StopReason.shutdown)
.then(() => {
logger.info(`${this.logPrefix()} ${moduleName}.gracefulShutdown: Graceful shutdown`)
if (this.uiServerStarted) {
}
}
+ private prepareTemplateStatistics (): void {
+ this.initializeCounters()
+ reconstructTemplateIndexes(this.configurationsDir, this.templateStatistics, this.logPrefix)
+ }
+
private async restart (): Promise<void> {
- await this.stop()
+ await this.stop(StopReason.reload)
if (
this.uiServerStarted &&
Configuration.getConfigurationSection<UIServerConfiguration>(ConfigurationSection.uiServer)
this.uiServer.stop()
this.uiServerStarted = false
}
- this.initializeCounters()
+ this.prepareTemplateStatistics()
+ this.syncUIServerTemplates()
// TODO: compare worker configuration hash to skip unnecessary re-initialization
this.initializeWorkerImplementation(
Configuration.getConfigurationSection<WorkerConfiguration>(ConfigurationSection.worker)
await this.start()
}
+ private syncUIServerTemplates (): void {
+ this.uiServer.setChargingStationTemplates(
+ Configuration.getStationTemplateUrls()?.map(stationTemplateUrl =>
+ buildTemplateName(stationTemplateUrl.file)
+ )
+ )
+ }
+
private async waitChargingStationsStopped (): Promise<string> {
return await new Promise<string>((resolve, reject: (reason?: unknown) => void) => {
const waitTimeout = setTimeout(() => {
--- /dev/null
+// Partial Copyright Jerome Benoit. 2021-2025. All Rights Reserved.
+
+import {
+ existsSync,
+ mkdirSync,
+ readdirSync,
+ readFileSync,
+ renameSync,
+ rmSync,
+ writeFileSync,
+} from 'node:fs'
+import { basename, dirname, join } from 'node:path'
+
+import type { TemplateStatistics } from '../types/index.js'
+
+import { FileType } from '../types/index.js'
+import {
+ AsyncLock,
+ AsyncLockType,
+ ensureError,
+ formatLogPrefix,
+ handleFileException,
+ logger,
+} from '../utils/index.js'
+
+const moduleName = 'BootstrapStateUtils'
+
+export const STATE_FILE_VERSION = 1
+
+export interface SimulatorStateFile {
+ started: boolean
+ version: number
+}
+
+export const deleteStateFile = (stateFilePath: string, logPrefixFn?: () => string): void => {
+ try {
+ rmSync(stateFilePath, { force: true })
+ } catch (error) {
+ handleFileException(
+ stateFilePath,
+ FileType.SimulatorState,
+ ensureError(error),
+ logPrefixFn?.() ?? '',
+ { throwError: false }
+ )
+ }
+}
+
+export const readStateFile = (
+ stateFilePath: string,
+ logPrefixFn?: () => string
+): SimulatorStateFile | undefined => {
+ if (!existsSync(stateFilePath)) {
+ return undefined
+ }
+ try {
+ const parsed = JSON.parse(readFileSync(stateFilePath, 'utf8')) as unknown
+ if (parsed == null || typeof parsed !== 'object') {
+ logger.warn(
+ `${formatLogPrefix(logPrefixFn)}${moduleName}.readStateFile: State file content is not a JSON object, deleting`
+ )
+ deleteStateFile(stateFilePath, logPrefixFn)
+ return undefined
+ }
+ const content = parsed as Partial<SimulatorStateFile>
+ if (typeof content.version !== 'number' || typeof content.started !== 'boolean') {
+ logger.warn(
+ `${formatLogPrefix(logPrefixFn)}${moduleName}.readStateFile: Invalid state file content, deleting`
+ )
+ deleteStateFile(stateFilePath, logPrefixFn)
+ return undefined
+ }
+ if (content.version !== STATE_FILE_VERSION) {
+ const backupPath = `${stateFilePath}.v${content.version.toString()}.bak`
+ try {
+ renameSync(stateFilePath, backupPath)
+ logger.warn(
+ `${formatLogPrefix(logPrefixFn)}${moduleName}.readStateFile: Incompatible state file schema version ${content.version.toString()} (expected ${STATE_FILE_VERSION.toString()}), quarantined to ${basename(backupPath)}`
+ )
+ } catch (renameError) {
+ logger.warn(
+ `${formatLogPrefix(logPrefixFn)}${moduleName}.readStateFile: Failed to quarantine incompatible state file, deleting:`,
+ renameError
+ )
+ deleteStateFile(stateFilePath, logPrefixFn)
+ }
+ return undefined
+ }
+ return content as SimulatorStateFile
+ } catch (error) {
+ logger.warn(
+ `${formatLogPrefix(logPrefixFn)}${moduleName}.readStateFile: Failed to read state file, deleting:`,
+ error
+ )
+ deleteStateFile(stateFilePath, logPrefixFn)
+ return undefined
+ }
+}
+
+export const reconstructTemplateIndexes = (
+ configurationsDir: string,
+ templateStatistics: Map<string, TemplateStatistics>,
+ logPrefixFn?: () => string
+): void => {
+ if (!existsSync(configurationsDir)) {
+ return
+ }
+ let files: string[]
+ try {
+ files = readdirSync(configurationsDir).filter(
+ file => file.endsWith('.json') && !file.startsWith('.')
+ )
+ } catch (error) {
+ logger.warn(
+ `${formatLogPrefix(logPrefixFn)}${moduleName}.reconstructTemplateIndexes: Failed to read configurations directory:`,
+ error
+ )
+ return
+ }
+ for (const file of files) {
+ const filePath = join(configurationsDir, file)
+ try {
+ const { stationInfo } = JSON.parse(readFileSync(filePath, 'utf8')) as {
+ stationInfo?: { templateIndex?: number; templateName?: string }
+ }
+ if (stationInfo?.templateName == null || stationInfo.templateIndex == null) {
+ logger.warn(
+ `${formatLogPrefix(logPrefixFn)}${moduleName}.reconstructTemplateIndexes: Skipping ${file}: not a charging station configuration (missing stationInfo.templateName/templateIndex)`
+ )
+ continue
+ }
+ const templateStats = templateStatistics.get(stationInfo.templateName)
+ if (templateStats != null) {
+ templateStats.indexes.add(stationInfo.templateIndex)
+ }
+ } catch (error) {
+ logger.warn(
+ `${formatLogPrefix(logPrefixFn)}${moduleName}.reconstructTemplateIndexes: Skipping corrupt file ${file}:`,
+ error
+ )
+ }
+ }
+}
+
+export const writeStateFile = async (
+ stateFilePath: string,
+ started: boolean,
+ logPrefixFn?: () => string
+): Promise<void> => {
+ await AsyncLock.runExclusive(AsyncLockType.simulatorState, () => {
+ const tmpFile = `${stateFilePath}.tmp`
+ try {
+ mkdirSync(dirname(stateFilePath), { recursive: true })
+ const stateData: SimulatorStateFile = {
+ started,
+ version: STATE_FILE_VERSION,
+ }
+ writeFileSync(tmpFile, JSON.stringify(stateData, undefined, 2), 'utf8')
+ renameSync(tmpFile, stateFilePath)
+ } catch (error) {
+ // Best-effort tmp cleanup; ignore secondary failure to surface the original error.
+ try {
+ rmSync(tmpFile, { force: true })
+ } catch {
+ // Ignore
+ }
+ handleFileException(
+ stateFilePath,
+ FileType.SimulatorState,
+ ensureError(error),
+ logPrefixFn?.() ?? '',
+ { throwError: false }
+ )
+ }
+ })
+}
--- /dev/null
+/**
+ * @file Tests for BootstrapStateUtils
+ * @description Unit tests for simulator state file read/write and template index reconstruction
+ */
+import assert from 'node:assert/strict'
+import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import type { TemplateStatistics } from '../../src/types/index.js'
+
+import {
+ deleteStateFile,
+ readStateFile,
+ reconstructTemplateIndexes,
+ STATE_FILE_VERSION,
+ writeStateFile,
+} from '../../src/charging-station/BootstrapStateUtils.js'
+import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+
+await describe('BootstrapStateUtils', async () => {
+ let testDir: string
+ let stateFilePath: string
+ let configurationsDir: string
+
+ beforeEach(() => {
+ testDir = join(
+ tmpdir(),
+ `bootstrap-state-test-${Date.now().toString()}-${Math.random().toString(36).slice(2)}`
+ )
+ configurationsDir = join(testDir, 'configurations')
+ stateFilePath = join(testDir, 'state.json')
+ mkdirSync(configurationsDir, { recursive: true })
+ })
+
+ afterEach(() => {
+ rmSync(testDir, { force: true, recursive: true })
+ standardCleanup()
+ })
+
+ await describe('writeStateFile', async () => {
+ await it('should write state file with started true', async () => {
+ // Act
+ await writeStateFile(stateFilePath, true)
+
+ // Assert
+ assert.strictEqual(existsSync(stateFilePath), true)
+ const content = JSON.parse(readFileSync(stateFilePath, 'utf8')) as {
+ started: boolean
+ version: number
+ }
+ assert.strictEqual(content.version, STATE_FILE_VERSION)
+ assert.strictEqual(content.started, true)
+ })
+
+ await it('should write state file with started false', async () => {
+ // Act
+ await writeStateFile(stateFilePath, false)
+
+ // Assert
+ const content = JSON.parse(readFileSync(stateFilePath, 'utf8')) as {
+ started: boolean
+ version: number
+ }
+ assert.strictEqual(content.started, false)
+ })
+
+ await it('should atomically overwrite existing state file', async () => {
+ // Arrange
+ await writeStateFile(stateFilePath, true)
+
+ // Act
+ await writeStateFile(stateFilePath, false)
+
+ // Assert
+ const content = JSON.parse(readFileSync(stateFilePath, 'utf8')) as {
+ started: boolean
+ version: number
+ }
+ assert.strictEqual(content.started, false)
+ })
+
+ await it('should create parent directory if it does not exist', async () => {
+ // Arrange
+ const deepPath = join(testDir, 'nested', 'dir', 'state.json')
+
+ // Act
+ await writeStateFile(deepPath, true)
+
+ // Assert
+ assert.strictEqual(existsSync(deepPath), true)
+ })
+
+ await it('should not leave tmp file after successful write', async () => {
+ // Act
+ await writeStateFile(stateFilePath, true)
+
+ // Assert
+ assert.strictEqual(existsSync(`${stateFilePath}.tmp`), false)
+ })
+
+ await it('should not throw when target path is rejected by the OS', async () => {
+ // Arrange
+ const invalidPath = join(testDir, 'does-not-exist', '\0invalid', 'state.json')
+
+ // Act & Assert: writeStateFile must swallow filesystem errors so callers
+ // (Bootstrap.start/stop) do not surface persistence failures as fatal errors.
+ await assert.doesNotReject(async () => {
+ await writeStateFile(invalidPath, true)
+ })
+ })
+
+ await it('should clean up tmp file when atomic rename fails', async () => {
+ // Arrange
+ mkdirSync(stateFilePath, { recursive: true })
+ writeFileSync(join(stateFilePath, 'placeholder'), 'x', 'utf8')
+
+ // Act
+ await writeStateFile(stateFilePath, true)
+
+ // Assert
+ assert.strictEqual(existsSync(`${stateFilePath}.tmp`), false)
+ })
+ })
+
+ await describe('readStateFile', async () => {
+ await it('should return undefined when file does not exist', () => {
+ // Act
+ const result = readStateFile(stateFilePath)
+
+ // Assert
+ assert.strictEqual(result, undefined)
+ })
+
+ await it('should return parsed state when file is valid', async () => {
+ // Arrange
+ await writeStateFile(stateFilePath, true)
+
+ // Act
+ const result = readStateFile(stateFilePath)
+
+ // Assert
+ assert.notStrictEqual(result, undefined)
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by notStrictEqual
+ assert.strictEqual(result!.version, STATE_FILE_VERSION)
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by notStrictEqual
+ assert.strictEqual(result!.started, true)
+ })
+
+ await it('should return undefined and delete file when content is corrupt JSON', () => {
+ // Arrange
+ writeFileSync(stateFilePath, '{not valid json', 'utf8')
+
+ // Act
+ const result = readStateFile(stateFilePath)
+
+ // Assert
+ assert.strictEqual(result, undefined)
+ assert.strictEqual(existsSync(stateFilePath), false)
+ })
+
+ await it('should return undefined and quarantine file when schema version is incompatible', () => {
+ // Arrange
+ writeFileSync(stateFilePath, JSON.stringify({ started: true, version: 999 }), 'utf8')
+
+ // Act
+ const result = readStateFile(stateFilePath)
+
+ // Assert
+ assert.strictEqual(result, undefined)
+ assert.strictEqual(existsSync(stateFilePath), false)
+ assert.strictEqual(existsSync(`${stateFilePath}.v999.bak`), true)
+ })
+
+ await it('should return undefined and delete file when started field is missing', () => {
+ // Arrange
+ writeFileSync(stateFilePath, JSON.stringify({ version: STATE_FILE_VERSION }), 'utf8')
+
+ // Act
+ const result = readStateFile(stateFilePath)
+
+ // Assert
+ assert.strictEqual(result, undefined)
+ assert.strictEqual(existsSync(stateFilePath), false)
+ })
+
+ await it('should return undefined and delete file when version field is missing', () => {
+ // Arrange
+ writeFileSync(stateFilePath, JSON.stringify({ started: true }), 'utf8')
+
+ // Act
+ const result = readStateFile(stateFilePath)
+
+ // Assert
+ assert.strictEqual(result, undefined)
+ assert.strictEqual(existsSync(stateFilePath), false)
+ })
+
+ await it('should return undefined and delete file when content is JSON null', () => {
+ // Arrange
+ writeFileSync(stateFilePath, 'null', 'utf8')
+
+ // Act
+ const result = readStateFile(stateFilePath)
+
+ // Assert
+ assert.strictEqual(result, undefined)
+ assert.strictEqual(existsSync(stateFilePath), false)
+ })
+
+ await it('should return undefined and delete file when content is a JSON primitive', () => {
+ // Arrange
+ writeFileSync(stateFilePath, '42', 'utf8')
+
+ // Act
+ const result = readStateFile(stateFilePath)
+
+ // Assert
+ assert.strictEqual(result, undefined)
+ assert.strictEqual(existsSync(stateFilePath), false)
+ })
+
+ await it('should return undefined and delete file when content is a JSON array', () => {
+ // Arrange
+ writeFileSync(stateFilePath, '[]', 'utf8')
+
+ // Act
+ const result = readStateFile(stateFilePath)
+
+ // Assert
+ assert.strictEqual(result, undefined)
+ assert.strictEqual(existsSync(stateFilePath), false)
+ })
+ })
+
+ await describe('deleteStateFile', async () => {
+ await it('should delete existing state file', async () => {
+ // Arrange
+ await writeStateFile(stateFilePath, true)
+
+ // Act
+ deleteStateFile(stateFilePath)
+
+ // Assert
+ assert.strictEqual(existsSync(stateFilePath), false)
+ })
+
+ await it('should not throw when file does not exist', () => {
+ // Act & Assert
+ assert.doesNotThrow(() => {
+ deleteStateFile(stateFilePath)
+ })
+ })
+ })
+
+ await describe('reconstructTemplateIndexes', async () => {
+ const createTemplateStatistics = (
+ entries: [string, number][]
+ ): Map<string, TemplateStatistics> => {
+ return new Map(
+ entries.map(([name, configured]) => [
+ name,
+ { added: 0, configured, indexes: new Set<number>(), provisioned: 0, started: 0 },
+ ])
+ )
+ }
+
+ await it('should reconstruct indexes from valid station config files', () => {
+ // Arrange
+ const templateStatistics = createTemplateStatistics([['template-a', 2]])
+ writeFileSync(
+ join(configurationsDir, 'station1.json'),
+ JSON.stringify({ stationInfo: { templateIndex: 1, templateName: 'template-a' } }),
+ 'utf8'
+ )
+ writeFileSync(
+ join(configurationsDir, 'station2.json'),
+ JSON.stringify({ stationInfo: { templateIndex: 2, templateName: 'template-a' } }),
+ 'utf8'
+ )
+
+ // Act
+ reconstructTemplateIndexes(configurationsDir, templateStatistics)
+
+ // Assert
+ const indexes = templateStatistics.get('template-a')?.indexes
+ assert.notStrictEqual(indexes, undefined)
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by notStrictEqual
+ assert.strictEqual(indexes!.size, 2)
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by notStrictEqual
+ assert.strictEqual(indexes!.has(1), true)
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by notStrictEqual
+ assert.strictEqual(indexes!.has(2), true)
+ })
+
+ await it('should skip files for templates not in templateStatistics', () => {
+ // Arrange
+ const templateStatistics = createTemplateStatistics([['template-a', 1]])
+ writeFileSync(
+ join(configurationsDir, 'station1.json'),
+ JSON.stringify({
+ stationInfo: { templateIndex: 1, templateName: 'removed-template' },
+ }),
+ 'utf8'
+ )
+
+ // Act
+ reconstructTemplateIndexes(configurationsDir, templateStatistics)
+
+ // Assert
+ assert.strictEqual(templateStatistics.get('template-a')?.indexes.size, 0)
+ })
+
+ await it('should skip files missing templateName', () => {
+ // Arrange
+ const templateStatistics = createTemplateStatistics([['template-a', 1]])
+ writeFileSync(
+ join(configurationsDir, 'station1.json'),
+ JSON.stringify({ stationInfo: { templateIndex: 1 } }),
+ 'utf8'
+ )
+
+ // Act
+ reconstructTemplateIndexes(configurationsDir, templateStatistics)
+
+ // Assert
+ assert.strictEqual(templateStatistics.get('template-a')?.indexes.size, 0)
+ })
+
+ await it('should skip files missing templateIndex', () => {
+ // Arrange
+ const templateStatistics = createTemplateStatistics([['template-a', 1]])
+ writeFileSync(
+ join(configurationsDir, 'station1.json'),
+ JSON.stringify({ stationInfo: { templateName: 'template-a' } }),
+ 'utf8'
+ )
+
+ // Act
+ reconstructTemplateIndexes(configurationsDir, templateStatistics)
+
+ // Assert
+ assert.strictEqual(templateStatistics.get('template-a')?.indexes.size, 0)
+ })
+
+ await it('should skip corrupt JSON files', () => {
+ // Arrange
+ const templateStatistics = createTemplateStatistics([['template-a', 1]])
+ writeFileSync(join(configurationsDir, 'corrupt.json'), '{not valid}', 'utf8')
+
+ // Act
+ reconstructTemplateIndexes(configurationsDir, templateStatistics)
+
+ // Assert
+ assert.strictEqual(templateStatistics.get('template-a')?.indexes.size, 0)
+ })
+
+ await it('should skip non-JSON files', () => {
+ // Arrange
+ const templateStatistics = createTemplateStatistics([['template-a', 1]])
+ writeFileSync(join(configurationsDir, 'readme.txt'), 'not a config', 'utf8')
+
+ // Act
+ reconstructTemplateIndexes(configurationsDir, templateStatistics)
+
+ // Assert
+ assert.strictEqual(templateStatistics.get('template-a')?.indexes.size, 0)
+ })
+
+ await it('should handle empty configurations directory', () => {
+ // Arrange
+ const templateStatistics = createTemplateStatistics([['template-a', 1]])
+
+ // Act
+ reconstructTemplateIndexes(configurationsDir, templateStatistics)
+
+ // Assert
+ assert.strictEqual(templateStatistics.get('template-a')?.indexes.size, 0)
+ })
+
+ await it('should handle non-existent configurations directory', () => {
+ // Arrange
+ const templateStatistics = createTemplateStatistics([['template-a', 1]])
+ const nonExistentDir = join(testDir, 'nonexistent')
+
+ // Act
+ reconstructTemplateIndexes(nonExistentDir, templateStatistics)
+
+ // Assert
+ assert.strictEqual(templateStatistics.get('template-a')?.indexes.size, 0)
+ })
+
+ await it('should reconstruct indexes for multiple templates', () => {
+ // Arrange
+ const templateStatistics = createTemplateStatistics([
+ ['template-a', 2],
+ ['template-b', 1],
+ ])
+ writeFileSync(
+ join(configurationsDir, 'stationA1.json'),
+ JSON.stringify({ stationInfo: { templateIndex: 1, templateName: 'template-a' } }),
+ 'utf8'
+ )
+ writeFileSync(
+ join(configurationsDir, 'stationB1.json'),
+ JSON.stringify({ stationInfo: { templateIndex: 1, templateName: 'template-b' } }),
+ 'utf8'
+ )
+ writeFileSync(
+ join(configurationsDir, 'stationA2.json'),
+ JSON.stringify({ stationInfo: { templateIndex: 2, templateName: 'template-a' } }),
+ 'utf8'
+ )
+
+ // Act
+ reconstructTemplateIndexes(configurationsDir, templateStatistics)
+
+ // Assert
+ assert.strictEqual(templateStatistics.get('template-a')?.indexes.size, 2)
+ assert.strictEqual(templateStatistics.get('template-a')?.indexes.has(1), true)
+ assert.strictEqual(templateStatistics.get('template-a')?.indexes.has(2), true)
+ assert.strictEqual(templateStatistics.get('template-b')?.indexes.size, 1)
+ assert.strictEqual(templateStatistics.get('template-b')?.indexes.has(1), true)
+ })
+
+ await it('should handle files missing stationInfo entirely', () => {
+ // Arrange
+ const templateStatistics = createTemplateStatistics([['template-a', 1]])
+ writeFileSync(
+ join(configurationsDir, 'station1.json'),
+ JSON.stringify({ connectorsStatus: [] }),
+ 'utf8'
+ )
+
+ // Act
+ reconstructTemplateIndexes(configurationsDir, templateStatistics)
+
+ // Assert
+ assert.strictEqual(templateStatistics.get('template-a')?.indexes.size, 0)
+ })
+
+ await it('should skip dot-prefixed metadata files', () => {
+ // Arrange
+ const templateStatistics = createTemplateStatistics([['template-a', 1]])
+ writeFileSync(
+ join(configurationsDir, '.simulator-state.json'),
+ JSON.stringify({ started: true, version: 1 }),
+ 'utf8'
+ )
+ writeFileSync(
+ join(configurationsDir, 'station1.json'),
+ JSON.stringify({ stationInfo: { templateIndex: 1, templateName: 'template-a' } }),
+ 'utf8'
+ )
+
+ // Act
+ reconstructTemplateIndexes(configurationsDir, templateStatistics)
+
+ // Assert
+ assert.strictEqual(templateStatistics.get('template-a')?.indexes.size, 1)
+ assert.strictEqual(templateStatistics.get('template-a')?.indexes.has(1), true)
+ })
+ })
+})