@harborclient/sdk 0.6.16 → 0.6.17

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 (51) hide show
  1. package/dist/build/index.d.ts +59 -0
  2. package/dist/build/index.d.ts.map +1 -0
  3. package/dist/build/index.js +107 -0
  4. package/dist/components/index.d.ts +1 -0
  5. package/dist/components/index.d.ts.map +1 -1
  6. package/dist/components/index.js +1 -0
  7. package/dist/components/portalToBody.d.ts +10 -0
  8. package/dist/components/portalToBody.d.ts.map +1 -0
  9. package/dist/components/portalToBody.js +14 -0
  10. package/dist/eslint/index.d.ts +9 -0
  11. package/dist/eslint/index.d.ts.map +1 -0
  12. package/dist/eslint/index.js +18 -0
  13. package/dist/http/resolveRequest.test.js +2 -1
  14. package/dist/runtime/index.d.ts +18 -0
  15. package/dist/runtime/index.js +23 -0
  16. package/dist/runtime/index.test.d.ts +2 -0
  17. package/dist/runtime/index.test.d.ts.map +1 -0
  18. package/dist/runtime/index.test.js +41 -0
  19. package/dist/runtime/index.test.ts +53 -0
  20. package/dist/runtime/jsx-runtime-host.d.ts +44 -0
  21. package/dist/runtime/jsx-runtime-host.js +42 -0
  22. package/dist/runtime/react-dom.d.ts +1 -0
  23. package/dist/runtime/react-dom.js +16 -0
  24. package/dist/runtime/react.d.ts +6 -0
  25. package/dist/runtime/react.js +70 -2
  26. package/dist/runtime/reactHost.js +52 -0
  27. package/dist/runtime/store.d.ts +101 -0
  28. package/dist/runtime/store.d.ts.map +1 -1
  29. package/dist/runtime/store.js +94 -0
  30. package/dist/runtime/store.ts +203 -0
  31. package/dist/runtime/viewHost.js +2 -1
  32. package/dist/runtime-utils.d.ts +59 -0
  33. package/dist/runtime-utils.d.ts.map +1 -1
  34. package/dist/runtime-utils.js +66 -0
  35. package/dist/runtime-utils.test.js +74 -1
  36. package/dist/storage/index.d.ts +1 -0
  37. package/dist/storage/index.d.ts.map +1 -1
  38. package/dist/storage/index.js +1 -0
  39. package/dist/storage/validate.d.ts +71 -0
  40. package/dist/storage/validate.d.ts.map +1 -0
  41. package/dist/storage/validate.js +111 -0
  42. package/dist/storage/validate.test.d.ts +2 -0
  43. package/dist/storage/validate.test.d.ts.map +1 -0
  44. package/dist/storage/validate.test.js +78 -0
  45. package/dist/store.test.d.ts +2 -0
  46. package/dist/store.test.d.ts.map +1 -0
  47. package/dist/store.test.js +248 -0
  48. package/dist/types.d.ts +14 -0
  49. package/dist/types.d.ts.map +1 -1
  50. package/package.json +35 -7
  51. package/tsconfig.base.json +15 -0
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, it, jest } from '@jest/globals';
2
- import { byteLength, randomId, truncateBody, truncateToBytes } from './runtime-utils.js';
2
+ import { byteLength, createLogger, randomId, truncateBody, truncateToBytes } from './runtime-utils.js';
3
3
  const originalTextEncoder = globalThis.TextEncoder;
4
4
  const originalCrypto = globalThis.crypto;
5
5
  afterEach(() => {
@@ -102,3 +102,76 @@ describe('truncateBody', () => {
102
102
  });
103
103
  });
104
104
  });
