| 1 | import * as Schema from "effect/Schema"; |
| 2 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; |
| 3 | |
| 4 | function createStorage(overrides: Partial<Storage> = {}): Storage { |
| 5 | const store = new Map<string, string>(); |
| 6 | return { |
| 7 | clear: () => store.clear(), |
| 8 | getItem: (key) => store.get(key) ?? null, |
| 9 | key: (index) => [...store.keys()][index] ?? null, |
| 10 | get length() { |
| 11 | return store.size; |
| 12 | }, |
| 13 | removeItem: (key) => { |
| 14 | store.delete(key); |
| 15 | }, |
| 16 | setItem: (key, value) => { |
| 17 | store.set(key, value); |
| 18 | }, |
| 19 | ...overrides, |
| 20 | }; |
| 21 | } |
| 22 | |
| 23 | async function loadWithStorage(storage: Storage) { |
| 24 | vi.stubGlobal("window", { localStorage: storage }); |
| 25 | vi.stubGlobal("localStorage", storage); |
| 26 | return import("./useLocalStorage"); |
| 27 | } |
| 28 | |
| 29 | afterEach(() => { |
| 30 | vi.resetModules(); |
| 31 | vi.unstubAllGlobals(); |
| 32 | }); |
| 33 | |
| 34 | describe("local storage errors", () => { |
| 35 | it("preserves read failure context", async () => { |
| 36 | const cause = new Error("storage unavailable"); |
| 37 | const { getLocalStorageItem, LocalStorageOperationError } = await loadWithStorage( |
| 38 | createStorage({ |
| 39 | getItem: () => { |
| 40 | throw cause; |
| 41 | }, |
| 42 | }), |
| 43 | ); |
| 44 | |
| 45 | try { |
| 46 | getLocalStorageItem("read-key", Schema.String); |
| 47 | expect.unreachable("expected the read to fail"); |
| 48 | } catch (error) { |
| 49 | expect(error).toBeInstanceOf(LocalStorageOperationError); |
| 50 | expect(error).toMatchObject({ |
| 51 | operation: "read", |
| 52 | storageKey: "read-key", |
| 53 | cause, |
| 54 | }); |
| 55 | } |
| 56 | }); |
| 57 | |
| 58 | it("retries when access to browser storage becomes available", async () => { |
| 59 | const storage = createStorage(); |
| 60 | storage.setItem("read-key", JSON.stringify("saved value")); |
| 61 | let blocked = true; |
| 62 | vi.stubGlobal("window", { |
| 63 | get localStorage() { |
| 64 | if (blocked) throw new Error("storage unavailable"); |
| 65 | return storage; |
| 66 | }, |
| 67 | }); |
| 68 | const { getLocalStorageItem, LocalStorageOperationError } = await import("./useLocalStorage"); |
| 69 | |
| 70 | expect(() => getLocalStorageItem("read-key", Schema.String)).toThrow( |
| 71 | LocalStorageOperationError, |
| 72 | ); |
| 73 | blocked = false; |
| 74 | expect(getLocalStorageItem("read-key", Schema.String)).toBe("saved value"); |
| 75 | }); |
| 76 | |
| 77 | it.each(["", "not-json"])("preserves decode failure context for %j", async (value) => { |
| 78 | const { getLocalStorageItem, LocalStorageOperationError } = await loadWithStorage( |
| 79 | createStorage({ getItem: () => value }), |
| 80 | ); |
| 81 | |
| 82 | try { |
| 83 | getLocalStorageItem("decode-key", Schema.String); |
| 84 | expect.unreachable("expected decoding to fail"); |
| 85 | } catch (error) { |
| 86 | expect(error).toBeInstanceOf(LocalStorageOperationError); |
| 87 | expect(error).toMatchObject({ |
| 88 | operation: "decode", |
| 89 | storageKey: "decode-key", |
| 90 | cause: expect.anything(), |
| 91 | }); |
| 92 | } |
| 93 | }); |
| 94 | |
| 95 | it("preserves write failure context", async () => { |
| 96 | const cause = new Error("storage quota exceeded"); |
| 97 | const { LocalStorageOperationError, setLocalStorageItem } = await loadWithStorage( |
| 98 | createStorage({ |
| 99 | setItem: () => { |
| 100 | throw cause; |
| 101 | }, |
| 102 | }), |
| 103 | ); |
| 104 | |
| 105 | try { |
| 106 | setLocalStorageItem("write-key", "value", Schema.String); |
| 107 | expect.unreachable("expected the write to fail"); |
| 108 | } catch (error) { |
| 109 | expect(error).toBeInstanceOf(LocalStorageOperationError); |
| 110 | expect(error).toMatchObject({ |
| 111 | operation: "write", |
| 112 | storageKey: "write-key", |
| 113 | cause, |
| 114 | }); |
| 115 | } |
| 116 | }); |
| 117 | |
| 118 | it("preserves removal failure context", async () => { |
| 119 | const cause = new Error("storage unavailable"); |
| 120 | const { LocalStorageOperationError, removeLocalStorageItem } = await loadWithStorage( |
| 121 | createStorage({ |
| 122 | removeItem: () => { |
| 123 | throw cause; |
| 124 | }, |
| 125 | }), |
| 126 | ); |
| 127 | |
| 128 | try { |
| 129 | removeLocalStorageItem("remove-key"); |
| 130 | expect.unreachable("expected the removal to fail"); |
| 131 | } catch (error) { |
| 132 | expect(error).toBeInstanceOf(LocalStorageOperationError); |
| 133 | expect(error).toMatchObject({ |
| 134 | operation: "remove", |
| 135 | storageKey: "remove-key", |
| 136 | cause, |
| 137 | }); |
| 138 | } |
| 139 | }); |
| 140 | }); |