diff --git a/modules/wurflRtdProvider.js b/modules/wurflRtdProvider.js index d2e85f40ec7..beffabdb7b9 100644 --- a/modules/wurflRtdProvider.js +++ b/modules/wurflRtdProvider.js @@ -4,259 +4,1228 @@ import { loadExternalScript } from '../src/adloader.js'; import { mergeDeep, prefixLog, + debugTurnedOn, } from '../src/utils.js'; import { MODULE_TYPE_RTD } from '../src/activities/modules.js'; +import { getStorageManager } from '../src/storageManager.js'; +import { getGlobal } from '../src/prebidGlobal.js'; // Constants const REAL_TIME_MODULE = 'realTimeData'; const MODULE_NAME = 'wurfl'; +const MODULE_VERSION = '2.1.0'; // WURFL_JS_HOST is the host for the WURFL service endpoints const WURFL_JS_HOST = 'https://prebid.wurflcloud.com'; // WURFL_JS_ENDPOINT_PATH is the path for the WURFL.js endpoint used to load WURFL data const WURFL_JS_ENDPOINT_PATH = '/wurfl.js'; +// STATS_HOST is the host for the WURFL stats endpoint +const STATS_HOST = 'https://stats.prebid.wurflcloud.com' // STATS_ENDPOINT_PATH is the path for the stats endpoint used to send analytics data -const STATS_ENDPOINT_PATH = '/v1/prebid/stats'; +const STATS_ENDPOINT_PATH = '/v2/prebid/stats'; + +// Storage keys for localStorage caching +const WURFL_RTD_STORAGE_KEY = 'wurflrtd'; + +// OpenRTB 2.0 device type constants +// Based on OpenRTB 2.6 specification +const ORTB2_DEVICE_TYPE = { + MOBILE_OR_TABLET: 1, + PERSONAL_COMPUTER: 2, + CONNECTED_TV: 3, + PHONE: 4, + TABLET: 5, + CONNECTED_DEVICE: 6, + SET_TOP_BOX: 7, + OOH_DEVICE: 8 +}; + +// OpenRTB 2.0 device fields that can be enriched from WURFL data +const ORTB2_DEVICE_FIELDS = [ + 'make', 'model', 'devicetype', 'os', 'osv', 'hwv', + 'h', 'w', 'ppi', 'pxratio', 'js' +]; + +// Enrichment type constants +const ENRICHMENT_TYPE = { + UNKNOWN: 'unknown', + NONE: 'none', + LCE: 'lce', + LCE_ERROR: 'lcefailed', + WURFL_PUB: 'wurfl_pub', + WURFL_SSP: 'wurfl_ssp', + WURFL_PUB_SSP: 'wurfl_pub_ssp' +}; + +// Consent class constants +const CONSENT_CLASS = { + NO: 0, // No consent/opt-out/COPPA + PARTIAL: 1, // Partial or ambiguous + FULL: 2, // Full consent or non-GDPR region + ERROR: -1 // Error computing consent +}; + +// Default sampling rate constant +const DEFAULT_SAMPLING_RATE = 100; + +// Default over quota constant +const DEFAULT_OVER_QUOTA = 0; + +// A/B test constants +const AB_TEST = { + CONTROL_GROUP: 'control', + TREATMENT_GROUP: 'treatment', + DEFAULT_SPLIT: 0.5, + DEFAULT_NAME: 'unknown' +}; const logger = prefixLog('[WURFL RTD Submodule]'); -// enrichedBidders holds a list of prebid bidder names, of bidders which have been -// injected with WURFL data -const enrichedBidders = new Set(); +// Storage manager for WURFL RTD provider +export const storage = getStorageManager({ + moduleType: MODULE_TYPE_RTD, + moduleName: MODULE_NAME, +}); -/** - * init initializes the WURFL RTD submodule - * @param {Object} config Configuration for WURFL RTD submodule - * @param {Object} userConsent User consent data - */ -const init = (config, userConsent) => { - logger.logMessage('initialized'); - return true; -} +// bidderEnrichment maps bidder codes to their enrichment type for beacon reporting +let bidderEnrichment; -/** - * getBidRequestData enriches the OpenRTB 2.0 device data with WURFL data - * @param {Object} reqBidsConfigObj Bid request configuration object - * @param {Function} callback Called on completion - * @param {Object} config Configuration for WURFL RTD submodule - * @param {Object} userConsent User consent data - */ -const getBidRequestData = (reqBidsConfigObj, callback, config, userConsent) => { - const altHost = config.params?.altHost ?? null; - const isDebug = config.params?.debug ?? false; +// enrichmentType tracks the overall enrichment type used in the current auction +let enrichmentType; - const bidders = new Set(); - reqBidsConfigObj.adUnits.forEach(adUnit => { - adUnit.bids.forEach(bid => { - bidders.add(bid.bidder); - }); - }); +// wurflId stores the WURFL ID from device data +let wurflId; - let host = WURFL_JS_HOST; - if (altHost) { - host = altHost; - } +// samplingRate tracks the beacon sampling rate (0-100) +let samplingRate; - const url = new URL(host); - url.pathname = WURFL_JS_ENDPOINT_PATH; +// tier stores the WURFL tier from wurfl_pbjs data +let tier; - if (isDebug) { - url.searchParams.set('debug', 'true') +// overQuota stores the over_quota flag from wurfl_pbjs data (possible values: 0, 1) +let overQuota; + +// abTest stores A/B test configuration and variant (set by init) +let abTest; + +/** + * Safely gets an object from localStorage with JSON parsing + * @param {string} key The storage key + * @returns {Object|null} Parsed object or null if not found/invalid + */ +function getObjectFromStorage(key) { + if (!storage.hasLocalStorage() || !storage.localStorageIsEnabled()) { + return null; } - url.searchParams.set('mode', 'prebid') - url.searchParams.set('wurfl_id', 'true') + try { + const dataStr = storage.getDataFromLocalStorage(key); + return dataStr ? JSON.parse(dataStr) : null; + } catch (e) { + logger.logError(`Error parsing stored data for key ${key}:`, e); + return null; + } +} + +/** + * Safely sets an object to localStorage with JSON stringification + * @param {string} key The storage key + * @param {Object} data The data to store + * @returns {boolean} Success status + */ +function setObjectToStorage(key, data) { + if (!storage.hasLocalStorage() || !storage.localStorageIsEnabled()) { + return false; + } try { - loadExternalScript(url.toString(), MODULE_TYPE_RTD, MODULE_NAME, () => { - logger.logMessage('script injected'); - window.WURFLPromises.complete.then((res) => { - logger.logMessage('received data', res); - if (!res.wurfl_pbjs) { - logger.logError('invalid WURFL.js for Prebid response'); - } else { - enrichBidderRequests(reqBidsConfigObj, bidders, res); - } - callback(); - }); - }); - } catch (err) { - logger.logError(err); - callback(); + storage.setDataInLocalStorage(key, JSON.stringify(data)); + return true; + } catch (e) { + logger.logError(`Error storing data for key ${key}:`, e); + return false; } } /** - * enrichBidderRequests enriches the OpenRTB 2.0 device data with WURFL data for Business Edition + * enrichDeviceFPD enriches the global device object with device data * @param {Object} reqBidsConfigObj Bid request configuration object - * @param {Array} bidders List of bidders - * @param {Object} wjsResponse WURFL.js response + * @param {Object} deviceData Device data to enrich with */ -function enrichBidderRequests(reqBidsConfigObj, bidders, wjsResponse) { - const authBidders = wjsResponse.wurfl_pbjs?.authorized_bidders ?? {}; - const caps = wjsResponse.wurfl_pbjs?.caps ?? []; +function enrichDeviceFPD(reqBidsConfigObj, deviceData) { + if (!deviceData || !reqBidsConfigObj?.ortb2Fragments?.global) { + return; + } - bidders.forEach((bidderCode) => { - if (bidderCode in authBidders) { - // inject WURFL data - enrichedBidders.add(bidderCode); - const data = bidderData(wjsResponse.WURFL, caps, authBidders[bidderCode]); - data['enrich_device'] = true; - enrichBidderRequest(reqBidsConfigObj, bidderCode, data); + const prebidDevice = reqBidsConfigObj.ortb2Fragments.global.device || {}; + const enrichedDevice = {}; + + ORTB2_DEVICE_FIELDS.forEach(field => { + // Check if field already exists in prebid device + if (prebidDevice[field] !== undefined) { return; } - // inject WURFL low entropy data - const data = lowEntropyData(wjsResponse.WURFL, wjsResponse.wurfl_pbjs?.low_entropy_caps); - enrichBidderRequest(reqBidsConfigObj, bidderCode, data); + + // Check if deviceData has a valid value for this field + if (deviceData[field] === undefined) { + return; + } + + // Copy the field value from deviceData to enrichedDevice + enrichedDevice[field] = deviceData[field]; }); + + // Also copy ext field if present (contains ext.wurfl capabilities) + if (deviceData.ext) { + enrichedDevice.ext = deviceData.ext; + } + + // Use mergeDeep to properly merge into global device + mergeDeep(reqBidsConfigObj.ortb2Fragments.global, { device: enrichedDevice }); } /** - * bidderData returns the WURFL data for a bidder - * @param {Object} wurflData WURFL data - * @param {Array} caps Capability list - * @param {Array} filter Filter list - * @returns {Object} Bidder data + * enrichDeviceBidder enriches bidder-specific device data with WURFL data + * @param {Object} reqBidsConfigObj Bid request configuration object + * @param {Set} bidders Set of bidder codes + * @param {WurflJSDevice} wjsDevice WURFL.js device data with permissions and caps */ -export const bidderData = (wurflData, caps, filter) => { - const data = {}; - if ('wurfl_id' in wurflData) { - data['wurfl_id'] = wurflData.wurfl_id; +function enrichDeviceBidder(reqBidsConfigObj, bidders, wjsDevice) { + // Initialize bidder fragments if not present + if (!reqBidsConfigObj.ortb2Fragments.bidder) { + reqBidsConfigObj.ortb2Fragments.bidder = {}; } - caps.forEach((cap, index) => { - if (!filter.includes(index)) { + + const isOverQuota = wjsDevice._isOverQuota(); + + bidders.forEach((bidderCode) => { + const isAuthorized = wjsDevice._isAuthorized(bidderCode); + + if (!isAuthorized) { + // Over quota + unauthorized -> NO ENRICHMENT + if (isOverQuota) { + bidderEnrichment.set(bidderCode, ENRICHMENT_TYPE.NONE); + return; + } + // Under quota + unauthorized -> inherits from global no bidder enrichment + bidderEnrichment.set(bidderCode, ENRICHMENT_TYPE.WURFL_PUB); return; } - if (cap in wurflData) { - data[cap] = wurflData[cap]; + + // From here: bidder IS authorized + const bidderDevice = wjsDevice.Bidder(bidderCode); + bidderEnrichment.set(bidderCode, ENRICHMENT_TYPE.WURFL_SSP); + + // Edge case: authorized but no data (e.g., missing caps) + if (Object.keys(bidderDevice).length === 0) { + return; } + + // Authorized bidder with data to inject + const bd = reqBidsConfigObj.ortb2Fragments.bidder[bidderCode] || {}; + mergeDeep(bd, bidderDevice); + reqBidsConfigObj.ortb2Fragments.bidder[bidderCode] = bd; }); - return data; } /** - * lowEntropyData returns the WURFL low entropy data - * @param {Object} wurflData WURFL data - * @param {Array} lowEntropyCaps Low entropy capability list - * @returns {Object} Bidder data + * loadWurflJsAsync loads WURFL.js asynchronously and stores response to localStorage + * @param {Object} config Configuration for WURFL RTD submodule + * @param {Set} bidders Set of bidder codes */ -export const lowEntropyData = (wurflData, lowEntropyCaps) => { - const data = {}; - lowEntropyCaps.forEach((cap, _) => { - let value = wurflData[cap]; - if (cap === 'complete_device_name') { - value = value.replace(/Apple (iP(hone|ad|od)).*/, 'Apple iP$2'); - } - data[cap] = value; - }); - if ('model_name' in wurflData) { - data['model_name'] = wurflData.model_name.replace(/(iP(hone|ad|od)).*/, 'iP$2'); +function loadWurflJsAsync(config, bidders) { + const altHost = config.params?.altHost ?? null; + const isDebug = debugTurnedOn(); + + let host = WURFL_JS_HOST; + if (altHost) { + host = altHost; } - if ('brand_name' in wurflData) { - data['brand_name'] = wurflData.brand_name; + + const url = new URL(host); + url.pathname = WURFL_JS_ENDPOINT_PATH; + + // Start timing WURFL.js load + WurflDebugger.wurflJsLoadStart(); + + if (isDebug) { + url.searchParams.set('debug', 'true'); } - if ('wurfl_id' in wurflData) { - data['wurfl_id'] = wurflData.wurfl_id; + + url.searchParams.set('mode', 'prebid2'); + + // Add bidders list for server optimization + if (bidders && bidders.size > 0) { + url.searchParams.set('bidders', Array.from(bidders).join(',')); + } + + // Helper function to load WURFL.js script + const loadWurflJs = (scriptUrl) => { + try { + loadExternalScript(scriptUrl, MODULE_TYPE_RTD, MODULE_NAME, () => { + window.WURFLPromises.complete.then((res) => { + logger.logMessage('async WURFL.js data received', res); + if (res.wurfl_pbjs) { + // Create optimized cache object with only relevant device data + WurflDebugger.cacheWriteStart(); + const cacheData = { + WURFL: res.WURFL, + wurfl_pbjs: res.wurfl_pbjs, + expire_at: Date.now() + (res.wurfl_pbjs.ttl * 1000) + }; + setObjectToStorage(WURFL_RTD_STORAGE_KEY, cacheData); + WurflDebugger.cacheWriteStop(); + logger.logMessage('WURFL.js device cache stored to localStorage'); + } else { + logger.logError('invalid async WURFL.js for Prebid response'); + } + }).catch((err) => { + logger.logError('async WURFL.js promise error:', err); + }); + }); + } catch (err) { + logger.logError('async WURFL.js loading error:', err); + } + }; + + // Collect Client Hints if available, then load script + if (navigator?.userAgentData?.getHighEntropyValues) { + const hints = ['architecture', 'bitness', 'model', 'platformVersion', 'uaFullVersion', 'fullVersionList']; + navigator.userAgentData.getHighEntropyValues(hints) + .then(ch => { + if (ch !== null) { + url.searchParams.set('uach', JSON.stringify(ch)); + } + }) + .finally(() => { + loadWurflJs(url.toString()); + }); + } else { + // Load script immediately when Client Hints not available + loadWurflJs(url.toString()); } - return data; } + /** - * enrichBidderRequest enriches the bidder request with WURFL data - * @param {Object} reqBidsConfigObj Bid request configuration object - * @param {String} bidderCode Bidder code - * @param {Object} wurflData WURFL data + * shouldSample determines if an action should be taken based on sampling rate + * @param {number} rate Sampling rate from 0-100 (percentage) + * @returns {boolean} True if should proceed, false if should skip */ -export const enrichBidderRequest = (reqBidsConfigObj, bidderCode, wurflData) => { - const ortb2data = { - 'device': { - 'ext': {}, - }, - }; +function shouldSample(rate) { + if (rate >= 100) { + return true; + } + if (rate <= 0) { + return false; + } + const randomValue = Math.floor(Math.random() * 100); + return randomValue < rate; +} - const device = reqBidsConfigObj.ortb2Fragments.global.device; - enrichOrtb2DeviceData('make', wurflData.brand_name, device, ortb2data); - enrichOrtb2DeviceData('model', wurflData.model_name, device, ortb2data); - if (wurflData.enrich_device) { - delete wurflData.enrich_device; - enrichOrtb2DeviceData('devicetype', makeOrtb2DeviceType(wurflData), device, ortb2data); - enrichOrtb2DeviceData('os', wurflData.advertised_device_os, device, ortb2data); - enrichOrtb2DeviceData('osv', wurflData.advertised_device_os_version, device, ortb2data); - enrichOrtb2DeviceData('hwv', wurflData.model_name, device, ortb2data); - enrichOrtb2DeviceData('h', wurflData.resolution_height, device, ortb2data); - enrichOrtb2DeviceData('w', wurflData.resolution_width, device, ortb2data); - enrichOrtb2DeviceData('ppi', wurflData.pixel_density, device, ortb2data); - enrichOrtb2DeviceData('pxratio', toNumber(wurflData.density_class), device, ortb2data); - enrichOrtb2DeviceData('js', toNumber(wurflData.ajax_support_javascript), device, ortb2data); - } - ortb2data.device.ext['wurfl'] = wurflData - mergeDeep(reqBidsConfigObj.ortb2Fragments.bidder, { [bidderCode]: ortb2data }); +/** + * getABVariant determines A/B test variant assignment based on split + * @param {number} split Treatment group split from 0-1 (float, e.g., 0.5 = 50% treatment) + * @returns {string} AB_TEST.TREATMENT_GROUP or AB_TEST.CONTROL_GROUP + */ +function getABVariant(split) { + if (split >= 1) { + return AB_TEST.TREATMENT_GROUP; + } + if (split <= 0) { + return AB_TEST.CONTROL_GROUP; + } + return Math.random() < split ? AB_TEST.TREATMENT_GROUP : AB_TEST.CONTROL_GROUP; } /** - * makeOrtb2DeviceType returns the ortb2 device type based on WURFL data - * @param {Object} wurflData WURFL data - * @returns {Number} ortb2 device type - * @see https://www.scientiamobile.com/how-to-populate-iab-openrtb-device-object/ + * getConsentClass calculates the consent classification level + * @param {Object} userConsent User consent data + * @returns {number} Consent class (0, 1, or 2) */ -export function makeOrtb2DeviceType(wurflData) { - if (wurflData.is_mobile) { - if (!('is_phone' in wurflData) || !('is_tablet' in wurflData)) { - return undefined; - } - if (wurflData.is_phone || wurflData.is_tablet) { - return 1; +function getConsentClass(userConsent) { + // Default to no consent if userConsent is not provided or is an empty object + if (!userConsent || Object.keys(userConsent).length === 0) { + return CONSENT_CLASS.NO; + } + + // Check COPPA (Children's Privacy) + if (userConsent.coppa === true) { + return CONSENT_CLASS.NO; + } + + // Check USP/CCPA (US Privacy) + if (userConsent.usp && typeof userConsent.usp === 'string') { + if (userConsent.usp.substring(0, 2) === '1Y') { + return CONSENT_CLASS.NO; } - return 6; } - if (wurflData.is_full_desktop) { - return 2; + + // Check GDPR object exists + if (!userConsent.gdpr) { + return CONSENT_CLASS.FULL; // No GDPR data means not applicable + } + + // Check GDPR applicability - Note: might be in vendorData + const gdprApplies = userConsent.gdpr.gdprApplies === true || userConsent.gdpr.vendorData?.gdprApplies === true; + + if (!gdprApplies) { + return CONSENT_CLASS.FULL; + } + + // GDPR applies - evaluate purposes + const vendorData = userConsent.gdpr.vendorData; + + if (!vendorData || !vendorData.purpose) { + return CONSENT_CLASS.NO; + } + + const purposes = vendorData.purpose; + const consents = purposes.consents || {}; + const legitimateInterests = purposes.legitimateInterests || {}; + + // Count allowed purposes (7, 8, 10) + let allowedCount = 0; + + // Purpose 7: Measure ad performance + if (consents['7'] === true || legitimateInterests['7'] === true) { + allowedCount++; } - if (wurflData.is_connected_tv) { - return 3; + + // Purpose 8: Market research + if (consents['8'] === true || legitimateInterests['8'] === true) { + allowedCount++; } - if (wurflData.is_phone) { - return 4; + + // Purpose 10: Develop/improve products + if (consents['10'] === true || legitimateInterests['10'] === true) { + allowedCount++; } - if (wurflData.is_tablet) { - return 5; + + // Classify based on allowed purposes count + if (allowedCount === 0) { + return CONSENT_CLASS.NO; } - if (wurflData.is_ott) { - return 7; + if (allowedCount === 3) { + return CONSENT_CLASS.FULL; } - return undefined; + return CONSENT_CLASS.PARTIAL; +} + +// ==================== CLASSES ==================== + +// WurflDebugger object for performance tracking and debugging +const WurflDebugger = { + // Private timing start values + _moduleExecutionStart: null, + _cacheReadStart: null, + _lceDetectionStart: null, + _cacheWriteStart: null, + _wurflJsLoadStart: null, + + // Initialize WURFL debug tracking + init() { + if (!debugTurnedOn()) { + // Replace all methods (except init) with no-ops for zero overhead + Object.keys(this).forEach(key => { + if (typeof this[key] === 'function' && key !== 'init') { + this[key] = () => { }; + } + }); + return; + } + + // Full debug mode - create/reset window object for tracking + if (typeof window !== 'undefined') { + window.WurflRtdDebug = { + // Module version + version: MODULE_VERSION, + + // Prebid.js version + pbjsVersion: getGlobal().version, + + // Data source for current auction + dataSource: 'unknown', // 'cache' | 'lce' + + // Cache state + cacheExpired: false, // Whether the cache was expired when used + + // Simple timing measurements + moduleExecutionTime: null, // Total time from getBidRequestData start to callback + cacheReadTime: null, // Single cache read time (hit or miss) + lceDetectionTime: null, // LCE detection time (only if dataSource = 'lce') + cacheWriteTime: null, // Async cache write time (for future auctions) + wurflJsLoadTime: null, // Total time from WURFL.js load start to cache complete + + // The actual data used in current auction + data: { + // When dataSource = 'cache' + wurflData: null, // The cached WURFL device data + pbjsData: null, // The cached wurfl_pbjs data + + // When dataSource = 'lce' + lceDevice: null // The LCE-generated device object + }, + + // Beacon payload sent to analytics endpoint + beaconPayload: null + }; + } + }, + + // Module execution timing methods + moduleExecutionStart() { + this._moduleExecutionStart = performance.now(); + }, + + moduleExecutionStop() { + if (this._moduleExecutionStart === null) return; + const duration = performance.now() - this._moduleExecutionStart; + window.WurflRtdDebug.moduleExecutionTime = duration; + this._moduleExecutionStart = null; + }, + + // Cache read timing methods + cacheReadStart() { + this._cacheReadStart = performance.now(); + }, + + cacheReadStop() { + if (this._cacheReadStart === null) return; + const duration = performance.now() - this._cacheReadStart; + window.WurflRtdDebug.cacheReadTime = duration; + this._cacheReadStart = null; + }, + + // LCE detection timing methods + lceDetectionStart() { + this._lceDetectionStart = performance.now(); + }, + + lceDetectionStop() { + if (this._lceDetectionStart === null) return; + const duration = performance.now() - this._lceDetectionStart; + window.WurflRtdDebug.lceDetectionTime = duration; + this._lceDetectionStart = null; + }, + + // Cache write timing methods + cacheWriteStart() { + this._cacheWriteStart = performance.now(); + }, + + cacheWriteStop() { + if (this._cacheWriteStart === null) return; + const duration = performance.now() - this._cacheWriteStart; + window.WurflRtdDebug.cacheWriteTime = duration; + this._cacheWriteStart = null; + + // Calculate total WURFL.js load time (from load start to cache complete) + if (this._wurflJsLoadStart !== null) { + const totalLoadTime = performance.now() - this._wurflJsLoadStart; + window.WurflRtdDebug.wurflJsLoadTime = totalLoadTime; + this._wurflJsLoadStart = null; + } + + // Dispatch custom event when cache write data is available + if (typeof window !== 'undefined' && window.dispatchEvent) { + const event = new CustomEvent('wurflCacheWriteComplete', { + detail: { + duration: duration, + timestamp: Date.now(), + debugData: window.WurflRtdDebug + } + }); + window.dispatchEvent(event); + } + }, + + // WURFL.js load timing methods + wurflJsLoadStart() { + this._wurflJsLoadStart = performance.now(); + }, + + // Data tracking methods + setDataSource(source) { + window.WurflRtdDebug.dataSource = source; + }, + + setCacheData(wurflData, pbjsData) { + window.WurflRtdDebug.data.wurflData = wurflData; + window.WurflRtdDebug.data.pbjsData = pbjsData; + }, + + setLceData(lceDevice) { + window.WurflRtdDebug.data.lceDevice = lceDevice; + }, + + setCacheExpired(expired) { + window.WurflRtdDebug.cacheExpired = expired; + }, + + setBeaconPayload(payload) { + window.WurflRtdDebug.beaconPayload = payload; + } +}; + +// ==================== WURFL JS DEVICE MODULE ==================== +const WurflJSDevice = { + // Private properties + _wurflData: null, // WURFL data containing capability values (from window.WURFL) + _pbjsData: null, // wurfl_pbjs data with caps array and permissions (from response) + _basicCaps: null, // Cached basic capabilities (computed once) + _pubCaps: null, // Cached publisher capabilities (computed once) + _device: null, // Cached device object (computed once) + + // Constructor from WURFL.js local cache + fromCache(res) { + this._wurflData = res.WURFL || {}; + this._pbjsData = res.wurfl_pbjs || {}; + this._basicCaps = null; + this._pubCaps = null; + this._device = null; + return this; + }, + + // Private method - converts a given value to a number + _toNumber(value) { + if (value === '' || value === null) { + return undefined; + } + const num = Number(value); + return Number.isNaN(num) ? undefined : num; + }, + + // Private method - filters capabilities based on indices + _filterCaps(indexes) { + const data = {}; + const caps = this._pbjsData.caps; // Array of capability names + const wurflData = this._wurflData; // WURFL data containing capability values + + if (!indexes || !caps || !wurflData) { + return data; + } + + indexes.forEach((index) => { + const capName = caps[index]; // Get capability name by index + if (capName && capName in wurflData) { + data[capName] = wurflData[capName]; // Get value from WURFL data + } + }); + + return data; + }, + + // Private method - gets basic capabilities + _getBasicCaps() { + if (this._basicCaps !== null) { + return this._basicCaps; + } + const basicCaps = this._pbjsData.global?.basic_set?.cap_indices || []; + this._basicCaps = this._filterCaps(basicCaps); + return this._basicCaps; + }, + + // Private method - gets publisher capabilities + _getPubCaps() { + if (this._pubCaps !== null) { + return this._pubCaps; + } + const pubCaps = this._pbjsData.global?.publisher?.cap_indices || []; + this._pubCaps = this._filterCaps(pubCaps); + return this._pubCaps; + }, + + // Private method - gets bidder-specific capabilities + _getBidderCaps(bidderCode) { + const bidderCaps = this._pbjsData.bidders?.[bidderCode]?.cap_indices || []; + return this._filterCaps(bidderCaps); + }, + + // Private method - checks if bidder is authorized + _isAuthorized(bidderCode) { + return !!(this._pbjsData.bidders && bidderCode in this._pbjsData.bidders); + }, + + // Private method - checks if over quota + _isOverQuota() { + return this._pbjsData.over_quota === 1; + }, + + // Private method - returns the ortb2 device type based on WURFL data + _makeOrtb2DeviceType(wurflData) { + if (('is_ott' in wurflData) && (wurflData.is_ott)) { + return ORTB2_DEVICE_TYPE.SET_TOP_BOX; + } + if (('is_console' in wurflData) && (wurflData.is_console)) { + return ORTB2_DEVICE_TYPE.CONNECTED_DEVICE; + } + if (('physical_form_factor' in wurflData) && (wurflData.physical_form_factor === 'out_of_home_device')) { + return ORTB2_DEVICE_TYPE.OOH_DEVICE; + } + if (!('form_factor' in wurflData)) { + return undefined; + } + switch (wurflData.form_factor) { + case 'Desktop': + return ORTB2_DEVICE_TYPE.PERSONAL_COMPUTER; + case 'Smartphone': + return ORTB2_DEVICE_TYPE.PHONE; + case 'Feature Phone': + return ORTB2_DEVICE_TYPE.PHONE; + case 'Tablet': + return ORTB2_DEVICE_TYPE.TABLET; + case 'Smart-TV': + return ORTB2_DEVICE_TYPE.CONNECTED_TV; + case 'Other Non-Mobile': + return ORTB2_DEVICE_TYPE.CONNECTED_DEVICE; + case 'Other Mobile': + return ORTB2_DEVICE_TYPE.MOBILE_OR_TABLET; + default: + return undefined; + } + }, + + // Public API - returns device object for First Party Data (global) + // When under quota: returns device fields + ext.wurfl(basic+pub) + // When over quota: returns device fields only + FPD() { + if (this._device !== null) { + return this._device; + } + + const wd = this._wurflData; + if (!wd) { + this._device = {}; + return this._device; + } + + this._device = { + make: wd.brand_name, + model: wd.model_name, + devicetype: this._makeOrtb2DeviceType(wd), + os: wd.advertised_device_os, + osv: wd.advertised_device_os_version, + hwv: wd.model_name, + h: wd.resolution_height, + w: wd.resolution_width, + ppi: wd.pixel_density, + pxratio: this._toNumber(wd.density_class), + js: this._toNumber(wd.ajax_support_javascript) + }; + + const isOverQuota = this._isOverQuota(); + if (!isOverQuota) { + const basicCaps = this._getBasicCaps(); + const pubCaps = this._getPubCaps(); + this._device.ext = { + wurfl: { + ...basicCaps, + ...pubCaps + } + }; + } + + return this._device; + }, + + // Public API - returns device with bidder-specific ext data + Bidder(bidderCode) { + const isAuthorized = this._isAuthorized(bidderCode); + const isOverQuota = this._isOverQuota(); + + // When unauthorized return empty + if (!isAuthorized) { + return {}; + } + + // Start with empty device, populate only if publisher is over quota + // When over quota, we send device data to each authorized bidder individually + let fpdDevice = {}; + if (isOverQuota) { + fpdDevice = this.FPD(); + } + + if (!this._pbjsData.caps) { + return { device: fpdDevice }; + } + + // For authorized bidders: basic + pub + bidder-specific caps + const wurflData = { + ...(isOverQuota ? this._getBasicCaps() : {}), + ...this._getBidderCaps(bidderCode) + }; + + return { + device: { + ...fpdDevice, + ext: { + wurfl: wurflData + } + } + }; + } +}; +// ==================== END WURFL JS DEVICE MODULE ==================== + +// ==================== WURFL LCE DEVICE MODULE ==================== +const WurflLCEDevice = { + // Private mappings for device detection + _desktopMapping: new Map([ + ['Windows NT', 'Windows'], + ['Macintosh; Intel Mac OS X', 'macOS'], + ['Mozilla/5.0 (X11; Linux', 'Linux'], + ['X11; Ubuntu; Linux x86_64', 'Linux'], + ['Mozilla/5.0 (X11; CrOS', 'ChromeOS'], + ]), + + _tabletMapping: new Map([ + ['iPad; CPU OS ', 'iPadOS'], + ]), + + _smartphoneMapping: new Map([ + ['Android', 'Android'], + ['iPhone; CPU iPhone OS', 'iOS'], + ]), + + _smarttvMapping: new Map([ + ['Web0S', 'LG webOS'], + ['SMART-TV; Linux; Tizen', 'Tizen'], + ]), + + _ottMapping: new Map([ + ['Roku', 'Roku OS'], + ['Xbox', 'Windows'], + ['PLAYSTATION', 'PlayStation OS'], + ['PlayStation', 'PlayStation OS'], + ]), + + _makeMapping: new Map([ + ['motorola', 'Motorola'], + [' moto ', 'Motorola'], + ['Android', 'Generic'], + ['iPad', 'Apple'], + ['iPhone', 'Apple'], + ['Firefox', 'Mozilla'], + ['Edge', 'Microsoft'], + ['Chrome', 'Google'], + ]), + + _modelMapping: new Map([ + ['Android', 'Android'], + ['iPad', 'iPad'], + ['iPhone', 'iPhone'], + ['Firefox', 'Firefox'], + ['Edge', 'Edge'], + ['Chrome', 'Chrome'], + ]), + + // Private helper methods + _parseOsVersion(ua, osName) { + switch (osName) { + case 'Windows': { + const matches = ua.match(/Windows NT ([\d.]+)/); + return matches ? matches[1] : ''; + } + case 'macOS': { + const matches = ua.match(/Mac OS X ([\d_]+)/); + return matches ? matches[1].replace(/_/g, '.') : ''; + } + case 'iOS': { + // iOS 26 specific logic + const matches1 = ua.match(/iPhone; CPU iPhone OS 18[\d_]+ like Mac OS X\).+(?:Version|FBSV)\/(26[\d.]+)/); + if (matches1) { + return matches1[1]; + } + // iOS 18.x and lower + const matches2 = ua.match(/iPhone; CPU iPhone OS ([\d_]+) like Mac OS X/); + return matches2 ? matches2[1].replace(/_/g, '.') : ''; + } + case 'iPadOS': { + // iOS 26 specific logic + const matches1 = ua.match(/iPad; CPU OS 18[\d_]+ like Mac OS X\).+(?:Version|FBSV)\/(26[\d.]+)/); + if (matches1) { + return matches1[1]; + } + // iOS 18.x and lower + const matches2 = ua.match(/iPad; CPU OS ([\d_]+) like Mac OS X/); + return matches2 ? matches2[1].replace(/_/g, '.') : ''; + } + case 'Android': { + // For Android UAs with a decimal + const matches1 = ua.match(/Android ([\d.]+)/); + if (matches1) { + return matches1[1]; + } + // For Android UAs without a decimal + const matches2 = ua.match(/Android ([\d]+)/); + return matches2 ? matches2[1] : ''; + } + case 'ChromeOS': { + const matches = ua.match(/CrOS x86_64 ([\d.]+)/); + return matches ? matches[1] : ''; + } + case 'Tizen': { + const matches = ua.match(/Tizen ([\d.]+)/); + return matches ? matches[1] : ''; + } + case 'Roku OS': { + const matches = ua.match(/Roku\/DVP [\dA-Z]+ [\d.]+\/([\d.]+)/); + return matches ? matches[1] : ''; + } + case 'PlayStation OS': { + // PS4 + const matches1 = ua.match(/PlayStation \d\/([\d.]+)/); + if (matches1) { + return matches1[1]; + } + // PS3 + const matches2 = ua.match(/PLAYSTATION \d ([\d.]+)/); + return matches2 ? matches2[1] : ''; + } + case 'Linux': + case 'LG webOS': + default: + return ''; + } + }, + + _makeDeviceInfo(deviceType, osName, ua) { + return { deviceType, osName, osVersion: this._parseOsVersion(ua, osName) }; + }, + + _getDeviceInfo(ua) { + // Iterate over ottMapping + // Should remove above Desktop + for (const [osToken, osName] of this._ottMapping) { + if (ua.includes(osToken)) { + return this._makeDeviceInfo(ORTB2_DEVICE_TYPE.SET_TOP_BOX, osName, ua); + } + } + // Iterate over desktopMapping + for (const [osToken, osName] of this._desktopMapping) { + if (ua.includes(osToken)) { + return this._makeDeviceInfo(ORTB2_DEVICE_TYPE.PERSONAL_COMPUTER, osName, ua); + } + } + // Iterate over tabletMapping + for (const [osToken, osName] of this._tabletMapping) { + if (ua.includes(osToken)) { + return this._makeDeviceInfo(ORTB2_DEVICE_TYPE.TABLET, osName, ua); + } + } + // Android Tablets + if (ua.includes('Android') && !ua.includes('Mobile Safari') && ua.includes('Safari')) { + return this._makeDeviceInfo(ORTB2_DEVICE_TYPE.TABLET, 'Android', ua); + } + // Iterate over smartphoneMapping + for (const [osToken, osName] of this._smartphoneMapping) { + if (ua.includes(osToken)) { + return this._makeDeviceInfo(ORTB2_DEVICE_TYPE.PHONE, osName, ua); + } + } + // Iterate over smarttvMapping + for (const [osToken, osName] of this._smarttvMapping) { + if (ua.includes(osToken)) { + return this._makeDeviceInfo(ORTB2_DEVICE_TYPE.CONNECTED_TV, osName, ua); + } + } + return { deviceType: '', osName: '', osVersion: '' }; + }, + + _getDevicePixelRatioValue() { + if (window.devicePixelRatio) { + return window.devicePixelRatio; + } + + // Assumes window.screen exists (caller checked) + if (window.screen.deviceXDPI && window.screen.logicalXDPI && window.screen.logicalXDPI > 0) { + return window.screen.deviceXDPI / window.screen.logicalXDPI; + } + + const screenWidth = window.screen.availWidth; + const docWidth = window.document?.documentElement?.clientWidth; + + if (screenWidth && docWidth && docWidth > 0) { + return Math.round(screenWidth / docWidth); + } + + return undefined; + }, + + _getScreenWidth(pixelRatio) { + // Assumes window.screen exists (caller checked) + return Math.round(window.screen.width * pixelRatio); + }, + + _getScreenHeight(pixelRatio) { + // Assumes window.screen exists (caller checked) + return Math.round(window.screen.height * pixelRatio); + }, + + _getMake(ua) { + for (const [makeToken, brandName] of this._makeMapping) { + if (ua.includes(makeToken)) { + return brandName; + } + } + return 'Generic'; + }, + + _getModel(ua) { + for (const [modelToken, modelName] of this._modelMapping) { + if (ua.includes(modelToken)) { + return modelName; + } + } + return ''; + }, + + _getUserAgent() { + return window.navigator?.userAgent || ''; + }, + + _isRobot(useragent) { + const botTokens = ['+http', 'Googlebot', 'BingPreview', 'Yahoo! Slurp']; + for (const botToken of botTokens) { + if (useragent.includes(botToken)) { + return true; + } + } + return false; + }, + + // Public API - returns device object for First Party Data (global) + FPD() { + // Early exit - check window exists + if (typeof window === 'undefined') { + return { js: 1 }; + } + + // Check what globals are available upfront + const hasScreen = !!window.screen; + + const device = { js: 1 }; + const useragent = this._getUserAgent(); + + // Only process UA-dependent properties if we have a UA + if (useragent) { + // Get device info + const deviceInfo = this._getDeviceInfo(useragent); + if (deviceInfo.deviceType !== undefined) { + device.devicetype = deviceInfo.deviceType; + } + if (deviceInfo.osName !== undefined) { + device.os = deviceInfo.osName; + } + if (deviceInfo.osVersion !== undefined) { + device.osv = deviceInfo.osVersion; + } + + // Make/model + const make = this._getMake(useragent); + if (make !== undefined) { + device.make = make; + } + + const model = this._getModel(useragent); + if (model !== undefined) { + device.model = model; + device.hwv = model; + } + } + + // Screen-dependent properties (independent of UA) + if (hasScreen) { + const pixelRatio = this._getDevicePixelRatioValue(); + if (pixelRatio !== undefined) { + device.pxratio = pixelRatio; + + const width = this._getScreenWidth(pixelRatio); + if (width !== undefined) { + device.w = width; + } + + const height = this._getScreenHeight(pixelRatio); + if (height !== undefined) { + device.h = height; + } + } + } + + // Add ext.wurfl with is_robot detection + if (useragent) { + device.ext = { + wurfl: { + is_robot: this._isRobot(useragent) + } + }; + } + + return device; + } +}; +// ==================== END WURFL LCE DEVICE MODULE ==================== + +// ==================== EXPORTED FUNCTIONS ==================== + +/** + * init initializes the WURFL RTD submodule + * @param {Object} config Configuration for WURFL RTD submodule + * @param {Object} userConsent User consent data + */ +const init = (config, userConsent) => { + // Initialize debugger based on global debug flag + WurflDebugger.init(); + + // Initialize module state + bidderEnrichment = new Map(); + enrichmentType = ENRICHMENT_TYPE.UNKNOWN; + wurflId = ''; + samplingRate = DEFAULT_SAMPLING_RATE; + tier = ''; + overQuota = DEFAULT_OVER_QUOTA; + abTest = null; + + // A/B testing: set if enabled + const abTestEnabled = config?.params?.abTest ?? false; + if (abTestEnabled) { + const abName = config?.params?.abName ?? AB_TEST.DEFAULT_NAME; + const abSplit = config?.params?.abSplit ?? AB_TEST.DEFAULT_SPLIT; + const abVariant = getABVariant(abSplit); + abTest = { ab_name: abName, ab_variant: abVariant }; + logger.logMessage(`A/B test "${abName}": user in ${abVariant} group`); + } + + logger.logMessage('initialized', { + version: MODULE_VERSION, + abTest: abTest ? `${abTest.ab_name}:${abTest.ab_variant}` : 'disabled' + }); + return true; } /** - * enrichOrtb2DeviceData enriches the ortb2data device data with WURFL data. - * Note: it does not overrides properties set by Prebid.js - * @param {String} key the device property key - * @param {any} value the value of the device property - * @param {Object} device the ortb2 device object from Prebid.js - * @param {Object} ortb2data the ortb2 device data enrchiced with WURFL data + * getBidRequestData enriches the OpenRTB 2.0 device data with WURFL data + * @param {Object} reqBidsConfigObj Bid request configuration object + * @param {Function} callback Called on completion + * @param {Object} config Configuration for WURFL RTD submodule + * @param {Object} userConsent User consent data */ -function enrichOrtb2DeviceData(key, value, device, ortb2data) { - if (device?.[key] !== undefined) { - // value already defined by Prebid.js, do not overrides +const getBidRequestData = (reqBidsConfigObj, callback, config, userConsent) => { + // Start module execution timing + WurflDebugger.moduleExecutionStart(); + + // Extract bidders from request configuration and set default enrichment + const bidders = new Set(); + reqBidsConfigObj.adUnits.forEach(adUnit => { + adUnit.bids.forEach(bid => { + bidders.add(bid.bidder); + bidderEnrichment.set(bid.bidder, ENRICHMENT_TYPE.UNKNOWN); + }); + }); + + // A/B test: Skip enrichment for control group but allow beacon sending + if (abTest && abTest.ab_variant === AB_TEST.CONTROL_GROUP) { + logger.logMessage('A/B test control group: skipping enrichment'); + enrichmentType = ENRICHMENT_TYPE.NONE; + bidders.forEach(bidder => bidderEnrichment.set(bidder, ENRICHMENT_TYPE.NONE)); + WurflDebugger.moduleExecutionStop(); + callback(); return; } - if (value === undefined) { + + // Priority 1: Check if WURFL.js response is cached + WurflDebugger.cacheReadStart(); + const cachedWurflData = getObjectFromStorage(WURFL_RTD_STORAGE_KEY); + WurflDebugger.cacheReadStop(); + + if (cachedWurflData) { + const isExpired = cachedWurflData.expire_at && Date.now() > cachedWurflData.expire_at; + + WurflDebugger.setDataSource('cache'); + WurflDebugger.setCacheExpired(isExpired); + WurflDebugger.setCacheData(cachedWurflData.WURFL, cachedWurflData.wurfl_pbjs); + + const wjsDevice = WurflJSDevice.fromCache(cachedWurflData); + if (wjsDevice._isOverQuota()) { + enrichmentType = ENRICHMENT_TYPE.NONE; + } else { + enrichDeviceFPD(reqBidsConfigObj, wjsDevice.FPD()); + enrichmentType = ENRICHMENT_TYPE.WURFL_PUB; + } + enrichDeviceBidder(reqBidsConfigObj, bidders, wjsDevice); + + // Store WURFL ID for analytics + wurflId = cachedWurflData.WURFL?.wurfl_id || ''; + + // Store sampling rate for beacon + samplingRate = cachedWurflData.wurfl_pbjs?.sampling_rate ?? DEFAULT_SAMPLING_RATE; + + // Store tier for beacon + tier = cachedWurflData.wurfl_pbjs?.tier ?? ''; + + // Store over_quota for beacon + overQuota = cachedWurflData.wurfl_pbjs?.over_quota ?? DEFAULT_OVER_QUOTA; + + // If expired, refresh cache async + if (isExpired) { + loadWurflJsAsync(config, bidders); + } + + logger.logMessage('enrichment completed', { + type: enrichmentType, + dataSource: 'cache', + cacheExpired: isExpired, + bidders: Object.fromEntries(bidderEnrichment), + totalBidders: bidderEnrichment.size + }); + + WurflDebugger.moduleExecutionStop(); + callback(); return; } - ortb2data.device[key] = value; -} -/** - * toNumber converts a given value to a number. - * Returns `undefined` if the conversion results in `NaN`. - * @param {any} value - The value to convert to a number. - * @returns {number|undefined} The converted number, or `undefined` if the conversion fails. - */ -export function toNumber(value) { - if (value === '' || value === null) { - return undefined; + // Priority 2: return LCE data + WurflDebugger.setDataSource('lce'); + WurflDebugger.lceDetectionStart(); + + let lceDevice; + try { + lceDevice = WurflLCEDevice.FPD(); + enrichmentType = ENRICHMENT_TYPE.LCE; + } catch (e) { + logger.logError('Error generating LCE device data:', e); + lceDevice = { js: 1 }; + enrichmentType = ENRICHMENT_TYPE.LCE_ERROR; } - const num = Number(value); - return Number.isNaN(num) ? undefined : num; + + WurflDebugger.lceDetectionStop(); + WurflDebugger.setLceData(lceDevice); + enrichDeviceFPD(reqBidsConfigObj, lceDevice); + + // Set enrichment type for all bidders + bidders.forEach(bidder => bidderEnrichment.set(bidder, enrichmentType)); + + // Set default sampling rate for LCE + samplingRate = DEFAULT_SAMPLING_RATE; + + // Set default tier for LCE + tier = ''; + + // Set default over_quota for LCE + overQuota = DEFAULT_OVER_QUOTA; + + // Load WURFL.js async for future requests + loadWurflJsAsync(config, bidders); + + logger.logMessage('enrichment completed', { + type: enrichmentType, + dataSource: 'lce', + bidders: Object.fromEntries(bidderEnrichment), + totalBidders: bidderEnrichment.size + }); + + WurflDebugger.moduleExecutionStop(); + callback(); } /** @@ -266,23 +1235,130 @@ export function toNumber(value) { * @param {Object} userConsent User consent data */ function onAuctionEndEvent(auctionDetails, config, userConsent) { - const altHost = config.params?.altHost ?? null; + // Apply sampling + if (!shouldSample(samplingRate)) { + logger.logMessage(`beacon skipped due to sampling (rate: ${samplingRate}%)`); + return; + } - let host = WURFL_JS_HOST; - if (altHost) { - host = altHost; + const statsHost = config.params?.statsHost ?? null; + + let host = STATS_HOST; + if (statsHost) { + host = statsHost; } const url = new URL(host); url.pathname = STATS_ENDPOINT_PATH; - if (enrichedBidders.size === 0) { + // Calculate consent class + let consentClass; + try { + consentClass = getConsentClass(userConsent); + logger.logMessage('consent class', consentClass); + } catch (e) { + logger.logError('Error calculating consent class:', e); + consentClass = CONSENT_CLASS.ERROR; + } + + // Only send beacon if there are bids to report + if (!auctionDetails.bidsReceived || auctionDetails.bidsReceived.length === 0) { + logger.logMessage('auction completed - no bids received'); return; } - var payload = JSON.stringify({ bidders: [...enrichedBidders] }); + // Build a lookup object for winning bid request IDs + const winningBids = getGlobal().getHighestCpmBids() || []; + const winningBidIds = {}; + for (let i = 0; i < winningBids.length; i++) { + const bid = winningBids[i]; + winningBidIds[bid.requestId] = true; + } + + // Build a lookup object for bid responses: "adUnitCode:bidderCode" -> bid + const bidResponseMap = {}; + for (let i = 0; i < auctionDetails.bidsReceived.length; i++) { + const bid = auctionDetails.bidsReceived[i]; + const adUnitCode = bid.adUnitCode; + const bidderCode = bid.bidderCode || bid.bidder; + const key = adUnitCode + ':' + bidderCode; + bidResponseMap[key] = bid; + } + + // Build ad units array with all bidders (including non-responders) + const adUnits = []; + + if (auctionDetails.adUnits) { + for (let i = 0; i < auctionDetails.adUnits.length; i++) { + const adUnit = auctionDetails.adUnits[i]; + const adUnitCode = adUnit.code; + const bidders = []; + + // Check each bidder configured for this ad unit + for (let j = 0; j < adUnit.bids.length; j++) { + const bidConfig = adUnit.bids[j]; + const bidderCode = bidConfig.bidder; + const key = adUnitCode + ':' + bidderCode; + const bidResponse = bidResponseMap[key]; + + if (bidResponse) { + // Bidder responded - include full data + const isWinner = winningBidIds[bidResponse.requestId] === true; + bidders.push({ + bidder: bidderCode, + bdr_enrich: bidderEnrichment.get(bidderCode), + cpm: bidResponse.cpm, + currency: bidResponse.currency, + won: isWinner + }); + } else { + // Bidder didn't respond - include without cpm/currency + bidders.push({ + bidder: bidderCode, + bdr_enrich: bidderEnrichment.get(bidderCode), + won: false + }); + } + } + + adUnits.push({ + ad_unit_code: adUnitCode, + bidders: bidders + }); + } + } + + logger.logMessage('auction completed', { + bidsReceived: auctionDetails.bidsReceived.length, + bidsWon: winningBids.length, + adUnits: adUnits.length + }); + + // Build complete payload + const payloadData = { + version: MODULE_VERSION, + domain: typeof window !== 'undefined' ? window.location.hostname : '', + path: typeof window !== 'undefined' ? window.location.pathname : '', + sampling_rate: samplingRate, + enrichment: enrichmentType, + wurfl_id: wurflId, + tier: tier, + over_quota: overQuota, + consent_class: consentClass, + ad_units: adUnits + }; + + // Add A/B test fields if enabled + if (abTest) { + payloadData.ab_name = abTest.ab_name; + payloadData.ab_variant = abTest.ab_variant; + } + + const payload = JSON.stringify(payloadData); + const sentBeacon = sendBeacon(url.toString(), payload); if (sentBeacon) { + WurflDebugger.setBeaconPayload(JSON.parse(payload)); return; } @@ -292,8 +1368,12 @@ function onAuctionEndEvent(auctionDetails, config, userConsent) { mode: 'no-cors', keepalive: true }); + + WurflDebugger.setBeaconPayload(JSON.parse(payload)); } +// ==================== MODULE EXPORT ==================== + // The WURFL submodule export const wurflSubmodule = { name: MODULE_NAME, diff --git a/modules/wurflRtdProvider.md b/modules/wurflRtdProvider.md index abee06c02e7..551cf2f0792 100644 --- a/modules/wurflRtdProvider.md +++ b/modules/wurflRtdProvider.md @@ -13,6 +13,8 @@ The module sets the WURFL data in `device.ext.wurfl` and all the bidder adapters For a more detailed analysis bidders can subscribe to detect iPhone and iPad models and receive additional [WURFL device capabilities](https://www.scientiamobile.com/capabilities/?products%5B%5D=wurfl-js). +**Note:** This module loads a dynamically generated JavaScript from prebid.wurflcloud.com + ## User-Agent Client Hints WURFL.js is fully compatible with Chromium's User-Agent Client Hints (UA-CH) initiative. If User-Agent Client Hints are absent in the HTTP headers that WURFL.js receives, the service will automatically fall back to using the User-Agent Client Hints' JS API to fetch [high entropy client hint values](https://wicg.github.io/ua-client-hints/#getHighEntropyValues) from the client device. However, we recommend that you explicitly opt-in/advertise support for User-Agent Client Hints on your website and delegate them to the WURFL.js service for the fastest detection experience. Our documentation regarding implementing User-Agent Client Hint support [is available here](https://docs.scientiamobile.com/guides/implementing-useragent-clienthints). @@ -20,6 +22,7 @@ WURFL.js is fully compatible with Chromium's User-Agent Client Hints (UA-CH) ini ## Usage ### Build + ``` gulp build --modules="wurflRtdProvider,appnexusBidAdapter,..." ``` @@ -33,28 +36,56 @@ This module is configured as part of the `realTimeData.dataProviders` ```javascript var TIMEOUT = 1000; pbjs.setConfig({ - realTimeData: { - auctionDelay: TIMEOUT, - dataProviders: [{ - name: 'wurfl', - waitForIt: true, - params: { - debug: false - } - }] - } + realTimeData: { + auctionDelay: TIMEOUT, + dataProviders: [ + { + name: "wurfl", + }, + ], + }, }); ``` ### Parameters -| Name | Type | Description | Default | -| :------------------------ | :------------ | :--------------------------------------------------------------- |:----------------- | -| name | String | Real time data module name | Always 'wurfl' | -| waitForIt | Boolean | Should be `true` if there's an `auctionDelay` defined (optional) | `false` | -| params | Object | | | -| params.altHost | String | Alternate host to connect to WURFL.js | | -| params.debug | Boolean | Enable debug | `false` | +| Name | Type | Description | Default | +| :------------- | :------ | :--------------------------------------------------------------- | :------------- | +| name | String | Real time data module name | Always 'wurfl' | +| waitForIt | Boolean | Should be `true` if there's an `auctionDelay` defined (optional) | `false` | +| params | Object | | | +| params.altHost | String | Alternate host to connect to WURFL.js | | +| params.abTest | Boolean | Enable A/B testing mode | `false` | +| params.abName | String | A/B test name identifier | `'unknown'` | +| params.abSplit | Number | Fraction of users in treatment group (0-1) | `0.5` | + +### A/B Testing + +The WURFL RTD module supports A/B testing to measure the impact of WURFL enrichment on ad performance: + +```javascript +pbjs.setConfig({ + realTimeData: { + auctionDelay: 1000, + dataProviders: [ + { + name: "wurfl", + waitForIt: true, + params: { + abTest: true, + abName: "pub_test_sept23", + abSplit: 0.5, // 50% treatment, 50% control + }, + }, + ], + }, +}); +``` + +- **Treatment group** (`abSplit` \* 100%): Module enabled, bid requests enriched with WURFL device data +- **Control group** ((1 - `abSplit`) \* 100%): Module disabled, no enrichment occurs +- Assignment is random on each page load based on `Math.random()` +- Example: `abSplit: 0.75` means 75% get WURFL enrichment, 25% don't ## Testing diff --git a/test/spec/modules/wurflRtdProvider_spec.js b/test/spec/modules/wurflRtdProvider_spec.js index fa6ea2e642f..371d8cde95f 100644 --- a/test/spec/modules/wurflRtdProvider_spec.js +++ b/test/spec/modules/wurflRtdProvider_spec.js @@ -1,24 +1,41 @@ import { - bidderData, - enrichBidderRequest, - lowEntropyData, wurflSubmodule, - makeOrtb2DeviceType, - toNumber, + storage } from 'modules/wurflRtdProvider'; import * as ajaxModule from 'src/ajax'; import { loadExternalScriptStub } from 'test/mocks/adloaderStub.js'; +import * as prebidGlobalModule from 'src/prebidGlobal.js'; +import { guardOrtb2Fragments } from 'libraries/objectGuard/ortbGuard.js'; +import { config } from 'src/config.js'; describe('wurflRtdProvider', function () { describe('wurflSubmodule', function () { const altHost = 'http://example.local/wurfl.js'; + // Global cleanup to ensure debug config doesn't leak between tests + afterEach(function () { + config.resetConfig(); + }); + const wurfl_pbjs = { - low_entropy_caps: ['is_mobile', 'complete_device_name', 'form_factor'], - caps: ['advertised_browser', 'advertised_browser_version', 'advertised_device_os', 'advertised_device_os_version', 'ajax_support_javascript', 'brand_name', 'complete_device_name', 'density_class', 'form_factor', 'is_android', 'is_app_webview', 'is_connected_tv', 'is_full_desktop', 'is_ios', 'is_mobile', 'is_ott', 'is_phone', 'is_robot', 'is_smartphone', 'is_smarttv', 'is_tablet', 'manufacturer_name', 'marketing_name', 'max_image_height', 'max_image_width', 'model_name', 'physical_screen_height', 'physical_screen_width', 'pixel_density', 'pointing_method', 'resolution_height', 'resolution_width'], - authorized_bidders: { - bidder1: [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31], - bidder2: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 20, 21, 25, 28, 30, 31] + caps: ['wurfl_id', 'advertised_browser', 'advertised_browser_version', 'advertised_device_os', 'advertised_device_os_version', 'ajax_support_javascript', 'brand_name', 'complete_device_name', 'density_class', 'form_factor', 'is_android', 'is_app_webview', 'is_connected_tv', 'is_full_desktop', 'is_ios', 'is_mobile', 'is_ott', 'is_phone', 'is_robot', 'is_smartphone', 'is_smarttv', 'is_tablet', 'manufacturer_name', 'marketing_name', 'max_image_height', 'max_image_width', 'model_name', 'physical_screen_height', 'physical_screen_width', 'pixel_density', 'pointing_method', 'resolution_height', 'resolution_width'], + over_quota: 0, + sampling_rate: 100, + global: { + basic_set: { + cap_indices: [0, 9, 15, 16, 17, 18, 32] + }, + publisher: { + cap_indices: [1, 2, 3, 4, 5] + } + }, + bidders: { + bidder1: { + cap_indices: [6, 7, 8, 10, 11, 26, 27] + }, + bidder2: { + cap_indices: [12, 13, 14, 19, 20, 21, 22] + } } } const WURFL = { @@ -58,10 +75,12 @@ describe('wurflRtdProvider', function () { }; // expected analytics values - const expectedStatsURL = 'https://prebid.wurflcloud.com/v1/prebid/stats'; + const expectedStatsURL = 'https://stats.prebid.wurflcloud.com/v2/prebid/stats'; const expectedData = JSON.stringify({ bidders: ['bidder1', 'bidder2'] }); let sandbox; + // originalUserAgentData to restore after tests + let originalUAData; beforeEach(function () { sandbox = sinon.createSandbox(); @@ -69,12 +88,19 @@ describe('wurflRtdProvider', function () { init: new Promise(function (resolve, reject) { resolve({ WURFL, wurfl_pbjs }) }), complete: new Promise(function (resolve, reject) { resolve({ WURFL, wurfl_pbjs }) }), }; + originalUAData = window.navigator.userAgentData; + // Initialize module with clean state for each test + wurflSubmodule.init({ params: {} }); }); afterEach(() => { // Restore the original functions sandbox.restore(); window.WURFLPromises = undefined; + Object.defineProperty(window.navigator, 'userAgentData', { + value: originalUAData, + configurable: true, + }); }); // Bid request config @@ -94,396 +120,1860 @@ describe('wurflRtdProvider', function () { } }; + // Client Hints tests + describe('Client Hints support', () => { + it('should collect and send client hints when available', (done) => { + const clock = sinon.useFakeTimers(); + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + + // Mock Client Hints + const mockClientHints = { + architecture: 'arm', + bitness: '64', + model: 'Pixel 5', + platformVersion: '13.0.0', + uaFullVersion: '130.0.6723.58', + fullVersionList: [ + { brand: 'Chromium', version: '130.0.6723.58' } + ] + }; + + const getHighEntropyValuesStub = sandbox.stub().resolves(mockClientHints); + Object.defineProperty(navigator, 'userAgentData', { + value: { getHighEntropyValues: getHighEntropyValuesStub }, + configurable: true, + writable: true + }); + + // Empty cache to trigger async load + sandbox.stub(storage, 'getDataFromLocalStorage').returns(null); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + const callback = async () => { + // Verify client hints were requested + expect(getHighEntropyValuesStub.calledOnce).to.be.true; + expect(getHighEntropyValuesStub.calledWith( + ['architecture', 'bitness', 'model', 'platformVersion', 'uaFullVersion', 'fullVersionList'] + )).to.be.true; + + try { + // Use tickAsync to properly handle promise microtasks + await clock.tickAsync(1); + + // Now verify WURFL.js was loaded with client hints in URL + expect(loadExternalScriptStub.called).to.be.true; + const scriptUrl = loadExternalScriptStub.getCall(0).args[0]; + + const url = new URL(scriptUrl); + const uachParam = url.searchParams.get('uach'); + expect(uachParam).to.not.be.null; + + const parsedHints = JSON.parse(uachParam); + expect(parsedHints).to.deep.equal(mockClientHints); + + clock.restore(); + done(); + } catch (err) { + clock.restore(); + done(err); + } + }; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, { params: {} }, {}); + }) + it('should load WURFL.js without client hints when not available', (done) => { + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + + // No client hints available + Object.defineProperty(navigator, 'userAgentData', { + value: undefined, + configurable: true, + writable: true + }); + + // Empty cache to trigger async load + sandbox.stub(storage, 'getDataFromLocalStorage').returns(null); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + const callback = () => { + // Verify WURFL.js was loaded without uach parameter + expect(loadExternalScriptStub.calledOnce).to.be.true; + const scriptUrl = loadExternalScriptStub.getCall(0).args[0]; + + const url = new URL(scriptUrl); + const uachParam = url.searchParams.get('uach'); + expect(uachParam).to.be.null; + + done(); + }; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, { params: {} }, {}); + }); + }); + + // TTL handling tests + describe('TTL handling', () => { + it('should use valid (not expired) cached data without triggering async load', (done) => { + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + + // Setup cache with valid TTL (expires in future) + const futureExpiry = Date.now() + 1000000; // expires in future + const cachedData = { + WURFL, + wurfl_pbjs: { ...wurfl_pbjs, ttl: 2592000 }, + expire_at: futureExpiry + }; + sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + const callback = () => { + // Verify global FPD enrichment happened (not over quota) + expect(reqBidsConfigObj.ortb2Fragments.global.device).to.deep.include({ + make: 'Google', + model: 'Nexus 5', + devicetype: 4 + }); + + // Verify no async load was triggered (cache is valid) + expect(loadExternalScriptStub.called).to.be.false; + + done(); + }; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, { params: {} }, {}); + }); + + it('should use expired cached data and trigger async refresh (without Client Hints)', (done) => { + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + + Object.defineProperty(navigator, 'userAgentData', { + value: undefined, + configurable: true, + writable: true + }); + // Setup cache with expired TTL + const pastExpiry = Date.now() - 1000; // expired 1 second ago + const cachedData = { + WURFL, + wurfl_pbjs: { ...wurfl_pbjs, ttl: 2592000 }, + expire_at: pastExpiry + }; + sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + const callback = () => { + // Verify expired cache data is still used for enrichment + expect(reqBidsConfigObj.ortb2Fragments.global.device).to.deep.include({ + make: 'Google', + model: 'Nexus 5', + devicetype: 4 + }); + + // Verify bidders were enriched + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder1).to.exist; + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder2).to.exist; + + // Verify async load WAS triggered for refresh (cache expired) + expect(loadExternalScriptStub.calledOnce).to.be.true; + + done(); + }; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, { params: {} }, {}); + }); + }); + + // Debug mode initialization tests + describe('Debug mode', () => { + afterEach(() => { + // Clean up window object after each test + delete window.WurflRtdDebug; + // Reset global config + config.resetConfig(); + }); + + it('should not create window.WurflRtdDebug when global debug=false', () => { + config.setConfig({ debug: false }); + const moduleConfig = { params: {} }; + wurflSubmodule.init(moduleConfig); + expect(window.WurflRtdDebug).to.be.undefined; + }); + + it('should not create window.WurflRtdDebug when global debug is not configured', () => { + config.resetConfig(); + const moduleConfig = { params: {} }; + wurflSubmodule.init(moduleConfig); + expect(window.WurflRtdDebug).to.be.undefined; + }); + + it('should create window.WurflRtdDebug when global debug=true', () => { + config.setConfig({ debug: true }); + const moduleConfig = { params: {} }; + wurflSubmodule.init(moduleConfig); + expect(window.WurflRtdDebug).to.exist; + expect(window.WurflRtdDebug.dataSource).to.equal('unknown'); + expect(window.WurflRtdDebug.cacheExpired).to.be.false; + }); + }); + it('initialises the WURFL RTD provider', function () { expect(wurflSubmodule.init()).to.be.true; }); - it('should enrich the bid request data', (done) => { + describe('A/B testing', () => { + it('should return true when A/B testing is disabled', () => { + const config = { params: { abTest: false } }; + expect(wurflSubmodule.init(config)).to.be.true; + }); + + it('should return true when A/B testing is not configured', () => { + const config = { params: {} }; + expect(wurflSubmodule.init(config)).to.be.true; + }); + + it('should return true for users in treatment group (random < abSplit)', () => { + sandbox.stub(Math, 'random').returns(0.25); // 0.25 < 0.5 = treatment + const config = { params: { abTest: true, abName: 'test_sept', abSplit: 0.5 } }; + expect(wurflSubmodule.init(config)).to.be.true; + }); + + it('should return true for users in control group (random >= abSplit)', () => { + sandbox.stub(Math, 'random').returns(0.75); // 0.75 >= 0.5 = control + const config = { params: { abTest: true, abName: 'test_sept', abSplit: 0.5 } }; + expect(wurflSubmodule.init(config)).to.be.true; + }); + + it('should use default abSplit of 0.5 when not specified', () => { + sandbox.stub(Math, 'random').returns(0.40); // 0.40 < 0.5 = treatment + const config = { params: { abTest: true, abName: 'test_sept' } }; + expect(wurflSubmodule.init(config)).to.be.true; + }); + + it('should handle abSplit of 0 (all control)', () => { + sandbox.stub(Math, 'random').returns(0.01); // split <= 0 = control + const config = { params: { abTest: true, abName: 'test_sept', abSplit: 0 } }; + expect(wurflSubmodule.init(config)).to.be.true; + }); + + it('should handle abSplit of 1 (all treatment)', () => { + sandbox.stub(Math, 'random').returns(0.99); // split >= 1 = treatment + const config = { params: { abTest: true, abName: 'test_sept', abSplit: 1 } }; + expect(wurflSubmodule.init(config)).to.be.true; + }); + + it('should skip enrichment for control group in getBidRequestData', (done) => { + sandbox.stub(Math, 'random').returns(0.75); // Control group + const config = { params: { abTest: true, abName: 'test_sept', abSplit: 0.5 } }; + + // Initialize with A/B test config + wurflSubmodule.init(config); + + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + + const callback = () => { + // Control group should not enrich + expect(reqBidsConfigObj.ortb2Fragments.global.device).to.deep.equal({}); + expect(reqBidsConfigObj.ortb2Fragments.bidder).to.deep.equal({}); + done(); + }; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, config, {}); + }); + + it('should send beacon with ab_name and ab_variant for treatment group', (done) => { + sandbox.stub(Math, 'random').returns(0.25); // Treatment group + const config = { params: { abTest: true, abName: 'test_sept', abSplit: 0.5 } }; + + // Initialize with A/B test config + wurflSubmodule.init(config); + + const cachedData = { WURFL, wurfl_pbjs }; + sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + const sendBeaconStub = sandbox.stub(ajaxModule, 'sendBeacon').returns(true); + + sandbox.stub(prebidGlobalModule, 'getGlobal').returns({ + getHighestCpmBids: () => [] + }); + + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + + const callback = () => { + const auctionDetails = { + bidsReceived: [ + { requestId: 'req1', bidderCode: 'bidder1', adUnitCode: 'ad1', cpm: 1.5, currency: 'USD' } + ], + adUnits: [ + { + code: 'ad1', + bids: [{ bidder: 'bidder1' }] + } + ] + }; + + wurflSubmodule.onAuctionEndEvent(auctionDetails, config, null); + + expect(sendBeaconStub.calledOnce).to.be.true; + const beaconCall = sendBeaconStub.getCall(0); + const payload = JSON.parse(beaconCall.args[1]); + expect(payload).to.have.property('ab_name', 'test_sept'); + expect(payload).to.have.property('ab_variant', 'treatment'); + done(); + }; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, config, {}); + }); + + it('should send beacon with ab_name and ab_variant for control group', (done) => { + sandbox.stub(Math, 'random').returns(0.75); // Control group + const config = { params: { abTest: true, abName: 'test_sept', abSplit: 0.5 } }; + + // Initialize with A/B test config + wurflSubmodule.init(config); + + const sendBeaconStub = sandbox.stub(ajaxModule, 'sendBeacon').returns(true); + + sandbox.stub(prebidGlobalModule, 'getGlobal').returns({ + getHighestCpmBids: () => [] + }); + + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + + const callback = () => { + const auctionDetails = { + bidsReceived: [ + { requestId: 'req1', bidderCode: 'bidder1', adUnitCode: 'ad1', cpm: 1.5, currency: 'USD' } + ], + adUnits: [ + { + code: 'ad1', + bids: [{ bidder: 'bidder1' }] + } + ] + }; + + wurflSubmodule.onAuctionEndEvent(auctionDetails, config, null); + + expect(sendBeaconStub.calledOnce).to.be.true; + const beaconCall = sendBeaconStub.getCall(0); + const payload = JSON.parse(beaconCall.args[1]); + expect(payload).to.have.property('ab_name', 'test_sept'); + expect(payload).to.have.property('ab_variant', 'control'); + expect(payload).to.have.property('enrichment', 'none'); + done(); + }; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, config, {}); + }); + }); + + it('should enrich multiple bidders with cached WURFL data (not over quota)', (done) => { + // Reset reqBidsConfigObj to clean state + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + + // Setup localStorage with cached WURFL data + const cachedData = { WURFL, wurfl_pbjs }; + sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + const callback = () => { + // Verify global FPD has device data (not over quota) + expect(reqBidsConfigObj.ortb2Fragments.global.device).to.deep.include({ + make: 'Google', + model: 'Nexus 5', + devicetype: 4, + os: 'Android', + osv: '6.0', + hwv: 'Nexus 5', + h: 1920, + w: 1080, + ppi: 443, + pxratio: 3.0, + js: 1 + }); + + // Verify global has ext.wurfl with basic+pub capabilities (new behavior) + expect(reqBidsConfigObj.ortb2Fragments.global.device.ext).to.exist; + expect(reqBidsConfigObj.ortb2Fragments.global.device.ext.wurfl).to.exist; + + // Calculate expected basic+pub caps + const basicIndices = wurfl_pbjs.global.basic_set.cap_indices; + const pubIndices = wurfl_pbjs.global.publisher.cap_indices; + const allBasicPubIndices = [...new Set([...basicIndices, ...pubIndices])]; + const expectedBasicPubCaps = {}; + allBasicPubIndices.forEach(index => { + const capName = wurfl_pbjs.caps[index]; + if (capName && capName in WURFL) { + expectedBasicPubCaps[capName] = WURFL[capName]; + } + }); + expect(reqBidsConfigObj.ortb2Fragments.global.device.ext.wurfl).to.deep.equal(expectedBasicPubCaps); + + // Under quota, authorized bidders: should get only bidder-specific caps (delta) + const bidder1Indices = wurfl_pbjs.bidders.bidder1.cap_indices; + const expectedBidder1Caps = {}; + bidder1Indices.forEach(index => { + const capName = wurfl_pbjs.caps[index]; + if (capName && capName in WURFL) { + expectedBidder1Caps[capName] = WURFL[capName]; + } + }); + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder1.device.ext.wurfl).to.deep.equal(expectedBidder1Caps); + + const bidder2Indices = wurfl_pbjs.bidders.bidder2.cap_indices; + const expectedBidder2Caps = {}; + bidder2Indices.forEach(index => { + const capName = wurfl_pbjs.caps[index]; + if (capName && capName in WURFL) { + expectedBidder2Caps[capName] = WURFL[capName]; + } + }); + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder2.device.ext.wurfl).to.deep.equal(expectedBidder2Caps); + + // bidder3 is NOT authorized, should get empty object (inherits from global) + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder3).to.not.exist; + + done(); + }; + + const config = { params: {} }; + const userConsent = {}; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, config, userConsent); + }); + + it('should use LCE data when cache is empty and load WURFL.js async', (done) => { + // Reset reqBidsConfigObj to clean state + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + + // Setup empty cache + sandbox.stub(storage, 'getDataFromLocalStorage').returns(null); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + // Set global debug flag + config.setConfig({ debug: true }); + const expectedURL = new URL(altHost); - expectedURL.searchParams.set('debug', true); - expectedURL.searchParams.set('mode', 'prebid'); - expectedURL.searchParams.set('wurfl_id', true); + expectedURL.searchParams.set('debug', 'true'); + expectedURL.searchParams.set('mode', 'prebid2'); + expectedURL.searchParams.set('bidders', 'bidder1,bidder2,bidder3'); const callback = () => { - const v = { - bidder1: { - device: { - make: 'Google', - model: 'Nexus 5', - devicetype: 1, - os: 'Android', - osv: '6.0', - hwv: 'Nexus 5', - h: 1920, - w: 1080, - ppi: 443, - pxratio: 3.0, - js: 1, - ext: { - wurfl: { - advertised_browser: 'Chrome Mobile', - advertised_browser_version: '130.0.0.0', - advertised_device_os: 'Android', - advertised_device_os_version: '6.0', - ajax_support_javascript: !0, - brand_name: 'Google', - complete_device_name: 'Google Nexus 5', - density_class: '3.0', - form_factor: 'Feature Phone', - is_app_webview: !1, - is_connected_tv: !1, - is_full_desktop: !1, - is_mobile: !0, - is_ott: !1, - is_phone: !0, - is_robot: !1, - is_smartphone: !1, - is_smarttv: !1, - is_tablet: !1, - manufacturer_name: 'LG', - marketing_name: '', - max_image_height: 640, - max_image_width: 360, - model_name: 'Nexus 5', - physical_screen_height: 110, - physical_screen_width: 62, - pixel_density: 443, - pointing_method: 'touchscreen', - resolution_height: 1920, - resolution_width: 1080, - wurfl_id: 'lg_nexus5_ver1', - }, - }, - }, - }, - bidder2: { - device: { - make: 'Google', - model: 'Nexus 5', - devicetype: 1, - os: 'Android', - osv: '6.0', - hwv: 'Nexus 5', - h: 1920, - w: 1080, - ppi: 443, - pxratio: 3.0, - js: 1, - ext: { - wurfl: { - advertised_device_os: 'Android', - advertised_device_os_version: '6.0', - ajax_support_javascript: !0, - brand_name: 'Google', - complete_device_name: 'Google Nexus 5', - density_class: '3.0', - form_factor: 'Feature Phone', - is_android: !0, - is_app_webview: !1, - is_connected_tv: !1, - is_full_desktop: !1, - is_ios: !1, - is_mobile: !0, - is_ott: !1, - is_phone: !0, - is_tablet: !1, - manufacturer_name: 'LG', - model_name: 'Nexus 5', - pixel_density: 443, - resolution_height: 1920, - resolution_width: 1080, - wurfl_id: 'lg_nexus5_ver1', - }, - }, - }, - }, - bidder3: { - device: { - make: 'Google', - model: 'Nexus 5', - ext: { - wurfl: { - complete_device_name: 'Google Nexus 5', - form_factor: 'Feature Phone', - is_mobile: !0, - model_name: 'Nexus 5', - brand_name: 'Google', - wurfl_id: 'lg_nexus5_ver1', - }, - }, - }, - }, - }; - expect(reqBidsConfigObj.ortb2Fragments.bidder).to.deep.equal(v); + // Verify global FPD has LCE device data + expect(reqBidsConfigObj.ortb2Fragments.global.device).to.exist; + expect(reqBidsConfigObj.ortb2Fragments.global.device.js).to.equal(1); + + // Verify ext.wurfl.is_robot is set + expect(reqBidsConfigObj.ortb2Fragments.global.device.ext).to.exist; + expect(reqBidsConfigObj.ortb2Fragments.global.device.ext.wurfl).to.exist; + expect(reqBidsConfigObj.ortb2Fragments.global.device.ext.wurfl.is_robot).to.be.false; + + // No bidder enrichment should occur without cached WURFL data + expect(reqBidsConfigObj.ortb2Fragments.bidder).to.deep.equal({}); + done(); }; - const config = { + const moduleConfig = { params: { altHost: altHost, - debug: true, } }; const userConsent = {}; - wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, config, userConsent); + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, moduleConfig, userConsent); + + // Verify WURFL.js is loaded async for future requests expect(loadExternalScriptStub.calledOnce).to.be.true; const loadExternalScriptCall = loadExternalScriptStub.getCall(0); expect(loadExternalScriptCall.args[0]).to.equal(expectedURL.toString()); expect(loadExternalScriptCall.args[2]).to.equal('wurfl'); }); - it('onAuctionEndEvent: should send analytics data using navigator.sendBeacon, if available', () => { - const auctionDetails = {}; - const config = {}; - const userConsent = {}; + describe('LCE bot detection', () => { + let originalUserAgent; - const sendBeaconStub = sandbox.stub(navigator, 'sendBeacon'); + beforeEach(() => { + // Setup empty cache to trigger LCE + sandbox.stub(storage, 'getDataFromLocalStorage').returns(null); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); - // Call the function - wurflSubmodule.onAuctionEndEvent(auctionDetails, config, userConsent); + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; - // Assertions - expect(sendBeaconStub.calledOnce).to.be.true; - expect(sendBeaconStub.calledWithExactly(expectedStatsURL, expectedData)).to.be.true; - }); + // Save original userAgent + originalUserAgent = navigator.userAgent; + }); - it('onAuctionEndEvent: should send analytics data using fetch as fallback, if navigator.sendBeacon is not available', () => { - const auctionDetails = {}; - const config = {}; - const userConsent = {}; + afterEach(() => { + // Restore original userAgent + Object.defineProperty(navigator, 'userAgent', { + value: originalUserAgent, + configurable: true, + writable: true + }); + }); - const sendBeaconStub = sandbox.stub(navigator, 'sendBeacon').value(undefined); - const windowFetchStub = sandbox.stub(window, 'fetch'); - const fetchAjaxStub = sandbox.stub(ajaxModule, 'fetch'); + it('should detect Googlebot and set is_robot to true', (done) => { + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', + configurable: true, + writable: true + }); + + const callback = () => { + expect(reqBidsConfigObj.ortb2Fragments.global.device.ext).to.exist; + expect(reqBidsConfigObj.ortb2Fragments.global.device.ext.wurfl).to.exist; + expect(reqBidsConfigObj.ortb2Fragments.global.device.ext.wurfl.is_robot).to.be.true; + done(); + }; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, { params: {} }, {}); + }); + + it('should detect BingPreview and set is_robot to true', (done) => { + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/534+ (KHTML, like Gecko) BingPreview/1.0b', + configurable: true, + writable: true + }); + + const callback = () => { + expect(reqBidsConfigObj.ortb2Fragments.global.device.ext.wurfl.is_robot).to.be.true; + done(); + }; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, { params: {} }, {}); + }); + + it('should detect Yahoo! Slurp and set is_robot to true', (done) => { + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (compatible; Yahoo! Slurp; http://help.yahoo.com/help/us/ysearch/slurp)', + configurable: true, + writable: true + }); + + const callback = () => { + expect(reqBidsConfigObj.ortb2Fragments.global.device.ext.wurfl.is_robot).to.be.true; + done(); + }; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, { params: {} }, {}); + }); + + it('should detect +http bot token and set is_robot to true', (done) => { + Object.defineProperty(navigator, 'userAgent', { + value: 'SomeBot/1.0 (+http://example.com/bot)', + configurable: true, + writable: true + }); + + const callback = () => { + expect(reqBidsConfigObj.ortb2Fragments.global.device.ext.wurfl.is_robot).to.be.true; + done(); + }; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, { params: {} }, {}); + }); - // Call the function - wurflSubmodule.onAuctionEndEvent(auctionDetails, config, userConsent); + it('should set is_robot to false for regular Chrome user agent', (done) => { + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36', + configurable: true, + writable: true + }); - // Assertions - expect(sendBeaconStub.called).to.be.false; + const callback = () => { + expect(reqBidsConfigObj.ortb2Fragments.global.device.ext.wurfl.is_robot).to.be.false; + done(); + }; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, { params: {} }, {}); + }); + + it('should set is_robot to false for regular mobile Safari user agent', (done) => { + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', + configurable: true, + writable: true + }); - expect(fetchAjaxStub.calledOnce).to.be.true; - const fetchAjaxCall = fetchAjaxStub.getCall(0); - expect(fetchAjaxCall.args[0]).to.equal(expectedStatsURL); - expect(fetchAjaxCall.args[1].method).to.equal('POST'); - expect(fetchAjaxCall.args[1].body).to.equal(expectedData); - expect(fetchAjaxCall.args[1].mode).to.equal('no-cors'); + const callback = () => { + expect(reqBidsConfigObj.ortb2Fragments.global.device.ext.wurfl.is_robot).to.be.false; + done(); + }; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, { params: {} }, {}); + }); }); - }); - describe('bidderData', () => { - it('should return the WURFL data for a bidder', () => { - const wjsData = { - capability1: 'value1', - capability2: 'value2', - capability3: 'value3', + it('should enrich only bidders when over quota', (done) => { + // Reset reqBidsConfigObj to clean state + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + + // Setup localStorage with cached WURFL data (over quota) + const wurfl_pbjs_over_quota = { + ...wurfl_pbjs, + over_quota: 1 }; - const caps = ['capability1', 'capability2', 'capability3']; - const filter = [0, 2]; + const cachedData = { WURFL, wurfl_pbjs: wurfl_pbjs_over_quota }; + sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); - const result = bidderData(wjsData, caps, filter); + const callback = () => { + // Verify global FPD does NOT have device data (over quota) + expect(reqBidsConfigObj.ortb2Fragments.global.device).to.deep.equal({}); - expect(result).to.deep.equal({ - capability1: 'value1', - capability3: 'value3', - }); + // Over quota, authorized bidders: should get basic + bidder-specific (NO pub) + // bidder1 should get device fields + ext.wurfl with basic + bidder1-specific + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder1.device).to.deep.include({ + make: 'Google', + model: 'Nexus 5', + devicetype: 4, + os: 'Android', + osv: '6.0', + hwv: 'Nexus 5', + h: 1920, + w: 1080, + ppi: 443, + pxratio: 3.0, + js: 1 + }); + const basicIndices = wurfl_pbjs_over_quota.global.basic_set.cap_indices; + const bidder1Indices = wurfl_pbjs_over_quota.bidders.bidder1.cap_indices; + const allBidder1Indices = [...new Set([...basicIndices, ...bidder1Indices])]; + const expectedBidder1AllCaps = {}; + allBidder1Indices.forEach(index => { + const capName = wurfl_pbjs_over_quota.caps[index]; + if (capName && capName in WURFL) { + expectedBidder1AllCaps[capName] = WURFL[capName]; + } + }); + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder1.device.ext.wurfl).to.deep.equal(expectedBidder1AllCaps); + + // bidder2 should get device fields + ext.wurfl with basic + bidder2-specific + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder2.device).to.deep.include({ + make: 'Google', + model: 'Nexus 5', + devicetype: 4, + os: 'Android', + osv: '6.0', + hwv: 'Nexus 5', + h: 1920, + w: 1080, + ppi: 443, + pxratio: 3.0, + js: 1 + }); + const bidder2Indices = wurfl_pbjs_over_quota.bidders.bidder2.cap_indices; + const allBidder2Indices = [...new Set([...basicIndices, ...bidder2Indices])]; + const expectedBidder2AllCaps = {}; + allBidder2Indices.forEach(index => { + const capName = wurfl_pbjs_over_quota.caps[index]; + if (capName && capName in WURFL) { + expectedBidder2AllCaps[capName] = WURFL[capName]; + } + }); + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder2.device.ext.wurfl).to.deep.equal(expectedBidder2AllCaps); + + // bidder3 is NOT authorized, should get nothing + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder3).to.be.undefined; + + done(); + }; + + const config = { params: {} }; + const userConsent = {}; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, config, userConsent); }); - it('should return an empty object if the filter is empty', () => { - const wjsData = { - capability1: 'value1', - capability2: 'value2', - capability3: 'value3', + it('should initialize ortb2Fragments.bidder when undefined and enrich authorized bidders (over quota)', (done) => { + // Test the fix for ortb2Fragments.bidder being undefined + reqBidsConfigObj.ortb2Fragments.global.device = {}; + // Explicitly set bidder to undefined to simulate the race condition + reqBidsConfigObj.ortb2Fragments.bidder = undefined; + + // Setup localStorage with cached WURFL data (over quota) + const wurfl_pbjs_over_quota = { + ...wurfl_pbjs, + over_quota: 1 }; - const caps = ['capability1', 'capability3']; - const filter = []; + const cachedData = { WURFL, wurfl_pbjs: wurfl_pbjs_over_quota }; + sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); - const result = bidderData(wjsData, caps, filter); + const callback = () => { + // Verify ortb2Fragments.bidder was properly initialized + expect(reqBidsConfigObj.ortb2Fragments.bidder).to.exist; + expect(reqBidsConfigObj.ortb2Fragments.bidder).to.be.an('object'); + + // Verify global FPD does NOT have device data (over quota) + expect(reqBidsConfigObj.ortb2Fragments.global.device).to.deep.equal({}); + + // Over quota, authorized bidders: should get basic + pub + bidder-specific caps (ALL) + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder1).to.exist; + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder1.device).to.deep.include({ + make: 'Google', + model: 'Nexus 5', + devicetype: 4, + os: 'Android', + osv: '6.0', + hwv: 'Nexus 5', + h: 1920, + w: 1080, + ppi: 443, + pxratio: 3.0, + js: 1 + }); + const basicIndices = wurfl_pbjs_over_quota.global.basic_set.cap_indices; + const bidder1Indices = wurfl_pbjs_over_quota.bidders.bidder1.cap_indices; + const allBidder1Indices = [...new Set([...basicIndices, ...bidder1Indices])]; + const expectedBidder1AllCaps = {}; + allBidder1Indices.forEach(index => { + const capName = wurfl_pbjs_over_quota.caps[index]; + if (capName && capName in WURFL) { + expectedBidder1AllCaps[capName] = WURFL[capName]; + } + }); + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder1.device.ext.wurfl).to.deep.equal(expectedBidder1AllCaps); + + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder2).to.exist; + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder2.device).to.deep.include({ + make: 'Google', + model: 'Nexus 5', + devicetype: 4 + }); + const bidder2Indices = wurfl_pbjs_over_quota.bidders.bidder2.cap_indices; + const allBidder2Indices = [...new Set([...basicIndices, ...bidder2Indices])]; + const expectedBidder2AllCaps = {}; + allBidder2Indices.forEach(index => { + const capName = wurfl_pbjs_over_quota.caps[index]; + if (capName && capName in WURFL) { + expectedBidder2AllCaps[capName] = WURFL[capName]; + } + }); + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder2.device.ext.wurfl).to.deep.equal(expectedBidder2AllCaps); + + // bidder3 is NOT authorized, should get nothing + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder3).to.be.undefined; + + done(); + }; + + const config = { params: {} }; + const userConsent = {}; - expect(result).to.deep.equal({}); + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, config, userConsent); }); - }); - describe('lowEntropyData', () => { - it('should return the correct low entropy data for Apple devices', () => { - const wjsData = { - complete_device_name: 'Apple iPhone X', - form_factor: 'Smartphone', - is_mobile: !0, - brand_name: 'Apple', - model_name: 'iPhone X', + it('should work with guardOrtb2Fragments Proxy (Prebid 10.x compatibility)', (done) => { + // Simulate Prebid 10.x where rtdModule wraps ortb2Fragments with guardOrtb2Fragments + const plainFragments = { + global: { device: {} }, + bidder: {} }; - const lowEntropyCaps = ['complete_device_name', 'form_factor', 'is_mobile']; - const expectedData = { - complete_device_name: 'Apple iPhone', - form_factor: 'Smartphone', - is_mobile: !0, - brand_name: 'Apple', - model_name: 'iPhone', + + const plainReqBidsConfigObj = { + adUnits: [{ + bids: [ + { bidder: 'bidder1' }, + { bidder: 'bidder2' } + ] + }], + ortb2Fragments: plainFragments }; - const result = lowEntropyData(wjsData, lowEntropyCaps); - expect(result).to.deep.equal(expectedData); + + // Setup localStorage with cached WURFL data (over quota) + const wurfl_pbjs_over_quota = { + ...wurfl_pbjs, + over_quota: 1 + }; + const cachedData = { WURFL, wurfl_pbjs: wurfl_pbjs_over_quota }; + sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + // Wrap with guard (like rtdModule does in production) + const guardedFragments = guardOrtb2Fragments(plainFragments, {}); + const guardedReqBidsConfigObj = { ...plainReqBidsConfigObj, ortb2Fragments: guardedFragments }; + + const callback = () => { + // Over quota, authorized bidders: should get basic + pub + bidder-specific caps (ALL) + expect(plainFragments.bidder.bidder1).to.exist; + expect(plainFragments.bidder.bidder1.device).to.exist; + expect(plainFragments.bidder.bidder1.device.ext).to.exist; + + const basicIndices = wurfl_pbjs_over_quota.global.basic_set.cap_indices; + const bidder1Indices = wurfl_pbjs_over_quota.bidders.bidder1.cap_indices; + const allBidder1Indices = [...new Set([...basicIndices, ...bidder1Indices])]; + const expectedBidder1AllCaps = {}; + allBidder1Indices.forEach(index => { + const capName = wurfl_pbjs_over_quota.caps[index]; + if (capName && capName in WURFL) { + expectedBidder1AllCaps[capName] = WURFL[capName]; + } + }); + expect(plainFragments.bidder.bidder1.device.ext.wurfl).to.deep.equal(expectedBidder1AllCaps); + + // Verify FPD is present + expect(plainFragments.bidder.bidder1.device).to.deep.include({ + make: 'Google', + model: 'Nexus 5', + devicetype: 4, + os: 'Android', + osv: '6.0' + }); + + // Verify bidder2 (authorized) also got enriched + expect(plainFragments.bidder.bidder2).to.exist; + const bidder2Indices = wurfl_pbjs_over_quota.bidders.bidder2.cap_indices; + const allBidder2Indices = [...new Set([...basicIndices, ...bidder2Indices])]; + const expectedBidder2AllCaps = {}; + allBidder2Indices.forEach(index => { + const capName = wurfl_pbjs_over_quota.caps[index]; + if (capName && capName in WURFL) { + expectedBidder2AllCaps[capName] = WURFL[capName]; + } + }); + expect(plainFragments.bidder.bidder2.device.ext.wurfl).to.deep.equal(expectedBidder2AllCaps); + + done(); + }; + + const config = { params: {} }; + const userConsent = {}; + + wurflSubmodule.getBidRequestData(guardedReqBidsConfigObj, callback, config, userConsent); }); - it('should return the correct low entropy data for Android devices', () => { - const wjsData = { - complete_device_name: 'Samsung SM-G981B (Galaxy S20 5G)', - form_factor: 'Smartphone', - is_mobile: !0, + it('should pass basic+pub caps via global and authorized bidders get full caps when under quota', (done) => { + // Reset reqBidsConfigObj to clean state + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + + // Setup localStorage with cached WURFL data (NOT over quota) + const cachedData = { WURFL, wurfl_pbjs }; + sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + const callback = () => { + // Verify global FPD has device data (not over quota) + expect(reqBidsConfigObj.ortb2Fragments.global.device).to.deep.include({ + make: 'Google', + model: 'Nexus 5', + devicetype: 4 + }); + + // Calculate expected caps for basic + pub (no bidder-specific) + const basicIndices = wurfl_pbjs.global.basic_set.cap_indices; + const pubIndices = wurfl_pbjs.global.publisher.cap_indices; + const allBasicPubIndices = [...new Set([...basicIndices, ...pubIndices])]; + + const expectedBasicPubCaps = {}; + allBasicPubIndices.forEach(index => { + const capName = wurfl_pbjs.caps[index]; + if (capName && capName in WURFL) { + expectedBasicPubCaps[capName] = WURFL[capName]; + } + }); + + // Verify global has ext.wurfl with basic+pub caps (new behavior) + expect(reqBidsConfigObj.ortb2Fragments.global.device.ext).to.exist; + expect(reqBidsConfigObj.ortb2Fragments.global.device.ext.wurfl).to.deep.equal(expectedBasicPubCaps); + + // Under quota, authorized bidders: should get only bidder-specific caps (delta) + const bidder1Indices = wurfl_pbjs.bidders.bidder1.cap_indices; + const expectedBidder1Caps = {}; + bidder1Indices.forEach(index => { + const capName = wurfl_pbjs.caps[index]; + if (capName && capName in WURFL) { + expectedBidder1Caps[capName] = WURFL[capName]; + } + }); + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder1.device.ext.wurfl).to.deep.equal(expectedBidder1Caps); + + const bidder2Indices = wurfl_pbjs.bidders.bidder2.cap_indices; + const expectedBidder2Caps = {}; + bidder2Indices.forEach(index => { + const capName = wurfl_pbjs.caps[index]; + if (capName && capName in WURFL) { + expectedBidder2Caps[capName] = WURFL[capName]; + } + }); + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder2.device.ext.wurfl).to.deep.equal(expectedBidder2Caps); + + // bidder3 is NOT authorized, should get NOTHING (inherits from global.device.ext.wurfl) + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder3).to.not.exist; + + // Verify the caps calculation: basic+pub union in global + const globalCapCount = Object.keys(reqBidsConfigObj.ortb2Fragments.global.device.ext.wurfl).length; + expect(globalCapCount).to.equal(allBasicPubIndices.length); + + done(); }; - const lowEntropyCaps = ['complete_device_name', 'form_factor', 'is_mobile']; - const expectedData = { - complete_device_name: 'Samsung SM-G981B (Galaxy S20 5G)', - form_factor: 'Smartphone', - is_mobile: !0, + + const config = { params: {} }; + const userConsent = {}; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, config, userConsent); + }); + + it('should enrich global.device.ext.wurfl when under quota (verifies GlobalExt)', (done) => { + // This test verifies that GlobalExt() is called and global enrichment works + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + + const cachedData = { WURFL, wurfl_pbjs }; + sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + const callback = () => { + // Calculate expected basic+pub caps + const basicIndices = wurfl_pbjs.global.basic_set.cap_indices; + const pubIndices = wurfl_pbjs.global.publisher.cap_indices; + const allBasicPubIndices = [...new Set([...basicIndices, ...pubIndices])]; + const expectedBasicPubCaps = {}; + allBasicPubIndices.forEach(index => { + const capName = wurfl_pbjs.caps[index]; + if (capName && capName in WURFL) { + expectedBasicPubCaps[capName] = WURFL[capName]; + } + }); + + // Verify GlobalExt() populated global.device.ext.wurfl with basic+pub + expect(reqBidsConfigObj.ortb2Fragments.global.device.ext).to.exist; + expect(reqBidsConfigObj.ortb2Fragments.global.device.ext.wurfl).to.deep.equal(expectedBasicPubCaps); + + done(); + }; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, { params: {} }, {}); + }); + + it('onAuctionEndEvent: should send analytics data using navigator.sendBeacon, if available', (done) => { + // Reset reqBidsConfigObj to clean state + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + + // Setup localStorage with cached WURFL data to populate enrichedBidders + const cachedData = { WURFL, wurfl_pbjs }; + sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + const sendBeaconStub = sandbox.stub(navigator, 'sendBeacon').returns(true); + + // Mock getGlobal().getHighestCpmBids() + const mockHighestCpmBids = [ + { requestId: 'req1', bidderCode: 'bidder1', adUnitCode: 'ad1' } + ]; + sandbox.stub(prebidGlobalModule, 'getGlobal').returns({ + getHighestCpmBids: () => mockHighestCpmBids + }); + + const callback = () => { + // Build auctionDetails with bidsReceived and adUnits + const auctionDetails = { + bidsReceived: [ + { requestId: 'req1', bidderCode: 'bidder1', adUnitCode: 'ad1', cpm: 1.5, currency: 'USD' }, + { requestId: 'req2', bidderCode: 'bidder2', adUnitCode: 'ad1', cpm: 1.2, currency: 'USD' } + ], + adUnits: [ + { + code: 'ad1', + bids: [ + { bidder: 'bidder1' }, + { bidder: 'bidder2' } + ] + } + ] + }; + const config = { params: {} }; + const userConsent = {}; + + wurflSubmodule.onAuctionEndEvent(auctionDetails, config, userConsent); + + // Assertions + expect(sendBeaconStub.calledOnce).to.be.true; + const beaconCall = sendBeaconStub.getCall(0); + expect(beaconCall.args[0]).to.equal(expectedStatsURL); + + // Parse and verify payload structure + const payload = JSON.parse(beaconCall.args[1]); + expect(payload).to.have.property('version'); + expect(payload).to.have.property('domain'); + expect(payload).to.have.property('path'); + expect(payload).to.have.property('sampling_rate', 100); + expect(payload).to.have.property('enrichment', 'wurfl_pub'); + expect(payload).to.have.property('wurfl_id', 'lg_nexus5_ver1'); + expect(payload).to.have.property('over_quota', 0); + expect(payload).to.have.property('consent_class', 0); + expect(payload).to.have.property('ad_units'); + expect(payload.ad_units).to.be.an('array').with.lengthOf(1); + expect(payload.ad_units[0].ad_unit_code).to.equal('ad1'); + expect(payload.ad_units[0].bidders).to.be.an('array').with.lengthOf(2); + expect(payload.ad_units[0].bidders[0]).to.deep.include({ + bidder: 'bidder1', + bdr_enrich: 'wurfl_ssp', + cpm: 1.5, + currency: 'USD', + won: true + }); + expect(payload.ad_units[0].bidders[1]).to.deep.include({ + bidder: 'bidder2', + bdr_enrich: 'wurfl_ssp', + cpm: 1.2, + currency: 'USD', + won: false + }); + + done(); }; - const result = lowEntropyData(wjsData, lowEntropyCaps); - expect(result).to.deep.equal(expectedData); + + const config = { params: {} }; + const userConsent = {}; + + // First enrich bidders to populate enrichedBidders Set + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, config, userConsent); }); - it('should return an empty object if the lowEntropyCaps array is empty', () => { - const wjsData = { - complete_device_name: 'Samsung SM-G981B (Galaxy S20 5G)', - form_factor: 'Smartphone', - is_mobile: !0, + it('onAuctionEndEvent: should send analytics data using fetch as fallback, if navigator.sendBeacon is not available', (done) => { + // Reset reqBidsConfigObj to clean state + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + + // Setup localStorage with cached WURFL data to populate enrichedBidders + const cachedData = { WURFL, wurfl_pbjs }; + sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + const sendBeaconStub = sandbox.stub(ajaxModule, 'sendBeacon').returns(false); + const fetchAjaxStub = sandbox.stub(ajaxModule, 'fetch'); + + // Mock getGlobal().getHighestCpmBids() + const mockHighestCpmBids = [ + { requestId: 'req1', bidderCode: 'bidder1', adUnitCode: 'ad1' } + ]; + sandbox.stub(prebidGlobalModule, 'getGlobal').returns({ + getHighestCpmBids: () => mockHighestCpmBids + }); + + const callback = () => { + // Build auctionDetails with bidsReceived and adUnits + const auctionDetails = { + bidsReceived: [ + { requestId: 'req1', bidderCode: 'bidder1', adUnitCode: 'ad1', cpm: 1.5, currency: 'USD' }, + { requestId: 'req2', bidderCode: 'bidder2', adUnitCode: 'ad1', cpm: 1.2, currency: 'USD' } + ], + adUnits: [ + { + code: 'ad1', + bids: [ + { bidder: 'bidder1' }, + { bidder: 'bidder2' } + ] + } + ] + }; + const config = { params: {} }; + const userConsent = {}; + + wurflSubmodule.onAuctionEndEvent(auctionDetails, config, userConsent); + + // Assertions + expect(sendBeaconStub.calledOnce).to.be.true; + + expect(fetchAjaxStub.calledOnce).to.be.true; + const fetchAjaxCall = fetchAjaxStub.getCall(0); + expect(fetchAjaxCall.args[0]).to.equal(expectedStatsURL); + expect(fetchAjaxCall.args[1].method).to.equal('POST'); + expect(fetchAjaxCall.args[1].mode).to.equal('no-cors'); + + // Parse and verify payload structure + const payload = JSON.parse(fetchAjaxCall.args[1].body); + expect(payload).to.have.property('domain'); + expect(payload).to.have.property('path'); + expect(payload).to.have.property('sampling_rate', 100); + expect(payload).to.have.property('enrichment', 'wurfl_pub'); + expect(payload).to.have.property('wurfl_id', 'lg_nexus5_ver1'); + expect(payload).to.have.property('over_quota', 0); + expect(payload).to.have.property('consent_class', 0); + expect(payload).to.have.property('ad_units'); + expect(payload.ad_units).to.be.an('array').with.lengthOf(1); + + done(); }; - const lowEntropyCaps = []; - const expectedData = {}; - const result = lowEntropyData(wjsData, lowEntropyCaps); - expect(result).to.deep.equal(expectedData); + + const config = { params: {} }; + const userConsent = {}; + + // First enrich bidders to populate enrichedBidders Set + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, config, userConsent); }); - }); - describe('enrichBidderRequest', () => { - it('should enrich the bidder request with WURFL data', () => { - const reqBidsConfigObj = { - ortb2Fragments: { - global: { - device: {}, - }, - bidder: { - exampleBidder: { - device: { - ua: 'user-agent', + describe('consent classification', () => { + beforeEach(function () { + // Setup localStorage with cached WURFL data + const cachedData = { WURFL, wurfl_pbjs }; + sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + // Mock getGlobal().getHighestCpmBids() + sandbox.stub(prebidGlobalModule, 'getGlobal').returns({ + getHighestCpmBids: () => [] + }); + + // Reset reqBidsConfigObj + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + }); + + const testConsentClass = (description, userConsent, expectedClass, done) => { + const sendBeaconStub = sandbox.stub(ajaxModule, 'sendBeacon').returns(true); + + const callback = () => { + const auctionDetails = { + bidsReceived: [ + { requestId: 'req1', bidderCode: 'bidder1', adUnitCode: 'ad1', cpm: 1.5, currency: 'USD' } + ], + adUnits: [ + { + code: 'ad1', + bids: [{ bidder: 'bidder1' }] + } + ] + }; + const config = { params: {} }; + + wurflSubmodule.onAuctionEndEvent(auctionDetails, config, userConsent); + + expect(sendBeaconStub.calledOnce).to.be.true; + const beaconCall = sendBeaconStub.getCall(0); + const payload = JSON.parse(beaconCall.args[1]); + expect(payload).to.have.property('consent_class', expectedClass); + done(); + }; + + const config = { params: {} }; + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, config, {}); + }; + + it('should return NO consent (0) when userConsent is null', (done) => { + testConsentClass('null userConsent', null, 0, done); + }); + + it('should return NO consent (0) when userConsent is empty object', (done) => { + testConsentClass('empty object', {}, 0, done); + }); + + it('should return NO consent (0) when COPPA is enabled', (done) => { + testConsentClass('COPPA enabled', { coppa: true }, 0, done); + }); + + it('should return NO consent (0) when USP opt-out (1Y)', (done) => { + testConsentClass('USP opt-out', { usp: '1YYN' }, 0, done); + }); + + it('should return NO consent (0) when GDPR applies but no purposes granted', (done) => { + const userConsent = { + gdpr: { + gdprApplies: true, + vendorData: { + purpose: { + consents: {}, + legitimateInterests: {} } } } - } - }; - const bidderCode = 'exampleBidder'; - const wjsData = { - capability1: 'value1', - capability2: 'value2' - }; + }; + testConsentClass('GDPR no purposes', userConsent, 0, done); + }); + + it('should return FULL consent (2) when no GDPR object (non-GDPR region)', (done) => { + testConsentClass('no GDPR object', { usp: '1NNN' }, 2, done); + }); - enrichBidderRequest(reqBidsConfigObj, bidderCode, wjsData); + it('should return FULL consent (2) when GDPR does not apply', (done) => { + const userConsent = { + gdpr: { + gdprApplies: false + } + }; + testConsentClass('GDPR not applicable', userConsent, 2, done); + }); - expect(reqBidsConfigObj.ortb2Fragments.bidder).to.deep.equal({ - exampleBidder: { - device: { - ua: 'user-agent', - ext: { - wurfl: { - capability1: 'value1', - capability2: 'value2' + it('should return FULL consent (2) when all 3 GDPR purposes granted via consents', (done) => { + const userConsent = { + gdpr: { + gdprApplies: true, + vendorData: { + purpose: { + consents: { 7: true, 8: true, 10: true } } } } - } + }; + testConsentClass('all purposes via consents', userConsent, 2, done); }); - }); - }); - describe('makeOrtb2DeviceType', function () { - it('should return 1 when wurflData is_mobile and is_phone is true', function () { - const wurflData = { is_mobile: true, is_phone: true, is_tablet: false }; - const result = makeOrtb2DeviceType(wurflData); - expect(result).to.equal(1); - }); + it('should return FULL consent (2) when all 3 GDPR purposes granted via legitimateInterests', (done) => { + const userConsent = { + gdpr: { + gdprApplies: true, + vendorData: { + purpose: { + legitimateInterests: { 7: true, 8: true, 10: true } + } + } + } + }; + testConsentClass('all purposes via LI', userConsent, 2, done); + }); - it('should return 1 when wurflData is_mobile and is_tablet is true', function () { - const wurflData = { is_mobile: true, is_phone: false, is_tablet: true }; - const result = makeOrtb2DeviceType(wurflData); - expect(result).to.equal(1); - }); + it('should return FULL consent (2) when all 3 GDPR purposes granted via mixed consents and LI', (done) => { + const userConsent = { + gdpr: { + gdprApplies: true, + vendorData: { + purpose: { + consents: { 7: true, 10: true }, + legitimateInterests: { 8: true } + } + } + } + }; + testConsentClass('mixed consents and LI', userConsent, 2, done); + }); - it('should return 6 when wurflData is_mobile but is_phone and is_tablet are false', function () { - const wurflData = { is_mobile: true, is_phone: false, is_tablet: false }; - const result = makeOrtb2DeviceType(wurflData); - expect(result).to.equal(6); - }); + it('should return PARTIAL consent (1) when only 1 GDPR purpose granted', (done) => { + const userConsent = { + gdpr: { + gdprApplies: true, + vendorData: { + purpose: { + consents: { 7: true } + } + } + } + }; + testConsentClass('1 purpose granted', userConsent, 1, done); + }); - it('should return 2 when wurflData is_full_desktop is true', function () { - const wurflData = { is_full_desktop: true }; - const result = makeOrtb2DeviceType(wurflData); - expect(result).to.equal(2); + it('should return PARTIAL consent (1) when 2 GDPR purposes granted', (done) => { + const userConsent = { + gdpr: { + gdprApplies: true, + vendorData: { + purpose: { + consents: { 7: true }, + legitimateInterests: { 8: true } + } + } + } + }; + testConsentClass('2 purposes granted', userConsent, 1, done); + }); }); - it('should return 3 when wurflData is_connected_tv is true', function () { - const wurflData = { is_connected_tv: true }; - const result = makeOrtb2DeviceType(wurflData); - expect(result).to.equal(3); - }); + describe('sampling rate', () => { + it('should not send beacon when sampling_rate is 0', (done) => { + // Setup WURFL data with sampling_rate: 0 + const wurfl_pbjs_zero_sampling = { ...wurfl_pbjs, sampling_rate: 0 }; + const cachedData = { WURFL, wurfl_pbjs: wurfl_pbjs_zero_sampling }; + sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); - it('should return 4 when wurflData is_phone is true and is_mobile is false or undefined', function () { - const wurflData = { is_phone: true }; - const result = makeOrtb2DeviceType(wurflData); - expect(result).to.equal(4); - }); + const sendBeaconStub = sandbox.stub(ajaxModule, 'sendBeacon'); + const fetchStub = sandbox.stub(ajaxModule, 'fetch'); - it('should return 5 when wurflData is_tablet is true and is_mobile is false or undefined', function () { - const wurflData = { is_tablet: true }; - const result = makeOrtb2DeviceType(wurflData); - expect(result).to.equal(5); - }); + sandbox.stub(prebidGlobalModule, 'getGlobal').returns({ + getHighestCpmBids: () => [] + }); - it('should return 7 when wurflData is_ott is true', function () { - const wurflData = { is_ott: true }; - const result = makeOrtb2DeviceType(wurflData); - expect(result).to.equal(7); - }); + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; - it('should return undefined when wurflData is_mobile is true but is_phone and is_tablet are missing', function () { - const wurflData = { is_mobile: true }; - const result = makeOrtb2DeviceType(wurflData); - expect(result).to.be.undefined; - }); + const callback = () => { + const auctionDetails = { + bidsReceived: [ + { requestId: 'req1', bidderCode: 'bidder1', adUnitCode: 'ad1', cpm: 1.5, currency: 'USD' } + ], + adUnits: [ + { + code: 'ad1', + bids: [{ bidder: 'bidder1' }] + } + ] + }; + const config = { params: {} }; + const userConsent = null; - it('should return undefined when no conditions are met', function () { - const wurflData = {}; - const result = makeOrtb2DeviceType(wurflData); - expect(result).to.be.undefined; - }); - }); + wurflSubmodule.onAuctionEndEvent(auctionDetails, config, userConsent); + + // Beacon should NOT be sent due to sampling_rate: 0 + expect(sendBeaconStub.called).to.be.false; + expect(fetchStub.called).to.be.false; + done(); + }; + + const config = { params: {} }; + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, config, {}); + }); + + it('should send beacon when sampling_rate is 100', (done) => { + // Setup WURFL data with sampling_rate: 100 + const wurfl_pbjs_full_sampling = { ...wurfl_pbjs, sampling_rate: 100 }; + const cachedData = { WURFL, wurfl_pbjs: wurfl_pbjs_full_sampling }; + sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + const sendBeaconStub = sandbox.stub(ajaxModule, 'sendBeacon').returns(true); + + sandbox.stub(prebidGlobalModule, 'getGlobal').returns({ + getHighestCpmBids: () => [] + }); + + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + + const callback = () => { + const auctionDetails = { + bidsReceived: [ + { requestId: 'req1', bidderCode: 'bidder1', adUnitCode: 'ad1', cpm: 1.5, currency: 'USD' } + ], + adUnits: [ + { + code: 'ad1', + bids: [{ bidder: 'bidder1' }] + } + ] + }; + const config = { params: {} }; + const userConsent = null; + + wurflSubmodule.onAuctionEndEvent(auctionDetails, config, userConsent); + + // Beacon should be sent + expect(sendBeaconStub.calledOnce).to.be.true; + const beaconCall = sendBeaconStub.getCall(0); + const payload = JSON.parse(beaconCall.args[1]); + expect(payload).to.have.property('sampling_rate', 100); + done(); + }; + + const config = { params: {} }; + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, config, {}); + }); + + it('should use default sampling_rate (100) for LCE and send beacon', (done) => { + // No cached data - will use LCE + sandbox.stub(storage, 'getDataFromLocalStorage').returns(null); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + const sendBeaconStub = sandbox.stub(ajaxModule, 'sendBeacon').returns(true); + + sandbox.stub(prebidGlobalModule, 'getGlobal').returns({ + getHighestCpmBids: () => [] + }); + + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; - describe('toNumber', function () { - it('converts valid numbers', function () { - expect(toNumber(42)).to.equal(42); - expect(toNumber(3.14)).to.equal(3.14); - expect(toNumber('100')).to.equal(100); - expect(toNumber('3.14')).to.equal(3.14); - expect(toNumber(' 50 ')).to.equal(50); + const callback = () => { + const auctionDetails = { + bidsReceived: [ + { requestId: 'req1', bidderCode: 'bidder1', adUnitCode: 'ad1', cpm: 1.5, currency: 'USD' } + ], + adUnits: [ + { + code: 'ad1', + bids: [{ bidder: 'bidder1' }] + } + ] + }; + const config = { params: {} }; + const userConsent = null; + + wurflSubmodule.onAuctionEndEvent(auctionDetails, config, userConsent); + + // Beacon should be sent with default sampling_rate + expect(sendBeaconStub.calledOnce).to.be.true; + const beaconCall = sendBeaconStub.getCall(0); + const payload = JSON.parse(beaconCall.args[1]); + expect(payload).to.have.property('sampling_rate', 100); + // Enrichment type can be 'lce' or 'lcefailed' depending on what data is available + expect(payload.enrichment).to.be.oneOf(['lce', 'lcefailed']); + done(); + }; + + const config = { params: {} }; + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, config, {}); + }); }); - it('converts booleans correctly', function () { - expect(toNumber(true)).to.equal(1); - expect(toNumber(false)).to.equal(0); + describe('onAuctionEndEvent: overquota beacon enrichment', () => { + beforeEach(() => { + // Mock getGlobal().getHighestCpmBids() + sandbox.stub(prebidGlobalModule, 'getGlobal').returns({ + getHighestCpmBids: () => [] + }); + + // Reset reqBidsConfigObj + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + }); + + it('should report wurfl_ssp for authorized bidders and none for unauthorized when overquota', (done) => { + // Setup overquota scenario + const wurfl_pbjs_over_quota = { + ...wurfl_pbjs, + over_quota: 1 + }; + const cachedData = { WURFL, wurfl_pbjs: wurfl_pbjs_over_quota }; + sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + const sendBeaconStub = sandbox.stub(ajaxModule, 'sendBeacon').returns(true); + + const callback = () => { + const auctionDetails = { + bidsReceived: [ + { requestId: 'req1', bidderCode: 'bidder1', adUnitCode: 'ad1', cpm: 1.5, currency: 'USD' }, + { requestId: 'req2', bidderCode: 'bidder2', adUnitCode: 'ad1', cpm: 1.2, currency: 'USD' } + ], + adUnits: [ + { + code: 'ad1', + bids: [ + { bidder: 'bidder1' }, // authorized + { bidder: 'bidder2' }, // authorized + { bidder: 'bidder3' } // NOT authorized + ] + } + ] + }; + const config = { params: {} }; + const userConsent = {}; + + wurflSubmodule.onAuctionEndEvent(auctionDetails, config, userConsent); + + expect(sendBeaconStub.calledOnce).to.be.true; + const beaconCall = sendBeaconStub.getCall(0); + const payload = JSON.parse(beaconCall.args[1]); + + // Verify overall enrichment is none when overquota (publisher not enriched) + expect(payload).to.have.property('enrichment', 'none'); + expect(payload).to.have.property('over_quota', 1); + + // Verify per-bidder enrichment + expect(payload.ad_units).to.be.an('array').with.lengthOf(1); + expect(payload.ad_units[0].bidders).to.be.an('array').with.lengthOf(3); + + // bidder1 and bidder2 are authorized - should report wurfl_ssp + expect(payload.ad_units[0].bidders[0]).to.deep.include({ + bidder: 'bidder1', + bdr_enrich: 'wurfl_ssp', + cpm: 1.5, + currency: 'USD', + won: false + }); + expect(payload.ad_units[0].bidders[1]).to.deep.include({ + bidder: 'bidder2', + bdr_enrich: 'wurfl_ssp', + cpm: 1.2, + currency: 'USD', + won: false + }); + + // bidder3 is NOT authorized and overquota - should report none + expect(payload.ad_units[0].bidders[2]).to.deep.include({ + bidder: 'bidder3', + bdr_enrich: 'none', + won: false + }); + + done(); + }; + + const config = { params: {} }; + const userConsent = {}; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, config, userConsent); + }); + + it('should report wurfl_ssp for authorized and wurfl_pub for unauthorized when not overquota', (done) => { + // Setup NOT overquota scenario + const cachedData = { WURFL, wurfl_pbjs }; + sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + const sendBeaconStub = sandbox.stub(ajaxModule, 'sendBeacon').returns(true); + + const callback = () => { + const auctionDetails = { + bidsReceived: [ + { requestId: 'req1', bidderCode: 'bidder1', adUnitCode: 'ad1', cpm: 1.5, currency: 'USD' }, + { requestId: 'req3', bidderCode: 'bidder3', adUnitCode: 'ad1', cpm: 1.0, currency: 'USD' } + ], + adUnits: [ + { + code: 'ad1', + bids: [ + { bidder: 'bidder1' }, // authorized + { bidder: 'bidder3' } // NOT authorized + ] + } + ] + }; + const config = { params: {} }; + const userConsent = {}; + + wurflSubmodule.onAuctionEndEvent(auctionDetails, config, userConsent); + + expect(sendBeaconStub.calledOnce).to.be.true; + const beaconCall = sendBeaconStub.getCall(0); + const payload = JSON.parse(beaconCall.args[1]); + + // Verify overall enrichment is wurfl_pub when not overquota + expect(payload).to.have.property('enrichment', 'wurfl_pub'); + expect(payload).to.have.property('over_quota', 0); + + // Verify per-bidder enrichment + expect(payload.ad_units).to.be.an('array').with.lengthOf(1); + expect(payload.ad_units[0].bidders).to.be.an('array').with.lengthOf(2); + + // bidder1 is authorized - should always report wurfl_ssp + expect(payload.ad_units[0].bidders[0]).to.deep.include({ + bidder: 'bidder1', + bdr_enrich: 'wurfl_ssp', + cpm: 1.5, + currency: 'USD', + won: false + }); + + // bidder3 is NOT authorized but not overquota - should report wurfl_pub + expect(payload.ad_units[0].bidders[1]).to.deep.include({ + bidder: 'bidder3', + bdr_enrich: 'wurfl_pub', + cpm: 1.0, + currency: 'USD', + won: false + }); + + done(); + }; + + const config = { params: {} }; + const userConsent = {}; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, config, userConsent); + }); }); - it('handles special cases', function () { - expect(toNumber(null)).to.be.undefined; - expect(toNumber('')).to.be.undefined; + describe('device type mapping', () => { + it('should map is_ott priority over form_factor', (done) => { + const wurflWithOtt = { ...WURFL, is_ott: true, form_factor: 'Desktop' }; + const cachedData = { WURFL: wurflWithOtt, wurfl_pbjs }; + sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + + const callback = () => { + expect(reqBidsConfigObj.ortb2Fragments.global.device.devicetype).to.equal(7); + done(); + }; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, { params: {} }, {}); + }); + + it('should map is_console priority over form_factor', (done) => { + const wurflWithConsole = { ...WURFL, is_console: true, form_factor: 'Desktop' }; + const cachedData = { WURFL: wurflWithConsole, wurfl_pbjs }; + sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + + const callback = () => { + expect(reqBidsConfigObj.ortb2Fragments.global.device.devicetype).to.equal(6); + done(); + }; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, { params: {} }, {}); + }); + + it('should map physical_form_factor out_of_home_device', (done) => { + const wurflWithOOH = { ...WURFL, physical_form_factor: 'out_of_home_device', form_factor: 'Desktop' }; + const cachedData = { WURFL: wurflWithOOH, wurfl_pbjs }; + sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + + const callback = () => { + expect(reqBidsConfigObj.ortb2Fragments.global.device.devicetype).to.equal(8); + done(); + }; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, { params: {} }, {}); + }); + + it('should map form_factor Desktop to PERSONAL_COMPUTER', (done) => { + const wurflDesktop = { ...WURFL, form_factor: 'Desktop' }; + const cachedData = { WURFL: wurflDesktop, wurfl_pbjs }; + sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + + const callback = () => { + expect(reqBidsConfigObj.ortb2Fragments.global.device.devicetype).to.equal(2); + done(); + }; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, { params: {} }, {}); + }); + + it('should map form_factor Smartphone to PHONE', (done) => { + const wurflSmartphone = { ...WURFL, form_factor: 'Smartphone' }; + const cachedData = { WURFL: wurflSmartphone, wurfl_pbjs }; + sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + + const callback = () => { + expect(reqBidsConfigObj.ortb2Fragments.global.device.devicetype).to.equal(4); + done(); + }; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, { params: {} }, {}); + }); + + it('should map form_factor Tablet to TABLET', (done) => { + const wurflTablet = { ...WURFL, form_factor: 'Tablet' }; + const cachedData = { WURFL: wurflTablet, wurfl_pbjs }; + sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + + const callback = () => { + expect(reqBidsConfigObj.ortb2Fragments.global.device.devicetype).to.equal(5); + done(); + }; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, { params: {} }, {}); + }); + + it('should map form_factor Smart-TV to CONNECTED_TV', (done) => { + const wurflSmartTV = { ...WURFL, form_factor: 'Smart-TV' }; + const cachedData = { WURFL: wurflSmartTV, wurfl_pbjs }; + sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + + const callback = () => { + expect(reqBidsConfigObj.ortb2Fragments.global.device.devicetype).to.equal(3); + done(); + }; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, { params: {} }, {}); + }); + + it('should map form_factor Other Non-Mobile to CONNECTED_DEVICE', (done) => { + const wurflOtherNonMobile = { ...WURFL, form_factor: 'Other Non-Mobile' }; + const cachedData = { WURFL: wurflOtherNonMobile, wurfl_pbjs }; + sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + + const callback = () => { + expect(reqBidsConfigObj.ortb2Fragments.global.device.devicetype).to.equal(6); + done(); + }; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, { params: {} }, {}); + }); + + it('should map form_factor Other Mobile to MOBILE_OR_TABLET', (done) => { + const wurflOtherMobile = { ...WURFL, form_factor: 'Other Mobile' }; + const cachedData = { WURFL: wurflOtherMobile, wurfl_pbjs }; + sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + + const callback = () => { + expect(reqBidsConfigObj.ortb2Fragments.global.device.devicetype).to.equal(1); + done(); + }; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, { params: {} }, {}); + }); + + it('should return undefined when form_factor is missing', (done) => { + const wurflNoFormFactor = { ...WURFL }; + delete wurflNoFormFactor.form_factor; + const cachedData = { WURFL: wurflNoFormFactor, wurfl_pbjs }; + sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + + const callback = () => { + expect(reqBidsConfigObj.ortb2Fragments.global.device.devicetype).to.be.undefined; + done(); + }; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, { params: {} }, {}); + }); + + it('should return undefined for unknown form_factor', (done) => { + const wurflUnknownFormFactor = { ...WURFL, form_factor: 'UnknownDevice' }; + const cachedData = { WURFL: wurflUnknownFormFactor, wurfl_pbjs }; + sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + + const callback = () => { + expect(reqBidsConfigObj.ortb2Fragments.global.device.devicetype).to.be.undefined; + done(); + }; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, { params: {} }, {}); + }); }); - it('returns undefined for non-numeric values', function () { - expect(toNumber('abc')).to.be.undefined; - expect(toNumber(undefined)).to.be.undefined; - expect(toNumber(NaN)).to.be.undefined; - expect(toNumber({})).to.be.undefined; - expect(toNumber([1, 2, 3])).to.be.undefined; - // WURFL.js cannot return [] so it is safe to not handle and return undefined - expect(toNumber([])).to.equal(0); + describe('LCE Error Handling', function () { + beforeEach(function () { + // Setup empty cache to force LCE + sandbox.stub(storage, 'getDataFromLocalStorage').returns(null); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + }); + + it('should set LCE_ERROR enrichment type when LCE device detection throws error', (done) => { + const sendBeaconStub = sandbox.stub(ajaxModule, 'sendBeacon').returns(true); + + sandbox.stub(prebidGlobalModule, 'getGlobal').returns({ + getHighestCpmBids: () => [] + }); + + // Import the WurflLCEDevice to stub it + const wurflRtdProvider = require('modules/wurflRtdProvider.js'); + + const callback = () => { + const device = reqBidsConfigObj.ortb2Fragments.global.device; + + // Should have minimal fallback data + expect(device.js).to.equal(1); + + // UA-dependent fields should not be set when error occurs + expect(device.devicetype).to.be.undefined; + expect(device.os).to.be.undefined; + + // Trigger auction to verify enrichment type in beacon + const auctionDetails = { + bidsReceived: [ + { requestId: 'req1', bidderCode: 'bidder1', adUnitCode: 'ad1', cpm: 1.5, currency: 'USD' } + ], + adUnits: [ + { + code: 'ad1', + bids: [{ bidder: 'bidder1' }] + } + ] + }; + + wurflSubmodule.onAuctionEndEvent(auctionDetails, { params: {} }, null); + + // Check beacon was sent with lcefailed enrichment type + expect(sendBeaconStub.calledOnce).to.be.true; + const beaconCall = sendBeaconStub.getCall(0); + const payload = JSON.parse(beaconCall.args[1]); + expect(payload).to.have.property('enrichment', 'lcefailed'); + + done(); + }; + + // Stub _getDeviceInfo to throw an error + const originalGetDeviceInfo = window.navigator.userAgent; + Object.defineProperty(window.navigator, 'userAgent', { + get: () => { + throw new Error('User agent access failed'); + }, + configurable: true + }); + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, { params: {} }, {}); + + // Restore + Object.defineProperty(window.navigator, 'userAgent', { + value: originalGetDeviceInfo, + configurable: true + }); + }); }); }); });