Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions apps/loopover-miner-ui/src/test/restore-jsdom-web-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// #7576: Node 25+ ships a native `globalThis.localStorage` accessor that evaluates to `undefined` when no
// `--localstorage-file` is given (nodejs/node#60303). Vitest's jsdom environment only copies jsdom's real
// `Storage` onto the test global when the key is absent, so on Node 25+ (`"localStorage" in globalThis` is
// now truthy) it skips the copy and leaves Node's broken `undefined` in place -- every test touching
// window.localStorage then throws `Cannot read properties of undefined`. jsdom's working Storage is still
// reachable via the raw JSDOM instance vitest exposes as `globalThis.jsdom`, so restore both storages from
// there whenever the environment's own copy is missing. No-op on Node 22 (`.nvmrc`), where jsdom's copy is
// already present (`globalThis.localStorage` is truthy), so this touches nothing on today's CI.

type StorageKey = "localStorage" | "sessionStorage";
const STORAGE_KEYS: readonly StorageKey[] = ["localStorage", "sessionStorage"];

/** Copy jsdom's working Storage onto `target` (defaults to `globalThis`) for any storage the target is missing. */
export function restoreJsdomWebStorage(target: typeof globalThis = globalThis): void {
const jsdomWindow = (target as { jsdom?: { window?: Window } }).jsdom?.window;
if (!jsdomWindow) return;
for (const key of STORAGE_KEYS) {
// `target[key]` is falsy on Node 25+ (the broken `undefined` accessor); truthy on Node 22 -> skip.
if ((target as Record<StorageKey, unknown>)[key]) continue;
const jsdomStorage = jsdomWindow[key];
if (!jsdomStorage) continue;
const descriptor = { value: jsdomStorage, configurable: true, writable: true };
Object.defineProperty(target, key, descriptor);
Object.defineProperty(jsdomWindow, key, descriptor);
}
}
6 changes: 6 additions & 0 deletions apps/loopover-miner-ui/vitest.setup.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";

import { restoreJsdomWebStorage } from "./src/test/restore-jsdom-web-storage";

