Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/docs-restructure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@equinor/fusion-framework-module-service-discovery": patch
"@equinor/fusion-framework-module-msal": patch
"@equinor/fusion-framework": minor
---

Restructure documentation so each README is an entry point rather than a manual.

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.

- **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.
- **service-discovery** — `docs/configuration.md`, `docs/testing.md`, `docs/session-overrides.md`, `docs/api-reference.md`.
- **framework** — `docs/testing.md`, `docs/testing-design.md`, `docs/testing-extending.md`, `docs/testing-api.md`.

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.
9 changes: 9 additions & 0 deletions .changeset/dot-path-optional-branches.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@equinor/fusion-framework-module": patch
---

Fix `DotPath` skipping over optional object properties, which made anything beneath them unreachable from `BaseConfigBuilder._set`.

An optional property is typed `T | undefined`, which does not extend `object`, so the path union stopped at the property itself: given `{ foo?: { bar: string } }`, `'foo'` was allowed but `'foo.bar'` was not. `DotPathType` already unwrapped such properties with `NonNullable`, so the two disagreed — a path it could resolve was one `_set` refused.

`DotPath` now unwraps the same way. This only widens the accepted union, so existing calls are unaffected.
9 changes: 9 additions & 0 deletions .changeset/framework-init-without-dom.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@equinor/fusion-framework": patch
---

`init` no longer throws when no DOM is present.

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`.

The assignment is now skipped when `window` is undefined. Browser behaviour is unchanged.
24 changes: 24 additions & 0 deletions .changeset/framework-mock-configurator-pin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"@equinor/fusion-framework": minor
---

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.

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:

```typescript
class AppMockConfigurator extends FrameworkMockConfigurator<[InvoiceModule]> {
constructor() {
super();
this._pin(invoiceMockModule);
}

get invoices(): InvoiceMockConfigurator {
return this._getConfig('invoices');
}
}
```

`_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.

`.msal` and `.serviceDiscovery` are unchanged for consumers; they are now built from `_pin`/`_getConfig` themselves rather than from two private fields.
16 changes: 16 additions & 0 deletions .changeset/framework-mock-configurator-remaining-modules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@equinor/fusion-framework": minor
---

Add `.services`, `.context` and `.telemetry` accessors to `FrameworkMockConfigurator`, alongside the existing `.msal`, `.serviceDiscovery` and `.http`.

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`:

```typescript
const configurator = new FrameworkMockConfigurator();
configurator.services.configureClient('my-api', { baseUri: 'http://localhost:6669' });

const fusion = await init(configurator);
```

`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.
37 changes: 37 additions & 0 deletions .changeset/framework-mock-entry-point.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
"@equinor/fusion-framework": minor
---

Add a `./mock` entry point for initializing the framework in a test.

```typescript
import { mockFramework } from '@equinor/fusion-framework/mock';

const fusion = await mockFramework();
```

`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.

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:

```typescript
const fusion = await mockFramework((configurator) => {
configurator.msal.setAccount({ name: 'Ada Lovelace' });
configurator.serviceDiscovery.setBaseUri('http://localhost:6669');
configurator.serviceDiscovery.addService({ key: 'my-api' });
});
```

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:

```typescript
const fusion = await mockFramework<[InvoiceModule]>((configurator) => {
enableInvoicesMock(configurator, { total: 42 });
});

await fusion.modules.invoices.getInvoice('inv-1'); // typed
```

`FrameworkMockConfigurator` is also exported for tests that need to hold on to the configurator and call `init` themselves.

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.
5 changes: 5 additions & 0 deletions .changeset/module-configurator-callback-replacement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@equinor/fusion-framework-module": patch
---

Ensure module re-registration replaces prior `configure`, `afterConfig`, and `afterInit` callbacks for modules with the same name. This prevents stale callback execution when mock modules like `enableMsalMock` override a real module registration.
23 changes: 23 additions & 0 deletions .changeset/module-http_mock-entry-point.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@equinor/fusion-framework-module-http": minor
---

Add a `mock` entry point (`@equinor/fusion-framework-module-http/mock`) so HTTP clients answer requests from registered route handlers instead of the network.

```typescript
import { enableHttpMock } from '@equinor/fusion-framework-module-http/mock';

