@equinor/fusion-framework-app 13.0.0 → 14.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +35 -65
  3. package/dist/esm/__tests__/mock/AppMockConfigurator.test.js +70 -0
  4. package/dist/esm/__tests__/mock/AppMockConfigurator.test.js.map +1 -0
  5. package/dist/esm/__tests__/mock/mock-app.test.js +86 -0
  6. package/dist/esm/__tests__/mock/mock-app.test.js.map +1 -0
  7. package/dist/esm/__tests__/mock/msal-hoisting.test.js +36 -0
  8. package/dist/esm/__tests__/mock/msal-hoisting.test.js.map +1 -0
  9. package/dist/esm/configure-modules.js +2 -42
  10. package/dist/esm/configure-modules.js.map +1 -1
  11. package/dist/esm/initialize-app-modules.js +65 -0
  12. package/dist/esm/initialize-app-modules.js.map +1 -0
  13. package/dist/esm/mock/AppMockConfigurator.js +183 -0
  14. package/dist/esm/mock/AppMockConfigurator.js.map +1 -0
  15. package/dist/esm/mock/enable-app-manifest-mock.js +49 -0
  16. package/dist/esm/mock/enable-app-manifest-mock.js.map +1 -0
  17. package/dist/esm/mock/index.js +21 -0
  18. package/dist/esm/mock/index.js.map +1 -0
  19. package/dist/esm/mock/mock-app-modules.js +82 -0
  20. package/dist/esm/mock/mock-app-modules.js.map +1 -0
  21. package/dist/esm/version.js +1 -1
  22. package/dist/esm/version.js.map +1 -1
  23. package/dist/tsconfig.tsbuildinfo +1 -1
  24. package/dist/types/__tests__/mock/AppMockConfigurator.test.d.ts +1 -0
  25. package/dist/types/__tests__/mock/mock-app.test.d.ts +1 -0
  26. package/dist/types/__tests__/mock/msal-hoisting.test.d.ts +1 -0
  27. package/dist/types/initialize-app-modules.d.ts +31 -0
  28. package/dist/types/mock/AppMockConfigurator.d.ts +142 -0
  29. package/dist/types/mock/enable-app-manifest-mock.d.ts +33 -0
  30. package/dist/types/mock/index.d.ts +20 -0
  31. package/dist/types/mock/mock-app-modules.d.ts +72 -0
  32. package/dist/types/version.d.ts +1 -1
  33. package/docs/bookmarks.md +18 -0
  34. package/docs/http-clients.md +71 -0
  35. package/docs/testing.md +105 -0
  36. package/package.json +22 -13
  37. package/src/__tests__/mock/AppMockConfigurator.test.ts +96 -0
  38. package/src/__tests__/mock/mock-app.test.ts +112 -0
  39. package/src/__tests__/mock/msal-hoisting.test.ts +54 -0
  40. package/src/configure-modules.ts +2 -52
  41. package/src/initialize-app-modules.ts +98 -0
  42. package/src/mock/AppMockConfigurator.ts +218 -0
  43. package/src/mock/enable-app-manifest-mock.ts +62 -0
  44. package/src/mock/index.ts +21 -0
  45. package/src/mock/mock-app-modules.ts +109 -0
  46. package/src/version.ts +1 -1
  47. package/vitest.config.ts +1 -1
