diff --git a/modules/videoModule/index.js b/modules/videoModule/index.js index 2f618bc4733..bd249a1586d 100644 --- a/modules/videoModule/index.js +++ b/modules/videoModule/index.js @@ -4,10 +4,12 @@ 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_) { +export function PbVideo(videoCore_, getConfig_, pbGlobal_, pbEvents_, videoEvents_, adServerCore_, vastXmlEditor_) { const videoCore = videoCore_; const getConfig = getConfig_; const pbGlobal = pbGlobal_; @@ -15,6 +17,7 @@ export function PbVideo(videoCore_, getConfig_, pbGlobal_, pbEvents_, videoEvent const pbEvents = pbEvents_; const videoEvents = videoEvents_; const adServerCore = adServerCore_; + const vastXmlEditor = vastXmlEditor_; function init() { getConfig('video', ({ video }) => { @@ -42,6 +45,20 @@ export function PbVideo(videoCore_, getConfig_, pbGlobal_, pbEvents_, videoEvent } }); }); + + 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 }; @@ -81,12 +98,44 @@ export function PbVideo(videoCore_, getConfig_, pbGlobal_, pbEvents_, videoEvent 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 pbVideo = PbVideo(videoCore, config.getConfig, $$PREBID_GLOBAL$$, events, allVideoEvents, adServerCore); + const vastXmlEditor = vastXmlEditorFactory(); + const pbVideo = PbVideo(videoCore, config.getConfig, $$PREBID_GLOBAL$$, events, allVideoEvents, adServerCore, vastXmlEditor); pbVideo.init(); return pbVideo; } diff --git a/modules/videoModule/shared/vastXmlBuilder.js b/modules/videoModule/shared/vastXmlBuilder.js new file mode 100644 index 00000000000..8f029c2c678 --- /dev/null +++ b/modules/videoModule/shared/vastXmlBuilder.js @@ -0,0 +1,76 @@ + +export function buildVastWrapper(adId, adTagUrl, impressionUrl, impressionId, errorUrl) { + let wrapperBody = getAdSystemNode('Prebid org', $$PREBID_GLOBAL$$.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/modules/videoModule/shared/vastXmlEditor.js b/modules/videoModule/shared/vastXmlEditor.js new file mode 100644 index 00000000000..c5cb113c299 --- /dev/null +++ b/modules/videoModule/shared/vastXmlEditor.js @@ -0,0 +1,98 @@ +import { getErrorNode, getImpressionNode, buildVastWrapper } from './vastXmlBuilder.js'; + +export const XML_MIME_TYPE = 'application/xml'; + +export function VastXmlEditor(xmlUtil_) { + const xmlUtil = xmlUtil_; + + function getVastXmlWithTrackingNodes(vastXml, impressionUrl, impressionId, errorUrl) { + const impressionDoc = getImpressionDoc(impressionUrl, impressionId); + const errorDoc = getErrorDoc(errorUrl); + if (!impressionDoc && !errorDoc) { + return vastXml; + } + + const vastXmlDoc = xmlUtil.parse(vastXml); + 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); + } + + return xmlUtil.serialize(vastXmlDoc); + } + + return { + getVastXmlWithTrackingNodes, + 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/test/spec/modules/videoModule/pbVideo_spec.js b/test/spec/modules/videoModule/pbVideo_spec.js index ea0a54dc2eb..b46741312f0 100644 --- a/test/spec/modules/videoModule/pbVideo_spec.js +++ b/test/spec/modules/videoModule/pbVideo_spec.js @@ -1,5 +1,6 @@ import { expect } from 'chai'; import { PbVideo } from 'modules/videoModule/index.js'; +import CONSTANTS from 'src/constants.json'; let ortbParamsMock; let videoCoreMock; @@ -9,6 +10,7 @@ let pbGlobalMock; let pbEventsMock; let videoEventsMock; let adServerMock; +let vastXmlEditorMock; function resetTestVars() { ortbParamsMock = { @@ -38,16 +40,21 @@ function resetTestVars() { registerAdServer: sinon.spy(), getAdTagUrl: sinon.spy() }; + vastXmlEditorMock = { + getVastXmlWithTrackingNodes: sinon.spy(), + buildVastWrapper: sinon.spy() + }; } -let pbVideoFactory = (videoCore, getConfig, pbGlobal, pbEvents, videoEvents, adServer) => { +let pbVideoFactory = (videoCore, getConfig, pbGlobal, pbEvents, videoEvents, adServer, vastXmlEditor) => { const pbVideo = PbVideo( videoCore || videoCoreMock, getConfig || getConfigMock, pbGlobal || pbGlobalMock, pbEvents || pbEventsMock, videoEvents || videoEventsMock, - adServer || adServerMock + adServer || adServerMock, + vastXmlEditor || vastXmlEditorMock ); pbVideo.init(); return pbVideo; @@ -59,8 +66,10 @@ describe('Prebid Video', function () { describe('Setting video to config', function () { let providers = [{ divId: 'div1' }, { divId: 'div2' }]; let getConfigCallback; - let getConfig = (video, callback) => { - getConfigCallback = callback; + let getConfig = (propertyName, callback) => { + if (propertyName === 'video') { + getConfigCallback = callback; + } }; beforeEach(() => { @@ -213,4 +222,105 @@ describe('Prebid Video', function () { expect(videoCoreMock.setAdTagUrl.args[0][2]).to.have.property('adXml', expectedVastXml); }); }); + + describe('Ad tracking', function () { + let bidAdjustmentCb; + const adUnitCode = 'test_ad_unit_code'; + const sampleBid = { + adId: 'test_ad_id', + adUnitCode, + vastUrl: 'test_ad_url' + }; + const sampleAdUnit = { + code: adUnitCode, + }; + const pbEvents = { + on: (event, callback) => { + if (event === CONSTANTS.EVENTS.BID_ADJUSTMENT) { + bidAdjustmentCb = callback; + } + }, + emit: () => {} + }; + const expectedImpressionUrl = 'test_impression_url'; + const expectedImpressionId = 'test_impression_id'; + const expectedErrorUrl = 'test_error_url'; + const expectedVastXml = 'test_xml'; + + it('should not listen for bid adjustments when caching is not configured', function () { + pbVideoFactory(null, () => null); + expect(pbEventsMock.on.neverCalledWith(CONSTANTS.EVENTS.BID_ADJUSTMENT)).to.be.true; + }); + + 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/modules/videoModule/shared/vastXmlBuilder_spec.js b/test/spec/modules/videoModule/shared/vastXmlBuilder_spec.js new file mode 100644 index 00000000000..1007e3898c2 --- /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 'modules/videoModule/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..3040e54a571 --- /dev/null +++ b/test/spec/modules/videoModule/shared/vastXmlEditor_spec.js @@ -0,0 +1,183 @@ +import { vastXmlEditorFactory } from 'modules/videoModule/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.getVastXmlWithTrackingNodes(adWrapperXml, expectedImpressionUrl, expectedImpressionId); + const expectedXml = ` + + + Prebid org + + + +`; + expect(vastXml).to.equal(expectedXml); + }); + + it('should add Impression Nodes to the InLine', function () { + const vastXml = vastXmlEditor.getVastXmlWithTrackingNodes(inlineXml, 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.getVastXmlWithTrackingNodes(inLineWithWrapper, 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.getVastXmlWithTrackingNodes(adWrapperXml, null, null, expectedErrorUrl); + const expectedXml = ` + + + Prebid org + + + +`; + expect(vastXml).to.equal(expectedXml); + }); + + it('should add Error Nodes to the InLine', function () { + const vastXml = vastXmlEditor.getVastXmlWithTrackingNodes(inlineXml, 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.getVastXmlWithTrackingNodes(inLineWithWrapper, 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.getVastXmlWithTrackingNodes(adWrapperXml, 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.getVastXmlWithTrackingNodes(inlineXml, 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.getVastXmlWithTrackingNodes(inLineWithWrapper, expectedImpressionUrl, expectedImpressionId, expectedErrorUrl); + const expectedXml = ` + + + Prebid org + + + + + + Prebid org + Random Title + + +`; + expect(vastXml).to.equal(expectedXml); + }); +});