]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
refactor(charging-station): config assets single source of truth (#2157)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Thu, 29 Oct 2026 23:43:13 +0000 (00:43 +0100)
committerGitHub <noreply@github.com>
Thu, 29 Oct 2026 23:43:13 +0000 (00:43 +0100)
* refactor(charging-station): config assets single source of truth

Replace every join(dirname(fileURLToPath(import.meta.url)), "assets", ...)
asset path with a single resolveAssetsDir() helper in src/resolveAssetsDir.ts.
It honors the EVSE_ASSETS_DIR override, otherwise resolves the assets folder
that sits beside the helper - dist/assets in the bundle, src/assets via tsx.

- Configuration.ts, HelpersConfig.ts (templates, id-tags, EV profiles),
  Bootstrap.ts (assetsDir) and UIMCPServer.ts (schema dir) now use it.
- start:dev(+debug/prof) run src/start.ts through tsx with no build step, so
  dev reads and reloads src/assets directly; production start stays bundled.
- README documents editing src/assets (dist is a build artifact) plus override.

Configuration defaults are preserved: src/assets/config.json is absent from
sources, so the runtime still falls back to the default configuration.

* [autofix.ci] apply automated fixes

* refactor(charging-station): route remaining schema loaders and config diagnostics through resolveAssetsDir

Address PR review: MCPResources.getSchemaBaseDir and OCPPServiceUtils.parseJsonSchemaFile now resolve via resolveAssetsDir() (honoring EVSE_ASSETS_DIR); drop the assets/ prefix from createPayloadOptions so relativePath is relative to the assets dir. The missing-config error now interpolates the resolved configurationFile path.

* [autofix.ci] apply automated fixes

* test(charging-station): add unit tests for resolveAssetsDir assets-dir resolver

Add a unit test for the single source of truth that resolves the assets
directory (EVSE_ASSETS_DIR override; absolute paths used as-is; relative
paths resolved against cwd; module-side fallback). It locks the documented
absolute-used-as-is contract and closes the coverage gap on the resolver.

* chore(charging-station): gitignore dev-generated station config state

Dev mode now resolves assets from src/assets (single source of truth), so the simulator's provisioned station configs and .simulator-state.json are written to src/assets/configurations/ at runtime. Previously (bundle dev) this state landed in dist/assets (already ignored). Ignore the generated directory to keep the working tree clean.

* fix(charging-station): route cert-manager base path via resolveAssetsDir; qualify README asset paths

Review follow-up (MEDIUM findings):
- OCPP20CertificateManager: BASE_CERT_PATH now join(resolveAssetsDir(),
  'configurations') instead of hardcoded 'dist/assets/configurations'
  (which resolved to a nonexistent directory in tsx/dev/tests mode).
  Certificate-manager test cleanup paths now use resolveAssetsDir() too.
- README: charging-station configuration paths now expressed as
  <assets-dir>/configurations, where <assets-dir> is dist/assets in
  production and src/assets in development (locations, hierarchy, section
  header), so developers no longer look in dist/ in dev mode.

* test(assets): relocate resolveAssetsDir test to mirror source tree

resolveAssetsDir.ts lives at the src/ root (required by its
<module-dir>/assets resolution), so its test belongs at the tests/
root, not tests/utils/. Move tests/utils/resolveAssetsDir.test.ts to
tests/resolveAssetsDir.test.ts and adjust the relative import depth.

* docs(assets): make JSDoc asset-path references mode-neutral

Cross-validation of the asset-centralization review findings surfaced
JSDoc comments still assuming the production layout. Express them in
mode-neutral terms consistent with resolveAssetsDir():
- HelpersConfig: drop "bundled" from the id-tags/ev-profiles resolvers;
  buildTemplateName references the station-templates subdirectory of the
  assets directory.
- Statistics: template-index reconstruction path now
  <assets-dir>/configurations/ instead of dist/assets/configurations/.

* test(assets): make path assertions platform-robust (Windows)

Asset-path centralization (resolveAssetsDir) makes asset paths absolute,
which exposed POSIX-only path handling in two tests on Windows:
- OCPP20CertificateManager: the serial-sanitization test parsed the cert
  path with split('/').pop(), which on Windows returns the whole
  drive-qualified path (contains ':'); use path.basename() instead.
- resolveAssetsDir: the absolute-override tests used a POSIX-only
  '/tmp/...' fixture that isAbsolute() rejects on Windows; build the
  fixture with path.resolve(cwd, ...) so it is absolute on every platform.

* fix(assets): address PR #2157 review findings

- hermetic tests: tests/stubs/assets fixture (junctions to read-only src/assets sub-trees + writable configurations/) wired via EVSE_ASSETS_DIR in test scripts
- centralize OCPP schema base dir (getOcppSchemaBaseDir) used by MCPResources, UIMCPServer, OCPPServiceUtils
- isPathWithinBase: platform-safe sep-based prefix guard
- resolveAssetsDir tests: strict module-relative assertions
- README: EVSE_ASSETS_DIR layout/writability/pre-start, prod/dev asset-dir mapping, persistState generation
- parseJsonSchemaFile: document SyntaxError throw

* [autofix.ci] apply automated fixes

* docs: relocate EVSE_ASSETS_DIR contract from README to resolveAssetsDir JSDoc

The README is user-facing documentation; the expected asset layout,
writability requirement, and set-before-start timing for the
EVSE_ASSETS_DIR override are developer-facing details. Document the
full contract in the resolveAssetsDir.ts module JSDoc instead, and
keep the README limited to the user workflow (edit src/assets, dist
rebuilt by pnpm build, configurations/ persists across builds).

* test: run the suite hermetically against the assets fixture

The suite asserted configuration defaults while reading whatever
resolveAssetsDir() found: pnpm test from the repo root worked, but
standalone runs (node --import tsx --test <file>) hit the
user-generated src/assets content — a user config.json failed the
suite, and the certificate-manager test wrote into
src/assets/configurations.

- src/resolveAssetsDir.ts: fail fast with AssetsDirError when the
  EVSE_ASSETS_DIR override points to a missing or non-directory path
  (the <module-dir>/assets fallback stays the trusted default);
  export OCPP_SCHEMA_DIR (<assets-dir>/json-schemas/ocpp)
- OCPP 1.6 / 2.0 payload validators use OCPP_SCHEMA_DIR instead of
  hardcoded relative strings
- tests/stubs/testEnvBootstrap.mjs (new): node --import module for the
  test scripts — sets EVSE_ASSETS_DIR to the absolute fixture path
  (honoring an explicit user override), NODE_ENV=test, and ensures the
  fixture layout
- tests/stubs/setupAssetsFixture.mjs: split into a non-destructive,
  concurrency-safe ensureAssetsFixture() and the direct-entry
  full-regeneration setupAssetsFixture()
- package.json: test / test:debug / test:coverage load the bootstrap
  via --import instead of a cwd-relative cross-env EVSE_ASSETS_DIR
- Configuration.test.ts and OCPP20CertificateManager.test.ts import the
  bootstrap first: standalone runs of these files are hermetic too
- tests/resolveAssetsDir.test.ts: real temp dirs (mkdtemp) instead of
  relative paths never created on disk; AssetsDirError tests
- README: the <assets-dir>/configurations note now matches the runtime
  (mkdir recursive on write, not a startup generation); idTagsFile /
  evProfilesFile documented relative to <assets-dir> instead of
  src/assets

* fix: restore --test-reporter-destination in the test:coverage script

The script rewrite accidentally renamed the longstanding
--test-reporter-destination flag to --test-coverage-destination,
which Node 22.x/24.x do not know (CI failed with
'node: bad option: --test-coverage-destination=coverage/lcov.info').
Verified: the exact script runs on Node 22.23.4 and 26.12.1 and
produces a populated lcov file.

* refactor: move AssetsDirError to src/exception per review findings

- Relocate AssetsDirError to src/exception/AssetsDirError.ts to follow the
  repository error-class convention (BaseError, OCPPError, TimeoutError +
  barrel); re-export it from resolveAssetsDir.ts so the module API is
  unchanged.
- Correct the assertAssetsDir JSDoc: call sites span process startup,
  per-station setup, and per-transaction paths; the per-call cost is one
  statSync only when an override is set (no syscall on the no-override
  fallback).
- Assert the full AssetsDirError message (name + message) in
  tests/resolveAssetsDir.test.ts, following the repository's strict
  error-message assertion convention.

* fix: derive station configuration and diagnostics paths from the resolved assets layout

ChargingStation: derive the per-station configuration file from the
template file's parent directory (the assets directory) instead of a
string replace of 'station-templates', which broke EVSE_ASSETS_DIR
overrides whose path contains 'station-templates'.

OCPP16 GetDiagnostics: resolve the log scan and the diagnostics
archive against the working directory (where winston writes the logs
and where the archive is created) instead of module-relative, so the
command works in dev-from-source; drop the now-unused fileURLToPath
import.

README: note in the <assets-dir> definition that EVSE_ASSETS_DIR may
point to a custom assets directory (absolute, or relative to the
working directory).

* refactor: address remaining review findings on assets resolution

- Rename OCPP_SCHEMA_DIR to OCPP_SCHEMA_SUBDIR (it is a relative path
  fragment of the assets directory, not a directory path)
- Drop the unused AssetsDirError re-export (canonical barrel: src/exception)
- JSDoc: document the layout as a contract not validated by the resolver;
  correct the ui-protocol/ claim (source-tree-only, artifact for humans)
- README: source-managed content rebuild wording; backtick <assets-dir>
  in 3 table cells; idTagsFile/evProfilesFile are file names at the root
  of the assets directory (basename-only resolution)
- Bootstrap JSDoc: unset-or-empty override; warn about stale fixture
  content on manual full-suite runs; document the first-import contract
  and the string-equality fixture check
- Tests: cover the relative-missing override branch (raw and resolved
  paths in the error message); assert AssetsDirError messages by
  substrings per the suite convention instead of duplicating the format

* [autofix.ci] apply automated fixes

* refactor: centralize asset path derivation in resolveAssetsDir helpers

- add getAssetFilePath, getConfigurationsDir, getStationTemplatesDir and getConfigFilePath so every <assets-dir>/<subpath> is derived from one join primitive

- Bootstrap, HelpersConfig, Configuration and OCPP20CertificateManager use the derived helpers instead of join(resolveAssetsDir(), ...)

- AssetsDirError: drop the redundant name override (BaseError sets name via new.target.name)

- README: fix the \`<assets-dir>\`/configurations wording to <assets-dir>/configurations

- tests: cover getOcppSchemaBaseDir with and without EVSE_ASSETS_DIR

- apply pnpm format (prettier + eslint) on the changed files

* refactor: derive the OCPP 1.6 schema path via getAssetFilePath

parseJsonSchemaFile now uses the single join primitive from resolveAssetsDir, dropping the last direct join(resolveAssetsDir(), ...) call site and the unused node:path import. No behavior change.

* fix(assets): preserve nested persistence and cache paths

* chore: leave changelog updates to automation

* fix(ocpp20): canonicalize certificate asset roots

* test(assets): handle physical cwd through aliases

* fix(ocpp20): use native certificate root resolution

* fix(assets): preserve selected filesystem root

Cache physical asset identity without changing raw overrides.
Keep logical template subpaths consistent across selected aliases.

* fix: normalize aliased template identities consistently

* test: anchor alias lifecycle fixture to native temp root

* fix: share pinned asset roots with workers

Keep later workers on the selected assets revision after an alias
retarget. Preserve logical template namespaces across isolates.

* fix(test): avoid removing live fixture links

Keep ensure non-destructive. Let explicit setup repair conflicting
entries before readers start.

* ci: select stable Node versions for latest jobs

The latest alias can select prereleases rejected by the existing
engine check. Use a checked SemVer range without renaming checks.

* docs: clarify custom assets layout

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
28 files changed:
.github/workflows/ci.yml
.gitignore
README.md
package.json
src/charging-station/Bootstrap.ts
src/charging-station/ChargingStation.ts
src/charging-station/HelpersConfig.ts
src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts
src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts
src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts
src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts
src/charging-station/ocpp/OCPPServiceUtils.ts
src/charging-station/ui-server/UIMCPServer.ts
src/charging-station/ui-server/mcp/MCPResources.ts
src/exception/AssetsDirError.ts [new file with mode: 0644]
src/exception/index.ts
src/resolveAssetsDir.ts [new file with mode: 0644]
src/types/Statistics.ts
src/utils/Configuration.ts
tests/charging-station/Bootstrap.test.ts
tests/charging-station/ChargingStation-ConfigurationPersistence.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20CertificateManager.test.ts
tests/resolveAssetsDir.test.ts [new file with mode: 0644]
tests/stubs/assets/.gitignore [new file with mode: 0644]
tests/stubs/setupAssetsFixture.mjs [new file with mode: 0644]
tests/stubs/setupAssetsFixture.test.ts [new file with mode: 0644]
tests/stubs/testEnvBootstrap.mjs [new file with mode: 0644]
tests/utils/Configuration.test.ts

index 90601eb3bfe826d7b6774cf4e506c911f4d97576..4f406504994a2ea6ce060c85c9bad6544898a2ff 100644 (file)
@@ -85,7 +85,9 @@ jobs:
       - 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
@@ -183,7 +185,8 @@ jobs:
       - 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
@@ -238,7 +241,8 @@ jobs:
       - 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
index b3319278c4b77d7f3a61db7ce9526e0a8b1460f2..d04eaa309afa7b3ec82ce38e850258fa5e8681e9 100644 (file)
@@ -7,6 +7,7 @@ src/assets/*idtags*.json
 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
index 376acfc61df45709d511785e45ae08ea7af0afaf..a14a6820cd4809c228a857b839abd2fe90c14edf 100644 (file)
--- a/README.md
+++ b/README.md
@@ -144,22 +144,24 @@ All configuration files are using the JSON standard syntax.
 
 **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:
 
@@ -167,11 +169,13 @@ The charging stations simulator has an automatic configuration files reload feat
 - 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                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
 | -------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
@@ -183,7 +187,7 @@ But the modifications to test have to be done to the files in the build target d
 | 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 />&nbsp;&nbsp;- _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 />&nbsp;&nbsp;- _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 />&nbsp;&nbsp;- _allowLoopbackProxy_: accept forwarded headers when the immediate peer is loopback AND listed in _trustedProxies_ (e.g. `['127.0.0.1', '::1']`)<br />&nbsp;&nbsp;- _allowedHosts_: explicit Host header allowlist; mitigates DNS rebinding when the UI server is exposed through a browser-facing host<br />&nbsp;&nbsp;- _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 />&nbsp;&nbsp;- _enabled_: enable the `/metrics` endpoint<br />&nbsp;&nbsp;- _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 />&nbsp;&nbsp;- _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
 
@@ -198,7 +202,7 @@ But the modifications to test have to be done to the files in the build target d
 
 ### Charging station template configuration
 
-**src/assets/station-templates/\<name\>.json**:
+**`<assets-dir>/station-templates/<name>.json`**:
 
 | Key                                                  | Value(s)      | Default Value                                                                                                                      | Value type                                                                                                                                                                    | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
 | ---------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -211,11 +215,11 @@ But the modifications to test have to be done to the files in the build target d
 | 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                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
@@ -259,7 +263,7 @@ But the modifications to test have to be done to the files in the build target d
 | 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                                                                                                                                                                                                                                                                                                                                                                                                                               |
@@ -452,7 +456,7 @@ type AutomaticTransactionGeneratorConfiguration = {
 
 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
 {
@@ -501,10 +505,12 @@ A template file is available at [src/assets/ev-profiles-template.json](./src/ass
 
 ### 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)
index c7e8838d70145275314f69e9e985c76fb851861f..5cd3beda09d81d7af061f4c75c9a943d1f2ab058 100644 (file)
@@ -53,9 +53,9 @@
     "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",
@@ -69,9 +69,9 @@
     "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": {
index f8702b5a536b9cbc173f32512cd7a5701b824027..ab97423e06bd20e68b1e0809654c314822a29d8d 100644 (file)
@@ -14,6 +14,7 @@ import type { IBootstrap } from './IBootstrap.js'
 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,
@@ -105,7 +106,6 @@ export class Bootstrap extends EventEmitter implements IBootstrap {
     )
   }
 
-  private readonly assetsDir: string
   private readonly configurationsDir: string
   private shuttingDown: boolean
   private started: boolean
@@ -165,8 +165,7 @@ export class Bootstrap extends EventEmitter implements IBootstrap {
     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(
@@ -206,7 +205,7 @@ export class Bootstrap extends EventEmitter implements IBootstrap {
     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) {
index 9d4b7b2d7beaa4b0d8c550bc4c1ec706fb78f390..292284bcb5d357a4e7a2689a4e403d9ce5d987ee 100644 (file)
@@ -13,6 +13,7 @@ import type { CoherentSession } from './meter-values/index.js'
 
 import { BaseError, OCPPError } from '../exception/index.js'
 import { PerformanceStatistics } from '../performance/index.js'
+import { getStationConfigurationDir } from '../resolveAssetsDir.js'
 import {
   type AutomaticTransactionGeneratorConfiguration,
   AvailabilityType,
@@ -2875,7 +2876,7 @@ export class ChargingStation extends EventEmitter {
       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()
index 31a6858770f1171b597b6ad04ae16a1c1293231a..9b946419360231462c688df339244037353609bf 100644 (file)
  *   (`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,
@@ -42,19 +42,14 @@ const moduleName = 'HelpersConfig'
 
 /**
  * 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)
 }
 
@@ -351,24 +346,24 @@ export const getDefaultVoltageOut = (
 
 /**
  * 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
 }
index 53e4d330ef247091c898ee4aa3aed59ad4efbb40..740bbf54c5c7004806f17f08e8be143a14501371 100644 (file)
@@ -12,7 +12,8 @@ import { maxTime } from 'date-fns/constants'
 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 {
@@ -1518,9 +1519,9 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
           )
           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
@@ -1566,7 +1567,7 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
               })
           })
           uploadResponse = await ftpClient.uploadFrom(
-            join(resolve(dirname(fileURLToPath(import.meta.url)), '../'), diagnosticsArchive),
+            join(process.cwd(), diagnosticsArchive),
             `${uri.pathname}${diagnosticsArchive}`
           )
           if (!this.isIncomingRequestDeliveryCurrent(chargingStation)) {
index 5f7025ed9f08e15731d859ddd63a383f563e437d..e9082878f12a22e004c53b5fcc0d054dc13e63bf 100644 (file)
@@ -25,6 +25,7 @@ import {
   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,
@@ -840,7 +841,7 @@ export class OCPP16ServiceUtils {
   public static createPayloadOptions = (moduleName: string, methodName: string) =>
     PayloadValidatorOptions(
       OCPPVersion.VERSION_16,
-      'assets/json-schemas/ocpp/1.6',
+      `${OCPP_SCHEMA_SUBDIR}/1.6`,
       moduleName,
       methodName
     )
index 94f141f5614af425faadacd6d507d1179dbdffd2..90e9b863fcd045008abd14a818fccf271bce26f0 100644 (file)
@@ -1,12 +1,14 @@
 // 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,
@@ -104,7 +106,13 @@ export interface ValidateCertificateX509Result {
  * - 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-----'
index 08c1afbbfc6d2f968a493a1a1a96074cd59e8457..969b4f6dacd8964ebec903f90dff335dc692d285 100644 (file)
@@ -46,6 +46,7 @@ import {
   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,
@@ -1922,7 +1923,7 @@ export class OCPP20ServiceUtils {
   public static createPayloadOptions = (moduleName: string, methodName: string) =>
     PayloadValidatorOptions(
       OCPPVersion.VERSION_201,
-      'assets/json-schemas/ocpp/2.0',
+      `${OCPP_SCHEMA_SUBDIR}/2.0`,
       moduleName,
       methodName
     )
index d20cdd3d190632c6a7324f0f9d369e03737031d4..eb74462c95bdb1eae300c0b2771b797558039804 100644 (file)
@@ -3,8 +3,6 @@ import _ajvFormats from 'ajv-formats'
 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,
@@ -35,6 +33,7 @@ import {
   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,
@@ -3344,12 +3343,12 @@ export const PayloadValidatorOptions = (
 /**
  * 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,
@@ -3357,26 +3356,18 @@ function parseJsonSchemaFile<T extends JsonType> (
   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
   }
 }
 
index 145a6beb1eff3276deff6ca3168d47bbcf2c0308..9cfa8908d48152a2cb851618bf1b85c84cbd25b5 100644 (file)
@@ -5,13 +5,13 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
 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,
@@ -156,7 +156,7 @@ export class UIMCPServer extends AbstractUIServer {
   }
 
   protected getSchemaBaseDir (): string {
-    return join(dirname(fileURLToPath(import.meta.url)), 'assets', 'json-schemas', 'ocpp')
+    return getOcppSchemaBaseDir()
   }
 
   private checkVersionCompatibility (
index 55a7a2e3385f8bdb8e2e931dc32044c2c17fa369..ade3a2e37f3cbd15b7e51c9b5e6623f54d7c418f 100644 (file)
@@ -1,10 +1,10 @@
 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'
 
@@ -72,16 +72,11 @@ export const registerMCPResources = (server: McpServer, uiServer: AbstractUIServ
 
 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 => {
@@ -95,7 +90,7 @@ 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 {
@@ -149,7 +144,7 @@ export const registerMCPSchemaResources = (server: McpServer): void => {
       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 {
diff --git a/src/exception/AssetsDirError.ts b/src/exception/AssetsDirError.ts
new file mode 100644 (file)
index 0000000..6668082
--- /dev/null
@@ -0,0 +1,9 @@
+// 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 {}
index b9df000df70eb11b9e5bba10ef97328565c6a7b9..38a2b057b3c67c0fe2cc21ab4358648159ac79fb 100644 (file)
@@ -1,3 +1,4 @@
+export { AssetsDirError } from './AssetsDirError.js'
 export { BaseError } from './BaseError.js'
 export { OCPPError } from './OCPPError.js'
 export { TimeoutError } from './TimeoutError.js'
diff --git a/src/resolveAssetsDir.ts b/src/resolveAssetsDir.ts
new file mode 100644 (file)
index 0000000..e42a3d0
--- /dev/null
@@ -0,0 +1,267 @@
+// 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)
index f4dffa055434d2d0c32122fd2bbe0eb0d8b7064e..a217aa8b61897e1dfd3bcfcef4c956bc04ab63cd 100644 (file)
@@ -34,7 +34,7 @@ export interface TemplateStatistics {
   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.
    */
index 3399a47a9909539bb8036aee73fb5b9219c43825..6d909c8fb597c0ed49e35bb1b0fd94859222e78d 100644 (file)
@@ -1,10 +1,9 @@
 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,
@@ -104,13 +103,13 @@ export class Configuration {
   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 = {
index 986a6fe3ba017d03c3446942cbdbc5e4c5be6dc1..d2c1e2cdf44877c67a76f61bdd55cd2d98c20f9a 100644 (file)
@@ -1,21 +1,38 @@
 /**
  * @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>
@@ -124,12 +141,188 @@ await describe('Bootstrap lifecycle state machine', async () => {
   })
 
   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)
diff --git a/tests/charging-station/ChargingStation-ConfigurationPersistence.test.ts b/tests/charging-station/ChargingStation-ConfigurationPersistence.test.ts
new file mode 100644 (file)
index 0000000..abf70ca
--- /dev/null
@@ -0,0 +1,228 @@
+/**
+ * @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)
+    })
+  }
+})
index 06b58a212f324c5b2e2fa41f325ee20d175e0cf7..393376bd917a20e3068d2f444d78958359f8cd75 100644 (file)
@@ -3,17 +3,24 @@
  * @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 {
@@ -31,19 +38,151 @@ const TEST_CERT_TYPE = InstallCertificateUseEnumType.CSMSRootCertificate
 
 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 () => {
@@ -365,10 +504,7 @@ await describe('I02-I04 - ISO15118 Certificate Management', 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('/'))
     })
diff --git a/tests/resolveAssetsDir.test.ts b/tests/resolveAssetsDir.test.ts
new file mode 100644 (file)
index 0000000..c7d69c6
--- /dev/null
@@ -0,0 +1,440 @@
+/**
+ * @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'
+    )
+  })
+})
diff --git a/tests/stubs/assets/.gitignore b/tests/stubs/assets/.gitignore
new file mode 100644 (file)
index 0000000..ce90622
--- /dev/null
@@ -0,0 +1,9 @@
+# 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
diff --git a/tests/stubs/setupAssetsFixture.mjs b/tests/stubs/setupAssetsFixture.mjs
new file mode 100644 (file)
index 0000000..8904d96
--- /dev/null
@@ -0,0 +1,124 @@
+/**
+ * @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()
+}
diff --git a/tests/stubs/setupAssetsFixture.test.ts b/tests/stubs/setupAssetsFixture.test.ts
new file mode 100644 (file)
index 0000000..7df1b97
--- /dev/null
@@ -0,0 +1,203 @@
+/**
+ * @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)
+  })
+})
diff --git a/tests/stubs/testEnvBootstrap.mjs b/tests/stubs/testEnvBootstrap.mjs
new file mode 100644 (file)
index 0000000..e89c2f0
--- /dev/null
@@ -0,0 +1,61 @@
+/**
+ * @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()
+}
index 5226fad0c47921ad6f2d6fb142eacfff1582d39e..0921eacacafc56af54b486fdcbee268da2a1dfa4 100644 (file)
  * - 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'