diff --git a/modules/connatixBidAdapter.js b/modules/connatixBidAdapter.js index 925ddebe099..7a110a59107 100644 --- a/modules/connatixBidAdapter.js +++ b/modules/connatixBidAdapter.js @@ -2,12 +2,17 @@ import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { percentInView } from '../libraries/percentInView/percentInView.js'; + import { deepAccess, isFn, logError, isArray, formatQS, + getWindowTop, + isNumber, + isStr, deepSetValue } from '../src/utils.js'; @@ -62,6 +67,76 @@ export function validateVideo(mediaTypes) { return video.context !== ADPOD; } +export function _getMinSize(sizes) { + if (!sizes || sizes.length === 0) return undefined; + return sizes.reduce((minSize, currentSize) => { + const minArea = minSize.w * minSize.h; + const currentArea = currentSize.w * currentSize.h; + return currentArea < minArea ? currentSize : minSize; + }); +} + +export function _canSelectViewabilityContainer() { + try { + window.top.document.querySelector('#viewability-container'); + return true; + } catch (e) { + return false; + } +} + +export function _isViewabilityMeasurable(element) { + if (!element) return false; + return _canSelectViewabilityContainer(element); +} + +export function _getViewability(element, topWin, { w, h } = {}) { + return topWin.document.visibilityState === 'visible' + ? percentInView(element, topWin, { w, h }) + : 0; +} + +export function detectViewability(bid) { + const { params, adUnitCode } = bid; + + const viewabilityContainerIdentifier = params.viewabilityContainerIdentifier; + + let element = null; + let bidParamSizes = null; + let minSize = []; + + if (isStr(viewabilityContainerIdentifier)) { + try { + element = document.querySelector(viewabilityContainerIdentifier) || window.top.document.querySelector(viewabilityContainerIdentifier); + if (element) { + bidParamSizes = [element.offsetWidth, element.offsetHeight]; + minSize = _getMinSize(bidParamSizes) + } + } catch (e) { + logError(`Error while trying to find viewability container element: ${viewabilityContainerIdentifier}`); + } + } + + if (!element) { + // Get the sizes from the mediaTypes object if it exists, otherwise use the sizes array from the bid object + bidParamSizes = bid.mediaTypes && bid.mediaTypes.banner && bid.mediaTypes.banner.sizes ? bid.mediaTypes.banner.sizes : bid.sizes; + bidParamSizes = typeof bidParamSizes === 'undefined' && bid.mediaType && bid.mediaType.video && bid.mediaType.video.playerSize ? bid.mediaType.video.playerSize : bidParamSizes; + bidParamSizes = typeof bidParamSizes === 'undefined' && bid.mediaType && bid.mediaType.video && isNumber(bid.mediaType.video.w) && isNumber(bid.mediaType.h) ? [bid.mediaType.video.w, bid.mediaType.video.h] : bidParamSizes; + minSize = _getMinSize(bidParamSizes ?? []) + element = document.getElementById(adUnitCode); + } + + if (_isViewabilityMeasurable(element)) { + const minSizeObj = { + w: minSize[0], + h: minSize[1] + } + return Math.round(_getViewability(element, getWindowTop(), minSizeObj)) + } + + return null; +} + /** * Get ids from Prebid User ID Modules and add them to the payload */ @@ -91,9 +166,10 @@ export const spec = { const hasMediaTypes = Boolean(mediaTypes) && (Boolean(mediaTypes[BANNER]) || Boolean(mediaTypes[VIDEO])); const isValidBanner = validateBanner(mediaTypes); const isValidVideo = validateVideo(mediaTypes); + const isValidViewability = typeof params.viewabilityPercentage === 'undefined' || (isNumber(params.viewabilityPercentage) && params.viewabilityPercentage >= 0 && params.viewabilityPercentage <= 1); const hasRequiredBidParams = Boolean(params.placementId); - const isValid = hasBidId && hasMediaTypes && isValidBanner && isValidVideo && hasRequiredBidParams; + const isValid = hasBidId && hasMediaTypes && isValidBanner && isValidVideo && hasRequiredBidParams && isValidViewability; if (!isValid) { logError( `Invalid bid request: @@ -120,10 +196,18 @@ export const spec = { params, sizes, } = bid; + + let detectedViewabilityPercentage = detectViewability(bid); + if (isNumber(detectedViewabilityPercentage)) { + detectedViewabilityPercentage = detectedViewabilityPercentage / 100; + } + return { bidId, mediaTypes, sizes, + detectedViewabilityPercentage, + declaredViewabilityPercentage: bid.params.viewabilityPercentage ?? null, placementId: params.placementId, floor: getBidFloor(bid), }; diff --git a/test/spec/modules/connatixBidAdapter_spec.js b/test/spec/modules/connatixBidAdapter_spec.js index d2e8ea63cbe..e9718136052 100644 --- a/test/spec/modules/connatixBidAdapter_spec.js +++ b/test/spec/modules/connatixBidAdapter_spec.js @@ -1,7 +1,13 @@ import { expect } from 'chai'; +import sinon from 'sinon'; import { spec, - getBidFloor as connatixGetBidFloor + detectViewability as connatixDetectViewability, + getBidFloor as connatixGetBidFloor, + _getMinSize as connatixGetMinSize, + _isViewabilityMeasurable as connatixIsViewabilityMeasurable, + _canSelectViewabilityContainer as connatixCanSelectViewabilityContainer, + _getViewability as connatixGetViewability, } from '../../../modules/connatixBidAdapter.js'; import { ADPOD, BANNER, VIDEO } from '../../../src/mediaTypes.js'; @@ -44,6 +50,283 @@ describe('connatixBidAdapter', function () { bid.mediaTypes = mediaTypes; } + describe('connatixGetMinSize', () => { + it('should return the smallest size based on area', () => { + const sizes = [ + { w: 300, h: 250 }, + { w: 728, h: 90 }, + { w: 160, h: 600 } + ]; + const result = connatixGetMinSize(sizes); + expect(result).to.deep.equal({ w: 728, h: 90 }); + }); + + it('should handle an array with one size', () => { + const sizes = [{ w: 300, h: 250 }]; + const result = connatixGetMinSize(sizes); + expect(result).to.deep.equal({ w: 300, h: 250 }); + }); + + it('should handle empty array', () => { + const sizes = []; + const result = connatixGetMinSize(sizes); + expect(result).to.be.undefined; + }); + }); + + describe('_isIframe', () => { + let querySelectorStub; + + beforeEach(() => { + querySelectorStub = sinon.stub(window.top.document, 'querySelector'); + }); + + afterEach(() => { + querySelectorStub.restore(); + }); + + it('should return true when window.top.document.querySelector does not throw an error', () => { + querySelectorStub.returns({}); + expect(connatixCanSelectViewabilityContainer()).to.be.true; + }); + + it('should return false when window.top.document.querySelector throws an error', () => { + querySelectorStub.throws(new Error('test error')); + expect(connatixCanSelectViewabilityContainer()).to.be.false; + }); + }); + + describe('_isViewabilityMeasurable', () => { + let querySelectorStub; + + beforeEach(() => { + querySelectorStub = sinon.stub(window.top.document, 'querySelector'); + }); + + afterEach(() => { + querySelectorStub.restore(); + }); + + it('should return false if the element is null or undefined', () => { + expect(connatixIsViewabilityMeasurable(null)).to.be.false; + expect(connatixIsViewabilityMeasurable(undefined)).to.be.false; + }); + + it('should return false if _isIframe returns true', () => { + querySelectorStub.throws(new Error('test error')); + + const element = document.createElement('div'); + expect(connatixIsViewabilityMeasurable(element)).to.be.false; + }); + + it('should return true if _isIframe returns false', () => { + querySelectorStub.returns(document.createElement('div')) + + const element = document.createElement('div'); + expect(connatixIsViewabilityMeasurable(element)).to.be.true; + }); + }); + + describe('_getViewability', () => { + let element; + let getBoundingClientRectStub; + let topWinMock; + + beforeEach(() => { + element = document.createElement('div'); + getBoundingClientRectStub = sinon.stub(element, 'getBoundingClientRect'); + + topWinMock = { + document: { + visibilityState: 'visible' + }, + innerWidth: 800, + innerHeight: 600 + }; + }); + + afterEach(() => { + getBoundingClientRectStub.restore(); + }); + + it('should return 0 if the document is not visible', () => { + topWinMock.document.visibilityState = 'hidden'; + + const viewability = connatixGetViewability(element, topWinMock); + + expect(viewability).to.equal(0); + }); + + it('should return 100% if the element is fully in view', () => { + const boundingBox = { left: 100, top: 100, right: 300, bottom: 300, width: 200, height: 200 }; + getBoundingClientRectStub.returns(boundingBox); + + const viewability = connatixGetViewability(element, topWinMock); + + expect(viewability).to.equal(100); + }); + + it('should return the correct percentage if the element is partially in view', () => { + const boundingBox = { left: 700, top: 500, right: 900, bottom: 700, width: 200, height: 200 }; + getBoundingClientRectStub.returns(boundingBox); + + const viewability = connatixGetViewability(element, topWinMock); + + expect(viewability).to.equal(25); // 100x100 / 200x200 = 0.25 -> 25% + }); + + it('should return 0% if the element is not in view', () => { + const boundingBox = { left: 900, top: 700, right: 1100, bottom: 900, width: 200, height: 200 }; + getBoundingClientRectStub.returns(boundingBox); + + const viewability = connatixGetViewability(element, topWinMock); + + expect(viewability).to.equal(0); + }); + + it('should use provided width and height if element dimensions are zero', () => { + const boundingBox = { left: 100, top: 100, right: 100, bottom: 100, width: 0, height: 0 }; + getBoundingClientRectStub.returns(boundingBox); + + const dimensions = { w: 200, h: 200 }; + const viewability = connatixGetViewability(element, topWinMock, dimensions); + + expect(viewability).to.equal(100); // Element fully in view with provided dimensions + }); + }); + + describe('detectViewability', () => { + let element; + let getBoundingClientRectStub; + let topWinMock; + let querySelectorStub; + let getElementByIdStub; + + beforeEach(() => { + element = document.createElement('div'); + getBoundingClientRectStub = sinon.stub(element, 'getBoundingClientRect'); + + topWinMock = { + document: { + visibilityState: 'visible' + }, + innerWidth: 800, + innerHeight: 600 + }; + + querySelectorStub = sinon.stub(window.top.document, 'querySelector'); + getElementByIdStub = sinon.stub(document, 'getElementById'); + }); + + afterEach(() => { + getBoundingClientRectStub.restore(); + querySelectorStub.restore(); + getElementByIdStub.restore(); + }); + + it('should return 100% viewability when the element is fully within view and has a valid viewabilityContainerIdentifier', () => { + const bid = { + params: { viewabilityContainerIdentifier: '#validElement' }, + adUnitCode: 'adUnitCode123', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + sizes: [[300, 250]] + }; + + getBoundingClientRectStub.returns({ + left: 100, + top: 100, + right: 400, + bottom: 350, + width: 300, + height: 250 + }); + + querySelectorStub.withArgs('#validElement').returns(element); + getElementByIdStub.returns(null); + + const result = connatixDetectViewability(bid); + + // Expected calculation: the element is fully in view, so 100% viewability + expect(result).to.equal(100); + }); + + it('should fall back to using bid sizes and adUnitCode when the viewabilityContainerIdentifier is invalid or was not provided', () => { + const bid = { + params: { viewabilityContainerIdentifier: '#invalidElement' }, + adUnitCode: 'adUnitCode123', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + sizes: [[300, 250]] + }; + + getBoundingClientRectStub.returns({ + left: 200, + top: 100, + right: 500, + bottom: 350, + width: 300, + height: 250 + }); + + querySelectorStub.withArgs('#invalidElement').returns(null); + getElementByIdStub.withArgs('adUnitCode123').returns(element); + + const result = connatixDetectViewability(bid); + + expect(result).to.equal(100); // Full viewability + }); + + it('should use the adUnitCode as a fallback when querying an element fails due to a browser error, and return 100% viewability because adUnitCode container is fully in view', () => { + const bid = { + params: { viewabilityContainerIdentifier: '#invalidElement' }, + adUnitCode: 'adUnitCode123', + sizes: [[300, 250]] + }; + + // Simulate an error when querying the element + querySelectorStub.withArgs('#invalidElement').throws(new Error('Query failed')); + + getBoundingClientRectStub.returns({ + left: 100, + top: 100, + right: 400, + bottom: 350, + width: 300, + height: 250 + }); + + // The fallback should use the adUnitCode to find the element + getElementByIdStub.withArgs('adUnitCode123').returns(element); + + const result = connatixDetectViewability(bid); + + expect(result).to.equal(100); // Expect the fallback to work and return 100% viewability + }); + + it('should return null when querying the element by the provided identifier fails and the adUnitCode viewability container is unavailable', () => { + const bid = { + params: { viewabilityContainerIdentifier: '#invalidElement' }, + adUnitCode: 'adUnitCode123', + sizes: [[300, 250]] + }; + + // Simulate an error when querying the element + querySelectorStub.withArgs('#invalidElement').throws(new Error('Query failed')); + + getBoundingClientRectStub.returns({ + left: 100, + top: 100, + right: 400, + bottom: 350, + width: 300, + height: 250 + }); + + const result = connatixDetectViewability(bid); + + expect(result).to.equal(null); + }); + }); + describe('isBidRequestValid', function () { this.beforeEach(function () { bid = mockBidRequest();