enableHttpMock(configurator, (builder) => {
builder.configureClient('catalog', { baseUri: 'https://api.example.com' });
builder.get('/items', () => Response.json([{ id: 1 }]));
});
```

Route handlers are Fetch-standard middleware — `(request: Request) => Response | undefined | Promise<...>` — tried in registration order, with `undefined` falling through to the next one. One router is shared across every named client a `HttpMockConfigurator` builds, matched against each request's fully resolved URL.

Two adapters drop in an existing backend with no hard dependency on it:

- `fromExpressStyleHandler(handler)` — adapts an Express-style `(req, res)` handler (or a whole framework built from them, like `openapi-backend`).
- `fromOpenApiMock(openApiMock)` — adapts an `@equinor/fusion-openapi-mock` instance, so a real `openapi.json`/`openapi.yaml` fakes every response with no handlers written at all.

`@equinor/fusion-framework`'s `FrameworkMockConfigurator` now pins this instead of the real HTTP module, so `.http` on a mocked framework never reaches the network either.
9 changes: 9 additions & 0 deletions .changeset/module_configurator-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@equinor/fusion-framework-module": patch
---

Fix a bug in the module configurator that caused configurator phases (configure / post-initialize / dispose) to run out of order or skip post-configure hooks in certain initialization paths.

This ensures module configuration and plugin hooks run reliably during module initialization, preventing missed setup steps for consumer modules.

Fixes: restores correct configurator phase ordering and prevents lost initialization for modules that rely on post-configure hooks.
19 changes: 19 additions & 0 deletions .changeset/msal-config-schema-module.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@equinor/fusion-framework-module-msal": minor
---

Move the MSAL configuration schema into `MsalConfig.schema.ts` and add `MsalConfigExtension`, an extension point for variants of this module.

`MsalConfig` is now the schema's inferred type intersected with `MsalConfigExtension`, an empty interface a variant merges its own configuration into:

```typescript
declare module '@equinor/fusion-framework-module-msal' {
interface MsalConfigExtension {
mock?: { account?: MsalMockUser };
}
}
```

`BaseConfigBuilder._set` derives its target from `MsalConfig`, so before this a key the type did not know about could only be set by casting past the builder. Making the configurator generic over its configuration cannot solve that: a dot-path union over an unresolved type parameter defers, which takes every existing literal path down with it.

The schema is unchanged and still describes exactly what reaches `MsalProvider` — it strips anything merged in, so an extension carries a declaration across the builder and stops there. `MsalConfigSchema`, `TelemetryConfigSchema` and their types are re-exported from `MsalConfigurator` as before.
28 changes: 28 additions & 0 deletions .changeset/msal-create-client-seam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
"@equinor/fusion-framework-module-msal": minor
---

Extract client construction from `MsalConfigurator._processConfig` into overridable seams.

`_processConfig` now only decides *whether* a client is needed; `protected _createClient(config, init)` decides *what* to build, and `protected _createClientConfig(config)` resolves the `MsalClientConfig` it is built from — authority derived from the tenant, cache location, telemetry-backed logging and cache lookup policy included.

Behaviour is unchanged for consumers: the client is still auto-created from `setClientConfig`, and a client supplied through `setClient` still wins, because `_createClient` is consulted only when no client was set.

This gives a supported seam for authenticating through something other than Entra ID:

```typescript
class MyConfigurator extends MsalConfigurator {
protected override async _createClient(config: MsalConfig): Promise<IMsalClient> {
// same fully resolved configuration the real client is built from
return new MyOwnMsalClient(this._createClientConfig(config));
}
}
```

Overriding it replaces only the client, leaving the builder, the schema validation and `MsalProvider` untouched.

No client is built when the module is hoisted onto a host application's provider — an app running inside a portal authenticates through the host, so a client built during configuration would be discarded, or worse, shadow the host's signed-in user. `protected _isHoisted(init)` exposes that decision to subclasses.

Also adds `getClientConfig()`, the counterpart to `setClientConfig`, so a subclass can tell "nothing was declared" apart from "declared, and here it is".

`_createClientConfig` applies its defaults to a copy of the declared configuration, not the object itself, so a caller reusing or asserting on it never sees it rewritten, and a shared constant can be used as a default without one configurator's client contaminating the next.
22 changes: 22 additions & 0 deletions .changeset/msal-mock-account-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"@equinor/fusion-framework-module-msal": minor
---

Give `MsalMockClient` a real account cache, so its account APIs agree with each other and with MSAL.

Previously the client held a single nullable account, which made its surface inconsistent: `getAccount(filter)` ignored the filter, `getAllAccounts()` reported an account even after one had only been made active, and signing out merely blanked a field.

The client now keeps a cache keyed by `homeAccountId` alongside an active account, matching MSAL:

- `getAccount(filter)` matches on `homeAccountId`, `localAccountId`, `username` and `tenantId`.
- `getAllAccounts()` returns everything cached.
- Signing in adds the account and activates it; signing out removes it, rather than leaving a stale entry behind.
- `setUser` replaces the session, so declaring a second user never leaves two accounts cached.

`setActiveAccount` deliberately departs from MSAL in one respect: an account that was never issued by a sign-in is accepted and added to the cache. That makes swapping the user between tests a single line, without rebuilding the framework:

```typescript
beforeEach(() => {
fusion.modules.auth.client.setActiveAccount(account);
});
```
31 changes: 31 additions & 0 deletions .changeset/msal-mock-entry-point.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"@equinor/fusion-framework-module-msal": minor
---

Added a `./mock` entry point so applications can run against the auth module without credentials or network access.

```ts
import { enableMsalMock, createMsalMockClient } from '@equinor/fusion-framework-module-msal/mock';

// default mock user
enableMsalMock(configurator);

