From 9b0ad7657b7bc1f90c0852859d796d5989f72b71 Mon Sep 17 00:00:00 2001 From: Joseph Samir Date: Tue, 12 May 2026 12:45:07 +0300 Subject: [PATCH 01/11] feat: extend SDK with web project bucketing fields and runVariation DOM renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SDK-side of Web project support (Workstream A); the standalone tracking-script companion bundles live in the backend repo. - Add `a/b_fullstack` and `feature_rollout` to ExperienceTypes union/const — manual override of the auto-generated types until the upstream serving API spec catches up. - Add `experienceType?: ExperienceTypes` to BucketedVariation; populated from `experience.type` in DataManager._retrieveBucketing. - Add `experienceTypes?: Array` filter to BucketingAttributes; ExperienceManager.selectVariations and FeatureManager.runFeatures honor it; Context.runExperiences, runFeature, runFeatures pass it through. - Add `ruleDataProvider?: Record` to ConfigBase; DataManager uses it at all four rule-evaluation call sites (site_area locations, selectLocations, filterMatchedRecordsWithRule for audiences, convert for goal rules) when set. RuleManager already supports the custom interface mode via \`data.name === 'RuleData'\` — no RuleManager changes needed. - Add Context.runVariation(bucketedVariation, options?): applies a web variation's CSS and JS to the DOM in execution order (experience global_css → global_js → per-change css/js/custom_js), skips defaultRedirect (handled by the Split Bundle) and fullStackFeature (handled by runFeature), idempotent via DOM marker IDs. Tests - 5 new mocha tests in experience: experienceType value populated + experienceTypes filter (all-types, single-type, no-match, web-type). - 3 new mocha tests in data: ruleDataProvider routes to RuleManager.isRuleMatched in audience evaluation; falls back to plain visitorProperties when not configured. - 7 new Playwright tests in js-sdk/browser: runVariation execution order, defaultCode CSS+JS, customCode CSS+custom_js, defaultRedirect skip, fullStackFeature skip, idempotency, null-safety. - Updated existing test fixtures (shared.js, context.tests.ts, umd-bundle.spec.ts) for the new experienceType field on BucketedVariation. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/data/src/data-manager.ts | 15 +- packages/data/tests/data-manager.tests.ts | 99 ++++++++ packages/enums/src/dictionary.ts | 12 +- packages/experience/src/experience-manager.ts | 5 + .../tests/experience-manager.tests.ts | 83 ++++++ packages/js-sdk/src/context.ts | 162 +++++++++++- packages/js-sdk/src/feature-manager.ts | 11 +- packages/js-sdk/src/interfaces/context.ts | 6 + .../js-sdk/tests/browser/umd-bundle.spec.ts | 237 +++++++++++++++++- packages/js-sdk/tests/context.tests.ts | 1 + packages/js-sdk/tests/setup/shared.js | 1 + packages/types/src/BucketedVariation.ts | 3 +- packages/types/src/BucketingAttributes.ts | 3 + packages/types/src/Config.ts | 4 + packages/types/src/config/types.gen.ts | 17 +- 15 files changed, 643 insertions(+), 16 deletions(-) diff --git a/packages/data/src/data-manager.ts b/packages/data/src/data-manager.ts index 210f4c25..e950d887 100644 --- a/packages/data/src/data-manager.ts +++ b/packages/data/src/data-manager.ts @@ -83,6 +83,7 @@ export class DataManager implements DataManagerInterface { private _asyncStorage: boolean; private _environment: string; private _mapper: (...args: any) => any; + private _ruleDataProvider: Record | null; /** * @param {Config} config * @param {Object} dependencies @@ -121,6 +122,7 @@ export class DataManager implements DataManagerInterface { this._config = config; this._mapper = config?.mapper || ((value: any) => value); this._asyncStorage = asyncStorage; + this._ruleDataProvider = config?.ruleDataProvider || null; this._data = objectDeepValue(config, 'data'); this._accountId = this._data?.account_id; this._projectId = this._data?.project?.id; @@ -317,7 +319,7 @@ export class DataManager implements DataManagerInterface { } else if (experience?.site_area) { // Validate locationProperties against site area rules locationMatched = this._ruleManager.isRuleMatched( - locationProperties, + this._ruleDataProvider || locationProperties, experience.site_area, 'SiteArea' ); @@ -709,7 +711,8 @@ export class DataManager implements DataManagerInterface { ...{ experienceId: experience?.id, experienceName: experience?.name, - experienceKey: experience?.key + experienceKey: experience?.key, + experienceType: experience?.type }, bucketingAllocation, ...variation @@ -856,7 +859,7 @@ export class DataManager implements DataManagerInterface { for (let i = 0, length = items.length; i < length; i++) { if (!items?.[i]?.rules) continue; match = this._ruleManager.isRuleMatched( - locationProperties, + this._ruleDataProvider || locationProperties, items[i].rules, `ConfigLocation #${items[i][identityField]}` ); @@ -997,10 +1000,10 @@ export class DataManager implements DataManagerInterface { return; } - if (goalRule) { + if (goalRule || this._ruleDataProvider) { if (!goal?.rules) return; const ruleMatched = this._ruleManager.isRuleMatched( - goalRule, + this._ruleDataProvider || goalRule, goal.rules, `ConfigGoal #${goalId}` ); @@ -1112,7 +1115,7 @@ export class DataManager implements DataManagerInterface { for (let i = 0, length = items.length; i < length; i++) { if (!items?.[i]?.rules) continue; match = this._ruleManager.isRuleMatched( - visitorProperties, + this._ruleDataProvider || visitorProperties, items[i].rules, `${camelCase(entityType)} #${items[i][field]}` ); diff --git a/packages/data/tests/data-manager.tests.ts b/packages/data/tests/data-manager.tests.ts index 1be0a3fa..cf8afc7b 100644 --- a/packages/data/tests/data-manager.tests.ts +++ b/packages/data/tests/data-manager.tests.ts @@ -398,4 +398,103 @@ describe('DataManager tests', function () { expect(check).to.have.property('segments').that.deep.equal(segments); }); }); + describe('Test ruleDataProvider integration', function () { + let receivedRuleData; + let provider; + // eslint-disable-next-line mocha/no-hooks-for-single-case + before(function () { + // Mock RuleManager that captures what data shape was passed to isRuleMatched + const capturingRuleManager: any = { + isRuleMatched: (data) => { + receivedRuleData = data; + return true; + }, + isUsingCustomInterface: (data) => + !!data && data.name === 'RuleData' + }; + provider = { + name: 'RuleData', + getGenericTextKeyValue: () => 'something' + }; + const configWithProvider = objectDeepMerge(configuration, { + ruleDataProvider: provider, + dataStore: undefined + }) as unknown as ConfigType; + dataManager = new dm(configWithProvider, { + bucketingManager, + ruleManager: capturingRuleManager, + eventManager, + apiManager + }); + }); + beforeEach(function () { + receivedRuleData = undefined; + }); + it('Should store ruleDataProvider on the DataManager instance', function () { + // private field — assert by behavior + assert.isDefined(dataManager); + }); + it('Should pass ruleDataProvider to RuleManager.isRuleMatched for audiences when set', function (done) { + this.timeout(test_timeout); + const experienceKey = 'test-experience-ab-fullstack-2'; + dataManager.getBucketing(visitorId, experienceKey, { + visitorProperties: {varName3: 'plain-value'}, + locationProperties: {url: 'https://convert.com/'} + }); + server.on('request', (request, res) => { + if (request.url.startsWith(`/track/${accountId}/${projectId}`)) { + request.on('end', () => { + // The provider should have been used as the rule data instead of + // the plain visitorProperties object. + expect(receivedRuleData) + .to.be.an('object') + .that.has.property('name', 'RuleData'); + expect(receivedRuleData).to.equal(provider); + done(); + }); + } + res.writeHead(200, {'Content-Type': 'application/json'}); + res.end('{}'); + }); + }); + it('Should fall back to plain visitorProperties when no ruleDataProvider is set', function (done) { + this.timeout(test_timeout); + const noProviderConfig = objectDeepMerge(configuration, { + ruleDataProvider: undefined, + dataStore: undefined + }) as unknown as ConfigType; + const capturingRuleManager: any = { + isRuleMatched: (data) => { + receivedRuleData = data; + return true; + }, + isUsingCustomInterface: (data) => + !!data && data.name === 'RuleData' + }; + const localDataManager = new dm(noProviderConfig, { + bucketingManager, + ruleManager: capturingRuleManager, + eventManager, + apiManager + }); + const experienceKey = 'test-experience-ab-fullstack-2'; + localDataManager.getBucketing(visitorId, experienceKey, { + visitorProperties: {varName3: 'plain-value'}, + locationProperties: {url: 'https://convert.com/'} + }); + server.on('request', (request, res) => { + if (request.url.startsWith(`/track/${accountId}/${projectId}`)) { + request.on('end', () => { + // No provider configured — RuleManager should see the plain object. + expect(receivedRuleData).to.be.an('object'); + expect(receivedRuleData).to.not.have.property('name', 'RuleData'); + expect(receivedRuleData).to.have.property('varName3', 'plain-value'); + done(); + }); + } + res.writeHead(200, {'Content-Type': 'application/json'}); + res.end('{}'); + }); + }); + }); }); diff --git a/packages/enums/src/dictionary.ts b/packages/enums/src/dictionary.ts index 2176f83e..1767f5c3 100644 --- a/packages/enums/src/dictionary.ts +++ b/packages/enums/src/dictionary.ts @@ -69,5 +69,15 @@ export const MESSAGES = { CUSTOM_SEGMENTS_KEY_FOUND: 'Custom segments key already set', SEND_BEACON_SUCCESS: 'The user agent successfully queued the data for transfer', - RELEASING_QUEUE: 'Releasing event queue...' + RELEASING_QUEUE: 'Releasing event queue...', + RUN_VARIATION_BROWSER_ONLY: 'runVariation requires a browser environment', + RUN_VARIATION_EXPERIENCE_NOT_FOUND: + 'runVariation: experience # not found in config', + RUN_VARIATION_REDIRECT_SKIPPED: + 'runVariation: skipping defaultRedirect change (handled by Split Bundle)', + RUN_VARIATION_FULLSTACK_SKIPPED: + 'runVariation: skipping fullStackFeature change (use runFeature)', + RUN_VARIATION_TOOLKIT_MISSING: + 'runVariation: window.convert.T (Convert Toolkit) is not loaded; Visual Editor changes may fail', + RUN_VARIATION_SCRIPT_ERROR: 'runVariation: error executing change script #' }; diff --git a/packages/experience/src/experience-manager.ts b/packages/experience/src/experience-manager.ts index 1b544d6c..51b25764 100644 --- a/packages/experience/src/experience-manager.ts +++ b/packages/experience/src/experience-manager.ts @@ -156,7 +156,12 @@ export class ExperienceManager implements ExperienceManagerInterface { visitorId: string, attributes: BucketingAttributes ): Array { + const typeFilter = attributes?.experienceTypes; return this.getList() + .filter((experience) => { + if (!typeFilter?.length) return true; + return typeFilter.includes(experience?.type); + }) .map((experience) => { return this.selectVariation(visitorId, experience?.key, attributes); }) diff --git a/packages/experience/tests/experience-manager.tests.ts b/packages/experience/tests/experience-manager.tests.ts index 9a417c14..090b3baa 100644 --- a/packages/experience/tests/experience-manager.tests.ts +++ b/packages/experience/tests/experience-manager.tests.ts @@ -220,4 +220,87 @@ describe('ExperienceManager tests', function () { .to.equal(variationKey); }); }); + describe('Test experienceType field on BucketedVariation', function () { + it('Should populate experienceType from the source experience config', function (done) { + this.timeout(test_timeout); + const experienceKey = 'test-experience-ab-fullstack-2'; + const variation = experienceManager.selectVariation( + visitorId, + experienceKey, + { + visitorProperties: {varName3: 'something'}, + locationProperties: {url: 'https://convert.com/'} + } + ); + server.on('request', (request, res) => { + if (request.url.startsWith(`/track/${accountId}/${projectId}`)) { + request.on('end', () => { + expect(variation) + .to.be.an('object') + .that.has.property('experienceType'); + expect(variation.experienceType).to.equal('a/b_fullstack'); + done(); + }); + } + res.writeHead(200, {'Content-Type': 'application/json'}); + res.end('{}'); + }); + }); + }); + describe('Test experienceTypes filter on selectVariations', function () { + it('Should return all experiences when no type filter is set', function (done) { + this.timeout(test_timeout); + const variations = experienceManager.selectVariations(visitorId, { + visitorProperties: {varName3: 'something'}, + locationProperties: {url: 'https://convert.com/'} + }); + server.on('request', (request, res) => { + if (request.url.startsWith(`/track/${accountId}/${projectId}`)) { + request.on('end', () => { + expect(variations).to.be.an('array').that.has.length(2); + done(); + }); + } + res.writeHead(200, {'Content-Type': 'application/json'}); + res.end('{}'); + }); + }); + it('Should keep all fullstack experiences when experienceTypes=["a/b_fullstack"]', function (done) { + this.timeout(test_timeout); + const variations = experienceManager.selectVariations(visitorId, { + visitorProperties: {varName3: 'something'}, + locationProperties: {url: 'https://convert.com/'}, + experienceTypes: ['a/b_fullstack'] + }); + server.on('request', (request, res) => { + if (request.url.startsWith(`/track/${accountId}/${projectId}`)) { + request.on('end', () => { + expect(variations).to.be.an('array').that.has.length(2); + variations.forEach((v) => { + expect(v.experienceType).to.equal('a/b_fullstack'); + }); + done(); + }); + } + res.writeHead(200, {'Content-Type': 'application/json'}); + res.end('{}'); + }); + }); + it('Should return empty when filtering by a type with no matching experiences', function () { + const variations = experienceManager.selectVariations(visitorId, { + visitorProperties: {varName3: 'something'}, + locationProperties: {url: 'https://convert.com/'}, + experienceTypes: ['feature_rollout'] + }); + expect(variations).to.be.an('array').that.has.length(0); + }); + it('Should return empty when filtering by web type (no web experiences in config)', function () { + const variations = experienceManager.selectVariations(visitorId, { + visitorProperties: {varName3: 'something'}, + locationProperties: {url: 'https://convert.com/'}, + experienceTypes: ['a/b'] + }); + expect(variations).to.be.an('array').that.has.length(0); + }); + }); }); diff --git a/packages/js-sdk/src/context.ts b/packages/js-sdk/src/context.ts index f83180f6..f9c0b2af 100644 --- a/packages/js-sdk/src/context.ts +++ b/packages/js-sdk/src/context.ts @@ -29,8 +29,10 @@ import { BucketingError, ERROR_MESSAGES, EntityType, + MESSAGES, RuleError, - SystemEvents + SystemEvents, + VariationChangeType } from '@convertcom/js-sdk-enums'; import {objectDeepMerge, objectNotEmpty} from '@convertcom/js-sdk-utils'; import {SegmentsManagerInterface} from '@convertcom/js-sdk-segments'; @@ -193,6 +195,7 @@ export class Context implements ContextInterface { visitorProperties, // represents audiences locationProperties: attributes?.locationProperties, // represents site_area/locations updateVisitorProperties: attributes?.updateVisitorProperties, + experienceTypes: attributes?.experienceTypes, environment: attributes?.environment || this._environment } ); @@ -258,6 +261,7 @@ export class Context implements ContextInterface { visitorProperties, locationProperties: attributes?.locationProperties, updateVisitorProperties: attributes?.updateVisitorProperties, + experienceTypes: attributes?.experienceTypes, typeCasting: Object.prototype.hasOwnProperty.call( attributes || {}, 'typeCasting' @@ -338,6 +342,7 @@ export class Context implements ContextInterface { visitorProperties, locationProperties: attributes?.locationProperties, updateVisitorProperties: attributes?.updateVisitorProperties, + experienceTypes: attributes?.experienceTypes, typeCasting: Object.prototype.hasOwnProperty.call( attributes || {}, 'typeCasting' @@ -370,6 +375,161 @@ export class Context implements ContextInterface { return bucketedFeatures as Array; } + /** + * Apply web variation changes (CSS, JS, custom JS) to the DOM. + * + * Execution order, matching the tracking script monolith: + * 1. experience.global_css →