@equinor/fusion-framework-react-app 14.0.3 → 14.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +9 -2
  3. package/dist/esm/ag-grid/community.js +12 -0
  4. package/dist/esm/ag-grid/community.js.map +1 -0
  5. package/dist/esm/ag-grid/enterprise.js +12 -0
  6. package/dist/esm/ag-grid/enterprise.js.map +1 -0
  7. package/dist/esm/ag-grid/react.js +12 -0
  8. package/dist/esm/ag-grid/react.js.map +1 -0
  9. package/dist/esm/ag-grid/testing.js +19 -0
  10. package/dist/esm/ag-grid/testing.js.map +1 -0
  11. package/dist/esm/ag-grid/theme.js +13 -0
  12. package/dist/esm/ag-grid/theme.js.map +1 -0
  13. package/dist/esm/create-component.js +1 -1
  14. package/dist/esm/msal/useToken.js +12 -3
  15. package/dist/esm/msal/useToken.js.map +1 -1
  16. package/dist/esm/version.js +1 -1
  17. package/dist/tsconfig.tsbuildinfo +1 -1
  18. package/dist/types/ag-grid/community.d.ts +11 -0
  19. package/dist/types/ag-grid/enterprise.d.ts +11 -0
  20. package/dist/types/ag-grid/react.d.ts +11 -0
  21. package/dist/types/ag-grid/testing.d.ts +18 -0
  22. package/dist/types/ag-grid/theme.d.ts +13 -0
  23. package/dist/types/create-component.d.ts +1 -1
  24. package/dist/types/version.d.ts +1 -1
  25. package/docs/bookmark.md +7 -0
  26. package/docs/context.md +3 -1
  27. package/docs/framework.md +2 -2
  28. package/docs/msal.md +3 -3
  29. package/package.json +77 -25
  30. package/src/__tests__/Apploader.test.tsx +51 -0
  31. package/src/__tests__/fixtures/apploader-child-script.ts +9 -0
  32. package/src/__tests__/testApp.test.tsx +76 -0
  33. package/src/__tests__/useAccessToken.test.tsx +51 -0
  34. package/src/__tests__/useAppSetting.test.tsx +133 -0
  35. package/src/__tests__/useAppSettings.test.tsx +147 -0
  36. package/src/__tests__/useCurrentAccount.test.tsx +32 -0
  37. package/src/__tests__/useCurrentBookmark.test.tsx +108 -0
  38. package/src/__tests__/useCurrentContext.test.tsx +72 -0
  39. package/src/__tests__/useFeature.test.tsx +104 -0
  40. package/src/__tests__/useHelpCenter.test.tsx +64 -0
  41. package/src/__tests__/useStateSyncEvents.test.ts +12 -11
  42. package/src/__tests__/useToken.test.tsx +71 -0
  43. package/src/__tests__/useTrackFeature.test.tsx +83 -0
  44. package/src/ag-grid/community.ts +11 -0
  45. package/src/ag-grid/enterprise.ts +11 -0
  46. package/src/ag-grid/react.ts +11 -0
  47. package/src/ag-grid/testing.ts +19 -0
  48. package/src/ag-grid/theme.ts +17 -0
  49. package/src/create-component.tsx +1 -1
  50. package/src/msal/useToken.ts +14 -3
  51. package/src/version.ts +1 -1
  52. package/tsconfig.json +6 -0
  53. package/vitest.config.ts +2 -8