// or a specific one
enableMsalMock(configurator, (builder) => {
builder.setAccount({ name: 'Ada Lovelace' });
});
```

Only the MSAL **client** is substituted. `MsalMockClient` resolves tokens in-process and takes the same `MsalClientConfig` as the real `MsalClient`, so `setClientConfig` means the same thing whether a test runs against Entra ID or in-process. `MsalMockConfigurator` builds it through the `_createClient` seam, and `msalMockModule` differs from the real module in its `configure` alone — `initialize` is the production one, untouched, so `MsalProvider`, schema validation and the whole start-up path run exactly as they do in production. `IMsalProvider` and `MsalConfigurator` are untouched.

The same client works with the plain module, without the mock module:

```ts
enableMSAL(configurator, (builder) =>
builder.setClient(createMsalMockClient({ auth: { clientId: 'my-app' } }, { name: 'Ada Lovelace' })),
);
```

Tokens are structurally valid, unsigned JWTs and are identical between runs. They are not cryptographically valid and are rejected by any real service.

Exports `enableMsalMock`, `msalMockModule`, `MsalMockConfigurator`, `MsalMockClient`, `createMsalMockClient` and `createMockToken`. The entry point has no test-runner dependency.
34 changes: 34 additions & 0 deletions .changeset/msal-mock-set-account.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
"@equinor/fusion-framework-module-msal": minor
---

Add `setAccount` to `MsalMockConfigurator`, so a test declares the signed-in user on the builder instead of baking it into a client.

```typescript
enableMsalMock(configurator, (builder) => {
builder.setAccount({ name: 'Ada Lovelace', username: 'ada@equinor.com' });
});
```

It takes an object, `null` when nobody is signed in, or an ordinary `ConfigBuilderCallback` resolving either:

```typescript
builder.setAccount(null);
builder.setAccount(async ({ hasModule }) => ({
name: hasModule('app') ? 'App User' : 'Portal User',
}));
```

The callback is the builder's own type rather than a bespoke one, so it is handed the same arguments every other configuration callback receives and is resolved by the same machinery.

`null` and `{ signedOut: true }` both start without a session, and differ in what a later login resolves to: `null` forgets the identity, `signedOut` keeps it. A declared `null` is a declaration in its own right, so it overrides the default signed-in user — only an absent declaration means the test said nothing.

`setAccount` writes to `mock.account` on the configuration rather than to a field on the builder, so the user travels the ordinary builder pipeline: a callback is resolved by `_buildConfig` with the same arguments every other configuration callback receives, and the result is on the raw configuration before validation. The schema strips the key, so nothing about a test reaches `MsalProvider` — the branch exists purely to carry the declaration across the builder. `MsalMockConfig` is exported for code that reads it.

`setAccount` records configuration only — the user is signed in on the client as it is built, so it may be declared at any point before initialization and the last declaration wins. Keeping the user off `MsalClientConfig` is what lets `MsalMockClient` take the same argument the real `MsalClient` takes: a client is configured with *what it talks to*, never with *who is signed in*.

Because the user is in place before `MsalProvider.initialize()` runs, the provider's own start-up path acts on it. Pairing `{ signedOut: true }` with `setRequiresAuth(true)` therefore exercises the real automatic login, rather than a state assigned after initialization had already finished.

Because who is signed in is session state rather than client configuration, the user is applied to whichever client the module ends up authenticating through — wherever that client was built. When the module is hoisted onto a host application's provider, no client is built here, so the user is signed in on the *host's* client instead. Without that, a declaration made in an application's test would silently do nothing precisely when the application is being tested inside a portal. The session is shared, so the host sees the same user, as it does in production; when the host does not authenticate through a mock client the declaration throws rather than failing quietly.

`setClient` replaces the client, but not the rule: a mock client supplied that way receives the declared user too.
26 changes: 26 additions & 0 deletions .changeset/service-discovery-mock-entry-point.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@equinor/fusion-framework-module-service-discovery": minor
---

Added a `./mock` entry point exporting `mockServiceDiscovery`, `enableServiceDiscoveryMock`, `ServiceDiscoveryMockConfigurator` and `ServiceDiscoveryMockClient`, so service discovery resolves from an in-memory registry instead of the network.

```ts
import { mockServiceDiscovery } from '@equinor/fusion-framework-module-service-discovery/mock';

mockServiceDiscovery(configurator, { services: [{ key: 'apps', uri: 'https://apps.test' }] });
```

`ServiceDiscoveryMockConfigurator` builds the registry on the builder itself — `setBaseUri`, `addService`, `addServices`, `removeService`, `setServices`, `setResolveUnknownServices` — and the client is constructed from that registry when the module builds its config. A test never has to construct a client just to add a service or point services at a local mock server.

```ts
import { enableServiceDiscoveryMock } from '@equinor/fusion-framework-module-service-discovery/mock';

enableServiceDiscoveryMock(configurator, (builder) => {
builder.setBaseUri('http://localhost:6669');
builder.addService({ key: 'my-api' });
});
```

`setBaseUri` lets the default service endpoints point at a local mock server such as `http://localhost:3000`, letting an application make real HTTP calls against Mockoon, Prism or the dev server without a service worker intercepting requests.

Also widened `configureServiceDiscovery` to accept a synchronous callback. The underlying builder always allowed it; only the exported type required a promise.
Loading
Loading