From 84fc855fc10706b256467fdfa5e9b6a5add01005 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Rufiange?= Date: Fri, 1 Nov 2024 18:35:06 -0400 Subject: [PATCH 1/4] feat: ui events --- modules/contxtfulRtdProvider.js | 151 +++++++--- .../spec/modules/contxtfulRtdProvider_spec.js | 273 ++++++++++++------ 2 files changed, 301 insertions(+), 123 deletions(-) diff --git a/modules/contxtfulRtdProvider.js b/modules/contxtfulRtdProvider.js index a0d11328427..ff54526abfb 100644 --- a/modules/contxtfulRtdProvider.js +++ b/modules/contxtfulRtdProvider.js @@ -27,7 +27,7 @@ const CONTXTFUL_RECEPTIVITY_DOMAIN = 'api.receptivity.io'; const storageManager = getStorageManager({ moduleType: MODULE_TYPE_RTD, - moduleName: MODULE_NAME + moduleName: MODULE_NAME, }); let rxApi = null; @@ -43,14 +43,11 @@ function getRxEngineReceptivity(requester) { } function getItemFromSessionStorage(key) { - let value = null; try { - // Use the Storage Manager - value = storageManager.getDataFromSessionStorage(key, null); + return storageManager.getDataFromSessionStorage(key); } catch (error) { + logError(MODULE, error); } - - return value; } function loadSessionReceptivity(requester) { @@ -102,6 +99,9 @@ function init(config) { try { initCustomer(config); + + observeLastCursorPosition(); + return true; } catch (error) { logError(MODULE, error); @@ -170,6 +170,9 @@ function addConnectorEventListener(tagId, prebidConfig) { } config['prebid'] = prebidConfig || {}; rxApi = await rxApiBuilder(config); + + // Remove listener now that we can use rxApi. + removeListeners(); } ); } @@ -189,8 +192,11 @@ function getTargetingData(adUnits, config, _userConsent) { logInfo(MODULE, 'getTargetingData'); const requester = config?.params?.customer; - const rx = getRxEngineReceptivity(requester) || - loadSessionReceptivity(requester) || {}; + const rx = + getRxEngineReceptivity(requester) || + loadSessionReceptivity(requester) || + {}; + if (isEmpty(rx)) { return {}; } @@ -215,9 +221,10 @@ function getBidRequestData(reqBidsConfigObj, onDone, config, userConsent) { function onReturn() { if (isFirstBidRequestCall) { isFirstBidRequestCall = false; - }; + } onDone(); } + logInfo(MODULE, 'getBidRequestData'); const bidders = config?.params?.bidders || []; if (isEmpty(bidders) || !isArray(bidders)) { @@ -225,46 +232,31 @@ function getBidRequestData(reqBidsConfigObj, onDone, config, userConsent) { return; } - let fromApiBatched = () => rxApi?.receptivityBatched?.(bidders); - let fromApiSingle = () => prepareBatch(bidders, getRxEngineReceptivity); - let fromStorage = () => prepareBatch(bidders, loadSessionReceptivity); - - function tryMethods(methods) { - for (let method of methods) { - try { - let batch = method(); - if (!isEmpty(batch)) { - return batch; - } - } catch (error) { } - } - return {}; + let fromApi = rxApi?.receptivityBatched?.(bidders) || {}; + let fromStorage = prepareBatch(bidders, (bidder) => loadSessionReceptivity(`${config?.params?.customer}_${bidder}`)); + + let sources = [fromStorage, fromApi]; + if (isFirstBidRequestCall) { + sources.reverse(); } - let rxBatch = {}; - try { - if (isFirstBidRequestCall) { - rxBatch = tryMethods([fromStorage, fromApiBatched, fromApiSingle]); - } else { - rxBatch = tryMethods([fromApiBatched, fromApiSingle, fromStorage]) - } - } catch (error) { } + let rxBatch = Object.assign(...sources); + + let singlePointEvents; if (isEmpty(rxBatch)) { - onReturn(); - return; + singlePointEvents = btoa(JSON.stringify({ ui: getUiEvents() })); } bidders - .map((bidderCode) => ({ bidderCode, rx: rxBatch[bidderCode] })) - .filter(({ rx }) => !isEmpty(rx)) - .forEach(({ bidderCode, rx }) => { + .forEach(bidderCode => { const ortb2 = { user: { data: [ { name: MODULE_NAME, ext: { - rx, + rx: rxBatch[bidderCode], + events: singlePointEvents, params: { ev: config.params?.version, ci: config.params?.customer, @@ -274,13 +266,96 @@ function getBidRequestData(reqBidsConfigObj, onDone, config, userConsent) { ], }, }; + mergeDeep(reqBidsConfigObj.ortb2Fragments?.bidder, { [bidderCode]: ortb2, }); }); onReturn(); -}; +} + +function getUiEvents() { + return { + position: lastCursorPosition, + screen: getScreen(), + }; +} + +function getScreen() { + function getInnerSize() { + let w = window?.innerWidth; + let h = window?.innerHeight; + + if (w && h) { + return [w, h]; + } + } + + function getDocumentSize() { + let body = window?.document?.body; + let w = body.clientWidth; + let h = body.clientHeight; + + if (w && h) { + return [w, h]; + } + } + + // If we cannot access or cast the window dimensions, we get None. + // If we cannot collect the size from the window we try to use the root document dimensions + let [width, height] = getInnerSize() || getDocumentSize() || [0, 0]; + let topLeft = { x: window.scrollX, y: window.scrollY }; + + return { + topLeft, + width, + height, + timestampMs: performance.now(), + }; +} + +let lastCursorPosition; + +function observeLastCursorPosition() { + function pointerEventToPosition(event) { + lastCursorPosition = { + x: event.clientX, + y: event.clientY, + timestampMs: performance.now() + }; + } + + function touchEventToPosition(event) { + let touch = event.touches.item(0); + if (!touch) { + return; + } + + lastCursorPosition = { + x: touch.clientX, + y: touch.clientY, + timestampMs: performance.now() + }; + } + + addListener('pointermove', pointerEventToPosition); + addListener('touchmove', touchEventToPosition); +} + +let listeners = {}; +function addListener(name, listener) { + listeners[name] = listener; + + window.addEventListener(name, listener); +} + +function removeListeners() { + for (const name in listeners) { + window.removeEventListener(name, listeners[name]); + delete listeners[name]; + } +} export const contxtfulSubmodule = { name: MODULE_NAME, diff --git a/test/spec/modules/contxtfulRtdProvider_spec.js b/test/spec/modules/contxtfulRtdProvider_spec.js index ad79b051393..d9c06d1cd1c 100644 --- a/test/spec/modules/contxtfulRtdProvider_spec.js +++ b/test/spec/modules/contxtfulRtdProvider_spec.js @@ -1,7 +1,12 @@ import { contxtfulSubmodule, extractParameters } from '../../../modules/contxtfulRtdProvider.js'; import { expect } from 'chai'; import { loadExternalScriptStub } from 'test/mocks/adloaderStub.js'; +import { getStorageManager } from '../../../src/storageManager.js'; +import { MODULE_TYPE_UID } from '../../../src/activities/modules.js'; import * as events from '../../../src/events'; +import Sinon from 'sinon'; + +const MODULE_NAME = 'contxtful'; const VERSION = 'v1'; const CUSTOMER = 'CUSTOMER'; @@ -10,7 +15,7 @@ const CONTXTFUL_CONNECTOR_ENDPOINT = `https://api.receptivity.io/${VERSION}/preb const RX_FROM_SESSION_STORAGE = { ReceptivityState: 'Receptive', test_info: 'rx_from_session_storage' }; const RX_FROM_API = { ReceptivityState: 'Receptive', test_info: 'rx_from_engine' }; -const RX_API_MOCK = { receptivity: sinon.stub(), }; +const RX_API_MOCK = { receptivity: sinon.stub(), receptivityBatched: sinon.stub() }; const RX_CONNECTOR_MOCK = { fetchConfig: sinon.stub(), rxApiBuilder: sinon.stub(), @@ -19,13 +24,6 @@ const RX_CONNECTOR_MOCK = { const TIMEOUT = 10; const RX_CONNECTOR_IS_READY_EVENT = new CustomEvent('rxConnectorIsReady', { detail: {[CUSTOMER]: RX_CONNECTOR_MOCK}, bubbles: true }); -function writeToStorage(requester, timeDiff) { - let rx = RX_FROM_SESSION_STORAGE; - let exp = new Date().getTime() + timeDiff; - let item = { rx, exp, }; - sessionStorage.setItem(requester, JSON.stringify(item),); -} - function buildInitConfig(version, customer) { return { name: 'contxtful', @@ -44,12 +42,17 @@ describe('contxtfulRtdProvider', function () { let loadExternalScriptTag; let eventsEmitSpy; + const storage = getStorageManager({ moduleType: MODULE_TYPE_UID, moduleName: MODULE_NAME }); + beforeEach(() => { loadExternalScriptTag = document.createElement('script'); loadExternalScriptStub.callsFake((_url, _moduleName) => loadExternalScriptTag); RX_API_MOCK.receptivity.reset(); - RX_API_MOCK.receptivity.callsFake((tagId) => RX_FROM_API); + RX_API_MOCK.receptivity.callsFake(() => RX_FROM_API); + + RX_API_MOCK.receptivityBatched.reset(); + RX_API_MOCK.receptivityBatched.callsFake((bidders) => bidders.reduce((accumulator, bidder) => { accumulator[bidder] = RX_FROM_API; return accumulator; }, {})); RX_CONNECTOR_MOCK.fetchConfig.reset(); RX_CONNECTOR_MOCK.fetchConfig.callsFake((tagId) => new Promise((resolve, reject) => resolve({ tag_id: tagId }))); @@ -249,7 +252,7 @@ describe('contxtfulRtdProvider', function () { setTimeout(() => { let targetingData = contxtfulSubmodule.getTargetingData(adUnits, config); - expect(targetingData, description).to.deep.equal(expected); + expect(targetingData, description).to.deep.equal(expected, description); done(); }, TIMEOUT); }); @@ -322,22 +325,18 @@ describe('contxtfulRtdProvider', function () { ]; theories.forEach(([adUnits, expected, _description]) => { - // TODO: commented out because of rule violations - /* it('uses non-expired info from session storage and adds receptivity to the ad units using session storage', function (done) { - let config = buildInitConfig(VERSION, CUSTOMER); // Simulate that there was a write to sessionStorage in the past. - writeToStorage(config.params.customer, +100); + storage.setDataInSessionStorage(CUSTOMER, JSON.stringify({exp: new Date().getTime() + 1000, rx: RX_FROM_SESSION_STORAGE})) + + let config = buildInitConfig(VERSION, CUSTOMER); contxtfulSubmodule.init(config); - setTimeout(() => { - expect(contxtfulSubmodule.getTargetingData(adUnits, config)).to.deep.equal( - expected - ); - done(); - }, TIMEOUT); + let targetingData = contxtfulSubmodule.getTargetingData(adUnits, config); + expect(targetingData).to.deep.equal(expected); + + done(); }); - */ }); }); @@ -360,13 +359,15 @@ describe('contxtfulRtdProvider', function () { theories.forEach(([adUnits, expected, _description]) => { it('ignores expired info from session storage and does not forward the info to ad units', function (done) { - let config = buildInitConfig(VERSION, CUSTOMER); // Simulate that there was a write to sessionStorage in the past. - writeToStorage(config.params.customer, -100); + storage.setDataInSessionStorage(CUSTOMER, JSON.stringify({exp: new Date().getTime() - 100, rx: RX_FROM_SESSION_STORAGE})); + + let config = buildInitConfig(VERSION, CUSTOMER); contxtfulSubmodule.init(config); - expect(contxtfulSubmodule.getTargetingData(adUnits, config)).to.deep.equal( - expected - ); + + let targetingData = contxtfulSubmodule.getTargetingData(adUnits, config); + expect(targetingData).to.deep.equal(expected); + done(); }); }); @@ -428,42 +429,39 @@ describe('contxtfulRtdProvider', function () { }, }; - let expectedOrtb2 = { - user: { - data: [ - { - name: 'contxtful', - ext: { - rx: RX_FROM_API, - params: { - ev: config.params?.version, - ci: config.params?.customer, - }, - }, - }, - ], + let expectedData = { + name: 'contxtful', + ext: { + rx: RX_FROM_API, + params: { + ev: config.params?.version, + ci: config.params?.customer, + }, }, }; setTimeout(() => { - const onDone = () => undefined; - contxtfulSubmodule.getBidRequestData(reqBidsConfigObj, onDone, config); - let actualOrtb2 = reqBidsConfigObj.ortb2Fragments.bidder[config.params.bidders[0]]; - expect(actualOrtb2).to.deep.equal(expectedOrtb2); + const onDoneSpy = sinon.spy(); + contxtfulSubmodule.getBidRequestData(reqBidsConfigObj, onDoneSpy, config); + + let data = reqBidsConfigObj.ortb2Fragments.bidder[config.params.bidders[0]].user.data[0]; + + expect(data.name).to.deep.equal(expectedData.name); + expect(data.ext.rx).to.deep.equal(expectedData.ext.rx); + expect(data.ext.params).to.deep.equal(expectedData.ext.params); done(); }, TIMEOUT); }); }); describe('getBidRequestData', function () { - // TODO: commented out because of rule violations - /* it('uses non-expired info from session storage and adds receptivity to the reqBidsConfigObj', function (done) { let config = buildInitConfig(VERSION, CUSTOMER); + // Simulate that there was a write to sessionStorage in the past. - writeToStorage(config.params.bidders[0], +100); + let bidder = config.params.bidders[0]; - contxtfulSubmodule.init(config); + storage.setDataInSessionStorage(`${config.params.customer}_${bidder}`, JSON.stringify({exp: new Date().getTime() + 1000, rx: RX_FROM_SESSION_STORAGE})); let reqBidsConfigObj = { ortb2Fragments: { @@ -472,33 +470,26 @@ describe('contxtfulRtdProvider', function () { }, }; - let expectedOrtb2 = { - user: { - data: [ - { - name: 'contxtful', - ext: { - rx: RX_FROM_SESSION_STORAGE, - params: { - ev: config.params?.version, - ci: config.params?.customer, - }, - }, - }, - ], - }, - }; + contxtfulSubmodule.init(config); // Since the RX_CONNECTOR_IS_READY_EVENT event was not dispatched, the RX engine is not loaded. + contxtfulSubmodule.getBidRequestData(reqBidsConfigObj, () => {}, config); + setTimeout(() => { - const noOp = () => undefined; - contxtfulSubmodule.getBidRequestData(reqBidsConfigObj, noOp, buildInitConfig(VERSION, CUSTOMER)); - let actualOrtb2 = reqBidsConfigObj.ortb2Fragments.bidder[config.params.bidders[0]]; - expect(actualOrtb2).to.deep.equal(expectedOrtb2); + let ortb2BidderFragment = reqBidsConfigObj.ortb2Fragments.bidder[bidder]; + let userData = ortb2BidderFragment.user.data; + let contxtfulData = userData[0]; + + expect(contxtfulData.name).to.be.equal('contxtful'); + expect(contxtfulData.ext.rx).to.deep.equal(RX_FROM_SESSION_STORAGE); + expect(contxtfulData.ext.params).to.deep.equal({ + ev: config.params.version, + ci: config.params.customer, + }); + done(); }, TIMEOUT); }); - */ }); describe('getBidRequestData', function () { @@ -520,7 +511,7 @@ describe('contxtfulRtdProvider', function () { const onDoneSpy = sinon.spy(); contxtfulSubmodule.getBidRequestData(reqBidsConfigObj, onDoneSpy, config); expect(onDoneSpy.callCount).to.equal(1); - expect(RX_API_MOCK.receptivity.callCount).to.equal(1); + expect(RX_API_MOCK.receptivityBatched.callCount).to.equal(1); done(); }, TIMEOUT); }); @@ -539,29 +530,141 @@ describe('contxtfulRtdProvider', function () { }, }; - let ortb2 = { - user: { - data: [ - { - name: 'contxtful', - ext: { - rx: RX_FROM_API, - params: { - ev: config.params?.version, - ci: config.params?.customer, - }, - }, - }, - ], + let expectedData = { + name: 'contxtful', + ext: { + rx: RX_FROM_API, + params: { + ev: config.params?.version, + ci: config.params?.customer, + }, }, }; setTimeout(() => { const onDoneSpy = sinon.spy(); contxtfulSubmodule.getBidRequestData(reqBidsConfigObj, onDoneSpy, config); - expect(reqBidsConfigObj.ortb2Fragments.bidder[config.params.bidders[0]]).to.deep.equal(ortb2); + + let data = reqBidsConfigObj.ortb2Fragments.bidder[config.params.bidders[0]].user.data[0]; + + expect(data.name).to.deep.equal(expectedData.name); + expect(data.ext.rx).to.deep.equal(expectedData.ext.rx); + expect(data.ext.params).to.deep.equal(expectedData.ext.params); done(); }, TIMEOUT); }); + + describe('before rxApi is loaded', function () { + const moveEventTheories = [ + [ + new PointerEvent('pointermove', { clientX: 1, clientY: 2 }), + { x: 1, y: 2 }, + 'pointer move', + ], + [ + new TouchEvent('touchmove', { + touches: [ + new Touch({ + identifier: 1, + target: window.document, + clientX: 11, + clientY: 22, + }), + ], + }), + { x: 11, y: 22 }, + 'touch move', + ], + ]; + + moveEventTheories.forEach(([event, expected, _description]) => { + it('adds move event', function (done) { + let config = buildInitConfig(VERSION, CUSTOMER); + contxtfulSubmodule.init(config); + + window.dispatchEvent(event); + + let reqBidsConfigObj = { + ortb2Fragments: { + global: {}, + bidder: {}, + }, + }; + + setTimeout(() => { + const onDoneSpy = sinon.spy(); + contxtfulSubmodule.getBidRequestData(reqBidsConfigObj, onDoneSpy, config); + + let ext = reqBidsConfigObj.ortb2Fragments.bidder[config.params.bidders[0]].user.data[0].ext; + + let events = JSON.parse(atob(ext.events)); + + expect(events.ui.position.x).to.be.deep.equal(expected.x); + expect(events.ui.position.y).to.be.deep.equal(expected.y); + expect(Sinon.match.number.test(events.ui.position.timestampMs)).to.be.true; + done(); + }, TIMEOUT); + }); + }); + + it('adds screen event', function (done) { + let config = buildInitConfig(VERSION, CUSTOMER); + contxtfulSubmodule.init(config); + + // Cannot change the window size from JS + // So we take the current size as expectation + const width = window.innerWidth; + const height = window.innerHeight; + + let reqBidsConfigObj = { + ortb2Fragments: { + global: {}, + bidder: {}, + }, + }; + + setTimeout(() => { + const onDoneSpy = sinon.spy(); + contxtfulSubmodule.getBidRequestData(reqBidsConfigObj, onDoneSpy, config); + + let ext = reqBidsConfigObj.ortb2Fragments.bidder[config.params.bidders[0]].user.data[0].ext; + + let events = JSON.parse(atob(ext.events)); + + expect(events.ui.screen.topLeft).to.be.deep.equal({ x: 0, y: 0 }, 'screen top left'); + expect(events.ui.screen.width).to.be.deep.equal(width, 'screen width'); + expect(events.ui.screen.height).to.be.deep.equal(height, 'screen height'); + expect(Sinon.match.number.test(events.ui.screen.timestampMs), 'screen timestamp').to.be.true; + done(); + }, TIMEOUT); + }); + }) }); + + describe('after rxApi is loaded', function () { + it('does not add event', function (done) { + let config = buildInitConfig(VERSION, CUSTOMER); + contxtfulSubmodule.init(config); + window.dispatchEvent(RX_CONNECTOR_IS_READY_EVENT); + + let reqBidsConfigObj = { + ortb2Fragments: { + global: {}, + bidder: {}, + }, + }; + + setTimeout(() => { + const onDoneSpy = sinon.spy(); + contxtfulSubmodule.getBidRequestData(reqBidsConfigObj, onDoneSpy, config); + + let ext = reqBidsConfigObj.ortb2Fragments.bidder[config.params.bidders[0]].user.data[0].ext; + + let events = ext.events; + + expect(events).to.be.undefined; + done(); + }, TIMEOUT); + }); + }) }); From 4e518f2b1dedaa1481f359bb8241fb6de7f90ce8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Rufiange?= Date: Fri, 1 Nov 2024 19:35:34 -0400 Subject: [PATCH 2/4] doc: wording --- modules/contxtfulRtdProvider.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/contxtfulRtdProvider.md b/modules/contxtfulRtdProvider.md index 622b353c27a..1283b42548d 100644 --- a/modules/contxtfulRtdProvider.md +++ b/modules/contxtfulRtdProvider.md @@ -72,7 +72,7 @@ pbjs.setConfig({ | `customer` | `String` | Required | Your unique customer identifier. | | `hostname` | `String` | Optional | Target URL for CONTXTFUL external JavaScript file. Default is "api.receptivity.io". Changing default behaviour is not recommended. Please reach out to contact@contxtful.com if you experience issues. | | `adServerTargeting` | `Boolean`| Optional | Enables the `getTargetingData` to inject targeting value in ad units. Setting to true enables the feature, false disables the feature. Default is true | -| `bidders` | `Array` | Optional | Setting this array enables Receptivity in the `ortb2` object through `getBidRequestData` for all the listed `bidders`. Default is `[]` (an empty array). RECOMMENDED : Add all the bidders active like this `["bidderCode1", "bidderCode", "..."]` | +| `bidders` | `Array` | Optional | Setting this array enables Receptivity in the `ortb2` object through `getBidRequestData` for all the listed `bidders`. Default is `[]` (an empty array). RECOMMENDED : Add all the active bidders like this `["bidderCode1", "bidderCode", "..."]` | ## Usage: Injection in Ad Servers From c566afa8614f61ef04358e6739ca05b414f056f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Rufiange?= Date: Fri, 1 Nov 2024 22:16:24 -0400 Subject: [PATCH 3/4] doc: revised to re-run ci --- modules/contxtfulRtdProvider.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/contxtfulRtdProvider.md b/modules/contxtfulRtdProvider.md index 1283b42548d..de2376e782d 100644 --- a/modules/contxtfulRtdProvider.md +++ b/modules/contxtfulRtdProvider.md @@ -8,7 +8,7 @@ The Contxtful RTD module offers a unique feature—Receptivity. Receptivity is an efficiency metric, enabling the qualification of any instant in a session in real time based on attention. The core idea is straightforward: the likelihood of an ad’s success increases when it grabs attention and is presented in the right context at the right time. -To utilize this module, you need to register for an account with [Contxtful](https://contxtful.com). For inquiries, please contact [contact@contxtful.com](mailto:contact@contxtful.com). +To utilize this module, you need to register for an account with [Contxtful](https://contxtful.com). For inquiries, please reach out to [contact@contxtful.com](mailto:contact@contxtful.com). ## Build Instructions From e1e218855cf3bd1de98ee73c32c5020918b3c536 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Rufiange?= Date: Fri, 1 Nov 2024 23:15:58 -0400 Subject: [PATCH 4/4] test: removed os-specific test --- test/spec/modules/contxtfulRtdProvider_spec.js | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/test/spec/modules/contxtfulRtdProvider_spec.js b/test/spec/modules/contxtfulRtdProvider_spec.js index d9c06d1cd1c..e31ef554da0 100644 --- a/test/spec/modules/contxtfulRtdProvider_spec.js +++ b/test/spec/modules/contxtfulRtdProvider_spec.js @@ -560,21 +560,7 @@ describe('contxtfulRtdProvider', function () { new PointerEvent('pointermove', { clientX: 1, clientY: 2 }), { x: 1, y: 2 }, 'pointer move', - ], - [ - new TouchEvent('touchmove', { - touches: [ - new Touch({ - identifier: 1, - target: window.document, - clientX: 11, - clientY: 22, - }), - ], - }), - { x: 11, y: 22 }, - 'touch move', - ], + ] ]; moveEventTheories.forEach(([event, expected, _description]) => {