]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(ui-web): skip dev-only diagnostics under Vitest (#1897)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Sun, 14 Jun 2026 01:29:02 +0000 (03:29 +0200)
committerGitHub <noreply@github.com>
Sun, 14 Jun 2026 01:29:02 +0000 (03:29 +0200)
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.

ui/web/src/core/env.ts [new file with mode: 0644]
ui/web/src/core/index.ts
ui/web/src/core/providers.ts
ui/web/src/core/storage.ts
ui/web/src/main.ts
ui/web/src/shared/tokens/contract.ts
ui/web/src/skins/classic/components/buttons/ToggleButton.vue
ui/web/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 (file)
index 0000000..d09e01f
--- /dev/null
@@ -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'
index 6fcd13535d83e35f884391d94f6b7dfac9ea5aa5..f1f68c65064841b248b048a78d87592e0e3d0d9c 100644 (file)
@@ -12,6 +12,7 @@ export {
   UI_SERVER_CONFIGURATION_INDEX_KEY,
   WH_PER_KWH,
 } from './Constants.js'
+export { isDev } from './env.js'
 export {
   chargingStationsKey,
   configurationKey,
index b28c53baa2ed904e7bc5903f087ca7644dda8e30..15ae2fe54d783615323b7e9156443acb179a1f5e 100644 (file)
@@ -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<Ref<ConfigurationData>> = Symbol('configuration')
@@ -14,7 +15,7 @@ export const uiClientKey: InjectionKey<UIClient> = 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()
index bd4893a0cf6f7a3e464aac62a23dc1576d84a18e..15feb624af5ddb8e1ac6f20aa02d4668ceb73bc8 100644 (file)
@@ -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 = <T>(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 = <T>(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}'`)
     }
   }
index 21dbacb99c375af9a7efba7c5e1928ca0d880f6d..b1f3f301ee77edce3a62f0410577062431629069 100644 (file)
@@ -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<void> => {
   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)
     }
index b0f5f426808943dd2317aa2d8f3a61827a7b8b36..90a2e1e24e5421c206362957a3bb2cc420347804 100644 (file)
@@ -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) {
index de57002e33e07b50913241b457254837df478a61..5e79c3e22891c8cf217d0c4cce6b089b4a32989a 100644 (file)
@@ -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<boolean>(key, false)
       }
     } catch {
-      if (import.meta.env.DEV) {
+      if (isDev()) {
         console.debug('[ToggleButton] Failed to clear shared toggle buttons')
       }
     }
index 628a2264a7dec981301a6f7fb3e17c1c619f5ff5..c9512743eedd82476ff5ea47ec2c4152ce5fd4b5 100644 (file)
@@ -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()
+  })
+})