@fias/arche-sdk 2.29.0 → 2.31.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 +32 -6
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +73 -2
- package/dist/bridge.js.map +1 -1
- package/dist/bridge.test.js +71 -0
- package/dist/bridge.test.js.map +1 -1
- package/dist/entity-ops.d.ts +12 -0
- package/dist/entity-ops.d.ts.map +1 -1
- package/dist/entity-ops.js +13 -1
- 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 +8 -0
- package/dist/generated/permissions.js.map +1 -1
- package/dist/generated/surfaces.d.ts +119 -5
- package/dist/generated/surfaces.d.ts.map +1 -1
- package/dist/generated/surfaces.js +14 -6
- package/dist/generated/surfaces.js.map +1 -1
- package/dist/hooks.d.ts +161 -18
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +682 -38
- package/dist/hooks.js.map +1 -1
- package/dist/hooks.test.js +613 -0
- 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 +8 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +555 -21
- package/dist/types.d.ts +489 -13
- package/dist/types.d.ts.map +1 -1
- package/dist/use-fias-access.render.test.d.ts +12 -0
- package/dist/use-fias-access.render.test.d.ts.map +1 -0
- package/dist/use-fias-access.render.test.js +407 -0
- package/dist/use-fias-access.render.test.js.map +1 -0
- package/package.json +3 -2
- package/templates/default/AGENTS.md +360 -27
- package/templates/default/CLAUDE.md +360 -27
package/dist/hooks.test.js
CHANGED
|
@@ -71,7 +71,13 @@ function createMockBridge() {
|
|
|
71
71
|
getHolder: jest.fn().mockReturnValue(null),
|
|
72
72
|
onHolderReady: jest.fn().mockReturnValue(() => { }),
|
|
73
73
|
onThemeUpdate: jest.fn().mockReturnValue(() => { }),
|
|
74
|
+
onAccessUpdate: jest.fn().mockReturnValue(() => { }),
|
|
74
75
|
onNavigationUpdate: jest.fn().mockReturnValue(() => { }),
|
|
76
|
+
getChrome: jest.fn().mockReturnValue(null),
|
|
77
|
+
hasChromeSupport: jest.fn().mockReturnValue(false),
|
|
78
|
+
onChromeUpdate: jest.fn().mockReturnValue(() => { }),
|
|
79
|
+
setChrome: jest.fn(),
|
|
80
|
+
navigateHost: jest.fn(),
|
|
75
81
|
onStepNavigationUpdate: jest.fn().mockReturnValue(() => { }),
|
|
76
82
|
registerPreviewState: jest.fn().mockReturnValue(() => { }),
|
|
77
83
|
onSubscriptionEvent: jest.fn().mockReturnValue(() => { }),
|
|
@@ -244,6 +250,75 @@ describe('SDK hooks', () => {
|
|
|
244
250
|
});
|
|
245
251
|
});
|
|
246
252
|
});
|
|
253
|
+
describe('useFiasChrome', () => {
|
|
254
|
+
const HOST_STATE = {
|
|
255
|
+
focusMode: true,
|
|
256
|
+
focusModeAvailable: true,
|
|
257
|
+
sidebarAvailable: true,
|
|
258
|
+
sidebarCollapsed: false,
|
|
259
|
+
hostExpandedBar: true,
|
|
260
|
+
hostExpandButton: false,
|
|
261
|
+
};
|
|
262
|
+
it('is inert and unsupported before init / on an older host, and still renders', () => {
|
|
263
|
+
const { useFiasChrome } = require('./hooks');
|
|
264
|
+
const chrome = useFiasChrome();
|
|
265
|
+
expect(chrome.supported).toBe(false);
|
|
266
|
+
expect(chrome.focusMode).toBe(false);
|
|
267
|
+
expect(chrome.focusModeAvailable).toBe(false);
|
|
268
|
+
expect(chrome.sidebarAvailable).toBe(false);
|
|
269
|
+
expect(chrome.hostExpandedBar).toBe(true);
|
|
270
|
+
expect(mockBridge.onChromeUpdate).toHaveBeenCalled();
|
|
271
|
+
});
|
|
272
|
+
it('spreads the host state and reports supported once delivered', () => {
|
|
273
|
+
mockBridge.getChrome.mockReturnValue(HOST_STATE);
|
|
274
|
+
mockBridge.hasChromeSupport.mockReturnValue(true);
|
|
275
|
+
const { useFiasChrome } = require('./hooks');
|
|
276
|
+
const chrome = useFiasChrome();
|
|
277
|
+
expect(chrome.supported).toBe(true);
|
|
278
|
+
expect(chrome.focusMode).toBe(true);
|
|
279
|
+
expect(chrome.hostExpandButton).toBe(false);
|
|
280
|
+
});
|
|
281
|
+
it('TAKES THE CHROME OVER on mount — the host drops its default expand button', () => {
|
|
282
|
+
const { useFiasChrome } = require('./hooks');
|
|
283
|
+
useFiasChrome();
|
|
284
|
+
expect(mockBridge.setChrome).toHaveBeenCalledWith({ hostExpandButton: false });
|
|
285
|
+
});
|
|
286
|
+
it('setters send one-field chrome_set patches; navigation uses the closed enum', () => {
|
|
287
|
+
const { useFiasChrome } = require('./hooks');
|
|
288
|
+
const chrome = useFiasChrome();
|
|
289
|
+
chrome.setFocusMode(true);
|
|
290
|
+
expect(mockBridge.setChrome).toHaveBeenCalledWith({ focusMode: true });
|
|
291
|
+
chrome.setSidebarCollapsed(true);
|
|
292
|
+
expect(mockBridge.setChrome).toHaveBeenCalledWith({ sidebarCollapsed: true });
|
|
293
|
+
chrome.setHostExpandedBar(false);
|
|
294
|
+
expect(mockBridge.setChrome).toHaveBeenCalledWith({ hostExpandedBar: false });
|
|
295
|
+
chrome.goToFiasHome();
|
|
296
|
+
expect(mockBridge.navigateHost).toHaveBeenCalledWith('home');
|
|
297
|
+
chrome.goToWorktop();
|
|
298
|
+
expect(mockBridge.navigateHost).toHaveBeenCalledWith('worktop');
|
|
299
|
+
chrome.openAssistant();
|
|
300
|
+
expect(mockBridge.openAssistant).toHaveBeenCalled();
|
|
301
|
+
});
|
|
302
|
+
it('carries where the person came from, null when nothing opened the arche', () => {
|
|
303
|
+
const { useFiasChrome } = require('./hooks');
|
|
304
|
+
expect(useFiasChrome().opener).toBeNull();
|
|
305
|
+
mockBridge.getChrome.mockReturnValue({
|
|
306
|
+
...HOST_STATE,
|
|
307
|
+
opener: { kind: 'forge', name: 'My Arche' },
|
|
308
|
+
});
|
|
309
|
+
expect(useFiasChrome().opener).toEqual({ kind: 'forge', name: 'My Arche' });
|
|
310
|
+
});
|
|
311
|
+
it('cleanup unsubscribes from chrome updates', () => {
|
|
312
|
+
const unsubscribe = jest.fn();
|
|
313
|
+
mockBridge.onChromeUpdate.mockReturnValue(unsubscribe);
|
|
314
|
+
const { useFiasChrome } = require('./hooks');
|
|
315
|
+
useFiasChrome();
|
|
316
|
+
// The LAST captured cleanup is the takeover effect's (none) or the
|
|
317
|
+
// subscription's — walk the captured one if present.
|
|
318
|
+
capturedEffectCleanup?.();
|
|
319
|
+
expect(unsubscribe).toHaveBeenCalled();
|
|
320
|
+
});
|
|
321
|
+
});
|
|
247
322
|
describe('hook return identity', () => {
|
|
248
323
|
// These two returns MUST be memoized. The natural way to consume them is
|
|
249
324
|
// `useEffect(() => { void storage.readFile(...) }, [storage])`, and a
|
|
@@ -788,6 +863,270 @@ describe('SDK hooks', () => {
|
|
|
788
863
|
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_WORKSPACES_ENTITY_ID, 'create', { forActingHolder: true }));
|
|
789
864
|
});
|
|
790
865
|
});
|
|
866
|
+
// ADR-034 phase 0b. The real-render cases (stale answers across workspace
|
|
867
|
+
// switches, the render before effects run, refetch) live in
|
|
868
|
+
// use-fias-access.render.test.tsx, which CI runs through the package's
|
|
869
|
+
// `test:render` script; these mock React and run under the root config.
|
|
870
|
+
describe('useFiasAccess', () => {
|
|
871
|
+
const settle = () => new Promise((r) => setTimeout(r, 0));
|
|
872
|
+
const setCalls = () => mockSetState.mock.calls.map((c) => c[0]);
|
|
873
|
+
it('asks the workspaces entity for my_access with the workspace id', async () => {
|
|
874
|
+
const { useFiasAccess } = require('./hooks');
|
|
875
|
+
const access = { mode: 'tier', workspaceId: 'ws_1', tier: 'member', expiresAt: null };
|
|
876
|
+
mockBridge.request.mockResolvedValue(output({ access }));
|
|
877
|
+
useFiasAccess('ws_1');
|
|
878
|
+
await settle();
|
|
879
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_WORKSPACES_ENTITY_ID, 'my_access', { workspaceId: 'ws_1' }));
|
|
880
|
+
expect(setCalls()).toContainEqual({ workspaceId: 'ws_1', access, error: null });
|
|
881
|
+
});
|
|
882
|
+
it.each(['NOT_A_MEMBER', 'WORKSPACE_NOT_FOUND'])('%s becomes tier: null', async (code) => {
|
|
883
|
+
const { useFiasAccess } = require('./hooks');
|
|
884
|
+
const { FiasBridgeError } = jest.requireActual('./bridge');
|
|
885
|
+
mockBridge.request.mockRejectedValue(new FiasBridgeError('refused', code));
|
|
886
|
+
useFiasAccess('ws_1');
|
|
887
|
+
await settle();
|
|
888
|
+
expect(setCalls()).toContainEqual({
|
|
889
|
+
workspaceId: 'ws_1',
|
|
890
|
+
access: { mode: 'tier', workspaceId: 'ws_1', tier: null, expiresAt: null },
|
|
891
|
+
error: null,
|
|
892
|
+
});
|
|
893
|
+
});
|
|
894
|
+
it('surfaces any other failure as an error', async () => {
|
|
895
|
+
const { useFiasAccess } = require('./hooks');
|
|
896
|
+
const { FiasBridgeError } = jest.requireActual('./bridge');
|
|
897
|
+
mockBridge.request.mockRejectedValue(new FiasBridgeError('slow down', 'RATE_LIMIT'));
|
|
898
|
+
useFiasAccess('ws_1');
|
|
899
|
+
await settle();
|
|
900
|
+
expect(setCalls()).toContainEqual({
|
|
901
|
+
workspaceId: 'ws_1',
|
|
902
|
+
access: null,
|
|
903
|
+
error: expect.objectContaining({ code: 'RATE_LIMIT' }),
|
|
904
|
+
});
|
|
905
|
+
});
|
|
906
|
+
it('asks nothing without a workspace', async () => {
|
|
907
|
+
const { useFiasAccess } = require('./hooks');
|
|
908
|
+
const state = useFiasAccess(null);
|
|
909
|
+
await settle();
|
|
910
|
+
expect(mockBridge.request).not.toHaveBeenCalled();
|
|
911
|
+
expect(state).toMatchObject({ access: null, isLoading: false, error: null });
|
|
912
|
+
});
|
|
913
|
+
it('asks again when the host says the person’s access changed (ADR-034)', async () => {
|
|
914
|
+
const { useFiasAccess } = require('./hooks');
|
|
915
|
+
const access = { mode: 'tier', workspaceId: 'ws_1', tier: 'member', expiresAt: null };
|
|
916
|
+
mockBridge.request.mockResolvedValue(output({ access }));
|
|
917
|
+
useFiasAccess('ws_1');
|
|
918
|
+
await settle();
|
|
919
|
+
expect(mockBridge.request).toHaveBeenCalledTimes(1);
|
|
920
|
+
const listener = mockBridge.onAccessUpdate.mock.calls[0][0];
|
|
921
|
+
listener();
|
|
922
|
+
await settle();
|
|
923
|
+
expect(mockBridge.request).toHaveBeenCalledTimes(2);
|
|
924
|
+
});
|
|
925
|
+
it('a failed background refetch keeps the answer it had', async () => {
|
|
926
|
+
const { useFiasAccess } = require('./hooks');
|
|
927
|
+
const { FiasBridgeError } = jest.requireActual('./bridge');
|
|
928
|
+
const access = { mode: 'tier', workspaceId: 'ws_1', tier: 'member', expiresAt: null };
|
|
929
|
+
mockBridge.request.mockResolvedValueOnce(output({ access }));
|
|
930
|
+
useFiasAccess('ws_1');
|
|
931
|
+
await settle();
|
|
932
|
+
mockSetState.mockClear();
|
|
933
|
+
mockBridge.request.mockRejectedValueOnce(new FiasBridgeError('slow down', 'RATE_LIMIT'));
|
|
934
|
+
const subscriptions = mockBridge.onAccessUpdate.mock.calls;
|
|
935
|
+
subscriptions[subscriptions.length - 1][0]();
|
|
936
|
+
await settle();
|
|
937
|
+
// No blanking answer was written, and nothing flipped to loading.
|
|
938
|
+
expect(setCalls()).not.toContainEqual(expect.objectContaining({ access: null }));
|
|
939
|
+
expect(setCalls()).not.toContain(true);
|
|
940
|
+
});
|
|
941
|
+
it('window focus asks again at most once per 30 s', async () => {
|
|
942
|
+
const listeners = {};
|
|
943
|
+
const win = {
|
|
944
|
+
addEventListener: jest.fn((type, fn) => (listeners[type] = fn)),
|
|
945
|
+
removeEventListener: jest.fn(),
|
|
946
|
+
};
|
|
947
|
+
globalThis.window = win;
|
|
948
|
+
try {
|
|
949
|
+
const { useFiasAccess } = require('./hooks');
|
|
950
|
+
const access = { mode: 'tier', workspaceId: 'ws_1', tier: 'member', expiresAt: null };
|
|
951
|
+
mockBridge.request.mockResolvedValue(output({ access }));
|
|
952
|
+
useFiasAccess('ws_1');
|
|
953
|
+
await settle();
|
|
954
|
+
expect(mockBridge.request).toHaveBeenCalledTimes(1);
|
|
955
|
+
listeners.focus();
|
|
956
|
+
await settle();
|
|
957
|
+
expect(mockBridge.request).toHaveBeenCalledTimes(1);
|
|
958
|
+
const now = Date.now();
|
|
959
|
+
jest.spyOn(Date, 'now').mockReturnValue(now + 31000);
|
|
960
|
+
listeners.focus();
|
|
961
|
+
await settle();
|
|
962
|
+
expect(mockBridge.request).toHaveBeenCalledTimes(2);
|
|
963
|
+
}
|
|
964
|
+
finally {
|
|
965
|
+
delete globalThis.window;
|
|
966
|
+
jest.restoreAllMocks();
|
|
967
|
+
}
|
|
968
|
+
});
|
|
969
|
+
it('opens the host’s access sheet with no input, and asks again only if it changed', async () => {
|
|
970
|
+
const { useFiasAccess } = require('./hooks');
|
|
971
|
+
const access = { mode: 'tier', workspaceId: 'ws_1', tier: 'member', expiresAt: null };
|
|
972
|
+
mockBridge.request.mockResolvedValue(output({ access }));
|
|
973
|
+
const state = useFiasAccess('ws_1');
|
|
974
|
+
await settle();
|
|
975
|
+
mockBridge.request.mockClear();
|
|
976
|
+
mockBridge.request.mockResolvedValueOnce({ changed: false });
|
|
977
|
+
await expect(state.openAccessDialog()).resolves.toEqual({ changed: false });
|
|
978
|
+
expect(mockBridge.request).toHaveBeenCalledWith('client_entity_invoke', { entityId: 'ent_intg_app_access_dialog_00000', input: {} }, expect.any(Number));
|
|
979
|
+
expect(mockBridge.request).toHaveBeenCalledTimes(1);
|
|
980
|
+
mockBridge.request.mockResolvedValueOnce({ changed: true });
|
|
981
|
+
mockBridge.request.mockResolvedValue(output({ access }));
|
|
982
|
+
await expect(state.openAccessDialog()).resolves.toEqual({ changed: true });
|
|
983
|
+
await settle();
|
|
984
|
+
expect(mockBridge.request).toHaveBeenCalledTimes(3);
|
|
985
|
+
});
|
|
986
|
+
it('a host without the sheet (an older Fias) is ACCESS_UNAVAILABLE, not a bare bridge error', async () => {
|
|
987
|
+
const { useFiasAccess } = require('./hooks');
|
|
988
|
+
const { FiasBridgeError } = jest.requireActual('./bridge');
|
|
989
|
+
mockBridge.request.mockResolvedValue(output({ access: null }));
|
|
990
|
+
const state = useFiasAccess('ws_1');
|
|
991
|
+
await settle();
|
|
992
|
+
mockBridge.request.mockRejectedValueOnce(new FiasBridgeError('no client-side executor registered', 'BRIDGE_ERROR'));
|
|
993
|
+
await expect(state.openAccessDialog()).rejects.toMatchObject({ code: 'ACCESS_UNAVAILABLE' });
|
|
994
|
+
});
|
|
995
|
+
it('an older dev harness without the sheet is ACCESS_UNAVAILABLE too', async () => {
|
|
996
|
+
const { useFiasAccess } = require('./hooks');
|
|
997
|
+
const { FiasBridgeError } = jest.requireActual('./bridge');
|
|
998
|
+
mockBridge.request.mockResolvedValue(output({ access: null }));
|
|
999
|
+
const state = useFiasAccess('ws_1');
|
|
1000
|
+
await settle();
|
|
1001
|
+
mockBridge.request.mockRejectedValueOnce(new FiasBridgeError('client_entity_invoke: no client-side executor for ent_intg_app_access_dialog_00000 in the dev harness', 'BRIDGE_ERROR'));
|
|
1002
|
+
await expect(state.openAccessDialog()).rejects.toMatchObject({ code: 'ACCESS_UNAVAILABLE' });
|
|
1003
|
+
});
|
|
1004
|
+
it('any other bridge error (e.g. a missing permission) passes through unchanged', async () => {
|
|
1005
|
+
const { useFiasAccess } = require('./hooks');
|
|
1006
|
+
const { FiasBridgeError } = jest.requireActual('./bridge');
|
|
1007
|
+
mockBridge.request.mockResolvedValue(output({ access: null }));
|
|
1008
|
+
const state = useFiasAccess('ws_1');
|
|
1009
|
+
await settle();
|
|
1010
|
+
mockBridge.request.mockRejectedValueOnce(new FiasBridgeError('Permission denied: data:workspace not granted', 'BRIDGE_ERROR'));
|
|
1011
|
+
await expect(state.openAccessDialog()).rejects.toMatchObject({
|
|
1012
|
+
code: 'BRIDGE_ERROR',
|
|
1013
|
+
message: 'Permission denied: data:workspace not granted',
|
|
1014
|
+
});
|
|
1015
|
+
});
|
|
1016
|
+
it('the sheet’s refusal is a typed error', async () => {
|
|
1017
|
+
const { useFiasAccess } = require('./hooks');
|
|
1018
|
+
mockBridge.request.mockResolvedValue(output({ access: null }));
|
|
1019
|
+
const state = useFiasAccess('ws_1');
|
|
1020
|
+
await settle();
|
|
1021
|
+
mockBridge.request.mockResolvedValueOnce({
|
|
1022
|
+
error: { code: 'NOT_IN_WORKSPACE', message: 'Personal' },
|
|
1023
|
+
});
|
|
1024
|
+
await expect(state.openAccessDialog()).rejects.toMatchObject({ code: 'NOT_IN_WORKSPACE' });
|
|
1025
|
+
});
|
|
1026
|
+
});
|
|
1027
|
+
describe('useCreativeLibrary().pick (ADR-037 D8)', () => {
|
|
1028
|
+
const PICKED = {
|
|
1029
|
+
item: { itemId: 'vci_' + 'a'.repeat(32), kind: 'style', name: 'Ink', version: 2 },
|
|
1030
|
+
};
|
|
1031
|
+
it('asks the host’s picker for a kind, waiting on the person', async () => {
|
|
1032
|
+
const { useCreativeLibrary } = require('./hooks');
|
|
1033
|
+
mockBridge.request.mockResolvedValue(PICKED);
|
|
1034
|
+
await expect(useCreativeLibrary().pick({ kind: 'style' })).resolves.toEqual(PICKED);
|
|
1035
|
+
const [op, payload, timeout] = mockBridge.request.mock.calls[0];
|
|
1036
|
+
expect(op).toBe('client_entity_invoke');
|
|
1037
|
+
expect(payload).toEqual({ entityId: entity_ops_1.CREATIVE_PICKER_ENTITY_ID, input: { kind: 'style' } });
|
|
1038
|
+
expect(timeout).toBeGreaterThanOrEqual(5 * 60 * 1000);
|
|
1039
|
+
});
|
|
1040
|
+
it('sends nothing when no kind is asked, and passes a closed sheet through', async () => {
|
|
1041
|
+
const { useCreativeLibrary } = require('./hooks');
|
|
1042
|
+
mockBridge.request.mockResolvedValue({ canceled: true });
|
|
1043
|
+
await expect(useCreativeLibrary().pick()).resolves.toEqual({ canceled: true });
|
|
1044
|
+
expect(mockBridge.request.mock.calls[0][1]).toEqual({
|
|
1045
|
+
entityId: entity_ops_1.CREATIVE_PICKER_ENTITY_ID,
|
|
1046
|
+
input: {},
|
|
1047
|
+
});
|
|
1048
|
+
});
|
|
1049
|
+
it('turns a refusal result into a typed error', async () => {
|
|
1050
|
+
const { useCreativeLibrary } = require('./hooks');
|
|
1051
|
+
mockBridge.request.mockResolvedValue({
|
|
1052
|
+
error: { code: 'SHEET_BUSY', message: 'Another request is waiting' },
|
|
1053
|
+
});
|
|
1054
|
+
await expect(useCreativeLibrary().pick()).rejects.toMatchObject({ code: 'SHEET_BUSY' });
|
|
1055
|
+
});
|
|
1056
|
+
it('a host with no picker answers PICKER_UNAVAILABLE', async () => {
|
|
1057
|
+
const { useCreativeLibrary } = require('./hooks');
|
|
1058
|
+
const { FiasBridgeError } = jest.requireActual('./bridge');
|
|
1059
|
+
mockBridge.request.mockRejectedValue(new FiasBridgeError('no client-side executor registered for x', 'BRIDGE_ERROR'));
|
|
1060
|
+
await expect(useCreativeLibrary().pick()).rejects.toMatchObject({
|
|
1061
|
+
code: 'PICKER_UNAVAILABLE',
|
|
1062
|
+
});
|
|
1063
|
+
});
|
|
1064
|
+
});
|
|
1065
|
+
describe('useOpener (ADR-037 D8)', () => {
|
|
1066
|
+
const FILE = 'doc_' + 'a'.repeat(32);
|
|
1067
|
+
const opener = { kind: 'forge', name: 'My Arche' };
|
|
1068
|
+
const CHROME = {
|
|
1069
|
+
focusMode: false,
|
|
1070
|
+
focusModeAvailable: false,
|
|
1071
|
+
sidebarAvailable: false,
|
|
1072
|
+
sidebarCollapsed: false,
|
|
1073
|
+
hostExpandedBar: true,
|
|
1074
|
+
hostExpandButton: true,
|
|
1075
|
+
opener,
|
|
1076
|
+
};
|
|
1077
|
+
it('names where the person came from, without taking the chrome over', () => {
|
|
1078
|
+
mockBridge.getChrome.mockReturnValue(CHROME);
|
|
1079
|
+
const { useOpener } = require('./hooks');
|
|
1080
|
+
expect(useOpener().opener).toEqual(opener);
|
|
1081
|
+
expect(mockBridge.setChrome).not.toHaveBeenCalled();
|
|
1082
|
+
});
|
|
1083
|
+
it('returns there only when there is an opener', () => {
|
|
1084
|
+
const { useOpener } = require('./hooks');
|
|
1085
|
+
useOpener().returnTo();
|
|
1086
|
+
expect(mockBridge.navigateHost).not.toHaveBeenCalled();
|
|
1087
|
+
mockBridge.getChrome.mockReturnValue(CHROME);
|
|
1088
|
+
useOpener().returnTo();
|
|
1089
|
+
expect(mockBridge.navigateHost).toHaveBeenCalledWith('opener');
|
|
1090
|
+
});
|
|
1091
|
+
it('offers a file through the host’s sheet, waiting on the person', async () => {
|
|
1092
|
+
mockBridge.getChrome.mockReturnValue(CHROME);
|
|
1093
|
+
mockBridge.request.mockResolvedValue({ sent: true, name: 'pumpkin.png' });
|
|
1094
|
+
const { useOpener } = require('./hooks');
|
|
1095
|
+
await expect(useOpener().send({ fileId: FILE, name: 'pumpkin.png' })).resolves.toEqual({
|
|
1096
|
+
sent: true,
|
|
1097
|
+
name: 'pumpkin.png',
|
|
1098
|
+
});
|
|
1099
|
+
const [op, payload, timeout] = mockBridge.request.mock.calls[0];
|
|
1100
|
+
expect(op).toBe('client_entity_invoke');
|
|
1101
|
+
expect(payload).toEqual({
|
|
1102
|
+
entityId: entity_ops_1.SEND_TO_OPENER_ENTITY_ID,
|
|
1103
|
+
input: { fileId: FILE, name: 'pumpkin.png' },
|
|
1104
|
+
});
|
|
1105
|
+
expect(timeout).toBeGreaterThanOrEqual(5 * 60 * 1000);
|
|
1106
|
+
});
|
|
1107
|
+
it('refuses with NO_OPENER when nothing opened this arche, asking nothing', async () => {
|
|
1108
|
+
const { useOpener } = require('./hooks');
|
|
1109
|
+
const api = useOpener();
|
|
1110
|
+
expect(api.opener).toBeNull();
|
|
1111
|
+
await expect(api.send({ fileId: FILE })).rejects.toMatchObject({ code: 'NO_OPENER' });
|
|
1112
|
+
expect(mockBridge.request).not.toHaveBeenCalled();
|
|
1113
|
+
});
|
|
1114
|
+
it('turns a refusal into a typed error, and a host without it into SEND_UNAVAILABLE', async () => {
|
|
1115
|
+
mockBridge.getChrome.mockReturnValue(CHROME);
|
|
1116
|
+
const { useOpener } = require('./hooks');
|
|
1117
|
+
mockBridge.request.mockResolvedValueOnce({
|
|
1118
|
+
error: { code: 'SHEET_BUSY', message: 'Another request is waiting' },
|
|
1119
|
+
});
|
|
1120
|
+
await expect(useOpener().send({ fileId: FILE })).rejects.toMatchObject({
|
|
1121
|
+
code: 'SHEET_BUSY',
|
|
1122
|
+
});
|
|
1123
|
+
const { FiasBridgeError } = jest.requireActual('./bridge');
|
|
1124
|
+
mockBridge.request.mockRejectedValueOnce(new FiasBridgeError('no client-side executor registered for x', 'BRIDGE_ERROR'));
|
|
1125
|
+
await expect(useOpener().send({ fileId: FILE })).rejects.toMatchObject({
|
|
1126
|
+
code: 'SEND_UNAVAILABLE',
|
|
1127
|
+
});
|
|
1128
|
+
});
|
|
1129
|
+
});
|
|
791
1130
|
describe('useFiasConnections', () => {
|
|
792
1131
|
it('lists through the connections entity', async () => {
|
|
793
1132
|
const { useFiasConnections } = require('./hooks');
|
|
@@ -1212,6 +1551,7 @@ describe('SDK hooks', () => {
|
|
|
1212
1551
|
__esModule: true,
|
|
1213
1552
|
getBridge: () => mockSingletonBridge,
|
|
1214
1553
|
FiasBridge: jest.fn(),
|
|
1554
|
+
FiasBridgeError: jest.requireActual('./bridge').FiasBridgeError,
|
|
1215
1555
|
resetBridge: jest.fn(),
|
|
1216
1556
|
}));
|
|
1217
1557
|
jest.doMock('react', () => require('react'));
|
|
@@ -1337,6 +1677,136 @@ describe('SDK hooks', () => {
|
|
|
1337
1677
|
expect(second.url).toBe('https://example.com/new');
|
|
1338
1678
|
expect(mockBridge.request).toHaveBeenCalledTimes(2);
|
|
1339
1679
|
});
|
|
1680
|
+
it('a changed access answer forgets cached download URLs (ADR-034)', async () => {
|
|
1681
|
+
const { useVaultDocuments, useFiasAccess } = freshHooks();
|
|
1682
|
+
const api = useVaultDocuments();
|
|
1683
|
+
mockBridge.request.mockResolvedValueOnce({
|
|
1684
|
+
url: 'https://example.com/minted-under-old-access',
|
|
1685
|
+
expiresAt: Date.now() + 10 * 60000,
|
|
1686
|
+
});
|
|
1687
|
+
await api.getDownloadUrl('file_c');
|
|
1688
|
+
const answer = (permissions) => ({
|
|
1689
|
+
output: {
|
|
1690
|
+
access: {
|
|
1691
|
+
mode: 'roles',
|
|
1692
|
+
workspaceId: 'ws_1',
|
|
1693
|
+
tier: 'member',
|
|
1694
|
+
expiresAt: null,
|
|
1695
|
+
roles: [],
|
|
1696
|
+
permissions,
|
|
1697
|
+
},
|
|
1698
|
+
},
|
|
1699
|
+
});
|
|
1700
|
+
const settle = () => new Promise((r) => setTimeout(r, 0));
|
|
1701
|
+
mockBridge.request.mockResolvedValueOnce(answer(['ledger.read']));
|
|
1702
|
+
useFiasAccess('ws_1');
|
|
1703
|
+
await settle();
|
|
1704
|
+
// The same answer again keeps the cache…
|
|
1705
|
+
mockBridge.request.mockResolvedValueOnce(answer(['ledger.read']));
|
|
1706
|
+
const subscriptions = mockBridge.onAccessUpdate.mock.calls;
|
|
1707
|
+
const listener = subscriptions[subscriptions.length - 1][0];
|
|
1708
|
+
listener();
|
|
1709
|
+
await settle();
|
|
1710
|
+
mockBridge.request.mockClear();
|
|
1711
|
+
await api.getDownloadUrl('file_c');
|
|
1712
|
+
expect(mockBridge.request).not.toHaveBeenCalled();
|
|
1713
|
+
// …a different one does not.
|
|
1714
|
+
mockBridge.request.mockResolvedValueOnce(answer([]));
|
|
1715
|
+
listener();
|
|
1716
|
+
await settle();
|
|
1717
|
+
mockBridge.request.mockClear();
|
|
1718
|
+
mockBridge.request.mockResolvedValueOnce({
|
|
1719
|
+
url: 'https://example.com/fresh',
|
|
1720
|
+
expiresAt: Date.now() + 10 * 60000,
|
|
1721
|
+
});
|
|
1722
|
+
await expect(api.getDownloadUrl('file_c')).resolves.toMatchObject({
|
|
1723
|
+
url: 'https://example.com/fresh',
|
|
1724
|
+
});
|
|
1725
|
+
});
|
|
1726
|
+
it('losing the workspace (NOT_A_MEMBER) forgets cached download URLs too (ADR-034)', async () => {
|
|
1727
|
+
const { useVaultDocuments, useFiasAccess } = freshHooks();
|
|
1728
|
+
const { FiasBridgeError } = jest.requireActual('./bridge');
|
|
1729
|
+
const api = useVaultDocuments();
|
|
1730
|
+
mockBridge.request.mockResolvedValueOnce({
|
|
1731
|
+
url: 'https://example.com/old',
|
|
1732
|
+
expiresAt: Date.now() + 10 * 60000,
|
|
1733
|
+
});
|
|
1734
|
+
await api.getDownloadUrl('file_d');
|
|
1735
|
+
const settle = () => new Promise((r) => setTimeout(r, 0));
|
|
1736
|
+
mockBridge.request.mockResolvedValueOnce({
|
|
1737
|
+
output: { access: { mode: 'tier', workspaceId: 'ws_1', tier: 'member', expiresAt: null } },
|
|
1738
|
+
});
|
|
1739
|
+
useFiasAccess('ws_1');
|
|
1740
|
+
await settle();
|
|
1741
|
+
mockBridge.request.mockRejectedValueOnce(new FiasBridgeError('gone', 'NOT_A_MEMBER'));
|
|
1742
|
+
const subscriptions = mockBridge.onAccessUpdate.mock.calls;
|
|
1743
|
+
subscriptions[subscriptions.length - 1][0]();
|
|
1744
|
+
await settle();
|
|
1745
|
+
mockBridge.request.mockClear();
|
|
1746
|
+
mockBridge.request.mockResolvedValueOnce({
|
|
1747
|
+
url: 'https://example.com/new',
|
|
1748
|
+
expiresAt: Date.now() + 10 * 60000,
|
|
1749
|
+
});
|
|
1750
|
+
await expect(api.getDownloadUrl('file_d')).resolves.toMatchObject({
|
|
1751
|
+
url: 'https://example.com/new',
|
|
1752
|
+
});
|
|
1753
|
+
});
|
|
1754
|
+
it('a remounted access hook still forgets the caches — on no access, and on a changed answer', async () => {
|
|
1755
|
+
const { useVaultDocuments, useFiasAccess } = freshHooks();
|
|
1756
|
+
const { FiasBridgeError } = jest.requireActual('./bridge');
|
|
1757
|
+
const api = useVaultDocuments();
|
|
1758
|
+
const settle = () => new Promise((r) => setTimeout(r, 0));
|
|
1759
|
+
const cacheUrl = async (documentId, url) => {
|
|
1760
|
+
mockBridge.request.mockResolvedValueOnce({ url, expiresAt: Date.now() + 600000 });
|
|
1761
|
+
await api.getDownloadUrl(documentId);
|
|
1762
|
+
};
|
|
1763
|
+
const fresh = async (documentId, url) => {
|
|
1764
|
+
mockBridge.request.mockClear();
|
|
1765
|
+
mockBridge.request.mockResolvedValueOnce({ url, expiresAt: Date.now() + 600000 });
|
|
1766
|
+
await expect(api.getDownloadUrl(documentId)).resolves.toMatchObject({ url });
|
|
1767
|
+
};
|
|
1768
|
+
// A brand-new hook whose FIRST answer is "no access" (the old one unmounted).
|
|
1769
|
+
await cacheUrl('file_f', 'https://example.com/old');
|
|
1770
|
+
mockBridge.request.mockRejectedValueOnce(new FiasBridgeError('gone', 'NOT_A_MEMBER'));
|
|
1771
|
+
useFiasAccess('ws_9');
|
|
1772
|
+
await settle();
|
|
1773
|
+
await fresh('file_f', 'https://example.com/new');
|
|
1774
|
+
// One hook sees an answer; a later, new hook sees a different one.
|
|
1775
|
+
const answer = (tier) => ({
|
|
1776
|
+
output: { access: { mode: 'tier', workspaceId: 'ws_8', tier, expiresAt: null } },
|
|
1777
|
+
});
|
|
1778
|
+
mockBridge.request.mockResolvedValueOnce(answer('admin'));
|
|
1779
|
+
useFiasAccess('ws_8');
|
|
1780
|
+
await settle();
|
|
1781
|
+
await cacheUrl('file_g', 'https://example.com/old-g');
|
|
1782
|
+
// The first hook unmounts (instances of one workspace share an answer
|
|
1783
|
+
// while any is mounted), then a new one mounts.
|
|
1784
|
+
capturedEffectCleanup?.();
|
|
1785
|
+
mockBridge.request.mockResolvedValueOnce(answer('viewer'));
|
|
1786
|
+
useFiasAccess('ws_8');
|
|
1787
|
+
await settle();
|
|
1788
|
+
await fresh('file_g', 'https://example.com/new-g');
|
|
1789
|
+
});
|
|
1790
|
+
it('a fetch already in flight when the caches are forgotten does not refill them', async () => {
|
|
1791
|
+
const hooks = freshHooks();
|
|
1792
|
+
const api = hooks.useVaultDocuments();
|
|
1793
|
+
let answer;
|
|
1794
|
+
mockBridge.request.mockImplementationOnce(() => new Promise((r) => (answer = r)));
|
|
1795
|
+
const pending = api.getDownloadUrl('file_e');
|
|
1796
|
+
hooks.clearSdkReadCaches();
|
|
1797
|
+
answer({
|
|
1798
|
+
url: 'https://example.com/minted-before',
|
|
1799
|
+
expiresAt: new Date(Date.now() + 600000).toISOString(),
|
|
1800
|
+
});
|
|
1801
|
+
await pending;
|
|
1802
|
+
mockBridge.request.mockResolvedValueOnce({
|
|
1803
|
+
url: 'https://example.com/after',
|
|
1804
|
+
expiresAt: new Date(Date.now() + 600000).toISOString(),
|
|
1805
|
+
});
|
|
1806
|
+
await expect(api.getDownloadUrl('file_e')).resolves.toMatchObject({
|
|
1807
|
+
url: 'https://example.com/after',
|
|
1808
|
+
});
|
|
1809
|
+
});
|
|
1340
1810
|
it('getDownloadUrl dedups concurrent in-flight calls', async () => {
|
|
1341
1811
|
const { useVaultDocuments } = freshHooks();
|
|
1342
1812
|
const api = useVaultDocuments();
|
|
@@ -1698,6 +2168,47 @@ describe('SDK hooks', () => {
|
|
|
1698
2168
|
data: { score: 100 },
|
|
1699
2169
|
}));
|
|
1700
2170
|
});
|
|
2171
|
+
it('put with a null key creates: no key is sent, the audience is, and the server’s key comes back (ADR-034 phase 2)', async () => {
|
|
2172
|
+
const { useFiasDataStore } = require('./hooks');
|
|
2173
|
+
const ds = useFiasDataStore();
|
|
2174
|
+
mockBridge.request.mockResolvedValueOnce(output({ key: 'rec_0123456789abcdef0123456789abcdef', version: 1, audience: 'board' }));
|
|
2175
|
+
const result = await ds.put('papers', null, { t: 1 }, { workspaceId: 'ws_1', audience: 'board' });
|
|
2176
|
+
expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID, 'put', {
|
|
2177
|
+
collection: 'papers',
|
|
2178
|
+
data: { t: 1 },
|
|
2179
|
+
workspaceId: 'ws_1',
|
|
2180
|
+
audience: 'board',
|
|
2181
|
+
}));
|
|
2182
|
+
expect(result).toEqual({
|
|
2183
|
+
key: 'rec_0123456789abcdef0123456789abcdef',
|
|
2184
|
+
version: 1,
|
|
2185
|
+
audience: 'board',
|
|
2186
|
+
});
|
|
2187
|
+
});
|
|
2188
|
+
it('create is a keyless put; a host that predates server keys is refused, never guessed at', async () => {
|
|
2189
|
+
const { useFiasDataStore } = require('./hooks');
|
|
2190
|
+
const ds = useFiasDataStore();
|
|
2191
|
+
mockBridge.request.mockResolvedValueOnce(output({ key: 'rec_1', version: 1, audience: 'staff' }));
|
|
2192
|
+
await expect(ds.create('papers', { t: 1 }, { workspaceId: 'ws_1', audience: 'staff' })).resolves.toMatchObject({ key: 'rec_1', audience: 'staff' });
|
|
2193
|
+
mockBridge.request.mockResolvedValueOnce(output({ version: 1 }));
|
|
2194
|
+
await expect(ds.create('papers', { t: 1 }, { workspaceId: 'ws_1', audience: 'staff' })).rejects.toMatchObject({ code: 'AUDIENCES_UNAVAILABLE' });
|
|
2195
|
+
});
|
|
2196
|
+
it('get, query and search forward an audience only when one is named', async () => {
|
|
2197
|
+
const { useFiasDataStore } = require('./hooks');
|
|
2198
|
+
const ds = useFiasDataStore();
|
|
2199
|
+
mockBridge.request.mockResolvedValue(output({ data: null, documents: [], matches: [] }));
|
|
2200
|
+
await ds.get('papers', 'k', { workspaceId: 'ws_1', audience: 'staff' });
|
|
2201
|
+
await ds.query('papers', {}, { workspaceId: 'ws_1', audience: 'staff' });
|
|
2202
|
+
await ds.search('papers', 'q', {}, { workspaceId: 'ws_1', audience: 'staff' });
|
|
2203
|
+
await ds.get('papers', 'k', { workspaceId: 'ws_1' });
|
|
2204
|
+
const payloads = mockBridge.request.mock.calls.map((c) => c[1].payload);
|
|
2205
|
+
expect(payloads.slice(0, 3).map((p) => p.audience)).toEqual([
|
|
2206
|
+
'staff',
|
|
2207
|
+
'staff',
|
|
2208
|
+
'staff',
|
|
2209
|
+
]);
|
|
2210
|
+
expect(payloads[3]).not.toHaveProperty('audience');
|
|
2211
|
+
});
|
|
1701
2212
|
it('get unwraps { data } from the bridge envelope', async () => {
|
|
1702
2213
|
const { useFiasDataStore } = require('./hooks');
|
|
1703
2214
|
const ds = useFiasDataStore();
|
|
@@ -2474,6 +2985,19 @@ describe('useDataSubscription (ADR-012 Phase M4)', () => {
|
|
|
2474
2985
|
expect(onInvalidate).toHaveBeenCalledTimes(1);
|
|
2475
2986
|
expect(mockSetState).toHaveBeenCalledWith({ status: 'active' });
|
|
2476
2987
|
});
|
|
2988
|
+
it('names the audience to watch, where a collection has audiences (ADR-034 phase 2)', async () => {
|
|
2989
|
+
subscribed();
|
|
2990
|
+
const { useDataSubscription } = require('./hooks');
|
|
2991
|
+
useDataSubscription('papers', jest.fn().mockResolvedValue(undefined), {
|
|
2992
|
+
workspaceId: 'ws_1',
|
|
2993
|
+
audience: 'board',
|
|
2994
|
+
});
|
|
2995
|
+
await flush();
|
|
2996
|
+
expect(mockBridge.request).toHaveBeenCalledWith('entity_invoke', {
|
|
2997
|
+
entityId: 'ent_intg_plugin_datastore_000000',
|
|
2998
|
+
payload: { op: 'subscribe', collection: 'papers', workspaceId: 'ws_1', audience: 'board' },
|
|
2999
|
+
});
|
|
3000
|
+
});
|
|
2477
3001
|
it('runs onInvalidate single-flight with exactly one trailing call', async () => {
|
|
2478
3002
|
subscribed();
|
|
2479
3003
|
let resolveFirst = () => undefined;
|
|
@@ -2521,6 +3045,95 @@ describe('useDataSubscription (ADR-012 Phase M4)', () => {
|
|
|
2521
3045
|
expect(onInvalidate).not.toHaveBeenCalled();
|
|
2522
3046
|
});
|
|
2523
3047
|
});
|
|
3048
|
+
describe('useEntitySubscription (ADR-012 Phase M4 — any subscribe-kind verb)', () => {
|
|
3049
|
+
const flush = async () => {
|
|
3050
|
+
await Promise.resolve();
|
|
3051
|
+
await Promise.resolve();
|
|
3052
|
+
await Promise.resolve();
|
|
3053
|
+
};
|
|
3054
|
+
let mockBridge;
|
|
3055
|
+
beforeEach(() => {
|
|
3056
|
+
mockBridge = createMockBridge();
|
|
3057
|
+
mockContextValue = mockBridge;
|
|
3058
|
+
mockSingletonBridge = mockBridge;
|
|
3059
|
+
});
|
|
3060
|
+
it('forwards the entity id and the verb payload verbatim, then refetches once live', async () => {
|
|
3061
|
+
mockBridge.request.mockResolvedValue({
|
|
3062
|
+
output: { kind: 'subscription', subscriptionId: 'lsub_chat' },
|
|
3063
|
+
});
|
|
3064
|
+
const onInvalidate = jest.fn().mockResolvedValue(undefined);
|
|
3065
|
+
const { useEntitySubscription } = require('./hooks');
|
|
3066
|
+
useEntitySubscription('ent_intg_litcraft_player_0000000', { op: 'subscribe_guild_chat' }, onInvalidate);
|
|
3067
|
+
await flush();
|
|
3068
|
+
expect(mockBridge.request).toHaveBeenCalledWith('entity_invoke', {
|
|
3069
|
+
entityId: 'ent_intg_litcraft_player_0000000',
|
|
3070
|
+
payload: { op: 'subscribe_guild_chat' },
|
|
3071
|
+
});
|
|
3072
|
+
expect(mockBridge.onSubscriptionEvent).toHaveBeenCalledWith('lsub_chat', expect.any(Function));
|
|
3073
|
+
expect(onInvalidate).toHaveBeenCalledTimes(1);
|
|
3074
|
+
expect(mockSetState).toHaveBeenCalledWith({ status: 'active' });
|
|
3075
|
+
});
|
|
3076
|
+
it('enabled: false subscribes to nothing and reports unsubscribed', async () => {
|
|
3077
|
+
const onInvalidate = jest.fn();
|
|
3078
|
+
const { useEntitySubscription } = require('./hooks');
|
|
3079
|
+
useEntitySubscription('ent_x', { op: 'subscribe_x' }, onInvalidate, { enabled: false });
|
|
3080
|
+
await flush();
|
|
3081
|
+
expect(mockBridge.request).not.toHaveBeenCalled();
|
|
3082
|
+
expect(onInvalidate).not.toHaveBeenCalled();
|
|
3083
|
+
expect(mockSetState).toHaveBeenCalledWith({ status: 'ended', endedReason: 'unsubscribed' });
|
|
3084
|
+
});
|
|
3085
|
+
it('sends the payload exactly as given (key order is identity-neutral, not rewritten)', async () => {
|
|
3086
|
+
mockBridge.request.mockResolvedValue({
|
|
3087
|
+
output: { kind: 'subscription', subscriptionId: 'lsub_1' },
|
|
3088
|
+
});
|
|
3089
|
+
const { useEntitySubscription } = require('./hooks');
|
|
3090
|
+
useEntitySubscription('ent_x', { workspaceId: 'ws_1', op: 'subscribe_x' }, jest.fn());
|
|
3091
|
+
await flush();
|
|
3092
|
+
expect(mockBridge.request).toHaveBeenCalledWith('entity_invoke', {
|
|
3093
|
+
entityId: 'ent_x',
|
|
3094
|
+
payload: { op: 'subscribe_x', workspaceId: 'ws_1' },
|
|
3095
|
+
});
|
|
3096
|
+
});
|
|
3097
|
+
it('minRefetchIntervalMs caps refetches to one per interval with one trailing run', async () => {
|
|
3098
|
+
jest.useFakeTimers();
|
|
3099
|
+
try {
|
|
3100
|
+
mockBridge.request.mockResolvedValue({
|
|
3101
|
+
output: { kind: 'subscription', subscriptionId: 'lsub_1' },
|
|
3102
|
+
});
|
|
3103
|
+
const onInvalidate = jest.fn().mockResolvedValue(undefined);
|
|
3104
|
+
const { useEntitySubscription } = require('./hooks');
|
|
3105
|
+
useEntitySubscription('ent_x', { op: 'subscribe_x' }, onInvalidate, {
|
|
3106
|
+
minRefetchIntervalMs: 2000,
|
|
3107
|
+
});
|
|
3108
|
+
await flush();
|
|
3109
|
+
expect(onInvalidate).toHaveBeenCalledTimes(1); // the subscribe-then-fetch run
|
|
3110
|
+
const eventCb = mockBridge.onSubscriptionEvent.mock.calls[0][1];
|
|
3111
|
+
eventCb();
|
|
3112
|
+
eventCb();
|
|
3113
|
+
eventCb();
|
|
3114
|
+
await flush();
|
|
3115
|
+
expect(onInvalidate).toHaveBeenCalledTimes(1); // inside the interval: deferred, not dropped
|
|
3116
|
+
await jest.advanceTimersByTimeAsync(2000);
|
|
3117
|
+
expect(onInvalidate).toHaveBeenCalledTimes(2); // exactly one trailing run at the interval's end
|
|
3118
|
+
capturedEffectCleanup?.();
|
|
3119
|
+
}
|
|
3120
|
+
finally {
|
|
3121
|
+
jest.useRealTimers();
|
|
3122
|
+
}
|
|
3123
|
+
});
|
|
3124
|
+
it('a rejected handshake ends with transport_error and never refetches', async () => {
|
|
3125
|
+
mockBridge.request.mockRejectedValue(new Error('NOT_A_MEMBER'));
|
|
3126
|
+
const onInvalidate = jest.fn();
|
|
3127
|
+
const { useEntitySubscription } = require('./hooks');
|
|
3128
|
+
useEntitySubscription('ent_x', { op: 'subscribe_x' }, onInvalidate);
|
|
3129
|
+
await flush();
|
|
3130
|
+
expect(mockSetState).toHaveBeenCalledWith({
|
|
3131
|
+
status: 'ended',
|
|
3132
|
+
endedReason: 'transport_error',
|
|
3133
|
+
});
|
|
3134
|
+
expect(onInvalidate).not.toHaveBeenCalled();
|
|
3135
|
+
});
|
|
3136
|
+
});
|
|
2524
3137
|
describe('useFiasAIActions', () => {
|
|
2525
3138
|
let mockBridge;
|
|
2526
3139
|
beforeEach(() => {
|