@eyevinn/player-analytics-client-sdk-web 0.3.0 → 0.4.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,825 @@
1
+ import { Reporter, IReporterOptions } from "../src/utils/Reporter";
2
+ import { TPlayerAnalyticsEvent } from "@eyevinn/player-analytics-specification";
3
+
4
+ describe("Reporter", () => {
5
+ let mockFetch: jasmine.Spy;
6
+ let originalFetch: any;
7
+
8
+ beforeEach(() => {
9
+ // Save original fetch
10
+ originalFetch = globalThis.fetch;
11
+
12
+ // Mock fetch globally
13
+ mockFetch = jasmine.createSpy("fetch").and.returnValue(
14
+ Promise.resolve({
15
+ ok: true,
16
+ json: () => Promise.resolve({ sessionId: "server-generated-id" }),
17
+ statusText: "OK",
18
+ } as Response)
19
+ );
20
+ globalThis.fetch = mockFetch;
21
+ });
22
+
23
+ afterEach(() => {
24
+ // Restore original fetch
25
+ globalThis.fetch = originalFetch;
26
+ });
27
+
28
+ describe("sendBeacon()", () => {
29
+ let navigatorDescriptor: PropertyDescriptor | undefined;
30
+
31
+ function setNavigator(value: any) {
32
+ Object.defineProperty(globalThis, "navigator", {
33
+ value,
34
+ configurable: true,
35
+ writable: true,
36
+ });
37
+ }
38
+
39
+ beforeEach(() => {
40
+ navigatorDescriptor = Object.getOwnPropertyDescriptor(
41
+ globalThis,
42
+ "navigator"
43
+ );
44
+ });
45
+
46
+ afterEach(() => {
47
+ // Restore the original navigator descriptor
48
+ if (navigatorDescriptor) {
49
+ Object.defineProperty(globalThis, "navigator", navigatorDescriptor);
50
+ } else {
51
+ delete (globalThis as any).navigator;
52
+ }
53
+ });
54
+
55
+ const stoppedEvent: TPlayerAnalyticsEvent = {
56
+ event: "stopped",
57
+ sessionId: "test-session-id",
58
+ timestamp: 1234567890,
59
+ playhead: 42,
60
+ duration: 120,
61
+ payload: { reason: "aborted" },
62
+ } as TPlayerAnalyticsEvent;
63
+
64
+ async function readBlobJson(blob: Blob): Promise<any> {
65
+ return JSON.parse(await blob.text());
66
+ }
67
+
68
+ it("should send via navigator.sendBeacon with a text/plain JSON body (no fetch, no X-EPAS headers)", async () => {
69
+ const sendBeaconSpy = jasmine
70
+ .createSpy("sendBeacon")
71
+ .and.returnValue(true);
72
+ setNavigator({ sendBeacon: sendBeaconSpy });
73
+
74
+ const reporter = new Reporter({
75
+ eventsinkUrl: "https://example.com/analytics",
76
+ sessionId: "test-session-id",
77
+ shardId: "shard-123",
78
+ });
79
+ await reporter.init("test-session-id");
80
+ mockFetch.calls.reset();
81
+
82
+ const result = reporter.sendBeacon(stoppedEvent);
83
+
84
+ expect(result).toBe(true);
85
+ expect(mockFetch).not.toHaveBeenCalled();
86
+ expect(sendBeaconSpy).toHaveBeenCalledTimes(1);
87
+
88
+ const [url, blob] = sendBeaconSpy.calls.argsFor(0);
89
+ expect(url).toBe("https://example.com/analytics");
90
+ expect(blob instanceof Blob).toBe(true);
91
+ expect((blob as Blob).type).toContain("text/plain");
92
+
93
+ const body = await readBlobJson(blob as Blob);
94
+ expect(body.event).toBe("stopped");
95
+ // Reporter stamps its authoritative session identifiers (the server
96
+ // init response's sessionId wins) onto the payload
97
+ expect(body.sessionId).toBe("server-generated-id");
98
+ expect(body.shardId).toBe("shard-123");
99
+ expect(body.payload.reason).toBe("aborted");
100
+ });
101
+
102
+ it("should fall back to keepalive fetch when sendBeacon is unavailable", async () => {
103
+ setNavigator({});
104
+
105
+ const reporter = new Reporter({
106
+ eventsinkUrl: "https://example.com/analytics",
107
+ sessionId: "test-session-id",
108
+ });
109
+ await reporter.init("test-session-id");
110
+ mockFetch.calls.reset();
111
+
112
+ const result = reporter.sendBeacon(stoppedEvent);
113
+
114
+ expect(result).toBe(true);
115
+ expect(mockFetch).toHaveBeenCalledTimes(1);
116
+ const [url, fetchOptions] = mockFetch.calls.argsFor(0);
117
+ expect(url).toBe("https://example.com/analytics");
118
+ expect(fetchOptions.method).toBe("POST");
119
+ expect(fetchOptions.keepalive).toBe(true);
120
+ expect(fetchOptions.headers["Content-Type"]).toContain("text/plain");
121
+ // No X-EPAS-* headers on the beacon path (avoids the dropped preflight)
122
+ expect(fetchOptions.headers["X-EPAS-Event"]).toBeUndefined();
123
+ const body = JSON.parse(fetchOptions.body);
124
+ expect(body.event).toBe("stopped");
125
+ expect(body.sessionId).toBe("server-generated-id");
126
+ });
127
+
128
+ it("should fall back to keepalive fetch when sendBeacon returns false", async () => {
129
+ const sendBeaconSpy = jasmine
130
+ .createSpy("sendBeacon")
131
+ .and.returnValue(false);
132
+ setNavigator({ sendBeacon: sendBeaconSpy });
133
+
134
+ const reporter = new Reporter({
135
+ eventsinkUrl: "https://example.com/analytics",
136
+ sessionId: "test-session-id",
137
+ });
138
+ await reporter.init("test-session-id");
139
+ mockFetch.calls.reset();
140
+
141
+ const result = reporter.sendBeacon(stoppedEvent);
142
+
143
+ expect(result).toBe(true);
144
+ expect(sendBeaconSpy).toHaveBeenCalledTimes(1);
145
+ expect(mockFetch).toHaveBeenCalledTimes(1);
146
+ expect(mockFetch.calls.argsFor(0)[1].keepalive).toBe(true);
147
+ });
148
+
149
+ it("should log instead of sending in debug mode", async () => {
150
+ const sendBeaconSpy = jasmine
151
+ .createSpy("sendBeacon")
152
+ .and.returnValue(true);
153
+ setNavigator({ sendBeacon: sendBeaconSpy });
154
+ spyOn(console, "log");
155
+
156
+ const reporter = new Reporter({
157
+ eventsinkUrl: "https://example.com/analytics",
158
+ sessionId: "debug-session-id",
159
+ debug: true,
160
+ });
161
+ await reporter.init("debug-session-id");
162
+ mockFetch.calls.reset();
163
+
164
+ const result = reporter.sendBeacon(stoppedEvent);
165
+
166
+ expect(result).toBe(true);
167
+ expect(sendBeaconSpy).not.toHaveBeenCalled();
168
+ expect(mockFetch).not.toHaveBeenCalled();
169
+ expect(console.log).toHaveBeenCalled();
170
+ });
171
+
172
+ it("should return false and not send after destroy()", async () => {
173
+ const sendBeaconSpy = jasmine
174
+ .createSpy("sendBeacon")
175
+ .and.returnValue(true);
176
+ setNavigator({ sendBeacon: sendBeaconSpy });
177
+
178
+ const reporter = new Reporter({
179
+ eventsinkUrl: "https://example.com/analytics",
180
+ sessionId: "test-session-id",
181
+ });
182
+ await reporter.init("test-session-id");
183
+ reporter.destroy();
184
+ mockFetch.calls.reset();
185
+
186
+ const result = reporter.sendBeacon(stoppedEvent);
187
+
188
+ expect(result).toBe(false);
189
+ expect(sendBeaconSpy).not.toHaveBeenCalled();
190
+ expect(mockFetch).not.toHaveBeenCalled();
191
+ });
192
+ });
193
+
194
+ describe("init()", () => {
195
+ it("should call fetch with correct URL and headers", async () => {
196
+ const options: IReporterOptions = {
197
+ eventsinkUrl: "https://example.com/analytics",
198
+ heartbeatInterval: 30000,
199
+ };
200
+ const reporter = new Reporter(options);
201
+
202
+ await reporter.init();
203
+
204
+ expect(mockFetch).toHaveBeenCalledTimes(1);
205
+ const [url, fetchOptions] = mockFetch.calls.argsFor(0);
206
+ expect(url).toBe("https://example.com/analytics");
207
+ expect(fetchOptions.method).toBe("POST");
208
+ expect(fetchOptions.headers["Content-Type"]).toBe(
209
+ "application/json; charset=utf-8"
210
+ );
211
+ expect(fetchOptions.headers["X-EPAS-Event"]).toBe("init");
212
+ expect(fetchOptions.headers["X-EPAS-Version"]).toBeDefined();
213
+ });
214
+
215
+ it("should return sessionId from server response", async () => {
216
+ const options: IReporterOptions = {
217
+ eventsinkUrl: "https://example.com/analytics",
218
+ };
219
+ const reporter = new Reporter(options);
220
+
221
+ const result = await reporter.init();
222
+
223
+ expect(result.sessionId).toBe("server-generated-id");
224
+ expect(result.heartbeatInterval).toBeDefined();
225
+ expect(result.isInitiated).toBe(true);
226
+ });
227
+
228
+ it("should reuse provided sessionId when given", async () => {
229
+ const options: IReporterOptions = {
230
+ eventsinkUrl: "https://example.com/analytics",
231
+ sessionId: "existing-session-id",
232
+ };
233
+ const reporter = new Reporter(options);
234
+
235
+ mockFetch.and.returnValue(
236
+ Promise.resolve({
237
+ ok: true,
238
+ json: () => Promise.resolve({}),
239
+ statusText: "OK",
240
+ } as Response)
241
+ );
242
+
243
+ const result = await reporter.init("existing-session-id");
244
+
245
+ expect(result.sessionId).toBe("existing-session-id");
246
+
247
+ const bodyString = mockFetch.calls.argsFor(0)[1].body;
248
+ const body = JSON.parse(bodyString);
249
+ expect(body.sessionId).toBe("existing-session-id");
250
+ });
251
+
252
+ it("should handle fetch rejection gracefully", async () => {
253
+ const options: IReporterOptions = {
254
+ eventsinkUrl: "https://example.com/analytics",
255
+ };
256
+ const reporter = new Reporter(options);
257
+
258
+ mockFetch.and.returnValue(Promise.reject(new Error("Network error")));
259
+
260
+ await expectAsync(reporter.init()).toBeRejectedWithError("Network error");
261
+ });
262
+
263
+ it("should throw error when init response is not ok", async () => {
264
+ const options: IReporterOptions = {
265
+ eventsinkUrl: "https://example.com/analytics",
266
+ };
267
+ const reporter = new Reporter(options);
268
+
269
+ mockFetch.and.returnValue(
270
+ Promise.resolve({
271
+ ok: false,
272
+ statusText: "Internal Server Error",
273
+ } as Response)
274
+ );
275
+
276
+ await expectAsync(reporter.init()).toBeRejectedWithError(
277
+ "[AnalyticsReporter] init failed: Internal Server Error"
278
+ );
279
+ });
280
+
281
+ it("should throw error when no sessionId in response and none provided", async () => {
282
+ const options: IReporterOptions = {
283
+ eventsinkUrl: "https://example.com/analytics",
284
+ };
285
+ const reporter = new Reporter(options);
286
+
287
+ mockFetch.and.returnValue(
288
+ Promise.resolve({
289
+ ok: true,
290
+ json: () => Promise.resolve({}),
291
+ statusText: "OK",
292
+ } as Response)
293
+ );
294
+
295
+ await expectAsync(reporter.init()).toBeRejectedWithError(
296
+ "[AnalyticsReporter] init failed: no sessionId"
297
+ );
298
+ });
299
+
300
+ it("should not call fetch in debug mode", async () => {
301
+ const options: IReporterOptions = {
302
+ eventsinkUrl: "https://example.com/analytics",
303
+ sessionId: "debug-session-id",
304
+ debug: true,
305
+ };
306
+ const reporter = new Reporter(options);
307
+
308
+ spyOn(console, "log");
309
+
310
+ const result = await reporter.init();
311
+
312
+ expect(mockFetch).not.toHaveBeenCalled();
313
+ expect(result.sessionId).toBe("debug-session-id");
314
+ expect(result.isInitiated).toBe(true);
315
+ expect(console.log).toHaveBeenCalled();
316
+ });
317
+ });
318
+
319
+ describe("send()", () => {
320
+ it("should call fetch with correct JSON body and EPAS headers", async () => {
321
+ const options: IReporterOptions = {
322
+ eventsinkUrl: "https://example.com/analytics",
323
+ sessionId: "test-session-id",
324
+ };
325
+ const reporter = new Reporter(options);
326
+ await reporter.init();
327
+
328
+ mockFetch.calls.reset();
329
+
330
+ const eventData: TPlayerAnalyticsEvent = {
331
+ event: "playing",
332
+ sessionId: "test-session-id",
333
+ timestamp: 1234567890,
334
+ playhead: 10.5,
335
+ duration: 120,
336
+ };
337
+
338
+ reporter.send(eventData);
339
+
340
+ // Wait for async fetch to be called
341
+ await new Promise((resolve) => setTimeout(resolve, 10));
342
+
343
+ expect(mockFetch).toHaveBeenCalledTimes(1);
344
+ const [url, fetchOptions] = mockFetch.calls.argsFor(0);
345
+ expect(url).toBe("https://example.com/analytics");
346
+ expect(fetchOptions.method).toBe("POST");
347
+ expect(fetchOptions.headers["X-EPAS-Event"]).toBe("playing");
348
+
349
+ const body = JSON.parse(fetchOptions.body);
350
+ expect(body.event).toBe("playing");
351
+ // Reporter uses its authoritative sessionId (from server init response)
352
+ expect(body.sessionId).toBe("server-generated-id");
353
+ expect(body.playhead).toBe(10.5);
354
+ });
355
+
356
+ it("should include shardId in payload when provided", async () => {
357
+ const options: IReporterOptions = {
358
+ eventsinkUrl: "https://example.com/analytics",
359
+ sessionId: "test-session-id",
360
+ shardId: "shard-123",
361
+ };
362
+ const reporter = new Reporter(options);
363
+ await reporter.init();
364
+
365
+ mockFetch.calls.reset();
366
+
367
+ const eventData: TPlayerAnalyticsEvent = {
368
+ event: "playing",
369
+ sessionId: "test-session-id",
370
+ timestamp: 1234567890,
371
+ playhead: 10.5,
372
+ duration: 120,
373
+ };
374
+
375
+ reporter.send(eventData);
376
+
377
+ await new Promise((resolve) => setTimeout(resolve, 10));
378
+
379
+ const body = JSON.parse(mockFetch.calls.argsFor(0)[1].body);
380
+ expect(body.shardId).toBe("shard-123");
381
+ });
382
+
383
+ it("should log to console in debug mode instead of calling fetch", async () => {
384
+ const options: IReporterOptions = {
385
+ eventsinkUrl: "https://example.com/analytics",
386
+ sessionId: "debug-session-id",
387
+ debug: true,
388
+ };
389
+ const reporter = new Reporter(options);
390
+
391
+ spyOn(console, "log");
392
+ await reporter.init();
393
+
394
+ mockFetch.calls.reset();
395
+
396
+ const eventData: TPlayerAnalyticsEvent = {
397
+ event: "playing",
398
+ sessionId: "debug-session-id",
399
+ timestamp: 1234567890,
400
+ playhead: 10.5,
401
+ duration: 120,
402
+ };
403
+
404
+ reporter.send(eventData);
405
+
406
+ expect(mockFetch).not.toHaveBeenCalled();
407
+ expect(console.log).toHaveBeenCalled();
408
+ });
409
+
410
+ it("should warn on network failure in non-debug mode", async () => {
411
+ const options: IReporterOptions = {
412
+ eventsinkUrl: "https://example.com/analytics",
413
+ sessionId: "test-session-id",
414
+ debug: true,
415
+ };
416
+ const reporter = new Reporter(options);
417
+ await reporter.init();
418
+
419
+ // Switch to non-debug mode and make fetch fail
420
+ (reporter as any).debug = false;
421
+ mockFetch.calls.reset();
422
+ mockFetch.and.returnValue(Promise.reject(new Error("Network failure")));
423
+
424
+ spyOn(console, "warn");
425
+
426
+ const eventData: TPlayerAnalyticsEvent = {
427
+ event: "playing",
428
+ sessionId: "test-session-id",
429
+ timestamp: 1234567890,
430
+ playhead: 10.5,
431
+ duration: 120,
432
+ };
433
+
434
+ reporter.send(eventData);
435
+
436
+ await new Promise((resolve) => setTimeout(resolve, 10));
437
+
438
+ expect(mockFetch).toHaveBeenCalled();
439
+ expect(console.warn).toHaveBeenCalledWith(
440
+ "[AnalyticsReporter] Send failed:",
441
+ "Network failure"
442
+ );
443
+ });
444
+
445
+ it("should warn on HTTP error response (e.g. 404)", async () => {
446
+ const options: IReporterOptions = {
447
+ eventsinkUrl: "https://example.com/analytics",
448
+ sessionId: "test-session-id",
449
+ debug: true,
450
+ };
451
+ const reporter = new Reporter(options);
452
+ await reporter.init();
453
+
454
+ (reporter as any).debug = false;
455
+ mockFetch.calls.reset();
456
+ mockFetch.and.returnValue(
457
+ Promise.resolve({
458
+ ok: false,
459
+ status: 404,
460
+ statusText: "Not Found",
461
+ })
462
+ );
463
+
464
+ spyOn(console, "warn");
465
+
466
+ reporter.send({
467
+ event: "heartbeat",
468
+ sessionId: "test-session-id",
469
+ timestamp: 1234567890,
470
+ playhead: 30,
471
+ duration: 120,
472
+ } as TPlayerAnalyticsEvent);
473
+
474
+ await new Promise((resolve) => setTimeout(resolve, 10));
475
+
476
+ expect(console.warn).toHaveBeenCalledWith(
477
+ "[AnalyticsReporter] Send failed: 404 Not Found"
478
+ );
479
+ });
480
+
481
+ it("should warn and not send when not initiated", async () => {
482
+ const options: IReporterOptions = {
483
+ eventsinkUrl: "https://example.com/analytics",
484
+ sessionId: "test-session-id",
485
+ };
486
+ const reporter = new Reporter(options);
487
+ // Note: NOT calling init()
488
+
489
+ spyOn(console, "warn");
490
+
491
+ const eventData: TPlayerAnalyticsEvent = {
492
+ event: "playing",
493
+ sessionId: "test-session-id",
494
+ timestamp: 1234567890,
495
+ playhead: 10.5,
496
+ duration: 120,
497
+ };
498
+
499
+ reporter.send(eventData);
500
+
501
+ expect(console.warn).toHaveBeenCalledWith(
502
+ "[AnalyticsReporter] Cannot report before initiation:",
503
+ eventData
504
+ );
505
+ expect(mockFetch).not.toHaveBeenCalled();
506
+ });
507
+ });
508
+
509
+ describe("event queuing during init", () => {
510
+ // Helper: flush promise microtask queue
511
+ async function flushMicrotasks() {
512
+ for (let i = 0; i < 10; i++) await Promise.resolve();
513
+ }
514
+
515
+ it("should queue events sent while init is in flight", async () => {
516
+ let resolveInit!: (value: unknown) => void;
517
+ const pendingInit = new Promise((resolve) => {
518
+ resolveInit = resolve;
519
+ });
520
+ mockFetch.and.returnValue(pendingInit);
521
+
522
+ const reporter = new Reporter({
523
+ eventsinkUrl: "https://example.com/analytics",
524
+ sessionId: "test-session-id",
525
+ });
526
+
527
+ const initPromise = reporter.init();
528
+ mockFetch.calls.reset();
529
+
530
+ const event1: TPlayerAnalyticsEvent = {
531
+ event: "playing",
532
+ sessionId: "test-session-id",
533
+ timestamp: 1000,
534
+ playhead: 0,
535
+ duration: 120,
536
+ };
537
+ const event2: TPlayerAnalyticsEvent = {
538
+ event: "heartbeat",
539
+ sessionId: "test-session-id",
540
+ timestamp: 2000,
541
+ playhead: 10,
542
+ duration: 120,
543
+ };
544
+
545
+ reporter.send(event1);
546
+ reporter.send(event2);
547
+
548
+ expect(mockFetch).not.toHaveBeenCalled();
549
+
550
+ resolveInit({
551
+ ok: true,
552
+ json: () => Promise.resolve({ sessionId: "server-session-id" }),
553
+ statusText: "OK",
554
+ });
555
+
556
+ await initPromise;
557
+ await flushMicrotasks();
558
+
559
+ expect(mockFetch.calls.count()).toBe(2);
560
+ const body1 = JSON.parse(mockFetch.calls.argsFor(0)[1].body);
561
+ expect(body1.event).toBe("playing");
562
+ const body2 = JSON.parse(mockFetch.calls.argsFor(1)[1].body);
563
+ expect(body2.event).toBe("heartbeat");
564
+ });
565
+
566
+ it("should patch queued events with server sessionId on flush", async () => {
567
+ let resolveInit!: (value: unknown) => void;
568
+ const pendingInit = new Promise((resolve) => {
569
+ resolveInit = resolve;
570
+ });
571
+ mockFetch.and.returnValue(pendingInit);
572
+
573
+ const reporter = new Reporter({
574
+ eventsinkUrl: "https://example.com/analytics",
575
+ });
576
+ const initPromise = reporter.init();
577
+ mockFetch.calls.reset();
578
+
579
+ reporter.send({
580
+ event: "loading",
581
+ sessionId: undefined,
582
+ timestamp: 1000,
583
+ playhead: -1,
584
+ duration: -1,
585
+ } as TPlayerAnalyticsEvent);
586
+
587
+ resolveInit({
588
+ ok: true,
589
+ json: () => Promise.resolve({ sessionId: "server-generated-id" }),
590
+ statusText: "OK",
591
+ });
592
+
593
+ await initPromise;
594
+ await flushMicrotasks();
595
+
596
+ expect(mockFetch.calls.count()).toBe(1);
597
+ const body = JSON.parse(mockFetch.calls.argsFor(0)[1].body);
598
+ expect(body.sessionId).toBe("server-generated-id");
599
+ });
600
+
601
+ it("should discard queued events if init fails", async () => {
602
+ mockFetch.and.returnValue(
603
+ Promise.resolve({
604
+ ok: false,
605
+ statusText: "Internal Server Error",
606
+ })
607
+ );
608
+
609
+ const reporter = new Reporter({
610
+ eventsinkUrl: "https://example.com/analytics",
611
+ sessionId: "test-session-id",
612
+ });
613
+
614
+ const initPromise = reporter.init();
615
+
616
+ reporter.send({
617
+ event: "playing",
618
+ sessionId: "test-session-id",
619
+ timestamp: 1000,
620
+ playhead: 0,
621
+ duration: 120,
622
+ } as TPlayerAnalyticsEvent);
623
+
624
+ try {
625
+ await initPromise;
626
+ } catch (e) {
627
+ // Expected
628
+ }
629
+
630
+ mockFetch.calls.reset();
631
+ spyOn(console, "warn");
632
+
633
+ reporter.send({
634
+ event: "heartbeat",
635
+ sessionId: "test-session-id",
636
+ timestamp: 2000,
637
+ playhead: 10,
638
+ duration: 120,
639
+ } as TPlayerAnalyticsEvent);
640
+
641
+ expect(console.warn).toHaveBeenCalled();
642
+ expect(mockFetch).not.toHaveBeenCalled();
643
+ });
644
+
645
+ it("should not allow double init", async () => {
646
+ const reporter = new Reporter({
647
+ eventsinkUrl: "https://example.com/analytics",
648
+ sessionId: "test-session-id",
649
+ debug: true,
650
+ });
651
+
652
+ const result1 = await reporter.init();
653
+ const result2 = await reporter.init();
654
+
655
+ expect(result1.sessionId).toBe(result2.sessionId);
656
+ });
657
+
658
+ it("should preserve event order even when send() is called between flush completion and state=ready", async () => {
659
+ // This test verifies the ordering barrier: events sent during the flush
660
+ // window must not overtake the queued chain on the wire.
661
+ let resolveInit!: (value: unknown) => void;
662
+ const pendingInit = new Promise((resolve) => {
663
+ resolveInit = resolve;
664
+ });
665
+ mockFetch.and.returnValue(pendingInit);
666
+
667
+ const reporter = new Reporter({
668
+ eventsinkUrl: "https://example.com/analytics",
669
+ sessionId: "test-session-id",
670
+ });
671
+
672
+ const initPromise = reporter.init();
673
+ mockFetch.calls.reset();
674
+
675
+ // Queue 2 events during init
676
+ reporter.send({
677
+ event: "loading",
678
+ sessionId: "test-session-id",
679
+ timestamp: 1,
680
+ playhead: 0,
681
+ duration: 0,
682
+ } as TPlayerAnalyticsEvent);
683
+ reporter.send({
684
+ event: "loaded",
685
+ sessionId: "test-session-id",
686
+ timestamp: 2,
687
+ playhead: 0,
688
+ duration: 0,
689
+ } as TPlayerAnalyticsEvent);
690
+
691
+ expect(mockFetch).not.toHaveBeenCalled();
692
+
693
+ // Resolve init — flush starts. Make dispatch slow so we have time to call send during flush.
694
+ let dispatchCallCount = 0;
695
+ mockFetch.and.callFake(() => {
696
+ dispatchCallCount++;
697
+ // Slow down the first dispatch — gives us a window to send a new event mid-flush
698
+ return new Promise((resolve) =>
699
+ setTimeout(
700
+ () =>
701
+ resolve({
702
+ ok: true,
703
+ status: 200,
704
+ statusText: "OK",
705
+ }),
706
+ dispatchCallCount === 1 ? 30 : 0
707
+ )
708
+ );
709
+ });
710
+
711
+ resolveInit({
712
+ ok: true,
713
+ json: () => Promise.resolve({ sessionId: "server-session-id" }),
714
+ statusText: "OK",
715
+ });
716
+
717
+ // Wait briefly so flush starts dispatching the first queued event
718
+ await new Promise((resolve) => setTimeout(resolve, 5));
719
+
720
+ // Send a third event while flush is still in progress — must NOT overtake queue
721
+ reporter.send({
722
+ event: "playing",
723
+ sessionId: "test-session-id",
724
+ timestamp: 3,
725
+ playhead: 0,
726
+ duration: 0,
727
+ } as TPlayerAnalyticsEvent);
728
+
729
+ await initPromise;
730
+ // Allow all serialized awaits to settle
731
+ await new Promise((resolve) => setTimeout(resolve, 50));
732
+
733
+ // All 3 events should have been sent in order: loading, loaded, playing
734
+ expect(mockFetch.calls.count()).toBe(3);
735
+ const events = mockFetch.calls
736
+ .allArgs()
737
+ .map((args) => JSON.parse(args[1].body).event);
738
+ expect(events).toEqual(["loading", "loaded", "playing"]);
739
+ });
740
+ });
741
+
742
+ describe("destroy()", () => {
743
+ async function flushMicrotasks() {
744
+ for (let i = 0; i < 10; i++) await Promise.resolve();
745
+ }
746
+
747
+ it("should drop queued events when destroy() is called during init", async () => {
748
+ let resolveInit!: (value: unknown) => void;
749
+ const pendingInit = new Promise((resolve) => {
750
+ resolveInit = resolve;
751
+ });
752
+ mockFetch.and.returnValue(pendingInit);
753
+
754
+ const reporter = new Reporter({
755
+ eventsinkUrl: "https://example.com/analytics",
756
+ sessionId: "test-session-id",
757
+ });
758
+
759
+ const initPromise = reporter.init();
760
+ mockFetch.calls.reset();
761
+
762
+ // Queue events during init
763
+ reporter.send({
764
+ event: "loading",
765
+ sessionId: "test-session-id",
766
+ timestamp: 1,
767
+ playhead: 0,
768
+ duration: 0,
769
+ } as TPlayerAnalyticsEvent);
770
+ reporter.send({
771
+ event: "playing",
772
+ sessionId: "test-session-id",
773
+ timestamp: 2,
774
+ playhead: 0,
775
+ duration: 0,
776
+ } as TPlayerAnalyticsEvent);
777
+
778
+ // Destroy BEFORE init resolves
779
+ reporter.destroy();
780
+
781
+ // Now resolve the init fetch — but reporter should abort, not flush
782
+ resolveInit({
783
+ ok: true,
784
+ json: () => Promise.resolve({ sessionId: "server-session-id" }),
785
+ statusText: "OK",
786
+ });
787
+
788
+ try {
789
+ await initPromise;
790
+ } catch {
791
+ /* expected to reject */
792
+ }
793
+ await flushMicrotasks();
794
+
795
+ // No events should have been dispatched
796
+ expect(mockFetch).not.toHaveBeenCalled();
797
+ });
798
+
799
+ it("should silently drop send() calls after destroy()", async () => {
800
+ const reporter = new Reporter({
801
+ eventsinkUrl: "https://example.com/analytics",
802
+ sessionId: "test-session-id",
803
+ debug: true,
804
+ });
805
+
806
+ await reporter.init();
807
+ mockFetch.calls.reset();
808
+
809
+ reporter.destroy();
810
+ spyOn(console, "warn");
811
+
812
+ reporter.send({
813
+ event: "playing",
814
+ sessionId: "test-session-id",
815
+ timestamp: 1,
816
+ playhead: 0,
817
+ duration: 0,
818
+ } as TPlayerAnalyticsEvent);
819
+
820
+ expect(mockFetch).not.toHaveBeenCalled();
821
+ // Don't warn — it's expected after destroy
822
+ expect(console.warn).not.toHaveBeenCalled();
823
+ });
824
+ });
825
+ });