@rozenite/storage-plugin 1.12.0 → 2.0.0

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.
Files changed (79) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +6 -1
  3. package/dist/devtools/assets/panel-B6Wp0eie.css +1 -0
  4. package/dist/devtools/assets/panel-D7MFc4HG.js +32 -0
  5. package/dist/devtools/panel.html +2 -2
  6. package/dist/react-native/chunks/async-storage.require.cjs +1 -1
  7. package/dist/react-native/chunks/async-storage.require.js +8 -7
  8. package/dist/react-native/chunks/index.require.cjs +1 -1
  9. package/dist/react-native/chunks/index.require.js +70 -64
  10. package/dist/react-native/chunks/secure-storage.require.cjs +1 -1
  11. package/dist/react-native/chunks/secure-storage.require.js +22 -14
  12. package/dist/react-native/chunks/useRozeniteStoragePlugin.require.cjs +1 -1
  13. package/dist/react-native/chunks/useRozeniteStoragePlugin.require.js +863 -315
  14. package/dist/react-native/index.d.ts +154 -18
  15. package/dist/react-native/index.js +10 -2
  16. package/dist/rozenite.json +1 -1
  17. package/dist/sdk/index.cjs +1 -1
  18. package/dist/sdk/index.d.ts +13 -5
  19. package/dist/sdk/index.js +21 -12
  20. package/package.json +13 -15
  21. package/react-native.ts +29 -14
  22. package/rozenite.config.ts +78 -0
  23. package/src/react-native/__tests__/entry-preview-pagination.test.ts +262 -0
  24. package/src/react-native/__tests__/export-snapshot.test.ts +142 -0
  25. package/src/react-native/__tests__/full-entry-request.test.ts +155 -0
  26. package/src/react-native/__tests__/import.test.ts +172 -10
  27. package/src/react-native/__tests__/storage-discovery.test.ts +155 -0
  28. package/src/react-native/__tests__/storage-view.test.ts +172 -0
  29. package/src/react-native/__tests__/stress-verification.test.ts +153 -0
  30. package/src/react-native/__tests__/use-storage-agent-tools.test.ts +231 -0
  31. package/src/react-native/adapters/async-storage.ts +9 -7
  32. package/src/react-native/adapters/mmkv.ts +5 -0
  33. package/src/react-native/adapters/secure-storage.ts +9 -1
  34. package/src/react-native/entry-preview-pagination.ts +277 -0
  35. package/src/react-native/export-snapshot.ts +94 -0
  36. package/src/react-native/full-entry-request.ts +96 -0
  37. package/src/react-native/import.ts +190 -16
  38. package/src/react-native/storage-discovery.ts +96 -0
  39. package/src/react-native/storage-view.ts +45 -103
  40. package/src/react-native/useRozeniteStoragePlugin.ts +108 -72
  41. package/src/react-native/useStorageAgentTools.ts +207 -86
  42. package/src/shared/__tests__/entry-preview.test.ts +117 -0
  43. package/src/shared/__tests__/snapshot.test.ts +21 -0
  44. package/src/shared/agent-tools.ts +27 -11
  45. package/src/shared/entry-preview.ts +59 -0
  46. package/src/shared/messaging.ts +136 -21
  47. package/src/shared/snapshot.ts +8 -1
  48. package/src/shared/types.ts +20 -1
  49. package/src/ui/__tests__/binary-value-editor-state.test.ts +13 -0
  50. package/src/ui/__tests__/binary.test.ts +18 -0
  51. package/src/ui/__tests__/large-value-viewer-state.test.ts +21 -0
  52. package/src/ui/__tests__/large-value-viewer.test.tsx +63 -0
  53. package/src/ui/__tests__/panel.test.tsx +409 -0
  54. package/src/ui/__tests__/storage-groups.test.ts +91 -0
  55. package/src/ui/__tests__/use-storage-entries.test.tsx +353 -0
  56. package/src/ui/add-entry-dialog.tsx +65 -105
  57. package/src/ui/binary-value-editor-state.ts +10 -1
  58. package/src/ui/binary-value-editor.tsx +57 -33
  59. package/src/ui/binary.ts +56 -4
  60. package/src/ui/edit-entry-dialog.tsx +71 -99
  61. package/src/ui/editor-switcher.tsx +7 -5
  62. package/src/ui/entry-detail-dialog.tsx +69 -179
  63. package/src/ui/format-value.tsx +32 -0
  64. package/src/ui/globals.css +1 -123
  65. package/src/ui/import-dialog.tsx +66 -91
  66. package/src/ui/large-value-viewer-state.ts +32 -0
  67. package/src/ui/large-value-viewer.tsx +71 -0
  68. package/src/ui/panel.tsx +724 -496
  69. package/src/ui/query-client.tsx +34 -0
  70. package/src/ui/storage-groups.ts +66 -0
  71. package/src/ui/typed-value-editor.tsx +14 -15
  72. package/src/ui/use-storage-entries.ts +207 -0
  73. package/tsconfig.json +5 -1
  74. package/dist/devtools/assets/panel-Bm-SWF7d.js +0 -33
  75. package/dist/devtools/assets/panel-DIqI4WSp.css +0 -1
  76. package/postcss.config.js +0 -6
  77. package/src/ui/confirm-dialog.tsx +0 -100
  78. package/src/ui/editable-table.tsx +0 -300
  79. package/tailwind.config.ts +0 -94
