@equinor/fusion-framework-react-app 14.0.1 → 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.
@@ -27,7 +27,7 @@ export type ComponentRenderer<TFusion extends Fusion = Fusion, TEnv = AppEnv> =
27
27
  * @example
28
28
  * ```tsx
29
29
  * const configCallback: AppConfigurator = (configurator) => {
30
- * configurator.http.configureClient(
30
+ * configurator.configureHttpClient(
31
31
  * 'bar', {
32
32
  * baseUri: 'https://somewhere-test.com',
33
33
  * defaultScopes: ['foo/.default']
@@ -1 +1 @@
1
- export declare const version = "14.0.1";
1
+ export declare const version = "15.0.0-next.0";
package/docs/bookmark.md CHANGED
@@ -78,6 +78,13 @@ const MyApp = () => {
78
78
  };
79
79
  ```
80
80
 
81
+ > [!WARNING]
82
+ > If your app has **not** called `enableBookmark`, `useCurrentBookmark` falls back to the
83
+ > portal's framework-scoped bookmark provider and logs a `@deprecation` console warning
84
+ > (`"application has not enabled bookmarks, this will not work in the future"`). This
85
+ > fallback is deprecated and will be removed — always call `enableBookmark` in apps that
86
+ > use bookmarks.
87
+
81
88
  ## useBookmark (Deprecated)
82
89
 
83
90
  > [!CAUTION]
package/docs/context.md CHANGED
@@ -29,12 +29,14 @@ Returns the currently selected context from the application-scoped context modul
29
29
  ```ts
30
30
  function useCurrentContext(): {
31
31
  currentContext: ContextItem | null | undefined;
32
- setCurrentContext: (context?: ContextItem | string | null) => void;
32
+ setCurrentContext: (context?: ContextItem | string | null) => void | Promise<ContextItem | null>;
33
33
  };
34
34
  ```
35
35
 
36
36
  **Returns:** An object with the current context (`ContextItem | null | undefined`) and a setter to change it.
37
37
 
38
+ `setCurrentContext` clears the context when called with no argument (or `null`), resolves an item by `id` when given a `string`, or sets the given `ContextItem` directly. Clearing returns `void`; setting by `id` or by item returns a `Promise` that resolves once the switch completes — `await` it when you need to know the new context has taken effect (e.g. before navigating).
39
+
38
40
  ### Example
39
41
 
40
42
  ```tsx
package/docs/framework.md CHANGED
@@ -31,11 +31,11 @@ Use this when your app hasn't configured its own context module but still needs
31
31
  ```ts
32
32
  function useFrameworkCurrentContext(): {
33
33
  currentContext: ContextItem | undefined;
34
- setCurrentContext: (entry?: ContextItem | string | null) => void;
34
+ setCurrentContext: (entry?: ContextItem | string | null) => void | Promise<ContextItem | null>;
35
35
  };
