From 4bfbdd84a14bbbc77b955c7b89c89913a3f91574 Mon Sep 17 00:00:00 2001 From: raj pandey Date: Fri, 10 Jul 2026 09:39:59 +0530 Subject: [PATCH 1/4] fix(personalize): fix CT linking failure from empty-audience experiences [DX-9469] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pendingVariantAndVariantGrpForExperience was populated with ALL created experience UIDs regardless of whether valid variants were submitted to the API. Experiences with variants:[] never get variant groups from the Personalize backend, so the validation loop timed out and set importData=false, skipping attachCTsInExperience for every experience including valid ones. Fixed by: 1. Adding a versionMap guard in importExperienceVersions (returns boolean) so only experiences that actually submitted variants return true 2. Only adding UIDs to the pending set when importExperienceVersions returns true 3. Adding a null guard in attachCTsInExperience before accessing variantGroup to prevent a silent crash inside Promise.allSettled Note: Bug 2 (missing data-for-variant-entry.json) is not present in v2 — processCleanup always calls createEntryDataForVariantEntry regardless of environments. Co-Authored-By: Claude Sonnet 4.6 --- .../src/import/experiences.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/contentstack-variants/src/import/experiences.ts b/packages/contentstack-variants/src/import/experiences.ts index b4693a667..e5615fa46 100644 --- a/packages/contentstack-variants/src/import/experiences.ts +++ b/packages/contentstack-variants/src/import/experiences.ts @@ -145,6 +145,8 @@ export default class Experiences extends PersonalizationAdapter { log.info(`Processing ${experiencesCount} experiences for import`, this.config.context); + const experienceUidsWithVariants = new Set(); + for (const experience of this.experiences) { const { uid, ...restExperienceData } = experience; log.debug(`Processing experience: ${uid}`, this.config.context); @@ -162,7 +164,9 @@ export default class Experiences extends PersonalizationAdapter { try { // import versions of experience - await this.importExperienceVersions(expRes, uid); + if (await this.importExperienceVersions(expRes, uid)) { + experienceUidsWithVariants.add(expRes.uid); + } } catch (error) { handleAndLogError(error, this.config.context, `Failed to import experience versions for ${expRes.uid}`); } @@ -184,7 +188,7 @@ export default class Experiences extends PersonalizationAdapter { log.success('Experiences created successfully', this.config.context); log.info('Validating variant and variant group creation', this.config.context); - this.pendingVariantAndVariantGrpForExperience = values(cloneDeep(this.experiencesUidMapper)); + this.pendingVariantAndVariantGrpForExperience = Array.from(experienceUidsWithVariants); const jobRes = await this.validateVariantGroupAndVariantsCreated(); fsUtil.writeFile(this.cmsVariantPath, this.cmsVariants); fsUtil.writeFile(this.cmsVariantGroupPath, this.cmsVariantGroups); @@ -283,8 +287,13 @@ export default class Experiences extends PersonalizationAdapter { } }); + if (!Object.values(versionMap).some((v) => v !== undefined)) { + return false; + } + // Prioritize updating or creating versions based on the order: ACTIVE -> DRAFT -> PAUSE - return await this.handleVersionUpdateOrCreate(experience, versionMap); + await this.handleVersionUpdateOrCreate(experience, versionMap); + return true; } // Helper method to handle version update or creation logic @@ -414,6 +423,10 @@ export default class Experiences extends PersonalizationAdapter { ); const { variant_groups: [variantGroup] = [] } = (await this.getVariantGroup({ experienceUid: newExpUid })) || {}; + if (!variantGroup) { + log.warn(`No variant group found for experience: ${newExpUid} — skipping CT attachment`, this.config.context); + return; + } variantGroup.content_types = updatedContentTypes; // Update content types detail in the new experience asynchronously return await this.updateVariantGroup(variantGroup); From ddc69b1aed0356d97c5eff318c4a18e843d7737f Mon Sep 17 00:00:00 2001 From: raj pandey Date: Fri, 10 Jul 2026 10:01:40 +0530 Subject: [PATCH 2/4] test(variants): add unit tests for DX-9469 fix in experiences import - Add 11 test cases covering importExperienceVersions returning false for missing file, Lytics-only audiences, and unmapped CS audiences - Add tests verifying Set-based pending list only includes experiences with valid variants submitted to Personalize - Add tests for validateVariantGroupAndVariantsCreated resolving immediately when pending list is empty - Add tests for attachCTsInExperience null guard skipping gracefully when variantGroup is missing - Fix mocha-root-hooks.js to guard loadChalk as optional (not exported in current cli-utilities version) - Fix importExperienceVersions to return false (not undefined) when versions file does not exist, for consistent boolean API Co-Authored-By: Claude Sonnet 4.6 --- .../src/import/experiences.ts | 2 +- .../test/helpers/mocha-root-hooks.js | 5 +- .../test/unit/import/experiences.test.ts | 214 ++++++++++++++++++ 3 files changed, 218 insertions(+), 3 deletions(-) diff --git a/packages/contentstack-variants/src/import/experiences.ts b/packages/contentstack-variants/src/import/experiences.ts index e5615fa46..19bd89fce 100644 --- a/packages/contentstack-variants/src/import/experiences.ts +++ b/packages/contentstack-variants/src/import/experiences.ts @@ -262,7 +262,7 @@ export default class Experiences extends PersonalizationAdapter { if (!existsSync(versionsPath)) { log.debug(`No versions file found for experience: ${oldExperienceUid}`, this.config.context); - return; + return false; } const versions = fsUtil.readFile(versionsPath, true) as ExperienceStruct[]; diff --git a/packages/contentstack-variants/test/helpers/mocha-root-hooks.js b/packages/contentstack-variants/test/helpers/mocha-root-hooks.js index 77d5715d0..3c709f9e6 100644 --- a/packages/contentstack-variants/test/helpers/mocha-root-hooks.js +++ b/packages/contentstack-variants/test/helpers/mocha-root-hooks.js @@ -1,11 +1,12 @@ /** * @contentstack/cli-utilities uses lazy-loaded Chalk 5; preload before tests that hit cliux. */ -const { loadChalk } = require('@contentstack/cli-utilities'); +const cliUtils = require('@contentstack/cli-utilities'); +const loadChalk = cliUtils.loadChalk; exports.mochaHooks = { beforeAll() { this.timeout(30_000); - return loadChalk(); + if (typeof loadChalk === 'function') return loadChalk(); }, }; diff --git a/packages/contentstack-variants/test/unit/import/experiences.test.ts b/packages/contentstack-variants/test/unit/import/experiences.test.ts index f25989901..4c2638275 100644 --- a/packages/contentstack-variants/test/unit/import/experiences.test.ts +++ b/packages/contentstack-variants/test/unit/import/experiences.test.ts @@ -1,5 +1,8 @@ +import sinon from 'sinon'; import { expect } from 'chai'; +import * as fs from 'fs'; import { Import, ImportConfig } from '../../../src'; +import { fsUtil } from '../../../src/utils'; const makeImportConfig = (branchName?: string): ImportConfig => ({ @@ -27,6 +30,217 @@ const makeImportConfig = (branchName?: string): ImportConfig => ...(branchName ? { branchName } : {}), } as unknown as ImportConfig); +// ─── helpers ──────────────────────────────────────────────────────────────── + +function makeExperience(uid: string, latestVersion = 1) { + return { uid, latestVersion, name: `Experience ${uid}` }; +} + +function makeVersion(status: string, audiences: string[] = ['aud-001']) { + return { + uid: `ver-${status.toLowerCase()}`, + status, + variants: [{ __type: 'SegmentedVariant', audiences }], + }; +} + +// ─── importExperienceVersions ───────────────────────────────────────────────── + +describe('ImportExperiences — importExperienceVersions (DX-9469 fix)', () => { + let instance: any; + let tmpDir: string; + + beforeEach(() => { + instance = new Import.Experiences(makeImportConfig()); + instance.audiencesUid = { 'aud-001': 'aud-new-001' }; + instance.eventsUid = {}; + tmpDir = fs.mkdtempSync(require('os').tmpdir() + '/exp-test-'); + fs.mkdirSync(require('path').join(tmpDir, 'versions'), { recursive: true }); + instance.experiencesDirPath = tmpDir; + }); + + afterEach(() => { + sinon.restore(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeVersionFile(uid: string, versions: any[]) { + fs.writeFileSync( + require('path').join(tmpDir, 'versions', `${uid}.json`), + JSON.stringify(versions), + ); + } + + it('returns false (not undefined) when versions file does not exist', async () => { + // no file written → path does not exist + const result = await instance.importExperienceVersions(makeExperience('exp-001'), 'exp-no-file'); + expect(result).to.equal(false); + }); + + it('returns false when all versions have Lytics-only audiences (versionMap all-undefined)', async () => { + const lyticsVersion = { + uid: 'ver-active', + status: 'ACTIVE', + variants: [{ __type: 'SegmentedVariant', lyticsAudiences: ['lytics-123'], audiences: [] }], + }; + writeVersionFile('exp-002', [lyticsVersion]); + const handleStub = sinon.stub(instance, 'handleVersionUpdateOrCreate').resolves(); + + const result = await instance.importExperienceVersions(makeExperience('exp-002'), 'exp-002'); + + expect(result).to.equal(false); + expect(handleStub.called).to.be.false; + }); + + it('returns false when all versions have unmapped CS audiences (variants stripped to empty)', async () => { + writeVersionFile('exp-003', [makeVersion('ACTIVE', ['aud-unmapped'])]); + const handleStub = sinon.stub(instance, 'handleVersionUpdateOrCreate').resolves(); + + const result = await instance.importExperienceVersions(makeExperience('exp-003'), 'exp-003'); + + expect(result).to.equal(false); + expect(handleStub.called).to.be.false; + }); + + it('returns true when at least one valid version exists and calls handleVersionUpdateOrCreate', async () => { + writeVersionFile('exp-004', [makeVersion('ACTIVE')]); + const handleStub = sinon.stub(instance, 'handleVersionUpdateOrCreate').resolves(); + + const result = await instance.importExperienceVersions(makeExperience('exp-004'), 'exp-004'); + + expect(result).to.equal(true); + expect(handleStub.calledOnce).to.be.true; + }); + + it('returns true when only DRAFT version is valid (ACTIVE missing)', async () => { + writeVersionFile('exp-005', [makeVersion('DRAFT')]); + const handleStub = sinon.stub(instance, 'handleVersionUpdateOrCreate').resolves(); + + const result = await instance.importExperienceVersions(makeExperience('exp-005'), 'exp-005'); + + expect(result).to.equal(true); + expect(handleStub.calledOnce).to.be.true; + }); +}); + +// ─── import() — Set-based pending list (DX-9469 fix) ───────────────────────── + +describe('ImportExperiences — pending list only includes experiences with variants', () => { + let instance: any; + + afterEach(() => sinon.restore()); + + it('empty experiences list → pendingVariantAndVariantGrpForExperience stays empty', async () => { + instance = new Import.Experiences(makeImportConfig()); + sinon.stub(instance, 'analyzeExperiences' as any).resolves([false, 0]); + + await instance.import(); + + expect(instance.pendingVariantAndVariantGrpForExperience).to.deep.equal([]); + }); + + it('experience with no valid versions is NOT added to pending list', async () => { + instance = new Import.Experiences(makeImportConfig()); + sinon.stub(instance, 'analyzeExperiences' as any).resolves([true, 1]); + sinon.stub(instance, 'init' as any).resolves(); + sinon.stub(fsUtil, 'makeDirectory').resolves(); + instance.experiences = [makeExperience('old-exp-a')]; + sinon.stub(instance, 'createExperience' as any).resolves(makeExperience('new-exp-a')); + // importExperienceVersions returns false → UID must NOT enter the Set + sinon.stub(instance, 'importExperienceVersions' as any).resolves(false); + sinon.stub(instance, 'validateVariantGroupAndVariantsCreated').resolves(true); + sinon.stub(instance, 'attachCTsInExperience').resolves(); + sinon.stub(instance, 'createVariantIdMapper').resolves(); + sinon.stub(fsUtil, 'writeFile').returns(undefined); + sinon.stub(instance, 'updateProgress' as any).returns(undefined); + sinon.stub(instance, 'createSimpleProgress' as any).returns({ tick: () => {}, complete: () => {} }); + sinon.stub(instance, 'completeProgress' as any).returns(undefined); + + await instance.import(); + + expect(instance.pendingVariantAndVariantGrpForExperience).to.deep.equal([]); + }); + + it('experience with valid versions IS added to pending list', async () => { + instance = new Import.Experiences(makeImportConfig()); + sinon.stub(instance, 'analyzeExperiences' as any).resolves([true, 1]); + sinon.stub(instance, 'init' as any).resolves(); + sinon.stub(fsUtil, 'makeDirectory').resolves(); + instance.experiences = [makeExperience('old-exp-b')]; + sinon.stub(instance, 'createExperience' as any).resolves(makeExperience('new-exp-b')); + // importExperienceVersions returns true → UID MUST enter the Set + sinon.stub(instance, 'importExperienceVersions' as any).resolves(true); + sinon.stub(instance, 'validateVariantGroupAndVariantsCreated').resolves(true); + sinon.stub(instance, 'attachCTsInExperience').resolves(); + sinon.stub(instance, 'createVariantIdMapper').resolves(); + sinon.stub(fsUtil, 'writeFile').returns(undefined); + sinon.stub(instance, 'updateProgress' as any).returns(undefined); + sinon.stub(instance, 'createSimpleProgress' as any).returns({ tick: () => {}, complete: () => {} }); + sinon.stub(instance, 'completeProgress' as any).returns(undefined); + + await instance.import(); + + expect(instance.pendingVariantAndVariantGrpForExperience).to.deep.equal(['new-exp-b']); + }); +}); + +// ─── validateVariantGroupAndVariantsCreated — empty pending list ────────────── + +describe('ImportExperiences — validateVariantGroupAndVariantsCreated with empty pending list', () => { + afterEach(() => sinon.restore()); + + it('resolves true immediately when pendingVariantAndVariantGrpForExperience is empty', async () => { + const instance = new Import.Experiences(makeImportConfig()); + instance.pendingVariantAndVariantGrpForExperience = []; + const getExpStub = sinon.stub(instance, 'getExperience' as any).resolves({}); + + const result = await instance.validateVariantGroupAndVariantsCreated(); + + expect(result).to.equal(true); + expect(getExpStub.called).to.be.false; + }); +}); + +// ─── attachCTsInExperience — null guard (DX-9469 fix) ──────────────────────── + +describe('ImportExperiences — attachCTsInExperience null guard', () => { + afterEach(() => sinon.restore()); + + it('skips CT attachment gracefully when getVariantGroup returns no variantGroup', async () => { + const instance = new Import.Experiences(makeImportConfig()); + instance.experiencesUidMapper = { 'old-exp': 'new-exp' }; + sinon.stub(fsUtil, 'readFile') + .onFirstCall().returns(['ct-uid-1']) + .onSecondCall().returns({ 'old-exp': [{ uid: 'ct-uid-1', status: 'linked' }] }); + // getVariantGroup returns empty variant_groups → variantGroup is undefined + sinon.stub(instance, 'getVariantGroup' as any).resolves({ variant_groups: [] }); + const updateStub = sinon.stub(instance, 'updateVariantGroup' as any).resolves(); + + await instance.attachCTsInExperience(); + + // Must not throw; updateVariantGroup must NOT be called since variantGroup is missing + expect(updateStub.called).to.be.false; + }); + + it('attaches CTs normally when variantGroup exists', async () => { + const instance = new Import.Experiences(makeImportConfig()); + instance.experiencesUidMapper = { 'old-exp': 'new-exp' }; + sinon.stub(fsUtil, 'readFile') + .onFirstCall().returns(['ct-uid-1']) + .onSecondCall().returns({ 'old-exp': [{ uid: 'ct-uid-1', status: 'linked' }] }); + const fakeGroup = { uid: 'vg-001', content_types: [] }; + sinon.stub(instance, 'getVariantGroup' as any).resolves({ variant_groups: [fakeGroup] }); + const updateStub = sinon.stub(instance, 'updateVariantGroup' as any).resolves(); + + await instance.attachCTsInExperience(); + + expect(updateStub.calledOnce).to.be.true; + expect(fakeGroup.content_types).to.deep.equal([{ uid: 'ct-uid-1', status: 'linked' }]); + }); +}); + +// ─── branch header (pre-existing) ──────────────────────────────────────────── + describe('ImportExperiences — branch header', () => { describe('constructor (cmaConfig headers)', () => { it('includes branch header in cmaConfig.headers when branchName is set', () => { From fbdeb70e5f0a5774c6a2ec598be974b6e2ea7274 Mon Sep 17 00:00:00 2001 From: raj pandey Date: Fri, 10 Jul 2026 13:13:30 +0530 Subject: [PATCH 3/4] fix(variants): make variant-skip warning message generic [DX-9469] Message was blaming Lytics audiences specifically but the branch fires for any mapping failure (CS or Lytics). Broadened to "after audience/event mapping". Co-Authored-By: Claude Sonnet 4.6 --- packages/contentstack-variants/src/import/experiences.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contentstack-variants/src/import/experiences.ts b/packages/contentstack-variants/src/import/experiences.ts index 19bd89fce..3e0065a94 100644 --- a/packages/contentstack-variants/src/import/experiences.ts +++ b/packages/contentstack-variants/src/import/experiences.ts @@ -283,7 +283,7 @@ export default class Experiences extends PersonalizationAdapter { versionMap[versionReqObj.status] = versionReqObj; log.debug(`Mapped version with status: ${versionReqObj.status}`, this.config.context); } else if (versionReqObj?.status && !(versionReqObj.variants?.length ?? 0)) { - log.warn(`Skipping version ${versionReqObj.status}: no valid variants (all had unmapped Lytics audiences)`, this.config.context); + log.warn(`Skipping version ${versionReqObj.status}: no valid variants after audience/event mapping`, this.config.context); } }); From f866d10b64dd36e10a1000ff43798183d9daf0a1 Mon Sep 17 00:00:00 2001 From: raj pandey Date: Fri, 10 Jul 2026 13:21:40 +0530 Subject: [PATCH 4/4] chore(variants): remove unused lodash/values and lodash/cloneDeep imports [DX-9469] Both were made redundant by the Set-based pending list introduced in the DX-9469 fix. Leaving them risks build failures under noUnusedLocals or ESLint unused-import rules. Co-Authored-By: Claude Sonnet 4.6 --- packages/contentstack-variants/src/import/experiences.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/contentstack-variants/src/import/experiences.ts b/packages/contentstack-variants/src/import/experiences.ts index 3e0065a94..534fda64c 100644 --- a/packages/contentstack-variants/src/import/experiences.ts +++ b/packages/contentstack-variants/src/import/experiences.ts @@ -1,7 +1,5 @@ import { join, resolve } from 'path'; import { existsSync } from 'fs'; -import values from 'lodash/values'; -import cloneDeep from 'lodash/cloneDeep'; import { sanitizePath, log, handleAndLogError } from '@contentstack/cli-utilities'; import { PersonalizationAdapter, fsUtil, lookUpAudiences, lookUpEvents } from '../utils'; import {