@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.
- package/CHANGELOG.md +76 -0
- package/README.md +35 -65
- package/dist/esm/__tests__/mock/AppMockConfigurator.test.js +70 -0
- package/dist/esm/__tests__/mock/AppMockConfigurator.test.js.map +1 -0
- package/dist/esm/__tests__/mock/mock-app.test.js +86 -0
- package/dist/esm/__tests__/mock/mock-app.test.js.map +1 -0
- package/dist/esm/__tests__/mock/msal-hoisting.test.js +36 -0
- package/dist/esm/__tests__/mock/msal-hoisting.test.js.map +1 -0
- package/dist/esm/configure-modules.js +2 -42
- package/dist/esm/configure-modules.js.map +1 -1
- package/dist/esm/initialize-app-modules.js +65 -0
- package/dist/esm/initialize-app-modules.js.map +1 -0
- package/dist/esm/mock/AppMockConfigurator.js +183 -0
- package/dist/esm/mock/AppMockConfigurator.js.map +1 -0
- package/dist/esm/mock/enable-app-manifest-mock.js +49 -0
- package/dist/esm/mock/enable-app-manifest-mock.js.map +1 -0
- package/dist/esm/mock/index.js +21 -0
- package/dist/esm/mock/index.js.map +1 -0
- package/dist/esm/mock/mock-app-modules.js +82 -0
- package/dist/esm/mock/mock-app-modules.js.map +1 -0
- package/dist/esm/version.js +1 -1
- package/dist/esm/version.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/__tests__/mock/AppMockConfigurator.test.d.ts +1 -0
- package/dist/types/__tests__/mock/mock-app.test.d.ts +1 -0
- package/dist/types/__tests__/mock/msal-hoisting.test.d.ts +1 -0
- package/dist/types/initialize-app-modules.d.ts +31 -0
- package/dist/types/mock/AppMockConfigurator.d.ts +142 -0
- package/dist/types/mock/enable-app-manifest-mock.d.ts +33 -0
- package/dist/types/mock/index.d.ts +20 -0
- package/dist/types/mock/mock-app-modules.d.ts +72 -0
- package/dist/types/version.d.ts +1 -1
- package/docs/bookmarks.md +18 -0
- package/docs/http-clients.md +71 -0
- package/docs/testing.md +105 -0
- package/package.json +22 -13
- package/src/__tests__/mock/AppMockConfigurator.test.ts +96 -0
- package/src/__tests__/mock/mock-app.test.ts +112 -0
- package/src/__tests__/mock/msal-hoisting.test.ts +54 -0
- package/src/configure-modules.ts +2 -52
- package/src/initialize-app-modules.ts +98 -0
- package/src/mock/AppMockConfigurator.ts +218 -0
- package/src/mock/enable-app-manifest-mock.ts +62 -0
- package/src/mock/index.ts +21 -0
- package/src/mock/mock-app-modules.ts +109 -0
- package/src/version.ts +1 -1
- 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
|
+
});
|
package/src/configure-modules.ts
CHANGED
|
@@ -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;
|