From d6817ac073f7f9b97fe91c40959b32aa4d98bad5 Mon Sep 17 00:00:00 2001 From: Luca Corbo Date: Wed, 29 Oct 2025 10:12:04 +0100 Subject: [PATCH 01/22] zero latency - initial version --- modules/wurflRtdProvider.js | 1313 ++++++++++++--- modules/wurflRtdProvider.md | 29 + test/spec/modules/wurflRtdProvider_spec.js | 1722 ++++++++++++++++---- 3 files changed, 2557 insertions(+), 507 deletions(-) diff --git a/modules/wurflRtdProvider.js b/modules/wurflRtdProvider.js index d2e85f40ec7..5fc44778d69 100644 --- a/modules/wurflRtdProvider.js +++ b/modules/wurflRtdProvider.js @@ -6,257 +6,1091 @@ import { prefixLog, } 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.0.0-beta3'; // 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 = { + NONE: 'none', + LCE: 'lce', + 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 +}; + +// Default sampling rate constant +const DEFAULT_SAMPLING_RATE = 100; + +// A/B test constants +const AB_TEST = { + CONTROL_GROUP: 'control', + TREATMENT_GROUP: 'treatment', + DEFAULT_SPLIT: 50, + DEFAULT_NAME: 'unknown' +}; + +// Tier constants +const TIER_FREE = 'free'; 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; +// abTest stores A/B test configuration and variant (set by init) +let abTest; - if (isDebug) { - url.searchParams.set('debug', 'true') +/** + * 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]; }); + + // 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; - } - caps.forEach((cap, index) => { - if (!filter.includes(index)) { +function enrichDeviceBidder(reqBidsConfigObj, bidders, wjsDevice) { + bidders.forEach((bidderCode) => { + // Get bidder data (handles both authorized and unauthorized bidders) + const bidderDevice = wjsDevice.Bidder(bidderCode); + + // Skip if no data to inject (over quota + unauthorized) + if (Object.keys(bidderDevice).length === 0) { return; } - if (cap in wurflData) { - data[cap] = wurflData[cap]; + + // Set enrichment type based on authorization status + if (wjsDevice._isAuthorized(bidderCode)) { + bidderEnrichment.set(bidderCode, ENRICHMENT_TYPE.WURFL_SSP); + } else { + bidderEnrichment.set(bidderCode, ENRICHMENT_TYPE.WURFL_PUB); } + + // Inject WURFL data + mergeDeep(reqBidsConfigObj.ortb2Fragments.bidder, { [bidderCode]: bidderDevice }); }); - 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 = config.params?.debug ?? false; + + let host = WURFL_JS_HOST; + if (altHost) { + host = altHost; + } + + 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 ('brand_name' in wurflData) { - data['brand_name'] = wurflData.brand_name; + + url.searchParams.set('mode', 'prebid2'); + + // Add bidders list for server optimization + if (bidders && bidders.size > 0) { + url.searchParams.set('bidders', Array.from(bidders).join(',')); } - if ('wurfl_id' in wurflData) { - data['wurfl_id'] = wurflData.wurfl_id; + + // Helper function to load WURFL.js script + const loadWurflJs = (scriptUrl) => { + try { + loadExternalScript(scriptUrl, MODULE_TYPE_RTD, MODULE_NAME, () => { + logger.logMessage('async WURFL.js script injected'); + 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': {}, - }, - }; - - 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 }); +function shouldSample(rate) { + if (rate >= 100) { + return true; + } + if (rate <= 0) { + return false; + } + const randomValue = Math.floor(Math.random() * 100); + return randomValue < rate; } /** - * 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(isDebug) { + if (!isDebug) { + // 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) + 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) + }; + 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 and over quota, return empty + if (!isAuthorized && isOverQuota) { + 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 + // For unauthorized bidders (under quota only): basic + pub caps (no bidder-specific) + const wurflData = { + ...this._getBasicCaps(), + ...this._getPubCaps(), + ...(isAuthorized ? this._getBidderCaps(bidderCode) : {}) + }; + + // Exclude wurfl_id for free tier + if (this._pbjsData.tier === TIER_FREE && 'wurfl_id' in wurflData) { + delete wurflData.wurfl_id; + } + + 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) { + let osv = ""; + switch (osName) { + case "Windows": { + const matches = ua.match(/Windows NT ([\d.]+)/); + if (matches) { + return matches[1]; + } + return ""; + } + case "macOS": { + const matches = ua.match(/Mac OS X ([\d_]+)/); + if (matches) { + osv = matches[1].replaceAll('_', '.'); + return osv; + } + return ""; + } + case "iOS": { + const matches = ua.match(/iPhone; CPU iPhone OS ([\d_]+) like Mac OS X/); + if (matches) { + osv = matches[1].replaceAll('_', '.'); + return osv; + } + return ""; + } + case "iPadOS": { + const matches = ua.match(/iPad; CPU OS ([\d_]+) like Mac OS X/); + if (matches) { + osv = matches[1].replaceAll('_', '.'); + return osv; + } + return ""; + } + case "Android": { + // For Android UAs with a decimal + const matches1 = ua.match(/Android ([\d.]+)/); + // For Android UAs without a decimal + const matches2 = ua.match(/Android ([\d]+)/); + if (matches1) { + return matches1[1]; + } + if (matches2) { + return matches2[1]; + } + return ""; + } + case "ChromeOS": { + const matches = ua.match(/CrOS x86_64 ([\d.]+)/); + if (matches) { + return matches[1]; + } + return ""; + } + case "Tizen": { + const matches = ua.match(/Tizen ([\d.]+)/); + if (matches) { + return matches[1]; + } + return ""; + } + case "Roku OS": { + const matches = ua.match(/Roku\/DVP [\dA-Z]+ [\d.]+\/([\d.]+)/); + if (matches) { + return matches[1]; + } + return ""; + } + case "PlayStation OS": { + // PS4 + const matches1 = ua.match(/PlayStation \d\/([\d.]+)/); + // PS3 + const matches2 = ua.match(/PLAYSTATION \d ([\d.]+)/); + if (matches1) { + return matches1[1]; + } + if (matches2) { + return matches2[1]; + } + return ""; + } + 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(win = (typeof window !== "undefined" ? window : undefined)) { + if (!win) { + return 1; + } + return ( + win.devicePixelRatio || + (win.screen.deviceXDPI / win.screen.logicalXDPI) || + Math.round(win.screen.availWidth / win.document.documentElement.clientWidth) + ); + }, + + _getScreenWidth(win = (typeof window !== "undefined" ? window : undefined)) { + if (!win) { + return 0; + } + return Math.round(win.screen.width * this._getDevicePixelRatioValue(win)); + }, + + _getScreenHeight(win = (typeof window !== "undefined" ? window : undefined)) { + if (!win) { + return 0; + } + return Math.round(win.screen.height * this._getDevicePixelRatioValue(win)); + }, + + _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 ''; + }, + + // Public API - returns device object for First Party Data (global) + FPD() { + const useragent = typeof window !== "undefined" ? window.navigator.userAgent : ""; + const deviceInfo = this._getDeviceInfo(useragent); + + const win = typeof window !== "undefined" ? window : undefined; + const pixelRatio = this._getDevicePixelRatioValue(win); + const screenWidth = this._getScreenWidth(win); + const screenHeight = this._getScreenHeight(win); + + const brand = this._getMake(useragent); + const model = this._getModel(useragent); + + return { + devicetype: deviceInfo.deviceType, + make: brand, + model: model, + os: deviceInfo.osName, + osv: deviceInfo.osVersion, + hwv: model, + h: screenHeight, + w: screenWidth, + pxratio: pixelRatio, + js: 1 + }; + } +}; +// ==================== END WURFL LCE DEVICE MODULE ==================== + +// ==================== EXPORTED FUNCTIONS ==================== + /** - * 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 + * init initializes the WURFL RTD submodule + * @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 - return; - } - if (value === undefined) { - return; +const init = (config, userConsent) => { + // Initialize debugger based on debug flag + const isDebug = config?.params?.debug ?? false; + WurflDebugger.init(isDebug); + + // Initialize module state + bidderEnrichment = new Map(); + enrichmentType = ENRICHMENT_TYPE.NONE; + wurflId = ''; + samplingRate = DEFAULT_SAMPLING_RATE; + 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 isInTreatment = shouldSample(abSplit); + const abVariant = isInTreatment ? AB_TEST.TREATMENT_GROUP : AB_TEST.CONTROL_GROUP; + abTest = { ab_name: abName, ab_variant: abVariant }; + logger.logMessage(`A/B test "${abName}": user in ${abVariant} group`); } - ortb2data.device[key] = value; + + logger.logMessage('initialized'); + return true; } /** - * 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. + * 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 */ -export function toNumber(value) { - if (value === '' || value === null) { - return undefined; +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.NONE); + }); + }); + + // 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; + WurflDebugger.moduleExecutionStop(); + callback(); + return; } - const num = Number(value); - return Number.isNaN(num) ? undefined : num; + + // 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); + + logger.logMessage(isExpired ? 'using expired cached WURFL.js data' : 'using cached WURFL.js data'); + + const wjsDevice = WurflJSDevice.fromCache(cachedWurflData); + if (!wjsDevice._isOverQuota()) { + 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; + + // If expired, refresh cache async + if (isExpired) { + loadWurflJsAsync(config, bidders); + } + + WurflDebugger.moduleExecutionStop(); + callback(); + return; + } + + // Priority 2: return LCE data + logger.logMessage('generating fresh LCE data'); + WurflDebugger.setDataSource('lce'); + WurflDebugger.lceDetectionStart(); + const fpdDevice = WurflLCEDevice.FPD(); + WurflDebugger.lceDetectionStop(); + WurflDebugger.setLceData(fpdDevice); + enrichDeviceFPD(reqBidsConfigObj, fpdDevice); + + // Set enrichment type to LCE + enrichmentType = ENRICHMENT_TYPE.LCE; + bidders.forEach(bidder => bidderEnrichment.set(bidder, ENRICHMENT_TYPE.LCE)); + + // Set default sampling rate for LCE + samplingRate = DEFAULT_SAMPLING_RATE; + + // Load WURFL.js async for future requests + loadWurflJsAsync(config, bidders); + + WurflDebugger.moduleExecutionStop(); + callback(); } /** @@ -266,23 +1100,128 @@ 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) { + // Only send beacon if there are bids to report + if (!auctionDetails.bidsReceived || auctionDetails.bidsReceived.length === 0) { return; } - var payload = JSON.stringify({ bidders: [...enrichedBidders] }); + logger.logMessage(`onAuctionEndEvent: processing ${auctionDetails.bidsReceived.length} bid responses`); + + // 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; + } + + logger.logMessage(`onAuctionEndEvent: ${winningBids.length} winning bids identified`); + + // 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, + enrichment: bidderEnrichment.get(bidderCode), + cpm: bidResponse.cpm, + currency: bidResponse.currency, + won: isWinner + }); + } else { + // Bidder didn't respond - include without cpm/currency + bidders.push({ + bidder: bidderCode, + enrichment: bidderEnrichment.get(bidderCode), + won: false + }); + } + } + + adUnits.push({ + ad_unit_code: adUnitCode, + bidders: bidders + }); + } + } + + // Count bidders for logging + let totalBidderEntries = 0; + for (let i = 0; i < adUnits.length; i++) { + totalBidderEntries += adUnits[i].bidders.length; + } + const respondedBidders = auctionDetails.bidsReceived.length; + const nonRespondingBidders = totalBidderEntries - respondedBidders; + + logger.logMessage(`onAuctionEndEvent: built ${adUnits.length} ad units with ${totalBidderEntries} total bidder entries (${respondedBidders} responded, ${nonRespondingBidders} non-responding)`); + + // Calculate consent class + const consentClass = getConsentClass(userConsent); + + // 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, + 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 +1231,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..9ec3fdf2254 100644 --- a/modules/wurflRtdProvider.md +++ b/modules/wurflRtdProvider.md @@ -55,6 +55,35 @@ pbjs.setConfig({ | params | Object | | | | params.altHost | String | Alternate host to connect to WURFL.js | | | params.debug | Boolean | Enable debug | `false` | +| params.abTest | Boolean | Enable A/B testing mode | `false` | +| params.abName | String | A/B test name identifier | `'unknown'` | +| params.abSplit | Number | Percentage of users in treatment group (0-100) | `50` | + +### 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: 50 // 50% treatment, 50% control + } + }] + } +}); +``` + +- **Treatment group** (`abSplit` %): Module enabled, bid requests enriched with WURFL device data +- **Control group** (100 - `abSplit` %): Module disabled, no enrichment occurs +- Assignment is random on each page load based on `Math.random()` +- Example: `abSplit: 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..be55edac49c 100644 --- a/test/spec/modules/wurflRtdProvider_spec.js +++ b/test/spec/modules/wurflRtdProvider_spec.js @@ -1,24 +1,34 @@ 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'; describe('wurflRtdProvider', function () { describe('wurflSubmodule', function () { const altHost = 'http://example.local/wurfl.js'; 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: [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31] + } + }, + bidders: { + bidder1: { + cap_indices: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 13, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32] + }, + bidder2: { + cap_indices: [0, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 21, 22, 26, 29, 31, 32] + } } } const WURFL = { @@ -58,10 +68,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 +81,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,127 +113,432 @@ 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 () => { + console.log('Callback executed'); + // 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) => { + console.log('Starting No Client Hints test...'); + 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; + }); + + it('should not create window.WurflRtdDebug when debug=false', () => { + const config = { params: { debug: false } }; + wurflSubmodule.init(config); + expect(window.WurflRtdDebug).to.be.undefined; + }); + + it('should not create window.WurflRtdDebug when debug is not configured', () => { + const config = { params: {} }; + wurflSubmodule.init(config); + expect(window.WurflRtdDebug).to.be.undefined; + }); + + it('should create window.WurflRtdDebug when debug=true', () => { + const config = { params: { debug: true } }; + wurflSubmodule.init(config); + 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); // 25% -> random value = 25 + const config = { params: { abTest: true, abName: 'test_sept', abSplit: 50 } }; + 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); // 75% -> random value = 75 + const config = { params: { abTest: true, abName: 'test_sept', abSplit: 50 } }; + expect(wurflSubmodule.init(config)).to.be.true; + }); + + it('should use default abSplit of 50 when not specified', () => { + sandbox.stub(Math, 'random').returns(0.40); // 40% -> random value = 40 + 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); // 1% -> random value = 1 + const config = { params: { abTest: true, abName: 'test_sept', abSplit: 0 } }; + expect(wurflSubmodule.init(config)).to.be.true; + }); + + it('should handle abSplit of 100 (all treatment)', () => { + sandbox.stub(Math, 'random').returns(0.99); // 99% -> random value = 99 + const config = { params: { abTest: true, abName: 'test_sept', abSplit: 100 } }; + 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: 50 } }; + + // 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: 50 } }; + + // 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: 50 } }; + + // 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 + }); + + // bidder1 and bidder2 are authorized, should get ext.wurfl with all capabilities + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder1.device.ext.wurfl).to.deep.equal(WURFL); + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder2.device.ext.wurfl).to.deep.equal(WURFL); + + // bidder3 is NOT authorized, but should get basic+pub caps (tested in detail in dedicated test) + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder3).to.exist; + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder3.device.ext.wurfl).to.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); + const expectedURL = new URL(altHost); - expectedURL.searchParams.set('debug', true); + expectedURL.searchParams.set('debug', 'true'); expectedURL.searchParams.set('mode', 'prebid'); - expectedURL.searchParams.set('wurfl_id', true); + expectedURL.searchParams.set('wurfl_id', 'true'); + 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); + + // No bidder enrichment should occur without cached WURFL data + expect(reqBidsConfigObj.ortb2Fragments.bidder).to.deep.equal({}); + done(); }; @@ -227,263 +551,1017 @@ describe('wurflRtdProvider', function () { const userConsent = {}; wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, config, 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 = {}; + it('should enrich only bidders when over quota', (done) => { + // Reset reqBidsConfigObj to clean state + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; - const sendBeaconStub = sandbox.stub(navigator, 'sendBeacon'); + // 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); - // Call the function - wurflSubmodule.onAuctionEndEvent(auctionDetails, config, userConsent); + const callback = () => { + // Verify global FPD does NOT have device data (over quota) + expect(reqBidsConfigObj.ortb2Fragments.global.device).to.deep.equal({}); - // Assertions - expect(sendBeaconStub.calledOnce).to.be.true; - expect(sendBeaconStub.calledWithExactly(expectedStatsURL, expectedData)).to.be.true; - }); + // bidder1 and bidder2 are authorized, should get full device + ext.wurfl + 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 + }); + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder1.device.ext.wurfl).to.deep.equal(WURFL); - it('onAuctionEndEvent: should send analytics data using fetch as fallback, if navigator.sendBeacon is not available', () => { - const auctionDetails = {}; - const config = {}; - const userConsent = {}; + 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 + }); + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder2.device.ext.wurfl).to.deep.equal(WURFL); - const sendBeaconStub = sandbox.stub(navigator, 'sendBeacon').value(undefined); - const windowFetchStub = sandbox.stub(window, 'fetch'); - const fetchAjaxStub = sandbox.stub(ajaxModule, 'fetch'); + // bidder3 is NOT authorized, should get nothing + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder3).to.be.undefined; - // Call the function - wurflSubmodule.onAuctionEndEvent(auctionDetails, config, userConsent); + done(); + }; - // Assertions - expect(sendBeaconStub.called).to.be.false; + const config = { params: {} }; + const userConsent = {}; - 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'); + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, config, userConsent); }); - }); - describe('bidderData', () => { - it('should return the WURFL data for a bidder', () => { - const wjsData = { - capability1: 'value1', - capability2: 'value2', - capability3: 'value3', - }; - const caps = ['capability1', 'capability2', 'capability3']; - const filter = [0, 2]; + it('should pass basic+pub caps to unauthorized bidders when under quota', (done) => { + // Reset reqBidsConfigObj to clean state + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; - const result = bidderData(wjsData, caps, filter); + // 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); - expect(result).to.deep.equal({ - capability1: 'value1', - capability3: 'value3', - }); - }); + 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]; + } + }); - it('should return an empty object if the filter is empty', () => { - const wjsData = { - capability1: 'value1', - capability2: 'value2', - capability3: 'value3', + // bidder1 and bidder2 are authorized, should get ALL caps (basic + pub + bidder-specific) + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder1.device.ext.wurfl).to.deep.equal(WURFL); + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder2.device.ext.wurfl).to.deep.equal(WURFL); + + // bidder3 is NOT authorized, should get ONLY basic + pub caps (no bidder-specific) + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder3).to.exist; + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder3.device).to.exist; + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder3.device.ext).to.exist; + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder3.device.ext.wurfl).to.deep.equal(expectedBasicPubCaps); + + // Verify bidder3 does NOT have FPD device data (only authorized bidders get that when over quota) + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder3.device.make).to.be.undefined; + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder3.device.model).to.be.undefined; + + // Verify the caps calculation: basic+pub union should equal what bidder3 received + const bidder3CapCount = Object.keys(reqBidsConfigObj.ortb2Fragments.bidder.bidder3.device.ext.wurfl).length; + expect(bidder3CapCount).to.equal(allBasicPubIndices.length); + + done(); }; - const caps = ['capability1', 'capability3']; - const filter = []; - const result = bidderData(wjsData, caps, filter); + 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', - }; - 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', + 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('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', + enrichment: 'wurfl_ssp', + cpm: 1.5, + currency: 'USD', + won: true + }); + expect(payload.ad_units[0].bidders[1]).to.deep.include({ + bidder: 'bidder2', + enrichment: '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 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, - }; - 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, + 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('consent_class', 0); + expect(payload).to.have.property('ad_units'); + expect(payload.ad_units).to.be.an('array').with.lengthOf(1); + + 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, + 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, {}); }; - const lowEntropyCaps = []; - const expectedData = {}; - const result = lowEntropyData(wjsData, lowEntropyCaps); - expect(result).to.deep.equal(expectedData); - }); - }); - describe('enrichBidderRequest', () => { - it('should enrich the bidder request with WURFL data', () => { - const reqBidsConfigObj = { - ortb2Fragments: { - global: { - device: {}, - }, - bidder: { - exampleBidder: { - device: { - ua: 'user-agent', + 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); + }); - enrichBidderRequest(reqBidsConfigObj, bidderCode, wjsData); + it('should return FULL consent (2) when no GDPR object (non-GDPR region)', (done) => { + testConsentClass('no GDPR object', { usp: '1NNN' }, 2, done); + }); + + 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); + expect(payload).to.have.property('enrichment', 'lce'); + 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'); + + // 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', + enrichment: 'wurfl_ssp', + cpm: 1.5, + currency: 'USD', + won: false + }); + expect(payload.ad_units[0].bidders[1]).to.deep.include({ + bidder: 'bidder2', + enrichment: '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', + enrichment: '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'); + + // 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', + enrichment: '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', + enrichment: '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('tier-based wurfl_id exclusion', () => { + beforeEach(() => { + // Reset reqBidsConfigObj + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + }); + + it('should exclude wurfl_id from device.ext.wurfl when tier is free', (done) => { + // Setup free tier scenario + const wurfl_pbjs_free = { + ...wurfl_pbjs, + tier: 'free' + }; + const cachedData = { WURFL, wurfl_pbjs: wurfl_pbjs_free }; + sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + const callback = () => { + // Verify bidder1 (authorized) gets capabilities but NOT wurfl_id + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder1.device.ext.wurfl).to.exist; + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder1.device.ext.wurfl).to.not.have.property('wurfl_id'); + + // Verify other capabilities are still present + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder1.device.ext.wurfl).to.have.property('advertised_browser'); + + done(); + }; + + const config = { params: {} }; + const userConsent = {}; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, config, userConsent); + }); + + it('should include wurfl_id in device.ext.wurfl when tier is not free', (done) => { + // Setup large tier scenario + const wurfl_pbjs_large = { + ...wurfl_pbjs, + tier: 'large' + }; + const cachedData = { WURFL, wurfl_pbjs: wurfl_pbjs_large }; + sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'hasLocalStorage').returns(true); + + const callback = () => { + // Verify bidder1 (authorized) gets ALL capabilities including wurfl_id + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder1.device.ext.wurfl).to.exist; + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder1.device.ext.wurfl).to.have.property('wurfl_id', 'lg_nexus5_ver1'); + + done(); + }; + + const config = { params: {} }; + const userConsent = {}; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, config, userConsent); + }); }); - 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('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: {} }, {}); + }); }); }); }); From 6807fa1b5c7c5825f5d0334abd4896a42f4a2c6c Mon Sep 17 00:00:00 2001 From: lucor Date: Thu, 30 Oct 2025 11:13:28 +0100 Subject: [PATCH 02/22] WURFL RTD: remove free tier wurfl_id handling --- modules/wurflRtdProvider.js | 8 --- test/spec/modules/wurflRtdProvider_spec.js | 61 ---------------------- 2 files changed, 69 deletions(-) diff --git a/modules/wurflRtdProvider.js b/modules/wurflRtdProvider.js index 5fc44778d69..62f3daf3efc 100644 --- a/modules/wurflRtdProvider.js +++ b/modules/wurflRtdProvider.js @@ -72,9 +72,6 @@ const AB_TEST = { DEFAULT_NAME: 'unknown' }; -// Tier constants -const TIER_FREE = 'free'; - const logger = prefixLog('[WURFL RTD Submodule]'); // Storage manager for WURFL RTD provider @@ -697,11 +694,6 @@ const WurflJSDevice = { ...(isAuthorized ? this._getBidderCaps(bidderCode) : {}) }; - // Exclude wurfl_id for free tier - if (this._pbjsData.tier === TIER_FREE && 'wurfl_id' in wurflData) { - delete wurflData.wurfl_id; - } - return { device: { ...fpdDevice, diff --git a/test/spec/modules/wurflRtdProvider_spec.js b/test/spec/modules/wurflRtdProvider_spec.js index be55edac49c..e14011467a7 100644 --- a/test/spec/modules/wurflRtdProvider_spec.js +++ b/test/spec/modules/wurflRtdProvider_spec.js @@ -1302,67 +1302,6 @@ describe('wurflRtdProvider', function () { }); }); - describe('tier-based wurfl_id exclusion', () => { - beforeEach(() => { - // Reset reqBidsConfigObj - reqBidsConfigObj.ortb2Fragments.global.device = {}; - reqBidsConfigObj.ortb2Fragments.bidder = {}; - }); - - it('should exclude wurfl_id from device.ext.wurfl when tier is free', (done) => { - // Setup free tier scenario - const wurfl_pbjs_free = { - ...wurfl_pbjs, - tier: 'free' - }; - const cachedData = { WURFL, wurfl_pbjs: wurfl_pbjs_free }; - sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); - sandbox.stub(storage, 'localStorageIsEnabled').returns(true); - sandbox.stub(storage, 'hasLocalStorage').returns(true); - - const callback = () => { - // Verify bidder1 (authorized) gets capabilities but NOT wurfl_id - expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder1.device.ext.wurfl).to.exist; - expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder1.device.ext.wurfl).to.not.have.property('wurfl_id'); - - // Verify other capabilities are still present - expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder1.device.ext.wurfl).to.have.property('advertised_browser'); - - done(); - }; - - const config = { params: {} }; - const userConsent = {}; - - wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, config, userConsent); - }); - - it('should include wurfl_id in device.ext.wurfl when tier is not free', (done) => { - // Setup large tier scenario - const wurfl_pbjs_large = { - ...wurfl_pbjs, - tier: 'large' - }; - const cachedData = { WURFL, wurfl_pbjs: wurfl_pbjs_large }; - sandbox.stub(storage, 'getDataFromLocalStorage').returns(JSON.stringify(cachedData)); - sandbox.stub(storage, 'localStorageIsEnabled').returns(true); - sandbox.stub(storage, 'hasLocalStorage').returns(true); - - const callback = () => { - // Verify bidder1 (authorized) gets ALL capabilities including wurfl_id - expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder1.device.ext.wurfl).to.exist; - expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder1.device.ext.wurfl).to.have.property('wurfl_id', 'lg_nexus5_ver1'); - - done(); - }; - - const config = { params: {} }; - const userConsent = {}; - - wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, config, userConsent); - }); - }); - describe('device type mapping', () => { it('should map is_ott priority over form_factor', (done) => { const wurflWithOtt = { ...WURFL, is_ott: true, form_factor: 'Desktop' }; From 514085cb518d935eb410852bc8edabaf38ef6f06 Mon Sep 17 00:00:00 2001 From: lucor Date: Thu, 30 Oct 2025 11:19:18 +0100 Subject: [PATCH 03/22] WURFL RTD: update internal version --- modules/wurflRtdProvider.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/wurflRtdProvider.js b/modules/wurflRtdProvider.js index 62f3daf3efc..a49c01ed2b9 100644 --- a/modules/wurflRtdProvider.js +++ b/modules/wurflRtdProvider.js @@ -12,7 +12,7 @@ import { getGlobal } from '../src/prebidGlobal.js'; // Constants const REAL_TIME_MODULE = 'realTimeData'; const MODULE_NAME = 'wurfl'; -const MODULE_VERSION = '2.0.0-beta3'; +const MODULE_VERSION = '2.0.0-beta4'; // WURFL_JS_HOST is the host for the WURFL service endpoints const WURFL_JS_HOST = 'https://prebid.wurflcloud.com'; From 961e7313997f5266945fe5f2ca4045adcf6e5dc6 Mon Sep 17 00:00:00 2001 From: lucor Date: Thu, 30 Oct 2025 14:44:18 +0100 Subject: [PATCH 04/22] WURFL RTD: update abSplit to be a float --- modules/wurflRtdProvider.js | 20 ++++++++++++--- modules/wurflRtdProvider.md | 10 ++++---- test/spec/modules/wurflRtdProvider_spec.js | 29 ++++++++++------------ 3 files changed, 35 insertions(+), 24 deletions(-) diff --git a/modules/wurflRtdProvider.js b/modules/wurflRtdProvider.js index a49c01ed2b9..260ef7a9cce 100644 --- a/modules/wurflRtdProvider.js +++ b/modules/wurflRtdProvider.js @@ -68,7 +68,7 @@ const DEFAULT_SAMPLING_RATE = 100; const AB_TEST = { CONTROL_GROUP: 'control', TREATMENT_GROUP: 'treatment', - DEFAULT_SPLIT: 50, + DEFAULT_SPLIT: 0.5, DEFAULT_NAME: 'unknown' }; @@ -289,6 +289,21 @@ function shouldSample(rate) { return randomValue < rate; } +/** + * 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; +} + /** * getConsentClass calculates the consent classification level * @param {Object} userConsent User consent data @@ -986,8 +1001,7 @@ const init = (config, userConsent) => { if (abTestEnabled) { const abName = config?.params?.abName ?? AB_TEST.DEFAULT_NAME; const abSplit = config?.params?.abSplit ?? AB_TEST.DEFAULT_SPLIT; - const isInTreatment = shouldSample(abSplit); - const abVariant = isInTreatment ? AB_TEST.TREATMENT_GROUP : AB_TEST.CONTROL_GROUP; + const abVariant = getABVariant(abSplit); abTest = { ab_name: abName, ab_variant: abVariant }; logger.logMessage(`A/B test "${abName}": user in ${abVariant} group`); } diff --git a/modules/wurflRtdProvider.md b/modules/wurflRtdProvider.md index 9ec3fdf2254..682f7fdaab9 100644 --- a/modules/wurflRtdProvider.md +++ b/modules/wurflRtdProvider.md @@ -57,7 +57,7 @@ pbjs.setConfig({ | params.debug | Boolean | Enable debug | `false` | | params.abTest | Boolean | Enable A/B testing mode | `false` | | params.abName | String | A/B test name identifier | `'unknown'` | -| params.abSplit | Number | Percentage of users in treatment group (0-100) | `50` | +| params.abSplit | Number | Fraction of users in treatment group (0-1) | `0.5` | ### A/B Testing @@ -73,17 +73,17 @@ pbjs.setConfig({ params: { abTest: true, abName: 'pub_test_sept23', - abSplit: 50 // 50% treatment, 50% control + abSplit: 0.5 // 50% treatment, 50% control } }] } }); ``` -- **Treatment group** (`abSplit` %): Module enabled, bid requests enriched with WURFL device data -- **Control group** (100 - `abSplit` %): Module disabled, no enrichment occurs +- **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: 75` means 75% get WURFL enrichment, 25% don't +- 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 e14011467a7..4cbf18bd67e 100644 --- a/test/spec/modules/wurflRtdProvider_spec.js +++ b/test/spec/modules/wurflRtdProvider_spec.js @@ -145,7 +145,6 @@ describe('wurflRtdProvider', function () { sandbox.stub(storage, 'hasLocalStorage').returns(true); const callback = async () => { - console.log('Callback executed'); // Verify client hints were requested expect(getHighEntropyValuesStub.calledOnce).to.be.true; expect(getHighEntropyValuesStub.calledWith( @@ -176,10 +175,8 @@ describe('wurflRtdProvider', function () { }; wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, { params: {} }, {}); - }) it('should load WURFL.js without client hints when not available', (done) => { - console.log('Starting No Client Hints test...'); reqBidsConfigObj.ortb2Fragments.global.device = {}; reqBidsConfigObj.ortb2Fragments.bidder = {}; @@ -331,38 +328,38 @@ describe('wurflRtdProvider', function () { }); it('should return true for users in treatment group (random < abSplit)', () => { - sandbox.stub(Math, 'random').returns(0.25); // 25% -> random value = 25 - const config = { params: { abTest: true, abName: 'test_sept', abSplit: 50 } }; + 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); // 75% -> random value = 75 - const config = { params: { abTest: true, abName: 'test_sept', abSplit: 50 } }; + 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 50 when not specified', () => { - sandbox.stub(Math, 'random').returns(0.40); // 40% -> random value = 40 + 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); // 1% -> random value = 1 + 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 100 (all treatment)', () => { - sandbox.stub(Math, 'random').returns(0.99); // 99% -> random value = 99 - const config = { params: { abTest: true, abName: 'test_sept', abSplit: 100 } }; + 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: 50 } }; + const config = { params: { abTest: true, abName: 'test_sept', abSplit: 0.5 } }; // Initialize with A/B test config wurflSubmodule.init(config); @@ -382,7 +379,7 @@ describe('wurflRtdProvider', function () { 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: 50 } }; + const config = { params: { abTest: true, abName: 'test_sept', abSplit: 0.5 } }; // Initialize with A/B test config wurflSubmodule.init(config); @@ -429,7 +426,7 @@ describe('wurflRtdProvider', function () { 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: 50 } }; + const config = { params: { abTest: true, abName: 'test_sept', abSplit: 0.5 } }; // Initialize with A/B test config wurflSubmodule.init(config); From b2bda010323ba8f2924f958e573eaae310444bf1 Mon Sep 17 00:00:00 2001 From: lucor Date: Fri, 7 Nov 2025 09:40:03 +0100 Subject: [PATCH 05/22] WURFL RTD: LCE add iOS26 osv logic --- modules/wurflRtdProvider.js | 40 ++++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/modules/wurflRtdProvider.js b/modules/wurflRtdProvider.js index 260ef7a9cce..77afd39285f 100644 --- a/modules/wurflRtdProvider.js +++ b/modules/wurflRtdProvider.js @@ -12,7 +12,7 @@ import { getGlobal } from '../src/prebidGlobal.js'; // Constants const REAL_TIME_MODULE = 'realTimeData'; const MODULE_NAME = 'wurfl'; -const MODULE_VERSION = '2.0.0-beta4'; +const MODULE_VERSION = '2.0.0-beta6'; // WURFL_JS_HOST is the host for the WURFL service endpoints const WURFL_JS_HOST = 'https://prebid.wurflcloud.com'; @@ -775,7 +775,6 @@ const WurflLCEDevice = { // Private helper methods _parseOsVersion(ua, osName) { - let osv = ""; switch (osName) { case "Windows": { const matches = ua.match(/Windows NT ([\d.]+)/); @@ -787,35 +786,44 @@ const WurflLCEDevice = { case "macOS": { const matches = ua.match(/Mac OS X ([\d_]+)/); if (matches) { - osv = matches[1].replaceAll('_', '.'); - return osv; + return matches[1].replaceAll('_', '.'); } return ""; } case "iOS": { - const matches = ua.match(/iPhone; CPU iPhone OS ([\d_]+) like Mac OS X/); - if (matches) { - osv = matches[1].replaceAll('_', '.'); - return osv; + // 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/); + if (matches2) { + return matches2[1].replaceAll('_', '.'); } return ""; } case "iPadOS": { - const matches = ua.match(/iPad; CPU OS ([\d_]+) like Mac OS X/); - if (matches) { - osv = matches[1].replaceAll('_', '.'); - return osv; + // 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/); + if (matches2) { + return matches2[1].replaceAll('_', '.'); } return ""; } case "Android": { // For Android UAs with a decimal const matches1 = ua.match(/Android ([\d.]+)/); - // For Android UAs without a decimal - const matches2 = ua.match(/Android ([\d]+)/); if (matches1) { return matches1[1]; } + // For Android UAs without a decimal + const matches2 = ua.match(/Android ([\d]+)/); if (matches2) { return matches2[1]; } @@ -845,11 +853,11 @@ const WurflLCEDevice = { case "PlayStation OS": { // PS4 const matches1 = ua.match(/PlayStation \d\/([\d.]+)/); - // PS3 - const matches2 = ua.match(/PLAYSTATION \d ([\d.]+)/); if (matches1) { return matches1[1]; } + // PS3 + const matches2 = ua.match(/PLAYSTATION \d ([\d.]+)/); if (matches2) { return matches2[1]; } From ba2595497a47af44ba1413750e155165ddfaec3c Mon Sep 17 00:00:00 2001 From: lucor Date: Fri, 7 Nov 2025 09:52:51 +0100 Subject: [PATCH 06/22] WURFL RTD: beacon add tier --- modules/wurflRtdProvider.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/modules/wurflRtdProvider.js b/modules/wurflRtdProvider.js index 77afd39285f..f8605faaeb2 100644 --- a/modules/wurflRtdProvider.js +++ b/modules/wurflRtdProvider.js @@ -92,6 +92,9 @@ let wurflId; // samplingRate tracks the beacon sampling rate (0-100) let samplingRate; +// tier stores the WURFL tier from wurfl_pbjs data +let tier; + // abTest stores A/B test configuration and variant (set by init) let abTest; @@ -1002,6 +1005,7 @@ const init = (config, userConsent) => { enrichmentType = ENRICHMENT_TYPE.NONE; wurflId = ''; samplingRate = DEFAULT_SAMPLING_RATE; + tier = ''; abTest = null; // A/B testing: set if enabled @@ -1074,6 +1078,9 @@ const getBidRequestData = (reqBidsConfigObj, callback, config, userConsent) => { // Store sampling rate for beacon samplingRate = cachedWurflData.wurfl_pbjs?.sampling_rate ?? DEFAULT_SAMPLING_RATE; + // Store tier for beacon + tier = cachedWurflData.wurfl_pbjs?.tier ?? ''; + // If expired, refresh cache async if (isExpired) { loadWurflJsAsync(config, bidders); @@ -1100,6 +1107,9 @@ const getBidRequestData = (reqBidsConfigObj, callback, config, userConsent) => { // Set default sampling rate for LCE samplingRate = DEFAULT_SAMPLING_RATE; + // Set default tier for LCE + tier = ''; + // Load WURFL.js async for future requests loadWurflJsAsync(config, bidders); @@ -1221,6 +1231,7 @@ function onAuctionEndEvent(auctionDetails, config, userConsent) { sampling_rate: samplingRate, enrichment: enrichmentType, wurfl_id: wurflId, + tier: tier, consent_class: consentClass, ad_units: adUnits }; From add110d7bacecdc84fb1b76971e995baff6d9b17 Mon Sep 17 00:00:00 2001 From: lucor Date: Tue, 11 Nov 2025 11:04:47 +0100 Subject: [PATCH 07/22] WURFL RTD: bump version to 2.0.0 --- modules/wurflRtdProvider.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/wurflRtdProvider.js b/modules/wurflRtdProvider.js index f8605faaeb2..f9200cb900b 100644 --- a/modules/wurflRtdProvider.js +++ b/modules/wurflRtdProvider.js @@ -12,7 +12,7 @@ import { getGlobal } from '../src/prebidGlobal.js'; // Constants const REAL_TIME_MODULE = 'realTimeData'; const MODULE_NAME = 'wurfl'; -const MODULE_VERSION = '2.0.0-beta6'; +const MODULE_VERSION = '2.0.0'; // WURFL_JS_HOST is the host for the WURFL service endpoints const WURFL_JS_HOST = 'https://prebid.wurflcloud.com'; From bbb9222e460967f59667e57d0b7f6637fc0cfffa Mon Sep 17 00:00:00 2001 From: lucor Date: Wed, 12 Nov 2025 16:42:42 +0100 Subject: [PATCH 08/22] WURFL RTD: refactor LCE device detection for robustness and error tracking Refactored WurflLCEDevice.FPD() to be more error-resistant and provide better enrichment type tracking for analytics. --- modules/wurflRtdProvider.js | 202 ++++++++++++--------- test/spec/modules/wurflRtdProvider_spec.js | 72 +++++++- 2 files changed, 189 insertions(+), 85 deletions(-) diff --git a/modules/wurflRtdProvider.js b/modules/wurflRtdProvider.js index f9200cb900b..9cdf0c5d06e 100644 --- a/modules/wurflRtdProvider.js +++ b/modules/wurflRtdProvider.js @@ -47,8 +47,10 @@ const ORTB2_DEVICE_FIELDS = [ // Enrichment type constants const ENRICHMENT_TYPE = { + UNKNOWN: 'unknown', NONE: 'none', LCE: 'lce', + LCE_ERROR: 'lce_error', WURFL_PUB: 'wurfl_pub', WURFL_SSP: 'wurfl_ssp', WURFL_PUB_SSP: 'wurfl_pub_ssp' @@ -182,6 +184,7 @@ function enrichDeviceBidder(reqBidsConfigObj, bidders, wjsDevice) { // Skip if no data to inject (over quota + unauthorized) if (Object.keys(bidderDevice).length === 0) { + bidderEnrichment.set(bidderCode, ENRICHMENT_TYPE.NONE); return; } @@ -726,6 +729,9 @@ const WurflJSDevice = { // ==================== WURFL LCE DEVICE MODULE ==================== const WurflLCEDevice = { + // Expected fields that LCE can provide (excludes 'ppi' which requires physical screen dimensions) + _expectedFields: ['devicetype', 'make', 'model', 'os', 'osv', 'hwv', 'h', 'w', 'pxratio', 'js'], + // Private mappings for device detection _desktopMapping: new Map([ ["Windows NT", "Windows"], @@ -781,17 +787,11 @@ const WurflLCEDevice = { switch (osName) { case "Windows": { const matches = ua.match(/Windows NT ([\d.]+)/); - if (matches) { - return matches[1]; - } - return ""; + return matches ? matches[1] : undefined; } case "macOS": { const matches = ua.match(/Mac OS X ([\d_]+)/); - if (matches) { - return matches[1].replaceAll('_', '.'); - } - return ""; + return matches ? matches[1].replace(/_/g, '.') : undefined; } case "iOS": { // iOS 26 specific logic @@ -801,10 +801,7 @@ const WurflLCEDevice = { } // iOS 18.x and lower const matches2 = ua.match(/iPhone; CPU iPhone OS ([\d_]+) like Mac OS X/); - if (matches2) { - return matches2[1].replaceAll('_', '.'); - } - return ""; + return matches2 ? matches2[1].replace(/_/g, '.') : undefined; } case "iPadOS": { // iOS 26 specific logic @@ -814,10 +811,7 @@ const WurflLCEDevice = { } // iOS 18.x and lower const matches2 = ua.match(/iPad; CPU OS ([\d_]+) like Mac OS X/); - if (matches2) { - return matches2[1].replaceAll('_', '.'); - } - return ""; + return matches2 ? matches2[1].replace(/_/g, '.') : undefined; } case "Android": { // For Android UAs with a decimal @@ -827,31 +821,19 @@ const WurflLCEDevice = { } // For Android UAs without a decimal const matches2 = ua.match(/Android ([\d]+)/); - if (matches2) { - return matches2[1]; - } - return ""; + return matches2 ? matches2[1] : undefined; } case "ChromeOS": { const matches = ua.match(/CrOS x86_64 ([\d.]+)/); - if (matches) { - return matches[1]; - } - return ""; + return matches ? matches[1] : undefined; } case "Tizen": { const matches = ua.match(/Tizen ([\d.]+)/); - if (matches) { - return matches[1]; - } - return ""; + return matches ? matches[1] : undefined; } case "Roku OS": { const matches = ua.match(/Roku\/DVP [\dA-Z]+ [\d.]+\/([\d.]+)/); - if (matches) { - return matches[1]; - } - return ""; + return matches ? matches[1] : undefined; } case "PlayStation OS": { // PS4 @@ -861,15 +843,12 @@ const WurflLCEDevice = { } // PS3 const matches2 = ua.match(/PLAYSTATION \d ([\d.]+)/); - if (matches2) { - return matches2[1]; - } - return ""; + return matches2 ? matches2[1] : undefined; } case "Linux": case "LG webOS": default: - return ""; + return undefined; } }, @@ -916,29 +895,34 @@ const WurflLCEDevice = { return { deviceType: "", osName: "", osVersion: "" }; }, - _getDevicePixelRatioValue(win = (typeof window !== "undefined" ? window : undefined)) { - if (!win) { - return 1; + _getDevicePixelRatioValue() { + if (window.devicePixelRatio) { + return window.devicePixelRatio; } - return ( - win.devicePixelRatio || - (win.screen.deviceXDPI / win.screen.logicalXDPI) || - Math.round(win.screen.availWidth / win.document.documentElement.clientWidth) - ); - }, - _getScreenWidth(win = (typeof window !== "undefined" ? window : undefined)) { - if (!win) { - return 0; + // Assumes window.screen exists (caller checked) + if (window.screen.deviceXDPI && window.screen.logicalXDPI && window.screen.logicalXDPI > 0) { + return window.screen.deviceXDPI / window.screen.logicalXDPI; } - return Math.round(win.screen.width * this._getDevicePixelRatioValue(win)); - }, - _getScreenHeight(win = (typeof window !== "undefined" ? window : undefined)) { - if (!win) { - return 0; + const screenWidth = window.screen.availWidth; + const docWidth = window.document?.documentElement?.clientWidth; + + if (screenWidth && docWidth && docWidth > 0) { + return Math.round(screenWidth / docWidth); } - return Math.round(win.screen.height * this._getDevicePixelRatioValue(win)); + + 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) { @@ -947,7 +931,7 @@ const WurflLCEDevice = { return brandName; } } - return 'Generic'; + return undefined; }, _getModel(ua) { @@ -956,34 +940,78 @@ const WurflLCEDevice = { return modelName; } } - return ''; + return undefined; }, // Public API - returns device object for First Party Data (global) FPD() { - const useragent = typeof window !== "undefined" ? window.navigator.userAgent : ""; - const deviceInfo = this._getDeviceInfo(useragent); + // Early exit - check window exists + if (typeof window === 'undefined') { + return { device: { js: 1 }, hasError: true }; + } - const win = typeof window !== "undefined" ? window : undefined; - const pixelRatio = this._getDevicePixelRatioValue(win); - const screenWidth = this._getScreenWidth(win); - const screenHeight = this._getScreenHeight(win); + // Check what globals are available upfront + const hasScreen = !!window.screen; + const hasNavigator = !!window.navigator; - const brand = this._getMake(useragent); - const model = this._getModel(useragent); + const device = { js: 1 }; - return { - devicetype: deviceInfo.deviceType, - make: brand, - model: model, - os: deviceInfo.osName, - osv: deviceInfo.osVersion, - hwv: model, - h: screenHeight, - w: screenWidth, - pxratio: pixelRatio, - js: 1 - }; + try { + const useragent = hasNavigator ? (window.navigator.userAgent || '') : ''; + + // 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; + } + } + } + } catch (e) { + logger.logError('Error generating LCE device data:', e); + return { device, hasError: true }; + } + + // Check if any expected field is missing + const hasError = this._expectedFields.some(field => device[field] === undefined); + + return { device, hasError }; } }; // ==================== END WURFL LCE DEVICE MODULE ==================== @@ -1002,7 +1030,7 @@ const init = (config, userConsent) => { // Initialize module state bidderEnrichment = new Map(); - enrichmentType = ENRICHMENT_TYPE.NONE; + enrichmentType = ENRICHMENT_TYPE.UNKNOWN; wurflId = ''; samplingRate = DEFAULT_SAMPLING_RATE; tier = ''; @@ -1038,7 +1066,7 @@ const getBidRequestData = (reqBidsConfigObj, callback, config, userConsent) => { reqBidsConfigObj.adUnits.forEach(adUnit => { adUnit.bids.forEach(bid => { bidders.add(bid.bidder); - bidderEnrichment.set(bid.bidder, ENRICHMENT_TYPE.NONE); + bidderEnrichment.set(bid.bidder, ENRICHMENT_TYPE.UNKNOWN); }); }); @@ -1046,6 +1074,7 @@ const getBidRequestData = (reqBidsConfigObj, callback, config, userConsent) => { 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; @@ -1069,6 +1098,8 @@ const getBidRequestData = (reqBidsConfigObj, callback, config, userConsent) => { if (!wjsDevice._isOverQuota()) { enrichDeviceFPD(reqBidsConfigObj, wjsDevice.FPD()); enrichmentType = ENRICHMENT_TYPE.WURFL_PUB; + } else { + enrichmentType = ENRICHMENT_TYPE.NONE; } enrichDeviceBidder(reqBidsConfigObj, bidders, wjsDevice); @@ -1095,14 +1126,17 @@ const getBidRequestData = (reqBidsConfigObj, callback, config, userConsent) => { logger.logMessage('generating fresh LCE data'); WurflDebugger.setDataSource('lce'); WurflDebugger.lceDetectionStart(); - const fpdDevice = WurflLCEDevice.FPD(); + const lceResult = WurflLCEDevice.FPD(); WurflDebugger.lceDetectionStop(); - WurflDebugger.setLceData(fpdDevice); - enrichDeviceFPD(reqBidsConfigObj, fpdDevice); + WurflDebugger.setLceData(lceResult.device); + enrichDeviceFPD(reqBidsConfigObj, lceResult.device); - // Set enrichment type to LCE + // Set enrichment type based on error status enrichmentType = ENRICHMENT_TYPE.LCE; - bidders.forEach(bidder => bidderEnrichment.set(bidder, ENRICHMENT_TYPE.LCE)); + if (lceResult.hasError) { + enrichmentType = ENRICHMENT_TYPE.LCE_ERROR; + } + bidders.forEach(bidder => bidderEnrichment.set(bidder, enrichmentType)); // Set default sampling rate for LCE samplingRate = DEFAULT_SAMPLING_RATE; diff --git a/test/spec/modules/wurflRtdProvider_spec.js b/test/spec/modules/wurflRtdProvider_spec.js index 4cbf18bd67e..3999c060ec4 100644 --- a/test/spec/modules/wurflRtdProvider_spec.js +++ b/test/spec/modules/wurflRtdProvider_spec.js @@ -1131,7 +1131,8 @@ describe('wurflRtdProvider', function () { const beaconCall = sendBeaconStub.getCall(0); const payload = JSON.parse(beaconCall.args[1]); expect(payload).to.have.property('sampling_rate', 100); - expect(payload).to.have.property('enrichment', 'lce'); + // Enrichment type can be 'lce' or 'lce_error' depending on what data is available + expect(payload.enrichment).to.be.oneOf(['lce', 'lce_error']); done(); }; @@ -1499,5 +1500,74 @@ describe('wurflRtdProvider', function () { wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, { params: {} }, {}); }); }); + + 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 empty user agent triggers error', (done) => { + const sendBeaconStub = sandbox.stub(ajaxModule, 'sendBeacon').returns(true); + + sandbox.stub(prebidGlobalModule, 'getGlobal').returns({ + getHighestCpmBids: () => [] + }); + + const callback = () => { + const device = reqBidsConfigObj.ortb2Fragments.global.device; + + // Should have minimal data + expect(device.js).to.equal(1); + + // UA-dependent fields should not be set when UA is empty + 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 lce_error 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', 'lce_error'); + + done(); + }; + + // Mock empty UA to trigger error + const originalUA = window.navigator.userAgent; + Object.defineProperty(window.navigator, 'userAgent', { + value: '', + configurable: true + }); + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, { params: {} }, {}); + + // Restore + Object.defineProperty(window.navigator, 'userAgent', { + value: originalUA, + configurable: true + }); + }); + }); }); }); From 3600160310e6664ab5442ddc61cd27d912d2209b Mon Sep 17 00:00:00 2001 From: lucor Date: Wed, 12 Nov 2025 17:05:40 +0100 Subject: [PATCH 09/22] WURFL RTD: update LCE_ERROR value --- modules/wurflRtdProvider.js | 2 +- test/spec/modules/wurflRtdProvider_spec.js | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/modules/wurflRtdProvider.js b/modules/wurflRtdProvider.js index 9cdf0c5d06e..6662628485a 100644 --- a/modules/wurflRtdProvider.js +++ b/modules/wurflRtdProvider.js @@ -50,7 +50,7 @@ const ENRICHMENT_TYPE = { UNKNOWN: 'unknown', NONE: 'none', LCE: 'lce', - LCE_ERROR: 'lce_error', + LCE_ERROR: 'lcefailed', WURFL_PUB: 'wurfl_pub', WURFL_SSP: 'wurfl_ssp', WURFL_PUB_SSP: 'wurfl_pub_ssp' diff --git a/test/spec/modules/wurflRtdProvider_spec.js b/test/spec/modules/wurflRtdProvider_spec.js index 3999c060ec4..21607cd65f0 100644 --- a/test/spec/modules/wurflRtdProvider_spec.js +++ b/test/spec/modules/wurflRtdProvider_spec.js @@ -1131,8 +1131,8 @@ describe('wurflRtdProvider', function () { 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 'lce_error' depending on what data is available - expect(payload.enrichment).to.be.oneOf(['lce', 'lce_error']); + // Enrichment type can be 'lce' or 'lcefailed' depending on what data is available + expect(payload.enrichment).to.be.oneOf(['lce', 'lcefailed']); done(); }; @@ -1501,8 +1501,8 @@ describe('wurflRtdProvider', function () { }); }); - describe('LCE Error Handling', function() { - beforeEach(function() { + 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); @@ -1544,11 +1544,11 @@ describe('wurflRtdProvider', function () { wurflSubmodule.onAuctionEndEvent(auctionDetails, { params: {} }, null); - // Check beacon was sent with lce_error enrichment type + // 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', 'lce_error'); + expect(payload).to.have.property('enrichment', 'lcefailed'); done(); }; From 64bc19c7565847a3e6e379dd1de49ebe93a3042e Mon Sep 17 00:00:00 2001 From: lucor Date: Wed, 12 Nov 2025 17:11:32 +0100 Subject: [PATCH 10/22] WURFL RTD: bump version to 2.0.1 --- modules/wurflRtdProvider.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/wurflRtdProvider.js b/modules/wurflRtdProvider.js index 6662628485a..766fcae50a1 100644 --- a/modules/wurflRtdProvider.js +++ b/modules/wurflRtdProvider.js @@ -12,7 +12,7 @@ import { getGlobal } from '../src/prebidGlobal.js'; // Constants const REAL_TIME_MODULE = 'realTimeData'; const MODULE_NAME = 'wurfl'; -const MODULE_VERSION = '2.0.0'; +const MODULE_VERSION = '2.0.1'; // WURFL_JS_HOST is the host for the WURFL service endpoints const WURFL_JS_HOST = 'https://prebid.wurflcloud.com'; From d869f355e455bd8a62592af5eefe17922d8be35c Mon Sep 17 00:00:00 2001 From: lucor Date: Fri, 31 Oct 2025 16:34:05 +0100 Subject: [PATCH 11/22] WURFL RTD: add is_robot detection --- modules/wurflRtdProvider.js | 49 +++++++- test/spec/modules/wurflRtdProvider_spec.js | 123 +++++++++++++++++++++ 2 files changed, 169 insertions(+), 3 deletions(-) diff --git a/modules/wurflRtdProvider.js b/modules/wurflRtdProvider.js index 766fcae50a1..9a6b83ca071 100644 --- a/modules/wurflRtdProvider.js +++ b/modules/wurflRtdProvider.js @@ -171,6 +171,20 @@ function enrichDeviceFPD(reqBidsConfigObj, deviceData) { mergeDeep(reqBidsConfigObj.ortb2Fragments.global, { device: enrichedDevice }); } +/** + * enrichDeviceExt enriches the global device.ext.wurfl object with extension data + * @param {Object} reqBidsConfigObj Bid request configuration object + * @param {Object} extData Extension data in format { device: { ext: { wurfl: {...} } } } + */ +function enrichDeviceExt(reqBidsConfigObj, extData) { + if (!extData || !reqBidsConfigObj?.ortb2Fragments?.global) { + return; + } + + // Use mergeDeep to properly merge ext.wurfl data into global device + mergeDeep(reqBidsConfigObj.ortb2Fragments.global, extData); +} + /** * enrichDeviceBidder enriches bidder-specific device data with WURFL data * @param {Object} reqBidsConfigObj Bid request configuration object @@ -527,8 +541,11 @@ const WurflDebugger = { window.WurflRtdDebug.data.pbjsData = pbjsData; }, - setLceData(lceDevice) { - window.WurflRtdDebug.data.lceDevice = lceDevice; + setLceData(lceDevice, extWurfl) { + window.WurflRtdDebug.data.lceDevice = { + ...lceDevice, + ext: extWurfl?.device?.ext + }; }, setCacheExpired(expired) { @@ -943,6 +960,16 @@ const WurflLCEDevice = { return undefined; }, + _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 @@ -1012,6 +1039,20 @@ const WurflLCEDevice = { const hasError = this._expectedFields.some(field => device[field] === undefined); return { device, hasError }; + }, + + // Public API - returns device.ext.wurfl object with LCE-detected capabilities + // Returns: { device: { ext: { wurfl: { is_robot: boolean } } } } + Ext() { + return { + device: { + ext: { + wurfl: { + is_robot: this._isRobot(navigator.userAgent) + } + } + } + }; } }; // ==================== END WURFL LCE DEVICE MODULE ==================== @@ -1127,9 +1168,11 @@ const getBidRequestData = (reqBidsConfigObj, callback, config, userConsent) => { WurflDebugger.setDataSource('lce'); WurflDebugger.lceDetectionStart(); const lceResult = WurflLCEDevice.FPD(); + const extWurfl = WurflLCEDevice.Ext(); WurflDebugger.lceDetectionStop(); - WurflDebugger.setLceData(lceResult.device); + WurflDebugger.setLceData(lceResult.device, extWurfl); enrichDeviceFPD(reqBidsConfigObj, lceResult.device); + enrichDeviceExt(reqBidsConfigObj, extWurfl); // Set enrichment type based on error status enrichmentType = ENRICHMENT_TYPE.LCE; diff --git a/test/spec/modules/wurflRtdProvider_spec.js b/test/spec/modules/wurflRtdProvider_spec.js index 21607cd65f0..476c96422ef 100644 --- a/test/spec/modules/wurflRtdProvider_spec.js +++ b/test/spec/modules/wurflRtdProvider_spec.js @@ -533,6 +533,11 @@ describe('wurflRtdProvider', function () { 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({}); @@ -556,6 +561,124 @@ describe('wurflRtdProvider', function () { expect(loadExternalScriptCall.args[2]).to.equal('wurfl'); }); + describe('LCE bot detection', () => { + let originalUserAgent; + + 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); + + reqBidsConfigObj.ortb2Fragments.global.device = {}; + reqBidsConfigObj.ortb2Fragments.bidder = {}; + + // Save original userAgent + originalUserAgent = navigator.userAgent; + }); + + afterEach(() => { + // Restore original userAgent + Object.defineProperty(navigator, 'userAgent', { + value: originalUserAgent, + configurable: true, + writable: true + }); + }); + + 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: {} }, {}); + }); + + 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 + }); + + 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 + }); + + const callback = () => { + expect(reqBidsConfigObj.ortb2Fragments.global.device.ext.wurfl.is_robot).to.be.false; + done(); + }; + + wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, { params: {} }, {}); + }); + }); + it('should enrich only bidders when over quota', (done) => { // Reset reqBidsConfigObj to clean state reqBidsConfigObj.ortb2Fragments.global.device = {}; From d55294ea74de5447c2b9562127222219b15fe5cf Mon Sep 17 00:00:00 2001 From: lucor Date: Thu, 13 Nov 2025 11:01:11 +0100 Subject: [PATCH 12/22] WURFL RTD: refactor LCE device detection for robustness and error tracking --- modules/wurflRtdProvider.js | 239 +++++++++++---------- test/spec/modules/wurflRtdProvider_spec.js | 19 +- 2 files changed, 136 insertions(+), 122 deletions(-) diff --git a/modules/wurflRtdProvider.js b/modules/wurflRtdProvider.js index 9a6b83ca071..02fa08c992d 100644 --- a/modules/wurflRtdProvider.js +++ b/modules/wurflRtdProvider.js @@ -746,71 +746,68 @@ const WurflJSDevice = { // ==================== WURFL LCE DEVICE MODULE ==================== const WurflLCEDevice = { - // Expected fields that LCE can provide (excludes 'ppi' which requires physical screen dimensions) - _expectedFields: ['devicetype', 'make', 'model', 'os', 'osv', 'hwv', 'h', 'w', 'pxratio', 'js'], - // 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"], + ['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"], + ['iPad; CPU OS ', 'iPadOS'], ]), _smartphoneMapping: new Map([ - ["Android", "Android"], - ["iPhone; CPU iPhone OS", "iOS"], + ['Android', 'Android'], + ['iPhone; CPU iPhone OS', 'iOS'], ]), _smarttvMapping: new Map([ - ["Web0S", "LG webOS"], - ["SMART-TV; Linux; Tizen", "Tizen"], + ['Web0S', 'LG webOS'], + ['SMART-TV; Linux; Tizen', 'Tizen'], ]), _ottMapping: new Map([ - ["Roku", "Roku OS"], - ["Xbox", "Windows"], - ["PLAYSTATION", "PlayStation OS"], - ["PlayStation", "PlayStation OS"], + ['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"], + ['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"], + ['Android', 'Android'], + ['iPad', 'iPad'], + ['iPhone', 'iPhone'], + ['Firefox', 'Firefox'], + ['Edge', 'Edge'], + ['Chrome', 'Chrome'], ]), // Private helper methods _parseOsVersion(ua, osName) { switch (osName) { - case "Windows": { + case 'Windows': { const matches = ua.match(/Windows NT ([\d.]+)/); - return matches ? matches[1] : undefined; + return matches ? matches[1] : ''; } - case "macOS": { + case 'macOS': { const matches = ua.match(/Mac OS X ([\d_]+)/); - return matches ? matches[1].replace(/_/g, '.') : undefined; + return matches ? matches[1].replace(/_/g, '.') : ''; } - case "iOS": { + 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) { @@ -818,9 +815,9 @@ const WurflLCEDevice = { } // iOS 18.x and lower const matches2 = ua.match(/iPhone; CPU iPhone OS ([\d_]+) like Mac OS X/); - return matches2 ? matches2[1].replace(/_/g, '.') : undefined; + return matches2 ? matches2[1].replace(/_/g, '.') : ''; } - case "iPadOS": { + 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) { @@ -828,9 +825,9 @@ const WurflLCEDevice = { } // iOS 18.x and lower const matches2 = ua.match(/iPad; CPU OS ([\d_]+) like Mac OS X/); - return matches2 ? matches2[1].replace(/_/g, '.') : undefined; + return matches2 ? matches2[1].replace(/_/g, '.') : ''; } - case "Android": { + case 'Android': { // For Android UAs with a decimal const matches1 = ua.match(/Android ([\d.]+)/); if (matches1) { @@ -838,21 +835,21 @@ const WurflLCEDevice = { } // For Android UAs without a decimal const matches2 = ua.match(/Android ([\d]+)/); - return matches2 ? matches2[1] : undefined; + return matches2 ? matches2[1] : ''; } - case "ChromeOS": { + case 'ChromeOS': { const matches = ua.match(/CrOS x86_64 ([\d.]+)/); - return matches ? matches[1] : undefined; + return matches ? matches[1] : ''; } - case "Tizen": { + case 'Tizen': { const matches = ua.match(/Tizen ([\d.]+)/); - return matches ? matches[1] : undefined; + return matches ? matches[1] : ''; } - case "Roku OS": { + case 'Roku OS': { const matches = ua.match(/Roku\/DVP [\dA-Z]+ [\d.]+\/([\d.]+)/); - return matches ? matches[1] : undefined; + return matches ? matches[1] : ''; } - case "PlayStation OS": { + case 'PlayStation OS': { // PS4 const matches1 = ua.match(/PlayStation \d\/([\d.]+)/); if (matches1) { @@ -860,12 +857,12 @@ const WurflLCEDevice = { } // PS3 const matches2 = ua.match(/PLAYSTATION \d ([\d.]+)/); - return matches2 ? matches2[1] : undefined; + return matches2 ? matches2[1] : ''; } - case "Linux": - case "LG webOS": + case 'Linux': + case 'LG webOS': default: - return undefined; + return ''; } }, @@ -894,7 +891,7 @@ const WurflLCEDevice = { } } // Android Tablets - if (ua.includes("Android") && !ua.includes("Mobile Safari") && ua.includes("Safari")) { + if (ua.includes('Android') && !ua.includes('Mobile Safari') && ua.includes('Safari')) { return this._makeDeviceInfo(ORTB2_DEVICE_TYPE.TABLET, 'Android', ua); } // Iterate over smartphoneMapping @@ -909,7 +906,7 @@ const WurflLCEDevice = { return this._makeDeviceInfo(ORTB2_DEVICE_TYPE.CONNECTED_TV, osName, ua); } } - return { deviceType: "", osName: "", osVersion: "" }; + return { deviceType: '', osName: '', osVersion: '' }; }, _getDevicePixelRatioValue() { @@ -948,7 +945,7 @@ const WurflLCEDevice = { return brandName; } } - return undefined; + return 'Generic'; }, _getModel(ua) { @@ -957,7 +954,11 @@ const WurflLCEDevice = { return modelName; } } - return undefined; + return ''; + }, + + _getUserAgent() { + return window.navigator?.userAgent || ''; }, _isRobot(useragent) { @@ -974,81 +975,81 @@ const WurflLCEDevice = { FPD() { // Early exit - check window exists if (typeof window === 'undefined') { - return { device: { js: 1 }, hasError: true }; + return { js: 1 }; } // Check what globals are available upfront const hasScreen = !!window.screen; - const hasNavigator = !!window.navigator; 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; + } - try { - const useragent = hasNavigator ? (window.navigator.userAgent || '') : ''; - - // 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; - } + // 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; - } + 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; + // 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 width = this._getScreenWidth(pixelRatio); + if (width !== undefined) { + device.w = width; + } - const height = this._getScreenHeight(pixelRatio); - if (height !== undefined) { - device.h = height; - } + const height = this._getScreenHeight(pixelRatio); + if (height !== undefined) { + device.h = height; } } - } catch (e) { - logger.logError('Error generating LCE device data:', e); - return { device, hasError: true }; } - // Check if any expected field is missing - const hasError = this._expectedFields.some(field => device[field] === undefined); - - return { device, hasError }; + return device; }, // Public API - returns device.ext.wurfl object with LCE-detected capabilities // Returns: { device: { ext: { wurfl: { is_robot: boolean } } } } Ext() { + // Early exit - check window exists + if (typeof window === 'undefined') { + return { device: { ext: { wurfl: {} } } }; + } + + const useragent = this._getUserAgent(); + if (!useragent) { + return { device: { ext: { wurfl: {} } } }; + } + return { device: { ext: { wurfl: { - is_robot: this._isRobot(navigator.userAgent) + is_robot: this._isRobot(useragent) } } } @@ -1167,18 +1168,26 @@ const getBidRequestData = (reqBidsConfigObj, callback, config, userConsent) => { logger.logMessage('generating fresh LCE data'); WurflDebugger.setDataSource('lce'); WurflDebugger.lceDetectionStart(); - const lceResult = WurflLCEDevice.FPD(); - const extWurfl = WurflLCEDevice.Ext(); - WurflDebugger.lceDetectionStop(); - WurflDebugger.setLceData(lceResult.device, extWurfl); - enrichDeviceFPD(reqBidsConfigObj, lceResult.device); - enrichDeviceExt(reqBidsConfigObj, extWurfl); - // Set enrichment type based on error status - enrichmentType = ENRICHMENT_TYPE.LCE; - if (lceResult.hasError) { + let lceDevice; + let extWurfl; + try { + lceDevice = WurflLCEDevice.FPD(); + extWurfl = WurflLCEDevice.Ext(); + enrichmentType = ENRICHMENT_TYPE.LCE; + } catch (e) { + logger.logError('Error generating LCE device data:', e); + lceDevice = { js: 1 }; + extWurfl = { device: { ext: { wurfl: {} } } }; enrichmentType = ENRICHMENT_TYPE.LCE_ERROR; } + + WurflDebugger.lceDetectionStop(); + WurflDebugger.setLceData(lceDevice, extWurfl); + enrichDeviceFPD(reqBidsConfigObj, lceDevice); + enrichDeviceExt(reqBidsConfigObj, extWurfl); + + // Set enrichment type for all bidders bidders.forEach(bidder => bidderEnrichment.set(bidder, enrichmentType)); // Set default sampling rate for LCE diff --git a/test/spec/modules/wurflRtdProvider_spec.js b/test/spec/modules/wurflRtdProvider_spec.js index 476c96422ef..6e2a6342f78 100644 --- a/test/spec/modules/wurflRtdProvider_spec.js +++ b/test/spec/modules/wurflRtdProvider_spec.js @@ -1635,20 +1635,23 @@ describe('wurflRtdProvider', function () { reqBidsConfigObj.ortb2Fragments.bidder = {}; }); - it('should set LCE_ERROR enrichment type when empty user agent triggers error', (done) => { + 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 data + // Should have minimal fallback data expect(device.js).to.equal(1); - // UA-dependent fields should not be set when UA is empty + // UA-dependent fields should not be set when error occurs expect(device.devicetype).to.be.undefined; expect(device.os).to.be.undefined; @@ -1676,10 +1679,12 @@ describe('wurflRtdProvider', function () { done(); }; - // Mock empty UA to trigger error - const originalUA = window.navigator.userAgent; + // Stub _getDeviceInfo to throw an error + const originalGetDeviceInfo = window.navigator.userAgent; Object.defineProperty(window.navigator, 'userAgent', { - value: '', + get: () => { + throw new Error('User agent access failed'); + }, configurable: true }); @@ -1687,7 +1692,7 @@ describe('wurflRtdProvider', function () { // Restore Object.defineProperty(window.navigator, 'userAgent', { - value: originalUA, + value: originalGetDeviceInfo, configurable: true }); }); From 1c70161eb698c1862609c112b915c7ba7351e984 Mon Sep 17 00:00:00 2001 From: lucor Date: Fri, 14 Nov 2025 16:18:08 +0100 Subject: [PATCH 13/22] WURFL RTD: initialize bidder fragments if not already present --- modules/wurflRtdProvider.js | 5 +- test/spec/modules/wurflRtdProvider_spec.js | 61 ++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/modules/wurflRtdProvider.js b/modules/wurflRtdProvider.js index 02fa08c992d..514a70b9cb2 100644 --- a/modules/wurflRtdProvider.js +++ b/modules/wurflRtdProvider.js @@ -12,7 +12,7 @@ import { getGlobal } from '../src/prebidGlobal.js'; // Constants const REAL_TIME_MODULE = 'realTimeData'; const MODULE_NAME = 'wurfl'; -const MODULE_VERSION = '2.0.1'; +const MODULE_VERSION = '2.0.2'; // WURFL_JS_HOST is the host for the WURFL service endpoints const WURFL_JS_HOST = 'https://prebid.wurflcloud.com'; @@ -192,6 +192,9 @@ function enrichDeviceExt(reqBidsConfigObj, extData) { * @param {WurflJSDevice} wjsDevice WURFL.js device data with permissions and caps */ function enrichDeviceBidder(reqBidsConfigObj, bidders, wjsDevice) { + // Initialize bidder fragments if not already present + reqBidsConfigObj.ortb2Fragments.bidder = reqBidsConfigObj.ortb2Fragments.bidder || {}; + bidders.forEach((bidderCode) => { // Get bidder data (handles both authorized and unauthorized bidders) const bidderDevice = wjsDevice.Bidder(bidderCode); diff --git a/test/spec/modules/wurflRtdProvider_spec.js b/test/spec/modules/wurflRtdProvider_spec.js index 6e2a6342f78..f9e7d624edb 100644 --- a/test/spec/modules/wurflRtdProvider_spec.js +++ b/test/spec/modules/wurflRtdProvider_spec.js @@ -741,6 +741,67 @@ describe('wurflRtdProvider', function () { wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, config, userConsent); }); + 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 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 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({}); + + // bidder1 and bidder2 are authorized, should get full device + ext.wurfl + 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 + }); + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder1.device.ext.wurfl).to.deep.equal(WURFL); + + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder2).to.exist; + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder2.device).to.deep.include({ + make: 'Google', + model: 'Nexus 5', + devicetype: 4 + }); + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder2.device.ext.wurfl).to.deep.equal(WURFL); + + // 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 pass basic+pub caps to unauthorized bidders when under quota', (done) => { // Reset reqBidsConfigObj to clean state reqBidsConfigObj.ortb2Fragments.global.device = {}; From a36a7a8c628fccff7df0913c369e15adaf5ecb56 Mon Sep 17 00:00:00 2001 From: lucor Date: Mon, 17 Nov 2025 12:45:53 +0100 Subject: [PATCH 14/22] WURFL RTD: fix ortb2Fragments.bidder merge for Prebid 10.x compatibility --- modules/wurflRtdProvider.js | 10 ++-- test/spec/modules/wurflRtdProvider_spec.js | 61 ++++++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/modules/wurflRtdProvider.js b/modules/wurflRtdProvider.js index 514a70b9cb2..9a28840019f 100644 --- a/modules/wurflRtdProvider.js +++ b/modules/wurflRtdProvider.js @@ -192,8 +192,10 @@ function enrichDeviceExt(reqBidsConfigObj, extData) { * @param {WurflJSDevice} wjsDevice WURFL.js device data with permissions and caps */ function enrichDeviceBidder(reqBidsConfigObj, bidders, wjsDevice) { - // Initialize bidder fragments if not already present - reqBidsConfigObj.ortb2Fragments.bidder = reqBidsConfigObj.ortb2Fragments.bidder || {}; + // Initialize bidder fragments if not present + if (!reqBidsConfigObj.ortb2Fragments.bidder) { + reqBidsConfigObj.ortb2Fragments.bidder = {}; + } bidders.forEach((bidderCode) => { // Get bidder data (handles both authorized and unauthorized bidders) @@ -213,7 +215,9 @@ function enrichDeviceBidder(reqBidsConfigObj, bidders, wjsDevice) { } // Inject WURFL data - mergeDeep(reqBidsConfigObj.ortb2Fragments.bidder, { [bidderCode]: bidderDevice }); + const bd = reqBidsConfigObj.ortb2Fragments.bidder[bidderCode] || {}; + mergeDeep(bd, bidderDevice); + reqBidsConfigObj.ortb2Fragments.bidder[bidderCode] = bd; }); } diff --git a/test/spec/modules/wurflRtdProvider_spec.js b/test/spec/modules/wurflRtdProvider_spec.js index f9e7d624edb..4e5998543fa 100644 --- a/test/spec/modules/wurflRtdProvider_spec.js +++ b/test/spec/modules/wurflRtdProvider_spec.js @@ -5,6 +5,7 @@ import { 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'; describe('wurflRtdProvider', function () { describe('wurflSubmodule', function () { @@ -802,6 +803,66 @@ describe('wurflRtdProvider', function () { wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, config, userConsent); }); + 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 plainReqBidsConfigObj = { + adUnits: [{ + bids: [ + { bidder: 'bidder1' }, + { bidder: 'bidder2' } + ] + }], + ortb2Fragments: plainFragments + }; + + // 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 = () => { + // Verify bidder1 (authorized) got enriched data + expect(plainFragments.bidder.bidder1).to.exist; + expect(plainFragments.bidder.bidder1.device).to.exist; + expect(plainFragments.bidder.bidder1.device.ext).to.exist; + expect(plainFragments.bidder.bidder1.device.ext.wurfl).to.deep.equal(WURFL); + + // 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; + expect(plainFragments.bidder.bidder2.device.ext.wurfl).to.deep.equal(WURFL); + + done(); + }; + + const config = { params: {} }; + const userConsent = {}; + + wurflSubmodule.getBidRequestData(guardedReqBidsConfigObj, callback, config, userConsent); + }); + it('should pass basic+pub caps to unauthorized bidders when under quota', (done) => { // Reset reqBidsConfigObj to clean state reqBidsConfigObj.ortb2Fragments.global.device = {}; From 912f36126c20cabd53d5ebf34973660c9bdea123 Mon Sep 17 00:00:00 2001 From: lucor Date: Mon, 17 Nov 2025 12:46:49 +0100 Subject: [PATCH 15/22] WURFL RTD: bump version to 2.0.3 --- modules/wurflRtdProvider.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/wurflRtdProvider.js b/modules/wurflRtdProvider.js index 9a28840019f..cea49ca6617 100644 --- a/modules/wurflRtdProvider.js +++ b/modules/wurflRtdProvider.js @@ -12,7 +12,7 @@ import { getGlobal } from '../src/prebidGlobal.js'; // Constants const REAL_TIME_MODULE = 'realTimeData'; const MODULE_NAME = 'wurfl'; -const MODULE_VERSION = '2.0.2'; +const MODULE_VERSION = '2.0.3'; // WURFL_JS_HOST is the host for the WURFL service endpoints const WURFL_JS_HOST = 'https://prebid.wurflcloud.com'; From acdc13c7de8570b2423394c04cca35027eef22bf Mon Sep 17 00:00:00 2001 From: lucor Date: Mon, 17 Nov 2025 16:53:03 +0100 Subject: [PATCH 16/22] WURFL RTD: beacon add over_quota and rename bidder.enrichment to bidder.bdr_enrich --- modules/wurflRtdProvider.js | 20 +++++++++++++++++--- test/spec/modules/wurflRtdProvider_spec.js | 18 +++++++++++------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/modules/wurflRtdProvider.js b/modules/wurflRtdProvider.js index cea49ca6617..20cbfad1c17 100644 --- a/modules/wurflRtdProvider.js +++ b/modules/wurflRtdProvider.js @@ -12,7 +12,7 @@ import { getGlobal } from '../src/prebidGlobal.js'; // Constants const REAL_TIME_MODULE = 'realTimeData'; const MODULE_NAME = 'wurfl'; -const MODULE_VERSION = '2.0.3'; +const MODULE_VERSION = '2.0.4'; // WURFL_JS_HOST is the host for the WURFL service endpoints const WURFL_JS_HOST = 'https://prebid.wurflcloud.com'; @@ -66,6 +66,9 @@ const CONSENT_CLASS = { // 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', @@ -97,6 +100,9 @@ let samplingRate; // tier stores the WURFL tier from wurfl_pbjs data let tier; +// 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; @@ -1083,6 +1089,7 @@ const init = (config, userConsent) => { wurflId = ''; samplingRate = DEFAULT_SAMPLING_RATE; tier = ''; + overQuota = DEFAULT_OVER_QUOTA; abTest = null; // A/B testing: set if enabled @@ -1161,6 +1168,9 @@ const getBidRequestData = (reqBidsConfigObj, callback, config, userConsent) => { // 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); @@ -1203,6 +1213,9 @@ const getBidRequestData = (reqBidsConfigObj, callback, config, userConsent) => { // 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); @@ -1281,7 +1294,7 @@ function onAuctionEndEvent(auctionDetails, config, userConsent) { const isWinner = winningBidIds[bidResponse.requestId] === true; bidders.push({ bidder: bidderCode, - enrichment: bidderEnrichment.get(bidderCode), + bdr_enrich: bidderEnrichment.get(bidderCode), cpm: bidResponse.cpm, currency: bidResponse.currency, won: isWinner @@ -1290,7 +1303,7 @@ function onAuctionEndEvent(auctionDetails, config, userConsent) { // Bidder didn't respond - include without cpm/currency bidders.push({ bidder: bidderCode, - enrichment: bidderEnrichment.get(bidderCode), + bdr_enrich: bidderEnrichment.get(bidderCode), won: false }); } @@ -1325,6 +1338,7 @@ function onAuctionEndEvent(auctionDetails, config, userConsent) { enrichment: enrichmentType, wurfl_id: wurflId, tier: tier, + over_quota: overQuota, consent_class: consentClass, ad_units: adUnits }; diff --git a/test/spec/modules/wurflRtdProvider_spec.js b/test/spec/modules/wurflRtdProvider_spec.js index 4e5998543fa..148261ade53 100644 --- a/test/spec/modules/wurflRtdProvider_spec.js +++ b/test/spec/modules/wurflRtdProvider_spec.js @@ -978,6 +978,7 @@ describe('wurflRtdProvider', function () { 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); @@ -985,14 +986,14 @@ describe('wurflRtdProvider', function () { 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', - enrichment: 'wurfl_ssp', + bdr_enrich: 'wurfl_ssp', cpm: 1.5, currency: 'USD', won: true }); expect(payload.ad_units[0].bidders[1]).to.deep.include({ bidder: 'bidder2', - enrichment: 'wurfl_ssp', + bdr_enrich: 'wurfl_ssp', cpm: 1.2, currency: 'USD', won: false @@ -1068,6 +1069,7 @@ describe('wurflRtdProvider', function () { 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); @@ -1439,6 +1441,7 @@ describe('wurflRtdProvider', function () { // 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); @@ -1447,14 +1450,14 @@ describe('wurflRtdProvider', function () { // bidder1 and bidder2 are authorized - should report wurfl_ssp expect(payload.ad_units[0].bidders[0]).to.deep.include({ bidder: 'bidder1', - enrichment: 'wurfl_ssp', + bdr_enrich: 'wurfl_ssp', cpm: 1.5, currency: 'USD', won: false }); expect(payload.ad_units[0].bidders[1]).to.deep.include({ bidder: 'bidder2', - enrichment: 'wurfl_ssp', + bdr_enrich: 'wurfl_ssp', cpm: 1.2, currency: 'USD', won: false @@ -1463,7 +1466,7 @@ describe('wurflRtdProvider', function () { // bidder3 is NOT authorized and overquota - should report none expect(payload.ad_units[0].bidders[2]).to.deep.include({ bidder: 'bidder3', - enrichment: 'none', + bdr_enrich: 'none', won: false }); @@ -1512,6 +1515,7 @@ describe('wurflRtdProvider', function () { // 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); @@ -1520,7 +1524,7 @@ describe('wurflRtdProvider', function () { // bidder1 is authorized - should always report wurfl_ssp expect(payload.ad_units[0].bidders[0]).to.deep.include({ bidder: 'bidder1', - enrichment: 'wurfl_ssp', + bdr_enrich: 'wurfl_ssp', cpm: 1.5, currency: 'USD', won: false @@ -1529,7 +1533,7 @@ describe('wurflRtdProvider', function () { // bidder3 is NOT authorized but not overquota - should report wurfl_pub expect(payload.ad_units[0].bidders[1]).to.deep.include({ bidder: 'bidder3', - enrichment: 'wurfl_pub', + bdr_enrich: 'wurfl_pub', cpm: 1.0, currency: 'USD', won: false From b06de62574092d29c4dc441e136a25f56a2a9c6d Mon Sep 17 00:00:00 2001 From: lucor Date: Tue, 25 Nov 2025 10:36:48 +0100 Subject: [PATCH 17/22] WURFL RTD: improve debug logging --- modules/wurflRtdProvider.js | 58 +++++++++++++++++++++++-------------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/modules/wurflRtdProvider.js b/modules/wurflRtdProvider.js index 20cbfad1c17..8cc5152f81b 100644 --- a/modules/wurflRtdProvider.js +++ b/modules/wurflRtdProvider.js @@ -60,7 +60,8 @@ const ENRICHMENT_TYPE = { const CONSENT_CLASS = { NO: 0, // No consent/opt-out/COPPA PARTIAL: 1, // Partial or ambiguous - FULL: 2 // Full consent or non-GDPR region + FULL: 2, // Full consent or non-GDPR region + ERROR: -1 // Error computing consent }; // Default sampling rate constant @@ -262,7 +263,6 @@ function loadWurflJsAsync(config, bidders) { const loadWurflJs = (scriptUrl) => { try { loadExternalScript(scriptUrl, MODULE_TYPE_RTD, MODULE_NAME, () => { - logger.logMessage('async WURFL.js script injected'); window.WURFLPromises.complete.then((res) => { logger.logMessage('async WURFL.js data received', res); if (res.wurfl_pbjs) { @@ -1102,7 +1102,10 @@ const init = (config, userConsent) => { logger.logMessage(`A/B test "${abName}": user in ${abVariant} group`); } - logger.logMessage('initialized'); + logger.logMessage('initialized', { + version: MODULE_VERSION, + abTest: abTest ? `${abTest.ab_name}:${abTest.ab_variant}` : 'disabled' + }); return true; } @@ -1148,8 +1151,6 @@ const getBidRequestData = (reqBidsConfigObj, callback, config, userConsent) => { WurflDebugger.setCacheExpired(isExpired); WurflDebugger.setCacheData(cachedWurflData.WURFL, cachedWurflData.wurfl_pbjs); - logger.logMessage(isExpired ? 'using expired cached WURFL.js data' : 'using cached WURFL.js data'); - const wjsDevice = WurflJSDevice.fromCache(cachedWurflData); if (!wjsDevice._isOverQuota()) { enrichDeviceFPD(reqBidsConfigObj, wjsDevice.FPD()); @@ -1176,13 +1177,20 @@ const getBidRequestData = (reqBidsConfigObj, callback, config, userConsent) => { loadWurflJsAsync(config, bidders); } + logger.logMessage('enrichment completed', { + type: enrichmentType, + dataSource: 'cache', + cacheExpired: isExpired, + bidders: Object.fromEntries(bidderEnrichment), + totalBidders: bidderEnrichment.size + }); + WurflDebugger.moduleExecutionStop(); callback(); return; } // Priority 2: return LCE data - logger.logMessage('generating fresh LCE data'); WurflDebugger.setDataSource('lce'); WurflDebugger.lceDetectionStart(); @@ -1219,6 +1227,13 @@ const getBidRequestData = (reqBidsConfigObj, callback, config, userConsent) => { // 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(); } @@ -1246,13 +1261,21 @@ function onAuctionEndEvent(auctionDetails, config, userConsent) { const url = new URL(host); url.pathname = STATS_ENDPOINT_PATH; + // Calculate consent class + let consentClass; + try { + consentClass = getConsentClass(userConsent); + } 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; } - logger.logMessage(`onAuctionEndEvent: processing ${auctionDetails.bidsReceived.length} bid responses`); - // Build a lookup object for winning bid request IDs const winningBids = getGlobal().getHighestCpmBids() || []; const winningBidIds = {}; @@ -1261,8 +1284,6 @@ function onAuctionEndEvent(auctionDetails, config, userConsent) { winningBidIds[bid.requestId] = true; } - logger.logMessage(`onAuctionEndEvent: ${winningBids.length} winning bids identified`); - // Build a lookup object for bid responses: "adUnitCode:bidderCode" -> bid const bidResponseMap = {}; for (let i = 0; i < auctionDetails.bidsReceived.length; i++) { @@ -1316,18 +1337,11 @@ function onAuctionEndEvent(auctionDetails, config, userConsent) { } } - // Count bidders for logging - let totalBidderEntries = 0; - for (let i = 0; i < adUnits.length; i++) { - totalBidderEntries += adUnits[i].bidders.length; - } - const respondedBidders = auctionDetails.bidsReceived.length; - const nonRespondingBidders = totalBidderEntries - respondedBidders; - - logger.logMessage(`onAuctionEndEvent: built ${adUnits.length} ad units with ${totalBidderEntries} total bidder entries (${respondedBidders} responded, ${nonRespondingBidders} non-responding)`); - - // Calculate consent class - const consentClass = getConsentClass(userConsent); + logger.logMessage('auction completed', { + bidsReceived: auctionDetails.bidsReceived.length, + bidsWon: winningBids.length, + adUnits: adUnits.length + }); // Build complete payload const payloadData = { From 6967895be446c9a84db53037ce424102500b019c Mon Sep 17 00:00:00 2001 From: lucor Date: Tue, 25 Nov 2025 10:39:49 +0100 Subject: [PATCH 18/22] WURFL RTD: bump version to 2.0.5 --- modules/wurflRtdProvider.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/wurflRtdProvider.js b/modules/wurflRtdProvider.js index 8cc5152f81b..b8e549d1409 100644 --- a/modules/wurflRtdProvider.js +++ b/modules/wurflRtdProvider.js @@ -12,7 +12,7 @@ import { getGlobal } from '../src/prebidGlobal.js'; // Constants const REAL_TIME_MODULE = 'realTimeData'; const MODULE_NAME = 'wurfl'; -const MODULE_VERSION = '2.0.4'; +const MODULE_VERSION = '2.0.5'; // WURFL_JS_HOST is the host for the WURFL service endpoints const WURFL_JS_HOST = 'https://prebid.wurflcloud.com'; From 05342c088818fc7a5bf4a962d15b7db7cb9f1351 Mon Sep 17 00:00:00 2001 From: lucor Date: Tue, 25 Nov 2025 12:28:25 +0100 Subject: [PATCH 19/22] WURFL RTD: use global debug flag instead of params.debug --- modules/wurflRtdProvider.js | 15 ++--- modules/wurflRtdProvider.md | 68 +++++++++++----------- test/spec/modules/wurflRtdProvider_spec.js | 40 ++++++++----- 3 files changed, 68 insertions(+), 55 deletions(-) diff --git a/modules/wurflRtdProvider.js b/modules/wurflRtdProvider.js index b8e549d1409..d1013f9891f 100644 --- a/modules/wurflRtdProvider.js +++ b/modules/wurflRtdProvider.js @@ -4,6 +4,7 @@ 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'; @@ -12,7 +13,7 @@ import { getGlobal } from '../src/prebidGlobal.js'; // Constants const REAL_TIME_MODULE = 'realTimeData'; const MODULE_NAME = 'wurfl'; -const MODULE_VERSION = '2.0.5'; +const MODULE_VERSION = '2.0.6'; // WURFL_JS_HOST is the host for the WURFL service endpoints const WURFL_JS_HOST = 'https://prebid.wurflcloud.com'; @@ -235,7 +236,7 @@ function enrichDeviceBidder(reqBidsConfigObj, bidders, wjsDevice) { */ function loadWurflJsAsync(config, bidders) { const altHost = config.params?.altHost ?? null; - const isDebug = config.params?.debug ?? false; + const isDebug = debugTurnedOn(); let host = WURFL_JS_HOST; if (altHost) { @@ -423,8 +424,8 @@ const WurflDebugger = { _wurflJsLoadStart: null, // Initialize WURFL debug tracking - init(isDebug) { - if (!isDebug) { + 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') { @@ -1079,9 +1080,8 @@ const WurflLCEDevice = { * @param {Object} userConsent User consent data */ const init = (config, userConsent) => { - // Initialize debugger based on debug flag - const isDebug = config?.params?.debug ?? false; - WurflDebugger.init(isDebug); + // Initialize debugger based on global debug flag + WurflDebugger.init(); // Initialize module state bidderEnrichment = new Map(); @@ -1265,6 +1265,7 @@ function onAuctionEndEvent(auctionDetails, config, userConsent) { let consentClass; try { consentClass = getConsentClass(userConsent); + logger.logMessage('consent class', consentClass); } catch (e) { logger.logError('Error calculating consent class:', e); consentClass = CONSENT_CLASS.ERROR; diff --git a/modules/wurflRtdProvider.md b/modules/wurflRtdProvider.md index 682f7fdaab9..1916acbe4fb 100644 --- a/modules/wurflRtdProvider.md +++ b/modules/wurflRtdProvider.md @@ -20,6 +20,7 @@ WURFL.js is fully compatible with Chromium's User-Agent Client Hints (UA-CH) ini ## Usage ### Build + ``` gulp build --modules="wurflRtdProvider,appnexusBidAdapter,..." ``` @@ -33,31 +34,28 @@ 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` | -| 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` | +| 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 @@ -65,23 +63,25 @@ The WURFL RTD module supports A/B testing to measure the impact of WURFL enrichm ```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 - } - }] - } + 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 +- **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 diff --git a/test/spec/modules/wurflRtdProvider_spec.js b/test/spec/modules/wurflRtdProvider_spec.js index 148261ade53..57a431daf7c 100644 --- a/test/spec/modules/wurflRtdProvider_spec.js +++ b/test/spec/modules/wurflRtdProvider_spec.js @@ -6,11 +6,17 @@ 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 = { 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, @@ -290,23 +296,28 @@ describe('wurflRtdProvider', function () { afterEach(() => { // Clean up window object after each test delete window.WurflRtdDebug; + // Reset global config + config.resetConfig(); }); - it('should not create window.WurflRtdDebug when debug=false', () => { - const config = { params: { debug: false } }; - wurflSubmodule.init(config); + 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 debug is not configured', () => { - const config = { params: {} }; - wurflSubmodule.init(config); + 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 debug=true', () => { - const config = { params: { debug: true } }; - wurflSubmodule.init(config); + 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; @@ -523,10 +534,12 @@ describe('wurflRtdProvider', function () { 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('mode', 'prebid2'); expectedURL.searchParams.set('bidders', 'bidder1,bidder2,bidder3'); const callback = () => { @@ -545,15 +558,14 @@ describe('wurflRtdProvider', function () { 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; From 518898f23c8fff1e0e4c6f0f0d82c7c2dcc14ded Mon Sep 17 00:00:00 2001 From: lucor Date: Wed, 26 Nov 2025 16:58:03 +0100 Subject: [PATCH 20/22] WURFL RTD: optimize payload by moving basic+pub caps to global Optimize ortb2Fragments payload size by enriching global.device.ext.wurfl with basic+publisher capabilities when under quota, instead of duplicating them in every bidder fragment. --- modules/wurflRtdProvider.js | 127 ++++++------- test/spec/modules/wurflRtdProvider_spec.js | 209 +++++++++++++++++---- 2 files changed, 235 insertions(+), 101 deletions(-) diff --git a/modules/wurflRtdProvider.js b/modules/wurflRtdProvider.js index d1013f9891f..e91edd5d95d 100644 --- a/modules/wurflRtdProvider.js +++ b/modules/wurflRtdProvider.js @@ -13,7 +13,7 @@ import { getGlobal } from '../src/prebidGlobal.js'; // Constants const REAL_TIME_MODULE = 'realTimeData'; const MODULE_NAME = 'wurfl'; -const MODULE_VERSION = '2.0.6'; +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'; @@ -175,22 +175,13 @@ function enrichDeviceFPD(reqBidsConfigObj, deviceData) { enrichedDevice[field] = deviceData[field]; }); - // Use mergeDeep to properly merge into global device - mergeDeep(reqBidsConfigObj.ortb2Fragments.global, { device: enrichedDevice }); -} - -/** - * enrichDeviceExt enriches the global device.ext.wurfl object with extension data - * @param {Object} reqBidsConfigObj Bid request configuration object - * @param {Object} extData Extension data in format { device: { ext: { wurfl: {...} } } } - */ -function enrichDeviceExt(reqBidsConfigObj, extData) { - if (!extData || !reqBidsConfigObj?.ortb2Fragments?.global) { - return; + // Also copy ext field if present (contains ext.wurfl capabilities) + if (deviceData.ext) { + enrichedDevice.ext = deviceData.ext; } - // Use mergeDeep to properly merge ext.wurfl data into global device - mergeDeep(reqBidsConfigObj.ortb2Fragments.global, extData); + // Use mergeDeep to properly merge into global device + mergeDeep(reqBidsConfigObj.ortb2Fragments.global, { device: enrichedDevice }); } /** @@ -205,24 +196,32 @@ function enrichDeviceBidder(reqBidsConfigObj, bidders, wjsDevice) { reqBidsConfigObj.ortb2Fragments.bidder = {}; } + const isOverQuota = wjsDevice._isOverQuota(); + bidders.forEach((bidderCode) => { - // Get bidder data (handles both authorized and unauthorized bidders) - const bidderDevice = wjsDevice.Bidder(bidderCode); + const isAuthorized = wjsDevice._isAuthorized(bidderCode); - // Skip if no data to inject (over quota + unauthorized) - if (Object.keys(bidderDevice).length === 0) { - bidderEnrichment.set(bidderCode, ENRICHMENT_TYPE.NONE); + 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; } - // Set enrichment type based on authorization status - if (wjsDevice._isAuthorized(bidderCode)) { - bidderEnrichment.set(bidderCode, ENRICHMENT_TYPE.WURFL_SSP); - } else { - bidderEnrichment.set(bidderCode, ENRICHMENT_TYPE.WURFL_PUB); + // 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; } - // Inject WURFL data + // Authorized bidder with data to inject const bd = reqBidsConfigObj.ortb2Fragments.bidder[bidderCode] || {}; mergeDeep(bd, bidderDevice); reqBidsConfigObj.ortb2Fragments.bidder[bidderCode] = bd; @@ -555,11 +554,8 @@ const WurflDebugger = { window.WurflRtdDebug.data.pbjsData = pbjsData; }, - setLceData(lceDevice, extWurfl) { - window.WurflRtdDebug.data.lceDevice = { - ...lceDevice, - ext: extWurfl?.device?.ext - }; + setLceData(lceDevice) { + window.WurflRtdDebug.data.lceDevice = lceDevice; }, setCacheExpired(expired) { @@ -690,6 +686,8 @@ const WurflJSDevice = { }, // 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; @@ -714,6 +712,19 @@ const WurflJSDevice = { 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; }, @@ -722,8 +733,8 @@ const WurflJSDevice = { const isAuthorized = this._isAuthorized(bidderCode); const isOverQuota = this._isOverQuota(); - // When unauthorized and over quota, return empty - if (!isAuthorized && isOverQuota) { + // When unauthorized return empty + if (!isAuthorized) { return {}; } @@ -739,11 +750,10 @@ const WurflJSDevice = { } // For authorized bidders: basic + pub + bidder-specific caps - // For unauthorized bidders (under quota only): basic + pub caps (no bidder-specific) const wurflData = { - ...this._getBasicCaps(), - ...this._getPubCaps(), - ...(isAuthorized ? this._getBidderCaps(bidderCode) : {}) + ...(isOverQuota ? this._getBasicCaps() : {}), + ...(isOverQuota ? this._getPubCaps() : {}), + ...this._getBidderCaps(bidderCode) }; return { @@ -1043,31 +1053,16 @@ const WurflLCEDevice = { } } - return device; - }, - - // Public API - returns device.ext.wurfl object with LCE-detected capabilities - // Returns: { device: { ext: { wurfl: { is_robot: boolean } } } } - Ext() { - // Early exit - check window exists - if (typeof window === 'undefined') { - return { device: { ext: { wurfl: {} } } }; - } - - const useragent = this._getUserAgent(); - if (!useragent) { - return { device: { ext: { wurfl: {} } } }; + // Add ext.wurfl with is_robot detection + if (useragent) { + device.ext = { + wurfl: { + is_robot: this._isRobot(useragent) + } + }; } - return { - device: { - ext: { - wurfl: { - is_robot: this._isRobot(useragent) - } - } - } - }; + return device; } }; // ==================== END WURFL LCE DEVICE MODULE ==================== @@ -1152,11 +1147,11 @@ const getBidRequestData = (reqBidsConfigObj, callback, config, userConsent) => { WurflDebugger.setCacheData(cachedWurflData.WURFL, cachedWurflData.wurfl_pbjs); const wjsDevice = WurflJSDevice.fromCache(cachedWurflData); - if (!wjsDevice._isOverQuota()) { + if (wjsDevice._isOverQuota()) { + enrichmentType = ENRICHMENT_TYPE.NONE; + } else { enrichDeviceFPD(reqBidsConfigObj, wjsDevice.FPD()); enrichmentType = ENRICHMENT_TYPE.WURFL_PUB; - } else { - enrichmentType = ENRICHMENT_TYPE.NONE; } enrichDeviceBidder(reqBidsConfigObj, bidders, wjsDevice); @@ -1195,22 +1190,18 @@ const getBidRequestData = (reqBidsConfigObj, callback, config, userConsent) => { WurflDebugger.lceDetectionStart(); let lceDevice; - let extWurfl; try { lceDevice = WurflLCEDevice.FPD(); - extWurfl = WurflLCEDevice.Ext(); enrichmentType = ENRICHMENT_TYPE.LCE; } catch (e) { logger.logError('Error generating LCE device data:', e); lceDevice = { js: 1 }; - extWurfl = { device: { ext: { wurfl: {} } } }; enrichmentType = ENRICHMENT_TYPE.LCE_ERROR; } WurflDebugger.lceDetectionStop(); - WurflDebugger.setLceData(lceDevice, extWurfl); + WurflDebugger.setLceData(lceDevice); enrichDeviceFPD(reqBidsConfigObj, lceDevice); - enrichDeviceExt(reqBidsConfigObj, extWurfl); // Set enrichment type for all bidders bidders.forEach(bidder => bidderEnrichment.set(bidder, enrichmentType)); diff --git a/test/spec/modules/wurflRtdProvider_spec.js b/test/spec/modules/wurflRtdProvider_spec.js index 57a431daf7c..dfa02df5acf 100644 --- a/test/spec/modules/wurflRtdProvider_spec.js +++ b/test/spec/modules/wurflRtdProvider_spec.js @@ -26,15 +26,15 @@ describe('wurflRtdProvider', function () { cap_indices: [0, 9, 15, 16, 17, 18, 32] }, publisher: { - cap_indices: [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31] + cap_indices: [1, 2, 3, 4, 5] } }, bidders: { bidder1: { - cap_indices: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 13, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32] + cap_indices: [6, 7, 8, 10, 11, 26, 27] }, bidder2: { - cap_indices: [0, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 21, 22, 26, 29, 31, 32] + cap_indices: [12, 13, 14, 19, 20, 21, 22] } } } @@ -507,13 +507,46 @@ describe('wurflRtdProvider', function () { js: 1 }); - // bidder1 and bidder2 are authorized, should get ext.wurfl with all capabilities - expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder1.device.ext.wurfl).to.deep.equal(WURFL); - expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder2.device.ext.wurfl).to.deep.equal(WURFL); + // 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, but should get basic+pub caps (tested in detail in dedicated test) - expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder3).to.exist; - expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder3.device.ext.wurfl).to.exist; + // bidder3 is NOT authorized, should get empty object (inherits from global) + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder3).to.not.exist; done(); }; @@ -711,7 +744,8 @@ describe('wurflRtdProvider', function () { // Verify global FPD does NOT have device data (over quota) expect(reqBidsConfigObj.ortb2Fragments.global.device).to.deep.equal({}); - // bidder1 and bidder2 are authorized, should get full device + ext.wurfl + // Over quota, authorized bidders: should get basic + pub + bidder-specific caps (ALL) + // bidder1 should get device fields + ext.wurfl with basic + pub + bidder1-specific expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder1.device).to.deep.include({ make: 'Google', model: 'Nexus 5', @@ -725,8 +759,20 @@ describe('wurflRtdProvider', function () { pxratio: 3.0, js: 1 }); - expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder1.device.ext.wurfl).to.deep.equal(WURFL); + const basicIndices = wurfl_pbjs_over_quota.global.basic_set.cap_indices; + const pubIndices = wurfl_pbjs_over_quota.global.publisher.cap_indices; + const bidder1Indices = wurfl_pbjs_over_quota.bidders.bidder1.cap_indices; + const allBidder1Indices = [...new Set([...basicIndices, ...pubIndices, ...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 + pub + bidder2-specific expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder2.device).to.deep.include({ make: 'Google', model: 'Nexus 5', @@ -740,7 +786,16 @@ describe('wurflRtdProvider', function () { pxratio: 3.0, js: 1 }); - expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder2.device.ext.wurfl).to.deep.equal(WURFL); + const bidder2Indices = wurfl_pbjs_over_quota.bidders.bidder2.cap_indices; + const allBidder2Indices = [...new Set([...basicIndices, ...pubIndices, ...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; @@ -778,7 +833,7 @@ describe('wurflRtdProvider', function () { // Verify global FPD does NOT have device data (over quota) expect(reqBidsConfigObj.ortb2Fragments.global.device).to.deep.equal({}); - // bidder1 and bidder2 are authorized, should get full device + ext.wurfl + // 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', @@ -793,7 +848,18 @@ describe('wurflRtdProvider', function () { pxratio: 3.0, js: 1 }); - expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder1.device.ext.wurfl).to.deep.equal(WURFL); + const basicIndices = wurfl_pbjs_over_quota.global.basic_set.cap_indices; + const pubIndices = wurfl_pbjs_over_quota.global.publisher.cap_indices; + const bidder1Indices = wurfl_pbjs_over_quota.bidders.bidder1.cap_indices; + const allBidder1Indices = [...new Set([...basicIndices, ...pubIndices, ...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({ @@ -801,7 +867,16 @@ describe('wurflRtdProvider', function () { model: 'Nexus 5', devicetype: 4 }); - expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder2.device.ext.wurfl).to.deep.equal(WURFL); + const bidder2Indices = wurfl_pbjs_over_quota.bidders.bidder2.cap_indices; + const allBidder2Indices = [...new Set([...basicIndices, ...pubIndices, ...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; @@ -847,11 +922,23 @@ describe('wurflRtdProvider', function () { const guardedReqBidsConfigObj = { ...plainReqBidsConfigObj, ortb2Fragments: guardedFragments }; const callback = () => { - // Verify bidder1 (authorized) got enriched data + // 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; - expect(plainFragments.bidder.bidder1.device.ext.wurfl).to.deep.equal(WURFL); + + const basicIndices = wurfl_pbjs_over_quota.global.basic_set.cap_indices; + const pubIndices = wurfl_pbjs_over_quota.global.publisher.cap_indices; + const bidder1Indices = wurfl_pbjs_over_quota.bidders.bidder1.cap_indices; + const allBidder1Indices = [...new Set([...basicIndices, ...pubIndices, ...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({ @@ -864,7 +951,16 @@ describe('wurflRtdProvider', function () { // Verify bidder2 (authorized) also got enriched expect(plainFragments.bidder.bidder2).to.exist; - expect(plainFragments.bidder.bidder2.device.ext.wurfl).to.deep.equal(WURFL); + const bidder2Indices = wurfl_pbjs_over_quota.bidders.bidder2.cap_indices; + const allBidder2Indices = [...new Set([...basicIndices, ...pubIndices, ...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(); }; @@ -875,7 +971,7 @@ describe('wurflRtdProvider', function () { wurflSubmodule.getBidRequestData(guardedReqBidsConfigObj, callback, config, userConsent); }); - it('should pass basic+pub caps to unauthorized bidders when under quota', (done) => { + 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 = {}; @@ -907,23 +1003,37 @@ describe('wurflRtdProvider', function () { } }); - // bidder1 and bidder2 are authorized, should get ALL caps (basic + pub + bidder-specific) - expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder1.device.ext.wurfl).to.deep.equal(WURFL); - expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder2.device.ext.wurfl).to.deep.equal(WURFL); + // 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); - // bidder3 is NOT authorized, should get ONLY basic + pub caps (no bidder-specific) - expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder3).to.exist; - expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder3.device).to.exist; - expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder3.device.ext).to.exist; - expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder3.device.ext.wurfl).to.deep.equal(expectedBasicPubCaps); + 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); - // Verify bidder3 does NOT have FPD device data (only authorized bidders get that when over quota) - expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder3.device.make).to.be.undefined; - expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder3.device.model).to.be.undefined; + // 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 should equal what bidder3 received - const bidder3CapCount = Object.keys(reqBidsConfigObj.ortb2Fragments.bidder.bidder3.device.ext.wurfl).length; - expect(bidder3CapCount).to.equal(allBasicPubIndices.length); + // 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(); }; @@ -934,6 +1044,39 @@ describe('wurflRtdProvider', function () { 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 = {}; From 8cc404e4d3cf3ac47c1cb22d1bd8e5ee0100e43f Mon Sep 17 00:00:00 2001 From: lucor Date: Wed, 26 Nov 2025 17:20:03 +0100 Subject: [PATCH 21/22] WURFL RTD: authorized should not receive pub caps when overquota --- modules/wurflRtdProvider.js | 1 - test/spec/modules/wurflRtdProvider_spec.js | 21 +++++++++------------ 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/modules/wurflRtdProvider.js b/modules/wurflRtdProvider.js index e91edd5d95d..beffabdb7b9 100644 --- a/modules/wurflRtdProvider.js +++ b/modules/wurflRtdProvider.js @@ -752,7 +752,6 @@ const WurflJSDevice = { // For authorized bidders: basic + pub + bidder-specific caps const wurflData = { ...(isOverQuota ? this._getBasicCaps() : {}), - ...(isOverQuota ? this._getPubCaps() : {}), ...this._getBidderCaps(bidderCode) }; diff --git a/test/spec/modules/wurflRtdProvider_spec.js b/test/spec/modules/wurflRtdProvider_spec.js index dfa02df5acf..371d8cde95f 100644 --- a/test/spec/modules/wurflRtdProvider_spec.js +++ b/test/spec/modules/wurflRtdProvider_spec.js @@ -744,8 +744,8 @@ describe('wurflRtdProvider', function () { // 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) - // bidder1 should get device fields + ext.wurfl with basic + pub + bidder1-specific + // 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', @@ -760,9 +760,8 @@ describe('wurflRtdProvider', function () { js: 1 }); const basicIndices = wurfl_pbjs_over_quota.global.basic_set.cap_indices; - const pubIndices = wurfl_pbjs_over_quota.global.publisher.cap_indices; const bidder1Indices = wurfl_pbjs_over_quota.bidders.bidder1.cap_indices; - const allBidder1Indices = [...new Set([...basicIndices, ...pubIndices, ...bidder1Indices])]; + const allBidder1Indices = [...new Set([...basicIndices, ...bidder1Indices])]; const expectedBidder1AllCaps = {}; allBidder1Indices.forEach(index => { const capName = wurfl_pbjs_over_quota.caps[index]; @@ -772,7 +771,7 @@ describe('wurflRtdProvider', function () { }); expect(reqBidsConfigObj.ortb2Fragments.bidder.bidder1.device.ext.wurfl).to.deep.equal(expectedBidder1AllCaps); - // bidder2 should get device fields + ext.wurfl with basic + pub + bidder2-specific + // 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', @@ -787,7 +786,7 @@ describe('wurflRtdProvider', function () { js: 1 }); const bidder2Indices = wurfl_pbjs_over_quota.bidders.bidder2.cap_indices; - const allBidder2Indices = [...new Set([...basicIndices, ...pubIndices, ...bidder2Indices])]; + const allBidder2Indices = [...new Set([...basicIndices, ...bidder2Indices])]; const expectedBidder2AllCaps = {}; allBidder2Indices.forEach(index => { const capName = wurfl_pbjs_over_quota.caps[index]; @@ -849,9 +848,8 @@ describe('wurflRtdProvider', function () { js: 1 }); const basicIndices = wurfl_pbjs_over_quota.global.basic_set.cap_indices; - const pubIndices = wurfl_pbjs_over_quota.global.publisher.cap_indices; const bidder1Indices = wurfl_pbjs_over_quota.bidders.bidder1.cap_indices; - const allBidder1Indices = [...new Set([...basicIndices, ...pubIndices, ...bidder1Indices])]; + const allBidder1Indices = [...new Set([...basicIndices, ...bidder1Indices])]; const expectedBidder1AllCaps = {}; allBidder1Indices.forEach(index => { const capName = wurfl_pbjs_over_quota.caps[index]; @@ -868,7 +866,7 @@ describe('wurflRtdProvider', function () { devicetype: 4 }); const bidder2Indices = wurfl_pbjs_over_quota.bidders.bidder2.cap_indices; - const allBidder2Indices = [...new Set([...basicIndices, ...pubIndices, ...bidder2Indices])]; + const allBidder2Indices = [...new Set([...basicIndices, ...bidder2Indices])]; const expectedBidder2AllCaps = {}; allBidder2Indices.forEach(index => { const capName = wurfl_pbjs_over_quota.caps[index]; @@ -928,9 +926,8 @@ describe('wurflRtdProvider', function () { expect(plainFragments.bidder.bidder1.device.ext).to.exist; const basicIndices = wurfl_pbjs_over_quota.global.basic_set.cap_indices; - const pubIndices = wurfl_pbjs_over_quota.global.publisher.cap_indices; const bidder1Indices = wurfl_pbjs_over_quota.bidders.bidder1.cap_indices; - const allBidder1Indices = [...new Set([...basicIndices, ...pubIndices, ...bidder1Indices])]; + const allBidder1Indices = [...new Set([...basicIndices, ...bidder1Indices])]; const expectedBidder1AllCaps = {}; allBidder1Indices.forEach(index => { const capName = wurfl_pbjs_over_quota.caps[index]; @@ -952,7 +949,7 @@ describe('wurflRtdProvider', function () { // 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, ...pubIndices, ...bidder2Indices])]; + const allBidder2Indices = [...new Set([...basicIndices, ...bidder2Indices])]; const expectedBidder2AllCaps = {}; allBidder2Indices.forEach(index => { const capName = wurfl_pbjs_over_quota.caps[index]; From 5b8f4f9a84d146f6279cc6c2223aa13f466be773 Mon Sep 17 00:00:00 2001 From: lucor Date: Tue, 2 Dec 2025 13:13:41 +0100 Subject: [PATCH 22/22] WURFL RTD: update module doc to include disclosure about wurfl.js loading --- modules/wurflRtdProvider.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/modules/wurflRtdProvider.md b/modules/wurflRtdProvider.md index 1916acbe4fb..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).