From e3b385e924e301e063a8a263b5868e44dd1da853 Mon Sep 17 00:00:00 2001 From: Adi Date: Sun, 20 Sep 2026 10:45:35 +0300 Subject: [PATCH 1/2] fix: guard track() against window.gtag not being callable _initAnalytics() only checks window.gtag truthiness once at setup; track() then calls window.gtag(...) unguarded on every tracked event (play, pause, volumechange, etc). If gtag becomes non-callable after init - a consent-management tool swapping it out, an ad-blocker, CSP - the next tracked event throws an uncaught TypeError, breaking whatever runs after it (same failure shape as ZD #388711 for the old window.ga integration). Co-Authored-By: Claude Sonnet 5 --- src/plugins/analytics/index.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/plugins/analytics/index.js b/src/plugins/analytics/index.js index d2e73fae..0e751bc5 100644 --- a/src/plugins/analytics/index.js +++ b/src/plugins/analytics/index.js @@ -194,6 +194,10 @@ class AnalyticsPlugin { } track({ action, label, value = null, nonInteraction = false }) { + if (typeof window.gtag !== 'function') { + return; + } + const eventData = { event_category: this.options.category, event_label: label || this.options.defaultLabel(this.player), From 3b7de637bd23ae6eed9dc6ceb1c0cf7c02a797a7 Mon Sep 17 00:00:00 2001 From: Adi Date: Sun, 20 Sep 2026 10:51:46 +0300 Subject: [PATCH 2/2] test: cover analytics gtag guard Covers both sides of the track() guard: real gtag calls still fire normally, and a non-callable gtag (consent tool, ad-blocker) no longer errors. Since videojs's event dispatcher catches handler exceptions and logs them via videojs.log.error instead of rethrowing, the non-callable case asserts against that log spy rather than a thrown error. Co-Authored-By: Claude Sonnet 5 --- test/unit/analytics.test.js | 66 +++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 test/unit/analytics.test.js diff --git a/test/unit/analytics.test.js b/test/unit/analytics.test.js new file mode 100644 index 00000000..3b08836f --- /dev/null +++ b/test/unit/analytics.test.js @@ -0,0 +1,66 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import videojs from 'video.js'; +import VideoPlayer from '../../src/video-player'; +import { getResolveVideoElement, extractOptions } from '../../src/video-player.utils'; + +describe('AnalyticsPlugin', () => { + let originalFetch; + let originalGtag; + + beforeAll(() => { + originalFetch = global.fetch; + global.fetch = vi.fn(); + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + afterEach(() => { + window.gtag = originalGtag; + }); + + const createPlayer = async (id) => { + document.body.innerHTML = `
`; + const elem = getResolveVideoElement(id); + const options = extractOptions(elem, { + cloudinaryConfig: { cloud_name: 'demo' }, + analytics: true + }); + + const vp = new VideoPlayer(elem, options); + await new Promise(resolve => vp.videojs.ready(resolve)); + return vp; + }; + + it('calls gtag when it is callable', async () => { + originalGtag = window.gtag; + window.gtag = vi.fn(); + + const vp = await createPlayer('test-analytics-gtag-callable'); + vp.videojs.trigger('play'); + + expect(window.gtag).toHaveBeenCalledWith('event', 'Play', expect.objectContaining({ + event_category: 'Video' + })); + }); + + it('does not error when gtag becomes non-callable after init', async () => { + originalGtag = window.gtag; + window.gtag = vi.fn(); + + const vp = await createPlayer('test-analytics-gtag-noncallable'); + + // Simulate a consent-management tool / ad-blocker replacing gtag after setup + window.gtag = 'not-a-function'; + + // videojs's event dispatcher catches handler exceptions and logs them via + // videojs.log.error instead of letting them throw synchronously, so an + // uncaught TypeError here surfaces as a logged error, not a thrown one. + const logErrorSpy = vi.spyOn(videojs.log, 'error').mockImplementation(() => {}); + + vp.videojs.trigger('play'); + + expect(logErrorSpy).not.toHaveBeenCalled(); + }); +});