@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,1474 @@
1
+ import {
2
+ PlayerAnalyticsConnector,
3
+ IPlayerAnalyticsConnectorInitOptions,
4
+ } from "../src/PlayerAnalyticsConnector";
5
+
6
+ describe("PlayerAnalyticsConnector", () => {
7
+ let mockFetch: jasmine.Spy;
8
+ let originalFetch: any;
9
+ let mockVideoElement: any;
10
+
11
+ beforeEach(() => {
12
+ // Mock fetch
13
+ originalFetch = globalThis.fetch;
14
+ mockFetch = jasmine.createSpy("fetch").and.returnValue(
15
+ Promise.resolve({
16
+ ok: true,
17
+ json: () => Promise.resolve({ sessionId: "test-session-id" }),
18
+ statusText: "OK",
19
+ } as Response)
20
+ );
21
+ globalThis.fetch = mockFetch;
22
+
23
+ // Mock HTMLVideoElement
24
+ mockVideoElement = {
25
+ currentTime: 0,
26
+ duration: 100,
27
+ addEventListener: jasmine.createSpy("addEventListener"),
28
+ removeEventListener: jasmine.createSpy("removeEventListener"),
29
+ };
30
+ });
31
+
32
+ afterEach(() => {
33
+ globalThis.fetch = originalFetch;
34
+ });
35
+
36
+ describe("page unload (stopped via beacon)", () => {
37
+ let windowListeners: Record<string, (...args: any[]) => void>;
38
+ let documentListeners: Record<string, (...args: any[]) => void>;
39
+ let sendBeaconSpy: jasmine.Spy;
40
+ let navigatorDescriptor: PropertyDescriptor | undefined;
41
+ let docVisibility: string;
42
+
43
+ beforeEach(() => {
44
+ windowListeners = {};
45
+ documentListeners = {};
46
+ docVisibility = "visible";
47
+
48
+ (globalThis as any).window = {
49
+ addEventListener: (type: string, cb: (...args: any[]) => void) => {
50
+ windowListeners[type] = cb;
51
+ },
52
+ removeEventListener: (type: string) => {
53
+ delete windowListeners[type];
54
+ },
55
+ };
56
+ (globalThis as any).document = {
57
+ addEventListener: (type: string, cb: (...args: any[]) => void) => {
58
+ documentListeners[type] = cb;
59
+ },
60
+ removeEventListener: (type: string) => {
61
+ delete documentListeners[type];
62
+ },
63
+ get visibilityState() {
64
+ return docVisibility;
65
+ },
66
+ };
67
+
68
+ sendBeaconSpy = jasmine.createSpy("sendBeacon").and.returnValue(true);
69
+ navigatorDescriptor = Object.getOwnPropertyDescriptor(
70
+ globalThis,
71
+ "navigator"
72
+ );
73
+ Object.defineProperty(globalThis, "navigator", {
74
+ value: { sendBeacon: sendBeaconSpy },
75
+ configurable: true,
76
+ writable: true,
77
+ });
78
+ });
79
+
80
+ afterEach(() => {
81
+ delete (globalThis as any).window;
82
+ delete (globalThis as any).document;
83
+ if (navigatorDescriptor) {
84
+ Object.defineProperty(globalThis, "navigator", navigatorDescriptor);
85
+ } else {
86
+ delete (globalThis as any).navigator;
87
+ }
88
+ });
89
+
90
+ async function readBeaconBody(): Promise<any> {
91
+ const blob = sendBeaconSpy.calls.mostRecent().args[1] as Blob;
92
+ return JSON.parse(await blob.text());
93
+ }
94
+
95
+ it("registers the pagehide listener (and NOT visibilitychange) on load()", async () => {
96
+ const connector = new PlayerAnalyticsConnector(
97
+ "https://example.com/analytics"
98
+ );
99
+ await connector.init({ sessionId: "test-session" });
100
+ connector.load(mockVideoElement);
101
+
102
+ // pagehide is the unload mechanism; a visibilitychange (tab switch) must
103
+ // NOT be wired up to end the session (see #44).
104
+ expect(typeof windowListeners["pagehide"]).toBe("function");
105
+ expect(documentListeners["visibilitychange"]).toBeUndefined();
106
+ });
107
+
108
+ it("sends a stopped beacon (reason aborted) on pagehide", async () => {
109
+ const connector = new PlayerAnalyticsConnector(
110
+ "https://example.com/analytics"
111
+ );
112
+ await connector.init({ sessionId: "test-session" });
113
+ mockVideoElement.currentTime = 42.5;
114
+ mockVideoElement.duration = 120;
115
+ connector.load(mockVideoElement);
116
+ mockFetch.calls.reset();
117
+
118
+ windowListeners["pagehide"]();
119
+
120
+ expect(sendBeaconSpy).toHaveBeenCalledTimes(1);
121
+ const body = await readBeaconBody();
122
+ expect(body.event).toBe("stopped");
123
+ expect(body.payload.reason).toBe("aborted");
124
+ // Server init response's sessionId is authoritative
125
+ expect(body.sessionId).toBe("test-session-id");
126
+ expect(body.playhead).toBe(42.5);
127
+ });
128
+
129
+ it("does NOT end the session when the page becomes hidden (tab switch)", async () => {
130
+ const connector = new PlayerAnalyticsConnector(
131
+ "https://example.com/analytics"
132
+ );
133
+ await connector.init({ sessionId: "test-session" });
134
+ connector.load(mockVideoElement);
135
+
136
+ // Simulate a tab switch / backgrounding: the page goes hidden, but this
137
+ // is not an unload. No visibilitychange listener is wired, so even if the
138
+ // page fires one, nothing ends the session.
139
+ docVisibility = "hidden";
140
+ expect(documentListeners["visibilitychange"]).toBeUndefined();
141
+
142
+ // No stopped beacon, and the one-shot guard is untouched so a later real
143
+ // end still produces exactly one stopped.
144
+ expect(sendBeaconSpy).not.toHaveBeenCalled();
145
+ expect((connector as any).stoppedReported).toBe(false);
146
+ });
147
+
148
+ it("still delivers exactly one stopped beacon on a real unload (pagehide) after a hide", async () => {
149
+ const connector = new PlayerAnalyticsConnector(
150
+ "https://example.com/analytics"
151
+ );
152
+ await connector.init({ sessionId: "test-session" });
153
+ connector.load(mockVideoElement);
154
+
155
+ // Page hidden first (tab switch) — must not consume the one-shot...
156
+ docVisibility = "hidden";
157
+ // ...then a genuine unload (close/reload/navigate-away).
158
+ docVisibility = "visible";
159
+ windowListeners["pagehide"]();
160
+ windowListeners["pagehide"]();
161
+
162
+ expect(sendBeaconSpy).toHaveBeenCalledTimes(1);
163
+ const body = await readBeaconBody();
164
+ expect(body.event).toBe("stopped");
165
+ expect(body.payload.reason).toBe("aborted");
166
+ });
167
+
168
+ it("delivers integrator reportStop() over the unload-safe beacon", async () => {
169
+ const connector = new PlayerAnalyticsConnector(
170
+ "https://example.com/analytics"
171
+ );
172
+ await connector.init({ sessionId: "test-session" });
173
+ mockVideoElement.currentTime = 12.5;
174
+ connector.load(mockVideoElement);
175
+ mockFetch.calls.reset();
176
+
177
+ // Simulates an integrator calling reportStop() from a pagehide/unload
178
+ // handler: it must go out over the beacon (not the normal CORS fetch
179
+ // the browser drops during unload).
180
+ connector.reportStop();
181
+
182
+ expect(sendBeaconSpy).toHaveBeenCalledTimes(1);
183
+ expect(mockFetch).not.toHaveBeenCalled();
184
+ const body = await readBeaconBody();
185
+ expect(body.event).toBe("stopped");
186
+ expect(body.payload.reason).toBe("aborted");
187
+ expect(body.playhead).toBe(12.5);
188
+ });
189
+
190
+ it("delivers exactly one stopped when reportStop() and the SDK unload path both run", async () => {
191
+ const connector = new PlayerAnalyticsConnector(
192
+ "https://example.com/analytics"
193
+ );
194
+ await connector.init({ sessionId: "test-session" });
195
+ connector.load(mockVideoElement);
196
+
197
+ // Integrator's unload reportStop() fires, then the SDK's own pagehide
198
+ // handler runs. The stoppedReported guard must prevent a second beacon.
199
+ connector.reportStop();
200
+ windowListeners["pagehide"]();
201
+
202
+ expect(sendBeaconSpy).toHaveBeenCalledTimes(1);
203
+ });
204
+
205
+ it("does not send an unload beacon after reportStop already stopped the session", async () => {
206
+ const connector = new PlayerAnalyticsConnector(
207
+ "https://example.com/analytics"
208
+ );
209
+ await connector.init({ sessionId: "test-session" });
210
+ connector.load(mockVideoElement);
211
+
212
+ connector.reportStop();
213
+ sendBeaconSpy.calls.reset();
214
+ mockFetch.calls.reset();
215
+
216
+ windowListeners["pagehide"]();
217
+
218
+ expect(sendBeaconSpy).not.toHaveBeenCalled();
219
+ });
220
+
221
+ it("reportStop() respects the stoppedReported guard set by ended (no duplicate)", async () => {
222
+ const connector = new PlayerAnalyticsConnector(
223
+ "https://example.com/analytics"
224
+ );
225
+ await connector.init({ sessionId: "test-session" });
226
+ connector.load(mockVideoElement);
227
+
228
+ // ENDED emits the terminal stopped and sets the guard.
229
+ (connector as any).stoppedReported = true;
230
+ sendBeaconSpy.calls.reset();
231
+ mockFetch.calls.reset();
232
+
233
+ connector.reportStop();
234
+
235
+ expect(sendBeaconSpy).not.toHaveBeenCalled();
236
+ expect(mockFetch).not.toHaveBeenCalled();
237
+ });
238
+
239
+ it("removes unload listeners on destroy()", async () => {
240
+ const connector = new PlayerAnalyticsConnector(
241
+ "https://example.com/analytics"
242
+ );
243
+ await connector.init({ sessionId: "test-session" });
244
+ connector.load(mockVideoElement);
245
+
246
+ connector.destroy();
247
+
248
+ expect(windowListeners["pagehide"]).toBeUndefined();
249
+ });
250
+ });
251
+
252
+ describe("hidden page does not end a still-playing session (#44)", () => {
253
+ let documentListeners: Record<string, (...args: any[]) => void>;
254
+ let sendBeaconSpy: jasmine.Spy;
255
+ let navigatorDescriptor: PropertyDescriptor | undefined;
256
+ let docVisibility: string;
257
+
258
+ beforeEach(() => {
259
+ jasmine.clock().install();
260
+ documentListeners = {};
261
+ docVisibility = "visible";
262
+
263
+ (globalThis as any).window = {
264
+ addEventListener: () => {},
265
+ removeEventListener: () => {},
266
+ };
267
+ (globalThis as any).document = {
268
+ addEventListener: (type: string, cb: (...args: any[]) => void) => {
269
+ documentListeners[type] = cb;
270
+ },
271
+ removeEventListener: (type: string) => {
272
+ delete documentListeners[type];
273
+ },
274
+ get visibilityState() {
275
+ return docVisibility;
276
+ },
277
+ };
278
+
279
+ sendBeaconSpy = jasmine.createSpy("sendBeacon").and.returnValue(true);
280
+ navigatorDescriptor = Object.getOwnPropertyDescriptor(
281
+ globalThis,
282
+ "navigator"
283
+ );
284
+ Object.defineProperty(globalThis, "navigator", {
285
+ value: { sendBeacon: sendBeaconSpy },
286
+ configurable: true,
287
+ writable: true,
288
+ });
289
+ });
290
+
291
+ afterEach(() => {
292
+ jasmine.clock().uninstall();
293
+ delete (globalThis as any).window;
294
+ delete (globalThis as any).document;
295
+ if (navigatorDescriptor) {
296
+ Object.defineProperty(globalThis, "navigator", navigatorDescriptor);
297
+ } else {
298
+ delete (globalThis as any).navigator;
299
+ }
300
+ });
301
+
302
+ it("keeps heartbeats running while hidden and after returning visible, with no stopped", async () => {
303
+ const connector = new PlayerAnalyticsConnector(
304
+ "https://example.com/analytics"
305
+ );
306
+ await connector.init({
307
+ sessionId: "test-session",
308
+ heartbeatInterval: 5000,
309
+ });
310
+ connector.load(mockVideoElement);
311
+ (connector as any).startInterval();
312
+
313
+ // Page goes hidden (tab switch). No visibilitychange handler is wired, so
314
+ // nothing stops the interval.
315
+ docVisibility = "hidden";
316
+ expect(documentListeners["visibilitychange"]).toBeUndefined();
317
+
318
+ mockFetch.calls.reset();
319
+ jasmine.clock().tick(5000);
320
+ let events = mockFetch.calls
321
+ .all()
322
+ .map((c) => JSON.parse(c.args[1].body).event);
323
+ // Heartbeat still fires while hidden; no stopped is emitted.
324
+ expect(events).toContain("heartbeat");
325
+ expect(events).not.toContain("stopped");
326
+
327
+ // Back to visible: heartbeats keep going.
328
+ docVisibility = "visible";
329
+ mockFetch.calls.reset();
330
+ jasmine.clock().tick(5000);
331
+ events = mockFetch.calls
332
+ .all()
333
+ .map((c) => JSON.parse(c.args[1].body).event);
334
+ expect(events).toContain("heartbeat");
335
+
336
+ // The session was never ended by the hide.
337
+ expect(sendBeaconSpy).not.toHaveBeenCalled();
338
+ expect((connector as any).stoppedReported).toBe(false);
339
+ });
340
+ });
341
+
342
+ describe("init()", () => {
343
+ it("should initialize analytics reporter and set analyticsInitiated to true", async () => {
344
+ const connector = new PlayerAnalyticsConnector(
345
+ "https://example.com/analytics"
346
+ );
347
+
348
+ const options: IPlayerAnalyticsConnectorInitOptions = {
349
+ sessionId: "session-123",
350
+ heartbeatInterval: 30000,
351
+ };
352
+
353
+ await connector.init(options);
354
+
355
+ expect(mockFetch).toHaveBeenCalled();
356
+ expect((connector as any).analyticsInitiated).toBe(true);
357
+ });
358
+
359
+ it("should store heartbeatInterval from init response", async () => {
360
+ const connector = new PlayerAnalyticsConnector(
361
+ "https://example.com/analytics"
362
+ );
363
+
364
+ await connector.init({ heartbeatInterval: 45000 });
365
+
366
+ expect((connector as any).heartbeatInterval).toBe(45000);
367
+ });
368
+ });
369
+
370
+ describe("load()", () => {
371
+ it("should set player reference and send loading event", async () => {
372
+ const connector = new PlayerAnalyticsConnector(
373
+ "https://example.com/analytics"
374
+ );
375
+ await connector.init({ sessionId: "test-session" });
376
+
377
+ mockFetch.calls.reset();
378
+
379
+ connector.load(mockVideoElement);
380
+
381
+ expect((connector as any).player).toBe(mockVideoElement);
382
+ expect(mockFetch).toHaveBeenCalled();
383
+
384
+ const body = JSON.parse(mockFetch.calls.argsFor(0)[1].body);
385
+ expect(body.event).toBe("loading");
386
+ });
387
+
388
+ it("should initiate video event filter", async () => {
389
+ const connector = new PlayerAnalyticsConnector(
390
+ "https://example.com/analytics"
391
+ );
392
+ await connector.init({ sessionId: "test-session" });
393
+
394
+ connector.load(mockVideoElement);
395
+
396
+ expect((connector as any).videoEventFilter).toBeDefined();
397
+ });
398
+ });
399
+
400
+ describe("playbackState()", () => {
401
+ it("should return playhead 0 when currentTime is 0", async () => {
402
+ const connector = new PlayerAnalyticsConnector(
403
+ "https://example.com/analytics"
404
+ );
405
+ await connector.init({ sessionId: "test-session" });
406
+
407
+ mockVideoElement.currentTime = 0;
408
+ mockVideoElement.duration = 100;
409
+
410
+ connector.load(mockVideoElement);
411
+
412
+ mockFetch.calls.reset();
413
+ connector.reportMetadata({ contentId: "test", live: false });
414
+
415
+ const body = JSON.parse(mockFetch.calls.argsFor(0)[1].body);
416
+ expect(body.playhead).toBe(0);
417
+ expect(body.duration).toBe(100);
418
+ });
419
+
420
+ it("should return duration -1 for live stream (Infinity duration)", async () => {
421
+ const connector = new PlayerAnalyticsConnector(
422
+ "https://example.com/analytics"
423
+ );
424
+ await connector.init({ sessionId: "test-session" });
425
+
426
+ mockVideoElement.currentTime = 10;
427
+ mockVideoElement.duration = Infinity;
428
+
429
+ connector.load(mockVideoElement);
430
+
431
+ mockFetch.calls.reset();
432
+ connector.reportMetadata({ contentId: "test", live: true });
433
+
434
+ const body = JSON.parse(mockFetch.calls.argsFor(0)[1].body);
435
+ expect(body.duration).toBe(-1);
436
+ });
437
+
438
+ it("should report a valid playhead for live content (Infinity duration)", async () => {
439
+ const connector = new PlayerAnalyticsConnector(
440
+ "https://example.com/analytics"
441
+ );
442
+ await connector.init({ sessionId: "test-session" });
443
+
444
+ mockVideoElement.currentTime = 37.5;
445
+ mockVideoElement.duration = Infinity;
446
+
447
+ connector.load(mockVideoElement);
448
+
449
+ mockFetch.calls.reset();
450
+ connector.reportMetadata({ contentId: "test", live: true });
451
+
452
+ const body = JSON.parse(mockFetch.calls.argsFor(0)[1].body);
453
+ // Playhead must be decoupled from duration: -1 means "unknown", not "live".
454
+ expect(body.duration).toBe(-1);
455
+ expect(body.playhead).not.toBe(-1);
456
+ expect(body.playhead).toBe(37.5);
457
+ });
458
+
459
+ it("should report a valid playhead for VOD before duration metadata is available", async () => {
460
+ const connector = new PlayerAnalyticsConnector(
461
+ "https://example.com/analytics"
462
+ );
463
+ await connector.init({ sessionId: "test-session" });
464
+
465
+ // Duration not yet known (e.g. loadedmetadata hasn't fired) but the
466
+ // player already exposes a valid currentTime.
467
+ mockVideoElement.currentTime = 5;
468
+ mockVideoElement.duration = undefined;
469
+
470
+ connector.load(mockVideoElement);
471
+
472
+ mockFetch.calls.reset();
473
+ connector.reportMetadata({ contentId: "test", live: false });
474
+
475
+ const body = JSON.parse(mockFetch.calls.argsFor(0)[1].body);
476
+ expect(body.duration).toBe(-1);
477
+ expect(body.playhead).not.toBe(-1);
478
+ expect(body.playhead).toBe(5);
479
+ });
480
+
481
+ it("should normalize duration 0 to -1 while keeping a valid playhead", async () => {
482
+ const connector = new PlayerAnalyticsConnector(
483
+ "https://example.com/analytics"
484
+ );
485
+ await connector.init({ sessionId: "test-session" });
486
+
487
+ mockVideoElement.currentTime = 10;
488
+ mockVideoElement.duration = 0;
489
+
490
+ connector.load(mockVideoElement);
491
+
492
+ mockFetch.calls.reset();
493
+ connector.reportMetadata({ contentId: "test", live: false });
494
+
495
+ const body = JSON.parse(mockFetch.calls.argsFor(0)[1].body);
496
+ expect(body.duration).toBe(-1);
497
+ // Playhead is independent of duration normalization.
498
+ expect(body.playhead).toBe(10);
499
+ });
500
+
501
+ it("should return playhead -1 when currentTime is invalid", async () => {
502
+ const connector = new PlayerAnalyticsConnector(
503
+ "https://example.com/analytics"
504
+ );
505
+ await connector.init({ sessionId: "test-session" });
506
+
507
+ mockVideoElement.currentTime = -1;
508
+ mockVideoElement.duration = 100;
509
+
510
+ connector.load(mockVideoElement);
511
+
512
+ mockFetch.calls.reset();
513
+ connector.reportMetadata({ contentId: "test", live: false });
514
+
515
+ const body = JSON.parse(mockFetch.calls.argsFor(0)[1].body);
516
+ expect(body.playhead).toBe(-1);
517
+ });
518
+
519
+ it("should return correct playhead for normal playback", async () => {
520
+ const connector = new PlayerAnalyticsConnector(
521
+ "https://example.com/analytics"
522
+ );
523
+ await connector.init({ sessionId: "test-session" });
524
+
525
+ mockVideoElement.currentTime = 42.5;
526
+ mockVideoElement.duration = 120;
527
+
528
+ connector.load(mockVideoElement);
529
+
530
+ mockFetch.calls.reset();
531
+ connector.reportMetadata({ contentId: "test", live: false });
532
+
533
+ const body = JSON.parse(mockFetch.calls.argsFor(0)[1].body);
534
+ expect(body.playhead).toBe(42.5);
535
+ expect(body.duration).toBe(120);
536
+ });
537
+ });
538
+
539
+ describe("heartbeat", () => {
540
+ beforeEach(() => {
541
+ jasmine.clock().install();
542
+ });
543
+
544
+ afterEach(() => {
545
+ jasmine.clock().uninstall();
546
+ });
547
+
548
+ it("should start heartbeat interval when startInterval is called", async () => {
549
+ const connector = new PlayerAnalyticsConnector(
550
+ "https://example.com/analytics"
551
+ );
552
+ await connector.init({
553
+ sessionId: "test-session",
554
+ heartbeatInterval: 10000,
555
+ });
556
+
557
+ connector.load(mockVideoElement);
558
+
559
+ // Manually trigger startInterval (normally triggered by playing event)
560
+ (connector as any).startInterval();
561
+
562
+ mockFetch.calls.reset();
563
+
564
+ jasmine.clock().tick(10000);
565
+
566
+ expect(mockFetch).toHaveBeenCalled();
567
+ const body = JSON.parse(mockFetch.calls.argsFor(0)[1].body);
568
+ expect(body.event).toBe("heartbeat");
569
+ });
570
+
571
+ it("should not start duplicate interval if already running", async () => {
572
+ const connector = new PlayerAnalyticsConnector(
573
+ "https://example.com/analytics"
574
+ );
575
+ await connector.init({
576
+ sessionId: "test-session",
577
+ heartbeatInterval: 10000,
578
+ });
579
+
580
+ connector.load(mockVideoElement);
581
+
582
+ (connector as any).startInterval();
583
+ (connector as any).startInterval();
584
+
585
+ mockFetch.calls.reset();
586
+ jasmine.clock().tick(10000);
587
+
588
+ // Should only fire once
589
+ expect(mockFetch).toHaveBeenCalledTimes(1);
590
+ });
591
+
592
+ it("should stop heartbeat interval when stopInterval is called", async () => {
593
+ const connector = new PlayerAnalyticsConnector(
594
+ "https://example.com/analytics"
595
+ );
596
+ await connector.init({
597
+ sessionId: "test-session",
598
+ heartbeatInterval: 10000,
599
+ });
600
+
601
+ connector.load(mockVideoElement);
602
+
603
+ (connector as any).startInterval();
604
+ (connector as any).stopInterval();
605
+
606
+ mockFetch.calls.reset();
607
+ jasmine.clock().tick(10000);
608
+
609
+ expect(mockFetch).not.toHaveBeenCalled();
610
+ });
611
+
612
+ it("should restart heartbeat after stop", async () => {
613
+ const connector = new PlayerAnalyticsConnector(
614
+ "https://example.com/analytics"
615
+ );
616
+ await connector.init({
617
+ sessionId: "test-session",
618
+ heartbeatInterval: 5000,
619
+ });
620
+
621
+ connector.load(mockVideoElement);
622
+
623
+ (connector as any).startInterval();
624
+ jasmine.clock().tick(5000);
625
+ (connector as any).stopInterval();
626
+
627
+ mockFetch.calls.reset();
628
+
629
+ (connector as any).startInterval();
630
+ jasmine.clock().tick(5000);
631
+
632
+ expect(mockFetch).toHaveBeenCalled();
633
+ const body = JSON.parse(mockFetch.calls.argsFor(0)[1].body);
634
+ expect(body.event).toBe("heartbeat");
635
+ });
636
+ });
637
+
638
+ describe("report methods when not initialized", () => {
639
+ it("should warn when reportBitrateChange is called before init", () => {
640
+ const connector = new PlayerAnalyticsConnector(
641
+ "https://example.com/analytics"
642
+ );
643
+ spyOn(console, "warn");
644
+
645
+ connector.reportBitrateChange({ bitrate: 5000000 });
646
+
647
+ expect(console.warn).toHaveBeenCalledWith(
648
+ "[PlayerAnalyticsConnector] Analytics not initiated"
649
+ );
650
+ expect(mockFetch).not.toHaveBeenCalled();
651
+ });
652
+
653
+ it("should warn when reportMetadata is called before init", () => {
654
+ const connector = new PlayerAnalyticsConnector(
655
+ "https://example.com/analytics"
656
+ );
657
+ spyOn(console, "warn");
658
+
659
+ connector.reportMetadata({ contentId: "test", live: false });
660
+
661
+ expect(console.warn).toHaveBeenCalledWith(
662
+ "[PlayerAnalyticsConnector] Analytics not initiated"
663
+ );
664
+ });
665
+
666
+ it("should warn when reportError is called before init", () => {
667
+ const connector = new PlayerAnalyticsConnector(
668
+ "https://example.com/analytics"
669
+ );
670
+ spyOn(console, "warn");
671
+
672
+ connector.reportError({
673
+ category: "network",
674
+ code: "MEDIA_ERR_NETWORK",
675
+ message: "Network error",
676
+ });
677
+
678
+ expect(console.warn).toHaveBeenCalledWith(
679
+ "[PlayerAnalyticsConnector] Analytics not initiated"
680
+ );
681
+ });
682
+
683
+ it("should warn when reportStop is called before init", () => {
684
+ const connector = new PlayerAnalyticsConnector(
685
+ "https://example.com/analytics"
686
+ );
687
+ spyOn(console, "warn");
688
+
689
+ connector.reportStop();
690
+
691
+ expect(console.warn).toHaveBeenCalledWith(
692
+ "[PlayerAnalyticsConnector] Analytics not initiated"
693
+ );
694
+ });
695
+
696
+ it("should warn when reportWarning is called before init", () => {
697
+ const connector = new PlayerAnalyticsConnector(
698
+ "https://example.com/analytics"
699
+ );
700
+ spyOn(console, "warn");
701
+
702
+ connector.reportWarning({
703
+ category: "player",
704
+ code: "WARN_001",
705
+ message: "Warning message",
706
+ });
707
+
708
+ expect(console.warn).toHaveBeenCalledWith(
709
+ "[PlayerAnalyticsConnector] Analytics not initiated"
710
+ );
711
+ });
712
+ });
713
+
714
+ describe("report methods when initialized", () => {
715
+ let connector: PlayerAnalyticsConnector;
716
+
717
+ beforeEach(async () => {
718
+ connector = new PlayerAnalyticsConnector("https://example.com/analytics");
719
+ await connector.init({ sessionId: "test-session" });
720
+ connector.load(mockVideoElement);
721
+ mockFetch.calls.reset();
722
+ });
723
+
724
+ it("should send bitrate_changed event with correct payload", () => {
725
+ connector.reportBitrateChange({
726
+ bitrate: 5000000,
727
+ width: 1920,
728
+ height: 1080,
729
+ });
730
+
731
+ expect(mockFetch).toHaveBeenCalled();
732
+ const body = JSON.parse(mockFetch.calls.argsFor(0)[1].body);
733
+ expect(body.event).toBe("bitrate_changed");
734
+ expect(body.payload.bitrate).toBe(5000000);
735
+ expect(body.payload.width).toBe(1920);
736
+ expect(body.payload.height).toBe(1080);
737
+ });
738
+
739
+ it("should send stopped event with reason aborted when reportStop is called", () => {
740
+ connector.reportStop();
741
+
742
+ expect(mockFetch).toHaveBeenCalled();
743
+ const body = JSON.parse(mockFetch.calls.argsFor(0)[1].body);
744
+ expect(body.event).toBe("stopped");
745
+ expect(body.payload.reason).toBe("aborted");
746
+ });
747
+
748
+ it("should send error and stopped events when reportError is called", () => {
749
+ connector.reportError({
750
+ category: "network",
751
+ code: "MEDIA_ERR_NETWORK",
752
+ message: "Network error",
753
+ });
754
+
755
+ expect(mockFetch).toHaveBeenCalledTimes(2);
756
+
757
+ const errorBody = JSON.parse(mockFetch.calls.argsFor(0)[1].body);
758
+ expect(errorBody.event).toBe("error");
759
+ expect(errorBody.payload.category).toBe("network");
760
+
761
+ const stoppedBody = JSON.parse(mockFetch.calls.argsFor(1)[1].body);
762
+ expect(stoppedBody.event).toBe("stopped");
763
+ expect(stoppedBody.payload.reason).toBe("error");
764
+ });
765
+
766
+ it("should send metadata event with correct payload", () => {
767
+ connector.reportMetadata({
768
+ contentId: "video-123",
769
+ contentUrl: "https://example.com/video.m3u8",
770
+ live: false,
771
+ drmType: "widevine",
772
+ });
773
+
774
+ expect(mockFetch).toHaveBeenCalled();
775
+ const body = JSON.parse(mockFetch.calls.argsFor(0)[1].body);
776
+ expect(body.event).toBe("metadata");
777
+ expect(body.payload.contentId).toBe("video-123");
778
+ expect(body.payload.live).toBe(false);
779
+ expect(body.payload.drmType).toBe("widevine");
780
+ });
781
+
782
+ it("should send warning event with correct payload", () => {
783
+ connector.reportWarning({
784
+ category: "player",
785
+ code: "WARN_001",
786
+ message: "Warning message",
787
+ });
788
+
789
+ expect(mockFetch).toHaveBeenCalled();
790
+ const body = JSON.parse(mockFetch.calls.argsFor(0)[1].body);
791
+ expect(body.event).toBe("warning");
792
+ expect(body.payload.category).toBe("player");
793
+ });
794
+
795
+ it("should send warning event with all TWarningEventPayload fields", () => {
796
+ connector.reportWarning({
797
+ category: "decoder",
798
+ code: "CODEC_FALLBACK",
799
+ message: "Fell back to software decoding",
800
+ data: { codec: "hevc", fallback: "avc1" },
801
+ });
802
+
803
+ expect(mockFetch).toHaveBeenCalled();
804
+ const body = JSON.parse(mockFetch.calls.argsFor(0)[1].body);
805
+ expect(body.event).toBe("warning");
806
+ expect(body.payload.category).toBe("decoder");
807
+ expect(body.payload.code).toBe("CODEC_FALLBACK");
808
+ expect(body.payload.message).toBe("Fell back to software decoding");
809
+ expect(body.payload.data).toEqual({ codec: "hevc", fallback: "avc1" });
810
+ });
811
+ });
812
+
813
+ describe("deinit()", () => {
814
+ beforeEach(() => {
815
+ jasmine.clock().install();
816
+ });
817
+
818
+ afterEach(() => {
819
+ jasmine.clock().uninstall();
820
+ });
821
+
822
+ it("should set analyticsInitiated to false", async () => {
823
+ const connector = new PlayerAnalyticsConnector(
824
+ "https://example.com/analytics"
825
+ );
826
+ await connector.init({ sessionId: "test-session" });
827
+
828
+ connector.deinit();
829
+
830
+ expect((connector as any).analyticsInitiated).toBe(false);
831
+ });
832
+
833
+ it("should stop heartbeat interval", async () => {
834
+ const connector = new PlayerAnalyticsConnector(
835
+ "https://example.com/analytics"
836
+ );
837
+ await connector.init({
838
+ sessionId: "test-session",
839
+ heartbeatInterval: 5000,
840
+ });
841
+
842
+ connector.load(mockVideoElement);
843
+ (connector as any).startInterval();
844
+
845
+ connector.deinit();
846
+
847
+ mockFetch.calls.reset();
848
+ jasmine.clock().tick(5000);
849
+
850
+ expect(mockFetch).not.toHaveBeenCalled();
851
+ });
852
+
853
+ it("should teardown video event filter", async () => {
854
+ const connector = new PlayerAnalyticsConnector(
855
+ "https://example.com/analytics"
856
+ );
857
+ await connector.init({ sessionId: "test-session" });
858
+
859
+ connector.load(mockVideoElement);
860
+
861
+ const teardownSpy = jasmine.createSpy("teardown");
862
+ (connector as any).videoEventFilter = { teardown: teardownSpy };
863
+
864
+ connector.deinit();
865
+
866
+ expect(teardownSpy).toHaveBeenCalled();
867
+ expect((connector as any).videoEventFilter).toBeNull();
868
+ });
869
+
870
+ it("should warn when called before init", () => {
871
+ const connector = new PlayerAnalyticsConnector(
872
+ "https://example.com/analytics"
873
+ );
874
+ spyOn(console, "warn");
875
+
876
+ connector.deinit();
877
+
878
+ expect(console.warn).toHaveBeenCalledWith(
879
+ "[PlayerAnalyticsConnector] Analytics not initiated"
880
+ );
881
+ });
882
+ });
883
+
884
+ describe("destroy()", () => {
885
+ beforeEach(() => {
886
+ jasmine.clock().install();
887
+ });
888
+
889
+ afterEach(() => {
890
+ jasmine.clock().uninstall();
891
+ });
892
+
893
+ it("should set analyticsInitiated to false", async () => {
894
+ const connector = new PlayerAnalyticsConnector(
895
+ "https://example.com/analytics"
896
+ );
897
+ await connector.init({ sessionId: "test-session" });
898
+
899
+ connector.destroy();
900
+
901
+ expect((connector as any).analyticsInitiated).toBe(false);
902
+ });
903
+
904
+ it("should stop heartbeat and teardown filter", async () => {
905
+ const connector = new PlayerAnalyticsConnector(
906
+ "https://example.com/analytics"
907
+ );
908
+ await connector.init({
909
+ sessionId: "test-session",
910
+ heartbeatInterval: 5000,
911
+ });
912
+
913
+ connector.load(mockVideoElement);
914
+ (connector as any).startInterval();
915
+
916
+ const teardownSpy = jasmine.createSpy("teardown");
917
+ (connector as any).videoEventFilter = { teardown: teardownSpy };
918
+
919
+ connector.destroy();
920
+
921
+ mockFetch.calls.reset();
922
+ jasmine.clock().tick(5000);
923
+
924
+ expect(mockFetch).not.toHaveBeenCalled();
925
+ expect(teardownSpy).toHaveBeenCalled();
926
+ });
927
+
928
+ it("should destroy playerAnalytics instance", async () => {
929
+ const connector = new PlayerAnalyticsConnector(
930
+ "https://example.com/analytics"
931
+ );
932
+ await connector.init({ sessionId: "test-session" });
933
+
934
+ spyOn((connector as any).playerAnalytics, "destroy");
935
+
936
+ connector.destroy();
937
+
938
+ expect((connector as any).playerAnalytics.destroy).toHaveBeenCalled();
939
+ });
940
+
941
+ it("should warn when called before init", () => {
942
+ const connector = new PlayerAnalyticsConnector(
943
+ "https://example.com/analytics"
944
+ );
945
+ spyOn(console, "warn");
946
+
947
+ connector.destroy();
948
+
949
+ expect(console.warn).toHaveBeenCalledWith(
950
+ "[PlayerAnalyticsConnector] Analytics not initiated"
951
+ );
952
+ });
953
+ });
954
+
955
+ describe("non-blocking init", () => {
956
+ // Helper: flush promise microtask queue
957
+ async function flushMicrotasks() {
958
+ for (let i = 0; i < 10; i++) await Promise.resolve();
959
+ }
960
+
961
+ // Type-safe access to private members for test assertions
962
+ interface ConnectorInternals {
963
+ sessionId: string;
964
+ pendingHeartbeatStart: boolean;
965
+ heartbeatIntervalTimer: ReturnType<typeof setInterval> | null;
966
+ startInterval: () => void;
967
+ stopInterval: () => void;
968
+ }
969
+
970
+ it("should allow load() before init resolves (events queued)", async () => {
971
+ let resolveInit!: (value: unknown) => void;
972
+ const pendingInit = new Promise((resolve) => {
973
+ resolveInit = resolve;
974
+ });
975
+ mockFetch.and.returnValue(pendingInit);
976
+
977
+ const connector = new PlayerAnalyticsConnector(
978
+ "https://example.com/analytics"
979
+ );
980
+ const initPromise = connector.init({ sessionId: "test-session" });
981
+
982
+ mockFetch.calls.reset();
983
+ connector.load(mockVideoElement);
984
+
985
+ expect(mockFetch).not.toHaveBeenCalled();
986
+
987
+ resolveInit({
988
+ ok: true,
989
+ json: () => Promise.resolve({ sessionId: "test-session" }),
990
+ statusText: "OK",
991
+ });
992
+
993
+ await initPromise;
994
+ await flushMicrotasks();
995
+
996
+ expect(mockFetch).toHaveBeenCalled();
997
+ const body = JSON.parse(mockFetch.calls.argsFor(0)[1].body);
998
+ expect(body.event).toBe("loading");
999
+ });
1000
+
1001
+ it("should update sessionId from server response", async () => {
1002
+ const connector = new PlayerAnalyticsConnector(
1003
+ "https://example.com/analytics"
1004
+ );
1005
+
1006
+ mockFetch.and.returnValue(
1007
+ Promise.resolve({
1008
+ ok: true,
1009
+ json: () => Promise.resolve({ sessionId: "server-generated-id" }),
1010
+ statusText: "OK",
1011
+ })
1012
+ );
1013
+
1014
+ await connector.init({ sessionId: "client-id" });
1015
+ await flushMicrotasks();
1016
+
1017
+ const internals = connector as unknown as ConnectorInternals;
1018
+ expect(internals.sessionId).toBe("server-generated-id");
1019
+ });
1020
+
1021
+ it("should defer heartbeat start if PLAYING fires before init resolves", async () => {
1022
+ let resolveInit!: (value: unknown) => void;
1023
+ const pendingInit = new Promise((resolve) => {
1024
+ resolveInit = resolve;
1025
+ });
1026
+ mockFetch.and.returnValue(pendingInit);
1027
+
1028
+ const connector = new PlayerAnalyticsConnector(
1029
+ "https://example.com/analytics"
1030
+ );
1031
+ const initPromise = connector.init({
1032
+ sessionId: "test-session",
1033
+ heartbeatInterval: 5000,
1034
+ });
1035
+
1036
+ const internals = connector as unknown as ConnectorInternals;
1037
+ internals.startInterval();
1038
+
1039
+ expect(internals.pendingHeartbeatStart).toBe(true);
1040
+ expect(internals.heartbeatIntervalTimer).toBeUndefined();
1041
+
1042
+ resolveInit({
1043
+ ok: true,
1044
+ json: () => Promise.resolve({ sessionId: "test-session" }),
1045
+ statusText: "OK",
1046
+ });
1047
+
1048
+ await initPromise;
1049
+ await flushMicrotasks();
1050
+
1051
+ expect(internals.pendingHeartbeatStart).toBe(false);
1052
+ expect(internals.heartbeatIntervalTimer).toBeDefined();
1053
+
1054
+ internals.stopInterval();
1055
+ });
1056
+
1057
+ it("should drop queued events when deinit() is called during init", async () => {
1058
+ // Same as the destroy() case but for deinit() — must also cascade
1059
+ // to reporter.destroy() to abort the in-flight init.
1060
+ let resolveInit!: (value: unknown) => void;
1061
+ const pendingInit = new Promise((resolve) => {
1062
+ resolveInit = resolve;
1063
+ });
1064
+ mockFetch.and.returnValue(pendingInit);
1065
+
1066
+ const connector = new PlayerAnalyticsConnector(
1067
+ "https://example.com/analytics"
1068
+ );
1069
+ const initPromise = connector.init({ sessionId: "test-session" });
1070
+
1071
+ mockFetch.calls.reset();
1072
+ connector.load(mockVideoElement);
1073
+ // 'loading' is queued at this point
1074
+
1075
+ // Deinit mid-init (instead of destroy)
1076
+ connector.deinit();
1077
+
1078
+ resolveInit({
1079
+ ok: true,
1080
+ json: () => Promise.resolve({ sessionId: "test-session" }),
1081
+ statusText: "OK",
1082
+ });
1083
+
1084
+ try {
1085
+ await initPromise;
1086
+ } catch {
1087
+ /* expected */
1088
+ }
1089
+ await flushMicrotasks();
1090
+
1091
+ // No event should reach the wire after deinit
1092
+ expect(mockFetch).not.toHaveBeenCalled();
1093
+ });
1094
+
1095
+ it("should drop queued events when destroy() is called during init", async () => {
1096
+ // End-to-end: load() during pending init queues a 'loading' event.
1097
+ // destroy() must abort the init and prevent that event from reaching the wire.
1098
+ let resolveInit!: (value: unknown) => void;
1099
+ const pendingInit = new Promise((resolve) => {
1100
+ resolveInit = resolve;
1101
+ });
1102
+ mockFetch.and.returnValue(pendingInit);
1103
+
1104
+ const connector = new PlayerAnalyticsConnector(
1105
+ "https://example.com/analytics"
1106
+ );
1107
+ const initPromise = connector.init({ sessionId: "test-session" });
1108
+
1109
+ mockFetch.calls.reset();
1110
+ connector.load(mockVideoElement);
1111
+ // 'loading' is queued at this point
1112
+
1113
+ // Destroy mid-init
1114
+ connector.destroy();
1115
+
1116
+ resolveInit({
1117
+ ok: true,
1118
+ json: () => Promise.resolve({ sessionId: "test-session" }),
1119
+ statusText: "OK",
1120
+ });
1121
+
1122
+ try {
1123
+ await initPromise;
1124
+ } catch {
1125
+ /* expected */
1126
+ }
1127
+ await flushMicrotasks();
1128
+
1129
+ // No event should reach the wire after destroy
1130
+ expect(mockFetch).not.toHaveBeenCalled();
1131
+ });
1132
+
1133
+ it("should send playing and start heartbeats for autoplay that started during a slow init() handshake", async () => {
1134
+ // init() resolves slowly. Autoplay begins during the handshake, so the
1135
+ // DOM "playing" event fires before the media-event-filter is attached and
1136
+ // is never re-emitted. load() must detect the already-playing element,
1137
+ // emit a queued 'playing', and arrange for heartbeats to start once init
1138
+ // resolves.
1139
+ jasmine.clock().install();
1140
+ try {
1141
+ let resolveInit!: (value: unknown) => void;
1142
+ const pendingInit = new Promise((resolve) => {
1143
+ resolveInit = resolve;
1144
+ });
1145
+ mockFetch.and.returnValue(pendingInit);
1146
+
1147
+ const connector = new PlayerAnalyticsConnector(
1148
+ "https://example.com/analytics"
1149
+ );
1150
+ const initPromise = connector.init({
1151
+ sessionId: "test-session",
1152
+ heartbeatInterval: 5000,
1153
+ });
1154
+
1155
+ // Element already playing by the time load() is called.
1156
+ const playingElement = {
1157
+ ...mockVideoElement,
1158
+ paused: false,
1159
+ ended: false,
1160
+ readyState: 4,
1161
+ currentTime: 2,
1162
+ duration: 100,
1163
+ };
1164
+
1165
+ mockFetch.calls.reset();
1166
+ connector.load(playingElement);
1167
+
1168
+ // Events are queued until the handshake completes.
1169
+ expect(mockFetch).not.toHaveBeenCalled();
1170
+
1171
+ resolveInit({
1172
+ ok: true,
1173
+ json: () => Promise.resolve({ sessionId: "test-session" }),
1174
+ statusText: "OK",
1175
+ });
1176
+
1177
+ await initPromise;
1178
+ await flushMicrotasks();
1179
+
1180
+ // The queued 'loading' and 'playing' events must both reach the wire.
1181
+ const events = mockFetch.calls
1182
+ .all()
1183
+ .map((c) => JSON.parse(c.args[1].body).event);
1184
+ expect(events).toContain("loading");
1185
+ expect(events).toContain("playing");
1186
+
1187
+ // Heartbeats must now be running (deferred start fired on init resolve).
1188
+ mockFetch.calls.reset();
1189
+ jasmine.clock().tick(5000);
1190
+ const heartbeatEvents = mockFetch.calls
1191
+ .all()
1192
+ .map((c) => JSON.parse(c.args[1].body).event);
1193
+ expect(heartbeatEvents).toContain("heartbeat");
1194
+
1195
+ (connector as unknown as ConnectorInternals).stopInterval();
1196
+ } finally {
1197
+ jasmine.clock().uninstall();
1198
+ }
1199
+ });
1200
+
1201
+ it("should send playing immediately when element is already playing and init already resolved", async () => {
1202
+ const connector = new PlayerAnalyticsConnector(
1203
+ "https://example.com/analytics"
1204
+ );
1205
+ await connector.init({
1206
+ sessionId: "test-session",
1207
+ heartbeatInterval: 5000,
1208
+ });
1209
+ await flushMicrotasks();
1210
+
1211
+ const playingElement = {
1212
+ ...mockVideoElement,
1213
+ paused: false,
1214
+ ended: false,
1215
+ readyState: 4,
1216
+ };
1217
+
1218
+ mockFetch.calls.reset();
1219
+ connector.load(playingElement);
1220
+
1221
+ const events = mockFetch.calls
1222
+ .all()
1223
+ .map((c) => JSON.parse(c.args[1].body).event);
1224
+ expect(events).toContain("loading");
1225
+ expect(events).toContain("playing");
1226
+
1227
+ (connector as unknown as ConnectorInternals).stopInterval();
1228
+ });
1229
+
1230
+ it("should NOT emit a playing event on load() when the element is paused", async () => {
1231
+ const connector = new PlayerAnalyticsConnector(
1232
+ "https://example.com/analytics"
1233
+ );
1234
+ await connector.init({ sessionId: "test-session" });
1235
+ await flushMicrotasks();
1236
+
1237
+ const pausedElement = {
1238
+ ...mockVideoElement,
1239
+ paused: true,
1240
+ ended: false,
1241
+ readyState: 4,
1242
+ };
1243
+
1244
+ mockFetch.calls.reset();
1245
+ connector.load(pausedElement);
1246
+
1247
+ const events = mockFetch.calls
1248
+ .all()
1249
+ .map((c) => JSON.parse(c.args[1].body).event);
1250
+ expect(events).not.toContain("playing");
1251
+ });
1252
+
1253
+ it("should reset pendingHeartbeatStart on init failure (no stale flag for retry)", async () => {
1254
+ // First init: PLAYING fires before init completes (sets pendingHeartbeatStart),
1255
+ // then init fails. The flag must be cleared so a retry doesn't fire heartbeats
1256
+ // without a new PLAYING event.
1257
+ mockFetch.and.returnValue(
1258
+ Promise.resolve({
1259
+ ok: false,
1260
+ statusText: "Service Unavailable",
1261
+ })
1262
+ );
1263
+
1264
+ const connector = new PlayerAnalyticsConnector(
1265
+ "https://example.com/analytics"
1266
+ );
1267
+ const initPromise = connector.init({
1268
+ sessionId: "test-session",
1269
+ heartbeatInterval: 5000,
1270
+ });
1271
+
1272
+ const internals = connector as unknown as ConnectorInternals;
1273
+ // Simulate PLAYING event during init
1274
+ internals.startInterval();
1275
+ expect(internals.pendingHeartbeatStart).toBe(true);
1276
+
1277
+ try {
1278
+ await initPromise;
1279
+ } catch {
1280
+ /* expected */
1281
+ }
1282
+ await flushMicrotasks();
1283
+
1284
+ // pendingHeartbeatStart must be cleared after init failure
1285
+ expect(internals.pendingHeartbeatStart).toBe(false);
1286
+ });
1287
+ });
1288
+
1289
+ describe("autoplay terminal/seek fallback (already-playing attach)", () => {
1290
+ async function flushMicrotasks() {
1291
+ for (let i = 0; i < 10; i++) await Promise.resolve();
1292
+ }
1293
+
1294
+ // A video element whose add/removeEventListener actually register handlers,
1295
+ // so a DOM "ended"/"seeking"/"seeked" can be dispatched in-test. Models the
1296
+ // already-playing autoplay element the media-event-filter attaches to too
1297
+ // late to observe.
1298
+ function makePlayingElement(overrides: Record<string, any> = {}) {
1299
+ const listeners: Record<string, Set<(...a: any[]) => void>> = {};
1300
+ return {
1301
+ currentTime: 2,
1302
+ duration: 100,
1303
+ paused: false,
1304
+ ended: false,
1305
+ readyState: 4,
1306
+ addEventListener(type: string, cb: (...a: any[]) => void) {
1307
+ (listeners[type] || (listeners[type] = new Set())).add(cb);
1308
+ },
1309
+ removeEventListener(type: string, cb: (...a: any[]) => void) {
1310
+ listeners[type] && listeners[type].delete(cb);
1311
+ },
1312
+ __dispatch(type: string) {
1313
+ listeners[type] && listeners[type].forEach((cb) => cb({}));
1314
+ },
1315
+ ...overrides,
1316
+ } as any;
1317
+ }
1318
+
1319
+ const bodies = () =>
1320
+ mockFetch.calls.all().map((c: any) => JSON.parse(c.args[1].body));
1321
+
1322
+ it("reports exactly one stopped(ended) and stops heartbeats when an already-playing element ends", async () => {
1323
+ jasmine.clock().install();
1324
+ try {
1325
+ const connector = new PlayerAnalyticsConnector(
1326
+ "https://example.com/analytics"
1327
+ );
1328
+ await connector.init({
1329
+ sessionId: "test-session",
1330
+ heartbeatInterval: 5000,
1331
+ });
1332
+ await flushMicrotasks();
1333
+
1334
+ const el = makePlayingElement();
1335
+ connector.load(el);
1336
+
1337
+ // Heartbeats are running for the autoplay session.
1338
+ mockFetch.calls.reset();
1339
+ jasmine.clock().tick(5000);
1340
+ expect(bodies().map((b) => b.event)).toContain("heartbeat");
1341
+
1342
+ // End of media.
1343
+ mockFetch.calls.reset();
1344
+ el.__dispatch("ended");
1345
+
1346
+ const stopped = bodies().filter((b) => b.event === "stopped");
1347
+ expect(stopped.length).toBe(1);
1348
+ expect(stopped[0].payload.reason).toBe("ended");
1349
+
1350
+ // Heartbeats stop after ended.
1351
+ mockFetch.calls.reset();
1352
+ jasmine.clock().tick(5000);
1353
+ expect(mockFetch).not.toHaveBeenCalled();
1354
+ } finally {
1355
+ jasmine.clock().uninstall();
1356
+ }
1357
+ });
1358
+
1359
+ it("reports seeking and seeked for an already-playing element", async () => {
1360
+ const connector = new PlayerAnalyticsConnector(
1361
+ "https://example.com/analytics"
1362
+ );
1363
+ await connector.init({
1364
+ sessionId: "test-session",
1365
+ heartbeatInterval: 5000,
1366
+ });
1367
+ await flushMicrotasks();
1368
+
1369
+ const el = makePlayingElement();
1370
+ connector.load(el);
1371
+
1372
+ mockFetch.calls.reset();
1373
+ el.__dispatch("seeking");
1374
+ el.__dispatch("seeked");
1375
+
1376
+ const events = bodies().map((b) => b.event);
1377
+ expect(events).toContain("seeking");
1378
+ expect(events).toContain("seeked");
1379
+
1380
+ (connector as any).stopInterval();
1381
+ });
1382
+
1383
+ it("does not emit a second stopped when the session was already stopped (reportStop)", async () => {
1384
+ const connector = new PlayerAnalyticsConnector(
1385
+ "https://example.com/analytics"
1386
+ );
1387
+ await connector.init({
1388
+ sessionId: "test-session",
1389
+ heartbeatInterval: 5000,
1390
+ });
1391
+ await flushMicrotasks();
1392
+
1393
+ const el = makePlayingElement();
1394
+ connector.load(el);
1395
+
1396
+ connector.reportStop();
1397
+ mockFetch.calls.reset();
1398
+
1399
+ el.__dispatch("ended");
1400
+
1401
+ expect(bodies().filter((b) => b.event === "stopped").length).toBe(0);
1402
+ });
1403
+
1404
+ it("does not attach the fallback for a paused element", async () => {
1405
+ const connector = new PlayerAnalyticsConnector(
1406
+ "https://example.com/analytics"
1407
+ );
1408
+ await connector.init({ sessionId: "test-session" });
1409
+ await flushMicrotasks();
1410
+
1411
+ const el = makePlayingElement({ paused: true });
1412
+ connector.load(el);
1413
+
1414
+ mockFetch.calls.reset();
1415
+ el.__dispatch("ended");
1416
+
1417
+ // No fallback => the DOM ended must not produce a stopped event.
1418
+ expect(bodies().filter((b) => b.event === "stopped").length).toBe(0);
1419
+ expect((connector as any).autoplayFallbackActive).toBe(false);
1420
+ });
1421
+
1422
+ it("retires the DOM fallback once the media-event-filter goes live (seek reported once, not twice)", async () => {
1423
+ const connector = new PlayerAnalyticsConnector(
1424
+ "https://example.com/analytics"
1425
+ );
1426
+ await connector.init({
1427
+ sessionId: "test-session",
1428
+ heartbeatInterval: 5000,
1429
+ });
1430
+ await flushMicrotasks();
1431
+
1432
+ const el = makePlayingElement();
1433
+ connector.load(el);
1434
+ expect((connector as any).autoplayFallbackActive).toBe(true);
1435
+
1436
+ // Drive the real media-event-filter out of its initial "loading" state:
1437
+ // a DOM "playing" makes it emit, so it becomes the authoritative source
1438
+ // and the DOM fallback must retire. Both the filter and the fallback are
1439
+ // registered for "seeking" on the element; retiring the fallback is what
1440
+ // prevents the seek from being reported twice.
1441
+ el.__dispatch("playing");
1442
+ expect((connector as any).autoplayFallbackActive).toBe(false);
1443
+
1444
+ mockFetch.calls.reset();
1445
+ el.__dispatch("seeking");
1446
+ const seekingEvents = bodies().filter((b) => b.event === "seeking");
1447
+ expect(seekingEvents.length).toBe(1);
1448
+
1449
+ (connector as any).stopInterval();
1450
+ });
1451
+
1452
+ it("removes the fallback DOM listeners on destroy()", async () => {
1453
+ const connector = new PlayerAnalyticsConnector(
1454
+ "https://example.com/analytics"
1455
+ );
1456
+ await connector.init({
1457
+ sessionId: "test-session",
1458
+ heartbeatInterval: 5000,
1459
+ });
1460
+ await flushMicrotasks();
1461
+
1462
+ const el = makePlayingElement();
1463
+ connector.load(el);
1464
+ expect((connector as any).autoplayFallbackActive).toBe(true);
1465
+
1466
+ connector.destroy();
1467
+ expect((connector as any).autoplayFallbackActive).toBe(false);
1468
+
1469
+ mockFetch.calls.reset();
1470
+ el.__dispatch("ended");
1471
+ expect(bodies().filter((b) => b.event === "stopped").length).toBe(0);
1472
+ });
1473
+ });
1474
+ });