105
+ describe('createLogger', () => {
106
+ it('prefixes messages with [pluginId]', () => {
107
+ const logSpy = jest.spyOn(console, 'log').mockImplementation(() => { });
108
+ const logger = createLogger('my-plugin');
109
+ logger.info('hello');
110
+ expect(logSpy).toHaveBeenCalledWith('[my-plugin]', 'hello');
111
+ logSpy.mockRestore();
112
+ });
113
+ it('filters messages below the active level', () => {
114
+ const logSpy = jest.spyOn(console, 'log').mockImplementation(() => { });
115
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { });
116
+ const logger = createLogger('my-plugin', { level: 'warn' });
117
+ logger.debug('hidden');
118
+ logger.info('hidden');
119
+ logger.warn('visible');
120
+ expect(logSpy).not.toHaveBeenCalled();
121
+ expect(warnSpy).toHaveBeenCalledTimes(1);
122
+ expect(warnSpy).toHaveBeenCalledWith('[my-plugin]', 'visible');
123
+ logSpy.mockRestore();
124
+ warnSpy.mockRestore();
125
+ });
126
+ it('updates the active level via setLevel', () => {
127
+ const logSpy = jest.spyOn(console, 'log').mockImplementation(() => { });
128
+ const logger = createLogger('my-plugin');
129
+ logger.info('before');
130
+ logger.setLevel('silent');
131
+ logger.info('after');
132
+ expect(logSpy).toHaveBeenCalledTimes(1);
133
+ expect(logSpy).toHaveBeenCalledWith('[my-plugin]', 'before');
134
+ logSpy.mockRestore();
135
+ });
136
+ it('suppresses all output at silent level', () => {
137
+ const logSpy = jest.spyOn(console, 'log').mockImplementation(() => { });
138
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
139
+ const logger = createLogger('my-plugin', { level: 'silent' });
140
+ logger.debug('a');
141
+ logger.info('b');
142
+ logger.warn('c');
143
+ logger.error('d');
144
+ expect(logSpy).not.toHaveBeenCalled();
145
+ expect(errorSpy).not.toHaveBeenCalled();
146
+ logSpy.mockRestore();
147
+ errorSpy.mockRestore();
148
+ });
149
+ it('routes warn and error through console.warn and console.error when available', () => {
150
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { });
151
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
152
+ const logger = createLogger('my-plugin', { level: 'debug' });
153
+ logger.warn('warned');
154
+ logger.error('failed');
155
+ expect(warnSpy).toHaveBeenCalledWith('[my-plugin]', 'warned');
156
+ expect(errorSpy).toHaveBeenCalledWith('[my-plugin]', 'failed');
157
+ warnSpy.mockRestore();
158
+ errorSpy.mockRestore();
159
+ });
160
+ it('falls back to console.log when console.warn and console.error are unavailable', () => {
161
+ const logSpy = jest.spyOn(console, 'log').mockImplementation(() => { });
162
+ const originalWarn = console.warn;
163
+ const originalError = console.error;
164
+ // @ts-expect-error — exercise SES main-runtime fallback
165
+ console.warn = undefined;
166
+ // @ts-expect-error — exercise SES main-runtime fallback
167
+ console.error = undefined;
168
+ const logger = createLogger('my-plugin', { level: 'debug' });
169
+ logger.warn('warned');
170
+ logger.error('failed');
171
+ expect(logSpy).toHaveBeenCalledWith('[my-plugin]', 'warned');
172
+ expect(logSpy).toHaveBeenCalledWith('[my-plugin]', 'failed');
173
+ console.warn = originalWarn;
174
+ console.error = originalError;
175
+ logSpy.mockRestore();
176
+ });
177
+ });
@@ -1,2 +1,3 @@
1
1
  export { createCappedList, mergeById, type CreateCappedListOptions, type MergeByIdOptions } from './cappedList.js';
2
+ export { arrayOf, asRecord, bool, isRecord, num, numArray, oneOf, recordOf, str, strArray } from './validate.js';
2
3
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/storage/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,gBAAgB,EAChB,SAAS,EACT,KAAK,uBAAuB,EAC5B,KAAK,gBAAgB,EACtB,MAAM,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/storage/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,gBAAgB,EAChB,SAAS,EACT,KAAK,uBAAuB,EAC5B,KAAK,gBAAgB,EACtB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,OAAO,EACP,QAAQ,EACR,IAAI,EACJ,QAAQ,EACR,GAAG,EACH,QAAQ,EACR,KAAK,EACL,QAAQ,EACR,GAAG,EACH,QAAQ,EACT,MAAM,eAAe,CAAC"}
@@ -1 +1,2 @@
1
1
  export { createCappedList, mergeById } from './cappedList.js';
