@rozenite/storage-plugin 2.0.0 → 2.2.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 (69) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/devtools/assets/panel-D5z1BY8U.js +32 -0
  3. package/dist/devtools/assets/panel-DVlWRg6d.css +1 -0
  4. package/dist/devtools/panel.html +2 -2
  5. package/dist/react-native/chunks/index.require.js +1 -3
  6. package/dist/react-native/chunks/secure-storage.require.js +2 -6
  7. package/dist/react-native/chunks/useRozeniteStoragePlugin.require.cjs +1 -1
  8. package/dist/react-native/chunks/useRozeniteStoragePlugin.require.js +266 -295
  9. package/dist/react-native/index.d.ts +14 -2
  10. package/dist/react-native/index.js +2 -10
  11. package/dist/rozenite.json +1 -1
  12. package/package.json +29 -29
  13. package/react-native.ts +11 -36
  14. package/rozenite.config.ts +2 -6
  15. package/sdk.ts +1 -5
  16. package/src/react-native/__tests__/entry-preview-pagination.test.ts +12 -40
  17. package/src/react-native/__tests__/export-snapshot.test.ts +2 -6
  18. package/src/react-native/__tests__/full-entry-request.test.ts +5 -17
  19. package/src/react-native/__tests__/import.test.ts +9 -8
  20. package/src/react-native/__tests__/storage-discovery.test.ts +7 -18
  21. package/src/react-native/__tests__/stress-verification.test.ts +5 -18
  22. package/src/react-native/__tests__/use-storage-agent-tools.test.ts +25 -35
  23. package/src/react-native/adapters/__tests__/mmkv.test.ts +20 -30
  24. package/src/react-native/adapters/async-storage.ts +2 -5
  25. package/src/react-native/adapters/index.ts +2 -8
  26. package/src/react-native/adapters/mmkv.ts +7 -24
  27. package/src/react-native/adapters/secure-storage.ts +2 -6
  28. package/src/react-native/entry-preview-pagination.ts +11 -27
  29. package/src/react-native/export-snapshot.ts +3 -9
  30. package/src/react-native/full-entry-request.ts +3 -8
  31. package/src/react-native/import.ts +10 -21
  32. package/src/react-native/storage-discovery.ts +2 -6
  33. package/src/react-native/storage-view.ts +2 -6
  34. package/src/react-native/useRozeniteStoragePlugin.ts +107 -119
  35. package/src/react-native/useStorageAgentTools.ts +8 -24
  36. package/src/shared/__tests__/entry-preview.test.ts +9 -27
  37. package/src/shared/__tests__/snapshot.test.ts +10 -32
  38. package/src/shared/agent-tools.ts +9 -30
  39. package/src/shared/entry-preview.ts +3 -8
  40. package/src/shared/messaging.ts +15 -11
  41. package/src/shared/snapshot.ts +4 -17
  42. package/src/shared/types.ts +2 -4
  43. package/src/ui/__tests__/binary-value-editor-state.test.ts +6 -16
  44. package/src/ui/__tests__/binary.test.ts +13 -33
  45. package/src/ui/__tests__/large-value-viewer.test.tsx +4 -13
  46. package/src/ui/__tests__/panel.test.tsx +50 -63
  47. package/src/ui/__tests__/storage-groups.test.ts +1 -4
  48. package/src/ui/__tests__/type-conversion.test.ts +8 -24
  49. package/src/ui/__tests__/use-storage-entries.test.tsx +18 -34
  50. package/src/ui/add-entry-dialog.tsx +66 -87
  51. package/src/ui/binary-value-editor-state.ts +6 -24
  52. package/src/ui/binary-value-editor.tsx +6 -18
  53. package/src/ui/binary.ts +6 -19
  54. package/src/ui/edit-entry-dialog.tsx +59 -78
  55. package/src/ui/editor-switcher.tsx +2 -11
  56. package/src/ui/entry-detail-dialog.tsx +9 -16
  57. package/src/ui/format-value.tsx +2 -9
  58. package/src/ui/import-dialog.tsx +12 -24
  59. package/src/ui/large-value-viewer-state.ts +2 -9
  60. package/src/ui/large-value-viewer.tsx +3 -9
  61. package/src/ui/panel.tsx +129 -235
  62. package/src/ui/query-client.tsx +4 -15
  63. package/src/ui/type-conversion.ts +2 -5
  64. package/src/ui/typed-value-editor.tsx +1 -5
  65. package/src/ui/use-storage-entries.ts +6 -21
  66. package/src/ui/utils.ts +1 -4
  67. package/tsconfig.json +4 -7
  68. package/dist/devtools/assets/panel-B6Wp0eie.css +0 -1
  69. package/dist/devtools/assets/panel-D7MFc4HG.js +0 -32
