From 19efea28cdd3d8c275b164ba855d9b68acc60a50 Mon Sep 17 00:00:00 2001 From: Joseph Samir Date: Fri, 27 Mar 2026 18:52:05 +0200 Subject: [PATCH 1/6] feat: hybrid integration --- packages/data/src/data-manager.ts | 3 +- packages/experience/src/experience-manager.ts | 10 + packages/js-sdk/src/context.ts | 2 + packages/js-sdk/tests/context.tests.ts | 281 +++++++++++++++++- packages/types/src/BucketedVariation.ts | 3 +- packages/types/src/BucketingAttributes.ts | 3 + packages/types/src/config/types.gen.ts | 6 +- 7 files changed, 303 insertions(+), 5 deletions(-) diff --git a/packages/data/src/data-manager.ts b/packages/data/src/data-manager.ts index 210f4c25..509a0e05 100644 --- a/packages/data/src/data-manager.ts +++ b/packages/data/src/data-manager.ts @@ -709,7 +709,8 @@ export class DataManager implements DataManagerInterface { ...{ experienceId: experience?.id, experienceName: experience?.name, - experienceKey: experience?.key + experienceKey: experience?.key, + experienceType: experience?.type }, bucketingAllocation, ...variation diff --git a/packages/experience/src/experience-manager.ts b/packages/experience/src/experience-manager.ts index 1b544d6c..9ee30ea7 100644 --- a/packages/experience/src/experience-manager.ts +++ b/packages/experience/src/experience-manager.ts @@ -156,7 +156,17 @@ export class ExperienceManager implements ExperienceManagerInterface { visitorId: string, attributes: BucketingAttributes ): Array { + const {experienceTypes} = attributes; + const hasTypeFilter = Array.isArray(experienceTypes) + ? experienceTypes.length > 0 + : false; + return this.getList() + .filter((experience) => + hasTypeFilter + ? Boolean(experience?.type) && experienceTypes.includes(experience.type) + : true + ) .map((experience) => { return this.selectVariation(visitorId, experience?.key, attributes); }) diff --git a/packages/js-sdk/src/context.ts b/packages/js-sdk/src/context.ts index f83180f6..0e9c1782 100644 --- a/packages/js-sdk/src/context.ts +++ b/packages/js-sdk/src/context.ts @@ -170,6 +170,7 @@ export class Context implements ContextInterface { * @param {BucketingAttributes=} attributes An object that specifies attributes for the visitor * @param {string=} attributes.locationProperties An object of key-value pairs that are used for location matching * @param {Record=} attributes.visitorProperties An object of key-value pairs that are used for audience targeting + * @param {Array=} attributes.experienceTypes A list of allowed experience types * @param {boolean=} attributes.updateVisitorProperties Decide whether to update visitor properties upon bucketing * @param {string=} attributes.environment Overwrite the environment * @return {Array} @@ -192,6 +193,7 @@ export class Context implements ContextInterface { { visitorProperties, // represents audiences locationProperties: attributes?.locationProperties, // represents site_area/locations + experienceTypes: attributes?.experienceTypes, updateVisitorProperties: attributes?.updateVisitorProperties, environment: attributes?.environment || this._environment } diff --git a/packages/js-sdk/tests/context.tests.ts b/packages/js-sdk/tests/context.tests.ts index 44742f09..c102ea0e 100644 --- a/packages/js-sdk/tests/context.tests.ts +++ b/packages/js-sdk/tests/context.tests.ts @@ -16,7 +16,7 @@ import {Context as c} from '../src/context'; import testConfig from './test-config.json'; import {Config as ConfigType} from '@convertcom/js-sdk-types'; import {objectDeepMerge} from '@convertcom/js-sdk-utils'; -import {EntityType} from '@convertcom/js-sdk-enums'; +import {BucketingError, EntityType} from '@convertcom/js-sdk-enums'; import {defaultConfig} from '../src/config/default'; import { getFeaturesWithStatuses, @@ -47,6 +47,80 @@ const bucketingManager = new bm(configuration); const ruleManager = new rm(configuration); const eventManager = new em(configuration); const apiManager = new am(configuration, {eventManager}); +const mixedTypeConfiguration = objectDeepMerge( + testConfig, + defaultConfig, + { + api: { + endpoint: { + config: host + ':' + port, + track: host + ':' + port + } + }, + events: { + batch_size: batch_size, + release_interval: release_timeout + } + }, + { + data: { + experiences: [ + { + id: '100218248', + name: 'Test Experience AB Web', + key: 'test-experience-ab-web-1', + type: 'a/b', + version: 6, + status: 'active', + global_js: "var s = 'test_experience_web'; console.log(s);", + global_css: '.test-style { display: initial; }', + url: 'https://convert.com', + integrations: [], + environments: ['live', 'staging'], + site_area: { + OR: [ + { + AND: [ + { + OR_WHEN: [ + { + rule_type: 'generic_key_value', + matching: { + match_type: 'matches', + negated: false + }, + key: 'url', + value: 'https://convert.com/' + } + ] + } + ] + } + ] + }, + audiences: ['100299433'], + goals: ['100215959', '100215960', '100215961'], + settings: { + matching_options: { + audiences: 'any' + } + }, + variations: [ + { + id: '100299464', + name: 'Variation 1', + status: 'running', + is_baseline: true, + changes: [], + key: '100299464-variation-1', + traffic_allocation: 100.0 + } + ] + } + ] + } + } +) as unknown as ConfigType; describe('Context tests', function () { const visitorId = 'XXX'; @@ -151,6 +225,7 @@ describe('Context tests', function () { 'experienceKey', 'experienceName', 'bucketingAllocation', + 'experienceType', 'id', 'key', 'name', @@ -449,6 +524,210 @@ describe('Context tests', function () { }); }); }); + describe('Test mixed experience type bucketing', function () { + let dataManager, experienceManager, featureManager, segmentsManager, context; + const mixedVisitorId = 'TYPE-100'; + const mixedEventManager = new em(mixedTypeConfiguration); + const mixedApiManager = new am(mixedTypeConfiguration, { + eventManager: mixedEventManager + }); + const mixedRuleManager = new rm(mixedTypeConfiguration); + const mixedBucketingManager = new bm(mixedTypeConfiguration); + before(function () { + dataManager = new dm(mixedTypeConfiguration, { + bucketingManager: mixedBucketingManager, + ruleManager: mixedRuleManager, + eventManager: mixedEventManager, + apiManager: mixedApiManager + }); + experienceManager = new exm(mixedTypeConfiguration, {dataManager}); + featureManager = new fm(mixedTypeConfiguration, {dataManager}); + segmentsManager = new sm(mixedTypeConfiguration, { + dataManager, + ruleManager: mixedRuleManager + }); + context = new c( + mixedTypeConfiguration, + mixedVisitorId, + { + eventManager: mixedEventManager, + experienceManager, + featureManager, + segmentsManager, + dataManager, + apiManager: mixedApiManager + }, + {browser: 'chrome', country: 'US'} + ); + }); + afterEach(function () { + dataManager.reset(); + }); + it('Should include experienceType in bucketed variation payload', function () { + const fullstackVariation = context.runExperience( + 'test-experience-ab-fullstack-2', + { + locationProperties: {url: 'https://convert.com/'}, + visitorProperties: { + varName3: 'something' + }, + enableTracking: false + } + ); + const webVariation = context.runExperience( + 'test-experience-ab-web-1', + { + locationProperties: {url: 'https://convert.com/'}, + visitorProperties: { + varName3: 'something' + }, + enableTracking: false + } + ); + expect(fullstackVariation).to.have.property('experienceType', 'a/b_fullstack'); + expect(webVariation).to.have.property('experienceType', 'a/b'); + }); + it('Should return mixed web and fullstack variations without filtering', function () { + const variations = context.runExperiences({ + locationProperties: {url: 'https://convert.com/'}, + visitorProperties: { + varName3: 'something' + }, + enableTracking: false + }); + expect(variations).to.have.length(3); + expect( + variations + .map(({id}) => id) + .includes('100299464') + ).to.equal(true); + }); + it('Should filter variations by provided experience type', function () { + const webVariations = context.runExperiences({ + locationProperties: {url: 'https://convert.com/'}, + visitorProperties: { + varName3: 'something' + }, + experienceTypes: ['a/b'], + enableTracking: false + }); + const fullstackVariations = context.runExperiences({ + locationProperties: {url: 'https://convert.com/'}, + visitorProperties: { + varName3: 'something' + }, + experienceTypes: ['a/b_fullstack'], + enableTracking: false + }); + const unknownVariations = context.runExperiences({ + locationProperties: {url: 'https://convert.com/'}, + visitorProperties: { + varName3: 'something' + }, + experienceTypes: ['split_url'], + enableTracking: false + }); + expect(webVariations).to.be.an('array').that.have.length(1); + expect( + webVariations.every(({experienceType}) => experienceType === 'a/b') + ).to.equal(true); + expect(fullstackVariations).to.be.an('array').that.have.length(2); + expect( + fullstackVariations.every( + ({experienceType}) => experienceType === 'a/b_fullstack' + ) + ).to.equal(true); + expect(unknownVariations).to.be.an('array').that.have.length(0); + }); + it('Should treat empty experienceTypes filter as no filtering', function () { + const variations = context.runExperiences({ + locationProperties: {url: 'https://convert.com/'}, + visitorProperties: { + varName3: 'something' + }, + experienceTypes: [], + enableTracking: false + }); + expect(variations).to.have.length(3); + }); + }); + describe('Test Context bucketing contract', function () { + let dataManager, experienceManager, featureManager, segmentsManager, testContext; + const offlineVisitorId = 'OFFLINE-EXPERIENCE-BUCKETING'; + before(function () { + dataManager = new dm(configuration, { + bucketingManager, + ruleManager, + eventManager, + apiManager + }); + experienceManager = new exm(configuration, {dataManager}); + featureManager = new fm(configuration, {dataManager}); + segmentsManager = new sm(configuration, {dataManager, ruleManager}); + testContext = new c( + configuration, + offlineVisitorId, + { + eventManager, + experienceManager, + featureManager, + segmentsManager, + dataManager, + apiManager + }, + {browser: 'chrome', country: 'US'} + ); + }); + afterEach(function () { + dataManager.reset(); + }); + it('Should return stable bucketing payload for repeated runExperience calls', function () { + const experienceKey = 'test-experience-ab-fullstack-2'; + const firstVariation = testContext.runExperience(experienceKey, { + locationProperties: {url: 'https://convert.com/'}, + visitorProperties: { + varName3: 'something' + }, + enableTracking: false + }); + const secondVariation = testContext.runExperience(experienceKey, { + locationProperties: {url: 'https://convert.com/'}, + visitorProperties: { + varName3: 'something' + }, + enableTracking: false + }); + expect(firstVariation).to.be.an('object'); + expect(secondVariation).to.be.an('object'); + expect(firstVariation).to.have.property('id'); + expect(secondVariation).to.have.property('id'); + expect(secondVariation.id).to.equal(firstVariation.id); + expect(firstVariation).to.have.property( + 'experienceType', + 'a/b_fullstack' + ); + expect(firstVariation) + .to.have.property('bucketingAllocation') + .that.is.a('number'); + expect(firstVariation) + .to.have.property('traffic_allocation') + .that.is.a('number'); + expect(testContext.getVisitorData()) + .to.have.property('bucketing') + .to.deep.equal({[firstVariation.experienceId]: firstVariation.id}); + }); + it('Shoud fail to get variation when bucketing cannot be resolved', function () { + const experienceKey = 'test-experience-ab-fullstack-4'; + const variation = testContext.runExperience(experienceKey, { + locationProperties: {url: 'https://convert.com/'}, + visitorProperties: { + varName3: 'something' + }, + enableTracking: false + }); + expect(variation).to.equal(BucketingError.VARIAION_NOT_DECIDED); + }); + }); describe('Test invalid visitor', function () { let dataManager, experienceManager, diff --git a/packages/types/src/BucketedVariation.ts b/packages/types/src/BucketedVariation.ts index 8eb75fa2..377a82d6 100644 --- a/packages/types/src/BucketedVariation.ts +++ b/packages/types/src/BucketedVariation.ts @@ -5,11 +5,12 @@ * License Apache-2.0 */ -import {ExperienceVariationConfig} from './config/index'; +import {ExperienceTypes, ExperienceVariationConfig} from './config/index'; export type BucketedVariation = ExperienceVariationConfig & { experienceId?: string; experienceKey?: string; experienceName?: string; + experienceType?: ExperienceTypes; bucketingAllocation?: number; }; diff --git a/packages/types/src/BucketingAttributes.ts b/packages/types/src/BucketingAttributes.ts index 6223bb0f..a61ad7cc 100644 --- a/packages/types/src/BucketingAttributes.ts +++ b/packages/types/src/BucketingAttributes.ts @@ -5,12 +5,15 @@ * License Apache-2.0 */ +import {ExperienceTypes} from './config/index'; + export type BucketingAttributes = { environment?: string; locationProperties?: Record; visitorProperties?: Record; typeCasting?: boolean; experienceKeys?: Array; + experienceTypes?: Array; updateVisitorProperties?: boolean; forceVariationId?: string; enableTracking?: boolean; diff --git a/packages/types/src/config/types.gen.ts b/packages/types/src/config/types.gen.ts index 52935bc9..933c1f26 100644 --- a/packages/types/src/config/types.gen.ts +++ b/packages/types/src/config/types.gen.ts @@ -1899,11 +1899,13 @@ export const ExperienceStatuses = { SCHEDULED: 'scheduled' } as const; -export type ExperienceTypes = 'a/b' | 'a/a' | 'mvt' | 'split_url' | 'multipage' | 'deploy'; +export type ExperienceTypes = 'a/b' | 'a/b_fullstack' | 'a/a' | 'feature_rollout' | 'mvt' | 'split_url' | 'multipage' | 'deploy'; export const ExperienceTypes = { A_B: 'a/b', + A_B_FULLSTACK: 'a/b_fullstack', A_A: 'a/a', + FEATURE_ROLLOUT: 'feature_rollout', MVT: 'mvt', SPLIT_URL: 'split_url', MULTIPAGE: 'multipage', @@ -3095,4 +3097,4 @@ export type $OpenApiTs = { }; }; }; -}; \ No newline at end of file +}; From f31a87c9418ace13c8b1c875ee131d2f66c49abd Mon Sep 17 00:00:00 2001 From: Joseph Samir Date: Fri, 27 Mar 2026 19:32:45 +0200 Subject: [PATCH 2/6] feat: Rule evaluation and bucketing input integration --- packages/js-sdk/index.ts | 1 + packages/js-sdk/src/context.ts | 38 ++- packages/js-sdk/tests/context.tests.ts | 338 +++++++++++++++++++++ packages/js-sdk/tests/setup/shared.js | 1 + packages/rules/src/rule-manager.ts | 22 +- packages/rules/tests/rule-manager.tests.ts | 85 ++++++ packages/types/index.ts | 1 + packages/types/src/Config.ts | 2 + packages/types/src/RuleData.ts | 74 +++++ 9 files changed, 550 insertions(+), 12 deletions(-) create mode 100644 packages/types/src/RuleData.ts diff --git a/packages/js-sdk/index.ts b/packages/js-sdk/index.ts index 73641482..76963b41 100644 --- a/packages/js-sdk/index.ts +++ b/packages/js-sdk/index.ts @@ -160,6 +160,7 @@ export { ConfigExperience, ExperienceVariationConfig, ExperienceChange, + RuleData, ConfigFeature, ConfigProject, ConfigGoal, diff --git a/packages/js-sdk/src/context.ts b/packages/js-sdk/src/context.ts index 0e9c1782..420de672 100644 --- a/packages/js-sdk/src/context.ts +++ b/packages/js-sdk/src/context.ts @@ -16,6 +16,7 @@ import { BucketedFeature, BucketedVariation, BucketingAttributes, + RuleData, ConversionAttributes, VisitorSegments, SegmentsAttributes, @@ -52,6 +53,7 @@ export class Context implements ContextInterface { private _loggerManager: LogManagerInterface; private _config: Config; private _visitorId: string; + private _ruleDataProvider?: RuleData; private _visitorProperties: Record; private _environment: string; @@ -99,6 +101,7 @@ export class Context implements ContextInterface { this._segmentsManager = segmentsManager; this._apiManager = apiManager; this._loggerManager = loggerManager; + this._ruleDataProvider = this.resolveRuleDataProvider(config); if (objectNotEmpty(visitorProperties)) { const {properties} = @@ -137,7 +140,9 @@ export class Context implements ContextInterface { experienceKey, { visitorProperties, // represents audiences - locationProperties: attributes?.locationProperties, // represents site_area/locations + locationProperties: this.getLocationProperties( + attributes?.locationProperties + ), // represents site_area/locations updateVisitorProperties: attributes?.updateVisitorProperties, environment: attributes?.environment || this._environment } @@ -192,7 +197,9 @@ export class Context implements ContextInterface { this._visitorId, { visitorProperties, // represents audiences - locationProperties: attributes?.locationProperties, // represents site_area/locations + locationProperties: this.getLocationProperties( + attributes?.locationProperties + ), // represents site_area/locations experienceTypes: attributes?.experienceTypes, updateVisitorProperties: attributes?.updateVisitorProperties, environment: attributes?.environment || this._environment @@ -258,7 +265,9 @@ export class Context implements ContextInterface { key, { visitorProperties, - locationProperties: attributes?.locationProperties, + locationProperties: this.getLocationProperties( + attributes?.locationProperties + ), updateVisitorProperties: attributes?.updateVisitorProperties, typeCasting: Object.prototype.hasOwnProperty.call( attributes || {}, @@ -338,7 +347,9 @@ export class Context implements ContextInterface { ); const bucketedFeatures = this._featureManager.runFeatures(this._visitorId, { visitorProperties, - locationProperties: attributes?.locationProperties, + locationProperties: this.getLocationProperties( + attributes?.locationProperties + ), updateVisitorProperties: attributes?.updateVisitorProperties, typeCasting: Object.prototype.hasOwnProperty.call( attributes || {}, @@ -579,4 +590,23 @@ export class Context implements ContextInterface { : this._visitorProperties; return objectDeepMerge(segments || {}, visitorProperties || {}); } + + private resolveRuleDataProvider(config: Config): RuleData | null { + const {ruleDataProvider} = config || {}; + if (!ruleDataProvider) return null; + if (ruleDataProvider?.name !== 'RuleData') { + this._loggerManager?.warn?.( + 'Context.resolveRuleDataProvider()', + `Invalid ruleDataProvider marker ${ruleDataProvider?.name || 'none'}. Expected "RuleData".` + ); + return null; + } + return ruleDataProvider as RuleData; + } + + private getLocationProperties( + locationProperties?: Record + ): Record | RuleData | null { + return locationProperties || this._ruleDataProvider; + } } diff --git a/packages/js-sdk/tests/context.tests.ts b/packages/js-sdk/tests/context.tests.ts index c102ea0e..a85bdc51 100644 --- a/packages/js-sdk/tests/context.tests.ts +++ b/packages/js-sdk/tests/context.tests.ts @@ -121,8 +121,346 @@ const mixedTypeConfiguration = objectDeepMerge( } } ) as unknown as ConfigType; +const ruleDataProviderConfiguration = objectDeepMerge( + testConfig, + defaultConfig, + { + data: { + experiences: [ + { + id: '300001', + name: 'Test RuleData URL Experience', + key: 'ruledata-url', + type: 'a/b', + version: 1, + status: 'active', + global_js: "var s = 'ruledata_url';", + global_css: '.ruledata-url { display: none; }', + url: 'https://convert.com', + integrations: [], + environments: ['live', 'staging'], + site_area: { + OR: [ + { + AND: [ + { + OR_WHEN: [ + { + rule_type: 'url', + matching: { + match_type: 'equals', + negated: false + }, + value: 'https://convert.com/' + } + ] + } + ] + } + ] + }, + variations: [ + { + id: '300001-var-1', + name: 'Variation URL', + status: 'running', + is_baseline: true, + changes: [], + key: '300001-var-1', + traffic_allocation: 100.0 + } + ] + }, + { + id: '300002', + name: 'Test RuleData Cookie Experience', + key: 'ruledata-cookie', + type: 'a/b', + version: 1, + status: 'active', + global_js: "var s = 'ruledata_cookie';", + global_css: '.ruledata-cookie { display: none; }', + url: 'https://convert.com', + integrations: [], + environments: ['live', 'staging'], + site_area: { + OR: [ + { + AND: [ + { + OR_WHEN: [ + { + rule_type: 'cookie', + matching: { + match_type: 'equals', + negated: false + }, + value: 'utm_source=google' + } + ] + } + ] + } + ] + }, + variations: [ + { + id: '300002-var-1', + name: 'Variation Cookie', + status: 'running', + is_baseline: true, + changes: [], + key: '300002-var-1', + traffic_allocation: 100.0 + } + ] + }, + { + id: '300003', + name: 'Test RuleData Geo Experience', + key: 'ruledata-geo', + type: 'a/b', + version: 1, + status: 'active', + global_js: "var s = 'ruledata_geo';", + global_css: '.ruledata-geo { display: none; }', + url: 'https://convert.com', + integrations: [], + environments: ['live', 'staging'], + site_area: { + OR: [ + { + AND: [ + { + OR_WHEN: [ + { + rule_type: 'country', + matching: { + match_type: 'equals', + negated: false + }, + value: 'US' + } + ] + } + ] + } + ] + }, + variations: [ + { + id: '300003-var-1', + name: 'Variation Geo', + status: 'running', + is_baseline: true, + changes: [], + key: '300003-var-1', + traffic_allocation: 100.0 + } + ] + }, + { + id: '300004', + name: 'Test RuleData Browser Experience', + key: 'ruledata-browser', + type: 'a/b', + version: 1, + status: 'active', + global_js: "var s = 'ruledata_browser';", + global_css: '.ruledata-browser { display: none; }', + url: 'https://convert.com', + integrations: [], + environments: ['live', 'staging'], + site_area: { + OR: [ + { + AND: [ + { + OR_WHEN: [ + { + rule_type: 'browser_name', + matching: { + match_type: 'equals', + negated: false + }, + value: 'chrome' + } + ] + } + ] + } + ] + }, + variations: [ + { + id: '300004-var-1', + name: 'Variation Browser', + status: 'running', + is_baseline: true, + changes: [], + key: '300004-var-1', + traffic_allocation: 100.0 + } + ] + }, + { + id: '300005', + name: 'Test RuleData JS Condition Experience', + key: 'ruledata-js-condition', + type: 'a/b', + version: 1, + status: 'active', + global_js: "var s = 'ruledata_js_condition';", + global_css: '.ruledata-js-condition { display: none; }', + url: 'https://convert.com', + integrations: [], + environments: ['live', 'staging'], + site_area: { + OR: [ + { + AND: [ + { + OR_WHEN: [ + { + rule_type: 'js_condition', + matching: { + match_type: 'equals', + negated: false + }, + value: true + } + ] + } + ] + } + ] + }, + variations: [ + { + id: '300005-var-1', + name: 'Variation JS Condition', + status: 'running', + is_baseline: true, + changes: [], + key: '300005-var-1', + traffic_allocation: 100.0 + } + ] + } + ] + } + } +) as unknown as ConfigType; +const createContextForRuleDataProvider = ( + ruleDataProvider: Record +): c => { + const contextConfiguration = objectDeepMerge(ruleDataProviderConfiguration, { + ruleDataProvider + }) as unknown as ConfigType; + const contextEventManager = new em(contextConfiguration); + const contextApiManager = new am(contextConfiguration, { + eventManager: contextEventManager + }); + const contextBucketingManager = new bm(contextConfiguration); + const contextRuleManager = new rm(contextConfiguration); + const contextDataManager = new dm(contextConfiguration, { + bucketingManager: contextBucketingManager, + ruleManager: contextRuleManager, + eventManager: contextEventManager, + apiManager: contextApiManager + }); + const contextExperienceManager = new exm(contextConfiguration, { + dataManager: contextDataManager + }); + const contextFeatureManager = new fm(contextConfiguration, { + dataManager: contextDataManager + }); + const contextSegmentsManager = new sm(contextConfiguration, { + dataManager: contextDataManager, + ruleManager: contextRuleManager + }); + return new c( + contextConfiguration, + 'RULEDATA-VISITOR', + { + eventManager: contextEventManager, + experienceManager: contextExperienceManager, + featureManager: contextFeatureManager, + segmentsManager: contextSegmentsManager, + dataManager: contextDataManager, + apiManager: contextApiManager + }, + {browser: 'chrome', country: 'US'} + ); +}; describe('Context tests', function () { + const ruleDataProvider = { + name: 'RuleData', + getUrl: () => 'https://convert.com/', + getCookie: () => 'utm_source=google', + getCountry: () => 'US', + getBrowserName: () => 'chrome', + getJsCondition: () => true + }; + describe('Context ruleDataProvider integration', function () { + it('Should evaluate web-only rules with custom RuleData provider methods', function () { + const context = createContextForRuleDataProvider(ruleDataProvider); + + const urlMatch = context.runExperience('ruledata-url', { + enableTracking: false + }); + const cookieMatch = context.runExperience('ruledata-cookie', { + enableTracking: false + }); + const geoMatch = context.runExperience('ruledata-geo', { + enableTracking: false + }); + const browserMatch = context.runExperience('ruledata-browser', { + enableTracking: false + }); + const jsConditionMatch = context.runExperience('ruledata-js-condition', { + enableTracking: false + }); + + expect(urlMatch).to.be.an('object').that.has.property('experienceKey', 'ruledata-url'); + expect(cookieMatch).to.be.an('object').that.has.property( + 'experienceKey', + 'ruledata-cookie' + ); + expect(geoMatch).to.be.an('object').that.has.property('experienceKey', 'ruledata-geo'); + expect(browserMatch).to.be.an('object').that.has.property( + 'experienceKey', + 'ruledata-browser' + ); + expect(jsConditionMatch).to.be.an('object').that.has.property( + 'experienceKey', + 'ruledata-js-condition' + ); + }); + it('Should ignore invalid ruleDataProvider marker and return no match', function () { + const context = createContextForRuleDataProvider({ + name: 'wrong-name', + getUrl: () => 'https://convert.com/' + }); + expect(context.runExperience('ruledata-url', {enableTracking: false})).to.equal( + null + ); + }); + it('Should return no match without throwing when required getter is missing', function () { + const context = createContextForRuleDataProvider({ + name: 'RuleData', + getBrowserName: () => 'chrome' + }); + expect( + () => + expect(context.runExperience('ruledata-url', {enableTracking: false})).to.equal( + null + ) + ).to.not.throw(); + }); + }); + const visitorId = 'XXX'; const featureId = '10025'; it('Should expose Context', function () { diff --git a/packages/js-sdk/tests/setup/shared.js b/packages/js-sdk/tests/setup/shared.js index 476a0cee..d45662e9 100644 --- a/packages/js-sdk/tests/setup/shared.js +++ b/packages/js-sdk/tests/setup/shared.js @@ -24,6 +24,7 @@ export function getVariationsAcrossAllExperiences( 'experienceKey', 'experienceName', 'bucketingAllocation', + 'experienceType', 'id', 'key', 'name', diff --git a/packages/rules/src/rule-manager.ts b/packages/rules/src/rule-manager.ts index 4422ebeb..b0100db7 100644 --- a/packages/rules/src/rule-manager.ts +++ b/packages/rules/src/rule-manager.ts @@ -274,18 +274,24 @@ export class RuleManager implements RuleManagerInterface { 'RuleManager._processRuleItem()', MESSAGES.RULE_MATCH_START.replace('#', rule.rule_type) ); - for (const method of Object.getOwnPropertyNames( - data.constructor.prototype - )) { + const rule_method = camelCase( + `get ${rule.rule_type.replace(/_/g, ' ')}` + ); + const methods = new Set([ + ...Object.getOwnPropertyNames(data), + ...Object.getOwnPropertyNames( + Object.getPrototypeOf(data) || {} + ) + ]); + for (const method of methods) { if (method === 'constructor') continue; - const rule_method = camelCase( - `get ${rule.rule_type.replace(/_/g, ' ')}` - ); + if (typeof (data as any)[method] !== 'function') continue; if ( method === rule_method || - data?.mapper?.(method) === rule_method + data?.mapper?.(method) === rule_method || + data?.mapper?.(rule_method) === method ) { - const dataValue = data[method](rule); + const dataValue = (data as any)[method](rule); if ( Object.values(RuleError).includes(dataValue as RuleError) ) diff --git a/packages/rules/tests/rule-manager.tests.ts b/packages/rules/tests/rule-manager.tests.ts index cacc671f..bd591517 100644 --- a/packages/rules/tests/rule-manager.tests.ts +++ b/packages/rules/tests/rule-manager.tests.ts @@ -556,4 +556,89 @@ describe('RuleManager tests', function () { }); // TODO: Add direct value comparison with no `key` field in rule }); + describe('RuleManager with custom RuleData interface', function () { + let ruleManager; + const configuration = objectDeepMerge(testConfig, defaultConfig) as unknown as ConfigType; + const urlRuleSet = { + OR: [ + { + AND: [ + { + OR_WHEN: [ + { + rule_type: 'url', + matching: { + match_type: 'equals', + negated: false + }, + value: 'https://convert.com/' + } + ] + } + ] + } + ] + }; + const jsConditionRuleSet = { + OR: [ + { + AND: [ + { + OR_WHEN: [ + { + rule_type: 'js_condition', + matching: { + match_type: 'equals', + negated: false + }, + value: true + } + ] + } + ] + } + ] + }; + beforeEach(function () { + ruleManager = new rm(configuration); + }); + it('Should evaluate RuleData getter methods for web rule types', function () { + expect( + ruleManager.isRuleMatched( + { + name: 'RuleData', + getUrl: () => 'https://convert.com/', + getBrowserName: () => 'chrome', + getCookie: () => 'utm_source=google' + }, + urlRuleSet + ) + ).to.equal(true); + }); + it('Should evaluate RuleData prototype getter methods', function () { + class WebRuleDataProvider { + name = 'RuleData'; + getJsCondition() { + return true; + } + } + expect( + ruleManager.isRuleMatched( + new WebRuleDataProvider(), + jsConditionRuleSet + ) + ).to.equal(true); + }); + it('Should return false when a RuleData getter method is missing', function () { + expect( + ruleManager.isRuleMatched( + { + name: 'RuleData', + getBrowserName: () => 'chrome' + }, + urlRuleSet + ) + ).to.equal(false); + }); + }); }); diff --git a/packages/types/index.ts b/packages/types/index.ts index 68ac1a02..534ec610 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -21,6 +21,7 @@ export * from './src/LocationAttributes'; export * from './src/Path'; export * from './src/RequireAtLeastOne'; export * from './src/Rule'; +export * from './src/RuleData'; export * from './src/SegmentsAttributes'; export * from './src/StoreData'; export * from './src/TrackingEvent'; diff --git a/packages/types/src/Config.ts b/packages/types/src/Config.ts index 9ffe139e..3157a593 100644 --- a/packages/types/src/Config.ts +++ b/packages/types/src/Config.ts @@ -7,6 +7,7 @@ import {ConfigResponseData} from './config/index'; import {LogLevel} from '@convertcom/js-sdk-enums'; +import {RuleData} from './RuleData'; export * from './config/index'; @@ -49,6 +50,7 @@ type ConfigBase = { source?: string; }; mapper?: (...args: any) => any; + ruleDataProvider?: RuleData; }; type ConfigWithSdkKey = ConfigBase & { diff --git a/packages/types/src/RuleData.ts b/packages/types/src/RuleData.ts new file mode 100644 index 00000000..237485f9 --- /dev/null +++ b/packages/types/src/RuleData.ts @@ -0,0 +1,74 @@ +/* + * Convert JS SDK + * Version 1.0.0 + * Copyright(c) 2020 Convert Insights, Inc + * License Apache-2.0 + */ + +import {RuleElement} from './config/index'; + +export type RuleDataValue = + | string + | number + | boolean + | Record + | Array + | null + | undefined; + +export type RuleDataMethod = (rule?: RuleElement) => T; + +export type RuleData = { + name: 'RuleData'; + get?: (method?: RuleElement | string) => Record; + getUrl?: RuleDataMethod; + getUrlWithQuery?: RuleDataMethod; + getQueryString?: RuleDataMethod; + getPageTagPageType?: RuleDataMethod; + getPageTagCategoryId?: RuleDataMethod; + getPageTagCategoryName?: RuleDataMethod; + getPageTagProductSku?: RuleDataMethod; + getPageTagProductName?: RuleDataMethod; + getPageTagProductPrice?: RuleDataMethod; + getPageTagCustomerId?: RuleDataMethod; + getPageTagCustom1?: RuleDataMethod; + getPageTagCustom2?: RuleDataMethod; + getPageTagCustom3?: RuleDataMethod; + getPageTagCustom4?: RuleDataMethod; + getWeatherCondition?: RuleDataMethod; + getJsCondition?: RuleDataMethod; + getIsDesktop?: RuleDataMethod; + getIsMobile?: RuleDataMethod; + getIsTablet?: RuleDataMethod; + getUserAgent?: RuleDataMethod; + getOs?: RuleDataMethod; + getBrowserVersion?: RuleDataMethod; + getBrowserName?: RuleDataMethod; + getProjectTimeMinuteOfHour?: RuleDataMethod; + getProjectTimeHourOfDay?: RuleDataMethod; + getProjectTimeDayOfWeek?: RuleDataMethod; + getLocalTimeMinuteOfHour?: RuleDataMethod; + getLocalTimeHourOfDay?: RuleDataMethod; + getLocalTimeDayOfWeek?: RuleDataMethod; + getBucketedIntoSegment?: RuleDataMethod>; + getBucketedIntoExperience?: RuleDataMethod; + getVisitsCount?: RuleDataMethod; + getVisitorType?: RuleDataMethod; + getVisitorId?: RuleDataMethod; + getVisitorDataExists?: RuleDataMethod; + getCookie?: RuleDataMethod; + getVisitDuration?: RuleDataMethod; + getGoalTriggered?: RuleDataMethod>; + getPagesVisitedCount?: RuleDataMethod; + getLanguage?: RuleDataMethod; + getDaysSinceLastVisit?: RuleDataMethod; + getRegion?: RuleDataMethod; + getCountry?: RuleDataMethod; + getCity?: RuleDataMethod; + getAvgTimePage?: RuleDataMethod; + getSourceName?: RuleDataMethod; + getMedium?: RuleDataMethod; + getKeyword?: RuleDataMethod; + getCampaign?: RuleDataMethod; + [key: string]: any; +}; From a17e770e9a427f83fb1c3cedfba9a7e350f42192 Mon Sep 17 00:00:00 2001 From: Joseph Samir Date: Fri, 27 Mar 2026 21:58:47 +0200 Subject: [PATCH 3/6] feat: Web variation rendering --- packages/js-sdk/src/context.ts | 187 +++++ packages/js-sdk/src/interfaces/context.ts | 6 + packages/js-sdk/tests/context.tests.ts | 662 +++++++++++++++++- packages/js-sdk/tests/core.tests.ts | 169 +++++ .../js-sdk/tests/feature-manager.tests.ts | 238 ++++++- 5 files changed, 1252 insertions(+), 10 deletions(-) diff --git a/packages/js-sdk/src/context.ts b/packages/js-sdk/src/context.ts index 420de672..2ef54237 100644 --- a/packages/js-sdk/src/context.ts +++ b/packages/js-sdk/src/context.ts @@ -31,6 +31,7 @@ import { ERROR_MESSAGES, EntityType, RuleError, + VariationChangeType, SystemEvents } from '@convertcom/js-sdk-enums'; import {objectDeepMerge, objectNotEmpty} from '@convertcom/js-sdk-utils'; @@ -56,6 +57,7 @@ export class Context implements ContextInterface { private _ruleDataProvider?: RuleData; private _visitorProperties: Record; private _environment: string; + private _executedWebChanges: Set; /** * @param {Config} config @@ -102,6 +104,7 @@ export class Context implements ContextInterface { this._apiManager = apiManager; this._loggerManager = loggerManager; this._ruleDataProvider = this.resolveRuleDataProvider(config); + this._executedWebChanges = new Set(); if (objectNotEmpty(visitorProperties)) { const {properties} = @@ -111,6 +114,190 @@ export class Context implements ContextInterface { } } + /** + * Render a web variation in browser + * @param {BucketedVariation} bucketedVariation A variation returned from runExperience/runExperiences + * @param {Object} options + * @param {ConfigExperience} options.experience Optional experience payload override + */ + runVariation( + bucketedVariation: BucketedVariation, + options?: {experience?: ConfigExperience} + ): void { + if (!this._visitorId) { + this._loggerManager?.error?.( + 'Context.runVariation()', + ERROR_MESSAGES.VISITOR_ID_REQUIRED + ); + return; + } + if (!bucketedVariation) return; + + const experience = + options?.experience || + (bucketedVariation.experienceKey + ? (this.getConfigEntity( + bucketedVariation.experienceKey, + EntityType.EXPERIENCE + ) as ConfigExperience) + : bucketedVariation.experienceId + ? (this.getConfigEntityById( + bucketedVariation.experienceId, + EntityType.EXPERIENCE + ) as ConfigExperience) + : null); + + if (!experience) { + this._loggerManager?.warn?.( + 'Context.runVariation()', + `Unable to resolve experience for variation ${bucketedVariation.id || bucketedVariation.key}` + ); + return; + } + + const globalScope = typeof globalThis === 'object' ? (globalThis as any) : null; + const documentRef = globalScope?.document; + if ( + !documentRef || + typeof documentRef.createElement !== 'function' + ) { + this._loggerManager?.warn?.( + 'Context.runVariation()', + 'Variation rendering skipped due to missing document API' + ); + return; + } + + const appender = + documentRef.head || + documentRef.documentElement || + documentRef.body || + (typeof documentRef.getElementsByTagName === 'function' + ? documentRef.getElementsByTagName('head')[0] + : null); + + if (!appender) { + this._loggerManager?.warn?.( + 'Context.runVariation()', + 'Variation rendering skipped because document has no append target' + ); + return; + } + + if (typeof appender.appendChild !== 'function') return; + + const getChangeKey = ( + changeId: string, + variationId?: string + ): string | null => { + if (variationId) return `variation:${variationId}:change:${changeId}`; + return null; + }; + + const variationExecutionKey = String( + bucketedVariation.id || + bucketedVariation.key || + bucketedVariation.experienceId || + bucketedVariation.experienceKey + ); + + const runCss = (css?: string): void => { + if (!css) return; + try { + const style = documentRef.createElement('style'); + style.type = 'text/css'; + if ( + typeof documentRef.createTextNode === 'function' && + typeof style.appendChild === 'function' + ) { + style.appendChild(documentRef.createTextNode(css)); + } else { + style.textContent = css; + } + appender.appendChild(style); + } catch (error) { + this._loggerManager?.warn?.( + 'Context.runVariation()', + 'Failed to inject variation CSS', + error + ); + } + }; + + const runJs = (js?: string): void => { + if (!js) return; + try { + const expressionCode = String(js).trim().replace(/;+$/g, ''); + if (!expressionCode) return; + + const isFunctionCode = + /^(?:async\s+)?function\b/.test(expressionCode) || + /^(?:async\s*)?\(\s*[^)]*\)\s*=>/.test(expressionCode) || + /^(?:async\s*)?[A-Za-z_$][\w$]*\s*=>/.test(expressionCode); + + const executableCode = isFunctionCode + ? `return (${expressionCode})` + : `return function(){\n${expressionCode}\n}`; + + const result = Function(executableCode)(); + if (typeof result === 'function') result(); + } catch (error) { + this._loggerManager?.warn?.( + 'Context.runVariation()', + 'Failed to execute variation JS', + error + ); + } + }; + + if (!this._executedWebChanges.has(`variation:${variationExecutionKey}`)) { + runCss(experience.global_css); + runJs(experience.global_js); + this._executedWebChanges.add(`variation:${variationExecutionKey}`); + } + + (bucketedVariation?.changes || []).forEach((change) => { + const changeId = String(change?.id || ''); + const changeExecutionKey = + changeId && variationExecutionKey + ? getChangeKey(changeId, variationExecutionKey) + : null; + + if (changeExecutionKey && this._executedWebChanges.has(changeExecutionKey)) + return; + + const {type, data = {}} = change; + const {css: cssRaw, js: jsRaw, custom_js: customJsRaw} = data as { + css?: unknown; + js?: unknown; + custom_js?: unknown; + }; + const css = typeof cssRaw === 'string' ? cssRaw : undefined; + const js = typeof jsRaw === 'string' ? jsRaw : undefined; + const customJs = typeof customJsRaw === 'string' ? customJsRaw : undefined; + + if ( + type === VariationChangeType.DEFAULT_REDIRECT || + type === VariationChangeType.FULLSTACK_FEATURE + ) { + this._loggerManager?.warn?.( + 'Context.runVariation()', + `Skipping unsupported change type "${type}"` + ); + return; + } + + if (!css && !js && !customJs) return; + + runCss(css); + runJs(js); + runJs(customJs); + + if (changeExecutionKey) + this._executedWebChanges.add(changeExecutionKey); + }); + } + /** * Get variation from specific experience * @param {string} experienceKey An experience's key that should be activated diff --git a/packages/js-sdk/src/interfaces/context.ts b/packages/js-sdk/src/interfaces/context.ts index eb2d7acb..6693df4c 100644 --- a/packages/js-sdk/src/interfaces/context.ts +++ b/packages/js-sdk/src/interfaces/context.ts @@ -10,6 +10,7 @@ import { BucketedVariation, BucketingAttributes, ConversionAttributes, + ConfigExperience, Entity, SegmentsAttributes, StoreData, @@ -36,6 +37,11 @@ export interface ContextInterface { attributes?: BucketingAttributes ): Array; + runVariation( + bucketedVariation: BucketedVariation, + options?: {experience?: ConfigExperience} + ): void; + trackConversion( goalKey: string, attributes?: ConversionAttributes diff --git a/packages/js-sdk/tests/context.tests.ts b/packages/js-sdk/tests/context.tests.ts index a85bdc51..54b185e3 100644 --- a/packages/js-sdk/tests/context.tests.ts +++ b/packages/js-sdk/tests/context.tests.ts @@ -16,7 +16,7 @@ import {Context as c} from '../src/context'; import testConfig from './test-config.json'; import {Config as ConfigType} from '@convertcom/js-sdk-types'; import {objectDeepMerge} from '@convertcom/js-sdk-utils'; -import {BucketingError, EntityType} from '@convertcom/js-sdk-enums'; +import {BucketingError, EntityType, RuleError} from '@convertcom/js-sdk-enums'; import {defaultConfig} from '../src/config/default'; import { getFeaturesWithStatuses, @@ -719,6 +719,19 @@ describe('Context tests', function () { }); expect(response).to.be.undefined; }); + + it('Should fail to trigger Conversion if goal data is not an array', function () { + const goalKey = 'increase-engagement'; + const response = context.trackConversion(goalKey, { + ruleData: { + action: 'buy' + }, + conversionData: { + amount: 10.3 + } as any + }); + expect(response).to.be.undefined; + }); it('Should successfully set default segments', function () { const segments = {country: 'UK'}; context.setDefaultSegments(segments); @@ -1066,6 +1079,482 @@ describe('Context tests', function () { expect(variation).to.equal(BucketingError.VARIAION_NOT_DECIDED); }); }); + describe('Test variation rendering', function () { + let dataManager, + experienceManager, + featureManager, + segmentsManager, + context, + events; + let originalDocument; + let originalWindow; + + const createBrowserStub = (): { + document: any; + events: Array; + } => { + const events: Array = []; + const assignAppend = (element, child: any) => { + if ( + child && + Object.prototype.hasOwnProperty.call(child, 'text') && + child?.text !== undefined + ) { + element.textContent = child.text; + } + }; + const eventLabel = (node: any): string => { + if (node?.tagName === 'style') return 'css'; + if (node?.tagName === 'script') return 'js'; + return node?.tagName; + }; + const container = { + appendChild: (node: any) => { + const text = node?.textContent || ''; + events.push(`${eventLabel(node)}:${text}`); + return node; + } + }; + return { + document: { + head: container, + body: container, + documentElement: container, + createTextNode: (value: string) => ({text: value}), + createElement: (tagName: string) => { + const element = { + tagName, + type: '', + textContent: '', + appendChild: (node: any) => assignAppend(element, node), + setAttribute: () => {} + }; + return element; + }, + getElementsByTagName: () => [container] + }, + events + }; + }; + + const getContext = () => { + dataManager = new dm(configuration, { + bucketingManager, + ruleManager, + eventManager, + apiManager + }); + experienceManager = new exm(configuration, {dataManager}); + featureManager = new fm(configuration, {dataManager}); + segmentsManager = new sm(configuration, { + dataManager, + ruleManager + }); + return new c( + configuration, + 'RUN-VARIATION-VISITOR', + { + eventManager, + experienceManager, + featureManager, + segmentsManager, + dataManager, + apiManager + }, + {browser: 'chrome', country: 'US'} + ); + }; + + beforeEach(function () { + const setup = createBrowserStub(); + originalDocument = (global as any).document; + originalWindow = (global as any).window; + events = setup.events; + (global as any).document = setup.document; + (global as any).window = setup.document; + context = getContext(); + }); + + afterEach(function () { + (global as any).document = originalDocument; + (global as any).window = originalWindow; + }); + + it('Should execute global then per-change web payload in expected order', function () { + (global as any).window.__webVariationLog = []; + const variation = { + id: '100500100', + experienceKey: 'web-experience', + changes: [ + { + id: 10, + type: 'defaultCode', + data: { + css: '.global .item { color: red; }', + js: 'window.__webVariationLog.push("defaultCode-js");', + custom_js: 'window.__webVariationLog.push("defaultCode-custom");' + } + }, + { + id: 11, + type: 'defaultRedirect', + data: {} + }, + { + id: 12, + type: 'defaultCodeMultipage', + data: { + css: '.page .item { color: blue; }', + js: 'window.__webVariationLog.push("defaultCodeMultipage-js");', + custom_js: + 'window.__webVariationLog.push("defaultCodeMultipage-custom");' + } + }, + { + id: 13, + type: 'customCode', + data: { + css: '.custom .item { color: green; }', + js: 'window.__webVariationLog.push("customCode-js");' + } + } + ] + }; + const experience = { + key: 'web-experience', + global_js: 'window.__webVariationLog.push("global-js");', + global_css: '.global {display:block;}' + } as any; + + context.runVariation(variation as any, {experience}); + expect(events[0]).to.include('css'); + expect(events[0]).to.include('global {display:block;}'); + expect(events[1]).to.include('css:.global .item { color: red; }'); + expect(events[2]).to.include('css:.page .item { color: blue; }'); + expect(events[3]).to.include('css:.custom .item { color: green; }'); + expect(events).to.have.length(4); + expect((global as any).window.__webVariationLog).to.deep.equal([ + 'global-js', + 'defaultCode-js', + 'defaultCode-custom', + 'defaultCodeMultipage-js', + 'defaultCodeMultipage-custom', + 'customCode-js' + ]); + }); + + it('Should skip unsupported web variation change types', function () { + const variation = { + id: '100500102', + experienceKey: 'web-experience-nonweb', + changes: [ + { + id: 21, + type: 'fullStackFeature', + data: { + css: '.should-never-be-added { color: purple; }', + js: 'window.__webVariationLog = "fullStackFeature-js";' + } + }, + { + id: 22, + type: 'defaultRedirect', + data: { + css: '.redirect { color: yellow; }', + js: 'window.__webVariationLog = "defaultRedirect-js";' + } + }, + { + id: 23, + type: 'defaultCode', + data: { + css: '.still-applied { color: black; }' + } + } + ] + }; + const experience = { + key: 'web-experience-nonweb', + global_css: '.global {display:block;}' + } as any; + + context.runVariation(variation as any, {experience}); + expect(events[0]).to.include('css:.global {display:block;}'); + expect(events[1]).to.include('css:.still-applied { color: black; }'); + expect(events).to.have.length(2); + }); + + it('Should keep variation and change changes idempotent across repeated rendering calls', function () { + const variation = { + id: '100500101', + experienceKey: 'web-experience-repeat', + changes: [ + { + id: 21, + type: 'defaultCode', + data: { + css: '.global .item { color: red; }' + } + } + ] + }; + const experience = { + key: 'web-experience-repeat', + global_css: '.global {display:block;}' + } as any; + + context.runVariation(variation as any, {experience}); + context.runVariation(variation as any, {experience}); + expect(events).to.have.length(2); + expect(events[0]).to.include('css:.global {display:block;}'); + expect(events[1]).to.include('css:.global .item { color: red; }'); + }); + + it('Should skip global rendering when document API is unavailable', function () { + const variation = { + id: '100500104', + experienceKey: 'web-missing-document', + changes: [ + { + id: 31, + type: 'defaultCode', + data: { + css: '.not-added { color: red; }' + } + } + ] + }; + const experience = { + key: 'web-missing-document', + global_css: '.global {display:block;}' + } as any; + + (global as any).document = undefined; + context.runVariation(variation as any, {experience}); + expect(events).to.have.length(0); + }); + + it('Should skip global rendering when no append target exists', function () { + const variation = { + id: '100500105', + experienceKey: 'web-no-appender', + changes: [ + { + id: 32, + type: 'defaultCode', + data: { + css: '.not-added { color: red; }' + } + } + ] + }; + const experience = { + key: 'web-no-appender', + global_css: '.global {display:block;}' + } as any; + + (global as any).document = { + createElement: (tagName: string) => ({tagName, type: '', textContent: ''}), + createTextNode: (value: string) => ({text: value}), + getElementsByTagName: () => [] + } as any; + + context.runVariation(variation as any, {experience}); + expect(events).to.have.length(0); + }); + + it('Should skip rendering when append target does not support appendChild', function () { + const variation = { + id: '100500106', + experienceKey: 'web-no-append-child', + changes: [ + { + id: 33, + type: 'defaultCode', + data: { + css: '.not-added { color: red; }' + } + } + ] + }; + const experience = { + key: 'web-no-append-child', + global_css: '.global {display:block;}' + } as any; + const appender = {}; + + (global as any).document = { + head: appender, + body: appender, + documentElement: appender, + createElement: (tagName: string) => ({tagName, type: '', textContent: ''}), + createTextNode: (value: string) => ({text: value}), + getElementsByTagName: () => [appender] + } as any; + + context.runVariation(variation as any, {experience}); + expect(events).to.have.length(0); + }); + + it('Should fallback to style.textContent when createTextNode is unavailable', function () { + const variation = { + id: '100500107', + experienceKey: 'web-style-fallback', + changes: [ + { + id: 34, + type: 'defaultCode', + data: { + css: '.fallback-item { color: green; }' + } + } + ] + }; + const experience = { + key: 'web-style-fallback', + global_css: '.global {display:block;}' + } as any; + const rendered: string[] = []; + const createElement = (tagName: string) => { + if (tagName === 'style') { + return {tagName, type: '', textContent: ''}; + } + const element: any = {tagName, type: '', textContent: ''}; + element.appendChild = (node: any) => { + if ( + node && + Object.prototype.hasOwnProperty.call(node, 'text') && + node?.text !== undefined + ) { + element.textContent = node.text; + } + }; + return element; + }; + const appender = { + appendChild: (node: any) => { + rendered.push(`${node?.tagName}:${node?.textContent || ''}`); + } + }; + (global as any).document = { + head: appender, + body: appender, + documentElement: appender, + createElement, + getElementsByTagName: () => [appender] + } as any; + + context.runVariation(variation as any, {experience}); + expect(rendered).to.include('style:.global {display:block;}'); + expect(rendered).to.include('style:.fallback-item { color: green; }'); + expect(rendered).to.have.length(2); + }); + + it('Should continue rendering when CSS injection fails but JS injection still runs', function () { + (global as any).window.__webVariationLog = []; + const variation = { + id: '100500108', + experienceKey: 'web-css-error', + changes: [] + }; + const experience = { + key: 'web-css-error', + global_js: 'window.__webVariationLog.push("global-css-error");' + } as any; + const rendered: string[] = []; + const appender = { + appendChild: (node: any) => { + rendered.push(`${node?.tagName}:${node?.textContent || ''}`); + } + }; + (global as any).document = { + head: appender, + body: appender, + documentElement: appender, + createTextNode: (value: string) => ({text: value}), + createElement: (tagName: string) => { + if (tagName === 'style') { + throw new Error('style injection failed'); + } + return {tagName, type: '', textContent: ''}; + }, + getElementsByTagName: () => [appender] + } as any; + + context.runVariation(variation as any, {experience}); + expect(rendered).to.have.length(0); + expect((global as any).window.__webVariationLog).to.deep.equal([ + 'global-css-error' + ]); + }); + + it('Should continue rendering when JS injection fails', function () { + const variation = { + id: '100500109', + experienceKey: 'web-js-error', + changes: [] + }; + const experience = { + key: 'web-js-error', + global_css: '.global {display:block;}' + } as any; + const rendered: string[] = []; + const appender = { + appendChild: (node: any) => { + rendered.push(`${node?.tagName}:${node?.textContent || ''}`); + } + }; + (global as any).document = { + head: appender, + body: appender, + documentElement: appender, + createTextNode: (value: string) => ({text: value}), + createElement: (tagName: string) => { + if (tagName === 'script') { + throw new Error('script injection failed'); + } + const element: any = {tagName, type: '', textContent: ''}; + element.appendChild = (node: any) => { + if ( + node && + Object.prototype.hasOwnProperty.call(node, 'text') && + node?.text !== undefined + ) { + element.textContent = node.text; + } + }; + return element; + }, + getElementsByTagName: () => [appender] + } as any; + + context.runVariation(variation as any, {experience}); + expect(rendered).to.have.length(1); + expect(rendered[0]).to.equal('style:.global {display:block;}'); + }); + + it('Should execute changes when no change id is provided', function () { + const variation = { + changes: [ + { + type: 'defaultCode', + data: { + css: '.no-id-item { color: orange; }' + } + } + ] + }; + const experience = { + key: 'web-no-id-change', + global_css: '.global {display:block;}' + } as any; + + context.runVariation(variation as any, {experience}); + expect(events[0]).to.include('css:.global {display:block;}'); + expect(events[1]).to.include('css:.no-id-item { color: orange; }'); + expect(events).to.have.length(2); + }); + }); describe('Test invalid visitor', function () { let dataManager, experienceManager, @@ -1121,4 +1610,175 @@ describe('Context tests', function () { expect(output).to.be.undefined; }); }); + describe('Test Context branch coverage', function () { + const createMockContext = ({ + experienceManager, + featureManager, + segmentsManager, + dataManager, + apiManager + }: { + experienceManager?: any; + featureManager?: any; + segmentsManager?: any; + dataManager?: any; + apiManager?: any; + } = {}): c => { + return new c( + configuration, + 'BRANCH-COVERAGE-VISITOR', + { + eventManager, + experienceManager: experienceManager || {selectVariation: () => null}, + featureManager: + featureManager || { + runFeature: () => null, + runFeatures: () => [] + }, + segmentsManager: + segmentsManager || { + getSegments: () => ({}), + selectCustomSegments: () => null + }, + dataManager: dataManager || {getData: () => ({})}, + apiManager: apiManager || {releaseQueue: () => Promise.resolve()} + } as any + ); + }; + + it('Should return rule error from runExperience', function () { + const context = createMockContext({ + experienceManager: { + selectVariation: () => RuleError.NO_DATA_FOUND + } as any + }); + const output = context.runExperience('any-experience'); + expect(output).to.equal(RuleError.NO_DATA_FOUND); + }); + + it('Should return bucketing error from runExperience', function () { + const context = createMockContext({ + experienceManager: { + selectVariation: () => BucketingError.VARIAION_NOT_DECIDED + } as any + }); + const output = context.runExperience('any-experience'); + expect(output).to.equal(BucketingError.VARIAION_NOT_DECIDED); + }); + + it('Should return rule errors from runExperiences', function () { + const context = createMockContext({ + experienceManager: { + selectVariations: () => [RuleError.NO_DATA_FOUND, RuleError.NEED_MORE_DATA] + } as any + }); + const output = context.runExperiences(); + expect(output).to.deep.equal([ + RuleError.NO_DATA_FOUND, + RuleError.NEED_MORE_DATA + ]); + }); + + it('Should return bucketing errors from runExperiences', function () { + const context = createMockContext({ + experienceManager: { + selectVariations: () => [BucketingError.VARIAION_NOT_DECIDED] + } as any + }); + const output = context.runExperiences(); + expect(output).to.deep.equal([BucketingError.VARIAION_NOT_DECIDED]); + }); + + it('Should return rule error from runFeature', function () { + const context = createMockContext({ + featureManager: { + runFeature: () => RuleError.NO_DATA_FOUND + } as any + }); + const output = context.runFeature('feature-1'); + expect(output).to.equal(RuleError.NO_DATA_FOUND); + }); + + it('Should return rule errors from runFeatures', function () { + const context = createMockContext({ + featureManager: { + runFeatures: () => [RuleError.NO_DATA_FOUND] + } as any + }); + const output = context.runFeatures(); + expect(output).to.deep.equal([RuleError.NO_DATA_FOUND]); + }); + + it('Should return rule error from trackConversion', function () { + const context = createMockContext({ + segmentsManager: { + getSegments: () => ({}) + } as any, + dataManager: { + getData: () => ({}), + convert: () => RuleError.NEED_MORE_DATA + } as any + }); + const output = context.trackConversion('increase-engagement'); + expect(output).to.equal(RuleError.NEED_MORE_DATA); + }); + + it('Should return rule error from setCustomSegments', function () { + const context = createMockContext({ + segmentsManager: { + getSegments: () => ({}), + selectCustomSegments: () => RuleError.NO_DATA_FOUND + } as any + }); + const output = context.runCustomSegments(['feature-segment']); + expect(output).to.equal(RuleError.NO_DATA_FOUND); + }); + + it('Should release pending queues only through API when no dataStoreManager is available', async function () { + let apiReleaseCalls = 0; + let dataStoreReleaseCalls = 0; + const context = createMockContext({ + dataManager: { + getData: () => ({}), + dataStoreManager: undefined + } as any, + apiManager: { + releaseQueue: () => { + apiReleaseCalls += 1; + return Promise.resolve(); + } + } as any + }); + + await context.releaseQueues('context-branch'); + expect(apiReleaseCalls).to.equal(1); + expect(dataStoreReleaseCalls).to.equal(0); + }); + + it('Should release pending queues through both dataStoreManager and API', async function () { + let apiReleaseCalls = 0; + let dataStoreReleaseCalls = 0; + const context = createMockContext({ + dataManager: { + getData: () => ({}), + dataStoreManager: { + releaseQueue: () => { + dataStoreReleaseCalls += 1; + return Promise.resolve(); + } + } + } as any, + apiManager: { + releaseQueue: () => { + apiReleaseCalls += 1; + return Promise.resolve(); + } + } as any + }); + + await context.releaseQueues('context-branch'); + expect(apiReleaseCalls).to.equal(1); + expect(dataStoreReleaseCalls).to.equal(1); + }); + }); }); diff --git a/packages/js-sdk/tests/core.tests.ts b/packages/js-sdk/tests/core.tests.ts index 9ee85a8a..d65f9295 100644 --- a/packages/js-sdk/tests/core.tests.ts +++ b/packages/js-sdk/tests/core.tests.ts @@ -279,4 +279,173 @@ describe('Core tests', function () { } }); }); + + describe('Test Core branch coverage', function () { + it('Should initialize successfully on first fetch with fresh config', async function () { + const capturedEvents: string[] = []; + const dataManager = { + data: null + } as any; + const apiManager = { + getConfig: () => + Promise.resolve({ + account_id: 'unit-account', + project: {id: 'unit-project'} + }), + setData: (data) => { + dataManager.data = data; + } + } as any; + + const core = new c( + {sdkKey: 'unit-account/unit-project'} as any, + { + eventManager: { + fire: (event, args, err, immediate) => { + capturedEvents.push(event); + }, + on: () => {} + } as any, + experienceManager: {} as any, + featureManager: {} as any, + segmentsManager: {} as any, + dataManager, + apiManager, + loggerManager: { + trace: () => {} + } as any + } + ); + + await core.onReady(); + expect((core as any)._initialized).to.equal(true); + expect(capturedEvents[0]).to.equal(SystemEvents.READY); + }); + + it('Should log fetchConfig server-side error and keep client non-initialized', async function () { + const loggerCalls: any[] = []; + const dataManager = { + data: null + } as any; + const apiManager = { + getConfig: () => Promise.resolve({error: 'server-unavailable'}), + setData: (data) => { + dataManager.data = data; + } + } as any; + + new c( + {sdkKey: 'unit-account/unit-project'} as any, + { + eventManager: { + fire: () => {}, + on: () => {} + } as any, + experienceManager: {} as any, + featureManager: {} as any, + segmentsManager: {} as any, + dataManager, + apiManager, + loggerManager: { + error: (...values) => { + loggerCalls.push(values); + } + } as any + } + ); + + await (dataManager as any).initialized; + await Promise.resolve(); + expect(dataManager.data).to.deep.equal({error: 'server-unavailable'}); + expect(loggerCalls).to.have.length(1); + expect(loggerCalls[0][1]).to.deep.equal({ + error: 'server-unavailable' + }); + }); + + it('Should handle fetchConfig promise rejection', async function () { + const loggerCalls: any[] = []; + const getConfigError = new Error('network-down'); + + new c( + {sdkKey: 'unit-account/unit-project'} as any, + { + eventManager: { + fire: () => {}, + on: () => {} + } as any, + experienceManager: {} as any, + featureManager: {} as any, + segmentsManager: {} as any, + dataManager: {data: null} as any, + apiManager: { + getConfig: () => Promise.reject(getConfigError) + } as any, + loggerManager: { + error: (...values) => { + loggerCalls.push(values); + } + } as any + } + ); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(loggerCalls).to.have.length(1); + expect(loggerCalls[0][1]?.error).to.equal('network-down'); + }); + + it('Should clear pending fetchConfig timer before scheduling next fetch', async function () { + const dataManager = { + data: null + } as any; + + const originalClearTimeout = global.clearTimeout; + let clearTimeoutCalls = 0; + + const clearSpy = ((id: any): any => { + clearTimeoutCalls += 1; + return originalClearTimeout(id); + }) as any; + + try { + global.clearTimeout = clearSpy; + + const apiManager = { + getConfig: () => + Promise.resolve({ + account_id: 'unit-account', + project: {id: 'unit-project'} + }), + setData: (data) => { + dataManager.data = data; + } + } as any; + + const core = new c( + {sdkKey: 'unit-account/unit-project'} as any, + { + eventManager: { + fire: () => {}, + on: () => {} + } as any, + experienceManager: {} as any, + featureManager: {} as any, + segmentsManager: {} as any, + dataManager, + apiManager, + loggerManager: { + trace: () => {} + } as any + } + ); + + await (core as any)._promise; + await (core as any).fetchConfig(); + + expect(clearTimeoutCalls).to.equal(1); + } finally { + global.clearTimeout = originalClearTimeout as any; + } + }); + }); }); diff --git a/packages/js-sdk/tests/feature-manager.tests.ts b/packages/js-sdk/tests/feature-manager.tests.ts index 7637f9c3..e5c88c7f 100644 --- a/packages/js-sdk/tests/feature-manager.tests.ts +++ b/packages/js-sdk/tests/feature-manager.tests.ts @@ -14,6 +14,7 @@ import testConfig from './test-config.json'; import {Config as ConfigType} from '@convertcom/js-sdk-types'; import {objectDeepMerge} from '@convertcom/js-sdk-utils'; import {defaultConfig} from '../src/config/default'; +import {FeatureStatus, RuleError} from '@convertcom/js-sdk-enums'; const host = 'http://localhost'; const port = 8090; @@ -255,15 +256,234 @@ describe('FeatureManager tests', function () { res.end('{}'); }); }); - it('Convert value type', function () { - let value = featureManager.castType('123', 'integer'); - expect(typeof value).to.equal('number'); - value = featureManager.castType(123, 'string'); - expect(typeof value).to.equal('string'); - value = featureManager.castType('1.23', 'float'); - expect(typeof value).to.equal('number'); - value = featureManager.castType('false', 'boolean'); - expect(typeof value).to.equal('boolean'); + it('Convert value type', function () { + let value = featureManager.castType('123', 'integer'); + expect(typeof value).to.equal('number'); + value = featureManager.castType(123, 'string'); + expect(typeof value).to.equal('string'); + value = featureManager.castType('1.23', 'float'); + expect(typeof value).to.equal('number'); + value = featureManager.castType('false', 'boolean'); + expect(typeof value).to.equal('boolean'); + }); + }); + + describe('Test FeatureManager branch coverage', function () { + it('Should return null when feature variables are not provided for type detection', function () { + const featureWithoutVariables = { + id: '90001', + name: 'Feature without variables', + key: 'feature-without-variables' + }; + const customFeatureManager = new fm(null as any, { + dataManager: { + getEntity: () => featureWithoutVariables, + getEntityById: () => featureWithoutVariables, + getEntitiesListObject: () => ({ + 'feature-without-variables': featureWithoutVariables + }) + } as any + }); + + expect(customFeatureManager.getFeatureVariableType('feature-without-variables', 'missing')).to.be.null; + expect(customFeatureManager.getFeatureVariableTypeById('90001', 'missing')).to.be.null; + }); + + it('Should return disabled feature when feature is declared but no bucketed variation is returned', function () { + const declaredFeature = {id: '90002', key: 'feature-declared-no-variation', name: 'Feature disabled'}; + const featureManagerForNoVariation = new fm(null as any, { + dataManager: { + getEntity: (key: string) => + key === declaredFeature.key ? declaredFeature : null, + getEntityById: () => declaredFeature, + getListAsObject: () => ({[declaredFeature.id]: declaredFeature}), + getEntitiesList: () => [{key: 'test-experience-branch-no-variation'}], + getEntities: (keys: Array) => + keys.map((key) => ({key, id: `id-${key}`})), + getEntitiesByIds: (ids: Array) => + ids.map((id) => ({id, key: `id-${id}`})), + getEntitiesListObject: () => ({ + [declaredFeature.id]: declaredFeature + }), + getBucketing: () => ({ + experienceId: 'e1', + experienceName: 'Exp', + experienceKey: 'test-experience-branch-no-variation', + changes: [{type: 'defaultCode', data: {}}] + }) + } as any + }); + + const output = featureManagerForNoVariation.runFeature('feature-visitor-branch', declaredFeature.key, {}); + expect(output).to.deep.equal({ + id: declaredFeature.id, + name: declaredFeature.name, + key: declaredFeature.key, + status: FeatureStatus.DISABLED + }); + }); + + it('Should return disabled feature if feature is not declared', function () { + expect(featureManager.runFeature('BRANCH-VISITOR', 'feature-does-not-exist', {})).to.deep.equal({ + key: 'feature-does-not-exist', + status: FeatureStatus.DISABLED + }); + }); + + it('Should return false if the feature is not declared', function () { + expect(featureManager.isFeatureEnabled('BRANCH-VISITOR', 'feature-missing')).to.equal( + false + ); + }); + + it('Should return disabled feature when runFeatureById is not declared', function () { + expect( + featureManager.runFeatureById('feature-visitor', 'feature-id-missing', {}) + ).to.deep.equal({ + id: 'feature-id-missing', + status: FeatureStatus.DISABLED + }); + }); + + it('Should return a single feature from runFeatureById when only one match is found', function () { + const declaredFeature = { + id: '90003', + key: 'feature-single-match', + name: 'Single Match Feature', + variables: [{key: 'enabled', type: 'boolean'}] + }; + + const managerWithSingleMatch = new fm(null as any, { + dataManager: { + getEntityById: (id: string) => + id === declaredFeature.id ? declaredFeature : null, + getListAsObject: () => ({[declaredFeature.id]: declaredFeature}), + getEntitiesList: () => [{key: 'test-experience-single'}], + getEntities: () => [{key: 'test-experience-single', id: 'e1'}], + getEntitiesByIds: (ids: Array = []) => + ids.map((id) => ({id, key: `exp-${id}`})), + getEntitiesListObject: () => ({ + [declaredFeature.id]: declaredFeature + }), + getBucketing: () => ({ + experienceId: 'e1', + experienceName: 'Exp', + experienceKey: 'test-experience-single', + changes: [ + { + type: 'fullStackFeature', + data: { + feature_id: declaredFeature.id, + variables_data: {enabled: 'true'} + } + } + ] + }) + } as any + }); + + const output = managerWithSingleMatch.runFeatureById( + 'feature-visitor', + declaredFeature.id, + {} as any + ) as any; + expect(output).to.have.property('key', declaredFeature.key); + expect(output).to.have.property('status', FeatureStatus.ENABLED); + }); + + it('Should return rule errors when runFeatures detects bucketing errors', function () { + const managerWithErrors = new fm(null as any, { + dataManager: { + getListAsObject: () => ({}), + getEntitiesList: () => [{key: 'test-experience-errors'}], + getEntitiesListObject: () => ({}), + getBucketing: () => RuleError.NO_DATA_FOUND + } as any + }); + + const output = managerWithErrors.runFeatures('feature-visitor', {} as any); + expect(output).to.deep.equal([RuleError.NO_DATA_FOUND]); + }); + + it('Should skip unsupported changes, missing feature ids, and missing feature variable type definitions', function () { + const declaredFeatures = { + f4: { + id: 'f4', + key: 'feature-with-variables', + name: 'Feature with variable', + variables: [{key: 'enabled', type: 'boolean'}] + }, + f5: { + id: 'f5', + key: 'feature-without-variable-def', + name: 'Feature without variable definition' + } + }; + + const managerWithMixedChanges = new fm(null as any, { + dataManager: { + getListAsObject: () => ({ + f4: declaredFeatures.f4, + f5: declaredFeatures.f5 + }), + getEntitiesListObject: () => ({ + f4: declaredFeatures.f4, + f5: declaredFeatures.f5 + }), + getEntitiesList: () => [{key: 'test-experience-mixed'}], + getBucketing: () => ({ + experienceId: 'e1', + experienceName: 'Exp', + experienceKey: 'test-experience-mixed', + changes: [ + {type: 'defaultCode', data: {value: 1}}, + { + type: 'fullStackFeature', + data: { + feature_id: declaredFeatures.f4.id + } + }, + { + type: 'fullStackFeature', + data: { + feature_id: declaredFeatures.f5.id, + variables_data: {missingVariable: 'true'} + } + }, + { + type: 'fullStackFeature', + data: {} + } + ] + }) + } as any + }); + + const output = managerWithMixedChanges.runFeatures( + 'feature-visitor', + {} as any + ) as any[]; + expect(output).to.be.an('array'); + expect(output).to.have.length(2); + + const featureFromChanges = output.find((item) => item.id === declaredFeatures.f4.id) as any; + expect(featureFromChanges).to.have.property('status', FeatureStatus.ENABLED); + expect(featureFromChanges).to.have.property('key', declaredFeatures.f4.key); + expect(featureFromChanges).to.have.property('name', declaredFeatures.f4.name); + expect(featureFromChanges).to.have.property('experienceId', 'e1'); + expect(featureFromChanges).to.have.property('experienceName', 'Exp'); + expect(featureFromChanges).to.have.property('experienceKey', 'test-experience-mixed'); + + const featureWithMissingVariables = output.find( + (item) => item.id === declaredFeatures.f5.id + ) as any; + expect(featureWithMissingVariables).to.have.property('status', FeatureStatus.ENABLED); + expect(featureWithMissingVariables).to.have.property('key', declaredFeatures.f5.key); + expect(featureWithMissingVariables).to.have.property('name', declaredFeatures.f5.name); + expect(featureWithMissingVariables).to.have.property('variables'); + expect(featureWithMissingVariables.variables).to.deep.equal({ + missingVariable: 'true' + }); }); }); }); From 3ee194c2673a94ac1cac97a76b780d5d1453b365 Mon Sep 17 00:00:00 2001 From: Joseph Samir Date: Fri, 27 Mar 2026 22:19:18 +0200 Subject: [PATCH 4/6] feat: Standalone client bundles --- generate-rollup-config.mjs | 214 +++++-- packages/js-sdk/src/standalone/goals-entry.ts | 52 ++ .../js-sdk/src/standalone/goals-render.ts | 260 ++++++++ packages/js-sdk/src/standalone/goals.ts | 555 ++++++++++++++++++ .../src/standalone/integrations-entry.ts | 65 ++ .../js-sdk/src/standalone/integrations.ts | 121 ++++ packages/js-sdk/src/standalone/runtime.ts | 248 ++++++++ packages/js-sdk/src/standalone/split-entry.ts | 244 ++++++++ .../js-sdk/src/standalone/visitor-entry.ts | 5 + rollup.config.mjs | 8 +- 10 files changed, 1733 insertions(+), 39 deletions(-) create mode 100644 packages/js-sdk/src/standalone/goals-entry.ts create mode 100644 packages/js-sdk/src/standalone/goals-render.ts create mode 100644 packages/js-sdk/src/standalone/goals.ts create mode 100644 packages/js-sdk/src/standalone/integrations-entry.ts create mode 100644 packages/js-sdk/src/standalone/integrations.ts create mode 100644 packages/js-sdk/src/standalone/runtime.ts create mode 100644 packages/js-sdk/src/standalone/split-entry.ts create mode 100644 packages/js-sdk/src/standalone/visitor-entry.ts diff --git a/generate-rollup-config.mjs b/generate-rollup-config.mjs index 4c51fe0b..45e590dc 100644 --- a/generate-rollup-config.mjs +++ b/generate-rollup-config.mjs @@ -40,7 +40,7 @@ switch (logLevel) { break; case 4: console.log('log level:', 'error'); - LOGGER_OPTIONS.find = /this\._loggerManager(\?)?\.(?!(error)).*?;$/gms; + LOGGER_OPTIONS.find = /this\._loggerManager(\?)?\..*?;$/gms; break; case 5: console.log('log level:', 'silent'); @@ -160,20 +160,21 @@ const commonJSBundle = ({ info, packageName, peerDependencies, - isMainPackage + isMainPackage, + fileName = 'index' }) => ({ cache: BUILD_CACHE, input, output: [ { exports: 'named', - file: resolve(basePath, 'lib', 'index.js'), + file: resolve(basePath, 'lib', `${fileName}.js`), format: 'cjs', sourcemap: true }, { exports: 'named', - file: resolve(basePath, 'lib', 'index.min.js'), + file: resolve(basePath, 'lib', `${fileName}.min.js`), format: 'cjs', sourcemap: true, plugins: [terser(terserConfig)] @@ -195,10 +196,10 @@ const commonJSBundle = ({ generatePackageJson({ baseContents: (pkg) => ({ name: pkg.name, - main: 'index.min.js', - module: 'index.min.mjs', - browser: 'index.umd.min.js', - types: 'index.d.ts', + main: `${fileName}.min.js`, + module: `${fileName}.min.mjs`, + browser: `${fileName}.umd.min.js`, + types: `${fileName}.d.ts`, files: ['**/**/*'], author: 'Convert Insights, Inc', repository: { @@ -236,19 +237,65 @@ const commonJSBundle = ({ ]) }); -const commonJSLegacyBundle = ({basePath, input, info, packageName}) => ({ +const commonJSStandaloneBundle = ({ + basePath, + input, + info, + packageName, + fileName = 'index' +}) => ({ + cache: BUILD_CACHE, + input, + output: [ + { + exports: 'named', + file: resolve(basePath, 'lib', `${fileName}.js`), + format: 'cjs', + sourcemap: true + }, + { + exports: 'named', + file: resolve(basePath, 'lib', `${fileName}.min.js`), + format: 'cjs', + sourcemap: true, + plugins: [terser(terserConfig)] + } + ], + plugins: withLogging.concat([ + modify(CONFIG_ENV), + modify(TRACK_ENV), + modify({ + find: 'process.env.VERSION', + replace: `'js${info.version || 'js-sdk'}'` + }), + typescript({ + tsconfig: resolve(process.env.PROJECT_CWD, 'tsconfig.json'), + tsconfigOverride: tsconfigOverride(basePath, packageName) + }), + nodeResolve(), + commonjs() + ]) +}); + +const commonJSLegacyBundle = ({ + basePath, + input, + info, + packageName, + fileName = 'index' +}) => ({ cache: BUILD_CACHE, input, output: [ { exports: 'named', - file: resolve(basePath, 'lib', 'legacy', 'index.js'), + file: resolve(basePath, 'lib', 'legacy', `${fileName}.js`), format: 'cjs', sourcemap: true }, { exports: 'named', - file: resolve(basePath, 'lib', 'legacy', 'index.min.js'), + file: resolve(basePath, 'lib', 'legacy', `${fileName}.min.js`), format: 'cjs', sourcemap: true, plugins: [terser(terserConfig)] @@ -275,20 +322,26 @@ const commonJSLegacyBundle = ({basePath, input, info, packageName}) => ({ ]) }); -const esmBundle = ({basePath, input, info, packageName}) => ({ +const esmBundle = ({ + basePath, + input, + info, + packageName, + fileName = 'index' +}) => ({ cache: BUILD_CACHE, input, output: [ { exports: 'auto', format: 'es', - file: resolve(basePath, 'lib', 'index.mjs'), + file: resolve(basePath, 'lib', `${fileName}.mjs`), sourcemap: true }, { exports: 'auto', format: 'es', - file: resolve(basePath, 'lib', 'index.min.mjs'), + file: resolve(basePath, 'lib', `${fileName}.min.mjs`), plugins: [terser(terserConfig)], sourcemap: true } @@ -310,22 +363,28 @@ const esmBundle = ({basePath, input, info, packageName}) => ({ ]) }); -const umdBundle = ({basePath, input, info}) => ({ +const umdBundle = ({ + basePath, + input, + info, + name = 'ConvertSDK', + fileName = 'index' +}) => ({ cache: BUILD_CACHE, input, output: [ { - name: 'ConvertSDK', + name, exports: 'named', format: 'umd', - file: resolve(basePath, 'lib', 'index.umd.js'), + file: resolve(basePath, 'lib', `${fileName}.umd.js`), sourcemap: true }, { - name: 'ConvertSDK', + name, exports: 'named', format: 'umd', - file: resolve(basePath, 'lib', 'index.umd.min.js'), + file: resolve(basePath, 'lib', `${fileName}.umd.min.js`), plugins: [terser(terserConfig)], sourcemap: true } @@ -362,13 +421,18 @@ const replacePackages = (dir, file, packageName, contents) => { return contents; }; -const typeDeclarations = ({basePath, input, packageName, isMainPackage}) => ({ +const typeDeclarations = ({ + basePath, + input, + packageName, + fileName = 'index' +}) => ({ cache: BUILD_CACHE, input, output: [ { format: 'es', - file: resolve(basePath, 'lib', 'index.d.ts') + file: resolve(basePath, 'lib', `${fileName}.d.ts`) } ], external, @@ -418,27 +482,43 @@ const typeDeclarations = ({basePath, input, packageName, isMainPackage}) => ({ const BUNDLES = process.env.BUNDLES ? process.env.BUNDLES.split(',') - : ['cjs', 'cjs-legacy', 'esm', 'umd']; + : [ + 'cjs', + 'cjs-legacy', + 'esm', + 'umd' + ]; + +const STANDALONE_UMD_NAMES = { + 'visitor-entry': 'ConvertSDKVisitorEntry', + 'goals-entry': 'ConvertSDKGoalsEntry', + 'split-entry': 'ConvertSDKSplitEntry', + 'integrations-entry': 'ConvertSDKIntegrationsEntry' +}; + +const resolveBundleInput = (input, entryName) => { + if (typeof input === 'string') return input; + return input[entryName] || input.index; +}; export default async ({basePath, input, info, packageName}) => { const getVersion = (pkg) => JSON.parse( readFileSync( - resolve( - `${basePath}/../${pkg.replace('@convertcom/js-sdk-', '')}/package.json` - ), + resolve(`${basePath}/../${pkg.replace('@convertcom/js-sdk-', '')}/package.json`), 'utf-8' ) ); const peerDependencies = depsMap[packageName] - ? Object.fromEntries( - depsMap[packageName].map((dep) => [ - `@convertcom/js-sdk-${dep}`, - `>=${getVersion(dep).version}` - ]) - ) - : null, + ? Object.fromEntries( + depsMap[packageName].map((dep) => [ + `@convertcom/js-sdk-${dep}`, + `>=${getVersion(dep).version}` + ]) + ) + : null, isMainPackage = packageName === 'js-sdk'; + if (peerDependencies) { console.log('peerDependencies:', peerDependencies); writeFileSync( @@ -453,13 +533,15 @@ export default async ({basePath, input, info, packageName}) => { ) ); } + return BUNDLES.map((bundle) => { + const bundleInput = resolveBundleInput(input, bundle); switch (bundle) { case 'cjs': return [ commonJSBundle({ basePath, - input, + input: bundleInput, info, packageName, peerDependencies, @@ -467,17 +549,73 @@ export default async ({basePath, input, info, packageName}) => { }) ]; case 'cjs-legacy': - return [commonJSLegacyBundle({basePath, input, info, packageName})]; + return [ + commonJSLegacyBundle({ + basePath, + input: bundleInput, + info, + packageName + }) + ]; case 'esm': return [ - esmBundle({basePath, input, info, packageName}), + esmBundle({ + basePath, + input: bundleInput, + info, + packageName + }), ...(!['enums', 'types', 'utils'].includes(packageName) - ? [typeDeclarations({basePath, input, packageName, isMainPackage})] + ? [ + typeDeclarations({ + basePath, + input: bundleInput, + packageName + }) + ] : []) ]; case 'umd': - return isMainPackage ? [umdBundle({basePath, input, info})] : []; + return isMainPackage + ? [umdBundle({basePath, input: bundleInput, info})] + : []; + case 'visitor-entry': + case 'goals-entry': + case 'split-entry': + case 'integrations-entry': + return isMainPackage + ? [ + commonJSStandaloneBundle({ + basePath, + input: bundleInput, + info, + packageName, + fileName: bundle + }), + esmBundle({ + basePath, + input: bundleInput, + info, + packageName, + fileName: bundle + }), + umdBundle({ + basePath, + input: bundleInput, + info, + name: STANDALONE_UMD_NAMES[bundle], + fileName: bundle + }), + typeDeclarations({ + basePath, + input: bundleInput, + packageName, + fileName: bundle + }) + ] + : []; + default: + return []; } - return []; }).flat(); }; diff --git a/packages/js-sdk/src/standalone/goals-entry.ts b/packages/js-sdk/src/standalone/goals-entry.ts new file mode 100644 index 00000000..84fdc426 --- /dev/null +++ b/packages/js-sdk/src/standalone/goals-entry.ts @@ -0,0 +1,52 @@ +import {Goals} from './goals'; +import {GoalsRender} from './goals-render'; +import { + ensureConvertWindow, + initializeVisitorRuntime +} from './runtime'; + +const isRecord = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +const isGoalsLike = (value: unknown): value is Goals => + isRecord(value) && + typeof value.run === 'function' && + typeof value.enableGaInterception === 'function'; + +const isGoalsRenderLike = (value: unknown): value is GoalsRender => + isRecord(value) && + typeof value.prepareDOMGoalListeners === 'function' && + typeof value.prepareScrollGoalListener === 'function' && + typeof value.onLocationChange === 'function'; + +export const runGoalsEntry = async () => { + await initializeVisitorRuntime(); + + const convert = ensureConvertWindow(); + convert.Goals = Goals; + convert.GoalsRender = GoalsRender; + + convert.runGoals = () => { + const runtime = convert.remote; + if (!runtime) return null; + + if (!isGoalsRenderLike(convert.goalsRender)) { + convert.goalsRender = new GoalsRender({ + loggerManager: runtime.loggerManager + }); + } + if (!isGoalsLike(convert.goals)) { + convert.goals = new Goals({ + convert, + runtime, + render: convert.goalsRender + }); + } + + return convert.goals.run(); + }; + + return convert.runGoals; +}; + +void runGoalsEntry(); diff --git a/packages/js-sdk/src/standalone/goals-render.ts b/packages/js-sdk/src/standalone/goals-render.ts new file mode 100644 index 00000000..6e762aac --- /dev/null +++ b/packages/js-sdk/src/standalone/goals-render.ts @@ -0,0 +1,260 @@ +import {LogManagerInterface} from '@convertcom/js-sdk-logger'; + +const LOCATION_CHANGE_EVENT = 'convert:locationchange'; + +let historyPatched = false; + +const patchHistory = (): void => { + if (historyPatched || typeof window === 'undefined' || !window.history) return; + historyPatched = true; + + const dispatch = () => window.dispatchEvent(new Event(LOCATION_CHANGE_EVENT)); + const originalPushState = window.history.pushState.bind(window.history); + const originalReplaceState = window.history.replaceState.bind(window.history); + + window.history.pushState = function (...args: Parameters) { + const output = originalPushState(...args); + dispatch(); + return output; + }; + + window.history.replaceState = function ( + ...args: Parameters + ) { + const output = originalReplaceState(...args); + dispatch(); + return output; + }; + + window.addEventListener('popstate', dispatch); + window.addEventListener('hashchange', dispatch); +}; + +const throttle = >( + callback: (...args: TArgs) => void, + wait: number +) => { + let timeoutId: ReturnType | null = null; + let lastArgs: TArgs | null = null; + + return (...args: TArgs) => { + lastArgs = args; + if (timeoutId) return; + timeoutId = setTimeout(() => { + timeoutId = null; + if (lastArgs) callback(...lastArgs); + }, wait); + }; +}; + +type GoalListener = { + selector: string; + event: string; + goalId: string; + callback: (goalId: string) => void; +}; + +export class GoalsRender { + private readonly name = 'GoalsRender'; + + private _loggerManager?: LogManagerInterface; + private _goalQueue: Array = []; + private _activeGoalListeners = new Set(); + private _delegatedAbort = new AbortController(); + private _scrollAbort = new AbortController(); + private _mutationObserver: MutationObserver | null = null; + private _mutationTimer: ReturnType | null = null; + + constructor({loggerManager}: {loggerManager?: LogManagerInterface} = {}) { + this._loggerManager = loggerManager; + this.observeMutations(); + patchHistory(); + } + + onLocationChange(callback: () => void): void { + if (typeof window === 'undefined') return; + window.addEventListener(LOCATION_CHANGE_EVENT, callback, { + signal: this._delegatedAbort.signal + }); + } + + prepareDOMGoalListeners({ + selector, + event, + goalId, + callback + }: { + selector: string; + event: string; + goalId: string; + callback: (goalId: string) => void; + }): void { + this._loggerManager?.debug?.( + `${this.name}.prepareDOMGoalListeners()`, + `Queue Goal #${goalId} - selector "${selector}" - event "${event}"` + ); + this._goalQueue.push({selector, event, goalId, callback}); + setTimeout(() => this.processQueue(), 0); + } + + prepareScrollGoalListener({ + goals, + getPercentage, + callback + }: { + goals: Array; + getPercentage: (goalId: string) => number; + callback: ({goalId}: {goalId: Array}) => void; + }): void { + if (typeof document === 'undefined' || typeof window === 'undefined') return; + + this._scrollAbort.abort(); + this._scrollAbort = new AbortController(); + + const triggeredGoals = new Set(); + const onScroll = throttle(() => { + const denominator = document.body.scrollHeight - window.innerHeight; + const scrollPercentage = + denominator > 0 ? Math.ceil((window.scrollY / denominator) * 100) : 100; + const goalIds: Array = []; + + for (const goalId of Array.from(new Set(goals))) { + const percentage = Number(getPercentage(goalId) || 0); + if (scrollPercentage > percentage && !triggeredGoals.has(goalId)) { + triggeredGoals.add(goalId); + goalIds.push(goalId); + } + } + + if (goalIds.length) callback({goalId: goalIds}); + }, 200); + + document.addEventListener('scroll', onScroll, { + passive: true, + signal: this._scrollAbort.signal + }); + onScroll(); + } + + processQueue(): void { + if (typeof document === 'undefined') return; + + for (const item of this._goalQueue.splice(0)) { + const {selector, event, goalId, callback} = item; + const key = `${selector}:${event}:${goalId}`; + if (this._activeGoalListeners.has(key)) continue; + + try { + document.querySelector(selector); + } catch (error) { + this._loggerManager?.warn?.( + `${this.name}.processQueue()`, + `Invalid selector "${selector}": ${ + (error as Error)?.message || String(error) + }` + ); + continue; + } + + const delegatedCallback = () => callback(goalId); + const listener = (domEvent: Event) => + this.delegateEventListenerCallback({ + selector, + event: domEvent, + callback: delegatedCallback + }); + + document.addEventListener(event, listener, { + capture: event === 'submit', + signal: this._delegatedAbort.signal + }); + this._activeGoalListeners.add(key); + + this._loggerManager?.debug?.( + `${this.name}.processQueue()`, + `Goal #${goalId} - listening for "${event}" on "${selector}"` + ); + } + } + + destroy(): void { + this._delegatedAbort.abort(); + this._scrollAbort.abort(); + if (this._mutationObserver) this._mutationObserver.disconnect(); + if (this._mutationTimer) clearTimeout(this._mutationTimer); + this._goalQueue = []; + this._activeGoalListeners.clear(); + } + + private observeMutations(): void { + if ( + this._mutationObserver || + typeof MutationObserver === 'undefined' || + typeof document === 'undefined' + ) { + return; + } + + const target = document.documentElement || document.body; + if (!target) return; + + this._mutationObserver = new MutationObserver(() => { + if (this._mutationTimer) clearTimeout(this._mutationTimer); + this._mutationTimer = setTimeout(() => this.processQueue(), 0); + }); + this._mutationObserver.observe(target, { + childList: true, + subtree: true + }); + } + + private delegateEventListenerCallback({ + selector, + event, + callback + }: { + selector: string; + event: Event; + callback: () => void; + }): void { + let target = event.target as HTMLElement | null; + if (target?.nodeType === Node.TEXT_NODE) { + target = target.parentElement; + } + + const path: Array = + typeof event.composedPath === 'function' + ? event.composedPath() + : this.getFallbackEventPath(target); + + for (const current of path) { + if (!(current instanceof HTMLElement)) continue; + + if (typeof current.matches === 'function' && current.matches(selector)) { + callback(); + return; + } + + if (current.shadowRoot) { + try { + if (current.shadowRoot.querySelector(selector)) { + callback(); + return; + } + } catch { + return; + } + } + } + } + + private getFallbackEventPath(target: HTMLElement | null): Array { + const path: Array = []; + let current = target; + while (current) { + path.push(current); + current = current.parentElement; + } + return path; + } +} diff --git a/packages/js-sdk/src/standalone/goals.ts b/packages/js-sdk/src/standalone/goals.ts new file mode 100644 index 00000000..ae96811d --- /dev/null +++ b/packages/js-sdk/src/standalone/goals.ts @@ -0,0 +1,555 @@ +import { + ConfigExperience, + ConfigGoal, + ConversionAttributes, + GoalData +} from '@convertcom/js-sdk-types'; +import { + ConversionSettingKey, + GoalDataKey +} from '@convertcom/js-sdk-enums'; +import {ConvertStandaloneRuntime} from './runtime'; +import {GoalsRender} from './goals-render'; + +const isRecord = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +const isGoalArray = (value: unknown): value is Array => + Array.isArray(value) && value.every((item) => typeof item === 'string'); + +const toNumber = (value: unknown): number | undefined => { + if (typeof value === 'number' && !Number.isNaN(value)) return value; + if (typeof value === 'string' && value.trim().length) { + const parsed = Number(value); + if (!Number.isNaN(parsed)) return parsed; + } + return undefined; +}; + +const getSelectorBy = ({ + action, + href +}: { + action?: string; + href?: string; +}): string | undefined => { + if (action) return `form[action="${action.replace(/"/g, '\\"')}"]`; + if (href) return `a[href*="${href.replace(/"/g, '\\"')}"]`; + return undefined; +}; + +const isGoalsRenderLike = (value: unknown): value is GoalsRender => + isRecord(value) && + typeof value.prepareDOMGoalListeners === 'function' && + typeof value.prepareScrollGoalListener === 'function' && + typeof value.onLocationChange === 'function'; + +export class Goals { + private readonly name = 'Goals'; + + private _convert: Record; + private _runtime: ConvertStandaloneRuntime; + private _render: GoalsRender; + private _gaEventGoals = new Map>(); + private _revenueGoalKeys = new Set(); + private _gaInterceptionEnabled = false; + private _queueInstalled = false; + private _locationListenerInstalled = false; + + constructor({ + convert, + runtime, + render + }: { + convert: Record; + runtime: ConvertStandaloneRuntime; + render?: GoalsRender; + }) { + this._convert = convert; + this._runtime = runtime; + this._render = isGoalsRenderLike(render) + ? render + : isGoalsRenderLike(this._convert.goalsRender) + ? this._convert.goalsRender + : new GoalsRender({loggerManager: this._runtime.loggerManager}); + + this._convert.goalsRender = this._render; + this.installApiSurface(); + } + + run(): Goals { + this.installQueue(); + if (!this._locationListenerInstalled) { + this._render.onLocationChange(() => this.recheckGoals()); + this._locationListenerInstalled = true; + } + this.process(); + this.enableGaInterception(); + return this; + } + + process(): void { + const goals = this.getGoals(); + const activeGoalIds = this.getActiveExperienceGoalIds(); + const scrollGoals = new Map(); + + this._gaEventGoals.clear(); + this._revenueGoalKeys.clear(); + + for (const goal of goals) { + const goalId = String(goal?.id || ''); + const goalKey = String(goal?.key || goalId); + if (!goalId || !goalKey) continue; + if (!this.isGoalActive(goal, activeGoalIds)) continue; + + const type = String((goal as Record)?.type || ''); + const settings = ((goal as Record)?.settings || {}) as Record< + string, + any + >; + + if (type === 'dom_interaction' && Array.isArray(settings.tracked_items)) { + for (const trackedItem of settings.tracked_items) { + if (!trackedItem?.selector || !trackedItem?.event) continue; + this._render.prepareDOMGoalListeners({ + selector: String(trackedItem.selector), + event: String(trackedItem.event), + goalId: goalKey, + callback: (resolvedGoalId) => { + this.triggerConversion({goalId: resolvedGoalId}); + } + }); + } + continue; + } + + if (type === 'clicks_element') { + if (!settings.selector) continue; + this._render.prepareDOMGoalListeners({ + selector: String(settings.selector), + event: 'click', + goalId: goalKey, + callback: (resolvedGoalId) => { + this.triggerConversion({goalId: resolvedGoalId}); + } + }); + continue; + } + + if (type === 'clicks_link' || type === 'submits_form') { + const selector = getSelectorBy({ + action: settings.action, + href: settings.href + }); + if (!selector) continue; + this._render.prepareDOMGoalListeners({ + selector, + event: type === 'submits_form' ? 'submit' : 'click', + goalId: goalKey, + callback: (resolvedGoalId) => { + this.triggerConversion({goalId: resolvedGoalId}); + } + }); + continue; + } + + if (type === 'scroll_percentage') { + scrollGoals.set(goalKey, Number(settings.percentage || 0)); + continue; + } + + if (type === 'ga_import' && settings.ga_event) { + this.registerGaEventGoal(String(settings.ga_event), goalKey); + continue; + } + + if (type === 'revenue' && String(settings.triggering_type) === 'ga') { + this._revenueGoalKeys.add(goalKey); + } + } + + if (scrollGoals.size) { + this._render.prepareScrollGoalListener({ + goals: Array.from(scrollGoals.keys()), + getPercentage: (goalId) => scrollGoals.get(goalId) || 0, + callback: ({goalId}) => { + if (isGoalArray(goalId) && goalId.length) { + this.triggerConversions({goalIds: goalId}); + } + } + }); + } + } + + triggerConversion(...args: any[]): boolean { + const params = isRecord(args[0]) ? args[0] : {goalId: args[0]}; + const goalRef = params.goalKey || params.goalId; + if (Array.isArray(goalRef)) { + return goalRef + .map((item) => this.triggerConversion({...params, goalId: item})) + .some(Boolean); + } + return this.trackGoal(goalRef, { + ruleData: params.ruleData, + conversionData: params.conversionData, + conversionSetting: params.conversionSetting + }); + } + + triggerConversions(...args: any[]): boolean { + const params = isRecord(args[0]) ? args[0] : {goalIds: args[0]}; + const goalRefs = params.goalIds || params.goalKeys || params.goalId; + if (!Array.isArray(goalRefs)) { + return this.triggerConversion(params); + } + return goalRefs + .map((goalRef) => + this.trackGoal(goalRef, { + ruleData: params.ruleData, + conversionData: params.conversionData, + conversionSetting: params.conversionSetting + }) + ) + .some(Boolean); + } + + sendRevenue(...args: any[]): boolean { + if (isRecord(args[0])) { + return this.trackRevenue(args[0]); + } + return this.trackRevenue({ + transactionId: args[0], + amount: args[1], + productsCount: args[2], + goalId: args[3], + forceMultiple: args[4] + }); + } + + pushRevenue(...args: any[]): boolean { + if (isRecord(args[0])) { + return this.trackRevenue(args[0]); + } + return this.trackRevenue({ + amount: args[0], + productsCount: args[1], + goalId: args[2], + forceMultiple: args[3], + transactionId: args[4] + }); + } + + recheckGoals(): boolean { + this.process(); + return true; + } + + enableGaInterception(): void { + if (this._gaInterceptionEnabled || typeof window === 'undefined') return; + this._gaInterceptionEnabled = true; + + const dataLayerName = this.getDataLayerName(); + const dataLayer = ((window as Record)[dataLayerName] ||= []); + if (Array.isArray(dataLayer)) { + for (const payload of dataLayer) this.captureGaPayload(payload); + const originalPush = dataLayer.push.bind(dataLayer); + dataLayer.push = (...items: Array) => { + for (const payload of items) this.captureGaPayload(payload); + return originalPush(...items); + }; + } + + const gaq = (((window as Record)._gaq ||= []) as Array); + if (Array.isArray(gaq)) { + for (const payload of gaq) this.captureGaPayload(payload); + const originalPush = gaq.push.bind(gaq); + gaq.push = (...items: Array) => { + for (const payload of items) this.captureGaPayload(payload); + return originalPush(...items); + }; + } + + const ga = (window as Record).ga; + if (typeof ga === 'function' && Array.isArray((ga as Record).q)) { + const queue = (ga as Record).q as Array; + for (const payload of queue) this.captureGaPayload(payload); + const originalPush = queue.push.bind(queue); + queue.push = (...items: Array) => { + for (const payload of items) this.captureGaPayload(payload); + return originalPush(...items); + }; + } + } + + private installApiSurface(): void { + this._convert.triggerConversion = (...args: any[]) => + this.triggerConversion(...args); + this._convert.triggerConversions = (...args: any[]) => + this.triggerConversions(...args); + this._convert.sendRevenue = (...args: any[]) => this.sendRevenue(...args); + this._convert.pushRevenue = (...args: any[]) => this.pushRevenue(...args); + this._convert.recheckGoals = () => this.recheckGoals(); + this._convert.recheck_goals = () => this.recheckGoals(); + } + + private installQueue(): void { + if (this._queueInstalled || typeof window === 'undefined') return; + this._queueInstalled = true; + + const processItem = (item: any) => { + if (!isRecord(item)) return; + const what = String(item.what || ''); + const params = isRecord(item.params) ? item.params : {}; + switch (what) { + case 'triggerConversion': + this.triggerConversion(params); + break; + case 'triggerConversions': + this.triggerConversions(params); + break; + case 'sendRevenue': + this.sendRevenue(params); + break; + case 'pushRevenue': + this.pushRevenue(params); + break; + case 'recheckGoals': + case 'recheck_goals': + this.recheckGoals(); + break; + default: + break; + } + }; + + const existingQueue = Array.isArray((window as Record)._conv_q) + ? ((window as Record)._conv_q as Array).slice() + : []; + const queue = Array.isArray((window as Record)._conv_q) + ? ((window as Record)._conv_q as Array) + : []; + const originalPush = Array.prototype.push; + + queue.push = (...items: Array) => { + const size = originalPush.apply(queue, items); + for (const item of items) processItem(item); + return size; + }; + + (window as Record)._conv_q = queue; + this._convert._conv_q = queue; + + for (const item of existingQueue) processItem(item); + } + + private trackGoal( + goalRef: string, + attributes?: ConversionAttributes + ): boolean { + const goalKey = this.resolveGoalKey(goalRef); + const context = this._convert.visitor?.context; + if (!goalKey || !context) return false; + context.trackConversion(goalKey, attributes); + return true; + } + + private trackRevenue(params: Record): boolean { + const goalRefs = params.goalId + ? [params.goalId] + : Array.from(this._revenueGoalKeys.values()); + if (!goalRefs.length) return false; + + const conversionData = this.buildRevenueConversionData(params); + const conversionSetting = params.forceMultiple + ? { + [ConversionSettingKey.FORCE_MULTIPLE_TRANSACTIONS]: true + } + : undefined; + + return goalRefs + .map((goalRef) => + this.trackGoal(goalRef, { + conversionData, + conversionSetting + }) + ) + .some(Boolean); + } + + private buildRevenueConversionData(params: Record): Array { + const conversionData = Array.isArray(params.conversionData) + ? [...params.conversionData] + : []; + const amount = toNumber(params.amount ?? params.value ?? params.revenue); + const productsCount = toNumber( + params.productsCount ?? params.products_count ?? params.quantity + ); + const transactionId = params.transactionId || params.transaction_id || params.id; + + if (amount !== undefined) { + conversionData.push({key: GoalDataKey.AMOUNT, value: amount}); + } + if (productsCount !== undefined) { + conversionData.push({ + key: GoalDataKey.PRODUCTS_COUNT, + value: productsCount + }); + } + if (transactionId !== undefined) { + conversionData.push({ + key: GoalDataKey.TRANSACTION_ID, + value: String(transactionId) + }); + } + + return conversionData; + } + + private registerGaEventGoal(eventName: string, goalKey: string): void { + if (!this._gaEventGoals.has(eventName)) { + this._gaEventGoals.set(eventName, new Set()); + } + this._gaEventGoals.get(eventName)?.add(goalKey); + } + + private captureGaPayload(payload: any): void { + const captured = this.normalizeGaPayload(payload); + if (!captured?.eventName) return; + + const mappedGoals = Array.from( + this._gaEventGoals.get(captured.eventName)?.values() || [] + ); + if (mappedGoals.length) { + this.triggerConversions({goalIds: mappedGoals}); + } + + if (captured.eventName === 'purchase' && this._revenueGoalKeys.size) { + const revenueParams = this.extractRevenueParams(captured.params); + for (const goalKey of this._revenueGoalKeys) { + this.trackRevenue({goalId: goalKey, ...revenueParams}); + } + } + } + + private normalizeGaPayload( + payload: any + ): {eventName?: string; params?: Record} | null { + if (Array.isArray(payload)) { + if (payload[0] === 'event' && typeof payload[1] === 'string') { + return { + eventName: payload[1], + params: isRecord(payload[2]) ? payload[2] : {} + }; + } + if (payload[0] === '_trackEvent') { + return { + eventName: String(payload[2] || payload[1] || ''), + params: {} + }; + } + if (typeof payload[0] === 'string' && payload[0]) { + return { + eventName: payload[0], + params: isRecord(payload[1]) ? payload[1] : {} + }; + } + return null; + } + + if (isRecord(payload) && typeof payload.event === 'string') { + return { + eventName: payload.event, + params: payload + }; + } + + return null; + } + + private extractRevenueParams(params: Record = {}): Record { + const ecommerce = isRecord(params.ecommerce) ? params.ecommerce : {}; + const purchase = isRecord(ecommerce.purchase) ? ecommerce.purchase : {}; + const actionField = isRecord(purchase.actionField) + ? purchase.actionField + : {}; + const items = Array.isArray(params.items) + ? params.items + : Array.isArray(ecommerce.items) + ? ecommerce.items + : Array.isArray(purchase.products) + ? purchase.products + : []; + + return { + transactionId: + params.transaction_id || + params.transactionId || + ecommerce.transaction_id || + actionField.id, + amount: + params.value || + params.revenue || + ecommerce.value || + actionField.revenue, + productsCount: + params.productsCount || + params.quantity || + ecommerce.quantity || + items.length + }; + } + + private getGoals(): Array { + return (this._runtime.dataManager.getEntitiesList('goals') || []) as Array; + } + + private getActiveExperienceGoalIds(): Set { + const context = this._convert.visitor?.context; + if (!context) return new Set(); + + const storeData = context.getVisitorData(); + const bucketing = isRecord(storeData?.bucketing) ? storeData.bucketing : {}; + const activeGoalIds = new Set(); + + for (const experienceId of Object.keys(bucketing)) { + const experience = this._runtime.dataManager.getEntityById( + experienceId, + 'experiences' + ) as ConfigExperience; + for (const goalId of experience?.goals || []) { + activeGoalIds.add(String(goalId)); + } + } + + return activeGoalIds; + } + + private isGoalActive(goal: ConfigGoal, activeGoalIds: Set): boolean { + if (!activeGoalIds.size) return false; + const goalId = String(goal?.id || ''); + const goalKey = String(goal?.key || ''); + return activeGoalIds.has(goalId) || activeGoalIds.has(goalKey); + } + + private resolveGoalKey(goalRef: string): string | null { + if (!goalRef) return null; + const byId = this._runtime.dataManager.getEntityById(goalRef, 'goals') as + | ConfigGoal + | null; + if (byId?.key) return byId.key; + const byKey = this._runtime.dataManager.getEntity(goalRef, 'goals') as + | ConfigGoal + | null; + if (byKey?.key) return byKey.key; + return goalRef; + } + + private getDataLayerName(): string { + const integrationVariables = isRecord(this._convert.integrationVariables) + ? this._convert.integrationVariables + : {}; + return typeof integrationVariables.googleAnalytics === 'string' + ? integrationVariables.googleAnalytics + : 'dataLayer'; + } +} diff --git a/packages/js-sdk/src/standalone/integrations-entry.ts b/packages/js-sdk/src/standalone/integrations-entry.ts new file mode 100644 index 00000000..54b63f7b --- /dev/null +++ b/packages/js-sdk/src/standalone/integrations-entry.ts @@ -0,0 +1,65 @@ +import {Goals} from './goals'; +import {GoalsRender} from './goals-render'; +import {IntegrationsProcessor} from './integrations'; +import { + ensureConvertWindow, + initializeVisitorRuntime +} from './runtime'; + +const isRecord = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +const isGoalsLike = (value: unknown): value is Goals => + isRecord(value) && + typeof value.run === 'function' && + typeof value.enableGaInterception === 'function'; + +const isGoalsRenderLike = (value: unknown): value is GoalsRender => + isRecord(value) && + typeof value.prepareDOMGoalListeners === 'function' && + typeof value.prepareScrollGoalListener === 'function' && + typeof value.onLocationChange === 'function'; + +const isIntegrationsProcessorLike = ( + value: unknown +): value is IntegrationsProcessor => + isRecord(value) && typeof value.run === 'function'; + +export const runIntegrationsEntry = async () => { + await initializeVisitorRuntime(); + + const convert = ensureConvertWindow(); + convert.Goals = Goals; + convert.GoalsRender = GoalsRender; + convert.IntegrationsProcessor = IntegrationsProcessor; + + convert.runIntegrations = () => { + const runtime = convert.remote; + if (!runtime) return null; + + if (!isGoalsRenderLike(convert.goalsRender)) { + convert.goalsRender = new GoalsRender({ + loggerManager: runtime.loggerManager + }); + } + if (!isGoalsLike(convert.goals)) { + convert.goals = new Goals({ + convert, + runtime, + render: convert.goalsRender + }); + } + if (!isIntegrationsProcessorLike(convert.integrationsProcessor)) { + convert.integrationsProcessor = new IntegrationsProcessor({ + convert, + runtime + }); + } + + return convert.integrationsProcessor.run(); + }; + + return convert.runIntegrations; +}; + +void runIntegrationsEntry(); diff --git a/packages/js-sdk/src/standalone/integrations.ts b/packages/js-sdk/src/standalone/integrations.ts new file mode 100644 index 00000000..b8635a37 --- /dev/null +++ b/packages/js-sdk/src/standalone/integrations.ts @@ -0,0 +1,121 @@ +import {ConfigExperience} from '@convertcom/js-sdk-types'; +import {ConvertStandaloneRuntime} from './runtime'; +import {Goals} from './goals'; + +const isRecord = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +const isGoalsLike = (value: unknown): value is Goals => + isRecord(value) && + typeof value.run === 'function' && + typeof value.enableGaInterception === 'function'; + +export class IntegrationsProcessor { + private readonly name = 'Integrations'; + + private _convert: Record; + private _runtime: ConvertStandaloneRuntime; + private _integrations: Record< + string, + {name: string; enabled: boolean; process: () => void} + > = {}; + + constructor({ + convert, + runtime + }: { + convert: Record; + runtime: ConvertStandaloneRuntime; + }) { + this._convert = convert; + this._runtime = runtime; + this._integrations = this.buildIntegrations(); + } + + run(): IntegrationsProcessor { + const goals = this.ensureGoals(); + goals.enableGaInterception(); + + for (const integration of Object.values(this._integrations)) { + integration.process(); + } + + this._convert.integrations = this._integrations; + return this; + } + + get integrations(): Record void}> { + return this._integrations; + } + + private ensureGoals(): Goals { + if (!isGoalsLike(this._convert.goals)) { + this._convert.goals = new Goals({ + convert: this._convert, + runtime: this._runtime + }); + } + return this._convert.goals as Goals; + } + + private buildIntegrations(): Record< + string, + {name: string; enabled: boolean; process: () => void} + > { + const integrations: Record< + string, + {name: string; enabled: boolean; process: () => void} + > = { + google_analytics: { + name: 'google_analytics', + enabled: true, + process: () => this.ensureGoals().enableGaInterception() + }, + google_tag_manager: { + name: 'google_tag_manager', + enabled: true, + process: () => this.ensureGoals().enableGaInterception() + } + }; + + const experiences = (this._runtime.dataManager.getEntitiesList('experiences') || []) as Array; + for (const experience of experiences) { + const experienceIntegrations = isRecord(experience?.integrations) + ? experience.integrations + : {}; + for (const key of Object.keys(experienceIntegrations)) { + if (!integrations[key]) { + integrations[key] = { + name: key, + enabled: true, + process: () => undefined + }; + } + } + } + + const projectIntegrations = isRecord( + (this._runtime.dataManager.data as Record)?.project?.settings + ?.integrations + ) + ? ((this._runtime.dataManager.data as Record).project.settings + .integrations as Record) + : {}; + for (const key of Object.keys(projectIntegrations)) { + if (!integrations[key]) { + integrations[key] = { + name: key, + enabled: true, + process: () => undefined + }; + } + } + + this._runtime.loggerManager?.debug?.( + `${this.name}.buildIntegrations()`, + Object.keys(integrations) + ); + + return integrations; + } +} diff --git a/packages/js-sdk/src/standalone/runtime.ts b/packages/js-sdk/src/standalone/runtime.ts new file mode 100644 index 00000000..90aae16a --- /dev/null +++ b/packages/js-sdk/src/standalone/runtime.ts @@ -0,0 +1,248 @@ +import {ApiManager} from '@convertcom/js-sdk-api'; +import {BucketingManager} from '@convertcom/js-sdk-bucketing'; +import {DataManager} from '@convertcom/js-sdk-data'; +import {EventManager} from '@convertcom/js-sdk-event'; +import {ExperienceManager} from '@convertcom/js-sdk-experience'; +import {LogManager} from '@convertcom/js-sdk-logger'; +import {RuleManager} from '@convertcom/js-sdk-rules'; +import {SegmentsManager} from '@convertcom/js-sdk-segments'; +import {Config as ConvertConfig, VisitorSegments} from '@convertcom/js-sdk-types'; +import {ERROR_MESSAGES} from '@convertcom/js-sdk-enums'; +import {Config} from '../config'; +import {Core} from '../core'; +import {FeatureManager} from '../feature-manager'; + +export type ConvertStandaloneRuntime = { + sdk: Core; + config: ConvertConfig; + apiManager: ApiManager; + bucketingManager: BucketingManager; + dataManager: DataManager; + eventManager: EventManager; + experienceManager: ExperienceManager; + featureManager: FeatureManager; + loggerManager: LogManager; + ruleManager: RuleManager; + segmentsManager: SegmentsManager; + ready: Promise; +}; + +type ConvertWindowScope = Window & + typeof globalThis & { + convert?: Record; + }; + +const getWindowScope = (): ConvertWindowScope | null => + typeof window === 'undefined' ? null : (window as ConvertWindowScope); + +const isRecord = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +const getBootstrap = (convert: Record): Record => + isRecord(convert.bootstrap) ? convert.bootstrap : {}; + +const getRandomVisitorId = (): string => { + const scope = globalThis as Record; + if (typeof scope?.crypto?.randomUUID === 'function') { + return scope.crypto.randomUUID(); + } + return `visitor-${Date.now()}-${Math.random().toString(16).slice(2)}`; +}; + +const getRuntimeConfig = (convert: Record): ConvertConfig => + (getBootstrap(convert).config || convert.config || {}) as ConvertConfig; + +const getVisitorAttributes = (convert: Record): Record => + isRecord(getBootstrap(convert).visitorAttributes) + ? getBootstrap(convert).visitorAttributes + : isRecord(convert.visitor?.attributes) + ? convert.visitor.attributes + : isRecord(convert.visitorAttributes) + ? convert.visitorAttributes + : {}; + +const getRuleData = ( + convert: Record, + runtime: ConvertStandaloneRuntime +): Record => + (getBootstrap(convert).ruleData || + getBootstrap(convert).locationProperties || + convert.ruleData || + convert.locationProperties || + runtime.config?.ruleDataProvider || + {}) as Record; + +const getDefaultSegments = ( + convert: Record +): VisitorSegments | null => + (getBootstrap(convert).defaultSegments || convert.defaultSegments || null) as + | VisitorSegments + | null; + +const getVisitorId = (convert: Record): string => + String( + getBootstrap(convert).visitorId || + convert.visitor?.id || + convert.visitorId || + getRandomVisitorId() + ); + +export const ensureConvertWindow = (): Record => { + const scope = getWindowScope(); + if (!scope) return {}; + scope.convert = scope.convert || {}; + return scope.convert; +}; + +export const createConvertRuntime = ( + rawConfig: ConvertConfig = {} as ConvertConfig +): ConvertStandaloneRuntime => { + const isValidSDKKey = Boolean( + Object.prototype.hasOwnProperty.call(rawConfig, 'sdkKey') && + rawConfig.sdkKey?.length + ); + const isValidData = Boolean( + Object.prototype.hasOwnProperty.call(rawConfig, 'data') + ); + if (!isValidSDKKey && !isValidData) { + console.error(ERROR_MESSAGES.SDK_OR_DATA_OBJECT_REQUIRED); + } + + const configuration = Config(rawConfig); + if (!configuration?.network) configuration.network = {}; + if (!configuration.network?.source) { + configuration.network.source = + typeof process.env.VERSION === 'string' + ? process.env.VERSION + : 'js-sdk'; + } + + const loggerManager = new LogManager(console, configuration.logger.logLevel); + for (const key in configuration.logger.customLoggers) { + if ( + Object.prototype.hasOwnProperty.call( + configuration.logger.customLoggers[key], + 'logger' + ) && + Object.prototype.hasOwnProperty.call( + configuration.logger.customLoggers[key], + 'logLevel' + ) + ) { + loggerManager.addClient( + configuration.logger.customLoggers[key].logger, + configuration.logger.customLoggers[key].logLevel, + configuration.logger.customLoggers[key]?.methodsMap + ); + } else { + loggerManager.addClient( + configuration.logger.customLoggers[key], + configuration.logger.logLevel + ); + } + } + + const eventManager = new EventManager(configuration, {loggerManager}); + const apiManager = new ApiManager(configuration, {eventManager, loggerManager}); + const bucketingManager = new BucketingManager(configuration, {loggerManager}); + const ruleManager = new RuleManager(configuration, {loggerManager}); + const dataManager = new DataManager(configuration, { + bucketingManager, + ruleManager, + eventManager, + apiManager, + loggerManager + }); + const experienceManager = new ExperienceManager(configuration, {dataManager}); + const featureManager = new FeatureManager(configuration, { + dataManager, + loggerManager + }); + const segmentsManager = new SegmentsManager(configuration, { + dataManager, + ruleManager, + loggerManager + }); + const sdk = new Core(configuration, { + dataManager, + eventManager, + experienceManager, + featureManager, + segmentsManager, + apiManager, + loggerManager + }); + + return { + sdk, + config: configuration, + apiManager, + bucketingManager, + dataManager, + eventManager, + experienceManager, + featureManager, + loggerManager, + ruleManager, + segmentsManager, + ready: sdk.onReady().catch(() => undefined) + }; +}; + +export const initializeVisitorRuntime = async (): Promise => { + const scope = getWindowScope(); + if (!scope) return null; + + const convert = ensureConvertWindow(); + const runtime = (isRecord(convert.remote) && + convert.remote.sdk && + convert.remote.dataManager + ? convert.remote + : createConvertRuntime(getRuntimeConfig(convert))) as ConvertStandaloneRuntime; + + convert.remote = runtime; + convert.request = runtime.apiManager; + convert.segments = runtime.segmentsManager; + convert.dataStore = runtime.dataManager.dataStoreManager; + convert.config = runtime.config; + convert.ready = runtime.ready; + + await runtime.ready; + + convert.data = runtime.dataManager.data; + convert.ruleData = getRuleData(convert, runtime); + + const visitorId = getVisitorId(convert); + const visitorAttributes = getVisitorAttributes(convert); + const context = runtime.sdk.createContext( + visitorId, + Object.keys(visitorAttributes).length ? visitorAttributes : undefined + ); + + convert.visitor = { + ...(isRecord(convert.visitor) ? convert.visitor : {}), + id: visitorId, + attributes: visitorAttributes, + context + }; + + const defaultSegments = getDefaultSegments(convert); + if (context && defaultSegments) { + context.setDefaultSegments(defaultSegments); + } + + const locations = runtime.dataManager.getEntitiesList('locations') as Array< + Record + >; + if (context && Array.isArray(locations) && locations.length) { + runtime.dataManager.selectLocations(visitorId, locations, { + locationProperties: convert.ruleData, + identityField: 'id' + }); + convert.activeLocations = context.getVisitorData()?.locations || []; + } else if (!Array.isArray(convert.activeLocations)) { + convert.activeLocations = []; + } + + return runtime; +}; diff --git a/packages/js-sdk/src/standalone/split-entry.ts b/packages/js-sdk/src/standalone/split-entry.ts new file mode 100644 index 00000000..49c77162 --- /dev/null +++ b/packages/js-sdk/src/standalone/split-entry.ts @@ -0,0 +1,244 @@ +import {VariationChangeType} from '@convertcom/js-sdk-enums'; +import { + BucketedVariation, + ConfigExperience +} from '@convertcom/js-sdk-types'; +import { + ConvertStandaloneRuntime, + ensureConvertWindow, + initializeVisitorRuntime +} from './runtime'; + +const SPLIT_TEST_COOKIE = '_conv_sptest'; + +type SplitCookieState = { + destinationUrl: string; + experienceId?: string; + experienceKey: string; + fromHash: string; + timestamp: number; + toHash: string; + variationId?: string; + variationKey?: string; +}; + +const hashLocation = (value: string): string => { + let hash = 5381; + for (let i = 0; i < value.length; i++) { + hash = (hash * 33) ^ value.charCodeAt(i); + } + return (hash >>> 0).toString(16); +}; + +const readSplitCookie = (): SplitCookieState | null => { + if (typeof document === 'undefined') return null; + const cookie = document.cookie + .split('; ') + .find((item) => item.startsWith(`${SPLIT_TEST_COOKIE}=`)); + if (!cookie) return null; + try { + return JSON.parse(decodeURIComponent(cookie.split('=').slice(1).join('='))); + } catch { + return null; + } +}; + +const writeSplitCookie = (payload: SplitCookieState): void => { + if (typeof document === 'undefined') return; + document.cookie = `${SPLIT_TEST_COOKIE}=${encodeURIComponent( + JSON.stringify(payload) + )}; path=/; SameSite=Lax`; +}; + +const clearSplitCookie = (): void => { + if (typeof document === 'undefined') return; + document.cookie = `${SPLIT_TEST_COOKIE}=; Max-Age=0; path=/; SameSite=Lax`; +}; + +const isBucketedVariation = (value: unknown): value is BucketedVariation => + !!value && typeof value === 'object' && 'experienceKey' in (value as object); + +const normalizeLocationIds = (value: unknown): Array => + Array.isArray(value) ? value.map((item) => String(item)) : []; + +const getSplitExperiences = ( + runtime: ConvertStandaloneRuntime, + activeLocations: Array +): Array => + runtime.experienceManager + .getList() + .filter(({type}) => type === 'split_url') + .filter( + (experience) => + !Array.isArray(experience.locations) || + !experience.locations.length || + experience.locations.some((locationId) => + activeLocations.includes(String(locationId)) + ) + ); + +const getRedirectUrl = ( + currentUrl: string, + experience: ConfigExperience, + variation: BucketedVariation +): string | null => { + const redirectChange = variation.changes?.find( + ({type}) => type === VariationChangeType.DEFAULT_REDIRECT + ); + const redirectData = redirectChange?.data as Record; + if (!redirectData?.variation_pattern) return null; + + const originalPattern = String(redirectData.original_pattern || ''); + const variationPattern = String(redirectData.variation_pattern || ''); + const caseSensitive = Boolean(redirectData.case_sensitive); + const regexSupport = Boolean( + experience?.settings?.split_url_settings?.split_regex_support + ); + + let nextUrl = variationPattern; + if (originalPattern) { + if (regexSupport) { + const flags = caseSensitive ? '' : 'i'; + const pattern = new RegExp(originalPattern, flags); + if (!pattern.test(currentUrl)) return null; + nextUrl = currentUrl.replace(pattern, variationPattern); + } else if (caseSensitive) { + if (!currentUrl.includes(originalPattern)) return null; + nextUrl = currentUrl.replace(originalPattern, variationPattern); + } else { + const loweredUrl = currentUrl.toLowerCase(); + const loweredPattern = originalPattern.toLowerCase(); + const index = loweredUrl.indexOf(loweredPattern); + if (index === -1) return null; + nextUrl = + currentUrl.slice(0, index) + + variationPattern + + currentUrl.slice(index + originalPattern.length); + } + } + + try { + return new URL(nextUrl, currentUrl).toString(); + } catch { + return nextUrl || null; + } +}; + +const registerHelpers = (convert: Record): void => { + convert.splitTests = convert.splitTests || {}; + convert.redirect = + convert.redirect || + ((url: string, replace = true) => { + if (typeof window === 'undefined' || !url) return; + if (replace) { + window.location.replace(url); + } else { + window.location.assign(url); + } + }); + convert.refresh = + convert.refresh || + (() => { + if (typeof window === 'undefined') return; + window.location.reload(); + }); +}; + +const processDestinationFlow = (convert: Record): boolean => { + const payload = readSplitCookie(); + if (!payload || typeof window === 'undefined') return false; + + clearSplitCookie(); + + const currentHash = hashLocation(window.location.href); + if ( + !payload.fromHash || + !payload.toHash || + payload.fromHash === currentHash || + payload.toHash !== currentHash + ) { + return false; + } + + convert.splitTests[payload.experienceKey] = payload; + return true; +}; + +const processOriginFlow = ( + convert: Record, + runtime: ConvertStandaloneRuntime +): void => { + if (typeof window === 'undefined') return; + + const visitorId = convert.visitor?.id; + if (!visitorId) return; + + const activeLocations = normalizeLocationIds(convert.activeLocations); + const visitorAttributes = + convert.visitor?.attributes && + typeof convert.visitor.attributes === 'object' && + !Array.isArray(convert.visitor.attributes) + ? convert.visitor.attributes + : {}; + const currentUrl = window.location.href; + const currentHash = hashLocation(currentUrl); + + for (const experience of getSplitExperiences(runtime, activeLocations)) { + if (!experience?.key) continue; + + const bucketedVariation = runtime.experienceManager.selectVariation( + visitorId, + experience.key, + { + locationProperties: convert.ruleData, + visitorProperties: visitorAttributes, + enableTracking: false + } + ); + + if (!isBucketedVariation(bucketedVariation)) continue; + + const destinationUrl = getRedirectUrl( + currentUrl, + experience, + bucketedVariation + ); + if (!destinationUrl) continue; + + const destinationHash = hashLocation(destinationUrl); + if (destinationHash === currentHash) continue; + + const splitState: SplitCookieState = { + destinationUrl, + experienceId: experience.id, + experienceKey: experience.key, + fromHash: currentHash, + timestamp: Date.now(), + toHash: destinationHash, + variationId: bucketedVariation.id, + variationKey: bucketedVariation.key + }; + + writeSplitCookie(splitState); + convert.splitTests[experience.key] = splitState; + convert.redirect(destinationUrl); + return; + } +}; + +export const runSplitEntry = async () => { + const runtime = await initializeVisitorRuntime(); + if (!runtime || typeof window === 'undefined') return null; + + const convert = ensureConvertWindow(); + registerHelpers(convert); + + if (processDestinationFlow(convert)) { + return convert.splitTests; + } + + processOriginFlow(convert, runtime); + return convert.splitTests; +}; + +void runSplitEntry(); diff --git a/packages/js-sdk/src/standalone/visitor-entry.ts b/packages/js-sdk/src/standalone/visitor-entry.ts new file mode 100644 index 00000000..f4e6effb --- /dev/null +++ b/packages/js-sdk/src/standalone/visitor-entry.ts @@ -0,0 +1,5 @@ +import {initializeVisitorRuntime} from './runtime'; + +export const runVisitorEntry = () => initializeVisitorRuntime(); + +void runVisitorEntry(); diff --git a/rollup.config.mjs b/rollup.config.mjs index 7813c73b..8cc71e42 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -5,7 +5,13 @@ import generateRollupConfig from './generate-rollup-config.mjs'; export default () => { const basePath = resolve('.'); const packageName = basename(basePath); - const input = resolve('index.ts'); + const input = { + index: resolve('index.ts'), + 'goals-entry': resolve('src/standalone/goals-entry.ts'), + 'split-entry': resolve('src/standalone/split-entry.ts'), + 'visitor-entry': resolve('src/standalone/visitor-entry.ts'), + 'integrations-entry': resolve('src/standalone/integrations-entry.ts') + }; const info = JSON.parse(readFileSync(`${basePath}/package.json`, 'utf-8')); console.log(`build ${info.name}...`); return generateRollupConfig({basePath, input, info, packageName}); From 0d8e6bbdbd990733edb76d5920b7a5a616709d49 Mon Sep 17 00:00:00 2001 From: Joseph Samir Date: Sat, 28 Mar 2026 04:08:36 +0200 Subject: [PATCH 5/6] feat: Delivery infrastructure and toolkit --- generate-rollup-config.mjs | 15 +- .../js-sdk/src/standalone/toolkit-entry.ts | 13 + packages/js-sdk/src/standalone/toolkit.ts | 387 ++++++++++++++++++ packages/js-sdk/toolkit.browser.tests.js | 105 +++++ rollup.config.mjs | 3 +- 5 files changed, 516 insertions(+), 7 deletions(-) create mode 100644 packages/js-sdk/src/standalone/toolkit-entry.ts create mode 100644 packages/js-sdk/src/standalone/toolkit.ts create mode 100644 packages/js-sdk/toolkit.browser.tests.js diff --git a/generate-rollup-config.mjs b/generate-rollup-config.mjs index 45e590dc..395b3a29 100644 --- a/generate-rollup-config.mjs +++ b/generate-rollup-config.mjs @@ -486,14 +486,16 @@ const BUNDLES = process.env.BUNDLES 'cjs', 'cjs-legacy', 'esm', - 'umd' + 'umd', + 'toolkit' ]; const STANDALONE_UMD_NAMES = { 'visitor-entry': 'ConvertSDKVisitorEntry', 'goals-entry': 'ConvertSDKGoalsEntry', 'split-entry': 'ConvertSDKSplitEntry', - 'integrations-entry': 'ConvertSDKIntegrationsEntry' + 'integrations-entry': 'ConvertSDKIntegrationsEntry', + toolkit: 'ConvertSDKToolkit' }; const resolveBundleInput = (input, entryName) => { @@ -583,6 +585,7 @@ export default async ({basePath, input, info, packageName}) => { case 'goals-entry': case 'split-entry': case 'integrations-entry': + case 'toolkit': return isMainPackage ? [ commonJSStandaloneBundle({ @@ -590,27 +593,27 @@ export default async ({basePath, input, info, packageName}) => { input: bundleInput, info, packageName, - fileName: bundle + fileName: bundle === 'toolkit' ? 'static/toolkit' : bundle }), esmBundle({ basePath, input: bundleInput, info, packageName, - fileName: bundle + fileName: bundle === 'toolkit' ? 'static/toolkit' : bundle }), umdBundle({ basePath, input: bundleInput, info, name: STANDALONE_UMD_NAMES[bundle], - fileName: bundle + fileName: bundle === 'toolkit' ? 'static/toolkit' : bundle }), typeDeclarations({ basePath, input: bundleInput, packageName, - fileName: bundle + fileName: bundle === 'toolkit' ? 'static/toolkit' : bundle }) ] : []; diff --git a/packages/js-sdk/src/standalone/toolkit-entry.ts b/packages/js-sdk/src/standalone/toolkit-entry.ts new file mode 100644 index 00000000..5a4b0a6d --- /dev/null +++ b/packages/js-sdk/src/standalone/toolkit-entry.ts @@ -0,0 +1,13 @@ +import {ensureConvertWindow, initializeVisitorRuntime} from './runtime'; +import {runWithToolkit} from './toolkit'; + +export const runToolkitEntry = async () => { + const runtime = await initializeVisitorRuntime(); + const convert = ensureConvertWindow(); + if (!runtime || !convert) return null; + + runWithToolkit(convert, runtime); + return convert.T; +}; + +void runToolkitEntry(); diff --git a/packages/js-sdk/src/standalone/toolkit.ts b/packages/js-sdk/src/standalone/toolkit.ts new file mode 100644 index 00000000..41338faa --- /dev/null +++ b/packages/js-sdk/src/standalone/toolkit.ts @@ -0,0 +1,387 @@ +import {ConfigExperience, BucketedVariation} from '@convertcom/js-sdk-types'; +import {VariationChangeType} from '@convertcom/js-sdk-enums'; +import {ConvertStandaloneRuntime} from './runtime'; + +type ChangeLike = { + type?: string; + data?: unknown; + id?: string | number; +}; + +type ChangeCommand = { + what?: string; + params?: Record; +}; + +type QueueItem = ChangeCommand | unknown[]; + +type ConversionWindow = Window & { + convert?: Record; + _conv_q?: QueueItem[]; +}; + +const isRecord = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +const isChangeLike = (value: unknown): value is ChangeLike => + isRecord(value) && + (typeof value.type === 'string' || Object.prototype.hasOwnProperty.call(value, 'id') || isRecord(value.data)); + +const isChangeArray = (value: unknown): value is Array => + Array.isArray(value) && value.every(isChangeLike); + +const isVariationLike = (value: unknown): value is BucketedVariation => + isRecord(value) && + (Object.prototype.hasOwnProperty.call(value, 'id') || + Object.prototype.hasOwnProperty.call(value, 'key') || + Object.prototype.hasOwnProperty.call(value, 'experienceId') || + Object.prototype.hasOwnProperty.call(value, 'experienceKey')); + +const isBrowser = (): boolean => typeof window !== 'undefined'; + +const getWindowScope = (): ConversionWindow | null => + isBrowser() ? ((window as unknown) as ConversionWindow) : null; + +const getDocument = (): Document | null => + isBrowser() && typeof document !== 'undefined' ? document : null; + +const getApplierTarget = (): { + documentRef: Document | null; + appender: Element | null; +} => { + const documentRef = getDocument(); + if (!documentRef) + return {documentRef: null, appender: null}; + + const appender = + documentRef.head || + documentRef.documentElement || + documentRef.body || + (typeof documentRef.getElementsByTagName === 'function' + ? documentRef.getElementsByTagName('head')[0] + : null); + + return {documentRef, appender: appender || null}; +}; + +const runCss = (css: string): void => { + const {documentRef, appender} = getApplierTarget(); + if (!documentRef || !appender || typeof appender.appendChild !== 'function') return; + if (!css) return; + + try { + const style = documentRef.createElement('style'); + style.type = 'text/css'; + if ( + typeof documentRef.createTextNode === 'function' && + typeof style.appendChild === 'function' + ) { + style.appendChild(documentRef.createTextNode(css)); + } else { + style.textContent = css; + } + appender.appendChild(style); + } catch { + // noop + } +}; + +const runJs = (js: string): void => { + if (!js || typeof js !== 'string') return; + + try { + const expressionCode = js.trim().replace(/;+$/g, ''); + if (!expressionCode) return; + + const isFunctionCode = + /^(?:async\s+)?function\b/.test(expressionCode) || + /^(?:async\s*)?\(\s*[^)]*\)\s*=>/.test(expressionCode) || + /^(?:async\s*)?[A-Za-z_$][\w$]*\s*=>/.test(expressionCode); + + const executableCode = isFunctionCode + ? `return (${expressionCode})` + : `return function(){\n${expressionCode}\n}`; + + const result = Function(executableCode)(); + if (typeof result === 'function') result(); + } catch { + // noop + } +}; + +const normalizePayload = (value: unknown): Record => { + const data = isRecord(value) ? value : {}; + return data; +}; + +type ToolkitInstance = { + applyChange: (change: ChangeLike) => boolean; + applyChanges: (changes: Array | ChangeLike) => boolean; + applyVariation: ( + variation: BucketedVariation, + options?: {experience?: ConfigExperience} + ) => boolean; +}; + +type ToolkitApi = (( + payload?: unknown, + options?: {experience?: ConfigExperience} +) => boolean | void) & { + applyChange: (change: ChangeLike) => boolean; + applyChanges: (changes: Array | ChangeLike) => boolean; + applyVariation: ( + variation: BucketedVariation, + options?: {experience?: ConfigExperience} + ) => boolean; + run: ( + payload?: BucketedVariation, + options?: {experience?: ConfigExperience} + ) => boolean | void; +}; + +const createToolkit = ( + convert: Record, + runtime: ConvertStandaloneRuntime +): ToolkitApi => { + const executedChanges = new Set(); + const executedVariations = new Set(); + + const getChangeType = (change: ChangeLike): string => + String(change?.type || '').trim(); + + const getChangeKey = ( + variation: BucketedVariation, + change?: ChangeLike + ): string => { + const variationId = String( + variation.id || + variation.key || + variation.experienceId || + variation.experienceKey || + 'variation' + ); + const changeId = change?.id ? String(change.id) : null; + return changeId ? `variation:${variationId}:change:${changeId}` : `variation:${variationId}`; + }; + + const applySingleChange = (change: ChangeLike): boolean => { + if (!isChangeLike(change)) return false; + + const type = getChangeType(change); + const data = normalizePayload(change.data); + const cssRaw = data?.css; + const jsRaw = data?.js; + const customJsRaw = data?.custom_js; + const css = typeof cssRaw === 'string' ? cssRaw : undefined; + const js = typeof jsRaw === 'string' ? jsRaw : undefined; + const customJs = typeof customJsRaw === 'string' ? customJsRaw : undefined; + + if ( + type === VariationChangeType.DEFAULT_REDIRECT || + type === VariationChangeType.FULLSTACK_FEATURE + ) { + return false; + } + + if (!css && !js && !customJs) { + if (typeof cssRaw === 'string') runCss(cssRaw); + return false; + } + + if (css) runCss(css); + if (js) runJs(js); + if (customJs) runJs(customJs); + return true; + }; + + const applyChange: ToolkitInstance['applyChange'] = (change) => { + const payload = normalizePayload(change); + + const hasType = Object.prototype.hasOwnProperty.call(payload, 'type'); + let executed = false; + + if (!hasType && Object.prototype.hasOwnProperty.call(payload, 'data')) { + const data = normalizePayload(payload.data); + const cssRaw = data.css; + const jsRaw = data.js; + const customJsRaw = data.custom_js; + if (typeof cssRaw === 'string') { + runCss(cssRaw); + executed = true; + } + if (typeof jsRaw === 'string') { + runJs(jsRaw); + executed = true; + } + if (typeof customJsRaw === 'string') { + runJs(customJsRaw); + executed = true; + } + return executed; + } + + return applySingleChange(payload as ChangeLike); + }; + + const applyChanges = (changes: Array | ChangeLike): boolean => { + if (isChangeArray(changes)) { + let changed = false; + for (const change of changes) changed = applyChange(change) || changed; + return changed; + } + if (isChangeLike(changes)) return applyChange(changes); + return false; + }; + + const applyVariation: ToolkitInstance['applyVariation'] = ( + variation, + options = {} + ) => { + const experience = options.experience || null; + const changes = Array.isArray(variation?.changes) ? variation.changes : []; + const variationExecutionKey = getChangeKey(variation); + if (!executedVariations.has(variationExecutionKey)) { + const globalCss = isRecord(experience) + ? String(experience.global_css || '') + : ''; + const globalJs = isRecord(experience) ? String(experience.global_js || '') : ''; + if (globalCss) runCss(globalCss); + if (globalJs) runJs(globalJs); + executedVariations.add(variationExecutionKey); + } + + let changed = false; + for (const change of changes) { + const changeExecutionKey = getChangeKey(variation, change); + if (executedChanges.has(changeExecutionKey)) continue; + if (applyChange(change)) { + changed = true; + executedChanges.add(changeExecutionKey); + } + } + return changed; + }; + + const getQueuePayload = ( + item: QueueItem | unknown + ): { + what: string; + params: unknown; + } | null => { + if (Array.isArray(item)) { + return { + what: typeof item[0] === 'string' ? String(item[0]).trim() : '', + params: item.length > 1 ? item[1] : null + }; + } + + if (!isRecord(item)) return null; + const what = typeof item.what === 'string' ? String(item.what).trim() : ''; + const params = Object.prototype.hasOwnProperty.call(item, 'params') + ? item.params + : null; + return {what, params}; + }; + + const processQueueItem = (item: unknown) => { + const queueCommand = getQueuePayload(item); + if (!queueCommand || !queueCommand.what) return; + + const {what, params} = queueCommand; + const payload = normalizePayload(params); + + switch (what) { + case 'applyChange': + applyChange(payload as ChangeLike); + break; + case 'applyChanges': + applyChanges(payload as Array | ChangeLike); + break; + case 'applyVariation': + case 'runVariation': + if (isVariationLike(payload)) applyVariation(payload, {}); + break; + case 'run': + if (isVariationLike(payload)) applyVariation(payload, {}); + if ( + isRecord(payload) && + Array.isArray(payload.changes) && + !isVariationLike(payload) + ) { + applyChanges(payload.changes); + } + break; + case 'T': + if (isRecord(payload) && isChangeArray(payload.changes)) { + applyChanges(payload.changes); + } else if (isRecord(payload) || isChangeArray(payload)) { + applyChanges(payload as Array | ChangeLike); + } + break; + default: + break; + } + }; + + const installQueue = (): void => { + const scope = getWindowScope(); + if (!scope || !Array.isArray(scope._conv_q)) return; + + const queue = scope._conv_q; + const existingQueue = queue.slice(); + const originalPush = Array.prototype.push; + queue.push = (...items: Array) => { + const output = originalPush.apply(queue, items); + for (const item of items) processQueueItem(item); + return output; + }; + + scope._conv_q = queue; + convert._conv_q = queue; + + for (const item of existingQueue) processQueueItem(item as ChangeCommand); + }; + + const api = ((payload?: unknown, options?: {experience?: ConfigExperience}) => { + if (isVariationLike(payload)) return applyVariation(payload, options); + if (isChangeLike(payload) || isChangeArray(payload)) return applyChanges(payload); + if (typeof payload === 'string') { + if (payload === 'applyChange' && isChangeLike(options)) { + return applyChange(options as ChangeLike); + } + if (payload === 'applyChanges' && isChangeLike(options)) { + return applyChanges(options as ChangeLike); + } + if (payload === 'runVariation' && isVariationLike(options)) { + return applyVariation(options as BucketedVariation); + } + if (payload === 'run' && isVariationLike(options)) { + return applyVariation(options as BucketedVariation, {}); + } + return false; + } + return false; + }) as ToolkitApi; + + api.applyChange = applyChange; + api.applyChanges = applyChanges; + api.applyVariation = applyVariation; + api.run = api; + + if (runtime && isBrowser()) { + installQueue(); + } + + return api; +}; + +export const runWithToolkit = ( + convert: Record, + runtime: ConvertStandaloneRuntime +): ((payload?: unknown, options?: {experience?: ConfigExperience}) => boolean | void) => { + const toolkit = createToolkit(convert, runtime); + const scope = getWindowScope(); + if (scope) scope.convert = scope.convert || {}; + convert.T = toolkit; + return toolkit; +}; diff --git a/packages/js-sdk/toolkit.browser.tests.js b/packages/js-sdk/toolkit.browser.tests.js new file mode 100644 index 00000000..110332ad --- /dev/null +++ b/packages/js-sdk/toolkit.browser.tests.js @@ -0,0 +1,105 @@ +/* eslint-disable no-console */ +import {expect} from 'chai'; +import {runToolkitEntry} from './lib/static/toolkit'; +import testConfig from './tests/test-config.json'; + +const bootstrapConfig = () => ({ + ...testConfig, + bootstrap: {config: testConfig} +}); + +const resetDomState = () => { + document.querySelectorAll('style').forEach((style) => style.remove()); +}; + +const waitForTurn = () => + new Promise((resolve) => { + setTimeout(resolve, 0); + }); + +export default function runToolkitBrowserTests() { + describe('Karma browser tests for toolkit bundle', function () { + beforeEach(async function () { + window.convert = {}; + window._conv_q = []; + window.convert._conv_q = []; + window.convert.bootstrap = bootstrapConfig(); + resetDomState(); + window.__convertToolkitScriptRan = false; + window.__convertToolkitQueueRan = false; + await runToolkitEntry(); + }); + + it('Should expose convert.T with applyChange API', function () { + expect(window.convert).to.have.property('T').that.is.a('function'); + expect(window.convert.T.applyChange).to.be.a('function'); + }); + + it('Should execute CSS + JS payload from convert.T.applyChange without full SDK', function () { + window.convert.T.applyChange({ + type: 'css', + data: { + css: '#convertToolkitTest{display:none;}' + } + }); + + const marker = document.getElementById('convertToolkitStyle'); + const markerStyle = + marker?.style?.getPropertyValue('display') || marker?.getAttribute('style'); + expect(Boolean(markerStyle)).to.equal(false); + expect( + Array.from(document.getElementsByTagName('style')).some((el) => + String(el.textContent || '').includes('convertToolkitTest') + ) + ).to.equal(true); + + window.convert.T.applyChange({ + type: 'js', + data: {js: 'window.__convertToolkitScriptRan = true;'} + }); + expect(window.__convertToolkitScriptRan).to.equal(true); + }); + + it('Should process queued convert toolkit commands', async function () { + window._conv_q.push({ + what: 'applyChange', + params: { + type: 'js', + data: { + js: 'window.__convertToolkitQueueRan = true;' + } + } + }); + await waitForTurn(); + expect(window.__convertToolkitQueueRan).to.equal(true); + }); + + it('Should execute queued tuple commands from window._conv_q', async function () { + window._conv_q.push([ + 'applyChange', + { + type: 'js', + data: {js: 'window.__convertToolkitQueueRan = true;'} + } + ]); + await waitForTurn(); + expect(window.__convertToolkitQueueRan).to.equal(true); + }); + + it('Should execute variation payload through convert.T API without tracking script', function () { + window.convert.T({ + id: 'variation-1', + key: 'variation-key', + changes: [ + { + type: 'js', + data: { + js: 'window.__convertToolkitVariationRan = true;' + } + } + ] + }); + expect(window.__convertToolkitVariationRan).to.equal(true); + }); + }); +} diff --git a/rollup.config.mjs b/rollup.config.mjs index 8cc71e42..7fd8b551 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -10,7 +10,8 @@ export default () => { 'goals-entry': resolve('src/standalone/goals-entry.ts'), 'split-entry': resolve('src/standalone/split-entry.ts'), 'visitor-entry': resolve('src/standalone/visitor-entry.ts'), - 'integrations-entry': resolve('src/standalone/integrations-entry.ts') + 'integrations-entry': resolve('src/standalone/integrations-entry.ts'), + 'toolkit': resolve('src/standalone/toolkit-entry.ts') }; const info = JSON.parse(readFileSync(`${basePath}/package.json`, 'utf-8')); console.log(`build ${info.name}...`); From bb2e2ea2c5e2803ba0566361f4ecc636812ecc65 Mon Sep 17 00:00:00 2001 From: Joseph Samir Date: Sat, 28 Mar 2026 04:20:20 +0200 Subject: [PATCH 6/6] feat: Documentation --- packages/js-sdk/README.md | 143 ++++++- packages/js-sdk/index.browser.cjs.tests.js | 4 + packages/js-sdk/index.browser.umd.tests.js | 1 + packages/js-sdk/index.tests.js | 2 + packages/js-sdk/standalone.browser.tests.js | 451 ++++++++++++++++++++ 5 files changed, 600 insertions(+), 1 deletion(-) create mode 100644 packages/js-sdk/standalone.browser.tests.js diff --git a/packages/js-sdk/README.md b/packages/js-sdk/README.md index dfa9ef31..a2fb5977 100644 --- a/packages/js-sdk/README.md +++ b/packages/js-sdk/README.md @@ -24,6 +24,10 @@ 6. [Using the SDK](#using-the-sdk) - [Comprehensive Examples](#comprehensive-examples) - [Import the SDK into Your Project](#import-the-sdk-into-your-project) + - [Web + Hybrid Bundle Model](#web--hybrid-bundle-model) + - [Browser Helper API Contracts](#browser-helper-api-contracts) + - [Run Variation](#run-variation) + - [runGoals and runIntegrations](#rungoals-and-runintegrations) - [Initialize Using the SDK Key](#initialize-using-the-sdk-key) - [Initialize Using Static Configuration](#initialize-using-static-configuration) - [SDK Configuration Options](#sdk-configuration-options) @@ -263,6 +267,141 @@ convertSDK.onReady().then(() => { When using static project data, the SDK is instantiated as soon as the instance is created and can be used right away for starting a UserContext. +### Web + Hybrid Bundle Model + +This SDK can run in two runtime modes: + +- **Legacy fullstack mode** (existing behavior, unchanged): use the classic tracking endpoint and run features/bucketing in the SDK only. +- **Web companion mode**: use browser companion bundles for split URL + goal + integration behavior and then run the SDK in web mode. + +#### Five-bundle companion setup (web mode) + +Web mode depends on the following companion bundles: + +| Bundle | Responsibility | +| --- | --- | +| `/{account_id}-{project_id}-visitor.js` | Provides `window.convert` bootstrap, config, visitor, request, and segments state | +| `/{account_id}-{project_id}-split.js` | Evaluates `split_url` experiences and sets redirect helpers | +| `/{account_id}-{project_id}-goals.js` | Registers DOM and GA goal listeners | +| `/{account_id}-{project_id}-integrations.js` | Builds integrations map + installs integration/GA callbacks | +| `/static/toolkit.js` | Provides `window.convert.T` used by Visual Editor change JS | + +The SDK entry (`@convertcom/js-sdk` browser build) is always loaded after these bundles because it consumes shared `window.convert` state. + +#### Setup matrix + +| Flow | Companion bundles | SDK initialization sequence | +| --- | --- | --- | +| **Fullstack-only** | None | `/v1/js/{account_id}-{project_id}.js` + SDK only | +| **Web-only** | `visitor -> split -> goals -> integrations -> toolkit` | initialize SDK with `{ data: window.convert.config, dataStore, ruleDataProvider: window.convert.ruleData }` | +| **Hybrid migration** | Same as web-only where web rendering is needed; keep fullstack pages on legacy path | Use web mode only on pages that need web/visual behavior | + +#### Fullstack migration note + +If your current implementation already runs the fullstack path (`/v1/js/{account_id}-{project_id}.js`), that endpoint and behavior stay unchanged. + +### Browser Helper API Contracts + +### Run Variation + +Call `runVariation` on a `Context` created by the SDK after `runExperiences` / `runExperience` returns a bucketed variation. + +#### Signature + +``` +runVariation( + bucketedVariation: BucketedVariation, + options?: {experience?: ConfigExperience} +): void +``` + +#### Behavior + +- Executes experience-level payload first, then change payloads: + - `experience.global_css` + - `experience.global_js` + - per-change `css` + - per-change `js` + - per-change `custom_js` +- Skips unsupported change types: + - `defaultRedirect` + - `fullstack_feature` +- Applies idempotent execution per variation/change in the active `Context`. +- Fails silently with logger warnings when no target node / no DOM exists. + +#### Browser usage example + +```typescript +const context: ContextInterface = convertSDK.createContext('visitor-id'); +const variation = context.runExperience('web-home-hero'); + +if (variation && typeof variation !== 'string') { + context.runVariation(variation); +} +``` + +### runGoals and runIntegrations + +Both APIs are added by companion bundles and exposed on `window.convert`. + +#### `window.convert.runGoals()` + +- Available only after loading `...-goals.js` (auto-registers globals). +- Signature: `runGoals(): any` +- Returns the goals processor returned by `Goals.run()`. +- Installs and activates: + - command queue bridge on `window._conv_q` + - DOM listeners for active goals (`click`, `submit`, etc.) + - GA queue interception (`dataLayer`, `_gaq`, `ga.q`) for `ga_import` goals + +#### `window.convert.runIntegrations()` + +- Available only after loading `...-integrations.js`. +- Signature: `runIntegrations(): IntegrationsProcessor` +- Builds `window.convert.integrations` map from: + - project integrations (`project.settings.integrations`) + - experience integrations (from active configuration) +- Ensures GA interception path is enabled through the shared goals processor. +- Returns the processor so callers can inspect `convert.integrations`. + +#### Example sequence in browser + +```html + + + + + + + + +``` + ### SDK Configuration Options The following shows the object model for the configuration options: @@ -961,7 +1100,7 @@ const convertSDK: ConvertInterface = new ConvertSDK({ | Environment Variable | Description | Value | | -------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | | `LOG_LEVEL` | Specifies the level of log statements to keep, while removing the rest from the bundle output. | `0` = ALL, `1` = DEBUG, `2` = INFO, `3` = WARN, `4` = ERROR, `5` = SILENT | -| `BUNDLES` | Comma-separated tokens for specifying which bundles to build. Defaults to include all bundles. | `cjs`, `cjs-legacy`, `esm`, `umd` | +| `BUNDLES` | Comma-separated tokens for specifying which bundles to build. Defaults to include all base bundles plus standalone toolkit output. | `cjs`, `cjs-legacy`, `esm`, `umd`, `toolkit` | --- @@ -971,6 +1110,8 @@ You can use a customized build in certain situations. For example: 1. To reduce bundle size and remove all log statements: `LOG_LEVEL=5 yarn sdk:build` 2. To build CommonJS bundles only: `BUNDLES=cjs,cjs-legacy yarn sdk:build` +3. To build standalone client bundles only: `BUNDLES=visitor-entry,goals-entry,split-entry,integrations-entry,toolkit yarn sdk:build` +4. To build toolkit artifact only: `BUNDLES=toolkit yarn sdk:build` (emits `lib/static/toolkit.js`) Additionally, you can even include this repository as part of your own `TypeScript` project: diff --git a/packages/js-sdk/index.browser.cjs.tests.js b/packages/js-sdk/index.browser.cjs.tests.js index 4efa54ef..30c3615f 100644 --- a/packages/js-sdk/index.browser.cjs.tests.js +++ b/packages/js-sdk/index.browser.cjs.tests.js @@ -1,7 +1,11 @@ import * as ConvertSDK from './lib/index'; import runTests from './index.tests'; +import runToolkitTests from './toolkit.browser.tests'; +import './standalone.browser.tests'; describe('Karma browser tests for CommonJS bundle', function () { // eslint-disable-next-line mocha/no-setup-in-describe runTests(ConvertSDK); + // eslint-disable-next-line mocha/no-setup-in-describe + runToolkitTests(); }); diff --git a/packages/js-sdk/index.browser.umd.tests.js b/packages/js-sdk/index.browser.umd.tests.js index 88982033..f036b982 100644 --- a/packages/js-sdk/index.browser.umd.tests.js +++ b/packages/js-sdk/index.browser.umd.tests.js @@ -1,6 +1,7 @@ // No SDK library imports here. A UMD script should be already loaded in browser by karma import {assert} from 'chai'; import runTests from './index.tests'; +import './standalone.browser.tests'; describe('Karma browser tests for UMD bundle', function () { it('Should have an SDK instance in namespace', function () { diff --git a/packages/js-sdk/index.tests.js b/packages/js-sdk/index.tests.js index 053b6ffd..c602627c 100644 --- a/packages/js-sdk/index.tests.js +++ b/packages/js-sdk/index.tests.js @@ -70,6 +70,7 @@ export default function runTests(bundle) { 'runExperiences', 'runFeature', 'runFeatures', + 'runVariation', 'trackConversion', 'setDefaultSegments', 'runCustomSegments' @@ -102,6 +103,7 @@ export default function runTests(bundle) { 'experienceKey', 'experienceName', 'bucketingAllocation', + 'experienceType', 'id', 'key', 'name', diff --git a/packages/js-sdk/standalone.browser.tests.js b/packages/js-sdk/standalone.browser.tests.js new file mode 100644 index 00000000..6a488769 --- /dev/null +++ b/packages/js-sdk/standalone.browser.tests.js @@ -0,0 +1,451 @@ +/* eslint-disable mocha/consistent-spacing-between-blocks */ +import {expect} from 'chai'; +import testConfig from './tests/test-config.json'; +import {runGoalsEntry} from './lib/goals-entry'; +import {runIntegrationsEntry} from './lib/integrations-entry.umd'; +import {runSplitEntry} from './lib/split-entry'; + +const waitForTurn = () => + new Promise((resolve) => { + setTimeout(resolve, 0); + }); + +const SPLIT_COOKIE = '_conv_sptest'; + +const hashLocation = (value) => { + let hash = 5381; + for (let i = 0; i < value.length; i++) { + hash = (hash * 33) ^ value.charCodeAt(i); + } + return (hash >>> 0).toString(16); +}; + +const cloneConfig = (data) => JSON.parse(JSON.stringify(data)); + +const baseData = () => ({ + account_id: testConfig.data.account_id, + project: testConfig.data.project, + goals: [], + experiences: [], + audiences: [], + segments: [], + features: [], + locations: [] +}); + +const resetConvert = (data) => { + if (window.convert?.goalsRender?.destroy) { + window.convert.goalsRender.destroy(); + } + + window.convert = { + bootstrap: { + config: { + data + } + }, + data: data, + config: { + data + }, + remote: null, + _conv_q: [], + ruleData: { + url: window.location.href + } + }; + + window._conv_q = []; + window.convert._conv_q = window._conv_q; + delete window.convert.redirect; + delete window.convert.refresh; +}; + +const readSplitCookie = () => { + const cookie = document.cookie + .split('; ') + .find((item) => item.startsWith(`${SPLIT_COOKIE}=`)); + if (!cookie) return null; + try { + return JSON.parse(decodeURIComponent(cookie.split('=').slice(1).join('='))); + } catch { + return null; + } +}; + +const writeSplitCookie = (payload) => { + document.cookie = `${SPLIT_COOKIE}=${encodeURIComponent( + JSON.stringify(payload) + )}; path=/; SameSite=Lax`; +}; + +const clearSplitCookie = () => { + document.cookie = `${SPLIT_COOKIE}=; Max-Age=0; path=/; SameSite=Lax`; +}; + +const splitExperience = ({variationPattern, originalPattern}) => ({ + id: '100900001', + key: 'split-web-experience', + name: 'Split URL Experience', + type: 'split_url', + version: 1, + status: 'active', + url: window.location.href, + environments: ['staging', 'live'], + variations: [ + { + id: '100900001-var-1', + name: 'Variation 1', + status: 'running', + is_baseline: true, + key: '100900001-var-1', + traffic_allocation: 100, + changes: [ + { + id: '100900001-change-1', + type: 'defaultRedirect', + data: { + original_pattern: originalPattern, + variation_pattern: variationPattern, + case_sensitive: false + } + } + ] + } + ] +}); + +const goalEntity = ({type, selector}) => ({ + id: '100900010', + key: 'goal-click', + name: 'Goal click', + status: 'active', + type, + is_system: false, + selected_default: true, + settings: { + selector, + ga_event: 'goal_reached' + } +}); + +const webExperience = ({goalIds, integrations}) => ({ + id: '100900011', + key: 'web-goal-experience', + name: 'Web Goal Experience', + type: 'a/b', + version: 1, + status: 'active', + url: 'https://convert.com', + environment: 'staging', + integrations, + variations: [ + { + id: '100900011-var-1', + name: 'Variation 1', + status: 'running', + is_baseline: true, + key: '100900011-var-1', + traffic_allocation: 100, + changes: [] + } + ], + goals: goalIds +}); + +const buildGoalsConfig = ({goalType, integrationMap, selector, useGaEvent}) => { + const data = baseData(); + const primaryGoal = goalEntity({ + type: goalType || 'clicks_element', + selector: selector || '#goalTarget' + }); + const extraGoal = useGaEvent + ? { + id: '100900011', + key: 'goal-visit', + name: 'Goal visit', + status: 'active', + type: 'ga_import', + is_system: false, + selected_default: true, + settings: { + ga_event: 'purchase' + } + } + : null; + + const goals = extraGoal ? [primaryGoal, extraGoal] : [primaryGoal]; + + data.goals = goals; + data.experiences = [ + webExperience({ + goalIds: goals.map(({key}) => key), + integrations: integrationMap || {} + }) + ]; + + data.project = { + ...testConfig.data.project, + settings: { + ...testConfig.data.project.settings, + ...(useGaEvent + ? {integrations: {google_analytics: {enabled: true}}} + : {}) + } + }; + + return data; +}; + +const projectIntegrationData = { + ...testConfig.data.project, + settings: { + ...testConfig.data.project.settings, + integrations: { + project_tracking: { + enabled: true + } + } + } +}; + +describe('Karma browser tests for standalone bundles', function () { + beforeEach(function () { + window.convert = {}; + window._conv_q = []; + document.body.innerHTML = ''; + if (document.cookie.includes(`${SPLIT_COOKIE}=`)) { + clearSplitCookie(); + } + }); + + describe('split-entry', function () { + it('Should parse _conv_sptest and clear destination cookie', async function () { + const currentUrl = window.location.href; + const data = baseData(); + data.experiences = [ + splitExperience({ + originalPattern: window.location.origin, + variationPattern: `${window.location.origin}/split-target` + }) + ]; + resetConvert(data); + + const payload = { + destinationUrl: `${window.location.origin}/split-target`, + experienceKey: 'split-web-experience', + fromHash: hashLocation('origin'), + toHash: hashLocation(currentUrl), + timestamp: Date.now(), + variationKey: '100900001-var-1' + }; + writeSplitCookie(payload); + + const result = await runSplitEntry(); + + expect(result).to.have.property('split-web-experience'); + expect(readSplitCookie()).to.equal(null); + expect(window.convert.splitTests).to.have.property('split-web-experience'); + expect(window.convert.splitTests['split-web-experience'].toHash).to.equal( + hashLocation(currentUrl) + ); + expect(result).to.equal(window.convert.splitTests); + }); + + it('Should ignore destination cookie when target hash does not match and skip redirect', async function () { + const currentUrl = window.location.href; + const data = baseData(); + data.experiences = [ + splitExperience({ + originalPattern: 'https://example.com/miss', + variationPattern: `${window.location.origin}/split-target` + }) + ]; + resetConvert(data); + + let redirectHref = ''; + window.convert.redirect = (url) => { + redirectHref = url; + }; + + const payload = { + destinationUrl: `${window.location.origin}/split-target`, + experienceKey: 'split-web-experience', + fromHash: hashLocation('origin'), + toHash: hashLocation(currentUrl + '?mismatch'), + timestamp: Date.now(), + variationKey: '100900001-var-1' + }; + writeSplitCookie(payload); + + const result = await runSplitEntry(); + + expect(readSplitCookie()).to.equal(null); + expect(redirectHref).to.equal(''); + expect(result).to.not.have.property('split-web-experience'); + expect(window.convert.splitTests).to.deep.equal({}); + }); + + it('Should execute redirect from origin page when split rules match', async function () { + window.history.replaceState({}, '', '/'); + const currentUrl = window.location.href; + const data = baseData(); + data.experiences = [ + splitExperience({ + originalPattern: window.location.origin, + variationPattern: '/split-target' + }) + ]; + resetConvert(data); + + let redirectUrl = ''; + window.convert.redirect = (url) => { + redirectUrl = url; + }; + + const result = await runSplitEntry(); + + await waitForTurn(); + const splitState = window.convert.splitTests['split-web-experience']; + + expect(redirectUrl).to.include('/split-target'); + expect(splitState).to.be.an('object'); + expect(splitState.experienceId).to.equal('100900001'); + expect(splitState.fromHash).to.equal(hashLocation(currentUrl)); + expect(result).to.equal(window.convert.splitTests); + expect(readSplitCookie()).to.be.an('object'); + }); + }); + + describe('goals-entry', function () { + it('Should process _conv_q commands when runGoals is called', async function () { + const data = buildGoalsConfig({ + goalType: 'clicks_element', + selector: '#goalTarget' + }); + data.experiences[0].goals = ['goal-click']; + data.goals = [ + { + id: '100900010', + key: 'goal-click', + name: 'Goal click', + status: 'active', + type: 'clicks_element', + is_system: false, + selected_default: true, + settings: { + selector: '#goalTarget' + } + } + ]; + resetConvert(data); + + const runGoals = await runGoalsEntry(); + expect(runGoals).to.be.a('function'); + + const context = window.convert.visitor.context; + context.runExperiences({locationProperties: {url: window.location.href}}); + + let conversionCalls = 0; + context.trackConversion = () => { + conversionCalls += 1; + return true; + }; + + runGoals(); + window._conv_q.push({what: 'triggerConversion', params: {goalId: 'goal-click'}}); + + await waitForTurn(); + expect(conversionCalls).to.equal(1); + }); + + it('Should activate DOM listeners and register GA intercept for click and GA payloads', async function () { + const data = buildGoalsConfig({ + goalType: 'clicks_element', + selector: '#goalTarget', + useGaEvent: true + }); + resetConvert(data); + + const triggerButton = document.createElement('button'); + triggerButton.id = 'goalTarget'; + triggerButton.textContent = 'Trigger'; + document.body.appendChild(triggerButton); + + const runGoals = await runGoalsEntry(); + const context = window.convert.visitor.context; + context.runExperiences({locationProperties: {url: window.location.href}}); + + let conversionCalls = 0; + context.trackConversion = () => { + conversionCalls += 1; + return true; + }; + + runGoals(); + await waitForTurn(); + + triggerButton.dispatchEvent( + new MouseEvent('click', { + bubbles: true, + cancelable: true + }) + ); + + await waitForTurn(); + window.dataLayer.push(['event', 'purchase']); + await waitForTurn(); + + expect(conversionCalls).to.equal(2); + expect(window.dataLayer.push).to.not.equal(Array.prototype.push); + expect(window.dataLayer).to.have.length.of.at.least(1); + }); + }); + + describe('integrations-entry', function () { + it('Should build and expose integrations map during runIntegrations()', async function () { + const data = buildGoalsConfig({goalType: 'clicks_element', selector: '#goalTarget'}); + data.experiences = [ + webExperience({ + goalIds: ['goal-click'], + integrations: { + custom_widget: {enabled: true} + } + }) + ]; + data.project = projectIntegrationData; + data.goals = [ + { + id: '100900010', + key: 'goal-click', + name: 'Goal click', + status: 'active', + type: 'clicks_element', + is_system: false, + selected_default: true, + settings: { + selector: '#goalTarget' + } + } + ]; + resetConvert(data); + + const runIntegrations = await runIntegrationsEntry(); + expect(runIntegrations).to.be.a('function'); + + const processor = runIntegrations(); + const integrations = processor?.integrations || {}; + + expect(integrations).to.include.keys( + 'google_analytics', + 'google_tag_manager', + 'custom_widget', + 'project_tracking' + ); + expect(Object.prototype.hasOwnProperty.call(window.dataLayer, 'push')).to.equal( + true + ); + expect(window.convert.integrations).to.deep.equal(integrations); + }); + }); +});