@@ -0,0 +1,11 @@
1
+ /**
2
+ * AG Grid community-tier sub-path entry-point.
3
+ *
4
+ * @remarks
5
+ * Re-exports every public symbol from
6
+ * `@equinor/fusion-framework-react-ag-grid/community` so the application
7
+ * resolves a single shared copy of `ag-grid-community`.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+ export * from '@equinor/fusion-framework-react-ag-grid/community';
@@ -0,0 +1,11 @@
1
+ /**
2
+ * AG Grid enterprise-tier sub-path entry-point.
3
+ *
4
+ * @remarks
5
+ * Re-exports every public symbol from
6
+ * `@equinor/fusion-framework-react-ag-grid/enterprise` so the application
7
+ * resolves a single shared copy of `ag-grid-enterprise`.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+ export * from '@equinor/fusion-framework-react-ag-grid/enterprise';
@@ -0,0 +1,11 @@
1
+ /**
2
+ * AG Grid React component sub-path entry-point.
3
+ *
4
+ * @remarks
5
+ * Re-exports the AG Grid React bindings from
6
+ * `@equinor/fusion-framework-react-ag-grid/react`, including the
7
+ * `AgGridReact` component, `enableAgGrid`, and their associated types.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+ export * from '@equinor/fusion-framework-react-ag-grid/react';
@@ -0,0 +1,18 @@
1
+ /**
2
+ * AG Grid test helpers sub-path entry-point.
3
+ *
4
+ * @remarks
5
+ * Re-exports the AG Grid test helpers from
6
+ * `@equinor/fusion-framework-module-ag-grid/testing`. Import from test setup
7
+ * files only — these helpers patch global state and are not meant for
8
+ * application runtime code.
9
+ *
10
+ * @packageDocumentation
11
+ */
12
+ /**
13
+ * Silences AG Grid Enterprise's unlicensed "License Key Not Found" banner on
14
+ * `console.error` so it doesn't bury real failures in test output.
15
+ *
16
+ * @returns A function that restores the original `console.error`.
17
+ */
18
+ export { suppressAgGridLicenseBanner } from '@equinor/fusion-framework-module-ag-grid/testing';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * AG Grid theme sub-path entry-point.
3
+ *
4
+ * @remarks
5
+ * Re-exports the Fusion AG Grid theme utilities from
6
+ * `@equinor/fusion-framework-module-ag-grid/themes`, together with the
7
+ * application-scoped {@link useTheme} hook.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+ export { fusionTheme, createThemeFromTheme, createTheme, } from '@equinor/fusion-framework-module-ag-grid/themes';
12
+ export type { Theme } from '@equinor/fusion-framework-module-ag-grid/themes';
13
+ export { useTheme } from './useTheme';
@@ -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.3";
1
+ export declare const version = "14.1.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.3",
3
+ "version": "14.1.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": {
@@ -68,6 +69,26 @@
68
69
  "./routing": {
69
70
  "types": "./dist/types/routing/index.d.ts",
70
71
  "import": "./dist/esm/routing/index.js"
72
+ },
73
+ "./ag-grid": {
74
+ "types": "./dist/types/ag-grid/react.d.ts",
75
+ "import": "./dist/esm/ag-grid/react.js"
76
+ },
77
+ "./ag-grid/community": {
78
+ "types": "./dist/types/ag-grid/community.d.ts",
79
+ "import": "./dist/esm/ag-grid/community.js"
80
+ },
81
+ "./ag-grid/enterprise": {
82
+ "types": "./dist/types/ag-grid/enterprise.d.ts",
83
+ "import": "./dist/esm/ag-grid/enterprise.js"
84
+ },
85
+ "./ag-grid/theme": {
86
+ "types": "./dist/types/ag-grid/theme.d.ts",
87
+ "import": "./dist/esm/ag-grid/theme.js"
88
+ },
89
+ "./ag-grid/testing": {
90
+ "types": "./dist/types/ag-grid/testing.d.ts",
91
+ "import": "./dist/esm/ag-grid/testing.js"
71
92
  }
72
93
  },
73
94
  "typesVersions": {
@@ -116,6 +137,21 @@
116
137
  ],
117
138
  "routing": [
118
139
  "dist/types/routing/index.d.ts"
140
+ ],
141
+ "ag-grid": [
142
+ "dist/types/ag-grid/react.d.ts"
143
+ ],
144
+ "ag-grid/community": [
145
+ "dist/types/ag-grid/community.d.ts"
146
+ ],
147
+ "ag-grid/enterprise": [
148
+ "dist/types/ag-grid/enterprise.d.ts"
149
+ ],
150
+ "ag-grid/theme": [
151
+ "dist/types/ag-grid/theme.d.ts"
152
+ ],
153
+ "ag-grid/testing": [
154
+ "dist/types/ag-grid/testing.d.ts"
119
155
  ]
