Skip to content

Commit 5c1868e

Browse files
odinrCopilot
andcommitted
feat(framework): add a mock entry point that composes each module's own test double
Adds @equinor/fusion-framework/mock for initializing the framework in a test: import { mockFramework } from '@equinor/fusion-framework/mock'; const fusion = await mockFramework((configurator) => { configurator.msal.setAccount({ name: 'Ada Lovelace' }); configurator.serviceDiscovery.setBaseUri('http://localhost:6669'); }); mockFramework runs the real configure -> initialize pipeline with the real built-in modules and substitutes only the boundaries that leave the process - the MSAL client and the service discovery client. Module wiring, configuration validation and lifecycle hooks behave as they do in production. FrameworkMockConfigurator gains _pin/_getConfig, so a module supplied through TModules gets the same kind of named accessor .msal and .serviceDiscovery already have, rather than that pinning being hand-written once per built-in mock. .services, .context and .telemetry are exposed the same way, though they still perform real I/O until those modules have a test double of their own. event is intentionally left out - its configure factory reads ref to wire event bubbling to a parent event provider when hoisted, and pinning it would call configure() with ref always undefined, silently disabling that bubbling. The entry point owns no mock logic of its own: each module exports its own test double from its own ./mock entry point, and this one only composes the built-in set. It has no test-runner dependency and provides no mocking API, since replacing an individual call belongs to the test runner. init() no longer throws when no DOM is present - the window.Fusion assignment (for portal shells and widgets) is now skipped when window is undefined, so a test runner using the node environment or a server-side render no longer fails with 'ReferenceError: window is not defined'. Also restructures documentation so each README is an entry point rather than a manual: long-form content (including each package's testing.md) moved into per-package docs/ folders, matching the convention already used by @equinor/fusion-framework-module and @equinor/fusion-framework-module-http. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e60efe2 commit 5c1868e

18 files changed

Lines changed: 1289 additions & 3 deletions

.changeset/docs-restructure.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@equinor/fusion-framework-module-service-discovery": patch
3+
"@equinor/fusion-framework-module-msal": patch
4+
"@equinor/fusion-framework": minor
5+
---
6+
7+
Restructure documentation so each README is an entry point rather than a manual.
8+
9+
Long-form content moved into per-package `docs/` folders, matching the convention already used by `@equinor/fusion-framework-module` and `@equinor/fusion-framework-module-http`. Each README now keeps the elevator pitch, the shortest working example and a documentation table linking to the rest.
10+
11+
- **msal**`docs/api-reference.md`, `docs/auth-code-flow.md`, `docs/testing.md`, `docs/version-management.md`, `docs/migration-v2-to-v4.md`, `docs/troubleshooting.md`. The README also gained the top-level heading it was missing.
12+
- **service-discovery**`docs/configuration.md`, `docs/testing.md`, `docs/session-overrides.md`, `docs/api-reference.md`.
13+
- **framework**`docs/testing.md`, `docs/testing-design.md`, `docs/testing-extending.md`, `docs/testing-api.md`.
14+
15+
Both module READMEs now document their `/mock` entry point, which was previously undocumented, and state that spying on an individual call is the test runner's job rather than something these packages provide.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@equinor/fusion-framework": patch
3+
---
4+
5+
`init` no longer throws when no DOM is present.
6+
7+
The running instance is published as `window.Fusion` for portal shells and widgets. That assignment was unguarded, so initializing the framework anywhere without a `window` — a test runner using the `node` environment, or a server-side render — failed with `ReferenceError: window is not defined`.
8+
9+
The assignment is now skipped when `window` is undefined. Browser behaviour is unchanged.
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.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@equinor/fusion-framework": minor
3+
---
4+
5+
Add `.services`, `.context` and `.telemetry` accessors to `FrameworkMockConfigurator`, alongside the existing `.msal`, `.serviceDiscovery` and `.http`.
6+
7+
These three modules have no test double yet, so anything read through `.services`, `.context` or `.telemetry` still performs real I/O — but their configurators are now reachable synchronously the same way `.msal` and `.serviceDiscovery` already are, since none of their `configure` factories depend on `ref`:
8+
9+
```typescript
10+
const configurator = new FrameworkMockConfigurator();
11+
configurator.services.configureClient('my-api', { baseUri: 'http://localhost:6669' });
12+
13+
const fusion = await init(configurator);
14+
```
15+
16+
`event` is intentionally left out: its `configure` factory reads `ref` to wire event bubbling to a parent event provider when `FrameworkMockConfigurator` is hoisted inside a host framework, and pinning it would call `configure()` with `ref` always `undefined` — silently disabling that bubbling.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@equinor/fusion-framework": minor
3+
---
4+
5+
Add a `./mock` entry point for initializing the framework in a test.
6+
7+
```typescript
8+
import { mockFramework } from '@equinor/fusion-framework/mock';
9+
10+
const fusion = await mockFramework();
11+
```
12+
13+
`mockFramework` runs the real configure → initialize pipeline with the real built-in modules and substitutes only the boundaries that leave the process — the MSAL client and the service discovery client. Module wiring, configuration validation and lifecycle hooks behave as they do in production, so a test still catches wiring mistakes that a hand-built replacement for the module graph would hide.
14+
15+
It takes a single callback receiving a `FrameworkMockConfigurator`, which **is** a `FrameworkConfigurator`. Modules whose boundary is mocked expose their own configurator as a property, so a test configures them without registering a callback:
16+
17+
```typescript
18+
const fusion = await mockFramework((configurator) => {
19+
configurator.msal.setAccount({ name: 'Ada Lovelace' });
20+
configurator.serviceDiscovery.setBaseUri('http://localhost:6669');
21+
configurator.serviceDiscovery.addService({ key: 'my-api' });
22+
});
23+
```
24+
25+
Because it is a real configurator, every `enableX` helper an application already uses accepts it unchanged — including the ones a team writes for their own modules. Those modules can be passed as a type argument so they are typed on the configurator *and* on the resulting `fusion.modules` without a cast:
26+
27+
```typescript
28+
const fusion = await mockFramework<[InvoiceModule]>((configurator) => {
29+
enableInvoicesMock(configurator, { total: 42 });
30+
});
31+
32+
await fusion.modules.invoices.getInvoice('inv-1'); // typed
33+
```
34+
35+
`FrameworkMockConfigurator` is also exported for tests that need to hold on to the configurator and call `init` themselves.
36+
37+
The entry point owns no mock logic. Each module exports its own test double from its own `./mock` entry point, and this one composes the built-in set; an application module follows the same pattern and plugs in without any support from this package. It has no test-runner dependency and provides no mocking API, because replacing an individual call belongs to your test runner.

packages/framework/README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,35 @@ The framework ships with the following modules enabled by default:
9494
- **`@equinor/fusion-framework-module-context`** — application / project context selection
9595
- **`@equinor/fusion-framework-module-telemetry`** — telemetry, logging, and metadata
9696

97+
## Testing
98+
99+
Import from `@equinor/fusion-framework/mock` to initialize the framework in a test without credentials, without network access and without configuration.
100+
101+
```typescript
102+
import { mockFramework } from '@equinor/fusion-framework/mock';
103+
104+
const fusion = await mockFramework((configurator) => {
105+
configurator.msal.setAccount({ name: 'Ada Lovelace' });
106+
configurator.serviceDiscovery.setBaseUri('http://localhost:6669');
107+
});
108+
109+
const token = await fusion.modules.auth.acquireAccessToken();
110+
const apps = await fusion.modules.serviceDiscovery.resolveService('apps');
111+
```
112+
113+
The **real** configure → initialize pipeline runs with the **real** built-in modules; only the boundaries that leave the process — the MSAL client and the service discovery client — are substituted. Module wiring, configuration validation and lifecycle hooks therefore behave as they do in production, so a test still catches wiring mistakes.
114+
115+
The entry point has **no test-runner dependency**, and ships no mocking API of its own — spying on an individual call is your test runner's job.
116+
117+
## Documentation
118+
119+
| Guide | Covers |
120+
| --- | --- |
121+
| [Testing](./docs/testing.md) | Choosing the user, signed-out tests, composing the service registry, spying, and configuring as an application does |
122+
| [Testing — design](./docs/testing-design.md) | What is substituted and why, where mocks live, determinism, and test-runner support |
123+
| [Testing — adding a mock for another module](./docs/testing-extending.md) | Giving your own module a test double, and which framework modules are not covered yet |
124+
| [Testing — API](./docs/testing-api.md) | Every mock export and the package that owns it |
125+
97126
## Further reading
98127

99128
📚 [Full documentation](https://equinor.github.io/fusion-framework/)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# API
2+
3+
| Export | Owner | Purpose |
4+
| --- | --- | --- |
5+
| `mockFramework<TModules>(configure?)` | `/mock` | Build and initialize a framework instance for a test |
6+
| `FrameworkMockConfigurator<TModules>` | `/mock` | `FrameworkConfigurator` whose outward boundaries are mocked, exposing `msal` and `serviceDiscovery` |
7+
| `enableMsalMock(configurator, configure?)` | `-module-msal/mock` | Register the auth module with an in-process MSAL client |
8+
| `msalMockModule` | `-module-msal/mock` | The auth module with a mock client, for manual registration |
9+
| `MsalMockConfigurator` | `-module-msal/mock` | `MsalConfigurator` backed by a mock client (`setAccount`, `setClient`, …) |
10+
| `MsalMockClient(config)` | `-module-msal/mock` | Build the in-process MSAL client on its own, from the same `MsalClientConfig` as `MsalClient` |
11+
| `createMsalMockClient(config, user?)` | `-module-msal/mock` | Convenience alias for `new MsalMockClient(config)`, optionally signing a user in |
12+
| `createMockToken(claims?)` | `-module-msal/mock` | Mint a deterministic JWT |
13+
| `mockServiceDiscovery(configurator, options?, configure?)` | `-module-service-discovery/mock` | Replace service discovery with an in-memory registry |
14+
| `enableServiceDiscoveryMock(configurator, configure?)` | `-module-service-discovery/mock` | Register the discovery module with an in-memory registry |
15+
| `ServiceDiscoveryMockConfigurator` | `-module-service-discovery/mock` | `ServiceDiscoveryConfigurator` that builds an in-memory registry (`setBaseUri`, `addService`, …) |
16+
| `ServiceDiscoveryMockClient(options?)` | `-module-service-discovery/mock` | Build the in-memory discovery client on its own |
17+
| `defaultServiceDiscoveryMockServices` | `-module-service-discovery/mock` | Baseline services a Fusion app resolves at start-up |
18+
| `createMockService(service, baseUri?)` | `-module-service-discovery/mock` | Expand a sparse service declaration into a full `Service` |
19+
20+
Module-owned exports are re-exported from `@equinor/fusion-framework/mock` for convenience; importing them from their own package is equally valid.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Design
2+
3+
Why this entry point exists, what it substitutes, and the rules that keep it from growing into a mocking library.
4+
5+
## Why this exists
6+
7+
Fusion Framework cannot start without authenticating a user and resolving services from a registry. Both reach outside the process, so an application test either has to supply real credentials or hand-build a replacement for every built-in module.
8+
9+
This entry point removes that work. It runs the **real** configure → initialize pipeline with the **real** built-in modules, and substitutes only the boundaries that leave the process.
10+
11+
That distinction matters: module wiring, configuration validation and lifecycle hooks behave exactly as they do in production, so a test still catches wiring mistakes.
12+
13+
## How mocks are organised
14+
15+
> [!IMPORTANT]
16+
> This entry point contains **no mock logic**. Every module owns and exports its own test double from a `./mock` entry point. This entry point only composes the built-in set.
17+
18+
```
19+
@equinor/fusion-framework-module-msal/mock -> enableMsalMock
20+
@equinor/fusion-framework-module-service-discovery/mock -> mockServiceDiscovery
21+
@equinor/fusion-framework/mock -> composes the above
22+
```
23+
24+
Three consequences follow, and they are the reason for the split:
25+
26+
- **Mocks cannot drift.** A test double lives beside the implementation it stands in for, so a change to the real interface breaks it in the same package, in the same build.
27+
- **Mocks version with their module.** Installing msal `v10` gets msal `v10`'s mock. There is no version matrix to reconcile.
28+
- **Application modules work the same way.** A team's own module exposes its own mock entry point and composes without this entry point knowing it exists.
29+
30+
Adding mock support to another Fusion module means adding a `src/mock/` folder to that module — not editing this entry point.
31+
32+
## What is actually substituted
33+
34+
> [!IMPORTANT]
35+
> Only the **client** — the object that performs network I/O — is replaced. Providers, configurators, schema validation and module `initialize` are all real.
36+
37+
For authentication this means `MsalProvider` itself runs. A test therefore observes real provider behaviour, including decisions the provider makes on the caller's behalf:
38+
39+
```typescript
40+
const fusion = await mockFramework((configurator) => {
41+
configurator.msal.setClientConfig({ auth: { clientId: 'my-app', tenantId: 'my-tenant' } });
42+
});
43+
44+
const token = await fusion.modules.auth.acquireAccessToken();
45+
// scope is 'my-app/.default' — resolved by the real provider, not by the test double
46+
```
47+
48+
A double that replaced the provider would have skipped that logic and quietly reported whatever it was told to.
49+
50+
## Determinism
51+
52+
Tokens and resolved services are identical across runs and across machines. `createMockToken` uses a fixed issue time, so a token can be compared or snapshotted directly.
53+
54+
## Test-runner support
55+
56+
The package has **no test-runner dependency**. It builds a real framework instance and returns it; assertions are the caller's concern. It works under Vitest today and would work unchanged under another runner.
57+
58+
`vitest` appears only in `devDependencies`, for this entry point's own tests.
59+
60+
> [!IMPORTANT]
61+
> This is deliberate, and it is why there is **no Fusion mocking API**. The framework's job is making the runtime *substitutable* — a real configurator that validates, an I/O-boundary client that needs no network or credentials, a registry you compose on the builder. Replacing an individual call is your runner's job, and it does it better: call assertions, argument matchers and reset semantics you already know.
62+
>
63+
> Mock clients are therefore plain classes with ordinary methods. `vi.spyOn`, `bun:test`'s `spyOn` and Node's `t.mock.method` all work on them directly.
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Adding a mock for another module
2+
3+
How to give a module a test double, whether it is one of ours or one of yours.
4+
5+
## Mocking your own module
6+
7+
An application module needs no support from this entry point. Ship a test double next to the module and apply it alongside the framework mocks.
8+
9+
Two seams matter, and the second is the one teams miss:
10+
11+
1. **A client seam**`setClient`, the way `MsalConfigurator.setClient` and `setServiceDiscoveryClient` do — so the object performing I/O can be swapped.
12+
2. **Configuration on the builder** — so a test can adjust behaviour *without* constructing a client at all. `ServiceDiscoveryMockConfigurator.addService` is the reference: the configurator accumulates config, and the client is built from it when the module assembles its config.
13+
14+
```typescript
15+
// @my-app/module-invoices/mock
16+
export const mockInvoices = (configurator, options = {}) => {
17+
configurator.addConfig(
18+
configureInvoices((builder) => {
19+
builder.setClient({
20+
getInvoice: async (id) => ({ id, total: options.total ?? 100 }),
21+
});
22+
}),
23+
);
24+
};
25+
```
26+
27+
```typescript
28+
// @my-app/module-invoices — the module's own type, exported for tests
29+
export type InvoiceModule = Module<'invoices', InvoiceClient, InvoiceConfigurator>;
30+
```
31+
32+
Pass that descriptor to `mockFramework` as a type argument, and the module is typed on both the configurator and the returned instance:
33+
34+
```typescript
35+
const fusion = await mockFramework<[InvoiceModule]>((configurator) => {
36+
enableInvoicesMock(configurator, { total: 42 });
37+
});
38+
39+
await fusion.modules.invoices.getInvoice('inv-1'); // typed, no cast
40+
```
41+
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+
63+
## What is not covered yet
64+
65+
`.services`, `.context` and `.telemetry` are already reachable on `FrameworkMockConfigurator` — their `configure` factories take no `ref`, so they were safe to pin the same way `.msal` and `.serviceDiscovery` are. What is missing is a test double behind them: none of the three modules has a `src/mock/` folder yet, so anything issuing an actual request through them still reaches the network. Adding one means creating that folder in **that module**, then pinning its mock configurator with `_pin` and exposing it with `_getConfig` on `FrameworkMockConfigurator`, replacing the real module descriptor pinned there today.
66+
67+
`event` is not pinned at all, deliberately: its `configure` factory reads `ref` to wire event bubbling to a parent event provider when `FrameworkMockConfigurator` is hoisted inside a host framework. Pinning would call `configure()` with `ref` always `undefined`, silently breaking that bubbling — so it is left to build the normal way, from the module system's own configure phase, where `ref` is actually known.
68+
69+
## `.http`
70+
71+
`.http` is backed by `HttpMockConfigurator` (`@equinor/fusion-framework-module-http/mock`): every named client it builds answers requests from registered route handlers instead of the network, so a test needs no locally running server.
72+
73+
```typescript
74+
configurator.http.configureClient('catalog', { baseUri: 'https://api.example.com' });
75+
configurator.http.get('/items', () => Response.json([{ id: 1 }]));
76+
77+
const items = await fusion.modules.http.createClient('catalog').json('/items');
78+
```
79+
80+
Route handlers are Fetch-standard middleware — `(request: Request) => Response | undefined | Promise<...>` — matched in registration order, with `undefined` falling through to the next one. Three ways to fill that seam:
81+
82+
- **`.get`/`.post`/`.put`/`.patch`/`.delete`/`.on`** — hand-rolled handlers for a handful of routes.
83+
- **`fromExpressStyleHandler`** — adapts an Express-style `(req, res)` handler (or a whole framework built from them, like `openapi-backend`) into middleware, so `.use(fromExpressStyleHandler(api.handleRequest))` drops it straight in.
84+
- **`fromOpenApiMock`** — adapts an `@equinor/fusion-openapi-mock` instance (`createOpenApiMock(document)`), so a real `openapi.json`/`openapi.yaml` fakes every response with no handlers written at all until an edge case needs overriding.
85+
86+
All three are exported from `@equinor/fusion-framework-module-http/mock`.

0 commit comments

Comments
 (0)