Skip to content

Commit 00fc1ce

Browse files
odinrCopilot
andcommitted
feat(framework): let any module get the same accessor .msal has
FrameworkMockConfigurator hand-pinned the msal and service-discovery mock configurators to expose `.msal` and `.serviceDiscovery` as stable, synchronously reachable properties. Nothing let a module supplied through TModules — an application's own — do the same; that pinning trick was inline in the constructor twice, with no way to reuse it a third time. Extracted it into `_pin(module)` / `_getConfig(name)`, a protected pair a subclass calls the same way this class does for its own built-ins: class AppMockConfigurator extends FrameworkMockConfigurator<[InvoiceModule]> { constructor() { super(); this._pin(invoiceMockModule); } get invoices(): InvoiceMockConfigurator { return this._getConfig('invoices'); } } `_pin` replaces a module's `configure` factory with one that always returns the same instance and registers it; `_getConfig` looks that instance up by name. Both guard the cases that would otherwise fail silently or asynchronously: a module with no `configure` factory, and one whose factory returns a promise (pinning must be synchronous, or a test couldn't reach the accessor before initialization runs). Also dropped two `as { module: X }` casts in the constructor that tsc did not actually need. `.msal` and `.serviceDiscovery` are unchanged for consumers; they are now built from `_pin`/`_getConfig` rather than from two private fields carrying the same logic twice. `testing-extending.md` documents the pattern for a module that needs it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 43c67fd commit 00fc1ce

4 files changed

Lines changed: 202 additions & 19 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@equinor/fusion-framework": minor
3+
---
4+
5+
Add `_pin` and `_getConfig` to `FrameworkMockConfigurator`, so a module supplied through `TModules` can get the same kind of named accessor `.msal` and `.serviceDiscovery` already have.
6+
7+
Previously that pinning was hand-written twice, once per built-in mock, with no way for anything else to do the same. A subclass now reuses it directly:
8+
9+
```typescript
10+
class AppMockConfigurator extends FrameworkMockConfigurator<[InvoiceModule]> {
11+
constructor() {
12+
super();
13+
this._pin(invoiceMockModule);
14+
}
15+
16+
get invoices(): InvoiceMockConfigurator {
17+
return this._getConfig('invoices');
18+
}
19+
}
20+
```
21+
22+
`_pin(module)` replaces the module's own `configure` factory with one that always returns the same instance, and registers it — pinning it before initialization runs is what lets a test reach the accessor synchronously and have it be the configurator the module is actually built from. `_getConfig(name)` looks that instance up by the module's name, throwing if nothing was pinned for it.
23+
24+
`.msal` and `.serviceDiscovery` are unchanged for consumers; they are now built from `_pin`/`_getConfig` themselves rather than from two private fields.

packages/framework/docs/testing-extending.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,27 @@ const fusion = await mockFramework<[InvoiceModule]>((configurator) => {
3939
await fusion.modules.invoices.getInvoice('inv-1'); // typed, no cast
4040
```
4141

42+
### Giving your module the same accessor as `.msal` and `.serviceDiscovery`
43+
44+
`enableInvoicesMock(configurator, options)` is enough on its own — the configurator it builds is discarded once configuration runs, which is fine when a test only ever sets options up front. Reach for an accessor when a test needs the configurator itself, for example to assert against it after the fact.
45+
46+
Subclass `FrameworkMockConfigurator` and use the protected `_pin`/`_getConfig` pair it exposes for exactly this — the same mechanism `.msal` and `.serviceDiscovery` are built from:
47+
48+
```typescript
49+
class AppMockConfigurator extends FrameworkMockConfigurator<[InvoiceModule]> {
50+
constructor() {
51+
super();
52+
this._pin(invoiceMockModule);
53+
}
54+
55+
get invoices(): InvoiceMockConfigurator {
56+
return this._getConfig('invoices');
57+
}
58+
}
59+
```
60+
61+
`_pin` replaces the module's own `configure` factory with one that always returns the same instance — pinning it before initialization runs is what lets a test reach `.invoices` synchronously and have it be the configurator the module is actually built from. `_getConfig` looks that instance up by name, throwing if nothing was pinned for it.
62+
4263
## What is not covered yet
4364

44-
The `http`, `services`, `context` and `telemetry` modules initialize but are not yet backed by test doubles, so anything issuing an actual request still reaches the network. Adding one means creating a `src/mock/` folder in **that module**, then pinning its configurator as a property on `FrameworkMockConfigurator` — the two lines the built-in mocks already use.
65+
The `http`, `services`, `context` and `telemetry` modules initialize but are not yet backed by test doubles, so anything issuing an actual request still reaches the network. Adding one means creating a `src/mock/` folder in **that module**, then pinning its configurator with `_pin` and exposing it with `_getConfig` on `FrameworkMockConfigurator` — the two lines the built-in mocks already use.

packages/framework/src/__tests__/mock/mock-framework.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,33 @@
11
import { describe, expect, it } from 'vitest';
22

3+
import type { Module } from '@equinor/fusion-framework-module';
34
import { enableMsalMock } from '@equinor/fusion-framework-module-msal/mock';
45
import { enableServiceDiscoveryMock } from '@equinor/fusion-framework-module-service-discovery/mock';
56

67
import { FrameworkConfigurator } from '../../FrameworkConfigurator.js';
78
import { init } from '../../init.js';
89
import { FrameworkMockConfigurator, mockFramework } from '../../mock/index.js';
910

11+
/** A minimal configurator, standing in for an application's own. */
12+
class WidgetsConfigurator {
13+
#name = 'default';
14+
setName(name: string): this {
15+
this.#name = name;
16+
return this;
17+
}
18+
get name(): string {
19+
return this.#name;
20+
}
21+
}
22+
23+
/** A minimal module, standing in for one an application supplies through `TModules`. */
24+
type WidgetsModule = Module<'widgets', { name: string }, WidgetsConfigurator>;
25+
const widgetsModule: WidgetsModule = {
26+
name: 'widgets',
27+
configure: () => new WidgetsConfigurator(),
28+
initialize: ({ config }) => ({ name: config.name }),
29+
};
30+
1031
describe('mockFramework', () => {
1132
it('initializes every built-in module without configuration', async () => {
1233
const fusion = await mockFramework();
@@ -139,4 +160,61 @@ describe('FrameworkMockConfigurator', () => {
139160

140161
expect(configurator.addModule(() => undefined)).toBe(configurator);
141162
});
163+
164+
it('lets a module supplied through TModules get the same kind of accessor as msal and serviceDiscovery', async () => {
165+
// Standing in for an application subclassing FrameworkMockConfigurator to
166+
// expose its own module the same way the built-ins are exposed.
167+
class AppMockConfigurator extends FrameworkMockConfigurator<[WidgetsModule]> {
168+
constructor() {
169+
super();
170+
this._pin(widgetsModule);
171+
}
172+
get widgets(): WidgetsConfigurator {
173+
return this._getConfig('widgets');
174+
}
175+
}
176+
177+
const configurator = new AppMockConfigurator();
178+
configurator.widgets.setName('Ada');
179+
180+
const fusion = await init(configurator);
181+
182+
expect(fusion.modules.widgets.name).toBe('Ada');
183+
});
184+
185+
it('pins the same instance across repeated reads, so declarations accumulate on one configurator', () => {
186+
class AppMockConfigurator extends FrameworkMockConfigurator<[WidgetsModule]> {
187+
constructor() {
188+
super();
189+
this._pin(widgetsModule);
190+
}
191+
get widgets(): WidgetsConfigurator {
192+
return this._getConfig('widgets');
193+
}
194+
}
195+
196+
const configurator = new AppMockConfigurator();
197+
198+
expect(configurator.widgets).toBe(configurator.widgets);
199+
});
200+
201+
it('throws from _getConfig when nothing was pinned for that name', () => {
202+
class AppMockConfigurator extends FrameworkMockConfigurator {
203+
readMissing(): unknown {
204+
return this._getConfig('widgets');
205+
}
206+
}
207+
208+
expect(() => new AppMockConfigurator().readMissing()).toThrow(/widgets/);
209+
});
210+
211+
it('throws from _pin when the module declares no configure factory', () => {
212+
class AppMockConfigurator extends FrameworkMockConfigurator {
213+
pinMissingConfigure(): void {
214+
this._pin({ ...widgetsModule, configure: undefined } as WidgetsModule);
215+
}
216+
}
217+
218+
expect(() => new AppMockConfigurator().pinMissingConfigure()).toThrow(/configure factory/);
219+
});
142220
});

packages/framework/src/mock/FrameworkMockConfigurator.ts

Lines changed: 78 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import type { AnyModule } from '@equinor/fusion-framework-module';
22

3-
import type { MsalModule } from '@equinor/fusion-framework-module-msal';
4-
import { msalMockModule, MsalMockConfigurator } from '@equinor/fusion-framework-module-msal/mock';
5-
import type { ServiceDiscoveryModule } from '@equinor/fusion-framework-module-service-discovery';
3+
import {
4+
msalMockModule,
5+
type MsalMockConfigurator,
6+
} from '@equinor/fusion-framework-module-msal/mock';
67
import {
78
serviceDiscoveryMockModule,
8-
ServiceDiscoveryMockConfigurator,
9+
type ServiceDiscoveryMockConfigurator,
910
} from '@equinor/fusion-framework-module-service-discovery/mock';
1011

1112
import { FrameworkConfigurator } from '../FrameworkConfigurator.js';
@@ -46,23 +47,82 @@ export class FrameworkMockConfigurator<
4647
> extends FrameworkConfigurator<TModules> {
4748
static override readonly className: string = 'FrameworkMockConfigurator';
4849

49-
#msal = new MsalMockConfigurator();
50-
#serviceDiscovery = new ServiceDiscoveryMockConfigurator();
50+
// Keyed by module name, so `_getConfig` can look a pinned configurator up
51+
// without needing the module descriptor again.
52+
#configurators = new Map<string, unknown>();
5153

5254
constructor() {
5355
super();
5456

55-
// The module system builds a fresh configurator during the configure phase.
56-
// Pinning our instances is what lets a test reach `.msal` and
57-
// `.serviceDiscovery` before initialization and configure them directly.
58-
const msal: MsalModule = { ...msalMockModule, configure: () => this.#msal };
59-
const serviceDiscovery: ServiceDiscoveryModule = {
60-
...serviceDiscoveryMockModule,
61-
configure: () => this.#serviceDiscovery,
62-
};
57+
// Pinning the built-in mocks up front — rather than waiting for `.msal` or
58+
// `.serviceDiscovery` to be read — is what replaces the real `auth` and
59+
// `serviceDiscovery` modules registered by `FrameworkConfigurator`'s own
60+
// constructor, whether or not a test ever touches the accessor.
61+
this._pin(msalMockModule);
62+
this._pin(serviceDiscoveryMockModule);
63+
}
6364

64-
this.addConfig({ module: msal } as { module: MsalModule });
65-
this.addConfig({ module: serviceDiscovery } as { module: ServiceDiscoveryModule });
65+
/**
66+
* Pins a module to a single configurator instance for the lifetime of this
67+
* configurator, so it can be reached by name through {@link _getConfig}.
68+
*
69+
* @remarks
70+
* The module system otherwise builds a fresh configurator from its own
71+
* `configure` factory during the configure phase — too late for a test to
72+
* reach, and a new instance on every call besides. This replaces that
73+
* factory with one that always returns the same instance, and registers the
74+
* result under the module's own name.
75+
*
76+
* A subclass registering a module supplied through {@link TModules} uses
77+
* this the same way `.msal` and `.serviceDiscovery` do, to expose its own
78+
* named accessor:
79+
*
80+
* ```typescript
81+
* class MyMockConfigurator extends FrameworkMockConfigurator<[InvoiceModule]> {
82+
* constructor() {
83+
* super();
84+
* this._pin(invoiceMockModule);
85+
* }
86+
*
87+
* public get invoices(): InvoiceMockConfigurator {
88+
* return this._getConfig('invoices');
89+
* }
90+
* }
91+
* ```
92+
*
93+
* @param module - The module descriptor to pin a configurator for.
94+
* @throws {Error} If the module declares no `configure` factory to pin, or
95+
* the factory returns a promise instead of a configurator — pinning is
96+
* synchronous, so a test can reach the accessor immediately.
97+
*/
98+
protected _pin<TModule extends AnyModule>(module: TModule): void {
99+
if (!module.configure) {
100+
throw new Error(`Cannot pin "${module.name}": it declares no configure factory.`);
101+
}
102+
const instance = module.configure();
103+
if (instance instanceof Promise) {
104+
throw new Error(
105+
`Cannot pin "${module.name}": its configure factory returns a promise, so it cannot be resolved synchronously.`,
106+
);
107+
}
108+
this.#configurators.set(module.name, instance);
109+
this.addConfig({ module: { ...module, configure: () => instance } as TModule });
110+
}
111+
112+
/**
113+
* Returns the configurator pinned for a module by name.
114+
*
115+
* @param name - The module's name, as passed to {@link _pin}.
116+
* @throws {Error} If no configurator has been pinned for that name.
117+
*/
118+
protected _getConfig<TConfig>(name: string): TConfig {
119+
const config = this.#configurators.get(name);
120+
if (config === undefined) {
121+
throw new Error(
122+
`No configurator is pinned for module "${name}" — call this._pin(module) before this._getConfig("${name}").`,
123+
);
124+
}
125+
return config as TConfig;
66126
}
67127

68128
/**
@@ -73,7 +133,7 @@ export class FrameworkMockConfigurator<
73133
* a change made here is what the module sees.
74134
*/
75135
public get msal(): MsalMockConfigurator {
76-
return this.#msal;
136+
return this._getConfig<MsalMockConfigurator>(msalMockModule.name);
77137
}
78138

79139
/**
@@ -84,7 +144,7 @@ export class FrameworkMockConfigurator<
84144
* module is configured from, so a change made here is what the module sees.
85145
*/
86146
public get serviceDiscovery(): ServiceDiscoveryMockConfigurator {
87-
return this.#serviceDiscovery;
147+
return this._getConfig<ServiceDiscoveryMockConfigurator>(serviceDiscoveryMockModule.name);
88148
}
89149

90150
/**

0 commit comments

Comments
 (0)