120
156
  }
121
157
  },
@@ -131,46 +167,62 @@
131
167
  "directory": "packages/react"
132
168
  },
133
169
  "dependencies": {
134
- "@equinor/fusion-framework-app": "13.0.3",
135
- "@equinor/fusion-framework-module": "6.1.2",
136
- "@equinor/fusion-framework-module-http": "8.0.5",
137
- "@equinor/fusion-framework-module-app": "8.0.5",
138
- "@equinor/fusion-framework-module-navigation": "7.0.8",
139
- "@equinor/fusion-framework-react": "8.0.1",
140
- "@equinor/fusion-framework-react-module": "4.0.2",
141
- "@equinor/fusion-framework-react-module-http": "11.0.1"
170
+ "@equinor/fusion-framework-app": "13.1.0",
171
+ "@equinor/fusion-framework": "8.1.0",
172
+ "@equinor/fusion-framework-module": "6.1.3",
173
+ "@equinor/fusion-framework-module-app": "8.1.0",
174
+ "@equinor/fusion-framework-react-module": "4.0.3",
175
+ "@equinor/fusion-framework-react": "9.0.0",
176
+ "@equinor/fusion-framework-module-navigation": "7.0.9",
177
+ "@equinor/fusion-framework-module-http": "8.1.0",
178
+ "@equinor/fusion-framework-react-module-http": "12.0.0"
142
179
  },
143
180
  "devDependencies": {
144
- "@testing-library/react": "^16.0.0",
181
+ "@remix-run/router": "^1.23.3",
145
182
  "@types/react": "^19.2.7",
146
183
  "@types/react-dom": "^19.2.3",
147
- "happy-dom": "^20.8.4",
184
+ "@vitest/browser-playwright": "^4.1.0",
185
+ "playwright": "^1.62.1",
148
186
  "react": "^19.2.1",
149
187
  "react-dom": "^19.2.1",
150
188
  "rxjs": "^7.8.1",
151
189
  "typescript": "^7.0.2",
152
- "vitest": "^4.1.0",
153
- "@equinor/fusion-framework-module-event": "6.0.1",
154
- "@equinor/fusion-framework-module-analytics": "3.0.8",
155
- "@equinor/fusion-framework-module-ag-grid": "37.0.2",
156
- "@equinor/fusion-framework-module-msal": "10.0.2",
157
- "@equinor/fusion-framework-module-feature-flag": "2.0.3",
158
- "@equinor/fusion-framework-react-module-bookmark": "6.0.1",
159
- "@equinor/fusion-framework-module-state": "^2.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.2.0"
190
+ "vitest": "^4.1.10",
191
+ "vitest-browser-react": "^2.2.0",
192
+ "@equinor/fusion-framework-react-ag-grid": "37.1.0",
193
+ "@equinor/fusion-framework-module-ag-grid": "37.1.0",
194
+ "@equinor/fusion-framework-module-context": "9.0.0",
195
+ "@equinor/fusion-framework-module-event": "6.1.0",
196
+ "@equinor/fusion-framework-module-analytics": "3.1.0",
197
+ "@equinor/fusion-framework-module-bookmark": "4.1.0",
198
+ "@equinor/fusion-framework-module-feature-flag": "2.1.0",
199
+ "@equinor/fusion-framework-module-msal": "11.0.0",
200
+ "@equinor/fusion-framework-module-state": "^2.0.2",
201
+ "@equinor/fusion-framework-module-telemetry": "7.1.0",
202
+ "@equinor/fusion-framework-react-module-bookmark": "7.0.0",
203
+ "@equinor/fusion-framework-react-module-context": "7.0.3",
204
+ "@equinor/fusion-framework-react-router": "2.4.1",
205
+ "@equinor/fusion-framework-vitest-plugin-react-app": "1.0.0",
206
+ "@equinor/fusion-observable": "9.2.1"
163
207
  },