36
36
  ```
37
37
 
38
- **Returns:** An object with `currentContext` (the portal's active context, or `undefined` if none is selected) and `setCurrentContext` to change it.
38
+ **Returns:** An object with `currentContext` (the portal's active context, or `undefined` if none is selected) and `setCurrentContext` to change it. Clearing (no argument or `null`) returns `void`; setting by `id` or by item returns a `Promise` that resolves once the switch completes.
39
39
 
40
40
  **Example:**
41
41
 
package/docs/msal.md CHANGED
@@ -116,7 +116,7 @@ const TokenInfo = () => {
116
116
  return (
117
117
  <div>
118
118
  <p>Access Token: {token.accessToken.substring(0, 20)}...</p>
119
- <p>Expires: {new Date(token.expiresOn).toLocaleString()}</p>
119
+ <p>Expires: {token.expiresOn?.toLocaleString() ?? 'unknown'}</p>
120
120
  <p>Token Type: {token.tokenType}</p>
121
121
  <p>Scope: {token.scopes.join(', ')}</p>
122
122
  </div>
@@ -134,8 +134,8 @@ const TokenInfo = () => {
134
134
 
135
135
  **Note:** The `AuthenticationResult` type includes:
136
136
  - `accessToken: string`
137
- - `account: AccountInfo`
138
- - `expiresOn: Date`
137
+ - `account: AccountInfo | null`
138
+ - `expiresOn: Date | null`
139
139
  - `tokenType: string`
140
140
  - `scopes: string[]`
141
141
  - And more...
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@equinor/fusion-framework-react-app",
3
- "version": "14.0.1",
3
+ "version": "15.0.0-next.0",
4
4
  "description": "",
5
+ "type": "module",
5
6
  "main": "./dist/esm/index.js",
6
7
  "types": "./dist/types/index.d.ts",
7
8
  "exports": {
@@ -131,44 +132,51 @@
131
132
  "directory": "packages/react"
132
133
  },
133
134
  "dependencies": {
134
- "@equinor/fusion-framework-module-app": "8.0.4",
135
- "@equinor/fusion-framework-module-http": "8.0.5",
136
- "@equinor/fusion-framework-module": "6.1.2",
137
- "@equinor/fusion-framework-react": "8.0.1",
138
- "@equinor/fusion-framework-app": "13.0.1",
139
- "@equinor/fusion-framework-module-navigation": "7.0.7",
140
- "@equinor/fusion-framework-react-module": "4.0.2",
141
- "@equinor/fusion-framework-react-module-http": "11.0.1"
135
+ "@equinor/fusion-framework": "8.1.0-next.0",
136
+ "@equinor/fusion-framework-module": "6.1.3-next.0",
137
+ "@equinor/fusion-framework-module-http": "8.1.0-next.0",
138
+ "@equinor/fusion-framework-module-navigation": "7.0.8-next.0",
139
+ "@equinor/fusion-framework-react": "9.0.0-next.0",
140
+ "@equinor/fusion-framework-app": "14.0.0-next.0",
141
+ "@equinor/fusion-framework-react-module": "4.0.3-next.0",
142
+ "@equinor/fusion-framework-module-app": "8.1.0-next.0",
143
+ "@equinor/fusion-framework-react-module-http": "12.0.0-next.0"
142
144
  },
143
145
  "devDependencies": {
144
- "@testing-library/react": "^16.0.0",
146
+ "@remix-run/router": "^1.23.3",
145
147
  "@types/react": "^19.2.7",
146
148
  "@types/react-dom": "^19.2.3",
147
- "happy-dom": "^20.8.4",
149
+ "@vitest/browser-playwright": "^4.1.0",
150
+ "playwright": "^1.62.1",
148
151
  "react": "^19.2.1",
149
152
  "react-dom": "^19.2.1",
150
153
  "rxjs": "^7.8.1",
151
154
  "typescript": "^7.0.2",
152
- "vitest": "^4.1.0",
153
- "@equinor/fusion-framework-module-ag-grid": "37.0.1",
154
- "@equinor/fusion-framework-module-analytics": "3.0.6",
155
- "@equinor/fusion-framework-module-event": "6.0.1",
156
- "@equinor/fusion-framework-module-feature-flag": "2.0.3",
157
- "@equinor/fusion-framework-module-state": "^2.0.0",
158
- "@equinor/fusion-framework-module-msal": "10.0.2",
159
- "@equinor/fusion-framework-react-module-bookmark": "6.0.1",
160
- "@equinor/fusion-framework-react-module-context": "7.0.2",
161
- "@equinor/fusion-framework-react-router": "2.4.0",
162
- "@equinor/fusion-observable": "9.1.1"
155
+ "vitest": "^4.1.10",
156
+ "vitest-browser-react": "^2.2.0",
157
+ "@equinor/fusion-framework-module-ag-grid": "37.0.2-next.0",
158
+ "@equinor/fusion-framework-module-analytics": "3.1.0-next.0",
159
+ "@equinor/fusion-framework-module-bookmark": "4.1.0-next.0",
160
+ "@equinor/fusion-framework-module-context": "9.0.0-next.0",
161
+ "@equinor/fusion-framework-module-event": "6.1.0-next.0",
162
+ "@equinor/fusion-framework-module-feature-flag": "2.1.0-next.0",
163
+ "@equinor/fusion-framework-module-msal": "11.0.0-next.0",
164
+ "@equinor/fusion-framework-module-telemetry": "8.0.0-next.0",
165
+ "@equinor/fusion-framework-react-module-bookmark": "7.0.0-next.0",
166
+ "@equinor/fusion-framework-module-state": "^2.0.1-next.0",
167
+ "@equinor/fusion-framework-react-module-context": "7.0.3-next.0",
168
+ "@equinor/fusion-observable": "9.1.2-next.0",
169
+ "@equinor/fusion-framework-react-router": "2.4.1-next.0",
170
+ "@equinor/fusion-framework-vitest-plugin-react-app": "0.2.0-next.0"
163
171
  },
164
172
  "peerDependencies": {
165
173
  "@types/react": "^18.0.0 || ^19.0.0",
166
174
  "react": "^18.0.0 || ^19.0.0",
167
175
  "react-dom": "^18.0.0 || ^19.0.0",
168
176
  "rxjs": "^7.0.0",
169
- "@equinor/fusion-framework-module-msal": "^10.0.2",
170
- "@equinor/fusion-framework-react-router": "^2.4.0",
171
- "@equinor/fusion-framework-module-state": "^2.0.0"
177
+ "@equinor/fusion-framework-module-msal": "^11.0.0-next.0",
178
+ "@equinor/fusion-framework-module-state": "^2.0.1-next.0",
179
+ "@equinor/fusion-framework-react-router": "^2.4.1-next.0"
172
180
  },
173
181
  "peerDependenciesMeta": {
174
182
  "@equinor/fusion-framework-react-module-ag-grid": {
@@ -0,0 +1,51 @@
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 { AppManifest, AppModule } from '@equinor/fusion-framework-module-app';
6
+
7
+ import { Apploader } from '../apploader/Apploader';
8
+ import { renderAppComponent } from '@equinor/fusion-framework-vitest-plugin-react-app/test';
9
+
10
+ // resolved from this test file rather than hardcoded, so it survives a package move
11
+ const fixturesUri = new URL('./fixtures', import.meta.url).pathname;
12
+
13
+ describe('Apploader', () => {
14
+ it('mounts the child app’s rendered content once its script loads', async () => {
15
+ const manifest: AppManifest = {
16
+ appKey: 'child-app',
17
+ displayName: 'Child App',
18
+ description: 'A child application',
19
+ type: 'standalone',
20
+ build: { version: '1.0.0', entryPoint: 'apploader-child-script.ts', assetPath: '' },
21
+ };
22
+
23
+ const fusion = await mockFramework<[AppModule]>((configurator) =>
24
+ enableAppManifestMock(configurator, { manifest }, fixturesUri),
25
+ );
26
+
27
+ const { container } = await renderAppComponent(<Apploader appKey="child-app" />, { fusion });
28
+
29
+ // the loading state renders synchronously, before the script's dynamic import resolves
30
+ expect(container.textContent).toContain('Loading child-app');
31
+ await vi.waitFor(() => expect(container.textContent).toContain('mounted: child-app'));
32
+ });
33
+
34
+ it('surfaces the load error instead of throwing when the app’s script fails to import', async () => {
35
+ const manifest: AppManifest = {
36
+ appKey: 'broken-app',
37
+ displayName: 'Broken App',
38
+ description: 'An application whose build entry point does not exist',
39
+ type: 'standalone',
40
+ build: { version: '1.0.0', entryPoint: 'does-not-exist.ts', assetPath: '' },
41
+ };
42
+
43
+ const fusion = await mockFramework<[AppModule]>((configurator) =>
44
+ enableAppManifestMock(configurator, { manifest }, fixturesUri),
45
+ );
46
+
47
+ const { container } = await renderAppComponent(<Apploader appKey="broken-app" />, { fusion });
48
+
49
+ await vi.waitFor(() => expect(container.textContent).toContain('Error loading broken-app'));
50
+ });
51
+ });
@@ -0,0 +1,9 @@
1
+ import type { AppScriptModule } from '@equinor/fusion-framework-module-app';
2
+
3
+ // a minimal stand-in for a real app bundle's entry point, dynamically imported by `App.initialize()`
4
+ export const renderApp: AppScriptModule['renderApp'] = (el, { env }) => {
5
+ el.textContent = `mounted: ${env.manifest?.appKey}`;
6
+ return () => {
7
+ el.textContent = '';
8
+ };
9
+ };
@@ -0,0 +1,47 @@
1
+ import { describe, expect } from 'vitest';
2
+
3
+ import { enableContextMock } from '@equinor/fusion-framework-module-context/mock';
4
+ import type { ContextItem } from '@equinor/fusion-framework-module-context';
5
+ import { useCurrentContext } from '@equinor/fusion-framework-react-app/context';
6
+
7
+ import { testApp } from '@equinor/fusion-framework-vitest-plugin-react-app/test';
8
+
9
+ const projectA: ContextItem = {
10
+ id: 'ctx-a',
11
+ title: 'Project A',
12
+ type: { id: 'ProjectMaster' },
13
+ value: {},
14
+ };
15
+
16
+ const Greeting = () => <p>hello</p>;
17
+
18
+ const CurrentContext = () => {
19
+ const { currentContext } = useCurrentContext();
20
+ return <p>{currentContext?.title ?? 'none'}</p>;
21
+ };
22
+
23
+ describe('testApp', () => {
24
+ testApp(
25
+ 'resolves a fusion/app scope and renders through the public /vitest entry point',
26
+ async ({ render, fusion, app }) => {
27
+ const screen = await render(<Greeting />);
28
+ await expect.element(screen.getByText('hello')).toBeVisible();
29
+ expect(fusion).toBeDefined();
30
+ expect(app).toBeDefined();
31
+ },
32
+ );
33
+
34
+ describe('with a seeded context module', () => {
35
+ const test = testApp.extend(
36
+ 'configure',
37
+ { injected: true },
38
+ () => (configurator) =>
39
+ enableContextMock(configurator, (mock) => mock.setCurrentContext(projectA)),
40
+ );
41
+
42
+ test('starts on the seeded context', async ({ render }) => {
43
+ const screen = await render(<CurrentContext />);
44
+ await expect.element(screen.getByText(projectA.title as string)).toBeVisible();
45
+ });
46
+ });
47
+ });
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { mockFramework } from '@equinor/fusion-framework/mock';
4
+
5
+ import { useAccessToken } from '../msal/useAccessToken';
6
+ import { renderAppHook } from '@equinor/fusion-framework-vitest-plugin-react-app/test';
7
+
8
+ describe('useAccessToken', () => {
9
+ it('resolves an access token from the app scope’s real, mock-backed auth module', async () => {
10
+ // `renderAppHook` runs the real msal module against an in-process mock client,
11
+ // so `auth` behaves exactly as it would with a signed-in user in production.
12
+ const { result, unmount } = await renderAppHook(() =>
13
+ useAccessToken({ scopes: ['User.Read'] }),
14
+ );
15
+
16
+ // `renderAppHook` awaits the render, and the mock resolves near-instantly,
17
+ // so the pending state may have already settled by the time we can observe it
18
+ await vi.waitFor(() => expect(result.current.pending).toBe(false));
19
+
20
+ expect(result.current.error).toBeNull();
21
+ // a structurally valid JWT has three dot-separated segments
22
+ expect(result.current.token?.split('.')).toHaveLength(3);
23
+
24
+ // unmount before the next test's environment tears down, so no acquisition
25
+ // effect can flush a state update against an already-destroyed `window`
26
+ await unmount();
27
+ });
28
+
29
+ it('surfaces an acquisition error instead of throwing', async () => {
30
+ const fusion = await mockFramework();
31
+ // persistent, not `-Once`: the app module hoists via a version-compat proxy that
32
+ // may call through before the hook's own effect does, consuming a one-shot mock
33
+ vi.spyOn(fusion.modules.auth, 'acquireToken').mockRejectedValue(
34
+ new Error('acquisition failed'),
35
+ );
36
+
37
+ const { result, unmount } = await renderAppHook(
38
+ () => useAccessToken({ scopes: ['User.Read'] }),
39
+ {
40
+ fusion,
41
+ },
42
+ );
43
+
44
+ await vi.waitFor(() => expect(result.current.pending).toBe(false));
45
+
46
+ expect(result.current.token).toBeUndefined();
47
+ expect(result.current.error).toBeInstanceOf(Error);
48
+
49
+ await unmount();
50
+ });
51
+ });
@@ -0,0 +1,133 @@
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 { AppModule } from '@equinor/fusion-framework-module-app';
7
+ import { createRouterMiddleware } from '@equinor/fusion-framework-module-http/mock';
8
+
9
+ import { useAppSetting } from '../settings/useAppSetting';
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
+ /**
22
+ * Boots a parent framework whose `apps` client serves per-user settings from an
23
+ * in-memory store, so `useAppSetting` exercises the real fetch/update round-trip
24
+ * instead of a stubbed provider.
25
+ *
26
+ * @param initial - The settings the store starts seeded with.
27
+ * @param putResponse - Overrides the response a PUT request resolves to, to simulate a failed update.
28
+ */
29
+ const mockSettingsFusion = async (
30
+ initial: Record<string, unknown> = {},
31
+ putResponse?: () => Response,
32
+ ) => {
33
+ const store: Record<string, unknown> = { ...initial };
34
+ const fusion = await mockFramework<[AppModule]>((configurator) => {
35
+ configurator.http.addMiddleware(
36
+ createRouterMiddleware('https://apps.fusion.test', (router) => {
37
+ router.get('/persons/me/apps/:appKey/settings', () => Response.json(store));
38
+ router.put('/persons/me/apps/:appKey/settings', async ({ request }) => {
39
+ // a caller-supplied response simulates a failed persistence request
40
+ if (putResponse) return putResponse();
41
+ // the PUT body replaces the seeded keys it targets, leaving the rest of the store untouched
42
+ Object.assign(store, await request.json());
43
+ return Response.json(store);
44
+ });
45
+ }),
46
+ );
47
+ enableAppManifestMock(configurator, env);
48
+ });
49
+ fusion.modules.app.setCurrentApp(env.manifest.appKey);
50
+ return fusion;
51
+ };
52
+
53
+ describe('useAppSetting', () => {
54
+ it('resolves the persisted value for a single setting key', async () => {
55
+ const fusion = await mockSettingsFusion({ theme: 'dark' });
56
+
57
+ const { result } = await renderAppHook(
58
+ () => useAppSetting<{ theme: string }, 'theme'>('theme'),
59
+ { env, fusion },
60
+ );
61
+
62
+ await vi.waitFor(() => expect(result.current[0]).toBe('dark'));
63
+ });
64
+
65
+ it('falls back to the default value until the persisted settings resolve', async () => {
66
+ const fusion = await mockSettingsFusion({ theme: 'dark' });
67
+
68
+ const { result } = await renderAppHook(
69
+ () => useAppSetting<{ theme: string }, 'theme'>('theme', 'light'),
70
+ { env, fusion },
71
+ );
72
+
73
+ expect(result.current[0]).toBe('light');
74
+ await vi.waitFor(() => expect(result.current[0]).toBe('dark'));
75
+ });
76
+
77
+ it('persists an updated value and notifies onUpdated', async () => {
78
+ const fusion = await mockSettingsFusion({ theme: 'dark' });
79
+ const onUpdated = vi.fn();
80
+
81
+ const { result } = await renderAppHook(
82
+ () => useAppSetting<{ theme: string }, 'theme'>('theme', undefined, { onUpdated }),
83
+ { env, fusion },
84
+ );
85
+
86
+ await vi.waitFor(() => expect(result.current[0]).toBe('dark'));
87
+
88
+ act(() => {
89
+ result.current[1]('light');
90
+ });
91
+
92
+ await vi.waitFor(() => expect(result.current[0]).toBe('light'));
93
+ await vi.waitFor(() => expect(onUpdated).toHaveBeenCalled());
94
+ });
95
+
96
+ it('resolves the next value from the previous one when given an updater function', async () => {
97
+ const fusion = await mockSettingsFusion({ count: 1 });
98
+
99
+ const { result } = await renderAppHook(
100
+ () => useAppSetting<{ count: number }, 'count'>('count'),
101
+ { env, fusion },
102
+ );
103
+
104
+ await vi.waitFor(() => expect(result.current[0]).toBe(1));
105
+
106
+ act(() => {
107
+ result.current[1]((current) => (current ?? 0) + 1);
108
+ });
109
+
110
+ await vi.waitFor(() => expect(result.current[0]).toBe(2));
111
+ });
112
+
113
+ it('surfaces a persistence failure through onError instead of throwing', async () => {
114
+ const fusion = await mockSettingsFusion(
115
+ { theme: 'dark' },
116
+ () => new Response(null, { status: 500 }),
117
+ );
118
+ const onError = vi.fn();
119
+
120
+ const { result } = await renderAppHook(
121
+ () => useAppSetting<{ theme: string }, 'theme'>('theme', undefined, { onError }),
122
+ { env, fusion },
123
+ );
124
+
125
+ await vi.waitFor(() => expect(result.current[0]).toBe('dark'));
126
+
127
+ act(() => {
128
+ result.current[1]('light');
129
+ });
130
+
131
+ await vi.waitFor(() => expect(onError).toHaveBeenCalledWith(expect.any(Error)));
132
+ });
133
+ });
@@ -0,0 +1,147 @@
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 { AppModule } from '@equinor/fusion-framework-module-app';
7
+ import { createRouterMiddleware } from '@equinor/fusion-framework-module-http/mock';
8
+
9
+ import { useAppSettings } from '../settings/useAppSettings';
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
+ /**
22
+ * Boots a parent framework whose `apps` client serves per-user settings from an
23
+ * in-memory store, so `useAppSettings` exercises the real fetch/update round-trip
24
+ * instead of a stubbed provider.
25
+ *
26
+ * @param initial - The settings the store starts seeded with.
27
+ * @param putResponse - Overrides the response a PUT request resolves to, to simulate a failed update.
28
+ */
29
+ const mockSettingsFusion = async (
30
+ initial: Record<string, unknown> = {},
31
+ putResponse?: () => Response,
32
+ ) => {
33
+ const store: Record<string, unknown> = { ...initial };
34
+ const fusion = await mockFramework<[AppModule]>((configurator) => {
35
+ configurator.http.addMiddleware(
36
+ createRouterMiddleware('https://apps.fusion.test', (router) => {
37
+ router.get('/persons/me/apps/:appKey/settings', () => Response.json(store));
38
+ router.put('/persons/me/apps/:appKey/settings', async ({ request }) => {
39
+ // a caller-supplied response simulates a failed persistence request
40
+ if (putResponse) return putResponse();
41
+ // the PUT body replaces the seeded keys it targets, leaving the rest of the store untouched
42
+ Object.assign(store, await request.json());
43
+ return Response.json(store);
44
+ });
45
+ }),
46
+ );
47
+ enableAppManifestMock(configurator, env);
48
+ });
49
+ fusion.modules.app.setCurrentApp(env.manifest.appKey);
50
+ return fusion;
51
+ };
52
+
53
+ interface TestSettings extends Record<string, unknown> {
54
+ theme: string;
55
+ layout: string;
56
+ }
57
+
58
+ describe('useAppSettings', () => {
59
+ it('resolves the full persisted settings object', async () => {
60
+ const fusion = await mockSettingsFusion({ theme: 'dark', layout: 'grid' });
61
+
62
+ const { result } = await renderAppHook(() => useAppSettings<TestSettings>(), { env, fusion });
63
+
64
+ await vi.waitFor(() =>
65
+ expect(result.current[0]).toMatchObject({ theme: 'dark', layout: 'grid' }),
66
+ );
67
+ });
68
+
69
+ it('falls back to the default value until the persisted settings resolve', async () => {
70
+ const fusion = await mockSettingsFusion({ theme: 'dark', layout: 'grid' });
71
+
72
+ const { result } = await renderAppHook(
73
+ () => useAppSettings<TestSettings>({ theme: 'light', layout: 'list' }),
74
+ { env, fusion },
75
+ );
76
+
77
+ expect(result.current[0]).toMatchObject({ theme: 'light', layout: 'list' });
78
+ await vi.waitFor(() =>
79
+ expect(result.current[0]).toMatchObject({ theme: 'dark', layout: 'grid' }),
80
+ );
81
+ });
82
+
83
+ it('persists an updated settings object and notifies onUpdated', async () => {
84
+ const fusion = await mockSettingsFusion({ theme: 'dark', layout: 'grid' });
85
+ const onUpdated = vi.fn();
86
+
87
+ const { result } = await renderAppHook(
88
+ () => useAppSettings<TestSettings>(undefined, { onUpdated }),
89
+ { env, fusion },
90
+ );
91
+
92
+ await vi.waitFor(() =>
93
+ expect(result.current[0]).toMatchObject({ theme: 'dark', layout: 'grid' }),
94
+ );
95
+
96
+ act(() => {
97
+ result.current[1]({ theme: 'light', layout: 'grid' });
98
+ });
99
+
100
+ await vi.waitFor(() =>
101
+ expect(result.current[0]).toMatchObject({ theme: 'light', layout: 'grid' }),
102
+ );
103
+ await vi.waitFor(() => expect(onUpdated).toHaveBeenCalled());
104
+ });
105
+
106
+ it('resolves the next settings object from the previous one when given an updater function', async () => {
107
+ const fusion = await mockSettingsFusion({ theme: 'dark', layout: 'grid' });
108
+
109
+ const { result } = await renderAppHook(() => useAppSettings<TestSettings>(), { env, fusion });
110
+
111
+ await vi.waitFor(() =>
112
+ expect(result.current[0]).toMatchObject({ theme: 'dark', layout: 'grid' }),
113
+ );
114
+
115
+ act(() => {
116
+ // `current` is typed as possibly undefined even though it's already resolved by this point
117
+ result.current[1]((current) => ({ theme: current?.theme ?? 'dark', layout: 'list' }));
118
+ });
119
+
120
+ await vi.waitFor(() =>
121
+ expect(result.current[0]).toMatchObject({ theme: 'dark', layout: 'list' }),
122
+ );
123
+ });
124
+
125
+ it('surfaces a persistence failure through onError instead of throwing', async () => {
126
+ const fusion = await mockSettingsFusion(
127
+ { theme: 'dark', layout: 'grid' },
128
+ () => new Response(null, { status: 500 }),
129
+ );
130
+ const onError = vi.fn();
131
+
132
+ const { result } = await renderAppHook(
133
+ () => useAppSettings<TestSettings>(undefined, { onError }),
134
+ { env, fusion },
135
+ );
136
+
137
+ await vi.waitFor(() =>
138
+ expect(result.current[0]).toMatchObject({ theme: 'dark', layout: 'grid' }),
139
+ );
140
+
141
+ act(() => {
142
+ result.current[1]({ theme: 'light', layout: 'grid' });
143
+ });
144
+
145
+ await vi.waitFor(() => expect(onError).toHaveBeenCalledWith(expect.any(Error)));
146
+ });
147
+ });
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { useCurrentAccount } from '../msal/useCurrentAccount';
4
+ import { renderAppHook } from '@equinor/fusion-framework-vitest-plugin-react-app/test';
5
+
6
+ describe('useCurrentAccount', () => {
7
+ it('returns the default mock user signed in by the app scope’s auth module', async () => {
8
+ const { result } = await renderAppHook(() => useCurrentAccount());
9
+
10
+ expect(result.current).toMatchObject({
11
+ name: 'Test User',
12
+ username: 'test.user@equinor.com',
13
+ });
14
+ });
15
+
16
+ it('returns the account configured through the msal mock builder', async () => {
17
+ const { result } = await renderAppHook(() => useCurrentAccount(), {
18
+ configure: (configurator) =>
19
+ configurator.msal.setAccount({ name: 'Ada Lovelace', username: 'ada@equinor.com' }),
20
+ });
21
+
22
+ expect(result.current).toMatchObject({ name: 'Ada Lovelace', username: 'ada@equinor.com' });
23
+ });
24
+
25
+ it('returns undefined when no account is signed in', async () => {
26
+ const { result } = await renderAppHook(() => useCurrentAccount(), {
27
+ configure: (configurator) => configurator.msal.setAccount(null),
28
+ });
29
+
30
+ expect(result.current).toBeUndefined();
31
+ });
32
+ });