@fias/arche-sdk 2.19.1 → 2.20.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/entity-ops.d.ts +7 -4
- package/dist/entity-ops.d.ts.map +1 -1
- package/dist/entity-ops.js +7 -4
- package/dist/entity-ops.js.map +1 -1
- package/dist/generated/permissions.d.ts +1 -1
- package/dist/generated/permissions.d.ts.map +1 -1
- package/dist/generated/permissions.js +16 -12
- package/dist/generated/permissions.js.map +1 -1
- package/dist/hooks.d.ts +136 -6
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +324 -24
- package/dist/hooks.js.map +1 -1
- package/dist/hooks.test.js +414 -2
- package/dist/hooks.test.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +274 -35
- package/dist/types.d.ts +280 -24
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/templates/default/AGENTS.md +191 -18
- package/templates/default/CLAUDE.md +191 -18
package/dist/hooks.test.js
CHANGED
|
@@ -110,9 +110,11 @@ jest.mock('react-dom', () => ({
|
|
|
110
110
|
let mockSingletonBridge = null;
|
|
111
111
|
jest.mock('./bridge', () => ({
|
|
112
112
|
__esModule: true,
|
|
113
|
-
// Real exports the hooks module needs:
|
|
114
|
-
//
|
|
113
|
+
// Real exports the hooks module needs: getBridge, plus the coded error
|
|
114
|
+
// class the hooks throw for refusals they can make without a round-trip.
|
|
115
|
+
// resetBridge isn't referenced from hooks.ts.
|
|
115
116
|
getBridge: jest.fn(() => mockSingletonBridge),
|
|
117
|
+
FiasBridgeError: jest.requireActual('./bridge').FiasBridgeError,
|
|
116
118
|
FiasBridge: jest.fn(),
|
|
117
119
|
resetBridge: jest.fn(),
|
|
118
120
|
}));
|
|
@@ -238,6 +240,24 @@ describe('SDK hooks', () => {
|
|
|
238
240
|
});
|
|
239
241
|
});
|
|
240
242
|
});
|
|
243
|
+
describe('hook return identity', () => {
|
|
244
|
+
// These two returns MUST be memoized. The natural way to consume them is
|
|
245
|
+
// `useEffect(() => { void storage.readFile(...) }, [storage])`, and a
|
|
246
|
+
// fresh object literal every render turns that into an infinite loop —
|
|
247
|
+
// effect, setState, render, effect. It cost a live QA session to find.
|
|
248
|
+
//
|
|
249
|
+
// The react mock here stubs `useMemo` as a pass-through, so identity
|
|
250
|
+
// across renders cannot be asserted from this harness. What it CAN pin is
|
|
251
|
+
// that the return goes through `useMemo` at all, which is the thing a
|
|
252
|
+
// future edit would undo by reaching for an object literal again.
|
|
253
|
+
it.each([['useFiasStorage'], ['useFiasNavigation']])('%s memoizes its returned API object', (hookName) => {
|
|
254
|
+
const react = require('react');
|
|
255
|
+
react.useMemo.mockClear();
|
|
256
|
+
const hooks = require('./hooks');
|
|
257
|
+
hooks[hookName]();
|
|
258
|
+
expect(react.useMemo).toHaveBeenCalled();
|
|
259
|
+
});
|
|
260
|
+
});
|
|
241
261
|
describe('useFiasStorage', () => {
|
|
242
262
|
it('returns storage API methods that call bridge.request', async () => {
|
|
243
263
|
const { useFiasStorage } = require('./hooks');
|
|
@@ -289,6 +309,115 @@ describe('SDK hooks', () => {
|
|
|
289
309
|
expect(res.document.name).toBe('a.pdf');
|
|
290
310
|
});
|
|
291
311
|
});
|
|
312
|
+
describe('useVaultUserDocuments — edit access and in-place saves', () => {
|
|
313
|
+
const DOC = 'vdoc_0123456789abcdef0123456789abcdef';
|
|
314
|
+
const receipt = { documentId: DOC, sizeBytes: 5, updatedAt: 'now', revision: 'c:new' };
|
|
315
|
+
it('pick({ access }) forwards the access asked for; the default asks for nothing extra', async () => {
|
|
316
|
+
const { useVaultUserDocuments } = require('./hooks');
|
|
317
|
+
const docs = useVaultUserDocuments();
|
|
318
|
+
mockBridge.request.mockResolvedValue({ canceled: true });
|
|
319
|
+
await docs.pick({ maxDocuments: 3, access: 'write' });
|
|
320
|
+
await docs.pick();
|
|
321
|
+
expect(mockBridge.request).toHaveBeenNthCalledWith(1, 'vault_documents_pick', {
|
|
322
|
+
maxDocuments: 3,
|
|
323
|
+
access: 'write',
|
|
324
|
+
});
|
|
325
|
+
expect(mockBridge.request).toHaveBeenNthCalledWith(2, 'vault_documents_pick', {
|
|
326
|
+
maxDocuments: undefined,
|
|
327
|
+
access: undefined,
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
it('pick({ accept }) forwards the accepted types as a plain array', async () => {
|
|
331
|
+
const { useVaultUserDocuments } = require('./hooks');
|
|
332
|
+
mockBridge.request.mockResolvedValue({ canceled: true });
|
|
333
|
+
await useVaultUserDocuments().pick({ accept: ['image/*', '.pdf'] });
|
|
334
|
+
expect(mockBridge.request).toHaveBeenCalledWith('vault_documents_pick', {
|
|
335
|
+
maxDocuments: undefined,
|
|
336
|
+
access: undefined,
|
|
337
|
+
accept: ['image/*', '.pdf'],
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
it('requestEditAccess names ONE document and asks for write — through the same host-mediated op', async () => {
|
|
341
|
+
const { useVaultUserDocuments } = require('./hooks');
|
|
342
|
+
mockBridge.request.mockResolvedValue({ canceled: true });
|
|
343
|
+
await useVaultUserDocuments().requestEditAccess(DOC);
|
|
344
|
+
expect(mockBridge.request).toHaveBeenCalledWith('vault_documents_pick', {
|
|
345
|
+
maxDocuments: 1,
|
|
346
|
+
access: 'write',
|
|
347
|
+
documentId: DOC,
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
it('a small string saves over the entity lane with its expectedRevision', async () => {
|
|
351
|
+
const { useVaultUserDocuments } = require('./hooks');
|
|
352
|
+
mockBridge.request.mockResolvedValue(output(receipt));
|
|
353
|
+
const res = await useVaultUserDocuments().saveContent({
|
|
354
|
+
documentId: DOC,
|
|
355
|
+
content: 'hello',
|
|
356
|
+
expectedRevision: 'c:old',
|
|
357
|
+
});
|
|
358
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.VAULT_USER_DOCUMENTS_ENTITY_ID, 'save_consented_document_content', {
|
|
359
|
+
documentId: DOC,
|
|
360
|
+
content: 'hello',
|
|
361
|
+
expectedRevision: 'c:old',
|
|
362
|
+
}));
|
|
363
|
+
expect(res.revision).toBe('c:new');
|
|
364
|
+
});
|
|
365
|
+
it.each([
|
|
366
|
+
['bytes', new Uint8Array([1, 2, 3]), 'binary', Buffer.from([1, 2, 3]).toString('base64')],
|
|
367
|
+
['a string past the bridge body', 'x'.repeat(200001), 'text', null],
|
|
368
|
+
])('%s go to the host-orchestrated lane, aimed at the USER’S files and never able to create a document', async (_label, content, contentKind, expectedBase64) => {
|
|
369
|
+
const { useVaultUserDocuments } = require('./hooks');
|
|
370
|
+
mockBridge.request.mockResolvedValue(receipt);
|
|
371
|
+
await useVaultUserDocuments().saveContent({
|
|
372
|
+
documentId: DOC,
|
|
373
|
+
content,
|
|
374
|
+
expectedRevision: 'c:old',
|
|
375
|
+
});
|
|
376
|
+
expect(mockBridge.request).toHaveBeenCalledTimes(1);
|
|
377
|
+
const [type, payload] = mockBridge.request.mock.calls[0];
|
|
378
|
+
expect(type).toBe('vault_documents_upload');
|
|
379
|
+
expect(Object.keys(payload).sort()).toEqual([
|
|
380
|
+
'bytes',
|
|
381
|
+
'contentKind',
|
|
382
|
+
'expectedRevision',
|
|
383
|
+
'saveTarget',
|
|
384
|
+
'saveToDocumentId',
|
|
385
|
+
]);
|
|
386
|
+
expect(payload).toMatchObject({
|
|
387
|
+
saveToDocumentId: DOC,
|
|
388
|
+
saveTarget: 'user',
|
|
389
|
+
contentKind,
|
|
390
|
+
expectedRevision: 'c:old',
|
|
391
|
+
});
|
|
392
|
+
if (expectedBase64)
|
|
393
|
+
expect(payload.bytes).toBe(expectedBase64);
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
describe('saveContent — refused locally past the 15 MB cap', () => {
|
|
397
|
+
const DOC = 'vdoc_0123456789abcdef0123456789abcdef';
|
|
398
|
+
it.each([
|
|
399
|
+
['useVaultDocuments', 'useVaultDocuments'],
|
|
400
|
+
['useVaultUserDocuments', 'useVaultUserDocuments'],
|
|
401
|
+
])('%s: rejects with OWN_DOCUMENT_SAVE_TOO_LARGE before base64-encoding or sending anything', async (_label, hookName) => {
|
|
402
|
+
const hooks = require('./hooks');
|
|
403
|
+
const tooBig = new Uint8Array(15 * 1024 * 1024 + 1);
|
|
404
|
+
await expect(hooks[hookName]().saveContent({
|
|
405
|
+
documentId: DOC,
|
|
406
|
+
content: tooBig,
|
|
407
|
+
expectedRevision: 'c:old',
|
|
408
|
+
})).rejects.toMatchObject({ name: 'FiasBridgeError', code: 'OWN_DOCUMENT_SAVE_TOO_LARGE' });
|
|
409
|
+
expect(mockBridge.request).not.toHaveBeenCalled();
|
|
410
|
+
});
|
|
411
|
+
it('content exactly AT the cap is sent', async () => {
|
|
412
|
+
const { useVaultDocuments } = require('./hooks');
|
|
413
|
+
mockBridge.request.mockResolvedValue({ documentId: DOC, sizeBytes: 1, updatedAt: 'now' });
|
|
414
|
+
await useVaultDocuments().saveContent({
|
|
415
|
+
documentId: DOC,
|
|
416
|
+
content: new Uint8Array(15 * 1024 * 1024),
|
|
417
|
+
});
|
|
418
|
+
expect(mockBridge.request).toHaveBeenCalledTimes(1);
|
|
419
|
+
});
|
|
420
|
+
});
|
|
292
421
|
describe('useVaultDocuments.readBytes (arche-owned binary reads)', () => {
|
|
293
422
|
it('invokes get_own_document_bytes on the Vault Arche Documents entity and decodes byte-exact', async () => {
|
|
294
423
|
const { useVaultDocuments } = require('./hooks');
|
|
@@ -341,6 +470,143 @@ describe('SDK hooks', () => {
|
|
|
341
470
|
updatedAt: '2026-09-10T00:00:00.000Z',
|
|
342
471
|
});
|
|
343
472
|
});
|
|
473
|
+
// ── one method, lane picked from the argument ────────────────────────────
|
|
474
|
+
const DOC_ID = 'vdoc_0123456789abcdef0123456789abcdef';
|
|
475
|
+
const receipt = { documentId: DOC_ID, sizeBytes: 1, updatedAt: 'x', revision: 'c:new' };
|
|
476
|
+
/**
|
|
477
|
+
* REGRESSION GUARD. The text lane was chosen on raw UTF-8 size alone, but
|
|
478
|
+
* the text travels as a JSON string inside a 256 KB request: quotes,
|
|
479
|
+
* backslashes and newlines double, other control characters become six
|
|
480
|
+
* bytes. 150 KB of newlines is 300 KB on the wire — an opaque 413.
|
|
481
|
+
*/
|
|
482
|
+
it.each([
|
|
483
|
+
['newlines (2 bytes each on the wire)', '\n'.repeat(150000)],
|
|
484
|
+
['quotes', '"'.repeat(150000)],
|
|
485
|
+
['control characters (6 bytes each)', '\u0001'.repeat(60000)],
|
|
486
|
+
])('text under 200 KB that would NOT fit the request once JSON-escaped goes to the host lane: %s', async (_label, content) => {
|
|
487
|
+
const { useVaultDocuments } = require('./hooks');
|
|
488
|
+
mockBridge.request.mockResolvedValue(receipt);
|
|
489
|
+
expect(Buffer.byteLength(content, 'utf-8')).toBeLessThanOrEqual(200000);
|
|
490
|
+
await useVaultDocuments().saveContent({ documentId: DOC_ID, content });
|
|
491
|
+
const [type, payload] = mockBridge.request.mock.calls[0];
|
|
492
|
+
expect(type).toBe('vault_documents_upload');
|
|
493
|
+
expect(payload.contentKind).toBe('text');
|
|
494
|
+
expect(Buffer.from(payload.bytes, 'base64').toString('utf-8')).toBe(content);
|
|
495
|
+
});
|
|
496
|
+
it('the same amount of ORDINARY text still rides the small lane', async () => {
|
|
497
|
+
const { useVaultDocuments } = require('./hooks');
|
|
498
|
+
mockBridge.request.mockResolvedValue(output(receipt));
|
|
499
|
+
await useVaultDocuments().saveContent({ documentId: DOC_ID, content: 'x'.repeat(150000) });
|
|
500
|
+
expect(mockBridge.request.mock.calls[0][0]).toBe('entity_invoke');
|
|
501
|
+
});
|
|
502
|
+
it('write() has no second lane, so it refuses what cannot travel — by name, not as a 413', async () => {
|
|
503
|
+
const { useVaultDocuments } = require('./hooks');
|
|
504
|
+
const docs = useVaultDocuments();
|
|
505
|
+
await expect(docs.write({ name: 'a.txt', content: '\n'.repeat(150000) })).rejects.toMatchObject({
|
|
506
|
+
code: 'INVALID_PARAMS',
|
|
507
|
+
message: expect.stringMatching(/upload\(\)/),
|
|
508
|
+
});
|
|
509
|
+
await expect(docs.write({ name: 'a.txt', content: 'x'.repeat(200001) })).rejects.toMatchObject({
|
|
510
|
+
code: 'INVALID_PARAMS',
|
|
511
|
+
});
|
|
512
|
+
expect(mockBridge.request).not.toHaveBeenCalled();
|
|
513
|
+
mockBridge.request.mockResolvedValue({ documentId: DOC_ID });
|
|
514
|
+
await expect(docs.write({ name: 'a.txt', content: 'x'.repeat(150000) })).resolves.toEqual({
|
|
515
|
+
documentId: DOC_ID,
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
const decode = (b64) => Buffer.from(b64, 'base64');
|
|
519
|
+
/** The `[op, payload]` of the n-th bridge request, typed. */
|
|
520
|
+
const sent = (n = 0) => mockBridge.request.mock.calls[n];
|
|
521
|
+
it('sends small text with expectedRevision on the body lane', async () => {
|
|
522
|
+
const { useVaultDocuments } = require('./hooks');
|
|
523
|
+
mockBridge.request.mockResolvedValue(output(receipt));
|
|
524
|
+
const res = await useVaultDocuments().saveContent({
|
|
525
|
+
documentId: DOC_ID,
|
|
526
|
+
content: 'hello',
|
|
527
|
+
expectedRevision: 'c:old',
|
|
528
|
+
});
|
|
529
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.VAULT_ARCHE_DOCUMENTS_ENTITY_ID, 'save_own_document_content', {
|
|
530
|
+
documentId: DOC_ID,
|
|
531
|
+
content: 'hello',
|
|
532
|
+
expectedRevision: 'c:old',
|
|
533
|
+
}));
|
|
534
|
+
expect(res.revision).toBe('c:new');
|
|
535
|
+
});
|
|
536
|
+
it.each([
|
|
537
|
+
['Uint8Array', () => new Uint8Array([0x25, 0x50, 0x00, 0xff])],
|
|
538
|
+
['ArrayBuffer', () => new Uint8Array([0x25, 0x50, 0x00, 0xff]).buffer],
|
|
539
|
+
['Blob', () => new Blob([new Uint8Array([0x25, 0x50, 0x00, 0xff])])],
|
|
540
|
+
])('routes %s content through the host-uploaded lane, byte-exact, as binary', async (_l, make) => {
|
|
541
|
+
const { useVaultDocuments } = require('./hooks');
|
|
542
|
+
mockBridge.request.mockResolvedValue(receipt);
|
|
543
|
+
const res = await useVaultDocuments().saveContent({
|
|
544
|
+
documentId: DOC_ID,
|
|
545
|
+
content: make(),
|
|
546
|
+
contentType: 'application/pdf',
|
|
547
|
+
expectedRevision: 'c:old',
|
|
548
|
+
});
|
|
549
|
+
expect(mockBridge.request).toHaveBeenCalledTimes(1);
|
|
550
|
+
const [op, payload] = sent();
|
|
551
|
+
expect(op).toBe('vault_documents_upload');
|
|
552
|
+
expect(payload).toEqual({
|
|
553
|
+
saveToDocumentId: DOC_ID,
|
|
554
|
+
bytes: expect.any(String),
|
|
555
|
+
mimeType: 'application/pdf',
|
|
556
|
+
contentKind: 'binary',
|
|
557
|
+
expectedRevision: 'c:old',
|
|
558
|
+
});
|
|
559
|
+
expect(Array.from(decode(payload.bytes))).toEqual([0x25, 0x50, 0x00, 0xff]);
|
|
560
|
+
// NOT unwrapped from `{ output }` — the host returns the receipt itself.
|
|
561
|
+
expect(res).toEqual(receipt);
|
|
562
|
+
});
|
|
563
|
+
/**
|
|
564
|
+
* REGRESSION GUARD. A host that predates in-place saves handles
|
|
565
|
+
* `vault_documents_upload` by CREATING a document, and requires `name` to
|
|
566
|
+
* do it. Sending no `name` makes such a host refuse the message; sending
|
|
567
|
+
* one would make it silently fork a new document on every save — the
|
|
568
|
+
* exact behaviour this lane exists to remove.
|
|
569
|
+
*/
|
|
570
|
+
it('never sends create-path fields on the save lane, so an older host fails closed instead of forking a document', async () => {
|
|
571
|
+
const { useVaultDocuments } = require('./hooks');
|
|
572
|
+
mockBridge.request.mockResolvedValue(receipt);
|
|
573
|
+
await useVaultDocuments().saveContent({ documentId: DOC_ID, content: new Uint8Array([1]) });
|
|
574
|
+
const payload = mockBridge.request.mock.calls[0][1];
|
|
575
|
+
for (const key of [
|
|
576
|
+
'name',
|
|
577
|
+
'tags',
|
|
578
|
+
'folderPath',
|
|
579
|
+
'sensitivity',
|
|
580
|
+
'replacesDocumentId',
|
|
581
|
+
'userVisible',
|
|
582
|
+
]) {
|
|
583
|
+
expect(payload).not.toHaveProperty(key);
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
it('auto-routes text past the 200 KB body ceiling through the host lane AS TEXT, rather than failing', async () => {
|
|
587
|
+
const { useVaultDocuments } = require('./hooks');
|
|
588
|
+
mockBridge.request.mockResolvedValue(receipt);
|
|
589
|
+
const big = 'a'.repeat(200001);
|
|
590
|
+
await useVaultDocuments().saveContent({ documentId: DOC_ID, content: big });
|
|
591
|
+
const [op, payload] = sent();
|
|
592
|
+
expect(op).toBe('vault_documents_upload');
|
|
593
|
+
// 'text' keeps the must-be-a-text-type rule in force at any size.
|
|
594
|
+
expect(payload.contentKind).toBe('text');
|
|
595
|
+
expect(decode(payload.bytes).toString('utf-8')).toBe(big);
|
|
596
|
+
});
|
|
597
|
+
it('measures the ENCODED size: multi-byte text under 200k chars but over 200 KB takes the host lane', async () => {
|
|
598
|
+
const { useVaultDocuments } = require('./hooks');
|
|
599
|
+
mockBridge.request.mockResolvedValue(receipt);
|
|
600
|
+
const content = '€'.repeat(70000); // 210 000 bytes, 70 000 chars
|
|
601
|
+
await useVaultDocuments().saveContent({ documentId: DOC_ID, content });
|
|
602
|
+
expect(mockBridge.request.mock.calls[0][0]).toBe('vault_documents_upload');
|
|
603
|
+
});
|
|
604
|
+
it('keeps text at exactly the ceiling on the body lane', async () => {
|
|
605
|
+
const { useVaultDocuments } = require('./hooks');
|
|
606
|
+
mockBridge.request.mockResolvedValue(output(receipt));
|
|
607
|
+
await useVaultDocuments().saveContent({ documentId: DOC_ID, content: 'a'.repeat(200000) });
|
|
608
|
+
expect(mockBridge.request.mock.calls[0][0]).toBe('entity_invoke');
|
|
609
|
+
});
|
|
344
610
|
});
|
|
345
611
|
describe('useEntityInvocation', () => {
|
|
346
612
|
it('returns invoke function and initial state', () => {
|
|
@@ -535,6 +801,19 @@ describe('SDK hooks', () => {
|
|
|
535
801
|
await Promise.resolve();
|
|
536
802
|
expect(mockSetState).toHaveBeenCalledWith(42);
|
|
537
803
|
});
|
|
804
|
+
it('keeps a value set before the mount read resolves (the read must not revert it)', async () => {
|
|
805
|
+
let resolveRead;
|
|
806
|
+
mockBridge.request.mockImplementationOnce(() => new Promise((r) => (resolveRead = r)));
|
|
807
|
+
const { usePersistentState } = require('./hooks');
|
|
808
|
+
const [, setValue] = usePersistentState('choice', 'a');
|
|
809
|
+
setValue('b'); // the user acts while the read is still in flight
|
|
810
|
+
mockSetState.mockClear();
|
|
811
|
+
resolveRead(output({ content: JSON.stringify('stored'), exists: true }));
|
|
812
|
+
await Promise.resolve();
|
|
813
|
+
await Promise.resolve();
|
|
814
|
+
await Promise.resolve();
|
|
815
|
+
expect(mockSetState).not.toHaveBeenCalledWith('stored');
|
|
816
|
+
});
|
|
538
817
|
it('falls back to the initial value when the mount storage_read is denied/fails', async () => {
|
|
539
818
|
// e.g. the plugin didn't declare `storage:sandbox`. Must not become a
|
|
540
819
|
// silent unhandled rejection, and must not clobber the initial value.
|
|
@@ -715,6 +994,39 @@ describe('SDK hooks', () => {
|
|
|
715
994
|
documentId: 'vdoc_2',
|
|
716
995
|
});
|
|
717
996
|
});
|
|
997
|
+
it("upload({ destination: 'documents' }) asks the host for its Save dialog", async () => {
|
|
998
|
+
const { useVaultDocuments } = freshHooks();
|
|
999
|
+
const api = useVaultDocuments();
|
|
1000
|
+
const saved = {
|
|
1001
|
+
documentId: 'vdoc_s',
|
|
1002
|
+
status: 'uploaded',
|
|
1003
|
+
name: 'Report (2).pdf',
|
|
1004
|
+
access: 'read',
|
|
1005
|
+
};
|
|
1006
|
+
mockBridge.request.mockResolvedValueOnce(saved);
|
|
1007
|
+
const res = await api.upload(new Uint8Array([1, 2]), {
|
|
1008
|
+
name: 'Report.pdf',
|
|
1009
|
+
mimeType: 'application/pdf',
|
|
1010
|
+
destination: 'documents',
|
|
1011
|
+
});
|
|
1012
|
+
expect(res).toEqual(saved);
|
|
1013
|
+
expect(mockBridge.request).toHaveBeenCalledWith('vault_documents_upload', expect.objectContaining({ name: 'Report.pdf', destination: 'documents' }));
|
|
1014
|
+
});
|
|
1015
|
+
it('saveCopy rides the update op with only the saveCopy options', async () => {
|
|
1016
|
+
const { useVaultDocuments } = freshHooks();
|
|
1017
|
+
const api = useVaultDocuments();
|
|
1018
|
+
mockBridge.request.mockResolvedValue({ documentId: 'vdoc_c', status: 'uploaded' });
|
|
1019
|
+
await api.saveCopy('vdoc_own', { suggestedName: 'Final.png' });
|
|
1020
|
+
expect(mockBridge.request).toHaveBeenLastCalledWith('vault_documents_update', {
|
|
1021
|
+
documentId: 'vdoc_own',
|
|
1022
|
+
saveCopy: { suggestedName: 'Final.png' },
|
|
1023
|
+
});
|
|
1024
|
+
await api.saveCopy('vdoc_own');
|
|
1025
|
+
expect(mockBridge.request).toHaveBeenLastCalledWith('vault_documents_update', {
|
|
1026
|
+
documentId: 'vdoc_own',
|
|
1027
|
+
saveCopy: {},
|
|
1028
|
+
});
|
|
1029
|
+
});
|
|
718
1030
|
it('getDownloadUrl caches a still-valid presigned URL', async () => {
|
|
719
1031
|
const { useVaultDocuments } = freshHooks();
|
|
720
1032
|
const api = useVaultDocuments();
|
|
@@ -1405,6 +1717,106 @@ describe('SDK hooks', () => {
|
|
|
1405
1717
|
expect(asset.assetId).toBe('aset_1');
|
|
1406
1718
|
});
|
|
1407
1719
|
});
|
|
1720
|
+
describe('useFiasContent (content packs, ADR-021)', () => {
|
|
1721
|
+
const ENTITY = 'ent_intg_plugin_content_00000000';
|
|
1722
|
+
const load = () => {
|
|
1723
|
+
const { useFiasContent, FiasContentError } = require('./hooks');
|
|
1724
|
+
return { content: useFiasContent(), FiasContentError };
|
|
1725
|
+
};
|
|
1726
|
+
it('lists through the generic client_entity_invoke op, verb in the input', async () => {
|
|
1727
|
+
const { content } = load();
|
|
1728
|
+
const files = [{ path: 'lessons/01.md', sizeBytes: 5, mimeType: 'text/markdown' }];
|
|
1729
|
+
mockBridge.request.mockResolvedValueOnce({ files });
|
|
1730
|
+
expect(await content.list('lessons/')).toEqual(files);
|
|
1731
|
+
expect(mockBridge.request).toHaveBeenCalledWith('client_entity_invoke', { entityId: ENTITY, input: { verb: 'list', prefix: 'lessons/' } }, 120000);
|
|
1732
|
+
});
|
|
1733
|
+
it('reads text and parses JSON', async () => {
|
|
1734
|
+
const { content } = load();
|
|
1735
|
+
mockBridge.request.mockResolvedValueOnce({
|
|
1736
|
+
results: { 'a.md': { text: '# A', mimeType: 'text/markdown' } },
|
|
1737
|
+
});
|
|
1738
|
+
expect(await content.getText('a.md')).toBe('# A');
|
|
1739
|
+
mockBridge.request.mockResolvedValueOnce({
|
|
1740
|
+
results: { 'c.json': { text: '{"n":1}', mimeType: 'application/json' } },
|
|
1741
|
+
});
|
|
1742
|
+
expect(await content.getJson('c.json')).toEqual({ n: 1 });
|
|
1743
|
+
});
|
|
1744
|
+
it('throws a coded FiasContentError for a per-file and a whole-call failure', async () => {
|
|
1745
|
+
const { content, FiasContentError } = load();
|
|
1746
|
+
mockBridge.request.mockResolvedValueOnce({
|
|
1747
|
+
results: { 'x.md': { error: { code: 'CONTENT_NOT_FOUND', message: 'not in pack' } } },
|
|
1748
|
+
});
|
|
1749
|
+
const perFile = await content.getText('x.md').catch((e) => e);
|
|
1750
|
+
expect(perFile).toBeInstanceOf(FiasContentError);
|
|
1751
|
+
expect(perFile).toMatchObject({ code: 'CONTENT_NOT_FOUND', path: 'x.md' });
|
|
1752
|
+
mockBridge.request.mockResolvedValueOnce({
|
|
1753
|
+
error: { code: 'CONTENT_UNAVAILABLE_IN_PREVIEW', message: 'preview' },
|
|
1754
|
+
});
|
|
1755
|
+
await expect(content.list()).rejects.toMatchObject({
|
|
1756
|
+
code: 'CONTENT_UNAVAILABLE_IN_PREVIEW',
|
|
1757
|
+
});
|
|
1758
|
+
});
|
|
1759
|
+
it.each([
|
|
1760
|
+
['Permission denied: entities:client_invoke', undefined, 'PERMISSION_DENIED'],
|
|
1761
|
+
['Rate limit exceeded for client_entity_invoke', undefined, 'RATE_LIMIT'],
|
|
1762
|
+
['Bridge request timed out', 'TIMEOUT', 'CONTENT_UNAVAILABLE'],
|
|
1763
|
+
])('maps a bridge failure (%s, code %s) to %s', async (message, code, expected) => {
|
|
1764
|
+
const { content } = load();
|
|
1765
|
+
mockBridge.request.mockRejectedValueOnce(Object.assign(new Error(message), { code }));
|
|
1766
|
+
await expect(content.list()).rejects.toMatchObject({ code: expected });
|
|
1767
|
+
});
|
|
1768
|
+
it('reads bytes, and batches with per-file errors', async () => {
|
|
1769
|
+
const { content, FiasContentError } = load();
|
|
1770
|
+
const bytes = new Uint8Array([1, 2]).buffer;
|
|
1771
|
+
mockBridge.request.mockResolvedValueOnce({
|
|
1772
|
+
results: { 'a.bin': { bytes, mimeType: 'application/octet-stream' } },
|
|
1773
|
+
});
|
|
1774
|
+
expect(await content.getBytes('a.bin')).toBe(bytes);
|
|
1775
|
+
mockBridge.request.mockResolvedValueOnce({
|
|
1776
|
+
results: {
|
|
1777
|
+
'a.md': { text: '# A', mimeType: 'text/markdown' },
|
|
1778
|
+
'b.png': { error: { code: 'CONTENT_NOT_TEXT', message: 'image/png, not text' } },
|
|
1779
|
+
},
|
|
1780
|
+
});
|
|
1781
|
+
const many = await content.getMany(['a.md', 'b.png', 'gone.md'], 'text');
|
|
1782
|
+
expect(many['a.md']).toBe('# A');
|
|
1783
|
+
expect(many['b.png']).toMatchObject({ code: 'CONTENT_NOT_TEXT', path: 'b.png' });
|
|
1784
|
+
expect(many['gone.md']).toBeInstanceOf(FiasContentError);
|
|
1785
|
+
});
|
|
1786
|
+
it('builds one typed blob: URL per path from host-cached bytes', async () => {
|
|
1787
|
+
const { content } = load();
|
|
1788
|
+
const created = [];
|
|
1789
|
+
const original = URL.createObjectURL;
|
|
1790
|
+
URL.createObjectURL = jest.fn((blob) => {
|
|
1791
|
+
created.push(blob);
|
|
1792
|
+
return 'blob:plugin/1';
|
|
1793
|
+
});
|
|
1794
|
+
try {
|
|
1795
|
+
mockBridge.request.mockResolvedValueOnce({
|
|
1796
|
+
results: {
|
|
1797
|
+
'img/cover-unique.webp': { bytes: new ArrayBuffer(3), mimeType: 'image/webp' },
|
|
1798
|
+
},
|
|
1799
|
+
});
|
|
1800
|
+
expect(await content.getObjectUrl('img/cover-unique.webp')).toBe('blob:plugin/1');
|
|
1801
|
+
expect(await content.getObjectUrl('img/cover-unique.webp')).toBe('blob:plugin/1');
|
|
1802
|
+
expect(created).toHaveLength(1);
|
|
1803
|
+
expect(created[0].type).toBe('image/webp');
|
|
1804
|
+
}
|
|
1805
|
+
finally {
|
|
1806
|
+
URL.createObjectURL = original;
|
|
1807
|
+
}
|
|
1808
|
+
});
|
|
1809
|
+
it('returns a signed streaming URL with its expiry', async () => {
|
|
1810
|
+
const { content } = load();
|
|
1811
|
+
mockBridge.request.mockResolvedValueOnce({
|
|
1812
|
+
results: { 'a.ogg': { url: 'https://cdn/x?sig', expiresAt: '2026-09-24T00:00:00Z' } },
|
|
1813
|
+
});
|
|
1814
|
+
expect(await content.getUrl('a.ogg')).toEqual({
|
|
1815
|
+
url: 'https://cdn/x?sig',
|
|
1816
|
+
expiresAt: '2026-09-24T00:00:00Z',
|
|
1817
|
+
});
|
|
1818
|
+
});
|
|
1819
|
+
});
|
|
1408
1820
|
});
|
|
1409
1821
|
describe('useDataSubscription (ADR-012 Phase M4)', () => {
|
|
1410
1822
|
const flush = async () => {
|