@equinor/fusion-framework 8.0.15 → 8.1.0-next.1

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 (41) hide show
  1. package/CHANGELOG.md +197 -0
  2. package/README.md +30 -0
  3. package/dist/esm/__tests__/mock/application-module.test.js +53 -0
  4. package/dist/esm/__tests__/mock/application-module.test.js.map +1 -0
  5. package/dist/esm/__tests__/mock/documented-usage.test.js +73 -0
  6. package/dist/esm/__tests__/mock/documented-usage.test.js.map +1 -0
  7. package/dist/esm/__tests__/mock/mock-framework.test.js +212 -0
  8. package/dist/esm/__tests__/mock/mock-framework.test.js.map +1 -0
  9. package/dist/esm/init.js +7 -2
  10. package/dist/esm/init.js.map +1 -1
  11. package/dist/esm/mock/FrameworkMockConfigurator.js +233 -0
  12. package/dist/esm/mock/FrameworkMockConfigurator.js.map +1 -0
  13. package/dist/esm/mock/index.js +32 -0
  14. package/dist/esm/mock/index.js.map +1 -0
  15. package/dist/esm/mock/mock-framework.js +55 -0
  16. package/dist/esm/mock/mock-framework.js.map +1 -0
  17. package/dist/esm/version.js +1 -1
  18. package/dist/esm/version.js.map +1 -1
  19. package/dist/tsconfig.tsbuildinfo +1 -1
  20. package/dist/types/__tests__/mock/application-module.test.d.ts +1 -0
  21. package/dist/types/__tests__/mock/documented-usage.test.d.ts +1 -0
  22. package/dist/types/__tests__/mock/mock-framework.test.d.ts +1 -0
  23. package/dist/types/mock/FrameworkMockConfigurator.d.ts +185 -0
  24. package/dist/types/mock/index.d.ts +29 -0
  25. package/dist/types/mock/mock-framework.d.ts +58 -0
  26. package/dist/types/version.d.ts +1 -1
  27. package/docs/testing-api.md +25 -0
  28. package/docs/testing-choosing-a-layer.md +90 -0
  29. package/docs/testing-design.md +63 -0
  30. package/docs/testing-extending.md +102 -0
  31. package/docs/testing.md +178 -0
  32. package/package.json +17 -11
  33. package/src/__tests__/mock/application-module.test.ts +87 -0
  34. package/src/__tests__/mock/documented-usage.test.ts +98 -0
  35. package/src/__tests__/mock/mock-framework.test.ts +278 -0
  36. package/src/init.ts +7 -2
  37. package/src/mock/FrameworkMockConfigurator.ts +270 -0
  38. package/src/mock/index.ts +62 -0
  39. package/src/mock/mock-framework.ts +72 -0
  40. package/src/version.ts +1 -1
  41. package/vitest.config.ts +1 -1
