diff --git a/integrationExamples/videoModule/jwplayerAdapter/index.html b/integrationExamples/videoModule/jwplayerAdapter/index.html new file mode 100644 index 00000000000..636e48f03c7 --- /dev/null +++ b/integrationExamples/videoModule/jwplayerAdapter/index.html @@ -0,0 +1,145 @@ +- + + + + + + + + + + + +

Prebid.js Test

+
Div-1
+
+
Div-2
+
+ + + diff --git a/integrationExamples/videoModule/videojsAdapter/index.html b/integrationExamples/videoModule/videojsAdapter/index.html new file mode 100644 index 00000000000..b631c720a18 --- /dev/null +++ b/integrationExamples/videoModule/videojsAdapter/index.html @@ -0,0 +1,119 @@ + + + + + + + + + + + + + +

VideoJS Adapter Test

+ +
Div-1 existing Player
+ + + +
Div-2 existing player without automatic setup
+ + + + +
Div-3 controlled Player
+
+ + + + + + + + + + \ No newline at end of file diff --git a/libraries/video/constants/enums.js b/libraries/video/constants/enums.js new file mode 100644 index 00000000000..b0755020580 --- /dev/null +++ b/libraries/video/constants/enums.js @@ -0,0 +1,5 @@ +export const PLAYBACK_MODE = { + VOD: 0, + LIVE: 1, + DVR: 2 +}; diff --git a/libraries/video/constants/events.js b/libraries/video/constants/events.js new file mode 100644 index 00000000000..5be594b1b48 --- /dev/null +++ b/libraries/video/constants/events.js @@ -0,0 +1,97 @@ +// Life Cycle +export const SETUP_COMPLETE = 'setupComplete'; +export const SETUP_FAILED = 'setupFailed'; +export const DESTROYED = 'destroyed'; + +// Ads +export const AD_REQUEST = 'adRequest'; +export const AD_BREAK_START = 'adBreakStart'; +export const AD_LOADED = 'adLoaded'; +export const AD_STARTED = 'adStarted'; +export const AD_IMPRESSION = 'adImpression'; +export const AD_PLAY = 'adPlay'; +export const AD_TIME = 'adTime'; +export const AD_PAUSE = 'adPause'; +export const AD_CLICK = 'adClick'; +export const AD_SKIPPED = 'adSkipped'; +export const AD_ERROR = 'adError'; +export const AD_COMPLETE = 'adComplete'; +export const AD_BREAK_END = 'adBreakEnd'; + +// Media +export const PLAYLIST = 'playlist'; +export const PLAYBACK_REQUEST = 'playbackRequest'; +export const AUTOSTART_BLOCKED = 'autostartBlocked'; +export const PLAY_ATTEMPT_FAILED = 'playAttemptFailed'; +export const CONTENT_LOADED = 'contentLoaded'; +export const PLAY = 'play'; +export const PAUSE = 'pause'; +export const BUFFER = 'buffer'; +export const TIME = 'time'; +export const SEEK_START = 'seekStart'; +export const SEEK_END = 'seekEnd'; +export const MUTE = 'mute'; +export const VOLUME = 'volume'; +export const RENDITION_UPDATE = 'renditionUpdate'; +export const ERROR = 'error'; +export const COMPLETE = 'complete'; +export const PLAYLIST_COMPLETE = 'playlistComplete'; + +// Layout +export const FULLSCREEN = 'fullscreen'; +export const PLAYER_RESIZE = 'playerResize'; +export const VIEWABLE = 'viewable'; +export const CAST = 'cast'; + +export const allVideoEvents = [ + SETUP_COMPLETE, SETUP_FAILED, DESTROYED, AD_REQUEST, AD_BREAK_START, AD_LOADED, AD_STARTED, + AD_IMPRESSION, AD_PLAY, AD_TIME, AD_PAUSE, AD_CLICK, AD_SKIPPED, AD_ERROR, AD_COMPLETE, AD_BREAK_END, PLAYLIST, + PLAYBACK_REQUEST, AUTOSTART_BLOCKED, PLAY_ATTEMPT_FAILED, CONTENT_LOADED, PLAY, PAUSE, BUFFER, TIME, SEEK_START, + SEEK_END, MUTE, VOLUME, RENDITION_UPDATE, ERROR, COMPLETE, PLAYLIST_COMPLETE, FULLSCREEN, PLAYER_RESIZE, VIEWABLE, + CAST +]; + +export const AUCTION_AD_LOAD_ATTEMPT = 'auctionAdLoadAttempt'; +export const AUCTION_AD_LOAD_ABORT = 'auctionAdLoadAbort'; +export const BID_IMPRESSION = 'bidImpression'; +export const BID_ERROR = 'bidError'; + +export const videoEvents = { + SETUP_COMPLETE, + SETUP_FAILED, + DESTROYED, + AD_REQUEST, + AD_BREAK_START, + AD_LOADED, + AD_STARTED, + AD_IMPRESSION, + AD_PLAY, + AD_TIME, + AD_PAUSE, + AD_CLICK, + AD_SKIPPED, + AD_ERROR, + AD_COMPLETE, + AD_BREAK_END, + PLAYLIST, + PLAYBACK_REQUEST, + AUTOSTART_BLOCKED, + PLAY_ATTEMPT_FAILED, + CONTENT_LOADED, + PLAY, + PAUSE, + BUFFER, + TIME, + SEEK_START, + SEEK_END, + MUTE, + VOLUME, + RENDITION_UPDATE, + ERROR, + COMPLETE, + PLAYLIST_COMPLETE, + FULLSCREEN, + PLAYER_RESIZE, + VIEWABLE, + CAST, +}; diff --git a/libraries/video/constants/ortb.js b/libraries/video/constants/ortb.js new file mode 100644 index 00000000000..b5490380cdf --- /dev/null +++ b/libraries/video/constants/ortb.js @@ -0,0 +1,145 @@ +/** + * @typedef {Object} OrtbParams + * @property {OrtbVideoParamst} video + * @property {OrtbContentParams} content + */ + +/** + * @typedef OrtbVideoParams + * @property {[string]} mimes - Content MIME types supported (e.g., “video/x-ms-wmv”, “video/mp4”). + * @property {number|undefined} minduration - Minimum video ad duration in seconds. + * @property {number|undefined} maxduration - Maximum video ad duration in seconds. + * @property {[number]} protocols - Supported video protocols. At least one supported protocol must be specified. + * @property {number} w - Width of the video player in device independent pixels (DIPS). + * @property {number} h - Height of the video player in device independent pixels (DIPS). + * @property {number|undefined} startdelay - Indicates the offset of the ad placement. + * @property {number|undefined} placement - Placement type for the impression. + * @property {number|undefined} linearity - Indicates if the impression must be linear, nonlinear, etc. If omitted, assume all are allowed. + * @property {number} skip - Indicates if the player can allow the video to be skipped, where 0 is no, 1 is yes. + * @property {number|undefined} skipmin - Only ad creatives with a duration greater than this value can be skippable; only applicable if the ad is skippable. + * @property {number|undefined} skipafter - Number of seconds a video must play before skipping is enabled; only applicable if the ad is skippable. + * @property {number|undefined} sequence - If multiple ad impressions are offered in the same bid request, the sequence number will allow for the coordinated delivery of multiple creatives. + * @property {[number]|undefined} battr - Blocked creative attributes. Use this to indicate which creatives the player does not support. + * @property {number|undefined} maxextended - Maximum extended ad duration if extension is allowed. + * @property {number|undefined} minbitrate - Minimum bit rate in Kbps supported by the player. + * @property {number|undefined} maxbitrate - Maximum bit rate in Kbps supported by the player. + * @property {number|undefined} boxingallowed - Indicates if letter-boxing of 4:3 content into a 16:9 window is allowed. 0 is no, 1 is yes. + * @property {[number]|undefined} playbackmethod - Playback methods that may be in use. + * @property {number|undefined} playbackend - The scenario that causes playback to end. + * @property {[number]|undefined} delivery - Supported delivery methods (e.g., streaming, progressive). + * @property {number|undefined} pos - Ad position on screen. + * @property {[Object]|undefined} companionad - list of companion ads. Refer to Section 3.2.6 of the oRTB v2.5 spec for the interface of the companion ad object. + * @property {[number]|undefined} api - List of supported API frameworks for this impression. + * @property {[number]|undefined} companiontype - Supported VAST companion ad types. Refer to List 5.14 of the oRTB v2.5 spec for the interface. + * @property {Object|undefined} ext - Placeholder for exchange-specific extensions to OpenRTB. + */ + +/** + * @typedef OrtbContentParams + * @property {string} id - ID uniquely identifying the content. + * @property {string} url - URL of the content, for buy-side contextualization or review. + * @property {number|undefined} episode - Episode number. + * @property {string|undefined} title - Content title. + * @property {string|undefined} series - Content series i.e. “The Office” (television), “Star Wars” (movie). + * @property {string|undefined} season - Content season (e.g., “Season 3”). + * @property {string|undefined} artist - Artist credited with the content. + * @property {string|undefined} genre - Genre that best describes the content (e.g., rock, pop, etc). + * @property {string|undefined} album - Album to which the content belongs. Typically for audio. + * @property {string|undefined} isrc - International Standard Recording Code conforming to ISO3901. + * @property {Object|undefined} producer - Details about the content Producer. For Producer interface visit Section 3.2.17 of the oRTB v2.5 spec. + * @property {[string]|undefined} cat - List of IAB content categories that describe the content. Refer to List 5.1. of the oRTB v2.5 spec for the complete list. + * @property {number|undefined} prodq - Production quality. Refer to List 5.13 of the oRTB v2.5 spec. + * @property {number|undefined} context - Type of content (game, video, text, etc.). Refer to List 5.18 of the oRTB v2.5 spec. + * @property {string|undefined} contentrating - Content rating (e.g., MPAA). + * @property {string|undefined} userrating - User rating of the content (e.g., number of stars, likes, etc.). + * @property {number|undefined} qagmediarating - Media rating per IQG guidelines. Refer to List 5.19 of the oRTB v2.5 spec. + * @property {string|undefined} keywords - Comma separated list of keywords describing the content. + * @property {number|undefined} livestream - Whether the stream is live or not. 0 means not live (VOD), 1 means content is live streaming. + * @property {number|undefined} sourcerelationship - 0 means indirect, 1 means direct. + * @property {number} len - Duration of content in seconds. + * @property {string|undefined} language - Content language using ISO-639-1-alpha-2. + * @property {number|undefined} embeddable - Indicator of whether or not the content is embeddable (e.g., an embeddable video player). 0 means no, 1 means yes. + * @property {[Object]|undefined} data - Additional content data. Each Data object represents a different data source. See Section 3.2.21 of the oRTB v2.5 spec. + * @property {Object|undefined} ext - Placeholder for exchange-specific extensions to OpenRTB. + */ + +const VIDEO_PREFIX = 'video/'; +const APPLICATION_PREFIX = 'application/'; + +/** + * ORTB 2.5 section 3.2.7 - Video.mimes + * @enum OrtbVideoParams.mimes + */ +export const VIDEO_MIME_TYPE = { + MP4: VIDEO_PREFIX + 'mp4', + MPEG: VIDEO_PREFIX + 'mpeg', + OGG: VIDEO_PREFIX + 'ogg', + WEBM: VIDEO_PREFIX + 'webm', + AAC: VIDEO_PREFIX + 'aac', + HLS: APPLICATION_PREFIX + 'vnd.apple.mpegurl' +}; + +export const JS_APP_MIME_TYPE = APPLICATION_PREFIX + 'javascript'; +export const VPAID_MIME_TYPE = JS_APP_MIME_TYPE; + +/** + * ORTB 2.5 section 5.9 - Video Placement Types + * @enum OrtbVideoParams.placement + */ +export const PLACEMENT = { + INSTREAM: 1, + BANNER: 2, + ARTICLE: 3, + FEED: 4, + INTERSTITIAL: 5, + SLIDER: 5, + FLOATING: 5, + INTERSTITIAL_SLIDER_FLOATING: 5 +}; + +/** + * ORTB 2.5 section 5.10 - Playback Methods + * @enum OrtbVideoParams.playbackmethod + */ +export const PLAYBACK_METHODS = { + AUTOPLAY: 1, + AUTOPLAY_MUTED: 2, + CLICK_TO_PLAY: 3, + CLICK_TO_PLAY_MUTED: 4, + VIEWABLE: 5, + VIEWABLE_MUTED: 6 +}; + +/** + * ORTB 2.5 section 5.8 - Protocols + * @enum OrtbVideoParams.protocols + */ +export const PROTOCOLS = { + // VAST_1_0: 1, + VAST_2_0: 2, + VAST_3_0: 3, + // VAST_1_O_WRAPPER: 4, + VAST_2_0_WRAPPER: 5, + VAST_3_0_WRAPPER: 6, + VAST_4_0: 7, + VAST_4_0_WRAPPER: 8 +}; + +/** + * ORTB 2.5 section 5.6 - API Frameworks + * @enum OrtbVideoParams.api + */ +export const API_FRAMEWORKS = { + VPAID_1_0: 1, + VPAID_2_0: 2, + OMID_1_0: 7 +}; + +/** + * ORTB 2.5 section 5.18 - Content Context + * @enum OrtbContentParams.context + */ +export const CONTEXT = { + VIDEO: 1, + AUDIO: 3 +}; diff --git a/libraries/video/constants/vendorCodes.js b/libraries/video/constants/vendorCodes.js new file mode 100644 index 00000000000..370c151b997 --- /dev/null +++ b/libraries/video/constants/vendorCodes.js @@ -0,0 +1,6 @@ +// Video Vendors +export const JWPLAYER_VENDOR = 1; +export const VIDEO_JS_VENDOR = 2; + +// Ad Server Vendors +export const GAM_VENDOR = 'gam'; diff --git a/libraries/video/coreVideo.js b/libraries/video/coreVideo.js new file mode 100644 index 00000000000..5b9a8cdbb90 --- /dev/null +++ b/libraries/video/coreVideo.js @@ -0,0 +1,188 @@ +import { module } from '../../src/hook.js'; +import { ParentModule, SubmoduleBuilder } from './shared/parentModule.js'; + +// define, ortb object, events + +/** + * Video Provider Submodule interface. All submodules of the Core Video module must adhere to this. + * @description attached to a video player instance. + * @typedef {Object} VideoProvider + * @function init - Instantiates the Video Provider and the video player, if not already instantiated. + * @function getId - retrieves the div id (unique identifier) of the attached player instance. + * @function getOrtbParams - retrieves the oRTB params for a player's current video session. + * @function setAdTagUrl - Requests that a player render the ad in the provided ad tag url. + * @function onEvents - attaches event listeners to the player instance. + * @function offEvents - removes event listeners to the player instance. + * @function destroy - deallocates the player instance + */ + +/** + * @function VideoProvider#init + */ + +/** + * @function VideoProvider#getId + * @returns {string} + */ + +/** + * @function VideoProvider#getOrtParams + * @returns {Object} + */ + +/** + * @function VideoProvider#setAdTagUrl + * @param {string} adTagUrl - URL to a VAST ad tag + * @param {Object} options - Optional params + */ + +/** + * @function VideoProvider#onEvents + * @param {[string]} events - List of event names for which the listener should be added + * @param {function} callback - function that will get called when one of the events is triggered + */ + +/** + * @function VideoProvider#offEvents + * @param {[string]} events - List of event names for which the attached listener should be removed + * @param {function} callback - function that was assigned as a callback when the listener was added + */ + +/** + * @function VideoProvider#destroy + */ + +/** + * @typedef {Object} videoProviderConfig + * @name videoProviderConfig + * @summary contains data indicating which submodule to create and which player instance to attach it to + * @property {string} divId - unique identifier of the player instance + * @property {number} vendorCode - numeric identifier of the Video Provider type i.e. video.js or jwplayer + * @property {playerConfig} playerConfig + */ + +/** + * @typedef {Object} playerConfig + * @name playerConfig + * @summary contains data indicating the behavior the player instance should have + * @property {boolean} autoStart - determines if the player should start automatically when instantiated + * @property {boolean} mute - determines if the player should be muted when instantiated + * @property {string} licenseKey - authentication key required for commercial players. Optional for free players. + * @property {playerVendorParams} params + */ + +/** + * @typedef playerVendorParams + * @name playerVendorParams + * @summary configuration options specific to a Video Vendor's Provider + * @property {Object} vendorConfig - the settings object which can be used as an argument when instantiating a player. Specific to the video player's API. + */ + +/** + * Routes commands to the appropriate video submodule. + * @typedef {Object} VideoCore + * @class + * @function registerProvider + * @function getOrtbParams + * @function setAdTagUrl + * @function onEvents + * @function offEvents + */ + +/** + * @summary Maps a Video Provider factory to the video player's vendor code. + * @type {vendorSubmoduleDirectory} + */ +const videoVendorDirectory = {}; + +/** + * @constructor + * @param {ParentModule} parentModule_ + * @returns {VideoCore} + */ +export function VideoCore(parentModule_) { + const parentModule = parentModule_; + + /** + * requests that a submodule be instantiated for the specific player instance described by the @providerConfig + * @name VideoCore#registerProvider + * @param {videoProviderConfig} providerConfig + */ + function registerProvider(providerConfig) { + try { + parentModule.registerSubmodule(providerConfig.divId, providerConfig.vendorCode, providerConfig); + } catch (e) {} + } + + /** + * @name VideoCore#getOrtbParams + * @summary Obtains the oRTB params for a player's current video session. + * @param {string} divId - unique identifier of the player instance + * @returns {Object} oRTB params + */ + function getOrtbParams(divId) { + const submodule = parentModule.getSubmodule(divId); + return submodule && submodule.getOrtbParams(); + } + + /** + * @name VideoCore#setAdTagUrl + * @summary Requests that a player render the ad in the provided ad tag + * @param {string} adTagUrl - URL to a VAST ad tag + * @param {string} divId - unique identifier of the player instance + * @param {Object} options - additional params + */ + function setAdTagUrl(adTagUrl, divId, options) { + const submodule = parentModule.getSubmodule(divId); + submodule && submodule.setAdTagUrl(adTagUrl, options); + } + + /** + * @name VideoCore#onEvents + * @summary attaches event listeners + * @param {[string]} events - List of event names for which the listener should be added + * @param {function} callback - function that will get called when one of the events is triggered + * @param {string} divId - unique identifier of the player instance + */ + function onEvents(events, callback, divId) { + const submodule = parentModule.getSubmodule(divId); + submodule && submodule.onEvents(events, callback); + } + + /** + * @name VideoCore#offEvents + * @summary removes event listeners + * @param {[string]} events - List of event names for which the listener should be removed + * @param {function} callback - function that was assigned as a callback when the listener was added + * @param {string} divId - unique identifier of the player instance + */ + function offEvents(events, callback, divId) { + const submodule = parentModule.getSubmodule(divId); + submodule && submodule.offEvents(events, callback); + } + + return { + registerProvider, + getOrtbParams, + setAdTagUrl, + onEvents, + offEvents + }; +} + +/** + * @function videoCoreFactory + * @summary Factory to create a Video Core instance + * @returns {VideoCore} + */ +export function videoCoreFactory() { + const videoSubmoduleBuilder = SubmoduleBuilder(videoVendorDirectory); + const parentModule = ParentModule(videoSubmoduleBuilder); + return VideoCore(parentModule); +} + +function attachVideoProvider(submoduleFactory) { + videoVendorDirectory[submoduleFactory.vendorCode] = submoduleFactory; +} + +module('video', attachVideoProvider); diff --git a/libraries/video/gamAdServerSubmodule.js b/libraries/video/gamAdServerSubmodule.js new file mode 100644 index 00000000000..2b0aaad3ca4 --- /dev/null +++ b/libraries/video/gamAdServerSubmodule.js @@ -0,0 +1,27 @@ +import { GAM_VENDOR } from './constants/vendorCodes.js'; +import { getGlobal } from '../../src/prebidGlobal.js'; + +/** + * @constructor + * @param {Object} dfpModule_ - the DFP ad server module + * @returns {AdServerProvider} + */ +function GamAdServerProvider(dfpModule_) { + const dfp = dfpModule_; + + function getAdTagUrl(adUnit, baseAdTag, params) { + return dfp.buildVideoUrl({ adUnit: adUnit, url: baseAdTag, params }); + } + + return { + getAdTagUrl + } +} + +export function gamSubmoduleFactory() { + const dfp = getGlobal().adServers.dfp; + const gamProvider = GamAdServerProvider(dfp); + return gamProvider; +} + +gamSubmoduleFactory.vendorCode = GAM_VENDOR; diff --git a/libraries/video/index.js b/libraries/video/index.js new file mode 100644 index 00000000000..430354269ef --- /dev/null +++ b/libraries/video/index.js @@ -0,0 +1,50 @@ +import {getGlobal} from '../../src/prebidGlobal.js'; +import {config} from '../../src/config.js'; +import events from '../../src/events.js'; +import CONSTANTS from '../../src/constants.json' +import { videoCoreFactory } from './coreVideo.js' + +export function PbVideo(videoCore_, getConfig_, requestBids_, onPbEvents_) { + const videoCore = videoCore_; + const getConfig = getConfig_; + const requestBids = requestBids_; + const onPbEvents = onPbEvents_; + + function init() { + getConfig('video', ({ video }) => { + video.providers.forEach(provider => { + videoCore.registerProvider(provider); + }); + // maybe video.providers to get changes on providers + // called whenever 'video' is updated + // instantiate anc check for new submodules + }); + + // before bids are requested , getOrtbParams and write to the ad units. + requestBids.before(enrichAdUnits, 40); + + // bidsBackHandler -> setAdTagUrl + onPbEvents(CONSTANTS.EVENTS.AUCTION_END, function(auctionResult) { + // get winning bid + vast xml + }); + + // analytics registering and surfacing + } + + function enrichAdUnits(nextFn, bidRequest) { + // get oRTB arams + // write to ad units + // let adUnits = bidRequest.adUnits + } + + return { init }; +} + +function pbVideoFactory() { + const videoCore = videoCoreFactory(); + const pbVideo = PbVideo(videoCore, config.getConfig, getGlobal().requestBids, events.on); + pbVideo.init(); + return pbVideo; +} + +pbVideoFactory(); diff --git a/libraries/video/shared/parentModule.js b/libraries/video/shared/parentModule.js new file mode 100644 index 00000000000..e96113fd894 --- /dev/null +++ b/libraries/video/shared/parentModule.js @@ -0,0 +1,82 @@ +/** + * @typedef {Object} ParentModule + * @summary abstraction for any module to store and reference its submodules + * @param {SubmoduleBuilder} submoduleBuilder_ + * @returns {ParentModule} + * @constructor + */ +export function ParentModule(submoduleBuilder_) { + const submoduleBuilder = submoduleBuilder_; + const submodules = {}; + + /** + * @function ParentModule#registerSubmodule + * @summary Stores a submodule + * @param {String} id - unique identifier of the submodule instance + * @param {String} vendorCode - identifier to the submodule type that must be built + * @param {Object} config - additional information necessary to instantiate the submodule + */ + function registerSubmodule(id, vendorCode, config) { + if (submodules[id]) { + return; + } + + let submodule; + try { + submodule = submoduleBuilder.build(vendorCode, config); + } catch (e) { + throw e; + } + submodules[id] = submodule; + } + + /** + * @function ParentModule#getSubmodule + * @summary Stores a submodule + * @param {String} id - unique identifier of the submodule instance + * @returns {Object} - a submodule instance + */ + function getSubmodule(id) { + return submodules[id]; + } + + return { + registerSubmodule, + getSubmodule + } +} + +/** + * @typedef {Object} SubmoduleBuilder + * @summary Instantiates submodules + * @param {vendorSubmoduleDirectory} submoduleDirectory_ + * @param {Object|null|undefined} sharedUtils_ + * @returns {SubmoduleBuilder} + * @constructor + */ +export function SubmoduleBuilder(submoduleDirectory_, sharedUtils_) { + const submoduleDirectory = submoduleDirectory_; + const sharedUtils = sharedUtils_; + + /** + * @function SubmoduleBuilder#build + * @param vendorCode - identifier to the submodule type that must be instantiated + * @param config - additional information necessary to instantiate the submodule + * @throws + * @returns {{init}|*} - a submodule instance + */ + function build(vendorCode, config) { + const submoduleFactory = submoduleDirectory[vendorCode]; + if (!submoduleFactory) { + throw new Error('Unrecognized submodule vendor code: ' + vendorCode); + } + + const submodule = submoduleFactory(config, sharedUtils); + submodule && submodule.init && submodule.init(); + return submodule; + } + + return { + build + }; +} diff --git a/libraries/video/shared/state.js b/libraries/video/shared/state.js new file mode 100644 index 00000000000..b84cff8f997 --- /dev/null +++ b/libraries/video/shared/state.js @@ -0,0 +1,47 @@ +/** + * @typedef {Object} State + * @summary simple state object. Can be subclassed + * @function updateState + * @function getState + * @function clearState + */ + +/** + * @summary factory to create a simple state object + * @returns {State} + */ +export default function stateFactory() { + let state = {}; + + /** + * @function State#updateState + * @summary updates the state + * @param {Object} stateUpdate + */ + function updateState(stateUpdate) { + Object.assign(state, stateUpdate); + } + + /** + * @function State#getState + * @summary provides the current state + * @returns {Object} the current state + */ + function getState() { + return state; + } + + /** + * @function State#clearState + * @summary erases the current state + */ + function clearState() { + state = {}; + } + + return { + updateState, + getState, + clearState + }; +} diff --git a/libraries/video/shared/vastXmlBuilder.js b/libraries/video/shared/vastXmlBuilder.js new file mode 100644 index 00000000000..35547acc479 --- /dev/null +++ b/libraries/video/shared/vastXmlBuilder.js @@ -0,0 +1,77 @@ +import { getGlobal } from '../../../src/prebidGlobal.js'; + +export function buildVastWrapper(adId, adTagUrl, impressionUrl, impressionId, errorUrl) { + let wrapperBody = getAdSystemNode('Prebid org', getGlobal().version); + + if (adTagUrl) { + wrapperBody += getAdTagUriNode(adTagUrl); + } + + if (impressionUrl) { + wrapperBody += getImpressionNode(impressionUrl, impressionId); + } + + if (errorUrl) { + wrapperBody += getErrorNode(errorUrl); + } + + return getVastNode(getAdNode(getWrapperNode(wrapperBody), adId), '4.2'); +} + +export function getVastNode(body, vastVersion) { + return getNode('VAST', body, { version: vastVersion }); +} + +export function getAdNode(body, adId) { + return getNode('Ad', body, { id: adId }); +} + +export function getWrapperNode(body) { + return getNode('Wrapper', body); +} + +export function getAdSystemNode(system, version) { + return getNode('AdSystem', system, { version }); +} + +export function getAdTagUriNode(adTagUrl) { + return getUrlNode('VASTAdTagURI', adTagUrl); +} + +export function getImpressionNode(pingUrl, id) { + return getUrlNode('Impression', pingUrl, { id }); +} + +export function getErrorNode(pingUrl) { + return getUrlNode('Error', pingUrl); +} + +// Helpers + +function getUrlNode(labelName, url, attributes) { + const body = ``; + return getNode(labelName, body, attributes); +} + +function getNode(labelName, body, attributes) { + const openingLabel = getOpeningLabel(labelName, attributes); + return `<${openingLabel}>${body}`; +} + +/* +attributes is a KVP Object. + */ +function getOpeningLabel(name, attributes) { + if (!attributes) { + return name; + } + + return Object.keys(attributes).reduce((label, key) => { + const value = attributes[key]; + if (!value) { + return label; + } + + return label + ` ${key}="${value}"`; + }, name); +} diff --git a/libraries/video/shared/vastXmlEditor.js b/libraries/video/shared/vastXmlEditor.js new file mode 100644 index 00000000000..b586e5b4c29 --- /dev/null +++ b/libraries/video/shared/vastXmlEditor.js @@ -0,0 +1,115 @@ +import { getErrorNode, getImpressionNode, buildVastWrapper } from './vastXmlBuilder.js'; + +export const XML_MIME_TYPE = 'application/xml'; + +export function VastXmlEditor(xmlUtil_) { + const xmlUtil = xmlUtil_; + + function getVastXmlWithTracking(vastXml, adId, impressionUrl, impressionId, errorUrl) { + const impressionDoc = getImpressionDoc(impressionUrl, impressionId); + const errorDoc = getErrorDoc(errorUrl); + if (!adId && !impressionDoc && !errorDoc) { + return vastXml; + } + + const vastXmlDoc = xmlUtil.parse(vastXml); + appendTrackingNodes(vastXmlDoc, impressionDoc, errorDoc); + replaceAdId(vastXmlDoc, adId); + return xmlUtil.serialize(vastXmlDoc); + } + + function appendTrackingNodes(vastXmlDoc, impressionDoc, errorDoc) { + const nodes = vastXmlDoc.querySelectorAll('InLine,Wrapper'); + const nodeCount = nodes.length; + for (let i = 0; i < nodeCount; i++) { + const node = nodes[i]; + // A copy of the child is required until we reach the last node. + const requiresCopy = i < nodeCount - 1; + appendChild(node, impressionDoc, requiresCopy); + appendChild(node, errorDoc, requiresCopy); + } + } + + function replaceAdId(vastXmlDoc, adId) { + if (!adId) { + return; + } + + const adNode = vastXmlDoc.querySelector('Ad'); + if (!adNode) { + return; + } + + adNode.id = adId; + } + + return { + getVastXmlWithTracking, + buildVastWrapper + } + + function getImpressionDoc(impressionUrl, impressionId) { + if (!impressionUrl) { + return; + } + + const impressionNode = getImpressionNode(impressionUrl, impressionId); + return xmlUtil.parse(impressionNode); + } + + function getErrorDoc(errorUrl) { + if (!errorUrl) { + return; + } + + const errorNode = getErrorNode(errorUrl); + return xmlUtil.parse(errorNode); + } + + function appendChild(node, child, copy) { + if (!child) { + return; + } + + const doc = copy ? child.cloneNode(true) : child; + node.appendChild(doc.documentElement); + } +} + +function XMLUtil() { + let parser; + let serializer; + + function getParser() { + if (!parser) { + // DOMParser instantiation is costly; instantiate only once throughout Prebid lifecycle. + parser = new DOMParser(); + } + return parser; + } + + function getSerializer() { + if (!serializer) { + // XMLSerializer instantiation is costly; instantiate only once throughout Prebid lifecycle. + serializer = new XMLSerializer(); + } + return serializer; + } + + function parse(xmlString) { + return getParser().parseFromString(xmlString, XML_MIME_TYPE); + } + + function serialize(xmlDoc) { + return getSerializer().serializeToString(xmlDoc); + } + + return { + parse, + serialize + }; +} + +export function vastXmlEditorFactory() { + return VastXmlEditor(XMLUtil()); +} diff --git a/libraries/video/videoImpressionVerifier.js b/libraries/video/videoImpressionVerifier.js new file mode 100644 index 00000000000..c5be3f6fe48 --- /dev/null +++ b/libraries/video/videoImpressionVerifier.js @@ -0,0 +1,200 @@ +import { find } from '../../src/polyfill.js'; +import { vastXmlEditorFactory } from './shared/vastXmlEditor.js'; +import { generateUUID } from '../../src/utils.js'; + +export const PB_PREFIX = 'pb_'; +export const UUID_MARKER = PB_PREFIX + 'uuid'; + +/** + * Video Impression Verifier interface. All implementations of a Video Impression Verifier must comply with this interface. + * @description adds tracking markers to an ad and extracts the bid identifiers from ad event information. + * @typedef {Object} VideoImpressionVerifier + * @function trackBid - requests that a bid's ad be tracked for impression verification. + * @function getBidIdentifiers - requests information from the ad event data that can be used to match the ad to a tracked bid. + */ + +/** + * @function VideoImpressionVerifier#trackBid + * @param {Object} bid - Bid that should be tracked. + * @return {String} - Identifier for the bid being tracked. + */ + +/** + * @function VideoImpressionVerifier#getBidIdentifiers + * @param {String} adId - In the VAST tag, this value is present in the Ad element's id property. + * @param {String} adTagUrl - The ad tag url that was loaded into the player. + * @param {[String]} adWrapperIds - List of ad id's that were obtained from the different wrappers. Each redirect points to an ad wrapper. + * @return {bidIdentifier} - Object allowing the bid matching the ad event to be identified. + */ + +/** + * @typedef {Object} bidIdentifier + * @property {String} adId - Bid identifier. + * @property {String} adUnitCode - Identifier for the Ad Unit for which the bid was made. + * @property {String} auctionId - Id of the auction in which the bid was made. + * @property {String} requestId - Id of the bid request which resulted in the bid. + */ + +/** + * Factory function for obtaining a Video Impression Verifier. + * @param {Boolean} isCacheUsed - wether Prebid is configured to use a cache. + * @return {VideoImpressionVerifier} + */ +export function videoImpressionVerifierFactory(isCacheUsed) { + const vastXmlEditor = vastXmlEditorFactory(); + const bidTracker = tracker(); + if (isCacheUsed) { + return cachedVideoImpressionVerifier(vastXmlEditor, bidTracker); + } + + return videoImpressionVerifier(vastXmlEditor, bidTracker); +} + +export function videoImpressionVerifier(vastXmlEditor_, bidTracker_) { + const verifier = baseImpressionVerifier(bidTracker_); + const superTrackBid = verifier.trackBid; + const vastXmlEditor = vastXmlEditor_; + + verifier.trackBid = function(bid) { + let { vastXml, vastUrl } = bid; + if (!vastXml && !vastUrl) { + return; + } + + const uuid = superTrackBid(bid); + + if (vastUrl) { + const url = new URL(vastUrl); + url.searchParams.append(UUID_MARKER, uuid); + bid.vastUrl = url.toString(); + } else if (vastXml) { + bid.vastXml = vastXmlEditor.getVastXmlWithTracking(vastXml, uuid); + } + + return uuid; + } + + return verifier; +} + +export function cachedVideoImpressionVerifier(vastXmlEditor_, bidTracker_) { + const verifier = baseImpressionVerifier(bidTracker_); + const superTrackBid = verifier.trackBid; + const superGetBidIdentifiers = verifier.getBidIdentifiers; + const vastXmlEditor = vastXmlEditor_; + + verifier.trackBid = function (bid, globalAdUnits) { + const adIdOverride = superTrackBid(bid); + let { vastXml, vastUrl, adId, adUnitCode } = bid; + const adUnit = find(globalAdUnits, adUnit => adUnitCode === adUnit.code); + const videoConfig = adUnit && adUnit.video; + const adServerConfig = videoConfig && videoConfig.adServer; + const trackingConfig = adServerConfig && adServerConfig.tracking; + let impressionUrl; + let impressionId; + let errorUrl; + const impressionTracking = trackingConfig.impression; + const errorTracking = trackingConfig.error; + + if (impressionTracking) { + impressionUrl = getTrackingUrl(impressionTracking.getUrl, bid); + impressionId = impressionTracking.id || adId + '-impression'; + } + + if (errorTracking) { + errorUrl = getTrackingUrl(errorTracking.getUrl, bid); + } + + if (vastXml) { + vastXml = vastXmlEditor.getVastXmlWithTracking(vastXml, adIdOverride, impressionUrl, impressionId, errorUrl); + } else if (vastUrl) { + vastXml = vastXmlEditor.buildVastWrapper(adIdOverride, vastUrl, impressionUrl, impressionId, errorUrl); + } + + bid.vastXml = vastXml; + return adIdOverride; + } + + verifier.getBidIdentifiers = function (adId, adTagUrl, adWrapperIds) { + // When the video is cached, the ad tag loaded into the player is a parent wrapper of the cache url. + // As a result, the ad tag Url cannot include identifiers. + return superGetBidIdentifiers(adId, null, adWrapperIds); + } + + return verifier; + + function getTrackingUrl(getUrl, bid) { + if (!getUrl || typeof getUrl !== 'function') { + return; + } + + return getUrl(bid); + } +} + +export function baseImpressionVerifier(bidTracker_) { + const bidTracker = bidTracker_; + + function trackBid(bid) { + let { adId, adUnitCode, requestId, auctionId } = bid; + const trackingId = PB_PREFIX + generateUUID(10 ** 13); + bidTracker.store(trackingId, { adId, adUnitCode, requestId, auctionId }); + return trackingId; + } + + function getBidIdentifiers(adId, adTagUrl, adWrapperIds) { + return bidTracker.remove(adId) || getBidForAdTagUrl(adTagUrl) || getBidForAdWrappers(adWrapperIds); + } + + return { + trackBid, + getBidIdentifiers + }; + + function getBidForAdTagUrl(adTagUrl) { + if (!adTagUrl) { + return; + } + + const url = new URL(adTagUrl); + const queryParams = url.searchParams; + let uuid = queryParams.get(UUID_MARKER); + return uuid && bidTracker.remove(uuid); + } + + function getBidForAdWrappers(adWrapperIds) { + if (!adWrapperIds || !adWrapperIds.length) { + return; + } + + for (const wrapperId in adWrapperIds) { + const bidInfo = bidTracker.remove(wrapperId); + if (bidInfo) { + return bidInfo; + } + } + } +} + +export function tracker() { + const model = {}; + + function store(key, value) { + model[key] = value; + } + + function remove(key) { + const value = model[key]; + if (!value) { + return; + } + + delete model[key]; + return value; + } + + return { + store, + remove + } +} diff --git a/modules/.submodules.json b/modules/.submodules.json index d808b10051b..7db557706c6 100644 --- a/modules/.submodules.json +++ b/modules/.submodules.json @@ -82,6 +82,13 @@ "resetdigitalBidAdapter", "rtbhouseBidAdapter.js" ] + }, + "video": { + "files": [ "./index.js" ], + "dependants": [ + "jwplayerVideoProvider", + "videojsVideoProvider" + ] } } } diff --git a/modules/appnexusBidAdapter.js b/modules/appnexusBidAdapter.js index 9a2d3fa0a50..0ba2f533056 100644 --- a/modules/appnexusBidAdapter.js +++ b/modules/appnexusBidAdapter.js @@ -40,7 +40,7 @@ const URL = 'https://ib.adnxs.com/ut/v3/prebid'; const URL_SIMPLE = 'https://ib.adnxs-simple.com/ut/v3/prebid'; const VIDEO_TARGETING = ['id', 'minduration', 'maxduration', 'skippable', 'playback_method', 'frameworks', 'context', 'skipoffset']; -const VIDEO_RTB_TARGETING = ['minduration', 'maxduration', 'skip', 'skipafter', 'playbackmethod', 'api']; +const VIDEO_RTB_TARGETING = ['minduration', 'maxduration', 'skip', 'skipafter', 'playbackmethod', 'api', 'startdelay']; const USER_PARAMS = ['age', 'externalUid', 'segments', 'gender', 'dnt', 'language']; const APP_DEVICE_PARAMS = ['geo', 'device_id']; // appid is collected separately const DEBUG_PARAMS = ['enabled', 'dongle', 'member_id', 'debug_timeout']; @@ -949,6 +949,17 @@ function bidToTag(bid) { tag['video_frameworks'] = apiTmp; } break; + + case 'startdelay': + case 'placement': + const contextKey = 'context'; + if (typeof tag.video[contextKey] !== 'number') { + const placement = videoMediaType['placement']; + const startdelay = videoMediaType['startdelay']; + const context = getContextFromPlacement(placement) || getContextFromStartDelay(startdelay); + tag.video[contextKey] = VIDEO_MAPPING[contextKey][context]; + } + break; } }); } @@ -996,6 +1007,32 @@ function transformSizes(requestSizes) { return sizes; } +function getContextFromPlacement(ortbPlacement) { + if (!ortbPlacement) { + return; + } + + if (ortbPlacement === 2) { + return 'in-banner'; + } else if (ortbPlacement > 2) { + return 'outstream'; + } +} + +function getContextFromStartDelay(ortbStartDelay) { + if (!ortbStartDelay) { + return; + } + + if (ortbStartDelay === 0) { + return 'pre_roll'; + } else if (ortbStartDelay === -1) { + return 'mid_roll'; + } else if (ortbStartDelay === -2) { + return 'post_roll'; + } +} + function hasUserInfo(bid) { return !!bid.params.user; } diff --git a/modules/gridNMBidAdapter.js b/modules/gridNMBidAdapter.js index 63c42f60933..22fad3df1f8 100644 --- a/modules/gridNMBidAdapter.js +++ b/modules/gridNMBidAdapter.js @@ -1,4 +1,4 @@ -import { isStr, deepAccess, isArray, isNumber, logError, logWarn, parseGPTSingleSizeArrayToRtbSize } from '../src/utils.js'; +import { isStr, deepAccess, isArray, isNumber, logError, logWarn, parseGPTSingleSizeArrayToRtbSize, mergeDeep } from '../src/utils.js'; import { registerBidder } from '../src/adapters/bidderFactory.js'; import { Renderer } from '../src/Renderer.js'; import { VIDEO } from '../src/mediaTypes.js'; @@ -25,8 +25,6 @@ const LOG_ERROR_MESS = { hasNoArrayOfBids: 'Seatbid from response has no array of bid objects - ' }; -const VIDEO_KEYS = ['mimes', 'protocols', 'startdelay', 'placement', 'linearity', 'skip', 'skipmin', 'skipafter', 'sequence', 'battr', 'maxextended', 'minbitrate', 'maxbitrate', 'boxingallowed', 'playbackmethod', 'playbackend', 'delivery', 'pos', 'companionad', 'api', 'companiontype']; - export const spec = { code: BIDDER_CODE, supportedMediaTypes: [ VIDEO ], @@ -96,7 +94,7 @@ export const spec = { const impObj = { id: bidId.toString(), tagid: secid.toString(), - video: createVideoForImp(video, sizes, mediaTypes && mediaTypes.video), + video: createVideoForImp(mergeDeep({}, video, mediaTypes && mediaTypes.video), sizes), ext: { divid: adUnitCode.toString() } @@ -361,13 +359,7 @@ function createRenderer (bid, rendererParams) { return renderer; } -function createVideoForImp({ mind, maxd, size, ...paramsVideo }, bidSizes, bidVideo = {}) { - VIDEO_KEYS.forEach((key) => { - if (!(key in paramsVideo) && key in bidVideo) { - paramsVideo[key] = bidVideo[key]; - } - }); - +function createVideoForImp({ mind, maxd, size, ...paramsVideo }, bidSizes) { if (size && isStr(size)) { const sizeArray = size.split('x'); if (sizeArray.length === 2 && parseInt(sizeArray[0]) && parseInt(sizeArray[1])) { @@ -377,7 +369,7 @@ function createVideoForImp({ mind, maxd, size, ...paramsVideo }, bidSizes, bidVi } if (!paramsVideo.w || !paramsVideo.h) { - const playerSizes = bidVideo.playerSize && bidVideo.playerSize.length === 2 ? bidVideo.playerSize : bidSizes; + const playerSizes = paramsVideo.playerSize && paramsVideo.playerSize.length === 2 ? paramsVideo.playerSize : bidSizes; if (playerSizes) { const playerSize = playerSizes[0]; if (playerSize) { @@ -386,9 +378,13 @@ function createVideoForImp({ mind, maxd, size, ...paramsVideo }, bidSizes, bidVi } } - const durationRangeSec = bidVideo.durationRangeSec || []; - const minDur = mind || durationRangeSec[0] || bidVideo.minduration; - const maxDur = maxd || durationRangeSec[1] || bidVideo.maxduration; + if (paramsVideo.playerSize) { + delete paramsVideo.playerSize; + } + + const durationRangeSec = paramsVideo.durationRangeSec || []; + const minDur = mind || durationRangeSec[0] || paramsVideo.minduration; + const maxDur = maxd || durationRangeSec[1] || paramsVideo.maxduration; if (minDur) { paramsVideo.minduration = minDur; diff --git a/modules/jwplayerVideoProvider.js b/modules/jwplayerVideoProvider.js new file mode 100644 index 00000000000..b302884729e --- /dev/null +++ b/modules/jwplayerVideoProvider.js @@ -0,0 +1,1059 @@ +import { + PROTOCOLS, API_FRAMEWORKS, VIDEO_MIME_TYPE, PLAYBACK_METHODS, PLACEMENT, VPAID_MIME_TYPE, AD_POSITION +} from '../libraries/video/constants/ortb.js'; +import { + SETUP_COMPLETE, SETUP_FAILED, DESTROYED, AD_REQUEST, AD_BREAK_START, AD_LOADED, AD_STARTED, AD_IMPRESSION, AD_PLAY, + AD_TIME, AD_PAUSE, AD_CLICK, AD_SKIPPED, AD_ERROR, AD_COMPLETE, AD_BREAK_END, PLAYLIST, PLAYBACK_REQUEST, + AUTOSTART_BLOCKED, PLAY_ATTEMPT_FAILED, CONTENT_LOADED, PLAY, PAUSE, BUFFER, TIME, SEEK_START, SEEK_END, MUTE, VOLUME, + RENDITION_UPDATE, ERROR, COMPLETE, PLAYLIST_COMPLETE, FULLSCREEN, PLAYER_RESIZE, VIEWABLE, CAST +} from '../libraries/video/constants/events.js'; +import { PLAYBACK_MODE } from '../libraries/video/constants/enums.js'; +import stateFactory from '../libraries/video/shared/state.js'; +import { JWPLAYER_VENDOR } from '../libraries/video/constants/vendorCodes.js'; +import { submodule } from '../src/hook.js'; + +/** + * @constructor + * @param {videoProviderConfig} config + * @param {Object} jwplayer_ - JW Player global factory + * @param {State} adState_ + * @param {State} timeState_ + * @param {CallbackStorage} callbackStorage_ + * @param {Object} utils + * @returns {VideoProvider} + */ +export function JWPlayerProvider(config, jwplayer_, adState_, timeState_, callbackStorage_, utils, sharedUtils) { + const jwplayer = jwplayer_; + let player = null; + let playerVersion = null; + const playerConfig = config.playerConfig; + const divId = config.divId; + let adState = adState_; + let timeState = timeState_; + let callbackStorage = callbackStorage_; + let pendingSeek = {}; + let supportedMediaTypes = null; + let minimumSupportedPlayerVersion = '8.20.1'; + let setupCompleteCallback = null; + let setupFailedCallback = null; + const MEDIA_TYPES = [ + VIDEO_MIME_TYPE.MP4, + VIDEO_MIME_TYPE.OGG, + VIDEO_MIME_TYPE.WEBM, + VIDEO_MIME_TYPE.AAC, + VIDEO_MIME_TYPE.HLS + ]; + + function init() { + if (!jwplayer) { + triggerSetupFailure(-1); // TODO: come up with code for player absent + return; + } + + playerVersion = jwplayer.version; + + if (playerVersion < minimumSupportedPlayerVersion) { + triggerSetupFailure(-2); // TODO: come up with code for version not supported + return; + } + + player = jwplayer(divId); + if (player.getState() === undefined) { + setupPlayer(playerConfig); + } else { + setupCompleteCallback && setupCompleteCallback(SETUP_COMPLETE, getSetupCompletePayload()); + } + } + + function getId() { + return divId; + } + + function getOrtbParams() { + if (!player) { + return; + } + const config = player.getConfig() || {}; + const adConfig = config.advertising || {}; + supportedMediaTypes = supportedMediaTypes || utils.getSupportedMediaTypes(MEDIA_TYPES); + + const video = { + mimes: supportedMediaTypes, + protocols: [ + PROTOCOLS.VAST_2_0, + PROTOCOLS.VAST_3_0, + PROTOCOLS.VAST_4_0, + PROTOCOLS.VAST_2_0_WRAPPER, + PROTOCOLS.VAST_3_0_WRAPPER, + PROTOCOLS.VAST_4_0_WRAPPER + ], + h: player.getHeight(), // TODO does player call need optimization ? + w: player.getWidth(), // TODO does player call need optimization ? + startdelay: utils.getStartDelay(), + placement: utils.getPlacement(adConfig, player), + // linearity is omitted because both forms are supported. + // sequence - TODO not yet supported + battr: adConfig.battr, + maxextended: -1, // extension is allowed, and there is no time limit imposed. + boxingallowed: 1, + playbackmethod: [ utils.getPlaybackMethod(config) ], + playbackend: 1, // TODO: need to account for floating player - https://developer.jwplayer.com/jwplayer/docs/jw8-embed-an-outstream-player , https://developer.jwplayer.com/jwplayer/docs/jw8-player-configuration-reference#section-float-on-scroll + // companionad - TODO add in future version + // companiontype - TODO add in future version + // minbitrate - TODO add in future version + // maxbitrate - TODO add in future version + // delivery - omitted because all are supported. + // minduration - Is there value to specifying ? + // maxduration - Is there value to specifying ? + api: [ + API_FRAMEWORKS.VPAID_2_0 + ], + }; + + if (utils.isOmidSupported(adConfig.adClient)) { + video.api.push(API_FRAMEWORKS.OMID_1_0); + } + + Object.assign(video, utils.getSkipParams(adConfig)); + + if (player.getFullscreen()) { // TODO does player call need optimization ? + // only specify ad position when in Fullscreen since computational cost is low + // ad position options are listed in oRTB 2.5 section 5.4 + // https://www.iab.com/wp-content/uploads/2016/03/OpenRTB-API-Specification-Version-2-5-FINAL.pdf + video.pos = AD_POSITION.FULL_SCREEN; // TODO make constant in oRTB + } + + const item = player.getPlaylistItem() || {}; // TODO does player call need optimization ? + let { duration, playbackMode } = timeState.getState(); + if (duration === undefined) { + duration = player.getDuration(); + } + + const content = { + url: item.file, + title: item.title, + cat: item.iabCategories, + keywords: item.tags, + len: duration, + livestream: Math.min(playbackMode, 1), + embeddable: 1 + }; + + const mediaId = item.mediaid; + if (mediaId) { + content.id = 'jw_' + mediaId; + } + + const jwpseg = item.jwpseg; + const dataSegment = utils.getSegments(jwpseg); + const contentDatum = utils.getContentDatum(mediaId, dataSegment); + if (contentDatum) { + content.data = [contentDatum]; + } + + const isoLanguageCode = utils.getIsoLanguageCode(player); + if (isoLanguageCode) { + content.language = isoLanguageCode; + } + + return { + video, + content + } + } + + function setAdTagUrl(adTagUrl, options) { + if (!player || player.getPlugin('bidding') || player.getPlugin('biddingCore')) { + return; + } + + player.playAd(adTagUrl || options.adXml, options); + } + + function onEvents(events, callback) { + if (!callback) { + return; + } + + for (let i = 0; i < events.length; i++) { + const type = events[i]; + let payload = { + divId, + type + }; + + registerPreSetupListeners(type, callback, payload); + if (!player) { + return; + } + + registerPostSetupListeners(type, callback, payload); + } + } + + function offEvents(events, callback) { + events.forEach(event => { + const jwEvent = utils.getJwEvent(event); + if (!callback) { + player.off(jwEvent); + return; + } + + const eventHandler = callbackStorage.getCallback(event, callback); + if (!eventHandler) { + // skip this iteration when event handler not found. + return; + } + + player.off(jwEvent, eventHandler); + }); + } + + function destroy() { + if (!player) { + return; + } + player.remove(); + player = null; + } + + return { + init, + getId, + getOrtbParams, + setAdTagUrl, + onEvents, + offEvents, + destroy + }; + + function setupPlayer(config) { + if (!config) { + return; + } + player.setup(utils.getJwConfig(config)); + } + + function getSetupCompletePayload() { + return { + divId, + playerVersion, + type: SETUP_COMPLETE, + viewable: player.getViewable(), + viewabilityPercentage: player.getPercentViewable() * 100, + mute: player.getMute(), + volumePercentage: player.getVolume() + }; + } + + function triggerSetupFailure(errorCode) { + if (!setupFailedCallback) { + return; + } + + const payload = { + divId, + playerVersion, + type: SETUP_FAILED, + errorCode, + errorMessage: '', + sourceError: null + }; + setupFailedCallback(SETUP_FAILED, payload); + } + + function registerPreSetupListeners(type, callback, payload) { + let eventHandler; + + switch (type) { + case SETUP_COMPLETE: + setupCompleteCallback = callback; + eventHandler = () => { + payload = getSetupCompletePayload(); + callback(type, payload); + setupCompleteCallback = null; + }; + player && player.on('ready', eventHandler); + break; + + case SETUP_FAILED: + setupFailedCallback = callback; + eventHandler = e => { + Object.assign(payload, { + playerVersion, + errorCode: e.code, + errorMessage: e.message, + sourceError: e.sourceError + }); + callback(type, payload); + setupFailedCallback = null; + }; + player && player.on('setupError', eventHandler); + break; + + default: + return; + } + callbackStorage.storeCallback(type, eventHandler, callback); + } + + function registerPostSetupListeners(type, callback, payload) { + let eventHandler; + + switch (type) { + case DESTROYED: + eventHandler = () => { + callback(type, payload); + }; + player.on('remove', eventHandler); + break; + + case AD_REQUEST: + eventHandler = e => { + payload.adTagUrl = e.tag; + callback(type, payload); + }; + player.on(AD_REQUEST, eventHandler); + break; + + case AD_BREAK_START: + eventHandler = e => { + timeState.clearState(); + payload.offset = e.adPosition; + callback(type, payload); + }; + player.on(AD_BREAK_START, eventHandler); + break; + + case AD_LOADED: + eventHandler = e => { + adState.updateForEvent(e); + const adConfig = player.getConfig().advertising; + adState.updateState(utils.getSkipParams(adConfig)); + Object.assign(payload, adState.getState()); + callback(type, payload); + }; + player.on(AD_LOADED, eventHandler); + break; + + case AD_STARTED: + eventHandler = () => { + Object.assign(payload, adState.getState()); + callback(type, payload); + }; + // JW Player adImpression fires when the ad starts, regardless of viewability. + player.on(AD_IMPRESSION, eventHandler); + break; + + case AD_IMPRESSION: + eventHandler = () => { + Object.assign(payload, adState.getState(), timeState.getState()); + callback(type, payload); + }; + player.on('adViewableImpression', eventHandler); + break; + + case AD_PLAY: + eventHandler = e => { + payload.adTagUrl = e.tag; + callback(type, payload); + }; + player.on(AD_PLAY, eventHandler); + break; + + case AD_TIME: + eventHandler = e => { + timeState.updateForEvent(e); + Object.assign(payload, { + adTagUrl: e.tag, + time: e.position, + duration: e.duration, + }); + callback(type, payload); + }; + player.on(AD_TIME, eventHandler); + break; + + case AD_PAUSE: + eventHandler = e => { + payload.adTagUrl = e.tag; + callback(type, payload); + }; + player.on(AD_PAUSE, eventHandler); + break; + + case AD_CLICK: + eventHandler = () => { + Object.assign(payload, adState.getState(), timeState.getState()); + callback(type, payload); + }; + player.on(AD_CLICK, eventHandler); + break; + + case AD_SKIPPED: + eventHandler = e => { + Object.assign(payload, { + time: e.position, + duration: e.duration, + }); + callback(type, payload); + adState.clearState(); + }; + player.on(AD_SKIPPED, eventHandler); + break; + + case AD_ERROR: + eventHandler = e => { + Object.assign(payload, { + playerErrorCode: e.adErrorCode, + vastErrorCode: e.code, + errorMessage: e.message, + sourceError: e.sourceError + // timeout + }, adState.getState(), timeState.getState()); + adState.clearState(); + callback(type, payload); + }; + player.on(AD_ERROR, eventHandler); + break; + + case AD_COMPLETE: + eventHandler = e => { + payload.adTagUrl = e.tag; + callback(type, payload); + adState.clearState(); + }; + player.on(AD_COMPLETE, eventHandler); + break; + + case AD_BREAK_END: + eventHandler = e => { + payload.offset = e.adPosition; + callback(type, payload); + }; + player.on(AD_BREAK_END, eventHandler); + break; + + case PLAYLIST: + eventHandler = e => { + const playlistItemCount = e.playlist.length; + Object.assign(payload, { + playlistItemCount, + autostart: player.getConfig().autostart + }); + callback(type, payload); + }; + player.on(PLAYLIST, eventHandler); + break; + + case PLAYBACK_REQUEST: + eventHandler = e => { + payload.playReason = e.playReason; + callback(type, payload); + }; + player.on('playAttempt', eventHandler); + break; + + case AUTOSTART_BLOCKED: + eventHandler = e => { + Object.assign(payload, { + sourceError: e.error, + errorCode: e.code, + errorMessage: e.message + }); + callback(type, payload); + }; + player.on('autostartNotAllowed', eventHandler); + break; + + case PLAY_ATTEMPT_FAILED: + eventHandler = e => { + Object.assign(payload, { + playReason: e.playReason, + sourceError: e.sourceError, + errorCode: e.code, + errorMessage: e.message + }); + callback(type, payload); + }; + player.on(PLAY_ATTEMPT_FAILED, eventHandler); + break; + + case CONTENT_LOADED: + eventHandler = e => { + const { item, index } = e; + Object.assign(payload, { + contentId: item.mediaid, + contentUrl: item.file, // cover other sources ? util ? + title: item.title, + description: item.description, + playlistIndex: index, + contentTags: item.tags + }); + callback(type, payload); + }; + player.on('playlistItem', eventHandler); + break; + + case PLAY: + eventHandler = () => { + callback(type, payload); + }; + player.on(PLAY, eventHandler); + break; + + case PAUSE: + eventHandler = () => { + callback(type, payload); + }; + player.on(PAUSE, eventHandler); + break; + + case BUFFER: + eventHandler = () => { + Object.assign(payload, timeState.getState()); + callback(type, payload); + }; + player.on(BUFFER, eventHandler); + break; + + case TIME: + eventHandler = e => { + timeState.updateForEvent(e); + Object.assign(payload, { + position: e.position, + duration: e.duration + }); + callback(type, payload); + }; + player.on(TIME, eventHandler); + break; + + case SEEK_START: + eventHandler = e => { + const duration = e.duration; + const offset = e.offset; + pendingSeek = { + duration, + offset + }; + Object.assign(payload, { + position: e.position, + destination: offset, + duration: duration + }); + callback(type, payload); + } + player.on('seek', eventHandler); + break; + + case SEEK_END: + eventHandler = () => { + Object.assign(payload, { + position: pendingSeek.offset, + duration: pendingSeek.duration + }); + callback(type, payload); + pendingSeek = {}; + }; + player.on('seeked', eventHandler); + break; + + case MUTE: + eventHandler = e => { + payload.mute = e.mute; + callback(type, payload); + }; + player.on(MUTE, eventHandler); + break; + + case VOLUME: + eventHandler = e => { + payload.volumePercentage = e.volume; + callback(type, payload); + }; + player.on(VOLUME, eventHandler); + break; + + case RENDITION_UPDATE: + eventHandler = e => { + const bitrate = e.bitrate; + const level = e.level; + Object.assign(payload, { + videoReportedBitrate: bitrate, + audioReportedBitrate: bitrate, + encodedVideoWidth: level.width, + encodedVideoHeight: level.height, + videoFramerate: e.frameRate + }); + callback(type, payload); + }; + player.on('visualQuality', eventHandler); + break; + + case ERROR: + eventHandler = e => { + Object.assign(payload, { + sourceError: e.sourceError, + errorCode: e.code, + errorMessage: e.message, + }); + callback(type, payload); + }; + player.on(ERROR, eventHandler); + break; + + case COMPLETE: + eventHandler = e => { + callback(type, payload); + timeState.clearState(); + }; + player.on(COMPLETE, eventHandler); + break; + + case PLAYLIST_COMPLETE: + eventHandler = () => { + callback(type, payload); + }; + player.on(PLAYLIST_COMPLETE, eventHandler); + break; + + case FULLSCREEN: + eventHandler = e => { + payload.fullscreen = e.fullscreen; + callback(type, payload); + }; + player.on(FULLSCREEN, eventHandler); + break; + + case PLAYER_RESIZE: + eventHandler = e => { + Object.assign(payload, { + height: e.height, + width: e.width, + }); + callback(type, payload); + }; + player.on('resize', eventHandler); + break; + + case VIEWABLE: + eventHandler = e => { + Object.assign(payload, { + viewable: e.viewable, + viewabilityPercentage: player.getPercentViewable() * 100, + }); + callback(type, payload); + }; + player.on(VIEWABLE, eventHandler); + break; + + case CAST: + eventHandler = e => { + payload.casting = e.active; + callback(type, payload); + }; + player.on(CAST, eventHandler); + break; + + default: + return; + } + callbackStorage.storeCallback(type, eventHandler, callback); + } +} + +/** + * @param {videoProviderConfig} config + * @param {sharedUtils} sharedUtils + * @returns {VideoProvider} + */ +const jwplayerSubmoduleFactory = function (config, sharedUtils) { + const adState = adStateFactory(); + const timeState = timeStateFactory(); + const callbackStorage = callbackStorageFactory(); + return JWPlayerProvider(config, window.jwplayer, adState, timeState, callbackStorage, utils, sharedUtils); +} + +jwplayerSubmoduleFactory.vendorCode = JWPLAYER_VENDOR; +submodule('video', jwplayerSubmoduleFactory); +export default jwplayerSubmoduleFactory; + +// HELPERS + +export const utils = { + getJwConfig: function(config) { + if (!config) { + return; + } + + const params = config.params || {}; + const jwConfig = params.vendorConfig || {}; + if (jwConfig.autostart === undefined && config.autoStart !== undefined) { + jwConfig.autostart = config.autoStart; + } + + if (jwConfig.mute === undefined && config.mute !== undefined) { + jwConfig.mute = config.mute; + } + + if (!jwConfig.key && config.licenseKey !== undefined) { + jwConfig.key = config.licenseKey; + } + + if (params.adOptimization === false) { + return jwConfig; + } + + const advertising = jwConfig.advertising || { client: 'vast' }; + if (!jwConfig.file && !jwConfig.playlist && !jwConfig.source) { + // TODO verify accuracy + advertising.outstream = true; + } + + const bids = advertising.bids || {}; + bids.prebid = true; + advertising.bids = bids; + + jwConfig.advertising = advertising; + return jwConfig; + }, + + getJwEvent: function(eventName) { + switch (eventName) { + case SETUP_COMPLETE: + return 'ready'; + + case SETUP_FAILED: + return 'setupError'; + + case DESTROYED: + return 'remove'; + + case AD_STARTED: + return AD_IMPRESSION; + + case AD_IMPRESSION: + return 'adViewableImpression'; + + case PLAYBACK_REQUEST: + return 'playAttempt'; + + case AUTOSTART_BLOCKED: + return 'autostartNotAllowed'; + + case CONTENT_LOADED: + return 'playlistItem'; + + case SEEK_START: + return 'seek'; + + case SEEK_END: + return 'seeked'; + + case RENDITION_UPDATE: + return 'visualQuality'; + + case PLAYER_RESIZE: + return 'resize'; + + default: + return eventName; + } + }, + + getSkipParams: function(adConfig) { + const skipParams = {}; + const skipoffset = adConfig.skipoffset; + if (skipoffset !== undefined) { + const skippable = skipoffset >= 0; + skipParams.skip = skippable ? 1 : 0; + if (skippable) { + skipParams.skipmin = skipoffset + 2; + skipParams.skipafter = skipoffset; + } + } + return skipParams; + }, + + getSupportedMediaTypes: function(mediaTypes = []) { + const el = document.createElement('video'); + return mediaTypes + .filter(mediaType => el.canPlayType(mediaType)) + .concat(VPAID_MIME_TYPE); // Always allow VPAIDs. + }, + + getStartDelay: function() { + // todo calculate + // need to know which ad we are bidding on + // Might have to implement and set in Pb-video ; would required ad unit as param. + }, + + /** + * Determine the ad placement + * @param {Object} adConfig + * @param {Object} player + * @return {PLACEMENT|OrtbVideoParams.placement|undefined} + */ + getPlacement: function(adConfig, player) { + if (!adConfig.outstream) { + // https://developer.jwplayer.com/jwplayer/docs/jw8-embed-an-outstream-player for more info on outstream + return PLACEMENT.INSTREAM; + } + + if (player.getFloating()) { + return PLACEMENT.FLOATING; + } + + const placement = adConfig.placement; + if (!placement) { + return; + } + + return PLACEMENT[placement.toUpperCase()]; + }, + + getPlaybackMethod: function({ autoplay, mute, autoplayAdsMuted }) { + if (autoplay) { + // Determine whether player is going to start muted. + const isMuted = mute || autoplayAdsMuted; // todo autoplayAdsMuted only applies to preRoll + return isMuted ? PLAYBACK_METHODS.AUTOPLAY_MUTED : PLAYBACK_METHODS.AUTOPLAY; + } + /* + TODO + could support the following with float player: + 5 Initiates on Entering Viewport with Sound On + 6 Initiates on Entering Viewport with Sound Off by Default + */ + return PLAYBACK_METHODS.CLICK_TO_PLAY; + }, + + /** + * Indicates if Omid is supported + * + * @param {string} adClient - The identifier of the ad plugin requesting the bid + * @returns {boolean} - support of omid + */ + isOmidSupported: function(adClient) { + const omidIsLoaded = window.OmidSessionClient !== undefined; + return omidIsLoaded && adClient === 'vast'; + }, + + /** + * Gets ISO 639 language code of current audio track. + * @param {Object} player + * @returns {string|undefined} ISO 639 language code. + */ + getIsoLanguageCode: function(player) { + const audioTracks = player.getAudioTracks(); + if (!audioTracks || !audioTracks.length) { + return; + } + + const currentTrackIndex = Math.max(player.getCurrentAudioTrack() || 0, 0); // returns -1 when there are no alternative tracks. + const audioTrack = audioTracks[currentTrackIndex]; + return audioTrack && audioTrack.language; + }, + + /** + * Converts an array of jwpsegs into an array of data segments compliant with the oRTB content.data[index].segment + * @param {[String]} jwpsegs - jwplayer contextual targeting segments + * @return {[Object]|undefined} list of data segments compliant with the oRTB content.data[index].segment spec + */ + getSegments: function (jwpsegs) { + if (!jwpsegs || !jwpsegs.length) { + return; + } + + const formattedSegments = jwpsegs.reduce((convertedSegments, rawSegment) => { + convertedSegments.push({ + id: rawSegment, + value: rawSegment + }); + return convertedSegments; + }, []); + + return formattedSegments; + }, + + /** + * Creates an object compliant with the oRTB content.data[index] spec. + * @param {String} mediaId - content identifier + * @param {[Object]} segments - list of data segments compliant with the oRTB content.data[index].segment spec + * @return {Object} - Object compliant with the oRTB content.data[index] spec. + */ + getContentDatum: function (mediaId, segments) { + if (!mediaId && !segments) { + return; + } + + const contentData = { + name: 'jwplayer.com', + ext: {} + }; + + if (mediaId) { + contentData.ext.cids = [mediaId]; + } + + if (segments) { + contentData.segment = segments; + contentData.ext.segtax = 502; + } + + return contentData; + } +} + +/** + * Tracks which functions are attached to events + * @typedef CallbackStorage + * @function storeCallback + * @function getCallback + * @function clearStorage + */ + +/** + * @returns {CallbackStorage} + */ +export function callbackStorageFactory() { + let storage = {}; + + function storeCallback(eventType, eventHandler, callback) { + let eventHandlers = storage[eventType]; + if (!eventHandlers) { + eventHandlers = storage[eventType] = {}; + } + + eventHandlers[callback] = eventHandler; + } + + function getCallback(eventType, callback) { + let eventHandlers = storage[eventType]; + if (!eventHandlers) { + return; + } + + const eventHandler = eventHandlers[callback]; + delete eventHandlers[callback]; + return eventHandler; + } + + function clearStorage() { + storage = {}; + } + + return { + storeCallback, + getCallback, + clearStorage + } +} + +// STATE + +/** + * @returns {State} + */ +export function adStateFactory() { + const adState = Object.assign({}, stateFactory()); + + function updateForEvent(event) { + const updates = { + adTagUrl: event.tag, + offset: event.adPosition, + loadTime: event.timeLoading, + vastAdId: event.id, + adDescription: event.description, + adServer: event.adsystem, + adTitle: event.adtitle, + advertiserId: event.advertiserId, + advertiserName: event.advertiser, + dealId: event.dealId, + // adCategories + linear: event.linear, + vastVersion: event.vastversion, + // campaignId: + creativeUrl: event.mediaFile, // TODO: per AP, mediafile might be object w/ file property. verify + adId: event.adId, + universalAdId: event.universalAdId, + creativeId: event.creativeAdId, + creativeType: event.creativetype, + redirectUrl: event.clickThroughUrl, + adPlacementType: convertPlacementToOrtbCode(event.placement), + waterfallIndex: event.witem, + waterfallCount: event.wcount, + adPodCount: event.podcount, + adPodIndex: event.sequence, + wrapperAdIds: event.wrapperAdIds + }; + + if (event.client === 'googima' && !updates.wrapperAdIds) { + updates.wrapperAdIds = parseImaAdWrapperIds(event); + } + + this.updateState(updates); + } + + adState.updateForEvent = updateForEvent; + + function convertPlacementToOrtbCode(placement) { + switch (placement) { + case 'instream': + return PLACEMENT.INSTREAM; + + case 'banner': + return PLACEMENT.BANNER; + + case 'article': + return PLACEMENT.ARTICLE; + + case 'feed': + return PLACEMENT.FEED; + + case 'interstitial': + case 'slider': + case 'floating': + return PLACEMENT.INTERSTITIAL_SLIDER_FLOATING; + } + } + + function parseImaAdWrapperIds(adEvent) { + const ima = adEvent.ima; + const ad = ima && ima.ad; + const h = ad && ad.h; + return h && h.adWrapperIds; + } + + return adState; +} + +/** + * @returns {State} + */ +export function timeStateFactory() { + const timeState = Object.assign({}, stateFactory()); + + function updateForEvent(event) { + const { position, duration } = event; + this.updateState({ + time: position, + duration, + playbackMode: getPlaybackMode(duration) + }); + } + + timeState.updateForEvent = updateForEvent; + + function getPlaybackMode(duration) { + if (duration > 0) { + return PLAYBACK_MODE.VOD; + } else if (duration < 0) { + return PLAYBACK_MODE.DVR; + } + + return PLAYBACK_MODE.LIVE; + } + + return timeState; +} diff --git a/modules/spotxBidAdapter.js b/modules/spotxBidAdapter.js index 86a37e97e2f..0f5daed7f74 100644 --- a/modules/spotxBidAdapter.js +++ b/modules/spotxBidAdapter.js @@ -148,7 +148,7 @@ export const spec = { } } - const mimes = getBidIdParameter('mimes', bid.params) || ['application/javascript', 'video/mp4', 'video/webm']; + const mimes = deepAccess(bid, 'mediaTypes.video.mimes') || getBidIdParameter('mimes', bid.params) || ['application/javascript', 'video/mp4', 'video/webm']; const spotxReq = { id: bid.bidId, @@ -175,28 +175,29 @@ export const spec = { spotxReq.bidfloor = getBidIdParameter('price_floor', bid.params); } - if (getBidIdParameter('start_delay', bid.params) != '') { - spotxReq.video.startdelay = 0 + Boolean(getBidIdParameter('start_delay', bid.params)); + const startdelay = deepAccess(bid, 'mediaTypes.video.startdelay') || getBidIdParameter('start_delay', bid.params); + if (startdelay != '') { + spotxReq.video.startdelay = 0 + Boolean(startdelay); } - if (getBidIdParameter('min_duration', bid.params) != '') { - spotxReq.video.minduration = getBidIdParameter('min_duration', bid.params); + const minduration = deepAccess(bid, 'mediaTypes.video.minduration') || getBidIdParameter('min_duration', bid.params); + if (minduration != '') { + spotxReq.video.minduration = minduration; } - if (getBidIdParameter('max_duration', bid.params) != '') { - spotxReq.video.maxduration = getBidIdParameter('max_duration', bid.params); + const maxduration = deepAccess(bid, 'mediaTypes.video.maxduration') || getBidIdParameter('max_duration', bid.params); + if (maxduration != '') { + spotxReq.video.maxduration = maxduration; } - if (getBidIdParameter('placement_type', bid.params) != '') { - spotxReq.video.ext.placement = getBidIdParameter('placement_type', bid.params); + const placement = deepAccess(bid, 'mediaTypes.video.placement') || getBidIdParameter('placement_type', bid.params); + if (placement != '') { + spotxReq.video.ext.placement = placement; } - if (getBidIdParameter('position', bid.params) != '') { - spotxReq.video.ext.pos = getBidIdParameter('position', bid.params); - } else { - if (deepAccess(bid, 'mediaTypes.video.pos')) { - spotxReq.video.ext.pos = deepAccess(bid, 'mediaTypes.video.pos'); - } + const position = deepAccess(bid, 'mediaTypes.video.pos') || getBidIdParameter('position', bid.params); + if (position != '') { + spotxReq.video.ext.pos = position; } if (bid.crumbs && bid.crumbs.pubcid) { diff --git a/modules/synacormediaBidAdapter.js b/modules/synacormediaBidAdapter.js index a48c1aaf55b..1b034790edf 100644 --- a/modules/synacormediaBidAdapter.js +++ b/modules/synacormediaBidAdapter.js @@ -71,7 +71,7 @@ export const spec = { seatId = bid.params.seatId; } const tagIdOrPlacementId = bid.params.tagId || bid.params.placementId; - let pos = parseInt(bid.params.pos, 10); + let pos = parseInt(deepAccess(bid.mediaTypes, 'video.pos') || bid.params.pos, 10); if (isNaN(pos)) { logWarn(`Synacormedia: there is an invalid POS: ${bid.params.pos}`); pos = 0; diff --git a/modules/videoModule/constants/ortb.js b/modules/videoModule/constants/ortb.js new file mode 100644 index 00000000000..82bee1e9159 --- /dev/null +++ b/modules/videoModule/constants/ortb.js @@ -0,0 +1,88 @@ + +const VIDEO_PREFIX = 'video/' + +/* +ORTB 2.5 section 3.2.7 - Video.mimes + */ +export const VIDEO_MIME_TYPE = { + MP4: VIDEO_PREFIX + 'mp4', + MPEG: VIDEO_PREFIX + 'mpeg', + OGG: VIDEO_PREFIX + 'ogg', + WEBM: VIDEO_PREFIX + 'webm', + AAC: VIDEO_PREFIX + 'aac', + HLS: 'application/vnd.apple.mpegurl' +}; + +export const JS_APP_MIME_TYPE = 'application/javascript'; +export const VPAID_MIME_TYPE = JS_APP_MIME_TYPE; + +/* +ORTB 2.5 section 5.9 - Video Placement Types + */ +export const PLACEMENT = { + IN_STREAM: 1, + BANNER: 2, + ARTICLE: 3, + FEED: 4, + INTERSTITIAL: 5, + SLIDER: 5, + FLOATING: 5, + INTERSTITIAL_SLIDER_FLOATING: 5 +}; + +/* +ORTB 2.5 section 5.4 - Ad Position + */ +export const AD_POSITION = { + UNKNOWN: 0, + ABOVE_THE_FOLD: 1, + BELOW_THE_FOLD: 3, + HEADER: 4, + FOOTER: 5, + SIDEBAR: 6, + FULL_SCREEN: 7 +} + +/* +ORTB 2.5 section 5.11 - Playback Cessation Modes + */ +export const PLAYBACK_END = { + VIDEO_COMPLETION: 1, + VIEWPORT_LEAVE: 2, + FLOATING: 3 +} + +/* +ORTB 2.5 section 5.10 - Playback Methods + */ +export const PLAYBACK_METHODS = { + AUTOPLAY: 1, + AUTOPLAY_MUTED: 2, + CLICK_TO_PLAY: 3, + CLICK_TO_PLAY_MUTED: 4, + VIEWABLE: 5, + VIEWABLE_MUTED: 6 +}; + +/* +ORTB 2.5 section 5.8 - Protocols + */ +export const PROTOCOLS = { + // VAST_1_0: 1, + VAST_2_0: 2, + VAST_3_0: 3, + // VAST_1_O_WRAPPER: 4, + VAST_2_0_WRAPPER: 5, + VAST_3_0_WRAPPER: 6, + VAST_4_0: 7, + VAST_4_0_WRAPPER: 8 +}; + +/* +ORTB 2.5 section 5.6 - API Frameworks + */ +export const API_FRAMEWORKS = { + VPAID_1_0: 1, + VPAID_2_0: 2, + OMID_1_0: 7 +}; diff --git a/modules/videoModule/coreVideo.js b/modules/videoModule/coreVideo.js new file mode 100644 index 00000000000..36b0a90f4ff --- /dev/null +++ b/modules/videoModule/coreVideo.js @@ -0,0 +1,73 @@ +import { vendorDirectory } from './vendorDirectory.js'; + +export function VideoCore(submoduleBuilder_) { + const submodules = {}; + const submoduleBuilder = submoduleBuilder_; + + function registerProvider(providerConfig) { + const divId = providerConfig.divId; + if (submodules[divId]) { + return; + } + + let submodule; + try { + submodule = submoduleBuilder.build(providerConfig); + } catch (e) { + throw e; + } + submodules[divId] = submodule; + } + + function getOrtbParams(divId) { + const submodule = submodules[divId]; + return submodule && submodule.getOrtbParams(); + } + + function setAdTagUrl(adTagUrl, divId) { + const submodule = submodules[divId]; + return submodule && submodule.setAdTagUrl(adTagUrl); + } + + function onEvents(events, callback, divId) { + const submodule = submodules[divId]; + return submodule && submodule.onEvents(events, callback); + } + + function offEvents(events, callback, divId) { + const submodule = submodules[divId]; + return submodule && submodule.offEvents(events, callback); + } + + return { + registerProvider, + getOrtbParams, + setAdTagUrl, + onEvents, + offEvents + }; +} + +export function videoCoreFactory() { + const submoduleBuilder = VideoSubmoduleBuilder(vendorDirectory); + return VideoCore(submoduleBuilder); +} + +export function VideoSubmoduleBuilder(vendorDirectory_) { + const vendorDirectory = vendorDirectory_; + + function build(providerConfig) { + const submoduleFactory = vendorDirectory[providerConfig.vendorCode]; + if (!submoduleFactory) { + throw new Error('Unrecognized vendor code'); + } + + const submodule = submoduleFactory(providerConfig); + submodule && submodule.init && submodule.init(); + return submodule; + } + + return { + build + }; +} diff --git a/modules/videoModule/index.js b/modules/videoModule/index.js new file mode 100644 index 00000000000..73610eb7ed6 --- /dev/null +++ b/modules/videoModule/index.js @@ -0,0 +1,143 @@ +import { config } from '../../src/config.js'; +import events from '../../src/events.js'; +import { allVideoEvents } from './constants/events.js'; +import CONSTANTS from '../../src/constants.json'; +import { videoCoreFactory } from './coreVideo.js'; +import { coreAdServerFactory } from './adServer.js'; +import find from 'core-js-pure/features/array/find.js'; +import { vastXmlEditorFactory } from './shared/vastXmlEditor.js'; + +events.addEvents(allVideoEvents); + +export function PbVideo(videoCore_, getConfig_, pbGlobal_, pbEvents_, videoEvents_, adServerCore_, vastXmlEditor_) { + const videoCore = videoCore_; + const getConfig = getConfig_; + const pbGlobal = pbGlobal_; + const requestBids = pbGlobal.requestBids; + const pbEvents = pbEvents_; + const videoEvents = videoEvents_; + const adServerCore = adServerCore_; + const vastXmlEditor = vastXmlEditor_; + + function init() { + getConfig('video', ({ video }) => { + video.providers.forEach(provider => { + try { + videoCore.registerProvider(provider); + videoCore.onEvents(videoEvents, (type, payload) => { + pbEvents.emit(type, payload); + }, provider.divId); + } catch (e) {} + + const adServerConfig = provider.adServer; + if (adServerConfig) { + adServerCore.registerAdServer(adServerConfig.vendorCode, adServerConfig.params); + } + }); + }); + + requestBids.before(enrichAdUnits, 40); + + pbEvents.on(CONSTANTS.EVENTS.AUCTION_END, function(auctionResult) { + auctionResult.adUnits.forEach(adUnit => { + if (adUnit.video) { + renderWinningBid(adUnit); + } + }); + }); + + const cache = getConfig('cache'); + if (!cache) { + return; + } + + pbEvents.on(CONSTANTS.EVENTS.BID_ADJUSTMENT, function (bid) { + const adUnitCode = bid.adUnitCode; + const adUnit = find(pbGlobal.adUnits, adUnit => adUnitCode === adUnit.code); + const videoConfig = adUnit && adUnit.video; + const adServerConfig = videoConfig && videoConfig.adServer; + const trackingConfig = adServerConfig && adServerConfig.tracking; + addTrackingNodesToVastXml(bid, trackingConfig); + }); + } + + return { init }; + + function enrichAdUnits(nextFn, bidRequest) { + const adUnits = bidRequest.adUnits || pbGlobal.adUnits || []; + adUnits.forEach(adUnit => { + const oRtbParams = videoCore.getOrtbParams(adUnit.video.divId); + adUnit.mediaTypes.video = Object.assign({}, adUnit.mediaTypes.video, oRtbParams); + }); + return nextFn.call(this, bidRequest); + } + + function renderWinningBid(adUnit) { + const videoConfig = adUnit.video; + const divId = videoConfig.divId; + const adServerConfig = videoConfig.adServer; + let adTagUrl; + if (adServerConfig) { + adTagUrl = adServerCore.getAdTagUrl(adServerConfig.vendorCode, adUnit, adServerConfig.baseAdTagUrl); + } + + const adUnitCode = adUnit.code; + const options = { adUnitCode }; + if (adTagUrl) { + videoCore.setAdTagUrl(adTagUrl, divId, options); + return; + } + + const highestCpmBids = pbGlobal.getHighestCpmBids(adUnit.code); + const highestBid = highestCpmBids && highestCpmBids.shift(); + if (!highestBid) { + return; + } + + adTagUrl = highestBid.vastUrl; + options.adXml = highestBid.vastXml; + videoCore.setAdTagUrl(adTagUrl, divId, options); + } + + function addTrackingNodesToVastXml(bid, trackingConfig) { + if (!trackingConfig) { + return; + } + + let { vastXml, vastUrl, adId } = bid; + let impressionUrl; + let impressionId; + let errorUrl; + + const impressionTracking = trackingConfig.impression; + const errorTracking = trackingConfig.error; + + if (impressionTracking) { + impressionUrl = impressionTracking.url; + impressionId = impressionTracking.id || adId + '-impression'; + } + + if (errorTracking) { + errorUrl = errorTracking.url; + } + + if (vastXml) { + vastXml = vastXmlEditor.getVastXmlWithTrackingNodes(vastXml, impressionUrl, impressionId, errorUrl); + } else if (vastUrl) { + vastXml = vastXmlEditor.buildVastWrapper(adId, vastUrl, impressionUrl, impressionId, errorUrl); + } + + bid.vastXml = vastXml; + } +} + +export function pbVideoFactory() { + const videoCore = videoCoreFactory(); + const adServerCore = coreAdServerFactory(); + const vastXmlEditor = vastXmlEditorFactory(); + const pbVideo = PbVideo(videoCore, config.getConfig, $$PREBID_GLOBAL$$, events, allVideoEvents, adServerCore, vastXmlEditor); + pbVideo.init(); + return pbVideo; +} + +pbVideoFactory(); diff --git a/modules/videojsVideoProvider.js b/modules/videojsVideoProvider.js new file mode 100644 index 00000000000..e72fc54719c --- /dev/null +++ b/modules/videojsVideoProvider.js @@ -0,0 +1,566 @@ +import { + SETUP_COMPLETE, SETUP_FAILED, DESTROYED, AD_REQUEST, AD_BREAK_START, AD_LOADED, AD_STARTED, AD_IMPRESSION, AD_PLAY, + AD_TIME, AD_PAUSE, AD_CLICK, AD_SKIPPED, AD_ERROR, AD_COMPLETE, AD_BREAK_END, PLAYLIST, PLAYBACK_REQUEST, + AUTOSTART_BLOCKED, PLAY_ATTEMPT_FAILED, CONTENT_LOADED, PLAY, PAUSE, BUFFER, TIME, SEEK_START, SEEK_END, MUTE, VOLUME, + RENDITION_UPDATE, ERROR, COMPLETE, PLAYLIST_COMPLETE, FULLSCREEN, PLAYER_RESIZE, VIEWABLE, CAST, PLAYBACK_MODE +} from './videoModule/constants/events.js'; +import { + PROTOCOLS, API_FRAMEWORKS, VIDEO_MIME_TYPE, PLAYBACK_METHODS, PLACEMENT, VPAID_MIME_TYPE, AD_POSITION, PLAYBACK_END +} from './videoModule/constants/ortb.js'; +import { VIDEO_JS_VENDOR } from './videoModule/constants/vendorCodes.js'; +import { videoVendorDirectory } from './videoModule/vendorDirectory.js'; + +export function VideojsProvider(config, videojs_, adState_, timeState_, callbackStorage_, utils) { + let videojs = videojs_; + // Supplied callbacks are typically wrapped by handlers + // we use this dict to keep track of these pairings + const callback_to_handler = {}; + + let player = null; + let playerVersion = null; + let imaOptions = null; + const {playerConfig, divId} = config; + + let setupCompleteCallback, setupFailedCallback; + + // TODO: test with older videojs versions + let minimumSupportedPlayerVersion = '7.17.0'; + + function init() { + if (!videojs) { + triggerSetupFailure(-1, 'Videojs not present') + return; + } + playerVersion = videojs.VERSION; + + if (playerVersion < minimumSupportedPlayerVersion) { + triggerSetupFailure(-2, 'Videojs version not supported') + return; + } + + if (!document.getElementById(divId)) { + triggerSetupFailure(-3, `No div found with id ${divId}`) + return; + } + + player = videojs(divId) + + function adSetup(){ + // Todo: the linter doesn't like optional chaining is there a better way to do this + const vendorConfig = config.playerConfig && config.playerConfig.params && config.playerConfig.params.vendorConfig; + const tags = vendorConfig && vendorConfig.advertising && vendorConfig.advertising.tag; + if (player.ima && tags) { + imaOptions = { + adTagUrl: tags[0] + }; + player.ima(imaOptions); + } + } + + // Instantiate player if it does not exist + if(!player){ + // setupCompleteCallback should already be hooked to player.ready so no need to include it here + player = videojs(divId, playerConfig, adSetup) + setupFailedCallback && player.on('error', callback_to_handler[setupFailedCallback]) + setupCompleteCallback && player.on('ready', callback_to_handler[setupCompleteCallback]) + return + } + + setupCompleteCallback && setupCompleteCallback(SETUP_COMPLETE, getSetupCompletePayload()); + adSetup(); + + // TODO: make sure ortb gets called in integration example + // currently testing with a hacky solution by hooking it to window + // window.ortb = this.getOrtbParams + } + + function triggerSetupFailure(errorCode, msg) { + const payload = { + divId, + playerVersion, + type: SETUP_FAILED, + errorCode, + errorMessage: msg, + sourceError: null + }; + setupFailedCallback && setupFailedCallback(SETUP_FAILED, payload); + } + + function getId() { + return divId; + } + + function getOrtbParams() { + if (!player) { + return null; + } + + const content = { + // id:, TODO: find a suitable id for videojs sources + url: player.currentSrc() + }; + // Only include length if player is ready + // player.readyState() returns a level of readiness from 0 to 4 + // https://docs.videojs.com/player#readyState + if (player.readyState() > 0) { + content.len = Math.round(player.duration()); + } + const mediaItem = player.getMedia(); + if (mediaItem) { + for (let param of ['album', 'artist', 'title']) { + if (mediaItem[param]) { + content[param] = mediaItem[param]; + } + } + } + + let playBackMethod = PLAYBACK_METHODS.CLICK_TO_PLAY; + // returns a boolean or a string with the autoplay strategy + const autoplay = player.autoplay(); + const muted = player.muted() || autoplay === 'muted'; + // check if autoplay is truthy since it may be a bool or string + if (autoplay) { + playBackMethod = muted ? PLAYBACK_METHODS.AUTOPLAY_MUTED : PLAYBACK_METHODS.AUTOPLAY; + } + const supportedMediaTypes = Object.values(VIDEO_MIME_TYPE).filter( + // Follows w3 spec https://www.w3.org/TR/2011/WD-html5-20110113/video.html#dom-navigator-canplaytype + type => player.canPlayType(type) !== '' + ) + // IMA supports vpaid unless its expliclty turned off + if (imaOptions && imaOptions.vpaidMode !== 0) { + supportedMediaTypes.push(VPAID_MIME_TYPE); + } + + const video = { + mimes: supportedMediaTypes, + // Based on the protocol support provided by the videojs-ima plugin + // https://developers.google.com/interactive-media-ads/docs/sdks/html5/client-side/compatibility + // Need to check for the plugins + protocols: imaOptions ? [ + PROTOCOLS.VAST_2_0, + ] : [], + api: imaOptions ? [ + API_FRAMEWORKS.VPAID_2_0 + ] : [], + // TODO: Make sure this returns dimensions in DIPS + h: player.currentHeight(), + w: player.currentWidth(), + // TODO: implement startdelay since its reccomend param + // both linearity forms are supported so the param is excluded + // sequence - TODO not yet supported + maxextended: -1, + boxingallowed: 1, + playbackmethod: [ playBackMethod ], + playbackend: PLAYBACK_END.VIDEO_COMPLETION, + // Per ortb 7.4 skip is omitted since neither the player nor ima plugin imposes a skip button, or a skipmin/max + }; + + // TODO: Determine placement may not be in stream if videojs is only used to serve ad content + // ~ Sort of resolved check if the player has a source to tell if the placement is instream + // Still cannot reliably check what type of placement the player is if its outstream + // i.e. we can't tell if its interstitial, in article, etc. + if (content.url) { + video.placement = PLACEMENT.IN_STREAM; + } + + // Placement according to IQG Guidelines 4.2.8 + // https://cdn2.hubspot.net/hubfs/2848641/TrustworthyAccountabilityGroup_May2017/Docs/TAG-Inventory-Quality-Guidelines-v2_2-10-18-2016.pdf?t=1509469105938 + const findPosition = videojs.dom.findPosition; + if (player.isFullscreen()) { + video.pos = AD_POSITION.FULL_SCREEN; + } else if (findPosition) { + video.pos = utils.getPositionCode(findPosition(player.el())) + } + + return {video, content}; + } + + // Plugins to integrate: https://github.com/googleads/videojs-ima + function setAdTagUrl(adTagUrl, options) { + } + + // Should this function return some sort of signal + // to specify whether or not the callback was succesfully hooked? + function onEvents(events, callback) { + if (!callback) { + return; + } + + for (let i = 0; i < events.length; i++) { + const type = events[i]; + const payload = { + divId, + type + }; + + registerPreSetupListeners(type, callback, payload); + if (!player) { + return; + } + + registerPostSetupListeners(type, callback, payload); + } + } + + function getSetupCompletePayload() { + return { + divId, + playerVersion, + type: SETUP_COMPLETE, + }; + } + + function registerPreSetupListeners(type, callback, payload) { + let eventHandler; + switch (type) { + case SETUP_COMPLETE: + setupCompleteCallback = callback + eventHandler = () => { + payload = getSetupCompletePayload(); + callback(type, payload); + setupCompleteCallback = null; + }; + break; + case SETUP_FAILED: + setupFailedCallback = callback + eventHandler = () => { + // Videojs has no specific setup error handler + // so we imitate it by hooking to the general error + // handler and checking to see if the player has been setup + if (player.readyState() == 0) { + const e = player.error() + Object.assign(payload, { + playerVersion, + errorCode: e.errorTypes, + errorMessage: e.message, + }); + callback(type, payload); + setupFailedCallback = null; + } + } + break; + default: + return + } + callback_to_handler[callback] = eventHandler + player && player.on(utils.getVideojsEventName(type), eventHandler); + } + + function registerPostSetupListeners(type, callback, payload){ + let eventHandler; + + switch (type) { + case DESTROYED: + eventHandler = () => { + callback(type, payload); + }; + player.on(utils.getVideojsEventName(type), eventHandler); + break; + + case PLAY: + eventHandler = () => { + callback(type, payload); + }; + player.on(utils.getVideojsEventName(type), eventHandler); + break; + + case PAUSE: + eventHandler = () => { + callback(type, payload); + }; + player.on(utils.getVideojsEventName(type), eventHandler); + break; + + case BUFFER: + eventHandler = () => { + Object.assign(payload, { + position: 0, + duration: 0, + playbackMode: -1 + }); + callback(type, payload); + }; + player.el().addEventListener('waiting', eventHandler); + break; + + // TODO: No time event fired by videojs + case TIME: + eventHandler = e => { + Object.assign(payload, { + position: e.position, + duration: e.duration + }); + callback(type, payload); + }; + player.el().addEventListener('timeupdate', eventHandler); + break; + + case PLAYLIST: + eventHandler = e => { + const playlistItemCount = e.playlist.length; + Object.assign(payload, { + playlistItemCount, + autostart: player.getConfig().autostart + }); + callback(type, payload); + }; + break; + case PLAYBACK_REQUEST: + eventHandler = e => { + payload.playReason = e.playReason; + callback(type, payload); + }; + break; + case AUTOSTART_BLOCKED: + eventHandler = e => { + Object.assign(payload, { + sourceError: e.error, + errorCode: e.code, + errorMessage: e.message + }); + callback(type, payload); + }; + break; + case PLAY_ATTEMPT_FAILED: + eventHandler = e => { + Object.assign(payload, { + playReason: e.playReason, + sourceError: e.sourceError, + errorCode: e.code, + errorMessage: e.message + }); + callback(type, payload); + }; + break; + + case CONTENT_LOADED: + eventHandler = e => { + const {target} = e + Object.assign(payload, { + contentId: target.currentSrc, + contentUrl: target.currentSrc, // cover other sources ? util ? + title: null, + description: null, + playlistIndex: null, + contentTags: null + }); + callback(type, payload); + }; + player.el().addEventListener('loadeddata', eventHandler); + break; + + case SEEK_START: + eventHandler = e => { + const duration = e.duration; + const offset = e.offset; + pendingSeek = { + duration, + offset + }; + Object.assign(payload, { + position: e.position, + destination: offset, + duration: duration + }); + callback(type, payload); + } + player.on('seek', eventHandler); + break; + + case SEEK_END: + eventHandler = () => { + Object.assign(payload, { + position: pendingSeek.offset, + duration: pendingSeek.duration + }); + callback(type, payload); + pendingSeek = {}; + }; + player.on('seeked', eventHandler); + break; + + case MUTE: + eventHandler = e => { + payload.mute = e.mute; + callback(type, payload); + }; + player.on(MUTE, eventHandler); + break; + + case VOLUME: + eventHandler = e => { + payload.volumePercentage = e.volume; + callback(type, payload); + }; + player.on(VOLUME, eventHandler); + break; + + case RENDITION_UPDATE: + eventHandler = e => { + const bitrate = e.bitrate; + const level = e.level; + Object.assign(payload, { + videoReportedBitrate: bitrate, + audioReportedBitrate: bitrate, + encodedVideoWidth: level.width, + encodedVideoHeight: level.height, + videoFramerate: e.frameRate + }); + callback(type, payload); + }; + player.on('visualQuality', eventHandler); + break; + + case ERROR: + eventHandler = e => { + Object.assign(payload, { + sourceError: e.sourceError, + errorCode: e.code, + errorMessage: e.message, + }); + callback(type, payload); + }; + player.on(ERROR, eventHandler); + break; + + case COMPLETE: + eventHandler = e => { + callback(type, payload); + timeState.clearState(); + }; + player.on(COMPLETE, eventHandler); + break; + + case PLAYLIST_COMPLETE: + eventHandler = () => { + callback(type, payload); + }; + player.on(PLAYLIST_COMPLETE, eventHandler); + break; + + case FULLSCREEN: + eventHandler = e => { + payload.fullscreen = e.fullscreen; + callback(type, payload); + }; + player.on(FULLSCREEN, eventHandler); + break; + + case PLAYER_RESIZE: + eventHandler = e => { + Object.assign(payload, { + height: e.height, + width: e.width, + }); + callback(type, payload); + }; + player.on('resize', eventHandler); + break; + + case VIEWABLE: + eventHandler = e => { + Object.assign(payload, { + viewable: e.viewable, + viewabilityPercentage: player.getPercentViewable() * 100, + }); + callback(type, payload); + }; + player.on(VIEWABLE, eventHandler); + break; + + case CAST: + eventHandler = e => { + payload.casting = e.active; + callback(type, payload); + }; + player.on(CAST, eventHandler); + break; + + default: + return; + } + + } + + + function offEvents(events, callback) { + for (let event of events) { + const videojsEvent = utils.getVideojsEventName(event) + if (!callback) { + player.off(videojsEvent); + continue; + } + + const eventHandler = callbackStorage.getCallback(event, callback); + if (eventHandler) { + player.off(videojsEvent, eventHandler); + } + } + } + + function destroy() { + if (!player) { + return; + } + player.remove(); + player = null; + } + + return { + init, + getId, + getOrtbParams, + setAdTagUrl, + onEvents, + offEvents, + destroy + }; +} + +export const utils = { + getPositionCode: function({left, top, width, height}) { + const bottom = window.innerHeight - top - height; + const right = window.innerWidth - left - width; + + if (left < 0 || right < 0 || top < 0) { + return AD_POSITION.UNKNOWN; + } + + return bottom >= 0 ? AD_POSITION.ABOVE_THE_FOLD : AD_POSITION.BELOW_THE_FOLD; + }, + getVideojsEventName: function(eventName) { + switch (eventName) { + case SETUP_COMPLETE: + return 'ready'; + case SETUP_FAILED: + return 'error'; + case DESTROYED: + return 'dispose'; + case CONTENT_LOADED: + return 'loadeddata'; + case SEEK_START: + return 'seeking'; + case SEEK_END: + return 'timeupdate'; + case VOLUME: + return 'volumechange'; + case PLAYER_RESIZE: + return 'playerresize'; + default: + return eventName; + } + } +}; + +const videojsSubmoduleFactory = function (config) { + const adState = null; + const timeState = null; + const callbackStorage = callbackStorageFactory(); + // videojs factory is stored to window by default + const vjs = window.videojs; + return VideojsProvider(config, vjs, adState, timeState, callbackStorage, utils); +} +videojsSubmoduleFactory.vendorCode = VIDEO_JS_VENDOR; + +videoVendorDirectory[VIDEO_JS_VENDOR] = videojsSubmoduleFactory; +export default videojsSubmoduleFactory; diff --git a/package.json b/package.json index c4a83e22774..0b0a797e62f 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,9 @@ "through2": "^4.0.2", "url": "^0.11.0", "url-parse": "^1.0.5", + "video.js": "^7.17.0", + "videojs-contrib-ads": "^6.9.0", + "videojs-ima": "^1.11.0", "webdriverio": "^7.6.1", "webpack": "^5.70.0", "webpack-bundle-analyzer": "^4.5.0", diff --git a/src/events.js b/src/events.js index e675ffef7a9..8e6117c3abf 100644 --- a/src/events.js +++ b/src/events.js @@ -133,6 +133,10 @@ const _public = (function () { return _handlers; }; + _public.addEvents = function (events) { + allEvents = allEvents.concat(events); + } + /** * This method can return a copy of all the events fired * @return {Array} array of events fired @@ -152,4 +156,4 @@ const _public = (function () { utils._setEventEmitter(_public.emit.bind(_public)); -export const {on, off, get, getEvents, emit} = _public; +export const {on, off, get, getEvents, emit, addEvents} = _public; diff --git a/src/prebid.js b/src/prebid.js index ab336af08b6..1c35cc5505c 100644 --- a/src/prebid.js +++ b/src/prebid.js @@ -450,86 +450,99 @@ $$PREBID_GLOBAL$$.renderAd = hook('async', function (doc, id, options) { logInfo('Invoking $$PREBID_GLOBAL$$.renderAd', arguments); logMessage('Calling renderAd with adId :' + id); - if (doc && id) { - try { - // lookup ad by ad Id - const bid = auctionManager.findBidByAdId(id); - - if (bid) { - let shouldRender = true; - if (bid && bid.status === CONSTANTS.BID_STATUS.RENDERED) { - logWarn(`Ad id ${bid.adId} has been rendered before`); - events.emit(STALE_RENDER, bid); - if (deepAccess(config.getConfig('auctionOptions'), 'suppressStaleRender')) { - shouldRender = false; - } - } - - if (shouldRender) { - // replace macros according to openRTB with price paid = bid.cpm - bid.ad = replaceAuctionPrice(bid.ad, bid.originalCpm || bid.cpm); - bid.adUrl = replaceAuctionPrice(bid.adUrl, bid.originalCpm || bid.cpm); - // replacing clickthrough if submitted - if (options && options.clickThrough) { - const {clickThrough} = options; - bid.ad = replaceClickThrough(bid.ad, clickThrough); - bid.adUrl = replaceClickThrough(bid.adUrl, clickThrough); - } - - // save winning bids - auctionManager.addWinningBid(bid); - - // emit 'bid won' event here - events.emit(BID_WON, bid); - - const {height, width, ad, mediaType, adUrl, renderer} = bid; - - const creativeComment = document.createComment(`Creative ${bid.creativeId} served by ${bid.bidder} Prebid.js Header Bidding`); - insertElement(creativeComment, doc, 'html'); - - if (isRendererRequired(renderer)) { - executeRenderer(renderer, bid, doc); - reinjectNodeIfRemoved(creativeComment, doc, 'html'); - emitAdRenderSucceeded({ doc, bid, id }); - } else if ((doc === document && !inIframe()) || mediaType === 'video') { - const message = `Error trying to write ad. Ad render call ad id ${id} was prevented from writing to the main document.`; - emitAdRenderFail({reason: PREVENT_WRITING_ON_MAIN_DOCUMENT, message, bid, id}); - } else if (ad) { - doc.write(ad); - doc.close(); - setRenderSize(doc, width, height); - reinjectNodeIfRemoved(creativeComment, doc, 'html'); - callBurl(bid); - emitAdRenderSucceeded({ doc, bid, id }); - } else if (adUrl) { - const iframe = createInvisibleIframe(); - iframe.height = height; - iframe.width = width; - iframe.style.display = 'inline'; - iframe.style.overflow = 'hidden'; - iframe.src = adUrl; - - insertElement(iframe, doc, 'body'); - setRenderSize(doc, width, height); - reinjectNodeIfRemoved(creativeComment, doc, 'html'); - callBurl(bid); - emitAdRenderSucceeded({ doc, bid, id }); - } else { - const message = `Error trying to write ad. No ad for bid response id: ${id}`; - emitAdRenderFail({reason: NO_AD, message, bid, id}); - } - } - } else { - const message = `Error trying to write ad. Cannot find ad by given id : ${id}`; - emitAdRenderFail({ reason: CANNOT_FIND_AD, message, id }); + if (!id) { + const message = `Error trying to write ad Id :${id} to the page. Missing adId`; + emitAdRenderFail({ reason: MISSING_DOC_OR_ADID, message, id }); + return; + } + + try { + // lookup ad by ad Id + const bid = auctionManager.findBidByAdId(id); + if (!bid) { + const message = `Error trying to write ad. Cannot find ad by given id : ${id}`; + emitAdRenderFail({ reason: CANNOT_FIND_AD, message, id }); + return; + } + + if (bid.status === CONSTANTS.BID_STATUS.RENDERED) { + logWarn(`Ad id ${bid.adId} has been rendered before`); + events.emit(STALE_RENDER, bid); + if (deepAccess(config.getConfig('auctionOptions'), 'suppressStaleRender')) { + return; } - } catch (e) { - const message = `Error trying to write ad Id :${id} to the page:${e.message}`; - emitAdRenderFail({ reason: EXCEPTION, message, id }); } - } else { - const message = `Error trying to write ad Id :${id} to the page. Missing document or adId`; - emitAdRenderFail({ reason: MISSING_DOC_OR_ADID, message, id }); + + // replace macros according to openRTB with price paid = bid.cpm + bid.ad = replaceAuctionPrice(bid.ad, bid.originalCpm || bid.cpm); + bid.adUrl = replaceAuctionPrice(bid.adUrl, bid.originalCpm || bid.cpm); + // replacing clickthrough if submitted + if (options && options.clickThrough) { + const {clickThrough} = options; + bid.ad = replaceClickThrough(bid.ad, clickThrough); + bid.adUrl = replaceClickThrough(bid.adUrl, clickThrough); + } + + // save winning bids + auctionManager.addWinningBid(bid); + + // emit 'bid won' event here + events.emit(BID_WON, bid); + + const {height, width, ad, mediaType, adUrl, renderer} = bid; + + // video module + const adUnitCode = bid.adUnitCode; + const adUnit = $$PREBID_GLOBAL$$.adUnits.filter(adUnit => adUnit.code === adUnitCode); + const videoModule = $$PREBID_GLOBAL$$.videoModule; + if (adUnit.video && videoModule) { + videoModule.renderBid(adUnit.video.divId, bid); + return; + } + + if (!doc) { + const message = `Error trying to write ad Id :${id} to the page. Missing document`; + emitAdRenderFail({ reason: MISSING_DOC_OR_ADID, message, id }); + return; + } + + const creativeComment = document.createComment(`Creative ${bid.creativeId} served by ${bid.bidder} Prebid.js Header Bidding`); + insertElement(creativeComment, doc, 'html'); + + if (isRendererRequired(renderer)) { + executeRenderer(renderer, bid, doc); + reinjectNodeIfRemoved(creativeComment, doc, 'html'); + emitAdRenderSucceeded({ doc, bid, id }); + } else if ((doc === document && !inIframe()) || mediaType === 'video') { + const message = `Error trying to write ad. Ad render call ad id ${id} was prevented from writing to the main document.`; + emitAdRenderFail({reason: PREVENT_WRITING_ON_MAIN_DOCUMENT, message, bid, id}); + } else if (ad) { + doc.write(ad); + doc.close(); + setRenderSize(doc, width, height); + reinjectNodeIfRemoved(creativeComment, doc, 'html'); + callBurl(bid); + emitAdRenderSucceeded({ doc, bid, id }); + } else if (adUrl) { + const iframe = createInvisibleIframe(); + iframe.height = height; + iframe.width = width; + iframe.style.display = 'inline'; + iframe.style.overflow = 'hidden'; + iframe.src = adUrl; + + insertElement(iframe, doc, 'body'); + setRenderSize(doc, width, height); + reinjectNodeIfRemoved(creativeComment, doc, 'html'); + callBurl(bid); + emitAdRenderSucceeded({ doc, bid, id }); + } else { + const message = `Error trying to write ad. No ad for bid response id: ${id}`; + emitAdRenderFail({reason: NO_AD, message, bid, id}); + } + } catch (e) { + const message = `Error trying to write ad Id :${id} to the page:${e.message}`; + emitAdRenderFail({ reason: EXCEPTION, message, id }); } }); diff --git a/test/spec/modules/videoModule/coreVideo_spec.js b/test/spec/modules/videoModule/coreVideo_spec.js new file mode 100644 index 00000000000..4926c165af1 --- /dev/null +++ b/test/spec/modules/videoModule/coreVideo_spec.js @@ -0,0 +1,88 @@ +import { expect } from 'chai'; +import { VideoCore } from 'libraries/video/coreVideo.js'; + +describe('Video Core', function () { + const mockSubmodule = { + getOrtbParams: sinon.spy(), + setAdTagUrl: sinon.spy(), + onEvents: sinon.spy(), + offEvents: sinon.spy(), + }; + + const otherSubmodule = { + getOrtbParams: () => {}, + setAdTagUrl: () => {}, + onEvents: () => {}, + offEvents: () => {}, + }; + + const testId = 'test_id'; + const testVendorCode = 0; + const otherId = 'other_id'; + const otherVendorCode = 1; + + const parentModuleMock = { + registerSubmodule: sinon.spy(), + getSubmodule: sinon.spy(id => { + if (id === testId) { + return mockSubmodule; + } else if (id === otherId) { + return otherSubmodule; + } + }) + }; + + const videoCore = VideoCore(parentModuleMock); + + videoCore.registerProvider({ + vendorCode: testVendorCode, + divId: testId + }); + + videoCore.registerProvider({ + vendorCode: otherVendorCode, + divId: otherId + }); + + describe('registerProvider', function () { + it('should delegate the registration to the Parent Module', function () { + expect(parentModuleMock.registerSubmodule.calledTwice).to.be.true; + expect(parentModuleMock.registerSubmodule.args[0][0]).to.be.equal(testId); + expect(parentModuleMock.registerSubmodule.args[1][0]).to.be.equal(otherId); + expect(parentModuleMock.registerSubmodule.args[0][1]).to.be.equal(testVendorCode); + expect(parentModuleMock.registerSubmodule.args[1][1]).to.be.equal(otherVendorCode); + }); + }); + + describe('getOrtbParams', function () { + it('delegates to the submodule of the right divId', function () { + videoCore.getOrtbParams(testId); + videoCore.getOrtbParams(otherId); + expect(mockSubmodule.getOrtbParams.calledOnce).to.be.true; + }); + }); + + describe('setAdTagUrl', function () { + it('delegates to the submodule of the right divId', function () { + videoCore.setAdTagUrl('', testId); + videoCore.setAdTagUrl('', otherId); + expect(mockSubmodule.setAdTagUrl.calledOnce).to.be.true; + }); + }); + + describe('onEvents', function () { + it('delegates to the submodule of the right divId', function () { + videoCore.onEvents([], () => {}, testId); + videoCore.onEvents([], () => {}, otherId); + expect(mockSubmodule.onEvents.calledOnce).to.be.true; + }); + }); + + describe('offEvents', function () { + it('delegates to the submodule of the right divId', function () { + videoCore.offEvents([], () => {}, testId); + videoCore.offEvents([], () => {}, otherId); + expect(mockSubmodule.offEvents.calledOnce).to.be.true; + }); + }); +}); diff --git a/test/spec/modules/videoModule/pbVideo_spec.js b/test/spec/modules/videoModule/pbVideo_spec.js new file mode 100644 index 00000000000..da925c55c93 --- /dev/null +++ b/test/spec/modules/videoModule/pbVideo_spec.js @@ -0,0 +1,359 @@ +import { expect } from 'chai'; +import { PbVideo } from 'libraries/video/index.js'; +import CONSTANTS from 'src/constants.json'; +import { AD_IMPRESSION, AD_ERROR, BID_IMPRESSION, BID_ERROR } from 'libraries/video/constants/events.js'; + +let ortbParamsMock; +let videoCoreMock; +let getConfigMock; +let requestBidsMock; +let pbGlobalMock; +let pbEventsMock; +let videoEventsMock; +let gamSubmoduleMock; +let gamSubmoduleFactoryMock; +let videoImpressionVerifierFactoryMock; +let videoImpressionVerifierMock; + +function resetTestVars() { + ortbParamsMock = { + 'video': {}, + 'content': {} + }; + videoCoreMock = { + registerProvider: sinon.spy(), + onEvents: sinon.spy(), + getOrtbParams: () => ortbParamsMock, + setAdTagUrl: sinon.spy() + }; + getConfigMock = () => {}; + requestBidsMock = { + before: sinon.spy() + }; + pbGlobalMock = { + requestBids: requestBidsMock, + getHighestCpmBids: sinon.spy(), + getBidResponsesForAdUnitCode: sinon.spy(), + setConfig: sinon.spy() + }; + pbEventsMock = { + emit: sinon.spy(), + on: sinon.spy() + }; + videoEventsMock = []; + gamSubmoduleMock = { + getAdTagUrl: sinon.spy() + }; + + gamSubmoduleFactoryMock = sinon.spy(() => gamSubmoduleMock); + + videoImpressionVerifierMock = { + trackBid: sinon.spy(), + getBidIdentifiers: sinon.spy() + }; + + videoImpressionVerifierFactoryMock = () => videoImpressionVerifierMock; +} + +let pbVideoFactory = (videoCore, getConfig, pbGlobal, pbEvents, videoEvents, gamSubmoduleFactory, videoImpressionVerifierFactory) => { + const pbVideo = PbVideo( + videoCore || videoCoreMock, + getConfig || getConfigMock, + pbGlobal || pbGlobalMock, + pbEvents || pbEventsMock, + videoEvents || videoEventsMock, + gamSubmoduleFactory || gamSubmoduleFactoryMock, + videoImpressionVerifierFactory || videoImpressionVerifierFactoryMock + ); + pbVideo.init(); + return pbVideo; +} + +describe('Prebid Video', function () { + beforeEach(() => resetTestVars()); + + describe('Setting video to config', function () { + let providers = [{ divId: 'div1' }, { divId: 'div2' }]; + let getConfigCallback; + let getConfig = (propertyName, callback) => { + if (propertyName === 'video') { + getConfigCallback = callback; + } + }; + + beforeEach(() => { + pbVideoFactory(null, getConfig); + getConfigCallback({ video: { providers } }); + }); + + it('Should register providers', function () { + expect(videoCoreMock.registerProvider.calledTwice).to.be.true; + }); + + it('Should register events', function () { + expect(videoCoreMock.onEvents.calledTwice).to.be.true; + const onEventsSpy = videoCoreMock.onEvents; + expect(onEventsSpy.getCall(0).args[2]).to.be.equal('div1'); + expect(onEventsSpy.getCall(1).args[2]).to.be.equal('div2'); + }); + + describe('Event triggering', function () { + it('Should emit events off of Prebid\'s Events', function () { + let eventHandler; + const videoCore = Object.assign({}, videoCoreMock, { + onEvents: (events, eventHandler_) => eventHandler = eventHandler_ + }); + pbVideoFactory(videoCore, getConfig); + getConfigCallback({ video: { providers } }); + const expectedType = 'test_event'; + const expectedPayload = {'test': 'data'}; + eventHandler(expectedType, expectedPayload); + expect(pbEventsMock.emit.calledOnce).to.be.true; + expect(pbEventsMock.emit.getCall(0).args[0]).to.be.equal('video_' + expectedType); + expect(pbEventsMock.emit.getCall(0).args[1]).to.be.equal(expectedPayload); + }); + }); + + describe('Ad Server configuration', function() { + const test_vendor_code = 5; + const test_params = { test: 'params' }; + providers[0].adServer = { vendorCode: test_vendor_code, params: test_params }; + + it('should instantiate the GAM Submodule', function () { + expect(gamSubmoduleFactoryMock.calledOnce).to.be.true; + }); + }); + }); + + describe('Ad unit Enrichment', function () { + it('registers before:bidRequest hook', function () { + pbVideoFactory(); + expect(requestBidsMock.before.calledOnce).to.be.true; + }); + + it('requests oRtb params and writes them to ad unit and config', function() { + const getOrtbParamsSpy = videoCoreMock.getOrtbParams = sinon.spy(() => ({ + video: { + test: 'videoTestValue' + }, + content: { + test: 'contentTestValue' + } + })); + const setConfigSpy = pbGlobalMock.setConfig; + setConfigSpy.resetHistory(); + let beforeBidRequestCallback; + const requestBids = { + before: callback_ => beforeBidRequestCallback = callback_ + }; + + pbVideoFactory(null, null, { requestBids, setConfig: setConfigSpy }); + expect(beforeBidRequestCallback).to.not.be.undefined; + const nextFn = sinon.spy(); + const adUnits = [{ + code: 'ad1', + mediaTypes: { + video: {} + }, + video: { divId: 'divId' } + }]; + beforeBidRequestCallback(nextFn, { adUnits }); + expect(getOrtbParamsSpy.calledOnce).to.be.true; + const adUnit = adUnits[0]; + expect(adUnit.mediaTypes.video).to.have.property('test', 'videoTestValue'); + expect(setConfigSpy.calledOnce).to.be.true; + expect(setConfigSpy.getCall(0).args[0]).to.be.deep.equal({ ortb2: { site: { test: 'contentTestValue' } } }); + expect(nextFn.calledOnce).to.be.true; + }); + }); + + describe('Ad tag injection', function () { + let auctionEndCallback; + let providers = [{ divId: 'div1', adServer: {} }, { divId: 'div2' }]; + let getConfig = (propertyName, callbackFn) => { + if (propertyName === 'video') { + callbackFn({ + video: { providers } + }); + } + }; + + const pbEvents = { + emit: () => {}, + on: (event, callback) => { + if (event === CONSTANTS.EVENTS.AUCTION_END) { + auctionEndCallback = callback + } + } + }; + + const expectedVendorCode = 5; + const expectedAdTag = 'test_tag'; + const expectedAdUnitCode = 'expectedAdUnitcode'; + const expectedDivId = 'expectedDivId'; + const expectedAdUnit = { + code: expectedAdUnitCode, + video: { + divId: expectedDivId, + adServer: { + vendorCode: expectedVendorCode, + baseAdTagUrl: expectedAdTag + } + } + }; + const auctionResults = { adUnits: [ expectedAdUnit, {} ] }; + + beforeEach(() => { + gamSubmoduleMock.getAdTagUrl.resetHistory(); + videoCoreMock.setAdTagUrl.resetHistory(); + }); + + let beforeBidRequestCallback; + const requestBids = { + before: callback_ => beforeBidRequestCallback = callback_ + }; + + it('should request ad tag url from adServer when configured to use adServer', function () { + const expectedVastUrl = 'expectedVastUrl'; + const expectedVastXml = 'expectedVastXml'; + const pbGlobal = Object.assign({}, pbGlobalMock, { + requestBids, + getHighestCpmBids: () => [{ + vastUrl: expectedVastUrl, + vastXml: expectedVastXml + }, {}, {}, {}] + }); + pbVideoFactory(null, getConfig, pbGlobal, pbEvents); + + beforeBidRequestCallback(() => {}, {}); + auctionEndCallback(auctionResults); + expect(gamSubmoduleMock.getAdTagUrl.calledOnce).to.be.true; + expect(gamSubmoduleMock.getAdTagUrl.getCall(0).args[0]).is.equal(expectedAdUnit); + expect(gamSubmoduleMock.getAdTagUrl.getCall(0).args[1]).is.equal(expectedAdTag); + }); + + it('should load ad tag when ad server returns ad tag', function () { + const expectedAdTag = 'resulting ad tag'; + const gamSubmoduleFactory = () => ({ + getAdTagUrl: () => expectedAdTag + }); + const expectedVastUrl = 'expectedVastUrl'; + const expectedVastXml = 'expectedVastXml'; + const pbGlobal = Object.assign({}, pbGlobalMock, { + requestBids, + getHighestCpmBids: () => [{ + vastUrl: expectedVastUrl, + vastXml: expectedVastXml + }, {}, {}, {}] + }); + pbVideoFactory(null, getConfig, pbGlobal, pbEvents, null, gamSubmoduleFactory); + beforeBidRequestCallback(() => {}, {}); + auctionEndCallback(auctionResults); + expect(videoCoreMock.setAdTagUrl.calledOnce).to.be.true; + expect(videoCoreMock.setAdTagUrl.args[0][0]).to.be.equal(expectedAdTag); + expect(videoCoreMock.setAdTagUrl.args[0][1]).to.be.equal(expectedDivId); + expect(videoCoreMock.setAdTagUrl.args[0][2]).to.have.property('adUnitCode', expectedAdUnitCode); + }); + + it('should load ad tag from highest bid when ad server is not configured', function () { + const expectedVastUrl = 'expectedVastUrl'; + const expectedVastXml = 'expectedVastXml'; + const pbGlobal = Object.assign({}, pbGlobalMock, { + requestBids, + getHighestCpmBids: () => [{ + vastUrl: expectedVastUrl, + vastXml: expectedVastXml + }, {}, {}, {}] + }); + const expectedAdUnit = { + code: expectedAdUnitCode, + video: { divId: expectedDivId } + }; + const auctionResults = { adUnits: [ expectedAdUnit, {} ] }; + + pbVideoFactory(null, null, pbGlobal, pbEvents); + beforeBidRequestCallback(() => {}, {}); + auctionEndCallback(auctionResults); + expect(videoCoreMock.setAdTagUrl.calledOnce).to.be.true; + expect(videoCoreMock.setAdTagUrl.args[0][0]).to.be.equal(expectedVastUrl); + expect(videoCoreMock.setAdTagUrl.args[0][1]).to.be.equal(expectedDivId); + expect(videoCoreMock.setAdTagUrl.args[0][2]).to.have.property('adUnitCode', expectedAdUnitCode); + expect(videoCoreMock.setAdTagUrl.args[0][2]).to.have.property('adXml', expectedVastXml); + }); + }); + + describe('Ad tracking', function () { + const expectedAdEventPayload = { adEventPayloadMarker: 'marker' }; + const expectedBid = { bidMarker: 'marker' }; + let bidAdjustmentCb; + let adImpressionCb; + let adErrorCb; + + const pbEvents = { + on: (event, callback) => { + if (event === CONSTANTS.EVENTS.BID_ADJUSTMENT) { + bidAdjustmentCb = callback; + } else if (event === 'video_' + AD_IMPRESSION) { + adImpressionCb = callback; + } else if (event === 'video_' + AD_ERROR) { + adErrorCb = callback; + } + }, + emit: sinon.spy() + }; + + it('should ask Impression Verifier to track bid on Bid Adjustment', function () { + pbVideoFactory(null, null, null, pbEvents); + bidAdjustmentCb(); + expect(videoImpressionVerifierMock.trackBid.calledOnce).to.be.true; + }); + + it('should trigger video bid impression when the bid matched', function () { + pbEvents.emit.resetHistory(); + const pbGlobal = Object.assign({}, pbGlobalMock, { getBidResponsesForAdUnitCode: () => ({ bids: [expectedBid] }) }); + const videoImpressionVerifier = Object.assign({}, videoImpressionVerifierMock, { getBidIdentifiers: () => ({}) }); + pbVideoFactory(null, null, pbGlobal, pbEvents, null, null, () => videoImpressionVerifier); + adImpressionCb(expectedAdEventPayload); + + expect(pbEvents.emit.calledOnce).to.be.true; + expect(pbEvents.emit.getCall(0).args[0]).to.be.equal('video_' + BID_IMPRESSION); + const payload = pbEvents.emit.getCall(0).args[1]; + expect(payload.bid).to.be.equal(expectedBid); + expect(payload.adEvent).to.be.equal(expectedAdEventPayload); + }); + + it('should trigger video bid error when the bid matched', function () { + pbEvents.emit.resetHistory(); + const pbGlobal = Object.assign({}, pbGlobalMock, { getBidResponsesForAdUnitCode: () => ({ bids: [expectedBid] }) }); + const videoImpressionVerifier = Object.assign({}, videoImpressionVerifierMock, { getBidIdentifiers: () => ({}) }); + pbVideoFactory(null, null, pbGlobal, pbEvents, null, null, () => videoImpressionVerifier); + adErrorCb(expectedAdEventPayload); + + expect(pbEvents.emit.calledOnce).to.be.true; + expect(pbEvents.emit.getCall(0).args[0]).to.be.equal('video_' + BID_ERROR); + const payload = pbEvents.emit.getCall(0).args[1]; + expect(payload.bid).to.be.equal(expectedBid); + expect(payload.adEvent).to.be.equal(expectedAdEventPayload); + }); + + it('should not trigger a bid impression when the bid did not match', function () { + pbEvents.emit.resetHistory(); + const pbGlobal = Object.assign({}, pbGlobalMock, { getBidResponsesForAdUnitCode: () => ({ bids: [expectedBid] }) }); + const videoImpressionVerifier = Object.assign({}, videoImpressionVerifierMock, { getBidIdentifiers: () => ({ auctionId: 'id' }) }); + pbVideoFactory(null, null, pbGlobal, pbEvents, null, null, () => videoImpressionVerifier); + adImpressionCb(expectedAdEventPayload); + + expect(pbEvents.emit.called).to.be.false; + }); + + it('should not trigger a bid error when the bid did not match', function () { + pbEvents.emit.resetHistory(); + const pbGlobal = Object.assign({}, pbGlobalMock, { getBidResponsesForAdUnitCode: () => ({ bids: [expectedBid] }) }); + const videoImpressionVerifier = Object.assign({}, videoImpressionVerifierMock, { getBidIdentifiers: () => ({ auctionId: 'id' }) }); + pbVideoFactory(null, null, pbGlobal, pbEvents, null, null, () => videoImpressionVerifier); + adErrorCb(expectedAdEventPayload); + + expect(pbEvents.emit.called).to.be.false; + }); + }); +}); diff --git a/test/spec/modules/videoModule/shared/parentModule_spec.js b/test/spec/modules/videoModule/shared/parentModule_spec.js new file mode 100644 index 00000000000..1e8e7fda380 --- /dev/null +++ b/test/spec/modules/videoModule/shared/parentModule_spec.js @@ -0,0 +1,73 @@ +import { SubmoduleBuilder, ParentModule } from 'libraries/video/shared/parentModule.js'; +import { expect } from 'chai'; + +describe('Parent Module', function() { + const idForMock = 0; + const vendorCodeForMock = 'a'; + const unrecognizedId = 999; + const unrecognizedVendorCode = 'zzz'; + const mockSubmodule = { test: 'test' }; + const mockSubmoduleBuilder = { + build: vendorCode => { + if (vendorCode === vendorCodeForMock) { + return mockSubmodule; + } else { + throw new Error('flawed'); + } + } + }; + const parentModule = ParentModule(mockSubmoduleBuilder); + + describe('Register Submodule', function () { + it('should throw when the builder fails to build', function () { + expect(() => parentModule.registerSubmodule(unrecognizedId, unrecognizedVendorCode)).to.throw('flawed'); + }); + }); + + describe('Get Submodule', function () { + it('should return registered submodules', function () { + parentModule.registerSubmodule(idForMock, vendorCodeForMock); + const submodule = parentModule.getSubmodule(idForMock); + expect(submodule).to.be.equal(mockSubmodule); + }); + + it('should return undefined when submodule is not registered', function () { + const submodule = parentModule.getSubmodule(unrecognizedId); + expect(submodule).to.be.undefined; + }); + }) +}); + +describe('Submodule Builder', function () { + const vendorCode1 = 1; + const vendorCode2 = 2; + const submodule1 = {}; + const initSpy = sinon.spy(); + const submodule2 = { init: initSpy }; + const submoduleFactory1 = () => submodule1; + const submoduleFactory2 = () => submodule2; + const submoduleFactory1Spy = sinon.spy(submoduleFactory1); + + const vendorDirectory = {}; + vendorDirectory[vendorCode1] = submoduleFactory1Spy; + vendorDirectory[vendorCode2] = submoduleFactory2; + + const submoduleBuilder = SubmoduleBuilder(vendorDirectory); + + it('should call submodule factory when vendor code is supported', function () { + const submodule = submoduleBuilder.build(vendorCode1); + expect(submoduleFactory1Spy.calledOnce).to.be.true; + expect(submodule).to.be.equal(submodule1); + }); + + it('should instantiate the submodule, when supported', function () { + const submodule = submoduleBuilder.build(vendorCode2); + expect(initSpy.calledOnce).to.be.true; + expect(submodule).to.be.equal(submodule2); + }); + + it('should throw when vendor code is not recognized', function () { + const unrecognizedVendorCode = 999; + expect(() => submoduleBuilder.build(unrecognizedVendorCode)).to.throw('Unrecognized submodule vendor code: ' + unrecognizedVendorCode); + }); +}); diff --git a/test/spec/modules/videoModule/shared/state_spec.js b/test/spec/modules/videoModule/shared/state_spec.js new file mode 100644 index 00000000000..bbd0a35b57e --- /dev/null +++ b/test/spec/modules/videoModule/shared/state_spec.js @@ -0,0 +1,26 @@ +import stateFactory from 'libraries/video/shared/state.js'; +import { expect } from 'chai'; + +describe('State', function () { + let state = stateFactory(); + beforeEach(function () { + state.clearState(); + }); + + it('should update state', function () { + state.updateState({ 'test': 'a' }); + expect(state.getState()).to.have.property('test', 'a'); + state.updateState({ 'test': 'b' }); + expect(state.getState()).to.have.property('test', 'b'); + state.updateState({ 'test_2': 'c' }); + expect(state.getState()).to.have.property('test', 'b'); + expect(state.getState()).to.have.property('test_2', 'c'); + }); + + it('should clear state', function () { + state.updateState({ 'test': 'a' }); + state.clearState(); + expect(state.getState()).to.not.have.property('test', 'a'); + expect(state.getState()).to.be.empty; + }); +}); diff --git a/test/spec/modules/videoModule/shared/vastXmlBuilder_spec.js b/test/spec/modules/videoModule/shared/vastXmlBuilder_spec.js new file mode 100644 index 00000000000..2c67b898a53 --- /dev/null +++ b/test/spec/modules/videoModule/shared/vastXmlBuilder_spec.js @@ -0,0 +1,103 @@ +import { buildVastWrapper, getVastNode, getAdNode, getWrapperNode, getAdSystemNode, + getAdTagUriNode, getErrorNode, getImpressionNode } from 'libraries/video/shared/vastXmlBuilder.js'; +import { expect } from 'chai'; + +describe('buildVastWrapper', function () { + it('should include impression and error nodes when requested', function () { + const vastXml = buildVastWrapper( + 'adId123', + 'http://wwww.testUrl.com/redirectUrl.xml', + 'http://wwww.testUrl.com/impression.jpg', + 'impressionId123', + 'http://wwww.testUrl.com/error.jpg' + ); + expect(vastXml).to.be.equal(`Prebid org`); + }); + + it('should omit error nodes when excluded', function () { + const vastXml = buildVastWrapper( + 'adId123', + 'http://wwww.testUrl.com/redirectUrl.xml', + 'http://wwww.testUrl.com/impression.jpg', + 'impressionId123', + ); + expect(vastXml).to.be.equal(`Prebid org`); + }); + + it('should omit impression nodes when excluded', function () { + const vastXml = buildVastWrapper( + 'adId123', + 'http://wwww.testUrl.com/redirectUrl.xml', + ); + expect(vastXml).to.be.equal(`Prebid org`); + }); +}); + +describe('getVastNode', function () { + it('should return well formed Vast node', function () { + const vastNode = getVastNode('body', '4.0'); + expect(vastNode).to.be.equal('body'); + }); + + it('should omit version when missing', function() { + const vastNode = getVastNode('body'); + expect(vastNode).to.be.equal('body'); + }); +}); + +describe('getAdNode', function () { + it('should return well formed Ad node', function () { + const adNode = getAdNode('body', 'adId123'); + expect(adNode).to.be.equal('body'); + }); + + it('should omit id when missing', function() { + const adNode = getAdNode('body'); + expect(adNode).to.be.equal('body'); + }); +}); + +describe('getWrapperNode', function () { + it('should return well formed Wrapper node', function () { + const wrapperNode = getWrapperNode('body'); + expect(wrapperNode).to.be.equal('body'); + }); +}); + +describe('getAdSystemNode', function () { + it('should return well formed AdSystem node', function () { + const adSystemNode = getAdSystemNode('testSysName', '5.0'); + expect(adSystemNode).to.be.equal('testSysName'); + }); + + it('should omit version when missing', function() { + const adSystemNode = getAdSystemNode('testSysName'); + expect(adSystemNode).to.be.equal('testSysName'); + }); +}); + +describe('getAdTagUriNode', function () { + it('should return well formed ad tag URI node', function () { + const adTagNode = getAdTagUriNode('http://wwww.testUrl.com/ad.xml'); + expect(adTagNode).to.be.equal(''); + }); +}); + +describe('getImpressionNode', function () { + it('should return well formed Impression node', function () { + const impressionNode = getImpressionNode('http://wwww.testUrl.com/adImpression.jpg', 'impresionId123'); + expect(impressionNode).to.be.equal(''); + }); + + it('should omit id when missing', function() { + const impressionNode = getImpressionNode('http://wwww.testUrl.com/adImpression.jpg'); + expect(impressionNode).to.be.equal(''); + }); +}); + +describe('getErrorNode', function () { + it('should return well formed Error node', function () { + const errorNode = getErrorNode('http://wwww.testUrl.com/adError.jpg'); + expect(errorNode).to.be.equal(''); + }); +}); diff --git a/test/spec/modules/videoModule/shared/vastXmlEditor_spec.js b/test/spec/modules/videoModule/shared/vastXmlEditor_spec.js new file mode 100644 index 00000000000..2304b2f2833 --- /dev/null +++ b/test/spec/modules/videoModule/shared/vastXmlEditor_spec.js @@ -0,0 +1,209 @@ +import { vastXmlEditorFactory } from 'libraries/video/shared/vastXmlEditor.js'; +import { expect } from 'chai'; + +describe('Vast XML Editor', function () { + const adWrapperXml = ` + + + + Prebid org + + + + +`; + + const inlineXml = ` + + + + Prebid org + Random Title + + + +`; + + const inLineWithWrapper = ` + + + + Prebid org + + + + + + Prebid org + Random Title + + + +`; + + const vastXmlEditor = vastXmlEditorFactory(); + const expectedImpressionUrl = 'https://test.impression.com/ping.gif'; + const expectedImpressionId = 'test-impression-id'; + const expectedErrorUrl = 'https://test.error.com/ping.gif'; + + it('should add Impression Nodes to the Ad Wrapper', function () { + const vastXml = vastXmlEditor.getVastXmlWithTracking(adWrapperXml, null, expectedImpressionUrl, expectedImpressionId); + const expectedXml = ` + + + Prebid org + + + +`; + expect(vastXml).to.equal(expectedXml); + }); + + it('should add Impression Nodes to the InLine', function () { + const vastXml = vastXmlEditor.getVastXmlWithTracking(inlineXml, null, expectedImpressionUrl, expectedImpressionId); + const expectedXml = ` + + + Prebid org + Random Title + + +`; + expect(vastXml).to.equal(expectedXml); + }); + + it('should add Impression Nodes to the Ad Wrapper and Inline', function () { + const vastXml = vastXmlEditor.getVastXmlWithTracking(inLineWithWrapper, null, expectedImpressionUrl, expectedImpressionId); + const expectedXml = ` + + + Prebid org + + + + + + Prebid org + Random Title + + +`; + expect(vastXml).to.equal(expectedXml); + }); + + it('should add Error Nodes to the Ad Wrapper', function () { + const vastXml = vastXmlEditor.getVastXmlWithTracking(adWrapperXml, null, null, null, expectedErrorUrl); + const expectedXml = ` + + + Prebid org + + + +`; + expect(vastXml).to.equal(expectedXml); + }); + + it('should add Error Nodes to the InLine', function () { + const vastXml = vastXmlEditor.getVastXmlWithTracking(inlineXml, null, null, null, expectedErrorUrl); + const expectedXml = ` + + + Prebid org + Random Title + + +`; + expect(vastXml).to.equal(expectedXml); + }); + + it('should add Error Nodes to the Ad Wrapper and Inline', function () { + const vastXml = vastXmlEditor.getVastXmlWithTracking(inLineWithWrapper, null, null, null, expectedErrorUrl); + const expectedXml = ` + + + Prebid org + + + + + + Prebid org + Random Title + + +`; + expect(vastXml).to.equal(expectedXml); + }); + + it('should add Impression Nodes and Error Nodes to the Ad Wrapper', function () { + const vastXml = vastXmlEditor.getVastXmlWithTracking(adWrapperXml, null, expectedImpressionUrl, expectedImpressionId, expectedErrorUrl); + const expectedXml = ` + + + Prebid org + + + +`; + expect(vastXml).to.equal(expectedXml); + }); + + it('should add Impression Nodes and Error Nodes to the InLine', function () { + const vastXml = vastXmlEditor.getVastXmlWithTracking(inlineXml, null, expectedImpressionUrl, expectedImpressionId, expectedErrorUrl); + const expectedXml = ` + + + Prebid org + Random Title + + +`; + expect(vastXml).to.equal(expectedXml); + }); + + it('should add Impression Nodes and Error Nodes to the Ad Wrapper and Inline', function () { + const vastXml = vastXmlEditor.getVastXmlWithTracking(inLineWithWrapper, null, expectedImpressionUrl, expectedImpressionId, expectedErrorUrl); + const expectedXml = ` + + + Prebid org + + + + + + Prebid org + Random Title + + +`; + expect(vastXml).to.equal(expectedXml); + }); + + it('should override the ad id in inline', function () { + const vastXml = vastXmlEditor.getVastXmlWithTracking(inlineXml, 'adIdOverride'); + const expectedXml = ` + + + Prebid org + Random Title + + +`; + expect(vastXml).to.equal(expectedXml); + }); + + it('should override the ad id in the Ad Wrapper', function () { + const vastXml = vastXmlEditor.getVastXmlWithTracking(adWrapperXml, 'adIdOverride'); + const expectedXml = ` + + + Prebid org + + + +`; + expect(vastXml).to.equal(expectedXml); + }); +}); diff --git a/test/spec/modules/videoModule/submodules/jwplayerVideoProvider_spec.js b/test/spec/modules/videoModule/submodules/jwplayerVideoProvider_spec.js new file mode 100644 index 00000000000..0a1d34679da --- /dev/null +++ b/test/spec/modules/videoModule/submodules/jwplayerVideoProvider_spec.js @@ -0,0 +1,836 @@ +import { + JWPlayerProvider, + adStateFactory, + timeStateFactory, + callbackStorageFactory, + utils +} from 'modules/jwplayerVideoProvider'; + +import { + PROTOCOLS, API_FRAMEWORKS, VIDEO_MIME_TYPE, PLAYBACK_METHODS, PLACEMENT, VPAID_MIME_TYPE +} from 'libraries/video/constants/ortb.js'; + +import { + SETUP_COMPLETE, SETUP_FAILED, PLAY, AD_IMPRESSION, videoEvents +} from 'libraries/video/constants/events.js'; + +import { PLAYBACK_MODE } from 'libraries/video/constants/enums.js'; + +function getPlayerMock() { + return makePlayerFactoryMock({ + getState: function () {}, + setup: function () {}, + getViewable: function () {}, + getPercentViewable: function () {}, + getMute: function () {}, + getVolume: function () {}, + getConfig: function () {}, + getHeight: function () {}, + getWidth: function () {}, + getFullscreen: function () {}, + getPlaylistItem: function () {}, + playAd: function () {}, + on: function () {}, + off: function () {}, + remove: function () {}, + getAudioTracks: function () {}, + getCurrentAudioTrack: function () {}, + getPlugin: function () {}, + getFloating: function () {} + })(); +} + +function makePlayerFactoryMock(playerMock_) { + const playerFactory = function () { + return playerMock_; + } + playerFactory.version = '8.21.0'; + return playerFactory; +} + +function getUtilsMock() { + return { + getJwConfig: function () {}, + getSupportedMediaTypes: function () {}, + getStartDelay: function () {}, + getPlacement: function () {}, + getPlaybackMethod: function () {}, + isOmidSupported: function () {}, + getSkipParams: function () {}, + getJwEvent: function () {}, + getIsoLanguageCode: function () {}, + getSegments: function () {}, + getContentDatum: function () {} + }; +} + +const sharedUtils = { videoEvents }; + +describe('JWPlayerProvider', function () { + describe('init', function () { + let config; + let adState; + let timeState; + let callbackStorage; + let utilsMock; + + beforeEach(() => { + config = {}; + adState = adStateFactory(); + timeState = timeStateFactory(); + callbackStorage = callbackStorageFactory(); + utilsMock = getUtilsMock(); + }); + + it('should trigger failure when jwplayer is missing', function () { + const provider = JWPlayerProvider(config, null, adState, timeState, callbackStorage, utilsMock, sharedUtils); + const setupFailed = sinon.spy(); + provider.onEvents([SETUP_FAILED], setupFailed); + provider.init(); + expect(setupFailed.calledOnce).to.be.true; + const payload = setupFailed.args[0][1]; + expect(payload.errorCode).to.be.equal(-1); + }); + + it('should trigger failure when jwplayer version is under min supported version', function () { + let jwplayerMock = () => {}; + jwplayerMock.version = '8.20.0'; + const provider = JWPlayerProvider(config, jwplayerMock, adState, timeState, callbackStorage, utilsMock, sharedUtils); + const setupFailed = sinon.spy(); + provider.onEvents([SETUP_FAILED], setupFailed); + provider.init(); + expect(setupFailed.calledOnce).to.be.true; + const payload = setupFailed.args[0][1]; + expect(payload.errorCode).to.be.equal(-2); + }); + + it('should instantiate the player when uninstantied', function () { + const player = getPlayerMock(); + config.playerConfig = {}; + const setupSpy = player.setup = sinon.spy(); + const provider = JWPlayerProvider(config, makePlayerFactoryMock(player), adState, timeState, callbackStorage, utilsMock, sharedUtils); + provider.init(); + expect(setupSpy.calledOnce).to.be.true; + }); + + it('should trigger setup complete when player is already instantied', function () { + const player = getPlayerMock(); + player.getState = () => 'idle'; + const provider = JWPlayerProvider(config, makePlayerFactoryMock(player), adState, timeState, callbackStorage, utilsMock, sharedUtils); + const setupComplete = sinon.spy(); + provider.onEvents([SETUP_COMPLETE], setupComplete); + provider.init(); + expect(setupComplete.calledOnce).to.be.true; + }); + + it('should not reinstantiate player', function () { + const player = getPlayerMock(); + player.getState = () => 'idle'; + const setupSpy = player.setup = sinon.spy(); + const provider = JWPlayerProvider(config, makePlayerFactoryMock(player), adState, timeState, callbackStorage, utilsMock, sharedUtils); + provider.init(); + expect(setupSpy.called).to.be.false; + }); + }); + + describe('getId', function () { + it('should return configured div id', function () { + const provider = JWPlayerProvider({ divId: 'test_id' }, undefined, undefined, undefined, undefined, undefined, sharedUtils); + expect(provider.getId()).to.be.equal('test_id'); + }); + }); + + describe('getOrtbParams', function () { + it('should populate oRTB params', function () { + const test_media_type = VIDEO_MIME_TYPE.MP4; + const test_height = 100; + const test_width = 200; + const test_start_delay = 5; + const test_placement = PLACEMENT.ARTICLE; + const test_battr = 'battr'; + const test_playback_method = PLAYBACK_METHODS.CLICK_TO_PLAY; + const test_skip = 0; + const test_item = { + mediaid: 'id', + file: 'file', + title: 'title', + iabCategories: 'iabCategories', + tags: 'keywords', + }; + const test_duration = 30; + let test_playback_mode = PLAYBACK_MODE.VOD;// + + const config = {}; + const player = getPlayerMock(); + const utils = getUtilsMock(); + + player.getConfig = () => ({ + advertising: { + battr: test_battr + } + }); + player.getHeight = () => test_height; + player.getWidth = () => test_width; + player.getFullscreen = () => true; // + player.getPlaylistItem = () => test_item; + + utils.getSupportedMediaTypes = () => [test_media_type]; + utils.getStartDelay = () => test_start_delay; + utils.getPlacement = () => test_placement; + utils.getPlaybackMethod = () => test_playback_method; + utils.isOmidSupported = () => true; // + utils.getSkipParams = () => ({ skip: test_skip }); + + const timeState = { + getState: () => ({ + duration: test_duration, + playbackMode: test_playback_mode + }) + } + + const provider = JWPlayerProvider(config, makePlayerFactoryMock(player), adStateFactory(), timeState, {}, utils, sharedUtils); + provider.init(); + let oRTB = provider.getOrtbParams(); + + expect(oRTB).to.have.property('video'); + expect(oRTB).to.have.property('content'); + let { video, content } = oRTB; + + expect(video.mimes).to.include(VIDEO_MIME_TYPE.MP4); + expect(video.protocols).to.include.members([ + PROTOCOLS.VAST_2_0, + PROTOCOLS.VAST_3_0, + PROTOCOLS.VAST_4_0, + PROTOCOLS.VAST_2_0_WRAPPER, + PROTOCOLS.VAST_3_0_WRAPPER, + PROTOCOLS.VAST_4_0_WRAPPER + ]); + expect(video.h).to.equal(test_height); + expect(video.w).to.equal(test_width); + expect(video.startdelay).to.equal(test_start_delay); + expect(video.placement).to.equal(test_placement); + expect(video.battr).to.equal(test_battr); + expect(video.maxextended).to.equal(-1); + expect(video.boxingallowed).to.equal(1); + expect(video.playbackmethod).to.include(test_playback_method); + expect(video.playbackend).to.equal(1); + expect(video.api).to.have.length(2); + expect(video.api).to.include.members([API_FRAMEWORKS.VPAID_2_0, API_FRAMEWORKS.OMID_1_0]); // + expect(video.skip).to.equal(test_skip); + expect(video.pos).to.equal(7); // + + expect(content.id).to.be.equal('jw_' + test_item.mediaid); + expect(content.url).to.be.equal(test_item.file); + expect(content.title).to.be.equal(test_item.title); + expect(content.cat).to.be.equal(test_item.iabCategories); + expect(content.keywords).to.be.equal(test_item.tags); + expect(content.len).to.be.equal(test_duration); + expect(content.livestream).to.be.equal(0);// + + player.getFullscreen = () => false; + utils.isOmidSupported = () => false; + test_playback_mode = PLAYBACK_MODE.LIVE; + + oRTB = provider.getOrtbParams(); + video = oRTB.video; + content = oRTB.content; + expect(video).to.not.have.property('pos'); + expect(video.api).to.have.length(1); + expect(video.api).to.include(API_FRAMEWORKS.VPAID_2_0); + expect(video.api).to.not.include(API_FRAMEWORKS.OMID_1_0); + expect(content.livestream).to.be.equal(1); + + test_playback_mode = PLAYBACK_MODE.DVR; + + oRTB = provider.getOrtbParams(); + content = oRTB.content; + expect(content.livestream).to.be.equal(1); + }); + }); + + describe('setAdTagUrl', function () { + it('should call playAd', function () { + const player = getPlayerMock(); + const playAdSpy = player.playAd = sinon.spy(); + const provider = JWPlayerProvider({}, makePlayerFactoryMock(player), {}, {}, {}, {}, sharedUtils); + provider.init(); + provider.setAdTagUrl('tag'); + expect(playAdSpy.called).to.be.true; + const argument = playAdSpy.args[0][0]; + expect(argument).to.be.equal('tag'); + }); + }); + + describe('events', function () { + it('should register event listener on player', function () { + const player = getPlayerMock(); + const onSpy = player.on = sinon.spy(); + const provider = JWPlayerProvider({}, makePlayerFactoryMock(player), adStateFactory(), timeStateFactory(), callbackStorageFactory(), getUtilsMock(), sharedUtils); + provider.init(); + const callback = () => {}; + provider.onEvents([PLAY], callback); + expect(onSpy.calledOnce).to.be.true; + const eventName = onSpy.args[0][0]; + expect(eventName).to.be.equal('play'); + }); + + it('should remove event listener on player', function () { + const player = getPlayerMock(); + const offSpy = player.off = sinon.spy(); + const provider = JWPlayerProvider({}, makePlayerFactoryMock(player), adStateFactory(), timeStateFactory(), callbackStorageFactory(), utils, sharedUtils); + provider.init(); + const callback = () => {}; + provider.onEvents([AD_IMPRESSION], callback); + provider.offEvents([AD_IMPRESSION], callback); + expect(offSpy.calledOnce).to.be.true; + const eventName = offSpy.args[0][0]; + expect(eventName).to.be.equal('adViewableImpression'); + }); + }); + + describe('destroy', function () { + it('should remove and null the player', function () { + const player = getPlayerMock(); + const removeSpy = player.remove = sinon.spy(); + player.remove = removeSpy; + const provider = JWPlayerProvider({}, makePlayerFactoryMock(player), adStateFactory(), timeStateFactory(), callbackStorageFactory(), getUtilsMock(), sharedUtils); + provider.init(); + provider.destroy(); + provider.destroy(); + expect(removeSpy.calledOnce).to.be.true; + }); + }); +}); + +describe('adStateFactory', function () { + let adState = adStateFactory(); + + beforeEach(function() { + adState.clearState(); + }); + + it('should update state for ad events', function () { + const tag = 'tag'; + const adPosition = 'adPosition'; + const timeLoading = 'timeLoading'; + const id = 'id'; + const description = 'description'; + const adsystem = 'adsystem'; + const adtitle = 'adtitle'; + const advertiserId = 'advertiserId'; + const advertiser = 'advertiser'; + const dealId = 'dealId'; + const linear = 'linear'; + const vastversion = 'vastversion'; + const mediaFile = 'mediaFile'; + const adId = 'adId'; + const universalAdId = 'universalAdId'; + const creativeAdId = 'creativeAdId'; + const creativetype = 'creativetype'; + const clickThroughUrl = 'clickThroughUrl'; + const witem = 'witem'; + const wcount = 'wcount'; + const podcount = 'podcount'; + const sequence = 'sequence'; + + adState.updateForEvent({ + tag, + adPosition, + timeLoading, + id, + description, + adsystem, + adtitle, + advertiserId, + advertiser, + dealId, + linear, + vastversion, + mediaFile, + adId, + universalAdId, + creativeAdId, + creativetype, + clickThroughUrl, + witem, + wcount, + podcount, + sequence + }); + + const state = adState.getState(); + expect(state.adTagUrl).to.equal(tag); + expect(state.offset).to.equal(adPosition); + expect(state.loadTime).to.equal(timeLoading); + expect(state.vastAdId).to.equal(id); + expect(state.adDescription).to.equal(description); + expect(state.adServer).to.equal(adsystem); + expect(state.adTitle).to.equal(adtitle); + expect(state.advertiserId).to.equal(advertiserId); + expect(state.dealId).to.equal(dealId); + expect(state.linear).to.equal(linear); + expect(state.vastVersion).to.equal(vastversion); + expect(state.creativeUrl).to.equal(mediaFile); + expect(state.adId).to.equal(adId); + expect(state.universalAdId).to.equal(universalAdId); + expect(state.creativeId).to.equal(creativeAdId); + expect(state.creativeType).to.equal(creativetype); + expect(state.redirectUrl).to.equal(clickThroughUrl); + expect(state).to.have.property('adPlacementType'); + expect(state.adPlacementType).to.be.undefined; + expect(state.waterfallIndex).to.equal(witem); + expect(state.waterfallCount).to.equal(wcount); + expect(state.adPodCount).to.equal(podcount); + expect(state.adPodIndex).to.equal(sequence); + }); + + it('should convert placement to oRTB value', function () { + adState.updateForEvent({ + placement: 'instream' + }); + + let state = adState.getState(); + expect(state.adPlacementType).to.be.equal(PLACEMENT.INSTREAM); + + adState.updateForEvent({ + placement: 'banner' + }); + + state = adState.getState(); + expect(state.adPlacementType).to.be.equal(PLACEMENT.BANNER); + + adState.updateForEvent({ + placement: 'article' + }); + + state = adState.getState(); + expect(state.adPlacementType).to.be.equal(PLACEMENT.ARTICLE); + + adState.updateForEvent({ + placement: 'feed' + }); + + state = adState.getState(); + expect(state.adPlacementType).to.be.equal(PLACEMENT.FEED); + + adState.updateForEvent({ + placement: 'interstitial' + }); + + state = adState.getState(); + expect(state.adPlacementType).to.be.equal(PLACEMENT.INTERSTITIAL); + + adState.updateForEvent({ + placement: 'slider' + }); + + state = adState.getState(); + expect(state.adPlacementType).to.be.equal(PLACEMENT.SLIDER); + + adState.updateForEvent({ + placement: 'floating' + }); + + state = adState.getState(); + expect(state.adPlacementType).to.be.equal(PLACEMENT.FLOATING); + }); +}); + +describe('timeStateFactory', function () { + let timeState = timeStateFactory(); + + beforeEach(function() { + timeState.clearState(); + }); + + it('should update state for VOD time event', function() { + const position = 5; + const test_duration = 30; + + timeState.updateForEvent({ + position, + duration: test_duration + }); + + const { time, duration, playbackMode } = timeState.getState(); + expect(time).to.be.equal(position); + expect(duration).to.be.equal(test_duration); + expect(playbackMode).to.be.equal(PLAYBACK_MODE.VOD); + }); + + it('should update state for LIVE time events', function() { + const position = 0; + const test_duration = 0; + + timeState.updateForEvent({ + position, + duration: test_duration + }); + + const { time, duration, playbackMode } = timeState.getState(); + expect(time).to.be.equal(position); + expect(duration).to.be.equal(test_duration); + expect(playbackMode).to.be.equal(PLAYBACK_MODE.LIVE); + }); + + it('should update state for DVR time events', function() { + const position = -5; + const test_duration = -30; + + timeState.updateForEvent({ + position, + duration: test_duration + }); + + const { time, duration, playbackMode } = timeState.getState(); + expect(time).to.be.equal(position); + expect(duration).to.be.equal(test_duration); + expect(playbackMode).to.be.equal(PLAYBACK_MODE.DVR); + }); +}); + +describe('callbackStorageFactory', function () { + let callbackStorage = callbackStorageFactory(); + + beforeEach(function () { + callbackStorage.clearStorage(); + }); + + it('should store callbacks', function () { + const callback1 = () => 'callback1'; + const eventHandler1 = () => 'eventHandler1'; + callbackStorage.storeCallback('event', eventHandler1, callback1); + + const callback2 = () => 'callback2'; + const eventHandler2 = () => 'eventHandler2'; + callbackStorage.storeCallback('event', eventHandler2, callback2); + + const callback3 = () => 'callback3'; + + expect(callbackStorage.getCallback('event', callback1)).to.be.equal(eventHandler1); + expect(callbackStorage.getCallback('event', callback2)).to.be.equal(eventHandler2); + expect(callbackStorage.getCallback('event', callback3)).to.be.undefined; + }); + + it('should remove callbacks after retrieval', function () { + const callback1 = () => 'callback1'; + const eventHandler1 = () => 'eventHandler1'; + callbackStorage.storeCallback('event', eventHandler1, callback1); + + expect(callbackStorage.getCallback('event', callback1)).to.be.equal(eventHandler1); + expect(callbackStorage.getCallback('event', callback1)).to.be.undefined; + }); + + it('should clear callbacks', function () { + const callback1 = () => 'callback1'; + const eventHandler1 = () => 'eventHandler1'; + callbackStorage.storeCallback('event', eventHandler1, callback1); + + callbackStorage.clearStorage(); + expect(callbackStorage.getCallback('event', callback1)).to.be.undefined; + }); +}); + +describe('utils', function () { + describe('getJwConfig', function () { + const getJwConfig = utils.getJwConfig; + it('should return undefined when no config is provided', function () { + let jwConfig = getJwConfig(); + expect(jwConfig).to.be.undefined; + + jwConfig = getJwConfig(null); + expect(jwConfig).to.be.undefined; + }); + + it('should set vendor config params to top level', function () { + let jwConfig = getJwConfig({ + params: { + vendorConfig: { + 'test': 'a', + 'test_2': 'b' + } + } + }); + expect(jwConfig.test).to.be.equal('a'); + expect(jwConfig.test_2).to.be.equal('b'); + }); + + it('should convert video module params', function () { + let jwConfig = getJwConfig({ + mute: true, + autoStart: true, + licenseKey: 'key' + }); + + expect(jwConfig.mute).to.be.true; + expect(jwConfig.autostart).to.be.true; + expect(jwConfig.key).to.be.equal('key'); + }); + + it('should apply video module params only when absent from vendor config', function () { + let jwConfig = getJwConfig({ + mute: true, + autoStart: true, + licenseKey: 'key', + params: { + vendorConfig: { + mute: false, + autostart: false, + key: 'other_key' + } + } + }); + + expect(jwConfig.mute).to.be.false; + expect(jwConfig.autostart).to.be.false; + expect(jwConfig.key).to.be.equal('other_key'); + }); + + it('should not convert undefined properties', function () { + let jwConfig = getJwConfig({ + params: { + vendorConfig: { + test: 'a' + } + } + }); + + expect(jwConfig).to.not.have.property('mute'); + expect(jwConfig).to.not.have.property('autostart'); + expect(jwConfig).to.not.have.property('key'); + }); + + it('should exclude fallback ad block when adOptimization is explicitly disabled', function () { + let jwConfig = getJwConfig({ + params: { + adOptimization: false, + vendorConfig: {} + } + }); + + expect(jwConfig).to.not.have.property('advertising'); + }); + + it('should set advertising block when adOptimization is allowed', function () { + let jwConfig = getJwConfig({ + params: { + vendorConfig: { + advertising: { + tag: 'test_tag' + } + } + } + }); + + expect(jwConfig).to.have.property('advertising'); + expect(jwConfig.advertising).to.have.property('tag', 'test_tag'); + }); + + it('should fallback to vast plugin', function () { + let jwConfig = getJwConfig({}); + + expect(jwConfig).to.have.property('advertising'); + expect(jwConfig.advertising).to.have.property('client', 'vast'); + }); + }); + describe('getSkipParams', function () { + const getSkipParams = utils.getSkipParams; + + it('should return an empty object when skip is not configured', function () { + let skipParams = getSkipParams({}); + expect(skipParams).to.be.empty; + }); + + it('should set skip to false when explicitly configured', function () { + let skipParams = getSkipParams({ + skipoffset: -1 + }); + expect(skipParams.skip).to.be.equal(0); + expect(skipParams.skipmin).to.be.undefined; + expect(skipParams.skipafter).to.be.undefined; + }); + + it('should be skippable when skip offset is set', function () { + const skipOffset = 3; + let skipParams = getSkipParams({ + skipoffset: skipOffset + }); + expect(skipParams.skip).to.be.equal(1); + expect(skipParams.skipmin).to.be.equal(skipOffset + 2); + expect(skipParams.skipafter).to.be.equal(skipOffset); + }); + }); + + describe('getSupportedMediaTypes', function () { + const getSupportedMediaTypes = utils.getSupportedMediaTypes; + + it('should always support VPAID', function () { + let supportedMediaTypes = getSupportedMediaTypes([]); + expect(supportedMediaTypes).to.include(VPAID_MIME_TYPE); + + supportedMediaTypes = getSupportedMediaTypes([VIDEO_MIME_TYPE.MP4]); + expect(supportedMediaTypes).to.include(VPAID_MIME_TYPE); + }); + }); + + describe('getPlacement', function () { + const getPlacement = utils.getPlacement; + + it('should be INSTREAM when not configured for outstream', function () { + let adConfig = {}; + let placement = getPlacement(adConfig); + expect(placement).to.be.equal(PLACEMENT.INSTREAM); + + adConfig = { outstream: false }; + placement = getPlacement(adConfig); + expect(placement).to.be.equal(PLACEMENT.INSTREAM); + }); + + it('should be FLOATING when player is floating', function () { + const player = getPlayerMock(); + player.getFloating = () => true; + const placement = getPlacement({outstream: true}, player); + expect(placement).to.be.equal(PLACEMENT.FLOATING); + }); + + it('should be the value defined in the ad config', function () { + const player = getPlayerMock(); + player.getFloating = () => false; + + let placement = getPlacement({placement: 'banner', outstream: true}, player); + expect(placement).to.be.equal(PLACEMENT.BANNER); + + placement = getPlacement({placement: 'article', outstream: true}, player); + expect(placement).to.be.equal(PLACEMENT.ARTICLE); + + placement = getPlacement({placement: 'feed', outstream: true}, player); + expect(placement).to.be.equal(PLACEMENT.FEED); + + placement = getPlacement({placement: 'interstitial', outstream: true}, player); + expect(placement).to.be.equal(PLACEMENT.INTERSTITIAL); + + placement = getPlacement({placement: 'slider', outstream: true}, player); + expect(placement).to.be.equal(PLACEMENT.SLIDER); + }); + + it('should be undefined when undetermined', function () { + const placement = getPlacement({ outstream: true }, getPlayerMock()); + expect(placement).to.be.undefined; + }); + }); + + describe('getPlaybackMethod', function() { + const getPlaybackMethod = utils.getPlaybackMethod; + + it('should return autoplay with sound', function() { + const playbackMethod = getPlaybackMethod({ + autoplay: true, + mute: false + }); + expect(playbackMethod).to.equal(PLAYBACK_METHODS.AUTOPLAY); + }); + + it('should return autoplay muted', function() { + const playbackMethod = getPlaybackMethod({ + autoplay: true, + mute: true + }); + expect(playbackMethod).to.equal(PLAYBACK_METHODS.AUTOPLAY_MUTED); + }); + + it('should treat autoplayAdsMuted as mute', function () { + const playbackMethod = getPlaybackMethod({ + autoplay: true, + autoplayAdsMuted: true + }); + expect(playbackMethod).to.equal(PLAYBACK_METHODS.AUTOPLAY_MUTED); + }); + + it('should return click to play', function() { + let playbackMethod = getPlaybackMethod({ autoplay: false }); + expect(playbackMethod).to.equal(PLAYBACK_METHODS.CLICK_TO_PLAY); + + playbackMethod = getPlaybackMethod({ + autoplay: false, + autoplayAdsMuted: true + }); + expect(playbackMethod).to.equal(PLAYBACK_METHODS.CLICK_TO_PLAY); + + playbackMethod = getPlaybackMethod({ + autoplay: false, + mute: true + }); + expect(playbackMethod).to.equal(PLAYBACK_METHODS.CLICK_TO_PLAY); + }); + }); + + describe('isOmidSupported', function () { + const isOmidSupported = utils.isOmidSupported; + afterEach(() => { + window.OmidSessionClient = undefined; + }); + + it('should be true when Omid is loaded and client is VAST', function () { + window.OmidSessionClient = {}; + expect(isOmidSupported('vast')).to.be.true; + }); + + it('should be false when Omid is not present', function () { + expect(isOmidSupported('vast')).to.be.false; + }); + + it('should be false when client is not Vast', function () { + window.OmidSessionClient = {}; + expect(isOmidSupported('googima')).to.be.false; + expect(isOmidSupported('freewheel')).to.be.false; + expect(isOmidSupported('googimadai')).to.be.false; + expect(isOmidSupported('')).to.be.false; + expect(isOmidSupported(null)).to.be.false; + expect(isOmidSupported()).to.be.false; + }); + }); + + describe('getIsoLanguageCode', function () { + const sampleAudioTracks = [{language: 'ht'}, {language: 'fr'}, {language: 'es'}, {language: 'pt'}]; + + it('should return undefined when audio tracks are unavailable', function () { + const player = getPlayerMock(); + let languageCode = utils.getIsoLanguageCode(player); + expect(languageCode).to.be.undefined; + player.getAudioTracks = () => []; + languageCode = utils.getIsoLanguageCode(player); + expect(languageCode).to.be.undefined; + }); + + it('should return the first audio track language code if the getCurrentAudioTrack returns undefined', function () { + const player = getPlayerMock(); + player.getAudioTracks = () => sampleAudioTracks; + let languageCode = utils.getIsoLanguageCode(player); + expect(languageCode).to.be.equal('ht'); + }); + + it('should return the first audio track language code if the getCurrentAudioTrack returns null', function () { + const player = getPlayerMock(); + player.getAudioTracks = () => sampleAudioTracks; + player.getCurrentAudioTrack = () => null; + let languageCode = utils.getIsoLanguageCode(player); + expect(languageCode).to.be.equal('ht'); + }); + + it('should return the first audio track language code if the getCurrentAudioTrack returns -1', function () { + const player = getPlayerMock(); + player.getAudioTracks = () => sampleAudioTracks; + player.getCurrentAudioTrack = () => -1; + const languageCode = utils.getIsoLanguageCode(player); + expect(languageCode).to.be.equal('ht'); + }); + + it('should return the right audio track language code', function () { + const player = getPlayerMock(); + player.getAudioTracks = () => sampleAudioTracks; + player.getCurrentAudioTrack = () => 2; + const languageCode = utils.getIsoLanguageCode(player); + expect(languageCode).to.be.equal('es'); + }); + }); +}); diff --git a/test/spec/modules/videoModule/submodules/videojsVideoProvider_spec.js b/test/spec/modules/videoModule/submodules/videojsVideoProvider_spec.js new file mode 100644 index 00000000000..9f09e266d68 --- /dev/null +++ b/test/spec/modules/videoModule/submodules/videojsVideoProvider_spec.js @@ -0,0 +1,266 @@ +// Using require style imports for fine grained control of import time +const {VideojsProvider, utils} = require('modules/videojsVideoProvider') + +const { + PROTOCOLS, API_FRAMEWORKS, VIDEO_MIME_TYPE, PLAYBACK_METHODS, PLACEMENT, VPAID_MIME_TYPE +} = require('modules/videoModule/constants/ortb.js'); +const { AD_POSITION } = require('../../../../../modules/videoModule/constants/ortb'); + +import { + PLAYBACK_MODE, SETUP_COMPLETE, SETUP_FAILED, PLAY, AD_IMPRESSION +} from 'modules/videoModule/constants/events.js' + +const videojs = require('video.js').default + +describe('videojsProvider', function () { + let config; + let adState; + let timeState; + let callbackStorage; + let utilsMock; + + describe('init', function () { + beforeEach(() => { + config = {}; + document.body.innerHTML = ''; + }); + + it('should trigger failure when videojs is missing', function () { + const provider = VideojsProvider(config, null, adState, timeState, callbackStorage, utils); + const setupFailed = sinon.spy(); + provider.onEvents([SETUP_FAILED], setupFailed); + provider.init(); + expect(setupFailed.calledOnce).to.be.true; + const payload = setupFailed.args[0][1]; + expect(payload.errorCode).to.be.equal(-1); + }); + + it('should trigger failure when videojs version is under min supported version', function () { + const provider = VideojsProvider(config, {...videojs, VERSION:'0.0.0'}, adState, timeState, callbackStorage, utils); + const setupFailed = sinon.spy(); + provider.onEvents([SETUP_FAILED], setupFailed); + provider.init(); + expect(setupFailed.calledOnce).to.be.true; + const payload = setupFailed.args[0][1]; + expect(payload.errorCode).to.be.equal(-2); + }); + + it('should trigger failure when the div is not found', function () { + config.divId = 'fake-div' + const provider = VideojsProvider(config, videojs, adState, timeState, callbackStorage, utils); + const setupFailed = sinon.spy(); + provider.onEvents([SETUP_FAILED], setupFailed); + provider.init(); + expect(setupFailed.calledOnce).to.be.true; + const payload = setupFailed.args[0][1]; + expect(payload.errorCode).to.be.equal(-3); + }); + + it('should instantiate the player when uninstantied', function () { + config.playerConfig = {testAttr:true}; + config.divId = 'test-div' + const div = document.createElement('div'); + div.setAttribute('id', 'test-div'); + document.body.appendChild(div); + + const mockVideojs = sinon.spy(); + const provider = VideojsProvider(config, mockVideojs, adState, timeState, callbackStorage, utils); + provider.init(); + expect(mockVideojs.calledWith(config.divId, config.playerConfig)).to.be.true + }); + + it('should not reinstantiate the player', function () { + const div = document.createElement('div'); + div.setAttribute('id', 'test-div'); + document.body.appendChild(div); + const player = videojs(div, {}) + config.playerConfig = {}; + config.divId = 'test-div' + const provider = VideojsProvider(config, videojs, adState, timeState, callbackStorage, utils); + provider.init(); + expect(videojs.getPlayer('test-div')).to.be.equal(player) + videojs.getPlayer('test-div').dispose() + }); + + it('should trigger setup complete when player is already insantiated', function () { + const div = document.createElement('div'); + div.setAttribute('id', 'test-div'); + document.body.appendChild(div); + videojs(div, {}) + config.playerConfig = {}; + config.divId = 'test-div' + const provider = VideojsProvider(config, videojs, adState, timeState, callbackStorage, utils); + const setupComplete = sinon.spy(); + provider.onEvents([SETUP_COMPLETE], setupComplete); + provider.init(); + expect(setupComplete.calledOnce).to.be.true; + videojs.getPlayer('test-div').dispose() + }); + + }); + + describe('getId', function () { + it('should return configured div id', function () { + const provider = VideojsProvider({ divId: 'test_id' }); + expect(provider.getId()).to.be.equal('test_id'); + }); + }); + + describe('getOrtbParams', function () { + beforeEach(() => { + config = {divId: 'test'}; + // initialize videojs element + document.body.innerHTML = ` + ` + }); + + it('should populate oRTB params without ima present', function () { + const provider = VideojsProvider(config, videojs, adState, timeState, callbackStorage, utils); + provider.init(); + + const oRTB = provider.getOrtbParams(); + expect(oRTB).to.have.property('video'); + expect(oRTB).to.have.property('content'); + const { video, content } = oRTB; + + expect(video.mimes).to.include(VIDEO_MIME_TYPE.MP4); + expect(video.protocols).to.deep.equal([]); + expect(video.h).to.equal(100); + expect(video.w).to.equal(200); + + expect(video.maxextended).to.equal(-1); + expect(video.boxingallowed).to.equal(1); + expect(video.playbackmethod).to.include(PLAYBACK_METHODS.CLICK_TO_PLAY); + expect(video.playbackend).to.equal(1); + expect(video.api).to.deep.equal([]); + expect(video.placement).to.be.equal(PLACEMENT.IN_STREAM); + + expect(content.url).to.be.equal('http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4'); + expect(content).to.not.have.property('len'); + }); + + it('should change populated oRTB params when ima present', function () { + require('videojs-contrib-ads'); + require('videojs-ima'); + document.body.innerHTML = ` + ` + + let player = videojs('test') + + config.playerConfig = { + params: { + vendorConfig: { + mediaid: 'vendor-id', + advertising: { + tag: ['test-tag'] + } + } + } + } + + let provider = VideojsProvider(config, videojs, null, null, null, utils); + provider.init(); + let { video, content } = provider.getOrtbParams(); + + expect(video.protocols).to.include(PROTOCOLS.VAST_2_0); + expect(video.api).to.include(API_FRAMEWORKS.VPAID_2_0); + expect(video.mimes).to.include(VPAID_MIME_TYPE); + player.dispose(); + }); + + // We can't determine what type of outstream play is occuring + // if the src is absent so we should not set placement + it('should not set placement when src is absent', function() { + document.body.innerHTML = `` + const provider = VideojsProvider(config, videojs, null, null, null, utils); + provider.init(); + const { video, content } = provider.getOrtbParams(); + expect(video).to.not.have.property('placement') + }) + + it('should populate position when fullscreen', function () { + const provider = VideojsProvider(config, videojs, null, null, null, utils); + provider.init(); + const player = videojs.getPlayer('test') + player.isFullscreen = () => true; + const { video, content } = provider.getOrtbParams(); ; + expect(video.pos).to.equal(7); + }); + + it('should populate length when loaded', function () { + const provider = VideojsProvider(config, videojs, null, null, null, utils); + provider.init(); + const player = videojs.getPlayer('test') + player.readyState = () => 1 + player.duration = () => 100 + const { video, content } = provider.getOrtbParams(); + expect(content.len).to.equal(100); + }); + + it('should return the correct playback method for autoplay', function () { + const provider = VideojsProvider(config, videojs, null, null, null, utils); + provider.init(); + const player = videojs.getPlayer('test') + player.autoplay(true) + const { video, content } = provider.getOrtbParams(); + expect(video.playbackmethod).to.include(PLAYBACK_METHODS.AUTOPLAY); + }); + + it('should return the correct playback method for autoplay muted', function () { + const provider = VideojsProvider(config, videojs, null, null, null, utils); + provider.init(); + const player = videojs.getPlayer('test') + player.muted = () => true + player.autoplay = () => true + const { video, content } = provider.getOrtbParams(); + expect(video.playbackmethod).to.include(PLAYBACK_METHODS.AUTOPLAY_MUTED); + }); + + it('should return the correct playback method for the other autoplay muted', function () { + const provider = VideojsProvider(config, videojs, null, null, null, utils); + provider.init(); + const player = videojs.getPlayer('test') + player.autoplay = () => 'muted' + const { video, content } = provider.getOrtbParams(); + expect(video.playbackmethod).to.include(PLAYBACK_METHODS.AUTOPLAY_MUTED); + }); + }); +}); + +describe('utils', function() { + describe('getPositionCode', function() { + it('should return the correct position when video is above the fold', function () { + const code = utils.getPositionCode({ + left: window.innerWidth / 10, + top: 0, + width: window.innerWidth - window.innerWidth / 10, + height: window.innerHeight, + }) + expect(code).to.equal(AD_POSITION.ABOVE_THE_FOLD) + }); + + it('should return the correct position when video is below the fold', function () { + const code = utils.getPositionCode({ + left: window.innerWidth / 10, + top: window.innerHeight, + width: window.innerWidth - window.innerWidth / 10, + height: window.innerHeight / 2, + }) + expect(code).to.equal(AD_POSITION.BELOW_THE_FOLD) + }); + + it('should return the unkown position when the video is out of bounds', function () { + const code = utils.getPositionCode({ + left: window.innerWidth / 10, + top: window.innerHeight, + width: window.innerWidth, + height: window.innerHeight, + }) + expect(code).to.equal(AD_POSITION.UNKNOWN) + }); + }) +}) diff --git a/test/spec/modules/videoModule/videoImpressionVerifier_spec.js b/test/spec/modules/videoModule/videoImpressionVerifier_spec.js new file mode 100644 index 00000000000..cfce7933895 --- /dev/null +++ b/test/spec/modules/videoModule/videoImpressionVerifier_spec.js @@ -0,0 +1,112 @@ +import { baseImpressionVerifier, PB_PREFIX } from 'libraries/video/videoImpressionVerifier.js'; + +let trackerMock; +trackerMock = { + store: sinon.spy(), + remove: sinon.spy() +} + +describe('Base Impression Verifier', function() { + describe('trackBid', function () { + it('should generate uuid', function () { + const baseVerifier = baseImpressionVerifier(trackerMock); + const uuid = baseVerifier.trackBid({}); + expect(uuid.substring(0, 3)).to.equal(PB_PREFIX); + expect(uuid.length).to.be.lessThan(16); + }); + }); + + describe('getBidIdentifiers', function () { + it('should match ad id to uuid', function () { + + }); + }); +}); + +/* +const adUnitCode = 'test_ad_unit_code'; +const sampleBid = { + adId: 'test_ad_id', + adUnitCode, + vastUrl: 'test_ad_url' +}; +const sampleAdUnit = { + code: adUnitCode, +}; + +const expectedImpressionUrl = 'test_impression_url'; +const expectedImpressionId = 'test_impression_id'; +const expectedErrorUrl = 'test_error_url'; +const expectedVastXml = 'test_xml'; + +it('should not modify the bid\'s adXml when the tracking config is omitted', function () { + const adUnit = Object.assign({}, sampleAdUnit, { video: { adServer: { tracking: null } } }); + const pbGlobal = Object.assign({}, pbGlobalMock, { adUnits: [ adUnit ] }); + pbVideoFactory(null, () => ({}), pbGlobal, pbEvents); + + bidAdjustmentCb(sampleBid); + // expect(vastXmlEditorMock.getVastXmlWithTrackingNodes.called).to.be.false; + // expect(vastXmlEditorMock.buildVastWrapper.called).to.be.false; +}); + +it('should request a vast wrapper when only an ad url is provided', function () { + const adUnit = Object.assign({}, sampleAdUnit, { video: { adServer: { tracking: { } } } }); + const pbGlobal = Object.assign({}, pbGlobalMock, { adUnits: [ adUnit ] }); + pbVideoFactory(null, () => ({}), pbGlobal, pbEvents); + + bidAdjustmentCb(sampleBid); + // expect(vastXmlEditorMock.getVastXmlWithTrackingNodes.called).to.be.false; + // expect(vastXmlEditorMock.buildVastWrapper.called).to.be.true; +}); + +it('should request the addition of tracking nodes when an ad xml is provided', function () { + const adUnit = Object.assign({}, sampleAdUnit, { video: { adServer: { tracking: { } } } }); + const pbGlobal = Object.assign({}, pbGlobalMock, { adUnits: [ adUnit ] }); + pbVideoFactory(null, () => ({}), pbGlobal, pbEvents); + + const bid = Object.assign({}, sampleBid, { vastXml: 'test_xml' }); + bidAdjustmentCb(bid); + // expect(vastXmlEditorMock.getVastXmlWithTrackingNodes.called).to.be.true; + // expect(vastXmlEditorMock.buildVastWrapper.called).to.be.false; +}); + +it('should pass the tracking information as args to the xml editing function', function () { + const adUnit = Object.assign({}, sampleAdUnit, { video: { adServer: { tracking: { + impression: { + url: expectedImpressionUrl, + id: expectedImpressionId + }, + error: { + url: expectedErrorUrl + } + } } } }); + const pbGlobal = Object.assign({}, pbGlobalMock, { adUnits: [ adUnit ] }); + pbVideoFactory(null, () => ({}), pbGlobal, pbEvents); + + const bid = Object.assign({}, sampleBid, { vastXml: expectedVastXml }); + bidAdjustmentCb(bid); + // expect(vastXmlEditorMock.getVastXmlWithTrackingNodes.called).to.be.true; + // expect(vastXmlEditorMock.getVastXmlWithTrackingNodes.calledWith(expectedVastXml, expectedImpressionUrl, expectedImpressionId, expectedErrorUrl)) + // expect(vastXmlEditorMock.buildVastWrapper.called).to.be.false; +}); + +it('should generate the impression id when not specified in config', function () { + const adUnit = Object.assign({}, sampleAdUnit, { video: { adServer: { tracking: { + impression: { + url: expectedImpressionUrl, + }, + error: { + url: expectedErrorUrl + } + } } } }); + const pbGlobal = Object.assign({}, pbGlobalMock, { adUnits: [ adUnit ] }); + pbVideoFactory(null, () => ({}), pbGlobal, pbEvents); + + const bid = Object.assign({}, sampleBid, { vastXml: expectedVastXml }); + bidAdjustmentCb(bid); + const expectedGeneratedId = sampleBid.adId + '-impression'; + // expect(vastXmlEditorMock.getVastXmlWithTrackingNodes.called).to.be.true; + // expect(vastXmlEditorMock.getVastXmlWithTrackingNodes.calledWith(expectedVastXml, expectedImpressionUrl, expectedGeneratedId, expectedErrorUrl)) + // expect(vastXmlEditorMock.buildVastWrapper.called).to.be.false; +}); +*/ diff --git a/test/spec/unit/pbjs_api_spec.js b/test/spec/unit/pbjs_api_spec.js index 1abb89f3d24..929de6d2014 100644 --- a/test/spec/unit/pbjs_api_spec.js +++ b/test/spec/unit/pbjs_api_spec.js @@ -1198,7 +1198,7 @@ describe('Unit: Prebid Module', function () { it('should require doc and id params', function () { $$PREBID_GLOBAL$$.renderAd(); - var error = 'Error trying to write ad Id :undefined to the page. Missing document or adId'; + var error = 'Error trying to write ad Id :undefined to the page. Missing adId'; assert.ok(spyLogError.calledWith(error), 'expected param error was logged'); });