]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
refactor: audit-driven cleanup (OCPP 2.0 spec conformance + 5 low-risk refactors...
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Mon, 6 Jul 2026 14:14:42 +0000 (16:14 +0200)
committerGitHub <noreply@github.com>
Mon, 6 Jul 2026 14:14:42 +0000 (16:14 +0200)
* fix(ocpp/2.0): correct spec conformance for value size constants

OCPP 2.0.1 mandates two distinct value size caps that the codebase collapsed
into a single MAX_VARIABLE_VALUE_LENGTH = 2500 constant, which caused the
SetVariable code path to accept payloads up to 2.5x the spec-defined limit
(§2.1.20: ConfigurationValueSize.maxLimit = 1000; SetVariableDataType.attributeValue
is string[0..1000]) and mislabeled the reporting cap (§2.1.21: ReportingValueSize.maxLimit = 2500).

Replace MAX_VARIABLE_VALUE_LENGTH with two spec-cited constants:
- MAX_CONFIGURATION_VALUE_SIZE = 1000 (SetVariable path, §2.1.20)
- MAX_REPORTING_VALUE_SIZE     = 2500 (GetBaseReport / reporting truncation, §2.1.21)

Remap all consumers:
- OCPP20VariableRegistry: ConfigurationValueSize maxLimit -> MAX_CONFIGURATION_VALUE_SIZE;
  ReportingValueSize + OCPP 2.1 ValueSize maxLimit -> MAX_REPORTING_VALUE_SIZE.
- OCPP20VariableManager readAttributeValue reporting truncation -> MAX_REPORTING_VALUE_SIZE.
- OCPP20VariableManager setVariable effective-limit fallback + absolute cap ->
  MAX_CONFIGURATION_VALUE_SIZE.
- Test fixtures resetReportingValueSize / resetValueSizeLimits use each spec constant
  according to which variable they seed.

Public API surface is not affected (constants are internal to ocpp/2.0).

* refactor: dedupe zod field-error mapping, centralize ui/web skin/theme keys

Three independent cleanups from the from-zero audit, bundled to share quality gates.

1. Extract shared Zod-issue to FieldError helpers (z-A1-F1)
   Two identical inline duplications (in ConfigurationValidation and
   TemplateValidation) mapped ZodError.issues to { message, path } records
   and joined them into the same '  - <path>: <message>' field summary.
   Extract two exported helpers in ConfigurationMigrations:
   - mapZodIssuesToFieldErrors(zodError)
   - formatFieldErrorsSummary(fieldErrors)
   Update both call sites; TemplateValidationError.fieldErrors is retyped
   from an inline object array to the shared FieldError (structurally
   identical, public surface preserved).

2. Centralize ui/web skin/theme storage keys in core/Constants (z-A2-F3)
   SKIN_STORAGE_KEY, DEFAULT_THEME, and THEME_STORAGE_KEY previously lived
   in the composables (useSkin.ts, useTheme.ts) even though main.ts and
   SkinLoadError.vue imported them from there for storage bootstrap,
   mirroring the already-centralized DEFAULT_SKIN. Move all three to
   ui/web/src/core/Constants.ts, re-export via the @/core barrel, and update
   the composables plus the two external consumers to import from @/core.
   Behaviour and localStorage keys are unchanged.

3. Adopt ui-common extractErrorMessage at 3 sites (z-A2-F4)
   Replace three copies of the 'error instanceof Error ? error.message :
   String(error)' pattern with extractErrorMessage(error) (ui-common already
   used this helper in ui/cli/src/config/loader.ts, output/json.ts,
   output/formatter.ts, and ui/web/src/skins/modern/utils/errors.ts):
   - ui/cli/src/commands/action.ts
   - ui/cli/src/commands/skill.ts
   - ui/web/src/shared/composables/useSkin.ts

Rationale for a deferred audit item (z-A1-F2, UIServerSecurity DEFAULT_*
constants to global Constants): declined. The six constants
(DEFAULT_MAX_PAYLOAD_SIZE_BYTES, DEFAULT_RATE_LIMIT, DEFAULT_RATE_WINDOW_MS,
DEFAULT_MAX_STATIONS, DEFAULT_MAX_TRACKED_IPS,
DEFAULT_COMPRESSION_THRESHOLD_BYTES) are ui-server-security tunables used
only within src/charging-station/ui-server/. Promoting them into the
charging-station-wide Constants class would blur module boundaries with
no consumer benefit; co-location with the security domain is intentional.

Public API surface is unchanged. All gates pass (format / typecheck / lint /
build / test) across root, ui/cli, and ui/web; skott circular-deps baseline
of 62 preserved.

* refactor(ui/web): align default-skin fallback and extract host:port literal

Two independent slop cleanups in ui/web from the from-zero audit.

1. Use canonical DEFAULT_SKIN in place of the 'classic' literal fallback (z-A2-F6)
   `ui/web/src/main.ts:62` used a hardcoded 'classic' as the fallback for
   `config.skin ?? 'classic'` even though the codebase declares the canonical
   default in `ui/web/src/core/Constants.ts`:
     export const DEFAULT_SKIN = 'modern'
   and `SkinLoadError.vue` already uses DEFAULT_SKIN as the recovery target.
   The magic string kept the two out of sync: a fresh boot with neither
   localStorage nor config.skin would land on 'classic' while the rest of
   the app treated 'modern' as the default. Route the fallback through
   DEFAULT_SKIN so there is a single source of truth for the default skin.

   Behavior change (intentional): first boot with no persisted skin and no
   `config.skin` now initializes on 'modern' (the declared default) instead
   of 'classic'. Users who explicitly set a skin (via config or the skin
   selector) are unaffected because their choice is honored before the
   fallback kicks in.

2. Extract 'host:port' literal in UIClient WebSocket adapter (z-A2-F7)
   Three interpolations of `${config.host}:${config.port.toString()}` in the
   onerror/onopen handlers of `createClientWithAbort` are collapsed into a
   single `uiServerAddress` local computed once at the top of the closure.
   Zero behavior change; just removes the triplicated interpolation.

Skipped audit item (z-A2-F5, adopt `nonEmptyStringOrUndefined` in
`useSetUrlForm` for supervisionUser/supervisionPassword): declined.
Per the WebSocket protocol contract documented in README.md ('setSupervisionUrl'
procedure), an empty string `""` for user/password explicitly clears the
existing CSMS auth, while `undefined` preserves it. Wrapping the form values
with `nonEmptyStringOrUndefined` would silently change the UX: leaving a
password field blank would preserve the old password instead of clearing it,
with no user-facing indication of the change. Keeping the raw pass-through
matches the documented protocol semantics.

Gates green (format / typecheck / lint / build / test:coverage) for ui/web;
root and ui/cli untouched.

* refactor: prune unused barrel exports and downgrade internal-only symbols

Five symbols were exported from src/utils/index.ts and src/types/index.ts but
have zero consumers in src/, tests/, ui/common/, ui/cli/, or ui/web/. Prune
them from the barrels; since each is also unused outside its defining file,
downgrade the file-level 'export' keyword so the internal-only status is
enforced at compile time (except ElementsPerWorkerType which had no consumer
at all and is fully removed).

Pruned barrel exports (root package has no library API surface — package.json
'exports' points only to dist/start.js — so unused barrel entries are dead):

- DEFAULT_PERSIST_STATE (src/utils/index.ts):
  used only inside Configuration.ts:196; barrel entry removed; definition
  downgraded from 'export const' to 'const'.
- UIServerAccessPolicySchema (src/utils/index.ts):
  used only inside ConfigurationSchema.ts:243 as .optional() nested schema;
  barrel entry removed; 'export const' -> 'const'.
- UIServerAuthenticationSchema (src/utils/index.ts):
  used only inside ConfigurationSchema.ts:244 as .optional() nested schema;
  barrel entry removed; 'export const' -> 'const'.
- AtomicWriteOptions (src/utils/index.ts):
  used only inside FileUtils.ts as parameter type for atomicWriteFile /
  atomicWriteFileSync; barrel entry removed; 'export interface' -> 'interface'.
- ElementsPerWorkerType (src/types/index.ts + ConfigurationData.ts):
  no consumer anywhere; both barrel entry and type alias definition removed.

Kept (audit misclassified as dead):
- isCertificateBased, isOCPP16Type, isOCPP20Type, requiresAdditionalInfo:
  all four exercised by tests/charging-station/ocpp/auth/types/AuthTypes.test.ts
  — pruning would drop test coverage.
- StrictTemplateSchema (src/charging-station/index.ts):
  documented escape hatch ('For CI strict mode' per TemplateSchema.ts:309);
  intentional public surface for ad-hoc strict validation with no in-repo
  consumer. Kept to honor documented intent.

All gates pass (format / typecheck / lint / build / test across root, ui/cli,
ui/web); circular-deps baseline preserved.

* refactor(auth): rename ConfigValidator to AuthConfigValidator for name coherence

The file src/charging-station/ocpp/auth/utils/ConfigValidator.ts exports a
single object under the name AuthConfigValidator and uses the string
'AuthConfigValidator' as its moduleName for log prefixes, but the file itself
was named after a more generic 'ConfigValidator' concept. Two consumers and
the test file already refer to the symbol as AuthConfigValidator, and the
generic file name overlaps semantically with the unrelated
src/utils/ConfigurationValidation.ts (application config validation) —
grep 'ConfigValidator' surfaces both, which is easy to misread.

Rename via 'git mv' to preserve history:
- src/charging-station/ocpp/auth/utils/ConfigValidator.ts
  -> src/charging-station/ocpp/auth/utils/AuthConfigValidator.ts
