@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.
- package/CHANGELOG.md +46 -0
- package/README.md +6 -1
- package/dist/devtools/assets/panel-BlDRXgVW.js +32 -0
- package/dist/devtools/assets/panel-DBweZnW5.css +1 -0
- package/dist/devtools/panel.html +2 -2
- package/dist/react-native/chunks/async-storage.require.cjs +1 -1
- package/dist/react-native/chunks/async-storage.require.js +8 -7
- package/dist/react-native/chunks/index.require.cjs +1 -1
- package/dist/react-native/chunks/index.require.js +70 -66
- package/dist/react-native/chunks/secure-storage.require.cjs +1 -1
- package/dist/react-native/chunks/secure-storage.require.js +17 -13
- package/dist/react-native/chunks/useRozeniteStoragePlugin.require.cjs +1 -1
- package/dist/react-native/chunks/useRozeniteStoragePlugin.require.js +863 -344
- package/dist/react-native/index.d.ts +166 -18
- package/dist/rozenite.json +1 -1
- package/dist/sdk/index.cjs +1 -1
- package/dist/sdk/index.d.ts +13 -5
- package/dist/sdk/index.js +21 -12
- package/package.json +35 -37
- package/react-native.ts +12 -22
- package/rozenite.config.ts +74 -0
- package/sdk.ts +1 -5
- package/src/react-native/__tests__/entry-preview-pagination.test.ts +234 -0
- package/src/react-native/__tests__/export-snapshot.test.ts +138 -0
- package/src/react-native/__tests__/full-entry-request.test.ts +143 -0
- package/src/react-native/__tests__/import.test.ts +174 -11
- package/src/react-native/__tests__/storage-discovery.test.ts +144 -0
- package/src/react-native/__tests__/storage-view.test.ts +172 -0
- package/src/react-native/__tests__/stress-verification.test.ts +140 -0
- package/src/react-native/__tests__/use-storage-agent-tools.test.ts +221 -0
- package/src/react-native/adapters/__tests__/mmkv.test.ts +20 -30
- package/src/react-native/adapters/async-storage.ts +8 -9
- package/src/react-native/adapters/index.ts +2 -8
- package/src/react-native/adapters/mmkv.ts +12 -24
- package/src/react-native/adapters/secure-storage.ts +4 -0
- package/src/react-native/entry-preview-pagination.ts +261 -0
- package/src/react-native/export-snapshot.ts +88 -0
- package/src/react-native/full-entry-request.ts +91 -0
- package/src/react-native/import.ts +179 -16
- package/src/react-native/storage-discovery.ts +92 -0
- package/src/react-native/storage-view.ts +47 -109
- package/src/react-native/useRozeniteStoragePlugin.ts +153 -129
- package/src/react-native/useStorageAgentTools.ts +196 -91
- package/src/shared/__tests__/entry-preview.test.ts +99 -0
- package/src/shared/__tests__/snapshot.test.ts +31 -32
- package/src/shared/agent-tools.ts +33 -38
- package/src/shared/entry-preview.ts +54 -0
- package/src/shared/messaging.ts +140 -21
- package/src/shared/snapshot.ts +12 -18
- package/src/shared/types.ts +21 -4
- package/src/ui/__tests__/binary-value-editor-state.test.ts +16 -13
- package/src/ui/__tests__/binary.test.ts +31 -33
- package/src/ui/__tests__/large-value-viewer-state.test.ts +21 -0
- package/src/ui/__tests__/large-value-viewer.test.tsx +54 -0
- package/src/ui/__tests__/panel.test.tsx +382 -0
- package/src/ui/__tests__/storage-groups.test.ts +88 -0
- package/src/ui/__tests__/type-conversion.test.ts +8 -24
- package/src/ui/__tests__/use-storage-entries.test.tsx +337 -0
- package/src/ui/add-entry-dialog.tsx +66 -117
- package/src/ui/binary-value-editor-state.ts +16 -25
- package/src/ui/binary-value-editor.tsx +59 -47
- package/src/ui/binary.ts +54 -15
- package/src/ui/edit-entry-dialog.tsx +68 -104
- package/src/ui/editor-switcher.tsx +9 -16
- package/src/ui/entry-detail-dialog.tsx +62 -181
- package/src/ui/format-value.tsx +25 -0
- package/src/ui/globals.css +1 -123
- package/src/ui/import-dialog.tsx +72 -109
- package/src/ui/large-value-viewer-state.ts +25 -0
- package/src/ui/large-value-viewer.tsx +65 -0
- package/src/ui/panel.tsx +649 -509
- package/src/ui/query-client.tsx +23 -0
- package/src/ui/storage-groups.ts +66 -0
- package/src/ui/type-conversion.ts +2 -5
- package/src/ui/typed-value-editor.tsx +15 -20
- package/src/ui/use-storage-entries.ts +192 -0
- package/src/ui/utils.ts +1 -4
- package/tsconfig.json +5 -1
- package/dist/devtools/assets/panel-Bm-SWF7d.js +0 -33
- package/dist/devtools/assets/panel-DIqI4WSp.css +0 -1
- package/postcss.config.js +0 -6
- package/src/ui/confirm-dialog.tsx +0 -100
- package/src/ui/editable-table.tsx +0 -300
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
+
});
|