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