@@ -0,0 +1,63 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { cleanup, render, screen } from '@testing-library/react';
4
+ import '@testing-library/jest-dom/vitest';
5
+ import {
6
+ TEXT_CHUNK_SIZE,
7
+ VIRTUALIZED_TEXT_THRESHOLD,
8
+ } from '../large-value-viewer-state';
9
+
10
+ vi.mock('react-virtuoso', async () => {
11
+ const React = await import('react');
12
+ return {
13
+ Virtuoso: ({ totalCount, itemContent, ...props }: any) =>
14
+ React.createElement(
15
+ 'div',
16
+ { ...props, 'data-testid': 'virtuoso' },
17
+ Array.from({ length: Math.min(totalCount, 4) }, (_, index) =>
18
+ React.createElement('div', { key: index }, itemContent(index)),
19
+ ),
20
+ ),
21
+ };
22
+ });
23
+
24
+ import { HexdumpValueViewer, TextValueViewer } from '../large-value-viewer';
25
+
26
+ describe('large value viewers', () => {
27
+ afterEach(cleanup);
28
+
29
+ it('renders only virtualized chunks for a large single-line string', () => {
30
+ const value = 'x'.repeat(VIRTUALIZED_TEXT_THRESHOLD + TEXT_CHUNK_SIZE);
31
+ const { container } = render(<TextValueViewer value={value} />);
32
+
33
+ expect(screen.getByTestId('virtuoso')).toBeInTheDocument();
34
+ expect(
35
+ container.querySelectorAll('[data-testid="virtuoso"] > div'),
36
+ ).toHaveLength(4);
37
+ expect(container.textContent).toHaveLength(TEXT_CHUNK_SIZE * 4 + 1);
38
+ expect(container.textContent).not.toBe(value);
39
+ });
40
+
41
+ it('formats only visible hexdump rows for a large buffer', () => {
42
+ const bytes = new Array(16 * 1_000).fill(0x41);
43
+ const { container } = render(<HexdumpValueViewer bytes={bytes} />);
44
+
45
+ expect(screen.getByTestId('virtuoso')).toBeInTheDocument();
46
+ expect(
47
+ container.querySelectorAll('[data-testid="virtuoso"] > div'),
48
+ ).toHaveLength(4);
49
+ expect(container.textContent).toContain('00000000');
50
+ expect(container.textContent).not.toContain('00003e70');
51
+ });
52
+
53
+ it('drops virtualized value markup when its detail interaction closes', () => {
54
+ const { container, unmount } = render(
55
+ <TextValueViewer
56
+ value={'x'.repeat(VIRTUALIZED_TEXT_THRESHOLD + TEXT_CHUNK_SIZE)}
57
+ />,
58
+ );
59
+
60
+ unmount();
61
+ expect(container.textContent).toBe('');
62
+ });
63
+ });
@@ -0,0 +1,409 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, type ReactNode } from 'react';
4
+ import { createRoot } from 'react-dom/client';
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import type { StorageEventMap } from '../../shared/messaging';
7
+ import type { StorageDescriptor, StorageTarget } from '../../shared/types';
8
+
9
+ declare global {
10
+ var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
11
+ }
12
+ type Listener = (payload: unknown) => void;
13
+ type SortingItem = { id: string; desc: boolean };
14
+
15
+ const mocks = vi.hoisted(() => {
16
+ const listeners = new Map<string, Set<Listener>>();
17
+ const send = vi.fn();
18
+ const request = vi.fn();
19
+ const onMessage = vi.fn((type: string, listener: Listener) => {
20
+ const typeListeners = listeners.get(type) ?? new Set<Listener>();
21
+ typeListeners.add(listener);
22
+ listeners.set(type, typeListeners);
23
+ return { remove: () => typeListeners.delete(listener) };
24
+ });
25
+ return {
26
+ client: { send, onMessage, request },
27
+ searchChange: undefined as
28
+ | ((event: React.ChangeEvent<HTMLInputElement>) => void)
29
+ | undefined,
30
+ sortKey: undefined as (() => void) | undefined,
31
+ emit: (type: keyof StorageEventMap, payload: unknown) =>
32
+ listeners.get(type)?.forEach((listener) => listener(payload)),
33
+ reset: () => {
34
+ listeners.clear();
35
+ send.mockReset();
36
+ request.mockReset();
37
+ onMessage.mockClear();
38
+ mocks.searchChange = undefined;
39
+ mocks.sortKey = undefined;
40
+ },
41
+ };
42
+ });
43
+
44
+ vi.mock('@rozenite/plugin-bridge', () => ({
45
+ useRozeniteDevToolsClient: () => mocks.client,
46
+ }));
47
+ vi.mock('@rozenite/ui', async () => {
48
+ const React = await import('react');
49
+ const PassThrough = ({ children }: { children?: ReactNode }) => (
50
+ <>{children}</>
51
+ );
52
+ const PluginShell = Object.assign(PassThrough, { Body: PassThrough });
53
+ const Split = Object.assign(PassThrough, {
54
+ Pane: PassThrough,
55
+ Handle: PassThrough,
56
+ });
57
+ const Sidebar = Object.assign(PassThrough, {
58
+ Group: PassThrough,
59
+ Item: ({
60
+ children,
61
+ onClick,
62
+ trailing,
63
+ }: {
64
+ children?: ReactNode;
65
+ onClick: () => void;
66
+ trailing?: ReactNode;
67
+ }) => (
68
+ <button onClick={onClick}>
69
+ {children}
70
+ {trailing}
71
+ </button>
72
+ ),
73
+ });
74
+ const Toolbar = Object.assign(PassThrough, {
75
+ Group: PassThrough,
76
+ Separator: PassThrough,
77
+ Button: ({
78
+ children,
79
+ onClick,
80
+ disabled,
81
+ 'aria-label': ariaLabel,
82
+ title,
83
+ }: {
84
+ children?: ReactNode;
85
+ onClick: () => void;
86
+ disabled?: boolean;
87
+ 'aria-label'?: string;
88
+ title?: string;
89
+ }) => (
90
+ <button
91
+ onClick={onClick}
92
+ disabled={disabled}
93
+ aria-label={ariaLabel}
94
+ title={title}
95
+ >
96
+ {children}
97
+ </button>
98
+ ),
99
+ });
100
+ return {
101
+ Badge: PassThrough,
102
+ Button: ({
103
+ children,
104
+ onClick,
105
+ }: {
106
+ children?: ReactNode;
107
+ onClick?: () => void;
108
+ }) => <button onClick={onClick}>{children}</button>,
109
+ ConfirmDialog: () => null,
110
+ EmptyState: () => <div />,
111
+ PluginShell,
112
+ SearchField: ({
113
+ value,
114
+ onChange,
115
+ }: {
116
+ value: string;
117
+ onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
118
+ }) => {
119
+ mocks.searchChange = onChange;
120
+ return <input value={value} onChange={onChange} />;
121
+ },
122
+ Sidebar,
123
+ Split,
124
+ Toolbar,
125
+ VirtualizedDataTable: ({
126
+ data,
127
+ onEndReached,
128
+ onRowClick,
129
+ onSortingChange,
130
+ }: {
131
+ data: { key: string }[];
132
+ onEndReached?: () => void;
133
+ onRowClick?: (entry: { key: string }) => void;
134
+ onSortingChange?: (
135
+ updater: (current: SortingItem[]) => SortingItem[],
136
+ ) => void;
137
+ }) => {
138
+ mocks.sortKey = () => {
139
+ onSortingChange?.((current) => {
140
+ const keySort = current.find((item) => item.id === 'key');
141
+ if (!keySort) return [{ id: 'key', desc: false }];
142
+ if (!keySort.desc) return [{ id: 'key', desc: true }];
143
+ return [];
144
+ });
145
+ };
146
+ return (
147
+ <div>
148
+ <button onClick={onEndReached}>reach end</button>
149
+ <button onClick={mocks.sortKey}>sort key</button>
150
+ {data.map((entry) => (
151
+ <button key={entry.key} onClick={() => onRowClick?.(entry)}>
152
+ {entry.key}
153
+ </button>
154
+ ))}
155
+ </div>
156
+ );
157
+ },
158
+ };
159
+ });
160
+ vi.mock('../add-entry-dialog', () => ({ AddEntryDialog: () => null }));
161
+ vi.mock('../edit-entry-dialog', () => ({ EditEntryDialog: () => null }));
162
+ vi.mock('../entry-detail-dialog', () => ({ EntryDetailDialog: () => null }));
163
+ vi.mock('../import-dialog', () => ({ ImportDialog: () => null }));
164
+ vi.mock('../utils', () => ({
165
+ buildExportFilename: vi.fn(() => 'snapshot.json'),
166
+ downloadJson: vi.fn(),
167
+ }));
168
+
169
+ import StoragePanel from '../panel';
170
+
171
+ const target: StorageTarget = { adapterId: 'mmkv', storageId: 'first' };
172
+ const descriptors: StorageDescriptor[] = [
173
+ {
174
+ target,
175
+ adapterName: 'MMKV',
176
+ storageName: 'First',
177
+ entryCount: 1,
178
+ capabilities: { supportedTypes: ['string'] },
179
+ supportsSubscriptions: true,
180
+ },
181
+ ];
182
+ const previewResponse = (cursor?: string) => ({
183
+ type: 'entry-previews' as const,
184
+ requestId: `page-${cursor ?? 'first'}`,
185
+ target,
186
+ items: [
187
+ {
188
+ key: cursor ?? 'first',
189
+ type: 'string' as const,
190
+ preview: cursor ?? 'first',
191
+ valueSize: 5,
192
+ isTruncated: false,
193
+ },
194
+ ],
195
+ nextCursor: cursor ? undefined : 'next',
196
+ });
197
+ const renderPanel = async () => {
198
+ const container = document.createElement('div');
199
+ document.body.append(container);
200
+ const root = createRoot(container);
201
+ await act(async () => root.render(<StoragePanel />));
202
+ return { root, container };
203
+ };
204
+ const discover = async () => {
205
+ const request = mocks.client.send.mock.calls.find(
206
+ ([type]) => type === 'discover-storages',
207
+ )?.[1];
208
+ await act(async () =>
209
+ mocks.emit('storage-descriptors', {
210
+ type: 'storage-descriptors',
211
+ requestId: request.requestId,
212
+ storages: descriptors,
213
+ }),
214
+ );
215
+ };
216
+
217
+ describe('StoragePanel preview query cutover', () => {
218
+ beforeEach(() => {
219
+ mocks.reset();
220
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
221
+ mocks.client.request.mockImplementation(
222
+ ({
223
+ payload,
224
+ }: {
225
+ payload: { type: string; cursor?: string; key?: string };
226
+ }) => {
227
+ if (payload.type === 'list-entry-previews')
228
+ return Promise.resolve(previewResponse(payload.cursor));
229
+ if (payload.type === 'get-entry')
230
+ return Promise.resolve({
231
+ type: 'entry',
232
+ requestId: 'full-1',
233
+ target,
234
+ entry: { key: payload.key, type: 'string', value: 'full value' },
235
+ });
236
+ return Promise.resolve({
237
+ type: 'export-snapshot-result',
238
+ requestId: 'export-1',
239
+ target,
240
+ snapshot: {},
241
+ });
242
+ },
243
+ );
244
+ });
245
+ afterEach(() => {
246
+ document.body.innerHTML = '';
247
+ delete globalThis.IS_REACT_ACT_ENVIRONMENT;
248
+ vi.useRealTimers();
249
+ });
250
+
251
+ it('loads bounded preview pages and guards duplicate edge fetches', async () => {
252
+ const { root, container } = await renderPanel();
253
+ await discover();
254
+ expect(container.textContent).toContain('1');
255
+ await act(async () => {
256
+ await vi.waitFor(() =>
257
+ expect(mocks.client.request).toHaveBeenCalledTimes(1),
258
+ );
259
+ await vi.waitFor(() =>
260
+ expect(
261
+ Array.from(container.querySelectorAll('button')).some(
262
+ (button) => button.textContent === 'first',
263
+ ),
264
+ ).toBe(true),
265
+ );
266
+ });
267
+ await act(async () => {
268
+ const edge = Array.from(container.querySelectorAll('button')).find(
269
+ (button) => button.textContent === 'reach end',
270
+ );
271
+ edge?.click();
272
+ edge?.click();
273
+ });
274
+ await vi.waitFor(() =>
275
+ expect(mocks.client.request).toHaveBeenCalledTimes(2),
276
+ );
277
+ expect(
278
+ mocks.client.request.mock.calls.map(
279
+ ([options]) => options.payload.cursor,
280
+ ),
281
+ ).toEqual([undefined, 'next']);
282
+ await act(async () => root.unmount());
283
+ });
284
+
285
+ it('debounces search into a new device-owned query sequence', async () => {
286
+ vi.useFakeTimers();
287
+ const { root, container } = await renderPanel();
288
+ await discover();
289
+ await act(async () => {
290
+ await vi.waitFor(() => expect(container.textContent).toContain('first'));
291
+ });
292
+ await act(async () => {
293
+ mocks.searchChange?.({
294
+ target: { value: ' Needle ' },
295
+ } as React.ChangeEvent<HTMLInputElement>);
296
+ vi.advanceTimersByTime(250);
297
+ });
298
+ await vi.waitFor(() =>
299
+ expect(mocks.client.request).toHaveBeenCalledTimes(2),
300
+ );
301
+ expect(mocks.client.request.mock.calls[1][0].payload).toMatchObject({
302
+ cursor: undefined,
303
+ search: 'needle',
304
+ });
305
+ await act(async () => root.unmount());
306
+ });
307
+
308
+ it('cycles Key sorting back to ascending after descending', async () => {
309
+ const { root, container } = await renderPanel();
310
+ await discover();
311
+ await act(async () => {
312
+ await vi.waitFor(() => expect(container.textContent).toContain('first'));
313
+ });
314
+ const sortKey = () => {
315
+ if (!mocks.sortKey) throw new Error('Sort control is not mounted.');
316
+ mocks.sortKey();
317
+ };
318
+
319
+ await act(async () => sortKey());
320
+ await vi.waitFor(() =>
321
+ expect(mocks.client.request).toHaveBeenCalledTimes(2),
322
+ );
323
+ expect(mocks.client.request.mock.calls[1][0].payload).toMatchObject({
324
+ keySortDirection: 'descending',
325
+ });
326
+
327
+ await act(async () => sortKey());
328
+ await vi.waitFor(() =>
329
+ expect(mocks.client.request).toHaveBeenCalledTimes(3),
330
+ );
331
+ expect(mocks.client.request.mock.calls[2][0].payload).toMatchObject({
332
+ keySortDirection: 'ascending',
333
+ });
334
+
335
+ await act(async () => sortKey());
336
+ await vi.waitFor(() =>
337
+ expect(mocks.client.request).toHaveBeenCalledTimes(4),
338
+ );
339
+ expect(mocks.client.request.mock.calls[3][0].payload).toMatchObject({
340
+ keySortDirection: 'descending',
341
+ });
342
+ await act(async () => root.unmount());
343
+ });
344
+
345
+ it('fetches a full value only when a preview row opens details', async () => {
346
+ const { root, container } = await renderPanel();
347
+ await discover();
348
+ await vi.waitFor(() => expect(container.textContent).toContain('first'));
349
+ await act(async () =>
350
+ Array.from(container.querySelectorAll('button'))
351
+ .find((button) => button.textContent === 'first')
352
+ ?.click(),
353
+ );
354
+ await vi.waitFor(() =>
355
+ expect(mocks.client.request.mock.calls[1][0].payload).toMatchObject({
356
+ type: 'get-entry',
357
+ key: 'first',
358
+ }),
359
+ );
360
+ await act(async () => root.unmount());
361
+ });
362
+
363
+ it('refreshes from the first page without changing the query', async () => {
364
+ const { root, container } = await renderPanel();
365
+ await discover();
366
+ await act(async () => {
367
+ await vi.waitFor(() => expect(container.textContent).toContain('first'));
368
+ });
369
+ await act(async () =>
370
+ Array.from(container.querySelectorAll('button'))
371
+ .find(
372
+ (button) => button.getAttribute('aria-label') === 'Refresh storage',
373
+ )
374
+ ?.click(),
375
+ );
376
+ await vi.waitFor(() =>
377
+ expect(mocks.client.request).toHaveBeenCalledTimes(2),
378
+ );
379
+ expect(
380
+ mocks.client.request.mock.calls[1][0].payload.cursor,
381
+ ).toBeUndefined();
382
+ await act(async () => root.unmount());
383
+ });
384
+
385
+ it('coalesces invalidation events into one active preview refetch', async () => {
386
+ const { root } = await renderPanel();
387
+ await discover();
388
+ await act(async () => {
389
+ await vi.waitFor(() =>
390
+ expect(mocks.client.request).toHaveBeenCalledTimes(1),
391
+ );
392
+ mocks.emit('storage-invalidated', {
393
+ type: 'storage-invalidated',
394
+ target,
395
+ key: 'first',
396
+ operation: 'set',
397
+ });
398
+ mocks.emit('storage-invalidated', {
399
+ type: 'storage-invalidated',
400
+ target,
401
+ key: 'next',
402
+ });
403
+ });
404
+ await vi.waitFor(() =>
405
+ expect(mocks.client.request).toHaveBeenCalledTimes(2),
406
+ );
407
+ await act(async () => root.unmount());
408
+ });
409
+ });
@@ -0,0 +1,91 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ buildStorageSidebarGroups,
4
+ parseStorageViewId,
5
+ type StorageSnapshotEntry,
6
+ } from '../storage-groups';
7
+
8
+ const entry = (
9
+ adapterId: string,
10
+ storageId: string,
11
+ adapterName: string,
12
+ storageName: string,
13
+ entryCount: number,
14
+ ): StorageSnapshotEntry => ({
15
+ target: { adapterId, storageId },
16
+ adapterName,
17
+ storageName,
18
+ entryCount,
19
+ });
20
+
21
+ describe('buildStorageSidebarGroups', () => {
22
+ it('groups snapshots by adapterId, using adapterName as the group label', () => {
23
+ const snapshots = new Map([
24
+ ['mmkv:default', entry('mmkv', 'default', 'MMKV', 'default', 3)],
25
+ ['mmkv:user', entry('mmkv', 'user', 'MMKV', 'user', 1)],
26
+ [
27
+ 'async-storage:default',
28
+ entry('async-storage', 'default', 'AsyncStorage', 'default', 5),
29
+ ],
30
+ ]);
31
+
32
+ expect(buildStorageSidebarGroups(snapshots)).toEqual([
33
+ {
34
+ adapterId: 'mmkv',
35
+ adapterName: 'MMKV',
36
+ items: [
37
+ { viewId: 'mmkv:default', storageName: 'default', entryCount: 3 },
38
+ { viewId: 'mmkv:user', storageName: 'user', entryCount: 1 },
39
+ ],
40
+ },
41
+ {
42
+ adapterId: 'async-storage',
43
+ adapterName: 'AsyncStorage',
44
+ items: [
45
+ {
46
+ viewId: 'async-storage:default',
47
+ storageName: 'default',
48
+ entryCount: 5,
49
+ },
50
+ ],
51
+ },
52
+ ]);
53
+ });
54
+
55
+ it('preserves insertion order of both groups and items within a group', () => {
56
+ const snapshots = new Map([
57
+ ['b:1', entry('b', '1', 'B', '1', 0)],
58
+ ['a:1', entry('a', '1', 'A', '1', 0)],
59
+ ['b:2', entry('b', '2', 'B', '2', 0)],
60
+ ]);
61
+
62
+ const groups = buildStorageSidebarGroups(snapshots);
63
+
64
+ expect(groups.map((g) => g.adapterId)).toEqual(['b', 'a']);
65
+ expect(groups[0].items.map((i) => i.viewId)).toEqual(['b:1', 'b:2']);
66
+ });
67
+
68
+ it('returns an empty array for an empty map', () => {
69
+ expect(buildStorageSidebarGroups(new Map())).toEqual([]);
70
+ });
71
+ });
72
+
73
+ describe('parseStorageViewId', () => {
74
+ it('splits at the first colon', () => {
75
+ expect(parseStorageViewId('mmkv:default')).toEqual({
76
+ adapterId: 'mmkv',
77
+ storageId: 'default',
78
+ });
79
+ });
80
+
81
+ it('treats any further colons as part of the storageId', () => {
82
+ expect(parseStorageViewId('mmkv:namespace:default')).toEqual({
83
+ adapterId: 'mmkv',
84
+ storageId: 'namespace:default',
85
+ });
86
+ });
87
+
88
+ it('returns null when there is no colon', () => {
89
+ expect(parseStorageViewId('invalid')).toBeNull();
90
+ });
91
+ });