- tests/charging-station/ocpp/auth/utils/ConfigValidator.test.ts
  -> tests/charging-station/ocpp/auth/utils/AuthConfigValidator.test.ts

Update imports in AuthComponentFactory and OCPPAuthServiceImpl to reference
the new file path. No source content changes; exported symbol name and
module identity are unchanged.

Skipped audit items in this tier:
- z-A1-F3 (OCPP_WEBSOCKET_TIMEOUT_MS -> global Constants): declined. The
  constant already lives in src/charging-station/ocpp/OCPPConstants.ts, which
  is the OCPP-domain constants class. Promoting it to the charging-station-
  wide Constants would blur module boundaries with no consumer benefit —
  consistent with the same rationale that deferred UIServerSecurity DEFAULT_*
  centralization.
- z-A1-F4 (rename DEFAULT_ATG_WAIT_TIME_MS -> DEFAULT_ATG_RETRY_DELAY_MS):
  declined. All three call sites in AutomaticTransactionGenerator
  (waitChargingStationAvailable, waitConnectorAvailable,
  waitRunningTransactionStopped) are sleep() delays inside 'wait for
  condition' polling while-loops, not retry loops on a failing operation.
  'RETRY_DELAY' would misdescribe the semantics; the current 'WAIT_TIME' is
  the accurate label.

All gates pass (format / typecheck / lint / build / test).

* test: use TEST_SUPERVISION_URL constant instead of hardcoded ws://localhost:8080

Six test files repeated the literal 'ws://localhost:8080' 15 times as a
supervision URL fixture. tests/utils/TestNetworkConstants.ts already provides
the canonical TEST_SUPERVISION_URL:

  export const TEST_SUPERVISION_URL =
    `ws://${Constants.DEFAULT_UI_SERVER_HOST}:${Constants.DEFAULT_UI_SERVER_PORT.toString()}`

Route each fixture through that constant so a change to the production UI
server host/port defaults propagates to every test without a search/replace
sweep. Files updated:

- tests/charging-station/TemplateValidation.test.ts (6 sites)
- tests/charging-station/TemplateMigrations.test.ts (3 sites)
- tests/charging-station/TemplateSchema.test.ts     (1 site)
- tests/utils/ConfigurationSchema.test.ts           (3 sites — including the
  first entry of the ['ws://localhost:8080', 'ws://localhost:8081'] array;
  the second literal is intentionally distinct and stays hardcoded)
- tests/utils/ConfigurationValidation.test.ts       (1 site)
- tests/performance/storage/MikroOrmStorage.test.ts (1 site)

The JSDoc example in tests/charging-station/mocks/MockWebSocket.ts is
preserved as-is (documentation, not a fixture).

Fixture values, test assertions, and behavior are unchanged.
All gates pass (format / typecheck / lint / build / test).

* refactor: reword narrative comments and anchor non-systemic eslint-disable directives

Two audit tiers bundled to share the gate run.

Tier 8 - narrative/imperative comments -> state-describing form:
Three comments in touched files were rewritten from developer-narrative to
state-describing per the repo comment convention.

- src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts
  'We need the directory: basePath/ChargingStationCertificate' is replaced
  with a two-line state description of what dirPath is and how it relates
  to the getCertificatePath return value. Anchors the non-obvious
  resolve(certFilePath, '..') derivation to the surrounding directory check.
- src/charging-station/ocpp/auth/strategies/CertificateAuthStrategy.ts
  'Should not reach here due to canHandle check, but handle gracefully'
  becomes 'Unreachable when the canHandle contract holds; defensive fallback
  for unsupported OCPP versions'.
  'Must have certificate data in the identifier' becomes
  'The identifier carries certificate data' (paired with a matching rewrite
  of 'Certificate authentication must be enabled' -> '...is enabled per
  configuration').

Tier 9 - '-- reason' anchors on non-systemic eslint-disable directives:
Sixteen one-off eslint-disable-next-line directives now carry the '-- reason'
anchor already used elsewhere in the repo (e.g. UIServerFactory.ts,
OCPPResponseService.ts). Systemic directives (repeated idioms such as
@typescript-eslint/no-redeclare for the enum + type declaration-merging
pattern, restrict-template-expressions for logger calls, no-extraneous-class
for the Constants classes, no-unnecessary-type-parameters for contravariant
handler bridges) are left unchanged in this pass — those are pattern-level
choices that belong in a repo-wide rationale, not per-site.

Anchored one-offs:
- src/types/ocpp/ErrorType.ts @cspell/spellchecker
- src/charging-station/ChargingStation.ts @typescript-eslint/no-deprecated
- src/charging-station/ChargingStation.ts promise/no-promise-in-callback
- src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts
    three @typescript-eslint/no-invalid-void-type + one promise/catch-or-return
- src/charging-station/TemplateMigrations.ts @typescript-eslint/no-dynamic-delete
- src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts no-fallthrough
- src/charging-station/ocpp/OCPPServiceUtils.ts @typescript-eslint/no-unsafe-assignment
- src/charging-station/ocpp/OCPPRequestService.ts @typescript-eslint/no-this-alias
- src/charging-station/Bootstrap.ts two @typescript-eslint/unbound-method
- src/worker/WorkerSet.ts promise/no-promise-in-callback
- ui/web/src/shared/composables/useTheme.ts no-void
- ui/web/src/shared/composables/useAsyncAction.ts no-void

Behavior is unchanged; every directive still disables the same rule at the
same line, only the rationale is now recorded inline.

Gates green (format / typecheck / lint / build / test) across root and ui/web;
circular-deps baseline of 62 preserved.

* refactor(utils): extract FieldError into a single-purpose module

Two review findings from PR #1959 addressed together — both are internal
tidying with no behavior change.

1. Extract Zod field-error helpers out of ConfigurationMigrations
   ConfigurationMigrations owned the FieldError interface plus
   mapZodIssuesToFieldErrors and formatFieldErrorsSummary, but those helpers
   are Zod-issue formatters used by TemplateValidation and
   ConfigurationValidation — neither of which is a configuration-migration
   concern. Move the interface and both helpers into a dedicated single-
   purpose module src/utils/FieldError.ts, re-export via the utils barrel,
   and route all three consumers to import from ./FieldError.js:
   - src/utils/ConfigurationMigrations.ts now imports the FieldError type
     it still uses in remapDeprecatedKeys and RemapDeprecatedKeysResult.
   - src/utils/ConfigurationValidation.ts imports the type plus both helpers.
   - src/charging-station/TemplateValidation.ts switches from the deep
     import of ConfigurationMigrations to the deep import of FieldError.
   The utils/index.ts barrel gains a FieldError export block placed
   alphabetically between ErrorUtils and FileUtils.

2. Add TEST_SUPERVISION_URL_ALT_PORT for two-URL fixtures
   tests/utils/ConfigurationSchema.test.ts had a mixed literal/constant
   array — [TEST_SUPERVISION_URL, 'ws://localhost:8081'] — used to verify
   supervisionUrls accepts a string array. Both entries now come from
   TestNetworkConstants: the alternate port is DEFAULT_UI_SERVER_PORT + 1,
   keeping the same 'derived from production defaults' contract. The
   TestNetworkConstants file also factors HOST/PORT into local constants
   to keep the two URL definitions DRY.

Additional review-finding dispositions:

- Finding #3 (visual QA on the DEFAULT_SKIN fallback change): already
  regression-locked by existing Vitest coverage —
  tests/unit/skins/registry.test.ts:12 pins DEFAULT_SKIN === 'modern',
  tests/unit/shared/composables/useSkin.test.ts exercises
  switchSkin(DEFAULT_SKIN) and asserts activeSkinId defaults to
  DEFAULT_SKIN. No additional test needed.
- Finding #4 (Validation vs Validator terminology): withdrawn on
  re-inspection. *Validation.ts modules host the validation process
  (validateConfiguration, ConfigurationValidationError). AuthConfigValidator
  is a validator entity object with a .validate() method. The suffix
  distinguishes 'process module' from 'entity object' — intentional and
  consistent, not divergent.
- Finding #5 (spec coverage for VariableCharacteristics.valueList and
  EventData.actualValue): pre-existing feature-not-implemented gaps in the
  simulator (grep confirms neither valueList nor NotifyEvent code paths
  exist under src/charging-station/ocpp/2.0/). Bounding these would
  require implementing the underlying features first; out of scope for
  this review-response commit.

Public API surface unchanged. Gates green (format / typecheck / lint /
build / test); circular-deps baseline of 62 preserved.

* chore(charging-station): unify TemplateValidation utils imports through the barrel

Round-2 review finding: TemplateValidation.ts mixed two import styles for
the same target directory. Lines 6-10 pulled FieldError + helpers via a
deep import ('../utils/FieldError.js') while line 11 pulled other utilities
via the barrel ('../utils/index.js'). Both symbol groups originate from
src/utils/ and the barrel already re-exports the FieldError trio (utils/
index.ts:43-47).

Merge both into a single barrel import block, alphabetically ordered
(case-insensitive) per the repo's perfectionist convention:
  assertIsJsonObject, clone, type FieldError, formatFieldErrorsSummary,
  isEmpty, isNotEmptyString, logger, mapZodIssuesToFieldErrors.

