From 0b1c521f8adc502dd772f730aed81cf50c0624f0 Mon Sep 17 00:00:00 2001 From: anand-nexverse Date: Wed, 11 Dec 2024 19:51:09 +0530 Subject: [PATCH] Nexverse Bid Adapter : initial relese (#12297) * Add nexverseBidAdapter implementation and tests * Updated Nexverse adapter to support OpenRTB 2.5, handle 204 responses, and added device and connection type detection * Resolved conflicts and merged master into nexverse-bid-adapter * Code optimization: * created utils for all common code * reused prebid inbuild functions * created nexverse.html to text nexverse adaptor * changed test specs based on main js * Code optimization: * created utils for all common code * reused prebid inbuild functions * created nexverse.html to text nexverse adaptor * changed test specs based on main js * * Removed bidfloor from Adunit * Fixed issues of 1.Category 2.Domain 3.Attributes * Added Nexverse Demo html for testing with sample bid * Handled Height and width issue * * Added test cases for bid param checks * fixed bidFloor issue * * Added testcases for utils functions ' * * removed duplicate function and used from available library * * removed unwated logging * * added logger in try catch * fixed linter error * * fixed linter issue * fixed the getOsVersion missing function issue * fixed the getOsVersion missing function issue * removed hardcoded cpm value * Added comment for is debug param --------- Co-authored-by: yogeshverse --- integrationExamples/gpt/nexverse.html | 126 ++++++++++ libraries/nexverseUtils/index.js | 130 ++++++++++ modules/nexverseBidAdapter.js | 247 +++++++++++++++++++ modules/nexverseBidAdapter.md | 41 +++ test/spec/modules/nexverseBidAdapter_spec.js | 203 +++++++++++++++ 5 files changed, 747 insertions(+) create mode 100644 integrationExamples/gpt/nexverse.html create mode 100644 libraries/nexverseUtils/index.js create mode 100644 modules/nexverseBidAdapter.js create mode 100644 modules/nexverseBidAdapter.md create mode 100644 test/spec/modules/nexverseBidAdapter_spec.js diff --git a/integrationExamples/gpt/nexverse.html b/integrationExamples/gpt/nexverse.html new file mode 100644 index 00000000000..498cf2bd2f3 --- /dev/null +++ b/integrationExamples/gpt/nexverse.html @@ -0,0 +1,126 @@ + + + + + + NexVerse Prebid.Js Demo + + + + + + + + + + +

Nexverse Prebid.js Test

+
Div-1
+
+ +
+ +
Div-2
+
+ +
+ + + \ No newline at end of file diff --git a/libraries/nexverseUtils/index.js b/libraries/nexverseUtils/index.js new file mode 100644 index 00000000000..c9e286c221d --- /dev/null +++ b/libraries/nexverseUtils/index.js @@ -0,0 +1,130 @@ +import { logError, logInfo, logWarn, generateUUID } from '../../src/utils.js'; + +const LOG_WARN_PREFIX = '[Nexverse warn]: '; +const LOG_ERROR_PREFIX = '[Nexverse error]: '; +const LOG_INFO_PREFIX = '[Nexverse info]: '; +const NEXVERSE_USER_COOKIE_KEY = 'user_nexverse'; + +/** + * Determines the device model (if possible). + * @returns {string} The device model or a fallback message if not identifiable. + */ +export function getDeviceModel() { + const ua = navigator.userAgent; + if (/iPhone/i.test(ua)) { + return 'iPhone'; + } else if (/iPad/i.test(ua)) { + return 'iPad'; + } else if (/Android/i.test(ua)) { + const match = ua.match(/Android.*;\s([a-zA-Z0-9\s]+)\sBuild/); + return match ? match[1].trim() : 'Unknown Android Device'; + } else if (/Windows Phone/i.test(ua)) { + return 'Windows Phone'; + } else if (/Macintosh/i.test(ua)) { + return 'Mac'; + } else if (/Linux/i.test(ua)) { + return 'Linux'; + } else if (/Windows/i.test(ua)) { + return 'Windows PC'; + } + return ''; +} + +/** + * Prepapre the endpoint URL based on passed bid request. + * @param {string} bidderEndPoint - Bidder End Point. + * @param {object} bid - Bid details. + * @returns {string} The Endpoint URL with required parameters. + */ +export function buildEndpointUrl(bidderEndPoint, bid) { + const { uid, pubId, pubEpid } = bid.params; + const isDebug = bid.isDebug; + let endPoint = `${bidderEndPoint}?uid=${encodeURIComponent(uid)}&pub_id=${encodeURIComponent(pubId)}&pub_epid=${encodeURIComponent(pubEpid)}`; + if (isDebug) { + endPoint = `${endPoint}&test=1`; + } + return endPoint; +} +/** + * Validates the bid request to ensure all required parameters are present. + * @param {Object} bid - The bid request object. + * @returns {boolean} True if the bid request is valid, false otherwise. + */ +export function isBidRequestValid(bid) { + const isValid = !!( + bid.params && + bid.params.uid && bid.params.uid.trim() && + bid.params.pubId && bid.params.pubId.trim() && + bid.params.pubEpid && bid.params.pubEpid.trim() + ); + if (!isValid) { + logError(`${LOG_ERROR_PREFIX} Missing required bid parameters.`); + } + + return isValid; +} + +/** + * Parses the native response from the server into Prebid's native format. + * + * @param {string} adm - The adm field from the bid response (JSON string). + * @returns {Object} The parsed native response object. + */ +export function parseNativeResponse(adm) { + try { + const admObj = JSON.parse(adm); + return admObj.native; + } catch (e) { + printLog('error', `Error parsing native response: `, e) + logError(`${LOG_ERROR_PREFIX} Error parsing native response: `, e); + return {}; + } +} + +/** + * Parses the native response from the server into Prebid's native format. + * @param {type} type - Type of log. default is info + * @param {args} args - Log data. + */ +export function printLog(type, ...args) { + // Determine the prefix based on the log type + const prefixes = { + error: LOG_ERROR_PREFIX, + warning: LOG_WARN_PREFIX, // Assuming warning uses the same prefix as error + info: LOG_INFO_PREFIX + }; + + // Construct the log message by joining all arguments into a single string + const logMessage = args + .map(arg => (arg instanceof Error ? `${arg.name}: ${arg.message}` : arg)) + .join(' '); // Join all arguments into a single string with a space separator + // Add prefix and punctuation (for info type) + const formattedMessage = `${prefixes[type] || LOG_INFO_PREFIX} ${logMessage}${type === 'info' ? '.' : ''}`; + // Map the log type to its corresponding log function + const logFunctions = { + error: logError, + warning: logWarn, + info: logInfo + }; + + // Call the appropriate log function (defaulting to logInfo) + (logFunctions[type] || logInfo)(formattedMessage); +} +/** + * Get or Create Uid for First Party Cookie + */ +export const getUid = (storage) => { + let nexverseUid = storage.getCookie(NEXVERSE_USER_COOKIE_KEY); + if (!nexverseUid) { + nexverseUid = generateUUID(); + } + try { + const expirationInMs = 60 * 60 * 24 * 1000; // 1 day in milliseconds + const expirationTime = new Date(Date.now() + expirationInMs); // Set expiration time + // Set the cookie with the expiration date + storage.setCookie(NEXVERSE_USER_COOKIE_KEY, nexverseUid, expirationTime.toUTCString()); + } catch (e) { + printLog('error', `Failed to set UID cookie: ${e.message}`); + } + return nexverseUid; +}; diff --git a/modules/nexverseBidAdapter.js b/modules/nexverseBidAdapter.js new file mode 100644 index 00000000000..5ff6aa10bc5 --- /dev/null +++ b/modules/nexverseBidAdapter.js @@ -0,0 +1,247 @@ +/* eslint-disable camelcase */ + +import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { BANNER, VIDEO, NATIVE } from '../src/mediaTypes.js'; +import { isArray } from '../src/utils.js'; +import {getConnectionType} from '../libraries/connectionInfo/connectionUtils.js' +import { getDeviceType, getOS } from '../libraries/userAgentUtils/index.js'; +import { getDeviceModel, buildEndpointUrl, isBidRequestValid, parseNativeResponse, printLog, getUid } from '../libraries/nexverseUtils/index.js'; +import {getStorageManager} from '../src/storageManager.js'; +import {MODULE_TYPE_UID} from '../src/activities/modules.js'; +import { getUserSyncs } from '../libraries/teqblazeUtils/bidderUtils.js'; +import { getOsVersion } from '../libraries/advangUtils/index.js'; + +const BIDDER_CODE = 'nexverse'; +const BIDDER_ENDPOINT = 'https://rtb.nexverse.ai/'; +const SUPPORTED_MEDIA_TYPES = [BANNER, VIDEO, NATIVE]; +const DEFAULT_CURRENCY = 'USD'; +const BID_TTL = 300; +const DEFAULT_LANG = 'en'; + +export const storage = getStorageManager({moduleType: MODULE_TYPE_UID, moduleName: BIDDER_CODE}); + +export const spec = { + code: BIDDER_CODE, + supportedMediaTypes: SUPPORTED_MEDIA_TYPES, + isBidRequestValid, + /** + * Builds the OpenRTB server request from the list of valid bid requests. + * + * @param {Array} validBidRequests - Array of valid bid requests. + * @param {Object} bidderRequest - The bidder request object containing additional data. + * @returns {Array} Array of server requests to be sent to the endpoint. + */ + buildRequests(validBidRequests, bidderRequest) { + const requests = validBidRequests.map((bid) => { + // Build the endpoint URL with query parameters + const endpointUrl = buildEndpointUrl(BIDDER_ENDPOINT, bid); + + // Build the OpenRTB payload + const payload = buildOpenRtbRequest(bid, bidderRequest); + + if (!payload) { + printLog('error', 'Payload could not be built.'); + return null; // Skip this bid + } + + // Return the server request + return { + method: 'POST', + url: endpointUrl, + data: JSON.stringify(payload), + bidRequest: bid, + }; + }); + + return requests.filter((request) => request !== null); // Remove null entries + }, + + /** + * Interprets the server's response and extracts bid information. + * + * @param {Object} serverResponse - The response from the server. + * @param {Object} request - The original server request. + * @returns {Array} Array of bids to be passed to the auction. + */ + interpretResponse(serverResponse, request) { + if (serverResponse && serverResponse.status === 204) { + printLog('info', 'No ad available (204 response).'); + return []; + } + + const bidResponses = []; + const response = serverResponse.body; + + if (!response || !response.seatbid || !isArray(response.seatbid)) { + printLog('warning', 'No valid bids in the response.'); + return bidResponses; + } + + response.seatbid.forEach((seatbid) => { + seatbid.bid.forEach((bid) => { + const bidResponse = { + requestId: bid.impid, + cpm: bid.price, + currency: response.cur || DEFAULT_CURRENCY, + width: bid.width || 0, + height: bid.height || 0, + creativeId: bid.crid || bid.id, + ttl: BID_TTL, + netRevenue: true, + meta: {}, + }; + // Determine media type and assign the ad content + if (bid.ext && bid.ext.mediaType) { + bidResponse.mediaType = bid.ext.mediaType; + } else if (bid.adm && bid.adm.indexOf(' ({ w: size[0], h: size[1] })), // List of size objects + w: bid.sizes[0][0], + h: bid.sizes[0][1], + }, + secure: window.location.protocol === 'https:' ? 1 : 0, // Indicates whether the request is secure (HTTPS) + }); + } + if (bid.mediaTypes.video) { + imp.push({ + id: bid.bidId, + video: { + w: bid.sizes[0][0], + h: bid.sizes[0][1], + mimes: bid.mediaTypes.video.mimes || ['video/mp4'], // Default to video/mp4 if not specified + protocols: bid.mediaTypes.video.protocols || [2, 3, 5, 6], // RTB video ad serving protocols + maxduration: bid.mediaTypes.video.maxduration || 30, + linearity: bid.mediaTypes.video.linearity || 1, + playbackmethod: bid.mediaTypes.video.playbackmethod || [2], + }, + secure: window.location.protocol === 'https:' ? 1 : 0, // Indicates whether the request is secure (HTTPS) + }); + } + if (bid.mediaTypes.native) { + imp.push({ + id: bid.bidId, + native: { + request: JSON.stringify(bid.mediaTypes.native), // Convert native request to JSON string + }, + secure: window.location.protocol === 'https:' ? 1 : 0, // Indicates whether the request is secure (HTTPS) + }); + } + + // Construct the OpenRTB request object + const openRtbRequest = { + id: bidderRequest.auctionId, + imp: imp, + site: { + page: bidderRequest.refererInfo.page, + domain: bidderRequest.refererInfo.domain, + ref: bidderRequest.refererInfo.ref || '', // Referrer URL + }, + device: { + ua: navigator.userAgent, + devicetype: getDeviceType(), // 1 = Mobile/Tablet, 2 = Desktop + os: getOS(), + osv: getOsVersion(), + make: navigator.vendor || '', + model: getDeviceModel(), + connectiontype: getConnectionType(), // Include connection type + geo: { + lat: bid.params.geoLat || 0, + lon: bid.params.geoLon || 0, + }, + language: navigator.language || DEFAULT_LANG, + dnt: navigator.doNotTrack === '1' ? 1 : 0, // Do Not Track flag + }, + user: { + id: getUid(storage), + buyeruid: bidderRequest.userId || '', // User ID or Buyer ID + ext: { + consent: bidderRequest.gdprConsent ? bidderRequest.gdprConsent.consentString : null, // GDPR consent string + }, + }, + regs: { + ext: { + gdpr: bidderRequest.gdprConsent ? (bidderRequest.gdprConsent.gdprApplies ? 1 : 0) : 0, + }, + }, + ext: { + prebid: { + auctiontimestamp: bidderRequest.auctionStart, + }, + }, + }; + + // Add app object if the request comes from a mobile app + if (bidderRequest.app) { + openRtbRequest.app = { + id: bidderRequest.app.id, + name: bidderRequest.app.name, + bundle: bidderRequest.app.bundle, + domain: bidderRequest.app.domain, + storeurl: bidderRequest.app.storeUrl, + cat: bidderRequest.app.cat || [], + }; + } + // Add additional fields related to GDPR, US Privacy, CCPA + if (bidderRequest.uspConsent) { + openRtbRequest.regs.ext.us_privacy = bidderRequest.uspConsent; + } + return openRtbRequest; +} + +registerBidder(spec); diff --git a/modules/nexverseBidAdapter.md b/modules/nexverseBidAdapter.md new file mode 100644 index 00000000000..1de5dda01e9 --- /dev/null +++ b/modules/nexverseBidAdapter.md @@ -0,0 +1,41 @@ +# Nexverse Bid Adapter + +## Overview +The Nexverse Bid Adapter enables publishers to connect with the Nexverse Real-Time Bidding (RTB) platform. This adapter supports multiple ad formats, including Banner, Video, and Native ads. By integrating this adapter, publishers can send bid requests to Nexverse’s marketplace and receive high-quality ads in response. + +- **Module name**: Nexverse +- **Module type**: Bidder Adapter +- **Supported Media Types**: Banner, Video, Native +- **Maintainer**: anand.kumar@nexverse.ai + +## Bidder Parameters +To correctly configure the Nexverse Bid Adapter, the following parameters are required: + +| Param Name | Scope | Type | Description | +|--------------|----------|--------|-----------------------------------------------------| +| `uid` | required | string | Unique User ID assigned by Nexverse for the publisher | +| `pubId` | required | string | The unique ID for the publisher | +| `pubEpid` | required | string | The unique endpoint ID for the publisher | + +### Example Configuration +The following is an example configuration for a Nexverse bid request using Prebid.js: + +```javascript +var adUnits = [{ + code: 'div-gpt-ad-1460505748561-0', + mediaTypes: { + banner: { + sizes: [[300, 250], [300, 600]] + } + }, + bids: [{ + bidder: 'nexverse', + params: { + uid: '12345', + pubId: '54321', + pubEpid: 'abcde' + }, + isDebug: false // Optional, i.e True for debug mode + }] +}]; +``` diff --git a/test/spec/modules/nexverseBidAdapter_spec.js b/test/spec/modules/nexverseBidAdapter_spec.js new file mode 100644 index 00000000000..fb648a154f7 --- /dev/null +++ b/test/spec/modules/nexverseBidAdapter_spec.js @@ -0,0 +1,203 @@ +import { expect } from 'chai'; +import { spec } from 'modules/nexverseBidAdapter.js'; +import { getDeviceModel, buildEndpointUrl, parseNativeResponse } from '../../../libraries/nexverseUtils/index.js'; +import { getOsVersion } from '../../../libraries/advangUtils/index.js'; + +const BIDDER_ENDPOINT = 'https://rtb.nexverse.ai/'; + +describe('nexverseBidAdapterTests', () => { + describe('isBidRequestValid', function () { + let sbid = { + 'adUnitCode': 'div', + 'bidder': 'nexverse', + 'params': { + 'uid': '77d4a2eb3d209ce6c7691dc79fcab358', + 'pubId': '24051' + }, + }; + + it('should not accept bid without required params', function () { + let isValid = spec.isBidRequestValid(sbid); + expect(isValid).to.equal(false); + }); + + it('should return false when params are not passed', function () { + let bid = Object.assign({}, sbid); + delete bid.params; + bid.params = {}; + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + + it('should return false when valid params are not passed', function () { + let bid = Object.assign({}, sbid); + delete bid.params; + bid.params = {uid: '', pubId: '', pubEpid: ''}; + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + + it('should return false when valid params are not passed', function () { + let bid = Object.assign({}, sbid); + delete bid.params; + bid.adUnitCode = ''; + bid.mediaTypes = { + banner: { + sizes: [[300, 250]] + } + }; + bid.params = {uid: '77d4a2eb3d209ce6c7691dc79fcab358', pubId: '24051'}; + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + it('should return true when valid params are passed as nums', function () { + let bid = Object.assign({}, sbid); + delete bid.params; + bid.mediaTypes = { + banner: { + sizes: [[300, 250]] + } + }; + bid.params = {uid: '77d4a2eb3d209ce6c7691dc79fcab358', pubId: '24051', pubEpid: '34561'}; + expect(spec.isBidRequestValid(bid)).to.equal(true); + }); + }); + + describe('getDeviceModel', () => { + it('should return "iPhone" for iPhone userAgent', function () { + Object.defineProperty(navigator, 'userAgent', { value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)', configurable: true }); + expect(getDeviceModel()).to.equal('iPhone'); + }); + + it('should return "iPad" for iPad userAgent', function () { + Object.defineProperty(navigator, 'userAgent', { value: 'Mozilla/5.0 (iPad; CPU OS 14_0 like Mac OS X)', configurable: true }); + expect(getDeviceModel()).to.equal('iPad'); + }); + + it('should return the Android device name for Android userAgent', function () { + Object.defineProperty(navigator, 'userAgent', { value: 'Mozilla/5.0 (Linux; Android 10; Pixel 3 Build/QQ2A.200305.003) AppleWebKit/537.36', configurable: true }); + expect(getDeviceModel()).to.equal('Pixel 3'); + }); + + it('should return "Unknown Android Device" if device name is missing in Android userAgent', function () { + Object.defineProperty(navigator, 'userAgent', { value: 'Mozilla/5.0 (Linux; Android 10;) AppleWebKit/537.36', configurable: true }); + expect(getDeviceModel()).to.equal('Unknown Android Device'); + }); + + it('should return "Mac" for Mac userAgent', function () { + Object.defineProperty(navigator, 'userAgent', { value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', configurable: true }); + expect(getDeviceModel()).to.equal('Mac'); + }); + + it('should return "Linux" for Linux userAgent', function () { + Object.defineProperty(navigator, 'userAgent', { value: 'Mozilla/5.0 (X11; Linux x86_64)', configurable: true }); + expect(getDeviceModel()).to.equal('Linux'); + }); + + it('should return "Windows PC" for Windows userAgent', function () { + Object.defineProperty(navigator, 'userAgent', { value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', configurable: true }); + expect(getDeviceModel()).to.equal('Windows PC'); + }); + + it('should return "Unknown Device" for an unrecognized userAgent', function () { + Object.defineProperty(navigator, 'userAgent', { value: 'Mozilla/5.0 (Unknown OS)', configurable: true }); + expect(getDeviceModel()).to.equal(''); + }); + }); + + describe('buildEndpointUrl', () => { + it('should construct the URL with uid, pubId, and pubEpid', function () { + const bid = { + params: { + uid: '12345', + pubId: '67890', + pubEpid: 'abcdef' + }, + isDebug: false + }; + const expectedUrl = `${BIDDER_ENDPOINT}?uid=12345&pub_id=67890&pub_epid=abcdef`; + expect(buildEndpointUrl(BIDDER_ENDPOINT, bid)).to.equal(expectedUrl); + }); + }); + + describe('buildEndpointUrl', () => { + it('should construct the test URL with uid, pubId, and pubEpid', function () { + const bid = { + params: { + uid: '12345', + pubId: '67890', + pubEpid: 'abcdef' + }, + isDebug: true + }; + const expectedUrl = `${BIDDER_ENDPOINT}?uid=12345&pub_id=67890&pub_epid=abcdef&test=1`; + expect(buildEndpointUrl(BIDDER_ENDPOINT, bid)).to.equal(expectedUrl); + }); + }); + + describe('parseNativeResponse', () => { + it('should parse and return the empty json object from a invalid JSON string', function () { + const adm = 'Nexverse test ad'; + const result = parseNativeResponse(adm); + expect(result).to.deep.equal({}); + }); + it('should parse and return the native object from a valid JSON string', function () { + const adm = '{"native": "sample native ad"}'; // JSON string + const result = parseNativeResponse(adm); + expect(result).to.deep.equal('sample native ad'); + }); + }); + + describe('getOsVersion', () => { + it('should detect Android OS', function () { + Object.defineProperty(navigator, 'userAgent', { value: 'Mozilla/5.0 (Linux; Android 10; Pixel 3 Build/QQ2A.200305.003) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Mobile Safari/537.36', configurable: true }); + expect(getOsVersion()).to.equal('Android'); + }); + it('should detect iOS', function () { + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1', + configurable: true, + }); + expect(getOsVersion()).to.equal('iOS'); + }); + it('should detect Mac OS X', function () { + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + configurable: true, + }); + expect(getOsVersion()).to.equal('Mac OS X'); + }); + it('should detect Windows 10', function () { + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + configurable: true, + }); + expect(getOsVersion()).to.equal('Windows 10'); + }); + it('should detect Linux', function () { + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + configurable: true, + }); + expect(getOsVersion()).to.equal('Linux'); + }); + it('should detect Windows 7', function () { + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + configurable: true, + }); + expect(getOsVersion()).to.equal('Windows 7'); + }); + it('should detect Search Bot', function () { + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', + configurable: true, + }); + expect(getOsVersion()).to.equal('Search Bot'); + }); + it('should return unknown for an unrecognized user agent', function () { + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Unknown OS) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + configurable: true, + }); + expect(getOsVersion()).to.equal('unknown'); + }); + }); +});