164
208
  "peerDependencies": {
165
209
  "@types/react": "^18.0.0 || ^19.0.0",
166
210
  "react": "^18.0.0 || ^19.0.0",
167
211
  "react-dom": "^18.0.0 || ^19.0.0",
168
212
  "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.1"
213
+ "@equinor/fusion-framework-react-ag-grid": "^37.1.0",
214
+ "@equinor/fusion-framework-module-state": "^2.0.2",
215
+ "@equinor/fusion-framework-module-ag-grid": "^37.1.0",
216
+ "@equinor/fusion-framework-react-router": "^2.4.1",
217
+ "@equinor/fusion-framework-module-msal": "^11.0.0"
172
218
  },
173
219
  "peerDependenciesMeta": {
220
+ "@equinor/fusion-framework-module-ag-grid": {
221
+ "optional": true
222
+ },
223
+ "@equinor/fusion-framework-react-ag-grid": {
224
+ "optional": true
225
+ },
174
226
  "@equinor/fusion-framework-react-module-ag-grid": {
175
227
  "optional": true
176
228
  },
@@ -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,76 @@
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
+ import { useFramework } from '@equinor/fusion-framework-react';
7
+ import { createHistory, enableNavigation } from '@equinor/fusion-framework-module-navigation';
8
+ import type { NavigationModule } from '@equinor/fusion-framework-module-navigation';
9
+
10
+ import { testApp } from '@equinor/fusion-framework-vitest-plugin-react-app/test';
11
+
12
+ const projectA: ContextItem = {
13
+ id: 'ctx-a',
14
+ title: 'Project A',
15
+ type: { id: 'ProjectMaster' },
16
+ value: {},
17
+ };
18
+
19
+ const Greeting = () => <p>hello</p>;
20
+
21
+ const CurrentContext = () => {
22
+ const { currentContext } = useCurrentContext();
23
+ return <p>{currentContext?.title ?? 'none'}</p>;
24
+ };
25
+
26
+ describe('testApp', () => {
27
+ testApp(
28
+ 'resolves a fusion/app scope and renders through the public /vitest entry point',
29
+ async ({ render, fusion, app }) => {
30
+ const screen = await render(<Greeting />);
31
+ await expect.element(screen.getByText('hello')).toBeVisible();
32
+ expect(fusion).toBeDefined();
33
+ expect(app).toBeDefined();
34
+ },
35
+ );
36
+
37
+ describe('with a seeded context module', () => {
38
+ const test = testApp.extend(
39
+ 'configureApp',
40
+ { injected: true },
41
+ () => (configurator) =>
42
+ enableContextMock(configurator, (mock) => mock.setCurrentContext(projectA)),
43
+ );
44
+
45
+ test('starts on the seeded context', async ({ render }) => {
46
+ const screen = await render(<CurrentContext />);
47
+ await expect.element(screen.getByText(projectA.title as string)).toBeVisible();
48
+ });
49
+ });
50
+
51
+ describe('with a configured parent framework', () => {
52
+ const NavigationLocation = () => {
53
+ const { navigation } = useFramework<[NavigationModule]>().modules;
54
+ return <p>{navigation.history.location.pathname}</p>;
55
+ };
56
+
57
+ // pushed on the history instance itself, before it's handed to the framework configurator,
58
+ // so a passing assertion also proves `configureFusion` ran ahead of framework initialization
59
+ const history = createHistory('memory');
60
+ history.push('/configured-by-configure-fusion');
61
+
62
+ const test = testApp.extend(
63
+ 'configureFusion',
64
+ { injected: true },
65
+ () => (configurator) =>
66
+ enableNavigation(configurator, { configure: (config) => config.setHistory(history) }),
67
+ );
68
+
69
+ test('resolves the fusion instance with the configured navigation history', async ({
70
+ render,
71
+ }) => {
72
+ const screen = await render(<NavigationLocation />);
73
+ await expect.element(screen.getByText('/configured-by-configure-fusion')).toBeVisible();
74
+ });
75
+ });
76
+ });
@@ -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
+ });