@@ -0,0 +1,96 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { AppConfig } from '@equinor/fusion-framework-module-app';
4
+ import { enableTelemetry } from '@equinor/fusion-framework-module-telemetry';
5
+
6
+ import { AppConfigurator } from '../../AppConfigurator.js';
7
+ import { AppMockConfigurator } from '../../mock/AppMockConfigurator.js';
8
+
9
+ const mockEnv = {
10
+ manifest: {
11
+ appKey: 'test-app',
12
+ displayName: 'Test App',
13
+ description: 'A test application',
14
+ type: 'standalone' as const,
15
+ build: {
16
+ version: '1.0.0',
17
+ entryPoint: 'index.js',
18
+ },
19
+ },
20
+ };
21
+
22
+ describe('AppMockConfigurator', () => {
23
+ it('is a real AppConfigurator', () => {
24
+ const configurator = new AppMockConfigurator(mockEnv);
25
+
26
+ expect(configurator).toBeInstanceOf(AppMockConfigurator);
27
+ expect(configurator).toBeInstanceOf(AppConfigurator);
28
+ });
29
+
30
+ it('constructs without throwing when env.config declares endpoints', async () => {
31
+ // the base AppConfigurator constructor auto-registers these via addConfig,
32
+ // before this class's own fields (#pinnedModules) are initialized
33
+ const env = {
34
+ ...mockEnv,
35
+ config: new AppConfig({
36
+ endpoints: { status: { url: 'https://status.example.com', scopes: [] } },
37
+ }),
38
+ };
39
+
40
+ const configurator = new AppMockConfigurator(env);
41
+ configurator.http.addMiddleware(async (uri, init, next) =>
42
+ uri === 'https://status.example.com/health' ? Response.json({ ok: true }) : next(uri, init),
43
+ );
44
+
45
+ enableTelemetry(configurator);
46
+ const modules = await configurator.initialize();
47
+
48
+ await expect(modules.http.createClient('status').json('/health')).resolves.toEqual({
49
+ ok: true,
50
+ });
51
+ });
52
+
53
+ it('exposes the same http configurator the http module is built from', async () => {
54
+ const configurator = new AppMockConfigurator(mockEnv);
55
+
56
+ configurator.http.configureClient('catalog', { baseUri: 'https://api.example.com' });
57
+ configurator.http.addMiddleware(async (uri, init, next) =>
58
+ uri === 'https://api.example.com/items' ? Response.json([{ id: 1 }]) : next(uri, init),
59
+ );
60
+
61
+ // msal's config schema requires a telemetry module, normally wired by configureModules
62
+ enableTelemetry(configurator);
63
+ const modules = await configurator.initialize();
64
+
65
+ await expect(modules.http.createClient('catalog').json('/items')).resolves.toEqual([{ id: 1 }]);
66
+ });
67
+
68
+ it('exposes the same msal configurator the auth module is built from', async () => {
69
+ const configurator = new AppMockConfigurator(mockEnv);
70
+
71
+ configurator.msal.setAccount({ name: 'Ada Lovelace' });
72
+
73
+ // msal's config schema requires a telemetry module, normally wired by configureModules
74
+ enableTelemetry(configurator);
75
+ const modules = await configurator.initialize();
76
+
77
+ expect(modules.auth.account?.name).toBe('Ada Lovelace');
78
+ });
79
+
80
+ it('answers a client registered through configureHttpClient via addMiddleware', async () => {
81
+ const configurator = new AppMockConfigurator(mockEnv);
82
+
83
+ configurator.configureHttpClient('status-api', { baseUri: 'https://status.example.com' });
84
+ configurator.http.addMiddleware(async (uri, init, next) =>
85
+ uri === 'https://status.example.com/status' ? Response.json({ ok: true }) : next(uri, init),
86
+ );
87
+
88
+ // msal's config schema requires a telemetry module, normally wired by configureModules
89
+ enableTelemetry(configurator);
90
+ const modules = await configurator.initialize();
91
+
92
+ await expect(modules.http.createClient('status-api').json('/status')).resolves.toEqual({
93
+ ok: true,
94
+ });
95
+ });
96
+ });
@@ -0,0 +1,112 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import type { Fusion } from '@equinor/fusion-framework';
4
+ import { mockFramework } from '@equinor/fusion-framework/mock';
5
+ import { AppConfig, type AppModule } from '@equinor/fusion-framework-module-app';
6
+
7
+ import { AppMockConfigurator } from '../../mock/AppMockConfigurator.js';
8
+ import { mockAppModules } from '../../mock/mock-app-modules.js';
9
+
10
+ const env = {
11
+ manifest: {
12
+ appKey: 'test-app',
13
+ displayName: 'Test App',
14
+ description: 'A test application',
15
+ type: 'standalone' as const,
16
+ build: {
17
+ version: '1.0.0',
18
+ entryPoint: 'index.js',
19
+ },
20
+ },
21
+ config: new AppConfig({ environment: { foo: 'bar' } }),
22
+ };
23
+
24
+ describe('mockApp', () => {
25
+ it('initializes the app module pipeline with no configuration', async () => {
26
+ const modules = await mockAppModules(undefined, env);
27
+
28
+ expect(modules.event).toBeDefined();
29
+ expect(modules.auth).toBeDefined();
30
+ expect(modules.http).toBeDefined();
31
+ });
32
+
33
+ it('passes a real AppConfigurator to the callback', async () => {
34
+ expect.assertions(1);
35
+
36
+ await mockAppModules((configurator) => {
37
+ expect(configurator).toBeInstanceOf(AppMockConfigurator);
38
+ }, env);
39
+ });
40
+
41
+ it('answers a service-discovery-resolved client from the app’s own mocked http module', async () => {
42
+ const modules = await mockAppModules((configurator) => {
43
+ configurator.useFrameworkServiceClient('portal-api');
44
+ // matches against the full resolved URL, so the host is part of the match to keep this
45
+ // from also answering a different client's request
46
+ configurator.http.addMiddleware(async (uri, init, next) =>
47
+ uri === 'https://portal-api.fusion.test/items'
48
+ ? Response.json([{ id: 1 }])
49
+ : next(uri, init),
50
+ );
51
+ }, env);
52
+
53
+ await expect(modules.http.createClient('portal-api').json('/items')).resolves.toEqual([
54
+ { id: 1 },
55
+ ]);
56
+ });
57
+
58
+ it('reuses an already-mocked fusion instance instead of creating a new one', async () => {
59
+ expect.assertions(1);
60
+
61
+ const fusion = await mockFramework((configurator) => {
62
+ configurator.msal.setAccount({ name: 'Ada Lovelace' });
63
+ });
64
+
65
+ await mockAppModules(
66
+ (_configurator, { fusion: parent }) => {
67
+ expect(parent.modules.auth.account?.name).toBe('Ada Lovelace');
68
+ },
69
+ env,
70
+ fusion,
71
+ );
72
+ });
73
+
74
+ it('awaits an async configure callback before initialize resolves', async () => {
75
+ const modules = await mockAppModules(async (configurator) => {
76
+ await Promise.resolve();
77
+ configurator.msal.setAccount({ name: 'Ada Lovelace' });
78
+ }, env);
79
+
80
+ expect(modules.auth.account?.name).toBe('Ada Lovelace');
81
+ });
82
+
83
+ it('serves this app’s own manifest and config through the default parent’s app module', async () => {
84
+ expect.assertions(2);
85
+
86
+ await mockAppModules(async (_configurator, { fusion }) => {
87
+ // the default parent always has `app` enabled; typed as plain `Fusion` since callers
88
+ // may pass in a parent without it, so the module set is narrowed just for this assertion
89
+ const { app } = (fusion as Fusion<[AppModule]>).modules;
90
+ app.setCurrentApp(env.manifest.appKey);
91
+
92
+ await expect(app.current?.getManifestAsync()).resolves.toMatchObject({
93
+ appKey: env.manifest.appKey,
94
+ displayName: env.manifest.displayName,
95
+ });
96
+ await expect(app.current?.getConfigAsync()).resolves.toMatchObject({
97
+ environment: env.config.environment,
98
+ });
99
+ }, env);
100
+ });
101
+
102
+ it('falls through to the real client for a manifest not matching this app’s own', async () => {
103
+ expect.assertions(1);
104
+
105
+ await mockAppModules(async (_configurator, { fusion }) => {
106
+ const { app } = (fusion as Fusion<[AppModule]>).modules;
107
+ app.setCurrentApp('some-other-app');
108
+
109
+ await expect(app.current?.getManifestAsync()).rejects.toThrow();
110
+ }, env);
111
+ });
112
+ });
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { mockFramework } from '@equinor/fusion-framework/mock';
4
+ import type { AuthenticationResult } from '@equinor/fusion-framework-module-msal';
5
+
6
+ import { mockAppModules } from '../../mock/mock-app-modules.js';
7
+
8
+ const env = {
9
+ manifest: {
10
+ appKey: 'test-app',
11
+ displayName: 'Test App',
12
+ description: 'A test application',
13
+ type: 'standalone' as const,
14
+ },
15
+ };
16
+
17
+ describe('msal hoisting', () => {
18
+ it('delegates acquireToken to the parent’s auth module instead of building its own client', async () => {
19
+ const fusion = await mockFramework();
20
+ const result = { accessToken: 'parent-issued-token' } as AuthenticationResult;
21
+ vi.spyOn(fusion.modules.auth, 'acquireToken').mockResolvedValue(result);
22
+
23
+ const modules = await mockAppModules(undefined, env, fusion);
24
+
25
+ // the app's own `auth` is a distinct (proxying) object, not the parent's instance itself
26
+ expect(modules.auth).not.toBe(fusion.modules.auth);
27
+ await expect(
28
+ modules.auth.acquireToken({ request: { scopes: ['User.Read'] } }),
29
+ ).resolves.toMatchObject({ accessToken: 'parent-issued-token' });
30
+ });
31
+
32
+ it('surfaces the parent’s acquisition failures instead of falling back to its own client', async () => {
33
+ const fusion = await mockFramework();
34
+ vi.spyOn(fusion.modules.auth, 'acquireToken').mockRejectedValue(
35
+ new Error('acquisition failed'),
36
+ );
37
+
38
+ const modules = await mockAppModules(undefined, env, fusion);
39
+
40
+ await expect(modules.auth.acquireToken({ request: { scopes: ['User.Read'] } })).rejects.toThrow(
41
+ 'acquisition failed',
42
+ );
43
+ });
44
+
45
+ it('reflects the parent’s signed-in account rather than signing in its own', async () => {
46
+ const fusion = await mockFramework((configurator) => {
47
+ configurator.msal.setAccount({ name: 'Ada Lovelace' });
48
+ });
49
+
50
+ const modules = await mockAppModules(undefined, env, fusion);
51
+
52
+ expect(modules.auth.account?.name).toBe('Ada Lovelace');
53
+ });
54
+ });
@@ -7,14 +7,11 @@
7
7
 
