Skip to content

Commit d1a8b58

Browse files
odinrCopilot
andcommitted
feat(module-event): add mock entry point for recording and intercepting events
Add @equinor/fusion-framework-module-event/mock: EventMockConfigurator records every event dispatched through the framework and lets a test intercept or cancel one before its listeners run, closing the gap the existing event$ observable can't (subscribers there run strictly after dispatch, so preventDefault() has no effect from there). - EventMockConfigurator: events/getEvents/lastEvent for assertions, intercept(type, handler) for pre-listener interception/cancellation, clear() to reset recorded history. - enableEventMock / createEventMockModule: pins a single configurator instance while still wiring onBubble from ref at configure time, since the real module's bubbling decision can only be made then. - FrameworkMockConfigurator.event: pinned alongside .msal/.serviceDiscovery/ .http, using the same ref-aware wiring rather than the generic _pin (which would freeze the bubbling decision before ref could be known). - docs/testing.md + README Testing section, following the http module's established doc convention. - Tests: 9 new in the event module, 2 new in FrameworkMockConfigurator's suite, all passing alongside the existing 853. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 340a72f commit d1a8b58

11 files changed

Lines changed: 594 additions & 6 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@equinor/fusion-framework-module-event": minor
3+
"@equinor/fusion-framework": minor
4+
---
5+
6+
Add a `mock` entry point (`@equinor/fusion-framework-module-event/mock`) so a test can record every event dispatched through the framework and intercept or cancel one before its listeners run.
7+
8+
```typescript
9+
import { enableEventMock } from '@equinor/fusion-framework-module-event/mock';
10+
11+
const event = enableEventMock(configurator);
12+
13+
await fusion.modules.event.dispatchEvent('myFeature', { detail: { id: 1 } });
14+
expect(event.lastEvent('myFeature')?.detail).toEqual({ id: 1 });
15+
```
16+
17+
`EventMockConfigurator.intercept(type, handler)` runs before `addEventListener` handlers, for the same reason `event$` alone was not enough: canceling a cancelable event through `preventDefault()` only stops listeners if it happens before dispatch, which `event$` subscribers, running strictly after dispatch, cannot do.
18+
19+
`@equinor/fusion-framework`'s `FrameworkMockConfigurator` now pins this through `.event`, alongside `.msal`, `.serviceDiscovery` and `.http` — reachable synchronously right after construction, the same way the others are, while the real module's `onBubble` wiring to a parent event provider still happens at configure time.

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

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it } from 'vitest';
1+
import { describe, expect, it, vi } from 'vitest';
22

33
import type { Module } from '@equinor/fusion-framework-module';
44
import { enableMsalMock } from '@equinor/fusion-framework-module-msal/mock';
@@ -171,6 +171,31 @@ describe('FrameworkMockConfigurator', () => {
171171
expect(fusion.modules.http.createClient('my-api')).toBeDefined();
172172
});
173173

