diff --git a/libraries/percentInView/percentInView.js b/libraries/percentInView/percentInView.js index fc6ed7d27d7..fc12d12f5ba 100644 --- a/libraries/percentInView/percentInView.js +++ b/libraries/percentInView/percentInView.js @@ -1,8 +1,23 @@ import { getWinDimensions, inIframe } from '../../src/utils.js'; import { getBoundingClientRect } from '../boundingClientRect/boundingClientRect.js'; -import { defer, PbPromise, delay } from '../../src/utils/promise.js'; -import { startAuction } from '../../src/prebid.js'; +import { PbPromise, urgentDelay } from '../../src/utils/promise.js'; +import { requestBids, startAuction } from '../../src/prebid.js'; import { getAdUnitElement } from '../../src/utils/adUnits.js'; +import { getGlobal } from '../../src/prebidGlobal.js'; +import { config } from '../../src/config.js'; + +/** + * Whether viewability may be measured with an intersection observer. Doing so means waiting for the + * observer to deliver, and so yielding the main thread; otherwise measurements are taken from the + * element's bounding rect instead. + * + * Follows `auctionOptions.viewabilityMeasurement` where it is set, and whether the main thread may be + * yielded at all otherwise. + */ +function observerAllowed() { + const measurement = config.getConfig('auctionOptions')?.viewabilityMeasurement; + return measurement == null ? (getGlobal().yield ?? true) : measurement === 'observer'; +} /** * return the offset between the given window's viewport and the top window's. @@ -71,14 +86,19 @@ function getIntersectionOfRects(rects) { return bbox; } -const percentInViewStatic = (element, { w, h } = {}) => { - const elementBoundingBox = getBoundingBox(element, { w, h }); - +/** + * Percentage of the given bounding box that lies within the top window's viewport, + * and within each of `clipRects`. + * + * `elementBoundingBox` is taken relative to `win`'s viewport, and is modified in place. + * `clipRects` are in top window coordinates. + */ +function percentInViewOfBox(elementBoundingBox, win, clipRects = []) { // when in an iframe, the bounding box is relative to the iframe's viewport // since we are intersecting it with the top window's viewport, attempt to // compensate for the offset between them - const offset = getViewportOffset(element?.ownerDocument?.defaultView); + const offset = getViewportOffset(win); elementBoundingBox.left += offset.x; elementBoundingBox.right += offset.x; elementBoundingBox.top += offset.y; @@ -86,13 +106,13 @@ const percentInViewStatic = (element, { w, h } = {}) => { const dims = getWinDimensions(); - // Obtain the intersection of the element and the viewport + // Obtain the intersection of the element, the viewport, and everything that clips the element const elementInViewBoundingBox = getIntersectionOfRects([{ left: 0, top: 0, right: dims.document.documentElement.clientWidth, bottom: dims.document.documentElement.clientHeight - }, elementBoundingBox]); + }, elementBoundingBox, ...clipRects]); let elementInViewArea, elementTotalArea; @@ -107,6 +127,85 @@ const percentInViewStatic = (element, { w, h } = {}) => { // No overlap between element and the viewport; therefore, the element // lies completely out of view return 0; +} + +/** + * Whether an ancestor with the given style contains an element positioned as `position`, and can + * therefore clip it. Out of flow elements are laid out against something further up the tree, and + * are not clipped by the ancestors they skip over. + */ +function contains(style, position) { + if (position !== 'absolute' && position !== 'fixed') { + return true; + } + return (position === 'absolute' && style.position !== 'static') || + style.transform !== 'none' || + style.perspective !== 'none' || + style.filter !== 'none' || + /transform|perspective|filter/.test(style.willChange) || + /paint|layout|strict|content/.test(style.contain); +} + +/** + * Rectangles that clip the given element, in top window coordinates: the boxes of its + * scrolling or overflow-hidden ancestors, and of every frame that contains it. + * + * Returns null if the element or one of its ancestors is styled so that nothing renders. + * + * Clipping is approximated: the boxes include the ancestors' borders rather than stopping at their + * padding edge. + */ +function getClipRects(element) { + const rects = []; + let el = element; + let win = element?.ownerDocument?.defaultView; + try { + while (el != null && win != null) { + const { x, y } = getViewportOffset(win); + let node = el; + // how the subtree being walked is positioned, which decides which ancestors can clip it + let position; + while (node != null) { + const style = getComputedStyle(node); + // an ancestor that is transparent or hidden hides the element regardless of positioning + if (style.visibility === 'hidden' || style.opacity === '0') { + return null; + } + if (node === el) { + position = style.position; + } else if (contains(style, position)) { + if (style.overflow !== 'visible') { + const rect = getBoundingClientRect(node); + rects.push({ left: rect.left + x, top: rect.top + y, right: rect.right + x, bottom: rect.bottom + y }); + } + // from here up it is this ancestor that carries the element, so its own positioning + // decides which of the remaining ancestors can clip it + position = style.position; + } + node = node.parentElement; + } + // nothing can render outside the frame that contains it + const frame = win.frameElement; + if (frame == null) break; + win = frame.ownerDocument?.defaultView; + const offset = getViewportOffset(win); + const rect = getBoundingClientRect(frame); + rects.push({ left: rect.left + offset.x, top: rect.top + offset.y, right: rect.right + offset.x, bottom: rect.bottom + offset.y }); + el = frame; + } + } catch (e) { + // some ancestors are cross-frame and cannot be inspected; clip against those we could reach + } + return rects; +} + +const percentInViewStatic = (element, { w, h } = {}) => { + const clipRects = getClipRects(element); + return clipRects == null ? 0 : percentInViewOfBox( + getBoundingBox(element, { w, h }), + element?.ownerDocument?.defaultView, + clipRects + ); }; export const dep = { @@ -122,13 +221,20 @@ export const dep = { */ export function intersections(mkObserver) { const intersections = new WeakMap(); - let next = defer(); + // resolvers waiting on the first entry for an element, keyed by that element. + // only elements that someone is actually waiting on appear here; they are dropped + // as soon as they are woken, so a page that is merely scrolling allocates nothing. + const waiting = new WeakMap(); + function observerCallback(entries) { entries.forEach(entry => { if ((intersections.get(entry.target)?.time ?? -1) < entry.time) { intersections.set(entry.target, entry); - next.resolve(); - next = defer(); + const resolvers = waiting.get(entry.target); + if (resolvers != null) { + waiting.delete(entry.target); + resolvers.forEach(resolve => resolve(entry)); + } } }); } @@ -140,26 +246,32 @@ export function intersections(mkObserver) { // IntersectionObserver not supported } - async function waitFor(element) { - const intersection = getIntersection(element); - if (intersection != null) { - return intersection; - } else { - return next.promise.then(() => waitFor(element)); - } + function waitFor(element) { + return new PbPromise(resolve => { + const resolvers = waiting.get(element); + if (resolvers == null) { + waiting.set(element, [resolve]); + } else { + resolvers.push(resolve); + } + }); } /** * Observe the given element; returns a promise to the first available intersection observed for it. */ async function observe(element) { element = dep.getElement(element); - if (element != null && obs != null && !intersections.has(element)) { + if (element == null || obs == null) { + return PbPromise.resolve(getIntersection(element)); + } + if (!intersections.has(element)) { obs.observe(element); intersections.set(element, null); - return waitFor(element); - } else { - return PbPromise.resolve(getIntersection(element)); } + // a null entry marks an element that is being observed but has not been reported on yet; wait + // for it, so that observing the same element again does not resolve the second caller with + // nothing to measure + return getIntersection(element) ?? waitFor(element); } /** @@ -182,6 +294,11 @@ export const viewportIntersections = intersections((callback) => new Intersectio export function mkIntersectionHook(intersections = viewportIntersections) { return function (next, request) { + if (!observerAllowed()) { + // measurements will not consult the observer, so there is nothing to wait for + next.call(this, request); + return; + } PbPromise.race([ PbPromise.allSettled((request.adUnits ?? []).map(adUnit => intersections.observe(getAdUnitElement(adUnit)) @@ -189,24 +306,68 @@ export function mkIntersectionHook(intersections = viewportIntersections) { // according to MDN, with threshold 0 "the callback will be run as soon as the target element intersects or touches the boundary of the root, even if no pixels are yet visible" // https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API // However, browsers appear to run it even when the element is outside the DOM - // just to be sure, cap the amount of time we wait for intersections - delay(20) + // just to be sure, cap the amount of time we wait for intersections. + // + // the cap only holds as an upper bound with high priority scheduling: an ordinary timer is + // queued behind all other ready work, so on a busy page it comes due well past its deadline - + // which is when bounding the wait matters most. + // + // the first observation for an element arrives as a queued task, rather than with the + // rendering lifecycle that carries later updates, so it needs well under a millisecond of free + // main thread; 20ms leaves margin for a moderately busy one. Much less would be + // counterproductive: a user-blocking task outruns the observer, so the wait would end before + // any entry had arrived. + urgentDelay(20) ]).then(() => next.call(this, request)); }; } +/** + * Begin observing ad unit elements as soon as bids are requested, rather than when the auction + * starts, so that entries are already available by the time `mkIntersectionHook` needs them and it + * does not have to wait. The hooks that run in between are asynchronous (consent, price floors, + * currency, user ids), which gives the observer the main thread time it needs to deliver. + */ +export function mkPrewarmHook(intersections = viewportIntersections) { + return function (next, request) { + if (observerAllowed()) { + (request?.adUnits ?? []).forEach(adUnit => { + // deliberately not waited on; this only needs to get the observation started + intersections.observe(getAdUnitElement(adUnit)).catch(() => { + // the element cannot be observed, so percentInView measures the DOM directly instead + }); + }); + } + next.call(this, request); + }; +} + startAuction.before(mkIntersectionHook()); +// ahead of the asynchronous requestBids hooks (all of which are priority 50 or lower), so that their +// main thread time is available to the observer +requestBids.before(mkPrewarmHook(), 100); export function percentInView(element, { w, h } = {}) { + if (!observerAllowed()) { + return percentInViewStatic(element, { w, h }); + } const intersection = viewportIntersections.getIntersection(element); if (intersection == null) { viewportIntersections.observe(element); return percentInViewStatic(element, { w, h }); } else { - const adjusted = applySize(intersection.boundingClientRect, { w, h }); - if (adjusted.width !== intersection.boundingClientRect.width || adjusted.height !== intersection.boundingClientRect.height) { - // use w/h override - return percentInViewStatic(element, { w, h }); + const bbox = intersection.boundingClientRect; + const adjusted = applySize(bbox, { w, h }); + if (adjusted.width !== bbox.width || adjusted.height !== bbox.height) { + // the element has collapsed, so the observer's ratio describes a rect of no area; + // recompute from the w/h override, reusing the position the observer already + // reported to avoid forcing a layout for a rect we have on hand + return percentInViewOfBox(adjusted, element?.ownerDocument?.defaultView); + } + if (bbox.width === 0 || bbox.height === 0) { + // an element with no area renders nothing, but intersection observers report a ratio + // of 1 for a zero-area target that touches the viewport, which would read as fully in view + return 0; } return intersection.isIntersecting ? intersection.intersectionRatio * 100 : 0; } diff --git a/src/auction.ts b/src/auction.ts index 9eb60ba23aa..075c8464f86 100644 --- a/src/auction.ts +++ b/src/auction.ts @@ -150,6 +150,33 @@ export interface AuctionOptionsConfig { */ legacyRender?: boolean; + /** + * How viewability is measured when it is included in bid requests. The two options trade processing + * work against how much the auction can be held up by the rest of the page. + * + * `'observer'` takes the measurement from an intersection observer. It is much the cheaper of the + * two: reading it is a property access on a figure the browser has already worked out, so it costs + * no layout at all, and it correctly accounts for everything that clips the ad, including the + * bounds of a cross origin iframe. The cost is that the auction cannot start until the observer has + * reported, which requires yielding the main thread. On a page that keeps the main thread busy with + * long tasks, that yield is only taken once the longest of them has finished, and the auction is + * held up for that whole time. + * + * `'boundingBox'` computes the measurement from the ad element's bounding rect. Nothing is waited + * for, so the auction never queues behind the rest of the page. In exchange every measurement + * forces a layout, which is orders of magnitude dearer than reading an observer entry and on a page + * with complex CSS can run into milliseconds; and inside a cross origin iframe it can only measure + * against the frame's own viewport, so an ad scrolled well off the page can still read as fully in + * view. + * + * So: `'observer'` to do less work, `'boundingBox'` to keep the auction off the critical path of + * whatever else the page is doing. + * + * Defaults to `'observer'`, or to `'boundingBox'` when main thread yielding is turned off with + * `pbjs.yield = false`. + */ + viewabilityMeasurement?: 'observer' | 'boundingBox'; + /** * When true, reject bids without a response `mediaType` when the ad unit has an explicit mediaTypes list. * Default is false to preserve legacy behavior for responses that omit mediaType. diff --git a/src/config.ts b/src/config.ts index c866d05df9c..6fe22bcf09e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -66,7 +66,8 @@ function attachProperties(config, useDefaultValues = true) { const validateauctionOptions = (() => { const boolKeys = ['suppressStaleRender', 'suppressExpiredRender', 'legacyRender', 'rejectUnknownMediaTypes', 'rejectInvalidMediaTypes']; const arrKeys = ['secondaryBidders']; - const allKeys = [].concat(boolKeys).concat(arrKeys); + const enumKeys = { viewabilityMeasurement: ['observer', 'boundingBox'] }; + const allKeys = [].concat(boolKeys).concat(arrKeys).concat(Object.keys(enumKeys)); return function validateauctionOptions(val) { if (!isPlainObject(val)) { @@ -92,6 +93,11 @@ function attachProperties(config, useDefaultValues = true) { logWarn(`Auction Options ${k} must be of type boolean`); return false; } + } else if (enumKeys.hasOwnProperty(k)) { + if (!enumKeys[k].includes(val[k])) { + logWarn(`Auction Options ${k} must be one of: ${enumKeys[k].join(', ')}`); + return false; + } } } return true; diff --git a/src/utils/promise.ts b/src/utils/promise.ts index a83f5bf23c1..fa00fe2d4fb 100644 --- a/src/utils/promise.ts +++ b/src/utils/promise.ts @@ -23,6 +23,32 @@ export function delay(delayMs = 0): Promise { }); } +/** + * Like `delay`, but asks for high priority scheduling, so that the continuation is dispatched ahead + * of ordinary timers and network callbacks once the main thread frees up. Use this where a timeout + * is meant to act as an upper bound: plain timers queue behind work that is already pending, which + * on a busy page can put them arbitrarily far past their deadline. + * + * This is not a real time guarantee. Nothing in JS preempts a running task, so a single long task + * still pushes the continuation past `delayMs`; what this bounds is the wait by the longest blocking + * task rather than by the whole backlog of ready work. + */ +export function urgentDelay(delayMs = 0): Promise { + const scheduler = (window as any).scheduler; + if (typeof scheduler?.postTask === 'function') { + try { + return PbPromise.resolve( + scheduler.postTask(() => {}, { priority: 'user-blocking', delay: delayMs }) + // a task can only be aborted through a signal, which is not used here; resolve regardless so + // that callers racing against this never stall on a rejection + ).catch(() => {}) as Promise; + } catch (e) { + // options rejected by this implementation of postTask; fall back to a timer + } + } + return delay(delayMs); +} + export interface Defer { promise: Promise; resolve: Parameters>[0]>[0], diff --git a/test/mocks/percentInView.js b/test/mocks/percentInView.js index 741f68109ba..29c2ec6e9ba 100644 --- a/test/mocks/percentInView.js +++ b/test/mocks/percentInView.js @@ -22,3 +22,41 @@ export function enable() { export function disable() { enabled = false; } + +let frameRectEnabled = true; +const frameElement = window.frameElement; + +if (frameElement != null) { + // karma runs tests inside an iframe (context.html) that is offset from the top window, while the + // debug page runs them in the top window. Report the top window's viewport as the containing + // frame's box, so that viewability measurements do not depend on which of the two is in use. + const original = frameElement.getBoundingClientRect; + frameElement.getBoundingClientRect = function () { + if (!frameRectEnabled) { + return original.call(this); + } + const doc = window.top.document.documentElement; + return { + left: 0, + top: 0, + x: 0, + y: 0, + right: doc.clientWidth, + bottom: doc.clientHeight, + width: doc.clientWidth, + height: doc.clientHeight + }; + }; +} + +export function enableFrameRect() { + frameRectEnabled = true; +} + +/** + * Report the real box of the frame containing the tests, for tests that need the frame's actual + * position or size. Remember to `enableFrameRect()` afterwards. + */ +export function disableFrameRect() { + frameRectEnabled = false; +} diff --git a/test/spec/config_spec.js b/test/spec/config_spec.js index 370bdec72fd..e1b106bb8b5 100644 --- a/test/spec/config_spec.js +++ b/test/spec/config_spec.js @@ -353,6 +353,25 @@ describe('config API', function () { expect(getConfig('auctionOptions')).to.eql(auctionOptionsConfig); }); + it('sets auctionOptions viewabilityMeasurement', function () { + const auctionOptionsConfig = { + 'viewabilityMeasurement': 'boundingBox' + }; + setConfig({ auctionOptions: auctionOptionsConfig }); + expect(getConfig('auctionOptions')).to.eql(auctionOptionsConfig); + }); + + it('should log warning for invalid auctionOptions viewabilityMeasurement', function () { + setConfig({ + auctionOptions: { + 'viewabilityMeasurement': 'nope', + } + }); + expect(logWarnSpy.calledOnce).to.equal(true); + const warning = 'Auction Options viewabilityMeasurement must be one of: observer, boundingBox'; + assert.ok(logWarnSpy.calledWith(warning), 'expected warning was logged'); + }); + it('should log warning for the wrong value passed to auctionOptions', function () { setConfig({ auctionOptions: '' }); expect(logWarnSpy.calledOnce).to.equal(true); diff --git a/test/spec/libraries/percentInView_spec.js b/test/spec/libraries/percentInView_spec.js index 322a786fe9d..b3f35e1d3f4 100644 --- a/test/spec/libraries/percentInView_spec.js +++ b/test/spec/libraries/percentInView_spec.js @@ -2,13 +2,16 @@ import { getViewportOffset, intersections, mkIntersectionHook, + mkPrewarmHook, percentInView, viewportIntersections, } from '../../../libraries/percentInView/percentInView.js'; import * as bbox from 'libraries/boundingClientRect/boundingClientRect'; -import { enable, disable } from 'test/mocks/percentInView.js'; +import { enable, disable, enableFrameRect, disableFrameRect } from 'test/mocks/percentInView.js'; import { defer } from 'src/utils/promise.js'; +import { getGlobal } from 'src/prebidGlobal.js'; +import { config } from 'src/config.js'; describe('percentInView', () => { before(() => { @@ -143,6 +146,14 @@ describe('percentInView', () => { expect(result).to.eql(entry); }); }); + it('observe should wait when the element is already observed but has no entry yet', async () => { + obs.observe(el); + const pm = obs.observe(el); + const entry = { target: el, time: 1 }; + callback([entry]); + expect(await pm).to.eql(entry); + }); + it('should ignore stale entries', async () => { const entry = { target: el, @@ -258,22 +269,316 @@ describe('percentInView', () => { sandbox.stub(bbox, 'getBoundingClientRect'); }); - it('does not use intersection if w/h are relevant', () => { - bbox.getBoundingClientRect.returns({ - width: 0, - height: 0, - left: -50, - top: -100, - }); + it('does not use intersection ratio if w/h are relevant', () => { + const element = {}; intersection = { boundingClientRect: { width: 0, height: 0, + left: -50, + top: -100, }, isIntersecting: true, intersectionRatio: 1 }; - expect(percentInView({}, { w: 100, h: 200 })).to.not.eql(100); + // a quarter of the overridden 100x200 size lies within the viewport + expect(percentInView(element, { w: 100, h: 200 })).to.eql(25); + // the observer already reported where the element is; measuring it again would + // force a layout + sinon.assert.neverCalledWith(bbox.getBoundingClientRect, element); + }); + + it('uses the intersection ratio when the element has an area', () => { + intersection = { + boundingClientRect: { + width: 300, + height: 250, + }, + isIntersecting: true, + intersectionRatio: 0.5 + }; + expect(percentInView({})).to.eql(50); + }); + + Object.entries({ + 'height': { width: 300, height: 0 }, + 'width': { width: 0, height: 250 }, + }).forEach(([dimension, boundingClientRect]) => { + it(`returns 0 for an element with no ${dimension} and no size override`, () => { + // intersection observers report a ratio of 1 for zero-area targets + intersection = { + boundingClientRect, + isIntersecting: true, + intersectionRatio: 1 + }; + expect(percentInView({})).to.eql(0); + }); + }); + }); + + describe('viewabilityMeasurement', () => { + afterEach(() => { + config.resetConfig(); + delete getGlobal().yield; + }); + + function usesObserver() { + const getIntersection = sandbox.stub(viewportIntersections, 'getIntersection'); + sandbox.stub(viewportIntersections, 'observe'); + sandbox.stub(bbox, 'getBoundingClientRect').returns({ width: 1, height: 1, left: 0, top: 0, right: 1, bottom: 1 }); + percentInView(document.createElement('div')); + return getIntersection.called; + } + + it('uses the observer when neither it nor pbjs.yield is set', () => { + expect(usesObserver()).to.be.true; + }); + + it('is taken from pbjs.yield when unset', () => { + getGlobal().yield = false; + expect(usesObserver()).to.be.false; + }); + + it('uses the observer when pbjs.yield is explicitly true', () => { + getGlobal().yield = true; + expect(usesObserver()).to.be.true; + }); + + it('overrides pbjs.yield when set to observer', () => { + getGlobal().yield = false; + config.setConfig({ auctionOptions: { viewabilityMeasurement: 'observer' } }); + expect(usesObserver()).to.be.true; + }); + + it('overrides pbjs.yield when set to boundingBox', () => { + getGlobal().yield = true; + config.setConfig({ auctionOptions: { viewabilityMeasurement: 'boundingBox' } }); + expect(usesObserver()).to.be.false; + }); + }); + + describe('when yielding is disabled', () => { + beforeEach(() => { + getGlobal().yield = false; + }); + afterEach(() => { + delete getGlobal().yield; + }); + + it('the intersection hook does not wait', () => { + const next = sinon.stub(); + const request = { adUnits: [{ element: 'el1' }] }; + const intersections = { observe: sinon.stub().returns(new Promise(() => {})) }; + mkIntersectionHook(intersections)(next, request); + // synchronously, with no observation started + sinon.assert.calledWith(next, request); + sinon.assert.notCalled(intersections.observe); + }); + + it('the prewarm hook does not observe', () => { + const next = sinon.stub(); + const request = { adUnits: [{ element: 'el1' }] }; + const intersections = { observe: sinon.stub().resolves() }; + mkPrewarmHook(intersections)(next, request); + sinon.assert.notCalled(intersections.observe); + sinon.assert.calledWith(next, request); + }); + + it('percentInView measures the DOM instead of consulting the observer', () => { + const getIntersection = sandbox.stub(viewportIntersections, 'getIntersection'); + const observe = sandbox.stub(viewportIntersections, 'observe'); + const el = document.createElement('div'); + el.style.cssText = 'position:absolute;left:0;top:0;width:50px;height:50px'; + document.body.appendChild(el); + try { + expect(percentInView(el)).to.be.a('number'); + sinon.assert.notCalled(getIntersection); + sinon.assert.notCalled(observe); + } finally { + el.remove(); + } + }); + }); + + describe('prewarm hook', () => { + let intersections, hook, next, request; + beforeEach(() => { + next = sinon.stub(); + intersections = { observe: sinon.stub().resolves() }; + hook = mkPrewarmHook(intersections); + request = {}; + }); + + it('observes the element of every ad unit', () => { + request.adUnits = [{ element: 'el1' }, { code: 'el2' }]; + sandbox.stub(document, 'getElementById').returns('el2'); + hook(next, request); + sinon.assert.calledWith(intersections.observe, 'el1'); + sinon.assert.calledWith(intersections.observe, 'el2'); + }); + + it('does not wait for the observations to resolve', () => { + let observed; + intersections.observe.returns(new Promise((resolve) => { observed = resolve; })); + request.adUnits = [{ element: 'el1' }]; + hook(next, request); + sinon.assert.calledWith(next, request); + observed(); + }); + + it('continues when an element cannot be observed', async () => { + intersections.observe.rejects(new Error()); + request.adUnits = [{ element: 'el1' }]; + hook(next, request); + sinon.assert.calledWith(next, request); + // give the rejection a chance to surface as an unhandled rejection + await delay(); + }); + + it('does not choke on a request with no ad units', () => { + hook(next, request); + sinon.assert.notCalled(intersections.observe); + sinon.assert.calledWith(next, request); + }); + }); + + describe('prewarm followed by the auction hook', () => { + it('still waits for the observer when prewarm has already observed the element', async () => { + let callback; + const nakedObs = { observe: sinon.stub() }; + const obs = intersections((cb) => { callback = cb; return nakedObs; }); + const el = document.createElement('div'); + const request = { adUnits: [{ element: el }] }; + + // requestBids: prewarm starts the observation + mkPrewarmHook(obs)(sinon.stub(), request); + // startAuction runs before anything has yielded, so no entry has arrived yet + const next = sinon.stub(); + mkIntersectionHook(obs)(next, request); + + await delay(0); + expect(obs.getIntersection(el)).to.eql(null); + sinon.assert.notCalled(next); + + callback([{ target: el, time: 1, isIntersecting: true, intersectionRatio: 1 }]); + await delay(0); + sinon.assert.called(next); + }); + }); + + describe('frame rect mock', () => { + // only applies when karma runs the tests in an iframe, which excludes the debug page + if (window.frameElement == null) return; + + afterEach(() => { + enableFrameRect(); + }); + + it('reports the top window viewport while enabled', () => { + const { left, top, width, height } = window.frameElement.getBoundingClientRect(); + const doc = window.top.document.documentElement; + expect({ left, top, width, height }).to.eql({ + left: 0, top: 0, width: doc.clientWidth, height: doc.clientHeight + }); + }); + + it('reports the real frame rect once disabled', () => { + disableFrameRect(); + // the frame element belongs to the containing document, so its rect comes from that realm + const { DOMRect } = window.frameElement.ownerDocument.defaultView; + expect(window.frameElement.getBoundingClientRect()).to.be.instanceOf(DOMRect); + }); + }); + + describe('percentInView, with no intersection available', () => { + let container; + + beforeEach(() => { + // no intersection entry, so the measurement runs off the DOM + sandbox.stub(viewportIntersections, 'getIntersection').returns(undefined); + sandbox.stub(viewportIntersections, 'observe'); + bbox.clearCache(); + container = document.createElement('div'); + container.style.cssText = 'position:absolute;left:0;top:0'; + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + bbox.clearCache(); + }); + + function measure(html) { + container.innerHTML = html; + bbox.clearCache(); + return percentInView(container.querySelector('#target')); + } + + const TARGET = '
'; + + it('is clipped by an overflow-hidden ancestor', () => { + const unclipped = measure(TARGET); + expect(unclipped).to.be.greaterThan(0); + const clipped = measure(`
${TARGET}
`); + expect(clipped).to.be.greaterThan(0); + expect(clipped).to.be.lessThan(unclipped); + }); + + it('returns 0 for an element held entirely outside an overflow-hidden ancestor', () => { + expect(measure( + `
+
${TARGET}
+
` + )).to.eql(0); + }); + + it('returns 0 for an element scrolled out of a scrolling ancestor', () => { + expect(measure( + `
+
${TARGET} +
` + )).to.eql(0); + }); + + // identical markup except for what makes the clipper contain the target, so the only thing + // the comparison can be measuring is whether the clip was applied + const ABS_TARGET = '
'; + const escaping = () => measure(`
${ABS_TARGET}
`); + + it('is not clipped by an ancestor that does not contain it', () => { + const contained = measure( + `
${ABS_TARGET}
` + ); + expect(contained).to.be.lessThan(escaping()); + }); + + it('is clipped by an ancestor that establishes a containing block', () => { + const contained = measure( + `
${ABS_TARGET}
` + ); + expect(contained).to.be.lessThan(escaping()); + }); + + it('is not clipped by an ancestor when it is fixed to the viewport', () => { + // the target sits well clear of the clipper, so any clipping at all would report 0 + expect(measure( + `
+
+
` + )).to.be.greaterThan(0); + }); + + Object.entries({ + 'visibility:hidden': 'visibility:hidden', + 'opacity:0': 'opacity:0', + }).forEach(([label, css]) => { + it(`returns 0 for an element with ${label}`, () => { + expect(measure(`
`)).to.eql(0); + }); + + it(`returns 0 for an element under an ancestor with ${label}`, () => { + expect(measure(`
${TARGET}
`)).to.eql(0); + }); }); }); }); diff --git a/test/spec/unit/utils/promise_spec.js b/test/spec/unit/utils/promise_spec.js index 2b14394b67e..bf960bd3647 100644 --- a/test/spec/unit/utils/promise_spec.js +++ b/test/spec/unit/utils/promise_spec.js @@ -1,4 +1,4 @@ -import { defer } from '../../../../src/utils/promise.js'; +import { defer, urgentDelay } from '../../../../src/utils/promise.js'; describe('defer', () => { Object.entries({ @@ -21,3 +21,46 @@ describe('defer', () => { }); }); }); + +describe('urgentDelay', () => { + let sandbox, scheduler; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + scheduler = window.scheduler; + }); + + afterEach(() => { + sandbox.restore(); + if (scheduler == null) { + delete window.scheduler; + } else { + window.scheduler = scheduler; + } + }); + + it('schedules through postTask, at user-blocking priority, when it is available', async () => { + const postTask = sinon.stub().resolves(); + window.scheduler = { postTask }; + await urgentDelay(20); + sinon.assert.calledOnce(postTask); + expect(postTask.firstCall.args[1]).to.eql({ priority: 'user-blocking', delay: 20 }); + }); + + it('resolves through a timer when postTask is unavailable', async () => { + delete window.scheduler; + await urgentDelay(1); + }); + + it('falls back to a timer when postTask throws', async () => { + const postTask = sinon.stub().throws(new Error()); + window.scheduler = { postTask }; + await urgentDelay(1); + sinon.assert.called(postTask); + }); + + it('resolves, rather than rejecting, when the scheduled task is aborted', async () => { + window.scheduler = { postTask: sinon.stub().rejects(new Error()) }; + await urgentDelay(1); + }); +}); diff --git a/test/test_deps.js b/test/test_deps.js index 4abbd63d258..5c8e7127e39 100644 --- a/test/test_deps.js +++ b/test/test_deps.js @@ -47,14 +47,6 @@ sinon.createFakeServerWithClock = fakeServerWithClock.create.bind(fakeServerWith localStorage.clear(); -if (window.frameElement != null) { - // sometimes (e.g. chrome headless) the tests run in an iframe that is offset from the top window - // other times (e.g. browser debug page) they run in the top window - // this can cause inconsistencies with the percentInView libraries; if we are in a frame, - // fake the same dimensions as the top window - window.frameElement.getBoundingClientRect = () => window.top.getBoundingClientRect(); -} - require('test/helpers/global_hooks.js'); require('test/helpers/consentData.js'); require('test/helpers/prebidGlobal.js');