@rozenite/storage-plugin 1.9.0 → 1.11.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 +24 -0
- package/dist/devtools/assets/panel-Bm-SWF7d.js +33 -0
- package/dist/devtools/assets/panel-DIqI4WSp.css +1 -0
- package/dist/devtools/panel.html +2 -2
- package/dist/react-native/chunks/index.require.cjs +1 -1
- package/dist/react-native/chunks/index.require.js +38 -56
- package/dist/rozenite.json +1 -1
- package/package.json +9 -6
- package/src/react-native/adapters/__tests__/mmkv.test.ts +300 -5
- package/src/react-native/adapters/mmkv.ts +51 -40
- package/src/ui/__tests__/binary-value-editor-state.test.ts +199 -0
- package/src/ui/__tests__/binary.test.ts +251 -0
- package/src/ui/__tests__/type-conversion.test.ts +123 -0
- package/src/ui/add-entry-dialog.tsx +83 -173
- package/src/ui/binary-value-editor-state.ts +125 -0
- package/src/ui/binary-value-editor.tsx +168 -0
- package/src/ui/binary.ts +123 -0
- package/src/ui/edit-entry-dialog.tsx +64 -161
- package/src/ui/editable-table.tsx +12 -10
- package/src/ui/editor-switcher.tsx +62 -0
- package/src/ui/entry-detail-dialog.tsx +14 -6
- package/src/ui/type-conversion.ts +105 -0
- package/src/ui/typed-value-editor.tsx +96 -0
- package/dist/devtools/assets/panel-DVxtKLI9.css +0 -1
- package/dist/devtools/assets/panel-DnjbFMGo.js +0 -22
- package/src/react-native/is-garbled.ts +0 -17
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { MMKV as MMKVV3 } from 'react-native-mmkv-v3';
|
|
2
2
|
import type { MMKV as MMKVV4 } from 'react-native-mmkv-v4';
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
StorageAdapter,
|
|
5
|
+
StorageEntry,
|
|
6
|
+
StorageNode,
|
|
7
|
+
} from '../../shared/types';
|
|
4
8
|
import { DEFAULT_SUPPORTED_TYPES } from '../../shared/types';
|
|
5
|
-
import { looksLikeGarbled } from '../is-garbled';
|
|
6
9
|
|
|
7
10
|
type MMKV = MMKVV3 | MMKVV4;
|
|
8
11
|
|
|
@@ -36,12 +39,12 @@ const normalizeStorages = (storages: MMKV[] | Record<string, MMKV>) => {
|
|
|
36
39
|
|
|
37
40
|
if (isAnyStorageV4) {
|
|
38
41
|
throw new Error(
|
|
39
|
-
'[Rozenite] Storage Plugin: MMKV arrays are not supported for v4 storages. Pass a record of storage IDs and MMKV instances.'
|
|
42
|
+
'[Rozenite] Storage Plugin: MMKV arrays are not supported for v4 storages. Pass a record of storage IDs and MMKV instances.',
|
|
40
43
|
);
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
return Object.fromEntries(
|
|
44
|
-
(storages as MMKVV3[]).map((storage) => [storage['id'], storage])
|
|
47
|
+
(storages as MMKVV3[]).map((storage) => [storage['id'], storage]),
|
|
45
48
|
);
|
|
46
49
|
}
|
|
47
50
|
|
|
@@ -71,54 +74,60 @@ const getMMKVAdapter = (mmkv: MMKV): MMKVAdapter => {
|
|
|
71
74
|
getBuffer: (key) => mmkv.getBuffer(key) as ArrayBuffer | undefined,
|
|
72
75
|
delete: (key) => mmkv.delete(key),
|
|
73
76
|
getAllKeys: () => mmkv.getAllKeys(),
|
|
74
|
-
addOnValueChangedListener: (callback) =>
|
|
77
|
+
addOnValueChangedListener: (callback) =>
|
|
78
|
+
mmkv.addOnValueChangedListener(callback),
|
|
75
79
|
};
|
|
76
80
|
};
|
|
77
81
|
|
|
78
|
-
|
|
82
|
+
// MMKV's typed getters can disagree on the same key — on some platforms
|
|
83
|
+
// `getString` lenient-decodes invalid-UTF-8 buffer bytes to `""`, and
|
|
84
|
+
// `getNumber` reinterprets 8-byte buffers as IEEE 754 doubles without
|
|
85
|
+
// honoring the original `setBuffer` write. `getBuffer` is more reliably
|
|
86
|
+
// strict (only returns for keys actually written via `setBuffer`), so
|
|
87
|
+
// we consult it ahead of the numeric getters to break ties in favour of
|
|
88
|
+
// the bytes that are actually on disk.
|
|
89
|
+
//
|
|
90
|
+
// 1. Non-empty string — the common case. Buffer bytes that happen to
|
|
91
|
+
// be valid UTF-8 surface here too (documented trade-off); users
|
|
92
|
+
// disambiguate at edit time via the Hex editor.
|
|
93
|
+
// 2. Non-empty buffer — catches `setBuffer` payloads regardless of
|
|
94
|
+
// byte count and regardless of whether the other typed getters
|
|
95
|
+
// also return spurious values.
|
|
96
|
+
// 3. Number, then boolean — for keys actually written via `setNumber`
|
|
97
|
+
// / `setBoolean`. `getBuffer` will have returned `undefined` for
|
|
98
|
+
// those keys, so the chain falls through.
|
|
99
|
+
// 4. Empty string — intentional `setString(key, "")` lands here once
|
|
100
|
+
// we've ruled out a non-empty buffer payload at the same key.
|
|
101
|
+
const getEntry = (
|
|
102
|
+
adapter: MMKVAdapter,
|
|
103
|
+
key: string,
|
|
104
|
+
): StorageEntry | undefined => {
|
|
79
105
|
const stringValue = adapter.getString(key);
|
|
80
|
-
|
|
81
106
|
if (stringValue !== undefined && stringValue.length > 0) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
key,
|
|
85
|
-
type: 'buffer',
|
|
86
|
-
value: Array.from(new TextEncoder().encode(stringValue)),
|
|
87
|
-
};
|
|
88
|
-
}
|
|
107
|
+
return { key, type: 'string', value: stringValue };
|
|
108
|
+
}
|
|
89
109
|
|
|
110
|
+
const bufferValue = adapter.getBuffer(key);
|
|
111
|
+
if (bufferValue !== undefined && bufferValue.byteLength > 0) {
|
|
90
112
|
return {
|
|
91
113
|
key,
|
|
92
|
-
type: '
|
|
93
|
-
value:
|
|
114
|
+
type: 'buffer',
|
|
115
|
+
value: Array.from(new Uint8Array(bufferValue)),
|
|
94
116
|
};
|
|
95
117
|
}
|
|
96
118
|
|
|
97
119
|
const numberValue = adapter.getNumber(key);
|
|
98
120
|
if (numberValue !== undefined) {
|
|
99
|
-
return {
|
|
100
|
-
key,
|
|
101
|
-
type: 'number',
|
|
102
|
-
value: numberValue,
|
|
103
|
-
};
|
|
121
|
+
return { key, type: 'number', value: numberValue };
|
|
104
122
|
}
|
|
105
123
|
|
|
106
124
|
const booleanValue = adapter.getBoolean(key);
|
|
107
125
|
if (booleanValue !== undefined) {
|
|
108
|
-
return {
|
|
109
|
-
key,
|
|
110
|
-
type: 'boolean',
|
|
111
|
-
value: booleanValue,
|
|
112
|
-
};
|
|
126
|
+
return { key, type: 'boolean', value: booleanValue };
|
|
113
127
|
}
|
|
114
128
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
return {
|
|
118
|
-
key,
|
|
119
|
-
type: 'buffer',
|
|
120
|
-
value: Array.from(new Uint8Array(bufferValue)),
|
|
121
|
-
};
|
|
129
|
+
if (stringValue !== undefined) {
|
|
130
|
+
return { key, type: 'string', value: stringValue };
|
|
122
131
|
}
|
|
123
132
|
|
|
124
133
|
return undefined;
|
|
@@ -127,15 +136,14 @@ const getEntry = (adapter: MMKVAdapter, key: string): StorageEntry | undefined =
|
|
|
127
136
|
const setEntry = (adapter: MMKVAdapter, entry: StorageEntry) => {
|
|
128
137
|
if (entry.type === 'buffer') {
|
|
129
138
|
adapter.set(entry.key, new Uint8Array(entry.value).buffer);
|
|
130
|
-
|
|
139
|
+
} else {
|
|
140
|
+
adapter.set(entry.key, entry.value);
|
|
131
141
|
}
|
|
132
|
-
|
|
133
|
-
adapter.set(entry.key, entry.value);
|
|
134
142
|
};
|
|
135
143
|
|
|
136
144
|
const getStorageBlacklist = (
|
|
137
145
|
config: MMKVBlacklistConfig | undefined,
|
|
138
|
-
storageId: string
|
|
146
|
+
storageId: string,
|
|
139
147
|
) => {
|
|
140
148
|
if (!config) {
|
|
141
149
|
return undefined;
|
|
@@ -150,7 +158,7 @@ const getStorageBlacklist = (
|
|
|
150
158
|
|
|
151
159
|
const createStorageBlacklistMatcher = (
|
|
152
160
|
config: MMKVBlacklistConfig | undefined,
|
|
153
|
-
storageId: string
|
|
161
|
+
storageId: string,
|
|
154
162
|
) => {
|
|
155
163
|
if (!(config instanceof RegExp)) {
|
|
156
164
|
return undefined;
|
|
@@ -168,7 +176,10 @@ export const createMMKVStorageAdapter = ({
|
|
|
168
176
|
storages,
|
|
169
177
|
blacklist,
|
|
170
178
|
}: CreateMMKVStorageAdapterOptions): StorageAdapter => {
|
|
171
|
-
const normalizedStorages = normalizeStorages(storages) as Record<
|
|
179
|
+
const normalizedStorages = normalizeStorages(storages) as Record<
|
|
180
|
+
string,
|
|
181
|
+
MMKV
|
|
182
|
+
>;
|
|
172
183
|
|
|
173
184
|
const storageNodes: StorageNode[] = Object.entries(normalizedStorages).map(
|
|
174
185
|
([storageId, storage]) => {
|
|
@@ -191,7 +202,7 @@ export const createMMKVStorageAdapter = ({
|
|
|
191
202
|
subscribe: (callback) => mmkv.addOnValueChangedListener(callback),
|
|
192
203
|
},
|
|
193
204
|
};
|
|
194
|
-
}
|
|
205
|
+
},
|
|
195
206
|
);
|
|
196
207
|
|
|
197
208
|
return {
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
initialState,
|
|
4
|
+
reduce,
|
|
5
|
+
validate,
|
|
6
|
+
type EditorState,
|
|
7
|
+
} from '../binary-value-editor-state';
|
|
8
|
+
|
|
9
|
+
const hexState = (bytes?: number[]): EditorState =>
|
|
10
|
+
initialState({ initialBytes: bytes, mode: 'hex' });
|
|
11
|
+
|
|
12
|
+
describe('initialState', () => {
|
|
13
|
+
it('defaults to hex mode and empty text when no initialBytes', () => {
|
|
14
|
+
expect(initialState({})).toEqual({
|
|
15
|
+
mode: 'hex',
|
|
16
|
+
text: '',
|
|
17
|
+
bytes: null,
|
|
18
|
+
error: null,
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('encodes initial bytes as grouped hex when mode is hex', () => {
|
|
23
|
+
expect(initialState({ initialBytes: [0x89, 0x50, 0x4e, 0x47] })).toEqual({
|
|
24
|
+
mode: 'hex',
|
|
25
|
+
text: '89 50 4E 47',
|
|
26
|
+
bytes: [0x89, 0x50, 0x4e, 0x47],
|
|
27
|
+
error: null,
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('encodes initial bytes as base64 when mode is base64', () => {
|
|
32
|
+
expect(
|
|
33
|
+
initialState({ initialBytes: [0x48, 0x69], mode: 'base64' }),
|
|
34
|
+
).toEqual({
|
|
35
|
+
mode: 'base64',
|
|
36
|
+
text: 'SGk=',
|
|
37
|
+
bytes: [0x48, 0x69],
|
|
38
|
+
error: null,
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('treats empty initial bytes the same as no bytes', () => {
|
|
43
|
+
expect(initialState({ initialBytes: [] })).toEqual({
|
|
44
|
+
mode: 'hex',
|
|
45
|
+
text: '',
|
|
46
|
+
bytes: null,
|
|
47
|
+
error: null,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('reduce: set-text', () => {
|
|
53
|
+
it('parses valid hex into bytes', () => {
|
|
54
|
+
const next = reduce(hexState(), { type: 'set-text', text: '89 50 4E 47' });
|
|
55
|
+
expect(next).toEqual({
|
|
56
|
+
mode: 'hex',
|
|
57
|
+
text: '89 50 4E 47',
|
|
58
|
+
bytes: [0x89, 0x50, 0x4e, 0x47],
|
|
59
|
+
error: null,
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('keeps invalid hex text but clears bytes and surfaces the parse error', () => {
|
|
64
|
+
const next = reduce(hexState(), { type: 'set-text', text: 'gg' });
|
|
65
|
+
expect(next).toEqual({
|
|
66
|
+
mode: 'hex',
|
|
67
|
+
text: 'gg',
|
|
68
|
+
bytes: null,
|
|
69
|
+
error: 'Hex input contains invalid characters.',
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('keeps trailing-nibble text but reports incomplete bytes', () => {
|
|
74
|
+
const next = reduce(hexState(), { type: 'set-text', text: '89 5' });
|
|
75
|
+
expect(next).toEqual({
|
|
76
|
+
mode: 'hex',
|
|
77
|
+
text: '89 5',
|
|
78
|
+
bytes: null,
|
|
79
|
+
error: 'Hex input must contain complete bytes.',
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('surfaces the empty-input error for cleared text', () => {
|
|
84
|
+
const next = reduce(hexState([0x89]), { type: 'set-text', text: '' });
|
|
85
|
+
expect(next).toEqual({
|
|
86
|
+
mode: 'hex',
|
|
87
|
+
text: '',
|
|
88
|
+
bytes: null,
|
|
89
|
+
error: 'Enter at least one byte.',
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('does not rewrite text (paste filter is responsible for that)', () => {
|
|
94
|
+
// Even though "DEAD" would canonicalise to "DE AD", set-text must
|
|
95
|
+
// preserve the user's exact text. Only normalize-paste rewrites it.
|
|
96
|
+
const next = reduce(hexState(), { type: 'set-text', text: 'DEAD' });
|
|
97
|
+
expect(next.text).toBe('DEAD');
|
|
98
|
+
expect(next.bytes).toEqual([0xde, 0xad]);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('reduce: normalize-paste', () => {
|
|
103
|
+
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|';
|
|
106
|
+
const next = reduce(hexState(), { type: 'normalize-paste', text: pasted });
|
|
107
|
+
expect(next.text).toBe('89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52');
|
|
108
|
+
expect(next.bytes).toEqual([
|
|
109
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
|
|
110
|
+
0x49, 0x48, 0x44, 0x52,
|
|
111
|
+
]);
|
|
112
|
+
expect(next.error).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('rewrites pasted base64 into canonical base64', () => {
|
|
116
|
+
const state = initialState({ mode: 'base64' });
|
|
117
|
+
const next = reduce(state, {
|
|
118
|
+
type: 'normalize-paste',
|
|
119
|
+
text: ' SGVs\nbG8= ',
|
|
120
|
+
});
|
|
121
|
+
expect(next.text).toBe('SGVsbG8=');
|
|
122
|
+
expect(next.bytes).toEqual([0x48, 0x65, 0x6c, 0x6c, 0x6f]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('keeps the raw pasted text and reports the error when invalid', () => {
|
|
126
|
+
const next = reduce(hexState(), {
|
|
127
|
+
type: 'normalize-paste',
|
|
128
|
+
text: 'oh no',
|
|
129
|
+
});
|
|
130
|
+
expect(next.text).toBe('oh no');
|
|
131
|
+
expect(next.bytes).toBeNull();
|
|
132
|
+
expect(next.error).toBe('Hex input contains invalid characters.');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('reduce: switch-mode', () => {
|
|
137
|
+
it('converts valid bytes in place when switching hex -> base64', () => {
|
|
138
|
+
const start = reduce(hexState(), { type: 'set-text', text: '48 69' });
|
|
139
|
+
const next = reduce(start, { type: 'switch-mode', mode: 'base64' });
|
|
140
|
+
expect(next).toEqual({
|
|
141
|
+
mode: 'base64',
|
|
142
|
+
text: 'SGk=',
|
|
143
|
+
bytes: [0x48, 0x69],
|
|
144
|
+
error: null,
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('converts valid bytes in place when switching base64 -> hex', () => {
|
|
149
|
+
const start = reduce(initialState({ mode: 'base64' }), {
|
|
150
|
+
type: 'set-text',
|
|
151
|
+
text: 'SGk=',
|
|
152
|
+
});
|
|
153
|
+
const next = reduce(start, { type: 'switch-mode', mode: 'hex' });
|
|
154
|
+
expect(next).toEqual({
|
|
155
|
+
mode: 'hex',
|
|
156
|
+
text: '48 69',
|
|
157
|
+
bytes: [0x48, 0x69],
|
|
158
|
+
error: null,
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('clears text and error when current input is invalid', () => {
|
|
163
|
+
const invalid = reduce(hexState(), { type: 'set-text', text: '89 5' });
|
|
164
|
+
const next = reduce(invalid, { type: 'switch-mode', mode: 'base64' });
|
|
165
|
+
expect(next).toEqual({
|
|
166
|
+
mode: 'base64',
|
|
167
|
+
text: '',
|
|
168
|
+
bytes: null,
|
|
169
|
+
error: null,
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('is a no-op when the mode is unchanged', () => {
|
|
174
|
+
const state = reduce(hexState(), { type: 'set-text', text: '89 50' });
|
|
175
|
+
expect(reduce(state, { type: 'switch-mode', mode: 'hex' })).toBe(state);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('validate', () => {
|
|
180
|
+
it('passes when bytes are valid and non-empty', () => {
|
|
181
|
+
const state = reduce(hexState(), { type: 'set-text', text: '89 50' });
|
|
182
|
+
expect(validate(state)).toEqual({ ok: true, bytes: [0x89, 0x50] });
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('fails with the parse error when input is invalid', () => {
|
|
186
|
+
const state = reduce(hexState(), { type: 'set-text', text: 'gg' });
|
|
187
|
+
expect(validate(state)).toEqual({
|
|
188
|
+
ok: false,
|
|
189
|
+
reason: 'Hex input contains invalid characters.',
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('fails with the empty-input message when nothing is entered', () => {
|
|
194
|
+
expect(validate(hexState())).toEqual({
|
|
195
|
+
ok: false,
|
|
196
|
+
reason: 'Enter at least one byte.',
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
base64ToBytes,
|
|
4
|
+
bytesToAsciiPreview,
|
|
5
|
+
bytesToBase64,
|
|
6
|
+
bytesToGroupedHex,
|
|
7
|
+
bytesToHexdump,
|
|
8
|
+
compactBufferPreview,
|
|
9
|
+
hexInputToBytes,
|
|
10
|
+
} from '../binary';
|
|
11
|
+
|
|
12
|
+
// 0x89 'P' 'N' 'G' \r \n 0x1A \n - the canonical PNG signature.
|
|
13
|
+
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
14
|
+
|
|
15
|
+
describe('bytesToGroupedHex', () => {
|
|
16
|
+
it('returns empty string for empty bytes', () => {
|
|
17
|
+
expect(bytesToGroupedHex([])).toBe('');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('groups under 8 bytes without a double-space separator', () => {
|
|
21
|
+
expect(bytesToGroupedHex([0x89, 0x50])).toBe('89 50');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('inserts a double-space gap between bytes 8 and 9', () => {
|
|
25
|
+
expect(
|
|
26
|
+
bytesToGroupedHex([
|
|
27
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
|
|
28
|
+
0x49, 0x48, 0x44, 0x52,
|
|
29
|
+
]),
|
|
30
|
+
).toBe('89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('wraps after 16 bytes with a newline', () => {
|
|
34
|
+
const bytes = new Array(20).fill(0xff);
|
|
35
|
+
const out = bytesToGroupedHex(bytes);
|
|
36
|
+
const lines = out.split('\n');
|
|
37
|
+
expect(lines).toHaveLength(2);
|
|
38
|
+
expect(lines[0]).toBe('FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF');
|
|
39
|
+
expect(lines[1]).toBe('FF FF FF FF');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('bytesToHexdump', () => {
|
|
44
|
+
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
|
+
];
|
|
56
|
+
expect(bytesToHexdump(bytes)).toBe(
|
|
57
|
+
'00000000 89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52 |.PNG........IHDR|',
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('uses lowercase 8-digit offsets that increment by 16', () => {
|
|
62
|
+
const bytes = new Array(20).fill(0x41);
|
|
63
|
+
const lines = bytesToHexdump(bytes).split('\n');
|
|
64
|
+
expect(lines[0].startsWith('00000000 ')).toBe(true);
|
|
65
|
+
expect(lines[1].startsWith('00000010 ')).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('pads the hex column on a partial trailing line so ASCII aligns', () => {
|
|
69
|
+
const bytes = [0x41, 0x42, 0x43];
|
|
70
|
+
const line = bytesToHexdump(bytes);
|
|
71
|
+
// Hex section width must be exactly 48 chars before the " |..." block.
|
|
72
|
+
expect(line).toBe(
|
|
73
|
+
'00000000 41 42 43 |ABC|',
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('renders non-printable bytes as dots in the ASCII column', () => {
|
|
78
|
+
const bytes = [0x00, 0x09, 0x1f, 0x20, 0x7e, 0x7f, 0xff];
|
|
79
|
+
expect(bytesToHexdump(bytes)).toContain('|... ~..|');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('bytesToAsciiPreview', () => {
|
|
84
|
+
it('maps printable bytes to characters', () => {
|
|
85
|
+
expect(bytesToAsciiPreview([0x48, 0x69])).toBe('Hi');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('treats bytes outside 0x20..0x7E as "."', () => {
|
|
89
|
+
expect(bytesToAsciiPreview([0x1f, 0x20, 0x7e, 0x7f])).toBe('. ~.');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('treats tab and newline as non-printable', () => {
|
|
93
|
+
expect(bytesToAsciiPreview([0x09, 0x0a, 0x0d])).toBe('...');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('bytesToBase64 / base64ToBytes round-trip', () => {
|
|
98
|
+
it('encodes simple ASCII to base64 and back', () => {
|
|
99
|
+
const bytes = [0x48, 0x65, 0x6c, 0x6c, 0x6f]; // "Hello"
|
|
100
|
+
const encoded = bytesToBase64(bytes);
|
|
101
|
+
expect(encoded).toBe('SGVsbG8=');
|
|
102
|
+
const decoded = base64ToBytes(encoded);
|
|
103
|
+
expect(decoded).toEqual({ ok: true, value: bytes });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('round-trips arbitrary bytes including high values', () => {
|
|
107
|
+
const bytes = [0x00, 0xff, 0x89, 0x50, 0x4e, 0x47];
|
|
108
|
+
const decoded = base64ToBytes(bytesToBase64(bytes));
|
|
109
|
+
expect(decoded).toEqual({ ok: true, value: bytes });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('accepts base64 with surrounding and internal whitespace', () => {
|
|
113
|
+
expect(base64ToBytes(' SGVs\nbG8= ')).toEqual({
|
|
114
|
+
ok: true,
|
|
115
|
+
value: [0x48, 0x65, 0x6c, 0x6c, 0x6f],
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('rejects empty input with the canonical message', () => {
|
|
120
|
+
expect(base64ToBytes('')).toEqual({
|
|
121
|
+
ok: false,
|
|
122
|
+
error: 'Enter at least one byte.',
|
|
123
|
+
});
|
|
124
|
+
expect(base64ToBytes(' ')).toEqual({
|
|
125
|
+
ok: false,
|
|
126
|
+
error: 'Enter at least one byte.',
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('rejects malformed base64', () => {
|
|
131
|
+
expect(base64ToBytes('not*valid*base64')).toEqual({
|
|
132
|
+
ok: false,
|
|
133
|
+
error: 'Base64 input is invalid.',
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('compactBufferPreview', () => {
|
|
139
|
+
it('renders just the size for an empty buffer', () => {
|
|
140
|
+
expect(compactBufferPreview([])).toBe('0 B');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
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
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
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
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('respects a custom maxBytes', () => {
|
|
156
|
+
expect(compactBufferPreview([0x01, 0x02, 0x03], { maxBytes: 2 })).toBe(
|
|
157
|
+
'01 02 … 3 B',
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('hexInputToBytes', () => {
|
|
163
|
+
it('parses continuous hex', () => {
|
|
164
|
+
expect(hexInputToBytes('deadbeef')).toEqual({
|
|
165
|
+
ok: true,
|
|
166
|
+
value: [0xde, 0xad, 0xbe, 0xef],
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('parses grouped hex', () => {
|
|
171
|
+
expect(hexInputToBytes('DE AD BE EF')).toEqual({
|
|
172
|
+
ok: true,
|
|
173
|
+
value: [0xde, 0xad, 0xbe, 0xef],
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('parses multiline grouped hex', () => {
|
|
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
|
+
),
|
|
182
|
+
).toEqual({
|
|
183
|
+
ok: true,
|
|
184
|
+
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,
|
|
187
|
+
],
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('parses pasted hexdump rows, stripping offsets and ASCII column', () => {
|
|
192
|
+
const pasted = [
|
|
193
|
+
'00000000 89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52 |.PNG........IHDR|',
|
|
194
|
+
'00000010 00 00 00 20 00 00 00 20 08 06 00 00 00 73 7A 7A |... ... .....szz|',
|
|
195
|
+
].join('\n');
|
|
196
|
+
expect(hexInputToBytes(pasted)).toEqual({
|
|
197
|
+
ok: true,
|
|
198
|
+
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,
|
|
202
|
+
],
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('accepts colon-separated hexdump offsets', () => {
|
|
207
|
+
expect(hexInputToBytes('00000000: 89 50 4E 47')).toEqual({
|
|
208
|
+
ok: true,
|
|
209
|
+
value: [0x89, 0x50, 0x4e, 0x47],
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('strips "0x" prefixes', () => {
|
|
214
|
+
expect(hexInputToBytes('0xDE 0xAD 0xBE 0xEF')).toEqual({
|
|
215
|
+
ok: true,
|
|
216
|
+
value: [0xde, 0xad, 0xbe, 0xef],
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('tolerates mixed case', () => {
|
|
221
|
+
expect(hexInputToBytes('De Ad bE eF')).toEqual({
|
|
222
|
+
ok: true,
|
|
223
|
+
value: [0xde, 0xad, 0xbe, 0xef],
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('rejects empty input with the canonical message', () => {
|
|
228
|
+
expect(hexInputToBytes('')).toEqual({
|
|
229
|
+
ok: false,
|
|
230
|
+
error: 'Enter at least one byte.',
|
|
231
|
+
});
|
|
232
|
+
expect(hexInputToBytes(' \n ')).toEqual({
|
|
233
|
+
ok: false,
|
|
234
|
+
error: 'Enter at least one byte.',
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('rejects non-hex characters', () => {
|
|
239
|
+
expect(hexInputToBytes('DE AD GG')).toEqual({
|
|
240
|
+
ok: false,
|
|
241
|
+
error: 'Hex input contains invalid characters.',
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('rejects odd hex digit count', () => {
|
|
246
|
+
expect(hexInputToBytes('DEAD B')).toEqual({
|
|
247
|
+
ok: false,
|
|
248
|
+
error: 'Hex input must contain complete bytes.',
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
});
|