From 26dfa87ec2ad901249b88b90923e19f7797fac61 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Fri, 30 Oct 2026 00:43:13 +0100 Subject: [PATCH] refactor(charging-station): config assets single source of truth (#2157) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * 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 /configurations, where 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 /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 /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 ) 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 /assets fallback stays the trusted default); export OCPP_SCHEMA_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 /configurations note now matches the runtime (mkdir recursive on write, not a startup generation); idTagsFile / evProfilesFile documented relative to 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 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 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 / 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 \`\`/configurations wording to /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> --- .github/workflows/ci.yml | 10 +- .gitignore | 1 + README.md | 48 +- package.json | 12 +- src/charging-station/Bootstrap.ts | 7 +- src/charging-station/ChargingStation.ts | 3 +- src/charging-station/HelpersConfig.ts | 25 +- .../ocpp/1.6/OCPP16IncomingRequestService.ts | 11 +- .../ocpp/1.6/OCPP16ServiceUtils.ts | 3 +- .../ocpp/2.0/OCPP20CertificateManager.ts | 12 +- .../ocpp/2.0/OCPP20ServiceUtils.ts | 3 +- src/charging-station/ocpp/OCPPServiceUtils.ts | 37 +- src/charging-station/ui-server/UIMCPServer.ts | 6 +- .../ui-server/mcp/MCPResources.ts | 15 +- src/exception/AssetsDirError.ts | 9 + src/exception/index.ts | 1 + src/resolveAssetsDir.ts | 267 +++++++++++ src/types/Statistics.ts | 2 +- src/utils/Configuration.ts | 7 +- tests/charging-station/Bootstrap.test.ts | 201 +++++++- ...ngStation-ConfigurationPersistence.test.ts | 228 +++++++++ .../ocpp/2.0/OCPP20CertificateManager.test.ts | 152 +++++- tests/resolveAssetsDir.test.ts | 440 ++++++++++++++++++ tests/stubs/assets/.gitignore | 9 + tests/stubs/setupAssetsFixture.mjs | 124 +++++ tests/stubs/setupAssetsFixture.test.ts | 203 ++++++++ tests/stubs/testEnvBootstrap.mjs | 61 +++ tests/utils/Configuration.test.ts | 4 + 28 files changed, 1789 insertions(+), 112 deletions(-) create mode 100644 src/exception/AssetsDirError.ts create mode 100644 src/resolveAssetsDir.ts create mode 100644 tests/charging-station/ChargingStation-ConfigurationPersistence.test.ts create mode 100644 tests/resolveAssetsDir.test.ts create mode 100644 tests/stubs/assets/.gitignore create mode 100644 tests/stubs/setupAssetsFixture.mjs create mode 100644 tests/stubs/setupAssetsFixture.test.ts create mode 100644 tests/stubs/testEnvBootstrap.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90601eb3..4f406504 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index b3319278..d04eaa30 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 376acfc6..a14a6820 100644 --- 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). +`` 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: `/config.json`; +- charging station configuration templates: `/station-templates`; +- charging station configurations: `/configurations`; +- charging station RFID tags lists: ``. -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 `` 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: `/configurations`; +2. charging station configuration template: `/station-templates`; +3. charging stations simulator configuration: `/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 `/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**: +**`/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 | | {
"enabled": false,
"type": "ws",
"version": "1.1",
"accessPolicy": {
"requireTlsForNonLoopback": true,
"trustedProxies": [],
"allowLoopbackProxy": false,
"allowedHosts": [],
"allowedOrigins": []
},
"options": {
"host": "localhost",
"port": 8080
}
} | {
enabled?: boolean;
type?: ApplicationProtocol;
version?: ApplicationProtocolVersion;
accessPolicy?: {
requireTlsForNonLoopback?: boolean;
trustedProxies?: string[];
allowLoopbackProxy?: boolean;
allowedHosts?: string[];
allowedOrigins?: string[];
};
options?: ServerOptions;
authentication?: {
enabled: boolean;
type: AuthenticationType;
username?: string;
password?: string;
};
metrics?: {
enabled?: boolean;
softSampleCap?: number;
};
securityHeaders?: {
strictTransportSecurity?: string \| false;
};
} | UI server configuration section:
- _enabled_: enable UI server
- _type_: 'ws', 'mcp' or 'http' (deprecated)
- _version_: HTTP version '1.1' or '2.0' (ws and mcp transports only support '1.1')
- _accessPolicy_: gateway access policy. Loopback request sources are allowed in plaintext; non-loopback sources require TLS termination by a reverse proxy:
  - _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`
  - _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`
  - _allowLoopbackProxy_: accept forwarded headers when the immediate peer is loopback AND listed in _trustedProxies_ (e.g. `['127.0.0.1', '::1']`)
  - _allowedHosts_: explicit Host header allowlist; mitigates DNS rebinding when the UI server is exposed through a browser-facing host
  - _allowedOrigins_: explicit Origin header allowlist; when empty, the request Origin's URL hostname falls back to matching against _allowedHosts_