The 'type FieldError' inline qualifier is kept in the mixed named-import
(same pattern used by utils/index.ts's own FieldError re-export block).
Behavior unchanged; only import mechanics shift.

All gates pass (format / typecheck / lint / build / test); skott circular-
deps baseline of 62 preserved.

48 files changed:
src/charging-station/Bootstrap.ts
src/charging-station/ChargingStation.ts
src/charging-station/TemplateMigrations.ts
src/charging-station/TemplateValidation.ts
src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts
src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts
src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts
src/charging-station/ocpp/2.0/OCPP20Constants.ts
src/charging-station/ocpp/2.0/OCPP20VariableManager.ts
src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts
src/charging-station/ocpp/OCPPRequestService.ts
src/charging-station/ocpp/OCPPServiceUtils.ts
src/charging-station/ocpp/auth/factories/AuthComponentFactory.ts
src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.ts
src/charging-station/ocpp/auth/strategies/CertificateAuthStrategy.ts
src/charging-station/ocpp/auth/utils/AuthConfigValidator.ts [moved from src/charging-station/ocpp/auth/utils/ConfigValidator.ts with 100% similarity]
src/types/ConfigurationData.ts
src/types/index.ts
src/types/ocpp/ErrorType.ts
src/utils/Configuration.ts
src/utils/ConfigurationMigrations.ts
src/utils/ConfigurationSchema.ts
src/utils/ConfigurationValidation.ts
src/utils/FieldError.ts [new file with mode: 0644]
src/utils/FileUtils.ts
src/utils/index.ts
src/worker/WorkerSet.ts
tests/charging-station/TemplateMigrations.test.ts
tests/charging-station/TemplateSchema.test.ts
tests/charging-station/TemplateValidation.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetBaseReport.test.ts
tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts
tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts
tests/charging-station/ocpp/auth/utils/AuthConfigValidator.test.ts [moved from tests/charging-station/ocpp/auth/utils/ConfigValidator.test.ts with 99% similarity]
tests/performance/storage/MikroOrmStorage.test.ts
tests/utils/ConfigurationSchema.test.ts
tests/utils/ConfigurationValidation.test.ts
tests/utils/TestNetworkConstants.ts
ui/cli/src/commands/action.ts
ui/cli/src/commands/skill.ts
ui/web/src/core/Constants.ts
ui/web/src/core/UIClient.ts
ui/web/src/core/index.ts
ui/web/src/main.ts
ui/web/src/shared/components/SkinLoadError.vue
ui/web/src/shared/composables/useAsyncAction.ts
ui/web/src/shared/composables/useSkin.ts
ui/web/src/shared/composables/useTheme.ts

index 40a00eb73ca49f619c555c20f0365941c0fde36a..7a0355b32c4bacab93f76236dfcbd53a46360a10 100644 (file)
@@ -341,7 +341,7 @@ export class Bootstrap extends EventEmitter implements IBootstrap {
         ChargingStationWorkerMessageEvents.performanceStatistics,
         this.workerEventPerformanceStatistics
       )
-      // eslint-disable-next-line @typescript-eslint/unbound-method
+      // eslint-disable-next-line @typescript-eslint/unbound-method -- isAsyncFunction inspects the method's constructor tag only; no `this` binding is required
       if (isAsyncFunction(this.workerImplementation?.start)) {
         await this.workerImplementation.start()
       } else {
@@ -753,7 +753,7 @@ export class Bootstrap extends EventEmitter implements IBootstrap {
   }
 
   private readonly workerEventPerformanceStatistics = (data: Statistics): void => {
-    // eslint-disable-next-line @typescript-eslint/unbound-method
+    // eslint-disable-next-line @typescript-eslint/unbound-method -- isAsyncFunction inspects the method's constructor tag only; no `this` binding is required
     if (isAsyncFunction(this.storage?.storePerformanceStatistics)) {
       ;(
         this.storage.storePerformanceStatistics as (
index f32f9d0c44a631a6c157d63dcd6fcc6ed0bddb66..22f2136cf135556cf630bf5e025e599f26ab34fd 100644 (file)
@@ -1652,7 +1652,7 @@ export class ChargingStation extends EventEmitter {
     if (stationInfoPersistentConfiguration) {
       stationInfo = this.getConfigurationFromFile()?.stationInfo
       if (stationInfo != null) {
-        // eslint-disable-next-line @typescript-eslint/no-deprecated
+        // eslint-disable-next-line @typescript-eslint/no-deprecated -- pruning the deprecated `infoHash` field from the persisted-configuration snapshot before use
         delete stationInfo.infoHash
         delete (stationInfo as ChargingStationTemplate).numberOfConnectors
         // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
@@ -2783,7 +2783,7 @@ export class ChargingStation extends EventEmitter {
             error
           )
         }
-        // eslint-disable-next-line promise/no-promise-in-callback
+        // eslint-disable-next-line promise/no-promise-in-callback -- exponential-backoff sleep inside a WebSocket callback; failures on the outer send are surfaced separately
         sleep(
           computeExponentialBackOffDelay({
             baseDelayMs: Constants.DEFAULT_EXPONENTIAL_BACKOFF_BASE_DELAY_MS,
index 0654c04e0419bcb062f8f6e3c77aa763e34c4e18..e5857f1971a75803fcc7c2baf7ea7983da25a3d3 100644 (file)
@@ -130,7 +130,7 @@ function migrateV0ToV1 (template: Record<string, unknown>): Record<string, unkno
       if (key != null) {
         template[key] = template[deprecatedKey]
       }
-      // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
+      // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- deleting the deprecated key that was just remapped to its canonical destination
       delete template[deprecatedKey]
     }
   }
index 72d97073c1470af47493f8bbb6f685e0543d9e52..43f530539a56d43347131f1d1a0ee020206d76b5 100644 (file)
@@ -3,7 +3,16 @@ import type { ZodError } from 'zod'
 import type { ChargingStationTemplate } from '../types/index.js'
 
 import { BaseError } from '../exception/index.js'
-import { assertIsJsonObject, clone, isEmpty, isNotEmptyString, logger } from '../utils/index.js'
+import {
+  assertIsJsonObject,
+  clone,
+  type FieldError,
+  formatFieldErrorsSummary,
+  isEmpty,
+  isNotEmptyString,
+  logger,
+  mapZodIssuesToFieldErrors,
+} from '../utils/index.js'
 import { getMaxConfiguredNumberOfConnectors } from './Helpers.js'
 import { applyMigration, coerceVersion, CURRENT_SCHEMA_VERSION } from './TemplateMigrations.js'
 import { TemplateSchema } from './TemplateSchema.js'
@@ -15,19 +24,14 @@ const moduleName = 'TemplateValidation'
  * Includes structured field errors and migration context for diagnostics.
  */
 export class TemplateValidationError extends BaseError {
-  public readonly fieldErrors: { message: string; path: string }[]
+  public readonly fieldErrors: FieldError[]
   public readonly filePath: string
   public readonly migratedFrom?: number
   public override readonly name = 'TemplateValidationError' as const
 
   public constructor (zodError: ZodError, context: { filePath: string; migratedFrom?: number }) {
-    const fieldErrors = zodError.issues.map(issue => ({
-      message: issue.message,
-      path: issue.path.join('.'),
-    }))
-    const fieldSummary = fieldErrors
-      .map(e => `  - ${e.path !== '' ? e.path : '(root)'}: ${e.message}`)
-      .join('\n')
+    const fieldErrors = mapZodIssuesToFieldErrors(zodError)
+    const fieldSummary = formatFieldErrorsSummary(fieldErrors)
     const migrationNote =
       context.migratedFrom != null
         ? ` (migrated from v${context.migratedFrom.toString()} → v${CURRENT_SCHEMA_VERSION.toString()})`
index bf86c3f343c20df6a0a60c4ec6bfd54f73070079..b3f1f7511186a26912f9c094152da897da890424 100644 (file)
@@ -61,7 +61,7 @@ const moduleName = 'ChargingStationWorkerBroadcastChannel'
 
 type CommandHandler = (
   requestPayload?: BroadcastChannelRequestPayload
-  // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
+  // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- broadcast-channel command handlers may legitimately return no payload
 ) => CommandResponse | Promise<CommandResponse | void> | void
 
 type CommandResponse =
@@ -274,7 +274,7 @@ export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChanne
   private async commandHandler (
     command: BroadcastChannelProcedureName,
     requestPayload: BroadcastChannelRequestPayload
-    // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
+    // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- broadcast-channel command handlers may resolve with no payload
   ): Promise<CommandResponse | void> {
     if (this.commandHandlers.has(command)) {
       this.cleanRequestPayload(command, requestPayload)
@@ -288,7 +288,7 @@ export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChanne
       return (
         commandHandler as (
           requestPayload?: BroadcastChannelRequestPayload
-          // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
+          // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- narrow cast onto the void-returning branch of the CommandHandler union
         ) => CommandResponse | void
       )(requestPayload)
     }
@@ -544,7 +544,7 @@ export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChanne
       return
     }
     let responsePayload: BroadcastChannelResponsePayload | undefined
-    // eslint-disable-next-line promise/catch-or-return
+    // eslint-disable-next-line promise/catch-or-return -- errors are handled inside .then via the response payload; the returned promise is intentionally discarded
     this.commandHandler(command, requestPayload)
       .then(commandResponse => {
         if (commandResponse == null || isEmpty(commandResponse)) {
index a164db1180db1703491db903dfe160b8aad85164..13ad03dd7e23a33976c87c04c1200de4fba1fc6a 100644 (file)
@@ -1459,7 +1459,7 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService {
             response = OCPP16Constants.OCPP_RESERVATION_RESPONSE_OCCUPIED
             break
           }
-        // eslint-disable-next-line no-fallthrough
+        // eslint-disable-next-line no-fallthrough -- intentional fall-through when the connector-scoped reservability check passes: continue to the connector-agnostic check
         default:
           if (!chargingStation.isConnectorReservable(reservationId, idTag)) {
             response = OCPP16Constants.OCPP_RESERVATION_RESPONSE_OCCUPIED
index ac66e21327d4f8ebcbdacf15549f5e8455817ee6..7e0a3f3d87bb579a3be6f6b88db068fe0284510c 100644 (file)
@@ -342,8 +342,8 @@ export class OCPP20CertificateManager {
         CertificateSigningUseEnumType.ChargingStationCertificate,
         ''
       )
-      // getCertificatePath returns basePath/ChargingStationCertificate/.pem
-      // We need the directory: basePath/ChargingStationCertificate
+      // `getCertificatePath` returns `basePath/ChargingStationCertificate/.pem`;
+      // `dirPath` is its parent directory `basePath/ChargingStationCertificate`.
       const dirPath = resolve(certFilePath, '..')
 
       if (!(await this.pathExists(dirPath))) {
index ce5af58e92da3d722be12e3c688f02f88a11034a..cf051bf24d38850c8fd771c9fb7dc05efb304857 100644 (file)
@@ -161,9 +161,13 @@ export class OCPP20Constants extends OCPPConstants {
 
   static readonly LOG_UPLOAD_STEP_DELAY_MS = 1000
 
-  static readonly MAX_SECURITY_EVENT_SEND_ATTEMPTS = 3
+  /** OCPP 2.0.1 §2.1.20: `ConfigurationValueSize` `maxLimit`. Cap on `SetVariableDataType.attributeValue` `string[0..1000]`. */
+  static readonly MAX_CONFIGURATION_VALUE_SIZE = 1000
+
+  /** OCPP 2.0.1 §2.1.21: `ReportingValueSize` `maxLimit`. Cap on `GetVariableResult.attributeValue` `string[0..2500]`. */
+  static readonly MAX_REPORTING_VALUE_SIZE = 2500
 
-  static readonly MAX_VARIABLE_VALUE_LENGTH = 2500
+  static readonly MAX_SECURITY_EVENT_SEND_ATTEMPTS = 3
 
   static readonly OCPP_SEND_LOCAL_LIST_RESPONSE_ACCEPTED: OCPP20SendLocalListResponse =
     Object.freeze({
index 5a751eb07b5cba16612f38e98c8a8ab9cebf9e58..52fda3b9406866053e3ce195e916f318842360d8 100644 (file)
@@ -540,9 +540,9 @@ export class OCPP20VariableManager {
       variableValue = enforceReportingValueSize(variableValue, reportingValueSize)
     }
 
-    // Final absolute length enforcement (spec maxLength OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH)
-    if (variableValue.length > OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH) {
-      variableValue = variableValue.slice(0, OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH)
+    // Reporting absolute cap (OCPP 2.0.1 §2.1.21 ReportingValueSize maxLimit = 2500)
+    if (variableValue.length > OCPP20Constants.MAX_REPORTING_VALUE_SIZE) {
+      variableValue = variableValue.slice(0, OCPP20Constants.MAX_REPORTING_VALUE_SIZE)
     }
     return {
       attributeStatus: GetVariableStatusEnumType.Accepted,
@@ -873,8 +873,8 @@ export class OCPP20VariableManager {
     // 1. Read ConfigurationValueSize and ValueSize if present and valid (>0).
     // 2. If both valid, use the smaller positive value.
     // 3. If only one valid, use that value.
-    // 4. If neither valid/positive, fallback to spec maxLength (OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH).
-    // 5. Enforce absolute upper cap of OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH (spec).
+    // 4. If neither valid/positive, fallback to `OCPP20Constants.MAX_CONFIGURATION_VALUE_SIZE` (OCPP 2.0.1 §2.1.20 maxLimit = 1000).
+    // 5. Enforce absolute upper cap of `OCPP20Constants.MAX_CONFIGURATION_VALUE_SIZE` (SetVariableDataType.attributeValue = string[0..1000]).
     // 6. Reject with TooLargeElement when attributeValue length strictly exceeds effectiveLimit.
     if (resolvedAttributeType === AttributeEnumType.Actual) {
       const configurationValueSizeKey = buildCaseInsensitiveCompositeKey(
@@ -914,10 +914,10 @@ export class OCPP20VariableManager {
         effectiveLimit = effectiveLimit != null ? Math.min(effectiveLimit, valLimit) : valLimit
       }
       if (effectiveLimit == null || effectiveLimit <= 0) {
-        effectiveLimit = OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH
+        effectiveLimit = OCPP20Constants.MAX_CONFIGURATION_VALUE_SIZE
       }
-      if (effectiveLimit > OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH) {
-        effectiveLimit = OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH
+      if (effectiveLimit > OCPP20Constants.MAX_CONFIGURATION_VALUE_SIZE) {
+        effectiveLimit = OCPP20Constants.MAX_CONFIGURATION_VALUE_SIZE
       }
       if (attributeValue.length > effectiveLimit) {
         return this.rejectSet(
index 7cc9ec95461c01667c8e04988592cde04a3ad486..f6a89be34bb49dd4c17e73ceb282b41a98354458 100644 (file)
@@ -738,16 +738,16 @@ export const VARIABLE_REGISTRY: Record<string, VariableMetadata> = {
   },
 
   // DeviceDataCtrlr Component
-  // Value size family: ValueSize (broadest), ConfigurationValueSize (affects setting), ReportingValueSize (affects reporting). Simulator sets same absolute cap; truncate occurs at reporting step.
+  // Value size family per OCPP 2.0.1 §2.1.20-21: ConfigurationValueSize (SetVariable input cap, 1000), ReportingValueSize (GetVariable output cap, 2500). ValueSize is OCPP 2.1 (broadest umbrella, capped at reporting size).
   [buildRegistryKey(
     OCPP20ComponentName.DeviceDataCtrlr,
     OCPP20OptionalVariableName.ConfigurationValueSize
   )]: {
     component: OCPP20ComponentName.DeviceDataCtrlr,
     dataType: DataEnumType.integer,
-    defaultValue: OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH.toString(),
+    defaultValue: OCPP20Constants.MAX_CONFIGURATION_VALUE_SIZE.toString(),
     description: 'Maximum size allowed for configuration values when setting.',
-    max: OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH,
+    max: OCPP20Constants.MAX_CONFIGURATION_VALUE_SIZE,
     maxLength: 5,
     min: 1,
     mutability: MutabilityEnumType.ReadOnly,
@@ -763,9 +763,9 @@ export const VARIABLE_REGISTRY: Record<string, VariableMetadata> = {
   )]: {
     component: OCPP20ComponentName.DeviceDataCtrlr,
     dataType: DataEnumType.integer,
-    defaultValue: OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH.toString(),
+    defaultValue: OCPP20Constants.MAX_REPORTING_VALUE_SIZE.toString(),
     description: 'Maximum size of reported values.',
-    max: OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH,
+    max: OCPP20Constants.MAX_REPORTING_VALUE_SIZE,
     maxLength: 5,
     min: 1,
     mutability: MutabilityEnumType.ReadOnly,
@@ -778,9 +778,9 @@ export const VARIABLE_REGISTRY: Record<string, VariableMetadata> = {
   [buildRegistryKey(OCPP20ComponentName.DeviceDataCtrlr, OCPP20OptionalVariableName.ValueSize)]: {
     component: OCPP20ComponentName.DeviceDataCtrlr,
     dataType: DataEnumType.integer,
-    defaultValue: OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH.toString(),
+    defaultValue: OCPP20Constants.MAX_REPORTING_VALUE_SIZE.toString(),
     description: 'Maximum size for any stored or reported value.',
-    max: OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH,
+    max: OCPP20Constants.MAX_REPORTING_VALUE_SIZE,
     maxLength: 5,
     min: 1,
     mutability: MutabilityEnumType.ReadOnly,
index 4d30f9a7c78eab3b852537bb330b6cdb7fa0471d..09e4e2e531fcbdf8277496082ce7733137fdfcf0 100644 (file)
@@ -306,7 +306,7 @@ export abstract class OCPPRequestService {
       (chargingStation.inPendingState() &&
         (params.triggerMessage === true || messageType === MessageType.CALL_RESULT_MESSAGE))
     ) {
-      // eslint-disable-next-line @typescript-eslint/no-this-alias
+      // eslint-disable-next-line @typescript-eslint/no-this-alias -- stable outer-this reference captured for nested Promise executor and its response-handler closures
       const self = this
       // Send a message through wsConnection
       return await new Promise<ResponseType>((resolve, reject: (reason?: unknown) => void) => {
index d93196d1dba91dcdaf6c3abbad473ef5314e1412..3f91df21d97cd6a9b8dff5a340b81e79a0c91eb6 100644 (file)
@@ -225,7 +225,7 @@ export const convertDateToISOString = <T extends JsonType>(object: T): void => {
       }
     } else if (Array.isArray(value)) {
       for (let i = 0; i < value.length; i++) {
-        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- iterating an unknown-typed JSON array to normalize Date entries in place
         const item = value[i]
         if (isDate(item)) {
           try {
index 3a1424c3a107a47fd25a1f9b25c75b662d970718..a0d0decfb54862a37dfa064b70e89a9e7a8a6f78 100644 (file)
@@ -17,7 +17,7 @@ import { InMemoryLocalAuthListManager } from '../cache/InMemoryLocalAuthListMana
 import { CertificateAuthStrategy } from '../strategies/CertificateAuthStrategy.js'
 import { LocalAuthStrategy } from '../strategies/LocalAuthStrategy.js'
 import { RemoteAuthStrategy } from '../strategies/RemoteAuthStrategy.js'
-import { AuthConfigValidator } from '../utils/ConfigValidator.js'
+import { AuthConfigValidator } from '../utils/AuthConfigValidator.js'
 
 /**
  * Factory for creating authentication components with proper dependency injection
index a9b3d488433dc41a8604cd654b7ab9e49b2e2af3..7fd3d4fa0a42d8f1a157a03f18c264c9227f86cc 100644 (file)
@@ -33,7 +33,7 @@ import {
   type Identifier,
   IdentifierType,
 } from '../types/AuthTypes.js'
-import { AuthConfigValidator } from '../utils/ConfigValidator.js'
+import { AuthConfigValidator } from '../utils/AuthConfigValidator.js'
 
 const moduleName = 'OCPPAuthServiceImpl'
 
index 9dad5686049f61195316d89724b78df928d28beb..2cf6f3d2dc4227a297d1e014b54eeff51cfb6567 100644 (file)
@@ -83,7 +83,8 @@ export class CertificateAuthStrategy implements AuthStrategy {
         return result
       }
 
-      // Should not reach here due to canHandle check, but handle gracefully
+      // Unreachable when the `canHandle` contract holds; defensive fallback
+      // for unsupported OCPP versions.
       return this.createFailureResult(
         AuthorizationStatus.INVALID,
         `Certificate authentication not supported for OCPP ${this.adapter.ocppVersion}`,
@@ -118,10 +119,10 @@ export class CertificateAuthStrategy implements AuthStrategy {
       return false
     }
 
-    // Certificate authentication must be enabled
+    // Certificate authentication is enabled per configuration.
     const certAuthEnabled = config.certificateAuthEnabled
 
-    // Must have certificate data in the identifier
+    // The identifier carries certificate data.
     const hasCertificateData = this.hasCertificateData(request.identifier)
 
     return certAuthEnabled && hasCertificateData && this.isInitialized
index 81318c4a5f89dee274d5a19e56f7dc725aec09a3..425f57c34229ef611b3eb7c2f492a44c07dbac27 100644 (file)
@@ -29,7 +29,6 @@ export enum SupervisionUrlDistribution {
 }
 
 export type ConfigurationData = z.infer<typeof ConfigurationSchema>
-export type ElementsPerWorkerType = NonNullable<WorkerConfiguration['elementsPerWorker']>
 export type LogConfiguration = z.infer<typeof LogConfigurationSchema>
 export type StationTemplateUrl = z.infer<typeof StationTemplateUrlSchema>
 export type StorageConfiguration = z.infer<typeof StorageConfigurationSchema>
index 8685f3f6f160319e38b20455f48bbfed55e0d2cf..5dd88ef5ba4e7346208617aa9a58c68d381a5269 100644 (file)
@@ -37,7 +37,6 @@ export {
   ApplicationProtocolVersion,
   type ConfigurationData,
   ConfigurationSection,
-  type ElementsPerWorkerType,
   type LogConfiguration,
   type StationTemplateUrl,
   type StorageConfiguration,
index 9fbb5e73198e81759f47838e679aa19d165198d6..54da525457e9c9a9fdb2d514bce6726a7e63881f 100644 (file)
@@ -23,7 +23,7 @@ export enum ErrorType {
   RPC_FRAMEWORK_ERROR = 'RpcFrameworkError',
   // During the processing of Action a security issue occurred preventing receiver from completing the Action successfully
   SECURITY_ERROR = 'SecurityError',
-  // eslint-disable-next-line @cspell/spellchecker
+  // eslint-disable-next-line @cspell/spellchecker -- placeholder example value in the spec quote is not a dictionary word
   // Payload for Action is syntactically correct but at least one of the fields violates data type constraints (e.g. "somestring" = 12)
   TYPE_CONSTRAINT_VIOLATION = 'TypeConstraintViolation',
 }
index c242c9ecacc1148d11f9ba238ec635bb3a2116bb..13a1a185cb234df92957419d5597fd38fc4af506 100644 (file)
@@ -90,7 +90,7 @@ const defaultWorkerConfiguration: WorkerConfiguration = {
   startDelay: DEFAULT_WORKER_START_DELAY_MS,
 }
 
-export const DEFAULT_PERSIST_STATE = true as const
+const DEFAULT_PERSIST_STATE = true as const
 
 // eslint-disable-next-line @typescript-eslint/no-extraneous-class
 export class Configuration {
index b4ca14a225fbe057434060b2b079f8623b6550e9..357cf029ca4b9eb4b6913188e6267dc4cfe21d69 100644 (file)
@@ -1,5 +1,7 @@
 import { isDeepStrictEqual } from 'node:util'
 
+import type { FieldError } from './FieldError.js'
+
 // Direct path: the `exception/index.js` barrel re-exports OCPPError, causing a TDZ cycle.
 import { BaseError } from '../exception/BaseError.js'
 import { clone } from './Utils.js'
@@ -54,11 +56,6 @@ export const DEPRECATED_KEY_REMAPPINGS: Readonly<Record<string, null | string>>
  */
 export const CURRENT_CONFIGURATION_SCHEMA_VERSION = 1
 
-export interface FieldError {
-  message: string
-  path: string
-}
-
 export type MigrationFn = (
   config: Record<string, unknown>,
   filePath: string
index e26aec4f3dc8d595f767d00da87a0f8db24bd294..9f79a4880f70bdb4b4160074ae1f40efacd81ddb 100644 (file)
@@ -88,7 +88,7 @@ export const StorageConfigurationSchema = z
  * `UIServerFactory` so empty placeholders cannot ship under `enabled: false`
  * and become a Basic-Auth bypass on the next boot with `enabled: true`.
  */
-export const UIServerAuthenticationSchema = z
+const UIServerAuthenticationSchema = z
   .object({
     enabled: z.boolean(),
     password: z.string().min(1).optional(),
@@ -128,7 +128,7 @@ export const UI_SERVER_ACCESS_POLICY_DEFAULTS = {
   trustedProxies: [],
 } as const
 
-export const UIServerAccessPolicySchema = z
+const UIServerAccessPolicySchema = z
   .object({
     allowedHosts: z
       .array(
index 9cd36e789a6c785b1a454d44bd3de875e01981cf..68ad1e109023ed954dbdf5d7a2cf4cf9ee92f319 100644 (file)
@@ -3,7 +3,7 @@ import type { ZodError } from 'zod'
 import chalk from 'chalk'
 
 import type { ConfigurationData } from '../types/index.js'
-import type { FieldError } from './ConfigurationMigrations.js'
+import type { FieldError } from './FieldError.js'
 
 import { BaseError } from '../exception/index.js'
 import {
@@ -14,6 +14,7 @@ import {
 } from './ConfigurationMigrations.js'
 import { ConfigurationSchema } from './ConfigurationSchema.js'
 import { configurationLogPrefix } from './ConfigurationUtils.js'
+import { formatFieldErrorsSummary, mapZodIssuesToFieldErrors } from './FieldError.js'
 import { assertIsJsonObject, clone, isEmpty, isNotEmptyArray } from './Utils.js'
 
 const moduleName = 'ConfigurationValidation'
@@ -44,9 +45,7 @@ export class ConfigurationValidationError extends BaseError {
     fieldErrors: FieldError[],
     context: { filePath: string; migratedFrom?: number; phase: ValidationPhase }
   ) {
-    const fieldSummary = fieldErrors
-      .map(e => `  - ${e.path !== '' ? e.path : '(root)'}: ${e.message}`)
-      .join('\n')
+    const fieldSummary = formatFieldErrorsSummary(fieldErrors)
     const migrationNote =
       context.migratedFrom != null
         ? ` (migrated from v${context.migratedFrom.toString()} → v${CURRENT_CONFIGURATION_SCHEMA_VERSION.toString()})`
@@ -72,10 +71,7 @@ export class ConfigurationValidationError extends BaseError {
     zodError: ZodError,
     context: { filePath: string; migratedFrom?: number }
   ): ConfigurationValidationError {
-    const fieldErrors: FieldError[] = zodError.issues.map(issue => ({
-      message: issue.message,
-      path: issue.path.join('.'),
-    }))
+    const fieldErrors: FieldError[] = mapZodIssuesToFieldErrors(zodError)
     return new ConfigurationValidationError(fieldErrors, { ...context, phase: 'schema' })
   }
 }
diff --git a/src/utils/FieldError.ts b/src/utils/FieldError.ts
new file mode 100644 (file)
index 0000000..a0bf72f
--- /dev/null
@@ -0,0 +1,15 @@
+import type { ZodError } from 'zod'
+
+export interface FieldError {
+  message: string
+  path: string
+}
+
+export const mapZodIssuesToFieldErrors = (zodError: ZodError): FieldError[] =>
+  zodError.issues.map(issue => ({
+    message: issue.message,
+    path: issue.path.join('.'),
+  }))
+
+export const formatFieldErrorsSummary = (fieldErrors: readonly FieldError[]): string =>
+  fieldErrors.map(e => `  - ${e.path !== '' ? e.path : '(root)'}: ${e.message}`).join('\n')
index 33d06c7841c9f30be74335238a489ebe34517d14..32ce758252248e0f9aa51ab7bf91a6d2eacdc78c 100644 (file)
@@ -27,7 +27,7 @@ const DEFAULT_FILE_MODE = 0o666
 
 let tmpInvocationCounter = 0
 
-export interface AtomicWriteOptions {
+interface AtomicWriteOptions {
   /**
    * Character encoding when `data` is a string. Defaults to `'utf8'`.
    */
index 1862006396a9b8d0c6c98a0934f0e46d35f72d96..96c38f5d34285f4899b21fceda5a631e90376eb9 100644 (file)
@@ -7,7 +7,7 @@ export {
   buildEvseEntries,
   buildEvsesStatus,
 } from './ChargingStationConfigurationUtils.js'
-export { Configuration, DEFAULT_PERSIST_STATE } from './Configuration.js'
+export { Configuration } from './Configuration.js'
 export {
   CURRENT_CONFIGURATION_SCHEMA_VERSION,
   DEPRECATED_KEY_REMAPPINGS,
@@ -19,8 +19,6 @@ export {
   StationTemplateUrlSchema,
   StorageConfigurationSchema,
   UI_SERVER_ACCESS_POLICY_DEFAULTS,
-  UIServerAccessPolicySchema,
-  UIServerAuthenticationSchema,
   UIServerConfigurationSchema,
   UIServerMetricsConfigurationSchema,
   WorkerConfigurationSchema,
@@ -43,11 +41,11 @@ export {
   handleUnhandledRejection,
 } from './ErrorUtils.js'
 export {
-  atomicWriteFile,
-  atomicWriteFileSync,
-  type AtomicWriteOptions,
-  watchJsonFile,
-} from './FileUtils.js'
+  type FieldError,
+  formatFieldErrorsSummary,
+  mapZodIssuesToFieldErrors,
+} from './FieldError.js'
+export { atomicWriteFile, atomicWriteFileSync, watchJsonFile } from './FileUtils.js'
 export {
   isHostLiteralWithoutPort,
   isLoopback,
index 7fd618b46d70d3080fb51c3c8302dfa550dd046d..6ee6ca6cc46e52fcd97898fb708f232335da5e0a 100644 (file)
@@ -221,7 +221,7 @@ export class WorkerSet<D extends WorkerData, R extends WorkerData> extends Worke
         this.addWorkerSetElement()
       }
       worker.unref()
-      // eslint-disable-next-line promise/no-promise-in-callback
+      // eslint-disable-next-line promise/no-promise-in-callback -- fire-and-forget worker termination inside an 'error' listener; failures are surfaced via safeEmit
       worker.terminate().catch((error: unknown) => {
         this.safeEmit(WorkerSetEvents.error, error)
       })
index abb128b6d57743f38b0782f9e841e7e161e8d354..81fada42d6ed2d28d1b744eb2e66aee1c7876fa0 100644 (file)
@@ -13,6 +13,7 @@ import {
 } from '../../src/charging-station/index.js'
 import { logger } from '../../src/utils/index.js'
 import { mockLoggerWarnDebug, standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+import { TEST_SUPERVISION_URL } from '../utils/TestNetworkConstants.js'
 import { buildLegacyTemplate } from './helpers/TemplateFixtures.js'
 
 await describe('TemplateMigrations', async () => {
@@ -93,13 +94,13 @@ await describe('TemplateMigrations', async () => {
         authorizationFile: 'tags.json',
         mustAuthorizeAtRemoteStart: true,
         payloadSchemaValidation: false,
-        supervisionUrl: 'ws://localhost:8080',
+        supervisionUrl: TEST_SUPERVISION_URL,
       })
 
       const result = applyMigration(0, template)
 
       assert.strictEqual(result.$schemaVersion, CURRENT_SCHEMA_VERSION)
-      assert.strictEqual(result.supervisionUrls, 'ws://localhost:8080')
+      assert.strictEqual(result.supervisionUrls, TEST_SUPERVISION_URL)
       assert.strictEqual(result.idTagsFile, 'tags.json')
       assert.strictEqual(result.remoteAuthorization, true)
       assert.strictEqual(result.ocppStrictCompliance, false)
@@ -110,7 +111,7 @@ await describe('TemplateMigrations', async () => {
     })
 
     for (const [deprecated, replacement, value] of [
-      ['supervisionUrl', 'supervisionUrls', 'ws://localhost:8080'],
+      ['supervisionUrl', 'supervisionUrls', TEST_SUPERVISION_URL],
       ['authorizationFile', 'idTagsFile', 'tags.json'],
       ['payloadSchemaValidation', 'ocppStrictCompliance', false],
       ['mustAuthorizeAtRemoteStart', 'remoteAuthorization', true],
index ccd59d3e18bf035afd7638669a91df35b80edbbb..855e4aa2986daceba5ec6021f5408d6b953e84a5 100644 (file)
@@ -9,6 +9,7 @@ import { afterEach, describe, it } from 'node:test'
 import { CURRENT_SCHEMA_VERSION } from '../../src/charging-station/index.js'
 import { TemplateSchema } from '../../src/charging-station/index.js'
 import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+import { TEST_SUPERVISION_URL } from '../utils/TestNetworkConstants.js'
 import { TEST_CHARGING_STATION_BASE_NAME } from './ChargingStationTestConstants.js'
 import { buildMinimalTemplate } from './helpers/TemplateFixtures.js'
 
@@ -73,7 +74,7 @@ await describe('TemplateSchema', async () => {
 
   await describe('deprecated keys rejection', async () => {
     for (const [legacyKey, legacyValue] of [
-      ['supervisionUrl', 'ws://localhost:8080'],
+      ['supervisionUrl', TEST_SUPERVISION_URL],
       ['authorizationFile', 'tags.json'],
       ['mustAuthorizeAtRemoteStart', true],
       ['payloadSchemaValidation', false],
index 9ca19d965157f4ae582392ac09363dd2c7b60612..1c5747dfc77bea236420c6ecbd8040da1102c3cc 100644 (file)
@@ -12,6 +12,7 @@ import { TemplateValidationError, validateTemplate } from '../../src/charging-st
 import { BaseError } from '../../src/exception/index.js'
 import { logger } from '../../src/utils/index.js'
 import { mockLoggerWarnDebug, standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+import { TEST_SUPERVISION_URL } from '../utils/TestNetworkConstants.js'
 import { TEST_CHARGING_STATION_BASE_NAME } from './ChargingStationTestConstants.js'
 import { buildLegacyTemplate, buildMinimalTemplate } from './helpers/TemplateFixtures.js'
 
@@ -51,7 +52,7 @@ await describe('TemplateValidation', async () => {
       mockLoggerWarnDebug(t, logger)
       const parsed = buildLegacyTemplate({
         Connectors: { 0: {}, 1: {} },
-        supervisionUrl: 'ws://localhost:8080',
+        supervisionUrl: TEST_SUPERVISION_URL,
       })
       const before = structuredClone(parsed)
 
@@ -96,12 +97,12 @@ await describe('TemplateValidation', async () => {
       const parsed = buildLegacyTemplate({
         $schemaVersion: 0,
         Connectors: { 0: {}, 1: {} },
-        supervisionUrl: 'ws://localhost:8080',
+        supervisionUrl: TEST_SUPERVISION_URL,
       })
 
       const result = validateTemplate(parsed, 'test.json')
 
-      assert.strictEqual(result.supervisionUrls, 'ws://localhost:8080')
+      assert.strictEqual(result.supervisionUrls, TEST_SUPERVISION_URL)
     })
 
     await it('should auto-migrate template missing $schemaVersion (legacy v0 default)', t => {
@@ -111,12 +112,12 @@ await describe('TemplateValidation', async () => {
         Connectors: { 0: {}, 1: {} },
         mustAuthorizeAtRemoteStart: true,
         payloadSchemaValidation: false,
-        supervisionUrl: 'ws://localhost:8080',
+        supervisionUrl: TEST_SUPERVISION_URL,
       })
 
       const result = validateTemplate(parsed, 'legacy.json')
 
-      assert.strictEqual(result.supervisionUrls, 'ws://localhost:8080')
+      assert.strictEqual(result.supervisionUrls, TEST_SUPERVISION_URL)
       assert.strictEqual(result.idTagsFile, 'tags.json')
       assert.strictEqual(result.remoteAuthorization, true)
       assert.strictEqual(result.ocppStrictCompliance, false)
@@ -143,7 +144,7 @@ await describe('TemplateValidation', async () => {
     }
 
     for (const [legacyKey, legacyValue] of [
-      ['supervisionUrl', 'ws://localhost:8080'],
+      ['supervisionUrl', TEST_SUPERVISION_URL],
       ['mustAuthorizeAtRemoteStart', true],
       ['payloadSchemaValidation', false],
     ] as const) {
index d713c48a19bb4d0926db82bbbe72b5cc4b68b60f..7c6ed1bbe380e89389c4bf683ab57a2f7250ab95 100644 (file)
@@ -272,7 +272,7 @@ await describe('B07 - Get Base Report', async () => {
 
   // ReportingValueSize truncation test
   await it('should truncate long SequenceList/MemberList values per ReportingValueSize', () => {
-    // Ensure ReportingValueSize is at a small value (default is OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH). We will override configuration key if absent.
+    // Ensure ReportingValueSize is at a small value (default is OCPP20Constants.MAX_REPORTING_VALUE_SIZE). We will override configuration key if absent.
     const reportingSizeKey = buildConfigKey(
       OCPP20ComponentName.DeviceDataCtrlr,
       StandardParametersKey.ReportingValueSize
index cd9aac1c2ad34af9eb7608101dbbdb309a559cfe..3b411d4c6879930695d19a6e0c99df09f30993e4 100644 (file)
@@ -265,7 +265,7 @@ export function resetReportingValueSize (chargingStation: ChargingStation) {
       OCPP20ComponentName.DeviceDataCtrlr,
       OCPP20OptionalVariableName.ReportingValueSize
     ),
-    OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH.toString()
+    OCPP20Constants.MAX_REPORTING_VALUE_SIZE.toString()
   )
 }
 
@@ -281,12 +281,12 @@ export function resetValueSizeLimits (chargingStation: ChargingStation) {
       OCPP20ComponentName.DeviceDataCtrlr,
       OCPP20OptionalVariableName.ConfigurationValueSize
     ),
-    OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH.toString()
+    OCPP20Constants.MAX_CONFIGURATION_VALUE_SIZE.toString()
   )
   upsertConfigurationKey(
     chargingStation,
     buildConfigKey(OCPP20ComponentName.DeviceDataCtrlr, OCPP20OptionalVariableName.ValueSize),
-    OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH.toString()
+    OCPP20Constants.MAX_REPORTING_VALUE_SIZE.toString()
   )
 }
 
index 59ee7b52f1787b63ad009b793ed94d58f3de39ed..3ae07f5aff859adec5e92565b8cb36f829fb3032 100644 (file)
@@ -1868,7 +1868,7 @@ await describe('B05 - OCPP20VariableManager', async () => {
         buildConfigKey(OCPP20ComponentName.ChargingStation, OCPP20VendorVariableName.ConnectionUrl),
         overLongValue
       )
-      // Set generous ValueSize (1500) and ReportingValueSize (1400) so only absolute cap applies (since both < OCPP20Constants.MAX_VARIABLE_VALUE_LENGTH)
+      // Set generous ValueSize (1500) and ReportingValueSize (1400) so only absolute cap applies (since both < OCPP20Constants.MAX_REPORTING_VALUE_SIZE)
       setValueSize(station, 1500)
       setReportingValueSize(station, 1400)
       const getRes = manager.getVariables(station, [
similarity index 99%
rename from tests/charging-station/ocpp/auth/utils/ConfigValidator.test.ts
rename to tests/charging-station/ocpp/auth/utils/AuthConfigValidator.test.ts
index a1ebec8b20db4c56dcf29587f2bc01066449a1a5..fe2e625a53b8d5d9d8ca0f66d087b1387268ed6b 100644 (file)
@@ -11,7 +11,7 @@ import {
   AuthenticationError,
   AuthorizationStatus,
 } from '../../../../../src/charging-station/ocpp/auth/types/AuthTypes.js'
-import { AuthConfigValidator } from '../../../../../src/charging-station/ocpp/auth/utils/ConfigValidator.js'
+import { AuthConfigValidator } from '../../../../../src/charging-station/ocpp/auth/utils/AuthConfigValidator.js'
 import { standardCleanup } from '../../../../helpers/TestLifecycleHelpers.js'
 
 await describe('AuthConfigValidator', async () => {
index 76af0572d4c946849f9f8d77f9175a7c33988482..abf10ac29787586da7b9371fcee4677f75df138f 100644 (file)
@@ -12,6 +12,7 @@ import { StorageType } from '../../../src/types/index.js'
 import { PerformanceRecord } from '../../../src/types/orm/entities/PerformanceRecord.js'
 import { Constants, logger } from '../../../src/utils/index.js'
 import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js'
+import { TEST_SUPERVISION_URL } from '../../utils/TestNetworkConstants.js'
 import { buildTestStatistics } from './StorageTestHelpers.js'
 
 const TEST_LOG_PREFIX = '[MikroOrmStorage Test]'
@@ -331,7 +332,7 @@ await describe('MikroOrmStorage', async () => {
         const record = await verifyOrm.em.fork().findOne(PerformanceRecord, { id: 'station-1' })
         assert.notStrictEqual(record, undefined)
         assert.strictEqual(record?.name, 'cs-station-1')
-        assert.strictEqual(record.uri, 'ws://localhost:8080')
+        assert.strictEqual(record.uri, TEST_SUPERVISION_URL)
       } finally {
         await verifyOrm.close()
       }
index 38bf434b0121e85b6c74287ae2e0bd6039d60ff2..fc9047a2dc81a4fa099a8d58eac42f0f0b8a8a5c 100644 (file)
@@ -22,6 +22,7 @@ import {
   buildFullConfiguration,
   buildMinimalConfiguration,
 } from './helpers/ConfigurationFixtures.js'
+import { TEST_SUPERVISION_URL, TEST_SUPERVISION_URL_ALT_PORT } from './TestNetworkConstants.js'
 
 await describe('ConfigurationSchema', async () => {
   afterEach(() => {
@@ -104,7 +105,7 @@ await describe('ConfigurationSchema', async () => {
       ['logRotate', true],
       ['logStatisticsInterval', 60],
       ['stationTemplateURLs', [{ file: 'a.json', numberOfStations: 1 }]],
-      ['supervisionURLs', 'ws://localhost:8080'],
+      ['supervisionURLs', TEST_SUPERVISION_URL],
       ['uiWebSocketServer', {}],
       ['useWorkerPool', false],
       ['workerPoolMaxSize', 16],
@@ -805,7 +806,7 @@ await describe('ConfigurationSchema', async () => {
   await describe('mixed-type fields', async () => {
     await it('should accept supervisionUrls as string', () => {
       const result = ConfigurationSchema.safeParse(
-        buildMinimalConfiguration({ supervisionUrls: 'ws://localhost:8080' })
+        buildMinimalConfiguration({ supervisionUrls: TEST_SUPERVISION_URL })
       )
       assert.ok(result.success)
     })
@@ -813,7 +814,7 @@ await describe('ConfigurationSchema', async () => {
     await it('should accept supervisionUrls as string array', () => {
       const result = ConfigurationSchema.safeParse(
         buildMinimalConfiguration({
-          supervisionUrls: ['ws://localhost:8080', 'ws://localhost:8081'],
+          supervisionUrls: [TEST_SUPERVISION_URL, TEST_SUPERVISION_URL_ALT_PORT],
         })
       )
       assert.ok(result.success)
index 7f203756bfdbae5033420aa97800aa0049c0bdd5..21920219e5e869b364eaca0d8006c5401b27d76e 100644 (file)
@@ -22,6 +22,7 @@ import {
   buildMinimalConfiguration,
   buildV1WithDeprecatedKey,
 } from './helpers/ConfigurationFixtures.js'
+import { TEST_SUPERVISION_URL } from './TestNetworkConstants.js'
 
 /** Expected error message for a v1 config missing `stationTemplateUrls`. */
 const EXPECTED_SNAPSHOT =
@@ -50,7 +51,7 @@ const SAMPLE_DEPRECATED_VALUES: Readonly<Record<string, unknown>> = {
   logRotate: true,
   logStatisticsInterval: 60,
   stationTemplateURLs: [{ file: 'a.json', numberOfStations: 1 }],
-  supervisionURLs: 'ws://localhost:8080',
+  supervisionURLs: TEST_SUPERVISION_URL,
   uiWebSocketServer: {},
   useWorkerPool: false,
   'worker.elementStartDelay': 100,
index b41b5ccf6984ee1fbfa2068e87ec09130e137c34..c4e7053340c1dd968490c4b2bcbe1b5cd852b9f5 100644 (file)
@@ -1,10 +1,16 @@
 /**
  * @file Shared network-related test constants derived from production defaults.
- * @description Single source of truth for the `ws://host:port` test URL,
+ * @description Single source of truth for the `ws://host:port` test URLs,
  *   built from `Constants.DEFAULT_UI_SERVER_HOST` /
  *   `Constants.DEFAULT_UI_SERVER_PORT` so a change to the production defaults
- *   propagates automatically to the tests.
+ *   propagates automatically to the tests. Two variants are exposed: the
+ *   primary URL at the default port, and an alternate at the next port for
+ *   tests that need two distinct URL fixtures (e.g. `supervisionUrls` arrays).
  */
 import { Constants } from '../../src/utils/index.js'
 
-export const TEST_SUPERVISION_URL = `ws://${Constants.DEFAULT_UI_SERVER_HOST}:${Constants.DEFAULT_UI_SERVER_PORT.toString()}`
+const HOST = Constants.DEFAULT_UI_SERVER_HOST
+const PORT = Constants.DEFAULT_UI_SERVER_PORT
+
+export const TEST_SUPERVISION_URL = `ws://${HOST}:${PORT.toString()}`
+export const TEST_SUPERVISION_URL_ALT_PORT = `ws://${HOST}:${(PORT + 1).toString()}`
index 78b62e46de63e1c7374a23ca50e36129084a25b8..d00e091ac59bf76a149bbf49e99ee92edb289caf 100644 (file)
@@ -2,6 +2,7 @@ import type { Command } from 'commander'
 
 import process from 'node:process'
 import {
+  extractErrorMessage,
   type OCPPVersion,
   ProcedureName,
   type RequestPayload,
@@ -58,8 +59,7 @@ const fetchStationList = async (
       silent: true,
     })
   } catch (error: unknown) {
-    const msg = error instanceof Error ? error.message : String(error)
-    throw new Error(`Failed to fetch charging station list: ${msg}`)
+    throw new Error(`Failed to fetch charging station list: ${extractErrorMessage(error)}`)
   }
 
   if (response.status !== ResponseStatus.SUCCESS || !Array.isArray(response.chargingStations)) {
index 47f2d206d5b34b368181152c5682aa3380a18aa3..14729bce00e4e965993a61c9cfc028efeed1dcfa 100644 (file)
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
 import { homedir } from 'node:os'
 import { resolve } from 'node:path'
 import process from 'node:process'
+import { extractErrorMessage } from 'ui-common'
 
 declare const __EMBEDDED_SKILL__: string
 
@@ -43,8 +44,7 @@ export const createSkillCommands = (): Command => {
         mkdirSync(dir, { recursive: true })
         writeFileSync(filepath, __EMBEDDED_SKILL__, 'utf8')
       } catch (error: unknown) {
-        const msg = error instanceof Error ? error.message : String(error)
-        process.stderr.write(`Failed to install skill: ${msg}\n`)
+        process.stderr.write(`Failed to install skill: ${extractErrorMessage(error)}\n`)
         process.exitCode = 1
         return
       }
index fd67cef43ae4e447fe217fe310f36f3f73f75b4d..ec997102d3d33091bc40b7aeda95f7df0da98bc3 100644 (file)
@@ -1,14 +1,17 @@
-import { type SKIN_IDS } from 'ui-common'
+import { type SKIN_IDS, type THEME_IDS } from 'ui-common'
 
 // Local UI project constants
 
 export const ASYNC_COMPONENT_DELAY_MS = 200
 export const DEFAULT_SKIN: (typeof SKIN_IDS)[number] = 'modern'
+export const DEFAULT_THEME: (typeof THEME_IDS)[number] = 'tokyo-night-storm'
 export const ASYNC_COMPONENT_TIMEOUT_MS = 10_000
 export const EMPTY_VALUE_PLACEHOLDER = 'Ø'
 export const MAX_SKIN_ERROR_RELOADS = 2
 export const MAX_STATIONS_PER_ADD = 100
 export const SKIN_ERROR_RELOAD_COUNT_KEY = 'skin-error-reload-count'
+export const SKIN_STORAGE_KEY = 'ecs-ui-skin'
+export const THEME_STORAGE_KEY = 'ecs-ui-theme'
 export const WH_PER_KWH = 1000
 
 export const ROUTE_NAMES = {
index cca323776af3983a9e7421b18c0a9f535c640fd8..eb0ee40ead735d420344a9797abb046871dc5f71 100644 (file)
@@ -299,6 +299,7 @@ export class UIClient {
     let aborted = false
     const config = this.uiServerConfiguration
     const eventTarget = this.wsEventTarget
+    const uiServerAddress = `${config.host}:${config.port.toString()}`
 
     const factory: WebSocketFactory = (url, protocols) => {
       const adapter = createBrowserWsAdapter(
@@ -329,13 +330,8 @@ export class UIClient {
           adapter.onerror = event => {
             if (aborted) return
             handler?.(event)
-            useToast().error(
-              `Error in WebSocket to UI server '${config.host}:${config.port.toString()}'`
-            )
-            console.error(
-              `Error in WebSocket to UI server '${config.host}:${config.port.toString()}'`,
-              event
-            )
+            useToast().error(`Error in WebSocket to UI server '${uiServerAddress}'`)
+            console.error(`Error in WebSocket to UI server '${uiServerAddress}'`, event)
             eventTarget.dispatchEvent(new Event('error'))
           }
         },
@@ -352,9 +348,7 @@ export class UIClient {
           adapter.onopen = () => {
             if (aborted) return
             handler?.()
-            useToast().success(
-              `WebSocket to UI server '${config.host}:${config.port.toString()}' successfully opened`
-            )
+            useToast().success(`WebSocket to UI server '${uiServerAddress}' successfully opened`)
             eventTarget.dispatchEvent(new Event('open'))
           }
         },
index 1072c4945366d3f675c06d1866f590eacbac78c0..2e428eb5199ee3c02188513bc0e217ada8c4974a 100644 (file)
@@ -2,6 +2,7 @@ export {
   ASYNC_COMPONENT_DELAY_MS,
   ASYNC_COMPONENT_TIMEOUT_MS,
   DEFAULT_SKIN,
+  DEFAULT_THEME,
   EMPTY_VALUE_PLACEHOLDER,
   LEGACY_UI_SERVER_CONFIG_KEY,
   MAX_SKIN_ERROR_RELOADS,
@@ -9,6 +10,8 @@ export {
   ROUTE_NAMES,
   SHARED_TOGGLE_BUTTON_KEY_PREFIX,
   SKIN_ERROR_RELOAD_COUNT_KEY,
+  SKIN_STORAGE_KEY,
+  THEME_STORAGE_KEY,
   TOGGLE_BUTTON_KEY_PREFIX,
   UI_SERVER_CONFIGURATION_INDEX_KEY,
   WH_PER_KWH,
index b1f3f301ee77edce3a62f0410577062431629069..7b8f044faed25a978f56d385f296c4646a6c0868 100644 (file)
@@ -12,18 +12,21 @@ import {
   chargingStationsKey,
   configurationKey,
   DEFAULT_SKIN,
+  DEFAULT_THEME,
   getFromLocalStorage,
   isDev,
   LEGACY_UI_SERVER_CONFIG_KEY,
   setToLocalStorage,
+  SKIN_STORAGE_KEY,
   templatesKey,
+  THEME_STORAGE_KEY,
   UI_SERVER_CONFIGURATION_INDEX_KEY,
   UIClient,
   uiClientKey,
 } from '@/core/index.js'
 import { router } from '@/router/index.js'
-import { SKIN_STORAGE_KEY, useSkin } from '@/shared/composables/useSkin.js'
-import { DEFAULT_THEME, THEME_STORAGE_KEY, useTheme } from '@/shared/composables/useTheme.js'
+import { useSkin } from '@/shared/composables/useSkin.js'
+import { useTheme } from '@/shared/composables/useTheme.js'
 
 import 'vue-toast-notification/dist/theme-bootstrap.css'
 
@@ -56,7 +59,7 @@ const initializeApp = async (app: AppType, config: ConfigurationData): Promise<v
   if (getFromLocalStorage<string>(SKIN_STORAGE_KEY, '') === '' && config.skin != null) {
     setToLocalStorage<string>(SKIN_STORAGE_KEY, config.skin)
   }
-  const initialSkin = getFromLocalStorage<string>(SKIN_STORAGE_KEY, config.skin ?? 'classic')
+  const initialSkin = getFromLocalStorage<string>(SKIN_STORAGE_KEY, config.skin ?? DEFAULT_SKIN)
   const switched = await switchSkin(initialSkin)
   if (!switched && initialSkin !== DEFAULT_SKIN) {
     console.warn(`[useSkin] Failed to load skin '${initialSkin}', falling back to default`)
index b4cbccba480d95b71b2658bf2c63b54d2e0d89a2..0397061c4207fc7961dc16ef9e89278ee6618dbb 100644 (file)
@@ -16,8 +16,8 @@ import {
   MAX_SKIN_ERROR_RELOADS,
   setToLocalStorage,
   SKIN_ERROR_RELOAD_COUNT_KEY,
+  SKIN_STORAGE_KEY,
 } from '@/core/index.js'
-import { SKIN_STORAGE_KEY } from '@/shared/composables/useSkin.js'
 // Intentional: registry.ts is pure metadata (ids, labels, loaders) — no behavioral coupling.
 import { skins } from '@/skins/registry.js'
 
index 04ca14cc1a3b9b9f52ac223c38e807be53f98041..48cb3baff4b1fe488757c817fd886fef4ba5e1f9 100644 (file)
@@ -64,7 +64,7 @@ export function useAsyncAction<T extends Record<string, boolean>> (
     const { action, errorMsg, onSuccess, successMsg } = options
     if (pending[key]) return
     pending[key] = true as T[keyof T]
-    // eslint-disable-next-line no-void
+    // eslint-disable-next-line no-void -- fire-and-forget IIFE; the returned promise is intentionally discarded (rejections are handled inside the try/catch)
     void (async () => {
       try {
         await action()
index eb281ee3bc4e50c681e233bc490445bcabee070b..d0a0b64812cf454115624ff38f22c4c114e0d467 100644 (file)
@@ -1,4 +1,4 @@
-import { type SKIN_IDS } from 'ui-common'
+import { extractErrorMessage, type SKIN_IDS } from 'ui-common'
 import { readonly, ref, type Ref } from 'vue'
 
 import {
@@ -6,13 +6,12 @@ import {
   getFromLocalStorage,
   setToLocalStorage,
   SKIN_ERROR_RELOAD_COUNT_KEY,
+  SKIN_STORAGE_KEY,
 } from '@/core/index.js'
 import { validateTokenContract } from '@/shared/tokens/contract.js'
 // Intentional: registry.ts is pure metadata (ids, labels, loaders) — no behavioral coupling.
 import { type SkinDefinition, skins } from '@/skins/registry.js'
 
-export const SKIN_STORAGE_KEY = 'ecs-ui-skin'
-
 export type SkinName = (typeof SKIN_IDS)[number]
 
 /**
@@ -140,7 +139,7 @@ async function performSkinSwitch (skinId: string): Promise<boolean> {
     }
     return true
   } catch (error: unknown) {
-    const message = error instanceof Error ? error.message : String(error)
+    const message = extractErrorMessage(error)
     console.warn(`[useSkin] Failed to load CSS for skin '${skinId}':`, message)
     lastError.value = message
     return false
index 91de2fd6b43cfa7b4bae341f3b94e266f89d38ef..6b78b4935272b0ed2b4e689ea6d7fdcc4651834f 100644 (file)
@@ -1,12 +1,15 @@
 import { THEME_IDS } from 'ui-common'
 import { readonly, ref, type Ref } from 'vue'
 
-import { getFromLocalStorage, setToLocalStorage } from '@/core/index.js'
+import {
+  DEFAULT_THEME,
+  getFromLocalStorage,
+  setToLocalStorage,
+  THEME_STORAGE_KEY,
+} from '@/core/index.js'
 import { validateTokenContract } from '@/shared/tokens/contract.js'
 
 export const AVAILABLE_THEMES = THEME_IDS
-export const DEFAULT_THEME: ThemeName = 'tokyo-night-storm'
-export const THEME_STORAGE_KEY = 'ecs-ui-theme'
 
 export type ThemeName = (typeof THEME_IDS)[number]
 
@@ -37,7 +40,7 @@ function applyTheme (themeName: ThemeName): void {
   document.documentElement.classList.add('theme-switching')
   document.documentElement.setAttribute('data-theme', themeName)
   // Force reflow so the theme CSS is resolved before reading color-scheme.
-  // eslint-disable-next-line no-void
+  // eslint-disable-next-line no-void -- reading `offsetHeight` forces a layout reflow so the theme CSS is resolved before `color-scheme` is read; `void` discards the value
   void document.documentElement.offsetHeight
   document.documentElement.setAttribute('data-color-scheme', resolveColorScheme())
   document.documentElement.classList.remove('theme-switching')