From: Jérôme Benoit Date: Sun, 14 Jun 2026 01:29:02 +0000 (+0200) Subject: fix(ui-web): skip dev-only diagnostics under Vitest (#1897) X-Git-Tag: cli@v4.9.0~12 X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=18c3847df54a099e645e24561a5b55c295dba4ca;p=e-mobility-charging-stations-simulator.git fix(ui-web): skip dev-only diagnostics under Vitest (#1897) Extract isDev() helper in core/env.ts that returns true only in real DEV (excludes production build and Vitest). Migrate the 7 existing import.meta.env.DEV check sites to the helper. Primary motivation: validateTokenContract schedules a requestAnimationFrame whose body calls console.warn for missing CSS tokens. jsdom does not resolve --color-* tokens, so every theme/skin switch under Vitest 4 queued a console.warn that fired after environment teardown — surfacing as EnvironmentTeardownError on Linux/Node 22; passes on macOS/Node 24. Side effect: silences console.debug noise from storage.ts, providers.ts, ToggleButton.vue under Vitest. dev/build behaviour unchanged. Adds regression test in tests/unit/shared/tokens/contract.test.ts. --- diff --git a/ui/web/src/core/env.ts b/ui/web/src/core/env.ts new file mode 100644 index 00000000..d09e01fe --- /dev/null +++ b/ui/web/src/core/env.ts @@ -0,0 +1,8 @@ +/** + * Whether the application runs in a real DEV environment (`pnpm dev` / + * Vite serve), excluding production builds and Vitest runs. Use to gate + * dev-only diagnostics that would otherwise emit noise in tests or race + * environment teardown via `requestAnimationFrame` / async callbacks. + * @returns Whether the runtime is DEV and not a Vitest test run + */ +export const isDev = (): boolean => import.meta.env.DEV && import.meta.env.MODE !== 'test' diff --git a/ui/web/src/core/index.ts b/ui/web/src/core/index.ts index 6fcd1353..f1f68c65 100644 --- a/ui/web/src/core/index.ts +++ b/ui/web/src/core/index.ts @@ -12,6 +12,7 @@ export { UI_SERVER_CONFIGURATION_INDEX_KEY, WH_PER_KWH, } from './Constants.js' +export { isDev } from './env.js' export { chargingStationsKey, configurationKey, diff --git a/ui/web/src/core/providers.ts b/ui/web/src/core/providers.ts index b28c53ba..15ae2fe5 100644 --- a/ui/web/src/core/providers.ts +++ b/ui/web/src/core/providers.ts @@ -3,6 +3,7 @@ import type { InjectionKey, Ref } from 'vue' import { inject } from 'vue' +import { isDev } from './env.js' import { UIClient } from './UIClient.js' export const configurationKey: InjectionKey> = Symbol('configuration') @@ -14,7 +15,7 @@ export const uiClientKey: InjectionKey = Symbol('uiClient') export const useUIClient = (): UIClient => { const injected = inject(uiClientKey, undefined) if (injected != null) return injected - if (import.meta.env.DEV) { + if (isDev()) { console.debug('[useUIClient] Accessed outside provide scope — using singleton fallback') } return UIClient.getInstance() diff --git a/ui/web/src/core/storage.ts b/ui/web/src/core/storage.ts index bd4893a0..15feb624 100644 --- a/ui/web/src/core/storage.ts +++ b/ui/web/src/core/storage.ts @@ -1,11 +1,12 @@ import { SHARED_TOGGLE_BUTTON_KEY_PREFIX, TOGGLE_BUTTON_KEY_PREFIX } from './Constants.js' +import { isDev } from './env.js' export const getFromLocalStorage = (key: string, defaultValue: T): T => { try { const item = localStorage.getItem(key) return item != null ? (JSON.parse(item) as T) : defaultValue } catch { - if (import.meta.env.DEV) { + if (isDev()) { console.debug(`[localStorage] Failed to read key '${key}', using default`) } return defaultValue @@ -21,7 +22,7 @@ export const setToLocalStorage = (key: string, value: T): void => { // - QuotaExceededError when the origin's storage quota is genuinely exceeded // - SecurityError when storage is blocked by user settings or browser policies // (e.g., "Block All Cookies" in Safari, third-party iframe in Chrome, file: URLs) - if (import.meta.env.DEV) { + if (isDev()) { console.debug(`[localStorage] Failed to write key '${key}'`) } } @@ -31,7 +32,7 @@ export const deleteFromLocalStorage = (key: string): void => { try { localStorage.removeItem(key) } catch { - if (import.meta.env.DEV) { + if (isDev()) { console.debug(`[localStorage] Failed to delete key '${key}'`) } } @@ -56,7 +57,7 @@ export const deleteLocalStorageByKeyPattern = (pattern: string): void => { deleteFromLocalStorage(key) } } catch { - if (import.meta.env.DEV) { + if (isDev()) { console.debug(`[localStorage] Failed to delete keys matching '${pattern}'`) } } diff --git a/ui/web/src/main.ts b/ui/web/src/main.ts index 21dbacb9..b1f3f301 100644 --- a/ui/web/src/main.ts +++ b/ui/web/src/main.ts @@ -13,6 +13,7 @@ import { configurationKey, DEFAULT_SKIN, getFromLocalStorage, + isDev, LEGACY_UI_SERVER_CONFIG_KEY, setToLocalStorage, templatesKey, @@ -40,7 +41,7 @@ import './assets/themes/tokyo-night-storm.css' const initializeApp = async (app: AppType, config: ConfigurationData): Promise => { app.config.errorHandler = (error, instance, info) => { console.error('Error:', error) - if (import.meta.env.DEV) { + if (isDev()) { console.info('Vue instance:', instance) console.info('Error info:', info) } diff --git a/ui/web/src/shared/tokens/contract.ts b/ui/web/src/shared/tokens/contract.ts index b0f5f426..90a2e1e2 100644 --- a/ui/web/src/shared/tokens/contract.ts +++ b/ui/web/src/shared/tokens/contract.ts @@ -7,6 +7,8 @@ * * Every theme file MUST define a value for each token (as `--{token-name}`). */ +import { isDev } from '@/core/index.js' + export const TOKEN_CONTRACT = [ 'color-accent', 'color-bg', @@ -53,7 +55,9 @@ export type TokenName = (typeof TOKEN_CONTRACT)[number] * @param contextId - The skin/theme id that was just applied */ export function validateTokenContract (source: string, contextId: string): void { - if (!import.meta.env.DEV || typeof document === 'undefined') return + if (!isDev() || typeof document === 'undefined') { + return + } requestAnimationFrame(() => { const style = getComputedStyle(document.documentElement) for (const token of TOKEN_CONTRACT) { diff --git a/ui/web/src/skins/classic/components/buttons/ToggleButton.vue b/ui/web/src/skins/classic/components/buttons/ToggleButton.vue index de57002e..5e79c3e2 100644 --- a/ui/web/src/skins/classic/components/buttons/ToggleButton.vue +++ b/ui/web/src/skins/classic/components/buttons/ToggleButton.vue @@ -13,6 +13,7 @@ import { ref } from 'vue' import { getFromLocalStorage, getLocalStorage, + isDev, setToLocalStorage, SHARED_TOGGLE_BUTTON_KEY_PREFIX, TOGGLE_BUTTON_KEY_PREFIX, @@ -48,7 +49,7 @@ const click = (): void => { setToLocalStorage(key, false) } } catch { - if (import.meta.env.DEV) { + if (isDev()) { console.debug('[ToggleButton] Failed to clear shared toggle buttons') } } diff --git a/ui/web/tests/unit/shared/tokens/contract.test.ts b/ui/web/tests/unit/shared/tokens/contract.test.ts index 628a2264..c9512743 100644 --- a/ui/web/tests/unit/shared/tokens/contract.test.ts +++ b/ui/web/tests/unit/shared/tokens/contract.test.ts @@ -1,12 +1,13 @@ /** - * @file Tests for TOKEN_CONTRACT theme compliance - * @description Ensures every theme CSS file defines all CSS custom properties declared in TOKEN_CONTRACT. + * @file Tests for TOKEN_CONTRACT theme compliance and validateTokenContract guard + * @description Ensures every theme CSS file defines all CSS custom properties declared in + * TOKEN_CONTRACT, and that validateTokenContract is a no-op under Vitest. */ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' -import { TOKEN_CONTRACT } from '@/shared/tokens/contract.js' +import { TOKEN_CONTRACT, validateTokenContract } from '@/shared/tokens/contract.js' const themesDir = resolve(__dirname, '../../../../src/assets/themes') const themeFiles = ['tokyo-night-storm.css', 'catppuccin-latte.css', 'sap-horizon.css'] @@ -22,3 +23,11 @@ describe('TOKEN_CONTRACT', () => { } }) }) + +describe('validateTokenContract', () => { + it('should be a no-op under Vitest', () => { + const rafSpy = vi.spyOn(globalThis, 'requestAnimationFrame') + validateTokenContract('useTheme', 'tokyo-night-storm') + expect(rafSpy).not.toHaveBeenCalled() + }) +})