- _options_: node.js net module [listen options](https://nodejs.org/api/net.html#serverlistenoptions-callback)
- _authentication_: authentication type configuration section
- _metrics_: opt-in Prometheus `/metrics` endpoint (served on the configured `uiServer.type` listener — `http`, `ws` or `mcp`):
  - _enabled_: enable the `/metrics` endpoint
  - _softSampleCap_: soft cardinality cap above which a single warn is logged per scrape (default 5000)
- _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:
  - _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 | | {
"enabled": true,
"type": "none",
} | {
enabled?: boolean;
type?: string;
uri?: string;
} | Performance storage configuration section:
- _enabled_: enable performance storage
- _type_: 'jsonfile', 'mongodb' or 'none'
- _uri_: storage URI | | stationTemplateUrls | | {}[] | {
file: string;
numberOfStations: number;
provisionedNumberOfStations?: number;
}[] | array of charging station templates URIs configuration section:
- _file_: charging station configuration template file relative path
- _numberOfStations_: template number of stations at startup
- _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 `/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/\.json**: +**`/station-templates/.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 `/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 `/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 `/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 `` 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 `` 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 | | {
"versionUpgrade": {
"step": 1
},
"reset": true
} | {
versionUpgrade?: {
patternGroup?: number;
step?: number;
};
reset?: boolean;
failureStatus?: 'DownloadFailed' \| 'InstallationFailed';
} | Configuration section for simulating firmware upgrade support. | | commandsSupport | | {
"incomingCommands": {},
"outgoingCommands": {}
} | {
incomingCommands: Record;
outgoingCommands?: Record;
} | 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/.json`): +**Schema** (`/.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/\.json**: +**`/configurations/[/].json`**: The charging station configuration file is automatically generated at startup from the charging station configuration template file and is persistent. +The optional `/` 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 `/configurations/vendor/.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) diff --git a/package.json b/package.json index c7e8838d..5cd3beda 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/charging-station/Bootstrap.ts b/src/charging-station/Bootstrap.ts index f8702b5a..ab97423e 100644 --- a/src/charging-station/Bootstrap.ts +++ b/src/charging-station/Bootstrap.ts @@ -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() - 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) { diff --git a/src/charging-station/ChargingStation.ts b/src/charging-station/ChargingStation.ts index 9d4b7b2d..292284bc 100644 --- a/src/charging-station/ChargingStation.ts +++ b/src/charging-station/ChargingStation.ts @@ -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() diff --git a/src/charging-station/HelpersConfig.ts b/src/charging-station/HelpersConfig.ts index 31a68587..9b946419 100644 --- a/src/charging-station/HelpersConfig.ts +++ b/src/charging-station/HelpersConfig.ts @@ -17,12 +17,12 @@ * (`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 } diff --git a/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts b/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts index 53e4d330..740bbf54 100644 --- a/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts +++ b/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts @@ -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 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 PayloadValidatorOptions( OCPPVersion.VERSION_16, - 'assets/json-schemas/ocpp/1.6', + `${OCPP_SCHEMA_SUBDIR}/1.6`, moduleName, methodName ) diff --git a/src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts b/src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts index 94f141f5..90e9b863 100644 --- a/src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts +++ b/src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts @@ -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-----' diff --git a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts index 08c1afbb..969b4f6d 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts @@ -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 ) diff --git a/src/charging-station/ocpp/OCPPServiceUtils.ts b/src/charging-station/ocpp/OCPPServiceUtils.ts index d20cdd3d..eb74462c 100644 --- a/src/charging-station/ocpp/OCPPServiceUtils.ts +++ b/src/charging-station/ocpp/OCPPServiceUtils.ts @@ -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 ( relativePath: string, @@ -3357,26 +3356,18 @@ function parseJsonSchemaFile ( moduleName?: string, methodName?: string ): JSONSchemaType { - 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 - } 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 - } 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 + } catch (error) { + handleFileException( + schemaPath, + FileType.JsonSchema, + error as NodeJS.ErrnoException, + ocppServiceUtilsLogPrefix(ocppVersion, moduleName, methodName) + ) + // handleFileException throws by default; this satisfies the compiler + throw error } } diff --git a/src/charging-station/ui-server/UIMCPServer.ts b/src/charging-station/ui-server/UIMCPServer.ts index 145a6beb..9cfa8908 100644 --- a/src/charging-station/ui-server/UIMCPServer.ts +++ b/src/charging-station/ui-server/UIMCPServer.ts @@ -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 ( diff --git a/src/charging-station/ui-server/mcp/MCPResources.ts b/src/charging-station/ui-server/mcp/MCPResources.ts index 55a7a2e3..ade3a2e3 100644 --- a/src/charging-station/ui-server/mcp/MCPResources.ts +++ b/src/charging-station/ui-server/mcp/MCPResources.ts @@ -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 index 00000000..6668082e --- /dev/null +++ b/src/exception/AssetsDirError.ts @@ -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 {} diff --git a/src/exception/index.ts b/src/exception/index.ts index b9df000d..38a2b057 100644 --- a/src/exception/index.ts +++ b/src/exception/index.ts @@ -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 index 00000000..e42a3d03 --- /dev/null +++ b/src/resolveAssetsDir.ts @@ -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. `/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 `/` 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 `/configurations` + */ +export const getConfigurationsDir = (): string => getAssetFilePath(CONFIGURATIONS_DIR_NAME) + +/** + * Resolves the directory that holds the charging station configuration templates. + * @returns `/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 `/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 `/json-schemas/ocpp` + */ +export const getOcppSchemaBaseDir = (): string => getAssetFilePath(OCPP_SCHEMA_SUBDIR) diff --git a/src/types/Statistics.ts b/src/types/Statistics.ts index f4dffa05..a217aa8b 100644 --- a/src/types/Statistics.ts +++ b/src/types/Statistics.ts @@ -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 `/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. */ diff --git a/src/utils/Configuration.ts b/src/utils/Configuration.ts index 3399a47a..6d909c8f 100644 --- a/src/utils/Configuration.ts +++ b/src/utils/Configuration.ts @@ -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 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 = { diff --git a/tests/charging-station/Bootstrap.test.ts b/tests/charging-station/Bootstrap.test.ts index 986a6fe3..d2c1e2cd 100644 --- a/tests/charging-station/Bootstrap.test.ts +++ b/tests/charging-station/Bootstrap.test.ts @@ -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 @@ -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((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 index 00000000..abf70cae --- /dev/null +++ b/tests/charging-station/ChargingStation-ConfigurationPersistence.test.ts @@ -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 +} + +const waitForConfigurationSave = async (station: ChargingStation): Promise => { + 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) + }) + } +}) diff --git a/tests/charging-station/ocpp/2.0/OCPP20CertificateManager.test.ts b/tests/charging-station/ocpp/2.0/OCPP20CertificateManager.test.ts index 06b58a21..393376bd 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20CertificateManager.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20CertificateManager.test.ts @@ -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() + 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 index 00000000..c7d69c6f --- /dev/null +++ b/tests/resolveAssetsDir.test.ts @@ -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 + * `/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 => { + 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 index 00000000..ce906222 --- /dev/null +++ b/tests/stubs/assets/.gitignore @@ -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 index 00000000..8904d96d --- /dev/null +++ b/tests/stubs/setupAssetsFixture.mjs @@ -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 index 00000000..7df1b97b --- /dev/null +++ b/tests/stubs/setupAssetsFixture.test.ts @@ -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 => { + 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 index 00000000..e89c2f08 --- /dev/null +++ b/tests/stubs/testEnvBootstrap.mjs @@ -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() +} diff --git a/tests/utils/Configuration.test.ts b/tests/utils/Configuration.test.ts index 5226fad0..0921eaca 100644 --- a/tests/utils/Configuration.test.ts +++ b/tests/utils/Configuration.test.ts @@ -10,6 +10,10 @@ * - 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' -- 2.53.0