diff --git a/app/__tests__/setup-storage.test.ts b/app/__tests__/setup-storage.test.ts new file mode 100644 index 00000000..5b1d75ce --- /dev/null +++ b/app/__tests__/setup-storage.test.ts @@ -0,0 +1,66 @@ +/** + * Guards the Web Storage polyfill installed by __tests__/setup.ts. + * + * This environment's `window.localStorage` is a bare object with no Storage + * methods on it. Without the polyfill, every persistence test fails with + * "localStorage.clear is not a function" — 29 of them at once, across files + * that look unrelated to storage — or silently exercises nothing. + * + * If this file goes red, fix the polyfill rather than the tests that depend + * on it. + */ + +import { describe, it, expect } from "vitest"; + +describe("test environment: Web Storage", () => { + it.each(["localStorage", "sessionStorage"] as const)( + "%s implements the full Storage API", + (kind) => { + const storage = window[kind]; + expect(storage).toBeDefined(); + for (const method of ["getItem", "setItem", "removeItem", "clear", "key"]) { + expect(typeof (storage as unknown as Record)[method]).toBe( + "function", + ); + } + }, + ); + + it("round-trips values and reports length", () => { + localStorage.setItem("alpha", "1"); + localStorage.setItem("beta", "2"); + + expect(localStorage.getItem("alpha")).toBe("1"); + expect(localStorage.length).toBe(2); + expect(localStorage.key(0)).toBe("alpha"); + + localStorage.removeItem("alpha"); + expect(localStorage.getItem("alpha")).toBeNull(); + expect(localStorage.length).toBe(1); + + localStorage.clear(); + expect(localStorage.length).toBe(0); + }); + + it("coerces non-string keys and values like the real Storage", () => { + localStorage.setItem(1 as unknown as string, 2 as unknown as string); + expect(localStorage.getItem("1")).toBe("2"); + }); + + it("returns null for a missing key rather than undefined", () => { + // Callers do `JSON.parse(raw)` guarded on `raw === null`; undefined slips + // past that guard and throws. + expect(localStorage.getItem("never-set")).toBeNull(); + }); + + it("is cleared between tests", () => { + // Depends on the preceding tests having written keys — setup.ts's afterEach + // must have emptied them, or persisted state leaks across the suite. + expect(localStorage.length).toBe(0); + localStorage.setItem("leak-check", "x"); + }); + + it("did not inherit the previous test's keys", () => { + expect(localStorage.getItem("leak-check")).toBeNull(); + }); +}); diff --git a/app/__tests__/setup.ts b/app/__tests__/setup.ts index 514ac14b..941deda1 100644 --- a/app/__tests__/setup.ts +++ b/app/__tests__/setup.ts @@ -30,8 +30,73 @@ if (typeof window !== 'undefined') { }); } +// `window.localStorage` in this environment is a bare object with NO Storage +// methods on it at all — `getItem`, `setItem`, `clear` and friends are all +// `undefined` (verified: no constructor, prototype is Object.prototype). Any +// test touching persistence therefore either throws +// ("localStorage.clear is not a function") or, worse, silently exercises +// nothing. Only 3 of ~259 test files worked around it with their own +// `vi.stubGlobal` mock; everything else ran against a dead Storage. +// +// Install a real in-memory Storage so persistence behaviour is actually +// exercised. Guarded on `getItem` being missing, so if the environment ever +// gains a working implementation this defers to it rather than shadowing it. +// Per-file `vi.stubGlobal('localStorage', ...)` still overrides this. +class MemoryStorage implements Storage { + private store = new Map(); + + get length(): number { + return this.store.size; + } + + key(index: number): string | null { + return Array.from(this.store.keys())[index] ?? null; + } + + getItem(key: string): string | null { + return this.store.has(String(key)) ? this.store.get(String(key))! : null; + } + + setItem(key: string, value: string): void { + this.store.set(String(key), String(value)); + } + + removeItem(key: string): void { + this.store.delete(String(key)); + } + + clear(): void { + this.store.clear(); + } + + [name: string]: any; +} + +if (typeof window !== 'undefined') { + for (const kind of ['localStorage', 'sessionStorage'] as const) { + const existing = (window as any)[kind]; + if (!existing || typeof existing.getItem !== 'function') { + Object.defineProperty(window, kind, { + configurable: true, + writable: true, + value: new MemoryStorage(), + }); + } + } +} + // Cleanup after each test afterEach(() => { cleanup(); vi.clearAllMocks(); + // Storage is module-level state that outlives a test; leaving entries behind + // lets one test's persisted keys silently satisfy the next one's assertions. + if (typeof window !== 'undefined') { + try { + window.localStorage?.clear?.(); + window.sessionStorage?.clear?.(); + } catch { + // A test may have stubbed Storage with a partial mock — never fail teardown. + } + } });