From 30ba12c54851d8376cc2ce67e3a196d5b5646c93 Mon Sep 17 00:00:00 2001 From: Aleksei Pavlov Date: Fri, 5 Jun 2026 20:24:47 +0200 Subject: [PATCH 1/2] [CDX-201] Add support for tracking window parameters in autocomplete, browse, recommendations, and search --- cspell.json | 3 +- spec/src/modules/autocomplete.js | 65 +++++++++++ spec/src/modules/browse.js | 65 +++++++++++ spec/src/modules/recommendations.js | 65 +++++++++++ spec/src/modules/search.js | 82 ++++++++++++++ spec/src/modules/tracker.js | 21 ++++ spec/src/utils/helpers.js | 167 ++++++++++++++++++++++++++++ src/constructorio.js | 21 +++- src/modules/recommendations.js | 9 +- src/types/index.d.ts | 1 + src/utils/helpers.js | 49 ++++++++ 11 files changed, 544 insertions(+), 4 deletions(-) diff --git a/cspell.json b/cspell.json index b91c2462..df8b430d 100644 --- a/cspell.json +++ b/cspell.json @@ -49,6 +49,7 @@ "testdata", "Bytespider", "Timespans", - "googlequicksearchbox" + "googlequicksearchbox", + "cnstrc" ] } diff --git a/spec/src/modules/autocomplete.js b/spec/src/modules/autocomplete.js index 7ac2354e..2ef4c430 100644 --- a/spec/src/modules/autocomplete.js +++ b/spec/src/modules/autocomplete.js @@ -598,6 +598,71 @@ describe(`ConstructorIO - Autocomplete${bundledDescriptionSuffix}`, () => { autocomplete.getAutocompleteResults(query); }); + it('Should include window global userId when trackWindowParameters is true and options.userId is absent', (done) => { + window.cnstrc = { userId: 'window-user-id' }; + const { autocomplete } = new ConstructorIO({ + apiKey: testApiKey, + trackWindowParameters: true, + fetch: fetchSpy, + }); + + autocomplete.getAutocompleteResults(query).then(() => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + expect(requestedUrlParams).to.have.property('ui').to.equal('window-user-id'); + delete window.cnstrc; + done(); + }); + }); + + it('Should include window global testCells when trackWindowParameters is true and options.testCells is absent', (done) => { + window.cnstrc = { testCells: { experiment: 'variation_a' } }; + const { autocomplete } = new ConstructorIO({ + apiKey: testApiKey, + trackWindowParameters: true, + fetch: fetchSpy, + }); + + autocomplete.getAutocompleteResults(query).then(() => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + expect(requestedUrlParams).to.have.property('ef-experiment').to.equal('variation_a'); + delete window.cnstrc; + done(); + }); + }); + + it('Should include window global userSegments when trackWindowParameters is true and options.segments is absent', (done) => { + window.cnstrc = { userSegments: ['vip', 'beta'] }; + const { autocomplete } = new ConstructorIO({ + apiKey: testApiKey, + trackWindowParameters: true, + fetch: fetchSpy, + }); + + autocomplete.getAutocompleteResults(query).then(() => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + expect(requestedUrlParams).to.have.property('us').to.deep.equal(['vip', 'beta']); + delete window.cnstrc; + done(); + }); + }); + + it('Should not include window globals when trackWindowParameters is false', (done) => { + window.cnstrc = { userId: 'window-user-id', testCells: { exp: 'var' }, userSegments: ['seg'] }; + const { autocomplete } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + }); + + autocomplete.getAutocompleteResults(query).then(() => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + expect(requestedUrlParams).to.not.have.property('ui'); + expect(requestedUrlParams).to.not.have.property('ef-exp'); + expect(requestedUrlParams).to.not.have.property('us'); + delete window.cnstrc; + done(); + }); + }); + it('Should be rejected when invalid query is provided', () => { const { autocomplete } = new ConstructorIO({ apiKey: testApiKey }); diff --git a/spec/src/modules/browse.js b/spec/src/modules/browse.js index d3db86ef..5d95679c 100644 --- a/spec/src/modules/browse.js +++ b/spec/src/modules/browse.js @@ -153,6 +153,71 @@ describe(`ConstructorIO - Browse${bundledDescriptionSuffix}`, () => { }); }); + it('Should include window global userId when trackWindowParameters is true and options.userId is absent', (done) => { + window.cnstrc = { userId: 'window-user-id' }; + const { browse } = new ConstructorIO({ + apiKey: testApiKey, + trackWindowParameters: true, + fetch: fetchSpy, + }); + + browse.getBrowseResults(filterName, filterValue).then(() => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + expect(requestedUrlParams).to.have.property('ui').to.equal('window-user-id'); + delete window.cnstrc; + done(); + }); + }); + + it('Should include window global testCells when trackWindowParameters is true and options.testCells is absent', (done) => { + window.cnstrc = { testCells: { experiment: 'variation_a' } }; + const { browse } = new ConstructorIO({ + apiKey: testApiKey, + trackWindowParameters: true, + fetch: fetchSpy, + }); + + browse.getBrowseResults(filterName, filterValue).then(() => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + expect(requestedUrlParams).to.have.property('ef-experiment').to.equal('variation_a'); + delete window.cnstrc; + done(); + }); + }); + + it('Should include window global userSegments when trackWindowParameters is true and options.segments is absent', (done) => { + window.cnstrc = { userSegments: ['vip', 'beta'] }; + const { browse } = new ConstructorIO({ + apiKey: testApiKey, + trackWindowParameters: true, + fetch: fetchSpy, + }); + + browse.getBrowseResults(filterName, filterValue).then(() => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + expect(requestedUrlParams).to.have.property('us').to.deep.equal(['vip', 'beta']); + delete window.cnstrc; + done(); + }); + }); + + it('Should not include window globals when trackWindowParameters is false', (done) => { + window.cnstrc = { userId: 'window-user-id', testCells: { exp: 'var' }, userSegments: ['seg'] }; + const { browse } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + }); + + browse.getBrowseResults(filterName, filterValue).then(() => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + expect(requestedUrlParams).to.not.have.property('ui'); + expect(requestedUrlParams).to.not.have.property('ef-exp'); + expect(requestedUrlParams).to.not.have.property('us'); + delete window.cnstrc; + done(); + }); + }); + it('Should return a response with a valid filterName, filterValue and page', (done) => { const page = 1; const { browse } = new ConstructorIO({ diff --git a/spec/src/modules/recommendations.js b/spec/src/modules/recommendations.js index 53dfaf70..0d35177d 100644 --- a/spec/src/modules/recommendations.js +++ b/spec/src/modules/recommendations.js @@ -574,5 +574,70 @@ describe(`ConstructorIO - Recommendations${bundledDescriptionSuffix}`, () => { )).to.eventually.be.rejectedWith(timeoutRejectionMessage); }); } + + it('Should include window global userId when trackWindowParameters is true and options.userId is absent', (done) => { + window.cnstrc = { userId: 'window-user-id' }; + const { recommendations } = new ConstructorIO({ + apiKey: testApiKey, + trackWindowParameters: true, + fetch: fetchSpy, + }); + + recommendations.getRecommendations(podId, { itemIds: itemId }).then(() => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + expect(requestedUrlParams).to.have.property('ui').to.equal('window-user-id'); + delete window.cnstrc; + done(); + }); + }); + + it('Should include window global testCells when trackWindowParameters is true and options.testCells is absent', (done) => { + window.cnstrc = { testCells: { experiment: 'variation_a' } }; + const { recommendations } = new ConstructorIO({ + apiKey: testApiKey, + trackWindowParameters: true, + fetch: fetchSpy, + }); + + recommendations.getRecommendations(podId, { itemIds: itemId }).then(() => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + expect(requestedUrlParams).to.have.property('ef-experiment').to.equal('variation_a'); + delete window.cnstrc; + done(); + }); + }); + + it('Should include window global userSegments when trackWindowParameters is true and options.segments is absent', (done) => { + window.cnstrc = { userSegments: ['vip', 'beta'] }; + const { recommendations } = new ConstructorIO({ + apiKey: testApiKey, + trackWindowParameters: true, + fetch: fetchSpy, + }); + + recommendations.getRecommendations(podId, { itemIds: itemId }).then(() => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + expect(requestedUrlParams).to.have.property('us').to.deep.equal(['vip', 'beta']); + delete window.cnstrc; + done(); + }); + }); + + it('Should not include window globals when trackWindowParameters is false', (done) => { + window.cnstrc = { userId: 'window-user-id', testCells: { exp: 'var' }, userSegments: ['seg'] }; + const { recommendations } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + }); + + recommendations.getRecommendations(podId, { itemIds: itemId }).then(() => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + expect(requestedUrlParams).to.not.have.property('ui'); + expect(requestedUrlParams).to.not.have.property('ef-exp'); + expect(requestedUrlParams).to.not.have.property('us'); + delete window.cnstrc; + done(); + }); + }); }); }); diff --git a/spec/src/modules/search.js b/spec/src/modules/search.js index aaa65890..d049600f 100644 --- a/spec/src/modules/search.js +++ b/spec/src/modules/search.js @@ -150,6 +150,88 @@ describe(`ConstructorIO - Search${bundledDescriptionSuffix}`, () => { }); }); + it('Should include window global userId when trackWindowParameters is true and options.userId is absent', (done) => { + window.cnstrc = { userId: 'window-user-id' }; + const { search } = new ConstructorIO({ + apiKey: testApiKey, + trackWindowParameters: true, + fetch: fetchSpy, + }); + + search.getSearchResults(query, { section }).then(() => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + expect(requestedUrlParams).to.have.property('ui').to.equal('window-user-id'); + delete window.cnstrc; + done(); + }); + }); + + it('Should include window global testCells when trackWindowParameters is true and options.testCells is absent', (done) => { + window.cnstrc = { testCells: { experiment: 'variation_a' } }; + const { search } = new ConstructorIO({ + apiKey: testApiKey, + trackWindowParameters: true, + fetch: fetchSpy, + }); + + search.getSearchResults(query, { section }).then(() => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + expect(requestedUrlParams).to.have.property('ef-experiment').to.equal('variation_a'); + delete window.cnstrc; + done(); + }); + }); + + it('Should include window global userSegments when trackWindowParameters is true and options.segments is absent', (done) => { + window.cnstrc = { userSegments: ['vip', 'beta'] }; + const { search } = new ConstructorIO({ + apiKey: testApiKey, + trackWindowParameters: true, + fetch: fetchSpy, + }); + + search.getSearchResults(query, { section }).then(() => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + expect(requestedUrlParams).to.have.property('us').to.deep.equal(['vip', 'beta']); + delete window.cnstrc; + done(); + }); + }); + + it('Should prefer options.userId over window global when trackWindowParameters is true', (done) => { + window.cnstrc = { userId: 'window-user-id' }; + const { search } = new ConstructorIO({ + apiKey: testApiKey, + userId: 'options-user-id', + trackWindowParameters: true, + fetch: fetchSpy, + }); + + search.getSearchResults(query, { section }).then(() => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + expect(requestedUrlParams).to.have.property('ui').to.equal('options-user-id'); + delete window.cnstrc; + done(); + }); + }); + + it('Should not include window globals when trackWindowParameters is false', (done) => { + window.cnstrc = { userId: 'window-user-id', testCells: { exp: 'var' }, userSegments: ['seg'] }; + const { search } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + }); + + search.getSearchResults(query, { section }).then(() => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + expect(requestedUrlParams).to.not.have.property('ui'); + expect(requestedUrlParams).to.not.have.property('ef-exp'); + expect(requestedUrlParams).to.not.have.property('us'); + delete window.cnstrc; + done(); + }); + }); + it('Should return a response with a valid query, section, and offset', (done) => { const offset = 1; const { search } = new ConstructorIO({ diff --git a/spec/src/modules/tracker.js b/spec/src/modules/tracker.js index fc275beb..0d273af4 100644 --- a/spec/src/modules/tracker.js +++ b/spec/src/modules/tracker.js @@ -459,6 +459,27 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { expect(tracker.trackSessionStart()).to.equal(true); }); + + it('Should not include window globals in tracking requests even when trackWindowParameters is true', (done) => { + window.cnstrc = { userId: 'window-user-id', testCells: { exp: 'var' }, userSegments: ['seg'] }; + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + trackWindowParameters: true, + fetch: fetchSpy, + ...requestQueueOptions, + }); + + tracker.on('success', () => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + expect(requestedUrlParams).to.not.have.property('ui'); + expect(requestedUrlParams).to.not.have.property('ef-exp'); + expect(requestedUrlParams).to.not.have.property('us'); + delete window.cnstrc; + done(); + }); + + expect(tracker.trackSessionStart()).to.equal(true); + }); }); describe('trackInputFocus', () => { diff --git a/spec/src/utils/helpers.js b/spec/src/utils/helpers.js index 292e33c5..2a95a27c 100644 --- a/spec/src/utils/helpers.js +++ b/spec/src/utils/helpers.js @@ -27,6 +27,7 @@ const { trimUrl, cleanAndValidateUrl, toValidTestCells, + applyWindowParameterGetters, } = require('../../../test/utils/helpers'); // eslint-disable-line import/extensions const jsdom = require('./jsdom-global'); const store = require('../../../test/utils/store'); // eslint-disable-line import/extensions @@ -693,6 +694,172 @@ describe('ConstructorIO - Utils - Helpers', () => { expect(result.length).to.be.at.most(2000); }); }); + + describe('applyWindowParameterGetters', () => { + const jsdomLocal = require('./jsdom-global'); + let cleanup; + + beforeEach(() => { + cleanup = jsdomLocal({ url: 'http://localhost' }); + }); + + afterEach(() => { + delete window.cnstrc; + delete window.cnstrcUserId; + delete window.cnstrcTestCells; + delete window.cnstrcUserSegments; + cleanup(); + }); + + it('Should fall back to window.cnstrc.userId when options.userId is absent', () => { + window.cnstrc = { userId: 'cnstrc-user' }; + const options = {}; + applyWindowParameterGetters(options); + expect(options.userId).to.equal('cnstrc-user'); + }); + + it('Should fall back to window.cnstrcUserId when window.cnstrc does not exist', () => { + window.cnstrcUserId = 'global-user'; + const options = {}; + applyWindowParameterGetters(options); + expect(options.userId).to.equal('global-user'); + }); + + it('Should prefer window.cnstrc.userId over window.cnstrcUserId', () => { + window.cnstrc = { userId: 'cnstrc-user' }; + window.cnstrcUserId = 'global-user'; + const options = {}; + applyWindowParameterGetters(options); + expect(options.userId).to.equal('cnstrc-user'); + }); + + it('Should use options.userId when provided (priority over window globals)', () => { + window.cnstrc = { userId: 'cnstrc-user' }; + const options = { userId: 'options-user' }; + applyWindowParameterGetters(options); + expect(options.userId).to.equal('options-user'); + }); + + it('Should ignore non-string userId from window globals', () => { + window.cnstrc = { userId: 12345 }; + window.cnstrcUserId = { notAString: true }; + const options = {}; + applyWindowParameterGetters(options); + expect(options.userId).to.be.undefined; + }); + + it('Should ignore empty string userId from window globals', () => { + window.cnstrc = { userId: '' }; + const options = {}; + applyWindowParameterGetters(options); + expect(options.userId).to.be.undefined; + }); + + it('Should fall back to window.cnstrc.testCells when options.testCells is empty', () => { + window.cnstrc = { testCells: { foo: 'bar' } }; + const options = { testCells: {} }; + applyWindowParameterGetters(options); + expect(options.testCells).to.deep.equal({ foo: 'bar' }); + }); + + it('Should fall back to window.cnstrcTestCells when window.cnstrc does not exist', () => { + window.cnstrcTestCells = { baz: 'qux' }; + const options = { testCells: {} }; + applyWindowParameterGetters(options); + expect(options.testCells).to.deep.equal({ baz: 'qux' }); + }); + + it('Should prefer window.cnstrc.testCells over window.cnstrcTestCells', () => { + window.cnstrc = { testCells: { foo: 'bar' } }; + window.cnstrcTestCells = { baz: 'qux' }; + const options = { testCells: {} }; + applyWindowParameterGetters(options); + expect(options.testCells).to.deep.equal({ foo: 'bar' }); + }); + + it('Should use options.testCells when provided (priority over window globals)', () => { + window.cnstrc = { testCells: { foo: 'bar' } }; + const options = { testCells: { mine: 'value' } }; + applyWindowParameterGetters(options); + expect(options.testCells).to.deep.equal({ mine: 'value' }); + }); + + it('Should validate testCells from window through toValidTestCells', () => { + window.cnstrc = { testCells: { valid: 'yes', invalid: null, num: 123 } }; + const options = { testCells: {} }; + applyWindowParameterGetters(options); + expect(options.testCells).to.deep.equal({ valid: 'yes' }); + }); + + it('Should ignore non-object testCells from window globals', () => { + window.cnstrc = { testCells: 'not-an-object' }; + window.cnstrcTestCells = [1, 2, 3]; + const options = { testCells: {} }; + applyWindowParameterGetters(options); + expect(options.testCells).to.deep.equal({}); + }); + + it('Should fall back to window.cnstrc.userSegments when options.segments is empty', () => { + window.cnstrc = { userSegments: ['seg1', 'seg2'] }; + const options = { segments: [] }; + applyWindowParameterGetters(options); + expect(options.segments).to.deep.equal(['seg1', 'seg2']); + }); + + it('Should fall back to window.cnstrcUserSegments when window.cnstrc does not exist', () => { + window.cnstrcUserSegments = ['seg3']; + const options = {}; + applyWindowParameterGetters(options); + expect(options.segments).to.deep.equal(['seg3']); + }); + + it('Should prefer window.cnstrc.userSegments over window.cnstrcUserSegments', () => { + window.cnstrc = { userSegments: ['seg1'] }; + window.cnstrcUserSegments = ['seg2']; + const options = {}; + applyWindowParameterGetters(options); + expect(options.segments).to.deep.equal(['seg1']); + }); + + it('Should use options.segments when provided (priority over window globals)', () => { + window.cnstrc = { userSegments: ['window-seg'] }; + const options = { segments: ['option-seg'] }; + applyWindowParameterGetters(options); + expect(options.segments).to.deep.equal(['option-seg']); + }); + + it('Should ignore non-array userSegments from window globals', () => { + window.cnstrc = { userSegments: 'not-an-array' }; + window.cnstrcUserSegments = { notArray: true }; + const options = {}; + applyWindowParameterGetters(options); + expect(options.segments).to.be.undefined; + }); + + it('Should ignore empty array userSegments from window globals', () => { + window.cnstrc = { userSegments: [] }; + const options = {}; + applyWindowParameterGetters(options); + expect(options.segments).to.be.undefined; + }); + + it('Should allow setting values via setter after getters are applied', () => { + window.cnstrc = { userId: 'cnstrc-user' }; + const options = {}; + applyWindowParameterGetters(options); + expect(options.userId).to.equal('cnstrc-user'); + options.userId = 'new-user'; + expect(options.userId).to.equal('new-user'); + }); + + it('Should read latest window globals at access time', () => { + const options = {}; + applyWindowParameterGetters(options); + expect(options.userId).to.be.undefined; + window.cnstrc = { userId: 'late-user' }; + expect(options.userId).to.equal('late-user'); + }); + }); } describe('toValidTestCells', () => { diff --git a/src/constructorio.js b/src/constructorio.js index 8a469785..24ddfcb9 100644 --- a/src/constructorio.js +++ b/src/constructorio.js @@ -58,6 +58,7 @@ class ConstructorIO { * @param {object} [parameters.networkParameters] - Parameters relevant to network requests * @param {number} [parameters.networkParameters.timeout] - Request timeout (in milliseconds) - may be overridden within individual method calls * @param {string} [parameters.humanityCheckLocation='session'] - Storage location for the humanity check flag ('session' for sessionStorage, 'local' for localStorage) + * @param {boolean} [parameters.trackWindowParameters=false] - Indicates if window globals (cnstrc/cnstrcUserId/cnstrcTestCells/cnstrcUserSegments) should be used as fallback for userId, testCells, and segments * @property {object} search - Interface to {@link module:search} * @property {object} browse - Interface to {@link module:browse} * @property {object} autocomplete - Interface to {@link module:autocomplete} @@ -91,6 +92,7 @@ class ConstructorIO { beaconMode, networkParameters, humanityCheckLocation, + trackWindowParameters, } = options; if (!apiKey || typeof apiKey !== 'string') { @@ -139,14 +141,22 @@ class ConstructorIO { beaconMode: (beaconMode === false) ? false : true, // Defaults to 'true', networkParameters: networkParameters || {}, humanityCheckLocation: humanityCheckLocation || 'session', + trackWindowParameters: trackWindowParameters || false, }; + // Tracker gets its own options copy (without window parameter getters) + this.trackerOptions = { ...this.options }; + + if (trackWindowParameters && helpers.canUseDOM()) { + helpers.applyWindowParameterGetters(this.options); + } + // Expose global modules this.search = new Search(this.options); this.browse = new Browse(this.options); this.autocomplete = new Autocomplete(this.options); this.recommendations = new Recommendations(this.options); - this.tracker = new Tracker(this.options); + this.tracker = new Tracker(this.trackerOptions); this.quizzes = new Quizzes(this.options); this.agent = new Agent(this.options); this.assistant = new Assistant(this.options); @@ -172,29 +182,36 @@ class ConstructorIO { if (apiKey) { this.options.apiKey = apiKey; + this.trackerOptions.apiKey = apiKey; } if (segments) { this.options.segments = segments; + this.trackerOptions.segments = segments; } if (testCells) { - this.options.testCells = helpers.toValidTestCells(testCells); + const validTestCells = helpers.toValidTestCells(testCells); + this.options.testCells = validTestCells; + this.trackerOptions.testCells = validTestCells; } if (typeof sendTrackingEvents === 'boolean') { this.options.sendTrackingEvents = sendTrackingEvents; + this.trackerOptions.sendTrackingEvents = sendTrackingEvents; this.tracker.requests.sendTrackingEvents = sendTrackingEvents; } // Set Session ID in dom-less environments only if (sessionId && !helpers.canUseDOM()) { this.options.sessionId = sessionId; + this.trackerOptions.sessionId = sessionId; } // If User ID is passed if ('userId' in options) { this.options.userId = userId; + this.trackerOptions.userId = userId; } } } diff --git a/src/modules/recommendations.js b/src/modules/recommendations.js index ca839109..c30b6495 100644 --- a/src/modules/recommendations.js +++ b/src/modules/recommendations.js @@ -5,7 +5,7 @@ const helpers = require('../utils/helpers'); // Create URL from supplied parameters // eslint-disable-next-line complexity function createRecommendationsUrl(podId, parameters, options) { - const { apiKey, version, serviceUrl, sessionId, userId, clientId, segments } = options; + const { apiKey, version, serviceUrl, sessionId, userId, clientId, segments, testCells } = options; let queryParams = { c: version }; queryParams.key = apiKey; @@ -27,6 +27,13 @@ function createRecommendationsUrl(podId, parameters, options) { queryParams.ui = String(userId); } + // Pull test cells from options + if (testCells) { + Object.keys(testCells).forEach((testCellKey) => { + queryParams[`ef-${testCellKey}`] = testCells[testCellKey]; + }); + } + if (parameters) { const { numResults, diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 6ba5fc57..ed0ed4c0 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -70,6 +70,7 @@ export interface ConstructorClientOptions { beaconMode?: boolean; networkParameters?: NetworkParameters; humanityCheckLocation?: 'session' | 'local'; + trackWindowParameters?: boolean; } export interface RequestFeature extends Record { diff --git a/src/utils/helpers.js b/src/utils/helpers.js index 6ffc8396..256152ac 100644 --- a/src/utils/helpers.js +++ b/src/utils/helpers.js @@ -390,6 +390,55 @@ const utils = { return filtered; }, + applyWindowParameterGetters: (options) => { + const backing = { + userId: options.userId, + testCells: options.testCells, + segments: options.segments, + }; + + Object.defineProperty(options, 'userId', { + get() { + if (backing.userId) return backing.userId; + const windowUserId = (window.cnstrc && window.cnstrc.userId) || window.cnstrcUserId; + if (typeof windowUserId === 'string' && windowUserId.length > 0) { + return windowUserId; + } + return undefined; + }, + set(value) { backing.userId = value; }, + enumerable: true, + configurable: true, + }); + + Object.defineProperty(options, 'testCells', { + get() { + if (backing.testCells && Object.keys(backing.testCells).length > 0) { + return backing.testCells; + } + const windowTestCells = (window.cnstrc && window.cnstrc.testCells) || window.cnstrcTestCells; + return utils.toValidTestCells(windowTestCells); + }, + set(value) { backing.testCells = value; }, + enumerable: true, + configurable: true, + }); + + Object.defineProperty(options, 'segments', { + get() { + if (backing.segments && backing.segments.length > 0) return backing.segments; + const windowSegments = (window.cnstrc && window.cnstrc.userSegments) || window.cnstrcUserSegments; + if (Array.isArray(windowSegments) && windowSegments.length > 0) { + return windowSegments; + } + return undefined; + }, + set(value) { backing.segments = value; }, + enumerable: true, + configurable: true, + }); + }, + getBehaviorUrl: (mediaServiceUrl) => { const baseUrl = new URL(mediaServiceUrl); From 70909ff6bf2abdd9b685740e0875307d31334d9c Mon Sep 17 00:00:00 2001 From: Aleksei Pavlov Date: Fri, 19 Jun 2026 18:51:26 +0200 Subject: [PATCH 2/2] [CDX-201] Refactor setClientOptions to use a helper function for setting options and improve handling of window parameters --- spec/src/modules/tracker.js | 21 +++++++++++++++++++++ spec/src/utils/helpers.js | 2 +- src/constructorio.js | 26 ++++++++++---------------- src/utils/helpers.js | 12 ++++++++---- 4 files changed, 40 insertions(+), 21 deletions(-) diff --git a/spec/src/modules/tracker.js b/spec/src/modules/tracker.js index 0d273af4..570d05ec 100644 --- a/spec/src/modules/tracker.js +++ b/spec/src/modules/tracker.js @@ -480,6 +480,27 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { expect(tracker.trackSessionStart()).to.equal(true); }); + + it('Should include userId set via setClientOptions in tracking requests', (done) => { + window.cnstrc = { userId: 'window-user-id' }; + const instance = new ConstructorIO({ + apiKey: testApiKey, + trackWindowParameters: true, + fetch: fetchSpy, + ...requestQueueOptions, + }); + + instance.setClientOptions({ userId: 'explicit-user-id' }); + + instance.tracker.on('success', () => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + expect(requestedUrlParams).to.have.property('ui').to.equal('explicit-user-id'); + delete window.cnstrc; + done(); + }); + + expect(instance.tracker.trackSessionStart()).to.equal(true); + }); }); describe('trackInputFocus', () => { diff --git a/spec/src/utils/helpers.js b/spec/src/utils/helpers.js index 2a95a27c..a0692986 100644 --- a/spec/src/utils/helpers.js +++ b/spec/src/utils/helpers.js @@ -796,7 +796,7 @@ describe('ConstructorIO - Utils - Helpers', () => { window.cnstrcTestCells = [1, 2, 3]; const options = { testCells: {} }; applyWindowParameterGetters(options); - expect(options.testCells).to.deep.equal({}); + expect(options.testCells).to.be.undefined; }); it('Should fall back to window.cnstrc.userSegments when options.segments is empty', () => { diff --git a/src/constructorio.js b/src/constructorio.js index 24ddfcb9..e72b0e0f 100644 --- a/src/constructorio.js +++ b/src/constructorio.js @@ -144,7 +144,6 @@ class ConstructorIO { trackWindowParameters: trackWindowParameters || false, }; - // Tracker gets its own options copy (without window parameter getters) this.trackerOptions = { ...this.options }; if (trackWindowParameters && helpers.canUseDOM()) { @@ -179,39 +178,34 @@ class ConstructorIO { setClientOptions(options) { if (Object.keys(options).length) { const { apiKey, segments, testCells, sessionId, userId, sendTrackingEvents } = options; + const setOption = (key, value) => { + this.options[key] = value; + this.trackerOptions[key] = value; + }; if (apiKey) { - this.options.apiKey = apiKey; - this.trackerOptions.apiKey = apiKey; + setOption('apiKey', apiKey); } if (segments) { - this.options.segments = segments; - this.trackerOptions.segments = segments; + setOption('segments', segments); } if (testCells) { - const validTestCells = helpers.toValidTestCells(testCells); - this.options.testCells = validTestCells; - this.trackerOptions.testCells = validTestCells; + setOption('testCells', helpers.toValidTestCells(testCells)); } if (typeof sendTrackingEvents === 'boolean') { - this.options.sendTrackingEvents = sendTrackingEvents; - this.trackerOptions.sendTrackingEvents = sendTrackingEvents; + setOption('sendTrackingEvents', sendTrackingEvents); this.tracker.requests.sendTrackingEvents = sendTrackingEvents; } - // Set Session ID in dom-less environments only if (sessionId && !helpers.canUseDOM()) { - this.options.sessionId = sessionId; - this.trackerOptions.sessionId = sessionId; + setOption('sessionId', sessionId); } - // If User ID is passed if ('userId' in options) { - this.options.userId = userId; - this.trackerOptions.userId = userId; + setOption('userId', userId); } } } diff --git a/src/utils/helpers.js b/src/utils/helpers.js index 256152ac..f10a3095 100644 --- a/src/utils/helpers.js +++ b/src/utils/helpers.js @@ -392,9 +392,9 @@ const utils = { applyWindowParameterGetters: (options) => { const backing = { - userId: options.userId, - testCells: options.testCells, - segments: options.segments, + userId: options.userId || undefined, + testCells: options.testCells || undefined, + segments: options.segments || undefined, }; Object.defineProperty(options, 'userId', { @@ -417,7 +417,11 @@ const utils = { return backing.testCells; } const windowTestCells = (window.cnstrc && window.cnstrc.testCells) || window.cnstrcTestCells; - return utils.toValidTestCells(windowTestCells); + const validated = utils.toValidTestCells(windowTestCells); + if (validated && Object.keys(validated).length > 0) { + return validated; + } + return undefined; }, set(value) { backing.testCells = value; }, enumerable: true,