diff --git a/modules/geoedgeRtdProvider.js b/modules/geoedgeRtdProvider.js index 14df281098..b2e69bf63e 100644 --- a/modules/geoedgeRtdProvider.js +++ b/modules/geoedgeRtdProvider.js @@ -3,6 +3,7 @@ * The {@link module:modules/realTimeData} module is required * The module will fetch creative wrapper from geoedge server * The module will place geoedge RUM client on bid responses markup + * For outstream video the module holds the bid's own renderer until the client clears the creative * @module modules/geoedgeProvider * @requires module:modules/realTimeData */ @@ -12,48 +13,48 @@ * @property {string} key * @property {?Object} bidders * @property {?boolean} wap + * @property {?boolean} gpt + * @property {?boolean} outstream publisher opt-in to outstream video monitoring * @property {?string} keyName */ import { submodule } from '../src/hook.js'; +import { getGlobal } from '../src/prebidGlobal.js'; import { ajax } from '../src/ajax.js'; import { generateUUID, createInvisibleIframe, insertElement, isEmpty, logError } from '../src/utils.js'; import * as events from '../src/events.js'; import { EVENTS } from '../src/constants.js'; import { loadExternalScript } from '../src/adloader.js'; +import { isRendererRequired } from '../src/Renderer.js'; import { auctionManager } from '../src/auctionManager.js'; import { getRefererInfo } from '../src/refererDetection.js'; import { MODULE_TYPE_RTD } from '../src/activities/modules.js'; -/** - * @typedef {import('../modules/rtdModule/index.js').RtdSubmodule} RtdSubmodule - */ - -/** @type {string} */ const SUBMODULE_NAME = 'geoedge'; -/** @type {string} */ -export const WRAPPER_URL = 'https://wrappers.geoedge.be/wrapper.html'; -/** @type {string} */ /* eslint-disable no-template-curly-in-string */ +export const WRAPPER_URL = 'https://wrappers.geoedge.be/wrapper.html'; export const HTML_PLACEHOLDER = '${creative}'; -/** @type {string} */ const PV_ID = generateUUID(); -/** @type {string} */ const HOST_NAME = 'https://rumcdn.geoedge.be'; -/** @type {string} */ const FILE_NAME_CLIENT = 'grumi.js'; -/** @type {string} */ const FILE_NAME_INPAGE = 'grumi-ip.js'; -/** @type {function} */ export const getClientUrl = (key) => `${HOST_NAME}/${key}/${FILE_NAME_CLIENT}`; -/** @type {function} */ export const getInPageUrl = (key) => `${HOST_NAME}/${key}/${FILE_NAME_INPAGE}`; -/** @type {string} */ +const OUTSTREAM_API = 'grumiOutstreamApi'; // exposed by the client inside the client frame +const OUTSTREAM_GATED = '__geOutstreamGated'; // stamped on a renderer we wrapped; the client reads it +export const OUTSTREAM_GATE_TIMEOUT = 1500; // give up waiting for the client and render unprotected +const VAST_HEAD_CHARS = 300; // how far into bid.ad to look for the marker + export let wrapper; -/** @type {boolean} */ let wrapperReady; -/** @type {boolean} */ -let preloaded; +let clientLoaded = false; +let clientTimedOut = false; +let clientTimeoutId; +/** @type {HTMLIFrameElement} the client frame; the video gate delegates into it */ +let clientFrame; +/** @type {Array} renders parked until the client script has executed; flushed by markClientAsLoaded */ +let videoWaiters = []; + /** @type {object} */ const refererInfo = getRefererInfo(); /** @type {object} */ @@ -67,11 +68,12 @@ export function fetchWrapper(success) { if (wrapperReady) { return success(wrapper); } + ajax(WRAPPER_URL, success); } /** - * sets the wrapper and calls preload client + * sets the wrapper response * @param {string} responseText */ export function setWrapper(responseText) { @@ -79,37 +81,98 @@ export function setWrapper(responseText) { wrapper = responseText; } -export function getInitialParams(key) { +/** + * builds the params object handed to the client inside the frame + * @param {string} key + * @param {?boolean} outstream publisher opt-in to outstream video monitoring + * @return {Object} + */ +export function getInitialParams(key, outstream) { const params = { - wver: '1.1.1', + wver: '1.1.2', wtype: 'pbjs-module', key, - meta: { - topUrl: refererInfo.page - }, + meta: { topUrl: refererInfo.page }, site: refererInfo.domain, pimp: PV_ID, fsRan: true, - frameApi: true + frameApi: true, + outstream }; + + if (outstream) { + params.pbjs = getGlobal(); + } + return params; } -export function markAsLoaded() { - preloaded = true; +/** + * the client script's onload. Releases any render parked waiting for it. + */ +export function markClientAsLoaded() { + clientLoaded = true; + + if (clientTimedOut) { + return; + } + + stopClientLoadTimer(); + handleOutstreamPendingBids(); +} + +function onClientTimeout() { + clientTimedOut = true; + + flushOutstreamPendingBids(); +} + +function handleOutstreamPendingBids() { + videoWaiters.forEach((waiter) => { + const [renderInvoker, bid] = waiter; + + if (shouldRenderOutstream(bid)) { + renderInvoker(); + } + }); + + videoWaiters = []; +} + +function flushOutstreamPendingBids() { + videoWaiters.forEach((waiter) => { + const [renderInvoker] = waiter; + + renderInvoker(); + }); + + videoWaiters = []; +} + +function startClientLoadTimer() { + clientTimeoutId = setTimeout(onClientTimeout, OUTSTREAM_GATE_TIMEOUT); +} + +function stopClientLoadTimer() { + clearTimeout(clientTimeoutId); } /** - * preloads the client + * loads the monitoring client in an invisible iframe * @param {string} key + * @param {?boolean} outstream publisher opt-in to outstream video monitoring */ -export function preloadClient(key) { +export function loadClientInIframe(key, outstream) { const iframe = createInvisibleIframe(); + const url = getClientUrl(key); + iframe.id = 'grumiFrame'; insertElement(iframe); - iframe.contentWindow.grumi = getInitialParams(key); - const url = getClientUrl(key); - loadExternalScript(url, MODULE_TYPE_RTD, SUBMODULE_NAME, markAsLoaded, iframe.contentDocument); + iframe.contentWindow.grumi = getInitialParams(key, outstream); + clientFrame = iframe; + + loadExternalScript(url, MODULE_TYPE_RTD, SUBMODULE_NAME, markClientAsLoaded, iframe.contentDocument); + startClientLoadTimer(); } /** @@ -123,6 +186,12 @@ function replacer(str) { }; } +/** + * places the creative inside the wrapper + * @param {string} wrapper + * @param {string} html + * @return {string} + */ export function wrapHtml(wrapper, html) { return wrapper.replace(HTML_PLACEHOLDER, replacer(html)); } @@ -152,95 +221,199 @@ export function getMacros(bid, key) { }; } -/** - * replace macro placeholders in a string with values from a dictionary - * @param {string} wrapper - * @param {Object} macros - * @return {string} - */ function replaceMacros(wrapper, macros) { var re = new RegExp('\\' + Object.keys(macros).join('|'), 'gi'); - return wrapper.replace(re, function(matched) { + return wrapper.replace(re, function (matched) { return macros[matched]; }); } -/** - * build final creative html with creative wrapper - * @param {Object} bid - * @param {string} wrapper - * @param {string} html - * @return {string} - */ function buildHtml(bid, wrapper, html, key) { const macros = getMacros(bid, key); wrapper = replaceMacros(wrapper, macros); + return wrapHtml(wrapper, html); } -/** - * muatates the bid ad property - * @param {Object} bid - * @param {string} ad - */ function mutateBid(bid, ad) { bid.ad = ad; } /** - * wraps a bid object with the creative wrapper + * wraps the bid's markup with the creative wrapper * @param {Object} bid * @param {string} key */ export function wrapBidResponse(bid, key) { const wrapped = buildHtml(bid, wrapper, bid.ad, key); + mutateBid(bid, wrapped); } -/** - * checks if bidder's bids should be monitored - * @param {string} bidder - * @return {boolean} - */ function isSupportedBidder(bidder, paramsBidders) { return isEmpty(paramsBidders) || paramsBidders[bidder] === true; } -/** - * checks if bid should be monitored - * @param {Object} bid - * @return {boolean} - */ function shouldWrap(bid, params) { const supportedBidder = isSupportedBidder(bid.bidderCode, params.bidders); - const donePreload = params.wap ? preloaded : true; + const clientReady = params.wap ? clientLoaded : true; const isGPT = params.gpt; - return wrapperReady && supportedBidder && donePreload && !isGPT; + + return wrapperReady && supportedBidder && clientReady && !isGPT; } function conditionallyWrap(bidResponse, config, userConsent) { const params = config.params; + if (shouldWrap(bidResponse, params)) { wrapBidResponse(bidResponse, params.key); } } -function isBillingMessage(data, params) { - return data.key === params.key && data.impression; +// --------------------------------------------------------------------------- +// Outstream video gate +// +// Video creatives are VAST, not HTML, so there is no markup to wrap. The bid's own renderer is +// wrapped instead: on render, the client is asked whether the creative may run. If the client does +// not load in time the creative renders anyway. A publisher's ad is never lost because monitoring +// was unavailable. +// --------------------------------------------------------------------------- + +function getOutstreamAPI() { + try { + return clientFrame && clientFrame.contentWindow && clientFrame.contentWindow[OUTSTREAM_API]; + } catch (e) { + return null; // frame torn out of the DOM + } +} + +// Deliberately broad, because mediaType alone is not reliable: an adapter that omits it leaves a +// video bid labeled 'banner', and instream/outstream context lives on the adUnit, not the response. +// The bid.ad leg covers outstream, the one video context prebid does not require a VAST field for: +// checkVideoBidSetup accepts it on hasRenderer alone, so the VAST may arrive in `ad`. +// No vastUrl leg: handleVideoBidCaching backfills a bare vastUrl into vastXml before BID_RESPONSE, +// so a correctly labeled video bid always has vastXml by the time this runs. +/** + * whether this bid could carry a VAST document through its renderer + * @param {Object} bid + * @return {boolean} + */ +export function isVastBid(bid) { + return Boolean(bid.mediaType === 'video' || bid.vastXml || hasVastXmlInBidAd(bid)); +} + +function hasVastXmlInBidAd(bid) { + // head only, since display bids reach this leg too, and their `ad` is a full creative + return typeof bid.ad === 'string' && / originalRender.apply(self, args); + const videoWaiter = [renderInvoker, bid]; + + videoWaiters.push(videoWaiter); + }; + + setRendererAsGated(renderer); } /** - * Fire billable events when our client sends a message - * Messages will be sent only when: - * a. applicable bids are wrapped - * b. our code laoded and executed sucesfully + * Test-only: clears the client-load flags so a spec can reach the parked and failed-open branches. + * The module is a singleton and prebid's adloader mock fires the load callback synchronously, so the + * flags latch true on the first init(). clientFrame and the load timer are left intact. */ +export function resetOutstreamGateStateForTesting() { + clientLoaded = false; + clientTimedOut = false; + videoWaiters = []; +} + +function onBidResponse(bidResponse, config, userConsent) { + if (shouldGateOutstreamRender(bidResponse, config.params)) { + gateOutstreamRender(bidResponse); + + return; + } + + conditionallyWrap(bidResponse, config, userConsent); +} + +function isBillingMessage(data, params) { + return data.key === params.key && data.impression; +} + +// Fire billable events when our client posts an impression message function fireBillableEventsForApplicableBids(params) { window.addEventListener('message', function (message) { const data = message.data; + if (isBillingMessage(data, params)) { const winningBid = auctionManager.findBidByAdId(data.adId); + events.emit(EVENTS.BILLABLE_EVENT, { vendor: SUBMODULE_NAME, billingId: data.impressionId, @@ -253,10 +426,7 @@ function fireBillableEventsForApplicableBids(params) { }); } -/** - * Loads Geoedge in page script that monitors all ad slots created by GPT - * @param {Object} params - */ +// Loads the geoedge in-page script that monitors all ad slots created by GPT function setupInPage(params) { window.grumi = params; window.grumi.fromPrebid = true; @@ -273,21 +443,17 @@ function init(config, userConsent) { setupInPage(params); } else { fetchWrapper(setWrapper); - preloadClient(params.key); + loadClientInIframe(params.key, params.outstream); } fireBillableEventsForApplicableBids(params); + return true; } -/** @type {RtdSubmodule} */ export const geoedgeSubmodule = { - /** - * used to link submodule with realTimeData - * @type {string} - */ name: SUBMODULE_NAME, init, - onBidResponseEvent: conditionallyWrap + onBidResponseEvent: onBidResponse }; submodule('realTimeData', geoedgeSubmodule); diff --git a/modules/geoedgeRtdProvider.md b/modules/geoedgeRtdProvider.md index cdf913b889..529c832569 100644 --- a/modules/geoedgeRtdProvider.md +++ b/modules/geoedgeRtdProvider.md @@ -48,8 +48,41 @@ Parameters details: |params | Object | | | |params.key | String | Customer key |Required, contact Geoedge to get your key | |params.bidders | Object | Bidders to monitor |Optional, list of bidder to include / exclude from monitoring. Omitting this will monitor bids from all bidders. | -|params.wap |Boolean |Wrap after preload |Optional, defaults to `false`. Set to `true` if you want to monitor only after the module has preloaded the monitoring client. | +|params.wap |Boolean |Wrap after client load |Optional, defaults to `false`. Set to `true` if you want to monitor only after the module has loaded the monitoring client. | |params.gpt |Boolean |Wrap all GPT ad slots |Optional, defaults to `false`. Set to `true` if you want to monitor all Google Publisher Tag ad slots, regaedless if the winning bid comes from Prebid or Google Ad Manager (Direct, Adx, Adesnse, Open Bidding, etc). | +|params.outstream |Boolean |Monitor outstream video |Optional, defaults to `false`. Set to `true` to extend monitoring to outstream video bids. See "Outstream video" below. | + +## Outstream video + +Video creatives are VAST rather than HTML, so they cannot be wrapped the way display creatives are. +With `outstream: true` the module instead wraps the bid's own `renderer.render` and asks the +monitoring client whether the creative may run: + +```javascript +pbjs.setConfig({ + realTimeData: { + dataProviders: [{ + name: 'geoedge', + params: { + key: '123123', + outstream: true + } + }] + } +}); +``` + +Behavior worth knowing before enabling it: + +- **Render timing.** The monitoring client is loaded when the module initializes, so by the time a + bid renders it has normally already answered and rendering proceeds immediately. Only a render that + happens before the client is ready waits for the answer, and that wait is capped by a short + deadline. +- **It fails open.** If the client does not load within that deadline, or loads without a verdict for + the bid, the creative renders unmonitored. An ad is never lost because monitoring was unavailable. +- **It only affects bids Prebid renders through the bid's renderer.** Bids carrying a `safeRenderer`, + and bids whose VAST reaches a player straight from targeting or Prebid Cache, are left untouched. +- **Display monitoring is unchanged.** A bid handled by the outstream path is not also HTML-wrapped. ## Example diff --git a/test/spec/modules/geoedgeRtdProvider_spec.js b/test/spec/modules/geoedgeRtdProvider_spec.js index c2b1c3a0b5..1a2950279c 100644 --- a/test/spec/modules/geoedgeRtdProvider_spec.js +++ b/test/spec/modules/geoedgeRtdProvider_spec.js @@ -4,6 +4,7 @@ import * as geoedgeRtdModule from '../../../modules/geoedgeRtdProvider.js'; import { server } from '../../../test/mocks/xhr.js'; import * as events from '../../../src/events.js'; import { EVENTS } from '../../../src/constants.js'; +import { getGlobal } from '../../../src/prebidGlobal.js'; const { geoedgeSubmodule, @@ -13,10 +14,17 @@ const { setWrapper, getMacros, WRAPPER_URL, - preloadClient + loadClientInIframe, + isVastBid, + markClientAsLoaded, + resetOutstreamGateStateForTesting, + OUTSTREAM_GATE_TIMEOUT } = geoedgeRtdModule; const key = '123123123'; +// The client publishes its gate on the client frame's window under this name. +const OUTSTREAM_API = 'grumiOutstreamApi'; + function makeConfig(gpt) { return { name: 'geoedge', @@ -68,10 +76,10 @@ describe('Geoedge RTD module', function () { }); describe('init', function () { before(function () { - sinon.spy(geoedgeRtdModule, 'preloadClient'); + sinon.spy(geoedgeRtdModule, 'loadClientInIframe'); }); after(function () { - geoedgeRtdModule.preloadClient.restore(); + geoedgeRtdModule.loadClientInIframe.restore(); }); it('should return false when missing params or key', function () { const missingParams = geoedgeSubmodule.init({}); @@ -87,8 +95,8 @@ describe('Geoedge RTD module', function () { const isWrapperRequest = request && request.url && request.url && request.url === WRAPPER_URL; expect(isWrapperRequest).to.equal(true); }); - it('should call preloadClient', function () { - expect(preloadClient.called); + it('should call loadClientInIframe', function () { + expect(loadClientInIframe.called); }); it('should emit billable events with applicable winning bids', function (done) { let counter = 0; @@ -111,9 +119,9 @@ describe('Geoedge RTD module', function () { expect(hasGrumiObj && window.grumi.key === key && window.grumi.fromPrebid).to.equal(true); }); }); - describe('preloadClient', function () { + describe('loadClientInIframe', function () { let iframe; - preloadClient(key); + loadClientInIframe(key); const loadExternalScriptCall = loadExternalScriptStub.getCall(0); it('should create an invisible iframe and insert it to the DOM', function () { iframe = document.getElementById('grumiFrame'); @@ -123,10 +131,26 @@ describe('Geoedge RTD module', function () { const grumi = iframe.contentWindow.grumi; expect(grumi.key).to.equal(key); }); - it('should preload the client into the iframe', function () { + it('should load the client into the iframe', function () { const isClientUrl = arg => arg === getClientUrl(key); expect(loadExternalScriptCall.calledWithMatch(isClientUrl)).to.equal(true); }); + it('should carry the publisher outstream opt-in into the frame', function () { + loadClientInIframe(key, true); + // insertElement prepends into , so the newest frame is the FIRST match, not the last + const grumi = document.querySelector('#grumiFrame').contentWindow.grumi; + expect(grumi.outstream).to.equal(true); + }); + it('should hand the frame a reference to this prebid instance when outstream is on', function () { + loadClientInIframe(key, true); + const grumi = document.querySelector('#grumiFrame').contentWindow.grumi; + expect(grumi.pbjs).to.equal(getGlobal()); + }); + it('should not put the prebid instance in the frame without the outstream opt-in', function () { + loadClientInIframe(key); + const grumi = document.querySelector('#grumiFrame').contentWindow.grumi; + expect(grumi.pbjs).to.equal(undefined); + }); }); describe('setWrapper', function () { it('should set the wrapper', function () { @@ -169,5 +193,270 @@ describe('Geoedge RTD module', function () { expect(equalsOriginal).to.equal(true); }); }); + + // ----------------------------------------------------------------------- + // Outstream video gate + // ----------------------------------------------------------------------- + + describe('isVastBid', function () { + it('should accept a bid labeled as video', function () { + expect(isVastBid({ mediaType: 'video' })).to.equal(true); + }); + it('should accept a bid carrying vastXml', function () { + expect(isVastBid({ vastXml: '' })).to.equal(true); + }); + it('should accept a mislabeled bid whose ad starts with a VAST tag, case-insensitively', function () { + expect(isVastBid({ ad: '' })).to.equal(true); + expect(isVastBid({ ad: '' })).to.equal(true); + }); + it('should reject a vastUrl-only bid, since a correctly labeled video bid always arrives with vastXml backfilled', function () { + expect(isVastBid({ vastUrl: 'https://example.com/vast.xml' })).to.equal(false); + }); + it('should not scan past the head of bid.ad for a VAST marker', function () { + const buried = `${'x'.repeat(400)}`; + expect(isVastBid({ ad: buried })).to.equal(false); + }); + it('should reject a display bid', function () { + expect(isVastBid(mockBid('bidderA'))).to.equal(false); + }); + it('should reject a bid with no ad and no video fields', function () { + expect(isVastBid({})).to.equal(false); + }); + }); + + describe('outstream gate', function () { + let frame; + let originalRender; + + function makeOutstreamConfig(outstream) { + return { + name: 'geoedge', + params: { key, outstream, bidders: { bidderA: true } } + }; + } + + // isRendererRequired() needs url (or renderNow); the gate additionally needs render itself. + function mockRenderer() { + return { url: 'https://example.com/outstream.js', render: sinon.spy() }; + } + + function mockVideoBid(extra) { + return Object.assign(mockBid('bidderA'), { + mediaType: 'video', + vastXml: '', + renderer: mockRenderer() + }, extra); + } + + function gate(bid, outstream = true) { + geoedgeSubmodule.onBidResponseEvent(bid, makeOutstreamConfig(outstream)); + return bid; + } + + function isWrapped(bid, original) { + return bid.renderer.render !== original; + } + + function publishGate(shouldRender) { + frame.contentWindow[OUTSTREAM_API] = { shouldRender: sinon.stub().returns(shouldRender) }; + } + + beforeEach(function () { + document.querySelectorAll('#grumiFrame').forEach(el => el.remove()); + // establishes clientFrame; the adloader stub fires the load callback synchronously + loadClientInIframe(key, true); + frame = document.querySelector('#grumiFrame'); + delete frame.contentWindow[OUTSTREAM_API]; + resetOutstreamGateStateForTesting(); + }); + + describe('deciding what to wrap', function () { + it('should wrap the renderer of an outstream video bid', function () { + const bid = mockVideoBid(); + originalRender = bid.renderer.render; + gate(bid); + expect(isWrapped(bid, originalRender)).to.equal(true); + }); + it('should not wrap when the publisher did not opt in to outstream', function () { + const bid = mockVideoBid(); + originalRender = bid.renderer.render; + gate(bid, false); + expect(isWrapped(bid, originalRender)).to.equal(false); + }); + it('should not wrap a bid from a bidder params.bidders excludes', function () { + const bid = mockVideoBid({ bidderCode: 'bidderB' }); + originalRender = bid.renderer.render; + gate(bid); + expect(isWrapped(bid, originalRender)).to.equal(false); + }); + it('should not wrap a bid carrying a safeRenderer, since prebid never calls bid.renderer for those', function () { + const bid = mockVideoBid({ safeRenderer: true }); + originalRender = bid.renderer.render; + gate(bid); + expect(isWrapped(bid, originalRender)).to.equal(false); + }); + it('should not wrap when the renderer is not required by prebid', function () { + const bid = mockVideoBid({ renderer: { render: sinon.spy() } }); // no url / renderNow + originalRender = bid.renderer.render; + gate(bid); + expect(isWrapped(bid, originalRender)).to.equal(false); + }); + it('should not wrap when the renderer has no render method', function () { + const bid = mockVideoBid({ renderer: { url: 'https://example.com/outstream.js' } }); + gate(bid); + expect(bid.renderer.render).to.equal(undefined); + }); + it('should wrap a renderer only once across repeated bidResponse events', function () { + const bid = mockVideoBid(); + gate(bid); + const wrappedOnce = bid.renderer.render; + gate(bid); + expect(bid.renderer.render).to.equal(wrappedOnce); + }); + it('should fall through to html wrapping for a display bid when outstream is on', function () { + const bid = mockBid('bidderA'); + gate(bid); + expect(bid.ad.indexOf('')).to.equal(0); + }); + it('should not wrap the html of a gated video bid', function () { + const bid = mockVideoBid({ ad: '' }); + gate(bid); + expect(bid.ad.indexOf('')).to.equal(-1); + }); + }); + + describe('enforcing at render time', function () { + it('should render when the client allows the bid', function () { + const bid = mockVideoBid(); + originalRender = bid.renderer.render; + gate(bid); + markClientAsLoaded(); + publishGate(true); + bid.renderer.render(); + expect(originalRender.calledOnce).to.equal(true); + }); + it('should not render when the client blocks the bid', function () { + const bid = mockVideoBid(); + originalRender = bid.renderer.render; + gate(bid); + markClientAsLoaded(); + publishGate(false); + bid.renderer.render(); + expect(originalRender.called).to.equal(false); + }); + it('should render unprotected when the client published no gate', function () { + const bid = mockVideoBid(); + originalRender = bid.renderer.render; + gate(bid); + markClientAsLoaded(); + bid.renderer.render(); + expect(originalRender.calledOnce).to.equal(true); + }); + it('should preserve the renderer receiver and arguments', function () { + const bid = mockVideoBid(); + originalRender = bid.renderer.render; + gate(bid); + markClientAsLoaded(); + publishGate(true); + bid.renderer.render('a', 'b'); + expect(originalRender.calledOn(bid.renderer)).to.equal(true); + expect(originalRender.calledWithExactly('a', 'b')).to.equal(true); + }); + }); + + describe('parking a render until the client loads', function () { + it('should not render while the client has neither loaded nor timed out', function () { + const bid = mockVideoBid(); + originalRender = bid.renderer.render; + gate(bid); + bid.renderer.render(); + expect(originalRender.called).to.equal(false); + }); + it('should release a parked render once the client loads and allows it', function () { + const bid = mockVideoBid(); + originalRender = bid.renderer.render; + gate(bid); + bid.renderer.render(); + publishGate(true); + markClientAsLoaded(); + expect(originalRender.calledOnce).to.equal(true); + }); + it('should drop a parked render when the loaded client blocks it', function () { + const bid = mockVideoBid(); + originalRender = bid.renderer.render; + gate(bid); + bid.renderer.render(); + publishGate(false); + markClientAsLoaded(); + expect(originalRender.called).to.equal(false); + }); + it('should release each parked render exactly once', function () { + const first = mockVideoBid(); + const second = mockVideoBid(); + const firstRender = first.renderer.render; + const secondRender = second.renderer.render; + gate(first); + gate(second); + first.renderer.render(); + second.renderer.render(); + publishGate(true); + markClientAsLoaded(); + markClientAsLoaded(); + expect(firstRender.calledOnce).to.equal(true); + expect(secondRender.calledOnce).to.equal(true); + }); + }); + + describe('failing open on the client load deadline', function () { + let clock; + + beforeEach(function () { + clock = sinon.useFakeTimers(); + // re-arm the deadline against the fake clock + loadClientInIframe(key, true); + frame = document.querySelector('#grumiFrame'); + delete frame.contentWindow[OUTSTREAM_API]; + resetOutstreamGateStateForTesting(); + }); + afterEach(function () { + clock.restore(); + }); + + it('should release a parked render when the deadline passes', function () { + const bid = mockVideoBid(); + originalRender = bid.renderer.render; + gate(bid); + bid.renderer.render(); + expect(originalRender.called).to.equal(false); + clock.tick(OUTSTREAM_GATE_TIMEOUT); + expect(originalRender.calledOnce).to.equal(true); + }); + it('should release a parked render even when a gate would have blocked it', function () { + const bid = mockVideoBid(); + originalRender = bid.renderer.render; + gate(bid); + bid.renderer.render(); + publishGate(false); + clock.tick(OUTSTREAM_GATE_TIMEOUT); + expect(originalRender.calledOnce).to.equal(true); + }); + it('should render immediately once the deadline has already passed', function () { + const bid = mockVideoBid(); + originalRender = bid.renderer.render; + gate(bid); + clock.tick(OUTSTREAM_GATE_TIMEOUT); + bid.renderer.render(); + expect(originalRender.calledOnce).to.equal(true); + }); + it('should not release a parked render before the deadline', function () { + const bid = mockVideoBid(); + originalRender = bid.renderer.render; + gate(bid); + bid.renderer.render(); + clock.tick(OUTSTREAM_GATE_TIMEOUT - 1); + expect(originalRender.called).to.equal(false); + }); + }); + }); }); });