diff --git a/package-lock.json b/package-lock.json index d0b9bf5de0..de69f1cb15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,11 +10,11 @@ "license": "BSD-3-Clause", "dependencies": { "@svta/cml-608": "1.0.2", - "@svta/cml-cmcd": "2.3.2", + "@svta/cml-cmcd": "2.4.0", "@svta/cml-cmsd": "1.0.6", "@svta/cml-dash": "1.0.6", "@svta/cml-id3": "1.0.6", - "@svta/cml-request": "1.0.12", + "@svta/cml-request": "1.0.13", "@svta/cml-xml": "1.1.4", "bcp-47-match": "^2.0.3", "codem-isoboxer": "0.3.10", @@ -2695,9 +2695,9 @@ } }, "node_modules/@svta/cml-cmcd": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.3.2.tgz", - "integrity": "sha512-SKBBjLmci0WK8HMjuv+36tVIMktonoOoxsXblOFZmB+ePPV2zjRMTD+2ZmE/1VEPJkKHENyhSjSHgJyeOlvZ1A==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.4.0.tgz", + "integrity": "sha512-LEVH5t5igv1fN18huFK+9S+qQOej2jFU16Z6E1phlaL615uP/+G3JiVZrw5n/GPAME4XjWmcgzbd7rP8dNwzvQ==", "license": "Apache-2.0", "engines": { "node": ">=20" @@ -2760,15 +2760,15 @@ } }, "node_modules/@svta/cml-request": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@svta/cml-request/-/cml-request-1.0.12.tgz", - "integrity": "sha512-4sJvnnoNpq58j2mCGP8k+MF6wVy/qa4gbt6kfT1dPIKmn3mPxw+JVfilhcWsUi+peK2yCZxOJJYyHj1cAcQE1w==", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@svta/cml-request/-/cml-request-1.0.13.tgz", + "integrity": "sha512-DK2Bx94JjTPJAxNlc18JVa5+v3tCLHMorpgTPdy0KqhzQ6S0KjK4kdX8u7ULu3oYgEnBuimjMPOnWBvEJws87A==", "license": "Apache-2.0", "engines": { "node": ">=20" }, "peerDependencies": { - "@svta/cml-cmcd": "2.3.2", + "@svta/cml-cmcd": "2.4.0", "@svta/cml-utils": "1.5.0", "@svta/cml-xml": "1.1.4" } diff --git a/package.json b/package.json index 885d97e97a..a06b77958b 100644 --- a/package.json +++ b/package.json @@ -91,11 +91,11 @@ }, "dependencies": { "@svta/cml-608": "1.0.2", - "@svta/cml-cmcd": "2.3.2", + "@svta/cml-cmcd": "2.4.0", "@svta/cml-cmsd": "1.0.6", "@svta/cml-dash": "1.0.6", "@svta/cml-id3": "1.0.6", - "@svta/cml-request": "1.0.12", + "@svta/cml-request": "1.0.13", "@svta/cml-xml": "1.1.4", "bcp-47-match": "^2.0.3", "codem-isoboxer": "0.3.10", diff --git a/src/streaming/controllers/CmcdController.js b/src/streaming/controllers/CmcdController.js index 2d85c2c925..44968100fc 100644 --- a/src/streaming/controllers/CmcdController.js +++ b/src/streaming/controllers/CmcdController.js @@ -165,12 +165,35 @@ function CmcdController() { }); } - function _onPlaybackStateChange(state) { - // Update CmcdReporter with the new player state - if (cmcdReporter) { - cmcdReporter.update({ sta: state }); + function _partialHasStateField(partial) { + const stateFields = ['sta', 'pr', 'cid', 'bg', 'br']; + return stateFields.some((field) => field in partial); + } + + function _updateCmcdReporter(partial = {}) { + if (!cmcdReporter) { + return; + } + + _rebuildReporterIfNeeded(); + + const msdData = cmcdModel.calculateMsd(); + const metrics = _partialHasStateField(partial) + ? cmcdModel.getEventModeData() + : cmcdModel.getContinuousCmcdData(); + const payload = { + ...metrics, + ...partial, + }; + if (msdData.msd !== undefined) { + payload.msd = msdData.msd; } - triggerCmcdEventMode(Constants.CMCD_REPORTING_EVENTS.PLAY_STATE); + + cmcdReporter.update(payload); + } + + function _onPlaybackStateChange(state) { + _updateCmcdReporter({ sta: state }); } function _createCmcdReporter() { @@ -250,15 +273,12 @@ function CmcdController() { if (errorData.error?.data?.request?.type === HTTPRequest.CMCD_EVENT) { return; } - // Update CmcdReporter with the error code - if (cmcdReporter) { - const errorCode = errorData.error?.code || errorData.error?.data?.code; - if (errorCode) { - cmcdReporter.update({ ec: errorCode }); - } + const errorCode = errorData.error?.code || errorData.error?.data?.code; + const eventData = cmcdModel.getEventModeData(); + if (errorCode !== undefined) { + eventData.ec = Array.isArray(errorCode) ? errorCode : [String(errorCode)]; } - - triggerCmcdEventMode(Constants.CMCD_REPORTING_EVENTS.ERROR); + triggerCmcdEventMode(Constants.CMCD_REPORTING_EVENTS.ERROR, eventData); } function _rebuildReporterIfNeeded() { @@ -289,23 +309,23 @@ function CmcdController() { * The handler that is triggered for CMCD event mode events (e.g., play, pause, error). Note that response recevived (rr) events are handled by getCmcdResponseReceivedInterceptors. * @param event */ - function triggerCmcdEventMode(event) { + function triggerCmcdEventMode(event, eventData) { if (!cmcdReporter) { return; } _rebuildReporterIfNeeded(); - const cmcdData = cmcdModel.getEventModeData(); - - // Route media start delay (MSD) through update() for the reporter's internal send-once tracking const msdData = cmcdModel.calculateMsd(); if (msdData.msd !== undefined) { cmcdReporter.update(msdData); } - // Pass event-mode data as transient per-event data (not persisted) - cmcdReporter.recordEvent(event, cmcdData); + const data = eventData !== undefined + ? eventData + : cmcdModel.getEventModeData(); + + cmcdReporter.recordEvent(event, data); } /** @@ -324,13 +344,9 @@ function CmcdController() { _rebuildReporterIfNeeded(); try { - const cmcdData = cmcdModel.deriveCmcdDataForRequest(request); + _updateCmcdReporter(); - // Route MSD through update() for the reporter's internal send-once tracking - const msdData = cmcdModel.calculateMsd(); - if (msdData.msd !== undefined) { - cmcdReporter.update(msdData); - } + const cmcdData = cmcdModel.deriveCmcdDataForRequest(request); const decorated = cmcdReporter.createRequestReport(request, cmcdData); request.url = decorated.url; @@ -457,9 +473,10 @@ function CmcdController() { function _onPlaybackRateChanged(data) { const prData = cmcdModel.onPlaybackRateChanged(data); - if (cmcdReporter && prData) { - cmcdReporter.update(prData); + if (!prData) { + return; } + _updateCmcdReporter(prData); } function _onManifestLoaded(data) { diff --git a/src/streaming/models/CmcdModel.js b/src/streaming/models/CmcdModel.js index 8c4bd6ee95..c0d7a589ed 100644 --- a/src/streaming/models/CmcdModel.js +++ b/src/streaming/models/CmcdModel.js @@ -554,20 +554,21 @@ function CmcdModel() { return Date.now() - _playbackStartedTime; } - function getGenericCmcdData(mediaType) { + function getGenericCmcdData(mediaType, options = {}) { const data = {}; + const includeBackground = options.includeBackground !== false; - // Note: ts, st, sf, pr are handled by CmcdReporter: - // - ts: auto-generated by recordEvent() / recordResponseReceived() - // - st, sf: persisted via cmcdReporter.update() in _onManifestLoaded - // - pr: persisted via cmcdReporter.update() in _onPlaybackRateChanged + // Note: ts is auto-generated by CmcdReporter recordEvent / recordResponseReceived. + // st, sf: persisted via cmcdReporter.update() on manifest load (CmcdController). + // sta, pr: persisted and state-change events fired via _updateCmcdReporter() in CmcdController. + // bl, mtp, ab, etc.: refreshed via _updateCmcdReporter() for TIME_INTERVAL and state transitions. let ltc = playbackController.getCurrentLiveLatency() * 1000; if (!isNaN(ltc)) { data.ltc = ltc; } - if (typeof document !== 'undefined' && document.hidden) { + if (includeBackground && typeof document !== 'undefined' && document.hidden) { data.bg = true; } @@ -587,18 +588,24 @@ function CmcdModel() { mediaType === Constants.OTHER; } - function getEventModeData(){ - const cmcdData = { - ...getGenericCmcdData(), + function _getMetricsCmcdData({ includeEncodedBitrate = true, includeBackground = true } = {}) { + return { + ...getGenericCmcdData(undefined, { includeBackground }), ..._getAggregatedBitrateData(), - ..._getEncodedBitrateData(), + ...(includeEncodedBitrate ? _getEncodedBitrateData() : {}), ..._getBufferLevelData(), ..._getMeasuredThroughputData(), ..._getPlayheadBitrateData(), ..._getTopBitrateData(), }; + } + + function getEventModeData() { + return _getMetricsCmcdData(); + } - return cmcdData; + function getContinuousCmcdData() { + return _getMetricsCmcdData({ includeEncodedBitrate: false, includeBackground: false }); } @@ -861,6 +868,7 @@ function CmcdModel() { deriveCmcdDataForRequest, getCmcdParametersFromManifest, getEventModeData, + getContinuousCmcdData, getLastMediaTypeRequest, isIncludedInRequestFilter, onBufferLevelStateChanged, diff --git a/test/functional/config/test-configurations/streams/cmcd.json b/test/functional/config/test-configurations/streams/cmcd.json new file mode 100644 index 0000000000..ed339e8baf --- /dev/null +++ b/test/functional/config/test-configurations/streams/cmcd.json @@ -0,0 +1,27 @@ +{ + "testfiles": { + "included": [ + "feature-support/cmcd", + "feature-support/cmcd-v2" + ], + "excluded": [] + }, + "testvectors": [ + { + "name": "DASH-IF Live Sim - Segment Template without manifest updates", + "type": "live", + "url": "https://livesim2.dashif.org/livesim2/testpic_2s/Manifest.mpd", + "includedTestfiles": [ + "feature-support/cmcd" + ] + }, + { + "name": "BBB 30fps VoD CMCD v2", + "type": "vod", + "url": "https://dash.akamaized.net/akamai/bbb_30fps/bbb_30fps.mpd", + "includedTestfiles": [ + "feature-support/cmcd-v2" + ] + } + ] +} diff --git a/test/functional/helpers/CmcdRequestCollector.js b/test/functional/helpers/CmcdRequestCollector.js deleted file mode 100644 index ee08b0ee18..0000000000 --- a/test/functional/helpers/CmcdRequestCollector.js +++ /dev/null @@ -1,222 +0,0 @@ -const CMCD_HEADER_NAMES = [ - 'cmcd-object', - 'cmcd-request', - 'cmcd-session', - 'cmcd-status', -]; - -function classifyUrl(url, method) { - if (method === 'POST') { - return 'event'; - } - if (/\.mpd/i.test(url)) { - return 'manifest'; - } - if (/\.(m4s|m4v|m4a|mp4)/i.test(url)) { - return 'segment'; - } - return 'unknown'; -} - -function detectReportingMode(url, headers) { - const hasCmcdHeaders = CMCD_HEADER_NAMES.some((name) => headers[name]); - if (hasCmcdHeaders) { - return 'header'; - } - if (url.includes('CMCD=')) { - return 'query'; - } - return null; -} - -/** - * Collects CMCD request data from outgoing XHR requests by monkey-patching - * XMLHttpRequest prototype methods. Stores full httpRequest objects compatible - * with CML's validateCmcdRequest(). - * - * For event target URLs, intercepts POST requests and simulates a 200 - * response to prevent actual network calls. - */ -class CmcdRequestCollector { - - constructor() { - this.requests = []; - this._resolvers = []; - this._eventTargetUrls = []; - this._origOpen = null; - this._origSetRequestHeader = null; - this._origSend = null; - } - - /** - * Install XHR patches to start collecting CMCD data. - * @param {object} [options] - * @param {string[]} [options.eventTargetUrls] - URLs to intercept as event POSTs - */ - attach(options = {}) { - this._eventTargetUrls = options.eventTargetUrls || []; - - const self = this; - - this._origOpen = XMLHttpRequest.prototype.open; - this._origSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader; - this._origSend = XMLHttpRequest.prototype.send; - - XMLHttpRequest.prototype.open = function (method, url) { - this._cmcd_method = method; - this._cmcd_url = typeof url === 'string' ? url : String(url); - this._cmcd_headers = {}; - return self._origOpen.apply(this, arguments); - }; - - XMLHttpRequest.prototype.setRequestHeader = function (name, value) { - if (this._cmcd_headers) { - this._cmcd_headers[name.toLowerCase()] = value; - } - return self._origSetRequestHeader.apply(this, arguments); - }; - - XMLHttpRequest.prototype.send = function (body) { - const url = this._cmcd_url || ''; - const method = (this._cmcd_method || 'GET').toUpperCase(); - const headers = this._cmcd_headers || {}; - - // Event target POST interception - const isEventTarget = self._eventTargetUrls.some( - (target) => url.startsWith(target) - ); - - if (isEventTarget && method === 'POST') { - self.requests.push({ - httpRequest: { url, method, headers, body }, - type: 'event', - reportingMode: 'event', - timestamp: Date.now(), - }); - self._notifyResolvers('event'); - - // Simulate 200 response matching XHRLoader expectations - const xhr = this; - setTimeout(() => { - try { - Object.defineProperty(xhr, 'status', { value: 200, configurable: true }); - Object.defineProperty(xhr, 'statusText', { value: 'OK', configurable: true }); - Object.defineProperty(xhr, 'readyState', { value: 4, configurable: true }); - Object.defineProperty(xhr, 'responseURL', { value: url, configurable: true }); - Object.defineProperty(xhr, 'response', { value: '', configurable: true }); - Object.defineProperty(xhr, 'responseText', { value: '', configurable: true }); - xhr.getAllResponseHeaders = () => ''; - - if (typeof xhr.onload === 'function') { - xhr.onload.call(xhr); - } - if (typeof xhr.onloadend === 'function') { - xhr.onloadend.call(xhr); - } - } catch (e) { - console.error('Failed to simulate XHR response:', e); - } - }, 0); - return; - } - - // Passive collection for media requests - const type = classifyUrl(url, method); - if (type === 'manifest' || type === 'segment') { - const reportingMode = detectReportingMode(url, headers); - if (reportingMode) { - self.requests.push({ - httpRequest: { url, method, headers }, - type, - reportingMode, - timestamp: Date.now(), - }); - self._notifyResolvers(type); - } - } - - return self._origSend.apply(this, arguments); - }; - } - - /** - * Remove XHR patches and stop collecting. - */ - detach() { - if (this._origOpen) { - XMLHttpRequest.prototype.open = this._origOpen; - } - if (this._origSetRequestHeader) { - XMLHttpRequest.prototype.setRequestHeader = this._origSetRequestHeader; - } - if (this._origSend) { - XMLHttpRequest.prototype.send = this._origSend; - } - this._origOpen = null; - this._origSetRequestHeader = null; - this._origSend = null; - } - - /** - * Get collected requests, optionally filtered by type. - * @param {'manifest'|'segment'|'event'|'unknown'} [type] - * @returns {Array} - */ - getRequests(type) { - if (!type) { - return this.requests; - } - return this.requests.filter((r) => r.type === type); - } - - /** - * Wait until at least `count` requests of the given type have been collected. - * @param {'manifest'|'segment'|'event'|'unknown'} type - * @param {number} count - * @param {number} [timeout=15000] - * @returns {Promise} - */ - waitForRequests(type, count, timeout = 15000) { - const current = this.getRequests(type); - if (current.length >= count) { - return Promise.resolve(current); - } - - return new Promise((resolve) => { - const timer = setTimeout(() => { - this._resolvers = this._resolvers.filter((r) => r !== entry); - resolve(this.getRequests(type)); - }, timeout); - - const entry = { - type, - count, - resolve: (result) => { - clearTimeout(timer); - resolve(result); - }, - }; - this._resolvers.push(entry); - }); - } - - clear() { - this.requests = []; - } - - _notifyResolvers(type) { - this._resolvers = this._resolvers.filter((r) => { - if (r.type !== type) { - return true; - } - const requests = this.getRequests(r.type); - if (requests.length >= r.count) { - r.resolve(requests); - return false; - } - return true; - }); - } -} - -export default CmcdRequestCollector; diff --git a/test/functional/test/feature-support/cmcd-v2.js b/test/functional/test/feature-support/cmcd-v2.js index b5e8b86b3d..633ed79e40 100644 --- a/test/functional/test/feature-support/cmcd-v2.js +++ b/test/functional/test/feature-support/cmcd-v2.js @@ -2,13 +2,11 @@ import Constants from '../../src/Constants.js'; import Utils from '../../src/Utils.js'; import { checkIsPlaying, - checkIsProgressing, checkNoCriticalErrors, initializeDashJsAdapter, } from '../common/common.js'; import { expect } from 'chai'; -import CmcdRequestCollector from '../../helpers/CmcdRequestCollector.js'; -import { validateCmcdRequest, validateCmcdEvents } from '@svta/cml-cmcd'; +import { CmcdReportRecorder, createXhrTransport, validateCmcdRequest, validateCmcdEvents } from '@svta/cml-cmcd'; const TESTCASE = Constants.TESTCASES.FEATURE_SUPPORT.CMCD_V2; @@ -42,11 +40,11 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { describe(`CMCD V2 Query Mode - ${item.name} - ${mpd}`, () => { let playerAdapter; - let collector; + let recorder; before(() => { - collector = new CmcdRequestCollector(); - collector.attach(); + recorder = new CmcdReportRecorder(); + recorder.attach({ waitTimeout: TIMEOUTS.REQUEST_COLLECTION, transports: [createXhrTransport()] }); const settings = { streaming: { cmcd: { ...DEFAULT_CMCD_V2_CONFIG, mode: 'query' }, @@ -56,7 +54,7 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); after(() => { - collector.detach(); + recorder.detach(); if (playerAdapter) { playerAdapter.destroy(); } @@ -66,17 +64,13 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { await checkIsPlaying(playerAdapter, true); }); - it('Checking progressing state', async () => { - await checkIsProgressing(playerAdapter); - }); - it('Manifest requests carry CMCD query params with v=2, ot, sid, cid', async () => { - await collector.waitForRequests('segment', 3, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForSegments({ count: 3, timeout: TIMEOUTS.REQUEST_COLLECTION }); - const manifests = collector.getRequests('manifest'); + const manifests = recorder.getReports().filter((r) => r.type === 'manifest'); expect(manifests.length).to.be.greaterThan(0); - const result = validateCmcdRequest(manifests[0].httpRequest, { version: 2 }); + const result = validateCmcdRequest(manifests[0].request, { version: 2 }); expect(result.valid, `CMCD validation failed:\n${formatIssues(result)}`).to.be.true; expect(result.data.v).to.equal(2); expect(result.data.ot).to.not.be.undefined; @@ -85,14 +79,14 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); it('Init segment requests carry CMCD query params with ot, sid, cid, v=2', async function () { - await collector.waitForRequests('segment', 3, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForSegments({ count: 3, timeout: TIMEOUTS.REQUEST_COLLECTION }); - const initSegments = collector.getRequests('segment').filter((r) => /_0\.(m4s|m4v|m4a|mp4)/i.test(r.httpRequest.url)); + const initSegments = recorder.getReports().filter((r) => r.type === 'segment').filter((r) => /_0\.(m4s|m4v|m4a|mp4)/i.test(r.request.url)); if (initSegments.length === 0) { this.skip(); } - const result = validateCmcdRequest(initSegments[0].httpRequest, { version: 2 }); + const result = validateCmcdRequest(initSegments[0].request, { version: 2 }); expect(result.valid, `CMCD validation failed:\n${formatIssues(result)}`).to.be.true; expect(result.data.ot).to.not.be.undefined; expect(result.data.sid).to.equal('test-session-id'); @@ -100,10 +94,10 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); it('sn increments across successive requests', async () => { - await collector.waitForRequests('segment', 3, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForSegments({ count: 3, timeout: TIMEOUTS.REQUEST_COLLECTION }); - const allRequests = collector.getRequests().filter((r) => r.reportingMode === 'query'); - const parsed = allRequests.map((r) => validateCmcdRequest(r.httpRequest, { version: 2 }).data); + const allRequests = recorder.getReports().filter((r) => r.reportingMode === 'query'); + const parsed = allRequests.map((r) => validateCmcdRequest(r.request, { version: 2 }).data); const withSn = parsed.filter((d) => d.sn !== undefined); expect(withSn.length).to.be.at.least(2); @@ -113,10 +107,10 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); it('sf is d (DASH)', async () => { - await collector.waitForRequests('segment', 3, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForSegments({ count: 3, timeout: TIMEOUTS.REQUEST_COLLECTION }); - const allRequests = collector.getRequests().filter((r) => r.reportingMode === 'query'); - const parsed = allRequests.map((r) => validateCmcdRequest(r.httpRequest, { version: 2 }).data); + const allRequests = recorder.getReports().filter((r) => r.reportingMode === 'query'); + const parsed = allRequests.map((r) => validateCmcdRequest(r.request, { version: 2 }).data); const withSf = parsed.filter((d) => d.sf !== undefined); expect(withSf.length).to.be.greaterThan(0); @@ -126,10 +120,10 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); it('st is v for VOD', async () => { - await collector.waitForRequests('segment', 3, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForSegments({ count: 3, timeout: TIMEOUTS.REQUEST_COLLECTION }); - const allRequests = collector.getRequests().filter((r) => r.reportingMode === 'query'); - const parsed = allRequests.map((r) => validateCmcdRequest(r.httpRequest, { version: 2 }).data); + const allRequests = recorder.getReports().filter((r) => r.reportingMode === 'query'); + const parsed = allRequests.map((r) => validateCmcdRequest(r.request, { version: 2 }).data); const withSt = parsed.filter((d) => d.st !== undefined); expect(withSt.length).to.be.greaterThan(0); @@ -139,14 +133,14 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); it('CMCD query payloads pass spec validation', async () => { - await collector.waitForRequests('segment', 3, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForSegments({ count: 3, timeout: TIMEOUTS.REQUEST_COLLECTION }); - const queryRequests = collector.getRequests().filter((r) => r.reportingMode === 'query'); + const queryRequests = recorder.getReports().filter((r) => r.reportingMode === 'query'); expect(queryRequests.length).to.be.greaterThan(0); for (const req of queryRequests) { - const result = validateCmcdRequest(req.httpRequest, { version: 2 }); - expect(result.valid, `CMCD validation failed for ${req.httpRequest.url}:\n${formatIssues(result)}`).to.be.true; + const result = validateCmcdRequest(req.request, { version: 2 }); + expect(result.valid, `CMCD validation failed for ${req.request.url}:\n${formatIssues(result)}`).to.be.true; } }); @@ -159,11 +153,11 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { describe(`CMCD V2 Header Mode - ${item.name} - ${mpd}`, () => { let playerAdapter; - let collector; + let recorder; before(() => { - collector = new CmcdRequestCollector(); - collector.attach(); + recorder = new CmcdReportRecorder(); + recorder.attach({ waitTimeout: TIMEOUTS.REQUEST_COLLECTION, transports: [createXhrTransport()] }); const settings = { streaming: { cmcd: { ...DEFAULT_CMCD_V2_CONFIG, mode: 'headers' }, @@ -173,7 +167,7 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); after(() => { - collector.detach(); + recorder.detach(); if (playerAdapter) { playerAdapter.destroy(); } @@ -184,47 +178,47 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); it('CMCD headers present on manifest requests with v=2', async () => { - await collector.waitForRequests('segment', 3, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForSegments({ count: 3, timeout: TIMEOUTS.REQUEST_COLLECTION }); - const manifests = collector.getRequests('manifest'); + const manifests = recorder.getReports().filter((r) => r.type === 'manifest'); expect(manifests.length).to.be.greaterThan(0); - const result = validateCmcdRequest(manifests[0].httpRequest, { version: 2 }); + const result = validateCmcdRequest(manifests[0].request, { version: 2 }); expect(result.valid, `CMCD header validation failed:\n${formatIssues(result)}`).to.be.true; expect(result.data.v).to.equal(2); }); it('CMCD headers present on init segment requests', async function () { - await collector.waitForRequests('segment', 3, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForSegments({ count: 3, timeout: TIMEOUTS.REQUEST_COLLECTION }); - const initSegments = collector.getRequests('segment').filter((r) => /_0\.(m4s|m4v|m4a|mp4)/i.test(r.httpRequest.url)); + const initSegments = recorder.getReports().filter((r) => r.type === 'segment').filter((r) => /_0\.(m4s|m4v|m4a|mp4)/i.test(r.request.url)); if (initSegments.length === 0) { this.skip(); } - const result = validateCmcdRequest(initSegments[0].httpRequest, { version: 2 }); + const result = validateCmcdRequest(initSegments[0].request, { version: 2 }); expect(result.valid, `CMCD header validation failed:\n${formatIssues(result)}`).to.be.true; expect(result.data.ot).to.not.be.undefined; expect(result.data.sid).to.equal('test-session-id'); }); it('Keys distributed across correct header shards (validateCmcdRequest)', async () => { - await collector.waitForRequests('segment', 3, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForSegments({ count: 3, timeout: TIMEOUTS.REQUEST_COLLECTION }); - const headerRequests = collector.getRequests().filter((r) => r.reportingMode === 'header'); + const headerRequests = recorder.getReports().filter((r) => r.reportingMode === 'header'); expect(headerRequests.length).to.be.greaterThan(0); for (const req of headerRequests) { - const result = validateCmcdRequest(req.httpRequest, { version: 2 }); - expect(result.valid, `CMCD header validation failed for ${req.httpRequest.url}:\n${formatIssues(result)}`).to.be.true; + const result = validateCmcdRequest(req.request, { version: 2 }); + expect(result.valid, `CMCD header validation failed for ${req.request.url}:\n${formatIssues(result)}`).to.be.true; } }); it('No CMCD= query params when in header mode', async () => { - await collector.waitForRequests('segment', 3, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForSegments({ count: 3, timeout: TIMEOUTS.REQUEST_COLLECTION }); - const headerReqs = collector.getRequests().filter((r) => r.reportingMode === 'header'); - const queryReqs = collector.getRequests().filter((r) => r.reportingMode === 'query'); + const headerReqs = recorder.getReports().filter((r) => r.reportingMode === 'header'); + const queryReqs = recorder.getReports().filter((r) => r.reportingMode === 'query'); expect(queryReqs.length).to.equal(0); expect(headerReqs.length).to.be.greaterThan(0); }); @@ -240,13 +234,13 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { describe('Response-received events (e=rr)', () => { let playerAdapter; - let collector; + let recorder; before(function () { this.timeout(60000); const targetUrl = `${EVENT_TARGET_BASE}/rr`; - collector = new CmcdRequestCollector(); - collector.attach({ eventTargetUrls: [targetUrl] }); + recorder = new CmcdReportRecorder(); + recorder.attach({ eventTargetUrls: [targetUrl], waitTimeout: TIMEOUTS.REQUEST_COLLECTION, transports: [createXhrTransport()] }); const settings = { streaming: { cmcd: { @@ -268,7 +262,7 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); after(() => { - collector.detach(); + recorder.detach(); if (playerAdapter) { playerAdapter.destroy(); } @@ -280,12 +274,12 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { it('POST to configured target with e=rr', async function () { this.timeout(30000); - await collector.waitForRequests('event', 2, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForEvents({ count: 2, timeout: TIMEOUTS.REQUEST_COLLECTION }); - const events = collector.getRequests('event'); + const events = recorder.getReports().filter((r) => r.type === 'event'); expect(events.length).to.be.at.least(1); - const allEvents = events.flatMap((r) => validateCmcdEvents(r.httpRequest.body, { version: 2 }).data || []); + const allEvents = events.flatMap((r) => validateCmcdEvents(r.request.body, { version: 2 }).data || []); const rrEvents = allEvents.filter((d) => d.e === 'rr'); expect(rrEvents.length).to.be.at.least(1); }); @@ -293,13 +287,13 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { describe('Play-state events (e=ps)', () => { let playerAdapter; - let collector; + let recorder; before(function () { this.timeout(60000); const targetUrl = `${EVENT_TARGET_BASE}/ps`; - collector = new CmcdRequestCollector(); - collector.attach({ eventTargetUrls: [targetUrl] }); + recorder = new CmcdReportRecorder(); + recorder.attach({ eventTargetUrls: [targetUrl], waitTimeout: TIMEOUTS.REQUEST_COLLECTION, transports: [createXhrTransport()] }); const settings = { streaming: { cmcd: { @@ -320,7 +314,7 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); after(() => { - collector.detach(); + recorder.detach(); if (playerAdapter) { playerAdapter.destroy(); } @@ -332,12 +326,12 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { it('POST on state changes with sta defined', async function () { this.timeout(30000); - await collector.waitForRequests('event', 1, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForEvents({ count: 1, timeout: TIMEOUTS.REQUEST_COLLECTION }); - const events = collector.getRequests('event'); + const events = recorder.getReports().filter((r) => r.type === 'event'); expect(events.length).to.be.at.least(1); - const results = events.map((r) => validateCmcdEvents(r.httpRequest.body, { version: 2 })); + const results = events.map((r) => validateCmcdEvents(r.request.body, { version: 2 })); for (const result of results) { expect(result.valid, `Play-state event validation failed:\n${formatIssues(result)}`).to.be.true; @@ -354,13 +348,13 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { describe('Time interval events', () => { let playerAdapter; - let collector; + let recorder; before(function () { this.timeout(90000); const targetUrl = `${EVENT_TARGET_BASE}/ti`; - collector = new CmcdRequestCollector(); - collector.attach({ eventTargetUrls: [targetUrl] }); + recorder = new CmcdReportRecorder(); + recorder.attach({ eventTargetUrls: [targetUrl], waitTimeout: TIMEOUTS.REQUEST_COLLECTION, transports: [createXhrTransport()] }); const settings = { streaming: { cmcd: { @@ -382,7 +376,7 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); after(() => { - collector.detach(); + recorder.detach(); if (playerAdapter) { playerAdapter.destroy(); } @@ -396,10 +390,10 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { this.timeout(60000); // Wait long enough for at least 2 time interval events (3s interval) await playerAdapter.sleep(8000); - await collector.waitForRequests('event', 2, 20000); + await recorder.waitForEvents({ count: 2, timeout: 20000 }); - const allEvents = collector.getRequests('event') - .flatMap((r) => validateCmcdEvents(r.httpRequest.body, { version: 2 }).data || []); + const allEvents = recorder.getReports().filter((r) => r.type === 'event') + .flatMap((r) => validateCmcdEvents(r.request.body, { version: 2 }).data || []); const tiEvents = allEvents.filter((d) => d.e === 't'); expect(tiEvents.length).to.be.at.least(2); }); @@ -407,13 +401,13 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { describe('POST format and validation', () => { let playerAdapter; - let collector; + let recorder; before(function () { this.timeout(60000); const targetUrl = `${EVENT_TARGET_BASE}/all`; - collector = new CmcdRequestCollector(); - collector.attach({ eventTargetUrls: [targetUrl] }); + recorder = new CmcdReportRecorder(); + recorder.attach({ eventTargetUrls: [targetUrl], waitTimeout: TIMEOUTS.REQUEST_COLLECTION, transports: [createXhrTransport()] }); const settings = { streaming: { cmcd: { @@ -435,7 +429,7 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); after(() => { - collector.detach(); + recorder.detach(); if (playerAdapter) { playerAdapter.destroy(); } @@ -447,36 +441,36 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { it('POST content type is application/cmcd', async function () { this.timeout(30000); - await collector.waitForRequests('event', 1, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForEvents({ count: 1, timeout: TIMEOUTS.REQUEST_COLLECTION }); - const events = collector.getRequests('event'); + const events = recorder.getReports().filter((r) => r.type === 'event'); expect(events.length).to.be.at.least(1); for (const evt of events) { - const contentType = evt.httpRequest.headers['content-type'] || ''; + const contentType = evt.request.headers['content-type'] || ''; expect(contentType).to.include('application/cmcd'); } }); it('POST bodies pass CMCD event spec validation', async function () { this.timeout(30000); - await collector.waitForRequests('event', 1, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForEvents({ count: 1, timeout: TIMEOUTS.REQUEST_COLLECTION }); - const events = collector.getRequests('event'); + const events = recorder.getReports().filter((r) => r.type === 'event'); expect(events.length).to.be.at.least(1); for (const evt of events) { - const result = validateCmcdEvents(evt.httpRequest.body, { version: 2 }); - expect(result.valid, `CMCD event validation failed for POST to ${evt.httpRequest.url}:\n${formatIssues(result)}`).to.be.true; + const result = validateCmcdEvents(evt.request.body, { version: 2 }); + expect(result.valid, `CMCD event validation failed for POST to ${evt.request.url}:\n${formatIssues(result)}`).to.be.true; } }); it('sn increments across event POSTs', async function () { this.timeout(30000); - await collector.waitForRequests('event', 3, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForEvents({ count: 3, timeout: TIMEOUTS.REQUEST_COLLECTION }); - const withSn = collector.getRequests('event') - .flatMap((r) => validateCmcdEvents(r.httpRequest.body, { version: 2 }).data || []) + const withSn = recorder.getReports().filter((r) => r.type === 'event') + .flatMap((r) => validateCmcdEvents(r.request.body, { version: 2 }).data || []) .filter((d) => d.sn !== undefined); expect(withSn.length).to.be.at.least(2); @@ -493,12 +487,12 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { describe('Only configured enabledKeys appear', () => { let playerAdapter; - let collector; + let recorder; const enabledKeys = ['ot', 'sid', 'v', 'sn']; before(() => { - collector = new CmcdRequestCollector(); - collector.attach(); + recorder = new CmcdReportRecorder(); + recorder.attach({ waitTimeout: TIMEOUTS.REQUEST_COLLECTION, transports: [createXhrTransport()] }); const settings = { streaming: { cmcd: { @@ -512,7 +506,7 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); after(() => { - collector.detach(); + recorder.detach(); if (playerAdapter) { playerAdapter.destroy(); } @@ -523,13 +517,13 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); it('Only configured keys appear in query mode', async () => { - await collector.waitForRequests('segment', 3, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForSegments({ count: 3, timeout: TIMEOUTS.REQUEST_COLLECTION }); - const queryRequests = collector.getRequests().filter((r) => r.reportingMode === 'query'); + const queryRequests = recorder.getReports().filter((r) => r.reportingMode === 'query'); expect(queryRequests.length).to.be.greaterThan(0); for (const req of queryRequests) { - const result = validateCmcdRequest(req.httpRequest, { version: 2 }); + const result = validateCmcdRequest(req.request, { version: 2 }); const keys = Object.keys(result.data); for (const key of keys) { expect( @@ -543,11 +537,11 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { describe('Empty enabledKeys produces no CMCD data', () => { let playerAdapter; - let collector; + let recorder; before(() => { - collector = new CmcdRequestCollector(); - collector.attach(); + recorder = new CmcdReportRecorder(); + recorder.attach({ waitTimeout: TIMEOUTS.REQUEST_COLLECTION, transports: [createXhrTransport()] }); const settings = { streaming: { cmcd: { @@ -561,7 +555,7 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); after(() => { - collector.detach(); + recorder.detach(); if (playerAdapter) { playerAdapter.destroy(); } @@ -573,18 +567,18 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { it('No CMCD data appended to requests', async () => { await playerAdapter.sleep(5000); - const queryRequests = collector.getRequests().filter((r) => r.reportingMode === 'query'); + const queryRequests = recorder.getReports().filter((r) => r.reportingMode === 'query'); expect(queryRequests.length).to.equal(0); }); }); describe('All default keys appear when no filter is set', () => { let playerAdapter; - let collector; + let recorder; before(() => { - collector = new CmcdRequestCollector(); - collector.attach(); + recorder = new CmcdReportRecorder(); + recorder.attach({ waitTimeout: TIMEOUTS.REQUEST_COLLECTION, transports: [createXhrTransport()] }); const settings = { streaming: { cmcd: { @@ -597,7 +591,7 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); after(() => { - collector.detach(); + recorder.detach(); if (playerAdapter) { playerAdapter.destroy(); } @@ -608,12 +602,12 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); it('Core keys appear across requests', async () => { - await collector.waitForRequests('segment', 3, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForSegments({ count: 3, timeout: TIMEOUTS.REQUEST_COLLECTION }); const allSeenKeys = new Set(); - const queryRequests = collector.getRequests().filter((r) => r.reportingMode === 'query'); + const queryRequests = recorder.getReports().filter((r) => r.reportingMode === 'query'); for (const req of queryRequests) { - const result = validateCmcdRequest(req.httpRequest, { version: 2 }); + const result = validateCmcdRequest(req.request, { version: 2 }); for (const key of Object.keys(result.data)) { allSeenKeys.add(key); } @@ -637,11 +631,11 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { describe('v=2 in query mode', () => { let playerAdapter; - let collector; + let recorder; before(() => { - collector = new CmcdRequestCollector(); - collector.attach(); + recorder = new CmcdReportRecorder(); + recorder.attach({ waitTimeout: TIMEOUTS.REQUEST_COLLECTION, transports: [createXhrTransport()] }); const settings = { streaming: { cmcd: { ...DEFAULT_CMCD_V2_CONFIG, mode: 'query' }, @@ -651,7 +645,7 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); after(() => { - collector.detach(); + recorder.detach(); if (playerAdapter) { playerAdapter.destroy(); } @@ -662,15 +656,15 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); it('v=2 present in query mode payloads', async () => { - await collector.waitForRequests('segment', 3, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForSegments({ count: 3, timeout: TIMEOUTS.REQUEST_COLLECTION }); - const queryRequests = collector.getRequests().filter((r) => r.reportingMode === 'query'); + const queryRequests = recorder.getReports().filter((r) => r.reportingMode === 'query'); expect(queryRequests.length).to.be.greaterThan(0); - const manifests = collector.getRequests('manifest'); + const manifests = recorder.getReports().filter((r) => r.type === 'manifest'); expect(manifests.length).to.be.greaterThan(0); - const result = validateCmcdRequest(manifests[0].httpRequest, { version: 2 }); + const result = validateCmcdRequest(manifests[0].request, { version: 2 }); expect(result.valid, `CMCD v2 query validation failed:\n${formatIssues(result)}`).to.be.true; expect(result.data.v).to.equal(2); }); @@ -678,11 +672,11 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { describe('v=2 in header mode', () => { let playerAdapter; - let collector; + let recorder; before(() => { - collector = new CmcdRequestCollector(); - collector.attach(); + recorder = new CmcdReportRecorder(); + recorder.attach({ waitTimeout: TIMEOUTS.REQUEST_COLLECTION, transports: [createXhrTransport()] }); const settings = { streaming: { cmcd: { ...DEFAULT_CMCD_V2_CONFIG, mode: 'headers' }, @@ -692,7 +686,7 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); after(() => { - collector.detach(); + recorder.detach(); if (playerAdapter) { playerAdapter.destroy(); } @@ -703,15 +697,15 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); it('v=2 present in header mode payloads', async () => { - await collector.waitForRequests('segment', 3, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForSegments({ count: 3, timeout: TIMEOUTS.REQUEST_COLLECTION }); - const headerRequests = collector.getRequests().filter((r) => r.reportingMode === 'header'); + const headerRequests = recorder.getReports().filter((r) => r.reportingMode === 'header'); expect(headerRequests.length).to.be.greaterThan(0); - const manifests = collector.getRequests('manifest'); + const manifests = recorder.getReports().filter((r) => r.type === 'manifest'); expect(manifests.length).to.be.greaterThan(0); - const result = validateCmcdRequest(manifests[0].httpRequest, { version: 2 }); + const result = validateCmcdRequest(manifests[0].request, { version: 2 }); expect(result.valid, `CMCD v2 header validation failed:\n${formatIssues(result)}`).to.be.true; expect(result.data.v).to.equal(2); }); @@ -719,11 +713,11 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { describe('v1 regression', () => { let playerAdapter; - let collector; + let recorder; before(() => { - collector = new CmcdRequestCollector(); - collector.attach(); + recorder = new CmcdReportRecorder(); + recorder.attach({ waitTimeout: TIMEOUTS.REQUEST_COLLECTION, transports: [createXhrTransport()] }); const settings = { streaming: { cmcd: { @@ -743,7 +737,7 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); after(() => { - collector.detach(); + recorder.detach(); if (playerAdapter) { playerAdapter.destroy(); } @@ -754,13 +748,13 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); it('v absent or 1 in v1 mode', async () => { - await collector.waitForRequests('segment', 1, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForSegments({ count: 1, timeout: TIMEOUTS.REQUEST_COLLECTION }); - const queryRequests = collector.getRequests().filter((r) => r.reportingMode === 'query'); + const queryRequests = recorder.getReports().filter((r) => r.reportingMode === 'query'); expect(queryRequests.length).to.be.greaterThan(0); for (const req of queryRequests) { - const result = validateCmcdRequest(req.httpRequest, { version: 1 }); + const result = validateCmcdRequest(req.request, { version: 1 }); expect( result.data.v === undefined || result.data.v === 1, `Expected v to be undefined or 1, got ${result.data.v}` diff --git a/test/functional/test/feature-support/cmcd.js b/test/functional/test/feature-support/cmcd.js index f5816ac67c..fdf454b1f7 100644 --- a/test/functional/test/feature-support/cmcd.js +++ b/test/functional/test/feature-support/cmcd.js @@ -3,7 +3,6 @@ import Utils from '../../src/Utils.js'; import { checkIsPlaying, - checkIsProgressing, checkNoCriticalErrors, initializeDashJsAdapter } from '../common/common.js'; @@ -41,10 +40,6 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { await checkIsPlaying(playerAdapter, true); }) - it(`Checking progressing state`, async () => { - await checkIsProgressing(playerAdapter); - }); - it(`Expect CMCD event to be thrown`, async () => { await playerAdapter.waitForEvent(Constants.TEST_TIMEOUT_THRESHOLDS.EVENT_WAITING_TIME, dashjs.MetricsReporting.events.CMCD_DATA_GENERATED) }); diff --git a/test/unit/test/streaming/streaming.controllers.CmcdController.js b/test/unit/test/streaming/streaming.controllers.CmcdController.js index 942bfe9c5b..c1a41e5251 100644 --- a/test/unit/test/streaming/streaming.controllers.CmcdController.js +++ b/test/unit/test/streaming/streaming.controllers.CmcdController.js @@ -163,7 +163,7 @@ describe('CmcdController', function () { eventTargets: [{ url: 'https://cmcd.event.collector/api', enabled: true, - enabledKeys: ['e'], + enabledKeys: ['e', 'ec'], events: ['e'], interval: 0 }] @@ -193,6 +193,7 @@ describe('CmcdController', function () { const metrics = decodeCmcd(decodeURIComponent(requestSent.body)); expect(metrics).to.have.property('e', 'e'); + expect(metrics.ec).to.deep.equal(['123']); }); it('should not send a report when the ERROR event is triggered by a CMCD_EVENT', () => { @@ -203,7 +204,7 @@ describe('CmcdController', function () { eventTargets: [{ url: 'https://cmcd.event.collector/api', enabled: true, - enabledKeys: ['e'], + enabledKeys: ['e', 'ec'], events: ['e'], interval: 0 }] @@ -477,6 +478,84 @@ describe('CmcdController', function () { }); }); + describe('Event Mode playback rate events', () => { + let urlLoaderMock; + + beforeEach(() => { + urlLoaderMock = { + load: sinon.spy() + }; + cmcdController.reset(); + settings.update({ + streaming: { + cmcd: { + version: 2, + eventTargets: [{ + url: 'https://cmcd.event.collector/api', + enabled: true, + enabledKeys: ['e', 'pr', 'ts'], + events: ['pr'], + interval: 0 + }] + } + } + }); + cmcdController.setConfig({ + abrController: abrControllerMock, + dashMetrics: dashMetricsMock, + playbackController: playbackControllerMock, + throughputController: throughputControllerMock, + serviceDescriptionController: serviceDescriptionControllerMock, + urlLoader: urlLoaderMock + }); + cmcdController.initialize(); + }); + + it('should send e=pr with pr value when playback rate changes', function () { + eventBus.trigger(MediaPlayerEvents.PLAYBACK_RATE_CHANGED, { playbackRate: 2.4 }); + expect(urlLoaderMock.load.calledOnce).to.be.true; + const requestSent = urlLoaderMock.load.firstCall.args[0].request; + const metrics = decodeCmcd(decodeURIComponent(requestSent.body)); + expect(metrics).to.have.property('e', 'pr'); + expect(metrics).to.have.property('pr', 2.4); + }); + + it('should not send pr event when pr is not in target events', function () { + cmcdController.reset(); + settings.update({ + streaming: { + cmcd: { + version: 2, + eventTargets: [{ + url: 'https://cmcd.event.collector/api', + enabled: true, + enabledKeys: ['e', 'pr'], + events: ['ps'], + interval: 0 + }] + } + } + }); + cmcdController.setConfig({ + abrController: abrControllerMock, + dashMetrics: dashMetricsMock, + playbackController: playbackControllerMock, + throughputController: throughputControllerMock, + serviceDescriptionController: serviceDescriptionControllerMock, + urlLoader: urlLoaderMock + }); + cmcdController.initialize(); + eventBus.trigger(MediaPlayerEvents.PLAYBACK_RATE_CHANGED, { playbackRate: 2.4 }); + expect(urlLoaderMock.load.called).to.be.false; + }); + + it('should dedupe pr event when playback rate unchanged', function () { + eventBus.trigger(MediaPlayerEvents.PLAYBACK_RATE_CHANGED, { playbackRate: 1 }); + eventBus.trigger(MediaPlayerEvents.PLAYBACK_RATE_CHANGED, { playbackRate: 1 }); + expect(urlLoaderMock.load.calledOnce).to.be.true; + }); + }); + describe('Event Mode - time interval', () => { let urlLoaderMock; let clock;