@fias/arche-sdk 2.3.0 → 2.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/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 +11 -1
- package/dist/fias.d.ts.map +1 -1
- package/dist/fias.js +39 -12
- package/dist/fias.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 +35 -2
- 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 +228 -13
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +527 -104
- package/dist/hooks.js.map +1 -1
- package/dist/hooks.test.js +314 -247
- 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 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +982 -115
- 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 +373 -27
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -1
- package/templates/default/AGENTS.md +321 -34
- package/templates/default/CLAUDE.md +321 -34
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', () => {
|
|
@@ -928,137 +944,118 @@ describe('SDK hooks', () => {
|
|
|
928
944
|
await expect(api.generate({ prompt: 'x' })).rejects.toThrow('Lyria timeout');
|
|
929
945
|
});
|
|
930
946
|
});
|
|
931
|
-
describe('
|
|
947
|
+
describe('useClientEntity', () => {
|
|
932
948
|
it('returns initial state from useState', () => {
|
|
933
|
-
const {
|
|
934
|
-
const api =
|
|
949
|
+
const { useClientEntity } = require('./hooks');
|
|
950
|
+
const api = useClientEntity();
|
|
935
951
|
expect(api.isLoading).toBe(false);
|
|
936
|
-
expect(api.result).toBeNull();
|
|
937
952
|
expect(api.error).toBeNull();
|
|
938
|
-
expect(typeof api.
|
|
939
|
-
});
|
|
940
|
-
it('
|
|
941
|
-
const {
|
|
942
|
-
const
|
|
943
|
-
mockBridge.request.mockResolvedValue(
|
|
944
|
-
const api =
|
|
945
|
-
const input =
|
|
946
|
-
const result = await api.
|
|
947
|
-
expect(mockBridge.request).toHaveBeenCalledWith('client_entity_invoke', { entityId: '
|
|
948
|
-
expect(result).toBe(
|
|
949
|
-
});
|
|
950
|
-
it('rejects inputs over 25 MiB without calling the bridge', async () => {
|
|
951
|
-
const { useBackgroundRemoval } = require('./hooks');
|
|
952
|
-
// 25 MiB + 1 byte. Build a backing buffer (cheap — happy-dom Blob
|
|
953
|
-
// reads `size` from the constructor input length without
|
|
954
|
-
// materializing the array contents).
|
|
955
|
-
const oversized = new Blob([new Uint8Array(25 * 1024 * 1024 + 1)], { type: 'image/png' });
|
|
956
|
-
const api = useBackgroundRemoval();
|
|
957
|
-
await expect(api.removeBackground(oversized)).rejects.toThrow('25 MiB');
|
|
958
|
-
expect(mockBridge.request).not.toHaveBeenCalled();
|
|
953
|
+
expect(typeof api.invoke).toBe('function');
|
|
954
|
+
});
|
|
955
|
+
it('invoke posts client_entity_invoke with the given entityId/input and 120s timeout', async () => {
|
|
956
|
+
const { useClientEntity } = require('./hooks');
|
|
957
|
+
const response = { issues: [] };
|
|
958
|
+
mockBridge.request.mockResolvedValue(response);
|
|
959
|
+
const api = useClientEntity();
|
|
960
|
+
const input = { text: 'helo wrld' };
|
|
961
|
+
const result = await api.invoke('ent_intg_grammar_check_000000000', input);
|
|
962
|
+
expect(mockBridge.request).toHaveBeenCalledWith('client_entity_invoke', { entityId: 'ent_intg_grammar_check_000000000', input }, 120000);
|
|
963
|
+
expect(result).toBe(response);
|
|
959
964
|
});
|
|
960
965
|
it('rethrows bridge errors and wraps non-Error rejections', async () => {
|
|
961
|
-
const {
|
|
966
|
+
const { useClientEntity } = require('./hooks');
|
|
962
967
|
mockBridge.request.mockRejectedValue('rate limit hit');
|
|
963
|
-
const api =
|
|
964
|
-
await expect(api.
|
|
968
|
+
const api = useClientEntity();
|
|
969
|
+
await expect(api.invoke('ent_intg_grammar_check_000000000', {})).rejects.toThrow('rate limit hit');
|
|
965
970
|
});
|
|
966
971
|
});
|
|
967
972
|
describe('useFiasDataStore', () => {
|
|
968
973
|
it('createCollection forwards name + default userScope', async () => {
|
|
969
974
|
const { useFiasDataStore } = require('./hooks');
|
|
970
975
|
const ds = useFiasDataStore();
|
|
971
|
-
mockBridge.request.mockResolvedValueOnce({
|
|
972
|
-
collection: { name: 'scores', userScope: 'user' },
|
|
973
|
-
});
|
|
976
|
+
mockBridge.request.mockResolvedValueOnce(output({ collection: { name: 'scores', userScope: 'user' } }));
|
|
974
977
|
const coll = await ds.createCollection('scores');
|
|
975
|
-
expect(mockBridge.request).toHaveBeenCalledWith('
|
|
978
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID, 'create_collection', {
|
|
976
979
|
name: 'scores',
|
|
977
980
|
userScope: 'user',
|
|
978
|
-
});
|
|
981
|
+
}));
|
|
979
982
|
expect(coll.name).toBe('scores');
|
|
980
983
|
});
|
|
981
984
|
it('createCollection honors an explicit userScope=shared', async () => {
|
|
982
985
|
const { useFiasDataStore } = require('./hooks');
|
|
983
986
|
const ds = useFiasDataStore();
|
|
984
|
-
mockBridge.request.mockResolvedValueOnce({
|
|
985
|
-
collection: { name: 'global', userScope: 'shared' },
|
|
986
|
-
});
|
|
987
|
+
mockBridge.request.mockResolvedValueOnce(output({ collection: { name: 'global', userScope: 'shared' } }));
|
|
987
988
|
await ds.createCollection('global', { userScope: 'shared' });
|
|
988
|
-
expect(mockBridge.request).toHaveBeenCalledWith('
|
|
989
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID, 'create_collection', {
|
|
989
990
|
name: 'global',
|
|
990
991
|
userScope: 'shared',
|
|
991
|
-
});
|
|
992
|
+
}));
|
|
992
993
|
});
|
|
993
994
|
it('listCollections returns the array unwrapped from the bridge envelope', async () => {
|
|
994
995
|
const { useFiasDataStore } = require('./hooks');
|
|
995
996
|
const ds = useFiasDataStore();
|
|
996
|
-
mockBridge.request.mockResolvedValueOnce({
|
|
997
|
-
collections: [{ name: 'a' }, { name: 'b' }],
|
|
998
|
-
});
|
|
997
|
+
mockBridge.request.mockResolvedValueOnce(output({ collections: [{ name: 'a' }, { name: 'b' }] }));
|
|
999
998
|
const list = await ds.listCollections();
|
|
1000
999
|
expect(list).toHaveLength(2);
|
|
1001
|
-
expect(mockBridge.request).toHaveBeenCalledWith('
|
|
1000
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID, 'list_collections'));
|
|
1002
1001
|
});
|
|
1003
1002
|
it('deleteCollection forwards the name', async () => {
|
|
1004
1003
|
const { useFiasDataStore } = require('./hooks');
|
|
1005
1004
|
const ds = useFiasDataStore();
|
|
1006
|
-
mockBridge.request.mockResolvedValueOnce(
|
|
1005
|
+
mockBridge.request.mockResolvedValueOnce(output({ success: true }));
|
|
1007
1006
|
await ds.deleteCollection('scores');
|
|
1008
|
-
expect(mockBridge.request).toHaveBeenCalledWith('
|
|
1009
|
-
name: 'scores',
|
|
1010
|
-
});
|
|
1007
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID, 'delete_collection', { name: 'scores' }));
|
|
1011
1008
|
});
|
|
1012
1009
|
it('put forwards collection / key / data', async () => {
|
|
1013
1010
|
const { useFiasDataStore } = require('./hooks');
|
|
1014
1011
|
const ds = useFiasDataStore();
|
|
1015
|
-
mockBridge.request.mockResolvedValueOnce(
|
|
1012
|
+
mockBridge.request.mockResolvedValueOnce(output({ success: true }));
|
|
1016
1013
|
await ds.put('scores', 'level-1', { score: 100 });
|
|
1017
|
-
expect(mockBridge.request).toHaveBeenCalledWith('
|
|
1014
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID, 'put', {
|
|
1018
1015
|
collection: 'scores',
|
|
1019
1016
|
key: 'level-1',
|
|
1020
1017
|
data: { score: 100 },
|
|
1021
|
-
});
|
|
1018
|
+
}));
|
|
1022
1019
|
});
|
|
1023
1020
|
it('get unwraps { data } from the bridge envelope', async () => {
|
|
1024
1021
|
const { useFiasDataStore } = require('./hooks');
|
|
1025
1022
|
const ds = useFiasDataStore();
|
|
1026
|
-
mockBridge.request.mockResolvedValueOnce({ data: { score: 50 } });
|
|
1023
|
+
mockBridge.request.mockResolvedValueOnce(output({ data: { score: 50 } }));
|
|
1027
1024
|
const result = await ds.get('scores', 'level-1');
|
|
1028
1025
|
expect(result).toEqual({ score: 50 });
|
|
1029
1026
|
});
|
|
1030
1027
|
it('get returns null when the bridge says data is null', async () => {
|
|
1031
1028
|
const { useFiasDataStore } = require('./hooks');
|
|
1032
1029
|
const ds = useFiasDataStore();
|
|
1033
|
-
mockBridge.request.mockResolvedValueOnce({ data: null });
|
|
1030
|
+
mockBridge.request.mockResolvedValueOnce(output({ data: null }));
|
|
1034
1031
|
const result = await ds.get('scores', 'missing');
|
|
1035
1032
|
expect(result).toBeNull();
|
|
1036
1033
|
});
|
|
1037
1034
|
it('query forwards collection + options to data_query', async () => {
|
|
1038
1035
|
const { useFiasDataStore } = require('./hooks');
|
|
1039
1036
|
const ds = useFiasDataStore();
|
|
1040
|
-
mockBridge.request.mockResolvedValueOnce({ documents: [], nextCursor: null });
|
|
1037
|
+
mockBridge.request.mockResolvedValueOnce(output({ documents: [], nextCursor: null }));
|
|
1041
1038
|
await ds.query('scores', {
|
|
1042
1039
|
filters: [{ field: 'score', op: 'gte', value: 50 }],
|
|
1043
1040
|
orderBy: { field: 'score', direction: 'desc' },
|
|
1044
1041
|
limit: 10,
|
|
1045
1042
|
});
|
|
1046
|
-
expect(mockBridge.request).toHaveBeenCalledWith('
|
|
1043
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID, 'query', {
|
|
1047
1044
|
collection: 'scores',
|
|
1048
1045
|
filters: [{ field: 'score', op: 'gte', value: 50 }],
|
|
1049
1046
|
orderBy: { field: 'score', direction: 'desc' },
|
|
1050
1047
|
limit: 10,
|
|
1051
|
-
});
|
|
1048
|
+
}));
|
|
1052
1049
|
});
|
|
1053
1050
|
it('delete forwards collection + key', async () => {
|
|
1054
1051
|
const { useFiasDataStore } = require('./hooks');
|
|
1055
1052
|
const ds = useFiasDataStore();
|
|
1056
|
-
mockBridge.request.mockResolvedValueOnce(
|
|
1053
|
+
mockBridge.request.mockResolvedValueOnce(output({ success: true }));
|
|
1057
1054
|
await ds.delete('scores', 'level-1');
|
|
1058
|
-
expect(mockBridge.request).toHaveBeenCalledWith('
|
|
1055
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID, 'delete', {
|
|
1059
1056
|
collection: 'scores',
|
|
1060
1057
|
key: 'level-1',
|
|
1061
|
-
});
|
|
1058
|
+
}));
|
|
1062
1059
|
});
|
|
1063
1060
|
});
|
|
1064
1061
|
describe('useFiasStore', () => {
|
|
@@ -1069,8 +1066,8 @@ describe('SDK hooks', () => {
|
|
|
1069
1066
|
mockBridge.request.mockResolvedValueOnce(products).mockResolvedValueOnce(entitlements);
|
|
1070
1067
|
const api = useFiasStore();
|
|
1071
1068
|
// Effect kicks the two bridge calls.
|
|
1072
|
-
expect(mockBridge.request).toHaveBeenNthCalledWith(1, '
|
|
1073
|
-
expect(mockBridge.request).toHaveBeenNthCalledWith(2, '
|
|
1069
|
+
expect(mockBridge.request).toHaveBeenNthCalledWith(1, ...entityOp(entity_ops_1.PLUGIN_STORE_ENTITY_ID, 'get_products'));
|
|
1070
|
+
expect(mockBridge.request).toHaveBeenNthCalledWith(2, ...entityOp(entity_ops_1.PLUGIN_STORE_ENTITY_ID, 'get_entitlements'));
|
|
1074
1071
|
expect(typeof api.purchase).toBe('function');
|
|
1075
1072
|
});
|
|
1076
1073
|
it('hasEntitlement checks productIdentifier + isActive', () => {
|
|
@@ -1090,9 +1087,7 @@ describe('SDK hooks', () => {
|
|
|
1090
1087
|
.mockResolvedValueOnce({ success: true, purchaseId: 'p_1' });
|
|
1091
1088
|
const api = useFiasStore();
|
|
1092
1089
|
const result = await api.purchase('gems_100');
|
|
1093
|
-
expect(mockBridge.request).toHaveBeenLastCalledWith('
|
|
1094
|
-
productIdentifier: 'gems_100',
|
|
1095
|
-
});
|
|
1090
|
+
expect(mockBridge.request).toHaveBeenLastCalledWith(...entityOp(entity_ops_1.PLUGIN_STORE_ENTITY_ID, 'purchase', { productIdentifier: 'gems_100' }));
|
|
1096
1091
|
expect(result.purchaseId).toBe('p_1');
|
|
1097
1092
|
});
|
|
1098
1093
|
it('purchase forwards options.quantity to the bridge', async () => {
|
|
@@ -1103,10 +1098,10 @@ describe('SDK hooks', () => {
|
|
|
1103
1098
|
.mockResolvedValueOnce({ success: true });
|
|
1104
1099
|
const api = useFiasStore();
|
|
1105
1100
|
await api.purchase('gems_100', { quantity: 3 });
|
|
1106
|
-
expect(mockBridge.request).toHaveBeenLastCalledWith('
|
|
1101
|
+
expect(mockBridge.request).toHaveBeenLastCalledWith(...entityOp(entity_ops_1.PLUGIN_STORE_ENTITY_ID, 'purchase', {
|
|
1107
1102
|
productIdentifier: 'gems_100',
|
|
1108
1103
|
quantity: 3,
|
|
1109
|
-
});
|
|
1104
|
+
}));
|
|
1110
1105
|
});
|
|
1111
1106
|
it('getPurchaseHistory unwraps { purchases }', async () => {
|
|
1112
1107
|
const { useFiasStore } = require('./hooks');
|
|
@@ -1117,7 +1112,7 @@ describe('SDK hooks', () => {
|
|
|
1117
1112
|
const api = useFiasStore();
|
|
1118
1113
|
const history = await api.getPurchaseHistory();
|
|
1119
1114
|
expect(history).toHaveLength(1);
|
|
1120
|
-
expect(mockBridge.request).toHaveBeenLastCalledWith('
|
|
1115
|
+
expect(mockBridge.request).toHaveBeenLastCalledWith(...entityOp(entity_ops_1.PLUGIN_STORE_ENTITY_ID, 'get_purchase_history'));
|
|
1121
1116
|
});
|
|
1122
1117
|
it('restorePurchases returns the restored entitlements array directly', async () => {
|
|
1123
1118
|
const { useFiasStore } = require('./hooks');
|
|
@@ -1128,7 +1123,7 @@ describe('SDK hooks', () => {
|
|
|
1128
1123
|
const api = useFiasStore();
|
|
1129
1124
|
const restored = await api.restorePurchases();
|
|
1130
1125
|
expect(restored).toHaveLength(1);
|
|
1131
|
-
expect(mockBridge.request).toHaveBeenLastCalledWith('
|
|
1126
|
+
expect(mockBridge.request).toHaveBeenLastCalledWith(...entityOp(entity_ops_1.PLUGIN_STORE_ENTITY_ID, 'restore'));
|
|
1132
1127
|
});
|
|
1133
1128
|
it('swallows a mount-time bridge rejection and clears isLoading', async () => {
|
|
1134
1129
|
const { useFiasStore } = require('./hooks');
|
|
@@ -1166,24 +1161,16 @@ describe('SDK hooks', () => {
|
|
|
1166
1161
|
});
|
|
1167
1162
|
describe('usePersistentState', () => {
|
|
1168
1163
|
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
|
-
});
|
|
1164
|
+
mockBridge.request.mockResolvedValue(output({ content: JSON.stringify({ score: 50 }), exists: true }));
|
|
1173
1165
|
const { usePersistentState } = require('./hooks');
|
|
1174
1166
|
const [value, setValue] = usePersistentState('game-state', { score: 0 });
|
|
1175
1167
|
expect(value).toEqual({ score: 0 }); // initial (useState returns initial)
|
|
1176
|
-
expect(mockBridge.request).toHaveBeenCalledWith('
|
|
1177
|
-
path: '__state/game-state',
|
|
1178
|
-
});
|
|
1168
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_STORAGE_ENTITY_ID, 'read', { path: '__state/game-state' }));
|
|
1179
1169
|
// setValue is exposed regardless of load completion.
|
|
1180
1170
|
expect(typeof setValue).toBe('function');
|
|
1181
1171
|
});
|
|
1182
1172
|
it('tolerates a corrupted JSON read and keeps initial value', async () => {
|
|
1183
|
-
mockBridge.request.mockResolvedValue({
|
|
1184
|
-
content: 'not-valid-json{',
|
|
1185
|
-
exists: true,
|
|
1186
|
-
});
|
|
1173
|
+
mockBridge.request.mockResolvedValue(output({ content: 'not-valid-json{', exists: true }));
|
|
1187
1174
|
const { usePersistentState } = require('./hooks');
|
|
1188
1175
|
const [value] = usePersistentState('x', { count: 1 });
|
|
1189
1176
|
// initial state from useState (the mocked react.useState returns
|
|
@@ -1192,7 +1179,7 @@ describe('SDK hooks', () => {
|
|
|
1192
1179
|
await new Promise((r) => setTimeout(r, 0));
|
|
1193
1180
|
});
|
|
1194
1181
|
it('setValue with a function variant resolves prev from valueRef', () => {
|
|
1195
|
-
mockBridge.request.mockResolvedValue({ content: null, exists: false });
|
|
1182
|
+
mockBridge.request.mockResolvedValue(output({ content: null, exists: false }));
|
|
1196
1183
|
const { usePersistentState } = require('./hooks');
|
|
1197
1184
|
const [, setValue] = usePersistentState('counter', 0);
|
|
1198
1185
|
// Calling setValue with a function should not throw and should
|
|
@@ -1221,9 +1208,7 @@ describe('SDK hooks', () => {
|
|
|
1221
1208
|
});
|
|
1222
1209
|
const { useStepNavigation } = require('./hooks');
|
|
1223
1210
|
useStepNavigation(undefined, { persistKey: 'wizard' });
|
|
1224
|
-
expect(mockBridge.request).toHaveBeenCalledWith('
|
|
1225
|
-
path: '__state/wizard',
|
|
1226
|
-
});
|
|
1211
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_STORAGE_ENTITY_ID, 'read', { path: '__state/wizard' }));
|
|
1227
1212
|
});
|
|
1228
1213
|
it('setCurrentStep updates state and schedules a persistent write when persistKey is set', () => {
|
|
1229
1214
|
const { useStepNavigation } = require('./hooks');
|
|
@@ -1286,4 +1271,86 @@ describe('SDK hooks', () => {
|
|
|
1286
1271
|
});
|
|
1287
1272
|
});
|
|
1288
1273
|
});
|
|
1274
|
+
describe('useDataSubscription (ADR-012 Phase M4)', () => {
|
|
1275
|
+
const flush = async () => {
|
|
1276
|
+
// Drain the effect's async subscribe chain (request → listeners → state).
|
|
1277
|
+
await Promise.resolve();
|
|
1278
|
+
await Promise.resolve();
|
|
1279
|
+
await Promise.resolve();
|
|
1280
|
+
};
|
|
1281
|
+
function subscribed(subscriptionId = 'lsub_1') {
|
|
1282
|
+
mockBridge.request.mockResolvedValue({
|
|
1283
|
+
output: { kind: 'subscription', subscriptionId },
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
let mockBridge;
|
|
1287
|
+
beforeEach(() => {
|
|
1288
|
+
mockBridge = createMockBridge();
|
|
1289
|
+
mockContextValue = mockBridge;
|
|
1290
|
+
mockSingletonBridge = mockBridge;
|
|
1291
|
+
});
|
|
1292
|
+
it('subscribes via entity_invoke on the datastore entity and refetches once live', async () => {
|
|
1293
|
+
subscribed();
|
|
1294
|
+
const onInvalidate = jest.fn().mockResolvedValue(undefined);
|
|
1295
|
+
const { useDataSubscription } = require('./hooks');
|
|
1296
|
+
useDataSubscription('scores', onInvalidate);
|
|
1297
|
+
await flush();
|
|
1298
|
+
expect(mockBridge.request).toHaveBeenCalledWith('entity_invoke', {
|
|
1299
|
+
entityId: 'ent_intg_plugin_datastore_000000',
|
|
1300
|
+
payload: { op: 'subscribe', collection: 'scores', workspaceId: undefined },
|
|
1301
|
+
});
|
|
1302
|
+
expect(mockBridge.onSubscriptionEvent).toHaveBeenCalledWith('lsub_1', expect.any(Function));
|
|
1303
|
+
expect(mockBridge.onSubscriptionEnded).toHaveBeenCalledWith('lsub_1', expect.any(Function));
|
|
1304
|
+
// Subscribe-then-fetch: the initial refetch fires AFTER the
|
|
1305
|
+
// subscription is live, closing the write-races-the-handshake gap.
|
|
1306
|
+
expect(onInvalidate).toHaveBeenCalledTimes(1);
|
|
1307
|
+
expect(mockSetState).toHaveBeenCalledWith({ status: 'active' });
|
|
1308
|
+
});
|
|
1309
|
+
it('runs onInvalidate single-flight with exactly one trailing call', async () => {
|
|
1310
|
+
subscribed();
|
|
1311
|
+
let resolveFirst = () => undefined;
|
|
1312
|
+
const onInvalidate = jest
|
|
1313
|
+
.fn()
|
|
1314
|
+
.mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve)))
|
|
1315
|
+
.mockResolvedValue(undefined);
|
|
1316
|
+
const { useDataSubscription } = require('./hooks');
|
|
1317
|
+
useDataSubscription('scores', onInvalidate);
|
|
1318
|
+
await flush();
|
|
1319
|
+
expect(onInvalidate).toHaveBeenCalledTimes(1); // initial, still in flight
|
|
1320
|
+
const eventCb = mockBridge.onSubscriptionEvent.mock.calls[0][1];
|
|
1321
|
+
eventCb();
|
|
1322
|
+
eventCb();
|
|
1323
|
+
eventCb();
|
|
1324
|
+
expect(onInvalidate).toHaveBeenCalledTimes(1); // burst coalesced behind the in-flight run
|
|
1325
|
+
resolveFirst();
|
|
1326
|
+
await flush();
|
|
1327
|
+
expect(onInvalidate).toHaveBeenCalledTimes(2); // exactly one trailing call
|
|
1328
|
+
});
|
|
1329
|
+
it('surfaces terminal endings and releases the subscription on cleanup', async () => {
|
|
1330
|
+
subscribed();
|
|
1331
|
+
const { useDataSubscription } = require('./hooks');
|
|
1332
|
+
useDataSubscription('scores', jest.fn().mockResolvedValue(undefined));
|
|
1333
|
+
await flush();
|
|
1334
|
+
const endedCb = mockBridge.onSubscriptionEnded.mock.calls[0][1];
|
|
1335
|
+
endedCb('collection_deleted');
|
|
1336
|
+
expect(mockSetState).toHaveBeenCalledWith({
|
|
1337
|
+
status: 'ended',
|
|
1338
|
+
endedReason: 'collection_deleted',
|
|
1339
|
+
});
|
|
1340
|
+
capturedEffectCleanup?.();
|
|
1341
|
+
expect(mockBridge.releaseSubscription).toHaveBeenCalledWith('lsub_1');
|
|
1342
|
+
});
|
|
1343
|
+
it('a malformed subscribe response ends with transport_error', async () => {
|
|
1344
|
+
mockBridge.request.mockResolvedValue({ output: { data: null } });
|
|
1345
|
+
const onInvalidate = jest.fn();
|
|
1346
|
+
const { useDataSubscription } = require('./hooks');
|
|
1347
|
+
useDataSubscription('scores', onInvalidate);
|
|
1348
|
+
await flush();
|
|
1349
|
+
expect(mockSetState).toHaveBeenCalledWith({
|
|
1350
|
+
status: 'ended',
|
|
1351
|
+
endedReason: 'transport_error',
|
|
1352
|
+
});
|
|
1353
|
+
expect(onInvalidate).not.toHaveBeenCalled();
|
|
1354
|
+
});
|
|
1355
|
+
});
|
|
1289
1356
|
//# sourceMappingURL=hooks.test.js.map
|