// Unmount React trees between tests so jsdom state never leaks across cases.
afterEach(() => {
cleanup();
Expand All @@ -16,6 +18,10 @@ class ResizeObserverStub {
}
globalThis.ResizeObserver ??= ResizeObserverStub as unknown as typeof ResizeObserver;

// #7576: on Node 25+ vitest's jsdom environment leaves Node's broken native `localStorage` (undefined) in
// place instead of jsdom's working Storage; restore it before any test runs. No-op on Node 22 (`.nvmrc`).
restoreJsdomWebStorage();

// #7075: ChatConversation wires handlePortfolioQueueChatCommand, which imports chat-action-registry →
// governor-chokepoint → governor-ledger → node:sqlite. jsdom/Vite cannot bundle that builtin (same twin
// pattern as chat-governor-actions.test.tsx / chat-portfolio-queue-actions.test.tsx).
Expand Down
105 changes: 105 additions & 0 deletions apps/loopover-ui/src/test/restore-jsdom-web-storage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { afterEach, describe, expect, it } from "vitest";

import { restoreJsdomWebStorage } from "./restore-jsdom-web-storage";

// #7576: exercise the shim by SIMULATING Node 25+'s broken global (present-but-undefined localStorage) on a
// fake target, then asserting restoreJsdomWebStorage copies jsdom's working Storage back. This reproduces the
// real failure deterministically on any Node version, including CI's Node 22 where the native bug is absent.

/** A jsdom-like window carrying real Storage instances (the browser env vitest exposes as globalThis.jsdom.window). */
function fakeJsdomWindow() {
const jsdomWindow = (globalThis as { jsdom?: { window?: Window } }).jsdom?.window;
return {
localStorage: jsdomWindow?.localStorage ?? realStorage(),
sessionStorage: jsdomWindow?.sessionStorage ?? realStorage(),
} as unknown as Window;
}

/** Minimal in-memory Storage stand-in for environments where globalThis.jsdom isn't populated. */
function realStorage(): Storage {
const map = new Map<string, string>();
return {
get length() {
return map.size;
},
clear: () => map.clear(),
getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
key: (i: number) => [...map.keys()][i] ?? null,
removeItem: (k: string) => void map.delete(k),
setItem: (k: string, v: string) => void map.set(k, String(v)),
} as Storage;
}

/** Build a fake global target reproducing Node 25+: `localStorage` present in the object but === undefined. */
function brokenNode25Target(jsdomWindow: Window) {
const target = { jsdom: { window: jsdomWindow } } as unknown as typeof globalThis;
Object.defineProperty(target, "localStorage", {
value: undefined,
configurable: true,
writable: true,
});
Object.defineProperty(target, "sessionStorage", {
value: undefined,
configurable: true,
writable: true,
});
return target;
}

describe("restoreJsdomWebStorage (#7576)", () => {
afterEach(() => {
window.localStorage.clear();
});

it("copies jsdom's working Storage over Node 25+'s broken undefined global", () => {
const jsdomWindow = fakeJsdomWindow();
const target = brokenNode25Target(jsdomWindow);
// Precondition: the key is PRESENT but undefined -- exactly the Node 25 shape vitest fails to overwrite.
expect("localStorage" in target).toBe(true);
expect(target.localStorage).toBeUndefined();

restoreJsdomWebStorage(target);

expect(target.localStorage).toBeTruthy();
expect(target.sessionStorage).toBeTruthy();
target.localStorage.setItem("k", "v");
expect(target.localStorage.getItem("k")).toBe("v");
// The window's own reference is restored too, so `window.localStorage` inside a test works.
expect(jsdomWindow.localStorage.getItem("k")).toBe("v");
});

it("leaves an already-working Storage untouched (the Node 22 / CI no-op path)", () => {
const jsdomWindow = fakeJsdomWindow();
const working = realStorage();
working.setItem("keep", "me");
const target = {
jsdom: { window: jsdomWindow },
localStorage: working,
sessionStorage: working,
} as unknown as typeof globalThis;

restoreJsdomWebStorage(target);

// Same instance, not replaced by jsdom's -- the `if (target[key]) continue` guard fired.
expect(target.localStorage).toBe(working);
expect(target.localStorage.getItem("keep")).toBe("me");
});

it("no-ops when no jsdom instance is present (non-jsdom environment)", () => {
const target = {} as typeof globalThis;
expect(() => restoreJsdomWebStorage(target)).not.toThrow();
expect("localStorage" in target).toBe(false);
});

it("skips a storage jsdom itself doesn't expose", () => {
// jsdom window present but its sessionStorage is absent -> that key stays missing, localStorage still restored.
const jsdomWindow = {
localStorage: realStorage(),
sessionStorage: undefined,
} as unknown as Window;
const target = brokenNode25Target(jsdomWindow);
restoreJsdomWebStorage(target);
expect(target.localStorage).toBeTruthy();
expect(target.sessionStorage).toBeUndefined();
});
});
26 changes: 26 additions & 0 deletions apps/loopover-ui/src/test/restore-jsdom-web-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// #7576: Node 25+ ships a native `globalThis.localStorage` accessor that evaluates to `undefined` when no
// `--localstorage-file` is given (nodejs/node#60303). Vitest's jsdom environment only copies jsdom's real
// `Storage` onto the test global when the key is absent, so on Node 25+ (`"localStorage" in globalThis` is
// now truthy) it skips the copy and leaves Node's broken `undefined` in place -- every test touching
// window.localStorage then throws `Cannot read properties of undefined`. jsdom's working Storage is still
// reachable via the raw JSDOM instance vitest exposes as `globalThis.jsdom`, so restore both storages from
// there whenever the environment's own copy is missing. No-op on Node 22 (`.nvmrc`), where jsdom's copy is
// already present (`globalThis.localStorage` is truthy), so this touches nothing on today's CI.

type StorageKey = "localStorage" | "sessionStorage";
const STORAGE_KEYS: readonly StorageKey[] = ["localStorage", "sessionStorage"];

/** Copy jsdom's working Storage onto `target` (defaults to `globalThis`) for any storage the target is missing. */
export function restoreJsdomWebStorage(target: typeof globalThis = globalThis): void {
const jsdomWindow = (target as { jsdom?: { window?: Window } }).jsdom?.window;
if (!jsdomWindow) return;
for (const key of STORAGE_KEYS) {
// `target[key]` is falsy on Node 25+ (the broken `undefined` accessor); truthy on Node 22 -> skip.
if ((target as Record<StorageKey, unknown>)[key]) continue;
const jsdomStorage = jsdomWindow[key];
if (!jsdomStorage) continue;
const descriptor = { value: jsdomStorage, configurable: true, writable: true };
Object.defineProperty(target, key, descriptor);
Object.defineProperty(jsdomWindow, key, descriptor);
}
}
6 changes: 6 additions & 0 deletions apps/loopover-ui/vitest.setup.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";

import { restoreJsdomWebStorage } from "./src/test/restore-jsdom-web-storage";

// Unmount React trees between tests so jsdom state never leaks across cases.
afterEach(() => {
cleanup();
Expand All @@ -15,3 +17,7 @@ class ResizeObserverStub {
disconnect(): void {}
}
globalThis.ResizeObserver ??= ResizeObserverStub as unknown as typeof ResizeObserver;

// #7576: on Node 25+ vitest's jsdom environment leaves Node's broken native `localStorage` (undefined) in
// place instead of jsdom's working Storage; restore it before any test runs. No-op on Node 22 (`.nvmrc`).
restoreJsdomWebStorage();
Loading