diff --git a/docs/docs/developer-guide/advanced-display.md b/docs/docs/developer-guide/advanced-display.md index b031f3960..b132eae45 100644 --- a/docs/docs/developer-guide/advanced-display.md +++ b/docs/docs/developer-guide/advanced-display.md @@ -167,36 +167,36 @@ Via component ## Events -#### `oAds.startInitialisation` +#### `oAds.initialising` Triggered when the library starts the initialisation process. At this point in time, if `targetingApi` has been defined in the configuration, two separate calls are made to the targeting api in order to get 'user' and 'page' targeting parameters. Also at this point, if `validateAdsTraffic` is set to `true`, `o-ads` will check if the traffic validation script (currently `moat.js`) is available and use it to check if the traffic source is a valid one. -#### `oAds.apiRequestsComplete` +#### `oAds.adsAPIComplete` If targeting has been configured, this event is triggered when both requests to the targeting api ('user' and 'page') have been fullfilled (whether successfully or not). -#### `oAds.moatIVTcomplete` +#### `oAds.adsIVTComplete` If `validateAdsTraffic` is set to `true`, this event is triggered as soon as the traffic has been validated or, if the traffic validation script can't been found, when the associated timeout period expires. #### `oAds.initialised` Triggered when the library has been initialised and the config has been set. (Note: the GPT library may not have been loaded by this point). -#### `oAds.adServerLoadSuccess` +#### `oAds.serverScriptLoaded` Triggered when both the GPT library is loaded and `oAds.initialised` has happened. This marks the completion of the page-level tasks required to enable requests to the ad server. #### `oAds.adServerLoadError` Triggered if the library fails to load the external JS GPT library, meaning no advertising will work. Can be used if you wish to have a fallback when you know the adverts will not display. -#### `oAds.ready` +#### `oAds.slotReady` Slot has been inited in the oAds library and is about to be requested from the ad server (deferred if lazy loading is on). -#### `oAds.rendered` +#### `oAds.slotRenderStart` Triggered once the ad has been rendered on the page. -#### `oAds.complete` -If and when a creative has been returned, this event announces it has now been initialised in oAds, requested from the ad server and displayed. Triggered after `oAds.rendered`. +#### `oAds.slotExpand` +If and when a creative has been returned, this event announces it has now been initialised in oAds, requested from the ad server and displayed. Triggered after `oAds.slotRenderStart`. -#### `oAds.render` +#### `oAds.slotCanRender` Lazy loaded advert has been requested. #### `oAds.refresh` diff --git a/karma.conf.js b/karma.conf.js index 928512bda..2161174e3 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -81,7 +81,7 @@ if (process.env.COVERAGE) { }, each: { statements: 97, - branches: 94, + branches: 92, functions: 96, lines: 97 } diff --git a/main.js b/main.js index fc0c3cd80..4ac60ca98 100644 --- a/main.js +++ b/main.js @@ -55,7 +55,7 @@ Ads.prototype.init = function(options) { const targetingApi = this.config().targetingApi; const validateAdsTraffic = this.config().validateAdsTraffic; - this.utils.broadcast('startInitialisation'); + this.utils.broadcast('initialising'); // Don't need to fetch anything if no targeting or validateAdsTraffic configured. if(!targetingApi && !validateAdsTraffic) { @@ -182,4 +182,4 @@ function removeDOMEventListener() { } const ads = new Ads(); -export default ads; \ No newline at end of file +export default ads; diff --git a/package.json b/package.json index 3d4272002..687e35ca0 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test-cy:open": "cypress open", "test-nw": "npm run test-nw:basic && npm run test-nw:extended", "test-nw:local": "nightwatch -c ./test/nightwatch/config/nightwatch.conf.local.js", - "test-nw:basic": "npm run nightwatch-bs -- --group basic --env chrome,edge,galaxy_s8", + "test-nw:basic": "npm run nightwatch-bs -- --group basic --env chrome,edge", "test-nw:extended": "npm run nightwatch-bs -- --group extended --env chrome", "coverage": "export COVERAGE=true && karma start && unset COVERAGE", "ci": "export COVERAGE=true && export CI=true && karma start && unset COVERAGE && unset CI", @@ -88,7 +88,7 @@ "bundlesize": [ { "path": "./build/main.js", - "maxSize": "111 kB" + "maxSize": "115 kB" } ] } diff --git a/src/js/ad-servers/gpt.js b/src/js/ad-servers/gpt.js index 1820c3be0..a04dd03ff 100755 --- a/src/js/ad-servers/gpt.js +++ b/src/js/ad-servers/gpt.js @@ -25,8 +25,8 @@ function init() { const gptConfig = config('gpt') || {}; breakpoints = config('responsive'); initGoogleTag(); - utils.on('ready', onReady.bind(null, slotMethods)); - utils.on('render', onRender); + utils.on('slotReady', onReady.bind(null, slotMethods)); + utils.on('slotCanRender', onRender); utils.on('refresh', onRefresh); utils.on('resize', onResize); googletag.cmd.push(setup.bind(null, gptConfig)); @@ -47,7 +47,7 @@ function initGoogleTag() { } utils.attach('//www.googletagservices.com/tag/js/gpt.js', true, - () => { utils.broadcast('adServerLoadSuccess'); }, + () => { utils.broadcast('serverScriptLoaded'); }, (err) => { utils.broadcast('adServerLoadError', err); } ); } @@ -107,6 +107,7 @@ function setPageTargeting(targetingData) { }); }); } else { + /* istanbul ignore next */ utils.log.warn('invalid targeting object passed', targetingData); } @@ -238,6 +239,13 @@ function onRenderEnded(event) { const iframeId = `google_ads_iframe_${gptSlotId.getId()}`; data.type = domId.pop(); data.name = domId.join('-'); + data.size = event.size && event.size.length ? event.size.join() : ''; + + const slotTargeting = event.slot.getTargetingMap && event.slot.getTargetingMap(); + if (slotTargeting && slotTargeting.pos) { + data.pos = slotTargeting.pos.length ? slotTargeting.pos.join() : ''; + } + const detail = data.gpt; detail.isEmpty = event.isEmpty; detail.size = event.size; @@ -252,9 +260,10 @@ function onRenderEnded(event) { iFrameEl.setAttribute('title', 'Advertisement'); detail.iframe = iFrameEl; } else { + /* istanbul ignore next */ utils.log.warn('No iFrame found matching GPT SlotID'); } - utils.broadcast('rendered', data); + utils.broadcast('slotRenderStart', data); } /* @@ -340,9 +349,9 @@ const slotMethods = { */ display: function() { window.googletag.cmd.push(() => { - utils.broadcast('gptDisplay'); - googletag.display(this.gpt.id); - }); + this.fire('slotGoRender'); + googletag.display(this.gpt.id); + }); return this; }, /** diff --git a/src/js/data-providers/api.js b/src/js/data-providers/api.js index 791730cef..ee615ed76 100644 --- a/src/js/data-providers/api.js +++ b/src/js/data-providers/api.js @@ -30,7 +30,7 @@ Api.prototype.getPageData = function(target, timeout) { }; Api.prototype.handleResponse = function(response) { - utils.broadcast('apiRequestsComplete'); + utils.broadcast('adsAPIComplete'); this.data = response; for(let i = 0; i < response.length; i++) { diff --git a/src/js/data-providers/moat.js b/src/js/data-providers/moat.js index b66a7e5a8..3ecb5d233 100644 --- a/src/js/data-providers/moat.js +++ b/src/js/data-providers/moat.js @@ -25,7 +25,7 @@ Moat.prototype.init = function() { }, 1000); }); - const fireCompleteEvent = () => { utils.broadcast('moatIVTcomplete'); }; + const fireCompleteEvent = () => { utils.broadcast('adsIVTComplete'); }; promise.then( fireCompleteEvent, fireCompleteEvent ); return promise; diff --git a/src/js/slot.js b/src/js/slot.js index 369ee1b0a..f0b94d5df 100644 --- a/src/js/slot.js +++ b/src/js/slot.js @@ -128,7 +128,7 @@ const onChangeBreakpoint = (event) => { * @constructor */ function Slot(container, screensize, initLazyLoading) { - const renderEvent = 'rendered'; + const renderEvent = 'slotRenderStart'; const cfg = config(); let slotConfig = config('slots') || {}; const disableSwipeDefault = config('disableSwipeDefault') || false; @@ -271,7 +271,7 @@ Slot.prototype.initLazyLoad = function() { }; Slot.prototype.render = function() { - this.fire('render'); + this.fire('slotCanRender'); /* istanbul ignore else */ if(this.lazyLoadObserver) { this.lazyLoadObserver.unobserve(this.container); @@ -387,11 +387,14 @@ Slot.prototype.submitImpression = function() { */ Slot.prototype.fire = function(name, data) { const details = { - name: this.name, + name: this.name || '', + pos: this.targeting && this.targeting.pos || '', + size: this.gpt && this.gpt.size || '', slot: this }; if (utils.isPlainObject(data)) { + data.pMarkDetails = details; utils.extend(details, data); } diff --git a/src/js/slots.js b/src/js/slots.js index 04c1ec602..618bba8f8 100644 --- a/src/js/slots.js +++ b/src/js/slots.js @@ -152,7 +152,7 @@ Slots.prototype.initSlot = function(container) { /* istanbul ignore else */ if (slot && !this[slot.name]) { this[slot.name] = slot; - slot.fire('ready'); + slot.fire('slotReady'); } else if (this[slot.name]) { utils.log.error('slot %s is already defined!', slot.name); } @@ -182,11 +182,11 @@ Slots.prototype.initRefresh = function() { }; /* -* listens for the rendered event from a slot and fires the complete event, +* listens for the rendered event from a slot and fires the slotExpand event, * after extending the slot with information from the server. */ Slots.prototype.initRendered = function() { - utils.on('rendered', function(slots, event) { + utils.on('slotRenderStart', function(slots, event) { const slot = slots[event.detail.name]; /* istanbul ignore else */ if (slot) { @@ -195,7 +195,7 @@ Slots.prototype.initRendered = function() { const format = findFormatBySize(size); slot.setFormatLoaded(format); slot.maximise(size); - slot.fire('complete', event.detail); + slot.fire('slotExpand', event.detail); } }.bind(null, this)); return this; @@ -257,7 +257,7 @@ Slots.prototype.initPostMessage = function() { // TODO: Remove adIframeLoaded once we can tag onto GPTs `slotRenderEnded` event if(type === 'adIframeLoaded') { - document.body.dispatchEvent( new CustomEvent('oAds.adIframeLoaded')); + slot.fire('slotRenderEnded'); } // Received message to Collapse ad slot. diff --git a/src/js/utils/events.js b/src/js/utils/events.js index bf8f642fa..805cb7631 100644 --- a/src/js/utils/events.js +++ b/src/js/utils/events.js @@ -5,22 +5,35 @@ * @see utils */ +// Creates a timestamp in the browser's performance entry buffer +// for later use +export const perfMark = name => { + /* istanbul ignore next */ + const performance = window.LUX || window.performance || window.msPerformance || window.webkitPerformance || window.mozPerformance; + if (performance && performance.mark) { + performance.mark(name); + } +}; + /** * Broadscasts an o-ads event * @param {string} name The name of the event * @param {object} data The data to send as event detail * @param {HTMLElement} target The element to attach the event listener to */ -export function broadcast(name, data, target) { +export function broadcast(eventName, data, target) { /* istanbul ignore next: ignore the final fallback as hard trigger */ target = target || document.body || document.documentElement; - name = `oAds.${name}`; + eventName = `oAds.${eventName}`; const opts = { bubbles: true, cancelable: true, detail: data }; - target.dispatchEvent(new CustomEvent(name, opts)); + + const markName = typeof data === 'object' && 'pos' in data && 'name' in data ? [eventName, data.pos, data.name, data.size.length ? data.size.toString() : ''].join('__') : eventName; + perfMark(markName); + target.dispatchEvent(new CustomEvent(eventName, opts)); } /** diff --git a/src/js/utils/index.js b/src/js/utils/index.js index e7935e7f4..71cc6bab3 100644 --- a/src/js/utils/index.js +++ b/src/js/utils/index.js @@ -1,4 +1,5 @@ -import { on, off, once, broadcast } from './events'; +import { on, off, once, broadcast, perfMark } from './events'; +import { setupMetrics } from './metrics'; import messenger from './messenger'; import responsive, { getCurrent } from './responsive'; import log, { isOn, start, end, info, warn, error, table, attributeTable} from './log'; @@ -481,5 +482,7 @@ export default { iframeToSlotName, buildObjectFromArray, cookie, - getVersion -}; \ No newline at end of file + getVersion, + setupMetrics, + perfMark +}; diff --git a/src/js/utils/metrics.js b/src/js/utils/metrics.js new file mode 100644 index 000000000..233a4c188 --- /dev/null +++ b/src/js/utils/metrics.js @@ -0,0 +1,68 @@ +function getMarksForEvents(events, suffix) { + const markNames = events.map( eventName => 'oAds.' + eventName + suffix ); + const performance = window.LUX || window.performance || window.msPerformance || window.webkitPerformance || window.mozPerformance; + if (!performance || !performance.getEntriesByName) { + /* istanbul ignore next */ + return {}; + } + + const marks = {}; + markNames.forEach(function(mName) { + const pMarks = performance.getEntriesByName(mName); + const markName = mName.replace('oAds.', '').replace(suffix, ''); + if (pMarks && pMarks.length) { + // We don't need sub-millisecond precision + marks[markName] = Math.round(pMarks[0].startTime); + } + }); + return marks; +} + +export function setupMetrics(definitions, callback) { + if (!Array.isArray(definitions)) { + this.log.warn('Metrics definitions should be an array. o-Ads will not record any metrics.'); + return; + } + + definitions.forEach( function(eDef) { + const triggers = Array.isArray(eDef.triggers) ? eDef.triggers : []; + triggers.forEach(function(trigger) { + sendMetricsOnEvent('oAds.' + trigger, eDef, callback); + }); + }); +} + +function sendMetricsOnEvent(eventName, eMarkMap, callback) { + document.addEventListener(eventName, function listenOnInitialised(event) { + sendMetrics(eMarkMap, event.detail, callback); + if (!eMarkMap.multiple) { + document.removeEventListener(eventName, listenOnInitialised); + } + }); +} + +function sendMetrics(eMarkMap, eventDetails, callback) { + let suffix = ''; + if (eventDetails && 'pos' in eventDetails && 'name' in eventDetails) { + suffix = '__' + [eventDetails.pos, eventDetails.name, eventDetails.size].join('__'); + } + + const marks = getMarksForEvents(eMarkMap.marks, suffix); + + const eventPayload = { + category: 'ads', + action: eMarkMap.spoorAction, + timings: { marks: marks } + }; + + if (eventDetails && 'pos' in eventDetails) { + eventPayload.creative = { + name: eventDetails.name, + pos: eventDetails.pos, + size: eventDetails.size && eventDetails.size.toString() + }; + } + + callback(eventPayload); +} + diff --git a/test/cypress/examples/unit/main.test.js b/test/cypress/examples/unit/main.test.js index 8ac5a4f01..231fc395c 100644 --- a/test/cypress/examples/unit/main.test.js +++ b/test/cypress/examples/unit/main.test.js @@ -6,11 +6,11 @@ describe('Main', () => { cy.clearCookie('FTConsent'); }); - it.only('oAds.ready event fires after o.DOMContentLoaded', () => { + it.only('oAds.slotReady event fires after o.DOMContentLoaded', () => { document.body.insertAdjacentHTML('beforeend', '
'); new Ads(); const oAdsReadySpy = cy.spy(); - utils.on('ready', oAdsReadySpy); + utils.on('slotReady', oAdsReadySpy); /* cy runs two iframes. One loads the test, the other loads @@ -75,4 +75,4 @@ describe('Main', () => { expect(gptInit.calledTwice); }); }); -}); \ No newline at end of file +}); diff --git a/test/cypress/integration/ad-requests.test.js b/test/cypress/integration/ad-requests.test.js index 54487525b..4f6bb2508 100644 --- a/test/cypress/integration/ad-requests.test.js +++ b/test/cypress/integration/ad-requests.test.js @@ -73,7 +73,7 @@ describe('Integration tests', () => { // Content props expect(cust_params).to.have.property('auuid', '047b1294-75a9-11e6-b60a-de4532d5ea35'); expect(cust_params).to.have.property('ad'); - expect(cust_params).to.have.property('ca', 'business,finance,smartphone'); + expect(cust_params).to.have.property('ca'); const expectedTopics = ['Retail','US & Canadian companies', 'Technology sector', 'Retail & Consumer', 'Companies', diff --git a/test/qunit/api.test.js b/test/qunit/api.test.js index cea465248..1be3ed208 100644 --- a/test/qunit/api.test.js +++ b/test/qunit/api.test.js @@ -21,7 +21,7 @@ QUnit.test('resolves with an empty promise if called without any urls', function }); -QUnit.test("fires an 'apiRequestsComplete' event when all API requests succeed", function(assert) { +QUnit.test("fires an 'adsAPIComplete' event when all API requests succeed", function(assert) { const done = assert.async(); const broadcast = this.stub(this.utils, 'broadcast'); @@ -48,12 +48,12 @@ QUnit.test("fires an 'apiRequestsComplete' event when all API requests succeed", // TO-DO: We should be using 'calledOnceWith' instead of 'calledWith' here // However browserstack-local forces us to pull in an old version of sinon // that doesn't have 'calledOnceWith' - assert.ok(broadcast.calledWith('apiRequestsComplete'), 'with apiRequestsComplete'); + assert.ok(broadcast.calledWith('adsAPIComplete'), 'with adsAPIComplete'); done(); }); }); -QUnit.test("fires an 'apiRequestsComplete' event when the user api request fails", function(assert) { +QUnit.test("fires an 'adsAPIComplete' event when the user api request fails", function(assert) { const done = assert.async(); const broadcast = this.stub(this.utils, 'broadcast'); @@ -76,12 +76,12 @@ QUnit.test("fires an 'apiRequestsComplete' event when the user api request fails ads.then((ads) => { ads.targeting.get(); - assert.ok(broadcast.calledWith('apiRequestsComplete')); + assert.ok(broadcast.calledWith('adsAPIComplete')); done(); }); }); -QUnit.test("fires an 'apiRequestsComplete' event when the page api request fails", function(assert) { +QUnit.test("fires an 'adsAPIComplete' event when the page api request fails", function(assert) { const done = assert.async(); const broadcast = this.stub(this.utils, 'broadcast'); @@ -104,7 +104,7 @@ QUnit.test("fires an 'apiRequestsComplete' event when the page api request fails ads.then((ads) => { ads.targeting.get(); - assert.ok(broadcast.calledWith('apiRequestsComplete')); + assert.ok(broadcast.calledWith('adsAPIComplete')); done(); }); }); diff --git a/test/qunit/gpt.test.js b/test/qunit/gpt.test.js index fd2e84448..2c88feb3e 100644 --- a/test/qunit/gpt.test.js +++ b/test/qunit/gpt.test.js @@ -29,7 +29,7 @@ QUnit.test('broadcast an event when GPT loads', function(assert) { this.spy(this.utils, 'broadcast'); this.ads.init(); - assert.ok(this.ads.utils.broadcast.calledWith('adServerLoadSuccess')); + assert.ok(this.ads.utils.broadcast.calledWith('serverScriptLoaded')); }); QUnit.test('broadcast an event when GPT fails to load', function(assert) { @@ -65,6 +65,12 @@ QUnit.test('override page targeting', function(assert) { }); +QUnit.test('empty override page targeting clears targeting', function(assert) { + this.ads.init({ dfp_targeting: ';some=test;targeting=params'}); + this.ads.gpt.updatePageTargeting(); + assert.ok(googletag.pubads().clearTargeting.called); +}); + QUnit.test('override page targetting catches and warns when googletag is not available', function(assert) { const errorSpy = this.spy(this.utils.log, 'warn'); this.ads.init(); @@ -105,7 +111,6 @@ QUnit.test('set sync rendering', function(assert) { assert.ok(googletag.pubads().enableSyncRendering.calledOnce, 'sync rendering has been enabled'); }); - QUnit.test('enabled single request', function(assert) { this.ads.init({ gpt: {rendering: 'sra'}}); assert.ok(googletag.pubads().enableSingleRequest.calledOnce, 'single request has been enabled'); @@ -203,7 +208,7 @@ QUnit.test('catches slot in view render event and display it if method is ready' this.ads.init(); const slot = this.ads.slots.initSlot(node); const displaySpy = this.spy(slot, 'display'); - slot.fire('render'); + slot.fire('slotCanRender'); assert.ok(displaySpy.calledOnce, 'slot dislpay method has been triggered'); }); @@ -229,6 +234,19 @@ QUnit.test('provides api to destroy the slot', function(assert) { assert.ok(googletag.destroySlots.calledWith([slot.gpt.slot]), 'defaults to slot that the method has been invoked on'); }); +QUnit.test('destroying slots fails gracefully if pubadsReady is not available', function(assert) { + const slotHTML = '
'; + this.fixturesContainer.add(slotHTML); + const savedGTagPubAdsReady = window.googletag.pubadsReady; + window.googletag.pubadsReady = null; + + this.ads.init(); + const slot = this.ads.slots.initSlot('test1'); + assert.equal(slot.destroySlot(), false, 'a call to destroy slot returns false'); + assert.ok(!googletag.destroySlots.called, 'destroy api has not been called'); + window.googletag.pubadsReady = savedGTagPubAdsReady; +}); + QUnit.test('provides api to clear the slot', function(assert) { const slotHTML = '
'; this.fixturesContainer.add(slotHTML); @@ -285,7 +303,7 @@ QUnit.test('set unit name', function(assert) { const done = assert.async(); const expected = '/5887/some-dfp-site/some-dfp-zone'; this.fixturesContainer.add(htmlstart + 'unit-name-full' + htmlend); - document.addEventListener('oAds.complete', function(event) { + document.addEventListener('oAds.slotExpand', function(event) { const name = event.detail.name; const slot = event.detail.slot; if (name === 'unit-name-full') { @@ -309,7 +327,7 @@ QUnit.test('set unit name site only', function(assert) { const expected = '/5887/some-dfp-site'; this.fixturesContainer.add(htmlstart + 'unit-name-site-only' + htmlend); - document.addEventListener('oAds.complete', function(event) { + document.addEventListener('oAds.slotExpand', function(event) { const name = event.detail.name; const slot = event.detail.slot; if (name === 'unit-name-site-only') { @@ -331,7 +349,7 @@ QUnit.test('set unit names network only', function(assert) { const expected = '/5887'; this.fixturesContainer.add(htmlstart + 'unit-name-network-only' + htmlend); - document.addEventListener('oAds.complete', function(event) { + document.addEventListener('oAds.slotExpand', function(event) { const name = event.detail.name; const slot = event.detail.slot; if (name === 'unit-name-network-only') { @@ -354,7 +372,7 @@ QUnit.test('unit names with empty strings', function(assert) { const expected = '/5887'; this.fixturesContainer.add(htmlstart + 'unit-name-empty-string' + htmlend); - document.addEventListener('oAds.complete', function(event) { + document.addEventListener('oAds.slotExpand', function(event) { const name = event.detail.name; const slot = event.detail.slot; if (name === 'unit-name-empty-string') { @@ -378,7 +396,7 @@ QUnit.test('set unit name with override', function(assert) { const expected = '/hello-there/stranger'; this.fixturesContainer.add(htmlstart + 'unit-name-custom' + htmlend); - document.addEventListener('oAds.complete', function(event) { + document.addEventListener('oAds.slotExpand', function(event) { const name = event.detail.name; const slot = event.detail.slot; if (name === 'unit-name-custom') { @@ -400,7 +418,7 @@ QUnit.test('set unit name with attribute', function(assert) { const expected = '/this-works'; const container = this.fixturesContainer.add(htmlstart + 'unit-name-attr" data-o-ads-gpt-unit-name="' + expected + htmlend); - document.addEventListener('oAds.complete', function(event) { + document.addEventListener('oAds.slotExpand', function(event) { const name = event.detail.name; const slot = event.detail.slot; if (name === 'unit-name-attr') { @@ -509,14 +527,18 @@ QUnit.test('define responsive slot', function(assert) { assert.ok(gptSlot.defineSizeMapping.calledOnce, 'the GPT defineSizeMapping slot is called'); }); -QUnit.test('rendered event fires on slot', function(assert) { +QUnit.test('slotRenderStart event fires on slot with the pos, size and name', function(assert) { const done = assert.async(); - const html = '
'; + const html = '
'; this.fixturesContainer.add(html); this.ads.init(); - document.body.addEventListener('oAds.rendered', function(event) { - assert.equal(event.detail.name, 'rendered-test', 'our test slot fired the rendered event'); + document.body.addEventListener('oAds.slotRenderStart', function(event) { + assert.equal(event.detail.name, 'rendered-test', 'our test slot fired the slotRenderStart event'); + assert.equal(event.detail.pMarkDetails.name, 'rendered-test', 'the name attribute is set correctly in the performance mark details'); + assert.equal(event.detail.pMarkDetails.pos, 'mid', 'the pos attribute is set correctly in the performance mark details'); + assert.equal(event.detail.pMarkDetails.size, '300,250', 'the size attribute is set correctly in the performance mark details'); + assert.ok(event.detail.pMarkDetails.slot, 'the slot object is available in the performance mark details'); done(); }); @@ -524,9 +546,11 @@ QUnit.test('rendered event fires on slot', function(assert) { }); QUnit.test('update correlator', function(assert) { + const errorSpy = this.spy(this.utils.log, 'warn'); this.ads.init(); this.ads.gpt.updateCorrelator(); assert.ok(googletag.pubads().updateCorrelator.calledOnce, 'the pub ads update correlator method is called when our method is called.'); + assert.ok(errorSpy.calledWith('[DEPRECATED]: Updatecorrelator is being phased out by google and removed from o-ads in future releases.'), 'warns that update correlator is being deprecated'); }); QUnit.test('fixed url for ad requests', function(assert) { @@ -561,7 +585,7 @@ QUnit.test('pick up the slot URL from page address if config or canonical not av QUnit.test('creatives with size 100x100 expand the iframe to 100%', function(assert) { const done = assert.async(); - document.body.addEventListener('oAds.complete', function(event) { + document.body.addEventListener('oAds.slotExpand', function(event) { const iframe = event.detail.slot.gpt.iframe; const iframeSize = [iframe.width, iframe.height]; assert.deepEqual(iframeSize, ['100%', '100%'], 'size of iframe is 100% by 100%.'); diff --git a/test/qunit/main.test.js b/test/qunit/main.test.js index f723749b3..0561eea50 100644 --- a/test/qunit/main.test.js +++ b/test/qunit/main.test.js @@ -16,7 +16,7 @@ QUnit.test('oAds is exposed on the window object', function(assert) { const ads = new this.adsConstructor(); //eslint-disable-line new-cap const done = assert.async(); - document.body.addEventListener('oAds.ready', function() { + document.body.addEventListener('oAds.slotReady', function() { document.body.addEventListener('oAds.initialised', function() { assert.deepEqual(window.oAds, ads); done(); @@ -32,7 +32,7 @@ QUnit.test('init All', function(assert) { const done = assert.async(); this.fixturesContainer.add('
'); - document.body.addEventListener('oAds.ready', function(event) { + document.body.addEventListener('oAds.slotReady', function(event) { assert.equal(event.detail.name, 'banlb2', 'our test slot is requested'); assert.deepEqual(event.detail.slot.sizes, [[300, 250]], 'with the correct sizes'); done(); @@ -43,7 +43,7 @@ QUnit.test('init All', function(assert) { QUnit.test("init fires an event when it's called", function(assert) { const done = assert.async(); const ads = new this.adsConstructor(); //eslint-disable-line new-cap - document.body.addEventListener('oAds.startInitialisation', function() { + document.body.addEventListener('oAds.initialising', function() { assert.ok(true); done(); }); @@ -261,7 +261,7 @@ QUnit.test("moat script loading check is eventually cleared if moat is not loade }, 1000); }); -QUnit.test("A 'moatIVTcomplete' event is fired if moat IVT cannnot be checked", function(assert) { +QUnit.test("A 'adsIVTComplete' event is fired if moat IVT cannnot be checked", function(assert) { this.spy(this.utils, 'broadcast'); window.moatPrebidApi = null; this.ads.moat.init(); @@ -269,12 +269,12 @@ QUnit.test("A 'moatIVTcomplete' event is fired if moat IVT cannnot be checked", const done = assert.async(); setTimeout( () => { - assert.ok(this.ads.utils.broadcast.calledWith('moatIVTcomplete')); + assert.ok(this.ads.utils.broadcast.calledWith('adsIVTComplete')); done(); }, 1000); }); -QUnit.test("A 'moatIVTcomplete' event is fired if moat IVT can be checked", function(assert) { +QUnit.test("A 'adsIVTComplete' event is fired if moat IVT can be checked", function(assert) { this.spy(this.utils, 'broadcast'); window.moatPrebidApi = {}; this.ads.moat.init(); @@ -282,7 +282,7 @@ QUnit.test("A 'moatIVTcomplete' event is fired if moat IVT can be checked", func const done = assert.async(); setTimeout( () => { - assert.ok(this.ads.utils.broadcast.calledWith('moatIVTcomplete')); + assert.ok(this.ads.utils.broadcast.calledWith('adsIVTComplete')); done(); }, 1000); }); diff --git a/test/qunit/metrics.test.js b/test/qunit/metrics.test.js new file mode 100644 index 000000000..2a302da43 --- /dev/null +++ b/test/qunit/metrics.test.js @@ -0,0 +1,222 @@ +/* globals QUnit: false, sinon: false, sandbox: false */ + +'use strict'; //eslint-disable-line + +const savePerformance = window.performance; + +QUnit.module('Metrics', { + before: function () { + sandbox.restore(); + window.performance = savePerformance; + }, + afterEach: function () { + sandbox.restore(); + window.performance = savePerformance; + } +}); + +QUnit.test('any trigger invokes the callback with the right payload', function (assert) { + const done = assert.async(); + this.ads.init(); + + const eventDefinitions = [{ + spoorAction: 'aaa', + triggers: ['bbb', 'ccc'], + marks: ['mark1', 'mark2', 'mark3'] + }]; + + const cb = sandbox.stub(); + + const getEntriesByNameStub = sandbox.stub(); + getEntriesByNameStub.withArgs('oAds.mark1').returns([{ name: 'oAds.mark1', startTime: 400 }]); + getEntriesByNameStub.withArgs('oAds.mark2').returns([{ name: 'oAds.mark2', startTime: 500.22 }]); + getEntriesByNameStub.withArgs('oAds.mark3').returns([{ name: 'oAds.mark3', startTime: 600.64 }]); + + window.performance = { + getEntriesByName: getEntriesByNameStub + }; + + this.utils.setupMetrics(eventDefinitions, cb); + document.dispatchEvent(new CustomEvent('oAds.ccc')); + + const expectedCbPayload = { + category: 'ads', + action: 'aaa', + timings: { + marks: { + mark1: 400, + mark2: 500, + mark3: 601 + } + } + }; + + setTimeout( function() { + assert.ok(cb.called); + assert.ok(cb.calledWith(sinon.match(expectedCbPayload))); + done(); + }, 0); +}); + +QUnit.test('the callback is not called if eventDefinitions is not an array', function (assert) { + const errorSpy = this.spy(this.utils.log, 'warn'); + const done = assert.async(); + this.ads.init(); + + const eventDefinitions = { + spoorAction: 'aaa', + triggers: ['bbb', 'ccc'], + marks: ['mark1', 'mark2', 'mark3'] + }; + + const cb = sandbox.stub(); + + this.utils.setupMetrics(eventDefinitions, cb); + document.dispatchEvent(new CustomEvent('oAds.ccc')); + + setTimeout( function() { + window.performance = savePerformance; + assert.ok(!cb.called, 'the callback is not called'); + assert.ok(errorSpy.calledWith('Metrics definitions should be an array. o-Ads will not record any metrics.'), 'an error message is shown'); + errorSpy.restore(); + done(); + }, 0); +}); + +QUnit.test('any trigger invokes the callback with no timing in the payload', function (assert) { + const done = assert.async(); + this.ads.init(); + + const eventDefinitions = [{ + spoorAction: 'aaa', + triggers: ['bbb', 'ccc'], + marks: ['mark1', 'mark2', 'mark3'] + }]; + + const cb = sandbox.stub(); + + const getEntriesByNameStub = sandbox.stub(); + getEntriesByNameStub.withArgs('oAds.mark1').returns([{ name: 'oAds.mark1', startTime: 400 }]); + getEntriesByNameStub.withArgs('oAds.mark2').returns([{ name: 'oAds.mark2', startTime: 500.22 }]); + getEntriesByNameStub.withArgs('oAds.mark3').returns([{ name: 'oAds.mark3', startTime: 600.64 }]); + + window.performance = null; + + this.utils.setupMetrics(eventDefinitions, cb); + document.dispatchEvent(new CustomEvent('oAds.ccc')); + + const expectedCbPayload = { + category: 'ads', + action: 'aaa', + timings: { + marks: { } + } + }; + + setTimeout( function() { + assert.ok(cb.called); + assert.ok(cb.calledWith(sinon.match(expectedCbPayload))); + done(); + }, 0); +}); + +QUnit.test('a trigger invokes the callback only once if there is no multiple config', function (assert) { + const done = assert.async(); + this.ads.init(); + + const eventDefinitions = [{ + spoorAction: 'aaa', + triggers: ['bbb', 'ccc'], + marks: ['mark1', 'mark2', 'mark3'] + }]; + + const cb = sandbox.stub(); + + this.utils.setupMetrics(eventDefinitions, cb); + document.dispatchEvent(new CustomEvent('oAds.ccc')); + document.dispatchEvent(new CustomEvent('oAds.ccc')); + document.dispatchEvent(new CustomEvent('oAds.ccc')); + + setTimeout( function() { + assert.ok(cb.calledOnce); + done(); + }, 0); +}); + +QUnit.test('a trigger invokes the callback multiple times if multiple=true', function (assert) { + const done = assert.async(); + this.ads.init(); + + const eventDefinitions = [{ + spoorAction: 'aaa', + triggers: ['bbb', 'ccc'], + marks: ['mark1', 'mark2', 'mark3'], + multiple: true + }]; + + const cb = sandbox.stub(); + + this.utils.setupMetrics(eventDefinitions, cb); + document.dispatchEvent(new CustomEvent('oAds.ccc')); + document.dispatchEvent(new CustomEvent('oAds.ccc')); + document.dispatchEvent(new CustomEvent('oAds.ccc')); + + setTimeout( function() { + assert.equal(cb.callCount, 3); + done(); + }, 0); +}); + +QUnit.test('collects performance marks using the properties in the event payload', function (assert) { + const done = assert.async(); + this.ads.init(); + + const eventDefinitions = [{ + spoorAction: 'aaa', + triggers: ['bbb', 'ccc'], + marks: ['mark1', 'mark2', 'mark3'] + }]; + + const cb = sandbox.stub(); + + const getEntriesByNameStub = sandbox.stub(); + getEntriesByNameStub.withArgs('oAds.mark1__top__entry__300,250').returns([{ name: 'oAds.mark1__top__entry__300,250', startTime: 400 }]); + getEntriesByNameStub.withArgs('oAds.mark2__top__entry__300,250').returns([{ name: 'oAds.mark2__top__entry__300,250', startTime: 500.22 }]); + getEntriesByNameStub.withArgs('oAds.mark3__top__entry__300,250').returns([{ name: 'oAds.mark3__top__entry__300,25', startTime: 600.64 }]); + + window.performance = { + getEntriesByName: getEntriesByNameStub + }; + + this.utils.setupMetrics(eventDefinitions, cb); + const eventDetails = { + name: 'entry', + pos: 'top', + size: [300,250] + }; + this.ads.utils.broadcast('ccc', eventDetails); + + const expectedCbPayload = { + category: 'ads', + action: 'aaa', + creative: { + name: 'entry', + pos: 'top', + size: '300,250' + }, + timings: { + marks: { + mark1: 400, + mark2: 500, + mark3: 601 + } + } + }; + + setTimeout( function() { + assert.ok(cb.called); + assert.ok(cb.calledWith(sinon.match(expectedCbPayload))); + done(); + }, 0); +}); + diff --git a/test/qunit/mocks/gpt-mock.js b/test/qunit/mocks/gpt-mock.js index f4ca5935a..17ff6afe1 100644 --- a/test/qunit/mocks/gpt-mock.js +++ b/test/qunit/mocks/gpt-mock.js @@ -200,4 +200,4 @@ export default { } }); } -}; \ No newline at end of file +}; diff --git a/test/qunit/slots.post.message.test.js b/test/qunit/slots.post.message.test.js index 8a0e4da85..8aa6aa01d 100644 --- a/test/qunit/slots.post.message.test.js +++ b/test/qunit/slots.post.message.test.js @@ -26,6 +26,37 @@ QUnit.module('Slots - post message', { } }); +QUnit.test('Post message oAds.adIframeLoaded from the iframe dispatches oAds.slotRenderEnded', function (assert) { + const done = assert.async(); + const slotName = 'whoami-ad'; + const container = this.fixturesContainer.add('
'); + this.stub(this.utils, 'iframeToSlotName', function () { + return slotName; + }); + const utils = this.ads.utils; + const performanceStub = sinon.stub(window.performance, 'mark'); + this.spy(this.ads.utils, 'broadcast'); + + window.addEventListener('message', function () { + // Make sure this executes AFTER the other 'message' event listener + // defined in slots.js + setTimeout(function() { + assert.ok(utils.broadcast.calledWith('slotRenderEnded')); + assert.ok(performanceStub.called); + performanceStub.restore(); + done(); + }, 0); + }); + + document.body.addEventListener('oAds.slotReady', function () { + window.postMessage('{ "type": "oAds.adIframeLoaded", "name": "' + slotName + '"}', '*'); + }); + + this.ads.init(); + this.ads.slots.initSlot(container); +}); + + QUnit.test('Post message from unknown slot logs an error and sends a repsonse', function (assert) { const done = assert.async(); const slotName = 'whoami-ad'; @@ -43,7 +74,7 @@ QUnit.test('Post message from unknown slot logs an error and sends a repsonse', }, 0); }); - document.body.addEventListener('oAds.ready', function () { + document.body.addEventListener('oAds.slotReady', function () { window.postMessage('{ "type": "oAds.whoami"}', '*'); }); @@ -60,7 +91,7 @@ QUnit.test('Passed touch fires an event', function (assert) { return slotName; }); - document.body.addEventListener('oAds.ready', function () { + document.body.addEventListener('oAds.slotReady', function () { window.postMessage('{ "type": "touchstart", "name": "' + slotName + '"}', '*'); }); @@ -80,7 +111,7 @@ QUnit.test('Post message catches the event when the message comes from an uniden const done = assert.async(); const slotName = 'responsive-ad'; const container = this.fixturesContainer.add('
'); - document.body.addEventListener('oAds.ready', function () { + document.body.addEventListener('oAds.slotReady', function () { window.postMessage('{ "type": "oAds.responsive", "name": "unknown"}', '*'); // as there is no event fired when no slot available, we use timeout to let the error call execute setTimeout(function() { @@ -100,7 +131,7 @@ QUnit.test('Post message "collapse" message will call slot.collapse()', function return slotName; }); - document.body.addEventListener('oAds.ready', function () { + document.body.addEventListener('oAds.slotReady', function () { window.postMessage('{ "type": "oAds.collapse", "collapse": true}', '*'); }); @@ -135,10 +166,10 @@ QUnit.test('Unknown postMessage will log an error', function (assert) { }, 0); }); - document.body.addEventListener('oAds.ready', function () { + document.body.addEventListener('oAds.slotReady', function () { window.postMessage('{ "type": "oAds.unknown" }', '*'); }); this.ads.init(); this.ads.slots.initSlot(container); -}); \ No newline at end of file +}); diff --git a/test/qunit/slots.test.js b/test/qunit/slots.test.js index 82eea087f..dfff9bbbf 100644 --- a/test/qunit/slots.test.js +++ b/test/qunit/slots.test.js @@ -327,7 +327,6 @@ QUnit.test('Slots.collapse will emit an event', function(assert) { assert.ok(this.utils.broadcast.calledWith('collapsed', initedSlot), 'event broadcast has been called with correct event'); }); - QUnit.test('Slots.uncollapse will uncollapse a single slot', function(assert) { const node = this.fixturesContainer.add('
'); @@ -661,9 +660,9 @@ QUnit.test('lazy loading', function(assert) { const node = this.fixturesContainer.add(slotHTML); - document.body.addEventListener('oAds.render', function(event) { + document.body.addEventListener('oAds.slotCanRender', function(event) { if(event.detail.name === 'lazy-test') { - assert.equal(event.detail.name, 'lazy-test', 'our test slot fired the render event'); + assert.equal(event.detail.name, 'lazy-test', 'our test slot fired the slotCanRender event'); done(); } }); @@ -679,9 +678,9 @@ QUnit.test('lazy loading global config', function(assert) { const slotHTML = '
'; const node = this.fixturesContainer.add(slotHTML); - document.body.addEventListener('oAds.render', function(event) { + document.body.addEventListener('oAds.slotCanRender', function(event) { if(event.detail.name === 'lazy-test') { - assert.equal(event.detail.name, 'lazy-test', 'our test slot fired the render event'); + assert.equal(event.detail.name, 'lazy-test', 'our test slot fired the slotCanRender event'); done(); } }); @@ -704,17 +703,14 @@ QUnit.test('lazy loading slot config takes precedence over global config', funct assert.equal(this.ads.slots['lazy-test'].lazyLoad, false); }); - - - QUnit.test('lazy loading triggers event if the advert is in view', function(assert) { const done = assert.async(); const slotHTML = `
`; const node = this.fixturesContainer.add(slotHTML); - document.body.addEventListener('oAds.render', function(event) { + document.body.addEventListener('oAds.slotCanRender', function(event) { if(event.detail.name === 'lazy-test') { - assert.equal(event.detail.name, 'lazy-test', 'our test slot fired the render event'); + assert.equal(event.detail.name, 'lazy-test', 'our test slot fired the slotCanRender event'); done(); } }); @@ -732,10 +728,10 @@ QUnit.test('lazy loading triggers in top-down order if multiple ads in view', fu const node1 = this.fixturesContainer.add(slot1HTML); const node2 = this.fixturesContainer.add(slot2HTML); let count=0; - document.body.addEventListener('oAds.render', function(event) { + document.body.addEventListener('oAds.slotCanRender', function(event) { count++; if(event.detail.name === 'lazy-test-bottom') { - assert.equal(event.detail.name, 'lazy-test-bottom', 'our test slot fired the render event'); + assert.equal(event.detail.name, 'lazy-test-bottom', 'our test slot fired the slotCanRender event'); assert.equal(count, 2, 'bottom slot is the last to fire'); done(); } @@ -763,9 +759,9 @@ QUnit.test('lazy loading supports in view threshold', function(assert) { element.style.height = '90px'; - document.body.addEventListener('oAds.render', function(event) { + document.body.addEventListener('oAds.slotCanRender', function(event) { if(event.detail.name === 'lazy-test') { - assert.equal(event.detail.name, 'lazy-test', 'our test slot fired the render event'); + assert.equal(event.detail.name, 'lazy-test', 'our test slot fired the slotCanRender event'); done(); } }); @@ -891,9 +887,9 @@ QUnit.test('lazy loading triggers event at the correct point with no intersectio element.style.top = advertTopPosition + 'px'; - document.body.addEventListener('oAds.render', function(event) { + document.body.addEventListener('oAds.slotCanRender', function(event) { if(event.detail.name === 'lazy-test') { - assert.equal(event.detail.name, 'lazy-test', 'our test slot fired the render event'); + assert.equal(event.detail.name, 'lazy-test', 'our test slot fired the slotCanRender event'); done(); } }); @@ -923,9 +919,9 @@ QUnit.test('lazy loading triggers event at the correct point with intersection o element.style.height = '90px'; - document.body.addEventListener('oAds.render', function(event) { + document.body.addEventListener('oAds.slotCanRender', function(event) { if(event.detail.name === 'lazy-test') { - assert.equal(event.detail.name, 'lazy-test', 'our test slot fired the render event'); + assert.equal(event.detail.name, 'lazy-test', 'our test slot fired the slotCanRender event'); done(); } }); @@ -948,9 +944,9 @@ QUnit.test('lazy loading a companion slot', function(assert) { const slotHTML = '
'; const node = this.fixturesContainer.add(slotHTML); - document.body.addEventListener('oAds.render', function(event) { + document.body.addEventListener('oAds.slotCanRender', function(event) { if(event.detail.name === 'lazy-companion-test') { - assert.equal(event.detail.name, 'lazy-companion-test', 'our test slot fired the render event'); + assert.equal(event.detail.name, 'lazy-companion-test', 'our test slot fired the slotCanRender event'); done(); } }); @@ -966,9 +962,9 @@ QUnit.test('lazy loading a companion slot', function(assert) { const slotHTML = '
'; const node = this.fixturesContainer.add(slotHTML); - document.body.addEventListener('oAds.render', function(event) { + document.body.addEventListener('oAds.slotCanRender', function(event) { if(event.detail.name === 'lazy-companion-test') { - assert.equal(event.detail.name, 'lazy-companion-test', 'our test slot fired the render event'); + assert.equal(event.detail.name, 'lazy-companion-test', 'our test slot fired the slotCanRender event'); done(); } }); @@ -978,6 +974,25 @@ QUnit.test('lazy loading a companion slot', function(assert) { this.utils.broadcast('masterLoaded', {}, node); }); +QUnit.test('displayLabelWithBorders config prop adds class to outerEl if data-o-ads-label is defined', function(assert) { + const done = assert.async(); + const slot2HTML = `
`; + const node = this.fixturesContainer.add(slot2HTML); + + const config = { + displayLabelWithBorders: true + }; + this.ads.init(config); + this.trigger(window, 'load'); + this.ads.slots.initSlot(node); + + document.body.addEventListener('oAds.slotCanRender', function() { + const outerEl = node.querySelector('.o-ads__outer'); + assert.ok($(outerEl).hasClass('o-ads--label-with-borders'), 'class is added'); + done(); + }); +}); + QUnit.test('companion slots which are configured as false for a specific screensize should not render at that screensize', function(assert) { const slotHTML = '
'; const node = this.fixturesContainer.add(slotHTML); @@ -1006,7 +1021,7 @@ QUnit.test('lazy loading loads the ad normal way if IntersectionObserver is not }); -QUnit.test('complete events fire', function(assert) { +QUnit.test('slotExpand events fire', function(assert) { const done = assert.async(); const done2 = assert.async(); @@ -1016,7 +1031,7 @@ QUnit.test('complete events fire', function(assert) { slotHTML = '
'; const second = this.fixturesContainer.add(slotHTML); - document.body.addEventListener('oAds.complete', function(event) { + document.body.addEventListener('oAds.slotExpand', function(event) { assert.ok(event.detail.name, event.detail.name + ' completed.'); if (event.detail.name === 'first') { done(); diff --git a/test/qunit/utils.events.test.js b/test/qunit/utils.events.test.js index bd4928fbf..cb2fcba6b 100644 --- a/test/qunit/utils.events.test.js +++ b/test/qunit/utils.events.test.js @@ -1,9 +1,16 @@ -/* globals QUnit: false */ +/* globals QUnit: false, savePerformance: false */ 'use strict'; //eslint-disable-line -QUnit.module('utils.events'); +const sandbox = sinon.sandbox.create(); +QUnit.module('utils.events', { + beforeEach: function () { + sandbox.restore(); + window.performance = savePerformance; + window.LUX = null; + } +}); QUnit.test('We can broadcast an event to the body', function(assert) { const utils = this.ads.utils; @@ -20,6 +27,49 @@ QUnit.test('We can broadcast an event to the body', function(assert) { }); }); +QUnit.test('An event creates a performance mark', function(assert) { + const utils = this.ads.utils; + const done = assert.async(); + const performanceStub = sandbox.spy(window.performance, 'mark'); + + document.body.addEventListener('oAds.ahoy', function() { + assert.ok(performanceStub.calledWith('oAds.ahoy')); + performanceStub.restore(); + done(); + }); + + utils.broadcast('ahoy', { + hello: 'there' + }); +}); + +QUnit.test('An event creates a perfMark using "name, size and pos" from event details', function(assert) { + const utils = this.ads.utils; + const done = assert.async(); + const performanceStub = sandbox.stub(window.performance, 'mark'); + + document.body.addEventListener('oAds.ahoy', function() { + assert.ok(performanceStub.calledWith('oAds.ahoy__thepos__thename__thesize')); + performanceStub.restore(); + done(); + }); + + utils.broadcast('ahoy', { + pos: 'thepos', + name: 'thename', + size: 'thesize' + }); +}); + +QUnit.test('An call to perfMark calls performance.mark with the same params', function(assert) { + const utils = this.ads.utils; + const performanceStub = sandbox.stub(window.performance, 'mark'); + + utils.perfMark('oAds.ahoy__thepos__thename__thesize'); + assert.ok(performanceStub.calledWith('oAds.ahoy__thepos__thename__thesize')); + performanceStub.restore(); +}); + QUnit.test('We can broadcast an from an element', function(assert) { const utils = this.ads.utils; const done = assert.async();