2
+ export { arrayOf, asRecord, bool, isRecord, num, numArray, oneOf, recordOf, str, strArray } from './validate.js';
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Returns true when a value is a plain object record (not null, not an array).
3
+ *
4
+ * @param value - Candidate value from plugin storage.
5
+ */
6
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
7
+ /**
8
+ * Narrows an unknown storage value to a string-keyed record, or null when invalid.
9
+ *
10
+ * @param value - Raw value from plugin storage.
11
+ */
12
+ export declare function asRecord(value: unknown): Record<string, unknown> | null;
13
+ /**
14
+ * Returns a string when the value is a string; otherwise the fallback.
15
+ *
16
+ * @param value - Candidate field value.
17
+ * @param fallback - Value used when the candidate is not a string.
18
+ */
19
+ export declare function str<F>(value: unknown, fallback: F): string | F;
20
+ /**
21
+ * Returns a finite number when the value is a number; otherwise the fallback.
22
+ *
23
+ * @param value - Candidate field value.
24
+ * @param fallback - Value used when the candidate is not a finite number.
25
+ */
26
+ export declare function num<F>(value: unknown, fallback: F): number | F;
27
+ /**
28
+ * Returns a boolean when the value is a boolean; otherwise the fallback.
29
+ *
30
+ * @param value - Candidate field value.
31
+ * @param fallback - Value used when the candidate is not a boolean.
32
+ */
33
+ export declare function bool<F>(value: unknown, fallback: F): boolean | F;
34
+ /**
35
+ * Returns the candidate when it is one of the allowed string literals; otherwise the fallback.
36
+ *
37
+ * @param value - Candidate field value.
38
+ * @param allowed - Permitted string literals.
39
+ * @param fallback - Value used when the candidate is not in {@link allowed}.
40
+ */
41
+ export declare function oneOf<T extends string, F>(value: unknown, allowed: readonly T[], fallback: F): T | F;
42
+ /**
43
+ * Filters an array to finite numbers; returns an empty array when the input is not an array.
44
+ *
45
+ * @param value - Candidate array from plugin storage.
46
+ */
47
+ export declare function numArray(value: unknown): number[];
48
+ /**
49
+ * Filters an array to non-empty strings; returns an empty array when the input is not an array.
50
+ *
51
+ * @param value - Candidate array from plugin storage.
52
+ */
53
+ export declare function strArray(value: unknown): string[];
54
+ /**
55
+ * Filters an array through a type guard; returns an empty array when the input is not an array.
56
+ *
57
+ * @param value - Candidate array from plugin storage.
58
+ * @param guard - Predicate that narrows each element.
59
+ */
60
+ export declare function arrayOf<T>(value: unknown, guard: (entry: unknown) => entry is T): T[];
61
+ /**
62
+ * Builds a string-keyed record from entries that pass a type guard.
63
+ *
64
+ * Non-record inputs (including arrays) return an empty object so callers can treat
65
+ * malformed storage as "no data" without throwing.
66
+ *
67
+ * @param value - Raw value from plugin storage.
68
+ * @param guard - Predicate that narrows each property value.
69
+ */
70
+ export declare function recordOf<T>(value: unknown, guard: (entry: unknown) => entry is T): Record<string, T>;
71
+ //# sourceMappingURL=validate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/storage/validate.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAEzE;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAEvE;AAED;;;;;GAKG;AACH,wBAAgB,GAAG,CAAC,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,GAAG,MAAM,GAAG,CAAC,CAE9D;AAED;;;;;GAKG;AACH,wBAAgB,GAAG,CAAC,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,GAAG,MAAM,GAAG,CAAC,CAE9D;AAED;;;;;GAKG;AACH,wBAAgB,IAAI,CAAC,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,GAAG,OAAO,GAAG,CAAC,CAEhE;AAED;;;;;;GAMG;AACH,wBAAgB,KAAK,CAAC,CAAC,SAAS,MAAM,EAAE,CAAC,EACvC,KAAK,EAAE,OAAO,EACd,OAAO,EAAE,SAAS,CAAC,EAAE,EACrB,QAAQ,EAAE,CAAC,GACV,CAAC,GAAG,CAAC,CAIP;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,EAAE,CAOjD;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,EAAE,CAKjD;AAED;;;;;GAKG;AACH,wBAAgB,OAAO,CAAC,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAKrF;AAED;;;;;;;;GAQG;AACH,wBAAgB,QAAQ,CAAC,CAAC,EACxB,KAAK,EAAE,OAAO,EACd,KAAK,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,KAAK,IAAI,CAAC,GACpC,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,CAYnB"}
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Returns true when a value is a plain object record (not null, not an array).
3
+ *
4
+ * @param value - Candidate value from plugin storage.
5
+ */
6
+ export function isRecord(value) {
7
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
8
+ }
9
+ /**
10
+ * Narrows an unknown storage value to a string-keyed record, or null when invalid.
11
+ *
12
+ * @param value - Raw value from plugin storage.
13
+ */
14
+ export function asRecord(value) {
15
+ return isRecord(value) ? value : null;
16
+ }
17
+ /**
18
+ * Returns a string when the value is a string; otherwise the fallback.
19
+ *
20
+ * @param value - Candidate field value.
21
+ * @param fallback - Value used when the candidate is not a string.
22
+ */
23
+ export function str(value, fallback) {
24
+ return typeof value === 'string' ? value : fallback;
25
+ }
26
+ /**
27
+ * Returns a finite number when the value is a number; otherwise the fallback.
28
+ *
29
+ * @param value - Candidate field value.
30
+ * @param fallback - Value used when the candidate is not a finite number.
31
+ */
32
+ export function num(value, fallback) {
33
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
34
+ }
35
+ /**
36
+ * Returns a boolean when the value is a boolean; otherwise the fallback.
37
+ *
38
+ * @param value - Candidate field value.
39
+ * @param fallback - Value used when the candidate is not a boolean.
40
+ */
41
+ export function bool(value, fallback) {
42
+ return typeof value === 'boolean' ? value : fallback;
43
+ }
44
+ /**
45
+ * Returns the candidate when it is one of the allowed string literals; otherwise the fallback.
46
+ *
47
+ * @param value - Candidate field value.
48
+ * @param allowed - Permitted string literals.
49
+ * @param fallback - Value used when the candidate is not in {@link allowed}.
50
+ */
51
+ export function oneOf(value, allowed, fallback) {
52
+ return typeof value === 'string' && allowed.includes(value)
53
+ ? value
54
+ : fallback;
55
+ }
56
+ /**
57
+ * Filters an array to finite numbers; returns an empty array when the input is not an array.
58
+ *
59
+ * @param value - Candidate array from plugin storage.
60
+ */
61
+ export function numArray(value) {
62
+ if (!Array.isArray(value)) {
63
+ return [];
64
+ }
65
+ return value.filter((entry) => typeof entry === 'number' && Number.isFinite(entry));
66
+ }
67
+ /**
68
+ * Filters an array to non-empty strings; returns an empty array when the input is not an array.
69
+ *
70
+ * @param value - Candidate array from plugin storage.
71
+ */
72
+ export function strArray(value) {
73
+ if (!Array.isArray(value)) {
74
+ return [];
75
+ }
76
+ return value.filter((entry) => typeof entry === 'string');
77
+ }
78
+ /**
79
+ * Filters an array through a type guard; returns an empty array when the input is not an array.
80
+ *
81
+ * @param value - Candidate array from plugin storage.
82
+ * @param guard - Predicate that narrows each element.
83
+ */
84
+ export function arrayOf(value, guard) {
85
+ if (!Array.isArray(value)) {
86
+ return [];
87
+ }
88
+ return value.filter(guard);
89
+ }
90
+ /**
91
+ * Builds a string-keyed record from entries that pass a type guard.
92
+ *
93
+ * Non-record inputs (including arrays) return an empty object so callers can treat
94
+ * malformed storage as "no data" without throwing.
95
+ *
96
+ * @param value - Raw value from plugin storage.
97
+ * @param guard - Predicate that narrows each property value.
98
+ */
99
+ export function recordOf(value, guard) {
100
+ const record = asRecord(value);
101
+ if (!record) {
102
+ return {};
103
+ }
104
+ const result = {};
105
+ for (const [key, entry] of Object.entries(record)) {
106
+ if (guard(entry)) {
107
+ result[key] = entry;
108
+ }
109
+ }
110
+ return result;
111
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=validate.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate.test.d.ts","sourceRoot":"","sources":["../../src/storage/validate.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it } from '@jest/globals';
2
+ import { arrayOf, asRecord, bool, isRecord, num, numArray, oneOf, recordOf, str, strArray } from './validate.js';
3
+ describe('isRecord', () => {
4
+ it('accepts plain objects and rejects arrays and primitives', () => {
5
+ expect(isRecord({ a: 1 })).toBe(true);
6
+ expect(isRecord([])).toBe(false);
7
+ expect(isRecord(null)).toBe(false);
8
+ expect(isRecord('x')).toBe(false);
9
+ });
10
+ });
11
+ describe('asRecord', () => {
12
+ it('returns the record or null', () => {
13
+ const value = { key: 'v' };
14
+ expect(asRecord(value)).toBe(value);
15
+ expect(asRecord(null)).toBeNull();
16
+ expect(asRecord([1])).toBeNull();
17
+ });
18
+ });
19
+ describe('str', () => {
20
+ it('returns strings and falls back otherwise', () => {
21
+ expect(str('hello', '')).toBe('hello');
22
+ expect(str(42, 'fallback')).toBe('fallback');
23
+ expect(str(undefined, null)).toBeNull();
24
+ });
25
+ });
26
+ describe('num', () => {
27
+ it('returns finite numbers and falls back otherwise', () => {
28
+ expect(num(3, 0)).toBe(3);
29
+ expect(num(NaN, 0)).toBe(0);
30
+ expect(num(Infinity, 0)).toBe(0);
31
+ expect(num('3', null)).toBeNull();
32
+ });
33
+ });
34
+ describe('bool', () => {
35
+ it('returns booleans and falls back otherwise', () => {
36
+ expect(bool(true, false)).toBe(true);
37
+ expect(bool(false, true)).toBe(false);
38
+ expect(bool('true', true)).toBe(true);
39
+ });
40
+ });
41
+ describe('oneOf', () => {
42
+ it('accepts allowed literals and falls back otherwise', () => {
43
+ expect(oneOf('delay', ['interval', 'delay'], 'interval')).toBe('delay');
44
+ expect(oneOf('other', ['interval', 'delay'], 'interval')).toBe('interval');
45
+ expect(oneOf(1, ['interval', 'delay'], 'interval')).toBe('interval');
46
+ });
47
+ });
48
+ describe('numArray', () => {
49
+ it('filters to finite numbers', () => {
50
+ expect(numArray([1, NaN, 2, 'x', Infinity])).toEqual([1, 2]);
51
+ expect(numArray(null)).toEqual([]);
52
+ });
53
+ });
54
+ describe('strArray', () => {
55
+ it('filters to strings', () => {
56
+ expect(strArray(['a', 1, 'b'])).toEqual(['a', 'b']);
57
+ expect(strArray(undefined)).toEqual([]);
58
+ });
59
+ });
60
+ describe('arrayOf', () => {
61
+ it('filters through a guard', () => {
62
+ const isNum = (entry) => typeof entry === 'number' && Number.isFinite(entry);
63
+ expect(arrayOf([1, 'x', 2], isNum)).toEqual([1, 2]);
64
+ expect(arrayOf('nope', isNum)).toEqual([]);
65
+ });
66
+ });
67
+ describe('recordOf', () => {
68
+ it('keeps entries that pass the guard', () => {
69
+ const isNum = (entry) => typeof entry === 'number' && Number.isFinite(entry);
70
+ expect(recordOf({
71
+ a: 1,
72
+ b: 'x',
73
+ c: 2
74
+ }, isNum)).toEqual({ a: 1, c: 2 });
75
+ expect(recordOf([], isNum)).toEqual({});
76
+ expect(recordOf(null, isNum)).toEqual({});
77
+ });
78
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=store.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.test.d.ts","sourceRoot":"","sources":["../src/store.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,248 @@
1
+ import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
2
+ import { createExternalStore, createStorageStore, setIntervalDisposable, syncOnWindowFocus } from './runtime/store.js';
3
+ /**
4
+ * Builds an in-memory storage mock for store tests.
5
+ *
6
+ * @param initial - Seed values keyed by storage key.
7
+ */
8
+ function createMockStorage(initial = {}) {
9
+ const data = { ...initial };
10
+ const getMock = jest.fn(async (key) => data[key]);
11
+ const setMock = jest.fn(async (key, value) => {
12
+ data[key] = value;
13
+ });
14
+ return {
15
+ data,
16
+ get: getMock,
17
+ set: setMock,
18
+ getMock,
19
+ setMock
20
+ };
21
+ }
22
+ describe('createExternalStore', () => {
23
+ it('notifies subscribers when setState is called', () => {
24
+ const store = createExternalStore(0);
25
+ const listener = jest.fn();
26
+ store.subscribe(listener);
27
+ store.setState(1);
28
+ expect(listener).toHaveBeenCalledTimes(1);
29
+ expect(store.getSnapshot()).toBe(1);
30
+ });
31
+ });
32
+ describe('createStorageStore', () => {
33
+ it('starts from parse(undefined)', () => {
34
+ const storage = createMockStorage();
35
+ const store = createStorageStore({
36
+ storage,
37
+ key: 'items',
38
+ parse: (raw) => (Array.isArray(raw) ? raw : [])
39
+ });
40
+ expect(store.getSnapshot()).toEqual([]);
41
+ });
42
+ it('persists and updates the snapshot via set', async () => {
43
+ const storage = createMockStorage();
44
+ const store = createStorageStore({
45
+ storage,
46
+ key: 'items',
47
+ parse: (raw) => (Array.isArray(raw) ? raw : [])
48
+ });
49
+ const listener = jest.fn();
50
+ store.subscribe(listener);
51
+ await store.set(['a']);
52
+ expect(store.getSnapshot()).toEqual(['a']);
53
+ expect(storage.setMock).toHaveBeenCalledWith('items', ['a']);
54
+ expect(listener).toHaveBeenCalledTimes(1);
55
+ });
56
+ it('skips set when equals reports no change', async () => {
57
+ const storage = createMockStorage({ items: ['a'] });
58
+ const store = createStorageStore({
59
+ storage,
60
+ key: 'items',
61
+ parse: (raw) => (Array.isArray(raw) ? raw : [])
62
+ });
63
+ await store.reloadFromStorage();
64
+ const listener = jest.fn();
65
+ store.subscribe(listener);
66
+ await store.set(['a']);
67
+ expect(listener).not.toHaveBeenCalled();
68
+ expect(storage.setMock).not.toHaveBeenCalled();
69
+ });
70
+ it('reloadFromStorage applies parsed storage values', async () => {
71
+ const storage = createMockStorage({ count: 3 });
72
+ const store = createStorageStore({
73
+ storage,
74
+ key: 'count',
75
+ parse: (raw) => (typeof raw === 'number' ? raw : 0)
76
+ });
77
+ const listener = jest.fn();
78
+ store.subscribe(listener);
79
+ await store.reloadFromStorage();
80
+ expect(store.getSnapshot()).toBe(3);
81
+ expect(listener).toHaveBeenCalledTimes(1);
82
+ });
83
+ it('reloadFromStorage skips notify when parsed value is unchanged', async () => {
84
+ const storage = createMockStorage({ count: 2 });
85
+ const store = createStorageStore({
86
+ storage,
87
+ key: 'count',
88
+ parse: (raw) => (typeof raw === 'number' ? raw : 0)
89
+ });
90
+ await store.reloadFromStorage();
91
+ const listener = jest.fn();
92
+ store.subscribe(listener);
93
+ await store.reloadFromStorage();
94
+ expect(listener).not.toHaveBeenCalled();
95
+ });
96
+ it('uses a custom equals function', async () => {
97
+ const storage = createMockStorage();
98
+ const store = createStorageStore({
99
+ storage,
100
+ key: 'status',
101
+ parse: (raw) => raw && typeof raw === 'object' && 'running' in raw
102
+ ? raw
103
+ : { running: false },
104
+ equals: (a, b) => a.running === b.running
105
+ });
106
+ await store.set({ running: true });
107
+ const listener = jest.fn();
108
+ store.subscribe(listener);
109
+ storage.data.status = { running: true, port: 8080 };
110
+ await store.reloadFromStorage();
111
+ expect(listener).not.toHaveBeenCalled();
112
+ expect(store.getSnapshot()).toEqual({ running: true });
113
+ });
114
+ it('keeps the current snapshot when storage is missing and keepCurrentWhenMissing is true', async () => {
115
+ const storage = createMockStorage();
116
+ const store = createStorageStore({
117
+ storage,
118
+ key: 'status',
119
+ parse: () => ({ running: false }),
120
+ keepCurrentWhenMissing: true
121
+ });
122
+ await store.set({ running: true });
123
+ delete storage.data.status;
124
+ const listener = jest.fn();
125
+ store.subscribe(listener);
126
+ await store.reloadFromStorage();
127
+ expect(listener).not.toHaveBeenCalled();
128
+ expect(store.getSnapshot()).toEqual({ running: true });
129
+ });
130
+ });
131
+ describe('setIntervalDisposable', () => {
132
+ beforeEach(() => {
133
+ jest.useFakeTimers();
134
+ });
135
+ afterEach(() => {
136
+ jest.useRealTimers();
137
+ });
138
+ it('invokes the callback on an interval until disposed', () => {
139
+ const callback = jest.fn();
140
+ const disposable = setIntervalDisposable(callback, 100);
141
+ jest.advanceTimersByTime(250);
142
+ expect(callback).toHaveBeenCalledTimes(2);
143
+ disposable.dispose();
144
+ jest.advanceTimersByTime(200);
145
+ expect(callback).toHaveBeenCalledTimes(2);
146
+ });
147
+ });
148
+ describe('syncOnWindowFocus', () => {
149
+ const focusListeners = new Set();
150
+ const visibilityListeners = new Set();
151
+ const originalWindow = globalThis.window;
152
+ const originalDocument = globalThis.document;
153
+ beforeEach(() => {
154
+ jest.useFakeTimers();
155
+ focusListeners.clear();
156
+ visibilityListeners.clear();
157
+ globalThis.window = {
158
+ addEventListener: (type, listener) => {
159
+ if (type === 'focus' && typeof listener === 'function') {
160
+ focusListeners.add(listener);
161
+ }
162
+ },
163
+ removeEventListener: (type, listener) => {
164
+ if (type === 'focus' && typeof listener === 'function') {
165
+ focusListeners.delete(listener);
166
+ }
167
+ },
168
+ dispatchEvent: (event) => {
169
+ if (event.type === 'focus') {
170
+ for (const listener of focusListeners) {
171
+ listener(event);
172
+ }
173
+ }
174
+ return true;
175
+ }
176
+ };
177
+ globalThis.document = {
178
+ addEventListener: (type, listener) => {
179
+ if (type === 'visibilitychange' && typeof listener === 'function') {
180
+ visibilityListeners.add(listener);
181
+ }
182
+ },
183
+ removeEventListener: (type, listener) => {
184
+ if (type === 'visibilitychange' && typeof listener === 'function') {
185
+ visibilityListeners.delete(listener);
186
+ }
187
+ },
188
+ dispatchEvent: (event) => {
189
+ if (event.type === 'visibilitychange') {
190
+ for (const listener of visibilityListeners) {
191
+ listener(event);
192
+ }
193
+ }
194
+ return true;
195
+ }
196
+ };
197
+ });
198
+ afterEach(() => {
199
+ jest.useRealTimers();
200
+ globalThis.window = originalWindow;
201
+ globalThis.document = originalDocument;
202
+ });
203
+ it('reloads stores on focus, visibility change, mount, and interval', async () => {
204
+ const storage = createMockStorage({ count: 1 });
205
+ const store = createStorageStore({
206
+ storage,
207
+ key: 'count',
208
+ parse: (raw) => (typeof raw === 'number' ? raw : 0)
209
+ });
210
+ const reloadSpy = jest.spyOn(store, 'reloadFromStorage');
211
+ const disposable = syncOnWindowFocus(store, { intervalMs: 500 });
212
+ expect(reloadSpy).toHaveBeenCalledTimes(1);
213
+ storage.data.count = 2;
214
+ window.dispatchEvent(new Event('focus'));
215
+ expect(reloadSpy).toHaveBeenCalledTimes(2);
216
+ storage.data.count = 3;
217
+ document.dispatchEvent(new Event('visibilitychange'));
218
+ expect(reloadSpy).toHaveBeenCalledTimes(3);
219
+ storage.data.count = 4;
220
+ jest.advanceTimersByTime(500);
221
+ expect(reloadSpy).toHaveBeenCalledTimes(4);
222
+ disposable.dispose();
223
+ storage.data.count = 5;
224
+ window.dispatchEvent(new Event('focus'));
225
+ jest.advanceTimersByTime(500);
226
+ expect(reloadSpy).toHaveBeenCalledTimes(4);
227
+ await store.reloadFromStorage();
228
+ expect(store.getSnapshot()).toBe(5);
229
+ });
230
+ it('reloads multiple stores together', async () => {
231
+ const storage = createMockStorage();
232
+ const first = createStorageStore({
233
+ storage,
234
+ key: 'first',
235
+ parse: (raw) => (typeof raw === 'number' ? raw : 0)
236
+ });
237
+ const second = createStorageStore({
238
+ storage,
239
+ key: 'second',
240
+ parse: (raw) => (typeof raw === 'number' ? raw : 0)
241
+ });
242
+ const reloadFirst = jest.spyOn(first, 'reloadFromStorage');
243
+ const reloadSecond = jest.spyOn(second, 'reloadFromStorage');
244
+ syncOnWindowFocus([first, second]).dispose();
245
+ expect(reloadFirst).toHaveBeenCalledTimes(1);
246
+ expect(reloadSecond).toHaveBeenCalledTimes(1);
247
+ });
248
+ });
package/dist/types.d.ts CHANGED
@@ -358,6 +358,13 @@ export interface RequestTabContext {
358
358
  * Empty variable values fall back to each variable's defaultValue (same as Send).
359
359
  */
360
360
  variables: Record<string, string>;
361
+ /**
362
+ * Stable per-request identifier for namespacing persistent plugin state.
363
+ *
364
+ * Saved requests use `req:<id>` and remain stable across edits and restarts.
365
+ * Unsaved tabs fall back to a best-effort `METHOD url` fingerprint.
366
+ */
367
+ requestKey: string;
361
368
  }
362
369
  /**
363
370
  * Adds a segmented tab to the request editor (alongside Params, Headers, Body, and so on).
@@ -388,6 +395,13 @@ export interface ResponseTabContext {
388
395
  * Last response, or `null` when no response exists yet.
389
396
  */
390
397
  response: HttpResponse | null;
398
+ /**
399
+ * Stable per-request identifier for namespacing persistent plugin state.
400
+ *
401
+ * Saved requests use `req:<id>` and remain stable across edits and restarts.
402
+ * Unsaved tabs fall back to a best-effort `METHOD url` fingerprint.
403
+ */
404
+ requestKey: string;
391
405
  }
392
406
  /**
393
407
  * Adds a tab to the response viewer (alongside Body, Headers, Tests).