@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.
Files changed (57) hide show
  1. package/dist/bridge.d.ts +76 -1
  2. package/dist/bridge.d.ts.map +1 -1
  3. package/dist/bridge.js +219 -6
  4. package/dist/bridge.js.map +1 -1
  5. package/dist/bridge.test.js +155 -6
  6. package/dist/bridge.test.js.map +1 -1
  7. package/dist/coverage-fills.test.js +26 -32
  8. package/dist/coverage-fills.test.js.map +1 -1
  9. package/dist/entity-ops.d.ts +55 -0
  10. package/dist/entity-ops.d.ts.map +1 -0
  11. package/dist/entity-ops.js +48 -0
  12. package/dist/entity-ops.js.map +1 -0
  13. package/dist/fias.d.ts +7 -1
  14. package/dist/fias.d.ts.map +1 -1
  15. package/dist/fias.js +19 -12
  16. package/dist/fias.js.map +1 -1
  17. package/dist/generated/fonts.d.ts.map +1 -1
  18. package/dist/generated/fonts.js +11 -0
  19. package/dist/generated/fonts.js.map +1 -1
  20. package/dist/generated/permissions.d.ts +7 -1
  21. package/dist/generated/permissions.d.ts.map +1 -1
  22. package/dist/generated/permissions.js +36 -1
  23. package/dist/generated/permissions.js.map +1 -1
  24. package/dist/generated/surfaces.d.ts +424 -0
  25. package/dist/generated/surfaces.d.ts.map +1 -1
  26. package/dist/generated/surfaces.js +97 -1
  27. package/dist/generated/surfaces.js.map +1 -1
  28. package/dist/hooks.d.ts +230 -3
  29. package/dist/hooks.d.ts.map +1 -1
  30. package/dist/hooks.js +519 -72
  31. package/dist/hooks.js.map +1 -1
  32. package/dist/hooks.test.js +322 -219
  33. package/dist/hooks.test.js.map +1 -1
  34. package/dist/index.d.ts +4 -4
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +12 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/index.mjs +977 -97
  39. package/dist/protocol.d.ts +7 -0
  40. package/dist/protocol.d.ts.map +1 -1
  41. package/dist/protocol.js +7 -0
  42. package/dist/protocol.js.map +1 -1
  43. package/dist/provider.d.ts +50 -1
  44. package/dist/provider.d.ts.map +1 -1
  45. package/dist/provider.js +187 -2
  46. package/dist/provider.js.map +1 -1
  47. package/dist/provider.render.test.d.ts +17 -0
  48. package/dist/provider.render.test.d.ts.map +1 -0
  49. package/dist/provider.render.test.js +191 -0
  50. package/dist/provider.render.test.js.map +1 -0
  51. package/dist/provider.test.js +64 -8
  52. package/dist/provider.test.js.map +1 -1
  53. package/dist/types.d.ts +339 -13
  54. package/dist/types.d.ts.map +1 -1
  55. package/package.json +2 -1
  56. package/templates/default/AGENTS.md +328 -26
  57. package/templates/default/CLAUDE.md +328 -26
@@ -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('storage_read', { path: '/test.txt' });
153
- mockBridge.request.mockResolvedValue(undefined);
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('storage_write', {
156
- path: '/test.txt',
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('storage_list', { prefix: '/prefix' });
162
- mockBridge.request.mockResolvedValue(undefined);
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('storage_delete', { path: '/test.txt' });
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('storage_list', { prefix: undefined });
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('storage_read', expect.anything());
361
+ expect(mockBridge.request).not.toHaveBeenCalledWith('entity_invoke', expect.anything());
265
362
  result.setCurrentStep('step-2');
266
- expect(mockBridge.request).not.toHaveBeenCalledWith('storage_write', expect.anything());
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('storage_read', {
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('storage_write', expect.anything());
379
+ expect(mockBridge.request).not.toHaveBeenCalledWith('entity_invoke', expect.anything());
285
380
  jest.advanceTimersByTime(260);
286
- expect(mockBridge.request).toHaveBeenCalledWith('storage_write', {
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('storage_write', {
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) => call[0] === 'storage_write' && call[1].path === `__state/${key}`).length;
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('storage_read', {
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('storage_write', {
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] === 'storage_write');
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
- path: '__state/counter',
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('storage_write', {
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() orchestrates SHA-256 + presigned PUT + finalize, stripping Content-Length', async () => {
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
- // Mock crypto.subtle.digest to return a known 32-byte digest.
731
- const fakeDigest = new Uint8Array(32);
732
- for (let i = 0; i < 32; i++)
733
- fakeDigest[i] = i;
734
- const originalCrypto = globalThis.crypto;
735
- globalThis.crypto = {
736
- subtle: { digest: jest.fn().mockResolvedValue(fakeDigest.buffer) },
737
- };
738
- // Mock fetch.
739
- const fetchMock = jest.fn().mockResolvedValue({ ok: true, status: 200 });
740
- const originalFetch = globalThis.fetch;
741
- globalThis.fetch = fetchMock;
742
- try {
743
- mockBridge.request.mockResolvedValueOnce({
744
- documentId: 'vdoc_u',
745
- uploadUrl: 'https://s3/up',
746
- requiredHeaders: {
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() throws on non-2xx PUT response', async () => {
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
- const fakeDigest = new Uint8Array(32);
795
- const originalCrypto = globalThis.crypto;
796
- globalThis.crypto = {
797
- subtle: { digest: jest.fn().mockResolvedValue(fakeDigest.buffer) },
798
- };
799
- const fetchMock = jest.fn().mockResolvedValue({
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
- const fakeDigest = new Uint8Array(32);
828
- const originalCrypto = globalThis.crypto;
829
- globalThis.crypto = {
830
- subtle: { digest: jest.fn().mockResolvedValue(fakeDigest.buffer) },
831
- };
832
- const fetchMock = jest.fn().mockResolvedValue({ ok: true, status: 200 });
833
- const originalFetch = globalThis.fetch;
834
- globalThis.fetch = fetchMock;
835
- try {
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('data_create_collection', {
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('data_create_collection', {
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('data_list_collections', {});
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(undefined);
1041
+ mockBridge.request.mockResolvedValueOnce(output({ success: true }));
1007
1042
  await ds.deleteCollection('scores');
1008
- expect(mockBridge.request).toHaveBeenCalledWith('data_delete_collection', {
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(undefined);
1048
+ mockBridge.request.mockResolvedValueOnce(output({ success: true }));
1016
1049
  await ds.put('scores', 'level-1', { score: 100 });
1017
- expect(mockBridge.request).toHaveBeenCalledWith('data_put', {
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('data_query', {
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(undefined);
1089
+ mockBridge.request.mockResolvedValueOnce(output({ success: true }));
1057
1090
  await ds.delete('scores', 'level-1');
1058
- expect(mockBridge.request).toHaveBeenCalledWith('data_delete', {
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, 'store_get_products', {});
1073
- expect(mockBridge.request).toHaveBeenNthCalledWith(2, 'store_get_entitlements', {});
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('store_purchase', {
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('store_purchase', {
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('store_get_purchase_history', {});
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('store_restore', {});
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('storage_read', {
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('storage_read', {
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