8
8
  import type { Fusion } from '@equinor/fusion-framework';
9
9
  import type { AnyModule } from '@equinor/fusion-framework-module';
10
- import {
11
- enableTelemetry,
12
- type MetadataExtractor,
13
- } from '@equinor/fusion-framework-module-telemetry';
14
10
 
15
11
  import { AppConfigurator } from './AppConfigurator';
16
12
 
17
13
  import type { AppModulesInstance, AppModuleInitiator, AppEnv } from './types';
14
+ import { initializeAppModules } from './initialize-app-modules';
18
15
 
19
16
  /**
20
17
  * Create an application module initializer for a Fusion application.
@@ -66,56 +63,9 @@ export const configureModules =
66
63
  * @returns The fully initialized application module instance.
67
64
  */
68
65
  async (args: { fusion: TRef; env: TEnv }): Promise<AppModulesInstance<TModules>> => {
69
- const { fusion } = args;
70
-
71
66
  // Create app configurator
72
67
  const configurator = new AppConfigurator<TModules, TRef['modules'], TEnv>(args.env);
73
-
74
- // Extract telemetry metadata from app manifest for tracking and debugging
75
- const metadataExtractor: MetadataExtractor = () => {
76
- return {
77
- fusion: {
78
- type: 'app-telemetry',
79
- app: {
80
- key: args.env.manifest?.appKey || 'unknown-app',
81
- version: args.env.manifest?.build?.version || 'unknown-version',
82
- },
83
- },
84
- };
85
- };
86
-
87
- // Enable telemetry collection for module configuration events
88
- // attachConfiguratorEvents automatically prefixes events with configurator class name
89
- enableTelemetry(configurator, {
90
- attachConfiguratorEvents: true,
91
- configure: (builder) => {
92
- builder.setMetadata(metadataExtractor);
93
- builder.setParent(fusion.modules.telemetry);
94
- // Scope telemetry to 'app' level for app-specific event filtering
95
- builder.setDefaultScope(['app']);
96
- },
97
- });
98
-
99
- // Allow user configuration callback to run before module initialization
100
- if (cb) {
101
- await Promise.resolve(cb(configurator, args));
102
- }
103
- // Type cast is safe because AppConfigurator.initialize() returns the exact module
104
- // instance that was registered and configured above. The intermediate 'unknown'
105
- // cast is necessary due to TypeScript's generic inference limitations with the
106
- // configurator's initialization chain, but the runtime value is guaranteed to match.
107
- const modules: AppModulesInstance<TModules> = (await configurator.initialize(
108
- args.fusion.modules,
109
- )) as unknown as AppModulesInstance<TModules>;
110
-
111
- // Dispatch app modules loaded event for app lifecycle tracking
112
- // TODO(#5061): remove check after fusion-cli is updated (app module is not enabled in fusion-cli)
113
- if (args.env.manifest?.appKey) {
114
- modules.event.dispatchEvent('onAppModulesLoaded', {
115
- detail: { appKey: args.env.manifest.appKey, manifest: args.env.manifest, modules },
116
- });
117
- }
118
- return modules;
68
+ return initializeAppModules(configurator, cb, args);
119
69
  };
