From 22cb25f02648f1efcccdf05db595f33862b4b709 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Mon, 6 Jul 2026 16:14:42 +0200 Subject: [PATCH] refactor: audit-driven cleanup (OCPP 2.0 spec conformance + 5 low-risk refactors) (#1959) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * 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 ' - : ' 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. --- src/charging-station/Bootstrap.ts | 4 ++-- src/charging-station/ChargingStation.ts | 4 ++-- src/charging-station/TemplateMigrations.ts | 2 +- src/charging-station/TemplateValidation.ts | 22 +++++++++++-------- .../ChargingStationWorkerBroadcastChannel.ts | 8 +++---- .../ocpp/1.6/OCPP16IncomingRequestService.ts | 2 +- .../ocpp/2.0/OCPP20CertificateManager.ts | 4 ++-- .../ocpp/2.0/OCPP20Constants.ts | 8 +++++-- .../ocpp/2.0/OCPP20VariableManager.ts | 16 +++++++------- .../ocpp/2.0/OCPP20VariableRegistry.ts | 14 ++++++------ .../ocpp/OCPPRequestService.ts | 2 +- src/charging-station/ocpp/OCPPServiceUtils.ts | 2 +- .../auth/factories/AuthComponentFactory.ts | 2 +- .../ocpp/auth/services/OCPPAuthServiceImpl.ts | 2 +- .../strategies/CertificateAuthStrategy.ts | 7 +++--- ...figValidator.ts => AuthConfigValidator.ts} | 0 src/types/ConfigurationData.ts | 1 - src/types/index.ts | 1 - src/types/ocpp/ErrorType.ts | 2 +- src/utils/Configuration.ts | 2 +- src/utils/ConfigurationMigrations.ts | 7 ++---- src/utils/ConfigurationSchema.ts | 4 ++-- src/utils/ConfigurationValidation.ts | 12 ++++------ src/utils/FieldError.ts | 15 +++++++++++++ src/utils/FileUtils.ts | 2 +- src/utils/index.ts | 14 +++++------- src/worker/WorkerSet.ts | 2 +- .../TemplateMigrations.test.ts | 7 +++--- tests/charging-station/TemplateSchema.test.ts | 3 ++- .../TemplateValidation.test.ts | 13 ++++++----- ...comingRequestService-GetBaseReport.test.ts | 2 +- .../ocpp/2.0/OCPP20TestUtils.ts | 6 ++--- .../ocpp/2.0/OCPP20VariableManager.test.ts | 2 +- ...or.test.ts => AuthConfigValidator.test.ts} | 2 +- .../storage/MikroOrmStorage.test.ts | 3 ++- tests/utils/ConfigurationSchema.test.ts | 7 +++--- tests/utils/ConfigurationValidation.test.ts | 3 ++- tests/utils/TestNetworkConstants.ts | 12 +++++++--- ui/cli/src/commands/action.ts | 4 ++-- ui/cli/src/commands/skill.ts | 4 ++-- ui/web/src/core/Constants.ts | 5 ++++- ui/web/src/core/UIClient.ts | 14 ++++-------- ui/web/src/core/index.ts | 3 +++ ui/web/src/main.ts | 9 +++++--- .../src/shared/components/SkinLoadError.vue | 2 +- .../src/shared/composables/useAsyncAction.ts | 2 +- ui/web/src/shared/composables/useSkin.ts | 7 +++--- ui/web/src/shared/composables/useTheme.ts | 11 ++++++---- 48 files changed, 156 insertions(+), 126 deletions(-) rename src/charging-station/ocpp/auth/utils/{ConfigValidator.ts => AuthConfigValidator.ts} (100%) create mode 100644 src/utils/FieldError.ts rename tests/charging-station/ocpp/auth/utils/{ConfigValidator.test.ts => AuthConfigValidator.test.ts} (99%) diff --git a/src/charging-station/Bootstrap.ts b/src/charging-station/Bootstrap.ts index 40a00eb7..7a0355b3 100644 --- a/src/charging-station/Bootstrap.ts +++ b/src/charging-station/Bootstrap.ts @@ -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 ( diff --git a/src/charging-station/ChargingStation.ts b/src/charging-station/ChargingStation.ts index f32f9d0c..22f2136c 100644 --- a/src/charging-station/ChargingStation.ts +++ b/src/charging-station/ChargingStation.ts @@ -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, diff --git a/src/charging-station/TemplateMigrations.ts b/src/charging-station/TemplateMigrations.ts index 0654c04e..e5857f19 100644 --- a/src/charging-station/TemplateMigrations.ts +++ b/src/charging-station/TemplateMigrations.ts @@ -130,7 +130,7 @@ function migrateV0ToV1 (template: Record): Record ({ - 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()})` diff --git a/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts b/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts index bf86c3f3..b3f1f751 100644 --- a/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts +++ b/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts @@ -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 | 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 { 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)) { diff --git a/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts b/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts index a164db11..13ad03dd 100644 --- a/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts +++ b/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts @@ -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 diff --git a/src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts b/src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts index ac66e213..7e0a3f3d 100644 --- a/src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts +++ b/src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts @@ -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))) { diff --git a/src/charging-station/ocpp/2.0/OCPP20Constants.ts b/src/charging-station/ocpp/2.0/OCPP20Constants.ts index ce5af58e..cf051bf2 100644 --- a/src/charging-station/ocpp/2.0/OCPP20Constants.ts +++ b/src/charging-station/ocpp/2.0/OCPP20Constants.ts @@ -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({ diff --git a/src/charging-station/ocpp/2.0/OCPP20VariableManager.ts b/src/charging-station/ocpp/2.0/OCPP20VariableManager.ts index 5a751eb0..52fda3b9 100644 --- a/src/charging-station/ocpp/2.0/OCPP20VariableManager.ts +++ b/src/charging-station/ocpp/2.0/OCPP20VariableManager.ts @@ -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( diff --git a/src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts b/src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts index 7cc9ec95..f6a89be3 100644 --- a/src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts +++ b/src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts @@ -738,16 +738,16 @@ export const VARIABLE_REGISTRY: Record = { }, // 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 = { )]: { 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 = { [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, diff --git a/src/charging-station/ocpp/OCPPRequestService.ts b/src/charging-station/ocpp/OCPPRequestService.ts index 4d30f9a7..09e4e2e5 100644 --- a/src/charging-station/ocpp/OCPPRequestService.ts +++ b/src/charging-station/ocpp/OCPPRequestService.ts @@ -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((resolve, reject: (reason?: unknown) => void) => { diff --git a/src/charging-station/ocpp/OCPPServiceUtils.ts b/src/charging-station/ocpp/OCPPServiceUtils.ts index d93196d1..3f91df21 100644 --- a/src/charging-station/ocpp/OCPPServiceUtils.ts +++ b/src/charging-station/ocpp/OCPPServiceUtils.ts @@ -225,7 +225,7 @@ export const convertDateToISOString = (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 { diff --git a/src/charging-station/ocpp/auth/factories/AuthComponentFactory.ts b/src/charging-station/ocpp/auth/factories/AuthComponentFactory.ts index 3a1424c3..a0d0decf 100644 --- a/src/charging-station/ocpp/auth/factories/AuthComponentFactory.ts +++ b/src/charging-station/ocpp/auth/factories/AuthComponentFactory.ts @@ -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 diff --git a/src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.ts b/src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.ts index a9b3d488..7fd3d4fa 100644 --- a/src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.ts +++ b/src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.ts @@ -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' diff --git a/src/charging-station/ocpp/auth/strategies/CertificateAuthStrategy.ts b/src/charging-station/ocpp/auth/strategies/CertificateAuthStrategy.ts index 9dad5686..2cf6f3d2 100644 --- a/src/charging-station/ocpp/auth/strategies/CertificateAuthStrategy.ts +++ b/src/charging-station/ocpp/auth/strategies/CertificateAuthStrategy.ts @@ -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 diff --git a/src/charging-station/ocpp/auth/utils/ConfigValidator.ts b/src/charging-station/ocpp/auth/utils/AuthConfigValidator.ts similarity index 100% rename from src/charging-station/ocpp/auth/utils/ConfigValidator.ts rename to src/charging-station/ocpp/auth/utils/AuthConfigValidator.ts diff --git a/src/types/ConfigurationData.ts b/src/types/ConfigurationData.ts index 81318c4a..425f57c3 100644 --- a/src/types/ConfigurationData.ts +++ b/src/types/ConfigurationData.ts @@ -29,7 +29,6 @@ export enum SupervisionUrlDistribution { } export type ConfigurationData = z.infer -export type ElementsPerWorkerType = NonNullable export type LogConfiguration = z.infer export type StationTemplateUrl = z.infer export type StorageConfiguration = z.infer diff --git a/src/types/index.ts b/src/types/index.ts index 8685f3f6..5dd88ef5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -37,7 +37,6 @@ export { ApplicationProtocolVersion, type ConfigurationData, ConfigurationSection, - type ElementsPerWorkerType, type LogConfiguration, type StationTemplateUrl, type StorageConfiguration, diff --git a/src/types/ocpp/ErrorType.ts b/src/types/ocpp/ErrorType.ts index 9fbb5e73..54da5254 100644 --- a/src/types/ocpp/ErrorType.ts +++ b/src/types/ocpp/ErrorType.ts @@ -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', } diff --git a/src/utils/Configuration.ts b/src/utils/Configuration.ts index c242c9ec..13a1a185 100644 --- a/src/utils/Configuration.ts +++ b/src/utils/Configuration.ts @@ -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 { diff --git a/src/utils/ConfigurationMigrations.ts b/src/utils/ConfigurationMigrations.ts index b4ca14a2..357cf029 100644 --- a/src/utils/ConfigurationMigrations.ts +++ b/src/utils/ConfigurationMigrations.ts @@ -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> */ export const CURRENT_CONFIGURATION_SCHEMA_VERSION = 1 -export interface FieldError { - message: string - path: string -} - export type MigrationFn = ( config: Record, filePath: string diff --git a/src/utils/ConfigurationSchema.ts b/src/utils/ConfigurationSchema.ts index e26aec4f..9f79a488 100644 --- a/src/utils/ConfigurationSchema.ts +++ b/src/utils/ConfigurationSchema.ts @@ -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( diff --git a/src/utils/ConfigurationValidation.ts b/src/utils/ConfigurationValidation.ts index 9cd36e78..68ad1e10 100644 --- a/src/utils/ConfigurationValidation.ts +++ b/src/utils/ConfigurationValidation.ts @@ -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 index 00000000..a0bf72f6 --- /dev/null +++ b/src/utils/FieldError.ts @@ -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') diff --git a/src/utils/FileUtils.ts b/src/utils/FileUtils.ts index 33d06c78..32ce7582 100644 --- a/src/utils/FileUtils.ts +++ b/src/utils/FileUtils.ts @@ -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'`. */ diff --git a/src/utils/index.ts b/src/utils/index.ts index 18620063..96c38f5d 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -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, diff --git a/src/worker/WorkerSet.ts b/src/worker/WorkerSet.ts index 7fd618b4..6ee6ca6c 100644 --- a/src/worker/WorkerSet.ts +++ b/src/worker/WorkerSet.ts @@ -221,7 +221,7 @@ export class WorkerSet 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) }) diff --git a/tests/charging-station/TemplateMigrations.test.ts b/tests/charging-station/TemplateMigrations.test.ts index abb128b6..81fada42 100644 --- a/tests/charging-station/TemplateMigrations.test.ts +++ b/tests/charging-station/TemplateMigrations.test.ts @@ -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], diff --git a/tests/charging-station/TemplateSchema.test.ts b/tests/charging-station/TemplateSchema.test.ts index ccd59d3e..855e4aa2 100644 --- a/tests/charging-station/TemplateSchema.test.ts +++ b/tests/charging-station/TemplateSchema.test.ts @@ -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], diff --git a/tests/charging-station/TemplateValidation.test.ts b/tests/charging-station/TemplateValidation.test.ts index 9ca19d96..1c5747df 100644 --- a/tests/charging-station/TemplateValidation.test.ts +++ b/tests/charging-station/TemplateValidation.test.ts @@ -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) { diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetBaseReport.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetBaseReport.test.ts index d713c48a..7c6ed1bb 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetBaseReport.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetBaseReport.test.ts @@ -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 diff --git a/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts b/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts index cd9aac1c..3b411d4c 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts @@ -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() ) } diff --git a/tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts b/tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts index 59ee7b52..3ae07f5a 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts @@ -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, [ diff --git a/tests/charging-station/ocpp/auth/utils/ConfigValidator.test.ts b/tests/charging-station/ocpp/auth/utils/AuthConfigValidator.test.ts 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 a1ebec8b..fe2e625a 100644 --- a/tests/charging-station/ocpp/auth/utils/ConfigValidator.test.ts +++ b/tests/charging-station/ocpp/auth/utils/AuthConfigValidator.test.ts @@ -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 () => { diff --git a/tests/performance/storage/MikroOrmStorage.test.ts b/tests/performance/storage/MikroOrmStorage.test.ts index 76af0572..abf10ac2 100644 --- a/tests/performance/storage/MikroOrmStorage.test.ts +++ b/tests/performance/storage/MikroOrmStorage.test.ts @@ -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() } diff --git a/tests/utils/ConfigurationSchema.test.ts b/tests/utils/ConfigurationSchema.test.ts index 38bf434b..fc9047a2 100644 --- a/tests/utils/ConfigurationSchema.test.ts +++ b/tests/utils/ConfigurationSchema.test.ts @@ -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) diff --git a/tests/utils/ConfigurationValidation.test.ts b/tests/utils/ConfigurationValidation.test.ts index 7f203756..21920219 100644 --- a/tests/utils/ConfigurationValidation.test.ts +++ b/tests/utils/ConfigurationValidation.test.ts @@ -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> = { logRotate: true, logStatisticsInterval: 60, stationTemplateURLs: [{ file: 'a.json', numberOfStations: 1 }], - supervisionURLs: 'ws://localhost:8080', + supervisionURLs: TEST_SUPERVISION_URL, uiWebSocketServer: {}, useWorkerPool: false, 'worker.elementStartDelay': 100, diff --git a/tests/utils/TestNetworkConstants.ts b/tests/utils/TestNetworkConstants.ts index b41b5ccf..c4e70533 100644 --- a/tests/utils/TestNetworkConstants.ts +++ b/tests/utils/TestNetworkConstants.ts @@ -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()}` diff --git a/ui/cli/src/commands/action.ts b/ui/cli/src/commands/action.ts index 78b62e46..d00e091a 100644 --- a/ui/cli/src/commands/action.ts +++ b/ui/cli/src/commands/action.ts @@ -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)) { diff --git a/ui/cli/src/commands/skill.ts b/ui/cli/src/commands/skill.ts index 47f2d206..14729bce 100644 --- a/ui/cli/src/commands/skill.ts +++ b/ui/cli/src/commands/skill.ts @@ -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 } diff --git a/ui/web/src/core/Constants.ts b/ui/web/src/core/Constants.ts index fd67cef4..ec997102 100644 --- a/ui/web/src/core/Constants.ts +++ b/ui/web/src/core/Constants.ts @@ -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 = { diff --git a/ui/web/src/core/UIClient.ts b/ui/web/src/core/UIClient.ts index cca32377..eb0ee40e 100644 --- a/ui/web/src/core/UIClient.ts +++ b/ui/web/src/core/UIClient.ts @@ -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')) } }, diff --git a/ui/web/src/core/index.ts b/ui/web/src/core/index.ts index 1072c494..2e428eb5 100644 --- a/ui/web/src/core/index.ts +++ b/ui/web/src/core/index.ts @@ -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, diff --git a/ui/web/src/main.ts b/ui/web/src/main.ts index b1f3f301..7b8f044f 100644 --- a/ui/web/src/main.ts +++ b/ui/web/src/main.ts @@ -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(SKIN_STORAGE_KEY, '') === '' && config.skin != null) { setToLocalStorage(SKIN_STORAGE_KEY, config.skin) } - const initialSkin = getFromLocalStorage(SKIN_STORAGE_KEY, config.skin ?? 'classic') + const initialSkin = getFromLocalStorage(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`) diff --git a/ui/web/src/shared/components/SkinLoadError.vue b/ui/web/src/shared/components/SkinLoadError.vue index b4cbccba..0397061c 100644 --- a/ui/web/src/shared/components/SkinLoadError.vue +++ b/ui/web/src/shared/components/SkinLoadError.vue @@ -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' diff --git a/ui/web/src/shared/composables/useAsyncAction.ts b/ui/web/src/shared/composables/useAsyncAction.ts index 04ca14cc..48cb3baf 100644 --- a/ui/web/src/shared/composables/useAsyncAction.ts +++ b/ui/web/src/shared/composables/useAsyncAction.ts @@ -64,7 +64,7 @@ export function useAsyncAction> ( 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() diff --git a/ui/web/src/shared/composables/useSkin.ts b/ui/web/src/shared/composables/useSkin.ts index eb281ee3..d0a0b648 100644 --- a/ui/web/src/shared/composables/useSkin.ts +++ b/ui/web/src/shared/composables/useSkin.ts @@ -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 { } 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 diff --git a/ui/web/src/shared/composables/useTheme.ts b/ui/web/src/shared/composables/useTheme.ts index 91de2fd6..6b78b493 100644 --- a/ui/web/src/shared/composables/useTheme.ts +++ b/ui/web/src/shared/composables/useTheme.ts @@ -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') -- 2.53.0