174+
it('records every event dispatched through the framework, including onModulesLoaded', async () => {
175+
const configurator = new FrameworkMockConfigurator();
176+
177+
const fusion = await init(configurator);
178+
await fusion.modules.event.dispatchEvent('myFeature', { detail: { id: 1 } });
179+
180+
expect(configurator.event.getEvents('onModulesLoaded')).toHaveLength(1);
181+
expect(configurator.event.lastEvent('myFeature')?.detail).toEqual({ id: 1 });
182+
});
183+
184+
it('lets a test cancel an event before its listeners run', async () => {
185+
const configurator = new FrameworkMockConfigurator();
186+
configurator.event.intercept('myFeature', (event) => event.preventDefault());
187+
188+
const fusion = await init(configurator);
189+
const listener = vi.fn();
190+
fusion.modules.event.addEventListener('myFeature', listener);
191+
await fusion.modules.event.dispatchEvent('myFeature', {
192+
detail: null,
193+
cancelable: true,
194+
});
195+
196+
expect(listener).not.toHaveBeenCalled();
197+
});
198+
174199
it('exposes the same services configurator the services module is built from', () => {
175200
const configurator = new FrameworkMockConfigurator();
176201

packages/framework/src/mock/FrameworkMockConfigurator.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import type { AnyModule } from '@equinor/fusion-framework-module';
33
import contextModule, {
44
type ContextModuleConfigurator,
55
} from '@equinor/fusion-framework-module-context';
6+
import { eventModuleKey } from '@equinor/fusion-framework-module-event';
7+
import {
8+
createEventMockModule,
9+
type EventMockConfigurator,
10+
} from '@equinor/fusion-framework-module-event/mock';
611
import {
712
httpMockModule,
813
type HttpMockConfigurator,
@@ -43,10 +48,13 @@ import { FrameworkConfigurator } from '../FrameworkConfigurator.js';
4348
* are reachable the same way `.msal` is, since their `configure` factories
4449
* take no `ref` and so lose nothing by being pinned early.
4550
*
46-
* `event` is deliberately not pinned: its `configure` factory reads `ref` to
47-
* wire bubbling to a parent event provider when this configurator is hoisted
48-
* inside a host framework, and pinning would freeze that decision before a
49-
* `ref` could ever be known.
51+
* `event` is pinned through {@link createEventMockModule} rather than
52+
* {@link _pin}: its `configure` factory reads `ref` to wire bubbling to a
53+
* parent event provider when this configurator is hoisted inside a host
54+
* framework, a decision `_pin` would freeze before any `ref` could be known.
55+
* `.event` still records every dispatched event and lets a test intercept or
56+
* cancel one, so a test never subscribes to `event$` or races
57+
* `addEventListener` against dispatch order just to observe what fired.
5058
*
5159
* Because this *is* a `FrameworkConfigurator`, every `enableX` helper an
5260
* application already uses accepts it unchanged — including the ones an
@@ -90,6 +98,12 @@ export class FrameworkMockConfigurator<
9098
this._pin(servicesModule);
9199
this._pin(contextModule);
92100
this._pin(telemetryModule);
101+
102+
// `event`'s configure factory needs `ref` at configure time to wire
103+
// bubbling, so it is pinned by hand instead of through `_pin`.
104+
const { configurator: eventConfigurator, module: eventMockModule } = createEventMockModule();
105+
this.#configurators.set(eventMockModule.name, eventConfigurator);
106+
this.addConfig({ module: eventMockModule });
93107
}
94108

95109
/**
@@ -175,6 +189,20 @@ export class FrameworkMockConfigurator<
175189
return this._getConfig<MsalMockConfigurator>(msalMockModule.name);
176190
}
177191

192+
/**
193+
* Records and intercepts every event dispatched through the framework.
194+
*
195+
* @remarks
196+
* The same {@link EventMockConfigurator} the event module is configured
197+
* from, so a change made here — such as canceling an event through
198+
* {@link EventMockConfigurator.intercept} — is what the module sees.
199+
*
200+
* @returns The event mock configurator.
201+
*/
202+
public get event(): EventMockConfigurator {
203+
return this._getConfig<EventMockConfigurator>(eventModuleKey);
204+
}
205+
178206
/**
179207
* Configures the registry services are resolved from.
180208
*

packages/modules/event/README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,4 +193,10 @@ await modules.event.dispatchEvent('myEvent', {
193193
detail: data,
194194
canBubble: false,
195195
});
196-
```
196+
```
197+
198+
## Testing
199+
200+
Import from `@equinor/fusion-framework-module-event/mock` to record every event dispatched through the framework and intercept or cancel one before its listeners run, instead of subscribing to `event$` or racing `addEventListener` against dispatch order.
201+
202+
See [Testing](docs/testing.md) for `enableEventMock`, `EventMockConfigurator`, and `FrameworkMockConfigurator.event`.
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# Testing
2+
3+
`@equinor/fusion-framework-module-event/mock` swaps the event module's configurator for one that records every dispatched event and lets a test intercept or cancel one before its listeners run — without changing the provider, the dispatcher, or any other part of the module's behavior.
4+
5+
## Why not just use `event$`?
6+
7+
The real module already exposes `event$`, an observable of every dispatched event (see the main [README](../README.md#observable-event-stream)). That is enough for read-only assertions, but two things it cannot do:
8+
9+
- **Cancel or rewrite an event before listeners run.** Subscribers to `event$` receive events strictly after dispatch — calling `preventDefault()` there has no effect on whether `addEventListener` handlers ran.
10+
- **Look events up synchronously by type**, without setting up a subscription before the event you care about fires and buffering it yourself.
11+
12+
`EventMockConfigurator` is `onDispatch` itself: it records the event and runs any registered interceptor, all inside the same hook the real event module calls before invoking listeners.
13+
14+
## Enabling the mock
15+
16+
### Through `FrameworkMockConfigurator`
17+
18+
```ts
19+
import { FrameworkMockConfigurator } from '@equinor/fusion-framework/mock';
20+
21+
const configurator = new FrameworkMockConfigurator();
22+
configurator.event.intercept('analytics', (event) => event.preventDefault());
23+
24+
const fusion = await init(configurator);
25+
```
26+
27+
`FrameworkMockConfigurator` already pins an `EventMockConfigurator` for you — reach it through `.event`, the same way `.msal` and `.serviceDiscovery` work.
28+
29+
### Through `enableEventMock` directly
30+
31+
Use this when composing a custom modules configurator that does not extend `FrameworkMockConfigurator`:
32+
33+
```ts
34+
import { ModulesConfigurator } from '@equinor/fusion-framework-module';
35+
import { enableEventMock } from '@equinor/fusion-framework-module-event/mock';
36+
37+
const configurator = new ModulesConfigurator([/* ... */]);
38+
const event = enableEventMock(configurator, (builder) => {
39+
builder.intercept('analytics', (e) => e.preventDefault());
40+
});
41+
42+
const instances = await configurator.initialize();
43+
// `event` is the same configurator instance `instances.event` was built from
44+
```
45+
46+
`enableEventMock` returns the `EventMockConfigurator` it built the module around, so there is no second lookup needed.
47+
48+
## Asserting on dispatched events
49+
50+
Every event dispatched through the provider — including the framework's own `onModulesLoaded` — is recorded, in dispatch order:
51+
52+
```ts
53+
await fusion.modules.event.dispatchEvent('myFeature', { detail: { id: 1 } });
54+
55+
configurator.event.events; // readonly FrameworkEvent[] — every event recorded so far
56+
configurator.event.getEvents('myFeature'); // only events of that type
57+
configurator.event.lastEvent('myFeature')?.detail; // { id: 1 }
58+
```
59+
60+
`lastEvent` and `getEvents` both accept an optional `type` filter; omit it to look across every event type.
61+
62+
Call `configurator.event.clear()` between assertions (or between test cases) to reset the recorded history without needing a fresh configurator. Registered interceptors are unaffected by `clear()`.
63+
64+
## Intercepting and canceling events
65+
66+
`intercept(type, handler)` registers a callback that runs **before** any `addEventListener` handler, for every event of the given type, in registration order:
67+
68+
```ts
69+
const teardown = configurator.event.intercept('tokenAcquired', (event) => {
70+
// A cancelable event canceled here never reaches addEventListener handlers.
71+
event.preventDefault();
72+
});
73+
74+
// later, to stop intercepting
75+
teardown();
76+
```
77+
78+
`preventDefault()` only has an effect on a `cancelable` event — declare the event as `cancelable: true` when dispatching it if a test needs to cancel it:
79+
80+
```ts
81+
await fusion.modules.event.dispatchEvent('myFeature', {
82+
detail: null,
83+
cancelable: true,
84+
});
85+
```
86+
87+
An interceptor can also just observe (for logging or an assertion) without calling `preventDefault()` — nothing about registering one requires canceling.
88+
89+
## Bubbling to a parent framework
90+
91+
The real event module's `configure` factory reads a `ref` argument, present when this configurator's framework instance is hoisted inside a host framework, to wire `onBubble` and forward events to the parent's event provider. `EventMockConfigurator` does not decide this itself — `enableEventMock`'s module wiring still reads `ref` at configure time and assigns `onBubble` on the same pinned configurator, so a mock hoisted as a child framework still bubbles events exactly like the real module does.
92+
93+
This is also why `FrameworkMockConfigurator` does not use its usual `_pin` helper for the event module: `_pin` calls `configure()` once, synchronously, with no `ref` — which would freeze the bubbling decision before a `ref` could ever be known.
94+
95+
## Multiple named event configurators
96+
97+
Nothing about `EventMockConfigurator` is tied to a single framework instance — a test composing more than one `ModulesConfigurator` (for example, a host framework and a hoisted child) gets one recording configurator per `enableEventMock`/`FrameworkMockConfigurator` call, so events dispatched on one never appear on the other's `events` unless bubbling forwards them there.

packages/modules/event/package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,19 @@
99
".": {
1010
"import": "./dist/esm/index.js",
1111
"types": "./dist/types/index.d.ts"
12+
},
13+
"./mock": {
14+
"import": "./dist/esm/mock/index.js",
15+
"types": "./dist/types/mock/index.d.ts"
1216
}
1317
},
1418
"typesVersions": {
1519
"*": {
1620
".": [
1721
"dist/types/index.d.ts"
22+
],
23+
"mock": [
24+
"dist/types/mock/index.d.ts"
1825
]
1926
}
2027
},
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { ModulesConfigurator } from '@equinor/fusion-framework-module';
3+
4+
import type { IEventModuleProvider } from '../../provider';
5+
import { module as realModule } from '../../module';
6+
import { EventMockConfigurator, createEventMockModule, enableEventMock } from '../../mock';
7+
8+
/**
9+
* Initializes the mock module through the real module system.
10+
*
11+
* @remarks
12+
* Deliberately avoids hand-building initialization arguments — the module
13+
* system's configure/initialize/post-initialize pipeline is exactly what a
14+
* test should exercise, since that pipeline (not a hand-rolled provider) is
15+
* what an application actually runs.
16+
*
17+
* @param configure - Optional callback receiving the mock configurator.
18+
* @returns The provider the module produced and the configurator it was
19+
* pinned to.
20+
*/
21+
const initializeMockWith = async (
22+
configure?: (builder: EventMockConfigurator) => void,
23+
): Promise<{ provider: IEventModuleProvider; configurator: EventMockConfigurator }> => {
24+
const configurator = new ModulesConfigurator([]);
25+
const eventConfigurator = enableEventMock(configurator, configure);
26+
const instances = await configurator.initialize();
27+
return {
28+
provider: (instances as unknown as { event: IEventModuleProvider }).event,
29+
configurator: eventConfigurator,
30+
};
31+
};
32+
33+
describe('eventMockModule', () => {
34+
it('changes nothing but the configurator', () => {
35+
const { module: eventMockModule } = createEventMockModule();
36+
expect(eventMockModule.name).toBe(realModule.name);
37+
expect(eventMockModule.version).toBe(realModule.version);
38+
expect(eventMockModule.initialize).toBe(realModule.initialize);
39+
expect(eventMockModule.postInitialize).toBe(realModule.postInitialize);
40+
expect(eventMockModule.dispose).toBe(realModule.dispose);
41+
});
42+
43+
it('records every dispatched event, in dispatch order', async () => {
44+
const { provider, configurator } = await initializeMockWith();
45+
46+
await provider.dispatchEvent('first', { detail: 1 });
47+
await provider.dispatchEvent('second', { detail: 2 });
48+
49+
expect(configurator.events.map((e) => e.type)).toEqual([
50+
// `onModulesLoaded` is dispatched by the module's own `postInitialize`
51+
'onModulesLoaded',
52+
'first',
53+
'second',
54+
]);
55+
});
56+
57+
it('filters recorded events by type', async () => {
58+
const { provider, configurator } = await initializeMockWith();
59+
60+
await provider.dispatchEvent('myEvent', { detail: 1 });
61+
await provider.dispatchEvent('myEvent', { detail: 2 });
62+
await provider.dispatchEvent('otherEvent', { detail: 3 });
63+
64+
expect(configurator.getEvents('myEvent').map((e) => e.detail)).toEqual([1, 2]);
65+
expect(configurator.lastEvent('myEvent')?.detail).toBe(2);
66+
});
67+
68+
it('returns undefined from lastEvent when nothing of that type dispatched', async () => {
69+
const { configurator } = await initializeMockWith();
70+
expect(configurator.lastEvent('neverDispatched')).toBeUndefined();
71+
});
72+
73+
it('clears recorded history without touching interceptors', async () => {
74+
const { provider, configurator } = await initializeMockWith();
75+
76+
await provider.dispatchEvent('myEvent', { detail: 1 });
77+
configurator.clear();
78+
expect(configurator.events).toEqual([]);
79+
80+
const intercept = vi.fn();
81+
configurator.intercept('myEvent', intercept);
82+
configurator.clear();
83+
await provider.dispatchEvent('myEvent', { detail: 2 });
84+
expect(intercept).toHaveBeenCalledTimes(1);
85+
});
86+
87+
it('runs a registered interceptor before listeners', async () => {
88+
const { provider, configurator } = await initializeMockWith();
89+
const order: string[] = [];
90+
91+
configurator.intercept('myEvent', () => {
92+
order.push('intercepted');
93+
});
94+
provider.addEventListener('myEvent', () => {
95+
order.push('listened');
96+
});
97+
98+
await provider.dispatchEvent('myEvent', { detail: null });
99+
100+
expect(order).toEqual(['intercepted', 'listened']);
101+
});
102+
103+
it('lets an interceptor cancel a cancelable event before listeners run', async () => {
104+
const { provider, configurator } = await initializeMockWith();
105+
const listener = vi.fn();
106+
107+
configurator.intercept('myEvent', (event) => event.preventDefault());
108+
provider.addEventListener('myEvent', listener);
109+
110+
await provider.dispatchEvent('myEvent', { detail: null, cancelable: true });
111+
112+
expect(listener).not.toHaveBeenCalled();
113+
});
114+
115+
it('removes an interceptor through its teardown', async () => {
116+
const { provider, configurator } = await initializeMockWith();
117+
const intercept = vi.fn();
118+
119+
const teardown = configurator.intercept('myEvent', intercept);
120+
teardown();
121+
await provider.dispatchEvent('myEvent', { detail: null });
122+
123+
expect(intercept).not.toHaveBeenCalled();
124+
});
125+
126+
it('configures the recorder through enableEventMock', async () => {
127+
const { configurator } = await initializeMockWith((builder) => {
128+
builder.intercept('myEvent', (event) => event.preventDefault());
129+
});
130+
131+
expect(configurator).toBeInstanceOf(EventMockConfigurator);
132+
});
133+
});

0 commit comments

Comments
 (0)