@equinor/fusion-framework-react-app 14.0.0 → 15.0.0-next.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/CHANGELOG.md +69 -0
- package/README.md +9 -2
- package/dist/esm/create-component.js +1 -1
- package/dist/esm/msal/useToken.js +12 -3
- package/dist/esm/msal/useToken.js.map +1 -1
- package/dist/esm/version.js +1 -1
- package/dist/esm/version.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/create-component.d.ts +1 -1
- package/dist/types/version.d.ts +1 -1
- package/docs/bookmark.md +7 -0
- package/docs/context.md +3 -1
- package/docs/framework.md +2 -2
- package/docs/msal.md +3 -3
- package/package.json +33 -25
- package/src/__tests__/Apploader.test.tsx +51 -0
- package/src/__tests__/fixtures/apploader-child-script.ts +9 -0
- package/src/__tests__/testApp.test.tsx +47 -0
- package/src/__tests__/useAccessToken.test.tsx +51 -0
- package/src/__tests__/useAppSetting.test.tsx +133 -0
- package/src/__tests__/useAppSettings.test.tsx +147 -0
- package/src/__tests__/useCurrentAccount.test.tsx +32 -0
- package/src/__tests__/useCurrentBookmark.test.tsx +108 -0
- package/src/__tests__/useCurrentContext.test.tsx +72 -0
- package/src/__tests__/useFeature.test.tsx +104 -0
- package/src/__tests__/useHelpCenter.test.tsx +64 -0
- package/src/__tests__/useStateSyncEvents.test.ts +12 -11
- package/src/__tests__/useToken.test.tsx +71 -0
- package/src/__tests__/useTrackFeature.test.tsx +83 -0
- package/src/create-component.tsx +1 -1
- package/src/msal/useToken.ts +14 -3
- package/src/version.ts +1 -1
- package/tsconfig.json +3 -0
- package/vitest.config.ts +2 -8
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { mockFramework } from '@equinor/fusion-framework/mock';
|
|
4
|
+
import { enableAppManifestMock } from '@equinor/fusion-framework-app/mock';
|
|
5
|
+
import type { AppMockConfigureFn } from '@equinor/fusion-framework-app/mock';
|
|
6
|
+
import type { AppModule } from '@equinor/fusion-framework-module-app';
|
|
7
|
+
import { enableBookmarkMock } from '@equinor/fusion-framework-module-bookmark/mock';
|
|
8
|
+
import type { Bookmark, BookmarkModule } from '@equinor/fusion-framework-module-bookmark';
|
|
9
|
+
|
|
10
|
+
import { useCurrentBookmark } from '../bookmark/useCurrentBookmark';
|
|
11
|
+
import { renderAppHook } from '@equinor/fusion-framework-vitest-plugin-react-app/test';
|
|
12
|
+
import { useAppModules } from '../useAppModules';
|
|
13
|
+
|
|
14
|
+
const env = {
|
|
15
|
+
manifest: {
|
|
16
|
+
appKey: 'test-app',
|
|
17
|
+
displayName: 'Test App',
|
|
18
|
+
description: 'A test application',
|
|
19
|
+
type: 'standalone' as const,
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** A fully-formed seeded bookmark, so tests only override what they care about. */
|
|
24
|
+
const createBookmark = (overrides: Partial<Bookmark> = {}): Bookmark => ({
|
|
25
|
+
id: 'bookmark-1',
|
|
26
|
+
name: 'My Bookmark',
|
|
27
|
+
appKey: 'test-app',
|
|
28
|
+
created: new Date('2024-01-01T00:00:00.000Z'),
|
|
29
|
+
createdBy: { id: 'seed-user', name: 'Seed User' },
|
|
30
|
+
...overrides,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('useCurrentBookmark', () => {
|
|
34
|
+
it('returns the app-scoped current bookmark once it belongs to the active app', async () => {
|
|
35
|
+
const bookmark = createBookmark();
|
|
36
|
+
const fusion = await mockFramework<[AppModule]>((configurator) =>
|
|
37
|
+
enableAppManifestMock(configurator, env),
|
|
38
|
+
);
|
|
39
|
+
fusion.modules.app.setCurrentApp(env.manifest.appKey);
|
|
40
|
+
|
|
41
|
+
const configure: AppMockConfigureFn<[BookmarkModule]> = (configurator) =>
|
|
42
|
+
enableBookmarkMock(configurator, (builder) => {
|
|
43
|
+
builder.setBookmarks([bookmark]);
|
|
44
|
+
builder.setCurrentBookmark(bookmark.id);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const { result } = await renderAppHook(() => useCurrentBookmark(), { env, fusion, configure });
|
|
48
|
+
|
|
49
|
+
await vi.waitFor(() =>
|
|
50
|
+
expect(result.current.currentBookmark).toMatchObject({ id: bookmark.id }),
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('hides the current bookmark once it belongs to a different app', async () => {
|
|
55
|
+
const bookmark = createBookmark({ appKey: 'some-other-app' });
|
|
56
|
+
const fusion = await mockFramework<[AppModule]>((configurator) =>
|
|
57
|
+
enableAppManifestMock(configurator, env),
|
|
58
|
+
);
|
|
59
|
+
fusion.modules.app.setCurrentApp(env.manifest.appKey);
|
|
60
|
+
|
|
61
|
+
const configure: AppMockConfigureFn<[BookmarkModule]> = (configurator) =>
|
|
62
|
+
enableBookmarkMock(configurator, (builder) => {
|
|
63
|
+
builder.setBookmarks([bookmark]);
|
|
64
|
+
builder.setCurrentBookmark(bookmark.id);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const { result } = await renderAppHook(
|
|
68
|
+
() => ({
|
|
69
|
+
bookmark: useCurrentBookmark(),
|
|
70
|
+
// unfiltered provider state, so we can tell the seeded bookmark actually
|
|
71
|
+
// resolved rather than the filter trivially matching a still-pending value
|
|
72
|
+
provider: useAppModules<[BookmarkModule]>().bookmark,
|
|
73
|
+
}),
|
|
74
|
+
{ env, fusion, configure },
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
await vi.waitFor(() =>
|
|
78
|
+
expect(result.current.provider.currentBookmark).toMatchObject({ id: bookmark.id }),
|
|
79
|
+
);
|
|
80
|
+
expect(result.current.bookmark.currentBookmark).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('falls back to the framework-scoped bookmark provider, warning about the deprecation', async () => {
|
|
84
|
+
const bookmark = createBookmark();
|
|
85
|
+
const fusion = await mockFramework<[AppModule, BookmarkModule]>((configurator) => {
|
|
86
|
+
enableAppManifestMock(configurator, env);
|
|
87
|
+
enableBookmarkMock(configurator, (builder) => {
|
|
88
|
+
builder.setBookmarks([bookmark]);
|
|
89
|
+
builder.setCurrentBookmark(bookmark.id);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
fusion.modules.app.setCurrentApp(env.manifest.appKey);
|
|
93
|
+
|
|
94
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
95
|
+
|
|
96
|
+
const { result } = await renderAppHook(() => useCurrentBookmark(), { env, fusion });
|
|
97
|
+
|
|
98
|
+
await vi.waitFor(() =>
|
|
99
|
+
expect(result.current.currentBookmark).toMatchObject({ id: bookmark.id }),
|
|
100
|
+
);
|
|
101
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
102
|
+
'@deprecation',
|
|
103
|
+
expect.stringContaining('has not enabled bookmarks'),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
warnSpy.mockRestore();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { act } from 'react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { mockFramework } from '@equinor/fusion-framework/mock';
|
|
5
|
+
import { enableAppManifestMock } from '@equinor/fusion-framework-app/mock';
|
|
6
|
+
import type { AppMockConfigureFn } from '@equinor/fusion-framework-app/mock';
|
|
7
|
+
import type { AppModule } from '@equinor/fusion-framework-module-app';
|
|
8
|
+
import { enableContextMock } from '@equinor/fusion-framework-module-context/mock';
|
|
9
|
+
import type { ContextItem, ContextModule } from '@equinor/fusion-framework-module-context';
|
|
10
|
+
|
|
11
|
+
import { useCurrentContext } from '../context/useCurrentContext';
|
|
12
|
+
import { renderAppHook } from '@equinor/fusion-framework-vitest-plugin-react-app/test';
|
|
13
|
+
|
|
14
|
+
const env = {
|
|
15
|
+
manifest: {
|
|
16
|
+
appKey: 'test-app',
|
|
17
|
+
displayName: 'Test App',
|
|
18
|
+
description: 'A test application',
|
|
19
|
+
type: 'standalone' as const,
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const project: ContextItem = {
|
|
24
|
+
id: 'ctx-1',
|
|
25
|
+
title: 'My project',
|
|
26
|
+
type: { id: 'ProjectMaster' },
|
|
27
|
+
value: {},
|
|
28
|
+
};
|
|
29
|
+
const facility: ContextItem = {
|
|
30
|
+
id: 'ctx-2',
|
|
31
|
+
title: 'My facility',
|
|
32
|
+
type: { id: 'Facility' },
|
|
33
|
+
value: {},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
describe('useCurrentContext', () => {
|
|
37
|
+
it('resolves the context item selected on startup from the app-scoped context module', async () => {
|
|
38
|
+
const fusion = await mockFramework<[AppModule]>((configurator) =>
|
|
39
|
+
enableAppManifestMock(configurator, env),
|
|
40
|
+
);
|
|
41
|
+
const configure: AppMockConfigureFn<[ContextModule]> = (configurator) =>
|
|
42
|
+
enableContextMock(configurator, (mock) => {
|
|
43
|
+
mock.setCurrentContext(project);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const { result } = await renderAppHook(() => useCurrentContext(), { env, fusion, configure });
|
|
47
|
+
|
|
48
|
+
await vi.waitFor(() => expect(result.current.currentContext).toMatchObject({ id: project.id }));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('switches the current context when setCurrentContext is called with another seeded item', async () => {
|
|
52
|
+
const fusion = await mockFramework<[AppModule]>((configurator) =>
|
|
53
|
+
enableAppManifestMock(configurator, env),
|
|
54
|
+
);
|
|
55
|
+
const configure: AppMockConfigureFn<[ContextModule]> = (configurator) =>
|
|
56
|
+
enableContextMock(configurator, (mock) => {
|
|
57
|
+
mock.setCurrentContext(project);
|
|
58
|
+
mock.addContext(facility);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const { result } = await renderAppHook(() => useCurrentContext(), { env, fusion, configure });
|
|
62
|
+
await vi.waitFor(() => expect(result.current.currentContext).toMatchObject({ id: project.id }));
|
|
63
|
+
|
|
64
|
+
await act(async () => {
|
|
65
|
+
await result.current.setCurrentContext(facility.id);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
await vi.waitFor(() =>
|
|
69
|
+
expect(result.current.currentContext).toMatchObject({ id: facility.id }),
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { act } from 'react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { mockFramework } from '@equinor/fusion-framework/mock';
|
|
5
|
+
import { enableAppManifestMock } from '@equinor/fusion-framework-app/mock';
|
|
6
|
+
import type { AppMockConfigureFn } from '@equinor/fusion-framework-app/mock';
|
|
7
|
+
import type { AppModule } from '@equinor/fusion-framework-module-app';
|
|
8
|
+
import { enableFeatureFlagMock } from '@equinor/fusion-framework-module-feature-flag/mock';
|
|
9
|
+
import type { FeatureFlagModule } from '@equinor/fusion-framework-module-feature-flag';
|
|
10
|
+
|
|
11
|
+
import { useFeature } from '../feature-flag/useFeature';
|
|
12
|
+
import { renderAppHook } from '@equinor/fusion-framework-vitest-plugin-react-app/test';
|
|
13
|
+
|
|
14
|
+
const env = {
|
|
15
|
+
manifest: {
|
|
16
|
+
appKey: 'test-app',
|
|
17
|
+
displayName: 'Test App',
|
|
18
|
+
description: 'A test application',
|
|
19
|
+
type: 'standalone' as const,
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe('useFeature', () => {
|
|
24
|
+
it('resolves a flag seeded on the app scope when the framework has no feature-flag module', async () => {
|
|
25
|
+
const fusion = await mockFramework<[AppModule]>((configurator) =>
|
|
26
|
+
enableAppManifestMock(configurator, env),
|
|
27
|
+
);
|
|
28
|
+
const configure: AppMockConfigureFn<[FeatureFlagModule]> = (configurator) => {
|
|
29
|
+
enableFeatureFlagMock(configurator, (mock) => {
|
|
30
|
+
mock.addFeature({ key: 'dark-mode', enabled: true });
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const { result } = await renderAppHook(() => useFeature('dark-mode'), {
|
|
35
|
+
env,
|
|
36
|
+
fusion,
|
|
37
|
+
configure,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
await vi.waitFor(() => expect(result.current.feature?.enabled).toBe(true));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('lets an app-scoped flag override a framework-scoped flag with the same key', async () => {
|
|
44
|
+
const fusion = await mockFramework<[AppModule, FeatureFlagModule]>((configurator) => {
|
|
45
|
+
enableAppManifestMock(configurator, env);
|
|
46
|
+
enableFeatureFlagMock(configurator, (mock) => {
|
|
47
|
+
mock.addFeature({ key: 'dark-mode', enabled: false });
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
const configure: AppMockConfigureFn<[FeatureFlagModule]> = (configurator) => {
|
|
51
|
+
enableFeatureFlagMock(configurator, (mock) => {
|
|
52
|
+
mock.addFeature({ key: 'dark-mode', enabled: true });
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const { result } = await renderAppHook(() => useFeature('dark-mode'), {
|
|
57
|
+
env,
|
|
58
|
+
fusion,
|
|
59
|
+
configure,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await vi.waitFor(() => expect(result.current.feature?.enabled).toBe(true));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('exposes a framework-only flag through the merged stream when the app has not seeded it', async () => {
|
|
66
|
+
const fusion = await mockFramework<[AppModule, FeatureFlagModule]>((configurator) => {
|
|
67
|
+
enableAppManifestMock(configurator, env);
|
|
68
|
+
enableFeatureFlagMock(configurator, (mock) => {
|
|
69
|
+
mock.addFeature({ key: 'framework-only', enabled: true });
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
const configure: AppMockConfigureFn<[FeatureFlagModule]> = (configurator) => {
|
|
73
|
+
enableFeatureFlagMock(configurator);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const { result } = await renderAppHook(() => useFeature('framework-only'), {
|
|
77
|
+
env,
|
|
78
|
+
fusion,
|
|
79
|
+
configure,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await vi.waitFor(() => expect(result.current.feature?.enabled).toBe(true));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('inverts the current value when toggleFeature is called without an explicit value', async () => {
|
|
86
|
+
const fusion = await mockFramework<[AppModule]>((configurator) =>
|
|
87
|
+
enableAppManifestMock(configurator, env),
|
|
88
|
+
);
|
|
89
|
+
const configure: AppMockConfigureFn<[FeatureFlagModule]> = (configurator) => {
|
|
90
|
+
enableFeatureFlagMock(configurator, (mock) => {
|
|
91
|
+
mock.addFeature({ key: 'my-flag', enabled: false });
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const { result } = await renderAppHook(() => useFeature('my-flag'), { env, fusion, configure });
|
|
96
|
+
await vi.waitFor(() => expect(result.current.feature?.enabled).toBe(false));
|
|
97
|
+
|
|
98
|
+
act(() => {
|
|
99
|
+
result.current.toggleFeature();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await vi.waitFor(() => expect(result.current.feature?.enabled).toBe(true));
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { mockFramework } from '@equinor/fusion-framework/mock';
|
|
4
|
+
import { enableAppManifestMock } from '@equinor/fusion-framework-app/mock';
|
|
5
|
+
import type { AppModule } from '@equinor/fusion-framework-module-app';
|
|
6
|
+
|
|
7
|
+
import useAppModule from '../useAppModule';
|
|
8
|
+
import { useHelpCenter } from '../help-center/useHelpCenter';
|
|
9
|
+
import { EVENT_NAME } from '../help-center/event-name.js';
|
|
10
|
+
import { renderAppHook } from '@equinor/fusion-framework-vitest-plugin-react-app/test';
|
|
11
|
+
|
|
12
|
+
const env = {
|
|
13
|
+
manifest: {
|
|
14
|
+
appKey: 'test-app',
|
|
15
|
+
displayName: 'Test App',
|
|
16
|
+
description: 'A test application',
|
|
17
|
+
type: 'standalone' as const,
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe('useHelpCenter', () => {
|
|
22
|
+
it('dispatches the expected page and detail for every help-center action', async () => {
|
|
23
|
+
const fusion = await mockFramework<[AppModule]>((configurator) =>
|
|
24
|
+
enableAppManifestMock(configurator, env),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const { result } = await renderAppHook(
|
|
28
|
+
() => ({ helpCenter: useHelpCenter(), eventModule: useAppModule('event') }),
|
|
29
|
+
{ env, fusion },
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const received: Array<{ page: string } & Record<string, unknown>> = [];
|
|
33
|
+
result.current.eventModule.addEventListener(EVENT_NAME, (event) => {
|
|
34
|
+
received.push(event.detail as { page: string } & Record<string, unknown>);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
result.current.helpCenter.openHelp();
|
|
38
|
+
await vi.waitFor(() => expect(received).toHaveLength(1));
|
|
39
|
+
|
|
40
|
+
result.current.helpCenter.openArticle('my-article');
|
|
41
|
+
await vi.waitFor(() => expect(received).toHaveLength(2));
|
|
42
|
+
|
|
43
|
+
result.current.helpCenter.openFaqs();
|
|
44
|
+
await vi.waitFor(() => expect(received).toHaveLength(3));
|
|
45
|
+
|
|
46
|
+
result.current.helpCenter.openSearch('my search');
|
|
47
|
+
await vi.waitFor(() => expect(received).toHaveLength(4));
|
|
48
|
+
|
|
49
|
+
result.current.helpCenter.openGovernance();
|
|
50
|
+
await vi.waitFor(() => expect(received).toHaveLength(5));
|
|
51
|
+
|
|
52
|
+
result.current.helpCenter.openReleaseNotes();
|
|
53
|
+
await vi.waitFor(() => expect(received).toHaveLength(6));
|
|
54
|
+
|
|
55
|
+
expect(received).toEqual([
|
|
56
|
+
{ page: 'home' },
|
|
57
|
+
{ page: 'article', articleId: 'my-article' },
|
|
58
|
+
{ page: 'faqs' },
|
|
59
|
+
{ page: 'search', search: 'my search' },
|
|
60
|
+
{ page: 'governance' },
|
|
61
|
+
{ page: 'release-notes' },
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
+
import { act } from 'react';
|
|
1
2
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
3
|
import { Subject } from 'rxjs';
|
|
3
4
|
|
|
4
|
-
import { act, renderHook } from '@testing-library/react';
|
|
5
|
-
|
|
6
5
|
import { StateSyncEvent, type StateSyncEventType } from '@equinor/fusion-framework-module-state';
|
|
6
|
+
import { renderAppHook } from '@equinor/fusion-framework-vitest-plugin-react-app/test';
|
|
7
7
|
|
|
8
8
|
const event$ = new Subject<StateSyncEventType>();
|
|
9
9
|
|
|
@@ -14,8 +14,8 @@ vi.mock('../useAppModule', () => ({
|
|
|
14
14
|
import { useStateSyncEvents } from '../state/useStateSyncEvents';
|
|
15
15
|
|
|
16
16
|
describe('useStateSyncEvents', () => {
|
|
17
|
-
it('collects dispatched onStateSync.* events, oldest first', () => {
|
|
18
|
-
const { result } =
|
|
17
|
+
it('collects dispatched onStateSync.* events, oldest first', async () => {
|
|
18
|
+
const { result } = await renderAppHook(() => useStateSyncEvents(10));
|
|
19
19
|
|
|
20
20
|
expect(result.current).toEqual([]);
|
|
21
21
|
|
|
@@ -24,13 +24,14 @@ describe('useStateSyncEvents', () => {
|
|
|
24
24
|
event$.next(new StateSyncEvent.Status({ detail: { status: 'paused' } }));
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
expect(result.current).
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
expect(result.current).toMatchObject([
|
|
28
|
+
{ detail: { status: 'active' } },
|
|
29
|
+
{ detail: { status: 'paused' } },
|
|
30
|
+
]);
|
|
30
31
|
});
|
|
31
32
|
|
|
32
|
-
it('ignores events unrelated to state sync and trims the log to the given limit', () => {
|
|
33
|
-
const { result } =
|
|
33
|
+
it('ignores events unrelated to state sync and trims the log to the given limit', async () => {
|
|
34
|
+
const { result } = await renderAppHook(() => useStateSyncEvents(1));
|
|
34
35
|
|
|
35
36
|
act(() => {
|
|
36
37
|
event$.next(new StateSyncEvent.Status({ detail: { status: 'active' } }));
|
|
@@ -43,8 +44,8 @@ describe('useStateSyncEvents', () => {
|
|
|
43
44
|
expect(result.current[0]).toBeInstanceOf(StateSyncEvent.Error);
|
|
44
45
|
});
|
|
45
46
|
|
|
46
|
-
it('unsubscribes from the event stream on unmount', () => {
|
|
47
|
-
const { result, unmount } =
|
|
47
|
+
it('unsubscribes from the event stream on unmount', async () => {
|
|
48
|
+
const { result, unmount } = await renderAppHook(() => useStateSyncEvents(10));
|
|
48
49
|
|
|
49
50
|
unmount();
|
|
50
51
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { mockFramework } from '@equinor/fusion-framework/mock';
|
|
4
|
+
|
|
5
|
+
import { useToken } from '../msal/useToken';
|
|
6
|
+
import { renderAppHook } from '@equinor/fusion-framework-vitest-plugin-react-app/test';
|
|
7
|
+
|
|
8
|
+
describe('useToken', () => {
|
|
9
|
+
it('resolves a full AuthenticationResult from the app scope’s real, mock-backed auth module', async () => {
|
|
10
|
+
const { result, unmount } = await renderAppHook(() => useToken({ scopes: ['User.Read'] }));
|
|
11
|
+
|
|
12
|
+
// `renderAppHook` awaits the render, and the mock resolves near-instantly,
|
|
13
|
+
// so the pending state may have already settled by the time we can observe it
|
|
14
|
+
await vi.waitFor(() => expect(result.current.pending).toBe(false));
|
|
15
|
+
|
|
16
|
+
expect(result.current.error).toBeNull();
|
|
17
|
+
expect(result.current.token?.scopes).toEqual(['User.Read']);
|
|
18
|
+
// a structurally valid JWT has three dot-separated segments
|
|
19
|
+
expect(result.current.token?.accessToken.split('.')).toHaveLength(3);
|
|
20
|
+
expect(result.current.token?.account).toMatchObject({ username: 'test.user@equinor.com' });
|
|
21
|
+
|
|
22
|
+
// unmount before the next test's environment tears down, so no acquisition
|
|
23
|
+
// effect can flush a state update against an already-destroyed `window`
|
|
24
|
+
await unmount();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('surfaces an acquisition error instead of throwing', async () => {
|
|
28
|
+
const fusion = await mockFramework();
|
|
29
|
+
// persistent, not `-Once`: the app module hoists via a version-compat proxy that
|
|
30
|
+
// may call through before the hook's own effect does, consuming a one-shot mock
|
|
31
|
+
vi.spyOn(fusion.modules.auth, 'acquireToken').mockRejectedValue(
|
|
32
|
+
new Error('acquisition failed'),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const { result, unmount } = await renderAppHook(() => useToken({ scopes: ['User.Read'] }), {
|
|
36
|
+
fusion,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
await vi.waitFor(() => expect(result.current.pending).toBe(false));
|
|
40
|
+
|
|
41
|
+
expect(result.current.token).toBeUndefined();
|
|
42
|
+
expect(result.current.error).toBeInstanceOf(Error);
|
|
43
|
+
|
|
44
|
+
await unmount();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('only re-acquires the token when the scopes\u2019 contents change, not on every re-render', async () => {
|
|
48
|
+
const fusion = await mockFramework();
|
|
49
|
+
const acquireToken = vi.spyOn(fusion.modules.auth, 'acquireToken');
|
|
50
|
+
|
|
51
|
+
const { result, rerender, unmount } = await renderAppHook((props) => useToken(props), {
|
|
52
|
+
initialProps: { scopes: ['User.Read'] },
|
|
53
|
+
fusion,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await vi.waitFor(() => expect(result.current.pending).toBe(false));
|
|
57
|
+
expect(acquireToken).toHaveBeenCalledTimes(1);
|
|
58
|
+
|
|
59
|
+
// a fresh array literal with the same contents must not trigger a re-acquisition
|
|
60
|
+
await rerender({ scopes: ['User.Read'] });
|
|
61
|
+
await vi.waitFor(() => expect(result.current.pending).toBe(false));
|
|
62
|
+
expect(acquireToken).toHaveBeenCalledTimes(1);
|
|
63
|
+
|
|
64
|
+
// changed scope contents must trigger a second acquisition
|
|
65
|
+
await rerender({ scopes: ['User.Read', 'Mail.Read'] });
|
|
66
|
+
await vi.waitFor(() => expect(result.current.pending).toBe(false));
|
|
67
|
+
expect(acquireToken).toHaveBeenCalledTimes(2);
|
|
68
|
+
|
|
69
|
+
await unmount();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { mockFramework } from '@equinor/fusion-framework/mock';
|
|
4
|
+
import { enableAppManifestMock } from '@equinor/fusion-framework-app/mock';
|
|
5
|
+
import type { AppModule } from '@equinor/fusion-framework-module-app';
|
|
6
|
+
import { enableAnalytics, type AnalyticsModule } from '@equinor/fusion-framework-module-analytics';
|
|
7
|
+
import { MockAnalyticsAdapter } from '@equinor/fusion-framework-module-analytics/mock';
|
|
8
|
+
import type { MockTelemetryAdapter } from '@equinor/fusion-framework-module-telemetry/mock';
|
|
9
|
+
|
|
10
|
+
import { useTrackFeature } from '../analytics/useTrackFeature';
|
|
11
|
+
import { renderAppHook } from '@equinor/fusion-framework-vitest-plugin-react-app/test';
|
|
12
|
+
|
|
13
|
+
const env = {
|
|
14
|
+
manifest: {
|
|
15
|
+
appKey: 'test-app',
|
|
16
|
+
displayName: 'Test App',
|
|
17
|
+
description: 'A test application',
|
|
18
|
+
type: 'standalone' as const,
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe('useTrackFeature', () => {
|
|
23
|
+
it('tracks an app-feature analytics event carrying the current app’s key, leaving telemetry untouched', async () => {
|
|
24
|
+
const recorder = new MockAnalyticsAdapter();
|
|
25
|
+
|
|
26
|
+
const fusion = await mockFramework<[AppModule, AnalyticsModule]>((configurator) => {
|
|
27
|
+
enableAppManifestMock(configurator, env);
|
|
28
|
+
enableAnalytics(configurator, (builder) => {
|
|
29
|
+
builder.setAdapter('mock', async () => recorder);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
fusion.modules.app.setCurrentApp('test-app');
|
|
33
|
+
|
|
34
|
+
const { result } = await renderAppHook(() => useTrackFeature(), { env, fusion });
|
|
35
|
+
result.current('button-click', { section: 'header' });
|
|
36
|
+
|
|
37
|
+
// analytics is the only channel a successful call should reach
|
|
38
|
+
expect(recorder.getAnalytics()).toMatchObject([
|
|
39
|
+
{
|
|
40
|
+
name: 'app-feature',
|
|
41
|
+
value: { feature: 'button-click', data: { section: 'header' } },
|
|
42
|
+
attributes: { appKey: 'test-app', context: undefined },
|
|
43
|
+
},
|
|
44
|
+
]);
|
|
45
|
+
// the framework logs its own telemetry regardless; the fallback diagnostic
|
|
46
|
+
// specifically must not fire when analytics was reached successfully
|
|
47
|
+
const telemetry = fusion.modules.telemetry.getAdapter('mock') as MockTelemetryAdapter;
|
|
48
|
+
expect(telemetry.getItems('AnalyticsProviderNotFound')).toHaveLength(0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('includes the current context alongside the app key', async () => {
|
|
52
|
+
const recorder = new MockAnalyticsAdapter();
|
|
53
|
+
const project = { id: 'ctx-1', title: 'My project', type: { id: 'ProjectMaster' }, value: {} };
|
|
54
|
+
|
|
55
|
+
const fusion = await mockFramework<[AppModule, AnalyticsModule]>((configurator) => {
|
|
56
|
+
enableAppManifestMock(configurator, env);
|
|
57
|
+
enableAnalytics(configurator, (builder) => {
|
|
58
|
+
builder.setAdapter('mock', async () => recorder);
|
|
59
|
+
});
|
|
60
|
+
configurator.context.setCurrentContext(project);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const { result } = await renderAppHook(() => useTrackFeature(), { env, fusion });
|
|
64
|
+
result.current('button-click');
|
|
65
|
+
|
|
66
|
+
const [event] = recorder.getAnalytics('app-feature');
|
|
67
|
+
expect(event.attributes?.context).toMatchObject({ id: project.id, type: 'ProjectMaster' });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('reports to telemetry instead of throwing when no analytics provider is registered', async () => {
|
|
71
|
+
const fusion = await mockFramework<[AppModule]>((configurator) => {
|
|
72
|
+
enableAppManifestMock(configurator, env);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const { result } = await renderAppHook(() => useTrackFeature(), { env, fusion });
|
|
76
|
+
|
|
77
|
+
// the missing-analytics diagnostic is a telemetry concern, not an analytics one —
|
|
78
|
+
// it must not throw and must not fabricate an analytics event of its own
|
|
79
|
+
expect(() => result.current('button-click')).not.toThrow();
|
|
80
|
+
const telemetry = fusion.modules.telemetry.getAdapter('mock') as MockTelemetryAdapter;
|
|
81
|
+
expect(telemetry.getItems('AnalyticsProviderNotFound')).toHaveLength(1);
|
|
82
|
+
});
|
|
83
|
+
});
|
package/src/create-component.tsx
CHANGED
|
@@ -41,7 +41,7 @@ export type ComponentRenderer<TFusion extends Fusion = Fusion, TEnv = AppEnv> =
|
|
|
41
41
|
* @example
|
|
42
42
|
* ```tsx
|
|
43
43
|
* const configCallback: AppConfigurator = (configurator) => {
|
|
44
|
-
* configurator.
|
|
44
|
+
* configurator.configureHttpClient(
|
|
45
45
|
* 'bar', {
|
|
46
46
|
* baseUri: 'https://somewhere-test.com',
|
|
47
47
|
* defaultScopes: ['foo/.default']
|
package/src/msal/useToken.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useState } from 'react';
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
2
|
|
|
3
3
|
import type { AuthenticationResult } from '@equinor/fusion-framework-module-msal';
|
|
4
4
|
|
|
@@ -32,11 +32,22 @@ export const useToken = (req: {
|
|
|
32
32
|
const [token, setToken] = useState<AuthenticationResult | undefined>(undefined);
|
|
33
33
|
const [pending, setPending] = useState<boolean>(false);
|
|
34
34
|
const [error, setError] = useState<unknown>(null);
|
|
35
|
+
|
|
36
|
+
// `req` is typically a fresh object literal each render; key the effect on its
|
|
37
|
+
// scopes' content instead of identity, so it doesn't re-run (and reset `pending`
|
|
38
|
+
// to `true` forever) every time the caller re-renders. `JSON.stringify` (rather than
|
|
39
|
+
// `.join(',')`) keeps scopes with different array boundaries (e.g. `['a,b']` vs.
|
|
40
|
+
// `['a', 'b']`) from colliding on the same key.
|
|
41
|
+
const scopesKey = JSON.stringify(req.scopes);
|
|
42
|
+
const reqRef = useRef(req);
|
|
43
|
+
reqRef.current = req;
|
|
44
|
+
|
|
45
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: scopesKey is a re-run trigger, not read in the body
|
|
35
46
|
useEffect(() => {
|
|
36
47
|
setPending(true);
|
|
37
48
|
setToken(undefined);
|
|
38
49
|
msalProvider
|
|
39
|
-
.acquireToken({ request:
|
|
50
|
+
.acquireToken({ request: reqRef.current })
|
|
40
51
|
.then((result) => {
|
|
41
52
|
// Only update state when a token was actually acquired
|
|
42
53
|
if (result) {
|
|
@@ -45,7 +56,7 @@ export const useToken = (req: {
|
|
|
45
56
|
})
|
|
46
57
|
.catch(setError)
|
|
47
58
|
.finally(() => setPending(false));
|
|
48
|
-
}, [msalProvider,
|
|
59
|
+
}, [msalProvider, scopesKey]);
|
|
49
60
|
return { token, pending, error };
|
|
50
61
|
};
|
|
51
62
|
|
package/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by genversion.
|
|
2
|
-
export const version = '
|
|
2
|
+
export const version = '15.0.0-next.0';
|
package/tsconfig.json
CHANGED
package/vitest.config.ts
CHANGED
|
@@ -1,15 +1,9 @@
|
|
|
1
|
-
import { defineProject } from 'vitest/config';
|
|
1
|
+
import { defineProject } from '@equinor/fusion-framework-vitest-plugin-react-app/config';
|
|
2
2
|
|
|
3
|
-
import { name, version } from './package.json';
|
|
3
|
+
import { name, version } from './package.json' with { type: 'json' };
|
|
4
4
|
|
|
5
5
|
export default defineProject({
|
|
6
|
-
resolve: {
|
|
7
|
-
// @ts-expect-error -- tsconfigPaths is a Vite 8 option; vitest 4.x ships Vite 7 types
|
|
8
|
-
tsconfigPaths: true,
|
|
9
|
-
},
|
|
10
6
|
test: {
|
|
11
|
-
include: ['src/__tests__/**'],
|
|
12
7
|
name: `${name}@${version}`,
|
|
13
|
-
environment: 'happy-dom',
|
|
14
8
|
},
|
|
15
9
|
});
|