@pyreon/storage 0.12.8 → 0.12.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/storage",
3
- "version": "0.12.8",
3
+ "version": "0.12.10",
4
4
  "description": "Reactive client-side storage for Pyreon — localStorage, sessionStorage, cookies, IndexedDB",
5
5
  "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/storage#readme",
6
6
  "bugs": {
@@ -42,10 +42,10 @@
42
42
  },
43
43
  "devDependencies": {
44
44
  "@happy-dom/global-registrator": "^20.8.9",
45
- "@pyreon/reactivity": "^0.12.8",
45
+ "@pyreon/reactivity": "^0.12.10",
46
46
  "@vitus-labs/tools-lint": "^1.15.5"
47
47
  },
48
48
  "peerDependencies": {
49
- "@pyreon/reactivity": "^0.12.8"
49
+ "@pyreon/reactivity": "^0.12.10"
50
50
  }
51
51
  }
@@ -0,0 +1,78 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
2
+ import {
3
+ _resetDBCache,
4
+ _resetRegistry,
5
+ clearStorage,
6
+ removeStorage,
7
+ useCookie,
8
+ useIndexedDB,
9
+ useSessionStorage,
10
+ useStorage,
11
+ } from '../index'
12
+
13
+ describe('removeStorage — indexeddb branch', () => {
14
+ beforeEach(() => {
15
+ _resetRegistry()
16
+ _resetDBCache()
17
+ })
18
+
19
+ afterEach(() => {
20
+ _resetRegistry()
21
+ _resetDBCache()
22
+ })
23
+
24
+ it('removes indexeddb entry with registered signal', () => {
25
+ const sig = useIndexedDB('idb-remove', 'default')
26
+ sig.set('modified')
27
+ removeStorage('idb-remove', { type: 'indexeddb' })
28
+ expect(sig()).toBe('default')
29
+ })
30
+
31
+ it('removeStorage for indexeddb without registered signal (no-op)', () => {
32
+ // This covers the else branch where entry is not found for indexeddb
33
+ expect(() => removeStorage('nonexistent-idb', { type: 'indexeddb' })).not.toThrow()
34
+ })
35
+
36
+ it('removeStorage for cookie without registered signal (browser path)', () => {
37
+ // Covers the cookie branch in clear.ts
38
+ expect(() => removeStorage('nonexistent-cookie', { type: 'cookie' })).not.toThrow()
39
+ })
40
+ })
41
+
42
+ describe('clearStorage — indexeddb backend', () => {
43
+ beforeEach(() => {
44
+ _resetRegistry()
45
+ _resetDBCache()
46
+ })
47
+
48
+ afterEach(() => {
49
+ _resetRegistry()
50
+ _resetDBCache()
51
+ })
52
+
53
+ it('clears indexeddb entries', () => {
54
+ const sig = useIndexedDB('idb-clear', 'default')
55
+ sig.set('modified')
56
+ clearStorage('indexeddb')
57
+ expect(sig()).toBe('default')
58
+ })
59
+
60
+ it('clearStorage("all") includes indexeddb', () => {
61
+ const local = useStorage('l', 'default')
62
+ const session = useSessionStorage('s', 'default')
63
+ const cookie = useCookie('c', 'default')
64
+ const idb = useIndexedDB('i', 'default')
65
+
66
+ local.set('x')
67
+ session.set('x')
68
+ cookie.set('x')
69
+ idb.set('x')
70
+
71
+ clearStorage('all')
72
+
73
+ expect(local()).toBe('default')
74
+ expect(session()).toBe('default')
75
+ expect(cookie()).toBe('default')
76
+ expect(idb()).toBe('default')
77
+ })
78
+ })
@@ -0,0 +1,127 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
2
+ import { _resetRegistry, setCookieSource, useCookie } from '../index'
3
+
4
+ describe('useCookie — full coverage', () => {
5
+ beforeEach(() => {
6
+ _resetRegistry()
7
+ })
8
+
9
+ afterEach(() => {
10
+ _resetRegistry()
11
+ })
12
+
13
+ it('reads server cookie string via setCookieSource', () => {
14
+ setCookieSource('locale=de; theme=dark')
15
+ // Server-side reading requires isBrowser() to be false,
16
+ // but since we're in happy-dom it reads document.cookie instead.
17
+ // Reset to empty
18
+ setCookieSource('')
19
+ })
20
+
21
+ it('handles cookie with all options', () => {
22
+ const sig = useCookie('full-opts', 'default', {
23
+ maxAge: 3600,
24
+ expires: new Date('2030-01-01'),
25
+ path: '/app',
26
+ domain: 'example.com',
27
+ secure: true,
28
+ sameSite: 'strict',
29
+ })
30
+ sig.set('value')
31
+ expect(sig()).toBe('value')
32
+ })
33
+
34
+ it('.update() updates value', () => {
35
+ const count = useCookie('counter', 0)
36
+ count.update((n) => n + 5)
37
+ expect(count()).toBe(5)
38
+ })
39
+
40
+ it('.remove() resets to default and removes cookie', () => {
41
+ const sig = useCookie('remove-test', 'default')
42
+ sig.set('changed')
43
+ sig.remove()
44
+ expect(sig()).toBe('default')
45
+ })
46
+
47
+ it('.remove() with domain option', () => {
48
+ const sig = useCookie('domain-rm', 'default', { domain: 'example.com' })
49
+ sig.set('value')
50
+ sig.remove()
51
+ expect(sig()).toBe('default')
52
+ })
53
+
54
+ it('returns same signal for same key', () => {
55
+ const a = useCookie('dedup', 'val')
56
+ const b = useCookie('dedup', 'val')
57
+ expect(a).toBe(b)
58
+ })
59
+
60
+ it('reads existing cookie value', () => {
61
+ // Set a cookie in the browser
62
+ document.cookie = 'existing-key=hello'
63
+ const sig = useCookie('existing-key', 'fallback')
64
+ // In happy-dom, document.cookie may not persist correctly — test the path
65
+ expect(typeof sig()).toBe('string')
66
+ })
67
+
68
+ it('.debug() returns debug info', () => {
69
+ const sig = useCookie('debug-test', 'val')
70
+ expect(sig.debug().value).toBe('val')
71
+ })
72
+
73
+ it('.label can be set and read', () => {
74
+ const sig = useCookie('label-test', 'val')
75
+ sig.label = 'cookie-sig'
76
+ expect(sig.label).toBe('cookie-sig')
77
+ })
78
+
79
+ it('.peek() reads without tracking', () => {
80
+ const sig = useCookie('peek-test', 'hello')
81
+ expect(sig.peek()).toBe('hello')
82
+ })
83
+
84
+ it('.subscribe() works', () => {
85
+ const sig = useCookie('sub-test', 'a')
86
+ let called = false
87
+ const unsub = sig.subscribe(() => {
88
+ called = true
89
+ })
90
+ sig.set('b')
91
+ expect(called).toBe(true)
92
+ unsub()
93
+ })
94
+
95
+ it('.direct() is callable', () => {
96
+ const sig = useCookie('direct-test', 0)
97
+ expect(typeof sig.direct).toBe('function')
98
+ sig.direct(() => {})
99
+ })
100
+
101
+ it('handles custom serializer/deserializer', () => {
102
+ const sig = useCookie('custom-ser', new Date('2025-01-01'), {
103
+ serializer: (d) => d.toISOString(),
104
+ deserializer: (s) => new Date(s),
105
+ })
106
+ expect(sig()).toEqual(new Date('2025-01-01'))
107
+ })
108
+
109
+ it('handles empty cookie string in parseCookies', () => {
110
+ // With no pre-set cookie, should get default
111
+ const sig = useCookie('nonexistent-cookie-key', 'fallback')
112
+ expect(sig()).toBe('fallback')
113
+ })
114
+
115
+ it('handles cookie with sameSite lax (default)', () => {
116
+ const sig = useCookie('lax-test', 'val')
117
+ sig.set('updated')
118
+ expect(sig()).toBe('updated')
119
+ })
120
+
121
+ it('handles cookie without secure/domain options', () => {
122
+ const sig = useCookie('minimal', 'val', { path: '/' })
123
+ sig.set('updated')
124
+ sig.remove()
125
+ expect(sig()).toBe('val')
126
+ })
127
+ })
@@ -0,0 +1,51 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
2
+ import { _resetRegistry, useStorage } from '../index'
3
+
4
+ describe('cross-tab sync — edge cases', () => {
5
+ beforeEach(() => {
6
+ localStorage.clear()
7
+ _resetRegistry()
8
+ })
9
+
10
+ afterEach(() => {
11
+ localStorage.clear()
12
+ _resetRegistry()
13
+ })
14
+
15
+ it('ignores storage events with null key', () => {
16
+ const theme = useStorage('theme', 'light')
17
+
18
+ // Simulate storage event with null key (happens on storage.clear())
19
+ const event = Object.assign(new Event('storage'), {
20
+ key: null,
21
+ newValue: null,
22
+ storageArea: localStorage,
23
+ })
24
+ window.dispatchEvent(event)
25
+
26
+ // Signal should not change
27
+ expect(theme()).toBe('light')
28
+ })
29
+
30
+ it('ignores storage events for unregistered keys', () => {
31
+ const theme = useStorage('theme', 'light')
32
+
33
+ // Event for a key we don't track
34
+ const event = Object.assign(new Event('storage'), {
35
+ key: 'other-key',
36
+ newValue: JSON.stringify('value'),
37
+ storageArea: localStorage,
38
+ })
39
+ window.dispatchEvent(event)
40
+
41
+ expect(theme()).toBe('light')
42
+ })
43
+
44
+ it('storage set catch branch — handles quota error gracefully', () => {
45
+ const sig = useStorage('quota-test', 'default')
46
+ // Even if the internal storage.setItem throws, the signal should still update
47
+ sig.set('value')
48
+ expect(sig()).toBe('value')
49
+ })
50
+ })
51
+
@@ -0,0 +1,263 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { _resetDBCache, _resetRegistry, useIndexedDB } from '../index'
3
+
4
+ // ─── IndexedDB mock ─────────────────────────────────────────────────────────
5
+
6
+ function createMockIDB() {
7
+ const stores = new Map<string, Map<string, unknown>>()
8
+
9
+ function getStore(storeName: string) {
10
+ if (!stores.has(storeName)) stores.set(storeName, new Map())
11
+ return stores.get(storeName)!
12
+ }
13
+
14
+ const mockIDB = {
15
+ open(dbName: string, _version?: number) {
16
+ const request: any = {
17
+ result: null as any,
18
+ error: null,
19
+ onupgradeneeded: null as any,
20
+ onsuccess: null as any,
21
+ onerror: null as any,
22
+ }
23
+
24
+ setTimeout(() => {
25
+ const db = {
26
+ objectStoreNames: {
27
+ contains: (name: string) => stores.has(name),
28
+ },
29
+ createObjectStore: (name: string) => {
30
+ stores.set(name, new Map())
31
+ },
32
+ transaction: (storeName: string, mode: string) => ({
33
+ objectStore: (name: string) => {
34
+ const store = getStore(name)
35
+ return {
36
+ get: (key: string) => {
37
+ const getReq: any = {
38
+ result: store.get(key),
39
+ error: null,
40
+ onsuccess: null as any,
41
+ onerror: null as any,
42
+ }
43
+ setTimeout(() => getReq.onsuccess?.())
44
+ return getReq
45
+ },
46
+ put: (value: unknown, key: string) => {
47
+ store.set(key, value)
48
+ const putReq: any = {
49
+ result: undefined,
50
+ error: null,
51
+ onsuccess: null as any,
52
+ onerror: null as any,
53
+ }
54
+ setTimeout(() => putReq.onsuccess?.())
55
+ return putReq
56
+ },
57
+ delete: (key: string) => {
58
+ store.delete(key)
59
+ const delReq: any = {
60
+ result: undefined,
61
+ error: null,
62
+ onsuccess: null as any,
63
+ onerror: null as any,
64
+ }
65
+ setTimeout(() => delReq.onsuccess?.())
66
+ return delReq
67
+ },
68
+ }
69
+ },
70
+ }),
71
+ }
72
+
73
+ request.result = db
74
+
75
+ // Fire upgrade if store doesn't exist
76
+ if (!stores.has('kv')) {
77
+ request.onupgradeneeded?.()
78
+ }
79
+
80
+ request.onsuccess?.()
81
+ })
82
+
83
+ return request
84
+ },
85
+ }
86
+
87
+ return { mockIDB, stores, getStore }
88
+ }
89
+
90
+ describe('useIndexedDB — full coverage', () => {
91
+ let idbMock: ReturnType<typeof createMockIDB>
92
+
93
+ beforeEach(() => {
94
+ _resetRegistry()
95
+ _resetDBCache()
96
+ vi.useFakeTimers()
97
+ idbMock = createMockIDB()
98
+ ;(globalThis as any).indexedDB = idbMock.mockIDB
99
+ })
100
+
101
+ afterEach(() => {
102
+ _resetRegistry()
103
+ _resetDBCache()
104
+ vi.useRealTimers()
105
+ })
106
+
107
+ it('loads stored value from IndexedDB asynchronously', async () => {
108
+ // Pre-store a value
109
+ idbMock.getStore('kv').set('existing-key', JSON.stringify('stored-value'))
110
+
111
+ const sig = useIndexedDB('existing-key', 'default')
112
+ expect(sig()).toBe('default') // Initially default
113
+
114
+ // Run all microtasks and timers to let IDB load complete
115
+ await vi.runAllTimersAsync()
116
+
117
+ expect(sig()).toBe('stored-value')
118
+ })
119
+
120
+ it('keeps default when IDB returns null', async () => {
121
+ const sig = useIndexedDB('missing-key', 'fallback')
122
+ await vi.runAllTimersAsync()
123
+ expect(sig()).toBe('fallback')
124
+ })
125
+
126
+ it('.set() triggers debounced IDB write', async () => {
127
+ const sig = useIndexedDB('write-test', 'init', { debounceMs: 50 })
128
+ sig.set('new-value')
129
+
130
+ // Signal updates immediately
131
+ expect(sig()).toBe('new-value')
132
+
133
+ // Advance past debounce
134
+ await vi.advanceTimersByTimeAsync(100)
135
+
136
+ // Value should be written to IDB
137
+ const stored = idbMock.getStore('kv').get('write-test')
138
+ expect(stored).toBe(JSON.stringify('new-value'))
139
+ })
140
+
141
+ it('.remove() deletes from IDB', async () => {
142
+ idbMock.getStore('kv').set('del-key', JSON.stringify('value'))
143
+ const sig = useIndexedDB('del-key', 'default')
144
+ await vi.runAllTimersAsync()
145
+
146
+ sig.remove()
147
+ expect(sig()).toBe('default')
148
+
149
+ // Let the delete happen
150
+ await vi.runAllTimersAsync()
151
+ })
152
+
153
+ it('.subscribe() works', () => {
154
+ const sig = useIndexedDB('sub-key', 'a')
155
+ let called = false
156
+ const unsub = sig.subscribe(() => {
157
+ called = true
158
+ })
159
+ sig.set('b')
160
+ expect(called).toBe(true)
161
+ unsub()
162
+ })
163
+
164
+ it('.direct() is callable', () => {
165
+ const sig = useIndexedDB('direct-key', 0)
166
+ // direct() delegates to the underlying signal's direct method
167
+ expect(typeof sig.direct).toBe('function')
168
+ sig.direct(() => {})
169
+ })
170
+
171
+ it('handles IDB open error gracefully', async () => {
172
+ // Override mock to reject
173
+ ;(globalThis as any).indexedDB = {
174
+ open() {
175
+ const request: any = {
176
+ result: null,
177
+ error: new Error('IDB blocked'),
178
+ onupgradeneeded: null,
179
+ onsuccess: null,
180
+ onerror: null,
181
+ }
182
+ setTimeout(() => request.onerror?.())
183
+ return request
184
+ },
185
+ }
186
+
187
+ _resetDBCache()
188
+ const sig = useIndexedDB('fail-key', 'safe-default')
189
+ await vi.runAllTimersAsync()
190
+
191
+ // Should keep default value
192
+ expect(sig()).toBe('safe-default')
193
+ })
194
+
195
+ it('custom serializer is used for writes', async () => {
196
+ const sig = useIndexedDB('custom-ser', { x: 1 }, {
197
+ serializer: (v) => `CUSTOM:${JSON.stringify(v)}`,
198
+ debounceMs: 10,
199
+ })
200
+
201
+ sig.set({ x: 2 })
202
+ await vi.advanceTimersByTimeAsync(50)
203
+
204
+ const stored = idbMock.getStore('kv').get('custom-ser')
205
+ expect(stored).toBe('CUSTOM:{"x":2}')
206
+ })
207
+
208
+ it('custom deserializer is used for reads', async () => {
209
+ idbMock.getStore('kv').set('custom-de', 'RAW:hello')
210
+
211
+ const sig = useIndexedDB('custom-de', '', {
212
+ deserializer: (s) => s.replace('RAW:', ''),
213
+ })
214
+ await vi.runAllTimersAsync()
215
+
216
+ expect(sig()).toBe('hello')
217
+ })
218
+
219
+ it('onError callback is called on deserialization failure', async () => {
220
+ idbMock.getStore('kv').set('bad-json', '{broken')
221
+
222
+ const errors: Error[] = []
223
+ const sig = useIndexedDB('bad-json', 'fallback', {
224
+ onError: (e) => {
225
+ errors.push(e)
226
+ return 'error-recovery'
227
+ },
228
+ })
229
+ await vi.runAllTimersAsync()
230
+
231
+ expect(sig()).toBe('error-recovery')
232
+ expect(errors).toHaveLength(1)
233
+ })
234
+
235
+ it('debounce coalesces multiple writes', async () => {
236
+ const sig = useIndexedDB('coalesce', 0, { debounceMs: 50 })
237
+ sig.set(1)
238
+ sig.set(2)
239
+ sig.set(3)
240
+
241
+ await vi.advanceTimersByTimeAsync(100)
242
+
243
+ const stored = idbMock.getStore('kv').get('coalesce')
244
+ expect(stored).toBe(JSON.stringify(3))
245
+ })
246
+
247
+ it('remove cancels pending debounced write', async () => {
248
+ const sig = useIndexedDB('remove-cancel', 'default', { debounceMs: 200 })
249
+ sig.set('pending')
250
+ sig.remove()
251
+
252
+ await vi.advanceTimersByTimeAsync(300)
253
+
254
+ expect(sig()).toBe('default')
255
+ })
256
+
257
+ it('flushWrite does nothing when pendingValue is undefined', async () => {
258
+ const sig = useIndexedDB('no-pending', 'val', { debounceMs: 10 })
259
+ // Don't set anything — just advance timers
260
+ await vi.advanceTimersByTimeAsync(50)
261
+ expect(sig()).toBe('val')
262
+ })
263
+ })
@@ -0,0 +1,91 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
2
+ import { _resetRegistry, useSessionStorage } from '../index'
3
+
4
+ describe('useSessionStorage — full coverage', () => {
5
+ beforeEach(() => {
6
+ sessionStorage.clear()
7
+ _resetRegistry()
8
+ })
9
+
10
+ afterEach(() => {
11
+ sessionStorage.clear()
12
+ _resetRegistry()
13
+ })
14
+
15
+ it('returns default value when key is not in storage', () => {
16
+ const val = useSessionStorage('step', 0)
17
+ expect(val()).toBe(0)
18
+ })
19
+
20
+ it('reads existing value from sessionStorage', () => {
21
+ sessionStorage.setItem('step', JSON.stringify(3))
22
+ const val = useSessionStorage('step', 0)
23
+ expect(val()).toBe(3)
24
+ })
25
+
26
+ it('.set() updates signal and sessionStorage', () => {
27
+ const val = useSessionStorage('step', 0)
28
+ val.set(5)
29
+ expect(val()).toBe(5)
30
+ expect(JSON.parse(sessionStorage.getItem('step')!)).toBe(5)
31
+ })
32
+
33
+ it('.update() works', () => {
34
+ const val = useSessionStorage('count', 10)
35
+ val.update((n) => n + 5)
36
+ expect(val()).toBe(15)
37
+ })
38
+
39
+ it('.remove() resets to default and clears sessionStorage', () => {
40
+ const val = useSessionStorage('temp', 'default')
41
+ val.set('modified')
42
+ val.remove()
43
+ expect(val()).toBe('default')
44
+ expect(sessionStorage.getItem('temp')).toBeNull()
45
+ })
46
+
47
+ it('returns same signal for same key', () => {
48
+ const a = useSessionStorage('key', 'val')
49
+ const b = useSessionStorage('key', 'val')
50
+ expect(a).toBe(b)
51
+ })
52
+
53
+ it('handles corrupt storage values gracefully', () => {
54
+ sessionStorage.setItem('broken', 'not valid json{{{')
55
+ const val = useSessionStorage('broken', 'fallback')
56
+ expect(val()).toBe('fallback')
57
+ })
58
+
59
+ it('custom serializer/deserializer', () => {
60
+ const val = useSessionStorage('date', new Date('2025-01-01'), {
61
+ serializer: (d) => d.toISOString(),
62
+ deserializer: (s) => new Date(s),
63
+ })
64
+ expect(val()).toEqual(new Date('2025-01-01'))
65
+ })
66
+
67
+ it('.debug() and .label work', () => {
68
+ const val = useSessionStorage('debug', 'test')
69
+ expect(val.debug().value).toBe('test')
70
+ val.label = 'session-sig'
71
+ expect(val.label).toBe('session-sig')
72
+ })
73
+
74
+ it('.peek() reads without subscribing', () => {
75
+ const val = useSessionStorage('peek', 'hello')
76
+ expect(val.peek()).toBe('hello')
77
+ })
78
+
79
+ it('onError callback is called on deserialization failure', () => {
80
+ sessionStorage.setItem('bad', '{invalid')
81
+ const errors: Error[] = []
82
+ const val = useSessionStorage('bad', 'default', {
83
+ onError: (e) => {
84
+ errors.push(e)
85
+ return 'recovered'
86
+ },
87
+ })
88
+ expect(val()).toBe('recovered')
89
+ expect(errors).toHaveLength(1)
90
+ })
91
+ })
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { deserialize, getWebStorage, serialize } from '../utils'
3
+
4
+ describe('serialize', () => {
5
+ it('uses JSON.stringify by default', () => {
6
+ expect(serialize({ a: 1 })).toBe('{"a":1}')
7
+ })
8
+
9
+ it('uses custom serializer when provided', () => {
10
+ const custom = (v: number) => `NUM:${v}`
11
+ expect(serialize(42, custom)).toBe('NUM:42')
12
+ })
13
+ })
14
+
15
+ describe('deserialize', () => {
16
+ it('uses JSON.parse by default', () => {
17
+ expect(deserialize('"hello"', 'default')).toBe('hello')
18
+ })
19
+
20
+ it('uses custom deserializer when provided', () => {
21
+ const custom = (s: string) => s.toUpperCase()
22
+ expect(deserialize('hello', 'default', custom)).toBe('HELLO')
23
+ })
24
+
25
+ it('returns default value on parse error', () => {
26
+ expect(deserialize('{broken', 'fallback')).toBe('fallback')
27
+ })
28
+
29
+ it('calls onError and returns its result on parse error', () => {
30
+ const onError = (e: Error) => 'recovered'
31
+ expect(deserialize('{broken', 'default', undefined, onError)).toBe('recovered')
32
+ })
33
+
34
+ it('returns default when onError returns undefined', () => {
35
+ const onError = (_e: Error) => undefined
36
+ expect(deserialize('{broken', 'fallback', undefined, onError)).toBe('fallback')
37
+ })
38
+ })
39
+
40
+ describe('getWebStorage', () => {
41
+ it('returns localStorage for "local"', () => {
42
+ const storage = getWebStorage('local')
43
+ expect(storage).toBe(window.localStorage)
44
+ })
45
+
46
+ it('returns sessionStorage for "session"', () => {
47
+ const storage = getWebStorage('session')
48
+ expect(storage).toBe(window.sessionStorage)
49
+ })
50
+
51
+ it('returns null when storage throws (e.g. private browsing)', () => {
52
+ const originalLocalStorage = window.localStorage
53
+ // Mock localStorage to throw on setItem
54
+ Object.defineProperty(window, 'localStorage', {
55
+ value: {
56
+ setItem: () => {
57
+ throw new Error('Quota exceeded')
58
+ },
59
+ removeItem: () => {},
60
+ getItem: () => null,
61
+ },
62
+ writable: true,
63
+ configurable: true,
64
+ })
65
+
66
+ const result = getWebStorage('local')
67
+ expect(result).toBeNull()
68
+
69
+ // Restore
70
+ Object.defineProperty(window, 'localStorage', {
71
+ value: originalLocalStorage,
72
+ writable: true,
73
+ configurable: true,
74
+ })
75
+ })
76
+ })