120
70
 
121
71
  export default configureModules;
@@ -0,0 +1,98 @@
1
+ import type { Fusion } from '@equinor/fusion-framework';
2
+ import type { AnyModule } from '@equinor/fusion-framework-module';
3
+ import {
4
+ enableTelemetry,
5
+ type MetadataExtractor,
6
+ } from '@equinor/fusion-framework-module-telemetry';
7
+
8
+ import type { AppConfigurator } from './AppConfigurator';
9
+ import type { AppModulesInstance, AppEnv } from './types';
10
+
11
+ /**
12
+ * Runs the telemetry wiring, the caller's configuration callback and module
13
+ * initialization against an already constructed configurator.
14
+ *
15
+ * @remarks
16
+ * Extracted so `mockAppModules` (`@equinor/fusion-framework-app/mock`) can drive the
17
+ * exact same pipeline against an `AppMockConfigurator` instead of reimplementing
18
+ * it — the same way `FrameworkConfigurator` and `FrameworkMockConfigurator`
19
+ * share the framework's `init`.
20
+ *
21
+ * @param configurator - The (real or mock) app configurator to run the pipeline on.
22
+ * @param cb - Configuration callback invoked before module initialization, or `undefined` to skip it.
23
+ * @param args - Object containing the Fusion instance and the application environment.
24
+ * @returns The fully initialized application module instance.
25
+ * @template TModules - Application module descriptors beyond the default set.
26
+ * @template TRef - The parent Fusion instance type.
27
+ * @template TEnv - The application environment descriptor.
28
+ * @template TConfigurator - The (real or mock) `AppConfigurator` subclass driving the pipeline.
29
+ */
30
+ export async function initializeAppModules<
31
+ TModules extends Array<AnyModule> | never,
32
+ TRef extends Fusion = Fusion,
33
+ TEnv extends AppEnv = AppEnv,
34
+ // Widened beyond the plain `AppConfigurator` so callers such as `mockAppModules` can
35
+ // drive the same pipeline against a subclass (e.g. `AppMockConfigurator`) and have `cb`
36
+ // typed against that subclass rather than the base `IAppConfigurator` interface.
37
+ TConfigurator extends AppConfigurator<TModules, TRef['modules'], TEnv> = AppConfigurator<
38
+ TModules,
39
+ TRef['modules'],
40
+ TEnv
41
+ >,
42
+ >(
43
+ configurator: TConfigurator,
44
+ cb:
45
+ | ((configurator: TConfigurator, args: { fusion: TRef; env: TEnv }) => void | Promise<void>)
46
+ | undefined,
47
+ args: { fusion: TRef; env: TEnv },
48
+ ): Promise<AppModulesInstance<TModules>> {
49
+ const { fusion } = args;
50
+
51
+ // Extract telemetry metadata from app manifest for tracking and debugging
52
+ const metadataExtractor: MetadataExtractor = () => {
53
+ return {
54
+ fusion: {
55
+ type: 'app-telemetry',
56
+ app: {
57
+ key: args.env.manifest?.appKey || 'unknown-app',
58
+ version: args.env.manifest?.build?.version || 'unknown-version',
59
+ },
60
+ },
61
+ };
62
+ };
63
+
64
+ // Enable telemetry collection for module configuration events
65
+ // attachConfiguratorEvents automatically prefixes events with configurator class name
66
+ enableTelemetry(configurator, {
67
+ attachConfiguratorEvents: true,
68
+ configure: (builder) => {
69
+ builder.setMetadata(metadataExtractor);
70
+ builder.setParent(fusion.modules.telemetry);
71
+ // Scope telemetry to 'app' level for app-specific event filtering
72
+ builder.setDefaultScope(['app']);
73
+ },
74
+ });
75
+
76
+ // Allow user configuration callback to run before module initialization
77
+ if (cb) {
78
+ await Promise.resolve(cb(configurator, args));
79
+ }
80
+ // Type cast is safe because AppConfigurator.initialize() returns the exact module
81
+ // instance that was registered and configured above. The intermediate 'unknown'
82
+ // cast is necessary due to TypeScript's generic inference limitations with the
83
+ // configurator's initialization chain, but the runtime value is guaranteed to match.
84
+ const modules: AppModulesInstance<TModules> = (await configurator.initialize(
85
+ args.fusion.modules,
86
+ )) as unknown as AppModulesInstance<TModules>;
87
+
88
+ // Dispatch app modules loaded event for app lifecycle tracking
89
+ // TODO(#5061): remove check after fusion-cli is updated (app module is not enabled in fusion-cli)
90
+ if (args.env.manifest?.appKey) {
91
+ modules.event.dispatchEvent('onAppModulesLoaded', {
92
+ detail: { appKey: args.env.manifest.appKey, manifest: args.env.manifest, modules },
93
+ });
94
+ }
95
+ return modules;
96
+ }
97
+
98
+ export default initializeAppModules;
@@ -0,0 +1,218 @@
1
+ import type { FusionModulesInstance } from '@equinor/fusion-framework';
2
+
3
+ import type {
4
+ AnyModule,
5
+ IModuleConfigurator,
6
+ ModuleConfigType,
7
+ } from '@equinor/fusion-framework-module';
8
+
9
+ import http, { type IHttpClientConfigurator } from '@equinor/fusion-framework-module-http';
10
+ import {
11
+ msalMockModule,
12
+ type MsalMockConfigurator,
13
+ } from '@equinor/fusion-framework-module-msal/mock';
14
+
15
+ import { AppConfigurator } from '../AppConfigurator.js';
16
+ import type { AppEnv } from '../types.js';
17
+
18
+ /**
19
+ * The real `AppConfigurator`, with the `msal` module it registers backed by
20
+ * the same test double `FrameworkMockConfigurator` uses. `http` is the real
21
+ * module — fake a response by registering a short-circuiting middleware
22
+ * through `.http.addMiddleware(...)` instead of swapping the module out.
23
+ *
24
+ * @remarks
25
+ * Nothing else changes: the same module set (`event`, `http`, `msal`), the same
26
+ * configuration pipeline and the same lifecycle are used. `configureHttpClient`,
27
+ * `useFrameworkServiceClient` and any callback written for a real
28
+ * `AppConfigurator` work against this unchanged.
29
+ *
30
+ * `http` and `msal` are pinned early — mirroring `FrameworkMockConfigurator`,
31
+ * one level down — so `.http` and `.msal` are reachable synchronously, before
32
+ * `useFrameworkServiceClient` or a `configureModules` callback ever runs.
33
+ * `event` is deliberately not pinned, for the same reason it isn't in
34
+ * `FrameworkMockConfigurator`: its `configure` factory reads `ref` to wire
35
+ * bubbling to a parent event provider, and pinning would freeze that decision
36
+ * before a `ref` could ever be known.
37
+ *
38
+ * @typeParam TModules - Module descriptors beyond the default set. Supply this
39
+ * when a test registers application modules, so they are typed on the result.
40
+ * @typeParam TRef - The resolved Fusion modules instance used as a reference during initialization.
41
+ * @typeParam TEnv - The application environment descriptor.
42
+ *
43
+ * @example
44
+ * ```typescript
45
+ * const manifest = { appKey: 'my-app', displayName: 'My App', description: 'My app', type: 'standalone' } as const;
46
+ * const configurator = new AppMockConfigurator({ manifest });
47
+ *
48
+ * configurator.configureHttpClient('catalog', { baseUri: 'https://api.example.com' });
49
+ * configurator.http.addMiddleware(async (uri, init, next) =>
50
+ * uri === 'https://api.example.com/items' ? Response.json([{ id: 1 }]) : next(uri, init),
51
+ * );
52
+ * ```
53
+ */
54
+ export class AppMockConfigurator<
55
+ TModules extends Array<AnyModule> | unknown = unknown,
56
+ TRef extends FusionModulesInstance = FusionModulesInstance,
57
+ TEnv extends AppEnv = AppEnv,
58
+ > extends AppConfigurator<TModules, TRef, TEnv> {
59
+ static override readonly className: string = 'AppMockConfigurator';
60
+
61
+ // Keyed by module name, so `_getConfig` can look a pinned configurator up
62
+ // without needing the module descriptor again.
63
+ #configurators = new Map<string, unknown>();
64
+
65
+ // Keyed by module name, so `addConfig` can redirect a registration at the
66
+ // pinned descriptor instead of the unpinned one it was given.
67
+ #pinnedModules = new Map<string, AnyModule>();
68
+
69
+ /**
70
+ * Creates an app configurator backed by the built-in mock modules.
71
+ *
72
+ * @param env - The application environment containing manifest, config, and optional basename.
73
+ */
74
+ constructor(env: TEnv) {
75
+ super(env);
76
+
77
+ // Pinning up front replaces the modules AppConfigurator's own constructor
78
+ // already registered, whether or not a test ever touches the accessor.
79
+ this._pin(http);
80
+ this._pin(msalMockModule);
81
+
82
+ // deferred from AppConfigurator's own constructor (see the override below) until
83
+ // after pinning, so endpoint-derived clients register against the pinned http module
84
+ super._configureHttpClientsFromAppConfig();
85
+ }
86
+
87
+ /**
88
+ * No-ops the base constructor's own call to this, since it would otherwise run
89
+ * before {@link _pin} has anything to redirect `addConfig` at; this class calls
90
+ * {@link AppConfigurator._configureHttpClientsFromAppConfig} itself once pinned.
91
+ */
92
+ protected override _configureHttpClientsFromAppConfig(): void {}
93
+
94
+ /**
95
+ * Registers a module configurator, redirecting registrations for a pinned module
96
+ * at its pinned descriptor.
97
+ *
98
+ * @remarks
99
+ * `configureHttpClient`, `useFrameworkServiceClient` and similar helpers always
100
+ * pass the real, unpinned module descriptor — the base `addConfig` replaces a
101
+ * module's descriptor whenever it doesn't recognize the object it's given, even
102
+ * under the same name, which would otherwise silently un-pin it.
103
+ *
104
+ * @param config - The module configurator descriptor to register.
105
+ * @template T - The module type being configured.
106
+ * @template TConfig - The resolved configuration type for the module.
107
+ */
108
+ public override addConfig<T extends AnyModule, TConfig = ModuleConfigType<T>>(
109
+ config: IModuleConfigurator<T, TRef, TConfig>,
110
+ ): void {
111
+ const pinnedModule = this.#pinnedModules.get(config.module.name) as T | undefined;
112
+ super.addConfig(pinnedModule ? { ...config, module: pinnedModule } : config);
113
+ }
114
+
115
+ /**
116
+ * Pins a module to a single configurator instance for the lifetime of this
117
+ * configurator, so it can be reached by name through {@link _getConfig}.
118
+ *
119
+ * @remarks
120
+ * The module system otherwise builds a fresh configurator from its own
121
+ * `configure` factory during the configure phase — too late for a test to
122
+ * reach, and a new instance on every call besides. This replaces that factory
123
+ * with one that always returns the same instance, and registers the result
124
+ * under the module's own name.
125
+ *
126
+ * An application module supplied through {@link TModules} uses this the same
127
+ * way `.http` and `.msal` do, to expose its own named accessor:
128
+ *
129
+ * ```typescript
130
+ * class MyAppMockConfigurator extends AppMockConfigurator<[WidgetsModule]> {
131
+ * constructor(env: AppEnv) {
132
+ * super(env);
133
+ * this._pin(widgetsMockModule);
134
+ * }
135
+ *
136
+ * public get widgets(): WidgetsMockConfigurator {
137
+ * return this._getConfig('widgets');
138
+ * }
139
+ * }
140
+ * ```
141
+ *
142
+ * @param module - The module descriptor to pin a configurator for.
143
+ * @template TModule - The specific module descriptor type being pinned.
144
+ * @throws {Error} If the module declares no `configure` factory to pin, or
145
+ * the factory returns a promise instead of a configurator — pinning is
146
+ * synchronous, so a test can reach the accessor immediately.
147
+ */
148
+ protected _pin<TModule extends AnyModule>(module: TModule): void {
149
+ // A module without a configure factory has nothing this method could pin
150
+ if (!module.configure) {
151
+ throw new Error(`Cannot pin "${module.name}": it declares no configure factory.`);
152
+ }
153
+ const instance = module.configure();
154
+ // Async factories would make the pinned instance unavailable until the module system
155
+ // resolves it later, defeating the point of pinning it for immediate synchronous access
156
+ if (instance instanceof Promise) {
157
+ throw new Error(
158
+ `Cannot pin "${module.name}": its configure factory returns a promise, so it cannot be resolved synchronously.`,
159
+ );
160
+ }
161
+ this.#configurators.set(module.name, instance);
162
+ const pinnedModule = { ...module, configure: () => instance } as TModule;
163
+ this.#pinnedModules.set(module.name, pinnedModule);
164
+ this.addConfig({ module: pinnedModule });
165
+ }
166
+
167
+ /**
168
+ * Returns the configurator pinned for a module by name.
169
+ *
170
+ * @param name - The module's name, as passed to {@link _pin}.
171
+ * @template TConfig - The specific configurator type expected for this module.
172
+ * @returns The configurator pinned under `name`.
173
+ * @throws {Error} If no configurator has been pinned for that name.
174
+ */
175
+ protected _getConfig<TConfig>(name: string): TConfig {
176
+ const config = this.#configurators.get(name);
177
+ // A missing entry means _pin was never called for this module name
178
+ if (config === undefined) {
179
+ throw new Error(
180
+ `No configurator is pinned for module "${name}" — call this._pin(module) before this._getConfig("${name}").`,
181
+ );
182
+ }
183
+ return config as TConfig;
184
+ }
185
+
186
+ /**
187
+ * Configures the app's named HTTP clients.
188
+ *
189
+ * @remarks
190
+ * The same {@link IHttpClientConfigurator} the `http` module is configured
191
+ * from — the real one, not a test double. Every client it builds —
192
+ * including ones registered through
193
+ * {@link AppConfigurator.configureHttpClient} or
194
+ * {@link AppConfigurator.useFrameworkServiceClient} — is reachable here to
195
+ * register a short-circuiting {@link HttpMiddleware} through
196
+ * `addMiddleware`, so it answers from that instead of the network.
197
+ *
198
+ * @returns The real HTTP configurator.
199
+ */
200
+ public get http(): IHttpClientConfigurator {
201
+ return this._getConfig<IHttpClientConfigurator>(http.name);
202
+ }
203
+
204
+ /**
205
+ * Configures the user the app's `msal` module signs in.
206
+ *
207
+ * @remarks
208
+ * The same {@link MsalMockConfigurator} the `msal` module is configured from,
209
+ * so a change made here is what the module sees.
210
+ *
211
+ * @returns The MSAL mock configurator.
212
+ */
213
+ public get msal(): MsalMockConfigurator {
214
+ return this._getConfig<MsalMockConfigurator>(msalMockModule.name);
215
+ }
216
+ }
217
+
218
+ export default AppMockConfigurator;