@@ -1,9 +1,4 @@
1
- import type {
2
- StorageDescriptor,
3
- StorageEntry,
4
- StorageEntryPreview,
5
- StorageTarget,
6
- } from './types';
1
+ import type { StorageDescriptor, StorageEntry, StorageEntryPreview, StorageTarget } from './types';
7
2
  import type { ImportPreview, StorageSnapshotV1 } from './snapshot';
8
3
 
9
4
  export type StorageSetEntryEvent = {
@@ -23,11 +18,7 @@ export type StoragePurgeEvent = {
23
18
  target: StorageTarget;
24
19
  };
25
20
 
26
- export type StorageInvalidationOperation =
27
- | 'set'
28
- | 'delete'
29
- | 'import'
30
- | 'purge';
21
+ export type StorageInvalidationOperation = 'set' | 'delete' | 'import' | 'purge';
31
22
 
32
23
  export type StorageInvalidatedEvent = {
33
24
  type: 'storage-invalidated';
@@ -35,6 +26,18 @@ export type StorageInvalidatedEvent = {
35
26
  /** Native subscriptions only expose a key, not the operation. */
36
27
  key?: string;
37
28
  operation?: StorageInvalidationOperation;
29
+ /** Updated entry count for the storage after the invalidation. */
30
+ entryCount: number;
31
+ };
32
+
33
+ /**
34
+ * Announced by the device once it is listening for panel requests. The panel is
35
+ * recreated on every app reload and asks for storages immediately, which can
36
+ * land before the app's React tree has mounted the plugin; this lets the panel
37
+ * ask again instead of waiting forever on a dropped request.
38
+ */
39
+ export type StorageDeviceReadyEvent = {
40
+ type: 'device-ready';
38
41
  };
39
42
 
40
43
  export type StorageDiscoverStoragesRequestEvent = {
@@ -156,6 +159,7 @@ export type StorageEvent =
156
159
  | StorageDeleteEntryEvent
157
160
  | StoragePurgeEvent
158
161
  | StorageInvalidatedEvent
162
+ | StorageDeviceReadyEvent
159
163
  | StorageDiscoverStoragesRequestEvent
160
164
  | StorageDiscoverStoragesResponseEvent
161
165
  | StorageListEntryPreviewsRequestEvent
@@ -48,10 +48,7 @@ const describe = (value: unknown): string => {
48
48
  const isPlainObject = (value: unknown): value is Record<string, unknown> =>
49
49
  typeof value === 'object' && value !== null && !Array.isArray(value);
50
50
 
51
- const expectObject = (
52
- value: unknown,
53
- path: string,
54
- ): Record<string, unknown> => {
51
+ const expectObject = (value: unknown, path: string): Record<string, unknown> => {
55
52
  if (!isPlainObject(value)) {
56
53
  throw new ParseException(path, `Expected object, got ${describe(value)}`);
57
54
  }
@@ -73,10 +70,7 @@ const expectArray = (value: unknown, path: string): unknown[] => {
73
70
  };
74
71
 
75
72
  const expectEntryType = (value: unknown, path: string): StorageEntryType => {
76
- if (
77
- typeof value !== 'string' ||
78
- !DEFAULT_SUPPORTED_TYPES.includes(value as StorageEntryType)
79
- ) {
73
+ if (typeof value !== 'string' || !DEFAULT_SUPPORTED_TYPES.includes(value as StorageEntryType)) {
80
74
  throw new ParseException(
81
75
  path,
82
76
  `Expected one of ${DEFAULT_SUPPORTED_TYPES.join(', ')}, got ${describe(value)}`,
@@ -151,12 +145,7 @@ const parseEntry = (raw: unknown, path: string): StorageEntry => {
151
145
  );
152
146
  }
153
147
  const bytes = value.map((byte, index) => {
154
- if (
155
- typeof byte !== 'number' ||
156
- !Number.isInteger(byte) ||
157
- byte < 0 ||
158
- byte > 255
159
- ) {
148
+ if (typeof byte !== 'number' || !Number.isInteger(byte) || byte < 0 || byte > 255) {
160
149
  throw new ParseException(
161
150
  `${valuePath}[${index}]`,
162
151
  `Expected uint8 (integer 0-255), got ${describe(byte)}`,
@@ -183,9 +172,7 @@ export const parseSnapshot = (raw: unknown): ParseResult => {
183
172
  const createdAt = expectString(root.createdAt, 'createdAt');
184
173
  const storage = parseStorageMeta(root.storage);
185
174
  const rawEntries = expectArray(root.entries, 'entries');
186
- const entries = rawEntries.map((entry, index) =>
187
- parseEntry(entry, `entries[${index}]`),
188
- );
175
+ const entries = rawEntries.map((entry, index) => parseEntry(entry, `entries[${index}]`));
189
176
  return {
190
177
  ok: true,
191
178
  snapshot: {
@@ -80,7 +80,5 @@ export const DEFAULT_SUPPORTED_TYPES: StorageEntryType[] = [
80
80
  export const getStorageViewId = ({ adapterId, storageId }: StorageTarget) =>
81
81
  `${adapterId}:${storageId}`;
82
82
 
83
- export const supportsType = (
84
- capabilities: StorageCapabilities,
85
- type: StorageEntryType,
86
- ) => capabilities.supportedTypes.includes(type);
83
+ export const supportsType = (capabilities: StorageCapabilities, type: StorageEntryType) =>
84
+ capabilities.supportedTypes.includes(type);
@@ -1,10 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import {
3
- initialState,
4
- reduce,
5
- validate,
6
- type EditorState,
7
- } from '../binary-value-editor-state';
2
+ import { initialState, reduce, validate, type EditorState } from '../binary-value-editor-state';
8
3
 
9
4
  const hexState = (bytes?: number[]): EditorState =>
10
5
  initialState({ initialBytes: bytes, mode: 'hex' });
@@ -29,9 +24,7 @@ describe('initialState', () => {
29
24
  });
30
25
 
31
26
  it('encodes initial bytes as base64 when mode is base64', () => {
32
- expect(
33
- initialState({ initialBytes: [0x48, 0x69], mode: 'base64' }),
34
- ).toEqual({
27
+ expect(initialState({ initialBytes: [0x48, 0x69], mode: 'base64' })).toEqual({
35
28
  mode: 'base64',
36
29
  text: 'SGk=',
37
30
  bytes: [0x48, 0x69],
@@ -101,13 +94,12 @@ describe('reduce: set-text', () => {
101
94
 
102
95
  describe('reduce: normalize-paste', () => {
103
96
  it('rewrites pasted hexdump into canonical grouped hex', () => {
104
- const pasted =
105
- '00000000 89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52 |.PNG........IHDR|';
97
+ const pasted = '00000000 89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52 |.PNG........IHDR|';
106
98
  const next = reduce(hexState(), { type: 'normalize-paste', text: pasted });
107
99
  expect(next.text).toBe('89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52');
108
100
  expect(next.bytes).toEqual([
109
- 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
110
- 0x49, 0x48, 0x44, 0x52,
101
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
102
+ 0x52,
111
103
  ]);
112
104
  expect(next.error).toBeNull();
113
105
  });
@@ -178,9 +170,7 @@ describe('reduce: switch-mode', () => {
178
170
 
179
171
  describe('reduce: replace-bytes', () => {
180
172
  it('formats fetched bytes after the editor has had a chance to paint', () => {
181
- expect(
182
- reduce(hexState(), { type: 'replace-bytes', bytes: [0x48, 0x69] }),
183
- ).toEqual({
173
+ expect(reduce(hexState(), { type: 'replace-bytes', bytes: [0x48, 0x69] })).toEqual({
184
174
  mode: 'hex',
185
175
  text: '48 69',
186
176
  bytes: [0x48, 0x69],
@@ -26,8 +26,8 @@ describe('bytesToGroupedHex', () => {
26
26
  it('inserts a double-space gap between bytes 8 and 9', () => {
27
27
  expect(
28
28
  bytesToGroupedHex([
29
- 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
30
- 0x49, 0x48, 0x44, 0x52,
29
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
30
+ 0x52,
31
31
  ]),
32
32
  ).toBe('89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52');
33
33
  });
@@ -44,17 +44,7 @@ describe('bytesToGroupedHex', () => {
44
44
 
45
45
  describe('bytesToHexdump', () => {
46
46
  it('renders offset, grouped hex, and ASCII column', () => {
47
- const bytes = [
48
- ...PNG_SIGNATURE,
49
- 0x00,
50
- 0x00,
51
- 0x00,
52
- 0x0d,
53
- 0x49,
54
- 0x48,
55
- 0x44,
56
- 0x52,
57
- ];
47
+ const bytes = [...PNG_SIGNATURE, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52];
58
48
  expect(bytesToHexdump(bytes)).toBe(
59
49
  '00000000 89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52 |.PNG........IHDR|',
60
50
  );
@@ -71,9 +61,7 @@ describe('bytesToHexdump', () => {
71
61
  const bytes = [0x41, 0x42, 0x43];
72
62
  const line = bytesToHexdump(bytes);
73
63
  // Hex section width must be exactly 48 chars before the " |..." block.
74
- expect(line).toBe(
75
- '00000000 41 42 43 |ABC|',
76
- );
64
+ expect(line).toBe('00000000 41 42 43 |ABC|');
77
65
  });
78
66
 
79
67
  it('renders non-printable bytes as dots in the ASCII column', () => {
@@ -159,21 +147,15 @@ describe('compactBufferPreview', () => {
159
147
  });
160
148
 
161
149
  it('omits the ellipsis when the buffer fits within maxBytes', () => {
162
- expect(compactBufferPreview([0x89, 0x50, 0x4e, 0x47])).toBe(
163
- '89 50 4E 47 4 B',
164
- );
150
+ expect(compactBufferPreview([0x89, 0x50, 0x4e, 0x47])).toBe('89 50 4E 47 4 B');
165
151
  });
166
152
 
167
153
  it('includes an ellipsis when truncated', () => {
168
- expect(compactBufferPreview(new Array(128).fill(0xab))).toBe(
169
- 'AB AB AB AB AB AB AB AB … 128 B',
170
- );
154
+ expect(compactBufferPreview(new Array(128).fill(0xab))).toBe('AB AB AB AB AB AB AB AB … 128 B');
171
155
  });
172
156
 
173
157
  it('respects a custom maxBytes', () => {
174
- expect(compactBufferPreview([0x01, 0x02, 0x03], { maxBytes: 2 })).toBe(
175
- '01 02 … 3 B',
176
- );
158
+ expect(compactBufferPreview([0x01, 0x02, 0x03], { maxBytes: 2 })).toBe('01 02 … 3 B');
177
159
  });
178
160
  });
179
161
 
@@ -194,14 +176,12 @@ describe('hexInputToBytes', () => {
194
176
 
195
177
  it('parses multiline grouped hex', () => {
196
178
  expect(
197
- hexInputToBytes(
198
- '89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52\n00 00 00 20 00 00 00 20',
199
- ),
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'),
200
180
  ).toEqual({
201
181
  ok: true,
202
182
  value: [
203
- 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
204
- 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,
205
185
  ],
206
186
  });
207
187
  });
@@ -214,9 +194,9 @@ describe('hexInputToBytes', () => {
214
194
  expect(hexInputToBytes(pasted)).toEqual({
215
195
  ok: true,
216
196
  value: [
217
- 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
218
- 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x20,
219
- 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,
220
200
  ],
221
201
  });
222
202
  });
@@ -2,10 +2,7 @@
2
2
  import { afterEach, describe, expect, it, vi } from 'vitest';
3
3
  import { cleanup, render, screen } from '@testing-library/react';
4
4
  import '@testing-library/jest-dom/vitest';
5
- import {
6
- TEXT_CHUNK_SIZE,
7
- VIRTUALIZED_TEXT_THRESHOLD,
8
- } from '../large-value-viewer-state';
5
+ import { TEXT_CHUNK_SIZE, VIRTUALIZED_TEXT_THRESHOLD } from '../large-value-viewer-state';
9
6
 
10
7
  vi.mock('react-virtuoso', async () => {
11
8
  const React = await import('react');
@@ -31,9 +28,7 @@ describe('large value viewers', () => {
31
28
  const { container } = render(<TextValueViewer value={value} />);
32
29
 
33
30
  expect(screen.getByTestId('virtuoso')).toBeInTheDocument();
34
- expect(
35
- container.querySelectorAll('[data-testid="virtuoso"] > div'),
36
- ).toHaveLength(4);
31
+ expect(container.querySelectorAll('[data-testid="virtuoso"] > div')).toHaveLength(4);
37
32
  expect(container.textContent).toHaveLength(TEXT_CHUNK_SIZE * 4 + 1);
38
33
  expect(container.textContent).not.toBe(value);
39
34
  });
@@ -43,18 +38,14 @@ describe('large value viewers', () => {
43
38
  const { container } = render(<HexdumpValueViewer bytes={bytes} />);
44
39
 
45
40
  expect(screen.getByTestId('virtuoso')).toBeInTheDocument();
46
- expect(
47
- container.querySelectorAll('[data-testid="virtuoso"] > div'),
48
- ).toHaveLength(4);
41
+ expect(container.querySelectorAll('[data-testid="virtuoso"] > div')).toHaveLength(4);
49
42
  expect(container.textContent).toContain('00000000');
50
43
  expect(container.textContent).not.toContain('00003e70');
51
44
  });
52
45
 
53
46
  it('drops virtualized value markup when its detail interaction closes', () => {
54
47
  const { container, unmount } = render(
55
- <TextValueViewer
56
- value={'x'.repeat(VIRTUALIZED_TEXT_THRESHOLD + TEXT_CHUNK_SIZE)}
57
- />,
48
+ <TextValueViewer value={'x'.repeat(VIRTUALIZED_TEXT_THRESHOLD + TEXT_CHUNK_SIZE)} />,
58
49
  );
59
50
 
60
51
  unmount();
@@ -24,9 +24,7 @@ const mocks = vi.hoisted(() => {
24
24
  });
25
25
  return {
26
26
  client: { send, onMessage, request },
27
- searchChange: undefined as
28
- | ((event: React.ChangeEvent<HTMLInputElement>) => void)
29
- | undefined,
27
+ searchChange: undefined as ((event: React.ChangeEvent<HTMLInputElement>) => void) | undefined,
30
28
  sortKey: undefined as (() => void) | undefined,
31
29
  emit: (type: keyof StorageEventMap, payload: unknown) =>
32
30
  listeners.get(type)?.forEach((listener) => listener(payload)),
@@ -46,9 +44,7 @@ vi.mock('@rozenite/plugin-bridge', () => ({
46
44
  }));
47
45
  vi.mock('@rozenite/ui', async () => {
48
46
  const React = await import('react');
49
- const PassThrough = ({ children }: { children?: ReactNode }) => (
50
- <>{children}</>
51
- );
47
+ const PassThrough = ({ children }: { children?: ReactNode }) => <>{children}</>;
52
48
  const PluginShell = Object.assign(PassThrough, { Body: PassThrough });
53
49
  const Split = Object.assign(PassThrough, {
54
50
  Pane: PassThrough,
@@ -87,28 +83,33 @@ vi.mock('@rozenite/ui', async () => {
87
83
  'aria-label'?: string;
88
84
  title?: string;
89
85
  }) => (
90
- <button
91
- onClick={onClick}
92
- disabled={disabled}
93
- aria-label={ariaLabel}
94
- title={title}
95
- >
86
+ <button onClick={onClick} disabled={disabled} aria-label={ariaLabel} title={title}>
96
87
  {children}
97
88
  </button>
98
89
  ),
99
90
  });
100
91
  return {
101
92
  Badge: PassThrough,
102
- Button: ({
93
+ Button: ({ children, onClick }: { children?: ReactNode; onClick?: () => void }) => (
94
+ <button onClick={onClick}>{children}</button>
95
+ ),
96
+ IconButton: ({
103
97
  children,
104
98
  onClick,
99
+ label,
105
100
  }: {
106
101
  children?: ReactNode;
107
102
  onClick?: () => void;
108
- }) => <button onClick={onClick}>{children}</button>,
103
+ label: string;
104
+ }) => (
105
+ <button onClick={onClick} aria-label={label}>
106
+ {children}
107
+ </button>
108
+ ),
109
109
  ConfirmDialog: () => null,
110
110
  EmptyState: () => <div />,
111
111
  PluginShell,
112
+ useConfirmDialog: () => async () => true,
112
113
  SearchField: ({
113
114
  value,
114
115
  onChange,
@@ -131,9 +132,7 @@ vi.mock('@rozenite/ui', async () => {
131
132
  data: { key: string }[];
132
133
  onEndReached?: () => void;
133
134
  onRowClick?: (entry: { key: string }) => void;
134
- onSortingChange?: (
135
- updater: (current: SortingItem[]) => SortingItem[],
136
- ) => void;
135
+ onSortingChange?: (updater: (current: SortingItem[]) => SortingItem[]) => void;
137
136
  }) => {
138
137
  mocks.sortKey = () => {
139
138
  onSortingChange?.((current) => {
@@ -201,10 +200,10 @@ const renderPanel = async () => {
201
200
  await act(async () => root.render(<StoragePanel />));
202
201
  return { root, container };
203
202
  };
203
+ const discoveryRequests = () =>
204
+ mocks.client.send.mock.calls.filter(([type]) => type === 'discover-storages');
204
205
  const discover = async () => {
205
- const request = mocks.client.send.mock.calls.find(
206
- ([type]) => type === 'discover-storages',
207
- )?.[1];
206
+ const request = discoveryRequests().at(-1)?.[1];
208
207
  await act(async () =>
209
208
  mocks.emit('storage-descriptors', {
210
209
  type: 'storage-descriptors',
@@ -219,11 +218,7 @@ describe('StoragePanel preview query cutover', () => {
219
218
  mocks.reset();
220
219
  globalThis.IS_REACT_ACT_ENVIRONMENT = true;
221
220
  mocks.client.request.mockImplementation(
222
- ({
223
- payload,
224
- }: {
225
- payload: { type: string; cursor?: string; key?: string };
226
- }) => {
221
+ ({ payload }: { payload: { type: string; cursor?: string; key?: string } }) => {
227
222
  if (payload.type === 'list-entry-previews')
228
223
  return Promise.resolve(previewResponse(payload.cursor));
229
224
  if (payload.type === 'get-entry')
@@ -253,9 +248,7 @@ describe('StoragePanel preview query cutover', () => {
253
248
  await discover();
254
249
  expect(container.textContent).toContain('1');
255
250
  await act(async () => {
256
- await vi.waitFor(() =>
257
- expect(mocks.client.request).toHaveBeenCalledTimes(1),
258
- );
251
+ await vi.waitFor(() => expect(mocks.client.request).toHaveBeenCalledTimes(1));
259
252
  await vi.waitFor(() =>
260
253
  expect(
261
254
  Array.from(container.querySelectorAll('button')).some(
@@ -271,14 +264,11 @@ describe('StoragePanel preview query cutover', () => {
271
264
  edge?.click();
272
265
  edge?.click();
273
266
  });
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']);
267
+ await vi.waitFor(() => expect(mocks.client.request).toHaveBeenCalledTimes(2));
268
+ expect(mocks.client.request.mock.calls.map(([options]) => options.payload.cursor)).toEqual([
269
+ undefined,
270
+ 'next',
271
+ ]);
282
272
  await act(async () => root.unmount());
283
273
  });
284
274
 
@@ -295,9 +285,7 @@ describe('StoragePanel preview query cutover', () => {
295
285
  } as React.ChangeEvent<HTMLInputElement>);
296
286
  vi.advanceTimersByTime(250);
297
287
  });
298
- await vi.waitFor(() =>
299
- expect(mocks.client.request).toHaveBeenCalledTimes(2),
300
- );
288
+ await vi.waitFor(() => expect(mocks.client.request).toHaveBeenCalledTimes(2));
301
289
  expect(mocks.client.request.mock.calls[1][0].payload).toMatchObject({
302
290
  cursor: undefined,
303
291
  search: 'needle',
@@ -317,25 +305,19 @@ describe('StoragePanel preview query cutover', () => {
317
305
  };
318
306
 
319
307
  await act(async () => sortKey());
320
- await vi.waitFor(() =>
321
- expect(mocks.client.request).toHaveBeenCalledTimes(2),
322
- );
308
+ await vi.waitFor(() => expect(mocks.client.request).toHaveBeenCalledTimes(2));
323
309
  expect(mocks.client.request.mock.calls[1][0].payload).toMatchObject({
324
310
  keySortDirection: 'descending',
325
311
  });
326
312
 
327
313
  await act(async () => sortKey());
328
- await vi.waitFor(() =>
329
- expect(mocks.client.request).toHaveBeenCalledTimes(3),
330
- );
314
+ await vi.waitFor(() => expect(mocks.client.request).toHaveBeenCalledTimes(3));
331
315
  expect(mocks.client.request.mock.calls[2][0].payload).toMatchObject({
332
316
  keySortDirection: 'ascending',
333
317
  });
334
318
 
335
319
  await act(async () => sortKey());
336
- await vi.waitFor(() =>
337
- expect(mocks.client.request).toHaveBeenCalledTimes(4),
338
- );
320
+ await vi.waitFor(() => expect(mocks.client.request).toHaveBeenCalledTimes(4));
339
321
  expect(mocks.client.request.mock.calls[3][0].payload).toMatchObject({
340
322
  keySortDirection: 'descending',
341
323
  });
@@ -368,17 +350,11 @@ describe('StoragePanel preview query cutover', () => {
368
350
  });
369
351
  await act(async () =>
370
352
  Array.from(container.querySelectorAll('button'))
371
- .find(
372
- (button) => button.getAttribute('aria-label') === 'Refresh storage',
373
- )
353
+ .find((button) => button.getAttribute('aria-label') === 'Refresh storage')
374
354
  ?.click(),
375
355
  );
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();
356
+ await vi.waitFor(() => expect(mocks.client.request).toHaveBeenCalledTimes(2));
357
+ expect(mocks.client.request.mock.calls[1][0].payload.cursor).toBeUndefined();
382
358
  await act(async () => root.unmount());
383
359
  });
384
360
 
@@ -386,9 +362,7 @@ describe('StoragePanel preview query cutover', () => {
386
362
  const { root } = await renderPanel();
387
363
  await discover();
388
364
  await act(async () => {
389
- await vi.waitFor(() =>
390
- expect(mocks.client.request).toHaveBeenCalledTimes(1),
391
- );
365
+ await vi.waitFor(() => expect(mocks.client.request).toHaveBeenCalledTimes(1));
392
366
  mocks.emit('storage-invalidated', {
393
367
  type: 'storage-invalidated',
394
368
  target,
@@ -401,9 +375,22 @@ describe('StoragePanel preview query cutover', () => {
401
375
  key: 'next',
402
376
  });
403
377
  });
404
- await vi.waitFor(() =>
405
- expect(mocks.client.request).toHaveBeenCalledTimes(2),
406
- );
378
+ await vi.waitFor(() => expect(mocks.client.request).toHaveBeenCalledTimes(2));
379
+ await act(async () => root.unmount());
380
+ });
381
+
382
+ // The panel is recreated on every app reload and asks for storages before the
383
+ // app's React tree has mounted the plugin, so its first request is dropped.
384
+ it('runs discovery again when the device announces itself', async () => {
385
+ const { root, container } = await renderPanel();
386
+ expect(discoveryRequests()).toHaveLength(1);
387
+ expect(container.textContent).toContain('Waiting for storages');
388
+
389
+ await act(async () => mocks.emit('device-ready', { type: 'device-ready' }));
390
+ expect(discoveryRequests()).toHaveLength(2);
391
+ await discover();
392
+
393
+ expect(container.textContent).toContain('First');
407
394
  await act(async () => root.unmount());
408
395
  });
409
396
  });
@@ -23,10 +23,7 @@ describe('buildStorageSidebarGroups', () => {
23
23
  const snapshots = new Map([
24
24
  ['mmkv:default', entry('mmkv', 'default', 'MMKV', 'default', 3)],
25
25
  ['mmkv:user', entry('mmkv', 'user', 'MMKV', 'user', 1)],
26
- [
27
- 'async-storage:default',
28
- entry('async-storage', 'default', 'AsyncStorage', 'default', 5),
29
- ],
26
+ ['async-storage:default', entry('async-storage', 'default', 'AsyncStorage', 'default', 5)],
30
27
  ]);
31
28
 
32
29
  expect(buildStorageSidebarGroups(snapshots)).toEqual([
@@ -25,13 +25,9 @@ describe('convertValue', () => {
25
25
  });
26
26
 
27
27
  it('UTF-8 encodes a string to a byte array', () => {
28
- expect(convertValue('string', 'buffer', 'hello')).toEqual([
29
- 0x68, 0x65, 0x6c, 0x6c, 0x6f,
30
- ]);
28
+ expect(convertValue('string', 'buffer', 'hello')).toEqual([0x68, 0x65, 0x6c, 0x6c, 0x6f]);
31
29
  // 'é' is two bytes in UTF-8: 0xC3 0xA9.
32
- expect(convertValue('string', 'buffer', 'café')).toEqual([
33
- 0x63, 0x61, 0x66, 0xc3, 0xa9,
34
- ]);
30
+ expect(convertValue('string', 'buffer', 'café')).toEqual([0x63, 0x61, 0x66, 0xc3, 0xa9]);
35
31
  expect(convertValue('string', 'buffer', '')).toEqual([]);
36
32
  });
37
33
  });
@@ -66,20 +62,14 @@ describe('convertValue', () => {
66
62
  });
67
63
 
68
64
  it('encodes the literal "true" / "false" bytes for buffer conversion', () => {
69
- expect(convertValue('boolean', 'buffer', true)).toEqual([
70
- 0x74, 0x72, 0x75, 0x65,
71
- ]);
72
- expect(convertValue('boolean', 'buffer', false)).toEqual([
73
- 0x66, 0x61, 0x6c, 0x73, 0x65,
74
- ]);
65
+ expect(convertValue('boolean', 'buffer', true)).toEqual([0x74, 0x72, 0x75, 0x65]);
66
+ expect(convertValue('boolean', 'buffer', false)).toEqual([0x66, 0x61, 0x6c, 0x73, 0x65]);
75
67
  });
76
68
  });
77
69
 
78
70
  describe('from buffer', () => {
79
71
  it('UTF-8 decodes bytes back to a string (the headline round-trip)', () => {
80
- expect(
81
- convertValue('buffer', 'string', [0x68, 0x65, 0x6c, 0x6c, 0x6f]),
82
- ).toBe('hello');
72
+ expect(convertValue('buffer', 'string', [0x68, 0x65, 0x6c, 0x6c, 0x6f])).toBe('hello');
83
73
  });
84
74
 
85
75
  it('returns an empty string when bytes are not valid UTF-8', () => {
@@ -88,19 +78,13 @@ describe('convertValue', () => {
88
78
 
89
79
  it('decodes then parses to number, falls back to 0 on failure', () => {
90
80
  expect(convertValue('buffer', 'number', [0x34, 0x32])).toBe(42); // "42"
91
- expect(
92
- convertValue('buffer', 'number', [0x68, 0x65, 0x6c, 0x6c, 0x6f]),
93
- ).toBe(0); // "hello" → NaN → 0
81
+ expect(convertValue('buffer', 'number', [0x68, 0x65, 0x6c, 0x6c, 0x6f])).toBe(0); // "hello" → NaN → 0
94
82
  expect(convertValue('buffer', 'number', [0xff])).toBe(0); // invalid UTF-8 → 0
95
83
  });
96
84
 
97
85
  it('decodes then matches "true" for boolean conversion', () => {
98
- expect(convertValue('buffer', 'boolean', [0x74, 0x72, 0x75, 0x65])).toBe(
99
- true,
100
- );
101
- expect(
102
- convertValue('buffer', 'boolean', [0x66, 0x61, 0x6c, 0x73, 0x65]),
103
- ).toBe(false);
86
+ expect(convertValue('buffer', 'boolean', [0x74, 0x72, 0x75, 0x65])).toBe(true);
87
+ expect(convertValue('buffer', 'boolean', [0x66, 0x61, 0x6c, 0x73, 0x65])).toBe(false);
104
88
  expect(convertValue('buffer', 'boolean', [0xff])).toBe(false);
105
89
  });
106
90
  });