@fias/arche-sdk 2.2.1 → 2.10.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/dist/bridge.d.ts +76 -1
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +219 -6
- package/dist/bridge.js.map +1 -1
- package/dist/bridge.test.js +155 -6
- package/dist/bridge.test.js.map +1 -1
- package/dist/coverage-fills.test.js +26 -32
- package/dist/coverage-fills.test.js.map +1 -1
- package/dist/entity-ops.d.ts +55 -0
- package/dist/entity-ops.d.ts.map +1 -0
- package/dist/entity-ops.js +48 -0
- package/dist/entity-ops.js.map +1 -0
- package/dist/fias.d.ts +7 -1
- package/dist/fias.d.ts.map +1 -1
- package/dist/fias.js +19 -12
- package/dist/fias.js.map +1 -1
- package/dist/generated/fonts.d.ts.map +1 -1
- package/dist/generated/fonts.js +11 -0
- package/dist/generated/fonts.js.map +1 -1
- package/dist/generated/permissions.d.ts +7 -1
- package/dist/generated/permissions.d.ts.map +1 -1
- package/dist/generated/permissions.js +36 -1
- package/dist/generated/permissions.js.map +1 -1
- package/dist/generated/surfaces.d.ts +424 -0
- package/dist/generated/surfaces.d.ts.map +1 -1
- package/dist/generated/surfaces.js +97 -1
- package/dist/generated/surfaces.js.map +1 -1
- package/dist/hooks.d.ts +230 -3
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +519 -72
- package/dist/hooks.js.map +1 -1
- package/dist/hooks.test.js +322 -219
- package/dist/hooks.test.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +977 -97
- package/dist/protocol.d.ts +7 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +7 -0
- package/dist/protocol.js.map +1 -1
- package/dist/provider.d.ts +50 -1
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +187 -2
- package/dist/provider.js.map +1 -1
- package/dist/provider.render.test.d.ts +17 -0
- package/dist/provider.render.test.d.ts.map +1 -0
- package/dist/provider.render.test.js +191 -0
- package/dist/provider.render.test.js.map +1 -0
- package/dist/provider.test.js +64 -8
- package/dist/provider.test.js.map +1 -1
- package/dist/types.d.ts +339 -13
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -1
- package/templates/default/AGENTS.md +328 -26
- package/templates/default/CLAUDE.md +328 -26
package/dist/hooks.test.js
CHANGED
|
@@ -41,6 +41,18 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
41
41
|
})();
|
|
42
42
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
43
43
|
const theme_1 = require("./theme");
|
|
44
|
+
const entity_ops_1 = require("./entity-ops");
|
|
45
|
+
/**
|
|
46
|
+
* Expected `bridge.request` args for a verb call migrated onto `entity_invoke`
|
|
47
|
+
* (ADR-012 Phase L, Job 3). Spread into `toHaveBeenCalledWith(...)`.
|
|
48
|
+
*/
|
|
49
|
+
function entityOp(entityId, op, args = {}) {
|
|
50
|
+
return ['entity_invoke', { entityId, payload: { op, ...args } }];
|
|
51
|
+
}
|
|
52
|
+
/** Wrap a value the way the server's `entity_invoke` envelope does. */
|
|
53
|
+
function output(value) {
|
|
54
|
+
return { output: value };
|
|
55
|
+
}
|
|
44
56
|
// Build a mock bridge that tracks calls
|
|
45
57
|
function createMockBridge() {
|
|
46
58
|
return {
|
|
@@ -57,6 +69,10 @@ function createMockBridge() {
|
|
|
57
69
|
onThemeUpdate: jest.fn().mockReturnValue(() => { }),
|
|
58
70
|
onNavigationUpdate: jest.fn().mockReturnValue(() => { }),
|
|
59
71
|
onStepNavigationUpdate: jest.fn().mockReturnValue(() => { }),
|
|
72
|
+
registerPreviewState: jest.fn().mockReturnValue(() => { }),
|
|
73
|
+
onSubscriptionEvent: jest.fn().mockReturnValue(() => { }),
|
|
74
|
+
onSubscriptionEnded: jest.fn().mockReturnValue(() => { }),
|
|
75
|
+
releaseSubscription: jest.fn(),
|
|
60
76
|
attach: jest.fn(),
|
|
61
77
|
destroy: jest.fn(),
|
|
62
78
|
};
|
|
@@ -124,6 +140,54 @@ describe('SDK hooks', () => {
|
|
|
124
140
|
const { useFiasUser } = require('./hooks');
|
|
125
141
|
expect(() => useFiasUser()).toThrow('useBridge must be used within a <FiasProvider>');
|
|
126
142
|
});
|
|
143
|
+
it('records the error when get_user rejects (no silent swallow)', async () => {
|
|
144
|
+
const { useFiasUser } = require('./hooks');
|
|
145
|
+
mockBridge.request.mockRejectedValue(new Error('Permission denied: user:profile:read not granted'));
|
|
146
|
+
const result = useFiasUser();
|
|
147
|
+
expect(result).toBeNull();
|
|
148
|
+
// Let the rejection handler run.
|
|
149
|
+
await Promise.resolve();
|
|
150
|
+
await Promise.resolve();
|
|
151
|
+
// The setter (shared between the user/error useState slots) is called
|
|
152
|
+
// with the Error — proving the rejection path ran instead of being
|
|
153
|
+
// swallowed into a permanent null.
|
|
154
|
+
expect(mockSetState).toHaveBeenCalledWith(expect.any(Error));
|
|
155
|
+
const errorArg = mockSetState.mock.calls
|
|
156
|
+
.map((c) => c[0])
|
|
157
|
+
.find((a) => a instanceof Error);
|
|
158
|
+
expect(errorArg.message).toContain('user:profile:read');
|
|
159
|
+
});
|
|
160
|
+
it('throws the error during render so it hits an error boundary', () => {
|
|
161
|
+
const react = require('react');
|
|
162
|
+
// user slot → null; error slot → a populated error.
|
|
163
|
+
react.useState
|
|
164
|
+
.mockReturnValueOnce([null, mockSetState])
|
|
165
|
+
.mockReturnValueOnce([
|
|
166
|
+
new Error('Permission denied: user:profile:read not granted'),
|
|
167
|
+
mockSetState,
|
|
168
|
+
]);
|
|
169
|
+
const { useFiasUser } = require('./hooks');
|
|
170
|
+
expect(() => useFiasUser()).toThrow('user:profile:read');
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
describe('useFiasPreviewState', () => {
|
|
174
|
+
it('registers a keyed provider + restore handler with the bridge', () => {
|
|
175
|
+
const { useFiasPreviewState } = require('./hooks');
|
|
176
|
+
const getState = jest.fn(() => ({ rows: 5 }));
|
|
177
|
+
const onRestore = jest.fn();
|
|
178
|
+
useFiasPreviewState('report', getState, onRestore);
|
|
179
|
+
expect(mockBridge.registerPreviewState).toHaveBeenCalledWith('report', expect.any(Function), expect.any(Function));
|
|
180
|
+
// The wrappers proxy to the live getState/onRestore (via refs).
|
|
181
|
+
const [, provider, restore] = mockBridge.registerPreviewState.mock.calls[0];
|
|
182
|
+
expect(provider()).toEqual({ rows: 5 });
|
|
183
|
+
restore({ rows: 9 });
|
|
184
|
+
expect(onRestore).toHaveBeenCalledWith({ rows: 9 });
|
|
185
|
+
});
|
|
186
|
+
it('throws when used outside FiasProvider', () => {
|
|
187
|
+
mockContextValue = null;
|
|
188
|
+
const { useFiasPreviewState } = require('./hooks');
|
|
189
|
+
expect(() => useFiasPreviewState('k', () => null, () => { })).toThrow('useBridge must be used within a <FiasProvider>');
|
|
190
|
+
});
|
|
127
191
|
});
|
|
128
192
|
describe('useFiasTheme', () => {
|
|
129
193
|
it('returns initial theme from bridge and subscribes to updates', () => {
|
|
@@ -143,32 +207,48 @@ describe('SDK hooks', () => {
|
|
|
143
207
|
expect(capturedEffectCleanup).toBe(unsubscribe);
|
|
144
208
|
});
|
|
145
209
|
});
|
|
210
|
+
describe('useFiasNavigation', () => {
|
|
211
|
+
it('navigateTo and openArche call the bridge with the right ops', () => {
|
|
212
|
+
const { useFiasNavigation } = require('./hooks');
|
|
213
|
+
const nav = useFiasNavigation();
|
|
214
|
+
nav.navigateTo('/settings');
|
|
215
|
+
expect(mockBridge.request).toHaveBeenCalledWith('navigate', { path: '/settings' });
|
|
216
|
+
nav.openArche('arc_0123456789abcdef0123456789abcdef', { newTab: true });
|
|
217
|
+
expect(mockBridge.request).toHaveBeenCalledWith('open_arche', {
|
|
218
|
+
archeId: 'arc_0123456789abcdef0123456789abcdef',
|
|
219
|
+
newTab: true,
|
|
220
|
+
});
|
|
221
|
+
// newTab defaults to false when opts is omitted.
|
|
222
|
+
nav.openArche('arche_guilds');
|
|
223
|
+
expect(mockBridge.request).toHaveBeenCalledWith('open_arche', {
|
|
224
|
+
archeId: 'arche_guilds',
|
|
225
|
+
newTab: false,
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
});
|
|
146
229
|
describe('useFiasStorage', () => {
|
|
147
230
|
it('returns storage API methods that call bridge.request', async () => {
|
|
148
231
|
const { useFiasStorage } = require('./hooks');
|
|
149
232
|
const storage = useFiasStorage();
|
|
150
|
-
mockBridge.request.mockResolvedValue('file content');
|
|
233
|
+
mockBridge.request.mockResolvedValue(output({ content: 'file content', exists: true }));
|
|
151
234
|
await storage.readFile('/test.txt');
|
|
152
|
-
expect(mockBridge.request).toHaveBeenCalledWith('
|
|
153
|
-
mockBridge.request.mockResolvedValue(
|
|
235
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_STORAGE_ENTITY_ID, 'read', { path: '/test.txt' }));
|
|
236
|
+
mockBridge.request.mockResolvedValue(output({ success: true }));
|
|
154
237
|
await storage.writeFile('/test.txt', 'data');
|
|
155
|
-
expect(mockBridge.request).toHaveBeenCalledWith('
|
|
156
|
-
|
|
157
|
-
content: 'data',
|
|
158
|
-
});
|
|
159
|
-
mockBridge.request.mockResolvedValue(['a.txt', 'b.txt']);
|
|
238
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_STORAGE_ENTITY_ID, 'write', { path: '/test.txt', content: 'data' }));
|
|
239
|
+
mockBridge.request.mockResolvedValue(output({ files: ['a.txt', 'b.txt'] }));
|
|
160
240
|
await storage.listFiles('/prefix');
|
|
161
|
-
expect(mockBridge.request).toHaveBeenCalledWith('
|
|
162
|
-
mockBridge.request.mockResolvedValue(
|
|
241
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_STORAGE_ENTITY_ID, 'list', { prefix: '/prefix' }));
|
|
242
|
+
mockBridge.request.mockResolvedValue(output({ success: true }));
|
|
163
243
|
await storage.deleteFile('/test.txt');
|
|
164
|
-
expect(mockBridge.request).toHaveBeenCalledWith('
|
|
244
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_STORAGE_ENTITY_ID, 'delete', { path: '/test.txt' }));
|
|
165
245
|
});
|
|
166
246
|
it('calls listFiles without prefix', async () => {
|
|
167
247
|
const { useFiasStorage } = require('./hooks');
|
|
168
248
|
const storage = useFiasStorage();
|
|
169
|
-
mockBridge.request.mockResolvedValue([]);
|
|
249
|
+
mockBridge.request.mockResolvedValue(output({ files: [] }));
|
|
170
250
|
await storage.listFiles();
|
|
171
|
-
expect(mockBridge.request).toHaveBeenCalledWith('
|
|
251
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_STORAGE_ENTITY_ID, 'list', { prefix: undefined }));
|
|
172
252
|
});
|
|
173
253
|
});
|
|
174
254
|
describe('useEntityInvocation', () => {
|
|
@@ -211,6 +291,23 @@ describe('SDK hooks', () => {
|
|
|
211
291
|
await expect(api.invoke({ entityId: 'ent_test', input: 'hello' })).rejects.toThrow('string error');
|
|
212
292
|
});
|
|
213
293
|
});
|
|
294
|
+
describe('NLLB translation entity', () => {
|
|
295
|
+
it('exposes the NLLB_LANGUAGES picker list keyed by FLORES code', () => {
|
|
296
|
+
const { NLLB_LANGUAGES } = require('./hooks');
|
|
297
|
+
expect(Array.isArray(NLLB_LANGUAGES)).toBe(true);
|
|
298
|
+
const spanish = NLLB_LANGUAGES.find((l) => l.code === 'spa_Latn');
|
|
299
|
+
expect(spanish?.label).toBe('Spanish');
|
|
300
|
+
});
|
|
301
|
+
it('NLLB_LANGUAGES codes are well-formed FLORES-200 and unique (guards typos/drift)', () => {
|
|
302
|
+
const { NLLB_LANGUAGES } = require('./hooks');
|
|
303
|
+
const codes = NLLB_LANGUAGES.map((l) => l.code);
|
|
304
|
+
for (const l of NLLB_LANGUAGES) {
|
|
305
|
+
expect(l.label.length).toBeGreaterThan(0);
|
|
306
|
+
expect(l.code).toMatch(/^[a-z]{3}_[A-Z][a-z]{3}$/);
|
|
307
|
+
}
|
|
308
|
+
expect(new Set(codes).size).toBe(codes.length); // no duplicate codes
|
|
309
|
+
});
|
|
310
|
+
});
|
|
214
311
|
describe('useFiasNavigation', () => {
|
|
215
312
|
it('returns current path and subscribes to updates', () => {
|
|
216
313
|
mockBridge.getCurrentPath.mockReturnValue('/current');
|
|
@@ -261,16 +358,14 @@ describe('SDK hooks', () => {
|
|
|
261
358
|
it('without persistKey, does not touch storage on mount or setCurrentStep', () => {
|
|
262
359
|
const { useStepNavigation } = require('./hooks');
|
|
263
360
|
const result = useStepNavigation('step-1');
|
|
264
|
-
expect(mockBridge.request).not.toHaveBeenCalledWith('
|
|
361
|
+
expect(mockBridge.request).not.toHaveBeenCalledWith('entity_invoke', expect.anything());
|
|
265
362
|
result.setCurrentStep('step-2');
|
|
266
|
-
expect(mockBridge.request).not.toHaveBeenCalledWith('
|
|
363
|
+
expect(mockBridge.request).not.toHaveBeenCalledWith('entity_invoke', expect.anything());
|
|
267
364
|
});
|
|
268
365
|
it('with persistKey, reads persisted value on mount', () => {
|
|
269
366
|
const { useStepNavigation } = require('./hooks');
|
|
270
367
|
useStepNavigation('step-1', { persistKey: 'currentStep' });
|
|
271
|
-
expect(mockBridge.request).toHaveBeenCalledWith('
|
|
272
|
-
path: '__state/currentStep',
|
|
273
|
-
});
|
|
368
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_STORAGE_ENTITY_ID, 'read', { path: '__state/currentStep' }));
|
|
274
369
|
});
|
|
275
370
|
it('with persistKey, writes on setCurrentStep (after debounce flush)', () => {
|
|
276
371
|
jest.useFakeTimers();
|
|
@@ -281,12 +376,12 @@ describe('SDK hooks', () => {
|
|
|
281
376
|
result.setCurrentStep('step-2');
|
|
282
377
|
expect(mockSetState).toHaveBeenCalledWith('step-2');
|
|
283
378
|
// Setter updates in-memory state immediately; the write is debounced.
|
|
284
|
-
expect(mockBridge.request).not.toHaveBeenCalledWith('
|
|
379
|
+
expect(mockBridge.request).not.toHaveBeenCalledWith('entity_invoke', expect.anything());
|
|
285
380
|
jest.advanceTimersByTime(260);
|
|
286
|
-
expect(mockBridge.request).toHaveBeenCalledWith('
|
|
381
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_STORAGE_ENTITY_ID, 'write', {
|
|
287
382
|
path: '__state/currentStep',
|
|
288
383
|
content: JSON.stringify('step-2'),
|
|
289
|
-
});
|
|
384
|
+
}));
|
|
290
385
|
}
|
|
291
386
|
finally {
|
|
292
387
|
jest.useRealTimers();
|
|
@@ -307,20 +402,17 @@ describe('SDK hooks', () => {
|
|
|
307
402
|
hostCallback('step-from-host');
|
|
308
403
|
expect(mockSetState).toHaveBeenCalledWith('step-from-host');
|
|
309
404
|
jest.advanceTimersByTime(260);
|
|
310
|
-
expect(mockBridge.request).toHaveBeenCalledWith('
|
|
405
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_STORAGE_ENTITY_ID, 'write', {
|
|
311
406
|
path: '__state/currentStep',
|
|
312
407
|
content: JSON.stringify('step-from-host'),
|
|
313
|
-
});
|
|
408
|
+
}));
|
|
314
409
|
}
|
|
315
410
|
finally {
|
|
316
411
|
jest.useRealTimers();
|
|
317
412
|
}
|
|
318
413
|
});
|
|
319
414
|
it('with persistKey, persisted value wins over initialStep on mount', async () => {
|
|
320
|
-
mockBridge.request.mockResolvedValue({
|
|
321
|
-
content: JSON.stringify('step-persisted'),
|
|
322
|
-
exists: true,
|
|
323
|
-
});
|
|
415
|
+
mockBridge.request.mockResolvedValue(output({ content: JSON.stringify('step-persisted'), exists: true }));
|
|
324
416
|
const { useStepNavigation } = require('./hooks');
|
|
325
417
|
useStepNavigation('step-initial', { persistKey: 'currentStep' });
|
|
326
418
|
// Let the storage_read promise resolve
|
|
@@ -332,26 +424,39 @@ describe('SDK hooks', () => {
|
|
|
332
424
|
});
|
|
333
425
|
describe('usePersistentState', () => {
|
|
334
426
|
function countStorageWrites(key) {
|
|
335
|
-
return mockBridge.request.mock.calls.filter((call) =>
|
|
427
|
+
return mockBridge.request.mock.calls.filter((call) => {
|
|
428
|
+
if (call[0] !== 'entity_invoke')
|
|
429
|
+
return false;
|
|
430
|
+
const payload = call[1].payload;
|
|
431
|
+
return payload?.op === 'write' && payload?.path === `__state/${key}`;
|
|
432
|
+
}).length;
|
|
336
433
|
}
|
|
337
434
|
it('reads initial value from storage on mount', () => {
|
|
338
435
|
const { usePersistentState } = require('./hooks');
|
|
339
436
|
usePersistentState('counter', 0);
|
|
340
|
-
expect(mockBridge.request).toHaveBeenCalledWith('
|
|
341
|
-
path: '__state/counter',
|
|
342
|
-
});
|
|
437
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_STORAGE_ENTITY_ID, 'read', { path: '__state/counter' }));
|
|
343
438
|
});
|
|
344
439
|
it('applies persisted value after storage_read resolves', async () => {
|
|
345
|
-
mockBridge.request.mockResolvedValue({
|
|
346
|
-
content: JSON.stringify(42),
|
|
347
|
-
exists: true,
|
|
348
|
-
});
|
|
440
|
+
mockBridge.request.mockResolvedValue(output({ content: JSON.stringify(42), exists: true }));
|
|
349
441
|
const { usePersistentState } = require('./hooks');
|
|
350
442
|
usePersistentState('counter', 0);
|
|
351
443
|
await Promise.resolve();
|
|
352
444
|
await Promise.resolve();
|
|
353
445
|
expect(mockSetState).toHaveBeenCalledWith(42);
|
|
354
446
|
});
|
|
447
|
+
it('falls back to the initial value when the mount storage_read is denied/fails', async () => {
|
|
448
|
+
// e.g. the plugin didn't declare `storage:sandbox`. Must not become a
|
|
449
|
+
// silent unhandled rejection, and must not clobber the initial value.
|
|
450
|
+
mockBridge.request.mockRejectedValue(new Error('Permission denied: storage:sandbox'));
|
|
451
|
+
const { usePersistentState } = require('./hooks');
|
|
452
|
+
const [value] = usePersistentState('counter', 7);
|
|
453
|
+
// Let the rejection handler run.
|
|
454
|
+
await Promise.resolve();
|
|
455
|
+
await Promise.resolve();
|
|
456
|
+
// No persisted value was applied — the setter was never called with data.
|
|
457
|
+
expect(mockSetState).not.toHaveBeenCalled();
|
|
458
|
+
expect(value).toBe(7);
|
|
459
|
+
});
|
|
355
460
|
it('setter updates in-memory value synchronously, defers write', () => {
|
|
356
461
|
jest.useFakeTimers();
|
|
357
462
|
try {
|
|
@@ -365,10 +470,10 @@ describe('SDK hooks', () => {
|
|
|
365
470
|
expect(countStorageWrites('counter')).toBe(0);
|
|
366
471
|
jest.advanceTimersByTime(260);
|
|
367
472
|
expect(countStorageWrites('counter')).toBe(1);
|
|
368
|
-
expect(mockBridge.request).toHaveBeenCalledWith('
|
|
473
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_STORAGE_ENTITY_ID, 'write', {
|
|
369
474
|
path: '__state/counter',
|
|
370
475
|
content: JSON.stringify(1),
|
|
371
|
-
});
|
|
476
|
+
}));
|
|
372
477
|
}
|
|
373
478
|
finally {
|
|
374
479
|
jest.useRealTimers();
|
|
@@ -390,14 +495,15 @@ describe('SDK hooks', () => {
|
|
|
390
495
|
expect(writesDuringBurst).toBeLessThanOrEqual(1);
|
|
391
496
|
// Let the trailing-edge flush fire
|
|
392
497
|
jest.advanceTimersByTime(260);
|
|
393
|
-
const writes = mockBridge.request.mock.calls.filter((call) => call[0] === '
|
|
498
|
+
const writes = mockBridge.request.mock.calls.filter((call) => call[0] === 'entity_invoke' &&
|
|
499
|
+
call[1].payload?.op === 'write');
|
|
394
500
|
// Total writes over the burst: at most 2 (one at maxWait, one trailing).
|
|
395
501
|
// Never 60. Final write contains the last value.
|
|
396
502
|
expect(writes.length).toBeGreaterThanOrEqual(1);
|
|
397
503
|
expect(writes.length).toBeLessThanOrEqual(2);
|
|
398
504
|
expect(writes[writes.length - 1][1]).toEqual({
|
|
399
|
-
|
|
400
|
-
content: JSON.stringify(60),
|
|
505
|
+
entityId: entity_ops_1.PLUGIN_STORAGE_ENTITY_ID,
|
|
506
|
+
payload: { op: 'write', path: '__state/counter', content: JSON.stringify(60) },
|
|
401
507
|
});
|
|
402
508
|
}
|
|
403
509
|
finally {
|
|
@@ -438,10 +544,10 @@ describe('SDK hooks', () => {
|
|
|
438
544
|
expect(capturedEffectCleanup).not.toBeNull();
|
|
439
545
|
capturedEffectCleanup();
|
|
440
546
|
expect(countStorageWrites('counter')).toBe(1);
|
|
441
|
-
expect(mockBridge.request).toHaveBeenCalledWith('
|
|
547
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_STORAGE_ENTITY_ID, 'write', {
|
|
442
548
|
path: '__state/counter',
|
|
443
549
|
content: JSON.stringify(7),
|
|
444
|
-
});
|
|
550
|
+
}));
|
|
445
551
|
}
|
|
446
552
|
finally {
|
|
447
553
|
jest.useRealTimers();
|
|
@@ -724,139 +830,49 @@ describe('SDK hooks', () => {
|
|
|
724
830
|
});
|
|
725
831
|
expect(fin.status).toBe('uploaded');
|
|
726
832
|
});
|
|
727
|
-
it('upload()
|
|
833
|
+
it('upload() delegates to the host vault_documents_upload op with base64 bytes', async () => {
|
|
728
834
|
const { useVaultDocuments } = freshHooks();
|
|
729
835
|
const api = useVaultDocuments();
|
|
730
|
-
//
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
const
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
'Content-Type': 'application/octet-stream',
|
|
748
|
-
'Content-Length': '4',
|
|
749
|
-
'x-amz-checksum-sha256': 'aaa',
|
|
750
|
-
'x-amz-tagging': 'pending=true',
|
|
751
|
-
},
|
|
752
|
-
uploadUrlExpiresAt: '2026-05-21T00:05:00Z',
|
|
753
|
-
});
|
|
754
|
-
mockBridge.request.mockResolvedValueOnce({
|
|
755
|
-
documentId: 'vdoc_u',
|
|
756
|
-
status: 'uploaded',
|
|
757
|
-
});
|
|
758
|
-
const bytes = new Uint8Array([1, 2, 3, 4]);
|
|
759
|
-
const result = await api.upload(bytes, {
|
|
760
|
-
name: 'tiny.bin',
|
|
761
|
-
mimeType: 'application/octet-stream',
|
|
762
|
-
});
|
|
763
|
-
// SDK rendered the digest as lowercase hex.
|
|
764
|
-
const expectedHex = Array.from(fakeDigest)
|
|
765
|
-
.map((b) => b.toString(16).padStart(2, '0'))
|
|
766
|
-
.join('');
|
|
767
|
-
expect(mockBridge.request).toHaveBeenNthCalledWith(1, 'vault_documents_upload_init', expect.objectContaining({
|
|
768
|
-
sizeBytes: 4,
|
|
769
|
-
checksumSha256: expectedHex,
|
|
770
|
-
}));
|
|
771
|
-
// Browser fetch was called with Content-Length stripped from the
|
|
772
|
-
// headers (browser auto-computes it from body.byteLength).
|
|
773
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
774
|
-
const fetchOpts = fetchMock.mock.calls[0][1];
|
|
775
|
-
const headerKeys = Object.keys(fetchOpts.headers).map((k) => k.toLowerCase());
|
|
776
|
-
expect(headerKeys).not.toContain('content-length');
|
|
777
|
-
expect(headerKeys).toContain('content-type');
|
|
778
|
-
expect(headerKeys).toContain('x-amz-checksum-sha256');
|
|
779
|
-
// Finalize used the same hex digest.
|
|
780
|
-
expect(mockBridge.request).toHaveBeenNthCalledWith(2, 'vault_documents_upload_finalize', {
|
|
781
|
-
documentId: 'vdoc_u',
|
|
782
|
-
checksumSha256: expectedHex,
|
|
783
|
-
});
|
|
784
|
-
expect(result.status).toBe('uploaded');
|
|
785
|
-
}
|
|
786
|
-
finally {
|
|
787
|
-
globalThis.crypto = originalCrypto;
|
|
788
|
-
globalThis.fetch = originalFetch;
|
|
789
|
-
}
|
|
836
|
+
// Single host-orchestrated op: the host (parent window) runs init → PUT →
|
|
837
|
+
// finalize. The iframe never fetches (its CSP is connect-src 'none'), so
|
|
838
|
+
// the SDK no longer touches crypto/fetch here.
|
|
839
|
+
mockBridge.request.mockResolvedValueOnce({ documentId: 'vdoc_u', status: 'uploaded' });
|
|
840
|
+
const result = await api.upload(new Uint8Array([1, 2, 3, 4]), {
|
|
841
|
+
name: 'tiny.bin',
|
|
842
|
+
mimeType: 'application/octet-stream',
|
|
843
|
+
tags: ['t'],
|
|
844
|
+
});
|
|
845
|
+
expect(mockBridge.request).toHaveBeenCalledTimes(1);
|
|
846
|
+
expect(mockBridge.request).toHaveBeenCalledWith('vault_documents_upload', expect.objectContaining({
|
|
847
|
+
name: 'tiny.bin',
|
|
848
|
+
mimeType: 'application/octet-stream',
|
|
849
|
+
bytes: btoa(String.fromCharCode(1, 2, 3, 4)),
|
|
850
|
+
tags: ['t'],
|
|
851
|
+
}));
|
|
852
|
+
expect(result.status).toBe('uploaded');
|
|
790
853
|
});
|
|
791
|
-
it('upload()
|
|
854
|
+
it('upload() rejects when the host upload op fails (e.g. PUT 403)', async () => {
|
|
792
855
|
const { useVaultDocuments } = freshHooks();
|
|
793
856
|
const api = useVaultDocuments();
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
ok: false,
|
|
801
|
-
status: 403,
|
|
802
|
-
statusText: 'Forbidden',
|
|
803
|
-
text: () => Promise.resolve('signature mismatch'),
|
|
804
|
-
});
|
|
805
|
-
const originalFetch = globalThis.fetch;
|
|
806
|
-
globalThis.fetch = fetchMock;
|
|
807
|
-
try {
|
|
808
|
-
mockBridge.request.mockResolvedValueOnce({
|
|
809
|
-
documentId: 'vdoc_u',
|
|
810
|
-
uploadUrl: 'https://s3/up',
|
|
811
|
-
requiredHeaders: { 'Content-Type': 'application/octet-stream' },
|
|
812
|
-
uploadUrlExpiresAt: '2026-05-21T00:05:00Z',
|
|
813
|
-
});
|
|
814
|
-
await expect(api.upload(new Uint8Array([1, 2, 3]), {
|
|
815
|
-
name: 'x.bin',
|
|
816
|
-
mimeType: 'application/octet-stream',
|
|
817
|
-
})).rejects.toThrow(/403/);
|
|
818
|
-
}
|
|
819
|
-
finally {
|
|
820
|
-
globalThis.crypto = originalCrypto;
|
|
821
|
-
globalThis.fetch = originalFetch;
|
|
822
|
-
}
|
|
857
|
+
// The host now performs the PUT; a failure surfaces as a rejected op.
|
|
858
|
+
mockBridge.request.mockRejectedValueOnce(new Error('Vault upload PUT failed (403): Forbidden'));
|
|
859
|
+
await expect(api.upload(new Uint8Array([1, 2, 3]), {
|
|
860
|
+
name: 'x.bin',
|
|
861
|
+
mimeType: 'application/octet-stream',
|
|
862
|
+
})).rejects.toThrow(/403/);
|
|
823
863
|
});
|
|
824
864
|
it('upload() accepts a raw ArrayBuffer as well as a Uint8Array', async () => {
|
|
825
865
|
const { useVaultDocuments } = freshHooks();
|
|
826
866
|
const api = useVaultDocuments();
|
|
827
|
-
|
|
828
|
-
const
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
mockBridge.request.mockResolvedValueOnce({
|
|
837
|
-
documentId: 'vdoc_u',
|
|
838
|
-
uploadUrl: 'https://s3/up',
|
|
839
|
-
requiredHeaders: {},
|
|
840
|
-
uploadUrlExpiresAt: '2026-05-21T00:05:00Z',
|
|
841
|
-
});
|
|
842
|
-
mockBridge.request.mockResolvedValueOnce({
|
|
843
|
-
documentId: 'vdoc_u',
|
|
844
|
-
status: 'uploaded',
|
|
845
|
-
});
|
|
846
|
-
const buffer = new ArrayBuffer(8);
|
|
847
|
-
const view = new Uint8Array(buffer);
|
|
848
|
-
view.set([1, 2, 3, 4, 5, 6, 7, 8]);
|
|
849
|
-
const result = await api.upload(buffer, {
|
|
850
|
-
name: 'ab.bin',
|
|
851
|
-
mimeType: 'application/octet-stream',
|
|
852
|
-
});
|
|
853
|
-
expect(result.status).toBe('uploaded');
|
|
854
|
-
expect(mockBridge.request).toHaveBeenNthCalledWith(1, 'vault_documents_upload_init', expect.objectContaining({ sizeBytes: 8 }));
|
|
855
|
-
}
|
|
856
|
-
finally {
|
|
857
|
-
globalThis.crypto = originalCrypto;
|
|
858
|
-
globalThis.fetch = originalFetch;
|
|
859
|
-
}
|
|
867
|
+
mockBridge.request.mockResolvedValueOnce({ documentId: 'vdoc_u', status: 'uploaded' });
|
|
868
|
+
const buffer = new ArrayBuffer(8);
|
|
869
|
+
new Uint8Array(buffer).set([1, 2, 3, 4, 5, 6, 7, 8]);
|
|
870
|
+
const result = await api.upload(buffer, {
|
|
871
|
+
name: 'ab.bin',
|
|
872
|
+
mimeType: 'application/octet-stream',
|
|
873
|
+
});
|
|
874
|
+
expect(result.status).toBe('uploaded');
|
|
875
|
+
expect(mockBridge.request).toHaveBeenCalledWith('vault_documents_upload', expect.objectContaining({ bytes: btoa(String.fromCharCode(1, 2, 3, 4, 5, 6, 7, 8)) }));
|
|
860
876
|
});
|
|
861
877
|
});
|
|
862
878
|
describe('useSurface', () => {
|
|
@@ -964,101 +980,118 @@ describe('SDK hooks', () => {
|
|
|
964
980
|
await expect(api.removeBackground(new Blob([new Uint8Array([1])], { type: 'image/png' }))).rejects.toThrow('rate limit hit');
|
|
965
981
|
});
|
|
966
982
|
});
|
|
983
|
+
describe('useClientEntity', () => {
|
|
984
|
+
it('returns initial state from useState', () => {
|
|
985
|
+
const { useClientEntity } = require('./hooks');
|
|
986
|
+
const api = useClientEntity();
|
|
987
|
+
expect(api.isLoading).toBe(false);
|
|
988
|
+
expect(api.error).toBeNull();
|
|
989
|
+
expect(typeof api.invoke).toBe('function');
|
|
990
|
+
});
|
|
991
|
+
it('invoke posts client_entity_invoke with the given entityId/input and 120s timeout', async () => {
|
|
992
|
+
const { useClientEntity } = require('./hooks');
|
|
993
|
+
const response = { issues: [] };
|
|
994
|
+
mockBridge.request.mockResolvedValue(response);
|
|
995
|
+
const api = useClientEntity();
|
|
996
|
+
const input = { text: 'helo wrld' };
|
|
997
|
+
const result = await api.invoke('ent_intg_grammar_check_000000000', input);
|
|
998
|
+
expect(mockBridge.request).toHaveBeenCalledWith('client_entity_invoke', { entityId: 'ent_intg_grammar_check_000000000', input }, 120000);
|
|
999
|
+
expect(result).toBe(response);
|
|
1000
|
+
});
|
|
1001
|
+
it('rethrows bridge errors and wraps non-Error rejections', async () => {
|
|
1002
|
+
const { useClientEntity } = require('./hooks');
|
|
1003
|
+
mockBridge.request.mockRejectedValue('rate limit hit');
|
|
1004
|
+
const api = useClientEntity();
|
|
1005
|
+
await expect(api.invoke('ent_intg_grammar_check_000000000', {})).rejects.toThrow('rate limit hit');
|
|
1006
|
+
});
|
|
1007
|
+
});
|
|
967
1008
|
describe('useFiasDataStore', () => {
|
|
968
1009
|
it('createCollection forwards name + default userScope', async () => {
|
|
969
1010
|
const { useFiasDataStore } = require('./hooks');
|
|
970
1011
|
const ds = useFiasDataStore();
|
|
971
|
-
mockBridge.request.mockResolvedValueOnce({
|
|
972
|
-
collection: { name: 'scores', userScope: 'user' },
|
|
973
|
-
});
|
|
1012
|
+
mockBridge.request.mockResolvedValueOnce(output({ collection: { name: 'scores', userScope: 'user' } }));
|
|
974
1013
|
const coll = await ds.createCollection('scores');
|
|
975
|
-
expect(mockBridge.request).toHaveBeenCalledWith('
|
|
1014
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID, 'create_collection', {
|
|
976
1015
|
name: 'scores',
|
|
977
1016
|
userScope: 'user',
|
|
978
|
-
});
|
|
1017
|
+
}));
|
|
979
1018
|
expect(coll.name).toBe('scores');
|
|
980
1019
|
});
|
|
981
1020
|
it('createCollection honors an explicit userScope=shared', async () => {
|
|
982
1021
|
const { useFiasDataStore } = require('./hooks');
|
|
983
1022
|
const ds = useFiasDataStore();
|
|
984
|
-
mockBridge.request.mockResolvedValueOnce({
|
|
985
|
-
collection: { name: 'global', userScope: 'shared' },
|
|
986
|
-
});
|
|
1023
|
+
mockBridge.request.mockResolvedValueOnce(output({ collection: { name: 'global', userScope: 'shared' } }));
|
|
987
1024
|
await ds.createCollection('global', { userScope: 'shared' });
|
|
988
|
-
expect(mockBridge.request).toHaveBeenCalledWith('
|
|
1025
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID, 'create_collection', {
|
|
989
1026
|
name: 'global',
|
|
990
1027
|
userScope: 'shared',
|
|
991
|
-
});
|
|
1028
|
+
}));
|
|
992
1029
|
});
|
|
993
1030
|
it('listCollections returns the array unwrapped from the bridge envelope', async () => {
|
|
994
1031
|
const { useFiasDataStore } = require('./hooks');
|
|
995
1032
|
const ds = useFiasDataStore();
|
|
996
|
-
mockBridge.request.mockResolvedValueOnce({
|
|
997
|
-
collections: [{ name: 'a' }, { name: 'b' }],
|
|
998
|
-
});
|
|
1033
|
+
mockBridge.request.mockResolvedValueOnce(output({ collections: [{ name: 'a' }, { name: 'b' }] }));
|
|
999
1034
|
const list = await ds.listCollections();
|
|
1000
1035
|
expect(list).toHaveLength(2);
|
|
1001
|
-
expect(mockBridge.request).toHaveBeenCalledWith('
|
|
1036
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID, 'list_collections'));
|
|
1002
1037
|
});
|
|
1003
1038
|
it('deleteCollection forwards the name', async () => {
|
|
1004
1039
|
const { useFiasDataStore } = require('./hooks');
|
|
1005
1040
|
const ds = useFiasDataStore();
|
|
1006
|
-
mockBridge.request.mockResolvedValueOnce(
|
|
1041
|
+
mockBridge.request.mockResolvedValueOnce(output({ success: true }));
|
|
1007
1042
|
await ds.deleteCollection('scores');
|
|
1008
|
-
expect(mockBridge.request).toHaveBeenCalledWith('
|
|
1009
|
-
name: 'scores',
|
|
1010
|
-
});
|
|
1043
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID, 'delete_collection', { name: 'scores' }));
|
|
1011
1044
|
});
|
|
1012
1045
|
it('put forwards collection / key / data', async () => {
|
|
1013
1046
|
const { useFiasDataStore } = require('./hooks');
|
|
1014
1047
|
const ds = useFiasDataStore();
|
|
1015
|
-
mockBridge.request.mockResolvedValueOnce(
|
|
1048
|
+
mockBridge.request.mockResolvedValueOnce(output({ success: true }));
|
|
1016
1049
|
await ds.put('scores', 'level-1', { score: 100 });
|
|
1017
|
-
expect(mockBridge.request).toHaveBeenCalledWith('
|
|
1050
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID, 'put', {
|
|
1018
1051
|
collection: 'scores',
|
|
1019
1052
|
key: 'level-1',
|
|
1020
1053
|
data: { score: 100 },
|
|
1021
|
-
});
|
|
1054
|
+
}));
|
|
1022
1055
|
});
|
|
1023
1056
|
it('get unwraps { data } from the bridge envelope', async () => {
|
|
1024
1057
|
const { useFiasDataStore } = require('./hooks');
|
|
1025
1058
|
const ds = useFiasDataStore();
|
|
1026
|
-
mockBridge.request.mockResolvedValueOnce({ data: { score: 50 } });
|
|
1059
|
+
mockBridge.request.mockResolvedValueOnce(output({ data: { score: 50 } }));
|
|
1027
1060
|
const result = await ds.get('scores', 'level-1');
|
|
1028
1061
|
expect(result).toEqual({ score: 50 });
|
|
1029
1062
|
});
|
|
1030
1063
|
it('get returns null when the bridge says data is null', async () => {
|
|
1031
1064
|
const { useFiasDataStore } = require('./hooks');
|
|
1032
1065
|
const ds = useFiasDataStore();
|
|
1033
|
-
mockBridge.request.mockResolvedValueOnce({ data: null });
|
|
1066
|
+
mockBridge.request.mockResolvedValueOnce(output({ data: null }));
|
|
1034
1067
|
const result = await ds.get('scores', 'missing');
|
|
1035
1068
|
expect(result).toBeNull();
|
|
1036
1069
|
});
|
|
1037
1070
|
it('query forwards collection + options to data_query', async () => {
|
|
1038
1071
|
const { useFiasDataStore } = require('./hooks');
|
|
1039
1072
|
const ds = useFiasDataStore();
|
|
1040
|
-
mockBridge.request.mockResolvedValueOnce({ documents: [], nextCursor: null });
|
|
1073
|
+
mockBridge.request.mockResolvedValueOnce(output({ documents: [], nextCursor: null }));
|
|
1041
1074
|
await ds.query('scores', {
|
|
1042
1075
|
filters: [{ field: 'score', op: 'gte', value: 50 }],
|
|
1043
1076
|
orderBy: { field: 'score', direction: 'desc' },
|
|
1044
1077
|
limit: 10,
|
|
1045
1078
|
});
|
|
1046
|
-
expect(mockBridge.request).toHaveBeenCalledWith('
|
|
1079
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID, 'query', {
|
|
1047
1080
|
collection: 'scores',
|
|
1048
1081
|
filters: [{ field: 'score', op: 'gte', value: 50 }],
|
|
1049
1082
|
orderBy: { field: 'score', direction: 'desc' },
|
|
1050
1083
|
limit: 10,
|
|
1051
|
-
});
|
|
1084
|
+
}));
|
|
1052
1085
|
});
|
|
1053
1086
|
it('delete forwards collection + key', async () => {
|
|
1054
1087
|
const { useFiasDataStore } = require('./hooks');
|
|
1055
1088
|
const ds = useFiasDataStore();
|
|
1056
|
-
mockBridge.request.mockResolvedValueOnce(
|
|
1089
|
+
mockBridge.request.mockResolvedValueOnce(output({ success: true }));
|
|
1057
1090
|
await ds.delete('scores', 'level-1');
|
|
1058
|
-
expect(mockBridge.request).toHaveBeenCalledWith('
|
|
1091
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID, 'delete', {
|
|
1059
1092
|
collection: 'scores',
|
|
1060
1093
|
key: 'level-1',
|
|
1061
|
-
});
|
|
1094
|
+
}));
|
|
1062
1095
|
});
|
|
1063
1096
|
});
|
|
1064
1097
|
describe('useFiasStore', () => {
|
|
@@ -1069,8 +1102,8 @@ describe('SDK hooks', () => {
|
|
|
1069
1102
|
mockBridge.request.mockResolvedValueOnce(products).mockResolvedValueOnce(entitlements);
|
|
1070
1103
|
const api = useFiasStore();
|
|
1071
1104
|
// Effect kicks the two bridge calls.
|
|
1072
|
-
expect(mockBridge.request).toHaveBeenNthCalledWith(1, '
|
|
1073
|
-
expect(mockBridge.request).toHaveBeenNthCalledWith(2, '
|
|
1105
|
+
expect(mockBridge.request).toHaveBeenNthCalledWith(1, ...entityOp(entity_ops_1.PLUGIN_STORE_ENTITY_ID, 'get_products'));
|
|
1106
|
+
expect(mockBridge.request).toHaveBeenNthCalledWith(2, ...entityOp(entity_ops_1.PLUGIN_STORE_ENTITY_ID, 'get_entitlements'));
|
|
1074
1107
|
expect(typeof api.purchase).toBe('function');
|
|
1075
1108
|
});
|
|
1076
1109
|
it('hasEntitlement checks productIdentifier + isActive', () => {
|
|
@@ -1090,9 +1123,7 @@ describe('SDK hooks', () => {
|
|
|
1090
1123
|
.mockResolvedValueOnce({ success: true, purchaseId: 'p_1' });
|
|
1091
1124
|
const api = useFiasStore();
|
|
1092
1125
|
const result = await api.purchase('gems_100');
|
|
1093
|
-
expect(mockBridge.request).toHaveBeenLastCalledWith('
|
|
1094
|
-
productIdentifier: 'gems_100',
|
|
1095
|
-
});
|
|
1126
|
+
expect(mockBridge.request).toHaveBeenLastCalledWith(...entityOp(entity_ops_1.PLUGIN_STORE_ENTITY_ID, 'purchase', { productIdentifier: 'gems_100' }));
|
|
1096
1127
|
expect(result.purchaseId).toBe('p_1');
|
|
1097
1128
|
});
|
|
1098
1129
|
it('purchase forwards options.quantity to the bridge', async () => {
|
|
@@ -1103,10 +1134,10 @@ describe('SDK hooks', () => {
|
|
|
1103
1134
|
.mockResolvedValueOnce({ success: true });
|
|
1104
1135
|
const api = useFiasStore();
|
|
1105
1136
|
await api.purchase('gems_100', { quantity: 3 });
|
|
1106
|
-
expect(mockBridge.request).toHaveBeenLastCalledWith('
|
|
1137
|
+
expect(mockBridge.request).toHaveBeenLastCalledWith(...entityOp(entity_ops_1.PLUGIN_STORE_ENTITY_ID, 'purchase', {
|
|
1107
1138
|
productIdentifier: 'gems_100',
|
|
1108
1139
|
quantity: 3,
|
|
1109
|
-
});
|
|
1140
|
+
}));
|
|
1110
1141
|
});
|
|
1111
1142
|
it('getPurchaseHistory unwraps { purchases }', async () => {
|
|
1112
1143
|
const { useFiasStore } = require('./hooks');
|
|
@@ -1117,7 +1148,7 @@ describe('SDK hooks', () => {
|
|
|
1117
1148
|
const api = useFiasStore();
|
|
1118
1149
|
const history = await api.getPurchaseHistory();
|
|
1119
1150
|
expect(history).toHaveLength(1);
|
|
1120
|
-
expect(mockBridge.request).toHaveBeenLastCalledWith('
|
|
1151
|
+
expect(mockBridge.request).toHaveBeenLastCalledWith(...entityOp(entity_ops_1.PLUGIN_STORE_ENTITY_ID, 'get_purchase_history'));
|
|
1121
1152
|
});
|
|
1122
1153
|
it('restorePurchases returns the restored entitlements array directly', async () => {
|
|
1123
1154
|
const { useFiasStore } = require('./hooks');
|
|
@@ -1128,7 +1159,7 @@ describe('SDK hooks', () => {
|
|
|
1128
1159
|
const api = useFiasStore();
|
|
1129
1160
|
const restored = await api.restorePurchases();
|
|
1130
1161
|
expect(restored).toHaveLength(1);
|
|
1131
|
-
expect(mockBridge.request).toHaveBeenLastCalledWith('
|
|
1162
|
+
expect(mockBridge.request).toHaveBeenLastCalledWith(...entityOp(entity_ops_1.PLUGIN_STORE_ENTITY_ID, 'restore'));
|
|
1132
1163
|
});
|
|
1133
1164
|
it('swallows a mount-time bridge rejection and clears isLoading', async () => {
|
|
1134
1165
|
const { useFiasStore } = require('./hooks');
|
|
@@ -1166,24 +1197,16 @@ describe('SDK hooks', () => {
|
|
|
1166
1197
|
});
|
|
1167
1198
|
describe('usePersistentState', () => {
|
|
1168
1199
|
it('reads from storage on mount and applies the parsed value', async () => {
|
|
1169
|
-
mockBridge.request.mockResolvedValue({
|
|
1170
|
-
content: JSON.stringify({ score: 50 }),
|
|
1171
|
-
exists: true,
|
|
1172
|
-
});
|
|
1200
|
+
mockBridge.request.mockResolvedValue(output({ content: JSON.stringify({ score: 50 }), exists: true }));
|
|
1173
1201
|
const { usePersistentState } = require('./hooks');
|
|
1174
1202
|
const [value, setValue] = usePersistentState('game-state', { score: 0 });
|
|
1175
1203
|
expect(value).toEqual({ score: 0 }); // initial (useState returns initial)
|
|
1176
|
-
expect(mockBridge.request).toHaveBeenCalledWith('
|
|
1177
|
-
path: '__state/game-state',
|
|
1178
|
-
});
|
|
1204
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_STORAGE_ENTITY_ID, 'read', { path: '__state/game-state' }));
|
|
1179
1205
|
// setValue is exposed regardless of load completion.
|
|
1180
1206
|
expect(typeof setValue).toBe('function');
|
|
1181
1207
|
});
|
|
1182
1208
|
it('tolerates a corrupted JSON read and keeps initial value', async () => {
|
|
1183
|
-
mockBridge.request.mockResolvedValue({
|
|
1184
|
-
content: 'not-valid-json{',
|
|
1185
|
-
exists: true,
|
|
1186
|
-
});
|
|
1209
|
+
mockBridge.request.mockResolvedValue(output({ content: 'not-valid-json{', exists: true }));
|
|
1187
1210
|
const { usePersistentState } = require('./hooks');
|
|
1188
1211
|
const [value] = usePersistentState('x', { count: 1 });
|
|
1189
1212
|
// initial state from useState (the mocked react.useState returns
|
|
@@ -1192,7 +1215,7 @@ describe('SDK hooks', () => {
|
|
|
1192
1215
|
await new Promise((r) => setTimeout(r, 0));
|
|
1193
1216
|
});
|
|
1194
1217
|
it('setValue with a function variant resolves prev from valueRef', () => {
|
|
1195
|
-
mockBridge.request.mockResolvedValue({ content: null, exists: false });
|
|
1218
|
+
mockBridge.request.mockResolvedValue(output({ content: null, exists: false }));
|
|
1196
1219
|
const { usePersistentState } = require('./hooks');
|
|
1197
1220
|
const [, setValue] = usePersistentState('counter', 0);
|
|
1198
1221
|
// Calling setValue with a function should not throw and should
|
|
@@ -1221,9 +1244,7 @@ describe('SDK hooks', () => {
|
|
|
1221
1244
|
});
|
|
1222
1245
|
const { useStepNavigation } = require('./hooks');
|
|
1223
1246
|
useStepNavigation(undefined, { persistKey: 'wizard' });
|
|
1224
|
-
expect(mockBridge.request).toHaveBeenCalledWith('
|
|
1225
|
-
path: '__state/wizard',
|
|
1226
|
-
});
|
|
1247
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_STORAGE_ENTITY_ID, 'read', { path: '__state/wizard' }));
|
|
1227
1248
|
});
|
|
1228
1249
|
it('setCurrentStep updates state and schedules a persistent write when persistKey is set', () => {
|
|
1229
1250
|
const { useStepNavigation } = require('./hooks');
|
|
@@ -1286,4 +1307,86 @@ describe('SDK hooks', () => {
|
|
|
1286
1307
|
});
|
|
1287
1308
|
});
|
|
1288
1309
|
});
|
|
1310
|
+
describe('useDataSubscription (ADR-012 Phase M4)', () => {
|
|
1311
|
+
const flush = async () => {
|
|
1312
|
+
// Drain the effect's async subscribe chain (request → listeners → state).
|
|
1313
|
+
await Promise.resolve();
|
|
1314
|
+
await Promise.resolve();
|
|
1315
|
+
await Promise.resolve();
|
|
1316
|
+
};
|
|
1317
|
+
function subscribed(subscriptionId = 'lsub_1') {
|
|
1318
|
+
mockBridge.request.mockResolvedValue({
|
|
1319
|
+
output: { kind: 'subscription', subscriptionId },
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1322
|
+
let mockBridge;
|
|
1323
|
+
beforeEach(() => {
|
|
1324
|
+
mockBridge = createMockBridge();
|
|
1325
|
+
mockContextValue = mockBridge;
|
|
1326
|
+
mockSingletonBridge = mockBridge;
|
|
1327
|
+
});
|
|
1328
|
+
it('subscribes via entity_invoke on the datastore entity and refetches once live', async () => {
|
|
1329
|
+
subscribed();
|
|
1330
|
+
const onInvalidate = jest.fn().mockResolvedValue(undefined);
|
|
1331
|
+
const { useDataSubscription } = require('./hooks');
|
|
1332
|
+
useDataSubscription('scores', onInvalidate);
|
|
1333
|
+
await flush();
|
|
1334
|
+
expect(mockBridge.request).toHaveBeenCalledWith('entity_invoke', {
|
|
1335
|
+
entityId: 'ent_intg_plugin_datastore_000000',
|
|
1336
|
+
payload: { op: 'subscribe', collection: 'scores', workspaceId: undefined },
|
|
1337
|
+
});
|
|
1338
|
+
expect(mockBridge.onSubscriptionEvent).toHaveBeenCalledWith('lsub_1', expect.any(Function));
|
|
1339
|
+
expect(mockBridge.onSubscriptionEnded).toHaveBeenCalledWith('lsub_1', expect.any(Function));
|
|
1340
|
+
// Subscribe-then-fetch: the initial refetch fires AFTER the
|
|
1341
|
+
// subscription is live, closing the write-races-the-handshake gap.
|
|
1342
|
+
expect(onInvalidate).toHaveBeenCalledTimes(1);
|
|
1343
|
+
expect(mockSetState).toHaveBeenCalledWith({ status: 'active' });
|
|
1344
|
+
});
|
|
1345
|
+
it('runs onInvalidate single-flight with exactly one trailing call', async () => {
|
|
1346
|
+
subscribed();
|
|
1347
|
+
let resolveFirst = () => undefined;
|
|
1348
|
+
const onInvalidate = jest
|
|
1349
|
+
.fn()
|
|
1350
|
+
.mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve)))
|
|
1351
|
+
.mockResolvedValue(undefined);
|
|
1352
|
+
const { useDataSubscription } = require('./hooks');
|
|
1353
|
+
useDataSubscription('scores', onInvalidate);
|
|
1354
|
+
await flush();
|
|
1355
|
+
expect(onInvalidate).toHaveBeenCalledTimes(1); // initial, still in flight
|
|
1356
|
+
const eventCb = mockBridge.onSubscriptionEvent.mock.calls[0][1];
|
|
1357
|
+
eventCb();
|
|
1358
|
+
eventCb();
|
|
1359
|
+
eventCb();
|
|
1360
|
+
expect(onInvalidate).toHaveBeenCalledTimes(1); // burst coalesced behind the in-flight run
|
|
1361
|
+
resolveFirst();
|
|
1362
|
+
await flush();
|
|
1363
|
+
expect(onInvalidate).toHaveBeenCalledTimes(2); // exactly one trailing call
|
|
1364
|
+
});
|
|
1365
|
+
it('surfaces terminal endings and releases the subscription on cleanup', async () => {
|
|
1366
|
+
subscribed();
|
|
1367
|
+
const { useDataSubscription } = require('./hooks');
|
|
1368
|
+
useDataSubscription('scores', jest.fn().mockResolvedValue(undefined));
|
|
1369
|
+
await flush();
|
|
1370
|
+
const endedCb = mockBridge.onSubscriptionEnded.mock.calls[0][1];
|
|
1371
|
+
endedCb('collection_deleted');
|
|
1372
|
+
expect(mockSetState).toHaveBeenCalledWith({
|
|
1373
|
+
status: 'ended',
|
|
1374
|
+
endedReason: 'collection_deleted',
|
|
1375
|
+
});
|
|
1376
|
+
capturedEffectCleanup?.();
|
|
1377
|
+
expect(mockBridge.releaseSubscription).toHaveBeenCalledWith('lsub_1');
|
|
1378
|
+
});
|
|
1379
|
+
it('a malformed subscribe response ends with transport_error', async () => {
|
|
1380
|
+
mockBridge.request.mockResolvedValue({ output: { data: null } });
|
|
1381
|
+
const onInvalidate = jest.fn();
|
|
1382
|
+
const { useDataSubscription } = require('./hooks');
|
|
1383
|
+
useDataSubscription('scores', onInvalidate);
|
|
1384
|
+
await flush();
|
|
1385
|
+
expect(mockSetState).toHaveBeenCalledWith({
|
|
1386
|
+
status: 'ended',
|
|
1387
|
+
endedReason: 'transport_error',
|
|
1388
|
+
});
|
|
1389
|
+
expect(onInvalidate).not.toHaveBeenCalled();
|
|
1390
|
+
});
|
|
1391
|
+
});
|
|
1289
1392
|
//# sourceMappingURL=hooks.test.js.map
|