From e867456cb12cdc6c61b1395ee593056efdd839d2 Mon Sep 17 00:00:00 2001 From: amtins Date: Mon, 9 Sep 2024 19:04:23 +0200 Subject: [PATCH] feat: add playback monitoring Resolves #262, by allowing to track media playback sessions. With to the data sent, it will be possible to have a better overview of the quality of our service. Finally, it will help us to investigate potential causes of playback problems, so that we can better help our users or provide them with a more accurate response. - rename `analytics` folder to `trackers` - add `PillarboxMonitoring` class - modify `srgssr` middleware to add support for new tracker - add unit test - extract `srg-analytics` player mock to make it reusable - rename `test/analytics` folder to `test/trackers` --- src/middleware/srgssr.js | 34 +- src/trackers/PillarboxMonitoring.js | 898 ++++++++++++++++++++ src/{analytics => trackers}/SRGAnalytics.js | 0 test/__mocks__/player-mock.js | 81 ++ test/analytics/pillarbox-monitoring.spec.js | 728 ++++++++++++++++ test/analytics/srg-analytics.spec.js | 58 +- 6 files changed, 1741 insertions(+), 58 deletions(-) create mode 100644 src/trackers/PillarboxMonitoring.js rename src/{analytics => trackers}/SRGAnalytics.js (100%) create mode 100644 test/__mocks__/player-mock.js create mode 100644 test/analytics/pillarbox-monitoring.spec.js diff --git a/src/middleware/srgssr.js b/src/middleware/srgssr.js index 0666796..321a001 100644 --- a/src/middleware/srgssr.js +++ b/src/middleware/srgssr.js @@ -3,7 +3,8 @@ import DataProvider from '../dataProvider/services/DataProvider.js'; import Image from '../utils/Image.js'; import Drm from '../utils/Drm.js'; import AkamaiTokenService from '../utils/AkamaiTokenService.js'; -import SRGAnalytics from '../analytics/SRGAnalytics.js'; +import SRGAnalytics from '../trackers/SRGAnalytics.js'; +import PillarboxMonitoring from '../trackers/PillarboxMonitoring.js'; import MediaComposition from '../dataProvider/model/MediaComposition.js'; // Translations @@ -473,6 +474,8 @@ class SrgSsr { * @returns {Promise} - The composed source media data. */ static async getSrcMediaObj(player, srcObj) { + player.trigger('pillarbox-monitoring/sessionstart'); + const { src: urn, ...srcOptions } = srcObj; const mediaComposition = await SrgSsr.getMediaComposition( urn, @@ -599,6 +602,33 @@ class SrgSsr { } } + /** + * SRG monitoring singleton. + * + * @param {import('video.js/dist/types/player').default} player + * + * @returns {PillarboxMonitoring} instance of PillarboxMonitoring + */ + static srgMonitoring(player) { + if (player.options().trackers.srgMonitoring === false) return; + + if (!player.options().trackers.srgMonitoring) { + const srgMonitoring = new PillarboxMonitoring(player, { + debug: player.debug(), + playerVersion: Pillarbox.VERSION.pillarbox, + playerName: 'Pillarbox', + }); + + player.options({ + trackers: { + srgMonitoring, + }, + }); + } + + return player.options().trackers.srgMonitoring; + } + /** * Update player's poster. * @@ -637,7 +667,7 @@ class SrgSsr { * @returns {Object} */ static middleware(player) { - + SrgSsr.srgMonitoring(player); SrgSsr.cuechangeEventProxy(player); return { diff --git a/src/trackers/PillarboxMonitoring.js b/src/trackers/PillarboxMonitoring.js new file mode 100644 index 0000000..4293c7f --- /dev/null +++ b/src/trackers/PillarboxMonitoring.js @@ -0,0 +1,898 @@ +import pillarbox from '../pillarbox.js'; + +/* eslint max-statements: ["error", 25]*/ + +/** + * The PillarboxMonitoring class retrieves data about media playback. + * + * This data can be used to : + * - help investigate playback problems + * - measure the quality of our service + * + * The sending of this data tries to respect as much as possible the + * specification described in the link below. + * + * However, some platforms may have certain limitations. + * In this case, only the data available will be sent. + * + * @see https://github.com/SRGSSR/pillarbox-documentation/blob/main/Specifications/monitoring.md + */ +class PillarboxMonitoring { + constructor(player, { + playerName = 'none', + playerVersion = 'none', + platform = 'web', + schemaVersion = 1, + heartbeatInterval = 30_000, + beaconUrl = 'https://zdkimhgwhh.eu-central-1.awsapprunner.com/metrics' + } = {}) { + /** + * @type {import('video.js/dist/types/player').default} + */ + this.player = player; + /** + * @type {string} + */ + this.playerName = playerName; + /** + * @type {string} + */ + this.playerVersion = playerVersion; + /** + * @type {string} + */ + this.platform = platform; + /** + * @type {string} + */ + this.schemaVersion = schemaVersion; + /** + * @type {Number} + */ + this.heartbeatInterval = heartbeatInterval; + /** + * @type {string} + */ + this.beaconUrl = beaconUrl; + /** + * @type {string} + */ + this.currentSessionId = undefined; + /** + * @type {Number} + */ + this.lastPlaybackDuration = 0; + /** + * @type {Number} + */ + this.lastPlaybackStartTimestamp = 0; + /** + * @type {Number} + */ + this.lastStallCount = 0; + /** + * @type {Number} + */ + this.lastStallDuration = 0; + /** + * @type {Number} + */ + this.loadStartTimestamp = undefined; + /** + * @type {Number} + */ + this.metadataRequestTime = 0; + /** + * @type {string} + */ + this.mediaAssetUrl = undefined; + /** + * @type {string} + */ + this.mediaId = undefined; + /** + * @type {string} + */ + this.mediaMetadataUrl = undefined; + /** + * @type {string} + */ + this.mediaOrigin = undefined; + /** + * @type {Number} + */ + this.tokenRequestTime = 0; + + this.addListeners(); + } + + /** + * Adds event listeners to the player and the window. + */ + addListeners() { + this.bindCallBacks(); + + this.player.on('loadstart', this.loadStart); + this.player.on('loadeddata', this.loadedData); + this.player.on('pillarbox-monitoring/sessionstart', this.sessionStart); + this.player.on('playing', this.playbackStart); + this.player.on('pause', this.playbackStop); + this.player.on('error', this.error); + this.player.on(['playerreset', 'dispose', 'ended'], this.sessionStop); + this.player.on(['waiting', 'stalled'], this.stalled); + + window.addEventListener('beforeunload', this.sessionStop); + } + + /** + * The current bandwidth of the last segment download. + * + * @returns {number|undefined} The current bandwidth in bits per second, + * undefined otherwise. + */ + bandwidth() { + const playerStats = this.player + .tech(true).vhs ? this.player.tech(true).vhs.stats : undefined; + + return playerStats ? playerStats.bandwidth : undefined; + } + + /** + * Binds the callback functions to the current instance. + */ + bindCallBacks() { + + this.error = this.error.bind(this); + this.loadedData = this.loadedData.bind(this); + this.loadStart = this.loadStart.bind(this); + this.playbackStart = this.playbackStart.bind(this); + this.playbackStop = this.playbackStop.bind(this); + this.sessionStart = this.sessionStart.bind(this); + this.stalled = this.stalled.bind(this); + this.sessionStop = this.sessionStop.bind(this); + } + + /** + * Get the buffer duration in milliseconds. + * + * @returns {Number} The buffer duration + */ + bufferDuration() { + const buffered = this.player.buffered(); + let bufferDuration = 0; + + for (let i = 0; i < buffered.length; i++) { + const start = buffered.start(i); + const end = buffered.end(i); + + bufferDuration += end - start; + } + + return PillarboxMonitoring.secondsToMilliseconds(bufferDuration); + } + + /** + * Get the current representation when playing a Dash or Hls media. + * + * @typedef {Object} Representation + * @property {number|undefined} bandwidth The bandwidth of the current + * representation + * @property {number|undefined} programDateTime The program date time of the + * current representation + * @property {string|undefined} uri The URL of the current representation + * + * @returns {Representation|undefined} The current representation object + * undefined otherwise + */ + currentRepresentation() { + const { + activeCues: { cues_: [cue] } = { cues_: [] } + } = Array.from(this.player.textTracks()) + .find(({ label, kind }) => kind === 'metadata' && label === 'segment-metadata') || {}; + + return cue ? cue.value : undefined; + } + + /** + * Get the current resource information including bitrate and URL when available. + * + * @typedef {Object} Resource + * @property {number|undefined} bitrate The bitrate of the current resource + * @property {string|undefined} url The URL of the current resource + * + * @returns {Resource} The current resource information. + */ + currentResource() { + let { bandwidth: bitrate, uri: url } = this.currentRepresentation() || {}; + + if (pillarbox.browser.IS_ANY_SAFARI) { + const { configuration } = Array + .from(this.player.videoTracks()).find(track => track.selected) || {}; + + bitrate = configuration ? configuration.bitrate : undefined; + url = this.player.currentSource().src; + } + + return { + bitrate, + url + }; + } + + /** + * The media data of the current source. + * + * @returns {Object} The media data of the current source, or an empty object + * if no media data is available. + */ + currentSourceMediaData() { + if (!this.player.currentSource().mediaData) return {}; + + return this.player.currentSource().mediaData; + } + + /** + * Handles player errors by sending an `ERROR` event, then resets the session. + */ + error() { + const error = this.player.error(); + const playbackPosition = this.playbackPosition(); + const representation = this.currentRepresentation(); + const url = representation ? + representation.uri : this.player.currentSource().src; + + if (!this.player.hasStarted()) { + this.sendEvent('START', this.startEventData()); + } + + this.sendEvent('ERROR', { + log: error.metadata || JSON + .stringify(pillarbox.log.history().slice(-15)), + message: error.message, + name: error.code, + ...playbackPosition, + severity: 'FATAL', + url + }); + + this.reset(); + } + + /** + * Get the DRM license request duration from performance API. + * + * @returns {number|undefined} The request duration + */ + getDrmRequestDuration() { + const keySystems = Object + .values(this.player.currentSource().keySystems || {}) + .map(keySystem => keySystem.url); + + if (!keySystems.length) return; + + const resource = performance + .getEntriesByType('resource') + .filter(({ initiatorType, name }) => + initiatorType === 'xmlhttprequest' && keySystems.includes(name)) + .pop(); + + return resource && resource.duration; + } + + /** + * Get metadata information from the performance API for a given id. + * + * @typedef {Object} MetadataInfo + * @property {string} name The URL of the resource + * @property {number} duration The duration of the resource fetch in milliseconds + * + * @param {string} id The id to search for in the resource entries + * + * @returns {MetadataInfo|undefined} An object containing metadata + * information, or undefined otherwise + */ + getMetadataInfo(id) { + const resource = performance + .getEntriesByType('resource') + .filter(({ initiatorType, name }) => + initiatorType === 'fetch' && name.includes(id)) + .pop(); + + if (!resource) return {}; + + return { + name: resource.name, + duration: resource.duration + }; + } + + /** + * Get the Akamai token request duration from performance API. + * + * @returns {number|undefined} The request duration + */ + getTokenRequestDuration(tokenType) { + if (!tokenType) return; + + const resource = performance + .getEntriesByType('resource') + .filter(({ initiatorType, name }) => + initiatorType === 'fetch' && name.includes('/akahd/token')) + .pop(); + + return resource && resource.duration; + } + + /** + * Send an 'HEARTBEAT' event with the date of the current playback state at + * regular intervals. + */ + heartbeat() { + this.heartbeatIntervalId = setInterval(() => { + this.sendEvent('HEARTBEAT', this.statusEventData()); + }, this.heartbeatInterval); + } + + /** + * Check if the tracker is disabled. + * + * @returns {Boolean} __true__ if disabled __false__ otherwise. + */ + isTrackerDisabled() { + const currentSource = this.player.currentSource(); + + if (!Array.isArray(currentSource.disableTrackers)) { + return Boolean(currentSource.disableTrackers); + } + + return Boolean( + currentSource.disableTrackers.find( + (tracker) => tracker.toLowerCase() === PillarboxMonitoring + .name.toLowerCase() + ) + ); + } + + /** + * Handles the session start by sending a `START` event immediately followed + * by a `HEARTBEAT` when the `loadeddata` event is triggered. + */ + loadedData() { + this.sendEvent('START', this.startEventData()); + this.sendEvent('HEARTBEAT', this.statusEventData()); + // starts the heartbeat interval + this.heartbeat(); + } + + /** + * Handles `loadstart` event and captures the current timestamp. Will be used + * to calculate the media loading time. + */ + loadStart() { + // if the content is a plain old URL + if ( + !Object.keys(this.currentSourceMediaData()).length && + this.currentSessionId + ) { + this.sessionStop(); + // Reference timestamp used to calculate the different time metrics. + this.sessionStartTimestamp = PillarboxMonitoring.timestamp(); + } + + this.loadStartTimestamp = PillarboxMonitoring.timestamp(); + } + + /** + * The media information. + * + * @typedef {Object} MediaInfo + * @property {string} asset_url The URL of the media + * @property {string} id The ID of the media + * @property {string} metadata_url The URL of the media metadata + * @property {string} origin The origin of the media + * + * @returns {MediaInfo} An object container the media information + */ + mediaInfo() { + return { + asset_url: this.mediaAssetUrl, + id: this.mediaId, + metadata_url: this.mediaMetadataUrl, + origin: this.mediaOrigin, + }; + } + + /** + * The total playback duration for the current session. + * + * @returns {number} The total playback duration in milliseconds. + */ + playbackDuration() { + if (!this.lastPlaybackStartTimestamp) { + return this.lastPlaybackDuration; + } + + return ( + PillarboxMonitoring.timestamp() + + this.lastPlaybackDuration - + this.lastPlaybackStartTimestamp + ); + } + + /** + * The current playback position and position timestamp. + * + * @typedef {Object} PlaybackPosition + * @property {number} position The current playback position in milliseconds + * @property {number|undefined} position_timestamp The timestamp of the + * current playback position, or undefined if not available + * + * @returns {PlaybackPosition} The playback position object. + */ + playbackPosition() { + const currentRepresentation = this.currentRepresentation(); + const position = PillarboxMonitoring + .secondsToMilliseconds(this.player.currentTime()); + let position_timestamp; + + // Get the position timestamp from the program date time when VHS is used + // or undefined if there is no value + if (currentRepresentation) { + position_timestamp = currentRepresentation.programDateTime; + } + + // Calculate the position timestamp from the start date on Safari + if (pillarbox.browser.IS_ANY_SAFARI) { + const startDate = Date.parse(this.player.$('video').getStartDate()); + + position_timestamp = !isNaN(startDate) ? + (startDate + position) : undefined; + } + + return { + position, + position_timestamp + }; + } + + /** + * Assign the timestamp each time the playback starts. + */ + playbackStart() { + this.lastPlaybackStartTimestamp = PillarboxMonitoring.timestamp(); + } + + /** + * Calculates and accumulates the duration of the playback session each time + * the playback stops for the current media. + */ + playbackStop() { + this.lastPlaybackDuration += + PillarboxMonitoring.timestamp() - this.lastPlaybackStartTimestamp; + + this.lastPlaybackStartTimestamp = undefined; + } + + /** + * The current dimensions of the player. + * + * @typedef {Object} PlayerCurrentDimensions + * @property {number} width The current width of the player + * @property {number} height The current height of the player + * + * @returns {PlayerCurrentDimensions} The current dimensions of the player object. + */ + playerCurrentDimensions() { + return this.player.currentDimensions(); + } + + /** + * Information about the player. + * + * @typedef {Object} PlayerInfo + * @property {string} name The name of the player + * @property {string} version The version of the player + * @property {string} platform The platform on which the player is running + * + * @returns {PlayerInfo} An object containing player information. + */ + playerInfo() { + return { + name: this.playerName, + version: this.playerVersion, + platform: this.platform + }; + } + + /** + * Generates the QoE timings object. + * + * @typedef {Object} QoeTimings + * @property {number} metadata The time taken to load metadata + * @property {number} asset The time taken to load the asset + * @property {number} total The total time taken from session start to data load + * + * @param {number} timeToLoadedData The time taken to load the data + * @param {number} timestamp The current timestamp + * + * @returns {QoeTimings} The QoE timings + */ + qoeTimings(timeToLoadedData, timestamp) { + return { + metadata: this.metadataRequestTime, + asset: timeToLoadedData, + total: timestamp - this.sessionStartTimestamp + }; + } + + /** + * Generates the QoS timings object. + * + * @typedef {Object} QosTimings + * @property {number} asset The time taken to load the asset + * @property {number} drm The time taken for DRM processing + * @property {number} metadata The time taken to load metadata + * @property {number} token The time taken to request the token + * + * @param {number} timeToLoadedData The time taken to load the data + * + * @returns {QosTimings} The QoS timings + */ + qosTimings(timeToLoadedData) { + return { + asset: timeToLoadedData, + drm: this.getDrmRequestDuration(), + metadata: this.metadataRequestTime, + token: this.tokenRequestTime, + }; + } + + /** + * Removes all event listeners from the player and the window. + */ + removeListeners() { + this.player.off('loadstart', this.loadStart); + this.player.off('loadeddata', this.loadedData); + this.player.off('pillarbox-monitoring/sessionstart', this.sessionStart); + this.player.off('playing', this.playbackStart); + this.player.off('pause', this.playbackStop); + this.player.off('error', this.error); + this.player.off(['playerreset', 'dispose', 'ended'], this.sessionStop); + this.player.off(['waiting', 'stalled'], this.stalled); + + window.removeEventListener('beforeunload', this.sessionStop); + } + + /** + * Remove the token from the asset URL. + * + * @param {string} assetUrl The URL of the asset + * + * @returns {string|undefined} The URL without the token, or undefined if the + * input URL is invalid + */ + removeTokenFromAssetUrl(assetUrl) { + if (!assetUrl) return; + + try { + const url = new URL(assetUrl); + + url.searchParams.delete('hdnts'); + + return url.href; + } catch (e) { + return; + } + } + + /** + * Resets the playback session and clears relevant properties. + * + * @param {Event} event The event that triggered the reset. If the event type + * is not 'ended' or 'playerreset', listeners will be removed. + */ + reset(event) { + this.currentSessionId = undefined; + this.lastPlaybackDuration = 0; + this.lastPlaybackStartTimestamp = 0; + this.lastStallCount = 0; + this.lastStallDuration = 0; + this.loadStartTimestamp = 0; + this.metadataRequestTime = 0; + this.mediaAssetUrl = undefined; + this.mediaId = undefined; + this.mediaMetadataUrl = undefined; + this.mediaOrigin = undefined; + this.sessionStartTimestamp = undefined; + this.tokenRequestTime = 0; + + clearInterval(this.heartbeatIntervalId); + + if (event && !['ended', 'playerreset'].includes(event.type)) { + this.removeListeners(); + } + } + + /** + * Sends an event to the server using the Beacon API. + * + * @param {string} eventName Either START, STOP, ERROR, HEARTBEAT + * @param {Object} [data={}] The payload object to be sent. Defaults to an + * empty object if not provided + */ + sendEvent(eventName, data = {}) { + // If the tracker is disabled for the current session, and there has been no + // previous session, no event is sent. However, if a session was already + // active, we still want to send the STOP event so that it is properly + // stopped. + if ( + (this.isTrackerDisabled() && !this.currentSessionId) || + !this.currentSessionId + ) return; + + const payload = JSON.stringify({ + event_name: eventName, + session_id: this.currentSessionId, + timestamp: PillarboxMonitoring.timestamp(), + version: this.schemaVersion, + data + }); + + navigator.sendBeacon( + this.beaconUrl, + payload + ); + } + + /** + * Starts a new session by first stopping the previous session, then resetting + * the session start timestamp and media ID to their new values. + */ + sessionStart() { + if (this.sessionStartTimestamp) { + this.sessionStop(); + } + + // Reference timestamp used to calculate the different time metrics. + this.sessionStartTimestamp = PillarboxMonitoring.timestamp(); + // At this stage currentSource().src is the media identifier + // and not the playable source. + this.mediaId = this.player.currentSource().src; + } + + /** + * Stops the current session by sending a `STOP` event and resetting the + * session. + * + * @param {Event} [event] The event that triggered the stop. This is passed + * to the reset function. + */ + sessionStop(event) { + this.sendEvent('STOP', this.statusEventData()); + this.reset(event); + } + + /** + * Handles the stalled state of the player. Sets the stalled state and listens + * for the event that indicates the player is no longer stalled. + */ + stalled() { + if ( + !this.player.hasStarted() || + this.player.seeking() || + this.isStalled + ) return; + + this.isStalled = true; + + const stallStart = PillarboxMonitoring.timestamp(); + const unstalled = () => { + const stallEnd = PillarboxMonitoring.timestamp(); + + this.isStalled = false; + this.lastStallCount += 1; + this.lastStallDuration += (stallEnd - stallStart); + }; + + // As Safari is not consistent with its playing event, it is better to use + // the timeupdate event. + if (pillarbox.browser.IS_ANY_SAFARI) { + this.player.one('timeupdate', unstalled); + } else { + // As Chromium-based browsers are not consistent with their timeupdate + // event, it is better to use the playing event. + // + // Firefox is consistent with its playing event. + this.player.one('playing', unstalled); + } + } + + /** + * Information about the player's stall events. + * + * @typedef {Object} StallInfo + * @property {number} count The number of stall events + * @property {number} duration The total duration of stall events in + * milliseconds + * + * @returns {StallInfo} An object containing the stall information + */ + stallInfo() { + return { + count: this.lastStallCount, + duration: this.lastStallDuration, + }; + } + + /** + * Get data on the current playback state. Will be used when sending `HEARTBEAT` or `STOP` events. + * + * @typedef {Object} StatusEventData + * @property {number} bandwidth The current bandwidth + * @property {number|undefined} bitrate The bitrate of the current resource + * @property {number} buffered_duration The duration of the buffered content + * @property {number} playback_duration The duration of the playback + * @property {number} position The current playback position + * @property {number} position_timestamp The timestamp of the current playback position + * @property {Object} stall Information about any stalls + * @property {string} stream_type The type of stream, either 'on-demand' or 'live' + * @property {string|undefined} url The URL of the current resource + * + * @returns {StatusEventData} The current event data + */ + statusEventData() { + const bandwidth = this.bandwidth(); + const buffered_duration = this.bufferDuration(); + const { bitrate, url } = this.currentResource(); + const playback_duration = this.playbackDuration(); + const { position, position_timestamp } = this.playbackPosition(); + const stream_type = isFinite(this.player.duration()) ? 'On-demand' : 'Live'; + const stall = this.stallInfo(); + + const data = { + bandwidth, + bitrate, + buffered_duration, + playback_duration, + position, + position_timestamp, + stall, + stream_type, + url, + }; + + return data; + } + + /** + * Generates the data for the start event. + * + * @typedef {Object} Device + * @property {string} id The device ID. + * + * @typedef {Object} StartEventData + * @property {string} browser The user agent string of the browser. + * @property {Device} device Information about the device. + * @property {MediaInfo} media Information about the media. + * @property {PlayerInfo} player Information about the player. + * @property {QoeTimings} qoe_timings Quality of Experience timings. + * @property {QosTimings} qos_timings Quality of Service timings. + * @property {PlayerCurrentDimensions} screen The current dimensions of the + * player. + * + * @returns {StartEventData} An object containing the start event data. + */ + startEventData() { + const timestamp = PillarboxMonitoring.timestamp(); + // This avoids false subtraction results when loadStartTimestamp is not + // initialized. + // loadStartTimestamp will be 0 if loadstart is not triggered. + // This is the case when a STARTDATE error occurs. + const timeToLoadedData = this + .loadStartTimestamp ? timestamp - this.loadStartTimestamp : 0; + + if (!this.isTrackerDisabled()) { + this.currentSessionId = PillarboxMonitoring.sessionId(); + } + + this.mediaAssetUrl = this + .removeTokenFromAssetUrl(this.player.currentSource().src); + this.mediaMetadataUrl = this.getMetadataInfo(this.mediaId).name; + this.metadataRequestTime = this.getMetadataInfo(this.mediaId).duration; + this.mediaOrigin = window.location.href; + this.tokenRequestTime = this.getTokenRequestDuration( + this.currentSourceMediaData().tokenType + ); + + return { + browser: PillarboxMonitoring.userAgent(), + device: { id: PillarboxMonitoring.deviceId() }, + media: this.mediaInfo(), + player: this.playerInfo(), + qoe_timings: this.qoeTimings(timeToLoadedData, timestamp), + qos_timings: this.qosTimings(timeToLoadedData), + screen: this.playerCurrentDimensions() + }; + } + + /** + * Generates a new session ID. + * + * @returns {string} random UUID + */ + static sessionId() { + return PillarboxMonitoring.randomUUID(); + } + + /** + * Retrieve or generate a unique device ID and stores it in localStorage. + * + * @returns {string|undefined} The device ID if localStorage is available, + * otherwise `undefined` + */ + static deviceId() { + if (!localStorage) return; + + const deviceIdKey = 'pillarbox_device_id'; + let deviceId = localStorage.getItem(deviceIdKey); + + if (!deviceId) { + deviceId = PillarboxMonitoring.randomUUID(); + localStorage.setItem(deviceIdKey, deviceId); + } + + return deviceId; + } + + /** + * Generate a cryptographically secure random UUID. + * + * @returns {string} + */ + static randomUUID() { + if (!crypto.randomUUID) { + // Polyfill from the author of uuid js which is simple and + // cryptographically secure. + // https://stackoverflow.com/a/2117523 + return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, c => + // eslint-disable-next-line + (+c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> +c / 4) + .toString(16)); + } + + return crypto.randomUUID(); + } + + /** + * converts seconds into milliseconds. + * + * @param {number} seconds + * + * @returns {number} milliseconds as an integer value + */ + static secondsToMilliseconds(seconds) { + return parseInt(seconds * 1000); + } + + /** + * The timestamp in milliseconds. + * + * @return {number} milliseconds as an integer value + */ + static timestamp() { + return Date.now(); + } + + /** + * The browser's user agent. + * + * @returns {string} + */ + static userAgent() { + return { + user_agent: navigator.userAgent + }; + } +} + +export default PillarboxMonitoring; diff --git a/src/analytics/SRGAnalytics.js b/src/trackers/SRGAnalytics.js similarity index 100% rename from src/analytics/SRGAnalytics.js rename to src/trackers/SRGAnalytics.js diff --git a/test/__mocks__/player-mock.js b/test/__mocks__/player-mock.js new file mode 100644 index 0000000..01b1d14 --- /dev/null +++ b/test/__mocks__/player-mock.js @@ -0,0 +1,81 @@ +import * as mediaData from '../__mocks__/mediaData.json'; + +let playerMock = jest.fn(() => ({ + audioTracks: jest.fn().mockReturnValue({}), + buffered: jest.fn().mockReturnValue({ length: 0, start: jest.fn(), end: jest.fn() }), + currentDimensions: jest.fn().mockReturnValue({ width: 1024, height: 768 }), + currentSource: jest.fn().mockReturnValue(mediaData), + currentTime: jest.fn().mockReturnValue(0), + dispose: jest.fn(() => { + document.dispatchEvent(new Event('dispose')); + }), + debug: jest.fn().mockReturnValue(false), + duration: jest.fn().mockReturnValue(0), + liveTracker: { + atLiveEdge: jest.fn(), + liveCurrentTime: jest.fn(), + liveWindow: jest.fn(), + options: jest.fn().mockReturnValue({ + trackingThreshold: 100, + }), + seekableStart: jest.fn(), + }, + el: jest.fn(), + ended: jest.fn(), + error: jest.fn((err) => { + document.dispatchEvent(new Event('error')); + + return err; + }), + hasStarted: jest.fn(), + muted: jest.fn(), + play: jest.fn(() => { + document.dispatchEvent(new Event('play')); + document.dispatchEvent(new Event('playing')); + }), + pause: jest.fn(() => { + document.dispatchEvent(new Event('pause')); + }), + paused: jest.fn(), + playbackRate: jest.fn().mockReturnValue(1), + on: jest.fn((evt, fn) => { + if (!Array.isArray(evt)) { + document.addEventListener(evt, fn); + + return; + } + + evt.forEach(e => { + document.addEventListener(e, fn); + }); + }), + off: jest.fn((evt, fn) => { + document.removeEventListener(evt, fn); + }), + one: jest.fn((evt, fn) => { + document.addEventListener(evt, fn, { once: true }); + }), + reset: jest.fn(() => { + document.dispatchEvent(new Event('playerreset')); + }), + seekable: jest.fn(), + seeking: jest.fn(), + tech: jest.fn().mockReturnValue({ + isCasting: undefined, + }), + textTrack: jest.fn().mockReturnValue(undefined), + textTracks: jest.fn().mockReturnValue({}), + trigger: jest.fn((evt) => { + document.dispatchEvent(new Event(evt)); + }), + scrubbing: jest.fn(), + src: jest.fn(() => { + document.dispatchEvent(new Event('emptied')); + }), + videoTracks: jest.fn().mockReturnValue({}), + volume: jest.fn().mockReturnValue(1), + eventBusEl_: true, + options_: {}, +})); + +export default playerMock; diff --git a/test/analytics/pillarbox-monitoring.spec.js b/test/analytics/pillarbox-monitoring.spec.js new file mode 100644 index 0000000..e5665c8 --- /dev/null +++ b/test/analytics/pillarbox-monitoring.spec.js @@ -0,0 +1,728 @@ +import PillarboxMonitoring from '../../src/trackers/PillarboxMonitoring.js'; +import pillarbox from '../../src/pillarbox.js'; +import playerMock from '../__mocks__/player-mock.js'; + +describe('PillarboxMonitoring', () => { + let player; + let monitoring; + + global.navigator.sendBeacon = jest.fn(); + + beforeEach(() => { + player = playerMock(); + monitoring = new PillarboxMonitoring(player); + }); + + it('should ensure that listeners are added when a new instance is created', () => { + const spyOnAddListeners = jest.spyOn(PillarboxMonitoring.prototype, 'addListeners'); + const srgQos = new PillarboxMonitoring(player); + + expect(srgQos).toBeInstanceOf(PillarboxMonitoring); + + expect(spyOnAddListeners).toHaveBeenCalled(); + }); + + /** + ***************************************************************************** + * addListeners ************************************************************** + ***************************************************************************** + */ + describe('addListeners', () => { + it('should bind the callbacks and add the event listeners to the player and window', () => { + const spyOnBindCallBacks = jest.spyOn(monitoring, 'bindCallBacks'); + + monitoring.addListeners(); + + expect(spyOnBindCallBacks).toHaveBeenCalled(); + }); + }); + + /** + ***************************************************************************** + * bandwidth ***************************************************************** + ***************************************************************************** + */ + describe('bandwidth', () => { + it('should return the bandwidth when available', () => { + const bandwidth = 69420; + + player.tech.mockReturnValue({ + vhs: { + stats: { + bandwidth + } + } + }); + + expect(monitoring.bandwidth()).toBe(bandwidth); + }); + + it('should undefined if vhs is not used', () => { + expect(monitoring.bandwidth()).toBeUndefined(); + }); + }); + + /** + ***************************************************************************** + * bufferDuration ************************************************************ + ***************************************************************************** + */ + describe('bufferDuration', () => { + it('should return 0 if the buffer is empty', () => { + player.buffered.mockReturnValue(pillarbox.time.createTimeRanges()); + + expect(monitoring.bufferDuration()).toBe(0); + }); + + it('should return the buffer\'s duration in milliseconds', () => { + player.buffered.mockReturnValue(pillarbox.time.createTimeRanges([[1, 70]])); + + expect(monitoring.bufferDuration()).toBe(69000); + }); + }); + + /** + ***************************************************************************** + * currentRepresentation ***************************************************** + ***************************************************************************** + */ + describe('currentRepresentation', () => { + it('should return undefined when there is no CUE', () => { + expect(monitoring.currentRepresentation()).toBeUndefined(); + }); + + it('should return the current representation', () => { + const value = { + bandwidth: 2129221, + codecs: 'avc1.4d401f,mp4a.40.2', + byteLength: 1963217 + }; + + player.textTracks.mockReturnValue([{ + activeCues: { + cues_: [{ + value + }] + }, + mode: 'hidden', + kind: 'metadata', + label: 'segment-metadata', + }]); + + expect(monitoring.currentRepresentation()).toStrictEqual(value); + }); + }); + + /** + ***************************************************************************** + * currentResource *********************************************************** + ***************************************************************************** + */ + describe('currentResource', () => { + it('should return the current resource returned by currentRepresentation', () => { + const spyOnCurrentSource = jest.spyOn(player, 'currentSource'); + + jest.spyOn(monitoring, 'currentRepresentation').mockReturnValueOnce({ + bandwidth: 69420, + uri: 'https://example.com/sd/35.m4s', + }); + + expect(monitoring.currentResource()).toStrictEqual({ + bitrate: 69420, + url: 'https://example.com/sd/35.m4s', + }); + expect(spyOnCurrentSource).not.toHaveBeenCalled(); + }); + + it('should return the current resource from videoTracks when the browser is any safari', () => { + const mockIsAnySafari = jest.replaceProperty(pillarbox, 'browser', { + IS_ANY_SAFARI: true, + }); + + player.videoTracks.mockReturnValue([ + { + selected: false, + configuration: { + bitrate: 35420, + } + }, + { + selected: true, + configuration: { + bitrate: 69420, + } + } + ]); + player.currentSource.mockReturnValue({ + src: 'https://example.com/sd/player.m3u8' + }); + + expect(monitoring.currentResource()).toStrictEqual({ + bitrate: 69420, + url: 'https://example.com/sd/player.m3u8' + }); + + mockIsAnySafari.restore(); + }); + }); + + /** + ***************************************************************************** + * currentSourceMediaData **************************************************** + ***************************************************************************** + */ + describe('currentSourceMediaData', () => { + it('should return an empty object if the currentSource does not contain a mediaData object', () => { + player.currentSource.mockReturnValue({ + src: 'https://example.com/sd/player.m3u8' + }); + + expect(monitoring.currentSourceMediaData()).toStrictEqual({}); + }); + + it('should the mediaData object if available', () => { + const mediaData = { + urn: 'urn:test:1' + }; + + player.currentSource.mockReturnValue({ + src: 'https://example.com/sd/player.m3u8', + mediaData + }); + + expect(monitoring.currentSourceMediaData()).toStrictEqual(mediaData); + }); + }); + + /** + ***************************************************************************** + * error ********************************************************************* + ***************************************************************************** + */ + describe('error', () => { + it('should send an error and reset the session', () => { + global.performance.getEntriesByType = jest.fn().mockReturnValue([]); + player.error.mockReturnValueOnce({ + metadata: { hasSomething: true }, + message: 'Source not found', + code: 69 + }); + player.currentSource.mockReturnValue({ + src: 'https://example.com/sd/player.m3u8' + }); + jest.spyOn(monitoring, 'playbackPosition').mockReturnValueOnce({ + position: true, + position_timestamp: true + }); + jest.spyOn(monitoring, 'currentRepresentation').mockReturnValueOnce({ + bandwidth: 2129221, + codecs: 'avc1.4d401f,mp4a.40.2', + byteLength: 1963217, + }); + const spyOnSendEvent = jest.spyOn(monitoring, 'sendEvent'); + const spyOnReset = jest.spyOn(monitoring, 'reset'); + + monitoring.error(); + + expect(spyOnSendEvent).toHaveBeenCalledWith('ERROR', expect.any(Object)); + expect(spyOnReset).toHaveBeenCalled(); + }); + }); + + /** + ***************************************************************************** + * getDrmRequestDuration ***************************************************** + ***************************************************************************** + */ + describe('getDrmRequestDuration', () => { + it('should return undefined if no key system is provided', () => { + player.currentSource.mockReturnValue({ + src: 'https://example.com/sd/player.m3u8', + keySystems: undefined + }); + + expect(monitoring.getDrmRequestDuration()).toBeUndefined(); + }); + + it('should return undefined if the resource is not found', () => { + player.currentSource.mockReturnValue({ + src: 'https://example.com/sd/player.m3u8', + keySystems: { + 'com.microsoft.playready': { + url: 'https://example.com/widevine/license' + } + } + }); + + global.performance.getEntriesByType = jest.fn().mockReturnValue([{ + duration: 69, + initiatorType: 'xmlhttprequest', + name: 'https://example.com/playready/license', + }]); + + expect(monitoring.getDrmRequestDuration()).toBeUndefined(); + }); + + it('should return the request duration', () => { + player.currentSource.mockReturnValue({ + src: 'https://example.com/sd/player.m3u8', + keySystems: { + 'com.microsoft.playready': { + url: 'https://example.com/widevine/license' + } + } + }); + + global.performance.getEntriesByType = jest.fn().mockReturnValue([{ + duration: 69, + initiatorType: 'xmlhttprequest', + name: 'https://example.com/playready/license', + }, + { + duration: 420, + initiatorType: 'xmlhttprequest', + name: 'https://example.com/widevine/license', + } + ]); + + expect(monitoring.getDrmRequestDuration()).toBe(420); + }); + }); + + /** + ***************************************************************************** + * getMetadataInfo *********************************************************** + ***************************************************************************** + */ + describe('getMetadataInfo', () => { + it('should return an empty object if the media id is not found', () => { + global.performance.getEntriesByType = jest.fn().mockReturnValue([{ + initiatorType: 'fetch', + name: 'https://example.com/metadata/old-media-id', + }]); + + expect(monitoring.getMetadataInfo('new-media-id')).toEqual({}); + }); + + it('should return the medatada url according to the media id', () => { + global.performance.getEntriesByType = jest.fn().mockReturnValue([{ + initiatorType: 'fetch', + name: 'https://example.com/metadata/new-media-id', + duration: 420 + }]); + + expect(monitoring.getMetadataInfo('new-media-id')).toEqual({ + name: 'https://example.com/metadata/new-media-id', + duration: 420 + }); + }); + }); + + /** + ***************************************************************************** + * getTokenRequestDuration *************************************************** + ***************************************************************************** + */ + describe('getTokenRequestDuration', () => { + it('should return undefined if the tokenType is undefined', () => { + expect(monitoring.getTokenRequestDuration()).toBeUndefined(); + }); + + it('should return undefined if there is no URL token related resource', () => { + global.performance.getEntriesByType = jest.fn().mockReturnValue([{ + initiatorType: 'fetch', + name: 'https://example.com/metadata/new-media-id', + duration: 420 + }]); + + expect(monitoring.getTokenRequestDuration(true)).toBeUndefined(); + }); + + it('should return the token request duration', () => { + global.performance.getEntriesByType = jest.fn().mockReturnValue([{ + initiatorType: 'fetch', + name: 'https://example.com/akahd/token', + duration: 420 + }]); + + expect(monitoring.getTokenRequestDuration(true)).toBe(420); + }); + }); + + /** + ***************************************************************************** + * heartbeat ***************************************************************** + ***************************************************************************** + */ + describe('heartbeat', () => { + it('should send an HEARTBEAT when the time interval timeout has been reached', () => { + jest.useFakeTimers(); + + const spyOnSendEvent = jest.spyOn(monitoring, 'sendEvent'); + + monitoring.heartbeat(); + + jest.advanceTimersByTime(100); + expect(spyOnSendEvent).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(30_000); + expect(spyOnSendEvent).toHaveBeenCalled(); + }); + }); + + /** + ***************************************************************************** + * loadedData **************************************************************** + ***************************************************************************** + */ + describe('loadedData', () => { + it('should send a START immediately followed by an HEARTBEAT', () => { + player.currentSource.mockReturnValue({ + mediaData: {} + }); + + jest.spyOn(monitoring, 'getMetadataInfo').mockReturnValue({}); + const spyOnSendEvent = jest.spyOn(monitoring, 'sendEvent'); + const spyOnRandomUUID = jest.spyOn(PillarboxMonitoring, 'randomUUID').mockReturnValue(true); + + monitoring.loadedData(); + + expect(spyOnSendEvent).toHaveBeenCalledTimes(2); + expect(spyOnSendEvent).toHaveBeenNthCalledWith(1, 'START', expect.any(Object)); + expect(spyOnSendEvent).toHaveBeenNthCalledWith(2, 'HEARTBEAT', expect.any(Object)); + + spyOnRandomUUID.mockRestore(); + }); + }); + + /** + ***************************************************************************** + * loadStart ***************************************************************** + ***************************************************************************** + */ + describe('loadStart', () => { + it('should set the timestamp when the function is called', () => { + + expect(monitoring.loadStartTimestamp).toBeUndefined(); + + monitoring.loadStart(); + + expect(monitoring.loadStartTimestamp).toEqual(expect.any(Number)); + }); + + it('should stop the previous session when playing a plain old media URL', () => { + const spyOnSessionStop = jest.spyOn(monitoring, 'sessionStop'); + + monitoring.currentSessionId = true; + player.currentSource.mockReturnValue({ + src: 'https://example.com/sd/player.m3u8' + }); + + expect(monitoring.sessionStartTimestamp).toBeUndefined(); + + monitoring.loadStart(); + + expect(monitoring.sessionStartTimestamp).toEqual(expect.any(Number)); + expect(spyOnSessionStop).toHaveBeenCalled(); + }); + }); + + /** + ***************************************************************************** + * playbackDuration ********************************************************** + ***************************************************************************** + */ + describe('playbackDuration', () => { + it('should return 0 if the playback hasn\'t started', () => { + expect(monitoring.playbackDuration()).toBe(0); + }); + it('should return the intermediate playback time', () => { + const now = jest.now(); + + jest.setSystemTime(now); + + monitoring.playbackStart(); + + jest.setSystemTime(now + 420); + + expect(monitoring.playbackDuration()).toBe(420); + }); + }); + + /** + ***************************************************************************** + * playbackPosition ********************************************************** + ***************************************************************************** + */ + describe('playbackPosition', () => { + it('should return the playback position in milliseconds and undefined if there no position timestamp available', () => { + player.currentTime.mockReturnValue(69); + expect(monitoring.playbackPosition()).toEqual({ + position: 69_000, + position_timestamp: undefined + }); + }); + + it('should return the playback position in milliseconds and the timestamp position', () => { + const now = jest.now(); + + player.currentTime.mockReturnValue(69); + jest.spyOn(monitoring, 'currentRepresentation').mockReturnValueOnce({ + programDateTime: now + }); + + expect(monitoring.playbackPosition()).toEqual({ + position: 69_000, + position_timestamp: now + }); + }); + }); + + /** + ***************************************************************************** + * removeListeners *********************************************************** + ***************************************************************************** + */ + describe('removeListeners', () => { + it('should remove all listeners', () => { + + const spyOnOff = jest.spyOn(player, 'off'); + const spyOnRemoveEventListener = jest.spyOn( + window, + 'removeEventListener' + ); + + monitoring.removeListeners(); + + expect(spyOnOff).toHaveBeenCalledTimes(8); + expect(spyOnRemoveEventListener).toHaveBeenCalledTimes(1); + }); + }); + + /** + ***************************************************************************** + * removeTokenFromAssetUrl *************************************************** + ***************************************************************************** + */ + describe('removeTokenFromAssetUrl', () => { + it('should return undefined if no asset url is provided', () => { + expect(monitoring.removeTokenFromAssetUrl()).toBeUndefined(); + }); + + it('should remove the token from asset url', () => { + expect(monitoring.removeTokenFromAssetUrl('https://example.com/sd/player.m3u8?hdnts=xyz69&other=420')).toBe('https://example.com/sd/player.m3u8?other=420'); + }); + + it('should return undefined if the url is invalid', () => { + expect(monitoring.removeTokenFromAssetUrl('invalid_url')).toBeUndefined(); + }); + }); + + /** + ***************************************************************************** + * reset ******************************************************************** + ***************************************************************************** + */ + describe('reset', () => { + it('should reset the properties whithout removing the event listeners', () => { + const spyOnRemoveListeners = jest.spyOn(monitoring, 'removeListeners'); + + monitoring.reset(); + + expect(spyOnRemoveListeners).not.toHaveBeenCalled(); + }); + + it('should reset the properties and remove the event listeners', () => { + const spyOnRemoveListeners = jest.spyOn(monitoring, 'removeListeners'); + + monitoring.reset('dispose'); + + expect(spyOnRemoveListeners).toHaveBeenCalled(); + }); + }); + + /** + ***************************************************************************** + * sendEvent ***************************************************************** + ***************************************************************************** + */ + describe('sendEvent', () => { + it('should send a POST request using sendBeacon', () => { + global.performance.getEntriesByType = jest.fn().mockReturnValue([]); + + const spyOnSendBeacon = jest.spyOn(navigator, 'sendBeacon'); + + monitoring.startEventData(); + monitoring.sendEvent('start', { + property: 'value' + }); + + expect(spyOnSendBeacon).toHaveBeenCalledWith(expect.any(String), expect.any(String)); + }); + + it('should only send a STOP event if there was a previous session', () => { + global.performance.getEntriesByType = jest.fn().mockReturnValue([]); + + const spyOnSendBeacon = jest.spyOn(navigator, 'sendBeacon'); + const spyOnIsTrackerDisabled = jest.spyOn(monitoring, 'isTrackerDisabled').mockReturnValue(false); + + // previous playback session + monitoring.startEventData(); + monitoring.sessionStop(); + + // new playback session + spyOnIsTrackerDisabled.mockReturnValue(true); + monitoring.sendEvent('start', { + property: 'value' + }); + monitoring.sendEvent('heartbeat', { + property: 'value' + }); + + expect(spyOnSendBeacon).toHaveBeenCalledTimes(1); + expect(spyOnSendBeacon).toHaveBeenCalledWith(expect.any(String), expect.any(String)); + + spyOnIsTrackerDisabled.mockRestore(); + }); + }); + + /** + ***************************************************************************** + * stalled ******************************************************************* + ***************************************************************************** + */ + describe('stalled', () => { + it('should do nothing if the content has not started', () => { + const spyOnTimestamp = jest.spyOn(PillarboxMonitoring, 'timestamp'); + + player.hasStarted.mockReturnValue(false); + monitoring.stalled(); + + expect(spyOnTimestamp).not.toHaveBeenCalled(); + }); + + it('should do nothing if the player is seeking', () => { + const spyOnTimestamp = jest.spyOn(PillarboxMonitoring, 'timestamp'); + + player.hasStarted.mockReturnValue(true); + player.seeking.mockReturnValue(true); + monitoring.stalled(); + + expect(spyOnTimestamp).not.toHaveBeenCalled(); + }); + + it('should do nothing if the player is already stalled', () => { + const spyOnTimestamp = jest.spyOn(PillarboxMonitoring, 'timestamp'); + + player.hasStarted.mockReturnValue(true); + player.seeking.mockReturnValue(true); + monitoring.isStalled = true; + monitoring.stalled(); + + expect(spyOnTimestamp).not.toHaveBeenCalled(); + }); + + it('should add a listener to the timeupdate event that fires once on Safari', () => { + const mockIsAnySafari = jest.replaceProperty(pillarbox, 'browser', { + IS_ANY_SAFARI: true, + }); + const spyOnTimestamp = jest.spyOn(PillarboxMonitoring, 'timestamp'); + const spyOnOne = jest.spyOn(player, 'one'); + + player.hasStarted.mockReturnValue(true); + + monitoring.stalled(); + player.trigger('timeupdate'); + player.trigger('timeupdate'); + player.trigger('timeupdate'); + player.trigger('timeupdate'); + + expect(spyOnOne).toHaveBeenCalledWith('timeupdate', expect.any(Function)); + expect(spyOnTimestamp).toHaveBeenCalledTimes(2); + + mockIsAnySafari.restore(); + }); + + it('should add a listener to the playing event that fires once', () => { + const spyOnOne = jest.spyOn(player, 'one'); + + player.hasStarted.mockReturnValue(true); + + monitoring.stalled(); + + expect(spyOnOne).toHaveBeenCalledWith('playing', expect.any(Function)); + }); + }); + + /** + ***************************************************************************** + * sessionStart ************************************************************** + ***************************************************************************** + */ + describe('sessionStart', () => { + it('should call sessionStop if there is no active session', () => { + const spyOnRandomUUID = jest.spyOn(PillarboxMonitoring, 'randomUUID').mockReturnValue(true); + const spyOnStop = jest.spyOn(monitoring, 'sessionStop'); + + monitoring.sessionStart(); + + expect(spyOnStop).not.toHaveBeenCalled(); + spyOnRandomUUID.mockRestore(); + }); + + it('should stop the previous session before starting a new one', () => { + const spyOnRandomUUID = jest.spyOn(PillarboxMonitoring, 'randomUUID').mockReturnValue(true); + const spyOnStop = jest.spyOn(monitoring, 'sessionStop'); + + monitoring.sessionStartTimestamp = jest.now(); + monitoring.sessionStart(); + + expect(spyOnStop).toHaveBeenCalled(); + spyOnRandomUUID.mockRestore(); + }); + }); + + /** + ***************************************************************************** + * statusEventData *********************************************************** + ***************************************************************************** + */ + describe('statusEventData', () => { + it('should return a well formed json object', () => { + player.tech.mockReturnValue({ + vhs: { + stats: { + bandwidth: 69000 + } + } + }); + player.currentSource.mockReturnValue({ + src: 'https://example.com/sd/player.m3u8' + }); + jest.spyOn(monitoring, 'playbackPosition').mockReturnValueOnce({ + position: 10, + position_timestamp: jest.now() + 1000 + }); + jest.spyOn(monitoring, 'currentRepresentation').mockReturnValueOnce({ + bandwidth: 2129221, + codecs: 'avc1.4d401f,mp4a.40.2', + byteLength: 1963217, + uri: 'https://example.com/sd/35.m4s', + }); + jest.spyOn(monitoring, 'playbackDuration').mockReturnValueOnce(0); + + expect(monitoring.statusEventData()).toEqual(expect + .objectContaining({ + bandwidth: expect.any(Number), + bitrate: expect.any(Number), + buffered_duration: expect.any(Number), + playback_duration: expect.any(Number), + position: expect.any(Number), + position_timestamp: expect.any(Number), + stall: expect.any(Object), + stream_type: expect.any(String), + url: expect.any(String), + })); + }); + }); +}); diff --git a/test/analytics/srg-analytics.spec.js b/test/analytics/srg-analytics.spec.js index 6a389d1..5c7ec96 100644 --- a/test/analytics/srg-analytics.spec.js +++ b/test/analytics/srg-analytics.spec.js @@ -1,6 +1,6 @@ -import SRGAnalytics from '../../src/analytics/SRGAnalytics.js'; +import SRGAnalytics from '../../src/trackers/SRGAnalytics.js'; import Pillarbox from '../../src/pillarbox.js'; -import * as mediaData from '../__mocks__/mediaData.json'; +import playerMock from '../__mocks__/player-mock.js'; jest.mock('../../src/pillarbox.js', () => ({ browser: { @@ -43,60 +43,6 @@ const playbackSequences = (player, timeRanges = [[]]) => { }; describe('SRGAnalytics', () => { - let playerMock = jest.fn(() => ({ - audioTracks: jest.fn().mockReturnValue({}), - currentSource: jest.fn().mockReturnValue(mediaData), - currentTime: jest.fn().mockReturnValue(0), - debug: jest.fn().mockReturnValue(false), - duration: jest.fn().mockReturnValue(0), - liveTracker: { - atLiveEdge: jest.fn(), - liveCurrentTime: jest.fn(), - liveWindow: jest.fn(), - options: jest.fn().mockReturnValue({ - trackingThreshold: 100, - }), - seekableStart: jest.fn(), - }, - el: jest.fn(), - ended: jest.fn(), - muted: jest.fn(), - play: jest.fn(() => { - document.dispatchEvent(new Event('play')); - document.dispatchEvent(new Event('playing')); - }), - pause: jest.fn(() => { - document.dispatchEvent(new Event('pause')); - }), - paused: jest.fn(), - playbackRate: jest.fn().mockReturnValue(1), - on: jest.fn((evt, fn) => { - document.addEventListener(evt, fn); - }), - off: jest.fn((evt, fn) => { - document.removeEventListener(evt, fn); - }), - one: jest.fn((evt, fn) => { - document.addEventListener(evt, fn, { once: true }); - }), - seekable: jest.fn(), - seeking: jest.fn(), - tech: jest.fn().mockReturnValue({ - isCasting: undefined, - }), - textTrack: jest.fn().mockReturnValue(undefined), - trigger: jest.fn((evt) => { - document.dispatchEvent(new Event(evt)); - }), - scrubbing: jest.fn(), - src: jest.fn(() => { - document.dispatchEvent(new Event('emptied')); - }), - volume: jest.fn().mockReturnValue(1), - eventBusEl_: true, - options_: {}, - })); - let player; let analytics;