diff --git a/eslint.config.mjs b/eslint.config.mjs index 97a5b423..4d45416a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -14,6 +14,17 @@ const compat = new FlatCompat({ }); export default [ + { + // Generated files: skip linting. `types.gen.ts` and the companion + // `config/index.ts` re-export are regenerated by @hey-api/openapi-ts + // from the serving OpenAPI spec, so any prettier cleanup would be + // wiped on the next regen. The local override for ExperienceTypes + // uses a deliberately different multi-line shape. + ignores: [ + '**/packages/types/src/config/types.gen.ts', + '**/packages/types/src/config/index.ts' + ] + }, { files: ['**/*.ts', '**/*.tsx'], ignores: ['.yarn', '.vscode', '.github', 'node_modules'] diff --git a/package.json b/package.json index bbc1a68e..99e31810 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,5 @@ { + "packageManager": "yarn@4.5.3", "scripts": { "api:lint": "cd packages/api && yarn lint", "bucketing:lint": "cd packages/bucketing && yarn lint", diff --git a/packages/data/src/data-manager.ts b/packages/data/src/data-manager.ts index 210f4c25..bd8ac77f 100644 --- a/packages/data/src/data-manager.ts +++ b/packages/data/src/data-manager.ts @@ -43,7 +43,8 @@ import { ConfigAudienceTypes, VariationStatuses, eventType, - GenericListMatchingOptions + GenericListMatchingOptions, + RuleDataProvider } from '@convertcom/js-sdk-types'; import { @@ -83,6 +84,7 @@ export class DataManager implements DataManagerInterface { private _asyncStorage: boolean; private _environment: string; private _mapper: (...args: any) => any; + private _ruleDataProvider: RuleDataProvider | null; /** * @param {Config} config * @param {Object} dependencies @@ -121,6 +123,23 @@ export class DataManager implements DataManagerInterface { this._config = config; this._mapper = config?.mapper || ((value: any) => value); this._asyncStorage = asyncStorage; + this._ruleDataProvider = config?.ruleDataProvider || null; + // Guard: a misconfigured provider (missing the `name: 'RuleData'` + // discriminator) falls through RuleManager's "flat-key" branch and + // silently returns false for every rule — i.e. no audiences match, + // no experiences run, no errors thrown. Warn loudly at construction + // so the misconfiguration surfaces immediately instead of as silent + // data loss across the entire visitor population. + if ( + this._ruleDataProvider && + !this._ruleManager.isUsingCustomInterface(this._ruleDataProvider) + ) { + this._loggerManager?.warn?.( + 'DataManager()', + ERROR_MESSAGES.RULE_DATA_PROVIDER_INVALID + ); + this._ruleDataProvider = null; + } this._data = objectDeepValue(config, 'data'); this._accountId = this._data?.account_id; this._projectId = this._data?.project?.id; @@ -288,10 +307,13 @@ export class DataManager implements DataManagerInterface { isBucketed = true; } - // Check location rules against locationProperties + // Check location rules against locationProperties. + // Enter the eval block if EITHER a per-call locationProperties OR a + // globally-configured ruleDataProvider is available — the outer gate + // would otherwise skip the eval when only the provider is set. let locationMatched: boolean | RuleError = ignoreLocationProperties === true; - if (!locationMatched && locationProperties) { + if (!locationMatched && (locationProperties || this._ruleDataProvider)) { if (Array.isArray(experience?.locations) && experience.locations.length) { let matchedLocations = []; // Get attached locations @@ -317,7 +339,7 @@ export class DataManager implements DataManagerInterface { } else if (experience?.site_area) { // Validate locationProperties against site area rules locationMatched = this._ruleManager.isRuleMatched( - locationProperties, + locationProperties || this._ruleDataProvider, experience.site_area, 'SiteArea' ); @@ -347,7 +369,10 @@ export class DataManager implements DataManagerInterface { return null; } - // Check audience rules against visitorProperties + // Check audience rules against visitorProperties. + // Same gate-broadening as above: enter the eval block if either a + // per-call visitorProperties OR a global ruleDataProvider exists, + // otherwise the provider is unreachable from the audience path. let audiences = [], segments = [], matchedAudiences = [], @@ -355,7 +380,7 @@ export class DataManager implements DataManagerInterface { audiencesToCheck: Array = [], audiencesMatched = false, segmentsMatched = false; - if (visitorProperties) { + if (visitorProperties || this._ruleDataProvider) { if (Array.isArray(experience?.audiences) && experience.audiences.length) { // Get attached transient and/or permnent audiences audiences = this.getItemsByIds( @@ -709,7 +734,8 @@ export class DataManager implements DataManagerInterface { ...{ experienceId: experience?.id, experienceName: experience?.name, - experienceKey: experience?.key + experienceKey: experience?.key, + experienceType: experience?.type }, bucketingAllocation, ...variation @@ -856,7 +882,7 @@ export class DataManager implements DataManagerInterface { for (let i = 0, length = items.length; i < length; i++) { if (!items?.[i]?.rules) continue; match = this._ruleManager.isRuleMatched( - locationProperties, + locationProperties || this._ruleDataProvider, items[i].rules, `ConfigLocation #${items[i][identityField]}` ); @@ -997,10 +1023,19 @@ export class DataManager implements DataManagerInterface { return; } - if (goalRule) { - if (!goal?.rules) return; + // Two separate concerns: + // 1. Pre-PR contract: if the caller explicitly passes `goalRule` to a + // goal that itself has no rules, the conversion is a no-op. This + // surfaces the call-site misunderstanding instead of firing + // silently. Preserved here. + // 2. PR addition: a globally-configured `ruleDataProvider` should + // ONLY participate in rule evaluation when the goal actually has + // rules. Otherwise rule-less goals would be silently dropped on + // every visitor when a provider is set. + if (goalRule && !goal?.rules) return; + if (goal?.rules && (goalRule || this._ruleDataProvider)) { const ruleMatched = this._ruleManager.isRuleMatched( - goalRule, + goalRule || this._ruleDataProvider, goal.rules, `ConfigGoal #${goalId}` ); @@ -1112,7 +1147,7 @@ export class DataManager implements DataManagerInterface { for (let i = 0, length = items.length; i < length; i++) { if (!items?.[i]?.rules) continue; match = this._ruleManager.isRuleMatched( - visitorProperties, + visitorProperties || this._ruleDataProvider, items[i].rules, `${camelCase(entityType)} #${items[i][field]}` ); diff --git a/packages/data/tests/data-manager.tests.ts b/packages/data/tests/data-manager.tests.ts index 1be0a3fa..27c31fb7 100644 --- a/packages/data/tests/data-manager.tests.ts +++ b/packages/data/tests/data-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 '../../js-sdk/src/config/default'; +import {awaitTrackRequest} from '../../js-sdk/tests/setup/track-request'; class DataStore { data = {}; @@ -71,7 +72,7 @@ describe('DataManager tests', function () { customSegments: ['seg1', 'seg2'] }; let dataManager, accountId, projectId, storeKey, server; - // eslint-disable-next-line mocha/no-hooks-for-single-case + before(function () { accountId = configuration?.data?.account_id; projectId = configuration?.data?.project?.id; @@ -83,12 +84,12 @@ describe('DataManager tests', function () { apiManager }); }); - // eslint-disable-next-line mocha/no-hooks-for-single-case + beforeEach(function () { server = http.createServer(); server.listen(port); }); - // eslint-disable-next-line mocha/no-hooks-for-single-case + afterEach(function () { dataManager.reset(); server.closeAllConnections(); @@ -307,7 +308,6 @@ describe('DataManager tests', function () { }); }); describe('Persistent Data Store enqueue tests', function () { - // eslint-disable-next-line mocha/no-hooks-for-single-case before(function () { configuration.dataStore = dataStore; dataManager = new dm( @@ -357,7 +357,6 @@ describe('DataManager tests', function () { }); }); describe('Persistent Data Store tests (set immediately)', function () { - // eslint-disable-next-line mocha/no-hooks-for-single-case before(function () { dataStore.data = {}; delete configuration.dataStore; @@ -398,4 +397,173 @@ describe('DataManager tests', function () { expect(check).to.have.property('segments').that.deep.equal(segments); }); }); + describe('Test ruleDataProvider integration', function () { + let receivedRuleData; + let provider; + + before(function () { + // Mock RuleManager that captures what data shape was passed to isRuleMatched + const capturingRuleManager: any = { + isRuleMatched: (data) => { + receivedRuleData = data; + return true; + }, + isUsingCustomInterface: (data) => !!data && data.name === 'RuleData' + }; + provider = { + name: 'RuleData', + getGenericTextKeyValue: () => 'something' + }; + const configWithProvider = objectDeepMerge(configuration, { + ruleDataProvider: provider, + dataStore: undefined + }) as unknown as ConfigType; + dataManager = new dm(configWithProvider, { + bucketingManager, + ruleManager: capturingRuleManager, + eventManager, + apiManager + }); + }); + beforeEach(function () { + receivedRuleData = undefined; + }); + it('Should use ruleDataProvider when no per-call visitorProperties is supplied', async function () { + // Per-call wins, so when the caller omits visitorProperties the + // global provider supplies the rule data. + this.timeout(test_timeout); + const experienceKey = 'test-experience-ab-fullstack-2'; + dataManager.getBucketing(visitorId, experienceKey, { + locationProperties: {url: 'https://convert.com/'} + }); + await awaitTrackRequest(server, `/track/${accountId}/${projectId}`); + expect(receivedRuleData) + .to.be.an('object') + .that.has.property('name', 'RuleData'); + expect(receivedRuleData).to.equal(provider); + }); + it('Should let per-call visitorProperties win over a configured ruleDataProvider', async function () { + // Precedence guard: a caller who explicitly supplies + // visitorProperties is opting out of the provider for that call, + // matching standard config-vs-args layering. + this.timeout(test_timeout); + const experienceKey = 'test-experience-ab-fullstack-2'; + dataManager.getBucketing(visitorId, experienceKey, { + visitorProperties: {varName3: 'plain-value'}, + locationProperties: {url: 'https://convert.com/'} + }); + await awaitTrackRequest(server, `/track/${accountId}/${projectId}`); + expect(receivedRuleData).to.be.an('object'); + expect(receivedRuleData).to.not.have.property('name', 'RuleData'); + expect(receivedRuleData).to.have.property('varName3', 'plain-value'); + }); + it('Should fall back to plain visitorProperties when no ruleDataProvider is set', async function () { + this.timeout(test_timeout); + const noProviderConfig = objectDeepMerge(configuration, { + ruleDataProvider: undefined, + dataStore: undefined + }) as unknown as ConfigType; + const capturingRuleManager: any = { + isRuleMatched: (data) => { + receivedRuleData = data; + return true; + }, + isUsingCustomInterface: (data) => !!data && data.name === 'RuleData' + }; + const localDataManager = new dm(noProviderConfig, { + bucketingManager, + ruleManager: capturingRuleManager, + eventManager, + apiManager + }); + const experienceKey = 'test-experience-ab-fullstack-2'; + localDataManager.getBucketing(visitorId, experienceKey, { + visitorProperties: {varName3: 'plain-value'}, + locationProperties: {url: 'https://convert.com/'} + }); + await awaitTrackRequest(server, `/track/${accountId}/${projectId}`); + // No provider configured — RuleManager should see the plain object. + expect(receivedRuleData).to.be.an('object'); + expect(receivedRuleData).to.not.have.property('name', 'RuleData'); + expect(receivedRuleData).to.have.property('varName3', 'plain-value'); + }); + it('Should warn and ignore a misconfigured ruleDataProvider (missing name)', function () { + // A provider that doesn't satisfy isUsingCustomInterface (no + // `name: 'RuleData'`) would otherwise fall through RuleManager's + // flat-key branch and silently return false for every rule. The + // DataManager constructor must surface the misconfiguration as a + // warn and ignore the provider so the SDK falls back to plain + // visitor/location properties. + const warns: Array> = []; + const capturingLogger: any = { + warn: (...args: any[]) => warns.push(args), + error: () => {}, + info: () => {}, + debug: () => {}, + trace: () => {} + }; + const badProvider = {country: 'CA'}; // missing name: 'RuleData' + const badConfig = objectDeepMerge(configuration, { + ruleDataProvider: badProvider, + dataStore: undefined + }) as unknown as ConfigType; + const localDataManager = new dm(badConfig, { + bucketingManager, + ruleManager, + eventManager, + apiManager, + loggerManager: capturingLogger + }); + assert.isDefined(localDataManager); + const warned = warns.some((entry) => + entry.some( + (a) => + typeof a === 'string' && + a.includes('Config.ruleDataProvider is set') + ) + ); + expect(warned).to.equal(true); + }); + it('Should fire conversion for a rule-less goal even when ruleDataProvider is configured', async function () { + // Regression guard: if convert() always enters the rule-evaluation + // branch when a global ruleDataProvider is set, it would hit + // `if (!goal?.rules) return;` and silently drop every conversion for + // goals that have no rules. The fix gates on `goal.rules` first. + this.timeout(test_timeout); + const ruleLessGoal = { + id: '90000001', + name: 'Rule-less goal', + key: 'rule-less-goal', + type: 'event' + // intentionally no `rules` field + }; + const configWithProvider = objectDeepMerge(configuration, { + ruleDataProvider: { + name: 'RuleData', + getUrl: () => 'https://convert.com/' + }, + dataStore: undefined, + data: { + ...configuration.data, + goals: [...configuration.data.goals, ruleLessGoal] + } + }) as unknown as ConfigType; + const localDataManager = new dm(configWithProvider, { + bucketingManager, + ruleManager, + eventManager, + apiManager + }); + const triggered = localDataManager.convert( + visitorId, + ruleLessGoal.key, + undefined, // no goalRule passed + undefined, + {} + ); + await awaitTrackRequest(server, `/track/${accountId}/${projectId}`); + // convert() returns `true` when the conversion fires + expect(triggered).to.equal(true); + }); + }); }); diff --git a/packages/enums/src/dictionary.ts b/packages/enums/src/dictionary.ts index 2176f83e..aec63c0c 100644 --- a/packages/enums/src/dictionary.ts +++ b/packages/enums/src/dictionary.ts @@ -20,7 +20,9 @@ export const ERROR_MESSAGES = { GOAL_DATA_NOT_VALID: 'GoalData object is not valid', UNABLE_TO_SELECT_BUCKET_FOR_VISITOR: 'Unable to bucket visitor', UNABLE_TO_PERFORM_NETWORK_REQUEST: 'Unable to perform network request', - UNSUPPORTED_RESPONSE_TYPE: 'Unsupported response type' + UNSUPPORTED_RESPONSE_TYPE: 'Unsupported response type', + RULE_DATA_PROVIDER_INVALID: + 'Config.ruleDataProvider is set but does not satisfy the RuleData custom-interface contract (requires name: RuleData and rule-type getter methods on its constructor prototype). Provider has been ignored — falling back to plain visitor/location properties. See @convertcom/js-sdk-types#RuleDataProvider for the expected shape.' }; export const MESSAGES = { CONFIG_DATA_UPDATED: 'Config Data updated', @@ -69,5 +71,20 @@ export const MESSAGES = { CUSTOM_SEGMENTS_KEY_FOUND: 'Custom segments key already set', SEND_BEACON_SUCCESS: 'The user agent successfully queued the data for transfer', - RELEASING_QUEUE: 'Releasing event queue...' + RELEASING_QUEUE: 'Releasing event queue...', + RUN_VARIATION_BROWSER_ONLY: 'runVariation requires a browser environment', + RUN_VARIATION_EXPERIENCE_NOT_FOUND: + 'runVariation: experience # not found in config', + RUN_VARIATION_REDIRECT_SKIPPED: + 'runVariation: skipping defaultRedirect change (handled by Split Bundle)', + RUN_VARIATION_FULLSTACK_SKIPPED: + 'runVariation: skipping fullStackFeature change (use runFeature)', + RUN_VARIATION_RICH_STRUCTURE_SKIPPED: + 'runVariation: skipping richStructure change # (selector-scoped DOM mutations not supported by this renderer)', + RUN_VARIATION_MULTIPAGE_PAGE: + 'runVariation: applying defaultCodeMultipage change # for page_id=# (caller is responsible for funnel-step matching)', + RUN_VARIATION_TOOLKIT_MISSING: + 'runVariation: window.convert.T (Convert Toolkit) is not loaded; Visual Editor changes may fail', + RUN_VARIATION_STYLE_ERROR: 'runVariation: error injecting style #', + RUN_VARIATION_SCRIPT_ERROR: 'runVariation: error executing change script #' }; diff --git a/packages/experience/src/experience-manager.ts b/packages/experience/src/experience-manager.ts index 1b544d6c..62b42909 100644 --- a/packages/experience/src/experience-manager.ts +++ b/packages/experience/src/experience-manager.ts @@ -112,6 +112,16 @@ export class ExperienceManager implements ExperienceManagerInterface { experienceKey: string, attributes: BucketingAttributes ): BucketedVariation | RuleError | BucketingError { + // Honor `experienceTypes` filter — keep parity with selectVariations + // and FeatureManager.runFeatures. Without this gate, the singular + // path silently ignores the filter even though it's advertised on + // BucketingAttributes. See semantics note in selectVariations(). + const typeFilter = attributes?.experienceTypes; + if (typeFilter) { + if (typeFilter.length === 0) return null; + const experience = this.getExperience(experienceKey); + if (experience && !typeFilter.includes(experience.type)) return null; + } return this._dataManager.getBucketing(visitorId, experienceKey, attributes); } @@ -133,6 +143,12 @@ export class ExperienceManager implements ExperienceManagerInterface { experienceId: string, attributes: BucketingAttributes ): BucketedVariation | RuleError | BucketingError { + const typeFilter = attributes?.experienceTypes; + if (typeFilter) { + if (typeFilter.length === 0) return null; + const experience = this.getExperienceById(experienceId); + if (experience && !typeFilter.includes(experience.type)) return null; + } return this._dataManager.getBucketingById( visitorId, experienceId, @@ -156,8 +172,26 @@ export class ExperienceManager implements ExperienceManagerInterface { visitorId: string, attributes: BucketingAttributes ): Array { + const typeFilter = attributes?.experienceTypes; + // `experienceTypes` semantics: + // - undefined → no filter applied (all experience types match) + // - [] → zero types allowed (no experiences match) — matches + // standard array-filter intuition. Callers who want + // "all" must pass undefined or omit the option. + // - [...] → only experiences whose `type` is in the array + if (typeFilter && typeFilter.length === 0) return []; return this.getList() + .filter((experience) => { + if (!typeFilter) return true; + return typeFilter.includes(experience?.type); + }) .map((experience) => { + // Defense-in-depth: selectVariation re-applies the same + // experienceTypes filter on its singular path. The list above + // has already been filtered, so the inner check is redundant + // here — left intact so callers of selectVariation directly + // get the same semantics. The Array.includes() per experience + // is negligible. return this.selectVariation(visitorId, experience?.key, attributes); }) .filter( diff --git a/packages/experience/tests/experience-manager.tests.ts b/packages/experience/tests/experience-manager.tests.ts index 9a417c14..c7803d2e 100644 --- a/packages/experience/tests/experience-manager.tests.ts +++ b/packages/experience/tests/experience-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 '../../js-sdk/src/config/default'; +import {awaitTrackRequest} from '../../js-sdk/tests/setup/track-request'; const host = 'http://localhost'; const port = 8090; @@ -41,7 +42,7 @@ const apiManager = new am(configuration, {eventManager}); describe('ExperienceManager tests', function () { const visitorId = 'XXX'; let dataManager, experienceManager, accountId, projectId, server; - // eslint-disable-next-line mocha/no-hooks-for-single-case + before(function () { accountId = configuration?.data?.account_id; projectId = configuration?.data?.project?.id; @@ -53,12 +54,12 @@ describe('ExperienceManager tests', function () { }); experienceManager = new exm(configuration, {dataManager}); }); - // eslint-disable-next-line mocha/no-hooks-for-single-case + beforeEach(function () { server = http.createServer(); server.listen(port); }); - // eslint-disable-next-line mocha/no-hooks-for-single-case + afterEach(function () { dataManager.reset(); server.closeAllConnections(); @@ -220,4 +221,100 @@ describe('ExperienceManager tests', function () { .to.equal(variationKey); }); }); + describe('Test experienceType field on BucketedVariation', function () { + it('Should populate experienceType from the source experience config', async function () { + this.timeout(test_timeout); + const experienceKey = 'test-experience-ab-fullstack-2'; + const variation = experienceManager.selectVariation( + visitorId, + experienceKey, + { + visitorProperties: {varName3: 'something'}, + locationProperties: {url: 'https://convert.com/'} + } + ); + await awaitTrackRequest(server, `/track/${accountId}/${projectId}`); + expect(variation).to.be.an('object').that.has.property('experienceType'); + expect(variation.experienceType).to.equal('a/b_fullstack'); + }); + }); + describe('Test experienceTypes filter on selectVariations', function () { + it('Should return all experiences when no type filter is set', async function () { + this.timeout(test_timeout); + const variations = experienceManager.selectVariations(visitorId, { + visitorProperties: {varName3: 'something'}, + locationProperties: {url: 'https://convert.com/'} + }); + await awaitTrackRequest(server, `/track/${accountId}/${projectId}`); + expect(variations).to.be.an('array').that.has.length(2); + }); + it('Should keep all fullstack experiences when experienceTypes=["a/b_fullstack"]', async function () { + this.timeout(test_timeout); + const variations = experienceManager.selectVariations(visitorId, { + visitorProperties: {varName3: 'something'}, + locationProperties: {url: 'https://convert.com/'}, + experienceTypes: ['a/b_fullstack'] + }); + await awaitTrackRequest(server, `/track/${accountId}/${projectId}`); + expect(variations).to.be.an('array').that.has.length(2); + variations.forEach((v) => { + expect(v.experienceType).to.equal('a/b_fullstack'); + }); + }); + it('Should return empty when filtering by a type with no matching experiences', function () { + const variations = experienceManager.selectVariations(visitorId, { + visitorProperties: {varName3: 'something'}, + locationProperties: {url: 'https://convert.com/'}, + experienceTypes: ['feature_rollout'] + }); + expect(variations).to.be.an('array').that.has.length(0); + }); + it('Should return empty when filtering by web type (no web experiences in config)', function () { + const variations = experienceManager.selectVariations(visitorId, { + visitorProperties: {varName3: 'something'}, + locationProperties: {url: 'https://convert.com/'}, + experienceTypes: ['a/b'] + }); + expect(variations).to.be.an('array').that.has.length(0); + }); + it('Should return empty when experienceTypes is an empty array', function () { + // [] means "zero types allowed" → no matches. Differs from + // undefined which means "no filter applied". + const variations = experienceManager.selectVariations(visitorId, { + visitorProperties: {varName3: 'something'}, + locationProperties: {url: 'https://convert.com/'}, + experienceTypes: [] + }); + expect(variations).to.be.an('array').that.has.length(0); + }); + }); + describe('Test experienceTypes filter on selectVariation (singular)', function () { + it('Should return null when the experience type is not in the filter', function () { + const result = experienceManager.selectVariation( + visitorId, + 'test-experience-ab-fullstack-2', + { + visitorProperties: {varName3: 'something'}, + locationProperties: {url: 'https://convert.com/'}, + experienceTypes: ['a/b'] // experience is a/b_fullstack + } + ); + expect(result).to.equal(null); + }); + it('Should bucket normally when the experience type IS in the filter', async function () { + this.timeout(test_timeout); + const variation = experienceManager.selectVariation( + visitorId, + 'test-experience-ab-fullstack-2', + { + visitorProperties: {varName3: 'something'}, + locationProperties: {url: 'https://convert.com/'}, + experienceTypes: ['a/b_fullstack'] + } + ); + await awaitTrackRequest(server, `/track/${accountId}/${projectId}`); + expect(variation).to.be.an('object'); + expect(variation).to.have.property('experienceType', 'a/b_fullstack'); + }); + }); }); diff --git a/packages/js-sdk/package.json b/packages/js-sdk/package.json index 26d93ee1..2cb07a98 100644 --- a/packages/js-sdk/package.json +++ b/packages/js-sdk/package.json @@ -19,7 +19,7 @@ "pretest": "rm -rf coverage", "test": "nyc yarn test:all && yarn coverage", "test:all": "yarn test:mocha && yarn test:browser", - "test:browser": "playwright test --config playwright.config.ts --project chromium", + "test:browser": "playwright install chromium && playwright test --config playwright.config.ts --project chromium", "test:server": "nyc yarn test:mocha", "test:mocha": "mocha -r ts-node/register --recursive \"tests/**/*.tests.ts\" --exit", "clean": "rm -rf lib", diff --git a/packages/js-sdk/src/context.ts b/packages/js-sdk/src/context.ts index f83180f6..b8acc8b7 100644 --- a/packages/js-sdk/src/context.ts +++ b/packages/js-sdk/src/context.ts @@ -29,8 +29,10 @@ import { BucketingError, ERROR_MESSAGES, EntityType, + MESSAGES, RuleError, - SystemEvents + SystemEvents, + VariationChangeType } from '@convertcom/js-sdk-enums'; import {objectDeepMerge, objectNotEmpty} from '@convertcom/js-sdk-utils'; import {SegmentsManagerInterface} from '@convertcom/js-sdk-segments'; @@ -54,6 +56,10 @@ export class Context implements ContextInterface { private _visitorId: string; private _visitorProperties: Record; private _environment: string; + private _contentSecurityPolicyNonce?: string; + // `undefined` = not yet resolved; once resolved (either from config or + // DOM auto-detect) we cache to avoid re-querying the DOM on every change. + private _cspNonceResolved: boolean = false; /** * @param {Config} config @@ -92,6 +98,7 @@ export class Context implements ContextInterface { this._visitorId = visitorId; this._config = config; + this._contentSecurityPolicyNonce = config?.contentSecurityPolicyNonce; this._eventManager = eventManager; this._experienceManager = experienceManager; this._featureManager = featureManager; @@ -139,6 +146,7 @@ export class Context implements ContextInterface { visitorProperties, // represents audiences locationProperties: attributes?.locationProperties, // represents site_area/locations updateVisitorProperties: attributes?.updateVisitorProperties, + experienceTypes: attributes?.experienceTypes, environment: attributes?.environment || this._environment } ); @@ -193,6 +201,7 @@ export class Context implements ContextInterface { visitorProperties, // represents audiences locationProperties: attributes?.locationProperties, // represents site_area/locations updateVisitorProperties: attributes?.updateVisitorProperties, + experienceTypes: attributes?.experienceTypes, environment: attributes?.environment || this._environment } ); @@ -258,6 +267,7 @@ export class Context implements ContextInterface { visitorProperties, locationProperties: attributes?.locationProperties, updateVisitorProperties: attributes?.updateVisitorProperties, + experienceTypes: attributes?.experienceTypes, typeCasting: Object.prototype.hasOwnProperty.call( attributes || {}, 'typeCasting' @@ -338,6 +348,7 @@ export class Context implements ContextInterface { visitorProperties, locationProperties: attributes?.locationProperties, updateVisitorProperties: attributes?.updateVisitorProperties, + experienceTypes: attributes?.experienceTypes, typeCasting: Object.prototype.hasOwnProperty.call( attributes || {}, 'typeCasting' @@ -370,6 +381,303 @@ export class Context implements ContextInterface { return bucketedFeatures as Array; } + /** + * Apply web variation changes (CSS, JS, custom JS) to the DOM. + * + * Execution order, matching the tracking script monolith: + * 1. experience.global_css →