@@ -0,0 +1,178 @@
1
+ # Usage
2
+
3
+ Recipes for the situations a test normally runs into. Every one of them builds a real framework instance — only the boundaries that leave the process are substituted.
4
+
5
+ `mockFramework` takes a single callback, which receives a [`FrameworkMockConfigurator`](#the-configurator). That configurator **is** a `FrameworkConfigurator`, so everything an application does at configure time works here unchanged.
6
+
7
+ ## Zero configuration
8
+
9
+ ```typescript
10
+ import { mockFramework } from '@equinor/fusion-framework/mock';
11
+
12
+ const fusion = await mockFramework();
13
+
14
+ fusion.modules.auth.account?.name; // 'Test User'
15
+ await fusion.modules.serviceDiscovery.resolveService('apps'); // resolves offline
16
+ ```
17
+
18
+ Every module declared by `FrameworkConfigurator` is initialized: `event`, `auth`, `http`, `serviceDiscovery`, `context` and `telemetry`.
19
+
20
+ ## The configurator
21
+
22
+ Modules whose boundary is mocked expose their mock configurator directly, so a test configures them without registering a callback:
23
+
24
+ | Property | Type |
25
+ | --- | --- |
26
+ | `msal` | `MsalMockConfigurator` |
27
+ | `serviceDiscovery` | `ServiceDiscoveryMockConfigurator` |
28
+ | `http` | `IHttpClientConfigurator` (the real configurator — see [`.http`](testing-extending.md#http)) |
29
+ | `context` | `ContextMockConfigurator` |
30
+ | `telemetry` | `TelemetryMockConfigurator` |
31
+
32
+ ```typescript
33
+ const fusion = await mockFramework((configurator) => {
34
+ configurator.msal.setAccount({ name: 'Ada Lovelace' });
35
+ configurator.serviceDiscovery.setBaseUri('http://localhost:6669');
36
+ });
37
+ ```
38
+
39
+ These are the real module configurators, so the real builder API, the real validation and the real provider are used.
40
+
41
+ ## Choosing the signed-in user
42
+
43
+ ```typescript
44
+ const fusion = await mockFramework((configurator) => {
45
+ configurator.msal.setAccount({ name: 'Ada Lovelace', username: 'ada@equinor.com' });
46
+ });
47
+
48
+ const token = await fusion.modules.auth.acquireAccessToken({
49
+ request: { scopes: ['Files.Read'] },
50
+ });
51
+ ```
52
+
53
+ The token is a structurally valid JWT carrying the claims an application reads. It is unsigned by default and must never be accepted by anything but a test.
54
+
55
+ `setAccount` records configuration only — the user is signed in on the client before the provider initializes. It takes an object, `null` when nobody is signed in, or an ordinary config-builder callback resolving either:
56
+
57
+ ```typescript
58
+ configurator.msal.setAccount(null);
59
+ configurator.msal.setAccount(async ({ hasModule }) => ({
60
+ name: hasModule('app') ? 'App User' : 'Portal User',
61
+ }));
62
+ ```
63
+
64
+ Because the user is in place before `MsalProvider.initialize()` runs, the provider's real start-up path acts on it — pair `signedOut` with `setRequiresAuth(true)` to watch the automatic login happen.
65
+
66
+ The account replaces any previously declared account, and is signed in on whichever client the module authenticates through — the one it builds, one supplied through `setClient`, or the host's when the module is hoisted onto a host application's provider. When that client cannot represent a declared user, it throws rather than failing quietly.
67
+
68
+ ## Testing signed-out behaviour
69
+
70
+ ```typescript
71
+ const fusion = await mockFramework((configurator) => {
72
+ configurator.msal.setAccount({ signedOut: true });
73
+ });
74
+
75
+ fusion.modules.auth.account; // null
76
+ ```
77
+
78
+ Silent flows then resolve empty so the provider follows its unauthenticated path, while an explicit `login()` still succeeds — so a test can drive the sign-in journey, not only its end state.
79
+
80
+ ## Composing the service registry
81
+
82
+ Nothing has to be constructed to add a service, or to move every service to a locally running mock server such as Mockoon or Prism — in which case the application makes real HTTP calls with nothing intercepting them.
83
+
84
+ ```typescript
85
+ const fusion = await mockFramework((configurator) => {
86
+ configurator.serviceDiscovery.setBaseUri('http://localhost:6669');
87
+ configurator.serviceDiscovery.addService({ key: 'my-api' });
88
+ configurator.serviceDiscovery.removeService('bookmarks');
89
+ });
90
+ ```
91
+
92
+ To replace the baseline registry outright rather than compose onto it, use `setServices`:
93
+
94
+ ```typescript
95
+ configurator.serviceDiscovery.setServices([{ key: 'apps', uri: 'http://localhost:3000' }]);
96
+ ```
97
+
98
+ By default an undeclared service resolves to a synthesised entry rather than throwing, so a test does not fail merely because the application resolved something the test did not think to declare. Call `setResolveUnknownServices(false)` to assert the opposite.
99
+
100
+ > [!WARNING]
101
+ > Built-in modules resolve services **while the framework starts** — the context module resolves `context`, for example. Combining `setResolveUnknownServices(false)` with a `setServices` registry that omits them fails initialization rather than the assertion you were writing. Either keep synthesis on, or declare every service the framework itself needs.
102
+
103
+ ## Seeding context
104
+
105
+ ```typescript
106
+ const fusion = await mockFramework((configurator) => {
107
+ configurator.context.setCurrentContext({ id: 'project-42', type: { id: 'ProjectMaster' }, value: {} });
108
+ });
109
+
110
+ fusion.modules.context.currentContext; // the seeded item
111
+ ```
112
+
113
+ `configurator.context` is a `ContextMockConfigurator` — a small, context-domain vocabulary (`setCurrentContext`, `setContexts`, `addContext`, `setRelatedContexts`) covers seeding a known item with no HTTP mock and no service-discovery mock required. `setResolver` is the escape hatch for a custom resolution need the friendly methods do not cover. Real `ContextProvider` behaviour — `validateContext`, `resolveContext`, parent-context propagation — still runs against the seeded data in both.
114
+
115
+ This is one of two ways to fake context data: seeding an item directly (above) substitutes only the data source, with no transport involved. Mocking the context API's HTTP responses instead, through `.http`, exercises the real `ContextModuleConfigurator`/services/HTTP pipeline — reach for that when the test needs to cover that pipeline itself, optionally paired with `createOpenApiMockMiddleware` for faker-generated data straight from context's OpenAPI spec.
116
+
117
+ ## Mocking an individual call
118
+
119
+ That is your test runner's job, not this entry point's. Mock clients are plain classes with ordinary methods, so any runner can spy on them with its own tooling — including call assertions and its own reset semantics.
120
+
121
+ ```typescript
122
+ vi.spyOn(fusion.modules.serviceDiscovery.client, 'resolveService').mockResolvedValue(service);
123
+
124
+ afterEach(() => vi.restoreAllMocks());
125
+ ```
126
+
127
+ The same holds for `bun:test`'s `spyOn` and Node's `t.mock.method`, which is why this entry point introduces no mocking API of its own.
128
+
129
+ ## Configuring the framework as an application does
130
+
131
+ The configurator is a `FrameworkConfigurator`, so every `enableX` and `configureX` helper is available and behaves normally.
132
+
133
+ ```typescript
134
+ const fusion = await mockFramework((configurator) => {
135
+ configurator.onConfigured(() => {
136
+ /* ... */
137
+ });
138
+ });
139
+ ```
140
+
141
+ Mocks are registered **before** the callback runs, so anything configured there wins — including replacing a mock with a different one.
142
+
143
+ ## Registering your own modules
144
+
145
+ Pass your module descriptors as a type argument. They are then typed on both the configurator and the returned instance, so no cast is needed to reach them.
146
+
147
+ ```typescript
148
+ const fusion = await mockFramework<[InvoiceModule]>((configurator) => {
149
+ enableInvoicesMock(configurator, { total: 42 });
150
+ });
151
+
152
+ await fusion.modules.invoices.getInvoice('inv-1'); // fully typed
153
+ ```
154
+
155
+ `addModule` is available if it reads better at the call site; it is sugar for the same call.
156
+
157
+ ```typescript
158
+ configurator.addModule((c) => enableInvoicesMock(c, { total: 42 }));
159
+ ```
160
+
161
+ > [!NOTE]
162
+ > Only modules that ship a mock configurator get a property such as `configurator.msal`. Everything else is registered exactly as it is in production — through its own `enableX` helper or `configurator.addConfig`. Module *instances* never exist at configure time; they are created by `initialize`.
163
+
164
+ See [Adding a mock for another module](./testing-extending.md) for how to give your module a test double, and a property on the configurator.
165
+
166
+ ## Bringing your own configurator
167
+
168
+ `FrameworkMockConfigurator` can be constructed directly and initialized with `init`, which is useful when a test needs to hold on to the configurator.
169
+
170
+ ```typescript
171
+ import { init } from '@equinor/fusion-framework';
172
+ import { FrameworkMockConfigurator } from '@equinor/fusion-framework/mock';
173
+
174
+ const configurator = new FrameworkMockConfigurator();
175
+ configurator.msal.setAccount({ name: 'Ada Lovelace' });
176
+
177
+ const fusion = await init(configurator);
178
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@equinor/fusion-framework",
3
- "version": "8.0.15",
3
+ "version": "8.1.0-next.1",
4
4
  "description": "",
5
5
  "main": "dist/esm/index.js",
6
6
  "types": "dist/types/index.d.ts",
@@ -8,12 +8,18 @@
8
8
  ".": {
9
9
  "import": "./dist/esm/index.js",
10
10
  "types": "./dist/types/index.d.ts"
11
+ },
12
+ "./mock": {
13
+ "import": "./dist/esm/mock/index.js",
14
+ "types": "./dist/types/mock/index.d.ts"
11
15
  }
12
16
  },
13
17
  "keywords": [
14
18
  "fusion",
15
19
  "fusion-framework",
16
- "utility"
20
+ "utility",
21
+ "testing",
22
+ "mock"
17
23
  ],
18
24
  "homepage": "https://equinor.github.io/fusion-framework/",
19
25
  "author": {
@@ -32,18 +38,18 @@
32
38
  },
33
39
  "dependencies": {
34
40
  "rxjs": "^7.8.1",
35
- "@equinor/fusion-framework-module-context": "8.0.3",
36
- "@equinor/fusion-framework-module-event": "6.0.1",
37
- "@equinor/fusion-framework-module-http": "8.0.5",
38
- "@equinor/fusion-framework-module": "6.1.2",
39
- "@equinor/fusion-framework-module-service-discovery": "10.0.2",
40
- "@equinor/fusion-framework-module-msal": "10.0.2",
41
- "@equinor/fusion-framework-module-telemetry": "7.0.2",
42
- "@equinor/fusion-framework-module-services": "8.1.0"
41
+ "@equinor/fusion-framework-module": "6.1.3-next.0",
42
+ "@equinor/fusion-framework-module-event": "6.1.0-next.0",
43
+ "@equinor/fusion-framework-module-msal": "11.0.0-next.0",
44
+ "@equinor/fusion-framework-module-context": "9.0.0-next.0",
45
+ "@equinor/fusion-framework-module-http": "8.1.0-next.0",
46
+ "@equinor/fusion-framework-module-services": "8.1.1-next.0",
47
+ "@equinor/fusion-framework-module-service-discovery": "10.1.0-next.0",
48
+ "@equinor/fusion-framework-module-telemetry": "8.0.0-next.1"
43
49
  },
44
50
  "devDependencies": {
45
51
  "typescript": "^7.0.2",
46
- "vitest": "^4.1.0"
52
+ "vitest": "^4.1.10"
47
53
  },
48
54
  "scripts": {
49
55
  "build": "tsc -b",
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import type { IModulesConfigurator, Module } from '@equinor/fusion-framework-module';
4
+
5
+ import { mockFramework } from '../../mock/index.js';
6
+
7
+ /**
8
+ * A module an application team owns, which the framework knows nothing about.
9
+ *
10
+ * @remarks
11
+ * The point of these tests is that nothing below required support from
12
+ * `@equinor/fusion-framework/mock`. If an application team can do this, the
13
+ * extension pattern holds.
14
+ */
15
+ interface InvoiceClient {
16
+ getInvoice(id: string): Promise<{ id: string; total: number }>;
17
+ }
18
+
19
+ class InvoiceConfigurator {
20
+ #client?: InvoiceClient;
21
+
22
+ public setClient(client: InvoiceClient): void {
23
+ this.#client = client;
24
+ }
25
+
26
+ public createClient(): InvoiceClient {
27
+ if (!this.#client) {
28
+ throw new Error('An invoice client is required');
29
+ }
30
+ return this.#client;
31
+ }
32
+ }
33
+
34
+ type InvoiceModule = Module<'invoices', InvoiceClient, InvoiceConfigurator>;
35
+
36
+ const invoiceModule: InvoiceModule = {
37
+ name: 'invoices',
38
+ configure: () => new InvoiceConfigurator(),
39
+ initialize: ({ config }) => config.createClient(),
40
+ };
41
+
42
+ /** The team's own mock, following the pattern the built-in modules use. */
43
+ const enableInvoicesMock = (
44
+ // biome-ignore lint/suspicious/noExplicitAny: mirrors every enableX helper
45
+ configurator: IModulesConfigurator<any, any>,
46
+ options: { total?: number } = {},
47
+ ): void => {
48
+ configurator.addConfig({
49
+ module: invoiceModule,
50
+ configure: (builder: InvoiceConfigurator) =>
51
+ builder.setClient({
52
+ getInvoice: async (id) => ({ id, total: options.total ?? 0 }),
53
+ }),
54
+ } as { module: InvoiceModule });
55
+ };
56
+
57
+ describe('application modules', () => {
58
+ it('fails to start without its mock, so the seam is real and not a no-op', async () => {
59
+ await expect(
60
+ mockFramework<[InvoiceModule]>((configurator) => {
61
+ configurator.addConfig({ module: invoiceModule } as { module: InvoiceModule });
62
+ }),
63
+ ).rejects.toThrow(/invoice client is required/i);
64
+ });
65
+
66
+ it('composes with the built-in mocks and is typed without a cast', async () => {
67
+ const fusion = await mockFramework<[InvoiceModule]>((configurator) => {
68
+ configurator.msal.setAccount({ name: 'Ada Lovelace' });
69
+ enableInvoicesMock(configurator, { total: 42 });
70
+ });
71
+
72
+ // No cast: `TModules` must flow through to `fusion.modules`.
73
+ const invoice = await fusion.modules.invoices.getInvoice('inv-1');
74
+
75
+ expect(invoice).toEqual({ id: 'inv-1', total: 42 });
76
+ expect(fusion.modules.auth.account?.name).toBe('Ada Lovelace');
77
+ });
78
+
79
+ it('is registered exactly as it is in production', async () => {
80
+ // The same helper, against a real FrameworkConfigurator, would behave identically.
81
+ const fusion = await mockFramework<[InvoiceModule]>((configurator) =>
82
+ enableInvoicesMock(configurator, { total: 7 }),
83
+ );
84
+
85
+ await expect(fusion.modules.invoices.getInvoice('inv-2')).resolves.toMatchObject({ total: 7 });
86
+ });
87
+ });
@@ -0,0 +1,98 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { decodeJwtSegment } from '@equinor/fusion-framework-module-msal/mock';
4
+
5
+ import { init } from '../../init.js';
6
+ import { createMockService, FrameworkMockConfigurator, mockFramework } from '../../mock/index.js';
7
+
8
+ /**
9
+ * Pins the snippets in `docs/testing.md` and the package README to real
10
+ * behaviour, so documentation cannot drift away from the API it describes.
11
+ */
12
+ describe('documented usage', () => {
13
+ afterEach(() => vi.restoreAllMocks());
14
+
15
+ it('composes the service registry on the builder', async () => {
16
+ const fusion = await mockFramework((configurator) => {
17
+ configurator.serviceDiscovery.setBaseUri('http://localhost:6669');
18
+ configurator.serviceDiscovery.addService({ key: 'my-api' });
19
+ configurator.serviceDiscovery.removeService('bookmarks');
20
+ });
21
+
22
+ await expect(fusion.modules.serviceDiscovery.resolveService('my-api')).resolves.toMatchObject({
23
+ key: 'my-api',
24
+ });
25
+ });
26
+
27
+ it('replaces the baseline registry with setServices', async () => {
28
+ const fusion = await mockFramework((configurator) => {
29
+ configurator.serviceDiscovery.setServices([{ key: 'apps', uri: 'http://localhost:3000' }]);
30
+ });
31
+
32
+ await expect(fusion.modules.serviceDiscovery.resolveService('apps')).resolves.toMatchObject({
33
+ uri: 'http://localhost:3000',
34
+ });
35
+ });
36
+
37
+ it('keeps startup working when the registry is replaced, because unknown services synthesise', async () => {
38
+ // The context module resolves `context` while initializing, so a replaced
39
+ // registry that omits it would break start-up if synthesis were disabled.
40
+ const fusion = await mockFramework((configurator) => {
41
+ configurator.serviceDiscovery.setServices([{ key: 'apps', uri: 'http://localhost:3000' }]);
42
+ });
43
+
44
+ await expect(fusion.modules.serviceDiscovery.resolveService('people')).resolves.toBeDefined();
45
+ });
46
+
47
+ it('lets the test runner spy on the discovery client', async () => {
48
+ const fusion = await mockFramework();
49
+
50
+ vi.spyOn(fusion.modules.serviceDiscovery.client, 'resolveService').mockResolvedValue(
51
+ createMockService({ key: 'apps', uri: 'http://spied' }),
52
+ );
53
+
54
+ await expect(fusion.modules.serviceDiscovery.resolveService('apps')).resolves.toMatchObject({
55
+ uri: 'http://spied',
56
+ });
57
+ });
58
+
59
+ it('lets the test runner spy on the auth client', async () => {
60
+ const fusion = await mockFramework();
61
+
62
+ const spy = vi.spyOn(fusion.modules.auth.client, 'acquireToken');
63
+
64
+ await fusion.modules.auth.acquireAccessToken({ request: { scopes: ['Files.Read'] } });
65
+
66
+ expect(spy).toHaveBeenCalled();
67
+ });
68
+
69
+ it('resolves the real scope through the real provider, not the test double', async () => {
70
+ const fusion = await mockFramework((configurator) => {
71
+ // The client is configured with what it talks to, exactly as in production
72
+ configurator.msal.setClientConfig({ auth: { clientId: 'my-app', tenantId: 'my-tenant' } });
73
+ });
74
+
75
+ const token = await fusion.modules.auth.acquireAccessToken();
76
+ const claims = JSON.parse(decodeJwtSegment(token?.split('.')[1] ?? ''));
77
+
78
+ // MsalProvider — not the mock — turns "no scopes requested" into `${clientId}/.default`
79
+ expect(claims.scp).toBe('my-app/.default');
80
+ });
81
+
82
+ it('signs nobody in when the account is signed out', async () => {
83
+ const fusion = await mockFramework((configurator) => {
84
+ configurator.msal.setAccount({ signedOut: true });
85
+ });
86
+
87
+ expect(fusion.modules.auth.account).toBeFalsy();
88
+ });
89
+
90
+ it('can be constructed directly and initialized with init', async () => {
91
+ const configurator = new FrameworkMockConfigurator();
92
+ configurator.msal.setAccount({ name: 'Ada Lovelace' });
93
+
94
+ const fusion = await init(configurator);
95
+
96
+ expect(fusion.modules.auth.account?.name).toBe('Ada Lovelace');
97
+ });
98
+ });