- name: Setup node ${{ matrix.node }}
uses: actions/setup-node@v7
with:
- node-version: ${{ matrix.node }}
+ # SemVer '*' excludes prereleases; the 'latest' alias does not.
+ node-version: ${{ matrix.node == 'latest' && '*' || matrix.node }}
+ check-latest: ${{ matrix.node == 'latest' }}
cache: 'pnpm'
- name: pnpm install
run: pnpm install --ignore-scripts --frozen-lockfile
- name: Setup node ${{ matrix.node }}
uses: actions/setup-node@v7
with:
- node-version: ${{ matrix.node }}
+ node-version: ${{ matrix.node == 'latest' && '*' || matrix.node }}
+ check-latest: ${{ matrix.node == 'latest' }}
cache: 'pnpm'
- name: pnpm install
run: pnpm install --ignore-scripts --frozen-lockfile
- name: Setup node ${{ matrix.node }}
uses: actions/setup-node@v7
with:
- node-version: ${{ matrix.node }}
+ node-version: ${{ matrix.node == 'latest' && '*' || matrix.node }}
+ check-latest: ${{ matrix.node == 'latest' }}
cache: 'pnpm'
- name: pnpm install
run: pnpm install --ignore-scripts --frozen-lockfile
src/assets/ev-profiles*.json
!src/assets/ev-profiles-template.json
src/assets/configs-docker/*.json
+src/assets/configurations/
mikro-orm.config*.ts
!mikro-orm.config-template.ts
manifest*.yml
**Configuration files locations**:
-- charging stations simulator configuration: [src/assets/config.json](./src/assets/config.json);
-- charging station configuration templates: [src/assets/station-templates](./src/assets/station-templates);
-- charging station configurations: [dist/assets/configurations](./dist/assets/configurations);
-- charging station RFID tags lists: [src/assets](./src/assets).
+`<assets-dir>` is `dist/assets` in production and `src/assets` in development. Setting `EVSE_ASSETS_DIR` replaces the entire assets root, using an absolute path or a path relative to the working directory.
-The charging stations simulator's configuration parameters must be within the `src/assets/config.json` file. A charging station simulator configuration template file is available at [src/assets/config-template.json](./src/assets/config-template.json).
+Before starting the simulator with `EVSE_ASSETS_DIR`, populate the selected directory with the required assets, including `station-templates/` and the complete `json-schemas/ocpp/` tree.
-All charging station configuration templates are in the directory [src/assets/station-templates](./src/assets/station-templates).
+- charging stations simulator configuration: `<assets-dir>/config.json`;
+- charging station configuration templates: `<assets-dir>/station-templates`;
+- charging station configurations: `<assets-dir>/configurations`;
+- charging station RFID tags lists: `<assets-dir>`.
-A list of RFID tags must be defined for the automatic transaction generator in a file with the default location and name: `src/assets/idtags.json`. A template file is available at [src/assets/idtags-template.json](./src/assets/idtags-template.json).
+A charging stations simulator configuration template is available at [src/assets/config-template.json](./src/assets/config-template.json).
+
+RFID tags lists are read from the `<assets-dir>` root, using each station template's `idTagsFile` value. A template file is available at [src/assets/idtags-template.json](./src/assets/idtags-template.json).
**Configuration files hierarchy and priority**:
-1. charging station configuration: [dist/assets/configurations](./dist/assets/configurations);
-2. charging station configuration template: [src/assets/station-templates](./src/assets/station-templates);
-3. charging stations simulator configuration: [src/assets/config.json](./src/assets/config.json).
+1. charging station configuration: `<assets-dir>/configurations`;
+2. charging station configuration template: `<assets-dir>/station-templates`;
+3. charging stations simulator configuration: `<assets-dir>/config.json`.
The charging stations simulator has an automatic configuration files reload feature at change for:
- charging station configuration templates;
- charging station authorization RFID tags lists.
-But the modifications to test have to be done to the files in the build target directory [dist/assets](./dist/assets). Once the modifications are done, they have to be reported to the matching files in the build source directory [src/assets](./src/assets) to ensure they will be taken into account at next build.
+Without `EVSE_ASSETS_DIR`, edit configuration files in [src/assets](./src/assets). In production, the source-managed content of [dist/assets](./dist/assets) is rebuilt by `pnpm build` from `src/assets` and is never edited by hand. In development (`pnpm start:dev`), the simulator reads and reloads these source files directly (no build step). With `EVSE_ASSETS_DIR`, edit the files in the selected assets directory instead.
+
+The `<assets-dir>/configurations` subdirectory is created by the runtime when persistence is first used to persist charging station configurations, OCPP 2.0 certificates, and the simulator state. `pnpm build` neither cleans nor copies it, so it persists across builds.
### Charging stations simulator configuration
-**src/assets/config.json**:
+**`<assets-dir>/config.json`**:
| Key | Value(s) | Default Value | Value type | Description |
| -------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| uiServer | | {<br />"enabled": false,<br />"type": "ws",<br />"version": "1.1",<br />"accessPolicy": {<br />"requireTlsForNonLoopback": true,<br />"trustedProxies": [],<br />"allowLoopbackProxy": false,<br />"allowedHosts": [],<br />"allowedOrigins": []<br />},<br />"options": {<br />"host": "localhost",<br />"port": 8080<br />}<br />} | {<br />enabled?: boolean;<br />type?: ApplicationProtocol;<br />version?: ApplicationProtocolVersion;<br />accessPolicy?: {<br />requireTlsForNonLoopback?: boolean;<br />trustedProxies?: string[];<br />allowLoopbackProxy?: boolean;<br />allowedHosts?: string[];<br />allowedOrigins?: string[];<br />};<br />options?: ServerOptions;<br />authentication?: {<br />enabled: boolean;<br />type: AuthenticationType;<br />username?: string;<br />password?: string;<br />};<br />metrics?: {<br />enabled?: boolean;<br />softSampleCap?: number;<br />};<br />securityHeaders?: {<br />strictTransportSecurity?: string \| false;<br />};<br />} | UI server configuration section:<br />- _enabled_: enable UI server<br />- _type_: 'ws', 'mcp' or 'http' (deprecated)<br />- _version_: HTTP version '1.1' or '2.0' (ws and mcp transports only support '1.1')<br />- _accessPolicy_: gateway access policy. Loopback request sources are allowed in plaintext; non-loopback sources require TLS termination by a reverse proxy:<br /> - _requireTlsForNonLoopback_: reject non-loopback plaintext requests; the check honors `X-Forwarded-Proto` or `Forwarded: proto=` from a trusted proxy, non-loopback requests without forwarded protocol headers are denied as `tls-required`<br /> - _trustedProxies_: IPv4 or IPv6 literals of the immediate reverse proxies whose forwarded headers are honored (hostnames and CIDR ranges are not accepted; only single-hop forwarded chains are honored); a compromised entry can bypass per-client rate limiting by varying `X-Forwarded-For`<br /> - _allowLoopbackProxy_: accept forwarded headers when the immediate peer is loopback AND listed in _trustedProxies_ (e.g. `['127.0.0.1', '::1']`)<br /> - _allowedHosts_: explicit Host header allowlist; mitigates DNS rebinding when the UI server is exposed through a browser-facing host<br /> - _allowedOrigins_: explicit Origin header allowlist; when empty, the request Origin's URL hostname falls back to matching against _allowedHosts_<br />- _options_: node.js net module [listen options](https://nodejs.org/api/net.html#serverlistenoptions-callback)<br />- _authentication_: authentication type configuration section<br />- _metrics_: opt-in Prometheus `/metrics` endpoint (served on the configured `uiServer.type` listener — `http`, `ws` or `mcp`):<br /> - _enabled_: enable the `/metrics` endpoint<br /> - _softSampleCap_: soft cardinality cap above which a single warn is logged per scrape (default 5000)<br />- _securityHeaders_: opt-in security response headers emitted on every UI-server HTTP response path served over secure transport, across the `http`, `ws` and `mcp` transports (denials, WebSocket upgrade rejections, `http` success responses and the `/metrics` endpoint), except MCP JSON-RPC success bodies (written by the MCP SDK transport); absent by default so the default response behavior is unchanged:<br /> - _strictTransportSecurity_: `Strict-Transport-Security` (HSTS) header value, emitted verbatim (and unvalidated) when set to a non-empty string; `false` (default) or an empty string omits the header. Emission is gated on secure transport — direct TLS or a trusted-proxy-forwarded `https`/`wss` protocol — per [RFC 6797](https://www.rfc-editor.org/rfc/rfc6797) §7.2 (HSTS must not be sent over non-secure transport), so it is omitted over plaintext (e.g. loopback development). Recommended production value behind a TLS-terminating reverse proxy or native TLS: `"max-age=31536000; includeSubDomains"`. Caution: `includeSubDomains` extends the policy to every subdomain of the served host and `preload` (with submission to hstspreload.org) is effectively irreversible (removal propagates through browser preload lists over ~6–12 months); only use `includeSubDomains`/`preload` on a dedicated hostname you fully control, never on a shared host (including `localhost`) |
| performanceStorage | | {<br />"enabled": true,<br />"type": "none",<br />} | {<br />enabled?: boolean;<br />type?: string;<br />uri?: string;<br />} | Performance storage configuration section:<br />- _enabled_: enable performance storage<br />- _type_: 'jsonfile', 'mongodb' or 'none'<br />- _uri_: storage URI |
| stationTemplateUrls | | {}[] | {<br />file: string;<br />numberOfStations: number;<br />provisionedNumberOfStations?: number;<br />}[] | array of charging station templates URIs configuration section:<br />- _file_: charging station configuration template file relative path<br />- _numberOfStations_: template number of stations at startup<br />- _provisionedNumberOfStations_: template provisioned number of stations after startup |
-| persistState | true/false | true | boolean | persist the simulator stopped state to `dist/assets/configurations/.simulator-state.json`. On the next process startup, if the simulator was stopped via the UI procedure `stopSimulator`, charging stations are not auto-spawned and the user can recover via the `startSimulator` procedure. Signal-driven shutdowns (SIGINT/SIGTERM/SIGQUIT) and configuration-reload restarts do not modify the persisted state. The feature requires `uiServer.enabled` to be `true`; otherwise it is silently ignored (no recovery channel without UI). Set the environment variable `SIMULATOR_COLD_START` to a truthy value (case-insensitive `true` or `1`, optionally surrounded by whitespace) for one run to ignore the persisted state and force a cold start. UI-added charging stations beyond `numberOfStations` are not auto-respawned |
+| persistState | true/false | true | boolean | persist the simulator stopped state to `<assets-dir>/configurations/.simulator-state.json`. On the next process startup, if the simulator was stopped via the UI procedure `stopSimulator`, charging stations are not auto-spawned and the user can recover via the `startSimulator` procedure. Signal-driven shutdowns (SIGINT/SIGTERM/SIGQUIT) and configuration-reload restarts do not modify the persisted state. The feature requires `uiServer.enabled` to be `true`; otherwise it is silently ignored (no recovery channel without UI). Set the environment variable `SIMULATOR_COLD_START` to a truthy value (case-insensitive `true` or `1`, optionally surrounded by whitespace) for one run to ignore the persisted state and force a cold start. UI-added charging stations beyond `numberOfStations` are not auto-respawned |
#### Worker process model
### Charging station template configuration
-**src/assets/station-templates/\<name\>.json**:
+**`<assets-dir>/station-templates/<name>.json`**:
| Key | Value(s) | Default Value | Value type | Description |
| ---------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ocppVersion | 1.6/2.0/2.0.1 | 1.6 | string | OCPP version |
| ocppProtocol | json | json | string | OCPP protocol |
| ocppStrictCompliance | true/false | true | boolean | enable strict adherence to the OCPP version and protocol specifications with OCPP commands PDU validation against [OCA](https://www.openchargealliance.org/) JSON schemas |
-| ocppPersistentConfiguration | true/false | true | boolean | enable persistent OCPP parameters storage by charging stations 'hashId'. The persistency is ensured by the charging stations configuration files in [dist/assets/configurations](./dist/assets/configurations) |
-| stationInfoPersistentConfiguration | true/false | true | boolean | enable persistent station information and specifications storage by charging stations 'hashId'. The persistency is ensured by the charging stations configuration files in [dist/assets/configurations](./dist/assets/configurations) |
-| automaticTransactionGeneratorPersistentConfiguration | true/false | true | boolean | enable persistent automatic transaction generator configuration storage by charging stations 'hashId'. The persistency is ensured by the charging stations configuration files in [dist/assets/configurations](./dist/assets/configurations) |
+| ocppPersistentConfiguration | true/false | true | boolean | enable persistent OCPP parameters storage by charging stations 'hashId'. The persistency is ensured by the charging stations configuration files in `<assets-dir>/configurations` |
+| stationInfoPersistentConfiguration | true/false | true | boolean | enable persistent station information and specifications storage by charging stations 'hashId'. The persistency is ensured by the charging stations configuration files in `<assets-dir>/configurations` |
+| automaticTransactionGeneratorPersistentConfiguration | true/false | true | boolean | enable persistent automatic transaction generator configuration storage by charging stations 'hashId'. The persistency is ensured by the charging stations configuration files in `<assets-dir>/configurations` |
| wsOptions | | {} | ClientOptions & ClientRequestArgs | [ws](https://github.com/websockets/ws) and node.js [http](https://nodejs.org/api/http.html) clients options intersection |
-| idTagsFile | | undefined | string | RFID tags list file relative to [src/assets](./src/assets) path |
+| idTagsFile | | undefined | string | RFID tags list file name, at the root of the `<assets-dir>` directory |
| iccid | | undefined | string | SIM card ICCID |
| imsi | | undefined | string | SIM card IMSI |
| baseName | | undefined | string | base name to build charging stations id |
| phaseLineToLineVoltageMeterValues | true/false | false | boolean | include charging stations line-to-line voltage MeterValues on three-phase charging stations |
| customValueLimitationMeterValues | true/false | true | boolean | enable limitation on custom fluctuated value in MeterValues |
| coherentMeterValues | true/false | false | boolean | enable physics-based coherent MeterValues generation. When `true`, emitted voltage, current, power, imported-energy register, and SoC are derived from a single physics chain. INV-1 is `P = V·I·phases` for AC and `P = V·I` for DC; INV-2 is monotone non-decreasing SoC; INV-3 is `ΔE = P·Δt/3.6e6` with a non-decreasing energy register. `ΔE` is an internal per-sample intermediate, not an emitted measurand. Requires `evProfilesFile` to be set; falls back to the default random/fixed generation with a warning if the file is absent or malformed |
-| evProfilesFile | | undefined | string | EV profile file relative to [src/assets](./src/assets) path. Consumed only when `coherentMeterValues` is `true`. See [EV profile file format](#ev-profile-file-format) |
+| evProfilesFile | | undefined | string | EV profile file name, at the root of the `<assets-dir>` directory. Consumed only when `coherentMeterValues` is `true`. See [EV profile file format](#ev-profile-file-format) |
| randomSeed | | undefined (derived from `hashId` via FNV-1a) | integer | optional 32-bit unsigned integer seed for the coherent MeterValues PRNG. When set, identical `(randomSeed, transactionId)` inputs produce identical PRNG streams (and therefore identical per-measurand noise sequences); emitted energy and SoC additionally depend on `intervalMs` and elapsed session time. When absent, the seed is derived deterministically from the station `hashId`. Ignored when `coherentMeterValues` is `false` or absent |
| firmwareUpgrade | | {<br />"versionUpgrade": {<br />"step": 1<br />},<br />"reset": true<br />} | {<br />versionUpgrade?: {<br />patternGroup?: number;<br />step?: number;<br />};<br />reset?: boolean;<br />failureStatus?: 'DownloadFailed' \| 'InstallationFailed';<br />} | Configuration section for simulating firmware upgrade support. |
| commandsSupport | | {<br />"incomingCommands": {},<br />"outgoingCommands": {}<br />} | {<br /> incomingCommands: Record<IncomingRequestCommand, boolean>;<br />outgoingCommands?: Record<RequestCommand, boolean>;<br />} | Configuration section for OCPP commands support. Empty section or subsections means all implemented OCPP commands are supported |
The EV profile file is a JSON file referenced by the `evProfilesFile` template field. It defines a pool of EV charging profiles from which one is selected per transaction (weighted random, seeded deterministically). Consumed only when `coherentMeterValues` is `true`.
-**Schema** (`src/assets/<name>.json`):
+**Schema** (`<assets-dir>/<name>.json`):
```json
{
### Charging station configuration
-**dist/assets/configurations/\<hashId\>.json**:
+**`<assets-dir>/configurations/[<template-relative-parent>/]<hashId>.json`**:
The charging station configuration file is automatically generated at startup from the charging station configuration template file and is persistent.
+The optional `<template-relative-parent>/` segment preserves the template's parent directories under `station-templates`; it is omitted for flat templates. For example, `station-templates/vendor/model.json` persists to `<assets-dir>/configurations/vendor/<hashId>.json`.
+
The charging station configuration file content can be regenerated partially on matching charging station configuration template file changes. The charging station serial number is kept unchanged.
#### stationInfo section (optional)
"build-requirements": "node scripts/build-requirements.js",
"start": "pnpm build && cross-env NODE_ENV=production node dist/start.js",
"start:prof": "pnpm build && cross-env NODE_ENV=production node --prof --interpreted-frames-native-stack dist/start.js",
- "start:dev": "pnpm build:dev && cross-env NODE_ENV=development node --enable-source-maps dist/start.js",
- "start:dev:debug": "pnpm build:dev && cross-env NODE_ENV=development node --enable-source-maps --inspect dist/start.js",
- "start:dev:prof": "pnpm build:dev && cross-env NODE_ENV=development node --enable-source-maps --prof --interpreted-frames-native-stack dist/start.js",
+ "start:dev": "cross-env NODE_ENV=development node --import tsx --enable-source-maps src/start.ts",
+ "start:dev:debug": "cross-env NODE_ENV=development node --import tsx --enable-source-maps --inspect src/start.ts",
+ "start:dev:prof": "cross-env NODE_ENV=development node --import tsx --enable-source-maps --prof --interpreted-frames-native-stack src/start.ts",
"esbuild": "pnpm build-requirements && node scripts/bundle.js",
"build": "pnpm esbuild",
"build:dev": "cross-env BUILD=development pnpm esbuild",
"lint": "cross-env TIMING=1 eslint --cache src tests scripts ./*.js ./*.ts",
"lint:fix": "cross-env TIMING=1 eslint --cache --fix src tests scripts ./*.js ./*.ts",
"format": "prettier --cache --write .; eslint --cache --fix src tests scripts ./*.js ./*.ts",
- "test": "cross-env NODE_ENV=test node --import tsx --test --test-force-exit 'tests/**/*.test.ts'",
- "test:debug": "cross-env NODE_ENV=test node --import tsx --test --inspect 'tests/**/*.test.ts'",
- "test:coverage": "mkdir -p coverage && cross-env NODE_ENV=test node --import tsx --test --test-force-exit --experimental-test-coverage --test-coverage-include='src/**/*.ts' --test-reporter=lcov --test-reporter-destination=coverage/lcov.info 'tests/**/*.test.ts'",
+ "test": "node tests/stubs/setupAssetsFixture.mjs && cross-env NODE_ENV=test node --import tsx --import ./tests/stubs/testEnvBootstrap.mjs --test --test-force-exit 'tests/**/*.test.ts'",
+ "test:debug": "node tests/stubs/setupAssetsFixture.mjs && cross-env NODE_ENV=test node --import tsx --import ./tests/stubs/testEnvBootstrap.mjs --test --inspect 'tests/**/*.test.ts'",
+ "test:coverage": "node tests/stubs/setupAssetsFixture.mjs && mkdir -p coverage && cross-env NODE_ENV=test node --import tsx --import ./tests/stubs/testEnvBootstrap.mjs --test --test-force-exit --experimental-test-coverage --test-coverage-include='src/**/*.ts' --test-reporter=lcov --test-reporter-destination=coverage/lcov.info 'tests/**/*.test.ts'",
"sea": "pnpm exec rimraf ./dist/evse-simulator ./dist/evse-simulator.blob && node --experimental-sea-config sea-config.json && pnpm dlx ncp $(mise which node || n which lts || nvm which node || command -v node) ./dist/evse-simulator && pnpm dlx postject ./dist/evse-simulator NODE_SEA_BLOB ./dist/evse-simulator.blob --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 && pnpm exec rimraf ./dist/evse-simulator.blob"
},
"dependencies": {
import packageJson from '../../package.json' with { type: 'json' }
import { BaseError, TimeoutError } from '../exception/index.js'
import { type Storage, StorageFactory } from '../performance/index.js'
+import { getConfigurationsDir, getStationTemplatesDir } from '../resolveAssetsDir.js'
import {
type ChargingStationData,
type ChargingStationInfo,
)
}
- private readonly assetsDir: string
private readonly configurationsDir: string
private shuttingDown: boolean
private started: boolean
this.shuttingDown = 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.configurationsDir = getConfigurationsDir()
this.stateFilePath = join(this.configurationsDir, '.simulator-state.json')
this.warnPersistStateWithoutUIServerOnce = once(() => {
logger.warn(
const stationInfo = await this.workerImplementation?.addElement({
index,
options,
- templateFile: join(this.assetsDir, 'station-templates', templateFile),
+ templateFile: join(getStationTemplatesDir(), templateFile),
})
const templateStatistics = this.templateStatistics.get(buildTemplateName(templateFile))
if (stationInfo != null && templateStatistics != null) {
import { BaseError, OCPPError } from '../exception/index.js'
import { PerformanceStatistics } from '../performance/index.js'
+import { getStationConfigurationDir } from '../resolveAssetsDir.js'
import {
type AutomaticTransactionGeneratorConfiguration,
AvailabilityType,
throw new BaseError(errorMsg)
}
this.configurationFile = join(
- dirname(this.templateFile.replace('station-templates', 'configurations')),
+ getStationConfigurationDir(this.templateFile),
`${getHashId(this.index, stationTemplate)}.json`
)
const stationConfiguration = this.getConfigurationFromFile()
* (`import { validateStationInfo, ... } from './Helpers.js'`).
*/
-import { basename, dirname, isAbsolute, join, parse, relative, resolve } from 'node:path'
-import { fileURLToPath } from 'node:url'
+import { basename, join, parse } from 'node:path'
import type { ChargingStation } from './ChargingStation.js'
import { BaseError } from '../exception/index.js'
+import { getAssetFilePath, getStationTemplateRelativePath } from '../resolveAssetsDir.js'
import {
AmpereUnits,
type ChargingStationConfiguration,
/**
* Derives the template basename (path without extension) relative to the
- * `assets/station-templates` directory. Absolute paths are converted to
- * paths relative to that directory first.
+ * `station-templates` subdirectory of the assets directory. Relative paths are
+ * interpreted against that subdirectory; absolute paths are converted to paths
+ * relative to it. Both forms share selected-root alias normalization.
* @param templateFile - Template file path (absolute or relative).
* @returns Template basename without extension.
*/
export const buildTemplateName = (templateFile: string): string => {
- if (isAbsolute(templateFile)) {
- templateFile = relative(
- resolve(join(dirname(fileURLToPath(import.meta.url)), 'assets', 'station-templates')),
- templateFile
- )
- }
- const templateFileParsedPath = parse(templateFile)
+ const templateFileParsedPath = parse(getStationTemplateRelativePath(templateFile))
return join(templateFileParsedPath.dir, templateFileParsedPath.name)
}
/**
* Resolves the on-disk path to the RFID id-tags file referenced by
- * `stationInfo.idTagsFile`, rooted at the bundled `assets` directory.
+ * `stationInfo.idTagsFile`, rooted at the `assets` directory.
* @param stationInfo - Station-info carrying `idTagsFile`.
* @returns Absolute path, or `undefined` when `idTagsFile` is not set.
*/
export const getIdTagsFile = (stationInfo: ChargingStationInfo): string | undefined => {
return stationInfo.idTagsFile != null
- ? join(dirname(fileURLToPath(import.meta.url)), 'assets', basename(stationInfo.idTagsFile))
+ ? getAssetFilePath(basename(stationInfo.idTagsFile))
: undefined
}
/**
* Resolves the on-disk path to the EV profiles file referenced by
- * `stationInfo.evProfilesFile`, rooted at the bundled `assets` directory.
+ * `stationInfo.evProfilesFile`, rooted at the `assets` directory.
* @param stationInfo - Station-info carrying `evProfilesFile`.
* @returns Absolute path, or `undefined` when `evProfilesFile` is not set.
*/
export const getEvProfilesFile = (stationInfo: ChargingStationInfo): string | undefined => {
return stationInfo.evProfilesFile != null
- ? join(dirname(fileURLToPath(import.meta.url)), 'assets', basename(stationInfo.evProfilesFile))
+ ? getAssetFilePath(basename(stationInfo.evProfilesFile))
: undefined
}
import { randomInt } from 'node:crypto'
import { createWriteStream, readdirSync } from 'node:fs'
import { dirname, extname, join, resolve } from 'node:path'
-import { fileURLToPath, URL } from 'node:url'
+import process from 'node:process'
+import { URL } from 'node:url'
import { create } from 'tar'
import {
)
return OCPP16Constants.OCPP_RESPONSE_EMPTY
}
- const logFiles = readdirSync(
- resolve(dirname(fileURLToPath(import.meta.url)), '../', dirname(logFile))
- )
+ // Log files are written by winston relative to the working directory, so the
+ // scan must be too (a module-relative scan read the source tree in dev).
+ const logFiles = readdirSync(resolve(process.cwd(), dirname(logFile)))
.filter(file => file.endsWith(extname(logFile)))
.map(file => join(dirname(logFile), file))
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
})
})
uploadResponse = await ftpClient.uploadFrom(
- join(resolve(dirname(fileURLToPath(import.meta.url)), '../'), diagnosticsArchive),
+ join(process.cwd(), diagnosticsArchive),
`${uri.pathname}${diagnosticsArchive}`
)
if (!this.isIncomingRequestDeliveryCurrent(chargingStation)) {
TransactionMeterValueDeliveryBarrier,
} from '../../../charging-station/meter-values/index.js'
import { BaseError, OCPPError } from '../../../exception/index.js'
+import { OCPP_SCHEMA_SUBDIR } from '../../../resolveAssetsDir.js'
import {
type ConfigurationKey,
type ConnectorStatus,
public static createPayloadOptions = (moduleName: string, methodName: string) =>
PayloadValidatorOptions(
OCPPVersion.VERSION_16,
- 'assets/json-schemas/ocpp/1.6',
+ `${OCPP_SCHEMA_SUBDIR}/1.6`,
moduleName,
methodName
)
// Copyright Jerome Benoit. 2021-2025. All Rights Reserved.
import { hash, X509Certificate } from 'node:crypto'
+import { realpathSync } from 'node:fs'
import { readdir, readFile, realpath, rm, stat } from 'node:fs/promises'
-import { join, resolve, sep } from 'node:path'
+import { basename, dirname, join, resolve, sep } from 'node:path'
import type { ChargingStation } from '../../index.js'
import { BaseError } from '../../../exception/index.js'
+import { getConfigurationsDir } from '../../../resolveAssetsDir.js'
import {
type CertificateHashDataChainType,
type CertificateHashDataType,
* - Manage certificate storage paths
*/
export class OCPP20CertificateManager {
- private static readonly BASE_CERT_PATH = 'dist/assets/configurations'
+ private static readonly BASE_CERT_PATH = (() => {
+ const configurationsDir = getConfigurationsDir()
+ // Canonicalize the existing assets root without creating the lazy configurations directory.
+ // Match the native semantics used by fs.promises.realpath during containment checks.
+ return join(realpathSync.native(dirname(configurationsDir)), basename(configurationsDir))
+ })()
+
private static readonly CERT_FOLDER = 'certs'
private static readonly PEM_BEGIN_MARKER = '-----BEGIN CERTIFICATE-----'
private static readonly PEM_END_MARKER = '-----END CERTIFICATE-----'
truncateTransactionIntervalValue,
} from '../../../charging-station/meter-values/index.js'
import { BaseError, OCPPError } from '../../../exception/index.js'
+import { OCPP_SCHEMA_SUBDIR } from '../../../resolveAssetsDir.js'
import {
AvailabilityType,
type ConfigurationKeyType,
public static createPayloadOptions = (moduleName: string, methodName: string) =>
PayloadValidatorOptions(
OCPPVersion.VERSION_201,
- 'assets/json-schemas/ocpp/2.0',
+ `${OCPP_SCHEMA_SUBDIR}/2.0`,
moduleName,
methodName
)
import { isDate } from 'date-fns'
import { randomInt } from 'node:crypto'
import { readFileSync } from 'node:fs'
-import { dirname, join } from 'node:path'
-import { fileURLToPath } from 'node:url'
import type {
BootReasonEnumType,
truncateTransactionIntervalValue,
} from '../../charging-station/meter-values/index.js'
import { BaseError, OCPPError } from '../../exception/index.js'
+import { getAssetFilePath } from '../../resolveAssetsDir.js'
import {
type BootNotificationRequest,
type ChargingStationInfo,
/**
* Parses and loads a JSON schema file for OCPP payload validation.
* Handles file reading and JSON parsing for schema validation.
- * @param relativePath - Path to the schema file relative to the OCPP utils directory
+ * @param relativePath - Path to the schema file relative to the assets directory
* @param ocppVersion - The OCPP version for error logging context
* @param moduleName - Optional module name for error logging
* @param methodName - Optional method name for error logging
* @returns Parsed JSON schema object
- * @throws {NodeJS.ErrnoException} If the schema file cannot be read or parsed
+ * @throws {NodeJS.ErrnoException | SyntaxError} If the schema file cannot be read (ErrnoException) or the JSON is invalid (SyntaxError)
*/
function parseJsonSchemaFile<T extends JsonType> (
relativePath: string,
moduleName?: string,
methodName?: string
): JSONSchemaType<T> {
- const baseDir = dirname(fileURLToPath(import.meta.url))
- // Primary: resolve from file directory (production esbuild bundle)
- const primaryPath = join(baseDir, relativePath)
+ const schemaPath = getAssetFilePath(relativePath)
try {
- return JSON.parse(readFileSync(primaryPath, 'utf8')) as JSONSchemaType<T>
- } catch (primaryError) {
- // Fallback: resolve from source root (development/test with tsx)
- const fallbackPath = join(baseDir, '..', '..', relativePath)
- try {
- return JSON.parse(readFileSync(fallbackPath, 'utf8')) as JSONSchemaType<T>
- } catch {
- handleFileException(
- primaryPath,
- FileType.JsonSchema,
- primaryError as NodeJS.ErrnoException,
- ocppServiceUtilsLogPrefix(ocppVersion, moduleName, methodName)
- )
- // handleFileException throws by default; this satisfies the compiler
- throw primaryError
- }
+ return JSON.parse(readFileSync(schemaPath, 'utf8')) as JSONSchemaType<T>
+ } catch (error) {
+ handleFileException(
+ schemaPath,
+ FileType.JsonSchema,
+ error as NodeJS.ErrnoException,
+ ocppServiceUtilsLogPrefix(ocppVersion, moduleName, methodName)
+ )
+ // handleFileException throws by default; this satisfies the compiler
+ throw error
}
}
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import { getReasonPhrase, StatusCodes } from 'http-status-codes'
import { readFileSync } from 'node:fs'
-import { dirname, join } from 'node:path'
-import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
import type { IBootstrap } from '../IBootstrap.js'
import type { AbstractUIService } from './ui-services/AbstractUIService.js'
import { BaseError } from '../../exception/index.js'
+import { getOcppSchemaBaseDir } from '../../resolveAssetsDir.js'
import {
OCPPVersion,
type ProcedureName,
}
protected getSchemaBaseDir (): string {
- return join(dirname(fileURLToPath(import.meta.url)), 'assets', 'json-schemas', 'ocpp')
+ return getOcppSchemaBaseDir()
}
private checkVersionCompatibility (
import { type McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'
import { readdir, readFile } from 'node:fs/promises'
-import { dirname, join, resolve } from 'node:path'
-import { fileURLToPath } from 'node:url'
+import { join, resolve, sep } from 'node:path'
import type { AbstractUIServer } from '../AbstractUIServer.js'
+import { getOcppSchemaBaseDir } from '../../../resolveAssetsDir.js'
import { MapStringifyFormat, OCPPVersion } from '../../../types/index.js'
import { JSONStringify } from '../../../utils/index.js'
const OCPP_SCHEMA_VERSIONS = [OCPPVersion.VERSION_16, OCPPVersion.VERSION_20] as const
-const getSchemaBaseDir = (): string => {
- const currentDir = dirname(fileURLToPath(import.meta.url))
- return join(currentDir, 'assets', 'json-schemas', 'ocpp')
-}
-
// Path traversal guard: validate that the resolved path stays within the expected base directory.
const isPathWithinBase = (candidatePath: string, baseDir: string): boolean => {
const resolvedBase = resolve(baseDir)
const resolvedCandidate = resolve(candidatePath)
- return resolvedCandidate.startsWith(`${resolvedBase}/`) || resolvedCandidate === resolvedBase
+ return resolvedCandidate.startsWith(resolvedBase + sep) || resolvedCandidate === resolvedBase
}
export const registerMCPSchemaResources = (server: McpServer): void => {
},
async _uri => {
try {
- const baseDir = getSchemaBaseDir()
+ const baseDir = getOcppSchemaBaseDir()
const schemaDir = join(baseDir, version)
if (!isPathWithinBase(schemaDir, baseDir)) {
return {
try {
const versionStr = version as string
const commandStr = command as string
- const baseDir = getSchemaBaseDir()
+ const baseDir = getOcppSchemaBaseDir()
const schemaPath = join(baseDir, versionStr, `${commandStr}.json`)
if (!isPathWithinBase(schemaPath, baseDir)) {
return {
--- /dev/null
+// Partial Copyright Jerome Benoit. 2021-2025. All Rights Reserved.
+
+import { BaseError } from './BaseError.js'
+
+/**
+ * Thrown when `EVSE_ASSETS_DIR` is set to a path that does not exist or is
+ * not a directory.
+ */
+export class AssetsDirError extends BaseError {}
+export { AssetsDirError } from './AssetsDirError.js'
export { BaseError } from './BaseError.js'
export { OCPPError } from './OCPPError.js'
export { TimeoutError } from './TimeoutError.js'
--- /dev/null
+// Partial Copyright Jerome Benoit. 2021-2025. All Rights Reserved.
+
+import { realpathSync, statSync } from 'node:fs'
+import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path'
+import { env } from 'node:process'
+import { getEnvironmentData, isMainThread, setEnvironmentData } from 'node:worker_threads'
+
+import { AssetsDirError } from './exception/AssetsDirError.js'
+
+interface AssetsDirSelection {
+ readonly canonicalDir: string
+ readonly cwd?: string
+ readonly dir: string
+ readonly lexicalDir?: string
+ readonly override: string
+}
+
+const ASSETS_DIR_SELECTION_KEY = 'evse.assetsDirSelection'
+const DEFAULT_ASSETS_DIR = join(import.meta.dirname, 'assets')
+const CONFIGURATIONS_DIR_NAME = 'configurations'
+const STATION_TEMPLATES_DIR_NAME = 'station-templates'
+
+let cachedSelection: AssetsDirSelection | undefined = isMainThread
+ ? undefined
+ : (getEnvironmentData(ASSETS_DIR_SELECTION_KEY) as AssetsDirSelection | undefined)
+if (cachedSelection !== undefined) {
+ Object.freeze(cachedSelection)
+}
+
+/**
+ * @file Single source of truth for locating the project assets directory.
+ *
+ * Every on-disk asset path is derived from this module so that there is only
+ * one place describing how the `assets/` directory is resolved.
+ *
+ * Contract: the resolved directory must contain the expected asset layout: a
+ * `config.json` file (if absent, the simulator falls back to its built-in
+ * default configuration), a `station-templates/` subdirectory with the
+ * charging station configuration templates, a `json-schemas/` subdirectory
+ * with the OCPP JSON schemas, and optionally RFID tags or ev-profiles files.
+ * It must also be writable: the runtime creates a `configurations/`
+ * subdirectory in it (lazily, on the first persistence write) to persist
+ * per-station charging station configuration, OCPP 2.0 certificates, and the
+ * simulator state. The expected layout is a contract, not validated: the
+ * resolver asserts that an override target exists and is a directory, and
+ * never checks the content, so a wrong layout fails at the scattered
+ * downstream readers. The source tree additionally holds a `ui-protocol/`
+ * subdirectory with Insomnia WebSocket request collections (see the README);
+ * it is an artifact for humans: no code reads it, and `pnpm build` removes
+ * it from `dist/assets`.
+ *
+ * Bundle: when the code is bundled (`pnpm build`), `import.meta.dirname`
+ * evaluates to the output bundle directory, so this resolves to `dist/assets`
+ * for code bundled into `dist/start.js`. When running directly from TypeScript
+ * sources via `tsx` (development and tests), it resolves to `src/assets`, which
+ * is the single, editable source of truth.
+ */
+
+/**
+ * Resolves the directory that holds the project assets.
+ *
+ * Lookup order:
+ * 1. `EVSE_ASSETS_DIR` — explicit override, set by the test scripts, and
+ * available for packaging or custom deployments. An empty value is treated
+ * as unset. A non-empty value must point to an existing directory,
+ * otherwise {@link AssetsDirError} is thrown. Absolute paths are used as-is;
+ * relative paths are resolved against the current working directory. Some
+ * consumers resolve the directory once at module load or process start, so
+ * set this variable before starting the process.
+ * Each successful selection is validated and its physical root cached until
+ * a lookup observes a changed override, a changed working directory for a
+ * relative override, or an unset override. Derived paths stay anchored to
+ * that physical root; retargeting a symlink alone does not refresh them.
+ * The public return value retains the original absolute spelling.
+ * Downstream readers handle later removal or replacement of the selected
+ * physical directory. Failed selections are not cached.
+ * New workers inherit the selecting thread's immutable physical root and
+ * logical alias context before asset consumers initialize. An inherited
+ * selection still follows the worker's own override and cwd checks below.
+ * Workers created before any selection resolve from their environment.
+ * 2. `<this-module-dir>/assets` — the `assets/` folder that sits beside this
+ * module. It is never validated, as it exists by construction (`src/assets`
+ * is tracked in the repository and `dist/assets` is created by `pnpm build`).
+ * @throws {AssetsDirError} When `EVSE_ASSETS_DIR` is set but does not point to an existing directory.
+ * @returns Absolute path to the assets directory.
+ */
+export function resolveAssetsDir (): string {
+ const override = env.EVSE_ASSETS_DIR
+ if (!override) {
+ if (cachedSelection !== undefined) {
+ cachedSelection = undefined
+ setEnvironmentData(ASSETS_DIR_SELECTION_KEY, undefined)
+ }
+ return DEFAULT_ASSETS_DIR
+ }
+ if (cachedSelection?.override === override && cachedSelection.cwd === undefined) {
+ return cachedSelection.dir
+ }
+ const cwd = isAbsolute(override) ? undefined : process.cwd()
+ if (cachedSelection?.override === override && cachedSelection.cwd === cwd) {
+ return cachedSelection.dir
+ }
+ if (cachedSelection !== undefined) {
+ cachedSelection = undefined
+ setEnvironmentData(ASSETS_DIR_SELECTION_KEY, undefined)
+ }
+ const dir = cwd === undefined ? override : resolve(cwd, override)
+ assertAssetsDir(override, dir)
+ const canonicalDir = realpathSync.native(dir)
+ cachedSelection = Object.freeze({
+ canonicalDir,
+ cwd,
+ dir,
+ lexicalDir: getEquivalentLexicalDir(dir, canonicalDir),
+ override,
+ })
+ setEnvironmentData(ASSETS_DIR_SELECTION_KEY, cachedSelection)
+ return dir
+}
+
+/**
+ * Asserts that a new `EVSE_ASSETS_DIR` selection exists and is a directory
+ * before caching it. Repeated calls for that selection need no filesystem
+ * access; a failed assertion leaves no cached selection.
+ * @param override - The raw `EVSE_ASSETS_DIR` value, for the error message.
+ * @param dir - The resolved directory to assert.
+ * @throws {AssetsDirError} When the directory does not exist or is not a directory.
+ */
+function assertAssetsDir (override: string, dir: string): void {
+ if (statSync(dir, { throwIfNoEntry: false })?.isDirectory() !== true) {
+ throw new AssetsDirError(
+ `EVSE_ASSETS_DIR is set to '${override}', which resolves to '${dir}', but that path does not exist or is not a directory. ` +
+ 'Check the path, or unset EVSE_ASSETS_DIR to use the built-in assets directory.'
+ )
+ }
+}
+
+/**
+ * Retains a normalized alias only when it denotes the selected physical root.
+ * Lexical normalization can otherwise select a different directory when a
+ * parent segment follows a symlink.
+ * @param dir - Selected directory before lexical normalization.
+ * @param canonicalDir - Selected physical directory.
+ * @returns An equivalent lexical alias, or undefined when none is needed or proven.
+ */
+function getEquivalentLexicalDir (dir: string, canonicalDir: string): string | undefined {
+ const lexicalDir = normalize(dir)
+ if (lexicalDir === canonicalDir) {
+ return
+ }
+ if (lexicalDir === dir) {
+ return lexicalDir
+ }
+ try {
+ if (realpathSync.native(lexicalDir) === canonicalDir) {
+ return lexicalDir
+ }
+ } catch {
+ // The normalized spelling is optional; the selected physical root is valid.
+ }
+}
+
+/**
+ * Checks whether a relative path stays inside its base directory.
+ * @param subpath - Path relative to the base directory.
+ * @returns Whether the path names the base or one of its descendants.
+ */
+const isRelativeSubpath = (subpath: string): boolean =>
+ subpath !== '..' && !subpath.startsWith(`..${sep}`) && !isAbsolute(subpath)
+
+/**
+ * Builds an absolute asset file path from a subpath relative to the assets
+ * directory. Single join primitive shared by every derived asset path so that
+ * the `<assets-dir>/<subpath>` layout stays in one place.
+ * @param subpath - Asset path relative to the assets directory (e.g. `configurations`, `station-templates`).
+ * @returns Absolute path to the asset file.
+ */
+export const getAssetFilePath = (subpath: string): string => {
+ const dir = resolveAssetsDir()
+ return join(cachedSelection?.canonicalDir ?? dir, subpath)
+}
+
+/**
+ * Resolves the directory that holds the charging station configurations.
+ * @returns `<assets-dir>/configurations`
+ */
+export const getConfigurationsDir = (): string => getAssetFilePath(CONFIGURATIONS_DIR_NAME)
+
+/**
+ * Resolves the directory that holds the charging station configuration templates.
+ * @returns `<assets-dir>/station-templates`
+ */
+export const getStationTemplatesDir = (): string => getAssetFilePath(STATION_TEMPLATES_DIR_NAME)
+
+/**
+ * Resolves a template path relative to the selected templates directory.
+ * Equivalent selected-root aliases retain their logical subpaths, including
+ * child junctions. The most specific matching root wins when aliases overlap.
+ * Paths outside that tree retain lexical relative semantics. Relative inputs
+ * are interpreted against the selected templates directory, matching template
+ * file dispatch.
+ * @param templatePath - Template file or directory path.
+ * @returns Path relative to the selected templates directory.
+ */
+export const getStationTemplateRelativePath = (templatePath: string): string => {
+ const templatesDir = getStationTemplatesDir()
+ const absoluteTemplatePath = isAbsolute(templatePath)
+ ? templatePath
+ : join(templatesDir, templatePath)
+ const subpath = relative(templatesDir, absoluteTemplatePath)
+ if (cachedSelection?.lexicalDir !== undefined) {
+ const lexicalTemplatesDir = join(cachedSelection.lexicalDir, STATION_TEMPLATES_DIR_NAME)
+ const lexicalSubpath = relative(lexicalTemplatesDir, absoluteTemplatePath)
+ if (
+ isRelativeSubpath(lexicalSubpath) &&
+ (!isRelativeSubpath(subpath) || lexicalTemplatesDir.length > templatesDir.length)
+ ) {
+ return lexicalSubpath
+ }
+ }
+ return subpath
+}
+
+/**
+ * Resolves the persistence directory for a charging station template, preserving
+ * its parent subpath under the canonical templates directory. External template
+ * trees use the nearest exact `station-templates` ancestor; templates without one
+ * retain storage beside the template file.
+ * @param templateFile - Charging station template file path.
+ * @returns Absolute directory for the template's per-station configuration files.
+ */
+export const getStationConfigurationDir = (templateFile: string): string => {
+ const templateParent = dirname(resolve(templateFile))
+ const templateSubpath = getStationTemplateRelativePath(templateParent)
+ if (isRelativeSubpath(templateSubpath)) {
+ return join(getConfigurationsDir(), templateSubpath)
+ }
+ let ancestor = templateParent
+ for (;;) {
+ if (basename(ancestor) === STATION_TEMPLATES_DIR_NAME) {
+ return join(dirname(ancestor), CONFIGURATIONS_DIR_NAME, relative(ancestor, templateParent))
+ }
+ const parent = dirname(ancestor)
+ if (parent === ancestor) {
+ return templateParent
+ }
+ ancestor = parent
+ }
+}
+
+/**
+ * Resolves the path of the simulator configuration file.
+ * @returns `<assets-dir>/config.json`
+ */
+export const getConfigFilePath = (): string => getAssetFilePath('config.json')
+
+/**
+ * OCPP JSON schemas subdirectory, relative to the assets directory. It holds
+ * one subdirectory per OCPP schema version (`1.6`, `2.0`).
+ */
+export const OCPP_SCHEMA_SUBDIR = 'json-schemas/ocpp'
+
+/**
+ * Resolves the directory that holds the OCPP JSON command schemas.
+ * @returns `<assets-dir>/json-schemas/ocpp`
+ */
+export const getOcppSchemaBaseDir = (): string => getAssetFilePath(OCPP_SCHEMA_SUBDIR)
configured: number
/**
* Template indexes known to the simulator: those allocated in the current process plus those reconstructed at startup
- * from existing charging station configuration files in `dist/assets/configurations/`. Used by `getLastContiguousIndex`
+ * from existing charging station configuration files in `<assets-dir>/configurations/`. Used by `getLastContiguousIndex`
* to allocate collision-free indexes when adding stations via UI. May exceed `added` because reconstructed indexes
* reserve disk-persisted slots without re-spawning their stations.
*/
import chalk from 'chalk'
import { existsSync, type FSWatcher, readFileSync, watch } from 'node:fs'
-import { dirname, join } from 'node:path'
import { env } from 'node:process'
-import { fileURLToPath } from 'node:url'
import { BaseError } from '../exception/index.js'
+import { getConfigFilePath } from '../resolveAssetsDir.js'
import {
ApplicationProtocol,
ApplicationProtocolVersion,
private static configurationSectionCache: Map<ConfigurationSection, ConfigurationSectionType>
static {
- const configurationFile = join(dirname(fileURLToPath(import.meta.url)), 'assets', 'config.json')
+ const configurationFile = getConfigFilePath()
if (existsSync(configurationFile)) {
Configuration.configurationFile = configurationFile
} else {
console.error(
`${chalk.green(configurationLogPrefix())} ${chalk.red(
- "Configuration file './src/assets/config.json' not found, using default configuration"
+ `Configuration file '${configurationFile}' not found, using default configuration`
)}`
)
Configuration.configurationData = {
/**
* @file Tests for Bootstrap lifecycle state machine
* @description Verifies start/stop idempotency, in-flight transition memoization,
- * signal handler re-entrancy, state-file consistency on no-op stop, and idempotent
- * guard log levels. Covers the contract documented in
- * `.hermes/findings/bootstrap-state-machine-race.md`.
+ * signal handler re-entrancy, state-file consistency on no-op stop, idempotent
+ * guard log levels, and template identity across the real worker lifecycle.
+ * Covers the contract documented in `.hermes/findings/bootstrap-state-machine-race.md`.
*/
import assert from 'node:assert/strict'
import { spawn } from 'node:child_process'
import { EventEmitter } from 'node:events'
-import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import {
+ copyFileSync,
+ mkdirSync,
+ readFileSync,
+ realpathSync,
+ rmSync,
+ symlinkSync,
+ writeFileSync,
+} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, it, mock } from 'node:test'
import { Bootstrap, STATE_FILE_VERSION } from '../../src/charging-station/index.js'
+import { getAssetFilePath } from '../../src/resolveAssetsDir.js'
+import { ApplicationProtocolVersion } from '../../src/types/ConfigurationData.js'
+import { ApplicationProtocol } from '../../src/types/UIProtocol.js'
import { logger, sleep } from '../../src/utils/index.js'
+import { WorkerProcessType } from '../../src/worker/WorkerTypes.js'
import { resetSingleton, standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+import { TEST_HEARTBEAT_INTERVAL_SECONDS } from './ChargingStationTestConstants.js'
+import {
+ cleanupStationTemplates,
+ copyStationTemplate,
+} from './helpers/StationHelpers.realStation.js'
interface Barrier {
promise: Promise<void>
})
afterEach(() => {
+ cleanupStationTemplates()
rmSync(testDir, { force: true, recursive: true })
resetSingleton(Bootstrap)
mock.restoreAll()
standardCleanup()
})
+ await it('should preserve aliased template identity across the real worker lifecycle', async () => {
+ // Arrange
+ // Resolve aliased temp parents so the selected-root alias overlaps the canonical tree.
+ const assetsDir = join(realpathSync.native(testDir), 'assets')
+ const templatesDir = join(assetsDir, 'station-templates')
+ const aliasDir = join(templatesDir, 'assets-alias')
+ mkdirSync(join(templatesDir, 'vendor'), { recursive: true })
+ symlinkSync(assetsDir, aliasDir, 'junction')
+ symlinkSync(getAssetFilePath('json-schemas'), join(assetsDir, 'json-schemas'), 'junction')
+ copyFileSync(
+ copyStationTemplate({ idTagsFile: undefined }),
+ join(templatesDir, 'vendor', 'model.station-template.json')
+ )
+ const configuration = {
+ $schemaVersion: 2,
+ log: {
+ enabled: false,
+ errorFile: join(testDir, 'error.log'),
+ file: join(testDir, 'combined.log'),
+ statisticsInterval: 0,
+ },
+ performanceStorage: { enabled: false },
+ persistState: false,
+ stationTemplateUrls: [
+ {
+ file: join('assets-alias', 'station-templates', 'vendor', 'model.station-template.json'),
+ numberOfStations: 1,
+ },
+ ],
+ uiServer: {
+ enabled: true,
+ options: { host: '127.0.0.1', port: 0 },
+ type: ApplicationProtocol.WS,
+ version: ApplicationProtocolVersion.VERSION_11,
+ },
+ worker: {
+ elementAddDelay: 0,
+ elementsPerWorker: 2,
+ processType: WorkerProcessType.workerSet,
+ startDelay: 0,
+ },
+ }
+ const moduleUrl = (relativePath: string): string =>
+ JSON.stringify(new URL(relativePath, import.meta.url).href)
+ const fixturePath = join(testDir, 'template-identity.mjs')
+ writeFileSync(
+ fixturePath,
+ String.raw`
+import assert from 'node:assert/strict'
+import { once } from 'node:events'
+import { writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { WebSocketServer } from ${JSON.stringify(import.meta.resolve('ws'))}
+
+const csms = new WebSocketServer({ host: '127.0.0.1', port: 0 })
+await once(csms, 'listening')
+const configuration = ${JSON.stringify(configuration)}
+configuration.supervisionUrls = ['ws://127.0.0.1:' + csms.address().port + '/ocpp']
+await writeFile(${JSON.stringify(join(assetsDir, 'config.json'))}, JSON.stringify(configuration))
+// Static imports would capture configuration before this fixture writes it.
+const { Bootstrap } = await import(${moduleUrl('../../src/charging-station/Bootstrap.ts')})
+const { Configuration } = await import(${moduleUrl('../../src/utils/Configuration.ts')})
+const { generateUUID } = await import(${moduleUrl('../../src/utils/Utils.ts')})
+const { ChargingStationWorkerMessageEvents } = await import(${moduleUrl('../../src/types/ChargingStationWorker.ts')})
+const { ProcedureName, ResponseStatus } = await import(${moduleUrl('../../src/types/UIProtocol.ts')})
+const { OCPP16RequestCommand } = await import(${moduleUrl('../../src/types/ocpp/1.6/Requests.ts')})
+const { RegistrationStatusEnumType } = await import(${moduleUrl('../../src/types/ocpp/Common.ts')})
+const expectedName = join('vendor', 'model.station-template')
+const bootstrap = Bootstrap.getInstance()
+const uiServer = Reflect.get(bootstrap, 'uiServer')
+const nextEvent = event => once(bootstrap, event, { signal: AbortSignal.timeout(90_000) })
+const request = async (procedure, payload = {}) => {
+ const result = await uiServer.sendInternalRequest(
+ uiServer.buildProtocolRequest(generateUUID(), procedure, payload)
+ )
+ // Broadcast requests complete through worker events, not return values.
+ if (result === undefined) return
+ const [, response] = result
+ assert.strictEqual(response.status, ResponseStatus.SUCCESS, JSON.stringify(response))
+ return response
+}
+const assertStatistics = (added, started, indexes) => {
+ const state = bootstrap.getState()
+ assert.deepStrictEqual([...state.templateStatistics.keys()], [expectedName])
+ const statistics = state.templateStatistics.get(expectedName)
+ assert.strictEqual(statistics.configured, 1)
+ assert.strictEqual(statistics.added, added)
+ assert.strictEqual(statistics.started, started)
+ assert.deepStrictEqual([...statistics.indexes].sort((a, b) => a - b), indexes)
+ assert.strictEqual(bootstrap.getLastContiguousIndex(expectedName), indexes.at(-1) ?? 0)
+}
+csms.on('connection', socket => socket.on('message', raw => {
+ const [kind, id, action] = JSON.parse(String(raw))
+ if (kind !== 2) return
+ const response = action === OCPP16RequestCommand.BOOT_NOTIFICATION
+ ? { status: RegistrationStatusEnumType.ACCEPTED, currentTime: new Date().toISOString(), interval: ${TEST_HEARTBEAT_INTERVAL_SECONDS} }
+ : action === OCPP16RequestCommand.HEARTBEAT ? { currentTime: new Date().toISOString() } : {}
+ socket.send(JSON.stringify([3, id, response]))
+}))
+try {
+ const initialStarted = nextEvent(ChargingStationWorkerMessageEvents.started)
+ const [, [initial]] = await Promise.all([bootstrap.start(), initialStarted])
+ assert.strictEqual(initial.stationInfo.templateName, expectedName)
+ assertStatistics(1, 1, [1])
+ assert.deepStrictEqual((await request(ProcedureName.LIST_TEMPLATES)).templates, [expectedName])
+
+ const secondStarted = nextEvent(ChargingStationWorkerMessageEvents.started)
+ await request(ProcedureName.ADD_CHARGING_STATIONS, { template: expectedName, numberOfStations: 1 })
+ const [second] = await secondStarted
+ assert.strictEqual(second.stationInfo.templateName, expectedName)
+ assert.strictEqual(second.stationInfo.templateIndex, 2)
+ assertStatistics(2, 2, [1, 2])
+
+ const hashIds = [second.stationInfo.hashId]
+ const secondStopped = nextEvent(ChargingStationWorkerMessageEvents.stopped)
+ await request(ProcedureName.STOP_CHARGING_STATION, { hashIds })
+ await secondStopped
+ assertStatistics(2, 1, [1, 2])
+
+ const secondDeleted = nextEvent(ChargingStationWorkerMessageEvents.deleted)
+ await request(ProcedureName.DELETE_CHARGING_STATIONS, { hashIds })
+ await secondDeleted
+ assertStatistics(1, 1, [1])
+
+ const replacementStarted = nextEvent(ChargingStationWorkerMessageEvents.started)
+ await request(ProcedureName.ADD_CHARGING_STATIONS, { template: expectedName, numberOfStations: 1 })
+ const [replacement] = await replacementStarted
+ assert.strictEqual(replacement.stationInfo.templateIndex, 2)
+ assert.strictEqual(replacement.stationInfo.templateName, expectedName)
+ assertStatistics(2, 2, [1, 2])
+
+ await bootstrap.stop()
+ assert.strictEqual(bootstrap.getState().started, false)
+ assertStatistics(2, 0, [1, 2])
+} catch (error) {
+ console.error(error)
+ process.exitCode = 1
+} finally {
+ await bootstrap.stop()
+ const httpServer = Reflect.get(uiServer, 'httpServer')
+ const wasListening = httpServer.listening
+ uiServer.stop()
+ if (wasListening) await once(httpServer, 'close')
+ Reflect.get(Configuration, 'configurationFileWatcher')?.close()
+ for (const socket of csms.clients) socket.terminate()
+ await new Promise(resolve => csms.close(resolve))
+}
+process.exit(process.exitCode ?? 0)
+`,
+ 'utf8'
+ )
+
+ // Act
+ const child = spawn(process.execPath, ['--import', 'tsx', fixturePath], {
+ env: { ...process.env, EVSE_ASSETS_DIR: aliasDir },
+ killSignal: 'SIGKILL',
+ stdio: ['ignore', 'pipe', 'pipe'],
+ timeout: 120_000,
+ })
+ let diagnostics = ''
+ child.stdout.setEncoding('utf8').on('data', (chunk: string) => {
+ diagnostics += chunk
+ })
+ child.stderr.setEncoding('utf8').on('data', (chunk: string) => {
+ diagnostics += chunk
+ })
+ const exitCode = await new Promise<null | number>((resolve, reject) => {
+ child.once('error', reject)
+ child.once('close', resolve)
+ })
+
+ // Assert: the isolated child checks actual worker events and UI allocation.
+ assert.strictEqual(exitCode, 0, diagnostics)
+ })
+
await describe('concurrent stop() callers', async () => {
await it('must observe the same in-flight transition', async () => {
const bootstrap = buildLifecycleTestInstance(stateFilePath)
--- /dev/null
+/**
+ * @file Tests for ChargingStation configuration persistence
+ * @description Verifies isolated flat and nested template storage and restoration
+ * from the same persisted configuration path.
+ */
+import { secondsToMilliseconds } from 'date-fns'
+import assert from 'node:assert/strict'
+import { copyFileSync, existsSync, mkdirSync, readFileSync, symlinkSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import type { ChargingStation } from '../../src/charging-station/ChargingStation.js'
+import type {
+ ChargingStationConfiguration,
+ ChargingStationTemplate,
+} from '../../src/types/index.js'
+
+import { setConfigurationKeyValue } from '../../src/charging-station/ConfigurationKeyUtils.js'
+import { getHashId } from '../../src/charging-station/HelpersId.js'
+import { getAssetFilePath } from '../../src/resolveAssetsDir.js'
+import { StandardParametersKey } from '../../src/types/index.js'
+import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+import {
+ TEST_CHARGING_STATION_BASE_NAME,
+ TEST_CUSTOM_HEARTBEAT_INTERVAL_SECONDS,
+} from './ChargingStationTestConstants.js'
+import {
+ cleanupStationTemplates,
+ copyStationTemplate,
+ createStationFromTemplate,
+} from './helpers/StationHelpers.realStation.js'
+
+const OVERRIDE_KEY = 'EVSE_ASSETS_DIR'
+
+interface StationPersistence {
+ pendingConfigurationSave: Promise<void>
+}
+
+const waitForConfigurationSave = async (station: ChargingStation): Promise<void> => {
+ await (station as unknown as StationPersistence).pendingConfigurationSave
+}
+
+await describe('ChargingStation configuration persistence', async () => {
+ let originalOverride: string | undefined
+ let stations: ChargingStation[]
+
+ beforeEach(() => {
+ originalOverride = process.env[OVERRIDE_KEY]
+ stations = []
+ })
+
+ afterEach(async () => {
+ try {
+ for (const station of stations) {
+ await waitForConfigurationSave(station)
+ await station.delete(false)
+ }
+ } finally {
+ standardCleanup()
+ cleanupStationTemplates()
+ if (originalOverride === undefined) {
+ Reflect.deleteProperty(process.env, OVERRIDE_KEY)
+ } else {
+ process.env[OVERRIDE_KEY] = originalOverride
+ }
+ }
+ })
+
+ const makeStation = (templateFile: string): ChargingStation => {
+ const station = createStationFromTemplate(templateFile, {
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ fixedName: true,
+ persistentConfiguration: true,
+ })
+ stations.push(station)
+ return station
+ }
+
+ const persistenceCases = [
+ { canonical: false, name: 'flat external templates', parent: '' },
+ { canonical: false, name: 'nested external templates', parent: 'vendor' },
+ { canonical: true, name: 'flat canonical templates', parent: '' },
+ { canonical: true, name: 'nested canonical templates', parent: 'vendor' },
+ {
+ assetsName: 'station-templates-assets',
+ canonical: true,
+ name: 'an assets root containing station-templates in its name',
+ parent: 'vendor',
+ },
+ {
+ canonical: true,
+ name: 'a canonical vendor directory named station-templates',
+ parent: join('vendor', 'station-templates', 'model'),
+ },
+ {
+ assetsAlias: true,
+ assetsName: 'selected-assets',
+ canonical: true,
+ name: 'a selected assets alias with linked templates and vendor directories',
+ parent: join('vendor', 'linked', 'station-templates', 'model'),
+ },
+ {
+ aliasInTemplates: true,
+ assetsAlias: true,
+ assetsName: 'selected-assets',
+ canonical: true,
+ name: 'an assets alias nested under the selected templates directory',
+ parent: 'vendor',
+ },
+ {
+ canonical: false,
+ name: 'the nearest external station-templates directory',
+ parent: join('vendor', 'station-templates', 'model'),
+ storageParent: join('station-templates', 'vendor'),
+ storageSubpath: 'model',
+ },
+ {
+ canonical: false,
+ name: 'an external template without a station-templates directory',
+ outsideTemplates: true,
+ parent: 'vendor',
+ },
+ ]
+
+ for (const persistenceCase of persistenceCases) {
+ await it(`should persist and restore configuration for ${persistenceCase.name}`, async () => {
+ // Arrange
+ const sourceTemplate = copyStationTemplate()
+ const root = dirname(dirname(sourceTemplate))
+ const assetsDir =
+ persistenceCase.assetsName == null ? root : join(root, persistenceCase.assetsName)
+ const selectedAssetsDir =
+ persistenceCase.assetsAlias === true
+ ? persistenceCase.aliasInTemplates === true
+ ? join(assetsDir, 'station-templates', 'assets-alias')
+ : join(root, 'assets-alias')
+ : assetsDir
+ if (persistenceCase.assetsAlias === true) {
+ const templatesDir = join(root, 'templates-storage')
+ const vendorDir = join(root, 'vendor-target')
+ mkdirSync(join(templatesDir, 'vendor'), { recursive: true })
+ mkdirSync(vendorDir)
+ mkdirSync(assetsDir)
+ symlinkSync(templatesDir, join(assetsDir, 'station-templates'), 'junction')
+ symlinkSync(vendorDir, join(templatesDir, 'vendor', 'linked'), 'junction')
+ symlinkSync(assetsDir, selectedAssetsDir, 'junction')
+ }
+ const templateParent =
+ persistenceCase.outsideTemplates === true
+ ? join(root, 'external', persistenceCase.parent)
+ : join(selectedAssetsDir, 'station-templates', persistenceCase.parent)
+ mkdirSync(templateParent, { recursive: true })
+ const templateFile = join(templateParent, 'model.json')
+ copyFileSync(sourceTemplate, templateFile)
+ if (persistenceCase.canonical) {
+ symlinkSync(getAssetFilePath('json-schemas'), join(assetsDir, 'json-schemas'), 'junction')
+ process.env[OVERRIDE_KEY] = selectedAssetsDir
+ }
+ const template = JSON.parse(readFileSync(templateFile, 'utf8')) as ChargingStationTemplate
+ const expectedConfigurationDir =
+ persistenceCase.outsideTemplates === true
+ ? templateParent
+ : join(
+ assetsDir,
+ persistenceCase.storageParent ?? '',
+ 'configurations',
+ persistenceCase.storageSubpath ?? persistenceCase.parent
+ )
+ const expectedConfigurationFile = join(
+ expectedConfigurationDir,
+ `${getHashId(1, template)}.json`
+ )
+
+ // Act
+ const station = makeStation(templateFile)
+ await waitForConfigurationSave(station)
+
+ // Assert
+ assert.strictEqual(existsSync(expectedConfigurationFile), true)
+ if (persistenceCase.assetsAlias === true) {
+ assert.strictEqual(station.stationInfo?.templateName, join(persistenceCase.parent, 'model'))
+ }
+ if (persistenceCase.canonical) {
+ assert.strictEqual(
+ existsSync(join(assetsDir, 'station-templates', 'configurations')),
+ false
+ )
+ }
+ const initialConfiguration = JSON.parse(
+ readFileSync(expectedConfigurationFile, 'utf8')
+ ) as ChargingStationConfiguration
+ assert.strictEqual(
+ initialConfiguration.stationInfo?.chargingStationId,
+ TEST_CHARGING_STATION_BASE_NAME
+ )
+
+ // Change a public OCPP setting, then reconstruct from exactly the same file.
+ const heartbeatInterval = String(TEST_CUSTOM_HEARTBEAT_INTERVAL_SECONDS)
+ assert.notStrictEqual(
+ setConfigurationKeyValue(
+ station,
+ StandardParametersKey.HeartbeatInterval,
+ heartbeatInterval
+ ),
+ undefined
+ )
+ await waitForConfigurationSave(station)
+ const savedConfiguration = JSON.parse(
+ readFileSync(expectedConfigurationFile, 'utf8')
+ ) as ChargingStationConfiguration
+ assert.strictEqual(
+ savedConfiguration.configurationKey?.find(
+ entry => entry.key === StandardParametersKey.HeartbeatInterval.toString()
+ )?.value,
+ heartbeatInterval
+ )
+ await station.delete(false)
+ standardCleanup()
+ const restoredStation = makeStation(templateFile)
+ await waitForConfigurationSave(restoredStation)
+ assert.strictEqual(
+ restoredStation.getHeartbeatInterval(),
+ secondsToMilliseconds(TEST_CUSTOM_HEARTBEAT_INTERVAL_SECONDS)
+ )
+ assert.strictEqual(existsSync(expectedConfigurationFile), true)
+ })
+ }
+})
* @description Unit tests for OCPP 2.0 certificate management and validation
*/
+// First import by contract: project modules capture asset paths at module
+// load, so the bootstrap must set EVSE_ASSETS_DIR before any of them loads.
+import '../../../stubs/testEnvBootstrap.mjs'
import assert from 'node:assert/strict'
-import { rm } from 'node:fs/promises'
+import { spawn } from 'node:child_process'
+import { mkdir, rm, symlink } from 'node:fs/promises'
+import { basename, join } from 'node:path'
import { afterEach, beforeEach, describe, it } from 'node:test'
import { OCPP20CertificateManager } from '../../../../src/charging-station/ocpp/2.0/OCPP20CertificateManager.js'
+import { resolveAssetsDir } from '../../../../src/resolveAssetsDir.js'
import {
type CertificateHashDataType,
DeleteCertificateStatusEnumType,
HashAlgorithmEnumType,
InstallCertificateUseEnumType,
} from '../../../../src/types/index.js'
+import { cleanupTempDirs, createTempDir, writeTempFile } from '../../../helpers/TempFiles.js'
import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
import { TEST_CHARGING_STATION_HASH_ID } from '../../ChargingStationTestConstants.js'
import {
await describe('I02-I04 - ISO15118 Certificate Management', async () => {
afterEach(async () => {
- await rm(`dist/assets/configurations/${TEST_CHARGING_STATION_HASH_ID}`, {
+ await rm(join(resolveAssetsDir(), 'configurations', TEST_CHARGING_STATION_HASH_ID), {
force: true,
recursive: true,
})
- await rm('dist/assets/configurations/empty-station-hash-id', {
+ await rm(join(resolveAssetsDir(), 'configurations', 'empty-station-hash-id'), {
force: true,
recursive: true,
})
- await rm('dist/assets/configurations/invalid-station-id', {
+ await rm(join(resolveAssetsDir(), 'configurations', 'invalid-station-id'), {
force: true,
recursive: true,
})
standardCleanup()
+ cleanupTempDirs()
+ })
+
+ await describe('symlinked assets root', async () => {
+ for (const scenario of [
+ 'missing configurations',
+ 'existing configurations',
+ 'escaping station',
+ ]) {
+ await it(`should handle ${scenario} through an assets alias`, async () => {
+ // Arrange
+ const fixtureDir = createTempDir('certificate-assets-alias-')
+ const assetsDir = join(fixtureDir, 'assets')
+ const aliasDir = join(fixtureDir, 'assets-alias')
+ const configurationsDir = join(assetsDir, 'configurations')
+ const outsideDir = join(fixtureDir, 'outside')
+ const stationLink = join(configurationsDir, 'escaping-station')
+ const configurationsExist = scenario !== 'missing configurations'
+ await mkdir(assetsDir)
+ if (configurationsExist) {
+ await mkdir(configurationsDir)
+ }
+ await symlink(assetsDir, aliasDir, 'junction')
+ if (scenario === 'escaping station') {
+ await mkdir(outsideDir)
+ await symlink(outsideDir, stationLink, 'junction')
+ }
+ const moduleUrl = (relativePath: string): string =>
+ JSON.stringify(new URL(relativePath, import.meta.url).href)
+ const script = writeTempFile(
+ fixtureDir,
+ 'scenario.mjs',
+ `
+import assert from 'node:assert/strict'
+import { existsSync } from 'node:fs'
+import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+
+const configurationsDir = ${JSON.stringify(configurationsDir)}
+assert.strictEqual(existsSync(configurationsDir), ${String(configurationsExist)})
+// Dynamic imports let assertions observe filesystem state before module initialization.
+const { OCPP20CertificateManager } = await import(${moduleUrl('../../../../src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts')})
+assert.strictEqual(existsSync(configurationsDir), ${String(configurationsExist)})
+const { resolveAssetsDir } = await import(${moduleUrl('../../../../src/resolveAssetsDir.ts')})
+const { DeleteCertificateStatusEnumType, GetCertificateIdUseEnumType, InstallCertificateUseEnumType } =
+ await import(${moduleUrl('../../../../src/types/index.ts')})
+const { VALID_X509_PEM_CERTIFICATE } = await import(${moduleUrl('./OCPP20CertificateTestData.ts')})
+assert.strictEqual(resolveAssetsDir(), ${JSON.stringify(aliasDir)})
+const manager = new OCPP20CertificateManager()
+const certificateType = InstallCertificateUseEnumType.CSMSRootCertificate
+const hashData = manager.computeCertificateHash(VALID_X509_PEM_CERTIFICATE)
+
+if (${JSON.stringify(scenario)} === 'escaping station') {
+ const outsideDir = ${JSON.stringify(outsideDir)}
+ const certDir = join(outsideDir, 'certs', certificateType)
+ await mkdir(certDir, { recursive: true })
+ const certPath = join(certDir, hashData.serialNumber.replace(/:/g, '-') + '.pem')
+ await writeFile(certPath, VALID_X509_PEM_CERTIFICATE)
+ const entriesBefore = await readdir(outsideDir, { recursive: true })
+ const result = await manager.storeCertificate(
+ 'escaping-station', certificateType, VALID_X509_PEM_CERTIFICATE, 'test |'
+ )
+ assert.strictEqual(result.success, false)
+ assert.match(result.error, /Path traversal attempt detected/)
+ assert.deepStrictEqual(await manager.getInstalledCertificates('escaping-station'), {
+ certificateHashDataChain: [],
+ })
+ assert.deepStrictEqual(await manager.deleteCertificate('escaping-station', hashData), {
+ status: DeleteCertificateStatusEnumType.Failed,
+ })
+ assert.strictEqual(await readFile(certPath, 'utf8'), VALID_X509_PEM_CERTIFICATE)
+ assert.deepStrictEqual(await readdir(outsideDir, { recursive: true }), entriesBefore)
+} else {
+ for (const station of ['first-fresh-station', 'second-fresh-station']) {
+ assert.strictEqual(existsSync(join(configurationsDir, station)), false)
+ const result = await manager.storeCertificate(
+ station, certificateType, VALID_X509_PEM_CERTIFICATE, 'test |'
+ )
+ assert.strictEqual(result.success, true, result.error)
+ assert.strictEqual(typeof result.filePath, 'string')
+ assert.strictEqual(await readFile(result.filePath, 'utf8'), VALID_X509_PEM_CERTIFICATE)
+ assert.strictEqual(existsSync(configurationsDir), true)
+ assert.deepStrictEqual(await manager.getInstalledCertificates(station), {
+ certificateHashDataChain: [{
+ certificateHashData: hashData,
+ certificateType: GetCertificateIdUseEnumType.CSMSRootCertificate,
+ }],
+ })
+ assert.deepStrictEqual(await manager.deleteCertificate(station, hashData), {
+ status: DeleteCertificateStatusEnumType.Accepted,
+ })
+ assert.strictEqual(existsSync(result.filePath), false)
+ assert.deepStrictEqual(await manager.getInstalledCertificates(station), {
+ certificateHashDataChain: [],
+ })
+ assert.deepStrictEqual(await manager.deleteCertificate(station, hashData), {
+ status: DeleteCertificateStatusEnumType.NotFound,
+ })
+ }
+}
+`
+ )
+
+ // Act
+ try {
+ const child = spawn(process.execPath, ['--import', 'tsx', script], {
+ env: { ...process.env, EVSE_ASSETS_DIR: aliasDir, NODE_ENV: 'test' },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ timeout: 120_000,
+ })
+ let diagnostics = ''
+ child.stdout.setEncoding('utf8').on('data', (chunk: string) => {
+ diagnostics += chunk
+ })
+ child.stderr.setEncoding('utf8').on('data', (chunk: string) => {
+ diagnostics += chunk
+ })
+ const { promise, reject, resolve } = Promise.withResolvers<null | number>()
+ child.once('error', reject)
+ child.once('close', resolve)
+ const exitCode = await promise
+
+ // Assert
+ assert.strictEqual(exitCode, 0, diagnostics)
+ } finally {
+ if (scenario === 'escaping station') {
+ await rm(stationLink, { force: true })
+ }
+ await rm(aliasDir, { force: true })
+ }
+ })
+ }
})
await describe('storeCertificate', async () => {
)
assert.notStrictEqual(path, undefined)
- const filename = path.split('/').pop()
- if (filename == null) {
- assert.fail('Expected filename to be defined')
- }
+ const filename = basename(path)
assert.ok(!filename.includes(':'))
assert.ok(!filename.includes('/'))
})
--- /dev/null
+/**
+ * @file Tests for resolveAssetsDir
+ * @description Verifies the single source of truth for the assets directory:
+ * the EVSE_ASSETS_DIR override (absolute used as-is, relative resolved against
+ * the current working directory, existing directory required) and the
+ * `<module-dir>/assets` fallback.
+ */
+import assert from 'node:assert/strict'
+import { once } from 'node:events'
+import {
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ realpathSync,
+ rmSync,
+ symlinkSync,
+ writeFileSync,
+} from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join, resolve, sep } from 'node:path'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+import { Worker } from 'node:worker_threads'
+
+import {
+ getConfigFilePath,
+ getConfigurationsDir,
+ getOcppSchemaBaseDir,
+ resolveAssetsDir,
+} from '../src/resolveAssetsDir.js'
+import { standardCleanup } from './helpers/TestLifecycleHelpers.js'
+
+const OVERRIDE_KEY = 'EVSE_ASSETS_DIR'
+
+interface WorkerAssetsResult {
+ config: string
+ relativeTemplatePath: string
+ template: string
+}
+
+/**
+ * Reads selected assets and persists configuration through a fresh real worker.
+ * @param templateFile - Template path used for logical namespace resolution.
+ * @returns The configuration and template actually read by the worker.
+ */
+const readAssetsInWorker = async (templateFile: string): Promise<WorkerAssetsResult> => {
+ const workerFile = join(fixtureDir, 'assets-worker.mjs')
+ writeFileSync(
+ workerFile,
+ `
+import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { parentPort, workerData } from 'node:worker_threads'
+import { register } from ${JSON.stringify(import.meta.resolve('tsx/esm/api'))}
+
+const unregister = register()
+// Registration must precede loading the TypeScript resolver in this fresh isolate.
+const {
+ getConfigFilePath,
+ getStationConfigurationDir,
+ getStationTemplateRelativePath,
+ getStationTemplatesDir,
+} = await import(${JSON.stringify(new URL('../src/resolveAssetsDir.ts', import.meta.url).href)})
+try {
+ const config = readFileSync(getConfigFilePath(), 'utf8')
+ const template = readFileSync(join(getStationTemplatesDir(), 'vendor', 'model.json'), 'utf8')
+ const configurationDir = getStationConfigurationDir(workerData.templateFile)
+ mkdirSync(configurationDir, { recursive: true })
+ writeFileSync(join(configurationDir, 'worker-state.json'), config)
+ parentPort.postMessage({
+ config,
+ relativeTemplatePath: getStationTemplateRelativePath(workerData.templateFile),
+ template,
+ })
+} finally {
+ unregister()
+}
+`,
+ 'utf8'
+ )
+ const worker = new Worker(workerFile, { execArgv: [], workerData: { templateFile } })
+ try {
+ const [result] = (await once(worker, 'message', {
+ signal: AbortSignal.timeout(30_000),
+ })) as [WorkerAssetsResult]
+ return result
+ } finally {
+ await worker.terminate()
+ }
+}
+
+/**
+ * Asserts that `fn` throws an `AssetsDirError` whose message names each of
+ * the given paths (the raw override and the resolved directory) and the
+ * stable failure phrasing. Substring-based, following the suite's regex
+ * message-assertion convention, so it does not duplicate the full message
+ * format of the implementation.
+ * @param fn - The function expected to throw.
+ * @param expectedPaths - The paths the error message must name.
+ */
+const assertAssetsDirError = (fn: () => string, ...expectedPaths: string[]): void => {
+ assert.throws(fn, (err: unknown) => {
+ assert.ok(err instanceof Error, `expected an Error, got: ${String(err)}`)
+ assert.strictEqual(err.name, 'AssetsDirError')
+ assert.match(err.message, /does not exist or is not a directory/)
+ for (const expectedPath of expectedPaths) {
+ assert.ok(
+ err.message.includes(`'${expectedPath}'`),
+ `expected the message to name '${expectedPath}', got: ${err.message}`
+ )
+ }
+ return true
+ })
+}
+
+let originalOverride: string | undefined
+let fixtureDir: string
+
+beforeEach(() => {
+ originalOverride = process.env[OVERRIDE_KEY]
+ Reflect.deleteProperty(process.env, OVERRIDE_KEY)
+ fixtureDir = mkdtempSync(join(tmpdir(), 'evse-assets-'))
+ standardCleanup()
+})
+
+afterEach(() => {
+ rmSync(fixtureDir, { force: true, recursive: true })
+ standardCleanup()
+ if (originalOverride === undefined) {
+ Reflect.deleteProperty(process.env, OVERRIDE_KEY)
+ } else {
+ process.env[OVERRIDE_KEY] = originalOverride
+ }
+})
+
+await describe('resolveAssetsDir', async () => {
+ await it('should fall back to the module-side assets directory when no override is set', () => {
+ assert.strictEqual(resolveAssetsDir(), join(import.meta.dirname, '..', 'src', 'assets'))
+ })
+
+ await it('should return an absolute override as-is', () => {
+ process.env[OVERRIDE_KEY] = fixtureDir
+ assert.strictEqual(resolveAssetsDir(), fixtureDir)
+ })
+
+ await it('should keep a trailing slash on an absolute override', () => {
+ // Contract: absolute paths are used as-is; consumers normalize via join/resolve.
+ process.env[OVERRIDE_KEY] = `${fixtureDir}/`
+ assert.strictEqual(resolveAssetsDir(), `${fixtureDir}/`)
+ })
+
+ await it('should resolve a relative override against the current working directory', () => {
+ const originalCwd = process.cwd()
+ try {
+ process.chdir(fixtureDir)
+ mkdirSync(join(fixtureDir, 'sub'))
+ process.env[OVERRIDE_KEY] = 'sub'
+ assert.strictEqual(resolveAssetsDir(), resolve(process.cwd(), 'sub'))
+ } finally {
+ process.chdir(originalCwd)
+ }
+ })
+
+ await it('should treat an empty override as unset', () => {
+ process.env[OVERRIDE_KEY] = ''
+ assert.strictEqual(resolveAssetsDir(), join(import.meta.dirname, '..', 'src', 'assets'))
+ })
+
+ await it('should throw AssetsDirError when the override points to a missing directory', () => {
+ process.env[OVERRIDE_KEY] = join(fixtureDir, 'missing')
+ assertAssetsDirError(() => resolveAssetsDir(), join(fixtureDir, 'missing'))
+ })
+
+ await it('should throw AssetsDirError for a missing relative override target', () => {
+ const originalCwd = process.cwd()
+ try {
+ process.chdir(fixtureDir)
+ process.env[OVERRIDE_KEY] = 'missing-sub'
+ assertAssetsDirError(
+ () => resolveAssetsDir(),
+ 'missing-sub',
+ join(process.cwd(), 'missing-sub')
+ )
+ } finally {
+ process.chdir(originalCwd)
+ }
+ })
+
+ await it('should throw AssetsDirError when the override points to a file', () => {
+ const filePath = join(fixtureDir, 'file.json')
+ writeFileSync(filePath, '')
+ process.env[OVERRIDE_KEY] = filePath
+ assertAssetsDirError(() => resolveAssetsDir(), filePath)
+ })
+
+ await it('should follow absolute override transitions from one directory to another and back', () => {
+ const secondDir = join(fixtureDir, 'second')
+ mkdirSync(secondDir)
+ writeFileSync(join(fixtureDir, 'config.json'), 'first')
+ writeFileSync(join(secondDir, 'config.json'), 'second')
+ for (const [directory, marker] of [
+ [fixtureDir, 'first'],
+ [secondDir, 'second'],
+ [fixtureDir, 'first'],
+ ]) {
+ process.env[OVERRIDE_KEY] = directory
+ assert.strictEqual(resolveAssetsDir(), directory)
+ assert.strictEqual(readFileSync(getConfigFilePath(), 'utf8'), marker)
+ }
+ })
+
+ for (const unsetOverride of [undefined, '']) {
+ await it(`should fall back after clearing an override with ${String(unsetOverride)}`, () => {
+ // Arrange
+ process.env[OVERRIDE_KEY] = fixtureDir
+ assert.strictEqual(resolveAssetsDir(), fixtureDir)
+
+ // Act
+ if (unsetOverride === undefined) {
+ Reflect.deleteProperty(process.env, OVERRIDE_KEY)
+ } else {
+ process.env[OVERRIDE_KEY] = unsetOverride
+ }
+ const fallbackDir = join(import.meta.dirname, '..', 'src', 'assets')
+
+ // Assert
+ assert.strictEqual(resolveAssetsDir(), fallbackDir)
+ assert.strictEqual(getConfigurationsDir(), join(fallbackDir, 'configurations'))
+ rmSync(fixtureDir, { recursive: true })
+ process.env[OVERRIDE_KEY] = fixtureDir
+ assertAssetsDirError(() => resolveAssetsDir(), fixtureDir)
+ })
+ }
+
+ await it('should reject an invalid override after a successful selection', () => {
+ process.env[OVERRIDE_KEY] = fixtureDir
+ assert.strictEqual(resolveAssetsDir(), fixtureDir)
+ const missingDir = join(fixtureDir, 'missing')
+ process.env[OVERRIDE_KEY] = missingDir
+ assertAssetsDirError(() => getConfigurationsDir(), missingDir)
+ })
+
+ await it('should retry a missing override after its directory is created', () => {
+ const missingDir = join(fixtureDir, 'created-later')
+ process.env[OVERRIDE_KEY] = missingDir
+ assertAssetsDirError(() => resolveAssetsDir(), missingDir)
+ mkdirSync(missingDir)
+ writeFileSync(join(missingDir, 'config.json'), 'created')
+ assert.strictEqual(resolveAssetsDir(), missingDir)
+ assert.strictEqual(readFileSync(getConfigFilePath(), 'utf8'), 'created')
+ })
+
+ await it('should follow cwd changes for the same relative override including missing targets', () => {
+ // Arrange
+ const originalCwd = process.cwd()
+ const firstCwd = join(fixtureDir, 'first')
+ const firstCwdAlias = join(fixtureDir, 'first-alias')
+ const secondCwd = join(fixtureDir, 'second')
+ const missingCwd = join(fixtureDir, 'missing')
+ mkdirSync(join(firstCwd, 'assets'), { recursive: true })
+ mkdirSync(join(secondCwd, 'assets'), { recursive: true })
+ writeFileSync(join(firstCwd, 'assets', 'config.json'), 'first')
+ writeFileSync(join(secondCwd, 'assets', 'config.json'), 'second')
+ mkdirSync(missingCwd)
+ symlinkSync(firstCwd, firstCwdAlias, 'junction')
+ process.env[OVERRIDE_KEY] = 'assets'
+
+ try {
+ // Act and assert
+ for (const [cwd, marker] of [
+ [firstCwdAlias, 'first'],
+ [secondCwd, 'second'],
+ [firstCwdAlias, 'first'],
+ ]) {
+ process.chdir(cwd)
+ const expectedAssetsDir = join(process.cwd(), 'assets')
+ assert.strictEqual(resolveAssetsDir(), expectedAssetsDir)
+ assert.strictEqual(readFileSync(getConfigFilePath(), 'utf8'), marker)
+ }
+ process.chdir(missingCwd)
+ const missingAssetsDir = join(process.cwd(), 'assets')
+ assertAssetsDirError(() => getOcppSchemaBaseDir(), 'assets', missingAssetsDir)
+ mkdirSync(missingAssetsDir)
+ assert.strictEqual(resolveAssetsDir(), missingAssetsDir)
+ } finally {
+ process.chdir(originalCwd)
+ }
+ })
+
+ await it('should discard a successful selection before rejecting a different override', () => {
+ // Arrange
+ const firstDir = join(fixtureDir, 'first')
+ const missingDir = join(fixtureDir, 'missing')
+ mkdirSync(firstDir)
+ process.env[OVERRIDE_KEY] = firstDir
+ assert.strictEqual(resolveAssetsDir(), firstDir)
+
+ // Act
+ process.env[OVERRIDE_KEY] = missingDir
+ assertAssetsDirError(() => resolveAssetsDir(), missingDir)
+ rmSync(firstDir, { recursive: true })
+ process.env[OVERRIDE_KEY] = firstDir
+
+ // Assert
+ assertAssetsDirError(() => resolveAssetsDir(), firstDir)
+ })
+
+ await it('should read and persist in the filesystem-selected root with a parent segment', () => {
+ // Arrange
+ const physicalRoot = join(fixtureDir, 'physical', 'assets')
+ const lexicalRoot = join(fixtureDir, 'assets')
+ const alias = join(fixtureDir, 'alias')
+ mkdirSync(join(fixtureDir, 'physical', 'child'), { recursive: true })
+ mkdirSync(physicalRoot)
+ mkdirSync(lexicalRoot)
+ symlinkSync(join(fixtureDir, 'physical', 'child'), alias, 'junction')
+ writeFileSync(join(physicalRoot, 'config.json'), 'physical')
+ writeFileSync(join(lexicalRoot, 'config.json'), 'lexical')
+ const override = `${alias}${sep}..${sep}assets${sep}`
+ process.env[OVERRIDE_KEY] = override
+
+ // Act and assert: use native filesystem traversal as the expected identity,
+ // including Windows, where parent segments can be normalized before junctions.
+ assert.strictEqual(resolveAssetsDir(), override)
+ assert.strictEqual(
+ readFileSync(getConfigFilePath(), 'utf8'),
+ readFileSync(`${override}config.json`, 'utf8')
+ )
+ mkdirSync(getConfigurationsDir())
+ writeFileSync(join(getConfigurationsDir(), 'state.json'), '{"persisted":true}')
+ assert.deepStrictEqual(
+ JSON.parse(readFileSync(`${override}configurations${sep}state.json`, 'utf8')),
+ { persisted: true }
+ )
+ })
+
+ await it('should retain a selected physical root until the selection is invalidated', () => {
+ // Arrange
+ const firstRoot = join(fixtureDir, 'first')
+ const secondRoot = join(fixtureDir, 'second')
+ const alias = join(fixtureDir, 'alias')
+ mkdirSync(firstRoot)
+ mkdirSync(secondRoot)
+ writeFileSync(join(firstRoot, 'config.json'), 'first')
+ writeFileSync(join(secondRoot, 'config.json'), 'second')
+ symlinkSync(firstRoot, alias, 'junction')
+ const override = `${alias}${sep}`
+ process.env[OVERRIDE_KEY] = override
+ assert.strictEqual(resolveAssetsDir(), override)
+
+ // Act
+ rmSync(alias, { force: true, recursive: true })
+ symlinkSync(secondRoot, alias, 'junction')
+
+ // Assert
+ assert.strictEqual(readFileSync(getConfigFilePath(), 'utf8'), 'first')
+ Reflect.deleteProperty(process.env, OVERRIDE_KEY)
+ resolveAssetsDir()
+ process.env[OVERRIDE_KEY] = override
+ assert.strictEqual(resolveAssetsDir(), override)
+ assert.strictEqual(readFileSync(getConfigFilePath(), 'utf8'), 'second')
+ })
+
+ await it('should preserve pinned reads, writes and logical namespaces in later workers', async () => {
+ // Arrange: native spelling makes the selected-root alias overlap the physical tree.
+ const firstRoot = join(realpathSync.native(fixtureDir), 'first')
+ const secondRoot = join(realpathSync.native(fixtureDir), 'second')
+ for (const [root, marker] of [
+ [firstRoot, 'first'],
+ [secondRoot, 'second'],
+ ]) {
+ mkdirSync(join(root, 'station-templates', 'vendor'), { recursive: true })
+ writeFileSync(join(root, 'config.json'), marker)
+ writeFileSync(join(root, 'station-templates', 'vendor', 'model.json'), marker)
+ }
+ const alias = join(firstRoot, 'station-templates', 'active')
+ symlinkSync(firstRoot, alias, 'junction')
+ process.env[OVERRIDE_KEY] = alias
+ assert.strictEqual(readFileSync(getConfigFilePath(), 'utf8'), 'first')
+ rmSync(alias, { recursive: true })
+ symlinkSync(secondRoot, alias, 'junction')
+ const templateFile = join(alias, 'station-templates', 'vendor', 'model.json')
+ const firstState = join(firstRoot, 'configurations', 'vendor', 'worker-state.json')
+ const secondState = join(secondRoot, 'configurations', 'vendor', 'worker-state.json')
+
+ // Act and assert: a new isolate must inherit both the physical root and logical alias.
+ assert.deepStrictEqual(await readAssetsInWorker(templateFile), {
+ config: 'first',
+ relativeTemplatePath: join('vendor', 'model.json'),
+ template: 'first',
+ })
+ assert.strictEqual(readFileSync(firstState, 'utf8'), 'first')
+ assert.strictEqual(existsSync(secondState), false)
+
+ // An observed unset revokes inheritance. Do not resolve again before the next spawn.
+ Reflect.deleteProperty(process.env, OVERRIDE_KEY)
+ resolveAssetsDir()
+ process.env[OVERRIDE_KEY] = alias
+ assert.deepStrictEqual(await readAssetsInWorker(templateFile), {
+ config: 'second',
+ relativeTemplatePath: join('vendor', 'model.json'),
+ template: 'second',
+ })
+ assert.strictEqual(readFileSync(firstState, 'utf8'), 'first')
+ assert.strictEqual(readFileSync(secondState, 'utf8'), 'second')
+ })
+
+ await it('should honor a changed worker override before the parent resolves it', async () => {
+ // Arrange
+ const firstRoot = join(fixtureDir, 'first')
+ const secondRoot = join(fixtureDir, 'second')
+ for (const [root, marker] of [
+ [firstRoot, 'first'],
+ [secondRoot, 'second'],
+ ]) {
+ mkdirSync(join(root, 'station-templates', 'vendor'), { recursive: true })
+ writeFileSync(join(root, 'config.json'), marker)
+ writeFileSync(join(root, 'station-templates', 'vendor', 'model.json'), marker)
+ }
+ process.env[OVERRIDE_KEY] = firstRoot
+ assert.strictEqual(readFileSync(getConfigFilePath(), 'utf8'), 'first')
+
+ // Act: the inherited selection is stale, but the worker's explicit override wins.
+ process.env[OVERRIDE_KEY] = secondRoot
+ const result = await readAssetsInWorker(
+ join(secondRoot, 'station-templates', 'vendor', 'model.json')
+ )
+
+ // Assert
+ assert.deepStrictEqual(result, {
+ config: 'second',
+ relativeTemplatePath: join('vendor', 'model.json'),
+ template: 'second',
+ })
+ assert.strictEqual(
+ readFileSync(join(secondRoot, 'configurations', 'vendor', 'worker-state.json'), 'utf8'),
+ 'second'
+ )
+ })
+})
--- /dev/null
+# Hermetic test assets fixture. Content is (re)generated on every test run by
+# tests/stubs/setupAssetsFixture.mjs and referenced through EVSE_ASSETS_DIR:
+# a `configurations/` directory (write target for the OCPP 2.0 certificate
+# manager and the Bootstrap state file) plus directory junctions to the
+# tracked, read-only `src/assets` sub-trees (`json-schemas`, `station-templates`).
+# No `config.json` on purpose: its absence reproduces the CI baseline (fresh
+# checkout) and forces the hardcoded fallback in src/utils/Configuration.ts.
+*
+!.gitignore
--- /dev/null
+/**
+ * @file Prepares the hermetic test assets fixture (`tests/stubs/assets`).
+ *
+ * Invoked directly (`node tests/stubs/setupAssetsFixture.mjs`) — e.g. from the
+ * `test`, `test:debug` and `test:coverage` scripts (package.json) — it
+ * regenerates the fixture from scratch: stale content is removed and the
+ * layout is rebuilt, so every run starts from the CI baseline (fresh checkout).
+ *
+ * The fixture is referenced through `EVSE_ASSETS_DIR` (set by
+ * `tests/stubs/testEnvBootstrap.mjs`) so that no test reads user-generated
+ * assets (`config.json`, `configurations/`, idtags, ev-profiles) from
+ * `src/assets`.
+ *
+ * `ensureAssetsFixture()` is the non-destructive counterpart used by the
+ * bootstrap: it creates only missing layout parts without removing content,
+ * which makes it safe to run concurrently from the test runner's per-file
+ * child processes.
+ * Unusable existing entries are rejected; explicit serial setup owns repair.
+ */
+import {
+ existsSync,
+ lstatSync,
+ mkdirSync,
+ readdirSync,
+ realpathSync,
+ rmSync,
+ symlinkSync,
+} from 'node:fs'
+import { dirname, join, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const thisDir = dirname(fileURLToPath(import.meta.url))
+const srcAssetsDir = join(thisDir, '..', '..', 'src', 'assets')
+
+/**
+ * The fixture directory: `tests/stubs/assets`.
+ */
+export const fixtureDir = join(thisDir, 'assets')
+
+// Tracked, read-only sub-trees are shared with src/assets via directory junctions:
+// single source of truth, identical content in CI and local dev, no duplication.
+// 'junction' creates a directory junction on Windows (no elevation needed) and a
+// directory symlink on POSIX.
+/**
+ * Checks whether an existing fixture entry is a symbolic link with a live target.
+ * @param linkPath - The fixture entry to inspect.
+ * @returns Whether the entry can be reused without replacing it.
+ */
+const isUsableFixtureLink = linkPath => {
+ try {
+ // `lstatSync` does not follow the link; `existsSync` verifies its target.
+ return lstatSync(linkPath).isSymbolicLink() && existsSync(linkPath)
+ } catch (err) {
+ if (!(err instanceof Error) || err.code !== 'ENOENT') {
+ throw err
+ }
+ return false
+ }
+}
+
+/**
+ * Ensures the fixture layout exists, creating only missing parts.
+ * Non-destructive and concurrency-safe: existing content is left in place,
+ * so it is safe to call from multiple test-runner child processes.
+ * Unusable conflicts are preserved and require explicit serial setup.
+ */
+export const ensureAssetsFixture = () => {
+ // Create the fixture root, then the read-side junctions first and the write
+ // target (`configurations/`) last: by the time the directory is write-ready,
+ // every startup read target (JSON schemas, station templates) is complete.
+ // This closes the read-race window when multiple test-runner child processes
+ // run this concurrently; the runtime additionally creates parent directories
+ // on demand (`atomicWriteFile`, `ensureDir`).
+ mkdirSync(fixtureDir, { recursive: true })
+
+ for (const sub of ['json-schemas', 'station-templates']) {
+ const linkPath = join(fixtureDir, sub)
+ if (isUsableFixtureLink(linkPath)) {
+ continue
+ }
+ try {
+ symlinkSync(join(srcAssetsDir, sub), linkPath, 'junction')
+ } catch (err) {
+ // A peer may have won creation. Accept only a still-usable entry.
+ if (!(err instanceof Error) || err.code !== 'EEXIST' || !isUsableFixtureLink(linkPath)) {
+ throw err
+ }
+ }
+ }
+
+ // Write target for the OCPP 2.0 certificate manager and the Bootstrap state file.
+ mkdirSync(join(fixtureDir, 'configurations'), { recursive: true })
+}
+
+/**
+ * Regenerates the fixture from scratch, removing any stale content first.
+ * Run serially, before starting readers or concurrent initializers (idempotent).
+ */
+export const setupAssetsFixture = () => {
+ if (!existsSync(fixtureDir)) {
+ mkdirSync(fixtureDir, { recursive: true })
+ }
+
+ for (const entry of readdirSync(fixtureDir)) {
+ if (entry !== '.gitignore') {
+ rmSync(join(fixtureDir, entry), { force: true, recursive: true })
+ }
+ }
+
+ ensureAssetsFixture()
+}
+
+// Regenerate when invoked directly (`node tests/stubs/setupAssetsFixture.mjs`),
+// e.g. from the package.json scripts; not when imported (testEnvBootstrap.mjs).
+// `process.argv[1]` is the entry point as spelled on the command line (possibly
+// relative); canonicalizing both sides makes relative invocations and symlinked
+// checkouts compare equal to `import.meta.url`.
+const entryPath = process.argv[1]
+const entryIsThisModule =
+ entryPath !== undefined && realpathSync(resolve(entryPath)) === fileURLToPath(import.meta.url)
+
+if (entryIsThisModule) {
+ setupAssetsFixture()
+}
--- /dev/null
+/**
+ * @file Tests for assets fixture initialization
+ * @description Verifies concurrent reader availability and non-destructive conflict handling.
+ */
+import assert from 'node:assert/strict'
+import { once } from 'node:events'
+import {
+ copyFileSync,
+ existsSync,
+ mkdirSync,
+ readFileSync,
+ readlinkSync,
+ realpathSync,
+ symlinkSync,
+ writeFileSync,
+} from 'node:fs'
+import { join } from 'node:path'
+import { afterEach, describe, it } from 'node:test'
+import { pathToFileURL } from 'node:url'
+import { Worker } from 'node:worker_threads'
+
+import { cleanupTempDirs, createTempDir } from '../helpers/TempFiles.js'
+import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+
+interface FixtureModule {
+ ensureAssetsFixture: () => void
+ fixtureDir: string
+ setupAssetsFixture: () => void
+}
+
+/**
+ * Loads an unchanged copy of the fixture module against an isolated source tree.
+ * @returns Fixture operations and paths without touching the shared test assets.
+ */
+const createFixture = async (): Promise<FixtureModule & { modulePath: string; root: string }> => {
+ const root = realpathSync.native(createTempDir('assets-fixture-'))
+ const modulePath = join(root, 'tests', 'stubs', 'setupAssetsFixture.mjs')
+ mkdirSync(join(root, 'tests', 'stubs'), { recursive: true })
+ for (const subtree of ['json-schemas', 'station-templates']) {
+ mkdirSync(join(root, 'src', 'assets', subtree), { recursive: true })
+ writeFileSync(join(root, 'src', 'assets', subtree, 'marker.json'), subtree)
+ }
+ copyFileSync(new URL('./setupAssetsFixture.mjs', import.meta.url), modulePath)
+ // Each isolated tree needs its own module-relative fixture directory.
+ const fixture = (await import(pathToFileURL(modulePath).href)) as FixtureModule
+ return { ...fixture, modulePath, root }
+}
+
+/**
+ * Confirms that both read-side fixture junctions still serve their original data.
+ * @param fixtureDir - The fixture directory observed by an initialized reader.
+ */
+const assertReaderAvailable = (fixtureDir: string): void => {
+ for (const subtree of ['json-schemas', 'station-templates']) {
+ assert.strictEqual(readFileSync(join(fixtureDir, subtree, 'marker.json'), 'utf8'), subtree)
+ }
+}
+
+afterEach(() => {
+ cleanupTempDirs()
+ standardCleanup()
+})
+
+await describe('setupAssetsFixture', async () => {
+ await it('should keep a completed reader available during concurrent initialization', async () => {
+ // Arrange: delay the contender immediately before and after its first real mutation.
+ const fixture = await createFixture()
+ const workerPath = join(fixture.root, 'contender.mjs')
+ writeFileSync(
+ workerPath,
+ `
+import fs from 'node:fs'
+import { syncBuiltinESMExports } from 'node:module'
+import { pathToFileURL } from 'node:url'
+import { parentPort, workerData } from 'node:worker_threads'
+
+const control = new Int32Array(workerData.control)
+const originalRemove = fs.rmSync
+const originalCreate = fs.symlinkSync
+let scheduled = false
+const mutate = (operation, args, destinationIndex) => {
+ if (scheduled || args[destinationIndex] !== workerData.linkPath) {
+ return Reflect.apply(operation, fs, args)
+ }
+ scheduled = true
+ parentPort.postMessage('before')
+ Atomics.wait(control, 0, 0)
+ try {
+ return Reflect.apply(operation, fs, args)
+ } finally {
+ parentPort.postMessage('after')
+ Atomics.wait(control, 1, 0)
+ }
+}
+fs.rmSync = (...args) => mutate(originalRemove, args, 0)
+fs.symlinkSync = (...args) => mutate(originalCreate, args, 1)
+syncBuiltinESMExports()
+// Install scheduling barriers before the unchanged module captures the filesystem exports.
+const { ensureAssetsFixture } = await import(pathToFileURL(workerData.modulePath).href)
+ensureAssetsFixture()
+`,
+ 'utf8'
+ )
+ const control = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * 2))
+ const worker = new Worker(workerPath, {
+ execArgv: [],
+ workerData: {
+ control: control.buffer,
+ linkPath: join(fixture.fixtureDir, 'json-schemas'),
+ modulePath: fixture.modulePath,
+ },
+ })
+ try {
+ assert.deepStrictEqual(
+ await once(worker, 'message', { signal: AbortSignal.timeout(30_000) }),
+ ['before']
+ )
+ fixture.ensureAssetsFixture()
+ assertReaderAvailable(fixture.fixtureDir)
+
+ // Act: let the delayed peer mutate the entry published by the completed initializer.
+ const mutated = once(worker, 'message', { signal: AbortSignal.timeout(30_000) })
+ Atomics.store(control, 0, 1)
+ Atomics.notify(control, 0)
+ assert.deepStrictEqual(await mutated, ['after'])
+
+ // Assert: the ready reader remains usable even before the contender finishes.
+ assertReaderAvailable(fixture.fixtureDir)
+ const completed = once(worker, 'exit', { signal: AbortSignal.timeout(30_000) })
+ Atomics.store(control, 1, 1)
+ Atomics.notify(control, 1)
+ assert.deepStrictEqual(await completed, [0])
+ assertReaderAvailable(fixture.fixtureDir)
+ } finally {
+ Atomics.store(control, 0, 1)
+ Atomics.store(control, 1, 1)
+ Atomics.notify(control, 0)
+ Atomics.notify(control, 1)
+ await worker.terminate()
+ }
+ })
+
+ await it('should preserve conflicting directory contents until an explicit reset', async () => {
+ // Arrange
+ const fixture = await createFixture()
+ const conflictingDir = join(fixture.fixtureDir, 'json-schemas')
+ mkdirSync(conflictingDir, { recursive: true })
+ const sentinel = join(conflictingDir, 'user-data.json')
+ writeFileSync(sentinel, 'preserve this data')
+ const ignoreFile = join(fixture.fixtureDir, '.gitignore')
+ writeFileSync(ignoreFile, 'preserve this ignore file')
+
+ // Act and assert: ensure must reject the conflict without deleting the data.
+ assert.throws(
+ () => {
+ fixture.ensureAssetsFixture()
+ },
+ { code: 'EEXIST' }
+ )
+ assert.strictEqual(readFileSync(sentinel, 'utf8'), 'preserve this data')
+
+ // The explicit serial reset owns destructive repair and preserves the tracked ignore file.
+ fixture.setupAssetsFixture()
+ assert.strictEqual(existsSync(sentinel), false)
+ assert.strictEqual(readFileSync(ignoreFile, 'utf8'), 'preserve this ignore file')
+ assertReaderAvailable(fixture.fixtureDir)
+ })
+
+ await it('should preserve a conflicting file instead of replacing it with a junction', async () => {
+ // Arrange
+ const fixture = await createFixture()
+ mkdirSync(fixture.fixtureDir, { recursive: true })
+ const conflictingFile = join(fixture.fixtureDir, 'json-schemas')
+ writeFileSync(conflictingFile, 'preserve these bytes')
+
+ // Act and assert
+ assert.throws(
+ () => {
+ fixture.ensureAssetsFixture()
+ },
+ { code: 'EEXIST' }
+ )
+ assert.strictEqual(readFileSync(conflictingFile, 'utf8'), 'preserve these bytes')
+ })
+
+ await it('should preserve an unusable junction and reject initialization', async () => {
+ // Arrange
+ const fixture = await createFixture()
+ mkdirSync(fixture.fixtureDir, { recursive: true })
+ const link = join(fixture.fixtureDir, 'json-schemas')
+ symlinkSync(join(fixture.root, 'missing-target'), link, 'junction')
+ const originalTarget = readlinkSync(link)
+
+ // Act and assert
+ assert.throws(
+ () => {
+ fixture.ensureAssetsFixture()
+ },
+ { code: 'EEXIST' }
+ )
+ assert.strictEqual(readlinkSync(link), originalTarget)
+ })
+})
--- /dev/null
+/**
+ * @file Test environment bootstrap, loaded via `node --import` in the `test`,
+ * `test:debug` and `test:coverage` scripts (package.json) so that the test
+ * suite always runs against the hermetic assets fixture instead of the
+ * user-generated `src/assets` content (`config.json`, `configurations/`,
+ * idtags, ev-profiles).
+ *
+ * Standalone runs (test files outside of `pnpm test`) bypass the package.json
+ * scripts and therefore need the bootstrap flag to be equally hermetic, e.g.:
+ *
+ * node --import tsx --import ./tests/stubs/testEnvBootstrap.mjs --test --test-force-exit tests/utils/Configuration.test.ts
+ *
+ * The module runs before any test file is imported: it sets the variables
+ * that `resolveAssetsDir()` (src/resolveAssetsDir.ts) reads at module load,
+ * then ensures the fixture layout exists. All paths are derived from
+ * `import.meta.url`, so behavior is independent of the working directory.
+ *
+ * Effects (all idempotent — the test runner may evaluate this module once per
+ * test file, in the per-file child processes of `--test-isolation=process`):
+ * 1. Sets `EVSE_ASSETS_DIR` to the absolute fixture path when unset or empty, so
+ * `resolveAssetsDir()` returns the fixture. An explicit user override is
+ * honored.
+ * 2. Sets `NODE_ENV` to `test` unconditionally, mirroring the `cross-env`
+ * of the package.json scripts (`src/utils/Logger.ts` silences its file
+ * transports under `NODE_ENV=test`).
+ * 3. Ensures the fixture layout (`configurations/` + the directory junctions
+ * to the tracked `src/assets` sub-trees) without deleting content; the
+ * per-run full regeneration is performed by the
+ * `node tests/stubs/setupAssetsFixture.mjs` step that precedes `node` in
+ * the package.json scripts.
+ * A manual run skips that destructive step, so a full-suite run started that
+ * way may observe stale persisted content (`configurations/*.json`) left by a
+ * previous run: use `pnpm test` for full-suite runs, or run the setup step
+ * first.
+ */
+import { isAbsolute, resolve } from 'node:path'
+import process from 'node:process'
+
+import { ensureAssetsFixture, fixtureDir } from './setupAssetsFixture.mjs'
+
+// 1. Hermetic assets: point `resolveAssetsDir()` at the fixture unless the
+// user explicitly overrode `EVSE_ASSETS_DIR`.
+if (!process.env.EVSE_ASSETS_DIR) {
+ process.env.EVSE_ASSETS_DIR = fixtureDir
+}
+
+// 2. Test environment: silence log file transports (src/utils/Logger.ts).
+// Unconditional, like the scripts' `cross-env NODE_ENV=test`: a standalone
+// run must observe the same environment as `pnpm test`.
+process.env.NODE_ENV = 'test'
+
+// 3. Ensure the fixture layout when the effective assets dir is the fixture
+// (absolute or relative, resolved exactly like `resolveAssetsDir()`).
+const assetsDir = process.env.EVSE_ASSETS_DIR
+const assetsTarget = isAbsolute(assetsDir) ? assetsDir : resolve(process.cwd(), assetsDir)
+
+// String equality: a symlinked override pointing at the fixture does not
+// match, and ensureAssetsFixture() is skipped for it.
+if (assetsTarget === fixtureDir) {
+ ensureAssetsFixture()
+}
* - getSupervisionUrlDistribution — returns distribution strategy
* - workerPoolInUse / workerDynamicPoolInUse — pool type checks
*/
+
+// First import by contract: project modules capture asset paths at module
+// load, so the bootstrap must set EVSE_ASSETS_DIR before any of them loads.
+import '../stubs/testEnvBootstrap.mjs'
import assert from 'node:assert/strict'
import { afterEach, describe, it } from 'node:test'