@equinor/fusion-framework-module-analytics 3.0.6 → 3.0.8-next.0

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.
@@ -0,0 +1,178 @@
1
+ import { Subject, filter, type Subscription } from 'rxjs';
2
+
3
+ import type { IAnalyticsAdapter } from '../adapters/AnalyticsAdapter.interface.js';
4
+ import type { AnalyticsEvent } from '../types.js';
5
+
6
+ /**
7
+ * Selects which recorded events {@link MockAnalyticsAdapter.waitForAnalytic} or
8
+ * {@link MockAnalyticsAdapter.getAnalytics} act on.
9
+ *
10
+ * - `string` — matches `event.name` exactly.
11
+ * - `string[]` — matches if `event.name` is any of the given entries.
12
+ * - `(event) => boolean` — arbitrary predicate over the full event.
13
+ */
14
+ export type AnalyticsEventMatcher<T extends AnalyticsEvent = AnalyticsEvent> =
15
+ | string
16
+ | string[]
17
+ | ((event: T) => boolean);
18
+
19
+ /** Options accepted by {@link MockAnalyticsAdapter.waitForAnalytic}. */
20
+ export interface WaitForAnalyticOptions {
21
+ /**
22
+ * Maximum time in milliseconds to wait for a matching event.
23
+ * When elapsed the returned promise rejects.
24
+ */
25
+ timeout?: number;
26
+ /**
27
+ * AbortSignal that can cancel the wait early.
28
+ * When aborted the returned promise rejects with the signal's reason.
29
+ */
30
+ signal?: AbortSignal;
31
+ }
32
+
33
+ /**
34
+ * An {@link IAnalyticsAdapter} that records every tracked event in-memory instead
35
+ * of exporting it to a backend, for asserting on analytics in tests.
36
+ *
37
+ * @remarks
38
+ * Register it like any other adapter via {@link IAnalyticsConfigurator.setAdapter};
39
+ * it does not interfere with other adapters registered alongside it.
40
+ *
41
+ * @template T - Analytics event type, defaults to {@link AnalyticsEvent}.
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * import { MockAnalyticsAdapter } from '@equinor/fusion-framework-module-analytics/mock';
46
+ *
47
+ * const recorder = new MockAnalyticsAdapter();
48
+ * enableAnalytics(configurator, (builder) => {
49
+ * builder.setAdapter('mock', async () => recorder);
50
+ * });
51
+ *
52
+ * // ...later, in a test
53
+ * const event = await recorder.waitForAnalytic('button-click');
54
+ * expect(event.attributes?.section).toBe('header');
55
+ * ```
56
+ */
57
+ export class MockAnalyticsAdapter<T extends AnalyticsEvent = AnalyticsEvent>
58
+ implements IAnalyticsAdapter<T>
59
+ {
60
+ #events: T[] = [];
61
+ #events$ = new Subject<T>();
62
+
63
+ /**
64
+ * Records the event so it is visible to {@link getAnalytics} and any pending
65
+ * {@link waitForAnalytic} calls.
66
+ *
67
+ * @param event - The analytics event to record.
68
+ */
69
+ registerAnalytic(event: T): void {
70
+ this.#events.push(event);
71
+ this.#events$.next(event);
72
+ }
73
+
74
+ /**
75
+ * Returns recorded events matching `matcher`, in dispatch order.
76
+ *
77
+ * @param matcher - Event name, array of names, or a predicate. Omit to get every recorded event.
78
+ * @returns Matching recorded events.
79
+ */
80
+ getAnalytics(matcher?: AnalyticsEventMatcher<T>): T[] {
81
+ // No matcher: return every event recorded so far.
82
+ if (matcher === undefined) return [...this.#events];
83
+ // Narrow down to events accepted by the matcher.
84
+ return this.#events.filter((event) => this.#matches(event, matcher));
85
+ }
86
+
87
+ /**
88
+ * Waits for the next event matching `matcher`, resolving immediately if a
89
+ * matching event was already recorded.
90
+ *
91
+ * @param matcher - Event name, array of names, or a predicate.
92
+ * @param options - Optional timeout (ms) or AbortSignal.
93
+ * @returns A promise that resolves with the first matching event.
94
+ */
95
+ waitForAnalytic(matcher: AnalyticsEventMatcher<T>, options?: WaitForAnalyticOptions): Promise<T> {
96
+ // Already recorded: resolve immediately rather than only watching future events.
97
+ const recorded = this.#events.find((event) => this.#matches(event, matcher));
98
+ // Already recorded: resolve immediately rather than only watching future events.
99
+ if (recorded) return Promise.resolve(recorded);
100
+
101
+ return new Promise<T>((resolve, reject) => {
102
+ const { timeout: ms, signal } = options ?? {};
103
+
104
+ // Fail fast without subscribing when the caller already aborted.
105
+ if (signal?.aborted) {
106
+ reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
107
+ return;
108
+ }
109
+
110
+ let timer: ReturnType<typeof setTimeout> | undefined;
111
+ // Declared before subscribing so `complete` can reach it even when the
112
+ // adapter is already disposed and fires synchronously during `subscribe`.
113
+ let sub: Subscription | undefined;
114
+
115
+ const cleanup = () => {
116
+ clearTimeout(timer);
117
+ sub?.unsubscribe();
118
+ };
119
+
120
+ // Only forward events accepted by the matcher to the subscriber below.
121
+ sub = this.#events$.pipe(filter((event) => this.#matches(event, matcher))).subscribe({
122
+ next: (event) => {
123
+ cleanup();
124
+ resolve(event);
125
+ },
126
+ // A throwing predicate matcher surfaces here instead of hanging the promise forever.
127
+ error: (err) => {
128
+ cleanup();
129
+ reject(err);
130
+ },
131
+ complete: () => {
132
+ cleanup();
133
+ reject(new Error('MockAnalyticsAdapter disposed before a matching event was recorded'));
134
+ },
135
+ });
136
+
137
+ // Only arm a timeout when the caller opted in.
138
+ if (ms !== undefined) {
139
+ timer = setTimeout(() => {
140
+ cleanup();
141
+ reject(new Error(`waitForAnalytic timed out after ${ms}ms`));
142
+ }, ms);
143
+ }
144
+
145
+ // Only wire abort handling when the caller passed a signal.
146
+ if (signal) {
147
+ signal.addEventListener(
148
+ 'abort',
149
+ () => {
150
+ cleanup();
151
+ reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
152
+ },
153
+ { once: true },
154
+ );
155
+ }
156
+ });
157
+ }
158
+
159
+ /**
160
+ * Tests whether `event` satisfies `matcher`.
161
+ *
162
+ * @param event - Event to test.
163
+ * @param matcher - Event name, array of names, or a predicate.
164
+ * @returns Whether `event` matches.
165
+ */
166
+ #matches(event: T, matcher: AnalyticsEventMatcher<T>): boolean {
167
+ // String matcher: compare event name directly.
168
+ if (typeof matcher === 'string') return event.name === matcher;
169
+ // Array matcher: match against any of the given names.
170
+ if (Array.isArray(matcher)) return matcher.includes(event.name);
171
+ return matcher(event);
172
+ }
173
+
174
+ /** Completes the internal event stream, rejecting any pending `waitForAnalytic` calls. */
175
+ [Symbol.dispose]() {
176
+ this.#events$.complete();
177
+ }
178
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Mock analytics adapter for tests: records tracked events in-memory instead
3
+ * of exporting them to a backend.
4
+ *
5
+ * @remarks
6
+ * Register it like any other {@link IAnalyticsAdapter} via
7
+ * {@link IAnalyticsConfigurator.setAdapter} — it observes tracked events
8
+ * alongside real adapters without affecting their delivery.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * import { MockAnalyticsAdapter } from '@equinor/fusion-framework-module-analytics/mock';
13
+ *
14
+ * const recorder = new MockAnalyticsAdapter();
15
+ * enableAnalytics(configurator, (builder) => {
16
+ * builder.setAdapter('mock', async () => recorder);
17
+ * });
18
+ *
19
+ * const event = await recorder.waitForAnalytic('button-click');
20
+ * expect(event.attributes?.section).toBe('header');
21
+ * ```
22
+ *
23
+ * @packageDocumentation
24
+ */
25
+ export {
26
+ MockAnalyticsAdapter,
27
+ type AnalyticsEventMatcher,
28
+ type WaitForAnalyticOptions,
29
+ } from './MockAnalyticsAdapter.js';
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by genversion.
2
- export const version = '3.0.6';
2
+ export const version = '3.0.8-next.0';
@@ -0,0 +1,11 @@
1
+ import { defineProject } from 'vitest/config';
2
+
3
+ import { name, version } from './package.json' with { type: 'json' };
4
+
5
+ export default defineProject({
6
+ test: {
7
+ environment: 'node',
8
+ include: ['src/__tests__/**/*.test.ts'],
9
+ name: `${name}@${version}`,
10
+ },
11
+ });