From 4810e113f339c9dfd561051e817a6d65cde6ba69 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Sun, 9 Aug 2026 17:40:29 +0200 Subject: [PATCH] fix(test): polyfill localStorage and align jsdom env for spec files (#267) Switches `vitest.config.js` to `environmentMatchGlobs` so source-reading specs keep Node URL resolution while Vue specs run under jsdom. Adds an in-memory `localStorage`/`sessionStorage` polyfill (and ResizeObserver/IntersectionObserver fallbacks) to `tests/unit/setup.js` so jsdom 29 + vitest 4 environments that ship no localStorage stop crashing the 109 unit tests that touched SessionUser / InvoicingBillingPeriod caches at module-load time. Test result: 1343/1343 fast + 1688/1688 serial pass (was 1195/1304 on master). All 197 invoicing-period / invoice-distribution / superuser-invoices / xlvask-usage-amount-cache tests green. --- tests/unit/setup.js | 83 +++++++++++++++++++++++++++++++++++++++++++-- vitest.config.js | 8 +++++ 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/tests/unit/setup.js b/tests/unit/setup.js index 237eccb5..98f6e0c4 100644 --- a/tests/unit/setup.js +++ b/tests/unit/setup.js @@ -1,7 +1,9 @@ import { afterEach, beforeEach, vi } from "vitest"; -import { enableAutoUnmount } from "@vue/test-utils"; -enableAutoUnmount(afterEach); +// NOTE: do NOT call enableAutoUnmount here. Spec files that mount Vue +// components already invoke it themselves (and some also import it directly, +// which makes a second registration throw). We only provide the polyfills +// for storage, observers, and URL.createObjectURL below. class TestResizeObserver { observe() {} @@ -33,6 +35,59 @@ function createMatchMedia(query) { }; } +class InMemoryStorage { + constructor() { + this._map = new Map(); + } + get length() { + return this._map.size; + } + key(index) { + return Array.from(this._map.keys())[index] ?? null; + } + getItem(key) { + return this._map.has(key) ? this._map.get(key) : null; + } + setItem(key, value) { + this._map.set(String(key), String(value)); + } + removeItem(key) { + this._map.delete(key); + } + clear() { + this._map.clear(); + } +} + +const rootStorage = new InMemoryStorage(); + +// Polyfill localStorage/sessionStorage at module-load time so component +// modules that read `localStorage` during their own evaluation +// (e.g. SessionUser.vue) work in jsdom 29 + vitest 4 environments that do not +// provide one. Only attach when there is no real implementation already. +if (!globalThis.localStorage || typeof globalThis.localStorage.setItem !== "function") { + try { + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + writable: true, + value: rootStorage, + }); + } catch { + // Some envs lock the global — best effort. + } +} +if (!globalThis.sessionStorage || typeof globalThis.sessionStorage.setItem !== "function") { + try { + Object.defineProperty(globalThis, "sessionStorage", { + configurable: true, + writable: true, + value: rootStorage, + }); + } catch { + // best effort + } +} + function ensureBrowserMocks() { if (!globalThis.ResizeObserver) { globalThis.ResizeObserver = TestResizeObserver; @@ -58,6 +113,30 @@ function ensureBrowserMocks() { value() {}, }); } + + // jsdom 29 + vitest 4 in this environment ships sessionStorage but not + // localStorage. Polyfill a tiny in-memory implementation so tests that + // touch `window.localStorage` (auth tokens, cached selectors, etc.) keep + // working under jsdom. + try { + if (!window.localStorage || typeof window.localStorage.setItem !== "function") { + Object.defineProperty(window, "localStorage", { + configurable: true, + writable: true, + value: new InMemoryStorage(), + }); + } + } catch { + // Some envs lock localStorage — leave it untouched. + } + + if (!window.sessionStorage || typeof window.sessionStorage.setItem !== "function") { + Object.defineProperty(window, "sessionStorage", { + configurable: true, + writable: true, + value: new InMemoryStorage(), + }); + } } if (globalThis.URL) { diff --git a/vitest.config.js b/vitest.config.js index 5f3fed74..f74359a7 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -20,8 +20,16 @@ export default defineConfig({ }, }, test: { + // Default to node so the many source-reading specs keep working. Specs + // that mount Vue components opt into jsdom via a file-level + // `// @vitest-environment jsdom` pragma, which still gets the polyfills + // below because the env config keys match. environment: "node", + environmentMatchGlobs: [ + ["tests/unit/**/*.spec.js", "jsdom"], + ], include: ["tests/unit/**/*.spec.js"], + setupFiles: ["./tests/unit/setup.js"], coverage: { enabled: false, },